bits-ui
Version:
The headless components for Svelte.
431 lines (430 loc) • 17.2 kB
JavaScript
import { Previous, watch } from "runed";
import { onMount } from "svelte";
import { box, attachRef, DOMContext, } from "svelte-toolbelt";
import { usePasswordManagerBadge } from "./usePasswordManager.svelte.js";
import { createBitsAttrs, getDisabled } from "../../internal/attrs.js";
import { on } from "svelte/events";
export const REGEXP_ONLY_DIGITS = "^\\d+$";
export const REGEXP_ONLY_CHARS = "^[a-zA-Z]+$";
export const REGEXP_ONLY_DIGITS_AND_CHARS = "^[a-zA-Z0-9]+$";
const pinInputAttrs = createBitsAttrs({
component: "pin-input",
parts: ["root", "cell"],
});
const KEYS_TO_IGNORE = [
"Backspace",
"Delete",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"Escape",
"Enter",
"Tab",
"Shift",
"Control",
"Meta",
];
export class PinInputRootState {
static create(opts) {
return new PinInputRootState(opts);
}
opts;
attachment;
#inputRef = box(null);
#isHoveringInput = $state(false);
inputAttachment = attachRef(this.#inputRef);
#isFocused = box(false);
#mirrorSelectionStart = $state(null);
#mirrorSelectionEnd = $state(null);
#previousValue = new Previous(() => this.opts.value.current ?? "");
#regexPattern = $derived.by(() => {
if (typeof this.opts.pattern.current === "string") {
return new RegExp(this.opts.pattern.current);
}
else {
return this.opts.pattern.current;
}
});
#prevInputMetadata = $state({
prev: [null, null, "none"],
willSyntheticBlur: false,
});
#pwmb;
#initialLoad;
domContext;
constructor(opts) {
this.opts = opts;
this.attachment = attachRef(this.opts.ref);
this.domContext = new DOMContext(opts.ref);
this.#initialLoad = {
value: this.opts.value,
isIOS: typeof window !== "undefined" &&
window?.CSS?.supports("-webkit-touch-callout", "none"),
};
this.#pwmb = usePasswordManagerBadge({
containerRef: this.opts.ref,
inputRef: this.#inputRef,
isFocused: this.#isFocused,
pushPasswordManagerStrategy: this.opts.pushPasswordManagerStrategy,
domContext: this.domContext,
});
onMount(() => {
const input = this.#inputRef.current;
const container = this.opts.ref.current;
if (!input || !container)
return;
if (this.#initialLoad.value.current !== input.value) {
this.opts.value.current = input.value;
}
this.#prevInputMetadata.prev = [
input.selectionStart,
input.selectionEnd,
input.selectionDirection ?? "none",
];
const unsub = on(this.domContext.getDocument(), "selectionchange", this.#onDocumentSelectionChange, {
capture: true,
});
this.#onDocumentSelectionChange();
if (this.domContext.getActiveElement() === input) {
this.#isFocused.current = true;
}
if (!this.domContext.getElementById("pin-input-style")) {
this.#applyStyles();
}
const updateRootHeight = () => {
if (container) {
container.style.setProperty("--bits-pin-input-root-height", `${input.clientHeight}px`);
}
};
updateRootHeight();
const resizeObserver = new ResizeObserver(updateRootHeight);
resizeObserver.observe(input);
return () => {
unsub();
resizeObserver.disconnect();
};
});
watch([() => this.opts.value.current, () => this.#inputRef.current], () => {
syncTimeouts(() => {
const input = this.#inputRef.current;
if (!input)
return;
// forcefully remove :autofill state
input.dispatchEvent(new Event("input"));
// update selection state
const start = input.selectionStart;
const end = input.selectionEnd;
const dir = input.selectionDirection ?? "none";
if (start !== null && end !== null) {
this.#mirrorSelectionStart = start;
this.#mirrorSelectionEnd = end;
this.#prevInputMetadata.prev = [start, end, dir];
}
}, this.domContext);
});
$effect(() => {
// invoke `onComplete` when the input is completely filled.
const value = this.opts.value.current;
const prevValue = this.#previousValue.current;
const maxLength = this.opts.maxLength.current;
const onComplete = this.opts.onComplete.current;
if (prevValue === undefined)
return;
if (value !== prevValue && prevValue.length < maxLength && value.length === maxLength) {
onComplete(value);
}
});
}
onkeydown = (e) => {
const key = e.key;
if (KEYS_TO_IGNORE.includes(key))
return;
// if ctrl or cmd is pressed, they are likely to be shortcuts and should not be tested
// against the regex
if (e.ctrlKey || e.metaKey)
return;
if (key && this.#regexPattern && !this.#regexPattern.test(key)) {
e.preventDefault();
}
};
#rootStyles = $derived.by(() => ({
position: "relative",
cursor: this.opts.disabled.current ? "default" : "text",
userSelect: "none",
WebkitUserSelect: "none",
pointerEvents: "none",
}));
rootProps = $derived.by(() => ({
id: this.opts.id.current,
[pinInputAttrs.root]: "",
style: this.#rootStyles,
...this.attachment,
}));
inputWrapperProps = $derived.by(() => ({
style: {
position: "absolute",
inset: 0,
pointerEvents: "none",
},
}));
#inputStyle = $derived.by(() => ({
position: "absolute",
inset: 0,
width: this.#pwmb.willPushPwmBadge
? `calc(100% + ${this.#pwmb.PWM_BADGE_SPACE_WIDTH})`
: "100%",
clipPath: this.#pwmb.willPushPwmBadge
? `inset(0 ${this.#pwmb.PWM_BADGE_SPACE_WIDTH} 0 0)`
: undefined,
height: "100%",
display: "flex",
textAlign: this.opts.textAlign.current,
opacity: "1",
color: "transparent",
pointerEvents: "all",
background: "transparent",
caretColor: "transparent",
border: "0 solid transparent",
outline: "0 solid transparent",
boxShadow: "none",
lineHeight: "1",
letterSpacing: "-.5em",
fontSize: "var(--bits-pin-input-root-height)",
fontFamily: "monospace",
fontVariantNumeric: "tabular-nums",
}));
#applyStyles() {
const doc = this.domContext.getDocument();
const styleEl = doc.createElement("style");
styleEl.id = "pin-input-style";
doc.head.appendChild(styleEl);
if (styleEl.sheet) {
const autoFillStyles = "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;";
safeInsertRule(styleEl.sheet, "[data-pin-input-input]::selection { background: transparent !important; color: transparent !important; }");
safeInsertRule(styleEl.sheet, `[data-pin-input-input]:autofill { ${autoFillStyles} }`);
safeInsertRule(styleEl.sheet, `[data-pin-input-input]:-webkit-autofill { ${autoFillStyles} }`);
// iOS
safeInsertRule(styleEl.sheet, ` (-webkit-touch-callout: none) { [data-pin-input-input] { letter-spacing: -.6em !important; font-weight: 100 !important; font-stretch: ultra-condensed; font-optical-sizing: none !important; left: -1px !important; right: 1px !important; } }`);
// PWM badges
safeInsertRule(styleEl.sheet, `[data-pin-input-input] + * { pointer-events: all !important; }`);
}
}
#onDocumentSelectionChange = () => {
const input = this.#inputRef.current;
const container = this.opts.ref.current;
if (!input || !container)
return;
if (this.domContext.getActiveElement() !== input) {
this.#mirrorSelectionStart = null;
this.#mirrorSelectionEnd = null;
return;
}
const selStart = input.selectionStart;
const selEnd = input.selectionEnd;
const selDir = input.selectionDirection ?? "none";
const maxLength = input.maxLength;
const val = input.value;
const prev = this.#prevInputMetadata.prev;
let start = -1;
let end = -1;
let direction;
if (val.length !== 0 && selStart !== null && selEnd !== null) {
const isSingleCaret = selStart === selEnd;
const isInsertMode = selStart === val.length && val.length < maxLength;
if (isSingleCaret && !isInsertMode) {
const c = selStart;
if (c === 0) {
start = 0;
end = 1;
direction = "forward";
}
else if (c === maxLength) {
start = c - 1;
end = c;
direction = "backward";
}
else if (maxLength > 1 && val.length > 1) {
let offset = 0;
if (prev[0] !== null && prev[1] !== null) {
direction = c < prev[0] ? "backward" : "forward";
const wasPreviouslyInserting = prev[0] === prev[1] && prev[0] < maxLength;
if (direction === "backward" && !wasPreviouslyInserting) {
offset = -1;
}
}
start = offset - c;
end = offset + c + 1;
}
}
if (start !== -1 && end !== -1 && start !== end) {
this.#inputRef.current?.setSelectionRange(start, end, direction);
}
}
// finally update the state
const s = start !== -1 ? start : selStart;
const e = end !== -1 ? end : selEnd;
const dir = direction ?? selDir;
this.#mirrorSelectionStart = s;
this.#mirrorSelectionEnd = e;
this.#prevInputMetadata.prev = [s, e, dir];
};
oninput = (e) => {
const newValue = e.currentTarget.value.slice(0, this.opts.maxLength.current);
if (newValue.length > 0 && this.#regexPattern && !this.#regexPattern.test(newValue)) {
e.preventDefault();
return;
}
const maybeHasDeleted = typeof this.#previousValue.current === "string" &&
newValue.length < this.#previousValue.current.length;
if (maybeHasDeleted) {
// Since cutting/deleting text doesn't trigger
// selectionchange event, we'll have to dispatch it manually.
// NOTE: The following line also triggers when cmd+A then pasting
// a value with smaller length, which is not ideal for performance.
this.domContext.getDocument().dispatchEvent(new Event("selectionchange"));
}
this.opts.value.current = newValue;
};
onfocus = (_) => {
const input = this.#inputRef.current;
if (input) {
const start = Math.min(input.value.length, this.opts.maxLength.current - 1);
const end = input.value.length;
input.setSelectionRange(start, end);
this.#mirrorSelectionStart = start;
this.#mirrorSelectionEnd = end;
}
this.#isFocused.current = true;
};
onpaste = (e) => {
const input = this.#inputRef.current;
if (!input)
return;
const getNewValue = (finalContent) => {
const start = input.selectionStart === null ? undefined : input.selectionStart;
const end = input.selectionEnd === null ? undefined : input.selectionEnd;
const isReplacing = start !== end;
const initNewVal = this.opts.value.current;
const newValueUncapped = isReplacing
? initNewVal.slice(0, start) + finalContent + initNewVal.slice(end)
: initNewVal.slice(0, start) + finalContent + initNewVal.slice(start);
return newValueUncapped.slice(0, this.opts.maxLength.current);
};
const isValueInvalid = (newValue) => {
return newValue.length > 0 && this.#regexPattern && !this.#regexPattern.test(newValue);
};
if (!this.opts.pasteTransformer?.current &&
(!this.#initialLoad.isIOS || !e.clipboardData || !input)) {
const newValue = getNewValue(e.clipboardData?.getData("text/plain"));
if (isValueInvalid(newValue)) {
e.preventDefault();
}
return;
}
const _content = e.clipboardData?.getData("text/plain") ?? "";
const content = this.opts.pasteTransformer?.current
? this.opts.pasteTransformer.current(_content)
: _content;
e.preventDefault();
const newValue = getNewValue(content);
if (isValueInvalid(newValue))
return;
input.value = newValue;
this.opts.value.current = newValue;
const selStart = Math.min(newValue.length, this.opts.maxLength.current - 1);
const selEnd = newValue.length;
input.setSelectionRange(selStart, selEnd);
this.#mirrorSelectionStart = selStart;
this.#mirrorSelectionEnd = selEnd;
};
onmouseover = (_) => {
this.#isHoveringInput = true;
};
onmouseleave = (_) => {
this.#isHoveringInput = false;
};
onblur = (_) => {
if (this.#prevInputMetadata.willSyntheticBlur) {
this.#prevInputMetadata.willSyntheticBlur = false;
return;
}
this.#isFocused.current = false;
};
inputProps = $derived.by(() => ({
id: this.opts.inputId.current,
style: this.#inputStyle,
autocomplete: this.opts.autocomplete.current || "one-time-code",
"data-pin-input-input": "",
"data-pin-input-input-mss": this.#mirrorSelectionStart,
"data-pin-input-input-mse": this.#mirrorSelectionEnd,
inputmode: this.opts.inputmode.current,
pattern: this.#regexPattern?.source,
maxlength: this.opts.maxLength.current,
value: this.opts.value.current,
disabled: getDisabled(this.opts.disabled.current),
//
onpaste: this.onpaste,
oninput: this.oninput,
onkeydown: this.onkeydown,
onmouseover: this.onmouseover,
onmouseleave: this.onmouseleave,
onfocus: this.onfocus,
onblur: this.onblur,
...this.inputAttachment,
}));
#cells = $derived.by(() => Array.from({ length: this.opts.maxLength.current }).map((_, idx) => {
const isActive = this.#isFocused.current &&
this.#mirrorSelectionStart !== null &&
this.#mirrorSelectionEnd !== null &&
((this.#mirrorSelectionStart === this.#mirrorSelectionEnd &&
idx === this.#mirrorSelectionStart) ||
(idx >= this.#mirrorSelectionStart && idx < this.#mirrorSelectionEnd));
const char = this.opts.value.current[idx] !== undefined ? this.opts.value.current[idx] : null;
return {
char,
isActive,
hasFakeCaret: isActive && char === null,
};
}));
snippetProps = $derived.by(() => ({
cells: this.#cells,
isFocused: this.#isFocused.current,
isHovering: this.#isHoveringInput,
}));
}
export class PinInputCellState {
static create(opts) {
return new PinInputCellState(opts);
}
opts;
attachment;
constructor(opts) {
this.opts = opts;
this.attachment = attachRef(this.opts.ref);
}
props = $derived.by(() => ({
id: this.opts.id.current,
[pinInputAttrs.cell]: "",
"data-active": this.opts.cell.current.isActive ? "" : undefined,
"data-inactive": !this.opts.cell.current.isActive ? "" : undefined,
...this.attachment,
}));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function syncTimeouts(cb, domContext) {
const t1 = domContext.setTimeout(cb, 0); // For faster machines
const t2 = domContext.setTimeout(cb, 1_0);
const t3 = domContext.setTimeout(cb, 5_0);
return [t1, t2, t3];
}
function safeInsertRule(sheet, rule) {
try {
sheet.insertRule(rule);
}
catch {
console.error("pin input could not insert CSS rule:", rule);
}
}