@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.
Files changed (59) hide show
  1. package/README.md +47 -34
  2. package/dist/{index-Od4pIP4F.d.mts → Chip-C5hRFAhe.d.mts} +10 -606
  3. package/dist/{index-Od4pIP4F.d.ts → Chip-C5hRFAhe.d.ts} +10 -606
  4. package/dist/{chunk-G53YLHVE.mjs → chunk-E5ZBQYEX.mjs} +59 -9
  5. package/dist/{chunk-RZXHZWUB.mjs → chunk-SBBKXV35.mjs} +174 -222
  6. package/dist/index.d.mts +4 -80
  7. package/dist/index.d.ts +4 -80
  8. package/dist/index.js +562 -3746
  9. package/dist/index.mjs +3 -765
  10. package/dist/suite.d.mts +56 -25
  11. package/dist/suite.d.ts +56 -25
  12. package/dist/suite.js +248 -107
  13. package/dist/suite.mjs +217 -105
  14. package/dist/ui.d.mts +608 -2
  15. package/dist/ui.d.ts +608 -2
  16. package/dist/ui.js +232 -230
  17. package/dist/ui.mjs +2 -2
  18. package/package.json +2 -6
  19. package/scripts/install-theme-dev.sh +60 -0
  20. package/scripts/sync-posthog-icons.mjs +75 -0
  21. package/src/app/globals.css +1 -0
  22. package/src/components/ui/Chip.tsx +36 -18
  23. package/src/components/ui/CommandPalette.test.tsx +41 -0
  24. package/src/components/ui/CommandPalette.tsx +58 -9
  25. package/src/components/ui/DropdownMenu.tsx +50 -11
  26. package/src/components/ui/SegmentedControl.tsx +2 -1
  27. package/src/components/ui/SettingsLayout.tsx +19 -5
  28. package/src/components/ui/ThemeSwitcher.tsx +12 -3
  29. package/src/components/ui/interaction-primitives.test.tsx +52 -0
  30. package/src/styles/suite-skin.css +53 -1
  31. package/src/styles/themes.css +445 -1656
  32. package/src/suite/components/app-switcher.tsx +32 -25
  33. package/src/suite/components/command-palette-host.tsx +20 -8
  34. package/src/suite/components/suite-app-layout.tsx +31 -10
  35. package/src/suite/components/suite-bottom-nav.tsx +23 -18
  36. package/src/suite/components/suite-layout.test.tsx +44 -0
  37. package/src/suite/components/suite-layout.tsx +3 -3
  38. package/src/suite/components/suite-mobile-drawer.tsx +12 -0
  39. package/src/suite/components/suite-mobile-header.tsx +15 -9
  40. package/src/suite/components/suite-notification-bell.tsx +23 -11
  41. package/src/suite/components/suite-settings-mobile-nav.tsx +2 -0
  42. package/src/suite/components/suite-sidebar.tsx +54 -43
  43. package/src/suite/components/suite-user-menu.tsx +54 -26
  44. package/src/suite/components/today-page-frame.tsx +1 -1
  45. package/src/suite/icons/app-accents.ts +38 -43
  46. package/src/suite/icons/glyph-parts.tsx +16 -9
  47. package/src/suite/icons/glyphs.ts +130 -99
  48. package/src/suite/lib/apps.ts +1 -1
  49. package/src/suite/lib/injected.ts +12 -11
  50. package/src/suite/lib/use-sidebar-width.ts +23 -8
  51. package/src/components/charts/index.ts +0 -9
  52. package/src/components/charts/quill/BarChart.tsx +0 -85
  53. package/src/components/charts/quill/FunnelChart.tsx +0 -49
  54. package/src/components/charts/quill/InsightShell.tsx +0 -27
  55. package/src/components/charts/quill/MetricCard.tsx +0 -44
  56. package/src/components/charts/quill/PieChart.tsx +0 -81
  57. package/src/components/charts/quill/Sparkline.tsx +0 -57
  58. package/src/components/charts/quill/TimeSeriesLineChart.tsx +0 -62
  59. package/src/components/charts/quill/index.ts +0 -9
@@ -1,32 +1,32 @@
1
- 'use client';
1
+ "use client";
2
2
 
3
- import Image from 'next/image';
4
- import { useState, type ReactNode } from 'react';
5
- import { Check, NavArrowDown as ChevronDown } from 'iconoir-react';
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 '../lib/cn';
7
+ import { cn } from "../lib/cn";
8
8
  import type {
9
9
  SuiteDropdownItemComponent,
10
10
  SuiteDropdownMenuComponent,
11
- } from '../lib/injected';
11
+ } from "../lib/injected";
12
12
 
13
13
  export function appSwitcherTriggerClass(open: boolean, collapsed = false) {
14
14
  return cn(
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',
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
- ? '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',
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 '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]';
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 'rounded-lg px-2 py-2 data-[highlighted]:bg-transparent hover:bg-ph-muted/80';
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
- 'relative shrink-0',
43
- collapsed && 'group-hover:scale-[1.02]',
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
- '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',
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
- 'h-2 w-2 text-ph-mutedtext transition-transform duration-200',
70
- open && 'rotate-180 text-ph-brand',
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
- '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',
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
- ? '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',
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
- 'h-3 w-3 transition-transform duration-200',
90
- open && 'rotate-180',
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
- className={collapsed ? 'w-11 shrink-0' : 'min-w-0 flex-1'}
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
- 'use client';
1
+ "use client";
2
2
 
3
- import { useEffect, useState } from 'react';
3
+ import { useEffect, useState } from "react";
4
4
 
5
5
  import type {
6
6
  SuiteCommandItem,
7
7
  SuiteCommandPaletteComponent,
8
- } from '../lib/injected';
8
+ } from "../lib/injected";
9
9
 
10
10
  function isEditableTarget(): boolean {
11
- if (typeof document === 'undefined') return false;
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 = 'Type a command or search…',
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() === 'k')) return;
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('keydown', onKey);
49
- return () => window.removeEventListener('keydown', onKey);
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
- 'use client';
1
+ "use client";
2
2
 
3
- import { type ReactNode, type PointerEvent } from 'react';
3
+ import { type ReactNode, type PointerEvent } from "react";
4
4
 
5
- import { cn } from '../lib/cn';
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('flex min-h-dvh lg:h-screen lg:overflow-hidden', className)}
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-hidden border-r border-ph-border transition-[width] duration-200 ease-out lg:flex"
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 ? 'true' : 'false'}
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
- className="absolute inset-y-0 right-0 z-10 w-1 cursor-col-resize transition-colors hover:bg-[color-mix(in_oklab,var(--ph-accent)_30%,transparent)]"
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 ? 'true' : undefined}
95
+ data-lock-scroll={lockMainScroll ? "true" : undefined}
76
96
  className={cn(
77
- 'flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overscroll-contain bg-ph-canvas focus:outline-none',
78
- lockMainScroll ? 'overflow-hidden' : 'overflow-y-auto',
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
- 'use client';
1
+ "use client";
2
2
 
3
- import Link from 'next/link';
4
- import type { ComponentType } from 'react';
3
+ import Link from "next/link";
4
+ import type { ComponentType } from "react";
5
5
 
6
- import { cn } from '../lib/cn';
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
- 'aria-hidden'?: boolean | 'true' | 'false';
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 = 'Primary navigation',
33
- dataTest = 'suite-bottom-nav',
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 !== 'production' && items.length > 5) {
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: 'calc(60px + env(safe-area-inset-bottom))' }}
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
- 'suite-bottom-nav no-print z-40 grid auto-cols-fr grid-flow-col lg:hidden',
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
- '--suite-nav-items': items.length,
70
- '--suite-nav-active': activeIndex >= 0 ? activeIndex : 0,
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
- data-test={item.dataTest ?? `suite-nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
86
- aria-current={active ? 'page' : undefined}
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
- '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',
89
- active && 'font-semibold text-ph-brand',
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
- 'suite-bottom-nav__icon relative z-10 inline-flex items-center justify-center',
95
- active && 'suite-bottom-nav__icon--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 pb-2 sm:pb-3',
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 shrink-0 items-center gap-2 self-center">
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
- 'use client';
1
+ "use client";
2
2
 
3
- import type { ComponentType, ReactNode } from 'react';
4
- import { Menu } from 'iconoir-react';
3
+ import type { ComponentType, ReactNode } from "react";
4
+ import { Menu } from "iconoir-react";
5
5
 
6
- import { cn } from '../lib/cn';
6
+ import { cn } from "../lib/cn";
7
7
 
8
- type SuiteMenuIcon = ComponentType<{ className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>;
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 = 'Open navigation',
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
- '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',
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
- 'flex min-w-0 flex-1 items-center',
61
- !onMenuClick && 'pl-2',
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
- 'use client';
1
+ "use client";
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
4
- import { useRouter } from 'next/navigation';
5
- import { Bell } from 'iconoir-react';
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 '../lib/injected';
8
- import { resolveSuiteNotificationHref } from '../lib/apps';
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 'just now';
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 = 'suite-notifications-cache',
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-9 w-9 items-center justify-center rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink">
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 ? '9+' : unread}
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
  >