@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.
- package/dist/ListColumn.d.ts +41 -0
- package/dist/ListColumn.d.ts.map +1 -0
- package/dist/Select.d.ts +1 -0
- package/dist/Select.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +18 -0
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +65 -38
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +164 -4
- package/src/AppShell.mdx +8 -1
- package/src/AttachmentCard.mdx +3 -1
- package/src/Avatar.mdx +7 -2
- package/src/Banner.mdx +4 -5
- package/src/Breadcrumb.mdx +2 -0
- package/src/Button.mdx +3 -2
- package/src/Button.tsx +2 -2
- package/src/Card.mdx +5 -1
- package/src/Checkbox.mdx +1 -0
- package/src/ChipInput.mdx +1 -1
- package/src/CollapsibleSection.mdx +3 -3
- package/src/ConfirmDialog.mdx +2 -2
- package/src/ContainerHeader.mdx +12 -3
- package/src/DialogShell.mdx +1 -1
- package/src/Divider.mdx +3 -0
- package/src/EditableText.mdx +6 -1
- package/src/EmptyState.mdx +2 -2
- package/src/Field.mdx +3 -4
- package/src/Form.mdx +1 -1
- package/src/IconButton.mdx +1 -1
- package/src/IdentityMenu.mdx +1 -1
- package/src/InlineChip.mdx +3 -0
- package/src/Kbd.mdx +2 -2
- package/src/Link.mdx +3 -1
- package/src/ListColumn.mdx +87 -0
- package/src/ListColumn.stories.tsx +136 -0
- package/src/ListColumn.test.tsx +50 -0
- package/src/ListColumn.tsx +63 -0
- package/src/Menu.mdx +2 -0
- package/src/MenuItem.mdx +3 -0
- package/src/NavItem.mdx +4 -0
- package/src/Person.mdx +3 -0
- package/src/PersonTrigger.mdx +2 -1
- package/src/PersonTrigger.tsx +1 -1
- package/src/Popover.mdx +2 -0
- package/src/Rail.mdx +3 -0
- package/src/RailItem.mdx +4 -0
- package/src/RailItem.tsx +1 -1
- package/src/Reaction.mdx +1 -0
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +3 -0
- package/src/SectionHeader.mdx +5 -1
- package/src/SectionHeader.tsx +1 -1
- package/src/SectionLabel.mdx +6 -2
- package/src/Select.mdx +3 -2
- package/src/Select.test.tsx +12 -0
- package/src/Select.tsx +4 -3
- package/src/Skeleton.mdx +5 -1
- package/src/Skeleton.tsx +1 -1
- package/src/TextInput.mdx +1 -0
- package/src/TextInput.tsx +1 -1
- package/src/Toolbar.mdx +9 -2
- package/src/Toolbar.stories.tsx +19 -2
- package/src/Toolbar.test.tsx +24 -1
- package/src/Toolbar.tsx +22 -0
- package/src/Tooltip.mdx +2 -1
- package/src/Tooltip.test.tsx +22 -0
- package/src/Tooltip.tsx +1 -1
- package/src/TopBar.mdx +8 -1
- package/src/heights.test.tsx +78 -0
- package/src/index.ts +2 -1
- 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.
|
package/src/PersonTrigger.mdx
CHANGED
|
@@ -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.
|
|
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
|
|
package/src/PersonTrigger.tsx
CHANGED
|
@@ -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
|
package/src/ScrollArea.mdx
CHANGED
|
@@ -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.
|
package/src/SectionHeader.mdx
CHANGED
|
@@ -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
|
-
(
|
|
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
|
|
package/src/SectionHeader.tsx
CHANGED
|
@@ -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).
|
package/src/SectionLabel.mdx
CHANGED
|
@@ -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
|
|
39
|
-
passed in `className` is refused by the lint
|
|
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
|
|
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
|
|
package/src/Select.test.tsx
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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` | |
|