UNPKG

@corvu/otp-field

Version:

Unstyled, accessible and customizable UI primitives for SolidJS

465 lines (461 loc) 16.4 kB
import { createContext, mergeProps, splitProps, createSignal, createEffect, createMemo, untrack, useContext, onCleanup, Show } from 'solid-js'; import { createKeyedContext, useKeyedContext } from '@corvu/utils/create/keyedContext'; import { createComponent, mergeProps as mergeProps$1, template, isServer } from 'solid-js/web'; import { combineStyle, afterPaint, callEventHandler } from '@corvu/utils/dom'; import { Dynamic } from '@corvu/utils/dynamic'; import { mergeRefs } from '@corvu/utils/reactivity'; 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'; // src/context.ts 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; }; 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; var _tmpl$ = /* @__PURE__ */ template(`<noscript>`); 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); onCleanup(() => { 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); onCleanup(() => { 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 [createComponent(Show, { get when() { return localProps.noScriptCSSFallback !== null && isServer; }, get children() { return _tmpl$(); } }), createComponent(Dynamic, mergeProps$1({ as: "input", ref(r$) { var _ref$ = mergeRefs(setRef, localProps.ref); typeof _ref$ === "function" && _ref$(r$); }, onInput, onFocus, onBlur, onMouseOver, onMouseLeave, onKeyDown, onKeyUp, inputMode: "numeric", get autocomplete() { return localProps.autocomplete ?? "one-time-code"; }, get disabled() { return localProps.disabled; }, get spellcheck() { return localProps.spellcheck ?? false; }, get style() { return 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); }, get pattern() { return 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; var OtpFieldRoot = (props) => { const defaultedProps = mergeProps({ shiftPWManagers: true }, props); const [localProps, otherProps] = splitProps(defaultedProps, ["maxLength", "value", "onValueChange", "onComplete", "shiftPWManagers", "contextId", "ref", "style", "children"]); const [ref, setRef] = createSignal(null); const [value, setValue] = createControllableSignal({ value: () => localProps.value, initialValue: "", onChange: localProps.onValueChange }); const rootHeight = createSize({ element: ref, dimension: "height" }); createEffect(() => { const value_ = value(); if (value_.length !== localProps.maxLength) return; localProps.onComplete?.(value_); }); const [isFocused, setIsFocused] = createSignal(false); const [isHovered, setIsHovered] = createSignal(false); const [isInserting, setIsInserting] = createSignal(false); const [activeSlots, setActiveSlots] = createSignal([]); 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 = createMemo(() => { const OtpFieldContext2 = createOtpFieldContext(localProps.contextId); const InternalOtpFieldContext2 = createInternalOtpFieldContext(localProps.contextId); return createComponent(OtpFieldContext2.Provider, { value: { value, isFocused, isHovered, isInserting, maxLength: () => localProps.maxLength, activeSlots, shiftPWManagers: () => localProps.shiftPWManagers }, get children() { return createComponent(InternalOtpFieldContext2.Provider, { value: { value, isFocused, isHovered, isInserting, maxLength: () => localProps.maxLength, activeSlots, shiftPWManagers: () => localProps.shiftPWManagers, rootHeight, setValue, setIsFocused, setIsHovered, setIsInserting, setActiveSlots }, get children() { return createComponent(Dynamic, mergeProps$1({ as: "div", ref(r$) { var _ref$ = mergeRefs(setRef, localProps.ref); typeof _ref$ === "function" && _ref$(r$); }, get style() { return combineStyle({ position: "relative", "user-select": "none", "-webkit-user-select": "none", "pointer-events": "none" }, localProps.style); }, "data-corvu-otp-field-root": "" }, otherProps, { get children() { return untrack(() => resolveChildren()); } })); } }); } }); }); 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 };