Skip to content

Commit 0111b64

Browse files
committed
fix(addon-mobile): correct sheet dialog theme-color handling
Three independent failure modes in TuiSheetDialogService / TuiThemeColorService around `<meta name="theme-color">` handling: - Capture the theme color at the moment the first sheet opens instead of once at construction, so a theme change made after the service was created (e.g. an app-level dark-mode effect) is no longer lost on revert, and nested sheets no longer revert onto the sheet color. - Make the sheet color configurable via `themeColor` on TUI_SHEET_DIALOG_OPTIONS (defaults to #404040, `null` opts out) instead of a hardcoded constant that only matched the backdrop in light mode. - Split TuiThemeColorService: `color` now writes only the `<meta name="theme-color">` tag, while the new `navColor` setter writes the `--tui-theme-color` CSS variable used by navigation chrome. Dimming the address bar no longer flickers the nav. Adds a jest spec (revert / nesting / opt-out matrix), a Cypress test for the real-browser open/close behavior, and a `themeColor` docs example.
1 parent 1117a9c commit 0111b64

11 files changed

Lines changed: 282 additions & 12 deletions

File tree

projects/addon-mobile/components/sheet-dialog/sheet-dialog.options.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ export interface TuiSheetDialogOptions<I = undefined> {
1111
readonly stops: readonly string[];
1212
readonly bar: boolean;
1313
readonly required: boolean;
14+
/**
15+
* Address-bar (`<meta name="theme-color">`) color applied while the sheet is
16+
* open, matching the dimmed backdrop. Pass `null` to leave the address bar
17+
* untouched (e.g. apps that manage `theme-color` themselves, or need a value
18+
* that tracks dark mode).
19+
*/
20+
readonly themeColor: string | null;
1421
}
1522

1623
export const TUI_SHEET_DIALOG_DEFAULT_OPTIONS: TuiSheetDialogOptions = {
@@ -23,6 +30,7 @@ export const TUI_SHEET_DIALOG_DEFAULT_OPTIONS: TuiSheetDialogOptions = {
2330
data: undefined,
2431
bar: true,
2532
required: false,
33+
themeColor: '#404040',
2634
};
2735

2836
/**

projects/addon-mobile/components/sheet-dialog/sheet-dialog.service.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@ import {
99
type TuiSheetDialogOptions,
1010
} from './sheet-dialog.options';
1111

12-
const THEME = '#404040';
13-
1412
@Injectable({providedIn: 'root'})
1513
export class TuiSheetDialogService extends TuiModalService<TuiSheetDialogOptions<any>> {
1614
private readonly theme = inject(TuiThemeColorService);
17-
private readonly initial = this.theme.color;
15+
private initial = '';
1816
private count = 0;
1917

2018
protected readonly options = inject(TUI_SHEET_DIALOG_OPTIONS);
@@ -23,16 +21,29 @@ export class TuiSheetDialogService extends TuiModalService<TuiSheetDialogOptions
2321
protected override add(
2422
component: PolymorpheusComponent<TuiModalComponent<TuiSheetDialogOptions>>,
2523
): () => void {
26-
this.count++;
27-
this.theme.color = THEME;
24+
const {themeColor} = this.options;
25+
26+
if (themeColor !== null) {
27+
if (!this.count) {
28+
// Capture the live theme color at the moment the first sheet
29+
// opens, not once at construction — otherwise a theme change made
30+
// after the service was created (e.g. an app-level dark-mode
31+
// effect) is lost on revert. Capturing at the 0 -> 1 transition
32+
// also avoids the nested case reverting onto themeColor instead
33+
// of the original color.
34+
this.initial = this.theme.color;
35+
}
36+
37+
this.count++;
38+
this.theme.color = themeColor;
39+
}
2840

2941
const cleanup = super.add(component);
3042

3143
return () => {
3244
cleanup();
33-
this.count--;
3445

35-
if (!this.count) {
46+
if (themeColor !== null && !--this.count) {
3647
this.theme.color = this.initial;
3748
}
3849
};
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {DOCUMENT} from '@angular/common';
2+
import {ChangeDetectionStrategy, Component} from '@angular/core';
3+
import {type ComponentFixture, TestBed} from '@angular/core/testing';
4+
import {
5+
type TuiSheetDialogOptions,
6+
tuiSheetDialogOptionsProvider,
7+
TuiSheetDialogService,
8+
TuiThemeColorService,
9+
} from '@taiga-ui/addon-mobile';
10+
import {provideTaiga, TuiRoot} from '@taiga-ui/core';
11+
12+
describe('TuiSheetDialogService theme-color', () => {
13+
@Component({
14+
imports: [TuiRoot],
15+
template: '<tui-root />',
16+
changeDetection: ChangeDetectionStrategy.OnPush,
17+
})
18+
class Test {}
19+
20+
let fixture: ComponentFixture<Test>;
21+
let service: TuiSheetDialogService;
22+
let theme: TuiThemeColorService;
23+
24+
function themeColor(): string | null {
25+
return (
26+
TestBed.inject(DOCUMENT)
27+
.querySelector('meta[name="theme-color"]')
28+
?.getAttribute('content') ?? null
29+
);
30+
}
31+
32+
async function setup(options: Partial<TuiSheetDialogOptions> = {}): Promise<void> {
33+
TestBed.configureTestingModule({
34+
imports: [Test],
35+
providers: [tuiSheetDialogOptionsProvider(options), provideTaiga()],
36+
});
37+
await TestBed.compileComponents();
38+
fixture = TestBed.createComponent(Test);
39+
service = TestBed.inject(TuiSheetDialogService);
40+
theme = TestBed.inject(TuiThemeColorService);
41+
fixture.detectChanges();
42+
}
43+
44+
it('reverts to the color set after construction, not the construction-time value', async () => {
45+
await setup();
46+
// App changes theme after the service already exists (e.g. a dark-mode effect).
47+
theme.color = '#000000';
48+
49+
const sheet = service.open('content').subscribe();
50+
51+
fixture.detectChanges();
52+
expect(themeColor()).toBe('#404040');
53+
54+
sheet.unsubscribe();
55+
fixture.detectChanges();
56+
// Regression: the old code captured `initial` once at construction and
57+
// reverted to it here, losing the '#000000' write.
58+
expect(themeColor()).toBe('#000000');
59+
});
60+
61+
it('reverts only when the last nested sheet closes, never onto the theme color', async () => {
62+
await setup();
63+
theme.color = '#123456';
64+
65+
const outer = service.open('outer').subscribe();
66+
const inner = service.open('inner').subscribe();
67+
68+
fixture.detectChanges();
69+
expect(themeColor()).toBe('#404040');
70+
71+
inner.unsubscribe();
72+
fixture.detectChanges();
73+
// Outer is still open — must stay on the theme color, not revert early.
74+
expect(themeColor()).toBe('#404040');
75+
76+
outer.unsubscribe();
77+
fixture.detectChanges();
78+
// Must land on the original color, never get locked onto the theme color.
79+
expect(themeColor()).toBe('#123456');
80+
});
81+
82+
it('honours a custom themeColor from options', async () => {
83+
await setup({themeColor: '#112233'});
84+
theme.color = '#000000';
85+
86+
const sheet = service.open('content').subscribe();
87+
88+
fixture.detectChanges();
89+
expect(themeColor()).toBe('#112233');
90+
91+
sheet.unsubscribe();
92+
fixture.detectChanges();
93+
expect(themeColor()).toBe('#000000');
94+
});
95+
96+
it('leaves the address bar untouched when themeColor is null', async () => {
97+
await setup({themeColor: null});
98+
theme.color = '#000000';
99+
100+
const sheet = service.open('content').subscribe();
101+
102+
fixture.detectChanges();
103+
// Opt-out: the service must not touch the address bar at all.
104+
expect(themeColor()).toBe('#000000');
105+
106+
sheet.unsubscribe();
107+
fixture.detectChanges();
108+
expect(themeColor()).toBe('#000000');
109+
});
110+
});

projects/addon-mobile/services/theme-color.service.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,28 @@ export class TuiThemeColorService implements TuiThemeColor {
2222
this.color = this.current;
2323
}
2424

25+
/**
26+
* Address-bar color — the `<meta name="theme-color">` tag.
27+
*/
2528
public get color(): string {
2629
return this.current;
2730
}
2831

2932
public set color(content: string) {
3033
this.current = content;
3134
this.meta.updateTag({name: 'theme-color', content});
35+
}
36+
37+
/**
38+
* Navigation-chrome color — the `--tui-theme-color` CSS variable on `<html>`,
39+
* consumed by `tuiNavigationHeader` / `tuiNavigationAside` / drawer.
40+
*
41+
* Kept separate from {@link color} so that changing only the address bar
42+
* (e.g. a sheet dialog dimming it to match its backdrop) no longer repaints
43+
* the navigation chrome — the inline write has higher specificity than any
44+
* `:root` declaration and previously caused a one-frame flicker.
45+
*/
46+
public set navColor(content: string) {
3247
this.doc.documentElement.style.setProperty('--tui-theme-color', content);
3348
}
3449
}

projects/demo-cypress/src/tests/sheet-dialog.cy.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import {ChangeDetectionStrategy, Component} from '@angular/core';
2-
import {TuiSheetDialog, type TuiSheetDialogOptions} from '@taiga-ui/addon-mobile';
1+
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
2+
import {
3+
TuiSheetDialog,
4+
type TuiSheetDialogOptions,
5+
TuiThemeColorService,
6+
} from '@taiga-ui/addon-mobile';
37
import {TuiButton, TuiRoot} from '@taiga-ui/core';
48

59
describe('TuiSheetDialog', () => {
@@ -56,3 +60,65 @@ describe('TuiSheetDialog', () => {
5660
cy.get('tui-sheet-dialog').compareSnapshot('tui-sheet-dialog__1');
5761
});
5862
});
63+
64+
describe('TuiSheetDialog theme-color', () => {
65+
@Component({
66+
imports: [TuiButton, TuiRoot, TuiSheetDialog],
67+
template: `
68+
<tui-root>
69+
<button
70+
tuiButton
71+
type="button"
72+
(click)="open = true"
73+
>
74+
Show
75+
</button>
76+
<ng-template
77+
let-observer
78+
[tuiSheetDialogOptions]="options"
79+
[(tuiSheetDialog)]="open"
80+
>
81+
<p>Sheet content</p>
82+
<footer>
83+
<button
84+
size="m"
85+
tuiButton
86+
type="button"
87+
(click)="observer.complete()"
88+
>
89+
Close
90+
</button>
91+
</footer>
92+
</ng-template>
93+
</tui-root>
94+
`,
95+
changeDetection: ChangeDetectionStrategy.OnPush,
96+
})
97+
class Test {
98+
private readonly theme = inject(TuiThemeColorService);
99+
100+
protected open = false;
101+
protected readonly options: Partial<TuiSheetDialogOptions> = {closable: false};
102+
103+
constructor() {
104+
// Base address-bar color the sheet must revert to on close.
105+
this.theme.color = '#000000';
106+
}
107+
}
108+
109+
beforeEach(() => cy.mount(Test));
110+
111+
it('tints the address bar while open and reverts on close', () => {
112+
cy.get('head meta[name="theme-color"]').should('have.attr', 'content', '#000000');
113+
114+
cy.contains('button', 'Show').click();
115+
cy.get('tui-sheet-dialog').should('be.visible');
116+
// Default themeColor (#404040) applied while the sheet is open.
117+
cy.get('head meta[name="theme-color"]').should('have.attr', 'content', '#404040');
118+
119+
cy.contains('tui-sheet-dialog button', 'Close').click();
120+
cy.get('tui-sheet-dialog').should('not.exist');
121+
// Reverts to the color that was set before opening, not a stale value.
122+
cy.get('head meta[name="theme-color"]').should('have.attr', 'content', '#000000');
123+
});
124+
});

projects/demo/src/pages/components/navigation/examples/2/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,6 @@ export default class Example {
4343
protected color = false;
4444

4545
protected onColor(color: boolean): void {
46-
this.theme.color = color ? 'purple' : 'black';
46+
this.theme.navColor = color ? 'purple' : 'black';
4747
}
4848
}

projects/demo/src/pages/components/navigation/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ export default class Page implements OnDestroy {
2323
];
2424

2525
constructor() {
26-
this.theme.color = 'black';
26+
this.theme.navColor = 'black';
2727
}
2828

2929
public ngOnDestroy(): void {
30-
this.theme.color = '#ff7043';
30+
this.theme.navColor = '#ff7043';
3131
}
3232
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<button
2+
tuiButton
3+
type="button"
4+
(click)="onClick()"
5+
>
6+
Show
7+
</button>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {Component, inject} from '@angular/core';
2+
import {changeDetection} from '@demo/emulate/change-detection';
3+
import {encapsulation} from '@demo/emulate/encapsulation';
4+
import {
5+
tuiSheetDialogOptionsProvider,
6+
TuiSheetDialogService,
7+
} from '@taiga-ui/addon-mobile';
8+
import {TuiButton} from '@taiga-ui/core';
9+
10+
@Component({
11+
imports: [TuiButton],
12+
templateUrl: './index.html',
13+
encapsulation,
14+
changeDetection,
15+
// `themeColor` sets the `<meta name="theme-color">` (mobile address bar)
16+
// while the sheet is open, then reverts on close. Scope the service so the
17+
// option applies only to sheets opened from here; provide it once at the
18+
// application root to make it the default everywhere. Pass `null` to opt out.
19+
providers: [
20+
TuiSheetDialogService,
21+
tuiSheetDialogOptionsProvider({themeColor: '#ff1493'}),
22+
],
23+
})
24+
export default class Example {
25+
private readonly service = inject(TuiSheetDialogService);
26+
27+
protected onClick(): void {
28+
this.service
29+
.open(
30+
'On mobile, the browser address bar turns <b>pink</b> while this sheet is open.',
31+
{
32+
label: 'Address bar color',
33+
},
34+
)
35+
.subscribe();
36+
}
37+
}

projects/demo/src/pages/components/sheet-dialog/index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,21 @@
147147
<code>Observable</code>
148148
(you can catch it with "catch" operator or onError handler)
149149
</tr>
150+
<tr
151+
name="[themeColor]"
152+
tuiDocAPIItem
153+
type="string | null"
154+
>
155+
sets
156+
<code>&lt;meta name="theme-color"&gt;</code>
157+
(mobile address bar) while the sheet is open, reverting on close. Configure via
158+
<code>tuiSheetDialogOptionsProvider</code>
159+
; defaults to
160+
<code>#404040</code>
161+
,
162+
<code>null</code>
163+
opts out.
164+
</tr>
150165
</table>
151166
</ng-template>
152167

0 commit comments

Comments
 (0)