Skip to content

Commit 1ffcff1

Browse files
committed
fixup! docs(docs-infra): implement search history
1 parent c1703f5 commit 1ffcff1

File tree

7 files changed

+126
-46
lines changed

7 files changed

+126
-46
lines changed

adev/shared-docs/components/search-history/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ ng_project(
2727
"//adev/shared-docs/components/search-dialog:__pkg__",
2828
],
2929
deps = [
30+
"//:node_modules/@angular/cdk",
31+
"//adev/shared-docs/directives:directives_rjs",
3032
"//adev/shared-docs/pipes:pipes_rjs",
3133
"//adev/shared-docs/services:services_rjs",
3234
],

adev/shared-docs/components/search-history/search-history.component.html

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
@if (items.recent.length) {
44
<p class="title">Recent</p>
55
<ul class="history-results recent">
6-
@for (item of items.recent; track item.id) {
7-
<li>
6+
@for (item of items.recent; track item.id; let idx = $index) {
7+
<li docsSearchItem [item]="item" (mouseenter)="onMouseEnter(idx)">
88
<a
99
[routerLink]="'/' + item.url | relativeLink: 'pathname'"
1010
[fragment]="item.url | relativeLink: 'hash'"
@@ -35,11 +35,11 @@
3535
</ul>
3636
}
3737

38-
@if (items.favorites.length) {
39-
<p class="title">Favorites</p>
40-
<ul class="history-results favorites">
41-
@for (item of items.favorites; track item.id) {
42-
<li>
38+
@if (items.favorite.length) {
39+
<p class="title">Favorite</p>
40+
<ul class="history-results favorite">
41+
@for (item of items.favorite; track item.id; let idx = $index) {
42+
<li docsSearchItem [item]="item" (mouseenter)="onMouseEnter(items.recent.length + idx)">
4343
<a
4444
[routerLink]="'/' + item.url | relativeLink: 'pathname'"
4545
[fragment]="item.url | relativeLink: 'hash'"

adev/shared-docs/components/search-history/search-history.component.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
gap: 0.5rem;
3131
padding-inline-end: 1rem;
3232

33-
&:hover {
33+
&.active {
3434
background-color: var(--octonary-contrast);
3535
border-inline-start: 2px solid var(--primary-contrast);
3636
}

adev/shared-docs/components/search-history/search-history.component.spec.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {provideZonelessChangeDetection} from '@angular/core';
10-
import {ComponentFixture, TestBed} from '@angular/core/testing';
10+
import {ComponentFixture, fakeAsync, flushMicrotasks, TestBed} from '@angular/core/testing';
1111
import {By} from '@angular/platform-browser';
1212
import {provideRouter} from '@angular/router';
1313

@@ -18,7 +18,7 @@ import {MockLocalStorage} from '../../testing';
1818

1919
// Keep in sync with the template & styles
2020
const RECENT_CONT_SELECTOR = '.recent';
21-
const FAV_CONT_SELECTOR = '.favorites';
21+
const FAV_CONT_SELECTOR = '.favorite';
2222
const LABEL_SELECTOR = 'a > span';
2323
const REMOVE_BTN_SELECTOR = 'button.remove-btn';
2424
const FAV_BTN_SELECTOR = 'button.fav-btn';
@@ -28,10 +28,10 @@ const FAV_ITEMS = `${FAV_CONT_SELECTOR} ${LABEL_SELECTOR}`;
2828

2929
const ITEMS: HistoryItem[] = [
3030
{
31-
id: 'a',
32-
labelHtml: 'Item A',
31+
id: 'c',
32+
labelHtml: 'Item C',
3333
url: 'https://angular.dev',
34-
isFavorite: false,
34+
isFavorite: true,
3535
createdAt: 0,
3636
},
3737
{
@@ -42,10 +42,10 @@ const ITEMS: HistoryItem[] = [
4242
createdAt: 0,
4343
},
4444
{
45-
id: 'c',
46-
labelHtml: 'Item C',
45+
id: 'a',
46+
labelHtml: 'Item A',
4747
url: 'https://angular.dev',
48-
isFavorite: true,
48+
isFavorite: false,
4949
createdAt: 0,
5050
},
5151
];
@@ -57,20 +57,25 @@ function loadItems(history: SearchHistory) {
5757
// tick forward the clock to ensure proper/expected order.
5858
jasmine.clock().tick(100);
5959
history.addItem(item);
60+
// We need to tick to flush the microtask queue which
61+
// initiates synchornization. This is needed since we are
62+
// updating the internal signal multiple times.
63+
flushMicrotasks();
6064
}
6165

62-
const favorites = ITEMS.filter((i) => i.isFavorite);
63-
for (const item of favorites) {
66+
const favorite = ITEMS.filter((i) => i.isFavorite);
67+
for (const item of favorite) {
6468
jasmine.clock().tick(100);
6569
history.makeFavorite(item);
70+
flushMicrotasks();
6671
}
6772
}
6873

6974
describe('SearchHistoryComponent', () => {
7075
let fixture: ComponentFixture<SearchHistoryComponent>;
7176
let history: SearchHistory;
7277

73-
beforeEach(async () => {
78+
beforeEach(fakeAsync(async () => {
7479
jasmine.clock().uninstall();
7580
jasmine.clock().install();
7681

@@ -89,18 +94,18 @@ describe('SearchHistoryComponent', () => {
8994
loadItems(history);
9095

9196
fixture.detectChanges();
92-
});
97+
}));
9398

9499
afterEach(() => {
95100
jasmine.clock().uninstall();
96101
});
97102

98103
it('should render all items', () => {
99104
const recent = fixture.debugElement.queryAll(By.css(RECENT_ITEMS));
100-
const favorites = fixture.debugElement.queryAll(By.css(FAV_ITEMS));
105+
const favorite = fixture.debugElement.queryAll(By.css(FAV_ITEMS));
101106

102107
expect(recent.map((el) => el.nativeElement.innerText)).toEqual(['Item A', 'Item B']);
103-
expect(favorites.map((el) => el.nativeElement.innerText)).toEqual(['Item C']);
108+
expect(favorite.map((el) => el.nativeElement.innerText)).toEqual(['Item C']);
104109
});
105110

106111
it('should remove an item', () => {
@@ -126,7 +131,7 @@ describe('SearchHistoryComponent', () => {
126131
const recent = fixture.debugElement.queryAll(By.css(RECENT_ITEMS));
127132
expect(recent.map((el) => el.nativeElement.innerText)).toEqual(['Item B']);
128133

129-
const favorites = fixture.debugElement.queryAll(By.css(FAV_ITEMS));
130-
expect(favorites.map((el) => el.nativeElement.innerText)).toEqual(['Item A', 'Item C']);
134+
const favorite = fixture.debugElement.queryAll(By.css(FAV_ITEMS));
135+
expect(favorite.map((el) => el.nativeElement.innerText)).toEqual(['Item C', 'Item A']);
131136
});
132137
});

adev/shared-docs/components/search-history/search-history.component.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,83 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Component, inject} from '@angular/core';
10-
import {RouterLink} from '@angular/router';
9+
import {
10+
afterNextRender,
11+
Component,
12+
DestroyRef,
13+
effect,
14+
inject,
15+
Injector,
16+
viewChildren,
17+
} from '@angular/core';
18+
import {Router, RouterLink} from '@angular/router';
19+
import {toSignal} from '@angular/core/rxjs-interop';
20+
import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
21+
1122
import {SearchHistory} from '../../services';
1223
import {RelativeLink} from '../../pipes';
24+
import {SearchItem} from '../../directives/search-item/search-item.directive';
1325

1426
@Component({
1527
selector: 'docs-search-history',
16-
imports: [RelativeLink, RouterLink],
28+
imports: [RelativeLink, RouterLink, SearchItem],
1729
templateUrl: './search-history.component.html',
1830
styleUrl: './search-history.component.scss',
31+
host: {
32+
'(document:keydown)': 'onKeydown($event)',
33+
},
1934
})
2035
export class SearchHistoryComponent {
36+
protected readonly items = viewChildren(SearchItem);
37+
2138
readonly history = inject(SearchHistory);
39+
private readonly injector = inject(Injector);
40+
private readonly router = inject(Router);
41+
42+
private readonly relativeLink = new RelativeLink();
43+
private readonly keyManager = new ActiveDescendantKeyManager(
44+
this.items,
45+
this.injector,
46+
).withWrap();
47+
48+
constructor() {
49+
inject(DestroyRef).onDestroy(() => this.keyManager.destroy());
50+
51+
afterNextRender({
52+
write: () => {
53+
if (this.items().length) {
54+
this.keyManager.setFirstItemActive();
55+
}
56+
},
57+
});
58+
59+
const keyManagerActive = toSignal(this.keyManager.change);
60+
61+
effect(() => {
62+
if (keyManagerActive()) {
63+
this.keyManager.activeItem?.scrollIntoView();
64+
}
65+
});
66+
}
67+
68+
onKeydown(e: KeyboardEvent) {
69+
if (e.key === 'Enter') {
70+
this.navigateToTheActiveItem();
71+
} else {
72+
this.keyManager.onKeydown(e);
73+
}
74+
}
75+
76+
onMouseEnter(idx: number) {
77+
this.keyManager.setActiveItem(idx);
78+
}
79+
80+
private navigateToTheActiveItem() {
81+
const activeItemLink = this.keyManager.activeItem?.item()?.url;
82+
83+
if (activeItemLink) {
84+
const url = this.relativeLink.transform(activeItemLink);
85+
this.router.navigateByUrl(url);
86+
}
87+
}
2288
}

adev/shared-docs/services/search-history.service.spec.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {TestBed} from '@angular/core/testing';
9+
import {fakeAsync, flushMicrotasks, TestBed} from '@angular/core/testing';
1010
import {
1111
HistoryItem,
1212
MAX_RECENT_HISTORY_SIZE,
@@ -18,16 +18,16 @@ import {MockLocalStorage} from '../testing';
1818

1919
const ITEMS: HistoryItem[] = [
2020
{
21-
id: 'a',
22-
labelHtml: 'Item 1',
21+
id: 'c',
22+
labelHtml: 'Item C',
2323
},
2424
{
2525
id: 'b',
26-
labelHtml: 'Item 2',
26+
labelHtml: 'Item B',
2727
},
2828
{
29-
id: 'c',
30-
labelHtml: 'Item 3',
29+
id: 'a',
30+
labelHtml: 'Item A',
3131
},
3232
].map((i) => ({...i, url: '', createdAt: 0, isFavorite: false}));
3333

@@ -42,6 +42,10 @@ describe('SearchHistory', () => {
4242
// tick forward the clock to ensure proper/expected order.
4343
jasmine.clock().tick(100);
4444
service.addItem(item);
45+
// We need to flush the microtask queue which
46+
// initiates synchornization. This is needed since we are
47+
// updating the internal signal multiple times.
48+
flushMicrotasks();
4549
}
4650
}
4751

@@ -84,6 +88,8 @@ describe('SearchHistory', () => {
8488
// Instance check
8589
expect(service.items().recent.map((i) => i.id)).toEqual(['a']);
8690

91+
expect(service.hasItems()).toBeTruthy();
92+
8793
// Storage check
8894
const dataString = storage.getItem(SEARCH_HISTORY_LS_KEY) as string | null;
8995
const data = JSON.parse(dataString ?? '') ?? [];
@@ -96,34 +102,34 @@ describe('SearchHistory', () => {
96102
expect(data.pop()).toEqual(jasmine.objectContaining(itemCopy));
97103
});
98104

99-
it('should load history items', () => {
105+
it('should load history items', fakeAsync(() => {
100106
loadItems();
101107

102108
expect(service.items().recent.map((i) => i.id)).toEqual(['a', 'b', 'c']);
103-
});
109+
}));
104110

105-
it('should delete a history item', () => {
111+
it('should delete a history item', fakeAsync(() => {
106112
loadItems();
107113

108114
const bItem = ITEMS.find((i) => i.id === 'b')!;
109115

110116
service.removeItem(bItem);
111117

112118
expect(service.items().recent.map((i) => i.id)).toEqual(['a', 'c']);
113-
});
119+
}));
114120

115-
it('should make item favorite', () => {
121+
it('should make item favorite', fakeAsync(() => {
116122
loadItems();
117123

118124
const aItem = ITEMS.find((i) => i.id === 'a')!;
119125

120126
service.makeFavorite(aItem);
121127

122128
expect(service.items().recent.map((i) => i.id)).toEqual(['b', 'c']);
123-
expect(service.items().favorites.map((i) => i.id)).toEqual(['a']);
124-
});
129+
expect(service.items().favorite.map((i) => i.id)).toEqual(['a']);
130+
}));
125131

126-
it('should set a limit to history size', () => {
132+
it('should set a limit to history size', fakeAsync(() => {
127133
const extra = 10;
128134
const ids = [];
129135

@@ -139,10 +145,11 @@ describe('SearchHistory', () => {
139145
url: '',
140146
createdAt: 0,
141147
});
148+
flushMicrotasks();
142149
}
143150

144151
ids.splice(0, extra);
145152

146-
expect(service.items().recent.map((i) => i.id)).toEqual(ids);
147-
});
153+
expect(service.items().recent.map((i) => i.id)).toEqual(ids.reverse());
154+
}));
148155
});

adev/shared-docs/services/search-history.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ export class SearchHistory {
3333
Array.from(this.history().values()).sort((a, b) => b.createdAt - a.createdAt),
3434
);
3535

36-
items = computed<{recent: HistoryItem[]; favorites: HistoryItem[]}>(() => ({
36+
items = computed<{recent: HistoryItem[]; favorite: HistoryItem[]}>(() => ({
3737
recent: this.allItems().filter((v) => !v.isFavorite),
38-
favorites: this.allItems().filter((v) => v.isFavorite),
38+
favorite: this.allItems().filter((v) => v.isFavorite),
3939
}));
4040

4141
hasItems = computed(() => this.allItems().length > 0);
@@ -58,7 +58,7 @@ export class SearchHistory {
5858

5959
// `items` still hasn't been updated so we should use `>=`.
6060
if (this.items().recent.length >= MAX_RECENT_HISTORY_SIZE) {
61-
const [{id}] = this.items().recent;
61+
const {id} = this.items().recent.at(-1)!;
6262
map.delete(id);
6363
}
6464
});

0 commit comments

Comments
 (0)