@xenide-io/the-old-ui-theme 0.5.2 → 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/README.md +47 -34
- 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-RZXHZWUB.mjs → chunk-SBBKXV35.mjs} +174 -222
- package/dist/index.d.mts +4 -80
- package/dist/index.d.ts +4 -80
- package/dist/index.js +562 -3746
- package/dist/index.mjs +3 -765
- package/dist/suite.d.mts +56 -25
- package/dist/suite.d.ts +56 -25
- package/dist/suite.js +248 -107
- package/dist/suite.mjs +217 -105
- package/dist/ui.d.mts +608 -2
- package/dist/ui.d.ts +608 -2
- package/dist/ui.js +232 -230
- 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/app/globals.css +1 -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/ThemeSwitcher.tsx +12 -3
- package/src/components/ui/interaction-primitives.test.tsx +52 -0
- package/src/styles/suite-skin.css +53 -1
- package/src/styles/themes.css +445 -1656
- 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 +31 -10
- 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 +54 -43
- package/src/suite/components/suite-user-menu.tsx +54 -26
- package/src/suite/components/today-page-frame.tsx +1 -1
- package/src/suite/icons/app-accents.ts +38 -43
- package/src/suite/icons/glyph-parts.tsx +16 -9
- package/src/suite/icons/glyphs.ts +130 -99
- package/src/suite/lib/apps.ts +1 -1
- package/src/suite/lib/injected.ts +12 -11
- package/src/suite/lib/use-sidebar-width.ts +23 -8
- 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,32 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import Image from
|
|
4
|
-
import { useState, type ReactNode } from
|
|
5
|
-
import { Check, NavArrowDown as ChevronDown } from
|
|
3
|
+
import Image from "next/image";
|
|
4
|
+
import { useState, type ReactNode } from "react";
|
|
5
|
+
import { Check, NavArrowDown as ChevronDown } from "iconoir-react";
|
|
6
6
|
|
|
7
|
-
import { cn } from
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
8
|
import type {
|
|
9
9
|
SuiteDropdownItemComponent,
|
|
10
10
|
SuiteDropdownMenuComponent,
|
|
11
|
-
} from
|
|
11
|
+
} from "../lib/injected";
|
|
12
12
|
|
|
13
13
|
export function appSwitcherTriggerClass(open: boolean, collapsed = false) {
|
|
14
14
|
return cn(
|
|
15
|
-
|
|
16
|
-
collapsed ?
|
|
15
|
+
"group flex min-w-0 items-center gap-2 rounded-lg border px-2 py-1.5 text-left transition-all duration-200",
|
|
16
|
+
collapsed ? "h-11 w-11 justify-center px-0 py-0" : "w-full",
|
|
17
17
|
open
|
|
18
|
-
?
|
|
19
|
-
:
|
|
18
|
+
? "border-ph-brand/30 bg-ph-muted shadow-[0_1px_2px_rgba(0,0,0,0.06)] ring-1 ring-ph-brand/15"
|
|
19
|
+
: "border-transparent hover:border-ph-border hover:bg-ph-muted/80 hover:shadow-sm",
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function appSwitcherMarkClass() {
|
|
24
|
-
return
|
|
24
|
+
return "h-8 w-8 shrink-0 rounded-lg shadow-[0_1px_2px_rgba(0,0,0,0.1),0_4px_12px_rgba(0,0,0,0.08)] ring-1 ring-black/[0.06]";
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/** No sticky Radix highlight while waiting for cross-app redirect. */
|
|
28
28
|
export function appSwitcherMenuItemClass() {
|
|
29
|
-
return
|
|
29
|
+
return "rounded-lg px-2 py-2 data-[highlighted]:bg-transparent hover:bg-ph-muted/80";
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function AppSwitcherMark({
|
|
@@ -39,8 +39,8 @@ export function AppSwitcherMark({
|
|
|
39
39
|
return (
|
|
40
40
|
<span
|
|
41
41
|
className={cn(
|
|
42
|
-
|
|
43
|
-
collapsed &&
|
|
42
|
+
"relative shrink-0",
|
|
43
|
+
collapsed && "group-hover:scale-[1.02]",
|
|
44
44
|
)}
|
|
45
45
|
>
|
|
46
46
|
{children}
|
|
@@ -59,15 +59,15 @@ export function AppSwitcherChevron({
|
|
|
59
59
|
return (
|
|
60
60
|
<span
|
|
61
61
|
className={cn(
|
|
62
|
-
|
|
63
|
-
open &&
|
|
62
|
+
"absolute bottom-0 right-0 flex h-3 w-3 items-center justify-center rounded-full border border-ph-border bg-ph-surface shadow-sm transition-colors",
|
|
63
|
+
open && "border-ph-brand/40 bg-ph-brand/10",
|
|
64
64
|
)}
|
|
65
65
|
aria-hidden
|
|
66
66
|
>
|
|
67
67
|
<ChevronDown
|
|
68
68
|
className={cn(
|
|
69
|
-
|
|
70
|
-
open &&
|
|
69
|
+
"h-2 w-2 text-ph-mutedtext transition-transform duration-200",
|
|
70
|
+
open && "rotate-180 text-ph-brand",
|
|
71
71
|
)}
|
|
72
72
|
/>
|
|
73
73
|
</span>
|
|
@@ -77,17 +77,17 @@ export function AppSwitcherChevron({
|
|
|
77
77
|
return (
|
|
78
78
|
<span
|
|
79
79
|
className={cn(
|
|
80
|
-
|
|
80
|
+
"border-ph-border/80 flex h-5 w-5 shrink-0 items-center justify-center rounded-lg border bg-ph-canvas shadow-sm transition-all duration-200",
|
|
81
81
|
open
|
|
82
|
-
?
|
|
83
|
-
:
|
|
82
|
+
? "border-ph-brand/35 bg-ph-brand/10 text-ph-brand"
|
|
83
|
+
: "text-ph-mutedtext group-hover:border-ph-border group-hover:bg-ph-surface",
|
|
84
84
|
)}
|
|
85
85
|
aria-hidden
|
|
86
86
|
>
|
|
87
87
|
<ChevronDown
|
|
88
88
|
className={cn(
|
|
89
|
-
|
|
90
|
-
open &&
|
|
89
|
+
"h-3 w-3 transition-transform duration-200",
|
|
90
|
+
open && "rotate-180",
|
|
91
91
|
)}
|
|
92
92
|
/>
|
|
93
93
|
</span>
|
|
@@ -115,6 +115,8 @@ export function AppSwitcher({
|
|
|
115
115
|
collapsed = false,
|
|
116
116
|
dropdownMenu: DropdownMenu,
|
|
117
117
|
dropdownItem: DropdownItem,
|
|
118
|
+
triggerId,
|
|
119
|
+
triggerDataTest,
|
|
118
120
|
}: {
|
|
119
121
|
apps: SuiteAppEntry[];
|
|
120
122
|
currentApp: string;
|
|
@@ -124,13 +126,17 @@ export function AppSwitcher({
|
|
|
124
126
|
collapsed?: boolean;
|
|
125
127
|
dropdownMenu: SuiteDropdownMenuComponent;
|
|
126
128
|
dropdownItem: SuiteDropdownItemComponent;
|
|
129
|
+
triggerId?: string;
|
|
130
|
+
triggerDataTest?: string;
|
|
127
131
|
}) {
|
|
128
132
|
const [open, setOpen] = useState(false);
|
|
129
133
|
|
|
130
134
|
return (
|
|
131
135
|
<DropdownMenu
|
|
132
136
|
aria-label="Switch application"
|
|
133
|
-
|
|
137
|
+
triggerId={triggerId ?? "app-switcher-trigger"}
|
|
138
|
+
triggerDataTest={triggerDataTest ?? "app-switcher-trigger"}
|
|
139
|
+
className={collapsed ? "w-11 shrink-0" : "min-w-0 flex-1"}
|
|
134
140
|
panelClassName="w-64"
|
|
135
141
|
align="start"
|
|
136
142
|
open={open}
|
|
@@ -150,13 +156,14 @@ export function AppSwitcher({
|
|
|
150
156
|
</span>
|
|
151
157
|
}
|
|
152
158
|
>
|
|
153
|
-
<div className="p-1" data-test="app-switcher-menu">
|
|
159
|
+
<div id="app-switcher-menu" className="p-1" data-test="app-switcher-menu">
|
|
154
160
|
<p className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-ph-mutedtext">
|
|
155
161
|
Switch application
|
|
156
162
|
</p>
|
|
157
163
|
{apps.map((app) => (
|
|
158
164
|
<DropdownItem
|
|
159
165
|
key={app.slug}
|
|
166
|
+
id={`switch-app-${app.slug}`}
|
|
160
167
|
data-test={`switch-app-${app.slug}`}
|
|
161
168
|
onMouseDown={(e) => e.preventDefault()}
|
|
162
169
|
onClick={() => {
|
|
@@ -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,8 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
import { type ReactNode, type PointerEvent } from
|
|
3
|
+
import { type ReactNode, type PointerEvent } from "react";
|
|
4
4
|
|
|
5
|
-
import { cn } from
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
6
|
|
|
7
7
|
export interface SuiteAppLayoutProps {
|
|
8
8
|
sidebar: ReactNode;
|
|
@@ -20,6 +20,8 @@ export interface SuiteAppLayoutProps {
|
|
|
20
20
|
lockMainScroll?: boolean;
|
|
21
21
|
/** Drag this to resize the sidebar. */
|
|
22
22
|
onStartResize?: (e: PointerEvent<HTMLDivElement>) => void;
|
|
23
|
+
/** Keyboard equivalent for the resize handle. */
|
|
24
|
+
onResizeBy?: (delta: number) => void;
|
|
23
25
|
/** Double-click to reset sidebar width. */
|
|
24
26
|
onResetWidth?: () => void;
|
|
25
27
|
className?: string;
|
|
@@ -39,18 +41,22 @@ export function SuiteAppLayout({
|
|
|
39
41
|
collapsed = false,
|
|
40
42
|
lockMainScroll = false,
|
|
41
43
|
onStartResize,
|
|
44
|
+
onResizeBy,
|
|
42
45
|
onResetWidth,
|
|
43
46
|
className,
|
|
44
47
|
}: SuiteAppLayoutProps) {
|
|
45
48
|
return (
|
|
46
49
|
<div
|
|
47
50
|
data-test="app-shell"
|
|
48
|
-
className={cn(
|
|
51
|
+
className={cn(
|
|
52
|
+
"suite-app-layout flex h-dvh overflow-hidden",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
49
55
|
>
|
|
50
56
|
<aside
|
|
51
|
-
className="relative hidden shrink-0 flex-col overflow-
|
|
57
|
+
className="relative hidden shrink-0 flex-col overflow-visible border-r border-ph-border ease-out lg:flex"
|
|
52
58
|
style={{ width: `${sidebarWidth}px` }}
|
|
53
|
-
data-collapsed={collapsed ?
|
|
59
|
+
data-collapsed={collapsed ? "true" : "false"}
|
|
54
60
|
>
|
|
55
61
|
{sidebar}
|
|
56
62
|
{onStartResize ? (
|
|
@@ -58,9 +64,23 @@ export function SuiteAppLayout({
|
|
|
58
64
|
role="separator"
|
|
59
65
|
aria-orientation="vertical"
|
|
60
66
|
aria-label="Resize sidebar"
|
|
67
|
+
data-test="sidebar-resize-handle"
|
|
68
|
+
tabIndex={onResizeBy ? 0 : undefined}
|
|
61
69
|
onPointerDown={onStartResize}
|
|
62
70
|
onDoubleClick={onResetWidth}
|
|
63
|
-
|
|
71
|
+
onKeyDown={(event) => {
|
|
72
|
+
if (event.key === "Home") {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
onResetWidth?.();
|
|
75
|
+
} else if (event.key === "ArrowLeft") {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
onResizeBy?.(-16);
|
|
78
|
+
} else if (event.key === "ArrowRight") {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
onResizeBy?.(16);
|
|
81
|
+
}
|
|
82
|
+
}}
|
|
83
|
+
className="absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize touch-none transition-colors hover:bg-[color-mix(in_oklab,var(--ph-accent)_30%,transparent)]"
|
|
64
84
|
/>
|
|
65
85
|
) : null}
|
|
66
86
|
</aside>
|
|
@@ -72,10 +92,11 @@ export function SuiteAppLayout({
|
|
|
72
92
|
<main
|
|
73
93
|
id="main-content"
|
|
74
94
|
tabIndex={-1}
|
|
75
|
-
data-lock-scroll={lockMainScroll ?
|
|
95
|
+
data-lock-scroll={lockMainScroll ? "true" : undefined}
|
|
76
96
|
className={cn(
|
|
77
|
-
|
|
78
|
-
|
|
97
|
+
"flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overscroll-contain bg-ph-canvas focus:outline-none",
|
|
98
|
+
"suite-app-layout__main",
|
|
99
|
+
lockMainScroll ? "overflow-hidden" : "overflow-y-auto",
|
|
79
100
|
)}
|
|
80
101
|
>
|
|
81
102
|
{children}
|
|
@@ -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
|
>
|