@grimoire-rs/indexer 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/NOTICE +30 -0
  3. package/README.md +76 -331
  4. package/dist/cli/init.d.ts.map +1 -1
  5. package/dist/cli/init.js +35 -4
  6. package/dist/cli/init.js.map +1 -1
  7. package/dist/config.d.ts +107 -7
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +181 -36
  10. package/dist/config.js.map +1 -1
  11. package/dist/renderer/astro/components/CardLogo.d.ts +5 -0
  12. package/dist/renderer/astro/components/CardLogo.js +58 -0
  13. package/dist/renderer/astro/components/CardLogo.tsx +96 -0
  14. package/dist/renderer/astro/components/Catalog.d.ts +14 -1
  15. package/dist/renderer/astro/components/Catalog.js +467 -108
  16. package/dist/renderer/astro/components/Catalog.tsx +756 -349
  17. package/dist/renderer/astro/components/CommandBar.astro +66 -0
  18. package/dist/renderer/astro/components/CopyButton.d.ts +7 -0
  19. package/dist/renderer/astro/components/CopyButton.js +28 -0
  20. package/dist/renderer/astro/components/CopyButton.tsx +56 -0
  21. package/dist/renderer/astro/components/KindMark.d.ts +69 -0
  22. package/dist/renderer/astro/components/KindMark.js +66 -0
  23. package/dist/renderer/astro/components/KindMark.tsx +141 -0
  24. package/dist/renderer/astro/components/PackageCard.d.ts +18 -0
  25. package/dist/renderer/astro/components/PackageCard.js +50 -0
  26. package/dist/renderer/astro/components/PackageCard.tsx +273 -0
  27. package/dist/renderer/astro/components/PackageRow.d.ts +10 -0
  28. package/dist/renderer/astro/components/PackageRow.js +32 -0
  29. package/dist/renderer/astro/components/PackageRow.tsx +126 -0
  30. package/dist/renderer/astro/components/PickerMenu.astro +5 -14
  31. package/dist/renderer/astro/components/SiteFooter.astro +64 -0
  32. package/dist/renderer/astro/components/SiteHeader.astro +74 -0
  33. package/dist/renderer/astro/components/VersionMenu.astro +2 -2
  34. package/dist/renderer/astro/layouts/Base.astro +860 -206
  35. package/dist/renderer/astro/lib/base.d.ts +25 -0
  36. package/dist/renderer/astro/lib/base.js +23 -0
  37. package/dist/renderer/astro/lib/base.ts +27 -0
  38. package/dist/renderer/astro/lib/catalog.d.ts +24 -0
  39. package/dist/renderer/astro/lib/catalog.js +36 -0
  40. package/dist/renderer/astro/lib/catalog.ts +37 -0
  41. package/dist/renderer/astro/lib/commands.d.ts +58 -0
  42. package/dist/renderer/astro/lib/commands.js +86 -0
  43. package/dist/renderer/astro/lib/commands.ts +117 -0
  44. package/dist/renderer/astro/lib/keywordRail.d.ts +44 -0
  45. package/dist/renderer/astro/lib/keywordRail.js +99 -0
  46. package/dist/renderer/astro/lib/keywordRail.ts +110 -0
  47. package/dist/renderer/astro/pages/index.astro +40 -87
  48. package/dist/renderer/astro/pages/p/[...slug].astro +340 -195
  49. package/dist/renderer/astro/styles/tokens.css +40 -5
  50. package/dist/renderer/index.d.ts +58 -0
  51. package/dist/renderer/index.d.ts.map +1 -1
  52. package/dist/renderer/index.js +547 -5
  53. package/dist/renderer/index.js.map +1 -1
  54. package/dist/renderer/types.d.ts +9 -0
  55. package/dist/renderer/types.d.ts.map +1 -1
  56. package/package.json +9 -4
  57. package/templates/README.md +6 -0
  58. package/templates/ci/github-ratings.yml +5 -0
  59. package/templates/gitignore +4 -1
  60. package/templates/theme/README.md +38 -0
  61. package/templates/tsconfig.json +47 -0
@@ -1,12 +1,12 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
3
- // Lucide (ISC) draws the UI; brand marks come from `@mdi/js`, which Lucide
4
- // deliberately does not carry. No SVG on this site is hand-written.
5
- import { ArrowBigUp, Check, FolderRoot, Globe, Image, ImageOff } from "lucide-preact";
6
- import { mdiMicrosoftVisualStudioCode } from "@mdi/js";
7
- import { BrandMark } from "./BrandMark.js";
8
- import { withBase } from "../lib/base.js";
9
- import { lastUpdated, timeAgo, vscodeUrl } from "../lib/catalog.js";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, } from "preact/hooks";
3
+ // Lucide (ISC) draws the toolbar. The brand marks and kind glyphs moved out
4
+ // with the card and the row that wear them.
5
+ import { ArrowDownWideNarrow, ArrowUpNarrowWide, LayoutGrid, List, } from "lucide-preact";
6
+ import { PackageCard } from "./PackageCard.js";
7
+ import { PackageRow } from "./PackageRow.js";
8
+ import { keywordFrequency, selectRailKeywords } from "../lib/keywordRail.js";
9
+ import { lastUpdated } from "../lib/catalog.js";
10
10
  // Known kinds get stable chip ordering + badge colors; unknown kinds
11
11
  // (future schema growth) still render with a neutral badge.
12
12
  const KNOWN_KINDS = ["skill", "rule", "agent", "mcp", "bundle"];
@@ -14,6 +14,28 @@ function kindOrder(kind) {
14
14
  const i = KNOWN_KINDS.indexOf(kind);
15
15
  return i === -1 ? KNOWN_KINDS.length : i;
16
16
  }
17
+ /**
18
+ * How many keyword chips the rail shows at once, actives included.
19
+ *
20
+ * Everything past it goes behind the overflow menu. The cap is the point:
21
+ * this catalog's keyword vocabulary is open-ended, and a rail that renders
22
+ * all of it is a wall of chips nobody reads.
23
+ */
24
+ const KEYWORD_CHIP_LIMIT = 8;
25
+ /**
26
+ * The direction each field is *worth* reading first in — A→Z for a name,
27
+ * newest and best-liked first for the two ranked keys.
28
+ *
29
+ * Picking a field selects its natural direction; the toggle beside the
30
+ * combo box reverses that. So "descending" is not a global default a reader
31
+ * has to correct on every mode, and the arrow always describes what the
32
+ * order actually is rather than which way a flag is set.
33
+ */
34
+ export const NATURAL = {
35
+ name: "asc",
36
+ updated: "desc",
37
+ rating: "desc",
38
+ };
17
39
  /**
18
40
  * Bigger first, with `null` as its own bucket underneath every number.
19
41
  *
@@ -38,7 +60,8 @@ function updatedAt(p) {
38
60
  * mode, and it is unique, so no two rows ever compare equal — a browse order
39
61
  * that is not total is a browse order that reshuffles on rebuild.
40
62
  */
41
- const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) || a.ref.localeCompare(b.ref);
63
+ const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) ||
64
+ a.ref.localeCompare(b.ref);
42
65
  /**
43
66
  * Newest first. No usable date is *unknown*, not epoch 0: dating an undated
44
67
  * package to 1970 sorts it below real packages by accident rather than by
@@ -62,11 +85,14 @@ const CHAINS = {
62
85
  // any other row when the toggle brings them back. grim's own browse order
63
86
  // (`browse_sort.rs`) has no deprecated key either; keeping this comparator
64
87
  // silent on deprecation is what keeps the two in sync.
65
- export function compare(a, b, sort) {
88
+ export function compare(a, b, sort, dir = NATURAL[sort]) {
66
89
  for (const key of CHAINS[sort]) {
67
90
  const d = key(a, b);
91
+ // Reversed means reversed all the way down, the ref tiebreak included.
92
+ // Every chain ends on a unique key, so no two rows compare equal and
93
+ // negating the whole answer leaves the order just as total as it was.
68
94
  if (d !== 0)
69
- return d;
95
+ return dir === NATURAL[sort] ? d : -d;
70
96
  }
71
97
  return 0;
72
98
  }
@@ -74,12 +100,56 @@ export function compare(a, b, sort) {
74
100
  // codicons so "project" and "global" read identically in both. That parity
75
101
  // is gone on purpose: every icon now comes from one set. `FolderRoot` and
76
102
  // `Globe` are the nearest Lucide equivalents and carry the same meaning.
103
+ /**
104
+ * The reader's own preferences, kept out of the URL.
105
+ *
106
+ * The split is deliberate and matches grim: `q`, `kind` and `kw` are *what
107
+ * you are looking at* — a keyword chip on a package page links to
108
+ * `/?kw=<keyword>`, so that half has to stay shareable — while sort,
109
+ * direction, deprecated visibility and the cards/table choice are *how you
110
+ * like the catalog arranged*, the same answer on every visit. grim keeps
111
+ * `show_deprecated` in its config file for exactly that reason.
112
+ *
113
+ * Both accessors swallow: reading `localStorage` throws outright, not
114
+ * returns null, in a browser set to block site data, and a catalog is not
115
+ * worth a blank page. A reader who blocks it browses without preferences.
116
+ */
117
+ const PREF = "grim.catalog.";
118
+ function readPref(key) {
119
+ try {
120
+ return localStorage.getItem(PREF + key);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ function writePref(key, value) {
127
+ try {
128
+ if (value === null)
129
+ localStorage.removeItem(PREF + key);
130
+ else
131
+ localStorage.setItem(PREF + key, value);
132
+ }
133
+ catch {
134
+ // Nothing to do and nothing to report: preferences are a convenience.
135
+ }
136
+ }
137
+ /**
138
+ * A comma-joined URL parameter, back into the list it was.
139
+ *
140
+ * Empty and absent are the same answer — `?kind=` is a reader who cleared
141
+ * the filter, not a request for the kind named "". Duplicates collapse so a
142
+ * hand-edited `?kw=a,a` cannot render the same chip twice.
143
+ */
144
+ function list(value) {
145
+ return [...new Set((value ?? "").split(",").filter(Boolean))];
146
+ }
77
147
  /** Typing inside one of these means a bare keystroke is text, not a shortcut. */
78
148
  function isTyping(el) {
79
149
  const node = el;
80
150
  if (!node)
81
151
  return false;
82
- return node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName);
152
+ return (node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName));
83
153
  }
84
154
  /**
85
155
  * How many cards the grid actually laid out per row.
@@ -87,6 +157,10 @@ function isTyping(el) {
87
157
  * Measured, not read from CSS: the track list is `auto-fill` with a minimum
88
158
  * width, so the count is a layout outcome that depends on the viewport. The
89
159
  * first card whose top edge drops below the first row's starts row two.
160
+ *
161
+ * The table view needs no branch of its own — its rows stack, so the second
162
+ * one is already below the first and this measures the 1 that makes every
163
+ * arrow key move by a single row.
90
164
  */
91
165
  function columnCount(cards) {
92
166
  if (cards.length < 2)
@@ -96,69 +170,33 @@ function columnCount(cards) {
96
170
  return wrapped === -1 ? cards.length : wrapped;
97
171
  }
98
172
  /**
99
- * The card's 28px logo slot, in its three states.
173
+ * The same packages as a list, for reading down a column rather than across
174
+ * a grid.
175
+ *
176
+ * **A row is an anchor, and there is no `<table>`.** Two reasons, and the
177
+ * first is the load-bearing one: a header row that cannot sort is a header
178
+ * row that *looks* like it sorts — every reader who has met a data table
179
+ * clicks it once. Sorting lives in the toolbar, so the table has no headers,
180
+ * and a headerless table has no column semantics left to justify the element.
181
+ * What remains is a list of links, which is what this is. A CSS grid with
182
+ * `subgrid` rows keeps the columns aligned without the markup.
100
183
  *
101
- * The third one is the reason this is a component rather than inline JSX: a
102
- * package can declare a `logo` whose file is not actually served — the
103
- * enrich step failed, the asset was pruned, the path is stale and the
104
- * browser's own broken-image glyph is both ugly and says nothing. So a
105
- * declared-but-unreachable logo degrades to a marked placeholder, which is
106
- * deliberately *not* the same as the initial-letter tile a package with no
107
- * logo at all gets: one is a fault worth seeing, the other is normal.
184
+ * The anchor is also what makes the whole row clickable, focusable and
185
+ * middle-clickable for free no stretched-link overlay, no synthetic Enter
186
+ * handler. A row carries no controls of its own: the install buttons and the
187
+ * vote links are what the card exists for, and repeating them per row would
188
+ * be five columns of icons. The detail page has all of them.
108
189
  *
109
- * The static detail page needs the same treatment but cannot use `onError`,
110
- * so it opts into the global handler in `Base.astro` instead keep the two
111
- * placeholders looking alike.
190
+ * Columns are fixed, unlike the keyword rail above deliberately. A column
191
+ * is a slot the eye tracks down; one that appears and disappears as the
192
+ * filters change destroys the alignment the table exists to give. The rating
193
+ * column is the one exception, and it is decided once per index rather than
194
+ * per filter.
112
195
  */
113
- function CardLogo({ pkg }) {
114
- const [state, setState] = useState("loading");
115
- const imgRef = useRef(null);
116
- // The image is server-rendered, so the browser begins fetching it while
117
- // parsing the HTML — long before this island hydrates. Two consequences,
118
- // and the slot markup below answers both: `onError` can fire before any
119
- // listener exists (the placeholder used to appear only sometimes), and a
120
- // failed image paints the browser's broken glyph on the way (the flash on
121
- // reload). Starting the image hidden means nothing is ever shown until it
122
- // is known to be good.
123
- //
124
- // `complete` says the browser finished, not how it went. `decode()` is
125
- // what separates the two: it rejects for a failure and resolves for a good
126
- // image — including an SVG with no intrinsic size, where the usual
127
- // `naturalWidth === 0` test reports a false failure. Gating on `complete`
128
- // means it never starts a fetch, so `loading="lazy"` still holds off
129
- // -screen cards.
130
- useEffect(() => {
131
- setState("loading");
132
- const img = imgRef.current;
133
- if (!img?.complete)
134
- return;
135
- let live = true;
136
- img.decode().then(() => live && setState("ready"), () => live && setState("broken"));
137
- return () => {
138
- live = false;
139
- };
140
- }, [pkg.logo]);
141
- if (!pkg.logo) {
142
- return (_jsx("span", { class: "card-logo card-logo-fallback", "aria-hidden": "true", style: { background: `var(--grim-color-kind-${pkg.kind}, var(--grim-color-muted))` }, children: pkg.name[0]?.toUpperCase() }));
143
- }
144
- return (_jsxs("span", { class: "card-logo logo-slot", "data-state": state, role: state === "broken" ? "img" : undefined, "aria-label": state === "broken" ? "Logo image unavailable" : undefined, title: state === "broken" ? "Logo image unavailable" : undefined, children: [state === "broken" ? (_jsx(ImageOff, { class: "logo-mark", "aria-hidden": "true" })) : (_jsx(Image, { class: "logo-mark", "aria-hidden": "true" })), _jsx("img", { ref: imgRef, src: withBase(pkg.logo), alt: "", loading: "lazy", onLoad: () => setState("ready"), onError: () => setState("broken") })] }));
145
- }
146
- function CopyButton({ command, variant = "default", name, }) {
147
- const [copied, setCopied] = useState(false);
148
- const copy = () => {
149
- navigator.clipboard.writeText(command).then(() => {
150
- setCopied(true);
151
- // The toast lives in Base.astro's inline script, outside this island —
152
- // an event is how a hydrated component reaches it without either side
153
- // importing the other.
154
- document.dispatchEvent(new CustomEvent("grimoire:copied", { detail: { name, value: command } }));
155
- setTimeout(() => setCopied(false), 1500);
156
- });
157
- };
158
- return (_jsx("button", { type: "button", class: copied ? "copy copied" : "copy", title: command, "aria-label": `Copy: ${command}`,
159
- // Out of the Tab sequence: the card is the stop, and the same command
160
- // is copyable from the detail page Enter opens.
161
- tabIndex: -1, onClick: copy, children: copied ? _jsx(Check, { size: 14 }) : variant === "global" ? _jsx(Globe, { size: 14 }) : _jsx(FolderRoot, { size: 14 }) }));
196
+ function PackageTable({ packages, hasRatings, onKeyDown, rootRef, }) {
197
+ return (_jsx("div", { class: hasRatings ? "table rated" : "table", "data-slot": "package-table", ref: (el) => {
198
+ rootRef.current = el;
199
+ }, children: packages.map((p) => (_jsx(PackageRow, { pkg: p, hasRatings: hasRatings, onKeyDown: onKeyDown }, `${p.namespace}/${p.name}`))) }));
162
200
  }
163
201
  // `vscodeExtension` arrives as a prop, not from lib/data: this island
164
202
  // hydrates in the browser, so importing the build-time payload here would
@@ -179,19 +217,143 @@ export default function Catalog({ packages, vscodeExtension, }) {
179
217
  // Whether the URL's query has been applied. Gates the reveal below, so the
180
218
  // catalog is never unhidden while it still shows the unfiltered list.
181
219
  const [seeded, setSeeded] = useState(false);
182
- const [kind, setKind] = useState(null);
220
+ // Kinds combine with OR, keywords with AND, and the two groups with each
221
+ // other. That is not an inconsistency, it follows from the data: a package
222
+ // has exactly one kind, so requiring both of two kinds always yields
223
+ // nothing, while it carries many keywords, so requiring both of two is the
224
+ // only reading under which a second click narrows. A facet whose second
225
+ // click *widens* the result set reads as broken.
226
+ const [kinds, setKinds] = useState([]);
227
+ const [keywords, setKeywords] = useState([]);
183
228
  const [sort, setSort] = useState("name");
229
+ // Direction, not "reversed": what the arrow draws is the order itself.
230
+ const [dir, setDir] = useState(NATURAL.name);
184
231
  // Deprecated packages are hidden until asked for: a retired package is
185
232
  // noise for someone browsing what to install, and the publisher already
186
233
  // said as much by deprecating it.
187
234
  const [showDeprecated, setShowDeprecated] = useState(false);
235
+ const [view, setView] = useState("cards");
236
+ // Local to the overflow menu and deliberately not shareable: it narrows
237
+ // the list of keywords, not the catalog.
238
+ const [keywordFilter, setKeywordFilter] = useState("");
188
239
  const searchRef = useRef(null);
189
240
  const gridRef = useRef(null);
190
241
  const controlsRef = useRef(null);
191
- const cardsOf = () => [...(gridRef.current?.querySelectorAll("li.card") ?? [])];
242
+ // Both views, one selector: a table row is the Tab stop its card is, so
243
+ // every keyboard path below — the search hatch, ArrowDown out of the
244
+ // chips, Escape's blur — works in either without knowing which is up.
245
+ const cardsOf = () => [
246
+ ...(gridRef.current?.querySelectorAll("li.card, a.row") ?? []),
247
+ ];
248
+ // Clipped keyword chips are excluded: they are drawn as nothing, so an
249
+ // arrow key that landed on one would move focus somewhere the reader
250
+ // cannot see it.
192
251
  const chipsOf = () => [
193
- ...(controlsRef.current?.querySelectorAll("button.chip") ?? []),
252
+ ...(controlsRef.current?.querySelectorAll('button.chip:not([aria-hidden="true"])') ?? []),
194
253
  ];
254
+ const railRefs = useRef(new Map());
255
+ const railRects = useRef(new Map());
256
+ const railRef = useRef(null);
257
+ /**
258
+ * How many keyword chips actually fit on the row, measured.
259
+ *
260
+ * Not a constant, because the answer is a layout outcome: the rail is the
261
+ * one flexible child of the filter row, so its width is whatever the kinds,
262
+ * the overflow menu and the deprecated toggle left, and the chips are as
263
+ * wide as the words publishers wrote. `KEYWORD_CHIP_LIMIT` bounds how many
264
+ * are *offered*; this is how many are shown.
265
+ *
266
+ * The rule it enforces: the rail never wraps and never scrolls. A second
267
+ * row makes the toolbar a different height on every filter click, and a
268
+ * scrollbar hides the chips behind a gesture nobody looks for — it also
269
+ * pushed the overflow menu off the end of the row entirely.
270
+ *
271
+ * Every chip stays in the flow whatever this says; the ones past it are
272
+ * drawn as nothing (see `.chip.kw.clipped`). Taking them out of the flow
273
+ * would free the width that excluded them, which is a measurement that
274
+ * disagrees with itself on every other frame.
275
+ */
276
+ const [railFit, setRailFit] = useState(KEYWORD_CHIP_LIMIT);
277
+ useLayoutEffect(() => {
278
+ const rail = railRef.current;
279
+ if (!rail)
280
+ return;
281
+ const measure = () => {
282
+ const edge = rail.getBoundingClientRect().right;
283
+ let fits = 0;
284
+ for (const chip of rail.children) {
285
+ // Half a pixel of slack: a fractional layout can leave a chip's right
286
+ // edge a rounding error past a boundary it visually sits inside.
287
+ if (chip.getBoundingClientRect().right > edge + 0.5)
288
+ break;
289
+ fits += 1;
290
+ }
291
+ // At least one, always. A rail too narrow for its shortest chip should
292
+ // show that chip clipped rather than render an empty group beside a
293
+ // divider that then divides nothing.
294
+ setRailFit(Math.max(1, fits));
295
+ };
296
+ measure();
297
+ // Guarded rather than assumed: this effect also runs under the test
298
+ // renderer, whose DOM has no `ResizeObserver` — and a missing one costs
299
+ // only re-measurement on viewport resize, which is not worth throwing
300
+ // during a render over.
301
+ if (typeof ResizeObserver === "undefined")
302
+ return;
303
+ const observer = new ResizeObserver(measure);
304
+ observer.observe(rail);
305
+ return () => observer.disconnect();
306
+ // Re-measured on every commit that changes which chips are up, since the
307
+ // observer only fires when the rail's own box changes and a rescore can
308
+ // swap a short word for a long one at the same width.
309
+ });
310
+ /**
311
+ * FLIP for the keyword rail: chips slide to their new places instead of
312
+ * teleporting.
313
+ *
314
+ * The rail is rescored against the current result set, so it reorders on
315
+ * every click — the chip just picked moves to the front and the rest flow
316
+ * around it. Animating that is not decoration: a rail whose contents change
317
+ * between two frames reads as having been *replaced*, and a reader who
318
+ * cannot see that a chip moved has no reason to believe it is the same one.
319
+ *
320
+ * First (the map of rects kept from the last commit), Last (measured now),
321
+ * Invert (an inline translate back to where the chip was), Play (dropped on
322
+ * the next frame, so the stylesheet's transition carries it home). Measure
323
+ * every chip before transforming any: `translate` composites and does not
324
+ * reflow, but reading a rect after writing a style on a sibling is the
325
+ * shape that makes a layout thrash, and this runs per keystroke.
326
+ */
327
+ useLayoutEffect(() => {
328
+ const previous = railRects.current;
329
+ const current = new Map();
330
+ const moved = [];
331
+ for (const [keyword, el] of railRefs.current) {
332
+ const rect = el.getBoundingClientRect();
333
+ current.set(keyword, rect);
334
+ const was = previous.get(keyword);
335
+ if (!was)
336
+ continue;
337
+ const dx = was.left - rect.left;
338
+ const dy = was.top - rect.top;
339
+ if (dx !== 0 || dy !== 0)
340
+ moved.push({ el, dx, dy });
341
+ }
342
+ railRects.current = current;
343
+ if (moved.length === 0)
344
+ return;
345
+ for (const { el, dx, dy } of moved) {
346
+ el.style.transition = "none";
347
+ el.style.translate = `${dx}px ${dy}px`;
348
+ }
349
+ const frame = requestAnimationFrame(() => {
350
+ for (const { el } of moved) {
351
+ el.style.transition = "";
352
+ el.style.translate = "";
353
+ }
354
+ });
355
+ return () => cancelAnimationFrame(frame);
356
+ });
195
357
  /** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
196
358
  const focusCard = (from, delta) => {
197
359
  const cards = cardsOf();
@@ -218,17 +380,90 @@ export default function Catalog({ packages, vscodeExtension, }) {
218
380
  input.select();
219
381
  input.scrollIntoView({
220
382
  block: "start",
221
- behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
383
+ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
384
+ ? "auto"
385
+ : "smooth",
222
386
  });
223
387
  };
224
- // Apply `?q=…`, now that hydration has matched the server's markup and
225
- // Preact owns the tree. A layout effect rather than a plain one: the
388
+ /**
389
+ * Put the reader's view into state the query from the URL, the
390
+ * preferences from storage. Neither half was there before, and the missing
391
+ * preference half is what made a deprecated package unreachable by Back:
392
+ * you turned the toggle on, opened the package, came back, and the
393
+ * remounted catalog knew nothing about it, so the card you had just been
394
+ * looking at was hidden again.
395
+ *
396
+ * Unknown values are dropped rather than trusted at both doors: `kind`
397
+ * reaches a class name, `kw` reaches a chip that stays on screen until it
398
+ * is clicked off, and `sort` selects a comparator, so none of them follows
399
+ * a hand-typed URL or a hand-edited storage entry anywhere the controls
400
+ * cannot go. `kw` is checked against the catalog's own vocabulary rather
401
+ * than a fixed list, since keywords are whatever publishers wrote.
402
+ */
403
+ const applyView = () => {
404
+ const params = new URLSearchParams(location.search);
405
+ const s = readPref("sort");
406
+ const d = readPref("dir");
407
+ const v = readPref("view");
408
+ const field = s === "updated" || s === "rating" ? s : "name";
409
+ const published = new Set(packages.flatMap((p) => p.keywords ?? []));
410
+ setQuery(params.get("q") ?? "");
411
+ setKinds(list(params.get("kind")).filter((k) => KNOWN_KINDS.includes(k)));
412
+ setKeywords(list(params.get("kw")).filter((k) => published.has(k)));
413
+ setSort(field);
414
+ setDir(d === "asc" || d === "desc" ? d : NATURAL[field]);
415
+ // A flag: stored at all means on.
416
+ setShowDeprecated(readPref("deprecated") !== null);
417
+ setView(v === "table" ? "table" : "cards");
418
+ };
419
+ // Apply the URL's view, now that hydration has matched the server's markup
420
+ // and Preact owns the tree. A layout effect rather than a plain one: the
226
421
  // resulting render must land before the browser paints, or a `?q=` visitor
227
422
  // sees the whole catalog flash past on the way to their results.
228
423
  useLayoutEffect(() => {
229
- setQuery(new URLSearchParams(location.search).get("q") ?? "");
424
+ applyView();
230
425
  setSeeded(true);
231
426
  }, []);
427
+ // Back and Forward within the catalog — a keyword chip on a package page
428
+ // links to `/?q=…`, so the reader can land here more than once without a
429
+ // reload, and `popstate` is the only notice of it.
430
+ useEffect(() => {
431
+ const onPop = () => applyView();
432
+ addEventListener("popstate", onPop);
433
+ return () => removeEventListener("popstate", onPop);
434
+ }, []);
435
+ // The query, into the URL — so it can be shared, and so Back lands on the
436
+ // search the reader left. `replaceState`, not `pushState`: a history entry
437
+ // per keystroke would make Back mean "undo one letter" rather than "the
438
+ // page I came from". Gated on `seeded`, since writing before the URL has
439
+ // been read would erase a deep link on arrival.
440
+ useEffect(() => {
441
+ if (!seeded)
442
+ return;
443
+ const params = new URLSearchParams(location.search);
444
+ const set = (key, value) => value === null ? params.delete(key) : params.set(key, value);
445
+ set("q", query || null);
446
+ set("kind", kinds.length > 0 ? kinds.join(",") : null);
447
+ set("kw", keywords.length > 0 ? keywords.join(",") : null);
448
+ const search = params.toString();
449
+ const next = `${location.pathname}${search ? `?${search}` : ""}${location.hash}`;
450
+ if (next !== `${location.pathname}${location.search}${location.hash}`) {
451
+ history.replaceState(history.state, "", next);
452
+ }
453
+ // `keywords` is compared by identity, which is what we want: the array is
454
+ // replaced on every toggle and never mutated in place.
455
+ }, [seeded, query, kinds, keywords]);
456
+ // The preferences, into storage — so the next visit opens the way this one
457
+ // ended. Each is stored only when it is not the default, so a reader who
458
+ // never touched a control leaves nothing behind.
459
+ useEffect(() => {
460
+ if (!seeded)
461
+ return;
462
+ writePref("sort", sort === "name" ? null : sort);
463
+ writePref("dir", dir === NATURAL[sort] ? null : dir);
464
+ writePref("deprecated", showDeprecated ? "1" : null);
465
+ writePref("view", view === "cards" ? null : view);
466
+ }, [seeded, sort, dir, showDeprecated, view]);
232
467
  // Base.astro hides the catalog before first paint when the URL carries a
233
468
  // query. Reveal it only once the filtered render is in the DOM — keyed on
234
469
  // `seeded`, so the unfiltered first render is never the one revealed.
@@ -282,28 +517,38 @@ export default function Catalog({ packages, vscodeExtension, }) {
282
517
  const active = document.activeElement;
283
518
  // Also true for a control *inside* a card, which is still the card
284
519
  // being selected as far as the reader is concerned.
285
- const card = active instanceof HTMLElement ? active.closest("li.card") : null;
286
- if (!query && kind === null && !card)
287
- return; // nothing selected: not our key
520
+ const card = active instanceof HTMLElement ? active.closest("li.card, a.row") : null;
521
+ // Nothing selected: not our key.
522
+ if (!query && kinds.length === 0 && keywords.length === 0 && !card)
523
+ return;
288
524
  event.preventDefault();
289
525
  setQuery("");
290
- setKind(null);
526
+ setKinds([]);
527
+ setKeywords([]);
291
528
  if (card)
292
529
  active.blur();
293
530
  };
294
531
  document.addEventListener("keydown", onEscape);
295
532
  return () => document.removeEventListener("keydown", onEscape);
296
- }, [query, kind]);
533
+ }, [query, kinds, keywords]);
534
+ /** Both facets toggle the same way; only the relation between values differs. */
535
+ const toggle = (set) => (value) => set((was) => was.includes(value) ? was.filter((v) => v !== value) : [...was, value]);
536
+ const toggleKind = toggle(setKinds);
537
+ // Appends rather than inserts, so the pinned chips below stay in the order
538
+ // they were picked — the rail reorders underneath them, the actives do not.
539
+ const toggleKeyword = toggle(setKeywords);
297
540
  /**
298
- * The filter and sort chips are not Tab stops Tab is reserved for
299
- * crossing the catalog, so it runs search card card. The chips sit in
300
- * the row above the grid, so they are reached the way that row is:
301
- * ArrowUp out of the top card row, ArrowDown back into it.
541
+ * Arrow keys move *across* a rail the reader is already standing in. They
542
+ * are not Tab's replacement, and this is the correction of a real defect:
543
+ * the chips used to carry `tabIndex={-1}` whenever the grid had anything
544
+ * in it, which left filtering reachable by pointer and arrow key only.
545
+ * That is a WCAG 2.1.1 (A) failure — every control has to be operable from
546
+ * the keyboard through the ordinary sequence, and an undocumented arrow
547
+ * convention is not that sequence. The sibling `@ocx-sh/catalog` renderer
548
+ * shipped the same shortcut and reverted it for the same reason.
302
549
  *
303
- * The one case that would strand them is an empty result set, where there
304
- * is no card to arrow up from so with nothing shown they rejoin the Tab
305
- * order (see `chipTabIndex` below), which is also exactly when a reader
306
- * needs them most.
550
+ * So the chips are ordinary Tab stops now, and ArrowUp/ArrowDown remain as
551
+ * the faster way to cross a long rail or drop back into the grid.
307
552
  */
308
553
  const onChipKeyDown = (event) => {
309
554
  const chips = chipsOf();
@@ -334,7 +579,33 @@ export default function Catalog({ packages, vscodeExtension, }) {
334
579
  event.preventDefault();
335
580
  cardsOf()[0]?.focus();
336
581
  }
337
- else if (event.key === "Escape" && !query && kind === null) {
582
+ else if (event.key === "Tab" &&
583
+ !event.shiftKey &&
584
+ !event.metaKey &&
585
+ !event.ctrlKey &&
586
+ !event.altKey) {
587
+ // The hatch. Everything between the field and the grid — sort, the
588
+ // view toggle, every chip — sits after it in the DOM and is a real Tab
589
+ // stop again, so plain Tab would walk the whole toolbar before
590
+ // reaching a single package. Forward Tab skips to the results; the
591
+ // toolbar stays reachable by Shift+Tab back out of the grid.
592
+ //
593
+ // Reordering the DOM instead would have put focus order at odds with
594
+ // visual order, which is the worse defect of the two.
595
+ //
596
+ // With nothing to jump to — an empty result set — Tab is left alone
597
+ // rather than swallowed: trapping focus in the field is worse than the
598
+ // walk it was meant to save.
599
+ const first = cardsOf()[0];
600
+ if (!first)
601
+ return;
602
+ event.preventDefault();
603
+ first.focus();
604
+ }
605
+ else if (event.key === "Escape" &&
606
+ !query &&
607
+ kinds.length === 0 &&
608
+ keywords.length === 0) {
338
609
  // Clearing is the document handler's job; this is only the second
339
610
  // press, once there is nothing left to clear — so Escape leaves the
340
611
  // field rather than being a dead key.
@@ -382,6 +653,11 @@ export default function Catalog({ packages, vscodeExtension, }) {
382
653
  // mouse must keep its own Space/Enter behaviour.
383
654
  if (event.target !== card)
384
655
  return;
656
+ // A table row *is* an anchor, so Enter is the browser's to handle —
657
+ // swallowing it here would break activation rather than provide it.
658
+ // Only the card needs its title link clicked on its behalf.
659
+ if (card instanceof HTMLAnchorElement)
660
+ return;
385
661
  event.preventDefault();
386
662
  card.querySelector("h2 a")?.click();
387
663
  return;
@@ -393,18 +669,24 @@ export default function Catalog({ packages, vscodeExtension, }) {
393
669
  // deprecated entries drop out of those totals too while they are hidden,
394
670
  // so no count ever promises more than the grid shows.
395
671
  const counted = useMemo(() => (showDeprecated ? packages : packages.filter((p) => !p.deprecated)), [packages, showDeprecated]);
396
- const kinds = useMemo(() => {
397
- const counts = new Map();
398
- for (const p of counted)
399
- counts.set(p.kind, (counts.get(p.kind) ?? 0) + 1);
400
- return [...counts.entries()].sort((a, b) => kindOrder(a[0]) - kindOrder(b[0]) || a[0].localeCompare(b[0]));
672
+ // Which kinds this catalog publishes, in chip order. No counts on the
673
+ // chips: they cost every chip the width of a number, which is width the
674
+ // keyword rail beside them needs more, and the meta row already states how
675
+ // many packages the filters left. A per-chip count is also the harder one
676
+ // to read honestly — kinds are an OR group, so a count taken after the
677
+ // filter says "3" about a chip that is about to reveal thirty.
678
+ const kindNames = useMemo(() => {
679
+ const seen = new Set(counted.map((p) => p.kind));
680
+ return [...seen].sort((a, b) => kindOrder(a) - kindOrder(b) || a.localeCompare(b));
401
681
  }, [counted]);
402
682
  const q = query.trim().toLowerCase();
403
- // Query and kind first, deprecation last — so the toggle can report how
683
+ // Query and facets first, deprecation last — so the toggle can report how
404
684
  // many entries *it alone* is holding back, rather than a catalog-wide
405
685
  // number that has nothing to do with what is on screen.
406
686
  const matching = packages.filter((p) => {
407
- if (kind && p.kind !== kind)
687
+ if (kinds.length > 0 && !kinds.includes(p.kind))
688
+ return false;
689
+ if (!keywords.every((kw) => p.keywords?.includes(kw)))
408
690
  return false;
409
691
  if (!q)
410
692
  return true;
@@ -418,16 +700,93 @@ export default function Catalog({ packages, vscodeExtension, }) {
418
700
  (p.keywords ?? []).join(" "),
419
701
  ].some((field) => field.toLowerCase().includes(q));
420
702
  });
421
- const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) => compare(a, b, sort));
703
+ const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) => compare(a, b, sort, dir));
704
+ /**
705
+ * The keyword rail, over what is on screen rather than over the catalog.
706
+ *
707
+ * Two decisions, both borrowed from `@ocx-sh/catalog` and both load-bearing:
708
+ *
709
+ * Active keywords are **pinned**, first and in the order they were clicked,
710
+ * and never scored. A filter that scrolls out of the rail is a filter the
711
+ * reader cannot lift. Their count is `shown.length` by construction — under
712
+ * AND, every surviving package carries every active keyword.
713
+ *
714
+ * The rest are picked by splitting power over `shown`, not by frequency
715
+ * over `packages`. A rail scored against the whole catalog keeps offering
716
+ * keywords no surviving package carries, and under AND that is most of
717
+ * them — every such chip is one click to an empty grid.
718
+ *
719
+ * The cost, accepted: the rail's contents move as the reader filters, which
720
+ * is what the FLIP effect above animates. The set changing invisibly is
721
+ * what would read as broken.
722
+ */
723
+ const pinned = keywords.map((keyword) => ({
724
+ keyword,
725
+ count: shown.length,
726
+ }));
727
+ const rail = selectRailKeywords(shown, KEYWORD_CHIP_LIMIT)
728
+ // `selectRailKeywords` scores the actives like any other keyword, so
729
+ // over-request and drop them rather than spend rail slots twice.
730
+ .filter((k) => !keywords.includes(k.keyword))
731
+ .slice(0, Math.max(0, KEYWORD_CHIP_LIMIT - pinned.length));
732
+ const visibleKeywords = [...pinned, ...rail];
733
+ // What the menu has to carry: everything the rail had no slot for, plus
734
+ // everything it has a slot for but no ROOM for. The second half is why the
735
+ // menu is built from `railFit` rather than from `KEYWORD_CHIP_LIMIT` — a
736
+ // chip clipped at the rail's edge is one the reader cannot reach anywhere
737
+ // else, and a "+N more" that does not count it is lying about where it is.
738
+ const clippedKeywords = visibleKeywords.slice(railFit).map((k) => k.keyword);
739
+ const menuKeywords = keywordFrequency(shown).filter((k) => clippedKeywords.includes(k.keyword) ||
740
+ !visibleKeywords.some((v) => v.keyword === k.keyword));
741
+ // Plain substring, not a fuzzy match: this searches a list the reader is
742
+ // looking at, and every entry in it is one short known word.
743
+ const menuShown = menuKeywords.filter((k) => k.keyword.toLowerCase().includes(keywordFilter.trim().toLowerCase()));
422
744
  // A catalog with nothing deprecated gets no toggle — a control that can
423
745
  // only ever be a no-op is worse than its absence. An index that publishes
424
746
  // no ratings gets no rating chip for the same reason.
425
747
  const hasDeprecated = packages.some((p) => p.deprecated);
426
748
  const hasRatings = packages.some((p) => p.rating);
427
- // Chips leave the Tab order only while there is a grid to arrow up from.
428
- const chipTabIndex = shown.length === 0 ? 0 : -1;
429
- return (_jsxs("section", { class: "catalog", "data-slot": "catalog", children: [_jsxs("div", { class: "controls", "data-slot": "catalog-toolbar", ref: controlsRef, children: [_jsxs("div", { class: "search-field", "data-slot": "catalog-search", children: [_jsx("input", { ref: searchRef, type: "search", placeholder: `Search ${counted.length} packages…`, value: query, onInput: (e) => setQuery(e.target.value), onKeyDown: onSearchKeyDown, "aria-label": "Search packages", "aria-keyshortcuts": "/" }), _jsx("kbd", { class: "search-hint", "aria-hidden": "true", children: "/" })] }), _jsxs("div", { class: "chips", role: "group", "aria-label": "Sort by", children: [_jsx("button", { type: "button", class: sort === "name" ? "chip active" : "chip", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("name"), children: "name" }), _jsx("button", { type: "button", class: sort === "updated" ? "chip active" : "chip", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("updated"), children: "updated" }), hasRatings && (_jsx("button", { type: "button", class: sort === "rating" ? "chip active" : "chip", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setSort("rating"), children: "rating" }))] }), _jsx("span", { class: "chip-sep", "aria-hidden": "true" }), _jsxs("div", { class: "chips", role: "group", "aria-label": "Filter by kind", children: [_jsxs("button", { type: "button", class: kind === null ? "chip active" : "chip", "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setKind(null), children: ["all ", _jsx("small", { children: counted.length })] }), kinds.map(([k, count]) => (_jsxs("button", { type: "button", class: kind === k ? `chip active kind-${k}` : `chip kind-${k}`, "data-slot": "filter-chip", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setKind(kind === k ? null : k), children: [k, " ", _jsx("small", { children: count })] }, k)))] }), hasDeprecated && (_jsx("button", { type: "button", class: showDeprecated ? "chip deprecated-toggle active" : "chip deprecated-toggle", "aria-pressed": showDeprecated, title: showDeprecated ? "Hide deprecated packages" : "Show deprecated packages", tabIndex: chipTabIndex, onKeyDown: onChipKeyDown, onClick: () => setShowDeprecated((on) => !on), children: "deprecated" }))] }), shown.length === 0 ? (_jsx("p", { class: "empty", children: "No packages match." })) : (_jsx("ul", { class: "grid", ref: gridRef, children: shown.map((p) => (_jsxs("li", { class: "card", "data-slot": "package-card", tabIndex: 0, onKeyDown: onCardKeyDown, children: [_jsxs("div", { class: "card-head", children: [_jsx(CardLogo, { pkg: p }), _jsx("h2", { "data-slot": "package-name", children: _jsx("a", { href: withBase(`/p/${p.namespace}/${p.name}/`), tabIndex: -1, children: p.name }) }), p.deprecated ? (_jsx("span", { class: "badge deprecated", "data-slot": "package-kind", children: "deprecated" })) : (_jsx("span", { class: `badge kind-${p.kind}`, "data-slot": "package-kind", children: p.kind }))] }), _jsx("p", { class: "namespace", children: p.namespace }), (p.version || p.license || lastUpdated(p) || p.rating) && (_jsxs("div", { class: "meta-row", "data-slot": "package-meta", children: [p.version && _jsxs("span", { class: "pill version", children: ["v", p.version] }), p.license && _jsx("span", { class: "pill license", children: p.license }), p.rating && (_jsxs("span", { class: "pill rating", title: `${p.rating.up} upvote${p.rating.up === 1 ? "" : "s"}`, children: [_jsx(ArrowBigUp, { size: 13, "aria-hidden": "true" }), p.rating.up] })), (() => {
430
- const at = lastUpdated(p);
431
- return at && timeAgo(at) ? (_jsxs("time", { class: "updated", datetime: at, title: at, children: ["updated ", timeAgo(at)] })) : null;
432
- })()] })), p.deprecated && (_jsxs("p", { class: "deprecated-strip", children: ["deprecated", p.replacedBy ? ` — replaced by ${p.replacedBy}` : ""] })), p.description && _jsx("p", { class: "description", children: p.description }), p.keywords && p.keywords.length > 0 && (_jsxs("div", { class: "keywords", "data-slot": "package-keywords", children: [p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: "chip keyword", tabIndex: -1, onClick: () => setQuery(kw), children: kw }, kw))), p.keywords.length > 5 && (_jsxs("span", { class: "chip keyword overflow", children: ["+", p.keywords.length - 5] }))] })), _jsxs("div", { class: "card-foot", children: [_jsxs("div", { class: "copy-group", children: [_jsx(CopyButton, { command: `grim add --global ${p.ref}`, variant: "global", name: `global add for ${p.name}` }), _jsx(CopyButton, { command: `grim add ${p.ref}`, name: `project add for ${p.name}` }), vscodeUrl(vscodeExtension, p.ref) && (_jsx("a", { class: "copy vscode", href: vscodeUrl(vscodeExtension, p.ref), title: "Open in VS Code", "aria-label": `Open ${p.name} in VS Code`, tabIndex: -1, children: _jsx(BrandMark, { path: mdiMicrosoftVisualStudioCode }) }))] }), p.repository && (_jsx("a", { class: "source", href: p.repository, target: "_blank", rel: "noopener noreferrer", tabIndex: -1, children: "source" }))] })] }, `${p.namespace}/${p.name}`))) }))] }));
749
+ return (_jsxs("section", { class: "catalog", "data-slot": "catalog", children: [_jsxs("div", { class: "controls", "data-slot": "catalog-toolbar", ref: controlsRef, children: [_jsxs("div", { class: "search-field", "data-slot": "catalog-search", children: [_jsx("input", { ref: searchRef, type: "search", placeholder: "Search packages \u2014 name, keyword, description\u2026", value: query, onInput: (e) => setQuery(e.target.value), onKeyDown: onSearchKeyDown, "aria-label": "Search packages", "aria-keyshortcuts": "/" }), _jsx("kbd", { class: "search-hint", "aria-hidden": "true", children: "/" })] }), _jsxs("div", { class: "filter-row", children: [_jsxs("div", { class: "chips kind-chips", role: "group", "aria-label": "Filter by kind", children: [_jsx("button", { type: "button", class: kinds.length === 0 ? "chip active" : "chip", "data-slot": "filter-chip", "aria-pressed": kinds.length === 0, onKeyDown: onChipKeyDown, onClick: () => setKinds([]), children: "all" }), kindNames.map((k) => (_jsx("button", { type: "button", class: kinds.includes(k) ? `chip active kind-${k}` : `chip kind-${k}`, "data-slot": "filter-chip", "aria-pressed": kinds.includes(k), onKeyDown: onChipKeyDown, onClick: () => toggleKind(k), children: k }, k)))] }), visibleKeywords.length > 0 && (_jsxs(_Fragment, { children: [_jsx("span", { class: "filter-divider", "aria-hidden": "true" }), _jsx("div", { class: "chips kw-rail", role: "group", "aria-label": "Filter by keyword", ref: railRef, children: visibleKeywords.map(({ keyword }, i) => {
750
+ // Past the measured fit: still laid out, so the measurement
751
+ // that decided this stays true on the next pass, but drawn
752
+ // as nothing and out of reach. Removing it from the flow
753
+ // instead would free the width that excluded it, which is
754
+ // the oscillation this shape exists to avoid.
755
+ const clipped = i >= railFit;
756
+ return (_jsx("button", { ref: (el) => {
757
+ // The FLIP effect measures whatever is in this map, so
758
+ // a chip that leaves has to leave the map with it —
759
+ // Preact calls back with null on unmount for that.
760
+ if (el)
761
+ railRefs.current.set(keyword, el);
762
+ else
763
+ railRefs.current.delete(keyword);
764
+ }, type: "button", class: [
765
+ "chip kw",
766
+ keywords.includes(keyword) ? "active" : "",
767
+ clipped ? "clipped" : "",
768
+ ]
769
+ .filter(Boolean)
770
+ .join(" "), "data-slot": "filter-chip", "aria-pressed": keywords.includes(keyword), "aria-hidden": clipped ? "true" : undefined, tabIndex: clipped ? -1 : undefined, onKeyDown: onChipKeyDown, onClick: () => toggleKeyword(keyword), children: keyword }, keyword));
771
+ }) })] })), menuKeywords.length > 0 && (_jsxs("details", { class: "kw-menu", children: [_jsxs("summary", { class: "chip", "data-slot": "filter-chip", children: ["+", menuKeywords.length, " more"] }), _jsxs("div", { class: "kw-menu-panel", children: [_jsx("input", { type: "text", class: "kw-menu-search", placeholder: "Filter keywords\u2026", "aria-label": "Filter keywords", value: keywordFilter, onInput: (e) => setKeywordFilter(e.target.value) }), _jsxs("div", { class: "kw-menu-list", children: [menuShown.map(({ keyword, count }) => (_jsxs("button", { type: "button", class: "kw-menu-item", onClick: () => toggleKeyword(keyword), children: [_jsx("span", { children: keyword }), _jsx("small", { children: count })] }, keyword))), menuShown.length === 0 && (_jsx("p", { class: "kw-menu-empty", children: "No keyword matches." }))] })] })] })), hasDeprecated && (_jsx("button", { type: "button", class: showDeprecated
772
+ ? "chip deprecated-toggle active"
773
+ : "chip deprecated-toggle", "aria-pressed": showDeprecated, title: showDeprecated
774
+ ? "Hide deprecated packages"
775
+ : "Show deprecated packages", onKeyDown: onChipKeyDown, onClick: () => setShowDeprecated((on) => !on), children: "deprecated" }))] }), _jsxs("div", { class: "meta-row", children: [_jsx("p", { class: "result-count", role: "status", "aria-atomic": "true", children: shown.length === counted.length
776
+ ? `${counted.length} packages`
777
+ : `${shown.length} of ${counted.length} packages` }), _jsxs("div", { class: "sort-group", role: "group", "aria-label": "Sort by", children: [_jsx("button", { type: "button", class: "sort-dir", "data-slot": "filter-chip", title: dir === "asc"
778
+ ? "Ascending — click for descending"
779
+ : "Descending — click for ascending", "aria-label": dir === "asc"
780
+ ? "Sorted ascending; sort descending"
781
+ : "Sorted descending; sort ascending", onClick: () => setDir((d) => (d === "asc" ? "desc" : "asc")), children: dir === "asc" ? (_jsx(ArrowUpNarrowWide, { size: 15, "aria-hidden": "true" })) : (_jsx(ArrowDownWideNarrow, { size: 15, "aria-hidden": "true" })) }), _jsxs("select", { class: "sort-field", "data-slot": "filter-chip", "aria-label": "Sort by", value: sort, onChange: (event) => {
782
+ const next = event.currentTarget
783
+ .value;
784
+ setSort(next);
785
+ // Picking a field takes that field's own direction. Carrying
786
+ // the previous one over lands the reader on "oldest first"
787
+ // because they had asked for Z→A a moment ago.
788
+ setDir(NATURAL[next]);
789
+ }, children: [_jsx("option", { value: "name", children: "name" }), _jsx("option", { value: "updated", children: "updated" }), hasRatings && _jsx("option", { value: "rating", children: "rating" })] })] }), _jsxs("div", { class: "view-toggle", role: "group", "aria-label": "Catalog view", children: [_jsx("button", { type: "button", class: view === "cards" ? "view-pick active" : "view-pick", "data-slot": "filter-chip", "aria-pressed": view === "cards", title: "Cards", "aria-label": "Show packages as cards", onClick: () => setView("cards"), children: _jsx(LayoutGrid, { size: 15, "aria-hidden": "true" }) }), _jsx("button", { type: "button", class: view === "table" ? "view-pick active" : "view-pick", "data-slot": "filter-chip", "aria-pressed": view === "table", title: "List", "aria-label": "Show packages as a list", onClick: () => setView("table"), children: _jsx(List, { size: 15, "aria-hidden": "true" }) })] })] })] }), shown.length === 0 ? (_jsx("p", { class: "empty", children: "No packages match." })) : view === "table" ? (_jsx(PackageTable, { packages: shown, hasRatings: hasRatings, onKeyDown: onCardKeyDown, rootRef: gridRef })) : (_jsx("ul", { class: "grid", ref: (el) => {
790
+ gridRef.current = el;
791
+ }, children: shown.map((p) => (_jsx(PackageCard, { pkg: p, vscodeExtension: vscodeExtension, activeKeywords: keywords, onToggleKeyword: toggleKeyword, onKeyDown: onCardKeyDown }, `${p.namespace}/${p.name}`))) }))] }));
433
792
  }