From 08c849681a05d349f9f176d67e37cb22ebe85717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20H=C3=BCske?= <33117553+fabianhueske@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:59:39 +0200 Subject: [PATCH] feat: add color theme switching and expose admin theme to iframe apps Adds an mt-theme-select component and useColorScheme composable for switching the application color theme (light/dark/system), and exposes the resolved Administration theme to iframe apps via the Admin SDK (getTheme, subscribeTheme, syncTheme). Closes #1174 Closes #1129 --- .changeset/admin-sdk-theme.md | 5 + .changeset/theme-select-color-scheme.md | 5 + docs/admin-sdk/api-reference/context.md | 129 ++++++++++++- packages/admin-sdk/src/context/index.ts | 38 ++++ packages/admin-sdk/src/context/theme.spec.ts | 105 ++++++++++ packages/admin-sdk/src/message-types.ts | 2 + .../theme/mt-theme-select/mt-theme-select.mdx | 88 +++++++++ .../mt-theme-select/mt-theme-select.spec.ts | 54 ++++++ .../mt-theme-select.stories.ts | 153 +++++++++++++++ .../theme/mt-theme-select/mt-theme-select.vue | 66 +++++++ .../src/composables/useColorScheme.spec.ts | 180 ++++++++++++++++++ .../src/composables/useColorScheme.ts | 139 ++++++++++++++ packages/component-library/src/index.ts | 16 ++ 13 files changed, 979 insertions(+), 1 deletion(-) create mode 100644 .changeset/admin-sdk-theme.md create mode 100644 .changeset/theme-select-color-scheme.md create mode 100644 packages/admin-sdk/src/context/theme.spec.ts create mode 100644 packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.mdx create mode 100644 packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.spec.ts create mode 100644 packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.stories.ts create mode 100644 packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.vue create mode 100644 packages/component-library/src/composables/useColorScheme.spec.ts create mode 100644 packages/component-library/src/composables/useColorScheme.ts diff --git a/.changeset/admin-sdk-theme.md b/.changeset/admin-sdk-theme.md new file mode 100644 index 000000000..9a48de83e --- /dev/null +++ b/.changeset/admin-sdk-theme.md @@ -0,0 +1,5 @@ +--- +"@shopware-ag/meteor-admin-sdk": minor +--- + +Exposed the Administration color theme to iframe apps via `context.getTheme()` and `context.subscribeTheme()`, plus a `context.syncTheme()` helper that mirrors the resolved theme onto an element's `data-theme` attribute. diff --git a/.changeset/theme-select-color-scheme.md b/.changeset/theme-select-color-scheme.md new file mode 100644 index 000000000..77597b67e --- /dev/null +++ b/.changeset/theme-select-color-scheme.md @@ -0,0 +1,5 @@ +--- +"@shopware-ag/meteor-component-library": minor +--- + +Added an `mt-theme-select` component for choosing the application color theme (light, dark, or system) and a `useColorScheme` composable that resolves the system preference, applies the resolved theme to `data-theme`, and persists the choice. diff --git a/docs/admin-sdk/api-reference/context.md b/docs/admin-sdk/api-reference/context.md index f0f673e1d..9f231074c 100644 --- a/docs/admin-sdk/api-reference/context.md +++ b/docs/admin-sdk/api-reference/context.md @@ -7,7 +7,7 @@ sidebar_position: 40 The Context API provides read access to the current state of the Shopware Administration. Extensions can use these methods to retrieve information about the active language, locale, currency, environment, Shopware version, and more. -This is useful for adapting extension behavior based on the current Administration context — for example, loading translations for the active language or checking the Shopware version before using a newer API. +This is useful for adapting extension behavior based on the current Administration context — for example, loading translations for the active language, matching the active color theme, or checking the Shopware version before using a newer API. ```ts import { context } from "@shopware-ag/meteor-admin-sdk"; @@ -175,6 +175,133 @@ context.subscribeLocale(({ locale, fallbackLocale }) => { } ``` +## getTheme() + +Returns the current resolved color theme of the Administration. A user's `system` preference is always resolved to the value that is actually applied, so this is either `"light"` or `"dark"`. Use this to render your iframe app in the same theme as the Administration. + +#### Usage + +```ts +const theme = await context.getTheme(); +``` + +#### Parameters + +No parameters needed. + +#### Return value + +```ts +Promise<"light" | "dark"> +``` + +#### Example value + +```ts +"dark"; +``` + +## subscribeTheme() + +Subscribes to theme changes in the Administration. The callback fires whenever the resolved theme changes, allowing your app to react immediately. Returns a function that stops the subscription. + +#### Usage + +```ts +const unsubscribe = context.subscribeTheme((theme) => { + // theme is "light" or "dark" +}); + +// Stop listening when you no longer need updates +unsubscribe(); +``` + +#### Parameters + +| Name | Description | +| :--------------- | :----------------------------------------- | +| `callbackMethod` | Called every time the resolved theme changes | + +#### Callback value + +```ts +"light" | "dark" +``` + +## syncTheme() + +A small helper that mirrors the Administration theme onto your app. It writes the resolved theme to the `data-theme` attribute of an element (the document root by default), applies the current value immediately, and keeps it in sync with future changes. Because Meteor tokens are theme-aware through `data-theme`, this is usually all an iframe app needs to match the Administration's appearance. + +#### Usage + +```ts +// Sync to and keep it updated +const stopSync = await context.syncTheme(); + +// Stop syncing later (e.g. when the app is torn down) +stopSync(); + +// Sync to a specific element instead of the document root +await context.syncTheme({ target: document.getElementById("app") }); +``` + +#### Parameters + +| Name | Description | +| :-------------- | :----------------------------------------------------------------------------------- | +| `options.target` | Element whose `data-theme` attribute is updated. Defaults to `document.documentElement` | + +#### Return value + +```ts +Promise<() => void> +``` + +The returned function stops the subscription. + +### Minimal iframe app example + +A complete iframe app that adopts the Administration theme and reacts to changes: + +```html + + + + + + + +

My themed app

+ + + + +``` + +If you need the theme value yourself (for example to swap an illustration), combine `getTheme` and `subscribeTheme`: + +```ts +import { context } from "@shopware-ag/meteor-admin-sdk"; + +let theme = await context.getTheme(); +render(theme); + +context.subscribeTheme((next) => { + theme = next; + render(theme); +}); +``` + ## getCurrency() Returns the system currency configured for the Shopware instance. Use this when displaying prices or working with monetary values. diff --git a/packages/admin-sdk/src/context/index.ts b/packages/admin-sdk/src/context/index.ts index 5ee1460d3..a1f391eb7 100644 --- a/packages/admin-sdk/src/context/index.ts +++ b/packages/admin-sdk/src/context/index.ts @@ -17,6 +17,35 @@ export const getAppInformation = createSender('contextAppInformation', {}); export const can = createACLHelper(getAppInformation); export const getModuleInformation = createSender('contextModuleInformation', {}); export const getShopId = createSender('contextShopId', {}); +export const getTheme = createSender('contextTheme', {}); +export const subscribeTheme = createSubscriber('contextTheme'); + +/** + * Syncs the resolved Administration theme to the `data-theme` attribute of an + * element (the document root by default). The current theme is applied + * immediately and the attribute is updated whenever the Administration theme + * changes. + * + * Returns a function that stops the subscription. + */ +export async function syncTheme( + options: { target?: HTMLElement } = {}, +): Promise<() => void> { + const target = options.target ?? document.documentElement; + + const apply = (theme: contextTheme['responseType']): void => { + target.dataset.theme = theme; + }; + + /* + * Subscribe before fetching the current value so a theme change published + * while getTheme() is in flight cannot slip through unobserved. + */ + const unsubscribe = subscribeTheme(apply); + apply(await getTheme()); + + return unsubscribe; +} /** * Get the current content language @@ -123,3 +152,12 @@ export type contextModuleInformation = { export type contextShopId = { responseType: string|null, } + +/** + * Get the current resolved Administration color theme. A "system" preference is + * always resolved to the actually applied value, so this is either "light" or + * "dark". + */ +export type contextTheme = { + responseType: 'light' | 'dark', +} diff --git a/packages/admin-sdk/src/context/theme.spec.ts b/packages/admin-sdk/src/context/theme.spec.ts new file mode 100644 index 000000000..9e5660abb --- /dev/null +++ b/packages/admin-sdk/src/context/theme.spec.ts @@ -0,0 +1,105 @@ +import flushPromises from 'flush-promises'; +import type { handle as handleType, publish as publishType } from '../channel'; +import type { getTheme as getThemeType, subscribeTheme as subscribeThemeType, syncTheme as syncThemeType } from './index'; + +let handle: typeof handleType; +let publish: typeof publishType; +let getTheme: typeof getThemeType; +let subscribeTheme: typeof subscribeThemeType; +let syncTheme: typeof syncThemeType; + +// Collect every listener/subscription so leaked handlers from a failing +// assertion can never bleed into the next test (all handlers share one window). +const cleanups: Array<() => void> = []; +const track = (remove: () => void): (() => void) => { + cleanups.push(remove); + return remove; +}; + +describe('context theme', () => { + beforeAll(async () => { + window.addEventListener('message', (event: MessageEvent) => { + if (event.origin === '') { + event.stopImmediatePropagation(); + const eventWithOrigin: MessageEvent = new MessageEvent('message', { + data: event.data, + origin: window.location.href, + }); + window.dispatchEvent(eventWithOrigin); + } + }); + + const channel = await import('../channel'); + handle = channel.handle; + publish = channel.publish; + + const context = await import('./index'); + getTheme = context.getTheme; + subscribeTheme = context.subscribeTheme; + syncTheme = context.syncTheme; + }); + + afterEach(() => { + cleanups.splice(0).forEach((remove) => remove()); + delete document.documentElement.dataset.theme; + }); + + it('resolves the current theme via getTheme', async () => { + track(handle('contextTheme', () => 'dark' as const)); + + const theme = await getTheme(); + expect(theme).toBe('dark'); + }); + + it('notifies subscribers when the theme changes', async () => { + const seen: Array<'light' | 'dark'> = []; + track(subscribeTheme((theme) => { + seen.push(theme); + })); + + publish('contextTheme', 'dark'); + await flushPromises(); + expect(seen.at(-1)).toBe('dark'); + + publish('contextTheme', 'light'); + await flushPromises(); + expect(seen.at(-1)).toBe('light'); + expect(seen).toHaveLength(2); + }); + + it('stops notifying after unsubscribing', async () => { + const seen: Array<'light' | 'dark'> = []; + const unsubscribe = subscribeTheme((theme) => { + seen.push(theme); + }); + + publish('contextTheme', 'dark'); + await flushPromises(); + expect(seen).toHaveLength(1); + + unsubscribe(); + + publish('contextTheme', 'light'); + await flushPromises(); + expect(seen).toHaveLength(1); + }); + + it('syncs the resolved theme to the document root and tracks changes', async () => { + track(handle('contextTheme', () => 'dark' as const)); + + track(await syncTheme()); + expect(document.documentElement.dataset.theme).toBe('dark'); + + publish('contextTheme', 'light'); + await flushPromises(); + expect(document.documentElement.dataset.theme).toBe('light'); + }); + + it('syncs the theme to a custom target element', async () => { + track(handle('contextTheme', () => 'dark' as const)); + const target = document.createElement('div'); + + track(await syncTheme({ target })); + expect(target.dataset.theme).toBe('dark'); + }); +}); diff --git a/packages/admin-sdk/src/message-types.ts b/packages/admin-sdk/src/message-types.ts index 46770073f..a82de6c28 100644 --- a/packages/admin-sdk/src/message-types.ts +++ b/packages/admin-sdk/src/message-types.ts @@ -12,6 +12,7 @@ import type { contextUserInformation, contextUserTimezone, contextShopId, + contextTheme, } from './context'; import type { uiComponentSectionRenderer } from './ui/component-section/index'; import type { uiTabsAddTabItem } from './ui/tabs'; @@ -71,6 +72,7 @@ export interface ShopwareMessageTypes { contextAppInformation: contextAppInformation, contextModuleInformation: contextModuleInformation, contextShopId: contextShopId, + contextTheme: contextTheme, getPageTitle: getPageTitle, uiComponentSectionRenderer: uiComponentSectionRenderer, uiTabsAddTabItem: uiTabsAddTabItem, diff --git a/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.mdx b/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.mdx new file mode 100644 index 000000000..0a7942a50 --- /dev/null +++ b/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.mdx @@ -0,0 +1,88 @@ +import { ArgTypes, Canvas, Meta } from "@storybook/blocks"; + +import * as ThemeSelectStories from "./mt-theme-select.stories"; +import StorybookPageHeader from "../../../docs/_components/storybook-page-header.js"; + + + + + **Theme Select** lets users choose the application color theme: light, dark, + or following the system setting. + + +## When to use + +- Use **Theme Select** to let users pick their preferred color theme globally. +- Use it in settings screens, profile menus, or toolbars where a theme preference belongs. +- Use the `system` option so users can defer to their operating system preference. + +## Examples + +### Basic + + + +### With label + + + +### Disabled + + + +## Managing state + +**Theme Select** is a controlled component: it emits the selected scheme through `v-model` and does not apply or persist the theme itself. Pair it with the `useColorScheme` composable, which resolves the `system` option against `prefers-color-scheme`, writes the resolved value to `document.documentElement` as `data-theme`, and persists the choice to `localStorage`. + + + +### Live preview + +Switch the selection to see the resolved theme applied to a scoped preview. The +`system` option follows your operating system preference. + + + +```ts +import { useColorScheme } from "@shopware-ag/meteor-component-library"; + +const { scheme, resolvedScheme, setScheme } = useColorScheme({ + // storageKey: "mt-color-scheme", // set to null to disable persistence + // target: document.documentElement, // element that receives data-theme + // defaultScheme: "system", + // applyToTarget: true, +}); +``` + +- `scheme` is the user's preference (`"light" | "dark" | "system"`); bind it to `v-model`. +- `resolvedScheme` is the value actually applied (`"light" | "dark"`) after resolving `system`. +- `setScheme` sets the preference programmatically. + +## API reference + + + +## Do + +- Use clear, recognizable labels or tooltips for each option. +- Default to `system` so the application respects the user's operating system preference. +- Persist the choice so it survives reloads. + +## Don't + +- Do not use **Theme Select** for unrelated multi-option settings; it is purpose-built for color themes. + +## Behavior notes + +- The component is a thin wrapper around `mt-select` with the three color scheme options. +- It is purely presentational. Applying and persisting the theme is the host's responsibility, which `useColorScheme` handles out of the box. + +## Accessibility notes + +- Selection and keyboard interaction follow the underlying `mt-select` component. +- Each option is labelled with text, so the choice never relies on color alone. diff --git a/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.spec.ts b/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.spec.ts new file mode 100644 index 000000000..a270d869a --- /dev/null +++ b/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.spec.ts @@ -0,0 +1,54 @@ +import { mount } from "@vue/test-utils"; +import MtThemeSelect from "./mt-theme-select.vue"; +import MtSelect from "../../form/mt-select/mt-select.vue"; + +vi.mock("@/utils/debounce", () => ({ + debounce: (fn: (...args: unknown[]) => void) => fn, +})); + +function createWrapper(props: Record = {}) { + return mount(MtThemeSelect, { + props: { modelValue: "system", ...props }, + }); +} + +describe("mt-theme-select", () => { + it("renders the selected scheme's label", () => { + const wrapper = createWrapper({ modelValue: "dark" }); + + const input = wrapper.find(".mt-select-selection-list__input").element as HTMLInputElement; + expect(input.value).toBe("Dark"); + }); + + it("passes the three theme options to mt-select", () => { + const wrapper = createWrapper(); + + const options = wrapper.getComponent(MtSelect).props("options") as { + value: string; + label: string; + }[]; + + expect(options.map((option) => option.value)).toEqual(["light", "dark", "system"]); + expect(options.map((option) => option.label)).toEqual(["Light", "Dark", "System"]); + }); + + it("re-emits the scheme chosen in the select", async () => { + const wrapper = createWrapper({ modelValue: "system" }); + + await wrapper.getComponent(MtSelect).vm.$emit("update:modelValue", "light"); + + expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["light"]); + }); + + it("forwards the disabled state to the select", () => { + const wrapper = createWrapper({ disabled: true }); + + expect(wrapper.getComponent(MtSelect).props("disabled")).toBe(true); + }); + + it("uses the provided field label", () => { + const wrapper = createWrapper({ label: "Appearance" }); + + expect(wrapper.getComponent(MtSelect).props("label")).toBe("Appearance"); + }); +}); diff --git a/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.stories.ts b/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.stories.ts new file mode 100644 index 000000000..63b565f9f --- /dev/null +++ b/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.stories.ts @@ -0,0 +1,153 @@ +import MtThemeSelect from "./mt-theme-select.vue"; +import type { Meta, StoryObj } from "@storybook/vue3"; +import { fn } from "@storybook/test"; +import { ref } from "vue"; +import { useColorScheme } from "../../../composables/useColorScheme"; + +const meta: Meta = { + title: "Components/Theme Select", + component: MtThemeSelect, + args: { + modelValue: "system", + disabled: false, + "onUpdate:modelValue": fn(), + }, + argTypes: { + modelValue: { + control: { type: "select" }, + options: ["light", "dark", "system"], + }, + }, + render: (args) => ({ + components: { MtThemeSelect }, + setup() { + const value = ref(args.modelValue); + return { args, value }; + }, + template: ``, + }), +}; + +export default meta; + +type MtThemeSelectStory = StoryObj; + +export const Default: MtThemeSelectStory = { + parameters: { + docs: { + source: { + language: "html", + code: ``, + }, + }, + }, +}; + +export const WithLabel: MtThemeSelectStory = { + args: { + label: "Appearance", + }, + parameters: { + docs: { + source: { + language: "html", + code: ``, + }, + }, + }, +}; + +export const Disabled: MtThemeSelectStory = { + args: { + disabled: true, + }, + parameters: { + docs: { + source: { + language: "html", + code: ``, + }, + }, + }, +}; + +export const WithPreview: MtThemeSelectStory = { + render: () => ({ + components: { MtThemeSelect }, + setup() { + // Resolve the preference (incl. "system") without touching the document, + // then apply it to the scoped preview below via data-theme. + const { scheme, resolvedScheme } = useColorScheme({ + storageKey: null, + applyToTarget: false, + }); + return { scheme, resolvedScheme }; + }, + template: `
+ + +
+

Preview

+

+ Applied theme: {{ resolvedScheme }} + +

+
+
`, + }), + parameters: { + docs: { + source: { + language: "html", + code: ` + +`, + }, + }, + }, +}; + +export const WithComposable: MtThemeSelectStory = { + parameters: { + docs: { + source: { + language: "html", + code: ` + +`, + }, + }, + }, +}; diff --git a/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.vue b/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.vue new file mode 100644 index 000000000..3db140ca8 --- /dev/null +++ b/packages/component-library/src/components/theme/mt-theme-select/mt-theme-select.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/component-library/src/composables/useColorScheme.spec.ts b/packages/component-library/src/composables/useColorScheme.spec.ts new file mode 100644 index 000000000..b459e8a16 --- /dev/null +++ b/packages/component-library/src/composables/useColorScheme.spec.ts @@ -0,0 +1,180 @@ +import { effectScope, nextTick } from "vue"; +import { useColorScheme } from "./useColorScheme"; + +type MediaListener = (event: { matches: boolean }) => void; + +function mockMatchMedia(initialDark: boolean) { + const listeners = new Set(); + const mql = { + matches: initialDark, + media: "(prefers-color-scheme: dark)", + addEventListener: (_type: string, cb: MediaListener) => listeners.add(cb), + removeEventListener: (_type: string, cb: MediaListener) => listeners.delete(cb), + }; + + window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia; + + return { + listeners, + emit(matches: boolean) { + mql.matches = matches; + listeners.forEach((cb) => cb({ matches })); + }, + }; +} + +/** Runs the composable inside an effect scope so cleanup can be triggered. */ +function withScope(fn: () => T): { result: T; dispose: () => void } { + const scope = effectScope(); + const result = scope.run(fn) as T; + return { result, dispose: () => scope.stop() }; +} + +function createLocalStorageMock(): Storage { + const store = new Map(); + return { + getItem: (key) => (store.has(key) ? store.get(key)! : null), + setItem: (key, value) => void store.set(key, String(value)), + removeItem: (key) => void store.delete(key), + clear: () => store.clear(), + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; +} + +describe("useColorScheme", () => { + // jsdom does not implement localStorage, so provide an in-memory stand-in. + beforeEach(() => { + vi.stubGlobal("localStorage", createLocalStorageMock()); + delete document.documentElement.dataset.theme; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("defaults to system and resolves against the OS (light)", () => { + mockMatchMedia(false); + + const { result } = withScope(() => useColorScheme()); + + expect(result.scheme.value).toBe("system"); + expect(result.resolvedScheme.value).toBe("light"); + expect(document.documentElement.dataset.theme).toBe("light"); + }); + + it("resolves system to dark when the OS prefers dark", () => { + mockMatchMedia(true); + + const { result } = withScope(() => useColorScheme()); + + expect(result.resolvedScheme.value).toBe("dark"); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("honors an explicit preference over the OS setting", async () => { + mockMatchMedia(true); + + const { result } = withScope(() => useColorScheme()); + result.setScheme("light"); + await nextTick(); + + expect(result.resolvedScheme.value).toBe("light"); + expect(document.documentElement.dataset.theme).toBe("light"); + }); + + it("persists the preference to localStorage", async () => { + mockMatchMedia(false); + + const { result } = withScope(() => useColorScheme()); + result.setScheme("dark"); + await nextTick(); + + expect(localStorage.getItem("mt-color-scheme")).toBe("dark"); + }); + + it("restores a persisted preference on init", () => { + mockMatchMedia(false); + localStorage.setItem("mt-color-scheme", "dark"); + + const { result } = withScope(() => useColorScheme()); + + expect(result.scheme.value).toBe("dark"); + expect(result.resolvedScheme.value).toBe("dark"); + }); + + it("reacts to OS changes while following the system", async () => { + const media = mockMatchMedia(false); + + const { result } = withScope(() => useColorScheme()); + expect(result.resolvedScheme.value).toBe("light"); + + media.emit(true); + await nextTick(); + + expect(result.resolvedScheme.value).toBe("dark"); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("ignores OS changes once an explicit scheme is chosen", async () => { + const media = mockMatchMedia(false); + + const { result } = withScope(() => useColorScheme()); + result.setScheme("light"); + await nextTick(); + + media.emit(true); + await nextTick(); + + expect(result.resolvedScheme.value).toBe("light"); + }); + + it("does not touch the target when applyToTarget is false", () => { + mockMatchMedia(true); + + withScope(() => useColorScheme({ applyToTarget: false })); + + expect(document.documentElement.dataset.theme).toBeUndefined(); + }); + + it("applies the scheme to a custom target element", () => { + mockMatchMedia(true); + const target = document.createElement("div"); + + withScope(() => useColorScheme({ target })); + + expect(target.dataset.theme).toBe("dark"); + }); + + it("stops listening to OS changes after the scope is disposed", () => { + const media = mockMatchMedia(false); + + const { dispose } = withScope(() => useColorScheme()); + expect(media.listeners.size).toBe(1); + + dispose(); + expect(media.listeners.size).toBe(0); + }); + + it("stops listening to OS changes when stop() is called manually", () => { + const media = mockMatchMedia(false); + + const { result } = withScope(() => useColorScheme()); + expect(media.listeners.size).toBe(1); + + result.stop(); + expect(media.listeners.size).toBe(0); + }); + + it("disables persistence when storageKey is null", async () => { + mockMatchMedia(false); + + const { result } = withScope(() => useColorScheme({ storageKey: null })); + result.setScheme("dark"); + await nextTick(); + + expect(localStorage.getItem("mt-color-scheme")).toBeNull(); + }); +}); diff --git a/packages/component-library/src/composables/useColorScheme.ts b/packages/component-library/src/composables/useColorScheme.ts new file mode 100644 index 000000000..969e07a84 --- /dev/null +++ b/packages/component-library/src/composables/useColorScheme.ts @@ -0,0 +1,139 @@ +import { computed, onScopeDispose, ref, watch, type ComputedRef, type Ref } from "vue"; + +/** + * The color scheme a user can choose. `"system"` follows the operating system + * preference via `prefers-color-scheme`. + */ +export type ColorScheme = "light" | "dark" | "system"; + +/** + * The color scheme that is actually applied once `"system"` has been resolved. + */ +export type ResolvedColorScheme = "light" | "dark"; + +export interface UseColorSchemeOptions { + /** + * `localStorage` key used to persist the preference across reloads. Set to + * `null` to disable persistence. + */ + storageKey?: string | null; + /** + * Element whose `data-theme` attribute is kept in sync with the resolved + * scheme. Defaults to the document root element. + */ + target?: HTMLElement; + /** + * Preference used when nothing has been persisted yet. + */ + defaultScheme?: ColorScheme; + /** + * Whether to write the resolved scheme to the target's `data-theme` + * attribute. Disable this when the host applies the theme itself. + */ + applyToTarget?: boolean; +} + +export interface UseColorSchemeReturn { + /** The user's preference: `"light"`, `"dark"`, or `"system"`. */ + scheme: Ref; + /** The applied scheme after resolving `"system"`: `"light"` or `"dark"`. */ + resolvedScheme: ComputedRef; + /** Sets the preference. Convenient for binding to `mt-theme-select`. */ + setScheme: (scheme: ColorScheme) => void; + /** + * Stops listening to OS color-scheme changes. Called automatically on scope + * disposal when used inside a component or `effectScope`; call it manually + * when using the composable outside of an active scope. + */ + stop: () => void; +} + +const DEFAULT_STORAGE_KEY = "mt-color-scheme"; +const SYSTEM_QUERY = "(prefers-color-scheme: dark)"; + +function isColorScheme(value: unknown): value is ColorScheme { + return value === "light" || value === "dark" || value === "system"; +} + +function readStoredScheme(storageKey: string | null): ColorScheme | undefined { + if (storageKey === null || typeof window === "undefined") return undefined; + + try { + const stored = window.localStorage?.getItem(storageKey); + return isColorScheme(stored) ? stored : undefined; + } catch { + return undefined; + } +} + +/** + * Manages the application color scheme: tracks the user's preference, resolves + * `"system"` against the OS setting, applies the result to a target element's + * `data-theme` attribute, and persists the choice. + * + * The returned `scheme` ref is meant to be bound to the `mt-theme-select` + * component, while `resolvedScheme` reflects the value actually rendered. + */ +export function useColorScheme(options: UseColorSchemeOptions = {}): UseColorSchemeReturn { + const { + storageKey = DEFAULT_STORAGE_KEY, + target, + defaultScheme = "system", + applyToTarget = true, + } = options; + + const scheme = ref(readStoredScheme(storageKey) ?? defaultScheme); + + let media: MediaQueryList | undefined; + if (typeof window !== "undefined" && typeof window.matchMedia === "function") { + media = window.matchMedia(SYSTEM_QUERY); + } + + const systemScheme = ref(media?.matches ? "dark" : "light"); + + const resolvedScheme = computed(() => + scheme.value === "system" ? systemScheme.value : scheme.value, + ); + + const onSystemChange = (event: MediaQueryListEvent): void => { + systemScheme.value = event.matches ? "dark" : "light"; + }; + + media?.addEventListener("change", onSystemChange); + + const resolveTarget = (): HTMLElement | undefined => { + if (target) return target; + return typeof document !== "undefined" ? document.documentElement : undefined; + }; + + watch( + resolvedScheme, + (value) => { + if (!applyToTarget) return; + const element = resolveTarget(); + if (element) element.dataset.theme = value; + }, + { immediate: true }, + ); + + watch(scheme, (value) => { + if (storageKey === null || typeof window === "undefined") return; + try { + window.localStorage?.setItem(storageKey, value); + } catch { + // Ignore storage errors (quota, private mode, disabled storage). + } + }); + + const stop = (): void => { + media?.removeEventListener("change", onSystemChange); + }; + + onScopeDispose(stop); + + const setScheme = (value: ColorScheme): void => { + scheme.value = value; + }; + + return { scheme, resolvedScheme, setScheme, stop }; +} diff --git a/packages/component-library/src/index.ts b/packages/component-library/src/index.ts index ab3d9aba8..26bb90595 100644 --- a/packages/component-library/src/index.ts +++ b/packages/component-library/src/index.ts @@ -49,6 +49,14 @@ import MtModalAction from "./components/overlay/mt-modal/sub-components/mt-modal import MtText from "./components/content/mt-text/mt-text.vue"; import MtInset from "./components/layout/mt-inset/mt-inset.vue"; import MtThemeProvider from "./components/theme/mt-theme-provider.vue"; +import MtThemeSelect from "./components/theme/mt-theme-select/mt-theme-select.vue"; +import { + useColorScheme, + type ColorScheme, + type ResolvedColorScheme, + type UseColorSchemeOptions, + type UseColorSchemeReturn, +} from "./composables/useColorScheme"; import TooltipDirective from "./directives/tooltip.directive"; import DeviceHelperPlugin from "./plugin/device-helper.plugin"; import MtTooltip from "./components/overlay/mt-tooltip/mt-tooltip.vue"; @@ -132,6 +140,8 @@ export { MtSearch, MtUrlField, MtThemeProvider, + MtThemeSelect, + useColorScheme, MtUnitField, MtEntityDataTable, MtEntitySelect, @@ -193,5 +203,11 @@ export { // Exporting types export type { Filter, Option, Toast, Snackbar, ChartOptions }; +export type { + ColorScheme, + ResolvedColorScheme, + UseColorSchemeOptions, + UseColorSchemeReturn, +}; export type { Editor } from "@tiptap/vue-3"; export type { default as Link } from "@tiptap/extension-link";