create-omg 0.4.30
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 +14 -0
- package/dist/index.mjs +100 -0
- package/dist/template/AGENTS.md +49 -0
- package/dist/template/README.md +18 -0
- package/dist/template/_gitignore +24 -0
- package/dist/template/bun.lock +898 -0
- package/dist/template/eslint.config.js +23 -0
- package/dist/template/functions/.gitkeep +0 -0
- package/dist/template/index.html +20 -0
- package/dist/template/package.json +48 -0
- package/dist/template/public/favicon.svg +1 -0
- package/dist/template/public/icons/apple-touch-icon.png +0 -0
- package/dist/template/public/icons/pwa-192x192.png +0 -0
- package/dist/template/public/icons/pwa-512x512-maskable.png +0 -0
- package/dist/template/public/icons/pwa-512x512.png +0 -0
- package/dist/template/public/icons.svg +24 -0
- package/dist/template/schema.ts +5 -0
- package/dist/template/server/db.ts +103 -0
- package/dist/template/src/App.tsx +128 -0
- package/dist/template/src/components/ui/animated-number.tsx +102 -0
- package/dist/template/src/components/ui/animated-text.tsx +109 -0
- package/dist/template/src/components/ui/bottom-nav.tsx +131 -0
- package/dist/template/src/components/ui/button.tsx +84 -0
- package/dist/template/src/components/ui/card.tsx +70 -0
- package/dist/template/src/components/ui/otp-input.tsx +179 -0
- package/dist/template/src/components/ui/scroll-affordance.tsx +106 -0
- package/dist/template/src/components/ui/sound.ts +182 -0
- package/dist/template/src/components/ui/transitions.tsx +419 -0
- package/dist/template/src/components/ui/vibes-ui-styles.ts +388 -0
- package/dist/template/src/db.drizzle.ts +5 -0
- package/dist/template/src/index.css +1 -0
- package/dist/template/src/lib/utils.ts +6 -0
- package/dist/template/src/main.tsx +10 -0
- package/dist/template/tsconfig.app.json +30 -0
- package/dist/template/tsconfig.json +10 -0
- package/dist/template/tsconfig.node.json +24 -0
- package/dist/template/vite.config.ts +23 -0
- package/package.json +37 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// useSound / playSound — the AUDIO half of the delight kit (its haptic-ish
|
|
2
|
+
// sibling to the visual `transitions` / `AnimatedNumber` primitives).
|
|
3
|
+
//
|
|
4
|
+
// Every sound is SYNTHESIZED at runtime with the Web Audio API — there are no
|
|
5
|
+
// `.mp3`/`.wav` files to ship, fetch, or decode. The whole thing is a few
|
|
6
|
+
// oscillators and a gain envelope, so it adds ~0kb of assets and a couple kb of
|
|
7
|
+
// code, and it themes to nothing (pure tones) so it drops into any app.
|
|
8
|
+
//
|
|
9
|
+
// Design rules baked in, so a button that opts into sound never feels cheap:
|
|
10
|
+
// - One shared, lazily-created AudioContext, resumed on the first gesture.
|
|
11
|
+
// Browsers block audio until a user interaction, and a button press IS one,
|
|
12
|
+
// so the first click both unlocks and plays.
|
|
13
|
+
// - Silently no-ops when Web Audio is unavailable (SSR, old browsers) or when
|
|
14
|
+
// sound is disabled — exactly like the haptics singleton. It never throws.
|
|
15
|
+
// - Respects `prefers-reduced-motion`: a user who asked for a calmer, quieter
|
|
16
|
+
// experience gets silence unless the app explicitly forces sound on.
|
|
17
|
+
// - Short, quiet, rounded envelopes (soft attack + exponential release) so
|
|
18
|
+
// presses read as a tactile "tick", never a harsh beep.
|
|
19
|
+
//
|
|
20
|
+
// Self-contained: no CSS, no Tailwind, no runtime dependency beyond React.
|
|
21
|
+
// Edit the PRESETS table freely — it's vendored into your project.
|
|
22
|
+
|
|
23
|
+
import { useCallback, useMemo } from "react"
|
|
24
|
+
|
|
25
|
+
/** Built-in synthesized sounds. Pass one of these names to `play`. */
|
|
26
|
+
export type SoundName =
|
|
27
|
+
| "tap" // soft, short press tick — the default for buttons
|
|
28
|
+
| "click" // crisper, slightly louder press
|
|
29
|
+
| "toggleOn" // rising two-tone — switch/checkbox turning on
|
|
30
|
+
| "toggleOff" // falling two-tone — switch/checkbox turning off
|
|
31
|
+
| "success" // pleasant rising major third — save/confirm
|
|
32
|
+
| "error" // low, buzzy descending — invalid/destructive
|
|
33
|
+
| "notify" // gentle two-note chime — a new item/notification
|
|
34
|
+
|
|
35
|
+
type Tone = {
|
|
36
|
+
/** Oscillator frequency in Hz (or the START of a glide, with `to`). */
|
|
37
|
+
freq: number
|
|
38
|
+
/** Optional glide target — the pitch ramps from `freq` to `to`. */
|
|
39
|
+
to?: number
|
|
40
|
+
/** How long the tone rings, in seconds. */
|
|
41
|
+
duration: number
|
|
42
|
+
/** Peak gain (0–1). Kept low so sounds stay tactile, not startling. */
|
|
43
|
+
gain?: number
|
|
44
|
+
/** Oscillator shape. `sine`/`triangle` are soft; `square`/`sawtooth` bite. */
|
|
45
|
+
type?: OscillatorType
|
|
46
|
+
/** Delay before this tone starts, in seconds (for two-note sequences). */
|
|
47
|
+
delay?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Each preset is one or more tones layered/sequenced. Frequencies are chosen
|
|
51
|
+
// to sit in a musical relationship (unison, thirds, fifths) so multi-tone
|
|
52
|
+
// sounds read as "nice" rather than random.
|
|
53
|
+
const PRESETS: Record<SoundName, Tone[]> = {
|
|
54
|
+
tap: [{ freq: 660, duration: 0.05, gain: 0.05, type: "sine" }],
|
|
55
|
+
click: [{ freq: 880, duration: 0.045, gain: 0.07, type: "triangle" }],
|
|
56
|
+
toggleOn: [
|
|
57
|
+
{ freq: 587.33, duration: 0.06, gain: 0.05, type: "sine" },
|
|
58
|
+
{ freq: 880, duration: 0.07, gain: 0.05, type: "sine", delay: 0.045 },
|
|
59
|
+
],
|
|
60
|
+
toggleOff: [
|
|
61
|
+
{ freq: 880, duration: 0.06, gain: 0.05, type: "sine" },
|
|
62
|
+
{ freq: 587.33, duration: 0.07, gain: 0.05, type: "sine", delay: 0.045 },
|
|
63
|
+
],
|
|
64
|
+
success: [
|
|
65
|
+
{ freq: 523.25, duration: 0.08, gain: 0.05, type: "sine" },
|
|
66
|
+
{ freq: 659.25, duration: 0.12, gain: 0.05, type: "sine", delay: 0.07 },
|
|
67
|
+
],
|
|
68
|
+
error: [{ freq: 200, to: 120, duration: 0.16, gain: 0.06, type: "sawtooth" }],
|
|
69
|
+
notify: [
|
|
70
|
+
{ freq: 784, duration: 0.08, gain: 0.045, type: "triangle" },
|
|
71
|
+
{ freq: 1046.5, duration: 0.12, gain: 0.045, type: "triangle", delay: 0.06 },
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let ctx: AudioContext | null = null
|
|
76
|
+
let enabled = true
|
|
77
|
+
|
|
78
|
+
function prefersReducedMotion(): boolean {
|
|
79
|
+
return (
|
|
80
|
+
typeof window !== "undefined" &&
|
|
81
|
+
typeof window.matchMedia === "function" &&
|
|
82
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getContext(): AudioContext | null {
|
|
87
|
+
if (typeof window === "undefined") return null
|
|
88
|
+
const Ctor: typeof AudioContext | undefined =
|
|
89
|
+
window.AudioContext ??
|
|
90
|
+
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
|
91
|
+
.webkitAudioContext
|
|
92
|
+
if (!Ctor) return null
|
|
93
|
+
if (!ctx) {
|
|
94
|
+
try {
|
|
95
|
+
ctx = new Ctor()
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// A context created before a gesture starts `suspended`; a gesture-driven
|
|
101
|
+
// call resumes it. Fire-and-forget — if it's blocked, playback just no-ops.
|
|
102
|
+
if (ctx.state === "suspended") void ctx.resume().catch(() => {})
|
|
103
|
+
return ctx
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ring(context: AudioContext, tone: Tone, startAt: number) {
|
|
107
|
+
const osc = context.createOscillator()
|
|
108
|
+
const amp = context.createGain()
|
|
109
|
+
const peak = tone.gain ?? 0.05
|
|
110
|
+
const t0 = startAt + (tone.delay ?? 0)
|
|
111
|
+
const t1 = t0 + tone.duration
|
|
112
|
+
|
|
113
|
+
osc.type = tone.type ?? "sine"
|
|
114
|
+
osc.frequency.setValueAtTime(tone.freq, t0)
|
|
115
|
+
if (tone.to != null) osc.frequency.exponentialRampToValueAtTime(tone.to, t1)
|
|
116
|
+
|
|
117
|
+
// Soft attack, exponential release — a rounded "tick", not a click artifact.
|
|
118
|
+
amp.gain.setValueAtTime(0.0001, t0)
|
|
119
|
+
amp.gain.exponentialRampToValueAtTime(peak, t0 + 0.008)
|
|
120
|
+
amp.gain.exponentialRampToValueAtTime(0.0001, t1)
|
|
121
|
+
|
|
122
|
+
osc.connect(amp).connect(context.destination)
|
|
123
|
+
osc.start(t0)
|
|
124
|
+
osc.stop(t1 + 0.02)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Play a synthesized sound. Safe to call anywhere: silently no-ops on unsupported
|
|
129
|
+
* platforms, when sound is disabled, or under `prefers-reduced-motion`. Best
|
|
130
|
+
* called from within a user gesture (a click/press handler) so the browser lets
|
|
131
|
+
* audio through.
|
|
132
|
+
*
|
|
133
|
+
* You can also pass an ad-hoc tone (or array of tones) instead of a preset name
|
|
134
|
+
* to synthesize a one-off sound.
|
|
135
|
+
*/
|
|
136
|
+
export function playSound(sound: SoundName | Tone | Tone[]): void {
|
|
137
|
+
if (!enabled || prefersReducedMotion()) return
|
|
138
|
+
const context = getContext()
|
|
139
|
+
if (!context) return
|
|
140
|
+
const tones =
|
|
141
|
+
typeof sound === "string" ? PRESETS[sound] : Array.isArray(sound) ? sound : [sound]
|
|
142
|
+
if (!tones || tones.length === 0) return
|
|
143
|
+
const now = context.currentTime
|
|
144
|
+
try {
|
|
145
|
+
for (const tone of tones) ring(context, tone, now)
|
|
146
|
+
} catch {
|
|
147
|
+
// AudioContext can throw if it was closed out from under us — ignore.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Globally enable or disable all synthesized sound (e.g. a settings toggle).
|
|
153
|
+
* Defaults to enabled. Disabling makes every `playSound`/`useSound().play` a
|
|
154
|
+
* no-op without changing any call sites.
|
|
155
|
+
*/
|
|
156
|
+
export function setSoundEnabled(next: boolean): void {
|
|
157
|
+
enabled = next
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Whether synthesized sound is currently enabled (ignores reduced-motion). */
|
|
161
|
+
export function isSoundEnabled(): boolean {
|
|
162
|
+
return enabled
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Hook wrapper. Returns a stable `play` you can drop into event handlers, plus
|
|
167
|
+
* the enable/disable controls. `play` defaults to the `"tap"` press tick.
|
|
168
|
+
*
|
|
169
|
+
* ```tsx
|
|
170
|
+
* const { play } = useSound()
|
|
171
|
+
* <Button onClick={() => { play("success"); save() }}>Save</Button>
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
export function useSound() {
|
|
175
|
+
const play = useCallback((sound: SoundName | Tone | Tone[] = "tap") => {
|
|
176
|
+
playSound(sound)
|
|
177
|
+
}, [])
|
|
178
|
+
return useMemo(
|
|
179
|
+
() => ({ play, setEnabled: setSoundEnabled, isEnabled: isSoundEnabled }),
|
|
180
|
+
[play],
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// Transitions — dependency-free, CSS-only micro-interactions.
|
|
2
|
+
//
|
|
3
|
+
// Vendored from transitions.dev (Jakub Antalík), editable source. The motion
|
|
4
|
+
// lives in the injected `.vui-t-*` stylesheet (see vibes-ui-styles.ts); these
|
|
5
|
+
// React wrappers just own the awkward bits — flipping `.is-open` AFTER first
|
|
6
|
+
// paint so the open transition actually runs, keeping a closing element mounted
|
|
7
|
+
// until its exit finishes, and the three-phase reflow dance for swapping text.
|
|
8
|
+
// Self-contained: no Tailwind utility classes, no motion library, no runtime
|
|
9
|
+
// dependency beyond React. Only transform/opacity/filter animate, and every
|
|
10
|
+
// family honors prefers-reduced-motion.
|
|
11
|
+
//
|
|
12
|
+
// Reach for these when the DELIGHT rule "motion should REDUCE perceived travel"
|
|
13
|
+
// applies: a dialog, a menu, a drawer, a wizard step, or any element/label that
|
|
14
|
+
// swaps. Don't pull in Framer Motion for these — and don't wrap them in
|
|
15
|
+
// <AnimatePresence>; the two systems fight over the same element.
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
useEffect,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
type CSSProperties,
|
|
22
|
+
type ReactNode,
|
|
23
|
+
} from "react"
|
|
24
|
+
import { flushSync } from "react-dom"
|
|
25
|
+
import { ensureStyles } from "@/components/ui/vibes-ui-styles"
|
|
26
|
+
|
|
27
|
+
function cx(...parts: Array<string | false | null | undefined>): string {
|
|
28
|
+
return parts.filter(Boolean).join(" ")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mount/show lifecycle for an open/close element. Returns `mounted` (keep it in
|
|
33
|
+
* the DOM through the exit transition) and `show` (drives `.is-open`). On open
|
|
34
|
+
* it waits two animation frames before flipping `show`, so the element first
|
|
35
|
+
* paints in its closed state and the open transition has somewhere to travel
|
|
36
|
+
* from; on close it holds the element mounted for `closeMs` while `.is-closing`
|
|
37
|
+
* plays, then unmounts it.
|
|
38
|
+
*/
|
|
39
|
+
function usePresence(open: boolean, closeMs: number) {
|
|
40
|
+
const [mounted, setMounted] = useState(open)
|
|
41
|
+
const [show, setShow] = useState(open)
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (open) {
|
|
45
|
+
setMounted(true)
|
|
46
|
+
let raf2 = 0
|
|
47
|
+
const raf1 = requestAnimationFrame(() => {
|
|
48
|
+
raf2 = requestAnimationFrame(() => setShow(true))
|
|
49
|
+
})
|
|
50
|
+
return () => {
|
|
51
|
+
cancelAnimationFrame(raf1)
|
|
52
|
+
cancelAnimationFrame(raf2)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
setShow(false)
|
|
56
|
+
const timer = setTimeout(() => setMounted(false), closeMs)
|
|
57
|
+
return () => clearTimeout(timer)
|
|
58
|
+
}, [open, closeMs])
|
|
59
|
+
|
|
60
|
+
return { mounted, show }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Modal ──────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export type ModalProps = {
|
|
66
|
+
/** Whether the dialog is open. */
|
|
67
|
+
open: boolean
|
|
68
|
+
/** Called when the backdrop is clicked (wire it to your close handler). */
|
|
69
|
+
onClose?: () => void
|
|
70
|
+
children: ReactNode
|
|
71
|
+
/** Render a fading scrim behind the dialog. Default `true`. */
|
|
72
|
+
backdrop?: boolean
|
|
73
|
+
className?: string
|
|
74
|
+
style?: CSSProperties
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Centered dialog that scales + fades in, and (faster) back out. Stays mounted
|
|
79
|
+
* through the close transition, then unmounts itself. Renders an optional
|
|
80
|
+
* click-to-close backdrop. Style the dialog surface via `className` — bring your
|
|
81
|
+
* own `Card` or padding/background.
|
|
82
|
+
*/
|
|
83
|
+
export function Modal({
|
|
84
|
+
open,
|
|
85
|
+
onClose,
|
|
86
|
+
children,
|
|
87
|
+
backdrop = true,
|
|
88
|
+
className = "",
|
|
89
|
+
style,
|
|
90
|
+
}: ModalProps) {
|
|
91
|
+
useEffect(ensureStyles, [])
|
|
92
|
+
const { mounted, show } = usePresence(open, 150)
|
|
93
|
+
if (!mounted) return null
|
|
94
|
+
const state = show ? "is-open" : "is-closing"
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<>
|
|
98
|
+
{backdrop ? (
|
|
99
|
+
<div
|
|
100
|
+
className={cx("vui", "vui-t-backdrop", state)}
|
|
101
|
+
onClick={onClose}
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
/>
|
|
104
|
+
) : null}
|
|
105
|
+
<div
|
|
106
|
+
className={cx("vui", "vui-t-modal", state, className)}
|
|
107
|
+
style={style}
|
|
108
|
+
role="dialog"
|
|
109
|
+
aria-modal="true"
|
|
110
|
+
>
|
|
111
|
+
{children}
|
|
112
|
+
</div>
|
|
113
|
+
</>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Menu ─────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export type MenuOrigin =
|
|
120
|
+
| "top-left"
|
|
121
|
+
| "top-center"
|
|
122
|
+
| "top-right"
|
|
123
|
+
| "bottom-left"
|
|
124
|
+
| "bottom-center"
|
|
125
|
+
| "bottom-right"
|
|
126
|
+
|
|
127
|
+
export type MenuProps = {
|
|
128
|
+
/** Whether the menu is open. */
|
|
129
|
+
open: boolean
|
|
130
|
+
children: ReactNode
|
|
131
|
+
/** Corner the menu scales out from — match it to the trigger. Default `top-left`. */
|
|
132
|
+
origin?: MenuOrigin
|
|
133
|
+
className?: string
|
|
134
|
+
style?: CSSProperties
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Popover/dropdown that scales from its anchor corner. Position the element
|
|
139
|
+
* yourself (the caller owns layout — e.g. an absolutely-positioned wrapper under
|
|
140
|
+
* the trigger); this only animates open/close and unmounts when closed.
|
|
141
|
+
*/
|
|
142
|
+
export function Menu({
|
|
143
|
+
open,
|
|
144
|
+
children,
|
|
145
|
+
origin = "top-left",
|
|
146
|
+
className = "",
|
|
147
|
+
style,
|
|
148
|
+
}: MenuProps) {
|
|
149
|
+
useEffect(ensureStyles, [])
|
|
150
|
+
const { mounted, show } = usePresence(open, 150)
|
|
151
|
+
if (!mounted) return null
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div
|
|
155
|
+
className={cx("vui", "vui-t-menu", show ? "is-open" : "is-closing", className)}
|
|
156
|
+
data-origin={origin}
|
|
157
|
+
style={style}
|
|
158
|
+
role="menu"
|
|
159
|
+
>
|
|
160
|
+
{children}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Panel ────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
export type PanelProps = {
|
|
168
|
+
/** Whether the panel is revealed. */
|
|
169
|
+
open: boolean
|
|
170
|
+
children: ReactNode
|
|
171
|
+
className?: string
|
|
172
|
+
style?: CSSProperties
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Inline reveal — a drawer, sheet, or expander that slides up, fades, and
|
|
177
|
+
* un-blurs into place. Stays mounted in both states (so it can clip inside an
|
|
178
|
+
* `overflow: hidden` container); set `--vui-t-panel-y` to ~half the panel
|
|
179
|
+
* height via `style` for a fuller travel.
|
|
180
|
+
*/
|
|
181
|
+
export function Panel({ open, children, className = "", style }: PanelProps) {
|
|
182
|
+
useEffect(ensureStyles, [])
|
|
183
|
+
return (
|
|
184
|
+
<div
|
|
185
|
+
className={cx("vui", "vui-t-panel", className)}
|
|
186
|
+
data-open={open ? "true" : "false"}
|
|
187
|
+
style={style}
|
|
188
|
+
>
|
|
189
|
+
{children}
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Expand ───────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
export type ExpandProps = {
|
|
197
|
+
/** Whether the region is expanded. */
|
|
198
|
+
open: boolean
|
|
199
|
+
children: ReactNode
|
|
200
|
+
className?: string
|
|
201
|
+
style?: CSSProperties
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Height-auto reveal that animates smoothly WITHOUT measuring the DOM — an
|
|
206
|
+
* accordion row, a disclosure, a "show more" section, a collapsible filter
|
|
207
|
+
* panel. Built on the `grid-template-rows: 0fr → 1fr` trick, so it interpolates
|
|
208
|
+
* to the content's natural height (no fixed `max-height` guesswork, no JS
|
|
209
|
+
* measurement, no clipping when the content grows). Stays mounted in both
|
|
210
|
+
* states and clips its overflow while collapsed. Reach for this instead of
|
|
211
|
+
* toggling `hidden`/`display:none` (which snaps) or hand-rolling a `max-height`
|
|
212
|
+
* hack. For an overlay drawer/sheet that floats above content use `Panel`; this
|
|
213
|
+
* one pushes surrounding layout as it opens.
|
|
214
|
+
*/
|
|
215
|
+
export function Expand({ open, children, className = "", style }: ExpandProps) {
|
|
216
|
+
useEffect(ensureStyles, [])
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
className={cx("vui", "vui-t-expand", className)}
|
|
220
|
+
data-open={open ? "true" : "false"}
|
|
221
|
+
style={style}
|
|
222
|
+
>
|
|
223
|
+
<div className="vui-t-expand-inner">{children}</div>
|
|
224
|
+
</div>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── animateView (View Transitions) ─────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Run a state update inside a native View Transition so the browser cross-fades
|
|
232
|
+
* / morphs between the before and after DOM — list reorders, a filter that adds
|
|
233
|
+
* or removes rows, a route/tab swap, an item moving between two lists. Give the
|
|
234
|
+
* elements that should morph a stable `viewTransitionName` (via `style`) and the
|
|
235
|
+
* browser tweens their position/size for you.
|
|
236
|
+
*
|
|
237
|
+
* Uses `document.startViewTransition` with `flushSync` so React commits the
|
|
238
|
+
* update synchronously inside the transition callback. Degrades gracefully: if
|
|
239
|
+
* the API is unsupported OR the user prefers reduced motion, the update is
|
|
240
|
+
* applied immediately with no animation. Safe to call from any event handler.
|
|
241
|
+
*
|
|
242
|
+
* animateView(() => setItems(reorder(items)))
|
|
243
|
+
*/
|
|
244
|
+
export function animateView(update: () => void): void {
|
|
245
|
+
const doc = document as Document & {
|
|
246
|
+
startViewTransition?: (cb: () => void | Promise<void>) => unknown
|
|
247
|
+
}
|
|
248
|
+
const reducedMotion =
|
|
249
|
+
typeof window !== "undefined" &&
|
|
250
|
+
typeof window.matchMedia === "function" &&
|
|
251
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
252
|
+
|
|
253
|
+
if (typeof doc.startViewTransition !== "function" || reducedMotion) {
|
|
254
|
+
update()
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
doc.startViewTransition(() => flushSync(update))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── PageSlide ────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
export type PageSlideProps = {
|
|
263
|
+
/** Active page index (0-based). */
|
|
264
|
+
page: number
|
|
265
|
+
/** One node per page, in order. Pages before `page` exit left, after exit right. */
|
|
266
|
+
pages: ReactNode[]
|
|
267
|
+
className?: string
|
|
268
|
+
style?: CSSProperties
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Side-by-side wizard / tab steps. Only the active page is interactive; the
|
|
273
|
+
* others sit stacked, blurred, and shifted toward the side they came from, so
|
|
274
|
+
* forward/back reads as horizontal travel. Keeps every page mounted.
|
|
275
|
+
*/
|
|
276
|
+
export function PageSlide({ page, pages, className = "", style }: PageSlideProps) {
|
|
277
|
+
useEffect(ensureStyles, [])
|
|
278
|
+
return (
|
|
279
|
+
<div className={cx("vui", "vui-t-pages", className)} style={style}>
|
|
280
|
+
{pages.map((node, i) => (
|
|
281
|
+
<div
|
|
282
|
+
key={i}
|
|
283
|
+
className={cx("vui-t-page", i === page && "is-active")}
|
|
284
|
+
style={
|
|
285
|
+
{
|
|
286
|
+
"--vui-t-page-x":
|
|
287
|
+
i < page
|
|
288
|
+
? "calc(var(--vui-t-page-dist, 8px) * -1)"
|
|
289
|
+
: "var(--vui-t-page-dist, 8px)",
|
|
290
|
+
} as CSSProperties
|
|
291
|
+
}
|
|
292
|
+
aria-hidden={i === page ? undefined : true}
|
|
293
|
+
>
|
|
294
|
+
{node}
|
|
295
|
+
</div>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── IconSwap ─────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
export type IconSwapProps = {
|
|
304
|
+
/** Which icon is showing. */
|
|
305
|
+
active: "a" | "b"
|
|
306
|
+
/** First icon (shown when `active === "a"`). */
|
|
307
|
+
a: ReactNode
|
|
308
|
+
/** Second icon (shown when `active === "b"`). */
|
|
309
|
+
b: ReactNode
|
|
310
|
+
className?: string
|
|
311
|
+
style?: CSSProperties
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Cross-fade + scale between two stacked icons (play/pause, moon/sun,
|
|
316
|
+
* copy/check). Both icons render at once, sharing a grid cell; the inactive one
|
|
317
|
+
* fades, blurs, and shrinks away.
|
|
318
|
+
*/
|
|
319
|
+
export function IconSwap({ active, a, b, className = "", style }: IconSwapProps) {
|
|
320
|
+
useEffect(ensureStyles, [])
|
|
321
|
+
return (
|
|
322
|
+
<span
|
|
323
|
+
className={cx("vui", "vui-t-icon-swap", className)}
|
|
324
|
+
data-state={active}
|
|
325
|
+
style={style}
|
|
326
|
+
>
|
|
327
|
+
<span className="vui-t-icon" data-icon="a" aria-hidden={active !== "a"}>
|
|
328
|
+
{a}
|
|
329
|
+
</span>
|
|
330
|
+
<span className="vui-t-icon" data-icon="b" aria-hidden={active !== "b"}>
|
|
331
|
+
{b}
|
|
332
|
+
</span>
|
|
333
|
+
</span>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── TextSwap ─────────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
export type TextSwapProps = {
|
|
340
|
+
/** The current text. Changing it animates the old out (up) and the new in (from below). */
|
|
341
|
+
children: string
|
|
342
|
+
className?: string
|
|
343
|
+
style?: CSSProperties
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Swap a short label/status string in place: the old text lifts up + blurs +
|
|
348
|
+
* fades, then the new text rises from below. Handy for state words ("Saving…" →
|
|
349
|
+
* "Saved", "Copy" → "Copied"). For runtime numbers reach for `AnimatedNumber`,
|
|
350
|
+
* and for rolling slot-text reach for `AnimatedText`; this is for arbitrary
|
|
351
|
+
* label replacement.
|
|
352
|
+
*/
|
|
353
|
+
export function TextSwap({ children, className = "", style }: TextSwapProps) {
|
|
354
|
+
useEffect(ensureStyles, [])
|
|
355
|
+
const ref = useRef<HTMLSpanElement | null>(null)
|
|
356
|
+
const [text, setText] = useState(children)
|
|
357
|
+
const prev = useRef(children)
|
|
358
|
+
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
const el = ref.current
|
|
361
|
+
if (!el || children === prev.current) return
|
|
362
|
+
const next = children
|
|
363
|
+
prev.current = next
|
|
364
|
+
|
|
365
|
+
el.classList.add("is-exit")
|
|
366
|
+
const dur =
|
|
367
|
+
parseFloat(getComputedStyle(el).getPropertyValue("--vui-t-text-dur")) || 200
|
|
368
|
+
const timer = setTimeout(() => {
|
|
369
|
+
setText(next)
|
|
370
|
+
el.classList.remove("is-exit")
|
|
371
|
+
el.classList.add("is-enter-start")
|
|
372
|
+
// Force reflow so the jump-below state paints before we release it.
|
|
373
|
+
void el.offsetWidth
|
|
374
|
+
el.classList.remove("is-enter-start")
|
|
375
|
+
}, dur)
|
|
376
|
+
return () => clearTimeout(timer)
|
|
377
|
+
}, [children])
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<span ref={ref} className={cx("vui", "vui-t-text", className)} style={style}>
|
|
381
|
+
{text}
|
|
382
|
+
</span>
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── NotificationBadge ────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
export type NotificationBadgeProps = {
|
|
389
|
+
/** Whether the badge is shown. Toggling pops it in / out. */
|
|
390
|
+
show: boolean
|
|
391
|
+
/** Badge contents (a count, a dot). */
|
|
392
|
+
children?: ReactNode
|
|
393
|
+
className?: string
|
|
394
|
+
style?: CSSProperties
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* A count/dot that pops in with a slight overshoot and shrinks-blurs out. Place
|
|
399
|
+
* it inside a `position: relative` trigger (e.g. a bell button); it pins to the
|
|
400
|
+
* top-right corner and never intercepts pointer events.
|
|
401
|
+
*/
|
|
402
|
+
export function NotificationBadge({
|
|
403
|
+
show,
|
|
404
|
+
children,
|
|
405
|
+
className = "",
|
|
406
|
+
style,
|
|
407
|
+
}: NotificationBadgeProps) {
|
|
408
|
+
useEffect(ensureStyles, [])
|
|
409
|
+
return (
|
|
410
|
+
<span
|
|
411
|
+
className={cx("vui", "vui-t-badge", className)}
|
|
412
|
+
data-open={show ? "true" : "false"}
|
|
413
|
+
style={style}
|
|
414
|
+
aria-hidden={!show}
|
|
415
|
+
>
|
|
416
|
+
<span className="vui-t-badge-dot">{children}</span>
|
|
417
|
+
</span>
|
|
418
|
+
)
|
|
419
|
+
}
|