INIT
This commit is contained in:
+431
@@ -0,0 +1,431 @@
|
||||
import { Previous, watch } from "runed";
|
||||
import { onMount } from "svelte";
|
||||
import { attachRef, DOMContext, simpleBox, } from "svelte-toolbelt";
|
||||
import { usePasswordManagerBadge } from "./usePasswordManager.svelte.js";
|
||||
import { createBitsAttrs, boolToTrueOrUndef } 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 = simpleBox(null);
|
||||
#isHoveringInput = $state(false);
|
||||
inputAttachment = attachRef(this.#inputRef);
|
||||
#isFocused = simpleBox(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, `@supports (-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[1] ? "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: boolToTrueOrUndef(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,
|
||||
}));
|
||||
}
|
||||
// oxlint-disable-next-line 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 {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.error("pin input could not insert CSS rule:", rule);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user