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,131 @@
|
|
|
1
|
+
// Floating bottom tab bar — the iOS-style pill switcher from the omg home
|
|
2
|
+
// screen, vendored for apps. A translucent blurred pill wrapped in a gradient
|
|
3
|
+
// white hairline that catches the light along its top edge (the way an iOS
|
|
4
|
+
// material does), with an active capsule that *slides* between tabs via a pure
|
|
5
|
+
// GPU translate.
|
|
6
|
+
//
|
|
7
|
+
// This is the presentational, controlled component: pass it your tabs, the
|
|
8
|
+
// active id, and an onSelect handler. It owns no routing — wire `active` /
|
|
9
|
+
// `onSelect` to your router or local state.
|
|
10
|
+
//
|
|
11
|
+
// const [tab, setTab] = useState("home")
|
|
12
|
+
// <BottomNav
|
|
13
|
+
// tabs={[
|
|
14
|
+
// { id: "home", label: "Home", Icon: Home },
|
|
15
|
+
// { id: "browse", label: "Browse", Icon: Compass },
|
|
16
|
+
// { id: "profile", label: "Profile", Icon: User },
|
|
17
|
+
// ]}
|
|
18
|
+
// active={tab}
|
|
19
|
+
// onSelect={setTab}
|
|
20
|
+
// />
|
|
21
|
+
//
|
|
22
|
+
// Self-contained: colours read the app's shadcn theme vars (--primary,
|
|
23
|
+
// --background, --muted-foreground) so it follows whatever preset the app uses.
|
|
24
|
+
// Vendored — edit freely.
|
|
25
|
+
|
|
26
|
+
import type { ComponentType, CSSProperties } from "react"
|
|
27
|
+
|
|
28
|
+
import { cn } from "@/lib/utils"
|
|
29
|
+
|
|
30
|
+
// Tab width and gap are fixed so the sliding capsule can be a pure translate —
|
|
31
|
+
// the only animated property (GPU-cheap, interruptible). TAB_GAP must match the
|
|
32
|
+
// `gap-1` between tab buttons.
|
|
33
|
+
const TAB_W = "4.5rem"
|
|
34
|
+
const TAB_GAP = "0.25rem"
|
|
35
|
+
// The omg motion curve (Ionic's iOS drawer easing).
|
|
36
|
+
const EASE = "cubic-bezier(0.32, 0.72, 0, 1)"
|
|
37
|
+
|
|
38
|
+
// A whisper of haptic feedback on tap where the platform supports it (iOS
|
|
39
|
+
// Safari ignores it; Android/Chrome buzz ~10ms). No-op everywhere else.
|
|
40
|
+
function haptic() {
|
|
41
|
+
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
|
|
42
|
+
try {
|
|
43
|
+
navigator.vibrate(8)
|
|
44
|
+
} catch {
|
|
45
|
+
/* feature-detected but disallowed (e.g. no user gesture) — ignore */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type BottomNavItem<T extends string> = {
|
|
51
|
+
id: T
|
|
52
|
+
label: string
|
|
53
|
+
Icon: ComponentType<{ className?: string }>
|
|
54
|
+
// Visible but inert — rendered dimmed; taps don't fire onSelect.
|
|
55
|
+
disabled?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function BottomNav<T extends string>({
|
|
59
|
+
tabs,
|
|
60
|
+
active,
|
|
61
|
+
onSelect,
|
|
62
|
+
className,
|
|
63
|
+
}: {
|
|
64
|
+
tabs: ReadonlyArray<BottomNavItem<T>>
|
|
65
|
+
active: T
|
|
66
|
+
onSelect: (id: T) => void
|
|
67
|
+
className?: string
|
|
68
|
+
}) {
|
|
69
|
+
const activeIndex = Math.max(
|
|
70
|
+
0,
|
|
71
|
+
tabs.findIndex((t) => t.id === active),
|
|
72
|
+
)
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
className={cn(
|
|
76
|
+
"pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center pb-[calc(0.875rem+env(safe-area-inset-bottom))]",
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{/* Gradient hairline: brightest on the top edge, fading down — the
|
|
81
|
+
border itself is the p-px ring around the blurred pill. */}
|
|
82
|
+
<div className="pointer-events-auto rounded-full bg-gradient-to-b from-white/70 via-white/25 to-white/10 p-px shadow-[0_8px_28px_oklch(0_0_0/14%)] dark:from-white/25 dark:via-white/10 dark:to-white/5">
|
|
83
|
+
<nav className="relative flex items-center gap-1 rounded-full bg-background/85 p-1.5 backdrop-blur-xl">
|
|
84
|
+
{/* Sliding capsule: translate by activeIndex × (tab width + gap). */}
|
|
85
|
+
<span
|
|
86
|
+
aria-hidden
|
|
87
|
+
className="absolute inset-y-1.5 left-1.5 w-[var(--tab-w)] rounded-full bg-primary/12 transition-transform duration-[260ms]"
|
|
88
|
+
style={
|
|
89
|
+
{
|
|
90
|
+
"--tab-w": TAB_W,
|
|
91
|
+
transitionTimingFunction: EASE,
|
|
92
|
+
transform: `translateX(calc(${activeIndex} * (${TAB_W} + ${TAB_GAP})))`,
|
|
93
|
+
} as CSSProperties
|
|
94
|
+
}
|
|
95
|
+
/>
|
|
96
|
+
{tabs.map(({ id, label, Icon, disabled }) => {
|
|
97
|
+
const selected = id === active
|
|
98
|
+
return (
|
|
99
|
+
<button
|
|
100
|
+
key={id}
|
|
101
|
+
type="button"
|
|
102
|
+
aria-label={label}
|
|
103
|
+
aria-current={selected ? "page" : undefined}
|
|
104
|
+
aria-disabled={disabled || undefined}
|
|
105
|
+
onClick={() => {
|
|
106
|
+
if (disabled) return
|
|
107
|
+
haptic()
|
|
108
|
+
if (!selected) onSelect(id)
|
|
109
|
+
}}
|
|
110
|
+
className={cn(
|
|
111
|
+
"relative flex w-[var(--tab-w)] flex-col items-center gap-0.5 rounded-full px-4 py-1.5 transition-[color,transform] duration-200 ease-out",
|
|
112
|
+
disabled
|
|
113
|
+
? "text-muted-foreground/40"
|
|
114
|
+
: selected
|
|
115
|
+
? "text-primary active:scale-[0.96]"
|
|
116
|
+
: "text-muted-foreground hover:text-foreground active:scale-[0.96]",
|
|
117
|
+
)}
|
|
118
|
+
style={{ "--tab-w": TAB_W } as CSSProperties}
|
|
119
|
+
>
|
|
120
|
+
<Icon className="h-[18px] w-[18px]" />
|
|
121
|
+
<span className="text-[10.5px] font-medium tracking-[-0.01em]">
|
|
122
|
+
{label}
|
|
123
|
+
</span>
|
|
124
|
+
</button>
|
|
125
|
+
)
|
|
126
|
+
})}
|
|
127
|
+
</nav>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
import { playSound, type SoundName } from "@/components/ui/sound"
|
|
5
|
+
|
|
6
|
+
// Squishy base Button — vendored, self-contained (no class-variance-authority
|
|
7
|
+
// and no @base-ui dependency, so it works in a bare app without extra installs).
|
|
8
|
+
//
|
|
9
|
+
// The iOS "squish": `active:scale-[0.97]` on press, eased with the omg motion
|
|
10
|
+
// curve, snapping back instantly on release (`active:transition-none`). Corners
|
|
11
|
+
// pair the baked `squircle` utility with `rounded-md` so they read as soft
|
|
12
|
+
// continuous corners on Chromium 139+ and fall back to normal rounding
|
|
13
|
+
// everywhere else.
|
|
14
|
+
//
|
|
15
|
+
// Opt-in press SOUND: pass `sound` (or `sound="success"` etc.) and the button
|
|
16
|
+
// plays a synthesized, zero-file Web Audio tick on press — the audio companion
|
|
17
|
+
// to the visual squish. Off by default (silence is the right default; sound on
|
|
18
|
+
// EVERY button is noise), gesture-driven so the browser lets it through, and it
|
|
19
|
+
// self-mutes under prefers-reduced-motion. See `./sound`.
|
|
20
|
+
//
|
|
21
|
+
// Every colour reads the app's shadcn theme variables (--primary, --secondary,
|
|
22
|
+
// …), so the button inherits whatever preset the app uses. Edit freely — it's
|
|
23
|
+
// vendored into your project, not imported from a package.
|
|
24
|
+
|
|
25
|
+
type Variant = "default" | "secondary" | "outline" | "ghost" | "destructive"
|
|
26
|
+
type Size = "default" | "sm" | "lg" | "icon"
|
|
27
|
+
|
|
28
|
+
const VARIANTS: Record<Variant, string> = {
|
|
29
|
+
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
|
30
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
31
|
+
outline:
|
|
32
|
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
33
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
34
|
+
destructive: "bg-destructive text-white hover:bg-destructive/90",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SIZES: Record<Size, string> = {
|
|
38
|
+
default: "h-9 px-4 py-2",
|
|
39
|
+
sm: "h-8 gap-1.5 px-3 text-xs",
|
|
40
|
+
lg: "h-10 px-6",
|
|
41
|
+
icon: "size-9",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ButtonProps
|
|
45
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
46
|
+
variant?: Variant
|
|
47
|
+
size?: Size
|
|
48
|
+
/**
|
|
49
|
+
* Play a synthesized press sound on pointer-down. `true` uses the soft
|
|
50
|
+
* `"tap"` tick; pass a preset name (`"click"`, `"success"`, …) to pick
|
|
51
|
+
* another. Off by default. Fires on pointer-down so the tick lands with the
|
|
52
|
+
* press, not the release.
|
|
53
|
+
*/
|
|
54
|
+
sound?: boolean | SoundName
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
58
|
+
function Button(
|
|
59
|
+
{ className, variant = "default", size = "default", sound, onPointerDown, ...props },
|
|
60
|
+
ref,
|
|
61
|
+
) {
|
|
62
|
+
const handlePointerDown = React.useCallback(
|
|
63
|
+
(event: React.PointerEvent<HTMLButtonElement>) => {
|
|
64
|
+
if (sound && !props.disabled) playSound(sound === true ? "tap" : sound)
|
|
65
|
+
onPointerDown?.(event)
|
|
66
|
+
},
|
|
67
|
+
[sound, props.disabled, onPointerDown],
|
|
68
|
+
)
|
|
69
|
+
return (
|
|
70
|
+
<button
|
|
71
|
+
ref={ref}
|
|
72
|
+
data-slot="button"
|
|
73
|
+
onPointerDown={handlePointerDown}
|
|
74
|
+
className={cn(
|
|
75
|
+
"inline-flex shrink-0 select-none items-center justify-center gap-2 rounded-md squircle text-sm font-medium whitespace-nowrap outline-none transition-[transform,background-color,box-shadow,border-color,color] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.97] active:transition-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
76
|
+
VARIANTS[variant],
|
|
77
|
+
SIZES[size],
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
{...props}
|
|
81
|
+
/>
|
|
82
|
+
)
|
|
83
|
+
},
|
|
84
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
// Squircle Card — shadcn-shaped (Card / CardHeader / CardTitle / CardDescription
|
|
6
|
+
// / CardContent / CardFooter) so it's a drop-in for anyone used to shadcn, with
|
|
7
|
+
// the iOS continuous-corner `squircle` on the root surface. Theme-var driven
|
|
8
|
+
// (bg-card / text-card-foreground), so it follows the app's preset. Vendored —
|
|
9
|
+
// edit freely.
|
|
10
|
+
|
|
11
|
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
data-slot="card"
|
|
15
|
+
className={cn(
|
|
16
|
+
"flex flex-col gap-6 rounded-xl squircle border bg-card py-6 text-card-foreground shadow-sm",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
data-slot="card-header"
|
|
28
|
+
className={cn("flex flex-col gap-1.5 px-6", className)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
data-slot="card-title"
|
|
38
|
+
className={cn("font-semibold leading-none tracking-tight", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
data-slot="card-description"
|
|
48
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
55
|
+
return (
|
|
56
|
+
<div data-slot="card-content" className={cn("px-6", className)} {...props} />
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
data-slot="card-footer"
|
|
64
|
+
className={cn("flex items-center px-6", className)}
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
import { playSound, type SoundName } from "@/components/ui/sound"
|
|
5
|
+
|
|
6
|
+
// <OtpInput> — segmented one-time-code field (the "type the 6-digit code"
|
|
7
|
+
// input). Vendored, self-contained (no input-otp / no @radix dependency), so
|
|
8
|
+
// it drops into a bare app with nothing to install.
|
|
9
|
+
//
|
|
10
|
+
// Why it's not N separate <input>s: this renders ONE real, focusable text
|
|
11
|
+
// input stretched invisibly across the field, with the digit cells painted
|
|
12
|
+
// beneath it. That single input keeps everything the platform gives you for
|
|
13
|
+
// free — native paste of a whole code, iOS/Android SMS autofill
|
|
14
|
+
// (`autocomplete="one-time-code"`), the numeric soft keyboard on mobile
|
|
15
|
+
// (`inputmode="numeric"`), and correct screen-reader semantics — while the
|
|
16
|
+
// cells are yours to style. Auto-advance and backspace "just work" because
|
|
17
|
+
// there's only one caret to move.
|
|
18
|
+
//
|
|
19
|
+
// Delight, on by the same rules as the rest of the kit:
|
|
20
|
+
// - Squircle continuous corners (paired with `rounded-md`, the fallback) so
|
|
21
|
+
// the cells match `Button`/`Card`.
|
|
22
|
+
// - The active cell shows a soft blinking caret; a filled field pops a subtle
|
|
23
|
+
// ring. Motion self-mutes under `prefers-reduced-motion`.
|
|
24
|
+
// - Opt-in tactile SOUND (`sound` prop): a quiet `tap` per digit and a
|
|
25
|
+
// `success` chime when the code completes — off by default (silence is the
|
|
26
|
+
// right default), gesture-driven, and self-muting under reduced-motion.
|
|
27
|
+
// See `./sound`.
|
|
28
|
+
//
|
|
29
|
+
// Controlled: drive `value` / `onChange` from state (mirrors how `VibesLogin`
|
|
30
|
+
// already tracks the code). `onComplete` fires once the field fills — wire your
|
|
31
|
+
// verify call there. Every colour reads the app's shadcn theme variables, so it
|
|
32
|
+
// inherits whatever preset the app uses. Edit freely — it's yours now.
|
|
33
|
+
|
|
34
|
+
export interface OtpInputProps {
|
|
35
|
+
/** The current code (controlled). Only allowed characters are ever stored. */
|
|
36
|
+
value: string
|
|
37
|
+
/** Called with the sanitized next value on every change. */
|
|
38
|
+
onChange: (value: string) => void
|
|
39
|
+
/** Fires once the field reaches `length`. Great place to auto-submit/verify. */
|
|
40
|
+
onComplete?: (value: string) => void
|
|
41
|
+
/** Number of cells. Defaults to 6 (the vibes email-code length). */
|
|
42
|
+
length?: number
|
|
43
|
+
/**
|
|
44
|
+
* Play a synthesized sound as the user types. `true` uses a soft `tap` per
|
|
45
|
+
* digit plus a `success` chime on completion; pass a preset name to use that
|
|
46
|
+
* tone for the per-digit tick instead. Off by default.
|
|
47
|
+
*/
|
|
48
|
+
sound?: boolean | SoundName
|
|
49
|
+
disabled?: boolean
|
|
50
|
+
autoFocus?: boolean
|
|
51
|
+
/** Soft-keyboard hint. `"numeric"` (default) for codes; `"text"` for alnum. */
|
|
52
|
+
inputMode?: "numeric" | "text"
|
|
53
|
+
/**
|
|
54
|
+
* Which characters are accepted. Defaults to digits only. Anything typed or
|
|
55
|
+
* pasted that doesn't match is dropped (so pasting "123-456" yields "123456").
|
|
56
|
+
*/
|
|
57
|
+
allowed?: RegExp
|
|
58
|
+
"aria-label"?: string
|
|
59
|
+
className?: string
|
|
60
|
+
/** Extra classes merged onto each cell (e.g. sizing, radius). */
|
|
61
|
+
cellClassName?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function OtpInput({
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
onComplete,
|
|
68
|
+
length = 6,
|
|
69
|
+
sound = false,
|
|
70
|
+
disabled = false,
|
|
71
|
+
autoFocus = false,
|
|
72
|
+
inputMode = "numeric",
|
|
73
|
+
allowed = /[0-9]/,
|
|
74
|
+
"aria-label": ariaLabel = "Verification code",
|
|
75
|
+
className,
|
|
76
|
+
cellClassName,
|
|
77
|
+
}: OtpInputProps) {
|
|
78
|
+
const [focused, setFocused] = React.useState(false)
|
|
79
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
80
|
+
// Guard so onComplete fires once per fill, not on every keystroke while full.
|
|
81
|
+
const completedRef = React.useRef(false)
|
|
82
|
+
|
|
83
|
+
const tapTone: SoundName = sound === true ? "tap" : (sound as SoundName)
|
|
84
|
+
|
|
85
|
+
const sanitize = React.useCallback(
|
|
86
|
+
(raw: string) =>
|
|
87
|
+
Array.from(raw)
|
|
88
|
+
.filter((ch) => allowed.test(ch))
|
|
89
|
+
.join("")
|
|
90
|
+
.slice(0, length),
|
|
91
|
+
[allowed, length],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
95
|
+
const next = sanitize(e.target.value)
|
|
96
|
+
if (next === value) return
|
|
97
|
+
const grew = next.length > value.length
|
|
98
|
+
onChange(next)
|
|
99
|
+
|
|
100
|
+
if (next.length === length) {
|
|
101
|
+
if (!completedRef.current) {
|
|
102
|
+
completedRef.current = true
|
|
103
|
+
if (sound) playSound("success")
|
|
104
|
+
onComplete?.(next)
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
completedRef.current = false
|
|
108
|
+
if (grew && sound) playSound(tapTone)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The active cell is the first empty slot, clamped to the last cell when full
|
|
113
|
+
// so the caret has somewhere to sit.
|
|
114
|
+
const activeIndex = Math.min(value.length, length - 1)
|
|
115
|
+
const cells = Array.from({ length }, (_, i) => value[i] ?? "")
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
className={cn("relative inline-flex w-full", className)}
|
|
120
|
+
onMouseDown={(e) => {
|
|
121
|
+
// Keep clicks anywhere in the field focusing the (invisible) input,
|
|
122
|
+
// even if they land between cells.
|
|
123
|
+
if (e.target !== inputRef.current) {
|
|
124
|
+
e.preventDefault()
|
|
125
|
+
inputRef.current?.focus()
|
|
126
|
+
}
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<div className="flex w-full items-center justify-center gap-2">
|
|
130
|
+
{cells.map((char, i) => {
|
|
131
|
+
const isActive = focused && i === activeIndex && !disabled
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
key={i}
|
|
135
|
+
data-slot="otp-cell"
|
|
136
|
+
data-active={isActive || undefined}
|
|
137
|
+
data-filled={char ? true : undefined}
|
|
138
|
+
className={cn(
|
|
139
|
+
"relative flex h-12 w-10 items-center justify-center rounded-md squircle border border-input bg-background text-xl font-medium tabular-nums shadow-sm transition-[color,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)]",
|
|
140
|
+
isActive && "border-ring ring-[3px] ring-ring/50 z-10",
|
|
141
|
+
disabled && "opacity-50",
|
|
142
|
+
cellClassName,
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
{char || (
|
|
146
|
+
// Blinking caret on the active empty cell; motion off under
|
|
147
|
+
// prefers-reduced-motion (Tailwind's motion-reduce variant).
|
|
148
|
+
isActive ? (
|
|
149
|
+
<span
|
|
150
|
+
aria-hidden="true"
|
|
151
|
+
className="h-6 w-px animate-pulse bg-foreground motion-reduce:animate-none"
|
|
152
|
+
/>
|
|
153
|
+
) : null
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
})}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<input
|
|
161
|
+
ref={inputRef}
|
|
162
|
+
value={value}
|
|
163
|
+
onChange={handleChange}
|
|
164
|
+
onFocus={() => setFocused(true)}
|
|
165
|
+
onBlur={() => setFocused(false)}
|
|
166
|
+
disabled={disabled}
|
|
167
|
+
autoFocus={autoFocus}
|
|
168
|
+
inputMode={inputMode}
|
|
169
|
+
pattern={inputMode === "numeric" ? "[0-9]*" : undefined}
|
|
170
|
+
autoComplete="one-time-code"
|
|
171
|
+
aria-label={ariaLabel}
|
|
172
|
+
maxLength={length}
|
|
173
|
+
// Invisible but real: fills the field, captures typing/paste/autofill,
|
|
174
|
+
// and owns the (hidden) caret so the platform handles selection for us.
|
|
175
|
+
className="absolute inset-0 h-full w-full cursor-default bg-transparent text-center text-transparent caret-transparent opacity-0 outline-none disabled:cursor-not-allowed"
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// <ScrollAffordance> — a "there's more below" cue.
|
|
2
|
+
//
|
|
3
|
+
// Vendored, editable source (was @omg-dev/ui). Bobs while the watched container
|
|
4
|
+
// is at the top, fades the instant the user scrolls, and hides itself when
|
|
5
|
+
// there's nothing to scroll — so it's safe to render unconditionally.
|
|
6
|
+
// Self-contained: no Tailwind utility classes, no runtime dependency beyond
|
|
7
|
+
// React. Themes itself from your shadcn CSS variables (--muted-foreground)
|
|
8
|
+
// with light/dark fallbacks and honors prefers-reduced-motion.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
useEffect,
|
|
12
|
+
useLayoutEffect,
|
|
13
|
+
useState,
|
|
14
|
+
type CSSProperties,
|
|
15
|
+
type RefObject,
|
|
16
|
+
} from "react"
|
|
17
|
+
import { ensureStyles } from "@/components/ui/vibes-ui-styles"
|
|
18
|
+
|
|
19
|
+
// useLayoutEffect warns during SSR; fall back to useEffect on the server.
|
|
20
|
+
const useIsoLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect
|
|
21
|
+
|
|
22
|
+
export type ScrollAffordanceProps = {
|
|
23
|
+
/**
|
|
24
|
+
* The scroll container to watch. Omit to watch the window (the common case
|
|
25
|
+
* for a long landing page). Pass a ref to watch an overflowing panel.
|
|
26
|
+
*/
|
|
27
|
+
scrollRef?: RefObject<HTMLElement | null>
|
|
28
|
+
/** Caption above the chevron. Pass `null` for a bare chevron. */
|
|
29
|
+
label?: string | null
|
|
30
|
+
/** Hide once scrolled this many px past the top. */
|
|
31
|
+
threshold?: number
|
|
32
|
+
className?: string
|
|
33
|
+
style?: CSSProperties
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A self-aware "scroll for more" cue. It bobs while the watched container is
|
|
38
|
+
* at the top, fades away the moment the user scrolls, and hides itself
|
|
39
|
+
* entirely when there's nothing to scroll — so it's safe to render
|
|
40
|
+
* unconditionally.
|
|
41
|
+
*/
|
|
42
|
+
export function ScrollAffordance({
|
|
43
|
+
scrollRef,
|
|
44
|
+
label = "Scroll",
|
|
45
|
+
threshold = 24,
|
|
46
|
+
className = "",
|
|
47
|
+
style,
|
|
48
|
+
}: ScrollAffordanceProps) {
|
|
49
|
+
useEffect(ensureStyles, [])
|
|
50
|
+
// Start hidden; the first measurement reveals it only if there's overflow.
|
|
51
|
+
const [hidden, setHidden] = useState(true)
|
|
52
|
+
|
|
53
|
+
useIsoLayoutEffect(() => {
|
|
54
|
+
if (typeof window === "undefined") return
|
|
55
|
+
const el = scrollRef?.current ?? null
|
|
56
|
+
|
|
57
|
+
const measure = () => {
|
|
58
|
+
const scrollTop = el ? el.scrollTop : window.scrollY
|
|
59
|
+
const scrollHeight = el
|
|
60
|
+
? el.scrollHeight
|
|
61
|
+
: document.documentElement.scrollHeight
|
|
62
|
+
const clientHeight = el ? el.clientHeight : window.innerHeight
|
|
63
|
+
const scrollable = scrollHeight - clientHeight > threshold
|
|
64
|
+
setHidden(!scrollable || scrollTop > threshold)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const source: HTMLElement | Window = el ?? window
|
|
68
|
+
measure()
|
|
69
|
+
source.addEventListener("scroll", measure, { passive: true })
|
|
70
|
+
window.addEventListener("resize", measure)
|
|
71
|
+
// Content can grow after async data loads — re-measure on layout changes.
|
|
72
|
+
const ro =
|
|
73
|
+
typeof ResizeObserver !== "undefined"
|
|
74
|
+
? new ResizeObserver(measure)
|
|
75
|
+
: null
|
|
76
|
+
if (ro) ro.observe(el ?? document.documentElement)
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
source.removeEventListener("scroll", measure)
|
|
80
|
+
window.removeEventListener("resize", measure)
|
|
81
|
+
ro?.disconnect()
|
|
82
|
+
}
|
|
83
|
+
}, [scrollRef, threshold])
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<span
|
|
87
|
+
className={`vui vui-scroll ${className}`.trim()}
|
|
88
|
+
style={style}
|
|
89
|
+
data-hidden={hidden}
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
>
|
|
92
|
+
{label ? <span>{label}</span> : null}
|
|
93
|
+
<svg
|
|
94
|
+
className="vui-scroll-chevron"
|
|
95
|
+
viewBox="0 0 24 24"
|
|
96
|
+
fill="none"
|
|
97
|
+
stroke="currentColor"
|
|
98
|
+
strokeWidth="2"
|
|
99
|
+
strokeLinecap="round"
|
|
100
|
+
strokeLinejoin="round"
|
|
101
|
+
>
|
|
102
|
+
<path d="m6 9 6 6 6-6" />
|
|
103
|
+
</svg>
|
|
104
|
+
</span>
|
|
105
|
+
)
|
|
106
|
+
}
|