@agent-native/toolkit 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/chat-history.css +2 -2
  2. package/dist/composer/PromptComposer.d.ts.map +1 -1
  3. package/dist/composer/PromptComposer.js +1 -24
  4. package/dist/composer/PromptComposer.js.map +1 -1
  5. package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
  6. package/dist/composer/RealtimeVoiceMode.js +59 -11
  7. package/dist/composer/RealtimeVoiceMode.js.map +1 -1
  8. package/dist/composer/RealtimeVoiceMode.spec.js +68 -7
  9. package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -1
  10. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  11. package/dist/composer/useRealtimeVoiceMode.js +32 -0
  12. package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
  13. package/dist/composer/useRealtimeVoiceMode.spec.js +25 -2
  14. package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -1
  15. package/dist/conformance/conformance.spec.js +30 -1
  16. package/dist/conformance/conformance.spec.js.map +1 -1
  17. package/dist/conformance/runner.d.ts.map +1 -1
  18. package/dist/conformance/runner.js +75 -1
  19. package/dist/conformance/runner.js.map +1 -1
  20. package/dist/editor/SharedRichEditor.d.ts.map +1 -1
  21. package/dist/editor/SharedRichEditor.js +3 -2
  22. package/dist/editor/SharedRichEditor.js.map +1 -1
  23. package/dist/editor/SlashCommandMenu.d.ts +8 -0
  24. package/dist/editor/SlashCommandMenu.d.ts.map +1 -1
  25. package/dist/editor/SlashCommandMenu.js +6 -0
  26. package/dist/editor/SlashCommandMenu.js.map +1 -1
  27. package/dist/editor/SlashCommandMenu.spec.d.ts +2 -0
  28. package/dist/editor/SlashCommandMenu.spec.d.ts.map +1 -0
  29. package/dist/editor/SlashCommandMenu.spec.js +20 -0
  30. package/dist/editor/SlashCommandMenu.spec.js.map +1 -0
  31. package/dist/editor/index.d.ts +1 -1
  32. package/dist/editor/index.d.ts.map +1 -1
  33. package/dist/editor/index.js +1 -1
  34. package/dist/editor/index.js.map +1 -1
  35. package/dist/styles.css +197 -21
  36. package/package.json +1 -1
  37. package/src/chat-history.css +2 -2
  38. package/src/composer/PromptComposer.tsx +0 -27
  39. package/src/composer/RealtimeVoiceMode.spec.tsx +131 -9
  40. package/src/composer/RealtimeVoiceMode.tsx +124 -31
  41. package/src/composer/useRealtimeVoiceMode.spec.ts +28 -2
  42. package/src/composer/useRealtimeVoiceMode.tsx +30 -0
  43. package/src/conformance/conformance.spec.tsx +61 -1
  44. package/src/conformance/runner.tsx +153 -1
  45. package/src/editor/SharedRichEditor.tsx +13 -2
  46. package/src/editor/SlashCommandMenu.spec.ts +33 -0
  47. package/src/editor/SlashCommandMenu.tsx +20 -0
  48. package/src/editor/index.ts +2 -0
  49. package/src/styles.css +197 -21
@@ -10,10 +10,12 @@ import {
10
10
  import {
11
11
  IconAlertTriangle,
12
12
  IconBrain,
13
+ IconKey,
13
14
  IconLanguage,
14
15
  IconLoader2,
15
16
  IconMicrophone,
16
17
  IconPhoneOff,
18
+ IconPlugConnected,
17
19
  IconSettings,
18
20
  IconVolume,
19
21
  } from "@tabler/icons-react";
@@ -225,72 +227,90 @@ export function RealtimeVoiceModeEntry({
225
227
  collisionPadding={16}
226
228
  data-collision-boundary={collisionBoundary ? "agent-panel" : "viewport"}
227
229
  className={cn(
228
- setupRequired ? "p-4" : "p-3",
230
+ setupRequired ? "p-3.5" : "p-3",
229
231
  setupRequired
230
- ? "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,30rem),30rem)]"
232
+ ? "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,24rem),24rem)]"
231
233
  : "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,18rem),18rem)]",
232
234
  )}
233
235
  aria-labelledby={titleId}
234
236
  aria-describedby={setupRequired ? descriptionId : undefined}
235
237
  >
236
- <div className={cn("grid", setupRequired ? "gap-3" : "gap-2.5")}>
237
- <div className="grid gap-1">
238
- <h2 id={titleId} className="text-sm font-semibold text-foreground">
239
- {setupRequired ? copy.setupTitle : copy.promptTitle}
240
- </h2>
238
+ <div className={cn("grid", setupRequired ? "gap-3.5" : "gap-2.5")}>
239
+ <div
240
+ className={cn(
241
+ setupRequired ? "flex items-start gap-3" : "grid gap-1",
242
+ )}
243
+ >
241
244
  {setupRequired ? (
242
- <p
243
- id={descriptionId}
244
- className="text-sm leading-relaxed text-muted-foreground"
245
- >
246
- {copy.setupDescription}
247
- </p>
245
+ <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
246
+ <IconMicrophone aria-hidden="true" />
247
+ </div>
248
248
  ) : null}
249
+ <div className="grid gap-1">
250
+ <h2
251
+ id={titleId}
252
+ className="text-sm font-semibold leading-5 text-foreground"
253
+ >
254
+ {setupRequired ? copy.setupTitle : copy.promptTitle}
255
+ </h2>
256
+ {setupRequired ? (
257
+ <p
258
+ id={descriptionId}
259
+ className="text-xs leading-5 text-muted-foreground"
260
+ >
261
+ {copy.setupDescription}
262
+ </p>
263
+ ) : null}
264
+ </div>
249
265
  </div>
250
266
 
251
267
  <div
252
268
  className={cn(
253
- "gap-2",
254
- setupRequired
255
- ? "flex flex-col-reverse sm:flex-row sm:flex-wrap sm:justify-end"
256
- : "grid",
269
+ "grid gap-2",
270
+ setupRequired ? "rounded-lg bg-muted/30 p-1" : null,
257
271
  )}
258
272
  >
259
273
  {setupRequired ? (
260
274
  <>
261
275
  <Button
262
276
  type="button"
263
- variant="ghost"
264
277
  size="sm"
265
- onClick={() => choose("dictation", onKeepDictating)}
278
+ className="w-full justify-start px-3"
279
+ disabled={connectingBuilder}
280
+ onClick={() =>
281
+ choose("realtime", onConnectBuilder ?? onStartVoiceMode)
282
+ }
266
283
  >
267
- {copy.keepDictating}
284
+ {connectingBuilder ? (
285
+ <IconLoader2 className="animate-spin" />
286
+ ) : (
287
+ <IconPlugConnected aria-hidden="true" />
288
+ )}
289
+ {copy.connectBuilder}
268
290
  </Button>
269
291
  <Button
270
292
  type="button"
271
293
  variant="outline"
272
294
  size="sm"
295
+ className="w-full justify-start bg-background px-3"
273
296
  onClick={() =>
274
297
  choose("realtime", onUseOpenAiKey ?? onStartVoiceMode)
275
298
  }
276
299
  >
300
+ <IconKey aria-hidden="true" />
277
301
  {openAiConfigured
278
302
  ? copy.startWithOpenAiKey
279
303
  : copy.useOpenAiKey}
280
304
  </Button>
281
305
  <Button
282
306
  type="button"
307
+ variant="ghost"
283
308
  size="sm"
284
- className="whitespace-nowrap"
285
- disabled={connectingBuilder}
286
- onClick={() =>
287
- choose("realtime", onConnectBuilder ?? onStartVoiceMode)
288
- }
309
+ className="w-full justify-start px-3 text-muted-foreground"
310
+ onClick={() => choose("dictation", onKeepDictating)}
289
311
  >
290
- {connectingBuilder ? (
291
- <IconLoader2 className="animate-spin" />
292
- ) : null}
293
- {copy.connectBuilder}
312
+ <IconMicrophone aria-hidden="true" />
313
+ {copy.keepDictating}
294
314
  </Button>
295
315
  </>
296
316
  ) : (
@@ -382,9 +402,14 @@ export interface RealtimeVoiceModeInlineSettings {
382
402
  const SILENT_AUDIO_LEVELS = createRealtimeVoiceAudioLevelStore();
383
403
  const WAVEFORM_WEIGHTS = [0.55, 0.82, 1, 0.82, 0.55];
384
404
  const AUDIO_ACTIVITY_THRESHOLD = 0.1;
405
+ const WORKING_GLOW_HOLD_MS = 800;
385
406
 
386
407
  function usePrefersReducedMotion(): boolean {
387
- const [reduced, setReduced] = useState(false);
408
+ const [reduced, setReduced] = useState(
409
+ () =>
410
+ typeof window !== "undefined" &&
411
+ Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches),
412
+ );
388
413
  useEffect(() => {
389
414
  if (typeof window === "undefined" || !window.matchMedia) return;
390
415
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
@@ -697,6 +722,9 @@ export function RealtimeVoiceModeDock({
697
722
  const controlsId = useId();
698
723
  const [controlsOpen, setControlsOpen] = useState(false);
699
724
  const [settingsOpen, setSettingsOpen] = useState(false);
725
+ const [workingGlowHeld, setWorkingGlowHeld] = useState(state === "working");
726
+ const glowRef = useRef<HTMLSpanElement>(null);
727
+ const glowAnimationRef = useRef<Animation | null>(null);
700
728
  const selectInteractionRef = useRef(false);
701
729
  const selectInteractionFrameRef = useRef<number | null>(null);
702
730
  const levels = useSyncExternalStore(
@@ -724,6 +752,59 @@ export function RealtimeVoiceModeDock({
724
752
  const errorDetailVisible = state === "error" && Boolean(errorMessage);
725
753
  const controlsVisible = controlsOpen || settingsOpen;
726
754
  const chatPanelTranslation = useChatPanelTranslation(chatVisible);
755
+ const workingGlowActive = state === "working" || workingGlowHeld;
756
+
757
+ useEffect(() => {
758
+ if (state === "working") {
759
+ setWorkingGlowHeld(true);
760
+ return;
761
+ }
762
+ if (!workingGlowHeld) return;
763
+ const timer = window.setTimeout(
764
+ () => setWorkingGlowHeld(false),
765
+ WORKING_GLOW_HOLD_MS,
766
+ );
767
+ return () => window.clearTimeout(timer);
768
+ }, [state, workingGlowHeld]);
769
+
770
+ useEffect(() => {
771
+ if (!connected || reducedMotion || !glowRef.current) {
772
+ glowAnimationRef.current?.cancel();
773
+ glowAnimationRef.current = null;
774
+ return;
775
+ }
776
+
777
+ const animation = glowRef.current.animate(
778
+ [
779
+ { "--agent-realtime-voice-angle": "0deg" },
780
+ { "--agent-realtime-voice-angle": "360deg" },
781
+ ] as Keyframe[],
782
+ {
783
+ duration: 12_000,
784
+ easing: "linear",
785
+ iterations: Number.POSITIVE_INFINITY,
786
+ },
787
+ );
788
+ glowAnimationRef.current = animation;
789
+
790
+ return () => {
791
+ animation.cancel();
792
+ if (glowAnimationRef.current === animation) {
793
+ glowAnimationRef.current = null;
794
+ }
795
+ };
796
+ }, [connected, reducedMotion]);
797
+
798
+ useEffect(() => {
799
+ const animation = glowAnimationRef.current;
800
+ if (!animation) return;
801
+ const playbackRate = workingGlowActive ? 2.4 : 1;
802
+ if (typeof animation.updatePlaybackRate === "function") {
803
+ animation.updatePlaybackRate(playbackRate);
804
+ } else {
805
+ animation.playbackRate = playbackRate;
806
+ }
807
+ }, [workingGlowActive]);
727
808
 
728
809
  const handleSelectOpenChange = useCallback((open: boolean) => {
729
810
  if (selectInteractionFrameRef.current !== null) {
@@ -900,9 +981,21 @@ export function RealtimeVoiceModeDock({
900
981
  className={cn(
901
982
  "relative isolate size-16 overflow-visible rounded-full ring-1 backdrop-blur-xl transition-transform duration-150 ease-out focus-visible:ring-offset-2 active:scale-[0.97] motion-reduce:transition-none",
902
983
  ORB_STATE_CLASSES[state],
903
- state === "working" && "agent-realtime-voice-working",
904
984
  )}
905
985
  >
986
+ {connected ? (
987
+ <span
988
+ ref={glowRef}
989
+ aria-hidden="true"
990
+ data-realtime-voice-glow="true"
991
+ className={cn(
992
+ "agent-realtime-voice-glow",
993
+ workingGlowActive && "agent-realtime-voice-working",
994
+ )}
995
+ >
996
+ <span className="agent-realtime-voice-edge-light" />
997
+ </span>
998
+ ) : null}
906
999
  <span className="relative z-10 flex items-center justify-center">
907
1000
  {state === "connecting" ? (
908
1001
  <VoiceConnectingIndicator />
@@ -522,7 +522,7 @@ describe("Realtime voice dynamic tool manifests", () => {
522
522
  });
523
523
 
524
524
  describe("extractRealtimeVoiceFunctionCalls", () => {
525
- it("waits for the terminal response event before returning a function call", () => {
525
+ it("returns a function call as soon as its final arguments arrive", () => {
526
526
  expect(
527
527
  extractRealtimeVoiceFunctionCalls({
528
528
  type: "response.function_call_arguments.done",
@@ -530,7 +530,13 @@ describe("extractRealtimeVoiceFunctionCalls", () => {
530
530
  call_id: "call-1",
531
531
  arguments: '{"path":"/inbox"}',
532
532
  }),
533
- ).toEqual([]);
533
+ ).toEqual([
534
+ {
535
+ name: "navigate",
536
+ callId: "call-1",
537
+ argumentsText: '{"path":"/inbox"}',
538
+ },
539
+ ]);
534
540
  });
535
541
 
536
542
  it("falls back to completed function items on response.done", () => {
@@ -559,6 +565,26 @@ describe("extractRealtimeVoiceFunctionCalls", () => {
559
565
  ]);
560
566
  });
561
567
 
568
+ it("accepts a completed function output item as a provider fallback", () => {
569
+ expect(
570
+ extractRealtimeVoiceFunctionCalls({
571
+ type: "response.output_item.done",
572
+ item: {
573
+ type: "function_call",
574
+ name: "navigate",
575
+ call_id: "call-output-item",
576
+ arguments: '{"path":"/todos"}',
577
+ },
578
+ }),
579
+ ).toEqual([
580
+ {
581
+ name: "navigate",
582
+ callId: "call-output-item",
583
+ argumentsText: '{"path":"/todos"}',
584
+ },
585
+ ]);
586
+ });
587
+
562
588
  it("ignores function items from failed or cancelled responses", () => {
563
589
  const output = [
564
590
  {
@@ -1005,6 +1005,36 @@ export function createRealtimeVoiceToolManifestCoordinator(
1005
1005
  export function extractRealtimeVoiceFunctionCalls(
1006
1006
  event: RealtimeServerEvent,
1007
1007
  ): Array<{ name: string; callId: string; argumentsText: string }> {
1008
+ if (event.type === "response.function_call_arguments.done") {
1009
+ const name = typeof event.name === "string" ? event.name : "";
1010
+ const callId = typeof event.call_id === "string" ? event.call_id : "";
1011
+ if (!name || !callId) return [];
1012
+ return [
1013
+ {
1014
+ name,
1015
+ callId,
1016
+ argumentsText:
1017
+ typeof event.arguments === "string" ? event.arguments : "{}",
1018
+ },
1019
+ ];
1020
+ }
1021
+ if (event.type === "response.output_item.done") {
1022
+ const item = event.item;
1023
+ if (!item || typeof item !== "object") return [];
1024
+ const record = item as Record<string, unknown>;
1025
+ if (record.type !== "function_call") return [];
1026
+ const name = typeof record.name === "string" ? record.name : "";
1027
+ const callId = typeof record.call_id === "string" ? record.call_id : "";
1028
+ if (!name || !callId) return [];
1029
+ return [
1030
+ {
1031
+ name,
1032
+ callId,
1033
+ argumentsText:
1034
+ typeof record.arguments === "string" ? record.arguments : "{}",
1035
+ },
1036
+ ];
1037
+ }
1008
1038
  if (event.type !== "response.done") return [];
1009
1039
  const response = event.response;
1010
1040
  if (!response || typeof response !== "object") return [];
@@ -3,7 +3,10 @@
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import { defaultDesignSystemComponents } from "../design-system/default-adapter.js";
6
- import { DESIGN_SYSTEM_CONTRACT_VERSION } from "../design-system/types.js";
6
+ import {
7
+ DESIGN_SYSTEM_CONTRACT_VERSION,
8
+ type DesignSystemComponents,
9
+ } from "../design-system/types.js";
7
10
  import { cssInJsFixtureAdapter } from "./__fixtures__/css-in-js-adapter.js";
8
11
  import {
9
12
  assertDesignSystemConformance,
@@ -80,4 +83,61 @@ describe("design-system conformance kit", () => {
80
83
  });
81
84
  expect(report.results.filter((result) => !result.passed)).toEqual([]);
82
85
  });
86
+
87
+ it("rejects tabs that stack an icon above its label", async () => {
88
+ const stackedTabsAdapter: DesignSystemComponents = {
89
+ ...cssInJsFixtureAdapter,
90
+ Tabs: ({ items, value, onChange, orientation }) => (
91
+ <div>
92
+ <div role="tablist" aria-orientation={orientation}>
93
+ {items.map((item) => (
94
+ <button
95
+ key={String(item.value)}
96
+ role="tab"
97
+ aria-selected={item.value === value}
98
+ style={{ display: "flex", flexDirection: "column" }}
99
+ onClick={() => onChange(item.value)}
100
+ >
101
+ {item.icon}
102
+ {item.label}
103
+ </button>
104
+ ))}
105
+ </div>
106
+ {items.find((item) => item.value === value)?.content}
107
+ </div>
108
+ ),
109
+ };
110
+ const report = await runDesignSystemConformance({
111
+ adapterName: "stacked-tabs fixture",
112
+ components: stackedTabsAdapter,
113
+ contractVersion: DESIGN_SYSTEM_CONTRACT_VERSION,
114
+ });
115
+ const tabsResult = report.results.find(
116
+ (result) => result.id === "behavior.tabs",
117
+ );
118
+ expect(tabsResult?.passed).toBe(false);
119
+ expect(tabsResult?.message).toContain("same row");
120
+ });
121
+
122
+ it("rejects dialogs that render footer controls outside the dialog", async () => {
123
+ const detachedFooterAdapter: DesignSystemComponents = {
124
+ ...cssInJsFixtureAdapter,
125
+ Dialog: ({ footer, ...props }) => (
126
+ <>
127
+ <cssInJsFixtureAdapter.Dialog {...props} />
128
+ {footer}
129
+ </>
130
+ ),
131
+ };
132
+ const report = await runDesignSystemConformance({
133
+ adapterName: "detached-footer fixture",
134
+ components: detachedFooterAdapter,
135
+ contractVersion: DESIGN_SYSTEM_CONTRACT_VERSION,
136
+ });
137
+ const dialogResult = report.results.find(
138
+ (result) => result.id === "behavior.dialog",
139
+ );
140
+ expect(dialogResult?.passed).toBe(false);
141
+ expect(dialogResult?.message).toContain("footer");
142
+ });
83
143
  });
@@ -122,6 +122,87 @@ function elementWithText(document: Document, selector: string, text: string) {
122
122
  );
123
123
  }
124
124
 
125
+ function assertInlineLayout(
126
+ container: Element,
127
+ iconSelector: string,
128
+ labelSelector: string,
129
+ message: string,
130
+ ) {
131
+ const icon = container.querySelector<HTMLElement>(iconSelector);
132
+ const label = container.querySelector<HTMLElement>(labelSelector);
133
+ invariant(icon && label, message);
134
+
135
+ const iconRect = icon.getBoundingClientRect();
136
+ const labelRect = label.getBoundingClientRect();
137
+ const hasGeometry =
138
+ iconRect.width > 0 ||
139
+ iconRect.height > 0 ||
140
+ labelRect.width > 0 ||
141
+ labelRect.height > 0;
142
+ if (hasGeometry) {
143
+ const verticalOverlap =
144
+ Math.min(iconRect.bottom, labelRect.bottom) -
145
+ Math.max(iconRect.top, labelRect.top);
146
+ invariant(
147
+ verticalOverlap > 0 || Math.abs(iconRect.top - labelRect.top) <= 2,
148
+ message,
149
+ );
150
+ return;
151
+ }
152
+
153
+ // happy-dom does not calculate layout. Still reject adapters that explicitly
154
+ // stack the tab contents, while browser-backed runs use the geometry check.
155
+ const commonAncestor = (() => {
156
+ let candidate: Element | null = icon;
157
+ while (candidate && !candidate.contains(label)) {
158
+ candidate = candidate.parentElement;
159
+ }
160
+ return candidate;
161
+ })();
162
+ const styles = [
163
+ container,
164
+ commonAncestor,
165
+ icon.parentElement,
166
+ label.parentElement,
167
+ ]
168
+ .filter((element): element is Element => Boolean(element))
169
+ .map((element) =>
170
+ element.ownerDocument.defaultView?.getComputedStyle(element),
171
+ );
172
+ invariant(
173
+ !styles.some(
174
+ (style) => style?.display === "flex" && style.flexDirection === "column",
175
+ ),
176
+ message,
177
+ );
178
+ }
179
+
180
+ function assertContainedFooter(dialog: Element, footerSelector: string) {
181
+ const footer = dialog.querySelector<HTMLElement>(footerSelector);
182
+ invariant(
183
+ footer,
184
+ "Dialog footer controls must be rendered inside the dialog surface.",
185
+ );
186
+
187
+ const dialogRect = (dialog as HTMLElement).getBoundingClientRect();
188
+ const footerRect = footer.getBoundingClientRect();
189
+ const hasGeometry =
190
+ dialogRect.width > 0 ||
191
+ dialogRect.height > 0 ||
192
+ footerRect.width > 0 ||
193
+ footerRect.height > 0;
194
+ if (!hasGeometry) return;
195
+
196
+ const epsilon = 2;
197
+ invariant(
198
+ footerRect.top >= dialogRect.top - epsilon &&
199
+ footerRect.bottom <= dialogRect.bottom + epsilon &&
200
+ footerRect.left >= dialogRect.left - epsilon &&
201
+ footerRect.right <= dialogRect.right + epsilon,
202
+ "Dialog footer controls must remain within the dialog surface bounds.",
203
+ );
204
+ }
205
+
125
206
  const checks: readonly ConformanceCheck[] = [
126
207
  {
127
208
  id: "contract.components",
@@ -308,6 +389,21 @@ const checks: readonly ConformanceCheck[] = [
308
389
  "Tooltip must honor controlled open and portalContainer.",
309
390
  );
310
391
  unmount(probe);
392
+
393
+ const defaultOpenProbe = mount(
394
+ <components.Tooltip
395
+ trigger={<button>Help by default</button>}
396
+ content="Default tooltip"
397
+ defaultOpen
398
+ portalContainer={portal}
399
+ />,
400
+ );
401
+ await settle();
402
+ invariant(
403
+ portal.textContent?.includes("Default tooltip"),
404
+ "Tooltip must honor defaultOpen and portalContainer.",
405
+ );
406
+ unmount(defaultOpenProbe);
311
407
  portal.remove();
312
408
  },
313
409
  },
@@ -331,6 +427,21 @@ const checks: readonly ConformanceCheck[] = [
331
427
  click(item, document);
332
428
  invariant(selected === "archive", "Menu must report selected item ids.");
333
429
  unmount(probe);
430
+
431
+ const defaultOpenProbe = mount(
432
+ <components.Menu
433
+ trigger={<button>Default actions</button>}
434
+ items={[{ id: "restore", label: "Restore" }]}
435
+ defaultOpen
436
+ onAction={() => {}}
437
+ />,
438
+ );
439
+ await settle();
440
+ invariant(
441
+ elementWithText(document, '[role="menuitem"]', "Restore"),
442
+ "Menu must honor defaultOpen.",
443
+ );
444
+ unmount(defaultOpenProbe);
334
445
  },
335
446
  },
336
447
  {
@@ -393,6 +504,30 @@ const checks: readonly ConformanceCheck[] = [
393
504
  "Dialog must not invent actions when footer is omitted.",
394
505
  );
395
506
  unmount(probe);
507
+
508
+ const footerProbe = mount(
509
+ <components.Dialog
510
+ open
511
+ onOpenChange={() => {}}
512
+ title="Footer probe"
513
+ portalContainer={portal}
514
+ footer={
515
+ <button data-conformance-dialog-footer-action="true" type="button">
516
+ Save
517
+ </button>
518
+ }
519
+ >
520
+ Footer content
521
+ </components.Dialog>,
522
+ );
523
+ await settle();
524
+ const footerDialog = portal.querySelector('[role="dialog"]');
525
+ invariant(footerDialog, "Dialog footer probe did not render a dialog.");
526
+ assertContainedFooter(
527
+ footerDialog,
528
+ '[data-conformance-dialog-footer-action="true"]',
529
+ );
530
+ unmount(footerProbe);
396
531
  portal.remove();
397
532
  },
398
533
  },
@@ -466,7 +601,16 @@ const checks: readonly ConformanceCheck[] = [
466
601
  orientation="vertical"
467
602
  onChange={(next) => (value = next)}
468
603
  items={[
469
- { value: "first", label: "First", content: "One" },
604
+ {
605
+ value: "first",
606
+ label: <span data-conformance-tab-label="true">First</span>,
607
+ icon: (
608
+ <span data-conformance-tab-icon="true" aria-hidden="true">
609
+
610
+ </span>
611
+ ),
612
+ content: "One",
613
+ },
470
614
  { value: "second", label: "Second", content: "Two" },
471
615
  ]}
472
616
  />,
@@ -477,6 +621,14 @@ const checks: readonly ConformanceCheck[] = [
477
621
  tabList.getAttribute("aria-orientation") === "vertical",
478
622
  "Tabs must honor vertical orientation in their tablist semantics.",
479
623
  );
624
+ const first = elementWithText(document, '[role="tab"]', "First");
625
+ invariant(first, "Tabs must expose the icon-bearing tab.");
626
+ assertInlineLayout(
627
+ first,
628
+ '[data-conformance-tab-icon="true"]',
629
+ '[data-conformance-tab-label="true"]',
630
+ "Tabs must lay out an icon and its label on the same row.",
631
+ );
480
632
  const second = elementWithText(document, '[role="tab"]', "Second");
481
633
  invariant(second, "Tabs must expose tab semantics.");
482
634
  click(second, document);
@@ -14,7 +14,12 @@ import {
14
14
  type SharedEditorFeatures,
15
15
  } from "./extensions.js";
16
16
  import type { ImageUploadFn } from "./ImageExtension.js";
17
- import { SlashCommandMenu, type SlashCommandItem } from "./SlashCommandMenu.js";
17
+ import {
18
+ DEFAULT_SLASH_COMMANDS,
19
+ filterSlashCommandItems,
20
+ SlashCommandMenu,
21
+ type SlashCommandItem,
22
+ } from "./SlashCommandMenu.js";
18
23
  import {
19
24
  useCollabReconcile,
20
25
  getEditorMarkdown,
@@ -200,6 +205,12 @@ export function SharedRichEditor({
200
205
 
201
206
  const collab = !!ydoc;
202
207
 
208
+ const effectiveSlashItems = useMemo(
209
+ () =>
210
+ filterSlashCommandItems(slashItems ?? DEFAULT_SLASH_COMMANDS, features),
211
+ [features, slashItems],
212
+ );
213
+
203
214
  // The collab hook needs the editor, but useEditor's `onUpdate` needs the
204
215
  // hook's guards. Break the cycle with a ref: `onUpdate` reads the guards
205
216
  // through `guardsRef`, which is populated right after the hook runs below.
@@ -313,7 +324,7 @@ export function SharedRichEditor({
313
324
  <BubbleToolbar editor={editor} buildItems={buildBubbleItems} />
314
325
  ) : null}
315
326
  {editable ? (
316
- <SlashCommandMenu editor={editor} items={slashItems} />
327
+ <SlashCommandMenu editor={editor} items={effectiveSlashItems} />
317
328
  ) : null}
318
329
  <EditorContent editor={editor} />
319
330
  </div>
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ DEFAULT_SLASH_COMMANDS,
5
+ filterSlashCommandItems,
6
+ } from "./SlashCommandMenu.js";
7
+
8
+ describe("filterSlashCommandItems", () => {
9
+ it("hides commands whose editor extensions are disabled", () => {
10
+ const commands = filterSlashCommandItems(DEFAULT_SLASH_COMMANDS, {
11
+ codeBlock: false,
12
+ tables: false,
13
+ tasks: false,
14
+ });
15
+
16
+ expect(commands.map((command) => command.title)).not.toEqual(
17
+ expect.arrayContaining(["Code block", "Table", "To-do list"]),
18
+ );
19
+ });
20
+
21
+ it("keeps commands enabled when their feature is unspecified", () => {
22
+ const commands = filterSlashCommandItems(DEFAULT_SLASH_COMMANDS, {
23
+ tasks: false,
24
+ });
25
+
26
+ expect(commands.map((command) => command.title)).toEqual(
27
+ expect.arrayContaining(["Code block", "Table"]),
28
+ );
29
+ expect(commands.map((command) => command.title)).not.toContain(
30
+ "To-do list",
31
+ );
32
+ });
33
+ });