diff --git a/.github/workflows/build-app-crm.yml b/.github/workflows/build-app-crm.yml index 2bfd89874..77062edca 100644 --- a/.github/workflows/build-app-crm.yml +++ b/.github/workflows/build-app-crm.yml @@ -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" diff --git a/.github/workflows/build-app-lob.yml b/.github/workflows/build-app-lob.yml index 699c333d3..47fe1767a 100644 --- a/.github/workflows/build-app-lob.yml +++ b/.github/workflows/build-app-lob.yml @@ -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" diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 3c19b5984..ae90b4862 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -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" diff --git a/angular.json b/angular.json index 1509ba457..f3c2b57e3 100644 --- a/angular.json +++ b/angular.json @@ -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" diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 104654744..10970e42e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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', @@ -10,50 +11,22 @@ import { RouterOutlet } from '@angular/router'; }) export class AppComponent implements OnInit { private document = inject(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); } } diff --git a/src/app/services/theme.service.spec.ts b/src/app/services/theme.service.spec.ts new file mode 100644 index 000000000..aa97a511d --- /dev/null +++ b/src/app/services/theme.service.spec.ts @@ -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('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}`); + }); + } +}); diff --git a/src/app/services/theme.service.ts b/src/app/services/theme.service.ts new file mode 100644 index 000000000..bc24357df --- /dev/null +++ b/src/app/services/theme.service.ts @@ -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> = { + 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 `` 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 `` 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('material'); + + /** The selected color mode, `system` included. */ + public readonly mode = signal('light'); + + /** The color mode actually in effect, with `system` already resolved. */ + public readonly colorScheme = signal('light'); + + private prefersDark: MediaQueryList; + private typefaces = new Set(); + + /** + * 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); + } +} diff --git a/src/index.html b/src/index.html index 9920eced3..65e26031d 100644 --- a/src/index.html +++ b/src/index.html @@ -12,6 +12,12 @@ + + + diff --git a/src/styles.scss b/src/styles.scss index 8c144ff6d..03a2dd70b 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -5,6 +5,14 @@ @use 'app-layout'; @import "tailwindcss"; +// The Ignite UI theme itself is deliberately NOT compiled into this file. Every +// sample is embedded in the documentation next to a theming widget that can +// switch it between the Material, Fluent, Bootstrap and Indigo themes in light +// or dark, so the theme is loaded as a swappable stylesheet instead - see the +// `igniteui-theme` link in index.html, the `assets/themes` entry in angular.json +// and `src/app/services/theme.service.ts`. A theme compiled in here would sit +// later in the cascade and win over the one the visitor picked. + // Specifies large size for all components to match the previous defaults // This may not be needed for your project. Please consult https://www.infragistics.com/products/ignite-ui-angular/angular/components/general/update-guide for more details. :root { @@ -19,6 +27,13 @@ body { margin: 0; } +// Nothing here paints a page background per color mode on purpose. The sample +// is embedded in an iframe and the documentation's Sample component draws the +// surface behind it from the same mode the theming widget posts down here, so +// the iframe is left transparent for it to show through. `ThemeService` still +// reflects `data-ig-theme` / `data-ig-mode` onto the root element for samples +// that need to react to the selection themselves. + body.custom-body:has( app-badge-overview, app-badge-styling, @@ -34,16 +49,8 @@ body.custom-body:has( background: transparent; } -@include core(); -@include typography(); -@include theme( - $palette: $palette, - $schema: $schema -); - -:root { - @include palette($palette); -} +// `core()`, `typography()`, `theme()` and the root palette all ship inside the +// prebuilt stylesheets loaded through the `igniteui-theme` link (see above). .light-theme { @include light-theme($palette);