Skip to content

Commit 243a7b9

Browse files
authored
feat(PinInput): expose inputRef bindable prop (#2028)
1 parent 793c0f0 commit 243a7b9

8 files changed

Lines changed: 52 additions & 13 deletions

File tree

.changeset/plenty-ears-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"bits-ui": minor
3+
---
4+
5+
feat(PinInput): add bindable `inputRef` on `PinInput.Root`

docs/src/lib/content/api-reference/pin-input.api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
defineNumberProp,
1919
defineObjectProp,
2020
defineSimpleDataAttr,
21+
defineSimplePropSchema,
2122
defineStringProp,
2223
} from "../utils.js";
2324

@@ -63,6 +64,13 @@ const root = defineComponentApiSchema<PinInputRootPropsWithoutHTML>({
6364
inputId: defineStringProp({
6465
description: "Optionally provide an ID to apply to the hidden input element.",
6566
}),
67+
inputRef: defineSimplePropSchema({
68+
type: "HTMLInputElement",
69+
description:
70+
"The hidden `<input>` element that holds the OTP value. Bind to this to call `focus()`, read the selection, etc.",
71+
bindable: true,
72+
default: "null",
73+
}),
6674
pushPasswordManagerStrategy: defineEnumProp({
6775
description:
6876
"Enabled by default, it's an optional strategy for detecting Password Managers in the page and then shifting their badges to the right side, outside the input.",

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@
7070
"@tailwindcss/vite": "^4.1.13",
7171
"@types/node": "^20.19.16",
7272
"playwright": "1.55.0",
73-
"runed": "^0.35.1",
74-
"svelte": "5.46.4",
75-
"svelte-check": "^4.3.1",
73+
"runed": "0.37.1",
74+
"svelte": "5.55.4",
75+
"svelte-check": "^4.4.6",
7676
"tailwindcss": "^4.1.13",
7777
"typescript": "^5.9.2",
7878
"vite": "^7.1.5",

packages/bits-ui/src/lib/bits/pin-input/components/pin-input.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
id = createId(uid),
1212
inputId = `${createId(uid)}-input`,
1313
ref = $bindable(null),
14+
inputRef = $bindable(null),
1415
maxlength = 6,
1516
textalign = "left",
1617
pattern,
@@ -33,6 +34,10 @@
3334
() => ref,
3435
(v) => (ref = v)
3536
),
37+
inputRef: boxWith(
38+
() => inputRef,
39+
(v) => (inputRef = v)
40+
),
3641
inputId: boxWith(() => inputId),
3742
autocomplete: boxWith(() => autocomplete),
3843
maxLength: boxWith(() => maxlength),

packages/bits-ui/src/lib/bits/pin-input/pin-input.svelte.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface PinInputRootStateOpts
3434
extends WithRefOpts,
3535
WritableBoxedValues<{
3636
value: string;
37+
inputRef: HTMLInputElement | null;
3738
}>,
3839
ReadableBoxedValues<{
3940
inputId: string;
@@ -83,9 +84,8 @@ export class PinInputRootState {
8384

8485
readonly opts: PinInputRootStateOpts;
8586
readonly attachment: RefAttachment;
86-
#inputRef = simpleBox<HTMLInputElement | null>(null);
87+
readonly inputAttachment: RefAttachment<HTMLInputElement>;
8788
#isHoveringInput = $state(false);
88-
readonly inputAttachment: RefAttachment<HTMLInputElement> = attachRef(this.#inputRef);
8989
#isFocused = simpleBox(false);
9090
#mirrorSelectionStart = $state<number | null>(null);
9191
#mirrorSelectionEnd = $state<number | null>(null);
@@ -110,6 +110,7 @@ export class PinInputRootState {
110110
constructor(opts: PinInputRootStateOpts) {
111111
this.opts = opts;
112112
this.attachment = attachRef(this.opts.ref);
113+
this.inputAttachment = attachRef(this.opts.inputRef);
113114
this.domContext = new DOMContext(opts.ref);
114115

115116
this.#initialLoad = {
@@ -121,14 +122,14 @@ export class PinInputRootState {
121122

122123
this.#pwmb = usePasswordManagerBadge({
123124
containerRef: this.opts.ref,
124-
inputRef: this.#inputRef,
125+
inputRef: this.opts.inputRef,
125126
isFocused: this.#isFocused,
126127
pushPasswordManagerStrategy: this.opts.pushPasswordManagerStrategy,
127128
domContext: this.domContext,
128129
});
129130

130131
onMount(() => {
131-
const input = this.#inputRef.current;
132+
const input = this.opts.inputRef.current;
132133
const container = this.opts.ref.current;
133134

134135
if (!input || !container) return;
@@ -180,9 +181,9 @@ export class PinInputRootState {
180181
};
181182
});
182183

183-
watch([() => this.opts.value.current, () => this.#inputRef.current], () => {
184+
watch([() => this.opts.value.current, () => this.opts.inputRef.current], () => {
184185
syncTimeouts(() => {
185-
const input = this.#inputRef.current;
186+
const input = this.opts.inputRef.current;
186187
if (!input) return;
187188
// forcefully remove :autofill state
188189
input.dispatchEvent(new Event("input"));
@@ -313,7 +314,7 @@ export class PinInputRootState {
313314
}
314315

315316
#onDocumentSelectionChange = () => {
316-
const input = this.#inputRef.current;
317+
const input = this.opts.inputRef.current;
317318
const container = this.opts.ref.current;
318319
if (!input || !container) return;
319320

@@ -363,7 +364,7 @@ export class PinInputRootState {
363364
}
364365

365366
if (start !== -1 && end !== -1 && start !== end) {
366-
this.#inputRef.current?.setSelectionRange(start, end, direction);
367+
this.opts.inputRef.current?.setSelectionRange(start, end, direction);
367368
}
368369
}
369370

@@ -398,7 +399,7 @@ export class PinInputRootState {
398399
};
399400

400401
onfocus = (_: BitsFocusEvent<HTMLInputElement>) => {
401-
const input = this.#inputRef.current;
402+
const input = this.opts.inputRef.current;
402403
if (input) {
403404
const start = Math.min(input.value.length, this.opts.maxLength.current - 1);
404405
const end = input.value.length;
@@ -410,7 +411,7 @@ export class PinInputRootState {
410411
};
411412

412413
onpaste = (e: BitsEvent<ClipboardEvent>) => {
413-
const input = this.#inputRef.current;
414+
const input = this.opts.inputRef.current;
414415
if (!input) return;
415416

416417
const getNewValue = (finalContent: string | undefined) => {

packages/bits-ui/src/lib/bits/pin-input/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ export type PinInputRootPropsWithoutHTML = Omit<
6969
*/
7070
inputId?: string;
7171

72+
/**
73+
* The underlying hidden `<input>` element. Bind to call `focus()`, read selection, etc.
74+
*
75+
* @bindable
76+
*/
77+
inputRef?: HTMLInputElement | null;
78+
7279
/**
7380
* The children snippet used to render the individual cells.
7481
*/

tests/src/tests/pin-input/pin-input-test.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
toCopy?: string;
1212
} = $props();
1313
14+
let inputRef = $state<HTMLInputElement | null>(null);
15+
1416
type CellProps = PinInputRootSnippetProps["cells"][0];
1517
</script>
1618

@@ -19,9 +21,14 @@
1921
{value}
2022
</button>
2123

24+
<button type="button" data-testid="focus-input" onclick={() => inputRef?.focus()}>
25+
focus input
26+
</button>
27+
2228
<PinInput.Root
2329
aria-label="my input"
2430
inputId="myInput"
31+
bind:inputRef
2532
bind:value
2633
class="group/pininput text-foreground flex items-center has-[:disabled]:opacity-30"
2734
{maxlength}

tests/src/tests/pin-input/pin-input.browser.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ afterEach(() => {
3636
vi.resetAllMocks();
3737
});
3838

39+
it("should focus the hidden input when `inputRef.focus()` is called", async () => {
40+
const t = setup();
41+
await t.getByTestId("focus-input").click();
42+
await expect.element(t.hiddenInput).toHaveFocus();
43+
});
44+
3945
it("should sync the `name` prop to the hidden input", async () => {
4046
const t = setup({ name: "test" });
4147
await expect.element(t.hiddenInput).toHaveAttribute("name", "test");

0 commit comments

Comments
 (0)