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,488 @@
1
+ import 'dart:ui' show ImageFilter;
2
+
3
+ import 'package:flutter/material.dart';
4
+ import '../foundation/sh_ui_tokens.dart';
5
+
6
+ /// sh-ui Header — 상단 네비게이션 바.
7
+ ///
8
+ /// 기본([mode: auto])은 반응형으로 동작한다.
9
+ /// - 화면 폭 >= [ShUiBreakpointTokens.md] → 가로 나열된 네비 아이템
10
+ /// - 화면 폭 < md → 햄버거 트리거 + backdrop + 좌측 slide drawer
11
+ /// 강제 고정은 `mode: ShUiHeaderMode.inline` 또는 `.drawer`.
12
+ ///
13
+ /// ```dart
14
+ /// ShUiHeader(
15
+ /// logo: Icon(Icons.hexagon_outlined),
16
+ /// title: 'sh-ui',
17
+ /// items: [
18
+ /// ShUiHeaderItem(label: '홈', isActive: true, onTap: () {}),
19
+ /// ShUiHeaderItem(label: '문서', onTap: () {}),
20
+ /// ],
21
+ /// trailing: [IconButton(icon: Icon(Icons.dark_mode), onPressed: () {})],
22
+ /// )
23
+ /// ```
24
+
25
+ /// Header 배치 모드.
26
+ enum ShUiHeaderMode { auto, inline, drawer }
27
+
28
+ /// Header 네비게이션 아이템 모델.
29
+ @immutable
30
+ class ShUiHeaderItem {
31
+ /// 표시 텍스트.
32
+ final String label;
33
+
34
+ /// 라벨 좌측에 붙는 아이콘 (선택).
35
+ final IconData? icon;
36
+
37
+ /// 탭 콜백. `null`이면 비활성처럼 렌더링.
38
+ final VoidCallback? onTap;
39
+
40
+ /// 현재 페이지/섹션 표시. `true`면 시각적으로 강조된다.
41
+ /// 라우터 활성 상태와 직접 연결해 사용 (예: `isActive: location == '/docs'`).
42
+ final bool isActive;
43
+
44
+ const ShUiHeaderItem({
45
+ required this.label,
46
+ this.icon,
47
+ this.onTap,
48
+ this.isActive = false,
49
+ });
50
+ }
51
+
52
+ /// shUi Header 위젯. 위 파일 헤더 dartdoc 참고.
53
+ ///
54
+ /// 데스크탑에서는 inline nav, 모바일에서는 햄버거 + drawer로 자동 전환된다.
55
+ /// 강제로 한 모드를 고정하려면 [mode]를 [ShUiHeaderMode.inline] 또는 [ShUiHeaderMode.drawer]로 지정.
56
+ class ShUiHeader extends StatefulWidget {
57
+ /// 좌측 브랜드 로고. SVG/이미지/[Icon] 모두 가능.
58
+ final Widget? logo;
59
+
60
+ /// 브랜드 텍스트 타이틀. [logo] 옆에 표시된다.
61
+ final String? title;
62
+
63
+ /// 네비게이션 항목 목록.
64
+ final List<ShUiHeaderItem> items;
65
+
66
+ /// 우측 트레일링 액션. 검색·테마 토글·로그인 버튼 등을 둔다.
67
+ final List<Widget>? trailing;
68
+
69
+ /// 배치 모드.
70
+ /// - [ShUiHeaderMode.auto] — 화면 폭 기준 자동 (기본)
71
+ /// - [ShUiHeaderMode.inline] — 항상 가로 나열
72
+ /// - [ShUiHeaderMode.drawer] — 항상 햄버거 + drawer
73
+ final ShUiHeaderMode mode;
74
+
75
+ /// drawer 모드에서 슬라이드되는 패널의 폭. 기본 280.
76
+ final double drawerWidth;
77
+
78
+ /// 헤더 자체의 높이. 기본 [ShUiControlTokens.md].
79
+ final double? height;
80
+
81
+ const ShUiHeader({
82
+ super.key,
83
+ this.logo,
84
+ this.title,
85
+ this.items = const [],
86
+ this.trailing,
87
+ this.mode = ShUiHeaderMode.auto,
88
+ this.drawerWidth = 280,
89
+ this.height,
90
+ });
91
+
92
+ @override
93
+ State<ShUiHeader> createState() => _ShUiHeaderState();
94
+ }
95
+
96
+ class _ShUiHeaderState extends State<ShUiHeader>
97
+ with SingleTickerProviderStateMixin {
98
+ OverlayEntry? _drawerEntry;
99
+ AnimationController? _drawerCtrl;
100
+ bool _isOpen = false;
101
+
102
+ bool _computeDrawer(BuildContext context, ShUiTheme shUi) {
103
+ switch (widget.mode) {
104
+ case ShUiHeaderMode.inline:
105
+ return false;
106
+ case ShUiHeaderMode.drawer:
107
+ return true;
108
+ case ShUiHeaderMode.auto:
109
+ return MediaQuery.of(context).size.width < shUi.breakpoint.md;
110
+ }
111
+ }
112
+
113
+ @override
114
+ void dispose() {
115
+ _removeDrawer();
116
+ _drawerCtrl?.dispose();
117
+ super.dispose();
118
+ }
119
+
120
+ void _ensureCtrl(ShUiTheme shUi) {
121
+ _drawerCtrl ??= AnimationController(
122
+ vsync: this,
123
+ duration: shUi.duration.base,
124
+ );
125
+ }
126
+
127
+ void _toggleDrawer(ShUiTheme shUi) {
128
+ if (_isOpen) {
129
+ _closeDrawer();
130
+ } else {
131
+ _openDrawer(shUi);
132
+ }
133
+ }
134
+
135
+ void _openDrawer(ShUiTheme shUi) {
136
+ if (_drawerEntry != null || _drawerCtrl == null) return;
137
+ final overlay = Overlay.maybeOf(context);
138
+ if (overlay == null) return;
139
+ _drawerEntry = OverlayEntry(builder: (ctx) => _buildDrawerOverlay(ctx, shUi));
140
+ overlay.insert(_drawerEntry!);
141
+ _drawerCtrl!.forward();
142
+ setState(() => _isOpen = true);
143
+ }
144
+
145
+ void _closeDrawer() {
146
+ if (_drawerEntry == null || _drawerCtrl == null) {
147
+ setState(() => _isOpen = false);
148
+ return;
149
+ }
150
+ var closed = false;
151
+ void finish() {
152
+ if (closed) return;
153
+ closed = true;
154
+ _drawerCtrl?.value = 0;
155
+ _removeDrawer();
156
+ if (mounted) setState(() => _isOpen = false);
157
+ }
158
+
159
+ _drawerCtrl?.reverse().whenComplete(finish);
160
+ Future<void>.delayed(const Duration(milliseconds: 400), finish);
161
+ }
162
+
163
+ void _removeDrawer() {
164
+ _drawerEntry?.remove();
165
+ _drawerEntry = null;
166
+ }
167
+
168
+ Widget _buildDrawerOverlay(BuildContext overlayCtx, ShUiTheme shUi) {
169
+ final colors = shUi.colors;
170
+ final curve = CurvedAnimation(
171
+ parent: _drawerCtrl!,
172
+ curve: shUi.ease.standard,
173
+ );
174
+ return Stack(
175
+ fit: StackFit.expand,
176
+ children: [
177
+ FadeTransition(
178
+ opacity: curve,
179
+ child: BackdropFilter(
180
+ filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
181
+ child: ModalBarrier(
182
+ dismissible: true,
183
+ onDismiss: _closeDrawer,
184
+ color: Colors.black.withValues(alpha: 0.25),
185
+ ),
186
+ ),
187
+ ),
188
+ Positioned(
189
+ left: 0,
190
+ top: 0,
191
+ bottom: 0,
192
+ child: SlideTransition(
193
+ position: Tween<Offset>(
194
+ begin: const Offset(-1, 0),
195
+ end: Offset.zero,
196
+ ).animate(curve),
197
+ child: SizedBox(
198
+ width: widget.drawerWidth,
199
+ child: Material(
200
+ color: Colors.transparent,
201
+ child: Container(
202
+ decoration: BoxDecoration(
203
+ color: colors.backgroundSubtle,
204
+ border: Border(right: BorderSide(color: colors.border)),
205
+ ),
206
+ // 배경은 노치/홈 인디케이터 영역까지 연장하고, 콘텐츠만 SafeArea 안으로.
207
+ child: SafeArea(
208
+ right: false,
209
+ child: Column(
210
+ crossAxisAlignment: CrossAxisAlignment.stretch,
211
+ children: [
212
+ // Drawer 헤더 — 로고 + 닫기
213
+ Container(
214
+ padding: EdgeInsets.symmetric(
215
+ horizontal: shUi.spacing.s4,
216
+ vertical: shUi.spacing.s4,
217
+ ),
218
+ decoration: BoxDecoration(
219
+ border: Border(
220
+ bottom: BorderSide(color: colors.border),
221
+ ),
222
+ ),
223
+ child: Row(
224
+ children: [
225
+ if (widget.logo != null) ...[
226
+ widget.logo!,
227
+ SizedBox(width: shUi.spacing.s2),
228
+ ],
229
+ if (widget.title != null)
230
+ Expanded(
231
+ child: Text(
232
+ widget.title!,
233
+ style: TextStyle(
234
+ color: colors.foreground,
235
+ fontSize: shUi.text.base,
236
+ fontWeight: shUi.weight.bold,
237
+ ),
238
+ overflow: TextOverflow.ellipsis,
239
+ ),
240
+ )
241
+ else
242
+ const Spacer(),
243
+ IconButton(
244
+ icon: Icon(
245
+ Icons.close,
246
+ size: 20,
247
+ color: colors.foreground,
248
+ ),
249
+ onPressed: _closeDrawer,
250
+ ),
251
+ ],
252
+ ),
253
+ ),
254
+ // 아이템 목록
255
+ Expanded(
256
+ child: ListView(
257
+ padding: EdgeInsets.symmetric(
258
+ vertical: shUi.spacing.s2,
259
+ ),
260
+ children: [
261
+ for (final item in widget.items)
262
+ _DrawerItemTile(
263
+ item: item,
264
+ onTap: () {
265
+ item.onTap?.call();
266
+ _closeDrawer();
267
+ },
268
+ ),
269
+ ],
270
+ ),
271
+ ),
272
+ ],
273
+ ),
274
+ ),
275
+ ),
276
+ ),
277
+ ),
278
+ ),
279
+ ),
280
+ ],
281
+ );
282
+ }
283
+
284
+ @override
285
+ Widget build(BuildContext context) {
286
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
287
+ final colors = shUi.colors;
288
+ _ensureCtrl(shUi);
289
+ final isDrawer = _computeDrawer(context, shUi);
290
+ final height = widget.height ?? shUi.control.md;
291
+
292
+ // drawer 모드에서 라우트가 비활성화될 때 overlay 자동 정리용 플래그.
293
+ if (!isDrawer && _drawerEntry != null) {
294
+ WidgetsBinding.instance.addPostFrameCallback((_) {
295
+ _drawerCtrl?.value = 0;
296
+ _removeDrawer();
297
+ if (mounted) setState(() => _isOpen = false);
298
+ });
299
+ }
300
+
301
+ return Container(
302
+ height: height,
303
+ padding: EdgeInsets.symmetric(horizontal: shUi.spacing.s3),
304
+ decoration: BoxDecoration(
305
+ color: colors.background,
306
+ border: Border(bottom: BorderSide(color: colors.border)),
307
+ ),
308
+ child: Row(
309
+ children: [
310
+ // 좌측: drawer 모드면 햄버거, 아니면 로고+타이틀
311
+ if (isDrawer)
312
+ IconButton(
313
+ icon: Icon(Icons.menu, size: 22, color: colors.foreground),
314
+ onPressed: () => _toggleDrawer(shUi),
315
+ ),
316
+ if (widget.logo != null) ...[
317
+ if (!isDrawer) widget.logo!,
318
+ if (isDrawer) widget.logo!,
319
+ SizedBox(width: shUi.spacing.s2),
320
+ ],
321
+ if (widget.title != null)
322
+ Text(
323
+ widget.title!,
324
+ style: TextStyle(
325
+ color: colors.foreground,
326
+ fontSize: shUi.text.base,
327
+ fontWeight: shUi.weight.bold,
328
+ letterSpacing: -0.3,
329
+ ),
330
+ ),
331
+ // 중앙/우측: inline 모드에서 네비 아이템
332
+ if (!isDrawer && widget.items.isNotEmpty) ...[
333
+ SizedBox(width: shUi.spacing.s6),
334
+ Expanded(
335
+ child: Row(
336
+ children: [
337
+ for (final item in widget.items)
338
+ _InlineItemTile(item: item),
339
+ ],
340
+ ),
341
+ ),
342
+ ] else
343
+ const Spacer(),
344
+ // 트레일링
345
+ if (widget.trailing != null)
346
+ for (final w in widget.trailing!) w,
347
+ ],
348
+ ),
349
+ );
350
+ }
351
+ }
352
+
353
+ /// inline 모드용 네비 아이템. 텍스트 버튼 스타일.
354
+ class _InlineItemTile extends StatefulWidget {
355
+ final ShUiHeaderItem item;
356
+
357
+ const _InlineItemTile({required this.item});
358
+
359
+ @override
360
+ State<_InlineItemTile> createState() => _InlineItemTileState();
361
+ }
362
+
363
+ class _InlineItemTileState extends State<_InlineItemTile> {
364
+ bool _hover = false;
365
+
366
+ @override
367
+ Widget build(BuildContext context) {
368
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
369
+ final colors = shUi.colors;
370
+ final active = widget.item.isActive;
371
+ final fg = active || _hover ? colors.foreground : colors.foregroundMuted;
372
+
373
+ return MouseRegion(
374
+ cursor: SystemMouseCursors.click,
375
+ onEnter: (_) => setState(() => _hover = true),
376
+ onExit: (_) => setState(() => _hover = false),
377
+ child: GestureDetector(
378
+ onTap: widget.item.onTap,
379
+ child: Padding(
380
+ padding: EdgeInsets.symmetric(
381
+ horizontal: shUi.spacing.s3,
382
+ vertical: shUi.spacing.s2,
383
+ ),
384
+ child: Row(
385
+ mainAxisSize: MainAxisSize.min,
386
+ children: [
387
+ if (widget.item.icon != null) ...[
388
+ Icon(widget.item.icon, size: 16, color: fg),
389
+ SizedBox(width: shUi.spacing.s1),
390
+ ],
391
+ Text(
392
+ widget.item.label,
393
+ style: TextStyle(
394
+ color: fg,
395
+ fontSize: shUi.text.sm,
396
+ fontWeight:
397
+ active ? shUi.weight.semibold : shUi.weight.medium,
398
+ ),
399
+ ),
400
+ ],
401
+ ),
402
+ ),
403
+ ),
404
+ );
405
+ }
406
+ }
407
+
408
+ /// drawer 모드용 아이템 타일. 세로 리스트 엔트리.
409
+ class _DrawerItemTile extends StatefulWidget {
410
+ final ShUiHeaderItem item;
411
+ final VoidCallback onTap;
412
+
413
+ const _DrawerItemTile({required this.item, required this.onTap});
414
+
415
+ @override
416
+ State<_DrawerItemTile> createState() => _DrawerItemTileState();
417
+ }
418
+
419
+ class _DrawerItemTileState extends State<_DrawerItemTile> {
420
+ bool _hover = false;
421
+
422
+ @override
423
+ Widget build(BuildContext context) {
424
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
425
+ final colors = shUi.colors;
426
+ final active = widget.item.isActive;
427
+ Color bg;
428
+ Color fg;
429
+ if (active) {
430
+ bg = colors.backgroundMuted;
431
+ fg = colors.foreground;
432
+ } else if (_hover) {
433
+ bg = colors.backgroundMuted;
434
+ fg = colors.foreground;
435
+ } else {
436
+ bg = Colors.transparent;
437
+ fg = colors.foregroundMuted;
438
+ }
439
+
440
+ return MouseRegion(
441
+ cursor: SystemMouseCursors.click,
442
+ onEnter: (_) => setState(() => _hover = true),
443
+ onExit: (_) => setState(() => _hover = false),
444
+ child: GestureDetector(
445
+ onTap: widget.onTap,
446
+ child: Container(
447
+ margin: EdgeInsets.symmetric(
448
+ horizontal: shUi.spacing.s2,
449
+ vertical: 1,
450
+ ),
451
+ padding: EdgeInsets.symmetric(
452
+ horizontal: shUi.spacing.s3,
453
+ vertical: shUi.spacing.s2 + 2, // s2(8) + 2 = 10
454
+ ),
455
+ decoration: BoxDecoration(
456
+ color: bg,
457
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius - 2),
458
+ ),
459
+ child: Row(
460
+ children: [
461
+ if (widget.item.icon != null) ...[
462
+ Icon(widget.item.icon, size: 18, color: fg),
463
+ SizedBox(width: shUi.spacing.s3),
464
+ ],
465
+ Expanded(
466
+ child: Text(
467
+ widget.item.label,
468
+ style: TextStyle(
469
+ color: fg,
470
+ fontSize: shUi.text.sm,
471
+ fontWeight:
472
+ active ? shUi.weight.semibold : shUi.weight.regular,
473
+ ),
474
+ ),
475
+ ),
476
+ if (active)
477
+ Icon(
478
+ Icons.chevron_right,
479
+ size: 16,
480
+ color: shUi.colors.foregroundMuted,
481
+ ),
482
+ ],
483
+ ),
484
+ ),
485
+ ),
486
+ );
487
+ }
488
+ }