sh-ui-cli 0.46.0 → 0.48.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.
Files changed (85) hide show
  1. package/data/changelog/versions.json +25 -0
  2. package/data/registry/react/components/accordion/index.module.tsx +97 -0
  3. package/data/registry/react/components/accordion/styles.module.css +111 -0
  4. package/data/registry/react/components/avatar/index.module.tsx +73 -0
  5. package/data/registry/react/components/avatar/styles.module.css +36 -0
  6. package/data/registry/react/components/badge/index.module.tsx +40 -0
  7. package/data/registry/react/components/badge/styles.module.css +57 -0
  8. package/data/registry/react/components/breadcrumb/index.module.tsx +152 -0
  9. package/data/registry/react/components/breadcrumb/styles.module.css +82 -0
  10. package/data/registry/react/components/calendar/index.module.tsx +806 -0
  11. package/data/registry/react/components/calendar/styles.module.css +213 -0
  12. package/data/registry/react/components/carousel/index.module.tsx +430 -0
  13. package/data/registry/react/components/carousel/styles.module.css +155 -0
  14. package/data/registry/react/components/checkbox/index.module.tsx +96 -0
  15. package/data/registry/react/components/checkbox/styles.module.css +75 -0
  16. package/data/registry/react/components/code-editor/index.module.tsx +230 -0
  17. package/data/registry/react/components/code-editor/styles.module.css +76 -0
  18. package/data/registry/react/components/code-panel/index.module.tsx +191 -0
  19. package/data/registry/react/components/code-panel/styles.module.css +124 -0
  20. package/data/registry/react/components/color-picker/index.module.tsx +467 -0
  21. package/data/registry/react/components/color-picker/styles.module.css +166 -0
  22. package/data/registry/react/components/combobox/index.module.tsx +165 -0
  23. package/data/registry/react/components/combobox/styles.module.css +151 -0
  24. package/data/registry/react/components/context-menu/index.module.tsx +251 -0
  25. package/data/registry/react/components/context-menu/styles.module.css +140 -0
  26. package/data/registry/react/components/date-picker/index.module.tsx +520 -0
  27. package/data/registry/react/components/date-picker/styles.module.css +103 -0
  28. package/data/registry/react/components/dialog/index.module.tsx +95 -0
  29. package/data/registry/react/components/dialog/styles.module.css +127 -0
  30. package/data/registry/react/components/dropdown-menu/index.module.tsx +255 -0
  31. package/data/registry/react/components/dropdown-menu/styles.module.css +150 -0
  32. package/data/registry/react/components/file-upload/index.module.tsx +487 -0
  33. package/data/registry/react/components/file-upload/styles.module.css +170 -0
  34. package/data/registry/react/components/form/index.module.tsx +61 -0
  35. package/data/registry/react/components/form/styles.module.css +47 -0
  36. package/data/registry/react/components/header/index.module.tsx +805 -0
  37. package/data/registry/react/components/header/styles.module.css +350 -0
  38. package/data/registry/react/components/label/index.module.tsx +52 -0
  39. package/data/registry/react/components/label/styles.module.css +90 -0
  40. package/data/registry/react/components/markdown-editor/index.module.tsx +119 -0
  41. package/data/registry/react/components/markdown-editor/styles.module.css +160 -0
  42. package/data/registry/react/components/menubar/index.module.tsx +32 -0
  43. package/data/registry/react/components/menubar/styles.module.css +45 -0
  44. package/data/registry/react/components/numeric-input/index.module.tsx +148 -0
  45. package/data/registry/react/components/numeric-input/styles.module.css +56 -0
  46. package/data/registry/react/components/page-toc/index.module.tsx +174 -0
  47. package/data/registry/react/components/page-toc/styles.module.css +82 -0
  48. package/data/registry/react/components/pagination/index.module.tsx +269 -0
  49. package/data/registry/react/components/pagination/styles.module.css +105 -0
  50. package/data/registry/react/components/popover/index.module.tsx +113 -0
  51. package/data/registry/react/components/popover/styles.module.css +65 -0
  52. package/data/registry/react/components/progress/index.module.tsx +54 -0
  53. package/data/registry/react/components/progress/styles.module.css +41 -0
  54. package/data/registry/react/components/radio/index.module.tsx +65 -0
  55. package/data/registry/react/components/radio/styles.module.css +80 -0
  56. package/data/registry/react/components/rich-text-editor/index.module.tsx +348 -0
  57. package/data/registry/react/components/rich-text-editor/styles.module.css +196 -0
  58. package/data/registry/react/components/select/index.module.tsx +234 -0
  59. package/data/registry/react/components/select/styles.module.css +193 -0
  60. package/data/registry/react/components/separator/index.module.tsx +46 -0
  61. package/data/registry/react/components/separator/styles.module.css +15 -0
  62. package/data/registry/react/components/sidebar/index.module.tsx +1067 -0
  63. package/data/registry/react/components/sidebar/styles.module.css +502 -0
  64. package/data/registry/react/components/skeleton/index.module.tsx +22 -0
  65. package/data/registry/react/components/skeleton/styles.module.css +24 -0
  66. package/data/registry/react/components/slider/index.module.tsx +298 -0
  67. package/data/registry/react/components/slider/styles.module.css +64 -0
  68. package/data/registry/react/components/spinner/index.module.tsx +38 -0
  69. package/data/registry/react/components/spinner/styles.module.css +37 -0
  70. package/data/registry/react/components/switch/index.module.tsx +39 -0
  71. package/data/registry/react/components/switch/styles.module.css +83 -0
  72. package/data/registry/react/components/tabs/index.module.tsx +91 -0
  73. package/data/registry/react/components/tabs/styles.module.css +148 -0
  74. package/data/registry/react/components/textarea/index.module.tsx +23 -0
  75. package/data/registry/react/components/textarea/styles.module.css +54 -0
  76. package/data/registry/react/components/toast/index.module.tsx +258 -0
  77. package/data/registry/react/components/toast/styles.module.css +290 -0
  78. package/data/registry/react/components/toggle/index.module.tsx +131 -0
  79. package/data/registry/react/components/toggle/styles.module.css +85 -0
  80. package/data/registry/react/components/tooltip/index.module.tsx +83 -0
  81. package/data/registry/react/components/tooltip/styles.module.css +44 -0
  82. package/data/registry/react/registry.json +560 -0
  83. package/package.json +1 -1
  84. package/src/api.d.ts +4 -3
  85. package/src/constants.js +4 -3
@@ -0,0 +1,213 @@
1
+ /* ── Calendar root ── */
2
+
3
+ .calendar {
4
+ display: inline-flex;
5
+ gap: var(--space-4);
6
+ user-select: none;
7
+ }
8
+
9
+ .calendar--multi {
10
+ flex-wrap: wrap;
11
+ }
12
+
13
+ .calendar__month {
14
+ width: 17.5rem;
15
+ }
16
+
17
+ /* ── Header (compound) ── */
18
+
19
+ .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
+ .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
+ .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
+ .calendar__nav:hover:not(:disabled) {
54
+ background: var(--background-muted);
55
+ color: var(--foreground);
56
+ }
57
+
58
+ .calendar__nav:focus-visible {
59
+ outline: var(--border-width-strong) solid var(--foreground);
60
+ outline-offset: 2px;
61
+ }
62
+
63
+ .calendar__nav--placeholder {
64
+ visibility: hidden;
65
+ pointer-events: none;
66
+ }
67
+
68
+ /* ── Select (year / month dropdown) ── */
69
+ /* sh-ui Select 의 trigger 를 캘린더 헤더용으로 컴팩트하게 오버라이드 */
70
+
71
+ .calendar__select-trigger.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
+ .calendar__select-trigger.select__trigger:hover:not(:disabled) {
84
+ background: var(--background-muted);
85
+ border-color: transparent;
86
+ }
87
+
88
+ .calendar__select-trigger.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
+ .select__positioner:has(.calendar__select-popup) {
96
+ z-index: var(--z-popover);
97
+ }
98
+
99
+ /* ── Weekdays ── */
100
+
101
+ .calendar__weekdays {
102
+ display: grid;
103
+ grid-template-columns: repeat(7, 1fr);
104
+ margin-bottom: var(--space-1);
105
+ }
106
+
107
+ .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
+ .calendar__grid {
120
+ display: grid;
121
+ grid-template-columns: repeat(7, 1fr);
122
+ outline: none;
123
+ }
124
+
125
+ .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
+ /* ── Cell (column slot, range strip carrier) ── */
132
+
133
+ .calendar__cell {
134
+ display: flex;
135
+ align-items: center;
136
+ justify-content: center;
137
+ width: 100%;
138
+ height: 2.375rem;
139
+ min-width: 0;
140
+ }
141
+
142
+ .calendar__cell--in-range,
143
+ .calendar__cell--range-start,
144
+ .calendar__cell--range-end {
145
+ background: color-mix(in srgb, var(--primary) 12%, transparent);
146
+ }
147
+
148
+ .calendar__cell--range-start:not(.calendar__cell--range-end) {
149
+ border-radius: calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px);
150
+ }
151
+
152
+ .calendar__cell--range-end:not(.calendar__cell--range-start) {
153
+ border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
154
+ }
155
+
156
+ .calendar__cell--range-start.calendar__cell--range-end {
157
+ border-radius: calc(var(--radius) - 2px);
158
+ }
159
+
160
+ /* ── Day button ── */
161
+
162
+ .calendar__day {
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: center;
166
+ width: 2.25rem;
167
+ height: 2.25rem;
168
+ padding: 0;
169
+ border: none;
170
+ border-radius: calc(var(--radius) - 2px);
171
+ background: transparent;
172
+ color: var(--foreground);
173
+ font-size: 0.8125rem;
174
+ font-family: inherit;
175
+ cursor: pointer;
176
+ transition: background-color var(--duration-fast), color var(--duration-fast);
177
+ }
178
+
179
+ .calendar__day:hover:not(:disabled) {
180
+ background: var(--background-muted);
181
+ }
182
+
183
+ .calendar__day:focus-visible {
184
+ outline: var(--border-width-strong) solid var(--foreground);
185
+ outline-offset: 2px;
186
+ }
187
+
188
+ .calendar__day--outside {
189
+ color: var(--foreground-subtle, var(--foreground-muted));
190
+ opacity: 0.4;
191
+ }
192
+
193
+ .calendar__day--today {
194
+ font-weight: var(--weight-bold);
195
+ text-decoration: underline;
196
+ text-underline-offset: 0.125rem;
197
+ }
198
+
199
+ .calendar__day--selected {
200
+ background: var(--primary);
201
+ color: var(--primary-foreground);
202
+ font-weight: var(--weight-semibold);
203
+ }
204
+
205
+ .calendar__day--selected:hover:not(:disabled) {
206
+ background: var(--primary-hover);
207
+ color: var(--primary-foreground);
208
+ }
209
+
210
+ .calendar__day:disabled {
211
+ opacity: 0.3;
212
+ cursor: not-allowed;
213
+ }
@@ -0,0 +1,430 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import styles from "./styles.module.css";
5
+
6
+
7
+ import { cn } from "@SH_UI_UTILS@";
8
+ type Orientation = "horizontal" | "vertical";
9
+
10
+ interface CarouselContextValue {
11
+ orientation: Orientation;
12
+ loop: boolean;
13
+ index: number;
14
+ count: number;
15
+ goTo: (i: number) => void;
16
+ goPrev: () => void;
17
+ goNext: () => void;
18
+ registerItem: () => () => void;
19
+ setContentEl: (el: HTMLDivElement | null) => void;
20
+ contentRef: React.RefObject<HTMLDivElement | null>;
21
+ }
22
+
23
+ const CarouselContext = React.createContext<CarouselContextValue | null>(null);
24
+
25
+ function useCarousel() {
26
+ const ctx = React.useContext(CarouselContext);
27
+ if (!ctx) {
28
+ throw new Error("Carousel parts must be used within <Carousel>");
29
+ }
30
+ return ctx;
31
+ }
32
+
33
+ /** 현재 캐러셀 상태를 읽을 수 있는 hook. 커스텀 컨트롤을 만들 때 사용. */
34
+ export function useCarouselState(): Pick<
35
+ CarouselContextValue,
36
+ "orientation" | "loop" | "index" | "count" | "goTo" | "goPrev" | "goNext"
37
+ > {
38
+ const { orientation, loop, index, count, goTo, goPrev, goNext } =
39
+ useCarousel();
40
+ return { orientation, loop, index, count, goTo, goPrev, goNext };
41
+ }
42
+
43
+ export interface CarouselProps extends React.HTMLAttributes<HTMLDivElement> {
44
+ /** 스크롤 방향. 기본 `horizontal`. */
45
+ orientation?: Orientation;
46
+ /** 끝에서 다시 처음으로 이어지는 무한 루프. */
47
+ loop?: boolean;
48
+ /** 자동 재생 간격(ms). `true`면 4000ms. */
49
+ autoPlay?: boolean | number;
50
+ /** 초기 인덱스. */
51
+ defaultIndex?: number;
52
+ /** 제어 모드 인덱스. */
53
+ index?: number;
54
+ /** 인덱스 변경 콜백. */
55
+ onIndexChange?: (index: number) => void;
56
+ }
57
+
58
+ /**
59
+ * 순수 CSS scroll-snap 기반 캐러셀. 터치 드래그·키보드 화살표·자동 재생을
60
+ * 지원하며, prefers-reduced-motion 환경에서는 스냅 전환이 즉시 이동한다.
61
+ *
62
+ * 구성 요소: Carousel / CarouselContent / CarouselItem /
63
+ * CarouselPrevious / CarouselNext / CarouselIndicators.
64
+ */
65
+ export const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
66
+ (
67
+ {
68
+ className,
69
+ orientation = "horizontal",
70
+ loop = false,
71
+ autoPlay,
72
+ defaultIndex = 0,
73
+ index: controlledIndex,
74
+ onIndexChange,
75
+ children,
76
+ onKeyDown: userOnKeyDown,
77
+ ...props
78
+ },
79
+ ref,
80
+ ) => {
81
+ const isControlled = controlledIndex !== undefined;
82
+ const [uncontrolled, setUncontrolled] = React.useState(defaultIndex);
83
+ const index = isControlled ? controlledIndex : uncontrolled;
84
+
85
+ const [count, setCount] = React.useState(0);
86
+ const contentRef = React.useRef<HTMLDivElement | null>(null);
87
+ const scrollTargetRef = React.useRef<number | null>(null);
88
+
89
+ const setContentEl = React.useCallback((el: HTMLDivElement | null) => {
90
+ contentRef.current = el;
91
+ }, []);
92
+
93
+ const registerItem = React.useCallback(() => {
94
+ setCount((c) => c + 1);
95
+ return () => setCount((c) => Math.max(0, c - 1));
96
+ }, []);
97
+
98
+ const setIndex = React.useCallback(
99
+ (next: number) => {
100
+ if (!isControlled) setUncontrolled(next);
101
+ onIndexChange?.(next);
102
+ },
103
+ [isControlled, onIndexChange],
104
+ );
105
+
106
+ const clamp = React.useCallback(
107
+ (i: number) => {
108
+ if (count <= 0) return 0;
109
+ if (loop) {
110
+ return ((i % count) + count) % count;
111
+ }
112
+ return Math.max(0, Math.min(count - 1, i));
113
+ },
114
+ [count, loop],
115
+ );
116
+
117
+ const goTo = React.useCallback(
118
+ (i: number) => setIndex(clamp(i)),
119
+ [clamp, setIndex],
120
+ );
121
+ const goPrev = React.useCallback(
122
+ () => setIndex(clamp(index - 1)),
123
+ [clamp, index, setIndex],
124
+ );
125
+ const goNext = React.useCallback(
126
+ () => setIndex(clamp(index + 1)),
127
+ [clamp, index, setIndex],
128
+ );
129
+
130
+ // 인덱스 변경 시 프로그래매틱 스크롤
131
+ React.useEffect(() => {
132
+ const el = contentRef.current;
133
+ if (!el) return;
134
+ const child = el.children[index] as HTMLElement | undefined;
135
+ if (!child) return;
136
+ scrollTargetRef.current = index;
137
+ if (orientation === "horizontal") {
138
+ el.scrollTo({ left: child.offsetLeft, behavior: "smooth" });
139
+ } else {
140
+ el.scrollTo({ top: child.offsetTop, behavior: "smooth" });
141
+ }
142
+ }, [index, orientation, count]);
143
+
144
+ // 사용자 스와이프/스크롤 → 인덱스 동기화
145
+ React.useEffect(() => {
146
+ const el = contentRef.current;
147
+ if (!el) return;
148
+ let raf = 0;
149
+ const onScroll = () => {
150
+ cancelAnimationFrame(raf);
151
+ raf = requestAnimationFrame(() => {
152
+ const children = Array.from(el.children) as HTMLElement[];
153
+ if (children.length === 0) return;
154
+ const axis = orientation === "horizontal" ? "offsetLeft" : "offsetTop";
155
+ const scroll =
156
+ orientation === "horizontal" ? el.scrollLeft : el.scrollTop;
157
+ let nearest = 0;
158
+ let minDelta = Infinity;
159
+ children.forEach((c, i) => {
160
+ const d = Math.abs(c[axis] - scroll);
161
+ if (d < minDelta) {
162
+ minDelta = d;
163
+ nearest = i;
164
+ }
165
+ });
166
+ if (nearest !== index) {
167
+ // 프로그래매틱 이동 중이면 target 도착까지 무시
168
+ if (
169
+ scrollTargetRef.current !== null &&
170
+ nearest !== scrollTargetRef.current
171
+ ) {
172
+ return;
173
+ }
174
+ scrollTargetRef.current = null;
175
+ setIndex(nearest);
176
+ } else {
177
+ scrollTargetRef.current = null;
178
+ }
179
+ });
180
+ };
181
+ el.addEventListener("scroll", onScroll, { passive: true });
182
+ return () => {
183
+ cancelAnimationFrame(raf);
184
+ el.removeEventListener("scroll", onScroll);
185
+ };
186
+ }, [orientation, index, setIndex]);
187
+
188
+ // 자동 재생 — 호버·키보드 포커스 시 일시정지(WCAG 2.2.2).
189
+ React.useEffect(() => {
190
+ if (!autoPlay || count <= 1) return;
191
+ const delay = autoPlay === true ? 4000 : autoPlay;
192
+ const el = contentRef.current;
193
+ let paused = false;
194
+ const pause = () => (paused = true);
195
+ const resume = () => (paused = false);
196
+ el?.addEventListener("mouseenter", pause);
197
+ el?.addEventListener("mouseleave", resume);
198
+ el?.addEventListener("focusin", pause);
199
+ el?.addEventListener("focusout", resume);
200
+ const id = window.setInterval(() => {
201
+ if (paused) return;
202
+ setIndex(clamp(index + 1));
203
+ }, delay);
204
+ return () => {
205
+ window.clearInterval(id);
206
+ el?.removeEventListener("mouseenter", pause);
207
+ el?.removeEventListener("mouseleave", resume);
208
+ el?.removeEventListener("focusin", pause);
209
+ el?.removeEventListener("focusout", resume);
210
+ };
211
+ }, [autoPlay, count, index, clamp, setIndex]);
212
+
213
+ const onKeyDown = React.useCallback(
214
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
215
+ userOnKeyDown?.(event);
216
+ if (event.defaultPrevented) return;
217
+ const prevKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
218
+ const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
219
+ if (event.key === prevKey) {
220
+ event.preventDefault();
221
+ goPrev();
222
+ } else if (event.key === nextKey) {
223
+ event.preventDefault();
224
+ goNext();
225
+ }
226
+ },
227
+ [orientation, goPrev, goNext, userOnKeyDown],
228
+ );
229
+
230
+ const value = React.useMemo<CarouselContextValue>(
231
+ () => ({
232
+ orientation,
233
+ loop,
234
+ index,
235
+ count,
236
+ goTo,
237
+ goPrev,
238
+ goNext,
239
+ registerItem,
240
+ setContentEl,
241
+ contentRef,
242
+ }),
243
+ [
244
+ orientation,
245
+ loop,
246
+ index,
247
+ count,
248
+ goTo,
249
+ goPrev,
250
+ goNext,
251
+ registerItem,
252
+ setContentEl,
253
+ ],
254
+ );
255
+
256
+ return (
257
+ <CarouselContext.Provider value={value}>
258
+ <div
259
+ ref={ref}
260
+ className={cn(styles.carousel, className)}
261
+ data-orientation={orientation}
262
+ role="region"
263
+ aria-roledescription="carousel"
264
+ onKeyDown={onKeyDown}
265
+ {...props}
266
+ >
267
+ {children}
268
+ </div>
269
+ </CarouselContext.Provider>
270
+ );
271
+ },
272
+ );
273
+ Carousel.displayName = "Carousel";
274
+
275
+ export const CarouselContent = React.forwardRef<
276
+ HTMLDivElement,
277
+ React.HTMLAttributes<HTMLDivElement>
278
+ >(({ className, ...props }, ref) => {
279
+ const { orientation, setContentEl } = useCarousel();
280
+
281
+ const mergedRef = React.useCallback(
282
+ (el: HTMLDivElement | null) => {
283
+ setContentEl(el);
284
+ if (typeof ref === "function") ref(el);
285
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
286
+ },
287
+ [ref, setContentEl],
288
+ );
289
+
290
+ return (
291
+ <div
292
+ ref={mergedRef}
293
+ className={cn(styles.carousel__content, className)}
294
+ data-orientation={orientation}
295
+ {...props}
296
+ />
297
+ );
298
+ });
299
+ CarouselContent.displayName = "CarouselContent";
300
+
301
+ export const CarouselItem = React.forwardRef<
302
+ HTMLDivElement,
303
+ React.HTMLAttributes<HTMLDivElement>
304
+ >(({ className, ...props }, ref) => {
305
+ const { orientation, registerItem } = useCarousel();
306
+
307
+ React.useEffect(() => registerItem(), [registerItem]);
308
+
309
+ return (
310
+ <div
311
+ ref={ref}
312
+ role="group"
313
+ aria-roledescription="slide"
314
+ className={cn(styles.carousel__item, className)}
315
+ data-orientation={orientation}
316
+ {...props}
317
+ />
318
+ );
319
+ });
320
+ CarouselItem.displayName = "CarouselItem";
321
+
322
+ export const CarouselPrevious = React.forwardRef<
323
+ HTMLButtonElement,
324
+ React.ButtonHTMLAttributes<HTMLButtonElement>
325
+ >(({ className, onClick, disabled, children, ...props }, ref) => {
326
+ const { goPrev, orientation, index, count, loop } = useCarousel();
327
+ const atStart = !loop && index <= 0;
328
+ return (
329
+ <button
330
+ ref={ref}
331
+ type="button"
332
+ aria-label="이전"
333
+ className={cn(styles.carousel__nav, styles["carousel__nav--prev"], className)}
334
+ data-orientation={orientation}
335
+ disabled={disabled || atStart || count === 0}
336
+ onClick={(e) => {
337
+ onClick?.(e);
338
+ if (!e.defaultPrevented) goPrev();
339
+ }}
340
+ {...props}
341
+ >
342
+ {children ?? (
343
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
344
+ <path
345
+ d={orientation === "horizontal" ? "M10 4l-4 4 4 4" : "M4 10l4-4 4 4"}
346
+ stroke="currentColor"
347
+ strokeWidth="1.5"
348
+ strokeLinecap="round"
349
+ strokeLinejoin="round"
350
+ />
351
+ </svg>
352
+ )}
353
+ </button>
354
+ );
355
+ });
356
+ CarouselPrevious.displayName = "CarouselPrevious";
357
+
358
+ export const CarouselNext = React.forwardRef<
359
+ HTMLButtonElement,
360
+ React.ButtonHTMLAttributes<HTMLButtonElement>
361
+ >(({ className, onClick, disabled, children, ...props }, ref) => {
362
+ const { goNext, orientation, index, count, loop } = useCarousel();
363
+ const atEnd = !loop && index >= count - 1;
364
+ return (
365
+ <button
366
+ ref={ref}
367
+ type="button"
368
+ aria-label="다음"
369
+ className={cn(styles.carousel__nav, styles["carousel__nav--next"], className)}
370
+ data-orientation={orientation}
371
+ disabled={disabled || atEnd || count === 0}
372
+ onClick={(e) => {
373
+ onClick?.(e);
374
+ if (!e.defaultPrevented) goNext();
375
+ }}
376
+ {...props}
377
+ >
378
+ {children ?? (
379
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
380
+ <path
381
+ d={orientation === "horizontal" ? "M6 4l4 4-4 4" : "M4 6l4 4 4-4"}
382
+ stroke="currentColor"
383
+ strokeWidth="1.5"
384
+ strokeLinecap="round"
385
+ strokeLinejoin="round"
386
+ />
387
+ </svg>
388
+ )}
389
+ </button>
390
+ );
391
+ });
392
+ CarouselNext.displayName = "CarouselNext";
393
+
394
+ export interface CarouselIndicatorsProps
395
+ extends React.HTMLAttributes<HTMLDivElement> {
396
+ /** 각 도트 버튼에 전달할 aria-label 빌더. */
397
+ labelFor?: (index: number) => string;
398
+ }
399
+
400
+ export const CarouselIndicators = React.forwardRef<
401
+ HTMLDivElement,
402
+ CarouselIndicatorsProps
403
+ >(({ className, labelFor, ...props }, ref) => {
404
+ const { count, index, goTo, orientation } = useCarousel();
405
+ if (count <= 0) return null;
406
+ return (
407
+ <div
408
+ ref={ref}
409
+ role="tablist"
410
+ aria-label="슬라이드 선택"
411
+ className={cn(styles.carousel__indicators, className)}
412
+ data-orientation={orientation}
413
+ {...props}
414
+ >
415
+ {Array.from({ length: count }).map((_, i) => (
416
+ <button
417
+ key={i}
418
+ type="button"
419
+ role="tab"
420
+ aria-selected={i === index}
421
+ aria-label={labelFor ? labelFor(i) : `${i + 1}번 슬라이드`}
422
+ className={styles.carousel__indicator}
423
+ data-active={i === index || undefined}
424
+ onClick={() => goTo(i)}
425
+ />
426
+ ))}
427
+ </div>
428
+ );
429
+ });
430
+ CarouselIndicators.displayName = "CarouselIndicators";