@lovett/ui 0.0.10 → 0.1.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/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +121 -0
- package/src/tooltip.tsx +297 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DropdownMenu — 137 call sites, rebuilt on lib/anchor + lib/layer-stack.
|
|
3
|
+
*
|
|
4
|
+
* These pin the public contract the rebuild had to keep byte-for-byte:
|
|
5
|
+
* roles, data-slots, trigger ARIA, item click-closes, checkbox toggling,
|
|
6
|
+
* Escape-refocuses-trigger, outside-click, controlled mode, and the
|
|
7
|
+
* `align` / `side` / `sideOffset` / `alignOffset` props. Plus the one
|
|
8
|
+
* behaviour that CHANGED on purpose: a menu inside a Modal now closes
|
|
9
|
+
* alone on Escape.
|
|
10
|
+
*/
|
|
11
|
+
import { createRef, useState } from 'react'
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
import { render, screen } from '@testing-library/react'
|
|
14
|
+
import userEvent from '@testing-library/user-event'
|
|
15
|
+
import Modal from '../modal'
|
|
16
|
+
import {
|
|
17
|
+
DropdownMenu,
|
|
18
|
+
DropdownMenuCheckboxItem,
|
|
19
|
+
DropdownMenuContent,
|
|
20
|
+
DropdownMenuGroup,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuLabel,
|
|
23
|
+
DropdownMenuSeparator,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
type DropdownMenuContentProps,
|
|
26
|
+
} from '../dropdown-menu'
|
|
27
|
+
import { DropdownButton } from '../dropdown-button'
|
|
28
|
+
import { MenuButton } from '../menu-button'
|
|
29
|
+
import { mockGeometry, setViewport } from './helpers/geometry'
|
|
30
|
+
|
|
31
|
+
function Menu({
|
|
32
|
+
onSelect = () => {},
|
|
33
|
+
contentProps,
|
|
34
|
+
triggerProps,
|
|
35
|
+
}: {
|
|
36
|
+
onSelect?: () => void
|
|
37
|
+
contentProps?: Partial<React.ComponentProps<typeof DropdownMenuContent>>
|
|
38
|
+
triggerProps?: React.ComponentProps<typeof DropdownMenuTrigger>
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<DropdownMenu>
|
|
42
|
+
<DropdownMenuTrigger {...triggerProps}>Actions</DropdownMenuTrigger>
|
|
43
|
+
<DropdownMenuContent {...contentProps}>
|
|
44
|
+
<DropdownMenuGroup>
|
|
45
|
+
<DropdownMenuLabel>Section</DropdownMenuLabel>
|
|
46
|
+
</DropdownMenuGroup>
|
|
47
|
+
<DropdownMenuItem onClick={onSelect}>Rename</DropdownMenuItem>
|
|
48
|
+
<DropdownMenuSeparator />
|
|
49
|
+
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
|
50
|
+
</DropdownMenuContent>
|
|
51
|
+
</DropdownMenu>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const trigger = () => screen.getByRole('button', { name: 'Actions' })
|
|
56
|
+
|
|
57
|
+
describe('DropdownMenu', () => {
|
|
58
|
+
describe('trigger', () => {
|
|
59
|
+
it('is a menu-haspopup button with the secondary Button chrome by default', () => {
|
|
60
|
+
render(<Menu />)
|
|
61
|
+
const t = trigger()
|
|
62
|
+
expect(t).toHaveAttribute('type', 'button')
|
|
63
|
+
expect(t).toHaveAttribute('aria-haspopup', 'menu')
|
|
64
|
+
expect(t).toHaveAttribute('aria-expanded', 'false')
|
|
65
|
+
expect(t).not.toHaveAttribute('aria-controls')
|
|
66
|
+
expect(t).toHaveClass('btn', 'btn-secondary')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('drops the chrome when unstyled', () => {
|
|
70
|
+
render(<Menu triggerProps={{ unstyled: true, className: 'mine' }} />)
|
|
71
|
+
expect(trigger()).not.toHaveClass('btn')
|
|
72
|
+
expect(trigger()).toHaveClass('mine')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('forwards a ref to the <button>', () => {
|
|
76
|
+
const ref = createRef<HTMLButtonElement>()
|
|
77
|
+
render(<Menu triggerProps={{ ref }} />)
|
|
78
|
+
expect(ref.current).toBe(trigger())
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps the menu closed when onClick prevents default', async () => {
|
|
82
|
+
render(<Menu triggerProps={{ onClick: (e) => e.preventDefault() }} />)
|
|
83
|
+
await userEvent.click(trigger())
|
|
84
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('content', () => {
|
|
89
|
+
it('renders nothing while closed', () => {
|
|
90
|
+
render(<Menu />)
|
|
91
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
92
|
+
expect(document.querySelector('[data-slot="dropdown-menu-content"]')).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('opens on click as role=menu, portaled to body, wired by aria-controls', async () => {
|
|
96
|
+
const { container } = render(<Menu />)
|
|
97
|
+
await userEvent.click(trigger())
|
|
98
|
+
const menu = screen.getByRole('menu')
|
|
99
|
+
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
|
|
100
|
+
expect(trigger()).toHaveAttribute('aria-controls', menu.id)
|
|
101
|
+
expect(menu).toHaveAttribute('data-slot', 'dropdown-menu-content')
|
|
102
|
+
expect(menu).toHaveAttribute('data-state', 'open')
|
|
103
|
+
expect(menu.parentElement).toBe(document.body)
|
|
104
|
+
expect(container.contains(menu)).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('exposes the compound parts with their roles and data-slots', async () => {
|
|
108
|
+
render(<Menu />)
|
|
109
|
+
await userEvent.click(trigger())
|
|
110
|
+
expect(screen.getByRole('group')).toHaveAttribute('data-slot', 'dropdown-menu-group')
|
|
111
|
+
expect(screen.getByText('Section')).toHaveAttribute('data-slot', 'dropdown-menu-label')
|
|
112
|
+
expect(screen.getByRole('separator')).toHaveAttribute('data-slot', 'dropdown-menu-separator')
|
|
113
|
+
const items = screen.getAllByRole('menuitem')
|
|
114
|
+
expect(items).toHaveLength(2)
|
|
115
|
+
expect(items[0]).toHaveAttribute('data-slot', 'dropdown-menu-item')
|
|
116
|
+
expect(items[0]).toHaveAttribute('data-variant', 'default')
|
|
117
|
+
expect(items[1]).toHaveAttribute('data-variant', 'destructive')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// REPLACES 'does not move focus into the menu on open (the trigger keeps
|
|
121
|
+
// it)'. That pinned the defect: focus stayed outside an open menu, so
|
|
122
|
+
// ArrowDown did nothing and Tab walked off into the page behind it.
|
|
123
|
+
it('moves focus onto the first item on open', async () => {
|
|
124
|
+
render(<Menu />)
|
|
125
|
+
await userEvent.click(trigger())
|
|
126
|
+
expect(document.activeElement).toBe(
|
|
127
|
+
screen.getByRole('menuitem', { name: 'Rename' }),
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('spreads className and style, caller style last', async () => {
|
|
132
|
+
render(
|
|
133
|
+
<Menu contentProps={{ className: 'w-56', style: { width: '17rem', padding: 6 } }} />,
|
|
134
|
+
)
|
|
135
|
+
await userEvent.click(trigger())
|
|
136
|
+
const menu = screen.getByRole('menu')
|
|
137
|
+
expect(menu).toHaveClass('w-56')
|
|
138
|
+
expect(menu.style.width).toBe('17rem')
|
|
139
|
+
expect(menu.style.padding).toBe('6px')
|
|
140
|
+
expect(menu.style.background).toContain('--popover')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('forwards a ref to the menu element', async () => {
|
|
144
|
+
const ref = createRef<HTMLDivElement>()
|
|
145
|
+
render(<Menu contentProps={{ ref }} />)
|
|
146
|
+
await userEvent.click(trigger())
|
|
147
|
+
expect(ref.current).toBe(screen.getByRole('menu'))
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('items', () => {
|
|
152
|
+
it('calls onClick and closes the menu', async () => {
|
|
153
|
+
const onSelect = vi.fn()
|
|
154
|
+
render(<Menu onSelect={onSelect} />)
|
|
155
|
+
await userEvent.click(trigger())
|
|
156
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
|
157
|
+
expect(onSelect).toHaveBeenCalledTimes(1)
|
|
158
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('stays open when the item onClick prevents default', async () => {
|
|
162
|
+
render(
|
|
163
|
+
<DropdownMenu>
|
|
164
|
+
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
|
165
|
+
<DropdownMenuContent>
|
|
166
|
+
<DropdownMenuItem onClick={(e) => e.preventDefault()}>Keep</DropdownMenuItem>
|
|
167
|
+
</DropdownMenuContent>
|
|
168
|
+
</DropdownMenu>,
|
|
169
|
+
)
|
|
170
|
+
await userEvent.click(trigger())
|
|
171
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Keep' }))
|
|
172
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('inset pads the left edge', async () => {
|
|
176
|
+
render(
|
|
177
|
+
<DropdownMenu defaultOpen>
|
|
178
|
+
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
|
179
|
+
<DropdownMenuContent>
|
|
180
|
+
<DropdownMenuItem inset>Inset</DropdownMenuItem>
|
|
181
|
+
<DropdownMenuLabel inset>Inset label</DropdownMenuLabel>
|
|
182
|
+
</DropdownMenuContent>
|
|
183
|
+
</DropdownMenu>,
|
|
184
|
+
)
|
|
185
|
+
expect(screen.getByRole('menuitem', { name: 'Inset' })).toHaveClass('pl-9')
|
|
186
|
+
expect(screen.getByText('Inset label')).toHaveClass('pl-9')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('checkbox item toggles through onCheckedChange and shows its indicator', async () => {
|
|
190
|
+
function Checkable() {
|
|
191
|
+
const [checked, setChecked] = useState(false)
|
|
192
|
+
return (
|
|
193
|
+
<DropdownMenu defaultOpen>
|
|
194
|
+
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
|
195
|
+
<DropdownMenuContent>
|
|
196
|
+
<DropdownMenuCheckboxItem checked={checked} onCheckedChange={setChecked}>
|
|
197
|
+
Show summary
|
|
198
|
+
</DropdownMenuCheckboxItem>
|
|
199
|
+
</DropdownMenuContent>
|
|
200
|
+
</DropdownMenu>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
render(<Checkable />)
|
|
204
|
+
const item = screen.getByRole('menuitemcheckbox', { name: 'Show summary' })
|
|
205
|
+
expect(item).toHaveAttribute('aria-checked', 'false')
|
|
206
|
+
expect(item).toHaveAttribute('data-slot', 'dropdown-menu-checkbox-item')
|
|
207
|
+
expect(
|
|
208
|
+
item.querySelector('[data-slot="dropdown-menu-checkbox-item-indicator"]'),
|
|
209
|
+
).toBeNull()
|
|
210
|
+
|
|
211
|
+
await userEvent.click(item)
|
|
212
|
+
expect(item).toHaveAttribute('aria-checked', 'true')
|
|
213
|
+
expect(
|
|
214
|
+
item.querySelector('[data-slot="dropdown-menu-checkbox-item-indicator"]'),
|
|
215
|
+
).not.toBeNull()
|
|
216
|
+
// Toggling does NOT close the menu.
|
|
217
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('disabled checkbox item does not toggle', async () => {
|
|
221
|
+
const onCheckedChange = vi.fn()
|
|
222
|
+
render(
|
|
223
|
+
<DropdownMenu defaultOpen>
|
|
224
|
+
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
|
225
|
+
<DropdownMenuContent>
|
|
226
|
+
<DropdownMenuCheckboxItem disabled onCheckedChange={onCheckedChange}>
|
|
227
|
+
Locked
|
|
228
|
+
</DropdownMenuCheckboxItem>
|
|
229
|
+
</DropdownMenuContent>
|
|
230
|
+
</DropdownMenu>,
|
|
231
|
+
)
|
|
232
|
+
const item = screen.getByRole('menuitemcheckbox', { name: 'Locked' })
|
|
233
|
+
expect(item).toBeDisabled()
|
|
234
|
+
await userEvent.click(item)
|
|
235
|
+
expect(onCheckedChange).not.toHaveBeenCalled()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('dismissal', () => {
|
|
240
|
+
it('closes on Escape and returns focus to the trigger', async () => {
|
|
241
|
+
render(<Menu />)
|
|
242
|
+
await userEvent.click(trigger())
|
|
243
|
+
screen.getByRole('menuitem', { name: 'Rename' }).focus()
|
|
244
|
+
await userEvent.keyboard('{Escape}')
|
|
245
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
246
|
+
expect(document.activeElement).toBe(trigger())
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('closes on an outside click', async () => {
|
|
250
|
+
render(
|
|
251
|
+
<>
|
|
252
|
+
<Menu />
|
|
253
|
+
<button type="button">Elsewhere</button>
|
|
254
|
+
</>,
|
|
255
|
+
)
|
|
256
|
+
await userEvent.click(trigger())
|
|
257
|
+
await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
|
|
258
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('stays open on a click inside the menu that is not an item', async () => {
|
|
262
|
+
render(<Menu />)
|
|
263
|
+
await userEvent.click(trigger())
|
|
264
|
+
await userEvent.click(screen.getByText('Section'))
|
|
265
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('inside a Modal, Escape closes only the menu; the next Escape closes the modal', async () => {
|
|
269
|
+
// The regression this rebuild fixes: two document listeners used to
|
|
270
|
+
// both answer one Escape, closing menu AND modal together.
|
|
271
|
+
const onClose = vi.fn()
|
|
272
|
+
render(
|
|
273
|
+
<Modal isOpen onClose={onClose} title="Settings">
|
|
274
|
+
<Menu />
|
|
275
|
+
</Modal>,
|
|
276
|
+
)
|
|
277
|
+
await userEvent.click(trigger())
|
|
278
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
279
|
+
|
|
280
|
+
await userEvent.keyboard('{Escape}')
|
|
281
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
282
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
283
|
+
|
|
284
|
+
await userEvent.keyboard('{Escape}')
|
|
285
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('does not listen for Escape while closed', async () => {
|
|
289
|
+
const onOpenChange = vi.fn()
|
|
290
|
+
render(
|
|
291
|
+
<DropdownMenu onOpenChange={onOpenChange}>
|
|
292
|
+
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
|
293
|
+
<DropdownMenuContent>
|
|
294
|
+
<DropdownMenuItem>Rename</DropdownMenuItem>
|
|
295
|
+
</DropdownMenuContent>
|
|
296
|
+
</DropdownMenu>,
|
|
297
|
+
)
|
|
298
|
+
await userEvent.keyboard('{Escape}')
|
|
299
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe('controlled', () => {
|
|
304
|
+
it('reports transitions through onOpenChange and follows `open`', async () => {
|
|
305
|
+
function Controlled() {
|
|
306
|
+
const [open, setOpen] = useState(false)
|
|
307
|
+
return (
|
|
308
|
+
<>
|
|
309
|
+
<span data-testid="state">{open ? 'open' : 'closed'}</span>
|
|
310
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
311
|
+
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
|
312
|
+
<DropdownMenuContent>
|
|
313
|
+
<DropdownMenuItem>Rename</DropdownMenuItem>
|
|
314
|
+
</DropdownMenuContent>
|
|
315
|
+
</DropdownMenu>
|
|
316
|
+
</>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
render(<Controlled />)
|
|
320
|
+
await userEvent.click(trigger())
|
|
321
|
+
expect(screen.getByTestId('state')).toHaveTextContent('open')
|
|
322
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
|
323
|
+
expect(screen.getByTestId('state')).toHaveTextContent('closed')
|
|
324
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
describe('positioning (mocked geometry)', () => {
|
|
329
|
+
let restore: () => void
|
|
330
|
+
beforeEach(() => {
|
|
331
|
+
restore = mockGeometry()
|
|
332
|
+
setViewport(1000, 800)
|
|
333
|
+
})
|
|
334
|
+
afterEach(() => restore())
|
|
335
|
+
|
|
336
|
+
function Positioned(props: Partial<DropdownMenuContentProps> & { rect?: string }) {
|
|
337
|
+
const { rect = '400,300,80,32', ...contentProps } = props
|
|
338
|
+
return (
|
|
339
|
+
<DropdownMenu defaultOpen>
|
|
340
|
+
<DropdownMenuTrigger data-rect={rect}>Actions</DropdownMenuTrigger>
|
|
341
|
+
<DropdownMenuContent data-size="200,120" {...contentProps}>
|
|
342
|
+
<DropdownMenuItem>Rename</DropdownMenuItem>
|
|
343
|
+
</DropdownMenuContent>
|
|
344
|
+
</DropdownMenu>
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
it('defaults to bottom-start with a 6px gap and animates in once positioned', () => {
|
|
349
|
+
render(<Positioned />)
|
|
350
|
+
const menu = screen.getByRole('menu')
|
|
351
|
+
expect(menu.style.position).toBe('fixed')
|
|
352
|
+
expect(menu.style.left).toBe('400px')
|
|
353
|
+
expect(menu.style.top).toBe('338px')
|
|
354
|
+
expect(menu).toHaveAttribute('data-positioned', 'true')
|
|
355
|
+
expect(menu).toHaveAttribute('data-side', 'bottom')
|
|
356
|
+
expect(menu).toHaveClass('ds-enter-pop')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('align="end" right-aligns; alignOffset shifts inward', () => {
|
|
360
|
+
render(<Positioned align="end" alignOffset={10} />)
|
|
361
|
+
expect(screen.getByRole('menu').style.left).toBe(`${480 - 200 - 10}px`)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('align="center" centres on the trigger', () => {
|
|
365
|
+
render(<Positioned align="center" />)
|
|
366
|
+
expect(screen.getByRole('menu').style.left).toBe('340px')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('side="top" with sideOffset opens above', () => {
|
|
370
|
+
render(<Positioned side="top" sideOffset={10} />)
|
|
371
|
+
const menu = screen.getByRole('menu')
|
|
372
|
+
expect(menu).toHaveAttribute('data-side', 'top')
|
|
373
|
+
expect(menu.style.top).toBe(`${300 - 120 - 10}px`)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('flips to top when the trigger sits at the bottom of the viewport', () => {
|
|
377
|
+
render(<Positioned rect="400,700,80,32" />)
|
|
378
|
+
const menu = screen.getByRole('menu')
|
|
379
|
+
expect(menu).toHaveAttribute('data-side', 'top')
|
|
380
|
+
expect(menu.style.top).toBe(`${700 - 120 - 6}px`)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('clamps into the viewport gutter at the right edge', () => {
|
|
384
|
+
render(<Positioned rect="950,300,40,32" />)
|
|
385
|
+
expect(screen.getByRole('menu').style.left).toBe(`${1000 - 200 - 8}px`)
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// The APG menu-button keyboard model, added 2026-09-06. Before it, the
|
|
390
|
+
// primitive had NO keyboard handling: `ArrowDown|ArrowUp|onKeyDown` did
|
|
391
|
+
// not appear in the file, opening left focus on the trigger, and Tab
|
|
392
|
+
// moved to the next page control with the menu still open.
|
|
393
|
+
describe('keyboard model (APG menu)', () => {
|
|
394
|
+
const items = () => screen.getAllByRole('menuitem')
|
|
395
|
+
|
|
396
|
+
it('ArrowDown on the trigger opens onto the first item', async () => {
|
|
397
|
+
render(<Menu />)
|
|
398
|
+
trigger().focus()
|
|
399
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
400
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
401
|
+
expect(document.activeElement).toBe(items()[0])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('ArrowUp on the trigger opens onto the LAST item', async () => {
|
|
405
|
+
render(<Menu />)
|
|
406
|
+
trigger().focus()
|
|
407
|
+
await userEvent.keyboard('{ArrowUp}')
|
|
408
|
+
expect(document.activeElement).toBe(items()[1])
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('Up / Down move between items and wrap at both ends', async () => {
|
|
412
|
+
render(<Menu />)
|
|
413
|
+
await userEvent.click(trigger())
|
|
414
|
+
expect(document.activeElement).toBe(items()[0])
|
|
415
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
416
|
+
expect(document.activeElement).toBe(items()[1])
|
|
417
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
418
|
+
expect(document.activeElement).toBe(items()[0]) // wrapped forward
|
|
419
|
+
await userEvent.keyboard('{ArrowUp}')
|
|
420
|
+
expect(document.activeElement).toBe(items()[1]) // wrapped backward
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('Home and End jump to the first and last item', async () => {
|
|
424
|
+
render(<Menu />)
|
|
425
|
+
await userEvent.click(trigger())
|
|
426
|
+
await userEvent.keyboard('{End}')
|
|
427
|
+
expect(document.activeElement).toBe(items()[1])
|
|
428
|
+
await userEvent.keyboard('{Home}')
|
|
429
|
+
expect(document.activeElement).toBe(items()[0])
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('is ONE tab stop — roving tabindex, never a positive value', async () => {
|
|
433
|
+
render(<Menu />)
|
|
434
|
+
await userEvent.click(trigger())
|
|
435
|
+
expect(items().map((i) => i.tabIndex)).toEqual([0, -1])
|
|
436
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
437
|
+
expect(items().map((i) => i.tabIndex)).toEqual([-1, 0])
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('Tab from an item closes the menu and restores the trigger', async () => {
|
|
441
|
+
render(
|
|
442
|
+
<>
|
|
443
|
+
<Menu />
|
|
444
|
+
<button type="button">Next control</button>
|
|
445
|
+
</>,
|
|
446
|
+
)
|
|
447
|
+
await userEvent.click(trigger())
|
|
448
|
+
await userEvent.keyboard('{Tab}')
|
|
449
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
450
|
+
expect(document.activeElement).toBe(trigger())
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('activating an item restores focus to the trigger', async () => {
|
|
454
|
+
render(<Menu />)
|
|
455
|
+
await userEvent.click(trigger())
|
|
456
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
|
457
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
458
|
+
expect(document.activeElement).toBe(trigger())
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('an outside click that lands on another control leaves it focused', async () => {
|
|
462
|
+
render(
|
|
463
|
+
<>
|
|
464
|
+
<Menu />
|
|
465
|
+
<button type="button">Elsewhere</button>
|
|
466
|
+
</>,
|
|
467
|
+
)
|
|
468
|
+
await userEvent.click(trigger())
|
|
469
|
+
await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
|
|
470
|
+
expect(document.activeElement).toBe(
|
|
471
|
+
screen.getByRole('button', { name: 'Elsewhere' }),
|
|
472
|
+
)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
// The cascade the menu defect caused: Modal captures
|
|
476
|
+
// `document.activeElement` as its restore target, so a dialog opened
|
|
477
|
+
// from a menu item used to capture <body> and restore nothing.
|
|
478
|
+
it('a dialog opened from a menu item restores focus to the trigger', async () => {
|
|
479
|
+
function MenuWithDialog() {
|
|
480
|
+
const [open, setOpen] = useState(false)
|
|
481
|
+
return (
|
|
482
|
+
<>
|
|
483
|
+
<DropdownMenu>
|
|
484
|
+
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
|
485
|
+
<DropdownMenuContent>
|
|
486
|
+
<DropdownMenuItem onClick={() => setOpen(true)}>
|
|
487
|
+
Edit column
|
|
488
|
+
</DropdownMenuItem>
|
|
489
|
+
</DropdownMenuContent>
|
|
490
|
+
</DropdownMenu>
|
|
491
|
+
<Modal isOpen={open} onClose={() => setOpen(false)} title="Edit column">
|
|
492
|
+
<button type="button">Save</button>
|
|
493
|
+
</Modal>
|
|
494
|
+
</>
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
render(<MenuWithDialog />)
|
|
498
|
+
await userEvent.click(trigger())
|
|
499
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Edit column' }))
|
|
500
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
501
|
+
await userEvent.keyboard('{Escape}')
|
|
502
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
503
|
+
expect(document.activeElement).toBe(trigger())
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// Menus here legitimately host a small form (the keywords range-filter
|
|
507
|
+
// chip). Those must not be hijacked: no items to rove, arrows belong to
|
|
508
|
+
// the field, and Tab moves between the fields instead of closing.
|
|
509
|
+
describe('a form-shaped menu (no items)', () => {
|
|
510
|
+
const FormMenu = () => (
|
|
511
|
+
<DropdownMenu>
|
|
512
|
+
<DropdownMenuTrigger>Range</DropdownMenuTrigger>
|
|
513
|
+
<DropdownMenuContent>
|
|
514
|
+
<label htmlFor="lo">Min</label>
|
|
515
|
+
<input id="lo" type="number" />
|
|
516
|
+
<label htmlFor="hi">Max</label>
|
|
517
|
+
<input id="hi" type="number" />
|
|
518
|
+
</DropdownMenuContent>
|
|
519
|
+
</DropdownMenu>
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
it('focuses its first field on open', async () => {
|
|
523
|
+
render(<FormMenu />)
|
|
524
|
+
await userEvent.click(screen.getByRole('button', { name: 'Range' }))
|
|
525
|
+
expect(document.activeElement).toBe(screen.getByLabelText('Min'))
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('leaves Tab alone so the fields stay reachable', async () => {
|
|
529
|
+
render(<FormMenu />)
|
|
530
|
+
await userEvent.click(screen.getByRole('button', { name: 'Range' }))
|
|
531
|
+
await userEvent.tab()
|
|
532
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
533
|
+
expect(document.activeElement).toBe(screen.getByLabelText('Max'))
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
describe('composed primitives still work', () => {
|
|
539
|
+
it('DropdownButton opens its items and selects', async () => {
|
|
540
|
+
const onSelect = vi.fn()
|
|
541
|
+
render(
|
|
542
|
+
<DropdownButton
|
|
543
|
+
label="New plan"
|
|
544
|
+
items={[
|
|
545
|
+
{ id: 'set', label: 'Set', description: 'A set', onSelect },
|
|
546
|
+
{ id: 'group', label: 'Group', onSelect: () => {} },
|
|
547
|
+
]}
|
|
548
|
+
/>,
|
|
549
|
+
)
|
|
550
|
+
await userEvent.click(screen.getByRole('button', { name: 'New plan' }))
|
|
551
|
+
await userEvent.click(screen.getByRole('menuitem', { name: /Set/ }))
|
|
552
|
+
expect(onSelect).toHaveBeenCalledTimes(1)
|
|
553
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('MenuButton renders separators and shortcuts', async () => {
|
|
557
|
+
render(
|
|
558
|
+
<MenuButton
|
|
559
|
+
label="File"
|
|
560
|
+
items={[
|
|
561
|
+
{ id: 'new', label: 'New', shortcut: 'N', onSelect: () => {} },
|
|
562
|
+
{ type: 'separator' },
|
|
563
|
+
{ id: 'quit', label: 'Quit', variant: 'destructive', onSelect: () => {} },
|
|
564
|
+
]}
|
|
565
|
+
/>,
|
|
566
|
+
)
|
|
567
|
+
await userEvent.click(screen.getByRole('button', { name: 'File' }))
|
|
568
|
+
expect(screen.getAllByRole('menuitem')).toHaveLength(2)
|
|
569
|
+
expect(screen.getByRole('separator')).toBeInTheDocument()
|
|
570
|
+
})
|
|
571
|
+
})
|
|
572
|
+
})
|