diff --git a/projects/core/router/index.ts b/projects/core/router/index.ts index 2523417..0ed0567 100644 --- a/projects/core/router/index.ts +++ b/projects/core/router/index.ts @@ -1,6 +1,7 @@ export * from '@signality/core/router/params'; export * from '@signality/core/router/query-params'; export * from '@signality/core/router/fragment'; +export * from '@signality/core/router/page-title'; export * from '@signality/core/router/title'; export * from '@signality/core/router/url'; export * from '@signality/core/router/route-data'; diff --git a/projects/core/router/page-title/index.test.ts b/projects/core/router/page-title/index.test.ts new file mode 100644 index 0000000..abb0524 --- /dev/null +++ b/projects/core/router/page-title/index.test.ts @@ -0,0 +1,89 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { BehaviorSubject } from 'rxjs'; +import { pageTitle } from './index'; + +describe(pageTitle.name, () => { + let titleState: BehaviorSubject; + let mockTitle: { getTitle: jest.Mock; setTitle: jest.Mock }; + + beforeEach(() => { + titleState = new BehaviorSubject(undefined); + + mockTitle = { + getTitle: jest.fn().mockReturnValue('Browser Title'), + setTitle: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + title: titleState.asObservable(), + snapshot: { title: titleState.getValue() }, + }, + }, + { provide: Title, useValue: mockTitle }, + ], + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + @Component({ template: '{{ title() }}' }) + class TestComponent { + readonly title = pageTitle(); + } + + const createComponent = () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + return fixture.componentInstance; + }; + + it('should update when route title changes', () => { + titleState.next('Initial Title'); + const component = createComponent(); + + expect(component.title()).toBe('Initial Title'); + + titleState.next('About Page'); + expect(component.title()).toBe('About Page'); + + titleState.next('Contact Page'); + expect(component.title()).toBe('Contact Page'); + }); + + it('should use browser title when route title is not set', () => { + titleState.next(undefined); + const component = createComponent(); + + expect(component.title()).toBe('Browser Title'); + }); + + it('should set browser title when signal is updated', () => { + const component = createComponent(); + + component.title.set('New Title'); + + expect(mockTitle.setTitle).toHaveBeenCalledWith('New Title'); + expect(component.title()).toBe('New Title'); + }); + + it('should handle multiple title updates', () => { + const component = createComponent(); + + component.title.set('First Update'); + expect(mockTitle.setTitle).toHaveBeenCalledWith('First Update'); + expect(component.title()).toBe('First Update'); + + component.title.set('Second Update'); + expect(mockTitle.setTitle).toHaveBeenCalledWith('Second Update'); + expect(component.title()).toBe('Second Update'); + }); +}); diff --git a/projects/core/router/page-title/index.ts b/projects/core/router/page-title/index.ts new file mode 100644 index 0000000..18c7477 --- /dev/null +++ b/projects/core/router/page-title/index.ts @@ -0,0 +1,62 @@ +import { type CreateSignalOptions, inject, linkedSignal, type WritableSignal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs'; +import { setupContext } from '@signality/core/internal'; +import type { WithInjector } from '@signality/core/types'; +import { proxySignal } from '@signality/core/reactivity/proxy-signal'; + +export type PageTitleOptions = CreateSignalOptions & WithInjector; + +/** + * Reactive wrapper around the [Angular Router](https://angular.dev/guide/routing) route title. + * + * @param options - Optional configuration + * @returns A writable signal containing the current route title (string) + * + * @example + * ```typescript + * @Component({ + * template: ` + *
+ *

{{ title() }}

+ * + *
+ * ` + * }) + * export class Page { + * readonly title = pageTitle(); + * + * updateTitle() { + * this.title.set('New Page Title'); + * } + * } + * ``` + */ +export function pageTitle(options?: PageTitleOptions): WritableSignal { + const { runInContext } = setupContext(options?.injector, pageTitle); + + return runInContext(() => { + const route = inject(ActivatedRoute); + const html = inject(Title); + + const source = linkedSignal( + toSignal(route.title.pipe(filter(Boolean)), { + initialValue: route.snapshot.title || html.getTitle(), + }), + { ...options } + ); + + return proxySignal( + source, + { + set: value => { + html.setTitle(value); + source.set(value); + }, + }, + { equal: options?.equal } + ); + }); +} diff --git a/projects/core/router/page-title/ng-package.json b/projects/core/router/page-title/ng-package.json new file mode 100644 index 0000000..de08b83 --- /dev/null +++ b/projects/core/router/page-title/ng-package.json @@ -0,0 +1,6 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} + diff --git a/projects/core/router/title/index.ts b/projects/core/router/title/index.ts index 8089e68..9626bac 100644 --- a/projects/core/router/title/index.ts +++ b/projects/core/router/title/index.ts @@ -1,62 +1,10 @@ -import { type CreateSignalOptions, inject, linkedSignal, type WritableSignal } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { Title } from '@angular/platform-browser'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { filter } from 'rxjs'; -import { setupContext } from '@signality/core/internal'; -import type { WithInjector } from '@signality/core/types'; -import { proxySignal } from '@signality/core/reactivity/proxy-signal'; +import { type WritableSignal } from '@angular/core'; +import { pageTitle, PageTitleOptions } from '@signality/core/router/page-title'; -export type TitleOptions = CreateSignalOptions & WithInjector; +/** @deprecated Use `PageTitleOptions` instead. Will be removed in 1.0. */ +export type TitleOptions = PageTitleOptions; -/** - * Reactive wrapper around the [Angular Router](https://angular.dev/guide/routing) route title. - * - * @param options - Optional configuration - * @returns A writable signal containing the current route title (string) - * - * @example - * ```typescript - * @Component({ - * template: ` - *
- *

{{ pageTitle() }}

- * - *
- * ` - * }) - * export class Page { - * readonly pageTitle = title(); - * - * updateTitle() { - * this.pageTitle.set('New Page Title'); - * } - * } - * ``` - */ +/** @deprecated Use `pageTitle()` instead. Will be removed in 1.0. */ export function title(options?: TitleOptions): WritableSignal { - const { runInContext } = setupContext(options?.injector, title); - - return runInContext(() => { - const route = inject(ActivatedRoute); - const html = inject(Title); - - const source = linkedSignal( - toSignal(route.title.pipe(filter(Boolean)), { - initialValue: route.snapshot.title || html.getTitle(), - }), - { ...options } - ); - - return proxySignal( - source, - { - set: value => { - html.setTitle(value); - source.set(value); - }, - }, - { equal: options?.equal } - ); - }); + return pageTitle(options); } diff --git a/projects/docs/.vitepress/config.ts b/projects/docs/.vitepress/config.ts index 2676c38..5fd3c1f 100644 --- a/projects/docs/.vitepress/config.ts +++ b/projects/docs/.vitepress/config.ts @@ -262,7 +262,7 @@ export default defineConfig({ { text: 'QueryParams', link: '/router/query-params' }, { text: 'RouteData', link: '/router/route-data' }, { text: 'RouterListener', link: '/router/router-listener' }, - { text: 'Title', link: '/router/title' }, + { text: 'PageTitle', link: '/router/page-title' }, { text: 'Url', link: '/router/url' }, ], }, diff --git a/projects/docs/browser/web-share.md b/projects/docs/browser/web-share.md index 48b9134..16317cb 100644 --- a/projects/docs/browser/web-share.md +++ b/projects/docs/browser/web-share.md @@ -16,7 +16,7 @@ This feature is available only in [secure contexts](https://developer.mozilla.or ```angular-ts import { Component } from '@angular/core'; -import { webShare, title, url } from '@signality/core'; +import { webShare, pageTitle, url } from '@signality/core'; @Component({ template: ` @@ -27,7 +27,7 @@ import { webShare, title, url } from '@signality/core'; }) export class WebShareDemo { readonly webShare = webShare(); // [!code highlight] - readonly title = title(); + readonly title = pageTitle(); readonly url = url({ absolute: true }); async shareContent() { @@ -118,7 +118,7 @@ export class ImageShare { ```angular-ts import { Component, computed } from '@angular/core'; -import { webShare, title, url } from '@signality/core'; +import { webShare, pageTitle, url } from '@signality/core'; @Component({ selector: 'social-share', @@ -136,7 +136,7 @@ import { webShare, title, url } from '@signality/core'; }) export class SocialShare { readonly webShare = webShare(); - readonly title = title(); + readonly title = pageTitle(); readonly url = url({ absolute: true }); async nativeShare() { diff --git a/projects/docs/router/page-title.md b/projects/docs/router/page-title.md new file mode 100644 index 0000000..c278081 --- /dev/null +++ b/projects/docs/router/page-title.md @@ -0,0 +1,128 @@ +--- +source: https://github.com/signalityjs/signality/blob/main/projects/core/router/page-title/index.ts +--- + +# PageTitle + +Reactive wrapper around Angular Router's [route title](https://angular.dev/api/router/ActivatedRoute#title). Access the resolved route title as a writable signal that can be set to update the page title. + +## Usage + +```angular-ts +import { Component } from '@angular/core'; +import { pageTitle } from '@signality/core'; + +@Component({ + template: ` +

{{ title() ?? 'Default Title' }}

+ `, +}) +export class ProductPage { + readonly title = pageTitle(); // [!code highlight] +} +``` + +## Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `options` | `PageTitleOptions` | Optional configuration (see [Options](#options) below) | + +## Options + +The `PageTitleOptions` extends [`CreateSignalOptions`](https://angular.dev/api/core/CreateSignalOptions) and `WithInjector`: + +| Option | Type | Default | Description | +|------------|-----------|---------|------------------------------------------------| +| `equal` | [`ValueEqualityFn`](https://angular.dev/api/core/ValueEqualityFn) | - | Custom equality function ([see more](https://angular.dev/guide/signals#signal-equality-functions)) | +| `debugName` | `string` | - | Debug name for the signal (development only) | +| `injector` | [`Injector`](https://angular.dev/api/core/Injector) | - | Optional injector for DI context | + +## Return Value + +Returns a `WritableSignal` containing the current route title. The signal can be updated using `set()` or `update()` methods, which will also update the page title via Angular's [Title service](https://angular.dev/api/platform-browser/Title). + +## Examples + +### Display route title + +```angular-ts +import { Component } from '@angular/core'; +import { pageTitle } from '@signality/core'; + +@Component({ + template: ` +
+

{{ title() ?? 'My App' }}

+
+ + `, +}) +export class App { + readonly title = pageTitle(); // [!code highlight] +} +``` + +### Title from [resolver](https://angular.dev/guide/routing/define-routes#page-titles) + +```angular-ts +import { Component } from '@angular/core'; +import { pageTitle } from '@signality/core'; + +// Route configuration: +// { +// path: 'product/:id', +// component: ProductPage, +// title: route => `Product ${route.params['id']}` +// } + +@Component({ + template: ` +

{{ title() }}

+ `, +}) +export class ProductPage { + readonly title = pageTitle(); // Will be "Product 123" for /product/123 +} +``` + +### Updating title + +```angular-ts +import { Component, effect, inject } from '@angular/core'; +import { pageTitle } from '@signality/core'; +import { MessagesStore } from './messages'; + +@Component({ /* ... */ }) +export class MessagesPage { + readonly title = pageTitle(); + readonly messages = inject(MessagesStore); + + constructor() { + effect(() => { + const count = this.messages.unreadCount(); + this.title.set(count > 0 ? `(${count}) Messages` : 'Messages'); // [!code highlight] + }); + } +} +``` + +## SSR Compatibility + +On the server, the signal initializes with the title from the [snapshot](https://angular.dev/guide/routing/read-route-state#understanding-route-snapshots). + +## Type Definitions + +```typescript +type PageTitleOptions = CreateSignalOptions & WithInjector; + +function pageTitle(options?: PageTitleOptions): WritableSignal; +``` + +## Related + +- [params](/router/params) — Access route parameters +- [queryParams](/router/query-params) — Access query parameters +- [fragment](/router/fragment) — Access URL fragment +- [url](/router/url) — Access current URL +- [routeData](/router/route-data) — Access route data diff --git a/projects/docs/router/title.md b/projects/docs/router/title.md index 4a3925b..088e48b 100644 --- a/projects/docs/router/title.md +++ b/projects/docs/router/title.md @@ -4,6 +4,10 @@ source: https://github.com/signalityjs/signality/blob/main/projects/core/router/ # Title +::: warning Deprecated +`title()` is deprecated and will be removed in 1.0. Use [`pageTitle()`](/router/page-title) instead. +::: + Reactive wrapper around Angular Router's [route title](https://angular.dev/api/router/ActivatedRoute#title). Access the resolved route title as a writable signal that can be set to update the page title. ## Usage