Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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