@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.
- package/README.md +86 -2
- package/package.json +2 -2
- package/shell/src/AppShell.tsx +83 -0
- package/shell/src/AppSidebar.tsx +162 -0
- package/shell/src/SidebarAccountMenu.tsx +121 -0
- package/shell/src/SidebarIconTooltip.tsx +34 -0
- package/shell/src/SidebarPanel.tsx +71 -0
- package/shell/src/SidebarRail.tsx +130 -0
- package/shell/src/SignOutDialog.tsx +93 -0
- package/shell/src/app-shell-classes.ts +44 -0
- package/shell/src/index.ts +25 -0
- package/src/composer/input-area/FadePresence.tsx +30 -12
- package/src/index.ts +31 -0
- package/src/motion/panel-transition.tsx +38 -0
- package/src/motion/presets.ts +18 -0
- package/src/ui/_menu-parts.tsx +59 -0
- package/src/ui/button.tsx +28 -3
- package/src/ui/card.tsx +13 -1
- package/src/ui/command.tsx +46 -6
- package/src/ui/context-menu.tsx +40 -11
- package/src/ui/dropdown-menu.tsx +63 -15
- package/src/ui/overlay-a11y.ts +19 -0
- package/src/ui/popover.tsx +12 -2
- package/src/ui/select-field.tsx +58 -0
- package/shell/src/SessionsHoverCard.test.tsx +0 -256
package/src/ui/command.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
}:
|
|
58
|
+
}: CommandDialogProps) {
|
|
26
59
|
return (
|
|
27
60
|
<Dialog {...props}>
|
|
28
|
-
<DialogContent className="overflow-hidden p-0 sm:max-w-xl">
|
|
29
|
-
{
|
|
30
|
-
|
|
31
|
-
|
|
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>
|
package/src/ui/context-menu.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
>(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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<
|
package/src/ui/dropdown-menu.tsx
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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}
|
package/src/ui/overlay-a11y.ts
CHANGED
|
@@ -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());
|
package/src/ui/popover.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
});
|