@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,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Select — APG select-only combobox on lib/anchor + lib/layer-stack.
|
|
3
|
+
*
|
|
4
|
+
* Contract under test: trigger ARIA (role=combobox, haspopup, expanded,
|
|
5
|
+
* controls, activedescendant), listbox / option roles and aria-selected,
|
|
6
|
+
* the full keyboard model (open keys, arrows skipping disabled options,
|
|
7
|
+
* Home / End, Enter / Space commit, type-ahead incl. repeated-letter
|
|
8
|
+
* cycling and the 500 ms reset, Escape, Tab-closes-without-committing),
|
|
9
|
+
* focus never leaving the trigger, outside-click and layer-aware dismissal
|
|
10
|
+
* (inside a Modal, inside a Popover), controlled and uncontrolled value and
|
|
11
|
+
* open state, `.input-shell` sizing, and positioning with mocked geometry.
|
|
12
|
+
*/
|
|
13
|
+
import { createRef, useState } from 'react'
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
15
|
+
import { act, render, screen } from '@testing-library/react'
|
|
16
|
+
import userEvent from '@testing-library/user-event'
|
|
17
|
+
import Modal from '../modal'
|
|
18
|
+
import { Popover } from '../popover'
|
|
19
|
+
import { Select, type SelectOption, type SelectProps } from '../select'
|
|
20
|
+
import { mockGeometry, setViewport } from './helpers/geometry'
|
|
21
|
+
|
|
22
|
+
const OPTIONS: SelectOption[] = [
|
|
23
|
+
{ value: 'todo', label: 'To do' },
|
|
24
|
+
{ value: 'doing', label: 'In progress', description: 'Actively being worked' },
|
|
25
|
+
{ value: 'blocked', label: 'Blocked', disabled: true },
|
|
26
|
+
{ value: 'done', label: 'Done' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
function Basic(props: Partial<SelectProps>) {
|
|
30
|
+
return <Select aria-label="Status" options={OPTIONS} {...props} />
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const trigger = () => screen.getByRole('combobox', { name: 'Status' })
|
|
34
|
+
const listbox = () => screen.getByRole('listbox')
|
|
35
|
+
const option = (name: string | RegExp) => screen.getByRole('option', { name })
|
|
36
|
+
|
|
37
|
+
describe('Select', () => {
|
|
38
|
+
describe('rendering', () => {
|
|
39
|
+
it('renders a closed combobox trigger wearing the input-shell chrome', () => {
|
|
40
|
+
render(<Basic />)
|
|
41
|
+
const t = trigger()
|
|
42
|
+
expect(t.tagName).toBe('BUTTON')
|
|
43
|
+
expect(t).toHaveAttribute('type', 'button')
|
|
44
|
+
expect(t).toHaveAttribute('aria-haspopup', 'listbox')
|
|
45
|
+
expect(t).toHaveAttribute('aria-expanded', 'false')
|
|
46
|
+
expect(t).not.toHaveAttribute('aria-controls')
|
|
47
|
+
expect(t).not.toHaveAttribute('aria-activedescendant')
|
|
48
|
+
expect(t).toHaveAttribute('data-state', 'closed')
|
|
49
|
+
expect(t).toHaveClass('input-shell')
|
|
50
|
+
expect(t).toHaveAttribute('data-size', 'md')
|
|
51
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('shows the placeholder and data-placeholder while nothing is selected', () => {
|
|
55
|
+
render(<Basic placeholder="Pick a status" />)
|
|
56
|
+
expect(trigger()).toHaveTextContent('Pick a status')
|
|
57
|
+
expect(trigger()).toHaveAttribute('data-placeholder')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('shows the selected label (and icon) in the trigger', () => {
|
|
61
|
+
render(
|
|
62
|
+
<Basic
|
|
63
|
+
value="doing"
|
|
64
|
+
options={[{ value: 'doing', label: 'In progress', icon: <i data-testid="ico" /> }]}
|
|
65
|
+
/>,
|
|
66
|
+
)
|
|
67
|
+
expect(trigger()).toHaveTextContent('In progress')
|
|
68
|
+
expect(trigger()).not.toHaveAttribute('data-placeholder')
|
|
69
|
+
expect(trigger().querySelector('[data-testid="ico"]')).not.toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('size="sm" adds the input-shell size modifier; error adds is-error + aria-invalid; disabled adds is-disabled', () => {
|
|
73
|
+
render(<Basic size="sm" error disabled />)
|
|
74
|
+
const t = trigger()
|
|
75
|
+
expect(t).toHaveClass('size-sm', 'is-error', 'is-disabled')
|
|
76
|
+
expect(t).toHaveAttribute('data-size', 'sm')
|
|
77
|
+
expect(t).toBeDisabled()
|
|
78
|
+
expect(t).toHaveAttribute('aria-invalid', 'true')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('carries no aria-invalid without error, and a caller-supplied aria-invalid wins', () => {
|
|
82
|
+
const { rerender } = render(<Basic />)
|
|
83
|
+
expect(trigger()).not.toHaveAttribute('aria-invalid')
|
|
84
|
+
rerender(<Basic error aria-invalid="grammar" />)
|
|
85
|
+
expect(trigger()).toHaveAttribute('aria-invalid', 'grammar')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('spreads className, caller style last, keeps the align-items override', () => {
|
|
89
|
+
render(<Basic className="w-64" style={{ width: '20rem' }} />)
|
|
90
|
+
const t = trigger()
|
|
91
|
+
expect(t).toHaveClass('w-64')
|
|
92
|
+
expect(t.style.width).toBe('20rem')
|
|
93
|
+
expect(t.style.alignItems).toBe('center')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('forwards a ref to the trigger button', () => {
|
|
97
|
+
const ref = createRef<HTMLButtonElement>()
|
|
98
|
+
render(<Select ref={ref} aria-label="Status" options={OPTIONS} />)
|
|
99
|
+
expect(ref.current).toBe(trigger())
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('open / listbox', () => {
|
|
104
|
+
it('opens on click: listbox portaled to body, wired by aria-controls, focus stays on the trigger', async () => {
|
|
105
|
+
const { container } = render(<Basic value="doing" />)
|
|
106
|
+
await userEvent.click(trigger())
|
|
107
|
+
const list = listbox()
|
|
108
|
+
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
|
|
109
|
+
expect(trigger()).toHaveAttribute('aria-controls', list.id)
|
|
110
|
+
expect(trigger()).toHaveAttribute('data-state', 'open')
|
|
111
|
+
expect(list).toHaveAttribute('data-slot', 'select-listbox')
|
|
112
|
+
expect(list.parentElement).toBe(document.body)
|
|
113
|
+
expect(container.contains(list)).toBe(false)
|
|
114
|
+
expect(document.activeElement).toBe(trigger())
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('renders every option with role=option, aria-selected on the value, disabled on disabled ones', async () => {
|
|
118
|
+
render(<Basic value="doing" />)
|
|
119
|
+
await userEvent.click(trigger())
|
|
120
|
+
const options = screen.getAllByRole('option')
|
|
121
|
+
expect(options).toHaveLength(4)
|
|
122
|
+
expect(option(/^In progress/)).toHaveAttribute('aria-selected', 'true')
|
|
123
|
+
expect(option('To do')).toHaveAttribute('aria-selected', 'false')
|
|
124
|
+
expect(option('Blocked')).toHaveAttribute('aria-disabled', 'true')
|
|
125
|
+
expect(option('Blocked')).not.toBeDisabled()
|
|
126
|
+
expect(option('Done')).not.toHaveAttribute('aria-disabled')
|
|
127
|
+
expect(option(/^In progress/)).toHaveTextContent('Actively being worked')
|
|
128
|
+
for (const o of options) expect(o).toHaveAttribute('tabindex', '-1')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('names the listbox from the trigger aria-label', async () => {
|
|
132
|
+
render(<Basic />)
|
|
133
|
+
await userEvent.click(trigger())
|
|
134
|
+
expect(screen.getByRole('listbox', { name: 'Status' })).toBeInTheDocument()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('highlights the selected option on open (aria-activedescendant), else the first enabled', async () => {
|
|
138
|
+
const { unmount } = render(<Basic value="done" />)
|
|
139
|
+
await userEvent.click(trigger())
|
|
140
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Done').id)
|
|
141
|
+
expect(option('Done')).toHaveAttribute('data-highlighted')
|
|
142
|
+
unmount()
|
|
143
|
+
|
|
144
|
+
render(<Basic options={[{ value: 'x', label: 'X', disabled: true }, ...OPTIONS]} />)
|
|
145
|
+
await userEvent.click(trigger())
|
|
146
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('To do').id)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('toggles closed on a second trigger click', async () => {
|
|
150
|
+
render(<Basic />)
|
|
151
|
+
await userEvent.click(trigger())
|
|
152
|
+
await userEvent.click(trigger())
|
|
153
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
154
|
+
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('renders an empty message when there are no options', async () => {
|
|
158
|
+
render(<Basic options={[]} />)
|
|
159
|
+
await userEvent.click(trigger())
|
|
160
|
+
expect(listbox()).toHaveTextContent('No options')
|
|
161
|
+
expect(screen.queryByRole('option')).toBeNull()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('does nothing while disabled', async () => {
|
|
165
|
+
render(<Basic disabled />)
|
|
166
|
+
await userEvent.click(trigger())
|
|
167
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('mouse', () => {
|
|
172
|
+
it('hovering an option moves the highlight', async () => {
|
|
173
|
+
render(<Basic value="todo" />)
|
|
174
|
+
await userEvent.click(trigger())
|
|
175
|
+
await userEvent.hover(option('Done'))
|
|
176
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Done').id)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('clicking an option commits it, closes, and returns focus to the trigger', async () => {
|
|
180
|
+
const onValueChange = vi.fn()
|
|
181
|
+
render(<Basic onValueChange={onValueChange} />)
|
|
182
|
+
await userEvent.click(trigger())
|
|
183
|
+
await userEvent.click(option('Done'))
|
|
184
|
+
expect(onValueChange).toHaveBeenCalledWith('done')
|
|
185
|
+
expect(onValueChange).toHaveBeenCalledTimes(1)
|
|
186
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
187
|
+
expect(trigger()).toHaveTextContent('Done')
|
|
188
|
+
expect(document.activeElement).toBe(trigger())
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('a mousedown inside the listbox does not pull focus off the trigger', async () => {
|
|
192
|
+
render(<Basic />)
|
|
193
|
+
await userEvent.click(trigger())
|
|
194
|
+
await userEvent.pointer({ keys: '[MouseLeft>]', target: option('Done') })
|
|
195
|
+
expect(document.activeElement).toBe(trigger())
|
|
196
|
+
expect(listbox()).toBeInTheDocument()
|
|
197
|
+
await userEvent.pointer({ keys: '[/MouseLeft]', target: option('Done') })
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('a disabled option cannot be clicked', async () => {
|
|
201
|
+
const onValueChange = vi.fn()
|
|
202
|
+
render(<Basic onValueChange={onValueChange} />)
|
|
203
|
+
await userEvent.click(trigger())
|
|
204
|
+
await userEvent.click(option('Blocked'))
|
|
205
|
+
expect(onValueChange).not.toHaveBeenCalled()
|
|
206
|
+
expect(listbox()).toBeInTheDocument()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('keyboard', () => {
|
|
211
|
+
it.each(['{ArrowDown}', '{ArrowUp}', '{Enter}', ' '])(
|
|
212
|
+
'%s opens the list with the selected option highlighted',
|
|
213
|
+
async (key) => {
|
|
214
|
+
render(<Basic value="doing" />)
|
|
215
|
+
trigger().focus()
|
|
216
|
+
await userEvent.keyboard(key)
|
|
217
|
+
expect(listbox()).toBeInTheDocument()
|
|
218
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option(/^In progress/).id)
|
|
219
|
+
expect(document.activeElement).toBe(trigger())
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
it('Home / End open on the first / last enabled option', async () => {
|
|
224
|
+
const { unmount } = render(<Basic value="doing" />)
|
|
225
|
+
trigger().focus()
|
|
226
|
+
await userEvent.keyboard('{Home}')
|
|
227
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('To do').id)
|
|
228
|
+
unmount()
|
|
229
|
+
|
|
230
|
+
render(<Basic value="todo" options={[...OPTIONS, { value: 'z', label: 'Zed', disabled: true }]} />)
|
|
231
|
+
trigger().focus()
|
|
232
|
+
await userEvent.keyboard('{End}')
|
|
233
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Done').id)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('ArrowDown / ArrowUp move the highlight, skip disabled options and stop at the ends', async () => {
|
|
237
|
+
render(<Basic value="todo" />)
|
|
238
|
+
trigger().focus()
|
|
239
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
240
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
241
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option(/^In progress/).id)
|
|
242
|
+
// Blocked is disabled — skipped.
|
|
243
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
244
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Done').id)
|
|
245
|
+
// No wrap.
|
|
246
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
247
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Done').id)
|
|
248
|
+
await userEvent.keyboard('{ArrowUp}')
|
|
249
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option(/^In progress/).id)
|
|
250
|
+
await userEvent.keyboard('{Home}')
|
|
251
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('To do').id)
|
|
252
|
+
await userEvent.keyboard('{ArrowUp}')
|
|
253
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('To do').id)
|
|
254
|
+
await userEvent.keyboard('{End}')
|
|
255
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Done').id)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('Enter commits the highlighted option and closes; Space does too', async () => {
|
|
259
|
+
const onValueChange = vi.fn()
|
|
260
|
+
const { unmount } = render(<Basic value="todo" onValueChange={onValueChange} />)
|
|
261
|
+
trigger().focus()
|
|
262
|
+
await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}')
|
|
263
|
+
expect(onValueChange).toHaveBeenCalledWith('doing')
|
|
264
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
265
|
+
expect(document.activeElement).toBe(trigger())
|
|
266
|
+
unmount()
|
|
267
|
+
|
|
268
|
+
const onSpace = vi.fn()
|
|
269
|
+
render(<Basic value="todo" onValueChange={onSpace} />)
|
|
270
|
+
trigger().focus()
|
|
271
|
+
await userEvent.keyboard('{ArrowDown}{ArrowDown} ')
|
|
272
|
+
expect(onSpace).toHaveBeenCalledWith('doing')
|
|
273
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('does not fire onValueChange when the same value is re-committed', async () => {
|
|
277
|
+
const onValueChange = vi.fn()
|
|
278
|
+
render(<Basic value="todo" onValueChange={onValueChange} />)
|
|
279
|
+
trigger().focus()
|
|
280
|
+
await userEvent.keyboard('{Enter}{Enter}')
|
|
281
|
+
expect(onValueChange).not.toHaveBeenCalled()
|
|
282
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('Escape closes without changing the value; focus stays on the trigger', async () => {
|
|
286
|
+
const onValueChange = vi.fn()
|
|
287
|
+
render(<Basic value="todo" onValueChange={onValueChange} />)
|
|
288
|
+
trigger().focus()
|
|
289
|
+
await userEvent.keyboard('{ArrowDown}{ArrowDown}{Escape}')
|
|
290
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
291
|
+
expect(onValueChange).not.toHaveBeenCalled()
|
|
292
|
+
expect(document.activeElement).toBe(trigger())
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('Tab closes WITHOUT committing and lets focus move on', async () => {
|
|
296
|
+
const onValueChange = vi.fn()
|
|
297
|
+
render(
|
|
298
|
+
<>
|
|
299
|
+
<Basic value="todo" onValueChange={onValueChange} />
|
|
300
|
+
<button type="button">Next</button>
|
|
301
|
+
</>,
|
|
302
|
+
)
|
|
303
|
+
trigger().focus()
|
|
304
|
+
await userEvent.keyboard('{ArrowDown}{ArrowDown}')
|
|
305
|
+
await userEvent.tab()
|
|
306
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
307
|
+
expect(onValueChange).not.toHaveBeenCalled()
|
|
308
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Next' }))
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('does not listen for Escape while closed', async () => {
|
|
312
|
+
const onOpenChange = vi.fn()
|
|
313
|
+
render(<Basic onOpenChange={onOpenChange} />)
|
|
314
|
+
trigger().focus()
|
|
315
|
+
await userEvent.keyboard('{Escape}')
|
|
316
|
+
expect(onOpenChange).not.toHaveBeenCalled()
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('type-ahead', () => {
|
|
320
|
+
const LETTERS: SelectOption[] = [
|
|
321
|
+
{ value: 'alpha', label: 'Alpha' },
|
|
322
|
+
{ value: 'amber', label: 'Amber' },
|
|
323
|
+
{ value: 'apple', label: 'Apple' },
|
|
324
|
+
{ value: 'beta', label: 'Beta', disabled: true },
|
|
325
|
+
{ value: 'bravo', label: 'Bravo' },
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
it('a printable key opens the list on the first label starting with it', async () => {
|
|
329
|
+
render(<Basic options={LETTERS} />)
|
|
330
|
+
trigger().focus()
|
|
331
|
+
await userEvent.keyboard('b')
|
|
332
|
+
expect(listbox()).toBeInTheDocument()
|
|
333
|
+
// Beta is disabled — skipped.
|
|
334
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Bravo').id)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('a repeated letter cycles through its matches', async () => {
|
|
338
|
+
render(<Basic options={LETTERS} />)
|
|
339
|
+
trigger().focus()
|
|
340
|
+
await userEvent.keyboard('a')
|
|
341
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Alpha').id)
|
|
342
|
+
await userEvent.keyboard('a')
|
|
343
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Amber').id)
|
|
344
|
+
await userEvent.keyboard('a')
|
|
345
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Apple').id)
|
|
346
|
+
await userEvent.keyboard('a')
|
|
347
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Alpha').id)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('a typed prefix narrows to the matching label', async () => {
|
|
351
|
+
render(<Basic options={LETTERS} />)
|
|
352
|
+
trigger().focus()
|
|
353
|
+
await userEvent.keyboard('ap')
|
|
354
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Apple').id)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
describe('with fake timers', () => {
|
|
358
|
+
beforeEach(() => {
|
|
359
|
+
vi.useFakeTimers()
|
|
360
|
+
// RTL's asyncWrapper drains with a real setTimeout(0) unless it finds
|
|
361
|
+
// a jest-style advanceTimersByTime; without this shim every
|
|
362
|
+
// user-event call hangs under vitest fake timers.
|
|
363
|
+
vi.stubGlobal('jest', {
|
|
364
|
+
advanceTimersByTime: (ms: number) => vi.advanceTimersByTime(ms),
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
afterEach(() => {
|
|
368
|
+
vi.useRealTimers()
|
|
369
|
+
vi.unstubAllGlobals()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('the buffer resets after a pause', async () => {
|
|
373
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
|
374
|
+
render(<Basic options={LETTERS} />)
|
|
375
|
+
trigger().focus()
|
|
376
|
+
await user.keyboard('a')
|
|
377
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Alpha').id)
|
|
378
|
+
act(() => {
|
|
379
|
+
vi.advanceTimersByTime(600)
|
|
380
|
+
})
|
|
381
|
+
// A fresh 'b' search, not an 'ab' prefix (which matches nothing).
|
|
382
|
+
await user.keyboard('b')
|
|
383
|
+
expect(trigger()).toHaveAttribute('aria-activedescendant', option('Bravo').id)
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
describe('controlled / uncontrolled', () => {
|
|
390
|
+
it('uncontrolled: defaultValue seeds the value and picks update the trigger', async () => {
|
|
391
|
+
render(<Basic defaultValue="todo" />)
|
|
392
|
+
expect(trigger()).toHaveTextContent('To do')
|
|
393
|
+
await userEvent.click(trigger())
|
|
394
|
+
await userEvent.click(option('Done'))
|
|
395
|
+
expect(trigger()).toHaveTextContent('Done')
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('controlled: follows `value`; a parent that ignores onValueChange keeps the old value', async () => {
|
|
399
|
+
const onValueChange = vi.fn()
|
|
400
|
+
render(<Basic value="todo" onValueChange={onValueChange} />)
|
|
401
|
+
await userEvent.click(trigger())
|
|
402
|
+
await userEvent.click(option('Done'))
|
|
403
|
+
expect(onValueChange).toHaveBeenCalledWith('done')
|
|
404
|
+
expect(trigger()).toHaveTextContent('To do')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('controlled: reflects an external value change', async () => {
|
|
408
|
+
function Controlled() {
|
|
409
|
+
const [value, setValue] = useState<string | null>('todo')
|
|
410
|
+
return (
|
|
411
|
+
<>
|
|
412
|
+
<Basic value={value} onValueChange={setValue} />
|
|
413
|
+
<button type="button" onClick={() => setValue('done')}>
|
|
414
|
+
set done
|
|
415
|
+
</button>
|
|
416
|
+
</>
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
render(<Controlled />)
|
|
420
|
+
await userEvent.click(screen.getByRole('button', { name: 'set done' }))
|
|
421
|
+
expect(trigger()).toHaveTextContent('Done')
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('controlled open: reports through onOpenChange and follows `open`', async () => {
|
|
425
|
+
function ControlledOpen() {
|
|
426
|
+
const [open, setOpen] = useState(false)
|
|
427
|
+
return (
|
|
428
|
+
<>
|
|
429
|
+
<span data-testid="state">{open ? 'open' : 'closed'}</span>
|
|
430
|
+
<Basic open={open} onOpenChange={setOpen} />
|
|
431
|
+
</>
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
render(<ControlledOpen />)
|
|
435
|
+
await userEvent.click(trigger())
|
|
436
|
+
expect(screen.getByTestId('state')).toHaveTextContent('open')
|
|
437
|
+
expect(listbox()).toBeInTheDocument()
|
|
438
|
+
await userEvent.keyboard('{Escape}')
|
|
439
|
+
expect(screen.getByTestId('state')).toHaveTextContent('closed')
|
|
440
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('controlled open: stays closed when the parent ignores onOpenChange', async () => {
|
|
444
|
+
const onOpenChange = vi.fn()
|
|
445
|
+
render(<Basic open={false} onOpenChange={onOpenChange} />)
|
|
446
|
+
await userEvent.click(trigger())
|
|
447
|
+
expect(onOpenChange).toHaveBeenCalledWith(true)
|
|
448
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('defaultOpen renders the list open', () => {
|
|
452
|
+
render(<Basic defaultOpen />)
|
|
453
|
+
expect(listbox()).toBeInTheDocument()
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
describe('dismissal + layers', () => {
|
|
458
|
+
it('closes on an outside click', async () => {
|
|
459
|
+
render(
|
|
460
|
+
<>
|
|
461
|
+
<Basic />
|
|
462
|
+
<button type="button">Elsewhere</button>
|
|
463
|
+
</>,
|
|
464
|
+
)
|
|
465
|
+
await userEvent.click(trigger())
|
|
466
|
+
await userEvent.click(screen.getByRole('button', { name: 'Elsewhere' }))
|
|
467
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('inside a Modal, Escape closes only the Select; the next Escape closes the modal', async () => {
|
|
471
|
+
const onClose = vi.fn()
|
|
472
|
+
render(
|
|
473
|
+
<Modal isOpen onClose={onClose} title="Settings">
|
|
474
|
+
<Basic />
|
|
475
|
+
</Modal>,
|
|
476
|
+
)
|
|
477
|
+
await userEvent.click(trigger())
|
|
478
|
+
expect(listbox()).toBeInTheDocument()
|
|
479
|
+
await userEvent.keyboard('{Escape}')
|
|
480
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
481
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
482
|
+
await userEvent.keyboard('{Escape}')
|
|
483
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('inside a Popover, picking an option does not close the popover (layer-aware outside click)', async () => {
|
|
487
|
+
const onValueChange = vi.fn()
|
|
488
|
+
render(
|
|
489
|
+
<Popover defaultOpen>
|
|
490
|
+
<Popover.Trigger>Open</Popover.Trigger>
|
|
491
|
+
<Popover.Content aria-label="Filters">
|
|
492
|
+
<Basic onValueChange={onValueChange} />
|
|
493
|
+
</Popover.Content>
|
|
494
|
+
</Popover>,
|
|
495
|
+
)
|
|
496
|
+
await userEvent.click(trigger())
|
|
497
|
+
await userEvent.click(option('Done'))
|
|
498
|
+
expect(onValueChange).toHaveBeenCalledWith('done')
|
|
499
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
500
|
+
expect(screen.getByRole('dialog', { name: 'Filters' })).toBeInTheDocument()
|
|
501
|
+
// Escape now peels the popover.
|
|
502
|
+
await userEvent.keyboard('{Escape}')
|
|
503
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
describe('positioning (mocked geometry)', () => {
|
|
508
|
+
let restore: () => void
|
|
509
|
+
beforeEach(() => {
|
|
510
|
+
restore = mockGeometry()
|
|
511
|
+
setViewport(1000, 800)
|
|
512
|
+
})
|
|
513
|
+
afterEach(() => restore())
|
|
514
|
+
|
|
515
|
+
it('opens under the trigger, at least as wide as it, and animates in once positioned', async () => {
|
|
516
|
+
render(<Basic data-rect="400,300,240,40" data-size="240,40" listboxClassName="w-64" />)
|
|
517
|
+
await userEvent.click(trigger())
|
|
518
|
+
const list = listbox()
|
|
519
|
+
expect(list.style.position).toBe('fixed')
|
|
520
|
+
expect(list.style.left).toBe('400px')
|
|
521
|
+
expect(list.style.top).toBe('346px')
|
|
522
|
+
expect(list.style.minWidth).toBe('240px')
|
|
523
|
+
expect(list).toHaveAttribute('data-side', 'bottom')
|
|
524
|
+
expect(list).toHaveAttribute('data-positioned', 'true')
|
|
525
|
+
expect(list).toHaveClass('ds-enter-pop', 'w-64')
|
|
526
|
+
expect(list.style.background).toContain('--popover')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('flips above the trigger at the bottom of the viewport', async () => {
|
|
530
|
+
render(<Basic data-rect="400,780,240,40" />)
|
|
531
|
+
await userEvent.click(trigger())
|
|
532
|
+
expect(listbox()).toHaveAttribute('data-side', 'top')
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
it('honours side / align', async () => {
|
|
536
|
+
render(<Basic data-rect="400,300,240,40" side="top" align="end" />)
|
|
537
|
+
await userEvent.click(trigger())
|
|
538
|
+
const list = listbox()
|
|
539
|
+
expect(list).toHaveAttribute('data-side', 'top')
|
|
540
|
+
expect(list).toHaveAttribute('data-align', 'end')
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
})
|