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/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..c64e1983fc 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, 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 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/docs/guides/migration.ru.md b/docs/guides/migration.ru.md index 9367bf05d6..2e7f08e77a 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-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..0998f05e4b --- /dev/null +++ b/packages/components/core/services/theme.service.spec.ts @@ -0,0 +1,280 @@ +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 = ''; + 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('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); + + 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('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..cd5f3d687a 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,221 @@ 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; + /** `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', + storageKey: 'kbq-theme-mode', + autoLight: 'light', + autoDark: 'dark' +}; + +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 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; + + try { + return this.window.localStorage.getItem(this.storageKey); + } catch { + return null; + } + } + + 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 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 ThemeService implements OnDestroy { - protected readonly document = inject(DOCUMENT); - protected readonly rendererFactory = inject(RendererFactory2); - protected renderer: Renderer2; +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); - current: BehaviorSubject = new BehaviorSubject(null as T); + /** Currently selected mode. `'auto'` resolves to `light`/`dark` based on the OS color scheme. */ + readonly mode = signal(this.store.getMode() ?? this.config.mode); - themes: T[] = KbqDefaultThemes as T[]; + /** `mode()` resolved to a concrete theme name — never `'auto'`. Uses `autoLight`/`autoDark` from `KBQ_THEME_CONFIG`. */ + readonly resolvedMode = computed(() => { + const mode = this.mode(); - protected subscription: Subscription; + 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. */ + 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)); + + effect(() => { + const currentTheme = this.currentTheme(); - ngOnDestroy() { - this.subscription.unsubscribe(); + 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 `autoLight`/`autoDark` (`light`/`dark` by default), based on the currently resolved mode. */ + toggle() { + this.setMode(this.resolvedMode() === this.config.autoDark ? this.config.autoLight : this.config.autoDark); + } + + /** @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; - if (current) { - this.renderer.addClass(this.document.body, current.className); - current.selected = true; + // 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); + } } - }; + } } + +/** @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..63228e4883 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,76 @@ 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 { + autoDark?: string; + autoLight?: string; + 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 +5079,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;