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,648 @@
1
+ import 'package:flutter/material.dart';
2
+ import '../foundation/sh_ui_tokens.dart';
3
+
4
+ /// sh-ui DatePicker — 날짜 선택.
5
+ ///
6
+ /// ShUiDatePicker(
7
+ /// value: selectedDate,
8
+ /// onValueChange: (date) => setState(() => selectedDate = date),
9
+ /// placeholder: '날짜 선택',
10
+ /// )
11
+ class ShUiDatePicker extends StatefulWidget {
12
+ final DateTime? value;
13
+ final ValueChanged<DateTime?>? onValueChange;
14
+ final String placeholder;
15
+ final DateTime? min;
16
+ final DateTime? max;
17
+ final String Function(DateTime)? formatDate;
18
+ final bool enabled;
19
+
20
+ const ShUiDatePicker({
21
+ super.key,
22
+ this.value,
23
+ this.onValueChange,
24
+ this.placeholder = '날짜 선택',
25
+ this.min,
26
+ this.max,
27
+ this.formatDate,
28
+ this.enabled = true,
29
+ });
30
+
31
+ @override
32
+ State<ShUiDatePicker> createState() => _ShUiDatePickerState();
33
+ }
34
+
35
+ class _ShUiDatePickerState extends State<ShUiDatePicker> {
36
+ final LayerLink _layerLink = LayerLink();
37
+ OverlayEntry? _overlay;
38
+ bool _isOpen = false;
39
+ late DateTime _focusedMonth;
40
+
41
+ @override
42
+ void initState() {
43
+ super.initState();
44
+ _focusedMonth = widget.value ?? DateTime.now();
45
+ }
46
+
47
+ String _defaultFormat(DateTime d) =>
48
+ '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
49
+
50
+ void _open() {
51
+ if (_overlay != null) return;
52
+ _focusedMonth = widget.value ?? DateTime.now();
53
+ _overlay = OverlayEntry(builder: (_) => _buildOverlay());
54
+ Overlay.of(context).insert(_overlay!);
55
+ setState(() => _isOpen = true);
56
+ }
57
+
58
+ void _close() {
59
+ _overlay?.remove();
60
+ _overlay = null;
61
+ if (mounted) setState(() => _isOpen = false);
62
+ }
63
+
64
+ void _select(DateTime date) {
65
+ widget.onValueChange?.call(date);
66
+ _close();
67
+ }
68
+
69
+ @override
70
+ void dispose() {
71
+ _close();
72
+ super.dispose();
73
+ }
74
+
75
+ Widget _buildOverlay() {
76
+ return _DatePickerOverlay(
77
+ link: _layerLink,
78
+ selected: widget.value,
79
+ focusedMonth: _focusedMonth,
80
+ onFocusedMonthChange: (d) {
81
+ _focusedMonth = d;
82
+ _overlay?.markNeedsBuild();
83
+ },
84
+ onSelect: _select,
85
+ onDismiss: _close,
86
+ min: widget.min,
87
+ max: widget.max,
88
+ );
89
+ }
90
+
91
+ @override
92
+ Widget build(BuildContext context) {
93
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
94
+ final colors = shUi.colors;
95
+ final disabled = !widget.enabled;
96
+ final fmt = widget.formatDate ?? _defaultFormat;
97
+ final displayText = widget.value != null ? fmt(widget.value!) : null;
98
+
99
+ return CompositedTransformTarget(
100
+ link: _layerLink,
101
+ child: Opacity(
102
+ opacity: disabled ? shUi.opacity.disabled : 1,
103
+ child: GestureDetector(
104
+ onTap: disabled ? null : () => _isOpen ? _close() : _open(),
105
+ child: Container(
106
+ height: shUi.control.md,
107
+ padding: EdgeInsets.symmetric(horizontal: shUi.spacing.s3),
108
+ decoration: BoxDecoration(
109
+ color: colors.background,
110
+ border: Border.all(
111
+ color: _isOpen ? colors.foreground : colors.border,
112
+ width: _isOpen ? shUi.borderWidth.strong : shUi.borderWidth.normal,
113
+ ),
114
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
115
+ ),
116
+ child: Row(
117
+ children: [
118
+ Expanded(
119
+ child: Text(
120
+ displayText ?? widget.placeholder,
121
+ style: TextStyle(
122
+ color: displayText != null
123
+ ? colors.foreground
124
+ : colors.foregroundMuted,
125
+ fontSize: shUi.text.sm,
126
+ ),
127
+ ),
128
+ ),
129
+ Icon(Icons.calendar_today_outlined,
130
+ size: 16, color: colors.foregroundMuted),
131
+ ],
132
+ ),
133
+ ),
134
+ ),
135
+ ),
136
+ );
137
+ }
138
+ }
139
+
140
+ /// sh-ui DateRangePicker — 날짜 범위 선택.
141
+ class ShUiDateRangePicker extends StatefulWidget {
142
+ final DateTimeRange? value;
143
+ final ValueChanged<DateTimeRange?>? onValueChange;
144
+ final String placeholder;
145
+ final DateTime? min;
146
+ final DateTime? max;
147
+ final String Function(DateTime)? formatDate;
148
+ final bool enabled;
149
+
150
+ const ShUiDateRangePicker({
151
+ super.key,
152
+ this.value,
153
+ this.onValueChange,
154
+ this.placeholder = '시작일 ~ 종료일',
155
+ this.min,
156
+ this.max,
157
+ this.formatDate,
158
+ this.enabled = true,
159
+ });
160
+
161
+ @override
162
+ State<ShUiDateRangePicker> createState() => _ShUiDateRangePickerState();
163
+ }
164
+
165
+ class _ShUiDateRangePickerState extends State<ShUiDateRangePicker> {
166
+ final LayerLink _layerLink = LayerLink();
167
+ OverlayEntry? _overlay;
168
+ bool _isOpen = false;
169
+ late DateTime _focusedMonth;
170
+ DateTime? _picking; // 첫 번째 날짜 선택 후 두 번째 대기 중
171
+
172
+ @override
173
+ void initState() {
174
+ super.initState();
175
+ _focusedMonth = widget.value?.start ?? DateTime.now();
176
+ }
177
+
178
+ String _defaultFormat(DateTime d) =>
179
+ '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
180
+
181
+ void _open() {
182
+ if (_overlay != null) return;
183
+ _picking = null;
184
+ _focusedMonth = widget.value?.start ?? DateTime.now();
185
+ _overlay = OverlayEntry(builder: (_) => _buildOverlay());
186
+ Overlay.of(context).insert(_overlay!);
187
+ setState(() => _isOpen = true);
188
+ }
189
+
190
+ void _close() {
191
+ _overlay?.remove();
192
+ _overlay = null;
193
+ if (mounted) setState(() => _isOpen = false);
194
+ }
195
+
196
+ void _select(DateTime date) {
197
+ if (_picking == null) {
198
+ _picking = date;
199
+ _overlay?.markNeedsBuild();
200
+ } else {
201
+ final from = _picking!.isBefore(date) ? _picking! : date;
202
+ final to = _picking!.isBefore(date) ? date : _picking!;
203
+ widget.onValueChange?.call(DateTimeRange(start: from, end: to));
204
+ _picking = null;
205
+ _close();
206
+ }
207
+ }
208
+
209
+ @override
210
+ void dispose() {
211
+ _close();
212
+ super.dispose();
213
+ }
214
+
215
+ Widget _buildOverlay() {
216
+ return _DatePickerOverlay(
217
+ link: _layerLink,
218
+ selected: _picking,
219
+ focusedMonth: _focusedMonth,
220
+ onFocusedMonthChange: (d) {
221
+ _focusedMonth = d;
222
+ _overlay?.markNeedsBuild();
223
+ },
224
+ onSelect: _select,
225
+ onDismiss: _close,
226
+ min: widget.min,
227
+ max: widget.max,
228
+ rangeFrom: _picking ?? widget.value?.start,
229
+ rangeTo: _picking != null ? null : widget.value?.end,
230
+ hint: _picking != null ? '종료일을 선택하세요' : null,
231
+ );
232
+ }
233
+
234
+ @override
235
+ Widget build(BuildContext context) {
236
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
237
+ final colors = shUi.colors;
238
+ final disabled = !widget.enabled;
239
+ final fmt = widget.formatDate ?? _defaultFormat;
240
+ final displayText = widget.value != null
241
+ ? '${fmt(widget.value!.start)} ~ ${fmt(widget.value!.end)}'
242
+ : null;
243
+
244
+ return CompositedTransformTarget(
245
+ link: _layerLink,
246
+ child: Opacity(
247
+ opacity: disabled ? shUi.opacity.disabled : 1,
248
+ child: GestureDetector(
249
+ onTap: disabled ? null : () => _isOpen ? _close() : _open(),
250
+ child: Container(
251
+ height: shUi.control.md,
252
+ padding: EdgeInsets.symmetric(horizontal: shUi.spacing.s3),
253
+ decoration: BoxDecoration(
254
+ color: colors.background,
255
+ border: Border.all(
256
+ color: _isOpen ? colors.foreground : colors.border,
257
+ width: _isOpen ? shUi.borderWidth.strong : shUi.borderWidth.normal,
258
+ ),
259
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
260
+ ),
261
+ child: Row(
262
+ children: [
263
+ Expanded(
264
+ child: Text(
265
+ displayText ?? widget.placeholder,
266
+ style: TextStyle(
267
+ color: displayText != null
268
+ ? colors.foreground
269
+ : colors.foregroundMuted,
270
+ fontSize: shUi.text.sm,
271
+ ),
272
+ ),
273
+ ),
274
+ Icon(Icons.calendar_today_outlined,
275
+ size: 16, color: colors.foregroundMuted),
276
+ ],
277
+ ),
278
+ ),
279
+ ),
280
+ ),
281
+ );
282
+ }
283
+ }
284
+
285
+ /* ───────── Calendar overlay (shared) ───────── */
286
+
287
+ class _DatePickerOverlay extends StatelessWidget {
288
+ final LayerLink link;
289
+ final DateTime? selected;
290
+ final DateTime focusedMonth;
291
+ final ValueChanged<DateTime> onFocusedMonthChange;
292
+ final ValueChanged<DateTime> onSelect;
293
+ final VoidCallback onDismiss;
294
+ final DateTime? min;
295
+ final DateTime? max;
296
+ final DateTime? rangeFrom;
297
+ final DateTime? rangeTo;
298
+ final String? hint;
299
+
300
+ const _DatePickerOverlay({
301
+ required this.link,
302
+ this.selected,
303
+ required this.focusedMonth,
304
+ required this.onFocusedMonthChange,
305
+ required this.onSelect,
306
+ required this.onDismiss,
307
+ this.min,
308
+ this.max,
309
+ this.rangeFrom,
310
+ this.rangeTo,
311
+ this.hint,
312
+ });
313
+
314
+ @override
315
+ Widget build(BuildContext context) {
316
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
317
+ final colors = shUi.colors;
318
+
319
+ return Stack(
320
+ children: [
321
+ Positioned.fill(
322
+ child: GestureDetector(
323
+ onTap: onDismiss,
324
+ behavior: HitTestBehavior.opaque,
325
+ child: const SizedBox.expand(),
326
+ ),
327
+ ),
328
+ CompositedTransformFollower(
329
+ link: link,
330
+ offset: const Offset(0, 44),
331
+ child: Material(
332
+ color: Colors.transparent,
333
+ child: Container(
334
+ width: 280,
335
+ padding: EdgeInsets.all(shUi.spacing.s3),
336
+ decoration: BoxDecoration(
337
+ color: colors.background,
338
+ border: Border.all(color: colors.border),
339
+ borderRadius: BorderRadius.circular(shUi.radius.defaultRadius),
340
+ boxShadow: [
341
+ BoxShadow(
342
+ color: Colors.black.withValues(alpha: 0.08),
343
+ blurRadius: 8,
344
+ offset: const Offset(0, 4),
345
+ ),
346
+ ],
347
+ ),
348
+ child: Column(
349
+ mainAxisSize: MainAxisSize.min,
350
+ children: [
351
+ if (hint != null) ...[
352
+ Text(
353
+ hint!,
354
+ style: TextStyle(
355
+ color: colors.foregroundMuted,
356
+ fontSize: shUi.text.xs,
357
+ ),
358
+ ),
359
+ SizedBox(height: shUi.spacing.s2),
360
+ ],
361
+ _ShUiCalendar(
362
+ focusedMonth: focusedMonth,
363
+ selected: selected,
364
+ onSelect: onSelect,
365
+ onFocusedMonthChange: onFocusedMonthChange,
366
+ min: min,
367
+ max: max,
368
+ rangeFrom: rangeFrom,
369
+ rangeTo: rangeTo,
370
+ colors: colors,
371
+ radius: shUi.radius,
372
+ ),
373
+ ],
374
+ ),
375
+ ),
376
+ ),
377
+ ),
378
+ ],
379
+ );
380
+ }
381
+ }
382
+
383
+ /* ───────── Calendar grid ───────── */
384
+
385
+ const _weekLabels = ['일', '월', '화', '수', '목', '금', '토'];
386
+
387
+ bool _isSameDay(DateTime a, DateTime b) =>
388
+ a.year == b.year && a.month == b.month && a.day == b.day;
389
+
390
+ class _ShUiCalendar extends StatelessWidget {
391
+ final DateTime focusedMonth;
392
+ final DateTime? selected;
393
+ final ValueChanged<DateTime> onSelect;
394
+ final ValueChanged<DateTime> onFocusedMonthChange;
395
+ final DateTime? min;
396
+ final DateTime? max;
397
+ final DateTime? rangeFrom;
398
+ final DateTime? rangeTo;
399
+ final ShUiColorTokens colors;
400
+ final ShUiRadiusTokens radius;
401
+
402
+ const _ShUiCalendar({
403
+ required this.focusedMonth,
404
+ this.selected,
405
+ required this.onSelect,
406
+ required this.onFocusedMonthChange,
407
+ this.min,
408
+ this.max,
409
+ this.rangeFrom,
410
+ this.rangeTo,
411
+ required this.colors,
412
+ required this.radius,
413
+ });
414
+
415
+ List<_DayCell> _getDaysGrid() {
416
+ final year = focusedMonth.year;
417
+ final month = focusedMonth.month;
418
+ final first = DateTime(year, month, 1);
419
+ final startDay = first.weekday % 7; // 0=Sun
420
+ final daysInMonth = DateTime(year, month + 1, 0).day;
421
+ final prevDays = DateTime(year, month, 0).day;
422
+
423
+ final cells = <_DayCell>[];
424
+
425
+ for (var i = startDay - 1; i >= 0; i--) {
426
+ cells.add(_DayCell(
427
+ date: DateTime(year, month - 1, prevDays - i),
428
+ current: false,
429
+ ));
430
+ }
431
+ for (var d = 1; d <= daysInMonth; d++) {
432
+ cells.add(_DayCell(date: DateTime(year, month, d), current: true));
433
+ }
434
+ final remaining = 7 - (cells.length % 7);
435
+ if (remaining < 7) {
436
+ for (var d = 1; d <= remaining; d++) {
437
+ cells.add(_DayCell(date: DateTime(year, month + 1, d), current: false));
438
+ }
439
+ }
440
+
441
+ return cells;
442
+ }
443
+
444
+ bool _isDisabled(DateTime date) {
445
+ if (min != null && date.isBefore(DateTime(min!.year, min!.month, min!.day))) {
446
+ return true;
447
+ }
448
+ if (max != null && date.isAfter(DateTime(max!.year, max!.month, max!.day))) {
449
+ return true;
450
+ }
451
+ return false;
452
+ }
453
+
454
+ bool _isInRange(DateTime date) {
455
+ if (rangeFrom == null) return false;
456
+ final end = rangeTo;
457
+ if (end == null) return false;
458
+ final start = rangeFrom!.isBefore(end) ? rangeFrom! : end;
459
+ final finish = rangeFrom!.isBefore(end) ? end : rangeFrom!;
460
+ return !date.isBefore(start) && !date.isAfter(finish);
461
+ }
462
+
463
+ @override
464
+ Widget build(BuildContext context) {
465
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
466
+ final year = focusedMonth.year;
467
+ final month = focusedMonth.month;
468
+ final cells = _getDaysGrid();
469
+ final today = DateTime.now();
470
+ final monthLabel = '$year년 $month월';
471
+
472
+ return Column(
473
+ mainAxisSize: MainAxisSize.min,
474
+ children: [
475
+ // Header
476
+ Row(
477
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
478
+ children: [
479
+ GestureDetector(
480
+ onTap: () {
481
+ final m = month - 1;
482
+ onFocusedMonthChange(
483
+ m < 1 ? DateTime(year - 1, 12, 1) : DateTime(year, m, 1),
484
+ );
485
+ },
486
+ child: Icon(Icons.chevron_left, size: 20, color: colors.foreground),
487
+ ),
488
+ Text(
489
+ monthLabel,
490
+ style: TextStyle(
491
+ color: colors.foreground,
492
+ fontSize: shUi.text.sm,
493
+ fontWeight: shUi.weight.semibold,
494
+ ),
495
+ ),
496
+ GestureDetector(
497
+ onTap: () {
498
+ final m = month + 1;
499
+ onFocusedMonthChange(
500
+ m > 12 ? DateTime(year + 1, 1, 1) : DateTime(year, m, 1),
501
+ );
502
+ },
503
+ child: Icon(Icons.chevron_right, size: 20, color: colors.foreground),
504
+ ),
505
+ ],
506
+ ),
507
+ SizedBox(height: shUi.spacing.s2),
508
+ // Weekday labels
509
+ Row(
510
+ children: _weekLabels
511
+ .map((l) => Expanded(
512
+ child: Center(
513
+ child: Text(
514
+ l,
515
+ style: TextStyle(
516
+ color: colors.foregroundMuted,
517
+ fontSize: shUi.text.xs,
518
+ fontWeight: shUi.weight.medium,
519
+ ),
520
+ ),
521
+ ),
522
+ ))
523
+ .toList(),
524
+ ),
525
+ SizedBox(height: shUi.spacing.s1),
526
+ // Day grid
527
+ ...List.generate((cells.length / 7).ceil(), (row) {
528
+ return Row(
529
+ children: List.generate(7, (col) {
530
+ final idx = row * 7 + col;
531
+ if (idx >= cells.length) return const Expanded(child: SizedBox());
532
+ final cell = cells[idx];
533
+ final disabled = _isDisabled(cell.date);
534
+ final isSelected = selected != null && _isSameDay(cell.date, selected!);
535
+ final isToday = _isSameDay(cell.date, today);
536
+ final inRange = _isInRange(cell.date);
537
+
538
+ return Expanded(
539
+ child: _DayButton(
540
+ day: cell.date.day,
541
+ current: cell.current,
542
+ selected: isSelected,
543
+ today: isToday,
544
+ inRange: inRange,
545
+ disabled: disabled,
546
+ onTap: disabled ? null : () => onSelect(cell.date),
547
+ colors: colors,
548
+ radius: radius,
549
+ ),
550
+ );
551
+ }),
552
+ );
553
+ }),
554
+ ],
555
+ );
556
+ }
557
+ }
558
+
559
+ class _DayCell {
560
+ final DateTime date;
561
+ final bool current;
562
+ _DayCell({required this.date, required this.current});
563
+ }
564
+
565
+ class _DayButton extends StatefulWidget {
566
+ final int day;
567
+ final bool current;
568
+ final bool selected;
569
+ final bool today;
570
+ final bool inRange;
571
+ final bool disabled;
572
+ final VoidCallback? onTap;
573
+ final ShUiColorTokens colors;
574
+ final ShUiRadiusTokens radius;
575
+
576
+ const _DayButton({
577
+ required this.day,
578
+ required this.current,
579
+ required this.selected,
580
+ required this.today,
581
+ required this.inRange,
582
+ required this.disabled,
583
+ this.onTap,
584
+ required this.colors,
585
+ required this.radius,
586
+ });
587
+
588
+ @override
589
+ State<_DayButton> createState() => _DayButtonState();
590
+ }
591
+
592
+ class _DayButtonState extends State<_DayButton> {
593
+ bool _hover = false;
594
+
595
+ @override
596
+ Widget build(BuildContext context) {
597
+ Color bg;
598
+ Color fg;
599
+
600
+ if (widget.selected) {
601
+ bg = widget.colors.primary;
602
+ fg = widget.colors.primaryForeground;
603
+ } else if (widget.inRange) {
604
+ bg = widget.colors.primary.withValues(alpha: 0.1);
605
+ fg = widget.colors.foreground;
606
+ } else if (_hover && !widget.disabled) {
607
+ bg = widget.colors.backgroundMuted;
608
+ fg = widget.colors.foreground;
609
+ } else {
610
+ bg = Colors.transparent;
611
+ fg = widget.current ? widget.colors.foreground : widget.colors.foregroundMuted;
612
+ }
613
+
614
+ final shUi = Theme.of(context).extension<ShUiTheme>() ?? ShUiTheme.light;
615
+ return MouseRegion(
616
+ onEnter: (_) => setState(() => _hover = true),
617
+ onExit: (_) => setState(() => _hover = false),
618
+ child: GestureDetector(
619
+ onTap: widget.onTap,
620
+ child: Container(
621
+ height: shUi.control.sm,
622
+ margin: const EdgeInsets.all(1),
623
+ decoration: BoxDecoration(
624
+ color: bg,
625
+ borderRadius: BorderRadius.circular(widget.radius.defaultRadius - 2),
626
+ border: widget.today && !widget.selected
627
+ ? Border.all(color: widget.colors.primary, width: shUi.borderWidth.normal)
628
+ : null,
629
+ ),
630
+ alignment: Alignment.center,
631
+ child: Opacity(
632
+ opacity: widget.disabled ? 0.3 : 1,
633
+ child: Text(
634
+ '${widget.day}',
635
+ style: TextStyle(
636
+ color: fg,
637
+ fontSize: 13,
638
+ fontWeight: widget.today || widget.selected
639
+ ? shUi.weight.semibold
640
+ : shUi.weight.regular,
641
+ ),
642
+ ),
643
+ ),
644
+ ),
645
+ ),
646
+ );
647
+ }
648
+ }