@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.
Files changed (49) hide show
  1. package/dist/{index-Od4pIP4F.d.mts → Chip-C5hRFAhe.d.mts} +10 -606
  2. package/dist/{index-Od4pIP4F.d.ts → Chip-C5hRFAhe.d.ts} +10 -606
  3. package/dist/{chunk-G53YLHVE.mjs → chunk-E5ZBQYEX.mjs} +59 -9
  4. package/dist/{chunk-VFTZXPXM.mjs → chunk-SBBKXV35.mjs} +144 -79
  5. package/dist/index.d.mts +3 -79
  6. package/dist/index.d.ts +3 -79
  7. package/dist/index.js +524 -3595
  8. package/dist/index.mjs +3 -765
  9. package/dist/suite.d.mts +38 -12
  10. package/dist/suite.d.ts +38 -12
  11. package/dist/suite.js +84 -15
  12. package/dist/suite.mjs +53 -13
  13. package/dist/ui.d.mts +608 -2
  14. package/dist/ui.d.ts +608 -2
  15. package/dist/ui.js +202 -87
  16. package/dist/ui.mjs +2 -2
  17. package/package.json +2 -6
  18. package/scripts/install-theme-dev.sh +60 -0
  19. package/scripts/sync-posthog-icons.mjs +75 -0
  20. package/src/components/ui/Chip.tsx +36 -18
  21. package/src/components/ui/CommandPalette.test.tsx +41 -0
  22. package/src/components/ui/CommandPalette.tsx +58 -9
  23. package/src/components/ui/DropdownMenu.tsx +50 -11
  24. package/src/components/ui/SegmentedControl.tsx +2 -1
  25. package/src/components/ui/SettingsLayout.tsx +19 -5
  26. package/src/components/ui/interaction-primitives.test.tsx +52 -0
  27. package/src/styles/suite-skin.css +17 -35
  28. package/src/suite/components/app-switcher.tsx +32 -25
  29. package/src/suite/components/command-palette-host.tsx +20 -8
  30. package/src/suite/components/suite-app-layout.tsx +1 -1
  31. package/src/suite/components/suite-bottom-nav.tsx +23 -18
  32. package/src/suite/components/suite-layout.test.tsx +44 -0
  33. package/src/suite/components/suite-layout.tsx +3 -3
  34. package/src/suite/components/suite-mobile-drawer.tsx +12 -0
  35. package/src/suite/components/suite-mobile-header.tsx +15 -9
  36. package/src/suite/components/suite-notification-bell.tsx +23 -11
  37. package/src/suite/components/suite-settings-mobile-nav.tsx +2 -0
  38. package/src/suite/components/suite-sidebar.tsx +6 -3
  39. package/src/suite/components/suite-user-menu.tsx +6 -0
  40. package/src/suite/lib/injected.ts +12 -11
  41. package/src/components/charts/index.ts +0 -9
  42. package/src/components/charts/quill/BarChart.tsx +0 -85
  43. package/src/components/charts/quill/FunnelChart.tsx +0 -49
  44. package/src/components/charts/quill/InsightShell.tsx +0 -27
  45. package/src/components/charts/quill/MetricCard.tsx +0 -44
  46. package/src/components/charts/quill/PieChart.tsx +0 -81
  47. package/src/components/charts/quill/Sparkline.tsx +0 -57
  48. package/src/components/charts/quill/TimeSeriesLineChart.tsx +0 -62
  49. package/src/components/charts/quill/index.ts +0 -9
@@ -0,0 +1,41 @@
1
+ import { useState } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { CommandPalette } from "@/components/ui/CommandPalette";
6
+
7
+ describe("command palette", () => {
8
+ it("behaves as a modal dialog and restores focus when dismissed", async () => {
9
+ const user = userEvent.setup();
10
+ const onSelect = vi.fn();
11
+
12
+ function Fixture() {
13
+ const [open, setOpen] = useState(false);
14
+ return (
15
+ <>
16
+ <button type="button" onClick={() => setOpen(true)}>
17
+ Open commands
18
+ </button>
19
+ <CommandPalette
20
+ isOpen={open}
21
+ onClose={() => setOpen(false)}
22
+ items={[
23
+ { id: "reports", label: "Open reports", onSelect },
24
+ ]}
25
+ />
26
+ </>
27
+ );
28
+ }
29
+
30
+ render(<Fixture />);
31
+ const trigger = screen.getByRole("button", { name: "Open commands" });
32
+ await user.click(trigger);
33
+
34
+ expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument();
35
+ expect(screen.getByRole("textbox", { name: "Search commands" })).toHaveFocus();
36
+
37
+ await user.keyboard("{Escape}");
38
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
39
+ expect(trigger).toHaveFocus();
40
+ });
41
+ });
@@ -18,6 +18,10 @@ interface CommandPaletteProps {
18
18
  onClose: () => void;
19
19
  placeholder?: string;
20
20
  className?: string;
21
+ id?: string;
22
+ dataTest?: string;
23
+ inputId?: string;
24
+ inputDataTest?: string;
21
25
  }
22
26
 
23
27
  export function CommandPalette({
@@ -26,21 +30,32 @@ export function CommandPalette({
26
30
  onClose,
27
31
  placeholder = "Type a command or search...",
28
32
  className,
33
+ id,
34
+ dataTest,
35
+ inputId,
36
+ inputDataTest,
29
37
  }: CommandPaletteProps) {
30
38
  const [query, setQuery] = useState("");
31
39
  const [selectedIndex, setSelectedIndex] = useState(0);
32
40
  const inputRef = useRef<HTMLInputElement>(null);
41
+ const panelRef = useRef<HTMLDivElement>(null);
42
+ const previousFocusRef = useRef<HTMLElement | null>(null);
33
43
 
34
44
  const filtered = items.filter((item) =>
35
- item.label.toLowerCase().includes(query.toLowerCase())
45
+ item.label.toLowerCase().includes(query.toLowerCase()),
36
46
  );
37
47
 
38
48
  useEffect(() => {
39
- if (isOpen) {
40
- setQuery("");
41
- setSelectedIndex(0);
42
- inputRef.current?.focus();
43
- }
49
+ if (!isOpen) return;
50
+ previousFocusRef.current =
51
+ document.activeElement instanceof HTMLElement
52
+ ? document.activeElement
53
+ : null;
54
+ setQuery("");
55
+ setSelectedIndex(0);
56
+ inputRef.current?.focus();
57
+
58
+ return () => previousFocusRef.current?.focus();
44
59
  }, [isOpen]);
45
60
 
46
61
  useEffect(() => {
@@ -49,10 +64,12 @@ export function CommandPalette({
49
64
 
50
65
  switch (e.key) {
51
66
  case "ArrowDown":
67
+ if (!filtered.length) break;
52
68
  e.preventDefault();
53
69
  setSelectedIndex((i) => (i + 1) % filtered.length);
54
70
  break;
55
71
  case "ArrowUp":
72
+ if (!filtered.length) break;
56
73
  e.preventDefault();
57
74
  setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length);
58
75
  break;
@@ -64,6 +81,22 @@ export function CommandPalette({
64
81
  case "Escape":
65
82
  onClose();
66
83
  break;
84
+ case "Tab": {
85
+ const focusable = panelRef.current?.querySelectorAll<HTMLElement>(
86
+ 'input, button, [href], [tabindex]:not([tabindex="-1"])',
87
+ );
88
+ if (!focusable?.length) break;
89
+ const first = focusable[0];
90
+ const last = focusable[focusable.length - 1];
91
+ if (e.shiftKey && document.activeElement === first) {
92
+ e.preventDefault();
93
+ last.focus();
94
+ } else if (!e.shiftKey && document.activeElement === last) {
95
+ e.preventDefault();
96
+ first.focus();
97
+ }
98
+ break;
99
+ }
67
100
  }
68
101
  };
69
102
 
@@ -74,18 +107,32 @@ export function CommandPalette({
74
107
  if (!isOpen) return null;
75
108
 
76
109
  return (
77
- <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[20vh] backdrop-blur-sm">
110
+ <div
111
+ id={id}
112
+ data-test={dataTest}
113
+ className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[20vh] backdrop-blur-sm"
114
+ onMouseDown={(event) => {
115
+ if (event.target === event.currentTarget) onClose();
116
+ }}
117
+ >
78
118
  <div
119
+ ref={panelRef}
120
+ role="dialog"
121
+ aria-modal="true"
122
+ aria-label="Command palette"
79
123
  className={cn(
80
124
  "w-full max-w-xl overflow-hidden rounded-xl border border-ph-border bg-ph-surface shadow-ph-md",
81
- className
125
+ className,
82
126
  )}
83
127
  >
84
128
  <div className="flex items-center gap-3 border-b border-ph-border px-4 py-3">
85
129
  <IconSearch className="h-5 w-5 text-ph-mutedtext" />
86
130
  <input
87
131
  ref={inputRef}
132
+ id={inputId}
133
+ data-test={inputDataTest}
88
134
  type="text"
135
+ aria-label="Search commands"
89
136
  value={query}
90
137
  onChange={(e) => {
91
138
  setQuery(e.target.value);
@@ -108,6 +155,8 @@ export function CommandPalette({
108
155
  <button
109
156
  key={item.id}
110
157
  type="button"
158
+ id={`command-${item.id}`}
159
+ data-test={`command-${item.id}`}
111
160
  onClick={() => {
112
161
  item.onSelect();
113
162
  onClose();
@@ -117,7 +166,7 @@ export function CommandPalette({
117
166
  "flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
118
167
  index === selectedIndex
119
168
  ? "bg-ph-muted text-ph-ink"
120
- : "text-ph-subtle"
169
+ : "text-ph-subtle",
121
170
  )}
122
171
  >
123
172
  {item.icon && <span className="h-5 w-5">{item.icon}</span>}
@@ -35,11 +35,14 @@ interface DropdownOpenProps {
35
35
  }
36
36
 
37
37
  export interface DropdownMenuProps
38
- extends Omit<HTMLAttributes<HTMLDivElement>, "children">,
38
+ extends
39
+ Omit<HTMLAttributes<HTMLDivElement>, "children">,
39
40
  DropdownPlacementProps,
40
41
  DropdownOpenProps {
41
42
  /** Custom non-interactive trigger node, such as an avatar or icon chip. */
42
43
  trigger: ReactNode;
44
+ triggerId?: string;
45
+ triggerDataTest?: string;
43
46
  /** Accessible name when the trigger has no visible text. */
44
47
  "aria-label": string;
45
48
  children: ReactNode;
@@ -47,7 +50,8 @@ export interface DropdownMenuProps
47
50
  }
48
51
 
49
52
  export interface DropdownButtonProps
50
- extends Omit<HTMLAttributes<HTMLDivElement>, "children">,
53
+ extends
54
+ Omit<HTMLAttributes<HTMLDivElement>, "children">,
51
55
  DropdownPlacementProps,
52
56
  DropdownOpenProps {
53
57
  /** Visible button label. */
@@ -67,8 +71,12 @@ export interface DropdownButtonProps
67
71
  }
68
72
 
69
73
  export type DropdownItemProps = ButtonHTMLAttributes<HTMLButtonElement>;
70
- export type DropdownRadioGroupProps = ComponentPropsWithoutRef<typeof DropdownPrimitive.RadioGroup>;
71
- export type DropdownRadioItemProps = ComponentPropsWithoutRef<typeof DropdownPrimitive.RadioItem>;
74
+ export type DropdownRadioGroupProps = ComponentPropsWithoutRef<
75
+ typeof DropdownPrimitive.RadioGroup
76
+ >;
77
+ export type DropdownRadioItemProps = ComponentPropsWithoutRef<
78
+ typeof DropdownPrimitive.RadioItem
79
+ >;
72
80
 
73
81
  const sizeClass: Record<ButtonSize, string> = {
74
82
  xs: "ph-btn-xs",
@@ -106,7 +114,11 @@ function DropdownContent({
106
114
  sticky="partial"
107
115
  >
108
116
  {children}
109
- <DropdownPrimitive.Arrow className="ph-dropdown-arrow" width={12} height={6} />
117
+ <DropdownPrimitive.Arrow
118
+ className="ph-dropdown-arrow"
119
+ width={12}
120
+ height={6}
121
+ />
110
122
  </DropdownPrimitive.Content>
111
123
  </DropdownPrimitive.Portal>
112
124
  );
@@ -115,6 +127,8 @@ function DropdownContent({
115
127
  /** Collision-aware menu that flips sides and shifts its alignment to remain visible. */
116
128
  export function DropdownMenu({
117
129
  trigger,
130
+ triggerId,
131
+ triggerDataTest,
118
132
  children,
119
133
  align,
120
134
  side,
@@ -141,7 +155,13 @@ export function DropdownMenu({
141
155
  modal={modal}
142
156
  >
143
157
  <DropdownPrimitive.Trigger asChild disabled={disabled}>
144
- <button type="button" className="ph-dropdown-trigger" aria-label={ariaLabel}>
158
+ <button
159
+ type="button"
160
+ id={triggerId}
161
+ data-test={triggerDataTest}
162
+ className="ph-dropdown-trigger"
163
+ aria-label={ariaLabel}
164
+ >
145
165
  {trigger}
146
166
  </button>
147
167
  </DropdownPrimitive.Trigger>
@@ -187,7 +207,9 @@ export function DropdownButton({
187
207
  disabled,
188
208
  ...props
189
209
  }: DropdownButtonProps) {
190
- const trailing = sideIcon ?? <IconChevronDown className="h-4 w-4" aria-hidden />;
210
+ const trailing = sideIcon ?? (
211
+ <IconChevronDown className="h-4 w-4" aria-hidden />
212
+ );
191
213
 
192
214
  return (
193
215
  <div className={cn("ph-dropdown", className)} {...props}>
@@ -210,7 +232,12 @@ export function DropdownButton({
210
232
  )}
211
233
  aria-label={ariaLabel}
212
234
  >
213
- <ButtonChrome label={label} icon={icon} sideIcon={trailing} split={split} />
235
+ <ButtonChrome
236
+ label={label}
237
+ icon={icon}
238
+ sideIcon={trailing}
239
+ split={split}
240
+ />
214
241
  </button>
215
242
  </DropdownPrimitive.Trigger>
216
243
  <DropdownContent
@@ -229,7 +256,12 @@ export function DropdownButton({
229
256
  );
230
257
  }
231
258
 
232
- export function DropdownItem({ className, children, disabled, ...props }: DropdownItemProps) {
259
+ export function DropdownItem({
260
+ className,
261
+ children,
262
+ disabled,
263
+ ...props
264
+ }: DropdownItemProps) {
233
265
  return (
234
266
  <DropdownPrimitive.Item asChild disabled={disabled}>
235
267
  <button
@@ -248,9 +280,16 @@ export function DropdownRadioGroup(props: DropdownRadioGroupProps) {
248
280
  return <DropdownPrimitive.RadioGroup {...props} />;
249
281
  }
250
282
 
251
- export function DropdownRadioItem({ className, children, ...props }: DropdownRadioItemProps) {
283
+ export function DropdownRadioItem({
284
+ className,
285
+ children,
286
+ ...props
287
+ }: DropdownRadioItemProps) {
252
288
  return (
253
- <DropdownPrimitive.RadioItem className={cn("ph-dropdown-item ph-dropdown-radio-item", className)} {...props}>
289
+ <DropdownPrimitive.RadioItem
290
+ className={cn("ph-dropdown-item ph-dropdown-radio-item", className)}
291
+ {...props}
292
+ >
254
293
  <span className="ph-dropdown-item-indicator" aria-hidden>
255
294
  <DropdownPrimitive.ItemIndicator>
256
295
  <IconCheck className="h-3.5 w-3.5" />
@@ -27,9 +27,10 @@ export function SegmentedControl({ options, value, onChange, className }: Segmen
27
27
  <button
28
28
  key={option.value}
29
29
  type="button"
30
+ aria-pressed={value === option.value}
30
31
  onClick={() => onChange(option.value)}
31
32
  className={cn(
32
- "relative flex items-center gap-1.5 rounded-md px-3.5 py-1.5 text-sm font-medium transition-all",
33
+ "relative flex min-h-11 items-center gap-1.5 rounded-md px-3.5 py-1.5 text-sm font-medium transition-all sm:min-h-9",
33
34
  value === option.value
34
35
  ? "bg-ph-surface text-ph-ink shadow-ph"
35
36
  : "text-ph-subtle hover:text-ph-ink"
@@ -20,7 +20,10 @@ export interface SettingsNavProps extends ComponentPropsWithoutRef<"div"> {
20
20
  groups: readonly SettingsNavGroup[];
21
21
  }
22
22
 
23
- export interface SettingsLayoutProps extends Omit<ComponentPropsWithoutRef<"section">, "title"> {
23
+ export interface SettingsLayoutProps extends Omit<
24
+ ComponentPropsWithoutRef<"section">,
25
+ "title"
26
+ > {
24
27
  title: ReactNode;
25
28
  description?: ReactNode;
26
29
  actions?: ReactNode;
@@ -40,12 +43,21 @@ export function SettingsNav({ groups, className, ...props }: SettingsNavProps) {
40
43
  <a
41
44
  key={item.id}
42
45
  href={item.href}
43
- className={cn("ph-settings-nav__item", item.active && "ph-settings-nav__item--active")}
46
+ id={`settings-nav-${item.id}`}
47
+ data-test={`settings-nav-${item.id}`}
48
+ className={cn(
49
+ "ph-settings-nav__item",
50
+ item.active && "ph-settings-nav__item--active",
51
+ )}
44
52
  aria-current={item.active ? "page" : undefined}
45
53
  >
46
- {item.icon ? <span className="ph-settings-nav__icon">{item.icon}</span> : null}
54
+ {item.icon ? (
55
+ <span className="ph-settings-nav__icon">{item.icon}</span>
56
+ ) : null}
47
57
  <span>{item.label}</span>
48
- {item.badge ? <span className="ph-settings-nav__badge">{item.badge}</span> : null}
58
+ {item.badge ? (
59
+ <span className="ph-settings-nav__badge">{item.badge}</span>
60
+ ) : null}
49
61
  </a>
50
62
  ))}
51
63
  </div>
@@ -72,7 +84,9 @@ export function SettingsLayout({
72
84
  <h2>{title}</h2>
73
85
  {description ? <p>{description}</p> : null}
74
86
  </div>
75
- {actions ? <div className="ph-settings-layout__actions">{actions}</div> : null}
87
+ {actions ? (
88
+ <div className="ph-settings-layout__actions">{actions}</div>
89
+ ) : null}
76
90
  </header>
77
91
 
78
92
  <details className="ph-settings-layout__mobile-nav">
@@ -5,6 +5,8 @@ import { describe, expect, it, vi } from "vitest";
5
5
  import { DropdownButton, DropdownItem } from "@/components/ui/DropdownMenu";
6
6
  import { FilterMenu } from "@/components/ui/FilterBar";
7
7
  import { FilterChips } from "@/components/ui/FilterChips";
8
+ import { SegmentedControl } from "@/components/ui/SegmentedControl";
9
+ import { Chip } from "@/components/ui/Chip";
8
10
 
9
11
  describe("adaptive dropdown", () => {
10
12
  it("supports keyboard navigation, selection, closing, and focus restoration", async () => {
@@ -75,3 +77,53 @@ describe("filter chips", () => {
75
77
  expect(onToggle).not.toHaveBeenCalled();
76
78
  });
77
79
  });
80
+
81
+ describe("chip", () => {
82
+ it("keeps selection and removal as separate keyboard controls", async () => {
83
+ const user = userEvent.setup();
84
+ const onClick = vi.fn();
85
+ const onRemove = vi.fn();
86
+
87
+ render(
88
+ <Chip onClick={onClick} onRemove={onRemove}>
89
+ React
90
+ </Chip>,
91
+ );
92
+
93
+ await user.click(screen.getByRole("button", { name: "Remove React" }));
94
+ expect(onRemove).toHaveBeenCalledTimes(1);
95
+ expect(onClick).not.toHaveBeenCalled();
96
+ });
97
+ });
98
+
99
+ describe("segmented control", () => {
100
+ it("exposes and updates the selected option as a pressed button", async () => {
101
+ const user = userEvent.setup();
102
+
103
+ function Fixture() {
104
+ const [value, setValue] = useState("board");
105
+ return (
106
+ <SegmentedControl
107
+ value={value}
108
+ onChange={setValue}
109
+ options={[
110
+ { value: "board", label: "Board" },
111
+ { value: "list", label: "List" },
112
+ ]}
113
+ />
114
+ );
115
+ }
116
+
117
+ render(<Fixture />);
118
+ expect(screen.getByRole("button", { name: "Board" })).toHaveAttribute(
119
+ "aria-pressed",
120
+ "true",
121
+ );
122
+
123
+ await user.click(screen.getByRole("button", { name: "List" }));
124
+ expect(screen.getByRole("button", { name: "List" })).toHaveAttribute(
125
+ "aria-pressed",
126
+ "true",
127
+ );
128
+ });
129
+ });
@@ -78,41 +78,7 @@ body {
78
78
 
79
79
  .suite-app-layout__main {
80
80
  background: color-mix(in oklab, var(--ph-canvas) 94%, transparent);
81
- }
82
-
83
- /* The demo intentionally renders a large component catalogue. Let the
84
- browser skip layout and paint for sections outside the viewport while
85
- preserving their anchors for sidebar navigation. */
86
- .suite-app-layout__main > div > div > * {
87
- content-visibility: auto;
88
- contain-intrinsic-size: 0 560px;
89
- }
90
-
91
- @media print {
92
- .suite-app-layout__main > div > div > * {
93
- content-visibility: visible;
94
- }
95
- }
96
-
97
- .suite-app-layout aside [data-test="nav-link"] {
98
- position: relative;
99
- transition:
100
- color 160ms ease,
101
- background-color 160ms ease,
102
- box-shadow 160ms ease;
103
- }
104
-
105
- .suite-app-layout aside [data-test="nav-link"][aria-current="page"] {
106
- background: linear-gradient(
107
- 90deg,
108
- color-mix(in oklab, var(--ph-accent) 14%, var(--ph-muted)),
109
- color-mix(in oklab, var(--ph-muted) 60%, transparent)
110
- );
111
- box-shadow: inset 2px 0 0 var(--ph-accent);
112
- }
113
-
114
- .suite-app-layout aside [data-test="nav-link"][aria-current="page"] svg {
115
- color: var(--ph-accent);
81
+ scrollbar-gutter: stable;
116
82
  }
117
83
 
118
84
  @supports not (background: color-mix(in oklab, white, transparent)) {
@@ -531,6 +497,7 @@ button:hover > .ph-card {
531
497
  background: color-mix(in oklab, var(--ph-canvas) 86%, transparent);
532
498
  -webkit-backdrop-filter: blur(12px) saturate(1.4);
533
499
  backdrop-filter: blur(12px) saturate(1.4);
500
+ padding-bottom: env(safe-area-inset-bottom);
534
501
  box-shadow: 0 -1px 0 var(--ph-border-subtle);
535
502
  }
536
503
  @supports not (backdrop-filter: blur(1px)) {
@@ -545,6 +512,21 @@ button:hover > .ph-card {
545
512
  -webkit-overflow-scrolling: touch;
546
513
  }
547
514
 
515
+ /* Compact desktop controls keep their density; coarse pointers get a reliable
516
+ 44px hit area without app-specific overrides. */
517
+ @media (pointer: coarse) {
518
+ .ph-btn.ph-btn-raised:not(.ph-btn-lg) {
519
+ --ph-btn-control-size: 2.75rem;
520
+ }
521
+ .ph-btn:not(.ph-btn-raised) {
522
+ min-height: 2.75rem;
523
+ }
524
+ .ph-btn-circle,
525
+ .ph-btn-square {
526
+ min-width: 2.75rem;
527
+ }
528
+ }
529
+
548
530
  /* ── Ask AI answer content ───────────────────────────────────────────── */
549
531
  .suite-ai-blocknote .bn-editor {
550
532
  min-height: 0;
@@ -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={() => {