@xenide-io/the-old-ui-theme 0.7.0 → 0.9.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 +9 -10
- package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
- package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
- package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
- package/dist/chunk-2EO5VCXP.mjs +3912 -0
- package/dist/chunk-APKRZQRO.mjs +251 -0
- package/dist/chunk-BS7BZI5W.mjs +19 -0
- package/dist/chunk-IULORI3A.mjs +8 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +997 -576
- package/dist/index.mjs +49 -45
- package/dist/suite.d.mts +157 -55
- package/dist/suite.d.ts +157 -55
- package/dist/suite.js +4249 -801
- package/dist/suite.mjs +2578 -594
- package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
- package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
- package/dist/theme-boot.d.mts +2 -0
- package/dist/theme-boot.d.ts +2 -0
- package/dist/theme-boot.js +44 -0
- package/dist/theme-boot.mjs +8 -0
- package/dist/ui.d.mts +5 -29
- package/dist/ui.d.ts +5 -29
- package/dist/ui.js +647 -352
- package/dist/ui.mjs +10 -12
- package/docs/README.md +34 -0
- package/docs/ai-quick-reference.md +56 -0
- package/docs/component-library-improvement-plan.md +751 -0
- package/docs/component-reference.md +81 -0
- package/docs/components.md +48 -0
- package/docs/demos.md +19 -0
- package/docs/installation.md +117 -0
- package/docs/suite-audit.md +34 -0
- package/docs/theme-token-reference.md +67 -0
- package/docs/themes.md +164 -0
- package/package.json +22 -21
- package/public/fonts/OpenRunde-Bold.woff2 +0 -0
- package/public/fonts/OpenRunde-Medium.woff2 +0 -0
- package/public/fonts/OpenRunde-Regular.woff2 +0 -0
- package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
- package/scripts/package-smoke.mjs +43 -0
- package/src/app/globals.css +2 -1
- package/src/components/ui/Accordion.tsx +20 -6
- package/src/components/ui/Avatar.tsx +5 -3
- package/src/components/ui/Calendar.tsx +187 -63
- package/src/components/ui/Card.test.tsx +38 -0
- package/src/components/ui/Card.tsx +77 -14
- package/src/components/ui/CollapsibleSection.tsx +12 -4
- package/src/components/ui/CommandPalette.test.tsx +91 -4
- package/src/components/ui/CommandPalette.tsx +138 -128
- package/src/components/ui/EmptyState.tsx +2 -2
- package/src/components/ui/FormField.tsx +1 -1
- package/src/components/ui/Input.tsx +42 -18
- package/src/components/ui/Kbd.tsx +18 -3
- package/src/components/ui/Modal.tsx +3 -1
- package/src/components/ui/Progress.tsx +14 -5
- package/src/components/ui/SegmentedControl.tsx +2 -2
- package/src/components/ui/SettingsLayout.tsx +1 -1
- package/src/components/ui/Stat.tsx +3 -2
- package/src/components/ui/Table.tsx +8 -3
- package/src/components/ui/Tooltip.test.tsx +70 -1
- package/src/components/ui/Tooltip.tsx +53 -29
- package/src/components/ui/Typography.tsx +103 -49
- package/src/components/ui/avatar-stat.test.tsx +23 -0
- package/src/components/ui/calendar.test.tsx +88 -0
- package/src/components/ui/disclosure.test.tsx +39 -0
- package/src/components/ui/index.ts +44 -6
- package/src/components/ui/progress.test.tsx +15 -0
- package/src/components/ui/typography.test.tsx +26 -1
- package/src/styles/suite-skin.css +8 -2
- package/src/styles/themes.css +184 -68
- package/src/suite/components/ai-message-blocknote.tsx +13 -2
- package/src/suite/components/ai-panel.tsx +41 -10
- package/src/suite/components/app-switcher.test.tsx +21 -6
- package/src/suite/components/app-switcher.tsx +7 -11
- package/src/suite/components/suite-app-layout.tsx +12 -3
- package/src/suite/components/suite-bottom-nav.tsx +0 -1
- package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
- package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
- package/src/suite/components/suite-integration-picker.test.tsx +224 -0
- package/src/suite/components/suite-integration-picker.tsx +939 -0
- package/src/suite/components/suite-integration-rules.test.tsx +157 -0
- package/src/suite/components/suite-integration-rules.tsx +471 -0
- package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
- package/src/suite/components/suite-mobile-drawer.tsx +65 -98
- package/src/suite/components/suite-notification-bell.tsx +29 -4
- package/src/suite/components/suite-sidebar.tsx +25 -25
- package/src/suite/components/suite-skeleton.tsx +2 -2
- package/src/suite/components/suite-user-menu.tsx +9 -8
- package/src/suite/components/theme-provider.test.tsx +87 -3
- package/src/suite/components/theme-provider.tsx +144 -23
- package/src/suite/components/today-ui.tsx +79 -68
- package/src/suite/components/workspace-switcher.test.tsx +56 -0
- package/src/suite/components/workspace-switcher.tsx +3 -3
- package/src/suite/icons/app-accents.ts +12 -12
- package/src/suite/icons/glyphs.ts +6 -12
- package/src/suite/index.ts +27 -0
- package/src/suite/lib/apps.test.ts +9 -1
- package/src/suite/lib/apps.ts +14 -8
- package/src/suite/lib/cookies.ts +53 -0
- package/src/suite/lib/theme-boot.ts +32 -0
- package/src/suite/lib/theme-cookie.ts +7 -0
- package/src/suite/lib/use-sidebar-width.ts +5 -51
- package/dist/chunk-KDR6HI6F.mjs +0 -1075
- package/dist/chunk-NOI42JNZ.mjs +0 -151
- package/dist/chunk-VXISSXTB.mjs +0 -2564
- package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
- package/src/suite/lib/use-lock-mobile-page-zoom.ts +0 -51
|
@@ -37,7 +37,7 @@ export function SettingsNav({ groups, className, ...props }: SettingsNavProps) {
|
|
|
37
37
|
<div className={cn("ph-settings-nav", className)} {...props}>
|
|
38
38
|
{groups.map((group, index) => (
|
|
39
39
|
<div className="ph-settings-nav__group" key={index}>
|
|
40
|
-
<
|
|
40
|
+
<p className="ph-settings-nav__heading">{group.label}</p>
|
|
41
41
|
<div className="ph-settings-nav__items">
|
|
42
42
|
{group.items.map((item) => (
|
|
43
43
|
<a
|
|
@@ -16,7 +16,7 @@ const toneMap: Record<StatTone, string> = {
|
|
|
16
16
|
brand: "text-ph-brand",
|
|
17
17
|
blue: "text-ph-blue",
|
|
18
18
|
purple: "text-ph-purple",
|
|
19
|
-
warning: "text-
|
|
19
|
+
warning: "text-ph-warning",
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
const statMap: Record<StatTone, string> = {
|
|
@@ -24,7 +24,8 @@ const statMap: Record<StatTone, string> = {
|
|
|
24
24
|
brand: "",
|
|
25
25
|
blue: "",
|
|
26
26
|
purple: "",
|
|
27
|
-
warning:
|
|
27
|
+
warning:
|
|
28
|
+
"border-[color:color-mix(in_srgb,var(--ph-warning)_30%,var(--ph-border))] bg-[color:color-mix(in_srgb,var(--ph-warning)_10%,var(--ph-surface))]",
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
export function Stat({ icon, label, value, footer, tone = "default", className, ...props }: StatProps) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type { CSSProperties, ReactNode } from "react";
|
|
3
|
+
import type { CSSProperties, Key, ReactNode } from "react";
|
|
4
4
|
import { cn } from "@/lib/cn";
|
|
5
5
|
|
|
6
6
|
export interface TableColumn<T> {
|
|
@@ -17,6 +17,7 @@ export interface TableProps<T> {
|
|
|
17
17
|
compact?: boolean;
|
|
18
18
|
ribbon?: boolean;
|
|
19
19
|
getRowStyle?: (row: T) => CSSProperties | undefined;
|
|
20
|
+
getRowKey?: (row: T, index: number) => Key;
|
|
20
21
|
className?: string;
|
|
21
22
|
caption?: string;
|
|
22
23
|
}
|
|
@@ -28,11 +29,15 @@ export function Table<T>({
|
|
|
28
29
|
compact = false,
|
|
29
30
|
ribbon = false,
|
|
30
31
|
getRowStyle,
|
|
32
|
+
getRowKey,
|
|
31
33
|
className,
|
|
32
34
|
caption,
|
|
33
35
|
}: TableProps<T>) {
|
|
34
36
|
return (
|
|
35
|
-
<div
|
|
37
|
+
<div
|
|
38
|
+
className={cn("ph-table-wrap", compact && "ph-table-compact", className)}
|
|
39
|
+
tabIndex={0}
|
|
40
|
+
>
|
|
36
41
|
<table className={cn("ph-table", zebra && "ph-table-zebra", ribbon && "ph-table--ribbon")}>
|
|
37
42
|
{caption && <caption className="sr-only">{caption}</caption>}
|
|
38
43
|
<thead>
|
|
@@ -44,7 +49,7 @@ export function Table<T>({
|
|
|
44
49
|
</thead>
|
|
45
50
|
<tbody>
|
|
46
51
|
{data.map((row, i) => (
|
|
47
|
-
<tr key={i} style={getRowStyle?.(row)}>
|
|
52
|
+
<tr key={getRowKey?.(row, i) ?? i} style={getRowStyle?.(row)}>
|
|
48
53
|
{columns.map((col) => (
|
|
49
54
|
<td key={col.key} className={col.className}>{col.cell(row)}</td>
|
|
50
55
|
))}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { Tooltip, TooltipProvider } from "@/components/ui/Tooltip";
|
|
5
5
|
|
|
6
6
|
afterEach(cleanup);
|
|
@@ -55,4 +55,73 @@ describe("Tooltip", () => {
|
|
|
55
55
|
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
|
|
56
56
|
expect(trigger).toHaveFocus();
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it("keeps hoverable content open while the pointer moves from its trigger", async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
render(
|
|
62
|
+
<Tooltip content="Hoverable details" delayDuration={0}>
|
|
63
|
+
<button type="button">Hover target</button>
|
|
64
|
+
</Tooltip>,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const trigger = screen.getByRole("button", { name: "Hover target" });
|
|
68
|
+
await user.hover(trigger);
|
|
69
|
+
const tooltip = await screen.findByRole("tooltip");
|
|
70
|
+
await user.unhover(trigger);
|
|
71
|
+
await user.hover(tooltip);
|
|
72
|
+
|
|
73
|
+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
|
74
|
+
await user.unhover(tooltip);
|
|
75
|
+
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("closes when the trigger is left if hoverable content is disabled", async () => {
|
|
79
|
+
const user = userEvent.setup();
|
|
80
|
+
render(
|
|
81
|
+
<Tooltip content="Non-hoverable details" delayDuration={0} disableHoverableContent>
|
|
82
|
+
<button type="button">Hover target</button>
|
|
83
|
+
</Tooltip>,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const trigger = screen.getByRole("button", { name: "Hover target" });
|
|
87
|
+
await user.hover(trigger);
|
|
88
|
+
expect(await screen.findByRole("tooltip")).toBeInTheDocument();
|
|
89
|
+
await user.unhover(trigger);
|
|
90
|
+
|
|
91
|
+
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps content within its collision padding", async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
const originalInnerWidth = window.innerWidth;
|
|
97
|
+
const originalInnerHeight = window.innerHeight;
|
|
98
|
+
Object.defineProperty(window, "innerWidth", { configurable: true, value: 200 });
|
|
99
|
+
Object.defineProperty(window, "innerHeight", { configurable: true, value: 100 });
|
|
100
|
+
const rect = vi
|
|
101
|
+
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
|
|
102
|
+
.mockImplementation(function (this: HTMLElement) {
|
|
103
|
+
if (this.getAttribute("role") === "tooltip") {
|
|
104
|
+
return new DOMRect(0, 0, 100, 30);
|
|
105
|
+
}
|
|
106
|
+
return new DOMRect(2, 2, 20, 20);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
render(
|
|
111
|
+
<Tooltip content="Padded details" collisionPadding={16} delayDuration={0}>
|
|
112
|
+
<button type="button">Padded target</button>
|
|
113
|
+
</Tooltip>,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await user.hover(screen.getByRole("button", { name: "Padded target" }));
|
|
117
|
+
const tooltip = await screen.findByRole("tooltip");
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
expect(tooltip.parentElement).toHaveStyle({ left: "16px", top: "16px" });
|
|
120
|
+
});
|
|
121
|
+
} finally {
|
|
122
|
+
rect.mockRestore();
|
|
123
|
+
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
|
|
124
|
+
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
58
127
|
});
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import {
|
|
4
4
|
cloneElement,
|
|
5
5
|
createContext,
|
|
6
|
+
useCallback,
|
|
6
7
|
useContext,
|
|
7
8
|
useEffect,
|
|
8
9
|
useId,
|
|
10
|
+
useLayoutEffect,
|
|
9
11
|
useRef,
|
|
10
12
|
useState,
|
|
11
13
|
type HTMLAttributes,
|
|
@@ -29,12 +31,14 @@ export interface TooltipProps {
|
|
|
29
31
|
side?: TooltipSide;
|
|
30
32
|
align?: TooltipAlign;
|
|
31
33
|
sideOffset?: number;
|
|
34
|
+
/** Minimum distance between the tooltip and the viewport edge. */
|
|
32
35
|
collisionPadding?: number;
|
|
33
36
|
className?: string;
|
|
34
37
|
open?: boolean;
|
|
35
38
|
defaultOpen?: boolean;
|
|
36
39
|
onOpenChange?: (open: boolean) => void;
|
|
37
40
|
delayDuration?: number;
|
|
41
|
+
/** Prevents the pointer from keeping the tooltip open over its content. */
|
|
38
42
|
disableHoverableContent?: boolean;
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -53,9 +57,11 @@ export function TooltipProvider({
|
|
|
53
57
|
|
|
54
58
|
function tooltipPosition(
|
|
55
59
|
trigger: DOMRect,
|
|
60
|
+
content: DOMRect,
|
|
56
61
|
side: TooltipSide,
|
|
57
62
|
align: TooltipAlign,
|
|
58
63
|
offset: number,
|
|
64
|
+
collisionPadding: number,
|
|
59
65
|
) {
|
|
60
66
|
const horizontal =
|
|
61
67
|
align === "start"
|
|
@@ -69,28 +75,20 @@ function tooltipPosition(
|
|
|
69
75
|
: align === "end"
|
|
70
76
|
? trigger.bottom
|
|
71
77
|
: trigger.top + trigger.height / 2;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
left: horizontal,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
};
|
|
84
|
-
if (side === "left")
|
|
85
|
-
return {
|
|
86
|
-
left: trigger.left - offset,
|
|
87
|
-
top: vertical,
|
|
88
|
-
transform: "translate(-100%, -50%)",
|
|
89
|
-
};
|
|
78
|
+
const position =
|
|
79
|
+
side === "top"
|
|
80
|
+
? { left: horizontal - content.width / 2, top: trigger.top - offset - content.height }
|
|
81
|
+
: side === "bottom"
|
|
82
|
+
? { left: horizontal - content.width / 2, top: trigger.bottom + offset }
|
|
83
|
+
: side === "left"
|
|
84
|
+
? { left: trigger.left - offset - content.width, top: vertical - content.height / 2 }
|
|
85
|
+
: { left: trigger.right + offset, top: vertical - content.height / 2 };
|
|
86
|
+
const maxLeft = Math.max(collisionPadding, window.innerWidth - content.width - collisionPadding);
|
|
87
|
+
const maxTop = Math.max(collisionPadding, window.innerHeight - content.height - collisionPadding);
|
|
88
|
+
|
|
90
89
|
return {
|
|
91
|
-
left:
|
|
92
|
-
top:
|
|
93
|
-
transform: "translate(0, -50%)",
|
|
90
|
+
left: Math.min(Math.max(position.left, collisionPadding), maxLeft),
|
|
91
|
+
top: Math.min(Math.max(position.top, collisionPadding), maxTop),
|
|
94
92
|
};
|
|
95
93
|
}
|
|
96
94
|
|
|
@@ -100,15 +98,18 @@ export function Tooltip({
|
|
|
100
98
|
side = "top",
|
|
101
99
|
align = "center",
|
|
102
100
|
sideOffset = 7,
|
|
101
|
+
collisionPadding = 8,
|
|
103
102
|
className,
|
|
104
103
|
open,
|
|
105
104
|
defaultOpen = false,
|
|
106
105
|
onOpenChange,
|
|
107
106
|
delayDuration,
|
|
107
|
+
disableHoverableContent = false,
|
|
108
108
|
}: TooltipProps) {
|
|
109
109
|
const providerDelay = useContext(TooltipDelayContext);
|
|
110
110
|
const generatedId = useId().replace(/:/g, "");
|
|
111
111
|
const triggerRef = useRef<HTMLSpanElement>(null);
|
|
112
|
+
const contentRef = useRef<HTMLSpanElement>(null);
|
|
112
113
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
113
114
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
114
115
|
const [position, setPosition] = useState<ReturnType<
|
|
@@ -130,17 +131,19 @@ export function Tooltip({
|
|
|
130
131
|
timerRef.current = null;
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
|
|
134
|
-
if (!triggerRef.current) return;
|
|
134
|
+
const updatePosition = useCallback(() => {
|
|
135
|
+
if (!triggerRef.current || !contentRef.current) return;
|
|
135
136
|
setPosition(
|
|
136
137
|
tooltipPosition(
|
|
137
138
|
triggerRef.current.getBoundingClientRect(),
|
|
139
|
+
contentRef.current.getBoundingClientRect(),
|
|
138
140
|
side,
|
|
139
141
|
align,
|
|
140
142
|
sideOffset,
|
|
143
|
+
collisionPadding,
|
|
141
144
|
),
|
|
142
145
|
);
|
|
143
|
-
}
|
|
146
|
+
}, [align, collisionPadding, side, sideOffset]);
|
|
144
147
|
|
|
145
148
|
function show(immediate: boolean) {
|
|
146
149
|
clearTimer();
|
|
@@ -155,6 +158,20 @@ export function Tooltip({
|
|
|
155
158
|
setOpen(false);
|
|
156
159
|
}
|
|
157
160
|
|
|
161
|
+
function hideFromTrigger() {
|
|
162
|
+
if (disableHoverableContent) {
|
|
163
|
+
hide();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
clearTimer();
|
|
168
|
+
timerRef.current = setTimeout(() => setOpen(false), 100);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
useLayoutEffect(() => {
|
|
172
|
+
if (isOpen) updatePosition();
|
|
173
|
+
}, [isOpen, updatePosition]);
|
|
174
|
+
|
|
158
175
|
useEffect(() => {
|
|
159
176
|
if (!isOpen) return;
|
|
160
177
|
const reposition = () => updatePosition();
|
|
@@ -164,7 +181,7 @@ export function Tooltip({
|
|
|
164
181
|
window.removeEventListener("resize", reposition);
|
|
165
182
|
window.removeEventListener("scroll", reposition, true);
|
|
166
183
|
};
|
|
167
|
-
});
|
|
184
|
+
}, [isOpen, updatePosition]);
|
|
168
185
|
|
|
169
186
|
useEffect(() => () => clearTimer(), []);
|
|
170
187
|
|
|
@@ -179,7 +196,7 @@ export function Tooltip({
|
|
|
179
196
|
ref={triggerRef}
|
|
180
197
|
className="inline-flex"
|
|
181
198
|
onMouseEnter={() => show(false)}
|
|
182
|
-
onMouseLeave={
|
|
199
|
+
onMouseLeave={hideFromTrigger}
|
|
183
200
|
onFocusCapture={() => show(true)}
|
|
184
201
|
onBlurCapture={hide}
|
|
185
202
|
onKeyDown={(event) => {
|
|
@@ -188,17 +205,24 @@ export function Tooltip({
|
|
|
188
205
|
>
|
|
189
206
|
{trigger}
|
|
190
207
|
</span>
|
|
191
|
-
{isOpen &&
|
|
208
|
+
{isOpen && typeof document !== "undefined"
|
|
192
209
|
? createPortal(
|
|
193
210
|
<span
|
|
194
|
-
style={{ position: "fixed", ...position }}
|
|
211
|
+
style={{ position: "fixed", ...position, visibility: position ? undefined : "hidden" }}
|
|
195
212
|
className="pointer-events-none z-[200]"
|
|
196
213
|
>
|
|
197
214
|
<span
|
|
215
|
+
ref={contentRef}
|
|
198
216
|
id={tooltipId}
|
|
199
217
|
role="tooltip"
|
|
200
218
|
data-side={side}
|
|
201
|
-
className={cn(
|
|
219
|
+
className={cn(
|
|
220
|
+
"ph-tooltip-content",
|
|
221
|
+
!disableHoverableContent && "pointer-events-auto",
|
|
222
|
+
className,
|
|
223
|
+
)}
|
|
224
|
+
onMouseEnter={disableHoverableContent ? undefined : clearTimer}
|
|
225
|
+
onMouseLeave={disableHoverableContent ? undefined : hide}
|
|
202
226
|
>
|
|
203
227
|
{content}
|
|
204
228
|
</span>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { forwardRef, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "@/lib/cn";
|
|
3
3
|
|
|
4
4
|
export type TextTone =
|
|
@@ -27,6 +27,24 @@ export interface TextBaseProps {
|
|
|
27
27
|
truncate?: boolean;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
type TypographyProps<T extends ElementType> = TextBaseProps &
|
|
31
|
+
Omit<ComponentPropsWithoutRef<T>, keyof TextBaseProps>;
|
|
32
|
+
|
|
33
|
+
export type DisplayProps = TypographyProps<"h1">;
|
|
34
|
+
export type SectionTitleProps = TypographyProps<"h2">;
|
|
35
|
+
export type H1Props = TypographyProps<"h1">;
|
|
36
|
+
export type H2Props = TypographyProps<"h2">;
|
|
37
|
+
export type H3Props = TypographyProps<"h3">;
|
|
38
|
+
export type H4Props = TypographyProps<"h4">;
|
|
39
|
+
export type H5Props = TypographyProps<"h5">;
|
|
40
|
+
export type PProps = TypographyProps<"p">;
|
|
41
|
+
export type SmallProps = TypographyProps<"span">;
|
|
42
|
+
export type CaptionProps = TypographyProps<"span">;
|
|
43
|
+
export type OverlineProps = TypographyProps<"span">;
|
|
44
|
+
export type LeadProps = TypographyProps<"p">;
|
|
45
|
+
export type MonoProps = TypographyProps<"code">;
|
|
46
|
+
export type LabelProps = TypographyProps<"label">;
|
|
47
|
+
|
|
30
48
|
const toneMap: Record<TextTone, string> = {
|
|
31
49
|
default: "text-ph-ink",
|
|
32
50
|
muted: "text-ph-mutedtext",
|
|
@@ -61,77 +79,113 @@ function textClass(
|
|
|
61
79
|
* Display — the marketing hero: fluid size, tight tracking, one per page.
|
|
62
80
|
* Product page titles use H1/`SuitePageHeader` instead.
|
|
63
81
|
*/
|
|
64
|
-
export function Display(
|
|
65
|
-
children,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
truncate,
|
|
69
|
-
|
|
70
|
-
}: TextBaseProps) {
|
|
71
|
-
return <h1 className={textClass("ph-hero-title", tone, weight, truncate, className)}>{children}</h1>;
|
|
72
|
-
}
|
|
82
|
+
export const Display = forwardRef<HTMLHeadingElement, DisplayProps>(function Display(
|
|
83
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
84
|
+
ref,
|
|
85
|
+
) {
|
|
86
|
+
return <h1 ref={ref} {...props} className={textClass("ph-hero-title", tone, weight, truncate, className)}>{children}</h1>;
|
|
87
|
+
});
|
|
73
88
|
|
|
74
89
|
/** Section title — fluid; pairs with Display on public pages. */
|
|
75
|
-
export
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
export const SectionTitle = forwardRef<HTMLHeadingElement, SectionTitleProps>(function SectionTitle(
|
|
91
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
92
|
+
ref,
|
|
93
|
+
) {
|
|
94
|
+
return <h2 ref={ref} {...props} className={textClass("ph-section-title", tone, weight, truncate, className)}>{children}</h2>;
|
|
95
|
+
});
|
|
78
96
|
|
|
79
97
|
/** H1 — page title */
|
|
80
|
-
export
|
|
81
|
-
|
|
82
|
-
|
|
98
|
+
export const H1 = forwardRef<HTMLHeadingElement, H1Props>(function H1(
|
|
99
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
100
|
+
ref,
|
|
101
|
+
) {
|
|
102
|
+
return <h1 ref={ref} {...props} className={textClass("text-3xl font-bold tracking-tight md:text-4xl", tone, weight, truncate, className)}>{children}</h1>;
|
|
103
|
+
});
|
|
83
104
|
|
|
84
105
|
/** H2 — section title */
|
|
85
|
-
export
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
export const H2 = forwardRef<HTMLHeadingElement, H2Props>(function H2(
|
|
107
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
108
|
+
ref,
|
|
109
|
+
) {
|
|
110
|
+
return <h2 ref={ref} {...props} className={textClass("text-2xl font-bold tracking-tight", tone, weight, truncate, className)}>{children}</h2>;
|
|
111
|
+
});
|
|
88
112
|
|
|
89
113
|
/** H3 — subsection title */
|
|
90
|
-
export
|
|
91
|
-
|
|
92
|
-
|
|
114
|
+
export const H3 = forwardRef<HTMLHeadingElement, H3Props>(function H3(
|
|
115
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
116
|
+
ref,
|
|
117
|
+
) {
|
|
118
|
+
return <h3 ref={ref} {...props} className={textClass("text-xl font-semibold", tone, weight, truncate, className)}>{children}</h3>;
|
|
119
|
+
});
|
|
93
120
|
|
|
94
121
|
/** H4 — card / panel title */
|
|
95
|
-
export
|
|
96
|
-
|
|
97
|
-
|
|
122
|
+
export const H4 = forwardRef<HTMLHeadingElement, H4Props>(function H4(
|
|
123
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
124
|
+
ref,
|
|
125
|
+
) {
|
|
126
|
+
return <h4 ref={ref} {...props} className={textClass("text-lg font-semibold", tone, weight, truncate, className)}>{children}</h4>;
|
|
127
|
+
});
|
|
98
128
|
|
|
99
129
|
/** H5 — small heading */
|
|
100
|
-
export
|
|
101
|
-
|
|
102
|
-
|
|
130
|
+
export const H5 = forwardRef<HTMLHeadingElement, H5Props>(function H5(
|
|
131
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
132
|
+
ref,
|
|
133
|
+
) {
|
|
134
|
+
return <h5 ref={ref} {...props} className={textClass("text-base font-semibold", tone, weight, truncate, className)}>{children}</h5>;
|
|
135
|
+
});
|
|
103
136
|
|
|
104
137
|
/** Body — default paragraph text */
|
|
105
|
-
export
|
|
106
|
-
|
|
107
|
-
|
|
138
|
+
export const P = forwardRef<HTMLParagraphElement, PProps>(function P(
|
|
139
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
140
|
+
ref,
|
|
141
|
+
) {
|
|
142
|
+
return <p ref={ref} {...props} className={textClass("text-base leading-relaxed", tone, weight, truncate, className)}>{children}</p>;
|
|
143
|
+
});
|
|
108
144
|
|
|
109
145
|
/** Small body text */
|
|
110
|
-
export
|
|
111
|
-
|
|
112
|
-
|
|
146
|
+
export const Small = forwardRef<HTMLSpanElement, SmallProps>(function Small(
|
|
147
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
148
|
+
ref,
|
|
149
|
+
) {
|
|
150
|
+
return <span ref={ref} {...props} className={textClass("text-sm leading-relaxed", tone, weight, truncate, className)}>{children}</span>;
|
|
151
|
+
});
|
|
113
152
|
|
|
114
153
|
/** Extra small text — captions, meta */
|
|
115
|
-
export
|
|
116
|
-
|
|
117
|
-
|
|
154
|
+
export const Caption = forwardRef<HTMLSpanElement, CaptionProps>(function Caption(
|
|
155
|
+
{ children, tone = "muted", weight, truncate, className, ...props },
|
|
156
|
+
ref,
|
|
157
|
+
) {
|
|
158
|
+
return <span ref={ref} {...props} className={textClass("text-[11px] font-medium uppercase tracking-wider", tone, weight, truncate, className)}>{children}</span>;
|
|
159
|
+
});
|
|
118
160
|
|
|
119
161
|
/** Overline — tiny eyebrow text */
|
|
120
|
-
export
|
|
121
|
-
|
|
122
|
-
|
|
162
|
+
export const Overline = forwardRef<HTMLSpanElement, OverlineProps>(function Overline(
|
|
163
|
+
{ children, tone = "muted", weight, truncate, className, ...props },
|
|
164
|
+
ref,
|
|
165
|
+
) {
|
|
166
|
+
return <span ref={ref} {...props} className={textClass("text-[10px] font-bold uppercase tracking-[0.15em]", tone, weight, truncate, className)}>{children}</span>;
|
|
167
|
+
});
|
|
123
168
|
|
|
124
169
|
/** Lead — intro paragraph under a Display/SectionTitle; measure-capped for readability */
|
|
125
|
-
export
|
|
126
|
-
|
|
127
|
-
|
|
170
|
+
export const Lead = forwardRef<HTMLParagraphElement, LeadProps>(function Lead(
|
|
171
|
+
{ children, tone = "subtle", weight, truncate, className, ...props },
|
|
172
|
+
ref,
|
|
173
|
+
) {
|
|
174
|
+
return <p ref={ref} {...props} className={textClass("ph-lead", tone, weight, truncate, className)}>{children}</p>;
|
|
175
|
+
});
|
|
128
176
|
|
|
129
177
|
/** Mono — code, data, technical text */
|
|
130
|
-
export
|
|
131
|
-
|
|
132
|
-
|
|
178
|
+
export const Mono = forwardRef<HTMLElement, MonoProps>(function Mono(
|
|
179
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
180
|
+
ref,
|
|
181
|
+
) {
|
|
182
|
+
return <code ref={ref} {...props} className={textClass("text-sm font-mono leading-relaxed", tone, weight, truncate, className)}>{children}</code>;
|
|
183
|
+
});
|
|
133
184
|
|
|
134
185
|
/** Label — form labels, tags */
|
|
135
|
-
export
|
|
136
|
-
|
|
137
|
-
|
|
186
|
+
export const Label = forwardRef<HTMLLabelElement, LabelProps>(function Label(
|
|
187
|
+
{ children, tone = "default", weight, truncate, className, ...props },
|
|
188
|
+
ref,
|
|
189
|
+
) {
|
|
190
|
+
return <label ref={ref} {...props} className={textClass("text-sm font-medium", tone, weight, truncate, className)}>{children}</label>;
|
|
191
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Avatar } from "@/components/ui/Avatar";
|
|
4
|
+
import { Stat } from "@/components/ui/Stat";
|
|
5
|
+
|
|
6
|
+
describe("Avatar status", () => {
|
|
7
|
+
it("exposes its status to assistive technology with semantic theme tokens", () => {
|
|
8
|
+
render(<Avatar label="JD" status="online" />);
|
|
9
|
+
|
|
10
|
+
const status = screen.getByRole("img", { name: "online status" });
|
|
11
|
+
expect(status).toHaveClass("bg-ph-success", "border-ph-surface");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("Stat warning", () => {
|
|
16
|
+
it("uses warning theme tokens instead of fixed colours", () => {
|
|
17
|
+
render(<Stat label="Over budget" value="$120" tone="warning" />);
|
|
18
|
+
|
|
19
|
+
const stat = screen.getByRole("article");
|
|
20
|
+
expect(stat.className).not.toContain("amber");
|
|
21
|
+
expect(screen.getByText("$120")).toHaveClass("text-ph-warning");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Calendar } from "./Calendar";
|
|
6
|
+
|
|
7
|
+
describe("Calendar", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
cleanup();
|
|
10
|
+
vi.useRealTimers();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("shows the month containing a controlled value update", () => {
|
|
14
|
+
const { rerender } = render(
|
|
15
|
+
<Calendar value={new Date(2026, 0, 15)} />,
|
|
16
|
+
);
|
|
17
|
+
expect(screen.getByText("January 2026")).toBeInTheDocument();
|
|
18
|
+
|
|
19
|
+
rerender(<Calendar value={new Date(2026, 1, 15)} />);
|
|
20
|
+
expect(screen.getByText("February 2026")).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("provides full date names and marks today separately from selection", () => {
|
|
24
|
+
vi.useFakeTimers();
|
|
25
|
+
vi.setSystemTime(new Date(2026, 0, 15, 12));
|
|
26
|
+
|
|
27
|
+
render(<Calendar value={new Date(2026, 0, 14)} />);
|
|
28
|
+
|
|
29
|
+
expect(
|
|
30
|
+
screen.getByRole("button", { name: "Thursday, 15 January 2026" }),
|
|
31
|
+
).toHaveAttribute("aria-current", "date");
|
|
32
|
+
expect(
|
|
33
|
+
screen.getByRole("button", { name: "Wednesday, 14 January 2026" })
|
|
34
|
+
.parentElement,
|
|
35
|
+
).toHaveAttribute("aria-selected", "true");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("uses date-grid keyboard navigation without changing controlled selection", async () => {
|
|
39
|
+
const user = userEvent.setup();
|
|
40
|
+
const onChange = vi.fn();
|
|
41
|
+
|
|
42
|
+
render(<Calendar value={new Date(2026, 0, 15)} onChange={onChange} />);
|
|
43
|
+
|
|
44
|
+
const selectedDate = screen.getByRole("button", {
|
|
45
|
+
name: "Thursday, 15 January 2026",
|
|
46
|
+
});
|
|
47
|
+
expect(selectedDate.parentElement).toHaveAttribute("aria-selected", "true");
|
|
48
|
+
|
|
49
|
+
await user.click(selectedDate);
|
|
50
|
+
onChange.mockClear();
|
|
51
|
+
await user.keyboard("{ArrowRight}");
|
|
52
|
+
|
|
53
|
+
const focusedDate = screen.getByRole("button", {
|
|
54
|
+
name: "Friday, 16 January 2026",
|
|
55
|
+
});
|
|
56
|
+
expect(focusedDate).toHaveFocus();
|
|
57
|
+
expect(selectedDate.parentElement).toHaveAttribute("aria-selected", "true");
|
|
58
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
59
|
+
|
|
60
|
+
await user.keyboard("{Enter}");
|
|
61
|
+
expect(onChange).toHaveBeenCalledWith(new Date(2026, 0, 16));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("moves focus by week boundaries and months", async () => {
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
|
|
67
|
+
render(<Calendar value={new Date(2026, 0, 15)} />);
|
|
68
|
+
await user.click(
|
|
69
|
+
screen.getByRole("button", { name: "Thursday, 15 January 2026" }),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await user.keyboard("{Home}");
|
|
73
|
+
expect(
|
|
74
|
+
screen.getByRole("button", { name: "Sunday, 11 January 2026" }),
|
|
75
|
+
).toHaveFocus();
|
|
76
|
+
|
|
77
|
+
await user.keyboard("{End}");
|
|
78
|
+
expect(
|
|
79
|
+
screen.getByRole("button", { name: "Saturday, 17 January 2026" }),
|
|
80
|
+
).toHaveFocus();
|
|
81
|
+
|
|
82
|
+
await user.keyboard("{PageDown}");
|
|
83
|
+
expect(screen.getByText("February 2026")).toBeInTheDocument();
|
|
84
|
+
expect(
|
|
85
|
+
screen.getByRole("button", { name: "Tuesday, 17 February 2026" }),
|
|
86
|
+
).toHaveFocus();
|
|
87
|
+
});
|
|
88
|
+
});
|