sh-ui-cli 0.115.0 → 0.117.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 (37) hide show
  1. package/bin/sh-ui.mjs +70 -3
  2. package/data/changelog/versions.json +24 -0
  3. package/data/registry/flutter/registry.json +9 -0
  4. package/data/registry/flutter/widgets/sh_ui_tree.dart +428 -0
  5. package/data/registry/react/components/rich-text-editor/index.module.tsx +98 -8
  6. package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +87 -4
  7. package/data/registry/react/components/rich-text-editor/index.tsx +98 -8
  8. package/data/registry/react/components/rich-text-editor/rich-text-editor.test.tsx +283 -0
  9. package/data/registry/react/components/tree/flatten.test.ts +72 -0
  10. package/data/registry/react/components/tree/flatten.ts +69 -0
  11. package/data/registry/react/components/tree/index.module.tsx +215 -0
  12. package/data/registry/react/components/tree/index.tailwind.tsx +224 -0
  13. package/data/registry/react/components/tree/index.tsx +215 -0
  14. package/data/registry/react/components/tree/styles.css +69 -0
  15. package/data/registry/react/components/tree/styles.module.css +69 -0
  16. package/data/registry/react/components/tree/tree.test.tsx +110 -0
  17. package/data/registry/react/components/tree/types.ts +36 -0
  18. package/data/registry/react/peer-versions.json +9 -1
  19. package/data/registry/react/registry.json +45 -0
  20. package/data/registry/react/tokens-used.json +40 -1
  21. package/data/summaries/react.json +2 -1
  22. package/package.json +3 -3
  23. package/src/add.mjs +31 -1
  24. package/src/commands.mjs +6 -0
  25. package/src/create/generator.js +4 -0
  26. package/src/doctor.mjs +14 -0
  27. package/src/init.mjs +19 -0
  28. package/src/levenshtein.mjs +36 -0
  29. package/src/list.mjs +13 -0
  30. package/src/mcp.mjs +14 -0
  31. package/src/migrate-bundled.mjs +14 -0
  32. package/src/migrate-v065.mjs +14 -0
  33. package/src/remove.mjs +15 -0
  34. package/src/rename-app.mjs +15 -0
  35. package/src/theme-extract.mjs +13 -0
  36. package/src/tokens-cmd.mjs +12 -0
  37. package/src/upgrade-cli.mjs +13 -0
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import * as React from "react";
5
+ import { Extension } from "@tiptap/core";
6
+ import { RichTextEditor } from "./index";
7
+
8
+ void React;
9
+
10
+ describe('RichTextEditor format="markdown"', () => {
11
+ it("markdown defaultValue 를 받아 굵게 마크로 렌더한다", async () => {
12
+ render(<RichTextEditor format="markdown" defaultValue={"**굵게**"} />);
13
+ // 에디터가 굵게 마크로 렌더(strong 존재)
14
+ await screen.findByText("굵게");
15
+ expect(screen.getByText("굵게").closest("strong")).not.toBeNull();
16
+ });
17
+
18
+ it("편집 시 onChange 가 markdown 문자열을 돌려준다", async () => {
19
+ const onChange = vi.fn();
20
+ let editor: import("@tiptap/react").Editor | undefined;
21
+ render(
22
+ <RichTextEditor
23
+ format="markdown"
24
+ defaultValue={"*기울임*"}
25
+ onChange={onChange}
26
+ onCreate={(e) => {
27
+ editor = e;
28
+ }}
29
+ />,
30
+ );
31
+ await screen.findByText("기울임");
32
+ await vi.waitFor(() => expect(editor).toBeDefined());
33
+ // 본문 끝에 굵은 텍스트를 추가하는 편집을 트랜잭션으로 일으켜 onUpdate 를 트리거.
34
+ editor!.chain().focus("end").insertContent(" **굵게**").run();
35
+ await vi.waitFor(() => expect(onChange).toHaveBeenCalled());
36
+ const out = onChange.mock.calls.at(-1)![0] as string;
37
+ // markdown(HTML 아님) 으로 직렬화됐는지 검증: 굵게 마크 + 태그 부재.
38
+ expect(out).toContain("**굵게**");
39
+ expect(out).not.toContain("<strong>");
40
+ // _italic_ 은 CommonMark 정규화로 *italic* 형태(별표 기반)로 나온다.
41
+ expect(out).toContain("*기울임*");
42
+ });
43
+
44
+ it("html 모드(기본)에서는 markdown storage 가 없다(직렬화 미등록)", async () => {
45
+ let editor: import("@tiptap/react").Editor | undefined;
46
+ render(
47
+ <RichTextEditor
48
+ defaultValue={"<p><em>기울임</em></p>"}
49
+ onCreate={(e) => {
50
+ editor = e;
51
+ }}
52
+ />,
53
+ );
54
+ await screen.findByText("기울임");
55
+ await vi.waitFor(() => expect(editor).toBeDefined());
56
+ const storage = editor!.storage as { markdown?: unknown };
57
+ expect(storage.markdown).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe("RichTextEditor submitOnEnter", () => {
62
+ it("Enter 는 onSubmit, Shift+Enter 는 줄바꿈 (ProseMirror handleKeyDown 경유)", async () => {
63
+ const user = userEvent.setup();
64
+ const onSubmit = vi.fn();
65
+ let editor: import("@tiptap/react").Editor | undefined;
66
+ render(
67
+ <RichTextEditor
68
+ format="markdown"
69
+ submitOnEnter
70
+ onSubmit={onSubmit}
71
+ onCreate={(e) => {
72
+ editor = e;
73
+ }}
74
+ aria-label="c"
75
+ />,
76
+ );
77
+ const el = await screen.findByLabelText("c");
78
+ await vi.waitFor(() => expect(editor).toBeDefined());
79
+ // user-event 로 실제 키 이벤트를 발생시켜 ProseMirror 의 handleKeyDown 플러그인
80
+ // 체인을 진짜로 통과시킨다(raw dispatchEvent 는 PM view 를 거치지 않을 수 있음).
81
+ editor!.commands.focus();
82
+ el.focus();
83
+ await user.keyboard("{Enter}");
84
+ expect(onSubmit).toHaveBeenCalledTimes(1);
85
+
86
+ await user.keyboard("{Shift>}{Enter}{/Shift}");
87
+ expect(onSubmit).toHaveBeenCalledTimes(1); // 늘지 않음(줄바꿈)
88
+ });
89
+
90
+ it("IME 조합 중(isComposing) Enter 는 제출하지 않고, 일반 Enter 는 제출한다", async () => {
91
+ const onSubmit = vi.fn();
92
+ let editor: import("@tiptap/react").Editor | undefined;
93
+ render(
94
+ <RichTextEditor
95
+ format="markdown"
96
+ submitOnEnter
97
+ onSubmit={onSubmit}
98
+ onCreate={(e) => {
99
+ editor = e;
100
+ }}
101
+ aria-label="ime"
102
+ />,
103
+ );
104
+ const el = await screen.findByLabelText("ime");
105
+ await vi.waitFor(() => expect(editor).toBeDefined());
106
+ editor!.commands.focus();
107
+ el.focus();
108
+
109
+ // 한글 조합 확정 Enter 는 isComposing: true 로 들어온다 — 제출되면 안 됨.
110
+ el.dispatchEvent(
111
+ new KeyboardEvent("keydown", {
112
+ key: "Enter",
113
+ bubbles: true,
114
+ cancelable: true,
115
+ // @ts-expect-error jsdom KeyboardEvent 는 isComposing 을 옵션으로 받음
116
+ isComposing: true,
117
+ }),
118
+ );
119
+ expect(onSubmit).not.toHaveBeenCalled();
120
+
121
+ // 레거시 브라우저가 보내는 keyCode 229(조합 중) Enter 도 제출 금지.
122
+ el.dispatchEvent(
123
+ new KeyboardEvent("keydown", {
124
+ key: "Enter",
125
+ bubbles: true,
126
+ cancelable: true,
127
+ keyCode: 229,
128
+ }),
129
+ );
130
+ expect(onSubmit).not.toHaveBeenCalled();
131
+
132
+ // 조합이 끝난 일반 Enter 는 제출된다.
133
+ el.dispatchEvent(
134
+ new KeyboardEvent("keydown", {
135
+ key: "Enter",
136
+ bubbles: true,
137
+ cancelable: true,
138
+ }),
139
+ );
140
+ expect(onSubmit).toHaveBeenCalledTimes(1);
141
+ });
142
+ });
143
+
144
+ describe("RichTextEditor controlled-sync (C-2 루프/커서 점프 방지)", () => {
145
+ it("동일 value 로 부모가 리렌더해도 setContent·셀렉션을 건드리지 않는다(커서 점프 방지)", async () => {
146
+ let editor: import("@tiptap/react").Editor | undefined;
147
+ let forceRerender: (() => void) | undefined;
148
+
149
+ // controlled value 가 그대로인데 부모가 리렌더하는 흔한 상황. lastSyncedRef 비교 덕에
150
+ // sync effect 가 우리 doc 을 다시 setContent 하지 않아 커서/셀렉션이 보존된다.
151
+ // (직렬화 비교만 했다면 markdown 정규화 차이로 매 렌더 setContent → 점프 위험.)
152
+ function Harness() {
153
+ const [, tick] = React.useState(0);
154
+ forceRerender = () => tick((n) => n + 1);
155
+ return (
156
+ <RichTextEditor
157
+ format="markdown"
158
+ value={"**hi**"}
159
+ onChange={() => {}}
160
+ onCreate={(e) => {
161
+ editor = e;
162
+ }}
163
+ aria-label="sync"
164
+ />
165
+ );
166
+ }
167
+
168
+ render(<Harness />);
169
+ await vi.waitFor(() => expect(editor).toBeDefined());
170
+ await screen.findByText("hi");
171
+
172
+ // 커서를 문서 끝으로 두고 위치 기록.
173
+ editor!.commands.focus("end");
174
+ const before = editor!.state.selection.from;
175
+ expect(before).toBeGreaterThan(1);
176
+
177
+ // 같은 value 로 부모 리렌더 — sync effect 가 echo 를 다시 주입하면 안 된다.
178
+ const setContentSpy = vi.spyOn(editor!.commands, "setContent");
179
+ await React.act(async () => {
180
+ forceRerender!();
181
+ // effect flush 까지 대기(setContent 가 일어난다면 여기서 일어난다).
182
+ await Promise.resolve();
183
+ });
184
+
185
+ // setContent 미호출 + 셀렉션 그대로(점프 없음).
186
+ expect(setContentSpy).not.toHaveBeenCalled();
187
+ expect(editor!.state.selection.from).toBe(before);
188
+ setContentSpy.mockRestore();
189
+ });
190
+
191
+ it("진짜 다른 외부 value 는 에디터에 로드된다 (채널 전환)", async () => {
192
+ function Harness({ value }: { value: string }) {
193
+ return <RichTextEditor format="markdown" value={value} aria-label="ext" />;
194
+ }
195
+ const { rerender } = render(<Harness value={"**first**"} />);
196
+ await screen.findByText("first");
197
+
198
+ // 외부에서 완전히 다른 value 주입 → 에디터에 반영돼야 한다.
199
+ rerender(<Harness value={"**second**"} />);
200
+ await screen.findByText("second");
201
+ expect(screen.queryByText("first")).toBeNull();
202
+ });
203
+
204
+ it("html 모드에서도 controlled echo 루프가 발생하지 않는다", async () => {
205
+ let editor: import("@tiptap/react").Editor | undefined;
206
+ let lastEmitted = "";
207
+ function Harness() {
208
+ const [value, setValue] = React.useState("<p>hi</p>");
209
+ return (
210
+ <RichTextEditor
211
+ value={value}
212
+ onChange={(v) => {
213
+ lastEmitted = v;
214
+ setValue(v);
215
+ }}
216
+ onCreate={(e) => {
217
+ editor = e;
218
+ }}
219
+ aria-label="html-sync"
220
+ />
221
+ );
222
+ }
223
+ render(<Harness />);
224
+ await vi.waitFor(() => expect(editor).toBeDefined());
225
+ await screen.findByText("hi");
226
+
227
+ editor!.chain().focus("end").insertContent(" x").run();
228
+ await vi.waitFor(() => expect(lastEmitted).toContain("x"));
229
+
230
+ const setContentSpy = vi.spyOn(editor!.commands, "setContent");
231
+ editor!.chain().focus("end").insertContent("y").run();
232
+ await vi.waitFor(() => expect(lastEmitted).toContain("y"));
233
+ expect(setContentSpy).not.toHaveBeenCalled();
234
+ setContentSpy.mockRestore();
235
+ });
236
+ });
237
+
238
+ describe("RichTextEditor extensions/onCreate", () => {
239
+ it("추가 확장이 등록되고 onCreate 로 editor 에 접근한다", async () => {
240
+ const onCreate = vi.fn();
241
+ const marker = Extension.create({ name: "spikeMarker" });
242
+ render(
243
+ <RichTextEditor
244
+ format="markdown"
245
+ extensions={[marker]}
246
+ onCreate={onCreate}
247
+ />,
248
+ );
249
+ await vi.waitFor(() => expect(onCreate).toHaveBeenCalled());
250
+ const editor = onCreate.mock.calls[0][0];
251
+ expect(
252
+ editor.extensionManager.extensions.some(
253
+ (e: { name: string }) => e.name === "spikeMarker",
254
+ ),
255
+ ).toBe(true);
256
+ });
257
+ });
258
+
259
+ describe("RichTextEditor 하위호환 (default html)", () => {
260
+ it("기본 format(html)에서 편집 시 onChange 가 HTML 을 돌려준다", async () => {
261
+ const onChange = vi.fn();
262
+ let editor: import("@tiptap/react").Editor | undefined;
263
+ render(
264
+ <RichTextEditor
265
+ defaultValue={"<p><strong>굵게</strong></p>"}
266
+ onChange={onChange}
267
+ onCreate={(e) => {
268
+ editor = e;
269
+ }}
270
+ />,
271
+ );
272
+ await screen.findByText("굵게");
273
+ expect(screen.getByText("굵게").closest("strong")).not.toBeNull();
274
+ await vi.waitFor(() => expect(editor).toBeDefined());
275
+ editor!.chain().focus("end").insertContent(" 추가").run();
276
+ await vi.waitFor(() => expect(onChange).toHaveBeenCalled());
277
+ const out = onChange.mock.calls.at(-1)![0] as string;
278
+ // html 모드(기본)이므로 태그 문자열이 그대로 나와야 한다(markdown 별표 아님).
279
+ expect(out).toContain("<strong>");
280
+ expect(out).toContain("<p>");
281
+ expect(out).not.toContain("**");
282
+ });
283
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { flattenVisible, nextFocusable, prevFocusable, findByTypeahead } from "./flatten";
3
+ import type { TreeNode } from "./types";
4
+
5
+ const tree: TreeNode[] = [
6
+ { id: "a", label: "Apple", children: [
7
+ { id: "a1", label: "Ant" },
8
+ { id: "a2", label: "Arc", disabled: true },
9
+ ] },
10
+ { id: "b", label: "Banana" },
11
+ ];
12
+
13
+ describe("flattenVisible", () => {
14
+ it("닫힌 트리는 최상위만, hasChildren 표기", () => {
15
+ const v = flattenVisible(tree, new Set());
16
+ expect(v.map((n) => n.id)).toEqual(["a", "b"]);
17
+ expect(v[0].hasChildren).toBe(true);
18
+ expect(v[0].expanded).toBe(false);
19
+ expect(v[1].hasChildren).toBe(false);
20
+ });
21
+
22
+ it("부모가 펼쳐지면 자식이 가시 + level/aria 메타", () => {
23
+ const v = flattenVisible(tree, new Set(["a"]));
24
+ expect(v.map((n) => n.id)).toEqual(["a", "a1", "a2", "b"]);
25
+ const a1 = v[1];
26
+ expect(a1.level).toBe(1);
27
+ expect(a1.parentId).toBe("a");
28
+ expect(a1.posInSet).toBe(1);
29
+ expect(a1.setSize).toBe(2);
30
+ });
31
+
32
+ it("부모가 닫혀 있으면 자식은 안 보임", () => {
33
+ const v = flattenVisible(tree, new Set(["a2-only-irrelevant"]));
34
+ expect(v.map((n) => n.id)).toEqual(["a", "b"]);
35
+ });
36
+ });
37
+
38
+ describe("nextFocusable / prevFocusable", () => {
39
+ const v = flattenVisible(tree, new Set(["a"]));
40
+ it("다음 포커스는 disabled 를 건너뛴다", () => {
41
+ expect(nextFocusable(v, "a1")?.id).toBe("b");
42
+ });
43
+ it("이전 포커스도 disabled 를 건너뛴다", () => {
44
+ expect(prevFocusable(v, "b")?.id).toBe("a1");
45
+ });
46
+ it("끝에서 다음은 null", () => {
47
+ expect(nextFocusable(v, "b")).toBeNull();
48
+ });
49
+ it("처음에서 이전은 null", () => {
50
+ expect(prevFocusable(v, "a")).toBeNull();
51
+ });
52
+ it("fromId 가 없으면 next/prev 모두 null", () => {
53
+ expect(nextFocusable(v, "nonexistent")).toBeNull();
54
+ expect(prevFocusable(v, "nonexistent")).toBeNull();
55
+ });
56
+ });
57
+
58
+ describe("findByTypeahead", () => {
59
+ const v = flattenVisible(tree, new Set(["a"]));
60
+ it("접두사로 다음 가시 노드를 찾는다 (대소문자 무시)", () => {
61
+ expect(findByTypeahead(v, "ban", "a")?.id).toBe("b");
62
+ });
63
+ it("매치 없으면 null", () => {
64
+ expect(findByTypeahead(v, "zz", "a")).toBeNull();
65
+ });
66
+ it("disabled 는 typeahead 대상에서 제외", () => {
67
+ expect(findByTypeahead(v, "arc", "a")).toBeNull();
68
+ });
69
+ it("빈 prefix 는 null", () => {
70
+ expect(findByTypeahead(v, "", "a")).toBeNull();
71
+ });
72
+ });
@@ -0,0 +1,69 @@
1
+ import type { TreeNode, VisibleNode } from "./types";
2
+
3
+ /**
4
+ * 노드 label 을 typeahead 비교용 문자열로 변환한다.
5
+ * label 이 string 이면 그 값을, JSX 등 비문자열 ReactNode 면 "" 를 반환한다
6
+ * (비문자열 라벨 노드는 typeahead 검색 대상에서 제외됨).
7
+ */
8
+ function labelText(node: TreeNode): string {
9
+ return typeof node.label === "string" ? node.label : "";
10
+ }
11
+
12
+ export function flattenVisible(nodes: TreeNode[], expanded: Set<string>): VisibleNode[] {
13
+ const out: VisibleNode[] = [];
14
+ const walk = (siblings: TreeNode[], level: number, parentId: string | null) => {
15
+ siblings.forEach((node, i) => {
16
+ const hasChildren = !!node.children && node.children.length > 0;
17
+ const isExpanded = hasChildren && expanded.has(node.id);
18
+ out.push({
19
+ id: node.id,
20
+ node,
21
+ level,
22
+ parentId,
23
+ hasChildren,
24
+ expanded: isExpanded,
25
+ disabled: !!node.disabled,
26
+ setSize: siblings.length,
27
+ posInSet: i + 1,
28
+ });
29
+ if (isExpanded) walk(node.children!, level + 1, node.id);
30
+ });
31
+ };
32
+ walk(nodes, 0, null);
33
+ return out;
34
+ }
35
+
36
+ export function nextFocusable(visible: VisibleNode[], fromId: string): VisibleNode | null {
37
+ const i = visible.findIndex((n) => n.id === fromId);
38
+ if (i === -1) return null;
39
+ for (let j = i + 1; j < visible.length; j++) {
40
+ if (!visible[j].disabled) return visible[j];
41
+ }
42
+ return null;
43
+ }
44
+
45
+ export function prevFocusable(visible: VisibleNode[], fromId: string): VisibleNode | null {
46
+ const i = visible.findIndex((n) => n.id === fromId);
47
+ if (i === -1) return null;
48
+ for (let j = i - 1; j >= 0; j--) {
49
+ if (!visible[j].disabled) return visible[j];
50
+ }
51
+ return null;
52
+ }
53
+
54
+ export function findByTypeahead(
55
+ visible: VisibleNode[],
56
+ prefix: string,
57
+ fromId: string,
58
+ ): VisibleNode | null {
59
+ const p = prefix.toLowerCase();
60
+ if (!p) return null;
61
+ const start = visible.findIndex((n) => n.id === fromId);
62
+ const n = visible.length;
63
+ for (let k = 1; k <= n; k++) {
64
+ const cand = visible[(start + k) % n];
65
+ if (cand.disabled) continue;
66
+ if (labelText(cand.node).toLowerCase().startsWith(p)) return cand;
67
+ }
68
+ return null;
69
+ }
@@ -0,0 +1,215 @@
1
+ import * as React from "react";
2
+ import styles from "./styles.module.css";
3
+ import { cn } from "@SH_UI_UTILS@";
4
+ import type { TreeNode, TreeProps } from "./types";
5
+ export type { TreeNode, TreeProps, TreeSize } from "./types";
6
+ import { flattenVisible, nextFocusable, prevFocusable, findByTypeahead } from "./flatten";
7
+
8
+ function useControllableSet(
9
+ controlled: string[] | undefined,
10
+ defaultValue: string[] | undefined,
11
+ onChange?: (ids: string[]) => void,
12
+ ) {
13
+ const [internal, setInternal] = React.useState<Set<string>>(
14
+ () => new Set(controlled ?? defaultValue ?? []),
15
+ );
16
+ const set = controlled ? new Set(controlled) : internal;
17
+ const update = (next: Set<string>) => {
18
+ if (!controlled) setInternal(next);
19
+ onChange?.([...next]);
20
+ };
21
+ return [set, update] as const;
22
+ }
23
+
24
+ export const Tree = React.forwardRef<HTMLDivElement, TreeProps>(function Tree(
25
+ {
26
+ nodes,
27
+ expandedIds,
28
+ defaultExpandedIds,
29
+ onExpandedChange,
30
+ selectedId,
31
+ defaultSelectedId,
32
+ onSelect,
33
+ renderLabel,
34
+ size = "md",
35
+ className,
36
+ },
37
+ ref,
38
+ ) {
39
+ const [expanded, setExpanded] = useControllableSet(
40
+ expandedIds,
41
+ defaultExpandedIds,
42
+ onExpandedChange,
43
+ );
44
+
45
+ const isSelectedControlled = selectedId !== undefined;
46
+ const [selInternal, setSelInternal] = React.useState<string | null>(
47
+ defaultSelectedId ?? null,
48
+ );
49
+ const selected = isSelectedControlled ? selectedId! : selInternal;
50
+ const selectNode = (id: string | null) => {
51
+ if (!isSelectedControlled) setSelInternal(id);
52
+ onSelect?.(id);
53
+ };
54
+
55
+ const visible = flattenVisible(nodes, expanded);
56
+ const visibleMap = React.useMemo(() => new Map(visible.map((v) => [v.id, v])), [visible]);
57
+ const [focusId, setFocusId] = React.useState<string | null>(visible[0]?.id ?? null);
58
+
59
+ React.useEffect(() => {
60
+ if (focusId && !visibleMap.has(focusId)) {
61
+ setFocusId(visible[0]?.id ?? null);
62
+ }
63
+ }, [visibleMap, focusId, visible]);
64
+
65
+ const toggle = (id: string) => {
66
+ const next = new Set(expanded);
67
+ if (next.has(id)) next.delete(id);
68
+ else next.add(id);
69
+ setExpanded(next);
70
+ };
71
+
72
+ const onItemClick = (n: TreeNode, hasChildren: boolean) => {
73
+ if (n.disabled) return;
74
+ setFocusId(n.id);
75
+ if (hasChildren) toggle(n.id);
76
+ selectNode(n.id);
77
+ };
78
+
79
+ const itemRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
80
+ const focusNode = (id: string) => {
81
+ setFocusId(id);
82
+ itemRefs.current.get(id)?.focus();
83
+ };
84
+
85
+ const onItemKeyDown = (e: React.KeyboardEvent, n: TreeNode, hasChildren: boolean) => {
86
+ switch (e.key) {
87
+ case "ArrowDown": {
88
+ e.preventDefault();
89
+ const nx = nextFocusable(visible, n.id);
90
+ if (nx) focusNode(nx.id);
91
+ break;
92
+ }
93
+ case "ArrowUp": {
94
+ e.preventDefault();
95
+ const pv = prevFocusable(visible, n.id);
96
+ if (pv) focusNode(pv.id);
97
+ break;
98
+ }
99
+ case "ArrowRight": {
100
+ e.preventDefault();
101
+ if (hasChildren && !expanded.has(n.id)) toggle(n.id);
102
+ else if (hasChildren) {
103
+ const first = n.children!.find((c) => !c.disabled);
104
+ if (first) focusNode(first.id);
105
+ }
106
+ break;
107
+ }
108
+ case "ArrowLeft": {
109
+ e.preventDefault();
110
+ if (hasChildren && expanded.has(n.id)) toggle(n.id);
111
+ else {
112
+ const meta = visibleMap.get(n.id);
113
+ if (meta?.parentId) focusNode(meta.parentId);
114
+ }
115
+ break;
116
+ }
117
+ case "Home": {
118
+ e.preventDefault();
119
+ const first = visible.find((v) => !v.disabled);
120
+ if (first) focusNode(first.id);
121
+ break;
122
+ }
123
+ case "End": {
124
+ e.preventDefault();
125
+ for (let i = visible.length - 1; i >= 0; i--)
126
+ if (!visible[i].disabled) {
127
+ focusNode(visible[i].id);
128
+ break;
129
+ }
130
+ break;
131
+ }
132
+ case "Enter":
133
+ case " ": {
134
+ e.preventDefault();
135
+ if (!n.disabled) selectNode(n.id);
136
+ break;
137
+ }
138
+ default: {
139
+ if (e.key.length === 1 && /\S/.test(e.key)) {
140
+ const hit = findByTypeahead(visible, e.key, n.id);
141
+ if (hit) focusNode(hit.id);
142
+ }
143
+ }
144
+ }
145
+ };
146
+
147
+ const renderNodes = (siblings: TreeNode[], level: number): React.ReactNode => (
148
+ <div role={level === 0 ? undefined : "group"} className={styles.tree__group}>
149
+ {siblings.map((n) => {
150
+ const hasChildren = !!n.children?.length;
151
+ const isExpanded = hasChildren && expanded.has(n.id);
152
+ const meta = visibleMap.get(n.id);
153
+ const depth = meta?.level ?? level;
154
+ return (
155
+ <div key={n.id}>
156
+ <div
157
+ role="treeitem"
158
+ ref={(el) => {
159
+ if (el) itemRefs.current.set(n.id, el);
160
+ else itemRefs.current.delete(n.id);
161
+ }}
162
+ aria-level={depth + 1}
163
+ aria-setsize={meta?.setSize}
164
+ aria-posinset={meta?.posInSet}
165
+ aria-expanded={hasChildren ? isExpanded : undefined}
166
+ aria-selected={selected === n.id}
167
+ aria-disabled={n.disabled || undefined}
168
+ aria-label={typeof n.label === "string" ? n.label : undefined}
169
+ tabIndex={focusId === n.id ? 0 : -1}
170
+ data-disabled={n.disabled || undefined}
171
+ className={cn(
172
+ styles.tree__item,
173
+ selected === n.id && styles["tree__item--selected"],
174
+ )}
175
+ onClick={() => onItemClick(n, hasChildren)}
176
+ onKeyDown={(e) => onItemKeyDown(e, n, hasChildren)}
177
+ >
178
+ <span
179
+ className={styles.tree__indent}
180
+ style={{ width: `calc(var(--space-4) * ${depth})` }}
181
+ aria-hidden
182
+ />
183
+ {hasChildren ? (
184
+ <span
185
+ className={styles.tree__chevron}
186
+ data-expanded={isExpanded || undefined}
187
+ aria-hidden
188
+ >
189
+
190
+ </span>
191
+ ) : (
192
+ <span className={cn(styles.tree__chevron, styles["tree__chevron--leaf"])} aria-hidden />
193
+ )}
194
+ {n.icon ? (
195
+ <span className={styles.tree__icon} aria-hidden>
196
+ {n.icon}
197
+ </span>
198
+ ) : null}
199
+ <span className={styles.tree__label}>
200
+ {renderLabel ? renderLabel(n) : n.label}
201
+ </span>
202
+ </div>
203
+ {isExpanded ? renderNodes(n.children!, level + 1) : null}
204
+ </div>
205
+ );
206
+ })}
207
+ </div>
208
+ );
209
+
210
+ return (
211
+ <div ref={ref} role="tree" data-size={size} className={cn(styles.tree, className)}>
212
+ {renderNodes(nodes, 0)}
213
+ </div>
214
+ );
215
+ });