refactor: own the form-control appearance in the client primitives

Addresses the codex finding on ScheduleDialog: a feature-local `FIELD_CLASS`
restated the `Input` primitive's border, radius, height and background so it
could be pasted onto the schedule dropdowns, leaving those controls with no
connection to the primitive they were imitating.

Move that appearance into `packages/client/src/components/Field.ts` as the
single source `Input` and `Textarea` now compose, and give `Dropdown` and
`ControlCombobox` a `variant="field"` that applies it. The schedule dialog
passes the variant and carries no class strings of its own.

This also repairs a break the dev merge would otherwise have introduced: the
newer `Dropdown` splits `className` (wrapper) from `triggerClassName`, so the
old pasted classes would have landed on the wrapper and left the triggers
unstyled.

The semantic-token guard now watches the shared module and asserts each
primitive still composes it, which covers more than the two files it read
before.
This commit is contained in:
Danny Avila 2026-08-18 08:23:57 -04:00
parent fa13dc7ba0
commit f3f4cd1823
8 changed files with 55 additions and 27 deletions

View file

@ -63,9 +63,6 @@ const BASE_MINUTES = [0, 15, 30, 45];
const FORM_ID = 'schedule-form';
/** Matches the Input primitive so every control in the form reads as one field set. */
const FIELD_CLASS = 'h-10 w-full rounded-lg border border-border-light bg-transparent';
const getDefaultValues = (schedule?: TSchedule): ScheduleFormValues => {
if (!schedule) {
return {
@ -390,11 +387,7 @@ export default function ScheduleDialog({
// trap fighting Ariakit for focus locks the page up.
portal={false}
matchTriggerWidth={true}
containerClassName="w-full px-0"
className={cn(
FIELD_CLASS,
'justify-start px-3 text-sm hover:bg-surface-hover',
)}
variant="field"
/>
)}
/>
@ -475,7 +468,7 @@ export default function ScheduleDialog({
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={dayOptions}
className={FIELD_CLASS}
variant="field"
portal={false}
aria-labelledby="schedule-day-label"
testId="schedule-day-select"
@ -509,7 +502,7 @@ export default function ScheduleDialog({
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={hourOptions}
className={FIELD_CLASS}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_hour')}
testId="schedule-hour-select"
@ -525,7 +518,7 @@ export default function ScheduleDialog({
value={String(field.value)}
onChange={(value) => field.onChange(Number(value))}
options={minuteOptions}
className={FIELD_CLASS}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_minute')}
testId="schedule-minute-select"
@ -541,7 +534,7 @@ export default function ScheduleDialog({
value={field.value}
onChange={field.onChange}
options={meridiemOptions}
className={FIELD_CLASS}
variant="field"
portal={false}
ariaLabel={localize('com_ui_schedule_meridiem')}
testId="schedule-meridiem-select"

View file

@ -5,6 +5,7 @@ import { Search, ChevronDown } from 'lucide-react';
import { SelectRenderer } from '@ariakit/react-components/select/select-renderer';
import type { OptionWithIcon } from '~/common';
import { usePopoverZIndex } from './OriginalDialog';
import { fieldControl } from './Field';
import './AnimatePopover.css';
import { JSX } from 'react/jsx-runtime';
import { cn } from '~/utils';
@ -32,6 +33,8 @@ interface ControlComboboxProps {
placement?: Ariakit.SelectStoreProps['placement'];
popoverClassName?: string;
matchTriggerWidth?: boolean;
/** `field` matches the `Input` primitive so this can sit in a form row. */
variant?: 'default' | 'field';
gutter?: number;
/**
* Radix dialogs trap focus, so a portaled popover rendered outside the dialog
@ -67,6 +70,7 @@ function ControlCombobox({
placement,
popoverClassName,
matchTriggerWidth = true,
variant = 'default',
gutter = 4,
portal = true,
}: ControlComboboxProps): JSX.Element {
@ -142,7 +146,13 @@ function ControlCombobox({
);
return (
<div className={cn('flex w-full items-center justify-center px-1', containerClassName)}>
<div
className={cn(
'flex w-full items-center justify-center px-1',
variant === 'field' && 'px-0',
containerClassName,
)}
>
<Ariakit.SelectLabel store={select} className="sr-only">
{ariaLabel}
</Ariakit.SelectLabel>
@ -159,6 +169,7 @@ function ControlCombobox({
'text-text-primary hover:bg-surface-tertiary',
'border border-border-light',
isCollapsed ? 'h-9 w-9' : 'h-9 w-full rounded-xl px-3 py-2 text-sm',
variant === 'field' && cn(fieldControl, 'justify-start hover:bg-surface-hover'),
className,
)}
>

View file

@ -4,6 +4,7 @@ import { matchSorter } from 'match-sorter';
import * as Select from '@ariakit/react/select';
import * as Combobox from '@ariakit/react/combobox';
import type { Option } from '~/common';
import { fieldControl } from './Field';
import { cn } from '~/utils/';
import './Dropdown.css';
@ -25,6 +26,8 @@ interface DropdownProps {
ariaLabel?: string;
'aria-labelledby'?: string;
portal?: boolean;
/** `field` matches the `Input` primitive so this can sit in a form row. */
variant?: 'default' | 'field';
/** Renders the popover into this element instead of document.body */
portalElement?: ((element: HTMLElement) => HTMLElement | null) | HTMLElement | null;
disabled?: boolean;
@ -57,6 +60,7 @@ const Dropdown: React.FC<DropdownProps> = ({
ariaLabel,
'aria-labelledby': ariaLabelledBy,
portal = true,
variant = 'default',
portalElement,
disabled = false,
searchable = false,
@ -140,7 +144,7 @@ const Dropdown: React.FC<DropdownProps> = ({
);
return (
<div className={cn('relative', className)}>
<div className={cn('relative', variant === 'field' && 'w-full', className)}>
<Select.Select
store={selectProps}
disabled={disabled}
@ -149,6 +153,7 @@ const Dropdown: React.FC<DropdownProps> = ({
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-text-primary',
/** Horizontal padding would squeeze the icon, which flex-shrinks to fit */
iconOnly ? 'size-10 justify-center px-0' : 'w-fit gap-2 px-3',
variant === 'field' && fieldControl,
triggerClassName,
)}
data-testid={testId}

View file

@ -0,0 +1,11 @@
/**
* The shared appearance of a form control — border, radius, type scale and focus
* treatment. Owned here so `Input`, `Textarea`, and the select/combobox triggers
* that have to sit beside them in a form cannot drift apart as the theme evolves.
* Callers compose a variant rather than restating these classes locally.
*/
export const fieldBase: string =
'lc-field flex w-full rounded-lg border border-border-light px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus-visible:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary disabled:cursor-not-allowed disabled:opacity-50';
/** A single-line control sized to sit in a form row, matching `Input`. */
export const fieldControl: string = `${fieldBase} h-10 bg-transparent`;

View file

@ -1,4 +1,5 @@
import * as React from 'react';
import { fieldControl } from './Field';
import { cn } from '~/utils';
import './Field.css';
@ -8,10 +9,7 @@ const Input: React.ForwardRefExoticComponent<InputProps & React.RefAttributes<HT
React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
return (
<input
className={cn(
'lc-field flex h-10 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary ring-offset-surface-primary placeholder:text-text-secondary focus-visible:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary disabled:cursor-not-allowed disabled:opacity-50',
className ?? '',
)}
className={cn(fieldControl, 'ring-offset-surface-primary', className ?? '')}
ref={ref}
{...props}
/>

View file

@ -1,6 +1,7 @@
/* eslint-disable */
import * as React from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { fieldBase } from './Field';
import { cn } from '~/utils';
import './Field.css';
@ -11,10 +12,7 @@ const Textarea: React.ForwardRefExoticComponent<
> = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className = '', ...props }, ref) => {
return (
<textarea
className={cn(
'lc-field flex min-h-20 w-full resize-none rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus-visible:border-border-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
className={cn(fieldBase, 'min-h-20 resize-none bg-surface-secondary', className)}
ref={ref}
{...props}
/>

View file

@ -9,6 +9,7 @@ export * from './Checkbox';
export * from './Dialog';
export * from './DropdownMenu';
export * from './HoverCard';
export * from './Field';
export * from './Input';
export * from './InputNumber';
export * from './SecretInput';

View file

@ -112,12 +112,23 @@ describe('light brand text', () => {
describe('shared field and dropdown interaction styles', () => {
it('keeps pointer focus stable and keyboard focus visible on text fields', () => {
['Input.tsx', 'Textarea.tsx'].forEach((component) => {
const source = readFileSync(join(__dirname, '..', 'components', component), 'utf8');
/** The focus treatment lives in the shared field module, so guard it there and
* assert the primitives still compose it rather than restating the classes. */
const field = readFileSync(join(__dirname, '..', 'components', 'Field.ts'), 'utf8');
expect(source).toMatch(/focus-visible:border-border-medium/);
expect(source).toMatch(/focus-visible:ring-2/);
expect(source).toMatch(/focus-visible:ring-text-primary/);
expect(field).toMatch(/focus-visible:border-border-medium/);
expect(field).toMatch(/focus-visible:ring-2/);
expect(field).toMatch(/focus-visible:ring-text-primary/);
const composers: Array<[string, RegExp]> = [
['Input.tsx', /\bfieldControl\b/],
['Textarea.tsx', /\bfieldBase\b/],
['Dropdown.tsx', /\bfieldControl\b/],
['ControlCombobox.tsx', /\bfieldControl\b/],
];
composers.forEach(([component, token]) => {
const source = readFileSync(join(__dirname, '..', 'components', component), 'utf8');
expect(source).toMatch(token);
});
const secretInput = readFileSync(