@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.
Files changed (98) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Form.d.ts.map +1 -1
  4. package/dist/IconButton.d.ts +9 -1
  5. package/dist/IconButton.d.ts.map +1 -1
  6. package/dist/PreviewCard.d.ts +9 -2
  7. package/dist/PreviewCard.d.ts.map +1 -1
  8. package/dist/SectionHeader.d.ts +7 -1
  9. package/dist/SectionHeader.d.ts.map +1 -1
  10. package/dist/Select.d.ts +11 -3
  11. package/dist/Select.d.ts.map +1 -1
  12. package/dist/Tooltip.d.ts +13 -1
  13. package/dist/Tooltip.d.ts.map +1 -1
  14. package/dist/eslint/index.js +2 -1
  15. package/dist/eslint/index.js.map +2 -2
  16. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  17. package/dist/index.js +127 -108
  18. package/dist/index.js.map +2 -2
  19. package/package.json +1 -1
  20. package/registry.json +51 -8
  21. package/src/AppShell.mdx +10 -0
  22. package/src/AppShell.test.tsx +16 -0
  23. package/src/AppShell.tsx +4 -1
  24. package/src/AttachmentCard.mdx +9 -0
  25. package/src/AttachmentCard.test.tsx +10 -0
  26. package/src/AttachmentCard.tsx +8 -5
  27. package/src/Avatar.mdx +13 -3
  28. package/src/AvatarGroup.mdx +4 -0
  29. package/src/Banner.mdx +13 -3
  30. package/src/Breadcrumb.mdx +10 -0
  31. package/src/Button.mdx +14 -3
  32. package/src/Card.mdx +9 -0
  33. package/src/Checkbox.mdx +10 -0
  34. package/src/Chip.mdx +4 -0
  35. package/src/ChipInput.mdx +12 -0
  36. package/src/CollapsibleSection.mdx +9 -0
  37. package/src/CommandPalette.mdx +14 -2
  38. package/src/ConfirmDialog.mdx +11 -0
  39. package/src/ContainerHeader.mdx +4 -0
  40. package/src/DialogShell.mdx +14 -0
  41. package/src/Divider.mdx +9 -1
  42. package/src/EditableText.mdx +11 -0
  43. package/src/EmptyState.mdx +15 -4
  44. package/src/Field.mdx +10 -1
  45. package/src/FieldLine.mdx +9 -1
  46. package/src/FilePicker.mdx +8 -0
  47. package/src/Form.mdx +10 -0
  48. package/src/Form.test.tsx +26 -0
  49. package/src/Form.tsx +7 -0
  50. package/src/IconButton.mdx +16 -2
  51. package/src/IconButton.stories.tsx +3 -0
  52. package/src/IconButton.test.tsx +25 -0
  53. package/src/IconButton.tsx +61 -46
  54. package/src/IdentityMenu.mdx +9 -0
  55. package/src/InlineChip.mdx +8 -0
  56. package/src/Kbd.mdx +4 -0
  57. package/src/Link.mdx +10 -0
  58. package/src/Menu.mdx +16 -1
  59. package/src/MenuItem.mdx +12 -2
  60. package/src/NavItem.mdx +8 -0
  61. package/src/Person.mdx +4 -0
  62. package/src/PersonTrigger.mdx +9 -0
  63. package/src/Popover.mdx +21 -0
  64. package/src/PreviewCard.mdx +19 -4
  65. package/src/PreviewCard.tsx +11 -4
  66. package/src/ProgressBar.mdx +8 -0
  67. package/src/Property.mdx +4 -0
  68. package/src/Rail.mdx +9 -1
  69. package/src/RailItem.mdx +8 -0
  70. package/src/Reaction.mdx +9 -0
  71. package/src/ReactionPicker.mdx +8 -0
  72. package/src/ScrollArea.mdx +13 -2
  73. package/src/SearchInput.mdx +9 -0
  74. package/src/SectionHeader.mdx +13 -0
  75. package/src/SectionHeader.stories.tsx +9 -0
  76. package/src/SectionHeader.test.tsx +9 -0
  77. package/src/SectionHeader.tsx +8 -2
  78. package/src/SectionLabel.mdx +4 -0
  79. package/src/Select.mdx +21 -2
  80. package/src/Select.stories.tsx +4 -1
  81. package/src/Select.test.tsx +17 -0
  82. package/src/Select.tsx +34 -13
  83. package/src/Sidebar.mdx +8 -0
  84. package/src/Skeleton.mdx +4 -0
  85. package/src/Tabs.mdx +12 -1
  86. package/src/TextInput.mdx +8 -0
  87. package/src/Textarea.mdx +8 -0
  88. package/src/Toast.mdx +12 -1
  89. package/src/Toolbar.mdx +11 -0
  90. package/src/Tooltip.mdx +26 -2
  91. package/src/Tooltip.stories.tsx +26 -0
  92. package/src/Tooltip.test.tsx +29 -0
  93. package/src/Tooltip.tsx +17 -4
  94. package/src/TopBar.mdx +4 -0
  95. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  96. package/src/eslint/no-restyled-part.ts +1 -0
  97. package/src/pages.test.ts +142 -0
  98. package/tailwind-preset.js +22 -1
@@ -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, its padding, a max height. */
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-3 outline-none', className)}
90
+ className={cn('w-[360px] p-0 outline-none', className)}
84
91
  render={<MenuPanel />}
85
92
  >
86
- <ScrollArea viewportClassName="max-h-[calc(min(300px,var(--available-height))_-_1.5rem)]" contentClassName="flex flex-col gap-3">
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>
@@ -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 → a toolbar of **IconButton**s.
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 |
@@ -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 |
@@ -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: a menu or a dropdown list, a panel,
17
- a sidebar, the page's content column, a rail.
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 |
@@ -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} />
@@ -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()
@@ -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
  >
@@ -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 — wrap it in a **WithTooltip**
33
- that gives the reason.
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 |
@@ -89,9 +89,12 @@ export const InAField: Story = {
89
89
  ),
90
90
  }
91
91
 
92
- /** Unavailable — the same disabled treatment as every other control. Say why, with a tooltip around it. */
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) => (
@@ -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; `disabled` explains nothing by itself wrap it in a tooltip
33
- * that does.
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
- return (
63
- <BaseSelect.Root
64
- value={value}
65
- onValueChange={(next) => onChange(next as string)}
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 disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
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 → links and **Breadcrumb**.
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 → a Chip or your surface's own chrome.
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 interactive inside → **Menu** or **DialogShell**; a tooltip is
34
- `pointer-events: none` by design and cannot be pointed at.
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 |