sh-ui-cli 0.52.3 → 0.55.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.
- package/data/changelog/versions.json +41 -0
- package/data/registry/react/components/calendar/index.module.tsx +124 -25
- package/data/registry/react/components/calendar/index.tailwind.tsx +94 -20
- package/data/registry/react/components/calendar/index.tsx +124 -25
- package/data/registry/react/components/date-picker/index.module.tsx +41 -10
- package/data/registry/react/components/date-picker/index.tailwind.tsx +28 -8
- package/data/registry/react/components/date-picker/index.tsx +50 -10
- package/package.json +1 -1
- package/src/create/theme/decode.js +20 -1
- package/src/create/theme/encode.js +24 -0
- package/src/create/theme/inject.js +19 -3
- package/src/mcp.mjs +93 -1
|
@@ -2,6 +2,47 @@
|
|
|
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.55.0",
|
|
7
|
+
"date": "2026-05-04",
|
|
8
|
+
"title": "MCP — 테마 round-trip + 옵셔널 상태 컬러",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**`sh_ui_encode_theme` MCP 툴 신규** — 사용자가 손본 토큰 객체(`{ light, dark, radius }`)를 base64 로 인코딩. 산출물을 `sh_ui_create_project` 의 `theme` 인자에 그대로 넘기면 다음 스캐폴드에서 톤이 그대로 보존된다. round-trip 검증 내장 — 잘못된 입력은 즉시 거부.",
|
|
12
|
+
"**`sh_ui_decode_theme` MCP 툴 신규** — base64 테마 코드를 객체로 복원. 기존 테마 일부만 고치고 싶을 때 decode → 수정 → encode 양방향 흐름.",
|
|
13
|
+
"**옵셔널 색 토큰 — `success`/`warning`/`info` + `-foreground`** — base64 스키마에 6개 옵셔널 키 추가. 누락 OK(기존 base64 호환), 들어 있으면 hex 검증. `inject` 는 light/dark 둘 다 정의된 경우에만 CSS 로 emit (한쪽만 있으면 안전 가드로 skip).",
|
|
14
|
+
"**MCP `instructions` + `sh_ui_create_project` description 보강** — \"테마 커스터마이징 round-trip\" 흐름을 새 세션에서도 AI 가 자연스럽게 떠올릴 수 있게 명시.",
|
|
15
|
+
"**docs MCP 페이지 동기화** — 노출 툴 표에 `sh_ui_create_project`/`sh_ui_encode_theme`/`sh_ui_decode_theme` 3개 추가 + 테마 round-trip 섹션."
|
|
16
|
+
],
|
|
17
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.55.0"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"version": "0.54.0",
|
|
21
|
+
"date": "2026-05-04",
|
|
22
|
+
"title": "다크/라이트 자동 전환 — 시스템 설정 기본 반영",
|
|
23
|
+
"type": "minor",
|
|
24
|
+
"highlights": [
|
|
25
|
+
"**`prefers-color-scheme` 미디어쿼리 기본 emit** — `mode: \"light-dark\"` 토큰 출력에 `@media (prefers-color-scheme: dark) { :root:not(.light):not(.dark) { ... } }` 블록을 자동 추가. 사용자가 토글 컴포넌트를 따로 깔지 않아도 OS 다크모드면 다크, 라이트모드면 라이트로 자동 전환됨.",
|
|
26
|
+
"**`.light` 클래스 명시 override 지원** — 기존 `.dark` 클래스에 더해 `.light` 도 미디어쿼리를 이긴다. 시스템이 다크여도 사용자가 라이트를 명시 선택하면 의도대로 라이트 유지.",
|
|
27
|
+
"**Theme 컴포넌트 동기 업데이트** — `setTheme(\"light\")` 가 `<html>` 에 `.light` 클래스를 명시 부여하도록 변경. 토글 사용 시 시스템 설정과 충돌 없이 동작.",
|
|
28
|
+
"**템플릿 + docs 자동 적용** — `nextjs-standalone` / `ui-app-template` / docs 사이트의 `tokens.css` 가 새 패턴으로 갱신. 기존 컴포넌트 코드 변경 불필요."
|
|
29
|
+
],
|
|
30
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.54.0"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.53.0",
|
|
34
|
+
"date": "2026-05-04",
|
|
35
|
+
"title": "Calendar/DatePicker i18n — locale + messages prop",
|
|
36
|
+
"type": "minor",
|
|
37
|
+
"highlights": [
|
|
38
|
+
"**Calendar `locale` prop 추가** — BCP47 로케일(예: `\"en-US\"`, `\"ja-JP\"`) 한 줄로 요일·월 헤더·연도/월 dropdown 라벨이 모두 `Intl.DateTimeFormat` 으로 자동 생성. 한국어만 지원되던 기존 동작에서 다국어 지원으로 확장. 기본값은 `\"ko-KR\"` 유지 — 기존 사용자 영향 없음.",
|
|
39
|
+
"**`messages` prop 으로 aria-label override** — Nav 버튼(`이전 해`/`다음 달` 등) 과 select aria-label(`연도`/`월`) 을 `CalendarMessages` 객체로 한 번에 교체. ko/en 은 내장 기본값 제공, 그 외 언어는 직접 문자열 전달.",
|
|
40
|
+
"**DatePicker / DateRangePicker 도 `locale` 전파** — 내부 Calendar 에 자동 전달되고 placeholder(`날짜 선택`/`Select date` / `시작일 ~ 종료일`/`Start date – end date`) 도 locale 기반 자동 결정. `placeholder` 를 직접 넘기면 그게 우선.",
|
|
41
|
+
"**개별 포맷터 prop 은 그대로 유효** — `weekdayLabels`/`formatMonthLabel`/`CalendarYearSelect.formatYear` 등 기존 세밀 override 는 locale 보다 우선. 마이그레이션 불필요.",
|
|
42
|
+
"**새 export** — `Calendar` 모듈에서 `DEFAULT_LOCALE` 상수와 `CalendarMessages` 타입을 추가로 노출."
|
|
43
|
+
],
|
|
44
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.53.0"
|
|
45
|
+
},
|
|
5
46
|
{
|
|
6
47
|
"version": "0.52.3",
|
|
7
48
|
"date": "2026-05-04",
|
|
@@ -12,8 +12,8 @@ import styles from "./styles.module.css";
|
|
|
12
12
|
|
|
13
13
|
/* ───────── Helpers ───────── */
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
const
|
|
15
|
+
/** 미지정 시의 기본 로케일. 기존 동작(한국어) 보존. */
|
|
16
|
+
export const DEFAULT_LOCALE = "ko-KR";
|
|
17
17
|
|
|
18
18
|
const isSameDay = (a: Date, b: Date) =>
|
|
19
19
|
a.getFullYear() === b.getFullYear() &&
|
|
@@ -32,8 +32,68 @@ const addMonths = (d: Date, n: number) =>
|
|
|
32
32
|
const formatIsoDate = (d: Date) =>
|
|
33
33
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
/** Intl 기반 short weekday 라벨, Sunday-first 7개. */
|
|
36
|
+
function deriveWeekdayLabels(locale: string): string[] {
|
|
37
|
+
const fmt = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
|
38
|
+
// 2017-01-01 은 일요일 — 안전한 기준점.
|
|
39
|
+
return Array.from({ length: 7 }, (_, i) =>
|
|
40
|
+
fmt.format(new Date(2017, 0, 1 + i)),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function deriveMonthLabel(locale: string) {
|
|
45
|
+
const fmt = new Intl.DateTimeFormat(locale, { year: "numeric", month: "long" });
|
|
46
|
+
return (year: number, month: number) => fmt.format(new Date(year, month, 1));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function deriveYearLabel(locale: string) {
|
|
50
|
+
const fmt = new Intl.DateTimeFormat(locale, { year: "numeric" });
|
|
51
|
+
return (y: number) => fmt.format(new Date(y, 0, 1));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function deriveMonthSelectLabel(locale: string) {
|
|
55
|
+
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
|
|
56
|
+
return (m: number) => fmt.format(new Date(2017, m, 1));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Calendar 의 비-날짜 텍스트(aria-label 등). 일부만 override 가능. */
|
|
60
|
+
export interface CalendarMessages {
|
|
61
|
+
/** 1년 이전 버튼 aria-label. */
|
|
62
|
+
prevYear?: string;
|
|
63
|
+
/** 1년 다음 버튼 aria-label. */
|
|
64
|
+
nextYear?: string;
|
|
65
|
+
/** 1개월 이전 버튼 aria-label. */
|
|
66
|
+
prevMonth?: string;
|
|
67
|
+
/** 1개월 다음 버튼 aria-label. */
|
|
68
|
+
nextMonth?: string;
|
|
69
|
+
/** 연도 select aria-label. */
|
|
70
|
+
yearSelectLabel?: string;
|
|
71
|
+
/** 월 select aria-label. */
|
|
72
|
+
monthSelectLabel?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const MESSAGES_KO: Required<CalendarMessages> = {
|
|
76
|
+
prevYear: "이전 해",
|
|
77
|
+
nextYear: "다음 해",
|
|
78
|
+
prevMonth: "이전 달",
|
|
79
|
+
nextMonth: "다음 달",
|
|
80
|
+
yearSelectLabel: "연도",
|
|
81
|
+
monthSelectLabel: "월",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const MESSAGES_EN: Required<CalendarMessages> = {
|
|
85
|
+
prevYear: "Previous year",
|
|
86
|
+
nextYear: "Next year",
|
|
87
|
+
prevMonth: "Previous month",
|
|
88
|
+
nextMonth: "Next month",
|
|
89
|
+
yearSelectLabel: "Year",
|
|
90
|
+
monthSelectLabel: "Month",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function defaultMessagesFor(locale: string): Required<CalendarMessages> {
|
|
94
|
+
const lang = locale.toLowerCase().split(/[-_]/)[0];
|
|
95
|
+
return lang === "ko" ? MESSAGES_KO : MESSAGES_EN;
|
|
96
|
+
}
|
|
37
97
|
|
|
38
98
|
function getDaysGrid(year: number, month: number, weekStartsOn: 0 | 1) {
|
|
39
99
|
const first = new Date(year, month, 1);
|
|
@@ -94,10 +154,22 @@ interface CalendarCommonProps {
|
|
|
94
154
|
showOutsideDays?: boolean;
|
|
95
155
|
/** 주의 시작 요일. 0=일, 1=월. @default 0 */
|
|
96
156
|
weekStartsOn?: 0 | 1;
|
|
97
|
-
/** 요일 라벨 (Sunday-first 7개). */
|
|
157
|
+
/** 요일 라벨 (Sunday-first 7개). 미지정 시 `locale` 기반 자동 생성. */
|
|
98
158
|
weekdayLabels?: readonly string[];
|
|
99
|
-
/** 월 헤더 그룹의 aria-label 포맷.
|
|
159
|
+
/** 월 헤더 그룹의 aria-label 포맷. 미지정 시 `locale` 기반 자동 생성. */
|
|
100
160
|
formatMonthLabel?: (year: number, month: number) => string;
|
|
161
|
+
/**
|
|
162
|
+
* BCP47 로케일 (예: `"en-US"`, `"ja-JP"`, `"ko-KR"`). 요일·월·연도 라벨이 `Intl.DateTimeFormat` 으로
|
|
163
|
+
* 자동 생성된다. 개별 포맷터(`weekdayLabels`/`formatMonthLabel`/`CalendarYearSelect.formatYear` 등) 가
|
|
164
|
+
* 우선되고, 미지정 항목만 이 로케일에서 파생.
|
|
165
|
+
* @default "ko-KR"
|
|
166
|
+
*/
|
|
167
|
+
locale?: string;
|
|
168
|
+
/**
|
|
169
|
+
* Nav 버튼/select 의 aria-label 등 비-날짜 텍스트 override. 미지정 시 `locale` 의 언어 코드(`ko`/`en`)
|
|
170
|
+
* 기반 기본값. 다른 언어를 쓸 땐 해당 언어 문자열을 직접 넘긴다.
|
|
171
|
+
*/
|
|
172
|
+
messages?: CalendarMessages;
|
|
101
173
|
/** 연도 dropdown 시작 연도. */
|
|
102
174
|
fromYear?: number;
|
|
103
175
|
/** 연도 dropdown 끝 연도. */
|
|
@@ -195,6 +267,11 @@ interface CalendarContextValue {
|
|
|
195
267
|
weekdayLabels: string[];
|
|
196
268
|
showOutsideDays: boolean;
|
|
197
269
|
formatMonthLabel: (year: number, month: number) => string;
|
|
270
|
+
/** YearSelect 가 자체 `formatYear` 를 받지 않을 때 사용할 로케일 기본값. */
|
|
271
|
+
defaultFormatYear: (y: number) => string;
|
|
272
|
+
/** MonthSelect 가 자체 `formatMonth` 를 받지 않을 때 사용할 로케일 기본값. */
|
|
273
|
+
defaultFormatMonth: (m: number) => string;
|
|
274
|
+
messages: Required<CalendarMessages>;
|
|
198
275
|
ariaLabel?: string;
|
|
199
276
|
|
|
200
277
|
isSelected: (date: Date) => boolean;
|
|
@@ -240,7 +317,9 @@ export function Calendar(props: CalendarProps) {
|
|
|
240
317
|
showOutsideDays = true,
|
|
241
318
|
weekStartsOn = 0,
|
|
242
319
|
weekdayLabels: weekdayLabelsProp,
|
|
243
|
-
formatMonthLabel
|
|
320
|
+
formatMonthLabel: formatMonthLabelProp,
|
|
321
|
+
locale = DEFAULT_LOCALE,
|
|
322
|
+
messages: messagesProp,
|
|
244
323
|
fromYear,
|
|
245
324
|
toYear,
|
|
246
325
|
className,
|
|
@@ -304,11 +383,26 @@ export function Calendar(props: CalendarProps) {
|
|
|
304
383
|
return new Date();
|
|
305
384
|
});
|
|
306
385
|
|
|
386
|
+
/* locale-derived defaults */
|
|
387
|
+
const localeWeekdays = React.useMemo(() => deriveWeekdayLabels(locale), [locale]);
|
|
388
|
+
const localeMonthLabel = React.useMemo(() => deriveMonthLabel(locale), [locale]);
|
|
389
|
+
const localeYearLabel = React.useMemo(() => deriveYearLabel(locale), [locale]);
|
|
390
|
+
const localeMonthSelectLabel = React.useMemo(
|
|
391
|
+
() => deriveMonthSelectLabel(locale),
|
|
392
|
+
[locale],
|
|
393
|
+
);
|
|
394
|
+
const resolvedMessages = React.useMemo<Required<CalendarMessages>>(
|
|
395
|
+
() => ({ ...defaultMessagesFor(locale), ...messagesProp }),
|
|
396
|
+
[locale, messagesProp],
|
|
397
|
+
);
|
|
398
|
+
|
|
307
399
|
/* weekday labels */
|
|
308
400
|
const weekdayLabels = React.useMemo(() => {
|
|
309
|
-
const base = weekdayLabelsProp ??
|
|
401
|
+
const base = weekdayLabelsProp ?? localeWeekdays;
|
|
310
402
|
return rotateWeekdays(base, weekStartsOn);
|
|
311
|
-
}, [weekdayLabelsProp, weekStartsOn]);
|
|
403
|
+
}, [weekdayLabelsProp, localeWeekdays, weekStartsOn]);
|
|
404
|
+
|
|
405
|
+
const formatMonthLabel = formatMonthLabelProp ?? localeMonthLabel;
|
|
312
406
|
|
|
313
407
|
/* year options */
|
|
314
408
|
const nowYear = new Date().getFullYear();
|
|
@@ -490,6 +584,9 @@ export function Calendar(props: CalendarProps) {
|
|
|
490
584
|
weekdayLabels,
|
|
491
585
|
showOutsideDays,
|
|
492
586
|
formatMonthLabel,
|
|
587
|
+
defaultFormatYear: localeYearLabel,
|
|
588
|
+
defaultFormatMonth: localeMonthSelectLabel,
|
|
589
|
+
messages: resolvedMessages,
|
|
493
590
|
ariaLabel,
|
|
494
591
|
isSelected: isDateSelected,
|
|
495
592
|
isInRange: getRangeState,
|
|
@@ -562,7 +659,7 @@ export interface CalendarNavButtonProps
|
|
|
562
659
|
function makeNavButton(
|
|
563
660
|
displayName: string,
|
|
564
661
|
defaultIcon: React.ReactNode,
|
|
565
|
-
|
|
662
|
+
resolveDefaultLabel: (ctx: CalendarContextValue) => string,
|
|
566
663
|
resolveHandler: (ctx: CalendarContextValue) => () => void,
|
|
567
664
|
) {
|
|
568
665
|
const Component = React.forwardRef<HTMLButtonElement, CalendarNavButtonProps>(
|
|
@@ -573,7 +670,7 @@ function makeNavButton(
|
|
|
573
670
|
ref={ref}
|
|
574
671
|
type="button"
|
|
575
672
|
className={cn(styles.calendar__nav, className)}
|
|
576
|
-
aria-label={ariaLabel ??
|
|
673
|
+
aria-label={ariaLabel ?? resolveDefaultLabel(ctx)}
|
|
577
674
|
onClick={(e) => {
|
|
578
675
|
resolveHandler(ctx)();
|
|
579
676
|
onClick?.(e);
|
|
@@ -593,7 +690,7 @@ function makeNavButton(
|
|
|
593
690
|
export const CalendarPrevYearButton = makeNavButton(
|
|
594
691
|
"CalendarPrevYearButton",
|
|
595
692
|
<ChevronDoubleLeftIcon />,
|
|
596
|
-
|
|
693
|
+
(ctx) => ctx.messages.prevYear,
|
|
597
694
|
(ctx) => ctx.prevYear,
|
|
598
695
|
);
|
|
599
696
|
|
|
@@ -601,7 +698,7 @@ export const CalendarPrevYearButton = makeNavButton(
|
|
|
601
698
|
export const CalendarNextYearButton = makeNavButton(
|
|
602
699
|
"CalendarNextYearButton",
|
|
603
700
|
<ChevronDoubleRightIcon />,
|
|
604
|
-
|
|
701
|
+
(ctx) => ctx.messages.nextYear,
|
|
605
702
|
(ctx) => ctx.nextYear,
|
|
606
703
|
);
|
|
607
704
|
|
|
@@ -609,7 +706,7 @@ export const CalendarNextYearButton = makeNavButton(
|
|
|
609
706
|
export const CalendarPrevMonthButton = makeNavButton(
|
|
610
707
|
"CalendarPrevMonthButton",
|
|
611
708
|
<ChevronLeftIcon />,
|
|
612
|
-
|
|
709
|
+
(ctx) => ctx.messages.prevMonth,
|
|
613
710
|
(ctx) => ctx.prevMonth,
|
|
614
711
|
);
|
|
615
712
|
|
|
@@ -617,7 +714,7 @@ export const CalendarPrevMonthButton = makeNavButton(
|
|
|
617
714
|
export const CalendarNextMonthButton = makeNavButton(
|
|
618
715
|
"CalendarNextMonthButton",
|
|
619
716
|
<ChevronRightIcon />,
|
|
620
|
-
|
|
717
|
+
(ctx) => ctx.messages.nextMonth,
|
|
621
718
|
(ctx) => ctx.nextMonth,
|
|
622
719
|
);
|
|
623
720
|
|
|
@@ -626,16 +723,17 @@ export const CalendarNextMonthButton = makeNavButton(
|
|
|
626
723
|
export interface CalendarYearSelectProps {
|
|
627
724
|
/** select trigger 의 className. */
|
|
628
725
|
className?: string;
|
|
629
|
-
/** label 표시 포맷.
|
|
726
|
+
/** label 표시 포맷. 미지정 시 Calendar `locale` 기반 자동 생성. */
|
|
630
727
|
formatYear?: (year: number) => string;
|
|
631
728
|
}
|
|
632
729
|
|
|
633
730
|
/** 연도 dropdown. sh-ui Select 로 구현. */
|
|
634
731
|
export function CalendarYearSelect({
|
|
635
732
|
className,
|
|
636
|
-
formatYear
|
|
733
|
+
formatYear,
|
|
637
734
|
}: CalendarYearSelectProps) {
|
|
638
735
|
const ctx = useCalendarContext("CalendarYearSelect");
|
|
736
|
+
const resolvedFormat = formatYear ?? ctx.defaultFormatYear;
|
|
639
737
|
const year = ctx.visibleMonth.getFullYear();
|
|
640
738
|
const items = ctx.yearOptions.includes(year)
|
|
641
739
|
? ctx.yearOptions
|
|
@@ -648,14 +746,14 @@ export function CalendarYearSelect({
|
|
|
648
746
|
>
|
|
649
747
|
<SelectTrigger
|
|
650
748
|
className={cn(styles["calendar__select-trigger"], className)}
|
|
651
|
-
aria-label=
|
|
749
|
+
aria-label={ctx.messages.yearSelectLabel}
|
|
652
750
|
>
|
|
653
|
-
<span className={styles["calendar__select-value"]}>{
|
|
751
|
+
<span className={styles["calendar__select-value"]}>{resolvedFormat(year)}</span>
|
|
654
752
|
</SelectTrigger>
|
|
655
753
|
<SelectContent className={styles["calendar__select-popup"]}>
|
|
656
754
|
{items.map((y) => (
|
|
657
755
|
<SelectItem key={y} value={String(y)}>
|
|
658
|
-
{
|
|
756
|
+
{resolvedFormat(y)}
|
|
659
757
|
</SelectItem>
|
|
660
758
|
))}
|
|
661
759
|
</SelectContent>
|
|
@@ -665,16 +763,17 @@ export function CalendarYearSelect({
|
|
|
665
763
|
|
|
666
764
|
export interface CalendarMonthSelectProps {
|
|
667
765
|
className?: string;
|
|
668
|
-
/** label 표시 포맷.
|
|
766
|
+
/** label 표시 포맷. 미지정 시 Calendar `locale` 기반 자동 생성. */
|
|
669
767
|
formatMonth?: (month: number) => string;
|
|
670
768
|
}
|
|
671
769
|
|
|
672
770
|
/** 월 dropdown. */
|
|
673
771
|
export function CalendarMonthSelect({
|
|
674
772
|
className,
|
|
675
|
-
formatMonth
|
|
773
|
+
formatMonth,
|
|
676
774
|
}: CalendarMonthSelectProps) {
|
|
677
775
|
const ctx = useCalendarContext("CalendarMonthSelect");
|
|
776
|
+
const resolvedFormat = formatMonth ?? ctx.defaultFormatMonth;
|
|
678
777
|
const month = ctx.visibleMonth.getMonth();
|
|
679
778
|
return (
|
|
680
779
|
<Select
|
|
@@ -683,14 +782,14 @@ export function CalendarMonthSelect({
|
|
|
683
782
|
>
|
|
684
783
|
<SelectTrigger
|
|
685
784
|
className={cn(styles["calendar__select-trigger"], className)}
|
|
686
|
-
aria-label=
|
|
785
|
+
aria-label={ctx.messages.monthSelectLabel}
|
|
687
786
|
>
|
|
688
|
-
<span className={styles["calendar__select-value"]}>{
|
|
787
|
+
<span className={styles["calendar__select-value"]}>{resolvedFormat(month)}</span>
|
|
689
788
|
</SelectTrigger>
|
|
690
789
|
<SelectContent className={styles["calendar__select-popup"]}>
|
|
691
790
|
{Array.from({ length: 12 }, (_, m) => (
|
|
692
791
|
<SelectItem key={m} value={String(m)}>
|
|
693
|
-
{
|
|
792
|
+
{resolvedFormat(m)}
|
|
694
793
|
</SelectItem>
|
|
695
794
|
))}
|
|
696
795
|
</SelectContent>
|
|
@@ -5,7 +5,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from "../select";
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
import { cn } from "@SH_UI_UTILS@";
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
/** 미지정 시의 기본 로케일. 기존 동작(한국어) 보존. */
|
|
10
|
+
export const DEFAULT_LOCALE = "ko-KR";
|
|
9
11
|
|
|
10
12
|
const isSameDay = (a: Date, b: Date) =>
|
|
11
13
|
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
@@ -14,7 +16,53 @@ const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
|
|
|
14
16
|
const addMonths = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth() + n, 1);
|
|
15
17
|
const formatIsoDate = (d: Date) =>
|
|
16
18
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
17
|
-
|
|
19
|
+
|
|
20
|
+
function deriveWeekdayLabels(locale: string): string[] {
|
|
21
|
+
const fmt = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
|
22
|
+
return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(2017, 0, 1 + i)));
|
|
23
|
+
}
|
|
24
|
+
function deriveMonthLabel(locale: string) {
|
|
25
|
+
const fmt = new Intl.DateTimeFormat(locale, { year: "numeric", month: "long" });
|
|
26
|
+
return (year: number, month: number) => fmt.format(new Date(year, month, 1));
|
|
27
|
+
}
|
|
28
|
+
function deriveYearLabel(locale: string) {
|
|
29
|
+
const fmt = new Intl.DateTimeFormat(locale, { year: "numeric" });
|
|
30
|
+
return (y: number) => fmt.format(new Date(y, 0, 1));
|
|
31
|
+
}
|
|
32
|
+
function deriveMonthSelectLabel(locale: string) {
|
|
33
|
+
const fmt = new Intl.DateTimeFormat(locale, { month: "long" });
|
|
34
|
+
return (m: number) => fmt.format(new Date(2017, m, 1));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CalendarMessages {
|
|
38
|
+
prevYear?: string;
|
|
39
|
+
nextYear?: string;
|
|
40
|
+
prevMonth?: string;
|
|
41
|
+
nextMonth?: string;
|
|
42
|
+
yearSelectLabel?: string;
|
|
43
|
+
monthSelectLabel?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MESSAGES_KO: Required<CalendarMessages> = {
|
|
47
|
+
prevYear: "이전 해",
|
|
48
|
+
nextYear: "다음 해",
|
|
49
|
+
prevMonth: "이전 달",
|
|
50
|
+
nextMonth: "다음 달",
|
|
51
|
+
yearSelectLabel: "연도",
|
|
52
|
+
monthSelectLabel: "월",
|
|
53
|
+
};
|
|
54
|
+
const MESSAGES_EN: Required<CalendarMessages> = {
|
|
55
|
+
prevYear: "Previous year",
|
|
56
|
+
nextYear: "Next year",
|
|
57
|
+
prevMonth: "Previous month",
|
|
58
|
+
nextMonth: "Next month",
|
|
59
|
+
yearSelectLabel: "Year",
|
|
60
|
+
monthSelectLabel: "Month",
|
|
61
|
+
};
|
|
62
|
+
function defaultMessagesFor(locale: string): Required<CalendarMessages> {
|
|
63
|
+
const lang = locale.toLowerCase().split(/[-_]/)[0];
|
|
64
|
+
return lang === "ko" ? MESSAGES_KO : MESSAGES_EN;
|
|
65
|
+
}
|
|
18
66
|
|
|
19
67
|
function getDaysGrid(year: number, month: number, weekStartsOn: 0 | 1) {
|
|
20
68
|
const first = new Date(year, month, 1);
|
|
@@ -49,6 +97,10 @@ interface CalendarCommonProps {
|
|
|
49
97
|
weekStartsOn?: 0 | 1;
|
|
50
98
|
weekdayLabels?: readonly string[];
|
|
51
99
|
formatMonthLabel?: (year: number, month: number) => string;
|
|
100
|
+
/** BCP47 로케일. @default "ko-KR" */
|
|
101
|
+
locale?: string;
|
|
102
|
+
/** Nav/select aria-label override. */
|
|
103
|
+
messages?: CalendarMessages;
|
|
52
104
|
fromYear?: number; toYear?: number;
|
|
53
105
|
className?: string;
|
|
54
106
|
"aria-label"?: string;
|
|
@@ -92,6 +144,9 @@ interface CalendarContextValue {
|
|
|
92
144
|
weekdayLabels: string[];
|
|
93
145
|
showOutsideDays: boolean;
|
|
94
146
|
formatMonthLabel: (year: number, month: number) => string;
|
|
147
|
+
defaultFormatYear: (y: number) => string;
|
|
148
|
+
defaultFormatMonth: (m: number) => string;
|
|
149
|
+
messages: Required<CalendarMessages>;
|
|
95
150
|
ariaLabel?: string;
|
|
96
151
|
isSelected: (date: Date) => boolean;
|
|
97
152
|
isInRange: (date: Date) => { inRange: boolean; isStart: boolean; isEnd: boolean };
|
|
@@ -117,7 +172,9 @@ export function Calendar(props: CalendarProps) {
|
|
|
117
172
|
numberOfMonths: numberOfMonthsProp = 1,
|
|
118
173
|
min, max, disabled, showOutsideDays = true, weekStartsOn = 0,
|
|
119
174
|
weekdayLabels: weekdayLabelsProp,
|
|
120
|
-
formatMonthLabel
|
|
175
|
+
formatMonthLabel: formatMonthLabelProp,
|
|
176
|
+
locale = DEFAULT_LOCALE,
|
|
177
|
+
messages: messagesProp,
|
|
121
178
|
fromYear, toYear, className, "aria-label": ariaLabel, children,
|
|
122
179
|
} = props as CalendarCommonProps & { mode?: CalendarMode };
|
|
123
180
|
|
|
@@ -163,10 +220,21 @@ export function Calendar(props: CalendarProps) {
|
|
|
163
220
|
return new Date();
|
|
164
221
|
});
|
|
165
222
|
|
|
223
|
+
const localeWeekdays = React.useMemo(() => deriveWeekdayLabels(locale), [locale]);
|
|
224
|
+
const localeMonthLabel = React.useMemo(() => deriveMonthLabel(locale), [locale]);
|
|
225
|
+
const localeYearLabel = React.useMemo(() => deriveYearLabel(locale), [locale]);
|
|
226
|
+
const localeMonthSelectLabel = React.useMemo(() => deriveMonthSelectLabel(locale), [locale]);
|
|
227
|
+
const resolvedMessages = React.useMemo<Required<CalendarMessages>>(
|
|
228
|
+
() => ({ ...defaultMessagesFor(locale), ...messagesProp }),
|
|
229
|
+
[locale, messagesProp],
|
|
230
|
+
);
|
|
231
|
+
|
|
166
232
|
const weekdayLabels = React.useMemo(() => {
|
|
167
|
-
const base = weekdayLabelsProp ??
|
|
233
|
+
const base = weekdayLabelsProp ?? localeWeekdays;
|
|
168
234
|
return rotateWeekdays(base, weekStartsOn);
|
|
169
|
-
}, [weekdayLabelsProp, weekStartsOn]);
|
|
235
|
+
}, [weekdayLabelsProp, localeWeekdays, weekStartsOn]);
|
|
236
|
+
|
|
237
|
+
const formatMonthLabel = formatMonthLabelProp ?? localeMonthLabel;
|
|
170
238
|
|
|
171
239
|
const nowYear = new Date().getFullYear();
|
|
172
240
|
const resolvedFromYear = fromYear ?? min?.getFullYear() ?? nowYear - 10;
|
|
@@ -275,7 +343,11 @@ export function Calendar(props: CalendarProps) {
|
|
|
275
343
|
nextMonth: () => setMonth(addMonths(currentMonth, 1)),
|
|
276
344
|
prevYear: () => setMonth(addMonths(currentMonth, -12)),
|
|
277
345
|
nextYear: () => setMonth(addMonths(currentMonth, 12)),
|
|
278
|
-
weekStartsOn, weekdayLabels, showOutsideDays, formatMonthLabel,
|
|
346
|
+
weekStartsOn, weekdayLabels, showOutsideDays, formatMonthLabel,
|
|
347
|
+
defaultFormatYear: localeYearLabel,
|
|
348
|
+
defaultFormatMonth: localeMonthSelectLabel,
|
|
349
|
+
messages: resolvedMessages,
|
|
350
|
+
ariaLabel,
|
|
279
351
|
isSelected: isDateSelected, isInRange: getRangeState, isDisabled: isDateDisabled,
|
|
280
352
|
handleSelect,
|
|
281
353
|
setHoverDate: mode === "range" ? setHoverDate : () => {},
|
|
@@ -332,7 +404,7 @@ export interface CalendarNavButtonProps extends Omit<React.ButtonHTMLAttributes<
|
|
|
332
404
|
function makeNavButton(
|
|
333
405
|
displayName: string,
|
|
334
406
|
defaultIcon: React.ReactNode,
|
|
335
|
-
|
|
407
|
+
resolveDefaultLabel: (ctx: CalendarContextValue) => string,
|
|
336
408
|
resolveHandler: (ctx: CalendarContextValue) => () => void,
|
|
337
409
|
) {
|
|
338
410
|
const Component = React.forwardRef<HTMLButtonElement, CalendarNavButtonProps>(
|
|
@@ -343,7 +415,7 @@ function makeNavButton(
|
|
|
343
415
|
ref={ref}
|
|
344
416
|
type="button"
|
|
345
417
|
className={cn(navButtonClasses, className)}
|
|
346
|
-
aria-label={ariaLabel ??
|
|
418
|
+
aria-label={ariaLabel ?? resolveDefaultLabel(ctx)}
|
|
347
419
|
onClick={(e) => { resolveHandler(ctx)(); onClick?.(e); }}
|
|
348
420
|
{...props}
|
|
349
421
|
>
|
|
@@ -356,10 +428,10 @@ function makeNavButton(
|
|
|
356
428
|
return Component;
|
|
357
429
|
}
|
|
358
430
|
|
|
359
|
-
export const CalendarPrevYearButton = makeNavButton("CalendarPrevYearButton", <ChevronDoubleLeftIcon />,
|
|
360
|
-
export const CalendarNextYearButton = makeNavButton("CalendarNextYearButton", <ChevronDoubleRightIcon />,
|
|
361
|
-
export const CalendarPrevMonthButton = makeNavButton("CalendarPrevMonthButton", <ChevronLeftIcon />,
|
|
362
|
-
export const CalendarNextMonthButton = makeNavButton("CalendarNextMonthButton", <ChevronRightIcon />,
|
|
431
|
+
export const CalendarPrevYearButton = makeNavButton("CalendarPrevYearButton", <ChevronDoubleLeftIcon />, (ctx) => ctx.messages.prevYear, (ctx) => ctx.prevYear);
|
|
432
|
+
export const CalendarNextYearButton = makeNavButton("CalendarNextYearButton", <ChevronDoubleRightIcon />, (ctx) => ctx.messages.nextYear, (ctx) => ctx.nextYear);
|
|
433
|
+
export const CalendarPrevMonthButton = makeNavButton("CalendarPrevMonthButton", <ChevronLeftIcon />, (ctx) => ctx.messages.prevMonth, (ctx) => ctx.prevMonth);
|
|
434
|
+
export const CalendarNextMonthButton = makeNavButton("CalendarNextMonthButton", <ChevronRightIcon />, (ctx) => ctx.messages.nextMonth, (ctx) => ctx.nextMonth);
|
|
363
435
|
|
|
364
436
|
const calendarSelectTriggerClasses =
|
|
365
437
|
"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";
|
|
@@ -369,17 +441,18 @@ export interface CalendarYearSelectProps {
|
|
|
369
441
|
formatYear?: (year: number) => string;
|
|
370
442
|
}
|
|
371
443
|
|
|
372
|
-
export function CalendarYearSelect({ className, formatYear
|
|
444
|
+
export function CalendarYearSelect({ className, formatYear }: CalendarYearSelectProps) {
|
|
373
445
|
const ctx = useCalendarContext("CalendarYearSelect");
|
|
446
|
+
const resolvedFormat = formatYear ?? ctx.defaultFormatYear;
|
|
374
447
|
const year = ctx.visibleMonth.getFullYear();
|
|
375
448
|
const items = ctx.yearOptions.includes(year) ? ctx.yearOptions : [...ctx.yearOptions, year].sort((a, b) => a - b);
|
|
376
449
|
return (
|
|
377
450
|
<Select value={String(year)} onValueChange={(v) => ctx.setYearForVisible(Number(v))}>
|
|
378
|
-
<SelectTrigger className={cn(calendarSelectTriggerClasses, className)} aria-label=
|
|
379
|
-
<span>{
|
|
451
|
+
<SelectTrigger className={cn(calendarSelectTriggerClasses, className)} aria-label={ctx.messages.yearSelectLabel}>
|
|
452
|
+
<span>{resolvedFormat(year)}</span>
|
|
380
453
|
</SelectTrigger>
|
|
381
454
|
<SelectContent>
|
|
382
|
-
{items.map((y) => <SelectItem key={y} value={String(y)}>{
|
|
455
|
+
{items.map((y) => <SelectItem key={y} value={String(y)}>{resolvedFormat(y)}</SelectItem>)}
|
|
383
456
|
</SelectContent>
|
|
384
457
|
</Select>
|
|
385
458
|
);
|
|
@@ -390,16 +463,17 @@ export interface CalendarMonthSelectProps {
|
|
|
390
463
|
formatMonth?: (month: number) => string;
|
|
391
464
|
}
|
|
392
465
|
|
|
393
|
-
export function CalendarMonthSelect({ className, formatMonth
|
|
466
|
+
export function CalendarMonthSelect({ className, formatMonth }: CalendarMonthSelectProps) {
|
|
394
467
|
const ctx = useCalendarContext("CalendarMonthSelect");
|
|
468
|
+
const resolvedFormat = formatMonth ?? ctx.defaultFormatMonth;
|
|
395
469
|
const month = ctx.visibleMonth.getMonth();
|
|
396
470
|
return (
|
|
397
471
|
<Select value={String(month)} onValueChange={(v) => ctx.setMonthForVisible(Number(v))}>
|
|
398
|
-
<SelectTrigger className={cn(calendarSelectTriggerClasses, className)} aria-label=
|
|
399
|
-
<span>{
|
|
472
|
+
<SelectTrigger className={cn(calendarSelectTriggerClasses, className)} aria-label={ctx.messages.monthSelectLabel}>
|
|
473
|
+
<span>{resolvedFormat(month)}</span>
|
|
400
474
|
</SelectTrigger>
|
|
401
475
|
<SelectContent>
|
|
402
|
-
{Array.from({ length: 12 }, (_, m) => <SelectItem key={m} value={String(m)}>{
|
|
476
|
+
{Array.from({ length: 12 }, (_, m) => <SelectItem key={m} value={String(m)}>{resolvedFormat(m)}</SelectItem>)}
|
|
403
477
|
</SelectContent>
|
|
404
478
|
</Select>
|
|
405
479
|
);
|