@estiva-app/ui 0.16.1 → 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/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 +356 -239
- 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/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
|
@@ -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',
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from '
|
|
|
7
7
|
/**
|
|
8
8
|
* The plugin as an app uses it: a flat config with `configs.recommended`,
|
|
9
9
|
* linting text the way Peek's editor hook does (`lintText`). The rule's own
|
|
10
|
-
* cases are in no-raw-
|
|
10
|
+
* cases are in no-raw-element.test.ts.
|
|
11
11
|
*/
|
|
12
12
|
const tsx: Linter.Config = {
|
|
13
13
|
files: ['**/*.tsx'],
|
|
@@ -34,7 +34,7 @@ describe('the plugin object', () => {
|
|
|
34
34
|
|
|
35
35
|
it('carries every rule, the app ones and the inward ones', () => {
|
|
36
36
|
expect(Object.keys(estiva.rules)).toEqual([
|
|
37
|
-
'no-raw-
|
|
37
|
+
'no-raw-element',
|
|
38
38
|
'raw-element-outside-a-wrapper',
|
|
39
39
|
'no-hand-rolled-behaviour',
|
|
40
40
|
'component-has-a-page',
|
|
@@ -44,17 +44,19 @@ describe('the plugin object', () => {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* The apps spread `recommended`. A rule added for the package (UIG-5) must not
|
|
47
|
-
* arrive in Peek or Ship with the next version bump: an app
|
|
48
|
-
*
|
|
47
|
+
* arrive in Peek or Ship with the next version bump: an app's components are
|
|
48
|
+
* not primitives, so "buried inside a component" means nothing there
|
|
49
|
+
* (`no-raw-element` is the app's version, UIG-7), and an app has no `.mdx`
|
|
50
|
+
* pages at all. This is
|
|
49
51
|
* the test that holds that line — if you add an app-facing rule on purpose,
|
|
50
52
|
* change it deliberately, here.
|
|
51
53
|
*/
|
|
52
54
|
it('gives an app only the app rules, as errors, under estiva/', () => {
|
|
53
55
|
for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
|
|
54
56
|
expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
55
|
-
expect(config.rules).toEqual({ 'estiva/no-raw-
|
|
57
|
+
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error' })
|
|
56
58
|
}
|
|
57
|
-
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-
|
|
59
|
+
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element'])
|
|
58
60
|
})
|
|
59
61
|
|
|
60
62
|
it('gives this package its own set, as errors, and it reaches no app config', () => {
|
|
@@ -77,7 +79,14 @@ describe('an app lint with configs.recommended', () => {
|
|
|
77
79
|
it('reports a raw <button>, naming Button', async () => {
|
|
78
80
|
const [result] = await lint(component(' <button type="button">x</button>'))
|
|
79
81
|
expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
|
|
80
|
-
['estiva/no-raw-
|
|
82
|
+
['estiva/no-raw-element', 2, 'Use `Button` from @estiva-app/ui instead of a raw <button>.'],
|
|
83
|
+
])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('reports a raw <a>, naming Link', async () => {
|
|
87
|
+
const [result] = await lint(component(' <a href="/x">x</a>'))
|
|
88
|
+
expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
|
|
89
|
+
['estiva/no-raw-element', 2, 'Use `Link` from @estiva-app/ui instead of a raw <a>. For a chip, `InlineChip`; for a whole card, `Card` with `href`.'],
|
|
81
90
|
])
|
|
82
91
|
})
|
|
83
92
|
|
|
@@ -90,25 +99,25 @@ describe('an app lint with configs.recommended', () => {
|
|
|
90
99
|
describe('countGates', () => {
|
|
91
100
|
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
92
101
|
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
93
|
-
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-
|
|
94
|
-
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-
|
|
102
|
+
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 } })
|
|
103
|
+
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 } })
|
|
95
104
|
})
|
|
96
105
|
|
|
97
106
|
it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
|
|
98
|
-
const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-
|
|
107
|
+
const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-element\n <button>x</button>'), [countMode])
|
|
99
108
|
const count = countGates(results)
|
|
100
|
-
expect(count.rules['estiva/no-raw-
|
|
101
|
-
expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-
|
|
109
|
+
expect(count.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 0 })
|
|
110
|
+
expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-element' }])
|
|
102
111
|
})
|
|
103
112
|
|
|
104
113
|
it('counts a marker inside that directive as an error of the rule', async () => {
|
|
105
|
-
const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-
|
|
114
|
+
const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-element -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'), [countMode])
|
|
106
115
|
const count = countGates(results)
|
|
107
|
-
expect(count.rules['estiva/no-raw-
|
|
116
|
+
expect(count.rules['estiva/no-raw-element']).toEqual({ errors: 1, warnings: 0, escapes: 0 })
|
|
108
117
|
expect(count.disabled).toHaveLength(1)
|
|
109
118
|
})
|
|
110
119
|
|
|
111
120
|
it('lists every rule of the plugin, even with nothing found', () => {
|
|
112
|
-
expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-
|
|
121
|
+
expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
|
|
113
122
|
})
|
|
114
123
|
})
|
package/src/eslint/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { createRequire } from 'node:module'
|
|
|
20
20
|
import type { ESLint, Linter } from 'eslint'
|
|
21
21
|
import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
|
|
22
22
|
import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
|
|
23
|
-
import {
|
|
23
|
+
import { noRawElement } from './no-raw-element'
|
|
24
24
|
import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
|
|
25
25
|
|
|
26
26
|
export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
|
|
@@ -35,7 +35,7 @@ export const PLUGIN_KEY = 'estiva'
|
|
|
35
35
|
* already has. `recommended` and `strict` carry these and only these.
|
|
36
36
|
*/
|
|
37
37
|
const appRules = {
|
|
38
|
-
'no-raw-
|
|
38
|
+
'no-raw-element': noRawElement,
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -45,8 +45,9 @@ const appRules = {
|
|
|
45
45
|
*
|
|
46
46
|
* They are in `configs.package`, never in `recommended`, on purpose. Peek and
|
|
47
47
|
* Ship spread `recommended`, so a rule added here must not arrive in an app
|
|
48
|
-
* with the next version bump: an app
|
|
49
|
-
*
|
|
48
|
+
* with the next version bump: an app's components are not primitives, so
|
|
49
|
+
* "buried inside a component" means nothing there (`no-raw-element` is the
|
|
50
|
+
* app's version, UIG-7), and an app has no `.mdx` pages at all. `index.test.ts` holds the
|
|
50
51
|
* apps' list to exactly the app rules.
|
|
51
52
|
*/
|
|
52
53
|
const packageRules = {
|
|
@@ -80,7 +81,7 @@ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGI
|
|
|
80
81
|
plugin.configs.recommended = {
|
|
81
82
|
name: '@estiva-app/ui/recommended',
|
|
82
83
|
plugins: { [PLUGIN_KEY]: plugin },
|
|
83
|
-
rules: { [`${PLUGIN_KEY}/no-raw-
|
|
84
|
+
rules: { [`${PLUGIN_KEY}/no-raw-element`]: 'error' },
|
|
84
85
|
}
|
|
85
86
|
plugin.configs.strict = {
|
|
86
87
|
name: '@estiva-app/ui/strict',
|