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,757 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Popover as BasePopover } from "@base-ui-components/react/popover";
5
+ import "./styles.css";
6
+
7
+ /* ───────── Helpers ───────── */
8
+
9
+ function cx(...args: (string | undefined | false)[]) {
10
+ return args.filter(Boolean).join(" ");
11
+ }
12
+
13
+ const WEEK_LABELS = ["일", "월", "화", "수", "목", "금", "토"] as const;
14
+
15
+ const isSameDay = (a: Date, b: Date) =>
16
+ a.getFullYear() === b.getFullYear() &&
17
+ a.getMonth() === b.getMonth() &&
18
+ a.getDate() === b.getDate();
19
+
20
+ const toDateOnly = (d: Date) =>
21
+ new Date(d.getFullYear(), d.getMonth(), d.getDate());
22
+
23
+ const formatDefault = (d: Date) =>
24
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
25
+
26
+ const clampMonth = (year: number, month: number): [number, number] => {
27
+ if (month < 0) return [year - 1, 11];
28
+ if (month > 11) return [year + 1, 0];
29
+ return [year, month];
30
+ };
31
+
32
+ function getDaysGrid(year: number, month: number) {
33
+ const first = new Date(year, month, 1);
34
+ const startDay = first.getDay();
35
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
36
+ const prevDays = new Date(year, month, 0).getDate();
37
+
38
+ const cells: { date: Date; current: boolean }[] = [];
39
+
40
+ for (let i = startDay - 1; i >= 0; i--) {
41
+ cells.push({ date: new Date(year, month - 1, prevDays - i), current: false });
42
+ }
43
+ for (let d = 1; d <= daysInMonth; d++) {
44
+ cells.push({ date: new Date(year, month, d), current: true });
45
+ }
46
+ const remaining = 7 - (cells.length % 7);
47
+ if (remaining < 7) {
48
+ for (let d = 1; d <= remaining; d++) {
49
+ cells.push({ date: new Date(year, month + 1, d), current: false });
50
+ }
51
+ }
52
+
53
+ return cells;
54
+ }
55
+
56
+ /* ───────── Types ───────── */
57
+
58
+ export interface DateRange {
59
+ /** 시작일 (포함). */
60
+ from: Date;
61
+ /** 종료일 (포함). */
62
+ to: Date;
63
+ }
64
+
65
+ /* ───────── Icons ───────── */
66
+
67
+ function ChevronLeftIcon() {
68
+ return (
69
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
70
+ <path d="M10 3 5.5 8 10 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
71
+ </svg>
72
+ );
73
+ }
74
+
75
+ function ChevronRightIcon() {
76
+ return (
77
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
78
+ <path d="M6 3 10.5 8 6 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
79
+ </svg>
80
+ );
81
+ }
82
+
83
+ function CalendarIcon() {
84
+ return (
85
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
86
+ <rect x="2" y="3" width="12" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
87
+ <path d="M2 6.5h12M5.5 2v2M10.5 2v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
88
+ </svg>
89
+ );
90
+ }
91
+
92
+ /* ───────── Context ───────── */
93
+
94
+ interface DatePickerContextValue {
95
+ selected: Date | undefined;
96
+ setSelected: (date: Date | undefined) => void;
97
+ open: boolean;
98
+ setOpen: (open: boolean) => void;
99
+ focusedDate: Date;
100
+ setFocusedDate: (date: Date) => void;
101
+ formatDate: (date: Date) => string;
102
+ placeholder: string;
103
+ min?: Date;
104
+ max?: Date;
105
+ disabled?: boolean;
106
+ readOnly?: boolean;
107
+ ariaInvalid?: boolean | "true";
108
+ closeOnSelect: boolean;
109
+ }
110
+
111
+ const DatePickerContext = React.createContext<DatePickerContextValue | null>(null);
112
+
113
+ function useDatePickerContext(component: string) {
114
+ const ctx = React.useContext(DatePickerContext);
115
+ if (!ctx) {
116
+ throw new Error(`${component}는 <DatePicker> 내부에서 사용해야 합니다.`);
117
+ }
118
+ return ctx;
119
+ }
120
+
121
+ /* ───────── Internal Calendar Grid (shared w/ range picker) ───────── */
122
+
123
+ interface CalendarProps {
124
+ selected?: Date;
125
+ onSelect: (date: Date) => void;
126
+ min?: Date;
127
+ max?: Date;
128
+ focusedDate: Date;
129
+ onFocusedDateChange: (date: Date) => void;
130
+ rangeFrom?: Date;
131
+ rangeTo?: Date;
132
+ hoverDate?: Date;
133
+ onHoverDate?: (date: Date | undefined) => void;
134
+ }
135
+
136
+ function Calendar({
137
+ selected,
138
+ onSelect,
139
+ min,
140
+ max,
141
+ focusedDate,
142
+ onFocusedDateChange,
143
+ rangeFrom,
144
+ rangeTo,
145
+ hoverDate,
146
+ onHoverDate,
147
+ }: CalendarProps) {
148
+ const year = focusedDate.getFullYear();
149
+ const month = focusedDate.getMonth();
150
+ const cells = getDaysGrid(year, month);
151
+ const today = new Date();
152
+
153
+ const navigate = (newYear: number, newMonth: number) => {
154
+ const [y, m] = clampMonth(newYear, newMonth);
155
+ onFocusedDateChange(new Date(y, m, 1));
156
+ };
157
+
158
+ const isDisabled = (date: Date) => {
159
+ if (min && date < toDateOnly(min)) return true;
160
+ if (max && date > toDateOnly(max)) return true;
161
+ return false;
162
+ };
163
+
164
+ const handleKeyDown = (e: React.KeyboardEvent) => {
165
+ let next: Date | null = null;
166
+ const cursor = selected ?? focusedDate;
167
+
168
+ switch (e.key) {
169
+ case "ArrowLeft":
170
+ next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() - 1);
171
+ break;
172
+ case "ArrowRight":
173
+ next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1);
174
+ break;
175
+ case "ArrowUp":
176
+ next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() - 7);
177
+ break;
178
+ case "ArrowDown":
179
+ next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 7);
180
+ break;
181
+ case "Enter":
182
+ case " ":
183
+ e.preventDefault();
184
+ if (!isDisabled(cursor)) onSelect(cursor);
185
+ return;
186
+ default:
187
+ return;
188
+ }
189
+
190
+ e.preventDefault();
191
+ if (next && !isDisabled(next)) {
192
+ if (next.getMonth() !== month || next.getFullYear() !== year) {
193
+ onFocusedDateChange(new Date(next.getFullYear(), next.getMonth(), 1));
194
+ }
195
+ onSelect(next);
196
+ }
197
+ };
198
+
199
+ const getRangeState = (date: Date) => {
200
+ if (!rangeFrom) return { inRange: false, isStart: false, isEnd: false };
201
+
202
+ const end = rangeTo ?? hoverDate;
203
+ if (!end) return { inRange: false, isStart: isSameDay(date, rangeFrom), isEnd: false };
204
+
205
+ let [rStart, rEnd] = rangeFrom <= end ? [rangeFrom, end] : [end, rangeFrom];
206
+ rStart = toDateOnly(rStart);
207
+ rEnd = toDateOnly(rEnd);
208
+ const d = toDateOnly(date);
209
+
210
+ return {
211
+ inRange: d >= rStart && d <= rEnd,
212
+ isStart: isSameDay(d, rStart),
213
+ isEnd: isSameDay(d, rEnd),
214
+ };
215
+ };
216
+
217
+ const monthLabel = `${year}년 ${month + 1}월`;
218
+
219
+ return (
220
+ <div className="sh-ui-calendar" role="group" aria-label={monthLabel}>
221
+ <div className="sh-ui-calendar__header">
222
+ <button
223
+ type="button"
224
+ className="sh-ui-calendar__nav"
225
+ onClick={() => navigate(year, month - 1)}
226
+ aria-label="이전 달"
227
+ >
228
+ <ChevronLeftIcon />
229
+ </button>
230
+ <span className="sh-ui-calendar__title">{monthLabel}</span>
231
+ <button
232
+ type="button"
233
+ className="sh-ui-calendar__nav"
234
+ onClick={() => navigate(year, month + 1)}
235
+ aria-label="다음 달"
236
+ >
237
+ <ChevronRightIcon />
238
+ </button>
239
+ </div>
240
+
241
+ <div className="sh-ui-calendar__weekdays" role="row">
242
+ {WEEK_LABELS.map((label) => (
243
+ <span key={label} className="sh-ui-calendar__weekday" role="columnheader" aria-label={label}>
244
+ {label}
245
+ </span>
246
+ ))}
247
+ </div>
248
+
249
+ <div
250
+ className="sh-ui-calendar__grid"
251
+ role="grid"
252
+ tabIndex={0}
253
+ onKeyDown={handleKeyDown}
254
+ aria-label={monthLabel}
255
+ >
256
+ {cells.map(({ date, current }, i) => {
257
+ const disabled = isDisabled(date);
258
+ const isSelected = selected && isSameDay(date, selected);
259
+ const isToday = isSameDay(date, today);
260
+ const { inRange, isStart, isEnd } = getRangeState(date);
261
+
262
+ return (
263
+ <button
264
+ key={i}
265
+ type="button"
266
+ className={cx(
267
+ "sh-ui-calendar__day",
268
+ !current && "sh-ui-calendar__day--outside",
269
+ isSelected && "sh-ui-calendar__day--selected",
270
+ isToday && "sh-ui-calendar__day--today",
271
+ inRange && "sh-ui-calendar__day--in-range",
272
+ isStart && "sh-ui-calendar__day--range-start",
273
+ isEnd && "sh-ui-calendar__day--range-end",
274
+ )}
275
+ disabled={disabled}
276
+ tabIndex={-1}
277
+ onClick={() => { if (!disabled) onSelect(date); }}
278
+ onMouseEnter={() => onHoverDate?.(date)}
279
+ onMouseLeave={() => onHoverDate?.(undefined)}
280
+ aria-label={formatDefault(date)}
281
+ aria-selected={isSelected || inRange || undefined}
282
+ data-today={isToday || undefined}
283
+ >
284
+ {date.getDate()}
285
+ </button>
286
+ );
287
+ })}
288
+ </div>
289
+ </div>
290
+ );
291
+ }
292
+
293
+ /* ───────── DatePicker Root ───────── */
294
+
295
+ export interface DatePickerProps {
296
+ /** 제어 모드 선택값. `undefined`는 미선택. */
297
+ value?: Date;
298
+ /** 비제어 모드 초기값. */
299
+ defaultValue?: Date;
300
+ /** 값 변경 콜백. 미선택 상태로 전환되면 `undefined`. */
301
+ onValueChange?: (date: Date | undefined) => void;
302
+ /**
303
+ * 트리거에 표시할 문자열 포맷터.
304
+ * @default (d) => "YYYY-MM-DD"
305
+ */
306
+ formatDate?: (date: Date) => string;
307
+ /** 선택 가능 최소 날짜 (포함). 이전 날짜는 비활성. */
308
+ min?: Date;
309
+ /** 선택 가능 최대 날짜 (포함). 이후 날짜는 비활성. */
310
+ max?: Date;
311
+ /**
312
+ * 미선택 상태의 트리거 텍스트.
313
+ * @default "날짜 선택"
314
+ */
315
+ placeholder?: string;
316
+ /** 비활성. 트리거 클릭·키보드 모두 차단. */
317
+ disabled?: boolean;
318
+ /** 읽기 전용. 트리거 표시는 유지하되 popover가 열리지 않는다. */
319
+ readOnly?: boolean;
320
+ /** invalid 상태. 트리거 보더가 위험색으로 바뀌고 스크린리더에 오류로 노출. */
321
+ "aria-invalid"?: boolean | "true";
322
+ /**
323
+ * children 없을 때(기본 레이아웃) Trigger로 전달된다.
324
+ * children 조립 모드에서는 `DatePickerTrigger`에 직접 className을 넘긴다.
325
+ */
326
+ className?: string;
327
+ /**
328
+ * 날짜 선택 시 popover 자동 닫힘.
329
+ * @default true
330
+ */
331
+ closeOnSelect?: boolean;
332
+ /**
333
+ * compound 모드. 미지정 시 기본 레이아웃(Trigger + Content + Calendar)이 자동 렌더된다.
334
+ * 직접 조립하려면 `DatePickerTrigger`/`DatePickerContent`/`DatePickerCalendar`/`DatePickerFooter`를 자식으로 넘긴다.
335
+ */
336
+ children?: React.ReactNode;
337
+ }
338
+
339
+ /**
340
+ * 단일 날짜 선택. 트리거 클릭 시 popover 캘린더가 열리고 키보드 화살표로 이동한다.
341
+ * children을 생략하면 기본 레이아웃이 자동 렌더되며, 직접 조립하려면 DatePickerTrigger/Content/Calendar/Footer를 사용한다.
342
+ */
343
+ export function DatePicker({
344
+ value,
345
+ defaultValue,
346
+ onValueChange,
347
+ formatDate = formatDefault,
348
+ min,
349
+ max,
350
+ placeholder = "날짜 선택",
351
+ disabled,
352
+ readOnly,
353
+ "aria-invalid": ariaInvalid,
354
+ className,
355
+ closeOnSelect = true,
356
+ children,
357
+ }: DatePickerProps) {
358
+ const isControlled = value !== undefined;
359
+ const [internal, setInternal] = React.useState<Date | undefined>(defaultValue);
360
+ const selected = isControlled ? value : internal;
361
+
362
+ const [open, setOpen] = React.useState(false);
363
+ const [focusedDate, setFocusedDate] = React.useState<Date>(
364
+ () => selected ?? new Date(),
365
+ );
366
+
367
+ React.useEffect(() => {
368
+ if (open && selected) {
369
+ setFocusedDate(new Date(selected.getFullYear(), selected.getMonth(), 1));
370
+ }
371
+ }, [open, selected]);
372
+
373
+ const setSelected = React.useCallback(
374
+ (date: Date | undefined) => {
375
+ if (!isControlled) setInternal(date);
376
+ onValueChange?.(date);
377
+ },
378
+ [isControlled, onValueChange],
379
+ );
380
+
381
+ const ctx = React.useMemo<DatePickerContextValue>(
382
+ () => ({
383
+ selected,
384
+ setSelected,
385
+ open,
386
+ setOpen,
387
+ focusedDate,
388
+ setFocusedDate,
389
+ formatDate,
390
+ placeholder,
391
+ min,
392
+ max,
393
+ disabled,
394
+ readOnly,
395
+ ariaInvalid,
396
+ closeOnSelect,
397
+ }),
398
+ [
399
+ selected,
400
+ setSelected,
401
+ open,
402
+ focusedDate,
403
+ formatDate,
404
+ placeholder,
405
+ min,
406
+ max,
407
+ disabled,
408
+ readOnly,
409
+ ariaInvalid,
410
+ closeOnSelect,
411
+ ],
412
+ );
413
+
414
+ return (
415
+ <DatePickerContext.Provider value={ctx}>
416
+ <BasePopover.Root open={open} onOpenChange={setOpen}>
417
+ {children ?? (
418
+ <>
419
+ <DatePickerTrigger className={className} />
420
+ <DatePickerContent>
421
+ <DatePickerCalendar />
422
+ </DatePickerContent>
423
+ </>
424
+ )}
425
+ </BasePopover.Root>
426
+ </DatePickerContext.Provider>
427
+ );
428
+ }
429
+
430
+ /* ───────── DatePickerTrigger ───────── */
431
+
432
+ export interface DatePickerTriggerProps
433
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
434
+ /**
435
+ * 트리거 본문. 직접 노드를 넘기거나, 함수를 넘기면 현재 상태를 받아 직접 렌더할 수 있다.
436
+ *
437
+ * @example
438
+ * <DatePickerTrigger>
439
+ * {({ formatted, placeholder }) => <span>{formatted ?? placeholder}</span>}
440
+ * </DatePickerTrigger>
441
+ */
442
+ children?:
443
+ | React.ReactNode
444
+ | ((state: {
445
+ /** 현재 선택된 Date. 미선택 시 `undefined`. */
446
+ value: Date | undefined;
447
+ /** `formatDate`로 포맷된 문자열. 미선택 시 `undefined`. */
448
+ formatted: string | undefined;
449
+ /** DatePicker `placeholder` prop. */
450
+ placeholder: string;
451
+ }) => React.ReactNode);
452
+ }
453
+
454
+ /**
455
+ * 캘린더 popover를 여는 트리거 버튼. children에 함수를 넘기면 현재 값/포맷 문자열/placeholder를
456
+ * 받아 직접 렌더할 수 있다.
457
+ */
458
+ export const DatePickerTrigger = React.forwardRef<HTMLButtonElement, DatePickerTriggerProps>(
459
+ function DatePickerTrigger({ className, children, onClick, ...props }, ref) {
460
+ const ctx = useDatePickerContext("DatePickerTrigger");
461
+ const displayText = ctx.selected ? ctx.formatDate(ctx.selected) : undefined;
462
+
463
+ const renderContent = () => {
464
+ if (typeof children === "function") {
465
+ return children({
466
+ value: ctx.selected,
467
+ formatted: displayText,
468
+ placeholder: ctx.placeholder,
469
+ });
470
+ }
471
+ if (children !== undefined) return children;
472
+ return (
473
+ <>
474
+ <span
475
+ className={cx(
476
+ "sh-ui-date-picker__value",
477
+ !displayText && "sh-ui-date-picker__placeholder",
478
+ )}
479
+ >
480
+ {displayText ?? ctx.placeholder}
481
+ </span>
482
+ <span className="sh-ui-date-picker__icon" aria-hidden>
483
+ <CalendarIcon />
484
+ </span>
485
+ </>
486
+ );
487
+ };
488
+
489
+ return (
490
+ <BasePopover.Trigger
491
+ ref={ref}
492
+ className={cx("sh-ui-date-picker__trigger", className)}
493
+ disabled={ctx.disabled}
494
+ aria-invalid={ctx.ariaInvalid}
495
+ aria-haspopup="dialog"
496
+ onClick={(e) => {
497
+ if (ctx.readOnly) e.preventDefault();
498
+ onClick?.(e);
499
+ }}
500
+ {...props}
501
+ >
502
+ {renderContent()}
503
+ </BasePopover.Trigger>
504
+ );
505
+ },
506
+ );
507
+
508
+ /* ───────── DatePickerContent ───────── */
509
+
510
+ export interface DatePickerContentProps
511
+ extends Omit<React.ComponentPropsWithoutRef<typeof BasePopover.Popup>, "className"> {
512
+ className?: string;
513
+ /**
514
+ * Trigger와 popover 간격(px).
515
+ * @default 4
516
+ */
517
+ sideOffset?: React.ComponentPropsWithoutRef<typeof BasePopover.Positioner>["sideOffset"];
518
+ /**
519
+ * Trigger 기준 popover 방향. 공간 부족 시 자동 반대편으로 뒤집힘.
520
+ * @default "bottom"
521
+ */
522
+ side?: React.ComponentPropsWithoutRef<typeof BasePopover.Positioner>["side"];
523
+ /**
524
+ * Trigger 축에서의 정렬.
525
+ * @default "start"
526
+ */
527
+ align?: React.ComponentPropsWithoutRef<typeof BasePopover.Positioner>["align"];
528
+ }
529
+
530
+ /** 캘린더 popover 본문. portal로 마운트되며 `disabled`/`readOnly`이면 렌더되지 않는다. */
531
+ export const DatePickerContent = React.forwardRef<HTMLDivElement, DatePickerContentProps>(
532
+ function DatePickerContent(
533
+ { className, children, sideOffset = 4, side = "bottom", align = "start", ...props },
534
+ ref,
535
+ ) {
536
+ const ctx = useDatePickerContext("DatePickerContent");
537
+ if (ctx.disabled || ctx.readOnly) return null;
538
+
539
+ return (
540
+ <BasePopover.Portal>
541
+ <BasePopover.Positioner
542
+ className="sh-ui-date-picker__positioner"
543
+ sideOffset={sideOffset}
544
+ side={side}
545
+ align={align}
546
+ >
547
+ <BasePopover.Popup
548
+ ref={ref}
549
+ className={cx("sh-ui-date-picker__popup", className)}
550
+ {...props}
551
+ >
552
+ {children}
553
+ </BasePopover.Popup>
554
+ </BasePopover.Positioner>
555
+ </BasePopover.Portal>
556
+ );
557
+ },
558
+ );
559
+
560
+ /* ───────── DatePickerCalendar ───────── */
561
+
562
+ /** 월 단위 날짜 그리드. 화살표 키 이동, Home/End, Enter/Space 선택을 지원한다. */
563
+ export function DatePickerCalendar() {
564
+ const ctx = useDatePickerContext("DatePickerCalendar");
565
+
566
+ const handleSelect = (date: Date) => {
567
+ ctx.setSelected(date);
568
+ if (ctx.closeOnSelect) ctx.setOpen(false);
569
+ };
570
+
571
+ return (
572
+ <Calendar
573
+ selected={ctx.selected}
574
+ onSelect={handleSelect}
575
+ min={ctx.min}
576
+ max={ctx.max}
577
+ focusedDate={ctx.focusedDate}
578
+ onFocusedDateChange={ctx.setFocusedDate}
579
+ />
580
+ );
581
+ }
582
+
583
+ /* ───────── DatePickerFooter ───────── */
584
+
585
+ export interface DatePickerFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
586
+
587
+ /** popover 하단 액션 영역. "오늘", "지우기" 같은 커스텀 버튼을 두는 슬롯. */
588
+ export const DatePickerFooter = React.forwardRef<HTMLDivElement, DatePickerFooterProps>(
589
+ function DatePickerFooter({ className, ...props }, ref) {
590
+ return (
591
+ <div
592
+ ref={ref}
593
+ className={cx("sh-ui-date-picker__footer", className)}
594
+ {...props}
595
+ />
596
+ );
597
+ },
598
+ );
599
+
600
+ /* ───────── useDatePicker (for custom footer actions) ───────── */
601
+
602
+ /** 커스텀 footer 액션에서 값/open 상태를 직접 다룰 때 사용. DatePicker 내부에서만 호출 가능. */
603
+ export function useDatePicker() {
604
+ const ctx = useDatePickerContext("useDatePicker");
605
+ return {
606
+ value: ctx.selected,
607
+ setValue: ctx.setSelected,
608
+ open: ctx.open,
609
+ setOpen: ctx.setOpen,
610
+ focusedDate: ctx.focusedDate,
611
+ setFocusedDate: ctx.setFocusedDate,
612
+ };
613
+ }
614
+
615
+ /* ───────── DateRangePicker (단일 컴포넌트, 스코프 외) ───────── */
616
+
617
+ export interface DateRangePickerProps {
618
+ /** 선택된 범위 (controlled). */
619
+ value?: DateRange;
620
+ /** 초기 범위 (uncontrolled). */
621
+ defaultValue?: DateRange;
622
+ /** 범위 변경 콜백. */
623
+ onValueChange?: (range: DateRange | undefined) => void;
624
+ /** 표시 포맷 함수. 기본 YYYY-MM-DD. */
625
+ formatDate?: (date: Date) => string;
626
+ /** 선택 가능 최소 날짜. */
627
+ min?: Date;
628
+ /** 선택 가능 최대 날짜. */
629
+ max?: Date;
630
+ /**
631
+ * 미선택 상태의 트리거 텍스트.
632
+ * @default "시작일 ~ 종료일"
633
+ */
634
+ placeholder?: string;
635
+ /** 비활성. */
636
+ disabled?: boolean;
637
+ /** 읽기 전용. popover가 열리지 않는다. */
638
+ readOnly?: boolean;
639
+ /** invalid 상태. */
640
+ "aria-invalid"?: boolean | "true";
641
+ className?: string;
642
+ }
643
+
644
+ /**
645
+ * 시작·종료일을 선택하는 범위 picker. 첫 클릭으로 시작일, 두 번째 클릭으로 종료일이 결정된다.
646
+ * 호버 시 미리보기 범위가 시각화되고 두 번째 선택과 동시에 popover가 닫힌다.
647
+ */
648
+ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePickerProps>(
649
+ function DateRangePicker(
650
+ {
651
+ value,
652
+ defaultValue,
653
+ onValueChange,
654
+ formatDate = formatDefault,
655
+ min,
656
+ max,
657
+ placeholder = "시작일 ~ 종료일",
658
+ disabled,
659
+ readOnly,
660
+ "aria-invalid": ariaInvalid,
661
+ className,
662
+ },
663
+ ref,
664
+ ) {
665
+ const isControlled = value !== undefined;
666
+ const [internal, setInternal] = React.useState<DateRange | undefined>(defaultValue);
667
+ const selected = isControlled ? value : internal;
668
+
669
+ const [open, setOpen] = React.useState(false);
670
+ const [picking, setPicking] = React.useState<Date | undefined>(undefined);
671
+ const [hoverDate, setHoverDate] = React.useState<Date | undefined>(undefined);
672
+ const [focusedDate, setFocusedDate] = React.useState(
673
+ () => selected?.from ?? new Date(),
674
+ );
675
+
676
+ React.useEffect(() => {
677
+ if (open) {
678
+ setPicking(undefined);
679
+ setHoverDate(undefined);
680
+ if (selected?.from) {
681
+ setFocusedDate(new Date(selected.from.getFullYear(), selected.from.getMonth(), 1));
682
+ }
683
+ }
684
+ }, [open, selected?.from]);
685
+
686
+ const handleSelect = (date: Date) => {
687
+ if (!picking) {
688
+ setPicking(date);
689
+ return;
690
+ }
691
+
692
+ const [from, to] = picking <= date ? [picking, date] : [date, picking];
693
+ const range: DateRange = { from, to };
694
+
695
+ if (!isControlled) setInternal(range);
696
+ onValueChange?.(range);
697
+ setPicking(undefined);
698
+ setHoverDate(undefined);
699
+ setOpen(false);
700
+ };
701
+
702
+ const displayText = selected
703
+ ? `${formatDate(selected.from)} ~ ${formatDate(selected.to)}`
704
+ : undefined;
705
+
706
+ return (
707
+ <BasePopover.Root open={open} onOpenChange={setOpen}>
708
+ <BasePopover.Trigger
709
+ ref={ref}
710
+ className={cx("sh-ui-date-picker__trigger", className)}
711
+ disabled={disabled}
712
+ aria-invalid={ariaInvalid}
713
+ aria-haspopup="dialog"
714
+ onClick={(e) => {
715
+ if (readOnly) e.preventDefault();
716
+ }}
717
+ >
718
+ <span className={cx("sh-ui-date-picker__value", !displayText && "sh-ui-date-picker__placeholder")}>
719
+ {displayText ?? placeholder}
720
+ </span>
721
+ <span className="sh-ui-date-picker__icon" aria-hidden>
722
+ <CalendarIcon />
723
+ </span>
724
+ </BasePopover.Trigger>
725
+
726
+ {!disabled && !readOnly && (
727
+ <BasePopover.Portal>
728
+ <BasePopover.Positioner
729
+ className="sh-ui-date-picker__positioner"
730
+ sideOffset={4}
731
+ side="bottom"
732
+ align="start"
733
+ >
734
+ <BasePopover.Popup className="sh-ui-date-picker__popup">
735
+ {picking && (
736
+ <p className="sh-ui-date-picker__hint">종료일을 선택하세요</p>
737
+ )}
738
+ <Calendar
739
+ selected={picking}
740
+ onSelect={handleSelect}
741
+ min={min}
742
+ max={max}
743
+ focusedDate={focusedDate}
744
+ onFocusedDateChange={setFocusedDate}
745
+ rangeFrom={picking ?? selected?.from}
746
+ rangeTo={picking ? undefined : selected?.to}
747
+ hoverDate={picking ? hoverDate : undefined}
748
+ onHoverDate={picking ? setHoverDate : undefined}
749
+ />
750
+ </BasePopover.Popup>
751
+ </BasePopover.Positioner>
752
+ </BasePopover.Portal>
753
+ )}
754
+ </BasePopover.Root>
755
+ );
756
+ },
757
+ );