INIT
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "svelte-toolbelt";
|
||||
import type { PinInputCellProps } from "../types.js";
|
||||
import { PinInputCellState } from "../pin-input.svelte.js";
|
||||
import { createId } from "../../../internal/create-id.js";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
ref = $bindable(null),
|
||||
cell,
|
||||
child,
|
||||
children,
|
||||
...restProps
|
||||
}: PinInputCellProps = $props();
|
||||
|
||||
const cellState = PinInputCellState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
cell: boxWith(() => cell),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, cellState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import type { PinInputCellProps } from "../types.js";
|
||||
declare const PinInputCell: import("svelte").Component<PinInputCellProps, {}, "ref">;
|
||||
type PinInputCell = ReturnType<typeof PinInputCell>;
|
||||
export default PinInputCell;
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "svelte-toolbelt";
|
||||
import type { PinInputRootProps } from "../types.js";
|
||||
import { PinInputRootState } from "../pin-input.svelte.js";
|
||||
import { createId } from "../../../internal/create-id.js";
|
||||
import { noop } from "../../../internal/noop.js";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
inputId = `${createId(uid)}-input`,
|
||||
ref = $bindable(null),
|
||||
maxlength = 6,
|
||||
textalign = "left",
|
||||
pattern,
|
||||
inputmode = "numeric",
|
||||
onComplete = noop,
|
||||
pushPasswordManagerStrategy = "increase-width",
|
||||
class: containerClass = "",
|
||||
children,
|
||||
autocomplete = "one-time-code",
|
||||
disabled = false,
|
||||
value = $bindable(""),
|
||||
onValueChange = noop,
|
||||
pasteTransformer,
|
||||
...restProps
|
||||
}: PinInputRootProps = $props();
|
||||
|
||||
const rootState = PinInputRootState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
inputId: boxWith(() => inputId),
|
||||
autocomplete: boxWith(() => autocomplete),
|
||||
maxLength: boxWith(() => maxlength),
|
||||
textAlign: boxWith(() => textalign),
|
||||
disabled: boxWith(() => disabled),
|
||||
inputmode: boxWith(() => inputmode),
|
||||
pattern: boxWith(() => pattern),
|
||||
onComplete: boxWith(() => onComplete),
|
||||
value: boxWith(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange(v);
|
||||
}
|
||||
),
|
||||
pushPasswordManagerStrategy: boxWith(() => pushPasswordManagerStrategy),
|
||||
pasteTransformer: boxWith(() => pasteTransformer),
|
||||
});
|
||||
|
||||
const mergedInputProps = $derived(mergeProps(restProps, rootState.inputProps));
|
||||
const mergedRootProps = $derived(mergeProps(rootState.rootProps, { class: containerClass }));
|
||||
const mergedInputWrapperProps = $derived(mergeProps(rootState.inputWrapperProps, {}));
|
||||
</script>
|
||||
|
||||
<div {...mergedRootProps}>
|
||||
{@render children?.(rootState.snippetProps)}
|
||||
|
||||
<div {...mergedInputWrapperProps}>
|
||||
<input {...mergedInputProps} />
|
||||
</div>
|
||||
</div>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import type { PinInputRootProps } from "../types.js";
|
||||
declare const PinInput: import("svelte").Component<PinInputRootProps, {}, "value" | "ref">;
|
||||
type PinInput = ReturnType<typeof PinInput>;
|
||||
export default PinInput;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export { default as Root } from "./components/pin-input.svelte";
|
||||
export { default as Cell } from "./components/pin-input-cell.svelte";
|
||||
export type { PinInputRootProps as RootProps, PinInputCellProps as CellProps } from "./types.js";
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export { default as Root } from "./components/pin-input.svelte";
|
||||
export { default as Cell } from "./components/pin-input-cell.svelte";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * as PinInput from "./exports.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * as PinInput from "./exports.js";
|
||||
+121
File diff suppressed because one or more lines are too long
+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);
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import type { Snippet } from "svelte";
|
||||
import type { OnChangeFn, WithChild, Without } from "../../internal/types.js";
|
||||
import type { BitsPrimitiveDivAttributes, BitsPrimitiveInputAttributes } from "../../shared/attributes.js";
|
||||
export type PinInputRootSnippetProps = {
|
||||
cells: PinInputCell[];
|
||||
isFocused: boolean;
|
||||
isHovering: boolean;
|
||||
};
|
||||
export type PinInputRootPropsWithoutHTML = Omit<WithChild<{
|
||||
/**
|
||||
* The value of the input.
|
||||
*
|
||||
* @bindable
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* A callback function that is called when the value of the input changes.
|
||||
*/
|
||||
onValueChange?: OnChangeFn<string>;
|
||||
/**
|
||||
* A callback function that is called when the user pastes text into the input.
|
||||
* It receives the pasted text as an argument, and should return the sanitized text.
|
||||
*
|
||||
* Use this function to clean up the pasted text, like removing hyphens or other
|
||||
* characters that should not make it into the input.
|
||||
*/
|
||||
pasteTransformer?: (text: string) => string;
|
||||
/**
|
||||
* The max length of the input.
|
||||
*/
|
||||
maxlength: number;
|
||||
/**
|
||||
* Customize the alignment of the text within in the input.
|
||||
*
|
||||
* @default "left"
|
||||
*/
|
||||
textalign?: "left" | "center" | "right";
|
||||
/**
|
||||
* A callback function that is called when the input is completely filled.
|
||||
*
|
||||
*/
|
||||
onComplete?: (...args: any[]) => void;
|
||||
/**
|
||||
* How to handle the input when a password manager is detected.
|
||||
*/
|
||||
pushPasswordManagerStrategy?: "increase-width" | "none";
|
||||
/**
|
||||
* Whether the input is disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Optionally provide an ID to apply to the hidden input element.
|
||||
*/
|
||||
inputId?: string;
|
||||
/**
|
||||
* The children snippet used to render the individual cells.
|
||||
*/
|
||||
children: Snippet<[PinInputRootSnippetProps]>;
|
||||
}, PinInputRootSnippetProps>, "child">;
|
||||
export type PinInputRootProps = PinInputRootPropsWithoutHTML & Without<BitsPrimitiveInputAttributes, PinInputRootPropsWithoutHTML>;
|
||||
export type PinInputCellPropsWithoutHTML = WithChild<{
|
||||
/**
|
||||
* This specific cell, which is provided by the `cells` snippet prop from
|
||||
* the `PinInput.Root` component.
|
||||
*/
|
||||
cell: PinInputCell;
|
||||
}>;
|
||||
export type PinInputCellProps = PinInputCellPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, PinInputCellPropsWithoutHTML>;
|
||||
export type PinInputCell = {
|
||||
/**
|
||||
* The character displayed in the cell.
|
||||
*/
|
||||
char: string | null | undefined;
|
||||
/**
|
||||
* Whether the cell is active.
|
||||
*/
|
||||
isActive: boolean;
|
||||
/**
|
||||
* Whether the cell has a fake caret.
|
||||
*/
|
||||
hasFakeCaret: boolean;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { type DOMContext, type ReadableBox, type WritableBox } from "svelte-toolbelt";
|
||||
import type { PinInputRootPropsWithoutHTML } from "./types.js";
|
||||
type UsePasswordManagerBadgeProps = {
|
||||
containerRef: WritableBox<HTMLElement | null>;
|
||||
inputRef: WritableBox<HTMLInputElement | null>;
|
||||
pushPasswordManagerStrategy: ReadableBox<PinInputRootPropsWithoutHTML["pushPasswordManagerStrategy"]>;
|
||||
isFocused: ReadableBox<boolean>;
|
||||
domContext: DOMContext;
|
||||
};
|
||||
export declare function usePasswordManagerBadge({ containerRef, inputRef, pushPasswordManagerStrategy, isFocused, domContext, }: UsePasswordManagerBadgeProps): {
|
||||
readonly hasPwmBadge: boolean;
|
||||
readonly willPushPwmBadge: boolean;
|
||||
PWM_BADGE_SPACE_WIDTH: "40px";
|
||||
};
|
||||
export {};
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { getWindow } from "svelte-toolbelt";
|
||||
const PWM_BADGE_MARGIN_RIGHT = 18;
|
||||
const PWM_BADGE_SPACE_WIDTH_PX = 40;
|
||||
const PWM_BADGE_SPACE_WIDTH = `${PWM_BADGE_SPACE_WIDTH_PX}px`;
|
||||
const PASSWORD_MANAGER_SELECTORS = [
|
||||
"[data-lastpass-icon-root]", // LastPass,
|
||||
"com-1password-button", // 1Password,
|
||||
"[data-dashlanecreated]", // Dashlane,
|
||||
'[style$="2147483647 !important;"]', // Bitwarden
|
||||
].join(",");
|
||||
export function usePasswordManagerBadge({ containerRef, inputRef, pushPasswordManagerStrategy, isFocused, domContext, }) {
|
||||
let hasPwmBadge = $state(false);
|
||||
let hasPwmBadgeSpace = $state(false);
|
||||
let done = $state(false);
|
||||
function willPushPwmBadge() {
|
||||
const strategy = pushPasswordManagerStrategy.current;
|
||||
if (strategy === "none")
|
||||
return false;
|
||||
const increaseWidthCase = strategy === "increase-width" && hasPwmBadge && hasPwmBadgeSpace;
|
||||
return increaseWidthCase;
|
||||
}
|
||||
function trackPwmBadge() {
|
||||
const container = containerRef.current;
|
||||
const input = inputRef.current;
|
||||
if (!container || !input || done || pushPasswordManagerStrategy.current === "none")
|
||||
return;
|
||||
const elementToCompare = container;
|
||||
// get the top right-center point of the container
|
||||
// that is usually where most password managers place their badge
|
||||
const rightCornerX = elementToCompare.getBoundingClientRect().left + elementToCompare.offsetWidth;
|
||||
const centeredY = elementToCompare.getBoundingClientRect().top + elementToCompare.offsetHeight / 2;
|
||||
const x = rightCornerX - PWM_BADGE_MARGIN_RIGHT;
|
||||
const y = centeredY;
|
||||
// do an extra search to check for all the password manager badges
|
||||
const passwordManagerStrategy = domContext.querySelectorAll(PASSWORD_MANAGER_SELECTORS);
|
||||
// if no password manager is detected, dispatch document.elementFromPoint to
|
||||
// identify the badges
|
||||
if (passwordManagerStrategy.length === 0) {
|
||||
const maybeBadgeEl = domContext.getDocument().elementFromPoint(x, y);
|
||||
// if the found element is the container,
|
||||
// then it is not a badge, most times there is no badge in this case
|
||||
if (maybeBadgeEl === container)
|
||||
return;
|
||||
}
|
||||
hasPwmBadge = true;
|
||||
done = true;
|
||||
}
|
||||
$effect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || pushPasswordManagerStrategy.current === "none")
|
||||
return;
|
||||
// check if the pwm area is fully visible
|
||||
function checkHasSpace() {
|
||||
const viewportWidth = getWindow(container).innerWidth;
|
||||
const distanceToRightEdge = viewportWidth - container.getBoundingClientRect().right;
|
||||
hasPwmBadgeSpace = distanceToRightEdge >= PWM_BADGE_SPACE_WIDTH_PX;
|
||||
}
|
||||
checkHasSpace();
|
||||
const interval = setInterval(checkHasSpace, 1000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
$effect(() => {
|
||||
const focused = isFocused.current || domContext.getActiveElement() === inputRef.current;
|
||||
if (pushPasswordManagerStrategy.current === "none" || !focused)
|
||||
return;
|
||||
const t1 = setTimeout(trackPwmBadge, 0);
|
||||
const t2 = setTimeout(trackPwmBadge, 2000);
|
||||
const t3 = setTimeout(trackPwmBadge, 5000);
|
||||
const t4 = setTimeout(() => {
|
||||
done = true;
|
||||
}, 6000);
|
||||
return () => {
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
clearTimeout(t3);
|
||||
clearTimeout(t4);
|
||||
};
|
||||
});
|
||||
return {
|
||||
get hasPwmBadge() {
|
||||
return hasPwmBadge;
|
||||
},
|
||||
get willPushPwmBadge() {
|
||||
return willPushPwmBadge();
|
||||
},
|
||||
PWM_BADGE_SPACE_WIDTH,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user