diff --git a/.changeset/big-fishes-double.md b/.changeset/big-fishes-double.md new file mode 100644 index 000000000..a75ce4e74 --- /dev/null +++ b/.changeset/big-fishes-double.md @@ -0,0 +1,5 @@ +--- +"bits-ui": patch +--- + +fix: pin input pattern checking diff --git a/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts b/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts index 95fe3d9e9..c8bb7e06b 100644 --- a/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts +++ b/packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts @@ -37,7 +37,10 @@ type PinInputRootStateProps = WithRefProps< }> >; -type PrevInputMetadata = [number | null, number | null, "none" | "forward" | "backward"]; +type PrevInputMetadata = { + prev: [number | null, number | null, "none" | "forward" | "backward"]; + willSyntheticBlur: boolean; +}; type InitialLoad = { value: WritableBox; isIOS: boolean; @@ -69,7 +72,10 @@ class PinInputRootState { return this.#pattern.current; } }); - #prevInputMetadata = $state([null, null, "none"]); + #prevInputMetadata = $state({ + prev: [null, null, "none"], + willSyntheticBlur: false, + }); #pushPasswordManagerStrategy: PinInputRootStateProps["pushPasswordManagerStrategy"]; #pwmb: ReturnType; #initialLoad: InitialLoad; @@ -125,7 +131,7 @@ class PinInputRootState { this.value.current = input.value; } - this.#prevInputMetadata = [ + this.#prevInputMetadata.prev = [ input.selectionStart, input.selectionEnd, input.selectionDirection ?? "none", @@ -183,7 +189,7 @@ class PinInputRootState { if (start !== null && end !== null) { this.#mirrorSelectionStart = start; this.#mirrorSelectionEnd = end; - this.#prevInputMetadata = [start, end, dir]; + this.#prevInputMetadata.prev = [start, end, dir]; } }); }); @@ -202,6 +208,30 @@ class PinInputRootState { }); } + keysToIgnore = [ + "Backspace", + "Delete", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "Escape", + "Enter", + "Tab", + "Shift", + "Control", + ]; + + #onkeydown = (e: KeyboardEvent) => { + const key = e.key; + if (this.keysToIgnore.includes(key)) return; + if (key && this.#regexPattern && !this.#regexPattern.test(key)) { + e.preventDefault(); + } + }; + #rootStyles = $derived.by(() => ({ position: "relative", cursor: this.#disabled.current ? "default" : "text", @@ -304,7 +334,7 @@ class PinInputRootState { const selDir = input.selectionDirection ?? "none"; const maxLength = input.maxLength; const val = input.value; - const prev = this.#prevInputMetadata; + const prev = this.#prevInputMetadata.prev; let start = -1; let end = -1; @@ -349,7 +379,7 @@ class PinInputRootState { const dir = direction ?? selDir; this.#mirrorSelectionStart = s; this.#mirrorSelectionEnd = e; - this.#prevInputMetadata = [s, e, dir]; + this.#prevInputMetadata.prev = [s, e, dir]; }; #oninput = (e: Event & { currentTarget: HTMLInputElement }) => { @@ -387,11 +417,33 @@ class PinInputRootState { #onpaste = (e: ClipboardEvent & { currentTarget: HTMLInputElement }) => { const input = this.#inputRef.current; + + if (!this.#initialLoad.isIOS) { + if (!e.clipboardData || !input) return; + const content = e.clipboardData.getData("text/plain"); + const sanitizedContent = this.#onPaste?.current?.(content) ?? content; + if ( + sanitizedContent.length > 0 && + this.#regexPattern && + !this.#regexPattern.test(sanitizedContent) + ) { + e.preventDefault(); + return; + } + } + if (!this.#initialLoad.isIOS || !e.clipboardData || !input) return; const content = e.clipboardData.getData("text/plain"); e.preventDefault(); const sanitizedContent = this.#onPaste?.current?.(content) ?? content; + if ( + sanitizedContent.length > 0 && + this.#regexPattern && + !this.#regexPattern.test(sanitizedContent) + ) { + return; + } const start = input.selectionStart === null ? undefined : input.selectionStart; const end = input.selectionEnd === null ? undefined : input.selectionEnd; @@ -430,6 +482,10 @@ class PinInputRootState { }; #onblur = () => { + if (this.#prevInputMetadata.willSyntheticBlur) { + this.#prevInputMetadata.willSyntheticBlur = false; + return; + } this.#isFocused.current = false; }; @@ -448,6 +504,7 @@ class PinInputRootState { // onpaste: this.#onpaste, oninput: this.#oninput, + onkeydown: this.#onkeydown, onmouseover: this.#onmouseover, onmouseleave: this.#onmouseleave, onfocus: this.#onfocus, diff --git a/packages/bits-ui/src/lib/bits/pin-input/usePasswordManager.svelte.ts b/packages/bits-ui/src/lib/bits/pin-input/usePasswordManager.svelte.ts index 16217c5df..a790578c0 100644 --- a/packages/bits-ui/src/lib/bits/pin-input/usePasswordManager.svelte.ts +++ b/packages/bits-ui/src/lib/bits/pin-input/usePasswordManager.svelte.ts @@ -37,10 +37,10 @@ export function usePasswordManagerBadge({ let done = $state(false); function willPushPwmBadge() { - const strat = pushPasswordManagerStrategy.current; - if (strat === "none") return false; + const strategy = pushPasswordManagerStrategy.current; + if (strategy === "none") return false; - const increaseWidthCase = strat === "increase-width" && hasPwmBadge && hasPwmBadgeSpace; + const increaseWidthCase = strategy === "increase-width" && hasPwmBadge && hasPwmBadgeSpace; return increaseWidthCase; } @@ -62,11 +62,11 @@ export function usePasswordManagerBadge({ const y = centeredY; // do an extra search to check for all the password manager badges - const pwms = document.querySelectorAll(PASSWORD_MANAGER_SELECTORS); + const passwordManagerStrategy = document.querySelectorAll(PASSWORD_MANAGER_SELECTORS); - // if no password manager is detected, dispatch document.elementfrompoint to + // if no password manager is detected, dispatch document.elementFromPoint to // identify the badges - if (pwms.length === 0) { + if (passwordManagerStrategy.length === 0) { const maybeBadgeEl = document.elementFromPoint(x, y); // if the found element is the container, diff --git a/packages/tests/src/tests/pin-input/pin-input.test.ts b/packages/tests/src/tests/pin-input/pin-input.test.ts index 4fdffe43d..ad9bfe94d 100644 --- a/packages/tests/src/tests/pin-input/pin-input.test.ts +++ b/packages/tests/src/tests/pin-input/pin-input.test.ts @@ -1,7 +1,7 @@ import { render, waitFor } from "@testing-library/svelte/svelte5"; import { axe } from "jest-axe"; import { describe, it, vi } from "vitest"; -import type { PinInput } from "bits-ui"; +import { type PinInput, REGEXP_ONLY_DIGITS } from "bits-ui"; import { getTestKbd, setupUserEvents } from "../utils.js"; import PinInputTest from "./pin-input-test.svelte"; @@ -153,4 +153,23 @@ describe("pin Input", () => { expect(mockComplete).toHaveBeenCalledTimes(1); expect(mockComplete).toHaveBeenCalledWith("123456"); }); + + it("should ignore keys that do not match the pattern", async () => { + const { user, hiddenInput } = setup({ + pattern: REGEXP_ONLY_DIGITS, + }); + + await user.click(hiddenInput); + await user.keyboard("123"); + expect(hiddenInput).toHaveValue("123"); + + await user.keyboard(kbd.BACKSPACE); + await user.keyboard(kbd.BACKSPACE); + await user.keyboard(kbd.BACKSPACE); + expect(hiddenInput).toHaveValue(""); + await user.keyboard("$"); + expect(hiddenInput).toHaveValue(""); + await user.keyboard("1$"); + expect(hiddenInput).toHaveValue("1"); + }); }); diff --git a/sites/docs/content/components/pin-input.md b/sites/docs/content/components/pin-input.md index fb7749f3a..6fc50dda2 100644 --- a/sites/docs/content/components/pin-input.md +++ b/sites/docs/content/components/pin-input.md @@ -182,4 +182,28 @@ To submit the form when the input is complete, you can use the `onComplete` prop ``` +## Patterns + +You can use the `pattern` prop to restrict the characters that can be entered or pasted into the input. + + +Client-side validation cannot replace server-side validation. Use this in addition to server-side validation for an improved user experience. + + +Bits UI exports a few common patterns that you can import and use in your application. + +- `REGEXP_ONLY_DIGITS` - Only allow digits to be entered. +- `REGEXP_ONLY_CHARS` - Only allow characters to be entered. +- `REGEXP_ONLY_DIGITS_AND_CHARS` - Only allow digits and characters to be entered. + +```svelte + + + + + +``` + diff --git a/sites/docs/src/lib/components/demos/pin-input-demo.svelte b/sites/docs/src/lib/components/demos/pin-input-demo.svelte index 4b3637936..723aa63a4 100644 --- a/sites/docs/src/lib/components/demos/pin-input-demo.svelte +++ b/sites/docs/src/lib/components/demos/pin-input-demo.svelte @@ -1,5 +1,5 @@