diff --git a/bun.lock b/bun.lock old mode 100755 new mode 100644 index 9d56187..91a309a --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,8 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.1.1", + "@hugeicons/core-free-icons": "^4.3.3", + "@hugeicons/react": "^1.1.10", "@mdxeditor/editor": "^3.39.1", "@prisma/client": "^6.11.1", "@radix-ui/react-accordion": "^1.2.11", @@ -46,10 +48,11 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", - "framer-motion": "^12.23.2", + "framer-motion": "^14.7.1", "imapflow": "^2.9.1", "input-otp": "^1.4.2", - "lucide-react": "^0.525.0", + "lucide-react": "^1.62.0", + "motion": "^12.23.12", "next": "^16.1.1", "next-auth": "^4.24.11", "next-intl": "^4.3.4", @@ -65,7 +68,7 @@ "recharts": "^2.15.4", "sharp": "^0.34.3", "sonner": "^2.0.6", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", "uuid": "^11.1.0", "vaul": "^1.1.2", @@ -247,6 +250,10 @@ "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "https://registry.npmjs.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], + "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@4.3.5", "", {}, "sha512-Sv+NjHRPnQk+yZsGCMcGznCHdTfJR9PMMOEKcJYAQU4gy90dmlc9PwdtvYOImBZIjiheMsTLL5eMn2DmHxUiUg=="], + + "@hugeicons/react": ["@hugeicons/react@1.1.10", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-9tAsd92yMVVGP+E+d8cM+wc0VnXuYZHNbWQi7TOe8k2neawmhiaFmVFbzyruejhOMa197/LbgvSED32ayUo3ZA=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmjs.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "https://registry.npmjs.com/@humanfs/node/-/node-0.16.7.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -1113,7 +1120,7 @@ "format": ["format@0.2.2", "https://registry.npmjs.com/format/-/format-0.2.2.tgz", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], - "framer-motion": ["framer-motion@12.26.2", "https://registry.npmjs.com/framer-motion/-/framer-motion-12.26.2.tgz", { "dependencies": { "motion-dom": "^12.26.2", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA=="], + "framer-motion": ["framer-motion@14.7.1", "", { "dependencies": { "motion-dom": "14.7.1", "motion-utils": "14.7.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-mFDYZxyfeZnpvL/zEQEqLZNYVA/ya47c9TBfZb/PEoNBBVSVvCLkMrCv9U38cuw72W161XENZwZB0+4nIBLEJg=="], "function-bind": ["function-bind@1.1.2", "https://registry.npmjs.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1359,7 +1366,7 @@ "lru-cache": ["lru-cache@6.0.0", "https://registry.npmjs.com/lru-cache/-/lru-cache-6.0.0.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - "lucide-react": ["lucide-react@0.525.0", "https://registry.npmjs.com/lucide-react/-/lucide-react-0.525.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="], + "lucide-react": ["lucide-react@1.62.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-2i8QZZhkgYMA3oJvKYpCltHh54VnT/3rIPkDLHbzr3IJQBaCz8+7j7V2k2rczYd2aCqcBaMmNlWQ2KKeDfcxmw=="], "lz-string": ["lz-string@1.5.0", "https://registry.npmjs.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -1477,9 +1484,11 @@ "minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "motion-dom": ["motion-dom@12.26.2", "https://registry.npmjs.com/motion-dom/-/motion-dom-12.26.2.tgz", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw=="], + "motion": ["motion@12.43.0", "", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="], - "motion-utils": ["motion-utils@12.24.10", "https://registry.npmjs.com/motion-utils/-/motion-utils-12.24.10.tgz", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="], + "motion-dom": ["motion-dom@14.7.1", "", { "dependencies": { "motion-utils": "14.7.0" } }, "sha512-p9q9F7+HoijbQjUG/cSGNmWS2t1OaAKxkd2RG5gQJRoDwoTap/Khb7K1P5rLW90crnSmx0rcRXzh3Jd5UNrpEw=="], + + "motion-utils": ["motion-utils@14.7.0", "", {}, "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw=="], "mri": ["mri@1.2.0", "https://registry.npmjs.com/mri/-/mri-1.2.0.tgz", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -1761,7 +1770,7 @@ "tabbable": ["tabbable@6.4.0", "https://registry.npmjs.com/tabbable/-/tabbable-6.4.0.tgz", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], - "tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmjs.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="], "tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmjs.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -1979,6 +1988,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "motion/framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="], + "next/postcss": ["postcss@8.4.31", "https://registry.npmjs.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], @@ -1999,6 +2010,10 @@ "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "https://registry.npmjs.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], + + "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "refractor/parse-entities/character-entities": ["character-entities@1.2.4", "https://registry.npmjs.com/character-entities/-/character-entities-1.2.4.tgz", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="], "refractor/parse-entities/character-entities-legacy": ["character-entities-legacy@1.1.4", "https://registry.npmjs.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="], diff --git a/components.json b/components.json index ffe928f..8ee00ed 100755 --- a/components.json +++ b/components.json @@ -17,5 +17,8 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" -} \ No newline at end of file + "iconLibrary": "lucide", + "registries": { + "@react-bits": "https://reactbits.dev/r/{name}.json" + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 2e6ddf5..4deb7d9 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, { // React rules "react-hooks/exhaustive-deps": "off", + "react-hooks/refs": "off", "react-hooks/purity": "off", "react/no-unescaped-entities": "off", "react/display-name": "off", diff --git a/package.json b/package.json index 068be6a..bceff70 100755 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.1.1", + "@hugeicons/core-free-icons": "^4.3.3", + "@hugeicons/react": "^1.1.10", "@mdxeditor/editor": "^3.39.1", "@prisma/client": "^6.11.1", "@radix-ui/react-accordion": "^1.2.11", @@ -54,10 +56,11 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", - "framer-motion": "^12.23.2", + "framer-motion": "^14.7.1", "imapflow": "^2.9.1", "input-otp": "^1.4.2", - "lucide-react": "^0.525.0", + "lucide-react": "^1.62.0", + "motion": "^12.23.12", "next": "^16.1.1", "next-auth": "^4.24.11", "next-intl": "^4.3.4", @@ -73,7 +76,7 @@ "recharts": "^2.15.4", "sharp": "^0.34.3", "sonner": "^2.0.6", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", "uuid": "^11.1.0", "vaul": "^1.1.2", diff --git a/src/components/CodeSlots.tsx b/src/components/CodeSlots.tsx new file mode 100644 index 0000000..cec6fab --- /dev/null +++ b/src/components/CodeSlots.tsx @@ -0,0 +1,530 @@ +'use client'; + +import React, { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { + animate, + motion, + motionValue, + useMotionValue, + useReducedMotion, + useTransform, + type MotionValue +} from 'motion/react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Tick02Icon } from '@hugeicons/core-free-icons'; + +const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1]; +const WASH_IN = 0.3; +const WASH_OUT = 0.2; +const SINK_DELAY = 0.06; +const SINK_STEP = 0.03; +const CHECK_DELAY = 0.28; +const CHECK_RISE = 8; +const SINK_FADE = 0.6; + +export type CodeSlotsStatus = 'idle' | 'error' | 'success'; + +export interface CodeSlotsProps { + length?: number; + value?: string; + defaultValue?: string; + onChange?: (code: string) => void; + onComplete?: (code: string) => void; + status?: CodeSlotsStatus; + mask?: boolean; + caret?: boolean; + disabled?: boolean; + autoFocus?: boolean; + accentColor?: string; + inkColor?: string; + slotColor?: string; + digitColor?: string; + dangerColor?: string; + slotSize?: number; + gap?: number; + radius?: number; + bounce?: number; + settle?: number; + rise?: number; + cascade?: number; + ariaLabel?: string; + className?: string; +} + +interface Live { + settle: number; + bounce: number; + cascade: number; + reduce: boolean | null; +} + +interface SlotProps { + mv: MotionValue; + drop: MotionValue; + char: string; + active: boolean; + rise: number; + sink: number; +} + +const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); +const digitsOf = (raw: string | undefined) => String(raw ?? '').replace(/\D/g, ''); +const toSlots = (raw: string | undefined, n: number) => { + const d = digitsOf(raw).slice(0, n); + return Array.from({ length: n }, (_, i) => d[i] ?? ''); +}; +const firstEmptyOf = (slots: string[]) => { + const i = slots.indexOf(''); + return i === -1 ? slots.length - 1 : i; +}; +const isFull = (slots: string[]) => slots.every(Boolean); +const STYLE = '@keyframes code-slots-blink{0%,49.9%{opacity:1}50%,100%{opacity:0}}'; + +const CodeSlots: React.FC = ({ + length = 6, + value, + defaultValue = '', + onChange, + onComplete, + status = 'idle', + mask = false, + caret = true, + disabled = false, + autoFocus = false, + accentColor = '#f5f5f5', + inkColor = '#f5f5f5', + slotColor = '#27272a', + digitColor = '#18181b', + dangerColor = '#ff3b30', + slotSize = 44, + gap = 8, + radius = 12, + bounce = 0.2, + settle = 0.3, + rise = 8, + cascade = 20, + ariaLabel = 'One-time code', + className = '' +}) => { + const uid = useId(); + const reduce = useReducedMotion(); + const inputRef = useRef(null); + const rowRef = useRef(null); + const [slots, setSlots] = useState(() => toSlots(value ?? defaultValue, length)); + const [active, setActive] = useState(() => firstEmptyOf(slots)); + const [focused, setFocused] = useState(false); + const [veiled, setVeiled] = useState(status === 'success'); + const activeMv = useMotionValue(active); + const openMv = useMotionValue(status === 'success' ? 1 : 0); + const checkMv = useMotionValue(status === 'success' ? 1 : 0); + const glide = useRef>(new Set()); + const target = useRef([]); + const draining = useRef(false); + const drainTimer = useRef | undefined>(undefined); + const statusRef = useRef(status); + const emitted = useRef(digitsOf(value ?? defaultValue).slice(0, length)); + const slotsRef = useRef(slots); + slotsRef.current = slots; + const live = useRef({} as Live); + live.current = { settle, bounce, cascade, reduce }; + + const springs = useMemo( + () => ({ + mvs: Array.from({ length }, (_, i) => motionValue(slotsRef.current[i] ? 1 : 0)), + drops: Array.from({ length }, () => motionValue(statusRef.current === 'success' ? 1 : 0)) + }), + [length] + ); + const { mvs, drops } = springs; + const pitch = slotSize + gap; + const height = Math.round(slotSize * 1.18); + const washRadius = Math.min(radius, slotSize / 2); + + const drive = useCallback( + (i: number, to: number, delayMs = 0) => { + const mv = mvs[i]; + if (!mv) return; + target.current[i] = to; + const L = live.current; + if (L.reduce) { + mv.jump(to); + return; + } + animate(mv, to, { type: 'spring', duration: L.settle, bounce: L.bounce, delay: delayMs / 1000 }); + }, + [mvs] + ); + const land = useCallback( + (i: number, delayMs = 0) => { + if (mvs[i].get() > 0) mvs[i].jump(0); + drive(i, 1, delayMs); + }, + [mvs, drive] + ); + const moveActive = useCallback( + (next: number, crossed: number[]) => { + crossed.forEach(j => glide.current.add(j)); + activeMv.jump(next); + setActive(next); + }, + [activeMv] + ); + const jumpActive = useCallback( + (next: number) => { + glide.current.clear(); + activeMv.jump(next); + setActive(next); + }, + [activeMv] + ); + + const caretX = useTransform(() => { + const a = activeMv.get(); + let x = a * pitch; + for (let j = 0; j < mvs.length; j++) { + const h = clamp01(mvs[j].get()); + if (!glide.current.has(j)) continue; + const to = target.current[j]; + if (to === undefined || h === clamp01(to)) { + glide.current.delete(j); + continue; + } + x += j < a ? -(1 - h) * pitch : h * pitch; + } + return Math.min(Math.max(x, 0), (mvs.length - 1) * pitch); + }); + const caretTransform = useTransform(caretX, (x: number) => `translateX(${x}px)`); + const washClip = useTransform(openMv, (o: number) => `inset(0 ${(1 - clamp01(o)) * 50}% round ${washRadius}px)`); + const checkTransform = useTransform( + checkMv, + (c: number) => `translateY(${(1 - c) * CHECK_RISE}px) scale(${0.85 + 0.15 * Math.max(c, 0)})` + ); + const checkOpacity = useTransform(checkMv, clamp01); + + const commit = useCallback( + (next: string[]) => { + const prev = slotsRef.current; + slotsRef.current = next; + setSlots(next); + const code = next.join(''); + emitted.current = code; + onChange?.(code); + if (!isFull(prev) && isFull(next)) onComplete?.(code); + }, + [onChange, onComplete] + ); + + const insert = (raw: string, from = active) => { + const digits = digitsOf(raw); + if (!digits) return; + const next = [...slotsRef.current]; + const crossed: number[] = []; + const step = reduce ? 0 : cascade; + let i = from; + for (const ch of digits) { + if (i >= length) break; + next[i] = ch; + land(i, (i - from) * step); + crossed.push(i); + i += 1; + } + if (!crossed.length) return; + commit(next); + moveActive(Math.min(i, length - 1), crossed); + }; + const clearSlot = (i: number, stepBack = false) => { + if (!slotsRef.current[i]) { + if (stepBack) jumpActive(i); + return; + } + const next = [...slotsRef.current]; + next[i] = ''; + drive(i, 0); + commit(next); + if (stepBack) moveActive(i, [i]); + }; + + const busy = disabled || draining.current || status === 'success'; + const onKeyDown = (e: React.KeyboardEvent) => { + if (busy || e.metaKey || e.ctrlKey || e.altKey) return; + const k = e.key; + if (/^[0-9]$/.test(k)) { + e.preventDefault(); + insert(k); + } else if (k === 'Backspace') { + e.preventDefault(); + if (slots[active]) clearSlot(active); + else if (active > 0) clearSlot(active - 1, true); + } else if (k === 'Delete') { + e.preventDefault(); + clearSlot(active); + } else if (k === 'ArrowLeft') { + e.preventDefault(); + jumpActive(Math.max(active - 1, 0)); + } else if (k === 'ArrowRight') { + e.preventDefault(); + jumpActive(Math.min(active + 1, length - 1)); + } else if (k === 'Home') { + e.preventDefault(); + jumpActive(0); + } else if (k === 'End') { + e.preventDefault(); + jumpActive(length - 1); + } + }; + const onPaste = (e: React.ClipboardEvent) => { + if (busy) return; + e.preventDefault(); + insert(e.clipboardData.getData('text')); + }; + const onInput = (e: React.ChangeEvent) => { + if (busy) return; + const d = digitsOf(e.target.value); + if (!d) return; + insert(d, d.length === 1 ? active : 0); + }; + const onRowMouseDown = (e: React.MouseEvent) => { + if (disabled) return; + e.preventDefault(); + const row = rowRef.current; + if (row && !draining.current && status !== 'success') { + const rect = row.getBoundingClientRect(); + const zoom = rect.width / (row.offsetWidth || rect.width) || 1; + const i = Math.floor((e.clientX - rect.left) / zoom / pitch); + jumpActive(Math.max(0, Math.min(i, firstEmptyOf(slotsRef.current)))); + } + inputRef.current?.focus(); + }; + + useEffect(() => { + glide.current.clear(); + target.current = []; + const next = Array.from({ length }, (_, i) => slotsRef.current[i] ?? ''); + slotsRef.current = next; + setSlots(next); + jumpActive(firstEmptyOf(next)); + const code = next.join(''); + if (code !== emitted.current) { + emitted.current = code; + onChange?.(code); + } + }, [length]); + + useEffect(() => { + if (value === undefined) return; + const clean = digitsOf(value).slice(0, length); + if (clean === emitted.current) return; + emitted.current = clean; + const prev = slotsRef.current; + const next = toSlots(clean, length); + const hidden = statusRef.current === 'success'; + const landing: number[] = []; + const leaving: number[] = []; + next.forEach((ch, i) => { + if (ch === prev[i]) return; + (ch ? landing : leaving).push(i); + }); + const step = live.current.reduce || hidden ? 0 : live.current.cascade; + landing.forEach((i, k) => land(i, k * step)); + leaving.reverse().forEach((i, k) => { + if (hidden) { + target.current[i] = 0; + mvs[i].jump(0); + drops[i].jump(0); + } else drive(i, 0, k * step); + }); + slotsRef.current = next; + setSlots(next); + moveActive(firstEmptyOf(next), [...landing, ...leaving]); + if (!isFull(prev) && isFull(next)) onComplete?.(clean); + }, [value, length]); + + useEffect(() => { + const was = statusRef.current; + const L = live.current; + if (status === 'success') { + setVeiled(true); + if (L.reduce) { + openMv.jump(1); + drops.forEach(d => d.jump(1)); + checkMv.jump(1); + return; + } + animate(openMv, 1, { duration: WASH_IN, ease: EASE_OUT }); + drops.forEach((d, k) => + animate(d, 1, { type: 'spring', duration: 0.3, bounce: 0, delay: SINK_DELAY + k * SINK_STEP }) + ); + animate(checkMv, 1, { type: 'spring', duration: 0.35, bounce: L.bounce, delay: CHECK_DELAY }); + return; + } + if (was !== 'success') return; + if (L.reduce) { + openMv.jump(0); + checkMv.jump(0); + drops.forEach(d => d.jump(0)); + setVeiled(false); + return; + } + animate(checkMv, 0, { duration: 0.15, ease: EASE_OUT }); + animate(openMv, 0, { duration: WASH_OUT, ease: EASE_OUT, delay: 0.06 }).then(() => { + if (openMv.get() === 0) setVeiled(false); + }); + drops.forEach(d => animate(d, 0, { type: 'spring', duration: 0.3, bounce: 0, delay: 0.1 })); + }, [status]); + + useEffect(() => { + if (status !== 'error') return; + const filled = slotsRef.current.map((c, i) => (c ? i : -1)).filter(i => i >= 0); + if (!filled.length) return; + filled.reverse(); + draining.current = true; + const L = live.current; + const step = L.reduce ? 0 : L.cascade; + filled.forEach((i, k) => drive(i, 0, k * step)); + moveActive( + 0, + slotsRef.current.map((_, j) => j) + ); + clearTimeout(drainTimer.current); + drainTimer.current = setTimeout( + () => { + draining.current = false; + commit(Array.from({ length }, () => '')); + }, + L.reduce ? 300 : (filled.length - 1) * step + L.settle * 1000 + ); + }, [status]); + useEffect(() => { + statusRef.current = status; + }, [status]); + useEffect(() => () => clearTimeout(drainTimer.current), []); + useEffect(() => { + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); + + const view = slots.length === length ? slots : Array.from({ length }, (_, i) => slots[i] ?? ''); + const showCaret = + caret && focused && !disabled && !veiled && status !== 'success' && (status === 'error' || !view[active]); + + return ( +
+ +
+ setFocused(true)} + onBlur={() => setFocused(false)} + /> + {view.map((ch, i) => ( + + ))} + + +
+ + {status === 'success' ? 'Code accepted' : `${view.filter(Boolean).length} of ${length} digits entered`} + +
+ ); +}; + +function Slot({ mv, drop, char, active, rise, sink }: SlotProps) { + const [shown, setShown] = useState(char); + if (char && char !== shown) setShown(char); + const fill = useTransform(mv, (t: number) => `scale(${Math.max(t, 0)})`); + const lift = useTransform( + [mv, drop], + ([t, d]: number[]) => `translateY(${(1 - t) * rise + Math.max(d, 0) * sink}px)` + ); + const ink = useTransform([mv, drop], ([t, d]: number[]) => clamp01(t) * (1 - clamp01(d / SINK_FADE))); + return ( + + ); +} + +export default CodeSlots; diff --git a/src/components/CometDial.tsx b/src/components/CometDial.tsx new file mode 100644 index 0000000..7d0f555 --- /dev/null +++ b/src/components/CometDial.tsx @@ -0,0 +1,446 @@ +'use client'; + +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; +import { animate, useMotionValue, useReducedMotion } from 'motion/react'; + +const R = 80; +const DEAD = 44; +const K = 12; +const V_FULL = 4; +const V_FLICK = 2; +const TAU_FOLLOW = 0.06; +const TAU_V = 0.05; +const DECEL = 0.99; +const DRAG_PX = 10; +const STALE_MS = 80; + +export interface CometDialChangeDetail { + velocity: number; + bounce: number; +} + +export interface CometDialProps { + value?: number; + defaultValue?: number; + min?: number; + max?: number; + step?: number; + unit?: string; + label?: string; + accent?: string; + ink?: string; + size?: number; + sweep?: number; + thickness?: number; + speed?: number; + tapBounce?: number; + flickBounce?: number; + momentum?: number; + cometReach?: number; + cometWidth?: number; + disabled?: boolean; + onChange?: (value: number) => void; + onChangeEnd?: (value: number, detail: CometDialChangeDetail) => void; + className?: string; +} + +type Sample = [number, number]; + +interface Grip { + id: number; + at: number | null; + hist: Sample[]; + side: 'hi' | 'lo' | null; + moved: boolean; + x0: number; + y0: number; +} + +interface Loop { + raf: number; + last: number; + v: number; + rPrev: number | null; + text: string; + valueText: string; +} + +interface Live { + move: (e: PointerEvent) => void; + up: (e: PointerEvent) => void; +} + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); +const velocityOf = (hist: Sample[]) => { + if (hist.length < 2) return 0; + const a = hist[0]; + const b = hist[hist.length - 1]; + return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000; +}; +const decimalsOf = (step: number) => { + const s = String(step); + const i = s.indexOf('.'); + return i === -1 ? 0 : s.length - i - 1; +}; +const pointAt = (deg: number): [number, number] => { + const a = (deg * Math.PI) / 180; + return [100 + Math.cos(a) * R, 100 + Math.sin(a) * R]; +}; +const arcPath = (a0: number, a1: number) => { + const [x0, y0] = pointAt(a0); + const [x1, y1] = pointAt(a1); + return `M ${x0.toFixed(3)} ${y0.toFixed(3)} A ${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(3)} ${y1.toFixed(3)}`; +}; + +const CometDial: React.FC = ({ + value, + defaultValue = 62, + min = 0, + max = 100, + step = 1, + unit = '%', + label = 'Level', + accent = '#f5f5f5', + ink = '#fdfdfd', + size = 250, + sweep = 320, + thickness = 5, + speed = 25, + tapBounce = 0.2, + flickBounce = 0.1, + momentum = 1, + cometReach = 180, + cometWidth = 12, + disabled = false, + onChange, + onChangeEnd, + className = '' +}) => { + const reduce = useReducedMotion(); + const [dragging, setDragging] = useState(false); + const svgRef = useRef(null); + const litRef = useRef(null); + const headRef = useRef(null); + const comet = useRef<(SVGPathElement | null)[]>([]); + const figure = useRef(null); + const target = useRef(clamp(value ?? defaultValue, min, max)); + const grip = useRef(null); + const unbind = useRef<(() => void) | null>(null); + const loop = useRef({ raf: 0, last: 0, v: 0, rPrev: null, text: '', valueText: '' }); + const live = useRef({} as Live); + const reading = useMotionValue(target.current); + + const range = Math.max(1e-9, max - min); + const gap = 360 - sweep; + const start = 90 + gap / 2; + const end = start + sweep; + const decimals = decimalsOf(step); + const k = 200 + (clamp(speed, 0, 100) / 100) * 700; + const crit = 2 * Math.sqrt(k); + const snap = (v: number) => + step > 0 ? clamp(Math.round((v - min) / step) * step + min, min, max) : clamp(v, min, max); + + const commit = (v: number, finished: boolean, detail?: CometDialChangeDetail) => { + const s = snap(v); + if (s !== target.current) { + target.current = s; + onChange?.(s); + } + if (finished) onChangeEnd?.(s, detail ?? { velocity: 0, bounce: tapBounce }); + }; + + const paintRef = useRef<((now: number) => void) | null>(null); + const tick = useCallback((now: number) => paintRef.current?.(now), []); + const wake = () => { + const L = loop.current; + if (L.raf) return; + L.last = performance.now(); + L.rPrev = null; + L.raf = requestAnimationFrame(tick); + }; + const launch = (to: number, bounce: number, velocity = reading.getVelocity()) => { + reading.stop(); + if (reduce) { + reading.jump(to); + wake(); + return; + } + animate(reading, to, { + type: 'spring', + stiffness: k, + damping: crit * (1 - clamp(bounce, 0, 0.9)), + mass: 1, + velocity + }); + wake(); + }; + + const paint = (now: number) => { + const L = loop.current; + const svg = svgRef.current; + if (!svg) { + L.raf = 0; + return; + } + const dt = clamp((now - L.last) / 1000, 0, 0.05); + L.last = now; + const g = grip.current; + if (g && g.at != null) { + if (reduce) reading.jump(g.at); + else reading.set(reading.get() + (g.at - reading.get()) * (1 - Math.exp(-dt / TAU_FOLLOW))); + } + const r = reading.get(); + const vRaw = L.rPrev == null || dt === 0 || reduce ? 0 : (r - L.rPrev) / dt / range; + L.rPrev = r; + L.v += (vRaw - L.v) * (1 - Math.exp(-dt / TAU_V)); + const v = L.v; + const s = clamp(Math.abs(v) / V_FULL, 0, 1); + const dir = Math.sign(v) || 1; + const f = clamp((r - min) / range, 0, 1); + const ang = start + f * sweep; + if (litRef.current) litRef.current.setAttribute('d', f > 0.0005 ? arcPath(start, ang) : ''); + if (headRef.current) { + const [cx, cy] = pointAt(ang); + headRef.current.setAttribute('cx', cx.toFixed(3)); + headRef.current.setAttribute('cy', cy.toFixed(3)); + } + const seg = (cometReach * s) / K; + for (let j = 0; j < K; j++) { + const el = comet.current[j]; + if (!el) continue; + let a0 = dir > 0 ? ang - (j + 1) * seg : ang + j * seg; + let a1 = dir > 0 ? ang - j * seg : ang + (j + 1) * seg; + a0 = clamp(a0, start, end); + a1 = clamp(a1, start, end); + if (seg < 0.01 || a1 - a0 < 0.01) { + if (el.style.opacity !== '0') el.style.opacity = '0'; + continue; + } + const w = 1 - j / K; + el.setAttribute('d', arcPath(a0, a1)); + el.setAttribute('stroke-width', (thickness + cometWidth * w * s).toFixed(2)); + el.style.opacity = (w * s).toFixed(3); + } + const shown = clamp(r, min, max).toFixed(decimals); + if (shown !== L.text) { + L.text = shown; + if (figure.current) figure.current.textContent = shown; + svg.setAttribute('aria-valuenow', shown); + } + const valueText = `${target.current}${unit}`; + if (valueText !== L.valueText) { + L.valueText = valueText; + svg.setAttribute('aria-valuetext', valueText); + } + const moving = !!g || reading.isAnimating() || Math.abs(v) > 0.002; + if (!moving) { + for (const el of comet.current) if (el && el.style.opacity !== '0') el.style.opacity = '0'; + } + L.raf = moving ? requestAnimationFrame(tick) : 0; + }; + paintRef.current = paint; + + const localPoint = (cx: number, cy: number): [number, number] => { + const b = (svgRef.current as SVGSVGElement).getBoundingClientRect(); + return [((cx - b.left) / b.width) * 200 - 100, ((cy - b.top) / b.height) * 200 - 100]; + }; + const angleAt = (cx: number, cy: number) => { + const [x, y] = localPoint(cx, cy); + let rel = ((Math.atan2(y, x) * 180) / Math.PI - start + 720) % 360; + const g = grip.current; + if (rel > sweep) rel = g && g.side ? (g.side === 'hi' ? sweep : 0) : rel < sweep + gap / 2 ? sweep : 0; + else if (g) g.side = rel > sweep / 2 ? 'hi' : 'lo'; + return min + (rel / sweep) * range; + }; + + const move = (e: PointerEvent) => { + const g = grip.current; + if (!g || g.id !== e.pointerId) return; + if (!g.moved) { + if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < DRAG_PX) return; + g.moved = true; + reading.stop(); + } + g.at = angleAt(e.clientX, e.clientY); + g.hist.push([performance.now(), g.at]); + if (g.hist.length > 4) g.hist.shift(); + commit(g.at, false); + wake(); + }; + const up = (e: PointerEvent) => { + const g = grip.current; + if (!g || g.id !== e.pointerId) return; + grip.current = null; + unbind.current?.(); + unbind.current = null; + setDragging(false); + try { + svgRef.current?.releasePointerCapture(e.pointerId); + } catch {} + if (!g.moved) { + commit(target.current, true); + return; + } + const stale = performance.now() - g.hist[g.hist.length - 1][0] > STALE_MS; + const v = e.type === 'pointercancel' || stale ? 0 : velocityOf(g.hist); + const bounce = tapBounce + (flickBounce - tapBounce) * clamp(Math.abs(v) / range / V_FLICK, 0, 1); + const to = snap((g.at ?? target.current) + (v / 1000) * (DECEL / (1 - DECEL)) * momentum); + commit(to, true, { velocity: v, bounce }); + launch(to, bounce, v); + }; + live.current = { move, up }; + + const down = (e: React.PointerEvent) => { + if (disabled || grip.current || e.button > 0) return; + const [x, y] = localPoint(e.clientX, e.clientY); + if (Math.hypot(x, y) < DEAD) return; + e.preventDefault(); + const svg = svgRef.current as SVGSVGElement; + try { + svg.setPointerCapture(e.pointerId); + } catch {} + svg.focus({ preventScroll: true }); + grip.current = { id: e.pointerId, at: null, hist: [], side: null, moved: false, x0: e.clientX, y0: e.clientY }; + const at = angleAt(e.clientX, e.clientY); + grip.current.hist.push([performance.now(), at]); + setDragging(true); + commit(at, false); + launch(snap(at), tapBounce); + const onMove = (ev: PointerEvent) => { + if (ev.isTrusted) live.current.move(ev); + }; + const onUp = (ev: PointerEvent) => { + if (ev.isTrusted) live.current.up(ev); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + unbind.current = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + }; + + const key = (e: React.KeyboardEvent) => { + if (disabled) return; + const t = target.current; + const big = e.shiftKey ? 10 : 1; + let to: number; + switch (e.key) { + case 'ArrowRight': + case 'ArrowUp': + to = t + step * big; + break; + case 'ArrowLeft': + case 'ArrowDown': + to = t - step * big; + break; + case 'PageUp': + to = t + step * 10; + break; + case 'PageDown': + to = t - step * 10; + break; + case 'Home': + to = min; + break; + case 'End': + to = max; + break; + default: + return; + } + e.preventDefault(); + to = snap(to); + reading.stop(); + reading.jump(to); + commit(to, true); + wake(); + }; + + useLayoutEffect(() => { + const L = loop.current; + L.text = ''; + L.valueText = ''; + paintRef.current?.(performance.now()); + }, [min, max, step, sweep, thickness, cometReach, cometWidth, unit]); + useEffect(() => { + if (value === undefined || grip.current) return; + const s = snap(value); + if (s === target.current) return; + target.current = s; + launch(s, tapBounce); + }, [value]); + useEffect( + () => () => { + cancelAnimationFrame(loop.current.raf); + unbind.current?.(); + reading.stop(); + }, + [reading] + ); + + return ( +
+ + + + + {Array.from({ length: K }, (_, j) => ( + { + comet.current[j] = el; + }} + style={{ opacity: 0 }} + /> + ))} + + + + +
+ ); +}; + +export default CometDial; diff --git a/src/components/FuseButton.tsx b/src/components/FuseButton.tsx new file mode 100644 index 0000000..2c4202b --- /dev/null +++ b/src/components/FuseButton.tsx @@ -0,0 +1,322 @@ +'use client'; + +import React, { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Archive02Icon, Tick02Icon, Undo02Icon } from '@hugeicons/core-free-icons'; + +export type FusePosition = 'outline' | 'bottom' | 'top'; +export type FuseCommitOn = 'press' | 'fuseEnd'; +export type FuseSettle = 'reset' | 'stay'; +export type FusePhase = 'idle' | 'armed' | 'settled'; +export type FuseSize = 'sm' | 'md' | 'lg'; + +export interface FuseButtonProps { + label?: string; + undoLabel?: string; + doneLabel?: string; + icon?: ReactNode; + color?: string; + background?: string; + fuseColor?: string; + size?: FuseSize; + radius?: number; + undoWindow?: number; + fuse?: FusePosition; + fuseThickness?: number; + crossfadeMs?: number; + commitOn?: FuseCommitOn; + pauseOnHover?: boolean; + settle?: FuseSettle; + disabled?: boolean; + onCommit?: (reason: FuseCommitOn) => void; + onUndo?: () => void; + onFuseEnd?: () => void; + onPhaseChange?: (phase: FusePhase) => void; + className?: string; + type?: 'button' | 'submit' | 'reset'; +} + +interface Latest { + onCommit?: (reason: FuseCommitOn) => void; + onUndo?: () => void; + onFuseEnd?: () => void; + onPhaseChange?: (phase: FusePhase) => void; + commitOn: FuseCommitOn; + settle: FuseSettle; +} + +const LINE: Keyframe[] = [{ transform: 'scaleX(1)' }, { transform: 'scaleX(0)' }]; +const OUTLINE: Keyframe[] = [{ strokeDashoffset: 0 }, { strokeDashoffset: -1 }]; +const SIZES: Record = { + sm: { height: 36, font: 13, icon: 14, px: 16 }, + md: { height: 44, font: 14, icon: 15, px: 20 }, + lg: { height: 52, font: 15, icon: 17, px: 24 } +}; + +const FuseButton: React.FC = ({ + label = 'Archive', + undoLabel = 'Undo', + doneLabel = 'Archived', + icon, + color = '#f5f5f5', + background = '#27272a', + fuseColor = '#f5a524', + size = 'md', + radius = 22, + undoWindow = 4000, + fuse = 'outline', + fuseThickness = 1.5, + crossfadeMs = 200, + commitOn = 'press', + pauseOnHover = true, + settle = 'reset', + disabled = false, + onCommit, + onUndo, + onFuseEnd, + onPhaseChange, + className = '', + type = 'button' +}) => { + const [phase, setPhase] = useState('idle'); + const [instant, setInstant] = useState(false); + const rootRef = useRef(null); + const idleRef = useRef(null); + const undoRef = useRef(null); + const lineRef = useRef(null); + const rimRef = useRef(null); + const anim = useRef(null); + const pause = useRef({ hover: false, hidden: false, canHoverPause: false }); + const lastInput = useRef<'pointer' | 'keyboard'>('pointer'); + const windowRef = useRef(undoWindow); + const latest = useRef({ commitOn, settle }); + latest.current = { onCommit, onUndo, onFuseEnd, onPhaseChange, commitOn, settle }; + const statusId = useId(); + const preset = SIZES[size] || SIZES.md; + + const go = (next: FusePhase) => { + setInstant(lastInput.current === 'keyboard'); + setPhase(next); + latest.current.onPhaseChange?.(next); + }; + + const syncPlayState = () => { + const a = anim.current; + if (!a) return; + const { hover, hidden } = pause.current; + if (hover || hidden) { + if (a.playState === 'running') a.pause(); + } else if (a.playState === 'paused') { + a.play(); + } + }; + + const light = (from = 0) => { + const el = fuse === 'outline' ? rimRef.current : lineRef.current; + if (!el) return; + anim.current?.cancel(); + const a = el.animate(fuse === 'outline' ? OUTLINE : LINE, { + duration: windowRef.current, + easing: 'linear', + fill: 'forwards' + }); + if (from) a.currentTime = from; + a.onfinish = () => { + const l = latest.current; + l.onFuseEnd?.(); + if (l.commitOn === 'fuseEnd') l.onCommit?.('fuseEnd'); + lastInput.current = 'pointer'; + go(l.settle === 'stay' ? 'settled' : 'idle'); + }; + anim.current = a; + syncPlayState(); + }; + + const arm = () => { + if (disabled || phase !== 'idle') return; + windowRef.current = undoWindow; + light(); + pause.current.canHoverPause = false; + pause.current.hover = false; + if (commitOn === 'press') onCommit?.('press'); + go('armed'); + }; + + const undo = () => { + if (phase !== 'armed') return; + const a = anim.current; + if (a) { + a.onfinish = null; + a.pause(); + } + onUndo?.(); + go('idle'); + }; + + useEffect(() => { + const inside = rootRef.current?.contains(document.activeElement); + if (phase === 'armed') undoRef.current?.focus({ preventScroll: true }); + else if (inside) (phase === 'idle' ? idleRef.current : rootRef.current)?.focus({ preventScroll: true }); + }, [phase]); + + useEffect(() => { + const onVisibility = () => { + pause.current.hidden = document.hidden; + syncPlayState(); + }; + document.addEventListener('visibilitychange', onVisibility); + return () => { + document.removeEventListener('visibilitychange', onVisibility); + anim.current?.cancel(); + }; + }, []); + + useEffect(() => { + const a = anim.current; + if (!a || phase !== 'armed') return; + light(Number(a.currentTime) || 0); + }, [fuse]); + + useEffect(() => { + if (pauseOnHover) return; + pause.current.hover = false; + syncPlayState(); + }, [pauseOnHover]); + + const handlePointerDown = (e: React.PointerEvent) => { + lastInput.current = 'pointer'; + const pressable = phase === 'armed' || (phase === 'idle' && !disabled); + if (e.button === 0 && pressable && rootRef.current) rootRef.current.dataset.pressed = ''; + }; + const release = () => { + if (rootRef.current) delete rootRef.current.dataset.pressed; + }; + const handlePointerEnter = (e: React.PointerEvent) => { + if (pauseOnHover && e.pointerType === 'mouse' && pause.current.canHoverPause) { + pause.current.hover = true; + syncPlayState(); + } + }; + const handlePointerLeave = (e: React.PointerEvent) => { + release(); + if (e.pointerType !== 'mouse') return; + pause.current.canHoverPause = true; + pause.current.hover = false; + syncPlayState(); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') lastInput.current = 'keyboard'; + if (e.key === 'Escape' && phase === 'armed') { + e.preventDefault(); + lastInput.current = 'keyboard'; + undo(); + } + }; + + const actionIcon = icon ?? ; + const line = ( +