@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.
- package/README.md +3 -1
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +14 -1
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts +8 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/FilePicker.d.ts +22 -0
- package/dist/FilePicker.d.ts.map +1 -0
- package/dist/Form.d.ts +34 -0
- package/dist/Form.d.ts.map +1 -0
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +1 -1
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +88 -8
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-raw-element.d.ts +88 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -0
- package/dist/formBusy.d.ts +15 -0
- package/dist/formBusy.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +363 -241
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Button.tsx +4 -1
- package/src/Checkbox.mdx +19 -1
- package/src/Checkbox.stories.tsx +12 -0
- package/src/Checkbox.test.tsx +54 -0
- package/src/Checkbox.tsx +40 -3
- package/src/CommandPalette.mdx +11 -5
- package/src/CommandPalette.stories.tsx +3 -1
- package/src/CommandPalette.test.tsx +24 -1
- package/src/CommandPalette.tsx +23 -2
- package/src/FilePicker.mdx +49 -0
- package/src/FilePicker.stories.tsx +61 -0
- package/src/FilePicker.test.tsx +79 -0
- package/src/FilePicker.tsx +40 -0
- package/src/Form.mdx +68 -0
- package/src/Form.stories.tsx +68 -0
- package/src/Form.test.tsx +282 -0
- package/src/Form.tsx +113 -0
- package/src/IconButton.tsx +4 -1
- package/src/SearchInput.mdx +2 -2
- package/src/SearchInput.tsx +3 -1
- package/src/TextInput.mdx +2 -2
- package/src/TextInput.test.tsx +7 -0
- package/src/TextInput.tsx +4 -1
- package/src/Textarea.tsx +4 -1
- package/src/eslint/index.test.ts +24 -15
- package/src/eslint/index.ts +6 -5
- package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
- package/src/eslint/no-raw-element.ts +180 -0
- package/src/formBusy.ts +19 -0
- package/src/index.ts +2 -0
- package/stories/Choosing.mdx +3 -0
- package/dist/eslint/no-raw-button.d.ts +0 -11
- package/dist/eslint/no-raw-button.d.ts.map +0 -1
- 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
|
+
}
|
package/src/IconButton.tsx
CHANGED
|
@@ -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(
|
package/src/SearchInput.mdx
CHANGED
|
@@ -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
|
|
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
|
|
package/src/SearchInput.tsx
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
package/src/TextInput.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|