diff --git a/docs/STYLING.md b/docs/STYLING.md index 2dcca158..353325fd 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -21,10 +21,10 @@ with no class and no JS required. Neither step injects styles at runtime. a layer. - `styles/recipe.ts`: the internal `recipe()` engine shared by every component recipe, plus the `RecipeSelection` helper that derives a recipe's variant type. -- `styles/input-states.ts`: the shared field control-state selectors (`inputStates`, - `composeInputStateSelectors`, `descendantDisabledSelector`) field recipes compose. It is named - `.ts`, not `.css.ts`, because it emits no CSS. Each field recipe's `.css.ts` module composes its - plain data and functions. +- `styles/input-states.ts`: the shared field control-state selectors (`composeInputStateSelectors`, + `descendantDisabledSelector`) that field recipes compose. It is named `.ts`, not `.css.ts`, + because it emits no CSS. Each field recipe's `.css.ts` module composes its plain data and + functions. - `styles/invalid-indicator.ts`: the shared invalid-state `exclamationTriangle` icon, rendered as a CSS mask in two sizes. `invalidIndicatorIcon` (plus `invalidIndicatorIconForcedColors`) is the in-control icon `primitives/combobox/styles.css.ts` applies under its own invalid selector's @@ -291,19 +291,15 @@ with the recipe. "invalid", and "read-only" mean for a control, from `styles/input-states.ts`: ```ts -import { - composeInputStateSelectors, - descendantDisabledSelector, - inputStates, -} from './input-states.js'; +import { composeInputStateSelectors, descendantDisabledSelector } from './input-states.js'; -const { disabled, focusWithin, hover, invalid, readOnly } = composeInputStateSelectors(inputStates); +const { disabled, focusWithin, hover, invalid, readOnly } = composeInputStateSelectors(); ``` -`inputStates` is the base attribute/pseudo-class selector for each state. -`composeInputStateSelectors` combines them into the mutually exclusive selectors a recipe applies to -its styles (for example, `hover` deliberately excludes an element that is also focused or -read-only). Both field recipes use these definitions unchanged. +`composeInputStateSelectors` owns the shared attribute and pseudo-class matrix, then returns the +mutually exclusive selectors a recipe applies to its styles (for example, `hover` deliberately +excludes an element that is also focused or read-only). Both field recipes use these definitions +unchanged. Control-specific selectors stay in the TextField and Combobox recipes. Resist widening a state to probe descendants with `:has()`. React Aria publishes `isDisabled` and `isInvalid` through `GroupContext`, so a control group already carries `data-disabled` and diff --git a/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts b/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts index a29dd7b0..5263832b 100644 --- a/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts +++ b/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts @@ -5,7 +5,6 @@ import { focusRing, restingFocusRing } from '../../styles/focus-ring.js'; import { composeInputStateSelectors, descendantDisabledSelector, - inputStates, } from '../../styles/input-states.js'; import { invalidIndicatorIcon, @@ -25,7 +24,7 @@ const comboboxErrorIconSize = createVar(); // React Aria's `ComboBox` publishes `isDisabled`/`isInvalid` through `GroupContext`, which // `Group` writes onto the group element, so no `:has()` probing of descendants is needed. const { disabled, focusWithin, hover, invalid, invalidFocusWithin, readOnly, readOnlyFocusWithin } = - composeInputStateSelectors(inputStates); + composeInputStateSelectors(); const comboboxActionStyles = { '@media': { diff --git a/packages/@luke-ui/react/src/primitives/input-group/recipe.css.ts b/packages/@luke-ui/react/src/primitives/input-group/recipe.css.ts index 7342c1db..2e85cca2 100644 --- a/packages/@luke-ui/react/src/primitives/input-group/recipe.css.ts +++ b/packages/@luke-ui/react/src/primitives/input-group/recipe.css.ts @@ -2,7 +2,6 @@ import { focusRing, restingFocusRing } from '../../styles/focus-ring.js'; import { composeInputStateSelectors, descendantDisabledSelector, - inputStates, } from '../../styles/input-states.js'; import type { RecipeSelection, SlottedConfigInput } from '../../styles/recipe.js'; import { recipe } from '../../styles/recipe.js'; @@ -10,7 +9,7 @@ import { vars } from '../../theme/contract.css.js'; import { FONT_METRIC_SCALE } from '../../theme/font-metric-scale.js'; const { disabled, focusWithin, hover, invalid, invalidFocusWithin, readOnly, readOnlyFocusWithin } = - composeInputStateSelectors(inputStates); + composeInputStateSelectors(); /** * Raw slotted config for the `InputGroup` primitive. diff --git a/packages/@luke-ui/react/src/styles/input-states.ts b/packages/@luke-ui/react/src/styles/input-states.ts index b934508b..09ad4345 100644 --- a/packages/@luke-ui/react/src/styles/input-states.ts +++ b/packages/@luke-ui/react/src/styles/input-states.ts @@ -6,7 +6,7 @@ * itself and contains a single `input`. Anatomies with more parts extend * these when their anatomy has more parts. */ -export const inputStates = { +const inputStates = { disabled: '[data-disabled="true"], [aria-disabled="true"], :has(input:disabled), :has(input[aria-disabled="true"])', focusWithin: '[data-focus-within="true"], :focus-within', @@ -26,20 +26,18 @@ export const inputStates = { readOnly: '[data-readonly="true"], :has(input:read-only)', }; -export type InputStates = Record; - -/** Composes state definitions into selectors shared by field control recipes. */ -export function composeInputStateSelectors(states: InputStates) { - const notDisabled = `:not(:where(${states.disabled}))`; +/** Composes the shared field-state selectors used by TextField and Combobox recipes. */ +export function composeInputStateSelectors() { + const notDisabled = `:not(:where(${inputStates.disabled}))`; return { - disabled: `&:where(${states.disabled})`, - focusWithin: `&:where(${states.focusWithin})${notDisabled}`, - hover: `&:where(${states.hover})${notDisabled}:not(:where(${states.focusWithin})):not(:where(${states.readOnly}))`, - invalid: `&:where(${states.invalid})${notDisabled}`, - invalidFocusWithin: `&:where(${states.invalid}):where(${states.focusWithin})${notDisabled}`, - readOnly: `&:where(${states.readOnly})${notDisabled}`, - readOnlyFocusWithin: `&:where(${states.readOnly}):where(${states.focusWithin})${notDisabled}`, + disabled: `&:where(${inputStates.disabled})`, + focusWithin: `&:where(${inputStates.focusWithin})${notDisabled}`, + hover: `&:where(${inputStates.hover})${notDisabled}:not(:where(${inputStates.focusWithin})):not(:where(${inputStates.readOnly}))`, + invalid: `&:where(${inputStates.invalid})${notDisabled}`, + invalidFocusWithin: `&:where(${inputStates.invalid}):where(${inputStates.focusWithin})${notDisabled}`, + readOnly: `&:where(${inputStates.readOnly})${notDisabled}`, + readOnlyFocusWithin: `&:where(${inputStates.readOnly}):where(${inputStates.focusWithin})${notDisabled}`, }; } diff --git a/packages/@luke-ui/react/src/text-field/text-field.browser.test.tsx b/packages/@luke-ui/react/src/text-field/text-field.browser.test.tsx index 7990d55f..4a9db9f7 100644 --- a/packages/@luke-ui/react/src/text-field/text-field.browser.test.tsx +++ b/packages/@luke-ui/react/src/text-field/text-field.browser.test.tsx @@ -8,7 +8,6 @@ import { InputGroupPrefix, InputGroupSuffix, } from '../primitives/input-group/index.js'; -import { inputGroupRecipe } from '../primitives/input-group/recipe.css.js'; import { render } from '../test-utils/render.js'; import { TextField } from './index.js'; @@ -52,18 +51,14 @@ function groupFor(name: string): HTMLElement { return group; } -// `inputGroupRecipe().invalidIndicator()` returns one stable class list regardless of `size` -// (the slot's `marginInlineEnd` is a constant, see `primitives/input-group/recipe.css.ts`), but the lookup -// still keys on the first token only, matching the other slot lookups in this file. -const invalidIndicatorClass = inputGroupRecipe().invalidIndicator().split(' ')[0]; - /** * The invalid indicator `InputGroup` renders itself, if it is present. Matched by the - * recipe's own slot class rather than by tag name: a prefix or suffix can hold an - * `` of its own. + * public `exclamationTriangle` icon, not a generated recipe class. A prefix or suffix + * can hold an `` of its own. */ function indicatorFor(name: string): SVGSVGElement | null { - return groupFor(name).querySelector(`.${invalidIndicatorClass}`); + const glyph = groupFor(name).querySelector('use[href$="#exclamationTriangle"]'); + return glyph?.closest('svg') ?? null; } // The invalid icon must land before a trailing suffix, not after it. The suffix's @@ -125,7 +120,7 @@ test('InputGroupInput resolves object and callback refs to the input element', a expect(callbackResolved.at(-1)).toBe(callbackInput.element()); }); -// `inputStates.invalid` must not match `:has(:invalid)`: that matches a required, +// The shared invalid selector must not match `:has(:invalid)`: that matches a required, // empty input from first render — before any interaction or submit — while // `aria-invalid` stays null, painting an untouched required field invalid even // though assistive technology is told it's fine. Guard that the group only picks