@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.
- package/dist/chat-history.css +2 -2
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/PromptComposer.js +1 -24
- package/dist/composer/PromptComposer.js.map +1 -1
- package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/RealtimeVoiceMode.js +59 -11
- package/dist/composer/RealtimeVoiceMode.js.map +1 -1
- package/dist/composer/RealtimeVoiceMode.spec.js +68 -7
- package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.js +32 -0
- package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.spec.js +25 -2
- package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -1
- package/dist/conformance/conformance.spec.js +30 -1
- package/dist/conformance/conformance.spec.js.map +1 -1
- package/dist/conformance/runner.d.ts.map +1 -1
- package/dist/conformance/runner.js +75 -1
- package/dist/conformance/runner.js.map +1 -1
- package/dist/editor/SharedRichEditor.d.ts.map +1 -1
- package/dist/editor/SharedRichEditor.js +3 -2
- package/dist/editor/SharedRichEditor.js.map +1 -1
- package/dist/editor/SlashCommandMenu.d.ts +8 -0
- package/dist/editor/SlashCommandMenu.d.ts.map +1 -1
- package/dist/editor/SlashCommandMenu.js +6 -0
- package/dist/editor/SlashCommandMenu.js.map +1 -1
- package/dist/editor/SlashCommandMenu.spec.d.ts +2 -0
- package/dist/editor/SlashCommandMenu.spec.d.ts.map +1 -0
- package/dist/editor/SlashCommandMenu.spec.js +20 -0
- package/dist/editor/SlashCommandMenu.spec.js.map +1 -0
- package/dist/editor/index.d.ts +1 -1
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js +1 -1
- package/dist/editor/index.js.map +1 -1
- package/dist/styles.css +197 -21
- package/package.json +1 -1
- package/src/chat-history.css +2 -2
- package/src/composer/PromptComposer.tsx +0 -27
- package/src/composer/RealtimeVoiceMode.spec.tsx +131 -9
- package/src/composer/RealtimeVoiceMode.tsx +124 -31
- package/src/composer/useRealtimeVoiceMode.spec.ts +28 -2
- package/src/composer/useRealtimeVoiceMode.tsx +30 -0
- package/src/conformance/conformance.spec.tsx +61 -1
- package/src/conformance/runner.tsx +153 -1
- package/src/editor/SharedRichEditor.tsx +13 -2
- package/src/editor/SlashCommandMenu.spec.ts +33 -0
- package/src/editor/SlashCommandMenu.tsx +20 -0
- package/src/editor/index.ts +2 -0
- 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-
|
|
230
|
+
setupRequired ? "p-3.5" : "p-3",
|
|
229
231
|
setupRequired
|
|
230
|
-
? "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
278
|
+
className="w-full justify-start px-3"
|
|
279
|
+
disabled={connectingBuilder}
|
|
280
|
+
onClick={() =>
|
|
281
|
+
choose("realtime", onConnectBuilder ?? onStartVoiceMode)
|
|
282
|
+
}
|
|
266
283
|
>
|
|
267
|
-
{
|
|
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="
|
|
285
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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(
|
|
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("
|
|
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 {
|
|
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
|
-
{
|
|
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 {
|
|
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={
|
|
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
|
+
});
|