@estiva-app/ui 0.8.0 → 0.10.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 (153) 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 +882 -763
  58. package/dist/index.js.map +4 -4
  59. package/package.json +1 -1
  60. package/src/Avatar.mdx +17 -0
  61. package/src/Avatar.name.test.tsx +99 -0
  62. package/src/Avatar.tsx +25 -3
  63. package/src/AvatarGroup.tsx +4 -1
  64. package/src/Banner.mdx +10 -2
  65. package/src/Banner.stories.tsx +29 -0
  66. package/src/Banner.test.tsx +60 -0
  67. package/src/Banner.tsx +35 -3
  68. package/src/Breadcrumb.mdx +5 -2
  69. package/src/Breadcrumb.test.tsx +44 -0
  70. package/src/Breadcrumb.tsx +10 -2
  71. package/src/Button.compose.test.tsx +119 -0
  72. package/src/Button.mdx +7 -5
  73. package/src/Button.test.tsx +2 -1
  74. package/src/Button.tsx +21 -5
  75. package/src/Checkbox.tsx +3 -0
  76. package/src/Chip.tsx +3 -2
  77. package/src/ChipInput.tsx +4 -0
  78. package/src/ConfirmDialog.mdx +20 -1
  79. package/src/ConfirmDialog.test.tsx +109 -0
  80. package/src/ConfirmDialog.tsx +6 -0
  81. package/src/DialogShell.mdx +18 -4
  82. package/src/DialogShell.stories.tsx +56 -6
  83. package/src/DialogShell.test.tsx +184 -0
  84. package/src/DialogShell.tsx +108 -39
  85. package/src/EditableText.mdx +6 -1
  86. package/src/EditableText.test.tsx +156 -0
  87. package/src/EditableText.tsx +23 -5
  88. package/src/Field.mdx +23 -3
  89. package/src/Field.stories.tsx +35 -0
  90. package/src/Field.test.tsx +117 -27
  91. package/src/Field.tsx +86 -58
  92. package/src/IconButton.mdx +6 -2
  93. package/src/IconButton.test.tsx +5 -2
  94. package/src/IconButton.tsx +12 -6
  95. package/src/IdentityMenu.mdx +29 -6
  96. package/src/IdentityMenu.stories.tsx +41 -6
  97. package/src/IdentityMenu.tsx +74 -47
  98. package/src/Kbd.stories.tsx +3 -3
  99. package/src/Menu.mdx +88 -75
  100. package/src/Menu.stories.tsx +120 -52
  101. package/src/Menu.test.tsx +315 -0
  102. package/src/Menu.tsx +341 -251
  103. package/src/MenuItem.stories.tsx +135 -98
  104. package/src/NavItem.mdx +1 -1
  105. package/src/PersonTrigger.mdx +20 -14
  106. package/src/PersonTrigger.tsx +23 -1
  107. package/src/Popover.mdx +130 -0
  108. package/src/Popover.stories.tsx +222 -0
  109. package/src/Popover.test.tsx +188 -0
  110. package/src/Popover.tsx +153 -0
  111. package/src/PreviewCard.mdx +98 -0
  112. package/src/PreviewCard.stories.tsx +168 -0
  113. package/src/PreviewCard.test.tsx +83 -0
  114. package/src/PreviewCard.tsx +91 -0
  115. package/src/Rail.mdx +63 -0
  116. package/src/Rail.stories.tsx +81 -0
  117. package/src/Reaction.mdx +4 -2
  118. package/src/Reaction.tsx +4 -2
  119. package/src/ReactionPicker.mdx +85 -0
  120. package/src/ReactionPicker.stories.tsx +120 -0
  121. package/src/ReactionPicker.test.tsx +118 -0
  122. package/src/ReactionPicker.tsx +88 -0
  123. package/src/SearchInput.mdx +6 -2
  124. package/src/SearchInput.tsx +7 -1
  125. package/src/Select.mdx +16 -5
  126. package/src/Select.test.tsx +157 -0
  127. package/src/Select.tsx +89 -214
  128. package/src/Sidebar.mdx +6 -2
  129. package/src/Tabs.mdx +5 -0
  130. package/src/Tabs.test.tsx +15 -0
  131. package/src/Tabs.tsx +17 -2
  132. package/src/TextInput.mdx +8 -2
  133. package/src/TextInput.tsx +10 -7
  134. package/src/Textarea.mdx +6 -2
  135. package/src/Textarea.tsx +14 -9
  136. package/src/Toolbar.mdx +104 -0
  137. package/src/Toolbar.stories.tsx +142 -0
  138. package/src/Toolbar.test.tsx +198 -0
  139. package/src/Toolbar.tsx +158 -0
  140. package/src/Tooltip.mdx +43 -5
  141. package/src/Tooltip.stories.tsx +26 -0
  142. package/src/Tooltip.test.tsx +195 -0
  143. package/src/Tooltip.tsx +150 -48
  144. package/src/cn.ts +1 -1
  145. package/src/fit.test.ts +101 -0
  146. package/src/fit.ts +19 -63
  147. package/src/index.ts +6 -3
  148. package/stories/Choosing.mdx +8 -3
  149. package/stories/TokensPage.tsx +6 -2
  150. package/tailwind-preset.js +12 -0
  151. package/tokens.css +8 -0
  152. package/src/Menu.fit.test.ts +0 -90
  153. package/src/Select.fit.test.ts +0 -101
@@ -0,0 +1,168 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { MenuPanel } from './Menu'
4
+ import { PreviewCard } from './PreviewCard'
5
+ import { SkeletonBar } from './Skeleton'
6
+
7
+ /**
8
+ * More of a thing, on hover.
9
+ *
10
+ * A list shows one line per thing because a list has to. This is where the
11
+ * rest of that thing goes: a card that opens beside the row when the pointer
12
+ * rests on it, so the reader can decide whether to open the thing without
13
+ * opening it.
14
+ *
15
+ * The canvas draws the surface with `MenuPanel`, since a live card portals and
16
+ * places itself; **`OnARow`** and **`Scrolling`** are the live ones (D25).
17
+ */
18
+ const meta = {
19
+ title: 'Overlays/PreviewCard',
20
+ component: PreviewCard,
21
+ decorators: [(Story) => <div className="flex min-h-[260px] w-full items-center justify-center"><Story /></div>],
22
+ args: { content: null, children: null },
23
+ argTypes: { content: { control: false }, children: { control: false } },
24
+ } satisfies Meta<typeof PreviewCard>
25
+
26
+ export default meta
27
+ type Story = StoryObj<typeof meta>
28
+
29
+ /** A heading, a few named values and a paragraph — the shape most previews
30
+ * take. What goes in is entirely the caller's. */
31
+ function Detail() {
32
+ return (
33
+ <>
34
+ <span className="text-body-2-strong text-text-primary">Item one</span>
35
+ <div className="flex flex-col gap-1.5">
36
+ {[
37
+ ['Label', 'Value'],
38
+ ['Label', 'Another value'],
39
+ ['Label', 'A third value'],
40
+ ].map(([k, v], i) => (
41
+ <div key={i} className="flex items-baseline gap-2">
42
+ <span className="w-20 shrink-0 text-caption text-text-secondary">{k}</span>
43
+ <span className="min-w-0 flex-1 truncate text-[12px] leading-[1.45] text-text-primary">{v}</span>
44
+ </div>
45
+ ))}
46
+ </div>
47
+ <span className="text-[12px] leading-[1.45] text-text-secondary">
48
+ This is the card. It opened because the pointer came to rest on the row
49
+ and stayed there for 350ms, and it will close 200ms after the pointer
50
+ leaves — long enough to move into the card without losing it.
51
+ </span>
52
+ </>
53
+ )
54
+ }
55
+
56
+ function Row({ label, note }: { label: string; note?: string }) {
57
+ return (
58
+ <div className="flex w-full items-center gap-2 rounded-lg border border-border-default px-3 py-2 hover:bg-bg-hover">
59
+ <IconSquareRounded size={16} stroke={1.5} className="shrink-0 text-text-secondary" />
60
+ <span className="min-w-0 flex-1 truncate text-[14px] leading-[140%] text-text-primary">{label}</span>
61
+ {note && <span className="shrink-0 text-caption text-text-muted">{note}</span>}
62
+ </div>
63
+ )
64
+ }
65
+
66
+ /** The card at rest, so its anatomy can be read without hovering anything. */
67
+ export const Default: Story = {
68
+ parameters: { controls: { disable: true } },
69
+ render: () => (
70
+ <MenuPanel className="w-[360px] gap-3 p-3">
71
+ <Detail />
72
+ </MenuPanel>
73
+ ),
74
+ }
75
+
76
+ /**
77
+ * While the content is still arriving.
78
+ *
79
+ * A skeleton rather than a spinner, on purpose: a preview has a known shape,
80
+ * and drawing that shape says "this is what is coming" where a spinner only
81
+ * says "wait". It also keeps the card the size it is about to be, so nothing
82
+ * jumps when the content lands.
83
+ */
84
+ export const Loading: Story = {
85
+ parameters: { controls: { disable: true } },
86
+ render: () => (
87
+ /* The skeleton is the shape of `Default`, line for line: a heading, three
88
+ label/value rows, and two lines of detail. That is what makes it a
89
+ skeleton rather than a placeholder — the card is the size it is about to
90
+ be, so nothing moves when the content lands. */
91
+ <MenuPanel className="w-[360px] gap-3 p-3">
92
+ <SkeletonBar className="h-4 w-24" />
93
+ <div className="flex flex-col gap-1.5">
94
+ {['w-16', 'w-24', 'w-20'].map((w, i) => (
95
+ <div key={i} className="flex items-center gap-2">
96
+ <SkeletonBar className="w-20 shrink-0" />
97
+ <SkeletonBar className={w} />
98
+ </div>
99
+ ))}
100
+ </div>
101
+ <div className="flex flex-col gap-1.5">
102
+ <SkeletonBar className="w-full" />
103
+ <SkeletonBar className="w-2/3" />
104
+ </div>
105
+ </MenuPanel>
106
+ ),
107
+ }
108
+
109
+ /** Live. Rest the pointer on the row and read what the card says. */
110
+ export const OnARow: Story = {
111
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
112
+ // the row's note is muted caption text, 3.93:1 on --bg-base in signal (AA 4.5:1).
113
+ parameters: { controls: { disable: true }, a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
114
+ render: () => (
115
+ <PreviewCard content={<Detail />} wrapperClassName="w-[320px]">
116
+ <Row label="Item one" note="Label" />
117
+ </PreviewCard>
118
+ ),
119
+ }
120
+
121
+ /**
122
+ * A card taller than its cap scrolls, **and you can reach the scrollbar** —
123
+ * which is the whole difference between this and a tooltip. Rest the pointer
124
+ * on the row, move into the card, and scroll it.
125
+ */
126
+ export const Scrolling: Story = {
127
+ parameters: { controls: { disable: true } },
128
+ render: () => (
129
+ <PreviewCard
130
+ wrapperClassName="w-[320px]"
131
+ content={
132
+ <>
133
+ <span className="text-body-2-strong text-text-primary">Item with more than fits</span>
134
+ <span className="text-[12px] leading-[1.45] text-text-secondary">
135
+ There is more here than the card's 300px cap allows, so it scrolls.
136
+ You can reach that scrollbar because the pointer can enter this card.
137
+ A tooltip cannot be entered, so a tooltip that scrolled would be a
138
+ tooltip nobody could read to the end.
139
+ </span>
140
+ {Array.from({ length: 14 }, (_, i) => (
141
+ <div key={i} className="flex items-baseline gap-2">
142
+ <span className="w-20 shrink-0 text-caption text-text-secondary">Label</span>
143
+ <span className="min-w-0 flex-1 truncate text-[12px] leading-[1.45] text-text-primary">Value {i + 1}</span>
144
+ </div>
145
+ ))}
146
+ </>
147
+ }
148
+ >
149
+ <Row label="Item with more than fits" />
150
+ </PreviewCard>
151
+ ),
152
+ }
153
+
154
+ /**
155
+ * The side is a preference, not a promise: it flips when the side you asked
156
+ * for has no room. This one asks for the right in a row pinned to the right
157
+ * edge, so it opens on the left.
158
+ */
159
+ export const FlippedAtAnEdge: Story = {
160
+ parameters: { controls: { disable: true }, layout: 'fullscreen' },
161
+ render: () => (
162
+ <div className="flex h-[260px] w-full items-center justify-end p-4">
163
+ <PreviewCard content={<Detail />} wrapperClassName="w-[320px]">
164
+ <Row label="A row against the right edge" />
165
+ </PreviewCard>
166
+ </div>
167
+ ),
168
+ }
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the PreviewCard page claims, pinned.
4
+ *
5
+ * Split out of `Popover.test.tsx` on 2026-09-08: the two arrived in one
6
+ * commit and shared a file, and every other component in this package is
7
+ * tested beside itself. Placement is Floating UI's and is measured in a
8
+ * browser — jsdom lays nothing out — so what is here is the behaviour.
9
+ */
10
+ import { afterEach, describe, expect, it } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { PreviewCard } from './PreviewCard'
14
+
15
+ afterEach(cleanup)
16
+
17
+ describe('PreviewCard', () => {
18
+ it('renders its trigger, and nothing of its content, until it opens', () => {
19
+ render(
20
+ <PreviewCard content={<span>The rest of it</span>}>
21
+ <span>A row</span>
22
+ </PreviewCard>,
23
+ )
24
+ expect(screen.getByText('A row')).toBeTruthy()
25
+ // `content` is not rendered while closed, so a preview that fetches does
26
+ // not fetch once per row on screen.
27
+ expect(screen.queryByText('The rest of it')).toBeNull()
28
+ })
29
+
30
+ it('opens when the pointer rests on the trigger, and closes when it leaves', async () => {
31
+ const user = userEvent.setup()
32
+ render(
33
+ <PreviewCard content={<span>The rest of it</span>} delay={0} closeDelay={0}>
34
+ <span>A row</span>
35
+ </PreviewCard>,
36
+ )
37
+ await user.hover(screen.getByText('A row'))
38
+ expect(await screen.findByText('The rest of it')).toBeTruthy()
39
+ await user.unhover(screen.getByText('A row'))
40
+ expect(screen.queryByText('The rest of it')).toBeNull()
41
+ })
42
+
43
+ /*
44
+ * Not here: "the pointer can move into the card, and it scrolls" — the one
45
+ * claim that separates this from a tooltip.
46
+ *
47
+ * It cannot be tested in jsdom. No stylesheet is loaded there, so the
48
+ * `pointer-events: none` Base UI puts on the positioner is never reset by
49
+ * the popup's own class, and user-event refuses to move the pointer into
50
+ * something it reads as untouchable. Measured in Chrome instead
51
+ * (2026-09-08, the `Scrolling` story): the popup computes
52
+ * `pointer-events: auto`, the card stays open when the pointer moves into
53
+ * it, its 537px of content scrolls inside a 298px box — scrollTop 0 → 200
54
+ * on a wheel — and it closes when the pointer leaves for good.
55
+ */
56
+
57
+ /** The page says so in its Keys table, and a card the pointer can enter is
58
+ * a layer, so it owes the exit every layer owes. */
59
+ it('closes on Escape', async () => {
60
+ const user = userEvent.setup()
61
+ render(
62
+ <PreviewCard content={<span>The rest of it</span>} delay={0} closeDelay={0}>
63
+ <span>A row</span>
64
+ </PreviewCard>,
65
+ )
66
+ await user.hover(screen.getByText('A row'))
67
+ await screen.findByText('The rest of it')
68
+ await user.keyboard('{Escape}')
69
+ expect(screen.queryByText('The rest of it')).toBeNull()
70
+ })
71
+
72
+ it('the trigger is the wrapper, and the row inside it is untouched', () => {
73
+ const { container } = render(
74
+ <PreviewCard content={<span>x</span>} wrapperClassName="block w-full">
75
+ <div data-testid="row">A row</div>
76
+ </PreviewCard>,
77
+ )
78
+ const wrapper = container.firstElementChild as HTMLElement
79
+ expect(wrapper.tagName).toBe('SPAN')
80
+ expect(wrapper.className).toContain('block')
81
+ expect(wrapper.firstElementChild?.getAttribute('data-testid')).toBe('row')
82
+ })
83
+ })
@@ -0,0 +1,91 @@
1
+ import type { ReactNode } from 'react'
2
+ import { PreviewCard as BasePreviewCard } from '@base-ui/react/preview-card'
3
+ import { cn } from './cn'
4
+ import { MenuPanel } from './Menu'
5
+
6
+ /**
7
+ * More of a thing, on hover — a card beside a row that is only a snippet.
8
+ * New at stage 4 (2026-09-07).
9
+ *
10
+ * **It exists because Peek's Screener preview is this, hand-written**: its own
11
+ * `createPortal`, its own "prefer the right, flip left if it would run off
12
+ * screen" arithmetic against `window.innerWidth`, and its own clamp against
13
+ * the bottom edge (`ScreenerPreviewCard.tsx`). Floating UI does all three.
14
+ *
15
+ * **Not a Tooltip.** A tooltip is a word for a control, is
16
+ * `pointer-events: none`, and may hold nothing you can reach. A preview card
17
+ * holds *content* — faces, text, a link — and you can move the pointer into
18
+ * it, which is what a preview is for. If what you have is a label, use
19
+ * `WithTooltip`.
20
+ *
21
+ * The surface is the package's elevated panel, the same one a `Menu` draws, so
22
+ * there is one definition of that box. Width, height and internal rhythm are
23
+ * the caller's: a preview of a conversation is not the size of a preview of a
24
+ * person.
25
+ */
26
+ export interface PreviewCardProps {
27
+ /** What the card holds. Rendered only while it is open. */
28
+ content: ReactNode
29
+ /** The row, name or avatar the card previews. */
30
+ children: ReactNode
31
+ /** Which side of the trigger to prefer. It flips when that side has no room. Default right. */
32
+ side?: 'top' | 'bottom' | 'left' | 'right'
33
+ /** Before it opens, in ms. Long enough that crossing a list does not flash a card at every row. */
34
+ delay?: number
35
+ /** After the pointer leaves, in ms — the grace that lets you cross the gap into the card. */
36
+ closeDelay?: number
37
+ /** On the card's surface: its width, its padding, a max height. */
38
+ className?: string
39
+ /** Extra classes on the trigger wrapper — e.g. `block w-full` for a row. */
40
+ wrapperClassName?: string
41
+ }
42
+
43
+ /** The card's own numbers: 12px from the trigger, 8px clear of every screen
44
+ * edge. Peek's hand-written preview used 12 and 12; the 8 is the package's
45
+ * viewport margin, shared with every other floating surface. */
46
+ const GAP = 12
47
+ const VIEWPORT_PAD = 8
48
+ /** 350ms, which is Peek's number: `ScreenerItem`'s `HOVER_DELAY_MS`. Long
49
+ * enough not to flash a card at every row while a pointer crosses a list,
50
+ * short enough to feel like an answer. Taken rather than invented, because
51
+ * this component exists to replace that one. */
52
+ const OPEN_DELAY = 350
53
+ /** The diagonal from the row to the card has to survive. Peek's has no grace
54
+ * at all — it closes on `mouseleave` — because its card is
55
+ * `pointer-events: none` and there is nothing to cross to. Which also means
56
+ * **the `overflow-y-auto` on its 300px-capped card can never be scrolled**;
57
+ * here the card can be reached, so it can. */
58
+ const CLOSE_DELAY = 200
59
+
60
+ export function PreviewCard({ content, children, side = 'right', delay = OPEN_DELAY, closeDelay = CLOSE_DELAY, className, wrapperClassName }: PreviewCardProps) {
61
+ return (
62
+ <BasePreviewCard.Root>
63
+ {/* The part renders an `<a>` by default, which a row is not; `render`
64
+ makes it the wrapper this component has always been, and the trigger
65
+ inside keeps whatever element it already is. */}
66
+ <BasePreviewCard.Trigger
67
+ delay={delay}
68
+ closeDelay={closeDelay}
69
+ render={<span className={cn('inline-flex', wrapperClassName)} />}
70
+ >
71
+ {children}
72
+ </BasePreviewCard.Trigger>
73
+ <BasePreviewCard.Portal>
74
+ <BasePreviewCard.Positioner
75
+ side={side}
76
+ align="start"
77
+ sideOffset={GAP}
78
+ collisionPadding={VIEWPORT_PAD}
79
+ className="z-50 data-[anchor-hidden]:hidden"
80
+ >
81
+ <BasePreviewCard.Popup
82
+ className={cn('w-[360px] max-h-[min(300px,var(--available-height))] gap-3 overflow-y-auto p-3 outline-none [&>*]:shrink-0', className)}
83
+ render={<MenuPanel />}
84
+ >
85
+ {content}
86
+ </BasePreviewCard.Popup>
87
+ </BasePreviewCard.Positioner>
88
+ </BasePreviewCard.Portal>
89
+ </BasePreviewCard.Root>
90
+ )
91
+ }
package/src/Rail.mdx ADDED
@@ -0,0 +1,63 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as RailStories from './Rail.stories'
3
+
4
+ <Meta of={RailStories} />
5
+
6
+ # Rail
7
+
8
+ The 64px strip of icon tiles down the left of an app. It is the container and
9
+ nothing else: **RailItem** is the tile, and where a tile goes is the app's.
10
+
11
+ <Canvas of={RailStories.Default} />
12
+
13
+ ## When
14
+
15
+ - The top level of an app's navigation, when there are few enough places to
16
+ fit down one strip: five or six, each with a shape you can learn.
17
+ - Beside a **Sidebar**, when an app has both — the rail picks the area and the
18
+ sidebar picks the thing inside it.
19
+
20
+ <Canvas of={RailStories.BesideAColumn} />
21
+
22
+ ## When not
23
+
24
+ - A list of named places → **Sidebar**. A rail's label is 9px; a name that
25
+ needs more than a word needs a row.
26
+ - More entries than fit down a screen → fewer entries. The rail does not
27
+ scroll, on purpose.
28
+ - A strip of *actions* rather than places → a toolbar of **IconButton**s.
29
+
30
+ <Canvas of={RailStories.ManyItems} />
31
+
32
+ ## How
33
+
34
+ ```tsx
35
+ import { Rail, RailItem } from '@estiva-app/ui'
36
+
37
+ <Rail aria-label="Areas">
38
+ <RailItem href="/inbox" label="Inbox" icon={<IconInbox size={16} stroke={1.5} />} active />
39
+ <RailItem href="/people" label="People" icon={<IconUsers size={16} stroke={1.5} />} />
40
+ </Rail>
41
+ ```
42
+
43
+ - It renders a `<nav>`, so **it is a landmark** and owes a name. `aria-label`
44
+ defaults to "Navigation", which is right when there is one; name it when an
45
+ app has a rail *and* a sidebar, or a screen reader offers two navigations
46
+ called the same thing.
47
+ - **No border, no surface.** It stands on the app background and the column
48
+ beside it draws the edge, so a frame keeps one hairline rather than two.
49
+ - **Collapsing is the caller's.** The rail does not know about a burger; it is
50
+ given less room, or none.
51
+ - Desktop only. A narrow screen gets the app's own answer, not a squeezed rail.
52
+
53
+ <Canvas of={RailStories.OneItem} />
54
+
55
+ ## Keys
56
+
57
+ The rail has no keyboard of its own: its tiles are links, and Tab walks them in
58
+ order like any other list of links. `RailItem` marks the current one
59
+ `aria-current="page"`.
60
+
61
+ ## Props
62
+
63
+ <Controls of={RailStories.Default} />
@@ -0,0 +1,81 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { Rail } from './Rail'
4
+ import { RailItem } from './RailItem'
5
+
6
+ const placeholder = <IconSquareRounded size={16} stroke={1.5} />
7
+
8
+ /**
9
+ * The 64px strip the icon tiles stand in. It is the container and nothing
10
+ * else: **RailItem** is the tile, and what a rail leads to is the app's.
11
+ *
12
+ * It has no border and no surface of its own — it stands on the app
13
+ * background, so every canvas here is full height against one.
14
+ */
15
+ const meta = {
16
+ title: 'Frame/Rail',
17
+ component: Rail,
18
+ parameters: { layout: 'fullscreen' },
19
+ args: { 'aria-label': 'Navigation', children: null },
20
+ argTypes: { children: { control: false } },
21
+ decorators: [(Story) => <div className="flex h-screen bg-bg-base">{Story()}</div>],
22
+ } satisfies Meta<typeof Rail>
23
+
24
+ export default meta
25
+ type Story = StoryObj<typeof meta>
26
+
27
+ /** Three tiles, the first current. */
28
+ export const Default: Story = {
29
+ render: (args) => (
30
+ <Rail {...args}>
31
+ <RailItem href="#" label="Item" icon={placeholder} active />
32
+ <RailItem href="#" label="Item" icon={placeholder} />
33
+ <RailItem href="#" label="Item" icon={placeholder} />
34
+ </Rail>
35
+ ),
36
+ }
37
+
38
+ /** One tile, so the strip's own width is visible against nothing else. */
39
+ export const OneItem: Story = {
40
+ parameters: { controls: { disable: true } },
41
+ render: () => (
42
+ <Rail>
43
+ <RailItem href="#" label="Item" icon={placeholder} active />
44
+ </Rail>
45
+ ),
46
+ }
47
+
48
+ /**
49
+ * Beside the thing it navigates. The rail draws no edge of its own — the
50
+ * column beside it draws its own, which is what keeps a frame to one hairline.
51
+ */
52
+ export const BesideAColumn: Story = {
53
+ parameters: { controls: { disable: true } },
54
+ render: () => (
55
+ <>
56
+ <Rail>
57
+ <RailItem href="#" label="Item" icon={placeholder} active />
58
+ <RailItem href="#" label="Item" icon={placeholder} />
59
+ </Rail>
60
+ <div className="flex-1 border-l border-border-subtle bg-bg-surface p-6">
61
+ <span className="text-body-2 text-text-secondary">The column the rail stands beside.</span>
62
+ </div>
63
+ </>
64
+ ),
65
+ }
66
+
67
+ /**
68
+ * More tiles than the screen is tall. The rail does not scroll on its own —
69
+ * a rail with more entries than fit is a rail with too many entries, and the
70
+ * answer is fewer, not a scrollbar in a 64px strip.
71
+ */
72
+ export const ManyItems: Story = {
73
+ parameters: { controls: { disable: true } },
74
+ render: () => (
75
+ <Rail>
76
+ {Array.from({ length: 12 }, (_, i) => (
77
+ <RailItem key={i} href="#" label="Item" icon={placeholder} active={i === 0} />
78
+ ))}
79
+ </Rail>
80
+ ),
81
+ }
package/src/Reaction.mdx CHANGED
@@ -24,8 +24,10 @@ An emoji, how many people chose it, and whether you are one of them. A
24
24
 
25
25
  - Standing information nobody can toggle — a status, a category → **Chip**
26
26
  (a Chip is not a control).
27
- - Adding a reaction that is not there yet: that is a picker, and a strip of
28
- icon actions is a toolbar of **IconButton**s.
27
+ - **Offering the reactions to choose from** **ReactionPicker**. This is the
28
+ answer; that is the question, and it is a `Toolbar` so the whole row is one
29
+ Tab stop.
30
+ - Any other strip of icon actions → **Toolbar**.
29
31
  - An action with a verb for a label → **Button**.
30
32
 
31
33
  ## How
package/src/Reaction.tsx CHANGED
@@ -76,8 +76,10 @@ export function Reaction({ emoji, count, pressed = false, className, type, ...pr
76
76
  <span aria-hidden="true" className="shrink-0 text-[16px] leading-none">
77
77
  {emoji}
78
78
  </span>
79
- {/* `text-chip` is a plain class, never merged the same guard Chip uses,
80
- because tailwind-merge drops a token size that follows a text colour. */}
79
+ {/* `text-chip` stands alone here, so there is nothing to merge it with.
80
+ The old note said it must NEVER be merged; that stopped being true
81
+ when `cn()` was taught the ramp — `chip` is in it, and `cn.test.ts`
82
+ pins that. */}
81
83
  <span className="text-chip signal:font-mono signal:text-[10px] signal:font-semibold signal:tabular-nums">
82
84
  {count}
83
85
  </span>
@@ -0,0 +1,85 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ReactionPickerStories from './ReactionPicker.stories'
3
+
4
+ <Meta of={ReactionPickerStories} />
5
+
6
+ # ReactionPicker
7
+
8
+ The reactions on offer, to choose one from — icon buttons holding emoji, on a
9
+ **Toolbar**. **`Reaction` is the answer; this is the question.**
10
+
11
+ <Canvas of={ReactionPickerStories.Default} />
12
+
13
+ ## When
14
+
15
+ - **Offering a reaction** — from a control on a card, or from a `Toolbar` of
16
+ actions. This is the case it exists for, and the only one.
17
+ - **However many are on offer.** The row is whatever `options` holds; nothing
18
+ here caps or wraps it.
19
+
20
+ <Canvas of={ReactionPickerStories.FromATrigger} />
21
+
22
+ ## When not
23
+
24
+ - **Showing the reactions that have been made** — the pills with their counts,
25
+ in a row on the card → **Reaction**. That is the answer; this only asks.
26
+ Which reactions are yours is *its* state, not this one's.
27
+ - Any other strip of controls → **Toolbar**, which this is built on.
28
+ - A list of verbs → **Menu**.
29
+
30
+ ## How
31
+
32
+ ```tsx
33
+ import { ReactionPicker } from '@estiva-app/ui'
34
+
35
+ <Popover side="top" align="right" trigger={reactButton} ariaLabel="Reactions" className="w-auto p-1">
36
+ <ReactionPicker
37
+ surface={false}
38
+ options={[
39
+ { emoji: '👍', label: 'Makes sense' },
40
+ { emoji: '🎉', label: 'Congrats' },
41
+ ]}
42
+ onSelect={react}
43
+ />
44
+ </Popover>
45
+ ```
46
+
47
+ - **The vocabulary is yours.** Which emoji an app offers, and what each one
48
+ means, is product knowledge — so it arrives as `options`, and every option
49
+ owes a `label`.
50
+ - **`label` is not decoration.** The emoji is `aria-hidden`, for the same
51
+ reason it is on `Reaction`: a glyph read aloud is noise, and its spoken name
52
+ differs per screen reader. The label is what a screen reader says and what
53
+ the tooltip shows, so write the *meaning* — "Makes sense", not "thumbs up".
54
+ - **`onSelect` reports the emoji**, which is what an app stores. What happens
55
+ to it — a new reaction, or one of yours removed — is the caller's.
56
+ - **It opens above the control that was pressed**, not below: the thing being
57
+ reacted to is underneath. `Popover`'s `side="top"`.
58
+ - **It draws the box**, from `Toolbar` — so it floats correctly on its own.
59
+ Inside a `Popover`, which draws that box already, pass `surface={false}`.
60
+ - **It is one Tab stop**, because it is a `Toolbar`. The hand-written version
61
+ it replaces was one stop per emoji, inside a hover panel — five things to
62
+ Tab past to reach anything after the card.
63
+
64
+ ### What it produces
65
+
66
+ Picking puts a **Reaction** in the row on the card — the pill with the count
67
+ and, when it is yours, the accent fill. The picker asks; the pill answers.
68
+
69
+ <Canvas of={ReactionPickerStories.AndWhatItProduces} />
70
+
71
+ ## Keys
72
+
73
+ | Input | What happens |
74
+ |---|---|
75
+ | Tab | onto the row — once, however many reactions are offered |
76
+ | ← / → | move between them, and wrap at the ends |
77
+ | Enter / Space | choose the focused one |
78
+ | Tab again | leaves the row entirely |
79
+
80
+ The keys are `Toolbar`'s, and its gap is this one's: **Home and End do
81
+ nothing.**
82
+
83
+ ## Props
84
+
85
+ <Controls of={ReactionPickerStories.Default} />