Skip to content

Commit db6001e

Browse files
committed
feat(quickstart): catalog search + source-filter chips with match counts
1 parent e2ed50c commit db6001e

2 files changed

Lines changed: 207 additions & 5 deletions

File tree

src/cli/dashboard/src/components/quickstart/ScenarioCatalogGrid.module.scss

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,104 @@
7979
text-align: right;
8080
}
8181

82+
// Search + chips row. Sits between the section header (counts +
83+
// actor slider) and the grid itself, so the user can narrow what
84+
// they're browsing without losing the run controls. Wraps to a
85+
// second line on narrow viewports.
86+
.filterRow {
87+
display: flex;
88+
align-items: center;
89+
gap: 12px;
90+
flex-wrap: wrap;
91+
margin-bottom: 12px;
92+
}
93+
94+
.searchLabel {
95+
flex: 1;
96+
min-width: 200px;
97+
display: flex;
98+
}
99+
100+
.searchInput {
101+
width: 100%;
102+
background: var(--bg-deep);
103+
color: var(--text-1);
104+
border: 1px solid var(--border);
105+
border-radius: 4px;
106+
padding: 8px 12px;
107+
font-family: var(--mono);
108+
font-size: var(--font-sm);
109+
110+
&::placeholder {
111+
color: var(--text-3);
112+
}
113+
114+
&:focus {
115+
outline: none;
116+
border-color: var(--amber);
117+
}
118+
}
119+
120+
.chipGroup {
121+
display: flex;
122+
gap: 6px;
123+
flex-wrap: wrap;
124+
}
125+
126+
.chip {
127+
background: var(--bg-deep);
128+
color: var(--text-2);
129+
border: 1px solid var(--border);
130+
border-radius: 999px;
131+
padding: 4px 10px;
132+
font-family: var(--mono);
133+
font-size: var(--font-3xs);
134+
font-weight: 700;
135+
letter-spacing: 0.06em;
136+
text-transform: uppercase;
137+
cursor: pointer;
138+
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
139+
140+
&:hover {
141+
border-color: var(--amber);
142+
color: var(--text-1);
143+
}
144+
}
145+
146+
.chipActive {
147+
background: var(--amber);
148+
border-color: var(--amber);
149+
color: var(--bg-deep);
150+
}
151+
152+
.chipCount {
153+
font-weight: 400;
154+
margin-left: 4px;
155+
opacity: 0.75;
156+
}
157+
158+
.emptyMatches {
159+
font-family: var(--mono);
160+
font-size: var(--font-sm);
161+
color: var(--text-3);
162+
padding: 24px;
163+
background: var(--bg-panel);
164+
border: 1px dashed var(--border);
165+
border-radius: 8px;
166+
text-align: center;
167+
}
168+
169+
.clearFiltersBtn {
170+
background: none;
171+
border: none;
172+
color: var(--amber);
173+
text-decoration: underline;
174+
cursor: pointer;
175+
font: inherit;
176+
padding: 0;
177+
margin: 0;
178+
}
179+
82180
// Auto-fit grid: cards take their natural minimum width, the row
83181
// flows to fit. On narrow viewports it collapses to one column.
84182
.grid {

src/cli/dashboard/src/components/quickstart/ScenarioCatalogGrid.tsx

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ function sourceLabel(source?: string): string {
9393
return source ?? 'Unknown';
9494
}
9595

96+
/** Source filter chip values. `all` is the no-filter default; the
97+
* rest mirror the source-tone slugs used on the cards so the chip
98+
* semantics stay 1:1 with the badges. */
99+
type SourceFilter = 'all' | 'builtin' | 'disk' | 'compiled';
100+
96101
export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Element | null {
97102
const { disabled = false, onRunScenario } = props;
98103
const scenario = useScenarioContext();
@@ -101,6 +106,17 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
101106
const [actorCount, setActorCount] = useState<number>(2);
102107
const [loading, setLoading] = useState<boolean>(true);
103108
const [error, setError] = useState<string | null>(null);
109+
// Search query is debounced via a delayed setter below so a fast
110+
// typist doesn't re-filter the grid on every keystroke. The raw
111+
// input value drives the controlled <input>; the debounced value
112+
// drives the actual filter. Trims + lowercases at compare time.
113+
const [queryInput, setQueryInput] = useState<string>('');
114+
const [debouncedQuery, setDebouncedQuery] = useState<string>('');
115+
useEffect(() => {
116+
const handle = setTimeout(() => setDebouncedQuery(queryInput), 120);
117+
return () => clearTimeout(handle);
118+
}, [queryInput]);
119+
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all');
104120

105121
// Catalog refresh on mount + a soft 30s poll so a freshly-compiled
106122
// scenario from another browser tab (or a friend on the same hosted
@@ -155,11 +171,30 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
155171
if (scenarios.length < 2) return null;
156172

157173
const sliderId = 'scenario-catalog-actor-count';
174+
const searchInputId = 'scenario-catalog-search';
175+
const filtersActive = debouncedQuery.trim().length > 0 || sourceFilter !== 'all';
176+
177+
// Filter pipeline: source-chip → free-text → sort. The active
178+
// scenario stays pinned to position 0 regardless of filter so users
179+
// never lose track of it; filtering it OUT of the list mid-search
180+
// would be a reasonable behavior too, but pinning matches the
181+
// LoadedScenarioCTA's "this one is loaded" framing above the grid.
182+
const queryNeedle = debouncedQuery.trim().toLowerCase();
183+
const filtered = scenarios.filter((s) => {
184+
if (s.id === activeId) return true; // always show active
185+
if (sourceFilter !== 'all' && s.source !== sourceFilter) return false;
186+
if (queryNeedle.length === 0) return true;
187+
const haystack = [
188+
s.name,
189+
s.id,
190+
s.seedText ?? '',
191+
s.description ?? '',
192+
].join(' ').toLowerCase();
193+
return haystack.includes(queryNeedle);
194+
});
195+
158196
// Sort: active first, then most-run, then newest, then alphabetical.
159-
// Anchors the user's currently-loaded scenario as a visual reference
160-
// before the rest of the catalog so they can compare it to others
161-
// without scrolling.
162-
const sorted = [...scenarios].sort((a, b) => {
197+
const sorted = [...filtered].sort((a, b) => {
163198
if (a.id === activeId) return -1;
164199
if (b.id === activeId) return 1;
165200
const runDiff = (b.runCount ?? 0) - (a.runCount ?? 0);
@@ -174,11 +209,31 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
174209
return a.name.localeCompare(b.name);
175210
});
176211

212+
// Per-chip count for the filter row. Surfacing them as suffixes
213+
// (`Built-in 2`) tells the user how many entries they'll see before
214+
// they click — reduces the "filter to nothing then back out" cycle
215+
// when e.g. there are no Saved scenarios on a fresh install.
216+
const sourceCounts = scenarios.reduce<Record<string, number>>((acc, s) => {
217+
const key = s.source ?? 'other';
218+
acc[key] = (acc[key] ?? 0) + 1;
219+
return acc;
220+
}, {});
221+
222+
const chipDefs: Array<{ key: SourceFilter; label: string }> = [
223+
{ key: 'all', label: 'All' },
224+
{ key: 'builtin', label: 'Built-in' },
225+
{ key: 'disk', label: 'Saved' },
226+
{ key: 'compiled', label: 'Custom' },
227+
];
228+
177229
return (
178230
<section className={styles.section} aria-labelledby="scenario-catalog-heading">
179231
<header className={styles.header}>
180232
<h3 className={styles.heading} id="scenario-catalog-heading">
181-
All scenarios <span className={styles.count}>· {scenarios.length}</span>
233+
All scenarios{' '}
234+
<span className={styles.count}>
235+
· {filtersActive ? `${sorted.length} of ${scenarios.length}` : scenarios.length}
236+
</span>
182237
</h3>
183238
<div className={styles.actorRow}>
184239
<label className={styles.actorLabel} htmlFor={sliderId}>Actors</label>
@@ -196,6 +251,54 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
196251
<span className={styles.actorValue}>{actorCount}</span>
197252
</div>
198253
</header>
254+
<div className={styles.filterRow}>
255+
<label className={styles.searchLabel} htmlFor={searchInputId}>
256+
<span className="sr-only">Search scenarios</span>
257+
<input
258+
id={searchInputId}
259+
type="search"
260+
value={queryInput}
261+
onChange={(e) => setQueryInput(e.target.value)}
262+
placeholder="Search by name or seed text…"
263+
className={styles.searchInput}
264+
aria-label="Filter catalog by name or seed text"
265+
/>
266+
</label>
267+
<div className={styles.chipGroup} role="group" aria-label="Filter by scenario source">
268+
{chipDefs.map((c) => {
269+
const total = c.key === 'all'
270+
? scenarios.length
271+
: (sourceCounts[c.key] ?? 0);
272+
const active = sourceFilter === c.key;
273+
// Hide chips with no entries so a fresh-install single-
274+
// builtin user doesn't see four empty filter buttons.
275+
if (c.key !== 'all' && total === 0) return null;
276+
return (
277+
<button
278+
key={c.key}
279+
type="button"
280+
onClick={() => setSourceFilter(c.key)}
281+
className={`${styles.chip} ${active ? styles.chipActive : ''}`}
282+
aria-pressed={active}
283+
>
284+
{c.label} <span className={styles.chipCount}>{total}</span>
285+
</button>
286+
);
287+
})}
288+
</div>
289+
</div>
290+
{sorted.length === 0 ? (
291+
<div className={styles.emptyMatches} role="status" aria-live="polite">
292+
No scenarios match your filter.{' '}
293+
<button
294+
type="button"
295+
className={styles.clearFiltersBtn}
296+
onClick={() => { setQueryInput(''); setSourceFilter('all'); }}
297+
>
298+
Clear filters
299+
</button>
300+
</div>
301+
) : (
199302
<ul className={styles.grid} role="list">
200303
{sorted.map((s) => {
201304
const tone = sourceTone(s.source);
@@ -247,6 +350,7 @@ export function ScenarioCatalogGrid(props: ScenarioCatalogGridProps): JSX.Elemen
247350
);
248351
})}
249352
</ul>
353+
)}
250354
</section>
251355
);
252356
}

0 commit comments

Comments
 (0)