@xenide-io/the-old-ui-theme 0.2.9 → 0.3.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.
- package/README.md +1 -1
- package/dist/chunk-5HSOBJ4F.mjs +5968 -0
- package/dist/index-BgG8Homu.d.mts +1461 -0
- package/dist/index-BgG8Homu.d.ts +1461 -0
- package/dist/index.d.mts +206 -100
- package/dist/index.d.ts +206 -100
- package/dist/index.js +4275 -1455
- package/dist/index.mjs +689 -735
- package/dist/tailwind-preset.js +7 -0
- package/dist/tailwind-preset.mjs +7 -0
- package/dist/ui.d.mts +3 -2
- package/dist/ui.d.ts +3 -2
- package/dist/ui.js +3449 -701
- package/dist/ui.mjs +106 -3
- package/package.json +20 -16
- package/src/app/globals.css +1953 -266
- package/src/components/charts/index.ts +9 -8
- package/src/components/charts/quill/BarChart.tsx +85 -0
- package/src/components/charts/quill/FunnelChart.tsx +49 -0
- package/src/components/charts/quill/InsightShell.tsx +27 -0
- package/src/components/charts/quill/MetricCard.tsx +44 -0
- package/src/components/charts/quill/PieChart.tsx +81 -0
- package/src/components/charts/quill/Sparkline.tsx +57 -0
- package/src/components/charts/quill/TimeSeriesLineChart.tsx +62 -0
- package/src/components/charts/quill/index.ts +9 -0
- package/src/components/icons/IconBase.tsx +20 -12
- package/src/components/icons/createIconBase.tsx +12 -7
- package/src/components/icons/icons.tsx +210 -18
- package/src/components/icons/index.ts +3 -0
- package/src/components/icons/lemon-brand-icons.tsx +16 -0
- package/src/components/icons/lemon-category-icons.tsx +72 -0
- package/src/components/icons/lemon-icons.tsx +57 -0
- package/src/components/sections/AlertShowcase.tsx +2 -1
- package/src/components/sections/BadgeShowcase.tsx +1 -1
- package/src/components/sections/ButtonShowcase.tsx +6 -10
- package/src/components/sections/CardShowcase.tsx +22 -6
- package/src/components/sections/DropdownShowcase.tsx +4 -6
- package/src/components/sections/FilterChipsShowcase.tsx +74 -26
- package/src/components/sections/FormShowcase.tsx +2 -1
- package/src/components/sections/IconShowcase.tsx +195 -212
- package/src/components/sections/KbdShowcase.tsx +47 -21
- package/src/components/sections/ModalShowcase.tsx +7 -6
- package/src/components/sections/NewComponentsShowcase.tsx +1052 -0
- package/src/components/sections/ProductLayoutsShowcase.tsx +78 -0
- package/src/components/sections/ProgressShowcase.tsx +50 -53
- package/src/components/sections/QuillChartShowcase.tsx +92 -0
- package/src/components/sections/QuillDashboardShowcase.tsx +149 -0
- package/src/components/sections/SidebarShowcase.tsx +151 -0
- package/src/components/sections/SwapShowcase.tsx +13 -5
- package/src/components/sections/TableShowcase.tsx +2 -0
- package/src/components/sections/TooltipShowcase.tsx +19 -25
- package/src/components/ui/Alert.tsx +28 -19
- package/src/components/ui/AlertDialog.tsx +62 -0
- package/src/components/ui/AppLayout.tsx +27 -0
- package/src/components/ui/AuthLayout.tsx +58 -0
- package/src/components/ui/Autocomplete.tsx +106 -0
- package/src/components/ui/Badge.tsx +10 -5
- package/src/components/ui/Banner.tsx +63 -0
- package/src/components/ui/Button.tsx +22 -12
- package/src/components/ui/ButtonChrome.tsx +1 -1
- package/src/components/ui/ButtonGroup.tsx +16 -0
- package/src/components/ui/Calendar.tsx +6 -7
- package/src/components/ui/Card.tsx +21 -11
- package/src/components/ui/Chip.tsx +46 -0
- package/src/components/ui/Collapsible.tsx +46 -0
- package/src/components/ui/CollapsibleSection.tsx +11 -11
- package/src/components/ui/Combobox.tsx +119 -0
- package/src/components/ui/ContextMenu.tsx +81 -0
- package/src/components/ui/DataTable.tsx +133 -0
- package/src/components/ui/DatePicker.tsx +96 -0
- package/src/components/ui/Dialog.tsx +75 -0
- package/src/components/ui/Divider.tsx +39 -0
- package/src/components/ui/Dot.tsx +32 -0
- package/src/components/ui/DropdownMenu.tsx +189 -65
- package/src/components/ui/FilterBar.tsx +165 -0
- package/src/components/ui/FilterChips.tsx +65 -35
- package/src/components/ui/FilterControls.tsx +30 -0
- package/src/components/ui/FormField.tsx +69 -0
- package/src/components/ui/Input.tsx +166 -140
- package/src/components/ui/InputGroup.tsx +16 -0
- package/src/components/ui/Kbd.tsx +88 -13
- package/src/components/ui/Lettermark.tsx +47 -0
- package/src/components/ui/Link.tsx +32 -0
- package/src/components/ui/Loader.tsx +96 -0
- package/src/components/ui/LoadingBar.tsx +25 -0
- package/src/components/ui/Menubar.tsx +66 -0
- package/src/components/ui/Metric.tsx +34 -0
- package/src/components/ui/Modal.tsx +66 -36
- package/src/components/ui/NumberField.tsx +76 -0
- package/src/components/ui/Popover.tsx +73 -0
- package/src/components/ui/ProgressCircle.tsx +63 -0
- package/src/components/ui/Resizable.tsx +71 -0
- package/src/components/ui/Row.tsx +51 -0
- package/src/components/ui/ScrollArea.tsx +54 -0
- package/src/components/ui/SettingsLayout.tsx +94 -0
- package/src/components/ui/ShowcaseWrapper.tsx +4 -16
- package/src/components/ui/Sidebar.tsx +77 -0
- package/src/components/ui/Snack.tsx +48 -0
- package/src/components/ui/Spinner.tsx +30 -0
- package/src/components/ui/Splotch.tsx +33 -0
- package/src/components/ui/Stepper.tsx +5 -6
- package/src/components/ui/Table.tsx +2 -0
- package/src/components/ui/Tag.tsx +65 -0
- package/src/components/ui/Toggle.tsx +62 -0
- package/src/components/ui/Tooltip.test.tsx +58 -0
- package/src/components/ui/Tooltip.tsx +211 -0
- package/src/components/ui/Widget.tsx +31 -0
- package/src/components/ui/first-slice.test.tsx +98 -0
- package/src/components/ui/index.ts +335 -22
- package/src/components/ui/interaction-primitives.test.tsx +77 -0
- package/src/components/ui/kbd-icons.test.tsx +90 -0
- package/src/components/ui/product-patterns.test.tsx +77 -0
- package/src/styles/themes.css +411 -1
- package/tailwind-preset.ts +7 -0
- package/dist/chunk-SHF57J7U.mjs +0 -2910
- package/dist/index-CmS6hcUk.d.mts +0 -753
- package/dist/index-CmS6hcUk.d.ts +0 -753
- package/src/components/charts/ph-insight-charts.tsx +0 -162
- package/src/components/dashboard/DashboardFiltersBar.tsx +0 -19
- package/src/components/dashboard/DashboardGrid.tsx +0 -23
- package/src/components/dashboard/DashboardInsightPlaceholder.tsx +0 -37
- package/src/components/dashboard/DashboardKpiCard.tsx +0 -25
- package/src/components/dashboard/DashboardToolbar.tsx +0 -23
- package/src/components/dashboard/DashboardWell.tsx +0 -10
- package/src/components/dashboard/InsightMiniCard.tsx +0 -26
- package/src/components/dashboard/InsightShell.tsx +0 -37
- package/src/components/dashboard/InsightShellHeader.tsx +0 -22
- package/src/components/dashboard/MiniTrendBars.tsx +0 -51
- package/src/components/dashboard/index.ts +0 -31
- package/src/components/dashboard/types.ts +0 -9
- package/src/components/sections/ChartShowcase.tsx +0 -190
- package/src/components/sections/DashboardShowcase.tsx +0 -191
- package/src/components/sections/StatShowcase.tsx +0 -129
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
|
|
4
|
+
export type TagType = "default" | "primary" | "success" | "warning" | "danger" | "info" | "muted" | "highlight";
|
|
5
|
+
|
|
6
|
+
export interface TagProps extends HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
type?: TagType;
|
|
8
|
+
size?: "sm" | "md";
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
closable?: boolean;
|
|
11
|
+
onClose?: () => void;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const typeClass: Record<TagType, string> = {
|
|
16
|
+
default: "ph-tag-default",
|
|
17
|
+
primary: "ph-tag-primary",
|
|
18
|
+
success: "ph-tag-success",
|
|
19
|
+
warning: "ph-tag-warning",
|
|
20
|
+
danger: "ph-tag-danger",
|
|
21
|
+
info: "ph-tag-info",
|
|
22
|
+
muted: "ph-tag-muted",
|
|
23
|
+
highlight: "ph-tag-highlight",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Tag = forwardRef<HTMLDivElement, TagProps>(function Tag({
|
|
27
|
+
type = "default",
|
|
28
|
+
size = "md",
|
|
29
|
+
icon,
|
|
30
|
+
closable = false,
|
|
31
|
+
onClose,
|
|
32
|
+
className,
|
|
33
|
+
children,
|
|
34
|
+
...props
|
|
35
|
+
}, ref) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
ref={ref}
|
|
39
|
+
className={cn(
|
|
40
|
+
"ph-tag",
|
|
41
|
+
typeClass[type],
|
|
42
|
+
size === "sm" && "ph-tag-sm",
|
|
43
|
+
className
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
>
|
|
47
|
+
{icon && <span className="ph-tag__icon">{icon}</span>}
|
|
48
|
+
<span className="ph-tag__text">{children}</span>
|
|
49
|
+
{closable && (
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={(e) => { e.stopPropagation(); onClose?.(); }}
|
|
53
|
+
className="ph-tag__close"
|
|
54
|
+
aria-label="Remove"
|
|
55
|
+
>
|
|
56
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
57
|
+
<path d="M3 3l6 6M9 3l-6 6" />
|
|
58
|
+
</svg>
|
|
59
|
+
</button>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
Tag.displayName = "Tag";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface ToggleButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
5
|
+
pressed?: boolean;
|
|
6
|
+
onPressedChange?: (pressed: boolean) => void;
|
|
7
|
+
icon?: ReactNode;
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(function ToggleButton({
|
|
12
|
+
pressed = false,
|
|
13
|
+
onPressedChange,
|
|
14
|
+
icon,
|
|
15
|
+
children,
|
|
16
|
+
className,
|
|
17
|
+
...props
|
|
18
|
+
}, ref) {
|
|
19
|
+
return (
|
|
20
|
+
<button
|
|
21
|
+
ref={ref}
|
|
22
|
+
type="button"
|
|
23
|
+
aria-pressed={pressed}
|
|
24
|
+
onClick={() => onPressedChange?.(!pressed)}
|
|
25
|
+
className={cn(
|
|
26
|
+
"ph-toggle-btn",
|
|
27
|
+
pressed && "ph-toggle-btn--pressed",
|
|
28
|
+
className
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
{icon && <span className="ph-toggle-btn__icon">{icon}</span>}
|
|
33
|
+
{children}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
ToggleButton.displayName = "ToggleButton";
|
|
39
|
+
|
|
40
|
+
export interface ToggleGroupProps {
|
|
41
|
+
type?: "single" | "multiple";
|
|
42
|
+
value?: string | string[];
|
|
43
|
+
onValueChange?: (value: string | string[]) => void;
|
|
44
|
+
children: ReactNode;
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ToggleGroup({
|
|
49
|
+
type = "single",
|
|
50
|
+
value,
|
|
51
|
+
onValueChange,
|
|
52
|
+
children,
|
|
53
|
+
className,
|
|
54
|
+
}: ToggleGroupProps) {
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn("ph-toggle-group", className)} role="group">
|
|
57
|
+
{children}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ToggleGroup.displayName = "ToggleGroup";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { Tooltip, TooltipProvider } from "@/components/ui/Tooltip";
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe("Tooltip", () => {
|
|
9
|
+
it("opens when its trigger is hovered", async () => {
|
|
10
|
+
const user = userEvent.setup();
|
|
11
|
+
render(
|
|
12
|
+
<Tooltip content="Hover details" delayDuration={0}>
|
|
13
|
+
<button type="button">Hover target</button>
|
|
14
|
+
</Tooltip>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
await user.hover(screen.getByRole("button", { name: "Hover target" }));
|
|
18
|
+
|
|
19
|
+
expect(await screen.findByRole("tooltip")).toHaveTextContent("Hover details");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("opens on keyboard focus and exposes its description to assistive technology", async () => {
|
|
23
|
+
const user = userEvent.setup();
|
|
24
|
+
render(
|
|
25
|
+
<TooltipProvider delayDuration={0}>
|
|
26
|
+
<Tooltip content="Keyboard details" side="bottom">
|
|
27
|
+
<button type="button">Focus target</button>
|
|
28
|
+
</Tooltip>
|
|
29
|
+
</TooltipProvider>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
await user.tab();
|
|
33
|
+
|
|
34
|
+
const trigger = screen.getByRole("button", { name: "Focus target" });
|
|
35
|
+
const tooltip = await screen.findByRole("tooltip");
|
|
36
|
+
expect(trigger).toHaveFocus();
|
|
37
|
+
expect(tooltip).toHaveTextContent("Keyboard details");
|
|
38
|
+
expect(trigger).toHaveAttribute("aria-describedby", tooltip.id);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("dismisses an open tooltip with Escape without moving focus", async () => {
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
render(
|
|
44
|
+
<Tooltip content="Dismissible details">
|
|
45
|
+
<button type="button">Dismiss target</button>
|
|
46
|
+
</Tooltip>,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
await user.tab();
|
|
50
|
+
const trigger = screen.getByRole("button", { name: "Dismiss target" });
|
|
51
|
+
expect(await screen.findByRole("tooltip")).toBeInTheDocument();
|
|
52
|
+
|
|
53
|
+
await user.keyboard("{Escape}");
|
|
54
|
+
|
|
55
|
+
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
|
|
56
|
+
expect(trigger).toHaveFocus();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
cloneElement,
|
|
5
|
+
createContext,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useId,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type HTMLAttributes,
|
|
12
|
+
type ReactElement,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { createPortal } from "react-dom";
|
|
16
|
+
import { cn } from "@/lib/cn";
|
|
17
|
+
|
|
18
|
+
export type TooltipSide = "top" | "right" | "bottom" | "left";
|
|
19
|
+
export type TooltipAlign = "start" | "center" | "end";
|
|
20
|
+
|
|
21
|
+
export interface TooltipProviderProps {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
delayDuration?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TooltipProps {
|
|
27
|
+
content: ReactNode;
|
|
28
|
+
children: ReactElement<HTMLAttributes<HTMLElement>>;
|
|
29
|
+
side?: TooltipSide;
|
|
30
|
+
align?: TooltipAlign;
|
|
31
|
+
sideOffset?: number;
|
|
32
|
+
collisionPadding?: number;
|
|
33
|
+
className?: string;
|
|
34
|
+
open?: boolean;
|
|
35
|
+
defaultOpen?: boolean;
|
|
36
|
+
onOpenChange?: (open: boolean) => void;
|
|
37
|
+
delayDuration?: number;
|
|
38
|
+
disableHoverableContent?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TooltipDelayContext = createContext(0);
|
|
42
|
+
|
|
43
|
+
export function TooltipProvider({
|
|
44
|
+
children,
|
|
45
|
+
delayDuration = 0,
|
|
46
|
+
}: TooltipProviderProps) {
|
|
47
|
+
return (
|
|
48
|
+
<TooltipDelayContext.Provider value={delayDuration}>
|
|
49
|
+
{children}
|
|
50
|
+
</TooltipDelayContext.Provider>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tooltipPosition(
|
|
55
|
+
trigger: DOMRect,
|
|
56
|
+
side: TooltipSide,
|
|
57
|
+
align: TooltipAlign,
|
|
58
|
+
offset: number,
|
|
59
|
+
) {
|
|
60
|
+
const horizontal =
|
|
61
|
+
align === "start"
|
|
62
|
+
? trigger.left
|
|
63
|
+
: align === "end"
|
|
64
|
+
? trigger.right
|
|
65
|
+
: trigger.left + trigger.width / 2;
|
|
66
|
+
const vertical =
|
|
67
|
+
align === "start"
|
|
68
|
+
? trigger.top
|
|
69
|
+
: align === "end"
|
|
70
|
+
? trigger.bottom
|
|
71
|
+
: trigger.top + trigger.height / 2;
|
|
72
|
+
if (side === "top")
|
|
73
|
+
return {
|
|
74
|
+
left: horizontal,
|
|
75
|
+
top: trigger.top - offset,
|
|
76
|
+
transform: "translate(-50%, -100%)",
|
|
77
|
+
};
|
|
78
|
+
if (side === "bottom")
|
|
79
|
+
return {
|
|
80
|
+
left: horizontal,
|
|
81
|
+
top: trigger.bottom + offset,
|
|
82
|
+
transform: "translate(-50%, 0)",
|
|
83
|
+
};
|
|
84
|
+
if (side === "left")
|
|
85
|
+
return {
|
|
86
|
+
left: trigger.left - offset,
|
|
87
|
+
top: vertical,
|
|
88
|
+
transform: "translate(-100%, -50%)",
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
left: trigger.right + offset,
|
|
92
|
+
top: vertical,
|
|
93
|
+
transform: "translate(0, -50%)",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function Tooltip({
|
|
98
|
+
content,
|
|
99
|
+
children,
|
|
100
|
+
side = "top",
|
|
101
|
+
align = "center",
|
|
102
|
+
sideOffset = 7,
|
|
103
|
+
className,
|
|
104
|
+
open,
|
|
105
|
+
defaultOpen = false,
|
|
106
|
+
onOpenChange,
|
|
107
|
+
delayDuration,
|
|
108
|
+
}: TooltipProps) {
|
|
109
|
+
const providerDelay = useContext(TooltipDelayContext);
|
|
110
|
+
const generatedId = useId().replace(/:/g, "");
|
|
111
|
+
const triggerRef = useRef<HTMLSpanElement>(null);
|
|
112
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
113
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
114
|
+
const [position, setPosition] = useState<ReturnType<
|
|
115
|
+
typeof tooltipPosition
|
|
116
|
+
> | null>(null);
|
|
117
|
+
const isOpen = open ?? internalOpen;
|
|
118
|
+
const triggerId = children.props.id;
|
|
119
|
+
const tooltipId = triggerId
|
|
120
|
+
? `${triggerId}-tooltip`
|
|
121
|
+
: `ph-tooltip-${generatedId}`;
|
|
122
|
+
|
|
123
|
+
function setOpen(next: boolean) {
|
|
124
|
+
if (open === undefined) setInternalOpen(next);
|
|
125
|
+
onOpenChange?.(next);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function clearTimer() {
|
|
129
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
130
|
+
timerRef.current = null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function updatePosition() {
|
|
134
|
+
if (!triggerRef.current) return;
|
|
135
|
+
setPosition(
|
|
136
|
+
tooltipPosition(
|
|
137
|
+
triggerRef.current.getBoundingClientRect(),
|
|
138
|
+
side,
|
|
139
|
+
align,
|
|
140
|
+
sideOffset,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function show(immediate: boolean) {
|
|
146
|
+
clearTimer();
|
|
147
|
+
updatePosition();
|
|
148
|
+
const delay = immediate ? 0 : (delayDuration ?? providerDelay);
|
|
149
|
+
if (!delay) setOpen(true);
|
|
150
|
+
else timerRef.current = setTimeout(() => setOpen(true), delay);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hide() {
|
|
154
|
+
clearTimer();
|
|
155
|
+
setOpen(false);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (!isOpen) return;
|
|
160
|
+
const reposition = () => updatePosition();
|
|
161
|
+
window.addEventListener("resize", reposition);
|
|
162
|
+
window.addEventListener("scroll", reposition, true);
|
|
163
|
+
return () => {
|
|
164
|
+
window.removeEventListener("resize", reposition);
|
|
165
|
+
window.removeEventListener("scroll", reposition, true);
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
useEffect(() => () => clearTimer(), []);
|
|
170
|
+
|
|
171
|
+
const describedBy = [children.props["aria-describedby"], tooltipId]
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.join(" ");
|
|
174
|
+
const trigger = cloneElement(children, { "aria-describedby": describedBy });
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<>
|
|
178
|
+
<span
|
|
179
|
+
ref={triggerRef}
|
|
180
|
+
className="inline-flex"
|
|
181
|
+
onMouseEnter={() => show(false)}
|
|
182
|
+
onMouseLeave={hide}
|
|
183
|
+
onFocusCapture={() => show(true)}
|
|
184
|
+
onBlurCapture={hide}
|
|
185
|
+
onKeyDown={(event) => {
|
|
186
|
+
if (event.key === "Escape") hide();
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{trigger}
|
|
190
|
+
</span>
|
|
191
|
+
{isOpen && position && typeof document !== "undefined"
|
|
192
|
+
? createPortal(
|
|
193
|
+
<span
|
|
194
|
+
style={{ position: "fixed", ...position }}
|
|
195
|
+
className="pointer-events-none z-[200]"
|
|
196
|
+
>
|
|
197
|
+
<span
|
|
198
|
+
id={tooltipId}
|
|
199
|
+
role="tooltip"
|
|
200
|
+
data-side={side}
|
|
201
|
+
className={cn("ph-tooltip-content", className)}
|
|
202
|
+
>
|
|
203
|
+
{content}
|
|
204
|
+
</span>
|
|
205
|
+
</span>,
|
|
206
|
+
document.body,
|
|
207
|
+
)
|
|
208
|
+
: null}
|
|
209
|
+
</>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface WidgetProps {
|
|
5
|
+
title: ReactNode;
|
|
6
|
+
onClose?: () => void;
|
|
7
|
+
actions?: ReactNode;
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Widget({ title, onClose, actions, children, className }: WidgetProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className={cn("ph-widget", className)}>
|
|
15
|
+
<div className="ph-widget__header">
|
|
16
|
+
<span className="ph-widget__title">{title}</span>
|
|
17
|
+
{actions}
|
|
18
|
+
{onClose && (
|
|
19
|
+
<button type="button" onClick={onClose} className="ph-widget__close" aria-label="Close">
|
|
20
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
21
|
+
<path d="M3 3l8 8M11 3l-8 8" />
|
|
22
|
+
</svg>
|
|
23
|
+
</button>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
<div className="ph-widget__content">{children}</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Widget.displayName = "Widget";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createRef, useState } from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Alert } from "@/components/ui/Alert";
|
|
6
|
+
import { Badge } from "@/components/ui/Badge";
|
|
7
|
+
import { Button } from "@/components/ui/Button";
|
|
8
|
+
import { Card } from "@/components/ui/Card";
|
|
9
|
+
import { Input } from "@/components/ui/Input";
|
|
10
|
+
import { Modal } from "@/components/ui/Modal";
|
|
11
|
+
|
|
12
|
+
describe("first-slice components", () => {
|
|
13
|
+
it("forwards a button ref and exposes loading state", () => {
|
|
14
|
+
const ref = createRef<HTMLButtonElement>();
|
|
15
|
+
render(<Button ref={ref} loading>Save changes</Button>);
|
|
16
|
+
|
|
17
|
+
expect(ref.current).toBe(screen.getByRole("button", { name: "Save changes" }));
|
|
18
|
+
expect(ref.current).toBeDisabled();
|
|
19
|
+
expect(ref.current).toHaveAttribute("aria-busy", "true");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses the same tactile chrome for every filled variant", () => {
|
|
23
|
+
const { rerender } = render(<Button variant="signal">Launch</Button>);
|
|
24
|
+
expect(screen.getByRole("button", { name: "Launch" })).toHaveClass("ph-btn-raised");
|
|
25
|
+
expect(screen.getByRole("button", { name: "Launch" }).querySelector(".ph-btn-chrome")).toBeInTheDocument();
|
|
26
|
+
|
|
27
|
+
rerender(<Button variant="danger">Delete</Button>);
|
|
28
|
+
expect(screen.getByRole("button", { name: "Delete" }).querySelector(".ph-btn-chrome")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("associates input help and error messages", () => {
|
|
32
|
+
const { rerender } = render(
|
|
33
|
+
<Input label="Email" helperText="Use your work address" />,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(screen.getByLabelText("Email")).toHaveAccessibleDescription("Use your work address");
|
|
37
|
+
|
|
38
|
+
rerender(<Input label="Email" error="Enter a valid email address" />);
|
|
39
|
+
const input = screen.getByLabelText("Email");
|
|
40
|
+
expect(input).toHaveAttribute("aria-invalid", "true");
|
|
41
|
+
expect(input).toHaveAccessibleErrorMessage("Enter a valid email address");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps cards static unless explicitly interactive", () => {
|
|
45
|
+
const { rerender } = render(<Card title="Static card" />);
|
|
46
|
+
expect(screen.getByText("Static card").closest(".ph-card")).not.toHaveClass("ph-card-interactive");
|
|
47
|
+
|
|
48
|
+
rerender(<Card title="Interactive card" interactive />);
|
|
49
|
+
expect(screen.getByText("Interactive card").closest(".ph-card")).toHaveClass("ph-card-interactive");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("forwards badge attributes and keeps static alerts out of live regions", () => {
|
|
53
|
+
render(
|
|
54
|
+
<>
|
|
55
|
+
<Badge data-testid="badge">Live</Badge>
|
|
56
|
+
<Alert title="Saved" />
|
|
57
|
+
<Alert title="Background sync complete" live="polite" />
|
|
58
|
+
</>,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(screen.getByTestId("badge")).toHaveTextContent("Live");
|
|
62
|
+
expect(screen.getByText("Saved").closest(".ph-alert")).not.toHaveAttribute("role");
|
|
63
|
+
expect(screen.getByRole("status")).toHaveTextContent("Background sync complete");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("closes the modal with Escape and restores trigger focus", async () => {
|
|
67
|
+
const user = userEvent.setup();
|
|
68
|
+
const onClose = vi.fn();
|
|
69
|
+
|
|
70
|
+
function Fixture() {
|
|
71
|
+
const [open, setOpen] = useState(false);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
<button type="button" onClick={() => setOpen(true)}>Open settings</button>
|
|
76
|
+
<Modal
|
|
77
|
+
open={open}
|
|
78
|
+
onClose={() => {
|
|
79
|
+
onClose();
|
|
80
|
+
setOpen(false);
|
|
81
|
+
}}
|
|
82
|
+
title="Workspace settings"
|
|
83
|
+
>
|
|
84
|
+
<button type="button">Save settings</button>
|
|
85
|
+
</Modal>
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render(<Fixture />);
|
|
91
|
+
const trigger = screen.getByRole("button", { name: "Open settings" });
|
|
92
|
+
await user.click(trigger);
|
|
93
|
+
expect(screen.getByRole("dialog", { name: "Workspace settings" })).toBeInTheDocument();
|
|
94
|
+
await user.keyboard("{Escape}");
|
|
95
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(trigger).toHaveFocus();
|
|
97
|
+
});
|
|
98
|
+
});
|