@estiva-app/ui 0.16.0 → 0.17.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 (61) 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 +8 -1
  6. package/dist/CommandPalette.d.ts.map +1 -1
  7. package/dist/FilePicker.d.ts +22 -0
  8. package/dist/FilePicker.d.ts.map +1 -0
  9. package/dist/Form.d.ts +34 -0
  10. package/dist/Form.d.ts.map +1 -0
  11. package/dist/IconButton.d.ts.map +1 -1
  12. package/dist/SearchInput.d.ts.map +1 -1
  13. package/dist/TextInput.d.ts.map +1 -1
  14. package/dist/Textarea.d.ts.map +1 -1
  15. package/dist/eslint/index.d.ts +1 -1
  16. package/dist/eslint/index.d.ts.map +1 -1
  17. package/dist/eslint/index.js +88 -8
  18. package/dist/eslint/index.js.map +4 -4
  19. package/dist/eslint/no-raw-element.d.ts +88 -0
  20. package/dist/eslint/no-raw-element.d.ts.map +1 -0
  21. package/dist/formBusy.d.ts +15 -0
  22. package/dist/formBusy.d.ts.map +1 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +363 -241
  26. package/dist/index.js.map +4 -4
  27. package/package.json +1 -1
  28. package/src/Button.tsx +4 -1
  29. package/src/Checkbox.mdx +19 -1
  30. package/src/Checkbox.stories.tsx +12 -0
  31. package/src/Checkbox.test.tsx +54 -0
  32. package/src/Checkbox.tsx +40 -3
  33. package/src/CommandPalette.mdx +11 -5
  34. package/src/CommandPalette.stories.tsx +3 -1
  35. package/src/CommandPalette.test.tsx +24 -1
  36. package/src/CommandPalette.tsx +23 -2
  37. package/src/FilePicker.mdx +49 -0
  38. package/src/FilePicker.stories.tsx +61 -0
  39. package/src/FilePicker.test.tsx +79 -0
  40. package/src/FilePicker.tsx +40 -0
  41. package/src/Form.mdx +68 -0
  42. package/src/Form.stories.tsx +68 -0
  43. package/src/Form.test.tsx +282 -0
  44. package/src/Form.tsx +113 -0
  45. package/src/IconButton.tsx +4 -1
  46. package/src/SearchInput.mdx +2 -2
  47. package/src/SearchInput.tsx +3 -1
  48. package/src/TextInput.mdx +2 -2
  49. package/src/TextInput.test.tsx +7 -0
  50. package/src/TextInput.tsx +4 -1
  51. package/src/Textarea.tsx +4 -1
  52. package/src/eslint/index.test.ts +24 -15
  53. package/src/eslint/index.ts +6 -5
  54. package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
  55. package/src/eslint/no-raw-element.ts +180 -0
  56. package/src/formBusy.ts +19 -0
  57. package/src/index.ts +2 -0
  58. package/stories/Choosing.mdx +3 -0
  59. package/dist/eslint/no-raw-button.d.ts +0 -11
  60. package/dist/eslint/no-raw-button.d.ts.map +0 -1
  61. package/src/eslint/no-raw-button.ts +0 -39
package/src/Form.mdx ADDED
@@ -0,0 +1,68 @@
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.
40
+ - **`busy` switches off every field and button inside**, so they do not
41
+ each need `disabled={busy}`. A button outside the form that sends it —
42
+ a dialog's footer, with `form="<id>"` and the form's `id` — is outside,
43
+ and keeps its own `disabled`.
44
+ - **Focus waits on the form while it is busy.** A browser drops focus to the
45
+ page the moment the focused field is switched off; the form holds it
46
+ instead. When `busy` ends, focus goes to the first field showing an error,
47
+ else back to what sent the form, else to the first control. If focus was
48
+ moved somewhere else while it waited, it stays there.
49
+ - **A field showing its `error` stops the form from sending**, and Enter moves
50
+ focus to that field instead (Base UI checks every field before it sends).
51
+ Clear the error when the field changes, as in the example; an error left
52
+ standing keeps the form from ever sending.
53
+ - The browser's own validation bubbles are off (`noValidate`). `Field`'s
54
+ `required` marks a field; it does not block sending by itself.
55
+ - `className` places the fields: the form draws no box, and neither does the
56
+ `<fieldset>` inside it.
57
+
58
+ ## Keys
59
+
60
+ | Key | Does |
61
+ |---|---|
62
+ | Enter, in a one-line field | Sends the form. |
63
+ | Enter, in a Textarea | A new line; it does not send. |
64
+ | Tab, while busy | Nothing inside takes focus. |
65
+
66
+ ## Props
67
+
68
+ <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,282 @@
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
+ it('does not send while a field shows its error', async () => {
58
+ const user = userEvent.setup()
59
+ const onSubmit = vi.fn()
60
+ render(<Harness onSubmit={onSubmit} error="Not like that." />)
61
+ await user.type(screen.getByRole('textbox', { name: 'First' }), 'x{Enter}')
62
+ expect(onSubmit).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('sends on Enter in a one-line field, and not in a textarea', async () => {
66
+ const user = userEvent.setup()
67
+ const onSubmit = vi.fn()
68
+ render(
69
+ <Form onSubmit={onSubmit} aria-label="Probe">
70
+ <Field label="Words">
71
+ <Textarea />
72
+ </Field>
73
+ </Form>,
74
+ )
75
+ await user.type(screen.getByRole('textbox', { name: 'Words' }), 'one{Enter}two')
76
+ expect(onSubmit).not.toHaveBeenCalled()
77
+ expect((screen.getByRole('textbox', { name: 'Words' }) as HTMLTextAreaElement).value).toBe('one\ntwo')
78
+ })
79
+
80
+ it("prevents the page's own submit, so nothing reloads", () => {
81
+ render(<Harness />)
82
+ const form = screen.getByRole('form', { name: 'Probe' })
83
+ // dispatchEvent answers false when the event's default was prevented.
84
+ expect(fireEvent.submit(form)).toBe(false)
85
+ })
86
+
87
+ it('is a form with no browser validation bubbles, and passes id and className on', () => {
88
+ render(<Harness />)
89
+ const form = screen.getByRole('form', { name: 'Probe' }) as HTMLFormElement
90
+ expect(form.tagName).toBe('FORM')
91
+ expect(form.noValidate).toBe(true)
92
+ expect(form.id).toBe('probe')
93
+ expect(form.className).toContain('flex-col')
94
+ })
95
+
96
+ it('draws no box of its own around the fields', () => {
97
+ const { container } = render(<Harness />)
98
+ expect(container.querySelector('fieldset')?.className).toBe('contents')
99
+ })
100
+
101
+ it('switches off every field and button inside while busy, and back on after', () => {
102
+ const { rerender } = render(<Harness />)
103
+ const controls = () => [...screen.getAllByRole('textbox'), screen.getByRole('button', { name: 'Send' })]
104
+ expect(controls().every((c) => !c.matches(':disabled'))).toBe(true)
105
+ rerender(<Harness busy />)
106
+ expect(controls().every((c) => c.matches(':disabled'))).toBe(true)
107
+ rerender(<Harness />)
108
+ expect(controls().every((c) => !c.matches(':disabled'))).toBe(true)
109
+ })
110
+
111
+ it("switches the package's own parts off in their own look, not only the browser's way", async () => {
112
+ // A disabled part has pointer-events: none; the click is tried anyway, as a person would.
113
+ const user = userEvent.setup({ pointerEventsCheck: 0 })
114
+ const onTick = vi.fn()
115
+ const parts = (busy: boolean) => (
116
+ <Form onSubmit={() => {}} busy={busy} aria-label="Probe">
117
+ <Button type="submit">Send</Button>
118
+ <IconButton aria-label="Icon">x</IconButton>
119
+ <Checkbox checked={false} onChange={onTick} aria-label="Bare" />
120
+ <Checkbox checked={false} onChange={onTick} label="Words" />
121
+ </Form>
122
+ )
123
+ const { rerender, getByRole, getByText } = render(parts(false))
124
+ const drawnOff = () => ['Send', 'Icon'].map((name) => getByRole('button', { name }).hasAttribute('data-disabled'))
125
+ expect(drawnOff()).toEqual([false, false])
126
+ rerender(parts(true))
127
+ // Button and IconButton take their switched-off classes from Base UI's state, which data-disabled shows.
128
+ expect(drawnOff()).toEqual([true, true])
129
+ expect(getByRole('checkbox', { name: 'Bare' }).getAttribute('aria-disabled')).toBe('true')
130
+ // A checkbox is a span: the fieldset alone never stopped a click on it.
131
+ await user.click(getByRole('checkbox', { name: 'Bare' }))
132
+ await user.click(getByText('Words'))
133
+ expect(onTick).not.toHaveBeenCalled()
134
+ rerender(parts(false))
135
+ expect(drawnOff()).toEqual([false, false])
136
+ })
137
+
138
+ it('holds focus on the form while busy, never on a disabled field', () => {
139
+ const { rerender } = render(<Harness />)
140
+ const field = screen.getByRole('textbox', { name: 'Second' })
141
+ act(() => field.focus())
142
+ rerender(<Harness busy />)
143
+ expect(document.activeElement).toBe(screen.getByRole('form', { name: 'Probe' }))
144
+ expect(document.activeElement?.matches(':disabled')).toBe(false)
145
+ })
146
+
147
+ it('holds focus even when the browser already dropped it to the page, as Chrome does', () => {
148
+ const { rerender } = render(<Harness />)
149
+ const field = screen.getByRole('textbox', { name: 'Second' })
150
+ act(() => field.focus())
151
+ // Chrome blurs a field the moment it is disabled, before any effect runs;
152
+ // jsdom never does, so the test does it by hand.
153
+ act(() => field.blur())
154
+ rerender(<Harness busy />)
155
+ expect(document.activeElement).toBe(screen.getByRole('form', { name: 'Probe' }))
156
+ rerender(<Harness />)
157
+ expect(document.activeElement).toBe(field)
158
+ })
159
+
160
+ it('does not take focus when it was last somewhere outside the form', () => {
161
+ const { rerender } = render(
162
+ <>
163
+ <Harness />
164
+ <button type="button">Elsewhere</button>
165
+ </>,
166
+ )
167
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
168
+ act(() => screen.getByRole('button', { name: 'Elsewhere' }).focus())
169
+ act(() => screen.getByRole('button', { name: 'Elsewhere' }).blur())
170
+ rerender(
171
+ <>
172
+ <Harness busy />
173
+ <button type="button">Elsewhere</button>
174
+ </>,
175
+ )
176
+ expect(document.activeElement).toBe(document.body)
177
+ })
178
+
179
+ it('takes focus only while busy: the rest of the time it is not a Tab stop or a click target', () => {
180
+ const { rerender } = render(<Harness />)
181
+ const form = screen.getByRole('form', { name: 'Probe' })
182
+ expect(form.hasAttribute('tabindex')).toBe(false)
183
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
184
+ rerender(<Harness busy />)
185
+ expect(form.getAttribute('tabindex')).toBe('-1')
186
+ rerender(<Harness />)
187
+ expect(form.hasAttribute('tabindex')).toBe(false)
188
+ })
189
+
190
+ describe('when busy ends, focus goes', () => {
191
+ it('to the first invalid field', () => {
192
+ const { rerender } = render(<Harness />)
193
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
194
+ rerender(<Harness busy />)
195
+ rerender(<Harness error="Not like that." />)
196
+ expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Second' }))
197
+ })
198
+
199
+ it('else back to what sent the form', () => {
200
+ const { rerender } = render(<Harness />)
201
+ act(() => screen.getByRole('button', { name: 'Send' }).focus())
202
+ rerender(<Harness busy />)
203
+ rerender(<Harness />)
204
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Send' }))
205
+ })
206
+
207
+ it('else to the first control, when what sent it is gone', () => {
208
+ function Vanishing({ busy, sent }: { busy: boolean; sent: boolean }) {
209
+ return (
210
+ <Form onSubmit={() => {}} busy={busy} aria-label="Probe">
211
+ <Field label="First">
212
+ <TextInput />
213
+ </Field>
214
+ {!sent && <Button type="submit">Send</Button>}
215
+ </Form>
216
+ )
217
+ }
218
+ const { rerender } = render(<Vanishing busy={false} sent={false} />)
219
+ act(() => screen.getByRole('button', { name: 'Send' }).focus())
220
+ rerender(<Vanishing busy sent={false} />)
221
+ // Gone while the form waits, as a row that sent itself can be.
222
+ rerender(<Vanishing busy sent />)
223
+ rerender(<Vanishing busy={false} sent />)
224
+ expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'First' }))
225
+ })
226
+
227
+ it('nowhere, when someone moved focus on while waiting', () => {
228
+ const { rerender } = render(
229
+ <>
230
+ <Harness />
231
+ <button type="button">Elsewhere</button>
232
+ </>,
233
+ )
234
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
235
+ rerender(
236
+ <>
237
+ <Harness busy />
238
+ <button type="button">Elsewhere</button>
239
+ </>,
240
+ )
241
+ act(() => screen.getByRole('button', { name: 'Elsewhere' }).focus())
242
+ rerender(
243
+ <>
244
+ <Harness />
245
+ <button type="button">Elsewhere</button>
246
+ </>,
247
+ )
248
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Elsewhere' }))
249
+ })
250
+ })
251
+
252
+ it('a caller that waits: busy while sending, then back', async () => {
253
+ const user = userEvent.setup()
254
+ let finish: () => void = () => {}
255
+ function Sending() {
256
+ const [busy, setBusy] = useState(false)
257
+ return (
258
+ <Form
259
+ aria-label="Probe"
260
+ busy={busy}
261
+ onSubmit={async () => {
262
+ setBusy(true)
263
+ await new Promise<void>((resolve) => (finish = resolve))
264
+ setBusy(false)
265
+ }}
266
+ >
267
+ <Field label="First">
268
+ <TextInput />
269
+ </Field>
270
+ </Form>
271
+ )
272
+ }
273
+ render(<Sending />)
274
+ const field = screen.getByRole('textbox', { name: 'First' })
275
+ await user.type(field, 'x{Enter}')
276
+ expect(field.matches(':disabled')).toBe(true)
277
+ expect(document.activeElement).toBe(screen.getByRole('form', { name: 'Probe' }))
278
+ await act(async () => finish())
279
+ expect(field.matches(':disabled')).toBe(false)
280
+ expect(document.activeElement).toBe(field)
281
+ })
282
+ })
package/src/Form.tsx ADDED
@@ -0,0 +1,113 @@
1
+ import { useLayoutEffect, useRef, type FormHTMLAttributes, type ReactNode } from 'react'
2
+ import { Fieldset } from '@base-ui/react/fieldset'
3
+ import { Form as BaseForm } from '@base-ui/react/form'
4
+ import { cn } from './cn'
5
+ import { FormBusyContext, useFormBusy } from './formBusy'
6
+
7
+ /**
8
+ * A form: Enter in a field, or a submit button, sends it, and the page never
9
+ * reloads. On Base UI's `Form` (UIG-7, 16 September), which the apps' five
10
+ * forms had each written around a plain `<form>`: `preventDefault`, a busy
11
+ * flag, and `disabled={busy}` on every field one by one.
12
+ *
13
+ * Base UI's part, as it renders it: a `<form noValidate>` — the browser's own
14
+ * validation bubbles are off, which no field in either app used — that, on
15
+ * submit, checks every `Field` inside and moves focus to the first one that is
16
+ * invalid instead of sending. `Field`'s `error` is still how an error shows.
17
+ *
18
+ * `busy` is ours. While it is on, the children sit in a disabled
19
+ * `<fieldset>` (Base UI's `Fieldset`, with no box of its own), so every field
20
+ * and button inside is switched off at once — the package's Button,
21
+ * IconButton and Checkbox wearing their own switched-off look, which a
22
+ * fieldset alone does not give them (`formBusy.ts`) — and focus waits on the form
23
+ * rather than falling to the page. Chrome drops focus to `<body>` the moment
24
+ * the focused field is disabled, before any effect runs, and jsdom does not —
25
+ * so the form remembers the last element inside it that had focus, and takes
26
+ * focus back from the page on its behalf (measured in Chrome, 16 September).
27
+ * When `busy` ends, focus goes to the first invalid field, else to what sent
28
+ * the form, else to the first control. That is `CommandPalette`'s order
29
+ * too, so the two read the same (UIG-29).
30
+ */
31
+ export interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'children' | 'noValidate'> {
32
+ /** Enter in a field, or a submit button. The page's own submit is already prevented. */
33
+ onSubmit: () => void | Promise<void>
34
+ /** While sending: every field and button inside is switched off, and focus waits on the form. */
35
+ busy?: boolean
36
+ children: ReactNode
37
+ }
38
+
39
+ const CONTROL = 'input:not([type="hidden"]), textarea, select, button, [role="checkbox"], [role="combobox"], [tabindex]:not([tabindex="-1"])'
40
+
41
+ function usable(element: Element | null): element is HTMLElement {
42
+ return element instanceof HTMLElement && element.isConnected && element.matches(CONTROL) && !element.matches(':disabled') && element.getAttribute('aria-disabled') !== 'true'
43
+ }
44
+
45
+ export function Form({ onSubmit, busy: ownBusy = false, className, children, ...props }: FormProps) {
46
+ // A form inside a busy form is busy too.
47
+ const outerBusy = useFormBusy()
48
+ const busy = ownBusy || outerBusy
49
+ const form = useRef<HTMLFormElement>(null)
50
+ // What had focus when the form went busy, and whether the form took focus from it.
51
+ const sender = useRef<Element | null>(null)
52
+ const holding = useRef(false)
53
+ // The last element inside the form that had focus, until focus moves to something outside it.
54
+ const lastInside = useRef<Element | null>(null)
55
+
56
+ useLayoutEffect(() => {
57
+ const element = form.current
58
+ if (!element) return
59
+ if (busy) {
60
+ const active = document.activeElement
61
+ // Focus is still inside (jsdom, or a field that stayed enabled), or the
62
+ // browser has already dropped it to the page from a field inside (Chrome).
63
+ const from =
64
+ active && active !== element && element.contains(active)
65
+ ? active
66
+ : !active || active === document.body
67
+ ? lastInside.current
68
+ : null
69
+ if (from?.isConnected) {
70
+ sender.current = from
71
+ holding.current = true
72
+ element.focus()
73
+ }
74
+ return
75
+ }
76
+ if (!holding.current) return
77
+ holding.current = false
78
+ // Someone who moved focus on while waiting keeps it where they put it.
79
+ if (document.activeElement !== element && document.activeElement !== document.body) return
80
+ const invalid = Array.from(element.querySelectorAll('[data-invalid]')).find(usable)
81
+ const target = invalid ?? (usable(sender.current) ? sender.current : Array.from(element.querySelectorAll(CONTROL)).find(usable))
82
+ sender.current = null
83
+ target?.focus()
84
+ }, [busy])
85
+
86
+ return (
87
+ <BaseForm
88
+ ref={form}
89
+ {...props}
90
+ // Focus waits here only while busy; the rest of the time a click between two fields must not land on the form.
91
+ tabIndex={busy ? -1 : props.tabIndex}
92
+ // A form that holds focus for a moment must not draw a focus ring.
93
+ className={cn('outline-none', className)}
94
+ onFocus={(event) => {
95
+ if (event.target !== form.current) lastInside.current = event.target
96
+ props.onFocus?.(event)
97
+ }}
98
+ onBlur={(event) => {
99
+ const next = event.relatedTarget
100
+ if (next && !form.current?.contains(next)) lastInside.current = null
101
+ props.onBlur?.(event)
102
+ }}
103
+ onSubmit={(event) => {
104
+ event.preventDefault()
105
+ void onSubmit()
106
+ }}
107
+ >
108
+ <Fieldset.Root disabled={busy} className="contents">
109
+ <FormBusyContext.Provider value={busy}>{children}</FormBusyContext.Provider>
110
+ </Fieldset.Root>
111
+ </BaseForm>
112
+ )
113
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ComponentPropsWithRef, ReactNode } from 'react'
2
2
  import { Button as BaseButton } from '@base-ui/react/button'
3
3
  import { cn } from './cn'
4
+ import { useFormBusy } from './formBusy'
4
5
  import { TooltipTrigger } from './Tooltip'
5
6
 
6
7
  /**
@@ -42,10 +43,12 @@ export function IconButton({
42
43
  type = 'button',
43
44
  ...props
44
45
  }: IconButtonProps) {
46
+ // Inside a busy Form: switched off, and looking it (formBusy.ts).
47
+ const formBusy = useFormBusy()
45
48
  const button = (
46
49
  <BaseButton
47
50
  type={type}
48
- disabled={disabled || !!disabledReason}
51
+ disabled={disabled || !!disabledReason || formBusy}
49
52
  focusableWhenDisabled={!!disabledReason}
50
53
  className={(state) =>
51
54
  cn(
@@ -5,8 +5,8 @@ import * as SearchInputStories from './SearchInput.stories'
5
5
 
6
6
  # SearchInput
7
7
 
8
- An inset field with a hairline border that strengthens on focus, and an
9
- optional keyboard hint at the right edge.
8
+ An inset field with a hairline border that strengthens on hover and on
9
+ focus, and an optional keyboard hint at the right edge.
10
10
 
11
11
  <Canvas of={SearchInputStories.WithShortcut} />
12
12
 
@@ -33,7 +33,9 @@ export function SearchInput({ shortcut, className, placeholder = 'Search…', ..
33
33
  className={cn(
34
34
  'flex gap-2 items-center px-3 py-2 rounded-lg',
35
35
  'bg-bg-inset border border-border-default',
36
- 'focus-within:border-border-strong transition-colors',
36
+ // Hover strengthens the border as it does on every other field (16 September). Here that
37
+ // is also the focus look, which was the stronger border before hover existed.
38
+ 'hover:border-border-strong focus-within:border-border-strong transition-colors',
37
39
  className,
38
40
  )}
39
41
  >
package/src/TextInput.mdx CHANGED
@@ -5,8 +5,8 @@ import * as TextInputStories from './TextInput.stories'
5
5
 
6
6
  # TextInput
7
7
 
8
- The single-line field: inset surface, 8px radius, 14px text, the focus
9
- border in every theme.
8
+ The single-line field: inset surface, 8px radius, 14px text, a stronger
9
+ border on hover, the focus border in every theme.
10
10
 
11
11
  <Canvas of={TextInputStories.Filled} />
12
12
 
@@ -35,4 +35,11 @@ describe('TextInput', () => {
35
35
  for (const c of ['px-3', 'py-2', 'text-input-value']) expect(classes).not.toContain(c)
36
36
  expect(input.hasAttribute('size')).toBe(false)
37
37
  })
38
+
39
+ it("a stronger border on hover, as Select's, and the focus border still wins while focused", () => {
40
+ render(<TextInput aria-label="Title" />)
41
+ const classes = screen.getByRole('textbox').className.split(' ')
42
+ // Both survive cn(); Tailwind emits focus after hover, so the order in CSS decides, not here.
43
+ for (const c of ['border-border-default', 'hover:border-border-strong', 'focus:border-border-focus']) expect(classes).toContain(c)
44
+ })
38
45
  })
package/src/TextInput.tsx CHANGED
@@ -32,7 +32,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
32
32
  ref={ref}
33
33
  type={type}
34
34
  className={cn(
35
- 'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg',
35
+ // The border strengthens on hover, as Select's and ChipInput's do (Katerina, 16 September:
36
+ // "aren't there hover states in text input and text area?"). Focus comes after hover in
37
+ // Tailwind's order, so a focused field keeps the focus border under the pointer.
38
+ 'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg',
36
39
  // The small size is the small Select's trigger, class for class.
37
40
  size === 'default' && 'px-3 py-2 text-input-value',
38
41
  size === 'small' && 'h-6 px-2 text-caption',
package/src/Textarea.tsx CHANGED
@@ -21,7 +21,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
21
21
  ref={ref as React.Ref<HTMLElement>}
22
22
  render={<textarea />}
23
23
  className={cn(
24
- 'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
24
+ // The border strengthens on hover, as Select's and ChipInput's do (Katerina, 16 September:
25
+ // "aren't there hover states in text input and text area?"). Focus comes after hover in
26
+ // Tailwind's order, so a focused field keeps the focus border under the pointer.
27
+ 'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg px-3 py-2',
25
28
  'text-input-value text-text-primary placeholder:text-text-muted',
26
29
  'resize-none outline-none transition-colors',
27
30
  'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',