From 16cedd386b57f83df2446dfa4e5170c8bfd6c397 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 7 Aug 2026 10:59:52 +0300 Subject: [PATCH 01/15] feat(core): theme service refactor (#DS-3003) --- .../design-tokens-viewers/tokens-overview.ts | 12 +- .../docsearch/docsearch.directive.ts | 19 +- .../app/components/navbar/navbar.component.ts | 122 ++------ .../components/navbar/navbar.template.html | 6 +- .../components/welcome/welcome.component.ts | 23 +- apps/docs/src/app/config.ts | 9 +- docs/guides/migration.en.md | 25 ++ packages/components-dev/theme-toggle.ts | 15 +- .../core/services/theme.service.spec.ts | 263 ++++++++++++++++++ .../components/core/services/theme.service.ts | 263 +++++++++++++++--- .../empty-state-content-example.ts | 13 +- .../notification-center-empty-example.ts | 11 +- .../notification-center-error-example.ts | 11 +- ...fication-center-infinite-scroll-example.ts | 12 +- .../notification-center-overview-example.ts | 11 +- .../notification-center-popover-example.ts | 11 +- .../notification-center-push-example.ts | 11 +- tools/public_api_guard/components/core.api.md | 103 ++++--- 18 files changed, 667 insertions(+), 273 deletions(-) create mode 100644 packages/components/core/services/theme.service.spec.ts diff --git a/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts b/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts index 0b644fa931..127b4b735e 100644 --- a/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts +++ b/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts @@ -4,13 +4,14 @@ import { AfterViewInit, ChangeDetectionStrategy, Component, + effect, inject, Injector, input, signal, viewChild } from '@angular/core'; -import { KBQ_WINDOW, ThemeService } from '@koobiq/components/core'; +import { KBQ_WINDOW, KbqThemeService } from '@koobiq/components/core'; import { KbqTableModule } from '@koobiq/components/table'; import { KbqTooltipTrigger } from '@koobiq/components/tooltip'; import { DocsLocaleState } from '../../services/locale'; @@ -20,7 +21,7 @@ import { DocsComponentViewerWrapperComponent } from '../component-viewer/compone import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, UrlSegment } from '@angular/router'; -import { map, skip } from 'rxjs'; +import { map } from 'rxjs'; import { DocsAnchorsComponent } from '../anchors/anchors.component'; import { docsData as borderRadius } from './data/border-radius'; @@ -200,7 +201,7 @@ export class DocsTokensOverview extends DocsLocaleState implements AfterViewInit protected readonly wrapper = viewChild.required(DocsComponentViewerWrapperComponent); protected readonly anchors = viewChild.required(DocsAnchorsComponent); - protected readonly themeService = inject(ThemeService); + protected readonly themeService = inject(KbqThemeService); protected readonly window = inject(KBQ_WINDOW); protected readonly document = inject(DOCUMENT); protected readonly activatedRoute = inject(ActivatedRoute); @@ -237,7 +238,8 @@ export class DocsTokensOverview extends DocsLocaleState implements AfterViewInit constructor() { super(); - this.themeService.current.pipe(skip(1), takeUntilDestroyed()).subscribe(() => { + effect(() => { + this.themeService.resolvedMode(); this.tokensInfo.set(this.calculateViewData()); }); @@ -260,7 +262,7 @@ export class DocsTokensOverview extends DocsLocaleState implements AfterViewInit protected calculateViewData(): DocsTokensInfo[] { const styles = this.window.getComputedStyle(this.document.body); - const themeKey = this.themeService.getTheme()?.className ?? 'default'; + const themeKey = this.themeService.currentTheme()?.className ?? 'default'; const themeCache = this.tokenValueCache.get(themeKey) ?? new Map(); this.tokenValueCache.set(themeKey, themeCache); diff --git a/apps/docs/src/app/components/docsearch/docsearch.directive.ts b/apps/docs/src/app/components/docsearch/docsearch.directive.ts index ca02ed16ff..13d156a6c1 100644 --- a/apps/docs/src/app/components/docsearch/docsearch.directive.ts +++ b/apps/docs/src/app/components/docsearch/docsearch.directive.ts @@ -1,7 +1,7 @@ import { afterNextRender, DestroyRef, Directive, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import docsearch, { DocSearchInstance, DocSearchProps } from '@docsearch/js'; -import { KBQ_WINDOW, ThemeService } from '@koobiq/components/core'; +import { KBQ_WINDOW, KbqThemeService } from '@koobiq/components/core'; import { combineLatest } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; import { DocsLocale } from '../../constants/locale'; @@ -129,7 +129,11 @@ const TRANSLATIONS: Record = { export class DocsDocsearchDirective extends DocsLocaleState { private readonly window = inject(KBQ_WINDOW); private readonly destroyRef = inject(DestroyRef); - private readonly theme = inject(ThemeService); + private readonly theme = inject(KbqThemeService); + + // captured eagerly (in the constructor's injection context), since `toObservable()` can't be + // called lazily from the `afterNextRender()` callback in `init()` + private readonly resolvedMode$ = toObservable(this.theme.resolvedMode); private instance: DocSearchInstance | null = null; @@ -148,13 +152,8 @@ export class DocsDocsearchDirective extends DocsLocaleState { private init(): void { combineLatest([ - this.theme.current.pipe( - map( - (theme) => - (theme?.className.replace('kbq-', '') === 'dark' - ? 'dark' - : 'light') satisfies DocSearchProps['theme'] - ), + this.resolvedMode$.pipe( + map((mode) => (mode === 'dark' ? 'dark' : 'light') satisfies DocSearchProps['theme']), distinctUntilChanged() ), this.docsLocaleService.changes.pipe(distinctUntilChanged()) diff --git a/apps/docs/src/app/components/navbar/navbar.component.ts b/apps/docs/src/app/components/navbar/navbar.component.ts index be1952e513..f5fd45bc96 100644 --- a/apps/docs/src/app/components/navbar/navbar.component.ts +++ b/apps/docs/src/app/components/navbar/navbar.component.ts @@ -1,16 +1,8 @@ import { AsyncPipe } from '@angular/common'; -import { - afterNextRender, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - inject, - OnDestroy, - ViewEncapsulation -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core'; import { RouterLink } from '@angular/router'; import { KbqButtonModule } from '@koobiq/components/button'; -import { KBQ_WINDOW, KbqTheme, KbqThemeSelector, ThemeService } from '@koobiq/components/core'; +import { KbqThemeMode, KbqThemeService } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; @@ -22,7 +14,12 @@ import { DOCS_TRANSLATIONS } from 'src/app/services/i18n'; import { DocsLocaleState } from 'src/app/services/locale'; import { DocsDocStates, DocsNavbarState } from '../../services/doc-states'; import { DocsDocsearchDirective } from '../docsearch/docsearch.directive'; -import { DocsNavbarProperty } from './navbar-property'; + +/** A theme mode selectable from the navbar's theme dropdown. */ +interface DocsThemeOption { + mode: KbqThemeMode; + title: Record; +} @Component({ selector: 'docs-navbar', @@ -45,109 +42,30 @@ import { DocsNavbarProperty } from './navbar-property'; class: 'docs-navbar' } }) -export class DocsNavbarComponent extends DocsLocaleState implements OnDestroy { - private readonly window = inject(KBQ_WINDOW); - private readonly cdr = inject(ChangeDetectorRef); - private readonly themeService = inject(ThemeService); +export class DocsNavbarComponent extends DocsLocaleState { + private readonly themeService = inject(KbqThemeService); readonly docStates = inject(DocsDocStates); - readonly themeSwitch: DocsNavbarProperty; - - // To add for checking of current color theme of OS preferences - private readonly colorAutomaticTheme = this.window.matchMedia('(prefers-color-scheme: light)'); - - private readonly kbqThemes: (KbqTheme & { title: Record })[] = [ - { - name: 'system', - className: this.colorAutomaticTheme.matches ? KbqThemeSelector.Default : KbqThemeSelector.Dark, - selected: false, - title: DOCS_TRANSLATIONS.themeSystem - }, - { - name: 'light', - className: KbqThemeSelector.Default, - selected: false, - title: DOCS_TRANSLATIONS.themeLight - }, - { - name: 'dark', - className: KbqThemeSelector.Dark, - selected: false, - title: DOCS_TRANSLATIONS.themeDark - } + /** Options shown in the theme dropdown. `auto` follows the OS color scheme, handled inside `KbqThemeService`. */ + readonly themeOptions: DocsThemeOption[] = [ + { mode: 'auto', title: DOCS_TRANSLATIONS.themeSystem }, + { mode: 'light', title: DOCS_TRANSLATIONS.themeLight }, + { mode: 'dark', title: DOCS_TRANSLATIONS.themeDark } ]; + /** The currently selected mode — persistence and OS-preference resolution are handled by `KbqThemeService`. */ + readonly mode = computed(() => this.themeService.mode()); + readonly opened$: Observable = this.docStates.navbarMenu.pipe( map((state) => state === DocsNavbarState.Opened) ); - constructor() { - super(); - - // set custom theme configs for light/dark themes - this.themeService.setThemes(this.kbqThemes); - - this.themeSwitch = new DocsNavbarProperty({ - property: 'docs_theme', - data: this.kbqThemes, - updateSelected: false - }); - - // set theme when retrieval from storage completed - afterNextRender(() => { - this.themeService.setTheme(this.themeSwitch.currentValue); - // prevent NG0100 error - this.cdr.markForCheck(); - }); - - try { - // Chrome & Firefox - this.colorAutomaticTheme.addEventListener('change', this.setAutoTheme); - } catch { - try { - // Safari - this.colorAutomaticTheme.addListener(this.setAutoTheme); - } catch (errSafari) { - console.error(errSafari); - } - } - } - - ngOnDestroy() { - // NOTE: `ThemeService` is a root singleton and owns its own lifecycle — the navbar must not - // tear it down. Only this component's own media-query listener is removed here. - try { - this.colorAutomaticTheme.removeEventListener('change', this.setAutoTheme); - } catch (err) { - console.error(err); - } - } - toggleMenu() { this.docStates.toggleNavbarMenu(); } - setTheme(i: number) { - // should be set to keep theme index in storage - this.themeSwitch.setValue(i); - this.themeService.setTheme(i); + setTheme(mode: KbqThemeMode) { + this.themeService.setMode(mode); } - - private setAutoTheme = (e: MediaQueryListEvent) => { - if (!this.themeService.themes[0]) return; - - this.themeService.themes[0] = { - ...this.themeService.themes[0], - className: e.matches ? KbqThemeSelector.Default : KbqThemeSelector.Dark - }; - - if (this.themeService.themes[0].selected) { - this.setTheme(0); - } - - // The media-query listener runs outside Angular's event bindings, so trigger a check - // explicitly for the OnPush theme dropdown. - this.cdr.markForCheck(); - }; } diff --git a/apps/docs/src/app/components/navbar/navbar.template.html b/apps/docs/src/app/components/navbar/navbar.template.html index e671906d29..680e36f743 100644 --- a/apps/docs/src/app/components/navbar/navbar.template.html +++ b/apps/docs/src/app/components/navbar/navbar.template.html @@ -80,9 +80,9 @@ {{ t('themeGroupHeader') }} - @for (theme of themeSwitch.data; track theme) { - } diff --git a/apps/docs/src/app/components/welcome/welcome.component.ts b/apps/docs/src/app/components/welcome/welcome.component.ts index 025879891d..55a2e0b985 100644 --- a/apps/docs/src/app/components/welcome/welcome.component.ts +++ b/apps/docs/src/app/components/welcome/welcome.component.ts @@ -1,12 +1,20 @@ import { NgOptimizedImage } from '@angular/common'; -import { ChangeDetectionStrategy, Component, ElementRef, inject, OnInit, ViewEncapsulation } from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + OnInit, + ViewEncapsulation +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { RouterLink } from '@angular/router'; -import { ThemeService } from '@koobiq/components/core'; +import { KbqThemeService } from '@koobiq/components/core'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; import { fromEvent } from 'rxjs'; -import { debounceTime, map } from 'rxjs/operators'; +import { debounceTime } from 'rxjs/operators'; import { DocsDocStates } from 'src/app/services/doc-states'; import { DocsLocaleState } from 'src/app/services/locale'; import { docsGetCategories, DocsStructureCategory } from '../../structure'; @@ -30,13 +38,10 @@ import { DocsRegisterHeaderDirective } from '../register-header/register-header. } }) export class DocsWelcomeComponent extends DocsLocaleState implements OnInit { - private readonly themeService = inject(ThemeService); + private readonly themeService = inject(KbqThemeService); protected structureCategories: DocsStructureCategory[]; - readonly currentTheme = toSignal( - this.themeService.current.pipe(map((theme) => theme?.className.replace('kbq-', '') ?? 'light')), - { initialValue: 'light' } - ); + readonly currentTheme = computed(() => this.themeService.resolvedMode()); private readonly elementRef = inject>(ElementRef); private readonly docStates = inject(DocsDocStates); diff --git a/apps/docs/src/app/config.ts b/apps/docs/src/app/config.ts index a2763c7275..e1148a1e92 100644 --- a/apps/docs/src/app/config.ts +++ b/apps/docs/src/app/config.ts @@ -3,7 +3,12 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, TitleStrategy } from '@angular/router'; -import { KBQ_LOCALE_SERVICE, KbqLocaleService, kbqLocaleServiceLangAttrNameProvider } from '@koobiq/components/core'; +import { + KBQ_LOCALE_SERVICE, + KbqLocaleService, + kbqLocaleServiceLangAttrNameProvider, + kbqThemeProvider +} from '@koobiq/components/core'; import { kbqIconsResolverProvider } from '@koobiq/components/icon'; import { DOCS_ROUTES } from './routes'; import { docsProvideAnalytics } from './services/analytics'; @@ -14,6 +19,8 @@ export const appConfig: ApplicationConfig = { providers: [ { provide: KBQ_LOCALE_SERVICE, useClass: KbqLocaleService }, kbqLocaleServiceLangAttrNameProvider('examples-lang'), + // keeps the pre-existing localStorage key so users who already picked a theme don't lose it + kbqThemeProvider({ storageKey: 'docs_theme' }), kbqIconsResolverProvider((name) => `/assets/SVGIcons/${name.replace(/^kbq-/, '')}.svg`), provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(DOCS_ROUTES), diff --git a/docs/guides/migration.en.md b/docs/guides/migration.en.md index 99a42d591a..3e82d98e1d 100644 --- a/docs/guides/migration.en.md +++ b/docs/guides/migration.en.md @@ -16,6 +16,7 @@ New versions include improvements but also contain **breaking changes**; they mu 10. **20.3.0**: button supported colors — a default color of its own per style. 11. **20.3.0**: the button-toggle review — ARIA semantics, keyboard navigation and signal inputs. 12. **20.3.0**: the form-field review — signals, accessibility and the removal of `mixinColor`. +13. **20.3.0**: the theme service review — signals, `auto` mode and built-in persistence. ### 1. Upgrade to 18.5.3 @@ -741,6 +742,30 @@ A receiver is matched by its explicit type annotation (`KbqFormField`, `KbqHint` **Stylesheets that fought `!important`.** `.kbq-form-field_no-borders` and `.kbq-form-field_in-overlay` used `!important` to beat the state theme; they now override the `--kbq-form-field-*` tokens instead. The computed result is the same, but an override written specifically to outrank the old `!important` can be simplified. +### 13. Theme service review (20.3.0) + +`ThemeService` moved to signals, gained a built-in `auto` mode that follows the OS color scheme, and now persists the selected mode to `localStorage` out of the box. `ThemeService` keeps working under its old name and the deprecated `KbqTheme.selected` field is still kept in sync — nothing is forced to change, but new code should move to `KbqThemeService`. + +**It's `KbqThemeService` now.** `ThemeService` is exported as a `@deprecated` alias of `KbqThemeService` and will be removed in a future major version. There is no `ng update` schematic for the rename — swap the import when convenient. + +**`current` (a `BehaviorSubject`) is deprecated in favor of two signals.** It still exists and stays in sync, so `current.value` and `current.pipe(...)` keep working. `mode()` is the selected mode (`'auto' | 'light' | 'dark'` or a custom theme name); `currentTheme()` is the resolved `KbqTheme` object, equivalent to `current.value`. `resolvedMode()` gives you `mode()` with `'auto'` already resolved to `'light'`/`'dark'`. + +```ts +// Before +themeService.current.pipe(map((theme) => theme?.className)).subscribe(...); + +// After +themeService.currentTheme(); // read directly, or wrap with toObservable() if you need a stream +``` + +**`setTheme(index | theme)` is deprecated in favor of `setMode(name)`.** Selecting by array index was fragile once `auto` stopped being a regular registered theme. `setMode('light')` / `setMode('dark')` cover a fixed mode; `setAuto()` and `toggle()` are the two convenience methods kept for the common cases actually used in this library — there is no `setLight()`/`setDark()`. + +**`auto` mode is handled inside the service.** If you were reading `window.matchMedia('(prefers-color-scheme: …)')` yourself and rewriting a theme's `className` to fake a "system" option (as the docs app used to), call `themeService.setAuto()` instead and read `resolvedMode()` — the OS listener and the DOM update are both handled internally now. + +**Persistence is on by default.** The selected mode is now saved to `localStorage` (key `kbq-theme-mode` by default) and restored on init through the `KBQ_THEME_STORE` token, the same swappable-store pattern as `KBQ_ACCORDION_STATE_STORE`. If you rolled your own persistence under a different key (as the docs app did, under `docs_theme`), configure `kbqThemeProvider({ storageKey: '…' })` instead of dropping it — existing users keep their saved preference. Provide a custom `KbqThemeStore` if you need a different storage backend entirely. + +**Custom themes and DI-based setup.** `setThemes()` still accepts any array of `{ name, className }` objects. New: `kbqThemeProvider({ themes, mode, attribute, storageKey })` configures the service through DI instead of calling `setThemes()`/`setTheme()` imperatively. `attribute: 'data-theme'` is a new opt-in that sets `data-theme=""` on `` instead of the default CSS class. + ### After the migration The migration is regex-based and does not rewrite aliased imports, local variables, or re-exports — **review the diff before committing**, rebuild the project and run your tests. The full list of breaking changes is on the [Angular 20 breaking changes](https://github.com/koobiq/angular-components/blob/main/docs/guides/angular-20-breaking-changes.en.md) page. diff --git a/packages/components-dev/theme-toggle.ts b/packages/components-dev/theme-toggle.ts index ee4e761d12..6848586fbe 100644 --- a/packages/components-dev/theme-toggle.ts +++ b/packages/components-dev/theme-toggle.ts @@ -1,7 +1,6 @@ -import { ChangeDetectionStrategy, Component, inject, model } from '@angular/core'; -import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, effect, inject, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { KbqThemeSelector, ThemeService } from '@koobiq/components/core'; +import { KbqThemeService } from '@koobiq/components/core'; import { KbqToggleModule } from '@koobiq/components/toggle'; @Component({ @@ -18,14 +17,10 @@ import { KbqToggleModule } from '@koobiq/components/toggle'; exportAs: 'devThemeToggle' }) export class DevThemeToggle { - private readonly theme = inject(ThemeService); - readonly isDarkTheme = model(this.theme.current.value?.className === KbqThemeSelector.Dark); + private readonly theme = inject(KbqThemeService); + readonly isDarkTheme = model(this.theme.resolvedMode() === 'dark'); constructor() { - toObservable(this.isDarkTheme) - .pipe(takeUntilDestroyed()) - .subscribe((isDarkTheme) => { - this.theme.setTheme(isDarkTheme ? 1 : 0); - }); + effect(() => this.theme.setMode(this.isDarkTheme() ? 'dark' : 'light')); } } diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts new file mode 100644 index 0000000000..976c313587 --- /dev/null +++ b/packages/components/core/services/theme.service.spec.ts @@ -0,0 +1,263 @@ +import { Platform } from '@angular/cdk/platform'; +import { TestBed } from '@angular/core/testing'; +import { KBQ_WINDOW } from '../tokens/window'; +import { + KBQ_THEME_CONFIG, + KBQ_THEME_STORE, + KbqDefaultThemes, + KbqThemeLocalStorageStore, + KbqThemeService, + KbqThemeStore, + ThemeService +} from './theme.service'; + +/** Minimal fake `MediaQueryList` that lets tests flip `matches` and trigger the `change` listener. */ +function fakeMediaQueryList(matches: boolean) { + let listener: ((event: MediaQueryListEvent) => void) | undefined; + + const mql = { + matches, + media: '(prefers-color-scheme: dark)', + addEventListener: (_: string, cb: (event: MediaQueryListEvent) => void) => { + listener = cb; + }, + removeEventListener: () => { + listener = undefined; + }, + dispatchEvent: () => true + } as unknown as MediaQueryList; + + return { + mql, + emit(newMatches: boolean) { + (mql as { matches: boolean }).matches = newMatches; + listener?.({ matches: newMatches } as MediaQueryListEvent); + } + }; +} + +describe('KbqThemeService', () => { + let store: jest.Mocked; + + function setup(matches = false) { + const media = fakeMediaQueryList(matches); + + store = { getMode: jest.fn().mockReturnValue(null), setMode: jest.fn() }; + + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, + { provide: KBQ_THEME_STORE, useValue: store } + ] + }); + + const service = TestBed.inject(KbqThemeService); + + TestBed.tick(); + + return { service, media }; + } + + afterEach(() => { + document.body.className = ''; + document.body.removeAttribute('data-theme'); + localStorage.clear(); + }); + + it('defaults to auto mode, resolving dark when the OS prefers dark', () => { + const { service } = setup(true); + + expect(service.mode()).toBe('auto'); + expect(service.resolvedMode()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); + expect(document.body.classList.contains('kbq-dark')).toBe(true); + }); + + it('defaults to auto mode, resolving light when the OS prefers light', () => { + const { service } = setup(false); + + expect(service.resolvedMode()).toBe('light'); + expect(document.body.classList.contains('kbq-light')).toBe(true); + }); + + it('follows OS color scheme changes while in auto mode', () => { + const { service, media } = setup(false); + + expect(service.resolvedMode()).toBe('light'); + + media.emit(true); + TestBed.tick(); + + expect(service.resolvedMode()).toBe('dark'); + expect(document.body.classList.contains('kbq-dark')).toBe(true); + expect(document.body.classList.contains('kbq-light')).toBe(false); + }); + + it('setMode/setAuto select a fixed mode or fall back to the OS preference', () => { + const { service } = setup(true); + + service.setMode('light'); + TestBed.tick(); + expect(service.resolvedMode()).toBe('light'); + + service.setMode('dark'); + TestBed.tick(); + expect(service.resolvedMode()).toBe('dark'); + + service.setAuto(); + TestBed.tick(); + expect(service.mode()).toBe('auto'); + expect(service.resolvedMode()).toBe('dark'); + }); + + it('toggle switches between light and dark', () => { + const { service } = setup(false); + + service.toggle(); + TestBed.tick(); + expect(service.resolvedMode()).toBe('dark'); + + service.toggle(); + TestBed.tick(); + expect(service.resolvedMode()).toBe('light'); + }); + + it('supports registering a fully custom set of themes', () => { + const { service } = setup(false); + + service.setThemes([{ name: 'solarized', className: 'kbq-solarized' }]); + service.setMode('solarized'); + TestBed.tick(); + + expect(service.currentTheme()?.className).toBe('kbq-solarized'); + expect(document.body.classList.contains('kbq-solarized')).toBe(true); + }); + + it('persists the selected mode via KBQ_THEME_STORE', () => { + const { service } = setup(false); + + service.setMode('dark'); + TestBed.tick(); + + expect(store.setMode).toHaveBeenCalledWith('dark'); + }); + + it('restores the mode persisted in KBQ_THEME_STORE on init', () => { + const media = fakeMediaQueryList(false); + + store = { getMode: jest.fn().mockReturnValue('dark'), setMode: jest.fn() }; + + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, + { provide: KBQ_THEME_STORE, useValue: store } + ] + }); + + const service = TestBed.inject(KbqThemeService); + + TestBed.tick(); + + expect(service.mode()).toBe('dark'); + expect(service.resolvedMode()).toBe('dark'); + }); + + it('keeps the deprecated `selected` field in sync for backward compatibility', () => { + const { service } = setup(true); + + const themes = service.themes(); + + expect(themes.find((theme) => theme.name === 'dark')?.selected).toBe(true); + expect(themes.find((theme) => theme.name === 'light')?.selected).toBe(false); + }); + + it('supports data-theme attribute mode via KBQ_THEME_CONFIG', () => { + const media = fakeMediaQueryList(false); + + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, + { provide: KBQ_THEME_CONFIG, useValue: { attribute: 'data-theme' } }, + { provide: KBQ_THEME_STORE, useValue: { getMode: () => null, setMode: () => {} } } + ] + }); + + TestBed.inject(KbqThemeService); + TestBed.tick(); + + expect(document.body.getAttribute('data-theme')).toBe('light'); + expect(document.body.classList.contains('kbq-light')).toBe(false); + }); + + it('exposes the deprecated `setTheme`/`getTheme` shims', () => { + const { service } = setup(false); + + service.setTheme(1); + TestBed.tick(); + expect(service.mode()).toBe('dark'); + expect(service.getTheme()).toBe(service.currentTheme()); + + service.setTheme(KbqDefaultThemes[0]); + TestBed.tick(); + expect(service.mode()).toBe('light'); + }); + + it('exports `ThemeService` as a deprecated alias of `KbqThemeService`', () => { + expect(ThemeService).toBe(KbqThemeService); + }); + + it('keeps the deprecated `current` BehaviorSubject in sync with `currentTheme()`', () => { + const { service } = setup(false); + + expect(service.current.value?.name).toBe('light'); + + service.setMode('dark'); + TestBed.tick(); + + expect(service.current.value?.name).toBe('dark'); + expect(service.current.value).toBe(service.currentTheme()); + }); +}); + +describe('KbqThemeLocalStorageStore', () => { + function setup(isBrowser: boolean, config: { storageKey?: string } = {}) { + TestBed.configureTestingModule({ + providers: [ + { provide: Platform, useValue: { isBrowser } }, + { provide: KBQ_THEME_CONFIG, useValue: config } + ] + }); + + return TestBed.inject(KbqThemeLocalStorageStore); + } + + afterEach(() => localStorage.clear()); + + it('persists and restores the mode via localStorage in the browser', () => { + const store = setup(true); + + expect(store.getMode()).toBeNull(); + + store.setMode('dark'); + + expect(store.getMode()).toBe('dark'); + }); + + it('is a no-op on the server', () => { + const store = setup(false); + + store.setMode('dark'); + + expect(store.getMode()).toBeNull(); + expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); + }); + + it('uses the storage key configured via KBQ_THEME_CONFIG', () => { + const store = setup(true, { storageKey: 'docs_theme' }); + + store.setMode('dark'); + + expect(localStorage.getItem('docs_theme')).toBe('dark'); + expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); + }); +}); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index 683062c94c..f015f08f3b 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -1,13 +1,37 @@ +import { Platform } from '@angular/cdk/platform'; import { DOCUMENT } from '@angular/common'; -import { inject, Injectable, OnDestroy, Renderer2, RendererFactory2 } from '@angular/core'; -import { BehaviorSubject, pairwise, Subscription } from 'rxjs'; +import { + computed, + DestroyRef, + effect, + inject, + Injectable, + InjectionToken, + Provider, + Renderer2, + RendererFactory2, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { BehaviorSubject, fromEvent } from 'rxjs'; +import { KBQ_WINDOW } from '../tokens'; +/** A theme registered with `KbqThemeService`. */ export interface KbqTheme { + /** Unique name used to select the theme via `setMode()`. */ name: string; + /** CSS class applied to the document body when this theme is active. */ className: string; - selected: boolean; + /** + * @deprecated Selection state is now owned by `KbqThemeService` — read `currentTheme()` or `mode()` instead. + * Kept in sync by the service for backward compatibility. + */ + selected?: boolean; } +/** Theme mode understood by `KbqThemeService`. `auto` resolves to `light`/`dark` based on the OS color scheme. */ +export type KbqThemeMode = 'auto' | 'light' | 'dark'; + /** * Enum representing the available themes for the Koobiq design system. * This enum is used to manage and switch between different visual themes. @@ -15,7 +39,7 @@ export interface KbqTheme { export enum KbqThemeSelector { /** * Represents the default light theme. - * This is the standard theme that is applied + * This is the standard theme applied * when the application is first loaded if nothing else provided */ Default = 'kbq-light', @@ -26,67 +50,226 @@ export enum KbqThemeSelector { } export const KbqDefaultThemes: KbqTheme[] = [ - { - name: 'light', - className: KbqThemeSelector.Default, - selected: true - }, - { - name: 'dark', - className: KbqThemeSelector.Dark, - selected: false - } + { name: 'light', className: KbqThemeSelector.Default }, + { name: 'dark', className: KbqThemeSelector.Dark } ]; +/** Configuration accepted by `KBQ_THEME_CONFIG` / `kbqThemeProvider()`. */ +export interface KbqThemeConfig { + /** Themes available to the service. @default KbqDefaultThemes */ + themes?: T[]; + /** Initial mode, used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default 'auto' */ + mode?: KbqThemeMode; + /** How the active theme is applied to the document body. @default 'class' */ + attribute?: 'class' | 'data-theme'; + /** `localStorage` key used to persist the selected mode. @default 'kbq-theme-mode' */ + storageKey?: string; +} + +const KBQ_THEME_DEFAULT_CONFIG: Required = { + themes: KbqDefaultThemes, + mode: 'auto', + attribute: 'class', + storageKey: 'kbq-theme-mode' +}; + +export const KBQ_THEME_CONFIG = new InjectionToken('KBQ_THEME_CONFIG', { + providedIn: 'root', + factory: () => KBQ_THEME_DEFAULT_CONFIG +}); + +/** Configures `KbqThemeService` — registers custom themes, sets the initial mode, and how it's applied to the DOM. */ +export const kbqThemeProvider = (config: KbqThemeConfig): Provider => ({ + provide: KBQ_THEME_CONFIG, + useValue: config +}); + +/** + * Strategy used by `KbqThemeService` to persist and restore the selected theme mode. + * + * Provide a custom implementation through the `KBQ_THEME_STORE` token to change where the mode is stored + * (e.g. `sessionStorage`, a backend), or to disable persistence entirely. + */ +export interface KbqThemeStore { + /** Returns the previously saved mode, or `null` when nothing is stored/available. */ + getMode(): KbqThemeMode | string | null; + /** Persists the mode. */ + setMode(mode: KbqThemeMode | string): void; +} + +/** + * Default `KbqThemeStore` implementation backed by `localStorage`. + * + * All access is guarded so it is safe on the server (SSR) and in environments where storage throws on access + * (private mode, sandboxed iframes). The storage key is configured via `KBQ_THEME_CONFIG.storageKey` + * (see `kbqThemeProvider()`). + */ @Injectable({ providedIn: 'root' }) -export class ThemeService implements OnDestroy { - protected readonly document = inject(DOCUMENT); - protected readonly rendererFactory = inject(RendererFactory2); - protected renderer: Renderer2; +export class KbqThemeLocalStorageStore implements KbqThemeStore { + private readonly isBrowser = inject(Platform).isBrowser; + private readonly window = inject(KBQ_WINDOW); + private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey ?? KBQ_THEME_DEFAULT_CONFIG.storageKey; - current: BehaviorSubject = new BehaviorSubject(null as T); + getMode(): KbqThemeMode | string | null { + if (!this.isBrowser) return null; - themes: T[] = KbqDefaultThemes as T[]; + try { + return this.window.localStorage.getItem(this.storageKey); + } catch { + return null; + } + } - protected subscription: Subscription; + setMode(mode: KbqThemeMode | string): void { + if (!this.isBrowser) return; + + try { + this.window.localStorage.setItem(this.storageKey, mode); + } catch { + // Ignore storage write failures (quota exceeded, disabled/blocked storage, etc.). + } + } +} + +/** + * Injection token for the store used to persist the selected theme mode. + * Defaults to a `localStorage`-backed implementation (`KbqThemeLocalStorageStore`). + */ +export const KBQ_THEME_STORE = new InjectionToken('KBQ_THEME_STORE', { + providedIn: 'root', + factory: () => inject(KbqThemeLocalStorageStore) +}); + +/** + * Manages the active Koobiq theme: resolves `auto` mode from the OS color scheme, applies the active theme's + * class (or `data-theme` attribute) to the document body, and persists the selected mode via `KBQ_THEME_STORE`. + * + * @example + * ```ts + * providers: [kbqThemeProvider({ themes: myThemes, mode: 'dark' })] + * ``` + */ +@Injectable({ providedIn: 'root' }) +export class KbqThemeService { + private readonly document = inject(DOCUMENT); + private readonly window = inject(KBQ_WINDOW); + private readonly store = inject(KBQ_THEME_STORE); + private readonly destroyRef = inject(DestroyRef); + private readonly config: Required> = { + ...KBQ_THEME_DEFAULT_CONFIG, + ...inject(KBQ_THEME_CONFIG) + } as Required>; + + private readonly renderer: Renderer2; + private readonly media = this.window.matchMedia('(prefers-color-scheme: dark)'); + private readonly systemPrefersDark = signal(this.media.matches); + + /** Themes available to select from. Replace via `setThemes()` to register a fully custom set. */ + readonly themes = signal(this.config.themes); + + /** Currently selected mode. `'auto'` resolves to `light`/`dark` based on the OS color scheme. */ + readonly mode = signal(this.store.getMode() ?? this.config.mode); + + /** `mode()` resolved to a concrete theme name — never `'auto'`. */ + readonly resolvedMode = computed(() => { + const mode = this.mode(); + + return mode === 'auto' ? (this.systemPrefersDark() ? 'dark' : 'light') : mode; + }); + + /** The theme object currently applied to the document, or `null` if `resolvedMode()` matches no registered theme. */ + readonly currentTheme = computed(() => { + const resolvedMode = this.resolvedMode(); + + return this.themes().find((theme) => theme.name === resolvedMode) ?? null; + }); + + /** + * @deprecated read `currentTheme()` instead. Kept in sync for backward compatibility. + */ + readonly current = new BehaviorSubject(null); constructor() { - this.renderer = this.rendererFactory.createRenderer(null, null); + this.renderer = inject(RendererFactory2).createRenderer(null, null); - this.subscription = this.current.pipe(pairwise()).subscribe(this.update); - } + fromEvent(this.media, 'change') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.systemPrefersDark.set(event.matches)); - ngOnDestroy() { - this.subscription.unsubscribe(); + effect(() => { + const currentTheme = this.currentTheme(); + + this.applyTheme(currentTheme, this.themes()); + this.current.next(currentTheme); + }); + effect(() => this.store.setMode(this.mode())); } + /** Registers a custom set of themes. */ setThemes(items: T[]) { - this.themes = items; + this.themes.set(items); + } + + /** Selects a mode by theme `name`, or `'auto'` to follow the OS color scheme. */ + setMode(mode: KbqThemeMode | string) { + this.mode.set(mode); + } + + /** Follows the OS color scheme. */ + setAuto() { + this.setMode('auto'); + } + + /** Switches between `light` and `dark`, based on the currently resolved mode. */ + toggle() { + this.setMode(this.resolvedMode() === 'dark' ? 'light' : 'dark'); } + /** @deprecated use `setMode()` with a theme `name` instead. */ setTheme(value: T | number) { if (typeof value === 'number') { - this.current.next(this.themes[value]); - } else if (typeof value === 'object' && this.themes.includes(value)) { - this.current.next(value); + const theme = this.themes()[value]; + + if (theme) this.setMode(theme.name); + } else if (typeof value === 'object' && value !== null && this.themes().includes(value)) { + this.setMode(value.name); } else { throw Error(`value has unsupported type: ${typeof value}`); } } - getTheme(): T { - return this.current.value; + /** @deprecated read `currentTheme()` instead. */ + getTheme(): T | null { + return this.currentTheme(); } - protected update = ([prev, current]: T[]) => { - if (prev) { - prev.selected = false; - this.renderer.removeClass(this.document.body, prev.className); + private applyTheme(current: T | null, themes: T[]) { + for (const theme of themes) { + const isActive = theme === current; + + // deprecated back-compat sync, remove together with `KbqTheme.selected` + theme.selected = isActive; + + if (this.config.attribute === 'class') { + if (isActive) { + this.renderer.addClass(this.document.body, theme.className); + } else { + this.renderer.removeClass(this.document.body, theme.className); + } + } } - if (current) { - this.renderer.addClass(this.document.body, current.className); - current.selected = true; + if (this.config.attribute === 'data-theme') { + if (current) { + this.renderer.setAttribute(this.document.body, 'data-theme', current.name); + } else { + this.renderer.removeAttribute(this.document.body, 'data-theme'); + } } - }; + } } + +/** @deprecated use `KbqThemeService` instead. Will be removed in a future major version. */ +export type ThemeService = KbqThemeService; +/** @deprecated use `KbqThemeService` instead. Will be removed in a future major version. */ +export const ThemeService = KbqThemeService; diff --git a/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts b/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts index ba8d0000fa..3d94b1a5f1 100644 --- a/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts +++ b/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts @@ -1,11 +1,8 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button'; -import { KbqComponentColors, ThemeService } from '@koobiq/components/core'; +import { KbqComponentColors, KbqThemeService } from '@koobiq/components/core'; import { KbqEmptyStateModule } from '@koobiq/components/empty-state'; import { KbqIconModule } from '@koobiq/components/icon'; -import { of } from 'rxjs'; -import { map } from 'rxjs/operators'; /** * @title Empty-state content @@ -65,12 +62,8 @@ import { map } from 'rxjs/operators'; export class EmptyStateContentExample { readonly colors = KbqComponentColors; readonly styles = KbqButtonStyles; - protected readonly currentTheme = toSignal( - inject(ThemeService, { optional: true })?.current.pipe( - map((theme) => theme && theme.className.replace('kbq-', '')) - ) || of('light'), - { initialValue: 'light' } - ); + private readonly themeService = inject(KbqThemeService, { optional: true }); + protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts b/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts index e99f58f24e..46c204f0df 100644 --- a/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts @@ -5,14 +5,13 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButtonColor, KbqButtonModule, KbqButtonStyleInput, KbqButtonStyles } from '@koobiq/components/button'; -import { KbqComponentColors, KbqFormattersModule, PopUpPlacements, ThemeService } from '@koobiq/components/core'; +import { KbqComponentColors, KbqFormattersModule, KbqThemeService, PopUpPlacements } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqEmptyStateModule } from '@koobiq/components/empty-state'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqNavbarModule } from '@koobiq/components/navbar'; import { KbqNotificationCenterModule, KbqNotificationCenterService } from '@koobiq/components/notification-center'; import { KbqTopBarModule } from '@koobiq/components/top-bar'; -import { of } from 'rxjs'; import { map } from 'rxjs/operators'; type ExampleAction = { @@ -64,12 +63,8 @@ enum NavbarIcItems { export class NotificationCenterEmptyExample { readonly notificationService = inject(KbqNotificationCenterService); - protected readonly currentTheme = toSignal( - inject(ThemeService, { optional: true })?.current.pipe( - map((theme) => theme && theme.className.replace('kbq-', '')) - ) || of('light'), - { initialValue: 'light' } - ); + private readonly themeService = inject(KbqThemeService, { optional: true }); + protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts b/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts index 031b6715ee..70ee8a1a53 100644 --- a/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts @@ -5,7 +5,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButtonColor, KbqButtonModule, KbqButtonStyleInput, KbqButtonStyles } from '@koobiq/components/button'; -import { KbqComponentColors, KbqFormattersModule, PopUpPlacements, ThemeService } from '@koobiq/components/core'; +import { KbqComponentColors, KbqFormattersModule, KbqThemeService, PopUpPlacements } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqEmptyStateModule } from '@koobiq/components/empty-state'; import { KbqIconModule } from '@koobiq/components/icon'; @@ -14,7 +14,6 @@ import { KbqNavbarModule } from '@koobiq/components/navbar'; import { KbqNotificationCenterModule, KbqNotificationCenterService } from '@koobiq/components/notification-center'; import { KbqToastStyle } from '@koobiq/components/toast'; import { KbqTopBarModule } from '@koobiq/components/top-bar'; -import { of } from 'rxjs'; import { map } from 'rxjs/operators'; type ExampleAction = { @@ -69,12 +68,8 @@ export class NotificationCenterErrorExample { @ViewChild('actionsTemplate') actionsTemplateRef: TemplateRef; - protected readonly currentTheme = toSignal( - inject(ThemeService, { optional: true })?.current.pipe( - map((theme) => theme && theme.className.replace('kbq-', '')) - ) || of('light'), - { initialValue: 'light' } - ); + private readonly themeService = inject(KbqThemeService, { optional: true }); + protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts b/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts index d7ee72d32c..30034f8937 100644 --- a/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts @@ -5,7 +5,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButtonColor, KbqButtonModule, KbqButtonStyleInput, KbqButtonStyles } from '@koobiq/components/button'; -import { KbqComponentColors, KbqFormattersModule, ThemeService } from '@koobiq/components/core'; +import { KbqComponentColors, KbqFormattersModule, KbqThemeService } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqEmptyStateModule } from '@koobiq/components/empty-state'; import { KbqIconModule } from '@koobiq/components/icon'; @@ -18,7 +18,7 @@ import { } from '@koobiq/components/notification-center'; import { KbqToastStyle } from '@koobiq/components/toast'; import { KbqTopBarModule } from '@koobiq/components/top-bar'; -import { of, timer } from 'rxjs'; +import { timer } from 'rxjs'; import { map } from 'rxjs/operators'; /** Items per loaded page. */ @@ -158,12 +158,8 @@ export class NotificationCenterInfiniteScrollExample { return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, assets/images/${currentTheme}/empty_192@2x.png 2x`; }); - protected readonly currentTheme = toSignal( - inject(ThemeService, { optional: true })?.current.pipe( - map((theme) => theme && theme.className.replace('kbq-', '')) - ) || of('light'), - { initialValue: 'light' } - ); + private readonly themeService = inject(KbqThemeService, { optional: true }); + protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); readonly isDesktop = toSignal( inject(BreakpointObserver) diff --git a/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts b/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts index 4e738e45c6..ea369ae5bf 100644 --- a/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts @@ -13,7 +13,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButtonColor, KbqButtonModule, KbqButtonStyleInput, KbqButtonStyles } from '@koobiq/components/button'; -import { KbqComponentColors, KbqFormattersModule, PopUpPlacements, ThemeService } from '@koobiq/components/core'; +import { KbqComponentColors, KbqFormattersModule, KbqThemeService, PopUpPlacements } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqEmptyStateModule } from '@koobiq/components/empty-state'; import { KbqIconModule } from '@koobiq/components/icon'; @@ -22,7 +22,6 @@ import { KbqNavbarModule } from '@koobiq/components/navbar'; import { KbqNotificationCenterModule, KbqNotificationCenterService } from '@koobiq/components/notification-center'; import { KbqToastStyle } from '@koobiq/components/toast'; import { KbqTopBarModule } from '@koobiq/components/top-bar'; -import { of } from 'rxjs'; import { map } from 'rxjs/operators'; type ExampleAction = { @@ -75,12 +74,8 @@ export class NotificationCenterOverviewExample implements AfterViewInit { @ViewChild('actionsTemplate') actionsTemplateRef!: TemplateRef; @ViewChild('captionTemplate') captionTemplateRef: TemplateRef; - protected readonly currentTheme = toSignal( - inject(ThemeService, { optional: true })?.current.pipe( - map((theme) => theme && theme.className.replace('kbq-', '')) - ) || of('light'), - { initialValue: 'light' } - ); + private readonly themeService = inject(KbqThemeService, { optional: true }); + protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts b/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts index d6fe9e90c7..5890989a0f 100644 --- a/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts @@ -13,7 +13,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButtonColor, KbqButtonModule, KbqButtonStyleInput, KbqButtonStyles } from '@koobiq/components/button'; -import { KbqComponentColors, KbqFormattersModule, PopUpPlacements, ThemeService } from '@koobiq/components/core'; +import { KbqComponentColors, KbqFormattersModule, KbqThemeService, PopUpPlacements } from '@koobiq/components/core'; import { KbqDividerModule } from '@koobiq/components/divider'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqEmptyStateModule } from '@koobiq/components/empty-state'; @@ -23,7 +23,6 @@ import { KbqNavbarModule } from '@koobiq/components/navbar'; import { KbqNotificationCenterModule, KbqNotificationCenterService } from '@koobiq/components/notification-center'; import { KbqToastStyle } from '@koobiq/components/toast'; import { KbqTopBarModule } from '@koobiq/components/top-bar'; -import { of } from 'rxjs'; import { map } from 'rxjs/operators'; type ExampleAction = { @@ -82,12 +81,8 @@ export class NotificationCenterPopoverExample implements AfterViewInit { popUpPlacements = PopUpPlacements; - protected readonly currentTheme = toSignal( - inject(ThemeService, { optional: true })?.current.pipe( - map((theme) => theme && theme.className.replace('kbq-', '')) - ) || of('light'), - { initialValue: 'light' } - ); + private readonly themeService = inject(KbqThemeService, { optional: true }); + protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts b/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts index b766af4281..705a3c4d0d 100644 --- a/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts @@ -13,14 +13,13 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { LuxonDateModule } from '@koobiq/angular-luxon-adapter/adapter'; import { KbqBadgeModule } from '@koobiq/components/badge'; import { KbqButtonColor, KbqButtonModule, KbqButtonStyleInput, KbqButtonStyles } from '@koobiq/components/button'; -import { KbqComponentColors, KbqFormattersModule, PopUpPlacements, ThemeService } from '@koobiq/components/core'; +import { KbqComponentColors, KbqFormattersModule, KbqThemeService, PopUpPlacements } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; import { KbqNavbarModule } from '@koobiq/components/navbar'; import { KbqNotificationCenterModule, KbqNotificationCenterService } from '@koobiq/components/notification-center'; import { KbqToastStyle } from '@koobiq/components/toast'; -import { of } from 'rxjs'; import { map } from 'rxjs/operators'; type ExampleAction = { @@ -71,12 +70,8 @@ export class NotificationCenterPushExample implements AfterViewInit { @ViewChild('actionsTemplate') actionsTemplateRef!: TemplateRef; @ViewChild('captionTemplate') captionTemplateRef: TemplateRef; - protected readonly currentTheme = toSignal( - inject(ThemeService, { optional: true })?.current.pipe( - map((theme) => theme && theme.className.replace('kbq-', '')) - ) || of('light'), - { initialValue: 'light' } - ); + private readonly themeService = inject(KbqThemeService, { optional: true }); + protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index bec0b7e218..c7292c43f4 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -51,7 +51,6 @@ import { PipeTransform } from '@angular/core'; import { Provider } from '@angular/core'; import { QueryList } from '@angular/core'; import { Renderer2 } from '@angular/core'; -import { RendererFactory2 } from '@angular/core'; import { RepositionScrollStrategy } from '@angular/cdk/overlay'; import { ScrollDispatcher } from '@angular/cdk/overlay'; import { ScrollStrategy } from '@angular/cdk/overlay'; @@ -2425,6 +2424,12 @@ export const KBQ_SIZE_UNITS_CONFIG: InjectionToken; // @public (undocumented) export const KBQ_SIZE_UNITS_DEFAULT_CONFIG: KbqSizeUnitsConfig; +// @public (undocumented) +export const KBQ_THEME_CONFIG: InjectionToken>; + +// @public +export const KBQ_THEME_STORE: InjectionToken; + // @public (undocumented) export const KBQ_TITLE_TEXT_REF: InjectionToken; @@ -3819,22 +3824,75 @@ export class KbqTableNumberPipe implements KbqNumericPipe, PipeTransform { static ɵprov: i0.ɵɵInjectableDeclaration; } -// @public (undocumented) +// @public export interface KbqTheme { - // (undocumented) className: string; - // (undocumented) name: string; + // @deprecated (undocumented) + selected?: boolean; +} + +// @public +export interface KbqThemeConfig { + attribute?: 'class' | 'data-theme'; + mode?: KbqThemeMode; + storageKey?: string; + themes?: T[]; +} + +// @public +export class KbqThemeLocalStorageStore implements KbqThemeStore { // (undocumented) - selected: boolean; + getMode(): KbqThemeMode | string | null; + // (undocumented) + setMode(mode: KbqThemeMode | string): void; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration; } +// @public +export type KbqThemeMode = 'auto' | 'light' | 'dark'; + +// @public +export const kbqThemeProvider: (config: KbqThemeConfig) => Provider; + // @public export enum KbqThemeSelector { Dark = "kbq-dark", Default = "kbq-light" } +// @public +export class KbqThemeService { + constructor(); + // @deprecated (undocumented) + readonly current: BehaviorSubject; + readonly currentTheme: i0.Signal; + // @deprecated (undocumented) + getTheme(): T | null; + readonly mode: i0.WritableSignal; + readonly resolvedMode: i0.Signal; + setAuto(): void; + setMode(mode: KbqThemeMode | string): void; + // @deprecated (undocumented) + setTheme(value: T | number): void; + setThemes(items: T[]): void; + readonly themes: i0.WritableSignal; + toggle(): void; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration>; +} + +// @public +export interface KbqThemeStore { + getMode(): KbqThemeMode | string | null; + setMode(mode: KbqThemeMode | string): void; +} + // @public export type KbqTimeRangeLocaleConfig = { title: { @@ -5020,36 +5078,11 @@ export enum ThemePalette { Warning = "warning" } -// @public (undocumented) -export class ThemeService implements OnDestroy { - constructor(); - // (undocumented) - current: BehaviorSubject; - // (undocumented) - protected readonly document: Document; - // (undocumented) - getTheme(): T; - // (undocumented) - ngOnDestroy(): void; - // (undocumented) - protected renderer: Renderer2; - // (undocumented) - protected readonly rendererFactory: RendererFactory2; - // (undocumented) - setTheme(value: T | number): void; - // (undocumented) - setThemes(items: T[]): void; - // (undocumented) - protected subscription: Subscription; - // (undocumented) - themes: T[]; - // (undocumented) - protected update: (input: T[]) => void; - // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration, never>; - // (undocumented) - static ɵprov: i0.ɵɵInjectableDeclaration>; -} +// @public @deprecated (undocumented) +export type ThemeService = KbqThemeService; + +// @public @deprecated (undocumented) +export const ThemeService: typeof KbqThemeService; // @public (undocumented) export const THREE = 51; From 8db6431fef8794824959fcbe321fc5131805d9a6 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 7 Aug 2026 13:15:10 +0300 Subject: [PATCH 02/15] feat: usage auto with custom themes, simplified API --- docs/guides/migration.en.md | 2 +- docs/guides/migration.ru.md | 25 +++++++++ .../core/services/theme.service.spec.ts | 55 ++++++++++++------- .../components/core/services/theme.service.ts | 39 ++++++------- tools/public_api_guard/components/core.api.md | 3 +- 5 files changed, 81 insertions(+), 43 deletions(-) diff --git a/docs/guides/migration.en.md b/docs/guides/migration.en.md index 3e82d98e1d..c64e1983fc 100644 --- a/docs/guides/migration.en.md +++ b/docs/guides/migration.en.md @@ -764,7 +764,7 @@ themeService.currentTheme(); // read directly, or wrap with toObservable() if yo **Persistence is on by default.** The selected mode is now saved to `localStorage` (key `kbq-theme-mode` by default) and restored on init through the `KBQ_THEME_STORE` token, the same swappable-store pattern as `KBQ_ACCORDION_STATE_STORE`. If you rolled your own persistence under a different key (as the docs app did, under `docs_theme`), configure `kbqThemeProvider({ storageKey: '…' })` instead of dropping it — existing users keep their saved preference. Provide a custom `KbqThemeStore` if you need a different storage backend entirely. -**Custom themes and DI-based setup.** `setThemes()` still accepts any array of `{ name, className }` objects. New: `kbqThemeProvider({ themes, mode, attribute, storageKey })` configures the service through DI instead of calling `setThemes()`/`setTheme()` imperatively. `attribute: 'data-theme'` is a new opt-in that sets `data-theme=""` on `` instead of the default CSS class. +**Custom themes and DI-based setup.** `setThemes()` still accepts any array of `{ name, className }` objects. New: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` configures the service through DI instead of calling `setThemes()`/`setTheme()` imperatively. The active theme is always applied as a CSS class on `` — the design tokens' `.kbq-light`/`.kbq-dark` styles depend on it, so there's no attribute-based alternative. `auto` mode resolves to the theme named `autoLight`/`autoDark` (`'light'`/`'dark'` by default) — set these if your custom theme set doesn't use those names, otherwise `auto` won't match any registered theme. ### After the migration diff --git a/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index 9367bf05d6..fe5b87f626 100644 --- a/docs/guides/migration.ru.md +++ b/docs/guides/migration.ru.md @@ -16,6 +16,7 @@ 10. **20.3.0**: поддерживаемые цвета кнопки — свой дефолтный цвет у каждого стиля. 11. **20.3.0**: ревью группы кнопок — ARIA-семантика, навигация с клавиатуры и сигнальные входы. 12. **20.3.0**: ревью поля формы — сигналы, доступность и удаление `mixinColor`. +13. **20.3.0**: ревью сервиса темизации — сигналы, режим `auto` и персистентность из коробки. ### 1. Обновление до 18.5.3 @@ -741,6 +742,30 @@ if (formField.hasCleaner() && formField.hint().length && hint.fillTextOff()) { **Стили, боровшиеся с `!important`.** `.kbq-form-field_no-borders` и `.kbq-form-field_in-overlay` использовали `!important`, чтобы перебить тему состояний; теперь они переопределяют токены `--kbq-form-field-*`. Итоговое значение то же, но переопределение, написанное специально ради победы над старым `!important`, можно упростить. +### 13. Ревью сервиса темизации (20.3.0) + +`ThemeService` перешёл на сигналы, получил встроенный режим `auto`, следующий за темой ОС, и теперь сохраняет выбранный режим в `localStorage` из коробки. `ThemeService` продолжает работать под старым именем, а устаревшее поле `KbqTheme.selected` по-прежнему поддерживается в актуальном состоянии — ничего не сломается принудительно, но новый код стоит переводить на `KbqThemeService`. + +**Теперь это `KbqThemeService`.** `ThemeService` экспортируется как `@deprecated`-алиас `KbqThemeService` и будет удалён в одном из будущих мажорных релизов. Схематика `ng update` для переименования нет — замените импорт, когда будет удобно. + +**`current` (`BehaviorSubject`) устарел в пользу двух сигналов.** Он по-прежнему существует и остаётся синхронизирован, поэтому `current.value` и `current.pipe(...)` продолжают работать. `mode()` — выбранный режим (`'auto' | 'light' | 'dark'` или имя кастомной темы); `currentTheme()` — вычисленный объект `KbqTheme`, эквивалент `current.value`. `resolvedMode()` отдаёт `mode()` с уже вычисленным `'auto'` → `'light'`/`'dark'`. + +```ts +// Было +themeService.current.pipe(map((theme) => theme?.className)).subscribe(...); + +// Стало +themeService.currentTheme(); // читайте напрямую, либо оберните в toObservable(), если нужен поток +``` + +**`setTheme(index | theme)` устарел в пользу `setMode(name)`.** Выбор по индексу массива стал ненадёжным, как только `auto` перестал быть обычной зарегистрированной темой. `setMode('light')` / `setMode('dark')` покрывают выбор фиксированного режима; `setAuto()` и `toggle()` — два метода-помощника, оставленные для реально используемых в библиотеке случаев — `setLight()`/`setDark()` нет. + +**Режим `auto` теперь обрабатывается внутри сервиса.** Если вы сами читали `window.matchMedia('(prefers-color-scheme: …)')` и переопределяли `className` темы, чтобы сымитировать пункт «как в системе» (как раньше делала дока), теперь вызывайте `themeService.setAuto()` и читайте `resolvedMode()` — слушатель ОС и обновление DOM теперь внутри сервиса. + +**Персистентность включена по умолчанию.** Выбранный режим теперь сохраняется в `localStorage` (по умолчанию под ключом `kbq-theme-mode`) и восстанавливается при инициализации через токен `KBQ_THEME_STORE` — тот же паттерн подменяемого хранилища, что и у `KBQ_ACCORDION_STATE_STORE`. Если вы делали свою персистентность под другим ключом (как дока — под `docs_theme`), настройте `kbqThemeProvider({ storageKey: '…' })` вместо того, чтобы её убирать — так пользователи не потеряют сохранённые настройки. Если нужен другой бэкенд хранения, предоставьте свой `KbqThemeStore`. + +**Кастомные темы и настройка через DI.** `setThemes()` по-прежнему принимает любой массив объектов `{ name, className }`. Новое: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` настраивает сервис через DI вместо императивных вызовов `setThemes()`/`setTheme()`. Активная тема всегда применяется как CSS-класс на `` — от этого зависят стили `.kbq-light`/`.kbq-dark` дизайн-токенов, поэтому альтернативы через атрибут нет. Режим `auto` разрешается в тему с именем `autoLight`/`autoDark` (по умолчанию `'light'`/`'dark'`) — задайте их, если ваш набор кастомных тем использует другие имена, иначе `auto` не совпадёт ни с одной зарегистрированной темой. + ### После миграции Миграция работает на регулярных выражениях и не переписывает алиасные импорты, локальные переменные и ре-экспорты — **проверьте диф перед коммитом**, пересоберите проект и прогоните тесты. Полный список ломающих изменений — на странице [Ломающие изменения — Angular 20](https://github.com/koobiq/angular-components/blob/main/docs/guides/angular-20-breaking-changes.ru.md). diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index 976c313587..0998f05e4b 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -60,7 +60,6 @@ describe('KbqThemeService', () => { afterEach(() => { document.body.className = ''; - document.body.removeAttribute('data-theme'); localStorage.clear(); }); @@ -133,6 +132,42 @@ describe('KbqThemeService', () => { expect(document.body.classList.contains('kbq-solarized')).toBe(true); }); + it('resolves auto mode against custom theme names via autoLight/autoDark', () => { + const media = fakeMediaQueryList(true); + + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, + { + provide: KBQ_THEME_CONFIG, + useValue: { + themes: [ + { name: 'sunrise', className: 'kbq-sunrise' }, + { name: 'midnight', className: 'kbq-midnight' } + ], + autoLight: 'sunrise', + autoDark: 'midnight' + } + }, + { provide: KBQ_THEME_STORE, useValue: { getMode: () => null, setMode: () => {} } } + ] + }); + + const service = TestBed.inject(KbqThemeService); + + TestBed.tick(); + + expect(service.mode()).toBe('auto'); + expect(service.resolvedMode()).toBe('midnight'); + expect(document.body.classList.contains('kbq-midnight')).toBe(true); + + service.toggle(); + TestBed.tick(); + + expect(service.resolvedMode()).toBe('sunrise'); + expect(document.body.classList.contains('kbq-sunrise')).toBe(true); + }); + it('persists the selected mode via KBQ_THEME_STORE', () => { const { service } = setup(false); @@ -171,24 +206,6 @@ describe('KbqThemeService', () => { expect(themes.find((theme) => theme.name === 'light')?.selected).toBe(false); }); - it('supports data-theme attribute mode via KBQ_THEME_CONFIG', () => { - const media = fakeMediaQueryList(false); - - TestBed.configureTestingModule({ - providers: [ - { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, - { provide: KBQ_THEME_CONFIG, useValue: { attribute: 'data-theme' } }, - { provide: KBQ_THEME_STORE, useValue: { getMode: () => null, setMode: () => {} } } - ] - }); - - TestBed.inject(KbqThemeService); - TestBed.tick(); - - expect(document.body.getAttribute('data-theme')).toBe('light'); - expect(document.body.classList.contains('kbq-light')).toBe(false); - }); - it('exposes the deprecated `setTheme`/`getTheme` shims', () => { const { service } = setup(false); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index f015f08f3b..cd5f3d687a 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -60,17 +60,20 @@ export interface KbqThemeConfig { themes?: T[]; /** Initial mode, used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default 'auto' */ mode?: KbqThemeMode; - /** How the active theme is applied to the document body. @default 'class' */ - attribute?: 'class' | 'data-theme'; /** `localStorage` key used to persist the selected mode. @default 'kbq-theme-mode' */ storageKey?: string; + /** Theme `name` that `'auto'` resolves to when the OS prefers a light color scheme. @default 'light' */ + autoLight?: string; + /** Theme `name` that `'auto'` resolves to when the OS prefers a dark color scheme. @default 'dark' */ + autoDark?: string; } const KBQ_THEME_DEFAULT_CONFIG: Required = { themes: KbqDefaultThemes, mode: 'auto', - attribute: 'class', - storageKey: 'kbq-theme-mode' + storageKey: 'kbq-theme-mode', + autoLight: 'light', + autoDark: 'dark' }; export const KBQ_THEME_CONFIG = new InjectionToken('KBQ_THEME_CONFIG', { @@ -142,7 +145,7 @@ export const KBQ_THEME_STORE = new InjectionToken('KBQ_THEME_STOR /** * Manages the active Koobiq theme: resolves `auto` mode from the OS color scheme, applies the active theme's - * class (or `data-theme` attribute) to the document body, and persists the selected mode via `KBQ_THEME_STORE`. + * class to the document body, and persists the selected mode via `KBQ_THEME_STORE`. * * @example * ```ts @@ -170,11 +173,13 @@ export class KbqThemeService { /** Currently selected mode. `'auto'` resolves to `light`/`dark` based on the OS color scheme. */ readonly mode = signal(this.store.getMode() ?? this.config.mode); - /** `mode()` resolved to a concrete theme name — never `'auto'`. */ + /** `mode()` resolved to a concrete theme name — never `'auto'`. Uses `autoLight`/`autoDark` from `KBQ_THEME_CONFIG`. */ readonly resolvedMode = computed(() => { const mode = this.mode(); - return mode === 'auto' ? (this.systemPrefersDark() ? 'dark' : 'light') : mode; + if (mode !== 'auto') return mode; + + return this.systemPrefersDark() ? this.config.autoDark : this.config.autoLight; }); /** The theme object currently applied to the document, or `null` if `resolvedMode()` matches no registered theme. */ @@ -220,9 +225,9 @@ export class KbqThemeService { this.setMode('auto'); } - /** Switches between `light` and `dark`, based on the currently resolved mode. */ + /** Switches between `autoLight`/`autoDark` (`light`/`dark` by default), based on the currently resolved mode. */ toggle() { - this.setMode(this.resolvedMode() === 'dark' ? 'light' : 'dark'); + this.setMode(this.resolvedMode() === this.config.autoDark ? this.config.autoLight : this.config.autoDark); } /** @deprecated use `setMode()` with a theme `name` instead. */ @@ -250,20 +255,10 @@ export class KbqThemeService { // deprecated back-compat sync, remove together with `KbqTheme.selected` theme.selected = isActive; - if (this.config.attribute === 'class') { - if (isActive) { - this.renderer.addClass(this.document.body, theme.className); - } else { - this.renderer.removeClass(this.document.body, theme.className); - } - } - } - - if (this.config.attribute === 'data-theme') { - if (current) { - this.renderer.setAttribute(this.document.body, 'data-theme', current.name); + if (isActive) { + this.renderer.addClass(this.document.body, theme.className); } else { - this.renderer.removeAttribute(this.document.body, 'data-theme'); + this.renderer.removeClass(this.document.body, theme.className); } } } diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index c7292c43f4..63228e4883 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -3834,7 +3834,8 @@ export interface KbqTheme { // @public export interface KbqThemeConfig { - attribute?: 'class' | 'data-theme'; + autoDark?: string; + autoLight?: string; mode?: KbqThemeMode; storageKey?: string; themes?: T[]; From c5cb44bca6bd2f33dda069a9f98dd451c2b678a9 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Fri, 7 Aug 2026 13:34:32 +0300 Subject: [PATCH 03/15] fix: cspell and tests --- .../design-tokens-viewers/tokens-overview.spec.ts | 12 +++++++++++- docs/guides/migration.ru.md | 6 +++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.spec.ts b/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.spec.ts index 98186aa342..eb0ecdf648 100644 --- a/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.spec.ts +++ b/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.spec.ts @@ -34,7 +34,17 @@ describe('DocsTokensOverview token value caching (PERF-02)', () => { provideDocsLocale(DocsLocale.En), provideRouter([]), { provide: ActivatedRoute, useValue: { url: of([{ path: DocsStructureTokensTab.Colors }]) } }, - { provide: KBQ_WINDOW, useValue: { getComputedStyle: () => ({ getPropertyValue }) } } + { + provide: KBQ_WINDOW, + useValue: { + getComputedStyle: () => ({ getPropertyValue }), + matchMedia: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {} + }) + } + } ] }); diff --git a/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index fe5b87f626..2e7f08e77a 100644 --- a/docs/guides/migration.ru.md +++ b/docs/guides/migration.ru.md @@ -16,7 +16,7 @@ 10. **20.3.0**: поддерживаемые цвета кнопки — свой дефолтный цвет у каждого стиля. 11. **20.3.0**: ревью группы кнопок — ARIA-семантика, навигация с клавиатуры и сигнальные входы. 12. **20.3.0**: ревью поля формы — сигналы, доступность и удаление `mixinColor`. -13. **20.3.0**: ревью сервиса темизации — сигналы, режим `auto` и персистентность из коробки. +13. **20.3.0**: ревью сервиса темизации — сигналы, режим `auto` и сохранение выбора из коробки. ### 1. Обновление до 18.5.3 @@ -748,7 +748,7 @@ if (formField.hasCleaner() && formField.hint().length && hint.fillTextOff()) { **Теперь это `KbqThemeService`.** `ThemeService` экспортируется как `@deprecated`-алиас `KbqThemeService` и будет удалён в одном из будущих мажорных релизов. Схематика `ng update` для переименования нет — замените импорт, когда будет удобно. -**`current` (`BehaviorSubject`) устарел в пользу двух сигналов.** Он по-прежнему существует и остаётся синхронизирован, поэтому `current.value` и `current.pipe(...)` продолжают работать. `mode()` — выбранный режим (`'auto' | 'light' | 'dark'` или имя кастомной темы); `currentTheme()` — вычисленный объект `KbqTheme`, эквивалент `current.value`. `resolvedMode()` отдаёт `mode()` с уже вычисленным `'auto'` → `'light'`/`'dark'`. +**`current` (`BehaviorSubject`) устарел в пользу двух сигналов.** Он по-прежнему существует и остаётся синхронизирован, поэтому `current.value` и `current.pipe(...)` продолжают работать. `mode()` — выбранный режим (`'auto' | 'light' | 'dark'` или имя пользовательской темы); `currentTheme()` — вычисленный объект `KbqTheme`, эквивалент `current.value`. `resolvedMode()` отдаёт `mode()` с уже вычисленным `'auto'` → `'light'`/`'dark'`. ```ts // Было @@ -762,7 +762,7 @@ themeService.currentTheme(); // читайте напрямую, либо обе **Режим `auto` теперь обрабатывается внутри сервиса.** Если вы сами читали `window.matchMedia('(prefers-color-scheme: …)')` и переопределяли `className` темы, чтобы сымитировать пункт «как в системе» (как раньше делала дока), теперь вызывайте `themeService.setAuto()` и читайте `resolvedMode()` — слушатель ОС и обновление DOM теперь внутри сервиса. -**Персистентность включена по умолчанию.** Выбранный режим теперь сохраняется в `localStorage` (по умолчанию под ключом `kbq-theme-mode`) и восстанавливается при инициализации через токен `KBQ_THEME_STORE` — тот же паттерн подменяемого хранилища, что и у `KBQ_ACCORDION_STATE_STORE`. Если вы делали свою персистентность под другим ключом (как дока — под `docs_theme`), настройте `kbqThemeProvider({ storageKey: '…' })` вместо того, чтобы её убирать — так пользователи не потеряют сохранённые настройки. Если нужен другой бэкенд хранения, предоставьте свой `KbqThemeStore`. +**Сохранение выбора включено по умолчанию.** Выбранный режим теперь сохраняется в `localStorage` (по умолчанию под ключом `kbq-theme-mode`) и восстанавливается при инициализации через токен `KBQ_THEME_STORE` — тот же паттерн подменяемого хранилища, что и у `KBQ_ACCORDION_STATE_STORE`. Если вы сохраняли выбор под другим ключом (как дока — под `docs_theme`), настройте `kbqThemeProvider({ storageKey: '…' })` вместо того, чтобы это убирать — так пользователи не потеряют сохранённые настройки. Если нужно другое хранилище, предоставьте свой `KbqThemeStore`. **Кастомные темы и настройка через DI.** `setThemes()` по-прежнему принимает любой массив объектов `{ name, className }`. Новое: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` настраивает сервис через DI вместо императивных вызовов `setThemes()`/`setTheme()`. Активная тема всегда применяется как CSS-класс на `` — от этого зависят стили `.kbq-light`/`.kbq-dark` дизайн-токенов, поэтому альтернативы через атрибут нет. Режим `auto` разрешается в тему с именем `autoLight`/`autoDark` (по умолчанию `'light'`/`'dark'`) — задайте их, если ваш набор кастомных тем использует другие имена, иначе `auto` не совпадёт ни с одной зарегистрированной темой. From 5c06d29edb77f23bc8ff19cbc15195c81a1c8b46 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Mon, 10 Aug 2026 17:23:05 +0300 Subject: [PATCH 04/15] feat: after review --- .../design-tokens-viewers/tokens-overview.ts | 2 +- .../docsearch/docsearch.directive.ts | 6 +- .../components/welcome/welcome.component.ts | 2 +- apps/docs/src/app/config.ts | 4 + .../docs/src/app/services/theme-store.spec.ts | 46 ++++++ apps/docs/src/app/services/theme-store.ts | 32 ++++ packages/components-dev/theme-toggle.ts | 2 +- .../core/services/theme.service.spec.ts | 117 +++++++++++++-- .../components/core/services/theme.service.ts | 138 +++++++++++++----- .../empty-state-content-example.ts | 2 +- .../notification-center-empty-example.ts | 2 +- .../notification-center-error-example.ts | 2 +- ...fication-center-infinite-scroll-example.ts | 2 +- .../notification-center-overview-example.ts | 2 +- .../notification-center-popover-example.ts | 2 +- .../notification-center-push-example.ts | 2 +- tools/public_api_guard/components/core.api.md | 42 ++++-- 17 files changed, 339 insertions(+), 66 deletions(-) create mode 100644 apps/docs/src/app/services/theme-store.spec.ts create mode 100644 apps/docs/src/app/services/theme-store.ts diff --git a/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts b/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts index 127b4b735e..e1058e5489 100644 --- a/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts +++ b/apps/docs/src/app/components/design-tokens-viewers/tokens-overview.ts @@ -239,7 +239,7 @@ export class DocsTokensOverview extends DocsLocaleState implements AfterViewInit constructor() { super(); effect(() => { - this.themeService.resolvedMode(); + this.themeService.currentTheme(); this.tokensInfo.set(this.calculateViewData()); }); diff --git a/apps/docs/src/app/components/docsearch/docsearch.directive.ts b/apps/docs/src/app/components/docsearch/docsearch.directive.ts index 13d156a6c1..efaa446c58 100644 --- a/apps/docs/src/app/components/docsearch/docsearch.directive.ts +++ b/apps/docs/src/app/components/docsearch/docsearch.directive.ts @@ -133,7 +133,7 @@ export class DocsDocsearchDirective extends DocsLocaleState { // captured eagerly (in the constructor's injection context), since `toObservable()` can't be // called lazily from the `afterNextRender()` callback in `init()` - private readonly resolvedMode$ = toObservable(this.theme.resolvedMode); + private readonly colorScheme$ = toObservable(this.theme.colorScheme); private instance: DocSearchInstance | null = null; @@ -152,8 +152,8 @@ export class DocsDocsearchDirective extends DocsLocaleState { private init(): void { combineLatest([ - this.resolvedMode$.pipe( - map((mode) => (mode === 'dark' ? 'dark' : 'light') satisfies DocSearchProps['theme']), + this.colorScheme$.pipe( + map((colorScheme) => colorScheme satisfies DocSearchProps['theme']), distinctUntilChanged() ), this.docsLocaleService.changes.pipe(distinctUntilChanged()) diff --git a/apps/docs/src/app/components/welcome/welcome.component.ts b/apps/docs/src/app/components/welcome/welcome.component.ts index 55a2e0b985..ace2dc7578 100644 --- a/apps/docs/src/app/components/welcome/welcome.component.ts +++ b/apps/docs/src/app/components/welcome/welcome.component.ts @@ -41,7 +41,7 @@ export class DocsWelcomeComponent extends DocsLocaleState implements OnInit { private readonly themeService = inject(KbqThemeService); protected structureCategories: DocsStructureCategory[]; - readonly currentTheme = computed(() => this.themeService.resolvedMode()); + readonly currentTheme = computed(() => this.themeService.colorScheme()); private readonly elementRef = inject>(ElementRef); private readonly docStates = inject(DocsDocStates); diff --git a/apps/docs/src/app/config.ts b/apps/docs/src/app/config.ts index e1148a1e92..11cb34cddc 100644 --- a/apps/docs/src/app/config.ts +++ b/apps/docs/src/app/config.ts @@ -5,6 +5,7 @@ import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, TitleStrategy } from '@angular/router'; import { KBQ_LOCALE_SERVICE, + KBQ_THEME_STORE, KbqLocaleService, kbqLocaleServiceLangAttrNameProvider, kbqThemeProvider @@ -12,6 +13,7 @@ import { import { kbqIconsResolverProvider } from '@koobiq/components/icon'; import { DOCS_ROUTES } from './routes'; import { docsProvideAnalytics } from './services/analytics'; +import { DocsThemeStore } from './services/theme-store'; import { DocsTitleStrategy } from './services/title-strategy'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -21,6 +23,8 @@ export const appConfig: ApplicationConfig = { kbqLocaleServiceLangAttrNameProvider('examples-lang'), // keeps the pre-existing localStorage key so users who already picked a theme don't lose it kbqThemeProvider({ storageKey: 'docs_theme' }), + // that key held the old navbar's numeric dropdown index, not a mode name - translate it + { provide: KBQ_THEME_STORE, useClass: DocsThemeStore }, kbqIconsResolverProvider((name) => `/assets/SVGIcons/${name.replace(/^kbq-/, '')}.svg`), provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(DOCS_ROUTES), diff --git a/apps/docs/src/app/services/theme-store.spec.ts b/apps/docs/src/app/services/theme-store.spec.ts new file mode 100644 index 0000000000..9ba550f838 --- /dev/null +++ b/apps/docs/src/app/services/theme-store.spec.ts @@ -0,0 +1,46 @@ +import { TestBed } from '@angular/core/testing'; +import { KBQ_THEME_CONFIG, KBQ_WINDOW } from '@koobiq/components/core'; +import { DocsThemeStore } from './theme-store'; + +describe(DocsThemeStore.name, () => { + function setup() { + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_THEME_CONFIG, useValue: { storageKey: 'docs_theme' } }, + { provide: KBQ_WINDOW, useValue: window } + ] + }); + + return TestBed.inject(DocsThemeStore); + } + + afterEach(() => localStorage.clear()); + + it('reads null when nothing is stored', () => { + expect(setup().getMode()).toBeNull(); + }); + + it.each([ + ['0', 'auto'], + ['1', 'light'], + ['2', 'dark'] + ])('migrates the legacy dropdown index %s to mode %s', (legacyIndex, mode) => { + localStorage.setItem('docs_theme', legacyIndex); + + expect(setup().getMode()).toBe(mode); + }); + + it('passes an already-migrated mode name through unchanged', () => { + localStorage.setItem('docs_theme', 'dark'); + + expect(setup().getMode()).toBe('dark'); + }); + + it('writes new mode names, not legacy indexes', () => { + const store = setup(); + + store.setMode('dark'); + + expect(localStorage.getItem('docs_theme')).toBe('dark'); + }); +}); diff --git a/apps/docs/src/app/services/theme-store.ts b/apps/docs/src/app/services/theme-store.ts new file mode 100644 index 0000000000..3a21b992f6 --- /dev/null +++ b/apps/docs/src/app/services/theme-store.ts @@ -0,0 +1,32 @@ +import { inject, Injectable } from '@angular/core'; +import { KbqThemeLocalStorageStore, KbqThemeMode, KbqThemeStore } from '@koobiq/components/core'; + +/** + * Maps the pre-DS-3003 navbar's dropdown index (`DocsNavbarProperty`, options ordered + * system/light/dark) to the mode name `KbqThemeService` expects. + */ +const LEGACY_INDEX_TO_MODE: Record = { + '0': 'auto', + '1': 'light', + '2': 'dark' +}; + +/** + * Reuses the `docs_theme` `localStorage` key from the old navbar, which stored a numeric dropdown + * index (`"0"`/`"1"`/`"2"`) rather than a mode name. Reading that raw value as a mode would resolve + * to no theme and render the site unthemed, so it's translated on the way out. + */ +@Injectable({ providedIn: 'root' }) +export class DocsThemeStore implements KbqThemeStore { + private readonly delegate = inject(KbqThemeLocalStorageStore); + + getMode(): KbqThemeMode | string | null { + const stored = this.delegate.getMode(); + + return stored === null ? null : (LEGACY_INDEX_TO_MODE[stored] ?? stored); + } + + setMode(mode: KbqThemeMode | string): void { + this.delegate.setMode(mode); + } +} diff --git a/packages/components-dev/theme-toggle.ts b/packages/components-dev/theme-toggle.ts index 6848586fbe..ff8d41071d 100644 --- a/packages/components-dev/theme-toggle.ts +++ b/packages/components-dev/theme-toggle.ts @@ -18,7 +18,7 @@ import { KbqToggleModule } from '@koobiq/components/toggle'; }) export class DevThemeToggle { private readonly theme = inject(KbqThemeService); - readonly isDarkTheme = model(this.theme.resolvedMode() === 'dark'); + readonly isDarkTheme = model(this.theme.colorScheme() === 'dark'); constructor() { effect(() => this.theme.setMode(this.isDarkTheme() ? 'dark' : 'light')); diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index 0998f05e4b..8f7b62a543 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -1,10 +1,10 @@ -import { Platform } from '@angular/cdk/platform'; import { TestBed } from '@angular/core/testing'; import { KBQ_WINDOW } from '../tokens/window'; import { KBQ_THEME_CONFIG, KBQ_THEME_STORE, KbqDefaultThemes, + KbqThemeCookieStore, KbqThemeLocalStorageStore, KbqThemeService, KbqThemeStore, @@ -109,6 +109,23 @@ describe('KbqThemeService', () => { expect(service.resolvedMode()).toBe('dark'); }); + it('remembers the last selected theme across an auto toggle, within the session', () => { + const { service } = setup(true); + + service.setMode('light'); + TestBed.tick(); + + service.setAuto(); + TestBed.tick(); + expect(service.theme()).toBe('light'); + expect(service.resolvedMode()).toBe('dark'); + + service.setAuto(false); + TestBed.tick(); + expect(service.auto()).toBe(false); + expect(service.resolvedMode()).toBe('light'); + }); + it('toggle switches between light and dark', () => { const { service } = setup(false); @@ -124,7 +141,7 @@ describe('KbqThemeService', () => { it('supports registering a fully custom set of themes', () => { const { service } = setup(false); - service.setThemes([{ name: 'solarized', className: 'kbq-solarized' }]); + service.setThemes([{ name: 'solarized', className: 'kbq-solarized', colorScheme: 'dark' }]); service.setMode('solarized'); TestBed.tick(); @@ -132,6 +149,36 @@ describe('KbqThemeService', () => { expect(document.body.classList.contains('kbq-solarized')).toBe(true); }); + it("exposes colorScheme as the current theme's own polarity, independent of its name", () => { + const { service } = setup(false); + + service.setThemes([{ name: 'solarized', className: 'kbq-solarized', colorScheme: 'dark' }]); + service.setMode('solarized'); + TestBed.tick(); + + expect(service.colorScheme()).toBe('dark'); + }); + + it("toggle uses the current theme's colorScheme, not a name comparison against autoDark", () => { + const { service } = setup(false); + + // A directly-selected theme whose name matches neither 'light'/'dark' nor autoLight/autoDark - + // comparing resolvedMode() to autoDark (the old implementation) would always toggle to 'dark' + // here, regardless of this theme's actual polarity. + service.setThemes([ + ...service.themes(), + { name: 'solarized', className: 'kbq-solarized', colorScheme: 'dark' } + ]); + service.setMode('solarized'); + TestBed.tick(); + + service.toggle(); + TestBed.tick(); + + expect(service.mode()).toBe('light'); + expect(service.resolvedMode()).toBe('light'); + }); + it('resolves auto mode against custom theme names via autoLight/autoDark', () => { const media = fakeMediaQueryList(true); @@ -142,8 +189,8 @@ describe('KbqThemeService', () => { provide: KBQ_THEME_CONFIG, useValue: { themes: [ - { name: 'sunrise', className: 'kbq-sunrise' }, - { name: 'midnight', className: 'kbq-midnight' } + { name: 'sunrise', className: 'kbq-sunrise', colorScheme: 'light' }, + { name: 'midnight', className: 'kbq-midnight', colorScheme: 'dark' } ], autoLight: 'sunrise', autoDark: 'midnight' @@ -237,10 +284,10 @@ describe('KbqThemeService', () => { }); describe('KbqThemeLocalStorageStore', () => { - function setup(isBrowser: boolean, config: { storageKey?: string } = {}) { + function setup(config: { storageKey?: string } = {}, windowOverrides: Partial = {}) { TestBed.configureTestingModule({ providers: [ - { provide: Platform, useValue: { isBrowser } }, + { provide: KBQ_WINDOW, useValue: { ...window, ...windowOverrides } }, { provide: KBQ_THEME_CONFIG, useValue: config } ] }); @@ -251,7 +298,7 @@ describe('KbqThemeLocalStorageStore', () => { afterEach(() => localStorage.clear()); it('persists and restores the mode via localStorage in the browser', () => { - const store = setup(true); + const store = setup(); expect(store.getMode()).toBeNull(); @@ -260,8 +307,10 @@ describe('KbqThemeLocalStorageStore', () => { expect(store.getMode()).toBe('dark'); }); - it('is a no-op on the server', () => { - const store = setup(false); + it('is a no-op when `localStorage` is unavailable (e.g. on the server)', () => { + // Mirrors the server-provided `KBQ_WINDOW` in apps/docs/src/config.server.ts, which has no + // `localStorage` at all — accessing it throws, which the store must swallow. + const store = setup({}, { localStorage: undefined }); store.setMode('dark'); @@ -270,7 +319,7 @@ describe('KbqThemeLocalStorageStore', () => { }); it('uses the storage key configured via KBQ_THEME_CONFIG', () => { - const store = setup(true, { storageKey: 'docs_theme' }); + const store = setup({ storageKey: 'docs_theme' }); store.setMode('dark'); @@ -278,3 +327,51 @@ describe('KbqThemeLocalStorageStore', () => { expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); }); }); + +describe('KbqThemeCookieStore', () => { + function setup(config: { storageKey?: string } = {}) { + TestBed.configureTestingModule({ + providers: [{ provide: KBQ_THEME_CONFIG, useValue: { storageKey: 'kbq-theme-mode', ...config } }] + }); + + return TestBed.inject(KbqThemeCookieStore); + } + + function clearCookies() { + for (const cookie of document.cookie.split('; ')) { + const name = cookie.split('=')[0]; + + if (name) document.cookie = `${name}=; path=/; max-age=0`; + } + } + + afterEach(() => clearCookies()); + + it('persists and restores the mode via a cookie', () => { + const store = setup(); + + expect(store.getMode()).toBeNull(); + + store.setMode('dark'); + + expect(store.getMode()).toBe('dark'); + expect(document.cookie).toContain('kbq-theme-mode=dark'); + }); + + it('uses the storage key configured via KBQ_THEME_CONFIG', () => { + const store = setup({ storageKey: 'docs_theme' }); + + store.setMode('dark'); + + expect(document.cookie).toContain('docs_theme=dark'); + expect(document.cookie).not.toContain('kbq-theme-mode='); + }); + + it('does not confuse cookies whose name is a suffix of the storage key', () => { + document.cookie = 'other-kbq-theme-mode=dark; path=/'; + + const store = setup(); + + expect(store.getMode()).toBeNull(); + }); +}); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index cd5f3d687a..65526faa98 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -1,4 +1,3 @@ -import { Platform } from '@angular/cdk/platform'; import { DOCUMENT } from '@angular/common'; import { computed, @@ -16,12 +15,21 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { BehaviorSubject, fromEvent } from 'rxjs'; import { KBQ_WINDOW } from '../tokens'; +/** + * Light/dark polarity of a `KbqTheme`, independent of how many themes are registered or what they're named. + * Drives `'auto'` resolution and is the strictly-typed value to reach for when something (e.g. CSS + * `light-dark()`) needs to know which of the two a theme is, regardless of its `name`. + */ +export type KbqThemeColorScheme = 'light' | 'dark'; + /** A theme registered with `KbqThemeService`. */ export interface KbqTheme { /** Unique name used to select the theme via `setMode()`. */ name: string; /** CSS class applied to the document body when this theme is active. */ className: string; + /** This theme's light/dark polarity. Several themes may share the same one (e.g. two dark themes). */ + colorScheme: KbqThemeColorScheme; /** * @deprecated Selection state is now owned by `KbqThemeService` — read `currentTheme()` or `mode()` instead. * Kept in sync by the service for backward compatibility. @@ -29,9 +37,16 @@ export interface KbqTheme { selected?: boolean; } -/** Theme mode understood by `KbqThemeService`. `auto` resolves to `light`/`dark` based on the OS color scheme. */ +/** + * Value accepted by `setMode()`/returned by `mode()`. `'auto'` follows the OS color scheme; `'light'`/`'dark'` + * select the built-in themes. Any other registered theme's `name` also works — see `KbqThemeColorScheme` + * for the value that's actually restricted to 2 options. + */ export type KbqThemeMode = 'auto' | 'light' | 'dark'; +/** `string`, but keeps `KbqThemeMode`'s literals suggested in editors instead of collapsing to plain `string`. */ +export type KbqThemeName = string & {}; + /** * Enum representing the available themes for the Koobiq design system. * This enum is used to manage and switch between different visual themes. @@ -49,9 +64,10 @@ export enum KbqThemeSelector { Dark = 'kbq-dark' } +/** @docs-private */ export const KbqDefaultThemes: KbqTheme[] = [ - { name: 'light', className: KbqThemeSelector.Default }, - { name: 'dark', className: KbqThemeSelector.Dark } + { name: 'light', className: KbqThemeSelector.Default, colorScheme: 'light' }, + { name: 'dark', className: KbqThemeSelector.Dark, colorScheme: 'dark' } ]; /** Configuration accepted by `KBQ_THEME_CONFIG` / `kbqThemeProvider()`. */ @@ -61,7 +77,7 @@ export interface KbqThemeConfig { /** Initial mode, used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default 'auto' */ mode?: KbqThemeMode; /** `localStorage` key used to persist the selected mode. @default 'kbq-theme-mode' */ - storageKey?: string; + storageKey: string; /** Theme `name` that `'auto'` resolves to when the OS prefers a light color scheme. @default 'light' */ autoLight?: string; /** Theme `name` that `'auto'` resolves to when the OS prefers a dark color scheme. @default 'dark' */ @@ -81,10 +97,13 @@ export const KBQ_THEME_CONFIG = new InjectionToken('KBQ_THEME_CO factory: () => KBQ_THEME_DEFAULT_CONFIG }); -/** Configures `KbqThemeService` — registers custom themes, sets the initial mode, and how it's applied to the DOM. */ +/** + * Configures `KbqThemeService` — registers custom themes, sets the initial mode, and how it's applied to the DOM. + * Only the properties you pass are overridden; anything omitted keeps its `KBQ_THEME_DEFAULT_CONFIG` value. + */ export const kbqThemeProvider = (config: KbqThemeConfig): Provider => ({ provide: KBQ_THEME_CONFIG, - useValue: config + useValue: { ...KBQ_THEME_DEFAULT_CONFIG, ...config } }); /** @@ -95,9 +114,9 @@ export const kbqThemeProvider = (config: KbqThemeConfig): Provider => ({ */ export interface KbqThemeStore { /** Returns the previously saved mode, or `null` when nothing is stored/available. */ - getMode(): KbqThemeMode | string | null; + getMode(): KbqThemeMode | KbqThemeName | null; /** Persists the mode. */ - setMode(mode: KbqThemeMode | string): void; + setMode(mode: KbqThemeMode | KbqThemeName): void; } /** @@ -109,31 +128,61 @@ export interface KbqThemeStore { */ @Injectable({ providedIn: 'root' }) export class KbqThemeLocalStorageStore implements KbqThemeStore { - private readonly isBrowser = inject(Platform).isBrowser; private readonly window = inject(KBQ_WINDOW); - private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey ?? KBQ_THEME_DEFAULT_CONFIG.storageKey; - - getMode(): KbqThemeMode | string | null { - if (!this.isBrowser) return null; + private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; + getMode(): KbqThemeMode | KbqThemeName | null { try { return this.window.localStorage.getItem(this.storageKey); } catch { + // No-op on the server, or wherever `localStorage` is unavailable/throws (private mode, sandboxed iframes). return null; } } - setMode(mode: KbqThemeMode | string): void { - if (!this.isBrowser) return; - + setMode(mode: KbqThemeMode | KbqThemeName): void { try { this.window.localStorage.setItem(this.storageKey, mode); } catch { - // Ignore storage write failures (quota exceeded, disabled/blocked storage, etc.). + // Ignore storage write failures (server-side, quota exceeded, disabled/blocked storage, etc.). } } } +/** + * `KbqThemeStore` implementation backed by a cookie, for apps that render with **live** Angular SSR + * (`@angular/ssr` or similar, one render per request) rather than a build-time prerendered/static site. + * + * Unlike `localStorage`, a cookie travels with the HTTP request, so a live server-side render can read + * `DOCUMENT.cookie` and apply the right theme class before the response is ever sent — avoiding the + * flash of the wrong theme that a client-only store cannot prevent, since it can't be read until the + * client's JavaScript runs. This only helps if the app's SSR bootstrap populates `DOCUMENT.cookie` from + * the incoming request's `Cookie` header; that wiring is the app's responsibility, not this library's. + * + * Not useful for a statically prerendered site (no live request to read a cookie from) — use the + * default `KbqThemeLocalStorageStore` there, optionally paired with a small inline script in `index.html` + * that applies the stored preference before first paint. + */ +@Injectable({ providedIn: 'root' }) +export class KbqThemeCookieStore implements KbqThemeStore { + private readonly document = inject(DOCUMENT); + private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; + + getMode(): KbqThemeMode | KbqThemeName | null { + const prefix = `${this.storageKey}=`; + const cookie = this.document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); + + return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null; + } + + setMode(mode: KbqThemeMode | KbqThemeName): void { + // 1 year: matches the lifetime a persisted UI preference is expected to have. SameSite=Lax is + // sent on the top-level navigation request that SSR needs it for, while still blocking + // cross-site reads. + this.document.cookie = `${this.storageKey}=${encodeURIComponent(mode)}; path=/; max-age=31536000; SameSite=Lax`; + } +} + /** * Injection token for the store used to persist the selected theme mode. * Defaults to a `localStorage`-backed implementation (`KbqThemeLocalStorageStore`). @@ -170,17 +219,21 @@ export class KbqThemeService { /** Themes available to select from. Replace via `setThemes()` to register a fully custom set. */ readonly themes = signal(this.config.themes); - /** Currently selected mode. `'auto'` resolves to `light`/`dark` based on the OS color scheme. */ - readonly mode = signal(this.store.getMode() ?? this.config.mode); + private readonly initialMode = this.store.getMode() ?? this.config.mode; - /** `mode()` resolved to a concrete theme name — never `'auto'`. Uses `autoLight`/`autoDark` from `KBQ_THEME_CONFIG`. */ - readonly resolvedMode = computed(() => { - const mode = this.mode(); + /** Whether the theme follows the OS color scheme instead of `theme()`. */ + readonly auto = signal(this.initialMode === 'auto'); - if (mode !== 'auto') return mode; + /** Last explicitly selected theme name. Kept even while `auto()` is on, so turning it off restores it. */ + readonly theme = signal(this.initialMode === 'auto' ? this.config.autoLight : this.initialMode); - return this.systemPrefersDark() ? this.config.autoDark : this.config.autoLight; - }); + /** `'auto'` when `auto()` is on, otherwise `theme()`. A simpler view for a plain 3-way (auto/light/dark) UI. */ + readonly mode = computed(() => (this.auto() ? 'auto' : this.theme())); + + /** `mode()` resolved to a concrete theme name — never `'auto'`. Uses `autoLight`/`autoDark` from `KBQ_THEME_CONFIG`. */ + readonly resolvedMode = computed(() => + this.auto() ? (this.systemPrefersDark() ? this.config.autoDark : this.config.autoLight) : this.theme() + ); /** The theme object currently applied to the document, or `null` if `resolvedMode()` matches no registered theme. */ readonly currentTheme = computed(() => { @@ -189,6 +242,14 @@ export class KbqThemeService { return this.themes().find((theme) => theme.name === resolvedMode) ?? null; }); + /** + * Light/dark polarity of `currentTheme()`. Falls back to the OS preference when `resolvedMode()` + * matches no registered theme, so this is always `'light'`/`'dark'` — never `null`. + */ + readonly colorScheme = computed( + () => this.currentTheme()?.colorScheme ?? (this.systemPrefersDark() ? 'dark' : 'light') + ); + /** * @deprecated read `currentTheme()` instead. Kept in sync for backward compatibility. */ @@ -216,18 +277,29 @@ export class KbqThemeService { } /** Selects a mode by theme `name`, or `'auto'` to follow the OS color scheme. */ - setMode(mode: KbqThemeMode | string) { - this.mode.set(mode); + setMode(mode: KbqThemeMode | KbqThemeName) { + if (mode === 'auto') { + this.auto.set(true); + + return; + } + + this.auto.set(false); + this.theme.set(mode); } - /** Follows the OS color scheme. */ - setAuto() { - this.setMode('auto'); + /** Turns following the OS color scheme on or off. Turning it off restores the last selected `theme()`. */ + setAuto(auto = true) { + this.auto.set(auto); } - /** Switches between `autoLight`/`autoDark` (`light`/`dark` by default), based on the currently resolved mode. */ + /** + * Switches between `autoLight`/`autoDark` (`light`/`dark` by default), based on `colorScheme()` — the + * current theme's actual polarity, not its name. Unlike comparing `resolvedMode()` against `autoDark`, + * this also does the right thing when `currentTheme()` is some other, directly-selected theme. + */ toggle() { - this.setMode(this.resolvedMode() === this.config.autoDark ? this.config.autoLight : this.config.autoDark); + this.setMode(this.colorScheme() === 'dark' ? this.config.autoLight : this.config.autoDark); } /** @deprecated use `setMode()` with a theme `name` instead. */ diff --git a/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts b/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts index 3d94b1a5f1..08e9841e1f 100644 --- a/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts +++ b/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts @@ -63,7 +63,7 @@ export class EmptyStateContentExample { readonly colors = KbqComponentColors; readonly styles = KbqButtonStyles; private readonly themeService = inject(KbqThemeService, { optional: true }); - protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); + protected readonly currentTheme = computed(() => this.themeService?.colorScheme() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts b/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts index 46c204f0df..6e10582538 100644 --- a/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts @@ -64,7 +64,7 @@ export class NotificationCenterEmptyExample { readonly notificationService = inject(KbqNotificationCenterService); private readonly themeService = inject(KbqThemeService, { optional: true }); - protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); + protected readonly currentTheme = computed(() => this.themeService?.colorScheme() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts b/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts index 70ee8a1a53..d49678c4d0 100644 --- a/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts @@ -69,7 +69,7 @@ export class NotificationCenterErrorExample { @ViewChild('actionsTemplate') actionsTemplateRef: TemplateRef; private readonly themeService = inject(KbqThemeService, { optional: true }); - protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); + protected readonly currentTheme = computed(() => this.themeService?.colorScheme() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts b/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts index 30034f8937..6f879f4912 100644 --- a/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts @@ -159,7 +159,7 @@ export class NotificationCenterInfiniteScrollExample { }); private readonly themeService = inject(KbqThemeService, { optional: true }); - protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); + protected readonly currentTheme = computed(() => this.themeService?.colorScheme() ?? 'light'); readonly isDesktop = toSignal( inject(BreakpointObserver) diff --git a/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts b/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts index ea369ae5bf..98918e8180 100644 --- a/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts @@ -75,7 +75,7 @@ export class NotificationCenterOverviewExample implements AfterViewInit { @ViewChild('captionTemplate') captionTemplateRef: TemplateRef; private readonly themeService = inject(KbqThemeService, { optional: true }); - protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); + protected readonly currentTheme = computed(() => this.themeService?.colorScheme() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts b/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts index 5890989a0f..181eacaf03 100644 --- a/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts @@ -82,7 +82,7 @@ export class NotificationCenterPopoverExample implements AfterViewInit { popUpPlacements = PopUpPlacements; private readonly themeService = inject(KbqThemeService, { optional: true }); - protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); + protected readonly currentTheme = computed(() => this.themeService?.colorScheme() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts b/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts index 705a3c4d0d..5fabe86d12 100644 --- a/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts @@ -71,7 +71,7 @@ export class NotificationCenterPushExample implements AfterViewInit { @ViewChild('captionTemplate') captionTemplateRef: TemplateRef; private readonly themeService = inject(KbqThemeService, { optional: true }); - protected readonly currentTheme = computed(() => this.themeService?.resolvedMode() ?? 'light'); + protected readonly currentTheme = computed(() => this.themeService?.colorScheme() ?? 'light'); protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 63228e4883..eebcd94269 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -2722,7 +2722,7 @@ export class KbqDecimalPipe implements KbqNumericPipe, PipeTransform { // @public export type KbqDefaultSizes = 'compact' | 'normal' | 'big'; -// @public (undocumented) +// @public export const KbqDefaultThemes: KbqTheme[]; // @public @@ -3827,26 +3827,42 @@ export class KbqTableNumberPipe implements KbqNumericPipe, PipeTransform { // @public export interface KbqTheme { className: string; + colorScheme: KbqThemeColorScheme; name: string; // @deprecated (undocumented) selected?: boolean; } +// @public +export type KbqThemeColorScheme = 'light' | 'dark'; + // @public export interface KbqThemeConfig { autoDark?: string; autoLight?: string; mode?: KbqThemeMode; - storageKey?: string; + storageKey: string; themes?: T[]; } +// @public +export class KbqThemeCookieStore implements KbqThemeStore { + // (undocumented) + getMode(): KbqThemeMode | KbqThemeName | null; + // (undocumented) + setMode(mode: KbqThemeMode | KbqThemeName): void; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration; +} + // @public export class KbqThemeLocalStorageStore implements KbqThemeStore { // (undocumented) - getMode(): KbqThemeMode | string | null; + getMode(): KbqThemeMode | KbqThemeName | null; // (undocumented) - setMode(mode: KbqThemeMode | string): void; + setMode(mode: KbqThemeMode | KbqThemeName): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3856,6 +3872,9 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { // @public export type KbqThemeMode = 'auto' | 'light' | 'dark'; +// @public +export type KbqThemeName = string & {}; + // @public export const kbqThemeProvider: (config: KbqThemeConfig) => Provider; @@ -3868,18 +3887,21 @@ export enum KbqThemeSelector { // @public export class KbqThemeService { constructor(); + readonly auto: i0.WritableSignal; + readonly colorScheme: i0.Signal; // @deprecated (undocumented) readonly current: BehaviorSubject; readonly currentTheme: i0.Signal; // @deprecated (undocumented) getTheme(): T | null; - readonly mode: i0.WritableSignal; - readonly resolvedMode: i0.Signal; - setAuto(): void; - setMode(mode: KbqThemeMode | string): void; + readonly mode: i0.Signal; + readonly resolvedMode: i0.Signal; + setAuto(auto?: boolean): void; + setMode(mode: KbqThemeMode | KbqThemeName): void; // @deprecated (undocumented) setTheme(value: T | number): void; setThemes(items: T[]): void; + readonly theme: i0.WritableSignal; readonly themes: i0.WritableSignal; toggle(): void; // (undocumented) @@ -3890,8 +3912,8 @@ export class KbqThemeService { // @public export interface KbqThemeStore { - getMode(): KbqThemeMode | string | null; - setMode(mode: KbqThemeMode | string): void; + getMode(): KbqThemeMode | KbqThemeName | null; + setMode(mode: KbqThemeMode | KbqThemeName): void; } // @public From 44ae75bed321e9f9276a5935888f2950923ec308 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Mon, 10 Aug 2026 18:20:18 +0300 Subject: [PATCH 05/15] feat: split mode and theme selection --- .../docs/src/app/services/theme-store.spec.ts | 8 +- apps/docs/src/app/services/theme-store.ts | 12 +- .../core/services/theme.service.spec.ts | 81 +++++++----- .../components/core/services/theme.service.ts | 119 +++++++++--------- tools/public_api_guard/components/core.api.md | 28 +++-- 5 files changed, 133 insertions(+), 115 deletions(-) diff --git a/apps/docs/src/app/services/theme-store.spec.ts b/apps/docs/src/app/services/theme-store.spec.ts index 9ba550f838..d62c1761fa 100644 --- a/apps/docs/src/app/services/theme-store.spec.ts +++ b/apps/docs/src/app/services/theme-store.spec.ts @@ -17,7 +17,7 @@ describe(DocsThemeStore.name, () => { afterEach(() => localStorage.clear()); it('reads null when nothing is stored', () => { - expect(setup().getMode()).toBeNull(); + expect(setup().getSelection()).toBeNull(); }); it.each([ @@ -27,19 +27,19 @@ describe(DocsThemeStore.name, () => { ])('migrates the legacy dropdown index %s to mode %s', (legacyIndex, mode) => { localStorage.setItem('docs_theme', legacyIndex); - expect(setup().getMode()).toBe(mode); + expect(setup().getSelection()).toBe(mode); }); it('passes an already-migrated mode name through unchanged', () => { localStorage.setItem('docs_theme', 'dark'); - expect(setup().getMode()).toBe('dark'); + expect(setup().getSelection()).toBe('dark'); }); it('writes new mode names, not legacy indexes', () => { const store = setup(); - store.setMode('dark'); + store.setSelection('dark'); expect(localStorage.getItem('docs_theme')).toBe('dark'); }); diff --git a/apps/docs/src/app/services/theme-store.ts b/apps/docs/src/app/services/theme-store.ts index 3a21b992f6..a6394ded8f 100644 --- a/apps/docs/src/app/services/theme-store.ts +++ b/apps/docs/src/app/services/theme-store.ts @@ -1,11 +1,11 @@ import { inject, Injectable } from '@angular/core'; -import { KbqThemeLocalStorageStore, KbqThemeMode, KbqThemeStore } from '@koobiq/components/core'; +import { KbqThemeLocalStorageStore, KbqThemeSelection, KbqThemeStore } from '@koobiq/components/core'; /** * Maps the pre-DS-3003 navbar's dropdown index (`DocsNavbarProperty`, options ordered * system/light/dark) to the mode name `KbqThemeService` expects. */ -const LEGACY_INDEX_TO_MODE: Record = { +const LEGACY_INDEX_TO_MODE: Record = { '0': 'auto', '1': 'light', '2': 'dark' @@ -20,13 +20,13 @@ const LEGACY_INDEX_TO_MODE: Record = { export class DocsThemeStore implements KbqThemeStore { private readonly delegate = inject(KbqThemeLocalStorageStore); - getMode(): KbqThemeMode | string | null { - const stored = this.delegate.getMode(); + getSelection(): KbqThemeSelection | null { + const stored = this.delegate.getSelection(); return stored === null ? null : (LEGACY_INDEX_TO_MODE[stored] ?? stored); } - setMode(mode: KbqThemeMode | string): void { - this.delegate.setMode(mode); + setSelection(selection: KbqThemeSelection): void { + this.delegate.setSelection(selection); } } diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index 8f7b62a543..d6f107e301 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -6,6 +6,7 @@ import { KbqDefaultThemes, KbqThemeCookieStore, KbqThemeLocalStorageStore, + kbqThemeProvider, KbqThemeService, KbqThemeStore, ThemeService @@ -42,7 +43,7 @@ describe('KbqThemeService', () => { function setup(matches = false) { const media = fakeMediaQueryList(matches); - store = { getMode: jest.fn().mockReturnValue(null), setMode: jest.fn() }; + store = { getSelection: jest.fn().mockReturnValue(null), setSelection: jest.fn() }; TestBed.configureTestingModule({ providers: [ @@ -67,7 +68,6 @@ describe('KbqThemeService', () => { const { service } = setup(true); expect(service.mode()).toBe('auto'); - expect(service.resolvedMode()).toBe('dark'); expect(service.currentTheme()?.name).toBe('dark'); expect(document.body.classList.contains('kbq-dark')).toBe(true); }); @@ -75,19 +75,19 @@ describe('KbqThemeService', () => { it('defaults to auto mode, resolving light when the OS prefers light', () => { const { service } = setup(false); - expect(service.resolvedMode()).toBe('light'); + expect(service.currentTheme()?.name).toBe('light'); expect(document.body.classList.contains('kbq-light')).toBe(true); }); it('follows OS color scheme changes while in auto mode', () => { const { service, media } = setup(false); - expect(service.resolvedMode()).toBe('light'); + expect(service.currentTheme()?.name).toBe('light'); media.emit(true); TestBed.tick(); - expect(service.resolvedMode()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); expect(document.body.classList.contains('kbq-dark')).toBe(true); expect(document.body.classList.contains('kbq-light')).toBe(false); }); @@ -97,16 +97,16 @@ describe('KbqThemeService', () => { service.setMode('light'); TestBed.tick(); - expect(service.resolvedMode()).toBe('light'); + expect(service.currentTheme()?.name).toBe('light'); service.setMode('dark'); TestBed.tick(); - expect(service.resolvedMode()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); service.setAuto(); TestBed.tick(); expect(service.mode()).toBe('auto'); - expect(service.resolvedMode()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); }); it('remembers the last selected theme across an auto toggle, within the session', () => { @@ -117,13 +117,12 @@ describe('KbqThemeService', () => { service.setAuto(); TestBed.tick(); - expect(service.theme()).toBe('light'); - expect(service.resolvedMode()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); service.setAuto(false); TestBed.tick(); expect(service.auto()).toBe(false); - expect(service.resolvedMode()).toBe('light'); + expect(service.currentTheme()?.name).toBe('light'); }); it('toggle switches between light and dark', () => { @@ -131,18 +130,18 @@ describe('KbqThemeService', () => { service.toggle(); TestBed.tick(); - expect(service.resolvedMode()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); service.toggle(); TestBed.tick(); - expect(service.resolvedMode()).toBe('light'); + expect(service.currentTheme()?.name).toBe('light'); }); it('supports registering a fully custom set of themes', () => { const { service } = setup(false); service.setThemes([{ name: 'solarized', className: 'kbq-solarized', colorScheme: 'dark' }]); - service.setMode('solarized'); + service.selectTheme('solarized'); TestBed.tick(); expect(service.currentTheme()?.className).toBe('kbq-solarized'); @@ -153,7 +152,7 @@ describe('KbqThemeService', () => { const { service } = setup(false); service.setThemes([{ name: 'solarized', className: 'kbq-solarized', colorScheme: 'dark' }]); - service.setMode('solarized'); + service.selectTheme('solarized'); TestBed.tick(); expect(service.colorScheme()).toBe('dark'); @@ -169,14 +168,14 @@ describe('KbqThemeService', () => { ...service.themes(), { name: 'solarized', className: 'kbq-solarized', colorScheme: 'dark' } ]); - service.setMode('solarized'); + service.selectTheme('solarized'); TestBed.tick(); service.toggle(); TestBed.tick(); expect(service.mode()).toBe('light'); - expect(service.resolvedMode()).toBe('light'); + expect(service.currentTheme()?.name).toBe('light'); }); it('resolves auto mode against custom theme names via autoLight/autoDark', () => { @@ -196,7 +195,7 @@ describe('KbqThemeService', () => { autoDark: 'midnight' } }, - { provide: KBQ_THEME_STORE, useValue: { getMode: () => null, setMode: () => {} } } + { provide: KBQ_THEME_STORE, useValue: { getSelection: () => null, setSelection: () => {} } } ] }); @@ -205,13 +204,14 @@ describe('KbqThemeService', () => { TestBed.tick(); expect(service.mode()).toBe('auto'); - expect(service.resolvedMode()).toBe('midnight'); + expect(service.currentTheme()?.name).toBe('midnight'); expect(document.body.classList.contains('kbq-midnight')).toBe(true); service.toggle(); TestBed.tick(); - expect(service.resolvedMode()).toBe('sunrise'); + expect(service.mode()).not.toBe('auto'); + expect(service.currentTheme()?.name).toBe('sunrise'); expect(document.body.classList.contains('kbq-sunrise')).toBe(true); }); @@ -221,13 +221,13 @@ describe('KbqThemeService', () => { service.setMode('dark'); TestBed.tick(); - expect(store.setMode).toHaveBeenCalledWith('dark'); + expect(store.setSelection).toHaveBeenCalledWith('dark'); }); it('restores the mode persisted in KBQ_THEME_STORE on init', () => { const media = fakeMediaQueryList(false); - store = { getMode: jest.fn().mockReturnValue('dark'), setMode: jest.fn() }; + store = { getSelection: jest.fn().mockReturnValue('dark'), setSelection: jest.fn() }; TestBed.configureTestingModule({ providers: [ @@ -241,7 +241,7 @@ describe('KbqThemeService', () => { TestBed.tick(); expect(service.mode()).toBe('dark'); - expect(service.resolvedMode()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); }); it('keeps the deprecated `selected` field in sync for backward compatibility', () => { @@ -300,11 +300,11 @@ describe('KbqThemeLocalStorageStore', () => { it('persists and restores the mode via localStorage in the browser', () => { const store = setup(); - expect(store.getMode()).toBeNull(); + expect(store.getSelection()).toBeNull(); - store.setMode('dark'); + store.setSelection('dark'); - expect(store.getMode()).toBe('dark'); + expect(store.getSelection()).toBe('dark'); }); it('is a no-op when `localStorage` is unavailable (e.g. on the server)', () => { @@ -312,16 +312,16 @@ describe('KbqThemeLocalStorageStore', () => { // `localStorage` at all — accessing it throws, which the store must swallow. const store = setup({}, { localStorage: undefined }); - store.setMode('dark'); + store.setSelection('dark'); - expect(store.getMode()).toBeNull(); + expect(store.getSelection()).toBeNull(); expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); }); it('uses the storage key configured via KBQ_THEME_CONFIG', () => { const store = setup({ storageKey: 'docs_theme' }); - store.setMode('dark'); + store.setSelection('dark'); expect(localStorage.getItem('docs_theme')).toBe('dark'); expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); @@ -350,18 +350,18 @@ describe('KbqThemeCookieStore', () => { it('persists and restores the mode via a cookie', () => { const store = setup(); - expect(store.getMode()).toBeNull(); + expect(store.getSelection()).toBeNull(); - store.setMode('dark'); + store.setSelection('dark'); - expect(store.getMode()).toBe('dark'); + expect(store.getSelection()).toBe('dark'); expect(document.cookie).toContain('kbq-theme-mode=dark'); }); it('uses the storage key configured via KBQ_THEME_CONFIG', () => { const store = setup({ storageKey: 'docs_theme' }); - store.setMode('dark'); + store.setSelection('dark'); expect(document.cookie).toContain('docs_theme=dark'); expect(document.cookie).not.toContain('kbq-theme-mode='); @@ -372,6 +372,19 @@ describe('KbqThemeCookieStore', () => { const store = setup(); - expect(store.getMode()).toBeNull(); + expect(store.getSelection()).toBeNull(); + }); +}); + +describe('kbqThemeProvider', () => { + it('merges a partial config with defaults, so omitted properties keep their default value', () => { + TestBed.configureTestingModule({ + providers: [kbqThemeProvider({ mode: 'dark' })] + }); + + const config = TestBed.inject(KBQ_THEME_CONFIG); + + expect(config.mode).toBe('dark'); + expect(config.storageKey).toBe('kbq-theme-mode'); }); }); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index 65526faa98..65025dca74 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -24,12 +24,15 @@ export type KbqThemeColorScheme = 'light' | 'dark'; /** A theme registered with `KbqThemeService`. */ export interface KbqTheme { - /** Unique name used to select the theme via `setMode()`. */ + /** Unique name used to select the theme via `selectTheme()`. */ name: string; /** CSS class applied to the document body when this theme is active. */ className: string; - /** This theme's light/dark polarity. Several themes may share the same one (e.g. two dark themes). */ - colorScheme: KbqThemeColorScheme; + /** + * This theme's light/dark polarity. Several themes may share the same one (e.g. two dark themes). + * Optional — when omitted, `colorScheme()` falls back to the OS preference instead of this theme's own value. + */ + colorScheme?: KbqThemeColorScheme; /** * @deprecated Selection state is now owned by `KbqThemeService` — read `currentTheme()` or `mode()` instead. * Kept in sync by the service for backward compatibility. @@ -38,33 +41,26 @@ export interface KbqTheme { } /** - * Value accepted by `setMode()`/returned by `mode()`. `'auto'` follows the OS color scheme; `'light'`/`'dark'` - * select the built-in themes. Any other registered theme's `name` also works — see `KbqThemeColorScheme` - * for the value that's actually restricted to 2 options. + * Value accepted by `setMode()`. `'auto'` follows the OS color scheme; `'light'`/`'dark'` select those themes + * directly. To select a different registered theme by name, use `selectTheme()` instead. */ export type KbqThemeMode = 'auto' | 'light' | 'dark'; /** `string`, but keeps `KbqThemeMode`'s literals suggested in editors instead of collapsing to plain `string`. */ export type KbqThemeName = string & {}; -/** - * Enum representing the available themes for the Koobiq design system. - * This enum is used to manage and switch between different visual themes. - */ +/** What `mode()` returns and `KbqThemeStore` persists: `'auto'`, or the selected theme's `name`. */ +export type KbqThemeSelection = 'auto' | KbqThemeName; + +/** CSS class names for `KbqDefaultThemes`, the built-in light/dark theme set. */ export enum KbqThemeSelector { - /** - * Represents the default light theme. - * This is the standard theme applied - * when the application is first loaded if nothing else provided - */ + /** Class for the built-in light theme. */ Default = 'kbq-light', - /** - * This theme is used to provide a darker visual experience, often preferred in low-light environments. - */ + /** Class for the built-in dark theme. */ Dark = 'kbq-dark' } -/** @docs-private */ +/** The built-in light/dark theme set — `KBQ_THEME_CONFIG`'s default `themes`. @docs-private */ export const KbqDefaultThemes: KbqTheme[] = [ { name: 'light', className: KbqThemeSelector.Default, colorScheme: 'light' }, { name: 'dark', className: KbqThemeSelector.Dark, colorScheme: 'dark' } @@ -73,15 +69,15 @@ export const KbqDefaultThemes: KbqTheme[] = [ /** Configuration accepted by `KBQ_THEME_CONFIG` / `kbqThemeProvider()`. */ export interface KbqThemeConfig { /** Themes available to the service. @default KbqDefaultThemes */ - themes?: T[]; + themes: T[]; /** Initial mode, used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default 'auto' */ - mode?: KbqThemeMode; - /** `localStorage` key used to persist the selected mode. @default 'kbq-theme-mode' */ + mode: KbqThemeMode; + /** Key used to persist the selection — a `localStorage` key or cookie name, depending on `KBQ_THEME_STORE`. @default 'kbq-theme-mode' */ storageKey: string; /** Theme `name` that `'auto'` resolves to when the OS prefers a light color scheme. @default 'light' */ - autoLight?: string; + autoLight: string; /** Theme `name` that `'auto'` resolves to when the OS prefers a dark color scheme. @default 'dark' */ - autoDark?: string; + autoDark: string; } const KBQ_THEME_DEFAULT_CONFIG: Required = { @@ -92,6 +88,7 @@ const KBQ_THEME_DEFAULT_CONFIG: Required = { autoDark: 'dark' }; +/** Injection token for `KbqThemeService`'s configuration. Configure via `kbqThemeProvider()`, not this directly. */ export const KBQ_THEME_CONFIG = new InjectionToken('KBQ_THEME_CONFIG', { providedIn: 'root', factory: () => KBQ_THEME_DEFAULT_CONFIG @@ -107,16 +104,17 @@ export const kbqThemeProvider = (config: KbqThemeConfig): Provider => ({ }); /** - * Strategy used by `KbqThemeService` to persist and restore the selected theme mode. + * Strategy used by `KbqThemeService` to persist and restore `mode()` — `'auto'` or a selected theme `name`, + * not a mode alone, hence "selection" rather than "mode" here. * - * Provide a custom implementation through the `KBQ_THEME_STORE` token to change where the mode is stored + * Provide a custom implementation through the `KBQ_THEME_STORE` token to change where it's stored * (e.g. `sessionStorage`, a backend), or to disable persistence entirely. */ export interface KbqThemeStore { - /** Returns the previously saved mode, or `null` when nothing is stored/available. */ - getMode(): KbqThemeMode | KbqThemeName | null; - /** Persists the mode. */ - setMode(mode: KbqThemeMode | KbqThemeName): void; + /** Returns the previously saved selection, or `null` when nothing is stored/available. */ + getSelection(): KbqThemeSelection | null; + /** Persists the selection. */ + setSelection(selection: KbqThemeSelection): void; } /** @@ -131,7 +129,7 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { private readonly window = inject(KBQ_WINDOW); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - getMode(): KbqThemeMode | KbqThemeName | null { + getSelection(): KbqThemeSelection | null { try { return this.window.localStorage.getItem(this.storageKey); } catch { @@ -140,9 +138,9 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { } } - setMode(mode: KbqThemeMode | KbqThemeName): void { + setSelection(selection: KbqThemeSelection): void { try { - this.window.localStorage.setItem(this.storageKey, mode); + this.window.localStorage.setItem(this.storageKey, selection); } catch { // Ignore storage write failures (server-side, quota exceeded, disabled/blocked storage, etc.). } @@ -168,23 +166,23 @@ export class KbqThemeCookieStore implements KbqThemeStore { private readonly document = inject(DOCUMENT); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - getMode(): KbqThemeMode | KbqThemeName | null { + getSelection(): KbqThemeSelection | null { const prefix = `${this.storageKey}=`; const cookie = this.document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null; } - setMode(mode: KbqThemeMode | KbqThemeName): void { + setSelection(selection: KbqThemeSelection): void { // 1 year: matches the lifetime a persisted UI preference is expected to have. SameSite=Lax is // sent on the top-level navigation request that SSR needs it for, while still blocking // cross-site reads. - this.document.cookie = `${this.storageKey}=${encodeURIComponent(mode)}; path=/; max-age=31536000; SameSite=Lax`; + this.document.cookie = `${this.storageKey}=${encodeURIComponent(selection)}; path=/; max-age=31536000; SameSite=Lax`; } } /** - * Injection token for the store used to persist the selected theme mode. + * Injection token for the store used to persist the current selection (see `KbqThemeStore`). * Defaults to a `localStorage`-backed implementation (`KbqThemeLocalStorageStore`). */ export const KBQ_THEME_STORE = new InjectionToken('KBQ_THEME_STORE', { @@ -215,27 +213,27 @@ export class KbqThemeService { private readonly renderer: Renderer2; private readonly media = this.window.matchMedia('(prefers-color-scheme: dark)'); private readonly systemPrefersDark = signal(this.media.matches); + /** Seeds `auto`/`theme` below from persisted state, falling back to `config.mode` on first run. */ + private readonly initialMode = this.store.getSelection() ?? this.config.mode; + /** Last explicitly selected theme name. Kept even while `auto()` is on, so turning it off restores it. */ + private readonly theme = signal( + this.initialMode === 'auto' ? this.config.autoLight : this.initialMode + ); + /** The concrete theme name `mode()` resolves to when not `'auto'` — the lookup key for `currentTheme()`. */ + private readonly resolvedMode = computed(() => + this.auto() ? (this.systemPrefersDark() ? this.config.autoDark : this.config.autoLight) : this.theme() + ); /** Themes available to select from. Replace via `setThemes()` to register a fully custom set. */ readonly themes = signal(this.config.themes); - private readonly initialMode = this.store.getMode() ?? this.config.mode; - /** Whether the theme follows the OS color scheme instead of `theme()`. */ readonly auto = signal(this.initialMode === 'auto'); - /** Last explicitly selected theme name. Kept even while `auto()` is on, so turning it off restores it. */ - readonly theme = signal(this.initialMode === 'auto' ? this.config.autoLight : this.initialMode); - /** `'auto'` when `auto()` is on, otherwise `theme()`. A simpler view for a plain 3-way (auto/light/dark) UI. */ - readonly mode = computed(() => (this.auto() ? 'auto' : this.theme())); + readonly mode = computed(() => (this.auto() ? 'auto' : this.theme())); - /** `mode()` resolved to a concrete theme name — never `'auto'`. Uses `autoLight`/`autoDark` from `KBQ_THEME_CONFIG`. */ - readonly resolvedMode = computed(() => - this.auto() ? (this.systemPrefersDark() ? this.config.autoDark : this.config.autoLight) : this.theme() - ); - - /** The theme object currently applied to the document, or `null` if `resolvedMode()` matches no registered theme. */ + /** The theme object currently applied to the document, or `null` if the resolved name matches none. */ readonly currentTheme = computed(() => { const resolvedMode = this.resolvedMode(); @@ -268,7 +266,7 @@ export class KbqThemeService { this.applyTheme(currentTheme, this.themes()); this.current.next(currentTheme); }); - effect(() => this.store.setMode(this.mode())); + effect(() => this.store.setSelection(this.mode())); } /** Registers a custom set of themes. */ @@ -276,16 +274,21 @@ export class KbqThemeService { this.themes.set(items); } - /** Selects a mode by theme `name`, or `'auto'` to follow the OS color scheme. */ - setMode(mode: KbqThemeMode | KbqThemeName) { + /** Selects a specific registered theme directly by `name`, turning `auto()` off. */ + selectTheme(name: KbqThemeName) { + this.auto.set(false); + this.theme.set(name); + } + + /** Selects a fixed `'light'`/`'dark'` mode, or `'auto'` to follow the OS color scheme. */ + setMode(mode: KbqThemeMode) { if (mode === 'auto') { this.auto.set(true); return; } - this.auto.set(false); - this.theme.set(mode); + this.selectTheme(mode); } /** Turns following the OS color scheme on or off. Turning it off restores the last selected `theme()`. */ @@ -299,17 +302,17 @@ export class KbqThemeService { * this also does the right thing when `currentTheme()` is some other, directly-selected theme. */ toggle() { - this.setMode(this.colorScheme() === 'dark' ? this.config.autoLight : this.config.autoDark); + this.selectTheme(this.colorScheme() === 'dark' ? this.config.autoLight : this.config.autoDark); } - /** @deprecated use `setMode()` with a theme `name` instead. */ + /** @deprecated use `selectTheme()` with a theme `name` instead. */ setTheme(value: T | number) { if (typeof value === 'number') { const theme = this.themes()[value]; - if (theme) this.setMode(theme.name); + if (theme) this.selectTheme(theme.name); } else if (typeof value === 'object' && value !== null && this.themes().includes(value)) { - this.setMode(value.name); + this.selectTheme(value.name); } else { throw Error(`value has unsupported type: ${typeof value}`); } diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index eebcd94269..abd33e319a 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -2424,7 +2424,7 @@ export const KBQ_SIZE_UNITS_CONFIG: InjectionToken; // @public (undocumented) export const KBQ_SIZE_UNITS_DEFAULT_CONFIG: KbqSizeUnitsConfig; -// @public (undocumented) +// @public export const KBQ_THEME_CONFIG: InjectionToken>; // @public @@ -3827,7 +3827,7 @@ export class KbqTableNumberPipe implements KbqNumericPipe, PipeTransform { // @public export interface KbqTheme { className: string; - colorScheme: KbqThemeColorScheme; + colorScheme?: KbqThemeColorScheme; name: string; // @deprecated (undocumented) selected?: boolean; @@ -3841,16 +3841,16 @@ export interface KbqThemeConfig { autoDark?: string; autoLight?: string; mode?: KbqThemeMode; - storageKey: string; + storageKey?: string; themes?: T[]; } // @public export class KbqThemeCookieStore implements KbqThemeStore { // (undocumented) - getMode(): KbqThemeMode | KbqThemeName | null; + getSelection(): KbqThemeSelection | null; // (undocumented) - setMode(mode: KbqThemeMode | KbqThemeName): void; + setSelection(selection: KbqThemeSelection): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3860,9 +3860,9 @@ export class KbqThemeCookieStore implements KbqThemeStore { // @public export class KbqThemeLocalStorageStore implements KbqThemeStore { // (undocumented) - getMode(): KbqThemeMode | KbqThemeName | null; + getSelection(): KbqThemeSelection | null; // (undocumented) - setMode(mode: KbqThemeMode | KbqThemeName): void; + setSelection(selection: KbqThemeSelection): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3878,6 +3878,9 @@ export type KbqThemeName = string & {}; // @public export const kbqThemeProvider: (config: KbqThemeConfig) => Provider; +// @public +export type KbqThemeSelection = 'auto' | KbqThemeName; + // @public export enum KbqThemeSelector { Dark = "kbq-dark", @@ -3894,14 +3897,13 @@ export class KbqThemeService { readonly currentTheme: i0.Signal; // @deprecated (undocumented) getTheme(): T | null; - readonly mode: i0.Signal; - readonly resolvedMode: i0.Signal; + readonly mode: i0.Signal; + selectTheme(name: KbqThemeName): void; setAuto(auto?: boolean): void; - setMode(mode: KbqThemeMode | KbqThemeName): void; + setMode(mode: KbqThemeMode): void; // @deprecated (undocumented) setTheme(value: T | number): void; setThemes(items: T[]): void; - readonly theme: i0.WritableSignal; readonly themes: i0.WritableSignal; toggle(): void; // (undocumented) @@ -3912,8 +3914,8 @@ export class KbqThemeService { // @public export interface KbqThemeStore { - getMode(): KbqThemeMode | KbqThemeName | null; - setMode(mode: KbqThemeMode | KbqThemeName): void; + getSelection(): KbqThemeSelection | null; + setSelection(selection: KbqThemeSelection): void; } // @public From f5c5f784f2f66cf22861adc9f9b7237e6a6e0782 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Mon, 10 Aug 2026 18:44:21 +0300 Subject: [PATCH 06/15] feat: refactoring, simplification --- .../app/components/navbar/navbar.component.ts | 2 +- .../components/navbar/navbar.template.html | 2 +- .../core/services/theme.service.spec.ts | 16 +++---- .../components/core/services/theme.service.ts | 43 +++++++++---------- tools/public_api_guard/components/core.api.md | 16 +++---- 5 files changed, 38 insertions(+), 41 deletions(-) diff --git a/apps/docs/src/app/components/navbar/navbar.component.ts b/apps/docs/src/app/components/navbar/navbar.component.ts index f5fd45bc96..3d675f37df 100644 --- a/apps/docs/src/app/components/navbar/navbar.component.ts +++ b/apps/docs/src/app/components/navbar/navbar.component.ts @@ -55,7 +55,7 @@ export class DocsNavbarComponent extends DocsLocaleState { ]; /** The currently selected mode — persistence and OS-preference resolution are handled by `KbqThemeService`. */ - readonly mode = computed(() => this.themeService.mode()); + readonly selection = computed(() => this.themeService.selection()); readonly opened$: Observable = this.docStates.navbarMenu.pipe( map((state) => state === DocsNavbarState.Opened) diff --git a/apps/docs/src/app/components/navbar/navbar.template.html b/apps/docs/src/app/components/navbar/navbar.template.html index 680e36f743..9bf4b23b51 100644 --- a/apps/docs/src/app/components/navbar/navbar.template.html +++ b/apps/docs/src/app/components/navbar/navbar.template.html @@ -81,7 +81,7 @@ @for (option of themeOptions; track option.mode) { - } diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index d6f107e301..eb94b7e3db 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -67,7 +67,7 @@ describe('KbqThemeService', () => { it('defaults to auto mode, resolving dark when the OS prefers dark', () => { const { service } = setup(true); - expect(service.mode()).toBe('auto'); + expect(service.selection()).toBe('auto'); expect(service.currentTheme()?.name).toBe('dark'); expect(document.body.classList.contains('kbq-dark')).toBe(true); }); @@ -105,7 +105,7 @@ describe('KbqThemeService', () => { service.setAuto(); TestBed.tick(); - expect(service.mode()).toBe('auto'); + expect(service.selection()).toBe('auto'); expect(service.currentTheme()?.name).toBe('dark'); }); @@ -174,7 +174,7 @@ describe('KbqThemeService', () => { service.toggle(); TestBed.tick(); - expect(service.mode()).toBe('light'); + expect(service.selection()).toBe('light'); expect(service.currentTheme()?.name).toBe('light'); }); @@ -203,14 +203,14 @@ describe('KbqThemeService', () => { TestBed.tick(); - expect(service.mode()).toBe('auto'); + expect(service.selection()).toBe('auto'); expect(service.currentTheme()?.name).toBe('midnight'); expect(document.body.classList.contains('kbq-midnight')).toBe(true); service.toggle(); TestBed.tick(); - expect(service.mode()).not.toBe('auto'); + expect(service.selection()).not.toBe('auto'); expect(service.currentTheme()?.name).toBe('sunrise'); expect(document.body.classList.contains('kbq-sunrise')).toBe(true); }); @@ -240,7 +240,7 @@ describe('KbqThemeService', () => { TestBed.tick(); - expect(service.mode()).toBe('dark'); + expect(service.selection()).toBe('dark'); expect(service.currentTheme()?.name).toBe('dark'); }); @@ -258,12 +258,12 @@ describe('KbqThemeService', () => { service.setTheme(1); TestBed.tick(); - expect(service.mode()).toBe('dark'); + expect(service.selection()).toBe('dark'); expect(service.getTheme()).toBe(service.currentTheme()); service.setTheme(KbqDefaultThemes[0]); TestBed.tick(); - expect(service.mode()).toBe('light'); + expect(service.selection()).toBe('light'); }); it('exports `ThemeService` as a deprecated alias of `KbqThemeService`', () => { diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index 65025dca74..28006b87c8 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -34,7 +34,7 @@ export interface KbqTheme { */ colorScheme?: KbqThemeColorScheme; /** - * @deprecated Selection state is now owned by `KbqThemeService` — read `currentTheme()` or `mode()` instead. + * @deprecated Selection state is now owned by `KbqThemeService` — read `currentTheme()` or `selection()` instead. * Kept in sync by the service for backward compatibility. */ selected?: boolean; @@ -44,12 +44,12 @@ export interface KbqTheme { * Value accepted by `setMode()`. `'auto'` follows the OS color scheme; `'light'`/`'dark'` select those themes * directly. To select a different registered theme by name, use `selectTheme()` instead. */ -export type KbqThemeMode = 'auto' | 'light' | 'dark'; +export type KbqThemeMode = 'auto' | KbqThemeColorScheme; /** `string`, but keeps `KbqThemeMode`'s literals suggested in editors instead of collapsing to plain `string`. */ export type KbqThemeName = string & {}; -/** What `mode()` returns and `KbqThemeStore` persists: `'auto'`, or the selected theme's `name`. */ +/** What `selection()` returns and `KbqThemeStore` persists: `'auto'`, or the selected theme's `name`. */ export type KbqThemeSelection = 'auto' | KbqThemeName; /** CSS class names for `KbqDefaultThemes`, the built-in light/dark theme set. */ @@ -80,7 +80,7 @@ export interface KbqThemeConfig { autoDark: string; } -const KBQ_THEME_DEFAULT_CONFIG: Required = { +const KBQ_THEME_DEFAULT_CONFIG: KbqThemeConfig = { themes: KbqDefaultThemes, mode: 'auto', storageKey: 'kbq-theme-mode', @@ -98,13 +98,13 @@ export const KBQ_THEME_CONFIG = new InjectionToken('KBQ_THEME_CO * Configures `KbqThemeService` — registers custom themes, sets the initial mode, and how it's applied to the DOM. * Only the properties you pass are overridden; anything omitted keeps its `KBQ_THEME_DEFAULT_CONFIG` value. */ -export const kbqThemeProvider = (config: KbqThemeConfig): Provider => ({ +export const kbqThemeProvider = (config: Partial): Provider => ({ provide: KBQ_THEME_CONFIG, useValue: { ...KBQ_THEME_DEFAULT_CONFIG, ...config } }); /** - * Strategy used by `KbqThemeService` to persist and restore `mode()` — `'auto'` or a selected theme `name`, + * Strategy used by `KbqThemeService` to persist and restore `selection()` — `'auto'` or a selected theme `name`, * not a mode alone, hence "selection" rather than "mode" here. * * Provide a custom implementation through the `KBQ_THEME_STORE` token to change where it's stored @@ -205,10 +205,10 @@ export class KbqThemeService { private readonly window = inject(KBQ_WINDOW); private readonly store = inject(KBQ_THEME_STORE); private readonly destroyRef = inject(DestroyRef); - private readonly config: Required> = { + private readonly config: KbqThemeConfig = { ...KBQ_THEME_DEFAULT_CONFIG, ...inject(KBQ_THEME_CONFIG) - } as Required>; + } as KbqThemeConfig; private readonly renderer: Renderer2; private readonly media = this.window.matchMedia('(prefers-color-scheme: dark)'); @@ -219,10 +219,6 @@ export class KbqThemeService { private readonly theme = signal( this.initialMode === 'auto' ? this.config.autoLight : this.initialMode ); - /** The concrete theme name `mode()` resolves to when not `'auto'` — the lookup key for `currentTheme()`. */ - private readonly resolvedMode = computed(() => - this.auto() ? (this.systemPrefersDark() ? this.config.autoDark : this.config.autoLight) : this.theme() - ); /** Themes available to select from. Replace via `setThemes()` to register a fully custom set. */ readonly themes = signal(this.config.themes); @@ -231,19 +227,20 @@ export class KbqThemeService { readonly auto = signal(this.initialMode === 'auto'); /** `'auto'` when `auto()` is on, otherwise `theme()`. A simpler view for a plain 3-way (auto/light/dark) UI. */ - readonly mode = computed(() => (this.auto() ? 'auto' : this.theme())); + readonly selection = computed(() => (this.auto() ? 'auto' : this.theme())); /** The theme object currently applied to the document, or `null` if the resolved name matches none. */ readonly currentTheme = computed(() => { - const resolvedMode = this.resolvedMode(); + const resolvedThemeName = this.auto() + ? this.systemPrefersDark() + ? this.config.autoDark + : this.config.autoLight + : this.theme(); - return this.themes().find((theme) => theme.name === resolvedMode) ?? null; + return this.themes().find((theme) => theme.name === resolvedThemeName) ?? null; }); - /** - * Light/dark polarity of `currentTheme()`. Falls back to the OS preference when `resolvedMode()` - * matches no registered theme, so this is always `'light'`/`'dark'` — never `null`. - */ + /** Light/dark polarity of `currentTheme()`. Falls back to the OS preference. */ readonly colorScheme = computed( () => this.currentTheme()?.colorScheme ?? (this.systemPrefersDark() ? 'dark' : 'light') ); @@ -266,7 +263,7 @@ export class KbqThemeService { this.applyTheme(currentTheme, this.themes()); this.current.next(currentTheme); }); - effect(() => this.store.setSelection(this.mode())); + effect(() => this.store.setSelection(this.selection())); } /** Registers a custom set of themes. */ @@ -297,9 +294,9 @@ export class KbqThemeService { } /** - * Switches between `autoLight`/`autoDark` (`light`/`dark` by default), based on `colorScheme()` — the - * current theme's actual polarity, not its name. Unlike comparing `resolvedMode()` against `autoDark`, - * this also does the right thing when `currentTheme()` is some other, directly-selected theme. + * Switches between `autoLight`/`autoDark` (`light`/`dark` by default), based on `colorScheme()` — so + * it does the right thing even when `currentTheme()` is some other, directly-selected theme whose + * `name` doesn't match `light`/`dark`/`autoLight`/`autoDark`. */ toggle() { this.selectTheme(this.colorScheme() === 'dark' ? this.config.autoLight : this.config.autoDark); diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index abd33e319a..0362f17867 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -3838,11 +3838,11 @@ export type KbqThemeColorScheme = 'light' | 'dark'; // @public export interface KbqThemeConfig { - autoDark?: string; - autoLight?: string; - mode?: KbqThemeMode; - storageKey?: string; - themes?: T[]; + autoDark: string; + autoLight: string; + mode: KbqThemeMode; + storageKey: string; + themes: T[]; } // @public @@ -3870,13 +3870,13 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { } // @public -export type KbqThemeMode = 'auto' | 'light' | 'dark'; +export type KbqThemeMode = 'auto' | KbqThemeColorScheme; // @public export type KbqThemeName = string & {}; // @public -export const kbqThemeProvider: (config: KbqThemeConfig) => Provider; +export const kbqThemeProvider: (config: Partial) => Provider; // @public export type KbqThemeSelection = 'auto' | KbqThemeName; @@ -3897,7 +3897,7 @@ export class KbqThemeService { readonly currentTheme: i0.Signal; // @deprecated (undocumented) getTheme(): T | null; - readonly mode: i0.Signal; + readonly selection: i0.Signal; selectTheme(name: KbqThemeName): void; setAuto(auto?: boolean): void; setMode(mode: KbqThemeMode): void; From 7e07c5595aa3c9c9f91d2b8f19df68bb673788a4 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 11 Aug 2026 10:04:48 +0300 Subject: [PATCH 07/15] feat: refactoring, simplification --- .../app/components/navbar/navbar.component.ts | 12 +-- apps/docs/src/app/services/theme-store.ts | 8 +- docs/guides/migration.en.md | 10 +- docs/guides/migration.ru.md | 10 +- packages/components-dev/theme-toggle.ts | 10 +- .../core/services/theme.service.spec.ts | 26 +---- .../components/core/services/theme.service.ts | 96 +++++++------------ tools/public_api_guard/components/core.api.md | 35 ++++--- 8 files changed, 81 insertions(+), 126 deletions(-) diff --git a/apps/docs/src/app/components/navbar/navbar.component.ts b/apps/docs/src/app/components/navbar/navbar.component.ts index 3d675f37df..14487cf4d2 100644 --- a/apps/docs/src/app/components/navbar/navbar.component.ts +++ b/apps/docs/src/app/components/navbar/navbar.component.ts @@ -2,7 +2,7 @@ import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core'; import { RouterLink } from '@angular/router'; import { KbqButtonModule } from '@koobiq/components/button'; -import { KbqThemeMode, KbqThemeService } from '@koobiq/components/core'; +import { KbqThemeName, KbqThemeNames, KbqThemeService } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; @@ -17,7 +17,7 @@ import { DocsDocsearchDirective } from '../docsearch/docsearch.directive'; /** A theme mode selectable from the navbar's theme dropdown. */ interface DocsThemeOption { - mode: KbqThemeMode; + mode: KbqThemeNames | KbqThemeName; title: Record; } @@ -50,8 +50,8 @@ export class DocsNavbarComponent extends DocsLocaleState { /** Options shown in the theme dropdown. `auto` follows the OS color scheme, handled inside `KbqThemeService`. */ readonly themeOptions: DocsThemeOption[] = [ { mode: 'auto', title: DOCS_TRANSLATIONS.themeSystem }, - { mode: 'light', title: DOCS_TRANSLATIONS.themeLight }, - { mode: 'dark', title: DOCS_TRANSLATIONS.themeDark } + { mode: KbqThemeNames.Default, title: DOCS_TRANSLATIONS.themeLight }, + { mode: KbqThemeNames.Dark, title: DOCS_TRANSLATIONS.themeDark } ]; /** The currently selected mode — persistence and OS-preference resolution are handled by `KbqThemeService`. */ @@ -65,7 +65,7 @@ export class DocsNavbarComponent extends DocsLocaleState { this.docStates.toggleNavbarMenu(); } - setTheme(mode: KbqThemeMode) { - this.themeService.setMode(mode); + setTheme(mode: DocsThemeOption['mode']) { + this.themeService.selectTheme(mode); } } diff --git a/apps/docs/src/app/services/theme-store.ts b/apps/docs/src/app/services/theme-store.ts index a6394ded8f..03fec2a87c 100644 --- a/apps/docs/src/app/services/theme-store.ts +++ b/apps/docs/src/app/services/theme-store.ts @@ -1,11 +1,11 @@ import { inject, Injectable } from '@angular/core'; -import { KbqThemeLocalStorageStore, KbqThemeSelection, KbqThemeStore } from '@koobiq/components/core'; +import { KbqThemeLocalStorageStore, KbqThemeName, KbqThemeStore } from '@koobiq/components/core'; /** * Maps the pre-DS-3003 navbar's dropdown index (`DocsNavbarProperty`, options ordered * system/light/dark) to the mode name `KbqThemeService` expects. */ -const LEGACY_INDEX_TO_MODE: Record = { +const LEGACY_INDEX_TO_MODE: Record = { '0': 'auto', '1': 'light', '2': 'dark' @@ -20,13 +20,13 @@ const LEGACY_INDEX_TO_MODE: Record = { export class DocsThemeStore implements KbqThemeStore { private readonly delegate = inject(KbqThemeLocalStorageStore); - getSelection(): KbqThemeSelection | null { + getSelection(): KbqThemeName | null { const stored = this.delegate.getSelection(); return stored === null ? null : (LEGACY_INDEX_TO_MODE[stored] ?? stored); } - setSelection(selection: KbqThemeSelection): void { + setSelection(selection: KbqThemeName): void { this.delegate.setSelection(selection); } } diff --git a/docs/guides/migration.en.md b/docs/guides/migration.en.md index c64e1983fc..c284812bbc 100644 --- a/docs/guides/migration.en.md +++ b/docs/guides/migration.en.md @@ -748,7 +748,7 @@ A receiver is matched by its explicit type annotation (`KbqFormField`, `KbqHint` **It's `KbqThemeService` now.** `ThemeService` is exported as a `@deprecated` alias of `KbqThemeService` and will be removed in a future major version. There is no `ng update` schematic for the rename — swap the import when convenient. -**`current` (a `BehaviorSubject`) is deprecated in favor of two signals.** It still exists and stays in sync, so `current.value` and `current.pipe(...)` keep working. `mode()` is the selected mode (`'auto' | 'light' | 'dark'` or a custom theme name); `currentTheme()` is the resolved `KbqTheme` object, equivalent to `current.value`. `resolvedMode()` gives you `mode()` with `'auto'` already resolved to `'light'`/`'dark'`. +**`current` (a `BehaviorSubject`) is deprecated in favor of a few signals.** It still exists and stays in sync, so `current.value` and `current.pipe(...)` keep working. `selection()` is the raw selected value (`'auto'`, or a specific theme's `name`); `auto()` is whether that's currently `'auto'`; `currentTheme()` is the resolved `KbqTheme` object, equivalent to `current.value`; `colorScheme()` is the strictly `'light' | 'dark'` polarity of `currentTheme()` — reach for this, not a theme's `name`, when you just need to know which of the two you're in (e.g. driving CSS `light-dark()`). ```ts // Before @@ -758,13 +758,13 @@ themeService.current.pipe(map((theme) => theme?.className)).subscribe(...); themeService.currentTheme(); // read directly, or wrap with toObservable() if you need a stream ``` -**`setTheme(index | theme)` is deprecated in favor of `setMode(name)`.** Selecting by array index was fragile once `auto` stopped being a regular registered theme. `setMode('light')` / `setMode('dark')` cover a fixed mode; `setAuto()` and `toggle()` are the two convenience methods kept for the common cases actually used in this library — there is no `setLight()`/`setDark()`. +**`setTheme(index | theme)` is deprecated in favor of `selectTheme(name)`.** Selecting by array index was fragile once `auto` stopped being a regular registered theme. `selectTheme(name)` selects any registered theme directly, including the built-in `'light'`/`'dark'`; `setAuto()` and `toggle()` are the two convenience methods kept for the common cases actually used in this library — there is no `setLight()`/`setDark()`. -**`auto` mode is handled inside the service.** If you were reading `window.matchMedia('(prefers-color-scheme: …)')` yourself and rewriting a theme's `className` to fake a "system" option (as the docs app used to), call `themeService.setAuto()` instead and read `resolvedMode()` — the OS listener and the DOM update are both handled internally now. +**`auto` mode is handled inside the service.** If you were reading `window.matchMedia('(prefers-color-scheme: …)')` yourself and rewriting a theme's `className` to fake a "system" option (as the docs app used to), call `themeService.setAuto()` instead and read `currentTheme()`/`colorScheme()` — the OS listener and the DOM update are both handled internally now. -**Persistence is on by default.** The selected mode is now saved to `localStorage` (key `kbq-theme-mode` by default) and restored on init through the `KBQ_THEME_STORE` token, the same swappable-store pattern as `KBQ_ACCORDION_STATE_STORE`. If you rolled your own persistence under a different key (as the docs app did, under `docs_theme`), configure `kbqThemeProvider({ storageKey: '…' })` instead of dropping it — existing users keep their saved preference. Provide a custom `KbqThemeStore` if you need a different storage backend entirely. +**Persistence is on by default.** The selection is now saved to `localStorage` (key `kbq-theme-mode` by default) and restored on init through the `KBQ_THEME_STORE` token, the same swappable-store pattern as `KBQ_ACCORDION_STATE_STORE`. If you rolled your own persistence under a different key (as the docs app did, under `docs_theme`), configure `kbqThemeProvider({ storageKey: '…' })` instead of dropping it — existing users keep their saved preference, **provided the old value was already a mode/theme name**. If your old storage held something else (an index, a boolean, …), write a small `KbqThemeStore` wrapping `KbqThemeLocalStorageStore` that translates `getSelection()`'s return value before handing it back — see `DocsThemeStore` in the docs app's own `apps/docs/src/app/services/theme-store.ts` for the pattern. `KbqThemeCookieStore` is also available for apps that render with live Angular SSR and want the initial server-rendered HTML to already reflect the visitor's saved selection — read its doc comment first, since it doesn't help a build-time prerendered/static site. -**Custom themes and DI-based setup.** `setThemes()` still accepts any array of `{ name, className }` objects. New: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` configures the service through DI instead of calling `setThemes()`/`setTheme()` imperatively. The active theme is always applied as a CSS class on `` — the design tokens' `.kbq-light`/`.kbq-dark` styles depend on it, so there's no attribute-based alternative. `auto` mode resolves to the theme named `autoLight`/`autoDark` (`'light'`/`'dark'` by default) — set these if your custom theme set doesn't use those names, otherwise `auto` won't match any registered theme. +**Custom themes and DI-based setup.** `setThemes()` still accepts any array of `{ name, className, colorScheme? }` objects — `colorScheme` (`'light' | 'dark'`) is optional: when set, it's each theme's own polarity, independent of its `name`, and is what `colorScheme()` (and `toggle()`) key off; when omitted, `colorScheme()` falls back to the OS preference for that theme. New: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` configures the service through DI instead of calling `setThemes()`/`setTheme()` imperatively. The active theme is always applied as a CSS class on `` — the design tokens' `.kbq-light`/`.kbq-dark` styles depend on it, so there's no attribute-based alternative. `auto` resolves to the theme named `autoLight`/`autoDark` (`'light'`/`'dark'` by default) — set these if your custom theme set doesn't use those names, otherwise `auto` won't match any registered theme. ### After the migration diff --git a/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index 2e7f08e77a..565f12e210 100644 --- a/docs/guides/migration.ru.md +++ b/docs/guides/migration.ru.md @@ -748,7 +748,7 @@ if (formField.hasCleaner() && formField.hint().length && hint.fillTextOff()) { **Теперь это `KbqThemeService`.** `ThemeService` экспортируется как `@deprecated`-алиас `KbqThemeService` и будет удалён в одном из будущих мажорных релизов. Схематика `ng update` для переименования нет — замените импорт, когда будет удобно. -**`current` (`BehaviorSubject`) устарел в пользу двух сигналов.** Он по-прежнему существует и остаётся синхронизирован, поэтому `current.value` и `current.pipe(...)` продолжают работать. `mode()` — выбранный режим (`'auto' | 'light' | 'dark'` или имя пользовательской темы); `currentTheme()` — вычисленный объект `KbqTheme`, эквивалент `current.value`. `resolvedMode()` отдаёт `mode()` с уже вычисленным `'auto'` → `'light'`/`'dark'`. +**`current` (`BehaviorSubject`) устарел в пользу нескольких сигналов.** Он по-прежнему существует и остаётся синхронизирован, поэтому `current.value` и `current.pipe(...)` продолжают работать. `selection()` — сырое выбранное значение (`'auto'` либо имя конкретной темы); `auto()` — признак того, что сейчас выбран именно `'auto'`; `currentTheme()` — вычисленный объект `KbqTheme`, эквивалент `current.value`; `colorScheme()` — строго `'light' | 'dark'` полярность `currentTheme()`; используйте именно его, а не `name` темы, когда нужно узнать только светлая тема или тёмная (например, для CSS `light-dark()`). ```ts // Было @@ -758,13 +758,13 @@ themeService.current.pipe(map((theme) => theme?.className)).subscribe(...); themeService.currentTheme(); // читайте напрямую, либо оберните в toObservable(), если нужен поток ``` -**`setTheme(index | theme)` устарел в пользу `setMode(name)`.** Выбор по индексу массива стал ненадёжным, как только `auto` перестал быть обычной зарегистрированной темой. `setMode('light')` / `setMode('dark')` покрывают выбор фиксированного режима; `setAuto()` и `toggle()` — два метода-помощника, оставленные для реально используемых в библиотеке случаев — `setLight()`/`setDark()` нет. +**`setTheme(index | theme)` устарел в пользу `selectTheme(name)`.** Выбор по индексу массива стал ненадёжным, как только `auto` перестал быть обычной зарегистрированной темой. `selectTheme(name)` выбирает любую зарегистрированную тему напрямую, включая встроенные `'light'`/`'dark'`; `setAuto()` и `toggle()` — два метода-помощника, оставленные для реально используемых в библиотеке случаев — `setLight()`/`setDark()` нет. -**Режим `auto` теперь обрабатывается внутри сервиса.** Если вы сами читали `window.matchMedia('(prefers-color-scheme: …)')` и переопределяли `className` темы, чтобы сымитировать пункт «как в системе» (как раньше делала дока), теперь вызывайте `themeService.setAuto()` и читайте `resolvedMode()` — слушатель ОС и обновление DOM теперь внутри сервиса. +**Режим `auto` теперь обрабатывается внутри сервиса.** Если вы сами читали `window.matchMedia('(prefers-color-scheme: …)')` и переопределяли `className` темы, чтобы сымитировать пункт «как в системе» (как раньше делала дока), теперь вызывайте `themeService.setAuto()` и читайте `currentTheme()`/`colorScheme()` — слушатель ОС и обновление DOM теперь внутри сервиса. -**Сохранение выбора включено по умолчанию.** Выбранный режим теперь сохраняется в `localStorage` (по умолчанию под ключом `kbq-theme-mode`) и восстанавливается при инициализации через токен `KBQ_THEME_STORE` — тот же паттерн подменяемого хранилища, что и у `KBQ_ACCORDION_STATE_STORE`. Если вы сохраняли выбор под другим ключом (как дока — под `docs_theme`), настройте `kbqThemeProvider({ storageKey: '…' })` вместо того, чтобы это убирать — так пользователи не потеряют сохранённые настройки. Если нужно другое хранилище, предоставьте свой `KbqThemeStore`. +**Сохранение выбора включено по умолчанию.** Выбор теперь сохраняется в `localStorage` (по умолчанию под ключом `kbq-theme-mode`) и восстанавливается при инициализации через токен `KBQ_THEME_STORE` — тот же паттерн подменяемого хранилища, что и у `KBQ_ACCORDION_STATE_STORE`. Если вы сохраняли выбор под другим ключом (как дока — под `docs_theme`), настройте `kbqThemeProvider({ storageKey: '…' })` вместо того, чтобы это убирать — так пользователи не потеряют сохранённые настройки, **если старое значение уже было именем режима или темы**. Если раньше вы хранили что-то другое (индекс, булево значение, …), напишите небольшой `KbqThemeStore`, оборачивающий `KbqThemeLocalStorageStore` и преобразующий результат `getSelection()` — пример такого подхода: `DocsThemeStore` в `apps/docs/src/app/services/theme-store.ts`. Также доступен `KbqThemeCookieStore` — для приложений с живым Angular SSR, которым нужно, чтобы уже первый серверный рендер учитывал сохранённый выбор посетителя; сначала прочитайте его doc-комментарий — для статически собранного сайта он не поможет. -**Кастомные темы и настройка через DI.** `setThemes()` по-прежнему принимает любой массив объектов `{ name, className }`. Новое: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` настраивает сервис через DI вместо императивных вызовов `setThemes()`/`setTheme()`. Активная тема всегда применяется как CSS-класс на `` — от этого зависят стили `.kbq-light`/`.kbq-dark` дизайн-токенов, поэтому альтернативы через атрибут нет. Режим `auto` разрешается в тему с именем `autoLight`/`autoDark` (по умолчанию `'light'`/`'dark'`) — задайте их, если ваш набор кастомных тем использует другие имена, иначе `auto` не совпадёт ни с одной зарегистрированной темой. +**Кастомные темы и настройка через DI.** `setThemes()` по-прежнему принимает любой массив объектов `{ name, className, colorScheme? }` — `colorScheme` (`'light' | 'dark'`) необязателен: если задан, это собственная «полярность» темы, независимая от её `name`, и именно на неё опирается `colorScheme()` (а также `toggle()`); если не задан, `colorScheme()` для этой темы откатывается на предпочтение ОС. Новое: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` настраивает сервис через DI вместо императивных вызовов `setThemes()`/`setTheme()`. Активная тема всегда применяется как CSS-класс на `` — от этого зависят стили `.kbq-light`/`.kbq-dark` дизайн-токенов, поэтому альтернативы через атрибут нет. `auto` разрешается в тему с именем `autoLight`/`autoDark` (по умолчанию `'light'`/`'dark'`) — задайте их, если ваш набор кастомных тем использует другие имена, иначе `auto` не совпадёт ни с одной зарегистрированной темой. ### После миграции diff --git a/packages/components-dev/theme-toggle.ts b/packages/components-dev/theme-toggle.ts index ff8d41071d..4bbc4974d3 100644 --- a/packages/components-dev/theme-toggle.ts +++ b/packages/components-dev/theme-toggle.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, effect, inject, model } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { KbqThemeService } from '@koobiq/components/core'; import { KbqToggleModule } from '@koobiq/components/toggle'; @@ -7,7 +7,7 @@ import { KbqToggleModule } from '@koobiq/components/toggle'; selector: 'dev-theme-toggle', imports: [KbqToggleModule, FormsModule], template: ` - isDarkTheme + isDarkTheme `, changeDetection: ChangeDetectionStrategy.OnPush, host: { @@ -17,10 +17,6 @@ import { KbqToggleModule } from '@koobiq/components/toggle'; exportAs: 'devThemeToggle' }) export class DevThemeToggle { - private readonly theme = inject(KbqThemeService); + protected readonly theme = inject(KbqThemeService); readonly isDarkTheme = model(this.theme.colorScheme() === 'dark'); - - constructor() { - effect(() => this.theme.setMode(this.isDarkTheme() ? 'dark' : 'light')); - } } diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index eb94b7e3db..e34bf7563b 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -92,14 +92,14 @@ describe('KbqThemeService', () => { expect(document.body.classList.contains('kbq-light')).toBe(false); }); - it('setMode/setAuto select a fixed mode or fall back to the OS preference', () => { + it('selectTheme/setAuto select a fixed theme or fall back to the OS preference', () => { const { service } = setup(true); - service.setMode('light'); + service.selectTheme('light'); TestBed.tick(); expect(service.currentTheme()?.name).toBe('light'); - service.setMode('dark'); + service.selectTheme('dark'); TestBed.tick(); expect(service.currentTheme()?.name).toBe('dark'); @@ -109,22 +109,6 @@ describe('KbqThemeService', () => { expect(service.currentTheme()?.name).toBe('dark'); }); - it('remembers the last selected theme across an auto toggle, within the session', () => { - const { service } = setup(true); - - service.setMode('light'); - TestBed.tick(); - - service.setAuto(); - TestBed.tick(); - expect(service.currentTheme()?.name).toBe('dark'); - - service.setAuto(false); - TestBed.tick(); - expect(service.auto()).toBe(false); - expect(service.currentTheme()?.name).toBe('light'); - }); - it('toggle switches between light and dark', () => { const { service } = setup(false); @@ -218,7 +202,7 @@ describe('KbqThemeService', () => { it('persists the selected mode via KBQ_THEME_STORE', () => { const { service } = setup(false); - service.setMode('dark'); + service.selectTheme('dark'); TestBed.tick(); expect(store.setSelection).toHaveBeenCalledWith('dark'); @@ -275,7 +259,7 @@ describe('KbqThemeService', () => { expect(service.current.value?.name).toBe('light'); - service.setMode('dark'); + service.selectTheme('dark'); TestBed.tick(); expect(service.current.value?.name).toBe('dark'); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index 28006b87c8..bbce65eb31 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -25,7 +25,7 @@ export type KbqThemeColorScheme = 'light' | 'dark'; /** A theme registered with `KbqThemeService`. */ export interface KbqTheme { /** Unique name used to select the theme via `selectTheme()`. */ - name: string; + name: KbqThemeColorScheme | KbqThemeName; /** CSS class applied to the document body when this theme is active. */ className: string; /** @@ -41,16 +41,10 @@ export interface KbqTheme { } /** - * Value accepted by `setMode()`. `'auto'` follows the OS color scheme; `'light'`/`'dark'` select those themes - * directly. To select a different registered theme by name, use `selectTheme()` instead. + * What `selection()` returns and `KbqThemeStore` persists: `'auto'`, or a theme's `name`. `string`-backed, + * but keeps `'auto'`'s literal suggested in editors instead of collapsing to plain `string`. */ -export type KbqThemeMode = 'auto' | KbqThemeColorScheme; - -/** `string`, but keeps `KbqThemeMode`'s literals suggested in editors instead of collapsing to plain `string`. */ -export type KbqThemeName = string & {}; - -/** What `selection()` returns and `KbqThemeStore` persists: `'auto'`, or the selected theme's `name`. */ -export type KbqThemeSelection = 'auto' | KbqThemeName; +export type KbqThemeName = 'auto' | (string & {}); /** CSS class names for `KbqDefaultThemes`, the built-in light/dark theme set. */ export enum KbqThemeSelector { @@ -60,10 +54,18 @@ export enum KbqThemeSelector { Dark = 'kbq-dark' } +/** Theme names for `KbqDefaultThemes`, the built-in light/dark theme set. */ +export enum KbqThemeNames { + /** Name for the built-in light theme. */ + Default = 'light', + /** Name for the built-in dark theme. */ + Dark = 'dark' +} + /** The built-in light/dark theme set — `KBQ_THEME_CONFIG`'s default `themes`. @docs-private */ export const KbqDefaultThemes: KbqTheme[] = [ - { name: 'light', className: KbqThemeSelector.Default, colorScheme: 'light' }, - { name: 'dark', className: KbqThemeSelector.Dark, colorScheme: 'dark' } + { name: KbqThemeNames.Default, className: KbqThemeSelector.Default, colorScheme: 'light' }, + { name: KbqThemeNames.Dark, className: KbqThemeSelector.Dark, colorScheme: 'dark' } ]; /** Configuration accepted by `KBQ_THEME_CONFIG` / `kbqThemeProvider()`. */ @@ -71,7 +73,7 @@ export interface KbqThemeConfig { /** Themes available to the service. @default KbqDefaultThemes */ themes: T[]; /** Initial mode, used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default 'auto' */ - mode: KbqThemeMode; + mode: KbqThemeColorScheme | KbqThemeName; /** Key used to persist the selection — a `localStorage` key or cookie name, depending on `KBQ_THEME_STORE`. @default 'kbq-theme-mode' */ storageKey: string; /** Theme `name` that `'auto'` resolves to when the OS prefers a light color scheme. @default 'light' */ @@ -112,9 +114,9 @@ export const kbqThemeProvider = (config: Partial): Provider => ( */ export interface KbqThemeStore { /** Returns the previously saved selection, or `null` when nothing is stored/available. */ - getSelection(): KbqThemeSelection | null; + getSelection(): KbqThemeName | null; /** Persists the selection. */ - setSelection(selection: KbqThemeSelection): void; + setSelection(selection: KbqThemeName): void; } /** @@ -129,7 +131,7 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { private readonly window = inject(KBQ_WINDOW); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - getSelection(): KbqThemeSelection | null { + getSelection(): KbqThemeName | null { try { return this.window.localStorage.getItem(this.storageKey); } catch { @@ -138,7 +140,7 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { } } - setSelection(selection: KbqThemeSelection): void { + setSelection(selection: KbqThemeName): void { try { this.window.localStorage.setItem(this.storageKey, selection); } catch { @@ -148,32 +150,24 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { } /** - * `KbqThemeStore` implementation backed by a cookie, for apps that render with **live** Angular SSR - * (`@angular/ssr` or similar, one render per request) rather than a build-time prerendered/static site. - * - * Unlike `localStorage`, a cookie travels with the HTTP request, so a live server-side render can read - * `DOCUMENT.cookie` and apply the right theme class before the response is ever sent — avoiding the - * flash of the wrong theme that a client-only store cannot prevent, since it can't be read until the - * client's JavaScript runs. This only helps if the app's SSR bootstrap populates `DOCUMENT.cookie` from - * the incoming request's `Cookie` header; that wiring is the app's responsibility, not this library's. - * - * Not useful for a statically prerendered site (no live request to read a cookie from) — use the - * default `KbqThemeLocalStorageStore` there, optionally paired with a small inline script in `index.html` - * that applies the stored preference before first paint. + * `KbqThemeStore` implementation backed by a cookie, for apps with **live** Angular SSR — a cookie travels + * with the request, so the server can read it and render the right theme immediately, unlike `localStorage`. + * Requires the app's SSR bootstrap to populate `DOCUMENT.cookie` from the request. Not useful for a + * statically prerendered site — use `KbqThemeLocalStorageStore` there instead. */ @Injectable({ providedIn: 'root' }) export class KbqThemeCookieStore implements KbqThemeStore { private readonly document = inject(DOCUMENT); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - getSelection(): KbqThemeSelection | null { + getSelection(): KbqThemeName | null { const prefix = `${this.storageKey}=`; const cookie = this.document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null; } - setSelection(selection: KbqThemeSelection): void { + setSelection(selection: KbqThemeName): void { // 1 year: matches the lifetime a persisted UI preference is expected to have. SameSite=Lax is // sent on the top-level navigation request that SSR needs it for, while still blocking // cross-site reads. @@ -192,7 +186,7 @@ export const KBQ_THEME_STORE = new InjectionToken('KBQ_THEME_STOR /** * Manages the active Koobiq theme: resolves `auto` mode from the OS color scheme, applies the active theme's - * class to the document body, and persists the selected mode via `KBQ_THEME_STORE`. + * class to the document body, and persists the current selection via `KBQ_THEME_STORE`. * * @example * ```ts @@ -213,21 +207,15 @@ export class KbqThemeService { private readonly renderer: Renderer2; private readonly media = this.window.matchMedia('(prefers-color-scheme: dark)'); private readonly systemPrefersDark = signal(this.media.matches); - /** Seeds `auto`/`theme` below from persisted state, falling back to `config.mode` on first run. */ - private readonly initialMode = this.store.getSelection() ?? this.config.mode; - /** Last explicitly selected theme name. Kept even while `auto()` is on, so turning it off restores it. */ - private readonly theme = signal( - this.initialMode === 'auto' ? this.config.autoLight : this.initialMode - ); /** Themes available to select from. Replace via `setThemes()` to register a fully custom set. */ readonly themes = signal(this.config.themes); - /** Whether the theme follows the OS color scheme instead of `theme()`. */ - readonly auto = signal(this.initialMode === 'auto'); + /** `'auto'` to follow the OS color scheme, or the selected theme's `name`. Persisted via `KBQ_THEME_STORE`. */ + readonly selection = signal(this.store.getSelection() ?? this.config.mode); - /** `'auto'` when `auto()` is on, otherwise `theme()`. A simpler view for a plain 3-way (auto/light/dark) UI. */ - readonly selection = computed(() => (this.auto() ? 'auto' : this.theme())); + /** Whether the theme follows the OS color scheme instead of a specific selected theme. */ + readonly auto = computed(() => this.selection() === 'auto'); /** The theme object currently applied to the document, or `null` if the resolved name matches none. */ readonly currentTheme = computed(() => { @@ -235,7 +223,7 @@ export class KbqThemeService { ? this.systemPrefersDark() ? this.config.autoDark : this.config.autoLight - : this.theme(); + : this.selection(); return this.themes().find((theme) => theme.name === resolvedThemeName) ?? null; }); @@ -272,25 +260,13 @@ export class KbqThemeService { } /** Selects a specific registered theme directly by `name`, turning `auto()` off. */ - selectTheme(name: KbqThemeName) { - this.auto.set(false); - this.theme.set(name); - } - - /** Selects a fixed `'light'`/`'dark'` mode, or `'auto'` to follow the OS color scheme. */ - setMode(mode: KbqThemeMode) { - if (mode === 'auto') { - this.auto.set(true); - - return; - } - - this.selectTheme(mode); + selectTheme(name: KbqThemeNames | KbqThemeName) { + this.selection.set(name); } - /** Turns following the OS color scheme on or off. Turning it off restores the last selected `theme()`. */ - setAuto(auto = true) { - this.auto.set(auto); + /** Follows the OS color scheme. */ + setAuto() { + this.selection.set('auto'); } /** diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 0362f17867..9c9f419c75 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -3828,7 +3828,7 @@ export class KbqTableNumberPipe implements KbqNumericPipe, PipeTransform { export interface KbqTheme { className: string; colorScheme?: KbqThemeColorScheme; - name: string; + name: KbqThemeColorScheme | KbqThemeName; // @deprecated (undocumented) selected?: boolean; } @@ -3840,7 +3840,7 @@ export type KbqThemeColorScheme = 'light' | 'dark'; export interface KbqThemeConfig { autoDark: string; autoLight: string; - mode: KbqThemeMode; + mode: KbqThemeColorScheme | KbqThemeName; storageKey: string; themes: T[]; } @@ -3848,9 +3848,9 @@ export interface KbqThemeConfig { // @public export class KbqThemeCookieStore implements KbqThemeStore { // (undocumented) - getSelection(): KbqThemeSelection | null; + getSelection(): KbqThemeName | null; // (undocumented) - setSelection(selection: KbqThemeSelection): void; + setSelection(selection: KbqThemeName): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3860,9 +3860,9 @@ export class KbqThemeCookieStore implements KbqThemeStore { // @public export class KbqThemeLocalStorageStore implements KbqThemeStore { // (undocumented) - getSelection(): KbqThemeSelection | null; + getSelection(): KbqThemeName | null; // (undocumented) - setSelection(selection: KbqThemeSelection): void; + setSelection(selection: KbqThemeName): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3870,17 +3870,17 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { } // @public -export type KbqThemeMode = 'auto' | KbqThemeColorScheme; +export type KbqThemeName = 'auto' | (string & {}); // @public -export type KbqThemeName = string & {}; +export enum KbqThemeNames { + Dark = "dark", + Default = "light" +} // @public export const kbqThemeProvider: (config: Partial) => Provider; -// @public -export type KbqThemeSelection = 'auto' | KbqThemeName; - // @public export enum KbqThemeSelector { Dark = "kbq-dark", @@ -3890,17 +3890,16 @@ export enum KbqThemeSelector { // @public export class KbqThemeService { constructor(); - readonly auto: i0.WritableSignal; + readonly auto: i0.Signal; readonly colorScheme: i0.Signal; // @deprecated (undocumented) readonly current: BehaviorSubject; readonly currentTheme: i0.Signal; // @deprecated (undocumented) getTheme(): T | null; - readonly selection: i0.Signal; - selectTheme(name: KbqThemeName): void; - setAuto(auto?: boolean): void; - setMode(mode: KbqThemeMode): void; + readonly selection: i0.WritableSignal; + selectTheme(name: KbqThemeNames | KbqThemeName): void; + setAuto(): void; // @deprecated (undocumented) setTheme(value: T | number): void; setThemes(items: T[]): void; @@ -3914,8 +3913,8 @@ export class KbqThemeService { // @public export interface KbqThemeStore { - getSelection(): KbqThemeSelection | null; - setSelection(selection: KbqThemeSelection): void; + getSelection(): KbqThemeName | null; + setSelection(selection: KbqThemeName): void; } // @public From 5e333fe59a81b7a28d421f3b163280ce31fcd620 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 11 Aug 2026 16:23:18 +0300 Subject: [PATCH 08/15] feat: simplified from theme selection to mode selection --- .../app/components/navbar/navbar.component.ts | 8 +- .../components/navbar/navbar.template.html | 2 +- apps/docs/src/app/config.ts | 4 - .../docs/src/app/services/theme-store.spec.ts | 46 ---- apps/docs/src/app/services/theme-store.ts | 32 --- .../core/services/theme.service.spec.ts | 133 +++++---- .../components/core/services/theme.service.ts | 252 +++++++++--------- tools/public_api_guard/components/core.api.md | 78 +++--- 8 files changed, 252 insertions(+), 303 deletions(-) delete mode 100644 apps/docs/src/app/services/theme-store.spec.ts delete mode 100644 apps/docs/src/app/services/theme-store.ts diff --git a/apps/docs/src/app/components/navbar/navbar.component.ts b/apps/docs/src/app/components/navbar/navbar.component.ts index 14487cf4d2..bf6d95dfc2 100644 --- a/apps/docs/src/app/components/navbar/navbar.component.ts +++ b/apps/docs/src/app/components/navbar/navbar.component.ts @@ -2,7 +2,7 @@ import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core'; import { RouterLink } from '@angular/router'; import { KbqButtonModule } from '@koobiq/components/button'; -import { KbqThemeName, KbqThemeNames, KbqThemeService } from '@koobiq/components/core'; +import { KbqThemeMode, KbqThemeNames, KbqThemeService } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; @@ -17,7 +17,7 @@ import { DocsDocsearchDirective } from '../docsearch/docsearch.directive'; /** A theme mode selectable from the navbar's theme dropdown. */ interface DocsThemeOption { - mode: KbqThemeNames | KbqThemeName; + mode: KbqThemeMode; title: Record; } @@ -55,7 +55,7 @@ export class DocsNavbarComponent extends DocsLocaleState { ]; /** The currently selected mode — persistence and OS-preference resolution are handled by `KbqThemeService`. */ - readonly selection = computed(() => this.themeService.selection()); + readonly mode = computed(() => this.themeService.mode()); readonly opened$: Observable = this.docStates.navbarMenu.pipe( map((state) => state === DocsNavbarState.Opened) @@ -66,6 +66,6 @@ export class DocsNavbarComponent extends DocsLocaleState { } setTheme(mode: DocsThemeOption['mode']) { - this.themeService.selectTheme(mode); + this.themeService.mode.set(mode); } } diff --git a/apps/docs/src/app/components/navbar/navbar.template.html b/apps/docs/src/app/components/navbar/navbar.template.html index 9bf4b23b51..680e36f743 100644 --- a/apps/docs/src/app/components/navbar/navbar.template.html +++ b/apps/docs/src/app/components/navbar/navbar.template.html @@ -81,7 +81,7 @@ @for (option of themeOptions; track option.mode) { - } diff --git a/apps/docs/src/app/config.ts b/apps/docs/src/app/config.ts index 11cb34cddc..e1148a1e92 100644 --- a/apps/docs/src/app/config.ts +++ b/apps/docs/src/app/config.ts @@ -5,7 +5,6 @@ import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, TitleStrategy } from '@angular/router'; import { KBQ_LOCALE_SERVICE, - KBQ_THEME_STORE, KbqLocaleService, kbqLocaleServiceLangAttrNameProvider, kbqThemeProvider @@ -13,7 +12,6 @@ import { import { kbqIconsResolverProvider } from '@koobiq/components/icon'; import { DOCS_ROUTES } from './routes'; import { docsProvideAnalytics } from './services/analytics'; -import { DocsThemeStore } from './services/theme-store'; import { DocsTitleStrategy } from './services/title-strategy'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -23,8 +21,6 @@ export const appConfig: ApplicationConfig = { kbqLocaleServiceLangAttrNameProvider('examples-lang'), // keeps the pre-existing localStorage key so users who already picked a theme don't lose it kbqThemeProvider({ storageKey: 'docs_theme' }), - // that key held the old navbar's numeric dropdown index, not a mode name - translate it - { provide: KBQ_THEME_STORE, useClass: DocsThemeStore }, kbqIconsResolverProvider((name) => `/assets/SVGIcons/${name.replace(/^kbq-/, '')}.svg`), provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(DOCS_ROUTES), diff --git a/apps/docs/src/app/services/theme-store.spec.ts b/apps/docs/src/app/services/theme-store.spec.ts deleted file mode 100644 index d62c1761fa..0000000000 --- a/apps/docs/src/app/services/theme-store.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { KBQ_THEME_CONFIG, KBQ_WINDOW } from '@koobiq/components/core'; -import { DocsThemeStore } from './theme-store'; - -describe(DocsThemeStore.name, () => { - function setup() { - TestBed.configureTestingModule({ - providers: [ - { provide: KBQ_THEME_CONFIG, useValue: { storageKey: 'docs_theme' } }, - { provide: KBQ_WINDOW, useValue: window } - ] - }); - - return TestBed.inject(DocsThemeStore); - } - - afterEach(() => localStorage.clear()); - - it('reads null when nothing is stored', () => { - expect(setup().getSelection()).toBeNull(); - }); - - it.each([ - ['0', 'auto'], - ['1', 'light'], - ['2', 'dark'] - ])('migrates the legacy dropdown index %s to mode %s', (legacyIndex, mode) => { - localStorage.setItem('docs_theme', legacyIndex); - - expect(setup().getSelection()).toBe(mode); - }); - - it('passes an already-migrated mode name through unchanged', () => { - localStorage.setItem('docs_theme', 'dark'); - - expect(setup().getSelection()).toBe('dark'); - }); - - it('writes new mode names, not legacy indexes', () => { - const store = setup(); - - store.setSelection('dark'); - - expect(localStorage.getItem('docs_theme')).toBe('dark'); - }); -}); diff --git a/apps/docs/src/app/services/theme-store.ts b/apps/docs/src/app/services/theme-store.ts deleted file mode 100644 index 03fec2a87c..0000000000 --- a/apps/docs/src/app/services/theme-store.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { inject, Injectable } from '@angular/core'; -import { KbqThemeLocalStorageStore, KbqThemeName, KbqThemeStore } from '@koobiq/components/core'; - -/** - * Maps the pre-DS-3003 navbar's dropdown index (`DocsNavbarProperty`, options ordered - * system/light/dark) to the mode name `KbqThemeService` expects. - */ -const LEGACY_INDEX_TO_MODE: Record = { - '0': 'auto', - '1': 'light', - '2': 'dark' -}; - -/** - * Reuses the `docs_theme` `localStorage` key from the old navbar, which stored a numeric dropdown - * index (`"0"`/`"1"`/`"2"`) rather than a mode name. Reading that raw value as a mode would resolve - * to no theme and render the site unthemed, so it's translated on the way out. - */ -@Injectable({ providedIn: 'root' }) -export class DocsThemeStore implements KbqThemeStore { - private readonly delegate = inject(KbqThemeLocalStorageStore); - - getSelection(): KbqThemeName | null { - const stored = this.delegate.getSelection(); - - return stored === null ? null : (LEGACY_INDEX_TO_MODE[stored] ?? stored); - } - - setSelection(selection: KbqThemeName): void { - this.delegate.setSelection(selection); - } -} diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index e34bf7563b..d4f7b69602 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -67,7 +67,7 @@ describe('KbqThemeService', () => { it('defaults to auto mode, resolving dark when the OS prefers dark', () => { const { service } = setup(true); - expect(service.selection()).toBe('auto'); + expect(service.mode()).toBe('auto'); expect(service.currentTheme()?.name).toBe('dark'); expect(document.body.classList.contains('kbq-dark')).toBe(true); }); @@ -92,20 +92,20 @@ describe('KbqThemeService', () => { expect(document.body.classList.contains('kbq-light')).toBe(false); }); - it('selectTheme/setAuto select a fixed theme or fall back to the OS preference', () => { + it('setMode selects a fixed mode or falls back to the OS preference', () => { const { service } = setup(true); - service.selectTheme('light'); + service.mode.set('light'); TestBed.tick(); expect(service.currentTheme()?.name).toBe('light'); - service.selectTheme('dark'); + service.mode.set('dark'); TestBed.tick(); expect(service.currentTheme()?.name).toBe('dark'); - service.setAuto(); + service.mode.set('auto'); TestBed.tick(); - expect(service.selection()).toBe('auto'); + expect(service.mode()).toBe('auto'); expect(service.currentTheme()?.name).toBe('dark'); }); @@ -121,48 +121,34 @@ describe('KbqThemeService', () => { expect(service.currentTheme()?.name).toBe('light'); }); - it('supports registering a fully custom set of themes', () => { + it('supports registering a fully custom set of themes, resolved by colorScheme', () => { const { service } = setup(false); - service.setThemes([{ name: 'solarized', className: 'kbq-solarized', colorScheme: 'dark' }]); - service.selectTheme('solarized'); + service.themes.set([ + { name: 'acme-light', className: 'kbq-acme-light', colorScheme: 'light' }, + { name: 'acme-dark', className: 'kbq-acme-dark', colorScheme: 'dark' } + ]); + service.mode.set('dark'); TestBed.tick(); - expect(service.currentTheme()?.className).toBe('kbq-solarized'); - expect(document.body.classList.contains('kbq-solarized')).toBe(true); + expect(service.currentTheme()?.className).toBe('kbq-acme-dark'); + expect(document.body.classList.contains('kbq-acme-dark')).toBe(true); }); it("exposes colorScheme as the current theme's own polarity, independent of its name", () => { const { service } = setup(false); - service.setThemes([{ name: 'solarized', className: 'kbq-solarized', colorScheme: 'dark' }]); - service.selectTheme('solarized'); - TestBed.tick(); - - expect(service.colorScheme()).toBe('dark'); - }); - - it("toggle uses the current theme's colorScheme, not a name comparison against autoDark", () => { - const { service } = setup(false); - - // A directly-selected theme whose name matches neither 'light'/'dark' nor autoLight/autoDark - - // comparing resolvedMode() to autoDark (the old implementation) would always toggle to 'dark' - // here, regardless of this theme's actual polarity. - service.setThemes([ - ...service.themes(), - { name: 'solarized', className: 'kbq-solarized', colorScheme: 'dark' } + service.themes.set([ + { name: 'acme-light', className: 'kbq-acme-light', colorScheme: 'light' }, + { name: 'acme-dark', className: 'kbq-acme-dark', colorScheme: 'dark' } ]); - service.selectTheme('solarized'); - TestBed.tick(); - - service.toggle(); + service.mode.set('dark'); TestBed.tick(); - expect(service.selection()).toBe('light'); - expect(service.currentTheme()?.name).toBe('light'); + expect(service.colorScheme()).toBe('dark'); }); - it('resolves auto mode against custom theme names via autoLight/autoDark', () => { + it('resolves auto mode against a custom theme set via colorScheme', () => { const media = fakeMediaQueryList(true); TestBed.configureTestingModule({ @@ -174,9 +160,7 @@ describe('KbqThemeService', () => { themes: [ { name: 'sunrise', className: 'kbq-sunrise', colorScheme: 'light' }, { name: 'midnight', className: 'kbq-midnight', colorScheme: 'dark' } - ], - autoLight: 'sunrise', - autoDark: 'midnight' + ] } }, { provide: KBQ_THEME_STORE, useValue: { getSelection: () => null, setSelection: () => {} } } @@ -187,14 +171,14 @@ describe('KbqThemeService', () => { TestBed.tick(); - expect(service.selection()).toBe('auto'); + expect(service.mode()).toBe('auto'); expect(service.currentTheme()?.name).toBe('midnight'); expect(document.body.classList.contains('kbq-midnight')).toBe(true); service.toggle(); TestBed.tick(); - expect(service.selection()).not.toBe('auto'); + expect(service.mode()).toBe('light'); expect(service.currentTheme()?.name).toBe('sunrise'); expect(document.body.classList.contains('kbq-sunrise')).toBe(true); }); @@ -202,7 +186,7 @@ describe('KbqThemeService', () => { it('persists the selected mode via KBQ_THEME_STORE', () => { const { service } = setup(false); - service.selectTheme('dark'); + service.mode.set('dark'); TestBed.tick(); expect(store.setSelection).toHaveBeenCalledWith('dark'); @@ -224,14 +208,64 @@ describe('KbqThemeService', () => { TestBed.tick(); - expect(service.selection()).toBe('dark'); + expect(service.mode()).toBe('dark'); expect(service.currentTheme()?.name).toBe('dark'); }); + it('falls back to config.mode when the persisted value is not a valid mode', () => { + const media = fakeMediaQueryList(false); + + // Mimics a value persisted before mode-only selection existed (an arbitrary theme name), or any + // other foreign/stale value - `mode` is strictly closed now, so it can't be trusted as-is. + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, + { provide: KBQ_THEME_STORE, useValue: { getSelection: () => 'solarized', setSelection: () => {} } } + ] + }); + + const service = TestBed.inject(KbqThemeService); + + TestBed.tick(); + + expect(service.mode()).toBe('auto'); + }); +}); + +describe('ThemeService', () => { + function setup(matches = false) { + const media = fakeMediaQueryList(matches); + + TestBed.configureTestingModule({ + providers: [{ provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }] + }); + + const service = TestBed.inject(ThemeService); + + TestBed.tick(); + + return { service, media }; + } + + afterEach(() => { + document.body.className = ''; + localStorage.clear(); + }); + + it('shares state with the injected KbqThemeService (single source of truth)', () => { + const { service } = setup(false); + const kbqThemeService = TestBed.inject(KbqThemeService); + + kbqThemeService.mode.set('dark'); + TestBed.tick(); + + expect(service.current.value?.name).toBe('dark'); + }); + it('keeps the deprecated `selected` field in sync for backward compatibility', () => { const { service } = setup(true); - const themes = service.themes(); + const themes = service.themes; expect(themes.find((theme) => theme.name === 'dark')?.selected).toBe(true); expect(themes.find((theme) => theme.name === 'light')?.selected).toBe(false); @@ -242,28 +276,23 @@ describe('KbqThemeService', () => { service.setTheme(1); TestBed.tick(); - expect(service.selection()).toBe('dark'); - expect(service.getTheme()).toBe(service.currentTheme()); + expect(service.getTheme()?.name).toBe('dark'); service.setTheme(KbqDefaultThemes[0]); TestBed.tick(); - expect(service.selection()).toBe('light'); - }); - - it('exports `ThemeService` as a deprecated alias of `KbqThemeService`', () => { - expect(ThemeService).toBe(KbqThemeService); + expect(service.getTheme()?.name).toBe('light'); }); - it('keeps the deprecated `current` BehaviorSubject in sync with `currentTheme()`', () => { + it('keeps the deprecated `current` BehaviorSubject in sync with `getTheme()`', () => { const { service } = setup(false); expect(service.current.value?.name).toBe('light'); - service.selectTheme('dark'); + service.setTheme(KbqDefaultThemes[1]); TestBed.tick(); expect(service.current.value?.name).toBe('dark'); - expect(service.current.value).toBe(service.currentTheme()); + expect(service.current.value).toBe(service.getTheme()); }); }); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index bbce65eb31..45e9a9c733 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -6,45 +6,48 @@ import { inject, Injectable, InjectionToken, + OnDestroy, Provider, Renderer2, RendererFactory2, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { BehaviorSubject, fromEvent } from 'rxjs'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { BehaviorSubject, fromEvent, Subscription } from 'rxjs'; import { KBQ_WINDOW } from '../tokens'; /** - * Light/dark polarity of a `KbqTheme`, independent of how many themes are registered or what they're named. - * Drives `'auto'` resolution and is the strictly-typed value to reach for when something (e.g. CSS - * `light-dark()`) needs to know which of the two a theme is, regardless of its `name`. + * Light/dark polarity of a `KbqThemeConfig`. Drives `mode()` resolution and is the strictly-typed value + * to reach for when something (e.g. CSS `light-dark()`) needs to know which of the two is active. */ export type KbqThemeColorScheme = 'light' | 'dark'; -/** A theme registered with `KbqThemeService`. */ +/** Selection understood by `KbqThemeService`. The only way to select a theme — see `setMode()`. */ +export type KbqThemeMode = 'auto' | KbqThemeColorScheme; + +/** + * @deprecated will be removed in a future major version — use `KbqThemeConfig` instead, which adds the + * `colorScheme` this interface can no longer carry without a breaking change to existing consumers. + */ export interface KbqTheme { - /** Unique name used to select the theme via `selectTheme()`. */ - name: KbqThemeColorScheme | KbqThemeName; + name: string; /** CSS class applied to the document body when this theme is active. */ className: string; /** - * This theme's light/dark polarity. Several themes may share the same one (e.g. two dark themes). - * Optional — when omitted, `colorScheme()` falls back to the OS preference instead of this theme's own value. - */ - colorScheme?: KbqThemeColorScheme; - /** - * @deprecated Selection state is now owned by `KbqThemeService` — read `currentTheme()` or `selection()` instead. - * Kept in sync by the service for backward compatibility. + * @deprecated Selection state is now owned by `KbqThemeService` — read `currentTheme()`/`mode()` instead. + * Kept in sync by the deprecated `ThemeService` facade for backward compatibility. */ selected?: boolean; + colorScheme?: KbqThemeColorScheme; } -/** - * What `selection()` returns and `KbqThemeStore` persists: `'auto'`, or a theme's `name`. `string`-backed, - * but keeps `'auto'`'s literal suggested in editors instead of collapsing to plain `string`. - */ -export type KbqThemeName = 'auto' | (string & {}); +/** A theme registered with `KbqThemeService`, resolved by `mode()` via its required `colorScheme`. */ +export interface KbqThemeConfig { + name: string; + /** CSS class applied to the document body when this theme is active. */ + className: string; + colorScheme: KbqThemeColorScheme; +} /** CSS class names for `KbqDefaultThemes`, the built-in light/dark theme set. */ export enum KbqThemeSelector { @@ -63,60 +66,55 @@ export enum KbqThemeNames { } /** The built-in light/dark theme set — `KBQ_THEME_CONFIG`'s default `themes`. @docs-private */ -export const KbqDefaultThemes: KbqTheme[] = [ +export const KbqDefaultThemes: KbqThemeConfig[] = [ { name: KbqThemeNames.Default, className: KbqThemeSelector.Default, colorScheme: 'light' }, { name: KbqThemeNames.Dark, className: KbqThemeSelector.Dark, colorScheme: 'dark' } ]; -/** Configuration accepted by `KBQ_THEME_CONFIG` / `kbqThemeProvider()`. */ -export interface KbqThemeConfig { +/** Settings accepted by `KBQ_THEME_CONFIG` / `kbqThemeProvider()`. */ +export interface KbqThemeSettings { /** Themes available to the service. @default KbqDefaultThemes */ themes: T[]; /** Initial mode, used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default 'auto' */ - mode: KbqThemeColorScheme | KbqThemeName; + mode: KbqThemeMode; /** Key used to persist the selection — a `localStorage` key or cookie name, depending on `KBQ_THEME_STORE`. @default 'kbq-theme-mode' */ storageKey: string; - /** Theme `name` that `'auto'` resolves to when the OS prefers a light color scheme. @default 'light' */ - autoLight: string; - /** Theme `name` that `'auto'` resolves to when the OS prefers a dark color scheme. @default 'dark' */ - autoDark: string; } -const KBQ_THEME_DEFAULT_CONFIG: KbqThemeConfig = { +const KBQ_THEME_DEFAULT_SETTINGS: KbqThemeSettings = { themes: KbqDefaultThemes, mode: 'auto', - storageKey: 'kbq-theme-mode', - autoLight: 'light', - autoDark: 'dark' + storageKey: 'kbq-theme-mode' }; -/** Injection token for `KbqThemeService`'s configuration. Configure via `kbqThemeProvider()`, not this directly. */ -export const KBQ_THEME_CONFIG = new InjectionToken('KBQ_THEME_CONFIG', { +/** Injection token for `KbqThemeService`'s settings. Configure via `kbqThemeProvider()`, not this directly. */ +export const KBQ_THEME_CONFIG = new InjectionToken('KBQ_THEME_CONFIG', { providedIn: 'root', - factory: () => KBQ_THEME_DEFAULT_CONFIG + factory: () => KBQ_THEME_DEFAULT_SETTINGS }); /** - * Configures `KbqThemeService` — registers custom themes, sets the initial mode, and how it's applied to the DOM. - * Only the properties you pass are overridden; anything omitted keeps its `KBQ_THEME_DEFAULT_CONFIG` value. + * Configures `KbqThemeService` — registers custom themes, sets the initial mode, and how it's persisted. + * Only the properties you pass are overridden; anything omitted keeps its `KBQ_THEME_DEFAULT_SETTINGS` value. */ -export const kbqThemeProvider = (config: Partial): Provider => ({ +export const kbqThemeProvider = ( + config: Partial> +): Provider => ({ provide: KBQ_THEME_CONFIG, - useValue: { ...KBQ_THEME_DEFAULT_CONFIG, ...config } + useValue: { ...KBQ_THEME_DEFAULT_SETTINGS, ...config } }); /** - * Strategy used by `KbqThemeService` to persist and restore `selection()` — `'auto'` or a selected theme `name`, - * not a mode alone, hence "selection" rather than "mode" here. + * Strategy used by `KbqThemeService` to persist and restore `mode()`. * * Provide a custom implementation through the `KBQ_THEME_STORE` token to change where it's stored * (e.g. `sessionStorage`, a backend), or to disable persistence entirely. */ export interface KbqThemeStore { - /** Returns the previously saved selection, or `null` when nothing is stored/available. */ - getSelection(): KbqThemeName | null; - /** Persists the selection. */ - setSelection(selection: KbqThemeName): void; + /** Returns the previously saved mode, or `null` when nothing is stored/available. */ + getSelection(): KbqThemeMode | null; + /** Persists the mode. */ + setSelection(mode: KbqThemeMode): void; } /** @@ -131,18 +129,18 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { private readonly window = inject(KBQ_WINDOW); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - getSelection(): KbqThemeName | null { + getSelection(): KbqThemeMode | null { try { - return this.window.localStorage.getItem(this.storageKey); + return this.window.localStorage.getItem(this.storageKey) as KbqThemeMode | null; } catch { // No-op on the server, or wherever `localStorage` is unavailable/throws (private mode, sandboxed iframes). return null; } } - setSelection(selection: KbqThemeName): void { + setSelection(mode: KbqThemeMode): void { try { - this.window.localStorage.setItem(this.storageKey, selection); + this.window.localStorage.setItem(this.storageKey, mode); } catch { // Ignore storage write failures (server-side, quota exceeded, disabled/blocked storage, etc.). } @@ -160,23 +158,23 @@ export class KbqThemeCookieStore implements KbqThemeStore { private readonly document = inject(DOCUMENT); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - getSelection(): KbqThemeName | null { + getSelection(): KbqThemeMode | null { const prefix = `${this.storageKey}=`; const cookie = this.document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); - return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null; + return cookie ? (decodeURIComponent(cookie.slice(prefix.length)) as KbqThemeMode) : null; } - setSelection(selection: KbqThemeName): void { + setSelection(mode: KbqThemeMode): void { // 1 year: matches the lifetime a persisted UI preference is expected to have. SameSite=Lax is // sent on the top-level navigation request that SSR needs it for, while still blocking // cross-site reads. - this.document.cookie = `${this.storageKey}=${encodeURIComponent(selection)}; path=/; max-age=31536000; SameSite=Lax`; + this.document.cookie = `${this.storageKey}=${encodeURIComponent(mode)}; path=/; max-age=31536000; SameSite=Lax`; } } /** - * Injection token for the store used to persist the current selection (see `KbqThemeStore`). + * Injection token for the store used to persist the current mode (see `KbqThemeStore`). * Defaults to a `localStorage`-backed implementation (`KbqThemeLocalStorageStore`). */ export const KBQ_THEME_STORE = new InjectionToken('KBQ_THEME_STORE', { @@ -185,8 +183,9 @@ export const KBQ_THEME_STORE = new InjectionToken('KBQ_THEME_STOR }); /** - * Manages the active Koobiq theme: resolves `auto` mode from the OS color scheme, applies the active theme's - * class to the document body, and persists the current selection via `KBQ_THEME_STORE`. + * Manages the active Koobiq theme: resolves `mode()` against the OS color scheme and the registered + * `themes()`, applies the active theme's class to the document body, and persists `mode()` via + * `KBQ_THEME_STORE`. * * @example * ```ts @@ -194,15 +193,15 @@ export const KBQ_THEME_STORE = new InjectionToken('KBQ_THEME_STOR * ``` */ @Injectable({ providedIn: 'root' }) -export class KbqThemeService { +export class KbqThemeService { private readonly document = inject(DOCUMENT); private readonly window = inject(KBQ_WINDOW); private readonly store = inject(KBQ_THEME_STORE); private readonly destroyRef = inject(DestroyRef); - private readonly config: KbqThemeConfig = { - ...KBQ_THEME_DEFAULT_CONFIG, + private readonly config: KbqThemeSettings = { + ...KBQ_THEME_DEFAULT_SETTINGS, ...inject(KBQ_THEME_CONFIG) - } as KbqThemeConfig; + } as KbqThemeSettings; private readonly renderer: Renderer2; private readonly media = this.window.matchMedia('(prefers-color-scheme: dark)'); @@ -211,32 +210,23 @@ export class KbqThemeService { /** Themes available to select from. Replace via `setThemes()` to register a fully custom set. */ readonly themes = signal(this.config.themes); - /** `'auto'` to follow the OS color scheme, or the selected theme's `name`. Persisted via `KBQ_THEME_STORE`. */ - readonly selection = signal(this.store.getSelection() ?? this.config.mode); + /** Selected fixed `'light'`/`'dark'` mode, or `'auto'` to follow the OS color scheme. */ + readonly mode = signal(this.readInitialMode()); - /** Whether the theme follows the OS color scheme instead of a specific selected theme. */ - readonly auto = computed(() => this.selection() === 'auto'); + /** `mode()` resolved to a concrete `'light'`/`'dark'` target — never `'auto'`. */ + private readonly resolvedMode = computed(() => { + const mode = this.mode(); - /** The theme object currently applied to the document, or `null` if the resolved name matches none. */ - readonly currentTheme = computed(() => { - const resolvedThemeName = this.auto() - ? this.systemPrefersDark() - ? this.config.autoDark - : this.config.autoLight - : this.selection(); - - return this.themes().find((theme) => theme.name === resolvedThemeName) ?? null; + return mode === 'auto' ? (this.systemPrefersDark() ? 'dark' : 'light') : mode; }); - /** Light/dark polarity of `currentTheme()`. Falls back to the OS preference. */ - readonly colorScheme = computed( - () => this.currentTheme()?.colorScheme ?? (this.systemPrefersDark() ? 'dark' : 'light') + /** The theme whose `colorScheme` matches `resolvedMode()`, or `null` if none is registered for it. */ + readonly currentTheme = computed( + () => this.themes().find((theme) => theme.colorScheme === this.resolvedMode()) ?? null ); - /** - * @deprecated read `currentTheme()` instead. Kept in sync for backward compatibility. - */ - readonly current = new BehaviorSubject(null); + /** `currentTheme()`'s polarity, falling back to `resolvedMode()` if nothing matched. */ + readonly colorScheme = computed(() => this.currentTheme()?.colorScheme ?? this.resolvedMode()); constructor() { this.renderer = inject(RendererFactory2).createRenderer(null, null); @@ -245,74 +235,76 @@ export class KbqThemeService { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((event) => this.systemPrefersDark.set(event.matches)); - effect(() => { - const currentTheme = this.currentTheme(); + effect(() => this.applyTheme(this.currentTheme(), this.themes())); + effect(() => this.store.setSelection(this.mode())); + } - this.applyTheme(currentTheme, this.themes()); - this.current.next(currentTheme); - }); - effect(() => this.store.setSelection(this.selection())); + /** Switches between `'light'`/`'dark'`, based on `colorScheme()` — the current theme's actual polarity. */ + toggle() { + this.mode.set(this.colorScheme() === 'dark' ? 'light' : 'dark'); + } + + private readInitialMode(): KbqThemeMode { + const stored = this.store.getSelection(); + + return stored === 'auto' || stored === 'light' || stored === 'dark' ? stored : this.config.mode; + } + + private applyTheme(current: T | null, themes: T[]) { + for (const theme of themes) { + if (theme === current) { + this.renderer.addClass(this.document.body, theme.className); + } else { + this.renderer.removeClass(this.document.body, theme.className); + } + } } +} + +/** @deprecated use `KbqThemeService` instead. Will be removed in a future major version. */ +@Injectable({ providedIn: 'root' }) +export class ThemeService implements OnDestroy { + private readonly kbqThemeService = inject(KbqThemeService); + + /** @deprecated read `currentTheme()` on the injected `KbqThemeService` instead. */ + readonly current = new BehaviorSubject(null); - /** Registers a custom set of themes. */ - setThemes(items: T[]) { - this.themes.set(items); + private readonly subscription: Subscription; + + constructor() { + this.subscription = toObservable(this.kbqThemeService.currentTheme).subscribe((current) => { + for (const theme of this.kbqThemeService.themes()) theme.selected = theme === current; + + this.current.next(current); + }); } - /** Selects a specific registered theme directly by `name`, turning `auto()` off. */ - selectTheme(name: KbqThemeNames | KbqThemeName) { - this.selection.set(name); + ngOnDestroy() { + this.subscription.unsubscribe(); } - /** Follows the OS color scheme. */ - setAuto() { - this.selection.set('auto'); + /** @deprecated read `themes()` on the injected `KbqThemeService` instead. */ + get themes(): T[] { + return this.kbqThemeService.themes(); } - /** - * Switches between `autoLight`/`autoDark` (`light`/`dark` by default), based on `colorScheme()` — so - * it does the right thing even when `currentTheme()` is some other, directly-selected theme whose - * `name` doesn't match `light`/`dark`/`autoLight`/`autoDark`. - */ - toggle() { - this.selectTheme(this.colorScheme() === 'dark' ? this.config.autoLight : this.config.autoDark); + set themes(items: T[]) { + this.kbqThemeService.themes.set(items); } - /** @deprecated use `selectTheme()` with a theme `name` instead. */ + /** @deprecated use `setMode()` on the injected `KbqThemeService` instead. */ setTheme(value: T | number) { - if (typeof value === 'number') { - const theme = this.themes()[value]; + const theme = typeof value === 'number' ? this.themes[value] : value; - if (theme) this.selectTheme(theme.name); - } else if (typeof value === 'object' && value !== null && this.themes().includes(value)) { - this.selectTheme(value.name); + if (theme && this.themes.includes(theme)) { + this.kbqThemeService.mode.set(theme.colorScheme ?? 'light'); } else { throw Error(`value has unsupported type: ${typeof value}`); } } - /** @deprecated read `currentTheme()` instead. */ + /** @deprecated read `currentTheme()` on the injected `KbqThemeService` instead. */ getTheme(): T | null { - return this.currentTheme(); - } - - private applyTheme(current: T | null, themes: T[]) { - for (const theme of themes) { - const isActive = theme === current; - - // deprecated back-compat sync, remove together with `KbqTheme.selected` - theme.selected = isActive; - - if (isActive) { - this.renderer.addClass(this.document.body, theme.className); - } else { - this.renderer.removeClass(this.document.body, theme.className); - } - } + return this.current.value; } } - -/** @deprecated use `KbqThemeService` instead. Will be removed in a future major version. */ -export type ThemeService = KbqThemeService; -/** @deprecated use `KbqThemeService` instead. Will be removed in a future major version. */ -export const ThemeService = KbqThemeService; diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 9c9f419c75..3299901738 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -2425,7 +2425,7 @@ export const KBQ_SIZE_UNITS_CONFIG: InjectionToken; export const KBQ_SIZE_UNITS_DEFAULT_CONFIG: KbqSizeUnitsConfig; // @public -export const KBQ_THEME_CONFIG: InjectionToken>; +export const KBQ_THEME_CONFIG: InjectionToken>; // @public export const KBQ_THEME_STORE: InjectionToken; @@ -2723,7 +2723,7 @@ export class KbqDecimalPipe implements KbqNumericPipe, PipeTransform { export type KbqDefaultSizes = 'compact' | 'normal' | 'big'; // @public -export const KbqDefaultThemes: KbqTheme[]; +export const KbqDefaultThemes: KbqThemeConfig[]; // @public export class KbqDurationLongPipe extends BaseLocaleAwareFormatterPipe; } -// @public +// @public @deprecated (undocumented) export interface KbqTheme { className: string; - colorScheme?: KbqThemeColorScheme; - name: KbqThemeColorScheme | KbqThemeName; + // (undocumented) + name: string; // @deprecated (undocumented) selected?: boolean; } @@ -3837,20 +3837,17 @@ export interface KbqTheme { export type KbqThemeColorScheme = 'light' | 'dark'; // @public -export interface KbqThemeConfig { - autoDark: string; - autoLight: string; - mode: KbqThemeColorScheme | KbqThemeName; - storageKey: string; - themes: T[]; +export interface KbqThemeConfig extends KbqTheme { + // (undocumented) + colorScheme: KbqThemeColorScheme; } // @public export class KbqThemeCookieStore implements KbqThemeStore { // (undocumented) - getSelection(): KbqThemeName | null; + getSelection(): KbqThemeMode | null; // (undocumented) - setSelection(selection: KbqThemeName): void; + setSelection(mode: KbqThemeMode): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3860,9 +3857,9 @@ export class KbqThemeCookieStore implements KbqThemeStore { // @public export class KbqThemeLocalStorageStore implements KbqThemeStore { // (undocumented) - getSelection(): KbqThemeName | null; + getSelection(): KbqThemeMode | null; // (undocumented) - setSelection(selection: KbqThemeName): void; + setSelection(mode: KbqThemeMode): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3870,7 +3867,7 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { } // @public -export type KbqThemeName = 'auto' | (string & {}); +export type KbqThemeMode = 'auto' | KbqThemeColorScheme; // @public export enum KbqThemeNames { @@ -3879,7 +3876,7 @@ export enum KbqThemeNames { } // @public -export const kbqThemeProvider: (config: Partial) => Provider; +export const kbqThemeProvider: (config: Partial>) => Provider; // @public export enum KbqThemeSelector { @@ -3888,20 +3885,12 @@ export enum KbqThemeSelector { } // @public -export class KbqThemeService { +export class KbqThemeService { constructor(); - readonly auto: i0.Signal; readonly colorScheme: i0.Signal; - // @deprecated (undocumented) - readonly current: BehaviorSubject; readonly currentTheme: i0.Signal; - // @deprecated (undocumented) - getTheme(): T | null; - readonly selection: i0.WritableSignal; - selectTheme(name: KbqThemeNames | KbqThemeName): void; - setAuto(): void; - // @deprecated (undocumented) - setTheme(value: T | number): void; + readonly mode: i0.WritableSignal; + setMode(mode: KbqThemeMode): void; setThemes(items: T[]): void; readonly themes: i0.WritableSignal; toggle(): void; @@ -3911,10 +3900,17 @@ export class KbqThemeService { static ɵprov: i0.ɵɵInjectableDeclaration>; } +// @public +export interface KbqThemeSettings { + mode: KbqThemeMode; + storageKey: string; + themes: T[]; +} + // @public export interface KbqThemeStore { - getSelection(): KbqThemeName | null; - setSelection(selection: KbqThemeName): void; + getSelection(): KbqThemeMode | null; + setSelection(mode: KbqThemeMode): void; } // @public @@ -5103,10 +5099,24 @@ export enum ThemePalette { } // @public @deprecated (undocumented) -export type ThemeService = KbqThemeService; - -// @public @deprecated (undocumented) -export const ThemeService: typeof KbqThemeService; +export class ThemeService implements OnDestroy { + constructor(); + // @deprecated (undocumented) + readonly current: BehaviorSubject; + // @deprecated (undocumented) + getTheme(): T | null; + // (undocumented) + ngOnDestroy(): void; + // @deprecated (undocumented) + setTheme(value: T | number): void; + // @deprecated (undocumented) + get themes(): T[]; + set themes(items: T[]); + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration>; +} // @public (undocumented) export const THREE = 51; From 29766e3126a12e6276fb87f7bce61b2a4b45e605 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 11 Aug 2026 17:44:14 +0300 Subject: [PATCH 09/15] chore: upd golden file --- tools/public_api_guard/components/core.api.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 3299901738..ff28ec9c00 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -3828,6 +3828,8 @@ export class KbqTableNumberPipe implements KbqNumericPipe, PipeTransform { export interface KbqTheme { className: string; // (undocumented) + colorScheme?: KbqThemeColorScheme; + // (undocumented) name: string; // @deprecated (undocumented) selected?: boolean; @@ -3837,9 +3839,12 @@ export interface KbqTheme { export type KbqThemeColorScheme = 'light' | 'dark'; // @public -export interface KbqThemeConfig extends KbqTheme { +export interface KbqThemeConfig { + className: string; // (undocumented) colorScheme: KbqThemeColorScheme; + // (undocumented) + name: string; } // @public @@ -3890,8 +3895,6 @@ export class KbqThemeService { readonly colorScheme: i0.Signal; readonly currentTheme: i0.Signal; readonly mode: i0.WritableSignal; - setMode(mode: KbqThemeMode): void; - setThemes(items: T[]): void; readonly themes: i0.WritableSignal; toggle(): void; // (undocumented) @@ -5099,7 +5102,7 @@ export enum ThemePalette { } // @public @deprecated (undocumented) -export class ThemeService implements OnDestroy { +export class ThemeService implements OnDestroy { constructor(); // @deprecated (undocumented) readonly current: BehaviorSubject; From 9bf8b7488f0226ec58f2155b379c67315b144ede Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 11 Aug 2026 18:27:41 +0300 Subject: [PATCH 10/15] feat: updated get/setMode method name --- .../core/services/theme.service.spec.ts | 28 +++++++++---------- .../components/core/services/theme.service.ts | 16 +++++------ tools/public_api_guard/components/core.api.md | 12 ++++---- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index d4f7b69602..5b7825b742 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -43,7 +43,7 @@ describe('KbqThemeService', () => { function setup(matches = false) { const media = fakeMediaQueryList(matches); - store = { getSelection: jest.fn().mockReturnValue(null), setSelection: jest.fn() }; + store = { getMode: jest.fn().mockReturnValue(null), setMode: jest.fn() }; TestBed.configureTestingModule({ providers: [ @@ -189,13 +189,13 @@ describe('KbqThemeService', () => { service.mode.set('dark'); TestBed.tick(); - expect(store.setSelection).toHaveBeenCalledWith('dark'); + expect(store.setMode).toHaveBeenCalledWith('dark'); }); it('restores the mode persisted in KBQ_THEME_STORE on init', () => { const media = fakeMediaQueryList(false); - store = { getSelection: jest.fn().mockReturnValue('dark'), setSelection: jest.fn() }; + store = { getMode: jest.fn().mockReturnValue('dark'), setMode: jest.fn() }; TestBed.configureTestingModule({ providers: [ @@ -313,11 +313,11 @@ describe('KbqThemeLocalStorageStore', () => { it('persists and restores the mode via localStorage in the browser', () => { const store = setup(); - expect(store.getSelection()).toBeNull(); + expect(store.getMode()).toBeNull(); - store.setSelection('dark'); + store.setMode('dark'); - expect(store.getSelection()).toBe('dark'); + expect(store.getMode()).toBe('dark'); }); it('is a no-op when `localStorage` is unavailable (e.g. on the server)', () => { @@ -325,16 +325,16 @@ describe('KbqThemeLocalStorageStore', () => { // `localStorage` at all — accessing it throws, which the store must swallow. const store = setup({}, { localStorage: undefined }); - store.setSelection('dark'); + store.setMode('dark'); - expect(store.getSelection()).toBeNull(); + expect(store.getMode()).toBeNull(); expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); }); it('uses the storage key configured via KBQ_THEME_CONFIG', () => { const store = setup({ storageKey: 'docs_theme' }); - store.setSelection('dark'); + store.setMode('dark'); expect(localStorage.getItem('docs_theme')).toBe('dark'); expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); @@ -363,18 +363,18 @@ describe('KbqThemeCookieStore', () => { it('persists and restores the mode via a cookie', () => { const store = setup(); - expect(store.getSelection()).toBeNull(); + expect(store.getMode()).toBeNull(); - store.setSelection('dark'); + store.setMode('dark'); - expect(store.getSelection()).toBe('dark'); + expect(store.getMode()).toBe('dark'); expect(document.cookie).toContain('kbq-theme-mode=dark'); }); it('uses the storage key configured via KBQ_THEME_CONFIG', () => { const store = setup({ storageKey: 'docs_theme' }); - store.setSelection('dark'); + store.setMode('dark'); expect(document.cookie).toContain('docs_theme=dark'); expect(document.cookie).not.toContain('kbq-theme-mode='); @@ -385,7 +385,7 @@ describe('KbqThemeCookieStore', () => { const store = setup(); - expect(store.getSelection()).toBeNull(); + expect(store.getMode()).toBeNull(); }); }); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index 45e9a9c733..1258b6babe 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -112,9 +112,9 @@ export const kbqThemeProvider = ( */ export interface KbqThemeStore { /** Returns the previously saved mode, or `null` when nothing is stored/available. */ - getSelection(): KbqThemeMode | null; + getMode(): KbqThemeMode | null; /** Persists the mode. */ - setSelection(mode: KbqThemeMode): void; + setMode(mode: KbqThemeMode): void; } /** @@ -129,7 +129,7 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { private readonly window = inject(KBQ_WINDOW); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - getSelection(): KbqThemeMode | null { + getMode(): KbqThemeMode | null { try { return this.window.localStorage.getItem(this.storageKey) as KbqThemeMode | null; } catch { @@ -138,7 +138,7 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { } } - setSelection(mode: KbqThemeMode): void { + setMode(mode: KbqThemeMode): void { try { this.window.localStorage.setItem(this.storageKey, mode); } catch { @@ -158,14 +158,14 @@ export class KbqThemeCookieStore implements KbqThemeStore { private readonly document = inject(DOCUMENT); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - getSelection(): KbqThemeMode | null { + getMode(): KbqThemeMode | null { const prefix = `${this.storageKey}=`; const cookie = this.document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); return cookie ? (decodeURIComponent(cookie.slice(prefix.length)) as KbqThemeMode) : null; } - setSelection(mode: KbqThemeMode): void { + setMode(mode: KbqThemeMode): void { // 1 year: matches the lifetime a persisted UI preference is expected to have. SameSite=Lax is // sent on the top-level navigation request that SSR needs it for, while still blocking // cross-site reads. @@ -236,7 +236,7 @@ export class KbqThemeService { .subscribe((event) => this.systemPrefersDark.set(event.matches)); effect(() => this.applyTheme(this.currentTheme(), this.themes())); - effect(() => this.store.setSelection(this.mode())); + effect(() => this.store.setMode(this.mode())); } /** Switches between `'light'`/`'dark'`, based on `colorScheme()` — the current theme's actual polarity. */ @@ -245,7 +245,7 @@ export class KbqThemeService { } private readInitialMode(): KbqThemeMode { - const stored = this.store.getSelection(); + const stored = this.store.getMode(); return stored === 'auto' || stored === 'light' || stored === 'dark' ? stored : this.config.mode; } diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index ff28ec9c00..d088d2113a 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -3850,9 +3850,9 @@ export interface KbqThemeConfig { // @public export class KbqThemeCookieStore implements KbqThemeStore { // (undocumented) - getSelection(): KbqThemeMode | null; + getMode(): KbqThemeMode | null; // (undocumented) - setSelection(mode: KbqThemeMode): void; + setMode(mode: KbqThemeMode): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3862,9 +3862,9 @@ export class KbqThemeCookieStore implements KbqThemeStore { // @public export class KbqThemeLocalStorageStore implements KbqThemeStore { // (undocumented) - getSelection(): KbqThemeMode | null; + getMode(): KbqThemeMode | null; // (undocumented) - setSelection(mode: KbqThemeMode): void; + setMode(mode: KbqThemeMode): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3912,8 +3912,8 @@ export interface KbqThemeSettings { // @public export interface KbqThemeStore { - getSelection(): KbqThemeMode | null; - setSelection(mode: KbqThemeMode): void; + getMode(): KbqThemeMode | null; + setMode(mode: KbqThemeMode): void; } // @public From 755edcb247ceb89170d063cfae5309a4600fc787 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Tue, 11 Aug 2026 18:39:09 +0300 Subject: [PATCH 11/15] feat: upd units --- packages/components/core/services/theme.service.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index 5b7825b742..8432fd3509 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -163,7 +163,10 @@ describe('KbqThemeService', () => { ] } }, - { provide: KBQ_THEME_STORE, useValue: { getSelection: () => null, setSelection: () => {} } } + { + provide: KBQ_THEME_STORE, + useValue: { getMode: () => null, setMode: () => {} } satisfies KbqThemeStore + } ] }); @@ -220,7 +223,7 @@ describe('KbqThemeService', () => { TestBed.configureTestingModule({ providers: [ { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, - { provide: KBQ_THEME_STORE, useValue: { getSelection: () => 'solarized', setSelection: () => {} } } + { provide: KBQ_THEME_STORE, useValue: { getMode: () => 'solarized', setMode: () => {} } } ] }); From b4e2f1f6bcce4232a9921316908950652f8a1590 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Wed, 12 Aug 2026 12:01:50 +0300 Subject: [PATCH 12/15] feat: added pinned theme (#DS-3003) (#1882) --- packages/components/core/core.en.md | 6 + packages/components/core/core.ru.md | 6 + .../core/services/theme.service.spec.ts | 229 +++++++++++++++++- .../components/core/services/theme.service.ts | 99 +++++++- .../docs-examples/components/core/index.ts | 12 + .../components/core/ng-package.json | 5 + .../theme-static-selection-example.ts | 57 +++++ packages/docs-examples/example-module.ts | 15 ++ tools/public_api_guard/components/core.api.md | 13 + 9 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 packages/docs-examples/components/core/index.ts create mode 100644 packages/docs-examples/components/core/ng-package.json create mode 100644 packages/docs-examples/components/core/theme-static-selection/theme-static-selection-example.ts diff --git a/packages/components/core/core.en.md b/packages/components/core/core.en.md index e0062e5129..4ab870ff71 100644 --- a/packages/components/core/core.en.md +++ b/packages/components/core/core.en.md @@ -1,2 +1,8 @@ The `core` module is a foundational part of the **Koobiq** design system. It provides essential utilities, services, and components used across other modules in the system. + +## Pinning a theme by name + +Besides following the OS color scheme via `mode`, `KbqThemeService` lets you pin one theme out of the registered `themes()` by name — no light/dark polarity involved, the pin simply overrides `mode` resolution until cleared. Useful for a "select exact theme" picker, as opposed to a light/dark/auto switch. + + diff --git a/packages/components/core/core.ru.md b/packages/components/core/core.ru.md index eaf16cb55c..ae7a0c284b 100644 --- a/packages/components/core/core.ru.md +++ b/packages/components/core/core.ru.md @@ -1,2 +1,8 @@ Модуль `core` является фундаментальной частью дизайн-системы **Koobiq**. Он предоставляет базовые утилиты, сервисы и компоненты, необходимые для построения и функционирования остальных модулей системы. + +## Закрепление темы по имени + +Помимо следования цветовой схеме ОС через `mode`, `KbqThemeService` позволяет закрепить одну тему из зарегистрированных `themes()` по имени — без понятия светлой/тёмной полярности, закрепление просто переопределяет разрешение `mode` до сброса. Полезно для выбора конкретной темы, в отличие от переключателя светлая/тёмная/авто. + + diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index 8432fd3509..59d5d59e44 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -43,7 +43,12 @@ describe('KbqThemeService', () => { function setup(matches = false) { const media = fakeMediaQueryList(matches); - store = { getMode: jest.fn().mockReturnValue(null), setMode: jest.fn() }; + store = { + getMode: jest.fn().mockReturnValue(null), + setMode: jest.fn(), + getPinnedTheme: jest.fn().mockReturnValue(null), + setPinnedTheme: jest.fn() + }; TestBed.configureTestingModule({ providers: [ @@ -92,7 +97,7 @@ describe('KbqThemeService', () => { expect(document.body.classList.contains('kbq-light')).toBe(false); }); - it('setMode selects a fixed mode or falls back to the OS preference', () => { + it('mode.set() selects a fixed mode or falls back to the OS preference', () => { const { service } = setup(true); service.mode.set('light'); @@ -109,6 +114,21 @@ describe('KbqThemeService', () => { expect(service.currentTheme()?.name).toBe('dark'); }); + it('setMode() sets mode and clears an active pin, unlike mode.set() alone', () => { + const { service } = setup(false); + + service.pinnedTheme.set('dark'); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('dark'); + + service.setMode('dark'); + TestBed.tick(); + + expect(service.pinnedTheme()).toBeNull(); + expect(service.mode()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); + }); + it('toggle switches between light and dark', () => { const { service } = setup(false); @@ -165,7 +185,12 @@ describe('KbqThemeService', () => { }, { provide: KBQ_THEME_STORE, - useValue: { getMode: () => null, setMode: () => {} } satisfies KbqThemeStore + useValue: { + getMode: () => null, + setMode: () => {}, + getPinnedTheme: () => null, + setPinnedTheme: () => {} + } } ] }); @@ -198,7 +223,12 @@ describe('KbqThemeService', () => { it('restores the mode persisted in KBQ_THEME_STORE on init', () => { const media = fakeMediaQueryList(false); - store = { getMode: jest.fn().mockReturnValue('dark'), setMode: jest.fn() }; + store = { + getMode: jest.fn().mockReturnValue('dark'), + setMode: jest.fn(), + getPinnedTheme: jest.fn().mockReturnValue('dark'), + setPinnedTheme: jest.fn() + }; TestBed.configureTestingModule({ providers: [ @@ -223,7 +253,15 @@ describe('KbqThemeService', () => { TestBed.configureTestingModule({ providers: [ { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, - { provide: KBQ_THEME_STORE, useValue: { getMode: () => 'solarized', setMode: () => {} } } + { + provide: KBQ_THEME_STORE, + useValue: { + getMode: () => 'solarized', + setMode: () => {}, + getPinnedTheme: () => null, + setPinnedTheme: () => {} + } + } ] }); @@ -233,6 +271,130 @@ describe('KbqThemeService', () => { expect(service.mode()).toBe('auto'); }); + + it('pinnedTheme defaults to null, resolving currentTheme via mode as usual', () => { + const { service } = setup(true); + + expect(service.pinnedTheme()).toBeNull(); + expect(service.currentTheme()?.name).toBe('dark'); + }); + + it('pinning a theme overrides mode-based resolution, even against a mismatched colorScheme', () => { + const { service } = setup(false); + + service.themes.set([ + { name: 'acme-light', className: 'kbq-acme-light', colorScheme: 'light' }, + { name: 'acme-dark', className: 'kbq-acme-dark', colorScheme: 'dark' } + ]); + service.pinnedTheme.set('acme-dark'); + TestBed.tick(); + + expect(service.currentTheme()?.name).toBe('acme-dark'); + expect(service.colorScheme()).toBe('dark'); + expect(document.body.classList.contains('kbq-acme-dark')).toBe(true); + expect(document.body.classList.contains('kbq-acme-light')).toBe(false); + }); + + it('clearing the pin returns resolution to mode()', () => { + const { service } = setup(false); + + service.pinnedTheme.set('dark'); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('dark'); + + service.pinnedTheme.set(null); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('light'); + }); + + it("toggle clears an active pin and flips relative to the pinned theme's actual colorScheme", () => { + const { service } = setup(false); + + service.mode.set('light'); + service.pinnedTheme.set('dark'); + TestBed.tick(); + expect(service.colorScheme()).toBe('dark'); + + service.toggle(); + TestBed.tick(); + + expect(service.pinnedTheme()).toBeNull(); + expect(service.mode()).toBe('light'); + expect(service.currentTheme()?.name).toBe('light'); + }); + + it('currentTheme is null when the pinned name has no matching registered theme', () => { + const { service } = setup(false); + + expect(service.currentTheme()?.name).toBe('light'); + + service.pinnedTheme.set('unknown'); + TestBed.tick(); + + expect(service.currentTheme()).toBeNull(); + expect(document.body.classList.contains('kbq-light')).toBe(false); + }); + + it('persists the pinned theme via KBQ_THEME_STORE', () => { + const { service } = setup(false); + + service.pinnedTheme.set('dark'); + TestBed.tick(); + + expect(store.setPinnedTheme).toHaveBeenCalledWith('dark'); + }); + + it('restores the theme pinned in KBQ_THEME_STORE on init, taking priority over config.mode', () => { + const media = fakeMediaQueryList(false); + + store = { + getMode: jest.fn().mockReturnValue(null), + setMode: jest.fn(), + getPinnedTheme: jest.fn().mockReturnValue('dark'), + setPinnedTheme: jest.fn() + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, + { provide: KBQ_THEME_STORE, useValue: store } + ] + }); + + const service = TestBed.inject(KbqThemeService); + + TestBed.tick(); + + expect(service.pinnedTheme()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); + }); + + it('falls back to config.theme when nothing is persisted yet', () => { + const media = fakeMediaQueryList(false); + + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, + { provide: KBQ_THEME_CONFIG, useValue: { theme: 'dark' } }, + { + provide: KBQ_THEME_STORE, + useValue: { + getMode: () => null, + setMode: () => {}, + getPinnedTheme: () => null, + setPinnedTheme: () => {} + } + } + ] + }); + + const service = TestBed.inject(KbqThemeService); + + TestBed.tick(); + + expect(service.pinnedTheme()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); + }); }); describe('ThemeService', () => { @@ -342,6 +504,42 @@ describe('KbqThemeLocalStorageStore', () => { expect(localStorage.getItem('docs_theme')).toBe('dark'); expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); }); + + it('persists and restores the pinned theme name via localStorage', () => { + const store = setup(); + + expect(store.getPinnedTheme()).toBeNull(); + + store.setPinnedTheme('acme-dark'); + + expect(store.getPinnedTheme()).toBe('acme-dark'); + }); + + it('stores the pin under a key derived from storageKey, distinct from the mode key', () => { + const store = setup({ storageKey: 'docs_theme' }); + + store.setPinnedTheme('acme-dark'); + + expect(localStorage.getItem('docs_theme-pinned')).toBe('acme-dark'); + expect(localStorage.getItem('docs_theme')).toBeNull(); + }); + + it('clears the persisted pin when setPinnedTheme is called with null', () => { + const store = setup(); + + store.setPinnedTheme('acme-dark'); + store.setPinnedTheme(null); + + expect(store.getPinnedTheme()).toBeNull(); + }); + + it('is a no-op for the pin when localStorage is unavailable (e.g. on the server)', () => { + const store = setup({}, { localStorage: undefined }); + + store.setPinnedTheme('acme-dark'); + + expect(store.getPinnedTheme()).toBeNull(); + }); }); describe('KbqThemeCookieStore', () => { @@ -390,6 +588,26 @@ describe('KbqThemeCookieStore', () => { expect(store.getMode()).toBeNull(); }); + + it('persists and restores the pinned theme name via a cookie, under a key derived from storageKey', () => { + const store = setup(); + + expect(store.getPinnedTheme()).toBeNull(); + + store.setPinnedTheme('acme-dark'); + + expect(store.getPinnedTheme()).toBe('acme-dark'); + expect(document.cookie).toContain('kbq-theme-mode-pinned=acme-dark'); + }); + + it('clears the persisted pin when setPinnedTheme is called with null', () => { + const store = setup(); + + store.setPinnedTheme('acme-dark'); + store.setPinnedTheme(null); + + expect(store.getPinnedTheme()).toBeNull(); + }); }); describe('kbqThemeProvider', () => { @@ -401,6 +619,7 @@ describe('kbqThemeProvider', () => { const config = TestBed.inject(KBQ_THEME_CONFIG); expect(config.mode).toBe('dark'); + expect(config.theme).toBeNull(); expect(config.storageKey).toBe('kbq-theme-mode'); }); }); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index 1258b6babe..01d3e6a16e 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -77,6 +77,11 @@ export interface KbqThemeSettings { themes: T[]; /** Initial mode, used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default 'auto' */ mode: KbqThemeMode; + /** + * Name of the theme pinned initially, overriding `mode` resolution — see `KbqThemeService.pinnedTheme`. + * Used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default null + */ + theme: string | null; /** Key used to persist the selection — a `localStorage` key or cookie name, depending on `KBQ_THEME_STORE`. @default 'kbq-theme-mode' */ storageKey: string; } @@ -84,6 +89,7 @@ export interface KbqThemeSettings { const KBQ_THEME_DEFAULT_SETTINGS: KbqThemeSettings = { themes: KbqDefaultThemes, mode: 'auto', + theme: null, storageKey: 'kbq-theme-mode' }; @@ -115,6 +121,10 @@ export interface KbqThemeStore { getMode(): KbqThemeMode | null; /** Persists the mode. */ setMode(mode: KbqThemeMode): void; + /** Returns the previously saved pinned theme name, or `null` when nothing is pinned/stored/available. */ + getPinnedTheme(): string | null; + /** Persists the pinned theme name, or clears it when `null`. */ + setPinnedTheme(name: string | null): void; } /** @@ -128,6 +138,7 @@ export interface KbqThemeStore { export class KbqThemeLocalStorageStore implements KbqThemeStore { private readonly window = inject(KBQ_WINDOW); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; + private readonly pinnedStorageKey = `${this.storageKey}-pinned`; getMode(): KbqThemeMode | null { try { @@ -145,6 +156,26 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { // Ignore storage write failures (server-side, quota exceeded, disabled/blocked storage, etc.). } } + + getPinnedTheme(): string | null { + try { + return this.window.localStorage.getItem(this.pinnedStorageKey); + } catch { + return null; + } + } + + setPinnedTheme(name: string | null): void { + try { + if (name === null) { + this.window.localStorage.removeItem(this.pinnedStorageKey); + } else { + this.window.localStorage.setItem(this.pinnedStorageKey, name); + } + } catch { + // Ignore storage write failures (server-side, quota exceeded, disabled/blocked storage, etc.). + } + } } /** @@ -157,19 +188,40 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { export class KbqThemeCookieStore implements KbqThemeStore { private readonly document = inject(DOCUMENT); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; + private readonly pinnedStorageKey = `${this.storageKey}-pinned`; getMode(): KbqThemeMode | null { - const prefix = `${this.storageKey}=`; + return this.readCookie(this.storageKey) as KbqThemeMode | null; + } + + setMode(mode: KbqThemeMode): void { + this.writeCookie(this.storageKey, mode); + } + + getPinnedTheme(): string | null { + return this.readCookie(this.pinnedStorageKey); + } + + setPinnedTheme(name: string | null): void { + if (name === null) { + this.document.cookie = `${this.pinnedStorageKey}=; path=/; max-age=0`; + } else { + this.writeCookie(this.pinnedStorageKey, name); + } + } + + private readCookie(key: string): string | null { + const prefix = `${key}=`; const cookie = this.document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); - return cookie ? (decodeURIComponent(cookie.slice(prefix.length)) as KbqThemeMode) : null; + return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null; } - setMode(mode: KbqThemeMode): void { + private writeCookie(key: string, value: string): void { // 1 year: matches the lifetime a persisted UI preference is expected to have. SameSite=Lax is // sent on the top-level navigation request that SSR needs it for, while still blocking // cross-site reads. - this.document.cookie = `${this.storageKey}=${encodeURIComponent(mode)}; path=/; max-age=31536000; SameSite=Lax`; + this.document.cookie = `${key}=${encodeURIComponent(value)}; path=/; max-age=31536000; SameSite=Lax`; } } @@ -210,9 +262,15 @@ export class KbqThemeService { /** Themes available to select from. Replace via `setThemes()` to register a fully custom set. */ readonly themes = signal(this.config.themes); - /** Selected fixed `'light'`/`'dark'` mode, or `'auto'` to follow the OS color scheme. */ + /** Selected fixed `'light'`/`'dark'` mode, or `'auto'` to follow the OS color scheme. Prefer `setMode()` over setting this directly if a pin might be active. */ readonly mode = signal(this.readInitialMode()); + /** + * Name of a theme pinned out of `themes()`, overriding `mode` resolution in `currentTheme()` until + * cleared (`pinnedTheme.set(null)`) or `setMode()`/`toggle()` is called. `null` when nothing is pinned. + */ + readonly pinnedTheme = signal(this.readInitialPin()); + /** `mode()` resolved to a concrete `'light'`/`'dark'` target — never `'auto'`. */ private readonly resolvedMode = computed(() => { const mode = this.mode(); @@ -220,10 +278,16 @@ export class KbqThemeService { return mode === 'auto' ? (this.systemPrefersDark() ? 'dark' : 'light') : mode; }); - /** The theme whose `colorScheme` matches `resolvedMode()`, or `null` if none is registered for it. */ - readonly currentTheme = computed( - () => this.themes().find((theme) => theme.colorScheme === this.resolvedMode()) ?? null - ); + /** The pinned theme if `pinnedTheme()` is set, otherwise the theme whose `colorScheme` matches `resolvedMode()`. */ + readonly currentTheme = computed(() => { + const pinned = this.pinnedTheme(); + + if (pinned !== null) { + return this.themes().find((theme) => theme.name === pinned) ?? null; + } + + return this.themes().find((theme) => theme.colorScheme === this.resolvedMode()) ?? null; + }); /** `currentTheme()`'s polarity, falling back to `resolvedMode()` if nothing matched. */ readonly colorScheme = computed(() => this.currentTheme()?.colorScheme ?? this.resolvedMode()); @@ -237,11 +301,22 @@ export class KbqThemeService { effect(() => this.applyTheme(this.currentTheme(), this.themes())); effect(() => this.store.setMode(this.mode())); + effect(() => this.store.setPinnedTheme(this.pinnedTheme())); + } + + /** + * Sets a fixed `'light'`/`'dark'` mode, or `'auto'` to follow the OS color scheme — clearing an active + * pin first, so this always hands control back to dynamic resolution. Prefer this over `mode.set()` + * directly when a pin might be active; `mode.set()` alone doesn't clear `pinnedTheme()`. + */ + setMode(mode: KbqThemeMode) { + this.pinnedTheme.set(null); + this.mode.set(mode); } /** Switches between `'light'`/`'dark'`, based on `colorScheme()` — the current theme's actual polarity. */ toggle() { - this.mode.set(this.colorScheme() === 'dark' ? 'light' : 'dark'); + this.setMode(this.colorScheme() === 'dark' ? 'light' : 'dark'); } private readInitialMode(): KbqThemeMode { @@ -250,6 +325,10 @@ export class KbqThemeService { return stored === 'auto' || stored === 'light' || stored === 'dark' ? stored : this.config.mode; } + private readInitialPin(): string | null { + return this.store.getPinnedTheme() ?? this.config.theme ?? null; + } + private applyTheme(current: T | null, themes: T[]) { for (const theme of themes) { if (theme === current) { diff --git a/packages/docs-examples/components/core/index.ts b/packages/docs-examples/components/core/index.ts new file mode 100644 index 0000000000..1c9332d952 --- /dev/null +++ b/packages/docs-examples/components/core/index.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { ThemeStaticSelectionExample } from './theme-static-selection/theme-static-selection-example'; + +export { ThemeStaticSelectionExample }; + +const EXAMPLES = [ThemeStaticSelectionExample]; + +@NgModule({ + imports: EXAMPLES, + exports: EXAMPLES +}) +export class ThemeExamplesModule {} diff --git a/packages/docs-examples/components/core/ng-package.json b/packages/docs-examples/components/core/ng-package.json new file mode 100644 index 0000000000..bebf62dcb5 --- /dev/null +++ b/packages/docs-examples/components/core/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/docs-examples/components/core/theme-static-selection/theme-static-selection-example.ts b/packages/docs-examples/components/core/theme-static-selection/theme-static-selection-example.ts new file mode 100644 index 0000000000..d648a6535b --- /dev/null +++ b/packages/docs-examples/components/core/theme-static-selection/theme-static-selection-example.ts @@ -0,0 +1,57 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { + KBQ_THEME_STORE, + KbqThemeConfig, + KbqThemeLocalStorageStore, + kbqThemeProvider, + KbqThemeSelector, + KbqThemeService +} from '@koobiq/components/core'; +import { KbqDropdownModule } from '@koobiq/components/dropdown'; +import { KbqIconModule } from '@koobiq/components/icon'; + +/** + * Custom names pinned to the library's own light/dark classes (`KbqThemeSelector`) — reusing them, rather + * than inventing unstyled classes, keeps the docs page's own styling intact when this example is live. + */ +const CUSTOM_THEMES: KbqThemeConfig[] = [ + { name: 'Day', className: KbqThemeSelector.Default, colorScheme: 'light' }, + { name: 'Night', className: KbqThemeSelector.Dark, colorScheme: 'dark' } +]; + +/** + * @title Theme static selection + */ +@Component({ + selector: 'theme-static-selection-example', + imports: [KbqButtonModule, KbqDropdownModule, KbqIconModule], + template: ` + + + + @for (t of theme.themes(); track t.name) { + + } + + `, + providers: [ + KbqThemeService, + // Scopes persistence to this example too — `KBQ_THEME_STORE`'s default factory reads + // `KBQ_THEME_CONFIG` from wherever it's instantiated, so it must be re-provided locally + // alongside `kbqThemeProvider()` for the overridden `storageKey` below to actually apply. + { provide: KBQ_THEME_STORE, useClass: KbqThemeLocalStorageStore }, + kbqThemeProvider({ + themes: CUSTOM_THEMES, + theme: CUSTOM_THEMES[1].name, + storageKey: 'kbq-example-static-theme' + }) + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ThemeStaticSelectionExample { + protected readonly theme = inject(KbqThemeService); +} diff --git a/packages/docs-examples/example-module.ts b/packages/docs-examples/example-module.ts index deeb641bc0..4a8cb1147e 100644 --- a/packages/docs-examples/example-module.ts +++ b/packages/docs-examples/example-module.ts @@ -1367,6 +1367,19 @@ export const EXAMPLE_COMPONENTS: {[id: string]: LiveExample} = { "primaryFile": "content-panel-with-grid-example.ts", "importPath": "components/content-panel" }, + "theme-static-selection": { + "packagePath": "components/core/theme-static-selection", + "title": "Theme static selection", + "componentName": "ThemeStaticSelectionExample", + "files": [ + "theme-static-selection-example.ts" + ], + "localImportFiles": [], + "selector": "theme-static-selection-example", + "additionalComponents": [], + "primaryFile": "theme-static-selection-example.ts", + "importPath": "components/core" + }, "absolute-date-formatter": { "packagePath": "components/date-formatter/absolute-date-formatter", "title": "Absolute date-formatter", @@ -7759,6 +7772,8 @@ return import('@koobiq/docs-examples/components/code-block'); return import('@koobiq/docs-examples/components/content-panel'); case 'content-panel-with-grid': return import('@koobiq/docs-examples/components/content-panel'); + case 'theme-static-selection': +return import('@koobiq/docs-examples/components/core'); case 'absolute-date-formatter': return import('@koobiq/docs-examples/components/date-formatter'); case 'date-formatter-special-use': diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index d088d2113a..9dbd6b5233 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -3852,8 +3852,12 @@ export class KbqThemeCookieStore implements KbqThemeStore { // (undocumented) getMode(): KbqThemeMode | null; // (undocumented) + getPinnedTheme(): string | null; + // (undocumented) setMode(mode: KbqThemeMode): void; // (undocumented) + setPinnedTheme(name: string | null): void; + // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) static ɵprov: i0.ɵɵInjectableDeclaration; @@ -3864,8 +3868,12 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { // (undocumented) getMode(): KbqThemeMode | null; // (undocumented) + getPinnedTheme(): string | null; + // (undocumented) setMode(mode: KbqThemeMode): void; // (undocumented) + setPinnedTheme(name: string | null): void; + // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) static ɵprov: i0.ɵɵInjectableDeclaration; @@ -3895,6 +3903,8 @@ export class KbqThemeService { readonly colorScheme: i0.Signal; readonly currentTheme: i0.Signal; readonly mode: i0.WritableSignal; + readonly pinnedTheme: i0.WritableSignal; + setMode(mode: KbqThemeMode): void; readonly themes: i0.WritableSignal; toggle(): void; // (undocumented) @@ -3907,13 +3917,16 @@ export class KbqThemeService { export interface KbqThemeSettings { mode: KbqThemeMode; storageKey: string; + theme: string | null; themes: T[]; } // @public export interface KbqThemeStore { getMode(): KbqThemeMode | null; + getPinnedTheme(): string | null; setMode(mode: KbqThemeMode): void; + setPinnedTheme(name: string | null): void; } // @public From ebdc418cdf3012dd71f154f9f74ba50d1f838fbc Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Wed, 12 Aug 2026 18:07:44 +0300 Subject: [PATCH 13/15] chore: after review --- .../app/components/navbar/navbar.component.ts | 4 +- .../core/services/theme.service.spec.ts | 187 ++++++++++-------- .../components/core/services/theme.service.ts | 153 ++++++++------ .../theme-static-selection-example.ts | 50 +++-- tools/public_api_guard/components/core.api.md | 33 ++-- 5 files changed, 239 insertions(+), 188 deletions(-) diff --git a/apps/docs/src/app/components/navbar/navbar.component.ts b/apps/docs/src/app/components/navbar/navbar.component.ts index bf6d95dfc2..f3930d1906 100644 --- a/apps/docs/src/app/components/navbar/navbar.component.ts +++ b/apps/docs/src/app/components/navbar/navbar.component.ts @@ -50,7 +50,7 @@ export class DocsNavbarComponent extends DocsLocaleState { /** Options shown in the theme dropdown. `auto` follows the OS color scheme, handled inside `KbqThemeService`. */ readonly themeOptions: DocsThemeOption[] = [ { mode: 'auto', title: DOCS_TRANSLATIONS.themeSystem }, - { mode: KbqThemeNames.Default, title: DOCS_TRANSLATIONS.themeLight }, + { mode: KbqThemeNames.Light, title: DOCS_TRANSLATIONS.themeLight }, { mode: KbqThemeNames.Dark, title: DOCS_TRANSLATIONS.themeDark } ]; @@ -66,6 +66,6 @@ export class DocsNavbarComponent extends DocsLocaleState { } setTheme(mode: DocsThemeOption['mode']) { - this.themeService.mode.set(mode); + this.themeService.setMode(mode); } } diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index 59d5d59e44..c7f7d3eb52 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -1,9 +1,9 @@ import { TestBed } from '@angular/core/testing'; import { KBQ_WINDOW } from '../tokens/window'; import { + KBQ_DEFAULT_THEMES, KBQ_THEME_CONFIG, KBQ_THEME_STORE, - KbqDefaultThemes, KbqThemeCookieStore, KbqThemeLocalStorageStore, kbqThemeProvider, @@ -46,8 +46,8 @@ describe('KbqThemeService', () => { store = { getMode: jest.fn().mockReturnValue(null), setMode: jest.fn(), - getPinnedTheme: jest.fn().mockReturnValue(null), - setPinnedTheme: jest.fn() + getStaticTheme: jest.fn().mockReturnValue(null), + setStaticTheme: jest.fn() }; TestBed.configureTestingModule({ @@ -97,34 +97,34 @@ describe('KbqThemeService', () => { expect(document.body.classList.contains('kbq-light')).toBe(false); }); - it('mode.set() selects a fixed mode or falls back to the OS preference', () => { + it('setMode selects a fixed mode or falls back to the OS preference', () => { const { service } = setup(true); - service.mode.set('light'); + service.setMode('light'); TestBed.tick(); expect(service.currentTheme()?.name).toBe('light'); - service.mode.set('dark'); + service.setMode('dark'); TestBed.tick(); expect(service.currentTheme()?.name).toBe('dark'); - service.mode.set('auto'); + service.setMode('auto'); TestBed.tick(); expect(service.mode()).toBe('auto'); expect(service.currentTheme()?.name).toBe('dark'); }); - it('setMode() sets mode and clears an active pin, unlike mode.set() alone', () => { + it('setMode clears an active static theme', () => { const { service } = setup(false); - service.pinnedTheme.set('dark'); + service.selectTheme('dark'); TestBed.tick(); expect(service.currentTheme()?.name).toBe('dark'); service.setMode('dark'); TestBed.tick(); - expect(service.pinnedTheme()).toBeNull(); + expect(service.staticTheme()).toBeNull(); expect(service.mode()).toBe('dark'); expect(service.currentTheme()?.name).toBe('dark'); }); @@ -144,11 +144,11 @@ describe('KbqThemeService', () => { it('supports registering a fully custom set of themes, resolved by colorScheme', () => { const { service } = setup(false); - service.themes.set([ + service.setThemes([ { name: 'acme-light', className: 'kbq-acme-light', colorScheme: 'light' }, { name: 'acme-dark', className: 'kbq-acme-dark', colorScheme: 'dark' } ]); - service.mode.set('dark'); + service.setMode('dark'); TestBed.tick(); expect(service.currentTheme()?.className).toBe('kbq-acme-dark'); @@ -158,11 +158,11 @@ describe('KbqThemeService', () => { it("exposes colorScheme as the current theme's own polarity, independent of its name", () => { const { service } = setup(false); - service.themes.set([ + service.setThemes([ { name: 'acme-light', className: 'kbq-acme-light', colorScheme: 'light' }, { name: 'acme-dark', className: 'kbq-acme-dark', colorScheme: 'dark' } ]); - service.mode.set('dark'); + service.setMode('dark'); TestBed.tick(); expect(service.colorScheme()).toBe('dark'); @@ -174,22 +174,19 @@ describe('KbqThemeService', () => { TestBed.configureTestingModule({ providers: [ { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, - { - provide: KBQ_THEME_CONFIG, - useValue: { - themes: [ - { name: 'sunrise', className: 'kbq-sunrise', colorScheme: 'light' }, - { name: 'midnight', className: 'kbq-midnight', colorScheme: 'dark' } - ] - } - }, + kbqThemeProvider({ + themes: [ + { name: 'sunrise', className: 'kbq-sunrise', colorScheme: 'light' }, + { name: 'midnight', className: 'kbq-midnight', colorScheme: 'dark' } + ] + }), { provide: KBQ_THEME_STORE, useValue: { getMode: () => null, setMode: () => {}, - getPinnedTheme: () => null, - setPinnedTheme: () => {} + getStaticTheme: () => null, + setStaticTheme: () => {} } } ] @@ -214,7 +211,7 @@ describe('KbqThemeService', () => { it('persists the selected mode via KBQ_THEME_STORE', () => { const { service } = setup(false); - service.mode.set('dark'); + service.setMode('dark'); TestBed.tick(); expect(store.setMode).toHaveBeenCalledWith('dark'); @@ -226,8 +223,8 @@ describe('KbqThemeService', () => { store = { getMode: jest.fn().mockReturnValue('dark'), setMode: jest.fn(), - getPinnedTheme: jest.fn().mockReturnValue('dark'), - setPinnedTheme: jest.fn() + getStaticTheme: jest.fn().mockReturnValue('dark'), + setStaticTheme: jest.fn() }; TestBed.configureTestingModule({ @@ -258,8 +255,8 @@ describe('KbqThemeService', () => { useValue: { getMode: () => 'solarized', setMode: () => {}, - getPinnedTheme: () => null, - setPinnedTheme: () => {} + getStaticTheme: () => null, + setStaticTheme: () => {} } } ] @@ -272,21 +269,21 @@ describe('KbqThemeService', () => { expect(service.mode()).toBe('auto'); }); - it('pinnedTheme defaults to null, resolving currentTheme via mode as usual', () => { + it('staticTheme defaults to null, resolving currentTheme via mode as usual', () => { const { service } = setup(true); - expect(service.pinnedTheme()).toBeNull(); + expect(service.staticTheme()).toBeNull(); expect(service.currentTheme()?.name).toBe('dark'); }); - it('pinning a theme overrides mode-based resolution, even against a mismatched colorScheme', () => { + it('selecting a static theme overrides mode-based resolution, even against a mismatched colorScheme', () => { const { service } = setup(false); - service.themes.set([ + service.setThemes([ { name: 'acme-light', className: 'kbq-acme-light', colorScheme: 'light' }, { name: 'acme-dark', className: 'kbq-acme-dark', colorScheme: 'dark' } ]); - service.pinnedTheme.set('acme-dark'); + service.selectTheme('acme-dark'); TestBed.tick(); expect(service.currentTheme()?.name).toBe('acme-dark'); @@ -295,63 +292,63 @@ describe('KbqThemeService', () => { expect(document.body.classList.contains('kbq-acme-light')).toBe(false); }); - it('clearing the pin returns resolution to mode()', () => { + it('clearing the static theme returns resolution to mode()', () => { const { service } = setup(false); - service.pinnedTheme.set('dark'); + service.selectTheme('dark'); TestBed.tick(); expect(service.currentTheme()?.name).toBe('dark'); - service.pinnedTheme.set(null); + service.selectTheme(null); TestBed.tick(); expect(service.currentTheme()?.name).toBe('light'); }); - it("toggle clears an active pin and flips relative to the pinned theme's actual colorScheme", () => { + it('toggle clears an active static theme and flips relative to its actual colorScheme', () => { const { service } = setup(false); - service.mode.set('light'); - service.pinnedTheme.set('dark'); + service.setMode('light'); + service.selectTheme('dark'); TestBed.tick(); expect(service.colorScheme()).toBe('dark'); service.toggle(); TestBed.tick(); - expect(service.pinnedTheme()).toBeNull(); + expect(service.staticTheme()).toBeNull(); expect(service.mode()).toBe('light'); expect(service.currentTheme()?.name).toBe('light'); }); - it('currentTheme is null when the pinned name has no matching registered theme', () => { + it('currentTheme is null when the static theme name has no matching registered theme', () => { const { service } = setup(false); expect(service.currentTheme()?.name).toBe('light'); - service.pinnedTheme.set('unknown'); + service.selectTheme('unknown'); TestBed.tick(); expect(service.currentTheme()).toBeNull(); expect(document.body.classList.contains('kbq-light')).toBe(false); }); - it('persists the pinned theme via KBQ_THEME_STORE', () => { + it('persists the static theme via KBQ_THEME_STORE', () => { const { service } = setup(false); - service.pinnedTheme.set('dark'); + service.selectTheme('dark'); TestBed.tick(); - expect(store.setPinnedTheme).toHaveBeenCalledWith('dark'); + expect(store.setStaticTheme).toHaveBeenCalledWith('dark'); }); - it('restores the theme pinned in KBQ_THEME_STORE on init, taking priority over config.mode', () => { + it('restores the static theme persisted in KBQ_THEME_STORE on init, taking priority over config.mode', () => { const media = fakeMediaQueryList(false); store = { getMode: jest.fn().mockReturnValue(null), setMode: jest.fn(), - getPinnedTheme: jest.fn().mockReturnValue('dark'), - setPinnedTheme: jest.fn() + getStaticTheme: jest.fn().mockReturnValue('dark'), + setStaticTheme: jest.fn() }; TestBed.configureTestingModule({ @@ -365,7 +362,7 @@ describe('KbqThemeService', () => { TestBed.tick(); - expect(service.pinnedTheme()).toBe('dark'); + expect(service.staticTheme()).toBe('dark'); expect(service.currentTheme()?.name).toBe('dark'); }); @@ -375,14 +372,14 @@ describe('KbqThemeService', () => { TestBed.configureTestingModule({ providers: [ { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, - { provide: KBQ_THEME_CONFIG, useValue: { theme: 'dark' } }, + kbqThemeProvider({ theme: 'dark' }), { provide: KBQ_THEME_STORE, useValue: { getMode: () => null, setMode: () => {}, - getPinnedTheme: () => null, - setPinnedTheme: () => {} + getStaticTheme: () => null, + setStaticTheme: () => {} } } ] @@ -392,7 +389,7 @@ describe('KbqThemeService', () => { TestBed.tick(); - expect(service.pinnedTheme()).toBe('dark'); + expect(service.staticTheme()).toBe('dark'); expect(service.currentTheme()?.name).toBe('dark'); }); }); @@ -421,7 +418,7 @@ describe('ThemeService', () => { const { service } = setup(false); const kbqThemeService = TestBed.inject(KbqThemeService); - kbqThemeService.mode.set('dark'); + kbqThemeService.setMode('dark'); TestBed.tick(); expect(service.current.value?.name).toBe('dark'); @@ -443,7 +440,7 @@ describe('ThemeService', () => { TestBed.tick(); expect(service.getTheme()?.name).toBe('dark'); - service.setTheme(KbqDefaultThemes[0]); + service.setTheme(KBQ_DEFAULT_THEMES[0]); TestBed.tick(); expect(service.getTheme()?.name).toBe('light'); }); @@ -453,7 +450,7 @@ describe('ThemeService', () => { expect(service.current.value?.name).toBe('light'); - service.setTheme(KbqDefaultThemes[1]); + service.setTheme(KBQ_DEFAULT_THEMES[1]); TestBed.tick(); expect(service.current.value?.name).toBe('dark'); @@ -466,7 +463,7 @@ describe('KbqThemeLocalStorageStore', () => { TestBed.configureTestingModule({ providers: [ { provide: KBQ_WINDOW, useValue: { ...window, ...windowOverrides } }, - { provide: KBQ_THEME_CONFIG, useValue: config } + kbqThemeProvider(config) ] }); @@ -505,47 +502,50 @@ describe('KbqThemeLocalStorageStore', () => { expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); }); - it('persists and restores the pinned theme name via localStorage', () => { + it('persists and restores the static theme name via localStorage', () => { const store = setup(); - expect(store.getPinnedTheme()).toBeNull(); + expect(store.getStaticTheme()).toBeNull(); - store.setPinnedTheme('acme-dark'); + store.setStaticTheme('acme-dark'); - expect(store.getPinnedTheme()).toBe('acme-dark'); + expect(store.getStaticTheme()).toBe('acme-dark'); }); - it('stores the pin under a key derived from storageKey, distinct from the mode key', () => { + it('stores the static theme under a key derived from storageKey, distinct from the mode key', () => { const store = setup({ storageKey: 'docs_theme' }); - store.setPinnedTheme('acme-dark'); + store.setStaticTheme('acme-dark'); - expect(localStorage.getItem('docs_theme-pinned')).toBe('acme-dark'); + expect(localStorage.getItem('docs_theme-static')).toBe('acme-dark'); expect(localStorage.getItem('docs_theme')).toBeNull(); }); - it('clears the persisted pin when setPinnedTheme is called with null', () => { - const store = setup(); + it('clears the persisted static theme when setStaticTheme is called with null, by storing an empty string', () => { + const store = setup({ storageKey: 'docs_theme' }); - store.setPinnedTheme('acme-dark'); - store.setPinnedTheme(null); + store.setStaticTheme('acme-dark'); + store.setStaticTheme(null); - expect(store.getPinnedTheme()).toBeNull(); + expect(store.getStaticTheme()).toBeNull(); + // Empty, not absent — `setStaticTheme(null)` never removes the key, it stores `''` (see the store's + // own comment for why: `''` is unambiguous, since no real theme has an empty `name`). + expect(localStorage.getItem('docs_theme-static')).toBe(''); }); - it('is a no-op for the pin when localStorage is unavailable (e.g. on the server)', () => { + it('is a no-op for the static theme when localStorage is unavailable (e.g. on the server)', () => { const store = setup({}, { localStorage: undefined }); - store.setPinnedTheme('acme-dark'); + store.setStaticTheme('acme-dark'); - expect(store.getPinnedTheme()).toBeNull(); + expect(store.getStaticTheme()).toBeNull(); }); }); describe('KbqThemeCookieStore', () => { function setup(config: { storageKey?: string } = {}) { TestBed.configureTestingModule({ - providers: [{ provide: KBQ_THEME_CONFIG, useValue: { storageKey: 'kbq-theme-mode', ...config } }] + providers: [kbqThemeProvider(config)] }); return TestBed.inject(KbqThemeCookieStore); @@ -589,24 +589,43 @@ describe('KbqThemeCookieStore', () => { expect(store.getMode()).toBeNull(); }); - it('persists and restores the pinned theme name via a cookie, under a key derived from storageKey', () => { + it('persists and restores the static theme name via a cookie, under a key derived from storageKey', () => { const store = setup(); - expect(store.getPinnedTheme()).toBeNull(); + expect(store.getStaticTheme()).toBeNull(); + + store.setStaticTheme('acme-dark'); - store.setPinnedTheme('acme-dark'); + expect(store.getStaticTheme()).toBe('acme-dark'); + expect(document.cookie).toContain('kbq-theme-mode-static=acme-dark'); + }); + + it('clears the persisted static theme when setStaticTheme is called with null, by writing an empty value', () => { + const store = setup(); - expect(store.getPinnedTheme()).toBe('acme-dark'); - expect(document.cookie).toContain('kbq-theme-mode-pinned=acme-dark'); + store.setStaticTheme('acme-dark'); + store.setStaticTheme(null); + + expect(store.getStaticTheme()).toBeNull(); + // Empty, not absent — same reasoning as `KbqThemeLocalStorageStore`, and lets this go through the + // same `writeCookie()` path (and its skip-if-unchanged check) as every other value. + expect(document.cookie.split('; ')).toContain('kbq-theme-mode-static='); }); - it('clears the persisted pin when setPinnedTheme is called with null', () => { + it('skips writing the cookie again when the value is unchanged, so its expiry is not reset', () => { const store = setup(); - store.setPinnedTheme('acme-dark'); - store.setPinnedTheme(null); + store.setMode('dark'); + + const cookieSetter = jest.spyOn(document, 'cookie', 'set'); + + store.setMode('dark'); + + expect(cookieSetter).not.toHaveBeenCalled(); + + store.setMode('light'); - expect(store.getPinnedTheme()).toBeNull(); + expect(cookieSetter).toHaveBeenCalledWith(expect.stringContaining('kbq-theme-mode=light')); }); }); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index 01d3e6a16e..ce99d12372 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -49,36 +49,43 @@ export interface KbqThemeConfig { colorScheme: KbqThemeColorScheme; } -/** CSS class names for `KbqDefaultThemes`, the built-in light/dark theme set. */ +/** CSS class names for `KBQ_DEFAULT_THEMES`, the built-in light/dark theme set. */ export enum KbqThemeSelector { /** Class for the built-in light theme. */ + Light = 'kbq-light', + /** @deprecated use `Light` instead. Will be removed in a next major version. */ Default = 'kbq-light', /** Class for the built-in dark theme. */ Dark = 'kbq-dark' } -/** Theme names for `KbqDefaultThemes`, the built-in light/dark theme set. */ +/** Theme names for `KBQ_DEFAULT_THEMES`, the built-in light/dark theme set. */ export enum KbqThemeNames { /** Name for the built-in light theme. */ + Light = 'light', + /** @deprecated use `Light` instead. Will be removed in a next major version. */ Default = 'light', /** Name for the built-in dark theme. */ Dark = 'dark' } /** The built-in light/dark theme set — `KBQ_THEME_CONFIG`'s default `themes`. @docs-private */ -export const KbqDefaultThemes: KbqThemeConfig[] = [ - { name: KbqThemeNames.Default, className: KbqThemeSelector.Default, colorScheme: 'light' }, +export const KBQ_DEFAULT_THEMES: KbqThemeConfig[] = [ + { name: KbqThemeNames.Light, className: KbqThemeSelector.Light, colorScheme: 'light' }, { name: KbqThemeNames.Dark, className: KbqThemeSelector.Dark, colorScheme: 'dark' } ]; +/** @deprecated use `KBQ_DEFAULT_THEMES` instead. Will be removed in a next major version. */ +export const KbqDefaultThemes = KBQ_DEFAULT_THEMES; + /** Settings accepted by `KBQ_THEME_CONFIG` / `kbqThemeProvider()`. */ export interface KbqThemeSettings { - /** Themes available to the service. @default KbqDefaultThemes */ + /** Themes available to the service. @default KBQ_DEFAULT_THEMES */ themes: T[]; /** Initial mode, used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default 'auto' */ mode: KbqThemeMode; /** - * Name of the theme pinned initially, overriding `mode` resolution — see `KbqThemeService.pinnedTheme`. + * Name of the theme pinned initially, overriding `mode` resolution — see `KbqThemeService.staticTheme`. * Used only when nothing is persisted yet in the `KBQ_THEME_STORE`. @default null */ theme: string | null; @@ -87,7 +94,7 @@ export interface KbqThemeSettings { } const KBQ_THEME_DEFAULT_SETTINGS: KbqThemeSettings = { - themes: KbqDefaultThemes, + themes: KBQ_DEFAULT_THEMES, mode: 'auto', theme: null, storageKey: 'kbq-theme-mode' @@ -117,14 +124,21 @@ export const kbqThemeProvider = ( * (e.g. `sessionStorage`, a backend), or to disable persistence entirely. */ export interface KbqThemeStore { - /** Returns the previously saved mode, or `null` when nothing is stored/available. */ + /** + * Returns the previously saved mode, or `null` when nothing is stored/available. Raw value only — + * applying `KbqThemeSettings.mode` as the default for a `null`/invalid result is the caller's job + * (see `KbqThemeService`'s `readInitialMode()`), not this method's. + */ getMode(): KbqThemeMode | null; /** Persists the mode. */ setMode(mode: KbqThemeMode): void; - /** Returns the previously saved pinned theme name, or `null` when nothing is pinned/stored/available. */ - getPinnedTheme(): string | null; - /** Persists the pinned theme name, or clears it when `null`. */ - setPinnedTheme(name: string | null): void; + /** + * Returns the previously saved static theme name, or `null` when nothing is available. + * Raw value only — applying `KbqThemeSettings.theme` as the default is the caller's job, not this method's. + */ + getStaticTheme(): string | null; + /** Persists the static theme name, or clears it when `null`. */ + setStaticTheme(name: string | null): void; } /** @@ -138,7 +152,7 @@ export interface KbqThemeStore { export class KbqThemeLocalStorageStore implements KbqThemeStore { private readonly window = inject(KBQ_WINDOW); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - private readonly pinnedStorageKey = `${this.storageKey}-pinned`; + private readonly staticThemeStorageKey = `${this.storageKey}-static`; getMode(): KbqThemeMode | null { try { @@ -157,21 +171,21 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { } } - getPinnedTheme(): string | null { + getStaticTheme(): string | null { try { - return this.window.localStorage.getItem(this.pinnedStorageKey); + // `|| null`: an empty string means "cleared" (see `setStaticTheme()`) — never a real theme name. + return this.window.localStorage.getItem(this.staticThemeStorageKey) || null; } catch { return null; } } - setPinnedTheme(name: string | null): void { + setStaticTheme(name: string | null): void { try { - if (name === null) { - this.window.localStorage.removeItem(this.pinnedStorageKey); - } else { - this.window.localStorage.setItem(this.pinnedStorageKey, name); - } + // Not `setItem(key, null)` — `localStorage` coerces the value to the string `"null"`, which would + // then read back as if it were a real theme name. An empty string is unambiguous, since no theme + // has an empty `name`, and `getStaticTheme()` treats it the same as an absent key. + this.window.localStorage.setItem(this.staticThemeStorageKey, name ?? ''); } catch { // Ignore storage write failures (server-side, quota exceeded, disabled/blocked storage, etc.). } @@ -188,7 +202,7 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { export class KbqThemeCookieStore implements KbqThemeStore { private readonly document = inject(DOCUMENT); private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; - private readonly pinnedStorageKey = `${this.storageKey}-pinned`; + private readonly staticThemeStorageKey = `${this.storageKey}-static`; getMode(): KbqThemeMode | null { return this.readCookie(this.storageKey) as KbqThemeMode | null; @@ -198,16 +212,16 @@ export class KbqThemeCookieStore implements KbqThemeStore { this.writeCookie(this.storageKey, mode); } - getPinnedTheme(): string | null { - return this.readCookie(this.pinnedStorageKey); + getStaticTheme(): string | null { + // `|| null`: an empty string means "cleared" (see `setStaticTheme()`) — never a real theme name. + return this.readCookie(this.staticThemeStorageKey) || null; } - setPinnedTheme(name: string | null): void { - if (name === null) { - this.document.cookie = `${this.pinnedStorageKey}=; path=/; max-age=0`; - } else { - this.writeCookie(this.pinnedStorageKey, name); - } + setStaticTheme(name: string | null): void { + // An empty string is unambiguous, since no theme has an empty `name` — same reasoning as + // `KbqThemeLocalStorageStore`. Goes through the same `writeCookie()` as every other value, so it + // gets the same skip-if-unchanged behavior instead of needing a separate expiry branch. + this.writeCookie(this.staticThemeStorageKey, name ?? ''); } private readCookie(key: string): string | null { @@ -218,10 +232,14 @@ export class KbqThemeCookieStore implements KbqThemeStore { } private writeCookie(key: string, value: string): void { - // 1 year: matches the lifetime a persisted UI preference is expected to have. SameSite=Lax is - // sent on the top-level navigation request that SSR needs it for, while still blocking - // cross-site reads. - this.document.cookie = `${key}=${encodeURIComponent(value)}; path=/; max-age=31536000; SameSite=Lax`; + // Skip the write when unchanged — `modeState`/`staticThemeState` persistence runs as an `effect()` on every + // recompute, and re-writing an identical value would silently reset the cookie's expiry each time. + if (this.readCookie(key) === value) return; + + // 1 year: matches the lifetime a persisted UI preference is expected to have. No `SameSite` — + // this only ever writes its own theme cookie by exact key, so it has no cross-site write to + // guard against; leave whatever policy the app's other cookies use untouched. + this.document.cookie = `${key}=${encodeURIComponent(value)}; path=/; max-age=31536000`; } } @@ -250,43 +268,43 @@ export class KbqThemeService { private readonly window = inject(KBQ_WINDOW); private readonly store = inject(KBQ_THEME_STORE); private readonly destroyRef = inject(DestroyRef); - private readonly config: KbqThemeSettings = { - ...KBQ_THEME_DEFAULT_SETTINGS, - ...inject(KBQ_THEME_CONFIG) - } as KbqThemeSettings; + private readonly config = inject(KBQ_THEME_CONFIG) as KbqThemeSettings; private readonly renderer: Renderer2; private readonly media = this.window.matchMedia('(prefers-color-scheme: dark)'); private readonly systemPrefersDark = signal(this.media.matches); - /** Themes available to select from. Replace via `setThemes()` to register a fully custom set. */ - readonly themes = signal(this.config.themes); - - /** Selected fixed `'light'`/`'dark'` mode, or `'auto'` to follow the OS color scheme. Prefer `setMode()` over setting this directly if a pin might be active. */ - readonly mode = signal(this.readInitialMode()); + private readonly themesState = signal(this.config.themes); + private readonly modeState = signal(this.readInitialMode()); + private readonly staticThemeState = signal(this.readInitialStaticTheme()); + /** Themes available to select from. Set via `setThemes()` to register a fully custom set. */ + readonly themes = this.themesState.asReadonly(); + /** Selected fixed `'light'`/`'dark'` mode, or `'auto'` to follow the OS color scheme. Set via `setMode()`. */ + readonly mode = this.modeState.asReadonly(); /** - * Name of a theme pinned out of `themes()`, overriding `mode` resolution in `currentTheme()` until - * cleared (`pinnedTheme.set(null)`) or `setMode()`/`toggle()` is called. `null` when nothing is pinned. + * Name of a theme selected out of `themes()`, + * overriding `mode` resolution in `currentTheme()` until + * cleared or `setMode()`/`toggle()` is called. `null` when nothing is selected. */ - readonly pinnedTheme = signal(this.readInitialPin()); + readonly staticTheme = this.staticThemeState.asReadonly(); /** `mode()` resolved to a concrete `'light'`/`'dark'` target — never `'auto'`. */ private readonly resolvedMode = computed(() => { - const mode = this.mode(); + const mode = this.modeState(); return mode === 'auto' ? (this.systemPrefersDark() ? 'dark' : 'light') : mode; }); - /** The pinned theme if `pinnedTheme()` is set, otherwise the theme whose `colorScheme` matches `resolvedMode()`. */ + /** The static theme if `staticTheme()` is set, otherwise the theme whose `colorScheme` matches `resolvedMode()`. */ readonly currentTheme = computed(() => { - const pinned = this.pinnedTheme(); + const staticTheme = this.staticThemeState(); - if (pinned !== null) { - return this.themes().find((theme) => theme.name === pinned) ?? null; + if (staticTheme !== null) { + return this.themesState().find((theme) => theme.name === staticTheme) ?? null; } - return this.themes().find((theme) => theme.colorScheme === this.resolvedMode()) ?? null; + return this.themesState().find((theme) => theme.colorScheme === this.resolvedMode()) ?? null; }); /** `currentTheme()`'s polarity, falling back to `resolvedMode()` if nothing matched. */ @@ -299,19 +317,28 @@ export class KbqThemeService { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((event) => this.systemPrefersDark.set(event.matches)); - effect(() => this.applyTheme(this.currentTheme(), this.themes())); - effect(() => this.store.setMode(this.mode())); - effect(() => this.store.setPinnedTheme(this.pinnedTheme())); + effect(() => this.applyTheme(this.currentTheme(), this.themesState())); + effect(() => this.store.setMode(this.modeState())); + effect(() => this.store.setStaticTheme(this.staticThemeState())); + } + + /** Replaces the registered theme set with a fully custom one. */ + setThemes(items: T[]) { + this.themesState.set(items); } /** * Sets a fixed `'light'`/`'dark'` mode, or `'auto'` to follow the OS color scheme — clearing an active - * pin first, so this always hands control back to dynamic resolution. Prefer this over `mode.set()` - * directly when a pin might be active; `mode.set()` alone doesn't clear `pinnedTheme()`. + * static theme first, so this always hands control back to dynamic resolution. */ setMode(mode: KbqThemeMode) { - this.pinnedTheme.set(null); - this.mode.set(mode); + this.selectTheme(null); + this.modeState.set(mode); + } + + /** Pins a theme by name out of `themes()`, or clears the pin when `name` is `null`. */ + selectTheme(name: string | null) { + this.staticThemeState.set(name); } /** Switches between `'light'`/`'dark'`, based on `colorScheme()` — the current theme's actual polarity. */ @@ -325,8 +352,8 @@ export class KbqThemeService { return stored === 'auto' || stored === 'light' || stored === 'dark' ? stored : this.config.mode; } - private readInitialPin(): string | null { - return this.store.getPinnedTheme() ?? this.config.theme ?? null; + private readInitialStaticTheme(): string | null { + return this.store.getStaticTheme() ?? this.config.theme ?? null; } private applyTheme(current: T | null, themes: T[]) { @@ -368,7 +395,7 @@ export class ThemeService implements OnDestroy { } set themes(items: T[]) { - this.kbqThemeService.themes.set(items); + this.kbqThemeService.setThemes(items); } /** @deprecated use `setMode()` on the injected `KbqThemeService` instead. */ @@ -376,7 +403,7 @@ export class ThemeService implements OnDestroy { const theme = typeof value === 'number' ? this.themes[value] : value; if (theme && this.themes.includes(theme)) { - this.kbqThemeService.mode.set(theme.colorScheme ?? 'light'); + this.kbqThemeService.setMode(theme.colorScheme ?? 'light'); } else { throw Error(`value has unsupported type: ${typeof value}`); } diff --git a/packages/docs-examples/components/core/theme-static-selection/theme-static-selection-example.ts b/packages/docs-examples/components/core/theme-static-selection/theme-static-selection-example.ts index d648a6535b..c4c465f16b 100644 --- a/packages/docs-examples/components/core/theme-static-selection/theme-static-selection-example.ts +++ b/packages/docs-examples/components/core/theme-static-selection/theme-static-selection-example.ts @@ -1,13 +1,6 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; import { KbqButtonModule } from '@koobiq/components/button'; -import { - KBQ_THEME_STORE, - KbqThemeConfig, - KbqThemeLocalStorageStore, - kbqThemeProvider, - KbqThemeSelector, - KbqThemeService -} from '@koobiq/components/core'; +import { KbqThemeConfig, KbqThemeSelector, KbqThemeService } from '@koobiq/components/core'; import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; @@ -16,7 +9,7 @@ import { KbqIconModule } from '@koobiq/components/icon'; * than inventing unstyled classes, keeps the docs page's own styling intact when this example is live. */ const CUSTOM_THEMES: KbqThemeConfig[] = [ - { name: 'Day', className: KbqThemeSelector.Default, colorScheme: 'light' }, + { name: 'Day', className: KbqThemeSelector.Light, colorScheme: 'light' }, { name: 'Night', className: KbqThemeSelector.Dark, colorScheme: 'dark' } ]; @@ -28,30 +21,33 @@ const CUSTOM_THEMES: KbqThemeConfig[] = [ imports: [KbqButtonModule, KbqDropdownModule, KbqIconModule], template: ` - - @for (t of theme.themes(); track t.name) { - + @for (t of customThemes; track t.name) { + } `, - providers: [ - KbqThemeService, - // Scopes persistence to this example too — `KBQ_THEME_STORE`'s default factory reads - // `KBQ_THEME_CONFIG` from wherever it's instantiated, so it must be re-provided locally - // alongside `kbqThemeProvider()` for the overridden `storageKey` below to actually apply. - { provide: KBQ_THEME_STORE, useClass: KbqThemeLocalStorageStore }, - kbqThemeProvider({ - themes: CUSTOM_THEMES, - theme: CUSTOM_THEMES[1].name, - storageKey: 'kbq-example-static-theme' - }) - ], changeDetection: ChangeDetectionStrategy.OnPush }) -export class ThemeStaticSelectionExample { +export class ThemeStaticSelectionExample implements OnDestroy { + // Uses the app's single, shared `KbqThemeService` rather than a locally-provided instance — a second + // instance would apply its own resolved class to the same ``, fighting the app's real theme for + // control of it (see the `Day`/`Night` classes below, which alias `KbqThemeSelector` for this reason). protected readonly theme = inject(KbqThemeService); + protected readonly customThemes = CUSTOM_THEMES; + + private readonly previousThemes = this.theme.themes(); + private readonly previousStaticTheme = this.theme.staticTheme(); + + constructor() { + this.theme.setThemes([...this.previousThemes, ...CUSTOM_THEMES]); + } + + ngOnDestroy() { + this.theme.setThemes(this.previousThemes); + this.theme.selectTheme(this.previousStaticTheme); + } } diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 9dbd6b5233..8dcf3035c2 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -2355,6 +2355,9 @@ export const KBQ_DEFAULT_LOCALE_ID = "ru-RU"; // @public export const KBQ_DEFAULT_PRECISION_SEPARATOR = "."; +// @public +export const KBQ_DEFAULT_THEMES: KbqThemeConfig[]; + // @public @deprecated export const KBQ_FORM_FIELD_REF: InjectionToken; @@ -2722,7 +2725,7 @@ export class KbqDecimalPipe implements KbqNumericPipe, PipeTransform { // @public export type KbqDefaultSizes = 'compact' | 'normal' | 'big'; -// @public +// @public @deprecated (undocumented) export const KbqDefaultThemes: KbqThemeConfig[]; // @public @@ -3852,11 +3855,11 @@ export class KbqThemeCookieStore implements KbqThemeStore { // (undocumented) getMode(): KbqThemeMode | null; // (undocumented) - getPinnedTheme(): string | null; + getStaticTheme(): string | null; // (undocumented) setMode(mode: KbqThemeMode): void; // (undocumented) - setPinnedTheme(name: string | null): void; + setStaticTheme(name: string | null): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3868,11 +3871,11 @@ export class KbqThemeLocalStorageStore implements KbqThemeStore { // (undocumented) getMode(): KbqThemeMode | null; // (undocumented) - getPinnedTheme(): string | null; + getStaticTheme(): string | null; // (undocumented) setMode(mode: KbqThemeMode): void; // (undocumented) - setPinnedTheme(name: string | null): void; + setStaticTheme(name: string | null): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -3885,7 +3888,9 @@ export type KbqThemeMode = 'auto' | KbqThemeColorScheme; // @public export enum KbqThemeNames { Dark = "dark", - Default = "light" + // @deprecated (undocumented) + Default = "light", + Light = "light" } // @public @@ -3894,7 +3899,9 @@ export const kbqThemeProvider: (confi // @public export enum KbqThemeSelector { Dark = "kbq-dark", - Default = "kbq-light" + // @deprecated (undocumented) + Default = "kbq-light", + Light = "kbq-light" } // @public @@ -3902,10 +3909,12 @@ export class KbqThemeService { constructor(); readonly colorScheme: i0.Signal; readonly currentTheme: i0.Signal; - readonly mode: i0.WritableSignal; - readonly pinnedTheme: i0.WritableSignal; + readonly mode: i0.Signal; + selectTheme(name: string | null): void; setMode(mode: KbqThemeMode): void; - readonly themes: i0.WritableSignal; + setThemes(items: T[]): void; + readonly staticTheme: i0.Signal; + readonly themes: i0.Signal; toggle(): void; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; @@ -3924,9 +3933,9 @@ export interface KbqThemeSettings { // @public export interface KbqThemeStore { getMode(): KbqThemeMode | null; - getPinnedTheme(): string | null; + getStaticTheme(): string | null; setMode(mode: KbqThemeMode): void; - setPinnedTheme(name: string | null): void; + setStaticTheme(name: string | null): void; } // @public From 72f2c9c676fbba54d45033db4acf96b561625dc4 Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Wed, 12 Aug 2026 18:32:43 +0300 Subject: [PATCH 14/15] fix(docs): invalid example illustrations --- .../empty-state-content/empty-state-content-example.ts | 2 +- .../notification-center-empty-example.ts | 2 +- .../notification-center-error-example.ts | 2 +- .../notification-center-infinite-scroll-example.ts | 2 +- .../notification-center-overview-example.ts | 2 +- .../notification-center-popover-example.ts | 2 +- .../notification-center-push-example.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts b/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts index 08e9841e1f..be73e24eb9 100644 --- a/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts +++ b/packages/docs-examples/components/empty-state/empty-state-content/empty-state-content-example.ts @@ -68,7 +68,7 @@ export class EmptyStateContentExample { protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); - return `assets/images/${currentTheme}/empty_192.png 1x, assets/images/${currentTheme}/empty_192@2x.png 2x`; + return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, https://koobiq.io/assets/images/${currentTheme}/empty_192@2x.png 2x`; }); buttonText = 'Создать группу'; diff --git a/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts b/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts index 6e10582538..488d0e21e6 100644 --- a/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-empty/notification-center-empty-example.ts @@ -69,7 +69,7 @@ export class NotificationCenterEmptyExample { protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); - return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, assets/images/${currentTheme}/empty_192@2x.png 2x`; + return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, https://koobiq.io/assets/images/${currentTheme}/empty_192@2x.png 2x`; }); readonly isDesktop = toSignal( diff --git a/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts b/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts index d49678c4d0..1944a801d8 100644 --- a/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-error/notification-center-error-example.ts @@ -74,7 +74,7 @@ export class NotificationCenterErrorExample { protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); - return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, assets/images/${currentTheme}/empty_192@2x.png 2x`; + return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, https://koobiq.io/assets/images/${currentTheme}/empty_192@2x.png 2x`; }); readonly isDesktop = toSignal( diff --git a/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts b/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts index 6f879f4912..f4c5f843e5 100644 --- a/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-infinite-scroll/notification-center-infinite-scroll-example.ts @@ -155,7 +155,7 @@ export class NotificationCenterInfiniteScrollExample { protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); - return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, assets/images/${currentTheme}/empty_192@2x.png 2x`; + return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, https://koobiq.io/assets/images/${currentTheme}/empty_192@2x.png 2x`; }); private readonly themeService = inject(KbqThemeService, { optional: true }); diff --git a/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts b/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts index 98918e8180..95d909025f 100644 --- a/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-overview/notification-center-overview-example.ts @@ -80,7 +80,7 @@ export class NotificationCenterOverviewExample implements AfterViewInit { protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); - return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, assets/images/${currentTheme}/empty_192@2x.png 2x`; + return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, https://koobiq.io/assets/images/${currentTheme}/empty_192@2x.png 2x`; }); readonly isDesktop = toSignal( diff --git a/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts b/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts index 181eacaf03..4d4e18b4f2 100644 --- a/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-popover/notification-center-popover-example.ts @@ -87,7 +87,7 @@ export class NotificationCenterPopoverExample implements AfterViewInit { protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); - return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, assets/images/${currentTheme}/empty_192@2x.png 2x`; + return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, https://koobiq.io/assets/images/${currentTheme}/empty_192@2x.png 2x`; }); readonly isDesktop = toSignal( diff --git a/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts b/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts index 5fabe86d12..620f998fe2 100644 --- a/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts +++ b/packages/docs-examples/components/notification-center/notification-center-push/notification-center-push-example.ts @@ -76,7 +76,7 @@ export class NotificationCenterPushExample implements AfterViewInit { protected readonly srcSet = computed(() => { const currentTheme = this.currentTheme(); - return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, assets/images/${currentTheme}/empty_192@2x.png 2x`; + return `https://koobiq.io/assets/images/${currentTheme}/empty_192.png 1x, https://koobiq.io/assets/images/${currentTheme}/empty_192@2x.png 2x`; }); readonly isDesktop = toSignal( From c59aa1e0ac1b1b061d42c2f1b94d9981a7d7628b Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Thu, 13 Aug 2026 10:05:41 +0300 Subject: [PATCH 15/15] fix: compare current theme by className when applying --- .../core/services/theme.service.spec.ts | 17 +++++++++++++++++ .../components/core/services/theme.service.ts | 13 +++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/components/core/services/theme.service.spec.ts b/packages/components/core/services/theme.service.spec.ts index c7f7d3eb52..ee11080b53 100644 --- a/packages/components/core/services/theme.service.spec.ts +++ b/packages/components/core/services/theme.service.spec.ts @@ -155,6 +155,23 @@ describe('KbqThemeService', () => { expect(document.body.classList.contains('kbq-acme-dark')).toBe(true); }); + it('keeps the class applied when two registered themes share a className, regardless of array order', () => { + const { service } = setup(false); + + // A named theme layered onto the same class as an existing entry — e.g. a custom name pinned to + // the built-in light class, appended after it, exactly as a consumer registering extra named + // pins onto the default set would do. + service.setThemes([ + { name: 'light', className: 'kbq-light', colorScheme: 'light' }, + { name: 'dark', className: 'kbq-dark', colorScheme: 'dark' }, + { name: 'Day', className: 'kbq-light', colorScheme: 'light' } + ]); + TestBed.tick(); + + expect(service.currentTheme()?.name).toBe('light'); + expect(document.body.classList.contains('kbq-light')).toBe(true); + }); + it("exposes colorScheme as the current theme's own polarity, independent of its name", () => { const { service } = setup(false); diff --git a/packages/components/core/services/theme.service.ts b/packages/components/core/services/theme.service.ts index ce99d12372..c7dc5c28aa 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -357,11 +357,16 @@ export class KbqThemeService { } private applyTheme(current: T | null, themes: T[]) { - for (const theme of themes) { - if (theme === current) { - this.renderer.addClass(this.document.body, theme.className); + // By `className`, not by theme object identity — multiple registered themes (e.g. a custom set of + // names layered onto the same built-in light/dark classes) can share a `className`. Comparing by + // object would remove a class that another, differently-named entry just added for the same reason. + const classNames = new Set(themes.map((theme) => theme.className)); + + for (const className of classNames) { + if (className === current?.className) { + this.renderer.addClass(this.document.body, className); } else { - this.renderer.removeClass(this.document.body, theme.className); + this.renderer.removeClass(this.document.body, className); } } }