From cd0745ab43cd572c6d86035dc227a3cda0df0cbe Mon Sep 17 00:00:00 2001 From: Libby Maier Date: Wed, 22 Jul 2026 18:36:21 -0400 Subject: [PATCH 1/5] feat(labs): add theme preference controls and service for user theme management --- libs/labs/package.json | 1 + libs/labs/theme-preference/README.md | 41 +++ libs/labs/theme-preference/ng-package.json | 5 + libs/labs/theme-preference/src/index.ts | 7 + .../src/lib/theme-preference-selector.html | 10 + .../src/lib/theme-preference-selector.scss | 3 + .../src/lib/theme-preference-selector.spec.ts | 52 ++++ .../lib/theme-preference-selector.stories.ts | 44 ++++ .../src/lib/theme-preference-selector.ts | 39 +++ .../src/lib/theme-preference.service.spec.ts | 133 ++++++++++ .../src/lib/theme-preference.service.ts | 234 ++++++++++++++++++ .../src/lib/theme-preference.ts | 15 ++ tsconfig.base.json | 3 +- 13 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 libs/labs/theme-preference/README.md create mode 100644 libs/labs/theme-preference/ng-package.json create mode 100644 libs/labs/theme-preference/src/index.ts create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-selector.html create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-selector.scss create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-selector.ts create mode 100644 libs/labs/theme-preference/src/lib/theme-preference.service.spec.ts create mode 100644 libs/labs/theme-preference/src/lib/theme-preference.service.ts create mode 100644 libs/labs/theme-preference/src/lib/theme-preference.ts diff --git a/libs/labs/package.json b/libs/labs/package.json index fefb42b..7d87f65 100644 --- a/libs/labs/package.json +++ b/libs/labs/package.json @@ -27,6 +27,7 @@ "@angular/youtube-player": "^21.2.6", "@atlasng/analytics": "0.0.5", "@atlasng/common": "0.0.5", + "@atlasng/core": "0.0.5", "@atlasng/design-system": "0.0.5" }, "devDependencies": { diff --git a/libs/labs/theme-preference/README.md b/libs/labs/theme-preference/README.md new file mode 100644 index 0000000..3e83774 --- /dev/null +++ b/libs/labs/theme-preference/README.md @@ -0,0 +1,41 @@ +# @atlasng/labs/theme-preference + +Experimental theme-preference controls and browser-state management for AtlasNG applications. + +## Usage + +Configure the service once with the application's light and dark theme classes: + +```ts +import { ApplicationConfig } from '@angular/core'; +import { provideThemePreference } from '@atlasng/labs/theme-preference'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideThemePreference({ + lightThemeClass: 'hra-light-theme', + darkThemeClass: 'hra-dark-theme', + }), + ], +}; +``` + +Bind the presentational selector to the service: + +```ts +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ThemePreferenceSelector, ThemePreferenceService } from '@atlasng/labs/theme-preference'; + +@Component({ + selector: 'app-appearance-settings', + imports: [ThemePreferenceSelector], + template: ` `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppearanceSettings { + protected readonly themePreference = inject(ThemePreferenceService); +} +``` + +The default preference is `system`. The service follows device color-scheme changes, persists explicit choices, +sets `data-theme` and `color-scheme` on the document root, and applies the configured theme class. diff --git a/libs/labs/theme-preference/ng-package.json b/libs/labs/theme-preference/ng-package.json new file mode 100644 index 0000000..c781f0d --- /dev/null +++ b/libs/labs/theme-preference/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/labs/theme-preference/src/index.ts b/libs/labs/theme-preference/src/index.ts new file mode 100644 index 0000000..3c39728 --- /dev/null +++ b/libs/labs/theme-preference/src/index.ts @@ -0,0 +1,7 @@ +export { + provideThemePreference, + ThemePreferenceService, + type ThemePreferenceConfig, +} from './lib/theme-preference.service'; +export { ThemePreferenceSelector } from './lib/theme-preference-selector'; +export { type ResolvedTheme, type ThemePreference } from './lib/theme-preference'; diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.html b/libs/labs/theme-preference/src/lib/theme-preference-selector.html new file mode 100644 index 0000000..de0ff75 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.html @@ -0,0 +1,10 @@ + + Light + Dark + Use device setting + diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.scss b/libs/labs/theme-preference/src/lib/theme-preference-selector.scss new file mode 100644 index 0000000..48bb062 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.scss @@ -0,0 +1,3 @@ +:host { + display: inline-block; +} diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts b/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts new file mode 100644 index 0000000..5a41321 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { ThemePreference } from './theme-preference'; +import { ThemePreferenceSelector } from './theme-preference-selector'; + +@Component({ + imports: [ThemePreferenceSelector], + template: ``, +}) +class ThemePreferenceSelectorHost { + /** Preference bound to the presentational selector. */ + preference: ThemePreference = 'system'; +} + +describe('ThemePreferenceSelector', () => { + it('selects the device setting by default', async () => { + await render(ThemePreferenceSelector); + + expect(screen.getByRole('radio', { name: 'Use device setting' })).toBeChecked(); + }); + + it('renders an initial preference', async () => { + await render(ThemePreferenceSelector, { inputs: { preference: 'light' } }); + + expect(screen.getByRole('radio', { name: 'Light' })).toBeChecked(); + }); + + it('emits the preference selected by the user', async () => { + const user = userEvent.setup(); + const { fixture } = await render(ThemePreferenceSelectorHost); + + await user.click(screen.getByRole('radio', { name: 'Dark' })); + + expect(fixture.componentInstance.preference).toBe('dark'); + expect(screen.getByRole('radio', { name: 'Dark' })).toBeChecked(); + }); + + it('disables every preference option', async () => { + await render(ThemePreferenceSelector, { inputs: { disabled: true } }); + + expect(screen.getByRole('radio', { name: 'Light' })).toBeDisabled(); + expect(screen.getByRole('radio', { name: 'Dark' })).toBeDisabled(); + expect(screen.getByRole('radio', { name: 'Use device setting' })).toBeDisabled(); + }); + + it('applies a custom accessible group label', async () => { + await render(ThemePreferenceSelector, { inputs: { ariaLabel: 'Application color scheme' } }); + + expect(screen.getByRole('radiogroup', { name: 'Application color scheme' })).toBeVisible(); + }); +}); diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts b/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts new file mode 100644 index 0000000..180a023 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts @@ -0,0 +1,44 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { ThemePreferenceSelector } from './theme-preference-selector'; + +const meta: Meta = { + title: 'Labs/Theme Preference', + component: ThemePreferenceSelector, + args: { + preference: 'system', + disabled: false, + ariaLabel: 'Theme preference', + }, + argTypes: { + preference: { + control: 'select', + options: ['light', 'dark', 'system'], + description: 'Selected theme preference.', + }, + disabled: { + control: 'boolean', + description: 'Whether users can change the preference.', + }, + ariaLabel: { + control: 'text', + description: 'Accessible name for the preference group.', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Light: Story = { + args: { preference: 'light' }, +}; + +export const Dark: Story = { + args: { preference: 'dark' }, +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.ts b/libs/labs/theme-preference/src/lib/theme-preference-selector.ts new file mode 100644 index 0000000..9cb26e7 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.ts @@ -0,0 +1,39 @@ +import { booleanAttribute, ChangeDetectionStrategy, Component, input, model } from '@angular/core'; +import { MatButtonToggleChange, MatButtonToggleModule } from '@angular/material/button-toggle'; +import { isThemePreference, ThemePreference } from './theme-preference'; + +/** + * Presents light, dark, and device-controlled theme preferences as a single-select control. + * + * This component is intentionally presentational. Consumers are responsible for persisting + * and applying changes, typically through {@link ThemePreferenceService}. + */ +@Component({ + selector: 'ang-theme-preference-selector', + imports: [MatButtonToggleModule], + templateUrl: './theme-preference-selector.html', + styleUrl: './theme-preference-selector.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'ang-theme-preference-selector' }, +}) +export class ThemePreferenceSelector { + /** Selected theme preference. */ + readonly preference = model('system'); + + /** Whether users can change the selected preference. */ + readonly disabled = input(false, { transform: booleanAttribute }); + + /** Accessible name applied to the single-select toggle group. */ + readonly ariaLabel = input('Theme preference'); + + /** + * Updates the preference when Angular Material reports a valid selection. + * + * @param event Toggle-group change event. + */ + protected handlePreferenceChange(event: MatButtonToggleChange): void { + if (isThemePreference(event.value)) { + this.preference.set(event.value); + } + } +} diff --git a/libs/labs/theme-preference/src/lib/theme-preference.service.spec.ts b/libs/labs/theme-preference/src/lib/theme-preference.service.spec.ts new file mode 100644 index 0000000..6bd48e7 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference.service.spec.ts @@ -0,0 +1,133 @@ +import { DOCUMENT } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { LOCAL_STORAGE, WINDOW } from '@atlasng/core'; +import { provideThemePreference, ThemePreferenceService } from './theme-preference.service'; + +describe('ThemePreferenceService', () => { + const storageKey = 'test-theme-preference'; + let mediaQueryListener: ((event: MediaQueryListEvent) => void) | undefined; + let mediaQuery: MediaQueryList; + let storage: Storage; + + function setup({ dark = false, stored }: { dark?: boolean; stored?: string } = {}): ThemePreferenceService { + storage = { + length: 0, + clear: vi.fn(), + getItem: vi.fn(() => stored ?? null), + key: vi.fn(), + removeItem: vi.fn(), + setItem: vi.fn(), + } as unknown as Storage; + + mediaQuery = { + matches: dark, + media: '(prefers-color-scheme: dark)', + onchange: null, + addEventListener: vi.fn((_type: string, listener: EventListenerOrEventListenerObject) => { + mediaQueryListener = listener as (event: MediaQueryListEvent) => void; + }), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + + const window = { matchMedia: vi.fn(() => mediaQuery) } as unknown as Window; + TestBed.configureTestingModule({ + providers: [ + { provide: WINDOW, useValue: window }, + { provide: LOCAL_STORAGE, useValue: storage }, + provideThemePreference({ + storageKey, + lightThemeClass: 'test-light-theme', + darkThemeClass: 'test-dark-theme', + }), + ], + }); + + return TestBed.inject(ThemePreferenceService); + } + + afterEach(() => { + const root = TestBed.inject(DOCUMENT).documentElement; + root.removeAttribute('data-theme'); + root.style.removeProperty('color-scheme'); + root.classList.remove('test-light-theme', 'test-dark-theme'); + TestBed.resetTestingModule(); + mediaQueryListener = undefined; + }); + + it('uses the device setting by default', () => { + const service = setup({ dark: true }); + + expect(service.preference()).toBe('system'); + expect(service.resolvedTheme()).toBe('dark'); + expect(document.documentElement).toHaveAttribute('data-theme', 'dark'); + expect(document.documentElement).toHaveClass('test-dark-theme'); + expect(document.documentElement).toHaveStyle({ colorScheme: 'dark' }); + }); + + it('restores a valid persisted preference', () => { + const service = setup({ dark: false, stored: 'dark' }); + + expect(service.preference()).toBe('dark'); + expect(service.resolvedTheme()).toBe('dark'); + }); + + it('ignores an invalid persisted preference', () => { + const service = setup({ dark: false, stored: 'sepia' }); + + expect(service.preference()).toBe('system'); + expect(service.resolvedTheme()).toBe('light'); + }); + + it('persists and applies an explicit preference', () => { + const service = setup({ dark: false }); + + service.setPreference('dark'); + + expect(storage.setItem).toHaveBeenCalledWith(storageKey, 'dark'); + expect(document.documentElement).toHaveAttribute('data-theme', 'dark'); + expect(document.documentElement).toHaveClass('test-dark-theme'); + expect(document.documentElement).not.toHaveClass('test-light-theme'); + }); + + it('tracks device changes while the system preference is active', () => { + const service = setup({ dark: false }); + + mediaQueryListener?.({ matches: true } as MediaQueryListEvent); + + expect(service.resolvedTheme()).toBe('dark'); + expect(document.documentElement).toHaveClass('test-dark-theme'); + }); + + it('does not replace an explicit preference when the device setting changes', () => { + const service = setup({ dark: false }); + service.setPreference('light'); + + mediaQueryListener?.({ matches: true } as MediaQueryListEvent); + + expect(service.preference()).toBe('light'); + expect(service.resolvedTheme()).toBe('light'); + expect(document.documentElement).toHaveClass('test-light-theme'); + }); + + it('resets to the device preference and removes the persisted override', () => { + const service = setup({ dark: true, stored: 'light' }); + + service.resetPreference(); + + expect(service.preference()).toBe('system'); + expect(service.resolvedTheme()).toBe('dark'); + expect(storage.removeItem).toHaveBeenCalledWith(storageKey); + expect(document.documentElement).toHaveClass('test-dark-theme'); + }); + + it('removes the media-query listener when destroyed', () => { + setup(); + + TestBed.resetTestingModule(); + + expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', mediaQueryListener); + }); +}); diff --git a/libs/labs/theme-preference/src/lib/theme-preference.service.ts b/libs/labs/theme-preference/src/lib/theme-preference.service.ts new file mode 100644 index 0000000..a071b15 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference.service.ts @@ -0,0 +1,234 @@ +import { + DestroyRef, + inject, + Injectable, + InjectionToken, + Provider, + RendererFactory2, + signal, + computed, +} from '@angular/core'; +import { DOCUMENT, LOCAL_STORAGE, WINDOW } from '@atlasng/core'; +import { isThemePreference, ResolvedTheme, ThemePreference } from './theme-preference'; + +/** Default media query used to resolve the device color scheme. */ +const DARK_MODE_MEDIA_QUERY = '(prefers-color-scheme: dark)'; + +/** Default browser-storage key for the selected theme preference. */ +const DEFAULT_STORAGE_KEY = '__atlasng_theme_preference__'; + +/** + * Configuration for {@link ThemePreferenceService}. + */ +export interface ThemePreferenceConfig { + /** Storage backend used for persistence, or `false` to disable persistence. */ + storage?: Storage | false; + + /** Storage key used to persist the selected preference. */ + storageKey?: string; + + /** Optional CSS class applied when the resolved theme is light. */ + lightThemeClass?: string; + + /** Optional CSS class applied when the resolved theme is dark. */ + darkThemeClass?: string; + + /** Document-root attribute that receives the resolved theme, or `false` to disable it. */ + themeAttribute?: string | false; +} + +/** Internal configuration token for the theme-preference service. */ +const THEME_PREFERENCE_CONFIG = new InjectionToken('THEME_PREFERENCE_CONFIG', { + providedIn: 'root', + factory: () => ({}), +}); + +/** Fully resolved configuration used internally by the service. */ +interface ResolvedThemePreferenceConfig { + readonly storage: Storage | false; + readonly storageKey: string; + readonly lightThemeClass?: string; + readonly darkThemeClass?: string; + readonly themeAttribute: string | false; +} + +/** + * Provides application-specific theme class and persistence configuration. + * + * @param config Theme-preference configuration. + * @returns Provider for the theme-preference configuration token. + */ +export function provideThemePreference(config: ThemePreferenceConfig): Provider { + return { provide: THEME_PREFERENCE_CONFIG, useValue: config }; +} + +/** + * Resolves injected configuration with browser-aware defaults. + * + * @returns Fully resolved theme-preference configuration. + */ +function injectThemePreferenceConfig(): ResolvedThemePreferenceConfig { + const config = inject(THEME_PREFERENCE_CONFIG); + return { + storage: config.storage ?? inject(LOCAL_STORAGE) ?? false, + storageKey: config.storageKey ?? DEFAULT_STORAGE_KEY, + lightThemeClass: config.lightThemeClass, + darkThemeClass: config.darkThemeClass, + themeAttribute: config.themeAttribute ?? 'data-theme', + }; +} + +/** + * Persists a user's theme preference, resolves device settings, and applies the active theme. + */ +@Injectable({ providedIn: 'root' }) +export class ThemePreferenceService { + /** Window abstraction used for device-theme detection. */ + readonly #window = inject(WINDOW); + + /** Document abstraction whose root element receives the active theme. */ + readonly #document = inject(DOCUMENT); + + /** Renderer used for document-root attributes, classes, and styles. */ + readonly #renderer = inject(RendererFactory2).createRenderer(null, null); + + /** Resolved service configuration. */ + readonly #config = injectThemePreferenceConfig(); + + /** Writable user preference state. */ + readonly #preference = signal('system'); + + /** Writable device-theme state. */ + readonly #deviceTheme = signal('light'); + + /** Media-query object used to observe device-theme changes when supported. */ + readonly #darkModeQuery = + typeof this.#window.matchMedia === 'function' ? this.#window.matchMedia(DARK_MODE_MEDIA_QUERY) : undefined; + + /** The preference explicitly selected by the user. */ + readonly preference = this.#preference.asReadonly(); + + /** The concrete light or dark theme currently applied to the application. */ + readonly resolvedTheme = computed(() => { + const preference = this.#preference(); + return preference === 'system' ? this.#deviceTheme() : preference; + }); + + /** Initializes persisted state, device detection, and document theming. */ + constructor() { + this.#deviceTheme.set(this.#darkModeQuery?.matches ? 'dark' : 'light'); + this.#loadPreference(); + this.#listenForDeviceThemeChanges(); + this.#applyResolvedTheme(); + } + + /** + * Saves and applies a theme preference. + * + * @param preference Preference selected by the user. + */ + setPreference(preference: ThemePreference): void { + this.#preference.set(preference); + this.#savePreference(preference); + this.#applyResolvedTheme(); + } + + /** Restores device-controlled theming and removes the persisted override. */ + resetPreference(): void { + this.#preference.set('system'); + this.#removeStoredPreference(); + this.#applyResolvedTheme(); + } + + /** Loads a valid persisted preference when storage is available. */ + #loadPreference(): void { + const { storage, storageKey } = this.#config; + if (!storage) { + return; + } + + try { + const storedPreference = storage.getItem(storageKey); + if (isThemePreference(storedPreference)) { + this.#preference.set(storedPreference); + } + } catch { + // Storage can become unavailable after feature detection; retain the device default. + } + } + + /** + * Persists the current preference when storage is available. + * + * @param preference Preference to persist. + */ + #savePreference(preference: ThemePreference): void { + const { storage, storageKey } = this.#config; + if (!storage) { + return; + } + + try { + storage.setItem(storageKey, preference); + } catch { + // Ignore storage failures such as private-mode restrictions or exhausted quota. + } + } + + /** Removes a persisted preference when storage is available. */ + #removeStoredPreference(): void { + const { storage, storageKey } = this.#config; + if (!storage) { + return; + } + + try { + storage.removeItem(storageKey); + } catch { + // Ignore storage failures and continue using the in-memory device preference. + } + } + + /** Registers and tears down device-theme change handling. */ + #listenForDeviceThemeChanges(): void { + const query = this.#darkModeQuery; + if (!query) { + return; + } + + const handler = (event: MediaQueryListEvent): void => { + this.#deviceTheme.set(event.matches ? 'dark' : 'light'); + if (this.#preference() === 'system') { + this.#applyResolvedTheme(); + } + }; + + query.addEventListener('change', handler); + inject(DestroyRef).onDestroy(() => query.removeEventListener('change', handler)); + } + + /** Applies the resolved theme to the document root. */ + #applyResolvedTheme(): void { + const root = this.#document.documentElement; + const theme = this.resolvedTheme(); + const { darkThemeClass, lightThemeClass, themeAttribute } = this.#config; + + if (themeAttribute) { + this.#renderer.setAttribute(root, themeAttribute, theme); + } + + this.#renderer.setStyle(root, 'color-scheme', theme); + + if (lightThemeClass) { + this.#renderer.removeClass(root, lightThemeClass); + } + if (darkThemeClass) { + this.#renderer.removeClass(root, darkThemeClass); + } + + const activeThemeClass = theme === 'light' ? lightThemeClass : darkThemeClass; + if (activeThemeClass) { + this.#renderer.addClass(root, activeThemeClass); + } + } +} diff --git a/libs/labs/theme-preference/src/lib/theme-preference.ts b/libs/labs/theme-preference/src/lib/theme-preference.ts new file mode 100644 index 0000000..a684f43 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference.ts @@ -0,0 +1,15 @@ +/** Theme preference choices exposed to users. */ +export type ThemePreference = 'light' | 'dark' | 'system'; + +/** Concrete color schemes that can be applied to the document. */ +export type ResolvedTheme = Exclude; + +/** + * Determines whether an unknown value is a supported theme preference. + * + * @param value Value to validate. + * @returns Whether the value is a supported theme preference. + */ +export function isThemePreference(value: unknown): value is ThemePreference { + return value === 'light' || value === 'dark' || value === 'system'; +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 6eefdc1..28422e7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -54,7 +54,8 @@ "@atlasng/labs/section-header": ["./libs/labs/section-header/src/index.ts"], "@atlasng/labs/skip-to-content-button": ["./libs/labs/skip-to-content-button/src/index.ts"], "@atlasng/labs/table": ["./libs/labs/table/src/index.ts"], - "@atlasng/labs/youtube-player": ["./libs/labs/youtube-player/src/index.ts"] + "@atlasng/labs/youtube-player": ["./libs/labs/youtube-player/src/index.ts"], + "@atlasng/labs/theme-preference": ["libs/labs/theme-preference/src/index.ts"] } }, "exclude": ["node_modules", "tmp"], From 63048e2b465a6c06773a464e15911598ff71b722 Mon Sep 17 00:00:00 2001 From: Libby Maier Date: Thu, 23 Jul 2026 10:16:28 -0400 Subject: [PATCH 2/5] feat(labs): add ThemePreferenceMenu component and update theme preference selector --- libs/labs/theme-preference/README.md | 19 +++- libs/labs/theme-preference/src/index.ts | 1 + .../src/lib/theme-preference-menu.html | 37 +++++++ .../src/lib/theme-preference-menu.scss | 20 ++++ .../src/lib/theme-preference-menu.spec.ts | 102 ++++++++++++++++++ .../src/lib/theme-preference-menu.stories.ts | 89 +++++++++++++++ .../src/lib/theme-preference-menu.ts | 74 +++++++++++++ .../src/lib/theme-preference-selector.html | 2 +- .../src/lib/theme-preference-selector.spec.ts | 4 +- .../lib/theme-preference-selector.stories.ts | 2 +- 10 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-menu.html create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-menu.scss create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-menu.stories.ts create mode 100644 libs/labs/theme-preference/src/lib/theme-preference-menu.ts diff --git a/libs/labs/theme-preference/README.md b/libs/labs/theme-preference/README.md index 3e83774..f85ce5d 100644 --- a/libs/labs/theme-preference/README.md +++ b/libs/labs/theme-preference/README.md @@ -20,16 +20,20 @@ export const appConfig: ApplicationConfig = { }; ``` -Bind the presentational selector to the service: +Bind either presentational variant to the service: ```ts import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ThemePreferenceSelector, ThemePreferenceService } from '@atlasng/labs/theme-preference'; +import { ThemePreferenceMenu, ThemePreferenceSelector, ThemePreferenceService } from '@atlasng/labs/theme-preference'; @Component({ selector: 'app-appearance-settings', - imports: [ThemePreferenceSelector], - template: ` `, + imports: [ThemePreferenceMenu, ThemePreferenceSelector], + template: ` + + + + `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppearanceSettings { @@ -39,3 +43,10 @@ export class AppearanceSettings { The default preference is `system`. The service follows device color-scheme changes, persists explicit choices, sets `data-theme` and `color-scheme` on the document root, and applies the configured theme class. + +## Variants + +- `ThemePreferenceSelector` renders the three preferences as a Material button-toggle group. +- `ThemePreferenceMenu` renders a compact Material icon button and menu suitable for application headers. Its menu + opens below and before the trigger by default, and its position can be customized with `xPosition`, `yPosition`, + and `overlapTrigger`. diff --git a/libs/labs/theme-preference/src/index.ts b/libs/labs/theme-preference/src/index.ts index 3c39728..05820c3 100644 --- a/libs/labs/theme-preference/src/index.ts +++ b/libs/labs/theme-preference/src/index.ts @@ -3,5 +3,6 @@ export { ThemePreferenceService, type ThemePreferenceConfig, } from './lib/theme-preference.service'; +export { ThemePreferenceMenu } from './lib/theme-preference-menu'; export { ThemePreferenceSelector } from './lib/theme-preference-selector'; export { type ResolvedTheme, type ThemePreference } from './lib/theme-preference'; diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.html b/libs/labs/theme-preference/src/lib/theme-preference-menu.html new file mode 100644 index 0000000..7971fb7 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.html @@ -0,0 +1,37 @@ + + + + @for (option of options; track option.value) { + + } + diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.scss b/libs/labs/theme-preference/src/lib/theme-preference-menu.scss new file mode 100644 index 0000000..f44fb3f --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.scss @@ -0,0 +1,20 @@ +:host { + display: inline-block; +} + +.ang-theme-preference-menu-item-selected { + color: var(--mat-sys-on-secondary-container); + background-color: var(--mat-sys-secondary-container); +} + +.ang-theme-preference-menu-item-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + width: 100%; +} + +.ang-theme-preference-menu-check { + margin: 0; +} diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts b/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts new file mode 100644 index 0000000..a307b80 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts @@ -0,0 +1,102 @@ +import { Component } from '@angular/core'; +import { render, screen, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { ThemePreference } from './theme-preference'; +import { ThemePreferenceMenu } from './theme-preference-menu'; + +@Component({ + imports: [ThemePreferenceMenu], + template: ``, +}) +class ThemePreferenceMenuHost { + /** Preference bound to the menu component. */ + preference: ThemePreference = 'system'; +} + +describe('ThemePreferenceMenu', () => { + it('renders a Material icon-button menu trigger', async () => { + await render(ThemePreferenceMenu); + + const trigger = screen.getByRole('button', { name: 'Choose theme preference' }); + expect(trigger).toBeVisible(); + expect(within(trigger).getByRole('img', { hidden: true })).toHaveAttribute('data-mat-icon-name', 'brightness_4'); + }); + + it('shows the default Material tooltip on hover', async () => { + const user = userEvent.setup(); + await render(ThemePreferenceMenu); + + await user.hover(screen.getByRole('button', { name: 'Choose theme preference' })); + + expect(await screen.findAllByText('Theme settings')).toHaveLength(2); + }); + + it('shows the default Material tooltip on keyboard focus', async () => { + const user = userEvent.setup(); + await render(ThemePreferenceMenu); + + await user.tab(); + + expect(screen.getByRole('button', { name: 'Choose theme preference' })).toHaveFocus(); + expect(await screen.findAllByText('Theme settings')).toHaveLength(2); + }); + + it('renders all theme preferences and marks the device setting by default', async () => { + const user = userEvent.setup(); + await render(ThemePreferenceMenu); + + await user.click(screen.getByRole('button', { name: 'Choose theme preference' })); + + expect(screen.getByRole('menuitemradio', { name: 'Light mode' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('menuitemradio', { name: 'Dark mode' })).toHaveAttribute('aria-checked', 'false'); + const deviceOption = screen.getByRole('menuitemradio', { name: 'Device settings' }); + expect(deviceOption).toHaveAttribute('aria-checked', 'true'); + expect( + within(deviceOption) + .getAllByRole('img', { hidden: true }) + .map((icon) => icon.getAttribute('data-mat-icon-name')), + ).toEqual(['devices', 'check']); + }); + + it('updates the bound preference when the user chooses an option', async () => { + const user = userEvent.setup(); + const { fixture } = await render(ThemePreferenceMenuHost); + + await user.click(screen.getByRole('button', { name: 'Choose theme preference' })); + await user.click(screen.getByRole('menuitemradio', { name: 'Light mode' })); + + expect(fixture.componentInstance.preference).toBe('light'); + }); + + it('shows the check icon only for the selected option', async () => { + const user = userEvent.setup(); + await render(ThemePreferenceMenu, { inputs: { preference: 'dark' } }); + + await user.click(screen.getByRole('button', { name: 'Choose theme preference' })); + + const darkOption = screen.getByRole('menuitemradio', { name: 'Dark mode' }); + expect(darkOption).toHaveAttribute('aria-checked', 'true'); + expect( + within(darkOption) + .getAllByRole('img', { hidden: true }) + .map((icon) => icon.getAttribute('data-mat-icon-name')), + ).toEqual(['dark_mode', 'check']); + expect( + screen + .getAllByRole('img', { hidden: true }) + .filter((icon) => icon.getAttribute('data-mat-icon-name') === 'check'), + ).toHaveLength(1); + }); + + it('supports a disabled trigger', async () => { + await render(ThemePreferenceMenu, { inputs: { disabled: true } }); + + expect(screen.getByRole('button', { name: 'Choose theme preference' })).toBeDisabled(); + }); + + it('supports a custom accessible trigger label', async () => { + await render(ThemePreferenceMenu, { inputs: { ariaLabel: 'Change application theme' } }); + + expect(screen.getByRole('button', { name: 'Change application theme' })).toBeVisible(); + }); +}); diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.stories.ts b/libs/labs/theme-preference/src/lib/theme-preference-menu.stories.ts new file mode 100644 index 0000000..9f931e5 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.stories.ts @@ -0,0 +1,89 @@ +import { MatToolbarModule } from '@angular/material/toolbar'; +import { argsToTemplate, Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { ThemePreferenceMenu } from './theme-preference-menu'; + +const meta: Meta = { + title: 'Labs/Theme Preference/Menu', + component: ThemePreferenceMenu, + args: { + preference: 'system', + disabled: false, + ariaLabel: 'Choose theme preference', + tooltip: 'Theme settings', + xPosition: 'before', + yPosition: 'below', + overlapTrigger: false, + }, + argTypes: { + preference: { + control: 'select', + options: ['light', 'dark', 'system'], + description: 'Selected theme preference.', + }, + disabled: { + control: 'boolean', + description: 'Whether the menu trigger is disabled.', + }, + ariaLabel: { + control: 'text', + description: 'Accessible name for the menu trigger.', + }, + tooltip: { + control: 'text', + description: 'Tooltip displayed for the menu trigger.', + }, + xPosition: { + control: 'select', + options: ['before', 'after'], + description: 'Preferred horizontal menu position.', + }, + yPosition: { + control: 'select', + options: ['above', 'below'], + description: 'Preferred vertical menu position.', + }, + overlapTrigger: { + control: 'boolean', + description: 'Whether the menu overlaps its trigger.', + }, + }, + render: (args) => ({ + props: args, + template: ``, + }), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Light: Story = { + args: { preference: 'light' }, +}; + +export const Dark: Story = { + args: { preference: 'dark' }, +}; + +export const HeaderPlacement: Story = { + decorators: [ + moduleMetadata({ + imports: [MatToolbarModule], + }), + ], + render: (args) => ({ + props: args, + styles: ['.story-title { flex: 1 1 auto; }'], + template: ` + + Application + + + `, + }), +}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.ts b/libs/labs/theme-preference/src/lib/theme-preference-menu.ts new file mode 100644 index 0000000..8a5c571 --- /dev/null +++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.ts @@ -0,0 +1,74 @@ +import { booleanAttribute, ChangeDetectionStrategy, Component, input, model } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule, MenuPositionX, MenuPositionY } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ThemePreference } from './theme-preference'; + +/** Display information for a theme preference menu item. */ +interface ThemePreferenceMenuOption { + /** Value emitted when the option is selected. */ + readonly value: ThemePreference; + + /** Public-facing option label. */ + readonly label: string; + + /** Material icon displayed before the option label. */ + readonly icon: string; +} + +/** Theme preference options in their display order. */ +const THEME_PREFERENCE_MENU_OPTIONS: readonly ThemePreferenceMenuOption[] = [ + { value: 'light', label: 'Light mode', icon: 'light_mode' }, + { value: 'dark', label: 'Dark mode', icon: 'dark_mode' }, + { value: 'system', label: 'Device settings', icon: 'devices' }, +]; + +/** + * Compact icon-button menu for choosing a light, dark, or device-controlled theme preference. + * + * The menu defaults to opening below and before its trigger, which suits actions placed at the + * right edge of application headers. Angular Material can flip this position to remain onscreen. + */ +@Component({ + selector: 'ang-theme-preference-menu', + imports: [MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule], + templateUrl: './theme-preference-menu.html', + styleUrl: './theme-preference-menu.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'ang-theme-preference-menu' }, +}) +export class ThemePreferenceMenu { + /** Selected theme preference. */ + readonly preference = model('system'); + + /** Whether the menu trigger is disabled. */ + readonly disabled = input(false, { transform: booleanAttribute }); + + /** Accessible name applied to the icon-button trigger. */ + readonly ariaLabel = input('Choose theme preference'); + + /** Tooltip displayed for the icon-button trigger. */ + readonly tooltip = input('Theme settings'); + + /** Horizontal menu position relative to the trigger. */ + readonly xPosition = input('before'); + + /** Vertical menu position relative to the trigger. */ + readonly yPosition = input('below'); + + /** Whether the menu panel overlaps its trigger. */ + readonly overlapTrigger = input(false, { transform: booleanAttribute }); + + /** Ordered menu options rendered by the template. */ + protected readonly options = THEME_PREFERENCE_MENU_OPTIONS; + + /** + * Updates the preference selected by the user. + * + * @param preference Selected theme preference. + */ + protected selectPreference(preference: ThemePreference): void { + this.preference.set(preference); + } +} diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.html b/libs/labs/theme-preference/src/lib/theme-preference-selector.html index de0ff75..a36c20f 100644 --- a/libs/labs/theme-preference/src/lib/theme-preference-selector.html +++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.html @@ -6,5 +6,5 @@ > Light Dark - Use device setting + Device settings diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts b/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts index 5a41321..3955b56 100644 --- a/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts +++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.spec.ts @@ -17,7 +17,7 @@ describe('ThemePreferenceSelector', () => { it('selects the device setting by default', async () => { await render(ThemePreferenceSelector); - expect(screen.getByRole('radio', { name: 'Use device setting' })).toBeChecked(); + expect(screen.getByRole('radio', { name: 'Device settings' })).toBeChecked(); }); it('renders an initial preference', async () => { @@ -41,7 +41,7 @@ describe('ThemePreferenceSelector', () => { expect(screen.getByRole('radio', { name: 'Light' })).toBeDisabled(); expect(screen.getByRole('radio', { name: 'Dark' })).toBeDisabled(); - expect(screen.getByRole('radio', { name: 'Use device setting' })).toBeDisabled(); + expect(screen.getByRole('radio', { name: 'Device settings' })).toBeDisabled(); }); it('applies a custom accessible group label', async () => { diff --git a/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts b/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts index 180a023..4f805e8 100644 --- a/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts +++ b/libs/labs/theme-preference/src/lib/theme-preference-selector.stories.ts @@ -2,7 +2,7 @@ import { Meta, StoryObj } from '@storybook/angular'; import { ThemePreferenceSelector } from './theme-preference-selector'; const meta: Meta = { - title: 'Labs/Theme Preference', + title: 'Labs/Theme Preference/Selector', component: ThemePreferenceSelector, args: { preference: 'system', From ab412549e6ee8ab10f0efbb726a36079c4274e77 Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:21:24 +0000 Subject: [PATCH 3/5] chore(labs): rerun checks From 79bd0c6bff290d4558fc227fe7cac3806498422f Mon Sep 17 00:00:00 2001 From: Libby Maier Date: Thu, 23 Jul 2026 13:22:17 -0400 Subject: [PATCH 4/5] fix(labs): keep theme menu width stable --- .../src/lib/theme-preference-menu.html | 9 ++++++--- .../src/lib/theme-preference-menu.scss | 4 ++++ .../src/lib/theme-preference-menu.spec.ts | 17 ++++++----------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.html b/libs/labs/theme-preference/src/lib/theme-preference-menu.html index 7971fb7..0605796 100644 --- a/libs/labs/theme-preference/src/lib/theme-preference-menu.html +++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.html @@ -28,9 +28,12 @@ {{ option.label }} - @if (preference() === option.value) { - } diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.scss b/libs/labs/theme-preference/src/lib/theme-preference-menu.scss index f44fb3f..aa04861 100644 --- a/libs/labs/theme-preference/src/lib/theme-preference-menu.scss +++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.scss @@ -18,3 +18,7 @@ .ang-theme-preference-menu-check { margin: 0; } + +.ang-theme-preference-menu-check-hidden { + visibility: hidden; +} diff --git a/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts b/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts index a307b80..8507ce3 100644 --- a/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts +++ b/libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts @@ -68,24 +68,19 @@ describe('ThemePreferenceMenu', () => { expect(fixture.componentInstance.preference).toBe('light'); }); - it('shows the check icon only for the selected option', async () => { + it('reserves check-icon space and shows the check only for the selected option', async () => { const user = userEvent.setup(); await render(ThemePreferenceMenu, { inputs: { preference: 'dark' } }); await user.click(screen.getByRole('button', { name: 'Choose theme preference' })); + const lightOption = screen.getByRole('menuitemradio', { name: 'Light mode' }); const darkOption = screen.getByRole('menuitemradio', { name: 'Dark mode' }); + const deviceOption = screen.getByRole('menuitemradio', { name: 'Device settings' }); expect(darkOption).toHaveAttribute('aria-checked', 'true'); - expect( - within(darkOption) - .getAllByRole('img', { hidden: true }) - .map((icon) => icon.getAttribute('data-mat-icon-name')), - ).toEqual(['dark_mode', 'check']); - expect( - screen - .getAllByRole('img', { hidden: true }) - .filter((icon) => icon.getAttribute('data-mat-icon-name') === 'check'), - ).toHaveLength(1); + expect(within(lightOption).getAllByRole('img', { hidden: true })[1]).not.toBeVisible(); + expect(within(darkOption).getAllByRole('img', { hidden: true })[1]).toBeVisible(); + expect(within(deviceOption).getAllByRole('img', { hidden: true })[1]).not.toBeVisible(); }); it('supports a disabled trigger', async () => { From c482f0f4f1b6417a64fee98038a51189d0b9b217 Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:26:52 +0000 Subject: [PATCH 5/5] fix(labs): keep theme menu width stable [Self-Healing CI Rerun]