@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.
- package/package.json +5 -3
- package/src/accordion.tsx +5 -2
- package/src/alert-dialog.tsx +1 -1
- package/src/attachment.tsx +5 -2
- package/src/autocomplete.tsx +12 -9
- package/src/badge.tsx +73 -9
- package/src/block-editor.tsx +1747 -0
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +46 -21
- package/src/button.tsx +23 -12
- package/src/calendar.tsx +25 -11
- package/src/card.tsx +57 -14
- package/src/carousel.tsx +339 -15
- package/src/checkbox.tsx +36 -13
- package/src/code-block.tsx +66 -5
- package/src/collapsible.tsx +7 -3
- package/src/combobox.tsx +17 -15
- package/src/command.tsx +41 -14
- package/src/context-menu.tsx +6 -3
- package/src/date-picker.tsx +174 -0
- package/src/dialog.tsx +33 -10
- package/src/empty.tsx +4 -4
- package/src/field.tsx +4 -3
- package/src/filter-group.tsx +143 -0
- package/src/icons.tsx +55 -1
- package/src/illustrations.tsx +219 -141
- package/src/index.ts +263 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +49 -0
- package/src/lib/focus.ts +41 -0
- package/src/lib/motion.ts +16 -1
- package/src/lib/popup.ts +45 -0
- package/src/lifeline/company-icon.tsx +71 -0
- package/src/lifeline/index.ts +42 -0
- package/src/lifeline/lifeline-data.ts +97 -0
- package/src/lifeline/lifeline-desktop.tsx +204 -0
- package/src/lifeline/lifeline-event.tsx +108 -0
- package/src/lifeline/lifeline-fireworks.tsx +302 -0
- package/src/lifeline/lifeline-hover-image.tsx +269 -0
- package/src/lifeline/lifeline-icons.tsx +39 -0
- package/src/lifeline/lifeline-intro-timing.ts +105 -0
- package/src/lifeline/lifeline-labels.tsx +24 -0
- package/src/lifeline/lifeline-layout.ts +1 -0
- package/src/lifeline/lifeline-legend.tsx +31 -0
- package/src/lifeline/lifeline-lightbox.tsx +309 -0
- package/src/lifeline/lifeline-marker.tsx +183 -0
- package/src/lifeline/lifeline-people.tsx +99 -0
- package/src/lifeline/lifeline-photos.tsx +366 -0
- package/src/lifeline/lifeline-shell.tsx +135 -0
- package/src/lifeline/lifeline-utils.ts +83 -0
- package/src/lifeline/lifeline-vertical.tsx +482 -0
- package/src/lifeline/lifeline.tsx +69 -0
- package/src/lifeline/types.ts +112 -0
- package/src/lifeline/use-lifeline-intro.ts +130 -0
- package/src/lifeline/use-lifeline-scroll.ts +1011 -0
- package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
- package/src/menu.tsx +7 -4
- package/src/menubar.tsx +9 -2
- package/src/minimap.tsx +296 -0
- package/src/modal.tsx +608 -0
- package/src/navigation-menu.tsx +340 -67
- package/src/number-field.tsx +336 -61
- package/src/otp-field.tsx +4 -3
- package/src/pagination.tsx +262 -42
- package/src/password-protection.tsx +345 -0
- package/src/popover.tsx +3 -1
- package/src/preview-card.tsx +3 -1
- package/src/progress.tsx +5 -0
- package/src/questionnaire.tsx +284 -0
- package/src/radio.tsx +4 -2
- package/src/scroll-area.tsx +4 -1
- package/src/select.tsx +12 -7
- package/src/sidebar.tsx +113 -28
- package/src/slider.tsx +66 -6
- package/src/sounds.tsx +6 -10
- package/src/spinner.tsx +105 -32
- package/src/switch.tsx +4 -1
- package/src/table.tsx +82 -15
- package/src/tabs.tsx +189 -38
- package/src/text-animate.tsx +71 -28
- package/src/textarea.tsx +6 -1
- package/src/timeline.tsx +378 -0
- package/src/toast.tsx +214 -35
- package/src/toggle.tsx +4 -2
- package/src/toolbar.tsx +12 -7
- package/src/tooltip.tsx +43 -3
- package/src/video-player.tsx +396 -127
- package/src/image-modal.tsx +0 -110
- 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
|
-
|
|
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
|
|
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
|
|
187
|
-
|
|
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 = "
|
|
198
|
-
size = "
|
|
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
|
-
|
|
498
|
+
carouselControlClassName,
|
|
215
499
|
orientation === "horizontal"
|
|
216
|
-
? "top-1/2
|
|
217
|
-
: "
|
|
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 = "
|
|
231
|
-
size = "
|
|
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
|
-
|
|
531
|
+
carouselControlClassName,
|
|
248
532
|
orientation === "horizontal"
|
|
249
|
-
? "top-1/2
|
|
250
|
-
: "
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
)
|
package/src/code-block.tsx
CHANGED
|
@@ -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
|
|
21
|
-
md: "text-xs
|
|
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
|
|
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={
|
|
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
|
-
|
|
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>
|