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