Skip to content
Merged
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
1 change: 1 addition & 0 deletions projects/core/router/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
89 changes: 89 additions & 0 deletions projects/core/router/page-title/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>;
let mockTitle: { getTitle: jest.Mock; setTitle: jest.Mock };

beforeEach(() => {
titleState = new BehaviorSubject<string | undefined>(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');
});
});
62 changes: 62 additions & 0 deletions projects/core/router/page-title/index.ts
Original file line number Diff line number Diff line change
@@ -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<string> & 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: `
* <div>
* <h1>{{ title() }}</h1>
* <button (click)="updateTitle()">Update Title</button>
* </div>
* `
* })
* export class Page {
* readonly title = pageTitle();
*
* updateTitle() {
* this.title.set('New Page Title');
* }
* }
* ```
*/
export function pageTitle(options?: PageTitleOptions): WritableSignal<string> {
const { runInContext } = setupContext(options?.injector, pageTitle);

return runInContext(() => {
const route = inject(ActivatedRoute);
const html = inject(Title);

const source = linkedSignal(
toSignal<string, string>(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 }
);
});
}
6 changes: 6 additions & 0 deletions projects/core/router/page-title/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"lib": {
"entryFile": "index.ts"
}
}

64 changes: 6 additions & 58 deletions projects/core/router/title/index.ts
Original file line number Diff line number Diff line change
@@ -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<string> & 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: `
* <div>
* <h1>{{ pageTitle() }}</h1>
* <button (click)="updateTitle()">Update Title</button>
* </div>
* `
* })
* 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<string> {
const { runInContext } = setupContext(options?.injector, title);

return runInContext(() => {
const route = inject(ActivatedRoute);
const html = inject(Title);

const source = linkedSignal(
toSignal<string, string>(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);
}
2 changes: 1 addition & 1 deletion projects/docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
Expand Down
8 changes: 4 additions & 4 deletions projects/docs/browser/web-share.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
Expand All @@ -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() {
Expand Down Expand Up @@ -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',
Expand All @@ -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() {
Expand Down
Loading
Loading