mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 18:13:27 +00:00
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:
parent
fa13dc7ba0
commit
f3f4cd1823
8 changed files with 55 additions and 27 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
11
packages/client/src/components/Field.ts
Normal file
11
packages/client/src/components/Field.ts
Normal 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`;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue