diff --git a/modules/react/common/lib/CanvasProvider.tsx b/modules/react/common/lib/CanvasProvider.tsx index 4d53d6df26..d3dbc1b6f4 100644 --- a/modules/react/common/lib/CanvasProvider.tsx +++ b/modules/react/common/lib/CanvasProvider.tsx @@ -31,6 +31,18 @@ import {sanaCanvasProviderTheme} from './theming/sanaTheme'; */ export const CanvasBrandStyleContext = React.createContext({}); +/** + * Context for providing the `data-theme` value to popup containers. + * + * Token stylesheets scope their variables to an attribute selector (e.g. Sana's + * `[data-theme="sana-canvas"]` block defines ~300 variables — palette, shape, depth, type). + * Portaled popups render under `document.body`, outside the `CanvasProvider` wrapper, so they + * never inherit a nested `data-theme` and none of those variables resolve. Forwarding the + * attribute itself lets the popup container match the same selector, so the whole theme applies + * through normal cascade — rather than trying to mirror every variable as an inline style. + */ +export const CanvasThemeAttributeContext = React.createContext(undefined); + export interface CanvasProviderProps { /** * ⚠️ Only use this prop if you intent to to theme a part of your application that is different from global theming. @@ -222,6 +234,13 @@ export const CanvasProvider = ({ // Read parent context to support nested scoped providers const parentBrandStyle = React.useContext(CanvasBrandStyleContext); + const parentThemeAttribute = React.useContext(CanvasThemeAttributeContext); + + // `data-theme` on this provider wins; otherwise inherit from a parent provider so nested + // providers keep forwarding the outer theme attribute to popups. + const themeAttribute = + (props as React.HTMLAttributes & {'data-theme'?: string})['data-theme'] ?? + parentThemeAttribute; // Popup forwarding only needs CSS custom properties (not consumer layout styles). const mergedBrandStyle = React.useMemo(() => { @@ -256,9 +275,11 @@ export const CanvasProvider = ({ ); const wrappedContent = ( - - {content} - + + + {content} + + ); return ( diff --git a/modules/react/common/lib/theming/brandScope.ts b/modules/react/common/lib/theming/brandScope.ts index f52da0a5b7..491b83a52e 100644 --- a/modules/react/common/lib/theming/brandScope.ts +++ b/modules/react/common/lib/theming/brandScope.ts @@ -73,6 +73,11 @@ const EXTENDED_BRAND_TOKEN_MAP: Record = { neutral150: '--cnvs-brand-neutral-150', neutral850: '--cnvs-brand-neutral-850', neutralA150: '--cnvs-brand-neutral-a150', + neutralA850: '--cnvs-brand-neutral-a850', + primaryA300: '--cnvs-brand-primary-a300', + criticalA300: '--cnvs-brand-critical-a300', + cautionA300: '--cnvs-brand-caution-a300', + positiveA300: '--cnvs-brand-positive-a300', }; const setStyleVar = (style: React.CSSProperties, token: string, value: string) => { diff --git a/modules/react/common/lib/theming/sanaTheme.ts b/modules/react/common/lib/theming/sanaTheme.ts index 2f96d9a4aa..b7ecf961ce 100644 --- a/modules/react/common/lib/theming/sanaTheme.ts +++ b/modules/react/common/lib/theming/sanaTheme.ts @@ -15,93 +15,113 @@ * | `sanaCanvasNumericalTheme` | Numerical `brand` shape for popup forwarding | * | `sanaCanvasProviderTheme` | Same — pass to root `CanvasProvider` when `` is unavailable | */ +import {cssVar} from '@workday/canvas-kit-styling'; import {base, brand} from '@workday/canvas-tokens-web'; import type {CanvasNumericalBrandTheme} from './types'; -/** Reference a canvas-tokens CSS variable (resolves under `[data-theme="sana-canvas"]`). */ -const varRef = (token: string) => `var(${token})`; - /** - * Sana extends the neutral ramp with steps not yet exported from canvas-tokens-web JS. - * Defined in `@workday/canvas-tokens-web/css/sana/_variables.css`. + * `base.sana` is a pre-built map of `var(--token, fallback)` strings exported by + * canvas-tokens-web specifically for these Sana-only base-palette steps — use them directly, + * not `cssVar()` (which expects a bare variable name, not an already-wrapped `var()` string). + * Referencing `base.*` (not `brand.*` of the same name) avoids a `var()` self-reference cycle: + * CanvasProvider writes each ramp entry onto the identically-named `--cnvs-brand-*` variable, so + * `brand.sana.neutral150` (itself `var(--cnvs-brand-neutral-150, ...)`) would point right back at + * the variable being written. */ -const sanaBrandNeutral = { - '150': '--cnvs-brand-neutral-150', - '850': '--cnvs-brand-neutral-850', - A150: '--cnvs-brand-neutral-a150', -} as const; +const sanaBaseNeutral = base.sana; /** * Sana Canvas brand tokens for scoped `CanvasProvider` / popup forwarding. * Values are `var()` references to Sana brand variables — not merged from `defaultCanvasTheme`. + * + * Mirrors only what Sana's stylesheet itself defines: the `neutral` ramp, the `A300` step of + * `primary`/`critical`/`caution`/`positive` (the one step Sana redefines for those families — a + * stronger alpha wash on the matching hue), and the four `system.color.brand.*` tokens Sana + * overrides. Every other key is intentionally omitted — Sana does not define a distinct value + * for it, so writing it here would only reference the very variable being written (a `var()` + * cycle that resolves to invalid, leaking the classic-theme fallback color instead of Sana's). + * + * `action.*` and `selected.*` are deliberately **not** set. Sana's stylesheet does not define + * `--cnvs-brand-action-*` or `--cnvs-sys-color-brand-fg-selected`/`-surface-selected`; those are + * derived downstream from the root theme. Forcing them here would pin values the root is meant + * to own. + * + * Ramp values must reference `base.*` (the underlying palette), never `brand.*` of the same + * name — CanvasProvider writes each entry onto the identically-named `--cnvs-brand-*` CSS + * variable, so referencing `brand.*` here would create that same self-reference cycle. The + * `system.color.brand.*` overrides are the exception: they target *different* CSS variables + * (`--cnvs-sys-color-brand-*`) than the `brand.*` values they reference, so no cycle. + * + * Note this preset only covers brand tokens. The rest of Sana (shape, depth, type, non-brand + * system colors) comes from the stylesheet's `[data-theme="sana-canvas"]` block — pass + * `data-theme` to `CanvasProvider` alongside this preset and it is forwarded to portaled popups + * so the full theme applies there too. */ export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { // Explicit brand vars only — multi-key ramps write 1:1; no system shortcut bundles run. themeScope: 'brand', brand: { - action: { - base: varRef(brand.neutral975), - dark: varRef(brand.neutral950), - darkest: varRef(brand.neutral900), - accent: varRef(base.neutral0), - lightest: varRef(brand.neutral25), - lighter: varRef(brand.neutral50), - light: varRef(brand.neutral200), - }, - neutral: { - '25': varRef(brand.neutral25), - '50': varRef(brand.neutral50), - '100': varRef(brand.neutral100), - '150': varRef(sanaBrandNeutral['150']), - '200': varRef(brand.neutral200), - '300': varRef(brand.neutral300), - '400': varRef(brand.neutral400), - '500': varRef(brand.neutral500), - '600': varRef(brand.neutral600), - '700': varRef(brand.neutral700), - '800': varRef(brand.neutral800), - '850': varRef(sanaBrandNeutral['850']), - '900': varRef(brand.neutral900), - '950': varRef(brand.neutral950), - '975': varRef(brand.neutral975), - A25: varRef(brand.neutralA25), - A50: varRef(brand.neutralA50), - A100: varRef(brand.neutralA100), - A150: varRef(sanaBrandNeutral.A150), - A200: varRef(brand.neutralA200), - }, primary: { - '500': varRef(brand.primary500), - '600': varRef(brand.primary600), - '700': varRef(brand.primary700), - A25: varRef(brand.primaryA25), - A50: varRef(brand.primaryA50), - A100: varRef(brand.primaryA100), + A300: sanaBaseNeutral.blueA300, }, critical: { - '500': varRef(brand.critical500), - '600': varRef(brand.critical600), - '700': varRef(brand.critical700), - A25: varRef(brand.criticalA25), - A50: varRef(brand.criticalA50), + A300: sanaBaseNeutral.redA300, }, caution: { - '400': varRef(brand.caution400), - '500': varRef(brand.caution500), - A25: varRef(brand.cautionA25), - A50: varRef(brand.cautionA50), + A300: sanaBaseNeutral.amberA300, }, positive: { - '600': varRef(brand.positive600), - '800': varRef(brand.positive800), - A25: varRef(brand.positiveA25), - A50: varRef(brand.positiveA50), + A300: sanaBaseNeutral.greenA300, + }, + neutral: { + '25': cssVar(base.neutral25), + '50': cssVar(base.neutral50), + '100': cssVar(base.neutral100), + '150': sanaBaseNeutral.neutral150, + '200': cssVar(base.neutral200), + '300': cssVar(base.neutral300), + '400': cssVar(base.neutral400), + '500': cssVar(base.neutral500), + '600': cssVar(base.neutral600), + '700': cssVar(base.neutral700), + '800': cssVar(base.neutral800), + '850': sanaBaseNeutral.neutral850, + '900': cssVar(base.neutral900), + '950': cssVar(base.neutral950), + '975': cssVar(base.neutral975), + A25: cssVar(base.neutralA25), + A50: cssVar(base.neutralA50), + A100: cssVar(base.neutralA100), + A150: sanaBaseNeutral.neutralA150, + A200: cssVar(base.neutralA200), + A300: cssVar(base.neutralA300), + A400: cssVar(base.neutralA400), + A500: cssVar(base.neutralA500), + A600: cssVar(base.neutralA600), + A700: cssVar(base.neutralA700), + A800: cssVar(base.neutralA800), + A850: sanaBaseNeutral.neutralA850, + A900: cssVar(base.neutralA900), + A950: cssVar(base.neutralA950), + A975: cssVar(base.neutralA975), }, }, - selected: { - fg: varRef(brand.neutralA900), - surface: varRef(brand.neutralA100), + system: { + color: { + brand: { + accent: { + primary: cssVar(brand.neutral975), + action: cssVar(brand.neutral975), + }, + fg: { + primary: { + default: cssVar(brand.neutralA900), + strong: cssVar(brand.neutralA950), + }, + }, + }, + }, }, }; @@ -119,6 +139,14 @@ export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { * - Prefer setting `data-theme="sana-canvas"` on `` with Sana CSS imported. Popups then * inherit brand variables from the document and no `theme` prop is needed. * + * Pass `data-theme="sana-canvas"` alongside it. That attribute is forwarded to popup stack + * containers, so the rest of Sana (shape, depth, type, non-brand system colors) applies to + * portaled content through the stylesheet's own `[data-theme="sana-canvas"]` block — this preset + * covers brand tokens, the attribute covers everything else. + * + * `action.*` and selected-state tokens are not set by this preset — Sana does not define them, + * and they resolve from the root theme. + * * @example * ```tsx * // Preferred — control @@ -126,8 +154,10 @@ export const sanaCanvasNumericalTheme: CanvasNumericalBrandTheme = { * // * * - * // No access to — required for popup parity - * + * // No access to — pass both for full parity, including portaled popups + * + * + * * ``` */ export const sanaCanvasProviderTheme = sanaCanvasNumericalTheme; diff --git a/modules/react/common/lib/theming/types.ts b/modules/react/common/lib/theming/types.ts index a88e1f552c..d87e28fe06 100644 --- a/modules/react/common/lib/theming/types.ts +++ b/modules/react/common/lib/theming/types.ts @@ -312,17 +312,34 @@ export type CanvasBrandRamp = Partial< | 'A25' | 'A50' | 'A100' - | 'A200', + | 'A200' + | 'A300', string > >; /** - * Neutral brand ramp — includes Sana-only steps (`150` / `850` / `A150`) that are not - * exported for primary/critical/caution/positive families. + * Neutral brand ramp — includes Sana-only steps (`150` / `850` / `A150` / `A850`), plus the + * extended alpha steps (`A400`–`A975`) that only the neutral family exposes. */ export type CanvasNeutralBrandRamp = CanvasBrandRamp & - Partial>; + Partial< + Record< + | '150' + | '850' + | 'A150' + | 'A400' + | 'A500' + | 'A600' + | 'A700' + | 'A800' + | 'A850' + | 'A900' + | 'A950' + | 'A975', + string + > + >; /** Semantic keys for `brand.action.*` CSS variables (PrimaryButton, etc.). */ export type CanvasActionBrandRamp = Partial< diff --git a/modules/react/common/spec/CanvasProvider.spec.tsx b/modules/react/common/spec/CanvasProvider.spec.tsx index 487feeb115..2829ecea55 100644 --- a/modules/react/common/spec/CanvasProvider.spec.tsx +++ b/modules/react/common/spec/CanvasProvider.spec.tsx @@ -5,6 +5,16 @@ import {CanvasProvider} from '../lib/CanvasProvider'; import {sanaCanvasProviderTheme} from '../lib/theming/sanaTheme'; describe('CanvasProvider', () => { + it('forwards data-theme onto the wrapper div', () => { + const {container} = render( + +
Test
+
+ ); + + expect(container.firstElementChild?.getAttribute('data-theme')).toBe('sana-canvas'); + }); + describe('console warnings', () => { it('should warn when sanaCanvasProviderTheme is used with global Sana theme', () => { const consoleSpy = vi.spyOn(global.console, 'warn').mockImplementation(() => {}); diff --git a/modules/react/common/spec/sanaTheme.spec.ts b/modules/react/common/spec/sanaTheme.spec.ts index 7166c5ccf5..ea501a2402 100644 --- a/modules/react/common/spec/sanaTheme.spec.ts +++ b/modules/react/common/spec/sanaTheme.spec.ts @@ -5,26 +5,82 @@ import {defaultCanvasTheme} from '../lib/theming'; import {sanaCanvasNumericalTheme, sanaCanvasProviderTheme} from '../lib/theming/sanaTheme'; describe('sanaCanvasNumericalTheme', () => { - it('references Sana brand CSS variables instead of defaultCanvasTheme literals', () => { - expect(sanaCanvasNumericalTheme.brand?.neutral?.['600']).toBe(`var(${brand.neutral600})`); - expect(sanaCanvasNumericalTheme.brand?.action?.base).toBe(`var(${brand.neutral975})`); - expect(sanaCanvasNumericalTheme.brand?.action?.accent).toBe(`var(${base.neutral0})`); + it('references Sana base-palette CSS variables instead of defaultCanvasTheme literals', () => { + expect(sanaCanvasNumericalTheme.brand?.neutral?.['600']).toBe(`var(${base.neutral600})`); expect(sanaCanvasNumericalTheme.brand?.neutral?.['600']).not.toBe( defaultCanvasTheme.palette.neutral.main ); }); + it('never references the same CSS variable name it writes to (would create a var() cycle)', () => { + const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); + Object.entries(style).forEach(([key, value]) => { + if (key.startsWith('--') && typeof value === 'string') { + expect(value).not.toBe(`var(${key})`); + } + }); + }); + + it("only writes the A300 step of primary/critical/caution/positive (Sana's one distinct value)", () => { + expect(Object.keys(sanaCanvasNumericalTheme.brand?.primary ?? {})).toEqual(['A300']); + expect(Object.keys(sanaCanvasNumericalTheme.brand?.critical ?? {})).toEqual(['A300']); + expect(Object.keys(sanaCanvasNumericalTheme.brand?.caution ?? {})).toEqual(['A300']); + expect(Object.keys(sanaCanvasNumericalTheme.brand?.positive ?? {})).toEqual(['A300']); + + const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); + expect(style['--cnvs-brand-primary-a300' as any]).toBe(base.sana.blueA300); + expect(style['--cnvs-brand-critical-a300' as any]).toBe(base.sana.redA300); + expect(style['--cnvs-brand-caution-a300' as any]).toBe(base.sana.amberA300); + expect(style['--cnvs-brand-positive-a300' as any]).toBe(base.sana.greenA300); + }); + it('writes brand tokens when passed to canvasThemeToCssVars', () => { const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); expect(Object.keys(style).length).toBeGreaterThan(0); - expect(style[brand.neutral600 as any]).toBe(`var(${brand.neutral600})`); - expect(style[brand.action.base as any]).toBe(`var(${brand.neutral975})`); + expect(style[brand.neutral600 as any]).toBe(`var(${base.neutral600})`); }); - it('writes Sana extended neutral ramp keys', () => { + it('writes Sana extended neutral ramp keys from the base palette', () => { const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); - expect(style['--cnvs-brand-neutral-150' as any]).toBe('var(--cnvs-brand-neutral-150)'); - expect(style['--cnvs-brand-neutral-850' as any]).toBe('var(--cnvs-brand-neutral-850)'); - expect(style['--cnvs-brand-neutral-a150' as any]).toBe('var(--cnvs-brand-neutral-a150)'); + expect(style['--cnvs-brand-neutral-150' as any]).toBe(base.sana.neutral150); + expect(style['--cnvs-brand-neutral-850' as any]).toBe(base.sana.neutral850); + expect(style['--cnvs-brand-neutral-a150' as any]).toBe(base.sana.neutralA150); + expect(style['--cnvs-brand-neutral-a850' as any]).toBe(base.sana.neutralA850); + }); + + it('writes the full neutral alpha ramp through A975 (not just up to A200)', () => { + const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); + expect(style[brand.neutralA300 as any]).toBe(`var(${base.neutralA300})`); + expect(style[brand.neutralA900 as any]).toBe(`var(${base.neutralA900})`); + expect(style[brand.neutralA975 as any]).toBe(`var(${base.neutralA975})`); + }); + + it('does not write brand.action.* — Sana does not define it; it derives from the root theme', () => { + expect(sanaCanvasNumericalTheme.brand?.action).toBeUndefined(); + const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); + expect(style[brand.action.base as any]).toBeUndefined(); + expect(style[brand.action.darker as any]).toBeUndefined(); + expect(style[brand.action.accent as any]).toBeUndefined(); + }); + + it('does not write selected.* — Sana does not define it; it derives from the root theme', () => { + expect(sanaCanvasNumericalTheme.selected).toBeUndefined(); + const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); + expect(style['--cnvs-sys-color-brand-fg-selected' as any]).toBeUndefined(); + expect(style['--cnvs-sys-color-brand-surface-selected' as any]).toBeUndefined(); + }); + + it('forwards accent.primary/action and fg.primary.default/strong for portal parity', () => { + // Sana's stylesheet redefines these four `system.color.brand.*` tokens, so popups outside + // `[data-theme="sana-canvas"]`'s reach need the override too. + const {style} = canvasThemeToCssVars(sanaCanvasProviderTheme, {}); + expect(style['--cnvs-sys-color-brand-accent-primary' as any]).toBe(`var(${brand.neutral975})`); + expect(style['--cnvs-sys-color-brand-accent-action' as any]).toBe(`var(${brand.neutral975})`); + expect(style['--cnvs-sys-color-brand-fg-primary-default' as any]).toBe( + `var(${brand.neutralA900})` + ); + expect(style['--cnvs-sys-color-brand-fg-primary-strong' as any]).toBe( + `var(${brand.neutralA950})` + ); }); }); diff --git a/modules/react/common/stories/mdx/Theming.mdx b/modules/react/common/stories/mdx/Theming.mdx index e3106e3c8c..4a0d882b98 100644 --- a/modules/react/common/stories/mdx/Theming.mdx +++ b/modules/react/common/stories/mdx/Theming.mdx @@ -119,9 +119,11 @@ import {CanvasProvider} from '@workday/canvas-kit-react/common'; ``` **Scoped / no document-root control:** if you cannot set `data-theme` on `` (embedded apps, -microfrontends, third-party shells), a nested `data-theme` alone does **not** reach portaled -popups. Pass both `data-theme="sana-canvas"` (for in-tree UI) and `sanaCanvasProviderTheme` (so -Canvas Kit forwards Sana brand variables onto the popup stack container): +microfrontends, third-party shells), pass both `data-theme="sana-canvas"` and +`sanaCanvasProviderTheme` to `CanvasProvider`. The preset supplies Sana's brand variables, and +Canvas Kit forwards the `data-theme` attribute onto the popup stack container — so portaled +menus, modals, and dialogs match the same `[data-theme="sana-canvas"]` selector and pick up the +rest of the theme (shape, depth, type, non-brand system colors) through normal cascade: ```tsx import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; diff --git a/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx b/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx index 11641a13ad..8b78bf32e8 100644 --- a/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx +++ b/modules/react/common/stories/mdx/examples/SimplifiedSanaSetup.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import {PrimaryButton, SecondaryButton} from '@workday/canvas-kit-react/button'; +import {PrimaryButton} from '@workday/canvas-kit-react/button'; import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; import {Menu} from '@workday/canvas-kit-react/menu'; import {Popup, useCloseOnOutsideClick, usePopupModel} from '@workday/canvas-kit-react/popup'; +import {TextInput} from '@workday/canvas-kit-react/text-input'; /** * Scoped Sana setup for popup parity: `data-theme` themes the in-tree UI, and @@ -16,17 +17,18 @@ export const SimplifiedSetup = () => { return ( - Open Menu + Open Menu - - Option 1 + + Option 1 Option 2 Option 3 Hello World + diff --git a/modules/react/popup/lib/hooks/usePopupStack.ts b/modules/react/popup/lib/hooks/usePopupStack.ts index 5a8efbfebd..e8d4458708 100644 --- a/modules/react/popup/lib/hooks/usePopupStack.ts +++ b/modules/react/popup/lib/hooks/usePopupStack.ts @@ -1,7 +1,12 @@ import React from 'react'; import {PopupStack} from '@workday/canvas-kit-popup-stack'; -import {CanvasBrandStyleContext, isElementRTL, useLocalRef} from '@workday/canvas-kit-react/common'; +import { + CanvasBrandStyleContext, + CanvasThemeAttributeContext, + isElementRTL, + useLocalRef, +} from '@workday/canvas-kit-react/common'; /** * **Note:** If you're using {@link Popper}, you do not need to use this hook directly. @@ -53,6 +58,7 @@ export const usePopupStack = ( // Read brand style from the context provided by CanvasProvider const style = React.useContext(CanvasBrandStyleContext); + const themeAttribute = React.useContext(CanvasThemeAttributeContext); const firstLoadRef = React.useRef(true); // React 19 can call a useState more than once, so we need to track if we've already created a container // useState function input ensures we only create a container once. @@ -90,6 +96,22 @@ export const usePopupStack = ( return undefined; }, [localRef, style]); + // Forward `data-theme` so the popup container matches the same attribute selector the token + // stylesheet scopes its variables to (e.g. `[data-theme="sana-canvas"]`). Portals render under + // `document.body`, outside the CanvasProvider wrapper, so without this none of those variables + // resolve — and unlike the inline styles above, this covers the *whole* theme (palette, shape, + // depth, type), not just the brand vars the theme object enumerates. Runs before PopupStack.add + // to avoid a theme flash. + React.useLayoutEffect(() => { + const element = localRef.current; + if (!element || !themeAttribute) { + return undefined; + } + element.setAttribute('data-theme', themeAttribute); + // No cleanup: leave theme on container so reopening doesn't flash + return undefined; + }, [localRef, themeAttribute]); + // We useLayoutEffect to ensure proper timing of registration of the element to the popup stack. // Without this, the timing is unpredictable when mixed with other frameworks. Other frameworks // should also register as soon as the element is available diff --git a/modules/react/popup/spec/usePopupStack.spec.tsx b/modules/react/popup/spec/usePopupStack.spec.tsx index 864277ee35..eebb8ff76b 100644 --- a/modules/react/popup/spec/usePopupStack.spec.tsx +++ b/modules/react/popup/spec/usePopupStack.spec.tsx @@ -2,6 +2,7 @@ import {renderHook, waitFor} from '@testing-library/react'; import React from 'react'; import {CanvasProvider, sanaCanvasProviderTheme} from '@workday/canvas-kit-react/common'; +import {base} from '@workday/canvas-tokens-web'; import {usePopupStack} from '../lib/hooks/usePopupStack'; @@ -58,18 +59,71 @@ describe('usePopupStack', () => { // Wait for the effect to apply styles await waitFor(() => { - // Check that the container has the correct CSS variables const styles = container?.style; if (styles) { - // The Sana theme should set brand variables - // Sana theme uses CSS variable references (var(--cnvs-brand-primary-600)) - const primary600 = styles.getPropertyValue('--cnvs-brand-primary-600'); - // Check that the value is a CSS variable reference - expect(primary600).toContain('var(--cnvs-brand-primary-600)'); + // Sana only redefines primary A300 (not 600) — writing primary-600 would be a + // var() self-reference cycle. The neutral ramp and system.color.brand.* overrides + // are forwarded as var() references to the underlying palette. + expect(styles.getPropertyValue('--cnvs-brand-primary-600')).toBe(''); + expect(styles.getPropertyValue('--cnvs-brand-primary-a300')).toBe(base.sana.blueA300); + expect(styles.getPropertyValue('--cnvs-brand-neutral-600')).toBe( + `var(${base.neutral600})` + ); + expect(styles.getPropertyValue('--cnvs-sys-color-brand-accent-primary')).toBe( + 'var(--cnvs-brand-neutral-975)' + ); + // `action` and selected-state tokens are not set by the preset — Sana doesn't define + // them, so they resolve from the root theme rather than being pinned here. + expect(styles.getPropertyValue('--cnvs-brand-action-base')).toBe(''); + expect(styles.getPropertyValue('--cnvs-sys-color-brand-fg-selected')).toBe(''); + expect(styles.getPropertyValue('--cnvs-sys-color-brand-surface-selected')).toBe(''); } }); }); + it('should forward data-theme to the popup container so scoped token CSS applies', async () => { + // Token stylesheets scope variables to `[data-theme="..."]`. Portals render outside the + // CanvasProvider wrapper, so the attribute must be copied for the whole theme (palette, + // shape, depth, type) to resolve — inline brand vars alone don't cover it. + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + + await waitFor(() => { + expect(result.current.current?.getAttribute('data-theme')).toBe('sana-canvas'); + }); + }); + + it('should not set data-theme on the popup container when the provider has none', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + + await waitFor(() => { + expect(result.current.current?.hasAttribute('data-theme')).toBe(false); + }); + }); + + it('should inherit data-theme from an outer provider through nested providers', async () => { + const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + + ); + + const {result} = renderHook(() => usePopupStack(), {wrapper}); + + await waitFor(() => { + expect(result.current.current?.getAttribute('data-theme')).toBe('sana-canvas'); + }); + }); + it('should forward legacy palette theme CSS variables to popup container', async () => { const legacyTheme = { canvas: {