@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.
- package/dist/ErrorBoundary.d.ts +32 -0
- package/dist/ErrorBoundary.d.ts.map +1 -0
- package/dist/ListColumn.d.ts +42 -0
- package/dist/ListColumn.d.ts.map +1 -0
- package/dist/Menu.d.ts +7 -0
- package/dist/Menu.d.ts.map +1 -1
- 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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +183 -108
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +211 -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 +4 -4
- package/src/ConfirmDialog.mdx +2 -2
- package/src/ContainerHeader.mdx +12 -3
- package/src/DialogShell.mdx +1 -1
- package/src/Divider.mdx +5 -2
- package/src/EditableText.mdx +6 -1
- package/src/EmptyState.mdx +2 -2
- package/src/ErrorBoundary.mdx +66 -0
- package/src/ErrorBoundary.stories.tsx +64 -0
- package/src/ErrorBoundary.test.tsx +79 -0
- package/src/ErrorBoundary.tsx +54 -0
- 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 +95 -0
- package/src/ListColumn.stories.tsx +160 -0
- package/src/ListColumn.test.tsx +63 -0
- package/src/ListColumn.tsx +86 -0
- package/src/Menu.mdx +7 -2
- package/src/Menu.stories.tsx +6 -7
- package/src/Menu.test.tsx +19 -1
- package/src/Menu.tsx +11 -1
- 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 +10 -2
- package/src/SectionHeader.tsx +1 -1
- package/src/SectionLabel.mdx +10 -4
- 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 +4 -2
- 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.
|
|
142
|
-
|
|
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
|
|
package/src/Menu.stories.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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.
|
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
|
@@ -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
|
|
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
|
-
(
|
|
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
|
|
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,13 +14,16 @@ how).
|
|
|
14
14
|
|
|
15
15
|
## When
|
|
16
16
|
|
|
17
|
-
-
|
|
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
|
|
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
|
|
39
|
-
passed in `className` is refused by the lint
|
|
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
|
|
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` | |
|
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.
|
|
73
|
-
|
|
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.
|