@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/MenuItem.mdx CHANGED
@@ -25,8 +25,9 @@ other two), `destructive` in the error colour, `selected` as the fill.
25
25
 
26
26
  ## When not
27
27
 
28
- - Outside a menu it renders `role="menuitem"` and belongs inside a
29
- `role="menu"` container.
28
+ - A single action that is not a row in a list **Button**. Outside a
29
+ **Menu**, MenuItem is a plain button with a row's look, so a row-shaped
30
+ action at the top of a list — "Add members" — is fine.
30
31
  - Picking a form value → **Select**'s own options.
31
32
  - A row that toggles a checkbox → not a menu at all; see the Menu page.
32
33
 
@@ -50,6 +51,18 @@ import { IconPencil } from '@tabler/icons-react'
50
51
  - A destructive row's `leading` icon takes the error colour itself
51
52
  (`text-error-default`) — the row colours only its label.
52
53
 
54
+ - **Planned:** one part for the pop-up lists while typing in an editor (UIG-31).
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
+ | Is a row of its Menu: the Menu highlights it, walks to it with the arrow keys, and closes when it is chosen | a highlight or a key handler on the row | |
63
+ | Opens its `submenu` with → and closes it with ← | a submenu of your own | |
64
+ | Keeps its row's height in a scrolling column | `shrink-0` | |
65
+
53
66
  ## Props
54
67
 
55
68
  <Controls of={MenuItemStories.Default} />
package/src/NavItem.mdx CHANGED
@@ -48,6 +48,18 @@ import { NavItem } from '@estiva-app/ui'
48
48
  anchor prop passes through.
49
49
  - The label truncates; the count never gives up its width.
50
50
 
51
+ - A row is 32px tall, 8px in on each side, 8px between the icon and the label,
52
+ the label 14px regular, 6px corners.
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
+ | Marks the current place for assistive technology when `active` | `aria-current` | |
61
+ | Keeps its 32px in a scrolling column | `shrink-0` | |
62
+
51
63
  ## Props
52
64
 
53
65
  <Controls of={NavItemStories.Default} />
package/src/Person.mdx CHANGED
@@ -12,6 +12,7 @@ and name is one rule, 8px, wherever a person appears.
12
12
 
13
13
  ## When
14
14
 
15
+ - **The name takes the size of the text around it**; `size` sets the face.
15
16
  - A person named inline: an assignee, an author, a participant, a value in
16
17
  a property row.
17
18
  - Someone with no published name shows the `fallback` — an em dash by
@@ -39,6 +40,12 @@ import { Person } from '@estiva-app/ui'
39
40
  and words are always set together (the Sizes story shows the pairings).
40
41
  - The name truncates; give the container `min-w-0` room to let it.
41
42
 
43
+ - **Planned:** a Byline part — face · name · time — in migration stage 7.
44
+
45
+ ## What it owns
46
+
47
+ Nothing. It only draws. Clicks and keys are yours.
48
+
42
49
  ## Props
43
50
 
44
51
  <Controls of={PersonStories.Named} />
@@ -47,12 +47,22 @@ import { Menu, PersonTrigger } from '@estiva-app/ui'
47
47
  - **The accessible name comes from the person, in both shapes.** The row is
48
48
  named by its visible name, and the `compact` face — which has no visible
49
49
  text — takes the same name through an `aria-label` of its own, so nothing
50
- is owed. Until 2026-09-08 that face announced **"AD"**: the initials, read
50
+ is owed. Without it that face would announce **"AD"**: the initials, read
51
51
  as text. Pass an `aria-label` only to name what it *opens* rather than who
52
52
  it shows; `IdentityMenu`'s compact shape says "Account menu".
53
53
  - It is a native `<button>` on Base UI's Button: it takes a `ref` and every
54
54
  native button prop.
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
+ | Is a button: Tab reaches it, Enter and Space call your `onClick` | a clickable `<div>` | |
63
+ | Names itself from the person's name, and says whether what it opens is open (`open`) | `aria-label` or `aria-expanded` | |
64
+ | Keeps its 32px in a scrolling column | `shrink-0` | |
65
+
56
66
  ## Keys
57
67
 
58
68
  | Key | Does |
@@ -65,7 +65,7 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
65
65
  aria-haspopup="menu"
66
66
  aria-expanded={open}
67
67
  className={cn(
68
- 'flex h-8 cursor-pointer items-center gap-1.5 rounded-md pl-1.5 pr-1.5 text-body-2 text-text-primary transition-colors hover:bg-bg-hover',
68
+ 'flex h-8 min-h-8 cursor-pointer items-center gap-1.5 rounded-md pl-1.5 pr-1.5 text-body-2 text-text-primary transition-colors hover:bg-bg-hover',
69
69
  open && 'bg-bg-hover',
70
70
  // Under a `Menu` the open state is Base UI's, not a prop: the trigger
71
71
  // carries `data-popup-open` while its menu is up, and sets its own
package/src/Popover.mdx CHANGED
@@ -83,7 +83,15 @@ so it is the one case the caller drives:
83
83
  open={selectionRect !== null}
84
84
  onOpenChange={(open) => !open && setSelectionRect(null)}
85
85
  ariaLabel="Formatting"
86
+ side="top"
87
+ contentClassName="p-1"
86
88
  >
89
+ <Toolbar aria-label="Formatting">
90
+ <ToolbarButton aria-label="Bold" tooltip="Bold" onClick={bold}>
91
+ <IconBold size={16} stroke={1.5} />
92
+ </ToolbarButton>
93
+ </Toolbar>
94
+ </Popover>
87
95
  ```
88
96
 
89
97
  - **`anchor`** takes an element or a rect. A new rect moves the panel, so
@@ -116,6 +124,21 @@ something belongs on a `trigger`.
116
124
 
117
125
  <Canvas of={PopoverStories.FlippedForRoom} />
118
126
 
127
+ - **Planned:** one part for the pop-up lists while typing in an editor (UIG-31).
128
+
129
+ ## What it owns
130
+
131
+ It does these by itself. Write none of them around it.
132
+
133
+ | It does | So you never write | Refused when written by hand |
134
+ |---|---|---|
135
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
136
+ | Stays attached to its anchor on scroll and resize, and flips side when there is no room | a scroll or resize listener, or placement arithmetic | ✓ |
137
+ | Closes on a press outside | a click listener on the page | ✓ |
138
+ | Takes its keys by itself: Escape closes it | a key listener on the page | ✓ |
139
+ | Moves focus in when it opens, and back to the trigger when it closes | focus code | |
140
+ | Keeps its own open state | `useState` for open — unless you must know it (`open` / `onOpenChange`) | |
141
+
119
142
  ## Keys
120
143
 
121
144
  | Input | What happens |
@@ -45,7 +45,7 @@ pointer never sees it.
45
45
  import { PreviewCard } from '@estiva-app/ui'
46
46
 
47
47
  <PreviewCard content={<ItemDetail id={row.id} />} wrapperClassName="block w-full">
48
- <ListRow …/>
48
+ <ListRow item={row} />
49
49
  </PreviewCard>
50
50
  ```
51
51
 
@@ -57,9 +57,12 @@ import { PreviewCard } from '@estiva-app/ui'
57
57
  - **It prefers the right of the trigger and flips** when that side has no room,
58
58
  staying 12px from the trigger and 8px clear of every screen edge. `side`
59
59
  changes the preference, not the promise.
60
- - **Width, padding and height are yours** through `className`. The default is
61
- 360px wide, capped at 300px or the room available, whichever is smaller — a
62
- preview of one kind of thing is not the size of a preview of another.
60
+ - **Width is yours** through `className`; **padding and rhythm** through
61
+ `contentClassName`. The default is 360px wide, 12px of padding, capped at
62
+ 300px or the room available, whichever is smaller a preview of one kind of
63
+ thing is not the size of a preview of another.
64
+ - The padding sits inside the scrolling box, as in `Popover`, so the
65
+ scrollbar hugs the card's edge: its thumb 3px from it, like every panel's.
63
66
  - The trigger wrapper is `inline-flex`; a full-width row wants
64
67
  `wrapperClassName="block w-full"`.
65
68
 
@@ -82,6 +85,18 @@ lands.
82
85
 
83
86
  <Canvas of={PreviewCardStories.Loading} />
84
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
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
95
+ | Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
96
+ | Closes on a press outside | a click listener on the page | ✓ |
97
+ | Opens after the pointer rests, and stays up while the pointer moves into it | a hover timer | |
98
+ | Closes on Escape | a key handler | |
99
+
85
100
  ## Keys
86
101
 
87
102
  There is no keyboard trigger, by design — see above.
@@ -35,8 +35,15 @@ export interface PreviewCardProps {
35
35
  delay?: number
36
36
  /** After the pointer leaves, in ms — the grace that lets you cross the gap into the card. */
37
37
  closeDelay?: number
38
- /** On the card's surface: its width, its padding, a max height. */
38
+ /** On the card's surface: its width. */
39
39
  className?: string
40
+ /**
41
+ * On the scrolling content: its padding (default 12px) and rhythm. The
42
+ * padding sits inside the scrolling box, as in `Popover`, so the scrollbar
43
+ * hugs the card's edge — its thumb 3px from it, like every panel's
44
+ * (Katerina, 19 September; it sat 12px in, behind the card's padding).
45
+ */
46
+ contentClassName?: string
40
47
  /** Extra classes on the trigger wrapper — e.g. `block w-full` for a row. */
41
48
  wrapperClassName?: string
42
49
  }
@@ -58,7 +65,7 @@ const OPEN_DELAY = 350
58
65
  * here the card can be reached, so it can. */
59
66
  const CLOSE_DELAY = 200
60
67
 
61
- export function PreviewCard({ content, children, side = 'right', delay = OPEN_DELAY, closeDelay = CLOSE_DELAY, className, wrapperClassName }: PreviewCardProps) {
68
+ export function PreviewCard({ content, children, side = 'right', delay = OPEN_DELAY, closeDelay = CLOSE_DELAY, className, contentClassName, wrapperClassName }: PreviewCardProps) {
62
69
  return (
63
70
  <BasePreviewCard.Root>
64
71
  {/* The part renders an `<a>` by default, which a row is not; `render`
@@ -80,10 +87,10 @@ export function PreviewCard({ content, children, side = 'right', delay = OPEN_DE
80
87
  className="z-50 data-[anchor-hidden]:hidden"
81
88
  >
82
89
  <BasePreviewCard.Popup
83
- className={cn('w-[360px] p-3 outline-none', className)}
90
+ className={cn('w-[360px] p-0 outline-none', className)}
84
91
  render={<MenuPanel />}
85
92
  >
86
- <ScrollArea viewportClassName="max-h-[calc(min(300px,var(--available-height))_-_1.5rem)]" contentClassName="flex flex-col gap-3">
93
+ <ScrollArea viewportClassName="max-h-[min(300px,var(--available-height))]" contentClassName={cn('flex flex-col gap-3 p-3', contentClassName)}>
87
94
  {content}
88
95
  </ScrollArea>
89
96
  </BasePreviewCard.Popup>
@@ -42,6 +42,14 @@ import { ProgressBar } from '@estiva-app/ui'
42
42
  of the button's.
43
43
  - `className` is for placement: a width, a margin, `flex-1` in a row.
44
44
 
45
+ ## What it owns
46
+
47
+ It does these by itself. Write none of them around it.
48
+
49
+ | It does | So you never write | Refused when written by hand |
50
+ |---|---|---|
51
+ | Says what it is to assistive technology: a progress bar, with its value | `role="progressbar"` or `aria-valuenow` | ✓ |
52
+
45
53
  ## Props
46
54
 
47
55
  <Controls of={ProgressBarStories.Default} />
package/src/Property.mdx CHANGED
@@ -54,6 +54,10 @@ WithControls story shows the pairings).
54
54
  a child of the row or the column: a control with `flex-1` still fills the
55
55
  row, and two values still sit the row's gap apart.
56
56
 
57
+ ## What it owns
58
+
59
+ Nothing. It only draws. Clicks and keys are yours.
60
+
57
61
  ## Props
58
62
 
59
63
  <Controls of={PropertyStories.Row} />
package/src/Rail.mdx CHANGED
@@ -25,7 +25,7 @@ nothing else: **RailItem** is the tile, and where a tile goes is the app's.
25
25
  needs more than a word needs a row.
26
26
  - More entries than fit down a screen → fewer entries. The rail does not
27
27
  scroll, on purpose.
28
- - A strip of *actions* rather than places → a toolbar of **IconButton**s.
28
+ - A strip of *actions* rather than places → **Toolbar**.
29
29
 
30
30
  <Canvas of={RailStories.ManyItems} />
31
31
 
@@ -52,6 +52,17 @@ import { Rail, RailItem } from '@estiva-app/ui'
52
52
 
53
53
  <Canvas of={RailStories.OneItem} />
54
54
 
55
+ - The column is 64px wide, 12px from the top and bottom, 8px at the sides,
56
+ 8px between tiles.
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
+ | Is a navigation, named "Navigation" unless you name it | `<nav>` or `aria-label` | |
65
+
55
66
  ## Keys
56
67
 
57
68
  The rail has no keyboard of its own: its tiles are links, and Tab walks them in
package/src/RailItem.mdx CHANGED
@@ -45,6 +45,18 @@ import { Rail, RailItem } from '@estiva-app/ui'
45
45
  click, navigate.
46
46
  - Icons are 16px at stroke 1.5.
47
47
 
48
+ - A tile is 64px wide and 48px tall: the 16px icon in its 32px square, over its
49
+ 9px label.
50
+
51
+ ## What it owns
52
+
53
+ It does these by itself. Write none of them around it.
54
+
55
+ | It does | So you never write | Refused when written by hand |
56
+ |---|---|---|
57
+ | Marks the current place for assistive technology when `active` | `aria-current` | |
58
+ | Keeps its 48px in a scrolling column | `shrink-0` | |
59
+
48
60
  ## Props
49
61
 
50
62
  <Controls of={RailItemStories.Default} />
package/src/RailItem.tsx CHANGED
@@ -27,7 +27,7 @@ export function RailItem({ href, icon, label, active = false, className, ...prop
27
27
  <a
28
28
  href={href}
29
29
  aria-current={active ? 'page' : undefined}
30
- className={cn('group flex w-full shrink-0 flex-col items-center gap-0.5 px-2 py-0.5', className)}
30
+ className={cn('group flex h-12 w-full shrink-0 flex-col items-center gap-0.5 px-2 py-0.5', className)}
31
31
  {...props}
32
32
  >
33
33
  <div
package/src/Reaction.mdx CHANGED
@@ -50,6 +50,16 @@ import { Reaction } from '@estiva-app/ui'
50
50
  another client can change the count.
51
51
  - `disabled` takes it out of the Tab order and ignores presses.
52
52
 
53
+ ## What it owns
54
+
55
+ It does these by itself. Write none of them around it.
56
+
57
+ | It does | So you never write | Refused when written by hand |
58
+ |---|---|---|
59
+ | Is a toggle: pressed or not, and says so to assistive technology | `aria-pressed` | |
60
+ | Presses on Enter and Space; Tab skips it while disabled | a key handler | |
61
+ | Keeps its 24px in a scrolling column | `shrink-0` | |
62
+
53
63
  ## Keys
54
64
 
55
65
  | Key | What happens |
package/src/Reaction.tsx CHANGED
@@ -70,7 +70,7 @@ export function Reaction({ emoji, count, pressed = false, className, ...props }:
70
70
  className={cn(
71
71
  // Chip's pill at a control's height, so a reaction and a status chip
72
72
  // read as the same family — 24px matches Button `small`.
73
- 'inline-flex h-6 items-center justify-center gap-1.5 rounded-full px-2',
73
+ 'inline-flex h-6 min-h-6 items-center justify-center gap-1.5 rounded-full px-2',
74
74
  'border transition-colors',
75
75
  'disabled:cursor-not-allowed disabled:opacity-50',
76
76
  pressed
@@ -68,6 +68,14 @@ and, when it is yours, the accent fill. The picker asks; the pill answers.
68
68
 
69
69
  <Canvas of={ReactionPickerStories.AndWhatItProduces} />
70
70
 
71
+ ## What it owns
72
+
73
+ It does these by itself. Write none of them around it.
74
+
75
+ | It does | So you never write | Refused when written by hand |
76
+ |---|---|---|
77
+ | Everything a Toolbar owns: one Tab stop, arrow keys between the reactions, wrapping at the ends | a key handler, or `tabIndex` on each reaction | |
78
+
71
79
  ## Keys
72
80
 
73
81
  | Input | What happens |
@@ -13,8 +13,10 @@ the content is moving; nothing moves when it appears.
13
13
 
14
14
  ## When
15
15
 
16
- - Any box that can hold more than fits: a menu or a dropdown list, a panel,
17
- a sidebar, the page's content column, a rail.
16
+ - Any box that can hold more than fits — **a list that grows with its data
17
+ counts, even when today's data fits**: a menu or a dropdown list, a panel, a
18
+ sidebar, the page's content column. Not a rail: a rail does not scroll, on
19
+ purpose.
18
20
  - `orientation="horizontal"` for a row wider than its box — a table, a
19
21
  board; `"both"` when a thing can run over either way.
20
22
 
@@ -53,6 +55,18 @@ import { ScrollArea } from '@estiva-app/ui'
53
55
  Chrome: wheel down over a sideways region inside a page, the page moves
54
56
  and the region does not; a sideways swipe, the other way round.
55
57
 
58
+ - Text inside can look a touch lighter than outside: that is Chrome drawing a
59
+ scrolling layer, not a colour. Nothing to fix.
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
+ | Scrolls with our scrollbar | `overflow-auto` or `overflow-scroll` | ✓ |
68
+ | Scrolls with the keyboard when it has focus, like any scrolling box | key handlers | |
69
+
56
70
  ## Keys
57
71
 
58
72
  | Key | Does |
@@ -38,6 +38,15 @@ import { SearchInput } from '@estiva-app/ui'
38
38
  and this usually stands alone in a top bar rather than in a `Field`.
39
39
  Inside one, the label names it and you add nothing.
40
40
 
41
+ ## What it owns
42
+
43
+ It does these by itself. Write none of them around it.
44
+
45
+ | It does | So you never write | Refused when written by hand |
46
+ |---|---|---|
47
+ | Inside a `Field`, takes its name from the Field's label | an `id` / `htmlFor` pair | |
48
+ | Shows its focus ring while anything inside it has focus | a focus style of your own | |
49
+
41
50
  ## Props
42
51
 
43
52
  <Controls of={SearchInputStories.Default} />
@@ -39,7 +39,7 @@ import { IconPlus } from '@tabler/icons-react'
39
39
  ```
40
40
 
41
41
  - `chevron`, `isExpanded` and `onToggle` are CollapsibleSection's to set
42
- (decided 2026-09-09: one component folds, and it is that one). With
42
+ (one component folds, and it is that one). With
43
43
  them, the title is a button that fills the row up to the actions — the
44
44
  whole row is the hit target, the keyboard can toggle it, and it says its
45
45
  state (`aria-expanded`).
@@ -53,6 +53,23 @@ import { IconPlus } from '@tabler/icons-react'
53
53
  - `render` swaps the title's element in Base UI's manner; it is how
54
54
  CollapsibleSection makes the title a `Collapsible.Trigger`.
55
55
 
56
+ - `hover="none"` keeps the row still under the pointer: for a heading whose
57
+ actions are always shown (`showActions="always"`), where the buttons light up
58
+ on their own. The default, `fill`, lights a row that toggles or has actions.
59
+
60
+ - The row is 32px tall, 8px in on each side, 4px between the title and the
61
+ actions, with 8px corners.
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
+ | With `chevron`, its title is a button that calls `onToggle` on a click, Enter or Space, and says whether it is open | a button of your own, or `aria-expanded` | |
70
+ | Shows its actions while one of them has focus | a focus style of your own | |
71
+ | Keeps its 32px in a scrolling column | `shrink-0` | |
72
+
56
73
  ## Keys
57
74
 
58
75
  | Key | Does |
@@ -35,6 +35,15 @@ export const PersistentActions: Story = {
35
35
  },
36
36
  }
37
37
 
38
+ /** `hover="none"`: actions always shown, and the row stays still under the pointer — only the buttons light up. */
39
+ export const StillOnHover: Story = {
40
+ args: {
41
+ showActions: 'always',
42
+ hover: 'none',
43
+ actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }],
44
+ },
45
+ }
46
+
38
47
  /** A count beside the title, held on screen while the actions come and go. */
39
48
  export const WithTrailing: Story = {
40
49
  args: {
@@ -23,6 +23,15 @@ describe('SectionHeader', () => {
23
23
  expect(onToggle).toHaveBeenCalledTimes(2)
24
24
  })
25
25
 
26
+ it('fills under the pointer when it does something, and hover="none" keeps it still', () => {
27
+ const actions = [{ icon: <i />, tooltip: 'Add', onClick: () => {} }]
28
+ const lit = render(<SectionHeader title="Section" actions={actions} />)
29
+ expect((lit.container.firstElementChild as HTMLElement).className).toContain('hover:bg-bg-hover')
30
+ cleanup()
31
+ const still = render(<SectionHeader title="Section" actions={actions} hover="none" />)
32
+ expect((still.container.firstElementChild as HTMLElement).className).not.toContain('hover:bg-bg-hover')
33
+ })
34
+
26
35
  it('an action beside the title acts, and never toggles', async () => {
27
36
  const onToggle = vi.fn()
28
37
  const add = vi.fn()
@@ -59,6 +59,12 @@ export interface SectionHeaderProps {
59
59
  actions?: SectionAction[]
60
60
  /** `hover` reveals the actions while the row is hovered or focused; `always` keeps them. */
61
61
  showActions?: 'hover' | 'always'
62
+ /**
63
+ * `fill` lights the row under the pointer when it does something (a toggle,
64
+ * actions); `none` keeps it still — a heading whose actions are always shown,
65
+ * where the buttons light up on their own (UIG-14, Katerina, 19 September).
66
+ */
67
+ hover?: 'fill' | 'none'
62
68
  /**
63
69
  * What the title renders as, in Base UI's manner. A plain button with
64
70
  * `onToggle` by default; `CollapsibleSection` hands in `Collapsible.Trigger`.
@@ -67,7 +73,7 @@ export interface SectionHeaderProps {
67
73
  className?: string
68
74
  }
69
75
 
70
- export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', render, className }: SectionHeaderProps) {
76
+ export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', hover = 'fill', render, className }: SectionHeaderProps) {
71
77
  const titleElement = useRender({
72
78
  render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
73
79
  props: {
@@ -93,11 +99,11 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
93
99
  return (
94
100
  <div
95
101
  className={cn(
96
- 'group flex h-[32px] items-center gap-1 rounded-lg px-2 transition-colors',
102
+ 'group flex h-[32px] shrink-0 items-center gap-1 rounded-lg px-2 transition-colors',
97
103
  // The fill says "this does something": a row with a toggle or actions
98
104
  // lights up, a fixed heading over rows does not (2026-09-09, the
99
105
  // Sidebar's fixed group).
100
- (chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
106
+ hover === 'fill' && (chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
101
107
  className,
102
108
  )}
103
109
  >
@@ -14,6 +14,7 @@ how).
14
14
 
15
15
  ## When
16
16
 
17
+ - **It is a bare label**: the row it sits in, and the room around it, are yours.
17
18
  - Titling a group of rows in a sidebar, a panel, a rail.
18
19
  - Inside a menu it arrives via **MenuSection** — don't place it there by
19
20
  hand.
@@ -35,8 +36,15 @@ import { SectionLabel } from '@estiva-app/ui'
35
36
 
36
37
  It is a bare span: the caller owns the row it sits in and the spacing
37
38
  around it. Read it `tone="secondary"` where it labels rather than leads — menu
38
- headings and the command palette's groups do (Katerina, 2026-09-01). A colour
39
- passed in `className` is refused by the lint (UIG-9).
39
+ headings and the command palette's groups do. A colour
40
+ passed in `className` is refused by the lint.
41
+
42
+ - Import it — never copy its classes. A copied class list drifts the first time
43
+ the label changes.
44
+
45
+ ## What it owns
46
+
47
+ Nothing. It only draws. Clicks and keys are yours.
40
48
 
41
49
  ## Props
42
50
 
package/src/Select.mdx CHANGED
@@ -29,8 +29,10 @@ corners and scrolling panels.
29
29
  things.
30
30
  - Several values → **ChipInput**.
31
31
  - Switching views of one thing → **Tabs**.
32
- - `disabled` explains nothing by itself — wrap it in a **WithTooltip**
33
- that gives the reason.
32
+ - `disabled` explains nothing by itself — give it `disabledReason`, which says
33
+ why and keeps it reachable by Tab. Busy for a moment while an action runs
34
+ needs no reason.
35
+ - Ticking several on and off in a list → **Checkbox** with `row`.
34
36
 
35
37
  ## How
36
38
 
@@ -53,16 +55,34 @@ import { Select } from '@estiva-app/ui'
53
55
  - **The list hangs under the trigger**, 4px below it, and flips above when
54
56
  the room below is worse. Base UI would rather lay it *over* the trigger
55
57
  with the chosen option covering the trigger's own text, the way macOS
56
- does; that is off (Katerina, D24) because every Select in both apps opens
57
- below one today.
58
+ does; that is off, so every Select opens below its trigger.
58
59
  - It caps at 288px, or at the room the screen actually has, whichever is
59
60
  smaller — and scrolls inside that. Measured in a bottom-right corner:
60
61
  the list flips above the trigger, slides left to stay on screen, and
61
62
  stands at its full 288.
63
+ - **`disabledReason="Read only"`** holds it shut and keeps it reachable by Tab,
64
+ showing the reason on hover and on keyboard focus — as `Button` does. Plain
65
+ `disabled` is a native disabled control: Tab skips it, so it cannot say why.
66
+ Given both, the reason wins, as on `Button`.
62
67
  - **The trigger is a `combobox`, not a `button`.** That is the ARIA pattern
63
68
  for this control and Base UI's doing; a test looking for it by role has
64
69
  to ask for `combobox`.
65
70
 
71
+ ## What it owns
72
+
73
+ It does these by itself. Write none of them around it.
74
+
75
+ | It does | So you never write | Refused when written by hand |
76
+ |---|---|---|
77
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
78
+ | Stays attached to its anchor on scroll and resize, and hides when its trigger scrolls out of sight | a scroll or resize listener, or placement arithmetic | ✓ |
79
+ | Closes on a press outside | a click listener on the page | ✓ |
80
+ | Takes its keys by itself: Enter and Space pick, Escape and Tab close | a key listener on the page | ✓ |
81
+ | Moves through its items with the arrow keys, and jumps to an option by its first letters | an arrow-key handler | ✓ |
82
+ | Says what it is to assistive technology: a list of options | `role="listbox"` or `role="option"` | ✓ |
83
+ | Keeps the highlighted option in view, and gives focus back to the trigger | scroll or focus code | |
84
+ | Keeps its height in a scrolling column | `shrink-0` | |
85
+
66
86
  ## Keys
67
87
 
68
88
  | Input | What happens |
@@ -89,9 +89,12 @@ export const InAField: Story = {
89
89
  ),
90
90
  }
91
91
 
92
- /** Unavailable — the same disabled treatment as every other control. Say why, with a tooltip around it. */
92
+ /** Unavailable — the same disabled treatment as every other control. To say why, `WithAReason`. */
93
93
  export const Disabled: Story = { render: (args) => <Demo {...args} initial="done" disabled /> }
94
94
 
95
+ /** `disabledReason`: it looks disabled and will not open, but Tab reaches it and the reason shows on hover and on focus. */
96
+ export const WithAReason: Story = { render: (args) => <Demo {...args} initial="done" disabledReason="Read only: you are a guest here" /> }
97
+
95
98
  /** Longer labels than the trigger is wide: the trigger truncates rather than growing; the menu takes the trigger's width as a minimum and grows past it. */
96
99
  export const LongLabels: Story = {
97
100
  render: (args) => (
@@ -57,6 +57,35 @@ describe('Select', () => {
57
57
  expect(trigger.getAttribute('aria-expanded')).toBe('false')
58
58
  })
59
59
 
60
+ it('disabledReason: held shut, reachable by Tab, and it says why', async () => {
61
+ const user = userEvent.setup()
62
+ const onChange = vi.fn()
63
+ render(<Select value="todo" onChange={onChange} options={STATUSES} ariaLabel="Status" disabledReason="Read only: you are a guest" />)
64
+ const trigger = screen.getByRole('combobox', { name: 'Status' })
65
+ expect(trigger.getAttribute('aria-disabled')).toBe('true')
66
+ expect(trigger.hasAttribute('disabled')).toBe(false)
67
+ await user.click(trigger)
68
+ await user.keyboard('{ArrowDown}{Enter}')
69
+ expect(screen.queryByRole('listbox')).toBeNull()
70
+ expect(onChange).not.toHaveBeenCalled()
71
+ await user.tab()
72
+ await user.tab({ shift: true })
73
+ expect(document.activeElement).toBe(trigger)
74
+ expect((await screen.findByRole('tooltip')).textContent).toBe('Read only: you are a guest')
75
+ })
76
+
77
+ // F2: as on Button, a reason wins over plain `disabled` — or the reason is never seen.
78
+ it('disabled with a reason: the reason wins', async () => {
79
+ const user = userEvent.setup()
80
+ render(<Select value="todo" onChange={vi.fn()} options={STATUSES} ariaLabel="Status" disabled disabledReason="Read only: you are a guest" />)
81
+ const trigger = screen.getByRole('combobox', { name: 'Status' })
82
+ expect(trigger.getAttribute('aria-disabled')).toBe('true')
83
+ expect(trigger.hasAttribute('disabled')).toBe(false)
84
+ await user.tab()
85
+ expect(document.activeElement).toBe(trigger)
86
+ expect((await screen.findByRole('tooltip')).textContent).toBe('Read only: you are a guest')
87
+ })
88
+
60
89
  it('shows the placeholder when nothing matches', () => {
61
90
  render(<Controlled initial="" placeholder="Choose" />)
62
91
  expect(screen.getByRole('combobox', { name: 'Status' }).textContent).toContain('Choose')