@charcuterie/ui 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/Combobox/Combobox.d.ts +54 -0
  2. package/dist/Combobox/Combobox.d.ts.map +1 -0
  3. package/dist/Combobox/Combobox.js +216 -0
  4. package/dist/Combobox/Combobox.js.map +1 -0
  5. package/dist/Combobox/ComboboxOption.d.ts +36 -0
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -0
  7. package/dist/Combobox/ComboboxOption.js +32 -0
  8. package/dist/Combobox/ComboboxOption.js.map +1 -0
  9. package/dist/Dialog/Dialog.d.ts +47 -0
  10. package/dist/Dialog/Dialog.d.ts.map +1 -0
  11. package/dist/Dialog/Dialog.js +52 -0
  12. package/dist/Dialog/Dialog.js.map +1 -0
  13. package/dist/Lightbox/Lightbox.d.ts +7 -7
  14. package/dist/Lightbox/Lightbox.js +9 -9
  15. package/dist/Lightbox/Lightbox.js.map +1 -1
  16. package/dist/Listbox/Listbox.d.ts +56 -0
  17. package/dist/Listbox/Listbox.d.ts.map +1 -0
  18. package/dist/Listbox/Listbox.js +151 -0
  19. package/dist/Listbox/Listbox.js.map +1 -0
  20. package/dist/Listbox/ListboxOption.d.ts +36 -0
  21. package/dist/Listbox/ListboxOption.d.ts.map +1 -0
  22. package/dist/Listbox/ListboxOption.js +42 -0
  23. package/dist/Listbox/ListboxOption.js.map +1 -0
  24. package/dist/Menu/Menu.d.ts.map +1 -1
  25. package/dist/Menu/Menu.js +47 -97
  26. package/dist/Menu/Menu.js.map +1 -1
  27. package/dist/Modal/Modal.d.ts +11 -59
  28. package/dist/Modal/Modal.d.ts.map +1 -1
  29. package/dist/Modal/Modal.js +28 -101
  30. package/dist/Modal/Modal.js.map +1 -1
  31. package/dist/Overlay/OverlayPanel.d.ts +50 -0
  32. package/dist/Overlay/OverlayPanel.d.ts.map +1 -0
  33. package/dist/Overlay/OverlayPanel.js +72 -0
  34. package/dist/Overlay/OverlayPanel.js.map +1 -0
  35. package/dist/Overlay/OverlayStack.d.ts +47 -0
  36. package/dist/Overlay/OverlayStack.d.ts.map +1 -0
  37. package/dist/Overlay/OverlayStack.js +46 -0
  38. package/dist/Overlay/OverlayStack.js.map +1 -0
  39. package/dist/Overlay/SharedBackdrop.d.ts +24 -0
  40. package/dist/Overlay/SharedBackdrop.d.ts.map +1 -0
  41. package/dist/Overlay/SharedBackdrop.js +28 -0
  42. package/dist/Overlay/SharedBackdrop.js.map +1 -0
  43. package/dist/Overlay/lockScrollBehind.d.ts.map +1 -0
  44. package/dist/Overlay/lockScrollBehind.js.map +1 -0
  45. package/dist/Overlay/overlayPanelClass.d.ts +17 -0
  46. package/dist/Overlay/overlayPanelClass.d.ts.map +1 -0
  47. package/dist/Overlay/overlayPanelClass.js +17 -0
  48. package/dist/Overlay/overlayPanelClass.js.map +1 -0
  49. package/dist/Overlay/useAnchoredOverlay.d.ts +97 -0
  50. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -0
  51. package/dist/Overlay/useAnchoredOverlay.js +81 -0
  52. package/dist/Overlay/useAnchoredOverlay.js.map +1 -0
  53. package/dist/Popover/Popover.d.ts +16 -24
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +32 -68
  56. package/dist/Popover/Popover.js.map +1 -1
  57. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  58. package/dist/Tooltip/Tooltip.js +25 -25
  59. package/dist/Tooltip/Tooltip.js.map +1 -1
  60. package/dist/index.d.ts +7 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +3 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/mountStory.testHelpers.d.ts +1 -0
  65. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  66. package/dist/mountStory.testHelpers.js +21 -1
  67. package/dist/mountStory.testHelpers.js.map +1 -1
  68. package/package.json +3 -2
  69. package/src/Combobox/Combobox.mdx +71 -0
  70. package/src/Combobox/Combobox.stories.tsx +245 -0
  71. package/src/Combobox/Combobox.test.tsx +289 -0
  72. package/src/Combobox/Combobox.tsx +513 -0
  73. package/src/Combobox/ComboboxOption.tsx +88 -0
  74. package/src/Dialog/Dialog.mdx +87 -0
  75. package/src/Dialog/Dialog.stories.tsx +347 -0
  76. package/src/Dialog/Dialog.test.tsx +312 -0
  77. package/src/Dialog/Dialog.tsx +127 -0
  78. package/src/Field/Field.test.tsx +6 -4
  79. package/src/Lightbox/Lightbox.mdx +9 -8
  80. package/src/Lightbox/Lightbox.stories.tsx +5 -5
  81. package/src/Lightbox/Lightbox.test.tsx +25 -36
  82. package/src/Lightbox/Lightbox.tsx +10 -10
  83. package/src/Listbox/Listbox.mdx +68 -0
  84. package/src/Listbox/Listbox.stories.tsx +205 -0
  85. package/src/Listbox/Listbox.test.tsx +198 -0
  86. package/src/Listbox/Listbox.tsx +295 -0
  87. package/src/Listbox/ListboxOption.tsx +104 -0
  88. package/src/Menu/Menu.mdx +11 -13
  89. package/src/Menu/Menu.test.tsx +47 -38
  90. package/src/Menu/Menu.tsx +81 -142
  91. package/src/Modal/Modal.mdx +29 -82
  92. package/src/Modal/Modal.stories.tsx +55 -188
  93. package/src/Modal/Modal.test.tsx +44 -177
  94. package/src/Modal/Modal.tsx +54 -204
  95. package/src/Overlay/OverlayPanel.tsx +195 -0
  96. package/src/Overlay/OverlayStack.test.tsx +235 -0
  97. package/src/Overlay/OverlayStack.tsx +132 -0
  98. package/src/Overlay/SharedBackdrop.tsx +40 -0
  99. package/src/Overlay/overlayPanelClass.ts +17 -0
  100. package/src/Overlay/useAnchoredOverlay.ts +197 -0
  101. package/src/Popover/Popover.mdx +17 -20
  102. package/src/Popover/Popover.stories.tsx +33 -0
  103. package/src/Popover/Popover.test.tsx +68 -32
  104. package/src/Popover/Popover.tsx +56 -95
  105. package/src/Tooltip/Tooltip.mdx +12 -9
  106. package/src/Tooltip/Tooltip.test.tsx +17 -19
  107. package/src/Tooltip/Tooltip.tsx +35 -32
  108. package/src/index.ts +13 -4
  109. package/src/mountStory.testHelpers.ts +25 -1
  110. package/src/sourceRules.test.ts +12 -3
  111. package/src/storyControls.test.ts +25 -12
  112. package/dist/Modal/lockScrollBehind.d.ts.map +0 -1
  113. package/dist/Modal/lockScrollBehind.js.map +0 -1
  114. /package/dist/{Modal → Overlay}/lockScrollBehind.d.ts +0 -0
  115. /package/dist/{Modal → Overlay}/lockScrollBehind.js +0 -0
  116. /package/src/{Modal → Overlay}/lockScrollBehind.ts +0 -0
@@ -0,0 +1,245 @@
1
+ import { useVisibility } from "@charcuterie/logic"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+ import { useState } from "react"
5
+
6
+ import { Button } from "../Button/Button.tsx"
7
+ import type { ListboxItem } from "../Listbox/Listbox.tsx"
8
+ import { Combobox } from "./Combobox.tsx"
9
+
10
+ const LANGUAGES: ListboxItem[] = [
11
+ { label: "English", value: "eng" },
12
+ { label: "Spanish", value: "spa" },
13
+ { label: "French", value: "fra" },
14
+ { label: "German", value: "deu" },
15
+ { label: "Japanese", value: "jpn" },
16
+ { label: "Korean", value: "kor" },
17
+ { label: "Portuguese", value: "por" },
18
+ ]
19
+
20
+ const LANGUAGES_DISABLED_FIRST: ListboxItem[] = [
21
+ { isDisabled: true, label: "English", value: "eng" },
22
+ { label: "Spanish", value: "spa" },
23
+ { label: "French", value: "fra" },
24
+ ]
25
+
26
+ // A long list, to trip the auto-virtualization threshold.
27
+ const MANY: ListboxItem[] = Array.from(
28
+ { length: 500 },
29
+ (_unused, index) => ({
30
+ label: `Track ${index + 1}`,
31
+ textValue: `Track ${index + 1}`,
32
+ value: `track-${index + 1}`,
33
+ }),
34
+ )
35
+
36
+ const meta = {
37
+ title: "Components/Combobox",
38
+ component: Combobox,
39
+ parameters: { layout: "padded" },
40
+ } satisfies Meta<typeof Combobox>
41
+
42
+ export default meta
43
+
44
+ type Story = StoryObj<typeof meta>
45
+
46
+ const ComboboxHarness = ({
47
+ emptyLabel,
48
+ error,
49
+ footer,
50
+ isCreatable,
51
+ isInitiallyVisible = false,
52
+ isLoading,
53
+ isMultiple,
54
+ isVirtualized,
55
+ options,
56
+ triggerLabel,
57
+ }: {
58
+ emptyLabel?: string
59
+ error?: ReactNode
60
+ footer?: ReactNode
61
+ isCreatable?: boolean
62
+ isInitiallyVisible?: boolean
63
+ isLoading?: boolean
64
+ isMultiple?: boolean
65
+ isVirtualized?: boolean
66
+ options: ListboxItem[]
67
+ triggerLabel: string
68
+ }): ReactNode => {
69
+ const { hide, isVisible, toggle } = useVisibility({
70
+ isVisible: isInitiallyVisible,
71
+ })
72
+
73
+ const [chosen, setChosen] = useState<string[]>([])
74
+
75
+ return (
76
+ <>
77
+ <Combobox
78
+ emptyLabel={emptyLabel}
79
+ error={error}
80
+ footer={footer}
81
+ isCreatable={isCreatable}
82
+ isLoading={isLoading}
83
+ isMultiple={isMultiple}
84
+ isVirtualized={isVirtualized}
85
+ isVisible={isVisible}
86
+ onDismiss={hide}
87
+ onSelect={(value) => {
88
+ setChosen((previous) =>
89
+ previous.includes(value)
90
+ ? previous.filter((one) => one !== value)
91
+ : [...previous, value],
92
+ )
93
+ }}
94
+ options={options}
95
+ trigger={
96
+ <Button appearance="outline" onClick={toggle}>
97
+ {triggerLabel}
98
+ </Button>
99
+ }
100
+ />
101
+
102
+ <p className="mt-2 text-content-muted text-xs">
103
+ Chosen: {chosen.join(", ") || "—"}
104
+ </p>
105
+ </>
106
+ )
107
+ }
108
+
109
+ export const Default: Story = {
110
+ args: {
111
+ isVisible: false,
112
+ onDismiss: () => {},
113
+ onSelect: () => {},
114
+ options: LANGUAGES,
115
+ trigger: <Button>Search languages</Button>,
116
+ },
117
+ render: () => (
118
+ <ComboboxHarness
119
+ options={LANGUAGES}
120
+ triggerLabel="Search languages"
121
+ />
122
+ ),
123
+ }
124
+
125
+ /**
126
+ * The loading and error panel states — the ones only `PathPicker` had
127
+ * all of, and every fleet picker did differently. Both keep the popup
128
+ * open.
129
+ */
130
+ export const AllStates: Story = {
131
+ args: {
132
+ isVisible: false,
133
+ onDismiss: () => {},
134
+ onSelect: () => {},
135
+ options: LANGUAGES,
136
+ trigger: <Button>Search</Button>,
137
+ },
138
+ render: () => (
139
+ <div className="flex flex-col gap-8">
140
+ <ComboboxHarness
141
+ isLoading
142
+ options={LANGUAGES}
143
+ triggerLabel="Loading state"
144
+ />
145
+
146
+ <ComboboxHarness
147
+ error="Could not reach the server."
148
+ options={LANGUAGES}
149
+ triggerLabel="Error state"
150
+ />
151
+ </div>
152
+ ),
153
+ }
154
+
155
+ /**
156
+ * Multi-select renders the chosen values as removable chips rather
157
+ * than filling the input, with a sticky footer hint — the
158
+ * `LanguageCodeField` and `LinkPicker` shapes. `isCreatable` lets Enter
159
+ * commit the raw query.
160
+ */
161
+ export const AllVariants: Story = {
162
+ args: {
163
+ isVisible: false,
164
+ onDismiss: () => {},
165
+ onSelect: () => {},
166
+ options: LANGUAGES,
167
+ trigger: <Button>Pick languages</Button>,
168
+ },
169
+ render: () => (
170
+ <ComboboxHarness
171
+ footer="Type a code and press Enter to add it."
172
+ isCreatable
173
+ isMultiple
174
+ options={LANGUAGES}
175
+ triggerLabel="Pick languages"
176
+ />
177
+ ),
178
+ }
179
+
180
+ /**
181
+ * Five hundred options, windowed with `@tanstack/react-virtual`: only
182
+ * the visible rows are in the DOM, so each carries `aria-setsize` and
183
+ * `aria-posinset` — otherwise a screen reader announces "2 of 12" for
184
+ * a list that is nothing of the sort.
185
+ */
186
+ export const Virtualized: Story = {
187
+ args: {
188
+ isVisible: false,
189
+ onDismiss: () => {},
190
+ onSelect: () => {},
191
+ options: MANY,
192
+ trigger: <Button>Search 500 tracks</Button>,
193
+ },
194
+ render: () => (
195
+ <ComboboxHarness
196
+ isInitiallyVisible
197
+ options={MANY}
198
+ triggerLabel="Search 500 tracks"
199
+ />
200
+ ),
201
+ }
202
+
203
+ /**
204
+ * Open it and type: the list filters as you go, arrows move the active
205
+ * option while the caret stays in the input, Enter chooses, and Escape
206
+ * clears the query before it closes.
207
+ */
208
+ export const Interactive: Story = {
209
+ args: {
210
+ isVisible: false,
211
+ onDismiss: () => {},
212
+ onSelect: () => {},
213
+ options: LANGUAGES,
214
+ trigger: <Button>Search languages</Button>,
215
+ },
216
+ render: () => (
217
+ <ComboboxHarness
218
+ options={LANGUAGES}
219
+ triggerLabel="Search languages"
220
+ />
221
+ ),
222
+ }
223
+
224
+ /**
225
+ * A disabled option in the seed position. Opening seeds the active row at
226
+ * index 0, so the resolved active descendant must skip past the disabled
227
+ * first option to the first enabled one — and Enter must never commit the
228
+ * disabled value.
229
+ */
230
+ export const DisabledFirstOption: Story = {
231
+ args: {
232
+ isVisible: false,
233
+ onDismiss: () => {},
234
+ onSelect: () => {},
235
+ options: LANGUAGES_DISABLED_FIRST,
236
+ trigger: <Button>Pick a language</Button>,
237
+ },
238
+ render: () => (
239
+ <ComboboxHarness
240
+ isInitiallyVisible
241
+ options={LANGUAGES_DISABLED_FIRST}
242
+ triggerLabel="Pick a language"
243
+ />
244
+ ),
245
+ }
@@ -0,0 +1,289 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Combobox.stories.tsx"
9
+
10
+ const {
11
+ AllVariants,
12
+ Default,
13
+ DisabledFirstOption,
14
+ Interactive,
15
+ Virtualized,
16
+ } = composeStories(stories)
17
+
18
+ const openMulti = async () => {
19
+ const mounted = await mountStory(AllVariants)
20
+
21
+ await userEvent.click(
22
+ expectAgentDrivable(mounted.canvas, {
23
+ name: "Pick languages",
24
+ role: "button",
25
+ }),
26
+ )
27
+
28
+ const input = expectAgentDrivable(mounted.body, {
29
+ role: "combobox",
30
+ })
31
+
32
+ await waitFor(() => {
33
+ expect(input).toHaveFocus()
34
+ })
35
+
36
+ return { ...mounted, input }
37
+ }
38
+
39
+ const openDefault = async () => {
40
+ const mounted = await mountStory(Default)
41
+
42
+ await userEvent.click(
43
+ expectAgentDrivable(mounted.canvas, {
44
+ name: "Search languages",
45
+ role: "button",
46
+ }),
47
+ )
48
+
49
+ const input = expectAgentDrivable(mounted.body, {
50
+ role: "combobox",
51
+ })
52
+
53
+ await waitFor(() => {
54
+ expect(input).toHaveFocus()
55
+ })
56
+
57
+ return { ...mounted, input }
58
+ }
59
+
60
+ test("a button opens a combobox whose popup is a listbox", async () => {
61
+ const { body, input } = await openDefault()
62
+
63
+ await expect(input).toHaveAttribute(
64
+ "aria-autocomplete",
65
+ "list",
66
+ )
67
+
68
+ const listbox = expectAgentDrivable(body, {
69
+ role: "listbox",
70
+ })
71
+
72
+ await expect(input).toHaveAttribute(
73
+ "aria-controls",
74
+ listbox.id,
75
+ )
76
+
77
+ await expectNoAxeViolations(listbox)
78
+ })
79
+
80
+ test("typing filters the options, and focus stays in the input", async () => {
81
+ const { body, input } = await openDefault()
82
+
83
+ await expect(body.getAllByRole("option")).toHaveLength(7)
84
+
85
+ await userEvent.keyboard("fr")
86
+
87
+ await waitFor(() => {
88
+ expect(body.getAllByRole("option")).toHaveLength(1)
89
+ })
90
+
91
+ expect(body.getByRole("option")).toHaveTextContent(
92
+ "French",
93
+ )
94
+
95
+ // The caret never left the input.
96
+ await expect(input).toHaveFocus()
97
+ })
98
+
99
+ test("no match shows the empty label and keeps the popup open", async () => {
100
+ const { body } = await openDefault()
101
+
102
+ await userEvent.keyboard("zzz")
103
+
104
+ await waitFor(() => {
105
+ expect(body.queryAllByRole("option")).toHaveLength(0)
106
+ })
107
+
108
+ // Still open, still typeable.
109
+ await expect(
110
+ body.getByText("No matches"),
111
+ ).toBeInTheDocument()
112
+
113
+ await expect(
114
+ body.getByRole("combobox"),
115
+ ).toBeInTheDocument()
116
+ })
117
+
118
+ test("the arrow keys move aria-activedescendant, not focus", async () => {
119
+ const { body, input } = await openDefault()
120
+
121
+ const options = body.getAllByRole("option")
122
+
123
+ // Opens on the first option.
124
+ await expect(input).toHaveAttribute(
125
+ "aria-activedescendant",
126
+ options[0]?.id,
127
+ )
128
+
129
+ await userEvent.keyboard("{ArrowDown}")
130
+
131
+ await waitFor(() => {
132
+ expect(input).toHaveAttribute(
133
+ "aria-activedescendant",
134
+ options[1]?.id,
135
+ )
136
+ })
137
+
138
+ // Focus never moved to the option.
139
+ await expect(input).toHaveFocus()
140
+ })
141
+
142
+ test("Enter selects the active option and closes", async () => {
143
+ const { body, input } = await openDefault()
144
+
145
+ await userEvent.keyboard("{ArrowDown}{Enter}")
146
+
147
+ await waitFor(() => {
148
+ expect(body.queryByRole("combobox")).toBeNull()
149
+ })
150
+
151
+ // Single-select flows back through `onSelect`.
152
+ await expect(input).not.toBeInTheDocument()
153
+ })
154
+
155
+ test("Escape clears the query first, then closes", async () => {
156
+ const { body, canvas } = await mountStory(Interactive)
157
+
158
+ await userEvent.click(
159
+ expectAgentDrivable(canvas, {
160
+ name: "Search languages",
161
+ role: "button",
162
+ }),
163
+ )
164
+
165
+ const input = body.getByRole("combobox")
166
+
167
+ await waitFor(() => {
168
+ expect(input).toHaveFocus()
169
+ })
170
+
171
+ await userEvent.keyboard("fr")
172
+
173
+ await expect(input).toHaveValue("fr")
174
+
175
+ await userEvent.keyboard("{Escape}")
176
+
177
+ // First Escape clears the query but leaves the popup open.
178
+ await expect(input).toHaveValue("")
179
+
180
+ await expect(
181
+ body.getByRole("combobox"),
182
+ ).toBeInTheDocument()
183
+
184
+ await userEvent.keyboard("{Escape}")
185
+
186
+ await waitFor(() => {
187
+ expect(body.queryByRole("combobox")).toBeNull()
188
+ })
189
+ })
190
+
191
+ test("multi-select Tab escapes the field instead of committing", async () => {
192
+ const { body, canvas, input } = await openMulti()
193
+
194
+ // Nothing chosen yet.
195
+ await expect(
196
+ canvas.getByText("Chosen: —"),
197
+ ).toBeInTheDocument()
198
+
199
+ await userEvent.tab()
200
+
201
+ // Tab did not commit the active option — no chip, still nothing chosen…
202
+ await expect(
203
+ canvas.getByText("Chosen: —"),
204
+ ).toBeInTheDocument()
205
+
206
+ await expect(
207
+ body.queryByRole("button", { name: /^Remove / }),
208
+ ).toBeNull()
209
+
210
+ // …and focus left the input rather than being trapped back in it.
211
+ await expect(input).not.toHaveFocus()
212
+ })
213
+
214
+ test("removing a chip reports the removal through onSelect", async () => {
215
+ const { body, canvas } = await openMulti()
216
+
217
+ // Enter commits the active option in multi-select and keeps the popup
218
+ // open, so a chip appears and the parent is told.
219
+ await userEvent.keyboard("{Enter}")
220
+
221
+ await waitFor(() => {
222
+ expect(
223
+ canvas.getByText("Chosen: eng"),
224
+ ).toBeInTheDocument()
225
+ })
226
+
227
+ await userEvent.click(
228
+ body.getByRole("button", { name: "Remove eng" }),
229
+ )
230
+
231
+ // The removal flowed back through `onSelect`, so the parent cleared it
232
+ // — a chip removal and a list toggle-off now agree.
233
+ await waitFor(() => {
234
+ expect(
235
+ canvas.getByText("Chosen: —"),
236
+ ).toBeInTheDocument()
237
+ })
238
+ })
239
+
240
+ test("a disabled first option is skipped, never active or committed", async () => {
241
+ const { body, canvas } = await mountStory(
242
+ DisabledFirstOption,
243
+ )
244
+
245
+ const input = body.getByRole("combobox")
246
+
247
+ await waitFor(() => {
248
+ expect(input).toHaveFocus()
249
+ })
250
+
251
+ const options = body.getAllByRole("option")
252
+
253
+ // The seed lands on index 0 (disabled English); the resolved active
254
+ // descendant skips to the first enabled option, Spanish.
255
+ await expect(input).toHaveAttribute(
256
+ "aria-activedescendant",
257
+ options[1]?.id,
258
+ )
259
+
260
+ await userEvent.keyboard("{Enter}")
261
+
262
+ // Enter committed Spanish, not the disabled English, and closed.
263
+ await waitFor(() => {
264
+ expect(body.queryByRole("combobox")).toBeNull()
265
+ })
266
+
267
+ await expect(
268
+ canvas.getByText("Chosen: spa"),
269
+ ).toBeInTheDocument()
270
+ })
271
+
272
+ test("a windowed list carries aria-setsize and aria-posinset", async () => {
273
+ const { body } = await mountStory(Virtualized)
274
+
275
+ const options = body.getAllByRole("option")
276
+
277
+ // Only a window of the 500 is in the DOM.
278
+ await expect(options.length).toBeLessThan(500)
279
+
280
+ await expect(options.length).toBeGreaterThan(0)
281
+
282
+ // Each announces its place in the whole set, not the window.
283
+ await expect(options[0]).toHaveAttribute(
284
+ "aria-setsize",
285
+ "500",
286
+ )
287
+
288
+ await expect(options[0]).toHaveAttribute("aria-posinset")
289
+ })