@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.
- package/dist/Avatar.d.ts +13 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/Banner.d.ts +16 -1
- package/dist/Banner.d.ts.map +1 -1
- package/dist/Breadcrumb.d.ts +9 -0
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/Button.d.ts +12 -2
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +2 -0
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/Chip.d.ts +3 -2
- package/dist/Chip.d.ts.map +1 -1
- package/dist/ChipInput.d.ts +3 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/ConfirmDialog.d.ts +5 -0
- package/dist/ConfirmDialog.d.ts.map +1 -1
- package/dist/DialogShell.d.ts +27 -3
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/EditableText.d.ts +6 -0
- package/dist/EditableText.d.ts.map +1 -1
- package/dist/Field.d.ts +42 -25
- package/dist/Field.d.ts.map +1 -1
- package/dist/IconButton.d.ts +3 -2
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts +25 -13
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Menu.d.ts +65 -82
- package/dist/Menu.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/Popover.d.ts +86 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/PreviewCard.d.ts +39 -0
- package/dist/PreviewCard.d.ts.map +1 -0
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/ReactionPicker.d.ts +55 -0
- package/dist/ReactionPicker.d.ts.map +1 -0
- package/dist/SearchInput.d.ts +5 -0
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/Select.d.ts +26 -16
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tabs.d.ts +11 -1
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/TextInput.d.ts +7 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts +8 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +95 -0
- package/dist/Toolbar.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +36 -10
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/fit.d.ts +7 -72
- package/dist/fit.d.ts.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +882 -763
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Avatar.mdx +17 -0
- package/src/Avatar.name.test.tsx +99 -0
- package/src/Avatar.tsx +25 -3
- package/src/AvatarGroup.tsx +4 -1
- package/src/Banner.mdx +10 -2
- package/src/Banner.stories.tsx +29 -0
- package/src/Banner.test.tsx +60 -0
- package/src/Banner.tsx +35 -3
- package/src/Breadcrumb.mdx +5 -2
- package/src/Breadcrumb.test.tsx +44 -0
- package/src/Breadcrumb.tsx +10 -2
- package/src/Button.compose.test.tsx +119 -0
- package/src/Button.mdx +7 -5
- package/src/Button.test.tsx +2 -1
- package/src/Button.tsx +21 -5
- package/src/Checkbox.tsx +3 -0
- package/src/Chip.tsx +3 -2
- package/src/ChipInput.tsx +4 -0
- package/src/ConfirmDialog.mdx +20 -1
- package/src/ConfirmDialog.test.tsx +109 -0
- package/src/ConfirmDialog.tsx +6 -0
- package/src/DialogShell.mdx +18 -4
- package/src/DialogShell.stories.tsx +56 -6
- package/src/DialogShell.test.tsx +184 -0
- package/src/DialogShell.tsx +108 -39
- package/src/EditableText.mdx +6 -1
- package/src/EditableText.test.tsx +156 -0
- package/src/EditableText.tsx +23 -5
- package/src/Field.mdx +23 -3
- package/src/Field.stories.tsx +35 -0
- package/src/Field.test.tsx +117 -27
- package/src/Field.tsx +86 -58
- package/src/IconButton.mdx +6 -2
- package/src/IconButton.test.tsx +5 -2
- package/src/IconButton.tsx +12 -6
- package/src/IdentityMenu.mdx +29 -6
- package/src/IdentityMenu.stories.tsx +41 -6
- package/src/IdentityMenu.tsx +74 -47
- package/src/Kbd.stories.tsx +3 -3
- package/src/Menu.mdx +88 -75
- package/src/Menu.stories.tsx +120 -52
- package/src/Menu.test.tsx +315 -0
- package/src/Menu.tsx +341 -251
- package/src/MenuItem.stories.tsx +135 -98
- package/src/NavItem.mdx +1 -1
- package/src/PersonTrigger.mdx +20 -14
- package/src/PersonTrigger.tsx +23 -1
- package/src/Popover.mdx +130 -0
- package/src/Popover.stories.tsx +222 -0
- package/src/Popover.test.tsx +188 -0
- package/src/Popover.tsx +153 -0
- package/src/PreviewCard.mdx +98 -0
- package/src/PreviewCard.stories.tsx +168 -0
- package/src/PreviewCard.test.tsx +83 -0
- package/src/PreviewCard.tsx +91 -0
- package/src/Rail.mdx +63 -0
- package/src/Rail.stories.tsx +81 -0
- package/src/Reaction.mdx +4 -2
- package/src/Reaction.tsx +4 -2
- package/src/ReactionPicker.mdx +85 -0
- package/src/ReactionPicker.stories.tsx +120 -0
- package/src/ReactionPicker.test.tsx +118 -0
- package/src/ReactionPicker.tsx +88 -0
- package/src/SearchInput.mdx +6 -2
- package/src/SearchInput.tsx +7 -1
- package/src/Select.mdx +16 -5
- package/src/Select.test.tsx +157 -0
- package/src/Select.tsx +89 -214
- package/src/Sidebar.mdx +6 -2
- package/src/Tabs.mdx +5 -0
- package/src/Tabs.test.tsx +15 -0
- package/src/Tabs.tsx +17 -2
- package/src/TextInput.mdx +8 -2
- package/src/TextInput.tsx +10 -7
- package/src/Textarea.mdx +6 -2
- package/src/Textarea.tsx +14 -9
- package/src/Toolbar.mdx +104 -0
- package/src/Toolbar.stories.tsx +142 -0
- package/src/Toolbar.test.tsx +198 -0
- package/src/Toolbar.tsx +158 -0
- package/src/Tooltip.mdx +43 -5
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +195 -0
- package/src/Tooltip.tsx +150 -48
- package/src/cn.ts +1 -1
- package/src/fit.test.ts +101 -0
- package/src/fit.ts +19 -63
- package/src/index.ts +6 -3
- package/stories/Choosing.mdx +8 -3
- package/stories/TokensPage.tsx +6 -2
- package/tailwind-preset.js +12 -0
- package/tokens.css +8 -0
- package/src/Menu.fit.test.ts +0 -90
- 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
|
-
-
|
|
28
|
-
|
|
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`
|
|
80
|
-
|
|
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} />
|