Skip to content
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
4 changes: 2 additions & 2 deletions src/components/ui/form/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { useFormField } from '@/components/ui/form/useFormField';
import { Slot } from '@radix-ui/react-slot';
import { ComponentProps } from 'react';

export function FormControl({ ...props }: ComponentProps<typeof Slot>) {
export function FormControl({ 'aria-describedby': describedBy, ...props }: ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();

return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-describedby={[formDescriptionId, error && formMessageId, describedBy].filter(Boolean).join(' ')}
aria-invalid={!!error}
{...props}
/>
Expand Down
177 changes: 177 additions & 0 deletions src/components/ui/form/LengthLimitedInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* @vitest-environment jsdom
*/
import { Form } from '@/components/ui/form/Form';
import { FormField } from '@/components/ui/form/FormField';
import { FormItem } from '@/components/ui/form/FormItem';
import { FormLabel } from '@/components/ui/form/FormLabel';
import { FormMessage } from '@/components/ui/form/FormMessage';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { afterEach, describe, expect, it } from 'vitest';
import { z } from 'zod';
import { LengthLimitedInput } from './LengthLimitedInput';

function NameForm({ type = 'text', name = '', maxLength = 10, focus = false }: {
type?: 'text' | 'email';
name?: string;
maxLength?: number;
focus?: boolean;
}) {
const form = useForm({ defaultValues: { name } });
const { setFocus } = form;
useEffect(() => {
if (focus) {
setFocus('name');
}
}, [focus, setFocus]);
return (
<Form {...form}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<LengthLimitedInput type={type} maxLength={maxLength} {...field} />
<FormMessage />
</FormItem>
)}
/>
</Form>
);
}

afterEach(cleanup);

const input = () => screen.getByLabelText('Name') as HTMLInputElement;
const hint = () => document.querySelector('[data-slot="form-length-hint"]')!;
const type = (value: string) => fireEvent.change(input(), { target: { value } });
const description = () =>
(input().getAttribute('aria-describedby') ?? '').split(' ').map(id => document.getElementById(id)?.textContent)
.join(' ').trim();

// jsdom has no paste default action, so `landed` stands in for what the browser inserts.
function startPaste(text: string, landed: string) {
fireEvent.paste(input(), { clipboardData: { getData: () => text } });
type(landed);
}
async function paste(text: string, landed: string) {
startPaste(text, landed);
await act(() => new Promise(resolve => setTimeout(resolve)));
}

describe('LengthLimitedInput', () => {
it('stops the input at the limit and says so, politely, in its accessible description', () => {
render(<NameForm />);
expect(input().maxLength).toBe(10);

type('a'.repeat(10));

expect(hint().textContent).toBe('Maximum 10 characters reached.');
expect(description()).toBe('Maximum 10 characters reached.');
expect(hint().getAttribute('aria-live')).toBe('polite');
});

it("passes react-hook-form the input's ref, so a refused field can still be focused", async () => {
render(<NameForm focus />);

await waitFor(() => expect(document.activeElement).toBe(input()));
});

it('says nothing below the limit, from a live region that is already in place', () => {
render(<NameForm />);
type('a'.repeat(9));

expect(hint().textContent).toBe('');
expect(hint().getAttribute('aria-live')).toBe('polite');
expect(input().getAttribute('aria-describedby')).toContain(hint().id);
});

it('clears once the value is back under the limit', () => {
render(<NameForm />);
type('a'.repeat(10));
type('a'.repeat(9));

expect(hint().textContent).toBe('');
});

it('says when a paste was cut, how long it was and how much of it fit', async () => {
render(<NameForm />);
await paste('p'.repeat(312), 'p'.repeat(10));

expect(description()).toBe(
'Maximum 10 characters reached. Pasted text was 312 characters; only the first 10 fit.',
);
});

it('counts only the room left beside what the field already held', async () => {
render(<NameForm />);
type('abcd');
await paste('x'.repeat(20), `abcd${'x'.repeat(6)}`);

expect(hint().textContent).toBe(
'Maximum 10 characters reached. Pasted text was 20 characters; only the first 6 fit.',
);
});

it('announces the cut paste alone, not the limit first and then the paste', async () => {
render(<NameForm />);
startPaste('p'.repeat(312), 'p'.repeat(10));

expect(hint().textContent).toBe('');
await act(() => new Promise(resolve => setTimeout(resolve)));
expect(hint().textContent).toContain('Pasted text was 312 characters');
});

it('keeps the paste notice while the value is what the paste left, and drops it on any change', async () => {
render(<NameForm />);
await paste('p'.repeat(312), 'p'.repeat(10));
expect(hint().textContent).toContain('Pasted text was 312 characters');

type(`${'p'.repeat(9)}q`);
expect(hint().textContent).toBe('Maximum 10 characters reached.');

type('p'.repeat(9));
expect(hint().textContent).toBe('');
});

it('says none of a paste fit when the browser stopped short of a surrogate pair', async () => {
render(<NameForm />);
type('a'.repeat(9));
await paste('😀', 'a'.repeat(9));

expect(hint().textContent).toBe('Maximum 10 characters. Pasted text was 2 characters; none of it fit.');
});

it('says nothing about a paste that fit', async () => {
render(<NameForm />);
await paste('abc', 'abc');

expect(hint().textContent).toBe('');
});

it('says how far over the limit a value starts, such as a name saved before the limit was lowered', () => {
render(<NameForm name={'a'.repeat(12)} />);

expect(description()).toBe('Maximum 10 characters; this has 12.');
});

it('leaves the field alone when its schema sets no maximum, where zod reports the limit as null', () => {
render(<NameForm maxLength={z.string().maxLength!} />);
type('a'.repeat(50));

expect(input().maxLength).toBe(-1);
expect(document.querySelector('[data-slot="form-length-hint"]')).toBeNull();
});

it('makes no claim about a paste into a field that hides its selection, as type="email" does', async () => {
render(<NameForm type="email" />);
expect(input().selectionStart).toBeNull();
type('a@b.co');
await paste(`bob@${'e'.repeat(40)}.com`, `bob@${'e'.repeat(6)}`);

expect(hint().textContent).toBe('Maximum 10 characters reached.');
});
});
19 changes: 19 additions & 0 deletions src/components/ui/form/LengthLimitedInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { FormControl } from '@/components/ui/form/FormControl';
import { useLengthLimit } from '@/components/ui/form/useLengthLimit';
import { Input } from '@/components/ui/input';
import { ComponentProps } from 'react';

export function LengthLimitedInput(
{ maxLength, onPaste, ...props }: ComponentProps<typeof Input> & { maxLength: number },
) {
const { controlProps, inputProps, hint } = useLengthLimit(maxLength, onPaste);

return (
<>
<FormControl {...controlProps}>
<Input {...props} {...inputProps} />
</FormControl>
{hint}
</>
);
}
51 changes: 51 additions & 0 deletions src/components/ui/form/measurePasteCut.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { measurePasteCut } from './measurePasteCut';

const long = 'x'.repeat(312);

describe('measurePasteCut', () => {
it('reports what an empty field kept of a longer paste', () => {
expect(measurePasteCut({ text: long, before: '', after: long.slice(0, 80), selection: [0, 0], maxLength: 80 }))
.toEqual({ pasted: 312, kept: 80 });
});

it('counts only the room left beside what the field already held', () => {
const before = 'a'.repeat(10);
const after = before + 'b'.repeat(70);
expect(measurePasteCut({ text: 'b'.repeat(75), before, after, selection: [10, 10], maxLength: 80 }))
.toEqual({ pasted: 75, kept: 70 });
});

it('counts the selection the paste replaces as room', () => {
const before = 'a'.repeat(50);
const text = 'b'.repeat(80);
expect(measurePasteCut({ text, before, after: text, selection: [0, 50], maxLength: 80 })).toBeNull();
});

it('says none of it fit when the field was already full', () => {
const full = 'a'.repeat(80);
expect(measurePasteCut({ text: 'hello', before: full, after: full, selection: [80, 80], maxLength: 80 }))
.toEqual({ pasted: 5, kept: 0 });
});

it('counts what landed when the browser stopped short of a surrogate pair', () => {
const before = 'a'.repeat(79);
expect(measurePasteCut({ text: '😀', before, after: before, selection: [79, 79], maxLength: 80 }))
.toEqual({ pasted: 2, kept: 0 });
});

it('ignores a paste that fit exactly', () => {
const text = 'b'.repeat(80);
expect(measurePasteCut({ text, before: '', after: text, selection: [0, 0], maxLength: 80 })).toBeNull();
});

it('ignores the trailing line break a copied line carries, and counts each inner break as one space', () => {
const text = `${'b'.repeat(39)}\r\n${'c'.repeat(40)}\n`;
const after = `${'b'.repeat(39)} ${'c'.repeat(40)}`;
expect(measurePasteCut({ text, before: '', after, selection: [0, 0], maxLength: 80 })).toBeNull();
});

it('ignores a paste that left the field short of its limit, which something other than the limit refused', () => {
expect(measurePasteCut({ text: long, before: 'abc', after: 'abc', selection: [3, 3], maxLength: 80 })).toBeNull();
});
});
25 changes: 25 additions & 0 deletions src/components/ui/form/measurePasteCut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type PasteCut = { pasted: number; kept: number };

type PasteMeasurement = {
text: string;
before: string;
after: string;
selection: readonly [number, number];
maxLength: number;
};

export function measurePasteCut({ text, before, after, selection, maxLength }: PasteMeasurement): PasteCut | null {
// What a single-line input inserts: trailing line breaks dropped, the rest turned into spaces.
const inserted = text.replace(/[\r\n]+$/, '').replace(/\r\n|\r|\n/g, ' ');
// The browser cuts only by filling the field, though it may stop one short rather than split a surrogate pair.
if (!inserted || after.length < maxLength - 1) {
return null;
}
const outside = before.length - (selection[1] - selection[0]);
const room = Math.max(0, maxLength - outside);
if (room >= inserted.length) {
return null;
}
// What landed, which is short of the room when the browser stopped before a surrogate pair.
return { pasted: inserted.length, kept: Math.min(room, Math.max(0, after.length - outside)) };
}
1 change: 1 addition & 0 deletions src/components/ui/form/useFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function useFormField() {
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
formLengthHintId: `${id}-form-item-length-hint`,
error,
};
}
96 changes: 96 additions & 0 deletions src/components/ui/form/useLengthLimit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { measurePasteCut, PasteCut } from '@/components/ui/form/measurePasteCut';
import { useFormField } from '@/components/ui/form/useFormField';
import { pluralize } from '@/lib/pluralize';
import { TriangleAlertIcon } from 'lucide-react';
import { ClipboardEvent, ClipboardEventHandler, useEffect, useRef, useState } from 'react';
import { useWatch } from 'react-hook-form';

type PasteState = 'pending' | (PasteCut & { value: string }) | null;

/** The browser cuts a paste to `maxLength` without a word. */
export function useLengthLimit(
maxLength: number | null | undefined,
onPaste?: ClipboardEventHandler<HTMLInputElement>,
) {
const { name, formLengthHintId } = useFormField();
const watched: unknown = useWatch({ name, disabled: maxLength == null });
const value = typeof watched === 'string' ? watched : '';
const [paste, setPaste] = useState<PasteState>(null);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);

// zod's `.maxLength` is null on a schema without `.max()`.
if (maxLength == null) {
return { controlProps: {}, inputProps: { onPaste }, hint: null };
}

// Any later change, even one that stays at the limit, means the value is no longer what the paste left.
let shown = paste;
if (shown && shown !== 'pending' && value !== shown.value) {
shown = null;
setPaste(null);
}

const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
onPaste?.(event);
const input = event.currentTarget;
const text = event.clipboardData?.getData('text/plain') ?? '';
const { selectionStart: start, selectionEnd: end } = input;
// `type="email"` hides its selection, so there is no cut to measure. Email fields are left uncapped
// anyway, since a cut address can still be a valid one, just not the user's.
if (event.defaultPrevented || input.readOnly || !text || start === null || end === null) {
return;
}
const before = input.value;
const selection = [start, end] as const;
setPaste('pending');
clearTimeout(timer.current);
// The browser inserts the paste after this handler returns, so measure once it has.
timer.current = setTimeout(() => {
const after = input.value;
const cut = measurePasteCut({ text, before, after, selection, maxLength });
setPaste(cut && { ...cut, value: after });
});
};

const limit = describeLimit(maxLength, value.length);
let message: string | null = null;
if (shown && shown !== 'pending') {
const fit = shown.kept ? `only the first ${shown.kept} fit` : 'none of it fit';
message = `${limit} Pasted text was ${pluralize(shown.pasted, 'character', 'characters')}; ${fit}.`;
} else if (!shown && value.length >= maxLength) {
// Not while a paste is pending, or the limit is announced and then the paste right after it.
message = limit;
}

return {
controlProps: { 'aria-describedby': formLengthHintId },
inputProps: { maxLength, onPaste: handlePaste },
hint: <FormLengthHint id={formLengthHintId} message={message} />,
};
}

function describeLimit(maxLength: number, length: number) {
// A value can start over the limit, such as a name saved before the limit was lowered.
if (length > maxLength) {
return `Maximum ${maxLength} characters; this has ${length}.`;
}
// Short of it only after a paste that stopped before a surrogate pair.
return length === maxLength ? `Maximum ${maxLength} characters reached.` : `Maximum ${maxLength} characters.`;
}

// Always rendered, so screen readers already track the live region when the message arrives.
function FormLengthHint({ id, message }: { id: string; message: string | null }) {
return (
<p
id={id}
data-slot="form-length-hint"
aria-live="polite"
aria-atomic="true"
className={message ? 'flex items-start gap-1.5 text-sm text-amber-800 dark:text-yellow' : 'sr-only'}
>
{message && <TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />}
{message}
</p>
);
}
Loading
Loading