@xenide-io/the-old-ui-theme 0.5.3 → 0.5.4
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/dist/{index-Od4pIP4F.d.mts → Chip-C5hRFAhe.d.mts} +10 -606
- package/dist/{index-Od4pIP4F.d.ts → Chip-C5hRFAhe.d.ts} +10 -606
- package/dist/{chunk-G53YLHVE.mjs → chunk-E5ZBQYEX.mjs} +59 -9
- package/dist/{chunk-VFTZXPXM.mjs → chunk-SBBKXV35.mjs} +144 -79
- package/dist/index.d.mts +3 -79
- package/dist/index.d.ts +3 -79
- package/dist/index.js +524 -3595
- package/dist/index.mjs +3 -765
- package/dist/suite.d.mts +38 -12
- package/dist/suite.d.ts +38 -12
- package/dist/suite.js +84 -15
- package/dist/suite.mjs +53 -13
- package/dist/ui.d.mts +608 -2
- package/dist/ui.d.ts +608 -2
- package/dist/ui.js +202 -87
- package/dist/ui.mjs +2 -2
- package/package.json +2 -6
- package/scripts/install-theme-dev.sh +60 -0
- package/scripts/sync-posthog-icons.mjs +75 -0
- package/src/components/ui/Chip.tsx +36 -18
- package/src/components/ui/CommandPalette.test.tsx +41 -0
- package/src/components/ui/CommandPalette.tsx +58 -9
- package/src/components/ui/DropdownMenu.tsx +50 -11
- package/src/components/ui/SegmentedControl.tsx +2 -1
- package/src/components/ui/SettingsLayout.tsx +19 -5
- package/src/components/ui/interaction-primitives.test.tsx +52 -0
- package/src/styles/suite-skin.css +17 -35
- package/src/suite/components/app-switcher.tsx +32 -25
- package/src/suite/components/command-palette-host.tsx +20 -8
- package/src/suite/components/suite-app-layout.tsx +1 -1
- package/src/suite/components/suite-bottom-nav.tsx +23 -18
- package/src/suite/components/suite-layout.test.tsx +44 -0
- package/src/suite/components/suite-layout.tsx +3 -3
- package/src/suite/components/suite-mobile-drawer.tsx +12 -0
- package/src/suite/components/suite-mobile-header.tsx +15 -9
- package/src/suite/components/suite-notification-bell.tsx +23 -11
- package/src/suite/components/suite-settings-mobile-nav.tsx +2 -0
- package/src/suite/components/suite-sidebar.tsx +6 -3
- package/src/suite/components/suite-user-menu.tsx +6 -0
- package/src/suite/lib/injected.ts +12 -11
- package/src/components/charts/index.ts +0 -9
- package/src/components/charts/quill/BarChart.tsx +0 -85
- package/src/components/charts/quill/FunnelChart.tsx +0 -49
- package/src/components/charts/quill/InsightShell.tsx +0 -27
- package/src/components/charts/quill/MetricCard.tsx +0 -44
- package/src/components/charts/quill/PieChart.tsx +0 -81
- package/src/components/charts/quill/Sparkline.tsx +0 -57
- package/src/components/charts/quill/TimeSeriesLineChart.tsx +0 -62
- package/src/components/charts/quill/index.ts +0 -9
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
SuiteCommandItem,
|
|
7
7
|
SuiteCommandPaletteComponent,
|
|
8
|
-
} from
|
|
8
|
+
} from "../lib/injected";
|
|
9
9
|
|
|
10
10
|
function isEditableTarget(): boolean {
|
|
11
|
-
if (typeof document ===
|
|
11
|
+
if (typeof document === "undefined") return false;
|
|
12
12
|
const el = document.activeElement;
|
|
13
13
|
return (
|
|
14
14
|
el instanceof HTMLElement &&
|
|
@@ -27,26 +27,34 @@ function isEditableTarget(): boolean {
|
|
|
27
27
|
export function CommandPaletteHost({
|
|
28
28
|
items,
|
|
29
29
|
isAuthenticated = true,
|
|
30
|
-
placeholder =
|
|
30
|
+
placeholder = "Type a command or search…",
|
|
31
|
+
id,
|
|
32
|
+
dataTest,
|
|
33
|
+
inputId,
|
|
34
|
+
inputDataTest,
|
|
31
35
|
commandPalette: CommandPalette,
|
|
32
36
|
}: {
|
|
33
37
|
items: SuiteCommandItem[];
|
|
34
38
|
isAuthenticated?: boolean;
|
|
35
39
|
placeholder?: string;
|
|
40
|
+
id?: string;
|
|
41
|
+
dataTest?: string;
|
|
42
|
+
inputId?: string;
|
|
43
|
+
inputDataTest?: string;
|
|
36
44
|
commandPalette: SuiteCommandPaletteComponent;
|
|
37
45
|
}) {
|
|
38
46
|
const [open, setOpen] = useState(false);
|
|
39
47
|
|
|
40
48
|
useEffect(() => {
|
|
41
49
|
const onKey = (e: KeyboardEvent) => {
|
|
42
|
-
if (!((e.metaKey || e.ctrlKey) && e.key.toLowerCase() ===
|
|
50
|
+
if (!((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k")) return;
|
|
43
51
|
// Leave editors (BlockNote uses Cmd/Ctrl+K for links) alone unless the palette is open.
|
|
44
52
|
if (!open && isEditableTarget()) return;
|
|
45
53
|
e.preventDefault();
|
|
46
54
|
setOpen((o) => !o);
|
|
47
55
|
};
|
|
48
|
-
window.addEventListener(
|
|
49
|
-
return () => window.removeEventListener(
|
|
56
|
+
window.addEventListener("keydown", onKey);
|
|
57
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
50
58
|
}, [open]);
|
|
51
59
|
|
|
52
60
|
if (!isAuthenticated) return null;
|
|
@@ -57,6 +65,10 @@ export function CommandPaletteHost({
|
|
|
57
65
|
isOpen={open}
|
|
58
66
|
onClose={() => setOpen(false)}
|
|
59
67
|
placeholder={placeholder}
|
|
68
|
+
id={id}
|
|
69
|
+
dataTest={dataTest}
|
|
70
|
+
inputId={inputId}
|
|
71
|
+
inputDataTest={inputDataTest}
|
|
60
72
|
/>
|
|
61
73
|
);
|
|
62
74
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import Link from
|
|
4
|
-
import type { ComponentType } from
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import type { ComponentType } from "react";
|
|
5
5
|
|
|
6
|
-
import { cn } from
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
7
|
|
|
8
8
|
/** Any icon component that accepts sizing/stroke props (Lucide, theme icons). */
|
|
9
9
|
export type SuiteNavIcon = ComponentType<{
|
|
10
10
|
className?: string;
|
|
11
11
|
strokeWidth?: number | string;
|
|
12
|
-
|
|
12
|
+
"aria-hidden"?: boolean | "true" | "false";
|
|
13
13
|
}>;
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -23,14 +23,15 @@ export interface SuiteBottomNavItem {
|
|
|
23
23
|
href: string;
|
|
24
24
|
label: string;
|
|
25
25
|
icon: SuiteNavIcon;
|
|
26
|
+
id?: string;
|
|
26
27
|
active?: boolean;
|
|
27
28
|
dataTest?: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export function SuiteBottomNav({
|
|
31
32
|
items,
|
|
32
|
-
ariaLabel =
|
|
33
|
-
dataTest =
|
|
33
|
+
ariaLabel = "Primary navigation",
|
|
34
|
+
dataTest = "suite-bottom-nav",
|
|
34
35
|
className,
|
|
35
36
|
}: {
|
|
36
37
|
items: readonly SuiteBottomNavItem[];
|
|
@@ -38,7 +39,7 @@ export function SuiteBottomNav({
|
|
|
38
39
|
dataTest?: string;
|
|
39
40
|
className?: string;
|
|
40
41
|
}) {
|
|
41
|
-
if (process.env.NODE_ENV !==
|
|
42
|
+
if (process.env.NODE_ENV !== "production" && items.length > 5) {
|
|
42
43
|
// eslint-disable-next-line no-console
|
|
43
44
|
console.warn(
|
|
44
45
|
`SuiteBottomNav: ${items.length} items — bottom nav should have at most 5 (Hick's Law).`,
|
|
@@ -54,20 +55,20 @@ export function SuiteBottomNav({
|
|
|
54
55
|
<div
|
|
55
56
|
aria-hidden
|
|
56
57
|
className="shrink-0 lg:hidden"
|
|
57
|
-
style={{ height:
|
|
58
|
+
style={{ height: "calc(60px + env(safe-area-inset-bottom))" }}
|
|
58
59
|
/>
|
|
59
60
|
<nav
|
|
60
61
|
aria-label={ariaLabel}
|
|
61
62
|
data-test={dataTest}
|
|
62
63
|
className={cn(
|
|
63
|
-
|
|
64
|
+
"suite-bottom-nav no-print z-40 grid auto-cols-fr grid-flow-col lg:hidden",
|
|
64
65
|
className,
|
|
65
66
|
)}
|
|
66
67
|
style={
|
|
67
68
|
{
|
|
68
69
|
gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
"--suite-nav-items": items.length,
|
|
71
|
+
"--suite-nav-active": activeIndex >= 0 ? activeIndex : 0,
|
|
71
72
|
} as React.CSSProperties
|
|
72
73
|
}
|
|
73
74
|
>
|
|
@@ -82,17 +83,21 @@ export function SuiteBottomNav({
|
|
|
82
83
|
<Link
|
|
83
84
|
key={item.href}
|
|
84
85
|
href={item.href}
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
id={item.id}
|
|
87
|
+
data-test={
|
|
88
|
+
item.dataTest ??
|
|
89
|
+
`suite-nav-${item.label.toLowerCase().replace(/\s+/g, "-")}`
|
|
90
|
+
}
|
|
91
|
+
aria-current={active ? "page" : undefined}
|
|
87
92
|
className={cn(
|
|
88
|
-
|
|
89
|
-
active &&
|
|
93
|
+
"suite-bottom-nav__item relative flex min-h-[60px] min-w-0 flex-col items-center justify-center gap-1 px-1 pb-1.5 pt-2 text-[10px] font-medium leading-none text-ph-mutedtext transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ph-brand motion-reduce:transition-none",
|
|
94
|
+
active && "font-semibold text-ph-brand",
|
|
90
95
|
)}
|
|
91
96
|
>
|
|
92
97
|
<span
|
|
93
98
|
className={cn(
|
|
94
|
-
|
|
95
|
-
active &&
|
|
99
|
+
"suite-bottom-nav__icon relative z-10 inline-flex items-center justify-center",
|
|
100
|
+
active && "suite-bottom-nav__icon--active",
|
|
96
101
|
)}
|
|
97
102
|
>
|
|
98
103
|
<Icon
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { SuiteAppLayout } from "./suite-app-layout";
|
|
5
|
+
import { SuitePageHeader } from "./suite-layout";
|
|
6
|
+
|
|
7
|
+
describe("suite scroll ownership", () => {
|
|
8
|
+
it("keeps mobile chrome outside the main scrollport and locks only on request", () => {
|
|
9
|
+
render(
|
|
10
|
+
<SuiteAppLayout
|
|
11
|
+
sidebar={<div>Sidebar</div>}
|
|
12
|
+
mobileHeader={<header>Mobile chrome</header>}
|
|
13
|
+
lockMainScroll
|
|
14
|
+
>
|
|
15
|
+
<div>Page content</div>
|
|
16
|
+
</SuiteAppLayout>,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const main = screen.getByRole("main");
|
|
20
|
+
const mobileHeader = screen.getByText("Mobile chrome").closest("header");
|
|
21
|
+
|
|
22
|
+
expect(main).toHaveAttribute("data-lock-scroll", "true");
|
|
23
|
+
expect(main).toHaveClass("overflow-hidden");
|
|
24
|
+
expect(main).not.toContainElement(mobileHeader);
|
|
25
|
+
expect(within(main).getByText("Page content")).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("lets page headers and actions reflow without clipping narrow screens", () => {
|
|
29
|
+
render(
|
|
30
|
+
<SuitePageHeader
|
|
31
|
+
title="A very long responsive page title"
|
|
32
|
+
actions={<button type="button">Export report</button>}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
screen.getByRole("heading", {
|
|
38
|
+
name: "A very long responsive page title",
|
|
39
|
+
}),
|
|
40
|
+
).toHaveClass("break-words", "text-balance");
|
|
41
|
+
expect(screen.getByRole("button", { name: "Export report" }).parentElement)
|
|
42
|
+
.toHaveClass("max-w-full", "overflow-x-auto");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -181,7 +181,7 @@ export function SuitePageHeader({
|
|
|
181
181
|
data-test={dataTest}
|
|
182
182
|
data-stuck={sticky ? (stuck ? 'true' : 'false') : undefined}
|
|
183
183
|
className={cn(
|
|
184
|
-
'flex items-start justify-between gap-4
|
|
184
|
+
'flex min-w-0 flex-col items-start gap-3 pb-2 sm:flex-row sm:justify-between sm:gap-4 sm:pb-3',
|
|
185
185
|
// No extra pt here — page inset sets the shared title baseline.
|
|
186
186
|
// Stuck state adds pad via .suite-page-header-sticky[data-stuck].
|
|
187
187
|
sticky && 'suite-page-header-sticky',
|
|
@@ -212,7 +212,7 @@ export function SuitePageHeader({
|
|
|
212
212
|
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
213
213
|
<h1
|
|
214
214
|
className={cn(
|
|
215
|
-
'font-display text-[1.625rem] font-bold leading-none tracking-tight text-ph-ink sm:text-[2.25rem]',
|
|
215
|
+
'min-w-0 break-words font-display text-[1.625rem] font-bold leading-none tracking-tight text-ph-ink text-balance sm:text-[2.25rem]',
|
|
216
216
|
titleClassName,
|
|
217
217
|
)}
|
|
218
218
|
>
|
|
@@ -237,7 +237,7 @@ export function SuitePageHeader({
|
|
|
237
237
|
) : null}
|
|
238
238
|
</div>
|
|
239
239
|
{actions ? (
|
|
240
|
-
<div className="flex
|
|
240
|
+
<div className="flex max-w-full items-center gap-2 self-stretch overflow-x-auto pb-1 sm:shrink-0 sm:self-center sm:overflow-visible sm:pb-0">
|
|
241
241
|
{actions}
|
|
242
242
|
</div>
|
|
243
243
|
) : null}
|
|
@@ -23,6 +23,10 @@ export function SuiteMobileDrawer({
|
|
|
23
23
|
durationMs = 200,
|
|
24
24
|
dataTest,
|
|
25
25
|
panelDataTest,
|
|
26
|
+
backdropId,
|
|
27
|
+
backdropDataTest,
|
|
28
|
+
closeButtonId,
|
|
29
|
+
closeButtonDataTest,
|
|
26
30
|
panelClassName,
|
|
27
31
|
}: {
|
|
28
32
|
open: boolean;
|
|
@@ -42,6 +46,10 @@ export function SuiteMobileDrawer({
|
|
|
42
46
|
durationMs?: number;
|
|
43
47
|
dataTest?: string;
|
|
44
48
|
panelDataTest?: string;
|
|
49
|
+
backdropId?: string;
|
|
50
|
+
backdropDataTest?: string;
|
|
51
|
+
closeButtonId?: string;
|
|
52
|
+
closeButtonDataTest?: string;
|
|
45
53
|
/** Panel surface overrides (e.g. `bg-ph-canvas`). */
|
|
46
54
|
panelClassName?: string;
|
|
47
55
|
}) {
|
|
@@ -106,6 +114,8 @@ export function SuiteMobileDrawer({
|
|
|
106
114
|
<div data-test={dataTest} className="no-print fixed inset-0 z-50 lg:hidden">
|
|
107
115
|
<button
|
|
108
116
|
type="button"
|
|
117
|
+
id={backdropId}
|
|
118
|
+
data-test={backdropDataTest}
|
|
109
119
|
tabIndex={-1}
|
|
110
120
|
className={cn(
|
|
111
121
|
"absolute inset-0 bg-black/25 backdrop-blur-sm motion-safe:transition-opacity motion-safe:ease-spring-subtle",
|
|
@@ -148,6 +158,8 @@ export function SuiteMobileDrawer({
|
|
|
148
158
|
) : null}
|
|
149
159
|
<button
|
|
150
160
|
type="button"
|
|
161
|
+
id={closeButtonId}
|
|
162
|
+
data-test={closeButtonDataTest}
|
|
151
163
|
onClick={onClose}
|
|
152
164
|
className="-mr-1.5 inline-flex h-11 w-11 items-center justify-center rounded-lg text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
|
|
153
165
|
aria-label={closeLabel}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import type { ComponentType, ReactNode } from
|
|
4
|
-
import { Menu } from
|
|
3
|
+
import type { ComponentType, ReactNode } from "react";
|
|
4
|
+
import { Menu } from "iconoir-react";
|
|
5
5
|
|
|
6
|
-
import { cn } from
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
7
|
|
|
8
|
-
type SuiteMenuIcon = ComponentType<{
|
|
8
|
+
type SuiteMenuIcon = ComponentType<{
|
|
9
|
+
className?: string;
|
|
10
|
+
"aria-hidden"?: boolean | "true" | "false";
|
|
11
|
+
}>;
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* Suite mobile app bar — the standardized top of every app on phone/tablet.
|
|
@@ -18,7 +21,8 @@ export function SuiteMobileHeader({
|
|
|
18
21
|
menuIcon: MenuIcon = Menu,
|
|
19
22
|
title,
|
|
20
23
|
actions,
|
|
21
|
-
menuLabel =
|
|
24
|
+
menuLabel = "Open navigation",
|
|
25
|
+
menuId,
|
|
22
26
|
menuDataTest,
|
|
23
27
|
dataTest,
|
|
24
28
|
className,
|
|
@@ -32,6 +36,7 @@ export function SuiteMobileHeader({
|
|
|
32
36
|
/** Right-hand slot — search, bell, avatar (keep this order). */
|
|
33
37
|
actions?: ReactNode;
|
|
34
38
|
menuLabel?: string;
|
|
39
|
+
menuId?: string;
|
|
35
40
|
menuDataTest?: string;
|
|
36
41
|
dataTest?: string;
|
|
37
42
|
className?: string;
|
|
@@ -40,13 +45,14 @@ export function SuiteMobileHeader({
|
|
|
40
45
|
<header
|
|
41
46
|
data-test={dataTest}
|
|
42
47
|
className={cn(
|
|
43
|
-
|
|
48
|
+
"suite-app-bar no-print sticky top-0 z-30 flex min-h-[56px] shrink-0 items-center gap-1 border-b border-ph-border px-2 lg:hidden",
|
|
44
49
|
className,
|
|
45
50
|
)}
|
|
46
51
|
>
|
|
47
52
|
{onMenuClick ? (
|
|
48
53
|
<button
|
|
49
54
|
type="button"
|
|
55
|
+
id={menuId}
|
|
50
56
|
data-test={menuDataTest}
|
|
51
57
|
className="suite-press inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-ph-ink transition-colors hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
|
|
52
58
|
onClick={onMenuClick}
|
|
@@ -57,8 +63,8 @@ export function SuiteMobileHeader({
|
|
|
57
63
|
) : null}
|
|
58
64
|
<div
|
|
59
65
|
className={cn(
|
|
60
|
-
|
|
61
|
-
!onMenuClick &&
|
|
66
|
+
"flex min-w-0 flex-1 items-center",
|
|
67
|
+
!onMenuClick && "pl-2",
|
|
62
68
|
)}
|
|
63
69
|
>
|
|
64
70
|
{title}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useState } from
|
|
4
|
-
import { useRouter } from
|
|
5
|
-
import { Bell } from
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Bell } from "iconoir-react";
|
|
6
6
|
|
|
7
|
-
import type { SuiteDropdownMenuComponent } from
|
|
8
|
-
import { resolveSuiteNotificationHref } from
|
|
7
|
+
import type { SuiteDropdownMenuComponent } from "../lib/injected";
|
|
8
|
+
import { resolveSuiteNotificationHref } from "../lib/apps";
|
|
9
9
|
|
|
10
10
|
const POLL_MS = 60_000;
|
|
11
11
|
|
|
@@ -30,9 +30,9 @@ export interface SuiteNotificationsResponse {
|
|
|
30
30
|
|
|
31
31
|
function timeAgo(iso: string): string {
|
|
32
32
|
const then = new Date(iso).getTime();
|
|
33
|
-
if (Number.isNaN(then)) return
|
|
33
|
+
if (Number.isNaN(then)) return "";
|
|
34
34
|
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
|
|
35
|
-
if (secs < 60) return
|
|
35
|
+
if (secs < 60) return "just now";
|
|
36
36
|
const mins = Math.round(secs / 60);
|
|
37
37
|
if (mins < 60) return `${mins}m ago`;
|
|
38
38
|
const hrs = Math.round(mins / 60);
|
|
@@ -45,7 +45,10 @@ export function SuiteNotificationBell({
|
|
|
45
45
|
markRead,
|
|
46
46
|
markAllRead,
|
|
47
47
|
dropdownMenu: DropdownMenu,
|
|
48
|
-
cacheKey =
|
|
48
|
+
cacheKey = "suite-notifications-cache",
|
|
49
|
+
dataTest = "suite-notifications",
|
|
50
|
+
triggerId,
|
|
51
|
+
triggerDataTest,
|
|
49
52
|
}: {
|
|
50
53
|
fetchNotifications: () => Promise<SuiteNotificationsResponse>;
|
|
51
54
|
markRead: (id: string) => Promise<unknown>;
|
|
@@ -53,6 +56,9 @@ export function SuiteNotificationBell({
|
|
|
53
56
|
dropdownMenu: SuiteDropdownMenuComponent;
|
|
54
57
|
/** sessionStorage key for stale-while-revalidate; `null` disables caching. */
|
|
55
58
|
cacheKey?: string | null;
|
|
59
|
+
dataTest?: string;
|
|
60
|
+
triggerId?: string;
|
|
61
|
+
triggerDataTest?: string;
|
|
56
62
|
}) {
|
|
57
63
|
const router = useRouter();
|
|
58
64
|
const [open, setOpen] = useState(false);
|
|
@@ -165,16 +171,18 @@ export function SuiteNotificationBell({
|
|
|
165
171
|
return (
|
|
166
172
|
<DropdownMenu
|
|
167
173
|
aria-label="Notifications"
|
|
174
|
+
triggerId={triggerId ?? `${dataTest}-trigger`}
|
|
175
|
+
triggerDataTest={triggerDataTest ?? `${dataTest}-trigger`}
|
|
168
176
|
align="end"
|
|
169
177
|
panelClassName="w-80 overflow-hidden p-0"
|
|
170
178
|
open={open}
|
|
171
179
|
onOpenChange={onOpenChange}
|
|
172
180
|
trigger={
|
|
173
|
-
<span className="relative inline-flex h-
|
|
181
|
+
<span className="relative inline-flex h-11 w-11 items-center justify-center rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink">
|
|
174
182
|
<Bell className="h-5 w-5" strokeWidth={1.75} aria-hidden />
|
|
175
183
|
{unread > 0 ? (
|
|
176
184
|
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-ph-brand px-1 text-[10px] font-semibold leading-none text-white ring-2 ring-ph-surface">
|
|
177
|
-
{unread > 9 ?
|
|
185
|
+
{unread > 9 ? "9+" : unread}
|
|
178
186
|
</span>
|
|
179
187
|
) : null}
|
|
180
188
|
</span>
|
|
@@ -185,6 +193,8 @@ export function SuiteNotificationBell({
|
|
|
185
193
|
{unread > 0 ? (
|
|
186
194
|
<button
|
|
187
195
|
type="button"
|
|
196
|
+
id={`${dataTest}-mark-all`}
|
|
197
|
+
data-test={`${dataTest}-mark-all`}
|
|
188
198
|
onClick={markAll}
|
|
189
199
|
className="text-xs font-medium text-ph-brand hover:underline"
|
|
190
200
|
>
|
|
@@ -202,6 +212,8 @@ export function SuiteNotificationBell({
|
|
|
202
212
|
<button
|
|
203
213
|
key={n.id}
|
|
204
214
|
type="button"
|
|
215
|
+
id={`${dataTest}-item-${n.id}`}
|
|
216
|
+
data-test={`${dataTest}-item-${n.id}`}
|
|
205
217
|
onClick={() => void openItem(n)}
|
|
206
218
|
className="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition hover:bg-ph-muted"
|
|
207
219
|
>
|
|
@@ -7,6 +7,7 @@ export interface SuiteSettingsNavItem {
|
|
|
7
7
|
label: string;
|
|
8
8
|
href: string;
|
|
9
9
|
icon: ElementType | ReactNode;
|
|
10
|
+
id?: string;
|
|
10
11
|
testId?: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -59,6 +60,7 @@ export function SuiteSettingsMobileNav({
|
|
|
59
60
|
<button
|
|
60
61
|
key={item.href}
|
|
61
62
|
type="button"
|
|
63
|
+
id={item.id}
|
|
62
64
|
data-test={item.testId}
|
|
63
65
|
onClick={() => onSelect(item.href)}
|
|
64
66
|
aria-current={active ? "page" : undefined}
|
|
@@ -13,6 +13,8 @@ export interface SuiteSidebarNavItem {
|
|
|
13
13
|
href: string;
|
|
14
14
|
label: string;
|
|
15
15
|
icon: SuiteNavIcon;
|
|
16
|
+
id?: string;
|
|
17
|
+
dataTest?: string;
|
|
16
18
|
active?: boolean;
|
|
17
19
|
onClick?: () => void;
|
|
18
20
|
badge?: ReactNode;
|
|
@@ -115,16 +117,17 @@ export function SuiteSidebar({
|
|
|
115
117
|
<Link
|
|
116
118
|
key={item.label}
|
|
117
119
|
href={item.href}
|
|
118
|
-
|
|
120
|
+
id={item.id}
|
|
121
|
+
data-test={item.dataTest ?? "nav-link"}
|
|
119
122
|
aria-current={active ? "page" : undefined}
|
|
120
123
|
onClick={item.onClick}
|
|
121
124
|
className={cn(
|
|
122
|
-
"group relative flex items-center gap-2.5 rounded-[var(--ph-radius-app)] text-sm font-medium transition-colors",
|
|
125
|
+
"group relative flex min-h-11 touch-manipulation items-center gap-2.5 rounded-[var(--ph-radius-app)] text-sm font-medium transition-colors",
|
|
123
126
|
active
|
|
124
127
|
? "bg-ph-muted"
|
|
125
128
|
: "text-ph-subtle hover:bg-ph-muted hover:text-ph-ink",
|
|
126
129
|
collapsed
|
|
127
|
-
? "mx-auto size-
|
|
130
|
+
? "mx-auto size-11 shrink-0 justify-center gap-0 px-0 py-0"
|
|
128
131
|
: "w-full px-2.5 py-2",
|
|
129
132
|
)}
|
|
130
133
|
>
|
|
@@ -19,6 +19,8 @@ export interface SuiteUserMenuProps {
|
|
|
19
19
|
/** Show the old desktop sidebar sign-out action beside the avatar. */
|
|
20
20
|
showSignOutAction?: boolean;
|
|
21
21
|
dataTest?: string;
|
|
22
|
+
triggerId?: string;
|
|
23
|
+
triggerDataTest?: string;
|
|
22
24
|
className?: string;
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -50,6 +52,8 @@ export function SuiteUserMenu({
|
|
|
50
52
|
fallbackInitials,
|
|
51
53
|
showSignOutAction = false,
|
|
52
54
|
dataTest = "suite-user-menu",
|
|
55
|
+
triggerId,
|
|
56
|
+
triggerDataTest,
|
|
53
57
|
className,
|
|
54
58
|
}: SuiteUserMenuProps) {
|
|
55
59
|
const router = useRouter();
|
|
@@ -77,6 +81,8 @@ export function SuiteUserMenu({
|
|
|
77
81
|
const accountMenu = (
|
|
78
82
|
<DropdownMenu
|
|
79
83
|
trigger={avatar}
|
|
84
|
+
triggerId={triggerId ?? `${dataTest}-trigger`}
|
|
85
|
+
triggerDataTest={triggerDataTest ?? `${dataTest}-trigger`}
|
|
80
86
|
aria-label="Account menu"
|
|
81
87
|
align="end"
|
|
82
88
|
side="bottom"
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ButtonHTMLAttributes,
|
|
3
|
-
ComponentType,
|
|
4
|
-
ReactNode,
|
|
5
|
-
} from 'react';
|
|
1
|
+
import type { ButtonHTMLAttributes, ComponentType, ReactNode } from "react";
|
|
6
2
|
|
|
7
3
|
/**
|
|
8
4
|
* Structural types for theme primitives each app injects from
|
|
@@ -12,10 +8,12 @@ import type {
|
|
|
12
8
|
|
|
13
9
|
export interface SuiteDropdownMenuProps {
|
|
14
10
|
trigger: ReactNode;
|
|
11
|
+
triggerId?: string;
|
|
12
|
+
triggerDataTest?: string;
|
|
15
13
|
children: ReactNode;
|
|
16
|
-
|
|
17
|
-
align?:
|
|
18
|
-
side?:
|
|
14
|
+
"aria-label": string;
|
|
15
|
+
align?: "start" | "center" | "end";
|
|
16
|
+
side?: "top" | "right" | "bottom" | "left";
|
|
19
17
|
sideOffset?: number;
|
|
20
18
|
className?: string;
|
|
21
19
|
panelClassName?: string;
|
|
@@ -23,8 +21,7 @@ export interface SuiteDropdownMenuProps {
|
|
|
23
21
|
onOpenChange?: (open: boolean) => void;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
export type SuiteDropdownMenuComponent =
|
|
27
|
-
ComponentType<SuiteDropdownMenuProps>;
|
|
24
|
+
export type SuiteDropdownMenuComponent = ComponentType<SuiteDropdownMenuProps>;
|
|
28
25
|
|
|
29
26
|
export type SuiteDropdownItemComponent = ComponentType<
|
|
30
27
|
ButtonHTMLAttributes<HTMLButtonElement>
|
|
@@ -44,11 +41,15 @@ export type SuiteCommandPaletteComponent = ComponentType<{
|
|
|
44
41
|
onClose: () => void;
|
|
45
42
|
placeholder?: string;
|
|
46
43
|
className?: string;
|
|
44
|
+
id?: string;
|
|
45
|
+
dataTest?: string;
|
|
46
|
+
inputId?: string;
|
|
47
|
+
inputDataTest?: string;
|
|
47
48
|
}>;
|
|
48
49
|
|
|
49
50
|
/** App-branded spinner (each app has its own AppSpinner design). */
|
|
50
51
|
export type SuiteSpinnerComponent = ComponentType<{
|
|
51
|
-
size?:
|
|
52
|
+
size?: "sm" | "md" | "lg";
|
|
52
53
|
className?: string;
|
|
53
54
|
label?: string;
|
|
54
55
|
}>;
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
export { TimeSeriesLineChart, BarChart, PieChart, Sparkline, MetricCard, FunnelChart } from "./quill";
|
|
4
|
-
export type { TimeSeriesLineChartProps, TimeSeriesLineChartSeries } from "./quill";
|
|
5
|
-
export type { BarChartProps, BarChartSeries } from "./quill";
|
|
6
|
-
export type { PieChartProps, PieChartSlice } from "./quill";
|
|
7
|
-
export type { SparklineProps } from "./quill";
|
|
8
|
-
export type { MetricCardProps } from "./quill";
|
|
9
|
-
export type { FunnelChartProps, FunnelStep } from "./quill/FunnelChart";
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useMemo } from "react";
|
|
4
|
-
import { useChartTokens } from "@/lib/chart/use-ph-chart-tokens";
|
|
5
|
-
import { cn } from "@/lib/cn";
|
|
6
|
-
|
|
7
|
-
export interface BarChartSeries {
|
|
8
|
-
key: string;
|
|
9
|
-
label: string;
|
|
10
|
-
data: number[];
|
|
11
|
-
color?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface BarChartProps {
|
|
15
|
-
series: BarChartSeries[];
|
|
16
|
-
labels: string[];
|
|
17
|
-
className?: string;
|
|
18
|
-
height?: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function BarChart({ series, labels, className, height = 280 }: BarChartProps) {
|
|
22
|
-
const tokens = useChartTokens();
|
|
23
|
-
const groupCount = labels.length;
|
|
24
|
-
const barCount = series.length;
|
|
25
|
-
|
|
26
|
-
const { maxVal, chartData } = useMemo(() => {
|
|
27
|
-
let mx = 0;
|
|
28
|
-
const flat = series.flatMap((s) => s.data);
|
|
29
|
-
for (const v of flat) if (v > mx) mx = v;
|
|
30
|
-
return {
|
|
31
|
-
maxVal: mx || 1,
|
|
32
|
-
chartData: series.map((s, i) => ({
|
|
33
|
-
label: s.label,
|
|
34
|
-
color: s.color ?? tokens.series[i % tokens.series.length],
|
|
35
|
-
values: s.data,
|
|
36
|
-
})),
|
|
37
|
-
};
|
|
38
|
-
}, [series, tokens.series]);
|
|
39
|
-
|
|
40
|
-
const padding = { top: 16, right: 8, bottom: 24, left: 8 };
|
|
41
|
-
const innerW = 400 - padding.left - padding.right;
|
|
42
|
-
const innerH = height - padding.top - padding.bottom;
|
|
43
|
-
const groupW = innerW / groupCount;
|
|
44
|
-
const barW = Math.max(4, (groupW * 0.7) / barCount);
|
|
45
|
-
const gap = groupW * 0.15;
|
|
46
|
-
|
|
47
|
-
return (
|
|
48
|
-
<div className={cn("relative w-full", className)} style={{ height }}>
|
|
49
|
-
<svg viewBox={`0 0 400 ${height}`} className="h-full w-full" preserveAspectRatio="xMidYMid meet" suppressHydrationWarning>
|
|
50
|
-
{chartData.map((bar, bi) =>
|
|
51
|
-
bar.values.map((v, li) => {
|
|
52
|
-
const x = padding.left + li * groupW + gap / 2 + bi * barW;
|
|
53
|
-
const barH = (v / maxVal) * (innerH - 4);
|
|
54
|
-
const y = padding.top + innerH - barH;
|
|
55
|
-
return (
|
|
56
|
-
<rect
|
|
57
|
-
key={`${bi}-${li}`}
|
|
58
|
-
x={x}
|
|
59
|
-
y={y}
|
|
60
|
-
width={barW}
|
|
61
|
-
height={barH}
|
|
62
|
-
fill={bar.color}
|
|
63
|
-
rx={2}
|
|
64
|
-
>
|
|
65
|
-
<title>{`${bar.label}: ${v}`}</title>
|
|
66
|
-
</rect>
|
|
67
|
-
);
|
|
68
|
-
})
|
|
69
|
-
)}
|
|
70
|
-
{labels.map((l, i) => (
|
|
71
|
-
<text
|
|
72
|
-
key={l}
|
|
73
|
-
x={padding.left + i * groupW + groupW / 2}
|
|
74
|
-
y={height - 4}
|
|
75
|
-
textAnchor="middle"
|
|
76
|
-
className="fill-ph-mutedtext"
|
|
77
|
-
fontSize="10"
|
|
78
|
-
>
|
|
79
|
-
{l}
|
|
80
|
-
</text>
|
|
81
|
-
))}
|
|
82
|
-
</svg>
|
|
83
|
-
</div>
|
|
84
|
-
);
|
|
85
|
-
}
|