Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {}
})
}
}
]
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -237,7 +238,8 @@ export class DocsTokensOverview extends DocsLocaleState implements AfterViewInit

constructor() {
super();
this.themeService.current.pipe(skip(1), takeUntilDestroyed()).subscribe(() => {
effect(() => {
this.themeService.resolvedMode();
this.tokensInfo.set(this.calculateViewData());
});

Expand All @@ -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<string, string>();

this.tokenValueCache.set(themeKey, themeCache);
Expand Down
19 changes: 9 additions & 10 deletions apps/docs/src/app/components/docsearch/docsearch.directive.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -129,7 +129,11 @@ const TRANSLATIONS: Record<DocsLocale, DocSearchProps['translations']> = {
export class DocsDocsearchDirective extends DocsLocaleState {
private readonly window = inject(KBQ_WINDOW);
private readonly destroyRef = inject(DestroyRef);
private readonly theme = inject(ThemeService);
private readonly theme = inject(KbqThemeService);

// captured eagerly (in the constructor's injection context), since `toObservable()` can't be
// called lazily from the `afterNextRender()` callback in `init()`
private readonly resolvedMode$ = toObservable(this.theme.resolvedMode);

private instance: DocSearchInstance | null = null;

Expand All @@ -148,13 +152,8 @@ export class DocsDocsearchDirective extends DocsLocaleState {

private init(): void {
combineLatest([
this.theme.current.pipe(
map(
(theme) =>
(theme?.className.replace('kbq-', '') === 'dark'
? 'dark'
: 'light') satisfies DocSearchProps['theme']
),
this.resolvedMode$.pipe(
map((mode) => (mode === 'dark' ? 'dark' : 'light') satisfies DocSearchProps['theme']),
distinctUntilChanged()
),
this.docsLocaleService.changes.pipe(distinctUntilChanged())
Expand Down
122 changes: 20 additions & 102 deletions apps/docs/src/app/components/navbar/navbar.component.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import { AsyncPipe } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
OnDestroy,
ViewEncapsulation
} from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core';
import { RouterLink } from '@angular/router';
import { KbqButtonModule } from '@koobiq/components/button';
import { KBQ_WINDOW, KbqTheme, KbqThemeSelector, ThemeService } from '@koobiq/components/core';
import { KbqThemeMode, KbqThemeService } from '@koobiq/components/core';
import { KbqDropdownModule } from '@koobiq/components/dropdown';
import { KbqIconModule } from '@koobiq/components/icon';
import { KbqLinkModule } from '@koobiq/components/link';
Expand All @@ -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<DocsLocale, string>;
}

@Component({
selector: 'docs-navbar',
Expand All @@ -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<DocsLocale, string> })[] = [
{
name: 'system',
className: this.colorAutomaticTheme.matches ? KbqThemeSelector.Default : KbqThemeSelector.Dark,
selected: false,
title: DOCS_TRANSLATIONS.themeSystem
},
{
name: 'light',
className: KbqThemeSelector.Default,
selected: false,
title: DOCS_TRANSLATIONS.themeLight
},
{
name: 'dark',
className: KbqThemeSelector.Dark,
selected: false,
title: DOCS_TRANSLATIONS.themeDark
}
/** Options shown in the theme dropdown. `auto` follows the OS color scheme, handled inside `KbqThemeService`. */
readonly themeOptions: DocsThemeOption[] = [
{ mode: 'auto', title: DOCS_TRANSLATIONS.themeSystem },
{ mode: 'light', title: DOCS_TRANSLATIONS.themeLight },
{ mode: 'dark', title: DOCS_TRANSLATIONS.themeDark }
];

/** The currently selected mode — persistence and OS-preference resolution are handled by `KbqThemeService`. */
readonly mode = computed(() => this.themeService.mode());

readonly opened$: Observable<boolean> = this.docStates.navbarMenu.pipe(
map((state) => state === DocsNavbarState.Opened)
);

constructor() {
super();

// set custom theme configs for light/dark themes
this.themeService.setThemes(this.kbqThemes);

this.themeSwitch = new DocsNavbarProperty({
property: 'docs_theme',
data: this.kbqThemes,
updateSelected: false
});

// set theme when retrieval from storage completed
afterNextRender(() => {
this.themeService.setTheme(this.themeSwitch.currentValue);
// prevent NG0100 error
this.cdr.markForCheck();
});

try {
// Chrome & Firefox
this.colorAutomaticTheme.addEventListener('change', this.setAutoTheme);
} catch {
try {
// Safari
this.colorAutomaticTheme.addListener(this.setAutoTheme);
} catch (errSafari) {
console.error(errSafari);
}
}
}

ngOnDestroy() {
// NOTE: `ThemeService` is a root singleton and owns its own lifecycle — the navbar must not
// tear it down. Only this component's own media-query listener is removed here.
try {
this.colorAutomaticTheme.removeEventListener('change', this.setAutoTheme);
} catch (err) {
console.error(err);
}
}

toggleMenu() {
this.docStates.toggleNavbarMenu();
}

setTheme(i: number) {
// should be set to keep theme index in storage
this.themeSwitch.setValue(i);
this.themeService.setTheme(i);
setTheme(mode: KbqThemeMode) {
this.themeService.setMode(mode);
}

private setAutoTheme = (e: MediaQueryListEvent) => {
if (!this.themeService.themes[0]) return;

this.themeService.themes[0] = {
...this.themeService.themes[0],
className: e.matches ? KbqThemeSelector.Default : KbqThemeSelector.Dark
};

if (this.themeService.themes[0].selected) {
this.setTheme(0);
}

// The media-query listener runs outside Angular's event bindings, so trigger a check
// explicitly for the OnPush theme dropdown.
this.cdr.markForCheck();
};
}
6 changes: 3 additions & 3 deletions apps/docs/src/app/components/navbar/navbar.template.html
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@
<span class="kbq-caps-compact-strong">{{ t('themeGroupHeader') }}</span>
</div>

@for (theme of themeSwitch.data; track theme) {
<button kbq-dropdown-item [class.kbq-selected]="theme.selected" (click)="setTheme($index)">
{{ theme.title[locale()] }}
@for (option of themeOptions; track option.mode) {
<button kbq-dropdown-item [class.kbq-selected]="option.mode === mode()" (click)="setTheme(option.mode)">
{{ option.title[locale()] }}
</button>
}
</kbq-dropdown>
23 changes: 14 additions & 9 deletions apps/docs/src/app/components/welcome/welcome.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -30,13 +38,10 @@ import { DocsRegisterHeaderDirective } from '../register-header/register-header.
}
})
export class DocsWelcomeComponent extends DocsLocaleState implements OnInit {
private readonly themeService = inject(ThemeService);
private readonly themeService = inject(KbqThemeService);

protected structureCategories: DocsStructureCategory[];
readonly currentTheme = toSignal(
this.themeService.current.pipe(map((theme) => theme?.className.replace('kbq-', '') ?? 'light')),
{ initialValue: 'light' }
);
readonly currentTheme = computed(() => this.themeService.resolvedMode());

private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly docStates = inject(DocsDocStates);
Expand Down
9 changes: 8 additions & 1 deletion apps/docs/src/app/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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),
Expand Down
25 changes: 25 additions & 0 deletions docs/guides/migration.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<KbqTheme | null>`) is deprecated in favor of two signals.** It still exists and stays in sync, so `current.value` and `current.pipe(...)` keep working. `mode()` is the selected mode (`'auto' | 'light' | 'dark'` or a custom theme name); `currentTheme()` is the resolved `KbqTheme` object, equivalent to `current.value`. `resolvedMode()` gives you `mode()` with `'auto'` already resolved to `'light'`/`'dark'`.

```ts
// Before
themeService.current.pipe(map((theme) => theme?.className)).subscribe(...);

// After
themeService.currentTheme(); // read directly, or wrap with toObservable() if you need a stream
```

**`setTheme(index | theme)` is deprecated in favor of `setMode(name)`.** Selecting by array index was fragile once `auto` stopped being a regular registered theme. `setMode('light')` / `setMode('dark')` cover a fixed mode; `setAuto()` and `toggle()` are the two convenience methods kept for the common cases actually used in this library — there is no `setLight()`/`setDark()`.

**`auto` mode is handled inside the service.** If you were reading `window.matchMedia('(prefers-color-scheme: …)')` yourself and rewriting a theme's `className` to fake a "system" option (as the docs app used to), call `themeService.setAuto()` instead and read `resolvedMode()` — the OS listener and the DOM update are both handled internally now.

**Persistence is on by default.** The selected mode is now saved to `localStorage` (key `kbq-theme-mode` by default) and restored on init through the `KBQ_THEME_STORE` token, the same swappable-store pattern as `KBQ_ACCORDION_STATE_STORE`. If you rolled your own persistence under a different key (as the docs app did, under `docs_theme`), configure `kbqThemeProvider({ storageKey: '…' })` instead of dropping it — existing users keep their saved preference. Provide a custom `KbqThemeStore` if you need a different storage backend entirely.

**Custom themes and DI-based setup.** `setThemes()` still accepts any array of `{ name, className }` objects. New: `kbqThemeProvider({ themes, mode, storageKey, autoLight, autoDark })` configures the service through DI instead of calling `setThemes()`/`setTheme()` imperatively. The active theme is always applied as a CSS class on `<body>` — the design tokens' `.kbq-light`/`.kbq-dark` styles depend on it, so there's no attribute-based alternative. `auto` mode resolves to the theme named `autoLight`/`autoDark` (`'light'`/`'dark'` by default) — set these if your custom theme set doesn't use those names, otherwise `auto` won't match any registered theme.

### After the migration

The migration is regex-based and does not rewrite aliased imports, local variables, or re-exports — **review the diff before committing**, rebuild the project and run your tests. The full list of breaking changes is on the [Angular 20 breaking changes](https://github.com/koobiq/angular-components/blob/main/docs/guides/angular-20-breaking-changes.en.md) page.
Loading