@estiva-app/ui 0.9.0 → 0.10.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 (154) hide show
  1. package/dist/Avatar.d.ts +13 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +16 -1
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/Breadcrumb.d.ts +9 -0
  7. package/dist/Breadcrumb.d.ts.map +1 -1
  8. package/dist/Button.d.ts +12 -2
  9. package/dist/Button.d.ts.map +1 -1
  10. package/dist/Checkbox.d.ts +2 -0
  11. package/dist/Checkbox.d.ts.map +1 -1
  12. package/dist/Chip.d.ts +3 -2
  13. package/dist/Chip.d.ts.map +1 -1
  14. package/dist/ChipInput.d.ts +3 -1
  15. package/dist/ChipInput.d.ts.map +1 -1
  16. package/dist/ConfirmDialog.d.ts +5 -0
  17. package/dist/ConfirmDialog.d.ts.map +1 -1
  18. package/dist/DialogShell.d.ts +27 -3
  19. package/dist/DialogShell.d.ts.map +1 -1
  20. package/dist/EditableText.d.ts +6 -0
  21. package/dist/EditableText.d.ts.map +1 -1
  22. package/dist/Field.d.ts +42 -25
  23. package/dist/Field.d.ts.map +1 -1
  24. package/dist/IconButton.d.ts +3 -2
  25. package/dist/IconButton.d.ts.map +1 -1
  26. package/dist/IdentityMenu.d.ts +25 -13
  27. package/dist/IdentityMenu.d.ts.map +1 -1
  28. package/dist/Menu.d.ts +65 -82
  29. package/dist/Menu.d.ts.map +1 -1
  30. package/dist/PersonTrigger.d.ts.map +1 -1
  31. package/dist/Popover.d.ts +86 -0
  32. package/dist/Popover.d.ts.map +1 -0
  33. package/dist/PreviewCard.d.ts +39 -0
  34. package/dist/PreviewCard.d.ts.map +1 -0
  35. package/dist/Reaction.d.ts.map +1 -1
  36. package/dist/ReactionPicker.d.ts +55 -0
  37. package/dist/ReactionPicker.d.ts.map +1 -0
  38. package/dist/SearchInput.d.ts +5 -0
  39. package/dist/SearchInput.d.ts.map +1 -1
  40. package/dist/Select.d.ts +26 -16
  41. package/dist/Select.d.ts.map +1 -1
  42. package/dist/Tabs.d.ts +11 -1
  43. package/dist/Tabs.d.ts.map +1 -1
  44. package/dist/TextInput.d.ts +7 -1
  45. package/dist/TextInput.d.ts.map +1 -1
  46. package/dist/Textarea.d.ts +8 -1
  47. package/dist/Textarea.d.ts.map +1 -1
  48. package/dist/Toolbar.d.ts +95 -0
  49. package/dist/Toolbar.d.ts.map +1 -0
  50. package/dist/Tooltip.d.ts +36 -10
  51. package/dist/Tooltip.d.ts.map +1 -1
  52. package/dist/cn.d.ts.map +1 -1
  53. package/dist/fit.d.ts +7 -72
  54. package/dist/fit.d.ts.map +1 -1
  55. package/dist/index.d.ts +6 -3
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +888 -763
  58. package/dist/index.js.map +4 -4
  59. package/dist/triggerDisabled.d.ts +10 -0
  60. package/dist/triggerDisabled.d.ts.map +1 -0
  61. package/package.json +1 -1
  62. package/src/Avatar.mdx +17 -0
  63. package/src/Avatar.name.test.tsx +99 -0
  64. package/src/Avatar.tsx +25 -3
  65. package/src/AvatarGroup.tsx +4 -1
  66. package/src/Banner.mdx +10 -2
  67. package/src/Banner.stories.tsx +29 -0
  68. package/src/Banner.test.tsx +60 -0
  69. package/src/Banner.tsx +35 -3
  70. package/src/Breadcrumb.mdx +5 -2
  71. package/src/Breadcrumb.test.tsx +44 -0
  72. package/src/Breadcrumb.tsx +10 -2
  73. package/src/Button.compose.test.tsx +119 -0
  74. package/src/Button.mdx +7 -5
  75. package/src/Button.test.tsx +2 -1
  76. package/src/Button.tsx +21 -5
  77. package/src/Checkbox.tsx +3 -0
  78. package/src/Chip.tsx +3 -2
  79. package/src/ChipInput.tsx +4 -0
  80. package/src/ConfirmDialog.mdx +20 -1
  81. package/src/ConfirmDialog.test.tsx +109 -0
  82. package/src/ConfirmDialog.tsx +6 -0
  83. package/src/DialogShell.mdx +18 -4
  84. package/src/DialogShell.stories.tsx +56 -6
  85. package/src/DialogShell.test.tsx +184 -0
  86. package/src/DialogShell.tsx +108 -39
  87. package/src/EditableText.mdx +6 -1
  88. package/src/EditableText.test.tsx +156 -0
  89. package/src/EditableText.tsx +23 -5
  90. package/src/Field.mdx +23 -3
  91. package/src/Field.stories.tsx +35 -0
  92. package/src/Field.test.tsx +144 -26
  93. package/src/Field.tsx +94 -59
  94. package/src/IconButton.mdx +6 -2
  95. package/src/IconButton.test.tsx +5 -2
  96. package/src/IconButton.tsx +12 -6
  97. package/src/IdentityMenu.mdx +29 -6
  98. package/src/IdentityMenu.stories.tsx +41 -6
  99. package/src/IdentityMenu.tsx +74 -47
  100. package/src/Kbd.stories.tsx +3 -3
  101. package/src/Menu.mdx +88 -75
  102. package/src/Menu.stories.tsx +120 -52
  103. package/src/Menu.test.tsx +315 -0
  104. package/src/Menu.tsx +347 -251
  105. package/src/MenuItem.stories.tsx +135 -98
  106. package/src/NavItem.mdx +1 -1
  107. package/src/PersonTrigger.mdx +20 -14
  108. package/src/PersonTrigger.tsx +23 -1
  109. package/src/Popover.mdx +130 -0
  110. package/src/Popover.stories.tsx +222 -0
  111. package/src/Popover.test.tsx +188 -0
  112. package/src/Popover.tsx +156 -0
  113. package/src/PreviewCard.mdx +98 -0
  114. package/src/PreviewCard.stories.tsx +168 -0
  115. package/src/PreviewCard.test.tsx +83 -0
  116. package/src/PreviewCard.tsx +91 -0
  117. package/src/Rail.mdx +63 -0
  118. package/src/Rail.stories.tsx +81 -0
  119. package/src/Reaction.mdx +4 -2
  120. package/src/Reaction.tsx +4 -2
  121. package/src/ReactionPicker.mdx +85 -0
  122. package/src/ReactionPicker.stories.tsx +120 -0
  123. package/src/ReactionPicker.test.tsx +118 -0
  124. package/src/ReactionPicker.tsx +88 -0
  125. package/src/SearchInput.mdx +6 -2
  126. package/src/SearchInput.tsx +7 -1
  127. package/src/Select.mdx +16 -5
  128. package/src/Select.test.tsx +157 -0
  129. package/src/Select.tsx +89 -214
  130. package/src/Sidebar.mdx +6 -2
  131. package/src/Tabs.mdx +5 -0
  132. package/src/Tabs.test.tsx +15 -0
  133. package/src/Tabs.tsx +17 -2
  134. package/src/TextInput.mdx +8 -2
  135. package/src/TextInput.tsx +10 -7
  136. package/src/Textarea.mdx +6 -2
  137. package/src/Textarea.tsx +14 -9
  138. package/src/Toolbar.mdx +104 -0
  139. package/src/Toolbar.stories.tsx +142 -0
  140. package/src/Toolbar.test.tsx +253 -0
  141. package/src/Toolbar.tsx +158 -0
  142. package/src/Tooltip.mdx +43 -5
  143. package/src/Tooltip.stories.tsx +26 -0
  144. package/src/Tooltip.test.tsx +195 -0
  145. package/src/Tooltip.tsx +150 -48
  146. package/src/cn.ts +1 -1
  147. package/src/fit.test.ts +101 -0
  148. package/src/fit.ts +19 -63
  149. package/src/index.ts +6 -3
  150. package/src/triggerDisabled.ts +13 -0
  151. package/stories/Choosing.mdx +8 -3
  152. package/tailwind-preset.js +7 -0
  153. package/src/Menu.fit.test.ts +0 -90
  154. package/src/Select.fit.test.ts +0 -101
@@ -1,98 +1,135 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite'
2
- import { IconCopy, IconPin, IconTrash } from '@tabler/icons-react'
3
- import { Avatar } from './Avatar'
4
- import { EnterHint, Menu, MenuItem } from './Menu'
5
-
6
- /**
7
- * One row of a menu. Anatomy: `leading` (a 16px icon or an Avatar) · label
8
- * with an optional `description` line · one thing at the right edge —
9
- * `trailing` (any hint), else `shortcut` (the kbd chip), else the `submenu`
10
- * chevron. Stories sit inside a pinned-open Menu so the row is seen on the
11
- * surface it lives on.
12
- */
13
- const meta = {
14
- title: 'Overlays/MenuItem',
15
- component: MenuItem,
16
- decorators: [
17
- (Story) => (
18
- <div className="flex min-h-[120px] w-full items-center justify-center">
19
- <Menu onClose={() => {}} className="static w-72">
20
- <Story />
21
- </Menu>
22
- </div>
23
- ),
24
- ],
25
- args: { label: 'Rename', onClick: () => {} },
26
- argTypes: {
27
- onClick: { control: false },
28
- leading: { control: false },
29
- trailing: { control: false },
30
- },
31
- } satisfies Meta<typeof MenuItem>
32
-
33
- export default meta
34
- type Story = StoryObj<typeof meta>
35
-
36
- export const Default: Story = {}
37
-
38
- export const WithAnIcon: Story = {
39
- args: { label: 'Pin', leading: <IconPin size={16} stroke={1.5} className="text-text-secondary" /> },
40
- }
41
-
42
- export const WithAShortcut: Story = {
43
- args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'Ctrl+C' },
44
- }
45
-
46
- /** The row opens another menu. */
47
- export const Submenu: Story = {
48
- args: { label: 'Move to…', submenu: true },
49
- }
50
-
51
- /** A person as a row the face, the name, a second line, a trailing hint. Peek's mention rows. */
52
- export const APerson: Story = {
53
- args: {
54
- label: 'Ana Duarte',
55
- description: 'Product designer',
56
- leading: <Avatar name="Ana Duarte" size={32} />,
57
- trailing: <EnterHint />,
58
- },
59
- }
60
-
61
- /** The submenu's chosen value. */
62
- export const Selected: Story = {
63
- args: { label: 'Newest first', selected: true },
64
- }
65
-
66
- export const Destructive: Story = {
67
- args: { label: 'Delete', destructive: true, leading: <IconTrash size={16} stroke={1.5} className="text-error-default" /> },
68
- }
69
-
70
- /** Every shape on one canvas. */
71
- export const AllVariants: Story = {
72
- parameters: { controls: { disable: true } },
73
- render: () => (
74
- <>
75
- <MenuItem label="Bare" onClick={() => {}} />
76
- <MenuItem label="With an icon" leading={<IconPin size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
77
- <MenuItem label="With a shortcut" shortcut="Ctrl+K" onClick={() => {}} />
78
- <MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
79
- <MenuItem label="Opens another menu" submenu onClick={() => {}} />
80
- <MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<EnterHint />} onClick={() => {}} />
81
- <MenuItem label="The chosen value" selected onClick={() => {}} />
82
- <MenuItem label="Destructive" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
83
- <MenuItem label="A very long label that runs out of room and truncates" onClick={() => {}} />
84
- </>
85
- ),
86
- }
87
-
88
- /** The tall picker row — 48px, px-3, a 32px face or tile, the description line, and the hint while highlighted. TopicMenu, MentionMenu, the files menu and the launcher all draw this row. */
89
- export const Tall: Story = {
90
- args: {
91
- size: 'tall',
92
- label: 'Ana Duarte',
93
- description: 'Product designer',
94
- leading: <Avatar name="Ana Duarte" size={32} />,
95
- selected: true,
96
- trailing: <EnterHint />,
97
- },
98
- }
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconCopy, IconPin, IconTrash } from '@tabler/icons-react'
3
+ import { Avatar } from './Avatar'
4
+ import { useRef, useState } from 'react'
5
+ import { Button } from './Button'
6
+ import { Divider } from './Divider'
7
+ import { EnterHint, Menu, MenuItem, MenuPanel, MenuSub } from './Menu'
8
+
9
+ /**
10
+ * One row of a menu. Anatomy: `leading` (a 16px icon or an Avatar) · label
11
+ * with an optional `description` line · one thing at the right edge —
12
+ * `trailing` (any hint), else `shortcut` (the kbd chip), else the `submenu`
13
+ * chevron. Stories sit on a `MenuPanel` — the menu's surface, drawn without
14
+ * its behaviour — so the row is seen where it lives. A live menu portals and
15
+ * places itself, so it cannot stand in the page (Katerina, D25); **Menu →
16
+ * FromATrigger** is where the keyboard and the placement are.
17
+ */
18
+ const meta = {
19
+ title: 'Overlays/MenuItem',
20
+ component: MenuItem,
21
+ decorators: [
22
+ (Story) => (
23
+ <div className="flex min-h-[120px] w-full items-center justify-center">
24
+ <MenuPanel className="w-72">
25
+ <Story />
26
+ </MenuPanel>
27
+ </div>
28
+ ),
29
+ ],
30
+ args: { label: 'Rename', onClick: () => {} },
31
+ argTypes: {
32
+ onClick: { control: false },
33
+ leading: { control: false },
34
+ trailing: { control: false },
35
+ },
36
+ } satisfies Meta<typeof MenuItem>
37
+
38
+ export default meta
39
+ type Story = StoryObj<typeof meta>
40
+
41
+ export const Default: Story = {}
42
+
43
+ export const WithAnIcon: Story = {
44
+ args: { label: 'Pin', leading: <IconPin size={16} stroke={1.5} className="text-text-secondary" /> },
45
+ }
46
+
47
+ export const WithAShortcut: Story = {
48
+ args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'Ctrl+C' },
49
+ }
50
+
51
+ /** The row opens another menu. */
52
+ export const Submenu: Story = {
53
+ args: { label: 'Move to…', submenu: true },
54
+ }
55
+
56
+ /**
57
+ * The submenu, working. The story above draws the row at rest — the chevron is
58
+ * the whole affordance — but a row that opens another menu can only be tried
59
+ * inside a real one, so here is one.
60
+ *
61
+ * Hover **Move to…**, or arrow onto it and press →. The panel opens beside the
62
+ * row, flips to the other side at a screen edge, and stays open while you
63
+ * cross the diagonal into it.
64
+ *
65
+ * From the keyboard: → opens it and leaves the highlight on the row, ↓ steps
66
+ * into it, and ← closes it and puts the highlight back on the row. Measured in
67
+ * that order → then ← alone does nothing, because focus has not entered yet.
68
+ */
69
+ export const SubmenuLive: Story = {
70
+ parameters: { controls: { disable: true } },
71
+ decorators: [(Story) => <Story />],
72
+ render: () => (
73
+ <div className="flex min-h-[220px] w-full items-start justify-center pt-4">
74
+ <Menu trigger={<Button variant="outlined">Open the menu</Button>}>
75
+ <MenuItem label="Rename" onClick={() => {}} />
76
+ <MenuSub label="Move to…">
77
+ <MenuItem label="Item one" onClick={() => {}} />
78
+ <MenuItem label="Item two" onClick={() => {}} />
79
+ <MenuItem label="Item three" onClick={() => {}} />
80
+ </MenuSub>
81
+ <Divider className="my-1" />
82
+ <MenuItem label="Delete" destructive onClick={() => {}} />
83
+ </Menu>
84
+ </div>
85
+ ),
86
+ }
87
+
88
+ /** A person as a row — the face, the name, a second line, a trailing hint. Peek's mention rows. */
89
+ export const APerson: Story = {
90
+ args: {
91
+ label: 'Ana Duarte',
92
+ description: 'Product designer',
93
+ leading: <Avatar name="Ana Duarte" size={32} />,
94
+ trailing: <EnterHint />,
95
+ },
96
+ }
97
+
98
+ /** The submenu's chosen value. */
99
+ export const Selected: Story = {
100
+ args: { label: 'Item one', selected: true },
101
+ }
102
+
103
+ export const Destructive: Story = {
104
+ args: { label: 'Delete', destructive: true, leading: <IconTrash size={16} stroke={1.5} className="text-error-default" /> },
105
+ }
106
+
107
+ /** Every shape on one canvas. */
108
+ export const AllVariants: Story = {
109
+ parameters: { controls: { disable: true } },
110
+ render: () => (
111
+ <>
112
+ <MenuItem label="Bare" onClick={() => {}} />
113
+ <MenuItem label="With an icon" leading={<IconPin size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
114
+ <MenuItem label="With a shortcut" shortcut="Ctrl+K" onClick={() => {}} />
115
+ <MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
116
+ <MenuItem label="Opens another menu" submenu onClick={() => {}} />
117
+ <MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<EnterHint />} onClick={() => {}} />
118
+ <MenuItem label="The chosen value" selected onClick={() => {}} />
119
+ <MenuItem label="Destructive" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
120
+ <MenuItem label="A very long label that runs out of room and truncates" onClick={() => {}} />
121
+ </>
122
+ ),
123
+ }
124
+
125
+ /** The tall picker row — 48px, px-3, a 32px face or tile, the description line, and the hint while highlighted. TopicMenu, MentionMenu, the files menu and the launcher all draw this row. */
126
+ export const Tall: Story = {
127
+ args: {
128
+ size: 'tall',
129
+ label: 'Ana Duarte',
130
+ description: 'Product designer',
131
+ leading: <Avatar name="Ana Duarte" size={32} />,
132
+ selected: true,
133
+ trailing: <EnterHint />,
134
+ },
135
+ }
package/src/NavItem.mdx CHANGED
@@ -34,7 +34,7 @@ count — a muted mono number whose tooltip says what it counts.
34
34
  import { NavItem } from '@estiva-app/ui'
35
35
 
36
36
  <NavItem
37
- href="#/documents"
37
+ href="/documents"
38
38
  label="Documents"
39
39
  icon={<IconListDetails size={16} stroke={1.5} />}
40
40
  count={openCount}
@@ -29,23 +29,29 @@ fill, because the face fills the whole control.
29
29
  ## How
30
30
 
31
31
  ```tsx
32
- import { PersonTrigger } from '@estiva-app/ui'
32
+ import { Menu, PersonTrigger } from '@estiva-app/ui'
33
33
 
34
- <PersonTrigger name={me.name} picture={me.picture} open={open}
35
- onMouseDown={(e) => e.stopPropagation()} onClick={() => setOpen((v) => !v)} />
34
+ <Menu trigger={<PersonTrigger name={me.name} picture={me.picture} />}>
35
+
36
+ </Menu>
36
37
  ```
37
38
 
38
- - `open` holds the row's hover fill while the menu is up, and feeds
39
- `aria-expanded`.
40
- - **The accessible name**: the row shape is named by the visible name
41
- do **not** add an `aria-label`, it would override the person. The
42
- `compact` face has no visible text, so there an `aria-label` is
43
- required.
44
- - A toggle trigger must swallow its `mousedown` (as above) or the menu's
45
- outside-click dismiss turns each press into close-then-reopen — the
46
- trap is spelled out on the Menu page.
47
- - It is a native `<button>` on Base UI's Button: it takes a `ref` and
48
- every native button prop anchor your menu on it.
39
+ - **Hand it to the menu and write nothing else.** Since stage 4 a `Menu` owns
40
+ its trigger, so the open state, the toggle, `aria-expanded`, the placement
41
+ and the focus return are all its. The `onMouseDown` + `stopPropagation`
42
+ guard this page used to show is gone with the trap it worked around.
43
+ - `open` is still there for a caller driving something that is not a `Menu`;
44
+ it holds the row's hover fill and feeds `aria-expanded`. Under a `Menu` it
45
+ is unnecessary the trigger carries `data-popup-open`, which holds the
46
+ same fill.
47
+ - **The accessible name comes from the person, in both shapes.** The row is
48
+ named by its visible name, and the `compact` face which has no visible
49
+ text takes the same name through an `aria-label` of its own, so nothing
50
+ is owed. Until 2026-09-08 that face announced **"AD"**: the initials, read
51
+ as text. Pass an `aria-label` only to name what it *opens* rather than who
52
+ it shows; `IdentityMenu`'s compact shape says "Account menu".
53
+ - It is a native `<button>` on Base UI's Button: it takes a `ref` and every
54
+ native button prop.
49
55
 
50
56
  ## Keys
51
57
 
@@ -35,7 +35,24 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
35
35
  type="button"
36
36
  aria-haspopup="menu"
37
37
  aria-expanded={open}
38
- className={cn('cursor-pointer rounded-full focus:outline-none', className)}
38
+ /*
39
+ The face alone is an icon-only control, so it owes a name. It has
40
+ one already — the person — and taking it from there means no caller
41
+ can forget: measured 2026-09-08, this button announced **"AD"**, the
42
+ initials, with no `aria-label` anywhere. A caller that wants to name
43
+ the destination rather than the person still can, and `IdentityMenu`
44
+ does ("Account menu").
45
+ */
46
+ aria-label={props['aria-label'] ?? name ?? fallback}
47
+ /*
48
+ No `focus:outline-none` here any more. It removed the browser's ring
49
+ and put nothing in its place, so this control — the account trigger
50
+ in Peek's top bar — had **no visible focus at all** (measured
51
+ 2026-09-08: `outline: solid 2px rgba(0,0,0,0)`, no shadow). Left
52
+ alone it wears the same ring `Button`, `IconButton` and a `Tab`
53
+ wear, which is the ring the rest of the package already relies on.
54
+ */
55
+ className={cn('cursor-pointer rounded-full', className)}
39
56
  {...props}
40
57
  >
41
58
  <Avatar name={name} src={picture} size={size ?? 36} />
@@ -50,6 +67,11 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
50
67
  className={cn(
51
68
  'flex h-8 cursor-pointer items-center gap-1.5 rounded-md pl-1.5 pr-1.5 text-body-2 text-text-primary transition-colors hover:bg-bg-hover',
52
69
  open && 'bg-bg-hover',
70
+ // Under a `Menu` the open state is Base UI's, not a prop: the trigger
71
+ // carries `data-popup-open` while its menu is up, and sets its own
72
+ // `aria-expanded`. Both spellings hold the fill, so this works whether
73
+ // the caller drives it or the menu does.
74
+ 'data-[popup-open]:bg-bg-hover',
53
75
  className,
54
76
  )}
55
77
  {...props}
@@ -0,0 +1,130 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as PopoverStories from './Popover.stories'
3
+
4
+ <Meta of={PopoverStories} />
5
+
6
+ # Popover
7
+
8
+ A floating panel from a trigger: the same elevated surface a **Menu** draws,
9
+ with none of a menu's semantics. Its contents are contents — Tab walks them
10
+ in order, nothing is a menu item, and typing goes where you are typing.
11
+
12
+ <Canvas of={PopoverStories.Default} />
13
+
14
+ ## When
15
+
16
+ - A small form beside its trigger: rename, a date, a link address.
17
+ - A row of controls over a text selection — a formatting toolbar.
18
+ - A panel of settings or toggles.
19
+ - Anything you would have reached for `Menu` for because you wanted the box.
20
+
21
+ <Canvas of={PopoverStories.AToolbar} />
22
+
23
+ ## When not
24
+
25
+ - A list of actions → **Menu**. That is what the arrow keys, the typeahead
26
+ and `role="menuitem"` are for, and a Popover has none of them.
27
+ - One value from a known set → **Select**.
28
+ - A word for a control → **WithTooltip** — it cannot be pointed at, which
29
+ is the difference.
30
+ - More of a thing on hover → **PreviewCard**.
31
+ - A title, a body and a footer, with the page behind it out of reach →
32
+ **DialogShell**.
33
+
34
+ ## How
35
+
36
+ The panel owns the trigger, exactly as `Menu` does: hand it the control and the
37
+ contents, and write no open state, no placement and no dismiss.
38
+
39
+ ```tsx
40
+ import { Popover } from '@estiva-app/ui'
41
+
42
+ <Popover trigger={<Button variant="outlined">Rename</Button>} ariaLabel="Rename this">
43
+ <TextInput value={name} onChange={onChange} aria-label="Name" />
44
+ </Popover>
45
+ ```
46
+
47
+ - **`trigger`** is any element that forwards its ref and spreads its props —
48
+ this package's `Button`, `IconButton` and `PersonTrigger` all do.
49
+ - **Focus lands on the first thing in the panel** when it opens — the field, in
50
+ the panel this component exists for — and goes back to the trigger when it
51
+ closes. Nothing is written to get that.
52
+ - **`actionsRef`** gives `close()`, for the Cancel and Save a form panel ends
53
+ with. Unlike a menu row, a control inside a panel does not close it by being
54
+ pressed: it is content, and content may be used more than once.
55
+ - **`align="right"`** hangs the panel's right edge from the trigger's.
56
+ - **`side` is a preference, not a promise.** It says which side to try; Base UI
57
+ measures the room and flips when there is none — which is the reason the
58
+ placement is its job rather than arithmetic of ours. **A panel holding a
59
+ `Toolbar` asks for `top`**: a strip of controls acts on what is under it, so
60
+ it stands over that rather than on top of it.
61
+ - **`open` / `onOpenChange`** are there for a caller that must know or must
62
+ force it. Leave them off and the panel keeps its own state.
63
+ - `ariaLabel` names the panel. A panel with a visible heading can point at it
64
+ with `aria-labelledby` instead.
65
+ - Width, padding and internal rhythm are yours, through `className` — the
66
+ panel is a surface, not a layout.
67
+
68
+ <Canvas of={PopoverStories.FromATrigger} />
69
+
70
+ ### A panel with no trigger
71
+
72
+ A toolbar over a text selection hangs from the selection, and there is no
73
+ control that opened it. That is the one case with nothing for Base UI to watch,
74
+ so it is the one case the caller drives:
75
+
76
+ ```tsx
77
+ <Popover
78
+ anchor={selectionRect}
79
+ open={selectionRect !== null}
80
+ onOpenChange={(open) => !open && setSelectionRect(null)}
81
+ ariaLabel="Formatting"
82
+ >
83
+ ```
84
+
85
+ - **`anchor`** takes an element or a rect. A new rect moves the panel, so
86
+ re-measuring as the selection changes is all the tracking needed.
87
+ - **It does not take focus**, on purpose: the person is still in the text, and
88
+ a toolbar that moved the caret out of it would end the edit it exists to
89
+ serve. `finalFocus` is therefore only for a panel something inside it *did*
90
+ take focus into — a field the person clicked.
91
+ - **Render it always and toggle `open`.** Not `{rect && <Popover…>}`: mounting
92
+ the panel only while it is open takes Base UI's state machine with it, and
93
+ its exits go too — measured, Escape and re-opening both became intermittent.
94
+ - **Two traps that come with this shape**, both measured rather than reasoned
95
+ about, and both belonging to the caller:
96
+ - **Escape must not reopen what it just closed.** The keydown closes the
97
+ panel; if focus is on the element that reads the selection, the same key's
98
+ *keyup* re-reads a selection that is still perfectly alive. Ignore Escape
99
+ in that handler.
100
+ - **A press in the text is a press outside the panel.** It closes it, and the
101
+ same gesture's new selection opens it again. That is right, but it means
102
+ the panel is remade rather than moved.
103
+
104
+ **The gap, stated: an anchored panel cannot be reached by keyboard.** There is
105
+ no trigger to Tab from and it does not take focus, so everything in one must be
106
+ reachable another way — a shortcut, a menu. A panel that is the only route to
107
+ something belongs on a `trigger`.
108
+
109
+ <Canvas of={PopoverStories.FromASelection} />
110
+
111
+ ### When the side it asks for has no room
112
+
113
+ <Canvas of={PopoverStories.FlippedForRoom} />
114
+
115
+ ## Keys
116
+
117
+ | Input | What happens |
118
+ |---|---|
119
+ | click / Enter / Space on the trigger | opens, and focus moves to the first thing inside |
120
+ | Tab / Shift-Tab | walks the contents, in order |
121
+ | Escape | closes, and gives focus back to the trigger |
122
+ | a press outside | closes |
123
+ | a second press of the trigger | closes — it is the trigger's own toggle, not a press outside |
124
+
125
+ An anchored panel has the same two exits — Escape and a press outside — but no
126
+ trigger row: it is not opened from the keyboard and does not take focus.
127
+
128
+ ## Props
129
+
130
+ <Controls of={PopoverStories.FromATrigger} />