@xenide-io/the-old-ui-theme 0.6.6 → 0.6.8

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.
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import { Building, Check, NavArrowDown } from "iconoir-react";
5
+
6
+ import { cn } from "../lib/cn";
7
+ import type {
8
+ SuiteDropdownItemComponent,
9
+ SuiteDropdownMenuComponent,
10
+ } from "../lib/injected";
11
+ import { appSwitcherMenuItemClass } from "./app-switcher";
12
+
13
+ export interface SuiteWorkspaceEntry {
14
+ id: string;
15
+ name: string;
16
+ description?: string;
17
+ }
18
+
19
+ export interface SuiteWorkspaceGroup {
20
+ id: string;
21
+ label: string;
22
+ workspaces: SuiteWorkspaceEntry[];
23
+ }
24
+
25
+ /**
26
+ * Suite workspace switcher. Full-width bordered trigger; dropdown menu.
27
+ * Apps inject workspace rows (or labelled groups) and the theme dropdown.
28
+ */
29
+ export function SuiteWorkspaceSwitcher({
30
+ workspaces = [],
31
+ groups,
32
+ currentId,
33
+ onSelect,
34
+ name,
35
+ collapsed = false,
36
+ emptyLabel = "No workspaces yet.",
37
+ dropdownMenu: DropdownMenu,
38
+ dropdownItem: DropdownItem,
39
+ triggerId,
40
+ triggerDataTest,
41
+ footer,
42
+ }: {
43
+ workspaces?: SuiteWorkspaceEntry[];
44
+ groups?: SuiteWorkspaceGroup[];
45
+ currentId?: string | null;
46
+ onSelect: (workspace: SuiteWorkspaceEntry) => void;
47
+ name: string;
48
+ collapsed?: boolean;
49
+ emptyLabel?: string;
50
+ dropdownMenu: SuiteDropdownMenuComponent;
51
+ dropdownItem: SuiteDropdownItemComponent;
52
+ triggerId?: string;
53
+ triggerDataTest?: string;
54
+ footer?: ReactNode;
55
+ }) {
56
+ const [open, setOpen] = useState(false);
57
+ const sections =
58
+ groups?.filter((group) => group.workspaces.length > 0) ??
59
+ (workspaces.length > 0
60
+ ? [{ id: "workspaces", label: "Switch workspace", workspaces }]
61
+ : []);
62
+
63
+ return (
64
+ <DropdownMenu
65
+ aria-label="Switch workspace"
66
+ triggerId={triggerId ?? "workspace-switcher-trigger"}
67
+ triggerDataTest={triggerDataTest ?? "workspace-switcher-trigger"}
68
+ className={
69
+ collapsed
70
+ ? "w-11 shrink-0"
71
+ : "w-full min-w-0 max-w-full [&_.ph-dropdown-trigger]:w-full"
72
+ }
73
+ panelClassName="w-64 max-w-full"
74
+ align="start"
75
+ open={open}
76
+ onOpenChange={setOpen}
77
+ trigger={
78
+ <span
79
+ className={cn(
80
+ "flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-ph-border bg-ph-muted text-left transition-colors hover:bg-ph-muted",
81
+ collapsed ? "h-10 w-10 justify-center p-0" : "w-full px-3 py-2",
82
+ open && "border-ph-brand/40",
83
+ )}
84
+ data-test="workspace-switcher-trigger"
85
+ aria-expanded={open}
86
+ >
87
+ <Building className="h-4 w-4 shrink-0 text-ph-ink" aria-hidden />
88
+ {!collapsed ? (
89
+ <>
90
+ <span className="min-w-0 flex-1 truncate text-sm font-semibold text-ph-ink">
91
+ {name}
92
+ </span>
93
+ <NavArrowDown
94
+ className={cn(
95
+ "h-3.5 w-3.5 shrink-0 text-ph-mutedtext transition-transform",
96
+ open && "rotate-180",
97
+ )}
98
+ aria-hidden
99
+ />
100
+ </>
101
+ ) : null}
102
+ </span>
103
+ }
104
+ >
105
+ <div
106
+ id="workspace-switcher-menu"
107
+ className="p-1"
108
+ data-test="workspace-switcher-menu"
109
+ >
110
+ {sections.length === 0 ? (
111
+ <p className="px-3 py-2 text-sm text-ph-mutedtext">{emptyLabel}</p>
112
+ ) : (
113
+ sections.map((section) => (
114
+ <div key={section.id}>
115
+ <p className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-ph-mutedtext">
116
+ {section.label}
117
+ </p>
118
+ {section.workspaces.map((workspace) => (
119
+ <DropdownItem
120
+ key={workspace.id}
121
+ id={`workspace-option-${workspace.id}`}
122
+ role="option"
123
+ aria-selected={workspace.id === currentId}
124
+ data-test="workspace-option"
125
+ data-workspace-id={workspace.id}
126
+ onMouseDown={(event) => event.preventDefault()}
127
+ onClick={() => {
128
+ setOpen(false);
129
+ onSelect(workspace);
130
+ }}
131
+ className={appSwitcherMenuItemClass()}
132
+ >
133
+ <span className="flex min-w-0 w-full items-center gap-3">
134
+ <span className="min-w-0 flex-1">
135
+ <span className="block truncate text-sm font-medium text-ph-ink">
136
+ {workspace.name}
137
+ </span>
138
+ {workspace.description ? (
139
+ <span className="block truncate text-xs text-ph-mutedtext">
140
+ {workspace.description}
141
+ </span>
142
+ ) : null}
143
+ </span>
144
+ {workspace.id === currentId ? (
145
+ <Check
146
+ className="h-4 w-4 shrink-0 text-ph-brand"
147
+ aria-hidden="true"
148
+ />
149
+ ) : null}
150
+ </span>
151
+ </DropdownItem>
152
+ ))}
153
+ </div>
154
+ ))
155
+ )}
156
+ {footer ? (
157
+ <div className="mt-1 border-t border-ph-border pt-1">{footer}</div>
158
+ ) : null}
159
+ </div>
160
+ </DropdownMenu>
161
+ );
162
+ }
@@ -35,8 +35,10 @@ export {
35
35
  SUITE_APP_MAP,
36
36
  suiteAppBaseUrl,
37
37
  resolveSuiteNotificationHref,
38
+ classifySuiteHref,
38
39
  type SuiteAppDefinition,
39
40
  type SuiteAppSlug,
41
+ type SuiteHrefTarget,
40
42
  } from "./lib/apps";
41
43
  export { TodayCalibrating } from "./components/today-calibrating";
42
44
  export {
@@ -50,11 +52,17 @@ export {
50
52
  export {
51
53
  SuiteAiPanel,
52
54
  openSuiteAskAi,
55
+ consumePendingAskAiPrompt,
53
56
  SUITE_OPEN_ASK_AI_EVENT,
54
57
  type SuiteAiPreset,
55
58
  type SuiteAiChatMessage,
56
59
  type SuiteAskAiOpenDetail,
57
60
  } from "./components/ai-panel";
61
+ export {
62
+ SuiteWorkspaceSwitcher,
63
+ type SuiteWorkspaceEntry,
64
+ type SuiteWorkspaceGroup,
65
+ } from "./components/workspace-switcher";
58
66
  export {
59
67
  APP_ACCENTS,
60
68
  APP_GLYPHS,
@@ -85,6 +93,7 @@ export {
85
93
  SIDEBAR_MAX_WIDTH,
86
94
  SIDEBAR_MIN_EXPANDED_WIDTH,
87
95
  SIDEBAR_RAIL_WIDTH,
96
+ sidebarColumnWidth,
88
97
  useSidebarWidth,
89
98
  } from "./lib/use-sidebar-width";
90
99
  export { cn } from "./lib/cn";
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { classifySuiteHref } from "./apps";
4
+
5
+ const BASES = {
6
+ tides: "http://localhost:3001",
7
+ turtletime: "http://localhost:3000",
8
+ kraken: "http://localhost:3003",
9
+ };
10
+
11
+ describe("classifySuiteHref", () => {
12
+ it("maps a Tides task URL to the Tides app and path", () => {
13
+ const match = classifySuiteHref(
14
+ "http://localhost:3001/dashboard/projects/abc/tasks/def",
15
+ BASES,
16
+ "http://localhost:3000",
17
+ );
18
+ expect(match).toEqual({
19
+ slug: "tides",
20
+ path: "/dashboard/projects/abc/tasks/def",
21
+ sameApp: false,
22
+ });
23
+ });
24
+
25
+ it("marks a same-origin TurtleTime link as sameApp", () => {
26
+ const match = classifySuiteHref(
27
+ "http://localhost:3000/tracker",
28
+ BASES,
29
+ "http://localhost:3000",
30
+ );
31
+ expect(match?.sameApp).toBe(true);
32
+ expect(match?.path).toBe("/tracker");
33
+ });
34
+
35
+ it("ignores urls that are not a suite app", () => {
36
+ expect(
37
+ classifySuiteHref("https://example.com/x", BASES, "http://localhost:3000"),
38
+ ).toBeNull();
39
+ });
40
+ });
@@ -114,3 +114,40 @@ export function resolveSuiteNotificationHref(
114
114
  if (!slug) return path;
115
115
  return `${suiteAppBaseUrl(slug)}${path}`;
116
116
  }
117
+
118
+ export interface SuiteHrefTarget {
119
+ slug: SuiteAppSlug;
120
+ path: string;
121
+ sameApp: boolean;
122
+ }
123
+
124
+ /** Match a URL to a suite app origin so Ask AI links can switch apps. */
125
+ export function classifySuiteHref(
126
+ href: string,
127
+ bases: Partial<Record<SuiteAppSlug, string>>,
128
+ currentOrigin = '',
129
+ ): SuiteHrefTarget | null {
130
+ let url: URL;
131
+ try {
132
+ url = new URL(href, currentOrigin || 'http://local.invalid');
133
+ } catch {
134
+ return null;
135
+ }
136
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
137
+ for (const slug of Object.keys(bases) as SuiteAppSlug[]) {
138
+ const base = bases[slug];
139
+ if (!base) continue;
140
+ try {
141
+ if (new URL(base).origin !== url.origin) continue;
142
+ } catch {
143
+ continue;
144
+ }
145
+ const path = `${url.pathname}${url.search}${url.hash}` || '/';
146
+ return {
147
+ slug,
148
+ path,
149
+ sameApp: Boolean(currentOrigin && url.origin === currentOrigin),
150
+ };
151
+ }
152
+ return null;
153
+ }
@@ -10,9 +10,19 @@ import {
10
10
  export const SIDEBAR_RAIL_WIDTH = 56;
11
11
  export const SIDEBAR_COLLAPSE_THRESHOLD = 80;
12
12
  export const SIDEBAR_MIN_EXPANDED_WIDTH = 180;
13
- export const SIDEBAR_MAX_WIDTH = 320;
13
+ export const SIDEBAR_MAX_WIDTH = 640;
14
14
  export const SIDEBAR_DEFAULT_WIDTH = 320;
15
15
 
16
+ /** Desktop column width: icon rail when collapsed, otherwise the persisted width. */
17
+ export function sidebarColumnWidth(
18
+ width: number,
19
+ collapsed: boolean,
20
+ _chatOpen = false,
21
+ ): number {
22
+ if (collapsed) return SIDEBAR_RAIL_WIDTH;
23
+ return width;
24
+ }
25
+
16
26
  function clampWidth(width: number): number {
17
27
  return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_RAIL_WIDTH, width));
18
28
  }