@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.
- package/README.md +9 -0
- package/package.json +46 -0
- package/src/accordion.tsx +84 -0
- package/src/alert-dialog.tsx +101 -0
- package/src/attachment.tsx +138 -0
- package/src/autocomplete.tsx +281 -0
- package/src/avatar.tsx +56 -0
- package/src/badge.tsx +28 -0
- package/src/brand.tsx +70 -0
- package/src/breadcrumb.tsx +81 -0
- package/src/button.tsx +129 -0
- package/src/calendar.tsx +84 -0
- package/src/card.tsx +54 -0
- package/src/carousel.tsx +253 -0
- package/src/chart.tsx +185 -0
- package/src/checkbox-group.tsx +14 -0
- package/src/checkbox.tsx +31 -0
- package/src/code-block.tsx +124 -0
- package/src/collapsible.tsx +61 -0
- package/src/combobox.tsx +324 -0
- package/src/command.tsx +377 -0
- package/src/context-menu.tsx +250 -0
- package/src/dialog.tsx +78 -0
- package/src/drawer.tsx +156 -0
- package/src/empty.tsx +52 -0
- package/src/field.tsx +75 -0
- package/src/fieldset.tsx +25 -0
- package/src/form.tsx +14 -0
- package/src/icons.tsx +91 -0
- package/src/illustrations.tsx +167 -0
- package/src/image-modal.tsx +110 -0
- package/src/index.ts +833 -0
- package/src/input.tsx +68 -0
- package/src/lib/cn.ts +3 -0
- package/src/lib/motion.ts +23 -0
- package/src/markdown-editor.tsx +302 -0
- package/src/menu.tsx +205 -0
- package/src/menubar.tsx +22 -0
- package/src/meter.tsx +61 -0
- package/src/navigation-menu.tsx +217 -0
- package/src/number-field.tsx +119 -0
- package/src/orb.tsx +33 -0
- package/src/otp-field.tsx +45 -0
- package/src/pagination.tsx +91 -0
- package/src/popover.tsx +92 -0
- package/src/preview-card.tsx +103 -0
- package/src/progress.tsx +60 -0
- package/src/radio.tsx +43 -0
- package/src/scroll-area.tsx +67 -0
- package/src/select.tsx +161 -0
- package/src/separator.tsx +17 -0
- package/src/sidebar.tsx +82 -0
- package/src/skeleton.tsx +32 -0
- package/src/slider.tsx +56 -0
- package/src/sounds.tsx +270 -0
- package/src/spinner.tsx +45 -0
- package/src/switch.tsx +19 -0
- package/src/table.tsx +81 -0
- package/src/tabs.tsx +62 -0
- package/src/text-animate.tsx +155 -0
- package/src/textarea.tsx +58 -0
- package/src/ticker.tsx +74 -0
- package/src/toast.tsx +142 -0
- package/src/toggle.tsx +32 -0
- package/src/toolbar.tsx +75 -0
- package/src/tooltip.tsx +55 -0
- 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
|
+
)
|
package/src/spinner.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/textarea.tsx
ADDED
|
@@ -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 }
|