sh-ui-cli 0.41.0 → 0.42.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.
@@ -0,0 +1,227 @@
1
+ /* ── Calendar root ── */
2
+
3
+ .sh-ui-calendar {
4
+ display: inline-flex;
5
+ gap: var(--space-4);
6
+ user-select: none;
7
+ }
8
+
9
+ .sh-ui-calendar--multi {
10
+ flex-wrap: wrap;
11
+ }
12
+
13
+ .sh-ui-calendar__month {
14
+ width: 17.5rem;
15
+ }
16
+
17
+ /* ── Header (compound) ── */
18
+
19
+ .sh-ui-calendar__header {
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: space-between;
23
+ gap: var(--space-1);
24
+ margin-bottom: var(--space-2);
25
+ }
26
+
27
+ .sh-ui-calendar__title {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ gap: var(--space-1);
31
+ flex: 1 1 auto;
32
+ justify-content: center;
33
+ }
34
+
35
+ /* ── Nav buttons ── */
36
+
37
+ .sh-ui-calendar__nav {
38
+ display: inline-flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ width: 1.75rem;
42
+ height: 1.75rem;
43
+ padding: 0;
44
+ border: none;
45
+ border-radius: calc(var(--radius) - 2px);
46
+ background: transparent;
47
+ color: var(--foreground-muted);
48
+ cursor: pointer;
49
+ flex-shrink: 0;
50
+ transition: background-color var(--duration-fast), color var(--duration-fast);
51
+ }
52
+
53
+ .sh-ui-calendar__nav:hover:not(:disabled) {
54
+ background: var(--background-muted);
55
+ color: var(--foreground);
56
+ }
57
+
58
+ .sh-ui-calendar__nav:focus-visible {
59
+ outline: var(--border-width-strong) solid var(--foreground);
60
+ outline-offset: 2px;
61
+ }
62
+
63
+ .sh-ui-calendar__nav--placeholder {
64
+ visibility: hidden;
65
+ pointer-events: none;
66
+ }
67
+
68
+ /* ── Select (year / month dropdown) ── */
69
+ /* sh-ui Select 의 trigger 를 캘린더 헤더용으로 컴팩트하게 오버라이드 */
70
+
71
+ .sh-ui-calendar__select-trigger.sh-ui-select__trigger {
72
+ min-width: 0;
73
+ height: 1.75rem;
74
+ gap: var(--space-1);
75
+ padding: 0 var(--space-2);
76
+ background: transparent;
77
+ border-color: transparent;
78
+ font-weight: var(--weight-semibold);
79
+ font-size: var(--text-sm);
80
+ color: var(--foreground);
81
+ }
82
+
83
+ .sh-ui-calendar__select-trigger.sh-ui-select__trigger:hover:not(:disabled) {
84
+ background: var(--background-muted);
85
+ border-color: transparent;
86
+ }
87
+
88
+ .sh-ui-calendar__select-trigger.sh-ui-select__trigger[data-popup-open] {
89
+ background: var(--background-muted);
90
+ border-color: transparent;
91
+ }
92
+
93
+ /* popover 안의 캘린더에서도 dropdown 이 위로 올라오도록 z-index 보강.
94
+ * (Select 의 기본 z-dropdown=200 < z-popover=500 이므로 :has 로 캘린더 select 만 선택해 z-popover 로 끌어올림.) */
95
+ .sh-ui-select__positioner:has(.sh-ui-calendar__select-popup) {
96
+ z-index: var(--z-popover);
97
+ }
98
+
99
+ /* ── Weekdays ── */
100
+
101
+ .sh-ui-calendar__weekdays {
102
+ display: grid;
103
+ grid-template-columns: repeat(7, 1fr);
104
+ margin-bottom: var(--space-1);
105
+ }
106
+
107
+ .sh-ui-calendar__weekday {
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ height: 2rem;
112
+ font-size: var(--text-xs);
113
+ font-weight: var(--weight-medium);
114
+ color: var(--foreground-muted);
115
+ }
116
+
117
+ /* ── Grid ── */
118
+
119
+ .sh-ui-calendar__grid {
120
+ display: grid;
121
+ grid-template-columns: repeat(7, 1fr);
122
+ outline: none;
123
+ }
124
+
125
+ .sh-ui-calendar__grid:focus-visible {
126
+ outline: var(--border-width-strong) solid var(--foreground);
127
+ outline-offset: 2px;
128
+ border-radius: calc(var(--radius) - 2px);
129
+ }
130
+
131
+ /* ── Day cell ── */
132
+
133
+ .sh-ui-calendar__day {
134
+ display: flex;
135
+ align-items: center;
136
+ justify-content: center;
137
+ width: 2.25rem;
138
+ height: 2.25rem;
139
+ margin: 0.0625rem auto;
140
+ padding: 0;
141
+ border: none;
142
+ border-radius: calc(var(--radius) - 2px);
143
+ background: transparent;
144
+ color: var(--foreground);
145
+ font-size: 0.8125rem;
146
+ font-family: inherit;
147
+ cursor: pointer;
148
+ transition: background-color var(--duration-fast), color var(--duration-fast);
149
+ }
150
+
151
+ .sh-ui-calendar__day:hover:not(:disabled) {
152
+ background: var(--background-muted);
153
+ }
154
+
155
+ .sh-ui-calendar__day:focus-visible {
156
+ outline: var(--border-width-strong) solid var(--foreground);
157
+ outline-offset: 2px;
158
+ }
159
+
160
+ .sh-ui-calendar__day--outside {
161
+ color: var(--foreground-subtle, var(--foreground-muted));
162
+ opacity: 0.4;
163
+ }
164
+
165
+ .sh-ui-calendar__day--hidden {
166
+ visibility: hidden;
167
+ pointer-events: none;
168
+ cursor: default;
169
+ background: transparent;
170
+ }
171
+
172
+ .sh-ui-calendar__day--today {
173
+ font-weight: var(--weight-bold);
174
+ text-decoration: underline;
175
+ text-underline-offset: 0.125rem;
176
+ }
177
+
178
+ .sh-ui-calendar__day--selected {
179
+ background: var(--primary);
180
+ color: var(--primary-foreground);
181
+ font-weight: var(--weight-semibold);
182
+ }
183
+
184
+ .sh-ui-calendar__day--selected:hover:not(:disabled) {
185
+ background: var(--primary-hover);
186
+ color: var(--primary-foreground);
187
+ }
188
+
189
+ .sh-ui-calendar__day:disabled {
190
+ opacity: 0.3;
191
+ cursor: not-allowed;
192
+ }
193
+
194
+ /* ── Range ── */
195
+
196
+ .sh-ui-calendar__day--in-range {
197
+ background: color-mix(in srgb, var(--primary) 12%, transparent);
198
+ border-radius: 0;
199
+ }
200
+
201
+ .sh-ui-calendar__day--in-range:hover:not(:disabled) {
202
+ background: color-mix(in srgb, var(--primary) 22%, transparent);
203
+ }
204
+
205
+ .sh-ui-calendar__day--range-start {
206
+ background: var(--primary);
207
+ color: var(--primary-foreground);
208
+ font-weight: var(--weight-semibold);
209
+ border-radius: calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px);
210
+ }
211
+
212
+ .sh-ui-calendar__day--range-end {
213
+ background: var(--primary);
214
+ color: var(--primary-foreground);
215
+ font-weight: var(--weight-semibold);
216
+ border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
217
+ }
218
+
219
+ .sh-ui-calendar__day--range-start.sh-ui-calendar__day--range-end {
220
+ border-radius: calc(var(--radius) - 2px);
221
+ }
222
+
223
+ .sh-ui-calendar__day--range-start:hover:not(:disabled),
224
+ .sh-ui-calendar__day--range-end:hover:not(:disabled) {
225
+ background: var(--primary-hover);
226
+ color: var(--primary-foreground);
227
+ }
@@ -2,84 +2,25 @@
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
6
  import "./styles.css";
6
7
 
8
+ export type { DateRange };
9
+
7
10
  /* ───────── Helpers ───────── */
8
11
 
9
12
  function cx(...args: (string | undefined | false)[]) {
10
13
  return args.filter(Boolean).join(" ");
11
14
  }
12
15
 
13
- const WEEK_LABELS = ["일", "월", "화", "수", "목", "금", "토"] as const;
14
-
15
- const isSameDay = (a: Date, b: Date) =>
16
- a.getFullYear() === b.getFullYear() &&
17
- a.getMonth() === b.getMonth() &&
18
- a.getDate() === b.getDate();
19
-
20
- const toDateOnly = (d: Date) =>
21
- new Date(d.getFullYear(), d.getMonth(), d.getDate());
22
-
23
16
  const formatDefault = (d: Date) =>
24
17
  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
25
18
 
26
- const clampMonth = (year: number, month: number): [number, number] => {
27
- if (month < 0) return [year - 1, 11];
28
- if (month > 11) return [year + 1, 0];
29
- return [year, month];
30
- };
31
-
32
- function getDaysGrid(year: number, month: number) {
33
- const first = new Date(year, month, 1);
34
- const startDay = first.getDay();
35
- const daysInMonth = new Date(year, month + 1, 0).getDate();
36
- const prevDays = new Date(year, month, 0).getDate();
37
-
38
- const cells: { date: Date; current: boolean }[] = [];
39
-
40
- for (let i = startDay - 1; i >= 0; i--) {
41
- cells.push({ date: new Date(year, month - 1, prevDays - i), current: false });
42
- }
43
- for (let d = 1; d <= daysInMonth; d++) {
44
- cells.push({ date: new Date(year, month, d), current: true });
45
- }
46
- const remaining = 7 - (cells.length % 7);
47
- if (remaining < 7) {
48
- for (let d = 1; d <= remaining; d++) {
49
- cells.push({ date: new Date(year, month + 1, d), current: false });
50
- }
51
- }
52
-
53
- return cells;
54
- }
55
-
56
- /* ───────── Types ───────── */
57
-
58
- export interface DateRange {
59
- /** 시작일 (포함). */
60
- from: Date;
61
- /** 종료일 (포함). */
62
- to: Date;
63
- }
19
+ const startOfMonth = (d: Date) =>
20
+ new Date(d.getFullYear(), d.getMonth(), 1);
64
21
 
65
22
  /* ───────── Icons ───────── */
66
23
 
67
- function ChevronLeftIcon() {
68
- return (
69
- <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
70
- <path d="M10 3 5.5 8 10 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
71
- </svg>
72
- );
73
- }
74
-
75
- function ChevronRightIcon() {
76
- return (
77
- <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
78
- <path d="M6 3 10.5 8 6 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
79
- </svg>
80
- );
81
- }
82
-
83
24
  function CalendarIcon() {
84
25
  return (
85
26
  <svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden>
@@ -118,178 +59,6 @@ function useDatePickerContext(component: string) {
118
59
  return ctx;
119
60
  }
120
61
 
121
- /* ───────── Internal Calendar Grid (shared w/ range picker) ───────── */
122
-
123
- interface CalendarProps {
124
- selected?: Date;
125
- onSelect: (date: Date) => void;
126
- min?: Date;
127
- max?: Date;
128
- focusedDate: Date;
129
- onFocusedDateChange: (date: Date) => void;
130
- rangeFrom?: Date;
131
- rangeTo?: Date;
132
- hoverDate?: Date;
133
- onHoverDate?: (date: Date | undefined) => void;
134
- }
135
-
136
- function Calendar({
137
- selected,
138
- onSelect,
139
- min,
140
- max,
141
- focusedDate,
142
- onFocusedDateChange,
143
- rangeFrom,
144
- rangeTo,
145
- hoverDate,
146
- onHoverDate,
147
- }: CalendarProps) {
148
- const year = focusedDate.getFullYear();
149
- const month = focusedDate.getMonth();
150
- const cells = getDaysGrid(year, month);
151
- const today = new Date();
152
-
153
- const navigate = (newYear: number, newMonth: number) => {
154
- const [y, m] = clampMonth(newYear, newMonth);
155
- onFocusedDateChange(new Date(y, m, 1));
156
- };
157
-
158
- const isDisabled = (date: Date) => {
159
- if (min && date < toDateOnly(min)) return true;
160
- if (max && date > toDateOnly(max)) return true;
161
- return false;
162
- };
163
-
164
- const handleKeyDown = (e: React.KeyboardEvent) => {
165
- let next: Date | null = null;
166
- const cursor = selected ?? focusedDate;
167
-
168
- switch (e.key) {
169
- case "ArrowLeft":
170
- next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() - 1);
171
- break;
172
- case "ArrowRight":
173
- next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1);
174
- break;
175
- case "ArrowUp":
176
- next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() - 7);
177
- break;
178
- case "ArrowDown":
179
- next = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 7);
180
- break;
181
- case "Enter":
182
- case " ":
183
- e.preventDefault();
184
- if (!isDisabled(cursor)) onSelect(cursor);
185
- return;
186
- default:
187
- return;
188
- }
189
-
190
- e.preventDefault();
191
- if (next && !isDisabled(next)) {
192
- if (next.getMonth() !== month || next.getFullYear() !== year) {
193
- onFocusedDateChange(new Date(next.getFullYear(), next.getMonth(), 1));
194
- }
195
- onSelect(next);
196
- }
197
- };
198
-
199
- const getRangeState = (date: Date) => {
200
- if (!rangeFrom) return { inRange: false, isStart: false, isEnd: false };
201
-
202
- const end = rangeTo ?? hoverDate;
203
- if (!end) return { inRange: false, isStart: isSameDay(date, rangeFrom), isEnd: false };
204
-
205
- let [rStart, rEnd] = rangeFrom <= end ? [rangeFrom, end] : [end, rangeFrom];
206
- rStart = toDateOnly(rStart);
207
- rEnd = toDateOnly(rEnd);
208
- const d = toDateOnly(date);
209
-
210
- return {
211
- inRange: d >= rStart && d <= rEnd,
212
- isStart: isSameDay(d, rStart),
213
- isEnd: isSameDay(d, rEnd),
214
- };
215
- };
216
-
217
- const monthLabel = `${year}년 ${month + 1}월`;
218
-
219
- return (
220
- <div className="sh-ui-calendar" role="group" aria-label={monthLabel}>
221
- <div className="sh-ui-calendar__header">
222
- <button
223
- type="button"
224
- className="sh-ui-calendar__nav"
225
- onClick={() => navigate(year, month - 1)}
226
- aria-label="이전 달"
227
- >
228
- <ChevronLeftIcon />
229
- </button>
230
- <span className="sh-ui-calendar__title">{monthLabel}</span>
231
- <button
232
- type="button"
233
- className="sh-ui-calendar__nav"
234
- onClick={() => navigate(year, month + 1)}
235
- aria-label="다음 달"
236
- >
237
- <ChevronRightIcon />
238
- </button>
239
- </div>
240
-
241
- <div className="sh-ui-calendar__weekdays" role="row">
242
- {WEEK_LABELS.map((label) => (
243
- <span key={label} className="sh-ui-calendar__weekday" role="columnheader" aria-label={label}>
244
- {label}
245
- </span>
246
- ))}
247
- </div>
248
-
249
- <div
250
- className="sh-ui-calendar__grid"
251
- role="grid"
252
- tabIndex={0}
253
- onKeyDown={handleKeyDown}
254
- aria-label={monthLabel}
255
- >
256
- {cells.map(({ date, current }, i) => {
257
- const disabled = isDisabled(date);
258
- const isSelected = selected && isSameDay(date, selected);
259
- const isToday = isSameDay(date, today);
260
- const { inRange, isStart, isEnd } = getRangeState(date);
261
-
262
- return (
263
- <button
264
- key={i}
265
- type="button"
266
- className={cx(
267
- "sh-ui-calendar__day",
268
- !current && "sh-ui-calendar__day--outside",
269
- isSelected && "sh-ui-calendar__day--selected",
270
- isToday && "sh-ui-calendar__day--today",
271
- inRange && "sh-ui-calendar__day--in-range",
272
- isStart && "sh-ui-calendar__day--range-start",
273
- isEnd && "sh-ui-calendar__day--range-end",
274
- )}
275
- disabled={disabled}
276
- tabIndex={-1}
277
- onClick={() => { if (!disabled) onSelect(date); }}
278
- onMouseEnter={() => onHoverDate?.(date)}
279
- onMouseLeave={() => onHoverDate?.(undefined)}
280
- aria-label={formatDefault(date)}
281
- aria-selected={isSelected || inRange || undefined}
282
- data-today={isToday || undefined}
283
- >
284
- {date.getDate()}
285
- </button>
286
- );
287
- })}
288
- </div>
289
- </div>
290
- );
291
- }
292
-
293
62
  /* ───────── DatePicker Root ───────── */
294
63
 
295
64
  export interface DatePickerProps {
@@ -372,7 +141,7 @@ export function DatePicker({
372
141
 
373
142
  React.useEffect(() => {
374
143
  if (open && selected) {
375
- setFocusedDate(new Date(selected.getFullYear(), selected.getMonth(), 1));
144
+ setFocusedDate(startOfMonth(selected));
376
145
  }
377
146
  }, [open, selected]);
378
147
 
@@ -574,19 +343,20 @@ export const DatePickerContent = React.forwardRef<HTMLDivElement, DatePickerCont
574
343
  export function DatePickerCalendar() {
575
344
  const ctx = useDatePickerContext("DatePickerCalendar");
576
345
 
577
- const handleSelect = (date: Date) => {
346
+ const handleSelect = (date: Date | undefined) => {
578
347
  ctx.setSelected(date);
579
- if (ctx.closeOnSelect) ctx.setOpen(false);
348
+ if (date && ctx.closeOnSelect) ctx.setOpen(false);
580
349
  };
581
350
 
582
351
  return (
583
352
  <Calendar
584
- selected={ctx.selected}
585
- onSelect={handleSelect}
353
+ mode="single"
354
+ value={ctx.selected}
355
+ onValueChange={handleSelect}
356
+ month={ctx.focusedDate}
357
+ onMonthChange={ctx.setFocusedDate}
586
358
  min={ctx.min}
587
359
  max={ctx.max}
588
- focusedDate={ctx.focusedDate}
589
- onFocusedDateChange={ctx.setFocusedDate}
590
360
  />
591
361
  );
592
362
  }
@@ -684,36 +454,20 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
684
454
  const selected = isControlled ? value : internal;
685
455
 
686
456
  const [open, setOpen] = React.useState(false);
687
- const [picking, setPicking] = React.useState<Date | undefined>(undefined);
688
- const [hoverDate, setHoverDate] = React.useState<Date | undefined>(undefined);
689
- const [focusedDate, setFocusedDate] = React.useState(
457
+ const [calendarMonth, setCalendarMonth] = React.useState<Date>(
690
458
  () => selected?.from ?? new Date(),
691
459
  );
692
460
 
693
461
  React.useEffect(() => {
694
- if (open) {
695
- setPicking(undefined);
696
- setHoverDate(undefined);
697
- if (selected?.from) {
698
- setFocusedDate(new Date(selected.from.getFullYear(), selected.from.getMonth(), 1));
699
- }
462
+ if (open && selected?.from) {
463
+ setCalendarMonth(startOfMonth(selected.from));
700
464
  }
701
465
  }, [open, selected?.from]);
702
466
 
703
- const handleSelect = (date: Date) => {
704
- if (!picking) {
705
- setPicking(date);
706
- return;
707
- }
708
-
709
- const [from, to] = picking <= date ? [picking, date] : [date, picking];
710
- const range: DateRange = { from, to };
711
-
467
+ const handleRangeChange = (range: DateRange | undefined) => {
712
468
  if (!isControlled) setInternal(range);
713
469
  onValueChange?.(range);
714
- setPicking(undefined);
715
- setHoverDate(undefined);
716
- setOpen(false);
470
+ if (range) setOpen(false);
717
471
  };
718
472
 
719
473
  const displayText = selected
@@ -749,20 +503,14 @@ export const DateRangePicker = React.forwardRef<HTMLButtonElement, DateRangePick
749
503
  align="start"
750
504
  >
751
505
  <BasePopover.Popup className="sh-ui-date-picker__popup">
752
- {picking && (
753
- <p className="sh-ui-date-picker__hint">종료일을 선택하세요</p>
754
- )}
755
506
  <Calendar
756
- selected={picking}
757
- onSelect={handleSelect}
507
+ mode="range"
508
+ value={selected}
509
+ onValueChange={handleRangeChange}
510
+ month={calendarMonth}
511
+ onMonthChange={setCalendarMonth}
758
512
  min={min}
759
513
  max={max}
760
- focusedDate={focusedDate}
761
- onFocusedDateChange={setFocusedDate}
762
- rangeFrom={picking ?? selected?.from}
763
- rangeTo={picking ? undefined : selected?.to}
764
- hoverDate={picking ? hoverDate : undefined}
765
- onHoverDate={picking ? setHoverDate : undefined}
766
514
  />
767
515
  </BasePopover.Popup>
768
516
  </BasePopover.Positioner>