Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/build-app-crm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,8 @@ jobs:
- name: 'Generate live editing and build samples'
run: npm run build:app-crm
env:
# esbuild is a Go binary: Node heap flags do not limit it, GOMEMLIMIT/GOGC do.
# Without this the app build OOM-kills the runner ("The operation was canceled").
GOMEMLIMIT: '6GiB'
GOGC: '50'
NODE_OPTIONS: "--max_old_space_size=4096"
4 changes: 4 additions & 0 deletions .github/workflows/build-app-lob.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,8 @@ jobs:
- name: 'Generate live editing and build samples'
run: npm run build:app-lob
env:
# esbuild is a Go binary: Node heap flags do not limit it, GOMEMLIMIT/GOGC do.
# Without this the app build OOM-kills the runner ("The operation was canceled").
GOMEMLIMIT: '6GiB'
GOGC: '50'
NODE_OPTIONS: "--max_old_space_size=4096"
4 changes: 4 additions & 0 deletions .github/workflows/build-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,8 @@ jobs:
- name: 'Generate live editing and build samples'
run: npm run build
env:
# esbuild is a Go binary: Node heap flags do not limit it, GOMEMLIMIT/GOGC do.
# Without this the app build OOM-kills the runner ("The operation was canceled").
GOMEMLIMIT: '6GiB'
GOGC: '50'
NODE_OPTIONS: "--max_old_space_size=4096"
7 changes: 6 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@
"tsConfig": "src/tsconfig.app.json",
"assets": [
"src/assets",
"src/favicon.ico"
"src/favicon.ico",
{
"glob": "igniteui-{angular,angular-dark,fluent-light,fluent-dark,bootstrap-light,bootstrap-dark,indigo-light,indigo-dark}.css",
"input": "node_modules/igniteui-angular/styles",
"output": "assets/themes"
}
],
"styles": [
"src/styles.scss"
Expand Down
47 changes: 10 additions & 37 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@

import { Component, HostListener, OnInit, DOCUMENT, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ThemeService } from './services/theme.service';

@Component({
selector: 'app-root',
Expand All @@ -10,50 +11,22 @@ import { RouterOutlet } from '@angular/router';
})
export class AppComponent implements OnInit {
private document = inject<Document>(DOCUMENT);
private themeService = inject(ThemeService);

public title = 'Samples';
private theme = 'default-theme';
private styleElem: HTMLStyleElement;
private typefacesLoaded = ['Titillium Web', 'Roboto'];
private typefaceUrl = 'https://fonts.googleapis.com/css?family=';

public ngOnInit() {
this.createThemeStyle();
this.document.body.classList.add('custom-body');
this.themeService.init();
}

// eslint-disable-next-line @typescript-eslint/member-ordering
/**
* Samples are embedded in the documentation as iframes and the theming
* widget shown next to each of them posts the visitor's theme selection
* down into the sample. Anything that is not such a message is left alone.
*/
@HostListener('window:message', ['$event'])
protected onMessage(e: MessageEvent) {
if (e.origin === e.data.origin && typeof e.data.themeStyle === 'string') {
this.styleElem.textContent = e.data.themeStyle;
const typeface = window.getComputedStyle(this.document.body).fontFamily.replace(/[\'\"]/g, '');
if (!(typeface.match(/,/g) || []).length &&
!this.typefacesLoaded.includes(typeface)) {
this.typefacesLoaded.push(typeface);
this.createTypefaceLink(typeface);
}
} else if (e.origin === e.data.origin && typeof e.data.theme === 'string') {
this.document.body.classList.remove(this.theme);
this.document.body.classList.add(e.data.theme);
this.theme = e.data.theme;
}
}

private createTypefaceLink(typeface: string) {
let typefaceElem = this.document.getElementById('igniteui-theme-typeface') as HTMLLinkElement;
if (!typefaceElem) {
typefaceElem = this.document.createElement('link');
typefaceElem.rel = 'stylesheet';
typefaceElem.id = 'igniteui-theme-typeface';
this.document.head.appendChild(typefaceElem);
}
typefaceElem.href = this.typefaceUrl + typeface.split(' ').join('+');
}

private createThemeStyle() {
this.styleElem = this.document.createElement('style');
this.styleElem.id = 'igniteui-theme';
this.document.head.appendChild(this.styleElem);
this.document.body.classList.add('custom-body');
this.themeService.handleMessage(e);
}
}
56 changes: 56 additions & 0 deletions src/app/services/theme.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { DOCUMENT, PLATFORM_ID, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { THEME_TOKEN, type IgxTheme } from 'igniteui-angular/core';
import { THEME_MESSAGE, ThemeService } from './theme.service';

describe('ThemeService theme validation', () => {
let service: ThemeService;
let document: Document;

beforeEach(() => {
document = window.document.implementation.createHTMLDocument();
TestBed.configureTestingModule({
providers: [
{ provide: DOCUMENT, useValue: document },
{ provide: PLATFORM_ID, useValue: 'browser' },
{ provide: THEME_TOKEN, useValue: signal<IgxTheme>('material') }
]
});
service = TestBed.inject(ThemeService);
service.init();
});

for (const theme of ['toString', 'constructor', '__proto__', 'hasOwnProperty']) {
it(`rejects inherited theme key ${theme}`, () => {
const link = document.getElementById('igniteui-theme');
const href = link.getAttribute('href');

expect(service.handleMessage(new MessageEvent('message', {
data: { type: THEME_MESSAGE, theme }
}))).toBeFalse();
expect(service.theme()).toBe('material');
expect(link.getAttribute('href')).toBe(href);

expect(service.set(theme, 'dark')).toBeTrue();
expect(service.theme()).toBe('material');
expect(service.mode()).toBe('dark');
expect(link.getAttribute('href')).toBe('assets/themes/igniteui-angular-dark.css');
});
}

for (const [theme, stylesheet] of [
['material', 'igniteui-angular.css'],
['fluent', 'igniteui-fluent-light.css'],
['bootstrap', 'igniteui-bootstrap-light.css'],
['indigo', 'igniteui-indigo-light.css']
] as const) {
it(`accepts supported theme ${theme}`, () => {
expect(service.handleMessage(new MessageEvent('message', {
data: { type: THEME_MESSAGE, theme }
}))).toBeTrue();
expect(service.theme()).toBe(theme);
expect(document.getElementById('igniteui-theme').getAttribute('href'))
.toBe(`assets/themes/${stylesheet}`);
});
}
});
231 changes: 231 additions & 0 deletions src/app/services/theme.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { isPlatformBrowser } from '@angular/common';
import { DOCUMENT, Injectable, PLATFORM_ID, inject, signal } from '@angular/core';
import { THEME_TOKEN, type IgxTheme } from 'igniteui-angular/core';

/**
* Message type posted by the documentation site's theming widget into the
* sample iframe. See `ThemeService.handleMessage`.
*/
export const THEME_MESSAGE = 'igd-sample-theme';

/** Color modes the theming widget can request. */
export type ThemeMode = 'light' | 'dark' | 'system';

/** `system` resolved against the OS preference. */
export type ColorScheme = 'light' | 'dark';

/**
* Prebuilt Ignite UI stylesheets, one per theme/scheme pair. They are copied
* out of `igniteui-angular/styles` into `assets/themes` at build time - see the
* `assets` section of angular.json.
*/
const THEME_STYLESHEETS: Record<IgxTheme, Record<ColorScheme, string>> = {
material: { light: 'igniteui-angular.css', dark: 'igniteui-angular-dark.css' },
fluent: { light: 'igniteui-fluent-light.css', dark: 'igniteui-fluent-dark.css' },
bootstrap: { light: 'igniteui-bootstrap-light.css', dark: 'igniteui-bootstrap-dark.css' },
indigo: { light: 'igniteui-indigo-light.css', dark: 'igniteui-indigo-dark.css' }
};

const THEMES_PATH = 'assets/themes/';

/** Id of the `<link>` in index.html that carries the active theme. */
const THEME_LINK_ID = 'igniteui-theme';

const TYPEFACE_URL = 'https://fonts.googleapis.com/css?family=';
const TYPEFACE_WEIGHTS = ':300,400,600,700';

/**
* Font families that never need to be fetched: generic CSS keywords and the
* system fonts the Bootstrap theme is built on.
*/
const LOCAL_FAMILIES = new Set([
'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'ui-sans-serif', 'ui-serif', 'ui-monospace',
'segoe ui', 'helvetica', 'helvetica neue', 'arial', 'roboto'
]);

const isTheme = (value: unknown): value is IgxTheme =>
typeof value === 'string' && Object.hasOwn(THEME_STYLESHEETS, value);

const isMode = (value: unknown): value is ThemeMode =>
value === 'light' || value === 'dark' || value === 'system';

/**
* Swaps the Ignite UI theme of the samples application at runtime.
*
* Samples are embedded in the documentation as iframes and the site's theming
* widget posts the visitor's selection into them, so the whole application has
* to be able to move between the Material, Fluent, Bootstrap and Indigo themes
* (each in a light and a dark variant) without reloading. That is done by
* pointing a single `<link>` at one of the prebuilt stylesheets instead of
* compiling a single theme into `styles.scss`.
*/
@Injectable({ providedIn: 'root' })
export class ThemeService {
private document = inject(DOCUMENT);
private themeToken = inject(THEME_TOKEN);
private isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

/** The selected theme. */
public readonly theme = signal<IgxTheme>('material');

/** The selected color mode, `system` included. */
public readonly mode = signal<ThemeMode>('light');

/** The color mode actually in effect, with `system` already resolved. */
public readonly colorScheme = signal<ColorScheme>('light');

private prefersDark: MediaQueryList;
private typefaces = new Set<string>();

/**
* Applies the initial theme and starts following the OS color preference.
* Safe to call more than once.
*/
public init(): void {
if (this.isBrowser && !this.prefersDark) {
this.prefersDark = this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)');
// Only relevant while the widget is on `system`; `apply` ignores it otherwise.
this.prefersDark?.addEventListener('change', () => this.apply());
}

this.apply();
}

/**
* Handles a `message` event coming from the documentation site.
*
* The widget posts `{ type: 'igd-sample-theme', theme, themeName, mode }`
* once when the iframe loads and again on every change. The message is
* trusted for its shape rather than for its origin - the documentation is
* served from a number of hosts, and the only thing acted upon is a choice
* between the four known themes and the three known modes.
*
* @returns whether the message was recognized and applied.
*/
public handleMessage(event: MessageEvent): boolean {
const data = event.data;

if (!data || (data.type !== THEME_MESSAGE && data.event !== THEME_MESSAGE)) {
return false;
}

return this.set(data.theme ?? data.themeName, data.mode);
}

/**
* Switches to `theme` / `mode`. Unknown or missing values leave the
* corresponding part of the selection untouched.
*
* @returns whether anything was recognized.
*/
public set(theme?: unknown, mode?: unknown): boolean {
let known = false;

if (isTheme(theme)) {
this.theme.set(theme);
known = true;
}

if (isMode(mode)) {
this.mode.set(mode);
known = true;
}

if (known) {
this.apply();
}

return known;
}

private apply(): void {
if (!this.isBrowser) {
return;
}

const theme = this.theme();
const scheme = this.resolveScheme();
this.colorScheme.set(scheme);

// Exposed for samples that need to react to the selection. Deliberately
// not used to paint a page background: the documentation's Sample
// component owns the surface behind the iframe.
const root = this.document.documentElement;
root.dataset.igTheme = theme;
root.dataset.igMode = scheme;

const href = THEMES_PATH + THEME_STYLESHEETS[theme][scheme];
const link = this.themeLink();

if (!link.getAttribute('href')?.endsWith(href)) {
link.addEventListener('load', () => this.loadTypeface(), { once: true });
link.href = href;
}

// Components such as igx-input-group render differently per theme and
// take the theme from this token rather than from the stylesheet.
this.themeToken.set(theme);
}

private resolveScheme(): ColorScheme {
const mode = this.mode();

if (mode !== 'system') {
return mode;
}

return this.prefersDark?.matches ? 'dark' : 'light';
}

private themeLink(): HTMLLinkElement {
let link = this.document.getElementById(THEME_LINK_ID) as HTMLLinkElement;

if (!link) {
link = this.document.createElement('link');
link.id = THEME_LINK_ID;
link.rel = 'stylesheet';
// Ahead of everything else in the head, so that the application's
// own styles keep overriding the theme as they do in index.html.
this.document.head.insertBefore(link, this.document.head.firstChild);
}

return link;
}

/**
* Each theme comes with its own typeface. Once a theme stylesheet is in
* place, take the first family off `--ig-font-family` and pick it up from
* Google Fonts unless it is one the browser already has.
*/
private loadTypeface(): void {
const view = this.document.defaultView;

if (!view) {
return;
}

const family = view.getComputedStyle(this.document.body)
.getPropertyValue('--ig-font-family')
.split(',')[0]
.trim()
.replace(/['"]/g, '');

if (!family || family.startsWith('-') || LOCAL_FAMILIES.has(family.toLowerCase())) {
return;
}

if (this.typefaces.has(family)) {
return;
}

this.typefaces.add(family);

// A link per family, so that switching back to an earlier theme does
// not have to fetch its typeface again.
const link = this.document.createElement('link');
link.rel = 'stylesheet';
link.href = TYPEFACE_URL + family.split(' ').join('+') + TYPEFACE_WEIGHTS;
this.document.head.appendChild(link);
}
}
Loading