@estiva-app/ui 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +14 -1
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts +8 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/FilePicker.d.ts +22 -0
- package/dist/FilePicker.d.ts.map +1 -0
- package/dist/Form.d.ts +34 -0
- package/dist/Form.d.ts.map +1 -0
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +1 -1
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +88 -8
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-raw-element.d.ts +88 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -0
- package/dist/formBusy.d.ts +15 -0
- package/dist/formBusy.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +363 -241
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Button.tsx +4 -1
- package/src/Checkbox.mdx +19 -1
- package/src/Checkbox.stories.tsx +12 -0
- package/src/Checkbox.test.tsx +54 -0
- package/src/Checkbox.tsx +40 -3
- package/src/CommandPalette.mdx +11 -5
- package/src/CommandPalette.stories.tsx +3 -1
- package/src/CommandPalette.test.tsx +24 -1
- package/src/CommandPalette.tsx +23 -2
- package/src/FilePicker.mdx +49 -0
- package/src/FilePicker.stories.tsx +61 -0
- package/src/FilePicker.test.tsx +79 -0
- package/src/FilePicker.tsx +40 -0
- package/src/Form.mdx +68 -0
- package/src/Form.stories.tsx +68 -0
- package/src/Form.test.tsx +282 -0
- package/src/Form.tsx +113 -0
- package/src/IconButton.tsx +4 -1
- package/src/SearchInput.mdx +2 -2
- package/src/SearchInput.tsx +3 -1
- package/src/TextInput.mdx +2 -2
- package/src/TextInput.test.tsx +7 -0
- package/src/TextInput.tsx +4 -1
- package/src/Textarea.tsx +4 -1
- package/src/eslint/index.test.ts +24 -15
- package/src/eslint/index.ts +6 -5
- package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
- package/src/eslint/no-raw-element.ts +180 -0
- package/src/formBusy.ts +19 -0
- package/src/index.ts +2 -0
- package/stories/Choosing.mdx +3 -0
- package/dist/eslint/no-raw-button.d.ts +0 -11
- package/dist/eslint/no-raw-button.d.ts.map +0 -1
- package/src/eslint/no-raw-button.ts +0 -39
package/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
|
@@ -76,10 +76,16 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
|
|
|
76
76
|
- **`pending` while rows are still on their way; `empty` only once none are.**
|
|
77
77
|
Pass `empty` while a search is running and the page says "nothing" before it
|
|
78
78
|
knows.
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
- **`notes` are what a person should know about the rows and cannot act on** —
|
|
80
|
+
"2 more where you cannot open them", "the search was refused, so this list
|
|
81
|
+
is incomplete". Quiet lines under the rows, never rows: the arrows do not stop
|
|
82
|
+
on them.
|
|
83
|
+
- **Rows that arrive late do not take the highlight you moved to.** Base UI keeps
|
|
84
|
+
the highlight's position rather than its row, so a group arriving above the
|
|
85
|
+
lit row would hand the highlight — and Enter — to something else. Once you have
|
|
86
|
+
moved the highlight on a level, the palette walks it back to the row you had.
|
|
87
|
+
Until you have, the first row is lit, whichever row that becomes. Typing starts
|
|
88
|
+
again from the first row.
|
|
83
89
|
- **Home and End move the text cursor, and nothing else.** Base UI would also
|
|
84
90
|
send the highlight to the first or last row; the palette stops that.
|
|
85
91
|
- **The field is named by its placeholder**, so say what it asks for.
|
|
@@ -117,7 +123,7 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
|
|
|
117
123
|
|
|
118
124
|
**`CommandPaletteSearch`** — `query` and `onQueryChange` (the field, controlled),
|
|
119
125
|
`placeholder` (also the field's name), `groups` (`{ label, rows }`, a group
|
|
120
|
-
with no rows is not drawn), `chip?` (`{ label, leading?, onBack }`), `pending?`,
|
|
126
|
+
with no rows is not drawn), `chip?` (`{ label, leading?, onBack }`), `pending?`, `notes?`,
|
|
121
127
|
`empty?`, and `children` for what sits above the rows.
|
|
122
128
|
|
|
123
129
|
**A row** — `id`, `label`, `description?`, `icon?` or `leading?`, `onSelect`,
|
|
@@ -365,6 +365,8 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
|
|
|
365
365
|
: undefined
|
|
366
366
|
}
|
|
367
367
|
pending={level.kind === 'first' && searching ? 'Searching…' : undefined}
|
|
368
|
+
// What the later group left out: a line to read, not a row to pick.
|
|
369
|
+
notes={level.kind === 'first' && q && !searching && LATER.some((l) => l.toLowerCase().includes(q)) ? ['1 more in a place you cannot open.'] : undefined}
|
|
368
370
|
empty={
|
|
369
371
|
level.kind === 'first' && q && !searching
|
|
370
372
|
? `Nothing is called “${q}”.`
|
|
@@ -388,7 +390,7 @@ const story = (start: Start): Story => ({
|
|
|
388
390
|
/** The first level: rows in groups, the first one lit. ↑ ↓ stop at the ends, Tab goes into a place, Ctrl+Backspace forgets a recent row. */
|
|
389
391
|
export const FirstLevel: Story = story('first')
|
|
390
392
|
|
|
391
|
-
/** Typing: the rows that match now, a line while a later group is on its way,
|
|
393
|
+
/** Typing: the rows that match now, a line while a later group is on its way, that group arriving without moving the lit row, and a note about what it left out. */
|
|
392
394
|
export const WhileRowsArrive: Story = story('arriving')
|
|
393
395
|
|
|
394
396
|
/** Nothing matches: the empty line, where the first row would be. */
|
|
@@ -39,6 +39,7 @@ function Search({
|
|
|
39
39
|
onOpenChange = () => {},
|
|
40
40
|
modKey,
|
|
41
41
|
pending,
|
|
42
|
+
notes,
|
|
42
43
|
empty,
|
|
43
44
|
}: {
|
|
44
45
|
groups: CommandPaletteGroup[]
|
|
@@ -47,12 +48,13 @@ function Search({
|
|
|
47
48
|
onOpenChange?: (open: boolean) => void
|
|
48
49
|
modKey?: string
|
|
49
50
|
pending?: string
|
|
51
|
+
notes?: string[]
|
|
50
52
|
empty?: string
|
|
51
53
|
}) {
|
|
52
54
|
const [query, setQuery] = useState(initialQuery)
|
|
53
55
|
return (
|
|
54
56
|
<CommandPalette open onOpenChange={onOpenChange} label="Palette" where="In Item one" modKey={modKey}>
|
|
55
|
-
<CommandPaletteSearch query={query} onQueryChange={setQuery} placeholder="Search" groups={groups} chip={chip} pending={pending} empty={empty} />
|
|
57
|
+
<CommandPaletteSearch query={query} onQueryChange={setQuery} placeholder="Search" groups={groups} chip={chip} pending={pending} notes={notes} empty={empty} />
|
|
56
58
|
</CommandPalette>
|
|
57
59
|
)
|
|
58
60
|
}
|
|
@@ -201,6 +203,17 @@ describe('CommandPaletteSearch — rows', () => {
|
|
|
201
203
|
await waitFor(() => expect(lit()).toBe('Two'))
|
|
202
204
|
})
|
|
203
205
|
|
|
206
|
+
it('lets rows arriving above take the first place while the highlight has not been moved', async () => {
|
|
207
|
+
const early: CommandPaletteGroup = { label: 'Early', rows: [row('One'), row('Two')] }
|
|
208
|
+
const late: CommandPaletteGroup = { label: 'Late', rows: [row('Late one')] }
|
|
209
|
+
const { rerender } = render(<Search groups={[early]} />)
|
|
210
|
+
await focusedField()
|
|
211
|
+
await waitFor(() => expect(lit()).toBe('One'))
|
|
212
|
+
rerender(<Search groups={[late, early]} />)
|
|
213
|
+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
|
|
214
|
+
await waitFor(() => expect(lit()).toBe('Late one'))
|
|
215
|
+
})
|
|
216
|
+
|
|
204
217
|
it('names in the footer only the keys that work on the lit row', async () => {
|
|
205
218
|
const user = userEvent.setup()
|
|
206
219
|
render(
|
|
@@ -232,6 +245,16 @@ describe('CommandPaletteSearch — rows', () => {
|
|
|
232
245
|
expect(await screen.findByText('Nothing here yet.')).toBeTruthy()
|
|
233
246
|
})
|
|
234
247
|
|
|
248
|
+
it('draws notes as lines under the rows, which the arrows do not stop on', async () => {
|
|
249
|
+
const user = userEvent.setup()
|
|
250
|
+
render(<Search groups={[{ label: 'Group', rows: [row('One'), row('Two')] }]} notes={['2 more you cannot open.']} />)
|
|
251
|
+
await focusedField()
|
|
252
|
+
expect(await screen.findByText('2 more you cannot open.')).toBeTruthy()
|
|
253
|
+
expect(screen.getAllByRole('option')).toHaveLength(2)
|
|
254
|
+
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}')
|
|
255
|
+
expect(lit()).toBe('Two')
|
|
256
|
+
})
|
|
257
|
+
|
|
235
258
|
it('says rows are on their way', async () => {
|
|
236
259
|
render(<Search groups={[{ label: 'Group', rows: [row('One')] }]} pending="Searching…" />)
|
|
237
260
|
expect(await screen.findByText('Searching…')).toBeTruthy()
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -221,6 +221,13 @@ export interface CommandPaletteSearchProps {
|
|
|
221
221
|
chip?: CommandPaletteChip
|
|
222
222
|
/** A line under the rows while more are on their way — "Searching…". */
|
|
223
223
|
pending?: string
|
|
224
|
+
/**
|
|
225
|
+
* Quiet lines under the rows, for what a person should know about them and
|
|
226
|
+
* cannot act on — "2 more where you cannot open them", "the search was
|
|
227
|
+
* refused, so this list is incomplete". Plain lines, never rows: the arrows
|
|
228
|
+
* do not stop on them.
|
|
229
|
+
*/
|
|
230
|
+
notes?: string[]
|
|
224
231
|
/** The line when there are no rows. Leave it out while rows are still on their way. */
|
|
225
232
|
empty?: string
|
|
226
233
|
/** Above the rows: a `CommandPaletteWorking`, `CommandPaletteAnswer` or `CommandPaletteQuote`. */
|
|
@@ -229,7 +236,7 @@ export interface CommandPaletteSearchProps {
|
|
|
229
236
|
|
|
230
237
|
type ListGroup = { value: string; items: CommandPaletteRow[] }
|
|
231
238
|
|
|
232
|
-
export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups, chip, pending, empty, children }: CommandPaletteSearchProps) {
|
|
239
|
+
export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups, chip, pending, notes = [], empty, children }: CommandPaletteSearchProps) {
|
|
233
240
|
const { modKey, popupRef } = usePalette('CommandPaletteSearch')
|
|
234
241
|
const back = useBack(chip, popupRef)
|
|
235
242
|
const items = useMemo<ListGroup[]>(() => groups.filter((g) => g.rows.length > 0).map((g) => ({ value: g.label, items: g.rows })), [groups])
|
|
@@ -254,14 +261,22 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
|
|
|
254
261
|
arrow keys, exactly as a person would. It does so only when Base UI moved
|
|
255
262
|
the highlight by itself ("none") — never after an arrow, the pointer, or
|
|
256
263
|
typing, which starts again from the first row.
|
|
264
|
+
|
|
265
|
+
And only once the person has moved the highlight on this level. Until then
|
|
266
|
+
the lit row is simply the first one, and a row that arrives above it — a
|
|
267
|
+
thread's own rows, a moment after opening — becomes the first one.
|
|
257
268
|
*/
|
|
258
269
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
259
270
|
const reported = useRef<{ id?: string; reason?: string }>({})
|
|
260
271
|
const settled = useRef<{ id?: string; query: string; level?: string } | null>(null)
|
|
272
|
+
/** The person has moved the highlight since the text or the level last changed. */
|
|
273
|
+
const moved = useRef(false)
|
|
261
274
|
useLayoutEffect(() => {
|
|
262
275
|
const before = settled.current
|
|
263
276
|
const now = reported.current
|
|
264
|
-
|
|
277
|
+
const sameLevel = before?.query === query && before?.level === chip?.label
|
|
278
|
+
if (!sameLevel) moved.current = false
|
|
279
|
+
if (moved.current && before?.id && now.reason === 'none' && now.id !== before.id && sameLevel) {
|
|
265
280
|
const ids = rows.map((r) => r.id)
|
|
266
281
|
const want = ids.indexOf(before.id)
|
|
267
282
|
const at = now.id === undefined ? -1 : ids.indexOf(now.id)
|
|
@@ -347,6 +362,7 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
|
|
|
347
362
|
onItemHighlighted={(row, details) => {
|
|
348
363
|
const id = (row as CommandPaletteRow | undefined)?.id
|
|
349
364
|
reported.current = { id, reason: details.reason }
|
|
365
|
+
if (details.reason !== 'none') moved.current = true
|
|
350
366
|
setLitId(id)
|
|
351
367
|
}}
|
|
352
368
|
>
|
|
@@ -410,6 +426,11 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
|
|
|
410
426
|
</>
|
|
411
427
|
)}
|
|
412
428
|
</Autocomplete.Status>
|
|
429
|
+
{notes.map((note) => (
|
|
430
|
+
<div key={note} className="flex min-h-8 items-center px-3">
|
|
431
|
+
<FieldLine>{note}</FieldLine>
|
|
432
|
+
</div>
|
|
433
|
+
))}
|
|
413
434
|
<Autocomplete.Empty className="flex items-center px-3 [&:not(:empty)]:min-h-10">
|
|
414
435
|
{empty && <EmptyState scope="section" message={empty} />}
|
|
415
436
|
</Autocomplete.Empty>
|
|
@@ -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
|
+
})
|