-
Notifications
You must be signed in to change notification settings - Fork 27.2k
docs-infra: theming and dark mode #41129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
57fc798
feat(docs-infra): port over material io theming logic to angular io
AleksanderBodurri 1b8af62
refactor(docs-infra): rename namespaced scss to index files
AleksanderBodurri 38c2503
refactor(docs-infra): grab scss constants using scss use syntax inste…
AleksanderBodurri 25cc1be
refactor(docs-infra): grab scss mixins with scss use syntax instead o…
AleksanderBodurri eeb653e
feat(docs-infra): create typography and layout theme files
AleksanderBodurri efbdd63
feat(docs-infra): create module theme files
AleksanderBodurri 7ca90ae
feat(docs-infra): implement dark mode
AleksanderBodurri File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
92 changes: 92 additions & 0 deletions
92
aio/src/app/shared/theme-picker/theme-toggle.component.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | ||
| import { DebugElement } from '@angular/core'; | ||
| import { By } from '@angular/platform-browser'; | ||
| import { ThemeStorage, ThemeToggleComponent } from './theme-toggle.component'; | ||
|
|
||
| class FakeThemeStorage implements ThemeStorage { | ||
| fakeStorage: string | null = null; | ||
|
|
||
| getThemePreference(): string | null { | ||
| return this.fakeStorage; | ||
| } | ||
|
|
||
| setThemePreference(isDark: boolean): void { | ||
| this.fakeStorage = String(isDark); | ||
| } | ||
| } | ||
|
|
||
| let themeStorage: ThemeStorage; | ||
|
|
||
| // Verify that FakeThemeStorage behaves like ThemeStorage would | ||
| describe('FakeThemeStorage', () => { | ||
| beforeEach(() => { | ||
| themeStorage = new FakeThemeStorage(); | ||
| }); | ||
|
|
||
| it('should have null stored initially', () => { | ||
| expect(themeStorage.getThemePreference()).toBeNull(); | ||
| }); | ||
|
|
||
| it('should store true as a string if isDark is true', () => { | ||
| themeStorage.setThemePreference(true); | ||
| expect(themeStorage.getThemePreference()).toBe('true'); | ||
| }); | ||
|
|
||
| it('should store false as a string if isDark is false', () => { | ||
| themeStorage.setThemePreference(false); | ||
| expect(themeStorage.getThemePreference()).toBe('false'); | ||
| }); | ||
| }); | ||
|
|
||
|
|
||
| let component: ThemeToggleComponent; | ||
| let fixture: ComponentFixture<ThemeToggleComponent>; | ||
| let debugElement: DebugElement; | ||
|
|
||
| describe('ThemeToggleComponent', () => { | ||
| beforeEach(async () => { | ||
| await TestBed.configureTestingModule({ | ||
| declarations: [ ThemeToggleComponent ], | ||
| providers: [ { provide: ThemeStorage, useClass: FakeThemeStorage } ], | ||
| }) | ||
| .compileComponents(); | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| fixture = TestBed.createComponent(ThemeToggleComponent); | ||
| component = fixture.componentInstance; | ||
| debugElement = fixture.debugElement; | ||
| fixture.detectChanges(); | ||
| }); | ||
|
|
||
| it('should create', () => { | ||
| expect(component).toBeTruthy(); | ||
| }); | ||
|
|
||
| it('should show toggle button', () => { | ||
| expect(getToggleButton()).toBeDefined(); | ||
| }); | ||
|
|
||
| it('should toggle between light and dark mode', () => { | ||
| expect(component.getThemeName()).toBe('light'); | ||
| getToggleButton().click(); | ||
| expect(component.getThemeName()).toBe('dark'); | ||
| }); | ||
|
|
||
| it('should have the correct next theme', () => { | ||
| component.toggleTheme(); | ||
| expect(component.getThemeName()).toBe('dark'); | ||
| component.toggleTheme(); | ||
| expect(component.getThemeName()).toBe('light'); | ||
| }); | ||
|
|
||
| it('should have the correct aria-label', () => { | ||
| expect(component.getToggleLabel()).toBe(`Switch to dark mode`); | ||
| component.toggleTheme(); | ||
| expect(component.getToggleLabel()).toBe(`Switch to light mode`); | ||
| }); | ||
| }); | ||
|
|
||
| function getToggleButton(): HTMLButtonElement { | ||
| return debugElement.query(By.css('button')).nativeElement; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { DOCUMENT } from '@angular/common'; | ||
| import { Component, Inject, Injectable } from '@angular/core'; | ||
|
|
||
| /** Injectable facade around localStorage for theme preference to make testing easier. */ | ||
| @Injectable({ providedIn: 'root' }) | ||
| export class ThemeStorage { | ||
| getThemePreference(): string | null { | ||
| // Wrap localStorage access in try/catch because user agents can block localStorage. If it is | ||
| // blocked, we treat it as if no preference was previously stored. | ||
| try { | ||
| return localStorage.getItem('aio-theme'); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| setThemePreference(isDark: boolean): void { | ||
| // Wrap localStorage access in try/catch because user agents can block localStorage. If it | ||
| // fails, we persist nothing. | ||
| try { | ||
| localStorage.setItem('aio-theme', String(isDark)); | ||
| } catch { } | ||
| } | ||
| } | ||
|
|
||
| @Component({ | ||
| selector: 'aio-theme-toggle', | ||
| template: ` | ||
| <button mat-icon-button type="button" (click)="toggleTheme()" | ||
| [title]="getToggleLabel()" [attr.aria-label]="getToggleLabel()"> | ||
| <mat-icon> | ||
| {{ isDark ? 'light' : 'dark' }}_mode | ||
| </mat-icon> | ||
| </button> | ||
| `, | ||
| }) | ||
| export class ThemeToggleComponent { | ||
| isDark = false; | ||
|
|
||
| constructor(@Inject(DOCUMENT) private document: Document, private readonly themeStorage: ThemeStorage) { | ||
| this.initializeThemeFromPreferences(); | ||
| } | ||
|
|
||
| toggleTheme(): void { | ||
| this.isDark = !this.isDark; | ||
| this.updateRenderedTheme(); | ||
| } | ||
|
|
||
| private initializeThemeFromPreferences(): void { | ||
| // Check whether there's an explicit preference in localStorage. | ||
| const storedPreference = this.themeStorage.getThemePreference(); | ||
|
|
||
| // If we do have a preference in localStorage, use that. Otherwise, | ||
| // initialize based on the prefers-color-scheme media query. | ||
| if (storedPreference) { | ||
| this.isDark = storedPreference === 'true'; | ||
| } else { | ||
| this.isDark = matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; | ||
| } | ||
|
|
||
| const initialTheme = this.document.querySelector('#aio-initial-theme'); | ||
| if (initialTheme) { | ||
| // todo(aleksanderbodurri): change to initialTheme.remove() when ie support is dropped | ||
| initialTheme.parentElement?.removeChild(initialTheme); | ||
| } | ||
|
|
||
| const themeLink = this.document.createElement('link'); | ||
| themeLink.id = 'aio-custom-theme'; | ||
| themeLink.rel = 'stylesheet'; | ||
| themeLink.href = `${this.getThemeName()}-theme.css`; | ||
| this.document.head.appendChild(themeLink); | ||
| } | ||
|
|
||
| getThemeName(): string { | ||
| return this.isDark ? 'dark' : 'light'; | ||
| } | ||
|
|
||
| getToggleLabel(): string { | ||
| return `Switch to ${this.isDark ? 'light' : 'dark'} mode`; | ||
| } | ||
|
|
||
| private updateRenderedTheme(): void { | ||
| // If we're calling this method, the user has explicitly interacted with the theme toggle. | ||
| const customLinkElement = this.document.getElementById('aio-custom-theme') as HTMLLinkElement | null; | ||
| if (customLinkElement) { | ||
| customLinkElement.href = `${this.getThemeName()}-theme.css`; | ||
| } | ||
|
|
||
| this.themeStorage.setThemePreference(this.isDark); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,4 +2,4 @@ | |
| BASE STYLES | ||
| ============================== */ | ||
|
|
||
| @import 'typography'; | ||
| @forward 'typography'; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| @use '../constants'; | ||
|
|
||
| @mixin theme($theme) { | ||
| $is-dark-theme: map-get($theme, is-dark); | ||
|
|
||
| body { | ||
| color: if($is-dark-theme, constants.$offwhite, constants.$darkgray); | ||
| } | ||
|
|
||
| h1, | ||
| h2, | ||
| h3, | ||
| h4, | ||
| h5, | ||
| h6 { | ||
| color: if($is-dark-theme, constants.$offwhite, constants.$deepgray); | ||
| } | ||
|
|
||
| h6 { | ||
| color: if($is-dark-theme, constants.$offwhite, constants.$mediumgray); | ||
| } | ||
|
|
||
| h2 { | ||
| border-top: 1px solid if($is-dark-theme, constants.$mediumgray, constants.$lightgray); | ||
| } | ||
|
|
||
| p, | ||
| ol, | ||
| ul, | ||
| ol, | ||
| li, | ||
| input, | ||
| a { | ||
| color: if($is-dark-theme, constants.$white, constants.$darkgray); | ||
| } | ||
|
|
||
| .app-toolbar a { | ||
| color: constants.$white; | ||
| } | ||
|
|
||
| code { | ||
| color: if($is-dark-theme, constants.$white, constants.$darkgray); | ||
| } | ||
|
|
||
| .sidenav-content a { | ||
| color: if($is-dark-theme, constants.$lightblue, constants.$blue); | ||
| } | ||
|
|
||
| .error-text { | ||
| color: constants.$brightred; | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we add an event listener to sync with theme with the System? In most cases, the theme changes based on the time of the day.
Using local storage in this case will also cause the theme not to be synced with the system on refresh.
I don't know if we want to go down this route but IMHO theming has 3 states and not 2.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is this based on? While Apple and Android devices support this option, but I think the default is "light" and people can choose either "dark" or "auto" (manually setting up a schedule on Android). On Windows "auto" is not an option. ChromeOS doesn't support system themes yet.
That was the intent- if a user never touches the theme toggle, it will respect their system setting. If they manually change the theme, it will remember their choice. I suppose we could drop the part about remembering the user's choice, but my experience has always been that I want sites to retain this preference (especially since not all OSes support dark mode yet).
IMO adding extra code to react to changes in the system theme is overkill.
@AleksanderBodurri the media issue there is due to the critical CSS inlining built into Angular CLI. I see two options:
mediaattribute to having a singlelinkelement that does something like this:Both of these approaches effectively do the same thing- force the CSS into a separate load. I don't see any way around the inherent limitation of media queries being a purely client-side construct here.