Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

next: fix Pin Input pattern handling #848

Merged
merged 3 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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