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.
@@ -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 DEFAULT_WEEKDAYS_KO = ["", "월", "화", "수", "목", "금", "토"] as 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
- const defaultMonthLabel = (year: number, month: number) =>
36
- `${year}년 ${month + 1}월`;
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 포맷. @default "{year}년 {month+1}월" */
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 = defaultMonthLabel,
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 ?? DEFAULT_WEEKDAYS_KO;
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
- defaultLabel: string,
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 ?? defaultLabel}
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 표시 포맷. @default "{year}년" */
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 = (y) => `${y}년`,
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"]}>{formatYear(year)}</span>
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
- {formatYear(y)}
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 표시 포맷. @default "{month+1}월" */
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 = (m) => `${m + 1}월`,
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"]}>{formatMonth(month)}</span>
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
- {formatMonth(m)}
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
- const DEFAULT_WEEKDAYS_KO = ["일", "월", "화", "수", "목", "금", "토"] as const;
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
- const defaultMonthLabel = (year: number, month: number) => `${year}년 ${month + 1}월`;
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 = defaultMonthLabel,
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 ?? DEFAULT_WEEKDAYS_KO;
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, ariaLabel,
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
- defaultLabel: string,
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 ?? defaultLabel}
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 />, "이전 해", (ctx) => ctx.prevYear);
360
- export const CalendarNextYearButton = makeNavButton("CalendarNextYearButton", <ChevronDoubleRightIcon />, "다음 해", (ctx) => ctx.nextYear);
361
- export const CalendarPrevMonthButton = makeNavButton("CalendarPrevMonthButton", <ChevronLeftIcon />, "이전 달", (ctx) => ctx.prevMonth);
362
- export const CalendarNextMonthButton = makeNavButton("CalendarNextMonthButton", <ChevronRightIcon />, "다음 달", (ctx) => ctx.nextMonth);
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 = (y) => `${y}년` }: CalendarYearSelectProps) {
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>{formatYear(year)}</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)}>{formatYear(y)}</SelectItem>)}
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 = (m) => `${m + 1}월` }: CalendarMonthSelectProps) {
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>{formatMonth(month)}</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)}>{formatMonth(m)}</SelectItem>)}
476
+ {Array.from({ length: 12 }, (_, m) => <SelectItem key={m} value={String(m)}>{resolvedFormat(m)}</SelectItem>)}
403
477
  </SelectContent>
404
478
  </Select>
405
479
  );