[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. luke-ui.netlify.app/
0

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Luke Bennett and committed by
GitHub
(Jul 23, 2026, 4:58 PM +1000) 63bf7972 9194984a

+1170 -742
-5
.changeset/fix-tray-keyboard-inset.md
··· 1 - --- 2 - '@luke-ui/react': patch 3 - --- 4 - 5 - Fix the mobile tray sitting behind the iOS on-screen keyboard.
-5
.changeset/spectrum-combobox-tray.md
··· 1 - --- 2 - '@luke-ui/react': minor 3 - --- 4 - 5 - Add a Spectrum-style bottom tray for the combobox popover on small viewports.
-7
.changeset/tactile-action-controls.md
··· 1 - --- 2 - '@luke-ui/react': minor 3 - --- 4 - 5 - Replace the Button and IconButton appearance API with semantic `tone` and `appearance` options, 6 - defaulting to neutral and solid, and expose the theme foundation contract used to author their depth 7 - and face finish.
+1 -2
packages/@luke-ui/react/src/combobox-field/primitive/clear-button.tsx
··· 8 8 import { COMBOBOX_ICON_SIZE } from '../../sizing/combobox-sizing.js'; 9 9 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 10 10 import type { Prettify } from '../../types/prettify.js'; 11 - import { cx } from '../../utils/index.js'; 12 11 import type { ComboboxSize } from './root.js'; 13 12 import { useComboboxSize } from './size-context.js'; 14 13 ··· 41 40 <RacButton 42 41 {...buttonProps} 43 42 className={composeRenderProps(buttonProps.className, (className) => { 44 - return cx(styles.comboboxClearButton({ size }), className); 43 + return styles.combobox({ size }).clearButton(className); 45 44 })} 46 45 onPress={(event) => { 47 46 state.setValue(Array.isArray(state.value) ? [] : null);
+1 -2
packages/@luke-ui/react/src/combobox-field/primitive/control.tsx
··· 5 5 import * as styles from '../../recipes/combobox.css.js'; 6 6 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 7 7 import type { Prettify } from '../../types/prettify.js'; 8 - import { cx } from '../../utils/index.js'; 9 8 import type { ComboboxSize } from './root.js'; 10 9 import { useComboboxSize } from './size-context.js'; 11 10 ··· 31 30 <RacGroup 32 31 {...groupProps} 33 32 className={composeRenderProps(groupProps.className, (className) => { 34 - return cx(styles.comboboxControl({ size }), className); 33 + return styles.combobox({ size }).control(className); 35 34 })} 36 35 /> 37 36 );
+1 -2
packages/@luke-ui/react/src/combobox-field/primitive/empty-state.tsx
··· 1 1 import type { JSX, ReactNode } from 'react'; 2 2 import * as styles from '../../recipes/combobox.css.js'; 3 3 import type { Prettify } from '../../types/prettify.js'; 4 - import { cx } from '../../utils/index.js'; 5 4 6 5 interface _ComboboxEmptyStateProps { 7 6 children: ReactNode; ··· 18 17 export function ComboboxEmptyState(props: ComboboxEmptyStateProps): JSX.Element { 19 18 const { children, className } = props; 20 19 21 - return <div className={cx(styles.comboboxEmptyState(), className)}>{children}</div>; 20 + return <div className={styles.combobox().emptyState(className)}>{children}</div>; 22 21 }
+1 -2
packages/@luke-ui/react/src/combobox-field/primitive/input.tsx
··· 6 6 import * as styles from '../../recipes/combobox.css.js'; 7 7 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 8 8 import type { Prettify } from '../../types/prettify.js'; 9 - import { cx } from '../../utils/index.js'; 10 9 import type { ComboboxSize } from './root.js'; 11 10 import { useComboboxSize } from './size-context.js'; 12 11 ··· 40 39 <RacInput 41 40 {...inputProps} 42 41 className={composeRenderProps(inputProps.className, (className) => { 43 - return cx(styles.comboboxTextInput({ size }), className); 42 + return styles.combobox({ size }).textInput(className); 44 43 })} 45 44 onClick={handleClick} 46 45 />
+3 -4
packages/@luke-ui/react/src/combobox-field/primitive/item.tsx
··· 14 14 import { COMBOBOX_ICON_SIZE } from '../../sizing/combobox-sizing.js'; 15 15 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 16 16 import type { Prettify } from '../../types/prettify.js'; 17 - import { cx } from '../../utils/index.js'; 18 17 import type { ComboboxSize } from './root.js'; 19 18 import { useComboboxSize } from './size-context.js'; 20 19 ··· 44 43 textValue={typeof itemProps.children === 'string' ? itemProps.children : undefined} 45 44 {...itemProps} 46 45 className={composeRenderProps(itemProps.className, (className) => { 47 - return cx(styles.comboboxItem({ size }), className); 46 + return styles.combobox({ size }).item(className); 48 47 })} 49 48 > 50 49 {composeRenderProps(itemProps.children, (children, { isSelected }) => { ··· 52 51 <> 53 52 {children} 54 53 {isSelected ? ( 55 - <Icon aria-hidden className={styles.comboboxItemCheck} name="check" /> 54 + <Icon aria-hidden className={styles.combobox().itemCheck()} name="check" /> 56 55 ) : null} 57 56 </> 58 57 ); ··· 82 81 return ( 83 82 <RacListBoxLoadMoreItem 84 83 {...loadMoreItemProps} 85 - className={cx(styles.comboboxLoadMoreItem({ size }), loadMoreItemProps.className)} 84 + className={styles.combobox({ size }).loadMoreItem(loadMoreItemProps.className)} 86 85 /> 87 86 ); 88 87 }
+1 -2
packages/@luke-ui/react/src/combobox-field/primitive/listbox.tsx
··· 8 8 import * as styles from '../../recipes/combobox.css.js'; 9 9 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 10 10 import type { Prettify } from '../../types/prettify.js'; 11 - import { cx } from '../../utils/index.js'; 12 11 13 12 type _ComboboxListBoxOmit<T extends object> = DistributiveOmit< 14 13 RacListBoxProps<T>, ··· 51 50 <RacListBox 52 51 {...listBoxProps} 53 52 className={composeRenderProps(listBoxProps.className, (className) => { 54 - return cx(styles.comboboxListBox(), className); 53 + return styles.combobox().listBox(className); 55 54 })} 56 55 > 57 56 {listBoxChildren}
+1 -1
packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx
··· 37 37 <RacPopover 38 38 {...restProps} 39 39 className={composeRenderProps(restProps.className, (className) => { 40 - return cx(themeRootClassName, styles.comboboxPopover(), className); 40 + return cx(themeRootClassName, styles.combobox().popover(className)); 41 41 })} 42 42 ref={mergeRefs(ref, (node: HTMLElement | null) => { 43 43 setElement(node);
+2 -5
packages/@luke-ui/react/src/combobox-field/primitive/root.tsx
··· 5 5 import * as styles from '../../recipes/combobox.css.js'; 6 6 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 7 7 import type { Prettify } from '../../types/prettify.js'; 8 - import { cx } from '../../utils/index.js'; 9 8 import { ComboboxSizeProvider } from './size-context.js'; 10 9 11 - interface ComboboxVariantProps extends NonNullable<styles.ComboboxVariants> {} 12 - 13 - export type ComboboxSize = NonNullable<ComboboxVariantProps['size']>; 10 + export type ComboboxSize = styles.ComboboxSize; 14 11 15 12 type _ComboboxRootOmit<T extends object> = DistributiveOmit< 16 13 RacComboBoxProps<T, 'single'>, ··· 64 61 <RacComboBox 65 62 {...comboboxProps} 66 63 className={composeRenderProps(className, (renderedClassName) => { 67 - return cx(styles.comboboxRoot, renderedClassName); 64 + return styles.combobox().root(renderedClassName); 68 65 })} 69 66 menuTrigger={menuTrigger} 70 67 ref={ref}
+2 -3
packages/@luke-ui/react/src/combobox-field/primitive/section.tsx
··· 5 5 import * as styles from '../../recipes/combobox.css.js'; 6 6 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 7 7 import type { Prettify } from '../../types/prettify.js'; 8 - import { cx } from '../../utils/index.js'; 9 8 10 9 type _ComboboxSectionOmit<T extends object> = DistributiveOmit< 11 10 RacListBoxSectionProps<T>, ··· 27 26 export function ComboboxSection<T extends object>(props: ComboboxSectionProps<T>): JSX.Element { 28 27 const { children, className, title, ...sectionProps } = props; 29 28 30 - const sectionClassName = cx(styles.comboboxSection(), className); 29 + const sectionClassName = styles.combobox().section(className); 31 30 32 31 if (typeof children === 'function') { 33 32 return ( ··· 40 39 return ( 41 40 <RacListBoxSection {...sectionProps} className={sectionClassName}> 42 41 {title != null ? ( 43 - <RacHeader className={styles.comboboxSectionHeading}>{title}</RacHeader> 42 + <RacHeader className={styles.combobox().sectionHeading()}>{title}</RacHeader> 44 43 ) : null} 45 44 {children} 46 45 </RacListBoxSection>
+1 -2
packages/@luke-ui/react/src/combobox-field/primitive/trigger.tsx
··· 7 7 import { COMBOBOX_ICON_SIZE } from '../../sizing/combobox-sizing.js'; 8 8 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 9 9 import type { Prettify } from '../../types/prettify.js'; 10 - import { cx } from '../../utils/index.js'; 11 10 import type { ComboboxSize } from './root.js'; 12 11 import { useComboboxSize } from './size-context.js'; 13 12 ··· 34 33 <RacButton 35 34 {...buttonProps} 36 35 className={composeRenderProps(buttonProps.className, (className) => { 37 - return cx(styles.comboboxTrigger({ size }), className); 36 + return styles.combobox({ size }).trigger(className); 38 37 })} 39 38 /> 40 39 </IconSizeProvider>
+2 -7
packages/@luke-ui/react/src/field/primitive/description.tsx
··· 4 4 import * as styles from '../../recipes/field.css.js'; 5 5 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 6 6 import type { Prettify } from '../../types/prettify.js'; 7 - import { cx } from '../../utils/index.js'; 8 7 9 - interface FieldMessageVariantProps extends NonNullable<styles.FieldMessageVariants> {} 10 - 11 - type _FieldDescriptionOmit1 = DistributiveOmit<RacTextProps, 'slot'>; 12 - type _FieldDescriptionOmit2 = DistributiveOmit<FieldMessageVariantProps, 'tone'>; 13 - interface _FieldDescriptionProps extends _FieldDescriptionOmit1, _FieldDescriptionOmit2 {} 8 + type _FieldDescriptionProps = DistributiveOmit<RacTextProps, 'slot'>; 14 9 15 10 /** 16 11 * Props for `FieldDescription`. ··· 26 21 return ( 27 22 <RacText 28 23 {...restProps} 29 - className={cx(styles.fieldMessage({ tone: 'description' }), className)} 24 + className={styles.field({ tone: 'description' }).message(className)} 30 25 slot="description" 31 26 /> 32 27 );
+2 -9
packages/@luke-ui/react/src/field/primitive/error.tsx
··· 3 3 import { FieldError as RacFieldError } from 'react-aria-components/FieldError'; 4 4 import { composeRenderProps } from 'react-aria-components/composeRenderProps'; 5 5 import * as styles from '../../recipes/field.css.js'; 6 - import type { DistributiveOmit } from '../../types/distributive-omit.js'; 7 6 import type { Prettify } from '../../types/prettify.js'; 8 - import { cx } from '../../utils/index.js'; 9 - 10 - interface FieldMessageVariantProps extends NonNullable<styles.FieldMessageVariants> {} 11 - 12 - type _FieldErrorOmit = DistributiveOmit<FieldMessageVariantProps, 'tone'>; 13 - interface _FieldErrorProps extends RacFieldErrorProps, _FieldErrorOmit {} 14 7 15 8 /** 16 9 * Props for `FieldError`. 17 10 * 18 11 * @tier primitive 19 12 */ 20 - export type FieldErrorProps = Prettify<_FieldErrorProps>; 13 + export type FieldErrorProps = Prettify<RacFieldErrorProps>; 21 14 22 15 /** Styled validation message for a field. */ 23 16 export function FieldError(props: FieldErrorProps): JSX.Element { ··· 25 18 <RacFieldError 26 19 {...props} 27 20 className={composeRenderProps(props.className, (className) => { 28 - return cx(styles.fieldMessage({ tone: 'error' }), className); 21 + return styles.field({ tone: 'error' }).message(className); 29 22 })} 30 23 /> 31 24 );
+2 -5
packages/@luke-ui/react/src/field/primitive/label.tsx
··· 3 3 import { Label as RacLabel } from 'react-aria-components/Label'; 4 4 import * as styles from '../../recipes/field.css.js'; 5 5 import type { Prettify } from '../../types/prettify.js'; 6 - import { cx } from '../../utils/index.js'; 7 - 8 - interface FieldLabelVariantProps extends NonNullable<styles.FieldLabelVariants> {} 9 6 10 7 /** Allowed `necessityIndicator` values for `FieldLabel`. */ 11 - export type FieldNecessityIndicator = NonNullable<FieldLabelVariantProps['necessityIndicator']>; 8 + export type FieldNecessityIndicator = styles.FieldNecessityIndicator; 12 9 13 10 interface FieldLabelStyleProps { 14 11 /** Shows how required fields are marked. */ ··· 29 26 const { className, necessityIndicator = 'icon', ...restProps } = props; 30 27 31 28 return ( 32 - <RacLabel {...restProps} className={cx(styles.fieldLabel({ necessityIndicator }), className)} /> 29 + <RacLabel {...restProps} className={styles.field({ necessityIndicator }).label(className)} /> 33 30 ); 34 31 }
+1 -2
packages/@luke-ui/react/src/field/primitive/root.tsx
··· 1 1 import type { ComponentProps, JSX } from 'react'; 2 2 import * as styles from '../../recipes/field.css.js'; 3 - import { cx } from '../../utils/index.js'; 4 3 5 4 /** Props for the primitive field container. */ 6 5 type FieldProps = ComponentProps<'div'>; ··· 9 8 export function Field(props: FieldProps): JSX.Element { 10 9 const { className, ...restProps } = props; 11 10 12 - return <div {...restProps} className={cx(styles.field(), className)} />; 11 + return <div {...restProps} className={styles.field().root(className)} />; 13 12 }
+12 -18
packages/@luke-ui/react/src/recipes/combobox.browser.test.ts
··· 4 4 import { themeRootClassName } from '../theme/index.js'; 5 5 import { tactileThemeClassName } from '../themes/index.js'; 6 6 import { cx } from '../utils/index.js'; 7 - import { 8 - comboboxClearButton, 9 - comboboxControl, 10 - comboboxItem, 11 - comboboxPopover, 12 - comboboxTrigger, 13 - } from './combobox.css.js'; 7 + import { combobox } from './combobox.css.js'; 14 8 15 9 let wrappers: Array<HTMLElement> = []; 16 10 ··· 74 68 test('in-field actions are quiet inset squares without a permanent divider or travel', () => { 75 69 const { root } = mountControl(); 76 70 for (const { className, expectedSize, isTrigger } of [ 77 - { className: comboboxClearButton({ size: 'small' }), expectedSize: 24, isTrigger: false }, 78 - { className: comboboxTrigger({ size: 'small' }), expectedSize: 24, isTrigger: true }, 79 - { className: comboboxClearButton({ size: 'medium' }), expectedSize: 28, isTrigger: false }, 80 - { className: comboboxTrigger({ size: 'medium' }), expectedSize: 28, isTrigger: true }, 71 + { className: combobox({ size: 'small' }).clearButton(), expectedSize: 24, isTrigger: false }, 72 + { className: combobox({ size: 'small' }).trigger(), expectedSize: 24, isTrigger: true }, 73 + { className: combobox({ size: 'medium' }).clearButton(), expectedSize: 28, isTrigger: false }, 74 + { className: combobox({ size: 'medium' }).trigger(), expectedSize: 28, isTrigger: true }, 81 75 ]) { 82 76 const action = root.appendChild(document.createElement('button')); 83 77 action.className = className; ··· 153 147 test('the portalled surface uses floating surface and depth', () => { 154 148 const { root } = mountControl(); 155 149 const popover = root.appendChild(document.createElement('div')); 156 - popover.className = comboboxPopover(); 150 + popover.className = combobox().popover(); 157 151 const style = getComputedStyle(popover); 158 152 159 153 expect(style.backgroundColor).toBe(resolveColor(root, '--luke-color-surface-floating')); ··· 181 175 expect(getComputedStyle(focused).outlineColor).toBe(resolveSystemColor(root, 'Highlight')); 182 176 183 177 const action = root.appendChild(document.createElement('button')); 184 - action.className = comboboxTrigger({ size: 'medium' }); 178 + action.className = combobox({ size: 'medium' }).trigger(); 185 179 expect(getComputedStyle(action).boxShadow).toBe('none'); 186 180 action.dataset.hovered = 'true'; 187 181 const hoveredActionStyle = getComputedStyle(action); ··· 196 190 expect(pressedActionStyle.transform).toBe('none'); 197 191 198 192 const popover = root.appendChild(document.createElement('div')); 199 - popover.className = comboboxPopover(); 193 + popover.className = combobox().popover(); 200 194 expect(getComputedStyle(popover).boxShadow).toBe('none'); 201 195 202 196 const disabledItem = mountItem(root, { disabled: 'true' }); ··· 213 207 214 208 const { control, root } = mountControl(); 215 209 const action = root.appendChild(document.createElement('button')); 216 - action.className = comboboxClearButton({ size: 'medium' }); 210 + action.className = combobox({ size: 'medium' }).clearButton(); 217 211 action.dataset.hovered = 'true'; 218 212 const item = mountItem(root, { hovered: 'true' }); 219 213 const popover = root.appendChild(document.createElement('div')); 220 - popover.className = comboboxPopover(); 214 + popover.className = combobox().popover(); 221 215 popover.dataset.entering = ''; 222 216 223 217 for (const element of [control, action, item, popover]) { ··· 235 229 root.dataset.colorMode = 'light'; 236 230 wrappers.push(root); 237 231 const control = root.appendChild(document.createElement('div')); 238 - control.className = comboboxControl({ size: 'medium' }); 232 + control.className = combobox({ size: 'medium' }).control(); 239 233 control.append(document.createElement('input'), document.createElement('button')); 240 234 return { control, root }; 241 235 } 242 236 243 237 function mountItem(root: HTMLElement, states: Record<string, string> = {}) { 244 238 const item = root.appendChild(document.createElement('div')); 245 - item.className = comboboxItem({ size: 'medium' }); 239 + item.className = combobox({ size: 'medium' }).item(); 246 240 for (const [state, value] of Object.entries(states)) item.dataset[state] = value; 247 241 return item; 248 242 }
+349 -381
packages/@luke-ui/react/src/recipes/combobox.css.ts
··· 1 1 import type { StyleRule } from '@vanilla-extract/css'; 2 - import type { RecipeVariants } from '@vanilla-extract/recipes'; 3 2 import { focusRing } from '../styles/focus-ring.js'; 4 - import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; 5 3 import { vars } from '../theme/contract.css.js'; 6 4 import { 7 5 composeInputStateSelectors, 8 6 descendantDisabledSelector, 9 7 inputStates, 10 8 } from './input-states.css.js'; 9 + import type { RecipeSelection, SlottedConfigInput } from './recipe.js'; 10 + import { recipe } from './recipe.js'; 11 11 12 12 /** Custom property mirroring `visualViewport.height`, set by `useVisualViewportVars`. */ 13 13 export const comboboxTrayViewportHeightVar = '--luke-ui-visual-viewport-height'; ··· 26 26 const { disabled, focusWithin, hover, invalid, invalidFocusWithin, readOnly, readOnlyFocusWithin } = 27 27 composeInputStateSelectors(comboboxStates); 28 28 29 - export const comboboxRoot = styleInLayer('recipes', { 30 - display: 'flex', 31 - flexDirection: 'column', 32 - inlineSize: '100%', 33 - minInlineSize: 0, 34 - }); 35 - 36 - export const comboboxControl = recipeInLayer('recipes', { 37 - base: { 38 - '@media': { 39 - '(forced-colors: active)': { 40 - backgroundColor: 'Field', 41 - borderColor: 'FieldText', 42 - boxShadow: 'none', 43 - color: 'FieldText', 44 - forcedColorAdjust: 'auto', 45 - selectors: { 46 - [disabled]: { borderColor: 'GrayText', color: 'GrayText', opacity: 1 }, 47 - [focusWithin]: { outlineColor: 'Highlight' }, 48 - [invalidFocusWithin]: { outlineColor: 'Highlight' }, 49 - }, 50 - }, 51 - '(prefers-reduced-motion: reduce)': { transition: 'none' }, 52 - }, 53 - alignItems: 'center', 54 - backgroundColor: vars.color.surface.recessed, 55 - borderColor: vars.color.border.control, 56 - borderRadius: vars.radius.control, 57 - borderStyle: 'solid', 58 - borderWidth: '1px', 59 - boxShadow: vars.depth.recessed, 60 - color: vars.color.text.primary, 61 - cursor: 'text', 62 - display: 'inline-flex', 63 - fontFamily: vars.font.family, 64 - inlineSize: '100%', 65 - isolation: 'isolate', 66 - letterSpacing: vars.font[300].letterSpacing, 67 - lineHeight: vars.font[300].lineHeight, 68 - minInlineSize: 0, 69 - outlineColor: 'transparent', 70 - outlineOffset: 0, 71 - outlineStyle: 'solid', 72 - outlineWidth: '2px', 73 - overflow: 'visible', 74 - transitionDuration: vars.motion.duration.fast, 75 - transitionProperty: 'background-color, border-color, color', 76 - transitionTimingFunction: vars.motion.easing.standard, 77 - 78 - selectors: { 79 - [disabled]: { cursor: 'not-allowed', opacity: 0.55 }, 80 - [focusWithin]: { 81 - borderColor: vars.color.intent.accent.border, 82 - ...focusRing(vars.color.border.focus), 83 - }, 84 - [hover]: { borderColor: vars.color.intent.accent.border }, 85 - [invalid]: { borderColor: vars.color.intent.danger.border }, 86 - [invalidFocusWithin]: { 87 - borderColor: vars.color.intent.danger.border, 88 - ...focusRing(vars.color.border.focus), 89 - }, 90 - [readOnly]: { 91 - backgroundColor: vars.color.surface.canvas, 92 - borderColor: vars.color.border.decorative, 93 - boxShadow: 'none', 94 - }, 95 - [readOnlyFocusWithin]: { ...focusRing(vars.color.border.focus) }, 96 - }, 97 - }, 98 - defaultVariants: { size: 'medium' }, 99 - variants: { 100 - size: { 101 - medium: { blockSize: vars.controlSize.medium, fontSize: vars.font[300].fontSize }, 102 - small: { 103 - blockSize: vars.controlSize.small, 104 - ...vars.font[200], 105 - }, 106 - }, 107 - }, 108 - }); 109 - 110 - export const comboboxTextInput = recipeInLayer('recipes', { 111 - base: { 112 - appearance: 'none', 113 - backgroundColor: 'transparent', 114 - border: 'none', 115 - color: vars.color.text.primary, 116 - cursor: 'text', 117 - flex: 1, 118 - fontFamily: 'inherit', 119 - fontSize: 'inherit', 120 - fontWeight: 'inherit', 121 - inlineSize: '100%', 122 - letterSpacing: 'inherit', 123 - lineHeight: 'inherit', 124 - minInlineSize: 0, 125 - outline: 'none', 126 - paddingBlock: 0, 127 - 128 - selectors: { 129 - '&::placeholder': { color: vars.color.text.secondary, opacity: 1 }, 130 - '&:where([data-disabled="true"], :disabled)': { 131 - color: vars.color.textDisabled, 132 - cursor: 'not-allowed', 133 - }, 134 - }, 135 - }, 136 - defaultVariants: { size: 'medium' }, 137 - variants: { 138 - size: { 139 - medium: { 140 - blockSize: vars.controlSize.medium, 141 - paddingInlineEnd: vars.space[300], 142 - paddingInlineStart: vars.space[300], 143 - }, 144 - small: { 145 - blockSize: vars.controlSize.small, 146 - paddingInlineEnd: vars.space[200], 147 - paddingInlineStart: vars.space[200], 148 - }, 149 - }, 150 - }, 151 - }); 152 - 153 29 const comboboxActionStyles = { 154 30 '@media': { 155 31 '(forced-colors: active)': { ··· 218 94 }, 219 95 } satisfies StyleRule; 220 96 221 - export const comboboxTrigger = recipeInLayer('recipes', { 222 - base: { 223 - ...comboboxActionStyles, 224 - marginInlineEnd: vars.space[100], 225 - marginInlineStart: vars.space[100], 226 - 227 - selectors: { 228 - ...comboboxActionStyles.selectors, 229 - '&:first-child': { 230 - color: vars.color.text.primary, 231 - inlineSize: '100%', 232 - justifyContent: 'space-between', 233 - marginInline: 0, 234 - }, 97 + /** 98 + * Raw slotted config for the combobox anatomy. 99 + * 100 + * Slots follow the anatomy top to bottom: `root`, `control`, `textInput`, 101 + * `trigger`, `clearButton`, `itemCheck`, `popover`, `listBox`, `loadMoreItem`, 102 + * `section`, `sectionHeading`, `emptyState`, `item`. 103 + */ 104 + const comboboxConfig = { 105 + slots: { 106 + root: { 107 + display: 'flex', 108 + flexDirection: 'column', 109 + inlineSize: '100%', 110 + minInlineSize: 0, 235 111 }, 236 - }, 237 - defaultVariants: { size: 'medium' }, 238 - variants: { 239 - size: { 240 - medium: { 241 - blockSize: '28px', 242 - inlineSize: '28px', 243 - paddingInline: 0, 112 + control: { 113 + '@media': { 114 + '(forced-colors: active)': { 115 + backgroundColor: 'Field', 116 + borderColor: 'FieldText', 117 + boxShadow: 'none', 118 + color: 'FieldText', 119 + forcedColorAdjust: 'auto', 120 + selectors: { 121 + [disabled]: { borderColor: 'GrayText', color: 'GrayText', opacity: 1 }, 122 + [focusWithin]: { outlineColor: 'Highlight' }, 123 + [invalidFocusWithin]: { outlineColor: 'Highlight' }, 124 + }, 125 + }, 126 + '(prefers-reduced-motion: reduce)': { transition: 'none' }, 244 127 }, 245 - small: { 246 - blockSize: '24px', 247 - inlineSize: '24px', 248 - paddingInline: 0, 128 + alignItems: 'center', 129 + backgroundColor: vars.color.surface.recessed, 130 + borderColor: vars.color.border.control, 131 + borderRadius: vars.radius.control, 132 + borderStyle: 'solid', 133 + borderWidth: '1px', 134 + boxShadow: vars.depth.recessed, 135 + color: vars.color.text.primary, 136 + cursor: 'text', 137 + display: 'inline-flex', 138 + fontFamily: vars.font.family, 139 + inlineSize: '100%', 140 + isolation: 'isolate', 141 + letterSpacing: vars.font[300].letterSpacing, 142 + lineHeight: vars.font[300].lineHeight, 143 + minInlineSize: 0, 144 + outlineColor: 'transparent', 145 + outlineOffset: 0, 146 + outlineStyle: 'solid', 147 + outlineWidth: '2px', 148 + overflow: 'visible', 149 + transitionDuration: vars.motion.duration.fast, 150 + transitionProperty: 'background-color, border-color, color', 151 + transitionTimingFunction: vars.motion.easing.standard, 152 + 153 + selectors: { 154 + [disabled]: { cursor: 'not-allowed', opacity: 0.55 }, 155 + [focusWithin]: { 156 + borderColor: vars.color.intent.accent.border, 157 + ...focusRing(vars.color.border.focus), 158 + }, 159 + [hover]: { borderColor: vars.color.intent.accent.border }, 160 + [invalid]: { borderColor: vars.color.intent.danger.border }, 161 + [invalidFocusWithin]: { 162 + borderColor: vars.color.intent.danger.border, 163 + ...focusRing(vars.color.border.focus), 164 + }, 165 + [readOnly]: { 166 + backgroundColor: vars.color.surface.canvas, 167 + borderColor: vars.color.border.decorative, 168 + boxShadow: 'none', 169 + }, 170 + [readOnlyFocusWithin]: { ...focusRing(vars.color.border.focus) }, 249 171 }, 250 172 }, 251 - }, 252 - }); 173 + textInput: { 174 + appearance: 'none', 175 + backgroundColor: 'transparent', 176 + border: 'none', 177 + color: vars.color.text.primary, 178 + cursor: 'text', 179 + flex: 1, 180 + fontFamily: 'inherit', 181 + fontSize: 'inherit', 182 + fontWeight: 'inherit', 183 + inlineSize: '100%', 184 + letterSpacing: 'inherit', 185 + lineHeight: 'inherit', 186 + minInlineSize: 0, 187 + outline: 'none', 188 + paddingBlock: 0, 253 189 254 - export const comboboxClearButton = recipeInLayer('recipes', { 255 - base: comboboxActionStyles, 256 - defaultVariants: { size: 'medium' }, 257 - variants: { 258 - size: { 259 - medium: { blockSize: '28px', inlineSize: '28px', paddingInline: 0 }, 260 - small: { blockSize: '24px', inlineSize: '24px', paddingInline: 0 }, 190 + selectors: { 191 + '&::placeholder': { color: vars.color.text.secondary, opacity: 1 }, 192 + '&:where([data-disabled="true"], :disabled)': { 193 + color: vars.color.textDisabled, 194 + cursor: 'not-allowed', 195 + }, 196 + }, 261 197 }, 262 - }, 263 - }); 264 - 265 - export const comboboxItemCheck = styleInLayer('recipes', { 266 - flexShrink: 0, 267 - marginInlineStart: 'auto', 268 - }); 198 + trigger: { 199 + ...comboboxActionStyles, 200 + marginInlineEnd: vars.space[100], 201 + marginInlineStart: vars.space[100], 269 202 270 - export const comboboxPopover = recipeInLayer('recipes', { 271 - base: { 272 - '@media': { 273 - '(forced-colors: active)': { 274 - backgroundColor: 'Canvas', 275 - borderColor: 'CanvasText', 276 - boxShadow: 'none', 277 - forcedColorAdjust: 'auto', 203 + selectors: { 204 + ...comboboxActionStyles.selectors, 205 + '&:first-child': { 206 + color: vars.color.text.primary, 207 + inlineSize: '100%', 208 + justifyContent: 'space-between', 209 + marginInline: 0, 210 + }, 278 211 }, 279 - [trayMediaQuery]: { 280 - borderEndEndRadius: 0, 281 - borderEndStartRadius: 0, 282 - borderStartEndRadius: vars.radius.overlay, 283 - borderStartStartRadius: vars.radius.overlay, 284 - boxShadow: `${vars.depth.floating}, 0 0 0 100vmax rgb(0 0 0 / 20%)`, 285 - inlineSize: 'auto !important' as 'auto', 286 - insetBlockEnd: `var(${comboboxTrayKeyboardInsetVar}, 0px) !important`, 287 - insetBlockStart: 'auto !important' as 'auto', 288 - insetInline: '0 !important', 289 - maxBlockSize: `calc(var(${comboboxTrayViewportHeightVar}, 100dvh) - ${vars.space[800]}) !important`, 290 - minInlineSize: 'auto !important' as 'auto', 291 - paddingBlockEnd: 'env(safe-area-inset-bottom, 0px)', 292 - position: 'fixed !important' as 'fixed', 293 - selectors: { 294 - '&::before': { 295 - alignSelf: 'center', 296 - backgroundColor: vars.color.border.decorative, 297 - blockSize: '4px', 298 - borderRadius: vars.radius.full, 299 - content: '', 300 - flexShrink: 0, 301 - inlineSize: '36px', 302 - marginBlockEnd: vars.space[100], 303 - marginBlockStart: vars.space[200], 304 - }, 305 - '&[data-entering]': { 306 - boxShadow: `${vars.depth.floating}, 0 0 0 100vmax transparent`, 307 - opacity: 1, 308 - translate: '0 100%', 212 + }, 213 + clearButton: comboboxActionStyles, 214 + itemCheck: { 215 + flexShrink: 0, 216 + marginInlineStart: 'auto', 217 + }, 218 + popover: { 219 + '@media': { 220 + '(forced-colors: active)': { 221 + backgroundColor: 'Canvas', 222 + borderColor: 'CanvasText', 223 + boxShadow: 'none', 224 + forcedColorAdjust: 'auto', 225 + }, 226 + [trayMediaQuery]: { 227 + borderEndEndRadius: 0, 228 + borderEndStartRadius: 0, 229 + borderStartEndRadius: vars.radius.overlay, 230 + borderStartStartRadius: vars.radius.overlay, 231 + boxShadow: `${vars.depth.floating}, 0 0 0 100vmax rgb(0 0 0 / 20%)`, 232 + inlineSize: 'auto !important' as 'auto', 233 + insetBlockEnd: `var(${comboboxTrayKeyboardInsetVar}, 0px) !important`, 234 + insetBlockStart: 'auto !important' as 'auto', 235 + insetInline: '0 !important', 236 + maxBlockSize: `calc(var(${comboboxTrayViewportHeightVar}, 100dvh) - ${vars.space[800]}) !important`, 237 + minInlineSize: 'auto !important' as 'auto', 238 + paddingBlockEnd: 'env(safe-area-inset-bottom, 0px)', 239 + position: 'fixed !important' as 'fixed', 240 + selectors: { 241 + '&::before': { 242 + alignSelf: 'center', 243 + backgroundColor: vars.color.border.decorative, 244 + blockSize: '4px', 245 + borderRadius: vars.radius.full, 246 + content: '', 247 + flexShrink: 0, 248 + inlineSize: '36px', 249 + marginBlockEnd: vars.space[100], 250 + marginBlockStart: vars.space[200], 251 + }, 252 + '&[data-entering]': { 253 + boxShadow: `${vars.depth.floating}, 0 0 0 100vmax transparent`, 254 + opacity: 1, 255 + translate: '0 100%', 256 + }, 257 + '&[data-exiting]': { 258 + boxShadow: `${vars.depth.floating}, 0 0 0 100vmax transparent`, 259 + opacity: 1, 260 + translate: '0 100%', 261 + }, 309 262 }, 310 - '&[data-exiting]': { 311 - boxShadow: `${vars.depth.floating}, 0 0 0 100vmax transparent`, 312 - opacity: 1, 313 - translate: '0 100%', 263 + }, 264 + '(prefers-reduced-motion: reduce)': { 265 + transition: 'none', 266 + selectors: { 267 + '&[data-entering]': { opacity: 1, translate: 'none' }, 268 + '&[data-exiting]': { opacity: 1, translate: 'none' }, 314 269 }, 315 270 }, 316 271 }, 317 - '(prefers-reduced-motion: reduce)': { 318 - transition: 'none', 319 - selectors: { 320 - '&[data-entering]': { opacity: 1, translate: 'none' }, 321 - '&[data-exiting]': { opacity: 1, translate: 'none' }, 272 + backgroundColor: vars.color.surface.floating, 273 + borderColor: vars.color.border.decorative, 274 + borderRadius: vars.radius.surface, 275 + borderStyle: 'solid', 276 + borderWidth: '1px', 277 + boxShadow: vars.depth.floating, 278 + display: 'flex', 279 + flexDirection: 'column', 280 + inlineSize: 'var(--trigger-width)', 281 + isolation: 'isolate', 282 + minInlineSize: 'var(--trigger-width)', 283 + overflow: 'hidden', 284 + transition: [ 285 + `opacity ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, 286 + `translate ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, 287 + `box-shadow ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, 288 + ].join(', '), 289 + 290 + selectors: { 291 + '&[data-entering]': { opacity: 0 }, 292 + '&[data-exiting]': { opacity: 0 }, 293 + }, 294 + 295 + '@supports': { 296 + '(min-block-size: calc-size(fit-content, size))': { 297 + minBlockSize: 'calc-size(fit-content, min(size, 12em))', 322 298 }, 323 299 }, 324 300 }, 325 - backgroundColor: vars.color.surface.floating, 326 - borderColor: vars.color.border.decorative, 327 - borderRadius: vars.radius.surface, 328 - borderStyle: 'solid', 329 - borderWidth: '1px', 330 - boxShadow: vars.depth.floating, 331 - display: 'flex', 332 - flexDirection: 'column', 333 - inlineSize: 'var(--trigger-width)', 334 - isolation: 'isolate', 335 - minInlineSize: 'var(--trigger-width)', 336 - overflow: 'hidden', 337 - transition: [ 338 - `opacity ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, 339 - `translate ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, 340 - `box-shadow ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, 341 - ].join(', '), 342 - 343 - selectors: { 344 - '&[data-entering]': { opacity: 0 }, 345 - '&[data-exiting]': { opacity: 0 }, 301 + listBox: { 302 + '@media': { [trayMediaQuery]: { maxBlockSize: 'none' } }, 303 + boxSizing: 'border-box', 304 + flex: 1, 305 + inlineSize: '100%', 306 + listStyle: 'none', 307 + margin: 0, 308 + maxBlockSize: '18.75rem', 309 + minBlockSize: 0, 310 + outline: 'none', 311 + overflow: 'auto', 312 + padding: vars.space[100], 346 313 }, 314 + loadMoreItem: { 315 + alignItems: 'center', 316 + color: vars.color.text.secondary, 317 + display: 'flex', 318 + inlineSize: '100%', 319 + justifyContent: 'center', 320 + minInlineSize: 0, 321 + }, 322 + section: { 323 + display: 'flex', 324 + flexDirection: 'column', 325 + gap: vars.space[100], 326 + paddingBlock: vars.space[200], 347 327 348 - '@supports': { 349 - '(min-block-size: calc-size(fit-content, size))': { 350 - minBlockSize: 'calc-size(fit-content, min(size, 12em))', 328 + selectors: { 329 + '& + &': { borderBlockStart: `1px solid ${vars.color.border.decorative}` }, 351 330 }, 352 331 }, 353 - }, 354 - }); 355 - 356 - export const comboboxListBox = recipeInLayer('recipes', { 357 - base: { 358 - '@media': { [trayMediaQuery]: { maxBlockSize: 'none' } }, 359 - boxSizing: 'border-box', 360 - flex: 1, 361 - inlineSize: '100%', 362 - listStyle: 'none', 363 - margin: 0, 364 - maxBlockSize: '18.75rem', 365 - minBlockSize: 0, 366 - outline: 'none', 367 - overflow: 'auto', 368 - padding: vars.space[100], 369 - }, 370 - }); 371 - 372 - export const comboboxLoadMoreItem = recipeInLayer('recipes', { 373 - base: { 374 - alignItems: 'center', 375 - color: vars.color.text.secondary, 376 - display: 'flex', 377 - inlineSize: '100%', 378 - justifyContent: 'center', 379 - minInlineSize: 0, 380 - }, 381 - defaultVariants: { size: 'medium' }, 382 - variants: { 383 - size: { 384 - medium: { 385 - minBlockSize: vars.controlSize.medium, 386 - paddingBlock: vars.space[200], 387 - paddingInline: vars.space[300], 388 - }, 389 - small: { 390 - minBlockSize: vars.controlSize.small, 391 - paddingBlock: vars.space[100], 392 - paddingInline: vars.space[200], 393 - }, 332 + sectionHeading: { 333 + color: vars.color.text.secondary, 334 + ...vars.font[200], 335 + fontWeight: vars.font.weight.label, 336 + paddingBlockEnd: vars.space[100], 337 + paddingBlockStart: 0, 338 + paddingInline: vars.space[300], 394 339 }, 395 - }, 396 - }); 397 - 398 - export const comboboxSection = recipeInLayer('recipes', { 399 - base: { 400 - display: 'flex', 401 - flexDirection: 'column', 402 - gap: vars.space[100], 403 - paddingBlock: vars.space[200], 404 - 405 - selectors: { 406 - '& + &': { borderBlockStart: `1px solid ${vars.color.border.decorative}` }, 340 + emptyState: { 341 + alignItems: 'center', 342 + color: vars.color.text.secondary, 343 + display: 'flex', 344 + ...vars.font[200], 345 + justifyContent: 'center', 346 + paddingBlock: vars.space[600], 347 + paddingInline: vars.space[300], 348 + textAlign: 'center', 407 349 }, 408 - }, 409 - }); 410 - 411 - export const comboboxSectionHeading = styleInLayer('recipes', { 412 - color: vars.color.text.secondary, 413 - ...vars.font[200], 414 - fontWeight: vars.font.weight.label, 415 - paddingBlockEnd: vars.space[100], 416 - paddingBlockStart: 0, 417 - paddingInline: vars.space[300], 418 - }); 419 - 420 - export const comboboxEmptyState = recipeInLayer('recipes', { 421 - base: { 422 - alignItems: 'center', 423 - color: vars.color.text.secondary, 424 - display: 'flex', 425 - ...vars.font[200], 426 - justifyContent: 'center', 427 - paddingBlock: vars.space[600], 428 - paddingInline: vars.space[300], 429 - textAlign: 'center', 430 - }, 431 - }); 432 - 433 - export const comboboxItem = recipeInLayer('recipes', { 434 - base: { 435 - '@media': { 436 - '(forced-colors: active)': { 437 - forcedColorAdjust: 'auto', 438 - selectors: { 439 - '&[data-disabled="true"]': { color: 'GrayText', opacity: 1 }, 440 - '&[data-focus-visible="true"]': { 441 - outlineColor: 'Highlight', 442 - outlineOffset: '-2px', 443 - outlineStyle: 'solid', 444 - outlineWidth: '2px', 445 - }, 446 - '&[data-selected="true"]:not([data-disabled="true"])': { 447 - backgroundColor: 'Highlight', 448 - color: 'HighlightText', 350 + item: { 351 + '@media': { 352 + '(forced-colors: active)': { 353 + forcedColorAdjust: 'auto', 354 + selectors: { 355 + '&[data-disabled="true"]': { color: 'GrayText', opacity: 1 }, 356 + '&[data-focus-visible="true"]': { 357 + outlineColor: 'Highlight', 358 + outlineOffset: '-2px', 359 + outlineStyle: 'solid', 360 + outlineWidth: '2px', 361 + }, 362 + '&[data-selected="true"]:not([data-disabled="true"])': { 363 + backgroundColor: 'Highlight', 364 + color: 'HighlightText', 365 + }, 449 366 }, 450 367 }, 368 + '(prefers-reduced-motion: reduce)': { transform: 'none', transition: 'none' }, 451 369 }, 452 - '(prefers-reduced-motion: reduce)': { transform: 'none', transition: 'none' }, 453 - }, 454 - alignItems: 'center', 455 - backgroundColor: 'transparent', 456 - borderRadius: vars.radius.control, 457 - color: vars.color.text.primary, 458 - cursor: 'default', 459 - display: 'flex', 460 - gap: vars.space[200], 461 - inlineSize: '100%', 462 - minBlockSize: '24px', 463 - minInlineSize: 0, 464 - outline: 'none', 465 - transform: 'none', 466 - transitionDuration: vars.motion.duration.fast, 467 - transitionProperty: 'background-color, color, opacity', 468 - transitionTimingFunction: vars.motion.easing.standard, 370 + alignItems: 'center', 371 + backgroundColor: 'transparent', 372 + borderRadius: vars.radius.control, 373 + color: vars.color.text.primary, 374 + cursor: 'default', 375 + display: 'flex', 376 + gap: vars.space[200], 377 + inlineSize: '100%', 378 + minBlockSize: '24px', 379 + minInlineSize: 0, 380 + outline: 'none', 381 + transform: 'none', 382 + transitionDuration: vars.motion.duration.fast, 383 + transitionProperty: 'background-color, color, opacity', 384 + transitionTimingFunction: vars.motion.easing.standard, 469 385 470 - selectors: { 471 - '&[data-disabled="true"]': { 472 - color: vars.color.textDisabled, 473 - cursor: 'not-allowed', 474 - opacity: 0.55, 475 - }, 476 - '&[data-focused="true"]:not([data-disabled="true"])': { 477 - backgroundColor: vars.color.intent.neutral.surface.subtle, 478 - }, 479 - '&[data-hovered="true"]:not([data-disabled="true"])': { 480 - backgroundColor: vars.color.intent.neutral.surface.subtleHover, 481 - }, 482 - '&[data-focus-visible="true"]:not([data-disabled="true"])': { 483 - backgroundColor: vars.color.intent.accent.surface.subtleHover, 484 - }, 485 - '&[data-selected="true"]:not([data-disabled="true"])': { 486 - backgroundColor: vars.color.intent.accent.surface.subtle, 487 - fontWeight: vars.font.weight.label, 488 - }, 489 - '&[data-selected="true"][data-focus-visible="true"]:not([data-disabled="true"])': { 490 - backgroundColor: vars.color.intent.accent.surface.subtlePressed, 386 + selectors: { 387 + '&[data-disabled="true"]': { 388 + color: vars.color.textDisabled, 389 + cursor: 'not-allowed', 390 + opacity: 0.55, 391 + }, 392 + '&[data-focused="true"]:not([data-disabled="true"])': { 393 + backgroundColor: vars.color.intent.neutral.surface.subtle, 394 + }, 395 + '&[data-hovered="true"]:not([data-disabled="true"])': { 396 + backgroundColor: vars.color.intent.neutral.surface.subtleHover, 397 + }, 398 + '&[data-focus-visible="true"]:not([data-disabled="true"])': { 399 + backgroundColor: vars.color.intent.accent.surface.subtleHover, 400 + }, 401 + '&[data-selected="true"]:not([data-disabled="true"])': { 402 + backgroundColor: vars.color.intent.accent.surface.subtle, 403 + fontWeight: vars.font.weight.label, 404 + }, 405 + '&[data-selected="true"][data-focus-visible="true"]:not([data-disabled="true"])': { 406 + backgroundColor: vars.color.intent.accent.surface.subtlePressed, 407 + }, 491 408 }, 492 409 }, 493 410 }, ··· 495 412 variants: { 496 413 size: { 497 414 medium: { 498 - ...vars.font[200], 499 - minBlockSize: vars.controlSize.medium, 500 - paddingBlock: vars.space[200], 501 - paddingInline: vars.space[300], 415 + control: { blockSize: vars.controlSize.medium, fontSize: vars.font[300].fontSize }, 416 + textInput: { 417 + blockSize: vars.controlSize.medium, 418 + paddingInlineEnd: vars.space[300], 419 + paddingInlineStart: vars.space[300], 420 + }, 421 + trigger: { 422 + blockSize: '28px', 423 + inlineSize: '28px', 424 + paddingInline: 0, 425 + }, 426 + clearButton: { blockSize: '28px', inlineSize: '28px', paddingInline: 0 }, 427 + loadMoreItem: { 428 + minBlockSize: vars.controlSize.medium, 429 + paddingBlock: vars.space[200], 430 + paddingInline: vars.space[300], 431 + }, 432 + item: { 433 + ...vars.font[200], 434 + minBlockSize: vars.controlSize.medium, 435 + paddingBlock: vars.space[200], 436 + paddingInline: vars.space[300], 437 + }, 502 438 }, 503 439 small: { 504 - ...vars.font[200], 505 - minBlockSize: vars.controlSize.small, 506 - paddingBlock: vars.space[100], 507 - paddingInline: vars.space[300], 440 + control: { 441 + blockSize: vars.controlSize.small, 442 + ...vars.font[200], 443 + }, 444 + textInput: { 445 + blockSize: vars.controlSize.small, 446 + paddingInlineEnd: vars.space[200], 447 + paddingInlineStart: vars.space[200], 448 + }, 449 + trigger: { 450 + blockSize: '24px', 451 + inlineSize: '24px', 452 + paddingInline: 0, 453 + }, 454 + clearButton: { blockSize: '24px', inlineSize: '24px', paddingInline: 0 }, 455 + loadMoreItem: { 456 + minBlockSize: vars.controlSize.small, 457 + paddingBlock: vars.space[100], 458 + paddingInline: vars.space[200], 459 + }, 460 + item: { 461 + ...vars.font[200], 462 + minBlockSize: vars.controlSize.small, 463 + paddingBlock: vars.space[100], 464 + paddingInline: vars.space[300], 465 + }, 508 466 }, 509 467 }, 510 468 }, 511 - }); 469 + } as const satisfies SlottedConfigInput; 512 470 513 - export type ComboboxVariants = RecipeVariants<typeof comboboxControl>; 471 + /** 472 + * Slotted recipe for the combobox anatomy. Internal — not exported from 473 + * `@luke-ui/react/recipes`. 474 + */ 475 + export const combobox = recipe(comboboxConfig); 476 + 477 + /** Outer variant selection for the combobox recipe. */ 478 + export type ComboboxVariants = RecipeSelection<typeof combobox>; 479 + 480 + /** Allowed `size` values for the combobox recipe. */ 481 + export type ComboboxSize = keyof typeof comboboxConfig.variants.size;
+68 -57
packages/@luke-ui/react/src/recipes/field.css.ts
··· 1 - import type { RecipeVariants } from '@vanilla-extract/recipes'; 2 - import { recipeInLayer } from '../styles/layered-style.css.js'; 3 1 import { vars } from '../theme/contract.css.js'; 2 + import type { RecipeSelection, SlottedConfigInput } from './recipe.js'; 3 + import { recipe } from './recipe.js'; 4 4 5 5 const dataDisabledSelector = '[data-disabled="true"]'; 6 6 const dataRequiredSelector = '[data-required="true"]'; 7 7 8 - /** Vanilla-extract recipe for the `Field` primitive's layout styles. */ 9 - export const field = recipeInLayer('recipes', { 10 - base: { 11 - display: 'flex', 12 - flexDirection: 'column', 13 - gap: vars.space[100], 14 - minInlineSize: 0, 15 - }, 16 - }); 8 + /** 9 + * Raw slotted config for the `Field` primitive. 10 + * 11 + * Slots: `root` (layout), `label`, and `message` (description/error text). 12 + */ 13 + const fieldConfig = { 14 + slots: { 15 + root: { 16 + display: 'flex', 17 + flexDirection: 'column', 18 + gap: vars.space[100], 19 + minInlineSize: 0, 20 + }, 21 + label: { 22 + color: vars.color.text.primary, 23 + ...vars.font[200], 24 + fontWeight: vars.font.weight.label, 25 + minInlineSize: 0, 17 26 18 - /** Vanilla-extract recipe for the `Field` primitive's label styles. */ 19 - export const fieldLabel = recipeInLayer('recipes', { 20 - base: { 21 - color: vars.color.text.primary, 22 - ...vars.font[200], 23 - fontWeight: vars.font.weight.label, 24 - minInlineSize: 0, 27 + selectors: { 28 + [`${dataDisabledSelector} &`]: { 29 + color: vars.color.textDisabled, 30 + }, 31 + }, 32 + }, 33 + message: { 34 + ...vars.font[200], 35 + minInlineSize: 0, 25 36 26 - selectors: { 27 - [`${dataDisabledSelector} &`]: { 28 - color: vars.color.textDisabled, 37 + selectors: { 38 + [`${dataDisabledSelector} &`]: { 39 + color: vars.color.textDisabled, 40 + }, 29 41 }, 30 42 }, 31 43 }, 32 44 defaultVariants: { 33 45 necessityIndicator: 'icon', 46 + tone: 'description', 34 47 }, 35 48 variants: { 36 49 necessityIndicator: { 37 50 icon: { 38 - selectors: { 39 - [`${dataRequiredSelector} &::after`]: { 40 - color: vars.color.intent.danger.text, 41 - content: '"*"', 42 - marginInlineStart: vars.space[100], 51 + label: { 52 + selectors: { 53 + [`${dataRequiredSelector} &::after`]: { 54 + color: vars.color.intent.danger.text, 55 + content: '"*"', 56 + marginInlineStart: vars.space[100], 57 + }, 43 58 }, 44 59 }, 45 60 }, 46 61 label: { 47 - selectors: { 48 - [`${dataRequiredSelector} &::after`]: { 49 - color: vars.color.text.secondary, 50 - content: '"(required)"', 51 - fontWeight: vars.font.weight.body, 52 - marginInlineStart: vars.space[100], 62 + label: { 63 + selectors: { 64 + [`${dataRequiredSelector} &::after`]: { 65 + color: vars.color.text.secondary, 66 + content: '"(required)"', 67 + fontWeight: vars.font.weight.body, 68 + marginInlineStart: vars.space[100], 69 + }, 53 70 }, 54 71 }, 55 72 }, 56 73 }, 57 - }, 58 - }); 59 - 60 - export type FieldLabelVariants = RecipeVariants<typeof fieldLabel>; 61 - 62 - /** Vanilla-extract recipe for the `Field` primitive's message styles. */ 63 - export const fieldMessage = recipeInLayer('recipes', { 64 - base: { 65 - ...vars.font[200], 66 - minInlineSize: 0, 67 - 68 - selectors: { 69 - [`${dataDisabledSelector} &`]: { 70 - color: vars.color.textDisabled, 71 - }, 72 - }, 73 - }, 74 - defaultVariants: { 75 - tone: 'description', 76 - }, 77 - variants: { 78 74 tone: { 79 75 description: { 80 - color: vars.color.text.secondary, 76 + message: { 77 + color: vars.color.text.secondary, 78 + }, 81 79 }, 82 80 error: { 83 - color: vars.color.intent.danger.text, 81 + message: { 82 + color: vars.color.intent.danger.text, 83 + }, 84 84 }, 85 85 }, 86 86 }, 87 - }); 87 + } as const satisfies SlottedConfigInput; 88 + 89 + /** 90 + * Slotted recipe for the `Field` primitive. 91 + * 92 + * `field({ necessityIndicator, tone }).root() / .label() / .message()`. 93 + */ 94 + export const field = recipe(fieldConfig); 95 + 96 + /** Outer variant selection for the `Field` recipe. */ 97 + export type FieldVariants = RecipeSelection<typeof field>; 88 98 89 - export type FieldMessageVariants = RecipeVariants<typeof fieldMessage>; 99 + /** Allowed `necessityIndicator` values for the field label. */ 100 + export type FieldNecessityIndicator = keyof typeof fieldConfig.variants.necessityIndicator;
+3 -7
packages/@luke-ui/react/src/recipes/index.ts
··· 5 5 6 6 export type { ButtonVariants } from '../recipes/button.css.js'; 7 7 export { button } from '../recipes/button.css.js'; 8 - export { field, fieldLabel, fieldMessage } from '../recipes/field.css.js'; 8 + export type { FieldVariants } from '../recipes/field.css.js'; 9 + export { field } from '../recipes/field.css.js'; 9 10 export type { IconVariants } from '../recipes/icon.css.js'; 10 11 export { icon } from '../recipes/icon.css.js'; 11 12 export type { IconButtonVariants } from '../recipes/icon-button.css.js'; ··· 29 30 } from '../recipes/text.css.js'; 30 31 export { text } from '../recipes/text.css.js'; 31 32 export type { TextInputVariants } from '../recipes/text-input.css.js'; 32 - export { 33 - textInputAdornmentEnd, 34 - textInputAdornmentStart, 35 - textInputControl, 36 - textInputGroup, 37 - } from '../recipes/text-input.css.js'; 33 + export { textInput } from '../recipes/text-input.css.js';
+61
packages/@luke-ui/react/src/recipes/recipe.browser.test.ts
··· 1 + import { expect, expectTypeOf, test } from 'vite-plus/test'; 2 + import type { ComboboxVariants } from './combobox.css.js'; 3 + import { field } from './field.css.js'; 4 + import { textInput } from './text-input.css.js'; 5 + 6 + // The public recipe surface: `field` and `textInput` from `@luke-ui/react/recipes`. 7 + 8 + test('field selects variants at the outer call and returns slot functions', () => { 9 + const slots = field({ necessityIndicator: 'icon', tone: 'error' }); 10 + 11 + expect(typeof slots.root()).toBe('string'); 12 + expect(typeof slots.label()).toBe('string'); 13 + expect(typeof slots.message()).toBe('string'); 14 + }); 15 + 16 + test('slot functions merge an optional extra class', () => { 17 + expect(field().root('extra-class').split(' ')).toContain('extra-class'); 18 + expect(textInput({ size: 'small' }).control('mine').split(' ')).toContain('mine'); 19 + }); 20 + 21 + // Type assertions are compile-time only. 22 + // oxlint-disable-next-line vitest/expect-expect 23 + test('outer variant selection accepts known variants and rejects siblings/unknowns', () => { 24 + expectTypeOf(field).toBeCallableWith({ necessityIndicator: 'icon', tone: 'description' }); 25 + expectTypeOf(textInput).toBeCallableWith({ size: 'medium' }); 26 + 27 + // @ts-expect-error `size` belongs to text-input/combobox, not field. 28 + field({ size: 'small' }); 29 + // @ts-expect-error `icon`/`label` are the only necessity indicators. 30 + field({ necessityIndicator: 'asterisk' }); 31 + // @ts-expect-error `tone` is a field variant, not a text-input one. 32 + textInput({ tone: 'error' }); 33 + // @ts-expect-error `large` is not a text-input size. 34 + textInput({ size: 'large' }); 35 + }); 36 + 37 + // Type assertions are compile-time only. 38 + // oxlint-disable-next-line vitest/expect-expect 39 + test('slot functions take only an optional class string, never variant args', () => { 40 + const slots = field({ tone: 'error' }); 41 + 42 + expectTypeOf(slots.root).toBeCallableWith('extra'); 43 + expectTypeOf(slots.root).toBeCallableWith(); 44 + 45 + // Negative cases are compile-time only; this closure is never called so the 46 + // invalid arguments are type-checked without reaching `cx` at runtime. 47 + const rejectsVariantArgs = () => { 48 + // @ts-expect-error Slot functions do not accept variant selections. 49 + slots.root({ tone: 'error' }); 50 + // @ts-expect-error Slot functions take a class string, not a number. 51 + slots.label(42); 52 + }; 53 + void rejectsVariantArgs; 54 + }); 55 + 56 + // Type assertions are compile-time only. 57 + // oxlint-disable-next-line vitest/expect-expect 58 + test('internal ComboboxVariants type is the combobox size selection', () => { 59 + const variants: ComboboxVariants = { size: 'medium' }; 60 + expectTypeOf(variants.size).toEqualTypeOf<'medium' | 'small' | undefined>(); 61 + });
+457
packages/@luke-ui/react/src/recipes/recipe.ts
··· 1 + import type { StyleRule } from '@vanilla-extract/css'; 2 + import { addFunctionSerializer } from '@vanilla-extract/css/functionSerializer'; 3 + import { recipe as vanillaRecipe } from '@vanilla-extract/recipes'; 4 + import type { DistributiveOmit } from '../types/distributive-omit.js'; 5 + import { cx } from '../utils/index.js'; 6 + 7 + /** 8 + * `recipe()` styling helper for Vanilla Extract. 9 + * 10 + * One helper builds both single-part and multi-part (slotted) recipes and emits 11 + * static CSS in the `recipes` cascade layer. You pick variants at the outer call. 12 + * A single-part recipe returns a class string. A multi-part recipe returns one 13 + * function per slot, each taking an optional extra class to merge. 14 + * 15 + * `recipe()` runs at build time inside a `.css.ts` module. It splits a slotted 16 + * config into one recipe per slot, in declaration order, so its generated CSS and 17 + * class names match hand-written per-slot recipes byte-for-byte, and it applies the 18 + * `recipeInLayer` wrapping inline. Vanilla Extract only serialises `.css.ts` 19 + * exports, and a plain `.ts` helper cannot import a value from a function-exporting 20 + * `.css.ts` (such as `layered-style`) without turning that module into a failing 21 + * serialization boundary, so the `recipes`-layer wrapping is applied here directly 22 + * over `layers.recipes`. The returned function is registered with Vanilla Extract's 23 + * function serializer so it survives the `.css.ts` build boundary, and 24 + * `createRecipe`/`createSingleRecipe` rebuild it at runtime. 25 + */ 26 + 27 + // --------------------------------------------------------------------------- 28 + // Config surface (types) 29 + // --------------------------------------------------------------------------- 30 + 31 + /** A style rule authored for a recipe: a layered style object or a pre-built class string. */ 32 + type RecipeStyleRule = DistributiveOmit<StyleRule, '@layer'> | string; 33 + 34 + /** Maps the string variant keys `'true'`/`'false'` onto `boolean` for selection. */ 35 + type BooleanMap<T> = T extends 'true' | 'false' ? boolean : T; 36 + 37 + /** Variant groups for a single-part recipe: group name to value name to style rule. */ 38 + type VariantGroups = Record<string, Record<string, RecipeStyleRule>>; 39 + 40 + /** Outer selection for a single-part recipe. */ 41 + type VariantSelection<Variants extends VariantGroups> = { 42 + -readonly [Group in keyof Variants]?: BooleanMap<keyof Variants[Group]> | undefined; 43 + }; 44 + 45 + /** A compound variant for a single-part recipe. */ 46 + interface CompoundVariant<Variants extends VariantGroups> { 47 + variants: VariantSelection<Variants>; 48 + style: RecipeStyleRule; 49 + } 50 + 51 + /** Single-part recipe config. */ 52 + interface SinglePartConfig<Variants extends VariantGroups> { 53 + base?: RecipeStyleRule; 54 + variants?: Variants; 55 + defaultVariants?: VariantSelection<Variants>; 56 + compoundVariants?: Array<CompoundVariant<Variants>>; 57 + extend?: SinglePartConfig<VariantGroups>; 58 + } 59 + 60 + /** The runtime function a single-part `recipe()` returns. */ 61 + type SinglePartRecipe<Variants extends VariantGroups> = ( 62 + selection?: VariantSelection<Variants>, 63 + ) => string; 64 + 65 + /** A per-slot style map: slot name to style for that slot. */ 66 + type SlotStyles<Slot extends string> = Partial<Record<Slot, RecipeStyleRule>>; 67 + 68 + /** Variant groups for a slotted recipe: group to value to per-slot styles. */ 69 + type SlotVariantGroups<Slot extends string> = Record<string, Record<string, SlotStyles<Slot>>>; 70 + 71 + /** Outer selection for a slotted recipe. */ 72 + type SlotVariantSelection<Variants extends SlotVariantGroups<string>> = { 73 + -readonly [Group in keyof Variants]?: BooleanMap<keyof Variants[Group]> | undefined; 74 + }; 75 + 76 + /** A compound variant for a slotted recipe, whose style is keyed by slot. */ 77 + interface SlotCompoundVariant<Slot extends string, Variants extends SlotVariantGroups<Slot>> { 78 + variants: SlotVariantSelection<Variants>; 79 + style: SlotStyles<Slot>; 80 + } 81 + 82 + /** Slotted recipe config. */ 83 + interface MultiPartConfig<Slot extends string, Variants extends SlotVariantGroups<Slot>> { 84 + slots: Record<Slot, RecipeStyleRule>; 85 + variants?: Variants; 86 + defaultVariants?: SlotVariantSelection<Variants>; 87 + compoundVariants?: Array<SlotCompoundVariant<Slot, Variants>>; 88 + extend?: MultiPartConfig<Slot, SlotVariantGroups<Slot>>; 89 + } 90 + 91 + /** A single slot function: takes an optional extra class and returns a class string. */ 92 + type SlotFn = (extraClass?: string) => string; 93 + 94 + /** The runtime function a slotted `recipe()` returns. */ 95 + type MultiPartRecipe<Slot extends string, Variants extends SlotVariantGroups<Slot>> = ( 96 + selection?: SlotVariantSelection<Variants>, 97 + ) => Record<Slot, SlotFn>; 98 + 99 + /** A raw slotted config as accepted by `recipe`. Internal to this module. */ 100 + type AnyMultiPartConfig = MultiPartConfig<string, SlotVariantGroups<string>>; 101 + 102 + /** 103 + * Authoring constraint for a slotted config. Apply it with 104 + * `{ … } as const satisfies SlottedConfigInput` at the definition site: `as const` 105 + * keeps the literal slot names and variant values that `recipe()` infers, while 106 + * `satisfies` type-checks every slot and variant style against `StyleRule` (so a 107 + * mistyped CSS property is caught where it is written, not silently accepted by 108 + * `recipe()`'s structural inference). 109 + */ 110 + export interface SlottedConfigInput { 111 + slots: Record<string, RecipeStyleRule>; 112 + variants?: Record<string, Record<string, Record<string, RecipeStyleRule>>>; 113 + defaultVariants?: Record<string, string | number | boolean>; 114 + compoundVariants?: ReadonlyArray<{ 115 + variants: Record<string, string | number | boolean>; 116 + style: Record<string, RecipeStyleRule>; 117 + }>; 118 + } 119 + 120 + /** Derives the outer variant selection type for a built recipe. */ 121 + export type RecipeSelection<Fn> = Fn extends (selection?: infer Selection) => unknown 122 + ? NonNullable<Selection> 123 + : never; 124 + 125 + // --------------------------------------------------------------------------- 126 + // recipe (build time) 127 + // --------------------------------------------------------------------------- 128 + 129 + /** Builds a slotted recipe (variant selection at the outer call, one function per slot). */ 130 + export function recipe<const Slot extends string, const Variants extends SlotVariantGroups<Slot>>( 131 + config: MultiPartConfig<Slot, Variants>, 132 + ): MultiPartRecipe<Slot, Variants>; 133 + 134 + /** Builds a single-part recipe (variant selection at the outer call, returns a class string). */ 135 + export function recipe<const Variants extends VariantGroups>( 136 + config: SinglePartConfig<Variants>, 137 + ): SinglePartRecipe<Variants>; 138 + 139 + export function recipe(config: AnyMultiPartConfig | SinglePartConfig<VariantGroups>): unknown { 140 + if (isMultiPart(config)) { 141 + const descriptor = buildSlottedDescriptor(config); 142 + const fn = createRecipe(descriptor); 143 + registerSerializer(fn, 'createRecipe', [descriptor]); 144 + return fn; 145 + } 146 + 147 + const built = buildSinglePart(config); 148 + const fn = createSingleRecipe(built); 149 + registerSerializer(fn, 'createSingleRecipe', [built]); 150 + return fn; 151 + } 152 + 153 + // --------------------------------------------------------------------------- 154 + // Build helpers 155 + // --------------------------------------------------------------------------- 156 + 157 + const SERIALIZER_IMPORT_PATH = './recipe.js'; 158 + 159 + /** The `args` position of `addFunctionSerializer`'s config. */ 160 + type SerializerArgs = Parameters<typeof addFunctionSerializer>[1]['args']; 161 + 162 + /** 163 + * Registers a runtime constructor with Vanilla Extract's function serializer. The 164 + * args carry built recipe runtime functions (each marked by the serializer) 165 + * alongside plain descriptor data. They serialise correctly at build time even 166 + * though the `Serializable` arg type cannot express the recipe functions, so the 167 + * array is bridged to that type here. 168 + */ 169 + function registerSerializer(fn: object, importName: string, args: ReadonlyArray<unknown>): void { 170 + addFunctionSerializer(fn, { 171 + importPath: SERIALIZER_IMPORT_PATH, 172 + importName, 173 + args: args as SerializerArgs, 174 + }); 175 + } 176 + 177 + function buildSinglePart(config: SinglePartConfig<VariantGroups>): BuiltRecipe { 178 + const resolved = config.extend ? mergeSingleConfigs(config.extend, config) : config; 179 + 180 + return recipeInRecipesLayer({ 181 + ...(resolved.base === undefined ? {} : { base: resolved.base }), 182 + ...(resolved.variants === undefined ? {} : { variants: resolved.variants }), 183 + ...(resolved.defaultVariants === undefined 184 + ? {} 185 + : { defaultVariants: resolved.defaultVariants }), 186 + ...(resolved.compoundVariants === undefined 187 + ? {} 188 + : { compoundVariants: resolved.compoundVariants }), 189 + }); 190 + } 191 + 192 + function mergeSingleConfigs( 193 + base: SinglePartConfig<VariantGroups>, 194 + override: SinglePartConfig<VariantGroups>, 195 + ): SinglePartConfig<VariantGroups> { 196 + const variants: VariantGroups = { ...base.variants }; 197 + if (override.variants !== undefined) { 198 + for (const [group, values] of Object.entries(override.variants)) { 199 + variants[group] = { ...variants[group], ...values }; 200 + } 201 + } 202 + 203 + const mergedBase = override.base ?? base.base; 204 + 205 + return { 206 + ...(mergedBase === undefined ? {} : { base: mergedBase }), 207 + ...(Object.keys(variants).length > 0 ? { variants } : {}), 208 + defaultVariants: { ...base.defaultVariants, ...override.defaultVariants }, 209 + compoundVariants: [...(base.compoundVariants ?? []), ...(override.compoundVariants ?? [])], 210 + }; 211 + } 212 + 213 + function buildSlottedDescriptor(config: AnyMultiPartConfig): SlottedRecipeDescriptor { 214 + const resolved = config.extend ? mergeConfigs(config.extend, withoutExtend(config)) : config; 215 + 216 + const slotNames = Object.keys(resolved.slots); 217 + const slots: Record<string, BuiltRecipe> = {}; 218 + const slotGroups: Record<string, ReadonlyArray<string>> = {}; 219 + 220 + for (const slotName of slotNames) { 221 + const variants: Record<string, Record<string, RecipeStyleRule>> = {}; 222 + const groupsForSlot: Array<string> = []; 223 + 224 + if (resolved.variants !== undefined) { 225 + for (const [group, values] of Object.entries(resolved.variants)) { 226 + const slotValues: Record<string, RecipeStyleRule> = {}; 227 + let hasSlot = false; 228 + 229 + for (const [value, slotStyles] of Object.entries(values)) { 230 + const style = slotStyles[slotName]; 231 + if (style !== undefined) { 232 + slotValues[value] = style; 233 + hasSlot = true; 234 + } 235 + } 236 + 237 + if (hasSlot) { 238 + variants[group] = slotValues; 239 + groupsForSlot.push(group); 240 + } 241 + } 242 + } 243 + 244 + const compoundVariants: Array<{ variants: Record<string, unknown>; style: RecipeStyleRule }> = 245 + []; 246 + if (resolved.compoundVariants !== undefined) { 247 + for (const compound of resolved.compoundVariants) { 248 + const style = compound.style[slotName]; 249 + if (style !== undefined) { 250 + compoundVariants.push({ variants: compound.variants, style }); 251 + } 252 + } 253 + } 254 + 255 + const defaultVariants = pickGroups(resolved.defaultVariants, groupsForSlot); 256 + 257 + slots[slotName] = recipeInRecipesLayer({ 258 + base: resolved.slots[slotName], 259 + ...(groupsForSlot.length > 0 ? { variants } : {}), 260 + ...(defaultVariants !== undefined && Object.keys(defaultVariants).length > 0 261 + ? { defaultVariants } 262 + : {}), 263 + ...(compoundVariants.length > 0 ? { compoundVariants } : {}), 264 + }); 265 + slotGroups[slotName] = groupsForSlot; 266 + } 267 + 268 + return { slots, slotGroups }; 269 + } 270 + 271 + // --------------------------------------------------------------------------- 272 + // Layer wrapping (mirrors `recipeInLayer` for the `recipes` layer) 273 + // --------------------------------------------------------------------------- 274 + 275 + type LayeredStyleRule = DistributiveOmit<StyleRule, '@layer'>; 276 + 277 + // `layers.recipes` from `styles/layers.css.ts` resolves to this literal global 278 + // layer name. It is inlined rather than imported because importing a value from a 279 + // `.css.ts` into this plain `.ts` would turn `layers.css.ts` into a serialization 280 + // boundary and re-emit its `@layer` declarations. The byte-comparison build 281 + // proves this matches the shipped `@layer recipes { … }` wrapping. 282 + const RECIPES_LAYER = 'recipes'; 283 + 284 + interface RecipeInLayerOptions { 285 + base?: RecipeStyleRule; 286 + variants?: Record<string, Record<string, RecipeStyleRule>>; 287 + defaultVariants?: Record<string, unknown>; 288 + compoundVariants?: Array<{ variants: Record<string, unknown>; style: RecipeStyleRule }>; 289 + } 290 + 291 + function withRecipesLayer(rule: LayeredStyleRule): StyleRule { 292 + return { '@layer': { [RECIPES_LAYER]: rule } }; 293 + } 294 + 295 + function withLayerIfStyleRule(styleRule: RecipeStyleRule): RecipeStyleRule { 296 + return typeof styleRule === 'string' ? styleRule : withRecipesLayer(styleRule); 297 + } 298 + 299 + /** Builds a Vanilla Extract recipe with every style wrapped in the `recipes` layer. */ 300 + function recipeInRecipesLayer(options: RecipeInLayerOptions): BuiltRecipe { 301 + const layeredVariants = 302 + options.variants === undefined 303 + ? undefined 304 + : Object.fromEntries( 305 + Object.entries(options.variants).map(([variantName, variantValues]) => [ 306 + variantName, 307 + Object.fromEntries( 308 + Object.entries(variantValues).map(([variantValue, styleRule]) => [ 309 + variantValue, 310 + withLayerIfStyleRule(styleRule), 311 + ]), 312 + ), 313 + ]), 314 + ); 315 + 316 + const layeredCompoundVariants = 317 + options.compoundVariants === undefined 318 + ? undefined 319 + : options.compoundVariants.map((compound) => ({ 320 + ...compound, 321 + style: withLayerIfStyleRule(compound.style), 322 + })); 323 + 324 + // `vanillaRecipe`'s generic infers a variant map from a statically-known config. 325 + // This helper assembles the config dynamically (per slot, with layered rules), so 326 + // the input is bridged through `never` and the result through the recipe's own 327 + // runtime contract, `BuiltRecipe`. 328 + const built = vanillaRecipe({ 329 + ...options, 330 + ...(options.base === undefined ? {} : { base: withLayerIfStyleRule(options.base) }), 331 + ...(layeredVariants === undefined ? {} : { variants: layeredVariants }), 332 + ...(layeredCompoundVariants === undefined ? {} : { compoundVariants: layeredCompoundVariants }), 333 + } as never); 334 + 335 + return built as BuiltRecipe; 336 + } 337 + 338 + // --------------------------------------------------------------------------- 339 + // extend (single-base inheritance) 340 + // --------------------------------------------------------------------------- 341 + 342 + function isMultiPart( 343 + config: AnyMultiPartConfig | SinglePartConfig<VariantGroups>, 344 + ): config is AnyMultiPartConfig { 345 + return 'slots' in config && isObject(config.slots); 346 + } 347 + 348 + function isObject(value: unknown): value is Record<string, unknown> { 349 + return typeof value === 'object' && value !== null; 350 + } 351 + 352 + function withoutExtend(config: AnyMultiPartConfig): AnyMultiPartConfig { 353 + const { extend: _extend, ...rest } = config; 354 + return rest; 355 + } 356 + 357 + /** 358 + * Merges a slotted config's single `extend` base into it. Slots, variant groups, 359 + * variant values, per-slot variant styles, and default variants are combined. The 360 + * extending config takes precedence on same-named keys, and compound variants are 361 + * concatenated. Internal to `extend` resolution. 362 + */ 363 + function mergeConfigs(...configs: Array<AnyMultiPartConfig>): AnyMultiPartConfig { 364 + const slots: Record<string, RecipeStyleRule> = {}; 365 + const variants: Record<string, Record<string, SlotStyles<string>>> = {}; 366 + const defaultVariants: SlotVariantSelection<SlotVariantGroups<string>> = {}; 367 + const compoundVariants: Array<SlotCompoundVariant<string, SlotVariantGroups<string>>> = []; 368 + 369 + for (const config of configs) { 370 + const resolved = config.extend ? mergeConfigs(config.extend, withoutExtend(config)) : config; 371 + 372 + Object.assign(slots, resolved.slots); 373 + 374 + if (resolved.variants !== undefined) { 375 + for (const [group, values] of Object.entries(resolved.variants)) { 376 + const mergedGroup = variants[group] ?? {}; 377 + for (const [value, slotStyles] of Object.entries(values)) { 378 + mergedGroup[value] = { ...mergedGroup[value], ...slotStyles }; 379 + } 380 + variants[group] = mergedGroup; 381 + } 382 + } 383 + 384 + if (resolved.defaultVariants !== undefined) { 385 + Object.assign(defaultVariants, resolved.defaultVariants); 386 + } 387 + 388 + if (resolved.compoundVariants !== undefined) { 389 + compoundVariants.push(...resolved.compoundVariants); 390 + } 391 + } 392 + 393 + return { 394 + slots, 395 + ...(Object.keys(variants).length > 0 ? { variants } : {}), 396 + ...(Object.keys(defaultVariants).length > 0 ? { defaultVariants } : {}), 397 + ...(compoundVariants.length > 0 ? { compoundVariants } : {}), 398 + }; 399 + } 400 + 401 + // --------------------------------------------------------------------------- 402 + // Runtime (referenced by the function serializer at import time) 403 + // --------------------------------------------------------------------------- 404 + 405 + /** A built Vanilla Extract recipe runtime function (one per slot, or the whole single-part recipe). */ 406 + type BuiltRecipe = (selection?: Record<string, unknown>) => string; 407 + 408 + /** Serialized descriptor for a slotted recipe: per-slot runtime fns and their variant groups. */ 409 + interface SlottedRecipeDescriptor { 410 + slots: Record<string, BuiltRecipe>; 411 + slotGroups: Record<string, ReadonlyArray<string>>; 412 + } 413 + 414 + /** Narrows an outer selection to the variant groups a given slot actually uses. */ 415 + function pickGroups( 416 + selection: Record<string, unknown> | undefined, 417 + groups: ReadonlyArray<string>, 418 + ): Record<string, unknown> | undefined { 419 + if (selection === undefined) return undefined; 420 + 421 + const picked: Record<string, unknown> = {}; 422 + for (const group of groups) { 423 + if (group in selection) picked[group] = selection[group]; 424 + } 425 + return picked; 426 + } 427 + 428 + /** 429 + * Rebuilds a slotted recipe: `recipe(selection)` returns one function per slot, 430 + * each taking an optional extra class. Slots evaluate lazily, so reading one slot 431 + * does not compute the others. 432 + * 433 + * @public Imported by path string via Vanilla Extract's function serializer, so 434 + * the reference is invisible to static analysis. 435 + */ 436 + export function createRecipe(descriptor: SlottedRecipeDescriptor) { 437 + const slotEntries = Object.entries(descriptor.slots); 438 + 439 + return (selection?: Record<string, unknown>): Record<string, SlotFn> => { 440 + const slots: Record<string, SlotFn> = {}; 441 + for (const [slotName, built] of slotEntries) { 442 + const groups = descriptor.slotGroups[slotName] ?? []; 443 + slots[slotName] = (extraClass) => cx(built(pickGroups(selection, groups)), extraClass); 444 + } 445 + return slots; 446 + }; 447 + } 448 + 449 + /** 450 + * Rebuilds a single-part recipe: `recipe(selection)` returns a class string. 451 + * 452 + * @public Imported by path string via Vanilla Extract's function serializer, so 453 + * the reference is invisible to static analysis. 454 + */ 455 + export function createSingleRecipe(built: BuiltRecipe) { 456 + return (selection?: Record<string, unknown>): string => built(selection); 457 + }
+4 -4
packages/@luke-ui/react/src/recipes/text-input.browser.test.ts
··· 3 3 import { themeRootClassName } from '../theme/index.js'; 4 4 import { tactileThemeClassName } from '../themes/index.js'; 5 5 import { cx } from '../utils/index.js'; 6 - import { textInputAdornmentStart, textInputGroup } from './text-input.css.js'; 6 + import { textInput } from './text-input.css.js'; 7 7 8 8 let mounted: Array<HTMLElement> = []; 9 9 ··· 126 126 test('adornment divider uses the control border color and disabled text color follows the group', () => { 127 127 const { group, root } = mountGroup(); 128 128 const adornment = group.appendChild(document.createElement('span')); 129 - adornment.className = textInputAdornmentStart({ size: 'medium' }); 129 + adornment.className = textInput({ size: 'medium' }).adornmentStart(); 130 130 131 131 const controlBorderProbe = root.appendChild(document.createElement('div')); 132 132 controlBorderProbe.style.borderColor = 'var(--luke-color-border-control)'; ··· 140 140 expect(getComputedStyle(adornment).color).toBe(getComputedStyle(disabledTextProbe).color); 141 141 }); 142 142 143 - function mountGroup(options: Parameters<typeof textInputGroup>[0] = {}) { 143 + function mountGroup(options: Parameters<typeof textInput>[0] = {}) { 144 144 const root = document.body.appendChild(document.createElement('div')); 145 145 root.className = cx(themeRootClassName, tactileThemeClassName); 146 146 root.dataset.colorMode = 'light'; 147 147 const group = root.appendChild(document.createElement('div')); 148 - group.className = textInputGroup(options); 148 + group.className = textInput(options).group(); 149 149 group.style.transition = 'none'; 150 150 mounted.push(root); 151 151 return { group, root };
+186 -198
packages/@luke-ui/react/src/recipes/text-input.css.ts
··· 1 - import type { RecipeVariants } from '@vanilla-extract/recipes'; 2 1 import { focusRing } from '../styles/focus-ring.js'; 3 - import { recipeInLayer } from '../styles/layered-style.css.js'; 4 2 import { vars } from '../theme/contract.css.js'; 5 3 import { 6 4 composeInputStateSelectors, 7 5 descendantDisabledSelector, 8 6 inputStates, 9 7 } from './input-states.css.js'; 8 + import type { RecipeSelection, SlottedConfigInput } from './recipe.js'; 9 + import { recipe } from './recipe.js'; 10 10 11 11 const { disabled, focusWithin, hover, invalid, invalidFocusWithin, readOnly, readOnlyFocusWithin } = 12 12 composeInputStateSelectors(inputStates); 13 13 14 - /** Vanilla-extract recipe for the `TextInput` group's tactile well chrome. */ 15 - export const textInputGroup = recipeInLayer('recipes', { 16 - base: { 17 - '@media': { 18 - '(forced-colors: active)': { 19 - backgroundColor: 'Field', 20 - borderColor: 'FieldText', 21 - boxShadow: 'none', 22 - color: 'FieldText', 23 - forcedColorAdjust: 'auto', 24 - selectors: { 25 - [disabled]: { 26 - borderColor: 'GrayText', 27 - color: 'GrayText', 28 - opacity: 1, 29 - }, 30 - [focusWithin]: { 31 - outlineColor: 'Highlight', 32 - }, 33 - [invalidFocusWithin]: { 34 - outlineColor: 'Highlight', 14 + /** 15 + * Raw slotted config for the `TextInput` primitive. 16 + * 17 + * Slots: `group` (tactile well chrome), `control` (the input), and 18 + * `adornmentStart` / `adornmentEnd`. 19 + */ 20 + const textInputConfig = { 21 + slots: { 22 + group: { 23 + '@media': { 24 + '(forced-colors: active)': { 25 + backgroundColor: 'Field', 26 + borderColor: 'FieldText', 27 + boxShadow: 'none', 28 + color: 'FieldText', 29 + forcedColorAdjust: 'auto', 30 + selectors: { 31 + [disabled]: { 32 + borderColor: 'GrayText', 33 + color: 'GrayText', 34 + opacity: 1, 35 + }, 36 + [focusWithin]: { 37 + outlineColor: 'Highlight', 38 + }, 39 + [invalidFocusWithin]: { 40 + outlineColor: 'Highlight', 41 + }, 35 42 }, 36 43 }, 37 44 }, 38 - }, 39 - alignItems: 'center', 40 - backgroundColor: vars.color.surface.recessed, 41 - borderColor: vars.color.border.control, 42 - borderRadius: vars.radius.control, 43 - borderStyle: 'solid', 44 - borderWidth: '1px', 45 - boxShadow: vars.depth.recessed, 46 - cursor: 'text', 47 - display: 'inline-flex', 48 - fontFamily: vars.font.family, 49 - inlineSize: '100%', 50 - isolation: 'isolate', 51 - letterSpacing: vars.font[300].letterSpacing, 52 - lineHeight: vars.font[300].lineHeight, 53 - minInlineSize: 0, 54 - outlineColor: 'transparent', 55 - outlineOffset: 0, 56 - outlineStyle: 'solid', 57 - outlineWidth: '2px', 58 - overflow: 'visible', 59 - transitionDuration: vars.motion.duration.fast, 60 - transitionProperty: 'background-color, border-color, color', 61 - transitionTimingFunction: vars.motion.easing.standard, 45 + alignItems: 'center', 46 + backgroundColor: vars.color.surface.recessed, 47 + borderColor: vars.color.border.control, 48 + borderRadius: vars.radius.control, 49 + borderStyle: 'solid', 50 + borderWidth: '1px', 51 + boxShadow: vars.depth.recessed, 52 + cursor: 'text', 53 + display: 'inline-flex', 54 + fontFamily: vars.font.family, 55 + inlineSize: '100%', 56 + isolation: 'isolate', 57 + letterSpacing: vars.font[300].letterSpacing, 58 + lineHeight: vars.font[300].lineHeight, 59 + minInlineSize: 0, 60 + outlineColor: 'transparent', 61 + outlineOffset: 0, 62 + outlineStyle: 'solid', 63 + outlineWidth: '2px', 64 + overflow: 'visible', 65 + transitionDuration: vars.motion.duration.fast, 66 + transitionProperty: 'background-color, border-color, color', 67 + transitionTimingFunction: vars.motion.easing.standard, 62 68 63 - selectors: { 64 - [disabled]: { 65 - cursor: 'not-allowed', 66 - opacity: 0.55, 67 - }, 68 - [focusWithin]: { 69 - borderColor: vars.color.intent.accent.border, 70 - ...focusRing(vars.color.border.focus), 71 - }, 72 - [hover]: { 73 - borderColor: vars.color.intent.accent.border, 74 - }, 75 - [invalid]: { 76 - borderColor: vars.color.intent.danger.border, 77 - }, 78 - [invalidFocusWithin]: { 79 - borderColor: vars.color.intent.danger.border, 80 - ...focusRing(vars.color.border.focus), 81 - }, 82 - [readOnly]: { 83 - backgroundColor: vars.color.surface.canvas, 84 - borderColor: vars.color.border.decorative, 85 - boxShadow: 'none', 86 - }, 87 - [readOnlyFocusWithin]: { 88 - ...focusRing(vars.color.border.focus), 89 - }, 90 - }, 91 - }, 92 - defaultVariants: { 93 - size: 'medium', 94 - }, 95 - variants: { 96 - size: { 97 - medium: { 98 - blockSize: vars.controlSize.medium, 99 - fontSize: vars.font[300].fontSize, 100 - }, 101 - small: { 102 - blockSize: vars.controlSize.small, 103 - fontSize: vars.font[200].fontSize, 104 - letterSpacing: vars.font[200].letterSpacing, 105 - lineHeight: vars.font[200].lineHeight, 69 + selectors: { 70 + [disabled]: { 71 + cursor: 'not-allowed', 72 + opacity: 0.55, 73 + }, 74 + [focusWithin]: { 75 + borderColor: vars.color.intent.accent.border, 76 + ...focusRing(vars.color.border.focus), 77 + }, 78 + [hover]: { 79 + borderColor: vars.color.intent.accent.border, 80 + }, 81 + [invalid]: { 82 + borderColor: vars.color.intent.danger.border, 83 + }, 84 + [invalidFocusWithin]: { 85 + borderColor: vars.color.intent.danger.border, 86 + ...focusRing(vars.color.border.focus), 87 + }, 88 + [readOnly]: { 89 + backgroundColor: vars.color.surface.canvas, 90 + borderColor: vars.color.border.decorative, 91 + boxShadow: 'none', 92 + }, 93 + [readOnlyFocusWithin]: { 94 + ...focusRing(vars.color.border.focus), 95 + }, 106 96 }, 107 97 }, 108 - }, 109 - }); 110 - 111 - /** Vanilla-extract recipe for the `TextInput` control styles. */ 112 - export const textInputControl = recipeInLayer('recipes', { 113 - base: { 114 - appearance: 'none', 115 - backgroundColor: 'transparent', 116 - borderColor: 'transparent', 117 - borderStyle: 'none', 118 - borderWidth: 0, 119 - color: vars.color.text.primary, 120 - cursor: 'text', 121 - flex: 1, 122 - fontFamily: 'inherit', 123 - fontSize: 'inherit', 124 - fontWeight: 'inherit', 125 - inlineSize: '100%', 126 - letterSpacing: 'inherit', 127 - lineHeight: 'inherit', 128 - minInlineSize: 0, 129 - outlineColor: 'transparent', 130 - outlineStyle: 'none', 131 - outlineWidth: 0, 132 - paddingBlockEnd: 0, 133 - paddingBlockStart: 0, 98 + control: { 99 + appearance: 'none', 100 + backgroundColor: 'transparent', 101 + borderColor: 'transparent', 102 + borderStyle: 'none', 103 + borderWidth: 0, 104 + color: vars.color.text.primary, 105 + cursor: 'text', 106 + flex: 1, 107 + fontFamily: 'inherit', 108 + fontSize: 'inherit', 109 + fontWeight: 'inherit', 110 + inlineSize: '100%', 111 + letterSpacing: 'inherit', 112 + lineHeight: 'inherit', 113 + minInlineSize: 0, 114 + outlineColor: 'transparent', 115 + outlineStyle: 'none', 116 + outlineWidth: 0, 117 + paddingBlockEnd: 0, 118 + paddingBlockStart: 0, 134 119 135 - selectors: { 136 - '&::placeholder': { 137 - color: vars.color.text.secondary, 138 - opacity: 1, 139 - }, 140 - '&:where([data-disabled="true"], :disabled)': { 141 - color: vars.color.textDisabled, 142 - cursor: 'not-allowed', 120 + selectors: { 121 + '&::placeholder': { 122 + color: vars.color.text.secondary, 123 + opacity: 1, 124 + }, 125 + '&:where([data-disabled="true"], :disabled)': { 126 + color: vars.color.textDisabled, 127 + cursor: 'not-allowed', 128 + }, 143 129 }, 144 130 }, 145 - }, 146 - defaultVariants: { 147 - size: 'medium', 148 - }, 149 - variants: { 150 - size: { 151 - medium: { 152 - blockSize: vars.controlSize.medium, 153 - paddingInlineEnd: vars.space[300], 154 - paddingInlineStart: vars.space[300], 155 - }, 156 - small: { 157 - blockSize: vars.controlSize.small, 158 - paddingInlineEnd: vars.space[200], 159 - paddingInlineStart: vars.space[200], 131 + adornmentStart: { 132 + alignItems: 'center', 133 + borderInlineEndColor: vars.color.border.control, 134 + borderInlineEndStyle: 'solid', 135 + borderInlineEndWidth: '1px', 136 + color: vars.color.text.secondary, 137 + display: 'inline-flex', 138 + flexShrink: 0, 139 + 140 + selectors: { 141 + [descendantDisabledSelector]: { 142 + color: vars.color.textDisabled, 143 + }, 160 144 }, 161 145 }, 162 - }, 163 - }); 164 - 165 - /** Vanilla-extract recipe for the `TextInput` start-adornment styles. */ 166 - export const textInputAdornmentStart = recipeInLayer('recipes', { 167 - base: { 168 - alignItems: 'center', 169 - borderInlineEndColor: vars.color.border.control, 170 - borderInlineEndStyle: 'solid', 171 - borderInlineEndWidth: '1px', 172 - color: vars.color.text.secondary, 173 - display: 'inline-flex', 174 - flexShrink: 0, 146 + adornmentEnd: { 147 + alignItems: 'center', 148 + borderInlineStartColor: vars.color.border.control, 149 + borderInlineStartStyle: 'solid', 150 + borderInlineStartWidth: '1px', 151 + color: vars.color.text.secondary, 152 + display: 'inline-flex', 153 + flexShrink: 0, 175 154 176 - selectors: { 177 - [descendantDisabledSelector]: { 178 - color: vars.color.textDisabled, 155 + selectors: { 156 + [descendantDisabledSelector]: { 157 + color: vars.color.textDisabled, 158 + }, 179 159 }, 180 160 }, 181 161 }, ··· 185 165 variants: { 186 166 size: { 187 167 medium: { 188 - lineHeight: vars.font[300].lineHeight, 189 - paddingInlineEnd: vars.space[300], 190 - paddingInlineStart: vars.space[300], 168 + group: { 169 + blockSize: vars.controlSize.medium, 170 + fontSize: vars.font[300].fontSize, 171 + }, 172 + control: { 173 + blockSize: vars.controlSize.medium, 174 + paddingInlineEnd: vars.space[300], 175 + paddingInlineStart: vars.space[300], 176 + }, 177 + adornmentStart: { 178 + lineHeight: vars.font[300].lineHeight, 179 + paddingInlineEnd: vars.space[300], 180 + paddingInlineStart: vars.space[300], 181 + }, 182 + adornmentEnd: { 183 + lineHeight: vars.font[300].lineHeight, 184 + paddingInlineEnd: vars.space[300], 185 + paddingInlineStart: vars.space[300], 186 + }, 191 187 }, 192 188 small: { 193 - lineHeight: vars.font[200].lineHeight, 194 - paddingInlineEnd: vars.space[200], 195 - paddingInlineStart: vars.space[200], 189 + group: { 190 + blockSize: vars.controlSize.small, 191 + fontSize: vars.font[200].fontSize, 192 + letterSpacing: vars.font[200].letterSpacing, 193 + lineHeight: vars.font[200].lineHeight, 194 + }, 195 + control: { 196 + blockSize: vars.controlSize.small, 197 + paddingInlineEnd: vars.space[200], 198 + paddingInlineStart: vars.space[200], 199 + }, 200 + adornmentStart: { 201 + lineHeight: vars.font[200].lineHeight, 202 + paddingInlineEnd: vars.space[200], 203 + paddingInlineStart: vars.space[200], 204 + }, 205 + adornmentEnd: { 206 + lineHeight: vars.font[200].lineHeight, 207 + paddingInlineEnd: vars.space[200], 208 + paddingInlineStart: vars.space[200], 209 + }, 196 210 }, 197 211 }, 198 212 }, 199 - }); 213 + } as const satisfies SlottedConfigInput; 200 214 201 - /** Vanilla-extract recipe for the `TextInput` end-adornment styles. */ 202 - export const textInputAdornmentEnd = recipeInLayer('recipes', { 203 - base: { 204 - alignItems: 'center', 205 - borderInlineStartColor: vars.color.border.control, 206 - borderInlineStartStyle: 'solid', 207 - borderInlineStartWidth: '1px', 208 - color: vars.color.text.secondary, 209 - display: 'inline-flex', 210 - flexShrink: 0, 215 + /** 216 + * Slotted recipe for the `TextInput` primitive. 217 + * 218 + * `textInput({ size }).group() / .control() / .adornmentStart() / .adornmentEnd()`. 219 + */ 220 + export const textInput = recipe(textInputConfig); 211 221 212 - selectors: { 213 - [descendantDisabledSelector]: { 214 - color: vars.color.textDisabled, 215 - }, 216 - }, 217 - }, 218 - defaultVariants: { 219 - size: 'medium', 220 - }, 221 - variants: { 222 - size: { 223 - medium: { 224 - lineHeight: vars.font[300].lineHeight, 225 - paddingInlineEnd: vars.space[300], 226 - paddingInlineStart: vars.space[300], 227 - }, 228 - small: { 229 - lineHeight: vars.font[200].lineHeight, 230 - paddingInlineEnd: vars.space[200], 231 - paddingInlineStart: vars.space[200], 232 - }, 233 - }, 234 - }, 235 - }); 222 + /** Outer variant selection for the `TextInput` recipe. */ 223 + export type TextInputVariants = RecipeSelection<typeof textInput>; 236 224 237 - /** Variant type for the `TextInput` recipe. */ 238 - export type TextInputVariants = RecipeVariants<typeof textInputGroup>; 225 + /** Allowed `size` values for the `TextInput` recipe. */ 226 + export type TextInputSize = keyof typeof textInputConfig.variants.size;
+9 -12
packages/@luke-ui/react/src/text-field/primitive/index.tsx
··· 7 7 import * as styles from '../../recipes/text-input.css.js'; 8 8 import type { DistributiveOmit } from '../../types/distributive-omit.js'; 9 9 import type { Prettify } from '../../types/prettify.js'; 10 - import { cx } from '../../utils/index.js'; 11 10 12 - interface TextInputVariantProps extends NonNullable<styles.TextInputVariants> {} 11 + /** Allowed `size` values for `TextInput`. */ 12 + export type TextInputSize = styles.TextInputSize; 13 13 14 14 interface TextInputStyleProps { 15 15 /** 16 16 * Sets the input size. 17 17 * @default 'medium' 18 18 */ 19 - size?: TextInputVariantProps['size']; 19 + size?: TextInputSize; 20 20 } 21 - 22 - /** Allowed `size` values for `TextInput`. */ 23 - export type TextInputSize = NonNullable<TextInputVariantProps['size']>; 24 21 25 22 type _TextInputOmit = DistributiveOmit<RacInputProps, 'className' | keyof TextInputStyleProps>; 26 23 interface _TextInputProps extends _TextInputOmit, TextInputStyleProps { ··· 51 48 size = 'medium', 52 49 ...inputProps 53 50 } = props; 51 + 52 + const slots = styles.textInput({ size }); 54 53 55 54 return ( 56 55 <RacGroup 57 56 className={composeRenderProps(className, (value) => { 58 - return cx(styles.textInputGroup({ size }), value); 57 + return slots.group(value); 59 58 })} 60 59 > 61 60 {adornmentStart != null ? ( 62 - <span className={styles.textInputAdornmentStart({ size })}>{adornmentStart}</span> 61 + <span className={slots.adornmentStart()}>{adornmentStart}</span> 63 62 ) : null} 64 63 <RacInput 65 64 {...inputProps} 66 65 className={composeRenderProps(inputClassName, (value) => { 67 - return cx(styles.textInputControl({ size }), value); 66 + return slots.control(value); 68 67 })} 69 68 /> 70 - {adornmentEnd != null ? ( 71 - <span className={styles.textInputAdornmentEnd({ size })}>{adornmentEnd}</span> 72 - ) : null} 69 + {adornmentEnd != null ? <span className={slots.adornmentEnd()}>{adornmentEnd}</span> : null} 73 70 </RacGroup> 74 71 ); 75 72 }