@boyernick/standard-ui-react 0.1.1-canary.2 → 0.1.1-canary.22

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 (56) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +1 -1
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/autocomplete.tsx +12 -11
  5. package/src/badge.tsx +73 -9
  6. package/src/brand.tsx +2 -2
  7. package/src/breadcrumb.tsx +38 -20
  8. package/src/button.tsx +2 -2
  9. package/src/calendar.tsx +22 -11
  10. package/src/card.tsx +57 -14
  11. package/src/carousel.tsx +163 -6
  12. package/src/checkbox.tsx +35 -13
  13. package/src/collapsible.tsx +4 -2
  14. package/src/combobox.tsx +13 -12
  15. package/src/command.tsx +38 -14
  16. package/src/context-menu.tsx +6 -3
  17. package/src/date-picker.tsx +174 -0
  18. package/src/dialog.tsx +33 -10
  19. package/src/drawer.tsx +1 -1
  20. package/src/empty.tsx +4 -4
  21. package/src/field.tsx +1 -1
  22. package/src/filter-group.tsx +139 -0
  23. package/src/icons.tsx +39 -0
  24. package/src/illustrations.tsx +219 -141
  25. package/src/image-modal.tsx +440 -49
  26. package/src/index.ts +188 -0
  27. package/src/input.tsx +1 -1
  28. package/src/kbd.tsx +49 -0
  29. package/src/lib/motion.ts +128 -18
  30. package/src/lib/popup.ts +45 -0
  31. package/src/menu.tsx +7 -4
  32. package/src/menubar.tsx +5 -0
  33. package/src/meter.tsx +1 -1
  34. package/src/minimap.tsx +267 -0
  35. package/src/navigation-menu.tsx +336 -67
  36. package/src/number-field.tsx +327 -61
  37. package/src/pagination.tsx +258 -42
  38. package/src/popover.tsx +3 -1
  39. package/src/preview-card.tsx +3 -1
  40. package/src/progress.tsx +6 -1
  41. package/src/questionnaire.tsx +279 -0
  42. package/src/scroll-area.tsx +1 -1
  43. package/src/select.tsx +8 -6
  44. package/src/sidebar.tsx +109 -28
  45. package/src/slider.tsx +64 -6
  46. package/src/sounds.tsx +6 -10
  47. package/src/spinner.tsx +105 -32
  48. package/src/switch.tsx +2 -2
  49. package/src/table.tsx +82 -15
  50. package/src/tabs.tsx +184 -38
  51. package/src/text-animate.tsx +71 -28
  52. package/src/textarea.tsx +1 -1
  53. package/src/timeline.tsx +321 -0
  54. package/src/toast.tsx +156 -23
  55. package/src/tooltip.tsx +43 -3
  56. package/src/video-player.tsx +296 -120
@@ -0,0 +1,321 @@
1
+ "use client"
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import {
5
+ createContext,
6
+ type ComponentProps,
7
+ type KeyboardEvent,
8
+ type WheelEvent,
9
+ useContext,
10
+ } from "react"
11
+ import { cn } from "./lib/cn"
12
+
13
+ const timelineVariants = cva("group/timeline relative text-fg-primary", {
14
+ variants: {
15
+ orientation: {
16
+ vertical: "w-full",
17
+ horizontal:
18
+ "w-full snap-x snap-proximity overflow-x-auto overscroll-x-contain rounded-xl border border-border-primary bg-surface px-5 py-6 scroll-smooth focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20 focus-visible:outline-none",
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ orientation: "vertical",
23
+ },
24
+ })
25
+
26
+ const timelineTrackVariants = cva("relative m-0 list-none p-0", {
27
+ variants: {
28
+ orientation: {
29
+ vertical: "ml-1.5 border-l border-dashed border-border-primary",
30
+ horizontal:
31
+ "grid min-w-max auto-cols-64 grid-flow-col before:absolute before:inset-x-0 before:top-10 before:border-t before:border-dashed before:border-border-primary before:content-['']",
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ orientation: "vertical",
36
+ },
37
+ })
38
+
39
+ const timelineItemVariants = cva("relative", {
40
+ variants: {
41
+ orientation: {
42
+ vertical: "pb-8 pl-7 last:pb-0",
43
+ horizontal: "w-64 snap-start pt-16 pr-8 last:pr-0",
44
+ },
45
+ },
46
+ defaultVariants: {
47
+ orientation: "vertical",
48
+ },
49
+ })
50
+
51
+ const timelineMarkerVariants = cva(
52
+ "absolute z-[1] size-2.5 rounded-full border-2 border-surface shadow-hairline",
53
+ {
54
+ variants: {
55
+ orientation: {
56
+ vertical: "top-1 left-0 -translate-x-1/2",
57
+ horizontal: "top-10 left-0 -translate-y-1/2",
58
+ },
59
+ tone: {
60
+ neutral: "bg-fg-tertiary",
61
+ accent: "bg-brand-primary",
62
+ info: "bg-status-info",
63
+ success: "bg-status-success",
64
+ warning: "bg-status-warning",
65
+ critical: "bg-status-critical",
66
+ },
67
+ },
68
+ defaultVariants: {
69
+ orientation: "vertical",
70
+ tone: "neutral",
71
+ },
72
+ },
73
+ )
74
+
75
+ const timelineTimeVariants = cva(
76
+ "text-xs-strong block text-fg-tertiary tabular-nums",
77
+ {
78
+ variants: {
79
+ orientation: {
80
+ vertical: "mb-1",
81
+ horizontal: "absolute top-0 left-0",
82
+ },
83
+ },
84
+ defaultVariants: {
85
+ orientation: "vertical",
86
+ },
87
+ },
88
+ )
89
+
90
+ type TimelineOrientation = "horizontal" | "vertical"
91
+
92
+ const TimelineContext = createContext<TimelineOrientation>("vertical")
93
+
94
+ export type TimelineProps = ComponentProps<"div"> &
95
+ VariantProps<typeof timelineVariants> & {
96
+ /** Distance moved by the horizontal timeline's arrow keys. */
97
+ scrollStep?: number
98
+ }
99
+
100
+ export type TimelineTrackProps = ComponentProps<"ol">
101
+ export type TimelineItemProps = ComponentProps<"li">
102
+ export type TimelineMarkerProps = ComponentProps<"span"> &
103
+ VariantProps<typeof timelineMarkerVariants>
104
+ export type TimelineTimeProps = ComponentProps<"time">
105
+ export type TimelineContentProps = ComponentProps<"div">
106
+ export type TimelineTitleProps = ComponentProps<"h3">
107
+ export type TimelineDescriptionProps = ComponentProps<"p">
108
+ export type TimelineMediaProps = ComponentProps<"div">
109
+
110
+ export const Timeline = ({
111
+ orientation = "vertical",
112
+ scrollStep = 256,
113
+ className,
114
+ children,
115
+ tabIndex,
116
+ onKeyDown,
117
+ onWheel,
118
+ "aria-label": ariaLabel = "Timeline",
119
+ ...props
120
+ }: TimelineProps) => {
121
+ const resolvedOrientation = orientation ?? "vertical"
122
+
123
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
124
+ onKeyDown?.(event)
125
+ if (
126
+ event.defaultPrevented ||
127
+ resolvedOrientation !== "horizontal" ||
128
+ event.target !== event.currentTarget
129
+ ) {
130
+ return
131
+ }
132
+
133
+ const node = event.currentTarget
134
+ const prefersReducedMotion = window.matchMedia(
135
+ "(prefers-reduced-motion: reduce)",
136
+ ).matches
137
+ const behavior = prefersReducedMotion ? "auto" : "smooth"
138
+
139
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
140
+ event.preventDefault()
141
+ node.scrollBy({
142
+ left: event.key === "ArrowRight" ? scrollStep : -scrollStep,
143
+ behavior,
144
+ })
145
+ }
146
+
147
+ if (event.key === "Home" || event.key === "End") {
148
+ event.preventDefault()
149
+ node.scrollTo({
150
+ left: event.key === "Home" ? 0 : node.scrollWidth,
151
+ behavior,
152
+ })
153
+ }
154
+ }
155
+
156
+ const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
157
+ onWheel?.(event)
158
+ if (
159
+ event.defaultPrevented ||
160
+ resolvedOrientation !== "horizontal" ||
161
+ Math.abs(event.deltaX) >= Math.abs(event.deltaY)
162
+ ) {
163
+ return
164
+ }
165
+
166
+ const node = event.currentTarget
167
+ const maxScrollLeft = node.scrollWidth - node.clientWidth
168
+ const canScroll =
169
+ event.deltaY > 0 ? node.scrollLeft < maxScrollLeft : node.scrollLeft > 0
170
+
171
+ if (canScroll) {
172
+ event.preventDefault()
173
+ node.scrollLeft += event.deltaY
174
+ }
175
+ }
176
+
177
+ return (
178
+ <TimelineContext.Provider value={resolvedOrientation}>
179
+ <div
180
+ data-slot="timeline"
181
+ data-orientation={resolvedOrientation}
182
+ role="region"
183
+ aria-label={ariaLabel}
184
+ tabIndex={
185
+ tabIndex ?? (resolvedOrientation === "horizontal" ? 0 : undefined)
186
+ }
187
+ className={cn(
188
+ timelineVariants({ orientation: resolvedOrientation }),
189
+ className,
190
+ )}
191
+ onKeyDown={handleKeyDown}
192
+ onWheel={handleWheel}
193
+ {...props}
194
+ >
195
+ {children}
196
+ </div>
197
+ </TimelineContext.Provider>
198
+ )
199
+ }
200
+
201
+ export const TimelineTrack = ({
202
+ className,
203
+ ...props
204
+ }: TimelineTrackProps) => {
205
+ const orientation = useContext(TimelineContext)
206
+
207
+ return (
208
+ <ol
209
+ data-slot="timeline-track"
210
+ data-orientation={orientation}
211
+ className={cn(timelineTrackVariants({ orientation }), className)}
212
+ {...props}
213
+ />
214
+ )
215
+ }
216
+
217
+ export const TimelineItem = ({
218
+ className,
219
+ ...props
220
+ }: TimelineItemProps) => {
221
+ const orientation = useContext(TimelineContext)
222
+
223
+ return (
224
+ <li
225
+ data-slot="timeline-item"
226
+ data-orientation={orientation}
227
+ className={cn(timelineItemVariants({ orientation }), className)}
228
+ {...props}
229
+ />
230
+ )
231
+ }
232
+
233
+ export const TimelineMarker = ({
234
+ className,
235
+ tone,
236
+ ...props
237
+ }: TimelineMarkerProps) => {
238
+ const orientation = useContext(TimelineContext)
239
+
240
+ return (
241
+ <span
242
+ data-slot="timeline-marker"
243
+ aria-hidden
244
+ className={cn(
245
+ timelineMarkerVariants({ orientation, tone }),
246
+ className,
247
+ )}
248
+ {...props}
249
+ />
250
+ )
251
+ }
252
+
253
+ export const TimelineTime = ({
254
+ className,
255
+ ...props
256
+ }: TimelineTimeProps) => {
257
+ const orientation = useContext(TimelineContext)
258
+
259
+ return (
260
+ <time
261
+ data-slot="timeline-time"
262
+ className={cn(timelineTimeVariants({ orientation }), className)}
263
+ {...props}
264
+ />
265
+ )
266
+ }
267
+
268
+ export const TimelineContent = ({
269
+ className,
270
+ ...props
271
+ }: TimelineContentProps) => (
272
+ <div
273
+ data-slot="timeline-content"
274
+ className={cn("min-w-0", className)}
275
+ {...props}
276
+ />
277
+ )
278
+
279
+ export const TimelineTitle = ({
280
+ className,
281
+ ...props
282
+ }: TimelineTitleProps) => (
283
+ <h3
284
+ data-slot="timeline-title"
285
+ className={cn("text-sm-strong text-fg-primary", className)}
286
+ {...props}
287
+ />
288
+ )
289
+
290
+ export const TimelineDescription = ({
291
+ className,
292
+ ...props
293
+ }: TimelineDescriptionProps) => (
294
+ <p
295
+ data-slot="timeline-description"
296
+ className={cn("text-sm mt-1 leading-relaxed text-fg-secondary", className)}
297
+ {...props}
298
+ />
299
+ )
300
+
301
+ export const TimelineMedia = ({
302
+ className,
303
+ ...props
304
+ }: TimelineMediaProps) => (
305
+ <div
306
+ data-slot="timeline-media"
307
+ className={cn(
308
+ "mt-4 overflow-hidden rounded-xl border border-border-primary bg-background-secondary [&>img]:block [&>img]:aspect-4/3 [&>img]:w-full [&>img]:object-cover",
309
+ className,
310
+ )}
311
+ {...props}
312
+ />
313
+ )
314
+
315
+ export {
316
+ timelineVariants,
317
+ timelineItemVariants,
318
+ timelineMarkerVariants,
319
+ timelineTimeVariants,
320
+ timelineTrackVariants,
321
+ }
package/src/toast.tsx CHANGED
@@ -1,15 +1,24 @@
1
1
  "use client"
2
2
 
3
3
  import { Toast as BaseToast } from "@base-ui/react/toast"
4
+ import { cva, type VariantProps } from "class-variance-authority"
4
5
  import type { ComponentProps } from "react"
5
- import { IconCrossSmall } from "./icons"
6
+ import {
7
+ IconCircleCheck,
8
+ IconCircleInfo,
9
+ IconCrossSmall,
10
+ IconExclamationCircle,
11
+ IconExclamationTriangle,
12
+ } from "./icons"
13
+ import { Spinner } from "./spinner"
6
14
  import { cn } from "./lib/cn"
7
15
  import { motion } from "./lib/motion"
8
16
 
9
17
  export type ToastProviderProps = ComponentProps<typeof BaseToast.Provider>
10
18
  export type ToastPortalProps = ComponentProps<typeof BaseToast.Portal>
11
19
  export type ToastViewportProps = ComponentProps<typeof BaseToast.Viewport>
12
- export type ToastRootProps = ComponentProps<typeof BaseToast.Root>
20
+ export type ToastRootProps = ComponentProps<typeof BaseToast.Root> &
21
+ VariantProps<typeof toastRootVariants>
13
22
  export type ToastContentProps = ComponentProps<typeof BaseToast.Content>
14
23
  export type ToastTitleProps = ComponentProps<typeof BaseToast.Title>
15
24
  export type ToastDescriptionProps = ComponentProps<typeof BaseToast.Description>
@@ -18,6 +27,10 @@ export type ToastCloseProps = ComponentProps<typeof BaseToast.Close>
18
27
  export type ToastPositionerProps = ComponentProps<typeof BaseToast.Positioner>
19
28
  export type ToastArrowProps = ComponentProps<typeof BaseToast.Arrow>
20
29
 
30
+ /** The types that carry a glyph. Anything else renders no icon. */
31
+ export type ToastType = "success" | "error" | "warning" | "info" | "loading"
32
+ export type ToastIconProps = ComponentProps<"span"> & { type?: string }
33
+
21
34
  export const ToastProvider = (props: ToastProviderProps) => (
22
35
  <BaseToast.Provider {...props} />
23
36
  )
@@ -28,35 +41,150 @@ export const ToastPortal = (props: ToastPortalProps) => (
28
41
 
29
42
  export const ToastViewport = ({ className, ...props }: ToastViewportProps) => (
30
43
  <BaseToast.Viewport
44
+ // No flex column: the toasts inside are absolutely positioned so they pile
45
+ // up as a stack. In flow they would march down past the edge.
31
46
  className={cn(
32
- "fixed right-4 bottom-4 z-[100] flex w-[min(100vw-2rem,24rem)] flex-col outline-none",
47
+ "fixed top-8 left-1/2 z-[100] w-[min(100vw-2rem,28rem)] -translate-x-1/2 outline-none",
33
48
  className,
34
49
  )}
35
50
  {...props}
36
51
  />
37
52
  )
38
53
 
39
- export const ToastRoot = ({ className, ...props }: ToastRootProps) => (
54
+ /** Each toast is pinned to the top of the viewport and pushed down by its own
55
+ * index, so the stack reads as a pile of cards: the one behind peeks out below
56
+ * by `--peek` and sits a step smaller. Hovering the viewport sets
57
+ * `data-expanded`, which fans them out to their real heights.
58
+ *
59
+ * `origin-top` is what keeps the arithmetic simple — scaling a card toward its
60
+ * own top edge leaves that edge where it is, so the peek is the only offset
61
+ * the collapsed stack needs. */
62
+ const toastRootVariants = cva(
63
+ cn(
64
+ "absolute top-0 right-0 left-0 z-[calc(1000-var(--toast-index))] box-border w-full origin-top",
65
+ "[--gap:0.625rem] [--peek:0.875rem]",
66
+ "[--scale:calc(max(0,1-(var(--toast-index)*0.05)))]",
67
+ "[--height:var(--toast-frontmost-height,var(--toast-height))]",
68
+ "[--offset-y:calc(var(--toast-offset-y)+calc(var(--toast-index)*var(--gap))+var(--toast-swipe-movement-y,0px))]",
69
+ // A row, not an overlay: the action and the close sit inline at the right,
70
+ // so the close cannot be absolutely positioned over reserved padding.
71
+ "flex items-start gap-2 rounded-xl border py-2.5 pr-2 pl-4 shadow-lg outline-none select-none",
72
+ "h-[var(--height)] data-expanded:h-[var(--toast-height)]",
73
+ "[transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(calc(var(--toast-swipe-movement-y,0px)+(var(--toast-index)*var(--peek))))_scale(var(--scale))]",
74
+ "data-expanded:[transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(var(--offset-y))]",
75
+ // Bridges the gap between cards so crossing it does not collapse the fan.
76
+ "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
77
+ "transition-[transform,opacity,height] duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
78
+ // Anchored to the top, so they arrive and leave upwards.
79
+ "data-starting-style:[transform:translateY(-150%)]",
80
+ "data-ending-style:opacity-0 data-limited:opacity-0",
81
+ "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(-150%)]",
82
+ ),
83
+ {
84
+ variants: {
85
+ variant: {
86
+ // The page's own surface. The border stays transparent so the edge is
87
+ // `shadow-lg`'s hairline, the same one every dropdown carries.
88
+ default: "border-transparent bg-surface text-fg-primary",
89
+ // Flipped against the page. Here the hairline is invisible — a black
90
+ // line on a near-black card — so the edge is drawn with the inverted
91
+ // foreground, which flips with the theme alongside the surface.
92
+ inverted: "border-fg-inverted/10 bg-surface-inverted text-fg-inverted",
93
+ },
94
+ },
95
+ defaultVariants: { variant: "default" },
96
+ },
97
+ )
98
+
99
+ /** Each toast is pinned to the top of the viewport and pushed down by its own
100
+ * index, so the stack reads as a pile of cards: the one behind peeks out below
101
+ * by `--peek` and sits a step smaller. Hovering the viewport sets
102
+ * `data-expanded`, which fans them out to their real heights.
103
+ *
104
+ * `origin-top` is what keeps the arithmetic simple — scaling a card toward its
105
+ * own top edge leaves that edge where it is, so the peek is the only offset
106
+ * the collapsed stack needs.
107
+ *
108
+ * The variant is published as `data-variant` rather than passed down: the
109
+ * title, description, action, close and icon all have to follow the surface,
110
+ * and a data attribute lets each one carry its own pair of rules instead of
111
+ * every caller threading a prop through five components. */
112
+ export const ToastRoot = ({
113
+ className,
114
+ variant = "default",
115
+ ...props
116
+ }: ToastRootProps) => (
40
117
  <BaseToast.Root
41
- className={cn(
42
- "relative z-[calc(1000-var(--toast-index))] mb-2 box-border w-full rounded-xl border border-border-primary bg-surface p-4 shadow-lg outline-none",
43
- "h-[var(--toast-frontmost-height,var(--toast-height))]",
44
- "transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none",
45
- "data-starting-style:opacity-0 data-starting-style:translate-y-2",
46
- "data-ending-style:opacity-0 data-ending-style:translate-y-2",
47
- "data-expanded:h-[var(--toast-height)] data-expanded:translate-y-[var(--toast-offset-y)]",
48
- "data-[type=error]:border-destructive/40",
49
- "data-[type=success]:border-border-primary",
50
- className,
51
- )}
118
+ data-variant={variant}
119
+ className={cn("group/toast", toastRootVariants({ variant }), className)}
52
120
  {...props}
53
121
  />
54
122
  )
55
123
 
124
+ export { toastRootVariants }
125
+
126
+ const toastGlyphs = {
127
+ success: {
128
+ Icon: IconCircleCheck,
129
+ tone: `text-status-success group-data-[variant=inverted]/toast:text-status-success-inverted`,
130
+ },
131
+ error: {
132
+ Icon: IconExclamationCircle,
133
+ tone: `text-status-critical group-data-[variant=inverted]/toast:text-status-critical-inverted`,
134
+ },
135
+ warning: {
136
+ Icon: IconExclamationTriangle,
137
+ tone: `text-status-warning group-data-[variant=inverted]/toast:text-status-warning-inverted`,
138
+ },
139
+ info: {
140
+ Icon: IconCircleInfo,
141
+ tone: `text-status-info group-data-[variant=inverted]/toast:text-status-info-inverted`,
142
+ },
143
+ } as const
144
+
145
+ /** The glyph tracks the title, not the block. The row is `items-start` so the
146
+ * glyph and the content column measure from the same top edge, and `mt-0.5`
147
+ * is the half-leading above a 14px title in a 20px line — which centres the
148
+ * 16px glyph on the title whether or not a description follows it.
149
+ *
150
+ * The margin is on both axes, not just the top. Every item beside the content
151
+ * column is collapsed to a 20px outer box — the height of the title's line —
152
+ * so it centres on that line while contributing nothing extra to the row. A
153
+ * top-only offset lines the item up but leaves its full height in the box, and
154
+ * the tallest control then stretches the toast downward and everything reads
155
+ * high. Hit areas are untouched: the boxes overflow, they do not shrink. */
156
+ const ICON_ALIGN = "my-0.5 flex shrink-0"
157
+
158
+ /** The glyph for a toast's type. Each tone carries both surfaces: the plain
159
+ * status tokens are tuned for the page, and the `-inverted` pair for a card
160
+ * that flips against it. Pass `toast.type` from the render loop — Base UI
161
+ * gives no way to read the toast from inside the root. */
162
+ export const ToastIcon = ({ type, className, ...props }: ToastIconProps) => {
163
+ if (type === "loading") {
164
+ return (
165
+ <span className={cn(ICON_ALIGN, className)} {...props}>
166
+ <Spinner size="sm" />
167
+ </span>
168
+ )
169
+ }
170
+
171
+ const glyph = toastGlyphs[type as keyof typeof toastGlyphs]
172
+ if (!glyph) return null
173
+
174
+ return (
175
+ <span
176
+ className={cn(ICON_ALIGN, glyph.tone, className)}
177
+ {...props}
178
+ >
179
+ <glyph.Icon size={16} className="size-4" aria-hidden />
180
+ </span>
181
+ )
182
+ }
183
+
56
184
  export const ToastContent = ({ className, ...props }: ToastContentProps) => (
57
185
  <BaseToast.Content
58
186
  className={cn(
59
- "flex flex-col gap-1 overflow-hidden transition-opacity duration-150",
187
+ "flex min-w-0 flex-1 flex-col gap-0.5 overflow-hidden transition-opacity duration-[var(--duration-sm)]",
60
188
  "data-behind:pointer-events-none data-behind:opacity-0 data-expanded:data-behind:opacity-100",
61
189
  className,
62
190
  )}
@@ -66,7 +194,7 @@ export const ToastContent = ({ className, ...props }: ToastContentProps) => (
66
194
 
67
195
  export const ToastTitle = ({ className, ...props }: ToastTitleProps) => (
68
196
  <BaseToast.Title
69
- className={cn("text-sm-strong pr-8 text-fg-primary", className)}
197
+ className={cn("text-sm-strong text-fg-primary group-data-[variant=inverted]/toast:text-fg-inverted", className)}
70
198
  {...props}
71
199
  />
72
200
  )
@@ -76,7 +204,10 @@ export const ToastDescription = ({
76
204
  ...props
77
205
  }: ToastDescriptionProps) => (
78
206
  <BaseToast.Description
79
- className={cn("text-sm leading-relaxed text-fg-secondary", className)}
207
+ className={cn(
208
+ "text-sm text-fg-secondary group-data-[variant=inverted]/toast:text-fg-inverted-secondary",
209
+ className,
210
+ )}
80
211
  {...props}
81
212
  />
82
213
  )
@@ -84,9 +215,10 @@ export const ToastDescription = ({
84
215
  export const ToastAction = ({ className, ...props }: ToastActionProps) => (
85
216
  <BaseToast.Action
86
217
  className={cn(
87
- "mt-2 inline-flex h-8 cursor-pointer items-center justify-center rounded-md px-2.5 text-sm text-fg-primary outline-none",
218
+ "text-xs -my-1 inline-flex h-7 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border-secondary px-2.5 text-fg-primary outline-none",
88
219
  motion.colors,
89
- "hover:bg-background-tertiary 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",
220
+ "hover:bg-background-tertiary focus-visible:ring-2 focus-visible:ring-ring/40",
221
+ "group-data-[variant=inverted]/toast:border-fg-inverted/15 group-data-[variant=inverted]/toast:text-fg-inverted group-data-[variant=inverted]/toast:hover:bg-fg-inverted/10 group-data-[variant=inverted]/toast:focus-visible:ring-fg-inverted/50",
90
222
  className,
91
223
  )}
92
224
  {...props}
@@ -100,15 +232,16 @@ export const ToastClose = ({
100
232
  }: ToastCloseProps) => (
101
233
  <BaseToast.Close
102
234
  className={cn(
103
- "absolute top-3 right-3 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-fg-tertiary outline-none",
235
+ "-my-0.5 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-fg-tertiary outline-none",
104
236
  motion.colors,
105
- "hover:bg-background-tertiary hover:text-fg-primary 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",
237
+ "hover:bg-background-tertiary hover:text-fg-primary focus-visible:ring-2 focus-visible:ring-ring/40",
238
+ "group-data-[variant=inverted]/toast:text-fg-inverted/50 group-data-[variant=inverted]/toast:hover:bg-fg-inverted/10 group-data-[variant=inverted]/toast:hover:text-fg-inverted group-data-[variant=inverted]/toast:focus-visible:ring-fg-inverted/50",
106
239
  className,
107
240
  )}
108
241
  {...props}
109
242
  >
110
243
  {children ?? (
111
- <IconCrossSmall size={14} className="size-3.5" aria-hidden />
244
+ <IconCrossSmall size={18} className="size-4.5" aria-hidden />
112
245
  )}
113
246
  </BaseToast.Close>
114
247
  )
package/src/tooltip.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip"
4
+ import { cva, type VariantProps } from "class-variance-authority"
4
5
  import type { ComponentProps } from "react"
5
6
  import { cn } from "./lib/cn"
6
7
  import { motion } from "./lib/motion"
@@ -12,7 +13,8 @@ export type TooltipPortalProps = ComponentProps<typeof BaseTooltip.Portal>
12
13
  export type TooltipPositionerProps = ComponentProps<
13
14
  typeof BaseTooltip.Positioner
14
15
  >
15
- export type TooltipPopupProps = ComponentProps<typeof BaseTooltip.Popup>
16
+ export type TooltipPopupProps = ComponentProps<typeof BaseTooltip.Popup> &
17
+ VariantProps<typeof tooltipPopupVariants>
16
18
 
17
19
  export const TooltipProvider = (props: TooltipProviderProps) => (
18
20
  <BaseTooltip.Provider {...props} />
@@ -43,13 +45,51 @@ export const TooltipPositioner = ({
43
45
  />
44
46
  )
45
47
 
46
- export const TooltipPopup = ({ className, ...props }: TooltipPopupProps) => (
48
+ // Neither variant carries a border — `shadow-md` already composes
49
+ // `--elevation-hairline`, so one would stack a second edge. That hairline is
50
+ // what gives the default popup its edge against a same-coloured page.
51
+ const tooltipPopupVariants = cva(
52
+ cn(
53
+ "z-50 max-w-xs rounded-md px-2.5 py-1 text-xs shadow-md outline-none",
54
+ // A trailing keycap already carries its own inset, so the full 10px beside
55
+ // it reads as a gap rather than padding — but sitting flush on the vertical
56
+ // inset crowds the corner, so it gets a touch more than that.
57
+ //
58
+ // Laying the row out as flex is what centres it. On a text line the keycap
59
+ // is an inline-flex aligned `middle`, which sets its centre against the
60
+ // baseline plus half an x-height rather than the line's centre — at 16px it
61
+ // hung 1.18px low, leaving 6.36px above and 4px below. Scoped to `has-[kbd]`
62
+ // because making every popup a flex container would turn a multi-child
63
+ // tooltip from stacked blocks into a row.
64
+ "has-[kbd]:flex has-[kbd]:items-center has-[kbd]:pr-1.5",
65
+ ),
66
+ {
67
+ variants: {
68
+ variant: {
69
+ // Sits on the page's own surface: white in light mode, dark in dark.
70
+ default: "bg-surface text-fg-primary",
71
+ // Flipped against the page — dark in light mode, light in dark — for
72
+ // a hint that should read as separate from what it floats over.
73
+ inverted: "bg-surface-inverted text-fg-inverted",
74
+ },
75
+ },
76
+ defaultVariants: { variant: "default" },
77
+ },
78
+ )
79
+
80
+ export const TooltipPopup = ({
81
+ className,
82
+ variant,
83
+ ...props
84
+ }: TooltipPopupProps) => (
47
85
  <BaseTooltip.Popup
48
86
  className={cn(
49
- "z-50 max-w-xs rounded-md bg-surface-inverted px-2.5 py-1 text-xs text-fg-inverted shadow-md outline-none",
87
+ tooltipPopupVariants({ variant }),
50
88
  motion.popupAnchor,
51
89
  className,
52
90
  )}
53
91
  {...props}
54
92
  />
55
93
  )
94
+
95
+ export { tooltipPopupVariants }