Skip to content

Commit 953a9ce

Browse files
danymarquesdevversion
authored andcommitted
feat(report-list): add collapsible prompt badge list component
Extract prompt name badges into a dedicated `PromptBadgeList` component that automatically truncates overflow badges to a single row and shows a "+N more" toggle. Uses ResizeObserver and afterNextRender to measure badge layout after each render cycle and reacts to container width changes. - Add `PromptBadgeList` component with overflow detection via DOM measurement - Hide the container during measurement to avoid layout flicker - Show "Show less" / "+N more" toggle badge when names overflow one row - Move prompt-names styles from report-list.scss into the component - Replace inline badge list in report-list.html with the new component
1 parent 16e4160 commit 953a9ce

File tree

5 files changed

+165
-14
lines changed

5 files changed

+165
-14
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<ul
2+
#container
3+
class="status-badge-group prompt-names"
4+
[style.visibility]="measuring() ? 'hidden' : 'visible'"
5+
>
6+
@for (name of visibleNames(); track name) {
7+
<li class="status-badge neutral" data-badge>{{ name }}</li>
8+
}
9+
@if (showToggle()) {
10+
<li
11+
class="status-badge neutral toggle-badge"
12+
(click)="toggle($event)"
13+
>
14+
@if (expanded()) {
15+
Show less
16+
} @else {
17+
+{{ hiddenCount() }} more
18+
}
19+
</li>
20+
}
21+
</ul>
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
Component,
3+
ElementRef,
4+
Injector,
5+
PLATFORM_ID,
6+
afterNextRender,
7+
computed,
8+
effect,
9+
inject,
10+
input,
11+
signal,
12+
viewChild,
13+
} from '@angular/core';
14+
import {isPlatformServer} from '@angular/common';
15+
16+
@Component({
17+
selector: 'prompt-badge-list',
18+
templateUrl: './prompt-badge-list.html',
19+
styles: `
20+
.prompt-names {
21+
margin-top: 0.4rem;
22+
display: flex;
23+
width: 100%;
24+
}
25+
26+
.status-badge {
27+
font-size: 0.75rem;
28+
font-weight: 400;
29+
}
30+
31+
.toggle-badge {
32+
cursor: pointer;
33+
font-weight: 500;
34+
35+
&:hover {
36+
opacity: 0.8;
37+
}
38+
}
39+
`,
40+
})
41+
export class PromptBadgeList {
42+
readonly promptNames = input.required<string[]>();
43+
44+
private readonly isServer = isPlatformServer(inject(PLATFORM_ID));
45+
private readonly injector = inject(Injector);
46+
private lastContainerWidth = 0;
47+
private pendingMeasure = false;
48+
private containerRef = viewChild.required<ElementRef<HTMLElement>>('container');
49+
50+
protected measuring = signal(true);
51+
protected expanded = signal(false);
52+
protected visibleCount = signal<number>(Infinity);
53+
54+
protected visibleNames = computed(() => {
55+
const names = this.promptNames();
56+
if (this.expanded() || this.measuring()) {
57+
return names;
58+
}
59+
const count = this.visibleCount();
60+
return isFinite(count) ? names.slice(0, count) : names;
61+
});
62+
63+
protected hiddenCount = computed(() => {
64+
if (this.expanded() || this.measuring() || !isFinite(this.visibleCount())) {
65+
return 0;
66+
}
67+
return Math.max(0, this.promptNames().length - this.visibleCount());
68+
});
69+
70+
protected showToggle = computed(() => {
71+
if (this.measuring()) {
72+
return false;
73+
}
74+
if (this.expanded()) {
75+
return isFinite(this.visibleCount());
76+
}
77+
return this.hiddenCount() > 0;
78+
});
79+
80+
constructor() {
81+
effect(onCleanup => {
82+
if (this.isServer) {
83+
this.measuring.set(false);
84+
return;
85+
}
86+
const el = this.containerRef().nativeElement;
87+
const observer = new ResizeObserver(entries => {
88+
if (this.pendingMeasure) {
89+
return;
90+
}
91+
const newWidth = Math.round(entries[0].contentRect.width);
92+
if (newWidth !== this.lastContainerWidth) {
93+
this.lastContainerWidth = newWidth;
94+
this.scheduleMeasure();
95+
}
96+
});
97+
observer.observe(el);
98+
this.scheduleMeasure();
99+
100+
onCleanup(() => observer.disconnect());
101+
});
102+
}
103+
104+
protected toggle(event: Event): void {
105+
event.preventDefault();
106+
event.stopPropagation();
107+
this.expanded.update(v => !v);
108+
if (!this.expanded()) {
109+
this.scheduleMeasure();
110+
}
111+
}
112+
113+
private scheduleMeasure(): void {
114+
if (this.pendingMeasure) {
115+
return;
116+
}
117+
this.pendingMeasure = true;
118+
this.measuring.set(true);
119+
afterNextRender({read: () => this.doMeasure()}, {injector: this.injector});
120+
}
121+
122+
private doMeasure(): void {
123+
this.pendingMeasure = false;
124+
125+
const container = this.containerRef().nativeElement;
126+
const badges = Array.from(container.querySelectorAll('[data-badge]')) as HTMLElement[];
127+
128+
if (badges.length === 0) {
129+
this.measuring.set(false);
130+
return;
131+
}
132+
133+
const firstTop = badges[0].offsetTop;
134+
const overflowIdx = badges.findIndex(b => b.offsetTop > firstTop);
135+
136+
// If overflow is detected, reserve one slot for the toggle badge
137+
this.visibleCount.set(overflowIdx === -1 ? Infinity : Math.max(1, overflowIdx - 1));
138+
this.measuring.set(false);
139+
}
140+
}

report-app/src/app/pages/report-list/report-list.html

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,7 @@
3838
}
3939
</div>
4040
@if (group.promptNames.length) {
41-
<ul class="status-badge-group prompt-names">
42-
@for (name of group.promptNames; track name) {
43-
<li class="status-badge neutral">{{ name }}</li>
44-
}
45-
</ul>
41+
<prompt-badge-list [promptNames]="group.promptNames" />
4642
}
4743
</div>
4844
<div class="run-meta-container">

report-app/src/app/pages/report-list/report-list.scss

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,6 @@ h1, h2 {
106106
padding: 0 20px;
107107
}
108108

109-
.prompt-names {
110-
margin-top: 0.4rem;
111-
112-
.status-badge {
113-
font-size: 0.75rem;
114-
font-weight: 400;
115-
}
116-
}
117109

118110
.select-for-comparison input[type='checkbox'] {
119111
width: 20px;

report-app/src/app/pages/report-list/report-list.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Component, computed, inject, PLATFORM_ID, signal} from '@angular/core';
1+
import {Component, inject, PLATFORM_ID, signal} from '@angular/core';
22
import {Router, RouterLink} from '@angular/router';
33
import {ReportsFetcher} from '../../services/reports-fetcher';
44
import {DatePipe, isPlatformServer} from '@angular/common';
@@ -12,6 +12,7 @@ import {Score} from '../../shared/score/score';
1212
import {ProviderLabel} from '../../shared/provider-label';
1313
import {bucketToScoreVariable} from '../../shared/scoring';
1414
import {ReportFilters} from '../../shared/report-filters/report-filters';
15+
import {PromptBadgeList} from './prompt-badge/prompt-badge-list';
1516

1617
@Component({
1718
selector: 'app-report-list',
@@ -23,6 +24,7 @@ import {ReportFilters} from '../../shared/report-filters/report-filters';
2324
Score,
2425
ProviderLabel,
2526
ReportFilters,
27+
PromptBadgeList,
2628
],
2729
templateUrl: './report-list.html',
2830
styleUrls: ['./report-list.scss'],

0 commit comments

Comments
 (0)