@boyernick/standard-ui-react 0.1.1-canary.3 → 0.1.1-canary.34

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 (89) 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 +12 -9
  6. package/src/badge.tsx +73 -9
  7. package/src/block-editor.tsx +1747 -0
  8. package/src/brand.tsx +2 -2
  9. package/src/breadcrumb.tsx +46 -21
  10. package/src/button.tsx +23 -12
  11. package/src/calendar.tsx +25 -11
  12. package/src/card.tsx +57 -14
  13. package/src/carousel.tsx +339 -15
  14. package/src/checkbox.tsx +36 -13
  15. package/src/code-block.tsx +66 -5
  16. package/src/collapsible.tsx +7 -3
  17. package/src/combobox.tsx +17 -15
  18. package/src/command.tsx +41 -14
  19. package/src/context-menu.tsx +6 -3
  20. package/src/date-picker.tsx +174 -0
  21. package/src/dialog.tsx +33 -10
  22. package/src/empty.tsx +4 -4
  23. package/src/field.tsx +4 -3
  24. package/src/filter-group.tsx +143 -0
  25. package/src/icons.tsx +55 -1
  26. package/src/illustrations.tsx +219 -141
  27. package/src/index.ts +263 -18
  28. package/src/input.tsx +8 -5
  29. package/src/kbd.tsx +49 -0
  30. package/src/lib/focus.ts +41 -0
  31. package/src/lib/motion.ts +16 -1
  32. package/src/lib/popup.ts +45 -0
  33. package/src/lifeline/company-icon.tsx +71 -0
  34. package/src/lifeline/index.ts +42 -0
  35. package/src/lifeline/lifeline-data.ts +97 -0
  36. package/src/lifeline/lifeline-desktop.tsx +204 -0
  37. package/src/lifeline/lifeline-event.tsx +108 -0
  38. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  39. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  40. package/src/lifeline/lifeline-icons.tsx +39 -0
  41. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  42. package/src/lifeline/lifeline-labels.tsx +24 -0
  43. package/src/lifeline/lifeline-layout.ts +1 -0
  44. package/src/lifeline/lifeline-legend.tsx +31 -0
  45. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  46. package/src/lifeline/lifeline-marker.tsx +183 -0
  47. package/src/lifeline/lifeline-people.tsx +99 -0
  48. package/src/lifeline/lifeline-photos.tsx +366 -0
  49. package/src/lifeline/lifeline-shell.tsx +135 -0
  50. package/src/lifeline/lifeline-utils.ts +83 -0
  51. package/src/lifeline/lifeline-vertical.tsx +482 -0
  52. package/src/lifeline/lifeline.tsx +69 -0
  53. package/src/lifeline/types.ts +112 -0
  54. package/src/lifeline/use-lifeline-intro.ts +130 -0
  55. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  56. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  57. package/src/menu.tsx +7 -4
  58. package/src/menubar.tsx +9 -2
  59. package/src/minimap.tsx +296 -0
  60. package/src/modal.tsx +608 -0
  61. package/src/navigation-menu.tsx +340 -67
  62. package/src/number-field.tsx +336 -61
  63. package/src/otp-field.tsx +4 -3
  64. package/src/pagination.tsx +262 -42
  65. package/src/password-protection.tsx +345 -0
  66. package/src/popover.tsx +3 -1
  67. package/src/preview-card.tsx +3 -1
  68. package/src/progress.tsx +5 -0
  69. package/src/questionnaire.tsx +284 -0
  70. package/src/radio.tsx +4 -2
  71. package/src/scroll-area.tsx +4 -1
  72. package/src/select.tsx +12 -7
  73. package/src/sidebar.tsx +113 -28
  74. package/src/slider.tsx +66 -6
  75. package/src/sounds.tsx +6 -10
  76. package/src/spinner.tsx +105 -32
  77. package/src/switch.tsx +4 -1
  78. package/src/table.tsx +82 -15
  79. package/src/tabs.tsx +189 -38
  80. package/src/text-animate.tsx +71 -28
  81. package/src/textarea.tsx +6 -1
  82. package/src/timeline.tsx +378 -0
  83. package/src/toast.tsx +214 -35
  84. package/src/toggle.tsx +4 -2
  85. package/src/toolbar.tsx +12 -7
  86. package/src/tooltip.tsx +43 -3
  87. package/src/video-player.tsx +396 -127
  88. package/src/image-modal.tsx +0 -110
  89. package/src/markdown-editor.tsx +0 -302
package/src/carousel.tsx CHANGED
@@ -8,6 +8,8 @@ import {
8
8
  useCallback,
9
9
  useContext,
10
10
  useEffect,
11
+ useRef,
12
+ useState,
11
13
  useSyncExternalStore,
12
14
  type ComponentProps,
13
15
  type KeyboardEvent,
@@ -15,19 +17,36 @@ import {
15
17
  import { Button } from "./button"
16
18
  import { IconChevronRightSmall } from "./icons"
17
19
  import { cn } from "./lib/cn"
20
+ import { focusRing, focusRingBorder } from "./lib/focus"
21
+ import { motion } from "./lib/motion"
18
22
 
19
23
  type CarouselApi = UseEmblaCarouselType[1]
20
24
  type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
21
25
  type CarouselOptions = UseCarouselParameters[0]
22
26
  type CarouselPlugin = UseCarouselParameters[1]
23
27
 
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
39
+
24
40
  type CarouselContextValue = {
25
41
  carouselRef: ReturnType<typeof useEmblaCarousel>[0]
26
42
  api: CarouselApi
27
43
  scrollPrev: () => void
28
44
  scrollNext: () => void
45
+ scrollTo: (index: number) => void
29
46
  canScrollPrev: boolean
30
47
  canScrollNext: boolean
48
+ selectedIndex: number
49
+ scrollSnaps: number[]
31
50
  orientation: "horizontal" | "vertical"
32
51
  }
33
52
 
@@ -46,30 +65,89 @@ export type CarouselProps = ComponentProps<"div"> & {
46
65
  plugins?: CarouselPlugin
47
66
  orientation?: "horizontal" | "vertical"
48
67
  setApi?: (api: CarouselApi) => void
68
+ /** Adds directional edge fades wherever more slides are available. */
69
+ fade?: boolean
49
70
  }
50
71
 
51
- 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
+ }
52
76
  export type CarouselItemProps = ComponentProps<"div">
53
77
  export type CarouselPreviousProps = ComponentProps<typeof Button>
54
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
+ )
87
+
88
+ const CarouselEdgeFades = () => {
89
+ const { orientation, canScrollPrev, canScrollNext } = useCarousel()
90
+ const horizontal = orientation === "horizontal"
91
+ const sharedClassName =
92
+ "pointer-events-none absolute z-10 opacity-0 transition-opacity duration-[var(--duration-lg)] ease-enter data-[visible=true]:opacity-100 motion-reduce:transition-none"
93
+
94
+ return (
95
+ <>
96
+ <div
97
+ aria-hidden="true"
98
+ data-slot={horizontal ? "carousel-fade-left" : "carousel-fade-top"}
99
+ data-visible={canScrollPrev}
100
+ className={cn(
101
+ sharedClassName,
102
+ horizontal
103
+ ? "-top-px -bottom-px -left-px w-20 bg-linear-to-r from-surface via-surface/60 via-40% to-transparent"
104
+ : "-top-px -right-px -left-px h-20 bg-linear-to-b from-surface via-surface/60 via-40% to-transparent",
105
+ )}
106
+ />
107
+ <div
108
+ aria-hidden="true"
109
+ data-slot={horizontal ? "carousel-fade-right" : "carousel-fade-bottom"}
110
+ data-visible={canScrollNext}
111
+ className={cn(
112
+ sharedClassName,
113
+ horizontal
114
+ ? "-top-px -right-px -bottom-px w-20 bg-linear-to-l from-surface via-surface/60 via-40% to-transparent"
115
+ : "-right-px -bottom-px -left-px h-20 bg-linear-to-t from-surface via-surface/60 via-40% to-transparent",
116
+ )}
117
+ />
118
+ </>
119
+ )
120
+ }
55
121
 
56
122
  export const Carousel = ({
57
123
  orientation = "horizontal",
124
+ fade = false,
58
125
  opts,
59
126
  setApi,
60
127
  plugins,
61
128
  className,
62
129
  children,
63
130
  onKeyDownCapture,
131
+ tabIndex,
132
+ ref: forwardedRef,
64
133
  ...props
65
134
  }: CarouselProps) => {
135
+ const [carouselRoot, setCarouselRoot] = useState<HTMLDivElement | null>(null)
66
136
  const [carouselRef, api] = useEmblaCarousel(
67
137
  {
138
+ align: "start",
139
+ containScroll: "trimSnaps",
140
+ dragFree: true,
141
+ duration: 20,
68
142
  ...opts,
69
143
  axis: orientation === "horizontal" ? "x" : "y",
70
144
  },
71
145
  plugins,
72
146
  )
147
+ const apiRef = useRef(api)
148
+ useEffect(() => {
149
+ apiRef.current = api
150
+ }, [api])
73
151
  // Embla is an external store, so read from it directly rather than mirroring
74
152
  // it into state. Seeding mirrored state meant calling setState synchronously
75
153
  // inside an effect, costing an extra render on mount and on every slide
@@ -80,9 +158,11 @@ export const Carousel = ({
80
158
  if (!api) return () => {}
81
159
  api.on("reInit", onStoreChange)
82
160
  api.on("select", onStoreChange)
161
+ api.on("scroll", onStoreChange)
83
162
  return () => {
84
163
  api.off("reInit", onStoreChange)
85
164
  api.off("select", onStoreChange)
165
+ api.off("scroll", onStoreChange)
86
166
  }
87
167
  },
88
168
  [api],
@@ -98,6 +178,29 @@ export const Carousel = ({
98
178
  () => api?.canScrollNext() ?? false,
99
179
  () => false,
100
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
+ )
101
204
 
102
205
  const scrollPrev = useCallback(() => {
103
206
  api?.scrollPrev()
@@ -107,6 +210,42 @@ export const Carousel = ({
107
210
  api?.scrollNext()
108
211
  }, [api])
109
212
 
213
+ const scrollTo = useCallback(
214
+ (index: number) => {
215
+ api?.scrollTo(index)
216
+ },
217
+ [api],
218
+ )
219
+
220
+ const trackpadDelta = useRef(0)
221
+ const trackpadGestureHandled = useRef(false)
222
+ const trackpadGestureDirection = useRef(0)
223
+ const trackpadGestureTimeout = useRef<ReturnType<typeof setTimeout> | null>(
224
+ null,
225
+ )
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
+
237
+ const scheduleTrackpadGestureEnd = useCallback(() => {
238
+ if (trackpadGestureTimeout.current) {
239
+ clearTimeout(trackpadGestureTimeout.current)
240
+ }
241
+ trackpadGestureTimeout.current = setTimeout(() => {
242
+ trackpadDelta.current = 0
243
+ trackpadGestureHandled.current = false
244
+ trackpadGestureDirection.current = 0
245
+ trackpadGestureTimeout.current = null
246
+ }, TRACKPAD_GESTURE_END_DELAY)
247
+ }, [])
248
+
110
249
  const handleKeyDown = useCallback(
111
250
  (event: KeyboardEvent<HTMLDivElement>) => {
112
251
  onKeyDownCapture?.(event)
@@ -122,6 +261,118 @@ export const Carousel = ({
122
261
  [onKeyDownCapture, scrollNext, scrollPrev],
123
262
  )
124
263
 
264
+ const handleWheel = useCallback(
265
+ (event: globalThis.WheelEvent) => {
266
+ if (event.defaultPrevented || orientation !== "horizontal") return
267
+
268
+ const deltaMultiplier =
269
+ event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 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
283
+ ) {
284
+ scrollDelta = rawX
285
+ } else {
286
+ return
287
+ }
288
+
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
+
307
+ event.preventDefault()
308
+ engine.scrollBody
309
+ .useFriction(FREE_WHEEL_FRICTION)
310
+ .useDuration(FREE_WHEEL_DURATION)
311
+ engine.scrollTo.distance(nextTarget - engine.target.get(), false)
312
+ return
313
+ }
314
+
315
+ const canScrollInDirection =
316
+ scrollDelta > 0
317
+ ? carouselApi.canScrollNext()
318
+ : carouselApi.canScrollPrev()
319
+ if (!canScrollInDirection) return
320
+
321
+ event.preventDefault()
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
335
+ scheduleTrackpadGestureEnd()
336
+
337
+ if (Math.abs(trackpadDelta.current) < TRACKPAD_SWIPE_THRESHOLD) return
338
+
339
+ trackpadGestureHandled.current = true
340
+ trackpadGestureDirection.current = direction
341
+ trackpadDelta.current = 0
342
+ if (direction > 0) carouselApi.scrollNext()
343
+ else carouselApi.scrollPrev()
344
+ },
345
+ [
346
+ clearTrackpadGesture,
347
+ orientation,
348
+ scheduleTrackpadGestureEnd,
349
+ ],
350
+ )
351
+
352
+ useEffect(() => {
353
+ if (!carouselRoot) return
354
+
355
+ carouselRoot.addEventListener("wheel", handleWheel, {
356
+ passive: false,
357
+ capture: true,
358
+ })
359
+ return () => {
360
+ carouselRoot.removeEventListener("wheel", handleWheel, {
361
+ capture: true,
362
+ })
363
+ clearTrackpadGesture()
364
+ }
365
+ }, [carouselRoot, clearTrackpadGesture, handleWheel])
366
+
367
+ const setCarouselRootRef = useCallback(
368
+ (node: HTMLDivElement | null) => {
369
+ setCarouselRoot(node)
370
+ if (typeof forwardedRef === "function") forwardedRef(node)
371
+ else if (forwardedRef) forwardedRef.current = node
372
+ },
373
+ [forwardedRef],
374
+ )
375
+
125
376
  useEffect(() => {
126
377
  if (!api || !setApi) return
127
378
  setApi(api)
@@ -134,20 +385,36 @@ export const Carousel = ({
134
385
  api,
135
386
  scrollPrev,
136
387
  scrollNext,
388
+ scrollTo,
137
389
  canScrollPrev,
138
390
  canScrollNext,
391
+ selectedIndex,
392
+ scrollSnaps,
139
393
  orientation,
140
394
  }}
141
395
  >
142
396
  <div
397
+ ref={setCarouselRootRef}
143
398
  role="region"
144
399
  aria-roledescription="carousel"
145
400
  data-slot="carousel"
146
- className={cn("relative", className)}
401
+ data-fade={fade || undefined}
402
+ className={cn(
403
+ "relative",
404
+ fade &&
405
+ cn(
406
+ "rounded-xl",
407
+ focusRingBorder,
408
+ focusRing,
409
+ ),
410
+ className,
411
+ )}
147
412
  onKeyDownCapture={handleKeyDown}
413
+ tabIndex={tabIndex ?? (fade ? 0 : undefined)}
148
414
  {...props}
149
415
  >
150
416
  {children}
417
+ {fade ? <CarouselEdgeFades /> : null}
151
418
  </div>
152
419
  </CarouselContext.Provider>
153
420
  )
@@ -155,16 +422,29 @@ export const Carousel = ({
155
422
 
156
423
  export const CarouselContent = ({
157
424
  className,
425
+ viewportClassName,
158
426
  ...props
159
427
  }: CarouselContentProps) => {
160
428
  const { carouselRef, orientation } = useCarousel()
161
429
 
162
430
  return (
163
- <div ref={carouselRef} className="overflow-hidden" data-slot="carousel-viewport">
431
+ <div
432
+ ref={carouselRef}
433
+ className={cn(
434
+ "cursor-grab overflow-hidden active:cursor-grabbing",
435
+ orientation === "horizontal"
436
+ ? "touch-pan-y overscroll-x-contain"
437
+ : "touch-pan-x overscroll-y-contain",
438
+ viewportClassName,
439
+ )}
440
+ data-slot="carousel-viewport"
441
+ >
164
442
  <div
165
443
  data-slot="carousel-content"
166
444
  className={cn(
167
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).
168
448
  orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
169
449
  className,
170
450
  )}
@@ -183,8 +463,12 @@ export const CarouselItem = ({ className, ...props }: CarouselItemProps) => {
183
463
  aria-roledescription="slide"
184
464
  data-slot="carousel-item"
185
465
  className={cn(
186
- "min-w-0 shrink-0 grow-0 basis-full",
187
- 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",
188
472
  className,
189
473
  )}
190
474
  {...props}
@@ -194,8 +478,8 @@ export const CarouselItem = ({ className, ...props }: CarouselItemProps) => {
194
478
 
195
479
  export const CarouselPrevious = ({
196
480
  className,
197
- variant = "outline",
198
- size = "md",
481
+ variant = "ghost",
482
+ size = "sm",
199
483
  ...props
200
484
  }: CarouselPreviousProps) => {
201
485
  const { orientation, scrollPrev, canScrollPrev } = useCarousel()
@@ -211,10 +495,10 @@ export const CarouselPrevious = ({
211
495
  aria-label="Previous slide"
212
496
  data-slot="carousel-previous"
213
497
  className={cn(
214
- "absolute size-9",
498
+ carouselControlClassName,
215
499
  orientation === "horizontal"
216
- ? "top-1/2 -left-12 -translate-y-1/2 active:!-translate-y-1/2"
217
- : "-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",
218
502
  className,
219
503
  )}
220
504
  onClick={scrollPrev}
@@ -227,8 +511,8 @@ export const CarouselPrevious = ({
227
511
 
228
512
  export const CarouselNext = ({
229
513
  className,
230
- variant = "outline",
231
- size = "md",
514
+ variant = "ghost",
515
+ size = "sm",
232
516
  ...props
233
517
  }: CarouselNextProps) => {
234
518
  const { orientation, scrollNext, canScrollNext } = useCarousel()
@@ -244,10 +528,10 @@ export const CarouselNext = ({
244
528
  aria-label="Next slide"
245
529
  data-slot="carousel-next"
246
530
  className={cn(
247
- "absolute size-9",
531
+ carouselControlClassName,
248
532
  orientation === "horizontal"
249
- ? "top-1/2 -right-12 -translate-y-1/2 active:!-translate-y-1/2"
250
- : "-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",
251
535
  className,
252
536
  )}
253
537
  onClick={scrollNext}
@@ -258,5 +542,45 @@ export const CarouselNext = ({
258
542
  )
259
543
  }
260
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
+
261
585
  export type { CarouselApi }
262
586
  export { useCarousel }
package/src/checkbox.tsx CHANGED
@@ -4,28 +4,51 @@ 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"
8
+ import { motion } from "./lib/motion"
7
9
 
8
10
  export type CheckboxProps = ComponentProps<typeof BaseCheckbox.Root>
9
11
 
10
12
  export const Checkbox = ({ className, ...props }: CheckboxProps) => (
11
13
  <BaseCheckbox.Root
12
14
  className={cn(
13
- "group flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-secondary bg-surface transition-colors duration-[var(--duration-sm)] ease-enter motion-reduce:transition-none outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20 data-checked:border-brand-primary data-checked:bg-brand-primary data-indeterminate:border-brand-primary data-indeterminate:bg-brand-primary data-disabled:cursor-not-allowed data-disabled:opacity-50",
15
+ "group flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-secondary bg-surface",
16
+ // motion.all, not motion.colors: the filled state brings a box shadow
17
+ // with it, which has to fade in alongside the background.
18
+ motion.all,
19
+ focusRing,
20
+ // Ticked and mixed are both filled controls, so they carry the primary
21
+ // button's treatment: brand fill, the border token that pairs with it,
22
+ // and the top-edge highlight that makes a solid control read as raised.
23
+ "data-checked:border-brand-primary-border data-checked:bg-brand-primary data-checked:inset-shadow-solid-top",
24
+ "data-indeterminate:border-brand-primary-border data-indeterminate:bg-brand-primary data-indeterminate:inset-shadow-solid-top",
25
+ "data-checked:hover:bg-brand-primary-hover data-indeterminate:hover:bg-brand-primary-hover",
26
+ "data-checked:active:bg-brand-primary-active data-indeterminate:active:bg-brand-primary-active",
27
+ "data-disabled:cursor-not-allowed data-disabled:opacity-50",
14
28
  className,
15
29
  )}
16
30
  {...props}
17
31
  >
18
- <BaseCheckbox.Indicator className="flex text-brand-foreground data-unchecked:hidden">
19
- <IconCheckmark1
20
- size={12}
21
- className="size-3 group-data-indeterminate:hidden"
22
- aria-hidden
23
- />
24
- <IconMinus
25
- size={12}
26
- className="hidden size-3 group-data-indeterminate:block"
27
- aria-hidden
28
- />
29
- </BaseCheckbox.Indicator>
32
+ {/* Only the active glyph is mounted. Central Icons give every instance of
33
+ an icon the same mask id, so a CSS-hidden twin would claim that id for
34
+ the whole document; a `display: none` subtree renders no mask, leaving
35
+ the visible instance to paint its backing rect unmasked as a solid
36
+ block. Mounting one icon keeps every id in the document resolvable. */}
37
+ {/* No `data-unchecked:hidden` here. Base UI keeps the indicator mounted
38
+ through its exit and flags it with both `data-unchecked` and
39
+ `data-ending-style`; hiding on the former would cut the latter's
40
+ transition short and the mark would vanish instantly. */}
41
+ <BaseCheckbox.Indicator
42
+ className={cn("flex text-brand-foreground", motion.checkIndicator)}
43
+ render={(props, state) => (
44
+ <span {...props}>
45
+ {state.indeterminate ? (
46
+ <IconMinus size={12} className="size-3" aria-hidden />
47
+ ) : (
48
+ <IconCheckmark1 size={12} className="size-3" aria-hidden />
49
+ )}
50
+ </span>
51
+ )}
52
+ />
30
53
  </BaseCheckbox.Root>
31
54
  )
@@ -4,6 +4,10 @@ import { useState } from "react"
4
4
  import { highlight, type LanguageName } from "sugar-high"
5
5
  import { Button } from "./button"
6
6
  import { IconCheckmark1, IconSquareBehindSquare6 } from "./icons"
7
+ import { cn } from "./lib/cn"
8
+
9
+ /** 1-based line number, or an inclusive `[start, end]` range. */
10
+ export type CodeBlockHighlightLine = number | readonly [number, number]
7
11
 
8
12
  export type CodeBlockProps = {
9
13
  code: string
@@ -13,12 +17,18 @@ export type CodeBlockProps = {
13
17
  showHeader?: boolean
14
18
  /** Removes border and radius chrome for nesting inside another frame. */
15
19
  bare?: boolean
20
+ /** 1-based lines (or ranges) to emphasize inside the block. */
21
+ highlightLines?: readonly CodeBlockHighlightLine[]
22
+ /** 1-based lines added in a review (green). */
23
+ addedLines?: readonly CodeBlockHighlightLine[]
24
+ /** 1-based lines removed in a review (red). */
25
+ removedLines?: readonly CodeBlockHighlightLine[]
16
26
  className?: string
17
27
  }
18
28
 
19
29
  const sizeClass = {
20
- sm: "text-2xs p-3",
21
- md: "text-xs p-4",
30
+ sm: "text-2xs py-3",
31
+ md: "text-xs py-4",
22
32
  } as const
23
33
 
24
34
  const languages = new Set<LanguageName>([
@@ -59,17 +69,60 @@ const getHighlightLanguage = (lang: string): LanguageName => {
59
69
  return "typescript"
60
70
  }
61
71
 
72
+ const toHighlightedLines = (
73
+ lines?: readonly CodeBlockHighlightLine[],
74
+ ): Set<number> => {
75
+ const set = new Set<number>()
76
+ if (!lines) return set
77
+
78
+ for (const entry of lines) {
79
+ if (typeof entry === "number") {
80
+ set.add(entry)
81
+ continue
82
+ }
83
+
84
+ const [start, end] = entry
85
+ const from = Math.min(start, end)
86
+ const to = Math.max(start, end)
87
+ for (let line = from; line <= to; line += 1) set.add(line)
88
+ }
89
+
90
+ return set
91
+ }
92
+
62
93
  export const CodeBlock = ({
63
94
  code: rawCode,
64
95
  lang = "tsx",
65
96
  size = "md",
66
97
  showHeader = true,
67
98
  bare = false,
99
+ highlightLines,
100
+ addedLines,
101
+ removedLines,
68
102
  className = "",
69
103
  }: CodeBlockProps) => {
70
104
  const [copied, setCopied] = useState(false)
71
105
  const code = rawCode.replace(/^\n/, "").replace(/\n$/, "")
72
- const html = highlight(code, { lang: getHighlightLanguage(lang) })
106
+ const focused = toHighlightedLines(highlightLines)
107
+ const added = toHighlightedLines(addedLines)
108
+ const removed = toHighlightedLines(removedLines)
109
+ const html = highlight(code, {
110
+ lang: getHighlightLanguage(lang),
111
+ markLine: (line) => {
112
+ const number = line.index + 1
113
+ if (added.has(number)) {
114
+ line.className += " sh__line--added"
115
+ return
116
+ }
117
+ if (removed.has(number)) {
118
+ line.className += " sh__line--removed"
119
+ return
120
+ }
121
+ if (focused.has(number)) {
122
+ line.className += " sh__line--highlighted"
123
+ }
124
+ },
125
+ })
73
126
  const frameClass =
74
127
  !showHeader && !bare
75
128
  ? "rounded-lg border border-border-primary bg-surface"
@@ -87,7 +140,11 @@ export const CodeBlock = ({
87
140
 
88
141
  const codeContent = (
89
142
  <pre
90
- className={`sh-code overflow-x-auto font-mono ${sizeClass[size]} ${frameClass}`}
143
+ className={cn(
144
+ "sh-code overflow-x-auto font-mono",
145
+ sizeClass[size],
146
+ frameClass,
147
+ )}
91
148
  dangerouslySetInnerHTML={{ __html: html }}
92
149
  />
93
150
  )
@@ -98,7 +155,11 @@ export const CodeBlock = ({
98
155
 
99
156
  return (
100
157
  <div
101
- className={`overflow-hidden rounded-xl border border-border-primary bg-surface ${className}`}
158
+ data-slot="code-block"
159
+ className={cn(
160
+ "overflow-hidden rounded-xl border border-border-primary bg-surface",
161
+ className,
162
+ )}
102
163
  >
103
164
  <div className="flex items-center justify-between border-b border-border-primary bg-background-secondary px-3 py-1.5 pl-4">
104
165
  <p className="text-xs font-mono text-fg-quaternary">{lang}</p>