@purpurds/popover 0.0.1

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 (72) hide show
  1. package/dist/LICENSE.txt +905 -0
  2. package/dist/metadata.js +8 -0
  3. package/dist/popover-back.d.ts +9 -0
  4. package/dist/popover-back.d.ts.map +1 -0
  5. package/dist/popover-button.d.ts +37 -0
  6. package/dist/popover-button.d.ts.map +1 -0
  7. package/dist/popover-content.d.ts +93 -0
  8. package/dist/popover-content.d.ts.map +1 -0
  9. package/dist/popover-flow.d.ts +65 -0
  10. package/dist/popover-flow.d.ts.map +1 -0
  11. package/dist/popover-footer.d.ts +16 -0
  12. package/dist/popover-footer.d.ts.map +1 -0
  13. package/dist/popover-header.d.ts +7 -0
  14. package/dist/popover-header.d.ts.map +1 -0
  15. package/dist/popover-internal-context.d.ts +15 -0
  16. package/dist/popover-internal-context.d.ts.map +1 -0
  17. package/dist/popover-next.d.ts +9 -0
  18. package/dist/popover-next.d.ts.map +1 -0
  19. package/dist/popover-standalone.d.ts +12 -0
  20. package/dist/popover-standalone.d.ts.map +1 -0
  21. package/dist/popover-steps.d.ts +6 -0
  22. package/dist/popover-steps.d.ts.map +1 -0
  23. package/dist/popover-trigger.d.ts +27 -0
  24. package/dist/popover-trigger.d.ts.map +1 -0
  25. package/dist/popover-walkthrough.d.ts +13 -0
  26. package/dist/popover-walkthrough.d.ts.map +1 -0
  27. package/dist/popover.cjs.js +42 -0
  28. package/dist/popover.cjs.js.map +1 -0
  29. package/dist/popover.d.ts +36 -0
  30. package/dist/popover.d.ts.map +1 -0
  31. package/dist/popover.es.js +3849 -0
  32. package/dist/popover.es.js.map +1 -0
  33. package/dist/styles.css +1 -0
  34. package/dist/use-screen-size.hook.d.ts +7 -0
  35. package/dist/use-screen-size.hook.d.ts.map +1 -0
  36. package/dist/use-smooth-scroll.d.ts +5 -0
  37. package/dist/use-smooth-scroll.d.ts.map +1 -0
  38. package/dist/usePopoverTrigger.d.ts +5 -0
  39. package/dist/usePopoverTrigger.d.ts.map +1 -0
  40. package/dist/usePopoverWalkthrough.d.ts +7 -0
  41. package/dist/usePopoverWalkthrough.d.ts.map +1 -0
  42. package/eslint.config.mjs +2 -0
  43. package/package.json +82 -0
  44. package/src/global.d.ts +4 -0
  45. package/src/popover-back.test.tsx +63 -0
  46. package/src/popover-back.tsx +40 -0
  47. package/src/popover-button.test.tsx +51 -0
  48. package/src/popover-button.tsx +84 -0
  49. package/src/popover-content.test.tsx +1122 -0
  50. package/src/popover-content.tsx +277 -0
  51. package/src/popover-flow.tsx +170 -0
  52. package/src/popover-footer.test.tsx +21 -0
  53. package/src/popover-footer.tsx +32 -0
  54. package/src/popover-header.test.tsx +22 -0
  55. package/src/popover-header.tsx +32 -0
  56. package/src/popover-internal-context.tsx +28 -0
  57. package/src/popover-next.test.tsx +61 -0
  58. package/src/popover-next.tsx +40 -0
  59. package/src/popover-standalone.tsx +48 -0
  60. package/src/popover-steps.tsx +32 -0
  61. package/src/popover-trigger.tsx +71 -0
  62. package/src/popover-walkthrough.test.tsx +346 -0
  63. package/src/popover-walkthrough.tsx +45 -0
  64. package/src/popover.module.scss +315 -0
  65. package/src/popover.stories.tsx +1157 -0
  66. package/src/popover.test.tsx +642 -0
  67. package/src/popover.tsx +76 -0
  68. package/src/use-screen-size.hook.ts +39 -0
  69. package/src/use-smooth-scroll.ts +62 -0
  70. package/src/usePopoverTrigger.ts +59 -0
  71. package/src/usePopoverWalkthrough.ts +85 -0
  72. package/vitest.setup.ts +30 -0
@@ -0,0 +1,1122 @@
1
+ import React from "react";
2
+ import { Button } from "@purpurds/button";
3
+ import { IconInfo } from "@purpurds/icon/info";
4
+ import { render, screen, waitFor } from "@testing-library/react";
5
+ import userEvent from "@testing-library/user-event";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { axe } from "vitest-axe";
8
+
9
+ import { Popover } from "./popover";
10
+ import { PopoverButton } from "./popover-button";
11
+ import { PopoverContent } from "./popover-content";
12
+ import { PopoverFlow } from "./popover-flow";
13
+ import { PopoverFooter } from "./popover-footer";
14
+ import { PopoverTrigger } from "./popover-trigger";
15
+
16
+ describe("PopoverContent", () => {
17
+ describe("Basic Rendering", () => {
18
+ it("should render with required props", async () => {
19
+ const user = userEvent.setup({ delay: null });
20
+ render(
21
+ <Popover>
22
+ <PopoverTrigger>
23
+ <Button variant="primary">Trigger</Button>
24
+ </PopoverTrigger>
25
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="This is the body text" />
26
+ </Popover>
27
+ );
28
+
29
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
30
+
31
+ await waitFor(() => {
32
+ expect(screen.getByText("This is the body text")).toBeInTheDocument();
33
+ });
34
+ });
35
+
36
+ it("should render with header", async () => {
37
+ const user = userEvent.setup({ delay: null });
38
+ render(
39
+ <Popover>
40
+ <PopoverTrigger>
41
+ <Button variant="primary">Trigger</Button>
42
+ </PopoverTrigger>
43
+ <PopoverContent closeIconAriaLabel="Close" title="Test Title" body="Body text" />
44
+ </Popover>
45
+ );
46
+
47
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
48
+
49
+ await waitFor(() => {
50
+ expect(screen.getByText("Test Title")).toBeInTheDocument();
51
+ });
52
+ });
53
+
54
+ it("should render with header icon", async () => {
55
+ const user = userEvent.setup({ delay: null });
56
+ render(
57
+ <Popover>
58
+ <PopoverTrigger>
59
+ <Button variant="primary">Trigger</Button>
60
+ </PopoverTrigger>
61
+ <PopoverContent
62
+ closeIconAriaLabel="Close"
63
+ title="Test Title"
64
+ icon={<IconInfo data-testid="header-icon" />}
65
+ body="Body text"
66
+ />
67
+ </Popover>
68
+ );
69
+
70
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
71
+
72
+ await waitFor(() => {
73
+ expect(screen.getByTestId("header-icon")).toBeInTheDocument();
74
+ });
75
+ });
76
+
77
+ it("should render close button with correct aria-label", async () => {
78
+ const user = userEvent.setup({ delay: null });
79
+ render(
80
+ <Popover>
81
+ <PopoverTrigger>
82
+ <Button variant="primary">Trigger</Button>
83
+ </PopoverTrigger>
84
+ <PopoverContent closeIconAriaLabel="Close dialog" title="Test" body="Body text" />
85
+ </Popover>
86
+ );
87
+
88
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
89
+
90
+ await waitFor(() => {
91
+ expect(screen.getByRole("button", { name: "Close dialog" })).toBeInTheDocument();
92
+ });
93
+ });
94
+
95
+ it("should render with custom footer", async () => {
96
+ const user = userEvent.setup({ delay: null });
97
+ render(
98
+ <Popover>
99
+ <PopoverTrigger>
100
+ <Button variant="primary">Trigger</Button>
101
+ </PopoverTrigger>
102
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text">
103
+ <PopoverFooter>
104
+ <PopoverButton>Custom Action</PopoverButton>
105
+ </PopoverFooter>
106
+ </PopoverContent>
107
+ </Popover>
108
+ );
109
+
110
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
111
+
112
+ await waitFor(() => {
113
+ expect(screen.getByRole("button", { name: "Custom Action" })).toBeInTheDocument();
114
+ });
115
+ });
116
+ });
117
+
118
+ describe("Visual Variants", () => {
119
+ it("should apply negative variant", async () => {
120
+ const user = userEvent.setup({ delay: null });
121
+ render(
122
+ <Popover>
123
+ <PopoverTrigger>
124
+ <Button variant="primary">Trigger</Button>
125
+ </PopoverTrigger>
126
+ <PopoverContent
127
+ closeIconAriaLabel="Close"
128
+ title="Test Title"
129
+ body="Body text"
130
+ negative
131
+ data-testid="popover-content"
132
+ />
133
+ </Popover>
134
+ );
135
+
136
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
137
+
138
+ await waitFor(() => {
139
+ const content = screen.getByRole("dialog");
140
+ expect(content).toHaveClass("purpur-popover__content--negative");
141
+ });
142
+ });
143
+
144
+ it("should apply custom className", async () => {
145
+ const user = userEvent.setup({ delay: null });
146
+ render(
147
+ <Popover>
148
+ <PopoverTrigger>
149
+ <Button variant="primary">Trigger</Button>
150
+ </PopoverTrigger>
151
+ <PopoverContent
152
+ closeIconAriaLabel="Close"
153
+ title="Test Title"
154
+ body="Body text"
155
+ className="custom-class"
156
+ />
157
+ </Popover>
158
+ );
159
+
160
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
161
+
162
+ await waitFor(() => {
163
+ const content = screen.getByTestId("popover-content");
164
+ expect(content).toHaveClass("custom-class");
165
+ });
166
+ });
167
+
168
+ it("should render arrow by default", async () => {
169
+ const user = userEvent.setup({ delay: null });
170
+ render(
171
+ <Popover>
172
+ <PopoverTrigger>
173
+ <Button variant="primary">Trigger</Button>
174
+ </PopoverTrigger>
175
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text" />
176
+ </Popover>
177
+ );
178
+
179
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
180
+
181
+ await waitFor(() => {
182
+ const arrow = document.querySelector(".purpur-popover__arrow");
183
+ expect(arrow).toBeInTheDocument();
184
+ });
185
+ });
186
+
187
+ it("should hide arrow when beakPosition is None", async () => {
188
+ const user = userEvent.setup({ delay: null });
189
+ render(
190
+ <Popover>
191
+ <PopoverTrigger>
192
+ <Button variant="primary">Trigger</Button>
193
+ </PopoverTrigger>
194
+ <PopoverContent
195
+ closeIconAriaLabel="Close"
196
+ title="Test"
197
+ body="Body text"
198
+ beakPosition="none"
199
+ />
200
+ </Popover>
201
+ );
202
+
203
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
204
+
205
+ await waitFor(() => {
206
+ const arrow = document.querySelector(".purpur-popover__arrow");
207
+ expect(arrow).not.toBeInTheDocument();
208
+ });
209
+ });
210
+ });
211
+
212
+ describe("Positioning", () => {
213
+ it("should map beakPosition 'up' to Radix side 'bottom'", async () => {
214
+ const user = userEvent.setup({ delay: null });
215
+ render(
216
+ <Popover>
217
+ <PopoverTrigger>
218
+ <Button variant="primary">Trigger</Button>
219
+ </PopoverTrigger>
220
+ <PopoverContent
221
+ closeIconAriaLabel="Close"
222
+ title="Test"
223
+ body="Body text"
224
+ beakPosition="up"
225
+ />
226
+ </Popover>
227
+ );
228
+
229
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
230
+
231
+ await waitFor(() => {
232
+ const content = screen.getByTestId("popover-content");
233
+ expect(content).toHaveAttribute("data-side", "bottom");
234
+ });
235
+ });
236
+
237
+ it("should map beakPosition 'down' to Radix side 'top'", async () => {
238
+ const user = userEvent.setup({ delay: null });
239
+ render(
240
+ <Popover>
241
+ <PopoverTrigger>
242
+ <Button variant="primary">Trigger</Button>
243
+ </PopoverTrigger>
244
+ <PopoverContent
245
+ closeIconAriaLabel="Close"
246
+ title="Test"
247
+ body="Body text"
248
+ beakPosition="down"
249
+ />
250
+ </Popover>
251
+ );
252
+
253
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
254
+
255
+ await waitFor(() => {
256
+ const content = screen.getByTestId("popover-content");
257
+ expect(content).toHaveAttribute("data-side", "top");
258
+ });
259
+ });
260
+
261
+ it("should map beakPosition 'left' to Radix side 'right'", async () => {
262
+ const user = userEvent.setup({ delay: null });
263
+ render(
264
+ <Popover>
265
+ <PopoverTrigger>
266
+ <Button variant="primary">Trigger</Button>
267
+ </PopoverTrigger>
268
+ <PopoverContent
269
+ closeIconAriaLabel="Close"
270
+ title="Test"
271
+ body="Body text"
272
+ beakPosition="left"
273
+ />
274
+ </Popover>
275
+ );
276
+
277
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
278
+
279
+ await waitFor(() => {
280
+ const content = screen.getByTestId("popover-content");
281
+ expect(content).toHaveAttribute("data-side", "right");
282
+ });
283
+ });
284
+
285
+ it("should map beakPosition 'right' to Radix side 'left'", async () => {
286
+ const user = userEvent.setup({ delay: null });
287
+ render(
288
+ <Popover>
289
+ <PopoverTrigger>
290
+ <Button variant="primary">Trigger</Button>
291
+ </PopoverTrigger>
292
+ <PopoverContent
293
+ closeIconAriaLabel="Close"
294
+ title="Test"
295
+ body="Body text"
296
+ beakPosition="right"
297
+ />
298
+ </Popover>
299
+ );
300
+
301
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
302
+
303
+ await waitFor(() => {
304
+ const content = screen.getByTestId("popover-content");
305
+ expect(content).toHaveAttribute("data-side", "left");
306
+ });
307
+ });
308
+
309
+ it("should map beakPosition 'none' to Radix side 'bottom' (default)", async () => {
310
+ const user = userEvent.setup({ delay: null });
311
+ render(
312
+ <Popover>
313
+ <PopoverTrigger>
314
+ <Button variant="primary">Trigger</Button>
315
+ </PopoverTrigger>
316
+ <PopoverContent
317
+ closeIconAriaLabel="Close"
318
+ title="Test"
319
+ body="Body text"
320
+ beakPosition="none"
321
+ />
322
+ </Popover>
323
+ );
324
+
325
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
326
+
327
+ await waitFor(() => {
328
+ const content = screen.getByTestId("popover-content");
329
+ expect(content).toHaveAttribute("data-side", "bottom");
330
+ });
331
+ });
332
+
333
+ it("should default to 'down' (Radix 'top') when beakPosition is not provided", async () => {
334
+ const user = userEvent.setup({ delay: null });
335
+ render(
336
+ <Popover>
337
+ <PopoverTrigger>
338
+ <Button variant="primary">Trigger</Button>
339
+ </PopoverTrigger>
340
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text" />
341
+ </Popover>
342
+ );
343
+
344
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
345
+
346
+ await waitFor(() => {
347
+ const content = screen.getByTestId("popover-content");
348
+ expect(content).toHaveAttribute("data-side", "top");
349
+ });
350
+ });
351
+
352
+ it("should apply custom align", async () => {
353
+ const user = userEvent.setup({ delay: null });
354
+ render(
355
+ <Popover>
356
+ <PopoverTrigger>
357
+ <Button variant="primary">Trigger</Button>
358
+ </PopoverTrigger>
359
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text" align="start" />
360
+ </Popover>
361
+ );
362
+
363
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
364
+
365
+ await waitFor(() => {
366
+ const content = screen.getByTestId("popover-content");
367
+ expect(content).toHaveAttribute("data-align", "start");
368
+ });
369
+ });
370
+
371
+ it("should apply custom zIndex", async () => {
372
+ const user = userEvent.setup({ delay: null });
373
+ render(
374
+ <Popover>
375
+ <PopoverTrigger>
376
+ <Button variant="primary">Trigger</Button>
377
+ </PopoverTrigger>
378
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text" zIndex={999} />
379
+ </Popover>
380
+ );
381
+
382
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
383
+
384
+ await waitFor(() => {
385
+ const content = screen.getByTestId("popover-content");
386
+ expect(content).toHaveStyle({ "--popover-z-index": "999" });
387
+ });
388
+ });
389
+ });
390
+
391
+ describe("Interactions", () => {
392
+ it("should close when close button is clicked", async () => {
393
+ const user = userEvent.setup({ delay: null });
394
+ render(
395
+ <Popover>
396
+ <PopoverTrigger>
397
+ <Button variant="primary">Trigger</Button>
398
+ </PopoverTrigger>
399
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text" />
400
+ </Popover>
401
+ );
402
+
403
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
404
+
405
+ await waitFor(() => {
406
+ expect(screen.getByText("Body text")).toBeInTheDocument();
407
+ });
408
+
409
+ await user.click(screen.getByRole("button", { name: "Close" }));
410
+
411
+ await waitFor(() => {
412
+ expect(screen.queryByText("Body text")).not.toBeInTheDocument();
413
+ });
414
+ });
415
+
416
+ it("should call onAction when close button is clicked", async () => {
417
+ const onAction = vi.fn();
418
+ const user = userEvent.setup({ delay: null });
419
+ render(
420
+ <Popover>
421
+ <PopoverTrigger>
422
+ <Button variant="primary">Trigger</Button>
423
+ </PopoverTrigger>
424
+ <PopoverContent
425
+ closeIconAriaLabel="Close"
426
+ title="Test"
427
+ body="Body text"
428
+ onAction={onAction}
429
+ />
430
+ </Popover>
431
+ );
432
+
433
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
434
+
435
+ await waitFor(() => {
436
+ expect(screen.getByText("Body text")).toBeInTheDocument();
437
+ });
438
+
439
+ await user.click(screen.getByRole("button", { name: "Close" }));
440
+
441
+ await waitFor(() => {
442
+ expect(onAction).toHaveBeenCalledWith({ type: "dismiss", step: undefined });
443
+ });
444
+ });
445
+
446
+ it("should prevent default focus behavior", async () => {
447
+ const preventDefault = vi.fn();
448
+ const user = userEvent.setup({ delay: null });
449
+ render(
450
+ <Popover>
451
+ <PopoverTrigger>
452
+ <Button variant="primary">Trigger</Button>
453
+ </PopoverTrigger>
454
+ <PopoverContent
455
+ closeIconAriaLabel="Close"
456
+ title="Test"
457
+ body="Body text"
458
+ onOpenAutoFocus={(e) => {
459
+ preventDefault();
460
+ e.preventDefault();
461
+ }}
462
+ />
463
+ </Popover>
464
+ );
465
+
466
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
467
+
468
+ await waitFor(() => {
469
+ expect(preventDefault).toHaveBeenCalled();
470
+ });
471
+ });
472
+
473
+ it("should focus content on open", async () => {
474
+ const user = userEvent.setup({ delay: null });
475
+ render(
476
+ <Popover>
477
+ <PopoverTrigger>
478
+ <Button variant="primary">Trigger</Button>
479
+ </PopoverTrigger>
480
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text" />
481
+ </Popover>
482
+ );
483
+
484
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
485
+
486
+ await waitFor(() => {
487
+ const content = screen.getByTestId("popover-content");
488
+ expect(content).toHaveAttribute("tabindex", "-1");
489
+ });
490
+ });
491
+ });
492
+
493
+ describe("Accessibility", () => {
494
+ it("should have role dialog", async () => {
495
+ const user = userEvent.setup({ delay: null });
496
+ render(
497
+ <Popover>
498
+ <PopoverTrigger>
499
+ <Button variant="primary">Trigger</Button>
500
+ </PopoverTrigger>
501
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text" />
502
+ </Popover>
503
+ );
504
+
505
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
506
+
507
+ await waitFor(() => {
508
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
509
+ });
510
+ });
511
+
512
+ it("should have aria-modal attribute", async () => {
513
+ const user = userEvent.setup({ delay: null });
514
+ render(
515
+ <Popover>
516
+ <PopoverTrigger>
517
+ <Button variant="primary">Trigger</Button>
518
+ </PopoverTrigger>
519
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text" />
520
+ </Popover>
521
+ );
522
+
523
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
524
+
525
+ await waitFor(() => {
526
+ const content = screen.getByRole("dialog");
527
+ expect(content).toHaveAttribute("aria-modal", "true");
528
+ });
529
+ });
530
+
531
+ it("should use header title as aria-label", async () => {
532
+ const user = userEvent.setup({ delay: null });
533
+ render(
534
+ <Popover>
535
+ <PopoverTrigger>
536
+ <Button variant="primary">Trigger</Button>
537
+ </PopoverTrigger>
538
+ <PopoverContent closeIconAriaLabel="Close" title="Important Info" body="Body text" />
539
+ </Popover>
540
+ );
541
+
542
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
543
+
544
+ await waitFor(() => {
545
+ const content = screen.getByRole("dialog");
546
+ expect(content).toHaveAttribute("aria-labelledby", "popover-heading");
547
+ const heading = document.getElementById("popover-heading");
548
+ expect(heading).toHaveTextContent("Important Info");
549
+ });
550
+ });
551
+
552
+ it("should use custom aria-label when provided", async () => {
553
+ const user = userEvent.setup({ delay: null });
554
+ render(
555
+ <Popover>
556
+ <PopoverTrigger>
557
+ <Button variant="primary">Trigger</Button>
558
+ </PopoverTrigger>
559
+ <PopoverContent
560
+ closeIconAriaLabel="Close"
561
+ title="Title"
562
+ body="Body text"
563
+ aria-label="Custom label"
564
+ />
565
+ </Popover>
566
+ );
567
+
568
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
569
+
570
+ await waitFor(() => {
571
+ const content = screen.getByRole("dialog");
572
+ expect(content).toHaveAttribute("aria-label", "Custom label");
573
+ });
574
+ });
575
+
576
+ it("should have aria-hidden on arrow", async () => {
577
+ const user = userEvent.setup({ delay: null });
578
+ render(
579
+ <Popover>
580
+ <PopoverTrigger>
581
+ <Button variant="primary">Trigger</Button>
582
+ </PopoverTrigger>
583
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text" />
584
+ </Popover>
585
+ );
586
+
587
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
588
+
589
+ await waitFor(() => {
590
+ const arrow = document.querySelector(".purpur-popover__arrow");
591
+ expect(arrow).toHaveAttribute("aria-hidden", "true");
592
+ });
593
+ });
594
+
595
+ it("should pass accessibility checks for content with header and footer", async () => {
596
+ const user = userEvent.setup({ delay: null });
597
+ render(
598
+ <Popover>
599
+ <PopoverTrigger>
600
+ <Button variant="primary">Trigger</Button>
601
+ </PopoverTrigger>
602
+ <PopoverContent closeIconAriaLabel="Close" title="Test Title" body="Body text">
603
+ <PopoverFooter>
604
+ <PopoverButton>Got it</PopoverButton>
605
+ </PopoverFooter>
606
+ </PopoverContent>
607
+ </Popover>
608
+ );
609
+
610
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
611
+
612
+ await waitFor(() => {
613
+ expect(screen.getByText("Test Title")).toBeInTheDocument();
614
+ });
615
+
616
+ // Test only the content dialog, not the trigger which has known ARIA issues
617
+ const dialog = screen.getByRole("dialog");
618
+ const results = await axe(dialog);
619
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
620
+ // @ts-ignore
621
+ expect(results).toHaveNoViolations();
622
+ });
623
+
624
+ it("should pass accessibility checks for content with icon", async () => {
625
+ const user = userEvent.setup({ delay: null });
626
+ render(
627
+ <Popover>
628
+ <PopoverTrigger>
629
+ <Button variant="primary">Trigger</Button>
630
+ </PopoverTrigger>
631
+ <PopoverContent
632
+ closeIconAriaLabel="Close popover"
633
+ title="Information"
634
+ icon={<IconInfo aria-hidden="true" />}
635
+ body="This is important information"
636
+ />
637
+ </Popover>
638
+ );
639
+
640
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
641
+
642
+ await waitFor(() => {
643
+ expect(screen.getByText("Information")).toBeInTheDocument();
644
+ });
645
+
646
+ const dialog = screen.getByRole("dialog");
647
+ const results = await axe(dialog);
648
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
649
+ // @ts-ignore
650
+ expect(results).toHaveNoViolations();
651
+ });
652
+
653
+ it("should pass accessibility checks for negative variant", async () => {
654
+ const user = userEvent.setup({ delay: null });
655
+ render(
656
+ <Popover>
657
+ <PopoverTrigger>
658
+ <Button variant="primary">Trigger</Button>
659
+ </PopoverTrigger>
660
+ <PopoverContent
661
+ closeIconAriaLabel="Close"
662
+ title="Warning"
663
+ body="This is a warning message"
664
+ negative
665
+ />
666
+ </Popover>
667
+ );
668
+
669
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
670
+
671
+ await waitFor(() => {
672
+ expect(screen.getByText("Warning")).toBeInTheDocument();
673
+ });
674
+
675
+ const dialog = screen.getByRole("dialog");
676
+ const results = await axe(dialog);
677
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
678
+ // @ts-ignore
679
+ expect(results).toHaveNoViolations();
680
+ });
681
+
682
+ it("should have proper keyboard navigation", async () => {
683
+ const user = userEvent.setup({ delay: null });
684
+ render(
685
+ <Popover>
686
+ <PopoverTrigger>
687
+ <Button variant="primary">Trigger</Button>
688
+ </PopoverTrigger>
689
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text">
690
+ <PopoverFooter>
691
+ <PopoverButton>Action</PopoverButton>
692
+ </PopoverFooter>
693
+ </PopoverContent>
694
+ </Popover>
695
+ );
696
+
697
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
698
+
699
+ await waitFor(() => {
700
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
701
+ });
702
+
703
+ // Check that close button is focusable
704
+ const closeButton = screen.getByRole("button", { name: "Close" });
705
+ expect(closeButton).toHaveAttribute("type", "button");
706
+ expect(closeButton).not.toHaveAttribute("disabled");
707
+
708
+ // Check that action button is focusable
709
+ const actionButton = screen.getByRole("button", { name: "Action" });
710
+ expect(actionButton).toHaveAttribute("type", "button");
711
+ expect(actionButton).not.toHaveAttribute("disabled");
712
+ });
713
+
714
+ it("should have descriptive labels for screen readers", async () => {
715
+ const user = userEvent.setup({ delay: null });
716
+ render(
717
+ <Popover>
718
+ <PopoverTrigger>
719
+ <Button variant="primary">Trigger</Button>
720
+ </PopoverTrigger>
721
+ <PopoverContent
722
+ closeIconAriaLabel="Close information dialog"
723
+ title="Important Information"
724
+ body="This is the content"
725
+ aria-label="Important Information"
726
+ />
727
+ </Popover>
728
+ );
729
+
730
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
731
+
732
+ await waitFor(() => {
733
+ const dialog = screen.getByRole("dialog");
734
+ expect(dialog).toHaveAttribute("aria-label", "Important Information");
735
+ expect(dialog).toHaveAttribute("aria-modal", "true");
736
+ });
737
+
738
+ const closeButton = screen.getByRole("button", { name: "Close information dialog" });
739
+ expect(closeButton).toBeInTheDocument();
740
+ });
741
+
742
+ it("should pass accessibility checks for walkthrough mode", async () => {
743
+ render(
744
+ <PopoverFlow
745
+ separatorText="of"
746
+ stepText="Step"
747
+ backLabel="Back"
748
+ nextLabel="Next"
749
+ finishLabel="Finish"
750
+ >
751
+ <Popover multistep step={1}>
752
+ <PopoverTrigger>
753
+ <Button variant="primary">Step 1</Button>
754
+ </PopoverTrigger>
755
+ <PopoverContent closeIconAriaLabel="Close" title="Step 1" body="First step content" />
756
+ </Popover>
757
+ <Popover multistep step={2}>
758
+ <PopoverTrigger>
759
+ <Button variant="primary">Step 2</Button>
760
+ </PopoverTrigger>
761
+ <PopoverContent closeIconAriaLabel="Close" title="Step 2" body="Second step content" />
762
+ </Popover>
763
+ </PopoverFlow>
764
+ );
765
+
766
+ await waitFor(
767
+ () => {
768
+ expect(screen.getByText("First step content")).toBeInTheDocument();
769
+ },
770
+ { timeout: 5000 }
771
+ );
772
+
773
+ const dialog = screen.getByRole("dialog");
774
+ const results = await axe(dialog);
775
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
776
+ // @ts-ignore
777
+ expect(results).toHaveNoViolations();
778
+
779
+ // Check that step indicator is announced to screen readers
780
+ const srOnlyStepText = document.querySelector('[role="status"][aria-live="polite"]');
781
+ expect(srOnlyStepText).toBeInTheDocument();
782
+ expect(srOnlyStepText).toHaveTextContent("Step 1 of 2");
783
+ });
784
+
785
+ it("should maintain focus trap within dialog", async () => {
786
+ const user = userEvent.setup({ delay: null });
787
+ render(
788
+ <Popover>
789
+ <PopoverTrigger>
790
+ <Button variant="primary">Trigger</Button>
791
+ </PopoverTrigger>
792
+ <PopoverContent closeIconAriaLabel="Close" title="Test" body="Body text">
793
+ <PopoverFooter>
794
+ <PopoverButton>Action 1</PopoverButton>
795
+ </PopoverFooter>
796
+ </PopoverContent>
797
+ </Popover>
798
+ );
799
+
800
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
801
+
802
+ await waitFor(() => {
803
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
804
+ });
805
+
806
+ // Verify focus guards exist (provided by Radix UI)
807
+ const focusGuards = document.querySelectorAll("[data-radix-focus-guard]");
808
+ expect(focusGuards.length).toBeGreaterThan(0);
809
+ });
810
+ });
811
+
812
+ describe("Walkthrough Mode", () => {
813
+ it("should auto-generate footer for first step", async () => {
814
+ const onAction = vi.fn();
815
+ render(
816
+ <PopoverFlow
817
+ separatorText="of"
818
+ stepText="Step"
819
+ backLabel="Back"
820
+ nextLabel="Next"
821
+ finishLabel="Finish"
822
+ >
823
+ <Popover multistep step={1}>
824
+ <PopoverTrigger>
825
+ <Button variant="primary">Step 1</Button>
826
+ </PopoverTrigger>
827
+ <PopoverContent
828
+ closeIconAriaLabel="Close"
829
+ title="Step 1"
830
+ body="First step"
831
+ onAction={onAction}
832
+ />
833
+ </Popover>
834
+ <Popover multistep step={2}>
835
+ <PopoverTrigger>
836
+ <Button variant="primary">Step 2</Button>
837
+ </PopoverTrigger>
838
+ <PopoverContent closeIconAriaLabel="Close" title="Step 2" body="Second step" />
839
+ </Popover>
840
+ </PopoverFlow>
841
+ );
842
+
843
+ await waitFor(
844
+ () => {
845
+ expect(screen.getByText("First step")).toBeInTheDocument();
846
+ },
847
+ { timeout: 5000 }
848
+ );
849
+
850
+ expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
851
+ expect(screen.queryByRole("button", { name: "Back" })).not.toBeInTheDocument();
852
+ });
853
+
854
+ it("should auto-generate footer for middle step", async () => {
855
+ render(
856
+ <PopoverFlow
857
+ separatorText="of"
858
+ stepText="Step"
859
+ backLabel="Back"
860
+ nextLabel="Next"
861
+ finishLabel="Finish"
862
+ initialStep={2}
863
+ >
864
+ <Popover multistep step={1}>
865
+ <PopoverTrigger>
866
+ <Button variant="primary">Step 1</Button>
867
+ </PopoverTrigger>
868
+ <PopoverContent closeIconAriaLabel="Close" title="Step 1" body="First step" />
869
+ </Popover>
870
+ <Popover multistep step={2}>
871
+ <PopoverTrigger>
872
+ <Button variant="primary">Step 2</Button>
873
+ </PopoverTrigger>
874
+ <PopoverContent closeIconAriaLabel="Close" title="Step 2" body="Second step" />
875
+ </Popover>
876
+ <Popover multistep step={3}>
877
+ <PopoverTrigger>
878
+ <Button variant="primary">Step 3</Button>
879
+ </PopoverTrigger>
880
+ <PopoverContent closeIconAriaLabel="Close" title="Step 3" body="Third step" />
881
+ </Popover>
882
+ </PopoverFlow>
883
+ );
884
+
885
+ await waitFor(
886
+ () => {
887
+ expect(screen.getByText("Second step")).toBeInTheDocument();
888
+ },
889
+ { timeout: 5000 }
890
+ );
891
+
892
+ expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
893
+ expect(screen.getByRole("button", { name: "Back" })).toBeInTheDocument();
894
+ });
895
+
896
+ it("should auto-generate footer for last step", async () => {
897
+ render(
898
+ <PopoverFlow
899
+ separatorText="of"
900
+ stepText="Step"
901
+ backLabel="Back"
902
+ nextLabel="Next"
903
+ finishLabel="Finish"
904
+ initialStep={2}
905
+ >
906
+ <Popover multistep step={1}>
907
+ <PopoverTrigger>
908
+ <Button variant="primary">Step 1</Button>
909
+ </PopoverTrigger>
910
+ <PopoverContent closeIconAriaLabel="Close" title="Step 1" body="First step" />
911
+ </Popover>
912
+ <Popover multistep step={2}>
913
+ <PopoverTrigger>
914
+ <Button variant="primary">Step 2</Button>
915
+ </PopoverTrigger>
916
+ <PopoverContent closeIconAriaLabel="Close" title="Step 2" body="Second step" />
917
+ </Popover>
918
+ </PopoverFlow>
919
+ );
920
+
921
+ await waitFor(
922
+ () => {
923
+ expect(screen.getByText("Second step")).toBeInTheDocument();
924
+ },
925
+ { timeout: 5000 }
926
+ );
927
+
928
+ expect(screen.getByRole("button", { name: "Finish" })).toBeInTheDocument();
929
+ expect(screen.getByRole("button", { name: "Back" })).toBeInTheDocument();
930
+ });
931
+
932
+ it("should call onAction with correct step on back click", async () => {
933
+ const onAction = vi.fn();
934
+ const user = userEvent.setup({ delay: null });
935
+ render(
936
+ <PopoverFlow
937
+ separatorText="of"
938
+ stepText="Step"
939
+ backLabel="Back"
940
+ nextLabel="Next"
941
+ finishLabel="Finish"
942
+ initialStep={2}
943
+ >
944
+ <Popover multistep step={1}>
945
+ <PopoverTrigger>
946
+ <Button variant="primary">Step 1</Button>
947
+ </PopoverTrigger>
948
+ <PopoverContent closeIconAriaLabel="Close" title="Step 1" body="First step" />
949
+ </Popover>
950
+ <Popover multistep step={2}>
951
+ <PopoverTrigger>
952
+ <Button variant="primary">Step 2</Button>
953
+ </PopoverTrigger>
954
+ <PopoverContent
955
+ closeIconAriaLabel="Close"
956
+ title="Step 2"
957
+ body="Second step"
958
+ onAction={onAction}
959
+ />
960
+ </Popover>
961
+ </PopoverFlow>
962
+ );
963
+
964
+ await waitFor(
965
+ () => {
966
+ expect(screen.getByRole("button", { name: "Back" })).toBeInTheDocument();
967
+ },
968
+ { timeout: 5000 }
969
+ );
970
+
971
+ await user.click(screen.getByRole("button", { name: "Back" }));
972
+
973
+ expect(onAction).toHaveBeenCalledWith({ type: "back", step: 2 });
974
+ });
975
+
976
+ it("should call onAction with correct step on next click", async () => {
977
+ const onAction = vi.fn();
978
+ const user = userEvent.setup({ delay: null });
979
+ render(
980
+ <PopoverFlow
981
+ separatorText="of"
982
+ stepText="Step"
983
+ backLabel="Back"
984
+ nextLabel="Next"
985
+ finishLabel="Finish"
986
+ >
987
+ <Popover multistep step={1}>
988
+ <PopoverTrigger>
989
+ <Button variant="primary">Step 1</Button>
990
+ </PopoverTrigger>
991
+ <PopoverContent
992
+ closeIconAriaLabel="Close"
993
+ title="Step 1"
994
+ body="First step"
995
+ onAction={onAction}
996
+ />
997
+ </Popover>
998
+ <Popover multistep step={2}>
999
+ <PopoverTrigger>
1000
+ <Button variant="primary">Step 2</Button>
1001
+ </PopoverTrigger>
1002
+ <PopoverContent closeIconAriaLabel="Close" title="Step 2" body="Second step" />
1003
+ </Popover>
1004
+ </PopoverFlow>
1005
+ );
1006
+
1007
+ await waitFor(
1008
+ () => {
1009
+ expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
1010
+ },
1011
+ { timeout: 5000 }
1012
+ );
1013
+
1014
+ await user.click(screen.getByRole("button", { name: "Next" }));
1015
+
1016
+ expect(onAction).toHaveBeenCalledWith({ type: "next", step: 1 });
1017
+ });
1018
+
1019
+ it("should call onAction with correct step on finish click", async () => {
1020
+ const onAction = vi.fn();
1021
+ const user = userEvent.setup({ delay: null });
1022
+ render(
1023
+ <PopoverFlow
1024
+ separatorText="of"
1025
+ stepText="Step"
1026
+ backLabel="Back"
1027
+ nextLabel="Next"
1028
+ finishLabel="Finish"
1029
+ initialStep={2}
1030
+ >
1031
+ <Popover multistep step={1}>
1032
+ <PopoverTrigger>
1033
+ <Button variant="primary">Step 1</Button>
1034
+ </PopoverTrigger>
1035
+ <PopoverContent closeIconAriaLabel="Close" title="Step 1" body="First step" />
1036
+ </Popover>
1037
+ <Popover multistep step={2}>
1038
+ <PopoverTrigger>
1039
+ <Button variant="primary">Step 2</Button>
1040
+ </PopoverTrigger>
1041
+ <PopoverContent
1042
+ closeIconAriaLabel="Close"
1043
+ title="Step 2"
1044
+ body="Second step"
1045
+ onAction={onAction}
1046
+ />
1047
+ </Popover>
1048
+ </PopoverFlow>
1049
+ );
1050
+
1051
+ await waitFor(
1052
+ () => {
1053
+ expect(screen.getByRole("button", { name: "Finish" })).toBeInTheDocument();
1054
+ },
1055
+ { timeout: 5000 }
1056
+ );
1057
+
1058
+ await user.click(screen.getByRole("button", { name: "Finish" }));
1059
+
1060
+ expect(onAction).toHaveBeenCalledWith({ type: "finish", step: 2 });
1061
+ });
1062
+
1063
+ it("should not override custom footer in walkthrough mode", async () => {
1064
+ render(
1065
+ <PopoverFlow
1066
+ separatorText="of"
1067
+ stepText="Step"
1068
+ backLabel="Back"
1069
+ nextLabel="Next"
1070
+ finishLabel="Finish"
1071
+ >
1072
+ <Popover multistep step={1}>
1073
+ <PopoverTrigger>
1074
+ <Button variant="primary">Step 1</Button>
1075
+ </PopoverTrigger>
1076
+ <PopoverContent closeIconAriaLabel="Close" title="Step 1" body="First step">
1077
+ <PopoverFooter>
1078
+ <PopoverButton>Custom Footer</PopoverButton>
1079
+ </PopoverFooter>
1080
+ </PopoverContent>
1081
+ </Popover>
1082
+ <Popover multistep step={2}>
1083
+ <PopoverTrigger>
1084
+ <Button variant="primary">Step 2</Button>
1085
+ </PopoverTrigger>
1086
+ <PopoverContent closeIconAriaLabel="Close" title="Step 2" body="Second step" />
1087
+ </Popover>
1088
+ </PopoverFlow>
1089
+ );
1090
+
1091
+ await waitFor(
1092
+ () => {
1093
+ expect(screen.getByRole("button", { name: "Custom Footer" })).toBeInTheDocument();
1094
+ },
1095
+ { timeout: 5000 }
1096
+ );
1097
+
1098
+ expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument();
1099
+ });
1100
+ });
1101
+
1102
+ describe("Forward Ref", () => {
1103
+ it("should forward ref to content element", async () => {
1104
+ const ref = React.createRef<HTMLDivElement>();
1105
+ const user = userEvent.setup({ delay: null });
1106
+ render(
1107
+ <Popover>
1108
+ <PopoverTrigger>
1109
+ <Button variant="primary">Trigger</Button>
1110
+ </PopoverTrigger>
1111
+ <PopoverContent ref={ref} closeIconAriaLabel="Close" title="Test" body="Body text" />
1112
+ </Popover>
1113
+ );
1114
+
1115
+ await user.click(screen.getByRole("button", { name: "Trigger" }));
1116
+
1117
+ await waitFor(() => {
1118
+ expect(ref.current).toBeInstanceOf(HTMLElement);
1119
+ });
1120
+ });
1121
+ });
1122
+ });