@boyernick/standard-ui-react 0.1.1-canary.9 → 0.2.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 (82) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +5 -2
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/attachment.tsx +5 -2
  5. package/src/autocomplete.tsx +4 -3
  6. package/src/block-editor.tsx +1747 -0
  7. package/src/brand.tsx +2 -2
  8. package/src/breadcrumb.tsx +14 -7
  9. package/src/button.tsx +22 -11
  10. package/src/calendar.tsx +6 -3
  11. package/src/carousel.tsx +220 -53
  12. package/src/checkbox.tsx +3 -2
  13. package/src/code-block.tsx +66 -5
  14. package/src/collapsible.tsx +4 -2
  15. package/src/combobox.tsx +4 -3
  16. package/src/command.tsx +27 -12
  17. package/src/dialog.tsx +33 -10
  18. package/src/empty.tsx +4 -4
  19. package/src/field.tsx +4 -3
  20. package/src/filter-group.tsx +143 -0
  21. package/src/icons.tsx +28 -1
  22. package/src/illustrations.tsx +219 -141
  23. package/src/index.ts +238 -18
  24. package/src/input.tsx +8 -5
  25. package/src/kbd.tsx +10 -4
  26. package/src/lib/focus.ts +41 -0
  27. package/src/lib/popup.ts +1 -1
  28. package/src/lifeline/company-icon.tsx +71 -0
  29. package/src/lifeline/index.ts +42 -0
  30. package/src/lifeline/lifeline-data.ts +97 -0
  31. package/src/lifeline/lifeline-desktop.tsx +204 -0
  32. package/src/lifeline/lifeline-event.tsx +108 -0
  33. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  34. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  35. package/src/lifeline/lifeline-icons.tsx +39 -0
  36. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  37. package/src/lifeline/lifeline-labels.tsx +24 -0
  38. package/src/lifeline/lifeline-layout.ts +1 -0
  39. package/src/lifeline/lifeline-legend.tsx +31 -0
  40. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  41. package/src/lifeline/lifeline-marker.tsx +183 -0
  42. package/src/lifeline/lifeline-people.tsx +99 -0
  43. package/src/lifeline/lifeline-photos.tsx +366 -0
  44. package/src/lifeline/lifeline-shell.tsx +135 -0
  45. package/src/lifeline/lifeline-utils.ts +83 -0
  46. package/src/lifeline/lifeline-vertical.tsx +482 -0
  47. package/src/lifeline/lifeline.tsx +69 -0
  48. package/src/lifeline/types.ts +112 -0
  49. package/src/lifeline/use-lifeline-intro.ts +130 -0
  50. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  51. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  52. package/src/menubar.tsx +9 -2
  53. package/src/minimap.tsx +296 -0
  54. package/src/modal.tsx +608 -0
  55. package/src/navigation-menu.tsx +338 -67
  56. package/src/number-field.tsx +336 -61
  57. package/src/otp-field.tsx +4 -3
  58. package/src/pagination.tsx +262 -42
  59. package/src/password-protection.tsx +345 -0
  60. package/src/popover.tsx +1 -1
  61. package/src/progress.tsx +5 -0
  62. package/src/questionnaire.tsx +284 -0
  63. package/src/radio.tsx +4 -2
  64. package/src/scroll-area.tsx +4 -1
  65. package/src/select.tsx +5 -2
  66. package/src/sidebar.tsx +113 -28
  67. package/src/slider.tsx +66 -6
  68. package/src/sounds.tsx +6 -10
  69. package/src/spinner.tsx +105 -32
  70. package/src/switch.tsx +4 -1
  71. package/src/table.tsx +82 -15
  72. package/src/tabs.tsx +189 -38
  73. package/src/text-animate.tsx +71 -28
  74. package/src/textarea.tsx +6 -1
  75. package/src/timeline.tsx +378 -0
  76. package/src/toast.tsx +214 -35
  77. package/src/toggle.tsx +4 -2
  78. package/src/toolbar.tsx +12 -7
  79. package/src/tooltip.tsx +43 -3
  80. package/src/video-player.tsx +396 -127
  81. package/src/image-modal.tsx +0 -110
  82. package/src/markdown-editor.tsx +0 -302
package/src/brand.tsx CHANGED
@@ -45,13 +45,13 @@ export const BrandWordmark = ({
45
45
  compact = false,
46
46
  size = "md",
47
47
  }: BrandWordmarkProps) => {
48
- const resolvedMarkSize = markSize ?? (size === "sm" ? 16 : 28)
48
+ const resolvedMarkSize = markSize ?? (size === "sm" ? 16 : 32)
49
49
 
50
50
  return (
51
51
  <span
52
52
  className={cn(
53
53
  "inline-flex items-center",
54
- size === "sm" ? "gap-1" : "gap-1.5",
54
+ size === "sm" ? "gap-1.5" : "gap-2",
55
55
  className,
56
56
  )}
57
57
  aria-label="StandardUI"
@@ -2,6 +2,7 @@ import { cva, type VariantProps } from "class-variance-authority"
2
2
  import type { ComponentProps, ReactNode } from "react"
3
3
  import { IconChevronRightSmall } from "./icons"
4
4
  import { cn } from "./lib/cn"
5
+ import { focusRing, focusRingBorder } from "./lib/focus"
5
6
  import { motion } from "./lib/motion"
6
7
 
7
8
  export type BreadcrumbProps = ComponentProps<"nav">
@@ -14,11 +15,11 @@ const breadcrumbSeparatorVariants = cva("text-fg-tertiary", {
14
15
  variants: {
15
16
  variant: {
16
17
  chevron: "[&_svg]:size-3.5",
17
- slash: "px-0.5",
18
+ slash: "",
18
19
  },
19
20
  },
20
21
  defaultVariants: {
21
- variant: "chevron",
22
+ variant: "slash",
22
23
  },
23
24
  })
24
25
 
@@ -39,7 +40,7 @@ export const BreadcrumbList = ({
39
40
  ...props
40
41
  }: BreadcrumbListProps) => (
41
42
  <ol
42
- className={cn("flex flex-wrap items-center gap-0.5", className)}
43
+ className={cn("flex flex-wrap items-center gap-x-3 gap-y-1.5", className)}
43
44
  {...props}
44
45
  />
45
46
  )
@@ -49,7 +50,7 @@ export const BreadcrumbItem = ({
49
50
  ...props
50
51
  }: BreadcrumbItemProps) => (
51
52
  <li
52
- className={cn("inline-flex items-center gap-1.5 px-1", className)}
53
+ className={cn("inline-flex items-center gap-1.5", className)}
53
54
  {...props}
54
55
  />
55
56
  )
@@ -60,7 +61,9 @@ export const BreadcrumbLink = ({
60
61
  }: BreadcrumbLinkProps) => (
61
62
  <a
62
63
  className={cn(
63
- "cursor-pointer rounded-sm text-fg-secondary outline-none hover:text-fg-primary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
64
+ "cursor-pointer rounded-sm text-fg-secondary hover:text-fg-primary",
65
+ focusRingBorder,
66
+ focusRing,
64
67
  motion.colors,
65
68
  className,
66
69
  )}
@@ -82,7 +85,7 @@ export const BreadcrumbPage = ({
82
85
  export const BreadcrumbSeparator = ({
83
86
  className,
84
87
  children,
85
- variant,
88
+ variant = "slash",
86
89
  ...props
87
90
  }: BreadcrumbSeparatorProps) => (
88
91
  <li
@@ -92,7 +95,11 @@ export const BreadcrumbSeparator = ({
92
95
  {...props}
93
96
  >
94
97
  {children ??
95
- (variant === "slash" ? "/" : <IconChevronRightSmall aria-hidden />)}
98
+ (variant === "chevron" ? (
99
+ <IconChevronRightSmall aria-hidden />
100
+ ) : (
101
+ "/"
102
+ ))}
96
103
  </li>
97
104
  )
98
105
 
package/src/button.tsx CHANGED
@@ -1,22 +1,33 @@
1
1
  import { cva, type VariantProps } from "class-variance-authority"
2
2
  import type { ButtonHTMLAttributes, ReactNode } from "react"
3
3
  import { cn } from "./lib/cn"
4
+ import { focusRing, focusRingDestructive } from "./lib/focus"
4
5
 
5
6
  const buttonVariants = cva(
6
- "text-sm inline-flex cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap transition-all duration-[var(--duration-sm)] ease-enter outline-none focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:active:translate-y-0 active:translate-y-px [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
7
+ "text-sm inline-flex cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap transition-all duration-[var(--duration-sm)] ease-enter disabled:cursor-not-allowed disabled:opacity-50 disabled:active:translate-y-0 active:translate-y-px [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
7
8
  {
8
9
  variants: {
9
10
  variant: {
10
- primary:
11
- "border border-brand-primary-border bg-brand-primary text-brand-foreground inset-shadow-solid-top hover:bg-brand-primary-hover active:bg-brand-primary-active focus-visible:border-ring focus-visible:ring-ring/20",
12
- secondary:
13
- "border border-transparent bg-background-tertiary text-fg-primary hover:bg-background-quaternary active:bg-background-active focus-visible:border-ring focus-visible:ring-ring/20 dark:bg-gray-250 dark:hover:bg-gray-350 dark:active:bg-gray-200",
14
- outline:
15
- "border border-border-secondary bg-surface text-fg-primary inset-shadow-outline-top hover:bg-background-tertiary focus-visible:border-ring focus-visible:ring-ring/20",
16
- ghost:
17
- "border border-transparent text-fg-secondary hover:bg-background-tertiary hover:text-fg-primary focus-visible:border-ring focus-visible:ring-ring/20",
18
- destructive:
19
- "border border-destructive-active bg-destructive text-white inset-shadow-solid-top hover:bg-destructive-active focus-visible:border-destructive focus-visible:ring-destructive/20",
11
+ primary: cn(
12
+ "border border-brand-primary-border bg-brand-primary text-brand-foreground inset-shadow-solid-top hover:bg-brand-primary-hover active:bg-brand-primary-active",
13
+ focusRing,
14
+ ),
15
+ secondary: cn(
16
+ "border border-transparent bg-background-tertiary text-fg-primary hover:bg-background-quaternary active:bg-background-active dark:bg-gray-250 dark:hover:bg-gray-350 dark:active:bg-gray-200",
17
+ focusRing,
18
+ ),
19
+ outline: cn(
20
+ "border border-border-secondary bg-surface text-fg-primary inset-shadow-outline-top hover:bg-background-tertiary",
21
+ focusRing,
22
+ ),
23
+ ghost: cn(
24
+ "border border-transparent text-fg-secondary hover:bg-background-tertiary hover:text-fg-primary",
25
+ focusRing,
26
+ ),
27
+ destructive: cn(
28
+ "border border-destructive-active bg-destructive text-white inset-shadow-solid-top hover:bg-destructive-active",
29
+ focusRingDestructive,
30
+ ),
20
31
  },
21
32
  size: {
22
33
  sm: "h-8 px-3",
package/src/calendar.tsx CHANGED
@@ -3,6 +3,7 @@
3
3
  import { DayPicker, type ChevronProps, type DayPickerProps } from "react-day-picker"
4
4
  import { IconChevronBottom } from "./icons"
5
5
  import { cn } from "./lib/cn"
6
+ import { focusRing } from "./lib/focus"
6
7
  import { motion } from "./lib/motion"
7
8
 
8
9
  export type CalendarProps = DayPickerProps
@@ -10,7 +11,8 @@ export type CalendarProps = DayPickerProps
10
11
  const navButtonClassName = cn(
11
12
  "inline-flex size-6 items-center justify-center rounded-md border border-transparent text-fg-tertiary",
12
13
  motion.colors,
13
- "hover:bg-background-tertiary hover:text-fg-primary outline-none focus-visible:border-ring focus-visible:bg-background-tertiary focus-visible:text-fg-primary focus-visible:ring-2 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50",
14
+ focusRing,
15
+ "hover:bg-background-tertiary hover:text-fg-primary focus-visible:bg-background-tertiary focus-visible:text-fg-primary disabled:cursor-not-allowed disabled:opacity-50",
14
16
  )
15
17
 
16
18
  const CalendarChevron = ({
@@ -67,9 +69,10 @@ export const Calendar = ({
67
69
  week: "flex w-full",
68
70
  day: "relative p-0 text-center",
69
71
  day_button: cn(
70
- "inline-flex size-8 items-center justify-center rounded-lg text-sm text-fg-primary tabular-nums",
72
+ "inline-flex size-8 items-center justify-center rounded-lg border border-transparent text-sm text-fg-primary tabular-nums",
71
73
  motion.colors,
72
- "hover:bg-background-tertiary outline-none focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-selected:opacity-100",
74
+ focusRing,
75
+ "hover:bg-background-tertiary focus-visible:z-10 disabled:cursor-not-allowed disabled:opacity-50 aria-selected:opacity-100",
73
76
  ),
74
77
  selected:
75
78
  "[&>button]:bg-brand-primary [&>button]:text-brand-foreground [&>button]:ring-0 [&>button]:hover:bg-brand-primary-hover [&>button]:hover:text-brand-foreground",
package/src/carousel.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  useContext,
10
10
  useEffect,
11
11
  useRef,
12
+ useState,
12
13
  useSyncExternalStore,
13
14
  type ComponentProps,
14
15
  type KeyboardEvent,
@@ -16,22 +17,36 @@ import {
16
17
  import { Button } from "./button"
17
18
  import { IconChevronRightSmall } from "./icons"
18
19
  import { cn } from "./lib/cn"
20
+ import { focusRing, focusRingBorder } from "./lib/focus"
21
+ import { motion } from "./lib/motion"
19
22
 
20
23
  type CarouselApi = UseEmblaCarouselType[1]
21
24
  type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
22
25
  type CarouselOptions = UseCarouselParameters[0]
23
26
  type CarouselPlugin = UseCarouselParameters[1]
24
27
 
25
- const TRACKPAD_SWIPE_THRESHOLD = 24
26
- const TRACKPAD_GESTURE_END_DELAY = 140
28
+ const TRACKPAD_AXIS_MIN = 2
29
+ /**
30
+ * Allow ~30° off a pure horizontal swipe. Mac trackpads often emit a
31
+ * mixed deltaX/deltaY even when the gesture reads as sideways.
32
+ */
33
+ const TRACKPAD_HORIZONTAL_RATIO = 0.55
34
+ const TRACKPAD_SWIPE_THRESHOLD = 20
35
+ const TRACKPAD_GESTURE_END_DELAY = 100
36
+ /** Match Embla’s in-drag body so free wheel tracks the gesture. */
37
+ const FREE_WHEEL_FRICTION = 0.68
38
+ const FREE_WHEEL_DURATION = 22
27
39
 
28
40
  type CarouselContextValue = {
29
41
  carouselRef: ReturnType<typeof useEmblaCarousel>[0]
30
42
  api: CarouselApi
31
43
  scrollPrev: () => void
32
44
  scrollNext: () => void
45
+ scrollTo: (index: number) => void
33
46
  canScrollPrev: boolean
34
47
  canScrollNext: boolean
48
+ selectedIndex: number
49
+ scrollSnaps: number[]
35
50
  orientation: "horizontal" | "vertical"
36
51
  }
37
52
 
@@ -54,10 +69,21 @@ export type CarouselProps = ComponentProps<"div"> & {
54
69
  fade?: boolean
55
70
  }
56
71
 
57
- export type CarouselContentProps = ComponentProps<"div">
72
+ export type CarouselContentProps = ComponentProps<"div"> & {
73
+ /** Classes for the overflow viewport (Embla root), not the slide track. */
74
+ viewportClassName?: string
75
+ }
58
76
  export type CarouselItemProps = ComponentProps<"div">
59
77
  export type CarouselPreviousProps = ComponentProps<typeof Button>
60
78
  export type CarouselNextProps = ComponentProps<typeof Button>
79
+ export type CarouselDotsProps = ComponentProps<"div">
80
+
81
+ const carouselControlClassName = cn(
82
+ "absolute z-20 !size-6 border border-border-primary/40 bg-surface/55 text-fg-primary shadow-md backdrop-blur-md [&_svg:not([class*='size-'])]:!size-3.5",
83
+ "hover:bg-surface/80 hover:text-fg-primary",
84
+ "disabled:pointer-events-none disabled:opacity-0",
85
+ motion.colors,
86
+ )
61
87
 
62
88
  const CarouselEdgeFades = () => {
63
89
  const { orientation, canScrollPrev, canScrollNext } = useCarousel()
@@ -106,14 +132,22 @@ export const Carousel = ({
106
132
  ref: forwardedRef,
107
133
  ...props
108
134
  }: CarouselProps) => {
109
- const carouselRootRef = useRef<HTMLDivElement | null>(null)
135
+ const [carouselRoot, setCarouselRoot] = useState<HTMLDivElement | null>(null)
110
136
  const [carouselRef, api] = useEmblaCarousel(
111
137
  {
138
+ align: "start",
139
+ containScroll: "trimSnaps",
140
+ dragFree: true,
141
+ duration: 20,
112
142
  ...opts,
113
143
  axis: orientation === "horizontal" ? "x" : "y",
114
144
  },
115
145
  plugins,
116
146
  )
147
+ const apiRef = useRef(api)
148
+ useEffect(() => {
149
+ apiRef.current = api
150
+ }, [api])
117
151
  // Embla is an external store, so read from it directly rather than mirroring
118
152
  // it into state. Seeding mirrored state meant calling setState synchronously
119
153
  // inside an effect, costing an extra render on mount and on every slide
@@ -124,9 +158,11 @@ export const Carousel = ({
124
158
  if (!api) return () => {}
125
159
  api.on("reInit", onStoreChange)
126
160
  api.on("select", onStoreChange)
161
+ api.on("scroll", onStoreChange)
127
162
  return () => {
128
163
  api.off("reInit", onStoreChange)
129
164
  api.off("select", onStoreChange)
165
+ api.off("scroll", onStoreChange)
130
166
  }
131
167
  },
132
168
  [api],
@@ -142,6 +178,29 @@ export const Carousel = ({
142
178
  () => api?.canScrollNext() ?? false,
143
179
  () => false,
144
180
  )
181
+ const selectedIndex = useSyncExternalStore(
182
+ subscribe,
183
+ () => api?.selectedScrollSnap() ?? 0,
184
+ () => 0,
185
+ )
186
+
187
+ const scrollSnapsCache = useRef<number[]>([])
188
+ const scrollSnaps = useSyncExternalStore(
189
+ subscribe,
190
+ () => {
191
+ const next = api?.scrollSnapList() ?? []
192
+ const prev = scrollSnapsCache.current
193
+ if (
194
+ prev.length === next.length &&
195
+ prev.every((value, index) => value === next[index])
196
+ ) {
197
+ return prev
198
+ }
199
+ scrollSnapsCache.current = next
200
+ return next
201
+ },
202
+ () => scrollSnapsCache.current,
203
+ )
145
204
 
146
205
  const scrollPrev = useCallback(() => {
147
206
  api?.scrollPrev()
@@ -151,24 +210,30 @@ export const Carousel = ({
151
210
  api?.scrollNext()
152
211
  }, [api])
153
212
 
154
- const canScrollPrevRef = useRef(canScrollPrev)
155
- const canScrollNextRef = useRef(canScrollNext)
156
- const scrollPrevRef = useRef(scrollPrev)
157
- const scrollNextRef = useRef(scrollNext)
158
-
159
- useEffect(() => {
160
- canScrollPrevRef.current = canScrollPrev
161
- canScrollNextRef.current = canScrollNext
162
- scrollPrevRef.current = scrollPrev
163
- scrollNextRef.current = scrollNext
164
- }, [canScrollNext, canScrollPrev, scrollNext, scrollPrev])
213
+ const scrollTo = useCallback(
214
+ (index: number) => {
215
+ api?.scrollTo(index)
216
+ },
217
+ [api],
218
+ )
165
219
 
166
220
  const trackpadDelta = useRef(0)
167
221
  const trackpadGestureHandled = useRef(false)
222
+ const trackpadGestureDirection = useRef(0)
168
223
  const trackpadGestureTimeout = useRef<ReturnType<typeof setTimeout> | null>(
169
224
  null,
170
225
  )
171
226
 
227
+ const clearTrackpadGesture = useCallback(() => {
228
+ trackpadDelta.current = 0
229
+ trackpadGestureHandled.current = false
230
+ trackpadGestureDirection.current = 0
231
+ if (trackpadGestureTimeout.current) {
232
+ clearTimeout(trackpadGestureTimeout.current)
233
+ trackpadGestureTimeout.current = null
234
+ }
235
+ }, [])
236
+
172
237
  const scheduleTrackpadGestureEnd = useCallback(() => {
173
238
  if (trackpadGestureTimeout.current) {
174
239
  clearTimeout(trackpadGestureTimeout.current)
@@ -176,6 +241,7 @@ export const Carousel = ({
176
241
  trackpadGestureTimeout.current = setTimeout(() => {
177
242
  trackpadDelta.current = 0
178
243
  trackpadGestureHandled.current = false
244
+ trackpadGestureDirection.current = 0
179
245
  trackpadGestureTimeout.current = null
180
246
  }, TRACKPAD_GESTURE_END_DELAY)
181
247
  }, [])
@@ -201,60 +267,106 @@ export const Carousel = ({
201
267
 
202
268
  const deltaMultiplier =
203
269
  event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1
204
- const horizontalDelta = event.deltaX * deltaMultiplier
205
- const verticalDelta = event.deltaY * deltaMultiplier
206
-
207
- if (
208
- Math.abs(horizontalDelta) <= Math.abs(verticalDelta) ||
209
- Math.abs(horizontalDelta) < 1
270
+ const rawX = event.deltaX * deltaMultiplier
271
+ const rawY = event.deltaY * deltaMultiplier
272
+ const absX = Math.abs(rawX)
273
+ const absY = Math.abs(rawY)
274
+
275
+ // Only claim sideways gestures. Vertical trackpad scroll must pass
276
+ // through to the page. Shift+wheel is the mouse chord for horizontal.
277
+ let scrollDelta = 0
278
+ if (event.shiftKey && absY >= TRACKPAD_AXIS_MIN) {
279
+ scrollDelta = rawY
280
+ } else if (
281
+ absX >= TRACKPAD_AXIS_MIN &&
282
+ absX > absY * TRACKPAD_HORIZONTAL_RATIO
210
283
  ) {
284
+ scrollDelta = rawX
285
+ } else {
211
286
  return
212
287
  }
213
288
 
214
- if (trackpadGestureHandled.current) {
289
+ const carouselApi = apiRef.current
290
+ if (!carouselApi) return
291
+
292
+ const engine = carouselApi.internalEngine()
293
+
294
+ // Free-scroll strip (Apple gallery style): scrub continuously and clamp
295
+ // to Embla limits so the first/last slides never rest past the edge.
296
+ if (engine.options.dragFree) {
297
+ const nextTarget = engine.limit.constrain(
298
+ engine.target.get() - scrollDelta,
299
+ )
300
+ if (nextTarget === engine.target.get()) {
301
+ if (engine.limit.reachedAny(engine.target.get())) {
302
+ event.preventDefault()
303
+ }
304
+ return
305
+ }
306
+
215
307
  event.preventDefault()
216
- scheduleTrackpadGestureEnd()
308
+ engine.scrollBody
309
+ .useFriction(FREE_WHEEL_FRICTION)
310
+ .useDuration(FREE_WHEEL_DURATION)
311
+ engine.scrollTo.distance(nextTarget - engine.target.get(), false)
217
312
  return
218
313
  }
219
314
 
220
315
  const canScrollInDirection =
221
- horizontalDelta > 0
222
- ? canScrollNextRef.current
223
- : canScrollPrevRef.current
316
+ scrollDelta > 0
317
+ ? carouselApi.canScrollNext()
318
+ : carouselApi.canScrollPrev()
224
319
  if (!canScrollInDirection) return
225
320
 
226
321
  event.preventDefault()
227
- trackpadDelta.current += horizontalDelta
322
+
323
+ // Snapping carousels (e.g. lightbox): one slide per gesture.
324
+ // Same-direction inertia is ignored; reverse unlocks immediately.
325
+ const direction = scrollDelta > 0 ? 1 : -1
326
+ if (trackpadGestureHandled.current) {
327
+ if (direction === trackpadGestureDirection.current) {
328
+ scheduleTrackpadGestureEnd()
329
+ return
330
+ }
331
+ clearTrackpadGesture()
332
+ }
333
+
334
+ trackpadDelta.current += scrollDelta
228
335
  scheduleTrackpadGestureEnd()
229
336
 
230
337
  if (Math.abs(trackpadDelta.current) < TRACKPAD_SWIPE_THRESHOLD) return
231
338
 
232
339
  trackpadGestureHandled.current = true
340
+ trackpadGestureDirection.current = direction
233
341
  trackpadDelta.current = 0
234
- if (horizontalDelta > 0) scrollNextRef.current()
235
- else scrollPrevRef.current()
342
+ if (direction > 0) carouselApi.scrollNext()
343
+ else carouselApi.scrollPrev()
236
344
  },
237
- [orientation, scheduleTrackpadGestureEnd],
345
+ [
346
+ clearTrackpadGesture,
347
+ orientation,
348
+ scheduleTrackpadGestureEnd,
349
+ ],
238
350
  )
239
351
 
240
352
  useEffect(() => {
241
- const root = carouselRootRef.current
242
- if (!root) return
353
+ if (!carouselRoot) return
243
354
 
244
- root.addEventListener("wheel", handleWheel, { passive: false })
355
+ carouselRoot.addEventListener("wheel", handleWheel, {
356
+ passive: false,
357
+ capture: true,
358
+ })
245
359
  return () => {
246
- root.removeEventListener("wheel", handleWheel)
247
- if (trackpadGestureTimeout.current) {
248
- clearTimeout(trackpadGestureTimeout.current)
249
- }
250
- trackpadDelta.current = 0
251
- trackpadGestureHandled.current = false
360
+ carouselRoot.removeEventListener("wheel", handleWheel, {
361
+ capture: true,
362
+ })
363
+ clearTrackpadGesture()
252
364
  }
253
- }, [handleWheel])
365
+ }, [carouselRoot, clearTrackpadGesture, handleWheel])
254
366
 
255
367
  const setCarouselRootRef = useCallback(
256
368
  (node: HTMLDivElement | null) => {
257
- carouselRootRef.current = node
369
+ setCarouselRoot(node)
258
370
  if (typeof forwardedRef === "function") forwardedRef(node)
259
371
  else if (forwardedRef) forwardedRef.current = node
260
372
  },
@@ -273,8 +385,11 @@ export const Carousel = ({
273
385
  api,
274
386
  scrollPrev,
275
387
  scrollNext,
388
+ scrollTo,
276
389
  canScrollPrev,
277
390
  canScrollNext,
391
+ selectedIndex,
392
+ scrollSnaps,
278
393
  orientation,
279
394
  }}
280
395
  >
@@ -287,7 +402,11 @@ export const Carousel = ({
287
402
  className={cn(
288
403
  "relative",
289
404
  fade &&
290
- "rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
405
+ cn(
406
+ "rounded-xl",
407
+ focusRingBorder,
408
+ focusRing,
409
+ ),
291
410
  className,
292
411
  )}
293
412
  onKeyDownCapture={handleKeyDown}
@@ -303,6 +422,7 @@ export const Carousel = ({
303
422
 
304
423
  export const CarouselContent = ({
305
424
  className,
425
+ viewportClassName,
306
426
  ...props
307
427
  }: CarouselContentProps) => {
308
428
  const { carouselRef, orientation } = useCarousel()
@@ -311,10 +431,11 @@ export const CarouselContent = ({
311
431
  <div
312
432
  ref={carouselRef}
313
433
  className={cn(
314
- "overflow-hidden",
434
+ "cursor-grab overflow-hidden active:cursor-grabbing",
315
435
  orientation === "horizontal"
316
436
  ? "touch-pan-y overscroll-x-contain"
317
437
  : "touch-pan-x overscroll-y-contain",
438
+ viewportClassName,
318
439
  )}
319
440
  data-slot="carousel-viewport"
320
441
  >
@@ -322,6 +443,8 @@ export const CarouselContent = ({
322
443
  data-slot="carousel-content"
323
444
  className={cn(
324
445
  "flex",
446
+ // Start spacing so the first slide lines up with the page measure.
447
+ // No end padding — the strip bleeds to the track edge (Apple gallery).
325
448
  orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
326
449
  className,
327
450
  )}
@@ -340,8 +463,12 @@ export const CarouselItem = ({ className, ...props }: CarouselItemProps) => {
340
463
  aria-roledescription="slide"
341
464
  data-slot="carousel-item"
342
465
  className={cn(
343
- "min-w-0 shrink-0 grow-0 basis-full",
344
- orientation === "horizontal" ? "pl-4" : "pt-4",
466
+ "min-w-0 shrink-0 grow-0",
467
+ // Peek the next card by default — override with basis-full when a
468
+ // single focused slide is required (e.g. lightbox).
469
+ orientation === "horizontal"
470
+ ? "basis-[92%] pl-4 sm:basis-[72%] lg:basis-[62%]"
471
+ : "basis-full pt-4",
345
472
  className,
346
473
  )}
347
474
  {...props}
@@ -352,7 +479,7 @@ export const CarouselItem = ({ className, ...props }: CarouselItemProps) => {
352
479
  export const CarouselPrevious = ({
353
480
  className,
354
481
  variant = "ghost",
355
- size = "md",
482
+ size = "sm",
356
483
  ...props
357
484
  }: CarouselPreviousProps) => {
358
485
  const { orientation, scrollPrev, canScrollPrev } = useCarousel()
@@ -368,10 +495,10 @@ export const CarouselPrevious = ({
368
495
  aria-label="Previous slide"
369
496
  data-slot="carousel-previous"
370
497
  className={cn(
371
- "absolute size-9 border-0 focus-visible:border-0",
498
+ carouselControlClassName,
372
499
  orientation === "horizontal"
373
- ? "top-1/2 -left-12 -translate-y-1/2 active:!-translate-y-1/2"
374
- : "-top-12 left-1/2 -translate-x-1/2 rotate-90 active:!-translate-x-1/2 active:!translate-y-0",
500
+ ? "top-1/2 left-3 -translate-y-1/2 active:!-translate-y-1/2"
501
+ : "top-3 left-1/2 -translate-x-1/2 rotate-90 active:!-translate-x-1/2 active:!translate-y-0",
375
502
  className,
376
503
  )}
377
504
  onClick={scrollPrev}
@@ -385,7 +512,7 @@ export const CarouselPrevious = ({
385
512
  export const CarouselNext = ({
386
513
  className,
387
514
  variant = "ghost",
388
- size = "md",
515
+ size = "sm",
389
516
  ...props
390
517
  }: CarouselNextProps) => {
391
518
  const { orientation, scrollNext, canScrollNext } = useCarousel()
@@ -401,10 +528,10 @@ export const CarouselNext = ({
401
528
  aria-label="Next slide"
402
529
  data-slot="carousel-next"
403
530
  className={cn(
404
- "absolute size-9 border-0 focus-visible:border-0",
531
+ carouselControlClassName,
405
532
  orientation === "horizontal"
406
- ? "top-1/2 -right-12 -translate-y-1/2 active:!-translate-y-1/2"
407
- : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90 active:!-translate-x-1/2 active:!translate-y-0",
533
+ ? "top-1/2 right-3 -translate-y-1/2 active:!-translate-y-1/2"
534
+ : "bottom-3 left-1/2 -translate-x-1/2 rotate-90 active:!-translate-x-1/2 active:!translate-y-0",
408
535
  className,
409
536
  )}
410
537
  onClick={scrollNext}
@@ -415,5 +542,45 @@ export const CarouselNext = ({
415
542
  )
416
543
  }
417
544
 
545
+ export const CarouselDots = ({ className, ...props }: CarouselDotsProps) => {
546
+ const { scrollSnaps, selectedIndex, scrollTo } = useCarousel()
547
+
548
+ if (scrollSnaps.length <= 1) return null
549
+
550
+ return (
551
+ <div
552
+ data-slot="carousel-dots"
553
+ role="tablist"
554
+ aria-label="Slide indicators"
555
+ className={cn("mt-4 flex items-center justify-start gap-1 pl-4", className)}
556
+ {...props}
557
+ >
558
+ {scrollSnaps.map((_, index) => {
559
+ const isSelected = index === selectedIndex
560
+
561
+ return (
562
+ <button
563
+ key={index}
564
+ type="button"
565
+ role="tab"
566
+ aria-label={`Go to slide ${index + 1}`}
567
+ aria-selected={isSelected}
568
+ data-selected={isSelected ? "" : undefined}
569
+ className={cn(
570
+ "h-1.5 cursor-pointer rounded-full outline-none",
571
+ motion.all,
572
+ "focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background-primary",
573
+ isSelected
574
+ ? "w-5 bg-fg-tertiary"
575
+ : "w-1.5 bg-fg-quaternary/50 hover:bg-fg-quaternary",
576
+ )}
577
+ onClick={() => scrollTo(index)}
578
+ />
579
+ )
580
+ })}
581
+ </div>
582
+ )
583
+ }
584
+
418
585
  export type { CarouselApi }
419
586
  export { useCarousel }
package/src/checkbox.tsx CHANGED
@@ -4,6 +4,7 @@ import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"
4
4
  import type { ComponentProps } from "react"
5
5
  import { IconCheckmark1, IconMinus } from "./icons"
6
6
  import { cn } from "./lib/cn"
7
+ import { focusRing } from "./lib/focus"
7
8
  import { motion } from "./lib/motion"
8
9
 
9
10
  export type CheckboxProps = ComponentProps<typeof BaseCheckbox.Root>
@@ -11,11 +12,11 @@ export type CheckboxProps = ComponentProps<typeof BaseCheckbox.Root>
11
12
  export const Checkbox = ({ className, ...props }: CheckboxProps) => (
12
13
  <BaseCheckbox.Root
13
14
  className={cn(
14
- "group flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-secondary bg-surface outline-none",
15
+ "group flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-secondary bg-surface",
15
16
  // motion.all, not motion.colors: the filled state brings a box shadow
16
17
  // with it, which has to fade in alongside the background.
17
18
  motion.all,
18
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
19
+ focusRing,
19
20
  // Ticked and mixed are both filled controls, so they carry the primary
20
21
  // button's treatment: brand fill, the border token that pairs with it,
21
22
  // and the top-edge highlight that makes a solid control read as raised.