@tangle-network/ui 11.17.0 → 11.20.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/CHANGELOG.md +23 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +10 -1
- package/dist/primitives/alert-dialog.d.ts +34 -0
- package/dist/primitives/alert-dialog.js +68 -0
- package/dist/primitives/checkbox.d.ts +12 -0
- package/dist/primitives/checkbox.js +34 -0
- package/dist/primitives/collapsible.d.ts +12 -0
- package/dist/primitives/collapsible.js +13 -0
- package/dist/primitives/context-menu.d.ts +36 -0
- package/dist/primitives/context-menu.js +86 -0
- package/dist/primitives/icon-tile.d.ts +6 -1
- package/dist/primitives/icon-tile.js +3 -1
- package/dist/primitives/popover.d.ts +15 -0
- package/dist/primitives/popover.js +25 -0
- package/dist/primitives/separator.d.ts +11 -0
- package/dist/primitives/separator.js +21 -0
- package/dist/primitives/sheet.d.ts +28 -0
- package/dist/primitives/sheet.js +70 -0
- package/dist/primitives/slider.d.ts +14 -0
- package/dist/primitives/slider.js +37 -0
- package/dist/primitives/theme-toggle.js +8 -0
- package/dist/primitives/tooltip.d.ts +16 -0
- package/dist/primitives/tooltip.js +32 -0
- package/dist/primitives.d.ts +10 -1
- package/dist/primitives.js +10 -1
- package/package.json +9 -2
- package/src/primitives/alert-dialog.tsx +139 -0
- package/src/primitives/checkbox-separator-sheet-tooltip.stories.tsx +92 -0
- package/src/primitives/checkbox-separator-sheet-tooltip.test.tsx +107 -0
- package/src/primitives/checkbox.tsx +42 -0
- package/src/primitives/collapsible.tsx +16 -0
- package/src/primitives/context-menu.tsx +168 -0
- package/src/primitives/icon-tile.stories.tsx +2 -2
- package/src/primitives/icon-tile.test.tsx +39 -0
- package/src/primitives/icon-tile.tsx +8 -1
- package/src/primitives/index.ts +57 -0
- package/src/primitives/popover-collapsible-alert-dialog-context-menu-slider.stories.tsx +124 -0
- package/src/primitives/popover-collapsible-alert-dialog-context-menu-slider.test.tsx +165 -0
- package/src/primitives/popover.tsx +45 -0
- package/src/primitives/separator.tsx +30 -0
- package/src/primitives/sheet.tsx +134 -0
- package/src/primitives/slider.tsx +71 -0
- package/src/primitives/theme-toggle.test.tsx +2 -1
- package/src/primitives/theme-toggle.tsx +2 -0
- package/src/primitives/tooltip.tsx +54 -0
- package/src/stories/theme-showcase.stories.tsx +2 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
|
4
|
+
import { Check, Minus } from "lucide-react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { focusRing } from "../lib/focus";
|
|
7
|
+
import { cn } from "../lib/utils";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A two- or three-state checkbox. An indeterminate state, controlled
|
|
11
|
+
* (`checked`) or uncontrolled (`defaultChecked`), draws a dash.
|
|
12
|
+
* Pair it with a `Label` through `id`/`htmlFor`; `aria-invalid` paints the
|
|
13
|
+
* danger hairline.
|
|
14
|
+
*/
|
|
15
|
+
const Checkbox = React.forwardRef<
|
|
16
|
+
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
|
17
|
+
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
|
18
|
+
>(({ className, ...props }, ref) => (
|
|
19
|
+
<CheckboxPrimitive.Root
|
|
20
|
+
ref={ref}
|
|
21
|
+
className={cn(
|
|
22
|
+
"peer group/checkbox inline-grid size-4 shrink-0 cursor-pointer place-content-center rounded-[4px] border border-[var(--border-strong)] bg-transparent transition-[background-color,border-color,box-shadow] duration-150 ease-out",
|
|
23
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
24
|
+
"aria-invalid:border-[var(--surface-danger-border)]",
|
|
25
|
+
"data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
|
26
|
+
"data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground",
|
|
27
|
+
focusRing,
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
<CheckboxPrimitive.Indicator className="grid place-content-center text-current">
|
|
33
|
+
{/* Both glyphs render and the Root's Radix state picks one, so a
|
|
34
|
+
controlled or uncontrolled indeterminate box draws the same dash. */}
|
|
35
|
+
<Check aria-hidden="true" className="size-3.5 group-data-[state=indeterminate]/checkbox:hidden" strokeWidth={3} />
|
|
36
|
+
<Minus aria-hidden="true" className="hidden size-3.5 group-data-[state=indeterminate]/checkbox:block" strokeWidth={3} />
|
|
37
|
+
</CheckboxPrimitive.Indicator>
|
|
38
|
+
</CheckboxPrimitive.Root>
|
|
39
|
+
));
|
|
40
|
+
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
|
41
|
+
|
|
42
|
+
export { Checkbox };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Unstyled show/hide region. The trigger carries `aria-expanded` and
|
|
7
|
+
* `data-state`, so a chevron can rotate with
|
|
8
|
+
* `group-data-[state=open]:rotate-90` without local state.
|
|
9
|
+
*/
|
|
10
|
+
const Collapsible = CollapsiblePrimitive.Root;
|
|
11
|
+
|
|
12
|
+
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
|
13
|
+
|
|
14
|
+
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
|
15
|
+
|
|
16
|
+
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
|
4
|
+
import { Check, ChevronRight, Circle } from "lucide-react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Right-click (and long-press) menu with the same surface and item styles as
|
|
10
|
+
* `DropdownMenu`. Anything reachable here must also be reachable from a
|
|
11
|
+
* visible control, because touch and keyboard users rarely open it.
|
|
12
|
+
*/
|
|
13
|
+
const ContextMenu = ContextMenuPrimitive.Root;
|
|
14
|
+
|
|
15
|
+
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
|
16
|
+
|
|
17
|
+
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
|
18
|
+
|
|
19
|
+
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
|
20
|
+
|
|
21
|
+
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
|
22
|
+
|
|
23
|
+
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
|
24
|
+
|
|
25
|
+
const menuSurface = cn(
|
|
26
|
+
"z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border bg-card p-1 text-foreground shadow-[var(--shadow-card)]",
|
|
27
|
+
"data-[state=closed]:animate-out data-[state=open]:animate-in",
|
|
28
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
29
|
+
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
30
|
+
"motion-reduce:animate-none",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const menuItem =
|
|
34
|
+
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted/50 focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0";
|
|
35
|
+
|
|
36
|
+
const ContextMenuSubTrigger = React.forwardRef<
|
|
37
|
+
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
|
38
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { inset?: boolean }
|
|
39
|
+
>(({ className, inset, children, ...props }, ref) => (
|
|
40
|
+
<ContextMenuPrimitive.SubTrigger
|
|
41
|
+
ref={ref}
|
|
42
|
+
className={cn(menuItem, "data-[state=open]:bg-muted/50", inset && "pl-8", className)}
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
<ChevronRight className="ml-auto" />
|
|
47
|
+
</ContextMenuPrimitive.SubTrigger>
|
|
48
|
+
));
|
|
49
|
+
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
|
50
|
+
|
|
51
|
+
const ContextMenuSubContent = React.forwardRef<
|
|
52
|
+
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
|
53
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
|
54
|
+
>(({ className, ...props }, ref) => (
|
|
55
|
+
<ContextMenuPrimitive.Portal>
|
|
56
|
+
<ContextMenuPrimitive.SubContent ref={ref} className={cn(menuSurface, className)} {...props} />
|
|
57
|
+
</ContextMenuPrimitive.Portal>
|
|
58
|
+
));
|
|
59
|
+
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
|
60
|
+
|
|
61
|
+
const ContextMenuContent = React.forwardRef<
|
|
62
|
+
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
|
63
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
|
64
|
+
>(({ className, ...props }, ref) => (
|
|
65
|
+
<ContextMenuPrimitive.Portal>
|
|
66
|
+
<ContextMenuPrimitive.Content ref={ref} className={cn(menuSurface, className)} {...props} />
|
|
67
|
+
</ContextMenuPrimitive.Portal>
|
|
68
|
+
));
|
|
69
|
+
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
|
70
|
+
|
|
71
|
+
/** `tone="destructive"` paints the label and icon with the danger text token. */
|
|
72
|
+
const ContextMenuItem = React.forwardRef<
|
|
73
|
+
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
|
74
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
|
75
|
+
inset?: boolean;
|
|
76
|
+
tone?: "default" | "destructive";
|
|
77
|
+
}
|
|
78
|
+
>(({ className, inset, tone = "default", ...props }, ref) => (
|
|
79
|
+
<ContextMenuPrimitive.Item
|
|
80
|
+
ref={ref}
|
|
81
|
+
className={cn(
|
|
82
|
+
menuItem,
|
|
83
|
+
tone === "destructive" && "text-destructive focus:bg-destructive/10 focus:text-destructive",
|
|
84
|
+
inset && "pl-8",
|
|
85
|
+
className,
|
|
86
|
+
)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
));
|
|
90
|
+
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
|
91
|
+
|
|
92
|
+
const ContextMenuCheckboxItem = React.forwardRef<
|
|
93
|
+
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
|
94
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
|
95
|
+
>(({ className, children, checked, ...props }, ref) => (
|
|
96
|
+
<ContextMenuPrimitive.CheckboxItem
|
|
97
|
+
ref={ref}
|
|
98
|
+
className={cn(menuItem, "pl-8", className)}
|
|
99
|
+
checked={checked}
|
|
100
|
+
{...props}
|
|
101
|
+
>
|
|
102
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
103
|
+
<ContextMenuPrimitive.ItemIndicator>
|
|
104
|
+
<Check />
|
|
105
|
+
</ContextMenuPrimitive.ItemIndicator>
|
|
106
|
+
</span>
|
|
107
|
+
{children}
|
|
108
|
+
</ContextMenuPrimitive.CheckboxItem>
|
|
109
|
+
));
|
|
110
|
+
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
|
|
111
|
+
|
|
112
|
+
const ContextMenuRadioItem = React.forwardRef<
|
|
113
|
+
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
|
114
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
|
115
|
+
>(({ className, children, ...props }, ref) => (
|
|
116
|
+
<ContextMenuPrimitive.RadioItem ref={ref} className={cn(menuItem, "pl-8", className)} {...props}>
|
|
117
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
118
|
+
<ContextMenuPrimitive.ItemIndicator>
|
|
119
|
+
<Circle className="!size-2 fill-current" />
|
|
120
|
+
</ContextMenuPrimitive.ItemIndicator>
|
|
121
|
+
</span>
|
|
122
|
+
{children}
|
|
123
|
+
</ContextMenuPrimitive.RadioItem>
|
|
124
|
+
));
|
|
125
|
+
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
|
126
|
+
|
|
127
|
+
const ContextMenuLabel = React.forwardRef<
|
|
128
|
+
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
|
129
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { inset?: boolean }
|
|
130
|
+
>(({ className, inset, ...props }, ref) => (
|
|
131
|
+
<ContextMenuPrimitive.Label
|
|
132
|
+
ref={ref}
|
|
133
|
+
className={cn("px-2 py-1.5 font-semibold text-sm", inset && "pl-8", className)}
|
|
134
|
+
{...props}
|
|
135
|
+
/>
|
|
136
|
+
));
|
|
137
|
+
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
|
138
|
+
|
|
139
|
+
const ContextMenuSeparator = React.forwardRef<
|
|
140
|
+
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
|
141
|
+
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
|
142
|
+
>(({ className, ...props }, ref) => (
|
|
143
|
+
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
|
|
144
|
+
));
|
|
145
|
+
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
|
146
|
+
|
|
147
|
+
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
|
148
|
+
<span className={cn("ml-auto text-muted-foreground text-xs tracking-widest", className)} {...props} />
|
|
149
|
+
);
|
|
150
|
+
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
|
151
|
+
|
|
152
|
+
export {
|
|
153
|
+
ContextMenu,
|
|
154
|
+
ContextMenuCheckboxItem,
|
|
155
|
+
ContextMenuContent,
|
|
156
|
+
ContextMenuGroup,
|
|
157
|
+
ContextMenuItem,
|
|
158
|
+
ContextMenuLabel,
|
|
159
|
+
ContextMenuPortal,
|
|
160
|
+
ContextMenuRadioGroup,
|
|
161
|
+
ContextMenuRadioItem,
|
|
162
|
+
ContextMenuSeparator,
|
|
163
|
+
ContextMenuShortcut,
|
|
164
|
+
ContextMenuSub,
|
|
165
|
+
ContextMenuSubContent,
|
|
166
|
+
ContextMenuSubTrigger,
|
|
167
|
+
ContextMenuTrigger,
|
|
168
|
+
};
|
|
@@ -62,10 +62,10 @@ export const Sizes: Story = {
|
|
|
62
62
|
render: () => (
|
|
63
63
|
<BothThemes>
|
|
64
64
|
<div className="flex items-end gap-3">
|
|
65
|
-
{(["xs", "sm", "md", "lg"] as const).map((size) => (
|
|
65
|
+
{(["xs", "sm", "md", "lg", "xl", "2xl"] as const).map((size) => (
|
|
66
66
|
<IconTile key={size} size={size} name="Tangle Network" />
|
|
67
67
|
))}
|
|
68
|
-
{(["xs", "sm", "md", "lg"] as const).map((size) => (
|
|
68
|
+
{(["xs", "sm", "md", "lg", "xl", "2xl"] as const).map((size) => (
|
|
69
69
|
<IconTile key={`g-${size}`} size={size} tone="cyan" icon={<Globe />} />
|
|
70
70
|
))}
|
|
71
71
|
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import { Globe } from "lucide-react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { IconTile, initialsOf } from "./icon-tile";
|
|
5
|
+
|
|
6
|
+
describe("IconTile", () => {
|
|
7
|
+
it.each([
|
|
8
|
+
["xs", "size-5"],
|
|
9
|
+
["sm", "size-6"],
|
|
10
|
+
["md", "size-8"],
|
|
11
|
+
["lg", "size-10"],
|
|
12
|
+
["xl", "size-16"],
|
|
13
|
+
["2xl", "size-20"],
|
|
14
|
+
] as const)("renders size %s as %s", (size, cls) => {
|
|
15
|
+
const { container } = render(<IconTile size={size} name="Tangle Network" />);
|
|
16
|
+
expect(container.firstElementChild).toHaveClass(cls);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("scales the glyph with the large sizes", () => {
|
|
20
|
+
const { container } = render(<IconTile size="2xl" tone="cyan" icon={<Globe />} />);
|
|
21
|
+
expect(container.firstElementChild?.className).toContain("[&_svg]:size-10");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("shows initials and is decorative without a label", () => {
|
|
25
|
+
const { container } = render(<IconTile size="xl" name="Acme Robotics" />);
|
|
26
|
+
const tile = container.firstElementChild;
|
|
27
|
+
expect(tile).toHaveTextContent("AR");
|
|
28
|
+
expect(tile).toHaveAttribute("aria-hidden", "true");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("is an image with an accessible name when labelled", () => {
|
|
32
|
+
const { getByRole } = render(<IconTile size="xl" name="Acme" label="Acme" />);
|
|
33
|
+
expect(getByRole("img", { name: "Acme" })).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps an emoji whole in the initials", () => {
|
|
37
|
+
expect(initialsOf("🦊")).toBe("🦊");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -18,7 +18,12 @@ import { type CategoryTone, TONE_CLASSES, toneFor } from "./tone";
|
|
|
18
18
|
export interface IconTileProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
|
|
19
19
|
/** Defaults to a tone derived from `name`, or violet when there is no name. */
|
|
20
20
|
tone?: CategoryTone;
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* `xs` to `lg` (20 to 40px) sit in rows and cards. `xl` (64px) and `2xl`
|
|
23
|
+
* (80px) are for a page or profile header, where the tile is the thing's
|
|
24
|
+
* face rather than a list marker.
|
|
25
|
+
*/
|
|
26
|
+
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
|
22
27
|
src?: string;
|
|
23
28
|
icon?: React.ReactNode;
|
|
24
29
|
/** The thing's name. Supplies the initials and the derived tone. */
|
|
@@ -32,6 +37,8 @@ const SIZE = {
|
|
|
32
37
|
sm: "size-6 rounded-md text-[11px] [&_svg]:size-3.5",
|
|
33
38
|
md: "size-8 rounded-md text-xs [&_svg]:size-4",
|
|
34
39
|
lg: "size-10 rounded-lg text-sm [&_svg]:size-5",
|
|
40
|
+
xl: "size-16 rounded-2xl text-xl [&_svg]:size-8",
|
|
41
|
+
"2xl": "size-20 rounded-2xl text-2xl [&_svg]:size-10",
|
|
35
42
|
} as const;
|
|
36
43
|
|
|
37
44
|
/** Up to two initials: the first letters of the first two words, or of the one word. */
|
package/src/primitives/index.ts
CHANGED
|
@@ -24,6 +24,44 @@ export {
|
|
|
24
24
|
DialogDescription,
|
|
25
25
|
} from "./dialog";
|
|
26
26
|
|
|
27
|
+
export {
|
|
28
|
+
AlertDialog,
|
|
29
|
+
AlertDialogAction,
|
|
30
|
+
AlertDialogCancel,
|
|
31
|
+
AlertDialogContent,
|
|
32
|
+
AlertDialogDescription,
|
|
33
|
+
AlertDialogFooter,
|
|
34
|
+
AlertDialogHeader,
|
|
35
|
+
AlertDialogOverlay,
|
|
36
|
+
AlertDialogPortal,
|
|
37
|
+
AlertDialogTitle,
|
|
38
|
+
AlertDialogTrigger,
|
|
39
|
+
} from "./alert-dialog";
|
|
40
|
+
|
|
41
|
+
export { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible";
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
ContextMenu,
|
|
45
|
+
ContextMenuCheckboxItem,
|
|
46
|
+
ContextMenuContent,
|
|
47
|
+
ContextMenuGroup,
|
|
48
|
+
ContextMenuItem,
|
|
49
|
+
ContextMenuLabel,
|
|
50
|
+
ContextMenuPortal,
|
|
51
|
+
ContextMenuRadioGroup,
|
|
52
|
+
ContextMenuRadioItem,
|
|
53
|
+
ContextMenuSeparator,
|
|
54
|
+
ContextMenuShortcut,
|
|
55
|
+
ContextMenuSub,
|
|
56
|
+
ContextMenuSubContent,
|
|
57
|
+
ContextMenuSubTrigger,
|
|
58
|
+
ContextMenuTrigger,
|
|
59
|
+
} from "./context-menu";
|
|
60
|
+
|
|
61
|
+
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger } from "./popover";
|
|
62
|
+
|
|
63
|
+
export { Slider } from "./slider";
|
|
64
|
+
|
|
27
65
|
export { Input, Textarea } from "./input";
|
|
28
66
|
export type { InputProps, TextareaProps } from "./input";
|
|
29
67
|
|
|
@@ -87,6 +125,25 @@ export { Progress } from "./progress";
|
|
|
87
125
|
|
|
88
126
|
export { Switch } from "./switch";
|
|
89
127
|
|
|
128
|
+
export { Checkbox } from "./checkbox";
|
|
129
|
+
|
|
130
|
+
export { Separator } from "./separator";
|
|
131
|
+
|
|
132
|
+
export {
|
|
133
|
+
Sheet,
|
|
134
|
+
SheetClose,
|
|
135
|
+
SheetContent,
|
|
136
|
+
SheetDescription,
|
|
137
|
+
SheetFooter,
|
|
138
|
+
SheetHeader,
|
|
139
|
+
SheetOverlay,
|
|
140
|
+
SheetPortal,
|
|
141
|
+
SheetTitle,
|
|
142
|
+
SheetTrigger,
|
|
143
|
+
} from "./sheet";
|
|
144
|
+
|
|
145
|
+
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
|
146
|
+
|
|
90
147
|
export { Skeleton, SkeletonCard, SkeletonTable } from "./skeleton";
|
|
91
148
|
|
|
92
149
|
export { TextShimmer } from "./text-shimmer";
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { ChevronRight } from 'lucide-react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
AlertDialog,
|
|
6
|
+
AlertDialogAction,
|
|
7
|
+
AlertDialogCancel,
|
|
8
|
+
AlertDialogContent,
|
|
9
|
+
AlertDialogDescription,
|
|
10
|
+
AlertDialogFooter,
|
|
11
|
+
AlertDialogHeader,
|
|
12
|
+
AlertDialogTitle,
|
|
13
|
+
AlertDialogTrigger,
|
|
14
|
+
} from './alert-dialog'
|
|
15
|
+
import { Button } from './button'
|
|
16
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './collapsible'
|
|
17
|
+
import {
|
|
18
|
+
ContextMenu,
|
|
19
|
+
ContextMenuContent,
|
|
20
|
+
ContextMenuItem,
|
|
21
|
+
ContextMenuSeparator,
|
|
22
|
+
ContextMenuShortcut,
|
|
23
|
+
ContextMenuTrigger,
|
|
24
|
+
} from './context-menu'
|
|
25
|
+
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
|
26
|
+
import { Slider } from './slider'
|
|
27
|
+
|
|
28
|
+
const meta: Meta = {
|
|
29
|
+
title: 'Primitives/Popover, Collapsible, AlertDialog, ContextMenu, Slider',
|
|
30
|
+
parameters: { layout: 'centered' },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default meta
|
|
34
|
+
type Story = StoryObj
|
|
35
|
+
|
|
36
|
+
const LONG_NAME = 'customer-onboarding-agent-with-a-very-long-project-name-that-never-wraps-on-its-own'
|
|
37
|
+
|
|
38
|
+
export const PopoverWithLongContent: Story = {
|
|
39
|
+
render: () => (
|
|
40
|
+
<Popover>
|
|
41
|
+
<PopoverTrigger asChild>
|
|
42
|
+
<Button variant="outline">Project details</Button>
|
|
43
|
+
</PopoverTrigger>
|
|
44
|
+
<PopoverContent className="[overflow-wrap:anywhere]">
|
|
45
|
+
<p className="font-medium text-sm">{LONG_NAME}</p>
|
|
46
|
+
<p className="mt-1 text-muted-foreground text-sm">Created by a teammate. Last deployed from the develop branch.</p>
|
|
47
|
+
</PopoverContent>
|
|
48
|
+
</Popover>
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const CollapsibleSection: Story = {
|
|
53
|
+
render: () => (
|
|
54
|
+
<Collapsible className="w-80">
|
|
55
|
+
<CollapsibleTrigger className="group flex w-full items-center gap-2 text-sm">
|
|
56
|
+
<ChevronRight className="size-4 transition-transform group-data-[state=open]:rotate-90" />
|
|
57
|
+
Advanced settings
|
|
58
|
+
</CollapsibleTrigger>
|
|
59
|
+
<CollapsibleContent className="mt-2 text-muted-foreground text-sm">
|
|
60
|
+
Retry each failed step once before the run is marked failed.
|
|
61
|
+
</CollapsibleContent>
|
|
62
|
+
</Collapsible>
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const DestructiveConfirm: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<AlertDialog>
|
|
69
|
+
<AlertDialogTrigger asChild>
|
|
70
|
+
<Button variant="destructive">Delete project</Button>
|
|
71
|
+
</AlertDialogTrigger>
|
|
72
|
+
<AlertDialogContent>
|
|
73
|
+
<AlertDialogHeader>
|
|
74
|
+
<AlertDialogTitle>Delete {LONG_NAME}?</AlertDialogTitle>
|
|
75
|
+
<AlertDialogDescription>Its sandboxes stop and its deployments are removed.</AlertDialogDescription>
|
|
76
|
+
</AlertDialogHeader>
|
|
77
|
+
<AlertDialogFooter>
|
|
78
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
79
|
+
<AlertDialogAction tone="destructive">Delete</AlertDialogAction>
|
|
80
|
+
</AlertDialogFooter>
|
|
81
|
+
</AlertDialogContent>
|
|
82
|
+
</AlertDialog>
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const FileContextMenu: Story = {
|
|
87
|
+
render: () => (
|
|
88
|
+
<ContextMenu>
|
|
89
|
+
<ContextMenuTrigger className="flex h-24 w-72 items-center justify-center rounded-lg border border-border border-dashed text-muted-foreground text-sm">
|
|
90
|
+
Right-click this file
|
|
91
|
+
</ContextMenuTrigger>
|
|
92
|
+
<ContextMenuContent className="w-56">
|
|
93
|
+
<ContextMenuItem>
|
|
94
|
+
Rename <ContextMenuShortcut>F2</ContextMenuShortcut>
|
|
95
|
+
</ContextMenuItem>
|
|
96
|
+
<ContextMenuItem>Copy path</ContextMenuItem>
|
|
97
|
+
<ContextMenuSeparator />
|
|
98
|
+
<ContextMenuItem tone="destructive">Delete</ContextMenuItem>
|
|
99
|
+
</ContextMenuContent>
|
|
100
|
+
</ContextMenu>
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function RangeDemo() {
|
|
105
|
+
const [value, setValue] = useState([20, 80])
|
|
106
|
+
return (
|
|
107
|
+
<div className="w-72 space-y-2">
|
|
108
|
+
<Slider value={value} onValueChange={setValue} thumbLabels={['Minimum', 'Maximum']} />
|
|
109
|
+
<p className="text-muted-foreground text-sm">
|
|
110
|
+
{value[0]} to {value[1]}
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const Sliders: Story = {
|
|
117
|
+
render: () => (
|
|
118
|
+
<div className="flex flex-col gap-8">
|
|
119
|
+
<Slider aria-label="Temperature" defaultValue={[40]} className="w-72" />
|
|
120
|
+
<RangeDemo />
|
|
121
|
+
<Slider aria-label="Disabled" defaultValue={[60]} disabled className="w-72" />
|
|
122
|
+
</div>
|
|
123
|
+
),
|
|
124
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
AlertDialog,
|
|
6
|
+
AlertDialogAction,
|
|
7
|
+
AlertDialogCancel,
|
|
8
|
+
AlertDialogContent,
|
|
9
|
+
AlertDialogDescription,
|
|
10
|
+
AlertDialogTitle,
|
|
11
|
+
AlertDialogTrigger,
|
|
12
|
+
} from "./alert-dialog";
|
|
13
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible";
|
|
14
|
+
import {
|
|
15
|
+
ContextMenu,
|
|
16
|
+
ContextMenuContent,
|
|
17
|
+
ContextMenuItem,
|
|
18
|
+
ContextMenuSub,
|
|
19
|
+
ContextMenuSubContent,
|
|
20
|
+
ContextMenuSubTrigger,
|
|
21
|
+
ContextMenuTrigger,
|
|
22
|
+
} from "./context-menu";
|
|
23
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
24
|
+
import { Slider } from "./slider";
|
|
25
|
+
|
|
26
|
+
const PALETTE = /\b(?:bg|text|border)-(?:slate|zinc|gray|neutral|blue|green|red|amber|emerald|black|white)(?:-\d|\b)/;
|
|
27
|
+
|
|
28
|
+
describe("Popover", () => {
|
|
29
|
+
it("opens from its trigger on a token surface and closes on Escape", async () => {
|
|
30
|
+
const user = userEvent.setup();
|
|
31
|
+
render(
|
|
32
|
+
<Popover>
|
|
33
|
+
<PopoverTrigger>Filters</PopoverTrigger>
|
|
34
|
+
<PopoverContent>Only failed runs</PopoverContent>
|
|
35
|
+
</Popover>,
|
|
36
|
+
);
|
|
37
|
+
await user.click(screen.getByRole("button", { name: "Filters" }));
|
|
38
|
+
const content = await screen.findByRole("dialog");
|
|
39
|
+
expect(content).toHaveTextContent("Only failed runs");
|
|
40
|
+
expect(content.className).toContain("bg-card");
|
|
41
|
+
expect(content.className).not.toMatch(PALETTE);
|
|
42
|
+
await user.keyboard("{Escape}");
|
|
43
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("Collapsible", () => {
|
|
48
|
+
it("reports its expanded state on the trigger", async () => {
|
|
49
|
+
const user = userEvent.setup();
|
|
50
|
+
render(
|
|
51
|
+
<Collapsible>
|
|
52
|
+
<CollapsibleTrigger>Details</CollapsibleTrigger>
|
|
53
|
+
<CollapsibleContent>Hidden body</CollapsibleContent>
|
|
54
|
+
</Collapsible>,
|
|
55
|
+
);
|
|
56
|
+
const trigger = screen.getByRole("button", { name: "Details" });
|
|
57
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
58
|
+
expect(screen.queryByText("Hidden body")).toBeNull();
|
|
59
|
+
await user.click(trigger);
|
|
60
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
61
|
+
expect(screen.getByText("Hidden body")).toBeVisible();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("AlertDialog", () => {
|
|
66
|
+
it("focuses Cancel on open so a destructive action is never under Enter", async () => {
|
|
67
|
+
const user = userEvent.setup();
|
|
68
|
+
const onDelete = vi.fn();
|
|
69
|
+
render(
|
|
70
|
+
<AlertDialog>
|
|
71
|
+
<AlertDialogTrigger>Delete project</AlertDialogTrigger>
|
|
72
|
+
<AlertDialogContent>
|
|
73
|
+
<AlertDialogTitle>Delete this project?</AlertDialogTitle>
|
|
74
|
+
<AlertDialogDescription>Its sandboxes stop.</AlertDialogDescription>
|
|
75
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
76
|
+
<AlertDialogAction tone="destructive" onClick={onDelete}>
|
|
77
|
+
Delete
|
|
78
|
+
</AlertDialogAction>
|
|
79
|
+
</AlertDialogContent>
|
|
80
|
+
</AlertDialog>,
|
|
81
|
+
);
|
|
82
|
+
await user.click(screen.getByRole("button", { name: "Delete project" }));
|
|
83
|
+
const dialog = await screen.findByRole("alertdialog", { name: "Delete this project?" });
|
|
84
|
+
expect(dialog).toHaveAccessibleDescription("Its sandboxes stop.");
|
|
85
|
+
expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus();
|
|
86
|
+
const action = screen.getByRole("button", { name: "Delete" });
|
|
87
|
+
expect(action.className).toContain("bg-destructive");
|
|
88
|
+
await user.click(action);
|
|
89
|
+
expect(onDelete).toHaveBeenCalledOnce();
|
|
90
|
+
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("ContextMenu", () => {
|
|
95
|
+
it("opens on contextmenu and runs the chosen item", async () => {
|
|
96
|
+
const onRename = vi.fn();
|
|
97
|
+
render(
|
|
98
|
+
<ContextMenu>
|
|
99
|
+
<ContextMenuTrigger>File row</ContextMenuTrigger>
|
|
100
|
+
<ContextMenuContent>
|
|
101
|
+
<ContextMenuItem onSelect={onRename}>Rename</ContextMenuItem>
|
|
102
|
+
<ContextMenuItem tone="destructive">Delete</ContextMenuItem>
|
|
103
|
+
</ContextMenuContent>
|
|
104
|
+
</ContextMenu>,
|
|
105
|
+
);
|
|
106
|
+
fireEvent.contextMenu(screen.getByText("File row"));
|
|
107
|
+
const menu = await screen.findByRole("menu");
|
|
108
|
+
expect(menu.className).toContain("bg-card");
|
|
109
|
+
expect(screen.getByRole("menuitem", { name: "Delete" }).className).toContain("text-destructive");
|
|
110
|
+
fireEvent.click(screen.getByRole("menuitem", { name: "Rename" }));
|
|
111
|
+
expect(onRename).toHaveBeenCalledOnce();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("portals submenu content out of the clipped parent menu", async () => {
|
|
115
|
+
const user = userEvent.setup();
|
|
116
|
+
render(
|
|
117
|
+
<ContextMenu>
|
|
118
|
+
<ContextMenuTrigger>File row</ContextMenuTrigger>
|
|
119
|
+
<ContextMenuContent>
|
|
120
|
+
<ContextMenuSub>
|
|
121
|
+
<ContextMenuSubTrigger>Move to</ContextMenuSubTrigger>
|
|
122
|
+
<ContextMenuSubContent>
|
|
123
|
+
<ContextMenuItem>Archive</ContextMenuItem>
|
|
124
|
+
</ContextMenuSubContent>
|
|
125
|
+
</ContextMenuSub>
|
|
126
|
+
</ContextMenuContent>
|
|
127
|
+
</ContextMenu>,
|
|
128
|
+
);
|
|
129
|
+
fireEvent.contextMenu(screen.getByText("File row"));
|
|
130
|
+
const parent = await screen.findByRole("menu");
|
|
131
|
+
await user.hover(screen.getByRole("menuitem", { name: "Move to" }));
|
|
132
|
+
await user.keyboard("{ArrowRight}");
|
|
133
|
+
const archive = await screen.findByRole("menuitem", { name: "Archive" });
|
|
134
|
+
expect(parent.contains(archive)).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("Slider", () => {
|
|
139
|
+
beforeAll(() => {
|
|
140
|
+
// Radix measures the thumb with ResizeObserver, which jsdom lacks.
|
|
141
|
+
globalThis.ResizeObserver ??= class {
|
|
142
|
+
observe() {}
|
|
143
|
+
unobserve() {}
|
|
144
|
+
disconnect() {}
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("renders one labelled thumb per value", () => {
|
|
149
|
+
render(<Slider defaultValue={[20, 80]} thumbLabels={["Minimum", "Maximum"]} />);
|
|
150
|
+
const thumbs = screen.getAllByRole("slider");
|
|
151
|
+
expect(thumbs).toHaveLength(2);
|
|
152
|
+
expect(thumbs[0]).toHaveAccessibleName("Minimum");
|
|
153
|
+
expect(thumbs[1]).toHaveAttribute("aria-valuenow", "80");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("moves with the keyboard and reports the value", async () => {
|
|
157
|
+
const user = userEvent.setup();
|
|
158
|
+
const onValueChange = vi.fn();
|
|
159
|
+
render(<Slider aria-label="Budget" defaultValue={[50]} step={10} onValueChange={onValueChange} />);
|
|
160
|
+
expect(screen.getByRole("slider")).toHaveAccessibleName("Budget");
|
|
161
|
+
screen.getByRole("slider").focus();
|
|
162
|
+
await user.keyboard("{ArrowRight}");
|
|
163
|
+
expect(onValueChange).toHaveBeenLastCalledWith([60]);
|
|
164
|
+
});
|
|
165
|
+
});
|