sh-ui-cli 0.116.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.
@@ -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
+ });
@@ -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
+ });