From 63bf7972651d6a9ee02841b8dcfa67fa24940043 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Thu, 23 Jul 2026 16:58:49 +1000 Subject: [PATCH] Rework multi-part recipes to a slotted recipe() architecture (#223) * Rework multi-part recipes to a slotted recipe() architecture Replace the flat multi-part recipe exports with a HeroUI/Tailwind-Variants-style recipe() helper (issue #215). field and textInput from @luke-ui/react/recipes are now slotted recipes: select variants at the outer call, then read each slot, e.g. field({ necessityIndicator, tone }).label(className) and textInput({ size }).group(). Slot functions take only an optional extra class. combobox migrates to one internal slotted recipe with a shared size group. Types are inferred from each config; no Vanilla Extract implementation types (RuntimeFn/StyleRule) leak into the public .d.ts. recipe() runs at build time in a .css.ts, decomposing a slotted config into one recipeInLayer-equivalent recipe per slot in declaration order, so the emitted CSS and class names are byte-identical to the previous hand-written recipes. The returned callable is registered with Vanilla Extract's function serializer so it survives the .css.ts build boundary. Single-base extend inheritance is supported. The composed ComboboxField and TextField components are unchanged: they keep reusing the Field primitive internally and gain the migrated recipes through the primitives they render. Pre-1.0 breaking change to @luke-ui/react/recipes. Removed with no aliases: fieldLabel, fieldMessage, textInputGroup, textInputControl, textInputAdornmentStart, textInputAdornmentEnd. The *Variants types are kept. * Delete changesets * Simplify props * Improve types and comments --- .changeset/fix-tray-keyboard-inset.md | 5 - .changeset/spectrum-combobox-tray.md | 5 - .changeset/tactile-action-controls.md | 7 - .../combobox-field/primitive/clear-button.tsx | 3 +- .../src/combobox-field/primitive/control.tsx | 3 +- .../combobox-field/primitive/empty-state.tsx | 3 +- .../src/combobox-field/primitive/input.tsx | 3 +- .../src/combobox-field/primitive/item.tsx | 7 +- .../src/combobox-field/primitive/listbox.tsx | 3 +- .../src/combobox-field/primitive/popover.tsx | 2 +- .../src/combobox-field/primitive/root.tsx | 7 +- .../src/combobox-field/primitive/section.tsx | 5 +- .../src/combobox-field/primitive/trigger.tsx | 3 +- .../react/src/field/primitive/description.tsx | 9 +- .../react/src/field/primitive/error.tsx | 11 +- .../react/src/field/primitive/label.tsx | 7 +- .../react/src/field/primitive/root.tsx | 3 +- .../src/recipes/combobox.browser.test.ts | 30 +- .../react/src/recipes/combobox.css.ts | 730 +++++++++--------- .../@luke-ui/react/src/recipes/field.css.ts | 125 +-- packages/@luke-ui/react/src/recipes/index.ts | 10 +- .../react/src/recipes/recipe.browser.test.ts | 61 ++ packages/@luke-ui/react/src/recipes/recipe.ts | 457 +++++++++++ .../src/recipes/text-input.browser.test.ts | 8 +- .../react/src/recipes/text-input.css.ts | 384 +++++---- .../react/src/text-field/primitive/index.tsx | 21 +- 26 files changed, 1170 insertions(+), 742 deletions(-) delete mode 100644 .changeset/fix-tray-keyboard-inset.md delete mode 100644 .changeset/spectrum-combobox-tray.md delete mode 100644 .changeset/tactile-action-controls.md create mode 100644 packages/@luke-ui/react/src/recipes/recipe.browser.test.ts create mode 100644 packages/@luke-ui/react/src/recipes/recipe.ts diff --git a/.changeset/fix-tray-keyboard-inset.md b/.changeset/fix-tray-keyboard-inset.md deleted file mode 100644 index ed4344d0..00000000 --- a/.changeset/fix-tray-keyboard-inset.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@luke-ui/react': patch ---- - -Fix the mobile tray sitting behind the iOS on-screen keyboard. diff --git a/.changeset/spectrum-combobox-tray.md b/.changeset/spectrum-combobox-tray.md deleted file mode 100644 index e3cba3cb..00000000 --- a/.changeset/spectrum-combobox-tray.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@luke-ui/react': minor ---- - -Add a Spectrum-style bottom tray for the combobox popover on small viewports. diff --git a/.changeset/tactile-action-controls.md b/.changeset/tactile-action-controls.md deleted file mode 100644 index 86e443d1..00000000 --- a/.changeset/tactile-action-controls.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@luke-ui/react': minor ---- - -Replace the Button and IconButton appearance API with semantic `tone` and `appearance` options, -defaulting to neutral and solid, and expose the theme foundation contract used to author their depth -and face finish. diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/clear-button.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/clear-button.tsx index 607390f8..cfcc608e 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/clear-button.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/clear-button.tsx @@ -8,7 +8,6 @@ import * as styles from '../../recipes/combobox.css.js'; import { COMBOBOX_ICON_SIZE } from '../../sizing/combobox-sizing.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; import type { ComboboxSize } from './root.js'; import { useComboboxSize } from './size-context.js'; @@ -41,7 +40,7 @@ export function ComboboxClearButton(props: ComboboxClearButtonProps): JSX.Elemen { - return cx(styles.comboboxClearButton({ size }), className); + return styles.combobox({ size }).clearButton(className); })} onPress={(event) => { state.setValue(Array.isArray(state.value) ? [] : null); diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/control.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/control.tsx index 8c2ebb0c..c64d55ad 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/control.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/control.tsx @@ -5,7 +5,6 @@ import { composeRenderProps } from 'react-aria-components/composeRenderProps'; import * as styles from '../../recipes/combobox.css.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; import type { ComboboxSize } from './root.js'; import { useComboboxSize } from './size-context.js'; @@ -31,7 +30,7 @@ export function ComboboxControl(props: ComboboxControlProps): JSX.Element { { - return cx(styles.comboboxControl({ size }), className); + return styles.combobox({ size }).control(className); })} /> ); diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/empty-state.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/empty-state.tsx index 042689e9..e6d2af35 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/empty-state.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/empty-state.tsx @@ -1,7 +1,6 @@ import type { JSX, ReactNode } from 'react'; import * as styles from '../../recipes/combobox.css.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; interface _ComboboxEmptyStateProps { children: ReactNode; @@ -18,5 +17,5 @@ export type ComboboxEmptyStateProps = Prettify<_ComboboxEmptyStateProps>; export function ComboboxEmptyState(props: ComboboxEmptyStateProps): JSX.Element { const { children, className } = props; - return
{children}
; + return
{children}
; } diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/input.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/input.tsx index 338830cd..a8c0f123 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/input.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/input.tsx @@ -6,7 +6,6 @@ import { composeRenderProps } from 'react-aria-components/composeRenderProps'; import * as styles from '../../recipes/combobox.css.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; import type { ComboboxSize } from './root.js'; import { useComboboxSize } from './size-context.js'; @@ -40,7 +39,7 @@ export function ComboboxTextInput(props: ComboboxTextInputProps): JSX.Element { { - return cx(styles.comboboxTextInput({ size }), className); + return styles.combobox({ size }).textInput(className); })} onClick={handleClick} /> diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/item.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/item.tsx index 4f69aa1b..dffe7040 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/item.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/item.tsx @@ -14,7 +14,6 @@ import * as styles from '../../recipes/combobox.css.js'; import { COMBOBOX_ICON_SIZE } from '../../sizing/combobox-sizing.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; import type { ComboboxSize } from './root.js'; import { useComboboxSize } from './size-context.js'; @@ -44,7 +43,7 @@ export function ComboboxItem(props: ComboboxItemProps): JSX textValue={typeof itemProps.children === 'string' ? itemProps.children : undefined} {...itemProps} className={composeRenderProps(itemProps.className, (className) => { - return cx(styles.comboboxItem({ size }), className); + return styles.combobox({ size }).item(className); })} > {composeRenderProps(itemProps.children, (children, { isSelected }) => { @@ -52,7 +51,7 @@ export function ComboboxItem(props: ComboboxItemProps): JSX <> {children} {isSelected ? ( - + ) : null} ); @@ -82,7 +81,7 @@ export function ComboboxLoadMoreItem(props: ComboboxLoadMoreItemProps): JSX.Elem return ( ); } diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/listbox.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/listbox.tsx index 98ea63c6..be6e478f 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/listbox.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/listbox.tsx @@ -8,7 +8,6 @@ import { useSlottedContext } from 'react-aria-components/slots'; import * as styles from '../../recipes/combobox.css.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; type _ComboboxListBoxOmit = DistributiveOmit< RacListBoxProps, @@ -51,7 +50,7 @@ export function ComboboxListBox(props: ComboboxListBoxProps { - return cx(styles.comboboxListBox(), className); + return styles.combobox().listBox(className); })} > {listBoxChildren} diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx index 5b15cc85..32bc7765 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx @@ -37,7 +37,7 @@ export function ComboboxPopover(props: ComboboxPopoverProps): JSX.Element { { - return cx(themeRootClassName, styles.comboboxPopover(), className); + return cx(themeRootClassName, styles.combobox().popover(className)); })} ref={mergeRefs(ref, (node: HTMLElement | null) => { setElement(node); diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/root.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/root.tsx index de4c0a48..ce3b6353 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/root.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/root.tsx @@ -5,12 +5,9 @@ import { composeRenderProps } from 'react-aria-components/composeRenderProps'; import * as styles from '../../recipes/combobox.css.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; import { ComboboxSizeProvider } from './size-context.js'; -interface ComboboxVariantProps extends NonNullable {} - -export type ComboboxSize = NonNullable; +export type ComboboxSize = styles.ComboboxSize; type _ComboboxRootOmit = DistributiveOmit< RacComboBoxProps, @@ -64,7 +61,7 @@ export function ComboboxRoot(props: ComboboxRootProps): JSX { - return cx(styles.comboboxRoot, renderedClassName); + return styles.combobox().root(renderedClassName); })} menuTrigger={menuTrigger} ref={ref} diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/section.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/section.tsx index 9e857e14..1a564a80 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/section.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/section.tsx @@ -5,7 +5,6 @@ import { Header as RacHeader } from 'react-aria-components/Header'; import * as styles from '../../recipes/combobox.css.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; type _ComboboxSectionOmit = DistributiveOmit< RacListBoxSectionProps, @@ -27,7 +26,7 @@ export type ComboboxSectionProps = Prettify<_ComboboxSectionPr export function ComboboxSection(props: ComboboxSectionProps): JSX.Element { const { children, className, title, ...sectionProps } = props; - const sectionClassName = cx(styles.comboboxSection(), className); + const sectionClassName = styles.combobox().section(className); if (typeof children === 'function') { return ( @@ -40,7 +39,7 @@ export function ComboboxSection(props: ComboboxSectionProps return ( {title != null ? ( - {title} + {title} ) : null} {children} diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/trigger.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/trigger.tsx index d0a558f4..c59f9dcb 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/trigger.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/trigger.tsx @@ -7,7 +7,6 @@ import * as styles from '../../recipes/combobox.css.js'; import { COMBOBOX_ICON_SIZE } from '../../sizing/combobox-sizing.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; import type { ComboboxSize } from './root.js'; import { useComboboxSize } from './size-context.js'; @@ -34,7 +33,7 @@ export function ComboboxTrigger(props: ComboboxTriggerProps): JSX.Element { { - return cx(styles.comboboxTrigger({ size }), className); + return styles.combobox({ size }).trigger(className); })} /> diff --git a/packages/@luke-ui/react/src/field/primitive/description.tsx b/packages/@luke-ui/react/src/field/primitive/description.tsx index 2b71f123..763fa531 100644 --- a/packages/@luke-ui/react/src/field/primitive/description.tsx +++ b/packages/@luke-ui/react/src/field/primitive/description.tsx @@ -4,13 +4,8 @@ import { Text as RacText } from 'react-aria-components/Text'; import * as styles from '../../recipes/field.css.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; -interface FieldMessageVariantProps extends NonNullable {} - -type _FieldDescriptionOmit1 = DistributiveOmit; -type _FieldDescriptionOmit2 = DistributiveOmit; -interface _FieldDescriptionProps extends _FieldDescriptionOmit1, _FieldDescriptionOmit2 {} +type _FieldDescriptionProps = DistributiveOmit; /** * Props for `FieldDescription`. @@ -26,7 +21,7 @@ export function FieldDescription(props: FieldDescriptionProps): JSX.Element { return ( ); diff --git a/packages/@luke-ui/react/src/field/primitive/error.tsx b/packages/@luke-ui/react/src/field/primitive/error.tsx index 96efb135..28dd5a86 100644 --- a/packages/@luke-ui/react/src/field/primitive/error.tsx +++ b/packages/@luke-ui/react/src/field/primitive/error.tsx @@ -3,21 +3,14 @@ import type { FieldErrorProps as RacFieldErrorProps } from 'react-aria-component import { FieldError as RacFieldError } from 'react-aria-components/FieldError'; import { composeRenderProps } from 'react-aria-components/composeRenderProps'; import * as styles from '../../recipes/field.css.js'; -import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; - -interface FieldMessageVariantProps extends NonNullable {} - -type _FieldErrorOmit = DistributiveOmit; -interface _FieldErrorProps extends RacFieldErrorProps, _FieldErrorOmit {} /** * Props for `FieldError`. * * @tier primitive */ -export type FieldErrorProps = Prettify<_FieldErrorProps>; +export type FieldErrorProps = Prettify; /** Styled validation message for a field. */ export function FieldError(props: FieldErrorProps): JSX.Element { @@ -25,7 +18,7 @@ export function FieldError(props: FieldErrorProps): JSX.Element { { - return cx(styles.fieldMessage({ tone: 'error' }), className); + return styles.field({ tone: 'error' }).message(className); })} /> ); diff --git a/packages/@luke-ui/react/src/field/primitive/label.tsx b/packages/@luke-ui/react/src/field/primitive/label.tsx index 6a1e3559..2880084b 100644 --- a/packages/@luke-ui/react/src/field/primitive/label.tsx +++ b/packages/@luke-ui/react/src/field/primitive/label.tsx @@ -3,12 +3,9 @@ import type { LabelProps as RacLabelProps } from 'react-aria-components/Label'; import { Label as RacLabel } from 'react-aria-components/Label'; import * as styles from '../../recipes/field.css.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; - -interface FieldLabelVariantProps extends NonNullable {} /** Allowed `necessityIndicator` values for `FieldLabel`. */ -export type FieldNecessityIndicator = NonNullable; +export type FieldNecessityIndicator = styles.FieldNecessityIndicator; interface FieldLabelStyleProps { /** Shows how required fields are marked. */ @@ -29,6 +26,6 @@ export function FieldLabel(props: FieldLabelProps): JSX.Element { const { className, necessityIndicator = 'icon', ...restProps } = props; return ( - + ); } diff --git a/packages/@luke-ui/react/src/field/primitive/root.tsx b/packages/@luke-ui/react/src/field/primitive/root.tsx index 287f88f2..01b7e9d8 100644 --- a/packages/@luke-ui/react/src/field/primitive/root.tsx +++ b/packages/@luke-ui/react/src/field/primitive/root.tsx @@ -1,6 +1,5 @@ import type { ComponentProps, JSX } from 'react'; import * as styles from '../../recipes/field.css.js'; -import { cx } from '../../utils/index.js'; /** Props for the primitive field container. */ type FieldProps = ComponentProps<'div'>; @@ -9,5 +8,5 @@ type FieldProps = ComponentProps<'div'>; export function Field(props: FieldProps): JSX.Element { const { className, ...restProps } = props; - return
; + return
; } diff --git a/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts b/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts index 231ed426..48c33839 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts @@ -4,13 +4,7 @@ import { cdp } from 'vite-plus/test/context'; import { themeRootClassName } from '../theme/index.js'; import { tactileThemeClassName } from '../themes/index.js'; import { cx } from '../utils/index.js'; -import { - comboboxClearButton, - comboboxControl, - comboboxItem, - comboboxPopover, - comboboxTrigger, -} from './combobox.css.js'; +import { combobox } from './combobox.css.js'; let wrappers: Array = []; @@ -74,10 +68,10 @@ test('disabled and read-only controls preserve their non-interactive material', test('in-field actions are quiet inset squares without a permanent divider or travel', () => { const { root } = mountControl(); for (const { className, expectedSize, isTrigger } of [ - { className: comboboxClearButton({ size: 'small' }), expectedSize: 24, isTrigger: false }, - { className: comboboxTrigger({ size: 'small' }), expectedSize: 24, isTrigger: true }, - { className: comboboxClearButton({ size: 'medium' }), expectedSize: 28, isTrigger: false }, - { className: comboboxTrigger({ size: 'medium' }), expectedSize: 28, isTrigger: true }, + { className: combobox({ size: 'small' }).clearButton(), expectedSize: 24, isTrigger: false }, + { className: combobox({ size: 'small' }).trigger(), expectedSize: 24, isTrigger: true }, + { className: combobox({ size: 'medium' }).clearButton(), expectedSize: 28, isTrigger: false }, + { className: combobox({ size: 'medium' }).trigger(), expectedSize: 28, isTrigger: true }, ]) { const action = root.appendChild(document.createElement('button')); action.className = className; @@ -153,7 +147,7 @@ test('options keep hover, keyboard focus, selected, and disabled states distinct test('the portalled surface uses floating surface and depth', () => { const { root } = mountControl(); const popover = root.appendChild(document.createElement('div')); - popover.className = comboboxPopover(); + popover.className = combobox().popover(); const style = getComputedStyle(popover); expect(style.backgroundColor).toBe(resolveColor(root, '--luke-color-surface-floating')); @@ -181,7 +175,7 @@ test('forced colors remove authored depth and preserve system state colors', asy expect(getComputedStyle(focused).outlineColor).toBe(resolveSystemColor(root, 'Highlight')); const action = root.appendChild(document.createElement('button')); - action.className = comboboxTrigger({ size: 'medium' }); + action.className = combobox({ size: 'medium' }).trigger(); expect(getComputedStyle(action).boxShadow).toBe('none'); action.dataset.hovered = 'true'; const hoveredActionStyle = getComputedStyle(action); @@ -196,7 +190,7 @@ test('forced colors remove authored depth and preserve system state colors', asy expect(pressedActionStyle.transform).toBe('none'); const popover = root.appendChild(document.createElement('div')); - popover.className = comboboxPopover(); + popover.className = combobox().popover(); expect(getComputedStyle(popover).boxShadow).toBe('none'); const disabledItem = mountItem(root, { disabled: 'true' }); @@ -213,11 +207,11 @@ test('reduced motion makes control, action, option, and popover state changes im const { control, root } = mountControl(); const action = root.appendChild(document.createElement('button')); - action.className = comboboxClearButton({ size: 'medium' }); + action.className = combobox({ size: 'medium' }).clearButton(); action.dataset.hovered = 'true'; const item = mountItem(root, { hovered: 'true' }); const popover = root.appendChild(document.createElement('div')); - popover.className = comboboxPopover(); + popover.className = combobox().popover(); popover.dataset.entering = ''; for (const element of [control, action, item, popover]) { @@ -235,14 +229,14 @@ function mountControl() { root.dataset.colorMode = 'light'; wrappers.push(root); const control = root.appendChild(document.createElement('div')); - control.className = comboboxControl({ size: 'medium' }); + control.className = combobox({ size: 'medium' }).control(); control.append(document.createElement('input'), document.createElement('button')); return { control, root }; } function mountItem(root: HTMLElement, states: Record = {}) { const item = root.appendChild(document.createElement('div')); - item.className = comboboxItem({ size: 'medium' }); + item.className = combobox({ size: 'medium' }).item(); for (const [state, value] of Object.entries(states)) item.dataset[state] = value; return item; } diff --git a/packages/@luke-ui/react/src/recipes/combobox.css.ts b/packages/@luke-ui/react/src/recipes/combobox.css.ts index 342b3710..daf55f09 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.css.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.css.ts @@ -1,13 +1,13 @@ import type { StyleRule } from '@vanilla-extract/css'; -import type { RecipeVariants } from '@vanilla-extract/recipes'; import { focusRing } from '../styles/focus-ring.js'; -import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; import { vars } from '../theme/contract.css.js'; import { composeInputStateSelectors, descendantDisabledSelector, inputStates, } from './input-states.css.js'; +import type { RecipeSelection, SlottedConfigInput } from './recipe.js'; +import { recipe } from './recipe.js'; /** Custom property mirroring `visualViewport.height`, set by `useVisualViewportVars`. */ export const comboboxTrayViewportHeightVar = '--luke-ui-visual-viewport-height'; @@ -26,130 +26,6 @@ const comboboxStates = { const { disabled, focusWithin, hover, invalid, invalidFocusWithin, readOnly, readOnlyFocusWithin } = composeInputStateSelectors(comboboxStates); -export const comboboxRoot = styleInLayer('recipes', { - display: 'flex', - flexDirection: 'column', - inlineSize: '100%', - minInlineSize: 0, -}); - -export const comboboxControl = recipeInLayer('recipes', { - base: { - '@media': { - '(forced-colors: active)': { - backgroundColor: 'Field', - borderColor: 'FieldText', - boxShadow: 'none', - color: 'FieldText', - forcedColorAdjust: 'auto', - selectors: { - [disabled]: { borderColor: 'GrayText', color: 'GrayText', opacity: 1 }, - [focusWithin]: { outlineColor: 'Highlight' }, - [invalidFocusWithin]: { outlineColor: 'Highlight' }, - }, - }, - '(prefers-reduced-motion: reduce)': { transition: 'none' }, - }, - alignItems: 'center', - backgroundColor: vars.color.surface.recessed, - borderColor: vars.color.border.control, - borderRadius: vars.radius.control, - borderStyle: 'solid', - borderWidth: '1px', - boxShadow: vars.depth.recessed, - color: vars.color.text.primary, - cursor: 'text', - display: 'inline-flex', - fontFamily: vars.font.family, - inlineSize: '100%', - isolation: 'isolate', - letterSpacing: vars.font[300].letterSpacing, - lineHeight: vars.font[300].lineHeight, - minInlineSize: 0, - outlineColor: 'transparent', - outlineOffset: 0, - outlineStyle: 'solid', - outlineWidth: '2px', - overflow: 'visible', - transitionDuration: vars.motion.duration.fast, - transitionProperty: 'background-color, border-color, color', - transitionTimingFunction: vars.motion.easing.standard, - - selectors: { - [disabled]: { cursor: 'not-allowed', opacity: 0.55 }, - [focusWithin]: { - borderColor: vars.color.intent.accent.border, - ...focusRing(vars.color.border.focus), - }, - [hover]: { borderColor: vars.color.intent.accent.border }, - [invalid]: { borderColor: vars.color.intent.danger.border }, - [invalidFocusWithin]: { - borderColor: vars.color.intent.danger.border, - ...focusRing(vars.color.border.focus), - }, - [readOnly]: { - backgroundColor: vars.color.surface.canvas, - borderColor: vars.color.border.decorative, - boxShadow: 'none', - }, - [readOnlyFocusWithin]: { ...focusRing(vars.color.border.focus) }, - }, - }, - defaultVariants: { size: 'medium' }, - variants: { - size: { - medium: { blockSize: vars.controlSize.medium, fontSize: vars.font[300].fontSize }, - small: { - blockSize: vars.controlSize.small, - ...vars.font[200], - }, - }, - }, -}); - -export const comboboxTextInput = recipeInLayer('recipes', { - base: { - appearance: 'none', - backgroundColor: 'transparent', - border: 'none', - color: vars.color.text.primary, - cursor: 'text', - flex: 1, - fontFamily: 'inherit', - fontSize: 'inherit', - fontWeight: 'inherit', - inlineSize: '100%', - letterSpacing: 'inherit', - lineHeight: 'inherit', - minInlineSize: 0, - outline: 'none', - paddingBlock: 0, - - selectors: { - '&::placeholder': { color: vars.color.text.secondary, opacity: 1 }, - '&:where([data-disabled="true"], :disabled)': { - color: vars.color.textDisabled, - cursor: 'not-allowed', - }, - }, - }, - defaultVariants: { size: 'medium' }, - variants: { - size: { - medium: { - blockSize: vars.controlSize.medium, - paddingInlineEnd: vars.space[300], - paddingInlineStart: vars.space[300], - }, - small: { - blockSize: vars.controlSize.small, - paddingInlineEnd: vars.space[200], - paddingInlineStart: vars.space[200], - }, - }, - }, -}); - const comboboxActionStyles = { '@media': { '(forced-colors: active)': { @@ -218,276 +94,317 @@ const comboboxActionStyles = { }, } satisfies StyleRule; -export const comboboxTrigger = recipeInLayer('recipes', { - base: { - ...comboboxActionStyles, - marginInlineEnd: vars.space[100], - marginInlineStart: vars.space[100], - - selectors: { - ...comboboxActionStyles.selectors, - '&:first-child': { - color: vars.color.text.primary, - inlineSize: '100%', - justifyContent: 'space-between', - marginInline: 0, - }, +/** + * Raw slotted config for the combobox anatomy. + * + * Slots follow the anatomy top to bottom: `root`, `control`, `textInput`, + * `trigger`, `clearButton`, `itemCheck`, `popover`, `listBox`, `loadMoreItem`, + * `section`, `sectionHeading`, `emptyState`, `item`. + */ +const comboboxConfig = { + slots: { + root: { + display: 'flex', + flexDirection: 'column', + inlineSize: '100%', + minInlineSize: 0, }, - }, - defaultVariants: { size: 'medium' }, - variants: { - size: { - medium: { - blockSize: '28px', - inlineSize: '28px', - paddingInline: 0, + control: { + '@media': { + '(forced-colors: active)': { + backgroundColor: 'Field', + borderColor: 'FieldText', + boxShadow: 'none', + color: 'FieldText', + forcedColorAdjust: 'auto', + selectors: { + [disabled]: { borderColor: 'GrayText', color: 'GrayText', opacity: 1 }, + [focusWithin]: { outlineColor: 'Highlight' }, + [invalidFocusWithin]: { outlineColor: 'Highlight' }, + }, + }, + '(prefers-reduced-motion: reduce)': { transition: 'none' }, }, - small: { - blockSize: '24px', - inlineSize: '24px', - paddingInline: 0, + alignItems: 'center', + backgroundColor: vars.color.surface.recessed, + borderColor: vars.color.border.control, + borderRadius: vars.radius.control, + borderStyle: 'solid', + borderWidth: '1px', + boxShadow: vars.depth.recessed, + color: vars.color.text.primary, + cursor: 'text', + display: 'inline-flex', + fontFamily: vars.font.family, + inlineSize: '100%', + isolation: 'isolate', + letterSpacing: vars.font[300].letterSpacing, + lineHeight: vars.font[300].lineHeight, + minInlineSize: 0, + outlineColor: 'transparent', + outlineOffset: 0, + outlineStyle: 'solid', + outlineWidth: '2px', + overflow: 'visible', + transitionDuration: vars.motion.duration.fast, + transitionProperty: 'background-color, border-color, color', + transitionTimingFunction: vars.motion.easing.standard, + + selectors: { + [disabled]: { cursor: 'not-allowed', opacity: 0.55 }, + [focusWithin]: { + borderColor: vars.color.intent.accent.border, + ...focusRing(vars.color.border.focus), + }, + [hover]: { borderColor: vars.color.intent.accent.border }, + [invalid]: { borderColor: vars.color.intent.danger.border }, + [invalidFocusWithin]: { + borderColor: vars.color.intent.danger.border, + ...focusRing(vars.color.border.focus), + }, + [readOnly]: { + backgroundColor: vars.color.surface.canvas, + borderColor: vars.color.border.decorative, + boxShadow: 'none', + }, + [readOnlyFocusWithin]: { ...focusRing(vars.color.border.focus) }, }, }, - }, -}); + textInput: { + appearance: 'none', + backgroundColor: 'transparent', + border: 'none', + color: vars.color.text.primary, + cursor: 'text', + flex: 1, + fontFamily: 'inherit', + fontSize: 'inherit', + fontWeight: 'inherit', + inlineSize: '100%', + letterSpacing: 'inherit', + lineHeight: 'inherit', + minInlineSize: 0, + outline: 'none', + paddingBlock: 0, -export const comboboxClearButton = recipeInLayer('recipes', { - base: comboboxActionStyles, - defaultVariants: { size: 'medium' }, - variants: { - size: { - medium: { blockSize: '28px', inlineSize: '28px', paddingInline: 0 }, - small: { blockSize: '24px', inlineSize: '24px', paddingInline: 0 }, + selectors: { + '&::placeholder': { color: vars.color.text.secondary, opacity: 1 }, + '&:where([data-disabled="true"], :disabled)': { + color: vars.color.textDisabled, + cursor: 'not-allowed', + }, + }, }, - }, -}); + trigger: { + ...comboboxActionStyles, + marginInlineEnd: vars.space[100], + marginInlineStart: vars.space[100], -export const comboboxItemCheck = styleInLayer('recipes', { - flexShrink: 0, - marginInlineStart: 'auto', -}); - -export const comboboxPopover = recipeInLayer('recipes', { - base: { - '@media': { - '(forced-colors: active)': { - backgroundColor: 'Canvas', - borderColor: 'CanvasText', - boxShadow: 'none', - forcedColorAdjust: 'auto', + selectors: { + ...comboboxActionStyles.selectors, + '&:first-child': { + color: vars.color.text.primary, + inlineSize: '100%', + justifyContent: 'space-between', + marginInline: 0, + }, }, - [trayMediaQuery]: { - borderEndEndRadius: 0, - borderEndStartRadius: 0, - borderStartEndRadius: vars.radius.overlay, - borderStartStartRadius: vars.radius.overlay, - boxShadow: `${vars.depth.floating}, 0 0 0 100vmax rgb(0 0 0 / 20%)`, - inlineSize: 'auto !important' as 'auto', - insetBlockEnd: `var(${comboboxTrayKeyboardInsetVar}, 0px) !important`, - insetBlockStart: 'auto !important' as 'auto', - insetInline: '0 !important', - maxBlockSize: `calc(var(${comboboxTrayViewportHeightVar}, 100dvh) - ${vars.space[800]}) !important`, - minInlineSize: 'auto !important' as 'auto', - paddingBlockEnd: 'env(safe-area-inset-bottom, 0px)', - position: 'fixed !important' as 'fixed', - selectors: { - '&::before': { - alignSelf: 'center', - backgroundColor: vars.color.border.decorative, - blockSize: '4px', - borderRadius: vars.radius.full, - content: '', - flexShrink: 0, - inlineSize: '36px', - marginBlockEnd: vars.space[100], - marginBlockStart: vars.space[200], - }, - '&[data-entering]': { - boxShadow: `${vars.depth.floating}, 0 0 0 100vmax transparent`, - opacity: 1, - translate: '0 100%', + }, + clearButton: comboboxActionStyles, + itemCheck: { + flexShrink: 0, + marginInlineStart: 'auto', + }, + popover: { + '@media': { + '(forced-colors: active)': { + backgroundColor: 'Canvas', + borderColor: 'CanvasText', + boxShadow: 'none', + forcedColorAdjust: 'auto', + }, + [trayMediaQuery]: { + borderEndEndRadius: 0, + borderEndStartRadius: 0, + borderStartEndRadius: vars.radius.overlay, + borderStartStartRadius: vars.radius.overlay, + boxShadow: `${vars.depth.floating}, 0 0 0 100vmax rgb(0 0 0 / 20%)`, + inlineSize: 'auto !important' as 'auto', + insetBlockEnd: `var(${comboboxTrayKeyboardInsetVar}, 0px) !important`, + insetBlockStart: 'auto !important' as 'auto', + insetInline: '0 !important', + maxBlockSize: `calc(var(${comboboxTrayViewportHeightVar}, 100dvh) - ${vars.space[800]}) !important`, + minInlineSize: 'auto !important' as 'auto', + paddingBlockEnd: 'env(safe-area-inset-bottom, 0px)', + position: 'fixed !important' as 'fixed', + selectors: { + '&::before': { + alignSelf: 'center', + backgroundColor: vars.color.border.decorative, + blockSize: '4px', + borderRadius: vars.radius.full, + content: '', + flexShrink: 0, + inlineSize: '36px', + marginBlockEnd: vars.space[100], + marginBlockStart: vars.space[200], + }, + '&[data-entering]': { + boxShadow: `${vars.depth.floating}, 0 0 0 100vmax transparent`, + opacity: 1, + translate: '0 100%', + }, + '&[data-exiting]': { + boxShadow: `${vars.depth.floating}, 0 0 0 100vmax transparent`, + opacity: 1, + translate: '0 100%', + }, }, - '&[data-exiting]': { - boxShadow: `${vars.depth.floating}, 0 0 0 100vmax transparent`, - opacity: 1, - translate: '0 100%', + }, + '(prefers-reduced-motion: reduce)': { + transition: 'none', + selectors: { + '&[data-entering]': { opacity: 1, translate: 'none' }, + '&[data-exiting]': { opacity: 1, translate: 'none' }, }, }, }, - '(prefers-reduced-motion: reduce)': { - transition: 'none', - selectors: { - '&[data-entering]': { opacity: 1, translate: 'none' }, - '&[data-exiting]': { opacity: 1, translate: 'none' }, + backgroundColor: vars.color.surface.floating, + borderColor: vars.color.border.decorative, + borderRadius: vars.radius.surface, + borderStyle: 'solid', + borderWidth: '1px', + boxShadow: vars.depth.floating, + display: 'flex', + flexDirection: 'column', + inlineSize: 'var(--trigger-width)', + isolation: 'isolate', + minInlineSize: 'var(--trigger-width)', + overflow: 'hidden', + transition: [ + `opacity ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, + `translate ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, + `box-shadow ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, + ].join(', '), + + selectors: { + '&[data-entering]': { opacity: 0 }, + '&[data-exiting]': { opacity: 0 }, + }, + + '@supports': { + '(min-block-size: calc-size(fit-content, size))': { + minBlockSize: 'calc-size(fit-content, min(size, 12em))', }, }, }, - backgroundColor: vars.color.surface.floating, - borderColor: vars.color.border.decorative, - borderRadius: vars.radius.surface, - borderStyle: 'solid', - borderWidth: '1px', - boxShadow: vars.depth.floating, - display: 'flex', - flexDirection: 'column', - inlineSize: 'var(--trigger-width)', - isolation: 'isolate', - minInlineSize: 'var(--trigger-width)', - overflow: 'hidden', - transition: [ - `opacity ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, - `translate ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, - `box-shadow ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, - ].join(', '), - - selectors: { - '&[data-entering]': { opacity: 0 }, - '&[data-exiting]': { opacity: 0 }, + listBox: { + '@media': { [trayMediaQuery]: { maxBlockSize: 'none' } }, + boxSizing: 'border-box', + flex: 1, + inlineSize: '100%', + listStyle: 'none', + margin: 0, + maxBlockSize: '18.75rem', + minBlockSize: 0, + outline: 'none', + overflow: 'auto', + padding: vars.space[100], }, - - '@supports': { - '(min-block-size: calc-size(fit-content, size))': { - minBlockSize: 'calc-size(fit-content, min(size, 12em))', - }, + loadMoreItem: { + alignItems: 'center', + color: vars.color.text.secondary, + display: 'flex', + inlineSize: '100%', + justifyContent: 'center', + minInlineSize: 0, }, - }, -}); + section: { + display: 'flex', + flexDirection: 'column', + gap: vars.space[100], + paddingBlock: vars.space[200], -export const comboboxListBox = recipeInLayer('recipes', { - base: { - '@media': { [trayMediaQuery]: { maxBlockSize: 'none' } }, - boxSizing: 'border-box', - flex: 1, - inlineSize: '100%', - listStyle: 'none', - margin: 0, - maxBlockSize: '18.75rem', - minBlockSize: 0, - outline: 'none', - overflow: 'auto', - padding: vars.space[100], - }, -}); - -export const comboboxLoadMoreItem = recipeInLayer('recipes', { - base: { - alignItems: 'center', - color: vars.color.text.secondary, - display: 'flex', - inlineSize: '100%', - justifyContent: 'center', - minInlineSize: 0, - }, - defaultVariants: { size: 'medium' }, - variants: { - size: { - medium: { - minBlockSize: vars.controlSize.medium, - paddingBlock: vars.space[200], - paddingInline: vars.space[300], - }, - small: { - minBlockSize: vars.controlSize.small, - paddingBlock: vars.space[100], - paddingInline: vars.space[200], + selectors: { + '& + &': { borderBlockStart: `1px solid ${vars.color.border.decorative}` }, }, }, - }, -}); - -export const comboboxSection = recipeInLayer('recipes', { - base: { - display: 'flex', - flexDirection: 'column', - gap: vars.space[100], - paddingBlock: vars.space[200], - - selectors: { - '& + &': { borderBlockStart: `1px solid ${vars.color.border.decorative}` }, + sectionHeading: { + color: vars.color.text.secondary, + ...vars.font[200], + fontWeight: vars.font.weight.label, + paddingBlockEnd: vars.space[100], + paddingBlockStart: 0, + paddingInline: vars.space[300], }, - }, -}); - -export const comboboxSectionHeading = styleInLayer('recipes', { - color: vars.color.text.secondary, - ...vars.font[200], - fontWeight: vars.font.weight.label, - paddingBlockEnd: vars.space[100], - paddingBlockStart: 0, - paddingInline: vars.space[300], -}); - -export const comboboxEmptyState = recipeInLayer('recipes', { - base: { - alignItems: 'center', - color: vars.color.text.secondary, - display: 'flex', - ...vars.font[200], - justifyContent: 'center', - paddingBlock: vars.space[600], - paddingInline: vars.space[300], - textAlign: 'center', - }, -}); - -export const comboboxItem = recipeInLayer('recipes', { - base: { - '@media': { - '(forced-colors: active)': { - forcedColorAdjust: 'auto', - selectors: { - '&[data-disabled="true"]': { color: 'GrayText', opacity: 1 }, - '&[data-focus-visible="true"]': { - outlineColor: 'Highlight', - outlineOffset: '-2px', - outlineStyle: 'solid', - outlineWidth: '2px', - }, - '&[data-selected="true"]:not([data-disabled="true"])': { - backgroundColor: 'Highlight', - color: 'HighlightText', + emptyState: { + alignItems: 'center', + color: vars.color.text.secondary, + display: 'flex', + ...vars.font[200], + justifyContent: 'center', + paddingBlock: vars.space[600], + paddingInline: vars.space[300], + textAlign: 'center', + }, + item: { + '@media': { + '(forced-colors: active)': { + forcedColorAdjust: 'auto', + selectors: { + '&[data-disabled="true"]': { color: 'GrayText', opacity: 1 }, + '&[data-focus-visible="true"]': { + outlineColor: 'Highlight', + outlineOffset: '-2px', + outlineStyle: 'solid', + outlineWidth: '2px', + }, + '&[data-selected="true"]:not([data-disabled="true"])': { + backgroundColor: 'Highlight', + color: 'HighlightText', + }, }, }, + '(prefers-reduced-motion: reduce)': { transform: 'none', transition: 'none' }, }, - '(prefers-reduced-motion: reduce)': { transform: 'none', transition: 'none' }, - }, - alignItems: 'center', - backgroundColor: 'transparent', - borderRadius: vars.radius.control, - color: vars.color.text.primary, - cursor: 'default', - display: 'flex', - gap: vars.space[200], - inlineSize: '100%', - minBlockSize: '24px', - minInlineSize: 0, - outline: 'none', - transform: 'none', - transitionDuration: vars.motion.duration.fast, - transitionProperty: 'background-color, color, opacity', - transitionTimingFunction: vars.motion.easing.standard, + alignItems: 'center', + backgroundColor: 'transparent', + borderRadius: vars.radius.control, + color: vars.color.text.primary, + cursor: 'default', + display: 'flex', + gap: vars.space[200], + inlineSize: '100%', + minBlockSize: '24px', + minInlineSize: 0, + outline: 'none', + transform: 'none', + transitionDuration: vars.motion.duration.fast, + transitionProperty: 'background-color, color, opacity', + transitionTimingFunction: vars.motion.easing.standard, - selectors: { - '&[data-disabled="true"]': { - color: vars.color.textDisabled, - cursor: 'not-allowed', - opacity: 0.55, - }, - '&[data-focused="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.neutral.surface.subtle, - }, - '&[data-hovered="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.neutral.surface.subtleHover, - }, - '&[data-focus-visible="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.accent.surface.subtleHover, - }, - '&[data-selected="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.accent.surface.subtle, - fontWeight: vars.font.weight.label, - }, - '&[data-selected="true"][data-focus-visible="true"]:not([data-disabled="true"])': { - backgroundColor: vars.color.intent.accent.surface.subtlePressed, + selectors: { + '&[data-disabled="true"]': { + color: vars.color.textDisabled, + cursor: 'not-allowed', + opacity: 0.55, + }, + '&[data-focused="true"]:not([data-disabled="true"])': { + backgroundColor: vars.color.intent.neutral.surface.subtle, + }, + '&[data-hovered="true"]:not([data-disabled="true"])': { + backgroundColor: vars.color.intent.neutral.surface.subtleHover, + }, + '&[data-focus-visible="true"]:not([data-disabled="true"])': { + backgroundColor: vars.color.intent.accent.surface.subtleHover, + }, + '&[data-selected="true"]:not([data-disabled="true"])': { + backgroundColor: vars.color.intent.accent.surface.subtle, + fontWeight: vars.font.weight.label, + }, + '&[data-selected="true"][data-focus-visible="true"]:not([data-disabled="true"])': { + backgroundColor: vars.color.intent.accent.surface.subtlePressed, + }, }, }, }, @@ -495,19 +412,70 @@ export const comboboxItem = recipeInLayer('recipes', { variants: { size: { medium: { - ...vars.font[200], - minBlockSize: vars.controlSize.medium, - paddingBlock: vars.space[200], - paddingInline: vars.space[300], + control: { blockSize: vars.controlSize.medium, fontSize: vars.font[300].fontSize }, + textInput: { + blockSize: vars.controlSize.medium, + paddingInlineEnd: vars.space[300], + paddingInlineStart: vars.space[300], + }, + trigger: { + blockSize: '28px', + inlineSize: '28px', + paddingInline: 0, + }, + clearButton: { blockSize: '28px', inlineSize: '28px', paddingInline: 0 }, + loadMoreItem: { + minBlockSize: vars.controlSize.medium, + paddingBlock: vars.space[200], + paddingInline: vars.space[300], + }, + item: { + ...vars.font[200], + minBlockSize: vars.controlSize.medium, + paddingBlock: vars.space[200], + paddingInline: vars.space[300], + }, }, small: { - ...vars.font[200], - minBlockSize: vars.controlSize.small, - paddingBlock: vars.space[100], - paddingInline: vars.space[300], + control: { + blockSize: vars.controlSize.small, + ...vars.font[200], + }, + textInput: { + blockSize: vars.controlSize.small, + paddingInlineEnd: vars.space[200], + paddingInlineStart: vars.space[200], + }, + trigger: { + blockSize: '24px', + inlineSize: '24px', + paddingInline: 0, + }, + clearButton: { blockSize: '24px', inlineSize: '24px', paddingInline: 0 }, + loadMoreItem: { + minBlockSize: vars.controlSize.small, + paddingBlock: vars.space[100], + paddingInline: vars.space[200], + }, + item: { + ...vars.font[200], + minBlockSize: vars.controlSize.small, + paddingBlock: vars.space[100], + paddingInline: vars.space[300], + }, }, }, }, -}); +} as const satisfies SlottedConfigInput; + +/** + * Slotted recipe for the combobox anatomy. Internal — not exported from + * `@luke-ui/react/recipes`. + */ +export const combobox = recipe(comboboxConfig); + +/** Outer variant selection for the combobox recipe. */ +export type ComboboxVariants = RecipeSelection; -export type ComboboxVariants = RecipeVariants; +/** Allowed `size` values for the combobox recipe. */ +export type ComboboxSize = keyof typeof comboboxConfig.variants.size; diff --git a/packages/@luke-ui/react/src/recipes/field.css.ts b/packages/@luke-ui/react/src/recipes/field.css.ts index 14558db5..132effbb 100644 --- a/packages/@luke-ui/react/src/recipes/field.css.ts +++ b/packages/@luke-ui/react/src/recipes/field.css.ts @@ -1,89 +1,100 @@ -import type { RecipeVariants } from '@vanilla-extract/recipes'; -import { recipeInLayer } from '../styles/layered-style.css.js'; import { vars } from '../theme/contract.css.js'; +import type { RecipeSelection, SlottedConfigInput } from './recipe.js'; +import { recipe } from './recipe.js'; const dataDisabledSelector = '[data-disabled="true"]'; const dataRequiredSelector = '[data-required="true"]'; -/** Vanilla-extract recipe for the `Field` primitive's layout styles. */ -export const field = recipeInLayer('recipes', { - base: { - display: 'flex', - flexDirection: 'column', - gap: vars.space[100], - minInlineSize: 0, - }, -}); +/** + * Raw slotted config for the `Field` primitive. + * + * Slots: `root` (layout), `label`, and `message` (description/error text). + */ +const fieldConfig = { + slots: { + root: { + display: 'flex', + flexDirection: 'column', + gap: vars.space[100], + minInlineSize: 0, + }, + label: { + color: vars.color.text.primary, + ...vars.font[200], + fontWeight: vars.font.weight.label, + minInlineSize: 0, -/** Vanilla-extract recipe for the `Field` primitive's label styles. */ -export const fieldLabel = recipeInLayer('recipes', { - base: { - color: vars.color.text.primary, - ...vars.font[200], - fontWeight: vars.font.weight.label, - minInlineSize: 0, + selectors: { + [`${dataDisabledSelector} &`]: { + color: vars.color.textDisabled, + }, + }, + }, + message: { + ...vars.font[200], + minInlineSize: 0, - selectors: { - [`${dataDisabledSelector} &`]: { - color: vars.color.textDisabled, + selectors: { + [`${dataDisabledSelector} &`]: { + color: vars.color.textDisabled, + }, }, }, }, defaultVariants: { necessityIndicator: 'icon', + tone: 'description', }, variants: { necessityIndicator: { icon: { - selectors: { - [`${dataRequiredSelector} &::after`]: { - color: vars.color.intent.danger.text, - content: '"*"', - marginInlineStart: vars.space[100], + label: { + selectors: { + [`${dataRequiredSelector} &::after`]: { + color: vars.color.intent.danger.text, + content: '"*"', + marginInlineStart: vars.space[100], + }, }, }, }, label: { - selectors: { - [`${dataRequiredSelector} &::after`]: { - color: vars.color.text.secondary, - content: '"(required)"', - fontWeight: vars.font.weight.body, - marginInlineStart: vars.space[100], + label: { + selectors: { + [`${dataRequiredSelector} &::after`]: { + color: vars.color.text.secondary, + content: '"(required)"', + fontWeight: vars.font.weight.body, + marginInlineStart: vars.space[100], + }, }, }, }, }, - }, -}); - -export type FieldLabelVariants = RecipeVariants; - -/** Vanilla-extract recipe for the `Field` primitive's message styles. */ -export const fieldMessage = recipeInLayer('recipes', { - base: { - ...vars.font[200], - minInlineSize: 0, - - selectors: { - [`${dataDisabledSelector} &`]: { - color: vars.color.textDisabled, - }, - }, - }, - defaultVariants: { - tone: 'description', - }, - variants: { tone: { description: { - color: vars.color.text.secondary, + message: { + color: vars.color.text.secondary, + }, }, error: { - color: vars.color.intent.danger.text, + message: { + color: vars.color.intent.danger.text, + }, }, }, }, -}); +} as const satisfies SlottedConfigInput; + +/** + * Slotted recipe for the `Field` primitive. + * + * `field({ necessityIndicator, tone }).root() / .label() / .message()`. + */ +export const field = recipe(fieldConfig); + +/** Outer variant selection for the `Field` recipe. */ +export type FieldVariants = RecipeSelection; -export type FieldMessageVariants = RecipeVariants; +/** Allowed `necessityIndicator` values for the field label. */ +export type FieldNecessityIndicator = keyof typeof fieldConfig.variants.necessityIndicator; diff --git a/packages/@luke-ui/react/src/recipes/index.ts b/packages/@luke-ui/react/src/recipes/index.ts index a9b29cb7..265aa1fe 100644 --- a/packages/@luke-ui/react/src/recipes/index.ts +++ b/packages/@luke-ui/react/src/recipes/index.ts @@ -5,7 +5,8 @@ import '../stylesheet.css.js'; export type { ButtonVariants } from '../recipes/button.css.js'; export { button } from '../recipes/button.css.js'; -export { field, fieldLabel, fieldMessage } from '../recipes/field.css.js'; +export type { FieldVariants } from '../recipes/field.css.js'; +export { field } from '../recipes/field.css.js'; export type { IconVariants } from '../recipes/icon.css.js'; export { icon } from '../recipes/icon.css.js'; export type { IconButtonVariants } from '../recipes/icon-button.css.js'; @@ -29,9 +30,4 @@ export type { } from '../recipes/text.css.js'; export { text } from '../recipes/text.css.js'; export type { TextInputVariants } from '../recipes/text-input.css.js'; -export { - textInputAdornmentEnd, - textInputAdornmentStart, - textInputControl, - textInputGroup, -} from '../recipes/text-input.css.js'; +export { textInput } from '../recipes/text-input.css.js'; diff --git a/packages/@luke-ui/react/src/recipes/recipe.browser.test.ts b/packages/@luke-ui/react/src/recipes/recipe.browser.test.ts new file mode 100644 index 00000000..931ac4c0 --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/recipe.browser.test.ts @@ -0,0 +1,61 @@ +import { expect, expectTypeOf, test } from 'vite-plus/test'; +import type { ComboboxVariants } from './combobox.css.js'; +import { field } from './field.css.js'; +import { textInput } from './text-input.css.js'; + +// The public recipe surface: `field` and `textInput` from `@luke-ui/react/recipes`. + +test('field selects variants at the outer call and returns slot functions', () => { + const slots = field({ necessityIndicator: 'icon', tone: 'error' }); + + expect(typeof slots.root()).toBe('string'); + expect(typeof slots.label()).toBe('string'); + expect(typeof slots.message()).toBe('string'); +}); + +test('slot functions merge an optional extra class', () => { + expect(field().root('extra-class').split(' ')).toContain('extra-class'); + expect(textInput({ size: 'small' }).control('mine').split(' ')).toContain('mine'); +}); + +// Type assertions are compile-time only. +// oxlint-disable-next-line vitest/expect-expect +test('outer variant selection accepts known variants and rejects siblings/unknowns', () => { + expectTypeOf(field).toBeCallableWith({ necessityIndicator: 'icon', tone: 'description' }); + expectTypeOf(textInput).toBeCallableWith({ size: 'medium' }); + + // @ts-expect-error `size` belongs to text-input/combobox, not field. + field({ size: 'small' }); + // @ts-expect-error `icon`/`label` are the only necessity indicators. + field({ necessityIndicator: 'asterisk' }); + // @ts-expect-error `tone` is a field variant, not a text-input one. + textInput({ tone: 'error' }); + // @ts-expect-error `large` is not a text-input size. + textInput({ size: 'large' }); +}); + +// Type assertions are compile-time only. +// oxlint-disable-next-line vitest/expect-expect +test('slot functions take only an optional class string, never variant args', () => { + const slots = field({ tone: 'error' }); + + expectTypeOf(slots.root).toBeCallableWith('extra'); + expectTypeOf(slots.root).toBeCallableWith(); + + // Negative cases are compile-time only; this closure is never called so the + // invalid arguments are type-checked without reaching `cx` at runtime. + const rejectsVariantArgs = () => { + // @ts-expect-error Slot functions do not accept variant selections. + slots.root({ tone: 'error' }); + // @ts-expect-error Slot functions take a class string, not a number. + slots.label(42); + }; + void rejectsVariantArgs; +}); + +// Type assertions are compile-time only. +// oxlint-disable-next-line vitest/expect-expect +test('internal ComboboxVariants type is the combobox size selection', () => { + const variants: ComboboxVariants = { size: 'medium' }; + expectTypeOf(variants.size).toEqualTypeOf<'medium' | 'small' | undefined>(); +}); diff --git a/packages/@luke-ui/react/src/recipes/recipe.ts b/packages/@luke-ui/react/src/recipes/recipe.ts new file mode 100644 index 00000000..40f4fc68 --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/recipe.ts @@ -0,0 +1,457 @@ +import type { StyleRule } from '@vanilla-extract/css'; +import { addFunctionSerializer } from '@vanilla-extract/css/functionSerializer'; +import { recipe as vanillaRecipe } from '@vanilla-extract/recipes'; +import type { DistributiveOmit } from '../types/distributive-omit.js'; +import { cx } from '../utils/index.js'; + +/** + * `recipe()` styling helper for Vanilla Extract. + * + * One helper builds both single-part and multi-part (slotted) recipes and emits + * static CSS in the `recipes` cascade layer. You pick variants at the outer call. + * A single-part recipe returns a class string. A multi-part recipe returns one + * function per slot, each taking an optional extra class to merge. + * + * `recipe()` runs at build time inside a `.css.ts` module. It splits a slotted + * config into one recipe per slot, in declaration order, so its generated CSS and + * class names match hand-written per-slot recipes byte-for-byte, and it applies the + * `recipeInLayer` wrapping inline. Vanilla Extract only serialises `.css.ts` + * exports, and a plain `.ts` helper cannot import a value from a function-exporting + * `.css.ts` (such as `layered-style`) without turning that module into a failing + * serialization boundary, so the `recipes`-layer wrapping is applied here directly + * over `layers.recipes`. The returned function is registered with Vanilla Extract's + * function serializer so it survives the `.css.ts` build boundary, and + * `createRecipe`/`createSingleRecipe` rebuild it at runtime. + */ + +// --------------------------------------------------------------------------- +// Config surface (types) +// --------------------------------------------------------------------------- + +/** A style rule authored for a recipe: a layered style object or a pre-built class string. */ +type RecipeStyleRule = DistributiveOmit | string; + +/** Maps the string variant keys `'true'`/`'false'` onto `boolean` for selection. */ +type BooleanMap = T extends 'true' | 'false' ? boolean : T; + +/** Variant groups for a single-part recipe: group name to value name to style rule. */ +type VariantGroups = Record>; + +/** Outer selection for a single-part recipe. */ +type VariantSelection = { + -readonly [Group in keyof Variants]?: BooleanMap | undefined; +}; + +/** A compound variant for a single-part recipe. */ +interface CompoundVariant { + variants: VariantSelection; + style: RecipeStyleRule; +} + +/** Single-part recipe config. */ +interface SinglePartConfig { + base?: RecipeStyleRule; + variants?: Variants; + defaultVariants?: VariantSelection; + compoundVariants?: Array>; + extend?: SinglePartConfig; +} + +/** The runtime function a single-part `recipe()` returns. */ +type SinglePartRecipe = ( + selection?: VariantSelection, +) => string; + +/** A per-slot style map: slot name to style for that slot. */ +type SlotStyles = Partial>; + +/** Variant groups for a slotted recipe: group to value to per-slot styles. */ +type SlotVariantGroups = Record>>; + +/** Outer selection for a slotted recipe. */ +type SlotVariantSelection> = { + -readonly [Group in keyof Variants]?: BooleanMap | undefined; +}; + +/** A compound variant for a slotted recipe, whose style is keyed by slot. */ +interface SlotCompoundVariant> { + variants: SlotVariantSelection; + style: SlotStyles; +} + +/** Slotted recipe config. */ +interface MultiPartConfig> { + slots: Record; + variants?: Variants; + defaultVariants?: SlotVariantSelection; + compoundVariants?: Array>; + extend?: MultiPartConfig>; +} + +/** A single slot function: takes an optional extra class and returns a class string. */ +type SlotFn = (extraClass?: string) => string; + +/** The runtime function a slotted `recipe()` returns. */ +type MultiPartRecipe> = ( + selection?: SlotVariantSelection, +) => Record; + +/** A raw slotted config as accepted by `recipe`. Internal to this module. */ +type AnyMultiPartConfig = MultiPartConfig>; + +/** + * Authoring constraint for a slotted config. Apply it with + * `{ … } as const satisfies SlottedConfigInput` at the definition site: `as const` + * keeps the literal slot names and variant values that `recipe()` infers, while + * `satisfies` type-checks every slot and variant style against `StyleRule` (so a + * mistyped CSS property is caught where it is written, not silently accepted by + * `recipe()`'s structural inference). + */ +export interface SlottedConfigInput { + slots: Record; + variants?: Record>>; + defaultVariants?: Record; + compoundVariants?: ReadonlyArray<{ + variants: Record; + style: Record; + }>; +} + +/** Derives the outer variant selection type for a built recipe. */ +export type RecipeSelection = Fn extends (selection?: infer Selection) => unknown + ? NonNullable + : never; + +// --------------------------------------------------------------------------- +// recipe (build time) +// --------------------------------------------------------------------------- + +/** Builds a slotted recipe (variant selection at the outer call, one function per slot). */ +export function recipe>( + config: MultiPartConfig, +): MultiPartRecipe; + +/** Builds a single-part recipe (variant selection at the outer call, returns a class string). */ +export function recipe( + config: SinglePartConfig, +): SinglePartRecipe; + +export function recipe(config: AnyMultiPartConfig | SinglePartConfig): unknown { + if (isMultiPart(config)) { + const descriptor = buildSlottedDescriptor(config); + const fn = createRecipe(descriptor); + registerSerializer(fn, 'createRecipe', [descriptor]); + return fn; + } + + const built = buildSinglePart(config); + const fn = createSingleRecipe(built); + registerSerializer(fn, 'createSingleRecipe', [built]); + return fn; +} + +// --------------------------------------------------------------------------- +// Build helpers +// --------------------------------------------------------------------------- + +const SERIALIZER_IMPORT_PATH = './recipe.js'; + +/** The `args` position of `addFunctionSerializer`'s config. */ +type SerializerArgs = Parameters[1]['args']; + +/** + * Registers a runtime constructor with Vanilla Extract's function serializer. The + * args carry built recipe runtime functions (each marked by the serializer) + * alongside plain descriptor data. They serialise correctly at build time even + * though the `Serializable` arg type cannot express the recipe functions, so the + * array is bridged to that type here. + */ +function registerSerializer(fn: object, importName: string, args: ReadonlyArray): void { + addFunctionSerializer(fn, { + importPath: SERIALIZER_IMPORT_PATH, + importName, + args: args as SerializerArgs, + }); +} + +function buildSinglePart(config: SinglePartConfig): BuiltRecipe { + const resolved = config.extend ? mergeSingleConfigs(config.extend, config) : config; + + return recipeInRecipesLayer({ + ...(resolved.base === undefined ? {} : { base: resolved.base }), + ...(resolved.variants === undefined ? {} : { variants: resolved.variants }), + ...(resolved.defaultVariants === undefined + ? {} + : { defaultVariants: resolved.defaultVariants }), + ...(resolved.compoundVariants === undefined + ? {} + : { compoundVariants: resolved.compoundVariants }), + }); +} + +function mergeSingleConfigs( + base: SinglePartConfig, + override: SinglePartConfig, +): SinglePartConfig { + const variants: VariantGroups = { ...base.variants }; + if (override.variants !== undefined) { + for (const [group, values] of Object.entries(override.variants)) { + variants[group] = { ...variants[group], ...values }; + } + } + + const mergedBase = override.base ?? base.base; + + return { + ...(mergedBase === undefined ? {} : { base: mergedBase }), + ...(Object.keys(variants).length > 0 ? { variants } : {}), + defaultVariants: { ...base.defaultVariants, ...override.defaultVariants }, + compoundVariants: [...(base.compoundVariants ?? []), ...(override.compoundVariants ?? [])], + }; +} + +function buildSlottedDescriptor(config: AnyMultiPartConfig): SlottedRecipeDescriptor { + const resolved = config.extend ? mergeConfigs(config.extend, withoutExtend(config)) : config; + + const slotNames = Object.keys(resolved.slots); + const slots: Record = {}; + const slotGroups: Record> = {}; + + for (const slotName of slotNames) { + const variants: Record> = {}; + const groupsForSlot: Array = []; + + if (resolved.variants !== undefined) { + for (const [group, values] of Object.entries(resolved.variants)) { + const slotValues: Record = {}; + let hasSlot = false; + + for (const [value, slotStyles] of Object.entries(values)) { + const style = slotStyles[slotName]; + if (style !== undefined) { + slotValues[value] = style; + hasSlot = true; + } + } + + if (hasSlot) { + variants[group] = slotValues; + groupsForSlot.push(group); + } + } + } + + const compoundVariants: Array<{ variants: Record; style: RecipeStyleRule }> = + []; + if (resolved.compoundVariants !== undefined) { + for (const compound of resolved.compoundVariants) { + const style = compound.style[slotName]; + if (style !== undefined) { + compoundVariants.push({ variants: compound.variants, style }); + } + } + } + + const defaultVariants = pickGroups(resolved.defaultVariants, groupsForSlot); + + slots[slotName] = recipeInRecipesLayer({ + base: resolved.slots[slotName], + ...(groupsForSlot.length > 0 ? { variants } : {}), + ...(defaultVariants !== undefined && Object.keys(defaultVariants).length > 0 + ? { defaultVariants } + : {}), + ...(compoundVariants.length > 0 ? { compoundVariants } : {}), + }); + slotGroups[slotName] = groupsForSlot; + } + + return { slots, slotGroups }; +} + +// --------------------------------------------------------------------------- +// Layer wrapping (mirrors `recipeInLayer` for the `recipes` layer) +// --------------------------------------------------------------------------- + +type LayeredStyleRule = DistributiveOmit; + +// `layers.recipes` from `styles/layers.css.ts` resolves to this literal global +// layer name. It is inlined rather than imported because importing a value from a +// `.css.ts` into this plain `.ts` would turn `layers.css.ts` into a serialization +// boundary and re-emit its `@layer` declarations. The byte-comparison build +// proves this matches the shipped `@layer recipes { … }` wrapping. +const RECIPES_LAYER = 'recipes'; + +interface RecipeInLayerOptions { + base?: RecipeStyleRule; + variants?: Record>; + defaultVariants?: Record; + compoundVariants?: Array<{ variants: Record; style: RecipeStyleRule }>; +} + +function withRecipesLayer(rule: LayeredStyleRule): StyleRule { + return { '@layer': { [RECIPES_LAYER]: rule } }; +} + +function withLayerIfStyleRule(styleRule: RecipeStyleRule): RecipeStyleRule { + return typeof styleRule === 'string' ? styleRule : withRecipesLayer(styleRule); +} + +/** Builds a Vanilla Extract recipe with every style wrapped in the `recipes` layer. */ +function recipeInRecipesLayer(options: RecipeInLayerOptions): BuiltRecipe { + const layeredVariants = + options.variants === undefined + ? undefined + : Object.fromEntries( + Object.entries(options.variants).map(([variantName, variantValues]) => [ + variantName, + Object.fromEntries( + Object.entries(variantValues).map(([variantValue, styleRule]) => [ + variantValue, + withLayerIfStyleRule(styleRule), + ]), + ), + ]), + ); + + const layeredCompoundVariants = + options.compoundVariants === undefined + ? undefined + : options.compoundVariants.map((compound) => ({ + ...compound, + style: withLayerIfStyleRule(compound.style), + })); + + // `vanillaRecipe`'s generic infers a variant map from a statically-known config. + // This helper assembles the config dynamically (per slot, with layered rules), so + // the input is bridged through `never` and the result through the recipe's own + // runtime contract, `BuiltRecipe`. + const built = vanillaRecipe({ + ...options, + ...(options.base === undefined ? {} : { base: withLayerIfStyleRule(options.base) }), + ...(layeredVariants === undefined ? {} : { variants: layeredVariants }), + ...(layeredCompoundVariants === undefined ? {} : { compoundVariants: layeredCompoundVariants }), + } as never); + + return built as BuiltRecipe; +} + +// --------------------------------------------------------------------------- +// extend (single-base inheritance) +// --------------------------------------------------------------------------- + +function isMultiPart( + config: AnyMultiPartConfig | SinglePartConfig, +): config is AnyMultiPartConfig { + return 'slots' in config && isObject(config.slots); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function withoutExtend(config: AnyMultiPartConfig): AnyMultiPartConfig { + const { extend: _extend, ...rest } = config; + return rest; +} + +/** + * Merges a slotted config's single `extend` base into it. Slots, variant groups, + * variant values, per-slot variant styles, and default variants are combined. The + * extending config takes precedence on same-named keys, and compound variants are + * concatenated. Internal to `extend` resolution. + */ +function mergeConfigs(...configs: Array): AnyMultiPartConfig { + const slots: Record = {}; + const variants: Record>> = {}; + const defaultVariants: SlotVariantSelection> = {}; + const compoundVariants: Array>> = []; + + for (const config of configs) { + const resolved = config.extend ? mergeConfigs(config.extend, withoutExtend(config)) : config; + + Object.assign(slots, resolved.slots); + + if (resolved.variants !== undefined) { + for (const [group, values] of Object.entries(resolved.variants)) { + const mergedGroup = variants[group] ?? {}; + for (const [value, slotStyles] of Object.entries(values)) { + mergedGroup[value] = { ...mergedGroup[value], ...slotStyles }; + } + variants[group] = mergedGroup; + } + } + + if (resolved.defaultVariants !== undefined) { + Object.assign(defaultVariants, resolved.defaultVariants); + } + + if (resolved.compoundVariants !== undefined) { + compoundVariants.push(...resolved.compoundVariants); + } + } + + return { + slots, + ...(Object.keys(variants).length > 0 ? { variants } : {}), + ...(Object.keys(defaultVariants).length > 0 ? { defaultVariants } : {}), + ...(compoundVariants.length > 0 ? { compoundVariants } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Runtime (referenced by the function serializer at import time) +// --------------------------------------------------------------------------- + +/** A built Vanilla Extract recipe runtime function (one per slot, or the whole single-part recipe). */ +type BuiltRecipe = (selection?: Record) => string; + +/** Serialized descriptor for a slotted recipe: per-slot runtime fns and their variant groups. */ +interface SlottedRecipeDescriptor { + slots: Record; + slotGroups: Record>; +} + +/** Narrows an outer selection to the variant groups a given slot actually uses. */ +function pickGroups( + selection: Record | undefined, + groups: ReadonlyArray, +): Record | undefined { + if (selection === undefined) return undefined; + + const picked: Record = {}; + for (const group of groups) { + if (group in selection) picked[group] = selection[group]; + } + return picked; +} + +/** + * Rebuilds a slotted recipe: `recipe(selection)` returns one function per slot, + * each taking an optional extra class. Slots evaluate lazily, so reading one slot + * does not compute the others. + * + * @public Imported by path string via Vanilla Extract's function serializer, so + * the reference is invisible to static analysis. + */ +export function createRecipe(descriptor: SlottedRecipeDescriptor) { + const slotEntries = Object.entries(descriptor.slots); + + return (selection?: Record): Record => { + const slots: Record = {}; + for (const [slotName, built] of slotEntries) { + const groups = descriptor.slotGroups[slotName] ?? []; + slots[slotName] = (extraClass) => cx(built(pickGroups(selection, groups)), extraClass); + } + return slots; + }; +} + +/** + * Rebuilds a single-part recipe: `recipe(selection)` returns a class string. + * + * @public Imported by path string via Vanilla Extract's function serializer, so + * the reference is invisible to static analysis. + */ +export function createSingleRecipe(built: BuiltRecipe) { + return (selection?: Record): string => built(selection); +} diff --git a/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts b/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts index 1c35c775..8df17b94 100644 --- a/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts @@ -3,7 +3,7 @@ import { afterEach, expect, test } from 'vite-plus/test'; import { themeRootClassName } from '../theme/index.js'; import { tactileThemeClassName } from '../themes/index.js'; import { cx } from '../utils/index.js'; -import { textInputAdornmentStart, textInputGroup } from './text-input.css.js'; +import { textInput } from './text-input.css.js'; let mounted: Array = []; @@ -126,7 +126,7 @@ test('read-only still shows the focus ring since read-only fields remain focusab test('adornment divider uses the control border color and disabled text color follows the group', () => { const { group, root } = mountGroup(); const adornment = group.appendChild(document.createElement('span')); - adornment.className = textInputAdornmentStart({ size: 'medium' }); + adornment.className = textInput({ size: 'medium' }).adornmentStart(); const controlBorderProbe = root.appendChild(document.createElement('div')); controlBorderProbe.style.borderColor = 'var(--luke-color-border-control)'; @@ -140,12 +140,12 @@ test('adornment divider uses the control border color and disabled text color fo expect(getComputedStyle(adornment).color).toBe(getComputedStyle(disabledTextProbe).color); }); -function mountGroup(options: Parameters[0] = {}) { +function mountGroup(options: Parameters[0] = {}) { const root = document.body.appendChild(document.createElement('div')); root.className = cx(themeRootClassName, tactileThemeClassName); root.dataset.colorMode = 'light'; const group = root.appendChild(document.createElement('div')); - group.className = textInputGroup(options); + group.className = textInput(options).group(); group.style.transition = 'none'; mounted.push(root); return { group, root }; diff --git a/packages/@luke-ui/react/src/recipes/text-input.css.ts b/packages/@luke-ui/react/src/recipes/text-input.css.ts index 2d2bbe33..fcc67f36 100644 --- a/packages/@luke-ui/react/src/recipes/text-input.css.ts +++ b/packages/@luke-ui/react/src/recipes/text-input.css.ts @@ -1,181 +1,161 @@ -import type { RecipeVariants } from '@vanilla-extract/recipes'; import { focusRing } from '../styles/focus-ring.js'; -import { recipeInLayer } from '../styles/layered-style.css.js'; import { vars } from '../theme/contract.css.js'; import { composeInputStateSelectors, descendantDisabledSelector, inputStates, } from './input-states.css.js'; +import type { RecipeSelection, SlottedConfigInput } from './recipe.js'; +import { recipe } from './recipe.js'; const { disabled, focusWithin, hover, invalid, invalidFocusWithin, readOnly, readOnlyFocusWithin } = composeInputStateSelectors(inputStates); -/** Vanilla-extract recipe for the `TextInput` group's tactile well chrome. */ -export const textInputGroup = recipeInLayer('recipes', { - base: { - '@media': { - '(forced-colors: active)': { - backgroundColor: 'Field', - borderColor: 'FieldText', - boxShadow: 'none', - color: 'FieldText', - forcedColorAdjust: 'auto', - selectors: { - [disabled]: { - borderColor: 'GrayText', - color: 'GrayText', - opacity: 1, - }, - [focusWithin]: { - outlineColor: 'Highlight', - }, - [invalidFocusWithin]: { - outlineColor: 'Highlight', +/** + * Raw slotted config for the `TextInput` primitive. + * + * Slots: `group` (tactile well chrome), `control` (the input), and + * `adornmentStart` / `adornmentEnd`. + */ +const textInputConfig = { + slots: { + group: { + '@media': { + '(forced-colors: active)': { + backgroundColor: 'Field', + borderColor: 'FieldText', + boxShadow: 'none', + color: 'FieldText', + forcedColorAdjust: 'auto', + selectors: { + [disabled]: { + borderColor: 'GrayText', + color: 'GrayText', + opacity: 1, + }, + [focusWithin]: { + outlineColor: 'Highlight', + }, + [invalidFocusWithin]: { + outlineColor: 'Highlight', + }, }, }, }, - }, - alignItems: 'center', - backgroundColor: vars.color.surface.recessed, - borderColor: vars.color.border.control, - borderRadius: vars.radius.control, - borderStyle: 'solid', - borderWidth: '1px', - boxShadow: vars.depth.recessed, - cursor: 'text', - display: 'inline-flex', - fontFamily: vars.font.family, - inlineSize: '100%', - isolation: 'isolate', - letterSpacing: vars.font[300].letterSpacing, - lineHeight: vars.font[300].lineHeight, - minInlineSize: 0, - outlineColor: 'transparent', - outlineOffset: 0, - outlineStyle: 'solid', - outlineWidth: '2px', - overflow: 'visible', - transitionDuration: vars.motion.duration.fast, - transitionProperty: 'background-color, border-color, color', - transitionTimingFunction: vars.motion.easing.standard, + alignItems: 'center', + backgroundColor: vars.color.surface.recessed, + borderColor: vars.color.border.control, + borderRadius: vars.radius.control, + borderStyle: 'solid', + borderWidth: '1px', + boxShadow: vars.depth.recessed, + cursor: 'text', + display: 'inline-flex', + fontFamily: vars.font.family, + inlineSize: '100%', + isolation: 'isolate', + letterSpacing: vars.font[300].letterSpacing, + lineHeight: vars.font[300].lineHeight, + minInlineSize: 0, + outlineColor: 'transparent', + outlineOffset: 0, + outlineStyle: 'solid', + outlineWidth: '2px', + overflow: 'visible', + transitionDuration: vars.motion.duration.fast, + transitionProperty: 'background-color, border-color, color', + transitionTimingFunction: vars.motion.easing.standard, - selectors: { - [disabled]: { - cursor: 'not-allowed', - opacity: 0.55, - }, - [focusWithin]: { - borderColor: vars.color.intent.accent.border, - ...focusRing(vars.color.border.focus), - }, - [hover]: { - borderColor: vars.color.intent.accent.border, - }, - [invalid]: { - borderColor: vars.color.intent.danger.border, - }, - [invalidFocusWithin]: { - borderColor: vars.color.intent.danger.border, - ...focusRing(vars.color.border.focus), - }, - [readOnly]: { - backgroundColor: vars.color.surface.canvas, - borderColor: vars.color.border.decorative, - boxShadow: 'none', - }, - [readOnlyFocusWithin]: { - ...focusRing(vars.color.border.focus), - }, - }, - }, - defaultVariants: { - size: 'medium', - }, - variants: { - size: { - medium: { - blockSize: vars.controlSize.medium, - fontSize: vars.font[300].fontSize, - }, - small: { - blockSize: vars.controlSize.small, - fontSize: vars.font[200].fontSize, - letterSpacing: vars.font[200].letterSpacing, - lineHeight: vars.font[200].lineHeight, + selectors: { + [disabled]: { + cursor: 'not-allowed', + opacity: 0.55, + }, + [focusWithin]: { + borderColor: vars.color.intent.accent.border, + ...focusRing(vars.color.border.focus), + }, + [hover]: { + borderColor: vars.color.intent.accent.border, + }, + [invalid]: { + borderColor: vars.color.intent.danger.border, + }, + [invalidFocusWithin]: { + borderColor: vars.color.intent.danger.border, + ...focusRing(vars.color.border.focus), + }, + [readOnly]: { + backgroundColor: vars.color.surface.canvas, + borderColor: vars.color.border.decorative, + boxShadow: 'none', + }, + [readOnlyFocusWithin]: { + ...focusRing(vars.color.border.focus), + }, }, }, - }, -}); + control: { + appearance: 'none', + backgroundColor: 'transparent', + borderColor: 'transparent', + borderStyle: 'none', + borderWidth: 0, + color: vars.color.text.primary, + cursor: 'text', + flex: 1, + fontFamily: 'inherit', + fontSize: 'inherit', + fontWeight: 'inherit', + inlineSize: '100%', + letterSpacing: 'inherit', + lineHeight: 'inherit', + minInlineSize: 0, + outlineColor: 'transparent', + outlineStyle: 'none', + outlineWidth: 0, + paddingBlockEnd: 0, + paddingBlockStart: 0, -/** Vanilla-extract recipe for the `TextInput` control styles. */ -export const textInputControl = recipeInLayer('recipes', { - base: { - appearance: 'none', - backgroundColor: 'transparent', - borderColor: 'transparent', - borderStyle: 'none', - borderWidth: 0, - color: vars.color.text.primary, - cursor: 'text', - flex: 1, - fontFamily: 'inherit', - fontSize: 'inherit', - fontWeight: 'inherit', - inlineSize: '100%', - letterSpacing: 'inherit', - lineHeight: 'inherit', - minInlineSize: 0, - outlineColor: 'transparent', - outlineStyle: 'none', - outlineWidth: 0, - paddingBlockEnd: 0, - paddingBlockStart: 0, - - selectors: { - '&::placeholder': { - color: vars.color.text.secondary, - opacity: 1, - }, - '&:where([data-disabled="true"], :disabled)': { - color: vars.color.textDisabled, - cursor: 'not-allowed', + selectors: { + '&::placeholder': { + color: vars.color.text.secondary, + opacity: 1, + }, + '&:where([data-disabled="true"], :disabled)': { + color: vars.color.textDisabled, + cursor: 'not-allowed', + }, }, }, - }, - defaultVariants: { - size: 'medium', - }, - variants: { - size: { - medium: { - blockSize: vars.controlSize.medium, - paddingInlineEnd: vars.space[300], - paddingInlineStart: vars.space[300], - }, - small: { - blockSize: vars.controlSize.small, - paddingInlineEnd: vars.space[200], - paddingInlineStart: vars.space[200], + adornmentStart: { + alignItems: 'center', + borderInlineEndColor: vars.color.border.control, + borderInlineEndStyle: 'solid', + borderInlineEndWidth: '1px', + color: vars.color.text.secondary, + display: 'inline-flex', + flexShrink: 0, + + selectors: { + [descendantDisabledSelector]: { + color: vars.color.textDisabled, + }, }, }, - }, -}); - -/** Vanilla-extract recipe for the `TextInput` start-adornment styles. */ -export const textInputAdornmentStart = recipeInLayer('recipes', { - base: { - alignItems: 'center', - borderInlineEndColor: vars.color.border.control, - borderInlineEndStyle: 'solid', - borderInlineEndWidth: '1px', - color: vars.color.text.secondary, - display: 'inline-flex', - flexShrink: 0, + adornmentEnd: { + alignItems: 'center', + borderInlineStartColor: vars.color.border.control, + borderInlineStartStyle: 'solid', + borderInlineStartWidth: '1px', + color: vars.color.text.secondary, + display: 'inline-flex', + flexShrink: 0, - selectors: { - [descendantDisabledSelector]: { - color: vars.color.textDisabled, + selectors: { + [descendantDisabledSelector]: { + color: vars.color.textDisabled, + }, }, }, }, @@ -185,54 +165,62 @@ export const textInputAdornmentStart = recipeInLayer('recipes', { variants: { size: { medium: { - lineHeight: vars.font[300].lineHeight, - paddingInlineEnd: vars.space[300], - paddingInlineStart: vars.space[300], + group: { + blockSize: vars.controlSize.medium, + fontSize: vars.font[300].fontSize, + }, + control: { + blockSize: vars.controlSize.medium, + paddingInlineEnd: vars.space[300], + paddingInlineStart: vars.space[300], + }, + adornmentStart: { + lineHeight: vars.font[300].lineHeight, + paddingInlineEnd: vars.space[300], + paddingInlineStart: vars.space[300], + }, + adornmentEnd: { + lineHeight: vars.font[300].lineHeight, + paddingInlineEnd: vars.space[300], + paddingInlineStart: vars.space[300], + }, }, small: { - lineHeight: vars.font[200].lineHeight, - paddingInlineEnd: vars.space[200], - paddingInlineStart: vars.space[200], + group: { + blockSize: vars.controlSize.small, + fontSize: vars.font[200].fontSize, + letterSpacing: vars.font[200].letterSpacing, + lineHeight: vars.font[200].lineHeight, + }, + control: { + blockSize: vars.controlSize.small, + paddingInlineEnd: vars.space[200], + paddingInlineStart: vars.space[200], + }, + adornmentStart: { + lineHeight: vars.font[200].lineHeight, + paddingInlineEnd: vars.space[200], + paddingInlineStart: vars.space[200], + }, + adornmentEnd: { + lineHeight: vars.font[200].lineHeight, + paddingInlineEnd: vars.space[200], + paddingInlineStart: vars.space[200], + }, }, }, }, -}); +} as const satisfies SlottedConfigInput; -/** Vanilla-extract recipe for the `TextInput` end-adornment styles. */ -export const textInputAdornmentEnd = recipeInLayer('recipes', { - base: { - alignItems: 'center', - borderInlineStartColor: vars.color.border.control, - borderInlineStartStyle: 'solid', - borderInlineStartWidth: '1px', - color: vars.color.text.secondary, - display: 'inline-flex', - flexShrink: 0, +/** + * Slotted recipe for the `TextInput` primitive. + * + * `textInput({ size }).group() / .control() / .adornmentStart() / .adornmentEnd()`. + */ +export const textInput = recipe(textInputConfig); - selectors: { - [descendantDisabledSelector]: { - color: vars.color.textDisabled, - }, - }, - }, - defaultVariants: { - size: 'medium', - }, - variants: { - size: { - medium: { - lineHeight: vars.font[300].lineHeight, - paddingInlineEnd: vars.space[300], - paddingInlineStart: vars.space[300], - }, - small: { - lineHeight: vars.font[200].lineHeight, - paddingInlineEnd: vars.space[200], - paddingInlineStart: vars.space[200], - }, - }, - }, -}); +/** Outer variant selection for the `TextInput` recipe. */ +export type TextInputVariants = RecipeSelection; -/** Variant type for the `TextInput` recipe. */ -export type TextInputVariants = RecipeVariants; +/** Allowed `size` values for the `TextInput` recipe. */ +export type TextInputSize = keyof typeof textInputConfig.variants.size; diff --git a/packages/@luke-ui/react/src/text-field/primitive/index.tsx b/packages/@luke-ui/react/src/text-field/primitive/index.tsx index c8781904..fdf44602 100644 --- a/packages/@luke-ui/react/src/text-field/primitive/index.tsx +++ b/packages/@luke-ui/react/src/text-field/primitive/index.tsx @@ -7,21 +7,18 @@ import { composeRenderProps } from 'react-aria-components/composeRenderProps'; import * as styles from '../../recipes/text-input.css.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; -import { cx } from '../../utils/index.js'; -interface TextInputVariantProps extends NonNullable {} +/** Allowed `size` values for `TextInput`. */ +export type TextInputSize = styles.TextInputSize; interface TextInputStyleProps { /** * Sets the input size. * @default 'medium' */ - size?: TextInputVariantProps['size']; + size?: TextInputSize; } -/** Allowed `size` values for `TextInput`. */ -export type TextInputSize = NonNullable; - type _TextInputOmit = DistributiveOmit; interface _TextInputProps extends _TextInputOmit, TextInputStyleProps { /** Element shown at the end of the control. */ @@ -52,24 +49,24 @@ export function TextInput(props: TextInputProps): JSX.Element { ...inputProps } = props; + const slots = styles.textInput({ size }); + return ( { - return cx(styles.textInputGroup({ size }), value); + return slots.group(value); })} > {adornmentStart != null ? ( - {adornmentStart} + {adornmentStart} ) : null} { - return cx(styles.textInputControl({ size }), value); + return slots.control(value); })} /> - {adornmentEnd != null ? ( - {adornmentEnd} - ) : null} + {adornmentEnd != null ? {adornmentEnd} : null} ); } -- 2.51.2