@grimoire-rs/indexer 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +190 -0
- package/NOTICE +30 -0
- package/README.md +76 -331
- 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/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 +467 -108
- package/dist/renderer/astro/components/Catalog.tsx +756 -349
- 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 +860 -206
- 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/commands.d.ts +58 -0
- package/dist/renderer/astro/lib/commands.js +86 -0
- package/dist/renderer/astro/lib/commands.ts +117 -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 +340 -195
- package/dist/renderer/astro/styles/tokens.css +40 -5
- package/dist/renderer/index.d.ts +58 -0
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +547 -5
- 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/ci/github-ratings.yml +5 -0
- package/templates/gitignore +4 -1
- package/templates/theme/README.md +38 -0
- package/templates/tsconfig.json +47 -0
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "preact/hooks";
|
|
8
|
+
// Lucide (ISC) draws the toolbar. The brand marks and kind glyphs moved out
|
|
9
|
+
// with the card and the row that wear them.
|
|
10
|
+
import {
|
|
11
|
+
ArrowDownWideNarrow,
|
|
12
|
+
ArrowUpNarrowWide,
|
|
13
|
+
LayoutGrid,
|
|
14
|
+
List,
|
|
15
|
+
} from "lucide-preact";
|
|
16
|
+
import { PackageCard } from "./PackageCard.js";
|
|
17
|
+
import { PackageRow } from "./PackageRow.js";
|
|
18
|
+
import { keywordFrequency, selectRailKeywords } from "../lib/keywordRail.js";
|
|
19
|
+
import { lastUpdated, type CatalogPackage } from "../lib/catalog.js";
|
|
9
20
|
|
|
10
21
|
// Known kinds get stable chip ordering + badge colors; unknown kinds
|
|
11
22
|
// (future schema growth) still render with a neutral badge.
|
|
@@ -17,6 +28,33 @@ function kindOrder(kind: string): number {
|
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export type Sort = "name" | "updated" | "rating";
|
|
31
|
+
export type Dir = "asc" | "desc";
|
|
32
|
+
/** Roomy cards, or the same packages as a scannable list. */
|
|
33
|
+
export type View = "cards" | "table";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How many keyword chips the rail shows at once, actives included.
|
|
37
|
+
*
|
|
38
|
+
* Everything past it goes behind the overflow menu. The cap is the point:
|
|
39
|
+
* this catalog's keyword vocabulary is open-ended, and a rail that renders
|
|
40
|
+
* all of it is a wall of chips nobody reads.
|
|
41
|
+
*/
|
|
42
|
+
const KEYWORD_CHIP_LIMIT = 8;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The direction each field is *worth* reading first in — A→Z for a name,
|
|
46
|
+
* newest and best-liked first for the two ranked keys.
|
|
47
|
+
*
|
|
48
|
+
* Picking a field selects its natural direction; the toggle beside the
|
|
49
|
+
* combo box reverses that. So "descending" is not a global default a reader
|
|
50
|
+
* has to correct on every mode, and the arrow always describes what the
|
|
51
|
+
* order actually is rather than which way a flag is set.
|
|
52
|
+
*/
|
|
53
|
+
export const NATURAL: Record<Sort, Dir> = {
|
|
54
|
+
name: "asc",
|
|
55
|
+
updated: "desc",
|
|
56
|
+
rating: "desc",
|
|
57
|
+
};
|
|
20
58
|
|
|
21
59
|
type Key = (a: CatalogPackage, b: CatalogPackage) => number;
|
|
22
60
|
|
|
@@ -46,7 +84,8 @@ function updatedAt(p: CatalogPackage): number | null {
|
|
|
46
84
|
* that is not total is a browse order that reshuffles on rebuild.
|
|
47
85
|
*/
|
|
48
86
|
const byName: Key = (a, b) =>
|
|
49
|
-
a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) ||
|
|
87
|
+
a.name.localeCompare(b.name, undefined, { sensitivity: "accent" }) ||
|
|
88
|
+
a.ref.localeCompare(b.ref);
|
|
50
89
|
|
|
51
90
|
/**
|
|
52
91
|
* Newest first. No usable date is *unknown*, not epoch 0: dating an undated
|
|
@@ -60,7 +99,8 @@ const byUpdated: Key = (a, b) => descending(updatedAt(a), updatedAt(b));
|
|
|
60
99
|
* a fresh index is all-unrated, and zeroes would leave every one of those
|
|
61
100
|
* rows comparing equal with nothing left to break the tie.
|
|
62
101
|
*/
|
|
63
|
-
const byRating: Key = (a, b) =>
|
|
102
|
+
const byRating: Key = (a, b) =>
|
|
103
|
+
descending(a.rating?.up ?? null, b.rating?.up ?? null);
|
|
64
104
|
|
|
65
105
|
/** Each mode as a chain of keys, most significant first. */
|
|
66
106
|
const CHAINS: Record<Sort, Key[]> = {
|
|
@@ -74,10 +114,18 @@ const CHAINS: Record<Sort, Key[]> = {
|
|
|
74
114
|
// any other row when the toggle brings them back. grim's own browse order
|
|
75
115
|
// (`browse_sort.rs`) has no deprecated key either; keeping this comparator
|
|
76
116
|
// silent on deprecation is what keeps the two in sync.
|
|
77
|
-
export function compare(
|
|
117
|
+
export function compare(
|
|
118
|
+
a: CatalogPackage,
|
|
119
|
+
b: CatalogPackage,
|
|
120
|
+
sort: Sort,
|
|
121
|
+
dir: Dir = NATURAL[sort],
|
|
122
|
+
): number {
|
|
78
123
|
for (const key of CHAINS[sort]) {
|
|
79
124
|
const d = key(a, b);
|
|
80
|
-
|
|
125
|
+
// Reversed means reversed all the way down, the ref tiebreak included.
|
|
126
|
+
// Every chain ends on a unique key, so no two rows compare equal and
|
|
127
|
+
// negating the whole answer leaves the order just as total as it was.
|
|
128
|
+
if (d !== 0) return dir === NATURAL[sort] ? d : -d;
|
|
81
129
|
}
|
|
82
130
|
return 0;
|
|
83
131
|
}
|
|
@@ -87,11 +135,57 @@ export function compare(a: CatalogPackage, b: CatalogPackage, sort: Sort): numbe
|
|
|
87
135
|
// is gone on purpose: every icon now comes from one set. `FolderRoot` and
|
|
88
136
|
// `Globe` are the nearest Lucide equivalents and carry the same meaning.
|
|
89
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The reader's own preferences, kept out of the URL.
|
|
140
|
+
*
|
|
141
|
+
* The split is deliberate and matches grim: `q`, `kind` and `kw` are *what
|
|
142
|
+
* you are looking at* — a keyword chip on a package page links to
|
|
143
|
+
* `/?kw=<keyword>`, so that half has to stay shareable — while sort,
|
|
144
|
+
* direction, deprecated visibility and the cards/table choice are *how you
|
|
145
|
+
* like the catalog arranged*, the same answer on every visit. grim keeps
|
|
146
|
+
* `show_deprecated` in its config file for exactly that reason.
|
|
147
|
+
*
|
|
148
|
+
* Both accessors swallow: reading `localStorage` throws outright, not
|
|
149
|
+
* returns null, in a browser set to block site data, and a catalog is not
|
|
150
|
+
* worth a blank page. A reader who blocks it browses without preferences.
|
|
151
|
+
*/
|
|
152
|
+
const PREF = "grim.catalog.";
|
|
153
|
+
|
|
154
|
+
function readPref(key: string): string | null {
|
|
155
|
+
try {
|
|
156
|
+
return localStorage.getItem(PREF + key);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function writePref(key: string, value: string | null): void {
|
|
163
|
+
try {
|
|
164
|
+
if (value === null) localStorage.removeItem(PREF + key);
|
|
165
|
+
else localStorage.setItem(PREF + key, value);
|
|
166
|
+
} catch {
|
|
167
|
+
// Nothing to do and nothing to report: preferences are a convenience.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A comma-joined URL parameter, back into the list it was.
|
|
173
|
+
*
|
|
174
|
+
* Empty and absent are the same answer — `?kind=` is a reader who cleared
|
|
175
|
+
* the filter, not a request for the kind named "". Duplicates collapse so a
|
|
176
|
+
* hand-edited `?kw=a,a` cannot render the same chip twice.
|
|
177
|
+
*/
|
|
178
|
+
function list(value: string | null): string[] {
|
|
179
|
+
return [...new Set((value ?? "").split(",").filter(Boolean))];
|
|
180
|
+
}
|
|
181
|
+
|
|
90
182
|
/** Typing inside one of these means a bare keystroke is text, not a shortcut. */
|
|
91
183
|
function isTyping(el: EventTarget | null): boolean {
|
|
92
184
|
const node = el as HTMLElement | null;
|
|
93
185
|
if (!node) return false;
|
|
94
|
-
return
|
|
186
|
+
return (
|
|
187
|
+
node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName)
|
|
188
|
+
);
|
|
95
189
|
}
|
|
96
190
|
|
|
97
191
|
/**
|
|
@@ -100,6 +194,10 @@ function isTyping(el: EventTarget | null): boolean {
|
|
|
100
194
|
* Measured, not read from CSS: the track list is `auto-fill` with a minimum
|
|
101
195
|
* width, so the count is a layout outcome that depends on the viewport. The
|
|
102
196
|
* first card whose top edge drops below the first row's starts row two.
|
|
197
|
+
*
|
|
198
|
+
* The table view needs no branch of its own — its rows stack, so the second
|
|
199
|
+
* one is already below the first and this measures the 1 that makes every
|
|
200
|
+
* arrow key move by a single row.
|
|
103
201
|
*/
|
|
104
202
|
function columnCount(cards: HTMLElement[]): number {
|
|
105
203
|
if (cards.length < 2) return 1;
|
|
@@ -109,125 +207,57 @@ function columnCount(cards: HTMLElement[]): number {
|
|
|
109
207
|
}
|
|
110
208
|
|
|
111
209
|
/**
|
|
112
|
-
* The
|
|
210
|
+
* The same packages as a list, for reading down a column rather than across
|
|
211
|
+
* a grid.
|
|
113
212
|
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
213
|
+
* **A row is an anchor, and there is no `<table>`.** Two reasons, and the
|
|
214
|
+
* first is the load-bearing one: a header row that cannot sort is a header
|
|
215
|
+
* row that *looks* like it sorts — every reader who has met a data table
|
|
216
|
+
* clicks it once. Sorting lives in the toolbar, so the table has no headers,
|
|
217
|
+
* and a headerless table has no column semantics left to justify the element.
|
|
218
|
+
* What remains is a list of links, which is what this is. A CSS grid with
|
|
219
|
+
* `subgrid` rows keeps the columns aligned without the markup.
|
|
121
220
|
*
|
|
122
|
-
* The
|
|
123
|
-
*
|
|
124
|
-
*
|
|
221
|
+
* The anchor is also what makes the whole row clickable, focusable and
|
|
222
|
+
* middle-clickable for free — no stretched-link overlay, no synthetic Enter
|
|
223
|
+
* handler. A row carries no controls of its own: the install buttons and the
|
|
224
|
+
* vote links are what the card exists for, and repeating them per row would
|
|
225
|
+
* be five columns of icons. The detail page has all of them.
|
|
226
|
+
*
|
|
227
|
+
* Columns are fixed, unlike the keyword rail above — deliberately. A column
|
|
228
|
+
* is a slot the eye tracks down; one that appears and disappears as the
|
|
229
|
+
* filters change destroys the alignment the table exists to give. The rating
|
|
230
|
+
* column is the one exception, and it is decided once per index rather than
|
|
231
|
+
* per filter.
|
|
125
232
|
*/
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// parsing the HTML — long before this island hydrates. Two consequences,
|
|
132
|
-
// and the slot markup below answers both: `onError` can fire before any
|
|
133
|
-
// listener exists (the placeholder used to appear only sometimes), and a
|
|
134
|
-
// failed image paints the browser's broken glyph on the way (the flash on
|
|
135
|
-
// reload). Starting the image hidden means nothing is ever shown until it
|
|
136
|
-
// is known to be good.
|
|
137
|
-
//
|
|
138
|
-
// `complete` says the browser finished, not how it went. `decode()` is
|
|
139
|
-
// what separates the two: it rejects for a failure and resolves for a good
|
|
140
|
-
// image — including an SVG with no intrinsic size, where the usual
|
|
141
|
-
// `naturalWidth === 0` test reports a false failure. Gating on `complete`
|
|
142
|
-
// means it never starts a fetch, so `loading="lazy"` still holds off
|
|
143
|
-
// -screen cards.
|
|
144
|
-
useEffect(() => {
|
|
145
|
-
setState("loading");
|
|
146
|
-
const img = imgRef.current;
|
|
147
|
-
if (!img?.complete) return;
|
|
148
|
-
let live = true;
|
|
149
|
-
img.decode().then(
|
|
150
|
-
() => live && setState("ready"),
|
|
151
|
-
() => live && setState("broken"),
|
|
152
|
-
);
|
|
153
|
-
return () => {
|
|
154
|
-
live = false;
|
|
155
|
-
};
|
|
156
|
-
}, [pkg.logo]);
|
|
157
|
-
|
|
158
|
-
if (!pkg.logo) {
|
|
159
|
-
return (
|
|
160
|
-
<span
|
|
161
|
-
class="card-logo card-logo-fallback"
|
|
162
|
-
aria-hidden="true"
|
|
163
|
-
style={{ background: `var(--grim-color-kind-${pkg.kind}, var(--grim-color-muted))` }}
|
|
164
|
-
>
|
|
165
|
-
{pkg.name[0]?.toUpperCase()}
|
|
166
|
-
</span>
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return (
|
|
171
|
-
<span
|
|
172
|
-
class="card-logo logo-slot"
|
|
173
|
-
data-state={state}
|
|
174
|
-
role={state === "broken" ? "img" : undefined}
|
|
175
|
-
aria-label={state === "broken" ? "Logo image unavailable" : undefined}
|
|
176
|
-
title={state === "broken" ? "Logo image unavailable" : undefined}
|
|
177
|
-
>
|
|
178
|
-
{state === "broken" ? (
|
|
179
|
-
<ImageOff class="logo-mark" aria-hidden="true" />
|
|
180
|
-
) : (
|
|
181
|
-
<Image class="logo-mark" aria-hidden="true" />
|
|
182
|
-
)}
|
|
183
|
-
<img
|
|
184
|
-
ref={imgRef}
|
|
185
|
-
src={withBase(pkg.logo)}
|
|
186
|
-
alt=""
|
|
187
|
-
loading="lazy"
|
|
188
|
-
onLoad={() => setState("ready")}
|
|
189
|
-
onError={() => setState("broken")}
|
|
190
|
-
/>
|
|
191
|
-
</span>
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function CopyButton({
|
|
196
|
-
command,
|
|
197
|
-
variant = "default",
|
|
198
|
-
name,
|
|
233
|
+
function PackageTable({
|
|
234
|
+
packages,
|
|
235
|
+
hasRatings,
|
|
236
|
+
onKeyDown,
|
|
237
|
+
rootRef,
|
|
199
238
|
}: {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
239
|
+
packages: CatalogPackage[];
|
|
240
|
+
hasRatings: boolean;
|
|
241
|
+
onKeyDown: (event: KeyboardEvent) => void;
|
|
242
|
+
rootRef: { current: HTMLElement | null };
|
|
204
243
|
}) {
|
|
205
|
-
const [copied, setCopied] = useState(false);
|
|
206
|
-
const copy = () => {
|
|
207
|
-
navigator.clipboard.writeText(command).then(() => {
|
|
208
|
-
setCopied(true);
|
|
209
|
-
// The toast lives in Base.astro's inline script, outside this island —
|
|
210
|
-
// an event is how a hydrated component reaches it without either side
|
|
211
|
-
// importing the other.
|
|
212
|
-
document.dispatchEvent(
|
|
213
|
-
new CustomEvent("grimoire:copied", { detail: { name, value: command } }),
|
|
214
|
-
);
|
|
215
|
-
setTimeout(() => setCopied(false), 1500);
|
|
216
|
-
});
|
|
217
|
-
};
|
|
218
244
|
return (
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// is copyable from the detail page Enter opens.
|
|
226
|
-
tabIndex={-1}
|
|
227
|
-
onClick={copy}
|
|
245
|
+
<div
|
|
246
|
+
class={hasRatings ? "table rated" : "table"}
|
|
247
|
+
data-slot="package-table"
|
|
248
|
+
ref={(el) => {
|
|
249
|
+
rootRef.current = el;
|
|
250
|
+
}}
|
|
228
251
|
>
|
|
229
|
-
{
|
|
230
|
-
|
|
252
|
+
{packages.map((p) => (
|
|
253
|
+
<PackageRow
|
|
254
|
+
key={`${p.namespace}/${p.name}`}
|
|
255
|
+
pkg={p}
|
|
256
|
+
hasRatings={hasRatings}
|
|
257
|
+
onKeyDown={onKeyDown}
|
|
258
|
+
/>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
231
261
|
);
|
|
232
262
|
}
|
|
233
263
|
|
|
@@ -256,22 +286,146 @@ export default function Catalog({
|
|
|
256
286
|
// Whether the URL's query has been applied. Gates the reveal below, so the
|
|
257
287
|
// catalog is never unhidden while it still shows the unfiltered list.
|
|
258
288
|
const [seeded, setSeeded] = useState(false);
|
|
259
|
-
|
|
289
|
+
// Kinds combine with OR, keywords with AND, and the two groups with each
|
|
290
|
+
// other. That is not an inconsistency, it follows from the data: a package
|
|
291
|
+
// has exactly one kind, so requiring both of two kinds always yields
|
|
292
|
+
// nothing, while it carries many keywords, so requiring both of two is the
|
|
293
|
+
// only reading under which a second click narrows. A facet whose second
|
|
294
|
+
// click *widens* the result set reads as broken.
|
|
295
|
+
const [kinds, setKinds] = useState<string[]>([]);
|
|
296
|
+
const [keywords, setKeywords] = useState<string[]>([]);
|
|
260
297
|
const [sort, setSort] = useState<Sort>("name");
|
|
298
|
+
// Direction, not "reversed": what the arrow draws is the order itself.
|
|
299
|
+
const [dir, setDir] = useState<Dir>(NATURAL.name);
|
|
261
300
|
// Deprecated packages are hidden until asked for: a retired package is
|
|
262
301
|
// noise for someone browsing what to install, and the publisher already
|
|
263
302
|
// said as much by deprecating it.
|
|
264
303
|
const [showDeprecated, setShowDeprecated] = useState(false);
|
|
304
|
+
const [view, setView] = useState<View>("cards");
|
|
305
|
+
// Local to the overflow menu and deliberately not shareable: it narrows
|
|
306
|
+
// the list of keywords, not the catalog.
|
|
307
|
+
const [keywordFilter, setKeywordFilter] = useState("");
|
|
265
308
|
|
|
266
309
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
267
|
-
const gridRef = useRef<
|
|
310
|
+
const gridRef = useRef<HTMLElement>(null);
|
|
268
311
|
const controlsRef = useRef<HTMLDivElement>(null);
|
|
269
312
|
|
|
270
|
-
|
|
313
|
+
// Both views, one selector: a table row is the Tab stop its card is, so
|
|
314
|
+
// every keyboard path below — the search hatch, ArrowDown out of the
|
|
315
|
+
// chips, Escape's blur — works in either without knowing which is up.
|
|
316
|
+
const cardsOf = () => [
|
|
317
|
+
...(gridRef.current?.querySelectorAll<HTMLElement>("li.card, a.row") ?? []),
|
|
318
|
+
];
|
|
319
|
+
// Clipped keyword chips are excluded: they are drawn as nothing, so an
|
|
320
|
+
// arrow key that landed on one would move focus somewhere the reader
|
|
321
|
+
// cannot see it.
|
|
271
322
|
const chipsOf = () => [
|
|
272
|
-
...(controlsRef.current?.querySelectorAll<HTMLElement>(
|
|
323
|
+
...(controlsRef.current?.querySelectorAll<HTMLElement>(
|
|
324
|
+
'button.chip:not([aria-hidden="true"])',
|
|
325
|
+
) ?? []),
|
|
273
326
|
];
|
|
274
327
|
|
|
328
|
+
const railRefs = useRef(new Map<string, HTMLElement>());
|
|
329
|
+
const railRects = useRef(new Map<string, DOMRect>());
|
|
330
|
+
const railRef = useRef<HTMLDivElement>(null);
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* How many keyword chips actually fit on the row, measured.
|
|
334
|
+
*
|
|
335
|
+
* Not a constant, because the answer is a layout outcome: the rail is the
|
|
336
|
+
* one flexible child of the filter row, so its width is whatever the kinds,
|
|
337
|
+
* the overflow menu and the deprecated toggle left, and the chips are as
|
|
338
|
+
* wide as the words publishers wrote. `KEYWORD_CHIP_LIMIT` bounds how many
|
|
339
|
+
* are *offered*; this is how many are shown.
|
|
340
|
+
*
|
|
341
|
+
* The rule it enforces: the rail never wraps and never scrolls. A second
|
|
342
|
+
* row makes the toolbar a different height on every filter click, and a
|
|
343
|
+
* scrollbar hides the chips behind a gesture nobody looks for — it also
|
|
344
|
+
* pushed the overflow menu off the end of the row entirely.
|
|
345
|
+
*
|
|
346
|
+
* Every chip stays in the flow whatever this says; the ones past it are
|
|
347
|
+
* drawn as nothing (see `.chip.kw.clipped`). Taking them out of the flow
|
|
348
|
+
* would free the width that excluded them, which is a measurement that
|
|
349
|
+
* disagrees with itself on every other frame.
|
|
350
|
+
*/
|
|
351
|
+
const [railFit, setRailFit] = useState(KEYWORD_CHIP_LIMIT);
|
|
352
|
+
|
|
353
|
+
useLayoutEffect(() => {
|
|
354
|
+
const rail = railRef.current;
|
|
355
|
+
if (!rail) return;
|
|
356
|
+
const measure = () => {
|
|
357
|
+
const edge = rail.getBoundingClientRect().right;
|
|
358
|
+
let fits = 0;
|
|
359
|
+
for (const chip of rail.children) {
|
|
360
|
+
// Half a pixel of slack: a fractional layout can leave a chip's right
|
|
361
|
+
// edge a rounding error past a boundary it visually sits inside.
|
|
362
|
+
if (chip.getBoundingClientRect().right > edge + 0.5) break;
|
|
363
|
+
fits += 1;
|
|
364
|
+
}
|
|
365
|
+
// At least one, always. A rail too narrow for its shortest chip should
|
|
366
|
+
// show that chip clipped rather than render an empty group beside a
|
|
367
|
+
// divider that then divides nothing.
|
|
368
|
+
setRailFit(Math.max(1, fits));
|
|
369
|
+
};
|
|
370
|
+
measure();
|
|
371
|
+
// Guarded rather than assumed: this effect also runs under the test
|
|
372
|
+
// renderer, whose DOM has no `ResizeObserver` — and a missing one costs
|
|
373
|
+
// only re-measurement on viewport resize, which is not worth throwing
|
|
374
|
+
// during a render over.
|
|
375
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
376
|
+
const observer = new ResizeObserver(measure);
|
|
377
|
+
observer.observe(rail);
|
|
378
|
+
return () => observer.disconnect();
|
|
379
|
+
// Re-measured on every commit that changes which chips are up, since the
|
|
380
|
+
// observer only fires when the rail's own box changes and a rescore can
|
|
381
|
+
// swap a short word for a long one at the same width.
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* FLIP for the keyword rail: chips slide to their new places instead of
|
|
386
|
+
* teleporting.
|
|
387
|
+
*
|
|
388
|
+
* The rail is rescored against the current result set, so it reorders on
|
|
389
|
+
* every click — the chip just picked moves to the front and the rest flow
|
|
390
|
+
* around it. Animating that is not decoration: a rail whose contents change
|
|
391
|
+
* between two frames reads as having been *replaced*, and a reader who
|
|
392
|
+
* cannot see that a chip moved has no reason to believe it is the same one.
|
|
393
|
+
*
|
|
394
|
+
* First (the map of rects kept from the last commit), Last (measured now),
|
|
395
|
+
* Invert (an inline translate back to where the chip was), Play (dropped on
|
|
396
|
+
* the next frame, so the stylesheet's transition carries it home). Measure
|
|
397
|
+
* every chip before transforming any: `translate` composites and does not
|
|
398
|
+
* reflow, but reading a rect after writing a style on a sibling is the
|
|
399
|
+
* shape that makes a layout thrash, and this runs per keystroke.
|
|
400
|
+
*/
|
|
401
|
+
useLayoutEffect(() => {
|
|
402
|
+
const previous = railRects.current;
|
|
403
|
+
const current = new Map<string, DOMRect>();
|
|
404
|
+
const moved: { el: HTMLElement; dx: number; dy: number }[] = [];
|
|
405
|
+
for (const [keyword, el] of railRefs.current) {
|
|
406
|
+
const rect = el.getBoundingClientRect();
|
|
407
|
+
current.set(keyword, rect);
|
|
408
|
+
const was = previous.get(keyword);
|
|
409
|
+
if (!was) continue;
|
|
410
|
+
const dx = was.left - rect.left;
|
|
411
|
+
const dy = was.top - rect.top;
|
|
412
|
+
if (dx !== 0 || dy !== 0) moved.push({ el, dx, dy });
|
|
413
|
+
}
|
|
414
|
+
railRects.current = current;
|
|
415
|
+
if (moved.length === 0) return;
|
|
416
|
+
for (const { el, dx, dy } of moved) {
|
|
417
|
+
el.style.transition = "none";
|
|
418
|
+
el.style.translate = `${dx}px ${dy}px`;
|
|
419
|
+
}
|
|
420
|
+
const frame = requestAnimationFrame(() => {
|
|
421
|
+
for (const { el } of moved) {
|
|
422
|
+
el.style.transition = "";
|
|
423
|
+
el.style.translate = "";
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
return () => cancelAnimationFrame(frame);
|
|
427
|
+
});
|
|
428
|
+
|
|
275
429
|
/** Move focus `delta` cards along, clamping at both ends rather than wrapping. */
|
|
276
430
|
const focusCard = (from: number, delta: number) => {
|
|
277
431
|
const cards = cardsOf();
|
|
@@ -297,19 +451,95 @@ export default function Catalog({
|
|
|
297
451
|
input.select();
|
|
298
452
|
input.scrollIntoView({
|
|
299
453
|
block: "start",
|
|
300
|
-
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
454
|
+
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
455
|
+
? "auto"
|
|
456
|
+
: "smooth",
|
|
301
457
|
});
|
|
302
458
|
};
|
|
303
459
|
|
|
304
|
-
|
|
305
|
-
|
|
460
|
+
/**
|
|
461
|
+
* Put the reader's view into state — the query from the URL, the
|
|
462
|
+
* preferences from storage. Neither half was there before, and the missing
|
|
463
|
+
* preference half is what made a deprecated package unreachable by Back:
|
|
464
|
+
* you turned the toggle on, opened the package, came back, and the
|
|
465
|
+
* remounted catalog knew nothing about it, so the card you had just been
|
|
466
|
+
* looking at was hidden again.
|
|
467
|
+
*
|
|
468
|
+
* Unknown values are dropped rather than trusted at both doors: `kind`
|
|
469
|
+
* reaches a class name, `kw` reaches a chip that stays on screen until it
|
|
470
|
+
* is clicked off, and `sort` selects a comparator, so none of them follows
|
|
471
|
+
* a hand-typed URL or a hand-edited storage entry anywhere the controls
|
|
472
|
+
* cannot go. `kw` is checked against the catalog's own vocabulary rather
|
|
473
|
+
* than a fixed list, since keywords are whatever publishers wrote.
|
|
474
|
+
*/
|
|
475
|
+
const applyView = () => {
|
|
476
|
+
const params = new URLSearchParams(location.search);
|
|
477
|
+
const s = readPref("sort");
|
|
478
|
+
const d = readPref("dir");
|
|
479
|
+
const v = readPref("view");
|
|
480
|
+
const field: Sort = s === "updated" || s === "rating" ? s : "name";
|
|
481
|
+
const published = new Set(packages.flatMap((p) => p.keywords ?? []));
|
|
482
|
+
setQuery(params.get("q") ?? "");
|
|
483
|
+
setKinds(list(params.get("kind")).filter((k) => KNOWN_KINDS.includes(k)));
|
|
484
|
+
setKeywords(list(params.get("kw")).filter((k) => published.has(k)));
|
|
485
|
+
setSort(field);
|
|
486
|
+
setDir(d === "asc" || d === "desc" ? d : NATURAL[field]);
|
|
487
|
+
// A flag: stored at all means on.
|
|
488
|
+
setShowDeprecated(readPref("deprecated") !== null);
|
|
489
|
+
setView(v === "table" ? "table" : "cards");
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// Apply the URL's view, now that hydration has matched the server's markup
|
|
493
|
+
// and Preact owns the tree. A layout effect rather than a plain one: the
|
|
306
494
|
// resulting render must land before the browser paints, or a `?q=` visitor
|
|
307
495
|
// sees the whole catalog flash past on the way to their results.
|
|
308
496
|
useLayoutEffect(() => {
|
|
309
|
-
|
|
497
|
+
applyView();
|
|
310
498
|
setSeeded(true);
|
|
311
499
|
}, []);
|
|
312
500
|
|
|
501
|
+
// Back and Forward within the catalog — a keyword chip on a package page
|
|
502
|
+
// links to `/?q=…`, so the reader can land here more than once without a
|
|
503
|
+
// reload, and `popstate` is the only notice of it.
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
const onPop = () => applyView();
|
|
506
|
+
addEventListener("popstate", onPop);
|
|
507
|
+
return () => removeEventListener("popstate", onPop);
|
|
508
|
+
}, []);
|
|
509
|
+
|
|
510
|
+
// The query, into the URL — so it can be shared, and so Back lands on the
|
|
511
|
+
// search the reader left. `replaceState`, not `pushState`: a history entry
|
|
512
|
+
// per keystroke would make Back mean "undo one letter" rather than "the
|
|
513
|
+
// page I came from". Gated on `seeded`, since writing before the URL has
|
|
514
|
+
// been read would erase a deep link on arrival.
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
if (!seeded) return;
|
|
517
|
+
const params = new URLSearchParams(location.search);
|
|
518
|
+
const set = (key: string, value: string | null) =>
|
|
519
|
+
value === null ? params.delete(key) : params.set(key, value);
|
|
520
|
+
set("q", query || null);
|
|
521
|
+
set("kind", kinds.length > 0 ? kinds.join(",") : null);
|
|
522
|
+
set("kw", keywords.length > 0 ? keywords.join(",") : null);
|
|
523
|
+
const search = params.toString();
|
|
524
|
+
const next = `${location.pathname}${search ? `?${search}` : ""}${location.hash}`;
|
|
525
|
+
if (next !== `${location.pathname}${location.search}${location.hash}`) {
|
|
526
|
+
history.replaceState(history.state, "", next);
|
|
527
|
+
}
|
|
528
|
+
// `keywords` is compared by identity, which is what we want: the array is
|
|
529
|
+
// replaced on every toggle and never mutated in place.
|
|
530
|
+
}, [seeded, query, kinds, keywords]);
|
|
531
|
+
|
|
532
|
+
// The preferences, into storage — so the next visit opens the way this one
|
|
533
|
+
// ended. Each is stored only when it is not the default, so a reader who
|
|
534
|
+
// never touched a control leaves nothing behind.
|
|
535
|
+
useEffect(() => {
|
|
536
|
+
if (!seeded) return;
|
|
537
|
+
writePref("sort", sort === "name" ? null : sort);
|
|
538
|
+
writePref("dir", dir === NATURAL[sort] ? null : dir);
|
|
539
|
+
writePref("deprecated", showDeprecated ? "1" : null);
|
|
540
|
+
writePref("view", view === "cards" ? null : view);
|
|
541
|
+
}, [seeded, sort, dir, showDeprecated, view]);
|
|
542
|
+
|
|
313
543
|
// Base.astro hides the catalog before first paint when the URL carries a
|
|
314
544
|
// query. Reveal it only once the filtered render is in the DOM — keyed on
|
|
315
545
|
// `seeded`, so the unfiltered first render is never the one revealed.
|
|
@@ -328,7 +558,8 @@ export default function Catalog({
|
|
|
328
558
|
// shares. Bound on the document so it works wherever the reader is.
|
|
329
559
|
useEffect(() => {
|
|
330
560
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
331
|
-
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey)
|
|
561
|
+
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey)
|
|
562
|
+
return;
|
|
332
563
|
if (isTyping(event.target)) return;
|
|
333
564
|
event.preventDefault();
|
|
334
565
|
focusSearch();
|
|
@@ -360,27 +591,44 @@ export default function Catalog({
|
|
|
360
591
|
const active = document.activeElement;
|
|
361
592
|
// Also true for a control *inside* a card, which is still the card
|
|
362
593
|
// being selected as far as the reader is concerned.
|
|
363
|
-
const card =
|
|
364
|
-
|
|
594
|
+
const card =
|
|
595
|
+
active instanceof HTMLElement ? active.closest("li.card, a.row") : null;
|
|
596
|
+
// Nothing selected: not our key.
|
|
597
|
+
if (!query && kinds.length === 0 && keywords.length === 0 && !card)
|
|
598
|
+
return;
|
|
365
599
|
event.preventDefault();
|
|
366
600
|
setQuery("");
|
|
367
|
-
|
|
601
|
+
setKinds([]);
|
|
602
|
+
setKeywords([]);
|
|
368
603
|
if (card) (active as HTMLElement).blur();
|
|
369
604
|
};
|
|
370
605
|
document.addEventListener("keydown", onEscape);
|
|
371
606
|
return () => document.removeEventListener("keydown", onEscape);
|
|
372
|
-
}, [query,
|
|
607
|
+
}, [query, kinds, keywords]);
|
|
608
|
+
|
|
609
|
+
/** Both facets toggle the same way; only the relation between values differs. */
|
|
610
|
+
const toggle =
|
|
611
|
+
(set: (next: (was: string[]) => string[]) => void) => (value: string) =>
|
|
612
|
+
set((was) =>
|
|
613
|
+
was.includes(value) ? was.filter((v) => v !== value) : [...was, value],
|
|
614
|
+
);
|
|
615
|
+
const toggleKind = toggle(setKinds);
|
|
616
|
+
// Appends rather than inserts, so the pinned chips below stay in the order
|
|
617
|
+
// they were picked — the rail reorders underneath them, the actives do not.
|
|
618
|
+
const toggleKeyword = toggle(setKeywords);
|
|
373
619
|
|
|
374
620
|
/**
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
* the
|
|
378
|
-
*
|
|
621
|
+
* Arrow keys move *across* a rail the reader is already standing in. They
|
|
622
|
+
* are not Tab's replacement, and this is the correction of a real defect:
|
|
623
|
+
* the chips used to carry `tabIndex={-1}` whenever the grid had anything
|
|
624
|
+
* in it, which left filtering reachable by pointer and arrow key only.
|
|
625
|
+
* That is a WCAG 2.1.1 (A) failure — every control has to be operable from
|
|
626
|
+
* the keyboard through the ordinary sequence, and an undocumented arrow
|
|
627
|
+
* convention is not that sequence. The sibling `@ocx-sh/catalog` renderer
|
|
628
|
+
* shipped the same shortcut and reverted it for the same reason.
|
|
379
629
|
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
* order (see `chipTabIndex` below), which is also exactly when a reader
|
|
383
|
-
* needs them most.
|
|
630
|
+
* So the chips are ordinary Tab stops now, and ArrowUp/ArrowDown remain as
|
|
631
|
+
* the faster way to cross a long rail or drop back into the grid.
|
|
384
632
|
*/
|
|
385
633
|
const onChipKeyDown = (event: KeyboardEvent) => {
|
|
386
634
|
const chips = chipsOf();
|
|
@@ -410,7 +658,35 @@ export default function Catalog({
|
|
|
410
658
|
if (event.key === "ArrowDown") {
|
|
411
659
|
event.preventDefault();
|
|
412
660
|
cardsOf()[0]?.focus();
|
|
413
|
-
} else if (
|
|
661
|
+
} else if (
|
|
662
|
+
event.key === "Tab" &&
|
|
663
|
+
!event.shiftKey &&
|
|
664
|
+
!event.metaKey &&
|
|
665
|
+
!event.ctrlKey &&
|
|
666
|
+
!event.altKey
|
|
667
|
+
) {
|
|
668
|
+
// The hatch. Everything between the field and the grid — sort, the
|
|
669
|
+
// view toggle, every chip — sits after it in the DOM and is a real Tab
|
|
670
|
+
// stop again, so plain Tab would walk the whole toolbar before
|
|
671
|
+
// reaching a single package. Forward Tab skips to the results; the
|
|
672
|
+
// toolbar stays reachable by Shift+Tab back out of the grid.
|
|
673
|
+
//
|
|
674
|
+
// Reordering the DOM instead would have put focus order at odds with
|
|
675
|
+
// visual order, which is the worse defect of the two.
|
|
676
|
+
//
|
|
677
|
+
// With nothing to jump to — an empty result set — Tab is left alone
|
|
678
|
+
// rather than swallowed: trapping focus in the field is worse than the
|
|
679
|
+
// walk it was meant to save.
|
|
680
|
+
const first = cardsOf()[0];
|
|
681
|
+
if (!first) return;
|
|
682
|
+
event.preventDefault();
|
|
683
|
+
first.focus();
|
|
684
|
+
} else if (
|
|
685
|
+
event.key === "Escape" &&
|
|
686
|
+
!query &&
|
|
687
|
+
kinds.length === 0 &&
|
|
688
|
+
keywords.length === 0
|
|
689
|
+
) {
|
|
414
690
|
// Clearing is the document handler's job; this is only the second
|
|
415
691
|
// press, once there is nothing left to clear — so Escape leaves the
|
|
416
692
|
// field rather than being a dead key.
|
|
@@ -457,6 +733,10 @@ export default function Catalog({
|
|
|
457
733
|
// Only when the card itself holds focus: an inner control reached by
|
|
458
734
|
// mouse must keep its own Space/Enter behaviour.
|
|
459
735
|
if (event.target !== card) return;
|
|
736
|
+
// A table row *is* an anchor, so Enter is the browser's to handle —
|
|
737
|
+
// swallowing it here would break activation rather than provide it.
|
|
738
|
+
// Only the card needs its title link clicked on its behalf.
|
|
739
|
+
if (card instanceof HTMLAnchorElement) return;
|
|
460
740
|
event.preventDefault();
|
|
461
741
|
card.querySelector<HTMLAnchorElement>("h2 a")?.click();
|
|
462
742
|
return;
|
|
@@ -473,20 +753,26 @@ export default function Catalog({
|
|
|
473
753
|
[packages, showDeprecated],
|
|
474
754
|
);
|
|
475
755
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
756
|
+
// Which kinds this catalog publishes, in chip order. No counts on the
|
|
757
|
+
// chips: they cost every chip the width of a number, which is width the
|
|
758
|
+
// keyword rail beside them needs more, and the meta row already states how
|
|
759
|
+
// many packages the filters left. A per-chip count is also the harder one
|
|
760
|
+
// to read honestly — kinds are an OR group, so a count taken after the
|
|
761
|
+
// filter says "3" about a chip that is about to reveal thirty.
|
|
762
|
+
const kindNames = useMemo(() => {
|
|
763
|
+
const seen = new Set(counted.map((p) => p.kind));
|
|
764
|
+
return [...seen].sort(
|
|
765
|
+
(a, b) => kindOrder(a) - kindOrder(b) || a.localeCompare(b),
|
|
481
766
|
);
|
|
482
767
|
}, [counted]);
|
|
483
768
|
|
|
484
769
|
const q = query.trim().toLowerCase();
|
|
485
|
-
// Query and
|
|
770
|
+
// Query and facets first, deprecation last — so the toggle can report how
|
|
486
771
|
// many entries *it alone* is holding back, rather than a catalog-wide
|
|
487
772
|
// number that has nothing to do with what is on screen.
|
|
488
773
|
const matching = packages.filter((p) => {
|
|
489
|
-
if (
|
|
774
|
+
if (kinds.length > 0 && !kinds.includes(p.kind)) return false;
|
|
775
|
+
if (!keywords.every((kw) => p.keywords?.includes(kw))) return false;
|
|
490
776
|
if (!q) return true;
|
|
491
777
|
return [
|
|
492
778
|
p.name,
|
|
@@ -498,8 +784,54 @@ export default function Catalog({
|
|
|
498
784
|
(p.keywords ?? []).join(" "),
|
|
499
785
|
].some((field) => field.toLowerCase().includes(q));
|
|
500
786
|
});
|
|
501
|
-
const shown = (
|
|
502
|
-
|
|
787
|
+
const shown = (
|
|
788
|
+
showDeprecated ? matching : matching.filter((p) => !p.deprecated)
|
|
789
|
+
).sort((a, b) => compare(a, b, sort, dir));
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* The keyword rail, over what is on screen rather than over the catalog.
|
|
793
|
+
*
|
|
794
|
+
* Two decisions, both borrowed from `@ocx-sh/catalog` and both load-bearing:
|
|
795
|
+
*
|
|
796
|
+
* Active keywords are **pinned**, first and in the order they were clicked,
|
|
797
|
+
* and never scored. A filter that scrolls out of the rail is a filter the
|
|
798
|
+
* reader cannot lift. Their count is `shown.length` by construction — under
|
|
799
|
+
* AND, every surviving package carries every active keyword.
|
|
800
|
+
*
|
|
801
|
+
* The rest are picked by splitting power over `shown`, not by frequency
|
|
802
|
+
* over `packages`. A rail scored against the whole catalog keeps offering
|
|
803
|
+
* keywords no surviving package carries, and under AND that is most of
|
|
804
|
+
* them — every such chip is one click to an empty grid.
|
|
805
|
+
*
|
|
806
|
+
* The cost, accepted: the rail's contents move as the reader filters, which
|
|
807
|
+
* is what the FLIP effect above animates. The set changing invisibly is
|
|
808
|
+
* what would read as broken.
|
|
809
|
+
*/
|
|
810
|
+
const pinned = keywords.map((keyword) => ({
|
|
811
|
+
keyword,
|
|
812
|
+
count: shown.length,
|
|
813
|
+
}));
|
|
814
|
+
const rail = selectRailKeywords(shown, KEYWORD_CHIP_LIMIT)
|
|
815
|
+
// `selectRailKeywords` scores the actives like any other keyword, so
|
|
816
|
+
// over-request and drop them rather than spend rail slots twice.
|
|
817
|
+
.filter((k) => !keywords.includes(k.keyword))
|
|
818
|
+
.slice(0, Math.max(0, KEYWORD_CHIP_LIMIT - pinned.length));
|
|
819
|
+
const visibleKeywords = [...pinned, ...rail];
|
|
820
|
+
// What the menu has to carry: everything the rail had no slot for, plus
|
|
821
|
+
// everything it has a slot for but no ROOM for. The second half is why the
|
|
822
|
+
// menu is built from `railFit` rather than from `KEYWORD_CHIP_LIMIT` — a
|
|
823
|
+
// chip clipped at the rail's edge is one the reader cannot reach anywhere
|
|
824
|
+
// else, and a "+N more" that does not count it is lying about where it is.
|
|
825
|
+
const clippedKeywords = visibleKeywords.slice(railFit).map((k) => k.keyword);
|
|
826
|
+
const menuKeywords = keywordFrequency(shown).filter(
|
|
827
|
+
(k) =>
|
|
828
|
+
clippedKeywords.includes(k.keyword) ||
|
|
829
|
+
!visibleKeywords.some((v) => v.keyword === k.keyword),
|
|
830
|
+
);
|
|
831
|
+
// Plain substring, not a fuzzy match: this searches a list the reader is
|
|
832
|
+
// looking at, and every entry in it is one short known word.
|
|
833
|
+
const menuShown = menuKeywords.filter((k) =>
|
|
834
|
+
k.keyword.toLowerCase().includes(keywordFilter.trim().toLowerCase()),
|
|
503
835
|
);
|
|
504
836
|
|
|
505
837
|
// A catalog with nothing deprecated gets no toggle — a control that can
|
|
@@ -508,9 +840,6 @@ export default function Catalog({
|
|
|
508
840
|
const hasDeprecated = packages.some((p) => p.deprecated);
|
|
509
841
|
const hasRatings = packages.some((p) => p.rating);
|
|
510
842
|
|
|
511
|
-
// Chips leave the Tab order only while there is a grid to arrow up from.
|
|
512
|
-
const chipTabIndex = shown.length === 0 ? 0 : -1;
|
|
513
|
-
|
|
514
843
|
return (
|
|
515
844
|
<section class="catalog" data-slot="catalog">
|
|
516
845
|
<div class="controls" data-slot="catalog-toolbar" ref={controlsRef}>
|
|
@@ -518,7 +847,7 @@ export default function Catalog({
|
|
|
518
847
|
<input
|
|
519
848
|
ref={searchRef}
|
|
520
849
|
type="search"
|
|
521
|
-
placeholder=
|
|
850
|
+
placeholder="Search packages — name, keyword, description…"
|
|
522
851
|
value={query}
|
|
523
852
|
onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
|
|
524
853
|
onKeyDown={onSearchKeyDown}
|
|
@@ -528,219 +857,297 @@ export default function Catalog({
|
|
|
528
857
|
{/* Decorative: the shortcut is announced by aria-keyshortcuts, so
|
|
529
858
|
repeating it here would be read twice. CSS hides it as soon as
|
|
530
859
|
the field is focused or holds a query. */}
|
|
531
|
-
<kbd class="search-hint" aria-hidden="true"
|
|
860
|
+
<kbd class="search-hint" aria-hidden="true">
|
|
861
|
+
/
|
|
862
|
+
</kbd>
|
|
532
863
|
</div>
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
type="button"
|
|
546
|
-
class={sort === "updated" ? "chip active" : "chip"}
|
|
547
|
-
data-slot="filter-chip"
|
|
548
|
-
tabIndex={chipTabIndex}
|
|
549
|
-
onKeyDown={onChipKeyDown}
|
|
550
|
-
onClick={() => setSort("updated")}
|
|
864
|
+
{/* One row, three groups, in the order they narrow: what sort of
|
|
865
|
+
thing, then what it is about, then what the catalog is
|
|
866
|
+
withholding. Only the keyword group flexes and scrolls, so a
|
|
867
|
+
narrow viewport shrinks IT rather than dropping the deprecated
|
|
868
|
+
toggle onto a second line — no chip can shrink below its own
|
|
869
|
+
longest word, and the rail's chip count is a constant, not a
|
|
870
|
+
viewport reading. */}
|
|
871
|
+
<div class="filter-row">
|
|
872
|
+
<div
|
|
873
|
+
class="chips kind-chips"
|
|
874
|
+
role="group"
|
|
875
|
+
aria-label="Filter by kind"
|
|
551
876
|
>
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
877
|
+
{/* "all" is the empty selection rendered as a chip, not a sixth
|
|
878
|
+
kind — so it is active exactly when nothing else is, and
|
|
879
|
+
clicking it clears rather than selects. */}
|
|
555
880
|
<button
|
|
556
881
|
type="button"
|
|
557
|
-
class={
|
|
882
|
+
class={kinds.length === 0 ? "chip active" : "chip"}
|
|
558
883
|
data-slot="filter-chip"
|
|
559
|
-
|
|
884
|
+
aria-pressed={kinds.length === 0}
|
|
560
885
|
onKeyDown={onChipKeyDown}
|
|
561
|
-
onClick={() =>
|
|
886
|
+
onClick={() => setKinds([])}
|
|
562
887
|
>
|
|
563
|
-
|
|
888
|
+
all
|
|
889
|
+
</button>
|
|
890
|
+
{kindNames.map((k) => (
|
|
891
|
+
<button
|
|
892
|
+
key={k}
|
|
893
|
+
type="button"
|
|
894
|
+
class={
|
|
895
|
+
kinds.includes(k) ? `chip active kind-${k}` : `chip kind-${k}`
|
|
896
|
+
}
|
|
897
|
+
data-slot="filter-chip"
|
|
898
|
+
aria-pressed={kinds.includes(k)}
|
|
899
|
+
onKeyDown={onChipKeyDown}
|
|
900
|
+
onClick={() => toggleKind(k)}
|
|
901
|
+
>
|
|
902
|
+
{k}
|
|
903
|
+
</button>
|
|
904
|
+
))}
|
|
905
|
+
</div>
|
|
906
|
+
{/* Divides "what sort of thing" from "about what" — two
|
|
907
|
+
questions sharing a row. Decorative: each group carries its own
|
|
908
|
+
aria-label, so this is drawn, not announced. */}
|
|
909
|
+
{visibleKeywords.length > 0 && (
|
|
910
|
+
<>
|
|
911
|
+
<span class="filter-divider" aria-hidden="true" />
|
|
912
|
+
<div
|
|
913
|
+
class="chips kw-rail"
|
|
914
|
+
role="group"
|
|
915
|
+
aria-label="Filter by keyword"
|
|
916
|
+
ref={railRef}
|
|
917
|
+
>
|
|
918
|
+
{visibleKeywords.map(({ keyword }, i) => {
|
|
919
|
+
// Past the measured fit: still laid out, so the measurement
|
|
920
|
+
// that decided this stays true on the next pass, but drawn
|
|
921
|
+
// as nothing and out of reach. Removing it from the flow
|
|
922
|
+
// instead would free the width that excluded it, which is
|
|
923
|
+
// the oscillation this shape exists to avoid.
|
|
924
|
+
const clipped = i >= railFit;
|
|
925
|
+
return (
|
|
926
|
+
<button
|
|
927
|
+
key={keyword}
|
|
928
|
+
ref={(el) => {
|
|
929
|
+
// The FLIP effect measures whatever is in this map, so
|
|
930
|
+
// a chip that leaves has to leave the map with it —
|
|
931
|
+
// Preact calls back with null on unmount for that.
|
|
932
|
+
if (el)
|
|
933
|
+
railRefs.current.set(keyword, el as HTMLElement);
|
|
934
|
+
else railRefs.current.delete(keyword);
|
|
935
|
+
}}
|
|
936
|
+
type="button"
|
|
937
|
+
class={[
|
|
938
|
+
"chip kw",
|
|
939
|
+
keywords.includes(keyword) ? "active" : "",
|
|
940
|
+
clipped ? "clipped" : "",
|
|
941
|
+
]
|
|
942
|
+
.filter(Boolean)
|
|
943
|
+
.join(" ")}
|
|
944
|
+
data-slot="filter-chip"
|
|
945
|
+
aria-pressed={keywords.includes(keyword)}
|
|
946
|
+
aria-hidden={clipped ? "true" : undefined}
|
|
947
|
+
tabIndex={clipped ? -1 : undefined}
|
|
948
|
+
onKeyDown={onChipKeyDown}
|
|
949
|
+
onClick={() => toggleKeyword(keyword)}
|
|
950
|
+
>
|
|
951
|
+
{keyword}
|
|
952
|
+
</button>
|
|
953
|
+
);
|
|
954
|
+
})}
|
|
955
|
+
</div>
|
|
956
|
+
</>
|
|
957
|
+
)}
|
|
958
|
+
{menuKeywords.length > 0 && (
|
|
959
|
+
// Everything the rail had no room for, behind a search box.
|
|
960
|
+
// Not an "expand" that dumps the remaining chips inline: a
|
|
961
|
+
// catalog's keyword vocabulary is open-ended, and a few hundred
|
|
962
|
+
// chips at once is a wall, not a control.
|
|
963
|
+
//
|
|
964
|
+
// A `<details>` for the same reason the platform picker and the
|
|
965
|
+
// version menus are: it opens, closes and takes Escape on its
|
|
966
|
+
// own, with no popover library and no script.
|
|
967
|
+
<details class="kw-menu">
|
|
968
|
+
<summary class="chip" data-slot="filter-chip">
|
|
969
|
+
+{menuKeywords.length} more
|
|
970
|
+
</summary>
|
|
971
|
+
<div class="kw-menu-panel">
|
|
972
|
+
<input
|
|
973
|
+
type="text"
|
|
974
|
+
class="kw-menu-search"
|
|
975
|
+
placeholder="Filter keywords…"
|
|
976
|
+
aria-label="Filter keywords"
|
|
977
|
+
value={keywordFilter}
|
|
978
|
+
onInput={(e) =>
|
|
979
|
+
setKeywordFilter((e.target as HTMLInputElement).value)
|
|
980
|
+
}
|
|
981
|
+
/>
|
|
982
|
+
<div class="kw-menu-list">
|
|
983
|
+
{menuShown.map(({ keyword, count }) => (
|
|
984
|
+
<button
|
|
985
|
+
key={keyword}
|
|
986
|
+
type="button"
|
|
987
|
+
class="kw-menu-item"
|
|
988
|
+
onClick={() => toggleKeyword(keyword)}
|
|
989
|
+
>
|
|
990
|
+
<span>{keyword}</span>
|
|
991
|
+
<small>{count}</small>
|
|
992
|
+
</button>
|
|
993
|
+
))}
|
|
994
|
+
{menuShown.length === 0 && (
|
|
995
|
+
<p class="kw-menu-empty">No keyword matches.</p>
|
|
996
|
+
)}
|
|
997
|
+
</div>
|
|
998
|
+
</div>
|
|
999
|
+
</details>
|
|
1000
|
+
)}
|
|
1001
|
+
{hasDeprecated && (
|
|
1002
|
+
// A toggle, not a filter: `aria-pressed` rather than the `active`
|
|
1003
|
+
// class alone, so it is announced as on/off instead of selected.
|
|
1004
|
+
//
|
|
1005
|
+
// No count, unlike the kind chips. Theirs is a fixed property of
|
|
1006
|
+
// the catalog; this one would be the number currently hidden, which
|
|
1007
|
+
// is zero once the toggle is on — so it vanished exactly when
|
|
1008
|
+
// pressed and the chip changed width under the pointer.
|
|
1009
|
+
<button
|
|
1010
|
+
type="button"
|
|
1011
|
+
class={
|
|
1012
|
+
showDeprecated
|
|
1013
|
+
? "chip deprecated-toggle active"
|
|
1014
|
+
: "chip deprecated-toggle"
|
|
1015
|
+
}
|
|
1016
|
+
aria-pressed={showDeprecated}
|
|
1017
|
+
title={
|
|
1018
|
+
showDeprecated
|
|
1019
|
+
? "Hide deprecated packages"
|
|
1020
|
+
: "Show deprecated packages"
|
|
1021
|
+
}
|
|
1022
|
+
onKeyDown={onChipKeyDown}
|
|
1023
|
+
onClick={() => setShowDeprecated((on) => !on)}
|
|
1024
|
+
>
|
|
1025
|
+
deprecated
|
|
564
1026
|
</button>
|
|
565
1027
|
)}
|
|
566
1028
|
</div>
|
|
567
|
-
{/*
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
<div class="
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
>
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1029
|
+
{/* The bottom line of the toolbar: what the filters above left, and
|
|
1030
|
+
the three controls that arrange it. The count leads because it is
|
|
1031
|
+
the answer to everything above it; the controls are pushed to the
|
|
1032
|
+
far end because they are not. */}
|
|
1033
|
+
<div class="meta-row">
|
|
1034
|
+
{/* `role="status"` on the count alone. It re-announces "N of M
|
|
1035
|
+
packages" as the filters change; putting it on the grid would
|
|
1036
|
+
read the whole result list out on every keystroke. */}
|
|
1037
|
+
<p class="result-count" role="status" aria-atomic="true">
|
|
1038
|
+
{shown.length === counted.length
|
|
1039
|
+
? `${counted.length} packages`
|
|
1040
|
+
: `${shown.length} of ${counted.length} packages`}
|
|
1041
|
+
</p>
|
|
1042
|
+
{/* Held at the far end, away from the chips: choosing an order is
|
|
1043
|
+
not filtering. Both halves take an ordinary tab stop; a select
|
|
1044
|
+
owns ArrowLeft/Right for its options, so neither can join the
|
|
1045
|
+
chips' roving arrow ring. */}
|
|
1046
|
+
<div class="sort-group" role="group" aria-label="Sort by">
|
|
1047
|
+
{/* Left, because that is the order the pair reads in: "descending,
|
|
1048
|
+
by rating". The bars-and-arrow glyph draws the order itself —
|
|
1049
|
+
tall-to-short under a down arrow — rather than labelling a flag,
|
|
1050
|
+
so it stays right whichever field is selected. */}
|
|
583
1051
|
<button
|
|
584
|
-
key={k}
|
|
585
1052
|
type="button"
|
|
586
|
-
class=
|
|
1053
|
+
class="sort-dir"
|
|
587
1054
|
data-slot="filter-chip"
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1055
|
+
title={
|
|
1056
|
+
dir === "asc"
|
|
1057
|
+
? "Ascending — click for descending"
|
|
1058
|
+
: "Descending — click for ascending"
|
|
1059
|
+
}
|
|
1060
|
+
aria-label={
|
|
1061
|
+
dir === "asc"
|
|
1062
|
+
? "Sorted ascending; sort descending"
|
|
1063
|
+
: "Sorted descending; sort ascending"
|
|
1064
|
+
}
|
|
1065
|
+
onClick={() => setDir((d) => (d === "asc" ? "desc" : "asc"))}
|
|
591
1066
|
>
|
|
592
|
-
{
|
|
1067
|
+
{dir === "asc" ? (
|
|
1068
|
+
<ArrowUpNarrowWide size={15} aria-hidden="true" />
|
|
1069
|
+
) : (
|
|
1070
|
+
<ArrowDownWideNarrow size={15} aria-hidden="true" />
|
|
1071
|
+
)}
|
|
593
1072
|
</button>
|
|
594
|
-
|
|
1073
|
+
<select
|
|
1074
|
+
class="sort-field"
|
|
1075
|
+
data-slot="filter-chip"
|
|
1076
|
+
aria-label="Sort by"
|
|
1077
|
+
value={sort}
|
|
1078
|
+
onChange={(event) => {
|
|
1079
|
+
const next = (event.currentTarget as HTMLSelectElement)
|
|
1080
|
+
.value as Sort;
|
|
1081
|
+
setSort(next);
|
|
1082
|
+
// Picking a field takes that field's own direction. Carrying
|
|
1083
|
+
// the previous one over lands the reader on "oldest first"
|
|
1084
|
+
// because they had asked for Z→A a moment ago.
|
|
1085
|
+
setDir(NATURAL[next]);
|
|
1086
|
+
}}
|
|
1087
|
+
>
|
|
1088
|
+
<option value="name">name</option>
|
|
1089
|
+
<option value="updated">updated</option>
|
|
1090
|
+
{hasRatings && <option value="rating">rating</option>}
|
|
1091
|
+
</select>
|
|
1092
|
+
</div>
|
|
1093
|
+
{/* Beside sort, because it answers the same kind of question — how
|
|
1094
|
+
the catalog is arranged, not which of it is shown. Two buttons
|
|
1095
|
+
rather than one that toggles: a single button has to be labelled
|
|
1096
|
+
with either the state or the action, and whichever it picks reads
|
|
1097
|
+
as the other half the time. `aria-pressed` on both says which is
|
|
1098
|
+
current without either label lying. */}
|
|
1099
|
+
<div class="view-toggle" role="group" aria-label="Catalog view">
|
|
1100
|
+
<button
|
|
1101
|
+
type="button"
|
|
1102
|
+
class={view === "cards" ? "view-pick active" : "view-pick"}
|
|
1103
|
+
data-slot="filter-chip"
|
|
1104
|
+
aria-pressed={view === "cards"}
|
|
1105
|
+
title="Cards"
|
|
1106
|
+
aria-label="Show packages as cards"
|
|
1107
|
+
onClick={() => setView("cards")}
|
|
1108
|
+
>
|
|
1109
|
+
<LayoutGrid size={15} aria-hidden="true" />
|
|
1110
|
+
</button>
|
|
1111
|
+
<button
|
|
1112
|
+
type="button"
|
|
1113
|
+
class={view === "table" ? "view-pick active" : "view-pick"}
|
|
1114
|
+
data-slot="filter-chip"
|
|
1115
|
+
aria-pressed={view === "table"}
|
|
1116
|
+
title="List"
|
|
1117
|
+
aria-label="Show packages as a list"
|
|
1118
|
+
onClick={() => setView("table")}
|
|
1119
|
+
>
|
|
1120
|
+
<List size={15} aria-hidden="true" />
|
|
1121
|
+
</button>
|
|
1122
|
+
</div>
|
|
595
1123
|
</div>
|
|
596
|
-
{hasDeprecated && (
|
|
597
|
-
// A toggle, not a filter: `aria-pressed` rather than the `active`
|
|
598
|
-
// class alone, so it is announced as on/off instead of selected.
|
|
599
|
-
//
|
|
600
|
-
// No count, unlike the kind chips. Theirs is a fixed property of
|
|
601
|
-
// the catalog; this one would be the number currently hidden, which
|
|
602
|
-
// is zero once the toggle is on — so it vanished exactly when
|
|
603
|
-
// pressed and the chip changed width under the pointer.
|
|
604
|
-
<button
|
|
605
|
-
type="button"
|
|
606
|
-
class={showDeprecated ? "chip deprecated-toggle active" : "chip deprecated-toggle"}
|
|
607
|
-
aria-pressed={showDeprecated}
|
|
608
|
-
title={showDeprecated ? "Hide deprecated packages" : "Show deprecated packages"}
|
|
609
|
-
tabIndex={chipTabIndex}
|
|
610
|
-
onKeyDown={onChipKeyDown}
|
|
611
|
-
onClick={() => setShowDeprecated((on) => !on)}
|
|
612
|
-
>
|
|
613
|
-
deprecated
|
|
614
|
-
</button>
|
|
615
|
-
)}
|
|
616
1124
|
</div>
|
|
617
1125
|
|
|
618
1126
|
{shown.length === 0 ? (
|
|
619
1127
|
<p class="empty">No packages match.</p>
|
|
1128
|
+
) : view === "table" ? (
|
|
1129
|
+
<PackageTable
|
|
1130
|
+
packages={shown}
|
|
1131
|
+
hasRatings={hasRatings}
|
|
1132
|
+
onKeyDown={onCardKeyDown}
|
|
1133
|
+
rootRef={gridRef}
|
|
1134
|
+
/>
|
|
620
1135
|
) : (
|
|
621
|
-
<ul
|
|
1136
|
+
<ul
|
|
1137
|
+
class="grid"
|
|
1138
|
+
ref={(el) => {
|
|
1139
|
+
gridRef.current = el;
|
|
1140
|
+
}}
|
|
1141
|
+
>
|
|
622
1142
|
{shown.map((p) => (
|
|
623
|
-
|
|
624
|
-
// left to right, top to bottom. Every control inside is taken
|
|
625
|
-
// out of the sequence (`tabindex={-1}`) so tabbing crosses the
|
|
626
|
-
// catalog instead of wading through it; arrow keys move by row
|
|
627
|
-
// and column, and Enter opens the detail page, which carries the
|
|
628
|
-
// same install commands the card's buttons do.
|
|
629
|
-
<li
|
|
1143
|
+
<PackageCard
|
|
630
1144
|
key={`${p.namespace}/${p.name}`}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1145
|
+
pkg={p}
|
|
1146
|
+
vscodeExtension={vscodeExtension}
|
|
1147
|
+
activeKeywords={keywords}
|
|
1148
|
+
onToggleKeyword={toggleKeyword}
|
|
634
1149
|
onKeyDown={onCardKeyDown}
|
|
635
|
-
|
|
636
|
-
<div class="card-head">
|
|
637
|
-
<CardLogo pkg={p} />
|
|
638
|
-
<h2 data-slot="package-name">
|
|
639
|
-
<a href={withBase(`/p/${p.namespace}/${p.name}/`)} tabIndex={-1}>
|
|
640
|
-
{p.name}
|
|
641
|
-
</a>
|
|
642
|
-
</h2>
|
|
643
|
-
{p.deprecated ? (
|
|
644
|
-
<span class="badge deprecated" data-slot="package-kind">deprecated</span>
|
|
645
|
-
) : (
|
|
646
|
-
<span class={`badge kind-${p.kind}`} data-slot="package-kind">{p.kind}</span>
|
|
647
|
-
)}
|
|
648
|
-
</div>
|
|
649
|
-
<p class="namespace">{p.namespace}</p>
|
|
650
|
-
{(p.version || p.license || lastUpdated(p) || p.rating) && (
|
|
651
|
-
<div class="meta-row" data-slot="package-meta">
|
|
652
|
-
{p.version && <span class="pill version">v{p.version}</span>}
|
|
653
|
-
{p.license && <span class="pill license">{p.license}</span>}
|
|
654
|
-
{/* A count and nothing more. The page is prerendered once
|
|
655
|
-
for everyone, so it cannot know whether *you* voted —
|
|
656
|
-
showing "not voted" to someone who has would be worse
|
|
657
|
-
than showing nothing. */}
|
|
658
|
-
{p.rating && (
|
|
659
|
-
<span
|
|
660
|
-
class="pill rating"
|
|
661
|
-
title={`${p.rating.up} upvote${p.rating.up === 1 ? "" : "s"}`}
|
|
662
|
-
>
|
|
663
|
-
<ArrowBigUp size={13} aria-hidden="true" />
|
|
664
|
-
{p.rating.up}
|
|
665
|
-
</span>
|
|
666
|
-
)}
|
|
667
|
-
{/* The date the sidecar derived, not the artifact's own
|
|
668
|
-
`created`: a package republished from the same commit
|
|
669
|
-
keeps its date, and one with no commit date at all gets
|
|
670
|
-
the day this index first saw its current digest. */}
|
|
671
|
-
{(() => {
|
|
672
|
-
const at = lastUpdated(p);
|
|
673
|
-
return at && timeAgo(at) ? (
|
|
674
|
-
<time class="updated" datetime={at} title={at}>
|
|
675
|
-
updated {timeAgo(at)}
|
|
676
|
-
</time>
|
|
677
|
-
) : null;
|
|
678
|
-
})()}
|
|
679
|
-
</div>
|
|
680
|
-
)}
|
|
681
|
-
{p.deprecated && (
|
|
682
|
-
<p class="deprecated-strip">
|
|
683
|
-
deprecated
|
|
684
|
-
{p.replacedBy ? ` — replaced by ${p.replacedBy}` : ""}
|
|
685
|
-
</p>
|
|
686
|
-
)}
|
|
687
|
-
{p.description && <p class="description">{p.description}</p>}
|
|
688
|
-
{p.keywords && p.keywords.length > 0 && (
|
|
689
|
-
<div class="keywords" data-slot="package-keywords">
|
|
690
|
-
{p.keywords.slice(0, 5).map((kw) => (
|
|
691
|
-
<button
|
|
692
|
-
key={kw}
|
|
693
|
-
type="button"
|
|
694
|
-
class="chip keyword"
|
|
695
|
-
tabIndex={-1}
|
|
696
|
-
onClick={() => setQuery(kw)}
|
|
697
|
-
>
|
|
698
|
-
{kw}
|
|
699
|
-
</button>
|
|
700
|
-
))}
|
|
701
|
-
{p.keywords.length > 5 && (
|
|
702
|
-
<span class="chip keyword overflow">
|
|
703
|
-
+{p.keywords.length - 5}
|
|
704
|
-
</span>
|
|
705
|
-
)}
|
|
706
|
-
</div>
|
|
707
|
-
)}
|
|
708
|
-
<div class="card-foot">
|
|
709
|
-
<div class="copy-group">
|
|
710
|
-
{/* Global first, matching the hero's scope picker — the two
|
|
711
|
-
are the same choice in two places, so they lead with the
|
|
712
|
-
same one. */}
|
|
713
|
-
<CopyButton
|
|
714
|
-
command={`grim add --global ${p.ref}`}
|
|
715
|
-
variant="global"
|
|
716
|
-
name={`global add for ${p.name}`}
|
|
717
|
-
/>
|
|
718
|
-
<CopyButton command={`grim add ${p.ref}`} name={`project add for ${p.name}`} />
|
|
719
|
-
{vscodeUrl(vscodeExtension, p.ref) && (
|
|
720
|
-
<a
|
|
721
|
-
class="copy vscode"
|
|
722
|
-
href={vscodeUrl(vscodeExtension, p.ref)!}
|
|
723
|
-
title="Open in VS Code"
|
|
724
|
-
aria-label={`Open ${p.name} in VS Code`}
|
|
725
|
-
tabIndex={-1}
|
|
726
|
-
>
|
|
727
|
-
<BrandMark path={mdiMicrosoftVisualStudioCode} />
|
|
728
|
-
</a>
|
|
729
|
-
)}
|
|
730
|
-
</div>
|
|
731
|
-
{p.repository && (
|
|
732
|
-
<a
|
|
733
|
-
class="source"
|
|
734
|
-
href={p.repository}
|
|
735
|
-
target="_blank"
|
|
736
|
-
rel="noopener noreferrer"
|
|
737
|
-
tabIndex={-1}
|
|
738
|
-
>
|
|
739
|
-
source
|
|
740
|
-
</a>
|
|
741
|
-
)}
|
|
742
|
-
</div>
|
|
743
|
-
</li>
|
|
1150
|
+
/>
|
|
744
1151
|
))}
|
|
745
1152
|
</ul>
|
|
746
1153
|
)}
|