sh-ui-cli 0.15.0 → 0.21.1

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 (163) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +366 -0
  3. package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
  4. package/data/registry/flutter/registry.json +336 -0
  5. package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
  6. package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
  7. package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
  8. package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
  9. package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
  10. package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
  11. package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
  12. package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
  13. package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
  14. package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
  15. package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
  16. package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
  17. package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
  18. package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
  19. package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
  20. package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
  21. package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
  22. package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
  23. package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
  24. package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
  25. package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
  26. package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
  27. package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
  28. package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
  29. package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
  30. package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
  31. package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
  32. package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
  33. package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
  34. package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
  35. package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
  36. package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
  37. package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
  38. package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
  39. package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
  40. package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
  41. package/data/registry/react/components/accordion/index.tsx +85 -0
  42. package/data/registry/react/components/accordion/styles.css +94 -0
  43. package/data/registry/react/components/animations/animations.css +51 -0
  44. package/data/registry/react/components/avatar/index.tsx +75 -0
  45. package/data/registry/react/components/avatar/styles.css +36 -0
  46. package/data/registry/react/components/badge/index.tsx +42 -0
  47. package/data/registry/react/components/badge/styles.css +57 -0
  48. package/data/registry/react/components/base/base.css +102 -0
  49. package/data/registry/react/components/breadcrumb/index.tsx +154 -0
  50. package/data/registry/react/components/breadcrumb/styles.css +82 -0
  51. package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
  52. package/data/registry/react/components/button/index.tsx +47 -0
  53. package/data/registry/react/components/button/styles.css +93 -0
  54. package/data/registry/react/components/card/index.tsx +86 -0
  55. package/data/registry/react/components/card/styles.css +73 -0
  56. package/data/registry/react/components/carousel/index.tsx +432 -0
  57. package/data/registry/react/components/carousel/styles.css +155 -0
  58. package/data/registry/react/components/checkbox/index.tsx +98 -0
  59. package/data/registry/react/components/checkbox/styles.css +75 -0
  60. package/data/registry/react/components/code-panel/copy.tsx +56 -0
  61. package/data/registry/react/components/code-panel/index.tsx +193 -0
  62. package/data/registry/react/components/code-panel/styles.css +124 -0
  63. package/data/registry/react/components/color-picker/index.tsx +466 -0
  64. package/data/registry/react/components/color-picker/styles.css +166 -0
  65. package/data/registry/react/components/combobox/index.tsx +167 -0
  66. package/data/registry/react/components/combobox/styles.css +151 -0
  67. package/data/registry/react/components/context-menu/index.tsx +253 -0
  68. package/data/registry/react/components/context-menu/styles.css +140 -0
  69. package/data/registry/react/components/date-picker/index.tsx +757 -0
  70. package/data/registry/react/components/date-picker/styles.css +279 -0
  71. package/data/registry/react/components/dialog/index.tsx +97 -0
  72. package/data/registry/react/components/dialog/styles.css +127 -0
  73. package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
  74. package/data/registry/react/components/dropdown-menu/styles.css +150 -0
  75. package/data/registry/react/components/file-upload/index.tsx +489 -0
  76. package/data/registry/react/components/file-upload/styles.css +170 -0
  77. package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
  78. package/data/registry/react/components/form/context.ts +92 -0
  79. package/data/registry/react/components/form/field.test.tsx +230 -0
  80. package/data/registry/react/components/form/field.tsx +236 -0
  81. package/data/registry/react/components/form/focus-first-error.ts +54 -0
  82. package/data/registry/react/components/form/form.section.test.tsx +58 -0
  83. package/data/registry/react/components/form/form.test.tsx +146 -0
  84. package/data/registry/react/components/form/form.tsx +180 -0
  85. package/data/registry/react/components/form/index.tsx +61 -0
  86. package/data/registry/react/components/form/steps.test.tsx +106 -0
  87. package/data/registry/react/components/form/steps.tsx +193 -0
  88. package/data/registry/react/components/form/store.test.ts +206 -0
  89. package/data/registry/react/components/form/store.ts +318 -0
  90. package/data/registry/react/components/form/styles.css +47 -0
  91. package/data/registry/react/components/form/types.ts +104 -0
  92. package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
  93. package/data/registry/react/components/form/utils.test.ts +44 -0
  94. package/data/registry/react/components/form/utils.ts +49 -0
  95. package/data/registry/react/components/form/validation.test.ts +67 -0
  96. package/data/registry/react/components/form/validation.ts +64 -0
  97. package/data/registry/react/components/form-rhf/README.md +27 -0
  98. package/data/registry/react/components/form-rhf/index.tsx +289 -0
  99. package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
  100. package/data/registry/react/components/form-tanstack/README.md +27 -0
  101. package/data/registry/react/components/form-tanstack/index.tsx +352 -0
  102. package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
  103. package/data/registry/react/components/form-yup/README.md +22 -0
  104. package/data/registry/react/components/form-yup/index.tsx +50 -0
  105. package/data/registry/react/components/form-yup/yup.test.ts +27 -0
  106. package/data/registry/react/components/header/index.tsx +257 -0
  107. package/data/registry/react/components/header/styles.css +190 -0
  108. package/data/registry/react/components/input/index.tsx +517 -0
  109. package/data/registry/react/components/input/styles.css +203 -0
  110. package/data/registry/react/components/label/index.tsx +54 -0
  111. package/data/registry/react/components/label/styles.css +90 -0
  112. package/data/registry/react/components/menubar/index.tsx +34 -0
  113. package/data/registry/react/components/menubar/styles.css +45 -0
  114. package/data/registry/react/components/pagination/index.tsx +271 -0
  115. package/data/registry/react/components/pagination/styles.css +105 -0
  116. package/data/registry/react/components/popover/index.tsx +115 -0
  117. package/data/registry/react/components/popover/styles.css +65 -0
  118. package/data/registry/react/components/progress/index.tsx +56 -0
  119. package/data/registry/react/components/progress/styles.css +41 -0
  120. package/data/registry/react/components/radio/index.tsx +67 -0
  121. package/data/registry/react/components/radio/styles.css +80 -0
  122. package/data/registry/react/components/select/index.tsx +236 -0
  123. package/data/registry/react/components/select/styles.css +193 -0
  124. package/data/registry/react/components/separator/index.tsx +48 -0
  125. package/data/registry/react/components/separator/styles.css +15 -0
  126. package/data/registry/react/components/sidebar/index.tsx +1084 -0
  127. package/data/registry/react/components/sidebar/styles.css +502 -0
  128. package/data/registry/react/components/skeleton/index.tsx +24 -0
  129. package/data/registry/react/components/skeleton/styles.css +24 -0
  130. package/data/registry/react/components/slider/index.tsx +300 -0
  131. package/data/registry/react/components/slider/styles.css +64 -0
  132. package/data/registry/react/components/spinner/index.tsx +40 -0
  133. package/data/registry/react/components/spinner/styles.css +37 -0
  134. package/data/registry/react/components/switch/index.tsx +41 -0
  135. package/data/registry/react/components/switch/styles.css +83 -0
  136. package/data/registry/react/components/tabs/index.tsx +93 -0
  137. package/data/registry/react/components/tabs/styles.css +148 -0
  138. package/data/registry/react/components/textarea/index.tsx +25 -0
  139. package/data/registry/react/components/textarea/styles.css +54 -0
  140. package/data/registry/react/components/theme/index.tsx +91 -0
  141. package/data/registry/react/components/toast/index.tsx +257 -0
  142. package/data/registry/react/components/toast/styles.css +290 -0
  143. package/data/registry/react/components/toggle/index.tsx +133 -0
  144. package/data/registry/react/components/toggle/styles.css +85 -0
  145. package/data/registry/react/components/tooltip/index.tsx +85 -0
  146. package/data/registry/react/components/tooltip/styles.css +44 -0
  147. package/data/registry/react/components/z-index/z-index.css +16 -0
  148. package/data/registry/react/hooks/use-active-section.ts +104 -0
  149. package/data/registry/react/hooks/use-media-query.ts +27 -0
  150. package/data/registry/react/lib/cn.ts +39 -0
  151. package/data/registry/react/peer-versions.json +10 -0
  152. package/data/registry/react/registry.json +835 -0
  153. package/data/summaries/flutter.json +42 -0
  154. package/data/summaries/react.json +50 -0
  155. package/data/tokens/build.mjs +553 -0
  156. package/data/tokens/src/primitives.json +146 -0
  157. package/data/tokens/src/semantic.json +146 -0
  158. package/package.json +9 -2
  159. package/src/add.mjs +41 -15
  160. package/src/list.mjs +3 -11
  161. package/src/mcp.mjs +308 -0
  162. package/src/paths.mjs +59 -0
  163. package/src/remove.mjs +4 -11
@@ -0,0 +1,904 @@
1
+ import 'package:flutter/material.dart';
2
+ import '../foundation/sh_ui_tokens.dart';
3
+
4
+ /// sh-ui Select — 드롭다운 선택.
5
+ ///
6
+ /// 기본 사용 (flat items):
7
+ /// ShUiSelect<String>(
8
+ /// value: selected,
9
+ /// onChanged: (v) => setState(() => selected = v),
10
+ /// placeholder: '과일 선택',
11
+ /// items: [
12
+ /// ShUiSelectItem(value: 'apple', child: Text('사과')),
13
+ /// ShUiSelectItem(value: 'banana', child: Text('바나나')),
14
+ /// ],
15
+ /// )
16
+ ///
17
+ /// 그룹/구분선 사용 (structured elements):
18
+ /// ShUiSelect<String>(
19
+ /// value: selected,
20
+ /// onChanged: (v) => setState(() => selected = v),
21
+ /// placeholder: '국가 선택',
22
+ /// elements: [
23
+ /// ShUiSelectGroup(label: '아시아', items: [
24
+ /// ShUiSelectItem(value: 'kr', child: Text('대한민국')),
25
+ /// ShUiSelectItem(value: 'jp', child: Text('일본')),
26
+ /// ]),
27
+ /// ShUiSelectSeparator(),
28
+ /// ShUiSelectGroup(label: '유럽', items: [
29
+ /// ShUiSelectItem(value: 'de', child: Text('독일')),
30
+ /// ]),
31
+ /// ],
32
+ /// )
33
+ class ShUiSelect<T> extends StatefulWidget {
34
+ final T? value;
35
+ final ValueChanged<T?>? onChanged;
36
+ final String? placeholder;
37
+
38
+ /// flat 구조. [elements]와 동시에 지정하지 않는 것을 권장.
39
+ /// 둘 다 지정되면 [elements]가 우선 사용된다.
40
+ final List<ShUiSelectItem<T>>? items;
41
+
42
+ /// 그룹/구분선/라벨을 포함하는 구조화된 목록.
43
+ final List<ShUiSelectElement>? elements;
44
+
45
+ final bool enabled;
46
+ final bool invalid;
47
+
48
+ const ShUiSelect({
49
+ super.key,
50
+ this.value,
51
+ this.onChanged,
52
+ this.placeholder,
53
+ this.items,
54
+ this.elements,
55
+ this.enabled = true,
56
+ this.invalid = false,
57
+ }) : assert(items != null || elements != null,
58
+ 'ShUiSelect: items 또는 elements 중 하나는 제공되어야 합니다.');
59
+
60
+ @override
61
+ State<ShUiSelect<T>> createState() => _ShUiSelectState<T>();
62
+ }
63
+
64
+ class _ShUiSelectState<T> extends State<ShUiSelect<T>> {
65
+ final LayerLink _layerLink = LayerLink();
66
+ OverlayEntry? _overlayEntry;
67
+ bool _hover = false;
68
+ bool _isOpen = false;
69
+
70
+ /// elements가 있으면 elements를, 아니면 items를 elements 형태로 래핑.
71
+ List<ShUiSelectElement> get _resolvedElements {
72
+ if (widget.elements != null) return widget.elements!;
73
+ return widget.items ?? const [];
74
+ }
75
+
76
+ /// 모든 item을 flat하게 순회 — 선택된 child lookup용.
77
+ Iterable<ShUiSelectItem> _flatItems() sync* {
78
+ for (final el in _resolvedElements) {
79
+ if (el is ShUiSelectItem) {
80
+ yield el;
81
+ } else if (el is ShUiSelectGroup) {
82
+ for (final item in el.items) {
83
+ yield item;
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ void _toggle() {
90
+ if (_isOpen) {
91
+ _close();
92
+ } else {
93
+ _open();
94
+ }
95
+ }
96
+
97
+ void _open() {
98
+ final overlay = Overlay.of(context);
99
+ final renderBox = context.findRenderObject() as RenderBox;
100
+ final size = renderBox.size;
101
+
102
+ _overlayEntry = OverlayEntry(
103
+ builder: (context) => _SelectOverlay<T>(
104
+ link: _layerLink,
105
+ triggerWidth: size.width,
106
+ elements: _resolvedElements,
107
+ selectedValue: widget.value,
108
+ onSelect: (value) {
109
+ widget.onChanged?.call(value);
110
+ _close();
111
+ },
112
+ onDismiss: _close,
113
+ ),
114
+ );
115
+
116
+ overlay.insert(_overlayEntry!);
117
+ setState(() => _isOpen = true);
118
+ }
119
+
120
+ void _close() {
121
+ _overlayEntry?.remove();
122
+ _overlayEntry = null;
123
+ if (mounted) setState(() => _isOpen = false);
124
+ }
125
+
126
+ @override
127
+ void dispose() {
128
+ _close();
129
+ super.dispose();
130
+ }
131
+
132
+ @override
133
+ Widget build(BuildContext context) {
134
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
135
+ final colors = shUi.colors;
136
+ final disabled = !widget.enabled || widget.onChanged == null;
137
+
138
+ // 현재 선택된 아이템의 child를 찾아 표시
139
+ Widget? selectedChild;
140
+ for (final item in _flatItems()) {
141
+ if (item.value == widget.value) {
142
+ selectedChild = item.child;
143
+ break;
144
+ }
145
+ }
146
+
147
+ Color borderColor;
148
+ if (widget.invalid) {
149
+ borderColor = colors.danger;
150
+ } else if (_isOpen) {
151
+ borderColor = colors.foreground;
152
+ } else if (_hover && !disabled) {
153
+ borderColor = colors.foregroundMuted;
154
+ } else {
155
+ borderColor = colors.border;
156
+ }
157
+
158
+ return CompositedTransformTarget(
159
+ link: _layerLink,
160
+ child: Opacity(
161
+ opacity: disabled ? shUi.opacity.disabled : 1,
162
+ child: MouseRegion(
163
+ cursor: disabled ? SystemMouseCursors.basic : SystemMouseCursors.click,
164
+ onEnter: (_) => setState(() => _hover = true),
165
+ onExit: (_) => setState(() => _hover = false),
166
+ child: GestureDetector(
167
+ onTap: disabled ? null : _toggle,
168
+ child: AnimatedContainer(
169
+ duration: shUi.duration.fast,
170
+ height: shUi.control.md,
171
+ padding: EdgeInsets.symmetric(horizontal: shUi.spacing.s3),
172
+ decoration: BoxDecoration(
173
+ color: colors.background,
174
+ border: Border.all(
175
+ color: borderColor,
176
+ width: _isOpen ? shUi.borderWidth.strong : shUi.borderWidth.normal,
177
+ ),
178
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
179
+ ),
180
+ child: Row(
181
+ children: [
182
+ Expanded(
183
+ child: selectedChild != null
184
+ ? DefaultTextStyle(
185
+ style: TextStyle(
186
+ color: colors.foreground,
187
+ fontSize: shUi.text.sm,
188
+ ),
189
+ child: selectedChild,
190
+ )
191
+ : Text(
192
+ widget.placeholder ?? '',
193
+ style: TextStyle(
194
+ color: colors.foregroundMuted,
195
+ fontSize: shUi.text.sm,
196
+ ),
197
+ ),
198
+ ),
199
+ Icon(
200
+ _isOpen
201
+ ? Icons.keyboard_arrow_up
202
+ : Icons.keyboard_arrow_down,
203
+ size: 18,
204
+ color: colors.foregroundMuted,
205
+ ),
206
+ ],
207
+ ),
208
+ ),
209
+ ),
210
+ ),
211
+ ),
212
+ );
213
+ }
214
+ }
215
+
216
+ /// Select 내부에 올 수 있는 요소의 공통 베이스.
217
+ /// 서브타입: [ShUiSelectItem], [ShUiSelectGroup], [ShUiSelectSeparator].
218
+ abstract class ShUiSelectElement {
219
+ const ShUiSelectElement();
220
+ }
221
+
222
+ class ShUiSelectItem<T> extends ShUiSelectElement {
223
+ final T value;
224
+ final Widget child;
225
+ final bool enabled;
226
+
227
+ const ShUiSelectItem({
228
+ required this.value,
229
+ required this.child,
230
+ this.enabled = true,
231
+ });
232
+ }
233
+
234
+ /// 여러 [ShUiSelectItem]을 라벨 아래 묶는 그룹.
235
+ class ShUiSelectGroup extends ShUiSelectElement {
236
+ final String label;
237
+ final List<ShUiSelectItem> items;
238
+
239
+ const ShUiSelectGroup({
240
+ required this.label,
241
+ required this.items,
242
+ });
243
+ }
244
+
245
+ /// 요소들 사이의 얇은 구분선.
246
+ class ShUiSelectSeparator extends ShUiSelectElement {
247
+ const ShUiSelectSeparator();
248
+ }
249
+
250
+ class _SelectOverlay<T> extends StatelessWidget {
251
+ final LayerLink link;
252
+ final double triggerWidth;
253
+ final List<ShUiSelectElement> elements;
254
+ final T? selectedValue;
255
+ final ValueChanged<T> onSelect;
256
+ final VoidCallback onDismiss;
257
+
258
+ const _SelectOverlay({
259
+ required this.link,
260
+ required this.triggerWidth,
261
+ required this.elements,
262
+ this.selectedValue,
263
+ required this.onSelect,
264
+ required this.onDismiss,
265
+ });
266
+
267
+ Widget _buildItem(ShUiTheme shUi, ShUiSelectItem item) {
268
+ final selected = item.value == selectedValue;
269
+ return _SelectItemTile(
270
+ item: item,
271
+ selected: selected,
272
+ colors: shUi.colors,
273
+ onSelect: (v) {
274
+ // T로 캐스팅. elements 사용 시 value 타입 책임은 호출자에 있다.
275
+ onSelect(v as T);
276
+ },
277
+ );
278
+ }
279
+
280
+ List<Widget> _buildChildren(ShUiTheme shUi) {
281
+ final colors = shUi.colors;
282
+ final result = <Widget>[];
283
+
284
+ for (final el in elements) {
285
+ if (el is ShUiSelectItem) {
286
+ result.add(_buildItem(shUi, el));
287
+ } else if (el is ShUiSelectGroup) {
288
+ // 그룹 라벨
289
+ result.add(Padding(
290
+ padding: EdgeInsets.fromLTRB(
291
+ shUi.spacing.s3,
292
+ shUi.spacing.s2,
293
+ shUi.spacing.s3,
294
+ shUi.spacing.s1,
295
+ ),
296
+ child: Text(
297
+ el.label.toUpperCase(),
298
+ style: TextStyle(
299
+ color: colors.foregroundMuted,
300
+ fontSize: shUi.text.xs,
301
+ fontWeight: shUi.weight.semibold,
302
+ letterSpacing: 0.5,
303
+ ),
304
+ ),
305
+ ));
306
+ for (final item in el.items) {
307
+ result.add(_buildItem(shUi, item));
308
+ }
309
+ } else if (el is ShUiSelectSeparator) {
310
+ result.add(Padding(
311
+ padding: EdgeInsets.symmetric(vertical: shUi.spacing.s1),
312
+ child: Divider(
313
+ color: colors.border,
314
+ height: 1,
315
+ thickness: 1,
316
+ ),
317
+ ));
318
+ }
319
+ }
320
+ return result;
321
+ }
322
+
323
+ @override
324
+ Widget build(BuildContext context) {
325
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
326
+ final colors = shUi.colors;
327
+
328
+ return Stack(
329
+ children: [
330
+ // Backdrop — dismiss on tap
331
+ Positioned.fill(
332
+ child: GestureDetector(
333
+ onTap: onDismiss,
334
+ behavior: HitTestBehavior.opaque,
335
+ child: const SizedBox.expand(),
336
+ ),
337
+ ),
338
+ // Dropdown
339
+ CompositedTransformFollower(
340
+ link: link,
341
+ offset: const Offset(0, 44),
342
+ child: Material(
343
+ color: Colors.transparent,
344
+ child: Container(
345
+ width: triggerWidth,
346
+ constraints: const BoxConstraints(maxHeight: 240),
347
+ decoration: BoxDecoration(
348
+ color: colors.background,
349
+ border: Border.all(color: colors.border),
350
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
351
+ boxShadow: [
352
+ BoxShadow(
353
+ color: Colors.black.withValues(alpha: 0.08),
354
+ blurRadius: 8,
355
+ offset: const Offset(0, 4),
356
+ ),
357
+ ],
358
+ ),
359
+ child: ClipRRect(
360
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
361
+ child: ListView(
362
+ shrinkWrap: true,
363
+ padding: EdgeInsets.symmetric(vertical: shUi.spacing.s1),
364
+ children: _buildChildren(shUi),
365
+ ),
366
+ ),
367
+ ),
368
+ ),
369
+ ),
370
+ ],
371
+ );
372
+ }
373
+ }
374
+
375
+ class _SelectItemTile extends StatefulWidget {
376
+ final ShUiSelectItem item;
377
+ final bool selected;
378
+ final ShUiColorTokens colors;
379
+ final ValueChanged<dynamic> onSelect;
380
+
381
+ const _SelectItemTile({
382
+ required this.item,
383
+ required this.selected,
384
+ required this.colors,
385
+ required this.onSelect,
386
+ });
387
+
388
+ @override
389
+ State<_SelectItemTile> createState() => _SelectItemTileState();
390
+ }
391
+
392
+ class _SelectItemTileState extends State<_SelectItemTile> {
393
+ bool _hover = false;
394
+
395
+ @override
396
+ Widget build(BuildContext context) {
397
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
398
+ return MouseRegion(
399
+ onEnter: (_) => setState(() => _hover = true),
400
+ onExit: (_) => setState(() => _hover = false),
401
+ child: GestureDetector(
402
+ onTap: widget.item.enabled
403
+ ? () => widget.onSelect(widget.item.value)
404
+ : null,
405
+ child: Container(
406
+ padding: EdgeInsets.symmetric(horizontal: shUi.spacing.s3, vertical: shUi.spacing.s2),
407
+ color: _hover ? widget.colors.backgroundSubtle : Colors.transparent,
408
+ child: Row(
409
+ children: [
410
+ Expanded(
411
+ child: DefaultTextStyle(
412
+ style: TextStyle(
413
+ color: widget.colors.foreground,
414
+ fontSize: shUi.text.sm,
415
+ ),
416
+ child: widget.item.child,
417
+ ),
418
+ ),
419
+ if (widget.selected)
420
+ Icon(Icons.check, size: 14, color: widget.colors.foreground),
421
+ ],
422
+ ),
423
+ ),
424
+ ),
425
+ );
426
+ }
427
+ }
428
+
429
+ /// sh-ui MultiSelect — 다중 선택 드롭다운.
430
+ ///
431
+ /// React의 `MultiSelect` + `MultiSelectValue` 에 대응.
432
+ ///
433
+ /// 기본 사용:
434
+ /// ShUiMultiSelect<String>(
435
+ /// value: selected,
436
+ /// onChanged: (v) => setState(() => selected = v),
437
+ /// elements: const [
438
+ /// ShUiSelectItem(value: 'apple', child: Text('사과')),
439
+ /// ShUiSelectItem(value: 'banana', child: Text('바나나')),
440
+ /// ],
441
+ /// placeholder: '과일 선택',
442
+ /// )
443
+ ///
444
+ /// 칩 렌더 커스터마이즈:
445
+ /// ShUiMultiSelect<String>(
446
+ /// value: selected,
447
+ /// onChanged: (v) => setState(() => selected = v),
448
+ /// elements: [...],
449
+ /// chipsBuilder: (values, onRemove) => Wrap(
450
+ /// spacing: 4,
451
+ /// children: values
452
+ /// .map((v) => ShUiSelectChip(label: v, onRemove: () => onRemove(v)))
453
+ /// .toList(),
454
+ /// ),
455
+ /// )
456
+ class ShUiMultiSelect<T> extends StatefulWidget {
457
+ final List<T> value;
458
+ final ValueChanged<List<T>>? onChanged;
459
+ final List<ShUiSelectElement> elements;
460
+ final String? placeholder;
461
+
462
+ /// 트리거 내부에 표시할 칩 렌더러. 지정하지 않으면 쉼표 join 텍스트.
463
+ final Widget Function(List<T> values, void Function(T) onRemove)? chipsBuilder;
464
+
465
+ final bool enabled;
466
+ final bool invalid;
467
+
468
+ const ShUiMultiSelect({
469
+ super.key,
470
+ required this.value,
471
+ required this.onChanged,
472
+ required this.elements,
473
+ this.placeholder,
474
+ this.chipsBuilder,
475
+ this.enabled = true,
476
+ this.invalid = false,
477
+ });
478
+
479
+ @override
480
+ State<ShUiMultiSelect<T>> createState() => _ShUiMultiSelectState<T>();
481
+ }
482
+
483
+ class _ShUiMultiSelectState<T> extends State<ShUiMultiSelect<T>> {
484
+ final LayerLink _layerLink = LayerLink();
485
+ OverlayEntry? _overlayEntry;
486
+ bool _hover = false;
487
+ bool _isOpen = false;
488
+
489
+ Iterable<ShUiSelectItem> _flatItems() sync* {
490
+ for (final el in widget.elements) {
491
+ if (el is ShUiSelectItem) {
492
+ yield el;
493
+ } else if (el is ShUiSelectGroup) {
494
+ for (final item in el.items) {
495
+ yield item;
496
+ }
497
+ }
498
+ }
499
+ }
500
+
501
+ void _toggleItem(T v) {
502
+ final next = List<T>.from(widget.value);
503
+ if (next.contains(v)) {
504
+ next.remove(v);
505
+ } else {
506
+ next.add(v);
507
+ }
508
+ widget.onChanged?.call(next);
509
+ _overlayEntry?.markNeedsBuild();
510
+ }
511
+
512
+ void _remove(T v) {
513
+ final next = List<T>.from(widget.value)..remove(v);
514
+ widget.onChanged?.call(next);
515
+ _overlayEntry?.markNeedsBuild();
516
+ }
517
+
518
+ void _open() {
519
+ final renderBox = context.findRenderObject() as RenderBox;
520
+ final size = renderBox.size;
521
+
522
+ _overlayEntry = OverlayEntry(
523
+ builder: (_) => _MultiSelectOverlay<T>(
524
+ link: _layerLink,
525
+ triggerWidth: size.width,
526
+ elements: widget.elements,
527
+ selectedValues: widget.value,
528
+ onToggle: _toggleItem,
529
+ onDismiss: _close,
530
+ ),
531
+ );
532
+ Overlay.of(context).insert(_overlayEntry!);
533
+ setState(() => _isOpen = true);
534
+ }
535
+
536
+ void _close() {
537
+ _overlayEntry?.remove();
538
+ _overlayEntry = null;
539
+ if (mounted) setState(() => _isOpen = false);
540
+ }
541
+
542
+ void _toggle() {
543
+ if (_isOpen) {
544
+ _close();
545
+ } else {
546
+ _open();
547
+ }
548
+ }
549
+
550
+ @override
551
+ void dispose() {
552
+ _close();
553
+ super.dispose();
554
+ }
555
+
556
+ String _joinedLabel() {
557
+ final labels = <String>[];
558
+ for (final item in _flatItems()) {
559
+ if (widget.value.contains(item.value)) {
560
+ final c = item.child;
561
+ if (c is Text && c.data != null) {
562
+ labels.add(c.data!);
563
+ } else {
564
+ labels.add(item.value.toString());
565
+ }
566
+ }
567
+ }
568
+ return labels.join(', ');
569
+ }
570
+
571
+ @override
572
+ Widget build(BuildContext context) {
573
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
574
+ final colors = shUi.colors;
575
+ final disabled = !widget.enabled || widget.onChanged == null;
576
+
577
+ Color borderColor;
578
+ if (widget.invalid) {
579
+ borderColor = colors.danger;
580
+ } else if (_isOpen) {
581
+ borderColor = colors.foreground;
582
+ } else if (_hover && !disabled) {
583
+ borderColor = colors.foregroundMuted;
584
+ } else {
585
+ borderColor = colors.border;
586
+ }
587
+
588
+ Widget inner;
589
+ if (widget.value.isEmpty) {
590
+ inner = Text(
591
+ widget.placeholder ?? '',
592
+ style: TextStyle(
593
+ color: colors.foregroundMuted,
594
+ fontSize: shUi.text.sm,
595
+ ),
596
+ );
597
+ } else if (widget.chipsBuilder != null) {
598
+ inner = widget.chipsBuilder!(widget.value, _remove);
599
+ } else {
600
+ inner = Text(
601
+ _joinedLabel(),
602
+ overflow: TextOverflow.ellipsis,
603
+ style: TextStyle(
604
+ color: colors.foreground,
605
+ fontSize: shUi.text.sm,
606
+ ),
607
+ );
608
+ }
609
+
610
+ return CompositedTransformTarget(
611
+ link: _layerLink,
612
+ child: Opacity(
613
+ opacity: disabled ? shUi.opacity.disabled : 1,
614
+ child: MouseRegion(
615
+ cursor: disabled ? SystemMouseCursors.basic : SystemMouseCursors.click,
616
+ onEnter: (_) => setState(() => _hover = true),
617
+ onExit: (_) => setState(() => _hover = false),
618
+ child: GestureDetector(
619
+ onTap: disabled ? null : _toggle,
620
+ child: AnimatedContainer(
621
+ duration: shUi.duration.fast,
622
+ constraints: BoxConstraints(minHeight: shUi.control.md),
623
+ padding: EdgeInsets.symmetric(
624
+ horizontal: shUi.spacing.s3,
625
+ vertical: shUi.spacing.s1,
626
+ ),
627
+ decoration: BoxDecoration(
628
+ color: colors.background,
629
+ border: Border.all(
630
+ color: borderColor,
631
+ width: _isOpen ? shUi.borderWidth.strong : shUi.borderWidth.normal,
632
+ ),
633
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
634
+ ),
635
+ child: Row(
636
+ children: [
637
+ Expanded(child: inner),
638
+ Icon(
639
+ _isOpen
640
+ ? Icons.keyboard_arrow_up
641
+ : Icons.keyboard_arrow_down,
642
+ size: 18,
643
+ color: colors.foregroundMuted,
644
+ ),
645
+ ],
646
+ ),
647
+ ),
648
+ ),
649
+ ),
650
+ ),
651
+ );
652
+ }
653
+ }
654
+
655
+ class _MultiSelectOverlay<T> extends StatelessWidget {
656
+ final LayerLink link;
657
+ final double triggerWidth;
658
+ final List<ShUiSelectElement> elements;
659
+ final List<T> selectedValues;
660
+ final ValueChanged<T> onToggle;
661
+ final VoidCallback onDismiss;
662
+
663
+ const _MultiSelectOverlay({
664
+ required this.link,
665
+ required this.triggerWidth,
666
+ required this.elements,
667
+ required this.selectedValues,
668
+ required this.onToggle,
669
+ required this.onDismiss,
670
+ });
671
+
672
+ Widget _buildItem(ShUiTheme shUi, ShUiSelectItem item) {
673
+ final selected = selectedValues.contains(item.value);
674
+ return _MultiSelectItemTile(
675
+ item: item,
676
+ selected: selected,
677
+ colors: shUi.colors,
678
+ onToggle: (v) => onToggle(v as T),
679
+ );
680
+ }
681
+
682
+ List<Widget> _buildChildren(ShUiTheme shUi) {
683
+ final colors = shUi.colors;
684
+ final result = <Widget>[];
685
+
686
+ for (final el in elements) {
687
+ if (el is ShUiSelectItem) {
688
+ result.add(_buildItem(shUi, el));
689
+ } else if (el is ShUiSelectGroup) {
690
+ result.add(Padding(
691
+ padding: EdgeInsets.fromLTRB(
692
+ shUi.spacing.s3,
693
+ shUi.spacing.s2,
694
+ shUi.spacing.s3,
695
+ shUi.spacing.s1,
696
+ ),
697
+ child: Text(
698
+ el.label.toUpperCase(),
699
+ style: TextStyle(
700
+ color: colors.foregroundMuted,
701
+ fontSize: shUi.text.xs,
702
+ fontWeight: shUi.weight.semibold,
703
+ letterSpacing: 0.5,
704
+ ),
705
+ ),
706
+ ));
707
+ for (final item in el.items) {
708
+ result.add(_buildItem(shUi, item));
709
+ }
710
+ } else if (el is ShUiSelectSeparator) {
711
+ result.add(Padding(
712
+ padding: EdgeInsets.symmetric(vertical: shUi.spacing.s1),
713
+ child: Divider(
714
+ color: colors.border,
715
+ height: 1,
716
+ thickness: 1,
717
+ ),
718
+ ));
719
+ }
720
+ }
721
+ return result;
722
+ }
723
+
724
+ @override
725
+ Widget build(BuildContext context) {
726
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
727
+ final colors = shUi.colors;
728
+
729
+ return Stack(
730
+ children: [
731
+ Positioned.fill(
732
+ child: GestureDetector(
733
+ onTap: onDismiss,
734
+ behavior: HitTestBehavior.opaque,
735
+ child: const SizedBox.expand(),
736
+ ),
737
+ ),
738
+ CompositedTransformFollower(
739
+ link: link,
740
+ offset: const Offset(0, 44),
741
+ child: Material(
742
+ color: Colors.transparent,
743
+ child: Container(
744
+ width: triggerWidth,
745
+ constraints: const BoxConstraints(maxHeight: 240),
746
+ decoration: BoxDecoration(
747
+ color: colors.background,
748
+ border: Border.all(color: colors.border),
749
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
750
+ boxShadow: [
751
+ BoxShadow(
752
+ color: Colors.black.withValues(alpha: 0.08),
753
+ blurRadius: 8,
754
+ offset: const Offset(0, 4),
755
+ ),
756
+ ],
757
+ ),
758
+ child: ClipRRect(
759
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
760
+ child: ListView(
761
+ shrinkWrap: true,
762
+ padding: EdgeInsets.symmetric(vertical: shUi.spacing.s1),
763
+ children: _buildChildren(shUi),
764
+ ),
765
+ ),
766
+ ),
767
+ ),
768
+ ),
769
+ ],
770
+ );
771
+ }
772
+ }
773
+
774
+ class _MultiSelectItemTile extends StatefulWidget {
775
+ final ShUiSelectItem item;
776
+ final bool selected;
777
+ final ShUiColorTokens colors;
778
+ final ValueChanged<dynamic> onToggle;
779
+
780
+ const _MultiSelectItemTile({
781
+ required this.item,
782
+ required this.selected,
783
+ required this.colors,
784
+ required this.onToggle,
785
+ });
786
+
787
+ @override
788
+ State<_MultiSelectItemTile> createState() => _MultiSelectItemTileState();
789
+ }
790
+
791
+ class _MultiSelectItemTileState extends State<_MultiSelectItemTile> {
792
+ bool _hover = false;
793
+
794
+ @override
795
+ Widget build(BuildContext context) {
796
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
797
+ final c = widget.colors;
798
+ return MouseRegion(
799
+ onEnter: (_) => setState(() => _hover = true),
800
+ onExit: (_) => setState(() => _hover = false),
801
+ child: GestureDetector(
802
+ onTap: widget.item.enabled
803
+ ? () => widget.onToggle(widget.item.value)
804
+ : null,
805
+ child: Container(
806
+ padding: EdgeInsets.symmetric(
807
+ horizontal: shUi.spacing.s3,
808
+ vertical: shUi.spacing.s2,
809
+ ),
810
+ color: _hover ? c.backgroundSubtle : Colors.transparent,
811
+ child: Row(
812
+ children: [
813
+ // 체크박스 인디케이터 (왼쪽)
814
+ Container(
815
+ width: 16,
816
+ height: 16,
817
+ decoration: BoxDecoration(
818
+ color: widget.selected ? c.foreground : Colors.transparent,
819
+ border: Border.all(
820
+ color: widget.selected ? c.foreground : c.border,
821
+ width: shUi.borderWidth.normal,
822
+ ),
823
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius / 2),
824
+ ),
825
+ child: widget.selected
826
+ ? Icon(Icons.check, size: 12, color: c.background)
827
+ : null,
828
+ ),
829
+ SizedBox(width: shUi.spacing.s2),
830
+ Expanded(
831
+ child: DefaultTextStyle(
832
+ style: TextStyle(
833
+ color: c.foreground,
834
+ fontSize: shUi.text.sm,
835
+ ),
836
+ child: widget.item.child,
837
+ ),
838
+ ),
839
+ ],
840
+ ),
841
+ ),
842
+ ),
843
+ );
844
+ }
845
+ }
846
+
847
+ /// sh-ui SelectChip — MultiSelect/MultiCombobox 트리거에 표시되는 작은 칩.
848
+ ///
849
+ /// ShUiSelectChip(label: '사과', onRemove: () => handleRemove('apple'))
850
+ class ShUiSelectChip extends StatelessWidget {
851
+ final String label;
852
+ final VoidCallback? onRemove;
853
+
854
+ const ShUiSelectChip({
855
+ super.key,
856
+ required this.label,
857
+ this.onRemove,
858
+ });
859
+
860
+ @override
861
+ Widget build(BuildContext context) {
862
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
863
+ final c = shUi.colors;
864
+
865
+ return Container(
866
+ padding: EdgeInsets.symmetric(
867
+ horizontal: shUi.spacing.s2,
868
+ vertical: shUi.spacing.s1,
869
+ ),
870
+ decoration: BoxDecoration(
871
+ color: c.backgroundSubtle,
872
+ border: Border.all(color: c.border, width: shUi.borderWidth.normal),
873
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
874
+ ),
875
+ child: Row(
876
+ mainAxisSize: MainAxisSize.min,
877
+ children: [
878
+ Text(
879
+ label,
880
+ style: TextStyle(
881
+ color: c.foreground,
882
+ fontSize: shUi.text.xs,
883
+ ),
884
+ ),
885
+ if (onRemove != null) ...[
886
+ SizedBox(width: shUi.spacing.s1),
887
+ GestureDetector(
888
+ onTap: onRemove,
889
+ behavior: HitTestBehavior.opaque,
890
+ child: MouseRegion(
891
+ cursor: SystemMouseCursors.click,
892
+ child: Icon(
893
+ Icons.close,
894
+ size: 12,
895
+ color: c.foregroundMuted,
896
+ ),
897
+ ),
898
+ ),
899
+ ],
900
+ ],
901
+ ),
902
+ );
903
+ }
904
+ }