@estiva-app/ui 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) 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/ListColumn.d.ts +41 -0
  7. package/dist/ListColumn.d.ts.map +1 -0
  8. package/dist/PreviewCard.d.ts +9 -2
  9. package/dist/PreviewCard.d.ts.map +1 -1
  10. package/dist/SectionHeader.d.ts +7 -1
  11. package/dist/SectionHeader.d.ts.map +1 -1
  12. package/dist/Select.d.ts +12 -3
  13. package/dist/Select.d.ts.map +1 -1
  14. package/dist/Toolbar.d.ts +18 -0
  15. package/dist/Toolbar.d.ts.map +1 -1
  16. package/dist/Tooltip.d.ts +13 -1
  17. package/dist/Tooltip.d.ts.map +1 -1
  18. package/dist/eslint/index.js +2 -1
  19. package/dist/eslint/index.js.map +2 -2
  20. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +189 -143
  24. package/dist/index.js.map +4 -4
  25. package/package.json +1 -1
  26. package/registry.json +214 -11
  27. package/src/AppShell.mdx +18 -1
  28. package/src/AppShell.test.tsx +16 -0
  29. package/src/AppShell.tsx +4 -1
  30. package/src/AttachmentCard.mdx +12 -1
  31. package/src/AttachmentCard.test.tsx +10 -0
  32. package/src/AttachmentCard.tsx +8 -5
  33. package/src/Avatar.mdx +20 -5
  34. package/src/AvatarGroup.mdx +4 -0
  35. package/src/Banner.mdx +17 -8
  36. package/src/Breadcrumb.mdx +12 -0
  37. package/src/Button.mdx +17 -5
  38. package/src/Button.tsx +2 -2
  39. package/src/Card.mdx +14 -1
  40. package/src/Checkbox.mdx +11 -0
  41. package/src/Chip.mdx +4 -0
  42. package/src/ChipInput.mdx +13 -1
  43. package/src/CollapsibleSection.mdx +12 -3
  44. package/src/CommandPalette.mdx +14 -2
  45. package/src/ConfirmDialog.mdx +13 -2
  46. package/src/ContainerHeader.mdx +15 -2
  47. package/src/DialogShell.mdx +15 -1
  48. package/src/Divider.mdx +12 -1
  49. package/src/EditableText.mdx +17 -1
  50. package/src/EmptyState.mdx +17 -6
  51. package/src/Field.mdx +13 -5
  52. package/src/FieldLine.mdx +9 -1
  53. package/src/FilePicker.mdx +8 -0
  54. package/src/Form.mdx +11 -1
  55. package/src/Form.test.tsx +26 -0
  56. package/src/Form.tsx +7 -0
  57. package/src/IconButton.mdx +17 -3
  58. package/src/IconButton.stories.tsx +3 -0
  59. package/src/IconButton.test.tsx +25 -0
  60. package/src/IconButton.tsx +61 -46
  61. package/src/IdentityMenu.mdx +10 -1
  62. package/src/InlineChip.mdx +11 -0
  63. package/src/Kbd.mdx +6 -2
  64. package/src/Link.mdx +13 -1
  65. package/src/ListColumn.mdx +87 -0
  66. package/src/ListColumn.stories.tsx +136 -0
  67. package/src/ListColumn.test.tsx +50 -0
  68. package/src/ListColumn.tsx +63 -0
  69. package/src/Menu.mdx +18 -1
  70. package/src/MenuItem.mdx +15 -2
  71. package/src/NavItem.mdx +12 -0
  72. package/src/Person.mdx +7 -0
  73. package/src/PersonTrigger.mdx +11 -1
  74. package/src/PersonTrigger.tsx +1 -1
  75. package/src/Popover.mdx +23 -0
  76. package/src/PreviewCard.mdx +19 -4
  77. package/src/PreviewCard.tsx +11 -4
  78. package/src/ProgressBar.mdx +8 -0
  79. package/src/Property.mdx +4 -0
  80. package/src/Rail.mdx +12 -1
  81. package/src/RailItem.mdx +12 -0
  82. package/src/RailItem.tsx +1 -1
  83. package/src/Reaction.mdx +10 -0
  84. package/src/Reaction.tsx +1 -1
  85. package/src/ReactionPicker.mdx +8 -0
  86. package/src/ScrollArea.mdx +16 -2
  87. package/src/SearchInput.mdx +9 -0
  88. package/src/SectionHeader.mdx +18 -1
  89. package/src/SectionHeader.stories.tsx +9 -0
  90. package/src/SectionHeader.test.tsx +9 -0
  91. package/src/SectionHeader.tsx +9 -3
  92. package/src/SectionLabel.mdx +10 -2
  93. package/src/Select.mdx +24 -4
  94. package/src/Select.stories.tsx +4 -1
  95. package/src/Select.test.tsx +29 -0
  96. package/src/Select.tsx +36 -14
  97. package/src/Sidebar.mdx +8 -0
  98. package/src/Skeleton.mdx +8 -0
  99. package/src/Skeleton.tsx +1 -1
  100. package/src/Tabs.mdx +12 -1
  101. package/src/TextInput.mdx +9 -0
  102. package/src/TextInput.tsx +1 -1
  103. package/src/Textarea.mdx +8 -0
  104. package/src/Toast.mdx +12 -1
  105. package/src/Toolbar.mdx +20 -2
  106. package/src/Toolbar.stories.tsx +19 -2
  107. package/src/Toolbar.test.tsx +24 -1
  108. package/src/Toolbar.tsx +22 -0
  109. package/src/Tooltip.mdx +27 -2
  110. package/src/Tooltip.stories.tsx +26 -0
  111. package/src/Tooltip.test.tsx +51 -0
  112. package/src/Tooltip.tsx +17 -4
  113. package/src/TopBar.mdx +11 -0
  114. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  115. package/src/eslint/no-restyled-part.ts +1 -0
  116. package/src/heights.test.tsx +78 -0
  117. package/src/index.ts +2 -1
  118. package/src/pages.test.ts +142 -0
  119. package/src/registry/registry.test.ts +16 -8
  120. package/tailwind-preset.js +22 -1
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,14 @@ 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
+ * Given with `disabled`, the reason wins, as on `Button`.
60
+ */
61
+ disabledReason?: string
52
62
  className?: string
53
63
  }
54
64
 
@@ -57,20 +67,16 @@ export interface SelectProps {
57
67
  const GAP = 4
58
68
  const VIEWPORT_PAD = 8
59
69
 
60
- export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className, ...aria }: SelectProps) {
70
+ export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, disabledReason, className, ...aria }: SelectProps) {
61
71
  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
- >
72
+ // With a reason it is held shut rather than disabled: a disabled trigger is
73
+ // a native `disabled` button, which Tab skips and a tooltip cannot open on.
74
+ const held = Boolean(disabledReason)
75
+ const trigger = (
71
76
  <BaseSelect.Trigger
72
77
  aria-label={ariaLabel}
73
78
  aria-required={aria['aria-required']}
79
+ aria-disabled={held || undefined}
74
80
  className={cn(
75
81
  /*
76
82
  * `min-w-0 max-w-full`: a trigger must never outgrow its container
@@ -85,11 +91,13 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
85
91
  // The focused border survives a hover: hover alone strengthens the
86
92
  // hairline, but hover while focused must not grey the focus colour —
87
93
  // 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',
94
+ !held && 'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus',
95
+ 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
96
+ held && 'cursor-not-allowed bg-bg-disabled text-text-disabled',
89
97
  'focus-visible:border-border-focus aria-expanded:border-border-focus',
90
98
  'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
91
99
  size === 'default' && 'px-3 py-2 text-input-value',
92
- size === 'small' && 'h-6 px-2 text-caption',
100
+ size === 'small' && 'h-6 min-h-6 px-2 text-caption',
93
101
  className,
94
102
  )}
95
103
  >
@@ -101,6 +109,20 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
101
109
  render={<IconChevronDown size={size === 'small' ? 14 : 16} stroke={1.5} className="shrink-0 text-text-secondary" />}
102
110
  />
103
111
  </BaseSelect.Trigger>
112
+ )
113
+ return (
114
+ <BaseSelect.Root
115
+ value={value}
116
+ onValueChange={(next) => {
117
+ if (!held) onChange(next as string)
118
+ }}
119
+ {...(held ? { open: false, onOpenChange: () => {} } : {})}
120
+ disabled={disabled && !held}
121
+ // Non-modal, as it has always been: the page behind stays scrollable and
122
+ // keeps its scrollbar, so opening a select never shifts the layout.
123
+ modal={false}
124
+ >
125
+ {held ? <TooltipTrigger label={disabledReason ?? ''}>{trigger}</TooltipTrigger> : trigger}
104
126
 
105
127
  <BaseSelect.Portal>
106
128
  <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,11 @@ 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
+ 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/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,15 @@ 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
+ | Keeps its height in a scrolling column | `shrink-0` | |
57
+
49
58
  ## Props
50
59
 
51
60
  <Controls of={TextInputStories.Empty} />
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/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
@@ -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. Peek had built this twice and the two had already drifted: one
73
- `rounded-sm` with `shadow-sm`, the other `rounded-lg` with `shadow-lg`.
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,24 @@ 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
+
92
+ ## What it owns
93
+
94
+ It does these by itself. Write none of them around it.
95
+
96
+ | It does | So you never write | Refused when written by hand |
97
+ |---|---|---|
98
+ | Takes its keys by itself: Enter and Space press the focused control | a key listener on the page | ✓ |
99
+ | Moves through its items with the arrow keys, and wraps at the ends | an arrow-key handler | ✓ |
100
+ | Says what it is to assistive technology: a toolbar | `role="toolbar"` | ✓ |
101
+ | Is one Tab stop, and remembers where the walk left off | `tabIndex` on each control | |
102
+
85
103
  ## Keys
86
104
 
87
105
  | Input | What happens |
@@ -1,9 +1,9 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
- import { IconArrowBackUp, IconArrowForwardUp, IconBold, IconItalic, IconLink, IconSquareRounded, IconUnderline } from '@tabler/icons-react'
2
+ import { IconArrowBackUp, IconArrowForwardUp, IconArrowUpRight, IconBold, IconItalic, IconLink, IconSquareRounded, IconUnderline } from '@tabler/icons-react'
3
3
  import { useState } from 'react'
4
4
  import { IconButton } from './IconButton'
5
5
  import { Popover } from './Popover'
6
- import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
6
+ import { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator } from './Toolbar'
7
7
 
8
8
  /**
9
9
  * A strip of controls that behaves as **one** control: Tab in, arrow keys
@@ -100,6 +100,23 @@ export const WithADisabledControl: Story = {
100
100
  ),
101
101
  }
102
102
 
103
+ /**
104
+ * A control that goes somewhere, beside controls that do something. The link
105
+ * has the buttons' look and joins the same walk; it has a real address, so a
106
+ * Ctrl+click opens a new tab.
107
+ */
108
+ export const WithALink: Story = {
109
+ parameters: { controls: { disable: true } },
110
+ render: () => (
111
+ <Toolbar aria-label="Item actions" surface={false}>
112
+ <ToolbarButton aria-label="Item one" tooltip="Item one">{icon}</ToolbarButton>
113
+ <ToolbarLink href="#item" aria-label="Open the item" tooltip="Open">
114
+ <IconArrowUpRight size={16} stroke={1.5} />
115
+ </ToolbarLink>
116
+ </Toolbar>
117
+ ),
118
+ }
119
+
103
120
  /** Down rather than across: ↑ and ↓ walk it. */
104
121
  export const Vertical: Story = {
105
122
  parameters: { controls: { disable: true } },
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
7
7
  import { cleanup, render, screen } from '@testing-library/react'
8
8
  import userEvent from '@testing-library/user-event'
9
9
  import { IconButton } from './IconButton'
10
- import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
10
+ import { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator } from './Toolbar'
11
11
  import { Menu, MenuItem } from './Menu'
12
12
  import { Popover } from './Popover'
13
13
 
@@ -176,6 +176,29 @@ describe('Toolbar', () => {
176
176
  })
177
177
  })
178
178
 
179
+ describe('a link in the strip (F3)', () => {
180
+ it('is a real link, in the walk with the buttons beside it', async () => {
181
+ const user = userEvent.setup()
182
+ render(
183
+ <>
184
+ <button type="button">Before</button>
185
+ <Toolbar aria-label="Item actions">
186
+ <ToolbarButton aria-label="One">{dot}</ToolbarButton>
187
+ <ToolbarLink href="/somewhere" aria-label="Open" tooltip="Open">{dot}</ToolbarLink>
188
+ </Toolbar>
189
+ </>,
190
+ )
191
+ const link = screen.getByRole('link', { name: 'Open' })
192
+ expect(link.getAttribute('href')).toBe('/somewhere')
193
+ await user.click(screen.getByRole('button', { name: 'Before' }))
194
+ await user.tab()
195
+ expect(focused()).toBe('One')
196
+ await user.keyboard('{ArrowRight}')
197
+ expect(document.activeElement).toBe(link)
198
+ expect((await screen.findByRole('tooltip')).textContent).toBe('Open')
199
+ })
200
+ })
201
+
179
202
  describe('a loose row, for comparison', () => {
180
203
  /** The measurement the component exists to change: the same three buttons
181
204
  * outside a toolbar are three Tab stops. */
package/src/Toolbar.tsx CHANGED
@@ -129,6 +129,28 @@ export function ToolbarButton({ ref, disabled, disabledReason, ...props }: Toolb
129
129
  )
130
130
  }
131
131
 
132
+ /**
133
+ * A link in the strip: the strip's own look, and it goes somewhere. It has a
134
+ * real address, so a Ctrl+click or a middle click opens a new tab, which a
135
+ * button that navigates cannot do.
136
+ *
137
+ * It is `IconButton`'s `href` joined to the toolbar's walk: Base UI's
138
+ * `Toolbar.Link`. A `ToolbarButton` cannot be a link — Base UI expects a real
139
+ * button there and warns when it gets an anchor (UIG-14, finding F3).
140
+ *
141
+ * A link is never disabled: a place you cannot go is left out of the strip.
142
+ */
143
+ export interface ToolbarLinkProps extends Omit<IconButtonProps, 'href' | 'disabled' | 'disabledReason' | 'pressed' | 'type' | 'ref'> {
144
+ href: string
145
+ ref?: Ref<HTMLAnchorElement>
146
+ }
147
+
148
+ /** An `IconButton` that is a link, and an item of a `Toolbar`: the arrow keys
149
+ * reach it like the buttons beside it. */
150
+ export function ToolbarLink({ ref, href, ...props }: ToolbarLinkProps) {
151
+ return <BaseToolbar.Link ref={ref} render={<IconButton href={href} {...props} />} />
152
+ }
153
+
132
154
  /**
133
155
  * A text field inside the strip — the link editor in a selection toolbar.
134
156
  *
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,12 @@ 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. It sits at the top of the line by itself, so the line keeps its
56
+ height; add no wrapper of your own around it.
57
+ - A label longer than 320px wraps onto more lines rather than running off the
58
+ screen; a short one stays one line at 30px.
50
59
  - **`Button` and `IconButton` need no wrapper**: pass `tooltip` (or
51
60
  `disabledReason`) and the button itself becomes the trigger. That is what
52
61
  lets a `Dialog.Close` or a `Menu.Trigger` be one of them — the component's
@@ -56,6 +65,10 @@ import { WithTooltip } from '@estiva-app/ui'
56
65
  looking at — so pass the finished label. `IconButton` forwards its own
57
66
  `tooltipShortcut` here.
58
67
 
68
+ <Canvas of={TooltipStories.InsideText} />
69
+
70
+ <Canvas of={TooltipStories.LongLabel} />
71
+
59
72
  ### The delay, and why a toolbar only pauses once
60
73
 
61
74
  A tooltip waits **300ms** before it opens. Mount **one `TooltipProvider` at
@@ -82,6 +95,18 @@ beneath one — and fades out over **80ms**. Moving between triggers inside a
82
95
  warm group skips the animation entirely, so a toolbar sweep does not flicker.
83
96
  `prefers-reduced-motion` removes it.
84
97
 
98
+ ## What it owns
99
+
100
+ It does these by itself. Write none of them around it.
101
+
102
+ | It does | So you never write | Refused when written by hand |
103
+ |---|---|---|
104
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
105
+ | Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
106
+ | Says what it is to assistive technology: a tooltip | `role="tooltip"` | ✓ |
107
+ | Opens after the pointer rests, and at once on keyboard focus | a timer, or hover and focus handlers | |
108
+ | Closes on Escape, on a click of its control, and when the pointer leaves | close handlers | |
109
+
85
110
  ## Keys
86
111
 
87
112
  | Input | What happens |
@@ -31,6 +31,32 @@ export const ShortcutComparison: Story = {
31
31
  ),
32
32
  }
33
33
 
34
+ /** A label longer than 320px wraps onto more lines, rather than running off the screen. A short one stays one line at 30px. */
35
+ export const LongLabel: Story = {
36
+ parameters: { controls: { disable: true } },
37
+ render: () => (
38
+ <div className="flex flex-col items-start gap-2">
39
+ <Tooltip label="Comment" />
40
+ <Tooltip label="naddr1qvzqqqr4gupzq9h35qgq6n8ll0xyyv8gurjzjrx9sjwp4hry6ejnlks8cqcmzp6tqyfhwumn8ghj7mmxve3ksctfdch8qatz9uq3wamnwvaz7tmjv4kxz7fwwpexjmtpdshxuet59uq3qamnwvaz7tm99ehx2aqqz9mhxue69uhkummnw3ez6un9d3shjtnwda4k7tnr" />
41
+ <Tooltip label="Item one, with a title long enough that it no longer fits on one line of a tooltip" />
42
+ </div>
43
+ ),
44
+ }
45
+
46
+ /** `inline`: a tooltip on a word inside a sentence. The wrapper is a span, so the paragraph stays valid HTML. Hover the word. */
47
+ export const InsideText: Story = {
48
+ parameters: { controls: { disable: true } },
49
+ render: () => (
50
+ <p className="max-w-sm text-body-2 text-text-primary">
51
+ The plan is in{' '}
52
+ <WithTooltip inline label="Item one, with a title long enough that it no longer fits on one line of a tooltip">
53
+ <span className="font-semibold">Item one</span>
54
+ </WithTooltip>
55
+ , and the rest follows.
56
+ </p>
57
+ ),
58
+ }
59
+
34
60
  /** Hover the button — WithTooltip portals the tooltip above the trigger. */
35
61
  export const OnHoverTop: Story = {
36
62
  parameters: { controls: { disable: true } },
@@ -27,6 +27,14 @@ describe('Tooltip, the surface', () => {
27
27
  expect(tip.textContent).toBe('BoldCmd+B')
28
28
  })
29
29
 
30
+ it('wraps a long label at 320px instead of running it off the screen, and stays 30px on one line', () => {
31
+ render(<Tooltip label="A label long enough to wrap onto a second line of the tooltip" />)
32
+ const pill = screen.getByRole('tooltip')
33
+ expect(pill.className).toContain('max-w-80')
34
+ expect(pill.className).toContain('min-h-[30px]')
35
+ expect(pill.querySelector('span')?.className).not.toContain('whitespace-nowrap')
36
+ })
37
+
30
38
  it('takes a caller class without losing its own', () => {
31
39
  render(<Tooltip label="Bold" className="w-40" />)
32
40
  const tip = screen.getByRole('tooltip')
@@ -49,6 +57,49 @@ describe('WithTooltip', () => {
49
57
  expect(wrapper.firstElementChild?.tagName).toBe('BUTTON')
50
58
  })
51
59
 
60
+ it('inline: a span wrapper, so it can sit inside a paragraph without React warning', () => {
61
+ const errors: unknown[] = []
62
+ const original = console.error
63
+ console.error = (...args: unknown[]) => { errors.push(args) }
64
+ try {
65
+ render(
66
+ <p>
67
+ The plan is in{' '}
68
+ <WithTooltip inline label="Item one">
69
+ <span>Item one</span>
70
+ </WithTooltip>
71
+ .
72
+ </p>,
73
+ )
74
+ } finally {
75
+ console.error = original
76
+ }
77
+ expect(screen.getByText('Item one').parentElement?.tagName).toBe('SPAN')
78
+ expect(errors).toEqual([])
79
+ })
80
+
81
+ // F1: without `align-top` a chip in the wrapper drops 0.2px and its line grows
82
+ // from 19.6 to 20px; the apps may not add the class to the part, so it carries it.
83
+ it('inline: the wrapper sits at the top of the line by itself', () => {
84
+ render(
85
+ <p>
86
+ <WithTooltip inline label="Item one">
87
+ <span>Item one</span>
88
+ </WithTooltip>
89
+ </p>,
90
+ )
91
+ expect(screen.getByText('Item one').parentElement?.className.split(' ')).toContain('align-top')
92
+ })
93
+
94
+ it('a block wrapper is not moved', () => {
95
+ render(
96
+ <WithTooltip label="Item one">
97
+ <span>Item one</span>
98
+ </WithTooltip>,
99
+ )
100
+ expect(screen.getByText('Item one').parentElement?.className.split(' ')).not.toContain('align-top')
101
+ })
102
+
52
103
  it('takes wrapperClassName, which is what keeps a truncating label truncating', () => {
53
104
  const { container } = render(
54
105
  <WithTooltip label="A long name" wrapperClassName="min-w-0 shrink">
package/src/Tooltip.tsx CHANGED
@@ -64,10 +64,16 @@ export interface TooltipProps extends Omit<ComponentPropsWithRef<'div'>, 'childr
64
64
  shortcut?: string
65
65
  }
66
66
 
67
+ /**
68
+ * One line at 30px, as it has always been; a label longer than 320px wraps
69
+ * onto more lines rather than running off the screen (Katerina, 19 September:
70
+ * a reference's full address drew a pill 950px wide). `py-1` only shows once
71
+ * it wraps: a single line is centred in the 30px.
72
+ */
67
73
  export function Tooltip({ label, shortcut, className, ...props }: TooltipProps) {
68
74
  return (
69
- <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center gap-1.5 px-2 shadow-lg', className)} {...props}>
70
- <span className="text-caption text-text-primary whitespace-nowrap">{label}</span>
75
+ <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg min-h-[30px] max-w-80 flex items-center justify-center gap-1.5 px-2 py-1 shadow-lg', className)} {...props}>
76
+ <span className="min-w-0 text-caption text-text-primary break-words">{label}</span>
71
77
  {shortcut && <Kbd>{shortcut}</Kbd>}
72
78
  </div>
73
79
  )
@@ -80,6 +86,12 @@ export interface WithTooltipProps {
80
86
  placement?: 'top' | 'bottom'
81
87
  /** Extra classes on the wrapper — e.g. `min-w-0 shrink` so a truncating label keeps truncating inside it. */
82
88
  wrapperClassName?: string
89
+ /**
90
+ * For a trigger inside a line of text — a reference, a name in a sentence:
91
+ * the wrapper is a `<span>`, because a `<div>` is not allowed inside a
92
+ * paragraph (UIG-14, Katerina, 19 September).
93
+ */
94
+ inline?: boolean
83
95
  children: ReactNode
84
96
  }
85
97
 
@@ -150,7 +162,8 @@ function TooltipSurface({ label, shortcut, placement }: { label: string; shortcu
150
162
  * an icon. It wraps what it is given in an element that carries the handlers.
151
163
  * A control that can be the trigger does it itself: `IconButton` takes a
152
164
  * `tooltip` prop, and `Button` shows a `disabledReason` the same way. */
153
- export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
165
+ export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, inline = false, children }: WithTooltipProps) {
166
+ const Wrapper = inline ? 'span' : 'div'
154
167
  return (
155
168
  <BaseTooltip.Root disableHoverablePopup>
156
169
  {/* The wrapper stays the trigger: `WithTooltip` wraps whatever it is
@@ -158,7 +171,7 @@ export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassNa
158
171
  can carry the handlers for all of them. A control that IS the trigger
159
172
  (Button, IconButton) composes the part onto itself instead, which is
160
173
  what lets a `Dialog.Close` or a `Menu.Trigger` be one of those. */}
161
- <BaseTooltip.Trigger delay={OPEN_DELAY} render={<div className={cn('inline-flex shrink-0', wrapperClassName)} />}>
174
+ <BaseTooltip.Trigger delay={OPEN_DELAY} render={<Wrapper className={cn('inline-flex shrink-0', inline && 'align-top', wrapperClassName)} />}>
162
175
  {children}
163
176
  </BaseTooltip.Trigger>
164
177
  <TooltipSurface label={label} shortcut={shortcut} placement={placement} />
package/src/TopBar.mdx CHANGED
@@ -57,6 +57,17 @@ import { TopBar, IdentityMenu, SearchInput } from '@estiva-app/ui'
57
57
  - The frame is desktop-only for now — there is no narrow-screen
58
58
  behaviour; the menu button collapses panels, it does not open a drawer.
59
59
 
60
+ - The bar is 52px tall, 20px in on the left and 26px on the right, 16px between
61
+ its slots.
62
+
63
+ ## What it owns
64
+
65
+ It does these by itself. Write none of them around it.
66
+
67
+ | It does | So you never write | Refused when written by hand |
68
+ |---|---|---|
69
+ | Keeps its 52px in a scrolling column | `shrink-0` | |
70
+
60
71
  ## Props
61
72
 
62
73
  <Controls of={TopBarStories.Solid} />
@@ -202,7 +202,7 @@ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
202
202
  },
203
203
  {
204
204
  id: 'page-keys',
205
- behaviour: 'Closes on Escape, and takes its keys, by itself',
205
+ behaviour: 'Takes its keys by itself',
206
206
  baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Tooltip', 'PreviewCard', 'Toast'],
207
207
  owners: ['DialogShell', 'Popover', 'Menu', 'Select', 'Tabs', 'Toolbar'],
208
208
  reads: 'a keydown, keyup or keypress listener on window or document',
@@ -118,6 +118,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
118
118
  RailItem: ['active'],
119
119
  Reaction: ['pressed'],
120
120
  ReactionPicker: ['surface'],
121
+ SectionHeader: ['hover'],
121
122
  SectionLabel: ['tone'],
122
123
  Select: ['size'],
123
124
  Tabs: ['size'],