sh-ui-cli 0.14.0 → 0.21.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 (162) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +354 -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/registry.json +835 -0
  152. package/data/summaries/flutter.json +42 -0
  153. package/data/summaries/react.json +50 -0
  154. package/data/tokens/build.mjs +553 -0
  155. package/data/tokens/src/primitives.json +146 -0
  156. package/data/tokens/src/semantic.json +146 -0
  157. package/package.json +13 -4
  158. package/src/add.mjs +13 -12
  159. package/src/list.mjs +3 -11
  160. package/src/mcp.mjs +308 -0
  161. package/src/paths.mjs +52 -0
  162. package/src/remove.mjs +4 -11
@@ -0,0 +1,614 @@
1
+ import 'package:flutter/material.dart';
2
+ import '../foundation/sh_ui_tokens.dart';
3
+ import 'sh_ui_select.dart' show ShUiSelectChip;
4
+
5
+ /// sh-ui Combobox — 검색 가능한 드롭다운 선택.
6
+ ///
7
+ /// ShUiCombobox<String>(
8
+ /// value: selected,
9
+ /// onChanged: (v) => setState(() => selected = v),
10
+ /// items: fruits.map((f) => ShUiComboboxItem(value: f, label: f)).toList(),
11
+ /// placeholder: '과일 검색...',
12
+ /// )
13
+ class ShUiCombobox<T> extends StatefulWidget {
14
+ final T? value;
15
+ final ValueChanged<T?>? onChanged;
16
+ final List<ShUiComboboxItem<T>> items;
17
+ final String? placeholder;
18
+ final String? emptyText;
19
+ final bool enabled;
20
+
21
+ const ShUiCombobox({
22
+ super.key,
23
+ this.value,
24
+ this.onChanged,
25
+ required this.items,
26
+ this.placeholder,
27
+ this.emptyText = '일치하는 항목 없음',
28
+ this.enabled = true,
29
+ });
30
+
31
+ @override
32
+ State<ShUiCombobox<T>> createState() => _ShUiComboboxState<T>();
33
+ }
34
+
35
+ class _ShUiComboboxState<T> extends State<ShUiCombobox<T>> {
36
+ final LayerLink _layerLink = LayerLink();
37
+ final TextEditingController _textController = TextEditingController();
38
+ final FocusNode _focusNode = FocusNode();
39
+ OverlayEntry? _overlay;
40
+ bool _isOpen = false;
41
+ String _filter = '';
42
+
43
+ @override
44
+ void initState() {
45
+ super.initState();
46
+ _syncText();
47
+ _focusNode.addListener(_onFocusChange);
48
+ }
49
+
50
+ @override
51
+ void didUpdateWidget(covariant ShUiCombobox<T> old) {
52
+ super.didUpdateWidget(old);
53
+ if (widget.value != old.value) {
54
+ _syncText();
55
+ }
56
+ }
57
+
58
+ void _syncText() {
59
+ if (widget.value != null) {
60
+ for (final item in widget.items) {
61
+ if (item.value == widget.value) {
62
+ _textController.text = item.label;
63
+ return;
64
+ }
65
+ }
66
+ }
67
+ _textController.clear();
68
+ }
69
+
70
+ void _onFocusChange() {
71
+ if (_focusNode.hasFocus) {
72
+ _open();
73
+ }
74
+ }
75
+
76
+ @override
77
+ void dispose() {
78
+ _close();
79
+ _textController.dispose();
80
+ _focusNode.removeListener(_onFocusChange);
81
+ _focusNode.dispose();
82
+ super.dispose();
83
+ }
84
+
85
+ void _open() {
86
+ if (_overlay != null) return;
87
+ _filter = '';
88
+ _overlay = OverlayEntry(builder: (_) => _buildOverlay());
89
+ Overlay.of(context).insert(_overlay!);
90
+ setState(() => _isOpen = true);
91
+ }
92
+
93
+ void _close() {
94
+ _overlay?.remove();
95
+ _overlay = null;
96
+ if (mounted) {
97
+ setState(() => _isOpen = false);
98
+ _syncText();
99
+ }
100
+ }
101
+
102
+ void _select(ShUiComboboxItem<T> item) {
103
+ widget.onChanged?.call(item.value);
104
+ _focusNode.unfocus();
105
+ _close();
106
+ }
107
+
108
+ void _onTextChanged(String text) {
109
+ _filter = text.toLowerCase();
110
+ _overlay?.markNeedsBuild();
111
+ }
112
+
113
+ List<ShUiComboboxItem<T>> get _filteredItems {
114
+ if (_filter.isEmpty) return widget.items;
115
+ return widget.items
116
+ .where((item) => item.label.toLowerCase().contains(_filter))
117
+ .toList();
118
+ }
119
+
120
+ Widget _buildOverlay() {
121
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
122
+ final colors = shUi.colors;
123
+ final renderBox = context.findRenderObject() as RenderBox;
124
+ final width = renderBox.size.width;
125
+ final filtered = _filteredItems;
126
+
127
+ return Stack(
128
+ children: [
129
+ Positioned.fill(
130
+ child: GestureDetector(
131
+ onTap: _close,
132
+ behavior: HitTestBehavior.opaque,
133
+ child: const SizedBox.expand(),
134
+ ),
135
+ ),
136
+ CompositedTransformFollower(
137
+ link: _layerLink,
138
+ offset: const Offset(0, 44),
139
+ child: Material(
140
+ color: Colors.transparent,
141
+ child: Container(
142
+ width: width,
143
+ constraints: const BoxConstraints(maxHeight: 240),
144
+ decoration: BoxDecoration(
145
+ color: colors.background,
146
+ border: Border.all(color: colors.border),
147
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
148
+ boxShadow: [
149
+ BoxShadow(
150
+ color: Colors.black.withValues(alpha: 0.08),
151
+ blurRadius: 8,
152
+ offset: const Offset(0, 4),
153
+ ),
154
+ ],
155
+ ),
156
+ child: ClipRRect(
157
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
158
+ child: filtered.isEmpty
159
+ ? Padding(
160
+ padding: EdgeInsets.all(shUi.spacing.s3),
161
+ child: Text(
162
+ widget.emptyText ?? '',
163
+ style: TextStyle(
164
+ color: colors.foregroundMuted,
165
+ fontSize: shUi.text.sm,
166
+ ),
167
+ ),
168
+ )
169
+ : ListView(
170
+ shrinkWrap: true,
171
+ padding: EdgeInsets.symmetric(vertical: shUi.spacing.s1),
172
+ children: filtered.map((item) {
173
+ final selected = item.value == widget.value;
174
+ return _ComboboxItemTile<T>(
175
+ item: item,
176
+ selected: selected,
177
+ colors: colors,
178
+ onSelect: () => _select(item),
179
+ );
180
+ }).toList(),
181
+ ),
182
+ ),
183
+ ),
184
+ ),
185
+ ),
186
+ ],
187
+ );
188
+ }
189
+
190
+ @override
191
+ Widget build(BuildContext context) {
192
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
193
+ final colors = shUi.colors;
194
+ final disabled = !widget.enabled;
195
+
196
+ return CompositedTransformTarget(
197
+ link: _layerLink,
198
+ child: Opacity(
199
+ opacity: disabled ? shUi.opacity.disabled : 1,
200
+ child: Container(
201
+ height: shUi.control.md,
202
+ padding: EdgeInsets.symmetric(horizontal: shUi.spacing.s3),
203
+ decoration: BoxDecoration(
204
+ color: colors.background,
205
+ border: Border.all(
206
+ color: _isOpen ? colors.foreground : colors.border,
207
+ width: _isOpen ? shUi.borderWidth.strong : shUi.borderWidth.normal,
208
+ ),
209
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
210
+ ),
211
+ child: Row(
212
+ children: [
213
+ Expanded(
214
+ child: TextField(
215
+ controller: _textController,
216
+ focusNode: _focusNode,
217
+ enabled: !disabled,
218
+ onChanged: _onTextChanged,
219
+ style: TextStyle(
220
+ color: colors.foreground,
221
+ fontSize: shUi.text.sm,
222
+ ),
223
+ decoration: InputDecoration(
224
+ isCollapsed: true,
225
+ contentPadding: EdgeInsets.zero,
226
+ border: InputBorder.none,
227
+ hintText: widget.placeholder,
228
+ hintStyle: TextStyle(
229
+ color: colors.foregroundMuted,
230
+ fontSize: shUi.text.sm,
231
+ ),
232
+ ),
233
+ ),
234
+ ),
235
+ Icon(
236
+ Icons.unfold_more,
237
+ size: 16,
238
+ color: colors.foregroundMuted,
239
+ ),
240
+ ],
241
+ ),
242
+ ),
243
+ ),
244
+ );
245
+ }
246
+ }
247
+
248
+ class ShUiComboboxItem<T> {
249
+ final T value;
250
+ final String label;
251
+
252
+ const ShUiComboboxItem({required this.value, required this.label});
253
+ }
254
+
255
+ class _ComboboxItemTile<T> extends StatefulWidget {
256
+ final ShUiComboboxItem<T> item;
257
+ final bool selected;
258
+ final ShUiColorTokens colors;
259
+ final VoidCallback onSelect;
260
+
261
+ const _ComboboxItemTile({
262
+ required this.item,
263
+ required this.selected,
264
+ required this.colors,
265
+ required this.onSelect,
266
+ });
267
+
268
+ @override
269
+ State<_ComboboxItemTile<T>> createState() => _ComboboxItemTileState<T>();
270
+ }
271
+
272
+ class _ComboboxItemTileState<T> extends State<_ComboboxItemTile<T>> {
273
+ bool _hover = false;
274
+
275
+ @override
276
+ Widget build(BuildContext context) {
277
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
278
+ return MouseRegion(
279
+ onEnter: (_) => setState(() => _hover = true),
280
+ onExit: (_) => setState(() => _hover = false),
281
+ child: GestureDetector(
282
+ onTap: widget.onSelect,
283
+ child: Container(
284
+ padding: EdgeInsets.symmetric(horizontal: shUi.spacing.s3, vertical: shUi.spacing.s2),
285
+ color: _hover ? widget.colors.backgroundSubtle : Colors.transparent,
286
+ child: Row(
287
+ children: [
288
+ SizedBox(
289
+ width: shUi.spacing.s5,
290
+ child: widget.selected
291
+ ? Icon(Icons.check, size: 14, color: widget.colors.foreground)
292
+ : null,
293
+ ),
294
+ SizedBox(width: shUi.spacing.s1),
295
+ Expanded(
296
+ child: Text(
297
+ widget.item.label,
298
+ style: TextStyle(
299
+ color: widget.colors.foreground,
300
+ fontSize: shUi.text.sm,
301
+ ),
302
+ ),
303
+ ),
304
+ ],
305
+ ),
306
+ ),
307
+ ),
308
+ );
309
+ }
310
+ }
311
+
312
+ /// sh-ui MultiCombobox — 다중 선택 + 검색 가능한 드롭다운.
313
+ ///
314
+ /// React의 `<Combobox multiple>` + `ComboboxChips` 에 대응.
315
+ ///
316
+ /// 기본 사용:
317
+ /// ShUiMultiCombobox<String>(
318
+ /// value: selected,
319
+ /// onChanged: (v) => setState(() => selected = v),
320
+ /// items: cities.map((c) => ShUiComboboxItem(value: c, label: c)).toList(),
321
+ /// placeholder: '도시 추가...',
322
+ /// )
323
+ ///
324
+ /// 칩 커스터마이즈: [chipsBuilder]. 생략하면 기본 칩이 입력 위에 표시된다.
325
+ class ShUiMultiCombobox<T> extends StatefulWidget {
326
+ final List<T> value;
327
+ final ValueChanged<List<T>>? onChanged;
328
+ final List<ShUiComboboxItem<T>> items;
329
+ final String? placeholder;
330
+ final String? emptyText;
331
+ final bool enabled;
332
+ final bool invalid;
333
+
334
+ /// 트리거 내부 input 위에 표시될 칩 영역 커스텀 렌더러.
335
+ /// 지정하지 않으면 [ShUiSelectChip]으로 자동 렌더.
336
+ final Widget Function(List<T> values, void Function(T) onRemove)? chipsBuilder;
337
+
338
+ const ShUiMultiCombobox({
339
+ super.key,
340
+ required this.value,
341
+ required this.onChanged,
342
+ required this.items,
343
+ this.placeholder,
344
+ this.emptyText = '일치하는 항목 없음',
345
+ this.enabled = true,
346
+ this.invalid = false,
347
+ this.chipsBuilder,
348
+ });
349
+
350
+ @override
351
+ State<ShUiMultiCombobox<T>> createState() => _ShUiMultiComboboxState<T>();
352
+ }
353
+
354
+ class _ShUiMultiComboboxState<T> extends State<ShUiMultiCombobox<T>> {
355
+ final LayerLink _layerLink = LayerLink();
356
+ final TextEditingController _textController = TextEditingController();
357
+ final FocusNode _focusNode = FocusNode();
358
+ OverlayEntry? _overlay;
359
+ bool _isOpen = false;
360
+ String _filter = '';
361
+
362
+ @override
363
+ void initState() {
364
+ super.initState();
365
+ _focusNode.addListener(_onFocusChange);
366
+ }
367
+
368
+ @override
369
+ void dispose() {
370
+ _close();
371
+ _textController.dispose();
372
+ _focusNode.removeListener(_onFocusChange);
373
+ _focusNode.dispose();
374
+ super.dispose();
375
+ }
376
+
377
+ void _onFocusChange() {
378
+ if (_focusNode.hasFocus) {
379
+ _open();
380
+ }
381
+ }
382
+
383
+ void _open() {
384
+ if (_overlay != null) return;
385
+ _overlay = OverlayEntry(builder: (_) => _buildOverlay());
386
+ Overlay.of(context).insert(_overlay!);
387
+ setState(() => _isOpen = true);
388
+ }
389
+
390
+ void _close() {
391
+ _overlay?.remove();
392
+ _overlay = null;
393
+ if (mounted) {
394
+ setState(() {
395
+ _isOpen = false;
396
+ _filter = '';
397
+ });
398
+ _textController.clear();
399
+ }
400
+ }
401
+
402
+ void _toggle(ShUiComboboxItem<T> item) {
403
+ final next = List<T>.from(widget.value);
404
+ if (next.contains(item.value)) {
405
+ next.remove(item.value);
406
+ } else {
407
+ next.add(item.value);
408
+ }
409
+ widget.onChanged?.call(next);
410
+ _overlay?.markNeedsBuild();
411
+ }
412
+
413
+ void _remove(T v) {
414
+ final next = List<T>.from(widget.value)..remove(v);
415
+ widget.onChanged?.call(next);
416
+ if (mounted) setState(() {});
417
+ _overlay?.markNeedsBuild();
418
+ }
419
+
420
+ void _onTextChanged(String text) {
421
+ _filter = text.toLowerCase();
422
+ _overlay?.markNeedsBuild();
423
+ }
424
+
425
+ List<ShUiComboboxItem<T>> get _filteredItems {
426
+ if (_filter.isEmpty) return widget.items;
427
+ return widget.items
428
+ .where((item) => item.label.toLowerCase().contains(_filter))
429
+ .toList();
430
+ }
431
+
432
+ String _labelFor(T v) {
433
+ for (final item in widget.items) {
434
+ if (item.value == v) return item.label;
435
+ }
436
+ return v.toString();
437
+ }
438
+
439
+ Widget _buildOverlay() {
440
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
441
+ final colors = shUi.colors;
442
+ final renderBox = context.findRenderObject() as RenderBox?;
443
+ if (renderBox == null) return const SizedBox.shrink();
444
+ final width = renderBox.size.width;
445
+ final height = renderBox.size.height;
446
+ final filtered = _filteredItems;
447
+
448
+ return Stack(
449
+ children: [
450
+ Positioned.fill(
451
+ child: GestureDetector(
452
+ onTap: _close,
453
+ behavior: HitTestBehavior.opaque,
454
+ child: const SizedBox.expand(),
455
+ ),
456
+ ),
457
+ CompositedTransformFollower(
458
+ link: _layerLink,
459
+ offset: Offset(0, height + 4),
460
+ child: Material(
461
+ color: Colors.transparent,
462
+ child: Container(
463
+ width: width,
464
+ constraints: const BoxConstraints(maxHeight: 240),
465
+ decoration: BoxDecoration(
466
+ color: colors.background,
467
+ border: Border.all(color: colors.border),
468
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
469
+ boxShadow: [
470
+ BoxShadow(
471
+ color: Colors.black.withValues(alpha: 0.08),
472
+ blurRadius: 8,
473
+ offset: const Offset(0, 4),
474
+ ),
475
+ ],
476
+ ),
477
+ child: ClipRRect(
478
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
479
+ child: filtered.isEmpty
480
+ ? Padding(
481
+ padding: EdgeInsets.all(shUi.spacing.s3),
482
+ child: Text(
483
+ widget.emptyText ?? '',
484
+ style: TextStyle(
485
+ color: colors.foregroundMuted,
486
+ fontSize: shUi.text.sm,
487
+ ),
488
+ ),
489
+ )
490
+ : ListView(
491
+ shrinkWrap: true,
492
+ padding: EdgeInsets.symmetric(vertical: shUi.spacing.s1),
493
+ children: filtered.map((item) {
494
+ final selected = widget.value.contains(item.value);
495
+ return _ComboboxItemTile<T>(
496
+ item: item,
497
+ selected: selected,
498
+ colors: colors,
499
+ onSelect: () => _toggle(item),
500
+ );
501
+ }).toList(),
502
+ ),
503
+ ),
504
+ ),
505
+ ),
506
+ ),
507
+ ],
508
+ );
509
+ }
510
+
511
+ @override
512
+ Widget build(BuildContext context) {
513
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
514
+ final colors = shUi.colors;
515
+ final disabled = !widget.enabled || widget.onChanged == null;
516
+
517
+ Color borderColor;
518
+ if (widget.invalid) {
519
+ borderColor = colors.danger;
520
+ } else if (_isOpen) {
521
+ borderColor = colors.foreground;
522
+ } else {
523
+ borderColor = colors.border;
524
+ }
525
+
526
+ final hasChips = widget.value.isNotEmpty;
527
+ final chipsChild = hasChips
528
+ ? (widget.chipsBuilder != null
529
+ ? widget.chipsBuilder!(widget.value, _remove)
530
+ : Wrap(
531
+ spacing: shUi.spacing.s1,
532
+ runSpacing: shUi.spacing.s1,
533
+ children: widget.value
534
+ .map(
535
+ (v) => ShUiSelectChip(
536
+ label: _labelFor(v),
537
+ onRemove: () => _remove(v),
538
+ ),
539
+ )
540
+ .toList(),
541
+ ))
542
+ : null;
543
+
544
+ return CompositedTransformTarget(
545
+ link: _layerLink,
546
+ child: Opacity(
547
+ opacity: disabled ? shUi.opacity.disabled : 1,
548
+ child: AnimatedContainer(
549
+ duration: shUi.duration.fast,
550
+ constraints: BoxConstraints(minHeight: shUi.control.md),
551
+ padding: EdgeInsets.symmetric(
552
+ horizontal: shUi.spacing.s3,
553
+ vertical: shUi.spacing.s1,
554
+ ),
555
+ decoration: BoxDecoration(
556
+ color: colors.background,
557
+ border: Border.all(
558
+ color: borderColor,
559
+ width: _isOpen ? shUi.borderWidth.strong : shUi.borderWidth.normal,
560
+ ),
561
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
562
+ ),
563
+ child: Column(
564
+ mainAxisSize: MainAxisSize.min,
565
+ crossAxisAlignment: CrossAxisAlignment.stretch,
566
+ children: [
567
+ if (chipsChild != null)
568
+ Padding(
569
+ padding: EdgeInsets.only(
570
+ top: shUi.spacing.s1,
571
+ bottom: shUi.spacing.s1,
572
+ ),
573
+ child: chipsChild,
574
+ ),
575
+ Row(
576
+ children: [
577
+ Expanded(
578
+ child: TextField(
579
+ controller: _textController,
580
+ focusNode: _focusNode,
581
+ enabled: !disabled,
582
+ onChanged: _onTextChanged,
583
+ style: TextStyle(
584
+ color: colors.foreground,
585
+ fontSize: shUi.text.sm,
586
+ ),
587
+ decoration: InputDecoration(
588
+ isCollapsed: true,
589
+ contentPadding: EdgeInsets.symmetric(
590
+ vertical: shUi.spacing.s2,
591
+ ),
592
+ border: InputBorder.none,
593
+ hintText: widget.placeholder,
594
+ hintStyle: TextStyle(
595
+ color: colors.foregroundMuted,
596
+ fontSize: shUi.text.sm,
597
+ ),
598
+ ),
599
+ ),
600
+ ),
601
+ Icon(
602
+ Icons.unfold_more,
603
+ size: 16,
604
+ color: colors.foregroundMuted,
605
+ ),
606
+ ],
607
+ ),
608
+ ],
609
+ ),
610
+ ),
611
+ ),
612
+ );
613
+ }
614
+ }
@@ -0,0 +1,71 @@
1
+ import 'package:flutter/material.dart';
2
+ import '../foundation/sh_ui_tokens.dart';
3
+ import 'sh_ui_dropdown_menu.dart';
4
+
5
+ /// sh-ui ContextMenu — 우클릭(데스크탑) / long-press(모바일)로 열리는 메뉴.
6
+ ///
7
+ /// DropdownMenu와 같은 항목 타입(ShUiDropdownMenu* 계열)을 그대로 사용한다.
8
+ ///
9
+ /// ShUiContextMenu<String>(
10
+ /// items: const [
11
+ /// ShUiDropdownMenuItem(value: 'copy', label: '복사'),
12
+ /// ShUiDropdownMenuItem(value: 'paste', label: '붙여넣기'),
13
+ /// ShUiDropdownMenuDivider(),
14
+ /// ShUiDropdownMenuItem(value: 'delete', label: '삭제'),
15
+ /// ],
16
+ /// onSelected: (value) => ...,
17
+ /// child: Container(... 우클릭 감지할 영역 ...),
18
+ /// )
19
+ class ShUiContextMenu<T> extends StatelessWidget {
20
+ /// 우클릭/long-press 감지 영역 위젯.
21
+ final Widget child;
22
+
23
+ final List<ShUiDropdownMenuEntry<T>> items;
24
+ final ValueChanged<T>? onSelected;
25
+
26
+ const ShUiContextMenu({
27
+ super.key,
28
+ required this.child,
29
+ required this.items,
30
+ this.onSelected,
31
+ });
32
+
33
+ Future<void> _showAt(BuildContext context, Offset globalPosition) async {
34
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
35
+ final colors = shUi.colors;
36
+ final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
37
+
38
+ final selected = await showMenu<T>(
39
+ context: context,
40
+ position: RelativeRect.fromRect(
41
+ Rect.fromPoints(globalPosition, globalPosition),
42
+ Offset.zero & overlay.size,
43
+ ),
44
+ color: colors.background,
45
+ surfaceTintColor: Colors.transparent,
46
+ shadowColor: Colors.black.withValues(alpha: 0.12),
47
+ elevation: 4,
48
+ shape: RoundedRectangleBorder(
49
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
50
+ side: BorderSide(color: colors.border),
51
+ ),
52
+ items: items.map((e) => e.toPopupMenuEntry(context)).toList(),
53
+ );
54
+
55
+ if (selected != null) onSelected?.call(selected);
56
+ }
57
+
58
+ @override
59
+ Widget build(BuildContext context) {
60
+ return GestureDetector(
61
+ behavior: HitTestBehavior.opaque,
62
+ onSecondaryTapDown: (details) {
63
+ _showAt(context, details.globalPosition);
64
+ },
65
+ onLongPressStart: (details) {
66
+ _showAt(context, details.globalPosition);
67
+ },
68
+ child: child,
69
+ );
70
+ }
71
+ }