sh-ui-cli 0.49.0 → 0.50.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 +14 -0
  2. package/data/registry/react/components/accordion/index.vanilla-extract.tsx +97 -0
  3. package/data/registry/react/components/accordion/styles.css.ts +131 -0
  4. package/data/registry/react/components/avatar/index.vanilla-extract.tsx +73 -0
  5. package/data/registry/react/components/avatar/styles.css.ts +68 -0
  6. package/data/registry/react/components/badge/index.vanilla-extract.tsx +40 -0
  7. package/data/registry/react/components/badge/styles.css.ts +71 -0
  8. package/data/registry/react/components/breadcrumb/index.vanilla-extract.tsx +152 -0
  9. package/data/registry/react/components/breadcrumb/styles.css.ts +95 -0
  10. package/data/registry/react/components/calendar/index.vanilla-extract.tsx +806 -0
  11. package/data/registry/react/components/calendar/styles.css.ts +250 -0
  12. package/data/registry/react/components/carousel/index.vanilla-extract.tsx +430 -0
  13. package/data/registry/react/components/carousel/styles.css.ts +169 -0
  14. package/data/registry/react/components/checkbox/index.vanilla-extract.tsx +96 -0
  15. package/data/registry/react/components/checkbox/styles.css.ts +74 -0
  16. package/data/registry/react/components/code-editor/index.vanilla-extract.tsx +230 -0
  17. package/data/registry/react/components/code-editor/styles.css.ts +97 -0
  18. package/data/registry/react/components/code-panel/index.vanilla-extract.tsx +191 -0
  19. package/data/registry/react/components/code-panel/styles.css.ts +151 -0
  20. package/data/registry/react/components/color-picker/index.vanilla-extract.tsx +467 -0
  21. package/data/registry/react/components/color-picker/styles.css.ts +169 -0
  22. package/data/registry/react/components/combobox/index.vanilla-extract.tsx +165 -0
  23. package/data/registry/react/components/combobox/styles.css.ts +174 -0
  24. package/data/registry/react/components/context-menu/index.vanilla-extract.tsx +251 -0
  25. package/data/registry/react/components/context-menu/styles.css.ts +167 -0
  26. package/data/registry/react/components/date-picker/index.vanilla-extract.tsx +520 -0
  27. package/data/registry/react/components/date-picker/styles.css.ts +111 -0
  28. package/data/registry/react/components/dialog/index.vanilla-extract.tsx +95 -0
  29. package/data/registry/react/components/dialog/styles.css.ts +140 -0
  30. package/data/registry/react/components/dropdown-menu/index.vanilla-extract.tsx +255 -0
  31. package/data/registry/react/components/dropdown-menu/styles.css.ts +175 -0
  32. package/data/registry/react/components/file-upload/index.vanilla-extract.tsx +487 -0
  33. package/data/registry/react/components/file-upload/styles.css.ts +193 -0
  34. package/data/registry/react/components/form/index.vanilla-extract.tsx +61 -0
  35. package/data/registry/react/components/form/styles.css.ts +56 -0
  36. package/data/registry/react/components/header/index.vanilla-extract.tsx +805 -0
  37. package/data/registry/react/components/header/styles.css.ts +413 -0
  38. package/data/registry/react/components/label/index.vanilla-extract.tsx +52 -0
  39. package/data/registry/react/components/label/styles.css.ts +141 -0
  40. package/data/registry/react/components/markdown-editor/index.vanilla-extract.tsx +119 -0
  41. package/data/registry/react/components/markdown-editor/styles.css.ts +231 -0
  42. package/data/registry/react/components/menubar/index.vanilla-extract.tsx +32 -0
  43. package/data/registry/react/components/menubar/styles.css.ts +53 -0
  44. package/data/registry/react/components/numeric-input/index.vanilla-extract.tsx +148 -0
  45. package/data/registry/react/components/numeric-input/styles.css.ts +65 -0
  46. package/data/registry/react/components/page-toc/index.vanilla-extract.tsx +174 -0
  47. package/data/registry/react/components/page-toc/styles.css.ts +97 -0
  48. package/data/registry/react/components/pagination/index.vanilla-extract.tsx +269 -0
  49. package/data/registry/react/components/pagination/styles.css.ts +113 -0
  50. package/data/registry/react/components/popover/index.vanilla-extract.tsx +113 -0
  51. package/data/registry/react/components/popover/styles.css.ts +78 -0
  52. package/data/registry/react/components/progress/index.vanilla-extract.tsx +54 -0
  53. package/data/registry/react/components/progress/styles.css.ts +53 -0
  54. package/data/registry/react/components/radio/index.vanilla-extract.tsx +65 -0
  55. package/data/registry/react/components/radio/styles.css.ts +79 -0
  56. package/data/registry/react/components/rich-text-editor/index.vanilla-extract.tsx +348 -0
  57. package/data/registry/react/components/rich-text-editor/styles.css.ts +243 -0
  58. package/data/registry/react/components/select/index.vanilla-extract.tsx +234 -0
  59. package/data/registry/react/components/select/styles.css.ts +225 -0
  60. package/data/registry/react/components/separator/index.vanilla-extract.tsx +46 -0
  61. package/data/registry/react/components/separator/styles.css.ts +24 -0
  62. package/data/registry/react/components/sidebar/index.vanilla-extract.tsx +1067 -0
  63. package/data/registry/react/components/sidebar/styles.css.ts +578 -0
  64. package/data/registry/react/components/skeleton/index.vanilla-extract.tsx +22 -0
  65. package/data/registry/react/components/skeleton/styles.css.ts +30 -0
  66. package/data/registry/react/components/slider/index.vanilla-extract.tsx +298 -0
  67. package/data/registry/react/components/slider/styles.css.ts +75 -0
  68. package/data/registry/react/components/spinner/index.vanilla-extract.tsx +38 -0
  69. package/data/registry/react/components/spinner/styles.css.ts +60 -0
  70. package/data/registry/react/components/switch/index.vanilla-extract.tsx +39 -0
  71. package/data/registry/react/components/switch/styles.css.ts +87 -0
  72. package/data/registry/react/components/tabs/index.vanilla-extract.tsx +91 -0
  73. package/data/registry/react/components/tabs/styles.css.ts +145 -0
  74. package/data/registry/react/components/textarea/index.vanilla-extract.tsx +23 -0
  75. package/data/registry/react/components/textarea/styles.css.ts +55 -0
  76. package/data/registry/react/components/toast/index.vanilla-extract.tsx +258 -0
  77. package/data/registry/react/components/toast/styles.css.ts +307 -0
  78. package/data/registry/react/components/toggle/index.vanilla-extract.tsx +131 -0
  79. package/data/registry/react/components/toggle/styles.css.ts +109 -0
  80. package/data/registry/react/components/tooltip/index.vanilla-extract.tsx +83 -0
  81. package/data/registry/react/components/tooltip/styles.css.ts +59 -0
  82. package/data/registry/react/registry.json +853 -36
  83. package/package.json +1 -1
  84. package/src/api.d.ts +4 -3
  85. package/src/constants.js +4 -3
@@ -0,0 +1,250 @@
1
+ import { style } from "@vanilla-extract/css";
2
+
3
+ export const calendar = style({
4
+ display: "inline-flex",
5
+ gap: "var(--space-4)",
6
+ userSelect: "none",
7
+ });
8
+
9
+ export const calendarMulti = style({
10
+ flexWrap: "wrap",
11
+ });
12
+
13
+ export const calendar__month = style({
14
+ width: "17.5rem",
15
+ });
16
+
17
+ export const calendar__header = style({
18
+ display: "flex",
19
+ alignItems: "center",
20
+ justifyContent: "space-between",
21
+ gap: "var(--space-1)",
22
+ marginBottom: "var(--space-2)",
23
+ });
24
+
25
+ export const calendar__title = style({
26
+ display: "inline-flex",
27
+ alignItems: "center",
28
+ gap: "var(--space-1)",
29
+ flex: "1 1 auto",
30
+ justifyContent: "center",
31
+ });
32
+
33
+ export const calendar__nav = style({
34
+ display: "inline-flex",
35
+ alignItems: "center",
36
+ justifyContent: "center",
37
+ width: "1.75rem",
38
+ height: "1.75rem",
39
+ padding: 0,
40
+ border: "none",
41
+ borderRadius: "calc(var(--radius) - 2px)",
42
+ background: "transparent",
43
+ color: "var(--foreground-muted)",
44
+ cursor: "pointer",
45
+ flexShrink: 0,
46
+ transition: "background-color var(--duration-fast), color var(--duration-fast)",
47
+ selectors: {
48
+ "&:hover:not(:disabled)": {
49
+ background: "var(--background-muted)",
50
+ color: "var(--foreground)",
51
+ },
52
+ "&:focus-visible": {
53
+ outline: "var(--border-width-strong) solid var(--foreground)",
54
+ outlineOffset: "2px",
55
+ },
56
+ },
57
+ });
58
+
59
+ export const calendarNavPlaceholder = style({
60
+ visibility: "hidden",
61
+ pointerEvents: "none",
62
+ });
63
+
64
+ export const calendarSelectTrigger = style({
65
+ selectors: {
66
+ "&.select__trigger": {
67
+ minWidth: 0,
68
+ height: "1.75rem",
69
+ gap: "var(--space-1)",
70
+ padding: "0 var(--space-2)",
71
+ background: "transparent",
72
+ borderColor: "transparent",
73
+ fontWeight: "var(--weight-semibold)",
74
+ fontSize: "var(--text-sm)",
75
+ color: "var(--foreground)",
76
+ },
77
+ "&.select__trigger:hover:not(:disabled)": {
78
+ background: "var(--background-muted)",
79
+ borderColor: "transparent",
80
+ },
81
+ "&.select__trigger[data-popup-open]": {
82
+ background: "var(--background-muted)",
83
+ borderColor: "transparent",
84
+ },
85
+ },
86
+ });
87
+
88
+ export const select__positioner = style({
89
+ selectors: {
90
+ [`&:has(${calendarSelectPopup})`]: {
91
+ zIndex: "var(--z-popover)",
92
+ },
93
+ },
94
+ });
95
+
96
+ export const calendar__weekdays = style({
97
+ display: "grid",
98
+ gridTemplateColumns: "repeat(7, 1fr)",
99
+ marginBottom: "var(--space-1)",
100
+ });
101
+
102
+ export const calendar__weekday = style({
103
+ display: "flex",
104
+ alignItems: "center",
105
+ justifyContent: "center",
106
+ height: "2rem",
107
+ fontSize: "var(--text-xs)",
108
+ fontWeight: "var(--weight-medium)",
109
+ color: "var(--foreground-muted)",
110
+ });
111
+
112
+ export const calendar__grid = style({
113
+ display: "grid",
114
+ gridTemplateColumns: "repeat(7, 1fr)",
115
+ outline: "none",
116
+ selectors: {
117
+ "&:focus-visible": {
118
+ outline: "var(--border-width-strong) solid var(--foreground)",
119
+ outlineOffset: "2px",
120
+ borderRadius: "calc(var(--radius) - 2px)",
121
+ },
122
+ },
123
+ });
124
+
125
+ export const calendar__cell = style({
126
+ display: "flex",
127
+ alignItems: "center",
128
+ justifyContent: "center",
129
+ width: "100%",
130
+ height: "2.375rem",
131
+ minWidth: 0,
132
+ });
133
+
134
+ export const calendarCellInRange = style({
135
+ background: "color-mix(in srgb, var(--primary) 12%, transparent)",
136
+ });
137
+
138
+ export const calendarCellRangeStart = style({
139
+ background: "color-mix(in srgb, var(--primary) 12%, transparent)",
140
+ selectors: {
141
+ [`&:not(${calendarCellRangeEnd})`]: {
142
+ borderRadius: "calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px)",
143
+ },
144
+ [`&${calendarCellRangeEnd}`]: {
145
+ borderRadius: "calc(var(--radius) - 2px)",
146
+ },
147
+ },
148
+ });
149
+
150
+ export const calendarCellRangeEnd = style({
151
+ background: "color-mix(in srgb, var(--primary) 12%, transparent)",
152
+ selectors: {
153
+ [`&:not(${calendarCellRangeStart})`]: {
154
+ borderRadius: "0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0",
155
+ },
156
+ },
157
+ });
158
+
159
+ export const calendar__day = style({
160
+ display: "flex",
161
+ alignItems: "center",
162
+ justifyContent: "center",
163
+ width: "2.25rem",
164
+ height: "2.25rem",
165
+ padding: 0,
166
+ border: "none",
167
+ borderRadius: "calc(var(--radius) - 2px)",
168
+ background: "transparent",
169
+ color: "var(--foreground)",
170
+ fontSize: "0.8125rem",
171
+ fontFamily: "inherit",
172
+ cursor: "pointer",
173
+ transition: "background-color var(--duration-fast), color var(--duration-fast)",
174
+ selectors: {
175
+ "&:hover:not(:disabled)": {
176
+ background: "var(--background-muted)",
177
+ },
178
+ "&:focus-visible": {
179
+ outline: "var(--border-width-strong) solid var(--foreground)",
180
+ outlineOffset: "2px",
181
+ },
182
+ "&:disabled": {
183
+ opacity: 0.3,
184
+ cursor: "not-allowed",
185
+ },
186
+ },
187
+ });
188
+
189
+ export const calendarDayOutside = style({
190
+ color: "var(--foreground-subtle, var(--foreground-muted))",
191
+ opacity: 0.4,
192
+ });
193
+
194
+ export const calendarDayToday = style({
195
+ fontWeight: "var(--weight-bold)",
196
+ textDecoration: "underline",
197
+ textUnderlineOffset: "0.125rem",
198
+ });
199
+
200
+ export const calendarDaySelected = style({
201
+ background: "var(--primary)",
202
+ color: "var(--primary-foreground)",
203
+ fontWeight: "var(--weight-semibold)",
204
+ selectors: {
205
+ "&:hover:not(:disabled)": {
206
+ background: "var(--primary-hover)",
207
+ color: "var(--primary-foreground)",
208
+ },
209
+ },
210
+ });
211
+
212
+ export const calendarSelectValue = style({
213
+ });
214
+
215
+ export const calendarSelectPopup = style({
216
+ });
217
+
218
+ export const calendarGridWrap = style({
219
+ });
220
+
221
+ export const calendarCellHidden = style({
222
+ });
223
+
224
+ /** 동적 키로 클래스 참조용 — `byKey[\`badge--${variant}\`]` 같은 패턴 지원. */
225
+ export const byKey: Record<string, string> = {
226
+ "calendar": calendar,
227
+ "calendar--multi": calendarMulti,
228
+ "calendar__month": calendar__month,
229
+ "calendar__header": calendar__header,
230
+ "calendar__title": calendar__title,
231
+ "calendar__nav": calendar__nav,
232
+ "calendar__nav--placeholder": calendarNavPlaceholder,
233
+ "calendar__select-trigger": calendarSelectTrigger,
234
+ "select__positioner": select__positioner,
235
+ "calendar__weekdays": calendar__weekdays,
236
+ "calendar__weekday": calendar__weekday,
237
+ "calendar__grid": calendar__grid,
238
+ "calendar__cell": calendar__cell,
239
+ "calendar__cell--in-range": calendarCellInRange,
240
+ "calendar__cell--range-start": calendarCellRangeStart,
241
+ "calendar__cell--range-end": calendarCellRangeEnd,
242
+ "calendar__day": calendar__day,
243
+ "calendar__day--outside": calendarDayOutside,
244
+ "calendar__day--today": calendarDayToday,
245
+ "calendar__day--selected": calendarDaySelected,
246
+ "calendar__select-value": calendarSelectValue,
247
+ "calendar__select-popup": calendarSelectPopup,
248
+ "calendar__grid-wrap": calendarGridWrap,
249
+ "calendar__cell--hidden": calendarCellHidden,
250
+ };
@@ -0,0 +1,430 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { byKey, carousel, carousel__content, carousel__item, carousel__nav, carouselNavPrev, carouselNavNext, carousel__indicators, carousel__indicator } from "./styles.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(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(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(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(carousel__nav, carouselNavPrev, 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(carousel__nav, carouselNavNext, 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(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={carousel__indicator}
423
+ data-active={i === index || undefined}
424
+ onClick={() => goTo(i)}
425
+ />
426
+ ))}
427
+ </div>
428
+ );
429
+ });
430
+ CarouselIndicators.displayName = "CarouselIndicators";