vite-plugin-nora 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +26 -0
  3. package/README.md +113 -0
  4. package/licenses/geist-OFL.txt +93 -0
  5. package/licenses/lucide-ISC.txt +17 -0
  6. package/package.json +90 -0
  7. package/src/cli.js +101 -0
  8. package/src/client/App.jsx +216 -0
  9. package/src/client/canvas/Canvas.jsx +95 -0
  10. package/src/client/canvas/ErrorBoundary.jsx +56 -0
  11. package/src/client/canvas/Preview.jsx +70 -0
  12. package/src/client/canvas/Viewport.jsx +211 -0
  13. package/src/client/chords.js +38 -0
  14. package/src/client/css.d.ts +5 -0
  15. package/src/client/fonts.css +48 -0
  16. package/src/client/frame.css +107 -0
  17. package/src/client/frame.html +11 -0
  18. package/src/client/frame.jsx +73 -0
  19. package/src/client/index.html +12 -0
  20. package/src/client/main.jsx +10 -0
  21. package/src/client/open-folder.js +84 -0
  22. package/src/client/selection.js +22 -0
  23. package/src/client/styles.css +1264 -0
  24. package/src/client/sweep/SweepPanel.jsx +206 -0
  25. package/src/client/sweep/measure.js +230 -0
  26. package/src/client/sweep/report.js +54 -0
  27. package/src/client/sweep/run-sweep.js +185 -0
  28. package/src/client/toolbar/Picker.jsx +343 -0
  29. package/src/client/toolbar/Toolbar.jsx +231 -0
  30. package/src/client/toolbar/ViewportMenu.jsx +83 -0
  31. package/src/client/toolbar/bar-shape.js +402 -0
  32. package/src/client/toolbar/icons.jsx +129 -0
  33. package/src/client/toolbar/use-draggable-bar.js +204 -0
  34. package/src/client/viewports.js +43 -0
  35. package/src/client/virtual.d.ts +28 -0
  36. package/src/index.js +4 -0
  37. package/src/server/create-server.js +147 -0
  38. package/src/server/plugin.js +207 -0
  39. package/src/server/safe-path.js +42 -0
  40. package/src/server/scan.js +350 -0
  41. package/types/index.d.ts +3 -0
  42. package/types/server/create-server.d.ts +22 -0
  43. package/types/server/plugin.d.ts +20 -0
  44. package/types/server/safe-path.d.ts +25 -0
  45. package/types/server/scan.d.ts +106 -0
@@ -0,0 +1,343 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { AlertIcon, ChevronUpIcon, FolderIcon } from "./icons.jsx";
3
+ import { openFolder } from "../open-folder.js";
4
+
5
+ /**
6
+ * One list for finding anything: the components in the folder you are pointed
7
+ * at, and the folders you could point at instead.
8
+ *
9
+ * This used to be two panels. You opened the folder browser, drilled to a
10
+ * directory, pressed a hover-only button to scan it, landed on an empty canvas,
11
+ * read a line of text telling you to press ⌘K, and opened a second panel to
12
+ * choose a component. Four of those five steps were the tool's bookkeeping
13
+ * rather than anything the user wanted, and the instruction only existed
14
+ * because picking a folder reloads the page and severed the thread.
15
+ *
16
+ * The collapse rests on one decision: **selecting a folder opens it**. There is
17
+ * no separate "browse into" and "choose this one" — descending into a folder
18
+ * and previewing it are the same act, so the two mechanics that contradicted
19
+ * each other become one. What you see afterwards is that folder's design, its
20
+ * components, and its subfolders, which is the same shape of list you were just
21
+ * looking at. Navigation is therefore closed under itself: every row leads to
22
+ * another list exactly like this one.
23
+ *
24
+ * Opening a folder still reloads, because the registry is a server-side virtual
25
+ * module and rebuilding it is the honest way to change what is scanned. The
26
+ * difference is that the reload now carries the picker with it (see App.jsx) and
27
+ * lands on the folder's own top-level design (see pickEntry in scan.js), so the
28
+ * reload is invisible and the dead end is gone.
29
+ */
30
+
31
+ /**
32
+ * Subsequence match with a light score: consecutive hits and matches right
33
+ * after a separator count for more, so "prc" finds PricingCard ahead of
34
+ * ProfileRadioControl.
35
+ */
36
+ function score(query, target) {
37
+ if (!query) return 0;
38
+ const q = query.toLowerCase();
39
+ const t = target.toLowerCase();
40
+ let qi = 0;
41
+ let total = 0;
42
+ let streak = 0;
43
+
44
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
45
+ if (t[ti] !== q[qi]) {
46
+ streak = 0;
47
+ continue;
48
+ }
49
+ let point = 1 + streak;
50
+ const prev = t[ti - 1];
51
+ if (ti === 0 || prev === "/" || prev === "-" || prev === "." || prev === "_") point += 3;
52
+ else if (target[ti] === target[ti].toUpperCase() && target[ti] !== target[ti].toLowerCase())
53
+ point += 2;
54
+ total += point;
55
+ streak++;
56
+ qi++;
57
+ }
58
+
59
+ return qi === q.length ? total : -1;
60
+ }
61
+
62
+ export function Picker({ entries, currentDir, selectedId, onPick, onClose }) {
63
+ const [query, setQuery] = useState("");
64
+ const [cursor, setCursor] = useState(0);
65
+ const [dirs, setDirs] = useState(null);
66
+ const [error, setError] = useState(null);
67
+ const [pending, setPending] = useState(false);
68
+ const inputRef = useRef(null);
69
+ const listRef = useRef(null);
70
+
71
+ // `home` is the folder actually scanned; `browse` is the folder being looked
72
+ // at, which starts there and moves with the arrow keys. Keeping them apart is
73
+ // what lets you look three levels down without opening three folders on the
74
+ // way — only Enter commits, and only committing reloads.
75
+ const home = currentDir ?? "";
76
+ const [browse, setBrowse] = useState(home);
77
+ const parent = browse ? browse.split("/").slice(0, -1).join("/") : null;
78
+ const atHome = browse === home;
79
+
80
+ useEffect(() => {
81
+ inputRef.current?.focus();
82
+ }, []);
83
+
84
+ // Folder names and their component counts, straight from the server. Cheap:
85
+ // counting candidate files does not parse them, which is why the counts can
86
+ // be shown for folders that have never been scanned.
87
+ useEffect(() => {
88
+ let cancelled = false;
89
+ fetch(`/__nora/dirs?path=${encodeURIComponent(browse)}`)
90
+ .then((r) => r.json())
91
+ .then((json) => {
92
+ if (cancelled) return;
93
+ // Settled once, rather than cleared up front. Clearing in the effect
94
+ // body is a synchronous setState during an effect, and a cascading
95
+ // render for a value that is almost always already null.
96
+ setError(json.error ?? null);
97
+ if (!json.error) setDirs(json.dirs);
98
+ })
99
+ .catch((err) => !cancelled && setError(String(err.message ?? err)));
100
+ return () => {
101
+ cancelled = true;
102
+ };
103
+ }, [browse]);
104
+
105
+ const rows = useMemo(() => {
106
+ const q = query.trim();
107
+
108
+ const matched = !q
109
+ ? entries
110
+ : entries
111
+ .map((e) => ({
112
+ e,
113
+ s: Math.max(score(q, e.name), score(q, `${e.group}/${e.name}`)),
114
+ }))
115
+ .filter((r) => r.s >= 0)
116
+ .sort((a, b) => b.s - a.s)
117
+ .map((r) => r.e);
118
+
119
+ // The top of the list is whatever you could land on from here. At the
120
+ // scanned folder that is its components; anywhere else it is the single act
121
+ // of opening the folder you have browsed to, since its components are not
122
+ // known until it is scanned.
123
+ const head = atHome
124
+ ? matched.map((e) => ({ kind: "component", key: e.id, entry: e }))
125
+ : [{ kind: "open", key: "__open" }];
126
+
127
+ const folders = (dirs ?? [])
128
+ .filter((d) => !q || score(q, d.name) >= 0)
129
+ .map((d) => ({ kind: "folder", key: `dir:${d.name}`, dir: d }));
130
+
131
+ // Going up is navigation, not a result, so it is hidden while searching.
132
+ const up = parent !== null && !q ? [{ kind: "up", key: "__up" }] : [];
133
+
134
+ return [...head, ...up, ...folders];
135
+ }, [entries, dirs, query, parent, atHome]);
136
+
137
+ useEffect(() => {
138
+ const el = listRef.current?.querySelector('[data-active="true"]');
139
+ el?.scrollIntoView({ block: "nearest" });
140
+ }, [cursor, rows]);
141
+
142
+ /**
143
+ * Nothing clears `pending` on success, deliberately: `openFolder` replaces the
144
+ * document, so there is no later render to clear it in. A rejection is the
145
+ * only outcome this component survives.
146
+ */
147
+ const open = async (dir) => {
148
+ setPending(true);
149
+ try {
150
+ await openFolder(dir);
151
+ } catch (err) {
152
+ setError(String(err.message ?? err));
153
+ setPending(false);
154
+ }
155
+ };
156
+
157
+ const childOf = (name) => (browse ? `${browse}/${name}` : name);
158
+
159
+ /**
160
+ * Move the list, and put the cursor back at the top.
161
+ *
162
+ * Both happen because of an event — a keystroke, a click — so they are done
163
+ * together here rather than by an effect watching `browse` afterwards. An
164
+ * effect would be React reacting to its own state change, one render late.
165
+ */
166
+ const goTo = (path) => {
167
+ setBrowse(path);
168
+ setCursor(0);
169
+ };
170
+
171
+ /** Enter: land on this row. Only this commits, and only this reloads. */
172
+ const commit = (row) => {
173
+ if (!row || pending) return;
174
+ if (row.kind === "component") {
175
+ onPick(row.entry.id);
176
+ onClose();
177
+ return;
178
+ }
179
+ if (row.kind === "open") return open(browse);
180
+ if (row.kind === "up") return goTo(parent);
181
+ open(childOf(row.dir.name));
182
+ };
183
+
184
+ /** Right: look inside, without opening anything. */
185
+ const descend = (row) => {
186
+ if (!row || pending || row.kind !== "folder") return;
187
+ goTo(childOf(row.dir.name));
188
+ };
189
+
190
+ /** Everything the picker acts on while it is open. */
191
+ const CLAIMED = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter", "Escape"];
192
+
193
+ const onKeyDown = (e) => {
194
+ if (!CLAIMED.includes(e.key)) return; // ⌘K and friends pass through
195
+
196
+ // Claimed keys stop here. That is what lets the shell's listener stay
197
+ // simple: it no longer has to ask whether a panel is open before acting on
198
+ // an arrow. Letting them bubble is what once made a single ⌥↑ move this
199
+ // cursor *and* change the canvas underneath it.
200
+ e.preventDefault();
201
+ e.stopPropagation();
202
+
203
+ if (e.key === "ArrowDown") setCursor((c) => Math.min(c + 1, rows.length - 1));
204
+ else if (e.key === "ArrowUp") setCursor((c) => Math.max(c - 1, 0));
205
+ else if (e.key === "Enter") commit(rows[cursor]);
206
+ else if (e.key === "ArrowRight") descend(rows[cursor]);
207
+ else if (e.key === "ArrowLeft") {
208
+ if (parent !== null) goTo(parent);
209
+ } else if (e.key === "Escape") onClose();
210
+ };
211
+
212
+ const componentCount = entries.length;
213
+ // The rule divides components from folders, so it only exists if there are
214
+ // components above it to divide. On a folder with none — a project root you
215
+ // have not pointed at anything yet — it would just be a stray line under the
216
+ // path.
217
+ const firstFolder = rows.findIndex((r) => r.kind !== "component");
218
+ const breakAt = firstFolder > 0 ? firstFolder : -1;
219
+
220
+ return (
221
+ <div className="nora-palette" role="dialog" aria-label="Find a Component">
222
+ <input
223
+ ref={inputRef}
224
+ className="nora-palette-input"
225
+ value={query}
226
+ placeholder={
227
+ componentCount
228
+ ? `Search ${componentCount} Component${componentCount === 1 ? "" : "s"} and Folders…`
229
+ : "Search Folders…"
230
+ }
231
+ onChange={(e) => {
232
+ setQuery(e.target.value);
233
+ setCursor(0);
234
+ }}
235
+ onKeyDown={onKeyDown}
236
+ aria-label="Search Components and Folders"
237
+ disabled={pending}
238
+ />
239
+
240
+ <div className="nora-picker-where">
241
+ <span className="nora-picker-path">{browse || "Project Root"}</span>
242
+ {pending ? <span className="nora-picker-pending">Opening…</span> : null}
243
+ </div>
244
+
245
+ {error ? <div className="nora-panel-error">{error}</div> : null}
246
+
247
+ <div className="nora-palette-list" ref={listRef}>
248
+ {rows.length === 0 ? (
249
+ <div className="nora-panel-empty">
250
+ {query.trim() ? `No match for “${query}”.` : "Nothing here."}
251
+ </div>
252
+ ) : null}
253
+
254
+ {rows.map((row, i) => {
255
+ const active = i === cursor;
256
+
257
+ if (row.kind === "component") {
258
+ const entry = row.entry;
259
+ return (
260
+ <button
261
+ key={row.key}
262
+ data-active={active}
263
+ className={
264
+ "nora-palette-row" +
265
+ (active ? " is-active" : "") +
266
+ (entry.id === selectedId ? " is-selected" : "") +
267
+ (entry.unsupported ? " is-unsupported" : "")
268
+ }
269
+ onMouseEnter={() => setCursor(i)}
270
+ onClick={() => commit(row)}
271
+ title={entry.unsupported ?? entry.file}
272
+ >
273
+ <span className="nora-palette-name">{entry.name}</span>
274
+ <span className="nora-palette-path">
275
+ {entry.group ? `${entry.group}/` : ""}
276
+ {entry.file.split("/").pop()}
277
+ </span>
278
+ {entry.unsupported ? <AlertIcon size={13} /> : null}
279
+ </button>
280
+ );
281
+ }
282
+
283
+ if (row.kind === "open") {
284
+ return (
285
+ <button
286
+ key={row.key}
287
+ data-active={active}
288
+ className={
289
+ "nora-palette-row nora-picker-dir nora-picker-open" + (active ? " is-active" : "")
290
+ }
291
+ onMouseEnter={() => setCursor(i)}
292
+ onClick={() => commit(row)}
293
+ disabled={pending}
294
+ title={`Open ${browse}`}
295
+ >
296
+ <FolderIcon size={14} />
297
+ <span className="nora-palette-name">Open {browse}</span>
298
+ </button>
299
+ );
300
+ }
301
+
302
+ const up = row.kind === "up";
303
+ return (
304
+ <div key={row.key} className={i === breakAt ? "nora-picker-break" : undefined}>
305
+ <button
306
+ data-active={active}
307
+ className={"nora-palette-row nora-picker-dir" + (active ? " is-active" : "")}
308
+ onMouseEnter={() => setCursor(i)}
309
+ onClick={() => commit(row)}
310
+ disabled={pending}
311
+ title={up ? `Open ${parent || "Project Root"}` : `Open ${row.dir.name}`}
312
+ >
313
+ {up ? <ChevronUpIcon size={14} /> : <FolderIcon size={14} />}
314
+ <span className="nora-palette-name">
315
+ {up ? parent || "Project Root" : row.dir.name}
316
+ </span>
317
+ <span className="nora-picker-count">{up ? "" : row.dir.count || ""}</span>
318
+ </button>
319
+ </div>
320
+ );
321
+ })}
322
+ </div>
323
+
324
+ <div className="nora-palette-foot">
325
+ <span>
326
+ <kbd>↑</kbd>
327
+ <kbd>↓</kbd>
328
+ <kbd>←</kbd>
329
+ <kbd>→</kbd> Move
330
+ </span>
331
+ <span>
332
+ <kbd>Enter</kbd> Choose
333
+ </span>
334
+ <span>
335
+ <kbd>Esc</kbd> Close
336
+ </span>
337
+ <span>
338
+ <kbd>⌘K</kbd> Open
339
+ </span>
340
+ </div>
341
+ </div>
342
+ );
343
+ }
@@ -0,0 +1,231 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Picker } from "./Picker.jsx";
3
+ import { ViewportMenu } from "./ViewportMenu.jsx";
4
+ import { SweepPanel } from "../sweep/SweepPanel.jsx";
5
+ import { GridIcon, SearchIcon, SunIcon, MoonIcon, SweepIcon, CollapseIcon } from "./icons.jsx";
6
+ import { useDraggableBar } from "./use-draggable-bar.js";
7
+ import { useBarShape } from "./bar-shape.js";
8
+
9
+ /**
10
+ * The bar.
11
+ *
12
+ * Collapsed it is a circle; expanded it is three clusters joined by pinched
13
+ * bridges into a single silhouette — the component on its own, then the three
14
+ * ways of looking at it, then collapse.
15
+ *
16
+ * The grouping is not decoration. Buttons inside one cluster are read as one
17
+ * subject, so what shares a pill has to belong together, and the split here is
18
+ * between *what* you are looking at and *how*. The name leads alone because it
19
+ * is the only control that changes the subject: everything else on the bar is
20
+ * downstream of whatever it names. Width, sweep and theme then group, because
21
+ * each is a way of inspecting that one subject — how wide, how it holds up
22
+ * across widths, under which lighting — and none of them changes what it is.
23
+ *
24
+ * Collapse gets the tail on its own because it is not about the canvas at all;
25
+ * it dismisses the bar. That also puts it at the edge the shut circle pins
26
+ * itself to, so the bar closes toward the button that closed it — see the
27
+ * anchor note in use-draggable-bar.js.
28
+ *
29
+ * The silhouette itself is drawn by `useBarShape` as a single path behind the
30
+ * controls; see bar-shape.js for why it has to be one path rather than three
31
+ * elements. Everything structural about this component — the `.nora-pill`
32
+ * element, its ref, its pointer handler, its `data-state` — is unchanged by
33
+ * that, which is what lets dragging, panel anchoring and the sweep stay out of
34
+ * it entirely.
35
+ */
36
+ export function Toolbar({
37
+ expanded,
38
+ setExpanded,
39
+ panel,
40
+ setPanel,
41
+ entries,
42
+ currentDir,
43
+ selectedId,
44
+ onSelect,
45
+ theme,
46
+ onToggleTheme,
47
+ viewport,
48
+ scale,
49
+ onPickViewport,
50
+ onSweep,
51
+ onToggleSweep,
52
+ sweeping,
53
+ sweepResult,
54
+ onPickWidth,
55
+ }) {
56
+ const ref = useRef(null);
57
+ const pillRef = useRef(null);
58
+ const controlsRef = useRef(null);
59
+ const selected = entries.find((e) => e.id === selectedId);
60
+ const { pos, style, dragging, onPointerDown } = useDraggableBar(pillRef, expanded);
61
+
62
+ const label = selected ? selected.name : currentDir ? "Choose a Component" : "No Folder";
63
+ const scaled = viewport.width && scale < 0.999 ? Math.round(scale * 100) : null;
64
+
65
+ // Anything that can move a cluster edge and so change the outline.
66
+ const { svgRef, pathRef } = useBarShape(
67
+ pillRef,
68
+ controlsRef,
69
+ expanded,
70
+ `${label}|${viewport.label}|${scaled}`,
71
+ );
72
+
73
+ // Close an open panel when a click lands outside the bar entirely.
74
+ useEffect(() => {
75
+ if (!panel) return;
76
+ const onDown = (e) => {
77
+ if (ref.current && !ref.current.contains(e.target)) setPanel(null);
78
+ };
79
+ document.addEventListener("mousedown", onDown);
80
+ return () => document.removeEventListener("mousedown", onDown);
81
+ }, [panel, setPanel]);
82
+
83
+ const toggle = (name) => setPanel(panel === name ? null : name);
84
+
85
+ return (
86
+ <div
87
+ className="nora-bar-area"
88
+ ref={ref}
89
+ style={style}
90
+ data-hside={pos.hside}
91
+ data-vside={pos.vside}
92
+ data-dragging={dragging ? "true" : "false"}
93
+ >
94
+ {panel === "picker" ? (
95
+ <Picker
96
+ entries={entries}
97
+ currentDir={currentDir}
98
+ selectedId={selectedId}
99
+ onPick={onSelect}
100
+ onClose={() => setPanel(null)}
101
+ />
102
+ ) : null}
103
+
104
+ {panel === "sweep" && sweepResult ? (
105
+ <SweepPanel
106
+ result={sweepResult}
107
+ subject={selected}
108
+ currentWidth={viewport.width}
109
+ onPickWidth={onPickWidth}
110
+ onRerun={onSweep}
111
+ onClose={() => setPanel(null)}
112
+ sweeping={sweeping}
113
+ />
114
+ ) : null}
115
+
116
+ {panel === "viewport" ? (
117
+ <ViewportMenu
118
+ current={viewport.id}
119
+ onPick={onPickViewport}
120
+ onClose={() => setPanel(null)}
121
+ />
122
+ ) : null}
123
+
124
+ <div
125
+ className="nora-pill"
126
+ ref={pillRef}
127
+ onPointerDown={onPointerDown}
128
+ data-state={expanded ? "expanded" : "collapsed"}
129
+ role={expanded ? "toolbar" : "button"}
130
+ tabIndex={expanded ? -1 : 0}
131
+ aria-label="nora Toolbar"
132
+ aria-expanded={expanded}
133
+ onClick={(e) => {
134
+ if (expanded) return;
135
+ e.stopPropagation();
136
+ setExpanded(true);
137
+ }}
138
+ onKeyDown={(e) => {
139
+ if (!expanded && (e.key === "Enter" || e.key === " ")) {
140
+ e.preventDefault();
141
+ setExpanded(true);
142
+ }
143
+ }}
144
+ >
145
+ {/* The silhouette. Painted, never composed — see bar-shape.js. */}
146
+ <svg className="nora-shape" ref={svgRef} aria-hidden="true" focusable="false">
147
+ <path ref={pathRef} />
148
+ </svg>
149
+
150
+ <span className={"nora-pill-face" + (expanded ? " is-out" : "")}>
151
+ <GridIcon size={18} strokeWidth={2} />
152
+ </span>
153
+
154
+ <span
155
+ className={"nora-pill-controls" + (expanded ? " is-in" : " is-out")}
156
+ ref={controlsRef}
157
+ >
158
+ <span className="nora-cluster nora-cluster-subject">
159
+ <button
160
+ className={"nora-name-btn" + (panel === "picker" ? " is-active" : "")}
161
+ onClick={() => toggle("picker")}
162
+ title={selected ? selected.file : "Find a Component (⌘K)"}
163
+ tabIndex={expanded ? 0 : -1}
164
+ >
165
+ <SearchIcon size={14} strokeWidth={1.9} className="nora-name-search" />
166
+ <span className="nora-name-label">{label}</span>
167
+ </button>
168
+ </span>
169
+
170
+ <span className="nora-bridge" aria-hidden="true" />
171
+
172
+ <span className="nora-cluster">
173
+ <button
174
+ className={"nora-vp-btn" + (panel === "viewport" ? " is-active" : "")}
175
+ onClick={() => toggle("viewport")}
176
+ disabled={!selectedId}
177
+ aria-label="Viewport Width"
178
+ title="Viewport Width"
179
+ tabIndex={expanded ? 0 : -1}
180
+ >
181
+ {viewport.label}
182
+ {scaled ? <span className="nora-vp-scale">{scaled}%</span> : null}
183
+ </button>
184
+
185
+ <button
186
+ className={
187
+ "nora-icon-btn nora-sweep-btn" +
188
+ (sweeping ? " is-running" : "") +
189
+ (panel === "sweep" ? " is-active" : "")
190
+ }
191
+ onClick={onToggleSweep}
192
+ disabled={!selectedId || sweeping}
193
+ aria-label="Sweep Widths for Layout Problems"
194
+ title={sweepResult ? "Sweep Results" : "Sweep 320–1600 for Overflow and Breakpoints"}
195
+ tabIndex={expanded ? 0 : -1}
196
+ >
197
+ <SweepIcon />
198
+ </button>
199
+
200
+ <button
201
+ className="nora-icon-btn"
202
+ onClick={onToggleTheme}
203
+ aria-label="Toggle Canvas Theme"
204
+ title="Toggle Canvas Theme"
205
+ tabIndex={expanded ? 0 : -1}
206
+ >
207
+ {theme === "dark" ? <MoonIcon /> : <SunIcon />}
208
+ </button>
209
+ </span>
210
+
211
+ <span className="nora-bridge" aria-hidden="true" />
212
+
213
+ <span className="nora-cluster">
214
+ <button
215
+ className="nora-icon-btn"
216
+ onClick={() => {
217
+ setPanel(null);
218
+ setExpanded(false);
219
+ }}
220
+ aria-label="Collapse Toolbar"
221
+ title="Collapse"
222
+ tabIndex={expanded ? 0 : -1}
223
+ >
224
+ <CollapseIcon size={16} />
225
+ </button>
226
+ </span>
227
+ </span>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,83 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { VIEWPORTS } from "../viewports.js";
3
+
4
+ /** Everything the menu acts on while it is open. */
5
+ const CLAIMED = ["ArrowDown", "ArrowUp", "Enter", "Escape"];
6
+
7
+ /**
8
+ * Pick a viewport width.
9
+ *
10
+ * Driveable from the keyboard on the same terms as the picker: the arrows move
11
+ * a cursor, Enter takes what it is on, Escape leaves. It has no text field to
12
+ * hold focus, so the panel itself takes it — the cursor highlight is the
13
+ * affordance, which is why the panel's own focus ring is suppressed.
14
+ *
15
+ * Claimed keys stop here rather than bubbling, so the shell's window listener
16
+ * never has to ask whether a panel is open before acting on an arrow.
17
+ *
18
+ * @param {object} props
19
+ * @param {string} props.current id of the viewport in use
20
+ * @param {(id: string) => void} props.onPick
21
+ * @param {() => void} props.onClose
22
+ */
23
+ export function ViewportMenu({ current, onPick, onClose }) {
24
+ const panelRef = useRef(null);
25
+ const [cursor, setCursor] = useState(() => {
26
+ const at = VIEWPORTS.findIndex((v) => v.id === current);
27
+ return at < 0 ? 0 : at;
28
+ });
29
+
30
+ // Opens on the width you are already using, so Enter is a no-op rather than
31
+ // a surprise.
32
+ useEffect(() => {
33
+ panelRef.current?.focus();
34
+ }, []);
35
+
36
+ const pick = (v) => {
37
+ onPick(v.id);
38
+ onClose();
39
+ };
40
+
41
+ const onKeyDown = (e) => {
42
+ if (!CLAIMED.includes(e.key)) return; // ⌘K and friends pass through
43
+ e.preventDefault();
44
+ e.stopPropagation();
45
+
46
+ if (e.key === "ArrowDown") setCursor((c) => Math.min(c + 1, VIEWPORTS.length - 1));
47
+ else if (e.key === "ArrowUp") setCursor((c) => Math.max(c - 1, 0));
48
+ else if (e.key === "Enter") pick(VIEWPORTS[cursor]);
49
+ else if (e.key === "Escape") onClose();
50
+ };
51
+
52
+ return (
53
+ <div
54
+ className="nora-panel nora-viewport-menu"
55
+ role="dialog"
56
+ aria-label="Viewport Width"
57
+ ref={panelRef}
58
+ tabIndex={-1}
59
+ onKeyDown={onKeyDown}
60
+ >
61
+ <div className="nora-panel-head">
62
+ <span className="nora-panel-title">Viewport</span>
63
+ </div>
64
+
65
+ <div className="nora-panel-body">
66
+ {VIEWPORTS.map((v, i) => (
67
+ <button
68
+ key={v.id}
69
+ data-active={i === cursor}
70
+ className={
71
+ "nora-row" + (v.id === current ? " is-current" : "") + (i === cursor ? " is-at" : "")
72
+ }
73
+ onMouseEnter={() => setCursor(i)}
74
+ onClick={() => pick(v)}
75
+ >
76
+ <span className="nora-row-name">{v.label}</span>
77
+ <span className="nora-row-count">{v.note}</span>
78
+ </button>
79
+ ))}
80
+ </div>
81
+ </div>
82
+ );
83
+ }