@boyernick/standard-ui-react 0.1.1-canary.14 → 0.1.1-canary.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyernick/standard-ui-react",
3
- "version": "0.1.1-canary.14",
3
+ "version": "0.1.1-canary.15",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -606,6 +606,8 @@ export {
606
606
  ToastViewport,
607
607
  ToastRoot,
608
608
  ToastContent,
609
+ ToastIcon,
610
+ toastRootVariants,
609
611
  ToastTitle,
610
612
  ToastDescription,
611
613
  ToastAction,
@@ -619,6 +621,8 @@ export {
619
621
  type ToastViewportProps,
620
622
  type ToastRootProps,
621
623
  type ToastContentProps,
624
+ type ToastIconProps,
625
+ type ToastType,
622
626
  type ToastTitleProps,
623
627
  type ToastDescriptionProps,
624
628
  type ToastActionProps,
@@ -8,6 +8,11 @@ import {
8
8
  } from "react"
9
9
  import { cn } from "./lib/cn"
10
10
 
11
+ /** How many glyphs are scrambling at once. The window travels the line; behind
12
+ * it the text is settled, ahead of it nothing is drawn yet. 8 is the reference
13
+ * default from baffle.js, which this effect follows. */
14
+ const DECODE_WINDOW = 8
15
+
11
16
  const DECODE_GLYPHS =
12
17
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789#@$%&*"
13
18
 
@@ -46,6 +51,7 @@ export const TextAnimate = ({
46
51
  useEffect(() => {
47
52
  let cancelled = false
48
53
  let timeoutId: ReturnType<typeof setTimeout> | undefined
54
+ let frameId: number | undefined
49
55
 
50
56
  const start = () => {
51
57
  if (
@@ -82,30 +88,59 @@ export const TextAnimate = ({
82
88
  return
83
89
  }
84
90
 
85
- // decode
86
- setOutput(
87
- chars.map(() => DECODE_GLYPHS[Math.floor(Math.random() * DECODE_GLYPHS.length)]).join(""),
88
- )
89
- let revealed = 0
90
- const tick = () => {
91
+ // Decode follows baffle.js's `reveal`, which is the reference for this
92
+ // effect: a fixed-width window of scrambled glyphs travels left to
93
+ // right, the real text sits behind it, and nothing at all is drawn ahead
94
+ // of it — so the line grows in with a churning leading edge rather than
95
+ // resolving out of a full-width block of noise.
96
+ //
97
+ // It runs on rAF with a time accumulator rather than `setTimeout(speed)`.
98
+ // A 28ms timer does not divide into a ~16.7ms frame, so steps land on
99
+ // uneven frames and the text visibly stutters; accumulating elapsed time
100
+ // keeps the reference's cadence while landing each step on a real frame.
101
+ const slots = chars.reduce<number[]>((acc, char, index) => {
102
+ if (char !== " ") acc.push(index)
103
+ return acc
104
+ }, [])
105
+
106
+ // The window starts off the front of the line so the first glyph is
107
+ // already scrambling as it arrives.
108
+ let position = -DECODE_WINDOW
109
+ let previousStep = -Infinity
110
+ const startedAt = performance.now()
111
+
112
+ const frame = (now: number) => {
91
113
  if (cancelled) return
92
- revealed += 1
93
- const next = chars
94
- .map((char, index) => {
95
- if (char === " ") return " "
96
- if (index < revealed) return char
97
- return DECODE_GLYPHS[Math.floor(Math.random() * DECODE_GLYPHS.length)]
98
- })
99
- .join("")
100
- setOutput(next)
101
- if (revealed < chars.length) {
102
- timeoutId = setTimeout(tick, speed)
103
- } else {
104
- setOutput(text)
105
- setDone(true)
114
+
115
+ const step = Math.floor((now - startedAt) / speed) - DECODE_WINDOW
116
+ if (step !== previousStep) {
117
+ previousStep = step
118
+ position = step
119
+
120
+ if (position > slots.length) {
121
+ setOutput(text)
122
+ setDone(true)
123
+ return
124
+ }
125
+
126
+ const next = [...chars]
127
+ for (let p = Math.max(position, 0); p < slots.length; p += 1) {
128
+ next[slots[p]] =
129
+ p < position + DECODE_WINDOW
130
+ ? DECODE_GLYPHS[
131
+ Math.floor(Math.random() * DECODE_GLYPHS.length)
132
+ ]
133
+ : ""
134
+ }
135
+ setOutput(next.join(""))
106
136
  }
137
+
138
+ frameId = requestAnimationFrame(frame)
107
139
  }
108
- timeoutId = setTimeout(tick, speed)
140
+
141
+ setOutput("")
142
+ frameId = requestAnimationFrame(frame)
143
+ return
109
144
  }
110
145
 
111
146
  timeoutId = setTimeout(start, delay)
@@ -113,6 +148,7 @@ export const TextAnimate = ({
113
148
  return () => {
114
149
  cancelled = true
115
150
  if (timeoutId) clearTimeout(timeoutId)
151
+ if (frameId) cancelAnimationFrame(frameId)
116
152
  }
117
153
  }, [chars, delay, effect, replay, speed, text])
118
154
 
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
  )
@@ -29,54 +42,149 @@ export const ToastPortal = (props: ToastPortalProps) => (
29
42
  export const ToastViewport = ({ className, ...props }: ToastViewportProps) => (
30
43
  <BaseToast.Viewport
31
44
  // No flex column: the toasts inside are absolutely positioned so they pile
32
- // up as a stack. In flow they would march down past the bottom edge.
45
+ // up as a stack. In flow they would march down past the edge.
33
46
  className={cn(
34
- "fixed right-4 bottom-4 z-[100] w-[min(100vw-2rem,24rem)] outline-none",
47
+ "fixed top-8 left-1/2 z-[100] w-[min(100vw-2rem,28rem)] -translate-x-1/2 outline-none",
35
48
  className,
36
49
  )}
37
50
  {...props}
38
51
  />
39
52
  )
40
53
 
41
- /** Each toast is pinned to the bottom of the viewport and lifted by its own
42
- * index, so the stack reads as a pile of cards rather than a list: the one
43
- * behind peeks out by `--peek` and sits a step smaller. Hovering the viewport
44
- * sets `data-expanded`, which fans them out to their real heights. */
45
- 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) => (
46
117
  <BaseToast.Root
47
- className={cn(
48
- "absolute right-0 bottom-0 left-auto z-[calc(1000-var(--toast-index))] box-border w-full origin-bottom",
49
- "[--gap:0.625rem] [--peek:0.75rem]",
50
- "[--scale:calc(max(0,1-(var(--toast-index)*0.05)))] [--shrink:calc(1-var(--scale))]",
51
- "[--height:var(--toast-frontmost-height,var(--toast-height))]",
52
- "[--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y,0px))]",
53
- // No border: `shadow-lg` composes `--elevation-hairline`, the same edge the
54
- // dropdowns carry — theme-aware and 0.5px on hi-dpi. A border on top of it
55
- // stacked a second, darker line. The error type tints that edge with a
56
- // ring, which also sits outside the box model so the height never shifts.
57
- "rounded-xl bg-surface px-3.5 py-3 shadow-lg outline-none select-none",
58
- // Collapsed, every toast behind the front one is scaled down and shifted
59
- // up; `--shrink` cancels the gap that scaling opens at the bottom edge.
60
- "h-[var(--height)] data-expanded:h-[var(--toast-height)]",
61
- "[transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(calc(var(--toast-swipe-movement-y,0px)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))]",
62
- "data-expanded:[transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(var(--offset-y))]",
63
- // Bridges the gap between cards so crossing it does not collapse the fan.
64
- "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
65
- "transition-[transform,opacity,height] duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
66
- "data-starting-style:[transform:translateY(150%)]",
67
- "data-ending-style:opacity-0 data-limited:opacity-0",
68
- "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
69
- "data-[type=error]:ring-1 data-[type=error]:ring-destructive/40",
70
- className,
71
- )}
118
+ data-variant={variant}
119
+ className={cn("group/toast", toastRootVariants({ variant }), className)}
72
120
  {...props}
73
121
  />
74
122
  )
75
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
+
76
184
  export const ToastContent = ({ className, ...props }: ToastContentProps) => (
77
185
  <BaseToast.Content
78
186
  className={cn(
79
- "flex flex-col gap-0.5 overflow-hidden transition-opacity duration-[var(--duration-sm)]",
187
+ "flex min-w-0 flex-1 flex-col gap-0.5 overflow-hidden transition-opacity duration-[var(--duration-sm)]",
80
188
  "data-behind:pointer-events-none data-behind:opacity-0 data-expanded:data-behind:opacity-100",
81
189
  className,
82
190
  )}
@@ -86,7 +194,7 @@ export const ToastContent = ({ className, ...props }: ToastContentProps) => (
86
194
 
87
195
  export const ToastTitle = ({ className, ...props }: ToastTitleProps) => (
88
196
  <BaseToast.Title
89
- className={cn("text-xs-strong pr-10 text-fg-primary", className)}
197
+ className={cn("text-sm-strong text-fg-primary group-data-[variant=inverted]/toast:text-fg-inverted", className)}
90
198
  {...props}
91
199
  />
92
200
  )
@@ -96,7 +204,10 @@ export const ToastDescription = ({
96
204
  ...props
97
205
  }: ToastDescriptionProps) => (
98
206
  <BaseToast.Description
99
- className={cn("text-xs 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
+ )}
100
211
  {...props}
101
212
  />
102
213
  )
@@ -104,9 +215,10 @@ export const ToastDescription = ({
104
215
  export const ToastAction = ({ className, ...props }: ToastActionProps) => (
105
216
  <BaseToast.Action
106
217
  className={cn(
107
- "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",
108
219
  motion.colors,
109
- "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",
110
222
  className,
111
223
  )}
112
224
  {...props}
@@ -120,9 +232,10 @@ export const ToastClose = ({
120
232
  }: ToastCloseProps) => (
121
233
  <BaseToast.Close
122
234
  className={cn(
123
- "absolute top-2.5 right-2.5 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",
124
236
  motion.colors,
125
- "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",
126
239
  className,
127
240
  )}
128
241
  {...props}