@immediately-run/grove 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -115
- package/llms.txt +6 -2
- package/package.json +20 -8
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +485 -85
- package/src/GroveWiki.tsx +176 -54
- package/src/components/AssetImage.tsx +1 -12
- package/src/components/Backlinks.tsx +2 -2
- package/src/components/Catalogue.test.tsx +157 -0
- package/src/components/ContentTheme.test.tsx +146 -0
- package/src/components/ContentTheme.tsx +111 -0
- package/src/components/DocList.infinite.test.tsx +177 -0
- package/src/components/DocList.tsx +82 -9
- package/src/components/Drawer.tsx +14 -2
- package/src/components/EntryHeader.tsx +24 -15
- package/src/components/EntryImage.test.tsx +210 -0
- package/src/components/EntryImage.tsx +47 -0
- package/src/components/Galleries.test.tsx +98 -0
- package/src/components/GroveAgent.test.tsx +330 -0
- package/src/components/GroveAgent.tsx +269 -149
- package/src/components/GroveNav.test.tsx +229 -0
- package/src/components/GroveNav.tsx +69 -20
- package/src/components/Icon.tsx +0 -1
- package/src/components/InlineProse.tsx +37 -0
- package/src/components/LayoutGallery.tsx +65 -0
- package/src/components/PageView.tsx +19 -4
- package/src/components/Search.test.tsx +113 -0
- package/src/components/Search.tsx +53 -18
- package/src/components/Sidebar.test.tsx +135 -0
- package/src/components/Sidebar.tsx +114 -12
- package/src/components/TableOfContents.test.tsx +27 -16
- package/src/components/ThemeAssets.test.tsx +109 -0
- package/src/components/ThemeAssets.tsx +63 -0
- package/src/components/ThemeGallery.tsx +31 -0
- package/src/components/Timeline.tsx +5 -2
- package/src/components/WikiLink.tsx +2 -2
- package/src/data/catalogue.ts +54 -0
- package/src/data/themeFonts.ts +58 -0
- package/src/data/themes.ts +44 -4
- package/src/devfs.d.ts +5 -4
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +99 -0
- package/src/hooks/useOpenWikiBoot.ts +1 -1
- package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
- package/src/hooks/useOverlayFocusDismiss.ts +118 -0
- package/src/hooks/useScrollReset.test.tsx +132 -0
- package/src/hooks/useScrollReset.ts +52 -0
- package/src/index.css +9 -2
- package/src/lib/agentPrompt.test.ts +96 -0
- package/src/lib/agentPrompt.ts +86 -0
- package/src/lib/agentTools.test.ts +115 -0
- package/src/lib/agentTools.ts +132 -0
- package/src/lib/agentTranscript.ts +50 -0
- package/src/lib/assetPath.test.ts +36 -0
- package/src/lib/assetPath.ts +42 -0
- package/src/lib/collectionCalls.test.ts +69 -0
- package/src/lib/content.test.ts +10 -2
- package/src/lib/content.ts +7 -0
- package/src/lib/contentRoot.ts +16 -1
- package/src/lib/contentStylesheet.test.ts +80 -0
- package/src/lib/contentStylesheet.ts +103 -0
- package/src/lib/corpusScan.test.ts +37 -3
- package/src/lib/corpusScan.ts +17 -2
- package/src/lib/editTarget.test.ts +108 -0
- package/src/lib/editTarget.ts +93 -0
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/openWiki.test.ts +46 -5
- package/src/lib/openWiki.ts +18 -3
- package/src/lib/pageVariants.test.tsx +87 -0
- package/src/lib/queries.test.ts +33 -1
- package/src/lib/queries.ts +33 -1
- package/src/lib/reachCard.test.ts +94 -0
- package/src/lib/reachCard.ts +112 -0
- package/src/lib/shell.ts +17 -0
- package/src/lib/starterSweep.test.tsx +160 -0
- package/src/lib/starterSweep.ts +97 -0
- package/src/lib/themeAssets.test.ts +135 -0
- package/src/lib/themeAssets.ts +143 -0
- package/src/lib/themeSelection.test.ts +52 -0
- package/src/lib/themeSelection.ts +59 -0
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +37 -0
- package/viewer.manifest.json +133 -6
|
@@ -3,7 +3,9 @@ import { Link, useMetadataQuery } from '@immediately-run/sdk';
|
|
|
3
3
|
import type { Metadata } from '@immediately-run/sdk';
|
|
4
4
|
import { keyToHref } from '../lib/content';
|
|
5
5
|
import { crumb } from '../lib/wiki';
|
|
6
|
-
import {
|
|
6
|
+
import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
|
|
7
|
+
import InlineProse from './InlineProse';
|
|
8
|
+
import { matchesQuery, searchQuery, toSearchEntries } from '../lib/queries';
|
|
7
9
|
import type { SearchRecord } from '../lib/queries';
|
|
8
10
|
import Icon from './Icon';
|
|
9
11
|
|
|
@@ -13,13 +15,19 @@ interface Hit {
|
|
|
13
15
|
ns: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
let searchListSeq = 0;
|
|
19
|
+
|
|
16
20
|
// `.grove-search` — the ⌘K command palette: client-side fuzzy filter over the
|
|
17
|
-
// in-memory index, grouped into entries + tags, keyboard-navigable.
|
|
21
|
+
// in-memory index, grouped into entries + tags, keyboard-navigable. The input
|
|
22
|
+
// carries combobox semantics (aria-activedescendant tracks the highlight the
|
|
23
|
+
// filter maintains) and the box the dialog contract via the shared overlay
|
|
24
|
+
// hook (focus in, trap, Escape, focus return — R3-608).
|
|
18
25
|
export default function Search({ onClose }: { onClose: () => void }) {
|
|
19
26
|
const [query, setQuery] = useState('');
|
|
20
27
|
const [sel, setSel] = useState(0);
|
|
21
|
-
const boxRef = useRef<HTMLDivElement>(null);
|
|
22
28
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
29
|
+
const dialogRef = useOverlayFocusDismiss(true, onClose);
|
|
30
|
+
const listId = useMemo(() => `grove-search-list-${++searchListSeq}`, []);
|
|
23
31
|
|
|
24
32
|
// Records, not tab-encoded paths (R3-276a): `tags` is the array itself, so the
|
|
25
33
|
// join/split round-trip is gone.
|
|
@@ -28,12 +36,10 @@ export default function Search({ onClose }: { onClose: () => void }) {
|
|
|
28
36
|
|
|
29
37
|
const rowsKey = rows.map((r) => r.path).join('|');
|
|
30
38
|
const { entries, tags } = useMemo(() => {
|
|
31
|
-
const es = rows.map(({ path, title, desc, tags: entryTags }) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
tags: entryTags.join(','),
|
|
36
|
-
}));
|
|
39
|
+
const es = rows.map(({ path, title, desc, tags: entryTags }) => {
|
|
40
|
+
const entry = toSearchEntries([{ path, title, desc }])[0]!;
|
|
41
|
+
return { ...entry, tags: entryTags.join(',') };
|
|
42
|
+
});
|
|
37
43
|
const tagSet = new Set<string>();
|
|
38
44
|
es.forEach((e) => e.tags.split(',').filter(Boolean).forEach((t) => tagSet.add(t)));
|
|
39
45
|
return { entries: es, tags: Array.from(tagSet).sort() };
|
|
@@ -43,7 +49,7 @@ export default function Search({ onClose }: { onClose: () => void }) {
|
|
|
43
49
|
const ql = query.trim().toLowerCase();
|
|
44
50
|
const matchedEntries: Hit[] = (ql
|
|
45
51
|
? entries.filter(
|
|
46
|
-
(e) =>
|
|
52
|
+
(e) => matchesQuery(e, ql)
|
|
47
53
|
)
|
|
48
54
|
: entries.slice(0, 6)
|
|
49
55
|
).map((e) => ({ key: e.key, title: e.title, ns: crumb(e.key) }));
|
|
@@ -55,7 +61,8 @@ export default function Search({ onClose }: { onClose: () => void }) {
|
|
|
55
61
|
}, []);
|
|
56
62
|
|
|
57
63
|
const onKey = (e: React.KeyboardEvent) => {
|
|
58
|
-
|
|
64
|
+
// Escape is owned by the overlay hook (document capture); only the list
|
|
65
|
+
// navigation lives here.
|
|
59
66
|
if (e.key === 'ArrowDown') {
|
|
60
67
|
e.preventDefault();
|
|
61
68
|
setSel((s) => Math.min(s + 1, Math.max(0, total - 1)));
|
|
@@ -64,17 +71,33 @@ export default function Search({ onClose }: { onClose: () => void }) {
|
|
|
64
71
|
setSel((s) => Math.max(0, s - 1));
|
|
65
72
|
} else if (e.key === 'Enter') {
|
|
66
73
|
e.preventDefault();
|
|
67
|
-
(
|
|
74
|
+
(dialogRef.current?.querySelector('.grove-search__row[data-sel="1"]') as HTMLElement | null)?.click();
|
|
68
75
|
}
|
|
69
76
|
};
|
|
70
77
|
|
|
78
|
+
const activeId = total > 0 ? `${listId}-opt-${sel}` : undefined;
|
|
79
|
+
|
|
71
80
|
return (
|
|
72
81
|
<div className="grove-search" onClick={onClose}>
|
|
73
|
-
<div
|
|
82
|
+
<div
|
|
83
|
+
className="grove-search__box"
|
|
84
|
+
ref={dialogRef}
|
|
85
|
+
tabIndex={-1}
|
|
86
|
+
role="dialog"
|
|
87
|
+
aria-modal="true"
|
|
88
|
+
aria-label="Search entries and tags"
|
|
89
|
+
onClick={(e) => e.stopPropagation()}
|
|
90
|
+
>
|
|
74
91
|
<div className="grove-search__in">
|
|
75
92
|
<Icon name="search" />
|
|
76
93
|
<input
|
|
77
94
|
ref={inputRef}
|
|
95
|
+
role="combobox"
|
|
96
|
+
aria-expanded="true"
|
|
97
|
+
aria-controls={listId}
|
|
98
|
+
aria-activedescendant={activeId}
|
|
99
|
+
aria-autocomplete="list"
|
|
100
|
+
aria-label="Search entries and tags"
|
|
78
101
|
value={query}
|
|
79
102
|
placeholder="Search entries and tags…"
|
|
80
103
|
onChange={(e) => {
|
|
@@ -85,7 +108,7 @@ export default function Search({ onClose }: { onClose: () => void }) {
|
|
|
85
108
|
/>
|
|
86
109
|
<kbd>esc</kbd>
|
|
87
110
|
</div>
|
|
88
|
-
<div className="grove-search__res">
|
|
111
|
+
<div className="grove-search__res" role="listbox" id={listId} aria-label="Results">
|
|
89
112
|
{total === 0 ? (
|
|
90
113
|
<div className="grove-search__empty">
|
|
91
114
|
No entry matches <code>{query}</code>.
|
|
@@ -99,24 +122,36 @@ export default function Search({ onClose }: { onClose: () => void }) {
|
|
|
99
122
|
href={keyToHref(h.key)}
|
|
100
123
|
className="grove-search__row"
|
|
101
124
|
data-sel={sel === i ? '1' : '0'}
|
|
125
|
+
id={`${listId}-opt-${i}`}
|
|
126
|
+
role="option"
|
|
127
|
+
aria-selected={sel === i}
|
|
102
128
|
onClick={onClose}
|
|
103
129
|
>
|
|
104
130
|
<Icon name="file" />
|
|
105
|
-
<span className="t">
|
|
131
|
+
<span className="t">
|
|
132
|
+
<InlineProse text={h.title} trimPeriod />
|
|
133
|
+
</span>
|
|
106
134
|
<span className="c">/{h.ns}</span>
|
|
107
135
|
</Link>
|
|
108
136
|
))}
|
|
109
137
|
{matchedTags.length ? <div className="grove-search__grp">Tags</div> : null}
|
|
110
138
|
{matchedTags.map((t, i) => (
|
|
111
|
-
<
|
|
139
|
+
<button
|
|
112
140
|
key={t}
|
|
141
|
+
type="button"
|
|
113
142
|
className="grove-search__row"
|
|
114
143
|
data-sel={sel === matchedEntries.length + i ? '1' : '0'}
|
|
115
|
-
|
|
144
|
+
id={`${listId}-opt-${matchedEntries.length + i}`}
|
|
145
|
+
role="option"
|
|
146
|
+
aria-selected={sel === matchedEntries.length + i}
|
|
147
|
+
onClick={() => {
|
|
148
|
+
setQuery(t);
|
|
149
|
+
setSel(0);
|
|
150
|
+
}}
|
|
116
151
|
>
|
|
117
152
|
<Icon name="list" />
|
|
118
153
|
<span className="t">#{t}</span>
|
|
119
|
-
</
|
|
154
|
+
</button>
|
|
120
155
|
))}
|
|
121
156
|
</>
|
|
122
157
|
)}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// Sidebar tree navigation (R3-608, APG Tree View): exactly ONE tabbable row
|
|
3
|
+
// over a real filesMetadata fixture (the producers `lib/directory.test.ts`
|
|
4
|
+
// exercises build this same map), Down walks the rendered rows, and Right
|
|
5
|
+
// expands a collapsed folder through the SAME handler #63's toggle button
|
|
6
|
+
// uses.
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { act } from 'react';
|
|
9
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
10
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
11
|
+
|
|
12
|
+
const { default: Sidebar } = await import('./Sidebar');
|
|
13
|
+
|
|
14
|
+
// A real corpus shape: home + a wiki folder holding a leaf and a nested
|
|
15
|
+
// (initially collapsed) guide folder.
|
|
16
|
+
const FILES = {
|
|
17
|
+
'/app/content/home.mdx': { title: 'Home.' },
|
|
18
|
+
'/app/content/wiki/security.mdx': { title: 'Security.' },
|
|
19
|
+
'/app/content/wiki/guide/api.mdx': { title: 'API guide.' },
|
|
20
|
+
'/app/content/wiki/guide/deploy.mdx': { title: 'Deploying.' },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const NAV = {
|
|
24
|
+
outerHref: 'https://example.immediately.run/app/x',
|
|
25
|
+
navigationState: { sandboxPath: '/app/x', provider: 'github', namespace: 'immediately-run', repository: 'docs', ref: 'main', hash: '', search: '' },
|
|
26
|
+
routingSpec: {} as never,
|
|
27
|
+
filesMetadata: FILES,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function mountSidebar(): Promise<{ root: Root; container: HTMLElement }> {
|
|
31
|
+
const container = document.createElement('div');
|
|
32
|
+
document.body.appendChild(container);
|
|
33
|
+
const root = createRoot(container);
|
|
34
|
+
await act(async () => {
|
|
35
|
+
root.render(
|
|
36
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
37
|
+
<Sidebar />
|
|
38
|
+
</TinkerableContext.Provider>,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
return { root, container };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rows = (container: HTMLElement): HTMLElement[] =>
|
|
45
|
+
[...container.querySelectorAll('[data-tree-row]')] as HTMLElement[];
|
|
46
|
+
|
|
47
|
+
describe('Sidebar tree navigation (R3-608)', () => {
|
|
48
|
+
it('exposes exactly ONE tabbable row — the roving stop', async () => {
|
|
49
|
+
const { container } = await mountSidebar();
|
|
50
|
+
const stops = container.querySelectorAll('.gs-tree [tabindex="0"]');
|
|
51
|
+
expect(stops.length).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('Down moves focus through the rendered rows in order', async () => {
|
|
55
|
+
const { container } = await mountSidebar();
|
|
56
|
+
const list = rows(container);
|
|
57
|
+
expect(list.length).toBeGreaterThanOrEqual(3); // home, wiki folder, security
|
|
58
|
+
list[0].focus();
|
|
59
|
+
await act(async () => {
|
|
60
|
+
list[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
|
61
|
+
});
|
|
62
|
+
expect(document.activeElement).toBe(list[1]);
|
|
63
|
+
await act(async () => {
|
|
64
|
+
list[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
|
65
|
+
});
|
|
66
|
+
expect(document.activeElement).toBe(list[2]);
|
|
67
|
+
// Home returns to the first row.
|
|
68
|
+
await act(async () => {
|
|
69
|
+
list[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true, cancelable: true }));
|
|
70
|
+
});
|
|
71
|
+
expect(document.activeElement).toBe(list[0]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('Right expands a collapsed folder through the #63 toggle handler', async () => {
|
|
75
|
+
const { container } = await mountSidebar();
|
|
76
|
+
// Rows: home, [wiki folder], security, [guide folder — depth 1, collapsed].
|
|
77
|
+
const guide = rows(container).find((r) => r.getAttribute('aria-expanded') === 'false');
|
|
78
|
+
expect(guide).toBeTruthy();
|
|
79
|
+
const before = rows(container).length;
|
|
80
|
+
guide!.focus();
|
|
81
|
+
await act(async () => {
|
|
82
|
+
guide!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }));
|
|
83
|
+
});
|
|
84
|
+
// The folder opened: its two leaf rows joined the rendered set, and the
|
|
85
|
+
// same row now announces expanded.
|
|
86
|
+
expect(rows(container).length).toBe(before + 2);
|
|
87
|
+
expect(guide!.getAttribute('aria-expanded')).toBe('true');
|
|
88
|
+
// Left collapses it again.
|
|
89
|
+
await act(async () => {
|
|
90
|
+
guide!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true }));
|
|
91
|
+
});
|
|
92
|
+
expect(rows(container).length).toBe(before);
|
|
93
|
+
expect(guide!.getAttribute('aria-expanded')).toBe('false');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('the rows carry treeitem semantics under a tree', async () => {
|
|
97
|
+
const { container } = await mountSidebar();
|
|
98
|
+
const tree = container.querySelector('[role="tree"]');
|
|
99
|
+
expect(tree).toBeTruthy();
|
|
100
|
+
expect(container.querySelectorAll('[role="treeitem"]').length).toBe(rows(container).length);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('collapsing the branch that holds the current entry never strands the tab stop', async () => {
|
|
104
|
+
// Current entry = the api leaf deep in wiki/guide: its branch renders open.
|
|
105
|
+
const container = document.createElement('div');
|
|
106
|
+
document.body.appendChild(container);
|
|
107
|
+
const root = createRoot(container);
|
|
108
|
+
await act(async () => {
|
|
109
|
+
root.render(
|
|
110
|
+
<TinkerableContext.Provider value={{ ...NAV, navigationState: { ...NAV.navigationState, sandboxPath: '/app/content/wiki/guide/api.mdx' } } as never}>
|
|
111
|
+
<Sidebar />
|
|
112
|
+
</TinkerableContext.Provider>,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
// The current row IS the roving stop.
|
|
116
|
+
expect(container.querySelectorAll('.gs-tree [tabindex="0"]').length).toBe(1);
|
|
117
|
+
expect((container.querySelector('.gs-tree [tabindex="0"]') as HTMLElement).getAttribute('data-cur')).toBe('1');
|
|
118
|
+
// Collapse the guide folder (it contains the current entry): its button
|
|
119
|
+
// becomes the stop — the nearest rendered ancestor — and the tree keeps
|
|
120
|
+
// exactly one tabbable row.
|
|
121
|
+
const guide = rows(container).find((r) => r.getAttribute('aria-expanded') === 'true' && r.textContent?.includes('guide'));
|
|
122
|
+
expect(guide).toBeTruthy();
|
|
123
|
+
guide!.focus();
|
|
124
|
+
await act(async () => {
|
|
125
|
+
guide!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true }));
|
|
126
|
+
});
|
|
127
|
+
expect(guide!.getAttribute('aria-expanded')).toBe('false');
|
|
128
|
+
const stops = container.querySelectorAll('.gs-tree [tabindex="0"]');
|
|
129
|
+
expect(stops.length).toBe(1);
|
|
130
|
+
expect(stops[0]).toBe(guide);
|
|
131
|
+
await act(async () => {
|
|
132
|
+
root.unmount();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -5,6 +5,8 @@ import type { Metadata } from '@immediately-run/sdk';
|
|
|
5
5
|
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
6
6
|
import { contentDir, keyToHref, sandboxPathToKey } from '../lib/content';
|
|
7
7
|
import { sidebarQuery } from '../lib/queries';
|
|
8
|
+
import { plainLabel } from '../lib/queries';
|
|
9
|
+
import InlineProse from './InlineProse';
|
|
8
10
|
import type { SidebarRecord } from '../lib/queries';
|
|
9
11
|
import Icon from './Icon';
|
|
10
12
|
|
|
@@ -34,9 +36,43 @@ function hasDescendant(node: TreeNode, key: string): boolean {
|
|
|
34
36
|
return Object.values(node.children).some((c) => c.key === key || hasDescendant(c, key));
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
function treeHoldsKey(node: TreeNode, key: string): boolean {
|
|
40
|
+
return node.key === key || hasDescendant(node, key);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A row's stable id: a leaf is its entry key; a folder is its names-path
|
|
44
|
+
* (the `#dir:` prefix cannot collide with an entry key). `dirPath` is the
|
|
45
|
+
* accumulated folder names from the tree root, trailing slash included. */
|
|
46
|
+
function folderRowId(dirPath: string, name: string): string {
|
|
47
|
+
return `#dir:${dirPath}${name}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The FIRST rendered row under a node (children in sorted order): the top
|
|
51
|
+
* child itself — a folder's button, or a leaf's link. */
|
|
52
|
+
function firstRowIdOf(node: TreeNode, dirPath: string): string | null {
|
|
53
|
+
const childKeys = Object.keys(node.children).sort();
|
|
54
|
+
if (!childKeys.length) return node.key ?? null;
|
|
55
|
+
const first = node.children[childKeys[0]];
|
|
56
|
+
return first.key ?? folderRowId(dirPath, first.name);
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
// A folder (has children) or a leaf entry. Folders are collapsible; the branch
|
|
38
|
-
// holding the current entry starts open.
|
|
39
|
-
|
|
60
|
+
// holding the current entry starts open. Rows are `treeitem`s under a roving
|
|
61
|
+
// tab stop (R3-608, APG Tree View): exactly one row is tabbable, arrows walk
|
|
62
|
+
// the tree — the #63 folder toggle keeps its handler and its aria-expanded.
|
|
63
|
+
function Branch({
|
|
64
|
+
node,
|
|
65
|
+
currentKey,
|
|
66
|
+
depth,
|
|
67
|
+
dirPath,
|
|
68
|
+
stopRowId,
|
|
69
|
+
}: {
|
|
70
|
+
node: TreeNode;
|
|
71
|
+
currentKey: string;
|
|
72
|
+
depth: number;
|
|
73
|
+
dirPath: string;
|
|
74
|
+
stopRowId: string | null;
|
|
75
|
+
}) {
|
|
40
76
|
const childKeys = Object.keys(node.children).sort();
|
|
41
77
|
const isFolder = childKeys.length > 0;
|
|
42
78
|
const containsCurrent = !!currentKey && (node.key === currentKey || hasDescendant(node, currentKey));
|
|
@@ -44,24 +80,57 @@ function Branch({ node, currentKey, depth }: { node: TreeNode; currentKey: strin
|
|
|
44
80
|
|
|
45
81
|
if (!isFolder) {
|
|
46
82
|
return (
|
|
47
|
-
<Link
|
|
83
|
+
<Link
|
|
84
|
+
href={keyToHref(node.key!)}
|
|
85
|
+
className="gs-tree__row"
|
|
86
|
+
data-cur={node.key === currentKey ? '1' : '0'}
|
|
87
|
+
data-tree-row="1"
|
|
88
|
+
role="treeitem"
|
|
89
|
+
aria-level={depth + 1}
|
|
90
|
+
aria-current={node.key === currentKey ? 'page' : undefined}
|
|
91
|
+
tabIndex={node.key === stopRowId ? 0 : -1}
|
|
92
|
+
>
|
|
48
93
|
<Icon name="file" />
|
|
49
|
-
{
|
|
94
|
+
<InlineProse text={node.title || node.name} trimPeriod />
|
|
50
95
|
</Link>
|
|
51
96
|
);
|
|
52
97
|
}
|
|
53
98
|
|
|
99
|
+
const rowId = folderRowId(dirPath, node.name);
|
|
54
100
|
return (
|
|
55
101
|
<div>
|
|
56
|
-
<
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className="gs-tree__row gs-folder"
|
|
105
|
+
data-open={open ? '1' : '0'}
|
|
106
|
+
data-tree-row="1"
|
|
107
|
+
role="treeitem"
|
|
108
|
+
aria-level={depth + 1}
|
|
109
|
+
aria-expanded={open}
|
|
110
|
+
/* Roving stop, with the collapsed-ancestor fallback (R3-608 review):
|
|
111
|
+
* the folder holds the stop when it IS the stop's row, or when it is
|
|
112
|
+
* collapsed yet CONTAINS the current entry — collapsing the branch the
|
|
113
|
+
* reader is on must never unmount the tree's only tab stop. Exactly
|
|
114
|
+
* one row qualifies either way: the collapsed folder is the nearest
|
|
115
|
+
* rendered ancestor of the (unrendered) current row. */
|
|
116
|
+
tabIndex={rowId === stopRowId || (!open && containsCurrent) ? 0 : -1}
|
|
117
|
+
onClick={() => setOpen((o) => !o)}
|
|
118
|
+
>
|
|
57
119
|
<Icon name="chevron-down" className="chev" />
|
|
58
120
|
{node.name}
|
|
59
121
|
<span className="ct">{childKeys.length}</span>
|
|
60
|
-
</
|
|
122
|
+
</button>
|
|
61
123
|
{open ? (
|
|
62
|
-
<div className="gs-tree__children">
|
|
124
|
+
<div className="gs-tree__children" role="group">
|
|
63
125
|
{childKeys.map((k) => (
|
|
64
|
-
<Branch
|
|
126
|
+
<Branch
|
|
127
|
+
key={k}
|
|
128
|
+
node={node.children[k]}
|
|
129
|
+
currentKey={currentKey}
|
|
130
|
+
depth={depth + 1}
|
|
131
|
+
dirPath={`${dirPath}${node.name}/`}
|
|
132
|
+
stopRowId={stopRowId}
|
|
133
|
+
/>
|
|
65
134
|
))}
|
|
66
135
|
</div>
|
|
67
136
|
) : null}
|
|
@@ -85,7 +154,7 @@ export default function Sidebar() {
|
|
|
85
154
|
const secs: { key: string; label: string }[] = [];
|
|
86
155
|
rows.forEach(({ path, title, tags, nav }) => {
|
|
87
156
|
insert(root, path, title);
|
|
88
|
-
if (tags.includes('ui/sidebar')) secs.push({ key: path, label: nav || title || path });
|
|
157
|
+
if (tags.includes('ui/sidebar')) secs.push({ key: path, label: plainLabel(nav || title || path) });
|
|
89
158
|
});
|
|
90
159
|
return { tree: root, sections: secs };
|
|
91
160
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -93,19 +162,52 @@ export default function Sidebar() {
|
|
|
93
162
|
|
|
94
163
|
const topKeys = Object.keys(tree.children).sort();
|
|
95
164
|
|
|
165
|
+
// The roving tab stop: the current entry's own row (its branch opens by
|
|
166
|
+
// construction), else the tree's first rendered row.
|
|
167
|
+
const stopRowId = useMemo(() => {
|
|
168
|
+
if (currentKey && treeHoldsKey(tree, currentKey)) return currentKey;
|
|
169
|
+
return firstRowIdOf(tree, '');
|
|
170
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
171
|
+
}, [rowsKey, currentKey]);
|
|
172
|
+
|
|
173
|
+
// The tree's keyboard controller (R3-608): Up/Down/Home/End walk the
|
|
174
|
+
// RENDERED rows in DOM order; Right/Left expand/collapse by delegating to
|
|
175
|
+
// the folder row's own handler (the #63 button's onClick) when the row's
|
|
176
|
+
// aria-expanded says the arrow applies.
|
|
177
|
+
const onTreeKeyDown = (e: React.KeyboardEvent) => {
|
|
178
|
+
const rowsInOrder = [...(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[data-tree-row]')];
|
|
179
|
+
if (!rowsInOrder.length) return;
|
|
180
|
+
const activeIdx = rowsInOrder.indexOf(document.activeElement as HTMLElement);
|
|
181
|
+
const move = (idx: number) => {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
rowsInOrder[Math.max(0, Math.min(rowsInOrder.length - 1, idx))]?.focus();
|
|
184
|
+
};
|
|
185
|
+
if (e.key === 'ArrowDown') move(activeIdx + 1);
|
|
186
|
+
else if (e.key === 'ArrowUp') move(activeIdx - 1);
|
|
187
|
+
else if (e.key === 'Home') move(0);
|
|
188
|
+
else if (e.key === 'End') move(rowsInOrder.length - 1);
|
|
189
|
+
else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
|
190
|
+
const row = rowsInOrder[activeIdx];
|
|
191
|
+
const wantOpen = e.key === 'ArrowRight';
|
|
192
|
+
if (!row || row.getAttribute('aria-expanded') !== (wantOpen ? 'false' : 'true')) return;
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
row.click(); // the #63 folder toggle's own handler
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
96
198
|
return (
|
|
97
199
|
<aside className="grove-sidebar">
|
|
98
200
|
<div className="gs-block">
|
|
99
201
|
<div className="gs-block__h">Browse</div>
|
|
100
|
-
<div className="gs-tree">
|
|
202
|
+
<div className="gs-tree" role="tree" aria-label="Entries" onKeyDown={onTreeKeyDown}>
|
|
101
203
|
{topKeys.map((k) => (
|
|
102
|
-
<Branch key={k} node={tree.children[k]} currentKey={currentKey} depth={0} />
|
|
204
|
+
<Branch key={k} node={tree.children[k]} currentKey={currentKey} depth={0} dirPath="" stopRowId={stopRowId} />
|
|
103
205
|
))}
|
|
104
206
|
</div>
|
|
105
207
|
</div>
|
|
106
208
|
{sections.map((s) => (
|
|
107
209
|
<div className="gs-block" key={s.key}>
|
|
108
|
-
<div className="gs-block__h">{s.label
|
|
210
|
+
<div className="gs-block__h">{s.label}</div>
|
|
109
211
|
<div className="gs-section__body">
|
|
110
212
|
<Link href={keyToHref(s.key)} className="grove-wikilink" data-state="ok">
|
|
111
213
|
Open
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
3
|
import { act } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
5
6
|
import TableOfContents from './TableOfContents';
|
|
6
7
|
|
|
7
8
|
// jsdom performs no layout, so every geometry property this component reads is 0. The
|
|
@@ -54,6 +55,25 @@ function layout(nav: HTMLElement, opts: { viewHeight?: number; scrollHeight?: nu
|
|
|
54
55
|
|
|
55
56
|
const IDS = Array.from({ length: 40 }, (_, i) => `sec-${i + 1}`);
|
|
56
57
|
|
|
58
|
+
// Every root this file mounts, so `afterEach` can tear them down. Clearing `document.body`
|
|
59
|
+
// is not unmounting: `useHeadings` arms 120/300/600ms re-scan timers and only clears them
|
|
60
|
+
// on unmount, so an abandoned root leaves a timer that fires after the file's jsdom
|
|
61
|
+
// environment is gone — an unhandled `document is not defined` attributed to whichever
|
|
62
|
+
// file happened to be running.
|
|
63
|
+
const roots: Root[] = [];
|
|
64
|
+
|
|
65
|
+
/** Mount `node` into a fresh host and remember the root for teardown. */
|
|
66
|
+
async function renderInto(node: ReactNode): Promise<HTMLElement> {
|
|
67
|
+
const host = document.createElement('div');
|
|
68
|
+
document.body.appendChild(host);
|
|
69
|
+
const root = createRoot(host);
|
|
70
|
+
roots.push(root);
|
|
71
|
+
await act(async () => {
|
|
72
|
+
root.render(node);
|
|
73
|
+
});
|
|
74
|
+
return host;
|
|
75
|
+
}
|
|
76
|
+
|
|
57
77
|
let scrollTo: ReturnType<typeof vi.fn>;
|
|
58
78
|
let scrollIntoView: ReturnType<typeof vi.fn>;
|
|
59
79
|
|
|
@@ -66,17 +86,16 @@ beforeEach(() => {
|
|
|
66
86
|
Element.prototype.scrollIntoView = scrollIntoView as never;
|
|
67
87
|
});
|
|
68
88
|
afterEach(() => {
|
|
89
|
+
act(() => {
|
|
90
|
+
roots.splice(0).forEach((r) => r.unmount());
|
|
91
|
+
});
|
|
69
92
|
document.body.innerHTML = '';
|
|
70
93
|
vi.restoreAllMocks();
|
|
71
94
|
});
|
|
72
95
|
|
|
73
96
|
async function mount(): Promise<HTMLElement> {
|
|
74
97
|
plantProse(IDS);
|
|
75
|
-
const host =
|
|
76
|
-
document.body.appendChild(host);
|
|
77
|
-
await act(async () => {
|
|
78
|
-
createRoot(host).render(<TableOfContents entryKey="/app/content/x.mdx" />);
|
|
79
|
-
});
|
|
98
|
+
const host = await renderInto(<TableOfContents entryKey="/app/content/x.mdx" />);
|
|
80
99
|
return layout(host.querySelector('nav')!);
|
|
81
100
|
}
|
|
82
101
|
|
|
@@ -112,11 +131,7 @@ describe('<TableOfContents/>', () => {
|
|
|
112
131
|
|
|
113
132
|
it('does nothing when the whole list fits', async () => {
|
|
114
133
|
plantProse(IDS);
|
|
115
|
-
const host =
|
|
116
|
-
document.body.appendChild(host);
|
|
117
|
-
await act(async () => {
|
|
118
|
-
createRoot(host).render(<TableOfContents entryKey="/app/content/x.mdx" />);
|
|
119
|
-
});
|
|
134
|
+
const host = await renderInto(<TableOfContents entryKey="/app/content/x.mdx" />);
|
|
120
135
|
layout(host.querySelector('nav')!, { viewHeight: 1000, scrollHeight: 1000 });
|
|
121
136
|
await spyTo('sec-30');
|
|
122
137
|
expect(scrollTo).not.toHaveBeenCalled();
|
|
@@ -141,11 +156,7 @@ describe('<TableOfContents/>', () => {
|
|
|
141
156
|
});
|
|
142
157
|
|
|
143
158
|
it('renders nothing for an entry with no sub-headings', async () => {
|
|
144
|
-
const host =
|
|
145
|
-
document.body.appendChild(host);
|
|
146
|
-
await act(async () => {
|
|
147
|
-
createRoot(host).render(<TableOfContents entryKey="/app/content/empty.mdx" />);
|
|
148
|
-
});
|
|
159
|
+
const host = await renderInto(<TableOfContents entryKey="/app/content/empty.mdx" />);
|
|
149
160
|
expect(host.querySelector('nav')).toBeNull();
|
|
150
161
|
});
|
|
151
162
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// R3-315's component-level exits: the engine emission renders the minted
|
|
3
|
+
// @font-face set inside the engine cascade layer, revokes on unmount (a switch
|
|
4
|
+
// leaks nothing), degrades to nothing when the bytes are unreadable, and — the
|
|
5
|
+
// offline exit — no shipped stylesheet carries a third-party @import.
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
7
|
+
import { act } from 'react';
|
|
8
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
const readFile = vi.fn();
|
|
12
|
+
(globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
|
|
13
|
+
promises: { readFile: (...a: unknown[]) => readFile(...a) },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const { default: ThemeAssets } = await import('./ThemeAssets');
|
|
17
|
+
const { DEFAULT_THEME_ASSETS } = await import('../data/themeFonts');
|
|
18
|
+
|
|
19
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
|
20
|
+
|
|
21
|
+
async function renderSwap(nodes: ReactNode[]): Promise<HTMLElement> {
|
|
22
|
+
const container = document.createElement('div');
|
|
23
|
+
document.body.appendChild(container);
|
|
24
|
+
const root = createRoot(container);
|
|
25
|
+
for (const node of nodes) {
|
|
26
|
+
await act(async () => {
|
|
27
|
+
root.render(node);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return container;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
readFile.mockReset();
|
|
35
|
+
revokeSpy.mockClear();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('the engine emission', () => {
|
|
39
|
+
it('renders the minted faces inside the ENGINE cascade layer, blob: srcs only', async () => {
|
|
40
|
+
readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
|
41
|
+
const el = await renderSwap([<ThemeAssets declarations={DEFAULT_THEME_ASSETS} />]);
|
|
42
|
+
const style = el.querySelector('style[data-grove-theme-assets]');
|
|
43
|
+
expect(style).toBeTruthy();
|
|
44
|
+
expect(style?.textContent).toContain('@layer grove.engine{');
|
|
45
|
+
expect(style?.textContent).toContain('"Gabarito"');
|
|
46
|
+
expect(style?.textContent).toContain('"Public Sans"');
|
|
47
|
+
expect(style?.textContent).toContain('"Space Mono"');
|
|
48
|
+
expect((style?.textContent?.match(/src: url\("blob:/g) ?? []).length).toBe(12);
|
|
49
|
+
// The read set is the in-repo face set — no network fetch is involved at all.
|
|
50
|
+
// The read goes through the SDK root-mount (leading slash stripped — the
|
|
51
|
+
// root-anchored relPath, the same key space MountImage reads).
|
|
52
|
+
expect(readFile).toHaveBeenCalledWith('/app/assets/fonts/gabarito-latin-400-normal.woff2');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('unmount revokes every minted URL — switching sets leaks nothing', async () => {
|
|
56
|
+
readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
|
57
|
+
const container = document.createElement('div');
|
|
58
|
+
document.body.appendChild(container);
|
|
59
|
+
let root: Root | null = createRoot(container);
|
|
60
|
+
await act(async () => {
|
|
61
|
+
root?.render(<ThemeAssets declarations={DEFAULT_THEME_ASSETS} />);
|
|
62
|
+
});
|
|
63
|
+
expect(revokeSpy).not.toHaveBeenCalled(); // live set holds its URLs
|
|
64
|
+
await act(async () => {
|
|
65
|
+
root?.render(<ThemeAssets declarations={{ fonts: [{ family: 'Other', src: './o.woff2' }] }} />);
|
|
66
|
+
});
|
|
67
|
+
// The DEFAULT set (12 URLs) was the OUTGOING set — revoked on the swap.
|
|
68
|
+
expect(revokeSpy).toHaveBeenCalledTimes(12);
|
|
69
|
+
await act(async () => {}); // let the incoming set's mint land (it is now live)
|
|
70
|
+
await act(async () => {
|
|
71
|
+
root?.unmount();
|
|
72
|
+
});
|
|
73
|
+
root = null;
|
|
74
|
+
expect(revokeSpy).toHaveBeenCalledTimes(13); // + the incoming set's 1
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('unreadable bytes degrade to no style element at all (the fallback stack answers)', async () => {
|
|
78
|
+
readFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
79
|
+
const el = await renderSwap([<ThemeAssets declarations={DEFAULT_THEME_ASSETS} />]);
|
|
80
|
+
expect(el.querySelector('style[data-grove-theme-assets]')).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('the offline exit — no third-party @import survives in any shipped stylesheet', () => {
|
|
85
|
+
it('grep over the shipped css: no @import names a network location', async () => {
|
|
86
|
+
const { readdir, readFile: rf } = await import('node:fs/promises');
|
|
87
|
+
const walk = async (dir: string): Promise<string[]> => {
|
|
88
|
+
const out: string[] = [];
|
|
89
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
90
|
+
const p = `${dir}/${e.name}`;
|
|
91
|
+
if (e.isDirectory()) out.push(...(await walk(p)));
|
|
92
|
+
else if (p.endsWith('.css')) out.push(p);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
const files = await walk('src');
|
|
97
|
+
expect(files.length).toBeGreaterThan(0);
|
|
98
|
+
for (const f of files) {
|
|
99
|
+
const css = await rf(f, 'utf8');
|
|
100
|
+
for (const line of css.split('\n')) {
|
|
101
|
+
if (line.includes('@import')) {
|
|
102
|
+
// Only same-package/relative imports could ever be acceptable; a URL is
|
|
103
|
+
// the withdrawn exception.
|
|
104
|
+
expect(line).not.toMatch(/https?:\/\//);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|