diff --git a/README.md b/README.md index 9d8e62ea..0adbba37 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ React design system using `vanilla-extract`. ## TODO +- Revise tokens +- Make docs more visual + Components to build: ### Core diff --git a/packages/@luke-ui/react/src/combobox-field/combobox-field.docs.md b/packages/@luke-ui/react/src/combobox-field/combobox-field.docs.md index b1b829dd..343ae3e9 100644 --- a/packages/@luke-ui/react/src/combobox-field/combobox-field.docs.md +++ b/packages/@luke-ui/react/src/combobox-field/combobox-field.docs.md @@ -19,10 +19,23 @@ ## API Shape - Root props follow React Aria `ComboBox` naming. -- Composed convenience props: `label`, `description`, `errorMessage`, `necessityIndicator`, `size`, `placeholder`. +- Composed convenience props: `label`, `description`, `errorMessage`, `necessityIndicator`, `size`, + `placeholder`. - `children` renders dynamic items from `items` or `defaultItems`. - `listBoxProps` and `loadMoreItem` are lower-level escape hatches. +## Selection indicators + +The selected option shows a checkmark in the listbox, and while a selection is present the control +shows a clear button before the trigger. Pressing it clears the selection and the input text. The +clear button is omitted when the field is disabled or read-only. + +```tsx + + {(item) => {item.label}} + +``` + ## Required fields ```tsx @@ -62,8 +75,7 @@ import { Form } from 'react-aria-components'; ## Async options -Pass `loadingState` for built-in loading and empty states. Control options -externally via `items`. +Pass `loadingState` for built-in loading and empty states. Control options externally via `items`. ```tsx { + const canvas = within(canvasElement); + const page = within(document.body); + const combobox = canvas.getByRole('combobox', { name: 'Country' }); + + // A selection is present, so the clear button renders. + const clearButton = canvas.getByRole('button', { name: 'Clear selection' }); + await expect(combobox).toHaveValue('Canada'); + + // The selected option is marked with a checkmark. + await userEvent.click(combobox); + const selected = page.getByRole('option', { name: 'Canada' }); + await expect(selected).toHaveAttribute('aria-selected', 'true'); + await expect(selected.querySelector('svg')).not.toBeNull(); + + // Clearing resets the value and removes the clear button. + await userEvent.click(clearButton); + await expect(combobox).toHaveValue(''); + await expect(canvas.queryByRole('button', { name: 'Clear selection' })).not.toBeInTheDocument(); + }, + render: function Render() { + return ( + + {(item) => {item.label}} + + ); + }, +}); + /** * Controlled mode uses `value`, `onChange`, and `items`. * The parent component manages the state. diff --git a/packages/@luke-ui/react/src/combobox-field/index.tsx b/packages/@luke-ui/react/src/combobox-field/index.tsx index 9398da2b..5a90a59e 100644 --- a/packages/@luke-ui/react/src/combobox-field/index.tsx +++ b/packages/@luke-ui/react/src/combobox-field/index.tsx @@ -7,6 +7,7 @@ import type { FieldNecessityIndicator } from '../field/primitive/label.js'; import { Icon } from '../icon/index.js'; import { LoadingSpinner } from '../loading-spinner/index.js'; import type { DistributiveOmit } from '../types/distributive-omit.js'; +import { ComboboxClearButton } from './primitive/clear-button.js'; import { ComboboxControl } from './primitive/control.js'; import { ComboboxEmptyState } from './primitive/empty-state.js'; import { ComboboxTextInput } from './primitive/input.js'; @@ -95,14 +96,21 @@ export function ComboboxField(props: ComboboxFieldProps): J } = restProps; const isAsync = loadingState != null; + const isInteractive = + comboboxInputProps.isDisabled !== true && comboboxInputProps.isReadOnly !== true; return ( size={size} {...comboboxInputProps}> + {isInteractive ? ( + + + + ) : null} - + {} + +interface ComboboxStyleProps { + size?: ComboboxVariantProps['size']; +} + +/** + * Props for the combobox clear button. + * + * @tier primitive + */ +export interface ComboboxClearButtonProps + extends DistributiveOmit, ComboboxStyleProps { + className?: RacButtonProps['className']; +} + +/** Clears the combobox selection. Renders nothing while no option is selected. */ +export function ComboboxClearButton(props: ComboboxClearButtonProps): JSX.Element | null { + const { size: sizeProp, ...buttonProps } = props; + const size = useComboboxSize(sizeProp); + const state = useContext(ComboBoxStateContext); + const hasValue = Array.isArray(state?.value) ? state.value.length > 0 : state?.value != null; + + if (state == null || !hasValue) { + return null; + } + + return ( + + { + return cx(styles.comboboxClearButton({ size }), className); + })} + onPress={(event) => { + state.setValue(Array.isArray(state.value) ? [] : null); + state.setInputValue(''); + buttonProps.onPress?.(event); + }} + // Opt out of the ComboBox button slot so pressing clears the selection + // instead of toggling the popover. + slot={null} + /> + + ); +} diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/index.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/index.tsx index beb67d41..4de28158 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/index.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/index.tsx @@ -1,3 +1,5 @@ +export type { ComboboxClearButtonProps } from './clear-button.js'; +export { ComboboxClearButton } from './clear-button.js'; export type { ComboboxControlProps } from './control.js'; export { ComboboxControl } from './control.js'; export type { ComboboxEmptyStateProps } from './empty-state.js'; 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 05700e03..e590e6f3 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/item.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/item.tsx @@ -8,7 +8,10 @@ import { ListBoxLoadMoreItem as RacListBoxLoadMoreItem, } from 'react-aria-components/ComboBox'; import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import { IconSizeProvider } from '../../icon-size-context/index.js'; +import { Icon } from '../../icon/index.js'; 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 { cx } from '../../utils/index.js'; import { useComboboxSize } from './size-context.js'; @@ -32,12 +35,28 @@ export function ComboboxItem(props: ComboboxItemProps): JSX const size = useComboboxSize(sizeProp); return ( - { - return cx(styles.comboboxItem({ size }), className); - })} - /> + + { + return cx(styles.comboboxItem({ size }), className); + })} + > + {composeRenderProps(itemProps.children, (children, { isSelected }) => { + return ( + <> + {children} + {isSelected ? ( + + ) : null} + + ); + })} + + ); } 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 820125ba..5ce73825 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/trigger.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/trigger.tsx @@ -2,7 +2,9 @@ import type { JSX } from 'react'; import type { ButtonProps as RacButtonProps } from 'react-aria-components/ComboBox'; import { Button as RacButton } from 'react-aria-components/ComboBox'; import { composeRenderProps } from 'react-aria-components/composeRenderProps'; +import { IconSizeProvider } from '../../icon-size-context/index.js'; 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 { cx } from '../../utils/index.js'; import { useComboboxSize } from './size-context.js'; @@ -29,11 +31,13 @@ export function ComboboxTrigger(props: ComboboxTriggerProps): JSX.Element { const size = useComboboxSize(sizeProp); return ( - { - return cx(styles.comboboxTrigger({ size }), className); - })} - /> + + { + return cx(styles.comboboxTrigger({ size }), className); + })} + /> + ); } diff --git a/packages/@luke-ui/react/src/recipes/combobox.css.ts b/packages/@luke-ui/react/src/recipes/combobox.css.ts index ff135c82..278ab9b3 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.css.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.css.ts @@ -1,25 +1,18 @@ import type { RecipeVariants } from '@vanilla-extract/recipes'; import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; import { vars } from '../styles/vars.css.js'; +import { descendantDisabledSelector, inputChromeStyles, inputStates } from './input-states.css.js'; -const transitionProperty = 'color, background-color, border-color'; - -const disabledState = - '[data-disabled="true"], [aria-disabled="true"], :has(input:disabled), :has(button:disabled), :has([data-disabled="true"])'; -const descendantDisabledState = '[data-disabled="true"], [aria-disabled="true"]'; -const focusWithinState = '[data-focus-within="true"], [data-focused="true"], :focus-within'; -const hoverState = '[data-hovered="true"], :hover'; -const invalidState = - '[data-invalid="true"], [aria-invalid="true"], :has(:invalid), :has(input[aria-invalid="true"]), :has([data-invalid="true"]), :has([aria-invalid="true"])'; -const readOnlyState = '[data-readonly="true"], :has(:read-only)'; - -const controlDisabledSelector = `&:where(${disabledState})`; -const controlFocusWithinSelector = `&:where(${focusWithinState})`; -const controlHoverSelector = `&:where(${hoverState}):not(:where(${disabledState})):not(:where(${focusWithinState}))`; -const controlInvalidSelector = `&:where(${invalidState})`; -const controlInvalidFocusWithinSelector = `&:where(${invalidState}):where(${focusWithinState})`; -const controlReadOnlySelector = `&:where(${readOnlyState})`; -const descendantDisabledSelector = `:where(${descendantDisabledState}) &`; +/** + * The combobox control is a group wrapping an input and a trigger button, and + * unlike a text field's group it does not receive RAC's field data attributes + * itself — so state detection extends the defaults to also watch descendants. + */ +const comboboxStates = { + ...inputStates, + disabled: `${inputStates.disabled}, :has(button:disabled), :has([data-disabled="true"])`, + invalid: `${inputStates.invalid}, :has([data-invalid="true"]), :has([aria-invalid="true"])`, +}; export const comboboxRoot = styleInLayer('recipes', { display: 'flex', @@ -29,56 +22,9 @@ export const comboboxRoot = styleInLayer('recipes', { }); export const comboboxControl = recipeInLayer('recipes', { - base: { - alignItems: 'center', - backgroundColor: vars.backgroundColor.input, - borderColor: vars.border.input, - borderRadius: vars.borderRadius.medium, - borderStyle: 'solid', - borderWidth: vars.borderWidth.thin, - color: vars.themeColor.inputColor, - display: 'inline-flex', - fontFamily: vars.font.family.body, - inlineSize: '100%', - minInlineSize: 0, - overflow: 'hidden', - - selectors: { - [controlDisabledSelector]: { - backgroundColor: vars.backgroundColor.inputDisabled, - borderColor: vars.border.default, - color: vars.foregroundColor.disabled, - cursor: 'not-allowed', - }, - [controlHoverSelector]: { - borderColor: vars.themeColor.paletteThemePrimary400, - }, - [controlFocusWithinSelector]: { - borderColor: vars.themeColor.paletteThemePrimary500, - outlineColor: vars.themeColor.paletteThemePrimary200, - outlineOffset: 0, - outlineStyle: 'solid', - outlineWidth: '3px', - }, - [controlInvalidSelector]: { - borderColor: vars.border.critical, - outlineStyle: 'none', - }, - [controlInvalidFocusWithinSelector]: { - borderColor: vars.border.critical, - outlineColor: vars.themeColor.paletteThemePrimary200, - outlineOffset: 0, - outlineStyle: 'solid', - outlineWidth: '3px', - }, - [controlReadOnlySelector]: { - backgroundColor: vars.backgroundColor.subtle, - }, - }, - transitionDuration: vars.motion.duration.fast, - transitionProperty, - transitionTimingFunction: vars.motion.easing.standard, - }, + // Unlike the text input's borderless disabled look, the disabled combobox + // control keeps a visible border. + base: inputChromeStyles(comboboxStates, { disabledBorderColor: vars.border.default }), defaultVariants: { size: 'medium', }, @@ -166,7 +112,7 @@ export const comboboxTrigger = recipeInLayer('recipes', { selectors: { '&:not(:first-child)': { - backgroundColor: vars.backgroundColor.subtle, + backgroundColor: 'transparent', inlineSize: 'auto', }, '&:not(:first-child)::before': { @@ -219,6 +165,50 @@ export const comboboxTrigger = recipeInLayer('recipes', { }, }); +export const comboboxClearButton = recipeInLayer('recipes', { + base: { + alignItems: 'center', + appearance: 'none', + backgroundColor: 'transparent', + border: 'none', + color: vars.foregroundColor.secondary, + cursor: 'pointer', + display: 'inline-flex', + flexShrink: 0, + justifyContent: 'center', + + selectors: { + '&:where([data-hovered="true"], :hover)': { + color: vars.foregroundColor.primary, + }, + '&:where([data-disabled="true"], :disabled)': { + color: vars.foregroundColor.disabled, + cursor: 'not-allowed', + }, + }, + }, + defaultVariants: { + size: 'medium', + }, + variants: { + size: { + medium: { + blockSize: vars.controlSize.medium, + paddingInline: vars.space.xsmall, + }, + small: { + blockSize: vars.controlSize.small, + paddingInline: vars.space.xxsmall, + }, + }, + }, +}); + +export const comboboxItemCheck = styleInLayer('recipes', { + flexShrink: 0, + marginInlineStart: 'auto', +}); + export const comboboxPopover = recipeInLayer('recipes', { base: { backgroundColor: vars.backgroundColor.default, @@ -324,18 +314,26 @@ export const comboboxItem = recipeInLayer('recipes', { lineHeight: vars.font.lineHeight.tight, minInlineSize: 0, outline: 'none', + '@media': { + '(forced-colors: active)': { + selectors: { + // Forced colors strips background highlights, so the active option + // gets a ring there — it's the only possible indicator. + '&[data-focus-visible="true"]': { + outlineColor: 'Highlight', + outlineOffset: '-2px', + outlineStyle: 'solid', + outlineWidth: '2px', + }, + }, + }, + }, selectors: { '&[data-disabled="true"]': { color: vars.foregroundColor.disabled, cursor: 'not-allowed', }, - '&[data-focus-visible="true"]': { - outlineColor: vars.themeColor.paletteThemePrimary200, - outlineOffset: 0, - outlineStyle: 'solid', - outlineWidth: '3px', - }, '&[data-focused="true"]:not([data-disabled="true"])': { backgroundColor: vars.backgroundColor.hover, }, @@ -343,6 +341,13 @@ export const comboboxItem = recipeInLayer('recipes', { backgroundColor: vars.backgroundColor.subtle, fontWeight: vars.font.weight.medium, }, + // DOM focus stays on the input (aria-activedescendant), which shows the + // only focus ring; the keyboard-active option is indicated by a + // background one step stronger than hover, not a second ring. Declared + // after the focused/selected backgrounds so it wins on the same option. + '&[data-focus-visible="true"]:not([data-disabled="true"])': { + backgroundColor: vars.backgroundColor.pressed, + }, }, }, defaultVariants: { diff --git a/packages/@luke-ui/react/src/recipes/input-states.browser.test.ts b/packages/@luke-ui/react/src/recipes/input-states.browser.test.ts new file mode 100644 index 00000000..2cdade43 --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/input-states.browser.test.ts @@ -0,0 +1,106 @@ +import { afterEach, expect, test } from 'vite-plus/test'; +import { themeClass, vars } from '../styles/vars.css.js'; +import { comboboxControl, comboboxItem } from './combobox.css.js'; +import { textInputAdornmentStart, textInputGroup } from './text-input.css.js'; + +let wrappers: Array = []; + +function mount(control: HTMLElement): HTMLElement { + // Token vars only resolve inside the theme class. + const wrapper = document.body.appendChild(document.createElement('div')); + wrapper.className = themeClass; + wrappers.push(wrapper); + wrapper.append(control); + return control; +} + +/** Resolves a token's `var()` reference to the computed color Chromium reports. */ +function resolveColor(value: string): string { + const probe = document.createElement('div'); + probe.style.backgroundColor = value; + const mounted = mount(probe); + return getComputedStyle(mounted).backgroundColor; +} + +function comboboxControlElement() { + const control = document.createElement('div'); + control.className = comboboxControl({ size: 'medium' }); + control.append(document.createElement('input'), document.createElement('button')); + return control; +} + +afterEach(() => { + for (const wrapper of wrappers) { + wrapper.remove(); + } + wrappers = []; +}); + +test('a resting combobox control is not styled as read-only by its trigger button', () => { + const control = mount(comboboxControlElement()); + + // `:has(:read-only)` would match the trigger button; `:has(input:read-only)` must not. + expect(getComputedStyle(control).backgroundColor).toBe(resolveColor(vars.backgroundColor.input)); +}); + +test('a resting text input group is not styled as read-only by its adornment', () => { + const group = document.createElement('div'); + group.className = textInputGroup({ size: 'medium' }); + const adornment = document.createElement('span'); + adornment.className = textInputAdornmentStart({ size: 'medium' }); + group.append(adornment, document.createElement('input')); + mount(group); + + expect(getComputedStyle(group).backgroundColor).toBe(resolveColor(vars.backgroundColor.input)); +}); + +test('a combobox control with a read-only input gets the read-only treatment', () => { + const control = comboboxControlElement(); + control.querySelector('input')?.setAttribute('readonly', ''); + mount(control); + + expect(getComputedStyle(control).backgroundColor).toBe(resolveColor(vars.backgroundColor.subtle)); +}); + +test('disabled beats read-only on a combobox control', () => { + const control = comboboxControlElement(); + // A disabled input also matches `:read-only`; the disabled treatment must win. + control.querySelector('input')?.setAttribute('disabled', ''); + mount(control); + + const style = getComputedStyle(control); + expect(style.backgroundColor).toBe(resolveColor(vars.backgroundColor.inputDisabled)); + expect(style.borderColor).toBe(resolveColor(vars.border.default)); +}); + +test('text input and combobox share the same focus ring color', () => { + const group = document.createElement('div'); + group.className = textInputGroup({ size: 'medium' }); + group.append(document.createElement('input')); + mount(group); + const control = mount(comboboxControlElement()); + + group.querySelector('input')?.focus(); + const groupOutline = getComputedStyle(group).outlineColor; + control.querySelector('input')?.focus(); + const controlOutline = getComputedStyle(control).outlineColor; + + expect(groupOutline).toBe(resolveColor(vars.themeColor.focusRingColor)); + expect(controlOutline).toBe(groupOutline); +}); + +test('keyboard-focused combobox items are indicated by background, not a second ring', () => { + const item = document.createElement('li'); + item.className = comboboxItem({ size: 'medium' }); + // RAC marks the keyboard-active option with both attributes. + item.setAttribute('data-focused', 'true'); + item.setAttribute('data-focus-visible', 'true'); + mount(item); + + const style = getComputedStyle(item); + expect(style.outlineStyle).toBe('none'); + expect(style.backgroundColor).toBe(resolveColor(vars.backgroundColor.pressed)); + // The keyboard indicator carries the whole job without a ring, so it must + // stay distinguishable from a plain hover highlight. + expect(style.backgroundColor).not.toBe(resolveColor(vars.backgroundColor.hover)); +}); diff --git a/packages/@luke-ui/react/src/recipes/input-states.css.ts b/packages/@luke-ui/react/src/recipes/input-states.css.ts new file mode 100644 index 00000000..291a345b --- /dev/null +++ b/packages/@luke-ui/react/src/recipes/input-states.css.ts @@ -0,0 +1,134 @@ +import type { StyleRule } from '@vanilla-extract/css'; +import { vars } from '../styles/vars.css.js'; + +/** + * State definitions shared by field control recipes (the `TextInput` group, + * the Combobox control): each entry lists every selector that means "this + * control is in state X". + * + * The defaults cover a RAC `Group` that carries the field's data attributes + * itself and contains a single `input`. Anatomies with more parts extend + * these — see `combobox.css.ts`, which also watches its trigger button. + */ +export const inputStates = { + disabled: + '[data-disabled="true"], [aria-disabled="true"], :has(input:disabled), :has(input[aria-disabled="true"])', + focusWithin: '[data-focus-within="true"], :focus-within', + hover: '[data-hovered="true"], :hover', + invalid: + '[data-invalid="true"], [aria-invalid="true"], :has(:invalid), :has(input[aria-invalid="true"])', + // Scoped to `input` deliberately: bare `:read-only` matches any non-editable + // element (spans, buttons), so `:has(:read-only)` would match any control + // that contains an adornment or trigger. + readOnly: '[data-readonly="true"], :has(input:read-only)', +}; + +/** State definitions consumed by {@link inputChromeStyles}. */ +export type InputStates = typeof inputStates; + +/** Only explicit disabled attrs; avoids `:has()` matching an ancestor that contains any disabled input on the page. */ +const descendantDisabledState = '[data-disabled="true"], [aria-disabled="true"]'; + +/** Selector for parts styled by a disabled ancestor (adornments, triggers). */ +export const descendantDisabledSelector = `:where(${descendantDisabledState}) &`; + +function composeInputStateSelectors(states: InputStates) { + const disabled = `&:where(${states.disabled})`; + return { + disabled, + focusWithin: `&:where(${states.focusWithin})`, + hover: `&:where(${states.hover}):not(:where(${states.disabled})):not(:where(${states.focusWithin}))`, + invalid: `&:where(${states.invalid})`, + invalidFocusWithin: `&:where(${states.invalid}):where(${states.focusWithin})`, + readOnly: `&:where(${states.readOnly}):not(:where(${states.disabled}))`, + }; +} + +interface InputChromeOptions { + /** Border color for the disabled state. */ + disabledBorderColor?: string; +} + +/** + * Complete base style for a field control group: resting chrome plus + * disabled / hover / focus-within / invalid / read-only treatments and + * forced-colors support. + */ +export function inputChromeStyles( + states: InputStates, + options: InputChromeOptions = {}, +): StyleRule { + const selectors = composeInputStateSelectors(states); + const { disabledBorderColor = vars.backgroundColor.inputDisabled } = options; + + return { + '@media': { + '(forced-colors: active)': { + selectors: { + [selectors.focusWithin]: { + outlineColor: 'Highlight', + }, + [selectors.invalid]: { + borderColor: 'ButtonText', + }, + [selectors.invalidFocusWithin]: { + borderColor: 'ButtonText', + outlineColor: 'Highlight', + }, + [selectors.disabled]: { + borderColor: 'GrayText', + color: 'GrayText', + }, + }, + }, + }, + alignItems: 'center', + backgroundColor: vars.backgroundColor.input, + borderColor: vars.border.input, + borderRadius: vars.borderRadius.medium, + borderStyle: 'solid', + borderWidth: vars.borderWidth.thin, + color: vars.themeColor.inputColor, + display: 'inline-flex', + fontFamily: vars.font.family.body, + inlineSize: '100%', + minInlineSize: 0, + overflow: 'hidden', + + selectors: { + [selectors.disabled]: { + backgroundColor: vars.backgroundColor.inputDisabled, + borderColor: disabledBorderColor, + color: vars.foregroundColor.disabled, + cursor: 'not-allowed', + }, + [selectors.hover]: { + borderColor: vars.themeColor.paletteThemePrimary400, + }, + [selectors.focusWithin]: { + borderColor: vars.themeColor.paletteThemePrimary500, + outlineColor: vars.themeColor.focusRingColor, + outlineOffset: 0, + outlineStyle: 'solid', + outlineWidth: '3px', + }, + [selectors.invalid]: { + borderColor: vars.border.critical, + outlineStyle: 'none', + }, + [selectors.invalidFocusWithin]: { + borderColor: vars.border.critical, + outlineColor: vars.themeColor.focusRingColor, + outlineOffset: 0, + outlineStyle: 'solid', + outlineWidth: '3px', + }, + [selectors.readOnly]: { + backgroundColor: vars.backgroundColor.subtle, + }, + }, + transitionDuration: vars.motion.duration.fast, + transitionProperty: 'color, background-color, border-color', + transitionTimingFunction: vars.motion.easing.standard, + }; +} 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 d6772ef8..e80afecf 100644 --- a/packages/@luke-ui/react/src/recipes/text-input.css.ts +++ b/packages/@luke-ui/react/src/recipes/text-input.css.ts @@ -1,99 +1,13 @@ import type { RecipeVariants } from '@vanilla-extract/recipes'; import { recipeInLayer } from '../styles/layered-style.css.js'; import { vars } from '../styles/vars.css.js'; - -const transitionProperty = 'color, background-color, border-color'; - -const disabledState = - '[data-disabled="true"], [aria-disabled="true"], :has(input:disabled), :has(input[aria-disabled="true"])'; -/** Only explicit disabled attrs; avoids :has() matching an ancestor that contains any disabled input on the page. */ -const descendantDisabledState = '[data-disabled="true"], [aria-disabled="true"]'; -const focusWithinState = '[data-focus-within="true"], :focus-within'; -const hoverState = '[data-hovered="true"], :hover'; -const invalidState = - '[data-invalid="true"], [aria-invalid="true"], :has(:invalid), :has(input[aria-invalid="true"])'; -const readOnlyState = '[data-readonly="true"], :has(:read-only)'; - -const groupDisabledSelector = `&:where(${disabledState})`; -const groupHoverSelector = `&:where(${hoverState}):not(:where(${disabledState})):not(:where(${focusWithinState}))`; -const groupFocusWithinSelector = `&:where(${focusWithinState})`; -const groupInvalidSelector = `&:where(${invalidState})`; -const groupInvalidFocusWithinSelector = `&:where(${invalidState}):where(${focusWithinState})`; -const groupReadOnlySelector = `&:where(${readOnlyState})`; -const descendantDisabledSelector = `:where(${descendantDisabledState}) &`; +import { descendantDisabledSelector, inputChromeStyles, inputStates } from './input-states.css.js'; /** Vanilla-extract recipe for the `TextInput` group styles. */ export const textInputGroup = recipeInLayer('recipes', { base: { - '@media': { - '(forced-colors: active)': { - selectors: { - [groupFocusWithinSelector]: { - outlineColor: 'Highlight', - }, - [groupInvalidSelector]: { - borderColor: 'ButtonText', - }, - [groupInvalidFocusWithinSelector]: { - borderColor: 'ButtonText', - outlineColor: 'Highlight', - }, - [groupDisabledSelector]: { - borderColor: 'GrayText', - color: 'GrayText', - }, - }, - }, - }, - alignItems: 'center', - backgroundColor: vars.backgroundColor.input, - borderColor: vars.border.input, - borderRadius: vars.borderRadius.medium, - borderStyle: 'solid', - borderWidth: vars.borderWidth.thin, - color: vars.themeColor.inputColor, + ...inputChromeStyles(inputStates), cursor: 'text', - display: 'inline-flex', - fontFamily: vars.font.family.body, - inlineSize: '100%', - minInlineSize: 0, - overflow: 'hidden', - - selectors: { - [groupDisabledSelector]: { - backgroundColor: vars.backgroundColor.inputDisabled, - borderColor: vars.backgroundColor.inputDisabled, - color: vars.foregroundColor.disabled, - cursor: 'not-allowed', - }, - [groupHoverSelector]: { - borderColor: vars.themeColor.paletteThemePrimary400, - }, - [groupFocusWithinSelector]: { - borderColor: vars.themeColor.paletteThemePrimary500, - outlineColor: vars.themeColor.focusRingColor, - outlineOffset: 0, - outlineStyle: 'solid', - outlineWidth: '3px', - }, - [groupInvalidSelector]: { - borderColor: vars.border.critical, - outlineStyle: 'none', - }, - [groupInvalidFocusWithinSelector]: { - borderColor: vars.border.critical, - outlineColor: vars.themeColor.focusRingColor, - outlineOffset: 0, - outlineStyle: 'solid', - outlineWidth: '3px', - }, - [groupReadOnlySelector]: { - backgroundColor: vars.backgroundColor.subtle, - }, - }, - transitionDuration: vars.motion.duration.fast, - transitionProperty, - transitionTimingFunction: vars.motion.easing.standard, }, defaultVariants: { size: 'medium', diff --git a/packages/@luke-ui/react/src/sizing/combobox-sizing.ts b/packages/@luke-ui/react/src/sizing/combobox-sizing.ts new file mode 100644 index 00000000..8d74fef9 --- /dev/null +++ b/packages/@luke-ui/react/src/sizing/combobox-sizing.ts @@ -0,0 +1,7 @@ +import type { IconSizeToken } from '../tokens/index.js'; + +/** Maps combobox control size to the appropriate icon size. */ +export const COMBOBOX_ICON_SIZE: Record<'medium' | 'small', IconSizeToken> = { + medium: 'small', + small: 'xsmall', +}; diff --git a/packages/@luke-ui/react/vitest.config.ts b/packages/@luke-ui/react/vitest.config.ts index 3ddc8b94..713bc77d 100644 --- a/packages/@luke-ui/react/vitest.config.ts +++ b/packages/@luke-ui/react/vitest.config.ts @@ -30,6 +30,10 @@ export default defineConfig({ }, { extends: true, + plugins: [ + // Required for .css.ts processing in Vitest browser mode. + vanillaExtractPlugin(), + ], test: { browser: { enabled: true,