From 0d6e3d36a45c99a42d070d1a78d1a80c81b6ae10 Mon Sep 17 00:00:00 2001 From: Utkarsh Singhal Date: Fri, 24 Jul 2026 10:33:58 +0530 Subject: [PATCH] feat: add JS/Tailwind, TS/CSS, and TS/Tailwind variants for --- public/r/CurvedInput-JS-TW.json | 2 +- public/r/CurvedInput-TS-CSS.json | 4 +- public/r/CurvedInput-TS-TW.json | 2 +- .../Components/CurvedInput/CurvedInput.jsx | 544 +++++++++++++++ .../Components/CurvedInput/CurvedInput.css | 71 ++ .../Components/CurvedInput/CurvedInput.tsx | 625 +++++++++++++++++ .../Components/CurvedInput/CurvedInput.tsx | 628 ++++++++++++++++++ 7 files changed, 1872 insertions(+), 4 deletions(-) diff --git a/public/r/CurvedInput-JS-TW.json b/public/r/CurvedInput-JS-TW.json index 19428dd61..4a2678d43 100644 --- a/public/r/CurvedInput-JS-TW.json +++ b/public/r/CurvedInput-JS-TW.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "CurvedInput/CurvedInput.jsx", - "content": "" + "content": "import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';\n\nconst DEG = 180 / Math.PI;\n\nconst round2 = n => Math.round(n * 100) / 100;\n\nconst hexToRgba = (hex, alpha) => {\n let h = String(hex).replace('#', '');\n if (h.length === 3)\n h = h\n .split('')\n .map(c => c + c)\n .join('');\n const n = parseInt(h.slice(0, 6), 16);\n if (Number.isNaN(n)) return hex;\n return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;\n};\n\nconst SHADOWS = { sm: [5, 12, 0.3], md: [10, 24, 0.4], lg: [16, 40, 0.52] };\n\nconst THEMES = {\n dark: {\n backgroundColor: '#1B1722',\n textColor: '#f5f5f5',\n placeholderColor: '#a1a1aa',\n borderColor: '#392e4e',\n buttonColor: '#A855F7',\n buttonTextColor: '#ffffff',\n shadowColor: '#000000'\n },\n light: {\n backgroundColor: '#ffffff',\n textColor: '#1d2050',\n placeholderColor: '#9aa0b6',\n borderColor: '#262a56',\n buttonColor: '#4763eb',\n buttonTextColor: '#ffffff',\n shadowColor: '#0b0e2a'\n }\n};\n\n// Maps the flat coordinate space (u: 0..W along the bar, v: offset from the\n// centerline, positive down) onto a circular arc with the given sagitta\n// (`bend`, in px). Positive bend arches up, negative sags down, 0 is flat.\nconst buildGeometry = (width, bend, thickness, pad) => {\n const W = width;\n const T = thickness;\n const s = Math.max(-W * 0.35, Math.min(bend, W * 0.35));\n const a = Math.abs(s);\n const dir = s >= 0 ? 1 : -1;\n const svgH = T + a + pad * 2;\n\n if (a < 0.75) {\n const midY = pad + T / 2;\n return {\n straight: true,\n W,\n T,\n svgH,\n uPerLen: 1,\n point: (u, v) => [u, midY + v],\n angleAt: () => 0,\n uFromPoint: x => x\n };\n }\n\n const R = (W * W * 0.25 + a * a) / (2 * a);\n const cx = W / 2;\n const apexY = pad + T / 2 + (dir > 0 ? 0 : a);\n const cy = apexY + dir * R;\n const phi = Math.asin(Math.min(1, W / (2 * R)));\n\n return {\n straight: false,\n W,\n T,\n svgH,\n R,\n dir,\n uPerLen: W / (2 * R * phi),\n point: (u, v) => {\n const th = ((u - cx) / cx) * phi;\n const rho = R - dir * v;\n return [cx + rho * Math.sin(th), cy - dir * rho * Math.cos(th)];\n },\n angleAt: u => dir * ((u - cx) / cx) * phi * DEG,\n uFromPoint: (x, y) => {\n const th = Math.atan2(x - cx, dir * (cy - y));\n return cx + (th / phi) * cx;\n }\n };\n};\n\nconst fmt = (g, u, v) => {\n const [x, y] = g.point(u, v);\n return `${round2(x)} ${round2(y)}`;\n};\n\n// Segment along a constant-v edge, as a circular arc (or a line when flat)\nconst edgeSeg = (g, uTo, v, ltr) => {\n if (g.straight) return `L ${fmt(g, uTo, v)}`;\n const rho = round2(g.R - g.dir * v);\n const sweep = ltr === g.dir > 0 ? 1 : 0;\n return `A ${rho} ${rho} 0 0 ${sweep} ${fmt(g, uTo, v)}`;\n};\n\n// A rectangle bent along the arc: circular top/bottom edges, radial end caps\n// and quadratic rounded corners.\nconst bentRectPath = (g, u0, u1, vTop, vBot, radius) => {\n const rc = Math.max(0, Math.min(radius, (vBot - vTop) / 2, (u1 - u0) / 2));\n return [\n `M ${fmt(g, u0 + rc, vTop)}`,\n edgeSeg(g, u1 - rc, vTop, true),\n `Q ${fmt(g, u1, vTop)} ${fmt(g, u1, vTop + rc)}`,\n `L ${fmt(g, u1, vBot - rc)}`,\n `Q ${fmt(g, u1, vBot)} ${fmt(g, u1 - rc, vBot)}`,\n edgeSeg(g, u0 + rc, vBot, false),\n `Q ${fmt(g, u0, vBot)} ${fmt(g, u0, vBot - rc)}`,\n `L ${fmt(g, u0, vTop + rc)}`,\n `Q ${fmt(g, u0, vTop)} ${fmt(g, u0 + rc, vTop)}`,\n 'Z'\n ].join(' ');\n};\n\nconst bentLinePath = (g, u0, u1, v) => `M ${fmt(g, u0, v)} ${edgeSeg(g, u1, v, true)}`;\n\nconst SELECTABLE_TYPES = ['text', 'search', 'tel', 'url', 'password'];\n\nconst CurvedInput = ({\n value,\n defaultValue = '',\n onChange,\n onSubmit,\n placeholder = 'Enter your email',\n buttonText = 'Get Started',\n type = 'email',\n name,\n ariaLabel,\n theme = 'dark',\n width = 450,\n bend = 28,\n height = 64,\n cornerRadius = 18,\n borderWidth = 1.5,\n fontSize = 16,\n backgroundColor,\n textColor,\n placeholderColor,\n borderColor,\n buttonColor,\n buttonTextColor,\n iconColor,\n shadowSize = 'md',\n shadowColor,\n showButton = true,\n showIcon = true,\n icon,\n className = '',\n style\n}) => {\n const uid = useId().replace(/:/g, '');\n const layoutPathId = `ci-text-${uid}`;\n const buttonPathId = `ci-btn-${uid}`;\n const clipId = `ci-clip-${uid}`;\n\n const rootRef = useRef(null);\n const svgRef = useRef(null);\n const inputRef = useRef(null);\n const textRef = useRef(null);\n const btnMeasureRef = useRef(null);\n const scrollRef = useRef(0);\n\n const [w, setW] = useState(0);\n const [innerValue, setInnerValue] = useState(defaultValue);\n const [caretIndex, setCaretIndex] = useState(defaultValue.length);\n const [focused, setFocused] = useState(false);\n const [caretU, setCaretU] = useState(0);\n const [scrollLen, setScrollLen] = useState(0);\n const [btnTextW, setBtnTextW] = useState(0);\n const [, setFontTick] = useState(0);\n\n const val = value !== undefined ? value : innerValue;\n const display = type === 'password' ? '•'.repeat(val.length) : val;\n\n const palette = THEMES[theme] || THEMES.dark;\n const bgColor = backgroundColor ?? palette.backgroundColor;\n const fgColor = textColor ?? palette.textColor;\n const phColor = placeholderColor ?? palette.placeholderColor;\n const strokeColor = borderColor ?? palette.borderColor;\n const accentColor = buttonColor ?? palette.buttonColor;\n const btnFgColor = buttonTextColor ?? palette.buttonTextColor;\n const shColor = shadowColor ?? palette.shadowColor;\n\n useEffect(() => {\n const el = rootRef.current;\n if (!el) return;\n const ro = new ResizeObserver(entries => {\n const cw = entries[0]?.contentRect?.width ?? el.clientWidth;\n setW(Math.round(cw));\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // Re-measure once webfonts finish loading\n useEffect(() => {\n let alive = true;\n if (document.fonts?.ready) {\n document.fonts.ready.then(() => {\n if (alive) setFontTick(t => t + 1);\n });\n }\n return () => {\n alive = false;\n };\n }, []);\n\n const pad = Math.ceil(borderWidth / 2) + 6;\n const geom = useMemo(() => (w > 2 ? buildGeometry(w, bend, height, pad) : null), [w, bend, height, pad]);\n\n const layout = useMemo(() => {\n if (!geom) return null;\n const T = height;\n const btnInset = Math.max(5, borderWidth + 4);\n const chipH = Math.min(34, Math.max(16, T * 0.34));\n const chipW = chipH * 1.25;\n const iconU = 22 + chipW / 2;\n const textStartU = showIcon ? 22 + chipW + 13 : 24;\n const btnW = showButton ? Math.max(btnTextW + fontSize * 2.7, T * 1.35) : 0;\n const btnU1 = geom.W - btnInset;\n const btnU0 = btnU1 - btnW;\n const textEndU = Math.max(textStartU + 20, showButton ? btnU0 - 14 : geom.W - 24);\n const winLen = (textEndU - textStartU) / geom.uPerLen;\n return { btnInset, chipH, chipW, iconU, textStartU, textEndU, btnU0, btnU1, winLen };\n }, [geom, height, borderWidth, btnTextW, fontSize, showIcon, showButton]);\n\n // Measure rendered text to keep the caret on the curve and scroll long\n // values along the arc, exactly like a native input would.\n useLayoutEffect(() => {\n if (btnMeasureRef.current) {\n const bw = btnMeasureRef.current.getComputedTextLength();\n setBtnTextW(prev => (Math.abs(prev - bw) > 0.5 ? bw : prev));\n }\n if (!geom || !layout) return;\n const textEl = textRef.current;\n const caret = Math.min(caretIndex, display.length);\n let caretLen = 0;\n let totalLen = 0;\n if (textEl && display.length) {\n try {\n totalLen = textEl.getSubStringLength(0, display.length);\n caretLen = caret > 0 ? textEl.getSubStringLength(0, caret) : 0;\n } catch {\n totalLen = 0;\n caretLen = 0;\n }\n }\n let next = scrollRef.current;\n if (caretLen - next > layout.winLen - 2) next = caretLen - layout.winLen + 2;\n if (caretLen - next < 0) next = caretLen;\n if (totalLen - next < layout.winLen) next = Math.max(0, totalLen - layout.winLen);\n next = Math.max(0, next);\n if (Math.abs(next - scrollRef.current) > 0.5) {\n scrollRef.current = next;\n setScrollLen(next);\n }\n setCaretU(layout.textStartU + (caretLen - next) * geom.uPerLen);\n });\n\n const commitValue = v => {\n if (value === undefined) setInnerValue(v);\n onChange?.(v);\n };\n\n const handleInputChange = e => {\n commitValue(e.target.value);\n setCaretIndex(e.target.selectionStart ?? e.target.value.length);\n };\n\n const handleSelect = e => {\n setCaretIndex(e.target.selectionStart ?? e.target.value.length);\n };\n\n const handleSubmit = e => {\n if (e?.preventDefault) e.preventDefault();\n if (onSubmit) onSubmit(val);\n };\n\n // Click on the curve: focus the hidden input and drop the caret on the\n // character closest to the click, measured in arc length.\n const handleSurfaceClick = e => {\n const input = inputRef.current;\n if (!input) return;\n let idx = display.length;\n const svg = svgRef.current;\n const textEl = textRef.current;\n if (svg && geom && layout && textEl && display.length) {\n try {\n const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(svg.getScreenCTM().inverse());\n const target = scrollRef.current + (geom.uFromPoint(pt.x, pt.y) - layout.textStartU) / geom.uPerLen;\n let best = 0;\n let bestDist = Infinity;\n for (let i = 0; i <= display.length; i++) {\n const li = i === 0 ? 0 : textEl.getSubStringLength(0, i);\n const d = Math.abs(li - target);\n if (d < bestDist) {\n bestDist = d;\n best = i;\n }\n }\n idx = best;\n } catch {\n idx = display.length;\n }\n }\n input.focus();\n try {\n input.setSelectionRange(idx, idx);\n } catch {\n /* selection API unavailable for this input type */\n }\n setCaretIndex(idx);\n };\n\n const safeType = SELECTABLE_TYPES.includes(type) ? type : 'text';\n const inputMode = type === 'email' ? 'email' : type === 'number' ? 'decimal' : undefined;\n\n const shadow = SHADOWS[shadowSize];\n const svgStyle = shadow\n ? { filter: `drop-shadow(0 ${shadow[0]}px ${shadow[1]}px ${hexToRgba(shColor, shadow[2])})` }\n : undefined;\n\n let content = null;\n if (geom && layout) {\n const T = height;\n const vBase = fontSize * 0.34;\n const scrollU = scrollLen * geom.uPerLen;\n const bandPath = bentRectPath(geom, 0, geom.W, -T / 2, T / 2, cornerRadius);\n const layoutPath = bentLinePath(geom, layout.textStartU - scrollU, geom.W, vBase);\n const clipPath = bentRectPath(geom, layout.textStartU - 6, layout.textEndU + 8, -T / 2, T / 2, 0);\n\n const chipFill = iconColor || accentColor;\n const { chipW, chipH } = layout;\n const ew = chipW * 0.5;\n const eh = chipH * 0.5;\n const sw = Math.max(1.1, chipH * 0.075);\n const [ix, iy] = geom.point(layout.iconU, 0);\n const iconAngle = geom.angleAt(layout.iconU);\n\n const [caretX, caretY] = geom.point(caretU, 0);\n const caretAngle = geom.angleAt(caretU);\n const caretH = Math.min(T * 0.58, fontSize * 1.45);\n\n const btnH = T - layout.btnInset * 2;\n const buttonPath = showButton\n ? bentRectPath(\n geom,\n layout.btnU0,\n layout.btnU1,\n -T / 2 + layout.btnInset,\n T / 2 - layout.btnInset,\n Math.min(cornerRadius * 0.72, btnH / 2)\n )\n : '';\n const buttonTextPath = showButton ? bentLinePath(geom, layout.btnU0, layout.btnU1, vBase) : '';\n\n content = (\n e.preventDefault()}\n onClick={handleSurfaceClick}\n >\n \n \n \n \n \n\n \n \n\n \n\n {showIcon && (\n \n {icon || (\n <>\n \n \n \n \n )}\n \n )}\n\n \n \n {display}\n \n {!display && placeholder && (\n \n {placeholder}\n \n )}\n {focused && (\n \n \n \n \n \n )}\n \n\n {showButton && (\n {\n e.stopPropagation();\n handleSubmit();\n }}\n onPointerDown={e => e.stopPropagation()}\n onKeyDown={e => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n handleSubmit();\n }\n }}\n >\n \n \n \n \n {buttonText}\n \n \n \n )}\n\n \n {buttonText}\n \n \n );\n }\n\n return (\n \n {content}\n setFocused(true)}\n onBlur={() => setFocused(false)}\n aria-label={ariaLabel || placeholder || 'Curved input'}\n autoComplete=\"off\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n spellCheck={false}\n />\n \n );\n};\n\nexport default CurvedInput;\n" } ], "registryDependencies": [], diff --git a/public/r/CurvedInput-TS-CSS.json b/public/r/CurvedInput-TS-CSS.json index b10e5673f..0d141bfaa 100644 --- a/public/r/CurvedInput-TS-CSS.json +++ b/public/r/CurvedInput-TS-CSS.json @@ -8,12 +8,12 @@ { "type": "registry:component", "path": "CurvedInput/CurvedInput.css", - "content": "" + "content": ".curved-input {\n position: relative;\n display: block;\n width: 100%;\n max-width: 100%;\n margin: 0;\n}\n\n.curved-input__svg {\n display: block;\n overflow: visible;\n width: 100%;\n height: auto;\n cursor: text;\n user-select: none;\n -webkit-user-select: none;\n -webkit-tap-highlight-color: transparent;\n}\n\n.curved-input__svg text {\n font-family: inherit;\n}\n\n.curved-input__ring {\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.curved-input--focused .curved-input__ring {\n opacity: 0.28;\n}\n\n.curved-input__button {\n cursor: pointer;\n outline: none;\n}\n\n.curved-input__button-bg {\n transition:\n filter 0.2s ease,\n opacity 0.2s ease;\n}\n\n.curved-input__button:hover .curved-input__button-bg {\n filter: brightness(1.12);\n}\n\n.curved-input__button:active .curved-input__button-bg {\n filter: brightness(0.94);\n}\n\n.curved-input__button:focus-visible .curved-input__button-bg {\n filter: brightness(1.18);\n}\n\n.curved-input__field {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n opacity: 0;\n border: 0;\n padding: 0;\n margin: 0;\n background: transparent;\n color: transparent;\n caret-color: transparent;\n font-size: 16px;\n pointer-events: none;\n outline: none;\n}\n" }, { "type": "registry:component", "path": "CurvedInput/CurvedInput.tsx", - "content": "" + "content": "import {\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n type ChangeEvent,\n type FormEvent,\n type KeyboardEvent,\n type MouseEvent as ReactMouseEvent,\n type PointerEvent as ReactPointerEvent,\n type SyntheticEvent\n} from 'react';\nimport './CurvedInput.css';\n\nconst DEG = 180 / Math.PI;\n\nconst round2 = (n: number): number => Math.round(n * 100) / 100;\n\nconst hexToRgba = (hex: string, alpha: number): string => {\n let h = String(hex).replace('#', '');\n if (h.length === 3)\n h = h\n .split('')\n .map(c => c + c)\n .join('');\n const n = parseInt(h.slice(0, 6), 16);\n if (Number.isNaN(n)) return hex;\n return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;\n};\n\ntype ShadowSize = 'sm' | 'md' | 'lg';\ntype Theme = 'dark' | 'light';\n\nconst SHADOWS: Record = {\n sm: [5, 12, 0.3],\n md: [10, 24, 0.4],\n lg: [16, 40, 0.52]\n};\n\ninterface ThemePalette {\n backgroundColor: string;\n textColor: string;\n placeholderColor: string;\n borderColor: string;\n buttonColor: string;\n buttonTextColor: string;\n shadowColor: string;\n}\n\nconst THEMES: Record = {\n dark: {\n backgroundColor: '#1B1722',\n textColor: '#f5f5f5',\n placeholderColor: '#a1a1aa',\n borderColor: '#392e4e',\n buttonColor: '#A855F7',\n buttonTextColor: '#ffffff',\n shadowColor: '#000000'\n },\n light: {\n backgroundColor: '#ffffff',\n textColor: '#1d2050',\n placeholderColor: '#9aa0b6',\n borderColor: '#262a56',\n buttonColor: '#4763eb',\n buttonTextColor: '#ffffff',\n shadowColor: '#0b0e2a'\n }\n};\n\ninterface Geometry {\n straight: boolean;\n W: number;\n T: number;\n svgH: number;\n R?: number;\n dir?: number;\n uPerLen: number;\n point: (u: number, v: number) => [number, number];\n angleAt: (u: number) => number;\n uFromPoint: (x: number, y?: number) => number;\n}\n\n// Maps the flat coordinate space (u: 0..W along the bar, v: offset from the\n// centerline, positive down) onto a circular arc with the given sagitta\n// (`bend`, in px). Positive bend arches up, negative sags down, 0 is flat.\nconst buildGeometry = (width: number, bend: number, thickness: number, pad: number): Geometry => {\n const W = width;\n const T = thickness;\n const s = Math.max(-W * 0.35, Math.min(bend, W * 0.35));\n const a = Math.abs(s);\n const dir = s >= 0 ? 1 : -1;\n const svgH = T + a + pad * 2;\n\n if (a < 0.75) {\n const midY = pad + T / 2;\n return {\n straight: true,\n W,\n T,\n svgH,\n uPerLen: 1,\n point: (u, v) => [u, midY + v],\n angleAt: () => 0,\n uFromPoint: x => x\n };\n }\n\n const R = (W * W * 0.25 + a * a) / (2 * a);\n const cx = W / 2;\n const apexY = pad + T / 2 + (dir > 0 ? 0 : a);\n const cy = apexY + dir * R;\n const phi = Math.asin(Math.min(1, W / (2 * R)));\n\n return {\n straight: false,\n W,\n T,\n svgH,\n R,\n dir,\n uPerLen: W / (2 * R * phi),\n point: (u, v) => {\n const th = ((u - cx) / cx) * phi;\n const rho = R - dir * v;\n return [cx + rho * Math.sin(th), cy - dir * rho * Math.cos(th)];\n },\n angleAt: u => dir * ((u - cx) / cx) * phi * DEG,\n uFromPoint: (x, y = 0) => {\n const th = Math.atan2(x - cx, dir * (cy - y));\n return cx + (th / phi) * cx;\n }\n };\n};\n\nconst fmt = (g: Geometry, u: number, v: number): string => {\n const [x, y] = g.point(u, v);\n return `${round2(x)} ${round2(y)}`;\n};\n\n// Segment along a constant-v edge, as a circular arc (or a line when flat)\nconst edgeSeg = (g: Geometry, uTo: number, v: number, ltr: boolean): string => {\n if (g.straight) return `L ${fmt(g, uTo, v)}`;\n const rho = round2(g.R! - g.dir! * v);\n const sweep = ltr === g.dir! > 0 ? 1 : 0;\n return `A ${rho} ${rho} 0 0 ${sweep} ${fmt(g, uTo, v)}`;\n};\n\n// A rectangle bent along the arc: circular top/bottom edges, radial end caps\n// and quadratic rounded corners.\nconst bentRectPath = (g: Geometry, u0: number, u1: number, vTop: number, vBot: number, radius: number): string => {\n const rc = Math.max(0, Math.min(radius, (vBot - vTop) / 2, (u1 - u0) / 2));\n return [\n `M ${fmt(g, u0 + rc, vTop)}`,\n edgeSeg(g, u1 - rc, vTop, true),\n `Q ${fmt(g, u1, vTop)} ${fmt(g, u1, vTop + rc)}`,\n `L ${fmt(g, u1, vBot - rc)}`,\n `Q ${fmt(g, u1, vBot)} ${fmt(g, u1 - rc, vBot)}`,\n edgeSeg(g, u0 + rc, vBot, false),\n `Q ${fmt(g, u0, vBot)} ${fmt(g, u0, vBot - rc)}`,\n `L ${fmt(g, u0, vTop + rc)}`,\n `Q ${fmt(g, u0, vTop)} ${fmt(g, u0 + rc, vTop)}`,\n 'Z'\n ].join(' ');\n};\n\nconst bentLinePath = (g: Geometry, u0: number, u1: number, v: number): string =>\n `M ${fmt(g, u0, v)} ${edgeSeg(g, u1, v, true)}`;\n\nconst SELECTABLE_TYPES = ['text', 'search', 'tel', 'url', 'password'];\n\ninterface CurvedInputProps {\n value?: string;\n defaultValue?: string;\n onChange?: (value: string) => void;\n onSubmit?: (value: string) => void;\n placeholder?: string;\n buttonText?: string;\n type?: string;\n name?: string;\n ariaLabel?: string;\n theme?: Theme;\n width?: number | string;\n bend?: number;\n height?: number;\n cornerRadius?: number;\n borderWidth?: number;\n fontSize?: number;\n backgroundColor?: string;\n textColor?: string;\n placeholderColor?: string;\n borderColor?: string;\n buttonColor?: string;\n buttonTextColor?: string;\n iconColor?: string;\n shadowSize?: ShadowSize;\n shadowColor?: string;\n showButton?: boolean;\n showIcon?: boolean;\n icon?: ReactNode;\n className?: string;\n style?: CSSProperties;\n}\n\nconst CurvedInput = ({\n value,\n defaultValue = '',\n onChange,\n onSubmit,\n placeholder = 'Enter your email',\n buttonText = 'Get Started',\n type = 'email',\n name,\n ariaLabel,\n theme = 'dark',\n width = 450,\n bend = 28,\n height = 64,\n cornerRadius = 18,\n borderWidth = 1.5,\n fontSize = 16,\n backgroundColor,\n textColor,\n placeholderColor,\n borderColor,\n buttonColor,\n buttonTextColor,\n iconColor,\n shadowSize = 'md',\n shadowColor,\n showButton = true,\n showIcon = true,\n icon,\n className = '',\n style\n}: CurvedInputProps) => {\n const uid = useId().replace(/:/g, '');\n const layoutPathId = `ci-text-${uid}`;\n const buttonPathId = `ci-btn-${uid}`;\n const clipId = `ci-clip-${uid}`;\n\n const rootRef = useRef(null);\n const svgRef = useRef(null);\n const inputRef = useRef(null);\n const textRef = useRef(null);\n const btnMeasureRef = useRef(null);\n const scrollRef = useRef(0);\n\n const [w, setW] = useState(0);\n const [innerValue, setInnerValue] = useState(defaultValue);\n const [caretIndex, setCaretIndex] = useState(defaultValue.length);\n const [focused, setFocused] = useState(false);\n const [caretU, setCaretU] = useState(0);\n const [scrollLen, setScrollLen] = useState(0);\n const [btnTextW, setBtnTextW] = useState(0);\n const [, setFontTick] = useState(0);\n\n const val = value !== undefined ? value : innerValue;\n const display = type === 'password' ? '•'.repeat(val.length) : val;\n\n const palette = THEMES[theme] || THEMES.dark;\n const bgColor = backgroundColor ?? palette.backgroundColor;\n const fgColor = textColor ?? palette.textColor;\n const phColor = placeholderColor ?? palette.placeholderColor;\n const strokeColor = borderColor ?? palette.borderColor;\n const accentColor = buttonColor ?? palette.buttonColor;\n const btnFgColor = buttonTextColor ?? palette.buttonTextColor;\n const shColor = shadowColor ?? palette.shadowColor;\n\n useEffect(() => {\n const el = rootRef.current;\n if (!el) return;\n const ro = new ResizeObserver(entries => {\n const cw = entries[0]?.contentRect?.width ?? el.clientWidth;\n setW(Math.round(cw));\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // Re-measure once webfonts finish loading\n useEffect(() => {\n let alive = true;\n if (document.fonts?.ready) {\n document.fonts.ready.then(() => {\n if (alive) setFontTick(t => t + 1);\n });\n }\n return () => {\n alive = false;\n };\n }, []);\n\n const pad = Math.ceil(borderWidth / 2) + 6;\n const geom = useMemo(\n () => (w > 2 ? buildGeometry(w, bend, height, pad) : null),\n [w, bend, height, pad]\n );\n\n const layout = useMemo(() => {\n if (!geom) return null;\n const T = height;\n const btnInset = Math.max(5, borderWidth + 4);\n const chipH = Math.min(34, Math.max(16, T * 0.34));\n const chipW = chipH * 1.25;\n const iconU = 22 + chipW / 2;\n const textStartU = showIcon ? 22 + chipW + 13 : 24;\n const btnW = showButton ? Math.max(btnTextW + fontSize * 2.7, T * 1.35) : 0;\n const btnU1 = geom.W - btnInset;\n const btnU0 = btnU1 - btnW;\n const textEndU = Math.max(textStartU + 20, showButton ? btnU0 - 14 : geom.W - 24);\n const winLen = (textEndU - textStartU) / geom.uPerLen;\n return { btnInset, chipH, chipW, iconU, textStartU, textEndU, btnU0, btnU1, winLen };\n }, [geom, height, borderWidth, btnTextW, fontSize, showIcon, showButton]);\n\n // Measure rendered text to keep the caret on the curve and scroll long\n // values along the arc, exactly like a native input would.\n useLayoutEffect(() => {\n if (btnMeasureRef.current) {\n const bw = btnMeasureRef.current.getComputedTextLength();\n setBtnTextW(prev => (Math.abs(prev - bw) > 0.5 ? bw : prev));\n }\n if (!geom || !layout) return;\n const textEl = textRef.current;\n const caret = Math.min(caretIndex, display.length);\n let caretLen = 0;\n let totalLen = 0;\n if (textEl && display.length) {\n try {\n totalLen = textEl.getSubStringLength(0, display.length);\n caretLen = caret > 0 ? textEl.getSubStringLength(0, caret) : 0;\n } catch {\n totalLen = 0;\n caretLen = 0;\n }\n }\n let next = scrollRef.current;\n if (caretLen - next > layout.winLen - 2) next = caretLen - layout.winLen + 2;\n if (caretLen - next < 0) next = caretLen;\n if (totalLen - next < layout.winLen) next = Math.max(0, totalLen - layout.winLen);\n next = Math.max(0, next);\n if (Math.abs(next - scrollRef.current) > 0.5) {\n scrollRef.current = next;\n setScrollLen(next);\n }\n setCaretU(layout.textStartU + (caretLen - next) * geom.uPerLen);\n });\n\n const commitValue = (v: string) => {\n if (value === undefined) setInnerValue(v);\n onChange?.(v);\n };\n\n const handleInputChange = (e: ChangeEvent) => {\n commitValue(e.target.value);\n setCaretIndex(e.target.selectionStart ?? e.target.value.length);\n };\n\n const handleSelect = (e: SyntheticEvent) => {\n const target = e.currentTarget;\n setCaretIndex(target.selectionStart ?? target.value.length);\n };\n\n const handleSubmit = (e?: FormEvent) => {\n if (e?.preventDefault) e.preventDefault();\n if (onSubmit) onSubmit(val);\n };\n\n // Click on the curve: focus the hidden input and drop the caret on the\n // character closest to the click, measured in arc length.\n const handleSurfaceClick = (e: ReactMouseEvent) => {\n const input = inputRef.current;\n if (!input) return;\n let idx = display.length;\n const svg = svgRef.current;\n const textEl = textRef.current;\n if (svg && geom && layout && textEl && display.length) {\n try {\n const ctm = svg.getScreenCTM();\n if (!ctm) throw new Error('missing screen CTM');\n const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());\n const target = scrollRef.current + (geom.uFromPoint(pt.x, pt.y) - layout.textStartU) / geom.uPerLen;\n let best = 0;\n let bestDist = Infinity;\n for (let i = 0; i <= display.length; i++) {\n const li = i === 0 ? 0 : textEl.getSubStringLength(0, i);\n const d = Math.abs(li - target);\n if (d < bestDist) {\n bestDist = d;\n best = i;\n }\n }\n idx = best;\n } catch {\n idx = display.length;\n }\n }\n input.focus();\n try {\n input.setSelectionRange(idx, idx);\n } catch {\n /* selection API unavailable for this input type */\n }\n setCaretIndex(idx);\n };\n\n const safeType = SELECTABLE_TYPES.includes(type) ? type : 'text';\n const inputMode = type === 'email' ? 'email' : type === 'number' ? 'decimal' : undefined;\n\n const shadow = SHADOWS[shadowSize];\n const svgStyle: CSSProperties | undefined = shadow\n ? { filter: `drop-shadow(0 ${shadow[0]}px ${shadow[1]}px ${hexToRgba(shColor, shadow[2])})` }\n : undefined;\n\n let content: ReactNode = null;\n if (geom && layout) {\n const T = height;\n const vBase = fontSize * 0.34;\n const scrollU = scrollLen * geom.uPerLen;\n const bandPath = bentRectPath(geom, 0, geom.W, -T / 2, T / 2, cornerRadius);\n const layoutPath = bentLinePath(geom, layout.textStartU - scrollU, geom.W, vBase);\n const clipPath = bentRectPath(geom, layout.textStartU - 6, layout.textEndU + 8, -T / 2, T / 2, 0);\n\n const chipFill = iconColor || accentColor;\n const { chipW, chipH } = layout;\n const ew = chipW * 0.5;\n const eh = chipH * 0.5;\n const sw = Math.max(1.1, chipH * 0.075);\n const [ix, iy] = geom.point(layout.iconU, 0);\n const iconAngle = geom.angleAt(layout.iconU);\n\n const [caretX, caretY] = geom.point(caretU, 0);\n const caretAngle = geom.angleAt(caretU);\n const caretH = Math.min(T * 0.58, fontSize * 1.45);\n\n const btnH = T - layout.btnInset * 2;\n const buttonPath = showButton\n ? bentRectPath(\n geom,\n layout.btnU0,\n layout.btnU1,\n -T / 2 + layout.btnInset,\n T / 2 - layout.btnInset,\n Math.min(cornerRadius * 0.72, btnH / 2)\n )\n : '';\n const buttonTextPath = showButton ? bentLinePath(geom, layout.btnU0, layout.btnU1, vBase) : '';\n\n content = (\n e.preventDefault()}\n onClick={handleSurfaceClick}\n >\n \n \n \n \n \n\n \n \n\n \n\n {showIcon && (\n \n {icon || (\n <>\n \n \n \n \n )}\n \n )}\n\n \n \n {display}\n \n {!display && placeholder && (\n \n {placeholder}\n \n )}\n {focused && (\n \n \n \n \n \n )}\n \n\n {showButton && (\n {\n e.stopPropagation();\n handleSubmit();\n }}\n onPointerDown={(e: ReactPointerEvent) => e.stopPropagation()}\n onKeyDown={(e: KeyboardEvent) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n handleSubmit();\n }\n }}\n >\n \n \n \n \n {buttonText}\n \n \n \n )}\n\n \n {buttonText}\n \n \n );\n }\n\n return (\n \n {content}\n setFocused(true)}\n onBlur={() => setFocused(false)}\n aria-label={ariaLabel || placeholder || 'Curved input'}\n autoComplete=\"off\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n spellCheck={false}\n />\n \n );\n};\n\nexport default CurvedInput;\n" } ], "registryDependencies": [], diff --git a/public/r/CurvedInput-TS-TW.json b/public/r/CurvedInput-TS-TW.json index 1c160dab0..95926bcc7 100644 --- a/public/r/CurvedInput-TS-TW.json +++ b/public/r/CurvedInput-TS-TW.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "CurvedInput/CurvedInput.tsx", - "content": "" + "content": "import {\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n type ChangeEvent,\n type FormEvent,\n type KeyboardEvent,\n type MouseEvent as ReactMouseEvent,\n type PointerEvent as ReactPointerEvent,\n type SyntheticEvent\n} from 'react';\n\nconst DEG = 180 / Math.PI;\n\nconst round2 = (n: number): number => Math.round(n * 100) / 100;\n\nconst hexToRgba = (hex: string, alpha: number): string => {\n let h = String(hex).replace('#', '');\n if (h.length === 3)\n h = h\n .split('')\n .map(c => c + c)\n .join('');\n const n = parseInt(h.slice(0, 6), 16);\n if (Number.isNaN(n)) return hex;\n return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;\n};\n\ntype ShadowSize = 'sm' | 'md' | 'lg';\ntype Theme = 'dark' | 'light';\n\nconst SHADOWS: Record = {\n sm: [5, 12, 0.3],\n md: [10, 24, 0.4],\n lg: [16, 40, 0.52]\n};\n\ninterface ThemePalette {\n backgroundColor: string;\n textColor: string;\n placeholderColor: string;\n borderColor: string;\n buttonColor: string;\n buttonTextColor: string;\n shadowColor: string;\n}\n\nconst THEMES: Record = {\n dark: {\n backgroundColor: '#1B1722',\n textColor: '#f5f5f5',\n placeholderColor: '#a1a1aa',\n borderColor: '#392e4e',\n buttonColor: '#A855F7',\n buttonTextColor: '#ffffff',\n shadowColor: '#000000'\n },\n light: {\n backgroundColor: '#ffffff',\n textColor: '#1d2050',\n placeholderColor: '#9aa0b6',\n borderColor: '#262a56',\n buttonColor: '#4763eb',\n buttonTextColor: '#ffffff',\n shadowColor: '#0b0e2a'\n }\n};\n\ninterface Geometry {\n straight: boolean;\n W: number;\n T: number;\n svgH: number;\n R?: number;\n dir?: number;\n uPerLen: number;\n point: (u: number, v: number) => [number, number];\n angleAt: (u: number) => number;\n uFromPoint: (x: number, y?: number) => number;\n}\n\n// Maps the flat coordinate space (u: 0..W along the bar, v: offset from the\n// centerline, positive down) onto a circular arc with the given sagitta\n// (`bend`, in px). Positive bend arches up, negative sags down, 0 is flat.\nconst buildGeometry = (width: number, bend: number, thickness: number, pad: number): Geometry => {\n const W = width;\n const T = thickness;\n const s = Math.max(-W * 0.35, Math.min(bend, W * 0.35));\n const a = Math.abs(s);\n const dir = s >= 0 ? 1 : -1;\n const svgH = T + a + pad * 2;\n\n if (a < 0.75) {\n const midY = pad + T / 2;\n return {\n straight: true,\n W,\n T,\n svgH,\n uPerLen: 1,\n point: (u, v) => [u, midY + v],\n angleAt: () => 0,\n uFromPoint: x => x\n };\n }\n\n const R = (W * W * 0.25 + a * a) / (2 * a);\n const cx = W / 2;\n const apexY = pad + T / 2 + (dir > 0 ? 0 : a);\n const cy = apexY + dir * R;\n const phi = Math.asin(Math.min(1, W / (2 * R)));\n\n return {\n straight: false,\n W,\n T,\n svgH,\n R,\n dir,\n uPerLen: W / (2 * R * phi),\n point: (u, v) => {\n const th = ((u - cx) / cx) * phi;\n const rho = R - dir * v;\n return [cx + rho * Math.sin(th), cy - dir * rho * Math.cos(th)];\n },\n angleAt: u => dir * ((u - cx) / cx) * phi * DEG,\n uFromPoint: (x, y = 0) => {\n const th = Math.atan2(x - cx, dir * (cy - y));\n return cx + (th / phi) * cx;\n }\n };\n};\n\nconst fmt = (g: Geometry, u: number, v: number): string => {\n const [x, y] = g.point(u, v);\n return `${round2(x)} ${round2(y)}`;\n};\n\n// Segment along a constant-v edge, as a circular arc (or a line when flat)\nconst edgeSeg = (g: Geometry, uTo: number, v: number, ltr: boolean): string => {\n if (g.straight) return `L ${fmt(g, uTo, v)}`;\n const rho = round2(g.R! - g.dir! * v);\n const sweep = ltr === g.dir! > 0 ? 1 : 0;\n return `A ${rho} ${rho} 0 0 ${sweep} ${fmt(g, uTo, v)}`;\n};\n\n// A rectangle bent along the arc: circular top/bottom edges, radial end caps\n// and quadratic rounded corners.\nconst bentRectPath = (g: Geometry, u0: number, u1: number, vTop: number, vBot: number, radius: number): string => {\n const rc = Math.max(0, Math.min(radius, (vBot - vTop) / 2, (u1 - u0) / 2));\n return [\n `M ${fmt(g, u0 + rc, vTop)}`,\n edgeSeg(g, u1 - rc, vTop, true),\n `Q ${fmt(g, u1, vTop)} ${fmt(g, u1, vTop + rc)}`,\n `L ${fmt(g, u1, vBot - rc)}`,\n `Q ${fmt(g, u1, vBot)} ${fmt(g, u1 - rc, vBot)}`,\n edgeSeg(g, u0 + rc, vBot, false),\n `Q ${fmt(g, u0, vBot)} ${fmt(g, u0, vBot - rc)}`,\n `L ${fmt(g, u0, vTop + rc)}`,\n `Q ${fmt(g, u0, vTop)} ${fmt(g, u0 + rc, vTop)}`,\n 'Z'\n ].join(' ');\n};\n\nconst bentLinePath = (g: Geometry, u0: number, u1: number, v: number): string =>\n `M ${fmt(g, u0, v)} ${edgeSeg(g, u1, v, true)}`;\n\nconst SELECTABLE_TYPES = ['text', 'search', 'tel', 'url', 'password'];\n\ninterface CurvedInputProps {\n value?: string;\n defaultValue?: string;\n onChange?: (value: string) => void;\n onSubmit?: (value: string) => void;\n placeholder?: string;\n buttonText?: string;\n type?: string;\n name?: string;\n ariaLabel?: string;\n theme?: Theme;\n width?: number | string;\n bend?: number;\n height?: number;\n cornerRadius?: number;\n borderWidth?: number;\n fontSize?: number;\n backgroundColor?: string;\n textColor?: string;\n placeholderColor?: string;\n borderColor?: string;\n buttonColor?: string;\n buttonTextColor?: string;\n iconColor?: string;\n shadowSize?: ShadowSize;\n shadowColor?: string;\n showButton?: boolean;\n showIcon?: boolean;\n icon?: ReactNode;\n className?: string;\n style?: CSSProperties;\n}\n\nconst CurvedInput = ({\n value,\n defaultValue = '',\n onChange,\n onSubmit,\n placeholder = 'Enter your email',\n buttonText = 'Get Started',\n type = 'email',\n name,\n ariaLabel,\n theme = 'dark',\n width = 450,\n bend = 28,\n height = 64,\n cornerRadius = 18,\n borderWidth = 1.5,\n fontSize = 16,\n backgroundColor,\n textColor,\n placeholderColor,\n borderColor,\n buttonColor,\n buttonTextColor,\n iconColor,\n shadowSize = 'md',\n shadowColor,\n showButton = true,\n showIcon = true,\n icon,\n className = '',\n style\n}: CurvedInputProps) => {\n const uid = useId().replace(/:/g, '');\n const layoutPathId = `ci-text-${uid}`;\n const buttonPathId = `ci-btn-${uid}`;\n const clipId = `ci-clip-${uid}`;\n\n const rootRef = useRef(null);\n const svgRef = useRef(null);\n const inputRef = useRef(null);\n const textRef = useRef(null);\n const btnMeasureRef = useRef(null);\n const scrollRef = useRef(0);\n\n const [w, setW] = useState(0);\n const [innerValue, setInnerValue] = useState(defaultValue);\n const [caretIndex, setCaretIndex] = useState(defaultValue.length);\n const [focused, setFocused] = useState(false);\n const [caretU, setCaretU] = useState(0);\n const [scrollLen, setScrollLen] = useState(0);\n const [btnTextW, setBtnTextW] = useState(0);\n const [, setFontTick] = useState(0);\n\n const val = value !== undefined ? value : innerValue;\n const display = type === 'password' ? '•'.repeat(val.length) : val;\n\n const palette = THEMES[theme] || THEMES.dark;\n const bgColor = backgroundColor ?? palette.backgroundColor;\n const fgColor = textColor ?? palette.textColor;\n const phColor = placeholderColor ?? palette.placeholderColor;\n const strokeColor = borderColor ?? palette.borderColor;\n const accentColor = buttonColor ?? palette.buttonColor;\n const btnFgColor = buttonTextColor ?? palette.buttonTextColor;\n const shColor = shadowColor ?? palette.shadowColor;\n\n useEffect(() => {\n const el = rootRef.current;\n if (!el) return;\n const ro = new ResizeObserver(entries => {\n const cw = entries[0]?.contentRect?.width ?? el.clientWidth;\n setW(Math.round(cw));\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n // Re-measure once webfonts finish loading\n useEffect(() => {\n let alive = true;\n if (document.fonts?.ready) {\n document.fonts.ready.then(() => {\n if (alive) setFontTick(t => t + 1);\n });\n }\n return () => {\n alive = false;\n };\n }, []);\n\n const pad = Math.ceil(borderWidth / 2) + 6;\n const geom = useMemo(\n () => (w > 2 ? buildGeometry(w, bend, height, pad) : null),\n [w, bend, height, pad]\n );\n\n const layout = useMemo(() => {\n if (!geom) return null;\n const T = height;\n const btnInset = Math.max(5, borderWidth + 4);\n const chipH = Math.min(34, Math.max(16, T * 0.34));\n const chipW = chipH * 1.25;\n const iconU = 22 + chipW / 2;\n const textStartU = showIcon ? 22 + chipW + 13 : 24;\n const btnW = showButton ? Math.max(btnTextW + fontSize * 2.7, T * 1.35) : 0;\n const btnU1 = geom.W - btnInset;\n const btnU0 = btnU1 - btnW;\n const textEndU = Math.max(textStartU + 20, showButton ? btnU0 - 14 : geom.W - 24);\n const winLen = (textEndU - textStartU) / geom.uPerLen;\n return { btnInset, chipH, chipW, iconU, textStartU, textEndU, btnU0, btnU1, winLen };\n }, [geom, height, borderWidth, btnTextW, fontSize, showIcon, showButton]);\n\n // Measure rendered text to keep the caret on the curve and scroll long\n // values along the arc, exactly like a native input would.\n useLayoutEffect(() => {\n if (btnMeasureRef.current) {\n const bw = btnMeasureRef.current.getComputedTextLength();\n setBtnTextW(prev => (Math.abs(prev - bw) > 0.5 ? bw : prev));\n }\n if (!geom || !layout) return;\n const textEl = textRef.current;\n const caret = Math.min(caretIndex, display.length);\n let caretLen = 0;\n let totalLen = 0;\n if (textEl && display.length) {\n try {\n totalLen = textEl.getSubStringLength(0, display.length);\n caretLen = caret > 0 ? textEl.getSubStringLength(0, caret) : 0;\n } catch {\n totalLen = 0;\n caretLen = 0;\n }\n }\n let next = scrollRef.current;\n if (caretLen - next > layout.winLen - 2) next = caretLen - layout.winLen + 2;\n if (caretLen - next < 0) next = caretLen;\n if (totalLen - next < layout.winLen) next = Math.max(0, totalLen - layout.winLen);\n next = Math.max(0, next);\n if (Math.abs(next - scrollRef.current) > 0.5) {\n scrollRef.current = next;\n setScrollLen(next);\n }\n setCaretU(layout.textStartU + (caretLen - next) * geom.uPerLen);\n });\n\n const commitValue = (v: string) => {\n if (value === undefined) setInnerValue(v);\n onChange?.(v);\n };\n\n const handleInputChange = (e: ChangeEvent) => {\n commitValue(e.target.value);\n setCaretIndex(e.target.selectionStart ?? e.target.value.length);\n };\n\n const handleSelect = (e: SyntheticEvent) => {\n const target = e.currentTarget;\n setCaretIndex(target.selectionStart ?? target.value.length);\n };\n\n const handleSubmit = (e?: FormEvent) => {\n if (e?.preventDefault) e.preventDefault();\n if (onSubmit) onSubmit(val);\n };\n\n // Click on the curve: focus the hidden input and drop the caret on the\n // character closest to the click, measured in arc length.\n const handleSurfaceClick = (e: ReactMouseEvent) => {\n const input = inputRef.current;\n if (!input) return;\n let idx = display.length;\n const svg = svgRef.current;\n const textEl = textRef.current;\n if (svg && geom && layout && textEl && display.length) {\n try {\n const ctm = svg.getScreenCTM();\n if (!ctm) throw new Error('missing screen CTM');\n const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());\n const target = scrollRef.current + (geom.uFromPoint(pt.x, pt.y) - layout.textStartU) / geom.uPerLen;\n let best = 0;\n let bestDist = Infinity;\n for (let i = 0; i <= display.length; i++) {\n const li = i === 0 ? 0 : textEl.getSubStringLength(0, i);\n const d = Math.abs(li - target);\n if (d < bestDist) {\n bestDist = d;\n best = i;\n }\n }\n idx = best;\n } catch {\n idx = display.length;\n }\n }\n input.focus();\n try {\n input.setSelectionRange(idx, idx);\n } catch {\n /* selection API unavailable for this input type */\n }\n setCaretIndex(idx);\n };\n\n const safeType = SELECTABLE_TYPES.includes(type) ? type : 'text';\n const inputMode = type === 'email' ? 'email' : type === 'number' ? 'decimal' : undefined;\n\n const shadow = SHADOWS[shadowSize];\n const svgStyle: CSSProperties | undefined = shadow\n ? { filter: `drop-shadow(0 ${shadow[0]}px ${shadow[1]}px ${hexToRgba(shColor, shadow[2])})` }\n : undefined;\n\n let content: ReactNode = null;\n if (geom && layout) {\n const T = height;\n const vBase = fontSize * 0.34;\n const scrollU = scrollLen * geom.uPerLen;\n const bandPath = bentRectPath(geom, 0, geom.W, -T / 2, T / 2, cornerRadius);\n const layoutPath = bentLinePath(geom, layout.textStartU - scrollU, geom.W, vBase);\n const clipPath = bentRectPath(geom, layout.textStartU - 6, layout.textEndU + 8, -T / 2, T / 2, 0);\n\n const chipFill = iconColor || accentColor;\n const { chipW, chipH } = layout;\n const ew = chipW * 0.5;\n const eh = chipH * 0.5;\n const sw = Math.max(1.1, chipH * 0.075);\n const [ix, iy] = geom.point(layout.iconU, 0);\n const iconAngle = geom.angleAt(layout.iconU);\n\n const [caretX, caretY] = geom.point(caretU, 0);\n const caretAngle = geom.angleAt(caretU);\n const caretH = Math.min(T * 0.58, fontSize * 1.45);\n\n const btnH = T - layout.btnInset * 2;\n const buttonPath = showButton\n ? bentRectPath(\n geom,\n layout.btnU0,\n layout.btnU1,\n -T / 2 + layout.btnInset,\n T / 2 - layout.btnInset,\n Math.min(cornerRadius * 0.72, btnH / 2)\n )\n : '';\n const buttonTextPath = showButton ? bentLinePath(geom, layout.btnU0, layout.btnU1, vBase) : '';\n\n content = (\n e.preventDefault()}\n onClick={handleSurfaceClick}\n >\n \n \n \n \n \n\n \n \n\n \n\n {showIcon && (\n \n {icon || (\n <>\n \n \n \n \n )}\n \n )}\n\n \n \n {display}\n \n {!display && placeholder && (\n \n {placeholder}\n \n )}\n {focused && (\n \n \n \n \n \n )}\n \n\n {showButton && (\n {\n e.stopPropagation();\n handleSubmit();\n }}\n onPointerDown={(e: ReactPointerEvent) => e.stopPropagation()}\n onKeyDown={(e: KeyboardEvent) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n handleSubmit();\n }\n }}\n >\n \n \n \n \n {buttonText}\n \n \n \n )}\n\n \n {buttonText}\n \n \n );\n }\n\n return (\n \n {content}\n setFocused(true)}\n onBlur={() => setFocused(false)}\n aria-label={ariaLabel || placeholder || 'Curved input'}\n autoComplete=\"off\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n spellCheck={false}\n />\n \n );\n};\n\nexport default CurvedInput;\n" } ], "registryDependencies": [], diff --git a/src/tailwind/Components/CurvedInput/CurvedInput.jsx b/src/tailwind/Components/CurvedInput/CurvedInput.jsx index e69de29bb..e84933716 100644 --- a/src/tailwind/Components/CurvedInput/CurvedInput.jsx +++ b/src/tailwind/Components/CurvedInput/CurvedInput.jsx @@ -0,0 +1,544 @@ +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +const DEG = 180 / Math.PI; + +const round2 = n => Math.round(n * 100) / 100; + +const hexToRgba = (hex, alpha) => { + let h = String(hex).replace('#', ''); + if (h.length === 3) + h = h + .split('') + .map(c => c + c) + .join(''); + const n = parseInt(h.slice(0, 6), 16); + if (Number.isNaN(n)) return hex; + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; +}; + +const SHADOWS = { sm: [5, 12, 0.3], md: [10, 24, 0.4], lg: [16, 40, 0.52] }; + +const THEMES = { + dark: { + backgroundColor: '#1B1722', + textColor: '#f5f5f5', + placeholderColor: '#a1a1aa', + borderColor: '#392e4e', + buttonColor: '#A855F7', + buttonTextColor: '#ffffff', + shadowColor: '#000000' + }, + light: { + backgroundColor: '#ffffff', + textColor: '#1d2050', + placeholderColor: '#9aa0b6', + borderColor: '#262a56', + buttonColor: '#4763eb', + buttonTextColor: '#ffffff', + shadowColor: '#0b0e2a' + } +}; + +// Maps the flat coordinate space (u: 0..W along the bar, v: offset from the +// centerline, positive down) onto a circular arc with the given sagitta +// (`bend`, in px). Positive bend arches up, negative sags down, 0 is flat. +const buildGeometry = (width, bend, thickness, pad) => { + const W = width; + const T = thickness; + const s = Math.max(-W * 0.35, Math.min(bend, W * 0.35)); + const a = Math.abs(s); + const dir = s >= 0 ? 1 : -1; + const svgH = T + a + pad * 2; + + if (a < 0.75) { + const midY = pad + T / 2; + return { + straight: true, + W, + T, + svgH, + uPerLen: 1, + point: (u, v) => [u, midY + v], + angleAt: () => 0, + uFromPoint: x => x + }; + } + + const R = (W * W * 0.25 + a * a) / (2 * a); + const cx = W / 2; + const apexY = pad + T / 2 + (dir > 0 ? 0 : a); + const cy = apexY + dir * R; + const phi = Math.asin(Math.min(1, W / (2 * R))); + + return { + straight: false, + W, + T, + svgH, + R, + dir, + uPerLen: W / (2 * R * phi), + point: (u, v) => { + const th = ((u - cx) / cx) * phi; + const rho = R - dir * v; + return [cx + rho * Math.sin(th), cy - dir * rho * Math.cos(th)]; + }, + angleAt: u => dir * ((u - cx) / cx) * phi * DEG, + uFromPoint: (x, y) => { + const th = Math.atan2(x - cx, dir * (cy - y)); + return cx + (th / phi) * cx; + } + }; +}; + +const fmt = (g, u, v) => { + const [x, y] = g.point(u, v); + return `${round2(x)} ${round2(y)}`; +}; + +// Segment along a constant-v edge, as a circular arc (or a line when flat) +const edgeSeg = (g, uTo, v, ltr) => { + if (g.straight) return `L ${fmt(g, uTo, v)}`; + const rho = round2(g.R - g.dir * v); + const sweep = ltr === g.dir > 0 ? 1 : 0; + return `A ${rho} ${rho} 0 0 ${sweep} ${fmt(g, uTo, v)}`; +}; + +// A rectangle bent along the arc: circular top/bottom edges, radial end caps +// and quadratic rounded corners. +const bentRectPath = (g, u0, u1, vTop, vBot, radius) => { + const rc = Math.max(0, Math.min(radius, (vBot - vTop) / 2, (u1 - u0) / 2)); + return [ + `M ${fmt(g, u0 + rc, vTop)}`, + edgeSeg(g, u1 - rc, vTop, true), + `Q ${fmt(g, u1, vTop)} ${fmt(g, u1, vTop + rc)}`, + `L ${fmt(g, u1, vBot - rc)}`, + `Q ${fmt(g, u1, vBot)} ${fmt(g, u1 - rc, vBot)}`, + edgeSeg(g, u0 + rc, vBot, false), + `Q ${fmt(g, u0, vBot)} ${fmt(g, u0, vBot - rc)}`, + `L ${fmt(g, u0, vTop + rc)}`, + `Q ${fmt(g, u0, vTop)} ${fmt(g, u0 + rc, vTop)}`, + 'Z' + ].join(' '); +}; + +const bentLinePath = (g, u0, u1, v) => `M ${fmt(g, u0, v)} ${edgeSeg(g, u1, v, true)}`; + +const SELECTABLE_TYPES = ['text', 'search', 'tel', 'url', 'password']; + +const CurvedInput = ({ + value, + defaultValue = '', + onChange, + onSubmit, + placeholder = 'Enter your email', + buttonText = 'Get Started', + type = 'email', + name, + ariaLabel, + theme = 'dark', + width = 450, + bend = 28, + height = 64, + cornerRadius = 18, + borderWidth = 1.5, + fontSize = 16, + backgroundColor, + textColor, + placeholderColor, + borderColor, + buttonColor, + buttonTextColor, + iconColor, + shadowSize = 'md', + shadowColor, + showButton = true, + showIcon = true, + icon, + className = '', + style +}) => { + const uid = useId().replace(/:/g, ''); + const layoutPathId = `ci-text-${uid}`; + const buttonPathId = `ci-btn-${uid}`; + const clipId = `ci-clip-${uid}`; + + const rootRef = useRef(null); + const svgRef = useRef(null); + const inputRef = useRef(null); + const textRef = useRef(null); + const btnMeasureRef = useRef(null); + const scrollRef = useRef(0); + + const [w, setW] = useState(0); + const [innerValue, setInnerValue] = useState(defaultValue); + const [caretIndex, setCaretIndex] = useState(defaultValue.length); + const [focused, setFocused] = useState(false); + const [caretU, setCaretU] = useState(0); + const [scrollLen, setScrollLen] = useState(0); + const [btnTextW, setBtnTextW] = useState(0); + const [, setFontTick] = useState(0); + + const val = value !== undefined ? value : innerValue; + const display = type === 'password' ? '•'.repeat(val.length) : val; + + const palette = THEMES[theme] || THEMES.dark; + const bgColor = backgroundColor ?? palette.backgroundColor; + const fgColor = textColor ?? palette.textColor; + const phColor = placeholderColor ?? palette.placeholderColor; + const strokeColor = borderColor ?? palette.borderColor; + const accentColor = buttonColor ?? palette.buttonColor; + const btnFgColor = buttonTextColor ?? palette.buttonTextColor; + const shColor = shadowColor ?? palette.shadowColor; + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const ro = new ResizeObserver(entries => { + const cw = entries[0]?.contentRect?.width ?? el.clientWidth; + setW(Math.round(cw)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Re-measure once webfonts finish loading + useEffect(() => { + let alive = true; + if (document.fonts?.ready) { + document.fonts.ready.then(() => { + if (alive) setFontTick(t => t + 1); + }); + } + return () => { + alive = false; + }; + }, []); + + const pad = Math.ceil(borderWidth / 2) + 6; + const geom = useMemo(() => (w > 2 ? buildGeometry(w, bend, height, pad) : null), [w, bend, height, pad]); + + const layout = useMemo(() => { + if (!geom) return null; + const T = height; + const btnInset = Math.max(5, borderWidth + 4); + const chipH = Math.min(34, Math.max(16, T * 0.34)); + const chipW = chipH * 1.25; + const iconU = 22 + chipW / 2; + const textStartU = showIcon ? 22 + chipW + 13 : 24; + const btnW = showButton ? Math.max(btnTextW + fontSize * 2.7, T * 1.35) : 0; + const btnU1 = geom.W - btnInset; + const btnU0 = btnU1 - btnW; + const textEndU = Math.max(textStartU + 20, showButton ? btnU0 - 14 : geom.W - 24); + const winLen = (textEndU - textStartU) / geom.uPerLen; + return { btnInset, chipH, chipW, iconU, textStartU, textEndU, btnU0, btnU1, winLen }; + }, [geom, height, borderWidth, btnTextW, fontSize, showIcon, showButton]); + + // Measure rendered text to keep the caret on the curve and scroll long + // values along the arc, exactly like a native input would. + useLayoutEffect(() => { + if (btnMeasureRef.current) { + const bw = btnMeasureRef.current.getComputedTextLength(); + setBtnTextW(prev => (Math.abs(prev - bw) > 0.5 ? bw : prev)); + } + if (!geom || !layout) return; + const textEl = textRef.current; + const caret = Math.min(caretIndex, display.length); + let caretLen = 0; + let totalLen = 0; + if (textEl && display.length) { + try { + totalLen = textEl.getSubStringLength(0, display.length); + caretLen = caret > 0 ? textEl.getSubStringLength(0, caret) : 0; + } catch { + totalLen = 0; + caretLen = 0; + } + } + let next = scrollRef.current; + if (caretLen - next > layout.winLen - 2) next = caretLen - layout.winLen + 2; + if (caretLen - next < 0) next = caretLen; + if (totalLen - next < layout.winLen) next = Math.max(0, totalLen - layout.winLen); + next = Math.max(0, next); + if (Math.abs(next - scrollRef.current) > 0.5) { + scrollRef.current = next; + setScrollLen(next); + } + setCaretU(layout.textStartU + (caretLen - next) * geom.uPerLen); + }); + + const commitValue = v => { + if (value === undefined) setInnerValue(v); + onChange?.(v); + }; + + const handleInputChange = e => { + commitValue(e.target.value); + setCaretIndex(e.target.selectionStart ?? e.target.value.length); + }; + + const handleSelect = e => { + setCaretIndex(e.target.selectionStart ?? e.target.value.length); + }; + + const handleSubmit = e => { + if (e?.preventDefault) e.preventDefault(); + if (onSubmit) onSubmit(val); + }; + + // Click on the curve: focus the hidden input and drop the caret on the + // character closest to the click, measured in arc length. + const handleSurfaceClick = e => { + const input = inputRef.current; + if (!input) return; + let idx = display.length; + const svg = svgRef.current; + const textEl = textRef.current; + if (svg && geom && layout && textEl && display.length) { + try { + const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(svg.getScreenCTM().inverse()); + const target = scrollRef.current + (geom.uFromPoint(pt.x, pt.y) - layout.textStartU) / geom.uPerLen; + let best = 0; + let bestDist = Infinity; + for (let i = 0; i <= display.length; i++) { + const li = i === 0 ? 0 : textEl.getSubStringLength(0, i); + const d = Math.abs(li - target); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + idx = best; + } catch { + idx = display.length; + } + } + input.focus(); + try { + input.setSelectionRange(idx, idx); + } catch { + /* selection API unavailable for this input type */ + } + setCaretIndex(idx); + }; + + const safeType = SELECTABLE_TYPES.includes(type) ? type : 'text'; + const inputMode = type === 'email' ? 'email' : type === 'number' ? 'decimal' : undefined; + + const shadow = SHADOWS[shadowSize]; + const svgStyle = shadow + ? { filter: `drop-shadow(0 ${shadow[0]}px ${shadow[1]}px ${hexToRgba(shColor, shadow[2])})` } + : undefined; + + let content = null; + if (geom && layout) { + const T = height; + const vBase = fontSize * 0.34; + const scrollU = scrollLen * geom.uPerLen; + const bandPath = bentRectPath(geom, 0, geom.W, -T / 2, T / 2, cornerRadius); + const layoutPath = bentLinePath(geom, layout.textStartU - scrollU, geom.W, vBase); + const clipPath = bentRectPath(geom, layout.textStartU - 6, layout.textEndU + 8, -T / 2, T / 2, 0); + + const chipFill = iconColor || accentColor; + const { chipW, chipH } = layout; + const ew = chipW * 0.5; + const eh = chipH * 0.5; + const sw = Math.max(1.1, chipH * 0.075); + const [ix, iy] = geom.point(layout.iconU, 0); + const iconAngle = geom.angleAt(layout.iconU); + + const [caretX, caretY] = geom.point(caretU, 0); + const caretAngle = geom.angleAt(caretU); + const caretH = Math.min(T * 0.58, fontSize * 1.45); + + const btnH = T - layout.btnInset * 2; + const buttonPath = showButton + ? bentRectPath( + geom, + layout.btnU0, + layout.btnU1, + -T / 2 + layout.btnInset, + T / 2 - layout.btnInset, + Math.min(cornerRadius * 0.72, btnH / 2) + ) + : ''; + const buttonTextPath = showButton ? bentLinePath(geom, layout.btnU0, layout.btnU1, vBase) : ''; + + content = ( + e.preventDefault()} + onClick={handleSurfaceClick} + > + + + + + + + + + + + + {showIcon && ( + + )} + + + + {!display && placeholder && ( + + )} + {focused && ( + + + + + + )} + + + {showButton && ( + { + e.stopPropagation(); + handleSubmit(); + }} + onPointerDown={e => e.stopPropagation()} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSubmit(); + } + }} + > + + + + + {buttonText} + + + + )} + + + + ); + } + + return ( +
+ {content} + setFocused(true)} + onBlur={() => setFocused(false)} + aria-label={ariaLabel || placeholder || 'Curved input'} + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> +
+ ); +}; + +export default CurvedInput; diff --git a/src/ts-default/Components/CurvedInput/CurvedInput.css b/src/ts-default/Components/CurvedInput/CurvedInput.css index e69de29bb..b718016c1 100644 --- a/src/ts-default/Components/CurvedInput/CurvedInput.css +++ b/src/ts-default/Components/CurvedInput/CurvedInput.css @@ -0,0 +1,71 @@ +.curved-input { + position: relative; + display: block; + width: 100%; + max-width: 100%; + margin: 0; +} + +.curved-input__svg { + display: block; + overflow: visible; + width: 100%; + height: auto; + cursor: text; + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.curved-input__svg text { + font-family: inherit; +} + +.curved-input__ring { + opacity: 0; + transition: opacity 0.25s ease; +} + +.curved-input--focused .curved-input__ring { + opacity: 0.28; +} + +.curved-input__button { + cursor: pointer; + outline: none; +} + +.curved-input__button-bg { + transition: + filter 0.2s ease, + opacity 0.2s ease; +} + +.curved-input__button:hover .curved-input__button-bg { + filter: brightness(1.12); +} + +.curved-input__button:active .curved-input__button-bg { + filter: brightness(0.94); +} + +.curved-input__button:focus-visible .curved-input__button-bg { + filter: brightness(1.18); +} + +.curved-input__field { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + border: 0; + padding: 0; + margin: 0; + background: transparent; + color: transparent; + caret-color: transparent; + font-size: 16px; + pointer-events: none; + outline: none; +} diff --git a/src/ts-default/Components/CurvedInput/CurvedInput.tsx b/src/ts-default/Components/CurvedInput/CurvedInput.tsx index e69de29bb..cc9c5f1ed 100644 --- a/src/ts-default/Components/CurvedInput/CurvedInput.tsx +++ b/src/ts-default/Components/CurvedInput/CurvedInput.tsx @@ -0,0 +1,625 @@ +import { + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, + type ChangeEvent, + type FormEvent, + type KeyboardEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type SyntheticEvent +} from 'react'; +import './CurvedInput.css'; + +const DEG = 180 / Math.PI; + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +const hexToRgba = (hex: string, alpha: number): string => { + let h = String(hex).replace('#', ''); + if (h.length === 3) + h = h + .split('') + .map(c => c + c) + .join(''); + const n = parseInt(h.slice(0, 6), 16); + if (Number.isNaN(n)) return hex; + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; +}; + +type ShadowSize = 'sm' | 'md' | 'lg'; +type Theme = 'dark' | 'light'; + +const SHADOWS: Record = { + sm: [5, 12, 0.3], + md: [10, 24, 0.4], + lg: [16, 40, 0.52] +}; + +interface ThemePalette { + backgroundColor: string; + textColor: string; + placeholderColor: string; + borderColor: string; + buttonColor: string; + buttonTextColor: string; + shadowColor: string; +} + +const THEMES: Record = { + dark: { + backgroundColor: '#1B1722', + textColor: '#f5f5f5', + placeholderColor: '#a1a1aa', + borderColor: '#392e4e', + buttonColor: '#A855F7', + buttonTextColor: '#ffffff', + shadowColor: '#000000' + }, + light: { + backgroundColor: '#ffffff', + textColor: '#1d2050', + placeholderColor: '#9aa0b6', + borderColor: '#262a56', + buttonColor: '#4763eb', + buttonTextColor: '#ffffff', + shadowColor: '#0b0e2a' + } +}; + +interface Geometry { + straight: boolean; + W: number; + T: number; + svgH: number; + R?: number; + dir?: number; + uPerLen: number; + point: (u: number, v: number) => [number, number]; + angleAt: (u: number) => number; + uFromPoint: (x: number, y?: number) => number; +} + +// Maps the flat coordinate space (u: 0..W along the bar, v: offset from the +// centerline, positive down) onto a circular arc with the given sagitta +// (`bend`, in px). Positive bend arches up, negative sags down, 0 is flat. +const buildGeometry = (width: number, bend: number, thickness: number, pad: number): Geometry => { + const W = width; + const T = thickness; + const s = Math.max(-W * 0.35, Math.min(bend, W * 0.35)); + const a = Math.abs(s); + const dir = s >= 0 ? 1 : -1; + const svgH = T + a + pad * 2; + + if (a < 0.75) { + const midY = pad + T / 2; + return { + straight: true, + W, + T, + svgH, + uPerLen: 1, + point: (u, v) => [u, midY + v], + angleAt: () => 0, + uFromPoint: x => x + }; + } + + const R = (W * W * 0.25 + a * a) / (2 * a); + const cx = W / 2; + const apexY = pad + T / 2 + (dir > 0 ? 0 : a); + const cy = apexY + dir * R; + const phi = Math.asin(Math.min(1, W / (2 * R))); + + return { + straight: false, + W, + T, + svgH, + R, + dir, + uPerLen: W / (2 * R * phi), + point: (u, v) => { + const th = ((u - cx) / cx) * phi; + const rho = R - dir * v; + return [cx + rho * Math.sin(th), cy - dir * rho * Math.cos(th)]; + }, + angleAt: u => dir * ((u - cx) / cx) * phi * DEG, + uFromPoint: (x, y = 0) => { + const th = Math.atan2(x - cx, dir * (cy - y)); + return cx + (th / phi) * cx; + } + }; +}; + +const fmt = (g: Geometry, u: number, v: number): string => { + const [x, y] = g.point(u, v); + return `${round2(x)} ${round2(y)}`; +}; + +// Segment along a constant-v edge, as a circular arc (or a line when flat) +const edgeSeg = (g: Geometry, uTo: number, v: number, ltr: boolean): string => { + if (g.straight) return `L ${fmt(g, uTo, v)}`; + const rho = round2(g.R! - g.dir! * v); + const sweep = ltr === g.dir! > 0 ? 1 : 0; + return `A ${rho} ${rho} 0 0 ${sweep} ${fmt(g, uTo, v)}`; +}; + +// A rectangle bent along the arc: circular top/bottom edges, radial end caps +// and quadratic rounded corners. +const bentRectPath = (g: Geometry, u0: number, u1: number, vTop: number, vBot: number, radius: number): string => { + const rc = Math.max(0, Math.min(radius, (vBot - vTop) / 2, (u1 - u0) / 2)); + return [ + `M ${fmt(g, u0 + rc, vTop)}`, + edgeSeg(g, u1 - rc, vTop, true), + `Q ${fmt(g, u1, vTop)} ${fmt(g, u1, vTop + rc)}`, + `L ${fmt(g, u1, vBot - rc)}`, + `Q ${fmt(g, u1, vBot)} ${fmt(g, u1 - rc, vBot)}`, + edgeSeg(g, u0 + rc, vBot, false), + `Q ${fmt(g, u0, vBot)} ${fmt(g, u0, vBot - rc)}`, + `L ${fmt(g, u0, vTop + rc)}`, + `Q ${fmt(g, u0, vTop)} ${fmt(g, u0 + rc, vTop)}`, + 'Z' + ].join(' '); +}; + +const bentLinePath = (g: Geometry, u0: number, u1: number, v: number): string => + `M ${fmt(g, u0, v)} ${edgeSeg(g, u1, v, true)}`; + +const SELECTABLE_TYPES = ['text', 'search', 'tel', 'url', 'password']; + +interface CurvedInputProps { + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + onSubmit?: (value: string) => void; + placeholder?: string; + buttonText?: string; + type?: string; + name?: string; + ariaLabel?: string; + theme?: Theme; + width?: number | string; + bend?: number; + height?: number; + cornerRadius?: number; + borderWidth?: number; + fontSize?: number; + backgroundColor?: string; + textColor?: string; + placeholderColor?: string; + borderColor?: string; + buttonColor?: string; + buttonTextColor?: string; + iconColor?: string; + shadowSize?: ShadowSize; + shadowColor?: string; + showButton?: boolean; + showIcon?: boolean; + icon?: ReactNode; + className?: string; + style?: CSSProperties; +} + +const CurvedInput = ({ + value, + defaultValue = '', + onChange, + onSubmit, + placeholder = 'Enter your email', + buttonText = 'Get Started', + type = 'email', + name, + ariaLabel, + theme = 'dark', + width = 450, + bend = 28, + height = 64, + cornerRadius = 18, + borderWidth = 1.5, + fontSize = 16, + backgroundColor, + textColor, + placeholderColor, + borderColor, + buttonColor, + buttonTextColor, + iconColor, + shadowSize = 'md', + shadowColor, + showButton = true, + showIcon = true, + icon, + className = '', + style +}: CurvedInputProps) => { + const uid = useId().replace(/:/g, ''); + const layoutPathId = `ci-text-${uid}`; + const buttonPathId = `ci-btn-${uid}`; + const clipId = `ci-clip-${uid}`; + + const rootRef = useRef(null); + const svgRef = useRef(null); + const inputRef = useRef(null); + const textRef = useRef(null); + const btnMeasureRef = useRef(null); + const scrollRef = useRef(0); + + const [w, setW] = useState(0); + const [innerValue, setInnerValue] = useState(defaultValue); + const [caretIndex, setCaretIndex] = useState(defaultValue.length); + const [focused, setFocused] = useState(false); + const [caretU, setCaretU] = useState(0); + const [scrollLen, setScrollLen] = useState(0); + const [btnTextW, setBtnTextW] = useState(0); + const [, setFontTick] = useState(0); + + const val = value !== undefined ? value : innerValue; + const display = type === 'password' ? '•'.repeat(val.length) : val; + + const palette = THEMES[theme] || THEMES.dark; + const bgColor = backgroundColor ?? palette.backgroundColor; + const fgColor = textColor ?? palette.textColor; + const phColor = placeholderColor ?? palette.placeholderColor; + const strokeColor = borderColor ?? palette.borderColor; + const accentColor = buttonColor ?? palette.buttonColor; + const btnFgColor = buttonTextColor ?? palette.buttonTextColor; + const shColor = shadowColor ?? palette.shadowColor; + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const ro = new ResizeObserver(entries => { + const cw = entries[0]?.contentRect?.width ?? el.clientWidth; + setW(Math.round(cw)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Re-measure once webfonts finish loading + useEffect(() => { + let alive = true; + if (document.fonts?.ready) { + document.fonts.ready.then(() => { + if (alive) setFontTick(t => t + 1); + }); + } + return () => { + alive = false; + }; + }, []); + + const pad = Math.ceil(borderWidth / 2) + 6; + const geom = useMemo( + () => (w > 2 ? buildGeometry(w, bend, height, pad) : null), + [w, bend, height, pad] + ); + + const layout = useMemo(() => { + if (!geom) return null; + const T = height; + const btnInset = Math.max(5, borderWidth + 4); + const chipH = Math.min(34, Math.max(16, T * 0.34)); + const chipW = chipH * 1.25; + const iconU = 22 + chipW / 2; + const textStartU = showIcon ? 22 + chipW + 13 : 24; + const btnW = showButton ? Math.max(btnTextW + fontSize * 2.7, T * 1.35) : 0; + const btnU1 = geom.W - btnInset; + const btnU0 = btnU1 - btnW; + const textEndU = Math.max(textStartU + 20, showButton ? btnU0 - 14 : geom.W - 24); + const winLen = (textEndU - textStartU) / geom.uPerLen; + return { btnInset, chipH, chipW, iconU, textStartU, textEndU, btnU0, btnU1, winLen }; + }, [geom, height, borderWidth, btnTextW, fontSize, showIcon, showButton]); + + // Measure rendered text to keep the caret on the curve and scroll long + // values along the arc, exactly like a native input would. + useLayoutEffect(() => { + if (btnMeasureRef.current) { + const bw = btnMeasureRef.current.getComputedTextLength(); + setBtnTextW(prev => (Math.abs(prev - bw) > 0.5 ? bw : prev)); + } + if (!geom || !layout) return; + const textEl = textRef.current; + const caret = Math.min(caretIndex, display.length); + let caretLen = 0; + let totalLen = 0; + if (textEl && display.length) { + try { + totalLen = textEl.getSubStringLength(0, display.length); + caretLen = caret > 0 ? textEl.getSubStringLength(0, caret) : 0; + } catch { + totalLen = 0; + caretLen = 0; + } + } + let next = scrollRef.current; + if (caretLen - next > layout.winLen - 2) next = caretLen - layout.winLen + 2; + if (caretLen - next < 0) next = caretLen; + if (totalLen - next < layout.winLen) next = Math.max(0, totalLen - layout.winLen); + next = Math.max(0, next); + if (Math.abs(next - scrollRef.current) > 0.5) { + scrollRef.current = next; + setScrollLen(next); + } + setCaretU(layout.textStartU + (caretLen - next) * geom.uPerLen); + }); + + const commitValue = (v: string) => { + if (value === undefined) setInnerValue(v); + onChange?.(v); + }; + + const handleInputChange = (e: ChangeEvent) => { + commitValue(e.target.value); + setCaretIndex(e.target.selectionStart ?? e.target.value.length); + }; + + const handleSelect = (e: SyntheticEvent) => { + const target = e.currentTarget; + setCaretIndex(target.selectionStart ?? target.value.length); + }; + + const handleSubmit = (e?: FormEvent) => { + if (e?.preventDefault) e.preventDefault(); + if (onSubmit) onSubmit(val); + }; + + // Click on the curve: focus the hidden input and drop the caret on the + // character closest to the click, measured in arc length. + const handleSurfaceClick = (e: ReactMouseEvent) => { + const input = inputRef.current; + if (!input) return; + let idx = display.length; + const svg = svgRef.current; + const textEl = textRef.current; + if (svg && geom && layout && textEl && display.length) { + try { + const ctm = svg.getScreenCTM(); + if (!ctm) throw new Error('missing screen CTM'); + const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse()); + const target = scrollRef.current + (geom.uFromPoint(pt.x, pt.y) - layout.textStartU) / geom.uPerLen; + let best = 0; + let bestDist = Infinity; + for (let i = 0; i <= display.length; i++) { + const li = i === 0 ? 0 : textEl.getSubStringLength(0, i); + const d = Math.abs(li - target); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + idx = best; + } catch { + idx = display.length; + } + } + input.focus(); + try { + input.setSelectionRange(idx, idx); + } catch { + /* selection API unavailable for this input type */ + } + setCaretIndex(idx); + }; + + const safeType = SELECTABLE_TYPES.includes(type) ? type : 'text'; + const inputMode = type === 'email' ? 'email' : type === 'number' ? 'decimal' : undefined; + + const shadow = SHADOWS[shadowSize]; + const svgStyle: CSSProperties | undefined = shadow + ? { filter: `drop-shadow(0 ${shadow[0]}px ${shadow[1]}px ${hexToRgba(shColor, shadow[2])})` } + : undefined; + + let content: ReactNode = null; + if (geom && layout) { + const T = height; + const vBase = fontSize * 0.34; + const scrollU = scrollLen * geom.uPerLen; + const bandPath = bentRectPath(geom, 0, geom.W, -T / 2, T / 2, cornerRadius); + const layoutPath = bentLinePath(geom, layout.textStartU - scrollU, geom.W, vBase); + const clipPath = bentRectPath(geom, layout.textStartU - 6, layout.textEndU + 8, -T / 2, T / 2, 0); + + const chipFill = iconColor || accentColor; + const { chipW, chipH } = layout; + const ew = chipW * 0.5; + const eh = chipH * 0.5; + const sw = Math.max(1.1, chipH * 0.075); + const [ix, iy] = geom.point(layout.iconU, 0); + const iconAngle = geom.angleAt(layout.iconU); + + const [caretX, caretY] = geom.point(caretU, 0); + const caretAngle = geom.angleAt(caretU); + const caretH = Math.min(T * 0.58, fontSize * 1.45); + + const btnH = T - layout.btnInset * 2; + const buttonPath = showButton + ? bentRectPath( + geom, + layout.btnU0, + layout.btnU1, + -T / 2 + layout.btnInset, + T / 2 - layout.btnInset, + Math.min(cornerRadius * 0.72, btnH / 2) + ) + : ''; + const buttonTextPath = showButton ? bentLinePath(geom, layout.btnU0, layout.btnU1, vBase) : ''; + + content = ( + e.preventDefault()} + onClick={handleSurfaceClick} + > + + + + + + + + + + + + {showIcon && ( + + )} + + + + {!display && placeholder && ( + + )} + {focused && ( + + + + + + )} + + + {showButton && ( + { + e.stopPropagation(); + handleSubmit(); + }} + onPointerDown={(e: ReactPointerEvent) => e.stopPropagation()} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSubmit(); + } + }} + > + + + + + {buttonText} + + + + )} + + + + ); + } + + return ( +
+ {content} + setFocused(true)} + onBlur={() => setFocused(false)} + aria-label={ariaLabel || placeholder || 'Curved input'} + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> +
+ ); +}; + +export default CurvedInput; diff --git a/src/ts-tailwind/Components/CurvedInput/CurvedInput.tsx b/src/ts-tailwind/Components/CurvedInput/CurvedInput.tsx index e69de29bb..de53ae4c3 100644 --- a/src/ts-tailwind/Components/CurvedInput/CurvedInput.tsx +++ b/src/ts-tailwind/Components/CurvedInput/CurvedInput.tsx @@ -0,0 +1,628 @@ +import { + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, + type ChangeEvent, + type FormEvent, + type KeyboardEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type SyntheticEvent +} from 'react'; + +const DEG = 180 / Math.PI; + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +const hexToRgba = (hex: string, alpha: number): string => { + let h = String(hex).replace('#', ''); + if (h.length === 3) + h = h + .split('') + .map(c => c + c) + .join(''); + const n = parseInt(h.slice(0, 6), 16); + if (Number.isNaN(n)) return hex; + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; +}; + +type ShadowSize = 'sm' | 'md' | 'lg'; +type Theme = 'dark' | 'light'; + +const SHADOWS: Record = { + sm: [5, 12, 0.3], + md: [10, 24, 0.4], + lg: [16, 40, 0.52] +}; + +interface ThemePalette { + backgroundColor: string; + textColor: string; + placeholderColor: string; + borderColor: string; + buttonColor: string; + buttonTextColor: string; + shadowColor: string; +} + +const THEMES: Record = { + dark: { + backgroundColor: '#1B1722', + textColor: '#f5f5f5', + placeholderColor: '#a1a1aa', + borderColor: '#392e4e', + buttonColor: '#A855F7', + buttonTextColor: '#ffffff', + shadowColor: '#000000' + }, + light: { + backgroundColor: '#ffffff', + textColor: '#1d2050', + placeholderColor: '#9aa0b6', + borderColor: '#262a56', + buttonColor: '#4763eb', + buttonTextColor: '#ffffff', + shadowColor: '#0b0e2a' + } +}; + +interface Geometry { + straight: boolean; + W: number; + T: number; + svgH: number; + R?: number; + dir?: number; + uPerLen: number; + point: (u: number, v: number) => [number, number]; + angleAt: (u: number) => number; + uFromPoint: (x: number, y?: number) => number; +} + +// Maps the flat coordinate space (u: 0..W along the bar, v: offset from the +// centerline, positive down) onto a circular arc with the given sagitta +// (`bend`, in px). Positive bend arches up, negative sags down, 0 is flat. +const buildGeometry = (width: number, bend: number, thickness: number, pad: number): Geometry => { + const W = width; + const T = thickness; + const s = Math.max(-W * 0.35, Math.min(bend, W * 0.35)); + const a = Math.abs(s); + const dir = s >= 0 ? 1 : -1; + const svgH = T + a + pad * 2; + + if (a < 0.75) { + const midY = pad + T / 2; + return { + straight: true, + W, + T, + svgH, + uPerLen: 1, + point: (u, v) => [u, midY + v], + angleAt: () => 0, + uFromPoint: x => x + }; + } + + const R = (W * W * 0.25 + a * a) / (2 * a); + const cx = W / 2; + const apexY = pad + T / 2 + (dir > 0 ? 0 : a); + const cy = apexY + dir * R; + const phi = Math.asin(Math.min(1, W / (2 * R))); + + return { + straight: false, + W, + T, + svgH, + R, + dir, + uPerLen: W / (2 * R * phi), + point: (u, v) => { + const th = ((u - cx) / cx) * phi; + const rho = R - dir * v; + return [cx + rho * Math.sin(th), cy - dir * rho * Math.cos(th)]; + }, + angleAt: u => dir * ((u - cx) / cx) * phi * DEG, + uFromPoint: (x, y = 0) => { + const th = Math.atan2(x - cx, dir * (cy - y)); + return cx + (th / phi) * cx; + } + }; +}; + +const fmt = (g: Geometry, u: number, v: number): string => { + const [x, y] = g.point(u, v); + return `${round2(x)} ${round2(y)}`; +}; + +// Segment along a constant-v edge, as a circular arc (or a line when flat) +const edgeSeg = (g: Geometry, uTo: number, v: number, ltr: boolean): string => { + if (g.straight) return `L ${fmt(g, uTo, v)}`; + const rho = round2(g.R! - g.dir! * v); + const sweep = ltr === g.dir! > 0 ? 1 : 0; + return `A ${rho} ${rho} 0 0 ${sweep} ${fmt(g, uTo, v)}`; +}; + +// A rectangle bent along the arc: circular top/bottom edges, radial end caps +// and quadratic rounded corners. +const bentRectPath = (g: Geometry, u0: number, u1: number, vTop: number, vBot: number, radius: number): string => { + const rc = Math.max(0, Math.min(radius, (vBot - vTop) / 2, (u1 - u0) / 2)); + return [ + `M ${fmt(g, u0 + rc, vTop)}`, + edgeSeg(g, u1 - rc, vTop, true), + `Q ${fmt(g, u1, vTop)} ${fmt(g, u1, vTop + rc)}`, + `L ${fmt(g, u1, vBot - rc)}`, + `Q ${fmt(g, u1, vBot)} ${fmt(g, u1 - rc, vBot)}`, + edgeSeg(g, u0 + rc, vBot, false), + `Q ${fmt(g, u0, vBot)} ${fmt(g, u0, vBot - rc)}`, + `L ${fmt(g, u0, vTop + rc)}`, + `Q ${fmt(g, u0, vTop)} ${fmt(g, u0 + rc, vTop)}`, + 'Z' + ].join(' '); +}; + +const bentLinePath = (g: Geometry, u0: number, u1: number, v: number): string => + `M ${fmt(g, u0, v)} ${edgeSeg(g, u1, v, true)}`; + +const SELECTABLE_TYPES = ['text', 'search', 'tel', 'url', 'password']; + +interface CurvedInputProps { + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + onSubmit?: (value: string) => void; + placeholder?: string; + buttonText?: string; + type?: string; + name?: string; + ariaLabel?: string; + theme?: Theme; + width?: number | string; + bend?: number; + height?: number; + cornerRadius?: number; + borderWidth?: number; + fontSize?: number; + backgroundColor?: string; + textColor?: string; + placeholderColor?: string; + borderColor?: string; + buttonColor?: string; + buttonTextColor?: string; + iconColor?: string; + shadowSize?: ShadowSize; + shadowColor?: string; + showButton?: boolean; + showIcon?: boolean; + icon?: ReactNode; + className?: string; + style?: CSSProperties; +} + +const CurvedInput = ({ + value, + defaultValue = '', + onChange, + onSubmit, + placeholder = 'Enter your email', + buttonText = 'Get Started', + type = 'email', + name, + ariaLabel, + theme = 'dark', + width = 450, + bend = 28, + height = 64, + cornerRadius = 18, + borderWidth = 1.5, + fontSize = 16, + backgroundColor, + textColor, + placeholderColor, + borderColor, + buttonColor, + buttonTextColor, + iconColor, + shadowSize = 'md', + shadowColor, + showButton = true, + showIcon = true, + icon, + className = '', + style +}: CurvedInputProps) => { + const uid = useId().replace(/:/g, ''); + const layoutPathId = `ci-text-${uid}`; + const buttonPathId = `ci-btn-${uid}`; + const clipId = `ci-clip-${uid}`; + + const rootRef = useRef(null); + const svgRef = useRef(null); + const inputRef = useRef(null); + const textRef = useRef(null); + const btnMeasureRef = useRef(null); + const scrollRef = useRef(0); + + const [w, setW] = useState(0); + const [innerValue, setInnerValue] = useState(defaultValue); + const [caretIndex, setCaretIndex] = useState(defaultValue.length); + const [focused, setFocused] = useState(false); + const [caretU, setCaretU] = useState(0); + const [scrollLen, setScrollLen] = useState(0); + const [btnTextW, setBtnTextW] = useState(0); + const [, setFontTick] = useState(0); + + const val = value !== undefined ? value : innerValue; + const display = type === 'password' ? '•'.repeat(val.length) : val; + + const palette = THEMES[theme] || THEMES.dark; + const bgColor = backgroundColor ?? palette.backgroundColor; + const fgColor = textColor ?? palette.textColor; + const phColor = placeholderColor ?? palette.placeholderColor; + const strokeColor = borderColor ?? palette.borderColor; + const accentColor = buttonColor ?? palette.buttonColor; + const btnFgColor = buttonTextColor ?? palette.buttonTextColor; + const shColor = shadowColor ?? palette.shadowColor; + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const ro = new ResizeObserver(entries => { + const cw = entries[0]?.contentRect?.width ?? el.clientWidth; + setW(Math.round(cw)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Re-measure once webfonts finish loading + useEffect(() => { + let alive = true; + if (document.fonts?.ready) { + document.fonts.ready.then(() => { + if (alive) setFontTick(t => t + 1); + }); + } + return () => { + alive = false; + }; + }, []); + + const pad = Math.ceil(borderWidth / 2) + 6; + const geom = useMemo( + () => (w > 2 ? buildGeometry(w, bend, height, pad) : null), + [w, bend, height, pad] + ); + + const layout = useMemo(() => { + if (!geom) return null; + const T = height; + const btnInset = Math.max(5, borderWidth + 4); + const chipH = Math.min(34, Math.max(16, T * 0.34)); + const chipW = chipH * 1.25; + const iconU = 22 + chipW / 2; + const textStartU = showIcon ? 22 + chipW + 13 : 24; + const btnW = showButton ? Math.max(btnTextW + fontSize * 2.7, T * 1.35) : 0; + const btnU1 = geom.W - btnInset; + const btnU0 = btnU1 - btnW; + const textEndU = Math.max(textStartU + 20, showButton ? btnU0 - 14 : geom.W - 24); + const winLen = (textEndU - textStartU) / geom.uPerLen; + return { btnInset, chipH, chipW, iconU, textStartU, textEndU, btnU0, btnU1, winLen }; + }, [geom, height, borderWidth, btnTextW, fontSize, showIcon, showButton]); + + // Measure rendered text to keep the caret on the curve and scroll long + // values along the arc, exactly like a native input would. + useLayoutEffect(() => { + if (btnMeasureRef.current) { + const bw = btnMeasureRef.current.getComputedTextLength(); + setBtnTextW(prev => (Math.abs(prev - bw) > 0.5 ? bw : prev)); + } + if (!geom || !layout) return; + const textEl = textRef.current; + const caret = Math.min(caretIndex, display.length); + let caretLen = 0; + let totalLen = 0; + if (textEl && display.length) { + try { + totalLen = textEl.getSubStringLength(0, display.length); + caretLen = caret > 0 ? textEl.getSubStringLength(0, caret) : 0; + } catch { + totalLen = 0; + caretLen = 0; + } + } + let next = scrollRef.current; + if (caretLen - next > layout.winLen - 2) next = caretLen - layout.winLen + 2; + if (caretLen - next < 0) next = caretLen; + if (totalLen - next < layout.winLen) next = Math.max(0, totalLen - layout.winLen); + next = Math.max(0, next); + if (Math.abs(next - scrollRef.current) > 0.5) { + scrollRef.current = next; + setScrollLen(next); + } + setCaretU(layout.textStartU + (caretLen - next) * geom.uPerLen); + }); + + const commitValue = (v: string) => { + if (value === undefined) setInnerValue(v); + onChange?.(v); + }; + + const handleInputChange = (e: ChangeEvent) => { + commitValue(e.target.value); + setCaretIndex(e.target.selectionStart ?? e.target.value.length); + }; + + const handleSelect = (e: SyntheticEvent) => { + const target = e.currentTarget; + setCaretIndex(target.selectionStart ?? target.value.length); + }; + + const handleSubmit = (e?: FormEvent) => { + if (e?.preventDefault) e.preventDefault(); + if (onSubmit) onSubmit(val); + }; + + // Click on the curve: focus the hidden input and drop the caret on the + // character closest to the click, measured in arc length. + const handleSurfaceClick = (e: ReactMouseEvent) => { + const input = inputRef.current; + if (!input) return; + let idx = display.length; + const svg = svgRef.current; + const textEl = textRef.current; + if (svg && geom && layout && textEl && display.length) { + try { + const ctm = svg.getScreenCTM(); + if (!ctm) throw new Error('missing screen CTM'); + const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse()); + const target = scrollRef.current + (geom.uFromPoint(pt.x, pt.y) - layout.textStartU) / geom.uPerLen; + let best = 0; + let bestDist = Infinity; + for (let i = 0; i <= display.length; i++) { + const li = i === 0 ? 0 : textEl.getSubStringLength(0, i); + const d = Math.abs(li - target); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + idx = best; + } catch { + idx = display.length; + } + } + input.focus(); + try { + input.setSelectionRange(idx, idx); + } catch { + /* selection API unavailable for this input type */ + } + setCaretIndex(idx); + }; + + const safeType = SELECTABLE_TYPES.includes(type) ? type : 'text'; + const inputMode = type === 'email' ? 'email' : type === 'number' ? 'decimal' : undefined; + + const shadow = SHADOWS[shadowSize]; + const svgStyle: CSSProperties | undefined = shadow + ? { filter: `drop-shadow(0 ${shadow[0]}px ${shadow[1]}px ${hexToRgba(shColor, shadow[2])})` } + : undefined; + + let content: ReactNode = null; + if (geom && layout) { + const T = height; + const vBase = fontSize * 0.34; + const scrollU = scrollLen * geom.uPerLen; + const bandPath = bentRectPath(geom, 0, geom.W, -T / 2, T / 2, cornerRadius); + const layoutPath = bentLinePath(geom, layout.textStartU - scrollU, geom.W, vBase); + const clipPath = bentRectPath(geom, layout.textStartU - 6, layout.textEndU + 8, -T / 2, T / 2, 0); + + const chipFill = iconColor || accentColor; + const { chipW, chipH } = layout; + const ew = chipW * 0.5; + const eh = chipH * 0.5; + const sw = Math.max(1.1, chipH * 0.075); + const [ix, iy] = geom.point(layout.iconU, 0); + const iconAngle = geom.angleAt(layout.iconU); + + const [caretX, caretY] = geom.point(caretU, 0); + const caretAngle = geom.angleAt(caretU); + const caretH = Math.min(T * 0.58, fontSize * 1.45); + + const btnH = T - layout.btnInset * 2; + const buttonPath = showButton + ? bentRectPath( + geom, + layout.btnU0, + layout.btnU1, + -T / 2 + layout.btnInset, + T / 2 - layout.btnInset, + Math.min(cornerRadius * 0.72, btnH / 2) + ) + : ''; + const buttonTextPath = showButton ? bentLinePath(geom, layout.btnU0, layout.btnU1, vBase) : ''; + + content = ( + e.preventDefault()} + onClick={handleSurfaceClick} + > + + + + + + + + + + + + {showIcon && ( + + )} + + + + {!display && placeholder && ( + + )} + {focused && ( + + + + + + )} + + + {showButton && ( + { + e.stopPropagation(); + handleSubmit(); + }} + onPointerDown={(e: ReactPointerEvent) => e.stopPropagation()} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSubmit(); + } + }} + > + + + + + {buttonText} + + + + )} + + + + ); + } + + return ( +
+ {content} + setFocused(true)} + onBlur={() => setFocused(false)} + aria-label={ariaLabel || placeholder || 'Curved input'} + autoComplete="off" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> +
+ ); +}; + +export default CurvedInput;