@estiva-app/ui 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +3 -1
  2. package/dist/Button.d.ts.map +1 -1
  3. package/dist/Checkbox.d.ts +14 -1
  4. package/dist/Checkbox.d.ts.map +1 -1
  5. package/dist/CommandPalette.d.ts.map +1 -1
  6. package/dist/FilePicker.d.ts +22 -0
  7. package/dist/FilePicker.d.ts.map +1 -0
  8. package/dist/Form.d.ts +49 -0
  9. package/dist/Form.d.ts.map +1 -0
  10. package/dist/IconButton.d.ts.map +1 -1
  11. package/dist/SearchInput.d.ts.map +1 -1
  12. package/dist/TextInput.d.ts.map +1 -1
  13. package/dist/Textarea.d.ts.map +1 -1
  14. package/dist/eslint/index.d.ts +1 -1
  15. package/dist/eslint/index.d.ts.map +1 -1
  16. package/dist/eslint/index.js +88 -8
  17. package/dist/eslint/index.js.map +4 -4
  18. package/dist/eslint/no-raw-element.d.ts +88 -0
  19. package/dist/eslint/no-raw-element.d.ts.map +1 -0
  20. package/dist/formBusy.d.ts +15 -0
  21. package/dist/formBusy.d.ts.map +1 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +479 -350
  25. package/dist/index.js.map +4 -4
  26. package/package.json +1 -1
  27. package/src/Button.tsx +4 -1
  28. package/src/Checkbox.mdx +19 -1
  29. package/src/Checkbox.stories.tsx +12 -0
  30. package/src/Checkbox.test.tsx +54 -0
  31. package/src/Checkbox.tsx +40 -3
  32. package/src/CommandPalette.mdx +6 -2
  33. package/src/CommandPalette.stories.tsx +6 -3
  34. package/src/CommandPalette.test.tsx +15 -0
  35. package/src/CommandPalette.tsx +34 -46
  36. package/src/FilePicker.mdx +49 -0
  37. package/src/FilePicker.stories.tsx +61 -0
  38. package/src/FilePicker.test.tsx +79 -0
  39. package/src/FilePicker.tsx +40 -0
  40. package/src/Form.mdx +78 -0
  41. package/src/Form.stories.tsx +68 -0
  42. package/src/Form.test.tsx +385 -0
  43. package/src/Form.tsx +173 -0
  44. package/src/IconButton.tsx +4 -1
  45. package/src/SearchInput.mdx +2 -2
  46. package/src/SearchInput.tsx +3 -1
  47. package/src/TextInput.mdx +2 -2
  48. package/src/TextInput.test.tsx +7 -0
  49. package/src/TextInput.tsx +4 -1
  50. package/src/Textarea.tsx +4 -1
  51. package/src/eslint/index.test.ts +24 -15
  52. package/src/eslint/index.ts +6 -5
  53. package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
  54. package/src/eslint/no-raw-element.ts +180 -0
  55. package/src/formBusy.ts +19 -0
  56. package/src/index.ts +2 -0
  57. package/stories/Choosing.mdx +3 -0
  58. package/dist/eslint/no-raw-button.d.ts +0 -11
  59. package/dist/eslint/no-raw-button.d.ts.map +0 -1
  60. package/src/eslint/no-raw-button.ts +0 -39
package/src/Form.mdx ADDED
@@ -0,0 +1,78 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as FormStories from './Form.stories'
3
+
4
+ <Meta of={FormStories} />
5
+
6
+ # Form
7
+
8
+ The fields and the button that sends them. Enter in a field, or a submit
9
+ button, sends it; the page never reloads; while it sends, everything inside
10
+ is switched off at once.
11
+
12
+ <Canvas of={FormStories.WhileSending} />
13
+
14
+ ## When
15
+
16
+ - Any set of fields that is sent: a dialog's fields, a one-line field with
17
+ its button, a composer.
18
+ - **`busy`** while the sending is waited on.
19
+
20
+ ## When not
21
+
22
+ - One field that saves itself as you leave it → **EditableText**.
23
+ - A field that filters a list as you type → **SearchInput**; nothing is sent.
24
+
25
+ ## How
26
+
27
+ ```tsx
28
+ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
29
+
30
+ <Form onSubmit={send} busy={sending} className="flex flex-col gap-6">
31
+ <Field label="Title" required error={titleError}>
32
+ <TextInput value={title} onChange={(e) => { setTitle(e.target.value); setTitleError(undefined) }} />
33
+ </Field>
34
+ <Button variant="primary" type="submit">Send</Button>
35
+ </Form>
36
+ ```
37
+
38
+ - **`onSubmit` takes nothing.** The page's own submit is already prevented;
39
+ there is no event to stop. Every way of sending — Enter, Ctrl+Enter, a
40
+ submit button — arrives here the same way, after the same field check.
41
+ - **`enterSends={false}`** keeps Enter in a one-line field from sending, where
42
+ only Ctrl+Enter may: a form inside **CommandPalette**, which uses it.
43
+ - **`busy` switches off every field and button inside**, so they do not
44
+ each need `disabled={busy}`. A button outside the form that sends it —
45
+ a dialog's footer, with `form="<id>"` and the form's `id` — is outside,
46
+ and keeps its own `disabled`.
47
+ - **Focus waits on the form while it is busy.** A browser drops focus to the
48
+ page the moment the focused field is switched off; the form holds it
49
+ instead. When `busy` ends, focus goes to the first field showing an error,
50
+ else back to what sent the form, else to the first control. If focus was
51
+ moved somewhere else while it waited, it stays there.
52
+ - **A field showing its `error` stops the form from sending**, and Enter moves
53
+ focus to that field instead (Base UI checks every field before it sends).
54
+ Clear the error when the field changes, as in the example; an error left
55
+ standing keeps the form from ever sending.
56
+ - The browser's own validation bubbles are off (`noValidate`). `Field`'s
57
+ `required` marks a field; it does not block sending by itself.
58
+ - `className` places the fields: the form draws no box, and neither does the
59
+ `<fieldset>` inside it.
60
+
61
+ ## Keys
62
+
63
+ The same in every form (Katerina, 16 September).
64
+
65
+ | Key | Does |
66
+ |---|---|
67
+ | Enter, in a one-line field | Sends the form — with or without a submit button, however many fields. Not with `enterSends={false}`. |
68
+ | Shift+Enter or Alt+Enter, in a one-line field | Nothing — the browser would send the form on its own; the form stops it. |
69
+ | Enter, in a Textarea | A new line; it does not send. |
70
+ | Enter, in a list or a people picker | Picks; it does not send. |
71
+ | Enter, in a field that handles Enter itself | What the field does; the form does not send. |
72
+ | Ctrl+Enter (Cmd+Enter), anywhere inside | Sends the form. |
73
+ | Any of these, while busy | Nothing: a form that is sending does not send again. |
74
+ | Tab, while busy | Nothing inside takes focus. |
75
+
76
+ ## Props
77
+
78
+ <Controls of={FormStories.Default} />
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { fn } from 'storybook/test'
4
+ import { Button } from './Button'
5
+ import { Field } from './Field'
6
+ import { Form } from './Form'
7
+ import { TextInput } from './TextInput'
8
+ import { Textarea } from './Textarea'
9
+
10
+ const meta = {
11
+ title: 'Inputs/Form',
12
+ component: Form,
13
+ parameters: { layout: 'padded' },
14
+ args: { onSubmit: fn(), busy: false, className: 'flex flex-col gap-6', 'aria-label': 'Example', children: null },
15
+ argTypes: { children: { control: false }, onSubmit: { control: false } },
16
+ decorators: [(Story) => <div className="w-96"><Story /></div>],
17
+ } satisfies Meta<typeof Form>
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof meta>
21
+
22
+ const fields = (error?: string) => (
23
+ <>
24
+ <Field label="Label" required>
25
+ <TextInput placeholder="Placeholder" />
26
+ </Field>
27
+ <Field label="Label" error={error}>
28
+ <Textarea placeholder="Placeholder" className="h-20" />
29
+ </Field>
30
+ <div className="flex justify-end">
31
+ <Button variant="primary" type="submit">
32
+ Send
33
+ </Button>
34
+ </div>
35
+ </>
36
+ )
37
+
38
+ export const Default: Story = { render: (args) => <Form {...args}>{fields()}</Form> }
39
+
40
+ /** Sending: every field and button inside is switched off at once. */
41
+ export const Busy: Story = { args: { busy: true }, render: (args) => <Form {...args}>{fields()}</Form> }
42
+
43
+ /** A field showing its error. The form does not send while it shows. */
44
+ export const WithError: Story = { render: (args) => <Form {...args}>{fields('That is not a valid value.')}</Form> }
45
+
46
+ /**
47
+ * Live. Press Enter in the first field, or Send: the form is busy for a
48
+ * second and a half, and focus comes back to where it was.
49
+ */
50
+ export const WhileSending: Story = {
51
+ parameters: { controls: { disable: true } },
52
+ render: (args) => {
53
+ const [busy, setBusy] = useState(false)
54
+ return (
55
+ <Form
56
+ {...args}
57
+ busy={busy}
58
+ onSubmit={async () => {
59
+ setBusy(true)
60
+ await new Promise((resolve) => setTimeout(resolve, 1500))
61
+ setBusy(false)
62
+ }}
63
+ >
64
+ {fields()}
65
+ </Form>
66
+ )
67
+ },
68
+ }
@@ -0,0 +1,385 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Form page claims, pinned: Enter and a submit button send it, the
4
+ * page's own submit is prevented, `busy` switches off everything inside and
5
+ * holds focus, and focus comes back in the page's order.
6
+ *
7
+ * jsdom does not move focus off a field that becomes disabled; Chrome drops it
8
+ * to `<body>`. So "focus is held" is checked as "focus is on the form, and not
9
+ * on a disabled element" — without the form taking focus, that fails here too
10
+ * (UIG-29 measured it). The browser check is in the PR.
11
+ */
12
+ import { useState } from 'react'
13
+ import { afterEach, describe, expect, it, vi } from 'vitest'
14
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
15
+ import userEvent from '@testing-library/user-event'
16
+ import { Button } from './Button'
17
+ import { Checkbox } from './Checkbox'
18
+ import { IconButton } from './IconButton'
19
+ import { Field } from './Field'
20
+ import { Form } from './Form'
21
+ import { TextInput } from './TextInput'
22
+ import { Textarea } from './Textarea'
23
+
24
+ afterEach(cleanup)
25
+
26
+ function Harness({ onSubmit = vi.fn(), error, busy = false }: { onSubmit?: () => void; error?: string; busy?: boolean }) {
27
+ return (
28
+ <Form onSubmit={onSubmit} busy={busy} aria-label="Probe" className="flex flex-col gap-6" id="probe">
29
+ <Field label="First">
30
+ <TextInput />
31
+ </Field>
32
+ <Field label="Second" error={error}>
33
+ <TextInput />
34
+ </Field>
35
+ <Button type="submit">Send</Button>
36
+ </Form>
37
+ )
38
+ }
39
+
40
+ describe('Form', () => {
41
+ it('sends on Enter in a field', async () => {
42
+ const user = userEvent.setup()
43
+ const onSubmit = vi.fn()
44
+ render(<Harness onSubmit={onSubmit} />)
45
+ await user.type(screen.getByRole('textbox', { name: 'First' }), 'x{Enter}')
46
+ expect(onSubmit).toHaveBeenCalledTimes(1)
47
+ })
48
+
49
+ it('sends from its submit button', async () => {
50
+ const user = userEvent.setup()
51
+ const onSubmit = vi.fn()
52
+ render(<Harness onSubmit={onSubmit} />)
53
+ await user.click(screen.getByRole('button', { name: 'Send' }))
54
+ expect(onSubmit).toHaveBeenCalledTimes(1)
55
+ })
56
+
57
+ describe('the keys, the same in every form', () => {
58
+ function Keys({ onSubmit, enterSends }: { onSubmit: () => void; enterSends?: boolean }) {
59
+ return (
60
+ <Form onSubmit={onSubmit} enterSends={enterSends} aria-label="Probe">
61
+ <Field label="One">
62
+ <TextInput />
63
+ </Field>
64
+ <Field label="Two">
65
+ <TextInput />
66
+ </Field>
67
+ <Field label="Words">
68
+ <Textarea />
69
+ </Field>
70
+ <input role="combobox" aria-label="Pick" aria-expanded="false" aria-controls="none" />
71
+ </Form>
72
+ )
73
+ }
74
+
75
+ it('Enter in a one-line field sends, even with two fields and no submit button', async () => {
76
+ const user = userEvent.setup()
77
+ const onSubmit = vi.fn()
78
+ render(<Keys onSubmit={onSubmit} />)
79
+ await user.type(screen.getByRole('textbox', { name: 'Two' }), 'x{Enter}')
80
+ expect(onSubmit).toHaveBeenCalledTimes(1)
81
+ })
82
+
83
+ it('Enter in a text area is a new line; Ctrl+Enter and Cmd+Enter there send', async () => {
84
+ const user = userEvent.setup()
85
+ const onSubmit = vi.fn()
86
+ render(<Keys onSubmit={onSubmit} />)
87
+ const words = screen.getByRole('textbox', { name: 'Words' }) as HTMLTextAreaElement
88
+ await user.type(words, 'a{Enter}b')
89
+ expect(words.value).toBe('a\nb')
90
+ expect(onSubmit).not.toHaveBeenCalled()
91
+ await user.keyboard('{Control>}{Enter}{/Control}')
92
+ expect(onSubmit).toHaveBeenCalledTimes(1)
93
+ await user.keyboard('{Meta>}{Enter}{/Meta}')
94
+ expect(onSubmit).toHaveBeenCalledTimes(2)
95
+ })
96
+
97
+ it("Enter in a picker's input does not send", async () => {
98
+ const user = userEvent.setup()
99
+ const onSubmit = vi.fn()
100
+ render(<Keys onSubmit={onSubmit} />)
101
+ await user.type(screen.getByRole('combobox', { name: 'Pick' }), 'x{Enter}')
102
+ expect(onSubmit).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('a field that handles its own Enter keeps it', async () => {
106
+ const user = userEvent.setup()
107
+ const onSubmit = vi.fn()
108
+ render(
109
+ <Form onSubmit={onSubmit} aria-label="Probe">
110
+ <TextInput aria-label="Own" onKeyDown={(event) => event.key === 'Enter' && event.preventDefault()} />
111
+ </Form>,
112
+ )
113
+ await user.type(screen.getByRole('textbox', { name: 'Own' }), 'x{Enter}')
114
+ expect(onSubmit).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('with enterSends off, Enter in a one-line field does nothing and Ctrl+Enter sends', async () => {
118
+ const user = userEvent.setup()
119
+ const onSubmit = vi.fn()
120
+ render(<Keys onSubmit={onSubmit} enterSends={false} />)
121
+ const one = screen.getByRole('textbox', { name: 'One' }) as HTMLInputElement
122
+ await user.type(one, 'x{Enter}')
123
+ expect(onSubmit).not.toHaveBeenCalled()
124
+ expect(one.value).toBe('x')
125
+ await user.keyboard('{Control>}{Enter}{/Control}')
126
+ expect(onSubmit).toHaveBeenCalledTimes(1)
127
+ })
128
+
129
+ it('Shift+Enter and Alt+Enter in a one-line field send nothing, even with a submit button', async () => {
130
+ const onSubmit = vi.fn()
131
+ render(<Harness onSubmit={onSubmit} />)
132
+ const field = screen.getByRole('textbox', { name: 'First' })
133
+ // The browser's implicit submission is the default action of this keydown: prevented, it does not happen.
134
+ for (const modifier of [{ shiftKey: true }, { altKey: true }]) {
135
+ expect(fireEvent.keyDown(field, { key: 'Enter', ...modifier })).toBe(false)
136
+ }
137
+ expect(onSubmit).not.toHaveBeenCalled()
138
+ })
139
+
140
+ it('Enter with a submit button in the form sends once, not twice', async () => {
141
+ const user = userEvent.setup()
142
+ const onSubmit = vi.fn()
143
+ render(<Harness onSubmit={onSubmit} />)
144
+ await user.type(screen.getByRole('textbox', { name: 'First' }), 'x{Enter}')
145
+ expect(onSubmit).toHaveBeenCalledTimes(1)
146
+ })
147
+
148
+ it('does not send again while busy', async () => {
149
+ const onSubmit = vi.fn()
150
+ const { rerender } = render(<Harness onSubmit={onSubmit} />)
151
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
152
+ rerender(<Harness onSubmit={onSubmit} busy />)
153
+ const form = screen.getByRole('form', { name: 'Probe' })
154
+ fireEvent.keyDown(form, { key: 'Enter', ctrlKey: true })
155
+ fireEvent.submit(form)
156
+ expect(onSubmit).not.toHaveBeenCalled()
157
+ })
158
+ })
159
+
160
+ it('does not send while a field shows its error', async () => {
161
+ const user = userEvent.setup()
162
+ const onSubmit = vi.fn()
163
+ render(<Harness onSubmit={onSubmit} error="Not like that." />)
164
+ await user.type(screen.getByRole('textbox', { name: 'First' }), 'x{Enter}')
165
+ expect(onSubmit).not.toHaveBeenCalled()
166
+ })
167
+
168
+ it('sends on Enter in a one-line field, and not in a textarea', async () => {
169
+ const user = userEvent.setup()
170
+ const onSubmit = vi.fn()
171
+ render(
172
+ <Form onSubmit={onSubmit} aria-label="Probe">
173
+ <Field label="Words">
174
+ <Textarea />
175
+ </Field>
176
+ </Form>,
177
+ )
178
+ await user.type(screen.getByRole('textbox', { name: 'Words' }), 'one{Enter}two')
179
+ expect(onSubmit).not.toHaveBeenCalled()
180
+ expect((screen.getByRole('textbox', { name: 'Words' }) as HTMLTextAreaElement).value).toBe('one\ntwo')
181
+ })
182
+
183
+ it("prevents the page's own submit, so nothing reloads", () => {
184
+ render(<Harness />)
185
+ const form = screen.getByRole('form', { name: 'Probe' })
186
+ // dispatchEvent answers false when the event's default was prevented.
187
+ expect(fireEvent.submit(form)).toBe(false)
188
+ })
189
+
190
+ it('is a form with no browser validation bubbles, and passes id and className on', () => {
191
+ render(<Harness />)
192
+ const form = screen.getByRole('form', { name: 'Probe' }) as HTMLFormElement
193
+ expect(form.tagName).toBe('FORM')
194
+ expect(form.noValidate).toBe(true)
195
+ expect(form.id).toBe('probe')
196
+ expect(form.className).toContain('flex-col')
197
+ })
198
+
199
+ it('draws no box of its own around the fields', () => {
200
+ const { container } = render(<Harness />)
201
+ expect(container.querySelector('fieldset')?.className).toBe('contents')
202
+ })
203
+
204
+ it('switches off every field and button inside while busy, and back on after', () => {
205
+ const { rerender } = render(<Harness />)
206
+ const controls = () => [...screen.getAllByRole('textbox'), screen.getByRole('button', { name: 'Send' })]
207
+ expect(controls().every((c) => !c.matches(':disabled'))).toBe(true)
208
+ rerender(<Harness busy />)
209
+ expect(controls().every((c) => c.matches(':disabled'))).toBe(true)
210
+ rerender(<Harness />)
211
+ expect(controls().every((c) => !c.matches(':disabled'))).toBe(true)
212
+ })
213
+
214
+ it("switches the package's own parts off in their own look, not only the browser's way", async () => {
215
+ // A disabled part has pointer-events: none; the click is tried anyway, as a person would.
216
+ const user = userEvent.setup({ pointerEventsCheck: 0 })
217
+ const onTick = vi.fn()
218
+ const parts = (busy: boolean) => (
219
+ <Form onSubmit={() => {}} busy={busy} aria-label="Probe">
220
+ <Button type="submit">Send</Button>
221
+ <IconButton aria-label="Icon">x</IconButton>
222
+ <Checkbox checked={false} onChange={onTick} aria-label="Bare" />
223
+ <Checkbox checked={false} onChange={onTick} label="Words" />
224
+ </Form>
225
+ )
226
+ const { rerender, getByRole, getByText } = render(parts(false))
227
+ const drawnOff = () => ['Send', 'Icon'].map((name) => getByRole('button', { name }).hasAttribute('data-disabled'))
228
+ expect(drawnOff()).toEqual([false, false])
229
+ rerender(parts(true))
230
+ // Button and IconButton take their switched-off classes from Base UI's state, which data-disabled shows.
231
+ expect(drawnOff()).toEqual([true, true])
232
+ expect(getByRole('checkbox', { name: 'Bare' }).getAttribute('aria-disabled')).toBe('true')
233
+ // A checkbox is a span: the fieldset alone never stopped a click on it.
234
+ await user.click(getByRole('checkbox', { name: 'Bare' }))
235
+ await user.click(getByText('Words'))
236
+ expect(onTick).not.toHaveBeenCalled()
237
+ rerender(parts(false))
238
+ expect(drawnOff()).toEqual([false, false])
239
+ })
240
+
241
+ it('holds focus on the form while busy, never on a disabled field', () => {
242
+ const { rerender } = render(<Harness />)
243
+ const field = screen.getByRole('textbox', { name: 'Second' })
244
+ act(() => field.focus())
245
+ rerender(<Harness busy />)
246
+ expect(document.activeElement).toBe(screen.getByRole('form', { name: 'Probe' }))
247
+ expect(document.activeElement?.matches(':disabled')).toBe(false)
248
+ })
249
+
250
+ it('holds focus even when the browser already dropped it to the page, as Chrome does', () => {
251
+ const { rerender } = render(<Harness />)
252
+ const field = screen.getByRole('textbox', { name: 'Second' })
253
+ act(() => field.focus())
254
+ // Chrome blurs a field the moment it is disabled, before any effect runs;
255
+ // jsdom never does, so the test does it by hand.
256
+ act(() => field.blur())
257
+ rerender(<Harness busy />)
258
+ expect(document.activeElement).toBe(screen.getByRole('form', { name: 'Probe' }))
259
+ rerender(<Harness />)
260
+ expect(document.activeElement).toBe(field)
261
+ })
262
+
263
+ it('does not take focus when it was last somewhere outside the form', () => {
264
+ const { rerender } = render(
265
+ <>
266
+ <Harness />
267
+ <button type="button">Elsewhere</button>
268
+ </>,
269
+ )
270
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
271
+ act(() => screen.getByRole('button', { name: 'Elsewhere' }).focus())
272
+ act(() => screen.getByRole('button', { name: 'Elsewhere' }).blur())
273
+ rerender(
274
+ <>
275
+ <Harness busy />
276
+ <button type="button">Elsewhere</button>
277
+ </>,
278
+ )
279
+ expect(document.activeElement).toBe(document.body)
280
+ })
281
+
282
+ it('takes focus only while busy: the rest of the time it is not a Tab stop or a click target', () => {
283
+ const { rerender } = render(<Harness />)
284
+ const form = screen.getByRole('form', { name: 'Probe' })
285
+ expect(form.hasAttribute('tabindex')).toBe(false)
286
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
287
+ rerender(<Harness busy />)
288
+ expect(form.getAttribute('tabindex')).toBe('-1')
289
+ rerender(<Harness />)
290
+ expect(form.hasAttribute('tabindex')).toBe(false)
291
+ })
292
+
293
+ describe('when busy ends, focus goes', () => {
294
+ it('to the first invalid field', () => {
295
+ const { rerender } = render(<Harness />)
296
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
297
+ rerender(<Harness busy />)
298
+ rerender(<Harness error="Not like that." />)
299
+ expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Second' }))
300
+ })
301
+
302
+ it('else back to what sent the form', () => {
303
+ const { rerender } = render(<Harness />)
304
+ act(() => screen.getByRole('button', { name: 'Send' }).focus())
305
+ rerender(<Harness busy />)
306
+ rerender(<Harness />)
307
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Send' }))
308
+ })
309
+
310
+ it('else to the first control, when what sent it is gone', () => {
311
+ function Vanishing({ busy, sent }: { busy: boolean; sent: boolean }) {
312
+ return (
313
+ <Form onSubmit={() => {}} busy={busy} aria-label="Probe">
314
+ <Field label="First">
315
+ <TextInput />
316
+ </Field>
317
+ {!sent && <Button type="submit">Send</Button>}
318
+ </Form>
319
+ )
320
+ }
321
+ const { rerender } = render(<Vanishing busy={false} sent={false} />)
322
+ act(() => screen.getByRole('button', { name: 'Send' }).focus())
323
+ rerender(<Vanishing busy sent={false} />)
324
+ // Gone while the form waits, as a row that sent itself can be.
325
+ rerender(<Vanishing busy sent />)
326
+ rerender(<Vanishing busy={false} sent />)
327
+ expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'First' }))
328
+ })
329
+
330
+ it('nowhere, when someone moved focus on while waiting', () => {
331
+ const { rerender } = render(
332
+ <>
333
+ <Harness />
334
+ <button type="button">Elsewhere</button>
335
+ </>,
336
+ )
337
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
338
+ rerender(
339
+ <>
340
+ <Harness busy />
341
+ <button type="button">Elsewhere</button>
342
+ </>,
343
+ )
344
+ act(() => screen.getByRole('button', { name: 'Elsewhere' }).focus())
345
+ rerender(
346
+ <>
347
+ <Harness />
348
+ <button type="button">Elsewhere</button>
349
+ </>,
350
+ )
351
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Elsewhere' }))
352
+ })
353
+ })
354
+
355
+ it('a caller that waits: busy while sending, then back', async () => {
356
+ const user = userEvent.setup()
357
+ let finish: () => void = () => {}
358
+ function Sending() {
359
+ const [busy, setBusy] = useState(false)
360
+ return (
361
+ <Form
362
+ aria-label="Probe"
363
+ busy={busy}
364
+ onSubmit={async () => {
365
+ setBusy(true)
366
+ await new Promise<void>((resolve) => (finish = resolve))
367
+ setBusy(false)
368
+ }}
369
+ >
370
+ <Field label="First">
371
+ <TextInput />
372
+ </Field>
373
+ </Form>
374
+ )
375
+ }
376
+ render(<Sending />)
377
+ const field = screen.getByRole('textbox', { name: 'First' })
378
+ await user.type(field, 'x{Enter}')
379
+ expect(field.matches(':disabled')).toBe(true)
380
+ expect(document.activeElement).toBe(screen.getByRole('form', { name: 'Probe' }))
381
+ await act(async () => finish())
382
+ expect(field.matches(':disabled')).toBe(false)
383
+ expect(document.activeElement).toBe(field)
384
+ })
385
+ })