Skip to content

Commit

Permalink
next: fix Pin Input pattern handling (#848)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Nov 1, 2024
1 parent b7571d8 commit 6470804
Show file tree
Hide file tree
Showing 6 changed files with 120 additions and 14 deletions.
5 changes: 5 additions & 0 deletions .changeset/big-fishes-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

fix: pin input pattern checking
69 changes: 63 additions & 6 deletions packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
isIOS: boolean;
Expand Down Expand Up @@ -69,7 +72,10 @@ class PinInputRootState {
return this.#pattern.current;
}
});
#prevInputMetadata = $state<PrevInputMetadata>([null, null, "none"]);
#prevInputMetadata = $state<PrevInputMetadata>({
prev: [null, null, "none"],
willSyntheticBlur: false,
});
#pushPasswordManagerStrategy: PinInputRootStateProps["pushPasswordManagerStrategy"];
#pwmb: ReturnType<typeof usePasswordManagerBadge>;
#initialLoad: InitialLoad;
Expand Down Expand Up @@ -125,7 +131,7 @@ class PinInputRootState {
this.value.current = input.value;
}

this.#prevInputMetadata = [
this.#prevInputMetadata.prev = [
input.selectionStart,
input.selectionEnd,
input.selectionDirection ?? "none",
Expand Down Expand Up @@ -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];
}
});
});
Expand All @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -430,6 +482,10 @@ class PinInputRootState {
};

#onblur = () => {
if (this.#prevInputMetadata.willSyntheticBlur) {
this.#prevInputMetadata.willSyntheticBlur = false;
return;
}
this.#isFocused.current = false;
};

Expand All @@ -448,6 +504,7 @@ class PinInputRootState {
//
onpaste: this.#onpaste,
oninput: this.#oninput,
onkeydown: this.#onkeydown,
onmouseover: this.#onmouseover,
onmouseleave: this.#onmouseleave,
onfocus: this.#onfocus,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion packages/tests/src/tests/pin-input/pin-input.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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");
});
});
24 changes: 24 additions & 0 deletions sites/docs/content/components/pin-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,28 @@ To submit the form when the input is complete, you can use the `onComplete` prop
</form>
```

## Patterns

You can use the `pattern` prop to restrict the characters that can be entered or pasted into the input.

<Callout type="warning" title="Note!">
Client-side validation cannot replace server-side validation. Use this in addition to server-side validation for an improved user experience.
</Callout>

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
<script lang="ts">
import { PinInput, REGEXP_ONLY_DIGITS } from "bits-ui";
</script>
<PinInput.Root pattern={REGEXP_ONLY_DIGITS}>
<!-- ... -->
</PinInput.Root>
```

<APISection {schemas} />
3 changes: 2 additions & 1 deletion sites/docs/src/lib/components/demos/pin-input-demo.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { PinInput, type PinInputRootSnippetProps } from "bits-ui";
import { PinInput, type PinInputRootSnippetProps, REGEXP_ONLY_DIGITS_AND_CHARS } from "bits-ui";
import { toast } from "svelte-sonner";
import { cn } from "$lib/utils/styles.js";
Expand All @@ -18,6 +18,7 @@
class="group/pininput flex items-center text-foreground has-[:disabled]:opacity-30"
maxlength={6}
{onComplete}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
>
{#snippet children({ cells })}
<div class="flex">
Expand Down

0 comments on commit 6470804

Please sign in to comment.