Skip to content
Open
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
2 changes: 2 additions & 0 deletions .changeset/otp-mosaic-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
11 changes: 9 additions & 2 deletions packages/headless/src/primitives/otp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,13 @@ and the `Ctrl`/`Cmd` boundary jumps stay logical (first / last-entered).
## ARIA

- `Root` is a `role="group"`; give it an `aria-label` (or `aria-labelledby`) describing the code.
- Each `Input` gets a default `aria-label` of `"Character N of M"`, overridable per input.
- `Root`'s `id` lands on the first `Input`, not the group, so a `<label htmlFor>` targets a real
control and clicking it focuses the code. Remaining slots take `${id}-2`, `${id}-3`, and so on.
- The first `Input` inherits the group's name (from `aria-labelledby`, then `aria-label`), or is
left to a native `<label>`. The rest get a default `aria-label` of `"Character N of M"`,
overridable per input.
- Slots use a roving tab index: `Tab` enters the group at the next empty slot and leaves in one step.
- When `name` is set, the hidden form input is `aria-hidden` and removed from the tab order.
- When `name` is set, the hidden form input is `aria-hidden`, removed from the tab order, and
disabled alongside the field so a disabled code submits nothing.
- `required` is applied to each slot rather than the hidden input, which is `readOnly` and so barred
from constraint validation. A partially entered code fails validation on its first empty slot.
12 changes: 12 additions & 0 deletions packages/headless/src/primitives/otp/otp-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@ export interface OtpSlot {
isFilled: boolean;
}

/** The naming attributes the first slot inherits from `<Otp.Root>`. */
export type FirstInputLabel = { 'aria-labelledby': string } | { 'aria-label': string };

export interface OtpContextValue {
/** The current Otp value. */
value: string;
/** The number of slots. */
length: number;
/** Whether the whole field is disabled. */
disabled: boolean;
/** Whether every slot must be filled before the enclosing form submits. */
required: boolean;
/** Whether every slot is filled. */
complete: boolean;
/** The allowed character set. */
Expand All @@ -36,6 +41,13 @@ export interface OtpContextValue {
/** Focus the slot at `index` (clamped to range). */
focus: (index: number) => void;
// --- internal wiring used by <Otp.Input> ---
/** The `id` for the slot at `index`. Slot `0` takes the root's `id` so a `<label>` can target it. */
getInputId: (index: number) => string;
/**
* The accessible name the first slot inherits from the root, or `undefined` when the root
* carries no name of its own and the slot is left to a native `<label>`.
*/
firstInputLabel: FirstInputLabel | undefined;
/** Register/unregister a slot input's element by index. */
registerInput: (index: number, element: HTMLInputElement | null) => void;
/** Propose a new full value; it is sanitized and clamped before commit. */
Expand Down
12 changes: 11 additions & 1 deletion packages/headless/src/primitives/otp/otp-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ export const OtpInput = React.forwardRef<HTMLInputElement, OtpInputProps>(functi
value,
length,
disabled,
required,
pattern,
mask,
activeIndex,
setValue,
queueFocus,
focus,
getInputId,
firstInputLabel,
registerInput,
onSlotFocus,
onSlotBlur,
Expand All @@ -40,9 +43,14 @@ export const OtpInput = React.forwardRef<HTMLInputElement, OtpInputProps>(functi
// when the field is unfocused, so Tab enters and leaves the group once.
const tabStop = activeIndex ?? Math.min(value.length, length - 1);

// The first slot answers to the field's own name; the rest are positional.
const labelProps = index === 0 ? (firstInputLabel ?? {}) : { 'aria-label': `Character ${index + 1} of ${length}` };

const state = { active: activeIndex === index, filled: char !== '', disabled };

const defaultProps: Record<string, unknown> = {
id: getInputId(index),
...labelProps,
value: char,
type: mask ? 'password' : 'text',
inputMode: inputModeForPattern(pattern),
Expand All @@ -56,7 +64,9 @@ export const OtpInput = React.forwardRef<HTMLInputElement, OtpInputProps>(functi
maxLength: index === 0 ? length : 1,
tabIndex: tabStop === index ? 0 : -1,
disabled,
'aria-label': `Character ${index + 1} of ${length}`,
// Constraint validation rides on the visible slots: the hidden input is `readOnly`,
// which bars it from validation entirely.
required,
onMouseDown: (event: React.MouseEvent<HTMLInputElement>) => {
if (disabled) {
return;
Expand Down
39 changes: 37 additions & 2 deletions packages/headless/src/primitives/otp/otp-root.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client';

import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type CSSProperties, type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';

import { useControllableState } from '../../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { OtpContext, type OtpContextValue, type OtpSlot } from './otp-context';
import { type FirstInputLabel, OtpContext, type OtpContextValue, type OtpSlot } from './otp-context';
import { inputModeForPattern, type OtpPattern, sanitize } from './otp-utils';

export interface OtpProps extends Omit<ComponentProps<'div'>, 'value' | 'defaultValue' | 'onChange'> {
Expand All @@ -29,6 +29,13 @@ export interface OtpProps extends Omit<ComponentProps<'div'>, 'value' | 'default
name?: string;
/** Disable every slot and the picker. @default false */
disabled?: boolean;
/** Require every slot to be filled before the enclosing form submits. @default false */
required?: boolean;
/**
* Identifies the field for a `<label>`. Applied to the first slot rather than this
* element, since a `role="group"` is not labelable. Generated when omitted.
*/
id?: string;
children: ReactNode;
}

Expand Down Expand Up @@ -58,10 +65,31 @@ export function OtpRoot(props: OtpProps) {
mask = false,
name,
disabled = false,
required = false,
id: idProp,
children,
...otherProps
} = props;

const generatedId = useId();
const id = idProp ?? `otp-${generatedId}`;
const getInputId = useCallback((index: number) => (index === 0 ? id : `${id}-${index + 1}`), [id]);

// The first slot is what a `<label>` targets and where focus lands, so it takes the field's
// name instead of its positional one; otherwise its accessible name would contradict the
// visible label. With no name here the slot is left to a native `<label>`.
const ariaLabel = otherProps['aria-label'];
const ariaLabelledBy = otherProps['aria-labelledby'];
const firstInputLabel = useMemo<FirstInputLabel | undefined>(() => {
if (ariaLabelledBy) {
return { 'aria-labelledby': ariaLabelledBy };
}
if (ariaLabel) {
return { 'aria-label': ariaLabel };
}
return undefined;
}, [ariaLabel, ariaLabelledBy]);

const [rawValue, setRawValue] = useControllableState(valueProp, defaultValue, onValueChange);
// Never let out-of-range or disallowed characters reach the slots, even from a
// controlled/default value the consumer passes in.
Expand Down Expand Up @@ -140,13 +168,16 @@ export function OtpRoot(props: OtpProps) {
value,
length,
disabled,
required,
complete,
pattern,
mask,
slots,
activeIndex,
clear,
focus,
getInputId,
firstInputLabel,
registerInput,
setValue,
queueFocus,
Expand All @@ -157,13 +188,16 @@ export function OtpRoot(props: OtpProps) {
value,
length,
disabled,
required,
complete,
pattern,
mask,
slots,
activeIndex,
clear,
focus,
getInputId,
firstInputLabel,
registerInput,
setValue,
queueFocus,
Expand All @@ -185,6 +219,7 @@ export function OtpRoot(props: OtpProps) {
name={name}
value={value}
readOnly
disabled={disabled}
aria-hidden='true'
tabIndex={-1}
autoComplete='one-time-code'
Expand Down
101 changes: 101 additions & 0 deletions packages/headless/src/primitives/otp/otp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,107 @@ describe('Otp', () => {
});
});

describe('form integration', () => {
it('marks every slot required so a partial code fails validation', () => {
render(
<Harness
required
name='code'
defaultValue='12'
/>,
);
expect(inputs().every(input => input.required)).toBe(true);
expect(inputs()[2].checkValidity()).toBe(false);
expect(inputs()[0].checkValidity()).toBe(true);
});

it('does not require slots by default', () => {
render(<Harness />);
expect(inputs().some(input => input.required)).toBe(false);
});

it('disables the hidden input so a disabled field submits nothing', () => {
render(
<Harness
disabled
name='code'
defaultValue='1234'
/>,
);
const hidden = document.querySelector<HTMLInputElement>('input[name="code"]');
expect(hidden).toBeDisabled();

const form = document.createElement('form');
form.append(hidden as Node);
expect(Array.from(new FormData(form).keys())).toEqual([]);
});

it('submits the hidden input when enabled', () => {
render(
<Harness
name='code'
defaultValue='1234'
/>,
);
const hidden = document.querySelector<HTMLInputElement>('input[name="code"]');
expect(hidden).not.toBeDisabled();

const form = document.createElement('form');
form.append(hidden as Node);
expect(new FormData(form).get('code')).toBe('1234');
});
});

describe('labelling', () => {
it('puts the root id on the first slot and derives the rest from it', () => {
render(<Harness id='code' />);
expect(inputs().map(input => input.id)).toEqual(['code', 'code-2', 'code-3', 'code-4']);
expect(document.querySelector('[data-testid="otp-root"]')).not.toHaveAttribute('id');
});

it('generates slot ids when no id is given', () => {
render(<Harness />);
expect(inputs().every(input => input.id !== '')).toBe(true);
expect(new Set(inputs().map(input => input.id)).size).toBe(4);
});

it('names the first slot after a native label targeting it', () => {
render(
<>
<label htmlFor='code'>Verification code</label>
<Harness id='code' />
</>,
);
expect(inputs()[0]).not.toHaveAttribute('aria-label');
expect(screen.getByLabelText('Verification code')).toBe(inputs()[0]);
});

it('passes the root aria-labelledby down to the first slot', () => {
render(
<>
<span id='code-label'>Verification code</span>
<Harness aria-labelledby='code-label' />
</>,
);
expect(inputs()[0]).toHaveAttribute('aria-labelledby', 'code-label');
expect(inputs()[0]).not.toHaveAttribute('aria-label');
});

it('passes the root aria-label down to the first slot', () => {
render(<Harness aria-label='Verification code' />);
expect(inputs()[0]).toHaveAttribute('aria-label', 'Verification code');
});

it('labels the remaining slots positionally', () => {
render(<Harness aria-label='Verification code' />);
expect(
inputs()
.slice(1)
.map(input => input.getAttribute('aria-label')),
).toEqual(['Character 2 of 4', 'Character 3 of 4', 'Character 4 of 4']);
});
});

describe('accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(
Expand Down
14 changes: 12 additions & 2 deletions packages/swingset/src/stories/otp.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,13 @@ const [code, setCode] = useState('');
/>;
```

Inside a `Field.Root`, the field's `disabled` and `invalid` flow into the boxes, and the label and messages are associated with the group:
Inside a `Field.Root`, the field's `disabled`, `required`, and `invalid` flow into the boxes, and the messages are associated with the group. `Field.Label` targets the first box, so clicking it focuses the code — drop `aria-label` and let the label name the field:

```tsx
<Field.Root invalid={Boolean(error)}>
<Field.Root
required
invalid={Boolean(error)}
>
<Field.Label>Verification code</Field.Label>
<Otp name='code' />
{error ? <Field.Error>{error}</Field.Error> : <Field.Description>Didn’t receive a code? Resend</Field.Description>}
Expand All @@ -72,6 +75,13 @@ Inside a `Field.Root`, the field's `disabled` and `invalid` flow into the boxes,

## Examples

### In a Field

<Story
name='WithField'
storyModule={OtpStories}
/>

### Success

<Story
Expand Down
13 changes: 13 additions & 0 deletions packages/swingset/src/stories/otp.component.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ export function Default(props: Record<string, unknown>) {
);
}

export function WithField() {
return (
<Field.Root
required
style={stackStyles}
>
<Field.Label>Verification code</Field.Label>
<Otp name='code' />
<Field.Description>Enter the code we sent to your device.</Field.Description>
</Field.Root>
);
}

export function Success() {
return (
<Field.Root style={stackStyles}>
Expand Down
Loading
Loading