sh-ui-cli 0.15.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 +9 -2
  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,362 @@
1
+ import 'dart:async';
2
+ import 'package:flutter/material.dart';
3
+ import '../foundation/sh_ui_tokens.dart';
4
+
5
+ enum ShUiToastVariant { defaultVariant, success, danger, warning }
6
+
7
+ enum ShUiToastPosition {
8
+ topLeft,
9
+ topRight,
10
+ topCenter,
11
+ bottomLeft,
12
+ bottomRight,
13
+ bottomCenter,
14
+ }
15
+
16
+ /// 토스트 데이터.
17
+ class _ToastItem {
18
+ final String id;
19
+ final String? title;
20
+ final String? description;
21
+ final ShUiToastVariant variant;
22
+ final Duration duration;
23
+ final Widget? action;
24
+
25
+ const _ToastItem({
26
+ required this.id,
27
+ this.title,
28
+ this.description,
29
+ required this.variant,
30
+ required this.duration,
31
+ this.action,
32
+ });
33
+ }
34
+
35
+ /// 토스트 입력.
36
+ class ShUiToastInput {
37
+ final String? title;
38
+ final String? description;
39
+ final ShUiToastVariant variant;
40
+ final Duration duration;
41
+ final Widget? action;
42
+
43
+ const ShUiToastInput({
44
+ this.title,
45
+ this.description,
46
+ this.variant = ShUiToastVariant.defaultVariant,
47
+ this.duration = const Duration(seconds: 4),
48
+ this.action,
49
+ });
50
+ }
51
+
52
+ /// sh-ui Toast — 전역 토스트 알림 시스템.
53
+ ///
54
+ /// 사용법:
55
+ /// 1) 앱 최상위에 ShUiToaster를 배치.
56
+ /// MaterialApp(builder: (_, child) => ShUiToaster(child: child!))
57
+ ///
58
+ /// 2) 어디서든 호출.
59
+ /// ShUiToast.show(context, description: '저장됨');
60
+ /// ShUiToast.success(context, description: '완료!');
61
+ /// ShUiToast.danger(context, description: '오류 발생');
62
+ /// ShUiToast.warning(context, description: '주의');
63
+ class ShUiToast {
64
+ ShUiToast._();
65
+
66
+ static String show(
67
+ BuildContext context, {
68
+ String? title,
69
+ String? description,
70
+ ShUiToastVariant variant = ShUiToastVariant.defaultVariant,
71
+ Duration duration = const Duration(seconds: 4),
72
+ Widget? action,
73
+ }) {
74
+ final state = _ShUiToasterState._of(context);
75
+ return state._add(ShUiToastInput(
76
+ title: title,
77
+ description: description,
78
+ variant: variant,
79
+ duration: duration,
80
+ action: action,
81
+ ));
82
+ }
83
+
84
+ static String success(BuildContext context, {String? title, String? description}) =>
85
+ show(context, title: title, description: description, variant: ShUiToastVariant.success);
86
+
87
+ static String danger(BuildContext context, {String? title, String? description}) =>
88
+ show(context, title: title, description: description, variant: ShUiToastVariant.danger);
89
+
90
+ static String warning(BuildContext context, {String? title, String? description}) =>
91
+ show(context, title: title, description: description, variant: ShUiToastVariant.warning);
92
+
93
+ static void dismiss(BuildContext context, String id) {
94
+ final state = _ShUiToasterState._of(context);
95
+ state._remove(id);
96
+ }
97
+ }
98
+
99
+ /// 토스트를 렌더링하는 Toaster 위젯. 앱 최상위에 배치.
100
+ class ShUiToaster extends StatefulWidget {
101
+ final Widget child;
102
+ final ShUiToastPosition position;
103
+
104
+ const ShUiToaster({
105
+ super.key,
106
+ required this.child,
107
+ this.position = ShUiToastPosition.bottomRight,
108
+ });
109
+
110
+ @override
111
+ State<ShUiToaster> createState() => _ShUiToasterState();
112
+ }
113
+
114
+ class _ShUiToasterState extends State<ShUiToaster> {
115
+ final List<_ToastItem> _toasts = [];
116
+ int _counter = 0;
117
+
118
+ static _ShUiToasterState _of(BuildContext context) {
119
+ final state = context.findAncestorStateOfType<_ShUiToasterState>();
120
+ if (state == null) {
121
+ throw FlutterError('ShUiToaster가 위젯 트리에 없습니다. 앱 최상위에 ShUiToaster를 배치하세요.');
122
+ }
123
+ return state;
124
+ }
125
+
126
+ String _add(ShUiToastInput input) {
127
+ final id = 'sh-toast-${++_counter}';
128
+ setState(() {
129
+ _toasts.add(_ToastItem(
130
+ id: id,
131
+ title: input.title,
132
+ description: input.description,
133
+ variant: input.variant,
134
+ duration: input.duration,
135
+ action: input.action,
136
+ ));
137
+ });
138
+ return id;
139
+ }
140
+
141
+ void _remove(String id) {
142
+ setState(() {
143
+ _toasts.removeWhere((t) => t.id == id);
144
+ });
145
+ }
146
+
147
+ Alignment _positionAlignment() {
148
+ switch (widget.position) {
149
+ case ShUiToastPosition.topLeft:
150
+ return Alignment.topLeft;
151
+ case ShUiToastPosition.topRight:
152
+ return Alignment.topRight;
153
+ case ShUiToastPosition.topCenter:
154
+ return Alignment.topCenter;
155
+ case ShUiToastPosition.bottomLeft:
156
+ return Alignment.bottomLeft;
157
+ case ShUiToastPosition.bottomRight:
158
+ return Alignment.bottomRight;
159
+ case ShUiToastPosition.bottomCenter:
160
+ return Alignment.bottomCenter;
161
+ }
162
+ }
163
+
164
+ @override
165
+ Widget build(BuildContext context) {
166
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
167
+ return Stack(
168
+ children: [
169
+ widget.child,
170
+ if (_toasts.isNotEmpty)
171
+ Positioned.fill(
172
+ child: IgnorePointer(
173
+ ignoring: false,
174
+ child: Align(
175
+ alignment: _positionAlignment(),
176
+ child: Padding(
177
+ padding: EdgeInsets.all(shUi.spacing.s4),
178
+ child: Column(
179
+ mainAxisSize: MainAxisSize.min,
180
+ crossAxisAlignment: CrossAxisAlignment.end,
181
+ children: _toasts.map((item) {
182
+ return Padding(
183
+ padding: EdgeInsets.only(bottom: shUi.spacing.s2),
184
+ child: _ShUiToastCard(
185
+ item: item,
186
+ onDismiss: () => _remove(item.id),
187
+ ),
188
+ );
189
+ }).toList(),
190
+ ),
191
+ ),
192
+ ),
193
+ ),
194
+ ),
195
+ ],
196
+ );
197
+ }
198
+ }
199
+
200
+ class _ShUiToastCard extends StatefulWidget {
201
+ final _ToastItem item;
202
+ final VoidCallback onDismiss;
203
+
204
+ const _ShUiToastCard({required this.item, required this.onDismiss});
205
+
206
+ @override
207
+ State<_ShUiToastCard> createState() => _ShUiToastCardState();
208
+ }
209
+
210
+ class _ShUiToastCardState extends State<_ShUiToastCard>
211
+ with SingleTickerProviderStateMixin {
212
+ late final AnimationController _controller;
213
+ late final Animation<double> _opacity;
214
+ late final Animation<Offset> _slide;
215
+ Timer? _timer;
216
+
217
+ @override
218
+ void initState() {
219
+ super.initState();
220
+ _controller = AnimationController(
221
+ vsync: this,
222
+ duration: ShUiTheme.light.duration.slow,
223
+ );
224
+ _opacity = Tween<double>(begin: 0, end: 1).animate(
225
+ CurvedAnimation(parent: _controller, curve: Curves.easeOut),
226
+ );
227
+ _slide = Tween<Offset>(
228
+ begin: const Offset(0, 0.3),
229
+ end: Offset.zero,
230
+ ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
231
+
232
+ _controller.forward();
233
+
234
+ if (widget.item.duration.inMilliseconds > 0) {
235
+ _timer = Timer(widget.item.duration, _exit);
236
+ }
237
+ }
238
+
239
+ void _exit() {
240
+ _controller.reverse().then((_) {
241
+ if (mounted) widget.onDismiss();
242
+ });
243
+ }
244
+
245
+ @override
246
+ void dispose() {
247
+ _timer?.cancel();
248
+ _controller.dispose();
249
+ super.dispose();
250
+ }
251
+
252
+ Color _variantColor(ShUiColorTokens colors) {
253
+ switch (widget.item.variant) {
254
+ case ShUiToastVariant.defaultVariant:
255
+ return colors.foreground;
256
+ case ShUiToastVariant.success:
257
+ return const Color(0xFF22C55E);
258
+ case ShUiToastVariant.danger:
259
+ return colors.danger;
260
+ case ShUiToastVariant.warning:
261
+ return const Color(0xFFF59E0B);
262
+ }
263
+ }
264
+
265
+ IconData? _variantIcon() {
266
+ switch (widget.item.variant) {
267
+ case ShUiToastVariant.defaultVariant:
268
+ return null;
269
+ case ShUiToastVariant.success:
270
+ return Icons.check_circle_outline;
271
+ case ShUiToastVariant.danger:
272
+ return Icons.cancel_outlined;
273
+ case ShUiToastVariant.warning:
274
+ return Icons.warning_amber_rounded;
275
+ }
276
+ }
277
+
278
+ @override
279
+ Widget build(BuildContext context) {
280
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
281
+ final colors = shUi.colors;
282
+ final variantColor = _variantColor(colors);
283
+ final icon = _variantIcon();
284
+
285
+ return SlideTransition(
286
+ position: _slide,
287
+ child: FadeTransition(
288
+ opacity: _opacity,
289
+ child: Container(
290
+ constraints: const BoxConstraints(maxWidth: 360, minWidth: 280),
291
+ padding: EdgeInsets.symmetric(horizontal: shUi.spacing.s4, vertical: shUi.spacing.s3),
292
+ decoration: BoxDecoration(
293
+ color: colors.background,
294
+ border: Border.all(color: colors.border),
295
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
296
+ boxShadow: [
297
+ BoxShadow(
298
+ color: Colors.black.withValues(alpha: 0.1),
299
+ blurRadius: 12,
300
+ offset: const Offset(0, 4),
301
+ ),
302
+ ],
303
+ ),
304
+ child: Row(
305
+ crossAxisAlignment: CrossAxisAlignment.start,
306
+ children: [
307
+ if (icon != null) ...[
308
+ Icon(icon, size: 18, color: variantColor),
309
+ const SizedBox(width: 10),
310
+ ],
311
+ Expanded(
312
+ child: Column(
313
+ crossAxisAlignment: CrossAxisAlignment.start,
314
+ mainAxisSize: MainAxisSize.min,
315
+ children: [
316
+ if (widget.item.title != null)
317
+ Text(
318
+ widget.item.title!,
319
+ style: TextStyle(
320
+ color: colors.foreground,
321
+ fontSize: shUi.text.sm,
322
+ fontWeight: shUi.weight.semibold,
323
+ height: 1.3,
324
+ ),
325
+ ),
326
+ if (widget.item.description != null) ...[
327
+ if (widget.item.title != null) const SizedBox(height: 2),
328
+ Text(
329
+ widget.item.description!,
330
+ style: TextStyle(
331
+ color: colors.foregroundMuted,
332
+ fontSize: 13,
333
+ height: 1.4,
334
+ ),
335
+ ),
336
+ ],
337
+ ],
338
+ ),
339
+ ),
340
+ if (widget.item.action != null) ...[
341
+ SizedBox(width: shUi.spacing.s2),
342
+ widget.item.action!,
343
+ ],
344
+ SizedBox(width: shUi.spacing.s1),
345
+ GestureDetector(
346
+ onTap: _exit,
347
+ child: Text(
348
+ '×',
349
+ style: TextStyle(
350
+ color: colors.foregroundMuted,
351
+ fontSize: shUi.text.base,
352
+ height: 1,
353
+ ),
354
+ ),
355
+ ),
356
+ ],
357
+ ),
358
+ ),
359
+ ),
360
+ );
361
+ }
362
+ }
@@ -0,0 +1,229 @@
1
+ import 'package:flutter/material.dart';
2
+ import '../foundation/sh_ui_tokens.dart';
3
+
4
+ enum ShUiToggleVariant { outline, ghost }
5
+
6
+ enum ShUiToggleSize { sm, md, lg }
7
+
8
+ enum ShUiToggleOrientation { horizontal, vertical }
9
+
10
+ /// sh-ui Toggle — 눌러서 on/off 전환하는 토글 버튼.
11
+ class ShUiToggle extends StatefulWidget {
12
+ final bool pressed;
13
+ final ValueChanged<bool>? onPressedChange;
14
+ final ShUiToggleVariant variant;
15
+ final ShUiToggleSize size;
16
+ final Widget child;
17
+
18
+ const ShUiToggle({
19
+ super.key,
20
+ this.pressed = false,
21
+ this.onPressedChange,
22
+ this.variant = ShUiToggleVariant.ghost,
23
+ this.size = ShUiToggleSize.md,
24
+ required this.child,
25
+ });
26
+
27
+ @override
28
+ State<ShUiToggle> createState() => _ShUiToggleState();
29
+ }
30
+
31
+ class _ShUiToggleState extends State<ShUiToggle> {
32
+ bool _hover = false;
33
+
34
+ EdgeInsets _paddingOf(ShUiTheme shUi) => switch (widget.size) {
35
+ ShUiToggleSize.sm => EdgeInsets.symmetric(horizontal: shUi.spacing.s2),
36
+ ShUiToggleSize.md => EdgeInsets.symmetric(horizontal: shUi.spacing.s3),
37
+ ShUiToggleSize.lg => EdgeInsets.symmetric(horizontal: shUi.spacing.s4),
38
+ };
39
+
40
+ double _heightOf(ShUiTheme shUi) => switch (widget.size) {
41
+ ShUiToggleSize.sm => shUi.control.sm,
42
+ ShUiToggleSize.md => shUi.control.md,
43
+ ShUiToggleSize.lg => shUi.control.lg,
44
+ };
45
+
46
+ double _fontSizeOf(ShUiTheme shUi) => switch (widget.size) {
47
+ ShUiToggleSize.sm => shUi.text.sm,
48
+ ShUiToggleSize.md => shUi.text.sm,
49
+ ShUiToggleSize.lg => shUi.text.base,
50
+ };
51
+
52
+ @override
53
+ Widget build(BuildContext context) {
54
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
55
+ final colors = shUi.colors;
56
+ final disabled = widget.onPressedChange == null;
57
+
58
+ Color bg;
59
+ Color borderColor;
60
+
61
+ if (widget.pressed) {
62
+ bg = colors.backgroundMuted;
63
+ borderColor = widget.variant == ShUiToggleVariant.outline
64
+ ? colors.border
65
+ : Colors.transparent;
66
+ } else if (_hover && !disabled) {
67
+ bg = colors.backgroundSubtle;
68
+ borderColor = widget.variant == ShUiToggleVariant.outline
69
+ ? colors.border
70
+ : Colors.transparent;
71
+ } else {
72
+ bg = Colors.transparent;
73
+ borderColor = widget.variant == ShUiToggleVariant.outline
74
+ ? colors.border
75
+ : Colors.transparent;
76
+ }
77
+
78
+ return Opacity(
79
+ opacity: disabled ? shUi.opacity.disabled : 1,
80
+ child: MouseRegion(
81
+ cursor: disabled ? SystemMouseCursors.basic : SystemMouseCursors.click,
82
+ onEnter: (_) => setState(() => _hover = true),
83
+ onExit: (_) => setState(() => _hover = false),
84
+ child: GestureDetector(
85
+ onTap: disabled
86
+ ? null
87
+ : () => widget.onPressedChange!(!widget.pressed),
88
+ child: AnimatedContainer(
89
+ duration: shUi.duration.fast,
90
+ height: _heightOf(shUi),
91
+ padding: _paddingOf(shUi),
92
+ decoration: BoxDecoration(
93
+ color: bg,
94
+ border: Border.all(color: borderColor),
95
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
96
+ ),
97
+ alignment: Alignment.center,
98
+ child: DefaultTextStyle(
99
+ style: TextStyle(
100
+ color: widget.pressed ? colors.foreground : colors.foregroundMuted,
101
+ fontSize: _fontSizeOf(shUi),
102
+ fontWeight: shUi.weight.medium,
103
+ height: 1,
104
+ ),
105
+ child: IconTheme(
106
+ data: IconThemeData(
107
+ color: widget.pressed ? colors.foreground : colors.foregroundMuted,
108
+ size: _fontSizeOf(shUi) + 2,
109
+ ),
110
+ child: widget.child,
111
+ ),
112
+ ),
113
+ ),
114
+ ),
115
+ ),
116
+ );
117
+ }
118
+ }
119
+
120
+ /// 토글 그룹 — 여러 토글을 묶어 하나만 또는 복수 선택 가능.
121
+ class ShUiToggleGroup<T> extends StatelessWidget {
122
+ final Set<T> value;
123
+ final ValueChanged<Set<T>>? onValueChange;
124
+ final bool multiple;
125
+ final ShUiToggleVariant variant;
126
+ final ShUiToggleSize size;
127
+ final ShUiToggleOrientation orientation;
128
+ final List<ShUiToggleGroupItem<T>> children;
129
+
130
+ const ShUiToggleGroup({
131
+ super.key,
132
+ required this.value,
133
+ this.onValueChange,
134
+ this.multiple = false,
135
+ this.variant = ShUiToggleVariant.ghost,
136
+ this.size = ShUiToggleSize.md,
137
+ this.orientation = ShUiToggleOrientation.horizontal,
138
+ required this.children,
139
+ });
140
+
141
+ @override
142
+ Widget build(BuildContext context) {
143
+ final layout = orientation == ShUiToggleOrientation.vertical
144
+ ? Column(
145
+ crossAxisAlignment: CrossAxisAlignment.stretch,
146
+ mainAxisSize: MainAxisSize.min,
147
+ children: children,
148
+ )
149
+ : Row(
150
+ mainAxisSize: MainAxisSize.min,
151
+ children: children,
152
+ );
153
+
154
+ return _ShUiToggleGroupScope(
155
+ value: value,
156
+ onValueChange: onValueChange,
157
+ multiple: multiple,
158
+ variant: variant,
159
+ size: size,
160
+ child: layout,
161
+ );
162
+ }
163
+ }
164
+
165
+ class ShUiToggleGroupItem<T> extends StatelessWidget {
166
+ final T value;
167
+ final Widget child;
168
+
169
+ const ShUiToggleGroupItem({
170
+ super.key,
171
+ required this.value,
172
+ required this.child,
173
+ });
174
+
175
+ @override
176
+ Widget build(BuildContext context) {
177
+ final scope = _ShUiToggleGroupScope.of<T>(context);
178
+ if (scope == null) return child;
179
+
180
+ final pressed = scope.value.contains(value);
181
+
182
+ return ShUiToggle(
183
+ pressed: pressed,
184
+ variant: scope.variant,
185
+ size: scope.size,
186
+ onPressedChange: scope.onValueChange == null
187
+ ? null
188
+ : (_) {
189
+ final next = Set<T>.from(scope.value);
190
+ if (pressed) {
191
+ next.remove(value);
192
+ } else {
193
+ if (!scope.multiple) next.clear();
194
+ next.add(value);
195
+ }
196
+ scope.onValueChange!(next);
197
+ },
198
+ child: child,
199
+ );
200
+ }
201
+ }
202
+
203
+ class _ShUiToggleGroupScope<T> extends InheritedWidget {
204
+ final Set<T> value;
205
+ final ValueChanged<Set<T>>? onValueChange;
206
+ final bool multiple;
207
+ final ShUiToggleVariant variant;
208
+ final ShUiToggleSize size;
209
+
210
+ const _ShUiToggleGroupScope({
211
+ required this.value,
212
+ this.onValueChange,
213
+ required this.multiple,
214
+ required this.variant,
215
+ required this.size,
216
+ required super.child,
217
+ });
218
+
219
+ static _ShUiToggleGroupScope<T>? of<T>(BuildContext context) {
220
+ return context.dependOnInheritedWidgetOfExactType<_ShUiToggleGroupScope<T>>();
221
+ }
222
+
223
+ @override
224
+ bool updateShouldNotify(covariant _ShUiToggleGroupScope<T> old) =>
225
+ value != old.value ||
226
+ multiple != old.multiple ||
227
+ variant != old.variant ||
228
+ size != old.size;
229
+ }
@@ -0,0 +1,62 @@
1
+ import 'package:flutter/material.dart';
2
+ import '../foundation/sh_ui_tokens.dart';
3
+
4
+ /// sh-ui Tooltip — hover/포커스/long-press 시 짧은 설명을 표시.
5
+ ///
6
+ /// Flutter의 [Tooltip]을 sh-ui 토큰으로 스타일링한 래퍼. 터치 기기에서는
7
+ /// long-press로, 데스크탑에서는 hover로 표시된다.
8
+ ///
9
+ /// ShUiTooltip(
10
+ /// message: '변경 사항을 저장합니다',
11
+ /// child: ShUiButton(onPressed: () {}, child: Text('저장')),
12
+ /// )
13
+ class ShUiTooltip extends StatelessWidget {
14
+ final String message;
15
+ final Widget child;
16
+
17
+ /// 트리거 위에 표시할지 아래에 표시할지.
18
+ final bool preferBelow;
19
+
20
+ /// 표시까지 지연(ms).
21
+ final Duration waitDuration;
22
+
23
+ const ShUiTooltip({
24
+ super.key,
25
+ required this.message,
26
+ required this.child,
27
+ this.preferBelow = true,
28
+ this.waitDuration = const Duration(milliseconds: 300),
29
+ });
30
+
31
+ @override
32
+ Widget build(BuildContext context) {
33
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
34
+ final colors = shUi.colors;
35
+
36
+ return Tooltip(
37
+ message: message,
38
+ preferBelow: preferBelow,
39
+ waitDuration: waitDuration,
40
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
41
+ margin: const EdgeInsets.all(4),
42
+ verticalOffset: 16,
43
+ textStyle: TextStyle(
44
+ color: colors.background,
45
+ fontSize: shUi.text.xs,
46
+ height: 1.4,
47
+ ),
48
+ decoration: BoxDecoration(
49
+ color: colors.foreground,
50
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius - 2),
51
+ boxShadow: [
52
+ BoxShadow(
53
+ color: Colors.black.withValues(alpha: 0.12),
54
+ blurRadius: 12,
55
+ offset: const Offset(0, 4),
56
+ ),
57
+ ],
58
+ ),
59
+ child: child,
60
+ );
61
+ }
62
+ }