@estiva-app/ui 0.24.0 → 0.25.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 (85) hide show
  1. package/dist/ErrorBoundary.d.ts +32 -0
  2. package/dist/ErrorBoundary.d.ts.map +1 -0
  3. package/dist/ListColumn.d.ts +42 -0
  4. package/dist/ListColumn.d.ts.map +1 -0
  5. package/dist/Menu.d.ts +7 -0
  6. package/dist/Menu.d.ts.map +1 -1
  7. package/dist/Select.d.ts +1 -0
  8. package/dist/Select.d.ts.map +1 -1
  9. package/dist/Toolbar.d.ts +18 -0
  10. package/dist/Toolbar.d.ts.map +1 -1
  11. package/dist/index.d.ts +4 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +183 -108
  14. package/dist/index.js.map +4 -4
  15. package/package.json +1 -1
  16. package/registry.json +211 -4
  17. package/src/AppShell.mdx +8 -1
  18. package/src/AttachmentCard.mdx +3 -1
  19. package/src/Avatar.mdx +7 -2
  20. package/src/Banner.mdx +4 -5
  21. package/src/Breadcrumb.mdx +2 -0
  22. package/src/Button.mdx +3 -2
  23. package/src/Button.tsx +2 -2
  24. package/src/Card.mdx +5 -1
  25. package/src/Checkbox.mdx +1 -0
  26. package/src/ChipInput.mdx +1 -1
  27. package/src/CollapsibleSection.mdx +4 -4
  28. package/src/ConfirmDialog.mdx +2 -2
  29. package/src/ContainerHeader.mdx +12 -3
  30. package/src/DialogShell.mdx +1 -1
  31. package/src/Divider.mdx +5 -2
  32. package/src/EditableText.mdx +6 -1
  33. package/src/EmptyState.mdx +2 -2
  34. package/src/ErrorBoundary.mdx +66 -0
  35. package/src/ErrorBoundary.stories.tsx +64 -0
  36. package/src/ErrorBoundary.test.tsx +79 -0
  37. package/src/ErrorBoundary.tsx +54 -0
  38. package/src/Field.mdx +3 -4
  39. package/src/Form.mdx +1 -1
  40. package/src/IconButton.mdx +1 -1
  41. package/src/IdentityMenu.mdx +1 -1
  42. package/src/InlineChip.mdx +3 -0
  43. package/src/Kbd.mdx +2 -2
  44. package/src/Link.mdx +3 -1
  45. package/src/ListColumn.mdx +95 -0
  46. package/src/ListColumn.stories.tsx +160 -0
  47. package/src/ListColumn.test.tsx +63 -0
  48. package/src/ListColumn.tsx +86 -0
  49. package/src/Menu.mdx +7 -2
  50. package/src/Menu.stories.tsx +6 -7
  51. package/src/Menu.test.tsx +19 -1
  52. package/src/Menu.tsx +11 -1
  53. package/src/MenuItem.mdx +3 -0
  54. package/src/NavItem.mdx +4 -0
  55. package/src/Person.mdx +3 -0
  56. package/src/PersonTrigger.mdx +2 -1
  57. package/src/PersonTrigger.tsx +1 -1
  58. package/src/Popover.mdx +2 -0
  59. package/src/Rail.mdx +3 -0
  60. package/src/RailItem.mdx +4 -0
  61. package/src/RailItem.tsx +1 -1
  62. package/src/Reaction.mdx +1 -0
  63. package/src/Reaction.tsx +1 -1
  64. package/src/ScrollArea.mdx +3 -0
  65. package/src/SectionHeader.mdx +10 -2
  66. package/src/SectionHeader.tsx +1 -1
  67. package/src/SectionLabel.mdx +10 -4
  68. package/src/Select.mdx +3 -2
  69. package/src/Select.test.tsx +12 -0
  70. package/src/Select.tsx +4 -3
  71. package/src/Skeleton.mdx +5 -1
  72. package/src/Skeleton.tsx +1 -1
  73. package/src/TextInput.mdx +1 -0
  74. package/src/TextInput.tsx +1 -1
  75. package/src/Toolbar.mdx +9 -2
  76. package/src/Toolbar.stories.tsx +19 -2
  77. package/src/Toolbar.test.tsx +24 -1
  78. package/src/Toolbar.tsx +22 -0
  79. package/src/Tooltip.mdx +2 -1
  80. package/src/Tooltip.test.tsx +22 -0
  81. package/src/Tooltip.tsx +1 -1
  82. package/src/TopBar.mdx +8 -1
  83. package/src/heights.test.tsx +78 -0
  84. package/src/index.ts +4 -2
  85. package/src/registry/registry.test.ts +16 -8
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+ import { ContainerHeader } from './ContainerHeader'
4
+ import { ErrorBoundary } from './ErrorBoundary'
5
+ import { ScrollArea } from './ScrollArea'
6
+
7
+ /**
8
+ * The list column of a page: the column beside the page's main area that lists
9
+ * what it can open — 290px wide, a line on its right, its name in a
10
+ * `ContainerHeader`, and a list under it that scrolls.
11
+ *
12
+ * Moved in from Peek as it looks (UIG-14, N9), where five pages drew it by
13
+ * hand and each carried the same numbers: the frame's 290px and its line, and
14
+ * the list's steps — 16px under the header, 12px above the bottom, rows 12px
15
+ * in from each side and 2px apart. The numbers live here now, once. One
16
+ * spacing for every list (Katerina, D5): a list of groups heads each with a
17
+ * `CollapsibleSection` or a `SectionHeader`.
18
+ *
19
+ * It scrolls its rows itself. A list column that did not was the first of the
20
+ * eight mistakes on the page that led to UIG-15: the frame's card clips, so a
21
+ * list that grew past the fold was simply cut off, with nothing to scroll.
22
+ *
23
+ * It keeps a crash inside itself (D1, D3): if its rows, its actions or the row
24
+ * above break, the column keeps its title and shows `ErrorBoundary`'s message
25
+ * in the middle of the room the list had, and the rest of the page keeps working.
26
+ *
27
+ * `collapsed` closes it with the rail, as Peek's did: it narrows to nothing
28
+ * and fades, in 300ms. What opens and closes the rail stays the app's.
29
+ */
30
+ export interface ListColumnProps {
31
+ /** The column's name, in its header. A node for a title that is more than words. */
32
+ title: ReactNode
33
+ /** A chevron after the title, as `ContainerHeader` draws it. */
34
+ chevron?: boolean
35
+ /** The column's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
36
+ actions?: ReactNode
37
+ /** A row between the header and the list that stays put while the list scrolls — a field that adds to the list. */
38
+ above?: ReactNode
39
+ /** Closes the column with the rail: it narrows to nothing and fades. */
40
+ collapsed?: boolean
41
+ /** The rows, 2px apart. Groups are headed by a `CollapsibleSection` or a `SectionHeader`. */
42
+ children: ReactNode
43
+ /** Placement only. */
44
+ className?: string
45
+ }
46
+
47
+ function Column({ title, chevron = false, actions, above, collapsed = false, children, className, broken = false }: ListColumnProps & { broken?: boolean }) {
48
+ return (
49
+ <div
50
+ className={cn(
51
+ // `shrink-0`: the column keeps its 290px beside a main area that grows.
52
+ 'flex shrink-0 flex-col overflow-hidden border-r border-border-subtle transition-[width,opacity] duration-300 ease-in-out',
53
+ collapsed ? 'w-0 border-r-0 opacity-0' : 'w-[290px] opacity-100',
54
+ className,
55
+ )}
56
+ data-collapsed={collapsed || undefined}
57
+ >
58
+ <ContainerHeader title={title} chevron={chevron} actions={actions} />
59
+ {above}
60
+ {broken ? (
61
+ // The message takes the room the list had and centres itself in it (Katerina, 19 September).
62
+ <div className="flex min-h-0 flex-1 flex-col px-3">{children}</div>
63
+ ) : (
64
+ <ScrollArea className="flex-1" contentClassName="flex flex-col gap-0.5 px-3 pt-4 pb-3">
65
+ {children}
66
+ </ScrollArea>
67
+ )}
68
+ </div>
69
+ )
70
+ }
71
+
72
+ export function ListColumn(props: ListColumnProps) {
73
+ return (
74
+ <ErrorBoundary
75
+ label="list panel"
76
+ frame={(message) => (
77
+ // Only the name comes back with the message: the actions and the row above may be what threw.
78
+ <Column title={props.title} chevron={props.chevron} collapsed={props.collapsed} className={props.className} broken>
79
+ {message}
80
+ </Column>
81
+ )}
82
+ >
83
+ <Column {...props} />
84
+ </ErrorBoundary>
85
+ )
86
+ }
package/src/Menu.mdx CHANGED
@@ -76,6 +76,8 @@ the trigger as "close" rather than as a press outside.
76
76
 
77
77
  <Canvas of={MenuStories.OnACard} />
78
78
 
79
+ - **Planned:** one part for the pop-up lists while typing in an editor (UIG-31).
80
+
79
81
  ## What it owns
80
82
 
81
83
  It does these by itself. Write none of them around it.
@@ -138,8 +140,11 @@ Four surfaces used to claim menu items of nothing.
138
140
 
139
141
  A menu whose rows are `px-3` (tall picker rows) aligns its headings by passing
140
142
  `className="px-3"` to `MenuSection` — the heading row is `px-2` by default,
141
- matching the default rows. A `Divider` inside a menu needs nothing: the panel
142
- already runs it the width of the rows it separates.
143
+ matching the default rows.
144
+
145
+ **Between groups, a `MenuSeparator`**: the line, the width of the rows it
146
+ separates, with its own 4px above and below. Not a `Divider`, whose room is
147
+ its caller's.
143
148
 
144
149
  ## Props
145
150
 
@@ -1,9 +1,8 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconCopy, IconDots, IconPencil, IconSquareRounded, IconTrash } from '@tabler/icons-react'
3
3
  import { Button } from './Button'
4
- import { Divider } from './Divider'
5
4
  import { IconButton } from './IconButton'
6
- import { Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSub } from './Menu'
5
+ import { Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSeparator, MenuSub } from './Menu'
7
6
  import { SectionLabel } from './SectionLabel'
8
7
 
9
8
  /**
@@ -41,7 +40,7 @@ export const Items: Story = {
41
40
  <MenuPanel className="min-w-[180px]">
42
41
  <MenuItem label="Rename" leading={icon(IconPencil)} onClick={() => {}} />
43
42
  <MenuItem label="Duplicate" leading={icon(IconCopy)} shortcut="Ctrl+D" onClick={() => {}} />
44
- <Divider className="my-1" />
43
+ <MenuSeparator />
45
44
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
46
45
  </MenuPanel>
47
46
  ),
@@ -57,7 +56,7 @@ export const Sections: Story = {
57
56
  <MenuItem label="Item one" selected onClick={() => {}} />
58
57
  <MenuItem label="Item two" onClick={() => {}} />
59
58
  </MenuSection>
60
- <Divider className="my-1" />
59
+ <MenuSeparator />
61
60
  <MenuSection label="Another section">
62
61
  <MenuItem label="Option one" onClick={() => {}} />
63
62
  <MenuItem label="Option two" onClick={() => {}} />
@@ -75,7 +74,7 @@ export const WithARow: Story = {
75
74
  <span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">Item one</span>
76
75
  <SectionLabel tone="secondary">Label</SectionLabel>
77
76
  </MenuRow>
78
- <Divider className="my-1" />
77
+ <MenuSeparator />
79
78
  <MenuItem label="An action" onClick={() => {}} />
80
79
  </MenuPanel>
81
80
  ),
@@ -99,7 +98,7 @@ export const FromATrigger: Story = {
99
98
  <MenuItem label="Item two" onClick={() => {}} />
100
99
  <MenuItem label="Item three" onClick={() => {}} />
101
100
  </MenuSub>
102
- <Divider className="my-1" />
101
+ <MenuSeparator />
103
102
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
104
103
  </Menu>
105
104
  ),
@@ -149,7 +148,7 @@ export const OnACard: Story = {
149
148
  <MenuItem label="Item two" onClick={() => {}} />
150
149
  </MenuSub>
151
150
  </MenuSection>
152
- <Divider className="my-1" />
151
+ <MenuSeparator />
153
152
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
154
153
  </Menu>
155
154
  </div>
package/src/Menu.test.tsx CHANGED
@@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
11
11
  import { cleanup, render, screen, waitFor } from '@testing-library/react'
12
12
  import userEvent from '@testing-library/user-event'
13
13
  import { Button } from './Button'
14
- import { Menu, MenuItem, MenuPanel, MenuSection, MenuSub } from './Menu'
14
+ import { Menu, MenuItem, MenuPanel, MenuSection, MenuSeparator, MenuSub } from './Menu'
15
15
 
16
16
  afterEach(cleanup)
17
17
 
@@ -269,6 +269,24 @@ describe('Menu', () => {
269
269
  * plugin already owns the keyboard — so `MenuItem` and `MenuSection` have to
270
270
  * work with no `Menu` above them.
271
271
  */
272
+ describe('MenuSeparator', () => {
273
+ // UIG-14: the line between groups brings its own 4px above and below; a plain
274
+ // Divider left that to each caller, and Peek's main menus had none.
275
+ it('is a separator with its own room, the width of the rows', async () => {
276
+ render(
277
+ <Menu open trigger={<Button>More</Button>}>
278
+ <MenuItem label="One" />
279
+ <MenuSeparator />
280
+ <MenuItem label="Two" />
281
+ </Menu>,
282
+ )
283
+ const line = await screen.findByRole('separator')
284
+ const classes = (line.getAttribute('class') ?? '').split(' ')
285
+ expect(classes).toContain('my-1')
286
+ expect(classes).toContain('mx-0')
287
+ })
288
+ })
289
+
272
290
  describe('rows on a bare MenuPanel', () => {
273
291
  /**
274
292
  * It is a button, and it does NOT claim to be a menu item. ARIA requires a
package/src/Menu.tsx CHANGED
@@ -2,7 +2,7 @@ import { createContext, useContext, type ComponentPropsWithRef, type ReactElemen
2
2
  import { IconChevronRight } from '@tabler/icons-react'
3
3
  import { Menu as BaseMenu } from '@base-ui/react/menu'
4
4
  import { cn } from './cn'
5
- import { DividerInPanel } from './Divider'
5
+ import { Divider, DividerInPanel } from './Divider'
6
6
  import { triggerDisabled } from './triggerDisabled'
7
7
  import { ScrollArea } from './ScrollArea'
8
8
  import { Kbd } from './Kbd'
@@ -590,6 +590,16 @@ export function MenuSection({ label, children, className }: { label: string; chi
590
590
  )
591
591
  }
592
592
 
593
+ /**
594
+ * The line between two groups of a menu, with its own 4px above and below
595
+ * (UIG-14, Katerina 19 September). A plain `Divider` left that room to each
596
+ * caller, and Peek's menus drew it three ways — none, 4px, 4px and inset — so
597
+ * the lines in its main menus touched the rows.
598
+ */
599
+ export function MenuSeparator() {
600
+ return <Divider className="my-1" />
601
+ }
602
+
593
603
  /** A non-interactive row inside a menu, at the item's own geometry. */
594
604
  export function MenuRow({ children, className }: { children: ReactNode; className?: string }) {
595
605
  return <div className={cn('flex items-center gap-2 px-2 py-1.5', className)}>{children}</div>
package/src/MenuItem.mdx CHANGED
@@ -51,6 +51,8 @@ import { IconPencil } from '@tabler/icons-react'
51
51
  - A destructive row's `leading` icon takes the error colour itself
52
52
  (`text-error-default`) — the row colours only its label.
53
53
 
54
+ - **Planned:** one part for the pop-up lists while typing in an editor (UIG-31).
55
+
54
56
  ## What it owns
55
57
 
56
58
  It does these by itself. Write none of them around it.
@@ -59,6 +61,7 @@ It does these by itself. Write none of them around it.
59
61
  |---|---|---|
60
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 | |
61
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` | |
62
65
 
63
66
  ## Props
64
67
 
package/src/NavItem.mdx CHANGED
@@ -48,6 +48,9 @@ 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
+
51
54
  ## What it owns
52
55
 
53
56
  It does these by itself. Write none of them around it.
@@ -55,6 +58,7 @@ It does these by itself. Write none of them around it.
55
58
  | It does | So you never write | Refused when written by hand |
56
59
  |---|---|---|
57
60
  | Marks the current place for assistive technology when `active` | `aria-current` | |
61
+ | Keeps its 32px in a scrolling column | `shrink-0` | |
58
62
 
59
63
  ## Props
60
64
 
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,8 @@ 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
+
42
45
  ## What it owns
43
46
 
44
47
  Nothing. It only draws. Clicks and keys are yours.
@@ -47,7 +47,7 @@ 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
@@ -61,6 +61,7 @@ It does these by itself. Write none of them around it.
61
61
  |---|---|---|
62
62
  | Is a button: Tab reaches it, Enter and Space call your `onClick` | a clickable `<div>` | |
63
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` | |
64
65
 
65
66
  ## Keys
66
67
 
@@ -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
@@ -124,6 +124,8 @@ something belongs on a `trigger`.
124
124
 
125
125
  <Canvas of={PopoverStories.FlippedForRoom} />
126
126
 
127
+ - **Planned:** one part for the pop-up lists while typing in an editor (UIG-31).
128
+
127
129
  ## What it owns
128
130
 
129
131
  It does these by itself. Write none of them around it.
package/src/Rail.mdx CHANGED
@@ -52,6 +52,9 @@ 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
+
55
58
  ## What it owns
56
59
 
57
60
  It does these by itself. Write none of them around it.
package/src/RailItem.mdx CHANGED
@@ -45,6 +45,9 @@ 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
+
48
51
  ## What it owns
49
52
 
50
53
  It does these by itself. Write none of them around it.
@@ -52,6 +55,7 @@ It does these by itself. Write none of them around it.
52
55
  | It does | So you never write | Refused when written by hand |
53
56
  |---|---|---|
54
57
  | Marks the current place for assistive technology when `active` | `aria-current` | |
58
+ | Keeps its 48px in a scrolling column | `shrink-0` | |
55
59
 
56
60
  ## Props
57
61
 
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
@@ -58,6 +58,7 @@ It does these by itself. Write none of them around it.
58
58
  |---|---|---|
59
59
  | Is a toggle: pressed or not, and says so to assistive technology | `aria-pressed` | |
60
60
  | Presses on Enter and Space; Tab skips it while disabled | a key handler | |
61
+ | Keeps its 24px in a scrolling column | `shrink-0` | |
61
62
 
62
63
  ## Keys
63
64
 
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
@@ -55,6 +55,9 @@ import { ScrollArea } from '@estiva-app/ui'
55
55
  Chrome: wheel down over a sideways region inside a page, the page moves
56
56
  and the region does not; a sideways swipe, the other way round.
57
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
+
58
61
  ## What it owns
59
62
 
60
63
  It does these by itself. Write none of them around it.
@@ -16,6 +16,9 @@ title the toggle — that is where a section folds.
16
16
 
17
17
  - A sidebar or panel section that carries its own actions — add, sort,
18
18
  filter.
19
+ - **A heading over a group of rows, even with nothing to act on**: no
20
+ `actions`, and `hover="none"` so the row stays still under the pointer.
21
+ Never a row drawn by hand with a SectionLabel in it.
19
22
  - `showActions="always"` keeps the actions visible — for touch, or for a
20
23
  section whose actions are the point.
21
24
 
@@ -25,7 +28,8 @@ title the toggle — that is where a section folds.
25
28
 
26
29
  - A section that opens and closes → **CollapsibleSection**: the header,
27
30
  the rows, the slide, and a `storageKey` that remembers.
28
- - A title with no chevron and no actions → **SectionLabel** alone.
31
+ - A label inside a row of another shape — a dialog's section, a panel's
32
+ title, a card's line → **SectionLabel**.
29
33
  - A heading inside a menu → **MenuSection**.
30
34
  - Page-level headings → the `h*` type tokens.
31
35
 
@@ -39,7 +43,7 @@ import { IconPlus } from '@tabler/icons-react'
39
43
  ```
40
44
 
41
45
  - `chevron`, `isExpanded` and `onToggle` are CollapsibleSection's to set
42
- (decided 2026-09-09: one component folds, and it is that one). With
46
+ (one component folds, and it is that one). With
43
47
  them, the title is a button that fills the row up to the actions — the
44
48
  whole row is the hit target, the keyboard can toggle it, and it says its
45
49
  state (`aria-expanded`).
@@ -57,6 +61,9 @@ import { IconPlus } from '@tabler/icons-react'
57
61
  actions are always shown (`showActions="always"`), where the buttons light up
58
62
  on their own. The default, `fill`, lights a row that toggles or has actions.
59
63
 
64
+ - The row is 32px tall, 8px in on each side, 4px between the title and the
65
+ actions, with 8px corners.
66
+
60
67
  ## What it owns
61
68
 
62
69
  It does these by itself. Write none of them around it.
@@ -65,6 +72,7 @@ It does these by itself. Write none of them around it.
65
72
  |---|---|---|
66
73
  | 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` | |
67
74
  | Shows its actions while one of them has focus | a focus style of your own | |
75
+ | Keeps its 32px in a scrolling column | `shrink-0` | |
68
76
 
69
77
  ## Keys
70
78
 
@@ -99,7 +99,7 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
99
99
  return (
100
100
  <div
101
101
  className={cn(
102
- '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',
103
103
  // The fill says "this does something": a row with a toggle or actions
104
104
  // lights up, a fixed heading over rows does not (2026-09-09, the
105
105
  // Sidebar's fixed group).
@@ -14,13 +14,16 @@ how).
14
14
 
15
15
  ## When
16
16
 
17
- - Titling a group of rows in a sidebar, a panel, a rail.
17
+ - **It is a bare label**: the row it sits in, and the room around it, are yours.
18
+ - A label inside a row of another shape: a dialog's section, a panel's
19
+ title, a card's line.
18
20
  - Inside a menu it arrives via **MenuSection** — don't place it there by
19
21
  hand.
20
22
 
21
23
  ## When not
22
24
 
23
- - A heading that also collapses or carries actions → **SectionHeader**.
25
+ - A heading over a group of rows → **SectionHeader**, even with nothing to
26
+ act on; a group that folds → **CollapsibleSection**.
24
27
  - The label above a stacked control → **Property** `stacked` — a field
25
28
  label is a different voice, at a different size, on purpose.
26
29
  - Page-level headings → the `h*` type tokens.
@@ -35,8 +38,11 @@ import { SectionLabel } from '@estiva-app/ui'
35
38
 
36
39
  It is a bare span: the caller owns the row it sits in and the spacing
37
40
  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).
41
+ headings and the command palette's groups do. A colour
42
+ passed in `className` is refused by the lint.
43
+
44
+ - Import it — never copy its classes. A copied class list drifts the first time
45
+ the label changes.
40
46
 
41
47
  ## What it owns
42
48
 
package/src/Select.mdx CHANGED
@@ -55,8 +55,7 @@ import { Select } from '@estiva-app/ui'
55
55
  - **The list hangs under the trigger**, 4px below it, and flips above when
56
56
  the room below is worse. Base UI would rather lay it *over* the trigger
57
57
  with the chosen option covering the trigger's own text, the way macOS
58
- does; that is off (Katerina, D24) because every Select in both apps opens
59
- below one today.
58
+ does; that is off, so every Select opens below its trigger.
60
59
  - It caps at 288px, or at the room the screen actually has, whichever is
61
60
  smaller — and scrolls inside that. Measured in a bottom-right corner:
62
61
  the list flips above the trigger, slides left to stay on screen, and
@@ -64,6 +63,7 @@ import { Select } from '@estiva-app/ui'
64
63
  - **`disabledReason="Read only"`** holds it shut and keeps it reachable by Tab,
65
64
  showing the reason on hover and on keyboard focus — as `Button` does. Plain
66
65
  `disabled` is a native disabled control: Tab skips it, so it cannot say why.
66
+ Given both, the reason wins, as on `Button`.
67
67
  - **The trigger is a `combobox`, not a `button`.** That is the ARIA pattern
68
68
  for this control and Base UI's doing; a test looking for it by role has
69
69
  to ask for `combobox`.
@@ -81,6 +81,7 @@ It does these by itself. Write none of them around it.
81
81
  | Moves through its items with the arrow keys, and jumps to an option by its first letters | an arrow-key handler | ✓ |
82
82
  | Says what it is to assistive technology: a list of options | `role="listbox"` or `role="option"` | ✓ |
83
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` | |
84
85
 
85
86
  ## Keys
86
87
 
@@ -74,6 +74,18 @@ describe('Select', () => {
74
74
  expect((await screen.findByRole('tooltip')).textContent).toBe('Read only: you are a guest')
75
75
  })
76
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
+
77
89
  it('shows the placeholder when nothing matches', () => {
78
90
  render(<Controlled initial="" placeholder="Choose" />)
79
91
  expect(screen.getByRole('combobox', { name: 'Status' }).textContent).toContain('Choose')
package/src/Select.tsx CHANGED
@@ -56,6 +56,7 @@ export interface SelectProps {
56
56
  * looks disabled and will not open, but Tab still reaches it, and the reason
57
57
  * shows as its tooltip — a native disabled button cannot be focused, so it
58
58
  * could never say why. Takes the place of wrapping it in `WithTooltip`.
59
+ * Given with `disabled`, the reason wins, as on `Button`.
59
60
  */
60
61
  disabledReason?: string
61
62
  className?: string
@@ -70,7 +71,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
70
71
  const selected = options.find((o) => o.value === value)
71
72
  // With a reason it is held shut rather than disabled: a disabled trigger is
72
73
  // a native `disabled` button, which Tab skips and a tooltip cannot open on.
73
- const held = Boolean(disabledReason) && !disabled
74
+ const held = Boolean(disabledReason)
74
75
  const trigger = (
75
76
  <BaseSelect.Trigger
76
77
  aria-label={ariaLabel}
@@ -96,7 +97,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
96
97
  'focus-visible:border-border-focus aria-expanded:border-border-focus',
97
98
  'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
98
99
  size === 'default' && 'px-3 py-2 text-input-value',
99
- size === 'small' && 'h-6 px-2 text-caption',
100
+ size === 'small' && 'h-6 min-h-6 px-2 text-caption',
100
101
  className,
101
102
  )}
102
103
  >
@@ -116,7 +117,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
116
117
  if (!held) onChange(next as string)
117
118
  }}
118
119
  {...(held ? { open: false, onOpenChange: () => {} } : {})}
119
- disabled={disabled}
120
+ disabled={disabled && !held}
120
121
  // Non-modal, as it has always been: the page behind stays scrollable and
121
122
  // keeps its scrollbar, so opening a select never shifts the layout.
122
123
  modal={false}
package/src/Skeleton.mdx CHANGED
@@ -40,4 +40,8 @@ import { SkeletonList, SkeletonBar } from '@estiva-app/ui'
40
40
 
41
41
  ## What it owns
42
42
 
43
- Nothing. It only draws. Clicks and keys are yours.
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
+ | Keeps each row's 32px in a scrolling column | `shrink-0` | |
package/src/Skeleton.tsx CHANGED
@@ -21,7 +21,7 @@ const ROW_BAR_WIDTHS = [150, 100, 170, 120, 90, 140, 110, 160]
21
21
  /** A 32px row: a 16px square and a bar, like a row with a face and a name. */
22
22
  export function SkeletonRow({ barWidth = 130 }: { barWidth?: number }) {
23
23
  return (
24
- <div className="flex items-center gap-2 px-2 h-[32px] rounded-lg">
24
+ <div className="flex shrink-0 items-center gap-2 px-2 h-[32px] rounded-lg">
25
25
  <SkeletonBar className="w-4 h-4 shrink-0" />
26
26
  <SkeletonBar className="h-3.5" style={{ width: barWidth }} />
27
27
  </div>
package/src/TextInput.mdx CHANGED
@@ -53,6 +53,7 @@ It does these by itself. Write none of them around it.
53
53
  | It does | So you never write | Refused when written by hand |
54
54
  |---|---|---|
55
55
  | Inside a `Field`, takes its name from the Field's label, and its error state | an `id` / `htmlFor` pair, or `aria-invalid` | |
56
+ | Keeps its height in a scrolling column | `shrink-0` | |
56
57
 
57
58
  ## Props
58
59
 
package/src/TextInput.tsx CHANGED
@@ -38,7 +38,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
38
38
  'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg',
39
39
  // The small size is the small Select's trigger, class for class.
40
40
  size === 'default' && 'px-3 py-2 text-input-value',
41
- size === 'small' && 'h-6 px-2 text-caption',
41
+ size === 'small' && 'h-6 min-h-6 px-2 text-caption',
42
42
  'text-text-primary placeholder:text-text-muted',
43
43
  'outline-none transition-colors',
44
44
  'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
package/src/Toolbar.mdx CHANGED
@@ -69,8 +69,8 @@ import { Toolbar, ToolbarButton, ToolbarSeparator } from '@estiva-app/ui'
69
69
  A toolbar floats over what it acts on — a card, a paragraph, an image — so it
70
70
  draws the elevated surface that separates it from that: **the same `MenuPanel`
71
71
  a `Menu` draws**, because a floating strip and a floating list are the same
72
- box. Peek had built this twice and the two had already drifted: one
73
- `rounded-sm` with `shadow-sm`, the other `rounded-lg` with `shadow-lg`.
72
+ box. Built twice by hand, the two drift: one `rounded-sm` with `shadow-sm`,
73
+ the other `rounded-lg` with `shadow-lg`.
74
74
 
75
75
  `surface={false}` for a strip inside something that draws that box already — a
76
76
  `Popover`, a dialog, a card's own panel.
@@ -82,6 +82,13 @@ why the placement is its job and not arithmetic of ours.
82
82
 
83
83
  <Canvas of={ToolbarStories.OnAnExistingSurface} />
84
84
 
85
+ **A control that goes somewhere is a `ToolbarLink`** with an `href`: the
86
+ strip's look, in the same walk, with a real address, so a Ctrl+click opens a
87
+ new tab. A `ToolbarButton` cannot be a link. A place you cannot go is left out
88
+ of the strip rather than disabled.
89
+
90
+ <Canvas of={ToolbarStories.WithALink} />
91
+
85
92
  ## What it owns
86
93
 
87
94
  It does these by itself. Write none of them around it.