@estiva-app/ui 0.24.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 (74) hide show
  1. package/dist/ListColumn.d.ts +41 -0
  2. package/dist/ListColumn.d.ts.map +1 -0
  3. package/dist/Select.d.ts +1 -0
  4. package/dist/Select.d.ts.map +1 -1
  5. package/dist/Toolbar.d.ts +18 -0
  6. package/dist/Toolbar.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +65 -38
  10. package/dist/index.js.map +4 -4
  11. package/package.json +1 -1
  12. package/registry.json +164 -4
  13. package/src/AppShell.mdx +8 -1
  14. package/src/AttachmentCard.mdx +3 -1
  15. package/src/Avatar.mdx +7 -2
  16. package/src/Banner.mdx +4 -5
  17. package/src/Breadcrumb.mdx +2 -0
  18. package/src/Button.mdx +3 -2
  19. package/src/Button.tsx +2 -2
  20. package/src/Card.mdx +5 -1
  21. package/src/Checkbox.mdx +1 -0
  22. package/src/ChipInput.mdx +1 -1
  23. package/src/CollapsibleSection.mdx +3 -3
  24. package/src/ConfirmDialog.mdx +2 -2
  25. package/src/ContainerHeader.mdx +12 -3
  26. package/src/DialogShell.mdx +1 -1
  27. package/src/Divider.mdx +3 -0
  28. package/src/EditableText.mdx +6 -1
  29. package/src/EmptyState.mdx +2 -2
  30. package/src/Field.mdx +3 -4
  31. package/src/Form.mdx +1 -1
  32. package/src/IconButton.mdx +1 -1
  33. package/src/IdentityMenu.mdx +1 -1
  34. package/src/InlineChip.mdx +3 -0
  35. package/src/Kbd.mdx +2 -2
  36. package/src/Link.mdx +3 -1
  37. package/src/ListColumn.mdx +87 -0
  38. package/src/ListColumn.stories.tsx +136 -0
  39. package/src/ListColumn.test.tsx +50 -0
  40. package/src/ListColumn.tsx +63 -0
  41. package/src/Menu.mdx +2 -0
  42. package/src/MenuItem.mdx +3 -0
  43. package/src/NavItem.mdx +4 -0
  44. package/src/Person.mdx +3 -0
  45. package/src/PersonTrigger.mdx +2 -1
  46. package/src/PersonTrigger.tsx +1 -1
  47. package/src/Popover.mdx +2 -0
  48. package/src/Rail.mdx +3 -0
  49. package/src/RailItem.mdx +4 -0
  50. package/src/RailItem.tsx +1 -1
  51. package/src/Reaction.mdx +1 -0
  52. package/src/Reaction.tsx +1 -1
  53. package/src/ScrollArea.mdx +3 -0
  54. package/src/SectionHeader.mdx +5 -1
  55. package/src/SectionHeader.tsx +1 -1
  56. package/src/SectionLabel.mdx +6 -2
  57. package/src/Select.mdx +3 -2
  58. package/src/Select.test.tsx +12 -0
  59. package/src/Select.tsx +4 -3
  60. package/src/Skeleton.mdx +5 -1
  61. package/src/Skeleton.tsx +1 -1
  62. package/src/TextInput.mdx +1 -0
  63. package/src/TextInput.tsx +1 -1
  64. package/src/Toolbar.mdx +9 -2
  65. package/src/Toolbar.stories.tsx +19 -2
  66. package/src/Toolbar.test.tsx +24 -1
  67. package/src/Toolbar.tsx +22 -0
  68. package/src/Tooltip.mdx +2 -1
  69. package/src/Tooltip.test.tsx +22 -0
  70. package/src/Tooltip.tsx +1 -1
  71. package/src/TopBar.mdx +8 -1
  72. package/src/heights.test.tsx +78 -0
  73. package/src/index.ts +2 -1
  74. package/src/registry/registry.test.ts +16 -8
@@ -0,0 +1,87 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ListColumnStories from './ListColumn.stories'
3
+
4
+ <Meta of={ListColumnStories} />
5
+
6
+ # ListColumn
7
+
8
+ The list column of a page: 290px wide with a line on its right, its name in a
9
+ `ContainerHeader`, and the list under it, which scrolls.
10
+
11
+ <Canvas of={ListColumnStories.Default} />
12
+
13
+ ## When
14
+
15
+ - **Its size is its own**: 290px wide and the full height of where it sits.
16
+ Nothing around it sets its width, and nothing squeezes it.
17
+ - A page that lists things beside the one that is open: the list on the left,
18
+ the chosen thing to its right.
19
+ - `spacing="rows"` (the default, 2px between rows) for a list of one kind of
20
+ row. `spacing="sections"` (4px) for groups with labels and dividers of their
21
+ own.
22
+ - `above` for a row that stays put while the list scrolls: a field that adds
23
+ to the list.
24
+ - `collapsed` closes it with the rail: it narrows to nothing and fades, in
25
+ 300ms. What opens and closes the rail is yours.
26
+
27
+ <Canvas of={ListColumnStories.Sections} />
28
+
29
+ <Canvas of={ListColumnStories.WithARowAbove} />
30
+
31
+ ## When not
32
+
33
+ - The app's own places, as the frame's column → **Sidebar**, in AppShell's
34
+ solid frame.
35
+ - A short list inside a panel or a card → your rows in a **ScrollArea**.
36
+ - A bar over something that is not a list → **ContainerHeader** on its own.
37
+
38
+ ## How
39
+
40
+ ```tsx
41
+ import { ListColumn, Toolbar, ToolbarButton } from '@estiva-app/ui'
42
+
43
+ <ListColumn
44
+ title="Items"
45
+ actions={
46
+ <Toolbar aria-label="Item list actions" surface={false}>
47
+ <ToolbarButton tooltip="New item" aria-label="New item" onClick={create}>
48
+ <IconEdit size={16} stroke={1.5} />
49
+ </ToolbarButton>
50
+ </Toolbar>
51
+ }
52
+ collapsed={railClosed}
53
+ >
54
+ {items.map((item) => (
55
+ <ItemRow key={item.id} item={item} />
56
+ ))}
57
+ </ListColumn>
58
+ ```
59
+
60
+ - It sits beside the page's main area, inside AppShell's card: put both in a
61
+ flex row, the main area `flex-1`.
62
+ - The numbers: 290px wide, a 1px line on the right. The header is
63
+ ContainerHeader's: 48px, the title 20px in. The list starts 16px under the
64
+ header and ends 12px above the bottom, and its rows sit 12px in from each
65
+ side.
66
+ - The rows are yours. It scrolls them, so a list that grows with its data is
67
+ never cut off at the fold, even when today's data fits.
68
+ - **Planned:** dragging its edge to resize it, with `SplitLayout` in
69
+ `@estiva-app/ui/layout` (migration stage 8). The 380px column beside a
70
+ conversation gets the same treatment then.
71
+
72
+ <Canvas of={ListColumnStories.ManyRows} />
73
+
74
+ ## What it owns
75
+
76
+ It does these by itself. Write none of them around it.
77
+
78
+ | It does | So you never write | Refused when written by hand |
79
+ |---|---|---|
80
+ | Draws the column: 290px, a line on its right, and a ContainerHeader on top | a width, a border and a header row of your own | |
81
+ | Scrolls its rows in a ScrollArea, 16px under the header, 12px in from each side | `overflow-auto`, or padding of your own around the rows | |
82
+ | Closes with the rail when `collapsed`: narrows to nothing and fades | a width transition of your own | |
83
+ | Keeps its 290px beside a main area that grows | `shrink-0` | |
84
+
85
+ ## Props
86
+
87
+ <Controls of={ListColumnStories.Default} />
@@ -0,0 +1,136 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSortDescending, IconSquareRounded } from '@tabler/icons-react'
3
+ import { useState } from 'react'
4
+ import { Button } from './Button'
5
+ import { Divider } from './Divider'
6
+ import { Form } from './Form'
7
+ import { ListColumn } from './ListColumn'
8
+ import { NavItem } from './NavItem'
9
+ import { SectionLabel } from './SectionLabel'
10
+ import { SkeletonList } from './Skeleton'
11
+ import { TextInput } from './TextInput'
12
+ import { Toolbar, ToolbarButton } from './Toolbar'
13
+
14
+ /**
15
+ * The list column of a page: 290px, a line on its right, a header, and a list
16
+ * that scrolls. The rows are the app's own; these stories draw `NavItem`s as
17
+ * stand-ins.
18
+ */
19
+ const meta = {
20
+ title: 'Layout/ListColumn',
21
+ component: ListColumn,
22
+ parameters: { layout: 'fullscreen' },
23
+ args: { title: 'Items', children: null },
24
+ argTypes: { children: { control: false }, actions: { control: false }, above: { control: false } },
25
+ // The column beside a main area, inside a surface — where it sits in the frame's card.
26
+ decorators: [
27
+ (Story) => (
28
+ <div className="flex h-screen bg-bg-surface">
29
+ {Story()}
30
+ <div className="min-w-0 flex-1" />
31
+ </div>
32
+ ),
33
+ ],
34
+ } satisfies Meta<typeof ListColumn>
35
+
36
+ export default meta
37
+ type Story = StoryObj<typeof meta>
38
+
39
+ const icon = <IconSquareRounded size={16} stroke={1.5} />
40
+ const names = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight']
41
+ const rows = (n: number) =>
42
+ Array.from({ length: n }, (_, i) => <NavItem key={i} href="#" label={`Item ${names[i] ?? i + 1}`} icon={icon} active={i === 1} />)
43
+
44
+ const actions = (
45
+ <Toolbar aria-label="Item list actions" surface={false}>
46
+ <ToolbarButton tooltip="Sort by" aria-label="Sort by">
47
+ <IconSortDescending size={16} stroke={1.5} />
48
+ </ToolbarButton>
49
+ <ToolbarButton tooltip="New item" aria-label="New item">
50
+ {icon}
51
+ </ToolbarButton>
52
+ </Toolbar>
53
+ )
54
+
55
+ /** A list of one kind of row, 2px apart, with the column's actions in its header. */
56
+ export const Default: Story = {
57
+ args: { title: 'Items', actions },
58
+ render: (args) => <ListColumn {...args}>{rows(6)}</ListColumn>,
59
+ }
60
+
61
+ /** Groups with labels and a divider of their own: 4px between rows. */
62
+ export const Sections: Story = {
63
+ args: { title: 'Items', spacing: 'sections' },
64
+ render: (args) => (
65
+ <ListColumn {...args}>
66
+ <div className="flex h-8 shrink-0 items-center px-2">
67
+ <SectionLabel>Group one</SectionLabel>
68
+ </div>
69
+ {rows(3)}
70
+ <Divider className="my-2" />
71
+ <div className="flex h-8 shrink-0 items-center px-2">
72
+ <SectionLabel>Group two</SectionLabel>
73
+ </div>
74
+ {rows(4)}
75
+ </ListColumn>
76
+ ),
77
+ }
78
+
79
+ /** A row under the header that stays put while the list scrolls: a field that adds to the list. */
80
+ export const WithARowAbove: Story = {
81
+ parameters: { controls: { disable: true } },
82
+ render: function RowAbove() {
83
+ const [name, setName] = useState('')
84
+ return (
85
+ <ListColumn
86
+ title="Items"
87
+ above={
88
+ <Form onSubmit={() => setName('')} className="flex items-center gap-2 px-3 pt-3 pb-1">
89
+ <TextInput className="min-w-0 flex-1" value={name} onChange={(e) => setName(e.target.value)} placeholder="New item name" aria-label="New item name" />
90
+ <Button className="shrink-0" type="submit" disabled={!name.trim()}>
91
+ Create
92
+ </Button>
93
+ </Form>
94
+ }
95
+ >
96
+ {rows(5)}
97
+ </ListColumn>
98
+ )
99
+ },
100
+ }
101
+
102
+ /** More rows than fit: the list scrolls under a header that stays. */
103
+ export const ManyRows: Story = {
104
+ args: { title: 'Items', actions },
105
+ render: (args) => <ListColumn {...args}>{rows(80)}</ListColumn>,
106
+ }
107
+
108
+ /** While the list is on its way: a skeleton shaped like the rows. */
109
+ export const Loading: Story = {
110
+ args: { title: 'Items' },
111
+ render: (args) => (
112
+ <ListColumn {...args}>
113
+ <SkeletonList rows={8} />
114
+ </ListColumn>
115
+ ),
116
+ }
117
+
118
+ /** Closed with the rail: it narrows to nothing and fades. Press the button to watch it. */
119
+ export const Collapsing: Story = {
120
+ parameters: { controls: { disable: true } },
121
+ render: function Collapsing() {
122
+ const [collapsed, setCollapsed] = useState(false)
123
+ return (
124
+ <>
125
+ <ListColumn title="Items" collapsed={collapsed}>
126
+ {rows(6)}
127
+ </ListColumn>
128
+ <div className="p-4">
129
+ <Button variant="outlined" onClick={() => setCollapsed((c) => !c)}>
130
+ {collapsed ? 'Open the column' : 'Close the column'}
131
+ </Button>
132
+ </div>
133
+ </>
134
+ )
135
+ },
136
+ }
@@ -0,0 +1,50 @@
1
+ // @vitest-environment jsdom
2
+ /** What the ListColumn page claims, pinned. Sizes are measured in the browser; these pin the parts. */
3
+ import { afterEach, describe, expect, it } from 'vitest'
4
+ import { cleanup, render, screen } from '@testing-library/react'
5
+ import { ListColumn } from './ListColumn'
6
+
7
+ afterEach(cleanup)
8
+
9
+ describe('ListColumn', () => {
10
+ it('is headed by its title', () => {
11
+ render(<ListColumn title="Items">{null}</ListColumn>)
12
+ expect(screen.getByText('Items')).toBeTruthy()
13
+ })
14
+
15
+ it('is 290px wide with a line on its right, and keeps its width', () => {
16
+ const { container } = render(<ListColumn title="Items">{null}</ListColumn>)
17
+ const classes = (container.firstElementChild as HTMLElement).className.split(' ')
18
+ expect(classes).toContain('w-[290px]')
19
+ expect(classes).toContain('border-r')
20
+ expect(classes).toContain('shrink-0')
21
+ })
22
+
23
+ it('collapsed: narrows to nothing, loses its line, and fades', () => {
24
+ const { container } = render(<ListColumn title="Items" collapsed>{null}</ListColumn>)
25
+ const classes = (container.firstElementChild as HTMLElement).className.split(' ')
26
+ expect(classes).toContain('w-0')
27
+ expect(classes).toContain('border-r-0')
28
+ expect(classes).toContain('opacity-0')
29
+ })
30
+
31
+ it('draws its rows in the list, and a row above the list outside it', () => {
32
+ render(
33
+ <ListColumn title="Items" above={<p>Above</p>}>
34
+ <p>Row</p>
35
+ </ListColumn>,
36
+ )
37
+ const list = screen.getByText('Row').parentElement as HTMLElement
38
+ expect(list.className.split(' ')).toEqual(expect.arrayContaining(['px-3', 'pt-4', 'pb-3', 'gap-0.5']))
39
+ expect(list.contains(screen.getByText('Above'))).toBe(false)
40
+ })
41
+
42
+ it('sections: 4px between rows', () => {
43
+ render(
44
+ <ListColumn title="Items" spacing="sections">
45
+ <p>Row</p>
46
+ </ListColumn>,
47
+ )
48
+ expect((screen.getByText('Row').parentElement as HTMLElement).className.split(' ')).toContain('gap-1')
49
+ })
50
+ })
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+ import { ContainerHeader } from './ContainerHeader'
4
+ import { ScrollArea } from './ScrollArea'
5
+
6
+ /**
7
+ * The list column of a page: the column beside the page's main area that lists
8
+ * what it can open — 290px wide, a line on its right, its name in a
9
+ * `ContainerHeader`, and a list under it that scrolls.
10
+ *
11
+ * Moved in from Peek as it looks (UIG-14, N9), where five pages drew it by
12
+ * hand and each carried the same numbers: the frame's 290px and its line, and
13
+ * the list's steps — 16px under the header, 12px above the bottom, rows 12px
14
+ * in from each side. The numbers live here now, once.
15
+ *
16
+ * It scrolls its rows itself. A list column that did not was the first of the
17
+ * eight mistakes on the page that led to UIG-15: the frame's card clips, so a
18
+ * list that grew past the fold was simply cut off, with nothing to scroll.
19
+ *
20
+ * `collapsed` closes it with the rail, as Peek's did: it narrows to nothing
21
+ * and fades, in 300ms. What opens and closes the rail stays the app's.
22
+ */
23
+ export interface ListColumnProps {
24
+ /** The column's name, in its header. A node for a title that is more than words. */
25
+ title: ReactNode
26
+ /** A chevron after the title, as `ContainerHeader` draws it. */
27
+ chevron?: boolean
28
+ /** The column's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
29
+ actions?: ReactNode
30
+ /** A row between the header and the list that stays put while the list scrolls — a field that adds to the list. */
31
+ above?: ReactNode
32
+ /**
33
+ * The room between rows. `rows` (2px) for a list of one kind of row;
34
+ * `sections` (4px) for groups with labels and dividers of their own.
35
+ */
36
+ spacing?: 'rows' | 'sections'
37
+ /** Closes the column with the rail: it narrows to nothing and fades. */
38
+ collapsed?: boolean
39
+ /** The rows. */
40
+ children: ReactNode
41
+ /** Placement only. */
42
+ className?: string
43
+ }
44
+
45
+ export function ListColumn({ title, chevron = false, actions, above, spacing = 'rows', collapsed = false, children, className }: ListColumnProps) {
46
+ return (
47
+ <div
48
+ className={cn(
49
+ // `shrink-0`: the column keeps its 290px beside a main area that grows.
50
+ 'flex shrink-0 flex-col overflow-hidden border-r border-border-subtle transition-[width,opacity] duration-300 ease-in-out',
51
+ collapsed ? 'w-0 border-r-0 opacity-0' : 'w-[290px] opacity-100',
52
+ className,
53
+ )}
54
+ data-collapsed={collapsed || undefined}
55
+ >
56
+ <ContainerHeader title={title} chevron={chevron} actions={actions} />
57
+ {above}
58
+ <ScrollArea className="flex-1" contentClassName={cn('flex flex-col px-3 pt-4 pb-3', spacing === 'rows' ? 'gap-0.5' : 'gap-1')}>
59
+ {children}
60
+ </ScrollArea>
61
+ </div>
62
+ )
63
+ }
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.
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.
@@ -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`).
@@ -57,6 +57,9 @@ import { IconPlus } from '@tabler/icons-react'
57
57
  actions are always shown (`showActions="always"`), where the buttons light up
58
58
  on their own. The default, `fill`, lights a row that toggles or has actions.
59
59
 
60
+ - The row is 32px tall, 8px in on each side, 4px between the title and the
61
+ actions, with 8px corners.
62
+
60
63
  ## What it owns
61
64
 
62
65
  It does these by itself. Write none of them around it.
@@ -65,6 +68,7 @@ It does these by itself. Write none of them around it.
65
68
  |---|---|---|
66
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` | |
67
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` | |
68
72
 
69
73
  ## Keys
70
74
 
@@ -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,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,11 @@ 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.
40
44
 
41
45
  ## What it owns
42
46
 
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` | |