@boyernick/standard-ui-react 0.1.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.
Files changed (67) hide show
  1. package/README.md +9 -0
  2. package/package.json +46 -0
  3. package/src/accordion.tsx +84 -0
  4. package/src/alert-dialog.tsx +101 -0
  5. package/src/attachment.tsx +138 -0
  6. package/src/autocomplete.tsx +281 -0
  7. package/src/avatar.tsx +56 -0
  8. package/src/badge.tsx +28 -0
  9. package/src/brand.tsx +70 -0
  10. package/src/breadcrumb.tsx +81 -0
  11. package/src/button.tsx +129 -0
  12. package/src/calendar.tsx +84 -0
  13. package/src/card.tsx +54 -0
  14. package/src/carousel.tsx +253 -0
  15. package/src/chart.tsx +185 -0
  16. package/src/checkbox-group.tsx +14 -0
  17. package/src/checkbox.tsx +31 -0
  18. package/src/code-block.tsx +124 -0
  19. package/src/collapsible.tsx +61 -0
  20. package/src/combobox.tsx +324 -0
  21. package/src/command.tsx +377 -0
  22. package/src/context-menu.tsx +250 -0
  23. package/src/dialog.tsx +78 -0
  24. package/src/drawer.tsx +156 -0
  25. package/src/empty.tsx +52 -0
  26. package/src/field.tsx +75 -0
  27. package/src/fieldset.tsx +25 -0
  28. package/src/form.tsx +14 -0
  29. package/src/icons.tsx +91 -0
  30. package/src/illustrations.tsx +167 -0
  31. package/src/image-modal.tsx +110 -0
  32. package/src/index.ts +833 -0
  33. package/src/input.tsx +68 -0
  34. package/src/lib/cn.ts +3 -0
  35. package/src/lib/motion.ts +23 -0
  36. package/src/markdown-editor.tsx +302 -0
  37. package/src/menu.tsx +205 -0
  38. package/src/menubar.tsx +22 -0
  39. package/src/meter.tsx +61 -0
  40. package/src/navigation-menu.tsx +217 -0
  41. package/src/number-field.tsx +119 -0
  42. package/src/orb.tsx +33 -0
  43. package/src/otp-field.tsx +45 -0
  44. package/src/pagination.tsx +91 -0
  45. package/src/popover.tsx +92 -0
  46. package/src/preview-card.tsx +103 -0
  47. package/src/progress.tsx +60 -0
  48. package/src/radio.tsx +43 -0
  49. package/src/scroll-area.tsx +67 -0
  50. package/src/select.tsx +161 -0
  51. package/src/separator.tsx +17 -0
  52. package/src/sidebar.tsx +82 -0
  53. package/src/skeleton.tsx +32 -0
  54. package/src/slider.tsx +56 -0
  55. package/src/sounds.tsx +270 -0
  56. package/src/spinner.tsx +45 -0
  57. package/src/switch.tsx +19 -0
  58. package/src/table.tsx +81 -0
  59. package/src/tabs.tsx +62 -0
  60. package/src/text-animate.tsx +155 -0
  61. package/src/textarea.tsx +58 -0
  62. package/src/ticker.tsx +74 -0
  63. package/src/toast.tsx +142 -0
  64. package/src/toggle.tsx +32 -0
  65. package/src/toolbar.tsx +75 -0
  66. package/src/tooltip.tsx +55 -0
  67. package/src/video-player.tsx +241 -0
package/src/sounds.tsx ADDED
@@ -0,0 +1,270 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ComponentProps,
12
+ type ReactNode,
13
+ } from "react"
14
+ import { Button } from "./button"
15
+ import { cn } from "./lib/cn"
16
+ import { motion } from "./lib/motion"
17
+
18
+ export type SoundId = "click" | "success" | "error" | "notify"
19
+
20
+ type SoundPreset = {
21
+ id: SoundId
22
+ label: string
23
+ description: string
24
+ play: (ctx: AudioContext, when: number, destination: AudioNode) => void
25
+ }
26
+
27
+ const SOUND_PRESETS: SoundPreset[] = [
28
+ {
29
+ id: "click",
30
+ label: "Click",
31
+ description: "Light confirmation for toggles and secondary actions.",
32
+ play: (ctx, when, destination) => {
33
+ const osc = ctx.createOscillator()
34
+ const gain = ctx.createGain()
35
+ osc.type = "triangle"
36
+ osc.frequency.setValueAtTime(880, when)
37
+ gain.gain.setValueAtTime(0.0001, when)
38
+ gain.gain.exponentialRampToValueAtTime(0.08, when + 0.01)
39
+ gain.gain.exponentialRampToValueAtTime(0.0001, when + 0.08)
40
+ osc.connect(gain)
41
+ gain.connect(destination)
42
+ osc.start(when)
43
+ osc.stop(when + 0.09)
44
+ },
45
+ },
46
+ {
47
+ id: "success",
48
+ label: "Success",
49
+ description: "Positive completion for saves and confirmations.",
50
+ play: (ctx, when, destination) => {
51
+ const notes = [523.25, 659.25, 783.99]
52
+ notes.forEach((frequency, index) => {
53
+ const osc = ctx.createOscillator()
54
+ const gain = ctx.createGain()
55
+ const start = when + index * 0.08
56
+ osc.type = "sine"
57
+ osc.frequency.setValueAtTime(frequency, start)
58
+ gain.gain.setValueAtTime(0.0001, start)
59
+ gain.gain.exponentialRampToValueAtTime(0.09, start + 0.02)
60
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.22)
61
+ osc.connect(gain)
62
+ gain.connect(destination)
63
+ osc.start(start)
64
+ osc.stop(start + 0.24)
65
+ })
66
+ },
67
+ },
68
+ {
69
+ id: "error",
70
+ label: "Error",
71
+ description: "Attention for failed actions and validation.",
72
+ play: (ctx, when, destination) => {
73
+ const osc = ctx.createOscillator()
74
+ const gain = ctx.createGain()
75
+ osc.type = "sawtooth"
76
+ osc.frequency.setValueAtTime(220, when)
77
+ osc.frequency.exponentialRampToValueAtTime(140, when + 0.18)
78
+ gain.gain.setValueAtTime(0.0001, when)
79
+ gain.gain.exponentialRampToValueAtTime(0.07, when + 0.02)
80
+ gain.gain.exponentialRampToValueAtTime(0.0001, when + 0.22)
81
+ osc.connect(gain)
82
+ gain.connect(destination)
83
+ osc.start(when)
84
+ osc.stop(when + 0.24)
85
+ },
86
+ },
87
+ {
88
+ id: "notify",
89
+ label: "Notify",
90
+ description: "Soft cue for toasts and background updates.",
91
+ play: (ctx, when, destination) => {
92
+ const osc = ctx.createOscillator()
93
+ const gain = ctx.createGain()
94
+ osc.type = "sine"
95
+ osc.frequency.setValueAtTime(660, when)
96
+ osc.frequency.setValueAtTime(880, when + 0.12)
97
+ gain.gain.setValueAtTime(0.0001, when)
98
+ gain.gain.exponentialRampToValueAtTime(0.06, when + 0.02)
99
+ gain.gain.exponentialRampToValueAtTime(0.0001, when + 0.28)
100
+ osc.connect(gain)
101
+ gain.connect(destination)
102
+ osc.start(when)
103
+ osc.stop(when + 0.3)
104
+ },
105
+ },
106
+ ]
107
+
108
+ type SoundsContextValue = {
109
+ muted: boolean
110
+ setMuted: (muted: boolean) => void
111
+ volume: number
112
+ setVolume: (volume: number) => void
113
+ play: (id: SoundId) => void
114
+ presets: SoundPreset[]
115
+ }
116
+
117
+ const SoundsContext = createContext<SoundsContextValue | null>(null)
118
+
119
+ export type SoundsProviderProps = {
120
+ children: ReactNode
121
+ defaultMuted?: boolean
122
+ defaultVolume?: number
123
+ }
124
+
125
+ export const SoundsProvider = ({
126
+ children,
127
+ defaultMuted = false,
128
+ defaultVolume = 0.8,
129
+ }: SoundsProviderProps) => {
130
+ const [muted, setMuted] = useState(defaultMuted)
131
+ const [volume, setVolume] = useState(defaultVolume)
132
+ const audioContextRef = useRef<AudioContext | null>(null)
133
+
134
+ const getContext = useCallback(() => {
135
+ if (typeof window === "undefined") return null
136
+ if (!audioContextRef.current) {
137
+ audioContextRef.current = new AudioContext()
138
+ }
139
+ return audioContextRef.current
140
+ }, [])
141
+
142
+ useEffect(() => {
143
+ return () => {
144
+ void audioContextRef.current?.close()
145
+ }
146
+ }, [])
147
+
148
+ const play = useCallback(
149
+ (id: SoundId) => {
150
+ if (muted) return
151
+ const preset = SOUND_PRESETS.find((item) => item.id === id)
152
+ if (!preset) return
153
+ const ctx = getContext()
154
+ if (!ctx) return
155
+ void ctx.resume()
156
+ const master = ctx.createGain()
157
+ master.gain.value = Math.min(1, Math.max(0, volume))
158
+ master.connect(ctx.destination)
159
+ preset.play(ctx, ctx.currentTime, master)
160
+ },
161
+ [getContext, muted, volume],
162
+ )
163
+
164
+ const value = useMemo(
165
+ () => ({
166
+ muted,
167
+ setMuted,
168
+ volume,
169
+ setVolume,
170
+ play,
171
+ presets: SOUND_PRESETS,
172
+ }),
173
+ [muted, play, volume],
174
+ )
175
+
176
+ return (
177
+ <SoundsContext.Provider value={value}>{children}</SoundsContext.Provider>
178
+ )
179
+ }
180
+
181
+ export const useSounds = () => {
182
+ const context = useContext(SoundsContext)
183
+ if (!context) {
184
+ throw new Error("useSounds must be used within <SoundsProvider>")
185
+ }
186
+ return context
187
+ }
188
+
189
+ export type SoundProps = ComponentProps<"div"> & {
190
+ id: SoundId
191
+ label?: string
192
+ description?: string
193
+ }
194
+
195
+ export const Sound = ({
196
+ id,
197
+ label,
198
+ description,
199
+ className,
200
+ ...props
201
+ }: SoundProps) => {
202
+ const { play, presets } = useSounds()
203
+ const preset = presets.find((item) => item.id === id)
204
+ const resolvedLabel = label ?? preset?.label ?? id
205
+ const resolvedDescription = description ?? preset?.description
206
+
207
+ const handlePlay = () => {
208
+ play(id)
209
+ }
210
+
211
+ return (
212
+ <div
213
+ data-slot="sound"
214
+ className={cn(
215
+ "flex items-center gap-3 rounded-xl border border-border-primary bg-surface p-3",
216
+ className,
217
+ )}
218
+ {...props}
219
+ >
220
+ <Button
221
+ type="button"
222
+ variant="outline"
223
+ size="sm"
224
+ iconOnly
225
+ rounded
226
+ aria-label={`Play ${resolvedLabel}`}
227
+ onClick={handlePlay}
228
+ >
229
+ <PlayIcon />
230
+ </Button>
231
+ <div className="min-w-0 flex-1">
232
+ <p className="text-sm-strong text-fg-primary">{resolvedLabel}</p>
233
+ {resolvedDescription ? (
234
+ <p className="text-sm mt-0.5 text-fg-tertiary">{resolvedDescription}</p>
235
+ ) : null}
236
+ </div>
237
+ <span className="text-xs font-mono text-fg-quaternary">{id}</span>
238
+ </div>
239
+ )
240
+ }
241
+
242
+ export type SoundToggleProps = ComponentProps<typeof Button>
243
+
244
+ export const SoundToggle = ({
245
+ className,
246
+ children,
247
+ ...props
248
+ }: SoundToggleProps) => {
249
+ const { muted, setMuted } = useSounds()
250
+
251
+ return (
252
+ <Button
253
+ type="button"
254
+ variant="outline"
255
+ size="sm"
256
+ aria-pressed={!muted}
257
+ className={cn(motion.colors, className)}
258
+ onClick={() => setMuted(!muted)}
259
+ {...props}
260
+ >
261
+ {children ?? (muted ? "Unmute sounds" : "Mute sounds")}
262
+ </Button>
263
+ )
264
+ }
265
+
266
+ const PlayIcon = () => (
267
+ <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
268
+ <path d="M9 7.5v9l8-4.5-8-4.5Z" fill="currentColor" />
269
+ </svg>
270
+ )
@@ -0,0 +1,45 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import type { ComponentProps } from "react";
3
+ import { cn } from "./lib/cn";
4
+
5
+ const spinnerVariants = cva(
6
+ "animate-spin text-current motion-reduce:animate-none",
7
+ { variants: {
8
+ size: {
9
+ sm: "size-3.5",
10
+ md: "size-4",
11
+ lg: "size-5",
12
+ },
13
+ },
14
+ defaultVariants: {
15
+ size: "md",
16
+ },
17
+ });
18
+
19
+ export type SpinnerProps = ComponentProps<"svg"> &
20
+ VariantProps<typeof spinnerVariants>;
21
+
22
+ export const Spinner = ({ className, size, ...props }: SpinnerProps) => (
23
+ <svg
24
+ role="status"
25
+ aria-label="Loading"
26
+ className={cn(spinnerVariants({ size }), className)}
27
+ viewBox="0 0 24 24"
28
+ fill="none"
29
+ {...props}
30
+ >
31
+ <path
32
+ d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"
33
+ stroke="currentColor"
34
+ strokeOpacity="0.3"
35
+ strokeWidth="2"
36
+ />
37
+ <path
38
+ d="M21 12C21 16.9706 16.9706 21 12 21"
39
+ stroke="currentColor"
40
+ strokeWidth="2"
41
+ />
42
+ </svg>
43
+ );
44
+
45
+ export { spinnerVariants };
package/src/switch.tsx ADDED
@@ -0,0 +1,19 @@
1
+ "use client"
2
+
3
+ import { Switch as BaseSwitch } from "@base-ui/react/switch"
4
+ import type { ComponentProps } from "react"
5
+ import { cn } from "./lib/cn"
6
+
7
+ export type SwitchProps = ComponentProps<typeof BaseSwitch.Root>
8
+
9
+ export const Switch = ({ className, ...props }: SwitchProps) => (
10
+ <BaseSwitch.Root
11
+ className={cn(
12
+ "relative inline-flex h-6 w-10 shrink-0 items-center rounded-full bg-background-quaternary transition-colors duration-150 ease-out motion-reduce:transition-none outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20 data-checked:bg-brand-primary data-disabled:cursor-not-allowed data-disabled:opacity-50",
13
+ className,
14
+ )}
15
+ {...props}
16
+ >
17
+ <BaseSwitch.Thumb className="pointer-events-none block size-5 translate-x-0.5 rounded-full bg-surface shadow-sm transition-transform duration-150 ease-out motion-reduce:transition-none data-checked:translate-x-[1.125rem]" />
18
+ </BaseSwitch.Root>
19
+ )
package/src/table.tsx ADDED
@@ -0,0 +1,81 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "./lib/cn";
3
+
4
+ export type TableProps = ComponentProps<"table">;
5
+ export type TableHeaderProps = ComponentProps<"thead">;
6
+ export type TableBodyProps = ComponentProps<"tbody">;
7
+ export type TableFooterProps = ComponentProps<"tfoot">;
8
+ export type TableRowProps = ComponentProps<"tr">;
9
+ export type TableHeadProps = ComponentProps<"th">;
10
+ export type TableCellProps = ComponentProps<"td">;
11
+ export type TableCaptionProps = ComponentProps<"caption">;
12
+
13
+ export const Table = ({ className, ...props }: TableProps) => (
14
+ <div className="w-full overflow-x-auto rounded-lg border border-border-primary bg-surface">
15
+ <table
16
+ className={cn(
17
+ "w-full border-collapse text-sm text-fg-primary",
18
+ className,
19
+ )}
20
+ {...props}
21
+ />
22
+ </div>
23
+ );
24
+
25
+ export const TableHeader = ({ className, ...props }: TableHeaderProps) => (
26
+ <thead
27
+ className={cn(
28
+ "bg-background-secondary [&_tr]:border-b [&_tr]:border-border-primary",
29
+ className,
30
+ )}
31
+ {...props}
32
+ />
33
+ );
34
+
35
+ export const TableBody = ({ className, ...props }: TableBodyProps) => (
36
+ <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />
37
+ );
38
+
39
+ export const TableFooter = ({ className, ...props }: TableFooterProps) => (
40
+ <tfoot
41
+ className={cn(
42
+ "border-t border-border-primary bg-background-secondary font-medium",
43
+ className,
44
+ )}
45
+ {...props}
46
+ />
47
+ );
48
+
49
+ export const TableRow = ({ className, ...props }: TableRowProps) => (
50
+ <tr
51
+ className={cn(
52
+ "border-b border-border-primary hover:bg-background-secondary",
53
+ className,
54
+ )}
55
+ {...props}
56
+ />
57
+ );
58
+
59
+ export const TableHead = ({ className, ...props }: TableHeadProps) => (
60
+ <th
61
+ className={cn(
62
+ "h-10 px-4 text-left text-xs-strong text-fg-secondary",
63
+ className,
64
+ )}
65
+ {...props}
66
+ />
67
+ );
68
+
69
+ export const TableCell = ({ className, ...props }: TableCellProps) => (
70
+ <td className={cn("px-4 py-3 align-middle", className)} {...props} />
71
+ );
72
+
73
+ export const TableCaption = ({ className, ...props }: TableCaptionProps) => (
74
+ <caption
75
+ className={cn(
76
+ "caption-bottom px-4 py-3 text-sm text-fg-secondary",
77
+ className,
78
+ )}
79
+ {...props}
80
+ />
81
+ );
package/src/tabs.tsx ADDED
@@ -0,0 +1,62 @@
1
+ "use client"
2
+
3
+ import { Tabs as BaseTabs } from "@base-ui/react/tabs"
4
+ import type { ComponentProps } from "react"
5
+ import { cn } from "./lib/cn"
6
+ import { motion } from "./lib/motion"
7
+
8
+ export type TabsProps = ComponentProps<typeof BaseTabs.Root>
9
+ export type TabsListProps = ComponentProps<typeof BaseTabs.List>
10
+ export type TabsTabProps = ComponentProps<typeof BaseTabs.Tab>
11
+ export type TabsIndicatorProps = ComponentProps<typeof BaseTabs.Indicator>
12
+ export type TabsPanelProps = ComponentProps<typeof BaseTabs.Panel>
13
+
14
+ export const Tabs = ({ className, ...props }: TabsProps) => (
15
+ <BaseTabs.Root className={cn("flex w-full flex-col", className)} {...props} />
16
+ )
17
+
18
+ export const TabsList = ({ className, ...props }: TabsListProps) => (
19
+ <BaseTabs.List
20
+ className={cn(
21
+ "relative flex gap-0.5 border-b border-border-primary",
22
+ className,
23
+ )}
24
+ {...props}
25
+ />
26
+ )
27
+
28
+ export const TabsTab = ({ className, ...props }: TabsTabProps) => (
29
+ <BaseTabs.Tab
30
+ className={cn(
31
+ "relative cursor-pointer px-3 py-2.5 text-sm text-fg-tertiary outline-none select-none",
32
+ motion.colors,
33
+ "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 data-active:text-fg-primary data-disabled:cursor-not-allowed data-disabled:opacity-50",
34
+ className,
35
+ )}
36
+ {...props}
37
+ />
38
+ )
39
+
40
+ export const TabsIndicator = ({
41
+ className,
42
+ ...props
43
+ }: TabsIndicatorProps) => (
44
+ <BaseTabs.Indicator
45
+ className={cn(
46
+ "absolute bottom-0 left-0 z-10 h-0.5 w-(--active-tab-width) translate-x-(--active-tab-left) rounded-full bg-brand-primary",
47
+ motion.tabsIndicator,
48
+ className,
49
+ )}
50
+ {...props}
51
+ />
52
+ )
53
+
54
+ export const TabsPanel = ({ className, ...props }: TabsPanelProps) => (
55
+ <BaseTabs.Panel
56
+ className={cn(
57
+ "pt-4 text-sm leading-relaxed text-fg-secondary outline-none focus-visible:outline-none",
58
+ className,
59
+ )}
60
+ {...props}
61
+ />
62
+ )
@@ -0,0 +1,155 @@
1
+ "use client"
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ type HTMLAttributes,
8
+ } from "react"
9
+ import { cn } from "./lib/cn"
10
+
11
+ const DECODE_GLYPHS =
12
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789#@$%&*"
13
+
14
+ export type TextAnimateEffect =
15
+ | "typewriter"
16
+ | "decode"
17
+ | "fade"
18
+ | "blur"
19
+
20
+ export type TextAnimateProps = HTMLAttributes<HTMLSpanElement> & {
21
+ text: string
22
+ effect?: TextAnimateEffect
23
+ /** Milliseconds per character (typewriter / decode). */
24
+ speed?: number
25
+ /** Delay before animation starts. */
26
+ delay?: number
27
+ /** Replay when `text` changes. Default true. */
28
+ replay?: boolean
29
+ as?: "span" | "p" | "h1" | "h2" | "h3"
30
+ }
31
+
32
+ export const TextAnimate = ({
33
+ text,
34
+ effect = "typewriter",
35
+ speed = 40,
36
+ delay = 0,
37
+ replay = true,
38
+ as: Tag = "span",
39
+ className,
40
+ ...props
41
+ }: TextAnimateProps) => {
42
+ const [output, setOutput] = useState(effect === "typewriter" || effect === "decode" ? "" : text)
43
+ const [done, setDone] = useState(effect === "fade" || effect === "blur")
44
+ const chars = useMemo(() => [...text], [text])
45
+
46
+ useEffect(() => {
47
+ let cancelled = false
48
+ let frame = 0
49
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
50
+
51
+ const start = () => {
52
+ if (
53
+ typeof window !== "undefined" &&
54
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
55
+ ) {
56
+ setOutput(text)
57
+ setDone(true)
58
+ return
59
+ }
60
+
61
+ if (effect === "fade" || effect === "blur") {
62
+ setOutput(text)
63
+ setDone(true)
64
+ return
65
+ }
66
+
67
+ setDone(false)
68
+
69
+ if (effect === "typewriter") {
70
+ setOutput("")
71
+ let i = 0
72
+ const tick = () => {
73
+ if (cancelled) return
74
+ i += 1
75
+ setOutput(text.slice(0, i))
76
+ if (i < text.length) {
77
+ timeoutId = setTimeout(tick, speed)
78
+ } else {
79
+ setDone(true)
80
+ }
81
+ }
82
+ timeoutId = setTimeout(tick, speed)
83
+ return
84
+ }
85
+
86
+ // decode
87
+ setOutput(
88
+ chars.map(() => DECODE_GLYPHS[Math.floor(Math.random() * DECODE_GLYPHS.length)]).join(""),
89
+ )
90
+ let revealed = 0
91
+ const tick = () => {
92
+ if (cancelled) return
93
+ revealed += 1
94
+ const next = chars
95
+ .map((char, index) => {
96
+ if (char === " ") return " "
97
+ if (index < revealed) return char
98
+ return DECODE_GLYPHS[Math.floor(Math.random() * DECODE_GLYPHS.length)]
99
+ })
100
+ .join("")
101
+ setOutput(next)
102
+ if (revealed < chars.length) {
103
+ timeoutId = setTimeout(tick, speed)
104
+ } else {
105
+ setOutput(text)
106
+ setDone(true)
107
+ }
108
+ }
109
+ timeoutId = setTimeout(tick, speed)
110
+ }
111
+
112
+ timeoutId = setTimeout(start, delay)
113
+
114
+ return () => {
115
+ cancelled = true
116
+ if (timeoutId) clearTimeout(timeoutId)
117
+ cancelAnimationFrame(frame)
118
+ }
119
+ }, [chars, delay, effect, replay, speed, text])
120
+
121
+ if (effect === "fade" || effect === "blur") {
122
+ return (
123
+ <Tag
124
+ className={cn(
125
+ "inline-block text-fg-primary",
126
+ effect === "fade" &&
127
+ "animate-[text-fade-in_0.6s_ease-out_both] motion-reduce:animate-none",
128
+ effect === "blur" &&
129
+ "animate-[text-blur-in_0.7s_ease-out_both] motion-reduce:animate-none",
130
+ className,
131
+ )}
132
+ style={{ animationDelay: `${delay}ms` }}
133
+ data-done={done || undefined}
134
+ {...props}
135
+ >
136
+ {text}
137
+ </Tag>
138
+ )
139
+ }
140
+
141
+ return (
142
+ <Tag
143
+ className={cn(
144
+ "inline-block text-fg-primary",
145
+ effect === "typewriter" && !done && "after:ml-0.5 after:inline-block after:h-[1em] after:w-px after:translate-y-[0.1em] after:bg-current after:align-baseline after:content-[''] after:animate-pulse motion-reduce:after:animate-none",
146
+ className,
147
+ )}
148
+ aria-label={text}
149
+ data-done={done || undefined}
150
+ {...props}
151
+ >
152
+ <span aria-hidden>{output || "\u00A0"}</span>
153
+ </Tag>
154
+ )
155
+ }
@@ -0,0 +1,58 @@
1
+ import { cva, type VariantProps } from "class-variance-authority"
2
+ import type { TextareaHTMLAttributes } from "react"
3
+ import { cn } from "./lib/cn"
4
+
5
+ const textareaVariants = cva(
6
+ "flex min-h-20 w-full cursor-text resize-y rounded-md px-3 py-2 text-sm text-fg-primary transition-[color,box-shadow] duration-150 ease-out placeholder:text-fg-quaternary 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 aria-invalid:border-destructive aria-invalid:focus-visible:border-destructive aria-invalid:focus-visible:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "border bg-surface inset-shadow-outline-top",
11
+ ghost: "border border-transparent bg-transparent",
12
+ },
13
+ invalid: {
14
+ true: "",
15
+ false: "",
16
+ },
17
+ },
18
+ compoundVariants: [
19
+ {
20
+ variant: "default",
21
+ invalid: false,
22
+ class: "border-border-secondary",
23
+ },
24
+ {
25
+ variant: "default",
26
+ invalid: true,
27
+ class: "border-destructive",
28
+ },
29
+ {
30
+ variant: "ghost",
31
+ invalid: true,
32
+ class: "border-destructive",
33
+ },
34
+ ],
35
+ defaultVariants: {
36
+ variant: "default",
37
+ invalid: false,
38
+ },
39
+ },
40
+ )
41
+
42
+ export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> &
43
+ VariantProps<typeof textareaVariants>
44
+
45
+ export const Textarea = ({
46
+ className,
47
+ variant,
48
+ invalid,
49
+ ...props
50
+ }: TextareaProps) => (
51
+ <textarea
52
+ className={cn(textareaVariants({ variant, invalid }), className)}
53
+ aria-invalid={invalid || undefined}
54
+ {...props}
55
+ />
56
+ )
57
+
58
+ export { textareaVariants }