@shadow-js/ui 0.0.0-stage → 1.0.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 (190) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/dist/cli.mjs +2293 -0
  5. package/dist/dev/cli/schema.d.ts +73 -0
  6. package/dist/dev/index.d.ts +6 -0
  7. package/dist/dev/index.js +5 -0
  8. package/dist/dev/locales/ar.d.ts +141 -0
  9. package/dist/dev/locales/ar.js +128 -0
  10. package/dist/dev/locales/ar.js.map +1 -0
  11. package/dist/dev/locales/en.d.ts +141 -0
  12. package/dist/dev/locales/en.js +128 -0
  13. package/dist/dev/locales/en.js.map +1 -0
  14. package/dist/dev/registry/utils/utils.d.ts +29 -0
  15. package/dist/dev/registry/utils/utils.js +27 -0
  16. package/dist/dev/registry/utils/utils.js.map +1 -0
  17. package/dist/dev/theme/theme-logic.d.ts +187 -0
  18. package/dist/dev/theme/theme-logic.js +184 -0
  19. package/dist/dev/theme/theme-logic.js.map +1 -0
  20. package/dist/dev/theme/theme-provider.d.ts +90 -0
  21. package/dist/dev/theme/theme-provider.js +150 -0
  22. package/dist/dev/theme/theme-provider.js.map +1 -0
  23. package/dist/prod/index.js +1 -0
  24. package/dist/prod/locales/ar.js +2 -0
  25. package/dist/prod/locales/ar.js.map +1 -0
  26. package/dist/prod/locales/en.js +2 -0
  27. package/dist/prod/locales/en.js.map +1 -0
  28. package/dist/prod/registry/utils/utils.js +2 -0
  29. package/dist/prod/registry/utils/utils.js.map +1 -0
  30. package/dist/prod/theme/theme-logic.js +2 -0
  31. package/dist/prod/theme/theme-logic.js.map +1 -0
  32. package/dist/prod/theme/theme-provider.js +2 -0
  33. package/dist/prod/theme/theme-provider.js.map +1 -0
  34. package/package.json +102 -4
  35. package/registry/accordion/accordion.tsx +210 -0
  36. package/registry/accordion/item.json +4 -0
  37. package/registry/alert/alert.tsx +200 -0
  38. package/registry/alert/item.json +4 -0
  39. package/registry/alert-dialog/alert-dialog.tsx +530 -0
  40. package/registry/alert-dialog/item.json +4 -0
  41. package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
  42. package/registry/aspect-ratio/item.json +4 -0
  43. package/registry/attachment/attachment.tsx +459 -0
  44. package/registry/attachment/item.json +4 -0
  45. package/registry/avatar/avatar.tsx +385 -0
  46. package/registry/avatar/item.json +4 -0
  47. package/registry/badge/badge.tsx +108 -0
  48. package/registry/badge/item.json +4 -0
  49. package/registry/breadcrumb/breadcrumb.tsx +354 -0
  50. package/registry/breadcrumb/item.json +4 -0
  51. package/registry/bubble/bubble.tsx +260 -0
  52. package/registry/bubble/item.json +4 -0
  53. package/registry/button/button.tsx +125 -0
  54. package/registry/button/item.json +4 -0
  55. package/registry/button-group/button-group.tsx +189 -0
  56. package/registry/button-group/item.json +4 -0
  57. package/registry/calendar/calendar.tsx +348 -0
  58. package/registry/calendar/item.json +4 -0
  59. package/registry/card/card.tsx +311 -0
  60. package/registry/card/item.json +4 -0
  61. package/registry/carousel/carousel.tsx +851 -0
  62. package/registry/carousel/item.json +4 -0
  63. package/registry/chart/chart.tsx +1956 -0
  64. package/registry/chart/item.json +4 -0
  65. package/registry/chart-scale/chart-scale.ts +670 -0
  66. package/registry/chart-scale/item.json +5 -0
  67. package/registry/checkbox/checkbox.tsx +79 -0
  68. package/registry/checkbox/item.json +4 -0
  69. package/registry/code-block/code-block.tsx +470 -0
  70. package/registry/code-block/item.json +4 -0
  71. package/registry/collapsible/collapsible.tsx +117 -0
  72. package/registry/collapsible/item.json +4 -0
  73. package/registry/combobox/combobox.tsx +977 -0
  74. package/registry/combobox/item.json +4 -0
  75. package/registry/command/command.tsx +757 -0
  76. package/registry/command/item.json +4 -0
  77. package/registry/context-menu/context-menu.tsx +697 -0
  78. package/registry/context-menu/item.json +4 -0
  79. package/registry/data-table/data-table.tsx +437 -0
  80. package/registry/data-table/item.json +4 -0
  81. package/registry/date-picker/date-picker.tsx +349 -0
  82. package/registry/date-picker/item.json +4 -0
  83. package/registry/dialog/dialog.tsx +412 -0
  84. package/registry/dialog/item.json +4 -0
  85. package/registry/direction/direction.tsx +88 -0
  86. package/registry/direction/item.json +4 -0
  87. package/registry/drawer/drawer.tsx +787 -0
  88. package/registry/drawer/item.json +4 -0
  89. package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
  90. package/registry/dropdown-menu/item.json +4 -0
  91. package/registry/empty/empty.tsx +240 -0
  92. package/registry/empty/item.json +4 -0
  93. package/registry/field/field.tsx +624 -0
  94. package/registry/field/item.json +4 -0
  95. package/registry/form/form.tsx +1112 -0
  96. package/registry/form/item.json +4 -0
  97. package/registry/hover-card/hover-card.tsx +144 -0
  98. package/registry/hover-card/item.json +4 -0
  99. package/registry/icons/icons.tsx +1691 -0
  100. package/registry/icons/item.json +4 -0
  101. package/registry/input/input.tsx +43 -0
  102. package/registry/input/item.json +4 -0
  103. package/registry/input-group/input-group.tsx +315 -0
  104. package/registry/input-group/item.json +4 -0
  105. package/registry/input-otp/input-otp.tsx +329 -0
  106. package/registry/input-otp/item.json +4 -0
  107. package/registry/item/item.json +4 -0
  108. package/registry/item/item.tsx +439 -0
  109. package/registry/kbd/item.json +4 -0
  110. package/registry/kbd/kbd.tsx +65 -0
  111. package/registry/label/item.json +4 -0
  112. package/registry/label/label.tsx +60 -0
  113. package/registry/marker/item.json +4 -0
  114. package/registry/marker/marker.tsx +175 -0
  115. package/registry/menubar/item.json +4 -0
  116. package/registry/menubar/menubar.tsx +694 -0
  117. package/registry/message/item.json +4 -0
  118. package/registry/message/message.tsx +280 -0
  119. package/registry/message-scroller/item.json +4 -0
  120. package/registry/message-scroller/message-scroller.tsx +262 -0
  121. package/registry/native-select/item.json +4 -0
  122. package/registry/native-select/native-select.tsx +113 -0
  123. package/registry/navigation-menu/item.json +4 -0
  124. package/registry/navigation-menu/navigation-menu.tsx +362 -0
  125. package/registry/pagination/item.json +4 -0
  126. package/registry/pagination/pagination.tsx +460 -0
  127. package/registry/password-input/item.json +5 -0
  128. package/registry/password-input/password-input.tsx +494 -0
  129. package/registry/popover/item.json +4 -0
  130. package/registry/popover/popover.tsx +284 -0
  131. package/registry/progress/item.json +4 -0
  132. package/registry/progress/progress.tsx +190 -0
  133. package/registry/questionnaire/item.json +4 -0
  134. package/registry/questionnaire/questionnaire.tsx +569 -0
  135. package/registry/radio-group/item.json +4 -0
  136. package/registry/radio-group/radio-group.tsx +101 -0
  137. package/registry/resizable/item.json +4 -0
  138. package/registry/resizable/resizable.tsx +455 -0
  139. package/registry/scroll-area/item.json +4 -0
  140. package/registry/scroll-area/scroll-area.tsx +149 -0
  141. package/registry/select/item.json +4 -0
  142. package/registry/select/select.tsx +571 -0
  143. package/registry/separator/item.json +4 -0
  144. package/registry/separator/separator.tsx +52 -0
  145. package/registry/sheet/item.json +4 -0
  146. package/registry/sheet/sheet.tsx +476 -0
  147. package/registry/sidebar/item.json +4 -0
  148. package/registry/sidebar/sidebar.tsx +1870 -0
  149. package/registry/skeleton/item.json +4 -0
  150. package/registry/skeleton/skeleton.tsx +47 -0
  151. package/registry/slider/item.json +4 -0
  152. package/registry/slider/slider.tsx +149 -0
  153. package/registry/spinner/item.json +4 -0
  154. package/registry/spinner/spinner.tsx +51 -0
  155. package/registry/switch/item.json +4 -0
  156. package/registry/switch/switch.tsx +93 -0
  157. package/registry/table/item.json +4 -0
  158. package/registry/table/table.tsx +373 -0
  159. package/registry/tabs/item.json +4 -0
  160. package/registry/tabs/tabs.tsx +209 -0
  161. package/registry/textarea/item.json +4 -0
  162. package/registry/textarea/textarea.tsx +51 -0
  163. package/registry/toast/item.json +4 -0
  164. package/registry/toast/toast.tsx +949 -0
  165. package/registry/toggle/item.json +4 -0
  166. package/registry/toggle/toggle.tsx +110 -0
  167. package/registry/toggle-group/item.json +4 -0
  168. package/registry/toggle-group/toggle-group.tsx +189 -0
  169. package/registry/tooltip/item.json +4 -0
  170. package/registry/tooltip/tooltip.tsx +169 -0
  171. package/registry/trace/item.json +4 -0
  172. package/registry/trace/trace.tsx +356 -0
  173. package/registry/ui-strings/item.json +5 -0
  174. package/registry/ui-strings/ui-strings.tsx +454 -0
  175. package/registry/use-highlighted-code/item.json +5 -0
  176. package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
  177. package/registry/use-mobile/item.json +5 -0
  178. package/registry/use-mobile/use-mobile.ts +46 -0
  179. package/registry/utils/item.json +5 -0
  180. package/registry/utils/utils.ts +38 -0
  181. package/registry.json +3274 -0
  182. package/styles/animations.css +321 -0
  183. package/styles/theme-core.css +55 -0
  184. package/styles/theme.css +12 -0
  185. package/styles/themes/gray.css +69 -0
  186. package/styles/themes/neutral.css +69 -0
  187. package/styles/themes/slate.css +69 -0
  188. package/styles/themes/stone.css +69 -0
  189. package/styles/themes/zinc.css +69 -0
  190. package/styles/typeset.css +251 -0
@@ -0,0 +1,851 @@
1
+ import { onCleanup, onMount, splitProps, useEffect, useSignal } from "@shadow-js/core";
2
+ import {
3
+ createPartContext,
4
+ type Direction,
5
+ useDirectionAround,
6
+ useRefSignal,
7
+ } from "@shadow-js/primitives";
8
+ import type { ComponentProps, JSX } from "@shadow-js/web";
9
+ import { Button } from "@/components/ui/button";
10
+ import {
11
+ ChevronDownIcon,
12
+ ChevronLeftIcon,
13
+ ChevronRightIcon,
14
+ ChevronUpIcon,
15
+ } from "@/components/ui/icons";
16
+ import { useUiStrings } from "@/lib/ui-strings";
17
+ import { cn } from "@/lib/utils";
18
+
19
+ /**
20
+ * The carousel's axis.
21
+ * @since 1.0.0
22
+ * @see {@link CarouselProps}
23
+ */
24
+ export type CarouselOrientation = "horizontal" | "vertical";
25
+
26
+ /** The index of the slide that is nearest the start of the scroll position. @internal */
27
+ function slideIndexAt(scroll: number, slideSize: number, count: number): number {
28
+ if (slideSize <= 0 || count <= 0) return 0;
29
+ return Math.min(count - 1, Math.max(0, Math.round(Math.abs(scroll) / slideSize)));
30
+ }
31
+
32
+ /**
33
+ * The text a carousel shows and announces, for a language other than English. Every part has an
34
+ * English default; pass the ones you translate or reword as `labels`.
35
+ * @since 1.0.0
36
+ * @see {@link CarouselProps}
37
+ */
38
+ export interface CarouselLabels {
39
+ /**
40
+ * The accessible name of the button that goes back.
41
+ * @default "Previous slide"
42
+ */
43
+ readonly previous?: string | undefined;
44
+ /**
45
+ * The accessible name of the button that goes forward.
46
+ * @default "Next slide"
47
+ */
48
+ readonly next?: string | undefined;
49
+ /**
50
+ * The name of a slide, from its 1-based position and the number of slides. The default gives
51
+ * names such as "1 of 3".
52
+ * @default (position, total) => `${position} of ${total}`
53
+ */
54
+ readonly slide?: ((position: number, total: number) => string) | undefined;
55
+ /**
56
+ * The accessible name of an indicator, from the 1-based position of its slide. The default gives
57
+ * names such as "Go to slide 2".
58
+ * @default (position) => `Go to slide ${position}`
59
+ */
60
+ readonly goToSlide?: ((position: number) => string) | undefined;
61
+ }
62
+
63
+ /** The labels with every default filled in. @internal */
64
+ interface ResolvedLabels {
65
+ readonly previous: string;
66
+ readonly next: string;
67
+ readonly slide: (position: number, total: number) => string;
68
+ readonly goToSlide: (position: number) => string;
69
+ }
70
+
71
+ /** @internal */
72
+ function resolveLabels(
73
+ labels: CarouselLabels | undefined,
74
+ t: ReturnType<typeof useUiStrings>,
75
+ ): ResolvedLabels {
76
+ return {
77
+ previous: labels?.previous ?? t("carousel.previous", "Previous slide"),
78
+ next: labels?.next ?? t("carousel.next", "Next slide"),
79
+ slide:
80
+ labels?.slide ??
81
+ ((position, total) => t("carousel.slide", "{position} of {total}", { position, total })),
82
+ goToSlide:
83
+ labels?.goToSlide ??
84
+ ((position) => t("carousel.goToSlide", "Go to slide {position}", { position })),
85
+ };
86
+ }
87
+
88
+ /** What the carousel shares with its parts. @internal */
89
+ interface CarouselContextValue {
90
+ readonly orientation: () => CarouselOrientation;
91
+ readonly dir: () => Direction;
92
+ readonly index: () => number;
93
+ readonly count: () => number;
94
+ readonly canPrevious: () => boolean;
95
+ readonly canNext: () => boolean;
96
+ readonly scrollToIndex: (index: number) => void;
97
+ readonly previous: () => void;
98
+ readonly next: () => void;
99
+ readonly setViewport: (element: HTMLElement | undefined) => void;
100
+ readonly claimSlide: () => number;
101
+ readonly registerCount: () => void;
102
+ readonly pause: (paused: boolean) => void;
103
+ readonly labels: () => ResolvedLabels;
104
+ /** True once the carousel is mounted in the browser; the slide names wait for it (see CarouselItem). */
105
+ readonly ready: () => boolean;
106
+ /** The viewport scrolled by hand (or by a drag): the index follows the position, next frame. */
107
+ readonly scrolled: () => void;
108
+ /** The user touched the viewport: a programmatic move in progress no longer holds the index. */
109
+ readonly userMoved: () => void;
110
+ }
111
+
112
+ const carouselContext = createPartContext<CarouselContextValue>("Carousel");
113
+
114
+ /**
115
+ * The options of a carousel in the shape of shadcn/ui's `opts`, for the keys this carousel supports.
116
+ * Embla's other options (`align`, `dragFree`, `slidesToScroll`, ...) do not exist here, and a type
117
+ * error says so.
118
+ * @since 1.0.0
119
+ */
120
+ export interface CarouselOptions {
121
+ /** Wrap from the last slide to the first. The `loop` prop wins when both are set. @default false */
122
+ readonly loop?: boolean | undefined;
123
+ /** The slide the carousel opens on, without animation. @default 0 */
124
+ readonly startIndex?: number | undefined;
125
+ /** `"y"` scrolls vertically. The `orientation` prop wins when both are set. @default "x" */
126
+ readonly axis?: "x" | "y" | undefined;
127
+ /** Reading direction. The `dir` prop wins when both are set. @default none (inherited from the page) */
128
+ readonly direction?: Direction | undefined;
129
+ }
130
+
131
+ /**
132
+ * The control handle that `setApi` receives, shaped like the parts of Embla's API that carousels use
133
+ * most: moving, asking where it is and listening for slide changes.
134
+ * @since 1.0.0
135
+ */
136
+ export interface CarouselApi {
137
+ /** Moves to the previous slide (wraps when looping, stays at the first otherwise). */
138
+ readonly scrollPrev: () => void;
139
+ /** Moves to the next slide (wraps when looping, stays at the last otherwise). */
140
+ readonly scrollNext: () => void;
141
+ /** Moves to the slide at `index` (clamped, or wrapped when looping). */
142
+ readonly scrollTo: (index: number) => void;
143
+ /** True when a previous slide exists, always true while looping. */
144
+ readonly canScrollPrev: () => boolean;
145
+ /** True when a next slide exists, always true while looping. */
146
+ readonly canScrollNext: () => boolean;
147
+ /** The index of the current slide. */
148
+ readonly selectedScrollSnap: () => number;
149
+ /** One entry per slide: its index. */
150
+ readonly scrollSnapList: () => number[];
151
+ /** Listens to `"select"` (the current slide changed) or `"reInit"` (the slide count changed). */
152
+ readonly on: (event: CarouselEvent, listener: () => void) => CarouselApi;
153
+ /** Removes a listener added with {@link CarouselApi.on}. */
154
+ readonly off: (event: CarouselEvent, listener: () => void) => CarouselApi;
155
+ }
156
+
157
+ /** The events of {@link CarouselApi.on}. @since 1.0.0 */
158
+ export type CarouselEvent = "select" | "reInit";
159
+
160
+ /**
161
+ * Props of {@link Carousel}: the attributes of a `section` plus the props below.
162
+ * @since 1.0.0
163
+ * @see {@link Carousel}
164
+ */
165
+ export interface CarouselProps extends ComponentProps<"section"> {
166
+ /**
167
+ * shadcn-style options: `loop`, `startIndex`, `axis` and `direction`. The matching props win.
168
+ * @default none
169
+ */
170
+ readonly opts?: CarouselOptions | undefined;
171
+ /**
172
+ * Called once after mount with a {@link CarouselApi}, to move the slides from outside and to follow
173
+ * them (`api.on("select", ...)`).
174
+ * @default none
175
+ */
176
+ readonly setApi?: ((api: CarouselApi) => void) | undefined;
177
+ /**
178
+ * The axis the slides scroll along. With `"vertical"` the up and down arrows move the slides.
179
+ * @default "horizontal"
180
+ */
181
+ readonly orientation?: CarouselOrientation | undefined;
182
+ /**
183
+ * Wrap from the last slide to the first, and back. The buttons are never disabled while looping.
184
+ * @default false
185
+ */
186
+ readonly loop?: boolean | undefined;
187
+ /**
188
+ * Milliseconds between slides when the carousel plays itself. Autoplay is off unless you set it.
189
+ * It never runs when the user prefers reduced motion, and it pauses while the pointer is over the
190
+ * carousel or focus is inside it.
191
+ * @default none (autoplay off)
192
+ */
193
+ readonly autoplay?: number | undefined;
194
+ /**
195
+ * Called with the slide index after the carousel moves.
196
+ * @default none
197
+ */
198
+ readonly onIndexChange?: ((index: number) => void) | undefined;
199
+ /**
200
+ * Reading direction. `"rtl"` swaps the horizontal arrow keys and the scroll origin. When unset,
201
+ * the direction is inherited from the page.
202
+ * @default none (inherited from the page)
203
+ */
204
+ readonly dir?: Direction | undefined;
205
+ /**
206
+ * The text of the buttons, the slide names and the indicators. See {@link CarouselLabels}.
207
+ * @default none (the English defaults of {@link CarouselLabels})
208
+ */
209
+ readonly labels?: CarouselLabels | undefined;
210
+ }
211
+
212
+ /**
213
+ * A set of slides you move through with buttons, the arrow keys, the indicators, a drag or
214
+ * (opt-in) autoplay. The slides scroll with native scroll-snap, so touch and wheel work as usual.
215
+ * The arrows, indicators and drag only steer that scroll. No carousel library is needed.
216
+ *
217
+ * Keyboard and ARIA: the carousel is a `section` with `role="region"` and
218
+ * `aria-roledescription="carousel"`. Give it an `aria-label` that names it. Left and Right move to
219
+ * the next or previous slide (Up and Down when vertical, and the keys swap in right-to-left
220
+ * layouts). Home and End go to the first and last slide. The keys work while focus is anywhere
221
+ * inside the carousel, including on its buttons. The scrolling viewport is a tab stop, and each
222
+ * slide is a `role="group"` with `aria-roledescription="slide"`, named "n of N" once the carousel
223
+ * has mounted in the browser. The buttons are named "Previous slide" and "Next slide", and each
224
+ * indicator is a button named "Go to slide N" with `aria-current="true"` on the current slide.
225
+ *
226
+ * Compared with shadcn/ui: the carousel does not use Embla. `opts` takes `loop`, `startIndex`, `axis`
227
+ * and `direction` (the matching props win), `setApi` hands over a small {@link CarouselApi}
228
+ * (`scrollPrev`, `scrollNext`, `scrollTo`, `canScrollPrev`, `canScrollNext`, `selectedScrollSnap`,
229
+ * `scrollSnapList`, `on` and `off` for `"select"` and `"reInit"`), and there are no `plugins`: give
230
+ * `autoplay` a number of milliseconds. `onIndexChange` also follows the slide. `class` replaces `className`.
231
+ *
232
+ * @param props - The carousel props: see {@link CarouselProps}.
233
+ * @returns The carousel region.
234
+ * @example
235
+ * ```tsx
236
+ * import {
237
+ * Carousel,
238
+ * CarouselContent,
239
+ * CarouselIndicators,
240
+ * CarouselItem,
241
+ * CarouselNext,
242
+ * CarouselPrevious,
243
+ * } from "@/components/ui/carousel";
244
+ *
245
+ * export function Featured() {
246
+ * return (
247
+ * <Carousel aria-label="Featured" loop>
248
+ * <CarouselContent>
249
+ * <CarouselItem>One</CarouselItem>
250
+ * <CarouselItem>Two</CarouselItem>
251
+ * </CarouselContent>
252
+ * <CarouselPrevious />
253
+ * <CarouselNext />
254
+ * <CarouselIndicators />
255
+ * </Carousel>
256
+ * );
257
+ * }
258
+ * ```
259
+ * @since 1.0.0
260
+ * @see {@link CarouselContent}
261
+ * @see {@link CarouselItem}
262
+ * @see {@link CarouselPrevious}
263
+ * @see {@link CarouselNext}
264
+ * @see {@link CarouselIndicators}
265
+ * @see {@link CarouselProps}
266
+ */
267
+ function Carousel(props: CarouselProps): JSX.Element {
268
+ const [local, rest] = splitProps(props, [
269
+ "class",
270
+ "opts",
271
+ "setApi",
272
+ "orientation",
273
+ "loop",
274
+ "autoplay",
275
+ "onIndexChange",
276
+ "dir",
277
+ "labels",
278
+ "onKeyDown",
279
+ "onPointerEnter",
280
+ "onPointerLeave",
281
+ "onFocusIn",
282
+ "onFocusOut",
283
+ "children",
284
+ ]);
285
+ const opts = (): CarouselOptions => (local.opts as CarouselOptions | undefined) ?? {};
286
+ const orientation = (): CarouselOrientation =>
287
+ (local.orientation as CarouselOrientation | undefined) ??
288
+ (opts().axis === "y" ? "vertical" : "horizontal");
289
+ // A dir attribute above the carousel applies when there is no prop or option: found after mount.
290
+ const [regionElement, setRegionElement] = useRefSignal<HTMLElement>();
291
+ let regionNode: HTMLElement | undefined;
292
+ const dir = useDirectionAround(
293
+ () => (local.dir as Direction | undefined) ?? opts().direction,
294
+ regionElement,
295
+ );
296
+ const listeners: Record<CarouselEvent, Set<() => void>> = {
297
+ select: new Set(),
298
+ reInit: new Set(),
299
+ };
300
+ const emit = (event: CarouselEvent): void => {
301
+ for (const listener of [...listeners[event]]) listener();
302
+ };
303
+ const [index, setIndexState] = useSignal(0);
304
+ const [count, setCount] = useSignal(0);
305
+ const [paused, setPaused] = useSignal(false);
306
+ const [ready, setReady] = useSignal(false);
307
+ let viewport: HTMLElement | undefined;
308
+ let claimed = 0;
309
+ // The slide a programmatic move is heading for, until the scroll arrives there. While it is set,
310
+ // the intermediate positions of a smooth scroll do not move the index back.
311
+ let pending: number | undefined;
312
+ let scheduled = false;
313
+ const loops = (): boolean => ((local.loop as boolean | undefined) ?? opts().loop) === true;
314
+ const t = useUiStrings();
315
+ const labels = (): ResolvedLabels => resolveLabels(local.labels as CarouselLabels | undefined, t);
316
+
317
+ const setIndex = (next: number): void => {
318
+ const changed = next !== index();
319
+ setIndexState(next);
320
+ (local.onIndexChange as ((index: number) => void) | undefined)?.(next);
321
+ if (changed) emit("select");
322
+ };
323
+ const slideSize = (): number => {
324
+ if (viewport === undefined) return 0;
325
+ return orientation() === "vertical" ? viewport.clientHeight : viewport.clientWidth;
326
+ };
327
+ const scrollPosition = (): number => {
328
+ if (viewport === undefined) return 0;
329
+ return orientation() === "vertical" ? viewport.scrollTop : Math.abs(viewport.scrollLeft);
330
+ };
331
+ /** Recomputes the index from where the viewport rests. Right-to-left offsets are negative. */
332
+ const syncIndex = (): void => {
333
+ if (viewport === undefined) return;
334
+ const size = slideSize();
335
+ if (size <= 0) return;
336
+ const position = scrollPosition();
337
+ if (pending !== undefined) {
338
+ if (Math.abs(position - pending * size) >= 1) return;
339
+ pending = undefined;
340
+ }
341
+ const nearest = slideIndexAt(position, size, count());
342
+ if (nearest !== index()) setIndex(nearest);
343
+ };
344
+ const scrolled = (): void => {
345
+ if (scheduled) return;
346
+ scheduled = true;
347
+ if (typeof requestAnimationFrame !== "function") {
348
+ scheduled = false;
349
+ syncIndex();
350
+ return;
351
+ }
352
+ requestAnimationFrame(() => {
353
+ scheduled = false;
354
+ syncIndex();
355
+ });
356
+ };
357
+ const scrollToIndex = (target: number, instant = false): void => {
358
+ const total = count();
359
+ if (total === 0) return;
360
+ const bounded = loops()
361
+ ? ((target % total) + total) % total
362
+ : Math.min(total - 1, Math.max(0, target));
363
+ setIndex(bounded);
364
+ if (viewport === undefined) return;
365
+ const reduced =
366
+ instant ||
367
+ (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
368
+ const position = bounded * slideSize();
369
+ const sign = orientation() === "horizontal" && dir() === "rtl" ? -1 : 1;
370
+ pending = Math.abs(scrollPosition() - position) < 1 ? undefined : bounded;
371
+ if (orientation() === "vertical") {
372
+ viewport.scrollTo?.({ top: position, behavior: reduced ? "auto" : "smooth" });
373
+ if (typeof viewport.scrollTo !== "function") viewport.scrollTop = position;
374
+ } else {
375
+ viewport.scrollTo?.({ left: position * sign, behavior: reduced ? "auto" : "smooth" });
376
+ if (typeof viewport.scrollTo !== "function") viewport.scrollLeft = position * sign;
377
+ }
378
+ };
379
+ const canPrevious = (): boolean => loops() || index() > 0;
380
+ const canNext = (): boolean => loops() || index() < count() - 1;
381
+
382
+ const context: CarouselContextValue = {
383
+ orientation,
384
+ dir,
385
+ index,
386
+ count,
387
+ canPrevious,
388
+ canNext,
389
+ scrollToIndex,
390
+ previous: () => {
391
+ if (canPrevious()) scrollToIndex(index() - 1);
392
+ },
393
+ next: () => {
394
+ if (canNext()) scrollToIndex(index() + 1);
395
+ },
396
+ setViewport(element) {
397
+ viewport = element;
398
+ },
399
+ claimSlide() {
400
+ const slot = claimed;
401
+ claimed += 1;
402
+ return slot;
403
+ },
404
+ registerCount() {
405
+ const changed = claimed !== count();
406
+ setCount(claimed);
407
+ if (changed && ready()) emit("reInit");
408
+ },
409
+ pause: (value) => setPaused(value),
410
+ labels,
411
+ ready,
412
+ scrolled,
413
+ userMoved: () => {
414
+ pending = undefined;
415
+ },
416
+ };
417
+
418
+ const api: CarouselApi = {
419
+ scrollPrev: () => context.previous(),
420
+ scrollNext: () => context.next(),
421
+ scrollTo: (target) => scrollToIndex(target),
422
+ canScrollPrev: canPrevious,
423
+ canScrollNext: canNext,
424
+ selectedScrollSnap: index,
425
+ scrollSnapList: () => Array.from({ length: count() }, (_, slide) => slide),
426
+ on(event, listener) {
427
+ listeners[event].add(listener);
428
+ return api;
429
+ },
430
+ off(event, listener) {
431
+ listeners[event].delete(listener);
432
+ return api;
433
+ },
434
+ };
435
+
436
+ onMount(() => {
437
+ setRegionElement(regionNode);
438
+ setReady(true);
439
+ const start = opts().startIndex;
440
+ if (start !== undefined && start > 0) scrollToIndex(start, true);
441
+ (local.setApi as ((api: CarouselApi) => void) | undefined)?.(api);
442
+ });
443
+
444
+ // Autoplay is opt-in, and never runs when the user asks for less motion. It follows the
445
+ // `autoplay` prop: changing it retimes the interval, and removing it stops the interval.
446
+ useEffect(() => {
447
+ const interval = local.autoplay as number | undefined;
448
+ if (interval === undefined || interval <= 0) return undefined;
449
+ if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches)
450
+ return undefined;
451
+ const timer = setInterval(() => {
452
+ if (paused()) return;
453
+ if (canNext()) scrollToIndex(index() + 1);
454
+ else if (loops()) scrollToIndex(0);
455
+ }, interval);
456
+ return () => clearInterval(timer);
457
+ });
458
+
459
+ const onKeyDown = (event: KeyboardEvent): void => {
460
+ const horizontal = orientation() === "horizontal";
461
+ const forward = horizontal ? (dir() === "rtl" ? "ArrowLeft" : "ArrowRight") : "ArrowDown";
462
+ const back = horizontal ? (dir() === "rtl" ? "ArrowRight" : "ArrowLeft") : "ArrowUp";
463
+ if (event.key === forward) {
464
+ event.preventDefault();
465
+ context.next();
466
+ } else if (event.key === back) {
467
+ event.preventDefault();
468
+ context.previous();
469
+ } else if (event.key === "Home") {
470
+ event.preventDefault();
471
+ scrollToIndex(0);
472
+ } else if (event.key === "End") {
473
+ event.preventDefault();
474
+ scrollToIndex(count() - 1);
475
+ }
476
+ };
477
+
478
+ return (
479
+ <carouselContext.Provider value={context}>
480
+ <section
481
+ ref={(node: HTMLElement) => {
482
+ regionNode = node;
483
+ }}
484
+ role="region"
485
+ aria-roledescription="carousel"
486
+ data-slot="carousel"
487
+ data-orientation={orientation()}
488
+ dir={local.dir as Direction | undefined}
489
+ class={cn("relative", local.class)}
490
+ onKeyDown={(event: KeyboardEvent) => {
491
+ onKeyDown(event);
492
+ (local.onKeyDown as ((event: KeyboardEvent) => void) | undefined)?.(event);
493
+ }}
494
+ onPointerEnter={() => setPaused(true)}
495
+ onPointerLeave={() => setPaused(false)}
496
+ onFocusIn={() => setPaused(true)}
497
+ onFocusOut={() => setPaused(false)}
498
+ {...rest}
499
+ >
500
+ {local.children as JSX.Element}
501
+ </section>
502
+ </carouselContext.Provider>
503
+ );
504
+ }
505
+
506
+ /**
507
+ * The scrolling row of slides. Dragging it with the mouse scrolls it and snaps to the nearest slide
508
+ * when released; touch and the wheel scroll natively. It renders a focusable `div`, so keyboard
509
+ * users can focus the slides and scroll them.
510
+ *
511
+ * @param props - The content props: `children`, `class`, and the `div` attributes.
512
+ * @returns The viewport element.
513
+ * @example
514
+ * ```tsx
515
+ * import {
516
+ * Carousel,
517
+ * CarouselContent,
518
+ * CarouselItem,
519
+ * } from "@/components/ui/carousel";
520
+ *
521
+ * export function Slides() {
522
+ * return (
523
+ * <Carousel aria-label="Photos">
524
+ * <CarouselContent>
525
+ * <CarouselItem>First photo</CarouselItem>
526
+ * <CarouselItem>Second photo</CarouselItem>
527
+ * </CarouselContent>
528
+ * </Carousel>
529
+ * );
530
+ * }
531
+ * ```
532
+ * @since 1.0.0
533
+ * @see {@link Carousel}
534
+ * @see {@link CarouselItem}
535
+ */
536
+ function CarouselContent(props: ComponentProps<"div">): JSX.Element {
537
+ const carousel = carouselContext.use("CarouselContent");
538
+ const [local, rest] = splitProps(props, ["class", "onPointerDown", "onScroll", "onWheel"]);
539
+ let stopDrag: (() => void) | undefined;
540
+ onCleanup(() => stopDrag?.());
541
+ return (
542
+ <div
543
+ data-slot="carousel-content"
544
+ ref={(element: HTMLElement) => carousel.setViewport(element)}
545
+ tabindex={0}
546
+ class={cn(
547
+ "flex overflow-hidden snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
548
+ carousel.orientation() === "vertical"
549
+ ? "flex-col snap-y overflow-y-auto"
550
+ : "snap-x overflow-x-auto",
551
+ local.class,
552
+ )}
553
+ onScroll={(event: Event) => {
554
+ (local.onScroll as ((event: Event) => void) | undefined)?.(event);
555
+ carousel.scrolled();
556
+ }}
557
+ onWheel={(event: WheelEvent) => {
558
+ (local.onWheel as ((event: WheelEvent) => void) | undefined)?.(event);
559
+ carousel.userMoved();
560
+ }}
561
+ onPointerDown={(event: PointerEvent) => {
562
+ (local.onPointerDown as ((event: PointerEvent) => void) | undefined)?.(event);
563
+ carousel.userMoved();
564
+ if (event.button !== 0 || event.pointerType === "touch") return;
565
+ const element = event.currentTarget as HTMLElement;
566
+ const vertical = carousel.orientation() === "vertical";
567
+ const start = vertical ? event.clientY : event.clientX;
568
+ const origin = vertical ? element.scrollTop : element.scrollLeft;
569
+ // The content follows the pointer in both directions. Under right-to-left the scroll
570
+ // offsets are negative, so a drag to the right (toward the next slide) lowers the offset.
571
+ const move = (moveEvent: PointerEvent): void => {
572
+ const delta = (vertical ? moveEvent.clientY : moveEvent.clientX) - start;
573
+ if (vertical) element.scrollTop = origin - delta;
574
+ else element.scrollLeft = origin - delta;
575
+ };
576
+ const end = (): void => {
577
+ stopDrag?.();
578
+ const size = vertical ? element.clientHeight : element.clientWidth;
579
+ const current = vertical ? element.scrollTop : Math.abs(element.scrollLeft);
580
+ carousel.scrollToIndex(slideIndexAt(current, size, carousel.count()));
581
+ };
582
+ window.addEventListener("pointermove", move);
583
+ window.addEventListener("pointerup", end);
584
+ stopDrag = () => {
585
+ window.removeEventListener("pointermove", move);
586
+ window.removeEventListener("pointerup", end);
587
+ stopDrag = undefined;
588
+ };
589
+ }}
590
+ {...rest}
591
+ />
592
+ );
593
+ }
594
+
595
+ /**
596
+ * One slide. It takes the full width (or height) of the row. It is a `role="group"` with
597
+ * `aria-roledescription="slide"`, and it is named "n of N" (see {@link CarouselLabels}). The name is
598
+ * set once the carousel has mounted in the browser, so the server markup has no slide name.
599
+ *
600
+ * @param props - The slide props: `children`, `class`, and the `div` attributes.
601
+ * @returns The slide element.
602
+ * @example
603
+ * ```tsx
604
+ * import {
605
+ * Carousel,
606
+ * CarouselContent,
607
+ * CarouselItem,
608
+ * } from "@/components/ui/carousel";
609
+ *
610
+ * export function Slide() {
611
+ * return (
612
+ * <Carousel aria-label="Tips">
613
+ * <CarouselContent>
614
+ * <CarouselItem class="flex h-40 items-center justify-center">Tip one</CarouselItem>
615
+ * </CarouselContent>
616
+ * </Carousel>
617
+ * );
618
+ * }
619
+ * ```
620
+ * @since 1.0.0
621
+ * @see {@link CarouselContent}
622
+ * @see {@link CarouselLabels}
623
+ */
624
+ function CarouselItem(props: ComponentProps<"div">): JSX.Element {
625
+ const carousel = carouselContext.use("CarouselItem");
626
+ const [local, rest] = splitProps(props, ["class"]);
627
+ const slot = carousel.claimSlide();
628
+ // Registered during render, so the server HTML lists one indicator per slide (UI-CRS-09). The
629
+ // count is only complete after the last slide renders, and the server pass renders each slide
630
+ // before the next one is counted; so a slide is named only once the carousel is mounted, and
631
+ // the name then uses the final count (UI-CRS-18).
632
+ carousel.registerCount();
633
+ return (
634
+ <div
635
+ role="group"
636
+ aria-roledescription="slide"
637
+ aria-label={
638
+ carousel.ready() ? carousel.labels().slide(slot + 1, carousel.count()) : undefined
639
+ }
640
+ data-slot="carousel-item"
641
+ class={cn(
642
+ "min-w-0 shrink-0 grow-0 basis-full snap-start",
643
+ carousel.orientation() === "vertical" ? "pt-4" : "ps-4",
644
+ local.class,
645
+ )}
646
+ {...rest}
647
+ />
648
+ );
649
+ }
650
+
651
+ /**
652
+ * The button that goes back one slide. It is disabled at the first slide unless the carousel loops.
653
+ * It is a shadcn-style outline icon button, named "Previous slide" by default.
654
+ *
655
+ * Keyboard and ARIA: a native button, so Enter and Space activate it. Its accessible name comes
656
+ * from `labels.previous`. It is disabled (`disabled`) at the first slide.
657
+ *
658
+ * @param props - The button props: `onClick`, `class`, and the attributes of the button.
659
+ * @returns The previous button.
660
+ * @example
661
+ * ```tsx
662
+ * import {
663
+ * Carousel,
664
+ * CarouselContent,
665
+ * CarouselItem,
666
+ * CarouselPrevious,
667
+ * } from "@/components/ui/carousel";
668
+ *
669
+ * export function Back() {
670
+ * return (
671
+ * <Carousel aria-label="Tips">
672
+ * <CarouselContent>
673
+ * <CarouselItem>Tip one</CarouselItem>
674
+ * </CarouselContent>
675
+ * <CarouselPrevious />
676
+ * </Carousel>
677
+ * );
678
+ * }
679
+ * ```
680
+ * @since 1.0.0
681
+ * @see {@link CarouselNext}
682
+ * @see {@link Carousel}
683
+ */
684
+ function CarouselPrevious(props: ComponentProps<"button">): JSX.Element {
685
+ const carousel = carouselContext.use("CarouselPrevious");
686
+ const [local, rest] = splitProps(props, ["class", "onClick"]);
687
+ return (
688
+ <Button
689
+ data-slot="carousel-previous"
690
+ variant="outline"
691
+ size="icon"
692
+ aria-label={carousel.labels().previous}
693
+ class={cn(
694
+ "absolute size-8 rounded-full",
695
+ carousel.orientation() === "vertical"
696
+ ? "-top-12 start-1/2 -translate-x-1/2 rotate-90"
697
+ : "top-1/2 -start-12 -translate-y-1/2 rtl:translate-x-1/2",
698
+ local.class,
699
+ )}
700
+ disabled={!carousel.canPrevious()}
701
+ onClick={(event: MouseEvent) => {
702
+ (local.onClick as ((event: MouseEvent) => void) | undefined)?.(event);
703
+ carousel.previous();
704
+ }}
705
+ {...rest}
706
+ >
707
+ {carousel.orientation() === "vertical" ? (
708
+ <ChevronUpIcon />
709
+ ) : (
710
+ <ChevronLeftIcon class="rtl:-scale-x-100" />
711
+ )}
712
+ </Button>
713
+ );
714
+ }
715
+
716
+ /**
717
+ * The button that goes forward one slide. It is disabled at the last slide unless the carousel
718
+ * loops. It is a shadcn-style outline icon button, named "Next slide" by default.
719
+ *
720
+ * Keyboard and ARIA: a native button, so Enter and Space activate it. Its accessible name comes
721
+ * from `labels.next`. It is disabled (`disabled`) at the last slide.
722
+ *
723
+ * @param props - The button props: `onClick`, `class`, and the attributes of the button.
724
+ * @returns The next button.
725
+ * @example
726
+ * ```tsx
727
+ * import {
728
+ * Carousel,
729
+ * CarouselContent,
730
+ * CarouselItem,
731
+ * CarouselNext,
732
+ * } from "@/components/ui/carousel";
733
+ *
734
+ * export function Forward() {
735
+ * return (
736
+ * <Carousel aria-label="Tips">
737
+ * <CarouselContent>
738
+ * <CarouselItem>Tip one</CarouselItem>
739
+ * </CarouselContent>
740
+ * <CarouselNext />
741
+ * </Carousel>
742
+ * );
743
+ * }
744
+ * ```
745
+ * @since 1.0.0
746
+ * @see {@link CarouselPrevious}
747
+ * @see {@link Carousel}
748
+ */
749
+ function CarouselNext(props: ComponentProps<"button">): JSX.Element {
750
+ const carousel = carouselContext.use("CarouselNext");
751
+ const [local, rest] = splitProps(props, ["class", "onClick"]);
752
+ return (
753
+ <Button
754
+ data-slot="carousel-next"
755
+ variant="outline"
756
+ size="icon"
757
+ aria-label={carousel.labels().next}
758
+ class={cn(
759
+ "absolute size-8 rounded-full",
760
+ carousel.orientation() === "vertical"
761
+ ? "-bottom-12 start-1/2 -translate-x-1/2 rotate-90"
762
+ : "top-1/2 -end-12 -translate-y-1/2 rtl:-translate-x-1/2",
763
+ local.class,
764
+ )}
765
+ disabled={!carousel.canNext()}
766
+ onClick={(event: MouseEvent) => {
767
+ (local.onClick as ((event: MouseEvent) => void) | undefined)?.(event);
768
+ carousel.next();
769
+ }}
770
+ {...rest}
771
+ >
772
+ {carousel.orientation() === "vertical" ? (
773
+ <ChevronDownIcon />
774
+ ) : (
775
+ <ChevronRightIcon class="rtl:-scale-x-100" />
776
+ )}
777
+ </Button>
778
+ );
779
+ }
780
+
781
+ /**
782
+ * One dot per slide. The current slide's dot is marked, and each dot is a button that goes to its
783
+ * slide.
784
+ *
785
+ * Keyboard and ARIA: each dot is a native `button` (Tab to reach it, Enter and Space to use it),
786
+ * named "Go to slide N" by default (see {@link CarouselLabels}). The current dot has
787
+ * `aria-current="true"` and `data-state="active"`.
788
+ *
789
+ * @param props - The indicators props: `class` and the `div` attributes.
790
+ * @returns The indicators container.
791
+ * @example
792
+ * ```tsx
793
+ * import {
794
+ * Carousel,
795
+ * CarouselContent,
796
+ * CarouselIndicators,
797
+ * CarouselItem,
798
+ * } from "@/components/ui/carousel";
799
+ *
800
+ * export function Dots() {
801
+ * return (
802
+ * <Carousel aria-label="Tips">
803
+ * <CarouselContent>
804
+ * <CarouselItem>Tip one</CarouselItem>
805
+ * <CarouselItem>Tip two</CarouselItem>
806
+ * </CarouselContent>
807
+ * <CarouselIndicators />
808
+ * </Carousel>
809
+ * );
810
+ * }
811
+ * ```
812
+ * @since 1.0.0
813
+ * @see {@link Carousel}
814
+ */
815
+ function CarouselIndicators(props: ComponentProps<"div">): JSX.Element {
816
+ const carousel = carouselContext.use("CarouselIndicators");
817
+ const [local, rest] = splitProps(props, ["class"]);
818
+ return (
819
+ <div
820
+ data-slot="carousel-indicators"
821
+ class={cn("mt-3 flex justify-center gap-1.5", local.class)}
822
+ {...rest}
823
+ >
824
+ {Array.from({ length: carousel.count() }, (_, slide) => (
825
+ <button
826
+ type="button"
827
+ data-slot="carousel-indicator"
828
+ aria-label={carousel.labels().goToSlide(slide + 1)}
829
+ aria-current={carousel.index() === slide ? "true" : undefined}
830
+ data-state={carousel.index() === slide ? "active" : "inactive"}
831
+ class="group inline-flex size-6 shrink-0 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none"
832
+ onClick={() => carousel.scrollToIndex(slide)}
833
+ >
834
+ <span
835
+ aria-hidden="true"
836
+ class="size-2 rounded-full bg-muted-foreground/40 transition-colors group-data-[state=active]:bg-primary forced-colors:border forced-colors:border-[ButtonText] forced-colors:group-data-[state=active]:bg-[Highlight] motion-reduce:transition-none"
837
+ />
838
+ </button>
839
+ ))}
840
+ </div>
841
+ );
842
+ }
843
+
844
+ export {
845
+ Carousel,
846
+ CarouselContent,
847
+ CarouselIndicators,
848
+ CarouselItem,
849
+ CarouselNext,
850
+ CarouselPrevious,
851
+ };