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,224 @@
1
+ import * as React from "react";
2
+ import { cn } from "@SH_UI_UTILS@";
3
+ import type { TreeNode, TreeProps } from "./types";
4
+ export type { TreeNode, TreeProps, TreeSize } from "./types";
5
+ import { flattenVisible, nextFocusable, prevFocusable, findByTypeahead } from "./flatten";
6
+
7
+ function useControllableSet(
8
+ controlled: string[] | undefined,
9
+ defaultValue: string[] | undefined,
10
+ onChange?: (ids: string[]) => void,
11
+ ) {
12
+ const [internal, setInternal] = React.useState<Set<string>>(
13
+ () => new Set(controlled ?? defaultValue ?? []),
14
+ );
15
+ const set = controlled ? new Set(controlled) : internal;
16
+ const update = (next: Set<string>) => {
17
+ if (!controlled) setInternal(next);
18
+ onChange?.([...next]);
19
+ };
20
+ return [set, update] as const;
21
+ }
22
+
23
+ export const Tree = React.forwardRef<HTMLDivElement, TreeProps>(function Tree(
24
+ {
25
+ nodes,
26
+ expandedIds,
27
+ defaultExpandedIds,
28
+ onExpandedChange,
29
+ selectedId,
30
+ defaultSelectedId,
31
+ onSelect,
32
+ renderLabel,
33
+ size = "md",
34
+ className,
35
+ },
36
+ ref,
37
+ ) {
38
+ const [expanded, setExpanded] = useControllableSet(
39
+ expandedIds,
40
+ defaultExpandedIds,
41
+ onExpandedChange,
42
+ );
43
+
44
+ const isSelectedControlled = selectedId !== undefined;
45
+ const [selInternal, setSelInternal] = React.useState<string | null>(
46
+ defaultSelectedId ?? null,
47
+ );
48
+ const selected = isSelectedControlled ? selectedId! : selInternal;
49
+ const selectNode = (id: string | null) => {
50
+ if (!isSelectedControlled) setSelInternal(id);
51
+ onSelect?.(id);
52
+ };
53
+
54
+ const visible = flattenVisible(nodes, expanded);
55
+ const visibleMap = React.useMemo(() => new Map(visible.map((v) => [v.id, v])), [visible]);
56
+ const [focusId, setFocusId] = React.useState<string | null>(visible[0]?.id ?? null);
57
+
58
+ React.useEffect(() => {
59
+ if (focusId && !visibleMap.has(focusId)) {
60
+ setFocusId(visible[0]?.id ?? null);
61
+ }
62
+ }, [visibleMap, focusId, visible]);
63
+
64
+ const toggle = (id: string) => {
65
+ const next = new Set(expanded);
66
+ if (next.has(id)) next.delete(id);
67
+ else next.add(id);
68
+ setExpanded(next);
69
+ };
70
+
71
+ const onItemClick = (n: TreeNode, hasChildren: boolean) => {
72
+ if (n.disabled) return;
73
+ setFocusId(n.id);
74
+ if (hasChildren) toggle(n.id);
75
+ selectNode(n.id);
76
+ };
77
+
78
+ const itemRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
79
+ const focusNode = (id: string) => {
80
+ setFocusId(id);
81
+ itemRefs.current.get(id)?.focus();
82
+ };
83
+
84
+ const onItemKeyDown = (e: React.KeyboardEvent, n: TreeNode, hasChildren: boolean) => {
85
+ switch (e.key) {
86
+ case "ArrowDown": {
87
+ e.preventDefault();
88
+ const nx = nextFocusable(visible, n.id);
89
+ if (nx) focusNode(nx.id);
90
+ break;
91
+ }
92
+ case "ArrowUp": {
93
+ e.preventDefault();
94
+ const pv = prevFocusable(visible, n.id);
95
+ if (pv) focusNode(pv.id);
96
+ break;
97
+ }
98
+ case "ArrowRight": {
99
+ e.preventDefault();
100
+ if (hasChildren && !expanded.has(n.id)) toggle(n.id);
101
+ else if (hasChildren) {
102
+ const first = n.children!.find((c) => !c.disabled);
103
+ if (first) focusNode(first.id);
104
+ }
105
+ break;
106
+ }
107
+ case "ArrowLeft": {
108
+ e.preventDefault();
109
+ if (hasChildren && expanded.has(n.id)) toggle(n.id);
110
+ else {
111
+ const meta = visibleMap.get(n.id);
112
+ if (meta?.parentId) focusNode(meta.parentId);
113
+ }
114
+ break;
115
+ }
116
+ case "Home": {
117
+ e.preventDefault();
118
+ const first = visible.find((v) => !v.disabled);
119
+ if (first) focusNode(first.id);
120
+ break;
121
+ }
122
+ case "End": {
123
+ e.preventDefault();
124
+ for (let i = visible.length - 1; i >= 0; i--)
125
+ if (!visible[i].disabled) {
126
+ focusNode(visible[i].id);
127
+ break;
128
+ }
129
+ break;
130
+ }
131
+ case "Enter":
132
+ case " ": {
133
+ e.preventDefault();
134
+ if (!n.disabled) selectNode(n.id);
135
+ break;
136
+ }
137
+ default: {
138
+ if (e.key.length === 1 && /\S/.test(e.key)) {
139
+ const hit = findByTypeahead(visible, e.key, n.id);
140
+ if (hit) focusNode(hit.id);
141
+ }
142
+ }
143
+ }
144
+ };
145
+
146
+ const renderNodes = (siblings: TreeNode[], level: number): React.ReactNode => (
147
+ <div role={level === 0 ? undefined : "group"} className="flex flex-col">
148
+ {siblings.map((n) => {
149
+ const hasChildren = !!n.children?.length;
150
+ const isExpanded = hasChildren && expanded.has(n.id);
151
+ const meta = visibleMap.get(n.id);
152
+ const depth = meta?.level ?? level;
153
+ return (
154
+ <div key={n.id}>
155
+ <div
156
+ role="treeitem"
157
+ ref={(el) => {
158
+ if (el) itemRefs.current.set(n.id, el);
159
+ else itemRefs.current.delete(n.id);
160
+ }}
161
+ aria-level={depth + 1}
162
+ aria-setsize={meta?.setSize}
163
+ aria-posinset={meta?.posInSet}
164
+ aria-expanded={hasChildren ? isExpanded : undefined}
165
+ aria-selected={selected === n.id}
166
+ aria-disabled={n.disabled || undefined}
167
+ aria-label={typeof n.label === "string" ? n.label : undefined}
168
+ tabIndex={focusId === n.id ? 0 : -1}
169
+ data-disabled={n.disabled || undefined}
170
+ className={cn(
171
+ "flex items-center gap-[var(--space-2)] px-[var(--space-2)] py-[var(--space-2)] rounded-[calc(var(--radius)-2px)] cursor-pointer select-none transition-[background-color] duration-[var(--duration-fast)] hover:not-data-[disabled]:bg-background-muted focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-ring focus-visible:outline-offset-[-2px] data-[disabled]:text-foreground-muted data-[disabled]:cursor-not-allowed motion-reduce:transition-none",
172
+ selected === n.id && "bg-background-muted font-medium",
173
+ )}
174
+ onClick={() => onItemClick(n, hasChildren)}
175
+ onKeyDown={(e) => onItemKeyDown(e, n, hasChildren)}
176
+ >
177
+ <span
178
+ style={{ width: `calc(var(--space-4) * ${depth})` }}
179
+ aria-hidden
180
+ />
181
+ {hasChildren ? (
182
+ <span
183
+ className="inline-flex w-[var(--space-4)] justify-center transition-transform duration-[var(--duration-fast)] data-[expanded]:rotate-90 motion-reduce:transition-none"
184
+ data-expanded={isExpanded || undefined}
185
+ aria-hidden
186
+ >
187
+
188
+ </span>
189
+ ) : (
190
+ <span
191
+ className="inline-flex w-[var(--space-4)] justify-center transition-transform duration-[var(--duration-fast)] data-[expanded]:rotate-90 motion-reduce:transition-none invisible"
192
+ aria-hidden
193
+ />
194
+ )}
195
+ {n.icon ? (
196
+ <span className="inline-flex" aria-hidden>
197
+ {n.icon}
198
+ </span>
199
+ ) : null}
200
+ <span className="min-w-0 [overflow-wrap:anywhere]">
201
+ {renderLabel ? renderLabel(n) : n.label}
202
+ </span>
203
+ </div>
204
+ {isExpanded ? renderNodes(n.children!, level + 1) : null}
205
+ </div>
206
+ );
207
+ })}
208
+ </div>
209
+ );
210
+
211
+ return (
212
+ <div
213
+ ref={ref}
214
+ role="tree"
215
+ data-size={size}
216
+ className={cn(
217
+ "flex flex-col w-full text-foreground text-[0.9375rem] data-[size=sm]:text-[length:var(--text-xs)]",
218
+ className,
219
+ )}
220
+ >
221
+ {renderNodes(nodes, 0)}
222
+ </div>
223
+ );
224
+ });
@@ -0,0 +1,215 @@
1
+ import * as React from "react";
2
+ import "./styles.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="sh-ui-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
+ "sh-ui-tree__item",
173
+ selected === n.id && "sh-ui-tree__item--selected",
174
+ )}
175
+ onClick={() => onItemClick(n, hasChildren)}
176
+ onKeyDown={(e) => onItemKeyDown(e, n, hasChildren)}
177
+ >
178
+ <span
179
+ className="sh-ui-tree__indent"
180
+ style={{ width: `calc(var(--space-4) * ${depth})` }}
181
+ aria-hidden
182
+ />
183
+ {hasChildren ? (
184
+ <span
185
+ className="sh-ui-tree__chevron"
186
+ data-expanded={isExpanded || undefined}
187
+ aria-hidden
188
+ >
189
+
190
+ </span>
191
+ ) : (
192
+ <span className="sh-ui-tree__chevron sh-ui-tree__chevron--leaf" aria-hidden />
193
+ )}
194
+ {n.icon ? (
195
+ <span className="sh-ui-tree__icon" aria-hidden>
196
+ {n.icon}
197
+ </span>
198
+ ) : null}
199
+ <span className="sh-ui-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("sh-ui-tree", className)}>
212
+ {renderNodes(nodes, 0)}
213
+ </div>
214
+ );
215
+ });
@@ -0,0 +1,69 @@
1
+ .sh-ui-tree {
2
+ display: flex;
3
+ flex-direction: column;
4
+ width: 100%;
5
+ color: var(--foreground);
6
+ font-size: 0.9375rem;
7
+ }
8
+ .sh-ui-tree[data-size="sm"] {
9
+ font-size: var(--text-xs);
10
+ }
11
+
12
+ .sh-ui-tree__group {
13
+ display: flex;
14
+ flex-direction: column;
15
+ }
16
+
17
+ .sh-ui-tree__item {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: var(--space-2);
21
+ padding: var(--space-2) var(--space-2);
22
+ border-radius: calc(var(--radius) - 2px);
23
+ cursor: pointer;
24
+ user-select: none;
25
+ transition: background-color var(--duration-fast) var(--ease-standard);
26
+ }
27
+ .sh-ui-tree__item:not([data-disabled]):hover {
28
+ background: var(--background-muted);
29
+ }
30
+ .sh-ui-tree__item:focus-visible {
31
+ outline: var(--border-width-strong) solid var(--ring);
32
+ outline-offset: -2px;
33
+ }
34
+ .sh-ui-tree__item--selected {
35
+ background: var(--background-muted);
36
+ font-weight: var(--weight-medium);
37
+ }
38
+ .sh-ui-tree__item[data-disabled] {
39
+ color: var(--foreground-muted);
40
+ cursor: not-allowed;
41
+ }
42
+
43
+ .sh-ui-tree__chevron {
44
+ display: inline-flex;
45
+ width: var(--space-4);
46
+ justify-content: center;
47
+ transition: transform var(--duration-fast) var(--ease-standard);
48
+ }
49
+ .sh-ui-tree__chevron[data-expanded] {
50
+ transform: rotate(90deg);
51
+ }
52
+ .sh-ui-tree__chevron--leaf {
53
+ visibility: hidden;
54
+ }
55
+
56
+ .sh-ui-tree__icon {
57
+ display: inline-flex;
58
+ }
59
+ .sh-ui-tree__label {
60
+ min-width: 0;
61
+ overflow-wrap: anywhere;
62
+ }
63
+
64
+ @media (prefers-reduced-motion: reduce) {
65
+ .sh-ui-tree__item,
66
+ .sh-ui-tree__chevron {
67
+ transition: none;
68
+ }
69
+ }
@@ -0,0 +1,69 @@
1
+ .tree {
2
+ display: flex;
3
+ flex-direction: column;
4
+ width: 100%;
5
+ color: var(--foreground);
6
+ font-size: 0.9375rem;
7
+ }
8
+ .tree[data-size="sm"] {
9
+ font-size: var(--text-xs);
10
+ }
11
+
12
+ .tree__group {
13
+ display: flex;
14
+ flex-direction: column;
15
+ }
16
+
17
+ .tree__item {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: var(--space-2);
21
+ padding: var(--space-2) var(--space-2);
22
+ border-radius: calc(var(--radius) - 2px);
23
+ cursor: pointer;
24
+ user-select: none;
25
+ transition: background-color var(--duration-fast) var(--ease-standard);
26
+ }
27
+ .tree__item:not([data-disabled]):hover {
28
+ background: var(--background-muted);
29
+ }
30
+ .tree__item:focus-visible {
31
+ outline: var(--border-width-strong) solid var(--ring);
32
+ outline-offset: -2px;
33
+ }
34
+ .tree__item--selected {
35
+ background: var(--background-muted);
36
+ font-weight: var(--weight-medium);
37
+ }
38
+ .tree__item[data-disabled] {
39
+ color: var(--foreground-muted);
40
+ cursor: not-allowed;
41
+ }
42
+
43
+ .tree__chevron {
44
+ display: inline-flex;
45
+ width: var(--space-4);
46
+ justify-content: center;
47
+ transition: transform var(--duration-fast) var(--ease-standard);
48
+ }
49
+ .tree__chevron[data-expanded] {
50
+ transform: rotate(90deg);
51
+ }
52
+ .tree__chevron--leaf {
53
+ visibility: hidden;
54
+ }
55
+
56
+ .tree__icon {
57
+ display: inline-flex;
58
+ }
59
+ .tree__label {
60
+ min-width: 0;
61
+ overflow-wrap: anywhere;
62
+ }
63
+
64
+ @media (prefers-reduced-motion: reduce) {
65
+ .tree__item,
66
+ .tree__chevron {
67
+ transition: none;
68
+ }
69
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import * as React from "react";
4
+ import { Tree } from "./index";
5
+ import type { TreeNode } from "./types";
6
+
7
+ const nodes: TreeNode[] = [
8
+ { id: "a", label: "Apple", children: [{ id: "a1", label: "Ant" }] },
9
+ { id: "b", label: "Banana" },
10
+ ];
11
+
12
+ describe("Tree 렌더 + 상태", () => {
13
+ it("role=tree 와 최상위 treeitem 을 렌더", () => {
14
+ render(<Tree nodes={nodes} />);
15
+ expect(screen.getByRole("tree")).toBeTruthy();
16
+ const items = screen.getAllByRole("treeitem");
17
+ expect(items.length).toBe(2);
18
+ });
19
+
20
+ it("부모 toggle 클릭으로 비제어 확장 → 자식 노출", () => {
21
+ render(<Tree nodes={nodes} />);
22
+ fireEvent.click(screen.getByText("Apple"));
23
+ expect(screen.getByText("Ant")).toBeTruthy();
24
+ expect(screen.getByRole("treeitem", { name: /Apple/ }).getAttribute("aria-expanded")).toBe("true");
25
+ });
26
+
27
+ it("제어 selectedId 가 aria-selected 로 반영", () => {
28
+ render(<Tree nodes={nodes} selectedId="b" />);
29
+ expect(screen.getByRole("treeitem", { name: /Banana/ }).getAttribute("aria-selected")).toBe("true");
30
+ });
31
+
32
+ it("노드 클릭이 onSelect 를 호출", () => {
33
+ const onSelect = vi.fn();
34
+ render(<Tree nodes={nodes} onSelect={onSelect} />);
35
+ fireEvent.click(screen.getByText("Banana"));
36
+ expect(onSelect).toHaveBeenCalledWith("b");
37
+ });
38
+
39
+ it("aria-level 이 깊이를 반영", () => {
40
+ render(<Tree nodes={nodes} defaultExpandedIds={["a"]} />);
41
+ expect(screen.getByRole("treeitem", { name: /Ant/ }).getAttribute("aria-level")).toBe("2");
42
+ });
43
+
44
+ it("aria-setsize / aria-posinset 가 형제 집합을 반영", () => {
45
+ render(<Tree nodes={nodes} />);
46
+ const apple = screen.getByRole("treeitem", { name: /Apple/ });
47
+ const banana = screen.getByRole("treeitem", { name: /Banana/ });
48
+ expect(apple.getAttribute("aria-setsize")).toBe("2");
49
+ expect(apple.getAttribute("aria-posinset")).toBe("1");
50
+ expect(banana.getAttribute("aria-posinset")).toBe("2");
51
+ });
52
+
53
+ it("제어 expandedIds 는 외부 값이 우선 (클릭 무관)", () => {
54
+ const { rerender } = render(<Tree nodes={nodes} expandedIds={["a"]} />);
55
+ expect(screen.getByText("Ant")).toBeTruthy();
56
+ rerender(<Tree nodes={nodes} expandedIds={[]} />);
57
+ expect(screen.queryByText("Ant")).toBeNull();
58
+ });
59
+
60
+ it("disabled 노드 클릭은 onSelect 를 호출하지 않는다", () => {
61
+ const onSelect = vi.fn();
62
+ const dnodes = [
63
+ { id: "x", label: "Xenon", disabled: true },
64
+ { id: "y", label: "Yttrium" },
65
+ ];
66
+ render(<Tree nodes={dnodes} onSelect={onSelect} />);
67
+ fireEvent.click(screen.getByText("Xenon"));
68
+ expect(onSelect).not.toHaveBeenCalled();
69
+ });
70
+ });
71
+
72
+ describe("Tree 키보드", () => {
73
+ function setup() {
74
+ render(<Tree nodes={nodes} defaultExpandedIds={["a"]} defaultSelectedId="a" />);
75
+ return screen.getByRole("tree");
76
+ }
77
+
78
+ it("ArrowDown 이 다음 가시 노드로 포커스 이동", () => {
79
+ setup();
80
+ const a = screen.getByRole("treeitem", { name: /Apple/ });
81
+ a.focus();
82
+ fireEvent.keyDown(a, { key: "ArrowDown" });
83
+ expect(screen.getByRole("treeitem", { name: /Ant/ }).getAttribute("tabindex")).toBe("0");
84
+ });
85
+
86
+ it("ArrowLeft 가 열린 부모를 축소", () => {
87
+ setup();
88
+ const a = screen.getByRole("treeitem", { name: /Apple/ });
89
+ a.focus();
90
+ fireEvent.keyDown(a, { key: "ArrowLeft" });
91
+ expect(a.getAttribute("aria-expanded")).toBe("false");
92
+ });
93
+
94
+ it("ArrowRight 가 닫힌 부모를 확장", () => {
95
+ render(<Tree nodes={nodes} />);
96
+ const a = screen.getByRole("treeitem", { name: /Apple/ });
97
+ a.focus();
98
+ fireEvent.keyDown(a, { key: "ArrowRight" });
99
+ expect(a.getAttribute("aria-expanded")).toBe("true");
100
+ });
101
+
102
+ it("Enter 가 포커스 노드를 선택", () => {
103
+ const onSelect = vi.fn();
104
+ render(<Tree nodes={nodes} onSelect={onSelect} />);
105
+ const b = screen.getByRole("treeitem", { name: /Banana/ });
106
+ b.focus();
107
+ fireEvent.keyDown(b, { key: "Enter" });
108
+ expect(onSelect).toHaveBeenCalledWith("b");
109
+ });
110
+ });