UNPKG

@corvu/otp-field

Version:

Unstyled, accessible and customizable UI primitives for SolidJS

533 lines (527 loc) 16.6 kB
// src/context.ts import { createContext, useContext } from "solid-js"; import { createKeyedContext, useKeyedContext } from "@corvu/utils/create/keyedContext"; var OtpFieldContext = createContext(); var createOtpFieldContext = (contextId) => { if (contextId === void 0) return OtpFieldContext; const context = createKeyedContext( `otp-field-${contextId}` ); return context; }; var useOtpFieldContext = (contextId) => { if (contextId === void 0) { const context2 = useContext(OtpFieldContext); if (!context2) { throw new Error( "[corvu]: OTP Field context not found. Make sure to wrap OTP Field components in <OtpField.Root>" ); } return context2; } const context = useKeyedContext( `otp-field-${contextId}` ); if (!context) { throw new Error( `[corvu]: OTP Field context with id "${contextId}" not found. Make sure to wrap OTP Field components in <OtpField.Root contextId="${contextId}">` ); } return context; }; var InternalOtpFieldContext = createContext(); var createInternalOtpFieldContext = (contextId) => { if (contextId === void 0) return InternalOtpFieldContext; const context = createKeyedContext( `otp-field-internal-${contextId}` ); return context; }; var useInternalOtpFieldContext = (contextId) => { if (contextId === void 0) { const context2 = useContext(InternalOtpFieldContext); if (!context2) { throw new Error( "[corvu]: OTP Field context not found. Make sure to wrap OTP Field components in <OtpField.Root>" ); } return context2; } const context = useKeyedContext( `otp-field-internal-${contextId}` ); if (!context) { throw new Error( `[corvu]: OTP Field context with id "${contextId}" not found. Make sure to wrap OTP Field components in <OtpField.Root contextId="${contextId}">` ); } return context; }; // src/Input.tsx import { afterPaint, callEventHandler, combineStyle } from "@corvu/utils/dom"; import { createEffect, createMemo, createSignal, mergeProps, onCleanup as onCleanup2, Show, splitProps } from "solid-js"; import { Dynamic } from "@corvu/utils/dynamic"; // src/lib/style.ts import { onCleanup } from "solid-js"; var otpFieldStyleElement = null; var activeCount = 0; var createOtpFieldStyleElement = () => { activeCount += 1; if (otpFieldStyleElement) return; otpFieldStyleElement = document.createElement("style"); document.head.appendChild(otpFieldStyleElement); const autofillStyle = "background: transparent !important; color: transparent !important; border-color: transparent !important; opacity: 0 !important; box-shadow: none !important; -webkit-box-shadow: none !important; -webkit-text-fill-color: transparent !important;"; const styleString = ` [data-corvu-otp-field-input]::selection { background: transparent !important; color: transparent !important; }'; [data-corvu-otp-field-input]:autofill { ${autofillStyle} }; [data-corvu-otp-field-input]:-webkit-autofill { ${autofillStyle} }; @supports (-webkit-touch-callout: none) { [data-corvu-otp-field-input] { letter-spacing: -.6em !important; font-weight: 100 !important; font-stretch: ultra-condensed; font-optical-sizing: none !important; left: -1px !important; right: 1px !important; } }; [data-corvu-otp-field-input] + * { pointer-events: all !important; }; `; otpFieldStyleElement.innerHTML = styleString; onCleanup(() => { activeCount -= 1; if (activeCount === 0 && otpFieldStyleElement) { otpFieldStyleElement.remove(); otpFieldStyleElement = null; } }); }; var style_default = createOtpFieldStyleElement; // src/Input.tsx import { isServer } from "solid-js/web"; import { mergeRefs } from "@corvu/utils/reactivity"; var OtpFieldInput = (props) => { const defaultedProps = mergeProps( { pattern: "^\\d*$", noScriptCSSFallback: DEFAULT_NOSCRIPT_CSS_FALLBACK }, props ); const [localProps, otherProps] = splitProps(defaultedProps, [ "pattern", "noScriptCSSFallback", "ref", "onInput", "onFocus", "onBlur", "onMouseOver", "onMouseLeave", "onKeyDown", "onKeyUp", "autocomplete", "disabled", "spellcheck", "style", "contextId" ]); const previousSelection = { inserting: false, start: null, end: null }; let shiftKeyDown = false; const [ref, setRef] = createSignal(null); const context = createMemo( () => useInternalOtpFieldContext(localProps.contextId) ); createEffect(() => { style_default(); const onSelectionChangeWrapper = () => onSelectionChange(); document.addEventListener("selectionchange", onSelectionChangeWrapper); onCleanup2(() => { document.removeEventListener("selectionchange", onSelectionChangeWrapper); }); }); createEffect(() => { const element = ref(); if (!element) return; const form = element.form; if (!form) return; const onReset = () => { afterPaint(() => { context().setValue(element.value); }); }; form.addEventListener("reset", onReset); onCleanup2(() => { form.removeEventListener("reset", onReset); }); }); createEffect(() => { const element = ref(); if (!element) return; element.value = context().value(); }); const patternRegex = createMemo( () => localProps.pattern !== null ? new RegExp(localProps.pattern) : void 0 ); const onInput = (event) => { if (callEventHandler(localProps.onInput, event)) return; const rawValue = event.currentTarget.value; let finalValue = rawValue; const contextValue = context().value(); const selectionSize = Math.abs( (previousSelection.start ?? 0) - (previousSelection.end ?? 0) ); const regex = patternRegex(); if ((previousSelection.inserting || selectionSize === contextValue.length) && regex) { finalValue = finalValue.replace(new RegExp(`[^${regex.source}]`, "g"), ""); } finalValue = finalValue.slice(0, context().maxLength()); const hasInvalidChars = !!regex && !regex.test(finalValue); if (rawValue.length !== 0 && finalValue.length === 0 || finalValue === contextValue || hasInvalidChars) { event.preventDefault(); event.currentTarget.value = contextValue; if (hasInvalidChars) { event.currentTarget.setSelectionRange( previousSelection.start ?? 0, previousSelection.end ?? 0 ); } return; } if (finalValue.length < contextValue.length) { onSelectionChange(event.inputType); } context().setValue(finalValue); }; const onFocus = (event) => { if (callEventHandler(localProps.onFocus, event)) return; event.currentTarget.setSelectionRange( context().value().length, context().value().length ); context().setIsFocused(true); onSelectionChange(); }; const onBlur = (event) => { if (callEventHandler(localProps.onBlur, event)) return; shiftKeyDown = false; context().setIsFocused(false); onSelectionChange(); }; const onMouseOver = (event) => { !callEventHandler(localProps.onMouseOver, event) && localProps.disabled !== true && context().setIsHovered(true); }; const onMouseLeave = (event) => { !callEventHandler(localProps.onMouseLeave, event) && context().setIsHovered(false); }; const onKeyDown = (event) => { if (callEventHandler(localProps.onKeyDown, event)) return; if (event.key !== "Shift") return; shiftKeyDown = true; }; const onKeyUp = (event) => { if (callEventHandler(localProps.onKeyUp, event)) return; if (event.key !== "Shift") return; shiftKeyDown = false; }; const onSelectionChange = (inputType) => { const element = ref(); if (!element) return; if (context().isFocused() === false || document.activeElement !== element || element.selectionStart === null || element.selectionEnd === null) { syncSelection({ start: null, end: null, inserting: false, originalStart: element.selectionStart, originalEnd: element.selectionEnd }); context().setIsInserting(false); return; } const maxLength = context().maxLength(); const inserting = element.value.length < maxLength && element.selectionStart === element.value.length; context().setIsInserting(inserting); if (inserting || element.selectionStart !== element.selectionEnd) { syncSelection({ start: element.selectionStart, end: inserting ? element.selectionEnd + 1 : element.selectionEnd, inserting, originalStart: element.selectionStart, originalEnd: element.selectionEnd }); return; } let selectionStart = 0; let selectionEnd = 0; let direction = void 0; if (element.selectionStart === 0) { selectionStart = 0; selectionEnd = 1; direction = "forward"; } else if (element.selectionStart === maxLength) { selectionStart = maxLength - 1; selectionEnd = maxLength; direction = "backward"; } else { let startOffset = 0; let endOffset = 1; if (previousSelection.start !== null && previousSelection.end !== null) { const navigatedBackwards = element.selectionStart < previousSelection.end && Math.abs(previousSelection.start - previousSelection.end) === 1; direction = navigatedBackwards ? "backward" : "forward"; if (navigatedBackwards && !previousSelection.inserting && inputType !== "deleteContentForward" || !navigatedBackwards && shiftKeyDown) { startOffset += -1; } } if (shiftKeyDown && inputType === void 0) { endOffset += 1; } selectionStart = element.selectionStart + startOffset; selectionEnd = element.selectionEnd + startOffset + endOffset; } element.setSelectionRange(selectionStart, selectionEnd, direction); syncSelection({ start: selectionStart, end: selectionEnd, inserting, originalStart: element.selectionStart, originalEnd: element.selectionEnd }); }; const syncSelection = (props2) => { previousSelection.inserting = props2.inserting; previousSelection.start = props2.originalStart; previousSelection.end = props2.originalEnd; const start = props2.start; const end = props2.end; if (start === null || end === null) { context().setActiveSlots([]); return; } const indexes = Array.from({ length: end - start }, (_, i) => start + i); context().setActiveSlots(indexes); }; return <> <Show when={localProps.noScriptCSSFallback !== null && isServer}> <noscript> <style>{localProps.noScriptCSSFallback}</style> </noscript> </Show> <Dynamic as="input" ref={mergeRefs(setRef, localProps.ref)} onInput={onInput} onFocus={onFocus} onBlur={onBlur} onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} onKeyDown={onKeyDown} onKeyUp={onKeyUp} inputMode="numeric" autocomplete={localProps.autocomplete ?? "one-time-code"} disabled={localProps.disabled} spellcheck={localProps.spellcheck ?? false} style={combineStyle( { display: "flex", position: "absolute", inset: 0, width: context().shiftPWManagers() ? "calc(100% + 40px)" : "100%", "clip-path": context().shiftPWManagers() ? "inset(0 40px 0 0)" : void 0, height: "100%", padding: 0, color: "transparent", background: "transparent", "caret-color": "transparent", border: "0 solid transparent", outline: "0 solid transparent", "box-shadow": "none", "line-height": "1", "letter-spacing": "-1em", "font-family": "monospace", "font-variant-numeric": "tabular-nums", "font-size": `${context().rootHeight()}px`, "pointer-events": "all" }, localProps.style )} pattern={patternRegex()?.source} data-corvu-otp-field-input="" {...otherProps} /> </>; }; var DEFAULT_NOSCRIPT_CSS_FALLBACK = ` [data-corvu-otp-field-input] { color: black !important; background-color: white !important; caret-color: black !important; letter-spacing: inherit !important; text-align: center !important; border: 1px solid black !important; width: 100% !important; font-size: inherit !important; clip-path: none !important; } `; var Input_default = OtpFieldInput; // src/Root.tsx import { createEffect as createEffect2, createMemo as createMemo2, createSignal as createSignal2, mergeProps as mergeProps2, splitProps as splitProps2, untrack } from "solid-js"; import { Dynamic as Dynamic2 } from "@corvu/utils/dynamic"; import { combineStyle as combineStyle2 } from "@corvu/utils/dom"; import createControllableSignal from "@corvu/utils/create/controllableSignal"; import createOnce from "@corvu/utils/create/once"; import createSize from "@corvu/utils/create/size"; import { isFunction } from "@corvu/utils"; import { mergeRefs as mergeRefs2 } from "@corvu/utils/reactivity"; var OtpFieldRoot = (props) => { const defaultedProps = mergeProps2( { shiftPWManagers: true }, props ); const [localProps, otherProps] = splitProps2(defaultedProps, [ "maxLength", "value", "onValueChange", "onComplete", "shiftPWManagers", "contextId", "ref", "style", "children" ]); const [ref, setRef] = createSignal2(null); const [value, setValue] = createControllableSignal({ value: () => localProps.value, initialValue: "", onChange: localProps.onValueChange }); const rootHeight = createSize({ element: ref, dimension: "height" }); createEffect2(() => { const value_ = value(); if (value_.length !== localProps.maxLength) return; localProps.onComplete?.(value_); }); const [isFocused, setIsFocused] = createSignal2(false); const [isHovered, setIsHovered] = createSignal2(false); const [isInserting, setIsInserting] = createSignal2(false); const [activeSlots, setActiveSlots] = createSignal2([]); const childrenProps = { get value() { return value(); }, get isFocused() { return isFocused(); }, get isHovered() { return isHovered(); }, get isInserting() { return isInserting(); }, get maxLength() { return localProps.maxLength; }, get activeSlots() { return activeSlots(); }, get shiftPWManagers() { return localProps.shiftPWManagers; } }; const memoizedChildren = createOnce(() => localProps.children); const resolveChildren = () => { const children = memoizedChildren()(); if (isFunction(children)) { return children(childrenProps); } return children; }; const memoizedOtpFieldRoot = createMemo2(() => { const OtpFieldContext2 = createOtpFieldContext(localProps.contextId); const InternalOtpFieldContext2 = createInternalOtpFieldContext( localProps.contextId ); return <OtpFieldContext2.Provider value={{ value, isFocused, isHovered, isInserting, maxLength: () => localProps.maxLength, activeSlots, shiftPWManagers: () => localProps.shiftPWManagers }} > <InternalOtpFieldContext2.Provider value={{ value, isFocused, isHovered, isInserting, maxLength: () => localProps.maxLength, activeSlots, shiftPWManagers: () => localProps.shiftPWManagers, rootHeight, setValue, setIsFocused, setIsHovered, setIsInserting, setActiveSlots }} > <Dynamic2 as="div" ref={mergeRefs2(setRef, localProps.ref)} style={combineStyle2( { position: "relative", "user-select": "none", "-webkit-user-select": "none", "pointer-events": "none" }, localProps.style )} data-corvu-otp-field-root="" {...otherProps} > {untrack(() => resolveChildren())} </Dynamic2> </InternalOtpFieldContext2.Provider> </OtpFieldContext2.Provider>; }); return memoizedOtpFieldRoot; }; var Root_default = OtpFieldRoot; // src/index.ts var OtpField = Object.assign(Root_default, { Input: Input_default, useContext: useOtpFieldContext }); var src_default = OtpField; export { Input_default as Input, Root_default as Root, src_default as default, useOtpFieldContext as useContext };