@eqtylab/docs 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/chrome/GlobalSearch.module.css +26 -0
- package/dist/runtime/chrome/GlobalSearch.tsx +422 -0
- package/dist/runtime/chrome/Header.astro +105 -0
- package/dist/runtime/chrome/LinkIcon.astro +35 -0
- package/dist/runtime/chrome/NavDrawer.astro +60 -0
- package/dist/runtime/chrome/NavTree.astro +112 -0
- package/dist/runtime/chrome/NotFoundBody.tsx +12 -0
- package/dist/runtime/chrome/PageFooter.astro +80 -0
- package/dist/runtime/chrome/Prose.astro +169 -0
- package/dist/runtime/chrome/Sidebar.astro +21 -0
- package/dist/runtime/chrome/TableOfContents.astro +86 -0
- package/dist/runtime/chrome/ThemeToggle.tsx +85 -0
- package/dist/runtime/chrome/TocElbow.astro +30 -0
- package/dist/runtime/chrome/TocList.astro +43 -0
- package/dist/runtime/components/AlertBridge.astro +16 -0
- package/dist/runtime/components/CodeFence.astro +38 -0
- package/dist/runtime/components/CodeFenceBridge.astro +20 -0
- package/dist/runtime/components/Link.astro +21 -0
- package/dist/runtime/components/TabItem.astro +16 -0
- package/dist/runtime/components/TableBridge.astro +18 -0
- package/dist/runtime/components/Tabs.astro +121 -0
- package/dist/runtime/components/TabsBridge.tsx +41 -0
- package/dist/runtime/components/index.ts +4 -0
- package/dist/runtime/layouts/DocsPage.astro +50 -0
- package/dist/runtime/layouts/DocsShell.astro +92 -0
- package/dist/runtime/lib/mdx-components.ts +49 -0
- package/dist/runtime/lib/nav-data.ts +74 -0
- package/dist/runtime/lib/summary.ts +57 -0
- package/dist/runtime/lib/theme.ts +84 -0
- package/dist/runtime/routes/docs-md.ts +33 -0
- package/dist/runtime/routes/docs.astro +92 -0
- package/dist/runtime/routes/llms-txt.ts +38 -0
- package/dist/runtime/routes/not-found.astro +16 -0
- package/dist/runtime/scripts/eq-copy.ts +27 -0
- package/dist/runtime/scripts/eq-highlight.ts +24 -0
- package/dist/runtime/scripts/eq-nav-drawer.ts +31 -0
- package/dist/runtime/scripts/eq-nav-group.ts +52 -0
- package/dist/runtime/scripts/eq-tabs.ts +114 -0
- package/dist/runtime/scripts/eq-toc.ts +166 -0
- package/dist/runtime/styles/chrome.css +30 -0
- package/dist/runtime/styles/prose.css +102 -0
- package/dist/runtime/styles/theme.css +2 -0
- package/dist/runtime/styles/utilities.css +38 -0
- package/package.json +1 -1
- package/dist/chunk-ATIOPYKE.js +0 -102
- package/dist/chunk-ATIOPYKE.js.map +0 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
@reference '../styles/theme.css';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* All that is left here are the two rules that fight another package's styling. Neither
|
|
5
|
+
* can move inline: one has to outrank Equality's own dialog class at equal specificity,
|
|
6
|
+
* and the other styles an element this component never renders.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* Class doubled to beat Equality's centring, which sits at the same specificity.
|
|
10
|
+
* Never override `transform` here: the entrance animation uses it. */
|
|
11
|
+
.dialog.dialog {
|
|
12
|
+
@apply p-0;
|
|
13
|
+
top: 10vh;
|
|
14
|
+
translate: -50% 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Two assumptions nothing enforces: `packages/ui` still generates an overlay class containing
|
|
19
|
+
* `dialog-overlay`, and Radix still renders that overlay as the dialog's previous sibling. Either
|
|
20
|
+
* changes and the blur silently disappears. `:has` keeps it off every other dialog on the site.
|
|
21
|
+
*/
|
|
22
|
+
:global([class*='dialog-overlay']):has(+ .dialog) {
|
|
23
|
+
@apply bg-background/25;
|
|
24
|
+
-webkit-backdrop-filter: blur(18px);
|
|
25
|
+
backdrop-filter: blur(6px);
|
|
26
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Command,
|
|
5
|
+
CommandGroup,
|
|
6
|
+
CommandInput,
|
|
7
|
+
CommandItem,
|
|
8
|
+
CommandList,
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContainer,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
EmptyTableState,
|
|
14
|
+
Icon,
|
|
15
|
+
} from '@eqtylab/equality';
|
|
16
|
+
|
|
17
|
+
import styles from './GlobalSearch.module.css';
|
|
18
|
+
|
|
19
|
+
/** The header sits outside `Command`: moving the query there costs a hidden input and key forwarding. */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Equality ships unlayered CSS, and an unlayered declaration beats anything in
|
|
23
|
+
* `@layer utilities` whatever its specificity. So any utility here that has to
|
|
24
|
+
* overrule a value Equality already sets on the same element needs `!` — the list's
|
|
25
|
+
* own `max-height: 300px` is one, and without the `!` the results pane silently
|
|
26
|
+
* shrinks. Utilities setting a property Equality leaves alone need nothing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Equality styles the hover but not cmdk's selected row. */
|
|
30
|
+
const ITEM =
|
|
31
|
+
'cursor-pointer data-[selected=true]:bg-lilac-300/50 dark:data-[selected=true]:bg-lilac-600/50';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Equality has no highlight token, so these name steps off the option scale directly. Not
|
|
35
|
+
* `brand-primary`: it is the selected row's colour, so a mark painted with it vanishes exactly
|
|
36
|
+
* when a row is selected. Each theme names its own step because `lilac-*` carries no dark values.
|
|
37
|
+
*/
|
|
38
|
+
const MARK = 'rounded bg-lilac-200 px-0.5 text-lilac-800 dark:bg-lilac-700 dark:text-lilac-200';
|
|
39
|
+
|
|
40
|
+
/** The same treatment, for the <mark>s inside Pagefind's own excerpt markup. */
|
|
41
|
+
const HIT_SUMMARY =
|
|
42
|
+
'text-text-secondary line-clamp-1 text-xs [&_mark]:rounded [&_mark]:bg-lilac-200 [&_mark]:px-0.5 [&_mark]:text-lilac-800 dark:[&_mark]:bg-lilac-700 dark:[&_mark]:text-lilac-200';
|
|
43
|
+
|
|
44
|
+
const MAX_RESULTS = 20;
|
|
45
|
+
const PAGE_SIZE = 5;
|
|
46
|
+
const RECENT_KEY = 'eq-docs-recent-pages';
|
|
47
|
+
const MAX_RECENT = 5;
|
|
48
|
+
|
|
49
|
+
/** Hand-written: Pagefind is fetched at runtime, so no package supplies types. */
|
|
50
|
+
interface PagefindDoc {
|
|
51
|
+
url: string;
|
|
52
|
+
excerpt: string;
|
|
53
|
+
/** Stamped by Prose.astro; `crumbs` is the `_group.yaml` trail, not the URL. */
|
|
54
|
+
meta?: { title?: string; crumbs?: string; description?: string };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PagefindRawResult {
|
|
58
|
+
id: string;
|
|
59
|
+
data: () => Promise<PagefindDoc>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface PagefindApi {
|
|
63
|
+
options: (opts: Record<string, unknown>) => Promise<void>;
|
|
64
|
+
init: () => Promise<void>;
|
|
65
|
+
debouncedSearch: (query: string) => Promise<{ results: PagefindRawResult[] } | null>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Props {
|
|
69
|
+
suggested?: Array<{ label: string; href: string }>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toRecent(item: { label: string; href: string }): RecentPage {
|
|
73
|
+
return { url: item.href, title: item.label };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Hit {
|
|
77
|
+
id: string;
|
|
78
|
+
url: string;
|
|
79
|
+
title: string;
|
|
80
|
+
summary: string;
|
|
81
|
+
isExcerpt: boolean;
|
|
82
|
+
crumbs?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const BASE = import.meta.env.BASE_URL;
|
|
86
|
+
const BUNDLE_PATH = `${BASE.replace(/\/+$/, '')}/pagefind/`;
|
|
87
|
+
|
|
88
|
+
let apiPromise: Promise<PagefindApi> | null = null;
|
|
89
|
+
|
|
90
|
+
/** Drop `@vite-ignore` and the build fails: this path exists only after the build runs. */
|
|
91
|
+
function loadPagefind(): Promise<PagefindApi> {
|
|
92
|
+
if (!apiPromise) {
|
|
93
|
+
apiPromise = import(/* @vite-ignore */ `${BUNDLE_PATH}pagefind.js`).then(async (module) => {
|
|
94
|
+
const api = module as unknown as PagefindApi;
|
|
95
|
+
// Without `baseUrl` every result href misses the base on a versioned deploy.
|
|
96
|
+
await api.options({ baseUrl: BASE, bundlePath: BUNDLE_PATH });
|
|
97
|
+
await api.init();
|
|
98
|
+
return api;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return apiPromise;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface RecentPage {
|
|
105
|
+
url: string;
|
|
106
|
+
title: string;
|
|
107
|
+
crumbs?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getRecentPages(): RecentPage[] {
|
|
111
|
+
try {
|
|
112
|
+
const stored = localStorage.getItem(RECENT_KEY);
|
|
113
|
+
return stored ? (JSON.parse(stored) as RecentPage[]) : [];
|
|
114
|
+
} catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Reads the page it is on from the same attributes the index is built from. */
|
|
120
|
+
function recordCurrentPage(): RecentPage[] {
|
|
121
|
+
const heading = document.querySelector('h1[data-pagefind-meta="title"]');
|
|
122
|
+
const article = document.querySelector('article[data-pagefind-body]');
|
|
123
|
+
const title = heading?.textContent?.trim();
|
|
124
|
+
if (!title) return getRecentPages();
|
|
125
|
+
|
|
126
|
+
const meta = article?.getAttribute('data-pagefind-meta') ?? '';
|
|
127
|
+
const crumbs = meta.startsWith('crumbs:') ? meta.slice('crumbs:'.length) : undefined;
|
|
128
|
+
const entry: RecentPage = { url: window.location.pathname, title, crumbs };
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const kept = getRecentPages().filter((page) => page.url !== entry.url);
|
|
132
|
+
const updated = [entry, ...kept].slice(0, MAX_RECENT);
|
|
133
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(updated));
|
|
134
|
+
return updated;
|
|
135
|
+
} catch {
|
|
136
|
+
return getRecentPages();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isTypingTarget(): boolean {
|
|
141
|
+
const el = document.activeElement;
|
|
142
|
+
if (!el) return false;
|
|
143
|
+
const tag = el.tagName.toLowerCase();
|
|
144
|
+
return tag === 'input' || tag === 'textarea' || (el as HTMLElement).isContentEditable;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function HighlightMatch({ text, query }: { text: string; query: string }) {
|
|
148
|
+
if (!query) return <>{text}</>;
|
|
149
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
150
|
+
const parts = text.split(new RegExp(`(${escaped})`, 'gi'));
|
|
151
|
+
return (
|
|
152
|
+
<>
|
|
153
|
+
{parts.map((part, i) =>
|
|
154
|
+
part.toLowerCase() === query.toLowerCase() ? (
|
|
155
|
+
<mark key={i} className={MARK}>
|
|
156
|
+
{part}
|
|
157
|
+
</mark>
|
|
158
|
+
) : (
|
|
159
|
+
part
|
|
160
|
+
)
|
|
161
|
+
)}
|
|
162
|
+
</>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default function GlobalSearch({ suggested = [] }: Props) {
|
|
167
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
168
|
+
const [query, setQuery] = useState('');
|
|
169
|
+
const [hits, setHits] = useState<Hit[]>([]);
|
|
170
|
+
const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
|
171
|
+
const [expanded, setExpanded] = useState(false);
|
|
172
|
+
// Lazy initializer, safe only because this island is client:only and never prerenders.
|
|
173
|
+
const [recentPages] = useState<RecentPage[]>(recordCurrentPage);
|
|
174
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
175
|
+
const requestId = useRef(0);
|
|
176
|
+
|
|
177
|
+
const open = useCallback(() => setIsOpen(true), []);
|
|
178
|
+
|
|
179
|
+
const close = useCallback(() => {
|
|
180
|
+
setIsOpen(false);
|
|
181
|
+
setQuery('');
|
|
182
|
+
setHits([]);
|
|
183
|
+
setStatus('idle');
|
|
184
|
+
setExpanded(false);
|
|
185
|
+
requestId.current++;
|
|
186
|
+
// DialogContainer preventDefaults onCloseAutoFocus; drop this and a keyboard user lands on <body>.
|
|
187
|
+
requestAnimationFrame(() => triggerRef.current?.focus());
|
|
188
|
+
}, [setExpanded]);
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
function handler(event: KeyboardEvent) {
|
|
192
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
|
193
|
+
event.preventDefault();
|
|
194
|
+
if (isOpen) close();
|
|
195
|
+
else open();
|
|
196
|
+
}
|
|
197
|
+
// Escape is handled by Radix once the dialog is open.
|
|
198
|
+
if (event.key === '/' && !isOpen && !isTypingTarget()) {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
open();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
window.addEventListener('keydown', handler);
|
|
204
|
+
return () => window.removeEventListener('keydown', handler);
|
|
205
|
+
}, [isOpen, open, close]);
|
|
206
|
+
|
|
207
|
+
const runSearch = useCallback(async (value: string) => {
|
|
208
|
+
const id = ++requestId.current;
|
|
209
|
+
if (!value.trim()) {
|
|
210
|
+
setHits([]);
|
|
211
|
+
setStatus('idle');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
setStatus('loading');
|
|
215
|
+
try {
|
|
216
|
+
const api = await loadPagefind();
|
|
217
|
+
const search = await api.debouncedSearch(value);
|
|
218
|
+
// Null means a newer keystroke superseded this call.
|
|
219
|
+
if (search === null || id !== requestId.current) return;
|
|
220
|
+
const docs = await Promise.all(search.results.slice(0, MAX_RESULTS).map((r) => r.data()));
|
|
221
|
+
if (id !== requestId.current) return;
|
|
222
|
+
setHits(
|
|
223
|
+
docs.map((doc, index) => ({
|
|
224
|
+
id: search.results[index].id,
|
|
225
|
+
url: doc.url,
|
|
226
|
+
title: doc.meta?.title ?? doc.url,
|
|
227
|
+
summary: doc.meta?.description ?? doc.excerpt,
|
|
228
|
+
isExcerpt: !doc.meta?.description,
|
|
229
|
+
crumbs: doc.meta?.crumbs,
|
|
230
|
+
}))
|
|
231
|
+
);
|
|
232
|
+
setStatus('ready');
|
|
233
|
+
} catch {
|
|
234
|
+
if (id !== requestId.current) return;
|
|
235
|
+
// No index before the first build; say so rather than showing nothing.
|
|
236
|
+
setStatus('error');
|
|
237
|
+
setHits([]);
|
|
238
|
+
}
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
const visible = useMemo(() => (expanded ? hits : hits.slice(0, PAGE_SIZE)), [hits, expanded]);
|
|
242
|
+
|
|
243
|
+
function onQueryChange(value: string) {
|
|
244
|
+
setQuery(value);
|
|
245
|
+
setExpanded(false);
|
|
246
|
+
void runSearch(value);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function handleSelect(hit: Hit) {
|
|
250
|
+
setIsOpen(false);
|
|
251
|
+
// assign(), not `location.href =`: the React compiler lint rejects the assignment form.
|
|
252
|
+
window.location.assign(hit.url);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Excluding the current page is what makes a first visit fall through to `suggested`.
|
|
256
|
+
const elsewhere = recentPages.filter((page) => page.url !== window.location.pathname);
|
|
257
|
+
const landing = elsewhere.length > 0 ? elsewhere : suggested.map(toRecent);
|
|
258
|
+
const landingHeading = elsewhere.length > 0 ? 'Recently viewed' : 'Start here';
|
|
259
|
+
const showLanding = !query && landing.length > 0;
|
|
260
|
+
const showEmpty = status !== 'error' && hits.length === 0 && !showLanding;
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<>
|
|
264
|
+
{/* A button, not an Input: Enter, Space and the accessible name come free. */}
|
|
265
|
+
<div role="search" className="w-full min-w-0 max-sm:w-auto">
|
|
266
|
+
{/* Tokens, not a copy of Input: a theme change carries, an Input restyle does not. */}
|
|
267
|
+
<button
|
|
268
|
+
ref={triggerRef}
|
|
269
|
+
type="button"
|
|
270
|
+
className="border-border bg-background text-text-secondary focus-ring flex h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-md border p-2 text-sm max-sm:w-10 max-sm:justify-center max-sm:border-transparent max-sm:bg-transparent max-sm:p-0"
|
|
271
|
+
aria-haspopup="dialog"
|
|
272
|
+
aria-label="Search documentation"
|
|
273
|
+
onClick={open}
|
|
274
|
+
>
|
|
275
|
+
<Icon icon="Search" size="xs" />
|
|
276
|
+
{/* min-w-0 so a long label truncates instead of pushing the shortcut out. */}
|
|
277
|
+
<span className="min-w-0 flex-1 truncate text-left max-sm:sr-only">
|
|
278
|
+
Search documentation...
|
|
279
|
+
</span>
|
|
280
|
+
{/* No room for it on a narrow header, and the shortcut still works. */}
|
|
281
|
+
<kbd
|
|
282
|
+
aria-hidden="true"
|
|
283
|
+
className="bg-background-raised text-text-secondary pointer-events-none hidden rounded border px-1.5 py-0.5 font-mono text-xs sm:inline-block"
|
|
284
|
+
>
|
|
285
|
+
⌘K
|
|
286
|
+
</kbd>
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<Dialog open={isOpen} onOpenChange={(next: boolean) => (next ? open() : close())}>
|
|
291
|
+
<DialogContainer className={styles.dialog} aria-describedby={undefined}>
|
|
292
|
+
<div className="sr-only">
|
|
293
|
+
<DialogTitle>Search documentation</DialogTitle>
|
|
294
|
+
<DialogDescription>
|
|
295
|
+
Type to search every page. Arrow keys move, Enter opens, Escape closes.
|
|
296
|
+
</DialogDescription>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{/* Pagefind has already ranked these. Let cmdk filter and it discards the good ones. */}
|
|
300
|
+
<Command shouldFilter={false} className="w-full border-none">
|
|
301
|
+
<CommandInput
|
|
302
|
+
value={query}
|
|
303
|
+
onValueChange={onQueryChange}
|
|
304
|
+
placeholder="Search documentation..."
|
|
305
|
+
/>
|
|
306
|
+
|
|
307
|
+
<CommandList className="max-h-[min(60vh,28rem)]! overflow-y-auto pb-2">
|
|
308
|
+
{status === 'error' && (
|
|
309
|
+
<div className="flex flex-col items-center gap-3 px-4 py-10">
|
|
310
|
+
<EmptyTableState icon="SearchX" title="Search needs a build to run first" />
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{/* Not CommandEmpty: its filter count never updates while shouldFilter is false. */}
|
|
315
|
+
{showEmpty && (
|
|
316
|
+
<div className="flex flex-col items-center gap-3 px-4 py-10">
|
|
317
|
+
<EmptyTableState
|
|
318
|
+
icon={query ? 'SearchX' : 'Search'}
|
|
319
|
+
title={
|
|
320
|
+
query
|
|
321
|
+
? status === 'loading'
|
|
322
|
+
? 'Searching...'
|
|
323
|
+
: `No results for "${query}"`
|
|
324
|
+
: 'Start typing to search'
|
|
325
|
+
}
|
|
326
|
+
/>
|
|
327
|
+
{query && status !== 'loading' && (
|
|
328
|
+
<Button variant="tertiary" size="sm" onClick={() => onQueryChange('')}>
|
|
329
|
+
Clear search
|
|
330
|
+
</Button>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
{showLanding && (
|
|
336
|
+
<CommandGroup className="px-1 [&>*+*]:mt-1" heading={landingHeading}>
|
|
337
|
+
{landing.map((page) => (
|
|
338
|
+
<CommandItem
|
|
339
|
+
key={page.url}
|
|
340
|
+
value={page.url}
|
|
341
|
+
onSelect={() => window.location.assign(page.url)}
|
|
342
|
+
className={ITEM}
|
|
343
|
+
>
|
|
344
|
+
<Icon icon="FileText" size="sm" />
|
|
345
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
346
|
+
<span className="text-text-primary truncate text-sm font-medium">
|
|
347
|
+
{page.title}
|
|
348
|
+
</span>
|
|
349
|
+
{page.crumbs && (
|
|
350
|
+
// Location, not identity: separated from the pair above it.
|
|
351
|
+
<span className="text-text-tertiary mt-1 truncate text-xs">
|
|
352
|
+
{page.crumbs}
|
|
353
|
+
</span>
|
|
354
|
+
)}
|
|
355
|
+
</span>
|
|
356
|
+
</CommandItem>
|
|
357
|
+
))}
|
|
358
|
+
</CommandGroup>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{/* Flat and in Pagefind's order: grouping by section reordered the ranking. */}
|
|
362
|
+
{hits.length > 0 && (
|
|
363
|
+
<CommandGroup className="px-1 [&>*+*]:mt-1" heading="Results">
|
|
364
|
+
{visible.map((hit) => (
|
|
365
|
+
<CommandItem
|
|
366
|
+
key={hit.id}
|
|
367
|
+
value={hit.id}
|
|
368
|
+
data-search-id={hit.id}
|
|
369
|
+
onSelect={() => handleSelect(hit)}
|
|
370
|
+
className={ITEM}
|
|
371
|
+
>
|
|
372
|
+
<Icon icon="FileText" size="sm" />
|
|
373
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
374
|
+
<span className="text-text-primary truncate text-sm font-medium">
|
|
375
|
+
<HighlightMatch text={hit.title} query={query} />
|
|
376
|
+
</span>
|
|
377
|
+
{hit.isExcerpt ? (
|
|
378
|
+
// Pagefind's markup, built from our own content.
|
|
379
|
+
<span
|
|
380
|
+
className={HIT_SUMMARY}
|
|
381
|
+
dangerouslySetInnerHTML={{ __html: hit.summary }}
|
|
382
|
+
/>
|
|
383
|
+
) : (
|
|
384
|
+
<span className={HIT_SUMMARY}>
|
|
385
|
+
<HighlightMatch text={hit.summary} query={query} />
|
|
386
|
+
</span>
|
|
387
|
+
)}
|
|
388
|
+
{hit.crumbs && (
|
|
389
|
+
// Location, not identity: separated from the pair above it.
|
|
390
|
+
<span className="text-text-tertiary mt-1 truncate text-xs">
|
|
391
|
+
{hit.crumbs}
|
|
392
|
+
</span>
|
|
393
|
+
)}
|
|
394
|
+
</span>
|
|
395
|
+
</CommandItem>
|
|
396
|
+
))}
|
|
397
|
+
|
|
398
|
+
{hits.length > visible.length && (
|
|
399
|
+
<CommandItem
|
|
400
|
+
value="view-more"
|
|
401
|
+
onSelect={() => setExpanded(true)}
|
|
402
|
+
className="text-brand-primary cursor-pointer justify-center text-sm"
|
|
403
|
+
>
|
|
404
|
+
View more results
|
|
405
|
+
</CommandItem>
|
|
406
|
+
)}
|
|
407
|
+
</CommandGroup>
|
|
408
|
+
)}
|
|
409
|
+
</CommandList>
|
|
410
|
+
|
|
411
|
+
{/* Outside the list: inside it the note scrolls out of sight. */}
|
|
412
|
+
{import.meta.env.DEV && status !== 'error' && (
|
|
413
|
+
<p className="text-text-secondary border-border border-t px-4 py-2 text-xs">
|
|
414
|
+
Dev mode: results come from the last build.
|
|
415
|
+
</p>
|
|
416
|
+
)}
|
|
417
|
+
</Command>
|
|
418
|
+
</DialogContainer>
|
|
419
|
+
</Dialog>
|
|
420
|
+
</>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
import CONFIG from 'virtual:eqty-docs/config';
|
|
3
|
+
import { withBase } from '@eqtylab/docs/paths';
|
|
4
|
+
import { Icon } from '@eqtylab/equality';
|
|
5
|
+
import GlobalSearch from './GlobalSearch.tsx';
|
|
6
|
+
import LinkIcon from './LinkIcon.astro';
|
|
7
|
+
import ThemeToggle from './ThemeToggle.tsx';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** First page of each top-level section. Shown before a reader has any history. */
|
|
11
|
+
suggested?: Array<{ label: string; href: string }>;
|
|
12
|
+
/** False when the page forwards a consumer-provided `search` slot. */
|
|
13
|
+
showSearch?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { showSearch = true, suggested = [] } = Astro.props;
|
|
17
|
+
|
|
18
|
+
const paths = { base: import.meta.env.BASE_URL, pathPrefix: CONFIG.pathPrefix };
|
|
19
|
+
const homeHref = withBase(CONFIG.pathPrefix ? `/${CONFIG.pathPrefix}/` : '/', {
|
|
20
|
+
base: import.meta.env.BASE_URL,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const navLink = 'eq-nav-control flex';
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<header
|
|
27
|
+
class="bg-background-raised border-border-raised sticky top-0 z-50 h-[var(--eq-docs-header-height)] border-b"
|
|
28
|
+
data-eq-chrome
|
|
29
|
+
data-pagefind-ignore
|
|
30
|
+
>
|
|
31
|
+
{
|
|
32
|
+
/* Below sm the search becomes an icon in the control cluster. `order` moves it
|
|
33
|
+
among these three children, so it only works while all three stay siblings. */
|
|
34
|
+
}
|
|
35
|
+
<div class="flex h-full items-center justify-between gap-4 px-4 max-sm:gap-2">
|
|
36
|
+
{/* min-h-10: the mark alone leaves this link under the 24px minimum tap target. */}
|
|
37
|
+
<a
|
|
38
|
+
class="text-text-primary flex min-h-10 shrink-0 items-center gap-3 no-underline"
|
|
39
|
+
href={homeHref}
|
|
40
|
+
>
|
|
41
|
+
{
|
|
42
|
+
/* Only the height is set. An <img> keeps its own aspect ratio, so any logo
|
|
43
|
+
fits. The mark ships white, so light mode inverts it; invert only works for
|
|
44
|
+
a one-colour logo, and a coloured one needs a second file. */
|
|
45
|
+
}
|
|
46
|
+
{
|
|
47
|
+
CONFIG.logo ? (
|
|
48
|
+
<img
|
|
49
|
+
class="light:invert block h-[22px] w-auto shrink-0"
|
|
50
|
+
src={withBase(CONFIG.logo.src, paths)}
|
|
51
|
+
alt={CONFIG.logo.alt}
|
|
52
|
+
height="24"
|
|
53
|
+
/>
|
|
54
|
+
) : (
|
|
55
|
+
<span class="whitespace-nowrap text-xl font-bold">{CONFIG.title}</span>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
<span class="eq-display-gradient whitespace-nowrap text-xl font-bold forced-colors:text-inherit">
|
|
59
|
+
{CONFIG.title}
|
|
60
|
+
</span>
|
|
61
|
+
</a>
|
|
62
|
+
|
|
63
|
+
{/* min-w-0: without it the search pushes the theme control off a narrow header. */}
|
|
64
|
+
<div class="flex min-w-0 max-w-[28rem] flex-1 justify-center max-sm:order-2 max-sm:ml-auto max-sm:max-w-none max-sm:flex-none">
|
|
65
|
+
<slot name="search" />
|
|
66
|
+
{
|
|
67
|
+
/* client:only: with no JS there is no search, and client:load would leave a dead control. */
|
|
68
|
+
showSearch && CONFIG.search.provider !== 'none' && <GlobalSearch suggested={suggested} client:only="react" />
|
|
69
|
+
}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="flex shrink-0 items-center gap-2 max-sm:order-3">
|
|
73
|
+
<slot name="versions" />
|
|
74
|
+
<div class="hidden items-center gap-1 lg:flex">
|
|
75
|
+
{CONFIG.header.links.map((link) => (
|
|
76
|
+
<a
|
|
77
|
+
class={navLink}
|
|
78
|
+
href={link.href}
|
|
79
|
+
target={link.external ? '_blank' : undefined}
|
|
80
|
+
rel={link.external ? 'noopener noreferrer' : undefined}
|
|
81
|
+
>
|
|
82
|
+
{link.icon && <LinkIcon icon={link.icon} />}
|
|
83
|
+
{/* Always visible. Hiding it shipped a nameless link, because `icon` is
|
|
84
|
+
optional and Lucide has no brand marks, so a GitHub link had nothing
|
|
85
|
+
left to render. */}
|
|
86
|
+
<span class="inline">{link.label}</span>
|
|
87
|
+
</a>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
{CONFIG.header.showThemeToggle && <ThemeToggle client:load className="eq-nav-control" />}
|
|
91
|
+
{
|
|
92
|
+
/* popovertarget: the browser handles opening, closing, Escape, a click
|
|
93
|
+
outside, and returning focus here. */
|
|
94
|
+
}
|
|
95
|
+
<button
|
|
96
|
+
class="text-text-secondary hover:text-text-primary flex size-10 items-center justify-center rounded-md lg:hidden"
|
|
97
|
+
type="button"
|
|
98
|
+
popovertarget="eq-nav-drawer"
|
|
99
|
+
aria-label="Open navigation"
|
|
100
|
+
>
|
|
101
|
+
<Icon icon="Menu" size="sm" />
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</header>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The icon on a configured header link: a Lucide name, or a path to an SVG.
|
|
4
|
+
*
|
|
5
|
+
* The SVG branch exists because Lucide ships no brand marks, so a GitHub link has no
|
|
6
|
+
* name it could use.
|
|
7
|
+
*/
|
|
8
|
+
import CONFIG from 'virtual:eqty-docs/config';
|
|
9
|
+
import { withBase } from '@eqtylab/docs/paths';
|
|
10
|
+
import { Icon } from '@eqtylab/equality';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
icon: string;
|
|
14
|
+
}
|
|
15
|
+
const { icon } = Astro.props;
|
|
16
|
+
const isFile = icon.endsWith('.svg');
|
|
17
|
+
const paths = { base: import.meta.env.BASE_URL, pathPrefix: CONFIG.pathPrefix };
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
/* size-4 matches Equality's Icon at `sm`, which the Lucide branch renders.
|
|
22
|
+
The file ships white, so light mode is the theme that inverts it. */
|
|
23
|
+
}
|
|
24
|
+
{
|
|
25
|
+
isFile ? (
|
|
26
|
+
<img
|
|
27
|
+
class="light:invert block size-4 shrink-0"
|
|
28
|
+
src={withBase(icon, paths)}
|
|
29
|
+
alt=""
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
/>
|
|
32
|
+
) : (
|
|
33
|
+
<Icon icon={icon} size="sm" />
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Mobile navigation, below `lg` where the sidebar is hidden.
|
|
4
|
+
*
|
|
5
|
+
* The Popover API, not a custom element. With no JavaScript it opens and closes,
|
|
6
|
+
* shuts on Escape or a click outside, returns focus to the button, and paints above
|
|
7
|
+
* everything else. It also avoids `DialogContainer`'s focus-restoration bug.
|
|
8
|
+
*/
|
|
9
|
+
import CONFIG from 'virtual:eqty-docs/config';
|
|
10
|
+
import type { NavNode } from '@eqtylab/docs/types';
|
|
11
|
+
import { Icon } from '@eqtylab/equality';
|
|
12
|
+
|
|
13
|
+
import LinkIcon from './LinkIcon.astro';
|
|
14
|
+
import NavTree from './NavTree.astro';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
nodes: NavNode[];
|
|
18
|
+
}
|
|
19
|
+
const { nodes } = Astro.props;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
/*
|
|
24
|
+
A popover is `display:none` until opened, so no `hidden` is needed, and above lg
|
|
25
|
+
the sidebar is back so it must never open. Resetting the UA's margin, border and
|
|
26
|
+
padding is required: a popover is not a plain box.
|
|
27
|
+
|
|
28
|
+
The `::backdrop` pair is the scrim. It comes with the popover, so there is no extra
|
|
29
|
+
element and no scrim selector reaching into another package.
|
|
30
|
+
*/
|
|
31
|
+
}
|
|
32
|
+
<div
|
|
33
|
+
id="eq-nav-drawer"
|
|
34
|
+
popover
|
|
35
|
+
class="bg-background-raised border-border-raised fixed inset-y-0 left-0 m-0 h-full w-[min(20rem,84vw)] overflow-y-auto border-r p-4 lg:hidden [&::backdrop]:bg-background/50 [&::backdrop]:backdrop-blur-[3px]"
|
|
36
|
+
data-eq-chrome
|
|
37
|
+
data-pagefind-ignore
|
|
38
|
+
>
|
|
39
|
+
<nav class="block" aria-label="Documentation">
|
|
40
|
+
{
|
|
41
|
+
CONFIG.header.links.map((link) => (
|
|
42
|
+
<a
|
|
43
|
+
class="text-text-secondary hover:text-text-primary mb-1 flex max-h-10 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium no-underline"
|
|
44
|
+
href={link.href}
|
|
45
|
+
target={link.external ? '_blank' : undefined}
|
|
46
|
+
rel={link.external ? 'noopener noreferrer' : undefined}
|
|
47
|
+
>
|
|
48
|
+
{link.icon && <LinkIcon icon={link.icon} />}
|
|
49
|
+
<span>{link.label}</span>
|
|
50
|
+
{link.external && <Icon icon="ArrowUpRight" size="xs" />}
|
|
51
|
+
</a>
|
|
52
|
+
))
|
|
53
|
+
}
|
|
54
|
+
<NavTree nodes={nodes} />
|
|
55
|
+
</nav>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<script>
|
|
59
|
+
import '@eqtylab/docs/scripts/eq-nav-drawer.ts';
|
|
60
|
+
</script>
|