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,1116 @@
1
+ import 'dart:ui' show ImageFilter;
2
+
3
+ import 'package:flutter/material.dart';
4
+ import '../foundation/sh_ui_tokens.dart';
5
+
6
+ /// sh-ui Sidebar — 네비게이션 사이드바 / 드로어.
7
+ ///
8
+ /// 기본(`mode: auto`)은 반응형으로 동작한다.
9
+ /// - 화면 폭 >= [ShUiBreakpointTokens.md] → inline (Row 레이아웃 유지)
10
+ /// - 화면 폭 < md → drawer (backdrop + 슬라이드)
11
+ /// 강제로 고정하려면 `mode: ShUiSidebarMode.inline` 또는 `.drawer`.
12
+ ///
13
+ /// drawer 모드에서는 사이드바가 숨겨져 있으므로 [ShUiSidebarTrigger]는
14
+ /// AppBar 등 바깥에 배치해야 한다.
15
+ ///
16
+ /// ShUiSidebarProvider(
17
+ /// child: Row(
18
+ /// children: [
19
+ /// ShUiSidebar(
20
+ /// header: ShUiSidebarHeader(child: Text('앱 이름')),
21
+ /// children: [
22
+ /// ShUiSidebarGroup(
23
+ /// label: '메뉴',
24
+ /// children: [
25
+ /// ShUiSidebarItem(icon: Icons.home, label: '홈', isActive: true),
26
+ /// ShUiSidebarItem(icon: Icons.settings, label: '설정'),
27
+ /// ],
28
+ /// ),
29
+ /// ],
30
+ /// ),
31
+ /// Expanded(child: mainContent),
32
+ /// ],
33
+ /// ),
34
+ /// )
35
+
36
+ /* ───────── Variant / Mode ───────── */
37
+
38
+ /// Sidebar 외형 변형.
39
+ /// - [sidebar] 기본. 가장자리에 붙어 border로 구분.
40
+ /// - [floating] 카드처럼 띄워 여백과 radius를 적용.
41
+ /// - [inset] 사이드바는 가장자리에 붙고, 메인 컨텐츠(ShUiSidebarInset)가
42
+ /// 내부 여백/radius를 가진 형태.
43
+ enum ShUiSidebarVariant { sidebar, floating, inset }
44
+
45
+ /// Sidebar 배치 모드.
46
+ /// - [auto] 화면 폭 기준 자동. `>= breakpoint.md` 면 inline, 미만이면 drawer.
47
+ /// - [inline] 항상 Row 레이아웃의 고정 사이드바.
48
+ /// - [drawer] 항상 backdrop + 슬라이드 drawer.
49
+ enum ShUiSidebarMode { auto, inline, drawer }
50
+
51
+ /* ───────── Provider ───────── */
52
+
53
+ /// Sidebar 영역 전체를 감싸는 Provider. open/closed·activePanel 상태와
54
+ /// expanded/collapsed 폭을 자식 트리에 공유한다. [ShUiSidebar]를 사용하는 영역
55
+ /// 바깥에 한 번 두어야 하며, 자식에서 [ShUiSidebar.of]로 상태에 접근한다.
56
+ class ShUiSidebarProvider extends StatefulWidget {
57
+ /// Provider 영역의 자식 트리. 보통 [Row]로 [ShUiSidebar]+메인 콘텐츠를 묶는다.
58
+ final Widget child;
59
+
60
+ /// 초기 열림 상태. 영속화가 필요하면 외부에서 저장값을 읽어 주입한다.
61
+ final bool defaultOpen;
62
+
63
+ /// 펼침 상태일 때 사이드바 폭(px). 기본 256.
64
+ final double expandedWidth;
65
+
66
+ /// 접힘 상태(icon 모드)일 때 사이드바 폭(px). 기본 56.
67
+ final double collapsedWidth;
68
+
69
+ const ShUiSidebarProvider({
70
+ super.key,
71
+ required this.child,
72
+ this.defaultOpen = true,
73
+ this.expandedWidth = 256,
74
+ this.collapsedWidth = 56,
75
+ });
76
+
77
+ @override
78
+ State<ShUiSidebarProvider> createState() => ShUiSidebarProviderState();
79
+ }
80
+
81
+ /// [ShUiSidebarProvider]의 State. 외부에서 toggle/setOpen/setActivePanel 명령형 제어가 필요할 때 GlobalKey로 접근한다.
82
+ class ShUiSidebarProviderState extends State<ShUiSidebarProvider> {
83
+ late bool _open;
84
+ String? _activePanelId;
85
+
86
+ bool get open => _open;
87
+ String? get activePanelId => _activePanelId;
88
+
89
+ void toggle() => setState(() => _open = !_open);
90
+ void setOpen(bool value) => setState(() => _open = value);
91
+
92
+ /// 같은 id를 다시 주면 닫힘(토글). null이면 명시적으로 닫기.
93
+ void setActivePanel(String? id) {
94
+ setState(() {
95
+ _activePanelId = (_activePanelId == id) ? null : id;
96
+ });
97
+ }
98
+
99
+ @override
100
+ void initState() {
101
+ super.initState();
102
+ _open = widget.defaultOpen;
103
+ }
104
+
105
+ @override
106
+ Widget build(BuildContext context) {
107
+ return _ShUiSidebarScope(
108
+ open: _open,
109
+ expandedWidth: widget.expandedWidth,
110
+ collapsedWidth: widget.collapsedWidth,
111
+ toggle: toggle,
112
+ activePanelId: _activePanelId,
113
+ setActivePanel: setActivePanel,
114
+ child: widget.child,
115
+ );
116
+ }
117
+ }
118
+
119
+ class _ShUiSidebarScope extends InheritedWidget {
120
+ final bool open;
121
+ final double expandedWidth;
122
+ final double collapsedWidth;
123
+ final VoidCallback toggle;
124
+ final String? activePanelId;
125
+ final ValueChanged<String?> setActivePanel;
126
+
127
+ const _ShUiSidebarScope({
128
+ required this.open,
129
+ required this.expandedWidth,
130
+ required this.collapsedWidth,
131
+ required this.toggle,
132
+ required this.activePanelId,
133
+ required this.setActivePanel,
134
+ required super.child,
135
+ });
136
+
137
+ static _ShUiSidebarScope? of(BuildContext context) {
138
+ return context.dependOnInheritedWidgetOfExactType<_ShUiSidebarScope>();
139
+ }
140
+
141
+ @override
142
+ bool updateShouldNotify(covariant _ShUiSidebarScope old) =>
143
+ open != old.open || activePanelId != old.activePanelId;
144
+ }
145
+
146
+ /* ───────── useSidebar equivalent ───────── */
147
+
148
+ /// [useSidebar]가 반환하는 스냅샷. open/toggle·폭·activePanel 정보를 담는 불변 데이터 객체.
149
+ class ShUiSidebarState {
150
+ /// 사이드바 펼침 상태.
151
+ final bool open;
152
+
153
+ /// 펼침/접힘 토글. 자식 위젯에서 직접 호출 가능.
154
+ final VoidCallback toggle;
155
+
156
+ /// 펼침 상태 폭(px).
157
+ final double expandedWidth;
158
+
159
+ /// 접힘 상태 폭(px).
160
+ final double collapsedWidth;
161
+
162
+ /// 현재 열린 보조 패널 id. 없으면 `null`.
163
+ final String? activePanelId;
164
+
165
+ /// 보조 패널 id 설정. 같은 id를 다시 주면 닫힘(토글), `null`이면 명시적으로 닫음.
166
+ final ValueChanged<String?> setActivePanel;
167
+
168
+ const ShUiSidebarState({
169
+ required this.open,
170
+ required this.toggle,
171
+ required this.expandedWidth,
172
+ required this.collapsedWidth,
173
+ required this.activePanelId,
174
+ required this.setActivePanel,
175
+ });
176
+ }
177
+
178
+ /// 가까운 [ShUiSidebarProvider]의 상태 스냅샷을 반환한다. Provider가 없으면 null.
179
+ ShUiSidebarState? useSidebar(BuildContext context) {
180
+ final scope = _ShUiSidebarScope.of(context);
181
+ if (scope == null) return null;
182
+ return ShUiSidebarState(
183
+ open: scope.open,
184
+ toggle: scope.toggle,
185
+ expandedWidth: scope.expandedWidth,
186
+ collapsedWidth: scope.collapsedWidth,
187
+ activePanelId: scope.activePanelId,
188
+ setActivePanel: scope.setActivePanel,
189
+ );
190
+ }
191
+
192
+ /* ───────── Sidebar ───────── */
193
+
194
+ /// shUi Sidebar 위젯. 위 파일 헤더 dartdoc 참고.
195
+ ///
196
+ /// [variant]로 외형(sidebar/floating/inset), [mode]로 inline/drawer 동작을 결정한다.
197
+ /// 자식은 [ShUiSidebarHeader]/[ShUiSidebarGroup]/[ShUiSidebarFooter] 등으로 구성한다.
198
+ class ShUiSidebar extends StatefulWidget {
199
+ /// 사이드바 상단 영역. 보통 [ShUiSidebarHeader]로 감싼 로고/앱 이름.
200
+ final Widget? header;
201
+
202
+ /// 사이드바 하단 영역. 보통 [ShUiSidebarFooter]로 감싼 사용자 정보·테마 토글.
203
+ final Widget? footer;
204
+
205
+ /// 메뉴 본문. [ShUiSidebarGroup]/[ShUiSidebarItem]/[ShUiSidebarSeparator] 등을 순서대로 나열.
206
+ final List<Widget> children;
207
+
208
+ /// 외형 변형.
209
+ /// - [ShUiSidebarVariant.sidebar] — 가장자리 부착 (기본)
210
+ /// - [ShUiSidebarVariant.floating] — 카드처럼 띄움
211
+ /// - [ShUiSidebarVariant.inset] — 메인 콘텐츠가 둥근 카드
212
+ final ShUiSidebarVariant variant;
213
+
214
+ /// 배치 모드.
215
+ /// - [ShUiSidebarMode.auto] — 화면 폭 기준 자동 (기본)
216
+ /// - [ShUiSidebarMode.inline] — 항상 inline
217
+ /// - [ShUiSidebarMode.drawer] — 항상 drawer
218
+ final ShUiSidebarMode mode;
219
+
220
+ const ShUiSidebar({
221
+ super.key,
222
+ this.header,
223
+ this.footer,
224
+ required this.children,
225
+ this.variant = ShUiSidebarVariant.sidebar,
226
+ this.mode = ShUiSidebarMode.auto,
227
+ });
228
+
229
+ @override
230
+ State<ShUiSidebar> createState() => _ShUiSidebarState();
231
+ }
232
+
233
+ class _ShUiSidebarState extends State<ShUiSidebar>
234
+ with SingleTickerProviderStateMixin {
235
+ OverlayEntry? _drawerEntry;
236
+ AnimationController? _drawerCtrl;
237
+ bool _lastOverlayOpen = false;
238
+
239
+ bool _computeDrawer(BuildContext context, ShUiTheme shUi) {
240
+ switch (widget.mode) {
241
+ case ShUiSidebarMode.inline:
242
+ return false;
243
+ case ShUiSidebarMode.drawer:
244
+ return true;
245
+ case ShUiSidebarMode.auto:
246
+ return MediaQuery.of(context).size.width < shUi.breakpoint.md;
247
+ }
248
+ }
249
+
250
+ @override
251
+ void dispose() {
252
+ _removeDrawer();
253
+ _drawerCtrl?.dispose();
254
+ super.dispose();
255
+ }
256
+
257
+ void _ensureCtrl(ShUiTheme shUi) {
258
+ _drawerCtrl ??= AnimationController(
259
+ vsync: this,
260
+ duration: shUi.duration.base,
261
+ );
262
+ }
263
+
264
+ void _showDrawer() {
265
+ if (_drawerEntry != null || _drawerCtrl == null) return;
266
+ final overlayState = Overlay.maybeOf(context);
267
+ if (overlayState == null) return;
268
+ _drawerEntry = OverlayEntry(builder: _buildDrawerOverlay);
269
+ overlayState.insert(_drawerEntry!);
270
+ _drawerCtrl!.forward();
271
+ }
272
+
273
+ void _hideDrawer(VoidCallback onClosed) {
274
+ if (_drawerEntry == null) {
275
+ onClosed();
276
+ return;
277
+ }
278
+ var closed = false;
279
+ void finish() {
280
+ if (closed) return;
281
+ closed = true;
282
+ _drawerCtrl?.value = 0;
283
+ _removeDrawer();
284
+ onClosed();
285
+ }
286
+
287
+ _drawerCtrl?.reverse().whenComplete(finish);
288
+ // 새 라우트로 이동해 현재 라우트가 비활성화되면 TickerMode가 꺼져
289
+ // reverse() 애니메이션이 진행되지 않는다. 타이머 fallback으로 강제 제거.
290
+ Future<void>.delayed(const Duration(milliseconds: 400), finish);
291
+ }
292
+
293
+ void _removeDrawer() {
294
+ _drawerEntry?.remove();
295
+ _drawerEntry = null;
296
+ }
297
+
298
+ Widget _buildDrawerOverlay(BuildContext overlayContext) {
299
+ final shUi =
300
+ Theme.of(overlayContext).extension<ShUiTheme>() ?? ShUiTheme.light;
301
+ final scope = _ShUiSidebarScope.of(context);
302
+ final width = scope?.expandedWidth ?? 256;
303
+ final curve = CurvedAnimation(
304
+ parent: _drawerCtrl!,
305
+ curve: shUi.ease.standard,
306
+ );
307
+ return Stack(
308
+ fit: StackFit.expand,
309
+ children: [
310
+ FadeTransition(
311
+ opacity: curve,
312
+ child: BackdropFilter(
313
+ filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
314
+ child: ModalBarrier(
315
+ dismissible: true,
316
+ onDismiss: () => scope?.toggle(),
317
+ color: Colors.black.withValues(alpha: 0.25),
318
+ ),
319
+ ),
320
+ ),
321
+ Positioned(
322
+ left: 0,
323
+ top: 0,
324
+ bottom: 0,
325
+ child: SlideTransition(
326
+ position: Tween<Offset>(
327
+ begin: const Offset(-1, 0),
328
+ end: Offset.zero,
329
+ ).animate(curve),
330
+ child: SizedBox(
331
+ width: width,
332
+ child: Material(
333
+ color: Colors.transparent,
334
+ child: _buildPanel(shUi, forceOpen: true, insetSafeArea: true),
335
+ ),
336
+ ),
337
+ ),
338
+ ),
339
+ ],
340
+ );
341
+ }
342
+
343
+ Widget _buildPanel(
344
+ ShUiTheme shUi, {
345
+ required bool forceOpen,
346
+ bool insetSafeArea = false,
347
+ }) {
348
+ final colors = shUi.colors;
349
+ final isFloating = widget.variant == ShUiSidebarVariant.floating;
350
+ final decoration = isFloating
351
+ ? BoxDecoration(
352
+ color: colors.backgroundSubtle,
353
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
354
+ border: Border.all(color: colors.border),
355
+ boxShadow: shUi.shadow.sm,
356
+ )
357
+ : BoxDecoration(
358
+ color: colors.backgroundSubtle,
359
+ border: Border(right: BorderSide(color: colors.border)),
360
+ );
361
+ final margin =
362
+ isFloating ? EdgeInsets.all(shUi.spacing.s2) : EdgeInsets.zero;
363
+ final clip = isFloating ? Clip.antiAlias : Clip.none;
364
+ final content = Column(
365
+ crossAxisAlignment: CrossAxisAlignment.stretch,
366
+ children: [
367
+ if (widget.header != null) widget.header!,
368
+ Expanded(
369
+ child: SingleChildScrollView(
370
+ padding: EdgeInsets.symmetric(vertical: shUi.spacing.s4),
371
+ child: Column(
372
+ crossAxisAlignment: CrossAxisAlignment.stretch,
373
+ children: widget.children,
374
+ ),
375
+ ),
376
+ ),
377
+ if (widget.footer != null) widget.footer!,
378
+ ],
379
+ );
380
+ return Container(
381
+ margin: margin,
382
+ decoration: decoration,
383
+ clipBehavior: clip,
384
+ // drawer 모드: 배경은 노치 영역까지 연장하되 콘텐츠만 SafeArea 안쪽으로.
385
+ child: insetSafeArea ? SafeArea(right: false, child: content) : content,
386
+ );
387
+ }
388
+
389
+ @override
390
+ Widget build(BuildContext context) {
391
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
392
+ final scope = _ShUiSidebarScope.of(context);
393
+ final isOpen = scope?.open ?? true;
394
+ final isDrawer = _computeDrawer(context, shUi);
395
+
396
+ _ensureCtrl(shUi);
397
+
398
+ // drawer 모드에서 open 상태 변화에 따라 overlay 토글
399
+ if (isDrawer) {
400
+ if (isOpen && !_lastOverlayOpen) {
401
+ WidgetsBinding.instance.addPostFrameCallback((_) {
402
+ if (mounted) _showDrawer();
403
+ });
404
+ } else if (!isOpen && _lastOverlayOpen) {
405
+ WidgetsBinding.instance.addPostFrameCallback((_) {
406
+ if (mounted) _hideDrawer(() {});
407
+ });
408
+ }
409
+ _lastOverlayOpen = isOpen;
410
+ // drawer 모드에서는 Row 레이아웃에서 자리를 차지하지 않는다.
411
+ return const SizedBox.shrink();
412
+ }
413
+
414
+ // inline 모드로 바뀌면 떠있는 overlay 정리.
415
+ if (_drawerEntry != null) {
416
+ WidgetsBinding.instance.addPostFrameCallback((_) {
417
+ _drawerCtrl?.value = 0;
418
+ _removeDrawer();
419
+ });
420
+ }
421
+ _lastOverlayOpen = false;
422
+
423
+ final width =
424
+ isOpen ? (scope?.expandedWidth ?? 256) : (scope?.collapsedWidth ?? 56);
425
+ final isFloating = widget.variant == ShUiSidebarVariant.floating;
426
+
427
+ return AnimatedContainer(
428
+ duration: shUi.duration.slow,
429
+ curve: shUi.ease.standard,
430
+ width: width + (isFloating ? shUi.spacing.s2 * 2 : 0),
431
+ margin: EdgeInsets.zero,
432
+ child: _buildPanel(shUi, forceOpen: false),
433
+ );
434
+ }
435
+ }
436
+
437
+ /* ───────── Inset (variant=inset 짝) ─────────
438
+ * 사이드바 옆 메인 영역을 둥근 카드 형태로 감싸는 래퍼.
439
+ * variant=inset 사이드바와 함께 쓰면 shadcn/ui 풍의 "inset" 레이아웃이 완성된다.
440
+ */
441
+
442
+ /// [ShUiSidebar] 옆의 메인 영역을 카드 형태로 감싼다. variant=inset과 짝으로 사용한다.
443
+ class ShUiSidebarInset extends StatelessWidget {
444
+ /// 카드로 감쌀 메인 콘텐츠.
445
+ final Widget child;
446
+
447
+ const ShUiSidebarInset({super.key, required this.child});
448
+
449
+ @override
450
+ Widget build(BuildContext context) {
451
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
452
+ final colors = shUi.colors;
453
+ return Expanded(
454
+ child: Padding(
455
+ padding: EdgeInsets.fromLTRB(
456
+ 0,
457
+ shUi.spacing.s2,
458
+ shUi.spacing.s2,
459
+ shUi.spacing.s2,
460
+ ),
461
+ child: Container(
462
+ decoration: BoxDecoration(
463
+ color: colors.background,
464
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
465
+ border: Border.all(color: colors.border),
466
+ ),
467
+ clipBehavior: Clip.antiAlias,
468
+ child: child,
469
+ ),
470
+ ),
471
+ );
472
+ }
473
+ }
474
+
475
+ /* ───────── Trigger ───────── */
476
+
477
+ /// Sidebar 토글 버튼. drawer 모드에서는 사이드바가 숨겨져 있으므로 AppBar 등 바깥에 둔다.
478
+ class ShUiSidebarTrigger extends StatelessWidget {
479
+ const ShUiSidebarTrigger({super.key});
480
+
481
+ @override
482
+ Widget build(BuildContext context) {
483
+ final scope = _ShUiSidebarScope.of(context);
484
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
485
+
486
+ return GestureDetector(
487
+ onTap: scope?.toggle,
488
+ child: Padding(
489
+ padding: EdgeInsets.all(shUi.spacing.s2),
490
+ child: Icon(
491
+ Icons.menu,
492
+ size: 20,
493
+ color: shUi.colors.foreground,
494
+ ),
495
+ ),
496
+ );
497
+ }
498
+ }
499
+
500
+ /* ───────── Header / Footer ───────── */
501
+
502
+ /// Sidebar 상단 영역. 보통 로고/앱 이름을 둔다. 접힘 상태에서는 좌우 padding이 자동으로 줄어든다.
503
+ class ShUiSidebarHeader extends StatelessWidget {
504
+ /// 헤더 안에 표시될 콘텐츠 (로고·타이틀·트리거 등).
505
+ final Widget child;
506
+
507
+ const ShUiSidebarHeader({super.key, required this.child});
508
+
509
+ @override
510
+ Widget build(BuildContext context) {
511
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
512
+ final scope = _ShUiSidebarScope.of(context);
513
+ final isOpen = scope?.open ?? true;
514
+ // 접힌 상태(56px)에선 s4(16) 패딩이 너무 커서 trigger(36px)가 경계를
515
+ // 벗어나 hit test 영역이 잘린다. 좌우만 s2로 줄여 트리거가 안쪽에 들어오도록.
516
+ return AnimatedContainer(
517
+ duration: shUi.duration.slow,
518
+ curve: shUi.ease.standard,
519
+ padding: EdgeInsets.symmetric(
520
+ horizontal: isOpen ? shUi.spacing.s4 : shUi.spacing.s2,
521
+ vertical: shUi.spacing.s4,
522
+ ),
523
+ decoration: BoxDecoration(
524
+ border: Border(bottom: BorderSide(color: shUi.colors.border)),
525
+ ),
526
+ child: child,
527
+ );
528
+ }
529
+ }
530
+
531
+ /// Sidebar 하단 영역. 사용자 정보·테마 토글 등을 둔다.
532
+ class ShUiSidebarFooter extends StatelessWidget {
533
+ /// 푸터 안에 표시될 콘텐츠.
534
+ final Widget child;
535
+
536
+ const ShUiSidebarFooter({super.key, required this.child});
537
+
538
+ @override
539
+ Widget build(BuildContext context) {
540
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
541
+ final scope = _ShUiSidebarScope.of(context);
542
+ final isOpen = scope?.open ?? true;
543
+ return AnimatedContainer(
544
+ duration: shUi.duration.slow,
545
+ curve: shUi.ease.standard,
546
+ padding: EdgeInsets.symmetric(
547
+ horizontal: isOpen ? shUi.spacing.s4 : shUi.spacing.s2,
548
+ vertical: shUi.spacing.s4,
549
+ ),
550
+ decoration: BoxDecoration(
551
+ border: Border(top: BorderSide(color: shUi.colors.border)),
552
+ ),
553
+ child: child,
554
+ );
555
+ }
556
+ }
557
+
558
+ /* ───────── Group ───────── */
559
+
560
+ /// 의미적으로 묶이는 메뉴 그룹. [label]을 주면 카테고리 헤더가 표시되고,
561
+ /// [collapsible]을 켜면 라벨 탭으로 접기/펼치기가 가능하다.
562
+ class ShUiSidebarGroup extends StatefulWidget {
563
+ /// 그룹 카테고리 라벨. 미지정 시 라벨 없이 자식만 노출.
564
+ final String? label;
565
+
566
+ /// 그룹에 속한 자식 위젯들. 보통 [ShUiSidebarItem]을 나열.
567
+ final List<Widget> children;
568
+
569
+ /// `true`면 label 탭으로 접기/펼치기 가능. `label`이 있어야 의미가 있다.
570
+ final bool collapsible;
571
+
572
+ /// `collapsible: true`일 때 초기 확장 상태.
573
+ final bool initiallyExpanded;
574
+
575
+ const ShUiSidebarGroup({
576
+ super.key,
577
+ this.label,
578
+ required this.children,
579
+ this.collapsible = false,
580
+ this.initiallyExpanded = true,
581
+ });
582
+
583
+ @override
584
+ State<ShUiSidebarGroup> createState() => _ShUiSidebarGroupState();
585
+ }
586
+
587
+ class _ShUiSidebarGroupState extends State<ShUiSidebarGroup>
588
+ with SingleTickerProviderStateMixin {
589
+ late bool _expanded;
590
+
591
+ @override
592
+ void initState() {
593
+ super.initState();
594
+ _expanded = widget.initiallyExpanded;
595
+ }
596
+
597
+ @override
598
+ Widget build(BuildContext context) {
599
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
600
+ final scope = _ShUiSidebarScope.of(context);
601
+ final isSidebarOpen = scope?.open ?? true;
602
+
603
+ final hasLabel = widget.label != null && isSidebarOpen;
604
+ final canCollapse = widget.collapsible && hasLabel;
605
+
606
+ return Padding(
607
+ padding: EdgeInsets.symmetric(vertical: shUi.spacing.s1),
608
+ child: Column(
609
+ crossAxisAlignment: CrossAxisAlignment.start,
610
+ mainAxisSize: MainAxisSize.min,
611
+ children: [
612
+ if (hasLabel)
613
+ _buildLabel(shUi, canCollapse: canCollapse),
614
+ AnimatedSize(
615
+ duration: shUi.duration.fast,
616
+ curve: shUi.ease.standard,
617
+ alignment: Alignment.topCenter,
618
+ child: (canCollapse && !_expanded)
619
+ ? const SizedBox(width: double.infinity)
620
+ : Column(
621
+ crossAxisAlignment: CrossAxisAlignment.stretch,
622
+ mainAxisSize: MainAxisSize.min,
623
+ children: widget.children,
624
+ ),
625
+ ),
626
+ ],
627
+ ),
628
+ );
629
+ }
630
+
631
+ Widget _buildLabel(ShUiTheme shUi, {required bool canCollapse}) {
632
+ final textStyle = TextStyle(
633
+ color: shUi.colors.foregroundMuted,
634
+ fontSize: shUi.text.xs,
635
+ fontWeight: shUi.weight.medium,
636
+ letterSpacing: 0.5,
637
+ );
638
+
639
+ if (!canCollapse) {
640
+ return Padding(
641
+ padding: EdgeInsets.symmetric(
642
+ horizontal: shUi.spacing.s4,
643
+ vertical: shUi.spacing.s1,
644
+ ),
645
+ child: Text(widget.label!, style: textStyle),
646
+ );
647
+ }
648
+
649
+ return InkWell(
650
+ onTap: () => setState(() => _expanded = !_expanded),
651
+ child: Padding(
652
+ padding: EdgeInsets.symmetric(
653
+ horizontal: shUi.spacing.s4,
654
+ vertical: shUi.spacing.s1,
655
+ ),
656
+ child: Row(
657
+ children: [
658
+ Expanded(child: Text(widget.label!, style: textStyle)),
659
+ AnimatedRotation(
660
+ turns: _expanded ? 0 : -0.25,
661
+ duration: shUi.duration.fast,
662
+ curve: shUi.ease.standard,
663
+ child: Icon(
664
+ Icons.keyboard_arrow_down,
665
+ size: 16,
666
+ color: shUi.colors.foregroundMuted,
667
+ ),
668
+ ),
669
+ ],
670
+ ),
671
+ ),
672
+ );
673
+ }
674
+ }
675
+
676
+ /* ───────── Item ─────────
677
+ *
678
+ * - `panelId`가 지정되면 탭 시 해당 id의 ShUiSidebarPanel을 토글한다.
679
+ * activePanelId와 일치하면 자동으로 isActive 처럼 보이도록 강조한다.
680
+ * - `children`이 지정되면 서브메뉴처럼 동작:
681
+ * - 탭 시 확장/축소 토글 (chevron 회전, AnimatedSize)
682
+ * - children은 들여쓰기되어 아래 렌더
683
+ */
684
+
685
+ /// 한 줄 메뉴 항목. [icon] + [label] + [isActive] 강조를 가지며 [onTap] 콜백을 발생시킨다.
686
+ /// [panelId]를 주면 탭 시 같은 id의 [ShUiSidebarPanel]을 토글하고,
687
+ /// [children]을 주면 서브메뉴처럼 확장/축소되는 트리 항목이 된다.
688
+ class ShUiSidebarItem extends StatefulWidget {
689
+ /// 라벨 좌측 아이콘 (선택). 접힘 상태(icon 모드)에서는 이것만 보인다.
690
+ final IconData? icon;
691
+
692
+ /// 메뉴 텍스트. 펼침 상태에서만 표시.
693
+ final String label;
694
+
695
+ /// 활성 상태 표시. 라우터 활성 경로와 직접 연결해 사용 (예: `isActive: route == '/home'`).
696
+ /// [panelId]가 지정되면 `activePanelId == panelId`일 때 자동으로 강조된다.
697
+ final bool isActive;
698
+
699
+ /// 탭 콜백. [panelId]가 같이 지정된 경우 패널 토글이 먼저 실행된 뒤 이 콜백이 호출된다.
700
+ final VoidCallback? onTap;
701
+
702
+ /// 보조 패널 id. 지정 시 탭으로 같은 id의 [ShUiSidebarPanel]을 토글한다.
703
+ final String? panelId;
704
+
705
+ /// 서브메뉴 항목들. 지정 시 탭으로 확장/축소 되며 들여쓰기되어 렌더된다.
706
+ final List<ShUiSidebarItem>? children;
707
+
708
+ final int _depth;
709
+
710
+ const ShUiSidebarItem({
711
+ super.key,
712
+ this.icon,
713
+ required this.label,
714
+ this.isActive = false,
715
+ this.onTap,
716
+ this.panelId,
717
+ this.children,
718
+ }) : _depth = 0;
719
+
720
+ const ShUiSidebarItem._nested({
721
+ super.key,
722
+ this.icon,
723
+ required this.label,
724
+ this.isActive = false,
725
+ this.onTap,
726
+ this.panelId,
727
+ this.children,
728
+ required int depth,
729
+ }) : _depth = depth;
730
+
731
+ @override
732
+ State<ShUiSidebarItem> createState() => _ShUiSidebarItemState();
733
+ }
734
+
735
+ class _ShUiSidebarItemState extends State<ShUiSidebarItem>
736
+ with SingleTickerProviderStateMixin {
737
+ bool _hover = false;
738
+ bool _expanded = false;
739
+
740
+ bool get _hasChildren =>
741
+ widget.children != null && widget.children!.isNotEmpty;
742
+
743
+ @override
744
+ Widget build(BuildContext context) {
745
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
746
+ final colors = shUi.colors;
747
+ final scope = _ShUiSidebarScope.of(context);
748
+ final isOpen = scope?.open ?? true;
749
+
750
+ // panelId 기반 자동 활성 판정.
751
+ final panelActive =
752
+ widget.panelId != null && scope?.activePanelId == widget.panelId;
753
+ final resolvedActive = widget.isActive || panelActive;
754
+
755
+ Color bg;
756
+ Color fg;
757
+
758
+ if (resolvedActive) {
759
+ bg = colors.backgroundMuted;
760
+ fg = colors.foreground;
761
+ } else if (_hover) {
762
+ bg = colors.backgroundMuted;
763
+ fg = colors.foreground;
764
+ } else {
765
+ bg = Colors.transparent;
766
+ fg = colors.foregroundMuted;
767
+ }
768
+
769
+ void handleTap() {
770
+ // panelId가 있으면 panel 토글
771
+ if (widget.panelId != null && scope != null) {
772
+ scope.setActivePanel(widget.panelId);
773
+ }
774
+ // children이 있으면 확장 토글
775
+ if (_hasChildren) {
776
+ setState(() => _expanded = !_expanded);
777
+ }
778
+ widget.onTap?.call();
779
+ }
780
+
781
+ final indent = widget._depth * shUi.spacing.s4;
782
+
783
+ final expandedLayout = Row(
784
+ children: [
785
+ if (widget.icon != null) ...[
786
+ Icon(widget.icon, size: 18, color: fg),
787
+ const SizedBox(width: 10),
788
+ ],
789
+ Expanded(
790
+ child: Text(
791
+ widget.label,
792
+ style: TextStyle(
793
+ color: fg,
794
+ fontSize: shUi.text.sm,
795
+ fontWeight: resolvedActive
796
+ ? shUi.weight.medium
797
+ : shUi.weight.regular,
798
+ ),
799
+ overflow: TextOverflow.ellipsis,
800
+ softWrap: false,
801
+ ),
802
+ ),
803
+ if (_hasChildren)
804
+ AnimatedRotation(
805
+ duration: shUi.duration.base,
806
+ curve: shUi.ease.standard,
807
+ turns: _expanded ? 0.25 : 0,
808
+ child: Icon(Icons.chevron_right, size: 16, color: fg),
809
+ ),
810
+ ],
811
+ );
812
+
813
+ final collapsedLayout = Center(
814
+ child: widget.icon != null
815
+ ? Icon(widget.icon, size: 20, color: fg)
816
+ : Text(
817
+ widget.label.isNotEmpty ? widget.label[0] : '',
818
+ style: TextStyle(color: fg, fontSize: shUi.text.sm),
819
+ ),
820
+ );
821
+
822
+ final row = MouseRegion(
823
+ cursor: SystemMouseCursors.click,
824
+ onEnter: (_) => setState(() => _hover = true),
825
+ onExit: (_) => setState(() => _hover = false),
826
+ child: GestureDetector(
827
+ onTap: handleTap,
828
+ child: AnimatedContainer(
829
+ duration: shUi.duration.slow,
830
+ curve: shUi.ease.standard,
831
+ margin: EdgeInsets.fromLTRB(
832
+ shUi.spacing.s2 + indent,
833
+ 1,
834
+ shUi.spacing.s2,
835
+ 1,
836
+ ),
837
+ padding: EdgeInsets.symmetric(
838
+ horizontal: isOpen ? shUi.spacing.s3 : 0,
839
+ vertical: shUi.spacing.s2,
840
+ ),
841
+ decoration: BoxDecoration(
842
+ color: bg,
843
+ borderRadius:
844
+ BorderRadius.circular(shUi.radius.defaultRadius - 2),
845
+ ),
846
+ child: ClipRect(
847
+ child: LayoutBuilder(
848
+ builder: (context, constraints) {
849
+ final w = constraints.maxWidth.isFinite
850
+ ? constraints.maxWidth
851
+ : 0.0;
852
+ return AnimatedCrossFade(
853
+ duration: shUi.duration.slow,
854
+ sizeCurve: shUi.ease.standard,
855
+ firstCurve: shUi.ease.standard,
856
+ secondCurve: shUi.ease.standard,
857
+ alignment: Alignment.centerLeft,
858
+ crossFadeState: isOpen
859
+ ? CrossFadeState.showFirst
860
+ : CrossFadeState.showSecond,
861
+ firstChild: SizedBox(width: w, child: expandedLayout),
862
+ secondChild: SizedBox(width: w, child: collapsedLayout),
863
+ );
864
+ },
865
+ ),
866
+ ),
867
+ ),
868
+ ),
869
+ );
870
+
871
+ if (!_hasChildren) return row;
872
+
873
+ // 서브메뉴: AnimatedSize로 높이 애니메이션.
874
+ return Column(
875
+ crossAxisAlignment: CrossAxisAlignment.stretch,
876
+ mainAxisSize: MainAxisSize.min,
877
+ children: [
878
+ row,
879
+ AnimatedSize(
880
+ duration: shUi.duration.base,
881
+ curve: shUi.ease.standard,
882
+ alignment: Alignment.topCenter,
883
+ child: (_expanded && isOpen)
884
+ ? Column(
885
+ crossAxisAlignment: CrossAxisAlignment.stretch,
886
+ mainAxisSize: MainAxisSize.min,
887
+ children: [
888
+ for (final child in widget.children!)
889
+ ShUiSidebarItem._nested(
890
+ key: child.key,
891
+ icon: child.icon,
892
+ label: child.label,
893
+ isActive: child.isActive,
894
+ onTap: child.onTap,
895
+ panelId: child.panelId,
896
+ children: child.children,
897
+ depth: widget._depth + 1,
898
+ ),
899
+ ],
900
+ )
901
+ : const SizedBox.shrink(),
902
+ ),
903
+ ],
904
+ );
905
+ }
906
+ }
907
+
908
+ /* ───────── Separator ───────── */
909
+
910
+ /// Sidebar 항목 사이의 시각적 구분선.
911
+ class ShUiSidebarSeparator extends StatelessWidget {
912
+ const ShUiSidebarSeparator({super.key});
913
+
914
+ @override
915
+ Widget build(BuildContext context) {
916
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
917
+ return Padding(
918
+ padding: EdgeInsets.symmetric(horizontal: shUi.spacing.s4, vertical: shUi.spacing.s2),
919
+ child: Divider(height: 1, color: shUi.colors.border),
920
+ );
921
+ }
922
+ }
923
+
924
+ /* ───────── TOC (Table of Contents) ─────────
925
+ *
926
+ * 페이지 내 섹션 링크를 트리 형태로 렌더한다.
927
+ * React와 달리 Flutter에는 IntersectionObserver가 없으므로
928
+ * 활성 섹션 id는 호출자가 직접 관리해 `activeId`로 주입한다.
929
+ *
930
+ * ShUiSidebarTOC(
931
+ * activeId: _activeId,
932
+ * onItemTap: (id) => _scrollTo(id),
933
+ * items: const [
934
+ * ShUiSidebarTOCItem(id: 'intro', label: 'Intro'),
935
+ * ShUiSidebarTOCItem(id: 'usage', label: 'Usage', children: [
936
+ * ShUiSidebarTOCItem(id: 'usage-basic', label: 'Basic'),
937
+ * ]),
938
+ * ],
939
+ * )
940
+ */
941
+
942
+ /// [ShUiSidebarTOC]의 항목 모델. id로 활성 섹션을 매칭하고 [children]으로 트리를 구성한다.
943
+ class ShUiSidebarTOCItem {
944
+ /// 활성 섹션 매칭에 사용되는 식별자. 보통 페이지 내 섹션 키.
945
+ final String id;
946
+
947
+ /// 표시 텍스트.
948
+ final String label;
949
+
950
+ /// 서브 항목들. 지정 시 들여쓰기되어 트리로 렌더된다.
951
+ final List<ShUiSidebarTOCItem>? children;
952
+
953
+ const ShUiSidebarTOCItem({
954
+ required this.id,
955
+ required this.label,
956
+ this.children,
957
+ });
958
+ }
959
+
960
+ /// 페이지 내 섹션 링크를 트리로 렌더한다. Flutter에는 IntersectionObserver가 없으므로
961
+ /// 활성 섹션은 호출자가 [activeId]로 직접 주입한다. 탭 시 [onItemTap] 콜백을 받는다.
962
+ class ShUiSidebarTOC extends StatelessWidget {
963
+ /// TOC 항목 목록. 문서 등장 순서대로 나열.
964
+ final List<ShUiSidebarTOCItem> items;
965
+
966
+ /// 현재 활성 섹션 id. 호출자가 스크롤 위치 등에서 직접 계산해 주입한다.
967
+ final String? activeId;
968
+
969
+ /// 항목 탭 콜백. 보통 해당 섹션으로 스크롤하는 동작을 연결한다.
970
+ final ValueChanged<String>? onItemTap;
971
+
972
+ const ShUiSidebarTOC({
973
+ super.key,
974
+ required this.items,
975
+ this.activeId,
976
+ this.onItemTap,
977
+ });
978
+
979
+ @override
980
+ Widget build(BuildContext context) {
981
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
982
+ return Padding(
983
+ padding: EdgeInsets.symmetric(vertical: shUi.spacing.s1),
984
+ child: Column(
985
+ crossAxisAlignment: CrossAxisAlignment.stretch,
986
+ mainAxisSize: MainAxisSize.min,
987
+ children: [
988
+ for (final item in items)
989
+ _TOCNode(item: item, depth: 0, activeId: activeId, onItemTap: onItemTap),
990
+ ],
991
+ ),
992
+ );
993
+ }
994
+ }
995
+
996
+ class _TOCNode extends StatefulWidget {
997
+ final ShUiSidebarTOCItem item;
998
+ final int depth;
999
+ final String? activeId;
1000
+ final ValueChanged<String>? onItemTap;
1001
+
1002
+ const _TOCNode({
1003
+ required this.item,
1004
+ required this.depth,
1005
+ required this.activeId,
1006
+ required this.onItemTap,
1007
+ });
1008
+
1009
+ @override
1010
+ State<_TOCNode> createState() => _TOCNodeState();
1011
+ }
1012
+
1013
+ class _TOCNodeState extends State<_TOCNode> {
1014
+ bool _hover = false;
1015
+
1016
+ @override
1017
+ Widget build(BuildContext context) {
1018
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
1019
+ final colors = shUi.colors;
1020
+ final isActive = widget.activeId == widget.item.id;
1021
+ final fg = isActive
1022
+ ? colors.foreground
1023
+ : (_hover ? colors.foreground : colors.foregroundMuted);
1024
+
1025
+ return Column(
1026
+ crossAxisAlignment: CrossAxisAlignment.stretch,
1027
+ mainAxisSize: MainAxisSize.min,
1028
+ children: [
1029
+ MouseRegion(
1030
+ cursor: SystemMouseCursors.click,
1031
+ onEnter: (_) => setState(() => _hover = true),
1032
+ onExit: (_) => setState(() => _hover = false),
1033
+ child: GestureDetector(
1034
+ onTap: () => widget.onItemTap?.call(widget.item.id),
1035
+ child: Container(
1036
+ color: Colors.transparent,
1037
+ padding: EdgeInsets.fromLTRB(
1038
+ shUi.spacing.s4 + widget.depth * shUi.spacing.s3,
1039
+ shUi.spacing.s1,
1040
+ shUi.spacing.s4,
1041
+ shUi.spacing.s1,
1042
+ ),
1043
+ child: Text(
1044
+ widget.item.label,
1045
+ style: TextStyle(
1046
+ color: fg,
1047
+ fontSize: shUi.text.sm,
1048
+ fontWeight:
1049
+ isActive ? shUi.weight.medium : shUi.weight.regular,
1050
+ ),
1051
+ ),
1052
+ ),
1053
+ ),
1054
+ ),
1055
+ if (widget.item.children != null)
1056
+ for (final child in widget.item.children!)
1057
+ _TOCNode(
1058
+ item: child,
1059
+ depth: widget.depth + 1,
1060
+ activeId: widget.activeId,
1061
+ onItemTap: widget.onItemTap,
1062
+ ),
1063
+ ],
1064
+ );
1065
+ }
1066
+ }
1067
+
1068
+ /* ───────── Panel (보조 확장 패널) ─────────
1069
+ *
1070
+ * ShUiSidebarItem의 panelId와 매칭되는 id로 열리는 보조 패널.
1071
+ * 사이드바 바로 옆(Row)에 배치하면 된다. activePanelId가 일치할 때만 렌더.
1072
+ */
1073
+
1074
+ /// [ShUiSidebarItem]의 panelId와 매칭되는 id로 열리는 보조 확장 패널.
1075
+ /// 사이드바와 메인 콘텐츠 사이의 Row에 배치하며, activePanelId가 일치할 때만 보인다.
1076
+ class ShUiSidebarPanel extends StatelessWidget {
1077
+ /// 트리거가 될 [ShUiSidebarItem.panelId]와 매칭되는 식별자.
1078
+ final String panelId;
1079
+
1080
+ /// 패널 내부 콘텐츠.
1081
+ final Widget child;
1082
+
1083
+ /// 패널 폭(px). 기본 320.
1084
+ final double width;
1085
+
1086
+ const ShUiSidebarPanel({
1087
+ super.key,
1088
+ required this.panelId,
1089
+ required this.child,
1090
+ this.width = 280,
1091
+ });
1092
+
1093
+ @override
1094
+ Widget build(BuildContext context) {
1095
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
1096
+ final colors = shUi.colors;
1097
+ final scope = _ShUiSidebarScope.of(context);
1098
+ final open = scope?.activePanelId == panelId;
1099
+
1100
+ return AnimatedSize(
1101
+ duration: shUi.duration.base,
1102
+ curve: shUi.ease.standard,
1103
+ alignment: Alignment.centerLeft,
1104
+ child: open
1105
+ ? Container(
1106
+ width: width,
1107
+ decoration: BoxDecoration(
1108
+ color: colors.background,
1109
+ border: Border(right: BorderSide(color: colors.border)),
1110
+ ),
1111
+ child: child,
1112
+ )
1113
+ : const SizedBox.shrink(),
1114
+ );
1115
+ }
1116
+ }