Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
827c863
feat(shared): redesign fields to align with Toggle + Button system
tsahimatsliah May 31, 2026
eb8e1c6
feat(shared): field variants, faint border, password gradient & dropd…
tsahimatsliah May 31, 2026
e3b73ba
fix(shared): consistent red error border + full field state coverage
tsahimatsliah May 31, 2026
9410617
docs(storybook): single shareable before/after page for every field
tsahimatsliah May 31, 2026
22f97fb
fix(shared): make resting fields read as active, not disabled
tsahimatsliah May 31, 2026
33514a1
fix(shared): polish field icon sizing, spacing & adornment colors
tsahimatsliah May 31, 2026
629e874
fix: tighten dropdown chevron size and padding
tsahimatsliah May 31, 2026
2e04a8f
fix: shrink dropdown chevron to 16px
tsahimatsliah May 31, 2026
10408e7
fix: add hover state to search field
tsahimatsliah May 31, 2026
e9b02d4
fix: lighten field resting border to match Button v2 Float hairline
tsahimatsliah May 31, 2026
efc2820
fix: equalize textarea padding when there's no inner label
tsahimatsliah May 31, 2026
3b30ebb
fix: align field left-icon padding with Button v2 icon-side rule
tsahimatsliah May 31, 2026
12821f3
refactor: source field corner radius from the shared button-aligned s…
tsahimatsliah May 31, 2026
ef00059
revert: restore previous field left-icon padding
tsahimatsliah May 31, 2026
d8fe368
fix: address fields v2 review — pristine error flash, dead tokens, pw…
tsahimatsliah May 31, 2026
7db1dfd
Merge branch 'main' into feat/fields-v2-redesign
tsahimatsliah Jun 1, 2026
0d709d2
Merge branch 'main' into feat/fields-v2-redesign
tsahimatsliah Jun 1, 2026
0b0536f
Merge branch 'main' into feat/fields-v2-redesign
tsahimatsliah Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/shared/src/components/dropdown/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
.DropdownMenuSubContent {
user-select: none;
z-index: 1000;
@apply py-1 px-0 m-0 bg-background-subtle rounded-12 border border-border-subtlest-secondary shadow-2;
@apply p-1.5 m-0 bg-background-subtle rounded-14 border border-border-subtlest-secondary shadow-2;
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
Expand Down Expand Up @@ -71,7 +71,15 @@
.DropdownMenuSubTrigger {
border-radius: 0.625rem;
@apply text-text-tertiary;
@apply flex items-center typo-footnote h-7 px-2 py-0 truncate;
@apply flex items-center typo-footnote h-8 px-2.5 py-0 truncate;
transition: background-color 0.12s linear, color 0.12s linear;
}

.DropdownMenuItem[data-highlighted],
.DropdownMenuCheckboxItem[data-highlighted],
.DropdownMenuRadioItem[data-highlighted],
.DropdownMenuSubTrigger[data-highlighted] {
@apply text-text-primary;
}

.DropdownMenuItem[data-disabled],
Expand Down
27 changes: 21 additions & 6 deletions packages/shared/src/components/fields/BaseFieldContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import classNames from 'classnames';
import type { ReactElement, ReactNode, MutableRefObject } from 'react';
import type { ReactElement, ReactNode, ForwardedRef } from 'react';
import React, { forwardRef } from 'react';
import type { FieldType, TextInputProps } from './common';
import { BaseField } from './common';
import type { IconProps } from '../Icon';
import { FieldSize, fieldSizeToRadius } from './fieldSizes';

interface FieldStateProps {
readOnly?: boolean;
Expand Down Expand Up @@ -73,7 +74,10 @@ export const getFieldFontColor = ({
return 'text-text-quaternary';
}

return 'text-text-tertiary hover:text-text-primary';
// Resting (empty, editable) fields read as active — secondary content on the
// floated surface, brightening to primary on hover. Tertiary here made an
// empty field look indistinguishable from the dimmed disabled state.
return 'text-text-secondary hover:text-text-primary';
};

interface InnerLabelProps extends FieldStateProps {
Expand All @@ -91,6 +95,7 @@ interface BaseFieldContainerProps extends FieldPlaceholderProps {
className?: FieldClassName;
inputId: string;
fieldType?: FieldType;
fieldSize?: FieldSize;
hint?: string;
hintIcon?: ReactElement<IconProps>;
saveHintSpace?: boolean;
Expand All @@ -112,11 +117,11 @@ export const getFieldPlaceholder = ({
label,
}: FieldPlaceholderProps): string => {
if (isQuaternaryField) {
return placeholder;
return placeholder ?? '';
}

if (isTertiaryField) {
return focused ? placeholder : label;
return (focused ? placeholder : label) ?? '';
}

if (focused || isSecondaryField) {
Expand Down Expand Up @@ -150,6 +155,7 @@ function BaseFieldContainer(
{
className = {},
fieldType = 'primary',
fieldSize,
readOnly,
isLocked,
hasInput,
Expand All @@ -164,9 +170,17 @@ function BaseFieldContainer(
saveHintSpace,
focusInput,
}: BaseFieldContainerProps,
ref?: MutableRefObject<HTMLDivElement>,
ref: ForwardedRef<HTMLDivElement>,
): ReactElement {
const isSecondaryField = fieldType === 'secondary';
// Radius always comes from the shared button-aligned scale, so a field's
// corner radius matches a button of the same size. The default (no explicit
// `fieldSize`) maps the compact secondary field to Small and every other
// field to Large — the same rung the default heights resolve to.
const radiusClass =
fieldSizeToRadius[
fieldSize ?? (isSecondaryField ? FieldSize.Small : FieldSize.Large)
];

return (
<div ref={ref} className={classNames('flex flex-col', className.container)}>
Expand All @@ -193,8 +207,9 @@ function BaseFieldContainer(
onClick={focusInput}
className={classNames(
'relative flex',
isSecondaryField ? 'rounded-10' : 'rounded-14',
radiusClass,
className.baseField,
disabled && 'pointer-events-none opacity-32',
{ readOnly, focused, invalid },
)}
>
Expand Down
11 changes: 8 additions & 3 deletions packages/shared/src/components/fields/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { RootPortal } from '../tooltips/Portal';
import type { DrawerProps } from '../drawers';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import type { IconProps } from '../Icon';
import { IconSize } from '../Icon';
import { Loader } from '../Loader';

export interface DropdownClassName {
Expand Down Expand Up @@ -142,7 +143,10 @@ export function Dropdown({
size={buttonSize}
disabled={disabled}
className={classNames(
'group flex w-full items-center px-3 font-normal text-text-tertiary typo-body hover:bg-surface-hover hover:text-text-primary',
// `!pl-4 !pr-2.5` overrides the Button's built-in Large padding (px-6)
// so the value lines up with the other fields' 16px text inset and the
// chevron sits tight to the right edge instead of floating 24px in.
'group flex w-full items-center !pl-4 !pr-2.5 font-normal text-text-secondary typo-body hover:bg-surface-hover hover:text-text-primary',
className?.button,
iconOnly && 'items-center justify-center',
)}
Expand Down Expand Up @@ -172,13 +176,14 @@ export function Dropdown({
{iconOnly ? null : (
<>
<span
className={classNames('mr-1 flex flex-1 truncate', className.label)}
className={classNames('mr-2 flex flex-1 truncate', className.label)}
>
{selectedIndex >= 0 ? options[selectedIndex] : placeholder}
</span>
<ArrowIcon
size={IconSize.Size16}
className={classNames(
'ml-auto text-xl transition-transform group-hover:text-text-tertiary',
'ml-auto shrink-0 text-text-quaternary transition-transform group-hover:text-text-primary',
isVisible ? 'rotate-0' : 'rotate-180',
styles.chevron,
className.chevron,
Expand Down
39 changes: 29 additions & 10 deletions packages/shared/src/components/fields/SearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type { ButtonProps } from '../buttons/Button';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { getFieldFontColor } from './BaseFieldContainer';
import type { IconProps } from '../Icon';
import { IconSize } from '../Icon';
import { FieldSize, fieldSizeToRadius } from './fieldSizes';

export interface SearchFieldProps
extends Pick<
Expand Down Expand Up @@ -88,25 +90,44 @@ export const SearchField = forwardRef(function SearchField(
onInput,
focusInput,
setInput,
} = useInputField(value, valueChanged);
} = useInputField(value as string | number | readonly string[], valueChanged);

const onClearClick = (event: MouseEvent): void => {
event.stopPropagation();
setInput(null);
setInput('');
};

const isPrimary = fieldType === 'primary';
const isSecondary = fieldType === 'secondary';
const sizeClass =
fieldSize === 'medium' ? 'h-10 rounded-12' : 'h-12 rounded-14';
const isMedium = fieldSize === 'medium';
const resolvedFieldSize = isMedium ? FieldSize.Medium : FieldSize.Large;
// Height + radius both come from the shared button-aligned scale so a search
// field lines up with a button (and every other field) of the same size.
const sizeClass = classNames(
isMedium ? 'h-10' : 'h-12',
fieldSizeToRadius[resolvedFieldSize],
);
// Mirror the TextField icon/gap scale so a search field lines up with the
// other fields and a button of the same height.
const searchIconSize = isMedium ? IconSize.Small : IconSize.Medium;
const gapClass = isMedium ? 'gap-1' : 'gap-1.5';

return (
<BaseField
{...props}
className={classNames(
'items-center !border !border-border-subtlest-tertiary !bg-background-default',
// Border width + background only — the resting border *color* is the Float
// hairline from `.field` so the search field matches every other field.
'items-center !border !bg-background-default',
// The base `.field:hover` background is blocked by `!bg-background-default`,
// so the search field needs its own hover feedback. Brighten the border and
// tint the surface, scoped to `:not(.focused)` so it never overrides the
// focus ring while the field is active.
'[&:hover:not(.focused)]:!border-border-subtlest-secondary [&:hover:not(.focused)]:!bg-surface-hover',
gapClass,
sizeClass,
className,
disabled && 'pointer-events-none opacity-32',
{ focused },
)}
onClick={focusInput}
Expand All @@ -117,21 +138,19 @@ export const SearchField = forwardRef(function SearchField(
(isSecondary && hasInput ? (
<Button
aria-label="Clear input text"
className="mr-2"
size={ButtonSize.XSmall}
variant={ButtonVariant.Tertiary}
title="Clear query"
onClick={onClearClick}
icon={
<CloseIcon className="icon text-lg group-hover:text-text-primary" />
}
icon={<CloseIcon className="icon group-hover:text-text-primary" />}
disabled={!hasInput}
/>
) : (
<SearchIcon
aria-hidden
className="icon mr-2 text-2xl"
className="icon"
role="presentation"
size={searchIconSize}
secondary={focused}
style={{
color:
Expand Down
120 changes: 72 additions & 48 deletions packages/shared/src/components/fields/TextField.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,67 +4,91 @@
}
}

/*
* Fields v2: focus / invalid / readonly all use the same 1px border mechanism
* so the states read as one family (no mix of border + ring). Focus paints the
* border text-primary (see fields.module.css); the rules below override the
* border colour for the read-only "ready" and invalid states. Password strength
* keeps the previous left-edge indicator bar + colour-graded fill (a progress
* cue rather than a validity cue).
*/
.field {
&:hover {
box-shadow: inset 0.125rem 0 0 0 var(--theme-text-primary);
}

/* Read-only "ready" affordance: a blue border while focused. */
&:global(.focused.readOnly) {
box-shadow: inset 0.125rem 0 0 0 var(--theme-accent-blueCheese-default);
border-color: var(--theme-accent-blueCheese-default);
}

&:global(.focused) {
box-shadow: inset 0.125rem 0 0 0 var(--theme-text-primary);
border-color: var(--theme-text-primary);
/*
* Invalid: a solid red border. Same width/mechanism as the focus border, and
* it stays red while focused/typing so the error remains visible until fixed.
*/
&:global(.invalid),
&:global(.focused.invalid) {
border-color: var(--status-error);
}

&:global(.invalid) {
&:global(.password-0),
&:global(.password-1) {
box-shadow: inset 0.125rem 0 0 0 var(--status-error);
}

&:global(.password-0), &:global(.password-1) {
box-shadow: inset 0.125rem 0 0 0 var(--status-error);
&:before {
content: '';
border-radius: 14px 0px 0px 14px;
opacity: 0.24;
background: linear-gradient(270deg, rgba(252, 83, 141, 0) 0%, rgba(252, 83, 141, 1) 100%);
width: 44px;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
/*
* The strength bar inherits the field's rounded left corners via the
* parent's `overflow: hidden` clip, so it matches whatever radius the
* field size resolves to instead of a hardcoded value.
*/
&:before {
content: '';
opacity: 0.24;
background: linear-gradient(
270deg,
rgba(252, 83, 141, 0) 0%,
rgba(252, 83, 141, 1) 100%
);
width: 2.75rem;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
}

&:global(.password-2) {
box-shadow: inset 0.125rem 0 0 0 var(--status-warning);
box-shadow: inset 0.125rem 0 0 0 var(--status-warning);

&:before {
content: '';
border-radius: 14px 0px 0px 14px;
opacity: 0.24;
background: linear-gradient(270deg, rgba(255, 142, 59, 0) 0%, rgba(255, 142, 59, 1) 100%);
width: 44px;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
}
content: '';
opacity: 0.24;
background: linear-gradient(
270deg,
rgba(255, 142, 59, 0) 0%,
rgba(255, 142, 59, 1) 100%
);
width: 2.75rem;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
}

&:global(.password-3) {
box-shadow: inset 0.125rem 0 0 0 var(--status-success);
&:before {
content: '';
border-radius: 14px 0px 0px 14px;
opacity: 0.24;
background: linear-gradient(270deg, rgba(57, 229, 140, 0) 0%, rgba(107, 244, 192, 1) 100%);
width: 44px;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
}
box-shadow: inset 0.125rem 0 0 0 var(--status-success);

&:before {
content: '';
opacity: 0.24;
background: linear-gradient(
270deg,
rgba(57, 229, 140, 0) 0%,
rgba(107, 244, 192, 1) 100%
);
width: 2.75rem;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
}
}

.field input[type='number']::-webkit-inner-spin-button,
Expand Down
Loading
Loading