@xenide-io/the-old-ui-theme 0.6.14 → 0.7.1

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/suite.mjs CHANGED
@@ -1,8 +1,11 @@
1
1
  import {
2
+ Button,
2
3
  DropdownItem,
3
4
  DropdownMenu,
5
+ Input,
6
+ Modal,
4
7
  Tooltip
5
- } from "./chunk-E5ZBQYEX.mjs";
8
+ } from "./chunk-YB4TBWHV.mjs";
6
9
  import {
7
10
  SuiteThemeProvider,
8
11
  useSuiteTheme
@@ -896,7 +899,10 @@ function TodayEmptyState({
896
899
  // src/suite/components/app-switcher.tsx
897
900
  import Image from "next/image";
898
901
  import { useState } from "react";
899
- import { Check, NavArrowDown as ChevronDown } from "iconoir-react";
902
+ import {
903
+ NavArrowDown as ChevronDown,
904
+ OpenNewWindow
905
+ } from "iconoir-react";
900
906
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
901
907
  function appSwitcherTriggerClass(open, collapsed = false) {
902
908
  return cn(
@@ -1013,43 +1019,53 @@ function AppSwitcher({
1013
1019
  ),
1014
1020
  children: /* @__PURE__ */ jsxs6("div", { id: "app-switcher-menu", className: "p-1", "data-test": "app-switcher-menu", children: [
1015
1021
  /* @__PURE__ */ jsx6("p", { className: "px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-ph-mutedtext", children: "Switch application" }),
1016
- apps.map((app) => /* @__PURE__ */ jsx6(
1017
- DropdownItem2,
1018
- {
1019
- id: `switch-app-${app.slug}`,
1020
- "data-test": `switch-app-${app.slug}`,
1021
- onMouseDown: (e) => e.preventDefault(),
1022
- onClick: () => {
1023
- setOpen(false);
1024
- onSelect(app);
1025
- },
1026
- className: appSwitcherMenuItemClass(),
1027
- children: /* @__PURE__ */ jsxs6("span", { className: "flex min-w-0 items-center gap-3", children: [
1028
- /* @__PURE__ */ jsx6(
1029
- Image,
1030
- {
1031
- src: app.icon,
1032
- alt: "",
1033
- width: 28,
1034
- height: 28,
1035
- className: "h-7 w-7 shrink-0 rounded-md object-contain"
1036
- }
1037
- ),
1038
- /* @__PURE__ */ jsxs6("span", { className: "min-w-0 flex-1", children: [
1039
- /* @__PURE__ */ jsx6("span", { className: "block truncate text-sm font-medium text-ph-ink", children: app.name }),
1040
- /* @__PURE__ */ jsx6("span", { className: "block truncate text-xs text-ph-mutedtext", children: app.description })
1041
- ] }),
1042
- app.slug === currentApp ? /* @__PURE__ */ jsx6(
1043
- Check,
1044
- {
1045
- className: "h-4 w-4 shrink-0 text-ph-brand",
1046
- "aria-hidden": "true"
1047
- }
1048
- ) : null
1049
- ] })
1050
- },
1051
- app.slug
1052
- ))
1022
+ apps.filter((app) => app.slug !== currentApp).map((app) => /* @__PURE__ */ jsxs6("div", { className: "relative", children: [
1023
+ /* @__PURE__ */ jsx6(
1024
+ DropdownItem2,
1025
+ {
1026
+ id: `switch-app-${app.slug}`,
1027
+ "data-test": `switch-app-${app.slug}`,
1028
+ onMouseDown: (e) => e.preventDefault(),
1029
+ onClick: () => {
1030
+ setOpen(false);
1031
+ onSelect(app);
1032
+ },
1033
+ className: cn(appSwitcherMenuItemClass(), "pr-11"),
1034
+ children: /* @__PURE__ */ jsxs6("span", { className: "flex min-w-0 items-center gap-3", children: [
1035
+ /* @__PURE__ */ jsx6(
1036
+ Image,
1037
+ {
1038
+ src: app.icon,
1039
+ alt: "",
1040
+ width: 28,
1041
+ height: 28,
1042
+ className: "h-7 w-7 shrink-0 rounded-md object-contain"
1043
+ }
1044
+ ),
1045
+ /* @__PURE__ */ jsxs6("span", { className: "min-w-0 flex-1", children: [
1046
+ /* @__PURE__ */ jsx6("span", { className: "block truncate text-sm font-medium text-ph-ink", children: app.name }),
1047
+ /* @__PURE__ */ jsx6("span", { className: "block truncate text-xs text-ph-mutedtext", children: app.description })
1048
+ ] })
1049
+ ] })
1050
+ }
1051
+ ),
1052
+ /* @__PURE__ */ jsx6(
1053
+ "button",
1054
+ {
1055
+ type: "button",
1056
+ id: `switch-app-${app.slug}-new-tab`,
1057
+ "data-test": `switch-app-${app.slug}-new-tab`,
1058
+ "aria-label": `Open ${app.name} in a new tab`,
1059
+ onMouseDown: (e) => e.preventDefault(),
1060
+ onClick: () => {
1061
+ setOpen(false);
1062
+ onSelect(app, { newTab: true });
1063
+ },
1064
+ className: "absolute right-1.5 top-1/2 z-[1] flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ph-brand/35",
1065
+ children: /* @__PURE__ */ jsx6(OpenNewWindow, { className: "h-4 w-4", "aria-hidden": "true" })
1066
+ }
1067
+ )
1068
+ ] }, app.slug))
1053
1069
  ] })
1054
1070
  }
1055
1071
  );
@@ -1286,10 +1302,10 @@ function SuiteNotificationBell({
1286
1302
  panelClassName: "w-80 overflow-hidden p-0",
1287
1303
  open,
1288
1304
  onOpenChange,
1289
- trigger: /* @__PURE__ */ jsxs7("span", { className: "relative inline-flex h-11 w-11 items-center justify-center overflow-visible rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink", children: [
1305
+ trigger: /* @__PURE__ */ jsx7("span", { className: "relative inline-flex h-11 w-11 items-center justify-center overflow-visible rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink", children: /* @__PURE__ */ jsxs7("span", { className: "relative inline-flex h-5 w-5", children: [
1290
1306
  /* @__PURE__ */ jsx7(Bell, { className: "h-5 w-5", strokeWidth: 1.75, "aria-hidden": true }),
1291
- unread > 0 ? /* @__PURE__ */ jsx7("span", { className: "pointer-events-none absolute right-0 top-0 z-[1] flex h-4 min-w-4 translate-x-1/2 items-center justify-center rounded-full bg-ph-brand px-1 text-[9px] font-semibold leading-none text-white ring-2 ring-ph-surface", children: unread > 9 ? "9+" : unread }) : null
1292
- ] }),
1307
+ unread > 0 ? /* @__PURE__ */ jsx7("span", { className: "pointer-events-none absolute -right-2 -top-2 z-[1] flex h-4 min-w-4 items-center justify-center rounded-full bg-ph-brand px-1 text-[9px] font-semibold leading-none text-white ring-2 ring-ph-surface", children: unread > 9 ? "9+" : unread }) : null
1308
+ ] }) }),
1293
1309
  children: [
1294
1310
  /* @__PURE__ */ jsxs7("div", { className: "flex items-center justify-between border-b border-ph-border px-3 py-2", children: [
1295
1311
  /* @__PURE__ */ jsx7("span", { className: "text-sm font-semibold text-ph-ink", children: "Notifications" }),
@@ -2727,7 +2743,7 @@ function SuiteAiPanel({
2727
2743
 
2728
2744
  // src/suite/components/workspace-switcher.tsx
2729
2745
  import { useState as useState8 } from "react";
2730
- import { Building, Check as Check2, NavArrowDown } from "iconoir-react";
2746
+ import { Building, Check, NavArrowDown } from "iconoir-react";
2731
2747
  import { Fragment as Fragment6, jsx as jsx18, jsxs as jsxs16 } from "react/jsx-runtime";
2732
2748
  function SuiteWorkspaceSwitcher({
2733
2749
  workspaces = [],
@@ -2814,7 +2830,7 @@ function SuiteWorkspaceSwitcher({
2814
2830
  workspace.description ? /* @__PURE__ */ jsx18("span", { className: "block truncate text-xs text-ph-mutedtext", children: workspace.description }) : null
2815
2831
  ] }),
2816
2832
  workspace.id === currentId ? /* @__PURE__ */ jsx18(
2817
- Check2,
2833
+ Check,
2818
2834
  {
2819
2835
  className: "h-4 w-4 shrink-0 text-ph-brand",
2820
2836
  "aria-hidden": "true"
@@ -3612,6 +3628,81 @@ function SuiteAppLayout({
3612
3628
  }
3613
3629
  );
3614
3630
  }
3631
+
3632
+ // src/suite/components/suite-integration-picker.tsx
3633
+ import { useMemo, useState as useState10 } from "react";
3634
+ import { NavArrowDown as NavArrowDown2, NavArrowUp, Server as Plug, Xmark } from "iconoir-react";
3635
+ import { jsx as jsx24, jsxs as jsxs21 } from "react/jsx-runtime";
3636
+ var DESCRIPTION_LIMIT = 90;
3637
+ function SuiteIntegrationPicker({
3638
+ open,
3639
+ items,
3640
+ connectedItems = [],
3641
+ loading = false,
3642
+ onClose,
3643
+ onConnect,
3644
+ onDisconnect,
3645
+ title = "Integrate an app",
3646
+ description = "Connect an app to this workspace securely.",
3647
+ dataTest = "suite-integration-picker"
3648
+ }) {
3649
+ const [query, setQuery] = useState10("");
3650
+ const [category, setCategory] = useState10("all");
3651
+ const [expanded, setExpanded] = useState10(null);
3652
+ const categories = useMemo(
3653
+ () => Array.from(new Set(items.flatMap((item) => {
3654
+ var _a;
3655
+ return (_a = item.categories) != null ? _a : [];
3656
+ }))).sort(),
3657
+ [items]
3658
+ );
3659
+ const filtered = items.filter((item) => {
3660
+ var _a;
3661
+ const haystack = `${item.label} ${item.description}`.toLowerCase();
3662
+ return haystack.includes(query.trim().toLowerCase()) && (category === "all" || ((_a = item.categories) != null ? _a : []).includes(category));
3663
+ });
3664
+ return /* @__PURE__ */ jsx24(Modal, { open, onClose, title, description, size: "xl", dataTest, children: /* @__PURE__ */ jsxs21("div", { className: "space-y-4", children: [
3665
+ /* @__PURE__ */ jsx24(Input, { value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Search apps...", "aria-label": "Search apps" }),
3666
+ /* @__PURE__ */ jsx24("div", { className: "flex flex-wrap gap-2", role: "group", "aria-label": "Integration categories", children: ["all", ...categories].map((value) => /* @__PURE__ */ jsx24("button", { type: "button", onClick: () => setCategory(value), className: category === value ? "rounded-full bg-ph-brand px-3 py-1.5 text-xs font-semibold text-white" : "rounded-full border border-ph-border px-3 py-1.5 text-xs text-ph-subtle hover:bg-ph-muted", children: value === "all" ? "All apps" : value }, value)) }),
3667
+ loading ? /* @__PURE__ */ jsx24("p", { className: "text-sm text-ph-subtle", children: "Loading apps..." }) : /* @__PURE__ */ jsx24("div", { className: "grid max-h-[60vh] gap-3 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3", children: filtered.map((item) => {
3668
+ var _a, _b;
3669
+ const isExpanded = expanded === item.id;
3670
+ const longDescription = item.description.length > DESCRIPTION_LIMIT;
3671
+ const text = isExpanded || !longDescription ? item.description : `${item.description.slice(0, DESCRIPTION_LIMIT).trimEnd()}...`;
3672
+ return /* @__PURE__ */ jsxs21("div", { className: "flex min-h-36 flex-col justify-between rounded-xl border border-ph-border bg-ph-surface p-4", children: [
3673
+ /* @__PURE__ */ jsx24("button", { type: "button", className: "text-left", onClick: () => longDescription && setExpanded(isExpanded ? null : item.id), "aria-expanded": isExpanded, children: /* @__PURE__ */ jsxs21("span", { className: "flex items-start gap-3", children: [
3674
+ /* @__PURE__ */ jsx24("span", { className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ph-muted text-ph-ink", children: (_a = item.icon) != null ? _a : /* @__PURE__ */ jsx24(Plug, { className: "h-5 w-5", "aria-hidden": true }) }),
3675
+ /* @__PURE__ */ jsxs21("span", { className: "min-w-0", children: [
3676
+ /* @__PURE__ */ jsx24("span", { className: "block font-medium text-ph-ink", children: item.label }),
3677
+ /* @__PURE__ */ jsx24("span", { className: "mt-1 block text-xs text-ph-subtle", children: text }),
3678
+ longDescription ? /* @__PURE__ */ jsxs21("span", { className: "mt-1 block text-xs font-medium text-ph-brand", children: [
3679
+ isExpanded ? "See less" : "See more",
3680
+ " ",
3681
+ isExpanded ? /* @__PURE__ */ jsx24(NavArrowUp, { className: "inline h-3 w-3" }) : /* @__PURE__ */ jsx24(NavArrowDown2, { className: "inline h-3 w-3" })
3682
+ ] }) : null
3683
+ ] })
3684
+ ] }) }),
3685
+ /* @__PURE__ */ jsx24(Button, { type: "button", variant: "primary", className: "mt-3 self-start", disabled: Boolean(item.unavailableReason) || item.connecting, onClick: () => onConnect(item), children: /* @__PURE__ */ jsxs21("span", { className: "flex items-center gap-1.5", children: [
3686
+ /* @__PURE__ */ jsx24(Plug, { className: "h-3.5 w-3.5", "aria-hidden": true }),
3687
+ item.connecting ? "Connecting..." : (_b = item.unavailableReason) != null ? _b : "Connect"
3688
+ ] }) })
3689
+ ] }, item.id);
3690
+ }) }),
3691
+ connectedItems.length > 0 && onDisconnect ? /* @__PURE__ */ jsxs21("div", { className: "space-y-2 border-t border-ph-border pt-4", children: [
3692
+ /* @__PURE__ */ jsx24("h3", { className: "font-medium text-ph-ink", children: "Connected apps" }),
3693
+ connectedItems.map((item) => /* @__PURE__ */ jsxs21("div", { className: "flex items-center justify-between gap-3 rounded-lg border border-ph-border bg-ph-muted p-3", children: [
3694
+ /* @__PURE__ */ jsxs21("span", { className: "flex min-w-0 items-center gap-2 text-sm text-ph-ink", children: [
3695
+ item.icon,
3696
+ /* @__PURE__ */ jsx24("span", { className: "truncate", children: item.label })
3697
+ ] }),
3698
+ /* @__PURE__ */ jsx24(Button, { type: "button", variant: "danger", onClick: () => onDisconnect(item), children: /* @__PURE__ */ jsxs21("span", { className: "flex items-center gap-1.5", children: [
3699
+ /* @__PURE__ */ jsx24(Xmark, { className: "h-4 w-4", "aria-hidden": true }),
3700
+ "Disconnect"
3701
+ ] }) })
3702
+ ] }, item.id))
3703
+ ] }) : null
3704
+ ] }) });
3705
+ }
3615
3706
  export {
3616
3707
  APP_ACCENTS,
3617
3708
  APP_GLYPHS,
@@ -3647,6 +3738,7 @@ export {
3647
3738
  SuiteBreadcrumbs,
3648
3739
  SuiteEmptyState,
3649
3740
  SuiteIcon,
3741
+ SuiteIntegrationPicker,
3650
3742
  SuiteMobileDrawer,
3651
3743
  SuiteMobileHeader,
3652
3744
  SuiteMotionProvider,
package/dist/ui.js CHANGED
@@ -399,7 +399,9 @@ var IconPlayCircle = createIconBase({
399
399
  )
400
400
  });
401
401
  var IconGridView = createIconBase({
402
- paths: fill("M3 3v8h8V3H3zm6 6H5V5h4v4zm-6 4v8h8v-8H3zm6 6H5v-4h4v4zm4-16v8h8V3h-8zm6 6h-4V5h4v4zm-6 4v8h8v-8h-8zm6 6h-4v-4h4v4z")
402
+ paths: fill(
403
+ "M3,3v8h8V3H3z M9,9H5V5h4V9z M3,13v8h8v-8H3z M9,19H5v-4h4V19z M13,3v8h8V3H13z M19,9h-4V5h4V9z M13,13v8h8v-8H13z M19,19h-4v-4h4V19z"
404
+ )
403
405
  });
404
406
  var IconListView = createIconBase({
405
407
  paths: fill(
package/dist/ui.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  AvatarGroup,
10
10
  Badge,
11
11
  Button,
12
+ ButtonChrome,
12
13
  Calendar,
13
14
  Caption,
14
15
  Card,
@@ -20,6 +21,11 @@ import {
20
21
  ComponentDocs,
21
22
  Display,
22
23
  Dot,
24
+ DropdownButton,
25
+ DropdownItem,
26
+ DropdownMenu,
27
+ DropdownRadioGroup,
28
+ DropdownRadioItem,
23
29
  EmptyState,
24
30
  FileUpload,
25
31
  FilterBar,
@@ -32,6 +38,8 @@ import {
32
38
  H3,
33
39
  H4,
34
40
  H5,
41
+ IconBase,
42
+ IconByName,
35
43
  Input,
36
44
  Kbd,
37
45
  Label,
@@ -64,20 +72,10 @@ import {
64
72
  Textarea,
65
73
  ThemeDomSync,
66
74
  ThemeSwitcher,
67
- Toggle
68
- } from "./chunk-VOCHIR3W.mjs";
69
- import {
70
- ButtonChrome,
71
- DropdownButton,
72
- DropdownItem,
73
- DropdownMenu,
74
- DropdownRadioGroup,
75
- DropdownRadioItem,
76
- IconBase,
77
- IconByName,
75
+ Toggle,
78
76
  Tooltip,
79
77
  TooltipProvider
80
- } from "./chunk-E5ZBQYEX.mjs";
78
+ } from "./chunk-YB4TBWHV.mjs";
81
79
  import "./chunk-FWCSY2DS.mjs";
82
80
  export {
83
81
  Accordion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenide-io/the-old-ui-theme",
3
- "version": "0.6.14",
3
+ "version": "0.7.1",
4
4
  "description": "Props-driven React components and theme tokens inspired by classic interface design \u2014 optimized for Next.js apps and internal tools.",
5
5
  "author": "Xenide",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@ const CACHE = resolve("scripts/.posthog-icons.tsx");
18
18
  function extractPhBlock(source, name) {
19
19
  const start = source.indexOf(`export function ${name}(`);
20
20
  if (start < 0) return null;
21
- const next = source.indexOf("\nexport function Icon", start + 10);
21
+ const next = source.indexOf("\nexport function ", start + 10);
22
22
  return source.slice(start, next > 0 ? next : start + 4000);
23
23
  }
24
24
 
@@ -34,9 +34,8 @@ function extractOurBlock(source, name) {
34
34
  function normPaths(block) {
35
35
  if (!block) return "";
36
36
  const fills = [...block.matchAll(/fill\(\s*\n?\s*"([^"]+)"/g)].map((m) => m[1]);
37
- const ds = [...block.matchAll(/<path d="([^"]+)"/g)].map((m) => m[1]);
38
- const attrs = [...block.matchAll(/\sd="([^"]+)"/g)].map((m) => m[1]);
39
- return [...fills, ...ds, ...attrs].join("|").replace(/\s/g, "");
37
+ const paths = [...block.matchAll(/\sd="([^"]+)"/g)].map((m) => m[1]);
38
+ return [...fills, ...paths].join("|").replace(/\s/g, "");
40
39
  }
41
40
 
42
41
  async function main() {
@@ -98,7 +98,9 @@ export const IconPlayCircle = createIconBase({
98
98
  });
99
99
 
100
100
  export const IconGridView = createIconBase({
101
- paths: fill("M3 3v8h8V3H3zm6 6H5V5h4v4zm-6 4v8h8v-8H3zm6 6H5v-4h4v4zm4-16v8h8V3h-8zm6 6h-4V5h4v4zm-6 4v8h8v-8h-8zm6 6h-4v-4h4v4z"),
101
+ paths: fill(
102
+ "M3,3v8h8V3H3z M9,9H5V5h4V9z M3,13v8h8v-8H3z M9,19H5v-4h4V19z M13,3v8h8V3H13z M19,9h-4V5h4V9z M13,13v8h8v-8H13z M19,19h-4v-4h4V19z",
103
+ ),
102
104
  });
103
105
 
104
106
  export const IconListView = createIconBase({
@@ -0,0 +1,96 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ vi.mock("next/image", () => ({
7
+ default: (props: ComponentPropsWithoutRef<"img">) => <img {...props} />,
8
+ }));
9
+
10
+ import { AppSwitcher, type SuiteAppEntry } from "./app-switcher";
11
+
12
+ afterEach(() => {
13
+ cleanup();
14
+ });
15
+
16
+ const APPS: SuiteAppEntry[] = [
17
+ { slug: "tides", name: "Tides", description: "Projects", icon: "/tides.svg" },
18
+ {
19
+ slug: "turtletime",
20
+ name: "TurtleTime",
21
+ description: "Time",
22
+ icon: "/time.svg",
23
+ },
24
+ {
25
+ slug: "kraken",
26
+ name: "Kraken",
27
+ description: "Documents",
28
+ icon: "/kraken.svg",
29
+ },
30
+ {
31
+ slug: "nakama",
32
+ name: "Nakama",
33
+ description: "AI teammates",
34
+ icon: "/nakama.svg",
35
+ },
36
+ ];
37
+
38
+ function TestDropdownMenu({
39
+ trigger,
40
+ children,
41
+ "aria-label": ariaLabel,
42
+ }: {
43
+ trigger: ReactNode;
44
+ children: ReactNode;
45
+ "aria-label": string;
46
+ }) {
47
+ return (
48
+ <div aria-label={ariaLabel}>
49
+ {trigger}
50
+ {children}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ function TestDropdownItem({
56
+ children,
57
+ ...props
58
+ }: ComponentPropsWithoutRef<"button">) {
59
+ return <button {...props}>{children}</button>;
60
+ }
61
+
62
+ describe("AppSwitcher", () => {
63
+ it("keeps the current app out of the simple application list", async () => {
64
+ const user = userEvent.setup();
65
+ const onSelect = vi.fn();
66
+
67
+ render(
68
+ <AppSwitcher
69
+ apps={APPS}
70
+ currentApp="tides"
71
+ onSelect={onSelect}
72
+ mark={<span />}
73
+ title={<span>Tides</span>}
74
+ dropdownMenu={TestDropdownMenu}
75
+ dropdownItem={TestDropdownItem}
76
+ />,
77
+ );
78
+
79
+ const menu = document.querySelector("[data-test='app-switcher-menu']");
80
+ expect(menu).not.toHaveClass("grid-cols-2");
81
+ expect(menu).toHaveTextContent("Switch application");
82
+ expect(
83
+ document.querySelector("[data-test='switch-app-tides']"),
84
+ ).not.toBeInTheDocument();
85
+ const newTabButton = screen.getByRole("button", {
86
+ name: "Open Kraken in a new tab",
87
+ });
88
+ expect(newTabButton).toHaveClass("absolute", "h-8", "w-8");
89
+ expect(
90
+ screen.queryByRole("button", { name: "Open Tides in a new tab" }),
91
+ ).not.toBeInTheDocument();
92
+
93
+ await user.click(newTabButton);
94
+ expect(onSelect).toHaveBeenCalledWith(APPS[2], { newTab: true });
95
+ });
96
+ });
@@ -2,7 +2,10 @@
2
2
 
3
3
  import Image from "next/image";
4
4
  import { useState, type ReactNode } from "react";
5
- import { Check, NavArrowDown as ChevronDown } from "iconoir-react";
5
+ import {
6
+ NavArrowDown as ChevronDown,
7
+ OpenNewWindow,
8
+ } from "iconoir-react";
6
9
 
7
10
  import { cn } from "../lib/cn";
8
11
  import type {
@@ -24,7 +27,6 @@ export function appSwitcherMarkClass() {
24
27
  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
28
  }
26
29
 
27
- /** No sticky Radix highlight while waiting for cross-app redirect. */
28
30
  export function appSwitcherMenuItemClass() {
29
31
  return "rounded-lg px-2 py-2 data-[highlighted]:bg-transparent hover:bg-ph-muted/80";
30
32
  }
@@ -101,6 +103,11 @@ export interface SuiteAppEntry {
101
103
  icon: string;
102
104
  }
103
105
 
106
+ export interface SuiteAppSelectOptions {
107
+ /** Open the handoff in a separate browser tab when requested by a caller. */
108
+ newTab?: boolean;
109
+ }
110
+
104
111
  /**
105
112
  * Suite app switcher dropdown. Apps inject their brand mark/title, the
106
113
  * visible app list, the cross-app navigation handler, and the theme
@@ -120,7 +127,7 @@ export function AppSwitcher({
120
127
  }: {
121
128
  apps: SuiteAppEntry[];
122
129
  currentApp: string;
123
- onSelect: (app: SuiteAppEntry) => void;
130
+ onSelect: (app: SuiteAppEntry, options?: SuiteAppSelectOptions) => void;
124
131
  mark: ReactNode;
125
132
  title: ReactNode;
126
133
  collapsed?: boolean;
@@ -160,43 +167,54 @@ export function AppSwitcher({
160
167
  <p className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-ph-mutedtext">
161
168
  Switch application
162
169
  </p>
163
- {apps.map((app) => (
164
- <DropdownItem
165
- key={app.slug}
166
- id={`switch-app-${app.slug}`}
167
- data-test={`switch-app-${app.slug}`}
168
- onMouseDown={(e) => e.preventDefault()}
169
- onClick={() => {
170
- setOpen(false);
171
- onSelect(app);
172
- }}
173
- className={appSwitcherMenuItemClass()}
174
- >
175
- <span className="flex min-w-0 items-center gap-3">
176
- <Image
177
- src={app.icon}
178
- alt=""
179
- width={28}
180
- height={28}
181
- className="h-7 w-7 shrink-0 rounded-md object-contain"
182
- />
183
- <span className="min-w-0 flex-1">
184
- <span className="block truncate text-sm font-medium text-ph-ink">
185
- {app.name}
186
- </span>
187
- <span className="block truncate text-xs text-ph-mutedtext">
188
- {app.description}
170
+ {apps
171
+ .filter((app) => app.slug !== currentApp)
172
+ .map((app) => (
173
+ <div key={app.slug} className="relative">
174
+ <DropdownItem
175
+ id={`switch-app-${app.slug}`}
176
+ data-test={`switch-app-${app.slug}`}
177
+ onMouseDown={(e) => e.preventDefault()}
178
+ onClick={() => {
179
+ setOpen(false);
180
+ onSelect(app);
181
+ }}
182
+ className={cn(appSwitcherMenuItemClass(), "pr-11")}
183
+ >
184
+ <span className="flex min-w-0 items-center gap-3">
185
+ <Image
186
+ src={app.icon}
187
+ alt=""
188
+ width={28}
189
+ height={28}
190
+ className="h-7 w-7 shrink-0 rounded-md object-contain"
191
+ />
192
+ <span className="min-w-0 flex-1">
193
+ <span className="block truncate text-sm font-medium text-ph-ink">
194
+ {app.name}
195
+ </span>
196
+ <span className="block truncate text-xs text-ph-mutedtext">
197
+ {app.description}
198
+ </span>
199
+ </span>
189
200
  </span>
190
- </span>
191
- {app.slug === currentApp ? (
192
- <Check
193
- className="h-4 w-4 shrink-0 text-ph-brand"
194
- aria-hidden="true"
195
- />
196
- ) : null}
197
- </span>
198
- </DropdownItem>
199
- ))}
201
+ </DropdownItem>
202
+ <button
203
+ type="button"
204
+ id={`switch-app-${app.slug}-new-tab`}
205
+ data-test={`switch-app-${app.slug}-new-tab`}
206
+ aria-label={`Open ${app.name} in a new tab`}
207
+ onMouseDown={(e) => e.preventDefault()}
208
+ onClick={() => {
209
+ setOpen(false);
210
+ onSelect(app, { newTab: true });
211
+ }}
212
+ className="absolute right-1.5 top-1/2 z-[1] flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ph-brand/35"
213
+ >
214
+ <OpenNewWindow className="h-4 w-4" aria-hidden="true" />
215
+ </button>
216
+ </div>
217
+ ))}
200
218
  </div>
201
219
  </DropdownMenu>
202
220
  );
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState, type ReactNode } from "react";
4
+ import { NavArrowDown, NavArrowUp, Server as Plug, Xmark } from "iconoir-react";
5
+ import { Button, Input, Modal } from "@/components/ui";
6
+
7
+ export interface SuiteIntegrationPickerItem {
8
+ id: string;
9
+ label: string;
10
+ description: string;
11
+ icon?: ReactNode;
12
+ categories?: string[];
13
+ connecting?: boolean;
14
+ unavailableReason?: string;
15
+ }
16
+
17
+ export interface SuiteIntegrationPickerProps {
18
+ open: boolean;
19
+ items: SuiteIntegrationPickerItem[];
20
+ connectedItems?: SuiteIntegrationPickerItem[];
21
+ loading?: boolean;
22
+ onClose: () => void;
23
+ onConnect: (item: SuiteIntegrationPickerItem) => void;
24
+ onDisconnect?: (item: SuiteIntegrationPickerItem) => void;
25
+ title?: string;
26
+ description?: string;
27
+ dataTest?: string;
28
+ }
29
+
30
+ const DESCRIPTION_LIMIT = 90;
31
+
32
+ export function SuiteIntegrationPicker({
33
+ open,
34
+ items,
35
+ connectedItems = [],
36
+ loading = false,
37
+ onClose,
38
+ onConnect,
39
+ onDisconnect,
40
+ title = "Integrate an app",
41
+ description = "Connect an app to this workspace securely.",
42
+ dataTest = "suite-integration-picker",
43
+ }: SuiteIntegrationPickerProps) {
44
+ const [query, setQuery] = useState("");
45
+ const [category, setCategory] = useState("all");
46
+ const [expanded, setExpanded] = useState<string | null>(null);
47
+ const categories = useMemo(
48
+ () => Array.from(new Set(items.flatMap((item) => item.categories ?? []))).sort(),
49
+ [items],
50
+ );
51
+ const filtered = items.filter((item) => {
52
+ const haystack = `${item.label} ${item.description}`.toLowerCase();
53
+ return haystack.includes(query.trim().toLowerCase()) && (category === "all" || (item.categories ?? []).includes(category));
54
+ });
55
+
56
+ return (
57
+ <Modal open={open} onClose={onClose} title={title} description={description} size="xl" dataTest={dataTest}>
58
+ <div className="space-y-4">
59
+ <Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search apps..." aria-label="Search apps" />
60
+ <div className="flex flex-wrap gap-2" role="group" aria-label="Integration categories">
61
+ {["all", ...categories].map((value) => <button key={value} type="button" onClick={() => setCategory(value)} className={category === value ? "rounded-full bg-ph-brand px-3 py-1.5 text-xs font-semibold text-white" : "rounded-full border border-ph-border px-3 py-1.5 text-xs text-ph-subtle hover:bg-ph-muted"}>{value === "all" ? "All apps" : value}</button>)}
62
+ </div>
63
+ {loading ? <p className="text-sm text-ph-subtle">Loading apps...</p> : <div className="grid max-h-[60vh] gap-3 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3">{filtered.map((item) => { const isExpanded = expanded === item.id; const longDescription = item.description.length > DESCRIPTION_LIMIT; const text = isExpanded || !longDescription ? item.description : `${item.description.slice(0, DESCRIPTION_LIMIT).trimEnd()}...`; return <div key={item.id} className="flex min-h-36 flex-col justify-between rounded-xl border border-ph-border bg-ph-surface p-4"><button type="button" className="text-left" onClick={() => longDescription && setExpanded(isExpanded ? null : item.id)} aria-expanded={isExpanded}><span className="flex items-start gap-3"><span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ph-muted text-ph-ink">{item.icon ?? <Plug className="h-5 w-5" aria-hidden />}</span><span className="min-w-0"><span className="block font-medium text-ph-ink">{item.label}</span><span className="mt-1 block text-xs text-ph-subtle">{text}</span>{longDescription ? <span className="mt-1 block text-xs font-medium text-ph-brand">{isExpanded ? "See less" : "See more"} {isExpanded ? <NavArrowUp className="inline h-3 w-3" /> : <NavArrowDown className="inline h-3 w-3" />}</span> : null}</span></span></button><Button type="button" variant="primary" className="mt-3 self-start" disabled={Boolean(item.unavailableReason) || item.connecting} onClick={() => onConnect(item)}><span className="flex items-center gap-1.5"><Plug className="h-3.5 w-3.5" aria-hidden />{item.connecting ? "Connecting..." : item.unavailableReason ?? "Connect"}</span></Button></div>; })}</div>}
64
+ {connectedItems.length > 0 && onDisconnect ? <div className="space-y-2 border-t border-ph-border pt-4"><h3 className="font-medium text-ph-ink">Connected apps</h3>{connectedItems.map((item) => <div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border border-ph-border bg-ph-muted p-3"><span className="flex min-w-0 items-center gap-2 text-sm text-ph-ink">{item.icon}<span className="truncate">{item.label}</span></span><Button type="button" variant="danger" onClick={() => onDisconnect(item)}><span className="flex items-center gap-1.5"><Xmark className="h-4 w-4" aria-hidden />Disconnect</span></Button></div>)}</div> : null}
65
+ </div>
66
+ </Modal>
67
+ );
68
+ }