diff --git a/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx b/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx index d701568f49..16d1356e18 100644 --- a/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx +++ b/client/src/components/SidePanel/Schedules/ScheduleDialog.tsx @@ -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" diff --git a/packages/client/src/components/ControlCombobox.tsx b/packages/client/src/components/ControlCombobox.tsx index 2a7e1ca9ea..05fc1a0bde 100644 --- a/packages/client/src/components/ControlCombobox.tsx +++ b/packages/client/src/components/ControlCombobox.tsx @@ -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 ( -
+
{ariaLabel} @@ -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, )} > diff --git a/packages/client/src/components/Dropdown.tsx b/packages/client/src/components/Dropdown.tsx index c18ddb9bd3..c4ba9a1ae9 100644 --- a/packages/client/src/components/Dropdown.tsx +++ b/packages/client/src/components/Dropdown.tsx @@ -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 = ({ ariaLabel, 'aria-labelledby': ariaLabelledBy, portal = true, + variant = 'default', portalElement, disabled = false, searchable = false, @@ -140,7 +144,7 @@ const Dropdown: React.FC = ({ ); return ( -
+
= ({ '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} diff --git a/packages/client/src/components/Field.ts b/packages/client/src/components/Field.ts new file mode 100644 index 0000000000..5c09d95a6b --- /dev/null +++ b/packages/client/src/components/Field.ts @@ -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`; diff --git a/packages/client/src/components/Input.tsx b/packages/client/src/components/Input.tsx index a8eae5c54f..0db5998670 100644 --- a/packages/client/src/components/Input.tsx +++ b/packages/client/src/components/Input.tsx @@ -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(({ className, ...props }, ref) => { return ( diff --git a/packages/client/src/components/Textarea.tsx b/packages/client/src/components/Textarea.tsx index 8c9a2ee1cc..19259faa18 100644 --- a/packages/client/src/components/Textarea.tsx +++ b/packages/client/src/components/Textarea.tsx @@ -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(({ className = '', ...props }, ref) => { return (