@estiva-app/ui 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +3 -1
  2. package/dist/Button.d.ts.map +1 -1
  3. package/dist/Checkbox.d.ts +14 -1
  4. package/dist/Checkbox.d.ts.map +1 -1
  5. package/dist/FilePicker.d.ts +22 -0
  6. package/dist/FilePicker.d.ts.map +1 -0
  7. package/dist/Form.d.ts +34 -0
  8. package/dist/Form.d.ts.map +1 -0
  9. package/dist/IconButton.d.ts.map +1 -1
  10. package/dist/SearchInput.d.ts.map +1 -1
  11. package/dist/TextInput.d.ts.map +1 -1
  12. package/dist/Textarea.d.ts.map +1 -1
  13. package/dist/eslint/index.d.ts +1 -1
  14. package/dist/eslint/index.d.ts.map +1 -1
  15. package/dist/eslint/index.js +88 -8
  16. package/dist/eslint/index.js.map +4 -4
  17. package/dist/eslint/no-raw-element.d.ts +88 -0
  18. package/dist/eslint/no-raw-element.d.ts.map +1 -0
  19. package/dist/formBusy.d.ts +15 -0
  20. package/dist/formBusy.d.ts.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +356 -239
  24. package/dist/index.js.map +4 -4
  25. package/package.json +1 -1
  26. package/src/Button.tsx +4 -1
  27. package/src/Checkbox.mdx +19 -1
  28. package/src/Checkbox.stories.tsx +12 -0
  29. package/src/Checkbox.test.tsx +54 -0
  30. package/src/Checkbox.tsx +40 -3
  31. package/src/FilePicker.mdx +49 -0
  32. package/src/FilePicker.stories.tsx +61 -0
  33. package/src/FilePicker.test.tsx +79 -0
  34. package/src/FilePicker.tsx +40 -0
  35. package/src/Form.mdx +68 -0
  36. package/src/Form.stories.tsx +68 -0
  37. package/src/Form.test.tsx +282 -0
  38. package/src/Form.tsx +113 -0
  39. package/src/IconButton.tsx +4 -1
  40. package/src/SearchInput.mdx +2 -2
  41. package/src/SearchInput.tsx +3 -1
  42. package/src/TextInput.mdx +2 -2
  43. package/src/TextInput.test.tsx +7 -0
  44. package/src/TextInput.tsx +4 -1
  45. package/src/Textarea.tsx +4 -1
  46. package/src/eslint/index.test.ts +24 -15
  47. package/src/eslint/index.ts +6 -5
  48. package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
  49. package/src/eslint/no-raw-element.ts +180 -0
  50. package/src/formBusy.ts +19 -0
  51. package/src/index.ts +2 -0
  52. package/stories/Choosing.mdx +3 -0
  53. package/dist/eslint/no-raw-button.d.ts +0 -11
  54. package/dist/eslint/no-raw-button.d.ts.map +0 -1
  55. package/src/eslint/no-raw-button.ts +0 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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` unless visible text right beside it names it.
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
 
@@ -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 } },
@@ -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
- export function Checkbox({ checked, onChange, disabled = false, className, ...aria }: CheckboxProps) {
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
- return (
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
- return (
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
  }
@@ -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
+ })
package/src/Form.mdx ADDED
@@ -0,0 +1,68 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as FormStories from './Form.stories'
3
+
4
+ <Meta of={FormStories} />
5
+
6
+ # Form
7
+
8
+ The fields and the button that sends them. Enter in a field, or a submit
9
+ button, sends it; the page never reloads; while it sends, everything inside
10
+ is switched off at once.
11
+
12
+ <Canvas of={FormStories.WhileSending} />
13
+
14
+ ## When
15
+
16
+ - Any set of fields that is sent: a dialog's fields, a one-line field with
17
+ its button, a composer.
18
+ - **`busy`** while the sending is waited on.
19
+
20
+ ## When not
21
+
22
+ - One field that saves itself as you leave it → **EditableText**.
23
+ - A field that filters a list as you type → **SearchInput**; nothing is sent.
24
+
25
+ ## How
26
+
27
+ ```tsx
28
+ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
29
+
30
+ <Form onSubmit={send} busy={sending} className="flex flex-col gap-6">
31
+ <Field label="Title" required error={titleError}>
32
+ <TextInput value={title} onChange={(e) => { setTitle(e.target.value); setTitleError(undefined) }} />
33
+ </Field>
34
+ <Button variant="primary" type="submit">Send</Button>
35
+ </Form>
36
+ ```
37
+
38
+ - **`onSubmit` takes nothing.** The page's own submit is already prevented;
39
+ there is no event to stop.
40
+ - **`busy` switches off every field and button inside**, so they do not
41
+ each need `disabled={busy}`. A button outside the form that sends it —
42
+ a dialog's footer, with `form="<id>"` and the form's `id` — is outside,
43
+ and keeps its own `disabled`.
44
+ - **Focus waits on the form while it is busy.** A browser drops focus to the
45
+ page the moment the focused field is switched off; the form holds it
46
+ instead. When `busy` ends, focus goes to the first field showing an error,
47
+ else back to what sent the form, else to the first control. If focus was
48
+ moved somewhere else while it waited, it stays there.
49
+ - **A field showing its `error` stops the form from sending**, and Enter moves
50
+ focus to that field instead (Base UI checks every field before it sends).
51
+ Clear the error when the field changes, as in the example; an error left
52
+ standing keeps the form from ever sending.
53
+ - The browser's own validation bubbles are off (`noValidate`). `Field`'s
54
+ `required` marks a field; it does not block sending by itself.
55
+ - `className` places the fields: the form draws no box, and neither does the
56
+ `<fieldset>` inside it.
57
+
58
+ ## Keys
59
+
60
+ | Key | Does |
61
+ |---|---|
62
+ | Enter, in a one-line field | Sends the form. |
63
+ | Enter, in a Textarea | A new line; it does not send. |
64
+ | Tab, while busy | Nothing inside takes focus. |
65
+
66
+ ## Props
67
+
68
+ <Controls of={FormStories.Default} />
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { fn } from 'storybook/test'
4
+ import { Button } from './Button'
5
+ import { Field } from './Field'
6
+ import { Form } from './Form'
7
+ import { TextInput } from './TextInput'
8
+ import { Textarea } from './Textarea'
9
+
10
+ const meta = {
11
+ title: 'Inputs/Form',
12
+ component: Form,
13
+ parameters: { layout: 'padded' },
14
+ args: { onSubmit: fn(), busy: false, className: 'flex flex-col gap-6', 'aria-label': 'Example', children: null },
15
+ argTypes: { children: { control: false }, onSubmit: { control: false } },
16
+ decorators: [(Story) => <div className="w-96"><Story /></div>],
17
+ } satisfies Meta<typeof Form>
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof meta>
21
+
22
+ const fields = (error?: string) => (
23
+ <>
24
+ <Field label="Label" required>
25
+ <TextInput placeholder="Placeholder" />
26
+ </Field>
27
+ <Field label="Label" error={error}>
28
+ <Textarea placeholder="Placeholder" className="h-20" />
29
+ </Field>
30
+ <div className="flex justify-end">
31
+ <Button variant="primary" type="submit">
32
+ Send
33
+ </Button>
34
+ </div>
35
+ </>
36
+ )
37
+
38
+ export const Default: Story = { render: (args) => <Form {...args}>{fields()}</Form> }
39
+
40
+ /** Sending: every field and button inside is switched off at once. */
41
+ export const Busy: Story = { args: { busy: true }, render: (args) => <Form {...args}>{fields()}</Form> }
42
+
43
+ /** A field showing its error. The form does not send while it shows. */
44
+ export const WithError: Story = { render: (args) => <Form {...args}>{fields('That is not a valid value.')}</Form> }
45
+
46
+ /**
47
+ * Live. Press Enter in the first field, or Send: the form is busy for a
48
+ * second and a half, and focus comes back to where it was.
49
+ */
50
+ export const WhileSending: Story = {
51
+ parameters: { controls: { disable: true } },
52
+ render: (args) => {
53
+ const [busy, setBusy] = useState(false)
54
+ return (
55
+ <Form
56
+ {...args}
57
+ busy={busy}
58
+ onSubmit={async () => {
59
+ setBusy(true)
60
+ await new Promise((resolve) => setTimeout(resolve, 1500))
61
+ setBusy(false)
62
+ }}
63
+ >
64
+ {fields()}
65
+ </Form>
66
+ )
67
+ },
68
+ }