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,