Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/labs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@angular/youtube-player": "^21.2.6",
"@atlasng/analytics": "0.0.6",
"@atlasng/common": "0.0.6",
"@atlasng/core": "0.0.6",
"@atlasng/design-system": "0.0.6"
},
"devDependencies": {
Expand Down
52 changes: 52 additions & 0 deletions libs/labs/theme-preference/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# @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 either presentational variant to the service:

```ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ThemePreferenceMenu, ThemePreferenceSelector, ThemePreferenceService } from '@atlasng/labs/theme-preference';

@Component({
selector: 'app-appearance-settings',
imports: [ThemePreferenceMenu, ThemePreferenceSelector],
template: `
<ang-theme-preference-selector [preference]="themePreference.preference()" (preferenceChange)="themePreference.setPreference($event)" />

<ang-theme-preference-menu [preference]="themePreference.preference()" (preferenceChange)="themePreference.setPreference($event)" />
`,
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.

## 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`.
5 changes: 5 additions & 0 deletions libs/labs/theme-preference/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
8 changes: 8 additions & 0 deletions libs/labs/theme-preference/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
provideThemePreference,
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';
40 changes: 40 additions & 0 deletions libs/labs/theme-preference/src/lib/theme-preference-menu.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<button
matIconButton
type="button"
[disabled]="disabled()"
[attr.aria-label]="ariaLabel()"
[matTooltip]="tooltip()"
[matMenuTriggerFor]="themeMenu"
>
<mat-icon fontIcon="brightness_4" />
</button>

<mat-menu
aria-label="Theme preference"
[xPosition]="xPosition()"
[yPosition]="yPosition()"
[overlapTrigger]="overlapTrigger()"
#themeMenu="matMenu"
>
@for (option of options; track option.value) {
<button
mat-menu-item
type="button"
role="menuitemradio"
[class.ang-theme-preference-menu-item-selected]="preference() === option.value"
[attr.aria-checked]="preference() === option.value"
(click)="selectPreference(option.value)"
>
<mat-icon [fontIcon]="option.icon" />
<span class="ang-theme-preference-menu-item-content">
<span>{{ option.label }}</span>
<mat-icon
class="ang-theme-preference-menu-check"
fontIcon="check"
aria-hidden="true"
[class.ang-theme-preference-menu-check-hidden]="preference() !== option.value"
/>
</span>
</button>
}
</mat-menu>
24 changes: 24 additions & 0 deletions libs/labs/theme-preference/src/lib/theme-preference-menu.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
: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;
}

.ang-theme-preference-menu-check-hidden {
visibility: hidden;
}
97 changes: 97 additions & 0 deletions libs/labs/theme-preference/src/lib/theme-preference-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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: `<ang-theme-preference-menu [(preference)]="preference" />`,
})
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('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(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 () => {
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();
});
});
Original file line number Diff line number Diff line change
@@ -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<ThemePreferenceMenu> = {
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: `<ang-theme-preference-menu ${argsToTemplate(args)} />`,
}),
};

export default meta;
type Story = StoryObj<ThemePreferenceMenu>;

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: `
<mat-toolbar>
<span class="story-title">Application</span>
<ang-theme-preference-menu ${argsToTemplate(args)} />
</mat-toolbar>
`,
}),
};

export const Disabled: Story = {
args: { disabled: true },
};
Loading
Loading