diff --git a/docs/STYLING.md b/docs/STYLING.md index 945edf78..527e97cc 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -3,7 +3,8 @@ ## Setup Luke UI ships static CSS. Consumers import `@luke-ui/react/stylesheet.css` and apply -`themeRootClassName` from `@luke-ui/react/theme` near the app root. +`themeRootClassName` from `@luke-ui/react/theme` near the app root. Import one bundled theme +stylesheet and apply its identity class to the same element. Neither step injects styles at runtime. ## Structure @@ -46,7 +47,12 @@ Without `data-color-mode`, a themed subtree follows `prefers-color-scheme`. Sett inside the subtree forces that mode, and nested scopes can override it. Every scope also sets native `color-scheme` so form controls and scrollbars agree. -Components still consume the legacy tokens until #96 migrates them to the new contract. +Components move to the semantic contract in the component-family migration slices. + +Luke UI's portalled Combobox popover carries the nearest identity class and explicit colour mode +from its trigger. Portals created by an application must apply the same public class and attribute +contract to their portal root. When no explicit mode exists, omit `data-color-mode` so the portalled +surface continues to follow the system preference. ## Cascade layers diff --git a/packages/@luke-ui/react/src/combobox-field/index.tsx b/packages/@luke-ui/react/src/combobox-field/index.tsx index 9227cea1..e1134741 100644 --- a/packages/@luke-ui/react/src/combobox-field/index.tsx +++ b/packages/@luke-ui/react/src/combobox-field/index.tsx @@ -1,4 +1,5 @@ import type { CSSProperties, JSX } from 'react'; +import { useRef } from 'react'; import type { ComboBoxProps as RacComboBoxProps } from 'react-aria-components/ComboBox'; import type { FieldSlotProps } from '../field/compose-field.js'; import { composeField } from '../field/compose-field.js'; @@ -69,6 +70,7 @@ export interface ComboboxFieldProps /** Composes `ComboboxInput` with label, description, and error slots. */ export function ComboboxField(props: ComboboxFieldProps): JSX.Element { + const themeSourceRef = useRef(null); const [fieldSlotProps, restProps] = composeField(props); const { children, @@ -112,7 +114,7 @@ export function ComboboxField(props: ComboboxFieldProps): J })(); return ( - size={size} {...comboboxInputProps}> + ref={themeSourceRef} size={size} {...comboboxInputProps}> @@ -125,7 +127,12 @@ export function ComboboxField(props: ComboboxFieldProps): J - + {...listBoxProps} loadMoreItem={loadMoreItem} diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx index 4786c968..d6fb17bb 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx @@ -19,7 +19,10 @@ export interface ComboboxPopoverProps extends Omit; } -/** Popover surface used for listbox content. */ +/** + * Popover surface used for listbox content. The portal preserves the theme identity and explicit + * colour mode active at its trigger. + */ export function ComboboxPopover(props: ComboboxPopoverProps): JSX.Element { const { ref, ...restProps } = props; const [element, setElement] = useState(null); @@ -31,7 +34,39 @@ export function ComboboxPopover(props: ComboboxPopoverProps): JSX.Element { className={composeRenderProps(restProps.className, (className) => { return cx(themeRootClassName, styles.comboboxPopover(), className); })} - ref={mergeRefs(ref, (node: HTMLElement | null) => setElement(node))} + ref={mergeRefs(ref, (node: HTMLElement | null) => { + setElement(node); + if (node === null) return; + + const themeSource = props.triggerRef?.current ?? document.activeElement; + if (!(themeSource instanceof Element)) return; + + carryThemeScope(themeSource, node); + })} /> ); } + +function carryThemeScope(source: Element, portal: HTMLElement) { + const identityClassName = findThemeIdentity(source); + if (identityClassName !== undefined) portal.classList.add(identityClassName); + + const modeRoot = source.closest('[data-color-mode]'); + const mode = modeRoot?.dataset.colorMode; + if (mode === 'light' || mode === 'dark') portal.dataset.colorMode = mode; +} + +function findThemeIdentity(source: Element) { + let identityClassName: string | undefined; + let ancestor: Element | null = source; + + while (ancestor !== null) { + for (const className of ancestor.classList) { + // Keep the outer identity because nested identities are not supported. + if (className.startsWith('luke-ui-theme-')) identityClassName = className; + } + ancestor = ancestor.parentElement; + } + + return identityClassName; +} diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/root.tsx b/packages/@luke-ui/react/src/combobox-field/primitive/root.tsx index b33d662c..c9c46d47 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/root.tsx +++ b/packages/@luke-ui/react/src/combobox-field/primitive/root.tsx @@ -1,4 +1,4 @@ -import type { JSX } from 'react'; +import type { JSX, Ref } from 'react'; import type { Key, ComboBoxProps as RacComboBoxProps } from 'react-aria-components/ComboBox'; import { ComboBox as RacComboBox } from 'react-aria-components/ComboBox'; import { composeRenderProps } from 'react-aria-components/composeRenderProps'; @@ -27,6 +27,9 @@ export interface ComboboxInputProps extends DistributiveOmit< | 'selectionMode' | 'value' > { + /** Forwarded to the combobox root element. */ + ref?: Ref; + /** The initially selected key (uncontrolled). */ defaultValue?: Key | null; @@ -49,7 +52,7 @@ export interface ComboboxInputProps extends DistributiveOmit< } export function ComboboxInput(props: ComboboxInputProps): JSX.Element { - const { className, menuTrigger = 'focus', size = 'medium', ...comboboxProps } = props; + const { className, menuTrigger = 'focus', ref, size = 'medium', ...comboboxProps } = props; return ( @@ -59,6 +62,7 @@ export function ComboboxInput(props: ComboboxInputProps): J return cx(styles.comboboxRoot, renderedClassName); })} menuTrigger={menuTrigger} + ref={ref} /> ); diff --git a/packages/@luke-ui/react/src/theme/theme.browser.test.tsx b/packages/@luke-ui/react/src/theme/theme.browser.test.tsx new file mode 100644 index 00000000..5ce0a08c --- /dev/null +++ b/packages/@luke-ui/react/src/theme/theme.browser.test.tsx @@ -0,0 +1,124 @@ +import '../../dist/themes/machined-edge.css'; +import { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { cdp, page, userEvent } from 'vite-plus/test/context'; +import { Button } from '../button/index.js'; +import { ComboboxField } from '../combobox-field/index.js'; +import { ComboboxItem } from '../combobox-field/primitive/index.js'; +import { IconSpritesheetProvider } from '../icon/index.js'; +import { machinedEdgeThemeClassName } from '../themes/index.js'; + +const mounted: Array<{ container: HTMLElement; root: Root }> = []; +const scopes: Array = []; + +afterEach(async () => { + for (const { container, root } of mounted) { + act(() => root.unmount()); + container.remove(); + } + mounted.length = 0; + for (const scope of scopes) scope.remove(); + scopes.length = 0; + + await emulateColorScheme('light'); +}); + +test('selects the system colour mode when no explicit mode exists', async () => { + const systemScope = renderScope(); + const lightScope = renderScope('light'); + const darkScope = renderScope('dark'); + + await emulateColorScheme('light'); + expect(getComputedStyle(systemScope).backgroundColor).toBe( + getComputedStyle(lightScope).backgroundColor, + ); + expect(getComputedStyle(systemScope).colorScheme).toBe('light'); + + await emulateColorScheme('dark'); + expect(getComputedStyle(systemScope).backgroundColor).toBe( + getComputedStyle(darkScope).backgroundColor, + ); + expect(getComputedStyle(systemScope).colorScheme).toBe('dark'); +}); + +test('applies explicit and nested opposite colour modes with native colour-scheme', () => { + const outer = renderScope('dark'); + const nested = outer.appendChild(document.createElement('div')); + nested.dataset.colorMode = 'light'; + nested.style.backgroundColor = 'var(--luke-color-surface-canvas)'; + const lightReference = renderScope('light'); + + expect(getComputedStyle(outer).colorScheme).toBe('dark'); + expect(getComputedStyle(nested).colorScheme).toBe('light'); + expect(getComputedStyle(nested).backgroundColor).toBe( + getComputedStyle(lightReference).backgroundColor, + ); +}); + +test('renders components from static CSS without theme context or injected styles', () => { + const styleCount = document.querySelectorAll('style').length; + const scope = renderScope('light'); + const root = createRoot(scope); + mounted.push({ container: scope, root }); + + act(() => root.render()); + const button = page.getByRole('button', { name: 'Continue' }).element(); + + expect(getComputedStyle(button).display).toBe('inline-flex'); + expect(document.querySelectorAll('style')).toHaveLength(styleCount); +}); + +test('preserves identity and an opposite nested mode on a portalled combobox', async () => { + const outer = renderScope('light'); + const nested = outer.appendChild(document.createElement('div')); + nested.dataset.colorMode = 'dark'; + const root = createRoot(nested); + mounted.push({ container: outer, root }); + + act(() => { + root.render( + + + {(item) => {item.label}} + + , + ); + }); + + await userEvent.click(page.getByRole('combobox', { name: 'Country' })); + const listbox = page.getByRole('listbox'); + await expect.element(listbox).toBeInTheDocument(); + + const portal = document.querySelector('[role="listbox"]')?.parentElement; + if (!portal) throw new Error('expected the listbox to have a popover parent'); + + expect(portal).toHaveAttribute('data-color-mode', 'dark'); + expect(getComputedStyle(portal).colorScheme).toBe('dark'); + const portalCanvas = getComputedStyle(portal).getPropertyValue('--luke-color-surface-canvas'); + expect(portalCanvas).not.toBe(''); + expect(portalCanvas).toBe( + getComputedStyle(nested).getPropertyValue('--luke-color-surface-canvas'), + ); +}); + +function renderScope(mode?: 'light' | 'dark') { + const scope = document.body.appendChild(document.createElement('div')); + scopes.push(scope); + scope.className = machinedEdgeThemeClassName; + scope.style.backgroundColor = 'var(--luke-color-surface-canvas)'; + if (mode !== undefined) scope.dataset.colorMode = mode; + + return scope; +} + +async function emulateColorScheme(mode: 'light' | 'dark') { + await cdp().send('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-color-scheme', value: mode }], + }); +} diff --git a/packages/@luke-ui/react/src/themes/index.ts b/packages/@luke-ui/react/src/themes/index.ts index d3c37d3a..64a740f6 100644 --- a/packages/@luke-ui/react/src/themes/index.ts +++ b/packages/@luke-ui/react/src/themes/index.ts @@ -3,12 +3,15 @@ import { elmoFoundation, machinedEdgeFoundation } from '../theme/foundations.js' /** * Identity class for the Machined edge theme, the Luke UI default. Apply it to `` or a - * subtree root together with the `@luke-ui/react/themes/machined-edge.css` stylesheet. + * subtree root together with the `@luke-ui/react/themes/machined-edge.css` stylesheet. Omit + * `data-color-mode` to follow the system preference, or set it to `light` or `dark` on a nested + * scope. */ export const machinedEdgeThemeClassName = themeClassName(machinedEdgeFoundation.name); /** * Identity class for the ELMO theme. Apply it to `` or a subtree root together with the - * `@luke-ui/react/themes/elmo.css` stylesheet. + * `@luke-ui/react/themes/elmo.css` stylesheet. Omit `data-color-mode` to follow the system + * preference, or set it to `light` or `dark` on a nested scope. */ export const elmoThemeClassName = themeClassName(elmoFoundation.name);