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: `