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..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 @@ -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.currentTheme(); 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..efaa446c58 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 colorScheme$ = toObservable(this.theme.colorScheme); 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.colorScheme$.pipe( + map((colorScheme) => colorScheme 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..f3930d1906 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, KbqThemeNames, 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: KbqThemeNames.Light, title: DOCS_TRANSLATIONS.themeLight }, + { mode: KbqThemeNames.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: DocsThemeOption['mode']) { + 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..ace2dc7578 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.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 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..c284812bbc 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 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 +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 `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 `currentTheme()`/`colorScheme()` — the OS listener and the DOM update are both handled internally now. + +**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, 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 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..565f12e210 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(...)` продолжают работать. `selection()` — сырое выбранное значение (`'auto'` либо имя конкретной темы); `auto()` — признак того, что сейчас выбран именно `'auto'`; `currentTheme()` — вычисленный объект `KbqTheme`, эквивалент `current.value`; `colorScheme()` — строго `'light' | 'dark'` полярность `currentTheme()`; используйте именно его, а не `name` темы, когда нужно узнать только светлая тема или тёмная (например, для CSS `light-dark()`). + +```ts +// Было +themeService.current.pipe(map((theme) => theme?.className)).subscribe(...); + +// Стало +themeService.currentTheme(); // читайте напрямую, либо оберните в toObservable(), если нужен поток +``` + +**`setTheme(index | theme)` устарел в пользу `selectTheme(name)`.** Выбор по индексу массива стал ненадёжным, как только `auto` перестал быть обычной зарегистрированной темой. `selectTheme(name)` выбирает любую зарегистрированную тему напрямую, включая встроенные `'light'`/`'dark'`; `setAuto()` и `toggle()` — два метода-помощника, оставленные для реально используемых в библиотеке случаев — `setLight()`/`setDark()` нет. + +**Режим `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`, оборачивающий `KbqThemeLocalStorageStore` и преобразующий результат `getSelection()` — пример такого подхода: `DocsThemeStore` в `apps/docs/src/app/services/theme-store.ts`. Также доступен `KbqThemeCookieStore` — для приложений с живым Angular SSR, которым нужно, чтобы уже первый серверный рендер учитывал сохранённый выбор посетителя; сначала прочитайте его doc-комментарий — для статически собранного сайта он не поможет. + +**Кастомные темы и настройка через 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` не совпадёт ни с одной зарегистрированной темой. + ### После миграции Миграция работает на регулярных выражениях и не переписывает алиасные импорты, локальные переменные и ре-экспорты — **проверьте диф перед коммитом**, пересоберите проект и прогоните тесты. Полный список ломающих изменений — на странице [Ломающие изменения — 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..4bbc4974d3 100644 --- a/packages/components-dev/theme-toggle.ts +++ b/packages/components-dev/theme-toggle.ts @@ -1,14 +1,13 @@ import { ChangeDetectionStrategy, Component, inject, model } from '@angular/core'; -import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; 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({ selector: 'dev-theme-toggle', imports: [KbqToggleModule, FormsModule], template: ` - isDarkTheme + isDarkTheme `, changeDetection: ChangeDetectionStrategy.OnPush, host: { @@ -18,14 +17,6 @@ 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); - - constructor() { - toObservable(this.isDarkTheme) - .pipe(takeUntilDestroyed()) - .subscribe((isDarkTheme) => { - this.theme.setTheme(isDarkTheme ? 1 : 0); - }); - } + protected readonly theme = inject(KbqThemeService); + readonly isDarkTheme = model(this.theme.colorScheme() === 'dark'); } 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 new file mode 100644 index 0000000000..ee11080b53 --- /dev/null +++ b/packages/components/core/services/theme.service.spec.ts @@ -0,0 +1,661 @@ +import { TestBed } from '@angular/core/testing'; +import { KBQ_WINDOW } from '../tokens/window'; +import { + KBQ_DEFAULT_THEMES, + KBQ_THEME_CONFIG, + KBQ_THEME_STORE, + KbqThemeCookieStore, + KbqThemeLocalStorageStore, + kbqThemeProvider, + 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(), + getStaticTheme: jest.fn().mockReturnValue(null), + setStaticTheme: 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.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.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.currentTheme()?.name).toBe('light'); + + media.emit(true); + TestBed.tick(); + + expect(service.currentTheme()?.name).toBe('dark'); + expect(document.body.classList.contains('kbq-dark')).toBe(true); + expect(document.body.classList.contains('kbq-light')).toBe(false); + }); + + it('setMode selects a fixed mode or falls back to the OS preference', () => { + const { service } = setup(true); + + service.setMode('light'); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('light'); + + service.setMode('dark'); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('dark'); + + service.setMode('auto'); + TestBed.tick(); + expect(service.mode()).toBe('auto'); + expect(service.currentTheme()?.name).toBe('dark'); + }); + + it('setMode clears an active static theme', () => { + const { service } = setup(false); + + service.selectTheme('dark'); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('dark'); + + service.setMode('dark'); + TestBed.tick(); + + expect(service.staticTheme()).toBeNull(); + expect(service.mode()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); + }); + + it('toggle switches between light and dark', () => { + const { service } = setup(false); + + service.toggle(); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('dark'); + + service.toggle(); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('light'); + }); + + it('supports registering a fully custom set of themes, resolved by colorScheme', () => { + const { service } = setup(false); + + service.setThemes([ + { name: 'acme-light', className: 'kbq-acme-light', colorScheme: 'light' }, + { name: 'acme-dark', className: 'kbq-acme-dark', colorScheme: 'dark' } + ]); + service.setMode('dark'); + TestBed.tick(); + + expect(service.currentTheme()?.className).toBe('kbq-acme-dark'); + 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); + + service.setThemes([ + { name: 'acme-light', className: 'kbq-acme-light', colorScheme: 'light' }, + { name: 'acme-dark', className: 'kbq-acme-dark', colorScheme: 'dark' } + ]); + service.setMode('dark'); + TestBed.tick(); + + expect(service.colorScheme()).toBe('dark'); + }); + + it('resolves auto mode against a custom theme set via colorScheme', () => { + const media = fakeMediaQueryList(true); + + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_WINDOW, useValue: { ...window, matchMedia: () => media.mql } }, + kbqThemeProvider({ + themes: [ + { name: 'sunrise', className: 'kbq-sunrise', colorScheme: 'light' }, + { name: 'midnight', className: 'kbq-midnight', colorScheme: 'dark' } + ] + }), + { + provide: KBQ_THEME_STORE, + useValue: { + getMode: () => null, + setMode: () => {}, + getStaticTheme: () => null, + setStaticTheme: () => {} + } + } + ] + }); + + const service = TestBed.inject(KbqThemeService); + + TestBed.tick(); + + 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.mode()).toBe('light'); + expect(service.currentTheme()?.name).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(), + getStaticTheme: jest.fn().mockReturnValue('dark'), + setStaticTheme: 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.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: { + getMode: () => 'solarized', + setMode: () => {}, + getStaticTheme: () => null, + setStaticTheme: () => {} + } + } + ] + }); + + const service = TestBed.inject(KbqThemeService); + + TestBed.tick(); + + expect(service.mode()).toBe('auto'); + }); + + it('staticTheme defaults to null, resolving currentTheme via mode as usual', () => { + const { service } = setup(true); + + expect(service.staticTheme()).toBeNull(); + expect(service.currentTheme()?.name).toBe('dark'); + }); + + it('selecting a static theme overrides mode-based resolution, even against a mismatched colorScheme', () => { + const { service } = setup(false); + + service.setThemes([ + { name: 'acme-light', className: 'kbq-acme-light', colorScheme: 'light' }, + { name: 'acme-dark', className: 'kbq-acme-dark', colorScheme: 'dark' } + ]); + service.selectTheme('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 static theme returns resolution to mode()', () => { + const { service } = setup(false); + + service.selectTheme('dark'); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('dark'); + + service.selectTheme(null); + TestBed.tick(); + expect(service.currentTheme()?.name).toBe('light'); + }); + + it('toggle clears an active static theme and flips relative to its actual colorScheme', () => { + const { service } = setup(false); + + service.setMode('light'); + service.selectTheme('dark'); + TestBed.tick(); + expect(service.colorScheme()).toBe('dark'); + + service.toggle(); + TestBed.tick(); + + expect(service.staticTheme()).toBeNull(); + expect(service.mode()).toBe('light'); + expect(service.currentTheme()?.name).toBe('light'); + }); + + 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.selectTheme('unknown'); + TestBed.tick(); + + expect(service.currentTheme()).toBeNull(); + expect(document.body.classList.contains('kbq-light')).toBe(false); + }); + + it('persists the static theme via KBQ_THEME_STORE', () => { + const { service } = setup(false); + + service.selectTheme('dark'); + TestBed.tick(); + + expect(store.setStaticTheme).toHaveBeenCalledWith('dark'); + }); + + 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(), + getStaticTheme: jest.fn().mockReturnValue('dark'), + setStaticTheme: 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.staticTheme()).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 } }, + kbqThemeProvider({ theme: 'dark' }), + { + provide: KBQ_THEME_STORE, + useValue: { + getMode: () => null, + setMode: () => {}, + getStaticTheme: () => null, + setStaticTheme: () => {} + } + } + ] + }); + + const service = TestBed.inject(KbqThemeService); + + TestBed.tick(); + + expect(service.staticTheme()).toBe('dark'); + expect(service.currentTheme()?.name).toBe('dark'); + }); +}); + +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.setMode('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; + + 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.getTheme()?.name).toBe('dark'); + + service.setTheme(KBQ_DEFAULT_THEMES[0]); + TestBed.tick(); + expect(service.getTheme()?.name).toBe('light'); + }); + + it('keeps the deprecated `current` BehaviorSubject in sync with `getTheme()`', () => { + const { service } = setup(false); + + expect(service.current.value?.name).toBe('light'); + + service.setTheme(KBQ_DEFAULT_THEMES[1]); + TestBed.tick(); + + expect(service.current.value?.name).toBe('dark'); + expect(service.current.value).toBe(service.getTheme()); + }); +}); + +describe('KbqThemeLocalStorageStore', () => { + function setup(config: { storageKey?: string } = {}, windowOverrides: Partial = {}) { + TestBed.configureTestingModule({ + providers: [ + { provide: KBQ_WINDOW, useValue: { ...window, ...windowOverrides } }, + kbqThemeProvider(config) + ] + }); + + return TestBed.inject(KbqThemeLocalStorageStore); + } + + afterEach(() => localStorage.clear()); + + it('persists and restores the mode via localStorage in the browser', () => { + const store = setup(); + + expect(store.getMode()).toBeNull(); + + store.setMode('dark'); + + expect(store.getMode()).toBe('dark'); + }); + + 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'); + + 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.setMode('dark'); + + expect(localStorage.getItem('docs_theme')).toBe('dark'); + expect(localStorage.getItem('kbq-theme-mode')).toBeNull(); + }); + + it('persists and restores the static theme name via localStorage', () => { + const store = setup(); + + expect(store.getStaticTheme()).toBeNull(); + + store.setStaticTheme('acme-dark'); + + expect(store.getStaticTheme()).toBe('acme-dark'); + }); + + it('stores the static theme under a key derived from storageKey, distinct from the mode key', () => { + const store = setup({ storageKey: 'docs_theme' }); + + store.setStaticTheme('acme-dark'); + + expect(localStorage.getItem('docs_theme-static')).toBe('acme-dark'); + expect(localStorage.getItem('docs_theme')).toBeNull(); + }); + + it('clears the persisted static theme when setStaticTheme is called with null, by storing an empty string', () => { + const store = setup({ storageKey: 'docs_theme' }); + + store.setStaticTheme('acme-dark'); + store.setStaticTheme(null); + + 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 static theme when localStorage is unavailable (e.g. on the server)', () => { + const store = setup({}, { localStorage: undefined }); + + store.setStaticTheme('acme-dark'); + + expect(store.getStaticTheme()).toBeNull(); + }); +}); + +describe('KbqThemeCookieStore', () => { + function setup(config: { storageKey?: string } = {}) { + TestBed.configureTestingModule({ + providers: [kbqThemeProvider(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(); + }); + + it('persists and restores the static theme name via a cookie, under a key derived from storageKey', () => { + const store = setup(); + + expect(store.getStaticTheme()).toBeNull(); + + store.setStaticTheme('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(); + + 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('skips writing the cookie again when the value is unchanged, so its expiry is not reset', () => { + const store = setup(); + + store.setMode('dark'); + + const cookieSetter = jest.spyOn(document, 'cookie', 'set'); + + store.setMode('dark'); + + expect(cookieSetter).not.toHaveBeenCalled(); + + store.setMode('light'); + + expect(cookieSetter).toHaveBeenCalledWith(expect.stringContaining('kbq-theme-mode=light')); + }); +}); + +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.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 683062c94c..c7dc5c28aa 100644 --- a/packages/components/core/services/theme.service.ts +++ b/packages/components/core/services/theme.service.ts @@ -1,92 +1,421 @@ 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, + OnDestroy, + Provider, + Renderer2, + RendererFactory2, + signal +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { BehaviorSubject, fromEvent, Subscription } from 'rxjs'; +import { KBQ_WINDOW } from '../tokens'; +/** + * 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'; + +/** 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 { name: string; + /** CSS class applied to the document body when this theme is active. */ + className: string; + /** + * @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; +} + +/** 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; - selected: boolean; + colorScheme: KbqThemeColorScheme; } +/** 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 `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 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 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.staticTheme`. + * 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; +} + +const KBQ_THEME_DEFAULT_SETTINGS: KbqThemeSettings = { + themes: KBQ_DEFAULT_THEMES, + mode: 'auto', + theme: null, + storageKey: 'kbq-theme-mode' +}; + +/** 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_SETTINGS +}); + /** - * Enum representing the available themes for the Koobiq design system. - * This enum is used to manage and switch between different visual themes. + * 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 enum KbqThemeSelector { +export const kbqThemeProvider = ( + config: Partial> +): Provider => ({ + provide: KBQ_THEME_CONFIG, + useValue: { ...KBQ_THEME_DEFAULT_SETTINGS, ...config } +}); + +/** + * 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 { /** - * Represents the default light theme. - * This is the standard theme that is applied - * when the application is first loaded if nothing else provided + * 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. */ - Default = 'kbq-light', + getMode(): KbqThemeMode | null; + /** Persists the mode. */ + setMode(mode: KbqThemeMode): void; /** - * This theme is used to provide a darker visual experience, often preferred in low-light environments. + * 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. */ - Dark = 'kbq-dark' + getStaticTheme(): string | null; + /** Persists the static theme name, or clears it when `null`. */ + setStaticTheme(name: string | null): void; } -export const KbqDefaultThemes: KbqTheme[] = [ - { - name: 'light', - className: KbqThemeSelector.Default, - selected: true - }, - { - name: 'dark', - className: KbqThemeSelector.Dark, - selected: false +/** + * 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 window = inject(KBQ_WINDOW); + private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; + private readonly staticThemeStorageKey = `${this.storageKey}-static`; + + getMode(): KbqThemeMode | null { + try { + 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; + } + } + + setMode(mode: KbqThemeMode): void { + try { + this.window.localStorage.setItem(this.storageKey, mode); + } catch { + // Ignore storage write failures (server-side, quota exceeded, disabled/blocked storage, etc.). + } + } + + getStaticTheme(): string | null { + try { + // `|| null`: an empty string means "cleared" (see `setStaticTheme()`) — never a real theme name. + return this.window.localStorage.getItem(this.staticThemeStorageKey) || null; + } catch { + return null; + } } -]; + setStaticTheme(name: string | null): void { + try { + // 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.). + } + } +} + +/** + * `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 ThemeService implements OnDestroy { - protected readonly document = inject(DOCUMENT); - protected readonly rendererFactory = inject(RendererFactory2); - protected renderer: Renderer2; +export class KbqThemeCookieStore implements KbqThemeStore { + private readonly document = inject(DOCUMENT); + private readonly storageKey = inject(KBQ_THEME_CONFIG).storageKey; + private readonly staticThemeStorageKey = `${this.storageKey}-static`; - current: BehaviorSubject = new BehaviorSubject(null as T); + getMode(): KbqThemeMode | null { + return this.readCookie(this.storageKey) as KbqThemeMode | null; + } - themes: T[] = KbqDefaultThemes as T[]; + setMode(mode: KbqThemeMode): void { + this.writeCookie(this.storageKey, mode); + } + + getStaticTheme(): string | null { + // `|| null`: an empty string means "cleared" (see `setStaticTheme()`) — never a real theme name. + return this.readCookie(this.staticThemeStorageKey) || null; + } + + 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 { + const prefix = `${key}=`; + const cookie = this.document.cookie.split('; ').find((entry) => entry.startsWith(prefix)); + + return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null; + } - protected subscription: Subscription; + private writeCookie(key: string, value: string): void { + // 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`; + } +} + +/** + * 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', { + providedIn: 'root', + factory: () => inject(KbqThemeLocalStorageStore) +}); + +/** + * 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 + * 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 = 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); + + 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 selected out of `themes()`, + * overriding `mode` resolution in `currentTheme()` until + * cleared or `setMode()`/`toggle()` is called. `null` when nothing is selected. + */ + readonly staticTheme = this.staticThemeState.asReadonly(); + + /** `mode()` resolved to a concrete `'light'`/`'dark'` target — never `'auto'`. */ + private readonly resolvedMode = computed(() => { + const mode = this.modeState(); + + return mode === 'auto' ? (this.systemPrefersDark() ? 'dark' : 'light') : mode; + }); + + /** The static theme if `staticTheme()` is set, otherwise the theme whose `colorScheme` matches `resolvedMode()`. */ + readonly currentTheme = computed(() => { + const staticTheme = this.staticThemeState(); + + if (staticTheme !== null) { + return this.themesState().find((theme) => theme.name === staticTheme) ?? null; + } + + return this.themesState().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()); + + constructor() { + this.renderer = inject(RendererFactory2).createRenderer(null, null); + + fromEvent(this.media, 'change') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.systemPrefersDark.set(event.matches)); + + 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 + * static theme first, so this always hands control back to dynamic resolution. + */ + setMode(mode: KbqThemeMode) { + 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. */ + toggle() { + this.setMode(this.colorScheme() === 'dark' ? 'light' : 'dark'); + } + + private readInitialMode(): KbqThemeMode { + const stored = this.store.getMode(); + + return stored === 'auto' || stored === 'light' || stored === 'dark' ? stored : this.config.mode; + } + + private readInitialStaticTheme(): string | null { + return this.store.getStaticTheme() ?? this.config.theme ?? null; + } + + private applyTheme(current: T | null, themes: T[]) { + // 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, 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); + + private readonly subscription: Subscription; constructor() { - this.renderer = this.rendererFactory.createRenderer(null, null); + this.subscription = toObservable(this.kbqThemeService.currentTheme).subscribe((current) => { + for (const theme of this.kbqThemeService.themes()) theme.selected = theme === current; - this.subscription = this.current.pipe(pairwise()).subscribe(this.update); + this.current.next(current); + }); } ngOnDestroy() { this.subscription.unsubscribe(); } - setThemes(items: T[]) { - this.themes = items; + /** @deprecated read `themes()` on the injected `KbqThemeService` instead. */ + get themes(): T[] { + return this.kbqThemeService.themes(); } + set themes(items: T[]) { + this.kbqThemeService.setThemes(items); + } + + /** @deprecated use `setMode()` on the injected `KbqThemeService` 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 = typeof value === 'number' ? this.themes[value] : value; + + if (theme && this.themes.includes(theme)) { + this.kbqThemeService.setMode(theme.colorScheme ?? 'light'); } else { throw Error(`value has unsupported type: ${typeof value}`); } } - getTheme(): T { + /** @deprecated read `currentTheme()` on the injected `KbqThemeService` instead. */ + getTheme(): T | null { return this.current.value; } - - protected update = ([prev, current]: T[]) => { - if (prev) { - prev.selected = false; - this.renderer.removeClass(this.document.body, prev.className); - } - - if (current) { - this.renderer.addClass(this.document.body, current.className); - current.selected = true; - } - }; } 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..c4c465f16b --- /dev/null +++ b/packages/docs-examples/components/core/theme-static-selection/theme-static-selection-example.ts @@ -0,0 +1,53 @@ +import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; +import { KbqButtonModule } from '@koobiq/components/button'; +import { KbqThemeConfig, 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.Light, 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 customThemes; track t.name) { + + } + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +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/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..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 @@ -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,17 +62,13 @@ 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?.colorScheme() ?? 'light'); 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 e99f58f24e..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 @@ -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,17 +63,13 @@ 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?.colorScheme() ?? 'light'); 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 031b6715ee..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 @@ -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,17 +68,13 @@ 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?.colorScheme() ?? 'light'); 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 d7ee72d32c..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 @@ -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. */ @@ -155,15 +155,11 @@ 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`; }); - 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?.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 4e738e45c6..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 @@ -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,17 +74,13 @@ 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?.colorScheme() ?? 'light'); 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 d6fe9e90c7..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 @@ -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,17 +81,13 @@ 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?.colorScheme() ?? 'light'); 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 b766af4281..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 @@ -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,17 +70,13 @@ 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?.colorScheme() ?? 'light'); 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/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 bec0b7e218..8dcf3035c2 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'; @@ -2356,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; @@ -2425,6 +2427,12 @@ export const KBQ_SIZE_UNITS_CONFIG: InjectionToken; // @public (undocumented) export const KBQ_SIZE_UNITS_DEFAULT_CONFIG: KbqSizeUnitsConfig; +// @public +export const KBQ_THEME_CONFIG: InjectionToken>; + +// @public +export const KBQ_THEME_STORE: InjectionToken; + // @public (undocumented) export const KBQ_TITLE_TEXT_REF: InjectionToken; @@ -2717,8 +2725,8 @@ export class KbqDecimalPipe implements KbqNumericPipe, PipeTransform { // @public export type KbqDefaultSizes = 'compact' | 'normal' | 'big'; -// @public (undocumented) -export const KbqDefaultThemes: KbqTheme[]; +// @public @deprecated (undocumented) +export const KbqDefaultThemes: KbqThemeConfig[]; // @public export class KbqDurationLongPipe extends BaseLocaleAwareFormatterPipe; } -// @public (undocumented) +// @public @deprecated (undocumented) export interface KbqTheme { + className: string; // (undocumented) + colorScheme?: KbqThemeColorScheme; + // (undocumented) + name: string; + // @deprecated (undocumented) + selected?: boolean; +} + +// @public +export type KbqThemeColorScheme = 'light' | 'dark'; + +// @public +export interface KbqThemeConfig { className: string; // (undocumented) + colorScheme: KbqThemeColorScheme; + // (undocumented) name: string; +} + +// @public +export class KbqThemeCookieStore implements KbqThemeStore { + // (undocumented) + getMode(): KbqThemeMode | null; + // (undocumented) + getStaticTheme(): string | null; + // (undocumented) + setMode(mode: KbqThemeMode): void; // (undocumented) - selected: boolean; + setStaticTheme(name: string | null): void; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration; } +// @public +export class KbqThemeLocalStorageStore implements KbqThemeStore { + // (undocumented) + getMode(): KbqThemeMode | null; + // (undocumented) + getStaticTheme(): string | null; + // (undocumented) + setMode(mode: KbqThemeMode): void; + // (undocumented) + setStaticTheme(name: string | null): void; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration; +} + +// @public +export type KbqThemeMode = 'auto' | KbqThemeColorScheme; + +// @public +export enum KbqThemeNames { + Dark = "dark", + // @deprecated (undocumented) + Default = "light", + Light = "light" +} + +// @public +export const kbqThemeProvider: (config: Partial>) => Provider; + // @public export enum KbqThemeSelector { Dark = "kbq-dark", - Default = "kbq-light" + // @deprecated (undocumented) + Default = "kbq-light", + Light = "kbq-light" +} + +// @public +export class KbqThemeService { + constructor(); + readonly colorScheme: i0.Signal; + readonly currentTheme: i0.Signal; + readonly mode: i0.Signal; + selectTheme(name: string | null): void; + setMode(mode: KbqThemeMode): void; + setThemes(items: T[]): void; + readonly staticTheme: i0.Signal; + readonly themes: i0.Signal; + toggle(): void; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration>; +} + +// @public +export interface KbqThemeSettings { + mode: KbqThemeMode; + storageKey: string; + theme: string | null; + themes: T[]; +} + +// @public +export interface KbqThemeStore { + getMode(): KbqThemeMode | null; + getStaticTheme(): string | null; + setMode(mode: KbqThemeMode): void; + setStaticTheme(name: string | null): void; } // @public @@ -5020,31 +5123,20 @@ export enum ThemePalette { Warning = "warning" } -// @public (undocumented) -export class ThemeService implements OnDestroy { +// @public @deprecated (undocumented) +export class ThemeService implements OnDestroy { constructor(); - // (undocumented) - current: BehaviorSubject; - // (undocumented) - protected readonly document: Document; - // (undocumented) - getTheme(): T; + // @deprecated (undocumented) + readonly current: BehaviorSubject; + // @deprecated (undocumented) + getTheme(): T | null; // (undocumented) ngOnDestroy(): void; - // (undocumented) - protected renderer: Renderer2; - // (undocumented) - protected readonly rendererFactory: RendererFactory2; - // (undocumented) + // @deprecated (undocumented) setTheme(value: T | number): void; - // (undocumented) - setThemes(items: T[]): void; - // (undocumented) - protected subscription: Subscription; - // (undocumented) - themes: T[]; - // (undocumented) - protected update: (input: T[]) => void; + // @deprecated (undocumented) + get themes(): T[]; + set themes(items: T[]); // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; // (undocumented)