@grimoire-rs/indexer 0.5.0 → 0.5.2

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.
@@ -12,6 +12,7 @@ import {
12
12
  ArrowUpNarrowWide,
13
13
  LayoutGrid,
14
14
  List,
15
+ X,
15
16
  } from "lucide-preact";
16
17
  import { PackageCard } from "./PackageCard.js";
17
18
  import { PackageRow } from "./PackageRow.js";
@@ -41,6 +42,13 @@ export type View = "cards" | "table";
41
42
  */
42
43
  const KEYWORD_CHIP_LIMIT = 8;
43
44
 
45
+ /**
46
+ * `popovertarget` needs an id, and the catalog is a singleton on its page —
47
+ * one island, one toolbar, one overflow menu — so this is a constant rather
48
+ * than something generated per mount.
49
+ */
50
+ const KEYWORD_MENU_ID = "grim-keyword-overflow";
51
+
44
52
  /**
45
53
  * The direction each field is *worth* reading first in — A→Z for a name,
46
54
  * newest and best-liked first for the two ranked keys.
@@ -326,9 +334,71 @@ export default function Catalog({
326
334
  ];
327
335
 
328
336
  const railRefs = useRef(new Map<string, HTMLElement>());
329
- const railRects = useRef(new Map<string, DOMRect>());
337
+ /**
338
+ * Where each chip sat at the last commit, as `offsetLeft`/`offsetTop`.
339
+ *
340
+ * Layout positions, deliberately, not `getBoundingClientRect`: the offsets
341
+ * ignore transforms and page scroll, so a chip measured mid-slide reports
342
+ * the seat it is animating towards rather than the box it is drawn in this
343
+ * frame. See the FLIP effect below for why that distinction is the whole
344
+ * fix.
345
+ */
346
+ const railSeats = useRef(new Map<string, { x: number; y: number }>());
347
+ /** The slide each chip is currently running, so a new one can replace it. */
348
+ const railSlides = useRef(new WeakMap<HTMLElement, Animation>());
330
349
  const railRef = useRef<HTMLDivElement>(null);
331
350
 
351
+ const kwMenuRef = useRef<HTMLDivElement>(null);
352
+ const kwTriggerRef = useRef<HTMLButtonElement>(null);
353
+ // Drives the trigger's own `aria-expanded` and its open styling. The panel's
354
+ // visibility is the popover's business, not this flag's.
355
+ const [kwMenuOpen, setKwMenuOpen] = useState(false);
356
+
357
+ /**
358
+ * Seat the overflow menu under its trigger.
359
+ *
360
+ * The panel is a popover, so it lives in the top layer and is positioned
361
+ * against the viewport rather than against any ancestor — which is the whole
362
+ * point (`.filter-row` is a scroll container and used to crop it). That
363
+ * leaves the seat to us.
364
+ *
365
+ * It always opens downward: the toolbar sits at the top of the page, and a
366
+ * flip would only ever fire on a viewport short enough that the panel has
367
+ * nowhere to go either way. What does not fit becomes `max-height` and
368
+ * scrolls inside the list.
369
+ *
370
+ * Called twice per open, from `beforetoggle` and again from `toggle`. The
371
+ * first runs while the panel is still `display: none`, so its measured width
372
+ * is 0 and the horizontal clamp is a no-op — but the vertical seat is right,
373
+ * which is what stops it appearing in the wrong place for a frame. The
374
+ * second has a real width and finishes the clamp.
375
+ */
376
+ const placeKwMenu = () => {
377
+ const panel = kwMenuRef.current;
378
+ const trigger = kwTriggerRef.current;
379
+ if (!panel || !trigger) return;
380
+ const seat = trigger.getBoundingClientRect();
381
+ const gap = 8;
382
+ const width = panel.getBoundingClientRect().width;
383
+ panel.style.left = `${Math.max(gap, Math.min(seat.left, window.innerWidth - width - gap))}px`;
384
+ panel.style.top = `${seat.bottom + gap}px`;
385
+ panel.style.maxHeight = `${Math.max(120, window.innerHeight - seat.bottom - gap * 3)}px`;
386
+ };
387
+
388
+ // A fixed panel does not travel with the trigger, so it is re-seated rather
389
+ // than left behind. Only while open — there is nothing to follow otherwise.
390
+ useEffect(() => {
391
+ if (!kwMenuOpen) return;
392
+ const reseat = () => placeKwMenu();
393
+ // Capturing: the scroll may be any ancestor's, including `.filter-row`'s.
394
+ window.addEventListener("scroll", reseat, { capture: true, passive: true });
395
+ window.addEventListener("resize", reseat);
396
+ return () => {
397
+ window.removeEventListener("scroll", reseat, { capture: true });
398
+ window.removeEventListener("resize", reseat);
399
+ };
400
+ }, [kwMenuOpen]);
401
+
332
402
  /**
333
403
  * How many keyword chips actually fit on the row, measured.
334
404
  *
@@ -391,39 +461,79 @@ export default function Catalog({
391
461
  * between two frames reads as having been *replaced*, and a reader who
392
462
  * cannot see that a chip moved has no reason to believe it is the same one.
393
463
  *
394
- * First (the map of rects kept from the last commit), Last (measured now),
395
- * Invert (an inline translate back to where the chip was), Play (dropped on
396
- * the next frame, so the stylesheet's transition carries it home). Measure
397
- * every chip before transforming any: `translate` composites and does not
398
- * reflow, but reading a rect after writing a style on a sibling is the
399
- * shape that makes a layout thrash, and this runs per keystroke.
464
+ * Two choices carry the whole thing, and both are here because the shape
465
+ * they replace — an inline `translate` under the stylesheet's transition,
466
+ * dropped again on a `requestAnimationFrame` could leave a chip stopped
467
+ * off its seat with no path back:
468
+ *
469
+ * - **Seats come from `offsetLeft`/`offsetTop`, never from
470
+ * `getBoundingClientRect`.** The offsets are layout positions and ignore
471
+ * both transforms and scroll; a rect is where the chip is *drawn*, so a
472
+ * chip measured mid-slide recorded its animated box and the next
473
+ * inversion compounded that error — the stutter. Mid-slide is the common
474
+ * case, not a rare one: the fit measurement above commits a second time
475
+ * whenever a rescore changes how many chips fit, and that commit lands
476
+ * inside the previous slide. Because the offsets are transform-blind,
477
+ * that second commit now measures the same seats and starts nothing.
478
+ * - **The slide is a Web Animation, not an inline style.** It needs no
479
+ * frame to start, so no commit can land between an invert and its play
480
+ * and freeze a chip at the offset. It writes nothing to `style`, so
481
+ * there is nothing left to strip when a chip unmounts or a pass is
482
+ * superseded. And it clears itself the instant it finishes or is
483
+ * cancelled, which makes "the rail always ends up in its real state" a
484
+ * property of the mechanism rather than of a cleanup remembering to run.
400
485
  */
401
486
  useLayoutEffect(() => {
402
- const previous = railRects.current;
403
- const current = new Map<string, DOMRect>();
487
+ const previous = railSeats.current;
488
+ const current = new Map<string, { x: number; y: number }>();
404
489
  const moved: { el: HTMLElement; dx: number; dy: number }[] = [];
405
490
  for (const [keyword, el] of railRefs.current) {
406
- const rect = el.getBoundingClientRect();
407
- current.set(keyword, rect);
491
+ const x = el.offsetLeft;
492
+ const y = el.offsetTop;
493
+ current.set(keyword, { x, y });
408
494
  const was = previous.get(keyword);
409
495
  if (!was) continue;
410
- const dx = was.left - rect.left;
411
- const dy = was.top - rect.top;
496
+ const dx = was.x - x;
497
+ const dy = was.y - y;
412
498
  if (dx !== 0 || dy !== 0) moved.push({ el, dx, dy });
413
499
  }
414
- railRects.current = current;
500
+ // Rebuilt from `railRefs` every pass, so a chip that left the rail leaves
501
+ // this map with it and cannot seed a slide if it comes back elsewhere.
502
+ railSeats.current = current;
415
503
  if (moved.length === 0) return;
504
+ const rail = railRef.current;
505
+ // Guarded rather than assumed, like the fit observer above: the test
506
+ // renderer's DOM has no Web Animations API, and a rail that does not
507
+ // slide is not worth throwing during a render over.
508
+ if (!rail || typeof rail.animate !== "function") return;
509
+ // The motion is the ornament here — the filtering works identically
510
+ // without it — so a reader who asked for less of it gets none.
511
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
512
+ // The stylesheet stays the source of truth for how long a slide runs:
513
+ // `--grim-duration-slow` is written in milliseconds and that is the unit
514
+ // the Web Animations API takes, so the token survives the move out of CSS.
515
+ // Read once, after every seat above, so the reads and the writes below
516
+ // stay in two passes rather than interleaving per chip.
517
+ const ms = Number.parseFloat(
518
+ getComputedStyle(rail).getPropertyValue("--grim-duration-slow"),
519
+ );
416
520
  for (const { el, dx, dy } of moved) {
417
- el.style.transition = "none";
418
- el.style.translate = `${dx}px ${dy}px`;
521
+ // One slide per chip. A chip that moves again mid-slide takes the new
522
+ // delta from its layout seat, which starts the second slide a step off
523
+ // where the first had drawn it — a jump measured in the pixels one
524
+ // frame of easing covers, and it is bounded, unlike two animations
525
+ // compositing the same property against each other.
526
+ // ponytail: not blended with the in-flight offset, which would cost a
527
+ // computed-style read per chip in the middle of the write pass.
528
+ railSlides.current.get(el)?.cancel();
529
+ railSlides.current.set(
530
+ el,
531
+ el.animate(
532
+ { translate: [`${dx}px ${dy}px`, "none"] },
533
+ { duration: Number.isFinite(ms) ? ms : 200, easing: "ease-out" },
534
+ ),
535
+ );
419
536
  }
420
- const frame = requestAnimationFrame(() => {
421
- for (const { el } of moved) {
422
- el.style.transition = "";
423
- el.style.translate = "";
424
- }
425
- });
426
- return () => cancelAnimationFrame(frame);
427
537
  });
428
538
 
429
539
  /** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
@@ -961,14 +1071,42 @@ export default function Catalog({
961
1071
  // catalog's keyword vocabulary is open-ended, and a few hundred
962
1072
  // chips at once is a wall, not a control.
963
1073
  //
964
- // A `<details>` for the same reason the platform picker and the
965
- // version menus are: it opens, closes and takes Escape on its
966
- // own, with no popover library and no script.
967
- <details class="kw-menu">
968
- <summary class="chip" data-slot="filter-chip">
1074
+ // A popover, and not by preference: the panel used to be an
1075
+ // absolutely-positioned child of `.filter-row`, which is a scroll
1076
+ // container, so it was cropped to the row and stretched the row's
1077
+ // scroll extent — an invisible menu and two stray scrollbars. The
1078
+ // top layer is outside every ancestor's `overflow`. Escape and
1079
+ // light dismiss come with it; only the seat is ours to compute,
1080
+ // and that is `placeKwMenu` above.
1081
+ <div class="kw-menu">
1082
+ <button
1083
+ type="button"
1084
+ class="chip"
1085
+ data-slot="filter-chip"
1086
+ ref={kwTriggerRef}
1087
+ popovertarget={KEYWORD_MENU_ID}
1088
+ aria-expanded={kwMenuOpen}
1089
+ >
969
1090
  +{menuKeywords.length} more
970
- </summary>
971
- <div class="kw-menu-panel">
1091
+ </button>
1092
+ <div
1093
+ class="kw-menu-panel"
1094
+ id={KEYWORD_MENU_ID}
1095
+ popover="auto"
1096
+ ref={kwMenuRef}
1097
+ // Both, and in this order: `beforetoggle` runs synchronously
1098
+ // inside the show steps, so the panel is seated before it is
1099
+ // ever painted; `toggle` runs after, when its width can
1100
+ // actually be measured for the clamp.
1101
+ onBeforeToggle={(e) => {
1102
+ setKwMenuOpen(e.newState === "open");
1103
+ placeKwMenu();
1104
+ }}
1105
+ onToggle={(e) => {
1106
+ setKwMenuOpen(e.newState === "open");
1107
+ placeKwMenu();
1108
+ }}
1109
+ >
972
1110
  <input
973
1111
  type="text"
974
1112
  class="kw-menu-search"
@@ -996,7 +1134,38 @@ export default function Catalog({
996
1134
  )}
997
1135
  </div>
998
1136
  </div>
999
- </details>
1137
+ </div>
1138
+ )}
1139
+ {keywords.length > 0 && (
1140
+ // Lifts every keyword at once. Only rendered while there is
1141
+ // something to lift — a permanently visible control that does
1142
+ // nothing most of the time is a chip's width spent on nothing, and
1143
+ // this row is already the one that runs out of room first.
1144
+ //
1145
+ // Keywords only, deliberately. Escape clears the search and the
1146
+ // kinds along with them, and a button that quietly did the same
1147
+ // would undo a filter the reader did not ask about; the label
1148
+ // names exactly what it lifts.
1149
+ <button
1150
+ type="button"
1151
+ class="chip kw-clear"
1152
+ data-slot="filter-chip"
1153
+ title="Clear the keyword filters"
1154
+ onKeyDown={onChipKeyDown}
1155
+ onClick={(e) => {
1156
+ setKeywords([]);
1157
+ // This button is the last thing standing when it is pressed:
1158
+ // clearing the facets unmounts it, and focus would land on
1159
+ // `<body>`, sending a keyboard reader back to the top of the
1160
+ // document. `detail === 0` is a click synthesized by Enter or
1161
+ // Space, so a pointer user is left alone and a keyboard one
1162
+ // gets the toolbar's own anchor instead of nothing.
1163
+ if (e.detail === 0) searchRef.current?.focus();
1164
+ }}
1165
+ >
1166
+ <X size={13} aria-hidden="true" />
1167
+ clear {keywords.length}
1168
+ </button>
1000
1169
  )}
1001
1170
  {hasDeprecated && (
1002
1171
  // A toggle, not a filter: `aria-pressed` rather than the `active`
@@ -0,0 +1,74 @@
1
+ ---
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Copyright 2026 The Grimoire Authors
4
+
5
+ // A highlighted code block wearing the site's own copy affordance, and
6
+ // optionally a link out to VS Code.
7
+ //
8
+ // It exists for the same reason `CommandBar.astro` does: an index's own page
9
+ // under `theme/pages/` should be able to draw what the rest of the site draws
10
+ // rather than a `<pre>` that looks almost like it. A setup guide showing a
11
+ // config snippet, a publish guide showing a workflow — both want this, and
12
+ // neither wants to restate the Shiki theme pair or the copy markup.
13
+ //
14
+ // ---
15
+ // import CodeBlock from "@grim/components/CodeBlock.astro";
16
+ // import { vscodeUrl } from "@grim/lib/catalog";
17
+ // import { data } from "@grim/lib/data";
18
+ // ---
19
+ // <CodeBlock code="grim add acme/code-review" name="add command"
20
+ // vscodeHref={vscodeUrl(data.config.vscodeExtension, "acme/code-review")}
21
+ // vscodeLabel="Open in VS Code" />
22
+ //
23
+ // The copy button itself is NOT rendered here. `Base.astro` injects one into
24
+ // every code block on the page — it has to, because a block inside rendered
25
+ // markdown has no markup to hang one on — and it fills the `.code-actions`
26
+ // slot below when it finds it. One button, one clipboard path, one toast, and
27
+ // no second copy of the markup to drift.
28
+ import { Code } from "astro:components";
29
+ import { mdiMicrosoftVisualStudioCode } from "@mdi/js";
30
+ import { BrandMark } from "./BrandMark.tsx";
31
+ import { SHIKI_THEMES } from "../lib/code";
32
+
33
+ interface Props {
34
+ /** The snippet, verbatim. What the copy button copies. */
35
+ code: string;
36
+ /** Shiki language id. `sh` covers the commands this site mostly shows. */
37
+ lang?: string;
38
+ /** What the copy toast calls this, e.g. `"add command"`. */
39
+ name?: string;
40
+ /**
41
+ * Any URL for the trailing button — a `vscode://` deep link from
42
+ * `@grim/lib/catalog`'s builders, or one written by hand. Null or omitted
43
+ * draws no button, which is what an index with `vscodeExtension: null` gets
44
+ * from those builders.
45
+ */
46
+ vscodeHref?: string | null;
47
+ /** Accessible name for that button. */
48
+ vscodeLabel?: string;
49
+ }
50
+
51
+ const {
52
+ code,
53
+ lang = "sh",
54
+ name,
55
+ vscodeHref = null,
56
+ vscodeLabel = "Open in VS Code",
57
+ } = Astro.props;
58
+ ---
59
+
60
+ <div class="code-block" data-slot="code-block" data-copy-name={name}>
61
+ <Code code={code} lang={lang} themes={SHIKI_THEMES} />
62
+ {/* Present even when empty: it is where `Base.astro` puts the copy button,
63
+ and a block that renders one before the script runs would flash a second
64
+ one after it. */}
65
+ <span class="code-actions">
66
+ {
67
+ vscodeHref && (
68
+ <a class="code-vscode" href={vscodeHref} title={vscodeLabel} aria-label={vscodeLabel}>
69
+ <BrandMark path={mdiMicrosoftVisualStudioCode} size={15} />
70
+ </a>
71
+ )
72
+ }
73
+ </span>
74
+ </div>
@@ -38,7 +38,7 @@ export function PackageCard({ pkg: p, vscodeExtension, activeKeywords, onToggleK
38
38
  const thread = externalUrl(p.rating.url);
39
39
  const vote = vscodeVoteUrl(vscodeExtension, p.ref);
40
40
  return (_jsxs("span", { class: "rating-group", children: [thread ? (_jsxs("a", { class: "rating-count", href: thread, target: "_blank", rel: "noopener noreferrer", tabIndex: -1, title: `${votes} — open the thread to vote`, "aria-label": `${p.name}: ${votes}. Open the voting thread`, children: [_jsx(ArrowBigUp, { size: 13, "aria-hidden": "true" }), p.rating.up] })) : (_jsxs("span", { class: "rating-count", title: votes, children: [_jsx(ArrowBigUp, { size: 13, "aria-hidden": "true" }), p.rating.up] })), vote && (_jsx("a", { class: "rating-vote", href: vote, tabIndex: -1, title: "Upvote in VS Code", "aria-label": `Upvote ${p.name} in VS Code`, children: _jsx(BrandMark, { path: mdiMicrosoftVisualStudioCode, size: 12 }) }))] }));
41
- })(), _jsxs("p", { class: "namespace", children: [_jsx("span", { class: "kind", "data-slot": "package-kind", children: p.kind }), _jsx("span", { "aria-hidden": "true", children: " \u00B7 " }), p.namespace] })] }), p.keywords && p.keywords.length > 0 && (_jsx("div", { class: "keywords", "data-slot": "package-keywords", children: p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: activeKeywords.includes(kw)
41
+ })(), _jsxs("p", { class: "namespace", children: [_jsx("span", { class: "kind", "data-slot": "package-kind", children: p.kind }), _jsx("span", { "aria-hidden": "true", children: " \u00B7 " }), _jsx("span", { class: "address", title: p.namespace, children: p.namespace })] })] }), p.keywords && p.keywords.length > 0 && (_jsx("div", { class: "keywords", "data-slot": "package-keywords", children: p.keywords.slice(0, 5).map((kw) => (_jsx("button", { type: "button", class: activeKeywords.includes(kw)
42
42
  ? "chip keyword active"
43
43
  : "chip keyword", "aria-pressed": activeKeywords.includes(kw), tabIndex: -1, onClick: () => onToggleKeyword(kw), children: kw }, kw))) })), p.description && _jsx("p", { class: "description", children: p.description }), _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 }) }))] }), (() => {
44
44
  const at = lastUpdated(p);
@@ -175,7 +175,15 @@ export function PackageCard({
175
175
  {p.kind}
176
176
  </span>
177
177
  <span aria-hidden="true"> · </span>
178
- {p.namespace}
178
+ {/* Its own element so the row can give the address the slack and
179
+ nothing else: the kind is one short word and keeps its width,
180
+ and what does not fit is dropped off the FRONT — a registry
181
+ host is the least distinguishing part of an address and the
182
+ repository is the most. `title` keeps the whole of it
183
+ reachable, since the ellipsis hides the head. */}
184
+ <span class="address" title={p.namespace}>
185
+ {p.namespace}
186
+ </span>
179
187
  </p>
180
188
  </div>
181
189
  {/* Under the head, above the description: what the package