diff --git a/apps/docs/content/docs/applying-a-theme.mdx b/apps/docs/content/docs/applying-a-theme.mdx index c0a4465e..a7849e42 100644 --- a/apps/docs/content/docs/applying-a-theme.mdx +++ b/apps/docs/content/docs/applying-a-theme.mdx @@ -55,8 +55,9 @@ The server HTML and first client render must use the same value to avoid a hydra ## Preserve the theme in portals Application-owned portals render outside the themed DOM branch, so inherited theme variables no -longer reach them. Apply the active identity class and nearest explicit `data-color-mode` value to -the portal root. Leave the attribute off when the source follows the system setting. +longer reach them. Apply `themeRootClassName`, the active identity class, and the nearest explicit +`data-color-mode` value to the portal root. Leave the attribute off when the source follows the +system setting. ## Next steps diff --git a/apps/docs/content/docs/theming.mdx b/apps/docs/content/docs/theming.mdx index fce7e999..ede3a108 100644 --- a/apps/docs/content/docs/theming.mdx +++ b/apps/docs/content/docs/theming.mdx @@ -8,13 +8,13 @@ does not change component behaviour. ## Theme foundations -Every themed subtree has one root element. It carries `themeRootClassName` and one identity class, -such as `tactileThemeClassName`. +Every themed subtree has one root element. It carries `themeRootClassName` and one identity class. `themeRootClassName` applies the shared reset and base theme layer. The identity class supplies the semantic token values used by components and custom UI. -Each identity includes light and dark colour values. The active colour mode chooses between them. +Each identity includes light and dark colour values. The active colour mode chooses between them. An +explicit `data-color-mode` is optional. Without one, the theme follows the system preference. Colour-mode scopes can nest within an identity. Identity classes cannot. Use a separate root for an independent subtree that needs another identity. diff --git a/apps/docs/src/components/color-mode-override.browser.test.tsx b/apps/docs/src/components/color-mode-override.browser.test.tsx new file mode 100644 index 00000000..c6c41099 --- /dev/null +++ b/apps/docs/src/components/color-mode-override.browser.test.tsx @@ -0,0 +1,62 @@ +import '../styles/app.css'; +import '@luke-ui/react/themes/tactile.css'; +import { tactileThemeClassName } from '@luke-ui/react/themes'; +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 { page, userEvent } from 'vite-plus/test/context'; +import ColorModeOverride from '../examples/theming/color-mode-override'; + +let container: HTMLElement | undefined; +let root: Root | undefined; + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + container = undefined; + root = undefined; +}); + +function renderColourModeExample() { + container = document.body.appendChild(document.createElement('div')); + container.className = `luke-ui-theme ${tactileThemeClassName}`; + root = createRoot(container); + act(() => { + root?.render(); + }); +} + +function panelStyle(panel: HTMLElement): { backgroundColor: string; color: string } { + const style = getComputedStyle(panel); + return { backgroundColor: style.backgroundColor, color: style.color }; +} + +test( + 're-colours the parent-following panel when the parent mode changes and leaves the fixed nested' + + ' panel in its explicit mode', + async () => { + renderColourModeExample(); + + const followingPanel = page + .getByText('This panel follows the parent mode.') + .element().parentElement; + const fixedPanel = page.getByText('This panel is fixed to dark mode.').element().parentElement; + if (!followingPanel || !fixedPanel) { + throw new Error('Expected the colour-mode panels rendered'); + } + + const lightFollowing = panelStyle(followingPanel); + const darkFixed = panelStyle(fixedPanel); + + expect(lightFollowing.backgroundColor).not.toBe(darkFixed.backgroundColor); + expect(lightFollowing.color).not.toBe(darkFixed.color); + + await userEvent.click(page.getByRole('button', { name: 'Dark' })); + + await expect.poll(() => panelStyle(followingPanel)).toEqual(darkFixed); + expect(panelStyle(followingPanel).backgroundColor).not.toBe(lightFollowing.backgroundColor); + expect(panelStyle(followingPanel).color).not.toBe(lightFollowing.color); + expect(panelStyle(fixedPanel)).toEqual(darkFixed); + }, +); diff --git a/apps/docs/src/components/theme-dom-guard.browser.test.tsx b/apps/docs/src/components/theme-dom-guard.browser.test.tsx new file mode 100644 index 00000000..691eddbf --- /dev/null +++ b/apps/docs/src/components/theme-dom-guard.browser.test.tsx @@ -0,0 +1,83 @@ +import '../styles/app.css'; +import '@luke-ui/react/themes/tactile.css'; +import { themeClassName, themeRootClassName } from '@luke-ui/react/theme'; +import { tactileThemeClassName } from '@luke-ui/react/themes'; +import { cx } from '@luke-ui/react/utils'; +import { act } from 'react'; +import type { ReactNode } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, test } from 'vite-plus/test'; +import ColorModeOverride from '../examples/theming/color-mode-override'; +import SemanticVariables from '../examples/theming/semantic-variables'; + +let container: HTMLElement | undefined; +let root: Root | undefined; + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + container = undefined; + root = undefined; +}); + +function renderInDocsThemeRoot(children: ReactNode) { + container = document.body.appendChild(document.createElement('div')); + container.className = `luke-ui-theme ${tactileThemeClassName}`; + root = createRoot(container); + act(() => { + root?.render(children); + }); +} + +/** + * Every identity class starts with this prefix, emitted by `themeClassName`. It is the + * theme-layer marker for a supported identity boundary, so a nested identity is caught without + * depending on any single bundled theme name. + */ +const IDENTITY_CLASS_PREFIX = 'luke-ui-theme-'; + +function nestedIdentityElementsWithin(themeRoot: HTMLElement): Array { + const nested: Array = []; + for (const element of themeRoot.querySelectorAll('*')) { + if (Array.from(element.classList).some((token) => token.startsWith(IDENTITY_CLASS_PREFIX))) { + nested.push(element); + } + } + return nested; +} + +test('detects a supported identity class nested beneath the docs identity root', () => { + renderInDocsThemeRoot( +
+ Docs themed example +
+ Nested application root +
+
, + ); + + const host = container; + if (!host) throw new Error('Expected the docs theme root'); + + const nested = nestedIdentityElementsWithin(host); + expect(nested).toHaveLength(1); + expect(nested[0]?.className).toContain(themeClassName('product')); +}); + +test('rendering the theming examples under the docs identity root adds no nested identity', () => { + renderInDocsThemeRoot( + <> + + + , + ); + + const host = container; + if (!host) throw new Error('Expected the docs theme root'); + + expect(nestedIdentityElementsWithin(host)).toEqual([]); +}); diff --git a/apps/docs/src/examples/theming/color-mode-override.tsx b/apps/docs/src/examples/theming/color-mode-override.tsx index 548789d1..025f4b5e 100644 --- a/apps/docs/src/examples/theming/color-mode-override.tsx +++ b/apps/docs/src/examples/theming/color-mode-override.tsx @@ -4,16 +4,8 @@ import { Text } from '@luke-ui/react/text'; import { vars } from '@luke-ui/react/theme'; import { useState } from 'react'; -type ColorMode = 'dark' | 'light'; - -const OPPOSITE_MODE: Record = { - dark: 'light', - light: 'dark', -}; - export default () => { - const [parentMode, setParentMode] = useState('light'); - const nestedMode = OPPOSITE_MODE[parentMode]; + const [parentMode, setParentMode] = useState<'light' | 'dark'>('light'); return ( { color: vars.color.text.primary, }} > - This panel follows the parent mode: {parentMode}. + This panel follows the parent mode. { color: vars.color.text.primary, }} > - This nested scope forces the opposite mode: {nestedMode}. + This panel is fixed to dark mode. );