create-omg 0.4.41 → 0.4.43
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 +16 -6
- package/dist/index.mjs +71 -12
- package/dist/{template → templates/react-ts}/AGENTS.md +22 -0
- package/dist/templates/react-ts/_gitignore +24 -0
- package/dist/{template → templates/react-ts}/bun.lock +105 -58
- package/dist/templates/react-ts/components.json +25 -0
- package/dist/templates/react-ts/eslint.config.js +23 -0
- package/dist/templates/react-ts/functions/.gitkeep +0 -0
- package/dist/{template → templates/react-ts}/package.json +5 -0
- package/dist/templates/react-ts/public/favicon.svg +1 -0
- package/dist/templates/react-ts/public/icons/apple-touch-icon.png +0 -0
- package/dist/templates/react-ts/public/icons/pwa-192x192.png +0 -0
- package/dist/templates/react-ts/public/icons/pwa-512x512-maskable.png +0 -0
- package/dist/templates/react-ts/public/icons/pwa-512x512.png +0 -0
- package/dist/templates/react-ts/public/icons.svg +24 -0
- package/dist/templates/react-ts/schema.ts +5 -0
- package/dist/templates/react-ts/server/db.ts +103 -0
- package/dist/templates/react-ts/src/App.tsx +128 -0
- package/dist/templates/react-ts/src/components/ui/animated-number.tsx +102 -0
- package/dist/templates/react-ts/src/components/ui/animated-text.tsx +109 -0
- package/dist/templates/react-ts/src/components/ui/bottom-nav.tsx +131 -0
- package/dist/templates/react-ts/src/components/ui/button.tsx +84 -0
- package/dist/templates/react-ts/src/components/ui/card.tsx +70 -0
- package/dist/templates/react-ts/src/components/ui/otp-input.tsx +179 -0
- package/dist/templates/react-ts/src/components/ui/scroll-affordance.tsx +106 -0
- package/dist/templates/react-ts/src/components/ui/sound.ts +182 -0
- package/dist/templates/react-ts/src/components/ui/transitions.tsx +419 -0
- package/dist/templates/react-ts/src/components/ui/vibes-ui-styles.ts +388 -0
- package/dist/templates/react-ts/src/db.drizzle.ts +5 -0
- package/dist/templates/react-ts/src/index.css +168 -0
- package/dist/templates/react-ts/src/lib/utils.ts +6 -0
- package/dist/templates/react-ts/src/main.tsx +22 -0
- package/dist/templates/react-ts/src/routeTree.gen.ts +59 -0
- package/dist/templates/react-ts/src/routes/__root.tsx +7 -0
- package/dist/templates/react-ts/src/routes/index.tsx +9 -0
- package/dist/templates/react-ts/tsconfig.app.json +30 -0
- package/dist/templates/react-ts/tsconfig.json +10 -0
- package/dist/templates/react-ts/tsconfig.node.json +24 -0
- package/dist/{template → templates/react-ts}/vite.config.ts +9 -1
- package/dist/templates/react-ts-start/AGENTS.md +75 -0
- package/dist/templates/react-ts-start/README.md +58 -0
- package/dist/templates/react-ts-start/bun.lock +1688 -0
- package/dist/templates/react-ts-start/package.json +58 -0
- package/dist/templates/react-ts-start/src/routeTree.gen.ts +68 -0
- package/dist/templates/react-ts-start/src/router.tsx +18 -0
- package/dist/templates/react-ts-start/src/routes/__root.tsx +40 -0
- package/dist/templates/react-ts-start/src/routes/index.tsx +7 -0
- package/dist/templates/react-ts-start/vite.config.ts +29 -0
- package/package.json +3 -3
- package/dist/template/src/main.tsx +0 -10
- /package/dist/{template → templates/react-ts}/README.md +0 -0
- /package/dist/{template → templates/react-ts}/index.html +0 -0
- /package/dist/{template → templates/react-ts-start}/_gitignore +0 -0
- /package/dist/{template → templates/react-ts-start}/components.json +0 -0
- /package/dist/{template → templates/react-ts-start}/eslint.config.js +0 -0
- /package/dist/{template → templates/react-ts-start}/functions/.gitkeep +0 -0
- /package/dist/{template → templates/react-ts-start}/public/favicon.svg +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/apple-touch-icon.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/pwa-192x192.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/pwa-512x512-maskable.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/pwa-512x512.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons.svg +0 -0
- /package/dist/{template → templates/react-ts-start}/schema.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/server/db.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/App.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/animated-number.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/animated-text.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/bottom-nav.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/button.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/card.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/otp-input.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/scroll-affordance.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/sound.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/transitions.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/vibes-ui-styles.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/db.drizzle.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/index.css +0 -0
- /package/dist/{template → templates/react-ts-start}/src/lib/utils.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/tsconfig.app.json +0 -0
- /package/dist/{template → templates/react-ts-start}/tsconfig.json +0 -0
- /package/dist/{template → templates/react-ts-start}/tsconfig.node.json +0 -0
|
@@ -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
|
+
}
|