sh-ui-cli 0.44.0 → 0.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,30 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$description": "sh-ui 릴리즈 노트 단일 소스. docs(React)와 showcase(Flutter)가 함께 읽는다. 새 릴리즈마다 맨 앞에 추가.",
4
4
  "versions": [
5
+ {
6
+ "version": "0.45.1",
7
+ "date": "2026-04-30",
8
+ "title": "릴리즈 자동화 — `pnpm release` 명령 + mcp.mjs 동적 버전",
9
+ "type": "patch",
10
+ "highlights": [
11
+ "**`pnpm release <patch|minor|major|X.Y.Z>` 명령 신설** — packages/cli/package.json 의 version 갱신 + packages/changelog/versions.json 에 placeholder 엔트리 prepend 를 한 번에 처리. 매 릴리즈 6단계 수작업 → highlights 채우기 + git/PR/merge/tag 6 명령으로 단순화. 거꾸로 가는 버전·중복 엔트리·잘못된 형식 인자는 모두 거부.",
12
+ "**MCP 서버 버전 동적 읽기** — `packages/cli/src/mcp.mjs` 가 시작 시점에 package.json 의 version 을 읽도록 변경. 하드코딩된 `version: \"X.Y.Z\"` 문자열 제거 — 매 릴리즈마다 손으로 갱신할 필요 영구 사라짐 (실제로 v0.45.0 출고 시 거의 빠뜨릴 뻔한 drift 위험을 잡은 변경).",
13
+ "기능 변경 0 — 릴리즈 워크플로 신뢰성만 향상."
14
+ ],
15
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.45.1"
16
+ },
17
+ {
18
+ "version": "0.45.0",
19
+ "date": "2026-04-30",
20
+ "title": "Tailwind 변종 100% 커버리지 — 11 컴포넌트 추가로 전체 완성",
21
+ "type": "minor",
22
+ "highlights": [
23
+ "**나머지 11 컴포넌트 Tailwind 변종 추가** — calendar, carousel, color-picker, file-upload, toast, header, sidebar, form, code-editor, markdown-editor, rich-text-editor. 이제 `cssFramework: \"tailwind\"` 로 만든 프로젝트는 모든 styled 컴포넌트가 utility-class 변종으로 설치 — fallback 알림이 더 이상 발생하지 않음.",
24
+ "**form 멀티파일 변종** — `index.tailwind.tsx` + `form.tailwind.tsx` + `field.tailwind.tsx` 세 파일이 함께 카피되어 sub-component(Field, Section, Error 등) 모두 utility-class. registry.json 의 file-단위 frameworks 분기가 멀티파일 컴포넌트도 깔끔하게 처리.",
25
+ "**editor 컴포넌트의 descendant 스타일 처리** — code-editor (CodeMirror `.cm-*`), markdown-editor (react-markdown 렌더 트리), rich-text-editor (Tiptap `.ProseMirror`) 는 3rd-party 라이브러리 자식 element 에 직접 CSS 가 필요 — outer wrapper 만 utility class 로 변환하고 descendant 규칙은 컴포넌트 모듈이 한 번 inject 하는 패턴 (`<style>` 태그). 토큰은 동일하게 사용해 테마 자동 추종."
26
+ ],
27
+ "url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.45.0"
28
+ },
5
29
  {
6
30
  "version": "0.44.0",
7
31
  "date": "2026-04-30",
@@ -0,0 +1,498 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger } from "../select";
5
+
6
+ function cx(...args: (string | undefined | false)[]) {
7
+ return args.filter(Boolean).join(" ");
8
+ }
9
+
10
+ const DEFAULT_WEEKDAYS_KO = ["일", "월", "화", "수", "목", "금", "토"] as const;
11
+
12
+ const isSameDay = (a: Date, b: Date) =>
13
+ a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
14
+ const toDateOnly = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
15
+ const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
16
+ const addMonths = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth() + n, 1);
17
+ const formatIsoDate = (d: Date) =>
18
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
19
+ const defaultMonthLabel = (year: number, month: number) => `${year}년 ${month + 1}월`;
20
+
21
+ function getDaysGrid(year: number, month: number, weekStartsOn: 0 | 1) {
22
+ const first = new Date(year, month, 1);
23
+ const startDay = (first.getDay() - weekStartsOn + 7) % 7;
24
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
25
+ const prevDays = new Date(year, month, 0).getDate();
26
+ const cells: { date: Date; current: boolean }[] = [];
27
+ for (let i = startDay - 1; i >= 0; i--) cells.push({ date: new Date(year, month - 1, prevDays - i), current: false });
28
+ for (let d = 1; d <= daysInMonth; d++) cells.push({ date: new Date(year, month, d), current: true });
29
+ const remaining = 7 - (cells.length % 7);
30
+ if (remaining < 7) {
31
+ for (let d = 1; d <= remaining; d++) cells.push({ date: new Date(year, month + 1, d), current: false });
32
+ }
33
+ return cells;
34
+ }
35
+
36
+ function rotateWeekdays(labels: readonly string[], weekStartsOn: 0 | 1): string[] {
37
+ if (weekStartsOn === 0) return [...labels];
38
+ return [...labels.slice(weekStartsOn), ...labels.slice(0, weekStartsOn)];
39
+ }
40
+
41
+ export interface DateRange { from: Date; to: Date; }
42
+ export type CalendarMode = "single" | "multiple" | "range";
43
+
44
+ interface CalendarCommonProps {
45
+ month?: Date; defaultMonth?: Date;
46
+ onMonthChange?: (month: Date) => void;
47
+ numberOfMonths?: number;
48
+ min?: Date; max?: Date;
49
+ disabled?: (date: Date) => boolean;
50
+ showOutsideDays?: boolean;
51
+ weekStartsOn?: 0 | 1;
52
+ weekdayLabels?: readonly string[];
53
+ formatMonthLabel?: (year: number, month: number) => string;
54
+ fromYear?: number; toYear?: number;
55
+ className?: string;
56
+ "aria-label"?: string;
57
+ children?: React.ReactNode;
58
+ }
59
+
60
+ export type CalendarSingleProps = CalendarCommonProps & {
61
+ mode?: "single"; value?: Date; defaultValue?: Date; onValueChange?: (date: Date | undefined) => void;
62
+ };
63
+ export type CalendarMultipleProps = CalendarCommonProps & {
64
+ mode: "multiple"; value?: Date[]; defaultValue?: Date[]; onValueChange?: (dates: Date[]) => void;
65
+ };
66
+ export type CalendarRangeProps = CalendarCommonProps & {
67
+ mode: "range"; value?: DateRange; defaultValue?: DateRange; onValueChange?: (range: DateRange | undefined) => void;
68
+ };
69
+ export type CalendarProps = CalendarSingleProps | CalendarMultipleProps | CalendarRangeProps;
70
+
71
+ function ChevronLeftIcon() {
72
+ return <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden><path d="M10 3 5.5 8 10 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>;
73
+ }
74
+ function ChevronRightIcon() {
75
+ return <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden><path d="M6 3 10.5 8 6 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>;
76
+ }
77
+ function ChevronDoubleLeftIcon() {
78
+ return <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden><path d="M8 3 3.5 8 8 13M13 3 8.5 8 13 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>;
79
+ }
80
+ function ChevronDoubleRightIcon() {
81
+ return <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden><path d="M3 3 7.5 8 3 13M8 3 12.5 8 8 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>;
82
+ }
83
+
84
+ interface CalendarContextValue {
85
+ visibleMonth: Date;
86
+ monthIndex: number;
87
+ monthsLength: number;
88
+ yearOptions: number[];
89
+ setYearForVisible: (y: number) => void;
90
+ setMonthForVisible: (m: number) => void;
91
+ prevMonth: () => void; nextMonth: () => void;
92
+ prevYear: () => void; nextYear: () => void;
93
+ weekStartsOn: 0 | 1;
94
+ weekdayLabels: string[];
95
+ showOutsideDays: boolean;
96
+ formatMonthLabel: (year: number, month: number) => string;
97
+ ariaLabel?: string;
98
+ isSelected: (date: Date) => boolean;
99
+ isInRange: (date: Date) => { inRange: boolean; isStart: boolean; isEnd: boolean };
100
+ isDisabled: (date: Date) => boolean;
101
+ handleSelect: (date: Date) => void;
102
+ setHoverDate: (date: Date | undefined) => void;
103
+ onKeyDown: (e: React.KeyboardEvent) => void;
104
+ isFirstMonth: boolean;
105
+ isLastMonth: boolean;
106
+ }
107
+
108
+ const CalendarContext = React.createContext<CalendarContextValue | null>(null);
109
+
110
+ function useCalendarContext(component: string) {
111
+ const ctx = React.useContext(CalendarContext);
112
+ if (!ctx) throw new Error(`${component}는 <Calendar> 내부에서 사용해야 합니다.`);
113
+ return ctx;
114
+ }
115
+
116
+ export function Calendar(props: CalendarProps) {
117
+ const {
118
+ mode = "single", month: monthProp, defaultMonth, onMonthChange,
119
+ numberOfMonths: numberOfMonthsProp = 1,
120
+ min, max, disabled, showOutsideDays = true, weekStartsOn = 0,
121
+ weekdayLabels: weekdayLabelsProp,
122
+ formatMonthLabel = defaultMonthLabel,
123
+ fromYear, toYear, className, "aria-label": ariaLabel, children,
124
+ } = props as CalendarCommonProps & { mode?: CalendarMode };
125
+
126
+ const numberOfMonths = children ? 1 : Math.max(1, numberOfMonthsProp);
127
+
128
+ const isControlled = "value" in props && props.value !== undefined;
129
+ const [internalSingle, setInternalSingle] = React.useState<Date | undefined>(
130
+ mode === "single" ? (props as CalendarSingleProps).defaultValue : undefined,
131
+ );
132
+ const [internalMultiple, setInternalMultiple] = React.useState<Date[]>(
133
+ mode === "multiple" ? (props as CalendarMultipleProps).defaultValue ?? [] : [],
134
+ );
135
+ const [internalRange, setInternalRange] = React.useState<DateRange | undefined>(
136
+ mode === "range" ? (props as CalendarRangeProps).defaultValue : undefined,
137
+ );
138
+
139
+ const singleValue = isControlled ? (props as CalendarSingleProps).value : internalSingle;
140
+ const multipleValue = isControlled ? (props as CalendarMultipleProps).value ?? [] : internalMultiple;
141
+ const rangeValue = isControlled ? (props as CalendarRangeProps).value : internalRange;
142
+
143
+ const [picking, setPicking] = React.useState<Date | undefined>(undefined);
144
+ const [hoverDate, setHoverDate] = React.useState<Date | undefined>(undefined);
145
+
146
+ const monthControlled = monthProp !== undefined;
147
+ const [internalMonth, setInternalMonth] = React.useState<Date>(() => {
148
+ if (defaultMonth) return startOfMonth(defaultMonth);
149
+ if (mode === "single" && singleValue) return startOfMonth(singleValue);
150
+ if (mode === "multiple" && multipleValue.length > 0) return startOfMonth(multipleValue[0]);
151
+ if (mode === "range" && rangeValue?.from) return startOfMonth(rangeValue.from);
152
+ return startOfMonth(new Date());
153
+ });
154
+ const currentMonth = monthControlled ? startOfMonth(monthProp!) : internalMonth;
155
+
156
+ const setMonth = React.useCallback((next: Date) => {
157
+ const normalized = startOfMonth(next);
158
+ if (!monthControlled) setInternalMonth(normalized);
159
+ onMonthChange?.(normalized);
160
+ }, [monthControlled, onMonthChange]);
161
+
162
+ const [focusedDate, setFocusedDate] = React.useState<Date>(() => {
163
+ if (mode === "single" && singleValue) return singleValue;
164
+ if (mode === "range" && rangeValue?.from) return rangeValue.from;
165
+ return new Date();
166
+ });
167
+
168
+ const weekdayLabels = React.useMemo(() => {
169
+ const base = weekdayLabelsProp ?? DEFAULT_WEEKDAYS_KO;
170
+ return rotateWeekdays(base, weekStartsOn);
171
+ }, [weekdayLabelsProp, weekStartsOn]);
172
+
173
+ const nowYear = new Date().getFullYear();
174
+ const resolvedFromYear = fromYear ?? min?.getFullYear() ?? nowYear - 10;
175
+ const resolvedToYear = toYear ?? max?.getFullYear() ?? nowYear + 10;
176
+ const yearOptions = React.useMemo(() => {
177
+ const out: number[] = [];
178
+ const start = Math.min(resolvedFromYear, resolvedToYear);
179
+ const end = Math.max(resolvedFromYear, resolvedToYear);
180
+ for (let y = start; y <= end; y++) out.push(y);
181
+ return out;
182
+ }, [resolvedFromYear, resolvedToYear]);
183
+
184
+ const isDateDisabled = React.useCallback((date: Date) => {
185
+ const d = toDateOnly(date);
186
+ if (min && d < toDateOnly(min)) return true;
187
+ if (max && d > toDateOnly(max)) return true;
188
+ if (disabled?.(date)) return true;
189
+ return false;
190
+ }, [min, max, disabled]);
191
+
192
+ const isDateSelected = React.useCallback((date: Date) => {
193
+ if (mode === "single") return !!singleValue && isSameDay(date, singleValue);
194
+ if (mode === "multiple") return multipleValue.some((d) => isSameDay(d, date));
195
+ if (mode === "range") {
196
+ if (picking) return isSameDay(date, picking);
197
+ if (rangeValue?.from && isSameDay(date, rangeValue.from)) return true;
198
+ if (rangeValue?.to && isSameDay(date, rangeValue.to)) return true;
199
+ return false;
200
+ }
201
+ return false;
202
+ }, [mode, singleValue, multipleValue, rangeValue, picking]);
203
+
204
+ const getRangeState = React.useCallback((date: Date) => {
205
+ if (mode !== "range") return { inRange: false, isStart: false, isEnd: false };
206
+ const from = picking ?? rangeValue?.from;
207
+ if (!from) return { inRange: false, isStart: false, isEnd: false };
208
+ const to = picking ? hoverDate : rangeValue?.to;
209
+ if (!to) return { inRange: false, isStart: isSameDay(date, from), isEnd: false };
210
+ const [rStart, rEnd] = from <= to ? [from, to] : [to, from];
211
+ const s = toDateOnly(rStart);
212
+ const e = toDateOnly(rEnd);
213
+ const d = toDateOnly(date);
214
+ return { inRange: d >= s && d <= e, isStart: isSameDay(d, s), isEnd: isSameDay(d, e) };
215
+ }, [mode, picking, hoverDate, rangeValue]);
216
+
217
+ const handleSelect = React.useCallback((date: Date) => {
218
+ if (mode === "single") {
219
+ if (!isControlled) setInternalSingle(date);
220
+ (props as CalendarSingleProps).onValueChange?.(date);
221
+ return;
222
+ }
223
+ if (mode === "multiple") {
224
+ const exists = multipleValue.some((d) => isSameDay(d, date));
225
+ const next = exists ? multipleValue.filter((d) => !isSameDay(d, date)) : [...multipleValue, date];
226
+ if (!isControlled) setInternalMultiple(next);
227
+ (props as CalendarMultipleProps).onValueChange?.(next);
228
+ return;
229
+ }
230
+ if (mode === "range") {
231
+ if (!picking) { setPicking(date); return; }
232
+ const [from, to] = picking <= date ? [picking, date] : [date, picking];
233
+ const range: DateRange = { from, to };
234
+ setPicking(undefined); setHoverDate(undefined);
235
+ if (!isControlled) setInternalRange(range);
236
+ (props as CalendarRangeProps).onValueChange?.(range);
237
+ }
238
+ }, [mode, isControlled, multipleValue, picking, props]);
239
+
240
+ const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
241
+ let next: Date | null = null;
242
+ const cursor = focusedDate;
243
+ switch (e.key) {
244
+ case "ArrowLeft": next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() - 1); break;
245
+ case "ArrowRight": next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1); break;
246
+ case "ArrowUp": next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() - 7); break;
247
+ case "ArrowDown": next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 7); break;
248
+ case "PageUp": next = new Date(cursor.getFullYear(), cursor.getMonth() - 1, cursor.getDate()); break;
249
+ case "PageDown": next = new Date(cursor.getFullYear(), cursor.getMonth() + 1, cursor.getDate()); break;
250
+ case "Home": next = new Date(cursor.getFullYear(), cursor.getMonth(), 1); break;
251
+ case "End": next = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0); break;
252
+ case "Enter":
253
+ case " ":
254
+ e.preventDefault();
255
+ if (!isDateDisabled(cursor)) handleSelect(cursor);
256
+ return;
257
+ default: return;
258
+ }
259
+ e.preventDefault();
260
+ if (!next || isDateDisabled(next)) return;
261
+ setFocusedDate(next);
262
+ const visibleEnd = addMonths(currentMonth, numberOfMonths - 1);
263
+ if (next < currentMonth) setMonth(addMonths(currentMonth, -1));
264
+ else if (next > new Date(visibleEnd.getFullYear(), visibleEnd.getMonth() + 1, 0)) setMonth(addMonths(currentMonth, 1));
265
+ }, [focusedDate, isDateDisabled, handleSelect, currentMonth, numberOfMonths, setMonth]);
266
+
267
+ const months = Array.from({ length: numberOfMonths }, (_, i) => addMonths(currentMonth, i));
268
+
269
+ const buildMonthContext = (visibleMonth: Date, idx: number): CalendarContextValue => ({
270
+ visibleMonth, monthIndex: idx, monthsLength: numberOfMonths, yearOptions,
271
+ setYearForVisible: (y) => {
272
+ const yearDiff = y - visibleMonth.getFullYear();
273
+ setMonth(new Date(currentMonth.getFullYear() + yearDiff, currentMonth.getMonth(), 1));
274
+ },
275
+ setMonthForVisible: (m) => setMonth(addMonths(currentMonth, m - visibleMonth.getMonth())),
276
+ prevMonth: () => setMonth(addMonths(currentMonth, -1)),
277
+ nextMonth: () => setMonth(addMonths(currentMonth, 1)),
278
+ prevYear: () => setMonth(addMonths(currentMonth, -12)),
279
+ nextYear: () => setMonth(addMonths(currentMonth, 12)),
280
+ weekStartsOn, weekdayLabels, showOutsideDays, formatMonthLabel, ariaLabel,
281
+ isSelected: isDateSelected, isInRange: getRangeState, isDisabled: isDateDisabled,
282
+ handleSelect,
283
+ setHoverDate: mode === "range" ? setHoverDate : () => {},
284
+ onKeyDown: handleKeyDown,
285
+ isFirstMonth: idx === 0, isLastMonth: idx === numberOfMonths - 1,
286
+ });
287
+
288
+ return (
289
+ <div
290
+ className={cx("inline-flex gap-[var(--space-4)] select-none", numberOfMonths > 1 && "flex-wrap", className)}
291
+ aria-label={ariaLabel}
292
+ >
293
+ {children
294
+ ? <CalendarContext.Provider value={buildMonthContext(months[0], 0)}>{children}</CalendarContext.Provider>
295
+ : months.map((m, idx) => (
296
+ <CalendarContext.Provider key={`${m.getFullYear()}-${m.getMonth()}`} value={buildMonthContext(m, idx)}>
297
+ <div className="w-[17.5rem]">
298
+ <CalendarHeader>
299
+ {idx === 0 ? <CalendarPrevYearButton /> : <CalendarNavPlaceholder />}
300
+ {idx === 0 ? <CalendarPrevMonthButton /> : <CalendarNavPlaceholder />}
301
+ <div className="inline-flex items-center gap-[var(--space-1)] flex-1 justify-center">
302
+ <CalendarYearSelect />
303
+ <CalendarMonthSelect />
304
+ </div>
305
+ {idx === months.length - 1 ? <CalendarNextMonthButton /> : <CalendarNavPlaceholder />}
306
+ {idx === months.length - 1 ? <CalendarNextYearButton /> : <CalendarNavPlaceholder />}
307
+ </CalendarHeader>
308
+ <CalendarGrid />
309
+ </div>
310
+ </CalendarContext.Provider>
311
+ ))}
312
+ </div>
313
+ );
314
+ }
315
+
316
+ export interface CalendarHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
317
+ export const CalendarHeader = React.forwardRef<HTMLDivElement, CalendarHeaderProps>(
318
+ function CalendarHeader({ className, ...props }, ref) {
319
+ return <div ref={ref} className={cx("flex items-center justify-between gap-[var(--space-1)] mb-[var(--space-2)]", className)} {...props} />;
320
+ },
321
+ );
322
+
323
+ const navButtonClasses =
324
+ "inline-flex items-center justify-center w-7 h-7 p-0 border-none rounded-[calc(var(--radius)-2px)] bg-transparent text-foreground-muted cursor-pointer shrink-0 transition-[background-color,color] duration-[var(--duration-fast)] hover:not-disabled:bg-background-muted hover:not-disabled:text-foreground focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 motion-reduce:transition-none";
325
+
326
+ function CalendarNavPlaceholder() {
327
+ return <span className={cx(navButtonClasses, "invisible pointer-events-none")} aria-hidden />;
328
+ }
329
+
330
+ export interface CalendarNavButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
331
+ children?: React.ReactNode;
332
+ }
333
+
334
+ function makeNavButton(
335
+ displayName: string,
336
+ defaultIcon: React.ReactNode,
337
+ defaultLabel: string,
338
+ resolveHandler: (ctx: CalendarContextValue) => () => void,
339
+ ) {
340
+ const Component = React.forwardRef<HTMLButtonElement, CalendarNavButtonProps>(
341
+ function NavButton({ className, children, "aria-label": ariaLabel, onClick, ...props }, ref) {
342
+ const ctx = useCalendarContext(displayName);
343
+ return (
344
+ <button
345
+ ref={ref}
346
+ type="button"
347
+ className={cx(navButtonClasses, className)}
348
+ aria-label={ariaLabel ?? defaultLabel}
349
+ onClick={(e) => { resolveHandler(ctx)(); onClick?.(e); }}
350
+ {...props}
351
+ >
352
+ {children ?? defaultIcon}
353
+ </button>
354
+ );
355
+ },
356
+ );
357
+ Component.displayName = displayName;
358
+ return Component;
359
+ }
360
+
361
+ export const CalendarPrevYearButton = makeNavButton("CalendarPrevYearButton", <ChevronDoubleLeftIcon />, "이전 해", (ctx) => ctx.prevYear);
362
+ export const CalendarNextYearButton = makeNavButton("CalendarNextYearButton", <ChevronDoubleRightIcon />, "다음 해", (ctx) => ctx.nextYear);
363
+ export const CalendarPrevMonthButton = makeNavButton("CalendarPrevMonthButton", <ChevronLeftIcon />, "이전 달", (ctx) => ctx.prevMonth);
364
+ export const CalendarNextMonthButton = makeNavButton("CalendarNextMonthButton", <ChevronRightIcon />, "다음 달", (ctx) => ctx.nextMonth);
365
+
366
+ const calendarSelectTriggerClasses =
367
+ "min-w-0 h-7 gap-[var(--space-1)] px-[var(--space-2)] bg-transparent border-transparent font-semibold text-[length:var(--text-sm)] text-foreground hover:not-disabled:bg-background-muted hover:not-disabled:border-transparent data-[popup-open]:bg-background-muted data-[popup-open]:border-transparent";
368
+
369
+ export interface CalendarYearSelectProps {
370
+ className?: string;
371
+ formatYear?: (year: number) => string;
372
+ }
373
+
374
+ export function CalendarYearSelect({ className, formatYear = (y) => `${y}년` }: CalendarYearSelectProps) {
375
+ const ctx = useCalendarContext("CalendarYearSelect");
376
+ const year = ctx.visibleMonth.getFullYear();
377
+ const items = ctx.yearOptions.includes(year) ? ctx.yearOptions : [...ctx.yearOptions, year].sort((a, b) => a - b);
378
+ return (
379
+ <Select value={String(year)} onValueChange={(v) => ctx.setYearForVisible(Number(v))}>
380
+ <SelectTrigger className={cx(calendarSelectTriggerClasses, className)} aria-label="연도">
381
+ <span>{formatYear(year)}</span>
382
+ </SelectTrigger>
383
+ <SelectContent>
384
+ {items.map((y) => <SelectItem key={y} value={String(y)}>{formatYear(y)}</SelectItem>)}
385
+ </SelectContent>
386
+ </Select>
387
+ );
388
+ }
389
+
390
+ export interface CalendarMonthSelectProps {
391
+ className?: string;
392
+ formatMonth?: (month: number) => string;
393
+ }
394
+
395
+ export function CalendarMonthSelect({ className, formatMonth = (m) => `${m + 1}월` }: CalendarMonthSelectProps) {
396
+ const ctx = useCalendarContext("CalendarMonthSelect");
397
+ const month = ctx.visibleMonth.getMonth();
398
+ return (
399
+ <Select value={String(month)} onValueChange={(v) => ctx.setMonthForVisible(Number(v))}>
400
+ <SelectTrigger className={cx(calendarSelectTriggerClasses, className)} aria-label="월">
401
+ <span>{formatMonth(month)}</span>
402
+ </SelectTrigger>
403
+ <SelectContent>
404
+ {Array.from({ length: 12 }, (_, m) => <SelectItem key={m} value={String(m)}>{formatMonth(m)}</SelectItem>)}
405
+ </SelectContent>
406
+ </Select>
407
+ );
408
+ }
409
+
410
+ export interface CalendarGridProps extends React.HTMLAttributes<HTMLDivElement> {}
411
+
412
+ export const CalendarGrid = React.forwardRef<HTMLDivElement, CalendarGridProps>(
413
+ function CalendarGrid({ className, ...rest }, ref) {
414
+ const ctx = useCalendarContext("CalendarGrid");
415
+ const year = ctx.visibleMonth.getFullYear();
416
+ const month = ctx.visibleMonth.getMonth();
417
+ const cells = getDaysGrid(year, month, ctx.weekStartsOn);
418
+ const today = new Date();
419
+ const monthLabel = ctx.formatMonthLabel(year, month);
420
+ const ariaLabel = ctx.ariaLabel ?? monthLabel;
421
+
422
+ return (
423
+ <div ref={ref} className={cx("", className)} {...rest}>
424
+ <div className="grid grid-cols-7 mb-[var(--space-1)]" role="row">
425
+ {ctx.weekdayLabels.map((label) => (
426
+ <span key={label} className="flex items-center justify-center h-8 text-[length:var(--text-xs)] font-medium text-foreground-muted" role="columnheader" aria-label={label}>
427
+ {label}
428
+ </span>
429
+ ))}
430
+ </div>
431
+ <div
432
+ className="grid grid-cols-7 outline-none focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 focus-visible:rounded-[calc(var(--radius)-2px)]"
433
+ role="grid"
434
+ tabIndex={0}
435
+ onKeyDown={ctx.onKeyDown}
436
+ aria-label={ariaLabel}
437
+ >
438
+ {cells.map(({ date, current }, i) => {
439
+ const dDisabled = ctx.isDisabled(date);
440
+ const selected = ctx.isSelected(date);
441
+ const isToday = isSameDay(date, today);
442
+ const { inRange, isStart, isEnd } = ctx.isInRange(date);
443
+ const hidden = !current && !ctx.showOutsideDays;
444
+ if (hidden) return <span key={i} className="flex items-center justify-center w-full h-[2.375rem] min-w-0" aria-hidden />;
445
+
446
+ const cellRangeBg = (inRange || isStart || isEnd) ? "bg-[color-mix(in_srgb,var(--primary)_12%,transparent)]" : "";
447
+ const cellRangeRadius =
448
+ isStart && !isEnd ? "rounded-l-[calc(var(--radius)-2px)]" :
449
+ isEnd && !isStart ? "rounded-r-[calc(var(--radius)-2px)]" :
450
+ isStart && isEnd ? "rounded-[calc(var(--radius)-2px)]" : "";
451
+
452
+ return (
453
+ <div key={i} className={cx("flex items-center justify-center w-full h-[2.375rem] min-w-0", cellRangeBg, cellRangeRadius)}>
454
+ <button
455
+ type="button"
456
+ className={cx(
457
+ "flex items-center justify-center w-9 h-9 p-0 border-none rounded-[calc(var(--radius)-2px)] bg-transparent text-foreground text-[0.8125rem] font-[inherit] cursor-pointer transition-[background-color,color] duration-[var(--duration-fast)] hover:not-disabled:bg-background-muted focus-visible:outline-[length:var(--border-width-strong)] focus-visible:outline-foreground focus-visible:outline-offset-2 disabled:opacity-30 disabled:cursor-not-allowed motion-reduce:transition-none",
458
+ !current && "text-[var(--foreground-subtle,var(--foreground-muted))] opacity-40",
459
+ isToday && "font-bold underline underline-offset-[0.125rem]",
460
+ selected && "bg-primary text-primary-foreground font-semibold hover:not-disabled:bg-primary-hover hover:not-disabled:text-primary-foreground",
461
+ )}
462
+ disabled={dDisabled}
463
+ tabIndex={-1}
464
+ onClick={() => { if (!dDisabled) ctx.handleSelect(date); }}
465
+ onMouseEnter={() => ctx.setHoverDate(date)}
466
+ onMouseLeave={() => ctx.setHoverDate(undefined)}
467
+ aria-label={formatIsoDate(date)}
468
+ aria-selected={selected || inRange || undefined}
469
+ data-today={isToday || undefined}
470
+ >
471
+ {date.getDate()}
472
+ </button>
473
+ </div>
474
+ );
475
+ })}
476
+ </div>
477
+ </div>
478
+ );
479
+ },
480
+ );
481
+
482
+ export function useCalendar() {
483
+ const ctx = useCalendarContext("useCalendar");
484
+ return {
485
+ visibleMonth: ctx.visibleMonth,
486
+ monthIndex: ctx.monthIndex,
487
+ monthsLength: ctx.monthsLength,
488
+ setYear: ctx.setYearForVisible,
489
+ setMonth: ctx.setMonthForVisible,
490
+ prevMonth: ctx.prevMonth,
491
+ nextMonth: ctx.nextMonth,
492
+ prevYear: ctx.prevYear,
493
+ nextYear: ctx.nextYear,
494
+ yearOptions: ctx.yearOptions,
495
+ isFirstMonth: ctx.isFirstMonth,
496
+ isLastMonth: ctx.isLastMonth,
497
+ };
498
+ }