@vvedantb/ui 0.4.3 → 0.5.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.
@@ -1,7 +1,13 @@
1
1
  import * as React from "react";
2
2
  import { Command as CommandPrimitive } from "cmdk";
3
3
  import { IconSearch } from "@tabler/icons-react";
4
- import { Dialog, DialogContent } from "./dialog";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "./dialog";
5
11
  import { cn } from "../utils/cn";
6
12
 
7
13
  const Command = React.forwardRef<
@@ -19,16 +25,50 @@ const Command = React.forwardRef<
19
25
  ));
20
26
  Command.displayName = CommandPrimitive.displayName;
21
27
 
28
+ // The list is pinned rather than `max-h`: a palette that resizes on every
29
+ // keystroke shifts the whole dialog under the cursor.
30
+ const commandDialogPinnedClass =
31
+ "[&_[cmdk-list]]:h-80 [&_[cmdk-list]]:max-h-[60dvh] [&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-widest [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-1.5 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-11 [&_[cmdk-item]]:rounded-md [&_[cmdk-item]]:px-2.5 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4";
32
+
33
+ // The list sizes itself and items keep the caller's styling.
34
+ const commandDialogAutoClass =
35
+ "[&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-widest [&_[cmdk-group-heading]]:text-muted [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-1.5 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-11 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"; // check-tokens-allow: text-muted
36
+
37
+ interface CommandDialogProps extends React.ComponentProps<typeof Dialog> {
38
+ /** Screen-reader title. With `description`, adds a visually hidden header. */
39
+ title?: string;
40
+ /** Screen-reader description. With `title`, adds a visually hidden header. */
41
+ description?: string;
42
+ /** Merged onto the dialog content. */
43
+ className?: string;
44
+ /** Passed to `Command`. Set `false` to filter items yourself. */
45
+ shouldFilter?: boolean;
46
+ /** `"pinned"` (default) fixes the list height; `"auto"` lets it size itself. */
47
+ listLayout?: "pinned" | "auto";
48
+ }
49
+
22
50
  function CommandDialog({
23
51
  children,
52
+ title,
53
+ description,
54
+ className,
55
+ shouldFilter,
56
+ listLayout = "pinned",
24
57
  ...props
25
- }: React.ComponentProps<typeof Dialog>) {
58
+ }: CommandDialogProps) {
26
59
  return (
27
60
  <Dialog {...props}>
28
- <DialogContent className="overflow-hidden p-0 sm:max-w-xl">
29
- {/* The list is pinned rather than `max-h`: a palette that resizes on
30
- every keystroke shifts the whole dialog under the cursor. */}
31
- <Command className="[&_[cmdk-list]]:h-80 [&_[cmdk-list]]:max-h-[60dvh] [&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-widest [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-1.5 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-11 [&_[cmdk-item]]:rounded-md [&_[cmdk-item]]:px-2.5 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4">
61
+ <DialogContent className={cn("overflow-hidden p-0 sm:max-w-xl", className)}>
62
+ {title !== undefined || description !== undefined ? (
63
+ <DialogHeader className="sr-only">
64
+ <DialogTitle>{title}</DialogTitle>
65
+ <DialogDescription>{description}</DialogDescription>
66
+ </DialogHeader>
67
+ ) : null}
68
+ <Command
69
+ shouldFilter={shouldFilter}
70
+ className={listLayout === "auto" ? commandDialogAutoClass : commandDialogPinnedClass}
71
+ >
32
72
  {children}
33
73
  </Command>
34
74
  </DialogContent>
@@ -15,6 +15,7 @@ import {
15
15
  menuShortcutClass,
16
16
  menuSubTriggerClass,
17
17
  } from "./_menu-classes";
18
+ import { MenuItemRow, SubTriggerRow, suppressAltKeyDown } from "./_menu-parts";
18
19
 
19
20
  const ContextMenu = ContextMenuPrimitive.Root;
20
21
  const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
@@ -27,14 +28,18 @@ const ContextMenuSubTrigger = React.forwardRef<
27
28
  React.ComponentRef<typeof ContextMenuPrimitive.SubTrigger>,
28
29
  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
29
30
  inset?: boolean;
31
+ /** Leading icon or mark. The label then fills the row. */
32
+ startContent?: React.ReactNode;
30
33
  }
31
- >(({ className, inset, children, ...props }, ref) => (
34
+ >(({ className, inset, startContent, children, ...props }, ref) => (
32
35
  <ContextMenuPrimitive.SubTrigger
33
36
  ref={ref}
34
37
  className={cn(menuSubTriggerClass, inset && "pl-8", className)}
35
38
  {...props}
36
39
  >
37
- {children}
40
+ {startContent === undefined ? children : (
41
+ <SubTriggerRow startContent={startContent}>{children}</SubTriggerRow>
42
+ )}
38
43
  <IconChevronRight className="ml-auto" />
39
44
  </ContextMenuPrimitive.SubTrigger>
40
45
  ));
@@ -54,12 +59,16 @@ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
54
59
 
55
60
  const ContextMenuContent = React.forwardRef<
56
61
  React.ComponentRef<typeof ContextMenuPrimitive.Content>,
57
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
58
- >(({ className, ...props }, ref) => (
62
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
63
+ /** Stop a bare Alt press from moving focus (e.g. into the browser menu bar). */
64
+ suppressAltKey?: boolean;
65
+ }
66
+ >(({ className, suppressAltKey, onKeyDown, ...props }, ref) => (
59
67
  <ContextMenuPrimitive.Portal>
60
68
  <ContextMenuPrimitive.Content
61
69
  ref={ref}
62
70
  className={cn(menuContentClass, "w-40", className)}
71
+ onKeyDown={suppressAltKey ? suppressAltKeyDown(onKeyDown) : onKeyDown}
63
72
  {...props}
64
73
  />
65
74
  </ContextMenuPrimitive.Portal>
@@ -70,14 +79,34 @@ const ContextMenuItem = React.forwardRef<
70
79
  React.ComponentRef<typeof ContextMenuPrimitive.Item>,
71
80
  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
72
81
  inset?: boolean;
82
+ /** Leading icon or mark. Ignored with `asChild`. */
83
+ startContent?: React.ReactNode;
84
+ /** Second line under the label. Ignored with `asChild`. */
85
+ description?: React.ReactNode;
86
+ /** Trailing content. Ignored with `asChild`. */
87
+ endContent?: React.ReactNode;
73
88
  }
74
- >(({ className, inset, ...props }, ref) => (
75
- <ContextMenuPrimitive.Item
76
- ref={ref}
77
- className={cn(menuItemClass, inset && "pl-8", className)}
78
- {...props}
79
- />
80
- ));
89
+ >(
90
+ (
91
+ { className, inset, startContent, description, endContent, children, asChild, ...props },
92
+ ref,
93
+ ) => (
94
+ <ContextMenuPrimitive.Item
95
+ ref={ref}
96
+ asChild={asChild}
97
+ className={cn(menuItemClass, inset && "pl-8", className)}
98
+ {...props}
99
+ >
100
+ {asChild || (startContent === undefined && description === undefined && endContent === undefined) ? (
101
+ children
102
+ ) : (
103
+ <MenuItemRow startContent={startContent} description={description} endContent={endContent}>
104
+ {children}
105
+ </MenuItemRow>
106
+ )}
107
+ </ContextMenuPrimitive.Item>
108
+ ),
109
+ );
81
110
  ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
82
111
 
83
112
  const ContextMenuCheckboxItem = React.forwardRef<
@@ -15,6 +15,13 @@ import {
15
15
  menuShortcutClass,
16
16
  menuSubTriggerClass,
17
17
  } from "./_menu-classes";
18
+ import {
19
+ fitViewportClass,
20
+ MenuItemRow,
21
+ SubTriggerRow,
22
+ suppressAltKeyDown,
23
+ } from "./_menu-parts";
24
+ import { restoreModalScrollLock } from "./overlay-a11y";
18
25
 
19
26
  const DropdownMenu = DropdownMenuPrimitive.Root;
20
27
  const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -27,14 +34,18 @@ const DropdownMenuSubTrigger = React.forwardRef<
27
34
  React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
28
35
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
29
36
  inset?: boolean;
37
+ /** Leading icon or mark. The label then fills the row. */
38
+ startContent?: React.ReactNode;
30
39
  }
31
- >(({ className, inset, children, ...props }, ref) => (
40
+ >(({ className, inset, startContent, children, ...props }, ref) => (
32
41
  <DropdownMenuPrimitive.SubTrigger
33
42
  ref={ref}
34
43
  className={cn(menuSubTriggerClass, inset && "pl-8", className)}
35
44
  {...props}
36
45
  >
37
- {children}
46
+ {startContent === undefined ? children : (
47
+ <SubTriggerRow startContent={startContent}>{children}</SubTriggerRow>
48
+ )}
38
49
  <IconChevronRight className="ml-auto" />
39
50
  </DropdownMenuPrimitive.SubTrigger>
40
51
  ));
@@ -58,17 +69,48 @@ DropdownMenuSubContent.displayName =
58
69
 
59
70
  const DropdownMenuContent = React.forwardRef<
60
71
  React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
61
- React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
62
- >(({ className, sideOffset = 4, ...props }, ref) => (
63
- <DropdownMenuPrimitive.Portal>
64
- <DropdownMenuPrimitive.Content
65
- ref={ref}
66
- sideOffset={sideOffset}
67
- className={cn(menuContentClass, className)}
68
- {...props}
69
- />
70
- </DropdownMenuPrimitive.Portal>
71
- ));
72
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
73
+ /** On small screens, cap the height to the viewport and scroll. */
74
+ fitViewport?: boolean;
75
+ /** Stop a bare Alt press from moving focus (e.g. into the browser menu bar). */
76
+ suppressAltKey?: boolean;
77
+ /** On close, clear any body scroll lock left behind when no dialog is open. */
78
+ restoreScrollLockOnClose?: boolean;
79
+ }
80
+ >(
81
+ (
82
+ {
83
+ className,
84
+ sideOffset = 4,
85
+ fitViewport,
86
+ suppressAltKey,
87
+ restoreScrollLockOnClose,
88
+ onKeyDown,
89
+ onCloseAutoFocus,
90
+ ...props
91
+ },
92
+ ref,
93
+ ) => (
94
+ <DropdownMenuPrimitive.Portal>
95
+ <DropdownMenuPrimitive.Content
96
+ ref={ref}
97
+ sideOffset={sideOffset}
98
+ className={cn(menuContentClass, fitViewport && fitViewportClass, className)}
99
+ onKeyDown={suppressAltKey ? suppressAltKeyDown(onKeyDown) : onKeyDown}
100
+ onCloseAutoFocus={
101
+ restoreScrollLockOnClose
102
+ ? (event) => {
103
+ restoreModalScrollLock();
104
+ window.setTimeout(restoreModalScrollLock, 0);
105
+ onCloseAutoFocus?.(event);
106
+ }
107
+ : onCloseAutoFocus
108
+ }
109
+ {...props}
110
+ />
111
+ </DropdownMenuPrimitive.Portal>
112
+ ),
113
+ );
72
114
  DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
73
115
 
74
116
  const DropdownMenuItem = React.forwardRef<
@@ -79,10 +121,12 @@ const DropdownMenuItem = React.forwardRef<
79
121
  startContent?: React.ReactNode;
80
122
  /** Second line under the label. Ignored with `asChild`. */
81
123
  description?: React.ReactNode;
124
+ /** Trailing content. The label then fills the row. Ignored with `asChild`. */
125
+ endContent?: React.ReactNode;
82
126
  }
83
127
  >(
84
128
  (
85
- { className, inset, startContent, description, children, asChild, ...props },
129
+ { className, inset, startContent, description, endContent, children, asChild, ...props },
86
130
  ref,
87
131
  ) => (
88
132
  <DropdownMenuPrimitive.Item
@@ -91,8 +135,12 @@ const DropdownMenuItem = React.forwardRef<
91
135
  className={cn(menuItemClass, inset && "pl-8", className)}
92
136
  {...props}
93
137
  >
94
- {asChild || (startContent === undefined && description === undefined) ? (
138
+ {asChild || (startContent === undefined && description === undefined && endContent === undefined) ? (
95
139
  children
140
+ ) : endContent !== undefined ? (
141
+ <MenuItemRow startContent={startContent} description={description} endContent={endContent}>
142
+ {children}
143
+ </MenuItemRow>
96
144
  ) : (
97
145
  <>
98
146
  {startContent}
@@ -32,6 +32,25 @@ export function restoreBodyPointerEvents(): void {
32
32
  }
33
33
  }
34
34
 
35
+ /**
36
+ * Clears the body pointer-events and scroll lock a closing modal menu can leave
37
+ * behind. Keeps the scroll lock while any dialog or alert dialog is still open.
38
+ */
39
+ export function restoreModalScrollLock(): void {
40
+ if (typeof document === "undefined") return;
41
+ document.body.style.removeProperty("pointer-events");
42
+ if (
43
+ document.querySelector(
44
+ '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]',
45
+ )
46
+ ) {
47
+ return;
48
+ }
49
+ document.body.style.removeProperty("overflow");
50
+ document.body.style.removeProperty("padding-right");
51
+ document.documentElement.style.removeProperty("overflow");
52
+ }
53
+
35
54
  export function connectBodyPointerEventsFix(): () => void {
36
55
  restoreBodyPointerEvents();
37
56
  const observer = new MutationObserver(() => restoreBodyPointerEvents());
@@ -5,23 +5,33 @@ import {
5
5
  floatingSurfaceAnimateClass,
6
6
  floatingSurfaceClass,
7
7
  } from "./_menu-classes";
8
+ import { fitViewportClass } from "./_menu-parts";
8
9
 
9
10
  const Popover = PopoverPrimitive.Root;
10
11
  const PopoverTrigger = PopoverPrimitive.Trigger;
11
12
 
12
13
  const PopoverContent = React.forwardRef<
13
14
  React.ComponentRef<typeof PopoverPrimitive.Content>,
14
- React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
15
- >(({ className, align = "center", sideOffset = 6, ...props }, ref) => (
15
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
16
+ /**
17
+ * Grow from the trigger, keep 8px from the viewport edge (unless
18
+ * `collisionPadding` is set) and, on small screens, cap width and height.
19
+ */
20
+ fitViewport?: boolean;
21
+ }
22
+ >(({ className, align = "center", sideOffset = 6, fitViewport, collisionPadding, ...props }, ref) => (
16
23
  <PopoverPrimitive.Portal>
17
24
  <PopoverPrimitive.Content
18
25
  ref={ref}
19
26
  align={align}
20
27
  sideOffset={sideOffset}
28
+ collisionPadding={fitViewport ? (collisionPadding ?? 8) : collisionPadding}
21
29
  className={cn(
22
30
  "z-50 w-72 max-sm:max-w-[calc(100vw-2rem)] max-sm:max-h-[85dvh] max-sm:overflow-y-auto p-4 outline-none",
23
31
  floatingSurfaceClass,
24
32
  floatingSurfaceAnimateClass,
33
+ fitViewport &&
34
+ `origin-[var(--radix-popper-transform-origin)] max-sm:max-w-[calc(100vw-1.5rem)] ${fitViewportClass}`,
25
35
  className,
26
36
  )}
27
37
  {...props}
@@ -0,0 +1,58 @@
1
+ import type * as React from "react";
2
+ import { cn } from "../utils/cn";
3
+ import { Label } from "./label";
4
+ import { Select, SelectContent, SelectTrigger, SelectValue } from "./select";
5
+
6
+ export interface SelectFieldProps {
7
+ /** Merged onto the trigger. */
8
+ className?: string;
9
+ /** `SelectItem` elements. */
10
+ children?: React.ReactNode;
11
+ label?: React.ReactNode;
12
+ placeholder?: string;
13
+ /** An empty string shows the placeholder. */
14
+ value?: string;
15
+ onValueChange?: (value: string) => void;
16
+ disabled?: boolean;
17
+ name?: string;
18
+ /** Set on the trigger; the label points at it. */
19
+ id?: string;
20
+ "aria-label"?: string;
21
+ }
22
+
23
+ /** Labelled single select built from the `Select` primitives. */
24
+ function SelectField({
25
+ className,
26
+ children,
27
+ label,
28
+ placeholder,
29
+ value,
30
+ onValueChange,
31
+ disabled,
32
+ name,
33
+ id,
34
+ "aria-label": ariaLabel,
35
+ }: SelectFieldProps) {
36
+ return (
37
+ <div className="flex flex-col gap-1.5">
38
+ {label ? (
39
+ <Label htmlFor={id} className="text-sm font-medium leading-none">
40
+ {label}
41
+ </Label>
42
+ ) : null}
43
+ <Select
44
+ value={value === undefined || value === "" ? undefined : value}
45
+ onValueChange={onValueChange}
46
+ disabled={disabled}
47
+ name={name}
48
+ >
49
+ <SelectTrigger id={id} aria-label={ariaLabel} className={cn("w-full", className)}>
50
+ <SelectValue placeholder={placeholder} />
51
+ </SelectTrigger>
52
+ <SelectContent>{children}</SelectContent>
53
+ </Select>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export { SelectField };
@@ -1,256 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { act, createRef } from "react";
3
- import { createRoot, type Root } from "react-dom/client";
4
- import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { SessionsHoverCard } from "./SessionsHoverCard";
6
-
7
- (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
-
9
- let root: Root | null = null;
10
- let host: HTMLElement | null = null;
11
-
12
- async function mount(node: React.ReactNode) {
13
- host = document.createElement("div");
14
- document.body.appendChild(host);
15
- root = createRoot(host);
16
- await act(async () => {
17
- root!.render(node);
18
- });
19
- }
20
-
21
- afterEach(async () => {
22
- await act(async () => root?.unmount());
23
- host?.remove();
24
- document.body.innerHTML = "";
25
- root = null;
26
- host = null;
27
- });
28
-
29
- describe("SessionsHoverCard", () => {
30
- it("renders header, body and footer slots when open", async () => {
31
- await mount(
32
- <SessionsHoverCard open header="Head" body="Body text" footer="Foot">
33
- <button type="button">Row</button>
34
- </SessionsHoverCard>,
35
- );
36
- const text = document.body.textContent ?? "";
37
- expect(text).toContain("Row");
38
- expect(text).toContain("Head");
39
- expect(text).toContain("Body text");
40
- expect(text).toContain("Foot");
41
- expect(document.body.innerHTML).toContain("w-64");
42
- });
43
-
44
- it("does not call the body render prop or loadContent while closed", async () => {
45
- const body = vi.fn(() => "x");
46
- const loadContent = vi.fn(() => "y");
47
- await mount(
48
- <>
49
- <SessionsHoverCard open={false} body={body}>
50
- <button type="button">A</button>
51
- </SessionsHoverCard>
52
- <SessionsHoverCard open={false} loadContent={loadContent}>
53
- <button type="button">B</button>
54
- </SessionsHoverCard>
55
- </>,
56
- );
57
- expect(body).not.toHaveBeenCalled();
58
- expect(loadContent).not.toHaveBeenCalled();
59
- });
60
-
61
- it("calls the render prop with open true and loads content once when open", async () => {
62
- const body = vi.fn((ctx: { open: boolean }) => `open=${ctx.open}`);
63
- const loadContent = vi.fn(async () => "Loaded preview");
64
- await mount(
65
- <>
66
- <SessionsHoverCard open body={body}>
67
- <button type="button">A</button>
68
- </SessionsHoverCard>
69
- <SessionsHoverCard open loadContent={loadContent} loadingFallback="Loading">
70
- <button type="button">B</button>
71
- </SessionsHoverCard>
72
- </>,
73
- );
74
- expect(body).toHaveBeenCalledWith({ open: true });
75
- expect(document.body.textContent).toContain("open=true");
76
- expect(loadContent).toHaveBeenCalledTimes(1);
77
- expect(document.body.textContent).toContain("Loaded preview");
78
- });
79
-
80
- it("portals the content to document.body by default", async () => {
81
- await mount(
82
- <div id="clip" style={{ overflow: "hidden" }}>
83
- <SessionsHoverCard open body="Body text">
84
- <button type="button">Row</button>
85
- </SessionsHoverCard>
86
- </div>,
87
- );
88
- const content = document.querySelector("[data-radix-popper-content-wrapper]");
89
- expect(content).not.toBeNull();
90
- expect(document.getElementById("clip")!.contains(content)).toBe(false);
91
- expect(document.body.textContent).toContain("Body text");
92
- });
93
-
94
- it("keeps the content in place with portal={false}", async () => {
95
- await mount(
96
- <div id="clip" style={{ overflow: "hidden" }}>
97
- <SessionsHoverCard open portal={false} body="Body text">
98
- <button type="button">Row</button>
99
- </SessionsHoverCard>
100
- </div>,
101
- );
102
- const content = document.querySelector("[data-radix-popper-content-wrapper]");
103
- expect(document.getElementById("clip")!.contains(content)).toBe(true);
104
- });
105
-
106
- it("swaps the surface without keeping the default one", async () => {
107
- await mount(
108
- <>
109
- <SessionsHoverCard open surface="glass" body="G" className="p-5">
110
- <button type="button">A</button>
111
- </SessionsHoverCard>
112
- </>,
113
- );
114
- const card = document.querySelector("[data-state=open][data-side]") as HTMLElement;
115
- expect(card.className).toContain("glass-panel-strong");
116
- expect(card.className).toContain("rounded-surface");
117
- expect(card.className).not.toContain("bg-overlay");
118
- expect(card.className).not.toContain("rounded-lg");
119
- expect(card.className).toContain("p-5");
120
- expect(card.className).not.toContain("p-3");
121
- });
122
-
123
- it("uses the floating overlay surface by default", async () => {
124
- await mount(
125
- <SessionsHoverCard open body="B">
126
- <button type="button">A</button>
127
- </SessionsHoverCard>,
128
- );
129
- const card = document.querySelector("[data-state=open][data-side]") as HTMLElement;
130
- expect(card.className).toContain("bg-overlay");
131
- expect(card.className).toContain("rounded-lg");
132
- });
133
-
134
- it("forwards the ref and props to the child trigger and keeps its handlers", async () => {
135
- const ref = createRef<HTMLButtonElement>();
136
- const onClick = vi.fn();
137
- await mount(
138
- <SessionsHoverCard ref={ref as never} data-testid="row" body="B">
139
- <button type="button" onClick={onClick}>
140
- Row
141
- </button>
142
- </SessionsHoverCard>,
143
- );
144
- const button = document.querySelector("button") as HTMLButtonElement;
145
- expect(ref.current).toBe(button);
146
- expect(button.getAttribute("data-testid")).toBe("row");
147
- await act(async () => button.click());
148
- expect(onClick).toHaveBeenCalledTimes(1);
149
- });
150
-
151
- it("renders its own trigger element with asChild={false}", async () => {
152
- await mount(
153
- <SessionsHoverCard asChild={false} body="B">
154
- <span>Row</span>
155
- </SessionsHoverCard>,
156
- );
157
- const trigger = document.querySelector("[data-state]") as HTMLElement;
158
- expect(trigger.tagName).toBe("A");
159
- expect(trigger.textContent).toBe("Row");
160
- });
161
-
162
- describe("focus and pointer", () => {
163
- function setFocusVisible(el: HTMLElement, value: boolean) {
164
- const orig = el.matches.bind(el);
165
- el.matches = (sel: string) => (sel === ":focus-visible" ? value : orig(sel));
166
- }
167
-
168
- it("does not open on pointer-driven focus after a context menu", async () => {
169
- const onOpenChange = vi.fn();
170
- await mount(
171
- <SessionsHoverCard openDelay={0} header="Head" onOpenChange={onOpenChange}>
172
- <button type="button">Row</button>
173
- </SessionsHoverCard>,
174
- );
175
- const row = document.querySelector("button")!;
176
- setFocusVisible(row, false);
177
- await act(async () => {
178
- row.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
179
- row.dispatchEvent(new FocusEvent("focus", { bubbles: false }));
180
- row.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
181
- });
182
- await act(async () => new Promise((r) => setTimeout(r, 20)));
183
- expect(onOpenChange).not.toHaveBeenCalledWith(true);
184
- expect(document.body.textContent).not.toContain("Head");
185
- });
186
-
187
- it("opens on keyboard focus-visible", async () => {
188
- const onOpenChange = vi.fn();
189
- await mount(
190
- <SessionsHoverCard openDelay={0} header="Head" onOpenChange={onOpenChange}>
191
- <button type="button">Row</button>
192
- </SessionsHoverCard>,
193
- );
194
- const row = document.querySelector("button")!;
195
- setFocusVisible(row, true);
196
- await act(async () => {
197
- row.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
198
- });
199
- await act(async () => new Promise((r) => setTimeout(r, 20)));
200
- expect(onOpenChange).toHaveBeenCalledWith(true);
201
- });
202
-
203
- // React derives onPointerLeave from native pointerout (with a null or outside
204
- // relatedTarget). A native pointerleave event does not reach the handler.
205
- function leave(el: HTMLElement) {
206
- return act(async () => {
207
- el.dispatchEvent(new PointerEvent("pointerout", { pointerType: "mouse", bubbles: true, relatedTarget: null }));
208
- });
209
- }
210
-
211
- it("closes on pointer leave when stuck open by non-focus-visible focus", async () => {
212
- const onOpenChange = vi.fn();
213
- await mount(
214
- <SessionsHoverCard defaultOpen header="Head" onOpenChange={onOpenChange}>
215
- <button type="button">Row</button>
216
- </SessionsHoverCard>,
217
- );
218
- const row = document.querySelector("button")!;
219
- setFocusVisible(row, false);
220
- row.focus();
221
- await leave(row);
222
- expect(onOpenChange).toHaveBeenCalledWith(false);
223
- await act(async () => new Promise((r) => setTimeout(r, 20)));
224
- expect(document.body.textContent).not.toContain("Head");
225
- });
226
-
227
- it("stays open on pointer leave with keyboard focus-visible", async () => {
228
- const onOpenChange = vi.fn();
229
- await mount(
230
- <SessionsHoverCard defaultOpen header="Head" onOpenChange={onOpenChange}>
231
- <button type="button">Row</button>
232
- </SessionsHoverCard>,
233
- );
234
- const row = document.querySelector("button")!;
235
- setFocusVisible(row, true);
236
- row.focus();
237
- await leave(row);
238
- await act(async () => new Promise((r) => setTimeout(r, 20)));
239
- expect(onOpenChange).not.toHaveBeenCalledWith(false);
240
- expect(document.body.textContent).toContain("Head");
241
- });
242
-
243
- it("does not force-close on pointer leave without focus (Radix owns hover close)", async () => {
244
- const onOpenChange = vi.fn();
245
- await mount(
246
- <SessionsHoverCard defaultOpen closeDelay={1000} header="Head" onOpenChange={onOpenChange}>
247
- <button type="button">Row</button>
248
- </SessionsHoverCard>,
249
- );
250
- const row = document.querySelector("button")!;
251
- await leave(row);
252
- expect(onOpenChange).not.toHaveBeenCalledWith(false);
253
- expect(document.body.textContent).toContain("Head");
254
- });
255
- });
256
- });