From eb9d56a67e1f6edce6f107ef0fc2f5b36201b193 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Tue, 14 Jul 2026 10:59:50 +1000 Subject: [PATCH] Refine input themes and review feedback --- .changeset/tactile-field-wells.md | 8 ---- .../docs/components/primitives/text-input.mdx | 4 -- .../@luke-ui/react/src/recipes/button.css.ts | 3 +- .../@luke-ui/react/src/recipes/field.css.ts | 8 +--- .../src/recipes/input-states.browser.test.ts | 21 +++++++-- .../react/src/recipes/input-states.css.ts | 12 ++--- .../src/recipes/text-input.browser.test.ts | 16 ------- .../react/src/recipes/text-input.css.ts | 20 ++++---- .../@luke-ui/react/src/styles/focus-ring.ts | 8 ++++ .../@luke-ui/react/src/styles/reset.css.ts | 6 +-- .../react/src/theme/build-theme.test.ts | 47 +++++++++++++------ .../@luke-ui/react/src/theme/build-theme.ts | 2 +- .../@luke-ui/react/src/theme/foundations.ts | 3 +- 13 files changed, 78 insertions(+), 80 deletions(-) delete mode 100644 .changeset/tactile-field-wells.md create mode 100644 packages/@luke-ui/react/src/styles/focus-ring.ts diff --git a/.changeset/tactile-field-wells.md b/.changeset/tactile-field-wells.md deleted file mode 100644 index 130c3552..00000000 --- a/.changeset/tactile-field-wells.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@luke-ui/react': patch ---- - -Move `Field`, `TextInput`, and `TextField` onto the new semantic theme contract. The input control -now renders as a recessed well with an accent-coloured hover and focus edge, and read-only fields -flatten into the canvas surface instead of relying on colour alone. No props or exported types -changed. diff --git a/apps/docs/content/docs/components/primitives/text-input.mdx b/apps/docs/content/docs/components/primitives/text-input.mdx index fc051cef..67d92ba6 100644 --- a/apps/docs/content/docs/components/primitives/text-input.mdx +++ b/apps/docs/content/docs/components/primitives/text-input.mdx @@ -27,10 +27,6 @@ semantics and focus behaviour. Pass `className` to style the outer group wrapper. Pass `inputClassName` to style the input element. -The control renders as a recessed well. Hover and focus both show an accent-coloured edge; read-only -flattens the well into the canvas surface instead of using colour, so it stays distinguishable from -an editable field even without focus. - ## Props ; /** Vanilla-extract recipe for the `Field` primitive's message styles. */ export const fieldMessage = recipeInLayer('recipes', { base: { - fontSize: vars.font[200].fontSize, - letterSpacing: vars.font[200].letterSpacing, - lineHeight: vars.font[200].lineHeight, + ...vars.font[200], minInlineSize: 0, selectors: { 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 index 6b2c4afa..cb55b8ab 100644 --- a/packages/@luke-ui/react/src/recipes/input-states.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/input-states.browser.test.ts @@ -1,5 +1,9 @@ +import '@luke-ui/react/themes/machined-edge.css'; import { afterEach, expect, test } from 'vite-plus/test'; import { themeClass, vars } from '../styles/vars.css.js'; +import { themeRootClassName } from '../theme/index.js'; +import { machinedEdgeThemeClassName } from '../themes/index.js'; +import { cx } from '../utils/index.js'; import { comboboxControl, comboboxItem } from './combobox.css.js'; let wrappers: Array = []; @@ -7,7 +11,7 @@ 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; + wrapper.className = cx(themeClass, themeRootClassName, machinedEdgeThemeClassName); wrappers.push(wrapper); wrapper.append(control); return control; @@ -61,10 +65,17 @@ test('disabled beats read-only on a combobox control', () => { expect(style.borderColor).toBe(resolveColor(vars.border.default)); }); -// `TextInput`'s focus ring moved to the new semantic theme contract in #99; -// Combobox still authors its own ring from the old token system until it -// migrates (#100), so the two are no longer guaranteed to share a color. See -// `recipes/text-input.browser.test.ts` for the migrated focus-ring contract. +test('focus-within shows the shared visible ring', () => { + const control = comboboxControlElement(); + control.dataset.focusWithin = 'true'; + mount(control); + + const style = getComputedStyle(control); + expect(style.outlineStyle).toBe('solid'); + expect(style.outlineWidth).toBe('2px'); + expect(style.outlineOffset).toBe('2px'); + expect(style.outlineColor).not.toBe('rgba(0, 0, 0, 0)'); +}); test('keyboard-focused combobox items are indicated by background, not a second ring', () => { const item = document.createElement('li'); diff --git a/packages/@luke-ui/react/src/recipes/input-states.css.ts b/packages/@luke-ui/react/src/recipes/input-states.css.ts index 291a345b..31e54a63 100644 --- a/packages/@luke-ui/react/src/recipes/input-states.css.ts +++ b/packages/@luke-ui/react/src/recipes/input-states.css.ts @@ -1,5 +1,7 @@ import type { StyleRule } from '@vanilla-extract/css'; +import { focusRing } from '../styles/focus-ring.js'; import { vars } from '../styles/vars.css.js'; +import { vars as themeVars } from '../theme/contract.css.js'; /** * State definitions shared by field control recipes (the `TextInput` group, @@ -107,10 +109,7 @@ export function inputChromeStyles( }, [selectors.focusWithin]: { borderColor: vars.themeColor.paletteThemePrimary500, - outlineColor: vars.themeColor.focusRingColor, - outlineOffset: 0, - outlineStyle: 'solid', - outlineWidth: '3px', + ...focusRing(themeVars.color.border.focus), }, [selectors.invalid]: { borderColor: vars.border.critical, @@ -118,10 +117,7 @@ export function inputChromeStyles( }, [selectors.invalidFocusWithin]: { borderColor: vars.border.critical, - outlineColor: vars.themeColor.focusRingColor, - outlineOffset: 0, - outlineStyle: 'solid', - outlineWidth: '3px', + ...focusRing(themeVars.color.border.focus), }, [selectors.readOnly]: { backgroundColor: vars.backgroundColor.subtle, diff --git a/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts b/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts index 2fb1f9be..4ceb4d8f 100644 --- a/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/text-input.browser.test.ts @@ -12,20 +12,6 @@ afterEach(() => { mounted = []; }); -test('the well uses the recessed surface, depth, and control radius', () => { - const { group, root } = mountGroup(); - const probe = root.appendChild(document.createElement('div')); - probe.style.backgroundColor = 'var(--luke-color-surface-recessed)'; - probe.style.boxShadow = 'var(--luke-depth-recessed)'; - const style = getComputedStyle(group); - - expect(style.backgroundColor).toBe(getComputedStyle(probe).backgroundColor); - expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)'); - expect(style.boxShadow).toBe(getComputedStyle(probe).boxShadow); - expect(style.boxShadow).not.toBe('none'); - expect(style.borderRadius).toBe(getComputedStyle(root).getPropertyValue('--luke-radius-control')); -}); - test('small and medium share the control geometry contract', () => { const { group: small } = mountGroup({ size: 'small' }); const { group: medium } = mountGroup({ size: 'medium' }); @@ -120,10 +106,8 @@ test('read-only flattens into canvas surface with a decorative border and ignore readOnlyHover.dataset.readonly = 'true'; readOnlyHover.dataset.hovered = 'true'; - const restingBackground = getComputedStyle(resting).backgroundColor; const readOnlyStyle = getComputedStyle(readOnly); - expect(readOnlyStyle.backgroundColor).not.toBe(restingBackground); expect(readOnlyStyle.borderColor).not.toBe(getComputedStyle(resting).borderColor); expect(readOnlyStyle.boxShadow).toBe('none'); expect(getComputedStyle(readOnlyHover).borderColor).toBe(readOnlyStyle.borderColor); 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 528b6de9..e74cda3d 100644 --- a/packages/@luke-ui/react/src/recipes/text-input.css.ts +++ b/packages/@luke-ui/react/src/recipes/text-input.css.ts @@ -1,4 +1,5 @@ import type { RecipeVariants } from '@vanilla-extract/recipes'; +import { focusRing } from '../styles/focus-ring.js'; import { recipeInLayer } from '../styles/layered-style.css.js'; import { vars } from '../theme/contract.css.js'; import { descendantDisabledSelector, inputStates } from './input-states.css.js'; @@ -17,13 +18,6 @@ const invalidFocusWithin = `&:where(${inputStates.invalid}):where(${inputStates. const readOnly = `&:where(${inputStates.readOnly})${notDisabled}`; const readOnlyFocusWithin = `&:where(${inputStates.readOnly}):where(${inputStates.focusWithin})${notDisabled}`; -const focusRing = { - outlineColor: vars.color.border.focus, - outlineOffset: '2px', - outlineStyle: 'solid', - outlineWidth: '2px', -} as const; - /** Vanilla-extract recipe for the `TextInput` group's tactile well chrome. */ export const textInputGroup = recipeInLayer('recipes', { base: { @@ -80,7 +74,7 @@ export const textInputGroup = recipeInLayer('recipes', { }, [focusWithin]: { borderColor: vars.color.intent.accent.border, - ...focusRing, + ...focusRing(vars.color.border.focus), }, [hover]: { borderColor: vars.color.intent.accent.border, @@ -90,7 +84,7 @@ export const textInputGroup = recipeInLayer('recipes', { }, [invalidFocusWithin]: { borderColor: vars.color.intent.danger.border, - ...focusRing, + ...focusRing(vars.color.border.focus), }, [readOnly]: { backgroundColor: vars.color.surface.canvas, @@ -98,7 +92,7 @@ export const textInputGroup = recipeInLayer('recipes', { boxShadow: 'none', }, [readOnlyFocusWithin]: { - ...focusRing, + ...focusRing(vars.color.border.focus), }, }, }, @@ -185,7 +179,6 @@ export const textInputAdornmentStart = recipeInLayer('recipes', { color: vars.color.text.secondary, display: 'inline-flex', flexShrink: 0, - lineHeight: 'inherit', selectors: { [descendantDisabledSelector]: { @@ -199,10 +192,12 @@ export const textInputAdornmentStart = recipeInLayer('recipes', { variants: { size: { medium: { + lineHeight: vars.font[300].lineHeight, paddingInlineEnd: vars.space[300], paddingInlineStart: vars.space[300], }, small: { + lineHeight: vars.font[200].lineHeight, paddingInlineEnd: vars.space[200], paddingInlineStart: vars.space[200], }, @@ -220,7 +215,6 @@ export const textInputAdornmentEnd = recipeInLayer('recipes', { color: vars.color.text.secondary, display: 'inline-flex', flexShrink: 0, - lineHeight: 'inherit', selectors: { [descendantDisabledSelector]: { @@ -234,10 +228,12 @@ export const textInputAdornmentEnd = recipeInLayer('recipes', { variants: { size: { medium: { + lineHeight: vars.font[300].lineHeight, paddingInlineEnd: vars.space[300], paddingInlineStart: vars.space[300], }, small: { + lineHeight: vars.font[200].lineHeight, paddingInlineEnd: vars.space[200], paddingInlineStart: vars.space[200], }, diff --git a/packages/@luke-ui/react/src/styles/focus-ring.ts b/packages/@luke-ui/react/src/styles/focus-ring.ts new file mode 100644 index 00000000..7fa6a61a --- /dev/null +++ b/packages/@luke-ui/react/src/styles/focus-ring.ts @@ -0,0 +1,8 @@ +export function focusRing(color: string) { + return { + outlineColor: color, + outlineOffset: '2px', + outlineStyle: 'solid', + outlineWidth: '2px', + } as const; +} diff --git a/packages/@luke-ui/react/src/styles/reset.css.ts b/packages/@luke-ui/react/src/styles/reset.css.ts index f4ebdba0..7fd3d9d1 100644 --- a/packages/@luke-ui/react/src/styles/reset.css.ts +++ b/packages/@luke-ui/react/src/styles/reset.css.ts @@ -1,4 +1,5 @@ import { classSelector, lukeUiClassNames } from './class-names.js'; +import { focusRing } from './focus-ring.js'; import { globalStyleInLayer } from './layered-style.css.js'; import { vars } from './vars.css.js'; @@ -71,10 +72,7 @@ globalStyleInLayer('reset', `${root} :where(:disabled, [data-disabled="true"])`, }); globalStyleInLayer('reset', `${root} :where(:focus-visible)`, { - outlineColor: vars.themeColor.focusRingColor, - outlineOffset: vars.borderWidth.thick, - outlineStyle: 'solid', - outlineWidth: vars.borderWidth.thick, + ...focusRing(vars.themeColor.focusRingColor), '@media': { '(forced-colors: active)': { diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts index 46d42aa3..c59b4506 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -131,7 +131,7 @@ describe('buildTheme output', () => { expect(css).toContain('--luke-icon-size-large: 32px'); }); - it('emits the authored semantic depth values without synthesising material layers', () => { + it('emits authored semantic depth while keeping only ELMO light flat', () => { expect(extractValue(blocks.baseLight, '--luke-depth-resting')).toBe( machinedEdgeFoundation.light.depth.resting, ); @@ -144,30 +144,41 @@ describe('buildTheme output', () => { expect(foundation[mode].depth.raised).not.toContain('inset'); expect(foundation[mode].depth.resting.split(', ')).toHaveLength(2); expect(foundation[mode].depth.raised.split(', ')).toHaveLength(2); - const recessedLayers = foundation[mode].depth.recessed.split(', '); - expect(foundation[mode].depth.recessed).not.toBe('none'); - expect(recessedLayers.every((layer) => layer.startsWith('inset '))).toBe(true); } } + expect(elmoFoundation.light.depth.recessed).toBe('none'); + for (const recessed of [ + machinedEdgeFoundation.light.depth.recessed, + machinedEdgeFoundation.dark.depth.recessed, + elmoFoundation.dark.depth.recessed, + ]) { + expect(recessed).not.toBe('none'); + expect(recessed.split(', ').every((layer) => layer.startsWith('inset '))).toBe(true); + } }); it('keeps ELMO softer than Machined edge while retaining finish and state depth', () => { const elmoBlocks = splitBlocks(buildTheme(elmoFoundation)); + expect(extractValue(elmoBlocks.identity, '--luke-radius-control')).toBe('4px'); + expect(extractValue(elmoBlocks.baseLight, '--luke-depth-recessed')).toBe('none'); + expect(extractValue(blocks.baseLight, '--luke-depth-recessed').split(', ')).toHaveLength(2); + + const elmoDarkRecessed = extractValue(elmoBlocks.mediaDark, '--luke-depth-recessed'); + const machinedDarkRecessed = extractValue(blocks.mediaDark, '--luke-depth-recessed'); + expect(elmoDarkRecessed.split(', ')).toHaveLength(1); + expect(machinedDarkRecessed.split(', ')).toHaveLength(2); + expect(Math.max(...extractShadowOpacities(machinedDarkRecessed))).toBeGreaterThan( + Math.max(...extractShadowOpacities(elmoDarkRecessed)), + ); + for (const [elmoBlock, machinedBlock] of [ [elmoBlocks.baseLight, blocks.baseLight], [elmoBlocks.mediaDark, blocks.mediaDark], ] as const) { - const elmoRecessed = extractValue(elmoBlock, '--luke-depth-recessed'); - const machinedRecessed = extractValue(machinedBlock, '--luke-depth-recessed'); const elmoResting = extractValue(elmoBlock, '--luke-depth-resting'); const elmoRaised = extractValue(elmoBlock, '--luke-depth-raised'); const elmoFinish = extractValue(elmoBlock, '--luke-action-control-finish-resting'); - expect(machinedRecessed.split(', ')).toHaveLength(2); - expect(elmoRecessed.split(', ')).toHaveLength(1); - expect(Math.max(...extractShadowOpacities(machinedRecessed))).toBeGreaterThan( - Math.max(...extractShadowOpacities(elmoRecessed)), - ); expect(extractValue(machinedBlock, '--luke-depth-resting')).toContain('0 2px 0'); expect(elmoResting).not.toContain('0 2px 0'); expect(elmoRaised).not.toContain('0 3px 0'); @@ -394,7 +405,7 @@ describe('bundled themes meet WCAG 2.2 AA', () => { } }); - it(`${foundation.name} keeps light recessed surfaces near canvas and dark wells distinct`, () => { + it(`${foundation.name} keeps light recessed surfaces at canvas and dark wells distinct`, () => { const blocks = splitBlocks(buildTheme(foundation)); const lightCanvas = parseColor(extractValue(blocks.baseLight, '--luke-color-surface-canvas')); const lightRecessed = parseColor( @@ -405,8 +416,7 @@ describe('bundled themes meet WCAG 2.2 AA', () => { extractValue(blocks.mediaDark, '--luke-color-surface-recessed'), ); - expect(lightCanvas.l - lightRecessed.l).toBeGreaterThan(0); - expect(lightCanvas.l - lightRecessed.l).toBeLessThanOrEqual(0.015); + expect(lightRecessed).toEqual(lightCanvas); expect(darkCanvas.l - darkRecessed.l).toBeGreaterThanOrEqual(0.02); }); @@ -434,6 +444,15 @@ describe('bundled themes meet WCAG 2.2 AA', () => { } }); } + + it('keeps the Machined edge light recessed surface near white at canvas lightness', () => { + const blocks = splitBlocks(buildTheme(machinedEdgeFoundation)); + const canvas = parseColor(extractValue(blocks.baseLight, '--luke-color-surface-canvas')); + const recessed = parseColor(extractValue(blocks.baseLight, '--luke-color-surface-recessed')); + + expect(recessed.l).toBeGreaterThan(0.97); + expect(recessed.l).toBe(canvas.l); + }); }); describe('bundled theme identity', () => { diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts index 59b1e7c5..bd22d982 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.ts @@ -192,7 +192,7 @@ const LIGHTNESS_WINDOWS: Record = { // surfaces separate more strongly, without exposing generated palette steps. const SURFACE_LIGHTNESS_DELTAS = { dark: { floating: 0.07, overlay: 0.09, recessed: -0.025, resting: 0.04 }, - light: { floating: 0.012, overlay: 0.015, recessed: -0.01, resting: 0.012 }, + light: { floating: 0.012, overlay: 0.015, recessed: 0, resting: 0.012 }, } as const satisfies Record< ColorMode, Record<'floating' | 'overlay' | 'recessed' | 'resting', number> diff --git a/packages/@luke-ui/react/src/theme/foundations.ts b/packages/@luke-ui/react/src/theme/foundations.ts index 825e015e..f32a8e43 100644 --- a/packages/@luke-ui/react/src/theme/foundations.ts +++ b/packages/@luke-ui/react/src/theme/foundations.ts @@ -154,7 +154,7 @@ export const elmoFoundation: ThemeFoundation = { '0 2px 6px oklch(0.2 0.01 250 / 0.05)', '0 1px 3px oklch(0.2 0.01 250 / 0.035)', ].join(', '), - recessed: 'inset 0 1px 2px oklch(0.2 0.01 250 / 0.06)', + recessed: 'none', resting: [ '0 1px 3px oklch(0.2 0.01 250 / 0.04)', '0 1px 2px oklch(0.2 0.01 250 / 0.02)', @@ -162,4 +162,5 @@ export const elmoFoundation: ThemeFoundation = { }, }, name: 'elmo', + radius: { control: 4 }, }; -- 2.51.2