@estiva-app/ui 0.16.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.map +1 -1
- package/dist/FilePicker.d.ts +22 -0
- package/dist/FilePicker.d.ts.map +1 -0
- package/dist/Form.d.ts +49 -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 +479 -350
- 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 +6 -2
- package/src/CommandPalette.stories.tsx +6 -3
- package/src/CommandPalette.test.tsx +15 -0
- package/src/CommandPalette.tsx +34 -46
- 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 +78 -0
- package/src/Form.stories.tsx +68 -0
- package/src/Form.test.tsx +385 -0
- package/src/Form.tsx +173 -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/package.json
CHANGED
package/src/Button.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
|
/**
|
|
@@ -59,10 +60,12 @@ export function Button({
|
|
|
59
60
|
...props
|
|
60
61
|
}: ButtonProps) {
|
|
61
62
|
const hasLeadingIcon = !!leadingIcon
|
|
63
|
+
// Inside a busy Form: switched off, and looking it (formBusy.ts).
|
|
64
|
+
const formBusy = useFormBusy()
|
|
62
65
|
const button = (
|
|
63
66
|
<BaseButton
|
|
64
67
|
type={type}
|
|
65
|
-
disabled={disabled || !!disabledReason}
|
|
68
|
+
disabled={disabled || !!disabledReason || formBusy}
|
|
66
69
|
focusableWhenDisabled={!!disabledReason}
|
|
67
70
|
className={(state) =>
|
|
68
71
|
cn(
|
package/src/Checkbox.mdx
CHANGED
|
@@ -21,6 +21,12 @@ the parent owns the state.
|
|
|
21
21
|
|
|
22
22
|
<Canvas of={CheckboxStories.InsideARow} />
|
|
23
23
|
|
|
24
|
+
- With words beside it: pass **`label`**. The words are part of the
|
|
25
|
+
target — clicking them toggles the box — and they name it, so no
|
|
26
|
+
`aria-label` is needed.
|
|
27
|
+
|
|
28
|
+
<Canvas of={CheckboxStories.WithLabel} />
|
|
29
|
+
|
|
24
30
|
## When not
|
|
25
31
|
|
|
26
32
|
- One choice out of several → **Select** (a value) or **Tabs** (a view).
|
|
@@ -38,7 +44,19 @@ import { Checkbox } from '@estiva-app/ui'
|
|
|
38
44
|
- Built on Base UI Checkbox, as Base UI renders it: a `<span>` with
|
|
39
45
|
`role="checkbox"` and `aria-checked`, and a hidden `<input>` beside it
|
|
40
46
|
for forms. Its click never reaches the row around it.
|
|
41
|
-
- Give it an `aria-label
|
|
47
|
+
- Give it an `aria-label`, or words of its own with `label`. Words written
|
|
48
|
+
beside it by hand name nothing and toggle nothing; `label` does both.
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
<Checkbox checked={on} onChange={setOn} label="Label" />
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- With `label` it is Base UI's `Field`, its `Field.Label` around the box and
|
|
55
|
+
the words: the box, 8px, then the words in `body-2`. `className` stays on
|
|
56
|
+
the box. When disabled, only the box shows it; the words keep their colour
|
|
57
|
+
and lose the pointer. It is a field of its own, so it does not go inside a
|
|
58
|
+
**Field**.
|
|
59
|
+
- Inside a busy **Form** it is disabled, and looks it.
|
|
42
60
|
- It does not move when it toggles: the tick is always in the box, hidden
|
|
43
61
|
when unchecked, so both states hang on a line of text the same way.
|
|
44
62
|
|
package/src/Checkbox.stories.tsx
CHANGED
|
@@ -20,6 +20,18 @@ export const Checked: Story = { args: { checked: true } }
|
|
|
20
20
|
export const Disabled: Story = { args: { disabled: true } }
|
|
21
21
|
export const DisabledChecked: Story = { args: { checked: true, disabled: true } }
|
|
22
22
|
|
|
23
|
+
/** Words beside the box. Clicking them toggles it too, and they name it. */
|
|
24
|
+
export const WithLabel: Story = {
|
|
25
|
+
args: { label: 'Label', 'aria-label': undefined },
|
|
26
|
+
render: (args) => {
|
|
27
|
+
const [checked, setChecked] = useState(args.checked)
|
|
28
|
+
return <Checkbox {...args} checked={checked} onChange={setChecked} />
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Disabled with words: only the box shows it; the words keep their colour. */
|
|
33
|
+
export const WithLabelDisabled: Story = { args: { label: 'Label', 'aria-label': undefined, disabled: true } }
|
|
34
|
+
|
|
23
35
|
/** Controlled, as always — the parent owns the state. */
|
|
24
36
|
export const Toggles: Story = {
|
|
25
37
|
parameters: { controls: { disable: true } },
|
package/src/Checkbox.test.tsx
CHANGED
|
@@ -70,4 +70,58 @@ describe('Checkbox', () => {
|
|
|
70
70
|
await user.click(square as HTMLElement)
|
|
71
71
|
expect(onRow).toHaveBeenCalledTimes(1)
|
|
72
72
|
})
|
|
73
|
+
|
|
74
|
+
describe('with label', () => {
|
|
75
|
+
it('is named by its words, and clicking the words toggles it', async () => {
|
|
76
|
+
const user = userEvent.setup()
|
|
77
|
+
const onChange = vi.fn()
|
|
78
|
+
render(<Checkbox checked={false} onChange={onChange} label="Compare every time" />)
|
|
79
|
+
const box = screen.getByRole('checkbox', { name: 'Compare every time' })
|
|
80
|
+
await user.click(screen.getByText('Compare every time'))
|
|
81
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
82
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
83
|
+
expect(box.getAttribute('aria-checked')).toBe('false')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('toggles once, not twice, when the box itself is clicked inside its words', async () => {
|
|
87
|
+
const user = userEvent.setup()
|
|
88
|
+
const onChange = vi.fn()
|
|
89
|
+
render(<Checkbox checked onChange={onChange} label="Label" />)
|
|
90
|
+
await user.click(screen.getByRole('checkbox', { name: 'Label' }))
|
|
91
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
92
|
+
expect(onChange).toHaveBeenCalledWith(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('keeps Space on the box', async () => {
|
|
96
|
+
const user = userEvent.setup()
|
|
97
|
+
const onChange = vi.fn()
|
|
98
|
+
render(<Checkbox checked={false} onChange={onChange} label="Label" />)
|
|
99
|
+
await user.tab()
|
|
100
|
+
expect(document.activeElement).toBe(screen.getByRole('checkbox', { name: 'Label' }))
|
|
101
|
+
await user.keyboard(' ')
|
|
102
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('does nothing from its words when disabled, and shows no pointer over them', async () => {
|
|
106
|
+
const user = userEvent.setup()
|
|
107
|
+
const onChange = vi.fn()
|
|
108
|
+
const { container } = render(<Checkbox checked={false} disabled onChange={onChange} label="Label" />)
|
|
109
|
+
await user.click(screen.getByText('Label'))
|
|
110
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
111
|
+
expect(container.querySelector('label')?.className).not.toContain('cursor-pointer')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('keeps className on the box', () => {
|
|
115
|
+
const { container } = render(<Checkbox checked={false} onChange={() => {}} label="Label" className="mt-1" />)
|
|
116
|
+
expect(container.querySelector('label')?.className).not.toContain('mt-1')
|
|
117
|
+
expect(screen.getByRole('checkbox', { name: 'Label' }).className).toContain('mt-1')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('with no onChange draws the words beside the picture, and is still not a control', () => {
|
|
121
|
+
const { container } = render(<Checkbox checked label="Label" />)
|
|
122
|
+
expect(screen.queryByRole('checkbox')).toBeNull()
|
|
123
|
+
expect(container.querySelector('label')).toBeNull()
|
|
124
|
+
expect(screen.getByText('Label')).not.toBeNull()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
73
127
|
})
|
package/src/Checkbox.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'
|
|
2
|
+
import { Field as BaseField } from '@base-ui/react/field'
|
|
2
3
|
import { IconCheck } from '@tabler/icons-react'
|
|
3
4
|
import { cn } from './cn'
|
|
5
|
+
import { useFormBusy } from './formBusy'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* A 16px square that fills with the accent when checked — Peek's Checkbox
|
|
@@ -24,11 +26,24 @@ import { cn } from './cn'
|
|
|
24
26
|
* "it should be fixed"). With the tick always there, Base UI's `<span>` and
|
|
25
27
|
* the `<button>` this used to be land on the same pixel, measured. In a flex
|
|
26
28
|
* row nothing ever moved.
|
|
29
|
+
*
|
|
30
|
+
* With `label`, the words sit beside the box and are part of the target: Base
|
|
31
|
+
* UI's `Field`, its `Field.Label` around the box and the words — the way its
|
|
32
|
+
* Checkbox page labels a checkbox inside a form. The class list is Peek's
|
|
33
|
+
* Read state panel's, where it was written by hand (UIG-7, 16 September). The
|
|
34
|
+
* words keep their colour when the box is disabled (Katerina: "no need").
|
|
35
|
+
*
|
|
36
|
+
* Inside a busy `Form` it is disabled, and looks it (`formBusy.ts`).
|
|
27
37
|
*/
|
|
28
38
|
export interface CheckboxProps {
|
|
29
39
|
checked: boolean
|
|
30
40
|
onChange?: (checked: boolean) => void
|
|
31
41
|
disabled?: boolean
|
|
42
|
+
/**
|
|
43
|
+
* Words beside the box. With `onChange`, clicking them toggles it too, and
|
|
44
|
+
* they name it, so no `aria-label` is needed. `className` stays on the box.
|
|
45
|
+
*/
|
|
46
|
+
label?: string
|
|
32
47
|
'aria-label'?: string
|
|
33
48
|
/** Set by a `Field` with `required`; a caller inside one owes nothing. */
|
|
34
49
|
'aria-required'?: boolean | 'true' | 'false'
|
|
@@ -48,17 +63,29 @@ function squareClasses(checked: boolean, disabled: boolean, interactive: boolean
|
|
|
48
63
|
/** `flex`, so the icon is a flex item and not an inline box with a line height of its own. */
|
|
49
64
|
const tickClasses = (checked: boolean) => cn('flex', !checked && 'invisible')
|
|
50
65
|
|
|
51
|
-
|
|
66
|
+
const WORDS_CLASSES = 'text-body-2 text-text-primary'
|
|
67
|
+
|
|
68
|
+
export function Checkbox({ checked, onChange, disabled: ownDisabled = false, label, className, ...aria }: CheckboxProps) {
|
|
69
|
+
const formBusy = useFormBusy()
|
|
70
|
+
const disabled = ownDisabled || formBusy
|
|
52
71
|
if (!onChange) {
|
|
53
|
-
|
|
72
|
+
const picture = (
|
|
54
73
|
<span aria-hidden="true" className={squareClasses(checked, disabled, false, className)}>
|
|
55
74
|
<span className={tickClasses(checked)}>
|
|
56
75
|
<IconCheck size={12} stroke={3} />
|
|
57
76
|
</span>
|
|
58
77
|
</span>
|
|
59
78
|
)
|
|
79
|
+
if (label === undefined) return picture
|
|
80
|
+
// The row around it is the control and says the state; the words are only words.
|
|
81
|
+
return (
|
|
82
|
+
<span className="flex items-center gap-2">
|
|
83
|
+
{picture}
|
|
84
|
+
<span className={WORDS_CLASSES}>{label}</span>
|
|
85
|
+
</span>
|
|
86
|
+
)
|
|
60
87
|
}
|
|
61
|
-
|
|
88
|
+
const box = (
|
|
62
89
|
<BaseCheckbox.Root
|
|
63
90
|
checked={checked}
|
|
64
91
|
disabled={disabled}
|
|
@@ -73,4 +100,14 @@ export function Checkbox({ checked, onChange, disabled = false, className, ...ar
|
|
|
73
100
|
</BaseCheckbox.Indicator>
|
|
74
101
|
</BaseCheckbox.Root>
|
|
75
102
|
)
|
|
103
|
+
if (label === undefined) return box
|
|
104
|
+
return (
|
|
105
|
+
<BaseField.Root disabled={disabled}>
|
|
106
|
+
{/* No pointer over words that toggle nothing. */}
|
|
107
|
+
<BaseField.Label className={cn('flex items-center gap-2', !disabled && 'cursor-pointer')}>
|
|
108
|
+
{box}
|
|
109
|
+
<span className={WORDS_CLASSES}>{label}</span>
|
|
110
|
+
</BaseField.Label>
|
|
111
|
+
</BaseField.Root>
|
|
112
|
+
)
|
|
76
113
|
}
|
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
|
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as FilePickerStories from './FilePicker.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={FilePickerStories} />
|
|
5
|
+
|
|
6
|
+
# FilePicker
|
|
7
|
+
|
|
8
|
+
The browser's file picker, with nothing drawn. Your own button opens it; it
|
|
9
|
+
hands you the files chosen.
|
|
10
|
+
|
|
11
|
+
<Canvas of={FilePickerStories.Default} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- A button that opens the file picker — to attach, to upload, to import.
|
|
16
|
+
- **`multiple`** to allow several at once; **`accept`** to limit the kinds
|
|
17
|
+
the picker offers.
|
|
18
|
+
|
|
19
|
+
## When not
|
|
20
|
+
|
|
21
|
+
- Showing a file that was chosen → **AttachmentCard**.
|
|
22
|
+
- Dropping files onto an area: the package has no part for that yet.
|
|
23
|
+
|
|
24
|
+
## How
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { FilePicker, IconButton } from '@estiva-app/ui'
|
|
28
|
+
|
|
29
|
+
const picker = useRef<HTMLInputElement>(null)
|
|
30
|
+
|
|
31
|
+
<FilePicker ref={picker} multiple onPick={(files) => attach(files)} />
|
|
32
|
+
<IconButton aria-label="Attach" tooltip="Attach" onClick={() => picker.current?.click()}>
|
|
33
|
+
<IconSquareRounded size={16} stroke={1.5} />
|
|
34
|
+
</IconButton>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- **The button is the control.** The picker is hidden from Tab and from
|
|
38
|
+
screen readers, so the button's name is the only one read out. A hidden
|
|
39
|
+
input still opens its picker when the button clicks it.
|
|
40
|
+
- **`onPick` gets an array**, in the order the picker gave the files, and is
|
|
41
|
+
not called when the picker is closed with nothing chosen.
|
|
42
|
+
- **The same file can be chosen again** — after an upload failed, or after
|
|
43
|
+
it was removed. The picker clears itself before `onPick` runs, so there is
|
|
44
|
+
nothing to reset afterwards.
|
|
45
|
+
- `data-*` attributes pass on to the input, for a test to find it.
|
|
46
|
+
|
|
47
|
+
## Props
|
|
48
|
+
|
|
49
|
+
<Controls of={FilePickerStories.Default} />
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { useRef, useState } from 'react'
|
|
4
|
+
import { Button } from './Button'
|
|
5
|
+
import { FilePicker } from './FilePicker'
|
|
6
|
+
import { IconButton } from './IconButton'
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: 'Inputs/FilePicker',
|
|
10
|
+
component: FilePicker,
|
|
11
|
+
// The code tab shows each story's own code: building it from the rendered
|
|
12
|
+
// element reads `element.ref`, which React 19 warns about.
|
|
13
|
+
parameters: { layout: 'padded', docs: { source: { type: 'code' } } },
|
|
14
|
+
args: { onPick: () => {} },
|
|
15
|
+
argTypes: { onPick: { control: false } },
|
|
16
|
+
} satisfies Meta<typeof FilePicker>
|
|
17
|
+
|
|
18
|
+
export default meta
|
|
19
|
+
type Story = StoryObj<typeof meta>
|
|
20
|
+
|
|
21
|
+
/** What was chosen, one line per pick, so choosing the same file twice shows twice. */
|
|
22
|
+
function Picks({ picks }: { picks: string[] }) {
|
|
23
|
+
return (
|
|
24
|
+
<ul className="flex flex-col gap-1 text-body-2 text-text-secondary">
|
|
25
|
+
{picks.length === 0 ? <li>Nothing chosen yet.</li> : picks.map((pick, i) => <li key={i}>{pick}</li>)}
|
|
26
|
+
</ul>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Opened by the caller's own button. It draws nothing itself. */
|
|
31
|
+
export const Default: Story = {
|
|
32
|
+
render: (args) => {
|
|
33
|
+
const picker = useRef<HTMLInputElement>(null)
|
|
34
|
+
const [picks, setPicks] = useState<string[]>([])
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex flex-col items-start gap-3">
|
|
37
|
+
<FilePicker {...args} ref={picker} onPick={(files) => setPicks((held) => [...held, files.map((f) => f.name).join(', ')])} />
|
|
38
|
+
<Button onClick={() => picker.current?.click()}>Choose</Button>
|
|
39
|
+
<Picks picks={picks} />
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `multiple`, opened from an icon-only button. */
|
|
46
|
+
export const Several: Story = {
|
|
47
|
+
args: { multiple: true },
|
|
48
|
+
render: (args) => {
|
|
49
|
+
const picker = useRef<HTMLInputElement>(null)
|
|
50
|
+
const [picks, setPicks] = useState<string[]>([])
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-col items-start gap-3">
|
|
53
|
+
<FilePicker {...args} ref={picker} onPick={(files) => setPicks((held) => [...held, files.map((f) => f.name).join(', ')])} />
|
|
54
|
+
<IconButton aria-label="Choose" tooltip="Choose" onClick={() => picker.current?.click()}>
|
|
55
|
+
<IconSquareRounded size={16} stroke={1.5} />
|
|
56
|
+
</IconButton>
|
|
57
|
+
<Picks picks={picks} />
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
},
|
|
61
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the FilePicker page claims, pinned: it draws nothing a person or a
|
|
4
|
+
* screen reader can reach, the caller's ref opens it, it hands over an array,
|
|
5
|
+
* the same file can be chosen twice, and closing it with nothing chosen calls
|
|
6
|
+
* nothing. Opening the browser's own picker is checked in Chrome (the PR).
|
|
7
|
+
*/
|
|
8
|
+
import { createRef } from 'react'
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { cleanup, fireEvent, render } from '@testing-library/react'
|
|
11
|
+
import { FilePicker } from './FilePicker'
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
|
|
15
|
+
/** A change as the browser sends one: the input's `files` set, then `change`. */
|
|
16
|
+
function choose(input: HTMLInputElement, files: File[]) {
|
|
17
|
+
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
|
18
|
+
fireEvent.change(input)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('FilePicker', () => {
|
|
22
|
+
it('is a hidden file input, out of the accessibility tree and out of Tab', () => {
|
|
23
|
+
const { container } = render(<FilePicker onPick={() => {}} />)
|
|
24
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
25
|
+
expect(input.type).toBe('file')
|
|
26
|
+
expect(input.className).toBe('hidden')
|
|
27
|
+
expect(input.getAttribute('aria-hidden')).toBe('true')
|
|
28
|
+
expect(input.tabIndex).toBe(-1)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("gives the caller's ref the input, which is how a button opens it", () => {
|
|
32
|
+
const ref = createRef<HTMLInputElement>()
|
|
33
|
+
render(<FilePicker ref={ref} onPick={() => {}} />)
|
|
34
|
+
expect(ref.current?.tagName).toBe('INPUT')
|
|
35
|
+
expect(ref.current?.type).toBe('file')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('hands over the chosen files as an array, in order', () => {
|
|
39
|
+
const onPick = vi.fn()
|
|
40
|
+
const { container } = render(<FilePicker multiple onPick={onPick} />)
|
|
41
|
+
const a = new File(['a'], 'a.txt')
|
|
42
|
+
const b = new File(['b'], 'b.txt')
|
|
43
|
+
choose(container.querySelector('input') as HTMLInputElement, [a, b])
|
|
44
|
+
expect(onPick).toHaveBeenCalledTimes(1)
|
|
45
|
+
expect(onPick.mock.calls[0][0]).toEqual([a, b])
|
|
46
|
+
expect(Array.isArray(onPick.mock.calls[0][0])).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('clears itself before handing the files over, so the same file can be chosen again', () => {
|
|
50
|
+
const input = { value: 'C:\\fakepath\\a.txt' }
|
|
51
|
+
const onPick = vi.fn(() => {
|
|
52
|
+
input.value = (container.querySelector('input') as HTMLInputElement).value
|
|
53
|
+
})
|
|
54
|
+
const { container } = render(<FilePicker onPick={onPick} />)
|
|
55
|
+
const element = container.querySelector('input') as HTMLInputElement
|
|
56
|
+
const set = vi.spyOn(element, 'value', 'set')
|
|
57
|
+
const a = new File(['a'], 'a.txt')
|
|
58
|
+
choose(element, [a])
|
|
59
|
+
expect(set).toHaveBeenCalledWith('')
|
|
60
|
+
expect(input.value).toBe('')
|
|
61
|
+
choose(element, [a])
|
|
62
|
+
expect(onPick).toHaveBeenCalledTimes(2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('calls nothing when the picker closes with nothing chosen', () => {
|
|
66
|
+
const onPick = vi.fn()
|
|
67
|
+
const { container } = render(<FilePicker onPick={onPick} />)
|
|
68
|
+
choose(container.querySelector('input') as HTMLInputElement, [])
|
|
69
|
+
expect(onPick).not.toHaveBeenCalled()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('passes accept, multiple and data attributes on', () => {
|
|
73
|
+
const { container } = render(<FilePicker onPick={() => {}} multiple accept="image/*" data-picker="probe" />)
|
|
74
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
75
|
+
expect(input.multiple).toBe(true)
|
|
76
|
+
expect(input.accept).toBe('image/*')
|
|
77
|
+
expect(input.getAttribute('data-picker')).toBe('probe')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { forwardRef, type InputHTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The browser's file picker, with nothing drawn: a hidden `<input type="file">`
|
|
5
|
+
* that the caller's own button opens with `ref.current.click()` (UIG-7,
|
|
6
|
+
* 16 September). Base UI has no part for it.
|
|
7
|
+
*
|
|
8
|
+
* Peek's paperclip and Ship's two wrote this by hand, each with the same two
|
|
9
|
+
* details — the files handed over as they were chosen, and the value cleared
|
|
10
|
+
* so the same file can be chosen again after a failure or after it was
|
|
11
|
+
* removed. Those are here once.
|
|
12
|
+
*
|
|
13
|
+
* Hidden from the accessibility tree and from Tab: the button that opens it is
|
|
14
|
+
* the control, and two things both named "Attach" is one more than a screen
|
|
15
|
+
* reader should find. A `display: none` input still opens its picker on a
|
|
16
|
+
* scripted click.
|
|
17
|
+
*/
|
|
18
|
+
export interface FilePickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | 'value' | 'defaultValue' | 'className' | 'style' | 'children'> {
|
|
19
|
+
/** The chosen files, in the order the picker gave them. Not called when the picker is closed with nothing chosen. */
|
|
20
|
+
onPick: (files: File[]) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const FilePicker = forwardRef<HTMLInputElement, FilePickerProps>(function FilePicker({ onPick, ...props }, ref) {
|
|
24
|
+
return (
|
|
25
|
+
<input
|
|
26
|
+
ref={ref}
|
|
27
|
+
type="file"
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
tabIndex={-1}
|
|
30
|
+
className="hidden"
|
|
31
|
+
{...props}
|
|
32
|
+
onChange={(event) => {
|
|
33
|
+
const files = Array.from(event.target.files ?? [])
|
|
34
|
+
// Cleared before anything else runs, so the same file can be chosen again.
|
|
35
|
+
event.target.value = ''
|
|
36
|
+
if (files.length > 0) onPick(files)
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
})
|