@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
@@ -166,6 +166,7 @@ export function SuiteAiPanel({
166
166
  onSwitchApp,
167
167
  onSameAppNavigate,
168
168
  resolveAction,
169
+ isWaitingForReply = false,
169
170
  }: {
170
171
  presets?: SuiteAiPreset[];
171
172
  emptyState?: ReactNode;
@@ -173,6 +174,7 @@ export function SuiteAiPanel({
173
174
  fetchChat: () => Promise<{
174
175
  messages: SuiteAiChatMessage[];
175
176
  configured: boolean;
177
+ jobs?: Array<{ status?: string; error?: string }>;
176
178
  }>;
177
179
  sendMessage: (params: {
178
180
  prompt: string;
@@ -193,6 +195,8 @@ export function SuiteAiPanel({
193
195
  id: string,
194
196
  decision: "confirm" | "cancel",
195
197
  ) => Promise<{ messages: SuiteAiChatMessage[] }>;
198
+ /** A durable background job is still preparing the assistant reply. */
199
+ isWaitingForReply?: boolean;
196
200
  }) {
197
201
  const inputId = useId();
198
202
  const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
@@ -216,6 +220,7 @@ export function SuiteAiPanel({
216
220
  const textareaRef = useRef<HTMLTextAreaElement>(null);
217
221
  const abortRef = useRef<AbortController | null>(null);
218
222
  const pendingPromptRef = useRef<string | null>(null);
223
+ const waiting = loading || isWaitingForReply;
219
224
 
220
225
  useEffect(() => {
221
226
  function onOpen(event: Event) {
@@ -253,6 +258,16 @@ export function SuiteAiPanel({
253
258
  setMessages(data.messages ?? []);
254
259
  if (!data.configured) {
255
260
  setError("ShellStack AI is not configured for this workspace yet.");
261
+ } else {
262
+ const latestJob = data.jobs?.[0];
263
+ if (
264
+ latestJob?.status === "failed" &&
265
+ latestJob.error &&
266
+ latestJob.error !== "Chat cleared." &&
267
+ data.messages.at(-1)?.role !== "assistant"
268
+ ) {
269
+ setError(latestJob.error);
270
+ }
256
271
  }
257
272
  } catch (err) {
258
273
  setError(
@@ -276,7 +291,7 @@ export function SuiteAiPanel({
276
291
 
277
292
  useEffect(() => {
278
293
  if (open && atBottom) scrollToBottom();
279
- }, [messages, loading, open, atBottom, scrollToBottom]);
294
+ }, [messages, waiting, open, atBottom, scrollToBottom]);
280
295
 
281
296
  useEffect(() => {
282
297
  if (open && !hydrating) textareaRef.current?.focus();
@@ -291,7 +306,7 @@ export function SuiteAiPanel({
291
306
 
292
307
  async function ask(text: string) {
293
308
  const trimmed = text.trim();
294
- if (!trimmed || loading) return;
309
+ if (!trimmed || waiting) return;
295
310
  const controller = new AbortController();
296
311
  abortRef.current = controller;
297
312
  setLoading(true);
@@ -424,7 +439,7 @@ export function SuiteAiPanel({
424
439
  <button
425
440
  type="button"
426
441
  onClick={() => void handleClear()}
427
- disabled={loading}
442
+ disabled={waiting}
428
443
  className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-ph-subtle transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
429
444
  data-test="ask-ai-clear"
430
445
  >
@@ -469,7 +484,7 @@ export function SuiteAiPanel({
469
484
  key={preset.label}
470
485
  type="button"
471
486
  onClick={() => void ask(preset.prompt)}
472
- disabled={loading || hydrating}
487
+ disabled={waiting || hydrating}
473
488
  className="group flex w-full items-center gap-2 rounded-xl border border-ph-border bg-ph-surface px-3 py-2 text-left text-sm text-ph-ink transition-colors hover:border-ph-brand/40 hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
474
489
  >
475
490
  <Sparks className="h-4 w-4 shrink-0 text-ph-brand" />
@@ -515,9 +530,16 @@ export function SuiteAiPanel({
515
530
  <div className="rounded-2xl rounded-bl-md bg-ph-muted px-3 py-2 text-sm leading-relaxed text-ph-ink">
516
531
  <Suspense
517
532
  fallback={
518
- <p className="whitespace-pre-wrap break-words">
519
- {message.content}
520
- </p>
533
+ <div
534
+ className="space-y-2 py-0.5"
535
+ role="status"
536
+ aria-label="Rendering response"
537
+ >
538
+ <span className="sr-only">Rendering response…</span>
539
+ <div className="h-3 w-11/12 rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" />
540
+ <div className="h-3 w-full rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" />
541
+ <div className="h-3 w-3/5 rounded bg-ph-mutedtext/20 motion-safe:animate-pulse" />
542
+ </div>
521
543
  }
522
544
  >
523
545
  <SuiteAiBlockNoteMessage markdown={message.content} />
@@ -584,7 +606,7 @@ export function SuiteAiPanel({
584
606
  );
585
607
  })}
586
608
 
587
- {loading ? (
609
+ {waiting ? (
588
610
  <div className="flex items-end gap-2" aria-hidden>
589
611
  <span className="mb-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-ph-brand/10 text-ph-brand">
590
612
  <Icon className="h-3.5 w-3.5" />
@@ -597,7 +619,7 @@ export function SuiteAiPanel({
597
619
  </div>
598
620
  ) : null}
599
621
 
600
- {loading ? (
622
+ {waiting ? (
601
623
  <span className="sr-only" aria-live="polite">
602
624
  Assistant is responding…
603
625
  </span>
@@ -615,7 +637,7 @@ export function SuiteAiPanel({
615
637
  <button
616
638
  type="button"
617
639
  onClick={retry}
618
- disabled={loading}
640
+ disabled={waiting}
619
641
  className="mt-2 inline-flex min-h-8 items-center gap-1.5 rounded-lg border border-ph-border bg-ph-surface px-2.5 text-xs font-medium text-ph-ink transition-colors hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-focus disabled:opacity-40"
620
642
  >
621
643
  Try again
@@ -678,6 +700,15 @@ export function SuiteAiPanel({
678
700
  <Square className="h-3.5 w-3.5 fill-current" />
679
701
  </button>
680
702
  </Tooltip>
703
+ ) : isWaitingForReply ? (
704
+ <span
705
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-ph-muted text-ph-subtle"
706
+ data-test="ask-ai-waiting"
707
+ role="status"
708
+ aria-label="Shelly is responding"
709
+ >
710
+ <Spinner className="h-4 w-4 animate-spin" />
711
+ </span>
681
712
  ) : (
682
713
  <Tooltip content="Send message">
683
714
  <button
@@ -4,7 +4,10 @@ import userEvent from "@testing-library/user-event";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  vi.mock("next/image", () => ({
7
- default: (props: ComponentPropsWithoutRef<"img">) => <img {...props} />,
7
+ default: ({ alt = "", ...props }: ComponentPropsWithoutRef<"img">) => (
8
+ // eslint-disable-next-line @next/next/no-img-element -- this is the next/image test double.
9
+ <img alt={alt} {...props} />
10
+ ),
8
11
  }));
9
12
 
10
13
  import { AppSwitcher, type SuiteAppEntry } from "./app-switcher";
@@ -28,10 +31,10 @@ const APPS: SuiteAppEntry[] = [
28
31
  icon: "/kraken.svg",
29
32
  },
30
33
  {
31
- slug: "nakama",
32
- name: "Nakama",
34
+ slug: "shelly",
35
+ name: "Shelly",
33
36
  description: "AI teammates",
34
- icon: "/nakama.svg",
37
+ icon: "/shelly.svg",
35
38
  },
36
39
  ];
37
40
 
@@ -56,7 +59,11 @@ function TestDropdownItem({
56
59
  children,
57
60
  ...props
58
61
  }: ComponentPropsWithoutRef<"button">) {
59
- return <button {...props}>{children}</button>;
62
+ return (
63
+ <button data-dropdown-item="true" {...props}>
64
+ {children}
65
+ </button>
66
+ );
60
67
  }
61
68
 
62
69
  describe("AppSwitcher", () => {
@@ -85,12 +92,20 @@ describe("AppSwitcher", () => {
85
92
  const newTabButton = screen.getByRole("button", {
86
93
  name: "Open Kraken in a new tab",
87
94
  });
88
- expect(newTabButton).toHaveClass("absolute", "h-8", "w-8");
95
+ expect(newTabButton).toHaveClass("absolute", "h-8");
96
+ expect(newTabButton).toHaveStyle({ width: "2rem" });
97
+ expect(newTabButton).toHaveAttribute("data-dropdown-item", "true");
89
98
  expect(
90
99
  screen.queryByRole("button", { name: "Open Tides in a new tab" }),
91
100
  ).not.toBeInTheDocument();
92
101
 
93
102
  await user.click(newTabButton);
94
103
  expect(onSelect).toHaveBeenCalledWith(APPS[2], { newTab: true });
104
+
105
+ onSelect.mockClear();
106
+ await user.click(
107
+ document.querySelector("[data-test='switch-app-kraken']") as HTMLElement,
108
+ );
109
+ expect(onSelect).toHaveBeenCalledWith(APPS[2]);
95
110
  });
96
111
  });
@@ -2,10 +2,7 @@
2
2
 
3
3
  import Image from "next/image";
4
4
  import { useState, type ReactNode } from "react";
5
- import {
6
- NavArrowDown as ChevronDown,
7
- OpenNewWindow,
8
- } from "iconoir-react";
5
+ import { NavArrowDown as ChevronDown, OpenNewWindow } from "iconoir-react";
9
6
 
10
7
  import { cn } from "../lib/cn";
11
8
  import type {
@@ -24,11 +21,11 @@ export function appSwitcherTriggerClass(open: boolean, collapsed = false) {
24
21
  }
25
22
 
26
23
  export function appSwitcherMarkClass() {
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]";
24
+ return "h-8 w-8 shrink-0 rounded-lg";
28
25
  }
29
26
 
30
27
  export function appSwitcherMenuItemClass() {
31
- return "rounded-lg px-2 py-2 data-[highlighted]:bg-transparent hover:bg-ph-muted/80";
28
+ return "rounded-lg px-2 py-2 data-[highlighted]:bg-transparent hover:bg-ph-muted/80 focus-visible:shadow-none";
32
29
  }
33
30
 
34
31
  export function AppSwitcherMark({
@@ -152,7 +149,6 @@ export function AppSwitcher({
152
149
  <span
153
150
  className={appSwitcherTriggerClass(open, collapsed)}
154
151
  data-test="app-switcher-trigger"
155
- aria-expanded={open}
156
152
  >
157
153
  <AppSwitcherMark collapsed={collapsed}>
158
154
  {mark}
@@ -199,8 +195,7 @@ export function AppSwitcher({
199
195
  </span>
200
196
  </span>
201
197
  </DropdownItem>
202
- <button
203
- type="button"
198
+ <DropdownItem
204
199
  id={`switch-app-${app.slug}-new-tab`}
205
200
  data-test={`switch-app-${app.slug}-new-tab`}
206
201
  aria-label={`Open ${app.name} in a new tab`}
@@ -209,10 +204,11 @@ export function AppSwitcher({
209
204
  setOpen(false);
210
205
  onSelect(app, { newTab: true });
211
206
  }}
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"
207
+ className="absolute right-1.5 top-1/2 z-[1] flex h-8 -translate-y-1/2 justify-center rounded-md p-0 text-ph-mutedtext transition-colors hover:bg-ph-muted hover:text-ph-ink focus-visible:shadow-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ph-brand/35"
208
+ style={{ width: "2rem" }}
213
209
  >
214
210
  <OpenNewWindow className="h-4 w-4" aria-hidden="true" />
215
- </button>
211
+ </DropdownItem>
216
212
  </div>
217
213
  ))}
218
214
  </div>
@@ -3,6 +3,10 @@
3
3
  import { type ReactNode, type PointerEvent } from "react";
4
4
 
5
5
  import { cn } from "../lib/cn";
6
+ import {
7
+ SIDEBAR_MAX_WIDTH,
8
+ SIDEBAR_RAIL_WIDTH,
9
+ } from "../lib/use-sidebar-width";
6
10
 
7
11
  export interface SuiteAppLayoutProps {
8
12
  sidebar: ReactNode;
@@ -54,6 +58,7 @@ export function SuiteAppLayout({
54
58
  )}
55
59
  >
56
60
  <aside
61
+ aria-label="Sidebar"
57
62
  className="relative hidden shrink-0 flex-col overflow-visible border-r border-ph-border ease-out lg:flex"
58
63
  style={{ width: `${sidebarWidth}px` }}
59
64
  data-collapsed={collapsed ? "true" : "false"}
@@ -65,6 +70,10 @@ export function SuiteAppLayout({
65
70
  role="separator"
66
71
  aria-orientation="vertical"
67
72
  aria-label="Resize sidebar"
73
+ aria-valuenow={sidebarWidth}
74
+ aria-valuemin={SIDEBAR_RAIL_WIDTH}
75
+ aria-valuemax={SIDEBAR_MAX_WIDTH}
76
+ aria-valuetext={`${sidebarWidth} pixels`}
68
77
  data-test="sidebar-resize-handle"
69
78
  tabIndex={onResizeBy ? 0 : undefined}
70
79
  onPointerDown={onStartResize}
@@ -81,7 +90,7 @@ export function SuiteAppLayout({
81
90
  onResizeBy?.(16);
82
91
  }
83
92
  }}
84
- className="absolute inset-y-0 right-0 z-20 w-2 cursor-col-resize touch-none transition-colors hover:bg-[color-mix(in_oklab,var(--ph-accent)_30%,transparent)]"
93
+ className="absolute inset-y-0 right-0 z-20 w-2 cursor-col-resize touch-none transition-colors before:absolute before:-left-2 before:-right-2 before:inset-y-0 before:content-[''] hover:bg-[color-mix(in_oklab,var(--ph-accent)_30%,transparent)]"
85
94
  />
86
95
  ) : null}
87
96
  </aside>
@@ -92,10 +101,10 @@ export function SuiteAppLayout({
92
101
  ) : null}
93
102
  <main
94
103
  id="main-content"
95
- tabIndex={-1}
104
+ tabIndex={0}
96
105
  data-lock-scroll={lockMainScroll ? "true" : undefined}
97
106
  className={cn(
98
- "flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overscroll-contain bg-ph-canvas focus:outline-none",
107
+ "flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overscroll-contain bg-ph-canvas focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ph-focus",
99
108
  "suite-app-layout__main",
100
109
  lockMainScroll ? "overflow-hidden" : "overflow-y-auto",
101
110
  )}
@@ -40,7 +40,6 @@ export function SuiteBottomNav({
40
40
  className?: string;
41
41
  }) {
42
42
  if (process.env.NODE_ENV !== "production" && items.length > 5) {
43
- // eslint-disable-next-line no-console
44
43
  console.warn(
45
44
  `SuiteBottomNav: ${items.length} items — bottom nav should have at most 5 (Hick's Law).`,
46
45
  );
@@ -0,0 +1,71 @@
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { SuiteClientsProjectsDirectory } from "@/suite/components/suite-clients-projects-directory";
5
+
6
+ describe("SuiteClientsProjectsDirectory", () => {
7
+ afterEach(cleanup);
8
+
9
+ it("uses roving focus and arrow keys for the icon radiogroup", async () => {
10
+ const user = userEvent.setup();
11
+
12
+ render(
13
+ <SuiteClientsProjectsDirectory
14
+ clients={[]}
15
+ projects={[]}
16
+ canEdit
17
+ onCreateClient={vi.fn()}
18
+ />,
19
+ );
20
+
21
+ await user.click(screen.getByRole("button", { name: "New client" }));
22
+ const castle = screen.getByRole("radio", { name: "Castle" });
23
+ const castleFlag = screen.getByRole("radio", { name: "Castle flag" });
24
+
25
+ expect(castle).toHaveAttribute("tabindex", "0");
26
+ expect(castleFlag).toHaveAttribute("tabindex", "-1");
27
+
28
+ castle.focus();
29
+ await user.keyboard("{ArrowRight}");
30
+ expect(castleFlag).toHaveAttribute("aria-checked", "true");
31
+ expect(castleFlag).toHaveFocus();
32
+
33
+ await user.keyboard("{Home}");
34
+ expect(screen.getByRole("radio", { name: "Arena" })).toHaveFocus();
35
+ });
36
+
37
+ it("moves focus between the mobile client list and detail view", async () => {
38
+ const user = userEvent.setup();
39
+ const originalMatchMedia = window.matchMedia;
40
+ Object.defineProperty(window, "matchMedia", {
41
+ configurable: true,
42
+ value: vi.fn().mockReturnValue({ matches: true }),
43
+ });
44
+
45
+ try {
46
+ render(
47
+ <SuiteClientsProjectsDirectory
48
+ clients={[{ id: "acme", name: "Acme" }]}
49
+ projects={[]}
50
+ />,
51
+ );
52
+
53
+ await user.click(screen.getByRole("option", { name: /Acme/ }));
54
+ await waitFor(() =>
55
+ expect(
56
+ screen.getByRole("button", { name: "Back to clients" }),
57
+ ).toHaveFocus(),
58
+ );
59
+
60
+ await user.click(screen.getByRole("button", { name: "Back to clients" }));
61
+ await waitFor(() =>
62
+ expect(screen.getByRole("option", { name: /Acme/ })).toHaveFocus(),
63
+ );
64
+ } finally {
65
+ Object.defineProperty(window, "matchMedia", {
66
+ configurable: true,
67
+ value: originalMatchMedia,
68
+ });
69
+ }
70
+ });
71
+ });