sh-ui-cli 0.52.3 → 0.53.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 +14 -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
|
@@ -2,6 +2,20 @@
|
|
|
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.53.0",
|
|
7
|
+
"date": "2026-05-04",
|
|
8
|
+
"title": "Calendar/DatePicker i18n — locale + messages prop",
|
|
9
|
+
"type": "minor",
|
|
10
|
+
"highlights": [
|
|
11
|
+
"**Calendar `locale` prop 추가** — BCP47 로케일(예: `\"en-US\"`, `\"ja-JP\"`) 한 줄로 요일·월 헤더·연도/월 dropdown 라벨이 모두 `Intl.DateTimeFormat` 으로 자동 생성. 한국어만 지원되던 기존 동작에서 다국어 지원으로 확장. 기본값은 `\"ko-KR\"` 유지 — 기존 사용자 영향 없음.",
|
|
12
|
+
"**`messages` prop 으로 aria-label override** — Nav 버튼(`이전 해`/`다음 달` 등) 과 select aria-label(`연도`/`월`) 을 `CalendarMessages` 객체로 한 번에 교체. ko/en 은 내장 기본값 제공, 그 외 언어는 직접 문자열 전달.",
|
|
13
|
+
"**DatePicker / DateRangePicker 도 `locale` 전파** — 내부 Calendar 에 자동 전달되고 placeholder(`날짜 선택`/`Select date` / `시작일 ~ 종료일`/`Start date – end date`) 도 locale 기반 자동 결정. `placeholder` 를 직접 넘기면 그게 우선.",
|
|
14
|
+
"**개별 포맷터 prop 은 그대로 유효** — `weekdayLabels`/`formatMonthLabel`/`CalendarYearSelect.formatYear` 등 기존 세밀 override 는 locale 보다 우선. 마이그레이션 불필요.",
|
|
15
|
+
"**새 export** — `Calendar` 모듈에서 `DEFAULT_LOCALE` 상수와 `CalendarMessages` 타입을 추가로 노출."
|
|
16
|
+
],
|
|
17
|
+
"url": "https://github.com/sanghyeonKim0201/sh-ui/releases/tag/v0.53.0"
|
|
18
|
+
},
|
|
5
19
|
{
|
|
6
20
|
"version": "0.52.3",
|
|
7
21
|
"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
|
);
|
|
@@ -12,8 +12,8 @@ import "./styles.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("sh-ui-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("sh-ui-calendar__select-trigger", className)}
|
|
651
|
-
aria-label=
|
|
749
|
+
aria-label={ctx.messages.yearSelectLabel}
|
|
652
750
|
>
|
|
653
|
-
<span className="sh-ui-calendar__select-value">{
|
|
751
|
+
<span className="sh-ui-calendar__select-value">{resolvedFormat(year)}</span>
|
|
654
752
|
</SelectTrigger>
|
|
655
753
|
<SelectContent className="sh-ui-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("sh-ui-calendar__select-trigger", className)}
|
|
686
|
-
aria-label=
|
|
785
|
+
aria-label={ctx.messages.monthSelectLabel}
|
|
687
786
|
>
|
|
688
|
-
<span className="sh-ui-calendar__select-value">{
|
|
787
|
+
<span className="sh-ui-calendar__select-value">{resolvedFormat(month)}</span>
|
|
689
788
|
</SelectTrigger>
|
|
690
789
|
<SelectContent className="sh-ui-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>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { Popover as BasePopover } from "@base-ui/react/popover";
|
|
5
|
-
import { Calendar, type DateRange } from "../calendar";
|
|
5
|
+
import { Calendar, DEFAULT_LOCALE, type CalendarMessages, type DateRange } from "../calendar";
|
|
6
6
|
import styles from "./styles.module.css";
|
|
7
7
|
|
|
8
8
|
import { cn } from "@SH_UI_UTILS@";
|
|
@@ -17,6 +17,15 @@ const formatDefault = (d: Date) =>
|
|
|
17
17
|
const startOfMonth = (d: Date) =>
|
|
18
18
|
new Date(d.getFullYear(), d.getMonth(), 1);
|
|
19
19
|
|
|
20
|
+
function defaultDatePlaceholder(locale: string): string {
|
|
21
|
+
const lang = locale.toLowerCase().split(/[-_]/)[0];
|
|
22
|
+
return lang === "ko" ? "날짜 선택" : "Select date";
|
|
23
|
+
}
|
|
24
|
+
function defaultRangePlaceholder(locale: string): string {
|
|
25
|
+
const lang = locale.toLowerCase().split(/[-_]/)[0];
|
|
26
|
+
return lang === "ko" ? "시작일 ~ 종료일" : "Start date – end date";
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
/* ───────── Icons ───────── */
|
|
21
30
|
|
|
22
31
|
function CalendarIcon() {
|
|
@@ -39,6 +48,8 @@ interface DatePickerContextValue {
|
|
|
39
48
|
setFocusedDate: (date: Date) => void;
|
|
40
49
|
formatDate: (date: Date) => string;
|
|
41
50
|
placeholder: string;
|
|
51
|
+
locale: string;
|
|
52
|
+
messages?: CalendarMessages;
|
|
42
53
|
min?: Date;
|
|
43
54
|
max?: Date;
|
|
44
55
|
disabled?: boolean;
|
|
@@ -76,10 +87,13 @@ export interface DatePickerProps {
|
|
|
76
87
|
/** 선택 가능 최대 날짜 (포함). 이후 날짜는 비활성. */
|
|
77
88
|
max?: Date;
|
|
78
89
|
/**
|
|
79
|
-
* 미선택 상태의 트리거 텍스트.
|
|
80
|
-
* @default "날짜 선택"
|
|
90
|
+
* 미선택 상태의 트리거 텍스트. 미지정 시 `locale` 기반 기본값.
|
|
81
91
|
*/
|
|
82
92
|
placeholder?: string;
|
|
93
|
+
/** BCP47 로케일. 내부 Calendar 와 placeholder 에 모두 적용. @default "ko-KR" */
|
|
94
|
+
locale?: string;
|
|
95
|
+
/** 내부 Calendar 의 nav/select aria-label override. */
|
|
96
|
+
messages?: CalendarMessages;
|
|
83
97
|
/** 비활성. 트리거 클릭·키보드 모두 차단. */
|
|
84
98
|
disabled?: boolean;
|
|
85
99
|
/** 읽기 전용. 트리거 표시는 유지하되 popover가 열리지 않는다. */
|
|
@@ -119,7 +133,9 @@ export function DatePicker({
|
|
|
119
133
|
formatDate = formatDefault,
|
|
120
134
|
min,
|
|
121
135
|
max,
|
|
122
|
-
placeholder
|
|
136
|
+
placeholder,
|
|
137
|
+
locale = DEFAULT_LOCALE,
|
|
138
|
+
messages,
|
|
123
139
|
disabled,
|
|
124
140
|
readOnly,
|
|
125
141
|
"aria-invalid": ariaInvalid,
|
|
@@ -128,6 +144,7 @@ export function DatePicker({
|
|
|
128
144
|
container,
|
|
129
145
|
children,
|
|
130
146
|
}: DatePickerProps) {
|
|
147
|
+
const resolvedPlaceholder = placeholder ?? defaultDatePlaceholder(locale);
|
|
131
148
|
const isControlled = value !== undefined;
|
|
132
149
|
const [internal, setInternal] = React.useState<Date | undefined>(defaultValue);
|
|
133
150
|
const selected = isControlled ? value : internal;
|
|
@@ -160,7 +177,9 @@ export function DatePicker({
|
|
|
160
177
|
focusedDate,
|
|
161
178
|
setFocusedDate,
|
|
162
179
|
formatDate,
|
|
163
|
-
placeholder,
|
|
180
|
+
placeholder: resolvedPlaceholder,
|
|
181
|
+
locale,
|
|
182
|
+
messages,
|
|
164
183
|
min,
|
|
165
184
|
max,
|
|
166
185
|
disabled,
|
|
@@ -174,7 +193,9 @@ export function DatePicker({
|
|
|
174
193
|
open,
|
|
175
194
|
focusedDate,
|
|
176
195
|
formatDate,
|
|
177
|
-
|
|
196
|
+
resolvedPlaceholder,
|
|
197
|
+
locale,
|
|
198
|
+
messages,
|
|
178
199
|
min,
|
|
179
200
|
max,
|
|
180
201
|
disabled,
|
|
@@ -355,6 +376,8 @@ export function DatePickerCalendar() {
|
|
|
355
376
|
onMonthChange={ctx.setFocusedDate}
|
|
356
377
|
min={ctx.min}
|
|
357
378
|
max={ctx.max}
|
|
379
|
+
locale={ctx.locale}
|
|
380
|
+
messages={ctx.messages}
|
|
358
381
|
/>
|
|
359
382
|
);
|
|
360
383
|
}
|
|
@@ -407,10 +430,13 @@ export interface DateRangePickerProps {
|
|
|
407
430
|
/** 선택 가능 최대 날짜. */
|
|
408
431
|
max?: Date;
|
|
409
432
|
/**
|
|
410
|
-
* 미선택 상태의 트리거 텍스트.
|
|
411
|
-
* @default "시작일 ~ 종료일"
|
|
433
|
+
* 미선택 상태의 트리거 텍스트. 미지정 시 `locale` 기반 기본값.
|
|
412
434
|
*/
|
|
413
435
|
placeholder?: string;
|
|
436
|
+
/** BCP47 로케일. @default "ko-KR" */
|
|
437
|
+
locale?: string;
|
|
438
|
+
/** 내부 Calendar 의 nav/select aria-label override. */
|
|
439
|
+
messages?: CalendarMessages;
|
|
414
440
|
/** 비활성. */
|
|
415
441
|
disabled?: boolean;
|
|
416
442
|
/** 읽기 전용. popover가 열리지 않는다. */
|
|
@@ -438,7 +464,9 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
438
464
|
formatDate = formatDefault,
|
|
439
465
|
min,
|
|
440
466
|
max,
|
|
441
|
-
placeholder
|
|
467
|
+
placeholder,
|
|
468
|
+
locale = DEFAULT_LOCALE,
|
|
469
|
+
messages,
|
|
442
470
|
disabled,
|
|
443
471
|
readOnly,
|
|
444
472
|
"aria-invalid": ariaInvalid,
|
|
@@ -447,6 +475,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
447
475
|
},
|
|
448
476
|
ref,
|
|
449
477
|
) {
|
|
478
|
+
const resolvedPlaceholder = placeholder ?? defaultRangePlaceholder(locale);
|
|
450
479
|
const isControlled = value !== undefined;
|
|
451
480
|
const [internal, setInternal] = React.useState<DateRange | undefined>(defaultValue);
|
|
452
481
|
const selected = isControlled ? value : internal;
|
|
@@ -485,7 +514,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
485
514
|
}}
|
|
486
515
|
>
|
|
487
516
|
<span className={cn(styles["date-picker__value"], !displayText && styles["date-picker__placeholder"])}>
|
|
488
|
-
{displayText ??
|
|
517
|
+
{displayText ?? resolvedPlaceholder}
|
|
489
518
|
</span>
|
|
490
519
|
<span className={styles["date-picker__icon"]} aria-hidden>
|
|
491
520
|
<CalendarIcon />
|
|
@@ -509,6 +538,8 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
509
538
|
onMonthChange={setCalendarMonth}
|
|
510
539
|
min={min}
|
|
511
540
|
max={max}
|
|
541
|
+
locale={locale}
|
|
542
|
+
messages={messages}
|
|
512
543
|
/>
|
|
513
544
|
</BasePopover.Popup>
|
|
514
545
|
</BasePopover.Positioner>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { Popover as BasePopover } from "@base-ui/react/popover";
|
|
5
|
-
import { Calendar, type DateRange } from "../calendar";
|
|
5
|
+
import { Calendar, DEFAULT_LOCALE, type CalendarMessages, type DateRange } from "../calendar";
|
|
6
6
|
|
|
7
7
|
import { cn } from "@SH_UI_UTILS@";
|
|
8
8
|
export type { DateRange };
|
|
@@ -12,6 +12,15 @@ const formatDefault = (d: Date) =>
|
|
|
12
12
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
13
13
|
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
|
|
14
14
|
|
|
15
|
+
function defaultDatePlaceholder(locale: string): string {
|
|
16
|
+
const lang = locale.toLowerCase().split(/[-_]/)[0];
|
|
17
|
+
return lang === "ko" ? "날짜 선택" : "Select date";
|
|
18
|
+
}
|
|
19
|
+
function defaultRangePlaceholder(locale: string): string {
|
|
20
|
+
const lang = locale.toLowerCase().split(/[-_]/)[0];
|
|
21
|
+
return lang === "ko" ? "시작일 ~ 종료일" : "Start date – end date";
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
const triggerClasses =
|
|
16
25
|
"inline-flex items-center justify-between w-full h-[var(--control-md)] px-[var(--space-3)] bg-background text-foreground border border-border rounded-[var(--radius)] font-[inherit] text-[length:var(--text-sm)] leading-none cursor-pointer transition-[border-color,box-shadow] duration-[var(--duration-fast)] hover:not-disabled:border-border-strong focus-visible:outline-none focus-visible:border-foreground focus-visible:shadow-[0_0_0_1px_var(--foreground)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed disabled:bg-background-subtle aria-[invalid=true]:border-danger aria-[invalid=true]:focus-visible:shadow-[0_0_0_1px_var(--danger)] [@media(hover:none)_and_(pointer:coarse)]:h-11 [@media(hover:none)_and_(pointer:coarse)]:text-[length:var(--text-base)]";
|
|
17
26
|
|
|
@@ -36,6 +45,8 @@ interface DatePickerContextValue {
|
|
|
36
45
|
setFocusedDate: (date: Date) => void;
|
|
37
46
|
formatDate: (date: Date) => string;
|
|
38
47
|
placeholder: string;
|
|
48
|
+
locale: string;
|
|
49
|
+
messages?: CalendarMessages;
|
|
39
50
|
min?: Date; max?: Date; disabled?: boolean; readOnly?: boolean;
|
|
40
51
|
ariaInvalid?: boolean | "true";
|
|
41
52
|
closeOnSelect: boolean;
|
|
@@ -54,7 +65,8 @@ export interface DatePickerProps {
|
|
|
54
65
|
onValueChange?: (date: Date | undefined) => void;
|
|
55
66
|
formatDate?: (date: Date) => string;
|
|
56
67
|
min?: Date; max?: Date;
|
|
57
|
-
placeholder?: string;
|
|
68
|
+
placeholder?: string; locale?: string; messages?: CalendarMessages;
|
|
69
|
+
disabled?: boolean; readOnly?: boolean;
|
|
58
70
|
"aria-invalid"?: boolean | "true";
|
|
59
71
|
className?: string; closeOnSelect?: boolean;
|
|
60
72
|
container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
|
|
@@ -63,9 +75,10 @@ export interface DatePickerProps {
|
|
|
63
75
|
|
|
64
76
|
export function DatePicker({
|
|
65
77
|
value, defaultValue, onValueChange, formatDate = formatDefault,
|
|
66
|
-
min, max, placeholder =
|
|
78
|
+
min, max, placeholder, locale = DEFAULT_LOCALE, messages, disabled, readOnly,
|
|
67
79
|
"aria-invalid": ariaInvalid, className, closeOnSelect = true, container, children,
|
|
68
80
|
}: DatePickerProps) {
|
|
81
|
+
const resolvedPlaceholder = placeholder ?? defaultDatePlaceholder(locale);
|
|
69
82
|
const isControlled = value !== undefined;
|
|
70
83
|
const [internal, setInternal] = React.useState<Date | undefined>(defaultValue);
|
|
71
84
|
const selected = isControlled ? value : internal;
|
|
@@ -84,9 +97,10 @@ export function DatePicker({
|
|
|
84
97
|
const ctx = React.useMemo<DatePickerContextValue>(
|
|
85
98
|
() => ({
|
|
86
99
|
selected, setSelected, open, setOpen, focusedDate, setFocusedDate,
|
|
87
|
-
formatDate, placeholder
|
|
100
|
+
formatDate, placeholder: resolvedPlaceholder, locale, messages,
|
|
101
|
+
min, max, disabled, readOnly, ariaInvalid, closeOnSelect,
|
|
88
102
|
}),
|
|
89
|
-
[selected, setSelected, open, focusedDate, formatDate,
|
|
103
|
+
[selected, setSelected, open, focusedDate, formatDate, resolvedPlaceholder, locale, messages, min, max, disabled, readOnly, ariaInvalid, closeOnSelect],
|
|
90
104
|
);
|
|
91
105
|
|
|
92
106
|
return (
|
|
@@ -187,6 +201,8 @@ export function DatePickerCalendar() {
|
|
|
187
201
|
onMonthChange={ctx.setFocusedDate}
|
|
188
202
|
min={ctx.min}
|
|
189
203
|
max={ctx.max}
|
|
204
|
+
locale={ctx.locale}
|
|
205
|
+
messages={ctx.messages}
|
|
190
206
|
/>
|
|
191
207
|
);
|
|
192
208
|
}
|
|
@@ -221,7 +237,8 @@ export interface DateRangePickerProps {
|
|
|
221
237
|
onValueChange?: (range: DateRange | undefined) => void;
|
|
222
238
|
formatDate?: (date: Date) => string;
|
|
223
239
|
min?: Date; max?: Date;
|
|
224
|
-
placeholder?: string;
|
|
240
|
+
placeholder?: string; locale?: string; messages?: CalendarMessages;
|
|
241
|
+
disabled?: boolean; readOnly?: boolean;
|
|
225
242
|
"aria-invalid"?: boolean | "true";
|
|
226
243
|
className?: string;
|
|
227
244
|
container?: React.ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
|
|
@@ -230,9 +247,10 @@ export interface DateRangePickerProps {
|
|
|
230
247
|
export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePickerProps>(
|
|
231
248
|
function DateRangePicker(
|
|
232
249
|
{ value, defaultValue, onValueChange, formatDate = formatDefault, min, max,
|
|
233
|
-
placeholder =
|
|
250
|
+
placeholder, locale = DEFAULT_LOCALE, messages, disabled, readOnly, "aria-invalid": ariaInvalid, className, container },
|
|
234
251
|
ref,
|
|
235
252
|
) {
|
|
253
|
+
const resolvedPlaceholder = placeholder ?? defaultRangePlaceholder(locale);
|
|
236
254
|
const isControlled = value !== undefined;
|
|
237
255
|
const [internal, setInternal] = React.useState<DateRange | undefined>(defaultValue);
|
|
238
256
|
const selected = isControlled ? value : internal;
|
|
@@ -262,7 +280,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
262
280
|
onClick={(e) => { if (readOnly) e.preventDefault(); }}
|
|
263
281
|
>
|
|
264
282
|
<span className={cn("overflow-hidden text-ellipsis whitespace-nowrap", !displayText && "text-foreground-subtle")}>
|
|
265
|
-
{displayText ??
|
|
283
|
+
{displayText ?? resolvedPlaceholder}
|
|
266
284
|
</span>
|
|
267
285
|
<span className="shrink-0 inline-flex text-foreground-muted ml-[var(--space-2)]" aria-hidden>
|
|
268
286
|
<CalendarIcon />
|
|
@@ -281,6 +299,8 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
281
299
|
onMonthChange={setCalendarMonth}
|
|
282
300
|
min={min}
|
|
283
301
|
max={max}
|
|
302
|
+
locale={locale}
|
|
303
|
+
messages={messages}
|
|
284
304
|
/>
|
|
285
305
|
</BasePopover.Popup>
|
|
286
306
|
</BasePopover.Positioner>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { Popover as BasePopover } from "@base-ui/react/popover";
|
|
5
|
-
import { Calendar, type DateRange } from "../calendar";
|
|
5
|
+
import { Calendar, DEFAULT_LOCALE, type CalendarMessages, type DateRange } from "../calendar";
|
|
6
6
|
import "./styles.css";
|
|
7
7
|
|
|
8
8
|
import { cn } from "@SH_UI_UTILS@";
|
|
@@ -17,6 +17,18 @@ const formatDefault = (d: Date) =>
|
|
|
17
17
|
const startOfMonth = (d: Date) =>
|
|
18
18
|
new Date(d.getFullYear(), d.getMonth(), 1);
|
|
19
19
|
|
|
20
|
+
/** locale 기반 단일 날짜 placeholder. 한국어 외에는 영어 fallback. */
|
|
21
|
+
function defaultDatePlaceholder(locale: string): string {
|
|
22
|
+
const lang = locale.toLowerCase().split(/[-_]/)[0];
|
|
23
|
+
return lang === "ko" ? "날짜 선택" : "Select date";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** locale 기반 범위 placeholder. */
|
|
27
|
+
function defaultRangePlaceholder(locale: string): string {
|
|
28
|
+
const lang = locale.toLowerCase().split(/[-_]/)[0];
|
|
29
|
+
return lang === "ko" ? "시작일 ~ 종료일" : "Start date – end date";
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
/* ───────── Icons ───────── */
|
|
21
33
|
|
|
22
34
|
function CalendarIcon() {
|
|
@@ -39,6 +51,8 @@ interface DatePickerContextValue {
|
|
|
39
51
|
setFocusedDate: (date: Date) => void;
|
|
40
52
|
formatDate: (date: Date) => string;
|
|
41
53
|
placeholder: string;
|
|
54
|
+
locale: string;
|
|
55
|
+
messages?: CalendarMessages;
|
|
42
56
|
min?: Date;
|
|
43
57
|
max?: Date;
|
|
44
58
|
disabled?: boolean;
|
|
@@ -76,10 +90,16 @@ export interface DatePickerProps {
|
|
|
76
90
|
/** 선택 가능 최대 날짜 (포함). 이후 날짜는 비활성. */
|
|
77
91
|
max?: Date;
|
|
78
92
|
/**
|
|
79
|
-
* 미선택 상태의 트리거 텍스트.
|
|
80
|
-
* @default "날짜 선택"
|
|
93
|
+
* 미선택 상태의 트리거 텍스트. 미지정 시 `locale` 기반 자동 생성("날짜 선택" / "Select date").
|
|
81
94
|
*/
|
|
82
95
|
placeholder?: string;
|
|
96
|
+
/**
|
|
97
|
+
* BCP47 로케일. 내부 Calendar 와 placeholder 기본값에 모두 적용된다.
|
|
98
|
+
* @default "ko-KR"
|
|
99
|
+
*/
|
|
100
|
+
locale?: string;
|
|
101
|
+
/** 내부 Calendar 의 nav/select aria-label override. */
|
|
102
|
+
messages?: CalendarMessages;
|
|
83
103
|
/** 비활성. 트리거 클릭·키보드 모두 차단. */
|
|
84
104
|
disabled?: boolean;
|
|
85
105
|
/** 읽기 전용. 트리거 표시는 유지하되 popover가 열리지 않는다. */
|
|
@@ -119,7 +139,9 @@ export function DatePicker({
|
|
|
119
139
|
formatDate = formatDefault,
|
|
120
140
|
min,
|
|
121
141
|
max,
|
|
122
|
-
placeholder
|
|
142
|
+
placeholder,
|
|
143
|
+
locale = DEFAULT_LOCALE,
|
|
144
|
+
messages,
|
|
123
145
|
disabled,
|
|
124
146
|
readOnly,
|
|
125
147
|
"aria-invalid": ariaInvalid,
|
|
@@ -128,6 +150,7 @@ export function DatePicker({
|
|
|
128
150
|
container,
|
|
129
151
|
children,
|
|
130
152
|
}: DatePickerProps) {
|
|
153
|
+
const resolvedPlaceholder = placeholder ?? defaultDatePlaceholder(locale);
|
|
131
154
|
const isControlled = value !== undefined;
|
|
132
155
|
const [internal, setInternal] = React.useState<Date | undefined>(defaultValue);
|
|
133
156
|
const selected = isControlled ? value : internal;
|
|
@@ -160,7 +183,9 @@ export function DatePicker({
|
|
|
160
183
|
focusedDate,
|
|
161
184
|
setFocusedDate,
|
|
162
185
|
formatDate,
|
|
163
|
-
placeholder,
|
|
186
|
+
placeholder: resolvedPlaceholder,
|
|
187
|
+
locale,
|
|
188
|
+
messages,
|
|
164
189
|
min,
|
|
165
190
|
max,
|
|
166
191
|
disabled,
|
|
@@ -174,7 +199,9 @@ export function DatePicker({
|
|
|
174
199
|
open,
|
|
175
200
|
focusedDate,
|
|
176
201
|
formatDate,
|
|
177
|
-
|
|
202
|
+
resolvedPlaceholder,
|
|
203
|
+
locale,
|
|
204
|
+
messages,
|
|
178
205
|
min,
|
|
179
206
|
max,
|
|
180
207
|
disabled,
|
|
@@ -355,6 +382,8 @@ export function DatePickerCalendar() {
|
|
|
355
382
|
onMonthChange={ctx.setFocusedDate}
|
|
356
383
|
min={ctx.min}
|
|
357
384
|
max={ctx.max}
|
|
385
|
+
locale={ctx.locale}
|
|
386
|
+
messages={ctx.messages}
|
|
358
387
|
/>
|
|
359
388
|
);
|
|
360
389
|
}
|
|
@@ -407,10 +436,16 @@ export interface DateRangePickerProps {
|
|
|
407
436
|
/** 선택 가능 최대 날짜. */
|
|
408
437
|
max?: Date;
|
|
409
438
|
/**
|
|
410
|
-
* 미선택 상태의 트리거 텍스트.
|
|
411
|
-
* @default "시작일 ~ 종료일"
|
|
439
|
+
* 미선택 상태의 트리거 텍스트. 미지정 시 `locale` 기반 자동 생성.
|
|
412
440
|
*/
|
|
413
441
|
placeholder?: string;
|
|
442
|
+
/**
|
|
443
|
+
* BCP47 로케일. 내부 Calendar 와 placeholder 기본값에 모두 적용.
|
|
444
|
+
* @default "ko-KR"
|
|
445
|
+
*/
|
|
446
|
+
locale?: string;
|
|
447
|
+
/** 내부 Calendar 의 nav/select aria-label override. */
|
|
448
|
+
messages?: CalendarMessages;
|
|
414
449
|
/** 비활성. */
|
|
415
450
|
disabled?: boolean;
|
|
416
451
|
/** 읽기 전용. popover가 열리지 않는다. */
|
|
@@ -438,7 +473,9 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
438
473
|
formatDate = formatDefault,
|
|
439
474
|
min,
|
|
440
475
|
max,
|
|
441
|
-
placeholder
|
|
476
|
+
placeholder,
|
|
477
|
+
locale = DEFAULT_LOCALE,
|
|
478
|
+
messages,
|
|
442
479
|
disabled,
|
|
443
480
|
readOnly,
|
|
444
481
|
"aria-invalid": ariaInvalid,
|
|
@@ -447,6 +484,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
447
484
|
},
|
|
448
485
|
ref,
|
|
449
486
|
) {
|
|
487
|
+
const resolvedPlaceholder = placeholder ?? defaultRangePlaceholder(locale);
|
|
450
488
|
const isControlled = value !== undefined;
|
|
451
489
|
const [internal, setInternal] = React.useState<DateRange | undefined>(defaultValue);
|
|
452
490
|
const selected = isControlled ? value : internal;
|
|
@@ -485,7 +523,7 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
485
523
|
}}
|
|
486
524
|
>
|
|
487
525
|
<span className={cn("sh-ui-date-picker__value", !displayText && "sh-ui-date-picker__placeholder")}>
|
|
488
|
-
{displayText ??
|
|
526
|
+
{displayText ?? resolvedPlaceholder}
|
|
489
527
|
</span>
|
|
490
528
|
<span className="sh-ui-date-picker__icon" aria-hidden>
|
|
491
529
|
<CalendarIcon />
|
|
@@ -509,6 +547,8 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
|
|
|
509
547
|
onMonthChange={setCalendarMonth}
|
|
510
548
|
min={min}
|
|
511
549
|
max={max}
|
|
550
|
+
locale={locale}
|
|
551
|
+
messages={messages}
|
|
512
552
|
/>
|
|
513
553
|
</BasePopover.Popup>
|
|
514
554
|
</BasePopover.Positioner>
|