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.
- package/bin/sh-ui.mjs +70 -3
- package/data/changelog/versions.json +24 -0
- package/data/registry/flutter/registry.json +9 -0
- package/data/registry/flutter/widgets/sh_ui_tree.dart +428 -0
- package/data/registry/react/components/rich-text-editor/index.module.tsx +98 -8
- package/data/registry/react/components/rich-text-editor/index.tailwind.tsx +87 -4
- package/data/registry/react/components/rich-text-editor/index.tsx +98 -8
- package/data/registry/react/components/rich-text-editor/rich-text-editor.test.tsx +283 -0
- package/data/registry/react/components/tree/flatten.test.ts +72 -0
- package/data/registry/react/components/tree/flatten.ts +69 -0
- package/data/registry/react/components/tree/index.module.tsx +215 -0
- package/data/registry/react/components/tree/index.tailwind.tsx +224 -0
- package/data/registry/react/components/tree/index.tsx +215 -0
- package/data/registry/react/components/tree/styles.css +69 -0
- package/data/registry/react/components/tree/styles.module.css +69 -0
- package/data/registry/react/components/tree/tree.test.tsx +110 -0
- package/data/registry/react/components/tree/types.ts +36 -0
- package/data/registry/react/peer-versions.json +9 -1
- package/data/registry/react/registry.json +45 -0
- package/data/registry/react/tokens-used.json +40 -1
- package/data/summaries/react.json +2 -1
- package/package.json +3 -3
- package/src/add.mjs +31 -1
- package/src/commands.mjs +6 -0
- package/src/create/generator.js +4 -0
- package/src/doctor.mjs +14 -0
- package/src/init.mjs +19 -0
- package/src/levenshtein.mjs +36 -0
- package/src/list.mjs +13 -0
- package/src/mcp.mjs +14 -0
- package/src/migrate-bundled.mjs +14 -0
- package/src/migrate-v065.mjs +14 -0
- package/src/remove.mjs +15 -0
- package/src/rename-app.mjs +15 -0
- package/src/theme-extract.mjs +13 -0
- package/src/tokens-cmd.mjs +12 -0
- package/src/upgrade-cli.mjs +13 -0
package/bin/sh-ui.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { init } from "../src/init.mjs";
|
|
2
|
+
import { init, HELP_TEXT as INIT_HELP } from "../src/init.mjs";
|
|
3
3
|
import { add } from "../src/add.mjs";
|
|
4
4
|
import { list } from "../src/list.mjs";
|
|
5
5
|
import { remove } from "../src/remove.mjs";
|
|
6
6
|
import { findShUiContext } from "../src/resolve-context.mjs";
|
|
7
|
+
import { suggest } from "../src/levenshtein.mjs";
|
|
8
|
+
import { KNOWN_COMMANDS } from "../src/commands.mjs";
|
|
7
9
|
|
|
8
10
|
const [, , cmd, ...rest] = process.argv;
|
|
9
11
|
|
|
@@ -36,6 +38,9 @@ const usage = `사용법:
|
|
|
36
38
|
sh-ui mcp MCP 서버(stdio) 시작 — IDE-내 AI용
|
|
37
39
|
sh-ui mcp init --client <name> IDE MCP 설정 파일에 sh-ui 엔트리 자동 추가
|
|
38
40
|
(claude-code | cursor | claude-desktop | codex)
|
|
41
|
+
|
|
42
|
+
각 명령의 상세 옵션은 \`sh-ui <command> --help\` 로 확인.
|
|
43
|
+
|
|
39
44
|
옵션:
|
|
40
45
|
--skip-install (add, rename-app) 외부 패키지 자동 설치 생략
|
|
41
46
|
--diff (add) 파일을 쓰지 않고 변경 내역만 출력
|
|
@@ -57,9 +62,18 @@ try {
|
|
|
57
62
|
break;
|
|
58
63
|
}
|
|
59
64
|
case "init":
|
|
65
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
66
|
+
console.log(INIT_HELP);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
60
69
|
await init({ cwd: process.cwd(), args: rest });
|
|
61
70
|
break;
|
|
62
71
|
case "add": {
|
|
72
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
73
|
+
const { HELP_TEXT } = await import("../src/add.mjs");
|
|
74
|
+
console.log(HELP_TEXT);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
63
77
|
const skipInstall = rest.includes("--skip-install");
|
|
64
78
|
const diffMode = rest.includes("--diff");
|
|
65
79
|
const force = rest.includes("--force");
|
|
@@ -107,11 +121,21 @@ try {
|
|
|
107
121
|
break;
|
|
108
122
|
}
|
|
109
123
|
case "list": {
|
|
124
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
125
|
+
const { HELP_TEXT } = await import("../src/list.mjs");
|
|
126
|
+
console.log(HELP_TEXT);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
110
129
|
const all = rest.includes("--all");
|
|
111
130
|
await list({ cwd: process.cwd(), all });
|
|
112
131
|
break;
|
|
113
132
|
}
|
|
114
133
|
case "doctor": {
|
|
134
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
135
|
+
const { HELP_TEXT } = await import("../src/doctor.mjs");
|
|
136
|
+
console.log(HELP_TEXT);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
115
139
|
const { doctor } = await import("../src/doctor.mjs");
|
|
116
140
|
const { existsSync, readdirSync } = await import("node:fs");
|
|
117
141
|
const { resolve } = await import("node:path");
|
|
@@ -157,12 +181,22 @@ try {
|
|
|
157
181
|
break;
|
|
158
182
|
}
|
|
159
183
|
case "upgrade-cli": {
|
|
184
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
185
|
+
const { HELP_TEXT } = await import("../src/upgrade-cli.mjs");
|
|
186
|
+
console.log(HELP_TEXT);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
160
189
|
const apply = rest.includes("--apply");
|
|
161
190
|
const { runUpgradeCli } = await import("../src/upgrade-cli.mjs");
|
|
162
191
|
await runUpgradeCli({ cwd: process.cwd(), apply });
|
|
163
192
|
break;
|
|
164
193
|
}
|
|
165
194
|
case "theme": {
|
|
195
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
196
|
+
const { HELP_TEXT } = await import("../src/theme-extract.mjs");
|
|
197
|
+
console.log(HELP_TEXT);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
166
200
|
const sub = rest[0];
|
|
167
201
|
const flags = rest.slice(1);
|
|
168
202
|
if (sub === "extract") {
|
|
@@ -177,6 +211,11 @@ try {
|
|
|
177
211
|
break;
|
|
178
212
|
}
|
|
179
213
|
case "tokens": {
|
|
214
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
215
|
+
const { HELP_TEXT } = await import("../src/tokens-cmd.mjs");
|
|
216
|
+
console.log(HELP_TEXT);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
180
219
|
// sh-ui tokens diff
|
|
181
220
|
// sh-ui tokens upgrade --apply | --replace
|
|
182
221
|
const sub = rest[0];
|
|
@@ -210,6 +249,12 @@ try {
|
|
|
210
249
|
case "mcp": {
|
|
211
250
|
// `sh-ui mcp init ...` → 설정 파일에 엔트리 추가
|
|
212
251
|
// `sh-ui mcp` → MCP 서버 시작
|
|
252
|
+
// 단, `sh-ui mcp --help` 는 mcp HELP_TEXT 출력 (mcp init 의 인자 처리는 mcp-init.mjs 가 담당).
|
|
253
|
+
if (rest[0] !== "init" && (rest.includes("--help") || rest.includes("-h"))) {
|
|
254
|
+
const { HELP_TEXT } = await import("../src/mcp.mjs");
|
|
255
|
+
console.log(HELP_TEXT);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
213
258
|
if (rest[0] === "init") {
|
|
214
259
|
const { mcpInit } = await import("../src/mcp-init.mjs");
|
|
215
260
|
await mcpInit({ cwd: process.cwd(), args: rest.slice(1) });
|
|
@@ -220,6 +265,11 @@ try {
|
|
|
220
265
|
break;
|
|
221
266
|
}
|
|
222
267
|
case "rename-app": {
|
|
268
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
269
|
+
const { HELP_TEXT } = await import("../src/rename-app.mjs");
|
|
270
|
+
console.log(HELP_TEXT);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
223
273
|
const yes = rest.includes("--yes");
|
|
224
274
|
const dryRun = rest.includes("--dry-run");
|
|
225
275
|
const skipInstall = rest.includes("--skip-install");
|
|
@@ -235,6 +285,11 @@ try {
|
|
|
235
285
|
break;
|
|
236
286
|
}
|
|
237
287
|
case "migrate": {
|
|
288
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
289
|
+
const { HELP_TEXT } = await import("../src/migrate-bundled.mjs");
|
|
290
|
+
console.log(HELP_TEXT);
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
238
293
|
// sh-ui migrate bundled [--apply] [--bundle <path>]
|
|
239
294
|
const sub = rest[0];
|
|
240
295
|
const flags = rest.slice(1);
|
|
@@ -251,6 +306,11 @@ try {
|
|
|
251
306
|
break;
|
|
252
307
|
}
|
|
253
308
|
case "migrate-v065": {
|
|
309
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
310
|
+
const { HELP_TEXT } = await import("../src/migrate-v065.mjs");
|
|
311
|
+
console.log(HELP_TEXT);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
254
314
|
const apply = rest.includes("--apply");
|
|
255
315
|
const skipImportRewrite = rest.includes("--skip-import-rewrite");
|
|
256
316
|
const { migrateToV065 } = await import("../src/migrate-v065.mjs");
|
|
@@ -264,6 +324,11 @@ try {
|
|
|
264
324
|
}
|
|
265
325
|
case "remove":
|
|
266
326
|
case "rm": {
|
|
327
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
328
|
+
const { HELP_TEXT } = await import("../src/remove.mjs");
|
|
329
|
+
console.log(HELP_TEXT);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
267
332
|
const force = rest.includes("--force");
|
|
268
333
|
const dryRun = rest.includes("--dry-run");
|
|
269
334
|
const names = rest.filter((a) => !a.startsWith("--"));
|
|
@@ -280,10 +345,12 @@ try {
|
|
|
280
345
|
case "--help":
|
|
281
346
|
console.log(usage);
|
|
282
347
|
break;
|
|
283
|
-
default:
|
|
284
|
-
|
|
348
|
+
default: {
|
|
349
|
+
const hits = suggest(cmd, KNOWN_COMMANDS);
|
|
350
|
+
console.error(`알 수 없는 명령: ${cmd}` + (hits.length ? ` — 혹시 ${hits.join(", ")}?` : "") + "\n");
|
|
285
351
|
console.error(usage);
|
|
286
352
|
process.exit(1);
|
|
353
|
+
}
|
|
287
354
|
}
|
|
288
355
|
} catch (err) {
|
|
289
356
|
console.error(`✗ ${err.message}`);
|
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
|
|
4
4
|
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"version": "0.117.0",
|
|
7
|
+
"date": "2026-06-18",
|
|
8
|
+
"title": "Tree 컴포넌트 — 계층 데이터 확장·선택·키보드 네비",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"신규 Tree — 데이터 주도(nodes 배열) 계층 트리. 확장/축소 + 단일 선택(제어·비제어)",
|
|
12
|
+
"WAI-ARIA Tree 패턴 — role=tree/treeitem/group, 화살표·Home/End·Enter·typeahead 키보드 네비",
|
|
13
|
+
"React(plain·tailwind·css-modules) + Flutter(ShUiTree) 동시 제공. 풀 기능(다중선택·DnD·lazy·인라인편집)은 후속"
|
|
14
|
+
],
|
|
15
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.117.0"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "0.116.0",
|
|
19
|
+
"date": "2026-06-17",
|
|
20
|
+
"title": "CLI 발견성 — 서브명령 --help + 오타 추천",
|
|
21
|
+
"type": "minor",
|
|
22
|
+
"highlights": [
|
|
23
|
+
"모든 서브명령에 --help — init·add·remove·doctor·tokens·theme·migrate·rename-app·upgrade-cli·mcp 전용 도움말",
|
|
24
|
+
"did-you-mean — 없는 컴포넌트/명령 입력 시 가까운 후보를 '혹시 …?' 로 제안",
|
|
25
|
+
"의존성 없는 levenshtein 유틸 내장 (packages/cli/src/levenshtein.mjs)"
|
|
26
|
+
],
|
|
27
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.116.0"
|
|
28
|
+
},
|
|
5
29
|
{
|
|
6
30
|
"version": "0.115.0",
|
|
7
31
|
"date": "2026-06-01",
|
|
@@ -331,6 +331,15 @@
|
|
|
331
331
|
],
|
|
332
332
|
"dependencies": [],
|
|
333
333
|
"registryDependencies": ["tokens"]
|
|
334
|
+
},
|
|
335
|
+
"tree": {
|
|
336
|
+
"name": "tree",
|
|
337
|
+
"type": "widget",
|
|
338
|
+
"files": [
|
|
339
|
+
{ "src": "widgets/sh_ui_tree.dart", "dest": "{widgets}/sh_ui_tree.dart" }
|
|
340
|
+
],
|
|
341
|
+
"dependencies": [],
|
|
342
|
+
"registryDependencies": ["tokens"]
|
|
334
343
|
}
|
|
335
344
|
}
|
|
336
345
|
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
2
|
+
import 'package:flutter/services.dart';
|
|
3
|
+
import '../foundation/sh_ui_tokens.dart';
|
|
4
|
+
|
|
5
|
+
/// Tree 밀도. [sm]은 좁은 사이드바용 컴팩트 변형.
|
|
6
|
+
enum ShUiTreeSize { sm, md }
|
|
7
|
+
|
|
8
|
+
/// Tree 노드 모델.
|
|
9
|
+
class ShUiTreeNode {
|
|
10
|
+
/// 고유 식별자. 확장/선택 상태 추적에 사용된다.
|
|
11
|
+
final String id;
|
|
12
|
+
|
|
13
|
+
/// 행에 표시될 라벨.
|
|
14
|
+
final String label;
|
|
15
|
+
|
|
16
|
+
/// 하위 노드 목록 (선택). 비어있지 않으면 확장 가능한 부모로 취급된다.
|
|
17
|
+
final List<ShUiTreeNode>? children;
|
|
18
|
+
|
|
19
|
+
/// 라벨 왼쪽에 붙는 아이콘 (선택).
|
|
20
|
+
final IconData? icon;
|
|
21
|
+
|
|
22
|
+
/// 비활성화 상태. 선택·확장되지 않고 키보드 네비에서도 건너뛴다.
|
|
23
|
+
final bool disabled;
|
|
24
|
+
|
|
25
|
+
const ShUiTreeNode({
|
|
26
|
+
required this.id,
|
|
27
|
+
required this.label,
|
|
28
|
+
this.children,
|
|
29
|
+
this.icon,
|
|
30
|
+
this.disabled = false,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
bool get hasChildren => children != null && children!.isNotEmpty;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// shUi Tree — 계층형 노드를 들여쓰기로 렌더하는 트리 뷰.
|
|
37
|
+
///
|
|
38
|
+
/// 확장/축소(부모 노드 탭), 단일 선택, disabled 노드 스킵, Semantics(스크린
|
|
39
|
+
/// 리더) 및 데스크탑/웹 키보드 네비(화살표·Enter/Space)를 지원한다.
|
|
40
|
+
///
|
|
41
|
+
/// 확장 상태는 [expandedIds]를 주면 controlled, 없으면 [defaultExpandedIds]로
|
|
42
|
+
/// 시드한 내부 상태로 동작한다. 선택 상태도 [selectedId] / [defaultSelectedId]로
|
|
43
|
+
/// 동일하게 controlled/uncontrolled를 가른다.
|
|
44
|
+
class ShUiTree extends StatefulWidget {
|
|
45
|
+
/// 렌더할 트리 노드 배열.
|
|
46
|
+
final List<ShUiTreeNode> nodes;
|
|
47
|
+
|
|
48
|
+
/// 확장된 노드 id 집합 (controlled). null이면 내부 상태를 사용한다.
|
|
49
|
+
final Set<String>? expandedIds;
|
|
50
|
+
|
|
51
|
+
/// 초기 확장 노드 id 집합 (uncontrolled).
|
|
52
|
+
final Set<String>? defaultExpandedIds;
|
|
53
|
+
|
|
54
|
+
/// 확장 상태 변경 콜백. 변경 후의 확장 id 집합이 전달된다.
|
|
55
|
+
final ValueChanged<Set<String>>? onExpandedChange;
|
|
56
|
+
|
|
57
|
+
/// 선택된 노드 id (controlled). null이면 내부 상태를 사용한다.
|
|
58
|
+
final String? selectedId;
|
|
59
|
+
|
|
60
|
+
/// 초기 선택 노드 id (uncontrolled).
|
|
61
|
+
final String? defaultSelectedId;
|
|
62
|
+
|
|
63
|
+
/// 선택 변경 콜백. 선택된 노드 id(또는 null)가 전달된다.
|
|
64
|
+
final ValueChanged<String?>? onSelect;
|
|
65
|
+
|
|
66
|
+
/// 밀도. [ShUiTreeSize.sm]은 좁은 사이드바용 컴팩트 변형.
|
|
67
|
+
final ShUiTreeSize size;
|
|
68
|
+
|
|
69
|
+
const ShUiTree({
|
|
70
|
+
super.key,
|
|
71
|
+
required this.nodes,
|
|
72
|
+
this.expandedIds,
|
|
73
|
+
this.defaultExpandedIds,
|
|
74
|
+
this.onExpandedChange,
|
|
75
|
+
this.selectedId,
|
|
76
|
+
this.defaultSelectedId,
|
|
77
|
+
this.onSelect,
|
|
78
|
+
this.size = ShUiTreeSize.md,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
@override
|
|
82
|
+
State<ShUiTree> createState() => _ShUiTreeState();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// 평탄화된 한 행의 메타.
|
|
86
|
+
class _FlatRow {
|
|
87
|
+
final ShUiTreeNode node;
|
|
88
|
+
final int level;
|
|
89
|
+
const _FlatRow(this.node, this.level);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
class _ShUiTreeState extends State<ShUiTree> {
|
|
93
|
+
late Set<String> _expanded;
|
|
94
|
+
String? _selected;
|
|
95
|
+
final FocusNode _focusNode = FocusNode();
|
|
96
|
+
String? _focusedId;
|
|
97
|
+
|
|
98
|
+
/// build에서 한 번 계산해 둔, 현재 보이는 행들. 키 이벤트는 build 이후에
|
|
99
|
+
/// 발생하므로 키 핸들러가 이 값을 그대로 재사용해 _flatten() 중복 호출을 피한다.
|
|
100
|
+
List<_FlatRow> _visibleRows = const [];
|
|
101
|
+
|
|
102
|
+
bool get _expandedControlled => widget.expandedIds != null;
|
|
103
|
+
bool get _selectedControlled => widget.selectedId != null;
|
|
104
|
+
|
|
105
|
+
Set<String> get _effectiveExpanded =>
|
|
106
|
+
_expandedControlled ? widget.expandedIds! : _expanded;
|
|
107
|
+
String? get _effectiveSelected =>
|
|
108
|
+
_selectedControlled ? widget.selectedId : _selected;
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
void initState() {
|
|
112
|
+
super.initState();
|
|
113
|
+
_expanded = {...?widget.defaultExpandedIds};
|
|
114
|
+
_selected = widget.defaultSelectedId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@override
|
|
118
|
+
void dispose() {
|
|
119
|
+
_focusNode.dispose();
|
|
120
|
+
super.dispose();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// 현재 확장 상태 기준으로 보이는 행만 평탄화한다.
|
|
124
|
+
List<_FlatRow> _flatten() {
|
|
125
|
+
final rows = <_FlatRow>[];
|
|
126
|
+
final expanded = _effectiveExpanded;
|
|
127
|
+
void walk(List<ShUiTreeNode> nodes, int level) {
|
|
128
|
+
for (final node in nodes) {
|
|
129
|
+
rows.add(_FlatRow(node, level));
|
|
130
|
+
if (node.hasChildren && expanded.contains(node.id)) {
|
|
131
|
+
walk(node.children!, level + 1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
walk(widget.nodes, 0);
|
|
137
|
+
return rows;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
void _toggleExpand(String id) {
|
|
141
|
+
final next = {..._effectiveExpanded};
|
|
142
|
+
if (next.contains(id)) {
|
|
143
|
+
next.remove(id);
|
|
144
|
+
} else {
|
|
145
|
+
next.add(id);
|
|
146
|
+
}
|
|
147
|
+
if (!_expandedControlled) {
|
|
148
|
+
setState(() => _expanded = next);
|
|
149
|
+
}
|
|
150
|
+
widget.onExpandedChange?.call(next);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
void _setExpanded(String id, bool expand) {
|
|
154
|
+
final current = _effectiveExpanded;
|
|
155
|
+
if (expand == current.contains(id)) return;
|
|
156
|
+
_toggleExpand(id);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
void _select(String? id) {
|
|
160
|
+
if (!_selectedControlled) {
|
|
161
|
+
setState(() => _selected = id);
|
|
162
|
+
}
|
|
163
|
+
widget.onSelect?.call(id);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// 노드 행 탭: 자식이 있으면 확장 토글 + 선택, 없으면 선택만.
|
|
167
|
+
void _onRowTap(ShUiTreeNode node) {
|
|
168
|
+
if (node.disabled) return;
|
|
169
|
+
setState(() => _focusedId = node.id);
|
|
170
|
+
if (node.hasChildren) {
|
|
171
|
+
_toggleExpand(node.id);
|
|
172
|
+
}
|
|
173
|
+
_select(node.id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── 키보드 네비게이션 ──────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
List<_FlatRow> _navigableRows(List<_FlatRow> rows) =>
|
|
179
|
+
rows.where((r) => !r.node.disabled).toList();
|
|
180
|
+
|
|
181
|
+
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
|
|
182
|
+
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
|
|
183
|
+
return KeyEventResult.ignored;
|
|
184
|
+
}
|
|
185
|
+
final rows = _visibleRows;
|
|
186
|
+
final navigable = _navigableRows(rows);
|
|
187
|
+
if (navigable.isEmpty) return KeyEventResult.ignored;
|
|
188
|
+
|
|
189
|
+
// 현재 포커스 인덱스 (없으면 첫 항목 직전으로 가정).
|
|
190
|
+
int currentIndex =
|
|
191
|
+
navigable.indexWhere((r) => r.node.id == _focusedId);
|
|
192
|
+
final key = event.logicalKey;
|
|
193
|
+
|
|
194
|
+
if (key == LogicalKeyboardKey.arrowDown) {
|
|
195
|
+
final nextIndex =
|
|
196
|
+
currentIndex < 0 ? 0 : (currentIndex + 1).clamp(0, navigable.length - 1);
|
|
197
|
+
setState(() => _focusedId = navigable[nextIndex].node.id);
|
|
198
|
+
return KeyEventResult.handled;
|
|
199
|
+
}
|
|
200
|
+
if (key == LogicalKeyboardKey.arrowUp) {
|
|
201
|
+
final prevIndex =
|
|
202
|
+
currentIndex <= 0 ? 0 : (currentIndex - 1);
|
|
203
|
+
setState(() => _focusedId = navigable[prevIndex].node.id);
|
|
204
|
+
return KeyEventResult.handled;
|
|
205
|
+
}
|
|
206
|
+
if (key == LogicalKeyboardKey.arrowRight) {
|
|
207
|
+
if (currentIndex < 0) return KeyEventResult.ignored;
|
|
208
|
+
final row = navigable[currentIndex];
|
|
209
|
+
if (row.node.hasChildren) {
|
|
210
|
+
if (!_effectiveExpanded.contains(row.node.id)) {
|
|
211
|
+
_setExpanded(row.node.id, true);
|
|
212
|
+
} else {
|
|
213
|
+
// 이미 확장됨 → 첫 자식으로 이동. 확장 상태 변경이 없으므로
|
|
214
|
+
// build에서 계산한 _visibleRows가 그대로 유효하다.
|
|
215
|
+
final after = _navigableRows(_visibleRows);
|
|
216
|
+
final idx = after.indexWhere((r) => r.node.id == row.node.id);
|
|
217
|
+
if (idx >= 0 && idx + 1 < after.length &&
|
|
218
|
+
after[idx + 1].level > row.level) {
|
|
219
|
+
setState(() => _focusedId = after[idx + 1].node.id);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return KeyEventResult.handled;
|
|
224
|
+
}
|
|
225
|
+
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
226
|
+
if (currentIndex < 0) return KeyEventResult.ignored;
|
|
227
|
+
final row = navigable[currentIndex];
|
|
228
|
+
if (row.node.hasChildren && _effectiveExpanded.contains(row.node.id)) {
|
|
229
|
+
_setExpanded(row.node.id, false);
|
|
230
|
+
} else if (row.level > 0) {
|
|
231
|
+
// 부모로 이동.
|
|
232
|
+
final all = rows;
|
|
233
|
+
final pos = all.indexWhere((r) => r.node.id == row.node.id);
|
|
234
|
+
for (var i = pos - 1; i >= 0; i--) {
|
|
235
|
+
if (all[i].level < row.level && !all[i].node.disabled) {
|
|
236
|
+
setState(() => _focusedId = all[i].node.id);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return KeyEventResult.handled;
|
|
242
|
+
}
|
|
243
|
+
if (key == LogicalKeyboardKey.home) {
|
|
244
|
+
setState(() => _focusedId = navigable.first.node.id);
|
|
245
|
+
return KeyEventResult.handled;
|
|
246
|
+
}
|
|
247
|
+
if (key == LogicalKeyboardKey.end) {
|
|
248
|
+
setState(() => _focusedId = navigable.last.node.id);
|
|
249
|
+
return KeyEventResult.handled;
|
|
250
|
+
}
|
|
251
|
+
if (key == LogicalKeyboardKey.enter ||
|
|
252
|
+
key == LogicalKeyboardKey.space) {
|
|
253
|
+
if (currentIndex < 0) return KeyEventResult.ignored;
|
|
254
|
+
_onRowTap(navigable[currentIndex].node);
|
|
255
|
+
return KeyEventResult.handled;
|
|
256
|
+
}
|
|
257
|
+
return KeyEventResult.ignored;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@override
|
|
261
|
+
Widget build(BuildContext context) {
|
|
262
|
+
final rows = _flatten();
|
|
263
|
+
_visibleRows = rows;
|
|
264
|
+
|
|
265
|
+
return Focus(
|
|
266
|
+
focusNode: _focusNode,
|
|
267
|
+
onKeyEvent: _handleKey,
|
|
268
|
+
child: Semantics(
|
|
269
|
+
container: true,
|
|
270
|
+
explicitChildNodes: true,
|
|
271
|
+
child: Column(
|
|
272
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
273
|
+
mainAxisSize: MainAxisSize.min,
|
|
274
|
+
children: [
|
|
275
|
+
for (final row in rows)
|
|
276
|
+
_ShUiTreeRow(
|
|
277
|
+
key: ValueKey(row.node.id),
|
|
278
|
+
node: row.node,
|
|
279
|
+
level: row.level,
|
|
280
|
+
size: widget.size,
|
|
281
|
+
isExpanded:
|
|
282
|
+
row.node.hasChildren && _effectiveExpanded.contains(row.node.id),
|
|
283
|
+
isSelected: _effectiveSelected == row.node.id,
|
|
284
|
+
isFocused: _focusedId == row.node.id,
|
|
285
|
+
onTap: () {
|
|
286
|
+
_focusNode.requestFocus();
|
|
287
|
+
_onRowTap(row.node);
|
|
288
|
+
},
|
|
289
|
+
),
|
|
290
|
+
],
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
class _ShUiTreeRow extends StatefulWidget {
|
|
298
|
+
final ShUiTreeNode node;
|
|
299
|
+
final int level;
|
|
300
|
+
final ShUiTreeSize size;
|
|
301
|
+
final bool isExpanded;
|
|
302
|
+
final bool isSelected;
|
|
303
|
+
final bool isFocused;
|
|
304
|
+
final VoidCallback onTap;
|
|
305
|
+
|
|
306
|
+
const _ShUiTreeRow({
|
|
307
|
+
super.key,
|
|
308
|
+
required this.node,
|
|
309
|
+
required this.level,
|
|
310
|
+
required this.size,
|
|
311
|
+
required this.isExpanded,
|
|
312
|
+
required this.isSelected,
|
|
313
|
+
required this.isFocused,
|
|
314
|
+
required this.onTap,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
@override
|
|
318
|
+
State<_ShUiTreeRow> createState() => _ShUiTreeRowState();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
class _ShUiTreeRowState extends State<_ShUiTreeRow> {
|
|
322
|
+
bool _hover = false;
|
|
323
|
+
|
|
324
|
+
@override
|
|
325
|
+
Widget build(BuildContext context) {
|
|
326
|
+
final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
|
|
327
|
+
final colors = shUi.colors;
|
|
328
|
+
final node = widget.node;
|
|
329
|
+
final disabled = node.disabled;
|
|
330
|
+
final sm = widget.size == ShUiTreeSize.sm;
|
|
331
|
+
|
|
332
|
+
final fontSize = sm ? shUi.text.xs : shUi.text.sm;
|
|
333
|
+
final vPad = sm ? shUi.spacing.s1 : shUi.spacing.s2;
|
|
334
|
+
final indent = shUi.spacing.s4 * widget.level;
|
|
335
|
+
|
|
336
|
+
final highlighted =
|
|
337
|
+
widget.isSelected || (widget.isFocused && !disabled);
|
|
338
|
+
final bg = highlighted
|
|
339
|
+
? colors.backgroundMuted
|
|
340
|
+
: (_hover && !disabled ? colors.backgroundMuted : Colors.transparent);
|
|
341
|
+
|
|
342
|
+
final row = MouseRegion(
|
|
343
|
+
cursor: disabled ? SystemMouseCursors.basic : SystemMouseCursors.click,
|
|
344
|
+
onEnter: (_) => setState(() => _hover = true),
|
|
345
|
+
onExit: (_) => setState(() => _hover = false),
|
|
346
|
+
child: GestureDetector(
|
|
347
|
+
onTap: disabled ? null : widget.onTap,
|
|
348
|
+
behavior: HitTestBehavior.opaque,
|
|
349
|
+
child: AnimatedContainer(
|
|
350
|
+
duration: shUi.duration.fast,
|
|
351
|
+
curve: shUi.ease.standard,
|
|
352
|
+
padding: EdgeInsets.fromLTRB(
|
|
353
|
+
shUi.spacing.s2 + indent,
|
|
354
|
+
vPad,
|
|
355
|
+
shUi.spacing.s2,
|
|
356
|
+
vPad,
|
|
357
|
+
),
|
|
358
|
+
decoration: BoxDecoration(
|
|
359
|
+
color: bg,
|
|
360
|
+
borderRadius:
|
|
361
|
+
BorderRadius.circular(shUi.radius.defaultRadius - 2),
|
|
362
|
+
border: widget.isFocused && !disabled
|
|
363
|
+
? Border.all(
|
|
364
|
+
color: colors.ring,
|
|
365
|
+
width: shUi.borderWidth.strong,
|
|
366
|
+
)
|
|
367
|
+
: Border.all(color: Colors.transparent, width: shUi.borderWidth.strong),
|
|
368
|
+
),
|
|
369
|
+
child: Row(
|
|
370
|
+
children: [
|
|
371
|
+
// chevron — leaf면 자리만 유지(visibility hidden).
|
|
372
|
+
SizedBox(
|
|
373
|
+
width: shUi.spacing.s4,
|
|
374
|
+
child: node.hasChildren
|
|
375
|
+
? AnimatedRotation(
|
|
376
|
+
turns: widget.isExpanded ? 0.25 : 0.0,
|
|
377
|
+
duration: shUi.duration.fast,
|
|
378
|
+
curve: shUi.ease.standard,
|
|
379
|
+
child: Icon(
|
|
380
|
+
Icons.chevron_right,
|
|
381
|
+
size: sm ? 14 : 16,
|
|
382
|
+
color: colors.foregroundMuted,
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
: const SizedBox.shrink(),
|
|
386
|
+
),
|
|
387
|
+
SizedBox(width: shUi.spacing.s2),
|
|
388
|
+
if (node.icon != null) ...[
|
|
389
|
+
Icon(
|
|
390
|
+
node.icon,
|
|
391
|
+
size: sm ? 14 : 16,
|
|
392
|
+
color: colors.foregroundMuted,
|
|
393
|
+
),
|
|
394
|
+
SizedBox(width: shUi.spacing.s2),
|
|
395
|
+
],
|
|
396
|
+
Expanded(
|
|
397
|
+
child: Text(
|
|
398
|
+
node.label,
|
|
399
|
+
overflow: TextOverflow.ellipsis,
|
|
400
|
+
style: TextStyle(
|
|
401
|
+
color: disabled ? colors.foregroundMuted : colors.foreground,
|
|
402
|
+
fontSize: fontSize,
|
|
403
|
+
fontWeight: widget.isSelected
|
|
404
|
+
? shUi.weight.medium
|
|
405
|
+
: shUi.weight.regular,
|
|
406
|
+
height: 1.25,
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
),
|
|
410
|
+
],
|
|
411
|
+
),
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
return Semantics(
|
|
417
|
+
selected: widget.isSelected,
|
|
418
|
+
enabled: !disabled,
|
|
419
|
+
expanded: node.hasChildren ? widget.isExpanded : null,
|
|
420
|
+
label: node.label,
|
|
421
|
+
button: true,
|
|
422
|
+
child: Opacity(
|
|
423
|
+
opacity: disabled ? shUi.opacity.disabled : 1,
|
|
424
|
+
child: row,
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|