@estiva-app/ui 0.23.0 → 0.24.1

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 (120) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Form.d.ts.map +1 -1
  4. package/dist/IconButton.d.ts +9 -1
  5. package/dist/IconButton.d.ts.map +1 -1
  6. package/dist/ListColumn.d.ts +41 -0
  7. package/dist/ListColumn.d.ts.map +1 -0
  8. package/dist/PreviewCard.d.ts +9 -2
  9. package/dist/PreviewCard.d.ts.map +1 -1
  10. package/dist/SectionHeader.d.ts +7 -1
  11. package/dist/SectionHeader.d.ts.map +1 -1
  12. package/dist/Select.d.ts +12 -3
  13. package/dist/Select.d.ts.map +1 -1
  14. package/dist/Toolbar.d.ts +18 -0
  15. package/dist/Toolbar.d.ts.map +1 -1
  16. package/dist/Tooltip.d.ts +13 -1
  17. package/dist/Tooltip.d.ts.map +1 -1
  18. package/dist/eslint/index.js +2 -1
  19. package/dist/eslint/index.js.map +2 -2
  20. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +189 -143
  24. package/dist/index.js.map +4 -4
  25. package/package.json +1 -1
  26. package/registry.json +214 -11
  27. package/src/AppShell.mdx +18 -1
  28. package/src/AppShell.test.tsx +16 -0
  29. package/src/AppShell.tsx +4 -1
  30. package/src/AttachmentCard.mdx +12 -1
  31. package/src/AttachmentCard.test.tsx +10 -0
  32. package/src/AttachmentCard.tsx +8 -5
  33. package/src/Avatar.mdx +20 -5
  34. package/src/AvatarGroup.mdx +4 -0
  35. package/src/Banner.mdx +17 -8
  36. package/src/Breadcrumb.mdx +12 -0
  37. package/src/Button.mdx +17 -5
  38. package/src/Button.tsx +2 -2
  39. package/src/Card.mdx +14 -1
  40. package/src/Checkbox.mdx +11 -0
  41. package/src/Chip.mdx +4 -0
  42. package/src/ChipInput.mdx +13 -1
  43. package/src/CollapsibleSection.mdx +12 -3
  44. package/src/CommandPalette.mdx +14 -2
  45. package/src/ConfirmDialog.mdx +13 -2
  46. package/src/ContainerHeader.mdx +15 -2
  47. package/src/DialogShell.mdx +15 -1
  48. package/src/Divider.mdx +12 -1
  49. package/src/EditableText.mdx +17 -1
  50. package/src/EmptyState.mdx +17 -6
  51. package/src/Field.mdx +13 -5
  52. package/src/FieldLine.mdx +9 -1
  53. package/src/FilePicker.mdx +8 -0
  54. package/src/Form.mdx +11 -1
  55. package/src/Form.test.tsx +26 -0
  56. package/src/Form.tsx +7 -0
  57. package/src/IconButton.mdx +17 -3
  58. package/src/IconButton.stories.tsx +3 -0
  59. package/src/IconButton.test.tsx +25 -0
  60. package/src/IconButton.tsx +61 -46
  61. package/src/IdentityMenu.mdx +10 -1
  62. package/src/InlineChip.mdx +11 -0
  63. package/src/Kbd.mdx +6 -2
  64. package/src/Link.mdx +13 -1
  65. package/src/ListColumn.mdx +87 -0
  66. package/src/ListColumn.stories.tsx +136 -0
  67. package/src/ListColumn.test.tsx +50 -0
  68. package/src/ListColumn.tsx +63 -0
  69. package/src/Menu.mdx +18 -1
  70. package/src/MenuItem.mdx +15 -2
  71. package/src/NavItem.mdx +12 -0
  72. package/src/Person.mdx +7 -0
  73. package/src/PersonTrigger.mdx +11 -1
  74. package/src/PersonTrigger.tsx +1 -1
  75. package/src/Popover.mdx +23 -0
  76. package/src/PreviewCard.mdx +19 -4
  77. package/src/PreviewCard.tsx +11 -4
  78. package/src/ProgressBar.mdx +8 -0
  79. package/src/Property.mdx +4 -0
  80. package/src/Rail.mdx +12 -1
  81. package/src/RailItem.mdx +12 -0
  82. package/src/RailItem.tsx +1 -1
  83. package/src/Reaction.mdx +10 -0
  84. package/src/Reaction.tsx +1 -1
  85. package/src/ReactionPicker.mdx +8 -0
  86. package/src/ScrollArea.mdx +16 -2
  87. package/src/SearchInput.mdx +9 -0
  88. package/src/SectionHeader.mdx +18 -1
  89. package/src/SectionHeader.stories.tsx +9 -0
  90. package/src/SectionHeader.test.tsx +9 -0
  91. package/src/SectionHeader.tsx +9 -3
  92. package/src/SectionLabel.mdx +10 -2
  93. package/src/Select.mdx +24 -4
  94. package/src/Select.stories.tsx +4 -1
  95. package/src/Select.test.tsx +29 -0
  96. package/src/Select.tsx +36 -14
  97. package/src/Sidebar.mdx +8 -0
  98. package/src/Skeleton.mdx +8 -0
  99. package/src/Skeleton.tsx +1 -1
  100. package/src/Tabs.mdx +12 -1
  101. package/src/TextInput.mdx +9 -0
  102. package/src/TextInput.tsx +1 -1
  103. package/src/Textarea.mdx +8 -0
  104. package/src/Toast.mdx +12 -1
  105. package/src/Toolbar.mdx +20 -2
  106. package/src/Toolbar.stories.tsx +19 -2
  107. package/src/Toolbar.test.tsx +24 -1
  108. package/src/Toolbar.tsx +22 -0
  109. package/src/Tooltip.mdx +27 -2
  110. package/src/Tooltip.stories.tsx +26 -0
  111. package/src/Tooltip.test.tsx +51 -0
  112. package/src/Tooltip.tsx +17 -4
  113. package/src/TopBar.mdx +11 -0
  114. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  115. package/src/eslint/no-restyled-part.ts +1 -0
  116. package/src/heights.test.tsx +78 -0
  117. package/src/index.ts +2 -1
  118. package/src/pages.test.ts +142 -0
  119. package/src/registry/registry.test.ts +16 -8
  120. package/tailwind-preset.js +22 -1
package/src/Button.mdx CHANGED
@@ -6,7 +6,7 @@ import * as ButtonStories from './Button.stories'
6
6
  # Button
7
7
 
8
8
  The push-button for an action: a verb, clicked once — "Create", "Save",
9
- "Delete project". Four variants on one geometry: 32px default / 24px small,
9
+ "Delete". Four variants on one geometry: 32px default / 24px small,
10
10
  6px radius, medium weight.
11
11
 
12
12
  <Canvas of={ButtonStories.AllVariants} />
@@ -27,12 +27,13 @@ The push-button for an action: a verb, clicked once — "Create", "Save",
27
27
  ## When not
28
28
 
29
29
  - An icon with no label → **IconButton**.
30
- - Going somewhere rather than doing something → **Tabs**, **Breadcrumb**,
31
- or a plain link.
30
+ - Going somewhere rather than doing something → **Link**; **Tabs** or
31
+ **Breadcrumb** when it is a view or a trail.
32
32
  - Choosing a value → **Select**. Acting from a popover list → **MenuItem**.
33
33
  - A control the person may never be allowed to use is **absent, not
34
34
  disabled**. `disabled` is for *momentarily* unavailable — and it says why,
35
- with `disabledReason` ("Available after sign-in").
35
+ with `disabledReason` ("Available after sign-in"). A send button waiting for
36
+ its fields needs no reason.
36
37
 
37
38
  ## How
38
39
 
@@ -66,7 +67,18 @@ import { IconPlus } from '@tabler/icons-react'
66
67
  has the same look.
67
68
  - **Placed from outside, never restyled.** `className` takes space, size,
68
69
  flex and grid, and position; a colour, a text size, a border, a corner or a
69
- shadow is refused by the lint (`estiva/no-restyled-part`, UIG-9).
70
+ shadow is refused by the lint (`estiva/no-restyled-part`).
71
+
72
+ ## What it owns
73
+
74
+ It does these by itself. Write none of them around it.
75
+
76
+ | It does | So you never write | Refused when written by hand |
77
+ |---|---|---|
78
+ | Says what it is to assistive technology: a button | `role="button"` | ✓ |
79
+ | Is reachable with Tab, also when it is disabled with a reason | a `tabIndex` | ✓ |
80
+ | Presses on Enter and Space, and does nothing while disabled | a key handler | |
81
+ | Keeps its 32px (24px small) in a scrolling column | `shrink-0` | |
70
82
 
71
83
  ## Keys
72
84
 
package/src/Button.tsx CHANGED
@@ -86,8 +86,8 @@ export function Button({
86
86
  // this package's own story frames, each of which asks for the top.
87
87
  // A control does not get to decide where its caller puts it.
88
88
  'inline-flex items-center justify-center gap-1 rounded-md transition-colors font-sans font-medium',
89
- size === 'default' && 'h-8 text-btn-default',
90
- size === 'small' && 'h-6 text-btn-small',
89
+ size === 'default' && 'h-8 min-h-8 text-btn-default',
90
+ size === 'small' && 'h-6 min-h-6 text-btn-small',
91
91
  // Extra right padding beside a leading icon, for optical balance.
92
92
  size === 'default' && (hasLeadingIcon ? 'pl-2 pr-3' : 'px-2'),
93
93
  size === 'small' && (hasLeadingIcon ? 'pl-1.5 pr-2' : 'px-1.5'),
package/src/Card.mdx CHANGED
@@ -12,6 +12,8 @@ hairline. What goes inside, and the space around it, is yours.
12
12
 
13
13
  ## When
14
14
 
15
+ - **It draws no padding and no layout**: both are yours.
16
+
15
17
  A box is a card when it stands for **one thing you could name**, and there are
16
18
  many of it or you treat the whole box as that thing — open it, point at it,
17
19
  move it.
@@ -73,9 +75,20 @@ import { Card } from '@estiva-app/ui'
73
75
  picture that runs edge to edge. Without it their square corners show.
74
76
  - **Placed from outside, never restyled.** `className` takes space, size,
75
77
  flex and grid, and position; a colour, a text size, a border, a corner or a
76
- shadow is refused by the lint (`estiva/no-restyled-part`, UIG-9). What is inside the card is yours: its padding and its words go on
78
+ shadow is refused by the lint (`estiva/no-restyled-part`). What is inside the card is yours: its padding and its words go on
77
79
  your own element inside.
78
80
 
81
+ - **Planned:** the inside layout of a linked object's card, in migration stage 7.
82
+
83
+ ## What it owns
84
+
85
+ It does these by itself. Write none of them around it.
86
+
87
+ | It does | So you never write | Refused when written by hand |
88
+ |---|---|---|
89
+ | With `href`, becomes a link: Link's anchor, with a real address | an anchor around the card | |
90
+ | Without `href`, shows it can be clicked when you pass `onClick` | `cursor-pointer` | |
91
+
79
92
  ## Props
80
93
 
81
94
  <Controls of={CardStories.Surface} />
package/src/Checkbox.mdx CHANGED
@@ -79,6 +79,17 @@ import { Checkbox } from '@estiva-app/ui'
79
79
  - It does not move when it toggles: the tick is always in the box, hidden
80
80
  when unchecked, so both states hang on a line of text the same way.
81
81
 
82
+ ## What it owns
83
+
84
+ It does these by itself. Write none of them around it.
85
+
86
+ | It does | So you never write | Refused when written by hand |
87
+ |---|---|---|
88
+ | Says what it is to assistive technology: a checkbox, ticked or not | `role="checkbox"` or `aria-checked` | ✓ |
89
+ | Toggles on Space and on a click; Enter is left to the form | a click or key handler | |
90
+ | Is one Tab stop, in a `row` too | a `tabIndex` on the row | |
91
+ | Keeps a `row`'s 40px in a scrolling column | `shrink-0` | |
92
+
82
93
  ## Keys
83
94
 
84
95
  | Key | Does |
package/src/Chip.mdx CHANGED
@@ -38,6 +38,10 @@ import { Chip } from '@estiva-app/ui'
38
38
  it never rides the text baseline.
39
39
  - A count alone works too — `label="3"`, no icon.
40
40
 
41
+ ## What it owns
42
+
43
+ Nothing. It only draws. Clicks and keys are yours.
44
+
41
45
  ## Props
42
46
 
43
47
  <Controls of={ChipStories.Neutral} />
package/src/ChipInput.mdx CHANGED
@@ -66,7 +66,7 @@ import { ChipInput } from '@estiva-app/ui'
66
66
  ```
67
67
 
68
68
  - **`InputChip` on its own** names its ✕ `Remove <label>`. Where the ✕ does
69
- something else, say what with `removeLabel` (a scope chip: "Leave Ship").
69
+ something else, say what with `removeLabel` (a chip for a group you belong to: "Leave Design").
70
70
  - To cap a long label, give the chip a `max-w-*` and `truncate`: the label is
71
71
  cut and the ✕ keeps its size. `truncate` is off unless asked, because
72
72
  cutting clips a letter's soft edge by up to 4px at 1x even when the label
@@ -74,6 +74,18 @@ import { ChipInput } from '@estiva-app/ui'
74
74
 
75
75
  <Canvas of={ChipInputStories.ALongLabelCut} />
76
76
 
77
+ ## What it owns
78
+
79
+ It does these by itself. Write none of them around it.
80
+
81
+ | It does | So you never write | Refused when written by hand |
82
+ |---|---|---|
83
+ | Floats on top of the page: its suggestions | a portal, or a `z-index` to win | ✓ |
84
+ | Moves through its items with the arrow keys, through the suggestions | an arrow-key handler | ✓ |
85
+ | Says what it is to assistive technology: a combobox and its options | `role="combobox"`, `listbox` or `option` | ✓ |
86
+ | Filters as you type; Enter adds the highlighted entry, Backspace on an empty query removes the last chip | a filter, or key handlers | |
87
+ | Escape clears the query first, and only then lets the surface around it act | an Escape handler | |
88
+
77
89
  ## Keys
78
90
 
79
91
  | Input | What happens |
@@ -14,11 +14,11 @@ toggle, and the rows under it, sliding shut and open.
14
14
  ## When
15
15
 
16
16
  - A group of rows in a sidebar or a panel that the reader may want out of
17
- the way — projects, folders, starred items.
17
+ the way — starred items, recent places.
18
18
  - `defaultOpen={false}` for a group that starts folded.
19
19
  - `storageKey` when the section should stay how the reader left it across
20
20
  reloads: this browser remembers, under that key. The app prefixes the
21
- key (`ship.sidebar.projects`).
21
+ key (`app.sidebar.starred`).
22
22
  - `open` + `onOpenChange` when the app owns the state.
23
23
  - `actions` for add, sort, filter beside the title — SectionHeader's,
24
24
  revealed on hover or focus.
@@ -41,7 +41,7 @@ composition lives:
41
41
  ```tsx
42
42
  import { CollapsibleSection, NavItem } from '@estiva-app/ui'
43
43
 
44
- <CollapsibleSection title="Projects" storageKey="app.sidebar.projects" className="mt-2 shrink-0" contentClassName="gap-px">
44
+ <CollapsibleSection title="Starred" storageKey="app.sidebar.starred" className="mt-2 shrink-0" contentClassName="gap-px">
45
45
  <NavItem href="/p/1" label="Item one" />
46
46
  <NavItem href="/p/2" label="Item two" />
47
47
  </CollapsibleSection>
@@ -60,6 +60,15 @@ import { CollapsibleSection, NavItem } from '@estiva-app/ui'
60
60
  with `open`.
61
61
  - A browser that blocks storage still gets a working section; it forgets.
62
62
 
63
+ ## What it owns
64
+
65
+ It does these by itself. Write none of them around it.
66
+
67
+ | It does | So you never write | Refused when written by hand |
68
+ |---|---|---|
69
+ | Opens and closes from its title, on a click, Enter or Space | open state, or a key handler | |
70
+ | Tells assistive technology whether it is open | `aria-expanded` | |
71
+
63
72
  ## Keys
64
73
 
65
74
  | Key | Does |
@@ -49,8 +49,8 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
49
49
  onQueryChange={setQuery}
50
50
  placeholder="Search, or choose a place"
51
51
  groups={[
52
- { label: 'Recent', rows: [{ id: 'item-one', label: 'Item one', icon: <IconSquareRounded size={16} stroke={1.5} />, onSelect: open, onForget: forget }] },
53
- { label: 'Places', rows: [{ id: 'place-one', label: 'Place one', icon, onSelect: goIn, onGoIn: goIn }] },
52
+ { label: 'Recent', rows: [{ id: 'item-one', label: 'Item one', icon: <IconSquareRounded size={16} stroke={1.5} />, onSelect: openItem, onForget: forget }] },
53
+ { label: 'Places', rows: [{ id: 'place-one', label: 'Place one', icon: <IconSquareRounded size={16} stroke={1.5} />, onSelect: goIn, onGoIn: goIn }] },
54
54
  ]}
55
55
  />
56
56
  </CommandPalette>
@@ -102,6 +102,18 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
102
102
  - **Focus never falls out of a level.** When the element that had it goes — a
103
103
  row picked, a level swapped — focus goes to the level's first control.
104
104
 
105
+ ## What it owns
106
+
107
+ It does these by itself. Write none of them around it.
108
+
109
+ | It does | So you never write | Refused when written by hand |
110
+ |---|---|---|
111
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
112
+ | Holds focus inside while open, and gives it back to whatever opened it | focus code, or a Tab handler | ✓ |
113
+ | Moves through its items with the arrow keys, through the rows, stopping at the ends | an arrow-key handler | ✓ |
114
+ | Says what it is to assistive technology: a list of options you search | `role="combobox"`, `listbox` or `option` | ✓ |
115
+ | Closes on Esc or a click outside; Esc closes an open list first | close handlers | |
116
+
105
117
  ## Keys
106
118
 
107
119
  | Where | Input | What happens |
@@ -45,7 +45,7 @@ import { ConfirmDialog } from '@estiva-app/ui'
45
45
  dialog open — your surface has said why; a resolved `void`/`true`
46
46
  closes it.
47
47
  - **Escape cancels; a press on the backdrop does not.** This is Base UI's
48
- `AlertDialog`, through the shell's `alert` (Katerina, D20, 2026-09-07): a
48
+ `AlertDialog`, through the shell's `alert`: a
49
49
  destructive question is answered rather than clicked away. The backdrop
50
50
  used to cancel, and that is the one behaviour stage 3 deliberately changed.
51
51
  - It announces itself as an alert (`role="alertdialog"`), so a screen reader
@@ -53,6 +53,17 @@ import { ConfirmDialog } from '@estiva-app/ui'
53
53
  - Focus is trapped in the card and returns to whatever opened it, as for
54
54
  every dialog since stage 3.
55
55
 
56
+ ## What it owns
57
+
58
+ It does these by itself. Write none of them around it.
59
+
60
+ | It does | So you never write | Refused when written by hand |
61
+ |---|---|---|
62
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
63
+ | Says what it is to assistive technology: a dialog that must be answered | `role="alertdialog"` | ✓ |
64
+ | Keeps focus inside while open, as DialogShell does, and gives it back to whatever opened it | focus code | |
65
+ | Ignores a press outside, because the question has to be answered; Escape cancels | a backdrop handler | |
66
+
56
67
  ## Keys
57
68
 
58
69
  Different from `DialogShell`'s in one row, and it is the row that matters.
@@ -60,7 +71,7 @@ Different from `DialogShell`'s in one row, and it is the row that matters.
60
71
  | Input | What happens |
61
72
  |---|---|
62
73
  | Escape | cancels |
63
- | backdrop click | **nothing** — the question has to be answered (D20) |
74
+ | backdrop click | **nothing** — the question has to be answered |
64
75
  | close button | cancels |
65
76
  | Tab / Shift+Tab | cycles the controls inside the card, and cannot leave it |
66
77
  | on close | focus returns to whatever opened it |
@@ -7,8 +7,7 @@ import * as ContainerHeaderStories from './ContainerHeader.stories'
7
7
 
8
8
  The bar across the top of a column — a list, a thread, a side panel: its
9
9
  title, and the buttons that act on the whole column at the right edge. 48px,
10
- with a hairline under it. Peek's ContainerHeader, moved into the package as it
11
- looks (Katerina, 2026-09-18).
10
+ with a hairline under it.
12
11
 
13
12
  <Canvas of={ContainerHeaderStories.WithActions} />
14
13
 
@@ -26,6 +25,11 @@ looks (Katerina, 2026-09-18).
26
25
  - The top of the whole app → **TopBar**.
27
26
  - A dialog's title → **DialogShell** draws its own.
28
27
 
28
+ - A row you draw yourself at the top of a column — your own padding and a line
29
+ under it → this part. Its numbers are every column's.
30
+ - A whole list column, header and scrolling list → **ListColumn**, which draws
31
+ this bar at its top.
32
+
29
33
  ## How
30
34
 
31
35
  ```tsx
@@ -51,6 +55,15 @@ import { ContainerHeader, IconButton } from '@estiva-app/ui'
51
55
  - Only offer a button that does something: a button with nothing behind it is
52
56
  a promise the column cannot keep.
53
57
 
58
+ ## What it owns
59
+
60
+ It does these by itself. Write none of them around it.
61
+
62
+ | It does | So you never write | Refused when written by hand |
63
+ |---|---|---|
64
+ | Draws the column's bar: 48px tall, the title 20px in at 14px medium, the actions 16px from the right, a hairline under it | a row of your own with padding and a border | |
65
+ | Keeps its 48px in a scrolling column | `shrink-0` | |
66
+
54
67
  ## Props
55
68
 
56
69
  <Controls of={ContainerHeaderStories.Default} />
@@ -28,6 +28,7 @@ footer for the buttons. A dialog is just what goes in the three slots.
28
28
  dialog rebuilt by hand, which is the one thing this bullet exists to stop.
29
29
  Its canvases and its nine tests are on that page.
30
30
  - Telling someone what happened → **Toast**.
31
+ - Searching for something and acting on it → **CommandPalette**.
31
32
 
32
33
  ## How
33
34
 
@@ -70,9 +71,22 @@ import { DialogShell, Button } from '@estiva-app/ui'
70
71
  worth setting where typing is the point — the trap decides where focus
71
72
  *cannot* go, not where it starts.
72
73
  - `alert` for a question that has to be answered: a press on the backdrop
73
- stops closing it (Katerina, D20). Escape and the ✕ still do.
74
+ stops closing it. Escape and the ✕ still do.
74
75
  `ConfirmDialog` sets it; a form or a roster should not.
75
76
 
77
+ ## What it owns
78
+
79
+ It does these by itself. Write none of them around it.
80
+
81
+ | It does | So you never write | Refused when written by hand |
82
+ |---|---|---|
83
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
84
+ | Closes on a press outside, except with `alert` | a click listener on the page | ✓ |
85
+ | Takes its keys by itself: Escape closes it | a key listener on the page | ✓ |
86
+ | Holds focus inside while open, and gives it back to whatever opened it | focus code, or a Tab handler | ✓ |
87
+ | Stops the page behind it scrolling | `overflow: hidden` on the page | ✓ |
88
+ | Says what it is to assistive technology: a dialog | `role="dialog"` | ✓ |
89
+
76
90
  ## Keys
77
91
 
78
92
  | Input | What happens |
package/src/Divider.mdx CHANGED
@@ -16,7 +16,7 @@ orientation for assistive tech.
16
16
 
17
17
  - Between groups in a **Menu** (`className="my-1"` gives it its breathing
18
18
  room there).
19
- - Between sections of a panel or rail.
19
+ - Between sections of a panel, a rail or a card.
20
20
  - Vertical: between clusters in a toolbar or a dense row.
21
21
 
22
22
  <Canvas of={DividerStories.Vertical} />
@@ -49,6 +49,17 @@ import { Divider } from '@estiva-app/ui'
49
49
  - **Inside a `Menu` or a `Popover` it has no inset**: the hairline runs the
50
50
  width of the rows. The panel tells it so; nothing is passed in.
51
51
 
52
+ - The line is 1px; the room around it is yours (`my-1` in a Menu).
53
+
54
+ ## What it owns
55
+
56
+ It does these by itself. Write none of them around it.
57
+
58
+ | It does | So you never write | Refused when written by hand |
59
+ |---|---|---|
60
+ | Says what it is to assistive technology: a separator | `role="separator"` | ✓ |
61
+ | Keeps its 1px line in a scrolling column | `shrink-0` | |
62
+
52
63
  ## Props
53
64
 
54
65
  <Controls of={DividerStories.Default} />
@@ -13,6 +13,9 @@ edit is never silently lost.
13
13
 
14
14
  ## When
15
15
 
16
+ - **It has no size of its own.** Give it the size of the text it stands in for —
17
+ a pane title's `text-body-2-strong` — on your element around it; the text and
18
+ the field both take it.
16
19
  - A title or description edited where it is shown — no dialog, no separate
17
20
  form.
18
21
  - A reader who cannot write gets `readOnly` — the value without the edit
@@ -23,6 +26,8 @@ edit is never silently lost.
23
26
  - A field in a form or dialog → **TextInput** / **Textarea** in a
24
27
  **Field**.
25
28
  - Composing something new → the field, not an edit-in-place of nothing.
29
+ - An edit that publishes something, like a message → **Textarea** with Save
30
+ and Cancel. EditableText saves when you leave it.
26
31
 
27
32
  ## How
28
33
 
@@ -53,7 +58,18 @@ import { EditableText } from '@estiva-app/ui'
53
58
  - **Its text size is where it sits**, like `Person`'s: put `text-h2` on your own
54
59
  element around it, and the text and the field both take it (a field inherits
55
60
  the page's font). `className` places it — `-mx-2` to line its text up with
56
- a header's inset — and a size passed there is refused by the lint (UIG-9).
61
+ a header's inset — and a size passed there is refused by the lint.
62
+
63
+ - **Planned:** rich text edited where it is shown (UIG-30).
64
+
65
+ ## What it owns
66
+
67
+ It does these by itself. Write none of them around it.
68
+
69
+ | It does | So you never write | Refused when written by hand |
70
+ |---|---|---|
71
+ | Opens its field on a click, with the value selected | an edit mode of your own | |
72
+ | Enter and leaving the field save; Escape cancels and puts the value back (`multiline`: Shift+Enter is a new line) | key or blur handlers | |
57
73
 
58
74
  ## Keys
59
75
 
@@ -36,10 +36,12 @@ need a height from the caller (`className="h-full"`).
36
36
 
37
37
  ## When not
38
38
 
39
- - Data is still on its way → **Skeleton**; empty and loading are
40
- different truths.
41
- - Something went wrong → say that, in your surface's own error treatment
42
- an empty state that hides a failure misleads.
39
+ - Data is still on its way → **Skeleton**; empty and loading are different
40
+ truths. When the shape of what arrives is unknown, an EmptyState that says so
41
+ "Opening…" is fine.
42
+ - Something went wrong say what failed, in words. An EmptyState can carry
43
+ it, in its usual look — never red. An empty state that hides a failure
44
+ misleads.
43
45
 
44
46
  ## How
45
47
 
@@ -55,14 +57,14 @@ import { EmptyState } from '@estiva-app/ui'
55
57
 
56
58
  **When there is one thing to do about it, give it as `action`.** It is drawn
57
59
  16px under the line as the package's Button, outlined — the same look wherever
58
- it appears (Katerina, 2026-09-18). Never put a Button beside an EmptyState in a
60
+ it appears. Never put a Button beside an EmptyState in a
59
61
  box of your own: the box places it, and no two boxes place it alike. A `page`
60
62
  only; a section's line takes no action.
61
63
 
62
64
  <Canvas of={EmptyStateStories.WithAction} />
63
65
 
64
66
  ```tsx
65
- <EmptyState icon={<IconMessagePlus size={16} stroke={1.5} />} message="No topics yet." action={{ label: 'New topic', onClick: openNewTopic }} />
67
+ <EmptyState icon={<IconMessagePlus size={16} stroke={1.5} />} message="Nothing here yet." action={{ label: 'Create', onClick: create }} />
66
68
  ```
67
69
 
68
70
  **Put a section's empty state inside the box its rows live in, and give it no
@@ -83,6 +85,15 @@ The message is the caller's — a shared component has no words of its own
83
85
  for what is missing. A hand-written "Nothing here" paragraph or span in a
84
86
  section is the `section` manner, and becomes it.
85
87
 
88
+ ## What it owns
89
+
90
+ It does these by itself. Write none of them around it.
91
+
92
+ | It does | So you never write | Refused when written by hand |
93
+ |---|---|---|
94
+ | As a `page`, fills the room it is given and centres itself in it | centring, or padding around it | |
95
+ | Draws its one action, an outlined Button, under the line when you pass `action` | a button of your own under the line | |
96
+
86
97
  ## Props
87
98
 
88
99
  <Controls of={EmptyStateStories.Page} />
package/src/Field.mdx CHANGED
@@ -19,7 +19,7 @@ token; `required` marks it with the error-coloured asterisk.
19
19
 
20
20
  - Displaying a label–value pair → **Property**; Field is for editing.
21
21
  - The line belongs to a **group** of controls rather than to one — a value
22
- with Save beside it, a row of action controls → **Field line**, the same
22
+ with Save beside it, a row of action controls → **FieldLine**, the same
23
23
  small line on its own.
24
24
 
25
25
  ## How
@@ -43,18 +43,26 @@ import { Field, TextInput } from '@estiva-app/ui'
43
43
  reversed at stage 3: the Field used to win and override it.
44
44
  - `helper` is the line under the control — the format, or what happens if
45
45
  it is left empty. `error` **replaces** it and marks the control invalid,
46
- so you no longer pass `aria-invalid` yourself. Both are announced;
47
- Ship and Peek wrote this line by hand six times between them, and none of
48
- those was.
46
+ so you no longer pass `aria-invalid` yourself. Both are announced; a line
47
+ written by hand beside the control is not.
49
48
  - With neither, the control stays a direct child of the field, so nothing
50
49
  that predates the two props moved a pixel.
51
50
  - **`required` marks the control as well as the label.** The asterisk is a
52
51
  picture of the requirement; `aria-required` on the control is the word for
53
- it, and the control had neither until 2026-09-08 — measured. Base UI's
52
+ it. Base UI's
54
53
  `Field` has no `required` of its own, so the Field puts it there. A control
55
54
  that says so already is left alone, and a `children` of several elements
56
55
  keeps the asterisk and owes its own.
57
56
 
57
+ ## What it owns
58
+
59
+ It does these by itself. Write none of them around it.
60
+
61
+ | It does | So you never write | Refused when written by hand |
62
+ |---|---|---|
63
+ | Ties the label, the description and the error to the control inside it | `aria-label`, an `id` / `htmlFor` pair, or `aria-describedby` | |
64
+ | Marks the control invalid when it has an `error`, and announces it | `aria-invalid` | |
65
+
58
66
  ## Props
59
67
 
60
68
  <Controls of={FieldStories.Default} />
package/src/FieldLine.mdx CHANGED
@@ -32,7 +32,7 @@ import { FieldLine } from '@estiva-app/ui'
32
32
 
33
33
  <div className="flex flex-col gap-1.5">
34
34
  <div className="flex items-center gap-2">
35
- <TextInput value={draft} onChange={} />
35
+ <TextInput value={draft} onChange={(e) => setDraft(e.target.value)} />
36
36
  <Button variant="primary" size="small">Save</Button>
37
37
  </div>
38
38
  {outcome && <FieldLine tone={outcome.kind}>{outcome.message}</FieldLine>}
@@ -52,6 +52,14 @@ import { FieldLine } from '@estiva-app/ui'
52
52
  - The three tones are the same two classes Field draws, from one map in
53
53
  `Field.tsx`, so they cannot drift.
54
54
 
55
+ ## What it owns
56
+
57
+ It does these by itself. Write none of them around it.
58
+
59
+ | It does | So you never write | Refused when written by hand |
60
+ |---|---|---|
61
+ | Says what it is to assistive technology: an alert for an error, a status for the rest | `role="alert"` or `role="status"` | ✓ |
62
+
55
63
  ## Props
56
64
 
57
65
  <Controls of={FieldLineStories.Helper} />
@@ -44,6 +44,14 @@ const picker = useRef<HTMLInputElement>(null)
44
44
  nothing to reset afterwards.
45
45
  - `data-*` attributes pass on to the input, for a test to find it.
46
46
 
47
+ ## What it owns
48
+
49
+ It does these by itself. Write none of them around it.
50
+
51
+ | It does | So you never write | Refused when written by hand |
52
+ |---|---|---|
53
+ | Opens the system's file picker from its button, and keeps the real file input out of Tab and screen readers | a hidden `<input type="file">` | |
54
+
47
55
  ## Props
48
56
 
49
57
  <Controls of={FilePickerStories.Default} />
package/src/Form.mdx CHANGED
@@ -58,9 +58,19 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
58
58
  - `className` places the fields: the form draws no box, and neither does the
59
59
  `<fieldset>` inside it.
60
60
 
61
+ ## What it owns
62
+
63
+ It does these by itself. Write none of them around it.
64
+
65
+ | It does | So you never write | Refused when written by hand |
66
+ |---|---|---|
67
+ | Sends on Enter in a one-line field, and on Ctrl+Enter anywhere inside | a key handler that submits | |
68
+ | Leaves Enter alone where it means something else: a Textarea, a list, a people picker | exceptions of your own | |
69
+ | Does nothing while it is sending: no second send, and nothing inside takes focus | a busy flag on every control | |
70
+
61
71
  ## Keys
62
72
 
63
- The same in every form (Katerina, 16 September).
73
+ The same in every form.
64
74
 
65
75
  | Key | Does |
66
76
  |---|---|
package/src/Form.test.tsx CHANGED
@@ -18,6 +18,7 @@ import { Checkbox } from './Checkbox'
18
18
  import { IconButton } from './IconButton'
19
19
  import { Field } from './Field'
20
20
  import { Form } from './Form'
21
+ import { Popover } from './Popover'
21
22
  import { TextInput } from './TextInput'
22
23
  import { Textarea } from './Textarea'
23
24
 
@@ -46,6 +47,31 @@ describe('Form', () => {
46
47
  expect(onSubmit).toHaveBeenCalledTimes(1)
47
48
  })
48
49
 
50
+ it('a Form in a Popover inside a Form sends only itself (C1)', async () => {
51
+ // A link field in a pop-up over a composer: Enter there adds the link, and
52
+ // must not also send the message around it. React carries the submit
53
+ // through the Popover's portal to the outer form.
54
+ const user = userEvent.setup()
55
+ const outer = vi.fn()
56
+ const inner = vi.fn()
57
+ render(
58
+ <Form onSubmit={outer}>
59
+ <TextInput aria-label="Message" />
60
+ <Popover trigger={<Button>Link</Button>} open ariaLabel="Link">
61
+ <Form onSubmit={inner}>
62
+ <TextInput aria-label="Address" />
63
+ </Form>
64
+ </Popover>
65
+ </Form>,
66
+ )
67
+ await user.type(screen.getByRole('textbox', { name: 'Address' }), 'example.com{Enter}')
68
+ expect(inner).toHaveBeenCalledTimes(1)
69
+ expect(outer).not.toHaveBeenCalled()
70
+ await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}')
71
+ expect(outer).toHaveBeenCalledTimes(1)
72
+ expect(inner).toHaveBeenCalledTimes(1)
73
+ })
74
+
49
75
  it('sends from its submit button', async () => {
50
76
  const user = userEvent.setup()
51
77
  const onSubmit = vi.fn()
package/src/Form.tsx CHANGED
@@ -160,6 +160,13 @@ export function Form({ onSubmit, busy: ownBusy = false, enterSends = true, class
160
160
  }}
161
161
  onKeyDown={onKeyDown}
162
162
  onSubmit={(event) => {
163
+ /*
164
+ A form's own send only. React carries a submit up the component tree,
165
+ through a portal too, so a Form in a Popover inside this Form sent both:
166
+ Enter in a link field sent the whole message around it (UIG-14, C1,
167
+ Katerina 19 September). The inner Form has already handled its own.
168
+ */
169
+ if (event.target !== event.currentTarget) return
163
170
  event.preventDefault()
164
171
  if (busyNow.current) return
165
172
  void onSubmit()
@@ -22,8 +22,8 @@ variants. For the actions a surface repeats too often to label every time.
22
22
  - The action deserves a visible label → **Button** (words are cheaper than
23
23
  a guessed icon).
24
24
  - Inside a menu → **MenuItem** with `leading`.
25
- - A strip of these is a toolbar right; but the moment rows toggle and
26
- persist state, see **Checkbox**.
25
+ - A strip of these acting on one thing **Toolbar**. The moment rows toggle
26
+ and persist state, see **Checkbox**.
27
27
 
28
28
  ## How
29
29
 
@@ -48,17 +48,31 @@ import { IconPencil } from '@tabler/icons-react'
48
48
  - `glow` lights it in Signal — the send arrow while there is something to send.
49
49
  - **Placed from outside, never restyled.** `className` takes space, size,
50
50
  flex and grid, and position; a colour, a text size, a border, a corner or a
51
- shadow is refused by the lint (`estiva/no-restyled-part`, UIG-9).
51
+ shadow is refused by the lint (`estiva/no-restyled-part`).
52
52
  - `type` defaults to `"button"`; it is a native `<button>` on Base UI's
53
53
  Button, and every native prop passes through.
54
54
  - `disabledReason="Read only"` disables it, keeps it reachable by Tab,
55
55
  and shows the reason in place of the tooltip — **on keyboard focus as
56
56
  well as on hover**.
57
+ - **`href`** makes it a link that looks like the button: an anchor, so the
58
+ address stays real for a modified click or a new tab. A router app passes
59
+ `onClick`, prevents the default and navigates. While it cannot be used it
60
+ is the button — a link cannot be disabled.
57
61
  - `tooltip` costs no wrapper: the button is the tooltip's trigger, so
58
62
  this component's root is the `<button>` whether it carries one or not,
59
63
  and both sit at the same height. A `Dialog.Close` or a `Menu.Trigger`
60
64
  can be an IconButton that has a tooltip.
61
65
 
66
+ ## What it owns
67
+
68
+ It does these by itself. Write none of them around it.
69
+
70
+ | It does | So you never write | Refused when written by hand |
71
+ |---|---|---|
72
+ | Says what it is to assistive technology: a button | `role="button"` | ✓ |
73
+ | Is reachable with Tab, also when it is disabled with a reason | a `tabIndex` | ✓ |
74
+ | Presses on Enter and Space, and does nothing while disabled | a key handler | |
75
+
62
76
  ## Keys
63
77
 
64
78
  | Key | Does |
@@ -26,6 +26,9 @@ export const WithTooltip: Story = { args: { tooltip: 'Settings', tooltipPlacemen
26
26
  /** Disabled with its reason in place of the tooltip; Tab still reaches it. */
27
27
  export const WithAReason: Story = { args: { tooltip: 'Settings', disabledReason: 'Sign in to change settings' } }
28
28
 
29
+ /** `href`: a link that looks like the button — the same box, an anchor underneath. */
30
+ export const AsALink: Story = { args: { href: '#settings', tooltip: 'Settings' } }
31
+
29
32
  /** Every variant × enabled/disabled. */
30
33
  export const AllVariants: Story = {
31
34
  // axe color-contrast is off here until PLAN.md stage 0.10 is ruled: