From d60ccdbb65f779b612a30e6ed7a75ef07514a2dc Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 11 Nov 2026 11:05:05 +0900 Subject: [PATCH 1/6] fix(demo): separate Material You and dark mode toggles --- demo/e2e/dynamic-colors.spec.ts | 25 +++++++------ demo/src/app/index/index-page.component.html | 12 +++---- demo/src/app/index/index-page.component.ts | 17 ++++++--- demo/src/theme/demo-dynamic-colors.spec.ts | 37 ++++++++++++++++++++ demo/src/theme/dynamic-colors.ts | 24 +++++++++---- demo/vitest.dynamic-colors.config.ts | 2 +- 6 files changed, 87 insertions(+), 30 deletions(-) create mode 100644 demo/src/theme/demo-dynamic-colors.spec.ts diff --git a/demo/e2e/dynamic-colors.spec.ts b/demo/e2e/dynamic-colors.spec.ts index b09d36f0..6ab66b9d 100644 --- a/demo/e2e/dynamic-colors.spec.ts +++ b/demo/e2e/dynamic-colors.spec.ts @@ -1,21 +1,26 @@ import { expect, test } from '@playwright/test'; -test('system/light/dark appearance follows device only in system mode', async ({ page }) => { +test('Material You and dark mode toggle independently', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await page.goto('/'); const root = page.locator('html'); + const dark = page.getByRole('switch', { name: 'Dark Mode', exact: true }); + const material = page.getByRole('switch', { name: 'Material You', exact: true }); + await expect(dark).not.toBeChecked(); + await expect(material).toBeChecked(); + await dark.click(); await expect(root).toHaveClass(/ion-palette-dark/); - await page.getByRole('button', { name: 'Light', exact: true }).click(); + await material.click(); + await expect(material).not.toBeChecked(); + await expect(dark).toBeChecked(); + await expect(root).toHaveClass(/ion-palette-dark/); + await dark.click(); await expect(root).not.toHaveClass(/ion-palette-dark/); - await page.emulateMedia({ colorScheme: 'light' }); - await page.emulateMedia({ colorScheme: 'dark' }); + await expect(material).not.toBeChecked(); + await material.click(); + await expect(material).toBeChecked(); await expect(root).not.toHaveClass(/ion-palette-dark/); - await page.getByRole('button', { name: 'Dark', exact: true }).click(); - await expect(root).toHaveClass(/ion-palette-dark/); await page.emulateMedia({ colorScheme: 'light' }); - await expect(root).toHaveClass(/ion-palette-dark/); - await page.getByRole('button', { name: 'System', exact: true }).click(); - await expect(root).not.toHaveClass(/ion-palette-dark/); await page.emulateMedia({ colorScheme: 'dark' }); - await expect(root).toHaveClass(/ion-palette-dark/); + await expect(root).not.toHaveClass(/ion-palette-dark/); }); diff --git a/demo/src/app/index/index-page.component.html b/demo/src/app/index/index-page.component.html index ab6d4bb6..f13e1fb6 100644 --- a/demo/src/app/index/index-page.component.html +++ b/demo/src/app/index/index-page.component.html @@ -19,14 +19,10 @@ Settings - Color mode - System - Light - Dark + Material You + + + Dark Mode diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index d8c8d088..804a2d3a 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import type { ToggleCustomEvent } from '@ionic/core'; import { IonButton, IonButtons, @@ -14,8 +14,9 @@ import { IonListHeader, IonTitle, IonToolbar, + IonToggle, } from '@demo/ionic'; -import { demoColorMode, setDemoColorMode } from '../../theme/dynamic-colors'; +import { demoColorMode, demoMaterialYou, setDemoColorMode, setDemoMaterialYou } from '../../theme/dynamic-colors'; import { ActivatedRoute, Router } from '@angular/router'; interface IComponent { @@ -32,7 +33,7 @@ interface IComponent { IonHeader, IonTitle, IonToolbar, - FormsModule, + IonToggle, IonButton, IonButtons, IonIcon, @@ -85,7 +86,13 @@ export class IndexPageComponent { readonly colorMode = demoColorMode; - changeColorMode(mode: 'system' | 'light' | 'dark') { - setDemoColorMode(mode); + readonly materialYou = demoMaterialYou; + + changeColorMode(event: ToggleCustomEvent) { + setDemoColorMode(event.detail.checked ? 'dark' : 'light'); + } + + async changeMaterialYou(event: ToggleCustomEvent) { + await setDemoMaterialYou(event.detail.checked); } } diff --git a/demo/src/theme/demo-dynamic-colors.spec.ts b/demo/src/theme/demo-dynamic-colors.spec.ts new file mode 100644 index 00000000..6adcd5be --- /dev/null +++ b/demo/src/theme/demo-dynamic-colors.spec.ts @@ -0,0 +1,37 @@ +import { expect, it, vi } from 'vitest'; + +const palette = { + primary: '#6750a4', + secondary: '#625b71', + tertiary: '#7d5260', + error: '#b3261e', + surface: '#fffbfe', + onSurface: '#1c1b1f', +}; +vi.mock('@capacitor/core', () => ({ + Capacitor: { getPlatform: () => 'android' }, + registerPlugin: () => ({ + getDynamicColorScheme: async () => ({ available: true, systemDark: false, light: palette, dark: { ...palette, primary: '#d0bcff' } }), + addListener: async () => ({ remove: async () => {} }), + }), +})); + +it('restores standard colors without changing dark mode, then reapplies the selected palette', async () => { + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })); + const { startDynamicColors, setDemoColorMode, setDemoMaterialYou } = await import('./dynamic-colors'); + const root = document.documentElement; + root.style.setProperty('--ion-color-primary', '#123456'); + await startDynamicColors(); + expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#6750a4'); + setDemoColorMode('dark'); + expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#d0bcff'); + await setDemoMaterialYou(false); + expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#123456'); + expect(root.style.getPropertyValue('--md-sys-color-primary')).toBe(''); + expect(root.classList.contains('ion-palette-dark')).toBe(true); + await setDemoMaterialYou(true); + expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#d0bcff'); + setDemoColorMode('light'); + expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#6750a4'); + vi.unstubAllGlobals(); +}); diff --git a/demo/src/theme/dynamic-colors.ts b/demo/src/theme/dynamic-colors.ts index fcb4626d..bbafe19b 100644 --- a/demo/src/theme/dynamic-colors.ts +++ b/demo/src/theme/dynamic-colors.ts @@ -9,14 +9,21 @@ const native = registerPlugin<{ addListener(name: 'dynamicColorSchemeChange', listener: () => void): Promise<{ remove(): Promise }>; }>('NativeUI'); let theme: DynamicColorTheme | undefined; -export const demoColorMode = signal('system'); +export const demoColorMode = signal('light'); +export const demoMaterialYou = signal(true); export const startDynamicColors = async (): Promise => { theme = await enableDynamicColors({ - mode: 'system', - getScheme: () => - Capacitor.getPlatform() === 'android' - ? native.getDynamicColorScheme() - : Promise.resolve({ available: false, systemDark: matchMedia('(prefers-color-scheme: dark)').matches }), + mode: demoColorMode(), + getScheme: async () => { + if (!demoMaterialYou()) { + return { available: false, systemDark: matchMedia('(prefers-color-scheme: dark)').matches }; + } + const scheme = + Capacitor.getPlatform() === 'android' + ? await native.getDynamicColorScheme() + : { available: false as const, systemDark: matchMedia('(prefers-color-scheme: dark)').matches }; + return demoMaterialYou() ? scheme : { available: false, systemDark: scheme.systemDark }; + }, listen: (listener) => Capacitor.getPlatform() === 'android' ? native.addListener('dynamicColorSchemeChange', listener) @@ -27,3 +34,8 @@ export const setDemoColorMode = (mode: ColorMode): void => { demoColorMode.set(mode); theme?.setMode(mode); }; + +export const setDemoMaterialYou = async (enabled: boolean): Promise => { + demoMaterialYou.set(enabled); + await theme?.refresh(); +}; diff --git a/demo/vitest.dynamic-colors.config.ts b/demo/vitest.dynamic-colors.config.ts index 53c0ca30..d8ab847e 100644 --- a/demo/vitest.dynamic-colors.config.ts +++ b/demo/vitest.dynamic-colors.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ - test: { environment: 'jsdom', include: ['src/theme/dynamic-colors.spec.ts'] }, + test: { environment: 'jsdom', include: ['src/theme/dynamic-colors.spec.ts', 'src/theme/demo-dynamic-colors.spec.ts'] }, }); From 6b825770a35cf6654c15fde98fa133eafb8e031b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 11 Nov 2026 11:08:42 +0900 Subject: [PATCH 2/6] refactor(demo): bind theme toggles with Signal Forms and effect --- demo/src/app/index/index-page.component.html | 4 +-- demo/src/app/index/index-page.component.ts | 25 +++++++++--------- demo/src/theme/demo-dynamic-colors.spec.ts | 10 ++++---- demo/src/theme/dynamic-colors.ts | 27 ++++++++++---------- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/demo/src/app/index/index-page.component.html b/demo/src/app/index/index-page.component.html index f13e1fb6..74ecc2e9 100644 --- a/demo/src/app/index/index-page.component.html +++ b/demo/src/app/index/index-page.component.html @@ -19,10 +19,10 @@ Settings - Material You + Material You - Dark Mode + Dark Mode diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 804a2d3a..2da2e030 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -1,6 +1,6 @@ -import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, ErrorHandler, inject, signal, untracked } from '@angular/core'; -import type { ToggleCustomEvent } from '@ionic/core'; +import { form, FormField } from '@angular/forms/signals'; import { IonButton, IonButtons, @@ -16,7 +16,7 @@ import { IonToolbar, IonToggle, } from '@demo/ionic'; -import { demoColorMode, demoMaterialYou, setDemoColorMode, setDemoMaterialYou } from '../../theme/dynamic-colors'; +import { demoThemeSettings, applyDemoThemeSettings } from '../../theme/dynamic-colors'; import { ActivatedRoute, Router } from '@angular/router'; interface IComponent { @@ -34,6 +34,7 @@ interface IComponent { IonTitle, IonToolbar, IonToggle, + FormField, IonButton, IonButtons, IonIcon, @@ -84,15 +85,15 @@ export class IndexPageComponent { await this.#router.navigate([item.name], { relativeTo: this.#route }); } - readonly colorMode = demoColorMode; + readonly settingsForm = form(demoThemeSettings); + readonly #errorHandler = inject(ErrorHandler); - readonly materialYou = demoMaterialYou; - - changeColorMode(event: ToggleCustomEvent) { - setDemoColorMode(event.detail.checked ? 'dark' : 'light'); - } - - async changeMaterialYou(event: ToggleCustomEvent) { - await setDemoMaterialYou(event.detail.checked); + constructor() { + effect(() => { + const settings = demoThemeSettings(); + untracked(() => { + void applyDemoThemeSettings(settings).catch((error: unknown) => this.#errorHandler.handleError(error)); + }); + }); } } diff --git a/demo/src/theme/demo-dynamic-colors.spec.ts b/demo/src/theme/demo-dynamic-colors.spec.ts index 6adcd5be..79afa869 100644 --- a/demo/src/theme/demo-dynamic-colors.spec.ts +++ b/demo/src/theme/demo-dynamic-colors.spec.ts @@ -18,20 +18,20 @@ vi.mock('@capacitor/core', () => ({ it('restores standard colors without changing dark mode, then reapplies the selected palette', async () => { vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })); - const { startDynamicColors, setDemoColorMode, setDemoMaterialYou } = await import('./dynamic-colors'); + const { startDynamicColors, applyDemoThemeSettings } = await import('./dynamic-colors'); const root = document.documentElement; root.style.setProperty('--ion-color-primary', '#123456'); await startDynamicColors(); expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#6750a4'); - setDemoColorMode('dark'); + await applyDemoThemeSettings({ materialYou: true, darkMode: true }); expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#d0bcff'); - await setDemoMaterialYou(false); + await applyDemoThemeSettings({ materialYou: false, darkMode: true }); expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#123456'); expect(root.style.getPropertyValue('--md-sys-color-primary')).toBe(''); expect(root.classList.contains('ion-palette-dark')).toBe(true); - await setDemoMaterialYou(true); + await applyDemoThemeSettings({ materialYou: true, darkMode: true }); expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#d0bcff'); - setDemoColorMode('light'); + await applyDemoThemeSettings({ materialYou: true, darkMode: false }); expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#6750a4'); vi.unstubAllGlobals(); }); diff --git a/demo/src/theme/dynamic-colors.ts b/demo/src/theme/dynamic-colors.ts index bbafe19b..e6a99967 100644 --- a/demo/src/theme/dynamic-colors.ts +++ b/demo/src/theme/dynamic-colors.ts @@ -1,6 +1,6 @@ import { signal } from '@angular/core'; import { Capacitor, registerPlugin } from '@capacitor/core'; -import { enableDynamicColors, type ColorMode, type DynamicColorScheme, type DynamicColorTheme } from '../../../src/dynamic-colors'; +import { enableDynamicColors, type DynamicColorScheme, type DynamicColorTheme } from '../../../src/dynamic-colors'; // The demo adapter keeps browser builds independent of the installed plugin exports. // Android needs a native build with the getter (plugin 2.1.3+); apps should use the public root exports. @@ -9,20 +9,20 @@ const native = registerPlugin<{ addListener(name: 'dynamicColorSchemeChange', listener: () => void): Promise<{ remove(): Promise }>; }>('NativeUI'); let theme: DynamicColorTheme | undefined; -export const demoColorMode = signal('light'); -export const demoMaterialYou = signal(true); +export const demoThemeSettings = signal({ materialYou: true, darkMode: false }); +let materialYou = demoThemeSettings().materialYou; export const startDynamicColors = async (): Promise => { theme = await enableDynamicColors({ - mode: demoColorMode(), + mode: demoThemeSettings().darkMode ? 'dark' : 'light', getScheme: async () => { - if (!demoMaterialYou()) { + if (!materialYou) { return { available: false, systemDark: matchMedia('(prefers-color-scheme: dark)').matches }; } const scheme = Capacitor.getPlatform() === 'android' ? await native.getDynamicColorScheme() : { available: false as const, systemDark: matchMedia('(prefers-color-scheme: dark)').matches }; - return demoMaterialYou() ? scheme : { available: false, systemDark: scheme.systemDark }; + return materialYou ? scheme : { available: false, systemDark: scheme.systemDark }; }, listen: (listener) => Capacitor.getPlatform() === 'android' @@ -30,12 +30,11 @@ export const startDynamicColors = async (): Promise => { : Promise.resolve({ remove: async () => {} }), }); }; -export const setDemoColorMode = (mode: ColorMode): void => { - demoColorMode.set(mode); - theme?.setMode(mode); -}; - -export const setDemoMaterialYou = async (enabled: boolean): Promise => { - demoMaterialYou.set(enabled); - await theme?.refresh(); +/** Apply form settings to the imperative theme API. Only palette changes need a native refresh. */ +export const applyDemoThemeSettings = async (settings: { materialYou: boolean; darkMode: boolean }): Promise => { + theme?.setMode(settings.darkMode ? 'dark' : 'light'); + if (materialYou !== settings.materialYou) { + materialYou = settings.materialYou; + await theme?.refresh(); + } }; From 3bc90ba1485fb9a1dca0a9a5a31e5f07e0d32f6a Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 11 Nov 2026 11:12:29 +0900 Subject: [PATCH 3/6] fix(demo): disable unavailable Material You controls --- demo/e2e/dynamic-colors.spec.ts | 11 +++--- demo/src/app/index/index-page.component.html | 6 ++- demo/src/app/index/index-page.component.ts | 9 +++-- demo/src/theme/demo-dynamic-colors.spec.ts | 41 +++++++++++++++++--- demo/src/theme/dynamic-colors.ts | 26 +++++++++---- 5 files changed, 70 insertions(+), 23 deletions(-) diff --git a/demo/e2e/dynamic-colors.spec.ts b/demo/e2e/dynamic-colors.spec.ts index 6ab66b9d..ab21d483 100644 --- a/demo/e2e/dynamic-colors.spec.ts +++ b/demo/e2e/dynamic-colors.spec.ts @@ -1,24 +1,23 @@ import { expect, test } from '@playwright/test'; -test('Material You and dark mode toggle independently', async ({ page }) => { +test('unsupported Material You is disabled while dark mode remains usable', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await page.goto('/'); const root = page.locator('html'); const dark = page.getByRole('switch', { name: 'Dark Mode', exact: true }); - const material = page.getByRole('switch', { name: 'Material You', exact: true }); + const material = page.getByRole('switch', { name: /^Material You/ }); await expect(dark).not.toBeChecked(); - await expect(material).toBeChecked(); + await expect(material).not.toBeChecked(); + await expect(material).toBeDisabled(); await dark.click(); await expect(root).toHaveClass(/ion-palette-dark/); - await material.click(); await expect(material).not.toBeChecked(); await expect(dark).toBeChecked(); await expect(root).toHaveClass(/ion-palette-dark/); await dark.click(); await expect(root).not.toHaveClass(/ion-palette-dark/); await expect(material).not.toBeChecked(); - await material.click(); - await expect(material).toBeChecked(); + await expect(material).toBeDisabled(); await expect(root).not.toHaveClass(/ion-palette-dark/); await page.emulateMedia({ colorScheme: 'light' }); await page.emulateMedia({ colorScheme: 'dark' }); diff --git a/demo/src/app/index/index-page.component.html b/demo/src/app/index/index-page.component.html index 74ecc2e9..28e2aeac 100644 --- a/demo/src/app/index/index-page.component.html +++ b/demo/src/app/index/index-page.component.html @@ -19,7 +19,11 @@ Settings - Material You + Material You Dark Mode diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 2da2e030..0c030f68 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, computed, effect, ErrorHandler, inject, signal, untracked } from '@angular/core'; -import { form, FormField } from '@angular/forms/signals'; +import { disabled, form, FormField } from '@angular/forms/signals'; import { IonButton, IonButtons, @@ -16,7 +16,7 @@ import { IonToolbar, IonToggle, } from '@demo/ionic'; -import { demoThemeSettings, applyDemoThemeSettings } from '../../theme/dynamic-colors'; +import { demoThemeSettings, demoMaterialYouAvailable, applyDemoThemeSettings } from '../../theme/dynamic-colors'; import { ActivatedRoute, Router } from '@angular/router'; interface IComponent { @@ -85,7 +85,10 @@ export class IndexPageComponent { await this.#router.navigate([item.name], { relativeTo: this.#route }); } - readonly settingsForm = form(demoThemeSettings); + readonly materialYouAvailable = demoMaterialYouAvailable; + readonly settingsForm = form(demoThemeSettings, (fields) => { + disabled(fields.materialYou, () => !demoMaterialYouAvailable()); + }); readonly #errorHandler = inject(ErrorHandler); constructor() { diff --git a/demo/src/theme/demo-dynamic-colors.spec.ts b/demo/src/theme/demo-dynamic-colors.spec.ts index 79afa869..7e3254c3 100644 --- a/demo/src/theme/demo-dynamic-colors.spec.ts +++ b/demo/src/theme/demo-dynamic-colors.spec.ts @@ -1,4 +1,4 @@ -import { expect, it, vi } from 'vitest'; +import { beforeEach, expect, it, vi } from 'vitest'; const palette = { primary: '#6750a4', @@ -8,20 +8,30 @@ const palette = { surface: '#fffbfe', onSurface: '#1c1b1f', }; +const native = vi.hoisted(() => ({ getPlatform: vi.fn(), getScheme: vi.fn(), addListener: vi.fn() })); vi.mock('@capacitor/core', () => ({ - Capacitor: { getPlatform: () => 'android' }, - registerPlugin: () => ({ - getDynamicColorScheme: async () => ({ available: true, systemDark: false, light: palette, dark: { ...palette, primary: '#d0bcff' } }), - addListener: async () => ({ remove: async () => {} }), - }), + Capacitor: { getPlatform: native.getPlatform }, + registerPlugin: () => ({ getDynamicColorScheme: native.getScheme, addListener: native.addListener }), })); +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + native.getPlatform.mockReturnValue('android'); + native.getScheme.mockResolvedValue({ available: true, systemDark: false, light: palette, dark: { ...palette, primary: '#d0bcff' } }); + native.addListener.mockResolvedValue({ remove: async () => {} }); + document.documentElement.removeAttribute('style'); + document.documentElement.className = ''; + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })); +}); + it('restores standard colors without changing dark mode, then reapplies the selected palette', async () => { vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })); const { startDynamicColors, applyDemoThemeSettings } = await import('./dynamic-colors'); const root = document.documentElement; root.style.setProperty('--ion-color-primary', '#123456'); await startDynamicColors(); + expect((await import('./dynamic-colors')).demoMaterialYouAvailable()).toBe(true); expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#6750a4'); await applyDemoThemeSettings({ materialYou: true, darkMode: true }); expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#d0bcff'); @@ -35,3 +45,22 @@ it('restores standard colors without changing dark mode, then reapplies the sele expect(root.style.getPropertyValue('--ion-color-primary')).toBe('#6750a4'); vi.unstubAllGlobals(); }); + +it.each(['web', 'ios', 'unsupported-android', 'missing-native-api'])( + 'disables Material You and keeps dark mode working on %s', + async (environment) => { + if (environment === 'web' || environment === 'ios') native.getPlatform.mockReturnValue(environment); + if (environment === 'unsupported-android') native.getScheme.mockResolvedValue({ available: false, systemDark: false }); + if (environment === 'missing-native-api') { + native.getScheme.mockRejectedValue(new Error('UNIMPLEMENTED')); + native.addListener.mockRejectedValue(new Error('UNIMPLEMENTED')); + } + const { startDynamicColors, applyDemoThemeSettings, demoMaterialYouAvailable, demoThemeSettings } = await import('./dynamic-colors'); + await startDynamicColors(); + expect(demoMaterialYouAvailable()).toBe(false); + expect(demoThemeSettings().materialYou).toBe(false); + await applyDemoThemeSettings({ materialYou: false, darkMode: true }); + expect(document.documentElement.classList.contains('ion-palette-dark')).toBe(true); + if (environment === 'web' || environment === 'ios') expect(native.getScheme).not.toHaveBeenCalled(); + }, +); diff --git a/demo/src/theme/dynamic-colors.ts b/demo/src/theme/dynamic-colors.ts index e6a99967..3d5e4dc9 100644 --- a/demo/src/theme/dynamic-colors.ts +++ b/demo/src/theme/dynamic-colors.ts @@ -10,23 +10,35 @@ const native = registerPlugin<{ }>('NativeUI'); let theme: DynamicColorTheme | undefined; export const demoThemeSettings = signal({ materialYou: true, darkMode: false }); +export const demoMaterialYouAvailable = signal(false); let materialYou = demoThemeSettings().materialYou; export const startDynamicColors = async (): Promise => { theme = await enableDynamicColors({ mode: demoThemeSettings().darkMode ? 'dark' : 'light', getScheme: async () => { - if (!materialYou) { - return { available: false, systemDark: matchMedia('(prefers-color-scheme: dark)').matches }; + const fallback: DynamicColorScheme = { + available: false, + systemDark: matchMedia('(prefers-color-scheme: dark)').matches, + }; + let scheme: DynamicColorScheme = fallback; + if (Capacitor.getPlatform() === 'android') { + // Older native builds may not implement this method even when the plugin exists. + try { + scheme = await native.getDynamicColorScheme(); + } catch { + scheme = fallback; + } + } + demoMaterialYouAvailable.set(scheme.available); + if (!scheme.available) { + demoThemeSettings.update((settings) => (settings.materialYou ? { ...settings, materialYou: false } : settings)); + materialYou = false; } - const scheme = - Capacitor.getPlatform() === 'android' - ? await native.getDynamicColorScheme() - : { available: false as const, systemDark: matchMedia('(prefers-color-scheme: dark)').matches }; return materialYou ? scheme : { available: false, systemDark: scheme.systemDark }; }, listen: (listener) => Capacitor.getPlatform() === 'android' - ? native.addListener('dynamicColorSchemeChange', listener) + ? native.addListener('dynamicColorSchemeChange', listener).catch(() => ({ remove: async () => {} })) : Promise.resolve({ remove: async () => {} }), }); }; From 022e8be0ef9046ad5657dc3902714ca87c98d9c0 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 11 Nov 2026 11:16:29 +0900 Subject: [PATCH 4/6] test(demo): select explicit appearance and verify supported toggles --- demo/e2e/alert-focus.spec.ts | 8 ++++++ demo/e2e/android-settings-regressions.spec.ts | 24 +++++++++++++++++ demo/e2e/android17-geometry.spec.ts | 10 +++---- demo/e2e/device-colors.ts | 19 +++++++++++++ demo/e2e/dynamic-colors.spec.ts | 27 +++++++++++++++++++ demo/e2e/ionic-color-inheritance.spec.ts | 6 +++++ demo/e2e/ionic-contracts.spec.ts | 12 +++++++++ demo/e2e/material-foregrounds.spec.ts | 4 +-- demo/e2e/md3-color-roles.spec.ts | 24 +++++++++++++++++ demo/e2e/neutral-palette.spec.ts | 8 +++--- demo/e2e/overlay-action-hierarchy.spec.ts | 5 ++-- demo/e2e/overlay-visibility.spec.ts | 18 +++++++++++++ demo/e2e/screenshot.spec.ts | 11 ++++---- demo/e2e/select-popover-colors.spec.ts | 12 +++++++-- 14 files changed, 168 insertions(+), 20 deletions(-) diff --git a/demo/e2e/alert-focus.spec.ts b/demo/e2e/alert-focus.spec.ts index 83c71b86..a0f81986 100644 --- a/demo/e2e/alert-focus.spec.ts +++ b/demo/e2e/alert-focus.spec.ts @@ -5,6 +5,14 @@ for (const appearance of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: appearance }); await page.setViewportSize({ width: 411, height: 891 }); await page.goto('/main/index/alert?type=preferred'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, appearance); + await expect(page.locator('html')).toHaveCSS('color-scheme', appearance); + await expect + .poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))) + .toBe(appearance === 'dark'); const alert = page.locator('ion-alert'); const wrapper = alert.locator('.alert-wrapper'); await expect(wrapper).toBeFocused(); diff --git a/demo/e2e/android-settings-regressions.spec.ts b/demo/e2e/android-settings-regressions.spec.ts index c094b570..14a549ae 100644 --- a/demo/e2e/android-settings-regressions.spec.ts +++ b/demo/e2e/android-settings-regressions.spec.ts @@ -29,6 +29,12 @@ for (const mode of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: mode }); await page.setViewportSize({ width: 411, height: 900 }); await page.goto('/main/index/item-list'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const label = page.locator('app-item-list ion-list.list-inset').nth(2).locator('ion-item').last().locator('ion-label'); const lines = await label.evaluate((element) => { const range = document.createRange(); @@ -54,6 +60,12 @@ for (const mode of ['light', 'dark'] as const) { .toBe('radial-gradient(20px, rgba(0, 0, 0, 0) 99%, rgba(0, 0, 0, 0) 100%)'); await page.goto('/main/index/accordion'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const accordions = page.locator('app-accordion ion-accordion'); await expect(accordions.first()).toHaveClass(/hydrated/); expect(await accordions.first().evaluate((element) => getComputedStyle(element).marginBottom)).toBe('2px'); @@ -67,6 +79,12 @@ for (const mode of ['light', 'dark'] as const) { test(`${mode}: toggles are icon-free unless Ionic on/off labels are explicitly enabled`, async ({ page }) => { await page.emulateMedia({ colorScheme: mode }); await page.goto('/main/index/toggle'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); for (const control of await page.locator('app-toggle ion-toggle:not([enable-on-off-labels])').all()) { await expect(control).toHaveClass(/hydrated/); const handle = control.locator('[part~="handle"]'); @@ -98,6 +116,12 @@ for (const mode of ['light', 'dark'] as const) { test(`${mode}: range cutout follows its Ionic container`, async ({ page }) => { await page.emulateMedia({ colorScheme: mode }); await page.goto('/main/index/range'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const range = page.locator('app-range ion-range').nth(3); await expect(range).toHaveClass(/hydrated/); await range.evaluate((element) => { diff --git a/demo/e2e/android17-geometry.spec.ts b/demo/e2e/android17-geometry.spec.ts index ec1ab495..8ae425db 100644 --- a/demo/e2e/android17-geometry.spec.ts +++ b/demo/e2e/android17-geometry.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { mockDeviceColors, expectDeviceColors } from './device-colors'; +import { mockDeviceColors, gotoDevicePage, expectDeviceColors } from './device-colors'; import { arrowBackSharp } from 'ionicons/icons'; test('CSS font default respects Ionic application overrides and iOS mode', async ({ page }) => { @@ -44,7 +44,7 @@ for (const mode of ['light', 'dark'] as const) { test(`${mode}: measured Android control geometry and Ionic overrides`, async ({ page }, testInfo) => { await mockDeviceColors(page, mode); await page.setViewportSize({ width: 412, height: 915 }); - await page.goto('/main/index/toggle'); + await gotoDevicePage(page, '/main/index/toggle', mode); await expectDeviceColors(page, mode); const toggle = page.locator('app-toggle ion-toggle').nth(2); const track = toggle.locator('[part~="track"]'); @@ -60,7 +60,7 @@ for (const mode of ['light', 'dark'] as const) { await toggle.evaluate((element) => element.classList.add('md3-disabled')); await expect(track).not.toHaveCSS('height', '28px'); - await page.goto('/main/index/item-list'); + await gotoDevicePage(page, '/main/index/item-list', mode); const title = page.locator('app-item-list > ion-header ion-title'); await expect(title).toHaveCSS('font-size', '22px'); await expect(title).toHaveCSS('padding-inline-start', '12px'); @@ -105,12 +105,12 @@ for (const mode of ['light', 'dark'] as const) { const itemBox = await twoLineItem.boundingBox(); expect(itemBox!.height).toBeCloseTo(twoLineBox!.height + noteBox!.height + 32, 1); - await page.goto('/main/index/radio'); + await gotoDevicePage(page, '/main/index/radio', mode); const radio = page.locator('app-radio ion-radio').first(); await expect(radio.locator('[part~="container"]')).toHaveCSS('width', '20px'); await expect(radio.locator('..')).toHaveCSS('height', '72px'); - await page.goto('/main/index/range'); + await gotoDevicePage(page, '/main/index/range', mode); const range = page.locator('app-range ion-range').first(); await expect(range.locator('[part~="bar"]')).toHaveCSS('height', '16px'); await expect(range.locator('[part~="knob"]').first()).toHaveCSS('width', '4px'); diff --git a/demo/e2e/device-colors.ts b/demo/e2e/device-colors.ts index 934af898..a6e17339 100644 --- a/demo/e2e/device-colors.ts +++ b/demo/e2e/device-colors.ts @@ -59,6 +59,25 @@ export const mockDeviceColors = async (page: Page, mode: 'light' | 'dark') => { ); }; +/** Select appearance through the settings UI before opening a device-color test route. */ +export const gotoDevicePage = async (page: Page, path: string, mode: 'light' | 'dark') => { + await page.goto('/main/index', { waitUntil: 'networkidle' }); + const dark = page.getByRole('switch', { name: 'Dark Mode', exact: true }); + if (mode === 'dark') { + await dark.click(); + await expect(dark).toBeChecked(); + } else await expect(dark).not.toBeChecked(); + await expectDeviceColors(page, mode); + // Use same-document browser navigation so the selected settings survive route changes. + await page.evaluate((path) => { + history.pushState(null, '', path); + window.dispatchEvent(new PopStateEvent('popstate')); + }, path); + await expect(page.locator('index-page')).toBeHidden(); + await expect(page.locator('ion-content[role="main"]:visible').first()).toBeVisible(); + await page.waitForLoadState('networkidle'); +}; + export const expectDeviceColors = async (page: Page, mode: 'light' | 'dark') => { const root = page.locator('html'); await expect diff --git a/demo/e2e/dynamic-colors.spec.ts b/demo/e2e/dynamic-colors.spec.ts index ab21d483..0bc07b8d 100644 --- a/demo/e2e/dynamic-colors.spec.ts +++ b/demo/e2e/dynamic-colors.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { mockDeviceColors, expectDeviceColors } from './device-colors'; test('unsupported Material You is disabled while dark mode remains usable', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); @@ -23,3 +24,29 @@ test('unsupported Material You is disabled while dark mode remains usable', asyn await page.emulateMedia({ colorScheme: 'dark' }); await expect(root).not.toHaveClass(/ion-palette-dark/); }); + +test('supported device toggles independently restore and reapply the selected palette', async ({ page }) => { + await mockDeviceColors(page, 'dark'); + await page.goto('/main/index'); + const dark = page.getByRole('switch', { name: 'Dark Mode', exact: true }); + const material = page.getByRole('switch', { name: 'Material You', exact: true }); + const root = page.locator('html'); + await expect(material).toBeEnabled(); + await expect(material).toBeChecked(); + await expectDeviceColors(page, 'light'); + await dark.click(); + await expect(dark).toBeChecked(); + await expectDeviceColors(page, 'dark'); + await material.click(); + await expect(material).not.toBeChecked(); + await expect.poll(() => root.evaluate((el) => (el as HTMLElement).style.getPropertyValue('--md-sys-color-primary'))).toBe(''); + await expect(root).toHaveClass(/ion-palette-dark/); + await expect(dark).toBeChecked(); + await material.click(); + await expect(material).toBeChecked(); + await expectDeviceColors(page, 'dark'); + await dark.click(); + await expect(dark).not.toBeChecked(); + await expectDeviceColors(page, 'light'); + await expect(material).toBeChecked(); +}); diff --git a/demo/e2e/ionic-color-inheritance.spec.ts b/demo/e2e/ionic-color-inheritance.spec.ts index 2925cbd5..046ba2f6 100644 --- a/demo/e2e/ionic-color-inheritance.spec.ts +++ b/demo/e2e/ionic-color-inheritance.spec.ts @@ -6,6 +6,12 @@ for (const mode of ['light', 'dark'] as const) { await page.setViewportSize({ width, height: width === 411 ? 891 : 800 }); await page.emulateMedia({ colorScheme: mode }); await page.goto('/main/index/color-palette'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); await expect(page.getByTestId('palette-card')).toHaveClass(/hydrated/); const result = await page.evaluate(async (mode) => { const panelRole = mode === 'light' ? '--md-sys-color-surface-container-lowest' : '--md-sys-color-surface-container-high'; diff --git a/demo/e2e/ionic-contracts.spec.ts b/demo/e2e/ionic-contracts.spec.ts index ad409836..56658d29 100644 --- a/demo/e2e/ionic-contracts.spec.ts +++ b/demo/e2e/ionic-contracts.spec.ts @@ -4,6 +4,12 @@ for (const mode of ['light', 'dark'] as const) { test(`${mode}: MD parents retain a child's explicit iOS mode`, async ({ page }) => { await page.emulateMedia({ colorScheme: mode }); await page.goto('/main/index/color-palette'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const card = page.getByTestId('palette-mixed-mode'); await expect(card).toHaveClass(/hydrated/); await card.evaluate((element) => { @@ -24,6 +30,12 @@ for (const mode of ['light', 'dark'] as const) { test(`${mode}: tab icon opt-out retains normal Ionic inheritance`, async ({ page }) => { await page.emulateMedia({ colorScheme: mode }); await page.goto('/main/index/tabs'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const tab = page.locator('app-tabs ion-tab-button').first(); await expect(tab).toHaveClass(/hydrated/); await tab.evaluate((element) => { diff --git a/demo/e2e/material-foregrounds.spec.ts b/demo/e2e/material-foregrounds.spec.ts index 6e5a8639..2a60ba32 100644 --- a/demo/e2e/material-foregrounds.spec.ts +++ b/demo/e2e/material-foregrounds.spec.ts @@ -1,12 +1,12 @@ import { expect, test } from '@playwright/test'; -import { mockDeviceColors, expectDeviceColors, expectDeviceColorPairs } from './device-colors'; +import { mockDeviceColors, gotoDevicePage, expectDeviceColors, expectDeviceColorPairs } from './device-colors'; import scheme from './fixtures/android17-device-colors.json'; for (const mode of ['light', 'dark'] as const) { test(`${mode}: explicit Ionic colors render native foreground pairs`, async ({ page }, testInfo) => { await mockDeviceColors(page, mode); await page.setViewportSize({ width: 800, height: 1000 }); - await page.goto('/main/index/color-palette'); + await gotoDevicePage(page, '/main/index/color-palette', mode); await expectDeviceColors(page, mode); await expectDeviceColorPairs(page, mode); await page.evaluate(() => { diff --git a/demo/e2e/md3-color-roles.spec.ts b/demo/e2e/md3-color-roles.spec.ts index 69d7f380..78aa3d1a 100644 --- a/demo/e2e/md3-color-roles.spec.ts +++ b/demo/e2e/md3-color-roles.spec.ts @@ -22,6 +22,12 @@ for (const mode of ['light', 'dark'] as const) { test(`${mode}: Ionic-only palette, optional pairs, explicit colors and opt-out`, async ({ page }) => { await page.emulateMedia({ colorScheme: mode }); await page.goto('/main/index/color-palette'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); await expect(page.getByTestId('palette-tonal')).toBeVisible(); await expect(page.getByTestId('palette-tonal')).toHaveClass(/hydrated/); const ids = [ @@ -97,6 +103,12 @@ for (const mode of ['light', 'dark'] as const) { test(`${mode}: Android Settings surfaces, groups, back button and icon-free switches`, async ({ page }) => { await page.emulateMedia({ colorScheme: mode }); await page.goto('/main/index/color-palette'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const title = page.locator('app-color-palette ion-card-title').first(); await expect(title).toHaveClass(/hydrated/); await title.evaluate((element) => element.setAttribute('color', 'danger')); @@ -117,6 +129,12 @@ for (const mode of ['light', 'dark'] as const) { expect(backStyle.background).toContain('20px'); await page.goto('/main/index/item-list'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const list = page.locator('app-item-list ion-list.list-inset').nth(1); await expect(list).toBeVisible(); expect(await list.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); @@ -136,6 +154,12 @@ for (const mode of ['light', 'dark'] as const) { expect(await item.evaluate((element) => getComputedStyle(element).marginBottom)).toBe('0px'); await page.goto('/main/index/toggle'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const toggle = page.locator('app-toggle ion-toggle').first(); await expect(toggle).toHaveClass(/hydrated/); const glyph = () => diff --git a/demo/e2e/neutral-palette.spec.ts b/demo/e2e/neutral-palette.spec.ts index ba09d206..06f783fe 100644 --- a/demo/e2e/neutral-palette.spec.ts +++ b/demo/e2e/neutral-palette.spec.ts @@ -1,11 +1,11 @@ import { expect, test } from '@playwright/test'; -import { mockDeviceColors, expectDeviceColors } from './device-colors'; +import { mockDeviceColors, gotoDevicePage, expectDeviceColors } from './device-colors'; import scheme from './fixtures/android17-device-colors.json'; for (const mode of ['light', 'dark'] as const) { test(`${mode}: white base and named neutral colors preserve their order`, async ({ page }) => { await mockDeviceColors(page, mode); - await page.goto('/main/index/card'); + await gotoDevicePage(page, '/main/index/card', mode); await expectDeviceColors(page, mode); const roles = scheme[mode]; const rgb = (hex: string) => `rgb(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(', ')})`; @@ -46,7 +46,7 @@ for (const mode of ['light', 'dark'] as const) { ]) { test(`${mode}: ${route} preserves the default overlay surface`, async ({ page }) => { await mockDeviceColors(page, mode); - await page.goto('/main/index/' + (route.startsWith('modal') ? 'modal' : route)); + await gotoDevicePage(page, '/main/index/' + (route.startsWith('modal') ? 'modal' : route), mode); if (route.startsWith('modal')) await page .locator('app-modal ion-item') @@ -69,7 +69,7 @@ for (const mode of ['light', 'dark'] as const) { test('dark device search fields remain distinct from toolbar panels', async ({ page }) => { await mockDeviceColors(page, 'dark'); - await page.goto('/main/index/searchbar'); + await gotoDevicePage(page, '/main/index/searchbar', 'dark'); await expectDeviceColors(page, 'dark'); const searchbars = page.locator('app-searchbar ion-toolbar:not(.ion-color) ion-searchbar'); await expect(searchbars.first()).toBeVisible(); diff --git a/demo/e2e/overlay-action-hierarchy.spec.ts b/demo/e2e/overlay-action-hierarchy.spec.ts index b9c4689f..6734deed 100644 --- a/demo/e2e/overlay-action-hierarchy.spec.ts +++ b/demo/e2e/overlay-action-hierarchy.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; import type { ActionSheetButton } from '@ionic/core'; -import { mockDeviceColors, expectDeviceColors } from './device-colors'; +import { mockDeviceColors, gotoDevicePage, expectDeviceColors } from './device-colors'; for (const width of [390, 1280]) { for (const mode of ['light', 'dark'] as const) { @@ -9,7 +9,8 @@ for (const width of [390, 1280]) { await page.setViewportSize({ width, height: 844 }); if (native) await mockDeviceColors(page, mode); else await page.addInitScript(() => ((window as unknown as { IONIC_E2E_TESTING: boolean }).IONIC_E2E_TESTING = true)); - await page.goto('/main/index/action-sheet?type=preferred', { waitUntil: 'networkidle' }); + if (native) await gotoDevicePage(page, '/main/index/action-sheet?type=preferred', mode); + else await page.goto('/main/index/action-sheet?type=preferred', { waitUntil: 'networkidle' }); if (native) await expectDeviceColors(page, mode); else await page.locator('html').evaluate((element, dark) => element.classList.toggle('ion-palette-dark', dark), mode === 'dark'); const sheet = page.locator('ion-action-sheet'); diff --git a/demo/e2e/overlay-visibility.spec.ts b/demo/e2e/overlay-visibility.spec.ts index c0309c9e..7ae0cc0c 100644 --- a/demo/e2e/overlay-visibility.spec.ts +++ b/demo/e2e/overlay-visibility.spec.ts @@ -4,6 +4,12 @@ for (const mode of ['light', 'dark'] as const) { test(`${mode}: clicking a popover trigger presents its visible content`, async ({ page }) => { await page.emulateMedia({ colorScheme: mode }); await page.goto('/main/index/popover'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const trigger = page.locator('#click-trigger-item'); await expect(trigger).toHaveClass(/hydrated/); await expect(page.locator('ion-popover:not(.overlay-hidden)')).toHaveCount(0); @@ -22,6 +28,12 @@ for (const mode of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: mode }); // Omit ?type so ionViewDidEnter does not race the user's click with an automatic open. await page.goto('/main/index/select'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const select = page.locator('app-select ion-select.select-popover'); await expect(select).toHaveClass(/hydrated/); await expect(select).toHaveJSProperty('interface', 'popover'); @@ -45,6 +57,12 @@ for (const mode of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: mode }); // Omit ?type so only this click creates the modal, including its nested ModalPage. await page.goto('/main/index/modal'); + await page.locator('html').evaluate((root, appearance) => { + root.classList.toggle('ion-palette-dark', appearance === 'dark'); + root.style.colorScheme = appearance; + }, mode); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + await expect.poll(() => page.locator('html').evaluate((root) => root.classList.contains('ion-palette-dark'))).toBe(mode === 'dark'); const trigger = page.locator('app-modal ion-item').filter({ hasText: /^present:sheet$/ }); await expect(trigger).toHaveClass(/hydrated/); await expect(page.locator('ion-modal')).toHaveCount(0); diff --git a/demo/e2e/screenshot.spec.ts b/demo/e2e/screenshot.spec.ts index 1f5f22b2..f308bafc 100644 --- a/demo/e2e/screenshot.spec.ts +++ b/demo/e2e/screenshot.spec.ts @@ -8,6 +8,7 @@ import { expectDeviceToggleColors, expectDeviceAlertFocusColors, mockDeviceColors, + gotoDevicePage, sendCompatibilityDeviceColors, } from './device-colors'; @@ -57,7 +58,7 @@ const routes = [ ].sort(() => Math.random() - 0.5); const prepareScreenShot = async (page: Page, routeName: string) => { - await expect(page.locator('ion-content[role="main"]')).toHaveClass(/hydrated/); + await expect(page.locator('ion-content[role="main"]:visible')).toHaveClass(/hydrated/); await page.evaluate(async () => { await document.fonts.ready; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); @@ -74,7 +75,7 @@ const prepareScreenShot = async (page: Page, routeName: string) => { await expect(focused).toHaveClass(/ion-focused/); } if (!routeName.includes(':')) { - const scrollHeight = await page.locator('ion-content[role="main"]').evaluate(async (el: any) => { + const scrollHeight = await page.locator('ion-content[role="main"]:visible').evaluate(async (el: any) => { const scrollEl = await el.getScrollElement(); return scrollEl.scrollHeight; }); @@ -136,7 +137,7 @@ for (const mode of ['light', 'dark'] as const) { test(`should match device ${mode} screenshot for ${route.name}`, async ({ page }) => { await mockDeviceColors(page, mode); const manualOverlay = route.name === 'popover' || route.name.startsWith('select:') || route.name.startsWith('modal:'); - await page.goto(manualOverlay ? route.path.split('?')[0] : route.path, { waitUntil: 'networkidle' }); + await gotoDevicePage(page, manualOverlay ? route.path.split('?')[0] : route.path, mode); await expectDeviceColors(page, mode); if (route.name === 'color-palette') await expectDeviceColorPairs(page, mode); if (route.name === 'toggle') await expectDeviceToggleColors(page, mode); @@ -194,7 +195,7 @@ for (const mode of ['light', 'dark'] as const) { for (const mode of ['light', 'dark'] as const) { test(`should match device ${mode} screenshot after a six-role native update`, async ({ page }) => { await mockDeviceColors(page, mode); - await page.goto('/main/index/color-palette', { waitUntil: 'networkidle' }); + await gotoDevicePage(page, '/main/index/color-palette', mode); await expectDeviceColors(page, mode); await expectDeviceColorPairs(page, mode); await sendCompatibilityDeviceColors(page, mode); @@ -206,7 +207,7 @@ for (const mode of ['light', 'dark'] as const) { for (const mode of ['light', 'dark'] as const) { test(`should match device ${mode} screenshot for an active range pin`, async ({ page }) => { await mockDeviceColors(page, mode); - await page.goto('/main/index/range', { waitUntil: 'networkidle' }); + await gotoDevicePage(page, '/main/index/range', mode); await expectDeviceColors(page, mode); const range = page.locator('app-range .section-example ion-range').first(); await expect(range).toHaveClass(/hydrated/); diff --git a/demo/e2e/select-popover-colors.spec.ts b/demo/e2e/select-popover-colors.spec.ts index 96b2e44c..7f47dc61 100644 --- a/demo/e2e/select-popover-colors.spec.ts +++ b/demo/e2e/select-popover-colors.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { mockDeviceColors, expectDeviceColors } from './device-colors'; +import { mockDeviceColors, gotoDevicePage, expectDeviceColors } from './device-colors'; import scheme from './fixtures/android17-device-colors.json'; for (const mode of ['light', 'dark'] as const) { @@ -7,7 +7,15 @@ for (const mode of ['light', 'dark'] as const) { test(`${mode} ${device ? 'device' : 'Ionic'}: select menu owns its surface and respects overrides`, async ({ page }) => { if (device) await mockDeviceColors(page, mode); else await page.emulateMedia({ colorScheme: mode }); - await page.goto('/main/index/select'); + if (device) await gotoDevicePage(page, '/main/index/select', mode); + else { + await page.goto('/main/index/select'); + await page.locator('html').evaluate((element, dark) => { + element.classList.toggle('ion-palette-dark', dark); + (element as HTMLElement).style.colorScheme = dark ? 'dark' : 'light'; + }, mode === 'dark'); + await expect(page.locator('html')).toHaveCSS('color-scheme', mode); + } if (device) await expectDeviceColors(page, mode); const normalItem = page.locator('app-select ion-item').first(); const normalPaint = await normalItem.locator('[part~=native]').evaluate((el) => getComputedStyle(el).backgroundColor); From 7d12b954bdb839e35b6e18121822e010d675b880 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 11 Nov 2026 11:18:30 +0900 Subject: [PATCH 5/6] test(demo): wait for theme color transitions before comparing --- demo/e2e/md3-color-roles.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/demo/e2e/md3-color-roles.spec.ts b/demo/e2e/md3-color-roles.spec.ts index 78aa3d1a..5394881f 100644 --- a/demo/e2e/md3-color-roles.spec.ts +++ b/demo/e2e/md3-color-roles.spec.ts @@ -113,7 +113,13 @@ for (const mode of ['light', 'dark'] as const) { await expect(title).toHaveClass(/hydrated/); await title.evaluate((element) => element.setAttribute('color', 'danger')); await expect(title).toHaveClass(/ion-color-danger/); - expect((await colors(title)).foreground).toEqual((await colors(page.getByTestId('palette-danger'))).background); + await expect + .poll( + async () => + JSON.stringify((await colors(title)).foreground) === + JSON.stringify((await colors(page.getByTestId('palette-danger'))).background), + ) + .toBe(true); await title.evaluate((element) => { element.removeAttribute('color'); (element as HTMLElement).style.setProperty('--color', '#123456'); From 14fc05bd81997f765202e895ec7963c762f31fc2 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 11 Nov 2026 11:27:10 +0900 Subject: [PATCH 6/6] test(demo): wait for select item theme surface before opening popover --- demo/e2e/select-popover-colors.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/demo/e2e/select-popover-colors.spec.ts b/demo/e2e/select-popover-colors.spec.ts index 7f47dc61..8fbba360 100644 --- a/demo/e2e/select-popover-colors.spec.ts +++ b/demo/e2e/select-popover-colors.spec.ts @@ -18,7 +18,17 @@ for (const mode of ['light', 'dark'] as const) { } if (device) await expectDeviceColors(page, mode); const normalItem = page.locator('app-select ion-item').first(); - const normalPaint = await normalItem.locator('[part~=native]').evaluate((el) => getComputedStyle(el).backgroundColor); + await expect(normalItem).toHaveClass(/hydrated/); + // Resolve the target surface independently of the native part's color transition. + const normalPaint = await normalItem.evaluate((el) => { + const probe = document.createElement('span'); + probe.style.backgroundColor = 'var(--background)'; + el.append(probe); + const background = getComputedStyle(probe).backgroundColor; + probe.remove(); + return background; + }); + await expect(normalItem.locator('[part~=native]')).toHaveCSS('background-color', normalPaint); await page.locator('app-select .select-popover').click(); const popover = page.locator('ion-popover:not(.overlay-hidden)'); await expect(popover).toBeVisible();