@grimoire-rs/indexer 0.4.4 → 0.5.1
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 +270 -0
- package/NOTICE +30 -0
- package/README.md +76 -331
- package/dist/cli/dev.d.ts +14 -0
- package/dist/cli/dev.d.ts.map +1 -1
- package/dist/cli/dev.js +22 -1
- package/dist/cli/dev.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +35 -4
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +5 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/config.d.ts +107 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +181 -36
- package/dist/config.js.map +1 -1
- package/dist/renderer/astro/components/CardLogo.d.ts +5 -0
- package/dist/renderer/astro/components/CardLogo.js +58 -0
- package/dist/renderer/astro/components/CardLogo.tsx +96 -0
- package/dist/renderer/astro/components/Catalog.d.ts +14 -1
- package/dist/renderer/astro/components/Catalog.js +573 -108
- package/dist/renderer/astro/components/Catalog.tsx +903 -349
- package/dist/renderer/astro/components/CodeBlock.astro +74 -0
- package/dist/renderer/astro/components/CommandBar.astro +66 -0
- package/dist/renderer/astro/components/CopyButton.d.ts +7 -0
- package/dist/renderer/astro/components/CopyButton.js +28 -0
- package/dist/renderer/astro/components/CopyButton.tsx +56 -0
- package/dist/renderer/astro/components/KindMark.d.ts +69 -0
- package/dist/renderer/astro/components/KindMark.js +66 -0
- package/dist/renderer/astro/components/KindMark.tsx +141 -0
- package/dist/renderer/astro/components/PackageCard.d.ts +18 -0
- package/dist/renderer/astro/components/PackageCard.js +50 -0
- package/dist/renderer/astro/components/PackageCard.tsx +273 -0
- package/dist/renderer/astro/components/PackageRow.d.ts +10 -0
- package/dist/renderer/astro/components/PackageRow.js +32 -0
- package/dist/renderer/astro/components/PackageRow.tsx +126 -0
- package/dist/renderer/astro/components/PickerMenu.astro +5 -14
- package/dist/renderer/astro/components/SiteFooter.astro +64 -0
- package/dist/renderer/astro/components/SiteHeader.astro +74 -0
- package/dist/renderer/astro/components/VersionMenu.astro +2 -2
- package/dist/renderer/astro/layouts/Base.astro +970 -227
- package/dist/renderer/astro/lib/base.d.ts +25 -0
- package/dist/renderer/astro/lib/base.js +23 -0
- package/dist/renderer/astro/lib/base.ts +27 -0
- package/dist/renderer/astro/lib/catalog.d.ts +24 -0
- package/dist/renderer/astro/lib/catalog.js +36 -0
- package/dist/renderer/astro/lib/catalog.ts +37 -0
- package/dist/renderer/astro/lib/code.d.ts +2 -2
- package/dist/renderer/astro/lib/code.js +2 -2
- package/dist/renderer/astro/lib/code.ts +2 -2
- package/dist/renderer/astro/lib/commands.d.ts +64 -0
- package/dist/renderer/astro/lib/commands.js +91 -0
- package/dist/renderer/astro/lib/commands.ts +133 -0
- package/dist/renderer/astro/lib/keywordRail.d.ts +44 -0
- package/dist/renderer/astro/lib/keywordRail.js +99 -0
- package/dist/renderer/astro/lib/keywordRail.ts +110 -0
- package/dist/renderer/astro/pages/index.astro +40 -87
- package/dist/renderer/astro/pages/p/[...slug].astro +349 -198
- package/dist/renderer/astro/styles/tokens.css +40 -5
- package/dist/renderer/index.d.ts +77 -0
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +565 -7
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/types.d.ts +9 -0
- package/dist/renderer/types.d.ts.map +1 -1
- package/package.json +9 -4
- package/templates/README.md +6 -0
- package/templates/gitignore +4 -1
- package/templates/theme/README.md +38 -0
- 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
|
|
4
|
-
//
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { lastUpdated
|
|
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, X, } 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,34 @@ 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
|
+
* `popovertarget` needs an id, and the catalog is a singleton on its page —
|
|
27
|
+
* one island, one toolbar, one overflow menu — so this is a constant rather
|
|
28
|
+
* than something generated per mount.
|
|
29
|
+
*/
|
|
30
|
+
const KEYWORD_MENU_ID = "grim-keyword-overflow";
|
|
31
|
+
/**
|
|
32
|
+
* The direction each field is *worth* reading first in — A→Z for a name,
|
|
33
|
+
* newest and best-liked first for the two ranked keys.
|
|
34
|
+
*
|
|
35
|
+
* Picking a field selects its natural direction; the toggle beside the
|
|
36
|
+
* combo box reverses that. So "descending" is not a global default a reader
|
|
37
|
+
* has to correct on every mode, and the arrow always describes what the
|
|
38
|
+
* order actually is rather than which way a flag is set.
|
|
39
|
+
*/
|
|
40
|
+
export const NATURAL = {
|
|
41
|
+
name: "asc",
|
|
42
|
+
updated: "desc",
|
|
43
|
+
rating: "desc",
|
|
44
|
+
};
|
|
17
45
|
/**
|
|
18
46
|
* Bigger first, with `null` as its own bucket underneath every number.
|
|
19
47
|
*
|
|
@@ -38,7 +66,8 @@ function updatedAt(p) {
|
|
|
38
66
|
* mode, and it is unique, so no two rows ever compare equal — a browse order
|
|
39
67
|
* that is not total is a browse order that reshuffles on rebuild.
|
|
40
68
|
*/
|
|
41
|
-
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) ||
|
|
69
|
+
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) ||
|
|
70
|
+
a.ref.localeCompare(b.ref);
|
|
42
71
|
/**
|
|
43
72
|
* Newest first. No usable date is *unknown*, not epoch 0: dating an undated
|
|
44
73
|
* package to 1970 sorts it below real packages by accident rather than by
|
|
@@ -62,11 +91,14 @@ const CHAINS = {
|
|
|
62
91
|
// any other row when the toggle brings them back. grim's own browse order
|
|
63
92
|
// (`browse_sort.rs`) has no deprecated key either; keeping this comparator
|
|
64
93
|
// silent on deprecation is what keeps the two in sync.
|
|
65
|
-
export function compare(a, b, sort) {
|
|
94
|
+
export function compare(a, b, sort, dir = NATURAL[sort]) {
|
|
66
95
|
for (const key of CHAINS[sort]) {
|
|
67
96
|
const d = key(a, b);
|
|
97
|
+
// Reversed means reversed all the way down, the ref tiebreak included.
|
|
98
|
+
// Every chain ends on a unique key, so no two rows compare equal and
|
|
99
|
+
// negating the whole answer leaves the order just as total as it was.
|
|
68
100
|
if (d !== 0)
|
|
69
|
-
return d;
|
|
101
|
+
return dir === NATURAL[sort] ? d : -d;
|
|
70
102
|
}
|
|
71
103
|
return 0;
|
|
72
104
|
}
|
|
@@ -74,12 +106,56 @@ export function compare(a, b, sort) {
|
|
|
74
106
|
// codicons so "project" and "global" read identically in both. That parity
|
|
75
107
|
// is gone on purpose: every icon now comes from one set. `FolderRoot` and
|
|
76
108
|
// `Globe` are the nearest Lucide equivalents and carry the same meaning.
|
|
109
|
+
/**
|
|
110
|
+
* The reader's own preferences, kept out of the URL.
|
|
111
|
+
*
|
|
112
|
+
* The split is deliberate and matches grim: `q`, `kind` and `kw` are *what
|
|
113
|
+
* you are looking at* — a keyword chip on a package page links to
|
|
114
|
+
* `/?kw=<keyword>`, so that half has to stay shareable — while sort,
|
|
115
|
+
* direction, deprecated visibility and the cards/table choice are *how you
|
|
116
|
+
* like the catalog arranged*, the same answer on every visit. grim keeps
|
|
117
|
+
* `show_deprecated` in its config file for exactly that reason.
|
|
118
|
+
*
|
|
119
|
+
* Both accessors swallow: reading `localStorage` throws outright, not
|
|
120
|
+
* returns null, in a browser set to block site data, and a catalog is not
|
|
121
|
+
* worth a blank page. A reader who blocks it browses without preferences.
|
|
122
|
+
*/
|
|
123
|
+
const PREF = "grim.catalog.";
|
|
124
|
+
function readPref(key) {
|
|
125
|
+
try {
|
|
126
|
+
return localStorage.getItem(PREF + key);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function writePref(key, value) {
|
|
133
|
+
try {
|
|
134
|
+
if (value === null)
|
|
135
|
+
localStorage.removeItem(PREF + key);
|
|
136
|
+
else
|
|
137
|
+
localStorage.setItem(PREF + key, value);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Nothing to do and nothing to report: preferences are a convenience.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A comma-joined URL parameter, back into the list it was.
|
|
145
|
+
*
|
|
146
|
+
* Empty and absent are the same answer — `?kind=` is a reader who cleared
|
|
147
|
+
* the filter, not a request for the kind named "". Duplicates collapse so a
|
|
148
|
+
* hand-edited `?kw=a,a` cannot render the same chip twice.
|
|
149
|
+
*/
|
|
150
|
+
function list(value) {
|
|
151
|
+
return [...new Set((value ?? "").split(",").filter(Boolean))];
|
|
152
|
+
}
|
|
77
153
|
/** Typing inside one of these means a bare keystroke is text, not a shortcut. */
|
|
78
154
|
function isTyping(el) {
|
|
79
155
|
const node = el;
|
|
80
156
|
if (!node)
|
|
81
157
|
return false;
|
|
82
|
-
return node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName);
|
|
158
|
+
return (node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName));
|
|
83
159
|
}
|
|
84
160
|
/**
|
|
85
161
|
* How many cards the grid actually laid out per row.
|
|
@@ -87,6 +163,10 @@ function isTyping(el) {
|
|
|
87
163
|
* Measured, not read from CSS: the track list is `auto-fill` with a minimum
|
|
88
164
|
* width, so the count is a layout outcome that depends on the viewport. The
|
|
89
165
|
* first card whose top edge drops below the first row's starts row two.
|
|
166
|
+
*
|
|
167
|
+
* The table view needs no branch of its own — its rows stack, so the second
|
|
168
|
+
* one is already below the first and this measures the 1 that makes every
|
|
169
|
+
* arrow key move by a single row.
|
|
90
170
|
*/
|
|
91
171
|
function columnCount(cards) {
|
|
92
172
|
if (cards.length < 2)
|
|
@@ -96,69 +176,33 @@ function columnCount(cards) {
|
|
|
96
176
|
return wrapped === -1 ? cards.length : wrapped;
|
|
97
177
|
}
|
|
98
178
|
/**
|
|
99
|
-
* The
|
|
179
|
+
* The same packages as a list, for reading down a column rather than across
|
|
180
|
+
* a grid.
|
|
181
|
+
*
|
|
182
|
+
* **A row is an anchor, and there is no `<table>`.** Two reasons, and the
|
|
183
|
+
* first is the load-bearing one: a header row that cannot sort is a header
|
|
184
|
+
* row that *looks* like it sorts — every reader who has met a data table
|
|
185
|
+
* clicks it once. Sorting lives in the toolbar, so the table has no headers,
|
|
186
|
+
* and a headerless table has no column semantics left to justify the element.
|
|
187
|
+
* What remains is a list of links, which is what this is. A CSS grid with
|
|
188
|
+
* `subgrid` rows keeps the columns aligned without the markup.
|
|
100
189
|
*
|
|
101
|
-
* The
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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.
|
|
190
|
+
* The anchor is also what makes the whole row clickable, focusable and
|
|
191
|
+
* middle-clickable for free — no stretched-link overlay, no synthetic Enter
|
|
192
|
+
* handler. A row carries no controls of its own: the install buttons and the
|
|
193
|
+
* vote links are what the card exists for, and repeating them per row would
|
|
194
|
+
* be five columns of icons. The detail page has all of them.
|
|
108
195
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
196
|
+
* Columns are fixed, unlike the keyword rail above — deliberately. A column
|
|
197
|
+
* is a slot the eye tracks down; one that appears and disappears as the
|
|
198
|
+
* filters change destroys the alignment the table exists to give. The rating
|
|
199
|
+
* column is the one exception, and it is decided once per index rather than
|
|
200
|
+
* per filter.
|
|
112
201
|
*/
|
|
113
|
-
function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 }) }));
|
|
202
|
+
function PackageTable({ packages, hasRatings, onKeyDown, rootRef, }) {
|
|
203
|
+
return (_jsx("div", { class: hasRatings ? "table rated" : "table", "data-slot": "package-table", ref: (el) => {
|
|
204
|
+
rootRef.current = el;
|
|
205
|
+
}, children: packages.map((p) => (_jsx(PackageRow, { pkg: p, hasRatings: hasRatings, onKeyDown: onKeyDown }, `${p.namespace}/${p.name}`))) }));
|
|
162
206
|
}
|
|
163
207
|
// `vscodeExtension` arrives as a prop, not from lib/data: this island
|
|
164
208
|
// hydrates in the browser, so importing the build-time payload here would
|
|
@@ -179,19 +223,222 @@ export default function Catalog({ packages, vscodeExtension, }) {
|
|
|
179
223
|
// Whether the URL's query has been applied. Gates the reveal below, so the
|
|
180
224
|
// catalog is never unhidden while it still shows the unfiltered list.
|
|
181
225
|
const [seeded, setSeeded] = useState(false);
|
|
182
|
-
|
|
226
|
+
// Kinds combine with OR, keywords with AND, and the two groups with each
|
|
227
|
+
// other. That is not an inconsistency, it follows from the data: a package
|
|
228
|
+
// has exactly one kind, so requiring both of two kinds always yields
|
|
229
|
+
// nothing, while it carries many keywords, so requiring both of two is the
|
|
230
|
+
// only reading under which a second click narrows. A facet whose second
|
|
231
|
+
// click *widens* the result set reads as broken.
|
|
232
|
+
const [kinds, setKinds] = useState([]);
|
|
233
|
+
const [keywords, setKeywords] = useState([]);
|
|
183
234
|
const [sort, setSort] = useState("name");
|
|
235
|
+
// Direction, not "reversed": what the arrow draws is the order itself.
|
|
236
|
+
const [dir, setDir] = useState(NATURAL.name);
|
|
184
237
|
// Deprecated packages are hidden until asked for: a retired package is
|
|
185
238
|
// noise for someone browsing what to install, and the publisher already
|
|
186
239
|
// said as much by deprecating it.
|
|
187
240
|
const [showDeprecated, setShowDeprecated] = useState(false);
|
|
241
|
+
const [view, setView] = useState("cards");
|
|
242
|
+
// Local to the overflow menu and deliberately not shareable: it narrows
|
|
243
|
+
// the list of keywords, not the catalog.
|
|
244
|
+
const [keywordFilter, setKeywordFilter] = useState("");
|
|
188
245
|
const searchRef = useRef(null);
|
|
189
246
|
const gridRef = useRef(null);
|
|
190
247
|
const controlsRef = useRef(null);
|
|
191
|
-
|
|
248
|
+
// Both views, one selector: a table row is the Tab stop its card is, so
|
|
249
|
+
// every keyboard path below — the search hatch, ArrowDown out of the
|
|
250
|
+
// chips, Escape's blur — works in either without knowing which is up.
|
|
251
|
+
const cardsOf = () => [
|
|
252
|
+
...(gridRef.current?.querySelectorAll("li.card, a.row") ?? []),
|
|
253
|
+
];
|
|
254
|
+
// Clipped keyword chips are excluded: they are drawn as nothing, so an
|
|
255
|
+
// arrow key that landed on one would move focus somewhere the reader
|
|
256
|
+
// cannot see it.
|
|
192
257
|
const chipsOf = () => [
|
|
193
|
-
...(controlsRef.current?.querySelectorAll(
|
|
258
|
+
...(controlsRef.current?.querySelectorAll('button.chip:not([aria-hidden="true"])') ?? []),
|
|
194
259
|
];
|
|
260
|
+
const railRefs = useRef(new Map());
|
|
261
|
+
const railRects = useRef(new Map());
|
|
262
|
+
const railRef = useRef(null);
|
|
263
|
+
const kwMenuRef = useRef(null);
|
|
264
|
+
const kwTriggerRef = useRef(null);
|
|
265
|
+
// Drives the trigger's own `aria-expanded` and its open styling. The panel's
|
|
266
|
+
// visibility is the popover's business, not this flag's.
|
|
267
|
+
const [kwMenuOpen, setKwMenuOpen] = useState(false);
|
|
268
|
+
/**
|
|
269
|
+
* Seat the overflow menu under its trigger.
|
|
270
|
+
*
|
|
271
|
+
* The panel is a popover, so it lives in the top layer and is positioned
|
|
272
|
+
* against the viewport rather than against any ancestor — which is the whole
|
|
273
|
+
* point (`.filter-row` is a scroll container and used to crop it). That
|
|
274
|
+
* leaves the seat to us.
|
|
275
|
+
*
|
|
276
|
+
* It always opens downward: the toolbar sits at the top of the page, and a
|
|
277
|
+
* flip would only ever fire on a viewport short enough that the panel has
|
|
278
|
+
* nowhere to go either way. What does not fit becomes `max-height` and
|
|
279
|
+
* scrolls inside the list.
|
|
280
|
+
*
|
|
281
|
+
* Called twice per open, from `beforetoggle` and again from `toggle`. The
|
|
282
|
+
* first runs while the panel is still `display: none`, so its measured width
|
|
283
|
+
* is 0 and the horizontal clamp is a no-op — but the vertical seat is right,
|
|
284
|
+
* which is what stops it appearing in the wrong place for a frame. The
|
|
285
|
+
* second has a real width and finishes the clamp.
|
|
286
|
+
*/
|
|
287
|
+
const placeKwMenu = () => {
|
|
288
|
+
const panel = kwMenuRef.current;
|
|
289
|
+
const trigger = kwTriggerRef.current;
|
|
290
|
+
if (!panel || !trigger)
|
|
291
|
+
return;
|
|
292
|
+
const seat = trigger.getBoundingClientRect();
|
|
293
|
+
const gap = 8;
|
|
294
|
+
const width = panel.getBoundingClientRect().width;
|
|
295
|
+
panel.style.left = `${Math.max(gap, Math.min(seat.left, window.innerWidth - width - gap))}px`;
|
|
296
|
+
panel.style.top = `${seat.bottom + gap}px`;
|
|
297
|
+
panel.style.maxHeight = `${Math.max(120, window.innerHeight - seat.bottom - gap * 3)}px`;
|
|
298
|
+
};
|
|
299
|
+
// A fixed panel does not travel with the trigger, so it is re-seated rather
|
|
300
|
+
// than left behind. Only while open — there is nothing to follow otherwise.
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (!kwMenuOpen)
|
|
303
|
+
return;
|
|
304
|
+
const reseat = () => placeKwMenu();
|
|
305
|
+
// Capturing: the scroll may be any ancestor's, including `.filter-row`'s.
|
|
306
|
+
window.addEventListener("scroll", reseat, { capture: true, passive: true });
|
|
307
|
+
window.addEventListener("resize", reseat);
|
|
308
|
+
return () => {
|
|
309
|
+
window.removeEventListener("scroll", reseat, { capture: true });
|
|
310
|
+
window.removeEventListener("resize", reseat);
|
|
311
|
+
};
|
|
312
|
+
}, [kwMenuOpen]);
|
|
313
|
+
/**
|
|
314
|
+
* How many keyword chips actually fit on the row, measured.
|
|
315
|
+
*
|
|
316
|
+
* Not a constant, because the answer is a layout outcome: the rail is the
|
|
317
|
+
* one flexible child of the filter row, so its width is whatever the kinds,
|
|
318
|
+
* the overflow menu and the deprecated toggle left, and the chips are as
|
|
319
|
+
* wide as the words publishers wrote. `KEYWORD_CHIP_LIMIT` bounds how many
|
|
320
|
+
* are *offered*; this is how many are shown.
|
|
321
|
+
*
|
|
322
|
+
* The rule it enforces: the rail never wraps and never scrolls. A second
|
|
323
|
+
* row makes the toolbar a different height on every filter click, and a
|
|
324
|
+
* scrollbar hides the chips behind a gesture nobody looks for — it also
|
|
325
|
+
* pushed the overflow menu off the end of the row entirely.
|
|
326
|
+
*
|
|
327
|
+
* Every chip stays in the flow whatever this says; the ones past it are
|
|
328
|
+
* drawn as nothing (see `.chip.kw.clipped`). Taking them out of the flow
|
|
329
|
+
* would free the width that excluded them, which is a measurement that
|
|
330
|
+
* disagrees with itself on every other frame.
|
|
331
|
+
*/
|
|
332
|
+
const [railFit, setRailFit] = useState(KEYWORD_CHIP_LIMIT);
|
|
333
|
+
useLayoutEffect(() => {
|
|
334
|
+
const rail = railRef.current;
|
|
335
|
+
if (!rail)
|
|
336
|
+
return;
|
|
337
|
+
const measure = () => {
|
|
338
|
+
const edge = rail.getBoundingClientRect().right;
|
|
339
|
+
let fits = 0;
|
|
340
|
+
for (const chip of rail.children) {
|
|
341
|
+
// Half a pixel of slack: a fractional layout can leave a chip's right
|
|
342
|
+
// edge a rounding error past a boundary it visually sits inside.
|
|
343
|
+
if (chip.getBoundingClientRect().right > edge + 0.5)
|
|
344
|
+
break;
|
|
345
|
+
fits += 1;
|
|
346
|
+
}
|
|
347
|
+
// At least one, always. A rail too narrow for its shortest chip should
|
|
348
|
+
// show that chip clipped rather than render an empty group beside a
|
|
349
|
+
// divider that then divides nothing.
|
|
350
|
+
setRailFit(Math.max(1, fits));
|
|
351
|
+
};
|
|
352
|
+
measure();
|
|
353
|
+
// Guarded rather than assumed: this effect also runs under the test
|
|
354
|
+
// renderer, whose DOM has no `ResizeObserver` — and a missing one costs
|
|
355
|
+
// only re-measurement on viewport resize, which is not worth throwing
|
|
356
|
+
// during a render over.
|
|
357
|
+
if (typeof ResizeObserver === "undefined")
|
|
358
|
+
return;
|
|
359
|
+
const observer = new ResizeObserver(measure);
|
|
360
|
+
observer.observe(rail);
|
|
361
|
+
return () => observer.disconnect();
|
|
362
|
+
// Re-measured on every commit that changes which chips are up, since the
|
|
363
|
+
// observer only fires when the rail's own box changes and a rescore can
|
|
364
|
+
// swap a short word for a long one at the same width.
|
|
365
|
+
});
|
|
366
|
+
/**
|
|
367
|
+
* FLIP for the keyword rail: chips slide to their new places instead of
|
|
368
|
+
* teleporting.
|
|
369
|
+
*
|
|
370
|
+
* The rail is rescored against the current result set, so it reorders on
|
|
371
|
+
* every click — the chip just picked moves to the front and the rest flow
|
|
372
|
+
* around it. Animating that is not decoration: a rail whose contents change
|
|
373
|
+
* between two frames reads as having been *replaced*, and a reader who
|
|
374
|
+
* cannot see that a chip moved has no reason to believe it is the same one.
|
|
375
|
+
*
|
|
376
|
+
* First (the map of rects kept from the last commit), Last (measured now),
|
|
377
|
+
* Invert (an inline translate back to where the chip was), Play (dropped on
|
|
378
|
+
* the next frame, so the stylesheet's transition carries it home). Measure
|
|
379
|
+
* every chip before transforming any: `translate` composites and does not
|
|
380
|
+
* reflow, but reading a rect after writing a style on a sibling is the
|
|
381
|
+
* shape that makes a layout thrash, and this runs per keystroke.
|
|
382
|
+
*/
|
|
383
|
+
useLayoutEffect(() => {
|
|
384
|
+
// Undo whatever the last pass left on the chips before measuring
|
|
385
|
+
// anything. Two reasons, and both were visible: `getBoundingClientRect`
|
|
386
|
+
// reports the *translated* box, so a chip caught mid-slide would be
|
|
387
|
+
// measured where it is drawn rather than where it belongs and the next
|
|
388
|
+
// inversion would compound that error; and a chip whose play frame never
|
|
389
|
+
// ran is still carrying `transition: none` with an offset, which is a chip
|
|
390
|
+
// frozen off its seat. Clearing here is what unfreezes it.
|
|
391
|
+
//
|
|
392
|
+
// Mid-slide is not a rare case: the fit measurement above commits a second
|
|
393
|
+
// time whenever the rescore changes how many chips fit, and that commit
|
|
394
|
+
// lands between this one and its animation frame.
|
|
395
|
+
for (const el of railRefs.current.values()) {
|
|
396
|
+
el.style.transition = "";
|
|
397
|
+
el.style.translate = "";
|
|
398
|
+
}
|
|
399
|
+
const previous = railRects.current;
|
|
400
|
+
const current = new Map();
|
|
401
|
+
const moved = [];
|
|
402
|
+
// A second pass, deliberately: every write above is flushed before the
|
|
403
|
+
// first read below, rather than interleaving them per chip.
|
|
404
|
+
for (const [keyword, el] of railRefs.current) {
|
|
405
|
+
const rect = el.getBoundingClientRect();
|
|
406
|
+
current.set(keyword, rect);
|
|
407
|
+
const was = previous.get(keyword);
|
|
408
|
+
if (!was)
|
|
409
|
+
continue;
|
|
410
|
+
const dx = was.left - rect.left;
|
|
411
|
+
const dy = was.top - rect.top;
|
|
412
|
+
if (dx !== 0 || dy !== 0)
|
|
413
|
+
moved.push({ el, dx, dy });
|
|
414
|
+
}
|
|
415
|
+
railRects.current = current;
|
|
416
|
+
if (moved.length === 0)
|
|
417
|
+
return;
|
|
418
|
+
for (const { el, dx, dy } of moved) {
|
|
419
|
+
el.style.transition = "none";
|
|
420
|
+
el.style.translate = `${dx}px ${dy}px`;
|
|
421
|
+
}
|
|
422
|
+
const frame = requestAnimationFrame(() => {
|
|
423
|
+
for (const { el } of moved) {
|
|
424
|
+
el.style.transition = "";
|
|
425
|
+
el.style.translate = "";
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
return () => {
|
|
429
|
+
cancelAnimationFrame(frame);
|
|
430
|
+
// Cancelling is not enough on its own. Nothing else takes these off, so
|
|
431
|
+
// a commit landing before the frame ran would leave every moved chip
|
|
432
|
+
// sitting at its inverted offset with transitions disabled — the rail
|
|
433
|
+
// stopping halfway and staying there. Deselecting the last keyword is
|
|
434
|
+
// the reliable way to see it: the rescore is at its largest, so the fit
|
|
435
|
+
// changes and the extra commit always lands.
|
|
436
|
+
for (const { el } of moved) {
|
|
437
|
+
el.style.transition = "";
|
|
438
|
+
el.style.translate = "";
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
});
|
|
195
442
|
/** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
|
|
196
443
|
const focusCard = (from, delta) => {
|
|
197
444
|
const cards = cardsOf();
|
|
@@ -218,17 +465,90 @@ export default function Catalog({ packages, vscodeExtension, }) {
|
|
|
218
465
|
input.select();
|
|
219
466
|
input.scrollIntoView({
|
|
220
467
|
block: "start",
|
|
221
|
-
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
468
|
+
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
469
|
+
? "auto"
|
|
470
|
+
: "smooth",
|
|
222
471
|
});
|
|
223
472
|
};
|
|
224
|
-
|
|
225
|
-
|
|
473
|
+
/**
|
|
474
|
+
* Put the reader's view into state — the query from the URL, the
|
|
475
|
+
* preferences from storage. Neither half was there before, and the missing
|
|
476
|
+
* preference half is what made a deprecated package unreachable by Back:
|
|
477
|
+
* you turned the toggle on, opened the package, came back, and the
|
|
478
|
+
* remounted catalog knew nothing about it, so the card you had just been
|
|
479
|
+
* looking at was hidden again.
|
|
480
|
+
*
|
|
481
|
+
* Unknown values are dropped rather than trusted at both doors: `kind`
|
|
482
|
+
* reaches a class name, `kw` reaches a chip that stays on screen until it
|
|
483
|
+
* is clicked off, and `sort` selects a comparator, so none of them follows
|
|
484
|
+
* a hand-typed URL or a hand-edited storage entry anywhere the controls
|
|
485
|
+
* cannot go. `kw` is checked against the catalog's own vocabulary rather
|
|
486
|
+
* than a fixed list, since keywords are whatever publishers wrote.
|
|
487
|
+
*/
|
|
488
|
+
const applyView = () => {
|
|
489
|
+
const params = new URLSearchParams(location.search);
|
|
490
|
+
const s = readPref("sort");
|
|
491
|
+
const d = readPref("dir");
|
|
492
|
+
const v = readPref("view");
|
|
493
|
+
const field = s === "updated" || s === "rating" ? s : "name";
|
|
494
|
+
const published = new Set(packages.flatMap((p) => p.keywords ?? []));
|
|
495
|
+
setQuery(params.get("q") ?? "");
|
|
496
|
+
setKinds(list(params.get("kind")).filter((k) => KNOWN_KINDS.includes(k)));
|
|
497
|
+
setKeywords(list(params.get("kw")).filter((k) => published.has(k)));
|
|
498
|
+
setSort(field);
|
|
499
|
+
setDir(d === "asc" || d === "desc" ? d : NATURAL[field]);
|
|
500
|
+
// A flag: stored at all means on.
|
|
501
|
+
setShowDeprecated(readPref("deprecated") !== null);
|
|
502
|
+
setView(v === "table" ? "table" : "cards");
|
|
503
|
+
};
|
|
504
|
+
// Apply the URL's view, now that hydration has matched the server's markup
|
|
505
|
+
// and Preact owns the tree. A layout effect rather than a plain one: the
|
|
226
506
|
// resulting render must land before the browser paints, or a `?q=` visitor
|
|
227
507
|
// sees the whole catalog flash past on the way to their results.
|
|
228
508
|
useLayoutEffect(() => {
|
|
229
|
-
|
|
509
|
+
applyView();
|
|
230
510
|
setSeeded(true);
|
|
231
511
|
}, []);
|
|
512
|
+
// Back and Forward within the catalog — a keyword chip on a package page
|
|
513
|
+
// links to `/?q=…`, so the reader can land here more than once without a
|
|
514
|
+
// reload, and `popstate` is the only notice of it.
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
const onPop = () => applyView();
|
|
517
|
+
addEventListener("popstate", onPop);
|
|
518
|
+
return () => removeEventListener("popstate", onPop);
|
|
519
|
+
}, []);
|
|
520
|
+
// The query, into the URL — so it can be shared, and so Back lands on the
|
|
521
|
+
// search the reader left. `replaceState`, not `pushState`: a history entry
|
|
522
|
+
// per keystroke would make Back mean "undo one letter" rather than "the
|
|
523
|
+
// page I came from". Gated on `seeded`, since writing before the URL has
|
|
524
|
+
// been read would erase a deep link on arrival.
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
if (!seeded)
|
|
527
|
+
return;
|
|
528
|
+
const params = new URLSearchParams(location.search);
|
|
529
|
+
const set = (key, value) => value === null ? params.delete(key) : params.set(key, value);
|
|
530
|
+
set("q", query || null);
|
|
531
|
+
set("kind", kinds.length > 0 ? kinds.join(",") : null);
|
|
532
|
+
set("kw", keywords.length > 0 ? keywords.join(",") : null);
|
|
533
|
+
const search = params.toString();
|
|
534
|
+
const next = `${location.pathname}${search ? `?${search}` : ""}${location.hash}`;
|
|
535
|
+
if (next !== `${location.pathname}${location.search}${location.hash}`) {
|
|
536
|
+
history.replaceState(history.state, "", next);
|
|
537
|
+
}
|
|
538
|
+
// `keywords` is compared by identity, which is what we want: the array is
|
|
539
|
+
// replaced on every toggle and never mutated in place.
|
|
540
|
+
}, [seeded, query, kinds, keywords]);
|
|
541
|
+
// The preferences, into storage — so the next visit opens the way this one
|
|
542
|
+
// ended. Each is stored only when it is not the default, so a reader who
|
|
543
|
+
// never touched a control leaves nothing behind.
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
if (!seeded)
|
|
546
|
+
return;
|
|
547
|
+
writePref("sort", sort === "name" ? null : sort);
|
|
548
|
+
writePref("dir", dir === NATURAL[sort] ? null : dir);
|
|
549
|
+
writePref("deprecated", showDeprecated ? "1" : null);
|
|
550
|
+
writePref("view", view === "cards" ? null : view);
|
|
551
|
+
}, [seeded, sort, dir, showDeprecated, view]);
|
|
232
552
|
// Base.astro hides the catalog before first paint when the URL carries a
|
|
233
553
|
// query. Reveal it only once the filtered render is in the DOM — keyed on
|
|
234
554
|
// `seeded`, so the unfiltered first render is never the one revealed.
|
|
@@ -282,28 +602,38 @@ export default function Catalog({ packages, vscodeExtension, }) {
|
|
|
282
602
|
const active = document.activeElement;
|
|
283
603
|
// Also true for a control *inside* a card, which is still the card
|
|
284
604
|
// being selected as far as the reader is concerned.
|
|
285
|
-
const card = active instanceof HTMLElement ? active.closest("li.card") : null;
|
|
286
|
-
|
|
287
|
-
|
|
605
|
+
const card = active instanceof HTMLElement ? active.closest("li.card, a.row") : null;
|
|
606
|
+
// Nothing selected: not our key.
|
|
607
|
+
if (!query && kinds.length === 0 && keywords.length === 0 && !card)
|
|
608
|
+
return;
|
|
288
609
|
event.preventDefault();
|
|
289
610
|
setQuery("");
|
|
290
|
-
|
|
611
|
+
setKinds([]);
|
|
612
|
+
setKeywords([]);
|
|
291
613
|
if (card)
|
|
292
614
|
active.blur();
|
|
293
615
|
};
|
|
294
616
|
document.addEventListener("keydown", onEscape);
|
|
295
617
|
return () => document.removeEventListener("keydown", onEscape);
|
|
296
|
-
}, [query,
|
|
618
|
+
}, [query, kinds, keywords]);
|
|
619
|
+
/** Both facets toggle the same way; only the relation between values differs. */
|
|
620
|
+
const toggle = (set) => (value) => set((was) => was.includes(value) ? was.filter((v) => v !== value) : [...was, value]);
|
|
621
|
+
const toggleKind = toggle(setKinds);
|
|
622
|
+
// Appends rather than inserts, so the pinned chips below stay in the order
|
|
623
|
+
// they were picked — the rail reorders underneath them, the actives do not.
|
|
624
|
+
const toggleKeyword = toggle(setKeywords);
|
|
297
625
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* the
|
|
301
|
-
*
|
|
626
|
+
* Arrow keys move *across* a rail the reader is already standing in. They
|
|
627
|
+
* are not Tab's replacement, and this is the correction of a real defect:
|
|
628
|
+
* the chips used to carry `tabIndex={-1}` whenever the grid had anything
|
|
629
|
+
* in it, which left filtering reachable by pointer and arrow key only.
|
|
630
|
+
* That is a WCAG 2.1.1 (A) failure — every control has to be operable from
|
|
631
|
+
* the keyboard through the ordinary sequence, and an undocumented arrow
|
|
632
|
+
* convention is not that sequence. The sibling `@ocx-sh/catalog` renderer
|
|
633
|
+
* shipped the same shortcut and reverted it for the same reason.
|
|
302
634
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* order (see `chipTabIndex` below), which is also exactly when a reader
|
|
306
|
-
* needs them most.
|
|
635
|
+
* So the chips are ordinary Tab stops now, and ArrowUp/ArrowDown remain as
|
|
636
|
+
* the faster way to cross a long rail or drop back into the grid.
|
|
307
637
|
*/
|
|
308
638
|
const onChipKeyDown = (event) => {
|
|
309
639
|
const chips = chipsOf();
|
|
@@ -334,7 +664,33 @@ export default function Catalog({ packages, vscodeExtension, }) {
|
|
|
334
664
|
event.preventDefault();
|
|
335
665
|
cardsOf()[0]?.focus();
|
|
336
666
|
}
|
|
337
|
-
else if (event.key === "
|
|
667
|
+
else if (event.key === "Tab" &&
|
|
668
|
+
!event.shiftKey &&
|
|
669
|
+
!event.metaKey &&
|
|
670
|
+
!event.ctrlKey &&
|
|
671
|
+
!event.altKey) {
|
|
672
|
+
// The hatch. Everything between the field and the grid — sort, the
|
|
673
|
+
// view toggle, every chip — sits after it in the DOM and is a real Tab
|
|
674
|
+
// stop again, so plain Tab would walk the whole toolbar before
|
|
675
|
+
// reaching a single package. Forward Tab skips to the results; the
|
|
676
|
+
// toolbar stays reachable by Shift+Tab back out of the grid.
|
|
677
|
+
//
|
|
678
|
+
// Reordering the DOM instead would have put focus order at odds with
|
|
679
|
+
// visual order, which is the worse defect of the two.
|
|
680
|
+
//
|
|
681
|
+
// With nothing to jump to — an empty result set — Tab is left alone
|
|
682
|
+
// rather than swallowed: trapping focus in the field is worse than the
|
|
683
|
+
// walk it was meant to save.
|
|
684
|
+
const first = cardsOf()[0];
|
|
685
|
+
if (!first)
|
|
686
|
+
return;
|
|
687
|
+
event.preventDefault();
|
|
688
|
+
first.focus();
|
|
689
|
+
}
|
|
690
|
+
else if (event.key === "Escape" &&
|
|
691
|
+
!query &&
|
|
692
|
+
kinds.length === 0 &&
|
|
693
|
+
keywords.length === 0) {
|
|
338
694
|
// Clearing is the document handler's job; this is only the second
|
|
339
695
|
// press, once there is nothing left to clear — so Escape leaves the
|
|
340
696
|
// field rather than being a dead key.
|
|
@@ -382,6 +738,11 @@ export default function Catalog({ packages, vscodeExtension, }) {
|
|
|
382
738
|
// mouse must keep its own Space/Enter behaviour.
|
|
383
739
|
if (event.target !== card)
|
|
384
740
|
return;
|
|
741
|
+
// A table row *is* an anchor, so Enter is the browser's to handle —
|
|
742
|
+
// swallowing it here would break activation rather than provide it.
|
|
743
|
+
// Only the card needs its title link clicked on its behalf.
|
|
744
|
+
if (card instanceof HTMLAnchorElement)
|
|
745
|
+
return;
|
|
385
746
|
event.preventDefault();
|
|
386
747
|
card.querySelector("h2 a")?.click();
|
|
387
748
|
return;
|
|
@@ -393,18 +754,24 @@ export default function Catalog({ packages, vscodeExtension, }) {
|
|
|
393
754
|
// deprecated entries drop out of those totals too while they are hidden,
|
|
394
755
|
// so no count ever promises more than the grid shows.
|
|
395
756
|
const counted = useMemo(() => (showDeprecated ? packages : packages.filter((p) => !p.deprecated)), [packages, showDeprecated]);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
757
|
+
// Which kinds this catalog publishes, in chip order. No counts on the
|
|
758
|
+
// chips: they cost every chip the width of a number, which is width the
|
|
759
|
+
// keyword rail beside them needs more, and the meta row already states how
|
|
760
|
+
// many packages the filters left. A per-chip count is also the harder one
|
|
761
|
+
// to read honestly — kinds are an OR group, so a count taken after the
|
|
762
|
+
// filter says "3" about a chip that is about to reveal thirty.
|
|
763
|
+
const kindNames = useMemo(() => {
|
|
764
|
+
const seen = new Set(counted.map((p) => p.kind));
|
|
765
|
+
return [...seen].sort((a, b) => kindOrder(a) - kindOrder(b) || a.localeCompare(b));
|
|
401
766
|
}, [counted]);
|
|
402
767
|
const q = query.trim().toLowerCase();
|
|
403
|
-
// Query and
|
|
768
|
+
// Query and facets first, deprecation last — so the toggle can report how
|
|
404
769
|
// many entries *it alone* is holding back, rather than a catalog-wide
|
|
405
770
|
// number that has nothing to do with what is on screen.
|
|
406
771
|
const matching = packages.filter((p) => {
|
|
407
|
-
if (
|
|
772
|
+
if (kinds.length > 0 && !kinds.includes(p.kind))
|
|
773
|
+
return false;
|
|
774
|
+
if (!keywords.every((kw) => p.keywords?.includes(kw)))
|
|
408
775
|
return false;
|
|
409
776
|
if (!q)
|
|
410
777
|
return true;
|
|
@@ -418,16 +785,114 @@ export default function Catalog({ packages, vscodeExtension, }) {
|
|
|
418
785
|
(p.keywords ?? []).join(" "),
|
|
419
786
|
].some((field) => field.toLowerCase().includes(q));
|
|
420
787
|
});
|
|
421
|
-
const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) => compare(a, b, sort));
|
|
788
|
+
const shown = (showDeprecated ? matching : matching.filter((p) => !p.deprecated)).sort((a, b) => compare(a, b, sort, dir));
|
|
789
|
+
/**
|
|
790
|
+
* The keyword rail, over what is on screen rather than over the catalog.
|
|
791
|
+
*
|
|
792
|
+
* Two decisions, both borrowed from `@ocx-sh/catalog` and both load-bearing:
|
|
793
|
+
*
|
|
794
|
+
* Active keywords are **pinned**, first and in the order they were clicked,
|
|
795
|
+
* and never scored. A filter that scrolls out of the rail is a filter the
|
|
796
|
+
* reader cannot lift. Their count is `shown.length` by construction — under
|
|
797
|
+
* AND, every surviving package carries every active keyword.
|
|
798
|
+
*
|
|
799
|
+
* The rest are picked by splitting power over `shown`, not by frequency
|
|
800
|
+
* over `packages`. A rail scored against the whole catalog keeps offering
|
|
801
|
+
* keywords no surviving package carries, and under AND that is most of
|
|
802
|
+
* them — every such chip is one click to an empty grid.
|
|
803
|
+
*
|
|
804
|
+
* The cost, accepted: the rail's contents move as the reader filters, which
|
|
805
|
+
* is what the FLIP effect above animates. The set changing invisibly is
|
|
806
|
+
* what would read as broken.
|
|
807
|
+
*/
|
|
808
|
+
const pinned = keywords.map((keyword) => ({
|
|
809
|
+
keyword,
|
|
810
|
+
count: shown.length,
|
|
811
|
+
}));
|
|
812
|
+
const rail = selectRailKeywords(shown, KEYWORD_CHIP_LIMIT)
|
|
813
|
+
// `selectRailKeywords` scores the actives like any other keyword, so
|
|
814
|
+
// over-request and drop them rather than spend rail slots twice.
|
|
815
|
+
.filter((k) => !keywords.includes(k.keyword))
|
|
816
|
+
.slice(0, Math.max(0, KEYWORD_CHIP_LIMIT - pinned.length));
|
|
817
|
+
const visibleKeywords = [...pinned, ...rail];
|
|
818
|
+
// What the menu has to carry: everything the rail had no slot for, plus
|
|
819
|
+
// everything it has a slot for but no ROOM for. The second half is why the
|
|
820
|
+
// menu is built from `railFit` rather than from `KEYWORD_CHIP_LIMIT` — a
|
|
821
|
+
// chip clipped at the rail's edge is one the reader cannot reach anywhere
|
|
822
|
+
// else, and a "+N more" that does not count it is lying about where it is.
|
|
823
|
+
const clippedKeywords = visibleKeywords.slice(railFit).map((k) => k.keyword);
|
|
824
|
+
const menuKeywords = keywordFrequency(shown).filter((k) => clippedKeywords.includes(k.keyword) ||
|
|
825
|
+
!visibleKeywords.some((v) => v.keyword === k.keyword));
|
|
826
|
+
// Plain substring, not a fuzzy match: this searches a list the reader is
|
|
827
|
+
// looking at, and every entry in it is one short known word.
|
|
828
|
+
const menuShown = menuKeywords.filter((k) => k.keyword.toLowerCase().includes(keywordFilter.trim().toLowerCase()));
|
|
422
829
|
// A catalog with nothing deprecated gets no toggle — a control that can
|
|
423
830
|
// only ever be a no-op is worse than its absence. An index that publishes
|
|
424
831
|
// no ratings gets no rating chip for the same reason.
|
|
425
832
|
const hasDeprecated = packages.some((p) => p.deprecated);
|
|
426
833
|
const hasRatings = packages.some((p) => p.rating);
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
834
|
+
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) => {
|
|
835
|
+
// Past the measured fit: still laid out, so the measurement
|
|
836
|
+
// that decided this stays true on the next pass, but drawn
|
|
837
|
+
// as nothing and out of reach. Removing it from the flow
|
|
838
|
+
// instead would free the width that excluded it, which is
|
|
839
|
+
// the oscillation this shape exists to avoid.
|
|
840
|
+
const clipped = i >= railFit;
|
|
841
|
+
return (_jsx("button", { ref: (el) => {
|
|
842
|
+
// The FLIP effect measures whatever is in this map, so
|
|
843
|
+
// a chip that leaves has to leave the map with it —
|
|
844
|
+
// Preact calls back with null on unmount for that.
|
|
845
|
+
if (el)
|
|
846
|
+
railRefs.current.set(keyword, el);
|
|
847
|
+
else
|
|
848
|
+
railRefs.current.delete(keyword);
|
|
849
|
+
}, type: "button", class: [
|
|
850
|
+
"chip kw",
|
|
851
|
+
keywords.includes(keyword) ? "active" : "",
|
|
852
|
+
clipped ? "clipped" : "",
|
|
853
|
+
]
|
|
854
|
+
.filter(Boolean)
|
|
855
|
+
.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));
|
|
856
|
+
}) })] })), menuKeywords.length > 0 && (_jsxs("div", { class: "kw-menu", children: [_jsxs("button", { type: "button", class: "chip", "data-slot": "filter-chip", ref: kwTriggerRef, popovertarget: KEYWORD_MENU_ID, "aria-expanded": kwMenuOpen, children: ["+", menuKeywords.length, " more"] }), _jsxs("div", { class: "kw-menu-panel", id: KEYWORD_MENU_ID, popover: "auto", ref: kwMenuRef,
|
|
857
|
+
// Both, and in this order: `beforetoggle` runs synchronously
|
|
858
|
+
// inside the show steps, so the panel is seated before it is
|
|
859
|
+
// ever painted; `toggle` runs after, when its width can
|
|
860
|
+
// actually be measured for the clamp.
|
|
861
|
+
onBeforeToggle: (e) => {
|
|
862
|
+
setKwMenuOpen(e.newState === "open");
|
|
863
|
+
placeKwMenu();
|
|
864
|
+
}, onToggle: (e) => {
|
|
865
|
+
setKwMenuOpen(e.newState === "open");
|
|
866
|
+
placeKwMenu();
|
|
867
|
+
}, 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." }))] })] })] })), keywords.length > 0 && (_jsxs("button", { type: "button", class: "chip kw-clear", "data-slot": "filter-chip", title: "Clear the keyword filters", onKeyDown: onChipKeyDown, onClick: (e) => {
|
|
868
|
+
setKeywords([]);
|
|
869
|
+
// This button is the last thing standing when it is pressed:
|
|
870
|
+
// clearing the facets unmounts it, and focus would land on
|
|
871
|
+
// `<body>`, sending a keyboard reader back to the top of the
|
|
872
|
+
// document. `detail === 0` is a click synthesized by Enter or
|
|
873
|
+
// Space, so a pointer user is left alone and a keyboard one
|
|
874
|
+
// gets the toolbar's own anchor instead of nothing.
|
|
875
|
+
if (e.detail === 0)
|
|
876
|
+
searchRef.current?.focus();
|
|
877
|
+
}, children: [_jsx(X, { size: 13, "aria-hidden": "true" }), "clear ", keywords.length] })), hasDeprecated && (_jsx("button", { type: "button", class: showDeprecated
|
|
878
|
+
? "chip deprecated-toggle active"
|
|
879
|
+
: "chip deprecated-toggle", "aria-pressed": showDeprecated, title: showDeprecated
|
|
880
|
+
? "Hide deprecated packages"
|
|
881
|
+
: "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
|
|
882
|
+
? `${counted.length} packages`
|
|
883
|
+
: `${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"
|
|
884
|
+
? "Ascending — click for descending"
|
|
885
|
+
: "Descending — click for ascending", "aria-label": dir === "asc"
|
|
886
|
+
? "Sorted ascending; sort descending"
|
|
887
|
+
: "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) => {
|
|
888
|
+
const next = event.currentTarget
|
|
889
|
+
.value;
|
|
890
|
+
setSort(next);
|
|
891
|
+
// Picking a field takes that field's own direction. Carrying
|
|
892
|
+
// the previous one over lands the reader on "oldest first"
|
|
893
|
+
// because they had asked for Z→A a moment ago.
|
|
894
|
+
setDir(NATURAL[next]);
|
|
895
|
+
}, 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) => {
|
|
896
|
+
gridRef.current = el;
|
|
897
|
+
}, children: shown.map((p) => (_jsx(PackageCard, { pkg: p, vscodeExtension: vscodeExtension, activeKeywords: keywords, onToggleKeyword: toggleKeyword, onKeyDown: onCardKeyDown }, `${p.namespace}/${p.name}`))) }))] }));
|
|
433
898
|
}
|