diff --git a/change/@fluentui-web-components-d74234e0-b903-4c51-af61-599648aee92d.json b/change/@fluentui-web-components-d74234e0-b903-4c51-af61-599648aee92d.json new file mode 100644 index 00000000000000..96ddf000a58255 --- /dev/null +++ b/change/@fluentui-web-components-d74234e0-b903-4c51-af61-599648aee92d.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: enabling setting theme on an individual element and unsetting themes", + "packageName": "@fluentui/web-components", + "email": "machi@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/.storybook/preview.mjs b/packages/web-components/.storybook/preview.mjs index d16b42a1387f1d..3650cefadb6a9f 100644 --- a/packages/web-components/.storybook/preview.mjs +++ b/packages/web-components/.storybook/preview.mjs @@ -1,15 +1,26 @@ -import { switchTheme } from '../public/theme-switch.js'; +import { teamsDarkTheme, teamsLightTheme, webDarkTheme, webLightTheme } from '@fluentui/tokens'; import webcomponentsTheme from './theme.mjs'; +import { setTheme } from '../src/theme/set-theme.js'; import '../src/index-rollup.js'; import './docs-root.css'; +const themes = { + 'web-light': webLightTheme, + 'web-dark': webDarkTheme, + 'teams-light': teamsLightTheme, + 'teams-dark': teamsDarkTheme, +}; + function changeTheme(/** @type {Event} */ e) { - switchTheme(/** @type {Parameters[number]} */ (/** @type {HTMLInputElement}*/ (e.target).value)); + setTheme(themes[/** @type {keyof themes} */ (/** @type {HTMLInputElement}*/ (e.target).value)]); } +// This is needed in Playwright. +Object.defineProperty(window, 'setTheme', { value: setTheme }); + document.getElementById('theme-switch')?.addEventListener('change', changeTheme, false); -switchTheme('web-light'); +setTheme(themes['web-light']); export const parameters = { layout: 'fullscreen', diff --git a/packages/web-components/docs/api-report.md b/packages/web-components/docs/api-report.md index 03193e0d7f7f59..ddf4a45380af48 100644 --- a/packages/web-components/docs/api-report.md +++ b/packages/web-components/docs/api-report.md @@ -519,6 +519,62 @@ export class BaseButton extends FASTElement { value?: string; } +// @public +export class BaseCheckbox extends FASTElement { + autofocus: boolean; + get checked(): boolean; + set checked(next: boolean); + checkValidity(): boolean; + // @internal + clickHandler(e: MouseEvent): boolean | void; + // (undocumented) + connectedCallback(): void; + disabled?: boolean; + disabledAttribute?: boolean; + // @internal + protected disabledAttributeChanged(prev: boolean | undefined, next: boolean | undefined): void; + // @internal + protected disabledChanged(prev: boolean | undefined, next: boolean | undefined): void; + // @internal + elementInternals: ElementInternals; + get form(): HTMLFormElement | null; + static formAssociated: boolean; + formAttribute?: string; + // @internal + formResetCallback(): void; + initialChecked?: boolean; + // @internal + protected initialCheckedChanged(prev: boolean | undefined, next: boolean | undefined): void; + initialValue: string; + // @internal + protected initialValueChanged(prev: string, next: string): void; + // @internal + inputHandler(e: InputEvent): boolean | void; + // @internal + keydownHandler(e: KeyboardEvent): boolean | void; + // @internal + keyupHandler(e: KeyboardEvent): boolean | void; + get labels(): ReadonlyArray; + name: string; + reportValidity(): boolean; + required: boolean; + // @internal + protected requiredChanged(prev: boolean, next: boolean): void; + // @internal + protected setAriaChecked(value?: boolean): void; + setCustomValidity(message: string): void; + // @internal + setFormValue(value: File | string | FormData | null, state?: File | string | FormData | null): void; + // @internal + setValidity(flags?: Partial, message?: string, anchor?: HTMLElement): void; + toggleChecked(force?: boolean): void; + get validationMessage(): string; + get validity(): ValidityState; + get value(): string; + set value(value: string); + get willValidate(): boolean; +} + // @public export class BaseDivider extends FASTElement { // (undocumented) @@ -883,8 +939,6 @@ export const ButtonType: { // @public export type ButtonType = ValuesOf; -// Warning: (ae-forgotten-export) The symbol "BaseCheckbox" needs to be exported by the entry point index.d.ts -// // @public export class Checkbox extends BaseCheckbox { constructor(); @@ -2234,7 +2288,10 @@ export const DividerDefinition: FASTElementDefinition; // @public export const DividerOrientation: { - readonly horizontal: "horizontal"; + readonly horizontal: "horizontal"; /** + * Divider roles + * @public + */ readonly vertical: "vertical"; }; @@ -2971,7 +3028,10 @@ export const RadioGroupDefinition: FASTElementDefinition; // @public export const RadioGroupOrientation: { - readonly horizontal: "horizontal"; + readonly horizontal: "horizontal"; /** + * Radio Group orientation + * @public + */ readonly vertical: "vertical"; }; @@ -3049,12 +3109,12 @@ export const roleForMenuItem: { // Warning: (ae-internal-missing-underscore) The name "setTheme" should be prefixed with an underscore because the declaration is marked as @internal // // @internal -export const setTheme: (theme: Theme) => void; +export function setTheme(theme: Theme | null, node?: Document | HTMLElement): void; // Warning: (ae-internal-missing-underscore) The name "setThemeFor" should be prefixed with an underscore because the declaration is marked as @internal // -// @internal (undocumented) -export const setThemeFor: (element: HTMLElement, theme: Theme) => void; +// @internal @deprecated (undocumented) +export function setThemeFor(element: HTMLElement, theme: Theme | null): void; // @public export const shadow16 = "var(--shadow16)"; @@ -3475,7 +3535,10 @@ export const TablistDefinition: FASTElementDefinition; // @public export const TablistOrientation: { - readonly horizontal: "horizontal"; + readonly horizontal: "horizontal"; /** + * The appearance of the component + * @public + */ readonly vertical: "vertical"; }; diff --git a/packages/web-components/playwright.config.ts b/packages/web-components/playwright.config.ts index bf64c0b6a1a66c..0b2718305e1cae 100644 --- a/packages/web-components/playwright.config.ts +++ b/packages/web-components/playwright.config.ts @@ -1,8 +1,8 @@ import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; const config: PlaywrightTestConfig = { reporter: 'list', - testMatch: /.*\.spec\.ts$/, retries: 3, fullyParallel: process.env.CI ? false : true, timeout: process.env.CI ? 10000 : 30000, @@ -13,6 +13,23 @@ const config: PlaywrightTestConfig = { width: 1280, }, }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + testMatch: /.*\.spec\.ts$/, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + testMatch: [/set-theme\.spec\.ts$/], + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + testMatch: [/set-theme\.spec\.ts$/], + }, + ], webServer: { // double-quotes are required for Windows command: `node -e "import('express').then(({ default: e }) => e().use(e.static('./dist/storybook')).listen(6006))"`, diff --git a/packages/web-components/public/theme-switch.ts b/packages/web-components/public/theme-switch.ts deleted file mode 100644 index 2477c498a8bcac..00000000000000 --- a/packages/web-components/public/theme-switch.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { teamsDarkTheme, teamsLightTheme, webDarkTheme, webLightTheme } from '@fluentui/tokens'; -import { setTheme } from '../src/theme/set-theme.js'; - -const themes = { - 'web-light': webLightTheme, - 'web-dark': webDarkTheme, - 'teams-light': teamsLightTheme, - 'teams-dark': teamsDarkTheme, -}; - -export function switchTheme(themeName: keyof typeof themes) { - setTheme(themes[themeName]); -} diff --git a/packages/web-components/src/theme/set-theme.bench.ts b/packages/web-components/src/theme/set-theme.global.bench.ts similarity index 94% rename from packages/web-components/src/theme/set-theme.bench.ts rename to packages/web-components/src/theme/set-theme.global.bench.ts index 9edcc959fc33b9..4a7e15af58634d 100644 --- a/packages/web-components/src/theme/set-theme.bench.ts +++ b/packages/web-components/src/theme/set-theme.global.bench.ts @@ -21,6 +21,9 @@ const tests: Record = { setTheme(teamsDarkTheme); setTheme(teamsLightTheme); + // Unset themes + setTheme(null); + endMeasure(); onComplete(); diff --git a/packages/web-components/src/theme/set-theme.local.bench.ts b/packages/web-components/src/theme/set-theme.local.bench.ts new file mode 100644 index 00000000000000..333b34c29aa175 --- /dev/null +++ b/packages/web-components/src/theme/set-theme.local.bench.ts @@ -0,0 +1,36 @@ +import { measurePerformance, type TestRenderFunction } from '@tensile-perf/web-components'; +import { teamsDarkTheme, teamsLightTheme, webDarkTheme, webLightTheme } from '@fluentui/tokens'; + +import { setTheme } from './set-theme.js'; + +const tests: Record = { + mount: ({ onComplete }) => { + const { startMeasure, endMeasure } = measurePerformance(); + + const el = document.createElement('div'); + document.body.append(el); + + startMeasure(); + + // Newly set themes + setTheme(webLightTheme, el); + setTheme(webDarkTheme, el); + setTheme(teamsDarkTheme, el); + setTheme(teamsLightTheme, el); + + // Cached themes + setTheme(webLightTheme, el); + setTheme(webDarkTheme, el); + setTheme(teamsDarkTheme, el); + setTheme(teamsLightTheme, el); + + // Unset themes + setTheme(null, el); + + endMeasure(); + + onComplete(); + }, +}; + +export { tests }; diff --git a/packages/web-components/src/theme/set-theme.shadow.bench.ts b/packages/web-components/src/theme/set-theme.shadow.bench.ts new file mode 100644 index 00000000000000..ccff97180c65a1 --- /dev/null +++ b/packages/web-components/src/theme/set-theme.shadow.bench.ts @@ -0,0 +1,38 @@ +import { measurePerformance, type TestRenderFunction } from '@tensile-perf/web-components'; +import { teamsDarkTheme, teamsLightTheme, webDarkTheme, webLightTheme } from '@fluentui/tokens'; + +import { setTheme } from './set-theme.js'; + +const tests: Record = { + mount: ({ onComplete }) => { + const { startMeasure, endMeasure } = measurePerformance(); + + const el = document.createElement('div'); + el.attachShadow({ mode: 'open' }); + el.shadowRoot!.append(document.createElement('span')); + document.body.append(el); + + startMeasure(); + + // Newly set themes + setTheme(webLightTheme, el); + setTheme(webDarkTheme, el); + setTheme(teamsDarkTheme, el); + setTheme(teamsLightTheme, el); + + // Cached themes + setTheme(webLightTheme, el); + setTheme(webDarkTheme, el); + setTheme(teamsDarkTheme, el); + setTheme(teamsLightTheme, el); + + // Unset themes + setTheme(null, el); + + endMeasure(); + + onComplete(); + }, +}; + +export { tests }; diff --git a/packages/web-components/src/theme/set-theme.spec.ts b/packages/web-components/src/theme/set-theme.spec.ts new file mode 100644 index 00000000000000..c777d3146f5e92 --- /dev/null +++ b/packages/web-components/src/theme/set-theme.spec.ts @@ -0,0 +1,186 @@ +import { expect, test } from '@playwright/test'; +import { fixtureURL } from '../helpers.tests.js'; + +import type { setTheme as _setTheme, Theme } from './set-theme.js'; + +const theme1: Theme = { + foo: 'foo1', + bar: 'bar1', +}; + +const theme2: Theme = { + foo: 'foo2', + bar: 'bar2', +}; + +declare global { + interface Window { + setTheme: typeof _setTheme; + } +} + +test.describe('setTheme()', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fixtureURL('theme-settheme--set-theme')); + }); + + test('should set and uset global tokens', async ({ page }) => { + const html = page.locator('html'); + const body = page.locator('body'); + const div = page.locator('div'); + + await page.setContent(`
`); + + await page.evaluate(theme => { + window.setTheme(theme); + }, theme1); + + await expect(html).toHaveCSS('--foo', 'foo1'); + await expect(html).toHaveCSS('--bar', 'bar1'); + await expect(body).toHaveCSS('--foo', 'foo1'); + await expect(body).toHaveCSS('--bar', 'bar1'); + await expect(div).toHaveCSS('--foo', 'foo1'); + await expect(div).toHaveCSS('--bar', 'bar1'); + + await page.evaluate(theme => { + window.setTheme(theme); + }, theme2); + + await expect(html).toHaveCSS('--foo', 'foo2'); + await expect(html).toHaveCSS('--bar', 'bar2'); + await expect(body).toHaveCSS('--foo', 'foo2'); + await expect(body).toHaveCSS('--bar', 'bar2'); + await expect(div).toHaveCSS('--foo', 'foo2'); + await expect(div).toHaveCSS('--bar', 'bar2'); + + await page.evaluate(() => { + window.setTheme(null); + }); + + // Revert the values back to the registered initial values. + await expect(html).toHaveCSS('--foo', ''); + await expect(html).toHaveCSS('--bar', ''); + await expect(body).toHaveCSS('--foo', ''); + await expect(body).toHaveCSS('--bar', ''); + await expect(div).toHaveCSS('--foo', ''); + await expect(div).toHaveCSS('--bar', ''); + }); + + test('should set and unset tokens in a light DOM subtree', async ({ page }) => { + const div = page.locator('div'); + const span = page.locator('span'); + + await page.setContent(`
`); + + await page.evaluate(theme => { + window.setTheme(theme); + }, theme1); + + await expect(div).toHaveCSS('--foo', 'foo1'); + await expect(div).toHaveCSS('--bar', 'bar1'); + await expect(span).toHaveCSS('--foo', 'foo1'); + await expect(span).toHaveCSS('--bar', 'bar1'); + + await div.evaluate((node: HTMLDivElement, theme) => { + window.setTheme(theme, node); + }, theme2); + + await expect(div).toHaveCSS('--foo', 'foo2'); + await expect(div).toHaveCSS('--bar', 'bar2'); + await expect(span).toHaveCSS('--foo', 'foo2'); + await expect(span).toHaveCSS('--bar', 'bar2'); + + await div.evaluate((node: HTMLDivElement) => { + window.setTheme(null, node); + }); + + await expect(div).toHaveCSS('--foo', 'foo1'); + await expect(div).toHaveCSS('--bar', 'bar1'); + await expect(span).toHaveCSS('--foo', 'foo1'); + await expect(span).toHaveCSS('--bar', 'bar1'); + }); + + test('should set and unset tokens in a shadow DOM tree', async ({ page }) => { + const div = page.locator('div'); + const span = page.locator('span'); + + // Using Declarative Shadow DOM with `page.setContent()` doesn’t work in Firefox. + await page.setContent('
'); + await div.evaluate((node: HTMLDivElement) => { + node.attachShadow({ mode: 'open' }); + node.shadowRoot!.innerHTML = ''; + }); + + await page.evaluate(theme => { + window.setTheme(theme); + }, theme1); + + await expect(div).toHaveCSS('--foo', 'foo1'); + await expect(div).toHaveCSS('--bar', 'bar1'); + await expect(span).toHaveCSS('--foo', 'foo1'); + await expect(span).toHaveCSS('--bar', 'bar1'); + + await div.evaluate((node: HTMLDivElement, theme) => { + window.setTheme(theme, node); + }, theme2); + + await expect(div).toHaveCSS('--foo', 'foo2'); + await expect(div).toHaveCSS('--bar', 'bar2'); + await expect(span).toHaveCSS('--foo', 'foo2'); + await expect(span).toHaveCSS('--bar', 'bar2'); + + await div.evaluate((node: HTMLDivElement) => { + window.setTheme(null, node); + }); + + await expect(div).toHaveCSS('--foo', 'foo1'); + await expect(div).toHaveCSS('--bar', 'bar1'); + await expect(span).toHaveCSS('--foo', 'foo1'); + await expect(span).toHaveCSS('--bar', 'bar1'); + }); + + test('should not inherit token values from light DOM subtree once tokens are set in the shadow DOM tree', async ({ + page, + }) => { + const parent = page.locator('div.parent'); + const host = page.locator('div.host'); + const span = host.locator('span'); + + // Using Declarative Shadow DOM with `page.setContent()` doesn’t work in Firefox. + await page.setContent(` +
+
+
+ `); + await host.evaluate((node: HTMLDivElement) => { + node.attachShadow({ mode: 'open' }); + node.shadowRoot!.innerHTML = ''; + }); + + await parent.evaluate((node: HTMLDivElement, theme) => { + window.setTheme(theme, node); + }, theme1); + + await host.evaluate((node: HTMLDivElement, theme) => { + window.setTheme(theme, node); + }, theme2); + + await expect(parent).toHaveCSS('--foo', 'foo1'); + await expect(parent).toHaveCSS('--bar', 'bar1'); + await expect(host).toHaveCSS('--foo', 'foo2'); + await expect(host).toHaveCSS('--bar', 'bar2'); + await expect(span).toHaveCSS('--foo', 'foo2'); + await expect(span).toHaveCSS('--bar', 'bar2'); + + await parent.evaluate((node: HTMLDivElement) => { + window.setTheme(null, node); + }); + + await expect(parent).toHaveCSS('--foo', ''); + await expect(parent).toHaveCSS('--bar', ''); + await expect(host).toHaveCSS('--foo', 'foo2'); + await expect(host).toHaveCSS('--bar', 'bar2'); + await expect(span).toHaveCSS('--foo', 'foo2'); + await expect(span).toHaveCSS('--bar', 'bar2'); + }); +}); diff --git a/packages/web-components/src/theme/set-theme.stories.ts b/packages/web-components/src/theme/set-theme.stories.ts index 4b557fbdfaa33f..eef476345f631e 100644 --- a/packages/web-components/src/theme/set-theme.stories.ts +++ b/packages/web-components/src/theme/set-theme.stories.ts @@ -1,17 +1,38 @@ -import { html } from '@microsoft/fast-element'; +import { html, repeat, when } from '@microsoft/fast-element'; import { teamsDarkTheme, teamsLightTheme, webDarkTheme, webLightTheme } from '@fluentui/tokens'; - -import type { Story } from '../helpers.stories.js'; +import type { Meta } from '../helpers.stories.js'; import { renderComponent } from '../helpers.stories.js'; import type { Theme } from './set-theme.js'; import { setTheme } from './set-theme.js'; +import { colorNeutralBackground2, colorNeutralForeground2 } from './design-tokens.js'; + +const themes: Map = new Map([ + ['web-light', webLightTheme], + ['web-dark', webDarkTheme], + ['team-light', teamsLightTheme], + ['team-dark', teamsDarkTheme], + ['unset', null], +]); + +function updateTheme(evt: Event, type = 'global') { + const { value } = evt.target as HTMLSelectElement; -const fluentTheme: Record = { - webLightTheme, - webDarkTheme, - teamsLightTheme, - teamsDarkTheme, -}; + if (themes.has(value) !== undefined) { + switch (type) { + case 'global': + setTheme(themes.get(value)!); + break; + case 'local': + setTheme(themes.get(value)!, document.querySelector('.local') as HTMLElement); + break; + case 'shadow': + document.querySelectorAll('.shadow').forEach(el => { + setTheme(themes.get(value)!, el as HTMLElement); + }); + break; + } + } +} const themeDescription = ` Flat object of theme tokens. Each object entry must follow these rules: @@ -22,33 +43,36 @@ Flat object of theme tokens. Each object entry must follow these rules: Note that this argument is not limited to existing theme objects (from \`@fluentui/tokens\`), you can pass in an arbitrary theme object as long as each entry’s value is either a string or a number. + +Set to \`null\` to unset the theme. `; export default { title: 'Theme/SetTheme', argTypes: { theme: { - type: 'string', - control: 'select', - options: Object.keys(fluentTheme), description: themeDescription, + control: false, + }, + node: { + description: 'The node or element to set theme on. Defaults to `Document`', + control: false, }, }, - args: { - theme: 'webLightTheme', + parameters: { + docs: { + description: { + component: 'A utility funciton to sets the theme tokens as CSS Custom Properties.', + }, + }, }, -}; +} as Meta; const ComponentCloudTemplate = html` -

A button

-

-

- - Text input - -

-

+ A button + + Toggle Menu @@ -59,11 +83,15 @@ const ComponentCloudTemplate = html`

- +

+ + Text input + +

- + @@ -79,23 +107,134 @@ const ComponentCloudTemplate = html`

-

- -

- -

+

`; -export const SetTheme: Story = renderComponent(ComponentCloudTemplate); -SetTheme.decorators = [ - (Story, { args: { theme } }) => { - theme && setTheme(fluentTheme[theme]); - return Story(); - }, -]; +const ThemeOptionsTemplate = (selected: string = '') => html` + + ${repeat( + Array.from(themes.keys()), + html` + ${when(k => k !== 'unset', html` `)} + `, + )} +`; + +export const SetTheme = renderComponent(html` + + +
+ + + + + +
+ +
+

These elements follow the global theme

+ ${ComponentCloudTemplate} +
+ +
+

These elements follow the container element’s theme

+ ${ComponentCloudTemplate} + +
+

+ These elements (which have shadow roots) follow their own themes when set +

+

+ + Text input + +

+

+ + Toggle Menu + + Menu item 1 + Menu item 2 + Menu item 3 + Menu item 4 + + +

+
+
+`); diff --git a/packages/web-components/src/theme/set-theme.ts b/packages/web-components/src/theme/set-theme.ts index 0f2b564e5054f2..9e18a00880a8c7 100644 --- a/packages/web-components/src/theme/set-theme.ts +++ b/packages/web-components/src/theme/set-theme.ts @@ -1,3 +1,5 @@ +import { uniqueId } from '@microsoft/fast-web-utilities'; + /** * Not using the `Theme` type from `@fluentui/tokens` package to allow custom * tokens to be added. @@ -5,11 +7,29 @@ */ export type Theme = Record; -const SUPPORTS_REGISTER_PROPERTY = 'registerProperty' in CSS; const SUPPORTS_ADOPTED_STYLE_SHEETS = 'adoptedStyleSheets' in document; -const themeStyleSheet = new CSSStyleSheet(); +const SUPPORTS_CSS_SCOPE = 'CSSScopeRule' in window; + +// A map from a theme to Custom Property declarations for the theme as a string. +// Each value should be a list of CSS Custom Property declarations, and should +// NOT include any selector, `{`, or `}`. const themeStyleTextMap = new Map(); +// A map from a theme to a unique string used to identity a theme. The string +// will be used as the value of the `data-fluent-theme` attribute on a +// differently themed element. +const scopedThemeKeyMap = new Map(); + +// A map from an element with shadow root to a `CSSStyleSheet` object that +// references its local theme style sheet. +const shadowAdoptedStyleSheetMap = new Map(); + +// A map from an element to its set theme. This is used only when +// `document.adoptedStyleSheets` or CSS Scope is not supported. +const elementThemeMap = new Map(); + +const globalThemeStyleSheet = new CSSStyleSheet(); + /** * Sets the theme tokens as CSS Custom Properties. The Custom Properties are * set in a constructed stylesheet on `document.adoptedStyleSheets` if @@ -23,58 +43,180 @@ const themeStyleTextMap = new Map(); * Note that this argument is not limited to existing theme objects (from * `@fluentui/tokens`), you can pass in an arbitrary theme object as long * as each entry’s value is either a string or a number. + * @param node - The node to set the theme on, defaults to `document` for + * setting global theme. * @internal */ -export const setTheme = (theme: Theme) => { +export function setTheme(theme: Theme | null, node: Document | HTMLElement = document) { + if (!node || !isThemeableNode(node)) { + return; + } + // Fallback to setting token custom properties on `` element’s `style` - // attribute, only checking the support of `document.adoptedStyleSheets` - // here because it has broader support than `CSS.registerProperty()`, which - // is checked later. - if (!SUPPORTS_ADOPTED_STYLE_SHEETS) { - setThemeFor(document.documentElement, theme); + // attribute. + if (!SUPPORTS_ADOPTED_STYLE_SHEETS || (node instanceof HTMLElement && !node.shadowRoot && !SUPPORTS_CSS_SCOPE)) { + const target: HTMLElement = node === document ? document.documentElement : (node as HTMLElement); + setThemePropertiesOnElement(theme, target); return; } + if ([document, document.documentElement, document.body].includes(node)) { + setGlobalTheme(theme); + } else { + setLocalTheme(theme, node as HTMLElement); + } +} + +function getThemeStyleText(theme: Theme): string { if (!themeStyleTextMap.has(theme)) { const tokenDeclarations: string[] = []; for (const [tokenName, tokenValue] of Object.entries(theme)) { - if (typeof tokenValue !== 'string' && Number.isNaN(tokenValue)) { - throw new Error(`"${tokenName}" must be a string or a number.`); - } - - const name = `--${tokenName}`; - const initialValue = tokenValue.toString(); - if (SUPPORTS_REGISTER_PROPERTY) { - try { - // @ts-expect-error - Baseline 2024 - CSS.registerProperty({ - name, - initialValue, - inherits: true, - }); - } catch {} - } - tokenDeclarations.push(`${name}:${initialValue};`); + tokenDeclarations.push(`--${tokenName}:${tokenValue.toString()};`); } - themeStyleTextMap.set(theme, `html{${tokenDeclarations.join('')}}`); + themeStyleTextMap.set(theme, tokenDeclarations.join('')); + } + + return themeStyleTextMap.get(theme)!; +} + +/** + * A themeable node should either be one of the following: + * - `document` + * - `html` + * - `body` + * - Any HTML element inside `body` + */ +function isThemeableNode(node: Document | HTMLElement) { + return [document, document.documentElement].includes(node) || (node instanceof HTMLElement && !!node.closest('body')); +} + +function setGlobalTheme(theme: Theme | null) { + if (theme === null) { + if (document.adoptedStyleSheets.includes(globalThemeStyleSheet)) { + globalThemeStyleSheet.replaceSync(''); + } + return; } // Update the CSSStyleSheet with the new theme - themeStyleSheet.replaceSync(themeStyleTextMap.get(theme)!); + globalThemeStyleSheet.replaceSync(` + html { + ${getThemeStyleText(theme)} + } + `); // Adopt the updated CSSStyleSheet if it hasn't been adopted yet - if (!document.adoptedStyleSheets.includes(themeStyleSheet)) { - document.adoptedStyleSheets.push(themeStyleSheet); + if (!document.adoptedStyleSheets.includes(globalThemeStyleSheet)) { + document.adoptedStyleSheets.push(globalThemeStyleSheet); + } +} + +function setLocalTheme(theme: Theme | null, element: HTMLElement) { + if (theme === null) { + if (element.shadowRoot && shadowAdoptedStyleSheetMap.has(element)) { + shadowAdoptedStyleSheetMap.get(element)!.replaceSync(''); + } else { + delete element.dataset.fluentTheme; + forceRepaint(element); + } + return; } -}; + + if (element.shadowRoot) { + getShadowAdoptedStyleSheet(element).replaceSync(` + :host { + ${getThemeStyleText(theme)} + } + `); + } else { + element.dataset.fluentTheme = getScopedThemeKey(theme); + forceRepaint(element); + } +} + +function getShadowAdoptedStyleSheet(element: HTMLElement): CSSStyleSheet { + if (!shadowAdoptedStyleSheetMap.has(element)) { + const shadowAdoptedStyleSheet = new CSSStyleSheet(); + shadowAdoptedStyleSheetMap.set(element, shadowAdoptedStyleSheet); + element.shadowRoot?.adoptedStyleSheets.push(shadowAdoptedStyleSheet); + } + + return shadowAdoptedStyleSheetMap.get(element)!; +} + +function getScopedThemeKey(theme: Theme): string { + if (!scopedThemeKeyMap.has(theme)) { + const themeKey = uniqueId('fluent-theme-'); + const scopedThemeStyleSheet = new CSSStyleSheet(); + + scopedThemeKeyMap.set(theme, themeKey); + scopedThemeStyleSheet.replaceSync(` + @scope ([data-fluent-theme="${themeKey}"]) { + :scope { + ${getThemeStyleText(theme)} + } + } + `); + document.adoptedStyleSheets.push(scopedThemeStyleSheet); + } + + return scopedThemeKeyMap.get(theme)!; +} + +function setThemePropertiesOnElement(theme: Theme | null, element: HTMLElement) { + let tokens: Theme; + + if (theme === null) { + if (!elementThemeMap.has(element)) { + return; + } + tokens = elementThemeMap.get(element)!; + } else { + elementThemeMap.set(element, theme); + tokens = theme; + } + + for (const [tokenName, tokenValue] of Object.entries(tokens)) { + if (theme === null) { + element.style.removeProperty(`--${tokenName}`); + } else { + element.style.setProperty(`--${tokenName}`, tokenValue.toString()); + } + } +} /** - * @internal + * This function fixes a Safari bug: when an element should no longer be + * selected by an `@scope` rule, the styles defined in the `:scope` selector + * persist. + * @see https://bugs.webkit.org/show_bug.cgi?id=276454 + * + * UA sniff regular expression is based on + * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#rendering_engine | the MDN documentation}. */ -export const setThemeFor = (element: HTMLElement, theme: Theme) => { - for (const [tokenName, tokenValue] of Object.entries(theme)) { - element.style.setProperty(`--${tokenName}`, tokenValue.toString()); +const { userAgent: UA } = navigator; +const isWebkit = /\bAppleWebKit\/[\d+\.]+\b/.test(UA); +function forceRepaint(element: HTMLElement) { + if (!isWebkit) { + return; } -}; + + const name = 'visibility'; + const tempValue = 'hidden'; + const currentValue = element.style.getPropertyValue(name); + + element.style.setProperty(name, tempValue); + requestAnimationFrame(() => { + element.style.setProperty(name, currentValue); + }); +} + +/** + * @internal + * @deprecated Use `setTheme(theme, element)` instead. + */ +export function setThemeFor(element: HTMLElement, theme: Theme | null) { + setThemePropertiesOnElement(theme, element); +}