@xenide-io/the-old-ui-theme 0.7.0 → 0.9.0

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 (109) hide show
  1. package/README.md +9 -10
  2. package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
  3. package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
  4. package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
  5. package/dist/chunk-2EO5VCXP.mjs +3912 -0
  6. package/dist/chunk-APKRZQRO.mjs +251 -0
  7. package/dist/chunk-BS7BZI5W.mjs +19 -0
  8. package/dist/chunk-IULORI3A.mjs +8 -0
  9. package/dist/index.d.mts +3 -3
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +997 -576
  12. package/dist/index.mjs +49 -45
  13. package/dist/suite.d.mts +157 -55
  14. package/dist/suite.d.ts +157 -55
  15. package/dist/suite.js +4249 -801
  16. package/dist/suite.mjs +2578 -594
  17. package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
  18. package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
  19. package/dist/theme-boot.d.mts +2 -0
  20. package/dist/theme-boot.d.ts +2 -0
  21. package/dist/theme-boot.js +44 -0
  22. package/dist/theme-boot.mjs +8 -0
  23. package/dist/ui.d.mts +5 -29
  24. package/dist/ui.d.ts +5 -29
  25. package/dist/ui.js +647 -352
  26. package/dist/ui.mjs +10 -12
  27. package/docs/README.md +34 -0
  28. package/docs/ai-quick-reference.md +56 -0
  29. package/docs/component-library-improvement-plan.md +751 -0
  30. package/docs/component-reference.md +81 -0
  31. package/docs/components.md +48 -0
  32. package/docs/demos.md +19 -0
  33. package/docs/installation.md +117 -0
  34. package/docs/suite-audit.md +34 -0
  35. package/docs/theme-token-reference.md +67 -0
  36. package/docs/themes.md +164 -0
  37. package/package.json +22 -21
  38. package/public/fonts/OpenRunde-Bold.woff2 +0 -0
  39. package/public/fonts/OpenRunde-Medium.woff2 +0 -0
  40. package/public/fonts/OpenRunde-Regular.woff2 +0 -0
  41. package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
  42. package/scripts/package-smoke.mjs +43 -0
  43. package/src/app/globals.css +2 -1
  44. package/src/components/ui/Accordion.tsx +20 -6
  45. package/src/components/ui/Avatar.tsx +5 -3
  46. package/src/components/ui/Calendar.tsx +187 -63
  47. package/src/components/ui/Card.test.tsx +38 -0
  48. package/src/components/ui/Card.tsx +77 -14
  49. package/src/components/ui/CollapsibleSection.tsx +12 -4
  50. package/src/components/ui/CommandPalette.test.tsx +91 -4
  51. package/src/components/ui/CommandPalette.tsx +138 -128
  52. package/src/components/ui/EmptyState.tsx +2 -2
  53. package/src/components/ui/FormField.tsx +1 -1
  54. package/src/components/ui/Input.tsx +42 -18
  55. package/src/components/ui/Kbd.tsx +18 -3
  56. package/src/components/ui/Modal.tsx +3 -1
  57. package/src/components/ui/Progress.tsx +14 -5
  58. package/src/components/ui/SegmentedControl.tsx +2 -2
  59. package/src/components/ui/SettingsLayout.tsx +1 -1
  60. package/src/components/ui/Stat.tsx +3 -2
  61. package/src/components/ui/Table.tsx +8 -3
  62. package/src/components/ui/Tooltip.test.tsx +70 -1
  63. package/src/components/ui/Tooltip.tsx +53 -29
  64. package/src/components/ui/Typography.tsx +103 -49
  65. package/src/components/ui/avatar-stat.test.tsx +23 -0
  66. package/src/components/ui/calendar.test.tsx +88 -0
  67. package/src/components/ui/disclosure.test.tsx +39 -0
  68. package/src/components/ui/index.ts +44 -6
  69. package/src/components/ui/progress.test.tsx +15 -0
  70. package/src/components/ui/typography.test.tsx +26 -1
  71. package/src/styles/suite-skin.css +8 -2
  72. package/src/styles/themes.css +184 -68
  73. package/src/suite/components/ai-message-blocknote.tsx +13 -2
  74. package/src/suite/components/ai-panel.tsx +41 -10
  75. package/src/suite/components/app-switcher.test.tsx +21 -6
  76. package/src/suite/components/app-switcher.tsx +7 -11
  77. package/src/suite/components/suite-app-layout.tsx +12 -3
  78. package/src/suite/components/suite-bottom-nav.tsx +0 -1
  79. package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
  80. package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
  81. package/src/suite/components/suite-integration-picker.test.tsx +224 -0
  82. package/src/suite/components/suite-integration-picker.tsx +939 -0
  83. package/src/suite/components/suite-integration-rules.test.tsx +157 -0
  84. package/src/suite/components/suite-integration-rules.tsx +471 -0
  85. package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
  86. package/src/suite/components/suite-mobile-drawer.tsx +65 -98
  87. package/src/suite/components/suite-notification-bell.tsx +29 -4
  88. package/src/suite/components/suite-sidebar.tsx +25 -25
  89. package/src/suite/components/suite-skeleton.tsx +2 -2
  90. package/src/suite/components/suite-user-menu.tsx +9 -8
  91. package/src/suite/components/theme-provider.test.tsx +87 -3
  92. package/src/suite/components/theme-provider.tsx +144 -23
  93. package/src/suite/components/today-ui.tsx +79 -68
  94. package/src/suite/components/workspace-switcher.test.tsx +56 -0
  95. package/src/suite/components/workspace-switcher.tsx +3 -3
  96. package/src/suite/icons/app-accents.ts +12 -12
  97. package/src/suite/icons/glyphs.ts +6 -12
  98. package/src/suite/index.ts +27 -0
  99. package/src/suite/lib/apps.test.ts +9 -1
  100. package/src/suite/lib/apps.ts +14 -8
  101. package/src/suite/lib/cookies.ts +53 -0
  102. package/src/suite/lib/theme-boot.ts +32 -0
  103. package/src/suite/lib/theme-cookie.ts +7 -0
  104. package/src/suite/lib/use-sidebar-width.ts +5 -51
  105. package/dist/chunk-KDR6HI6F.mjs +0 -1075
  106. package/dist/chunk-NOI42JNZ.mjs +0 -151
  107. package/dist/chunk-VXISSXTB.mjs +0 -2564
  108. package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
  109. package/src/suite/lib/use-lock-mobile-page-zoom.ts +0 -51
@@ -0,0 +1,939 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type FormEvent,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type ReactNode,
11
+ } from "react";
12
+ import {
13
+ EvPlug as Plug,
14
+ Filter,
15
+ NavArrowDown,
16
+ NavArrowUp,
17
+ Search,
18
+ Xmark,
19
+ } from "iconoir-react";
20
+ import { Button, Input, Modal } from "@/components/ui";
21
+ import { SUITE_APPS, SUITE_APP_MAP, type SuiteAppSlug } from "@/suite/lib/apps";
22
+ import { SuiteAppIcon } from "@/suite/icons";
23
+
24
+ export interface SuiteIntegrationPickerItem {
25
+ id: string;
26
+ label: string;
27
+ description: string;
28
+ icon?: ReactNode;
29
+ categories?: string[];
30
+ authSchemes?: string[];
31
+ connecting?: boolean;
32
+ unavailableReason?: string;
33
+ }
34
+
35
+ export interface SuiteIntegrationAuthField {
36
+ name: string;
37
+ label: string;
38
+ type?: "password" | "text" | "url";
39
+ }
40
+
41
+ export interface SuiteIntegrationAuthDetails {
42
+ fields: SuiteIntegrationAuthField[];
43
+ auth_scheme: string | null;
44
+ managed: boolean;
45
+ }
46
+
47
+ export interface SuiteIntegrationAuthSelection {
48
+ auth_scheme: string;
49
+ auth_fields?: Record<string, string>;
50
+ }
51
+
52
+ export interface SuiteIntegrationPickerProps {
53
+ open: boolean;
54
+ items: SuiteIntegrationPickerItem[];
55
+ connectedItems?: SuiteIntegrationPickerItem[];
56
+ loading?: boolean;
57
+ catalogLoading?: boolean;
58
+ loadingMore?: boolean;
59
+ hasMore?: boolean;
60
+ onEndReached?: () => void;
61
+ search?: string;
62
+ onSearchChange?: (value: string) => void;
63
+ category?: string;
64
+ categories?: string[];
65
+ onCategoryChange?: (value: string) => void;
66
+ onClose: () => void;
67
+ onConnect: (
68
+ item: SuiteIntegrationPickerItem,
69
+ auth?: SuiteIntegrationAuthSelection,
70
+ ) => void | Promise<void>;
71
+ onFetchAuthFields?: (
72
+ item: SuiteIntegrationPickerItem,
73
+ authScheme: string,
74
+ ) => Promise<SuiteIntegrationAuthDetails>;
75
+ onDisconnect?: (item: SuiteIntegrationPickerItem) => void;
76
+ title?: string;
77
+ description?: string;
78
+ dataTest?: string;
79
+ }
80
+
81
+ const DESCRIPTION_LIMIT = 90;
82
+
83
+ function IntegrationSkeletonCard() {
84
+ return (
85
+ <div
86
+ className="flex min-h-36 min-w-0 animate-pulse flex-col justify-between rounded-xl border border-ph-border bg-ph-surface p-4"
87
+ aria-hidden="true"
88
+ >
89
+ <div className="flex items-start gap-3">
90
+ <div className="h-10 w-10 shrink-0 rounded-lg bg-ph-muted" />
91
+ <div className="min-w-0 flex-1 space-y-2 pt-1">
92
+ <div className="h-4 w-3/5 rounded bg-ph-muted" />
93
+ <div className="h-3 w-full rounded bg-ph-muted" />
94
+ <div className="h-3 w-4/5 rounded bg-ph-muted" />
95
+ </div>
96
+ </div>
97
+ <div className="mt-3 h-9 w-20 rounded bg-ph-muted" />
98
+ </div>
99
+ );
100
+ }
101
+
102
+ const SKELETON_COUNT = 6;
103
+
104
+ const AUTH_SCHEME_LABELS: Record<string, string> = {
105
+ API_KEY: "API key",
106
+ BASIC_AUTH: "Basic auth",
107
+ BEARER_TOKEN: "Bearer token",
108
+ OAUTH2: "OAuth 2.0",
109
+ };
110
+
111
+ function authSchemeLabel(scheme: string) {
112
+ const normalised = scheme.trim().toUpperCase();
113
+ return (
114
+ AUTH_SCHEME_LABELS[normalised] ??
115
+ normalised
116
+ .replace(/[-_]/g, " ")
117
+ .replace(/\b\w/g, (letter) => letter.toUpperCase())
118
+ );
119
+ }
120
+
121
+ function authSchemesFor(item: SuiteIntegrationPickerItem) {
122
+ return Array.from(
123
+ new Set(
124
+ (item.authSchemes ?? [])
125
+ .map((scheme) => scheme.trim().toUpperCase())
126
+ .filter(Boolean),
127
+ ),
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Which ShellStack apps put a connected integration to work, per the deep
133
+ * integration plan: calendar/mail feed TurtleTime time entries and Tides
134
+ * tasks, code/chat/ticketing feed Tides tasks and Kraken runbooks, docs and
135
+ * knowledge sources feed Kraken, CRM/finance feed the Portal. Unmapped
136
+ * categories default to Kraken's authorised-source search.
137
+ */
138
+ const CATEGORY_APP_AFFINITY: Record<string, readonly SuiteAppSlug[]> = {
139
+ calendar: ["turtletime", "tides"],
140
+ email: ["turtletime", "tides"],
141
+ code: ["tides", "kraken"],
142
+ chat: ["tides", "kraken"],
143
+ documents: ["kraken"],
144
+ notes: ["kraken"],
145
+ knowledge: ["kraken"],
146
+ file_storage: ["kraken"],
147
+ crm: ["shellstack", "tides"],
148
+ finance: ["shellstack", "tides"],
149
+ payment: ["shellstack", "tides"],
150
+ monitoring: ["tides", "kraken"],
151
+ design: ["tides", "kraken"],
152
+ support: ["tides", "shellstack"],
153
+ ticketing: ["tides", "kraken"],
154
+ };
155
+
156
+ const DEFAULT_APP_AFFINITY: readonly SuiteAppSlug[] = ["tides", "kraken"];
157
+
158
+ export function suiteAppsForIntegration(
159
+ categories: readonly string[] | undefined,
160
+ ): SuiteAppSlug[] {
161
+ const apps = new Set<SuiteAppSlug>();
162
+ for (const category of categories ?? []) {
163
+ for (const app of CATEGORY_APP_AFFINITY[category.trim().toLowerCase()] ??
164
+ []) {
165
+ apps.add(app);
166
+ }
167
+ }
168
+ return apps.size > 0
169
+ ? SUITE_APPS.filter((app) => apps.has(app.slug)).map((app) => app.slug)
170
+ : [...DEFAULT_APP_AFFINITY];
171
+ }
172
+
173
+ export function SuiteIntegrationPicker({
174
+ open,
175
+ items,
176
+ connectedItems = [],
177
+ loading = false,
178
+ catalogLoading = false,
179
+ loadingMore = false,
180
+ hasMore = false,
181
+ onEndReached,
182
+ search: controlledSearch,
183
+ onSearchChange,
184
+ category: controlledCategory,
185
+ categories: controlledCategories,
186
+ onCategoryChange,
187
+ onClose,
188
+ onConnect,
189
+ onFetchAuthFields,
190
+ onDisconnect,
191
+ title = "Integrate an app",
192
+ description = "Connect an app to this workspace securely.",
193
+ dataTest = "suite-integration-picker",
194
+ }: SuiteIntegrationPickerProps) {
195
+ const [query, setQuery] = useState("");
196
+ const [category, setCategory] = useState("all");
197
+ const [categoryQuery, setCategoryQuery] = useState("");
198
+ const [categoryMenuOpen, setCategoryMenuOpen] = useState(false);
199
+ const [expanded, setExpanded] = useState<string | null>(null);
200
+ const categoryMenuRef = useRef<HTMLDivElement>(null);
201
+ const categoryTriggerRef = useRef<HTMLButtonElement>(null);
202
+ const categorySearchRef = useRef<HTMLInputElement>(null);
203
+ const categoryOptionRefs = useRef<Record<string, HTMLButtonElement | null>>({});
204
+ const pendingCategoryFocus = useRef<"trigger" | "search" | string | null>(
205
+ null,
206
+ );
207
+ const scrollViewportRef = useRef<HTMLDivElement>(null);
208
+ const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
209
+ const loadMoreInFlightRef = useRef(false);
210
+ const authRequestRef = useRef(0);
211
+ const [methodItem, setMethodItem] =
212
+ useState<SuiteIntegrationPickerItem | null>(null);
213
+ const [authItem, setAuthItem] = useState<{
214
+ item: SuiteIntegrationPickerItem;
215
+ scheme: string;
216
+ fields: SuiteIntegrationAuthField[];
217
+ } | null>(null);
218
+ const [authValues, setAuthValues] = useState<Record<string, string>>({});
219
+ const [authLoading, setAuthLoading] = useState(false);
220
+ const [authSubmitting, setAuthSubmitting] = useState(false);
221
+ const [authError, setAuthError] = useState<string | null>(null);
222
+ const [connectionError, setConnectionError] = useState<string | null>(null);
223
+ const [itemToDisconnect, setItemToDisconnect] =
224
+ useState<SuiteIntegrationPickerItem | null>(null);
225
+ const [disconnecting, setDisconnecting] = useState(false);
226
+ const [disconnectError, setDisconnectError] = useState<string | null>(null);
227
+ const itemCategories = useMemo(
228
+ () =>
229
+ Array.from(
230
+ new Set(items.flatMap((item) => item.categories ?? [])),
231
+ ).sort(),
232
+ [items],
233
+ );
234
+ const categories = controlledCategories ?? itemCategories;
235
+ const activeSearch = controlledSearch ?? query;
236
+ const activeCategory = controlledCategory ?? category;
237
+ const categoryLabel = (value: string) =>
238
+ value
239
+ .replace(/[-_]/g, " ")
240
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
241
+ const filtered =
242
+ onSearchChange || onCategoryChange
243
+ ? items
244
+ : items.filter((item) => {
245
+ return (
246
+ item.label
247
+ .toLowerCase()
248
+ .includes(activeSearch.trim().toLowerCase()) &&
249
+ (activeCategory === "all" ||
250
+ (item.categories ?? []).includes(activeCategory))
251
+ );
252
+ });
253
+ const visibleCategories = categories.filter((value) =>
254
+ categoryLabel(value)
255
+ .toLowerCase()
256
+ .includes(categoryQuery.trim().toLowerCase()),
257
+ );
258
+ const categoryValues = ["all", ...visibleCategories];
259
+ const focusableCategory = categoryValues.includes(activeCategory)
260
+ ? activeCategory
261
+ : "all";
262
+
263
+ function focusCategory(value: "trigger" | "search" | string) {
264
+ pendingCategoryFocus.current = value;
265
+ }
266
+
267
+ function openCategoryMenu(focus: "search" | string | null = null) {
268
+ setCategoryQuery("");
269
+ if (focus) focusCategory(focus);
270
+ setCategoryMenuOpen(true);
271
+ }
272
+
273
+ function closeCategoryMenu(restoreFocus = false) {
274
+ if (restoreFocus) focusCategory("trigger");
275
+ setCategoryMenuOpen(false);
276
+ }
277
+
278
+ function selectCategory(value: string) {
279
+ setCategory(value);
280
+ onCategoryChange?.(value);
281
+ closeCategoryMenu(true);
282
+ }
283
+
284
+ function moveCategoryFocus(
285
+ event: ReactKeyboardEvent<HTMLButtonElement>,
286
+ value: string,
287
+ ) {
288
+ const index = categoryValues.indexOf(value);
289
+ let nextIndex: number | null = null;
290
+ if (event.key === "ArrowDown") nextIndex = (index + 1) % categoryValues.length;
291
+ else if (event.key === "ArrowUp")
292
+ nextIndex = (index - 1 + categoryValues.length) % categoryValues.length;
293
+ else if (event.key === "Home") nextIndex = 0;
294
+ else if (event.key === "End") nextIndex = categoryValues.length - 1;
295
+
296
+ if (nextIndex === null) return;
297
+ event.preventDefault();
298
+ categoryOptionRefs.current[categoryValues[nextIndex]]?.focus();
299
+ }
300
+
301
+ function handleCategoryTriggerKeyDown(
302
+ event: ReactKeyboardEvent<HTMLButtonElement>,
303
+ ) {
304
+ if (event.key === "ArrowDown") {
305
+ event.preventDefault();
306
+ openCategoryMenu(activeCategory === "all" ? "all" : activeCategory);
307
+ } else if (event.key === "ArrowUp") {
308
+ event.preventDefault();
309
+ openCategoryMenu(categoryValues.at(-1) ?? "all");
310
+ } else if (event.key === "Enter" || event.key === " ") {
311
+ event.preventDefault();
312
+ openCategoryMenu("search");
313
+ }
314
+ }
315
+
316
+ function resetAuthForm() {
317
+ authRequestRef.current += 1;
318
+ setAuthItem(null);
319
+ setAuthValues({});
320
+ setAuthLoading(false);
321
+ setAuthError(null);
322
+ }
323
+
324
+ function closeAuthForm() {
325
+ if (authSubmitting) return;
326
+ resetAuthForm();
327
+ }
328
+
329
+ async function prepareConnection(
330
+ item: SuiteIntegrationPickerItem,
331
+ scheme: string,
332
+ ) {
333
+ setMethodItem(null);
334
+ setAuthError(null);
335
+ setConnectionError(null);
336
+ setAuthValues({});
337
+
338
+ if (!onFetchAuthFields) {
339
+ try {
340
+ await onConnect(item, { auth_scheme: scheme });
341
+ } catch (cause) {
342
+ setConnectionError(
343
+ cause instanceof Error ? cause.message : "Could not connect the app.",
344
+ );
345
+ }
346
+ return;
347
+ }
348
+
349
+ const requestId = authRequestRef.current + 1;
350
+ authRequestRef.current = requestId;
351
+ setAuthItem({ item, scheme, fields: [] });
352
+ setAuthLoading(true);
353
+ try {
354
+ const details = await onFetchAuthFields(item, scheme);
355
+ if (requestId !== authRequestRef.current) return;
356
+ const resolvedScheme = details.auth_scheme ?? scheme;
357
+ if (details.managed) {
358
+ setAuthItem(null);
359
+ await onConnect(item, { auth_scheme: resolvedScheme });
360
+ } else if (details.fields.length === 0) {
361
+ setAuthItem(null);
362
+ await onConnect(item, {
363
+ auth_scheme: resolvedScheme,
364
+ auth_fields: {},
365
+ });
366
+ } else {
367
+ setAuthItem({ item, scheme: resolvedScheme, fields: details.fields });
368
+ }
369
+ } catch (cause) {
370
+ if (requestId === authRequestRef.current) {
371
+ const message =
372
+ cause instanceof Error
373
+ ? cause.message
374
+ : "Could not load authentication details.";
375
+ setAuthError(message);
376
+ setConnectionError(message);
377
+ }
378
+ } finally {
379
+ if (requestId === authRequestRef.current) setAuthLoading(false);
380
+ }
381
+ }
382
+
383
+ function connectItem(item: SuiteIntegrationPickerItem) {
384
+ setConnectionError(null);
385
+ const schemes = authSchemesFor(item);
386
+ if (schemes.length > 1) {
387
+ setMethodItem(item);
388
+ return;
389
+ }
390
+ if (schemes.length === 1) {
391
+ void prepareConnection(item, schemes[0]);
392
+ return;
393
+ }
394
+ void (async () => {
395
+ try {
396
+ await onConnect(item);
397
+ } catch (cause) {
398
+ setConnectionError(
399
+ cause instanceof Error ? cause.message : "Could not connect the app.",
400
+ );
401
+ }
402
+ })();
403
+ }
404
+
405
+ function requestDisconnect(item: SuiteIntegrationPickerItem) {
406
+ setDisconnectError(null);
407
+ setItemToDisconnect(item);
408
+ }
409
+
410
+ async function disconnectItem() {
411
+ if (!onDisconnect || !itemToDisconnect) return;
412
+ setDisconnecting(true);
413
+ setDisconnectError(null);
414
+ try {
415
+ await onDisconnect(itemToDisconnect);
416
+ setItemToDisconnect(null);
417
+ } catch (cause) {
418
+ setDisconnectError(
419
+ cause instanceof Error ? cause.message : "Could not disconnect the app.",
420
+ );
421
+ } finally {
422
+ setDisconnecting(false);
423
+ }
424
+ }
425
+
426
+ async function submitAuthForm(event: FormEvent<HTMLFormElement>) {
427
+ event.preventDefault();
428
+ if (!authItem) return;
429
+ setAuthSubmitting(true);
430
+ setAuthError(null);
431
+ try {
432
+ await onConnect(authItem.item, {
433
+ auth_scheme: authItem.scheme,
434
+ auth_fields: authValues,
435
+ });
436
+ resetAuthForm();
437
+ } catch (cause) {
438
+ setAuthError(
439
+ cause instanceof Error ? cause.message : "Could not connect the app.",
440
+ );
441
+ } finally {
442
+ setAuthSubmitting(false);
443
+ }
444
+ }
445
+
446
+ useEffect(() => {
447
+ if (!categoryMenuOpen) return;
448
+ const handlePointerDown = (event: PointerEvent) => {
449
+ if (!categoryMenuRef.current?.contains(event.target as Node)) {
450
+ closeCategoryMenu();
451
+ }
452
+ };
453
+ const handleKeyDown = (event: KeyboardEvent) => {
454
+ if (event.key === "Escape") closeCategoryMenu(true);
455
+ };
456
+ document.addEventListener("pointerdown", handlePointerDown);
457
+ document.addEventListener("keydown", handleKeyDown);
458
+ return () => {
459
+ document.removeEventListener("pointerdown", handlePointerDown);
460
+ document.removeEventListener("keydown", handleKeyDown);
461
+ };
462
+ // closeCategoryMenu only updates stable React setters and refs.
463
+ // eslint-disable-next-line react-hooks/exhaustive-deps
464
+ }, [categoryMenuOpen]);
465
+
466
+ useEffect(() => {
467
+ const target = pendingCategoryFocus.current;
468
+ if (!target) return;
469
+ pendingCategoryFocus.current = null;
470
+
471
+ if (target === "trigger") categoryTriggerRef.current?.focus();
472
+ else if (target === "search") categorySearchRef.current?.focus();
473
+ else categoryOptionRefs.current[target]?.focus();
474
+ }, [activeCategory, categoryMenuOpen, categoryQuery, visibleCategories]);
475
+
476
+ useEffect(() => {
477
+ if (!loadingMore) loadMoreInFlightRef.current = false;
478
+ }, [loadingMore]);
479
+
480
+ useEffect(() => {
481
+ const root = scrollViewportRef.current;
482
+ const sentinel = loadMoreSentinelRef.current;
483
+ if (
484
+ !open ||
485
+ !root ||
486
+ !sentinel ||
487
+ !hasMore ||
488
+ !onEndReached ||
489
+ loading ||
490
+ catalogLoading ||
491
+ loadingMore
492
+ ) {
493
+ return;
494
+ }
495
+
496
+ const observer = new IntersectionObserver(
497
+ ([entry]) => {
498
+ if (!entry.isIntersecting || loadMoreInFlightRef.current) return;
499
+ loadMoreInFlightRef.current = true;
500
+ observer.unobserve(entry.target);
501
+ onEndReached();
502
+ },
503
+ { root, rootMargin: "0px 0px 80px", threshold: 0 },
504
+ );
505
+
506
+ observer.observe(sentinel);
507
+ return () => observer.disconnect();
508
+ }, [catalogLoading, hasMore, loading, loadingMore, onEndReached, open]);
509
+
510
+ return (
511
+ <>
512
+ <Modal
513
+ open={open}
514
+ onClose={onClose}
515
+ title={title}
516
+ description={description}
517
+ size="xl"
518
+ dataTest={dataTest}
519
+ bodyClassName="!overflow-hidden"
520
+ >
521
+ <div className="space-y-4">
522
+ {connectionError ? (
523
+ <p className="text-sm text-ph-danger" role="alert">
524
+ {connectionError}
525
+ </p>
526
+ ) : null}
527
+ <div className="grid gap-4 md:grid-cols-2">
528
+ <div className="relative">
529
+ <Search
530
+ className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-ph-subtle"
531
+ aria-hidden
532
+ />
533
+ <Input
534
+ value={activeSearch}
535
+ onChange={(event) => {
536
+ setQuery(event.target.value);
537
+ onSearchChange?.(event.target.value);
538
+ }}
539
+ placeholder="Search apps..."
540
+ aria-label="Search apps"
541
+ className="pl-10"
542
+ />
543
+ </div>
544
+ <div ref={categoryMenuRef} className="relative">
545
+ <button
546
+ ref={categoryTriggerRef}
547
+ id={`${dataTest}-category`}
548
+ type="button"
549
+ className="ph-input flex w-full items-center justify-between text-left"
550
+ aria-haspopup="listbox"
551
+ aria-expanded={categoryMenuOpen}
552
+ aria-controls={
553
+ categoryMenuOpen ? `${dataTest}-category-options` : undefined
554
+ }
555
+ aria-label="Filter by category"
556
+ onClick={() => {
557
+ if (categoryMenuOpen) closeCategoryMenu();
558
+ else openCategoryMenu();
559
+ }}
560
+ onKeyDown={handleCategoryTriggerKeyDown}
561
+ >
562
+ <span className="flex items-center gap-2">
563
+ <Filter
564
+ className="h-4 w-4 shrink-0 text-ph-subtle"
565
+ aria-hidden
566
+ />
567
+ <span>
568
+ {activeCategory === "all"
569
+ ? "All apps"
570
+ : categoryLabel(activeCategory)}
571
+ </span>
572
+ </span>
573
+ <NavArrowDown
574
+ className={`h-4 w-4 transition-transform ${categoryMenuOpen ? "rotate-180" : ""}`}
575
+ aria-hidden
576
+ />
577
+ </button>
578
+ {categoryMenuOpen ? (
579
+ <div
580
+ className="absolute z-20 mt-2 max-h-[min(22rem,calc(100dvh-12rem))] w-full max-w-full overflow-hidden rounded-lg border border-ph-border bg-ph-surface p-2 shadow-lg"
581
+ >
582
+ <Input
583
+ ref={categorySearchRef}
584
+ value={categoryQuery}
585
+ onChange={(event) => setCategoryQuery(event.target.value)}
586
+ placeholder="Search categories..."
587
+ aria-label="Search categories"
588
+ onKeyDown={(event) => {
589
+ if (event.key === "ArrowDown") {
590
+ event.preventDefault();
591
+ categoryOptionRefs.current[categoryValues[0]]?.focus();
592
+ } else if (event.key === "Escape") {
593
+ event.preventDefault();
594
+ closeCategoryMenu(true);
595
+ }
596
+ }}
597
+ />
598
+ <div
599
+ id={`${dataTest}-category-options`}
600
+ role="listbox"
601
+ aria-label="Integration categories"
602
+ className="mt-2 max-h-52 overflow-y-auto"
603
+ data-test="integration-category-options"
604
+ >
605
+ {categoryValues.map((value) => (
606
+ <button
607
+ key={value}
608
+ type="button"
609
+ role="option"
610
+ aria-selected={activeCategory === value}
611
+ tabIndex={value === focusableCategory ? 0 : -1}
612
+ ref={(element) => {
613
+ categoryOptionRefs.current[value] = element;
614
+ }}
615
+ className={`flex w-full min-w-0 items-center rounded-md px-3 py-2 text-left text-sm break-words ${activeCategory === value ? "bg-ph-brand text-[var(--ph-on-accent)]" : "text-ph-ink hover:bg-ph-muted"}`}
616
+ onClick={() => selectCategory(value)}
617
+ onKeyDown={(event) => {
618
+ if (event.key === "Escape") {
619
+ event.preventDefault();
620
+ closeCategoryMenu(true);
621
+ } else if (event.key === "Enter" || event.key === " ") {
622
+ event.preventDefault();
623
+ selectCategory(value);
624
+ } else moveCategoryFocus(event, value);
625
+ }}
626
+ >
627
+ {value === "all" ? "All apps" : categoryLabel(value)}
628
+ </button>
629
+ ))}
630
+ </div>
631
+ </div>
632
+ ) : null}
633
+ </div>
634
+ </div>
635
+
636
+ <div
637
+ ref={scrollViewportRef}
638
+ className="min-h-0 max-h-[min(60vh,32rem)] w-full overflow-y-auto pr-1"
639
+ >
640
+ {loading || catalogLoading ? (
641
+ <div
642
+ className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"
643
+ aria-label="Loading apps"
644
+ >
645
+ {Array.from({ length: SKELETON_COUNT }, (_, index) => (
646
+ <IntegrationSkeletonCard key={index} />
647
+ ))}
648
+ </div>
649
+ ) : (
650
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
651
+ {filtered.map((item) => {
652
+ const isExpanded = expanded === item.id;
653
+ const longDescription =
654
+ item.description.length > DESCRIPTION_LIMIT;
655
+ const text =
656
+ isExpanded || !longDescription
657
+ ? item.description
658
+ : `${item.description.slice(0, DESCRIPTION_LIMIT).trimEnd()}...`;
659
+ return (
660
+ <div
661
+ key={item.id}
662
+ className="flex min-h-36 min-w-0 flex-col justify-between rounded-xl border border-ph-border bg-ph-surface p-4"
663
+ >
664
+ <button
665
+ type="button"
666
+ className="min-w-0 text-left"
667
+ onClick={() =>
668
+ longDescription &&
669
+ setExpanded(isExpanded ? null : item.id)
670
+ }
671
+ aria-expanded={isExpanded}
672
+ >
673
+ <span className="flex items-start gap-3">
674
+ <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ph-muted text-ph-ink">
675
+ {item.icon ?? (
676
+ <Plug className="h-5 w-5" aria-hidden />
677
+ )}
678
+ </span>
679
+ <span className="min-w-0">
680
+ <span className="block truncate font-medium text-ph-ink">
681
+ {item.label}
682
+ </span>
683
+ <span className="mt-1 block text-xs text-ph-subtle">
684
+ {text}
685
+ </span>
686
+ {longDescription ? (
687
+ <span className="mt-1 block text-xs font-medium text-ph-brand">
688
+ {isExpanded ? "See less" : "See more"}{" "}
689
+ {isExpanded ? (
690
+ <NavArrowUp className="inline h-3 w-3" />
691
+ ) : (
692
+ <NavArrowDown className="inline h-3 w-3" />
693
+ )}
694
+ </span>
695
+ ) : null}
696
+ </span>
697
+ </span>
698
+ </button>
699
+ {suiteAppsForIntegration(item.categories).length > 0 ? (
700
+ <div
701
+ className="mt-3 flex flex-wrap items-center gap-1.5"
702
+ aria-label="Works with ShellStack apps"
703
+ >
704
+ {suiteAppsForIntegration(item.categories).map(
705
+ (app) => (
706
+ <span
707
+ key={app}
708
+ data-test={`${dataTest}-${item.id}-app-${app}`}
709
+ >
710
+ <SuiteAppIcon
711
+ app={app}
712
+ size={16}
713
+ label={`Works with ${SUITE_APP_MAP[app].name}`}
714
+ className="rounded-[4px]"
715
+ />
716
+ </span>
717
+ ),
718
+ )}
719
+ </div>
720
+ ) : null}
721
+ {authSchemesFor(item).length > 0 ? (
722
+ <div
723
+ className="mt-3 flex flex-wrap gap-1.5"
724
+ aria-label="Connection methods"
725
+ >
726
+ {authSchemesFor(item).map((scheme) => (
727
+ <span
728
+ key={scheme}
729
+ className="rounded-full border border-ph-border bg-ph-muted px-2 py-1 text-[0.6875rem] font-medium text-ph-subtle"
730
+ data-test={`${dataTest}-${item.id}-auth-${scheme.toLowerCase()}`}
731
+ >
732
+ {authSchemeLabel(scheme)}
733
+ </span>
734
+ ))}
735
+ </div>
736
+ ) : null}
737
+ <Button
738
+ type="button"
739
+ variant="primary"
740
+ className="mt-3 self-start"
741
+ disabled={
742
+ Boolean(item.unavailableReason) || item.connecting
743
+ }
744
+ onClick={() => connectItem(item)}
745
+ >
746
+ <span className="flex items-center gap-1.5">
747
+ <Plug className="h-3.5 w-3.5" aria-hidden />
748
+ {item.connecting
749
+ ? "Connecting..."
750
+ : (item.unavailableReason ?? "Connect")}
751
+ </span>
752
+ </Button>
753
+ </div>
754
+ );
755
+ })}
756
+ {loadingMore
757
+ ? Array.from({ length: 3 }, (_, index) => (
758
+ <IntegrationSkeletonCard key={`loading-${index}`} />
759
+ ))
760
+ : null}
761
+ </div>
762
+ )}
763
+ <div ref={loadMoreSentinelRef} className="h-1" aria-hidden="true" />
764
+ </div>
765
+
766
+ {connectedItems.length > 0 && onDisconnect ? (
767
+ <div className="space-y-2 border-t border-ph-border pt-4">
768
+ <h3 className="font-medium text-ph-ink">Connected apps</h3>
769
+ {connectedItems.map((item) => (
770
+ <div
771
+ key={item.id}
772
+ className="flex items-center justify-between gap-3 rounded-lg border border-ph-border bg-ph-muted p-3"
773
+ >
774
+ <span className="flex min-w-0 items-center gap-2 text-sm text-ph-ink">
775
+ {item.icon}
776
+ <span className="truncate">{item.label}</span>
777
+ </span>
778
+ <Button
779
+ type="button"
780
+ variant="danger"
781
+ onClick={() => requestDisconnect(item)}
782
+ >
783
+ <span className="flex items-center gap-1.5">
784
+ <Xmark className="h-4 w-4" aria-hidden />
785
+ Disconnect
786
+ </span>
787
+ </Button>
788
+ </div>
789
+ ))}
790
+ </div>
791
+ ) : null}
792
+ </div>
793
+ </Modal>
794
+
795
+ <Modal
796
+ open={itemToDisconnect !== null}
797
+ onClose={() => {
798
+ if (!disconnecting) setItemToDisconnect(null);
799
+ }}
800
+ title="Disconnect app"
801
+ description="This will stop syncing data from this app."
802
+ size="sm"
803
+ zIndex={220}
804
+ dataTest={`${dataTest}-disconnect-confirmation`}
805
+ footer={
806
+ <div className="flex justify-end gap-2">
807
+ <Button
808
+ type="button"
809
+ variant="tertiary"
810
+ onClick={() => setItemToDisconnect(null)}
811
+ disabled={disconnecting}
812
+ >
813
+ Cancel
814
+ </Button>
815
+ <Button
816
+ type="button"
817
+ variant="danger"
818
+ onClick={() => void disconnectItem()}
819
+ disabled={disconnecting}
820
+ data-test={`${dataTest}-disconnect-confirm`}
821
+ >
822
+ {disconnecting ? "Disconnecting..." : "Disconnect app"}
823
+ </Button>
824
+ </div>
825
+ }
826
+ >
827
+ {disconnectError ? (
828
+ <p className="text-sm text-ph-danger" role="alert">
829
+ {disconnectError}
830
+ </p>
831
+ ) : null}
832
+ <p className="text-sm text-ph-ink">
833
+ Disconnect {itemToDisconnect?.label}? You can reconnect it later.
834
+ </p>
835
+ </Modal>
836
+
837
+ <Modal
838
+ open={Boolean(methodItem)}
839
+ onClose={() => setMethodItem(null)}
840
+ title={
841
+ methodItem
842
+ ? `Connect ${methodItem.label}`
843
+ : "Choose connection method"
844
+ }
845
+ description="Choose how you want to connect this app."
846
+ size="sm"
847
+ zIndex={200}
848
+ dataTest={`${dataTest}-auth-method`}
849
+ >
850
+ {methodItem ? (
851
+ <div className="space-y-2">
852
+ {authSchemesFor(methodItem).map((scheme) => (
853
+ <button
854
+ key={scheme}
855
+ type="button"
856
+ className="flex w-full items-center justify-between rounded-lg border border-ph-border bg-ph-surface px-3 py-3 text-left text-sm text-ph-ink transition hover:border-ph-brand hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
857
+ onClick={() => void prepareConnection(methodItem, scheme)}
858
+ data-test={`${dataTest}-choose-${scheme.toLowerCase()}`}
859
+ >
860
+ <span className="font-medium">{authSchemeLabel(scheme)}</span>
861
+ <span className="text-xs text-ph-subtle">Select</span>
862
+ </button>
863
+ ))}
864
+ </div>
865
+ ) : null}
866
+ </Modal>
867
+
868
+ <Modal
869
+ open={Boolean(authItem)}
870
+ onClose={closeAuthForm}
871
+ title={
872
+ authItem ? `${authItem.item.label} authentication` : "Authentication"
873
+ }
874
+ description="Enter the credentials required by Composio. They are sent securely to the ShellStack API."
875
+ size="md"
876
+ zIndex={210}
877
+ dataTest={`${dataTest}-auth-form`}
878
+ >
879
+ {authLoading ? (
880
+ <p className="text-sm text-ph-subtle">
881
+ Loading authentication fields...
882
+ </p>
883
+ ) : authItem ? (
884
+ <form
885
+ className="space-y-4"
886
+ onSubmit={(event) => void submitAuthForm(event)}
887
+ >
888
+ {authError ? (
889
+ <p className="text-sm text-ph-danger" role="alert">
890
+ {authError}
891
+ </p>
892
+ ) : null}
893
+ {authItem.fields.length === 0 && authError ? (
894
+ <Button
895
+ type="button"
896
+ variant="tertiary"
897
+ onClick={() =>
898
+ void prepareConnection(authItem.item, authItem.scheme)
899
+ }
900
+ >
901
+ Try again
902
+ </Button>
903
+ ) : null}
904
+ {authItem.fields.map((field) => (
905
+ <Input
906
+ key={field.name}
907
+ label={field.label}
908
+ type={field.type ?? "password"}
909
+ required
910
+ value={authValues[field.name] ?? ""}
911
+ onChange={(event) =>
912
+ setAuthValues((current) => ({
913
+ ...current,
914
+ [field.name]: event.target.value,
915
+ }))
916
+ }
917
+ autoComplete="off"
918
+ data-test={`${dataTest}-auth-field-${field.name}`}
919
+ />
920
+ ))}
921
+ <div className="flex justify-end gap-2 border-t border-ph-border pt-4">
922
+ <Button
923
+ type="button"
924
+ variant="tertiary"
925
+ onClick={closeAuthForm}
926
+ disabled={authSubmitting}
927
+ >
928
+ Cancel
929
+ </Button>
930
+ <Button type="submit" variant="primary" disabled={authSubmitting}>
931
+ {authSubmitting ? "Connecting..." : "Connect"}
932
+ </Button>
933
+ </div>
934
+ </form>
935
+ ) : null}
936
+ </Modal>
937
+ </>
938
+ );
939
+ }