@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.
- 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 +4 -3
- package/src/block-editor.tsx +1747 -0
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +14 -7
- package/src/button.tsx +22 -11
- package/src/calendar.tsx +6 -3
- package/src/carousel.tsx +220 -53
- package/src/checkbox.tsx +3 -2
- package/src/code-block.tsx +66 -5
- package/src/collapsible.tsx +4 -2
- package/src/combobox.tsx +4 -3
- package/src/command.tsx +27 -12
- 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 +28 -1
- package/src/illustrations.tsx +219 -141
- package/src/index.ts +238 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +10 -4
- package/src/lib/focus.ts +41 -0
- package/src/lib/popup.ts +1 -1
- 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/menubar.tsx +9 -2
- package/src/minimap.tsx +296 -0
- package/src/modal.tsx +608 -0
- package/src/navigation-menu.tsx +338 -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 +1 -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 +5 -2
- 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/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 :
|
|
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-
|
|
54
|
+
size === "sm" ? "gap-1.5" : "gap-2",
|
|
55
55
|
className,
|
|
56
56
|
)}
|
|
57
57
|
aria-label="StandardUI"
|
package/src/breadcrumb.tsx
CHANGED
|
@@ -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: "
|
|
18
|
+
slash: "",
|
|
18
19
|
},
|
|
19
20
|
},
|
|
20
21
|
defaultVariants: {
|
|
21
|
-
variant: "
|
|
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-
|
|
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
|
|
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
|
|
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 === "
|
|
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
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"border border-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"border border-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
26
|
-
|
|
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
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
?
|
|
223
|
-
:
|
|
316
|
+
scrollDelta > 0
|
|
317
|
+
? carouselApi.canScrollNext()
|
|
318
|
+
: carouselApi.canScrollPrev()
|
|
224
319
|
if (!canScrollInDirection) return
|
|
225
320
|
|
|
226
321
|
event.preventDefault()
|
|
227
|
-
|
|
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 (
|
|
235
|
-
else
|
|
342
|
+
if (direction > 0) carouselApi.scrollNext()
|
|
343
|
+
else carouselApi.scrollPrev()
|
|
236
344
|
},
|
|
237
|
-
[
|
|
345
|
+
[
|
|
346
|
+
clearTrackpadGesture,
|
|
347
|
+
orientation,
|
|
348
|
+
scheduleTrackpadGestureEnd,
|
|
349
|
+
],
|
|
238
350
|
)
|
|
239
351
|
|
|
240
352
|
useEffect(() => {
|
|
241
|
-
|
|
242
|
-
if (!root) return
|
|
353
|
+
if (!carouselRoot) return
|
|
243
354
|
|
|
244
|
-
|
|
355
|
+
carouselRoot.addEventListener("wheel", handleWheel, {
|
|
356
|
+
passive: false,
|
|
357
|
+
capture: true,
|
|
358
|
+
})
|
|
245
359
|
return () => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
344
|
-
|
|
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 = "
|
|
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
|
-
|
|
498
|
+
carouselControlClassName,
|
|
372
499
|
orientation === "horizontal"
|
|
373
|
-
? "top-1/2
|
|
374
|
-
: "
|
|
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 = "
|
|
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
|
-
|
|
531
|
+
carouselControlClassName,
|
|
405
532
|
orientation === "horizontal"
|
|
406
|
-
? "top-1/2
|
|
407
|
-
: "
|
|
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
|
|
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
|
-
|
|
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.
|