@estiva-app/ui 0.23.0 → 0.24.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/AppShell.d.ts.map +1 -1
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Form.d.ts.map +1 -1
- package/dist/IconButton.d.ts +9 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/PreviewCard.d.ts +9 -2
- package/dist/PreviewCard.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +7 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Select.d.ts +11 -3
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +13 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/eslint/index.js +2 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/index.js +127 -108
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/registry.json +51 -8
- package/src/AppShell.mdx +10 -0
- package/src/AppShell.test.tsx +16 -0
- package/src/AppShell.tsx +4 -1
- package/src/AttachmentCard.mdx +9 -0
- package/src/AttachmentCard.test.tsx +10 -0
- package/src/AttachmentCard.tsx +8 -5
- package/src/Avatar.mdx +13 -3
- package/src/AvatarGroup.mdx +4 -0
- package/src/Banner.mdx +13 -3
- package/src/Breadcrumb.mdx +10 -0
- package/src/Button.mdx +14 -3
- package/src/Card.mdx +9 -0
- package/src/Checkbox.mdx +10 -0
- package/src/Chip.mdx +4 -0
- package/src/ChipInput.mdx +12 -0
- package/src/CollapsibleSection.mdx +9 -0
- package/src/CommandPalette.mdx +14 -2
- package/src/ConfirmDialog.mdx +11 -0
- package/src/ContainerHeader.mdx +4 -0
- package/src/DialogShell.mdx +14 -0
- package/src/Divider.mdx +9 -1
- package/src/EditableText.mdx +11 -0
- package/src/EmptyState.mdx +15 -4
- package/src/Field.mdx +10 -1
- package/src/FieldLine.mdx +9 -1
- package/src/FilePicker.mdx +8 -0
- package/src/Form.mdx +10 -0
- package/src/Form.test.tsx +26 -0
- package/src/Form.tsx +7 -0
- package/src/IconButton.mdx +16 -2
- package/src/IconButton.stories.tsx +3 -0
- package/src/IconButton.test.tsx +25 -0
- package/src/IconButton.tsx +61 -46
- package/src/IdentityMenu.mdx +9 -0
- package/src/InlineChip.mdx +8 -0
- package/src/Kbd.mdx +4 -0
- package/src/Link.mdx +10 -0
- package/src/Menu.mdx +16 -1
- package/src/MenuItem.mdx +12 -2
- package/src/NavItem.mdx +8 -0
- package/src/Person.mdx +4 -0
- package/src/PersonTrigger.mdx +9 -0
- package/src/Popover.mdx +21 -0
- package/src/PreviewCard.mdx +19 -4
- package/src/PreviewCard.tsx +11 -4
- package/src/ProgressBar.mdx +8 -0
- package/src/Property.mdx +4 -0
- package/src/Rail.mdx +9 -1
- package/src/RailItem.mdx +8 -0
- package/src/Reaction.mdx +9 -0
- package/src/ReactionPicker.mdx +8 -0
- package/src/ScrollArea.mdx +13 -2
- package/src/SearchInput.mdx +9 -0
- package/src/SectionHeader.mdx +13 -0
- package/src/SectionHeader.stories.tsx +9 -0
- package/src/SectionHeader.test.tsx +9 -0
- package/src/SectionHeader.tsx +8 -2
- package/src/SectionLabel.mdx +4 -0
- package/src/Select.mdx +21 -2
- package/src/Select.stories.tsx +4 -1
- package/src/Select.test.tsx +17 -0
- package/src/Select.tsx +34 -13
- package/src/Sidebar.mdx +8 -0
- package/src/Skeleton.mdx +4 -0
- package/src/Tabs.mdx +12 -1
- package/src/TextInput.mdx +8 -0
- package/src/Textarea.mdx +8 -0
- package/src/Toast.mdx +12 -1
- package/src/Toolbar.mdx +11 -0
- package/src/Tooltip.mdx +26 -2
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +29 -0
- package/src/Tooltip.tsx +17 -4
- package/src/TopBar.mdx +4 -0
- package/src/eslint/no-rebuilt-behaviour.ts +1 -1
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/pages.test.ts +142 -0
- package/tailwind-preset.js +22 -1
package/src/PreviewCard.tsx
CHANGED
|
@@ -35,8 +35,15 @@ export interface PreviewCardProps {
|
|
|
35
35
|
delay?: number
|
|
36
36
|
/** After the pointer leaves, in ms — the grace that lets you cross the gap into the card. */
|
|
37
37
|
closeDelay?: number
|
|
38
|
-
/** On the card's surface: its width
|
|
38
|
+
/** On the card's surface: its width. */
|
|
39
39
|
className?: string
|
|
40
|
+
/**
|
|
41
|
+
* On the scrolling content: its padding (default 12px) and rhythm. The
|
|
42
|
+
* padding sits inside the scrolling box, as in `Popover`, so the scrollbar
|
|
43
|
+
* hugs the card's edge — its thumb 3px from it, like every panel's
|
|
44
|
+
* (Katerina, 19 September; it sat 12px in, behind the card's padding).
|
|
45
|
+
*/
|
|
46
|
+
contentClassName?: string
|
|
40
47
|
/** Extra classes on the trigger wrapper — e.g. `block w-full` for a row. */
|
|
41
48
|
wrapperClassName?: string
|
|
42
49
|
}
|
|
@@ -58,7 +65,7 @@ const OPEN_DELAY = 350
|
|
|
58
65
|
* here the card can be reached, so it can. */
|
|
59
66
|
const CLOSE_DELAY = 200
|
|
60
67
|
|
|
61
|
-
export function PreviewCard({ content, children, side = 'right', delay = OPEN_DELAY, closeDelay = CLOSE_DELAY, className, wrapperClassName }: PreviewCardProps) {
|
|
68
|
+
export function PreviewCard({ content, children, side = 'right', delay = OPEN_DELAY, closeDelay = CLOSE_DELAY, className, contentClassName, wrapperClassName }: PreviewCardProps) {
|
|
62
69
|
return (
|
|
63
70
|
<BasePreviewCard.Root>
|
|
64
71
|
{/* The part renders an `<a>` by default, which a row is not; `render`
|
|
@@ -80,10 +87,10 @@ export function PreviewCard({ content, children, side = 'right', delay = OPEN_DE
|
|
|
80
87
|
className="z-50 data-[anchor-hidden]:hidden"
|
|
81
88
|
>
|
|
82
89
|
<BasePreviewCard.Popup
|
|
83
|
-
className={cn('w-[360px] p-
|
|
90
|
+
className={cn('w-[360px] p-0 outline-none', className)}
|
|
84
91
|
render={<MenuPanel />}
|
|
85
92
|
>
|
|
86
|
-
<ScrollArea viewportClassName="max-h-[
|
|
93
|
+
<ScrollArea viewportClassName="max-h-[min(300px,var(--available-height))]" contentClassName={cn('flex flex-col gap-3 p-3', contentClassName)}>
|
|
87
94
|
{content}
|
|
88
95
|
</ScrollArea>
|
|
89
96
|
</BasePreviewCard.Popup>
|
package/src/ProgressBar.mdx
CHANGED
|
@@ -42,6 +42,14 @@ import { ProgressBar } from '@estiva-app/ui'
|
|
|
42
42
|
of the button's.
|
|
43
43
|
- `className` is for placement: a width, a margin, `flex-1` in a row.
|
|
44
44
|
|
|
45
|
+
## What it owns
|
|
46
|
+
|
|
47
|
+
It does these by itself. Write none of them around it.
|
|
48
|
+
|
|
49
|
+
| It does | So you never write | Refused when written by hand |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| Says what it is to assistive technology: a progress bar, with its value | `role="progressbar"` or `aria-valuenow` | ✓ |
|
|
52
|
+
|
|
45
53
|
## Props
|
|
46
54
|
|
|
47
55
|
<Controls of={ProgressBarStories.Default} />
|
package/src/Property.mdx
CHANGED
|
@@ -54,6 +54,10 @@ WithControls story shows the pairings).
|
|
|
54
54
|
a child of the row or the column: a control with `flex-1` still fills the
|
|
55
55
|
row, and two values still sit the row's gap apart.
|
|
56
56
|
|
|
57
|
+
## What it owns
|
|
58
|
+
|
|
59
|
+
Nothing. It only draws. Clicks and keys are yours.
|
|
60
|
+
|
|
57
61
|
## Props
|
|
58
62
|
|
|
59
63
|
<Controls of={PropertyStories.Row} />
|
package/src/Rail.mdx
CHANGED
|
@@ -25,7 +25,7 @@ nothing else: **RailItem** is the tile, and where a tile goes is the app's.
|
|
|
25
25
|
needs more than a word needs a row.
|
|
26
26
|
- More entries than fit down a screen → fewer entries. The rail does not
|
|
27
27
|
scroll, on purpose.
|
|
28
|
-
- A strip of *actions* rather than places →
|
|
28
|
+
- A strip of *actions* rather than places → **Toolbar**.
|
|
29
29
|
|
|
30
30
|
<Canvas of={RailStories.ManyItems} />
|
|
31
31
|
|
|
@@ -52,6 +52,14 @@ import { Rail, RailItem } from '@estiva-app/ui'
|
|
|
52
52
|
|
|
53
53
|
<Canvas of={RailStories.OneItem} />
|
|
54
54
|
|
|
55
|
+
## What it owns
|
|
56
|
+
|
|
57
|
+
It does these by itself. Write none of them around it.
|
|
58
|
+
|
|
59
|
+
| It does | So you never write | Refused when written by hand |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Is a navigation, named "Navigation" unless you name it | `<nav>` or `aria-label` | |
|
|
62
|
+
|
|
55
63
|
## Keys
|
|
56
64
|
|
|
57
65
|
The rail has no keyboard of its own: its tiles are links, and Tab walks them in
|
package/src/RailItem.mdx
CHANGED
|
@@ -45,6 +45,14 @@ import { Rail, RailItem } from '@estiva-app/ui'
|
|
|
45
45
|
click, navigate.
|
|
46
46
|
- Icons are 16px at stroke 1.5.
|
|
47
47
|
|
|
48
|
+
## What it owns
|
|
49
|
+
|
|
50
|
+
It does these by itself. Write none of them around it.
|
|
51
|
+
|
|
52
|
+
| It does | So you never write | Refused when written by hand |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Marks the current place for assistive technology when `active` | `aria-current` | |
|
|
55
|
+
|
|
48
56
|
## Props
|
|
49
57
|
|
|
50
58
|
<Controls of={RailItemStories.Default} />
|
package/src/Reaction.mdx
CHANGED
|
@@ -50,6 +50,15 @@ import { Reaction } from '@estiva-app/ui'
|
|
|
50
50
|
another client can change the count.
|
|
51
51
|
- `disabled` takes it out of the Tab order and ignores presses.
|
|
52
52
|
|
|
53
|
+
## What it owns
|
|
54
|
+
|
|
55
|
+
It does these by itself. Write none of them around it.
|
|
56
|
+
|
|
57
|
+
| It does | So you never write | Refused when written by hand |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| Is a toggle: pressed or not, and says so to assistive technology | `aria-pressed` | |
|
|
60
|
+
| Presses on Enter and Space; Tab skips it while disabled | a key handler | |
|
|
61
|
+
|
|
53
62
|
## Keys
|
|
54
63
|
|
|
55
64
|
| Key | What happens |
|
package/src/ReactionPicker.mdx
CHANGED
|
@@ -68,6 +68,14 @@ and, when it is yours, the accent fill. The picker asks; the pill answers.
|
|
|
68
68
|
|
|
69
69
|
<Canvas of={ReactionPickerStories.AndWhatItProduces} />
|
|
70
70
|
|
|
71
|
+
## What it owns
|
|
72
|
+
|
|
73
|
+
It does these by itself. Write none of them around it.
|
|
74
|
+
|
|
75
|
+
| It does | So you never write | Refused when written by hand |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| Everything a Toolbar owns: one Tab stop, arrow keys between the reactions, wrapping at the ends | a key handler, or `tabIndex` on each reaction | |
|
|
78
|
+
|
|
71
79
|
## Keys
|
|
72
80
|
|
|
73
81
|
| Input | What happens |
|
package/src/ScrollArea.mdx
CHANGED
|
@@ -13,8 +13,10 @@ the content is moving; nothing moves when it appears.
|
|
|
13
13
|
|
|
14
14
|
## When
|
|
15
15
|
|
|
16
|
-
- Any box that can hold more than fits
|
|
17
|
-
|
|
16
|
+
- Any box that can hold more than fits — **a list that grows with its data
|
|
17
|
+
counts, even when today's data fits**: a menu or a dropdown list, a panel, a
|
|
18
|
+
sidebar, the page's content column. Not a rail: a rail does not scroll, on
|
|
19
|
+
purpose.
|
|
18
20
|
- `orientation="horizontal"` for a row wider than its box — a table, a
|
|
19
21
|
board; `"both"` when a thing can run over either way.
|
|
20
22
|
|
|
@@ -53,6 +55,15 @@ import { ScrollArea } from '@estiva-app/ui'
|
|
|
53
55
|
Chrome: wheel down over a sideways region inside a page, the page moves
|
|
54
56
|
and the region does not; a sideways swipe, the other way round.
|
|
55
57
|
|
|
58
|
+
## What it owns
|
|
59
|
+
|
|
60
|
+
It does these by itself. Write none of them around it.
|
|
61
|
+
|
|
62
|
+
| It does | So you never write | Refused when written by hand |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| Scrolls with our scrollbar | `overflow-auto` or `overflow-scroll` | ✓ |
|
|
65
|
+
| Scrolls with the keyboard when it has focus, like any scrolling box | key handlers | |
|
|
66
|
+
|
|
56
67
|
## Keys
|
|
57
68
|
|
|
58
69
|
| Key | Does |
|
package/src/SearchInput.mdx
CHANGED
|
@@ -38,6 +38,15 @@ import { SearchInput } from '@estiva-app/ui'
|
|
|
38
38
|
and this usually stands alone in a top bar rather than in a `Field`.
|
|
39
39
|
Inside one, the label names it and you add nothing.
|
|
40
40
|
|
|
41
|
+
## What it owns
|
|
42
|
+
|
|
43
|
+
It does these by itself. Write none of them around it.
|
|
44
|
+
|
|
45
|
+
| It does | So you never write | Refused when written by hand |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| Inside a `Field`, takes its name from the Field's label | an `id` / `htmlFor` pair | |
|
|
48
|
+
| Shows its focus ring while anything inside it has focus | a focus style of your own | |
|
|
49
|
+
|
|
41
50
|
## Props
|
|
42
51
|
|
|
43
52
|
<Controls of={SearchInputStories.Default} />
|
package/src/SectionHeader.mdx
CHANGED
|
@@ -53,6 +53,19 @@ import { IconPlus } from '@tabler/icons-react'
|
|
|
53
53
|
- `render` swaps the title's element in Base UI's manner; it is how
|
|
54
54
|
CollapsibleSection makes the title a `Collapsible.Trigger`.
|
|
55
55
|
|
|
56
|
+
- `hover="none"` keeps the row still under the pointer: for a heading whose
|
|
57
|
+
actions are always shown (`showActions="always"`), where the buttons light up
|
|
58
|
+
on their own. The default, `fill`, lights a row that toggles or has actions.
|
|
59
|
+
|
|
60
|
+
## What it owns
|
|
61
|
+
|
|
62
|
+
It does these by itself. Write none of them around it.
|
|
63
|
+
|
|
64
|
+
| It does | So you never write | Refused when written by hand |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| 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
|
+
| Shows its actions while one of them has focus | a focus style of your own | |
|
|
68
|
+
|
|
56
69
|
## Keys
|
|
57
70
|
|
|
58
71
|
| Key | Does |
|
|
@@ -35,6 +35,15 @@ export const PersistentActions: Story = {
|
|
|
35
35
|
},
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** `hover="none"`: actions always shown, and the row stays still under the pointer — only the buttons light up. */
|
|
39
|
+
export const StillOnHover: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
showActions: 'always',
|
|
42
|
+
hover: 'none',
|
|
43
|
+
actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }],
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
/** A count beside the title, held on screen while the actions come and go. */
|
|
39
48
|
export const WithTrailing: Story = {
|
|
40
49
|
args: {
|
|
@@ -23,6 +23,15 @@ describe('SectionHeader', () => {
|
|
|
23
23
|
expect(onToggle).toHaveBeenCalledTimes(2)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
+
it('fills under the pointer when it does something, and hover="none" keeps it still', () => {
|
|
27
|
+
const actions = [{ icon: <i />, tooltip: 'Add', onClick: () => {} }]
|
|
28
|
+
const lit = render(<SectionHeader title="Section" actions={actions} />)
|
|
29
|
+
expect((lit.container.firstElementChild as HTMLElement).className).toContain('hover:bg-bg-hover')
|
|
30
|
+
cleanup()
|
|
31
|
+
const still = render(<SectionHeader title="Section" actions={actions} hover="none" />)
|
|
32
|
+
expect((still.container.firstElementChild as HTMLElement).className).not.toContain('hover:bg-bg-hover')
|
|
33
|
+
})
|
|
34
|
+
|
|
26
35
|
it('an action beside the title acts, and never toggles', async () => {
|
|
27
36
|
const onToggle = vi.fn()
|
|
28
37
|
const add = vi.fn()
|
package/src/SectionHeader.tsx
CHANGED
|
@@ -59,6 +59,12 @@ export interface SectionHeaderProps {
|
|
|
59
59
|
actions?: SectionAction[]
|
|
60
60
|
/** `hover` reveals the actions while the row is hovered or focused; `always` keeps them. */
|
|
61
61
|
showActions?: 'hover' | 'always'
|
|
62
|
+
/**
|
|
63
|
+
* `fill` lights the row under the pointer when it does something (a toggle,
|
|
64
|
+
* actions); `none` keeps it still — a heading whose actions are always shown,
|
|
65
|
+
* where the buttons light up on their own (UIG-14, Katerina, 19 September).
|
|
66
|
+
*/
|
|
67
|
+
hover?: 'fill' | 'none'
|
|
62
68
|
/**
|
|
63
69
|
* What the title renders as, in Base UI's manner. A plain button with
|
|
64
70
|
* `onToggle` by default; `CollapsibleSection` hands in `Collapsible.Trigger`.
|
|
@@ -67,7 +73,7 @@ export interface SectionHeaderProps {
|
|
|
67
73
|
className?: string
|
|
68
74
|
}
|
|
69
75
|
|
|
70
|
-
export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', render, className }: SectionHeaderProps) {
|
|
76
|
+
export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', hover = 'fill', render, className }: SectionHeaderProps) {
|
|
71
77
|
const titleElement = useRender({
|
|
72
78
|
render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
|
|
73
79
|
props: {
|
|
@@ -97,7 +103,7 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
|
|
|
97
103
|
// The fill says "this does something": a row with a toggle or actions
|
|
98
104
|
// lights up, a fixed heading over rows does not (2026-09-09, the
|
|
99
105
|
// Sidebar's fixed group).
|
|
100
|
-
(chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
|
|
106
|
+
hover === 'fill' && (chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
|
|
101
107
|
className,
|
|
102
108
|
)}
|
|
103
109
|
>
|
package/src/SectionLabel.mdx
CHANGED
|
@@ -38,6 +38,10 @@ around it. Read it `tone="secondary"` where it labels rather than leads — menu
|
|
|
38
38
|
headings and the command palette's groups do (Katerina, 2026-09-01). A colour
|
|
39
39
|
passed in `className` is refused by the lint (UIG-9).
|
|
40
40
|
|
|
41
|
+
## What it owns
|
|
42
|
+
|
|
43
|
+
Nothing. It only draws. Clicks and keys are yours.
|
|
44
|
+
|
|
41
45
|
## Props
|
|
42
46
|
|
|
43
47
|
<Controls of={SectionLabelStories.Default} />
|
package/src/Select.mdx
CHANGED
|
@@ -29,8 +29,10 @@ corners and scrolling panels.
|
|
|
29
29
|
things.
|
|
30
30
|
- Several values → **ChipInput**.
|
|
31
31
|
- Switching views of one thing → **Tabs**.
|
|
32
|
-
- `disabled` explains nothing by itself —
|
|
33
|
-
|
|
32
|
+
- `disabled` explains nothing by itself — give it `disabledReason`, which says
|
|
33
|
+
why and keeps it reachable by Tab. Busy for a moment while an action runs
|
|
34
|
+
needs no reason.
|
|
35
|
+
- Ticking several on and off in a list → **Checkbox** with `row`.
|
|
34
36
|
|
|
35
37
|
## How
|
|
36
38
|
|
|
@@ -59,10 +61,27 @@ import { Select } from '@estiva-app/ui'
|
|
|
59
61
|
smaller — and scrolls inside that. Measured in a bottom-right corner:
|
|
60
62
|
the list flips above the trigger, slides left to stay on screen, and
|
|
61
63
|
stands at its full 288.
|
|
64
|
+
- **`disabledReason="Read only"`** holds it shut and keeps it reachable by Tab,
|
|
65
|
+
showing the reason on hover and on keyboard focus — as `Button` does. Plain
|
|
66
|
+
`disabled` is a native disabled control: Tab skips it, so it cannot say why.
|
|
62
67
|
- **The trigger is a `combobox`, not a `button`.** That is the ARIA pattern
|
|
63
68
|
for this control and Base UI's doing; a test looking for it by role has
|
|
64
69
|
to ask for `combobox`.
|
|
65
70
|
|
|
71
|
+
## What it owns
|
|
72
|
+
|
|
73
|
+
It does these by itself. Write none of them around it.
|
|
74
|
+
|
|
75
|
+
| It does | So you never write | Refused when written by hand |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| Floats on top of the page | a portal, or a `z-index` to win | ✓ |
|
|
78
|
+
| Stays attached to its anchor on scroll and resize, and hides when its trigger scrolls out of sight | a scroll or resize listener, or placement arithmetic | ✓ |
|
|
79
|
+
| Closes on a press outside | a click listener on the page | ✓ |
|
|
80
|
+
| Takes its keys by itself: Enter and Space pick, Escape and Tab close | a key listener on the page | ✓ |
|
|
81
|
+
| Moves through its items with the arrow keys, and jumps to an option by its first letters | an arrow-key handler | ✓ |
|
|
82
|
+
| Says what it is to assistive technology: a list of options | `role="listbox"` or `role="option"` | ✓ |
|
|
83
|
+
| Keeps the highlighted option in view, and gives focus back to the trigger | scroll or focus code | |
|
|
84
|
+
|
|
66
85
|
## Keys
|
|
67
86
|
|
|
68
87
|
| Input | What happens |
|
package/src/Select.stories.tsx
CHANGED
|
@@ -89,9 +89,12 @@ export const InAField: Story = {
|
|
|
89
89
|
),
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/** Unavailable — the same disabled treatment as every other control.
|
|
92
|
+
/** Unavailable — the same disabled treatment as every other control. To say why, `WithAReason`. */
|
|
93
93
|
export const Disabled: Story = { render: (args) => <Demo {...args} initial="done" disabled /> }
|
|
94
94
|
|
|
95
|
+
/** `disabledReason`: it looks disabled and will not open, but Tab reaches it and the reason shows on hover and on focus. */
|
|
96
|
+
export const WithAReason: Story = { render: (args) => <Demo {...args} initial="done" disabledReason="Read only: you are a guest here" /> }
|
|
97
|
+
|
|
95
98
|
/** Longer labels than the trigger is wide: the trigger truncates rather than growing; the menu takes the trigger's width as a minimum and grows past it. */
|
|
96
99
|
export const LongLabels: Story = {
|
|
97
100
|
render: (args) => (
|
package/src/Select.test.tsx
CHANGED
|
@@ -57,6 +57,23 @@ describe('Select', () => {
|
|
|
57
57
|
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
it('disabledReason: held shut, reachable by Tab, and it says why', async () => {
|
|
61
|
+
const user = userEvent.setup()
|
|
62
|
+
const onChange = vi.fn()
|
|
63
|
+
render(<Select value="todo" onChange={onChange} options={STATUSES} ariaLabel="Status" disabledReason="Read only: you are a guest" />)
|
|
64
|
+
const trigger = screen.getByRole('combobox', { name: 'Status' })
|
|
65
|
+
expect(trigger.getAttribute('aria-disabled')).toBe('true')
|
|
66
|
+
expect(trigger.hasAttribute('disabled')).toBe(false)
|
|
67
|
+
await user.click(trigger)
|
|
68
|
+
await user.keyboard('{ArrowDown}{Enter}')
|
|
69
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
70
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
71
|
+
await user.tab()
|
|
72
|
+
await user.tab({ shift: true })
|
|
73
|
+
expect(document.activeElement).toBe(trigger)
|
|
74
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe('Read only: you are a guest')
|
|
75
|
+
})
|
|
76
|
+
|
|
60
77
|
it('shows the placeholder when nothing matches', () => {
|
|
61
78
|
render(<Controlled initial="" placeholder="Choose" />)
|
|
62
79
|
expect(screen.getByRole('combobox', { name: 'Status' }).textContent).toContain('Choose')
|
package/src/Select.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
|
|
|
4
4
|
import { cn } from './cn'
|
|
5
5
|
import { ScrollArea } from './ScrollArea'
|
|
6
6
|
import { MenuPanel, menuItemClassName } from './Menu'
|
|
7
|
+
import { TooltipTrigger } from './Tooltip'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Peek's Select (2026-08-28), verbatim, plus what Ship added: an option may
|
|
@@ -29,8 +30,9 @@ import { MenuPanel, menuItemClassName } from './Menu'
|
|
|
29
30
|
* highlight is managed rather than counted, and a value that can be part of a
|
|
30
31
|
* form.
|
|
31
32
|
*
|
|
32
|
-
* Two sizes
|
|
33
|
-
*
|
|
33
|
+
* Two sizes. `disabled` explains nothing by itself; `disabledReason` says
|
|
34
|
+
* why, as Button's does: the select looks disabled, will not open, stays
|
|
35
|
+
* reachable by Tab, and shows the reason on hover and on keyboard focus.
|
|
34
36
|
*/
|
|
35
37
|
export interface SelectOption {
|
|
36
38
|
value: string
|
|
@@ -49,6 +51,13 @@ export interface SelectProps {
|
|
|
49
51
|
'aria-required'?: boolean | 'true' | 'false'
|
|
50
52
|
placeholder?: string
|
|
51
53
|
disabled?: boolean
|
|
54
|
+
/**
|
|
55
|
+
* Why it cannot be changed right now (UIG-14, Katerina, 19 September). It
|
|
56
|
+
* looks disabled and will not open, but Tab still reaches it, and the reason
|
|
57
|
+
* shows as its tooltip — a native disabled button cannot be focused, so it
|
|
58
|
+
* could never say why. Takes the place of wrapping it in `WithTooltip`.
|
|
59
|
+
*/
|
|
60
|
+
disabledReason?: string
|
|
52
61
|
className?: string
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -57,20 +66,16 @@ export interface SelectProps {
|
|
|
57
66
|
const GAP = 4
|
|
58
67
|
const VIEWPORT_PAD = 8
|
|
59
68
|
|
|
60
|
-
export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className, ...aria }: SelectProps) {
|
|
69
|
+
export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, disabledReason, className, ...aria }: SelectProps) {
|
|
61
70
|
const selected = options.find((o) => o.value === value)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
disabled={disabled}
|
|
67
|
-
// Non-modal, as it has always been: the page behind stays scrollable and
|
|
68
|
-
// keeps its scrollbar, so opening a select never shifts the layout.
|
|
69
|
-
modal={false}
|
|
70
|
-
>
|
|
71
|
+
// With a reason it is held shut rather than disabled: a disabled trigger is
|
|
72
|
+
// a native `disabled` button, which Tab skips and a tooltip cannot open on.
|
|
73
|
+
const held = Boolean(disabledReason) && !disabled
|
|
74
|
+
const trigger = (
|
|
71
75
|
<BaseSelect.Trigger
|
|
72
76
|
aria-label={ariaLabel}
|
|
73
77
|
aria-required={aria['aria-required']}
|
|
78
|
+
aria-disabled={held || undefined}
|
|
74
79
|
className={cn(
|
|
75
80
|
/*
|
|
76
81
|
* `min-w-0 max-w-full`: a trigger must never outgrow its container
|
|
@@ -85,7 +90,9 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
85
90
|
// The focused border survives a hover: hover alone strengthens the
|
|
86
91
|
// hairline, but hover while focused must not grey the focus colour —
|
|
87
92
|
// the stacked variant outranks plain hover by specificity.
|
|
88
|
-
'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus
|
|
93
|
+
!held && 'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus',
|
|
94
|
+
'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
|
|
95
|
+
held && 'cursor-not-allowed bg-bg-disabled text-text-disabled',
|
|
89
96
|
'focus-visible:border-border-focus aria-expanded:border-border-focus',
|
|
90
97
|
'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
|
|
91
98
|
size === 'default' && 'px-3 py-2 text-input-value',
|
|
@@ -101,6 +108,20 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
101
108
|
render={<IconChevronDown size={size === 'small' ? 14 : 16} stroke={1.5} className="shrink-0 text-text-secondary" />}
|
|
102
109
|
/>
|
|
103
110
|
</BaseSelect.Trigger>
|
|
111
|
+
)
|
|
112
|
+
return (
|
|
113
|
+
<BaseSelect.Root
|
|
114
|
+
value={value}
|
|
115
|
+
onValueChange={(next) => {
|
|
116
|
+
if (!held) onChange(next as string)
|
|
117
|
+
}}
|
|
118
|
+
{...(held ? { open: false, onOpenChange: () => {} } : {})}
|
|
119
|
+
disabled={disabled}
|
|
120
|
+
// Non-modal, as it has always been: the page behind stays scrollable and
|
|
121
|
+
// keeps its scrollbar, so opening a select never shifts the layout.
|
|
122
|
+
modal={false}
|
|
123
|
+
>
|
|
124
|
+
{held ? <TooltipTrigger label={disabledReason ?? ''}>{trigger}</TooltipTrigger> : trigger}
|
|
104
125
|
|
|
105
126
|
<BaseSelect.Portal>
|
|
106
127
|
<BaseSelect.Positioner
|
package/src/Sidebar.mdx
CHANGED
|
@@ -59,6 +59,14 @@ compressed instead — rows must keep their height.
|
|
|
59
59
|
It is a `nav` region — pass `aria-label` when "Workspace" is not the
|
|
60
60
|
right name for what it navigates.
|
|
61
61
|
|
|
62
|
+
## What it owns
|
|
63
|
+
|
|
64
|
+
It does these by itself. Write none of them around it.
|
|
65
|
+
|
|
66
|
+
| It does | So you never write | Refused when written by hand |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| Scrolls its rows in a ScrollArea when they overflow | `overflow-auto` on the column | |
|
|
69
|
+
|
|
62
70
|
## Props
|
|
63
71
|
|
|
64
72
|
<Controls of={SidebarStories.Composed} />
|
package/src/Skeleton.mdx
CHANGED
|
@@ -37,3 +37,7 @@ import { SkeletonList, SkeletonBar } from '@estiva-app/ui'
|
|
|
37
37
|
load never flashes a skeleton. Building your own placeholder from the
|
|
38
38
|
bar? Put that class on its container for the same manner.
|
|
39
39
|
- The list is `aria-hidden` — a placeholder has nothing to read.
|
|
40
|
+
|
|
41
|
+
## What it owns
|
|
42
|
+
|
|
43
|
+
Nothing. It only draws. Clicks and keys are yours.
|
package/src/Tabs.mdx
CHANGED
|
@@ -23,7 +23,7 @@ is a neutral fill, not the accent — selection is a place, not a highlight.
|
|
|
23
23
|
|
|
24
24
|
## When not
|
|
25
25
|
|
|
26
|
-
- Going to a different place →
|
|
26
|
+
- Going to a different place → **Link**, **NavItem** or **Breadcrumb**.
|
|
27
27
|
- Choosing a value that gets saved → **Select**.
|
|
28
28
|
- Actions → **Button**s; a tab never *does*, it *shows*.
|
|
29
29
|
|
|
@@ -54,6 +54,17 @@ import { Tabs } from '@estiva-app/ui'
|
|
|
54
54
|
that already names it takes `aria-labelledby` instead. Unnamed is fine for
|
|
55
55
|
the only tab row on a page.
|
|
56
56
|
|
|
57
|
+
## What it owns
|
|
58
|
+
|
|
59
|
+
It does these by itself. Write none of them around it.
|
|
60
|
+
|
|
61
|
+
| It does | So you never write | Refused when written by hand |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| Takes its keys by itself: Home and End select the first and last tab | a key listener on the page | ✓ |
|
|
64
|
+
| Moves through its items with the arrow keys: ← → select the next tab, and wrap at the ends | an arrow-key handler | ✓ |
|
|
65
|
+
| Says what it is to assistive technology: a set of tabs and their panels | `role="tablist"`, `tab` or `tabpanel` | ✓ |
|
|
66
|
+
| Is one Tab stop: Tab lands on the selected tab | a `tabIndex` on each tab | |
|
|
67
|
+
|
|
57
68
|
## Keys
|
|
58
69
|
|
|
59
70
|
| Key | Does |
|
package/src/TextInput.mdx
CHANGED
|
@@ -46,6 +46,14 @@ import { TextInput } from '@estiva-app/ui'
|
|
|
46
46
|
- `size` is this component's (`default` or `small`), not the native attribute
|
|
47
47
|
that counts characters. Set a width with the layout around it.
|
|
48
48
|
|
|
49
|
+
## What it owns
|
|
50
|
+
|
|
51
|
+
It does these by itself. Write none of them around it.
|
|
52
|
+
|
|
53
|
+
| It does | So you never write | Refused when written by hand |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| Inside a `Field`, takes its name from the Field's label, and its error state | an `id` / `htmlFor` pair, or `aria-invalid` | |
|
|
56
|
+
|
|
49
57
|
## Props
|
|
50
58
|
|
|
51
59
|
<Controls of={TextInputStories.Empty} />
|
package/src/Textarea.mdx
CHANGED
|
@@ -35,6 +35,14 @@ import { Textarea } from '@estiva-app/ui'
|
|
|
35
35
|
- **Inside a `Field`, the label names it. Add nothing.** Outside one, give
|
|
36
36
|
it an `aria-label`.
|
|
37
37
|
|
|
38
|
+
## What it owns
|
|
39
|
+
|
|
40
|
+
It does these by itself. Write none of them around it.
|
|
41
|
+
|
|
42
|
+
| It does | So you never write | Refused when written by hand |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| Inside a `Field`, takes its name from the Field's label, and its error state | an `id` / `htmlFor` pair, or `aria-invalid` | |
|
|
45
|
+
|
|
38
46
|
## Props
|
|
39
47
|
|
|
40
48
|
<Controls of={TextareaStories.Empty} />
|
package/src/Toast.mdx
CHANGED
|
@@ -33,7 +33,7 @@ the positioning (bottom-left), the portal, and the auto-dismiss.
|
|
|
33
33
|
- Something the person must read or act on → put it in the surface; a
|
|
34
34
|
toast dismisses itself.
|
|
35
35
|
- A question before the action → **ConfirmDialog**.
|
|
36
|
-
- Standing status →
|
|
36
|
+
- Standing status → **Chip**, or your surface's own chrome.
|
|
37
37
|
|
|
38
38
|
## How
|
|
39
39
|
|
|
@@ -70,6 +70,17 @@ showToast({ label: 'Link copied', type: 'success' })
|
|
|
70
70
|
- `useToast` outside a provider throws — mount the provider at the root,
|
|
71
71
|
once.
|
|
72
72
|
|
|
73
|
+
## What it owns
|
|
74
|
+
|
|
75
|
+
It does these by itself. Write none of them around it.
|
|
76
|
+
|
|
77
|
+
| It does | So you never write | Refused when written by hand |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Floats on top of the page | a portal, or a `z-index` to win | ✓ |
|
|
80
|
+
| Says what it is to assistive technology: a message, announced as it arrives | `role="status"` or `aria-live` | ✓ |
|
|
81
|
+
| F6 moves focus to the toasts from anywhere; Escape closes one | a key listener | |
|
|
82
|
+
| Leaves by itself after a while | a timer | |
|
|
83
|
+
|
|
73
84
|
## Keys
|
|
74
85
|
|
|
75
86
|
| Key | What happens |
|
package/src/Toolbar.mdx
CHANGED
|
@@ -82,6 +82,17 @@ why the placement is its job and not arithmetic of ours.
|
|
|
82
82
|
|
|
83
83
|
<Canvas of={ToolbarStories.OnAnExistingSurface} />
|
|
84
84
|
|
|
85
|
+
## What it owns
|
|
86
|
+
|
|
87
|
+
It does these by itself. Write none of them around it.
|
|
88
|
+
|
|
89
|
+
| It does | So you never write | Refused when written by hand |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| Takes its keys by itself: Enter and Space press the focused control | a key listener on the page | ✓ |
|
|
92
|
+
| Moves through its items with the arrow keys, and wraps at the ends | an arrow-key handler | ✓ |
|
|
93
|
+
| Says what it is to assistive technology: a toolbar | `role="toolbar"` | ✓ |
|
|
94
|
+
| Is one Tab stop, and remembers where the walk left off | `tabIndex` on each control | |
|
|
95
|
+
|
|
85
96
|
## Keys
|
|
86
97
|
|
|
87
98
|
| Input | What happens |
|
package/src/Tooltip.mdx
CHANGED
|
@@ -21,6 +21,7 @@ shows it beside a trigger — on hover, and on keyboard focus.
|
|
|
21
21
|
- **The key that does the same thing** — pass `shortcut` and it is drawn as
|
|
22
22
|
the `Kbd` chip after the label. For an icon-only control whose only other
|
|
23
23
|
affordance is a keyboard shortcut, the tooltip is the only place to say so.
|
|
24
|
+
- A detail behind a short word in running text — the time behind "edited".
|
|
24
25
|
|
|
25
26
|
<Canvas of={TooltipStories.ShortcutComparison} />
|
|
26
27
|
|
|
@@ -30,8 +31,10 @@ shows it beside a trigger — on hover, and on keyboard focus.
|
|
|
30
31
|
|
|
31
32
|
- Anything the person must read to proceed → put it in the surface, not
|
|
32
33
|
behind a hover.
|
|
33
|
-
- Anything
|
|
34
|
-
|
|
34
|
+
- Anything with controls inside → **Popover**, opened by a click. A list of
|
|
35
|
+
actions → **Menu**. A tooltip disappears as the pointer moves onto it, so
|
|
36
|
+
nothing inside it can be clicked.
|
|
37
|
+
- Something worth reading, scrolling or copying on hover → **PreviewCard**.
|
|
35
38
|
|
|
36
39
|
## How
|
|
37
40
|
|
|
@@ -47,6 +50,11 @@ import { WithTooltip } from '@estiva-app/ui'
|
|
|
47
50
|
shrinks nothing, so a wrapped block collapses to its content width.
|
|
48
51
|
- A truncating label keeps truncating if you pass
|
|
49
52
|
`wrapperClassName="min-w-0 shrink"`.
|
|
53
|
+
- **`inline`** for a trigger inside a line of text — a reference, a name in a
|
|
54
|
+
sentence: the wrapper is a `<span>`, because a `<div>` is not allowed inside
|
|
55
|
+
a paragraph.
|
|
56
|
+
- A label longer than 320px wraps onto more lines rather than running off the
|
|
57
|
+
screen; a short one stays one line at 30px.
|
|
50
58
|
- **`Button` and `IconButton` need no wrapper**: pass `tooltip` (or
|
|
51
59
|
`disabledReason`) and the button itself becomes the trigger. That is what
|
|
52
60
|
lets a `Dialog.Close` or a `Menu.Trigger` be one of them — the component's
|
|
@@ -56,6 +64,10 @@ import { WithTooltip } from '@estiva-app/ui'
|
|
|
56
64
|
looking at — so pass the finished label. `IconButton` forwards its own
|
|
57
65
|
`tooltipShortcut` here.
|
|
58
66
|
|
|
67
|
+
<Canvas of={TooltipStories.InsideText} />
|
|
68
|
+
|
|
69
|
+
<Canvas of={TooltipStories.LongLabel} />
|
|
70
|
+
|
|
59
71
|
### The delay, and why a toolbar only pauses once
|
|
60
72
|
|
|
61
73
|
A tooltip waits **300ms** before it opens. Mount **one `TooltipProvider` at
|
|
@@ -82,6 +94,18 @@ beneath one — and fades out over **80ms**. Moving between triggers inside a
|
|
|
82
94
|
warm group skips the animation entirely, so a toolbar sweep does not flicker.
|
|
83
95
|
`prefers-reduced-motion` removes it.
|
|
84
96
|
|
|
97
|
+
## What it owns
|
|
98
|
+
|
|
99
|
+
It does these by itself. Write none of them around it.
|
|
100
|
+
|
|
101
|
+
| It does | So you never write | Refused when written by hand |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Floats on top of the page | a portal, or a `z-index` to win | ✓ |
|
|
104
|
+
| Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
|
|
105
|
+
| Says what it is to assistive technology: a tooltip | `role="tooltip"` | ✓ |
|
|
106
|
+
| Opens after the pointer rests, and at once on keyboard focus | a timer, or hover and focus handlers | |
|
|
107
|
+
| Closes on Escape, on a click of its control, and when the pointer leaves | close handlers | |
|
|
108
|
+
|
|
85
109
|
## Keys
|
|
86
110
|
|
|
87
111
|
| Input | What happens |
|