@corvu/otp-field
Version:
Unstyled, accessible and customizable UI primitives for SolidJS
533 lines (527 loc) • 16.6 kB
JSX
// 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
};