@grimoire-rs/indexer 0.5.1 → 0.5.3
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.
- package/CHANGELOG.md +127 -1
- package/dist/renderer/astro/components/CardLogo.d.ts +2 -2
- package/dist/renderer/astro/components/CardLogo.tsx +2 -2
- package/dist/renderer/astro/components/Catalog.d.ts +12 -4
- package/dist/renderer/astro/components/Catalog.js +356 -88
- package/dist/renderer/astro/components/Catalog.tsx +442 -108
- package/dist/renderer/astro/components/PackageCard.d.ts +2 -2
- package/dist/renderer/astro/components/PackageCard.js +1 -1
- package/dist/renderer/astro/components/PackageCard.tsx +11 -3
- package/dist/renderer/astro/components/PackageRow.d.ts +2 -2
- package/dist/renderer/astro/components/PackageRow.tsx +2 -2
- package/dist/renderer/astro/layouts/Base.astro +145 -51
- package/dist/renderer/astro/lib/catalog.d.ts +31 -1
- package/dist/renderer/astro/lib/catalog.js +32 -0
- package/dist/renderer/astro/lib/catalog.ts +73 -1
- package/dist/renderer/astro/lib/search.d.ts +17 -0
- package/dist/renderer/astro/lib/search.js +138 -0
- package/dist/renderer/astro/lib/search.ts +157 -0
- package/dist/renderer/astro/pages/index.astro +45 -2
- package/dist/renderer/astro/styles/tokens.css +6 -1
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +52 -0
- package/dist/renderer/index.js.map +1 -1
- package/package.json +6 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
useCallback,
|
|
2
3
|
useEffect,
|
|
3
4
|
useLayoutEffect,
|
|
4
5
|
useMemo,
|
|
@@ -17,7 +18,13 @@ import {
|
|
|
17
18
|
import { PackageCard } from "./PackageCard.js";
|
|
18
19
|
import { PackageRow } from "./PackageRow.js";
|
|
19
20
|
import { keywordFrequency, selectRailKeywords } from "../lib/keywordRail.js";
|
|
20
|
-
import { lastUpdated, type
|
|
21
|
+
import { lastUpdated, type CardPackage } from "../lib/catalog.js";
|
|
22
|
+
import { withBase } from "../lib/base.js";
|
|
23
|
+
// TYPE-ONLY, and it has to stay that way: `../lib/search.js` is the only
|
|
24
|
+
// module that pulls `fuzzysort` in, and it is loaded with `await import()`
|
|
25
|
+
// below so neither reaches a reader who never types. A value import here
|
|
26
|
+
// would fold both back into the island's own chunk.
|
|
27
|
+
import type { Scores, SearchIndex } from "../lib/search.js";
|
|
21
28
|
|
|
22
29
|
// Known kinds get stable chip ordering + badge colors; unknown kinds
|
|
23
30
|
// (future schema growth) still render with a neutral badge.
|
|
@@ -28,7 +35,15 @@ function kindOrder(kind: string): number {
|
|
|
28
35
|
return i === -1 ? KNOWN_KINDS.length : i;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
|
|
38
|
+
/**
|
|
39
|
+
* `relevance` orders by how well each package answered the query on screen,
|
|
40
|
+
* where the other three order by something every package carries. It is
|
|
41
|
+
* offered and stored like any of them even so: with no query it has nothing
|
|
42
|
+
* to rank, and `CHAINS.relevance` answers that with alphabetical — the order
|
|
43
|
+
* the catalog opens on anyway — so a reader can leave the catalog set to it
|
|
44
|
+
* and have every later search come back ranked without touching the control.
|
|
45
|
+
*/
|
|
46
|
+
export type Sort = "name" | "updated" | "rating" | "relevance";
|
|
32
47
|
export type Dir = "asc" | "desc";
|
|
33
48
|
/** Roomy cards, or the same packages as a scannable list. */
|
|
34
49
|
export type View = "cards" | "table";
|
|
@@ -42,6 +57,11 @@ export type View = "cards" | "table";
|
|
|
42
57
|
*/
|
|
43
58
|
const KEYWORD_CHIP_LIMIT = 8;
|
|
44
59
|
|
|
60
|
+
/**
|
|
61
|
+
* How many packages the catalog builds at a time. See `limit` in `Catalog`.
|
|
62
|
+
*/
|
|
63
|
+
const WINDOW = 48;
|
|
64
|
+
|
|
45
65
|
/**
|
|
46
66
|
* `popovertarget` needs an id, and the catalog is a singleton on its page —
|
|
47
67
|
* one island, one toolbar, one overflow menu — so this is a constant rather
|
|
@@ -62,9 +82,10 @@ export const NATURAL: Record<Sort, Dir> = {
|
|
|
62
82
|
name: "asc",
|
|
63
83
|
updated: "desc",
|
|
64
84
|
rating: "desc",
|
|
85
|
+
relevance: "desc",
|
|
65
86
|
};
|
|
66
87
|
|
|
67
|
-
type Key = (a:
|
|
88
|
+
type Key = (a: CardPackage, b: CardPackage) => number;
|
|
68
89
|
|
|
69
90
|
/**
|
|
70
91
|
* Bigger first, with `null` as its own bucket underneath every number.
|
|
@@ -80,7 +101,7 @@ function descending(a: number | null, b: number | null): number {
|
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
/** `updated` as epoch ms; null when absent, empty or not a date at all. */
|
|
83
|
-
function updatedAt(p:
|
|
104
|
+
function updatedAt(p: CardPackage): number | null {
|
|
84
105
|
const at = lastUpdated(p);
|
|
85
106
|
const ms = at ? new Date(at).getTime() : NaN;
|
|
86
107
|
return Number.isFinite(ms) ? ms : null;
|
|
@@ -115,6 +136,16 @@ const CHAINS: Record<Sort, Key[]> = {
|
|
|
115
136
|
name: [byName],
|
|
116
137
|
updated: [byUpdated, byName],
|
|
117
138
|
rating: [byRating, byUpdated, byName],
|
|
139
|
+
// Relevance cannot be a key here: a score belongs to a query, not to a
|
|
140
|
+
// package, so it is not on the record `compare` is handed. `shown` sorts
|
|
141
|
+
// that mode itself.
|
|
142
|
+
//
|
|
143
|
+
// This entry is what the mode falls back to whenever there are no scores —
|
|
144
|
+
// no query typed, or the fuzzy index still downloading — which is also why
|
|
145
|
+
// relevance can be offered and stored like any other mode. Alphabetical is
|
|
146
|
+
// the honest answer to "rank these against nothing", and it is what the
|
|
147
|
+
// catalog already shows on arrival.
|
|
148
|
+
relevance: [byName],
|
|
118
149
|
};
|
|
119
150
|
|
|
120
151
|
// Deprecated packages get no special ordering here — they are filtered out
|
|
@@ -123,8 +154,8 @@ const CHAINS: Record<Sort, Key[]> = {
|
|
|
123
154
|
// (`browse_sort.rs`) has no deprecated key either; keeping this comparator
|
|
124
155
|
// silent on deprecation is what keeps the two in sync.
|
|
125
156
|
export function compare(
|
|
126
|
-
a:
|
|
127
|
-
b:
|
|
157
|
+
a: CardPackage,
|
|
158
|
+
b: CardPackage,
|
|
128
159
|
sort: Sort,
|
|
129
160
|
dir: Dir = NATURAL[sort],
|
|
130
161
|
): number {
|
|
@@ -244,7 +275,7 @@ function PackageTable({
|
|
|
244
275
|
onKeyDown,
|
|
245
276
|
rootRef,
|
|
246
277
|
}: {
|
|
247
|
-
packages:
|
|
278
|
+
packages: CardPackage[];
|
|
248
279
|
hasRatings: boolean;
|
|
249
280
|
onKeyDown: (event: KeyboardEvent) => void;
|
|
250
281
|
rootRef: { current: HTMLElement | null };
|
|
@@ -276,7 +307,7 @@ export default function Catalog({
|
|
|
276
307
|
packages,
|
|
277
308
|
vscodeExtension,
|
|
278
309
|
}: {
|
|
279
|
-
packages:
|
|
310
|
+
packages: CardPackage[];
|
|
280
311
|
vscodeExtension: string | null;
|
|
281
312
|
}) {
|
|
282
313
|
// Empty on the first render, ALWAYS — `?q=…` is applied a beat later, in
|
|
@@ -313,8 +344,14 @@ export default function Catalog({
|
|
|
313
344
|
// Local to the overflow menu and deliberately not shareable: it narrows
|
|
314
345
|
// the list of keywords, not the catalog.
|
|
315
346
|
const [keywordFilter, setKeywordFilter] = useState("");
|
|
347
|
+
// The fuzzy matcher, once it has been fetched. `null` is the normal state
|
|
348
|
+
// for most of a visit — see the loader below — and every consumer treats
|
|
349
|
+
// it as "fall back to the substring filter", never as an error.
|
|
350
|
+
const [index, setIndex] = useState<SearchIndex | null>(null);
|
|
316
351
|
|
|
317
352
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
353
|
+
/** Whether the sort combo's last interaction came from a pointer. */
|
|
354
|
+
const pickedByPointer = useRef(false);
|
|
318
355
|
const gridRef = useRef<HTMLElement>(null);
|
|
319
356
|
const controlsRef = useRef<HTMLDivElement>(null);
|
|
320
357
|
|
|
@@ -334,8 +371,40 @@ export default function Catalog({
|
|
|
334
371
|
];
|
|
335
372
|
|
|
336
373
|
const railRefs = useRef(new Map<string, HTMLElement>());
|
|
337
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Where each chip sat at the last commit, as `offsetLeft`/`offsetTop`.
|
|
376
|
+
*
|
|
377
|
+
* Layout positions, deliberately, not `getBoundingClientRect`: the offsets
|
|
378
|
+
* ignore transforms and page scroll, so a chip measured mid-slide reports
|
|
379
|
+
* the seat it is animating towards rather than the box it is drawn in this
|
|
380
|
+
* frame. See the FLIP effect below for why that distinction is the whole
|
|
381
|
+
* fix.
|
|
382
|
+
*/
|
|
383
|
+
const railSeats = useRef(new Map<string, { x: number; y: number }>());
|
|
384
|
+
/** The slide each chip is currently running, so a new one can replace it. */
|
|
385
|
+
const railSlides = useRef(new WeakMap<HTMLElement, Animation>());
|
|
338
386
|
const railRef = useRef<HTMLDivElement>(null);
|
|
387
|
+
/**
|
|
388
|
+
* Which chips are up, in order, as of the current render — written below,
|
|
389
|
+
* once `visibleKeywords` exists, and read by the two layout effects.
|
|
390
|
+
*
|
|
391
|
+
* Both of those effects measure geometry, and a geometry read forces a
|
|
392
|
+
* synchronous style and layout pass over the WHOLE document. Neither had a
|
|
393
|
+
* dependency array, so both ran on every commit — including a commit that
|
|
394
|
+
* changed no chip at all. Switching to the list view is that commit, and at
|
|
395
|
+
* 500 packages it paid two forced layouts over several hundred rows Preact
|
|
396
|
+
* had just mounted: a 600ms task, most of it in `ForcedStyleAndLayout`.
|
|
397
|
+
*
|
|
398
|
+
* A ref rather than a dependency array because the chip list is computed
|
|
399
|
+
* hundreds of lines below these hooks, and a value cannot be a dependency
|
|
400
|
+
* before it exists. Writing a ref during render is safe — it is not state,
|
|
401
|
+
* nothing re-renders from it, and an effect body always runs after the
|
|
402
|
+
* render that wrote it.
|
|
403
|
+
*/
|
|
404
|
+
const railSignature = useRef("");
|
|
405
|
+
/** What `railSignature` was when each effect last did its reads. */
|
|
406
|
+
const railMeasured = useRef<string | null>(null);
|
|
407
|
+
const railFlipped = useRef<string | null>(null);
|
|
339
408
|
|
|
340
409
|
const kwMenuRef = useRef<HTMLDivElement>(null);
|
|
341
410
|
const kwTriggerRef = useRef<HTMLButtonElement>(null);
|
|
@@ -409,35 +478,56 @@ export default function Catalog({
|
|
|
409
478
|
*/
|
|
410
479
|
const [railFit, setRailFit] = useState(KEYWORD_CHIP_LIMIT);
|
|
411
480
|
|
|
412
|
-
|
|
481
|
+
const measureRail = useCallback(() => {
|
|
413
482
|
const rail = railRef.current;
|
|
414
483
|
if (!rail) return;
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
484
|
+
const edge = rail.getBoundingClientRect().right;
|
|
485
|
+
let fits = 0;
|
|
486
|
+
for (const chip of rail.children) {
|
|
487
|
+
// Half a pixel of slack: a fractional layout can leave a chip's right
|
|
488
|
+
// edge a rounding error past a boundary it visually sits inside.
|
|
489
|
+
if (chip.getBoundingClientRect().right > edge + 0.5) break;
|
|
490
|
+
fits += 1;
|
|
491
|
+
}
|
|
492
|
+
// At least one, always. A rail too narrow for its shortest chip should
|
|
493
|
+
// show that chip clipped rather than render an empty group beside a
|
|
494
|
+
// divider that then divides nothing.
|
|
495
|
+
setRailFit(Math.max(1, fits));
|
|
496
|
+
}, []);
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The rail's own `ref`, so the resize observer is attached exactly when the
|
|
500
|
+
* element exists. It is conditionally rendered — no keywords, no rail — and
|
|
501
|
+
* a `[]`-dependency effect would miss it appearing later.
|
|
502
|
+
*
|
|
503
|
+
* This also stops the observer being torn down and rebuilt on every commit,
|
|
504
|
+
* which was not free: `observe()` delivers an initial callback, so a rebuild
|
|
505
|
+
* per commit meant a measurement per commit no matter what gated the effect.
|
|
506
|
+
*
|
|
507
|
+
* Guarded rather than assumed: this runs under the test renderer too, whose
|
|
508
|
+
* DOM has no `ResizeObserver` — and a missing one costs only re-measurement
|
|
509
|
+
* on viewport resize, which is not worth throwing during a render over.
|
|
510
|
+
*/
|
|
511
|
+
const railObserver = useRef<ResizeObserver | null>(null);
|
|
512
|
+
const attachRail = useCallback(
|
|
513
|
+
(el: HTMLDivElement | null) => {
|
|
514
|
+
railRef.current = el;
|
|
515
|
+
railObserver.current?.disconnect();
|
|
516
|
+
railObserver.current = null;
|
|
517
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
518
|
+
railObserver.current = new ResizeObserver(measureRail);
|
|
519
|
+
railObserver.current.observe(el);
|
|
520
|
+
},
|
|
521
|
+
[measureRail],
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// Re-measured when the chips change, and only then — a rescore can swap a
|
|
525
|
+
// short word for a long one at the same width, which the observer above
|
|
526
|
+
// would never see. See `railSignature` for why the guard is a ref.
|
|
527
|
+
useLayoutEffect(() => {
|
|
528
|
+
if (railSignature.current === railMeasured.current) return;
|
|
529
|
+
railMeasured.current = railSignature.current;
|
|
530
|
+
measureRail();
|
|
441
531
|
});
|
|
442
532
|
|
|
443
533
|
/**
|
|
@@ -450,68 +540,91 @@ export default function Catalog({
|
|
|
450
540
|
* between two frames reads as having been *replaced*, and a reader who
|
|
451
541
|
* cannot see that a chip moved has no reason to believe it is the same one.
|
|
452
542
|
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
543
|
+
* Two choices carry the whole thing, and both are here because the shape
|
|
544
|
+
* they replace — an inline `translate` under the stylesheet's transition,
|
|
545
|
+
* dropped again on a `requestAnimationFrame` — could leave a chip stopped
|
|
546
|
+
* off its seat with no path back:
|
|
547
|
+
*
|
|
548
|
+
* - **Seats come from `offsetLeft`/`offsetTop`, never from
|
|
549
|
+
* `getBoundingClientRect`.** The offsets are layout positions and ignore
|
|
550
|
+
* both transforms and scroll; a rect is where the chip is *drawn*, so a
|
|
551
|
+
* chip measured mid-slide recorded its animated box and the next
|
|
552
|
+
* inversion compounded that error — the stutter. Mid-slide is the common
|
|
553
|
+
* case, not a rare one: the fit measurement above commits a second time
|
|
554
|
+
* whenever a rescore changes how many chips fit, and that commit lands
|
|
555
|
+
* inside the previous slide. Because the offsets are transform-blind,
|
|
556
|
+
* that second commit now measures the same seats and starts nothing.
|
|
557
|
+
* - **The slide is a Web Animation, not an inline style.** It needs no
|
|
558
|
+
* frame to start, so no commit can land between an invert and its play
|
|
559
|
+
* and freeze a chip at the offset. It writes nothing to `style`, so
|
|
560
|
+
* there is nothing left to strip when a chip unmounts or a pass is
|
|
561
|
+
* superseded. And it clears itself the instant it finishes or is
|
|
562
|
+
* cancelled, which makes "the rail always ends up in its real state" a
|
|
563
|
+
* property of the mechanism rather than of a cleanup remembering to run.
|
|
459
564
|
*/
|
|
460
565
|
useLayoutEffect(() => {
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
// reports the *translated* box, so a chip caught mid-slide would be
|
|
464
|
-
// measured where it is drawn rather than where it belongs and the next
|
|
465
|
-
// inversion would compound that error; and a chip whose play frame never
|
|
466
|
-
// ran is still carrying `transition: none` with an offset, which is a chip
|
|
467
|
-
// frozen off its seat. Clearing here is what unfreezes it.
|
|
566
|
+
// Same guard as the fit measurement, for the same reason: `offsetLeft`
|
|
567
|
+
// forces layout, and a commit that moved no chip has nothing to animate.
|
|
468
568
|
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
569
|
+
// The seats it leaves behind are therefore from the last chip change
|
|
570
|
+
// rather than from the last commit, which is what FLIP wants. The one
|
|
571
|
+
// case that costs: a viewport resize between two chip changes moves the
|
|
572
|
+
// chips without changing the signature, so the next slide starts from
|
|
573
|
+
// pre-resize seats. One slightly-off slide after a resize, against two
|
|
574
|
+
// forced layouts on every commit — including every keystroke and every
|
|
575
|
+
// view switch — which is the trade this makes.
|
|
576
|
+
if (railSignature.current === railFlipped.current) return;
|
|
577
|
+
railFlipped.current = railSignature.current;
|
|
578
|
+
const previous = railSeats.current;
|
|
579
|
+
const current = new Map<string, { x: number; y: number }>();
|
|
478
580
|
const moved: { el: HTMLElement; dx: number; dy: number }[] = [];
|
|
479
|
-
// A second pass, deliberately: every write above is flushed before the
|
|
480
|
-
// first read below, rather than interleaving them per chip.
|
|
481
581
|
for (const [keyword, el] of railRefs.current) {
|
|
482
|
-
const
|
|
483
|
-
|
|
582
|
+
const x = el.offsetLeft;
|
|
583
|
+
const y = el.offsetTop;
|
|
584
|
+
current.set(keyword, { x, y });
|
|
484
585
|
const was = previous.get(keyword);
|
|
485
586
|
if (!was) continue;
|
|
486
|
-
const dx = was.
|
|
487
|
-
const dy = was.
|
|
587
|
+
const dx = was.x - x;
|
|
588
|
+
const dy = was.y - y;
|
|
488
589
|
if (dx !== 0 || dy !== 0) moved.push({ el, dx, dy });
|
|
489
590
|
}
|
|
490
|
-
|
|
591
|
+
// Rebuilt from `railRefs` every pass, so a chip that left the rail leaves
|
|
592
|
+
// this map with it and cannot seed a slide if it comes back elsewhere.
|
|
593
|
+
railSeats.current = current;
|
|
491
594
|
if (moved.length === 0) return;
|
|
595
|
+
const rail = railRef.current;
|
|
596
|
+
// Guarded rather than assumed, like the fit observer above: the test
|
|
597
|
+
// renderer's DOM has no Web Animations API, and a rail that does not
|
|
598
|
+
// slide is not worth throwing during a render over.
|
|
599
|
+
if (!rail || typeof rail.animate !== "function") return;
|
|
600
|
+
// The motion is the ornament here — the filtering works identically
|
|
601
|
+
// without it — so a reader who asked for less of it gets none.
|
|
602
|
+
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
|
603
|
+
// The stylesheet stays the source of truth for how long a slide runs:
|
|
604
|
+
// `--grim-duration-slow` is written in milliseconds and that is the unit
|
|
605
|
+
// the Web Animations API takes, so the token survives the move out of CSS.
|
|
606
|
+
// Read once, after every seat above, so the reads and the writes below
|
|
607
|
+
// stay in two passes rather than interleaving per chip.
|
|
608
|
+
const ms = Number.parseFloat(
|
|
609
|
+
getComputedStyle(rail).getPropertyValue("--grim-duration-slow"),
|
|
610
|
+
);
|
|
492
611
|
for (const { el, dx, dy } of moved) {
|
|
493
|
-
|
|
494
|
-
|
|
612
|
+
// One slide per chip. A chip that moves again mid-slide takes the new
|
|
613
|
+
// delta from its layout seat, which starts the second slide a step off
|
|
614
|
+
// where the first had drawn it — a jump measured in the pixels one
|
|
615
|
+
// frame of easing covers, and it is bounded, unlike two animations
|
|
616
|
+
// compositing the same property against each other.
|
|
617
|
+
// ponytail: not blended with the in-flight offset, which would cost a
|
|
618
|
+
// computed-style read per chip in the middle of the write pass.
|
|
619
|
+
railSlides.current.get(el)?.cancel();
|
|
620
|
+
railSlides.current.set(
|
|
621
|
+
el,
|
|
622
|
+
el.animate(
|
|
623
|
+
{ translate: [`${dx}px ${dy}px`, "none"] },
|
|
624
|
+
{ duration: Number.isFinite(ms) ? ms : 200, easing: "ease-out" },
|
|
625
|
+
),
|
|
626
|
+
);
|
|
495
627
|
}
|
|
496
|
-
const frame = requestAnimationFrame(() => {
|
|
497
|
-
for (const { el } of moved) {
|
|
498
|
-
el.style.transition = "";
|
|
499
|
-
el.style.translate = "";
|
|
500
|
-
}
|
|
501
|
-
});
|
|
502
|
-
return () => {
|
|
503
|
-
cancelAnimationFrame(frame);
|
|
504
|
-
// Cancelling is not enough on its own. Nothing else takes these off, so
|
|
505
|
-
// a commit landing before the frame ran would leave every moved chip
|
|
506
|
-
// sitting at its inverted offset with transitions disabled — the rail
|
|
507
|
-
// stopping halfway and staying there. Deselecting the last keyword is
|
|
508
|
-
// the reliable way to see it: the rescore is at its largest, so the fit
|
|
509
|
-
// changes and the extra commit always lands.
|
|
510
|
-
for (const { el } of moved) {
|
|
511
|
-
el.style.transition = "";
|
|
512
|
-
el.style.translate = "";
|
|
513
|
-
}
|
|
514
|
-
};
|
|
515
628
|
});
|
|
516
629
|
|
|
517
630
|
/** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
|
|
@@ -565,7 +678,8 @@ export default function Catalog({
|
|
|
565
678
|
const s = readPref("sort");
|
|
566
679
|
const d = readPref("dir");
|
|
567
680
|
const v = readPref("view");
|
|
568
|
-
const field: Sort =
|
|
681
|
+
const field: Sort =
|
|
682
|
+
s === "updated" || s === "rating" || s === "relevance" ? s : "name";
|
|
569
683
|
const published = new Set(packages.flatMap((p) => p.keywords ?? []));
|
|
570
684
|
setQuery(params.get("q") ?? "");
|
|
571
685
|
setKinds(list(params.get("kind")).filter((k) => KNOWN_KINDS.includes(k)));
|
|
@@ -855,26 +969,164 @@ export default function Catalog({
|
|
|
855
969
|
}, [counted]);
|
|
856
970
|
|
|
857
971
|
const q = query.trim().toLowerCase();
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Fetch the fuzzy matcher, once, the first time anyone searches.
|
|
975
|
+
*
|
|
976
|
+
* Not on mount: a reader who never types pays nothing — no `fuzzysort`
|
|
977
|
+
* chunk, no `/all.json`. Not per keystroke either; `tried` latches on the
|
|
978
|
+
* first attempt, so a failed load degrades to the substring filter for the
|
|
979
|
+
* rest of the visit rather than re-fetching on every letter.
|
|
980
|
+
*
|
|
981
|
+
* A failure is not shown to the reader on purpose. Search keeps working —
|
|
982
|
+
* the substring path over the card's own fields is what the catalog did
|
|
983
|
+
* before this existed — so an error banner would report a downgrade nobody
|
|
984
|
+
* asked about, over a page that is doing what they asked. It goes to the
|
|
985
|
+
* console with the error value itself, chain and stack intact.
|
|
986
|
+
*/
|
|
987
|
+
const tried = useRef(false);
|
|
988
|
+
useEffect(() => {
|
|
989
|
+
if (!q || tried.current) return;
|
|
990
|
+
tried.current = true;
|
|
991
|
+
// Self-catching, so the `void` attaches nothing it needs to: every await
|
|
992
|
+
// on the path is inside the try.
|
|
993
|
+
void (async () => {
|
|
994
|
+
try {
|
|
995
|
+
const { loadSearchIndex } = await import("../lib/search.js");
|
|
996
|
+
setIndex(await loadSearchIndex(withBase("/all.json")));
|
|
997
|
+
} catch (err) {
|
|
998
|
+
console.error("catalog: fuzzy search unavailable, using substring match", err);
|
|
999
|
+
}
|
|
1000
|
+
})();
|
|
1001
|
+
}, [q]);
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* What the current query scored against every package, or `null` when
|
|
1005
|
+
* there is no query or no matcher yet.
|
|
1006
|
+
*
|
|
1007
|
+
* Memoized on the pair: re-scoring the whole catalog is the one genuinely
|
|
1008
|
+
* expensive thing a keystroke triggers, and every consumer below reads it.
|
|
1009
|
+
*/
|
|
1010
|
+
const scores: Scores | null = useMemo(
|
|
1011
|
+
() => (index && q ? index.search(q) : null),
|
|
1012
|
+
[index, q],
|
|
1013
|
+
);
|
|
1014
|
+
|
|
858
1015
|
// Query and facets first, deprecation last — so the toggle can report how
|
|
859
1016
|
// many entries *it alone* is holding back, rather than a catalog-wide
|
|
860
1017
|
// number that has nothing to do with what is on screen.
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
p
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1018
|
+
//
|
|
1019
|
+
// Memoized, and `shown` with it, for a reason beyond the scan's own cost:
|
|
1020
|
+
// an unmemoized `.filter().sort()` yields a NEW array on every render, so
|
|
1021
|
+
// anything downstream keyed on `shown` — the keyword rail's set-cover below
|
|
1022
|
+
// — could never hit its own cache. Both had to move together or neither
|
|
1023
|
+
// helped. Every dependency here is a primitive or a state array, so the
|
|
1024
|
+
// identity is stable exactly when the answer is.
|
|
1025
|
+
const matching = useMemo(
|
|
1026
|
+
() =>
|
|
1027
|
+
packages.filter((p) => {
|
|
1028
|
+
if (kinds.length > 0 && !kinds.includes(p.kind)) return false;
|
|
1029
|
+
if (!keywords.every((kw) => p.keywords?.includes(kw))) return false;
|
|
1030
|
+
if (!q) return true;
|
|
1031
|
+
// The fuzzy index, once it is here: multi-term, order-independent,
|
|
1032
|
+
// typo-tolerant, and over every field `/all.json` carries — the
|
|
1033
|
+
// licence, the vendor, the repository, the authors, none of which
|
|
1034
|
+
// are on the record this island was handed.
|
|
1035
|
+
if (scores) return scores.has(p.ref);
|
|
1036
|
+
// Until then, and if the fetch never lands: the substring pass over
|
|
1037
|
+
// the fields the card itself ships. Narrower on both axes, and the
|
|
1038
|
+
// reason the search box is never dead while a chunk downloads.
|
|
1039
|
+
return [
|
|
1040
|
+
p.name,
|
|
1041
|
+
p.description ?? "",
|
|
1042
|
+
p.namespace,
|
|
1043
|
+
p.kind,
|
|
1044
|
+
p.ref,
|
|
1045
|
+
p.summary ?? "",
|
|
1046
|
+
(p.keywords ?? []).join(" "),
|
|
1047
|
+
].some((field) => field.toLowerCase().includes(q));
|
|
1048
|
+
}),
|
|
1049
|
+
[packages, kinds, keywords, q, scores],
|
|
1050
|
+
);
|
|
1051
|
+
const shown = useMemo(() => {
|
|
1052
|
+
// `.filter()` already returns a fresh array, so sorting in place here
|
|
1053
|
+
// mutates nothing the memo above holds — except in the `showDeprecated`
|
|
1054
|
+
// branch, where `matching` IS that array. Copy before sorting.
|
|
1055
|
+
const list = showDeprecated
|
|
1056
|
+
? [...matching]
|
|
1057
|
+
: matching.filter((p) => !p.deprecated);
|
|
1058
|
+
// Relevance is sorted here rather than in `compare`, because the score is
|
|
1059
|
+
// a property of the query and not of the package — see `CHAINS`. Name
|
|
1060
|
+
// breaks the tie, so equally-scored packages keep a total order and the
|
|
1061
|
+
// list cannot reshuffle between renders.
|
|
1062
|
+
if (sort === "relevance" && scores) {
|
|
1063
|
+
return list.sort((a, b) => {
|
|
1064
|
+
const d =
|
|
1065
|
+
descending(scores.get(a.ref) ?? null, scores.get(b.ref) ?? null) ||
|
|
1066
|
+
byName(a, b);
|
|
1067
|
+
return dir === NATURAL.relevance ? d : -d;
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
return list.sort((a, b) => compare(a, b, sort, dir));
|
|
1071
|
+
}, [matching, showDeprecated, sort, dir, scores]);
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* How many of `shown` are actually built.
|
|
1076
|
+
*
|
|
1077
|
+
* The island's cost is dominated by constructing components, not by drawing
|
|
1078
|
+
* them: `content-visibility` on the card and the row already means the
|
|
1079
|
+
* browser skips layout and paint for anything off screen, but Preact still
|
|
1080
|
+
* built every one. At 500 packages that was ~440 card components on
|
|
1081
|
+
* hydration and another ~440 row components the moment the view changed —
|
|
1082
|
+
* a 1.9s wait before a stored list view was on screen at all.
|
|
1083
|
+
*
|
|
1084
|
+
* So only a viewport's worth is built, and the slice GROWS as a sentinel
|
|
1085
|
+
* below the list comes into view. It never shrinks, which is the whole
|
|
1086
|
+
* reason this is a slice rather than true virtualization: an item that has
|
|
1087
|
+
* been built stays built, so scrolling back up can never meet a blank row,
|
|
1088
|
+
* and find-in-page keeps working over everything reached so far. The
|
|
1089
|
+
* worst case — a reader who scrolls to the bottom — is exactly today's
|
|
1090
|
+
* behaviour, and its paint is still bounded by `content-visibility`.
|
|
1091
|
+
*
|
|
1092
|
+
* 48 covers a tall viewport of either shape with room over: rows are 34px,
|
|
1093
|
+
* and the card grid is three or four across at 19rem minimum.
|
|
1094
|
+
*/
|
|
1095
|
+
const [limit, setLimit] = useState(WINDOW);
|
|
1096
|
+
// A new result set starts a new window — otherwise narrowing to 3 matches
|
|
1097
|
+
// and clearing the filter again would leave the whole catalog built.
|
|
1098
|
+
// `shown` is memoized, so this identity changes exactly when the answer does.
|
|
1099
|
+
const shownRef = useRef(shown);
|
|
1100
|
+
if (shownRef.current !== shown) {
|
|
1101
|
+
shownRef.current = shown;
|
|
1102
|
+
if (limit !== WINDOW) setLimit(WINDOW);
|
|
1103
|
+
}
|
|
1104
|
+
const visible = limit >= shown.length ? shown : shown.slice(0, limit);
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Grow the window when the sentinel below the list is reached.
|
|
1108
|
+
*
|
|
1109
|
+
* `rootMargin` is what keeps this invisible in use: the next slice is built
|
|
1110
|
+
* a screen and a half before the reader gets to it, so the list reads as
|
|
1111
|
+
* complete rather than as something that loads while you look at it. The
|
|
1112
|
+
* observer re-fires while the sentinel stays in view, so a fast scroll
|
|
1113
|
+
* keeps growing the window a slice per frame rather than stalling.
|
|
1114
|
+
*/
|
|
1115
|
+
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
1116
|
+
useEffect(() => {
|
|
1117
|
+
const el = sentinelRef.current;
|
|
1118
|
+
// Guarded like the rail's observer: the test renderer's DOM has neither.
|
|
1119
|
+
if (!el || typeof IntersectionObserver === "undefined") return;
|
|
1120
|
+
const io = new IntersectionObserver(
|
|
1121
|
+
(entries) => {
|
|
1122
|
+
if (!entries.some((e) => e.isIntersecting)) return;
|
|
1123
|
+
setLimit((l) => (l >= shown.length ? l : l + WINDOW));
|
|
1124
|
+
},
|
|
1125
|
+
{ rootMargin: "150% 0px" },
|
|
1126
|
+
);
|
|
1127
|
+
io.observe(el);
|
|
1128
|
+
return () => io.disconnect();
|
|
1129
|
+
}, [shown.length]);
|
|
878
1130
|
|
|
879
1131
|
/**
|
|
880
1132
|
* The keyword rail, over what is on screen rather than over the catalog.
|
|
@@ -899,7 +1151,15 @@ export default function Catalog({
|
|
|
899
1151
|
keyword,
|
|
900
1152
|
count: shown.length,
|
|
901
1153
|
}));
|
|
902
|
-
|
|
1154
|
+
// Memoized on `shown` alone, because that is the only thing the scan reads.
|
|
1155
|
+
// `selectRailKeywords` is a greedy set-cover over every keyword of every
|
|
1156
|
+
// shown package — it scales with catalog size, and unmemoized it ran on
|
|
1157
|
+
// EVERY render: each keystroke in the search box, each chip click, each
|
|
1158
|
+
// view toggle, and once more for every re-render none of those caused. At a
|
|
1159
|
+
// corporate-sized catalog that is the most expensive thing in the render
|
|
1160
|
+
// path, repeated for an answer that had not changed.
|
|
1161
|
+
const scored = useMemo(() => selectRailKeywords(shown, KEYWORD_CHIP_LIMIT), [shown]);
|
|
1162
|
+
const rail = scored
|
|
903
1163
|
// `selectRailKeywords` scores the actives like any other keyword, so
|
|
904
1164
|
// over-request and drop them rather than spend rail slots twice.
|
|
905
1165
|
.filter((k) => !keywords.includes(k.keyword))
|
|
@@ -911,6 +1171,10 @@ export default function Catalog({
|
|
|
911
1171
|
// chip clipped at the rail's edge is one the reader cannot reach anywhere
|
|
912
1172
|
// else, and a "+N more" that does not count it is lying about where it is.
|
|
913
1173
|
const clippedKeywords = visibleKeywords.slice(railFit).map((k) => k.keyword);
|
|
1174
|
+
// What the two layout effects above compare against. In render order, so a
|
|
1175
|
+
// reorder counts as a change — the FLIP effect exists to animate exactly
|
|
1176
|
+
// that. NUL-joined because a keyword may contain anything but that.
|
|
1177
|
+
railSignature.current = visibleKeywords.map((k) => k.keyword).join("\u0000");
|
|
914
1178
|
const menuKeywords = keywordFrequency(shown).filter(
|
|
915
1179
|
(k) =>
|
|
916
1180
|
clippedKeywords.includes(k.keyword) ||
|
|
@@ -948,6 +1212,31 @@ export default function Catalog({
|
|
|
948
1212
|
<kbd class="search-hint" aria-hidden="true">
|
|
949
1213
|
/
|
|
950
1214
|
</kbd>
|
|
1215
|
+
{/* Ours, in the hint's own box and its own place — the browser's
|
|
1216
|
+
built-in `type="search"` clear button lands in the same corner
|
|
1217
|
+
wearing the UA's styling, which belongs to no theme this site
|
|
1218
|
+
has. It is hidden in CSS and this replaces it, so the corner
|
|
1219
|
+
holds exactly one control that looks like the rest of the
|
|
1220
|
+
toolbar: the key hint when the field is empty, the same box
|
|
1221
|
+
carrying an X when it is not.
|
|
1222
|
+
|
|
1223
|
+
Focus goes back to the field rather than staying on a button
|
|
1224
|
+
that is about to unmount — the same thing Escape already does
|
|
1225
|
+
from in here. */}
|
|
1226
|
+
{query && (
|
|
1227
|
+
<button
|
|
1228
|
+
type="button"
|
|
1229
|
+
class="search-clear"
|
|
1230
|
+
aria-label="Clear search"
|
|
1231
|
+
title="Clear search"
|
|
1232
|
+
onClick={() => {
|
|
1233
|
+
setQuery("");
|
|
1234
|
+
searchRef.current?.focus();
|
|
1235
|
+
}}
|
|
1236
|
+
>
|
|
1237
|
+
<X size={12} aria-hidden="true" />
|
|
1238
|
+
</button>
|
|
1239
|
+
)}
|
|
951
1240
|
</div>
|
|
952
1241
|
{/* One row, three groups, in the order they narrow: what sort of
|
|
953
1242
|
thing, then what it is about, then what the catalog is
|
|
@@ -1001,7 +1290,7 @@ export default function Catalog({
|
|
|
1001
1290
|
class="chips kw-rail"
|
|
1002
1291
|
role="group"
|
|
1003
1292
|
aria-label="Filter by keyword"
|
|
1004
|
-
ref={
|
|
1293
|
+
ref={attachRail}
|
|
1005
1294
|
>
|
|
1006
1295
|
{visibleKeywords.map(({ keyword }, i) => {
|
|
1007
1296
|
// Past the measured fit: still laid out, so the measurement
|
|
@@ -1222,6 +1511,40 @@ export default function Catalog({
|
|
|
1222
1511
|
data-slot="filter-chip"
|
|
1223
1512
|
aria-label="Sort by"
|
|
1224
1513
|
value={sort}
|
|
1514
|
+
// Chromium matches `:focus-visible` on a `<select>` after a
|
|
1515
|
+
// plain MOUSE click — a select accepts keyboard input, so the
|
|
1516
|
+
// engine treats every focus as keyboard focus. The repo's
|
|
1517
|
+
// `:focus-visible` convention therefore cannot keep the accent
|
|
1518
|
+
// ring off this one control, and CSS has nothing else to go on:
|
|
1519
|
+
// no selector distinguishes focus that arrived from a pointer.
|
|
1520
|
+
//
|
|
1521
|
+
// So the pointer marks itself. `data-pointer` suppresses the
|
|
1522
|
+
// ring for the whole pointer interaction — an open dropdown is
|
|
1523
|
+
// its own affordance and needs no second one around the closed
|
|
1524
|
+
// box behind it — and the pick then hands focus back, so
|
|
1525
|
+
// nothing is left lit beside the thin neutral chips.
|
|
1526
|
+
//
|
|
1527
|
+
// The keyboard path must do NEITHER. Arrow keys on a closed
|
|
1528
|
+
// select fire `change` per option, so blurring there would take
|
|
1529
|
+
// the control away mid-selection, and a keyboard reader is
|
|
1530
|
+
// exactly who the ring exists for. `onKeyDown` clears both, so
|
|
1531
|
+
// a reader who clicks once and later tabs back is a keyboard
|
|
1532
|
+
// reader again.
|
|
1533
|
+
//
|
|
1534
|
+
// Written to the node rather than to state: this fires while
|
|
1535
|
+
// the native dropdown is open, and a re-render of the element
|
|
1536
|
+
// holding it open is not worth the risk for a styling hint.
|
|
1537
|
+
onPointerDown={(event) => {
|
|
1538
|
+
pickedByPointer.current = true;
|
|
1539
|
+
event.currentTarget.dataset.pointer = "";
|
|
1540
|
+
}}
|
|
1541
|
+
onKeyDown={(event) => {
|
|
1542
|
+
pickedByPointer.current = false;
|
|
1543
|
+
delete event.currentTarget.dataset.pointer;
|
|
1544
|
+
}}
|
|
1545
|
+
onBlur={(event) => {
|
|
1546
|
+
delete event.currentTarget.dataset.pointer;
|
|
1547
|
+
}}
|
|
1225
1548
|
onChange={(event) => {
|
|
1226
1549
|
const next = (event.currentTarget as HTMLSelectElement)
|
|
1227
1550
|
.value as Sort;
|
|
@@ -1230,11 +1553,14 @@ export default function Catalog({
|
|
|
1230
1553
|
// the previous one over lands the reader on "oldest first"
|
|
1231
1554
|
// because they had asked for Z→A a moment ago.
|
|
1232
1555
|
setDir(NATURAL[next]);
|
|
1556
|
+
// The pointer path only: see the handlers above.
|
|
1557
|
+
if (pickedByPointer.current) event.currentTarget.blur();
|
|
1233
1558
|
}}
|
|
1234
1559
|
>
|
|
1235
1560
|
<option value="name">name</option>
|
|
1236
1561
|
<option value="updated">updated</option>
|
|
1237
1562
|
{hasRatings && <option value="rating">rating</option>}
|
|
1563
|
+
<option value="relevance">relevance</option>
|
|
1238
1564
|
</select>
|
|
1239
1565
|
</div>
|
|
1240
1566
|
{/* Beside sort, because it answers the same kind of question — how
|
|
@@ -1274,7 +1600,7 @@ export default function Catalog({
|
|
|
1274
1600
|
<p class="empty">No packages match.</p>
|
|
1275
1601
|
) : view === "table" ? (
|
|
1276
1602
|
<PackageTable
|
|
1277
|
-
packages={
|
|
1603
|
+
packages={visible}
|
|
1278
1604
|
hasRatings={hasRatings}
|
|
1279
1605
|
onKeyDown={onCardKeyDown}
|
|
1280
1606
|
rootRef={gridRef}
|
|
@@ -1286,7 +1612,7 @@ export default function Catalog({
|
|
|
1286
1612
|
gridRef.current = el;
|
|
1287
1613
|
}}
|
|
1288
1614
|
>
|
|
1289
|
-
{
|
|
1615
|
+
{visible.map((p) => (
|
|
1290
1616
|
<PackageCard
|
|
1291
1617
|
key={`${p.namespace}/${p.name}`}
|
|
1292
1618
|
pkg={p}
|
|
@@ -1298,6 +1624,14 @@ export default function Catalog({
|
|
|
1298
1624
|
))}
|
|
1299
1625
|
</ul>
|
|
1300
1626
|
)}
|
|
1627
|
+
{/* The sentinel. Outside the list rather than inside it, so it is not a
|
|
1628
|
+
stray child of a `<ul>` whose children are all `<li>`, nor of the
|
|
1629
|
+
table's grid where it would take a row of tracks. `aria-hidden`
|
|
1630
|
+
because it is a scroll position, not content — the count above the
|
|
1631
|
+
list is what tells a screen reader how many packages there are. */}
|
|
1632
|
+
{visible.length < shown.length && (
|
|
1633
|
+
<div ref={sentinelRef} aria-hidden="true" />
|
|
1634
|
+
)}
|
|
1301
1635
|
</section>
|
|
1302
1636
|
);
|
|
1303
1637
|
}
|