Skip to content

Commit 6b1e1f0

Browse files
committed
refactor(ui): shared browse layer with virtual grid, filter rail and bulk actions
1 parent c09bc02 commit 6b1e1f0

58 files changed

Lines changed: 2985 additions & 142 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/src/app/core/data/browse-response.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,11 @@ function mapBrowseLink(raw: RawLink): BrowseLink {
5959
}
6060

6161
function mapBrowseFacetValue(raw: RawFacetLink): BrowseFacetValue {
62-
const count = raw.properties?.numberOfItems;
6362
return {
6463
value: raw.value,
6564
title: raw.title,
65+
count: raw.properties?.numberOfItems ?? 0,
6666
selected: normalizeRel(raw.rel).includes('self'),
67-
...(count === undefined ? {} : {count}),
6867
};
6968
}
7069

frontend/src/app/core/data/browse.models.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface BrowsePage<T> {
2121
export interface BrowseFacetValue {
2222
value: string;
2323
title: string;
24-
count?: number;
24+
count: number;
2525
selected: boolean;
2626
}
2727

@@ -46,3 +46,17 @@ export function findBrowsePageLink(
4646
): BrowseLink | undefined {
4747
return page.links.find(link => link.rel.includes(rel));
4848
}
49+
50+
export function flattenBrowsePages<T extends {id: number}>(
51+
data: {pages: BrowsePage<T>[]} | undefined,
52+
): T[] {
53+
const items = data?.pages.flatMap(page => page.content) ?? [];
54+
const seen = new Set<number>();
55+
return items.filter(item => {
56+
if (seen.has(item.id)) {
57+
return false;
58+
}
59+
seen.add(item.id);
60+
return true;
61+
});
62+
}

frontend/src/app/features/book/components/book-browser/book-browser.component.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,6 @@ export class BookBrowserComponent implements AfterViewInit {
301301
minimumCount: metrics => this.minimumLoadingGridItemCount(metrics),
302302
initialOffset: this.initialScrollOffset,
303303
fillItemWidth: true,
304-
deferViewportUpdates: this.layoutService.sidebarTransitioning,
305304
estimateItemHeight: itemWidth => this.isMobile()
306305
? this.mobileCardSizeForWidth(itemWidth).height
307306
: this.cardSizeForWidth(itemWidth).height,

frontend/src/app/features/book/data/book-query.models.spec.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.
Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,5 @@
1-
import {InfiniteData} from '@tanstack/angular-query-experimental';
2-
31
import {BrowseFacetGroup, BrowsePage} from '../../../core/data/browse.models';
42
import {BookSummary} from './book-response.models';
53

64
export type BookPage = BrowsePage<BookSummary>;
75
export type BookFacetGroup = BrowseFacetGroup;
8-
9-
export function flattenBookPages(
10-
data: InfiniteData<BookPage> | undefined,
11-
): BookSummary[] {
12-
const books = data?.pages.flatMap(page => page.content) ?? [];
13-
const seen = new Set<number>();
14-
return books.filter(book => {
15-
if (seen.has(book.id)) {
16-
return false;
17-
}
18-
seen.add(book.id);
19-
return true;
20-
});
21-
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import {
2+
Component,
3+
Directive,
4+
ElementRef,
5+
afterRenderEffect,
6+
computed,
7+
contentChildren,
8+
inject,
9+
input,
10+
output,
11+
signal,
12+
viewChild,
13+
} from '@angular/core';
14+
import {TranslocoPipe} from '@jsverse/transloco';
15+
import {LucideEllipsis, LucideX} from '@lucide/angular';
16+
17+
import {AppButtonComponent} from '../../ui/button/app-button.component';
18+
import {AppMenuComponent} from '../../ui/menu/app-menu.component';
19+
import {AppMenuTriggerDirective} from '../../ui/menu/app-menu-trigger.directive';
20+
import {LayoutService} from '../../layout/layout.service';
21+
22+
const PILL_CHROME_WIDTH = 46;
23+
const ITEM_GAP = 4;
24+
const MORE_BUTTON_WIDTH = 40 + ITEM_GAP;
25+
26+
@Component({
27+
selector: 'app-browse-bulk-actions-divider',
28+
template: `@if (!mobileShell()) {
29+
<span class="mx-1.5 block h-6 w-px bg-border" aria-hidden="true"></span>
30+
}`,
31+
host: {class: 'contents'},
32+
})
33+
export class BrowseBulkActionsDividerComponent {
34+
private readonly layout = inject(LayoutService);
35+
protected readonly mobileShell = computed(() => !this.layout.isDesktop());
36+
}
37+
38+
@Directive({
39+
selector: '[appBrowseBulkActionsItem]',
40+
host: {
41+
'[class.invisible]': 'overflowed()',
42+
'[class.absolute]': 'overflowed()',
43+
'[class.left-0]': 'overflowed()',
44+
'[class.top-0]': 'overflowed()',
45+
'[attr.inert]': "overflowed() ? '' : null",
46+
},
47+
})
48+
export class BrowseBulkActionsItemDirective {
49+
readonly id = input.required<string>({alias: 'appBrowseBulkActionsItem'});
50+
readonly element = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
51+
52+
private readonly bar = inject(BrowseBulkActionsBarComponent);
53+
protected readonly overflowed = computed(() => this.bar.overflowed().has(this.id()));
54+
}
55+
56+
@Component({
57+
selector: 'app-browse-bulk-actions-bar',
58+
imports: [TranslocoPipe, AppButtonComponent, AppMenuTriggerDirective, BrowseBulkActionsDividerComponent, LucideEllipsis, LucideX],
59+
host: {class: 'contents'},
60+
template: `
61+
<div
62+
#strip
63+
class="pointer-events-none fixed inset-x-0 bottom-[max(1.25rem,env(safe-area-inset-bottom))] z-30 flex justify-center pl-[calc(var(--sidebar-width,0px)*(1-var(--mobile-shell-active,0)))]"
64+
>
65+
<div
66+
class="pointer-events-auto relative flex h-12 max-w-[calc(100%-2rem)] items-center gap-1 overflow-hidden whitespace-nowrap rounded-xl border border-border bg-card px-1.5 text-sm shadow-float animate-in fade-in-0 slide-in-from-bottom-1 motion-reduce:animate-none"
67+
>
68+
@if (!mobileShell()) {
69+
<span #leading class="flex items-center gap-1">
70+
<app-button
71+
variant="ghost"
72+
size="md"
73+
iconOnly
74+
[ariaLabel]="'shared.ui.select.clearSelection' | transloco"
75+
(clicked)="clearSelection.emit()"
76+
>
77+
<svg lucideX aria-hidden="true"></svg>
78+
</app-button>
79+
<span role="status" class="px-1 font-semibold tabular-nums text-text">
80+
{{ 'shared.ui.select.selectedCount' | transloco: {count: countLabel()} }}
81+
</span>
82+
@if (showSelectAll()) {
83+
<app-button
84+
variant="ghost"
85+
tone="primary"
86+
size="md"
87+
[label]="'shared.ui.bulkActions.selectAll' | transloco"
88+
(clicked)="selectAll.emit()"
89+
/>
90+
}
91+
<app-browse-bulk-actions-divider />
92+
</span>
93+
}
94+
<ng-content />
95+
@if (moreMenu(); as menu) {
96+
@if (moreShown()) {
97+
<app-button
98+
variant="ghost"
99+
size="md"
100+
iconOnly
101+
[disabled]="moreDisabled()"
102+
[ariaLabel]="'browse.moreActions' | transloco"
103+
[appMenuTriggerFor]="menu"
104+
>
105+
<svg lucideEllipsis aria-hidden="true"></svg>
106+
</app-button>
107+
}
108+
}
109+
<ng-content select="[appBrowseBulkActionsTrailing]" />
110+
</div>
111+
</div>
112+
`,
113+
})
114+
export class BrowseBulkActionsBarComponent {
115+
readonly count = input.required<number>();
116+
readonly total = input<number | null>(null);
117+
readonly moreMenu = input<AppMenuComponent | null>(null);
118+
readonly moreAlways = input(false);
119+
readonly moreDisabled = input(false);
120+
121+
readonly clearSelection = output<void>();
122+
readonly selectAll = output<void>();
123+
124+
private readonly overflowedIds = signal<ReadonlySet<string>>(new Set(), {equal: sameIds});
125+
readonly overflowed = this.overflowedIds.asReadonly();
126+
127+
private readonly layout = inject(LayoutService);
128+
private readonly strip = viewChild.required<ElementRef<HTMLElement>>('strip');
129+
private readonly leading = viewChild<ElementRef<HTMLElement>>('leading');
130+
private readonly items = contentChildren(BrowseBulkActionsItemDirective);
131+
protected readonly mobileShell = computed(() => !this.layout.isDesktop());
132+
protected readonly countLabel = computed(() => this.count().toLocaleString());
133+
protected readonly showSelectAll = computed(() => {
134+
const total = this.total();
135+
return total !== null && this.count() < total;
136+
});
137+
protected readonly moreShown = computed(() => this.moreAlways() || this.overflowed().size > 0);
138+
139+
constructor() {
140+
afterRenderEffect(onCleanup => {
141+
const strip = this.strip().nativeElement;
142+
const leading = this.leading()?.nativeElement;
143+
const items = this.items().map(item => ({id: item.id(), element: item.element}));
144+
const moreAlways = this.moreAlways();
145+
let availableWidth = 0;
146+
const observer = new ResizeObserver(entries => {
147+
const stripEntry = entries.find(entry => entry.target === strip);
148+
if (stripEntry) availableWidth = stripEntry.contentRect.width;
149+
const capacity = availableWidth - PILL_CHROME_WIDTH - (leading ? leading.offsetWidth + ITEM_GAP : 0);
150+
const widths = items.map(item => ({id: item.id, width: item.element.offsetWidth + ITEM_GAP}));
151+
const withoutMoreButton = overflowingIds(widths, capacity);
152+
this.overflowedIds.set(moreAlways || withoutMoreButton.size > 0
153+
? overflowingIds(widths, capacity - MORE_BUTTON_WIDTH)
154+
: withoutMoreButton);
155+
});
156+
observer.observe(strip);
157+
if (leading) observer.observe(leading);
158+
for (const item of items) observer.observe(item.element);
159+
onCleanup(() => observer.disconnect());
160+
});
161+
}
162+
}
163+
164+
function overflowingIds(items: readonly {id: string; width: number}[], capacity: number): ReadonlySet<string> {
165+
const overflowed = new Set<string>();
166+
let used = 0;
167+
for (const item of items) {
168+
if (overflowed.size === 0 && used + item.width <= capacity) {
169+
used += item.width;
170+
} else {
171+
overflowed.add(item.id);
172+
}
173+
}
174+
return overflowed;
175+
}
176+
177+
function sameIds(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
178+
return a.size === b.size && [...a].every(id => b.has(id));
179+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {Component, computed, input, output} from '@angular/core';
2+
import {TranslocoPipe} from '@jsverse/transloco';
3+
4+
import {AppButtonComponent} from '../../ui/button/app-button.component';
5+
6+
@Component({
7+
selector: 'app-browse-select-mode-controls',
8+
imports: [TranslocoPipe, AppButtonComponent],
9+
host: {class: 'contents'},
10+
template: `
11+
<span role="status" class="px-1 text-sm font-semibold tabular-nums text-text">
12+
{{ 'shared.ui.select.selectedCount' | transloco: {count: countLabel()} }}
13+
</span>
14+
@if (showSelectAll()) {
15+
<app-button
16+
class="ml-auto"
17+
variant="soft"
18+
[label]="'shared.ui.bulkActions.selectAll' | transloco"
19+
(clicked)="selectAll.emit()" />
20+
}
21+
<app-button
22+
[class]="showSelectAll() ? '' : 'ml-auto'"
23+
variant="soft"
24+
[label]="'common.cancel' | transloco"
25+
(clicked)="cancelled.emit()" />
26+
`,
27+
})
28+
export class BrowseSelectModeControlsComponent {
29+
readonly count = input.required<number>();
30+
readonly total = input<number | null>(null);
31+
32+
readonly selectAll = output<void>();
33+
readonly cancelled = output<void>();
34+
35+
protected readonly countLabel = computed(() => this.count().toLocaleString());
36+
protected readonly showSelectAll = computed(() => {
37+
const total = this.total();
38+
return total !== null && this.count() < total;
39+
});
40+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export interface BrowseFacetBucket {
2+
readonly min?: number;
3+
readonly max?: number;
4+
readonly stars?: number;
5+
}
6+
7+
export function formatRangeToken(
8+
{min, max}: {readonly min?: number | null; readonly max?: number | null},
9+
): string | null {
10+
if (min == null && max == null) {
11+
return null;
12+
}
13+
return `${min ?? '*'}..${max ?? '*'}`;
14+
}
15+
16+
export function formatRangeLabel(
17+
{min, max}: {readonly min?: number | string | null; readonly max?: number | string | null},
18+
): string | null {
19+
if (min == null && max == null) {
20+
return null;
21+
}
22+
if (max == null) {
23+
return `${min}+`;
24+
}
25+
if (min == null) {
26+
return `-${max}`;
27+
}
28+
return min === max ? `${min}` : `${min}-${max}`;
29+
}
30+
31+
export function parseRangeToken(token: string): {min: number | null; max: number | null} | null {
32+
const match = /^(\*|-?\d+(?:\.\d+)?)\.\.(\*|-?\d+(?:\.\d+)?)$/.exec(token);
33+
if (match) {
34+
const min = match[1] === '*' ? null : Number(match[1]);
35+
const max = match[2] === '*' ? null : Number(match[2]);
36+
return min == null && max == null ? null : {min, max};
37+
}
38+
const exact = Number(token);
39+
return token.trim() !== '' && Number.isFinite(exact) ? {min: exact, max: exact} : null;
40+
}
41+
42+
export function bucketRangeTokens(
43+
buckets: readonly BrowseFacetBucket[] | undefined,
44+
): ReadonlySet<string> {
45+
return new Set((buckets ?? []).flatMap(bucket => {
46+
const token = formatRangeToken(bucket);
47+
return token == null ? [] : [token];
48+
}));
49+
}

0 commit comments

Comments
 (0)