@estiva-app/ui 0.17.0 → 0.19.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 +13 -5
- package/dist/Checkbox.d.ts +16 -1
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/Form.d.ts +16 -1
- package/dist/Form.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts +6 -0
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +459 -2
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-rebuilt-behaviour.d.ts +88 -0
- package/dist/eslint/no-rebuilt-behaviour.d.ts.map +1 -0
- package/dist/index.js +208 -188
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Checkbox.mdx +20 -1
- package/src/Checkbox.stories.tsx +44 -0
- package/src/Checkbox.test.tsx +63 -0
- package/src/Checkbox.tsx +30 -1
- package/src/CommandPalette.mdx +6 -2
- package/src/CommandPalette.stories.tsx +6 -3
- package/src/CommandPalette.test.tsx +15 -0
- package/src/CommandPalette.tsx +34 -46
- package/src/Form.mdx +12 -2
- package/src/Form.test.tsx +103 -0
- package/src/Form.tsx +64 -4
- package/src/ScrollArea.stories.tsx +18 -0
- package/src/ScrollArea.tsx +7 -1
- package/src/eslint/index.test.ts +31 -7
- package/src/eslint/index.ts +10 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +276 -0
- package/src/eslint/no-rebuilt-behaviour.ts +593 -0
- package/stories/Choosing.mdx +1 -1
package/package.json
CHANGED
package/src/Checkbox.mdx
CHANGED
|
@@ -27,9 +27,18 @@ the parent owns the state.
|
|
|
27
27
|
|
|
28
28
|
<Canvas of={CheckboxStories.WithLabel} />
|
|
29
29
|
|
|
30
|
+
- A list you tick several from, where the whole row is the target: pass
|
|
31
|
+
**`row`**, with `label` and, if the row has one, a `leading` picture. The
|
|
32
|
+
box sits at the end, and the row fills on hover and while checked.
|
|
33
|
+
|
|
34
|
+
<Canvas of={CheckboxStories.Row} />
|
|
35
|
+
|
|
30
36
|
## When not
|
|
31
37
|
|
|
32
38
|
- One choice out of several → **Select** (a value) or **Tabs** (a view).
|
|
39
|
+
- A row drawn by hand with `role="option"` and a Checkbox picture inside it: it
|
|
40
|
+
tells a screen reader "an option in a list" where there is no list. That is
|
|
41
|
+
`row`.
|
|
33
42
|
- Inside a **Menu** — a checkbox inside a `menuitem` is invalid HTML;
|
|
34
43
|
a checkable list lives in a DialogShell.
|
|
35
44
|
|
|
@@ -56,6 +65,16 @@ import { Checkbox } from '@estiva-app/ui'
|
|
|
56
65
|
the box. When disabled, only the box shows it; the words keep their colour
|
|
57
66
|
and lose the pointer. It is a field of its own, so it does not go inside a
|
|
58
67
|
**Field**.
|
|
68
|
+
- With `row` it is the same `Field`, its `Field.Label` the whole row:
|
|
69
|
+
`leading`, 12px, the words in `body-2` (cut off with an ellipsis when too
|
|
70
|
+
long), the box at the end; 40px tall, 12px in from each side, 8px corners.
|
|
71
|
+
It needs `onChange`: without one, `row` is ignored. Put the rows in a
|
|
72
|
+
column with `gap-0.5`.
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
<Checkbox row leading={<IconSquareRounded size={16} stroke={1.5} />} label="Item one" checked={on} onChange={setOn} />
|
|
76
|
+
```
|
|
77
|
+
|
|
59
78
|
- Inside a busy **Form** it is disabled, and looks it.
|
|
60
79
|
- It does not move when it toggles: the tick is always in the box, hidden
|
|
61
80
|
when unchecked, so both states hang on a line of text the same way.
|
|
@@ -64,7 +83,7 @@ import { Checkbox } from '@estiva-app/ui'
|
|
|
64
83
|
|
|
65
84
|
| Key | Does |
|
|
66
85
|
|---|---|
|
|
67
|
-
| Tab | Onto the box. |
|
|
86
|
+
| Tab | Onto the box (in a row too: the row is not a Tab stop of its own). |
|
|
68
87
|
| Space | Toggles it. |
|
|
69
88
|
| Enter | Nothing. As on a native checkbox, Enter is the form's key. |
|
|
70
89
|
|
package/src/Checkbox.stories.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { fn } from 'storybook/test'
|
|
3
3
|
import { useState } from 'react'
|
|
4
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
4
5
|
import { Checkbox } from './Checkbox'
|
|
5
6
|
|
|
6
7
|
const meta = {
|
|
@@ -32,6 +33,49 @@ export const WithLabel: Story = {
|
|
|
32
33
|
/** Disabled with words: only the box shows it; the words keep their colour. */
|
|
33
34
|
export const WithLabelDisabled: Story = { args: { label: 'Label', 'aria-label': undefined, disabled: true } }
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A list you tick several from: each row is the target — a picture, the words,
|
|
38
|
+
* the box at the end — and fills on hover and while checked.
|
|
39
|
+
*/
|
|
40
|
+
export const Row: Story = {
|
|
41
|
+
parameters: { controls: { disable: true } },
|
|
42
|
+
render: () => {
|
|
43
|
+
const [ticked, setTicked] = useState(new Set(['Item two']))
|
|
44
|
+
const toggle = (item: string) =>
|
|
45
|
+
setTicked((prev) => {
|
|
46
|
+
const next = new Set(prev)
|
|
47
|
+
if (next.has(item)) next.delete(item)
|
|
48
|
+
else next.add(item)
|
|
49
|
+
return next
|
|
50
|
+
})
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex w-80 flex-col gap-0.5">
|
|
53
|
+
{['Item one', 'Item two', 'Item three'].map((item) => (
|
|
54
|
+
<Checkbox
|
|
55
|
+
key={item}
|
|
56
|
+
row
|
|
57
|
+
leading={<IconSquareRounded size={16} stroke={1.5} />}
|
|
58
|
+
label={item}
|
|
59
|
+
checked={ticked.has(item)}
|
|
60
|
+
onChange={() => toggle(item)}
|
|
61
|
+
/>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A row that cannot be changed: the box shows it, and the row neither fills nor points. */
|
|
69
|
+
export const RowDisabled: Story = {
|
|
70
|
+
parameters: { controls: { disable: true } },
|
|
71
|
+
render: () => (
|
|
72
|
+
<div className="flex w-80 flex-col gap-0.5">
|
|
73
|
+
<Checkbox row disabled leading={<IconSquareRounded size={16} stroke={1.5} />} label="Item one" checked onChange={() => {}} />
|
|
74
|
+
<Checkbox row disabled leading={<IconSquareRounded size={16} stroke={1.5} />} label="Item two" checked={false} onChange={() => {}} />
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
|
|
35
79
|
/** Controlled, as always — the parent owns the state. */
|
|
36
80
|
export const Toggles: Story = {
|
|
37
81
|
parameters: { controls: { disable: true } },
|
package/src/Checkbox.test.tsx
CHANGED
|
@@ -124,4 +124,67 @@ describe('Checkbox', () => {
|
|
|
124
124
|
expect(screen.getByText('Label')).not.toBeNull()
|
|
125
125
|
})
|
|
126
126
|
})
|
|
127
|
+
|
|
128
|
+
describe('with row', () => {
|
|
129
|
+
const picture = <svg data-testid="picture" />
|
|
130
|
+
|
|
131
|
+
it('is named by its words, and a click anywhere on the row toggles it once', async () => {
|
|
132
|
+
const user = userEvent.setup()
|
|
133
|
+
const onChange = vi.fn()
|
|
134
|
+
render(<Checkbox row leading={picture} checked={false} onChange={onChange} label="Item one" />)
|
|
135
|
+
const box = screen.getByRole('checkbox', { name: 'Item one' })
|
|
136
|
+
await user.click(screen.getByText('Item one'))
|
|
137
|
+
await user.click(screen.getByTestId('picture'))
|
|
138
|
+
await user.click(box)
|
|
139
|
+
expect(onChange).toHaveBeenCalledTimes(3)
|
|
140
|
+
expect(onChange).toHaveBeenNthCalledWith(1, true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('draws the picture, the words, then the box, in one row', () => {
|
|
144
|
+
const { container } = render(<Checkbox row leading={picture} checked={false} onChange={() => {}} label="Item one" />)
|
|
145
|
+
const children = [...(container.querySelector('label')?.children ?? [])]
|
|
146
|
+
expect(children[0]?.getAttribute('data-testid')).toBe('picture')
|
|
147
|
+
expect(children[1]?.textContent).toBe('Item one')
|
|
148
|
+
expect(children[2]?.getAttribute('role')).toBe('checkbox')
|
|
149
|
+
expect(container.querySelector('label')?.className).toContain('h-10')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('fills while checked, and on hover', () => {
|
|
153
|
+
const { container, rerender } = render(<Checkbox row checked={false} onChange={() => {}} label="Item one" />)
|
|
154
|
+
const row = () => container.querySelector('label')?.className ?? ''
|
|
155
|
+
expect(row()).not.toContain('bg-bg-selected')
|
|
156
|
+
expect(row()).toContain('hover:bg-bg-hover')
|
|
157
|
+
rerender(<Checkbox row checked onChange={() => {}} label="Item one" />)
|
|
158
|
+
expect(row()).toContain('bg-bg-selected')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('does nothing when disabled, and neither fills under the pointer nor points', async () => {
|
|
162
|
+
const user = userEvent.setup()
|
|
163
|
+
const onChange = vi.fn()
|
|
164
|
+
const { container } = render(<Checkbox row disabled checked={false} onChange={onChange} label="Item one" />)
|
|
165
|
+
await user.click(screen.getByText('Item one'))
|
|
166
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
167
|
+
const className = container.querySelector('label')?.className ?? ''
|
|
168
|
+
expect(className).not.toContain('cursor-pointer')
|
|
169
|
+
expect(className).not.toContain('hover:bg-bg-hover')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('keeps Space on the box, and adds no Tab stop of its own', async () => {
|
|
173
|
+
const user = userEvent.setup()
|
|
174
|
+
const onChange = vi.fn()
|
|
175
|
+
render(<Checkbox row checked={false} onChange={onChange} label="Item one" />)
|
|
176
|
+
await user.tab()
|
|
177
|
+
expect(document.activeElement).toBe(screen.getByRole('checkbox', { name: 'Item one' }))
|
|
178
|
+
await user.keyboard(' ')
|
|
179
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
180
|
+
await user.tab()
|
|
181
|
+
expect(document.activeElement).toBe(document.body)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('is ignored with no onChange', () => {
|
|
185
|
+
const { container } = render(<Checkbox row checked label="Item one" />)
|
|
186
|
+
expect(container.querySelector('label')).toBeNull()
|
|
187
|
+
expect(screen.queryByRole('checkbox')).toBeNull()
|
|
188
|
+
})
|
|
189
|
+
})
|
|
127
190
|
})
|
package/src/Checkbox.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'
|
|
2
2
|
import { Field as BaseField } from '@base-ui/react/field'
|
|
3
3
|
import { IconCheck } from '@tabler/icons-react'
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
4
5
|
import { cn } from './cn'
|
|
5
6
|
import { useFormBusy } from './formBusy'
|
|
6
7
|
|
|
@@ -33,6 +34,12 @@ import { useFormBusy } from './formBusy'
|
|
|
33
34
|
* Read state panel's, where it was written by hand (UIG-7, 16 September). The
|
|
34
35
|
* words keep their colour when the box is disabled (Katerina: "no need").
|
|
35
36
|
*
|
|
37
|
+
* With `row`, the whole row is the target: an optional `leading` picture, the
|
|
38
|
+
* words, the box at the end, and the row fills on hover and while checked — a
|
|
39
|
+
* list you tick several from. The class list is Peek's "Add to Open work" rows,
|
|
40
|
+
* which drew it by hand as a `role="option"` in a list that was not one (UIG-8,
|
|
41
|
+
* Katerina's pick B, 16 September: the same pixels, on the package part).
|
|
42
|
+
*
|
|
36
43
|
* Inside a busy `Form` it is disabled, and looks it (`formBusy.ts`).
|
|
37
44
|
*/
|
|
38
45
|
export interface CheckboxProps {
|
|
@@ -44,6 +51,14 @@ export interface CheckboxProps {
|
|
|
44
51
|
* they name it, so no `aria-label` is needed. `className` stays on the box.
|
|
45
52
|
*/
|
|
46
53
|
label?: string
|
|
54
|
+
/**
|
|
55
|
+
* The whole row is the target: `leading`, then the words, then the box at
|
|
56
|
+
* the end; the row fills on hover and while checked. For a list you tick
|
|
57
|
+
* several from. Needs `label` and `onChange`; without `onChange` it is ignored.
|
|
58
|
+
*/
|
|
59
|
+
row?: boolean
|
|
60
|
+
/** In a `row`, a picture before the words: an icon, a status. */
|
|
61
|
+
leading?: ReactNode
|
|
47
62
|
'aria-label'?: string
|
|
48
63
|
/** Set by a `Field` with `required`; a caller inside one owes nothing. */
|
|
49
64
|
'aria-required'?: boolean | 'true' | 'false'
|
|
@@ -65,7 +80,9 @@ const tickClasses = (checked: boolean) => cn('flex', !checked && 'invisible')
|
|
|
65
80
|
|
|
66
81
|
const WORDS_CLASSES = 'text-body-2 text-text-primary'
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
const ROW_CLASSES = 'flex shrink-0 items-center gap-3 h-10 px-3 rounded-lg transition-colors'
|
|
84
|
+
|
|
85
|
+
export function Checkbox({ checked, onChange, disabled: ownDisabled = false, label, row, leading, className, ...aria }: CheckboxProps) {
|
|
69
86
|
const formBusy = useFormBusy()
|
|
70
87
|
const disabled = ownDisabled || formBusy
|
|
71
88
|
if (!onChange) {
|
|
@@ -101,6 +118,18 @@ export function Checkbox({ checked, onChange, disabled: ownDisabled = false, lab
|
|
|
101
118
|
</BaseCheckbox.Root>
|
|
102
119
|
)
|
|
103
120
|
if (label === undefined) return box
|
|
121
|
+
if (row) {
|
|
122
|
+
return (
|
|
123
|
+
<BaseField.Root disabled={disabled}>
|
|
124
|
+
{/* No fill under the pointer, and no pointer, over a row that toggles nothing. */}
|
|
125
|
+
<BaseField.Label className={cn(ROW_CLASSES, checked && 'bg-bg-selected', !disabled && 'cursor-pointer hover:bg-bg-hover')}>
|
|
126
|
+
{leading}
|
|
127
|
+
<span className={cn('flex-1 min-w-0 truncate', WORDS_CLASSES)}>{label}</span>
|
|
128
|
+
{box}
|
|
129
|
+
</BaseField.Label>
|
|
130
|
+
</BaseField.Root>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
104
133
|
return (
|
|
105
134
|
<BaseField.Root disabled={disabled}>
|
|
106
135
|
{/* No pointer over words that toggle nothing. */}
|
package/src/CommandPalette.mdx
CHANGED
|
@@ -90,7 +90,10 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
|
|
|
90
90
|
send the highlight to the first or last row; the palette stops that.
|
|
91
91
|
- **The field is named by its placeholder**, so say what it asks for.
|
|
92
92
|
- **A form's fields go in `Field`s.** Mark the missing ones with `error` inside
|
|
93
|
-
`onSubmit`, and the first of them takes focus.
|
|
93
|
+
`onSubmit`, and the first of them takes focus. The level is the package
|
|
94
|
+
**Form** (`enterSends={false}`), so work a mark out from what is typed — a
|
|
95
|
+
field that is filled must stop saying it is missing, or the Form will not
|
|
96
|
+
send again. `working` locks the fields,
|
|
94
97
|
puts its words on the button and beside it, and holds focus on the form so
|
|
95
98
|
Esc still closes it; when it clears, focus goes to the first field that needs
|
|
96
99
|
something, or back where it was. `error` is what went wrong, beside the
|
|
@@ -111,7 +114,8 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
|
|
|
111
114
|
| | Home / End | move the text cursor; the lit row stays |
|
|
112
115
|
| inside a level | Backspace at the start of the field, or the chip's ✕ | back, with focus in the field |
|
|
113
116
|
| a form | Tab / Shift+Tab | between the fields, never out of the window |
|
|
114
|
-
| |
|
|
117
|
+
| | Enter in a text field | nothing; Enter in a text area is a new line |
|
|
118
|
+
| | Ctrl+Enter, or the button | submits; if a field needs something, focus goes to the first |
|
|
115
119
|
| | Backspace in an empty text field | back. In a form that is one list, Backspace anywhere; in a list inside a form with text fields, nothing |
|
|
116
120
|
| | while working | nothing but Esc |
|
|
117
121
|
| | Esc in an open list | closes the list first |
|
|
@@ -155,7 +155,11 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
|
|
|
155
155
|
|
|
156
156
|
// ── The form keeps what was typed while the palette is open.
|
|
157
157
|
const [draft, setDraft] = useState({ title: '', notes: '', kind: 'one', group: '' })
|
|
158
|
-
|
|
158
|
+
// What a field needs is worked out from the draft once a send has been tried, so a field that is
|
|
159
|
+
// filled stops saying so at once. A mark left standing would keep the Form from ever sending.
|
|
160
|
+
const [tried, setTried] = useState(false)
|
|
161
|
+
const need = { title: draft.title.trim() ? undefined : 'Give it a title.', group: draft.group ? undefined : 'Choose a group.' }
|
|
162
|
+
const missing: { title?: string; group?: string } = tried ? need : {}
|
|
159
163
|
const [working, setWorking] = useState(false)
|
|
160
164
|
const [refused, setRefused] = useState(false)
|
|
161
165
|
const [kind, setKind] = useState('one')
|
|
@@ -264,8 +268,7 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
|
|
|
264
268
|
}, [frame, level, asked, recent, searching])
|
|
265
269
|
|
|
266
270
|
const submitForm = async () => {
|
|
267
|
-
|
|
268
|
-
setMissing(need)
|
|
271
|
+
setTried(true)
|
|
269
272
|
setRefused(false)
|
|
270
273
|
if (need.title || need.group) return
|
|
271
274
|
setWorking(true)
|
|
@@ -316,6 +316,21 @@ describe('CommandPaletteForm', () => {
|
|
|
316
316
|
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
317
317
|
})
|
|
318
318
|
|
|
319
|
+
it('sends nothing on a plain Enter in a field; the button sends, through the same Form submit', async () => {
|
|
320
|
+
const onSubmit = vi.fn()
|
|
321
|
+
const user = userEvent.setup()
|
|
322
|
+
render(<Form onSubmit={onSubmit} />)
|
|
323
|
+
const title = screen.getByRole('textbox', { name: 'Title' }) as HTMLInputElement
|
|
324
|
+
await waitFor(() => expect(document.activeElement).toBe(title))
|
|
325
|
+
await user.keyboard('A{Enter}')
|
|
326
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
327
|
+
expect(title.value).toBe('A')
|
|
328
|
+
const button = screen.getByRole('button', { name: 'Create item' })
|
|
329
|
+
expect(button.getAttribute('type')).toBe('submit')
|
|
330
|
+
await user.click(button)
|
|
331
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
332
|
+
})
|
|
333
|
+
|
|
319
334
|
it('puts focus on the first field that needs something', async () => {
|
|
320
335
|
const user = userEvent.setup()
|
|
321
336
|
render(<Form />)
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import { Button } from './Button'
|
|
|
19
19
|
import { InputChip } from './ChipInput'
|
|
20
20
|
import { EmptyState } from './EmptyState'
|
|
21
21
|
import { FieldLine } from './Field'
|
|
22
|
+
import { Form } from './Form'
|
|
22
23
|
import { Kbd } from './Kbd'
|
|
23
24
|
import { EnterHint, MenuItemBody, menuItemClassName } from './Menu'
|
|
24
25
|
import { ScrollArea } from './ScrollArea'
|
|
@@ -484,15 +485,18 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
|
|
|
484
485
|
const busy = !!working
|
|
485
486
|
const busyRef = useRef(busy)
|
|
486
487
|
busyRef.current = busy
|
|
487
|
-
const returnTo = useRef<HTMLElement | null>(null)
|
|
488
488
|
|
|
489
489
|
/** A field that says it needs something: Base UI marks the `Field` itself. */
|
|
490
490
|
const firstInvalid = () => frameRef.current?.querySelector<HTMLElement>('[data-invalid]')?.querySelector<HTMLElement>(CONTROL) ?? null
|
|
491
491
|
|
|
492
|
+
/*
|
|
493
|
+
The form's one way in: its button (type="submit") and Ctrl+Enter both
|
|
494
|
+
arrive here through the package Form's submit, so Base UI's field check
|
|
495
|
+
runs for both. The guard stays: a waiting button is focusable while
|
|
496
|
+
disabled, so it is not the browser that stops it.
|
|
497
|
+
*/
|
|
492
498
|
const submit = () => {
|
|
493
499
|
if (busyRef.current || submitWaits) return
|
|
494
|
-
const active = document.activeElement
|
|
495
|
-
returnTo.current = active instanceof HTMLElement && frameRef.current?.contains(active) ? active : null
|
|
496
500
|
onSubmit()
|
|
497
501
|
// If the caller marked fields instead of starting, the first of them
|
|
498
502
|
// takes focus: the key list's "focus goes to the first".
|
|
@@ -502,26 +506,12 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
|
|
|
502
506
|
}
|
|
503
507
|
|
|
504
508
|
/*
|
|
505
|
-
The lock must not lose focus
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
focus sits on the form's own box, where its keys still arrive; when it
|
|
511
|
-
stops, focus goes to the first field that needs something, or back where
|
|
512
|
-
it was, or to the first field.
|
|
509
|
+
The lock must not lose focus — measured in the prototype, where after
|
|
510
|
+
Ctrl+Enter no key but Esc ever worked again. The package Form does it now
|
|
511
|
+
(UIG-7): while it works, focus sits on the form, where its keys still
|
|
512
|
+
arrive; when it stops, focus goes to the first field that needs something,
|
|
513
|
+
or back where it was, or to the first field.
|
|
513
514
|
*/
|
|
514
|
-
useLayoutEffect(() => {
|
|
515
|
-
if (busy) {
|
|
516
|
-
frameRef.current?.focus()
|
|
517
|
-
return
|
|
518
|
-
}
|
|
519
|
-
if (document.activeElement !== frameRef.current) return
|
|
520
|
-
const was = returnTo.current
|
|
521
|
-
const target = firstInvalid() ?? (was?.isConnected && !(was as HTMLButtonElement).disabled ? was : null) ?? firstControl(popupRef.current)
|
|
522
|
-
target?.focus()
|
|
523
|
-
// Runs when the lock changes, and reads the DOM it leaves behind.
|
|
524
|
-
}, [busy])
|
|
525
515
|
|
|
526
516
|
/*
|
|
527
517
|
Backspace goes back only where nothing typed is lost: from an empty text
|
|
@@ -541,12 +531,8 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
|
|
|
541
531
|
const measure = () => setBackNamed(backWorksFrom(document.activeElement))
|
|
542
532
|
useLayoutEffect(measure)
|
|
543
533
|
|
|
534
|
+
// Ctrl+Enter is the Form's; plain Enter in a field sends nothing here (`enterSends={false}`).
|
|
544
535
|
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
545
|
-
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
546
|
-
e.preventDefault()
|
|
547
|
-
submit()
|
|
548
|
-
return
|
|
549
|
-
}
|
|
550
536
|
if (e.key === 'Backspace' && !e.ctrlKey && !e.metaKey && !e.altKey && backWorksFrom(e.target as Element)) {
|
|
551
537
|
e.preventDefault()
|
|
552
538
|
back()
|
|
@@ -565,27 +551,29 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
|
|
|
565
551
|
{icon != null && <span className="flex shrink-0 items-center text-text-secondary">{icon}</span>}
|
|
566
552
|
<LevelChip chip={chip} onBack={back} />
|
|
567
553
|
</div>
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
554
|
+
{/* The package Form, from the fields to the button (UIG-7). The chip row
|
|
555
|
+
stays outside it, so its ✕ still goes back while the form works; the
|
|
556
|
+
button row is inside it, so focus that was on the button is held.
|
|
557
|
+
The fields are found through data-command-palette-fields. */}
|
|
558
|
+
<Form data-command-palette-fields="" busy={busy} enterSends={false} onSubmit={submit} className="flex min-h-0 flex-col">
|
|
559
|
+
<ScrollArea viewportClassName="max-h-[420px]" contentClassName="flex flex-col px-5 py-4">
|
|
572
560
|
<div className="flex flex-col gap-4">{children}</div>
|
|
573
|
-
</
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
561
|
+
</ScrollArea>
|
|
562
|
+
<div className="flex shrink-0 items-center gap-3 px-5 pb-4">
|
|
563
|
+
<div className="min-w-0 flex-1">
|
|
564
|
+
{working ? <FieldLine>{working.line}</FieldLine> : error ? <FieldLine tone="error">{error}</FieldLine> : null}
|
|
565
|
+
</div>
|
|
566
|
+
<Button
|
|
567
|
+
type="submit"
|
|
568
|
+
variant="primary"
|
|
569
|
+
disabled={busy}
|
|
570
|
+
disabledReason={busy ? undefined : submitWaits}
|
|
571
|
+
leadingIcon={busy ? <IconLoader2 size={16} stroke={1.5} className="animate-spin" /> : undefined}
|
|
572
|
+
>
|
|
573
|
+
{working ? working.button : submitLabel}
|
|
574
|
+
</Button>
|
|
578
575
|
</div>
|
|
579
|
-
|
|
580
|
-
variant="primary"
|
|
581
|
-
onClick={submit}
|
|
582
|
-
disabled={busy}
|
|
583
|
-
disabledReason={busy ? undefined : submitWaits}
|
|
584
|
-
leadingIcon={busy ? <IconLoader2 size={16} stroke={1.5} className="animate-spin" /> : undefined}
|
|
585
|
-
>
|
|
586
|
-
{working ? working.button : submitLabel}
|
|
587
|
-
</Button>
|
|
588
|
-
</div>
|
|
576
|
+
</Form>
|
|
589
577
|
<Footer keys={keys} />
|
|
590
578
|
</div>
|
|
591
579
|
)
|
package/src/Form.mdx
CHANGED
|
@@ -36,7 +36,10 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
- **`onSubmit` takes nothing.** The page's own submit is already prevented;
|
|
39
|
-
there is no event to stop.
|
|
39
|
+
there is no event to stop. Every way of sending — Enter, Ctrl+Enter, a
|
|
40
|
+
submit button — arrives here the same way, after the same field check.
|
|
41
|
+
- **`enterSends={false}`** keeps Enter in a one-line field from sending, where
|
|
42
|
+
only Ctrl+Enter may: a form inside **CommandPalette**, which uses it.
|
|
40
43
|
- **`busy` switches off every field and button inside**, so they do not
|
|
41
44
|
each need `disabled={busy}`. A button outside the form that sends it —
|
|
42
45
|
a dialog's footer, with `form="<id>"` and the form's `id` — is outside,
|
|
@@ -57,10 +60,17 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
|
|
|
57
60
|
|
|
58
61
|
## Keys
|
|
59
62
|
|
|
63
|
+
The same in every form (Katerina, 16 September).
|
|
64
|
+
|
|
60
65
|
| Key | Does |
|
|
61
66
|
|---|---|
|
|
62
|
-
| Enter, in a one-line field | Sends the form. |
|
|
67
|
+
| Enter, in a one-line field | Sends the form — with or without a submit button, however many fields. Not with `enterSends={false}`. |
|
|
68
|
+
| Shift+Enter or Alt+Enter, in a one-line field | Nothing — the browser would send the form on its own; the form stops it. |
|
|
63
69
|
| Enter, in a Textarea | A new line; it does not send. |
|
|
70
|
+
| Enter, in a list or a people picker | Picks; it does not send. |
|
|
71
|
+
| Enter, in a field that handles Enter itself | What the field does; the form does not send. |
|
|
72
|
+
| Ctrl+Enter (Cmd+Enter), anywhere inside | Sends the form. |
|
|
73
|
+
| Any of these, while busy | Nothing: a form that is sending does not send again. |
|
|
64
74
|
| Tab, while busy | Nothing inside takes focus. |
|
|
65
75
|
|
|
66
76
|
## Props
|
package/src/Form.test.tsx
CHANGED
|
@@ -54,6 +54,109 @@ describe('Form', () => {
|
|
|
54
54
|
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
describe('the keys, the same in every form', () => {
|
|
58
|
+
function Keys({ onSubmit, enterSends }: { onSubmit: () => void; enterSends?: boolean }) {
|
|
59
|
+
return (
|
|
60
|
+
<Form onSubmit={onSubmit} enterSends={enterSends} aria-label="Probe">
|
|
61
|
+
<Field label="One">
|
|
62
|
+
<TextInput />
|
|
63
|
+
</Field>
|
|
64
|
+
<Field label="Two">
|
|
65
|
+
<TextInput />
|
|
66
|
+
</Field>
|
|
67
|
+
<Field label="Words">
|
|
68
|
+
<Textarea />
|
|
69
|
+
</Field>
|
|
70
|
+
<input role="combobox" aria-label="Pick" aria-expanded="false" aria-controls="none" />
|
|
71
|
+
</Form>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it('Enter in a one-line field sends, even with two fields and no submit button', async () => {
|
|
76
|
+
const user = userEvent.setup()
|
|
77
|
+
const onSubmit = vi.fn()
|
|
78
|
+
render(<Keys onSubmit={onSubmit} />)
|
|
79
|
+
await user.type(screen.getByRole('textbox', { name: 'Two' }), 'x{Enter}')
|
|
80
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('Enter in a text area is a new line; Ctrl+Enter and Cmd+Enter there send', async () => {
|
|
84
|
+
const user = userEvent.setup()
|
|
85
|
+
const onSubmit = vi.fn()
|
|
86
|
+
render(<Keys onSubmit={onSubmit} />)
|
|
87
|
+
const words = screen.getByRole('textbox', { name: 'Words' }) as HTMLTextAreaElement
|
|
88
|
+
await user.type(words, 'a{Enter}b')
|
|
89
|
+
expect(words.value).toBe('a\nb')
|
|
90
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
91
|
+
await user.keyboard('{Control>}{Enter}{/Control}')
|
|
92
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
93
|
+
await user.keyboard('{Meta>}{Enter}{/Meta}')
|
|
94
|
+
expect(onSubmit).toHaveBeenCalledTimes(2)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("Enter in a picker's input does not send", async () => {
|
|
98
|
+
const user = userEvent.setup()
|
|
99
|
+
const onSubmit = vi.fn()
|
|
100
|
+
render(<Keys onSubmit={onSubmit} />)
|
|
101
|
+
await user.type(screen.getByRole('combobox', { name: 'Pick' }), 'x{Enter}')
|
|
102
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('a field that handles its own Enter keeps it', async () => {
|
|
106
|
+
const user = userEvent.setup()
|
|
107
|
+
const onSubmit = vi.fn()
|
|
108
|
+
render(
|
|
109
|
+
<Form onSubmit={onSubmit} aria-label="Probe">
|
|
110
|
+
<TextInput aria-label="Own" onKeyDown={(event) => event.key === 'Enter' && event.preventDefault()} />
|
|
111
|
+
</Form>,
|
|
112
|
+
)
|
|
113
|
+
await user.type(screen.getByRole('textbox', { name: 'Own' }), 'x{Enter}')
|
|
114
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('with enterSends off, Enter in a one-line field does nothing and Ctrl+Enter sends', async () => {
|
|
118
|
+
const user = userEvent.setup()
|
|
119
|
+
const onSubmit = vi.fn()
|
|
120
|
+
render(<Keys onSubmit={onSubmit} enterSends={false} />)
|
|
121
|
+
const one = screen.getByRole('textbox', { name: 'One' }) as HTMLInputElement
|
|
122
|
+
await user.type(one, 'x{Enter}')
|
|
123
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
124
|
+
expect(one.value).toBe('x')
|
|
125
|
+
await user.keyboard('{Control>}{Enter}{/Control}')
|
|
126
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('Shift+Enter and Alt+Enter in a one-line field send nothing, even with a submit button', async () => {
|
|
130
|
+
const onSubmit = vi.fn()
|
|
131
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
132
|
+
const field = screen.getByRole('textbox', { name: 'First' })
|
|
133
|
+
// The browser's implicit submission is the default action of this keydown: prevented, it does not happen.
|
|
134
|
+
for (const modifier of [{ shiftKey: true }, { altKey: true }]) {
|
|
135
|
+
expect(fireEvent.keyDown(field, { key: 'Enter', ...modifier })).toBe(false)
|
|
136
|
+
}
|
|
137
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('Enter with a submit button in the form sends once, not twice', async () => {
|
|
141
|
+
const user = userEvent.setup()
|
|
142
|
+
const onSubmit = vi.fn()
|
|
143
|
+
render(<Harness onSubmit={onSubmit} />)
|
|
144
|
+
await user.type(screen.getByRole('textbox', { name: 'First' }), 'x{Enter}')
|
|
145
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('does not send again while busy', async () => {
|
|
149
|
+
const onSubmit = vi.fn()
|
|
150
|
+
const { rerender } = render(<Harness onSubmit={onSubmit} />)
|
|
151
|
+
act(() => screen.getByRole('textbox', { name: 'First' }).focus())
|
|
152
|
+
rerender(<Harness onSubmit={onSubmit} busy />)
|
|
153
|
+
const form = screen.getByRole('form', { name: 'Probe' })
|
|
154
|
+
fireEvent.keyDown(form, { key: 'Enter', ctrlKey: true })
|
|
155
|
+
fireEvent.submit(form)
|
|
156
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
57
160
|
it('does not send while a field shows its error', async () => {
|
|
58
161
|
const user = userEvent.setup()
|
|
59
162
|
const onSubmit = vi.fn()
|