@assistant-ui/react 0.14.24 → 0.14.26
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/legacy-runtime/hooks/AttachmentContext.d.ts +12 -12
- package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.d.ts.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.js +10 -3
- package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
- package/dist/primitives/threadListFocusGroup.d.ts +26 -0
- package/dist/primitives/threadListFocusGroup.d.ts.map +1 -0
- package/dist/primitives/threadListFocusGroup.js +15 -0
- package/dist/primitives/threadListFocusGroup.js.map +1 -0
- package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.js +45 -27
- package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js +26 -2
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +63 -38
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts +20 -3
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +95 -14
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +38 -21
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
- package/dist/utils/hooks/useMediaQuery.js +2 -2
- package/dist/utils/hooks/useMediaQuery.js.map +1 -1
- package/dist/utils/smooth/useSmooth.d.ts +4 -0
- package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +7 -1
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/dist/utils/useToolArgsFieldStatus.d.ts.map +1 -1
- package/package.json +8 -6
- package/src/primitives/messagePart/MessagePartText.tsx +1 -0
- package/src/primitives/threadList/ThreadListRoot.tsx +8 -1
- package/src/primitives/threadListFocusGroup.ts +24 -0
- package/src/primitives/threadListItem/ThreadListItemRoot.tsx +53 -5
- package/src/primitives/threadListItem/ThreadListItemTrigger.tsx +45 -0
- package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +141 -0
- package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +22 -1
- package/src/primitives/threadListItemMore/ThreadListItemMoreRoot.tsx +64 -3
- package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +29 -2
- package/src/tests/generative-ui.test.tsx +21 -1
- package/src/utils/hooks/useMediaQuery.ts +3 -2
- package/src/utils/smooth/useSmooth.test.tsx +26 -0
- package/src/utils/smooth/useSmooth.ts +7 -1
- package/src/primitives/threadListItem/ThreadListItemTrigger.ts +0 -23
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
import { useComposedRefs } from "@radix-ui/react-compose-refs";
|
|
5
|
+
import {
|
|
6
|
+
type ActionButtonElement,
|
|
7
|
+
type ActionButtonProps,
|
|
8
|
+
createActionButton,
|
|
9
|
+
} from "../../utils/createActionButton";
|
|
10
|
+
import { useThreadListItemTrigger as useThreadListItemTriggerBehavior } from "@assistant-ui/core/react";
|
|
11
|
+
import {
|
|
12
|
+
ThreadListCollection,
|
|
13
|
+
useThreadListItemFocus,
|
|
14
|
+
} from "../threadListFocusGroup";
|
|
15
|
+
|
|
16
|
+
const useThreadListItemTrigger = () => {
|
|
17
|
+
const { switchTo } = useThreadListItemTriggerBehavior();
|
|
18
|
+
return switchTo;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export namespace ThreadListItemPrimitiveTrigger {
|
|
22
|
+
export type Element = ActionButtonElement;
|
|
23
|
+
export type Props = ActionButtonProps<typeof useThreadListItemTrigger>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ThreadListItemTriggerButton = createActionButton(
|
|
27
|
+
"ThreadListItemPrimitive.Trigger",
|
|
28
|
+
useThreadListItemTrigger,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export const ThreadListItemPrimitiveTrigger = forwardRef<
|
|
32
|
+
ThreadListItemPrimitiveTrigger.Element,
|
|
33
|
+
ThreadListItemPrimitiveTrigger.Props
|
|
34
|
+
>((props, forwardedRef) => {
|
|
35
|
+
const focus = useThreadListItemFocus();
|
|
36
|
+
const ref = useComposedRefs(forwardedRef, focus?.triggerRef);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<ThreadListCollection.ItemSlot scope={undefined}>
|
|
40
|
+
<ThreadListItemTriggerButton {...props} ref={ref} />
|
|
41
|
+
</ThreadListCollection.ItemSlot>
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
ThreadListItemPrimitiveTrigger.displayName = "ThreadListItemPrimitive.Trigger";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { ThreadListPrimitiveRoot } from "../threadList/ThreadListRoot";
|
|
5
|
+
import { ThreadListItemPrimitiveRoot } from "./ThreadListItemRoot";
|
|
6
|
+
import { ThreadListItemPrimitiveTrigger } from "./ThreadListItemTrigger";
|
|
7
|
+
import { ThreadListItemMorePrimitiveRoot } from "../threadListItemMore/ThreadListItemMoreRoot";
|
|
8
|
+
import { ThreadListItemMorePrimitiveTrigger } from "../threadListItemMore/ThreadListItemMoreTrigger";
|
|
9
|
+
import { ThreadListItemMorePrimitiveContent } from "../threadListItemMore/ThreadListItemMoreContent";
|
|
10
|
+
import { ThreadListItemMorePrimitiveItem } from "../threadListItemMore/ThreadListItemMoreItem";
|
|
11
|
+
|
|
12
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import("@assistant-ui/store")>();
|
|
14
|
+
return { ...actual, useAuiState: () => false };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
vi.mock("@assistant-ui/core/react", async (importOriginal) => {
|
|
18
|
+
const actual =
|
|
19
|
+
await importOriginal<typeof import("@assistant-ui/core/react")>();
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
useThreadListItemTrigger: () => ({ switchTo: () => {} }),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const triggers = (root: HTMLElement) =>
|
|
27
|
+
Array.from(
|
|
28
|
+
root.querySelectorAll<HTMLButtonElement>("[data-radix-collection-item]"),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const moreOf = (root: HTMLElement) =>
|
|
32
|
+
root.querySelector<HTMLButtonElement>('[aria-haspopup="menu"]')!;
|
|
33
|
+
|
|
34
|
+
const renderItem = (rootProps?: { defaultOpen?: boolean }) => {
|
|
35
|
+
const result = render(
|
|
36
|
+
<ThreadListPrimitiveRoot>
|
|
37
|
+
<ThreadListItemPrimitiveRoot>
|
|
38
|
+
<ThreadListItemPrimitiveTrigger>item</ThreadListItemPrimitiveTrigger>
|
|
39
|
+
<ThreadListItemMorePrimitiveRoot sharedFocusGroup {...rootProps}>
|
|
40
|
+
<ThreadListItemMorePrimitiveTrigger>
|
|
41
|
+
more
|
|
42
|
+
</ThreadListItemMorePrimitiveTrigger>
|
|
43
|
+
<ThreadListItemMorePrimitiveContent>
|
|
44
|
+
<ThreadListItemMorePrimitiveItem>
|
|
45
|
+
Archive
|
|
46
|
+
</ThreadListItemMorePrimitiveItem>
|
|
47
|
+
</ThreadListItemMorePrimitiveContent>
|
|
48
|
+
</ThreadListItemMorePrimitiveRoot>
|
|
49
|
+
</ThreadListItemPrimitiveRoot>
|
|
50
|
+
</ThreadListPrimitiveRoot>,
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
...result,
|
|
54
|
+
trigger: triggers(result.container)[0]!,
|
|
55
|
+
more: moreOf(result.container),
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const menu = (): HTMLElement | null =>
|
|
60
|
+
document.querySelector<HTMLElement>('[role="menu"]');
|
|
61
|
+
|
|
62
|
+
describe("thread list keyboard navigation", () => {
|
|
63
|
+
it("moves focus between items with the up/down arrows", () => {
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<ThreadListPrimitiveRoot>
|
|
66
|
+
{[0, 1, 2].map((i) => (
|
|
67
|
+
<ThreadListItemPrimitiveRoot key={i}>
|
|
68
|
+
<ThreadListItemPrimitiveTrigger>
|
|
69
|
+
item {i}
|
|
70
|
+
</ThreadListItemPrimitiveTrigger>
|
|
71
|
+
</ThreadListItemPrimitiveRoot>
|
|
72
|
+
))}
|
|
73
|
+
</ThreadListPrimitiveRoot>,
|
|
74
|
+
);
|
|
75
|
+
const [first, second] = triggers(container);
|
|
76
|
+
|
|
77
|
+
first!.focus();
|
|
78
|
+
fireEvent.keyDown(first!, { key: "ArrowDown" });
|
|
79
|
+
expect(document.activeElement).toBe(second);
|
|
80
|
+
|
|
81
|
+
fireEvent.keyDown(second!, { key: "ArrowUp" });
|
|
82
|
+
expect(document.activeElement).toBe(first);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("moves between an item and its More button with right/left arrows", () => {
|
|
86
|
+
const { trigger, more } = renderItem();
|
|
87
|
+
|
|
88
|
+
trigger.focus();
|
|
89
|
+
fireEvent.keyDown(trigger, { key: "ArrowRight" });
|
|
90
|
+
expect(document.activeElement).toBe(more);
|
|
91
|
+
|
|
92
|
+
fireEvent.keyDown(more, { key: "ArrowLeft" });
|
|
93
|
+
expect(document.activeElement).toBe(trigger);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("opens the More menu on ArrowRight", () => {
|
|
97
|
+
const { more } = renderItem();
|
|
98
|
+
|
|
99
|
+
more.focus();
|
|
100
|
+
fireEvent.keyDown(more, { key: "ArrowRight" });
|
|
101
|
+
|
|
102
|
+
expect(menu()).not.toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each(["ArrowLeft", "Escape"])(
|
|
106
|
+
"closes the menu on %s and returns focus to the More button",
|
|
107
|
+
(key) => {
|
|
108
|
+
const { more } = renderItem({ defaultOpen: true });
|
|
109
|
+
|
|
110
|
+
fireEvent.keyDown(menu() as HTMLElement, { key });
|
|
111
|
+
|
|
112
|
+
expect(menu()).toBeNull();
|
|
113
|
+
expect(document.activeElement).toBe(more);
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
it("leaves the More menu's arrow keys inert without sharedFocusGroup (the default)", () => {
|
|
118
|
+
const result = render(
|
|
119
|
+
<ThreadListPrimitiveRoot>
|
|
120
|
+
<ThreadListItemPrimitiveRoot>
|
|
121
|
+
<ThreadListItemPrimitiveTrigger>item</ThreadListItemPrimitiveTrigger>
|
|
122
|
+
<ThreadListItemMorePrimitiveRoot>
|
|
123
|
+
<ThreadListItemMorePrimitiveTrigger>
|
|
124
|
+
more
|
|
125
|
+
</ThreadListItemMorePrimitiveTrigger>
|
|
126
|
+
<ThreadListItemMorePrimitiveContent>
|
|
127
|
+
<ThreadListItemMorePrimitiveItem>
|
|
128
|
+
Archive
|
|
129
|
+
</ThreadListItemMorePrimitiveItem>
|
|
130
|
+
</ThreadListItemMorePrimitiveContent>
|
|
131
|
+
</ThreadListItemMorePrimitiveRoot>
|
|
132
|
+
</ThreadListItemPrimitiveRoot>
|
|
133
|
+
</ThreadListPrimitiveRoot>,
|
|
134
|
+
);
|
|
135
|
+
const more = moreOf(result.container);
|
|
136
|
+
|
|
137
|
+
more.focus();
|
|
138
|
+
fireEvent.keyDown(more, { key: "ArrowRight" });
|
|
139
|
+
expect(menu()).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -5,9 +5,15 @@ import {
|
|
|
5
5
|
type ComponentRef,
|
|
6
6
|
forwardRef,
|
|
7
7
|
} from "react";
|
|
8
|
-
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
|
8
|
+
import { Direction, DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
|
9
|
+
import { composeEventHandlers } from "@radix-ui/primitive";
|
|
9
10
|
import type { WithRenderPropProps } from "../../utils/Primitive";
|
|
10
11
|
import { DropdownMenuRenderContent } from "../dropdownMenuRenderPrimitives";
|
|
12
|
+
import { useThreadListItemFocus } from "../threadListFocusGroup";
|
|
13
|
+
import {
|
|
14
|
+
useThreadListItemMoreSharedFocusGroup,
|
|
15
|
+
useThreadListItemMoreSetOpen,
|
|
16
|
+
} from "./ThreadListItemMoreRoot";
|
|
11
17
|
import { type ScopedProps, useDropdownMenuScope } from "./scope";
|
|
12
18
|
|
|
13
19
|
export namespace ThreadListItemMorePrimitiveContent {
|
|
@@ -35,12 +41,27 @@ export const ThreadListItemMorePrimitiveContent = forwardRef<
|
|
|
35
41
|
forwardedRef,
|
|
36
42
|
) => {
|
|
37
43
|
const scope = useDropdownMenuScope(__scopeThreadListItemMore);
|
|
44
|
+
const setOpen = useThreadListItemMoreSetOpen();
|
|
45
|
+
const focus = useThreadListItemFocus();
|
|
46
|
+
const sharedFocusGroup = useThreadListItemMoreSharedFocusGroup();
|
|
47
|
+
const direction = Direction.useDirection();
|
|
48
|
+
const closeKey = direction === "rtl" ? "ArrowRight" : "ArrowLeft";
|
|
38
49
|
|
|
39
50
|
return (
|
|
40
51
|
<DropdownMenuPrimitive.Portal {...scope} {...portalProps}>
|
|
41
52
|
<DropdownMenuRenderContent
|
|
42
53
|
{...scope}
|
|
43
54
|
{...props}
|
|
55
|
+
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
|
|
56
|
+
if (!sharedFocusGroup || event.key !== closeKey) return;
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
setOpen(false);
|
|
59
|
+
focus?.moreRef.current?.focus();
|
|
60
|
+
})}
|
|
61
|
+
onEscapeKeyDown={composeEventHandlers(props.onEscapeKeyDown, () => {
|
|
62
|
+
if (!sharedFocusGroup) return;
|
|
63
|
+
focus?.moreRef.current?.focus();
|
|
64
|
+
})}
|
|
44
65
|
ref={forwardedRef}
|
|
45
66
|
sideOffset={sideOffset}
|
|
46
67
|
/>
|
|
@@ -1,22 +1,83 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
type Dispatch,
|
|
6
|
+
type FC,
|
|
7
|
+
type SetStateAction,
|
|
8
|
+
useContext,
|
|
9
|
+
} from "react";
|
|
4
10
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
|
11
|
+
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
5
12
|
import { type ScopedProps, useDropdownMenuScope } from "./scope";
|
|
6
13
|
|
|
14
|
+
const ThreadListItemMoreSetOpenContext = createContext<
|
|
15
|
+
Dispatch<SetStateAction<boolean>>
|
|
16
|
+
>(() => {});
|
|
17
|
+
|
|
18
|
+
export const useThreadListItemMoreSetOpen = (): Dispatch<
|
|
19
|
+
SetStateAction<boolean>
|
|
20
|
+
> => useContext(ThreadListItemMoreSetOpenContext);
|
|
21
|
+
|
|
22
|
+
const ThreadListItemMoreSharedFocusGroupContext = createContext(false);
|
|
23
|
+
|
|
24
|
+
export const useThreadListItemMoreSharedFocusGroup = (): boolean =>
|
|
25
|
+
useContext(ThreadListItemMoreSharedFocusGroupContext);
|
|
26
|
+
|
|
7
27
|
export namespace ThreadListItemMorePrimitiveRoot {
|
|
8
|
-
export type Props = DropdownMenuPrimitive.DropdownMenuProps
|
|
28
|
+
export type Props = DropdownMenuPrimitive.DropdownMenuProps & {
|
|
29
|
+
/**
|
|
30
|
+
* Join the menu to the thread list's focus group: Right opens it, Left/Escape
|
|
31
|
+
* close it and return focus to the trigger (mirrored in RTL). Forces the menu
|
|
32
|
+
* non-modal, since a focus trap can't let focus cross the trigger/menu
|
|
33
|
+
* boundary. Defaults to a standalone modal dropdown.
|
|
34
|
+
*/
|
|
35
|
+
sharedFocusGroup?: boolean | undefined;
|
|
36
|
+
};
|
|
9
37
|
}
|
|
10
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Root container for the overflow menu, built on Radix DropdownMenu.
|
|
41
|
+
*
|
|
42
|
+
* Defaults to a standard, self-contained modal dropdown. Pass
|
|
43
|
+
* {@link ThreadListItemMorePrimitiveRoot.Props.sharedFocusGroup} to fold it into
|
|
44
|
+
* the thread list's keyboard navigation instead.
|
|
45
|
+
*/
|
|
11
46
|
export const ThreadListItemMorePrimitiveRoot: FC<
|
|
12
47
|
ThreadListItemMorePrimitiveRoot.Props
|
|
13
48
|
> = ({
|
|
14
49
|
__scopeThreadListItemMore,
|
|
50
|
+
open: openProp,
|
|
51
|
+
defaultOpen,
|
|
52
|
+
onOpenChange,
|
|
53
|
+
sharedFocusGroup = false,
|
|
54
|
+
modal: modalProp,
|
|
15
55
|
...rest
|
|
16
56
|
}: ScopedProps<ThreadListItemMorePrimitiveRoot.Props>) => {
|
|
57
|
+
const modal = sharedFocusGroup ? false : (modalProp ?? true);
|
|
17
58
|
const scope = useDropdownMenuScope(__scopeThreadListItemMore);
|
|
59
|
+
const [open, setOpen] = useControllableState({
|
|
60
|
+
prop: openProp,
|
|
61
|
+
defaultProp: defaultOpen ?? false,
|
|
62
|
+
caller: "ThreadListItemMorePrimitive.Root",
|
|
63
|
+
...(onOpenChange ? { onChange: onOpenChange } : {}),
|
|
64
|
+
});
|
|
18
65
|
|
|
19
|
-
return
|
|
66
|
+
return (
|
|
67
|
+
<ThreadListItemMoreSharedFocusGroupContext.Provider
|
|
68
|
+
value={sharedFocusGroup}
|
|
69
|
+
>
|
|
70
|
+
<ThreadListItemMoreSetOpenContext.Provider value={setOpen}>
|
|
71
|
+
<DropdownMenuPrimitive.Root
|
|
72
|
+
{...scope}
|
|
73
|
+
{...rest}
|
|
74
|
+
modal={modal}
|
|
75
|
+
open={open}
|
|
76
|
+
onOpenChange={setOpen}
|
|
77
|
+
/>
|
|
78
|
+
</ThreadListItemMoreSetOpenContext.Provider>
|
|
79
|
+
</ThreadListItemMoreSharedFocusGroupContext.Provider>
|
|
80
|
+
);
|
|
20
81
|
};
|
|
21
82
|
|
|
22
83
|
ThreadListItemMorePrimitiveRoot.displayName =
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { type ComponentRef, forwardRef } from "react";
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
Direction,
|
|
6
|
+
type DropdownMenu as DropdownMenuPrimitive,
|
|
7
|
+
} from "radix-ui";
|
|
8
|
+
import { useComposedRefs } from "@radix-ui/react-compose-refs";
|
|
9
|
+
import { composeEventHandlers } from "@radix-ui/primitive";
|
|
5
10
|
import type { WithRenderPropProps } from "../../utils/Primitive";
|
|
6
11
|
import { DropdownMenuRenderTrigger } from "../dropdownMenuRenderPrimitives";
|
|
12
|
+
import { useThreadListItemFocus } from "../threadListFocusGroup";
|
|
13
|
+
import {
|
|
14
|
+
useThreadListItemMoreSharedFocusGroup,
|
|
15
|
+
useThreadListItemMoreSetOpen,
|
|
16
|
+
} from "./ThreadListItemMoreRoot";
|
|
7
17
|
import { type ScopedProps, useDropdownMenuScope } from "./scope";
|
|
8
18
|
|
|
9
19
|
export namespace ThreadListItemMorePrimitiveTrigger {
|
|
@@ -23,8 +33,25 @@ export const ThreadListItemMorePrimitiveTrigger = forwardRef<
|
|
|
23
33
|
ref,
|
|
24
34
|
) => {
|
|
25
35
|
const scope = useDropdownMenuScope(__scopeThreadListItemMore);
|
|
36
|
+
const focus = useThreadListItemFocus();
|
|
37
|
+
const setOpen = useThreadListItemMoreSetOpen();
|
|
38
|
+
const sharedFocusGroup = useThreadListItemMoreSharedFocusGroup();
|
|
39
|
+
const composedRef = useComposedRefs(ref, focus?.moreRef);
|
|
40
|
+
const direction = Direction.useDirection();
|
|
41
|
+
const openKey = direction === "rtl" ? "ArrowLeft" : "ArrowRight";
|
|
26
42
|
|
|
27
|
-
return
|
|
43
|
+
return (
|
|
44
|
+
<DropdownMenuRenderTrigger
|
|
45
|
+
{...scope}
|
|
46
|
+
{...rest}
|
|
47
|
+
onKeyDown={composeEventHandlers(rest.onKeyDown, (event) => {
|
|
48
|
+
if (!sharedFocusGroup || event.key !== openKey) return;
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
setOpen(true);
|
|
51
|
+
})}
|
|
52
|
+
ref={composedRef}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
28
55
|
},
|
|
29
56
|
);
|
|
30
57
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import {
|
|
4
4
|
GenerativeUIRender,
|
|
@@ -113,6 +113,26 @@ describe("MessagePrimitive.GenerativeUI (same-realm renderer)", () => {
|
|
|
113
113
|
expect(out2).toContain("<h3>loading</h3>");
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
it("skips malformed nodes with non-string component names", () => {
|
|
117
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
118
|
+
const malformedNode = { component: 123, props: { title: "bad" } };
|
|
119
|
+
const spec = { root: malformedNode } as unknown as GenerativeUISpec;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const out = renderToStaticMarkup(
|
|
123
|
+
<GenerativeUIRender spec={spec} components={{ Card }} />,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(out).toBe("");
|
|
127
|
+
expect(warn).toHaveBeenCalledWith(
|
|
128
|
+
"[generative-ui] Skipping malformed node at 0:",
|
|
129
|
+
malformedNode,
|
|
130
|
+
);
|
|
131
|
+
} finally {
|
|
132
|
+
warn.mockRestore();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
116
136
|
it("respects user-provided keys", () => {
|
|
117
137
|
const spec: GenerativeUISpec = {
|
|
118
138
|
root: [
|
|
@@ -8,7 +8,7 @@ const noopUnsubscribe = () => {};
|
|
|
8
8
|
export const useMediaQuery = (query: string | null): boolean => {
|
|
9
9
|
const subscribe = useCallback(
|
|
10
10
|
(callback: () => void) => {
|
|
11
|
-
if (typeof window === "undefined" || query === null)
|
|
11
|
+
if (typeof window === "undefined" || query === null || !window.matchMedia)
|
|
12
12
|
return noopUnsubscribe;
|
|
13
13
|
const mql = window.matchMedia(query);
|
|
14
14
|
mql.addEventListener("change", callback);
|
|
@@ -18,7 +18,8 @@ export const useMediaQuery = (query: string | null): boolean => {
|
|
|
18
18
|
);
|
|
19
19
|
|
|
20
20
|
const getSnapshot = useCallback(() => {
|
|
21
|
-
if (typeof window === "undefined" || query === null
|
|
21
|
+
if (typeof window === "undefined" || query === null || !window.matchMedia)
|
|
22
|
+
return false;
|
|
22
23
|
return window.matchMedia(query).matches;
|
|
23
24
|
}, [query]);
|
|
24
25
|
|
|
@@ -39,6 +39,23 @@ const runningState = (text: string) =>
|
|
|
39
39
|
status: { type: "running" },
|
|
40
40
|
}) as MessagePartState & TextMessagePart;
|
|
41
41
|
|
|
42
|
+
const setReducedMotion = (matches: boolean) => {
|
|
43
|
+
Object.defineProperty(window, "matchMedia", {
|
|
44
|
+
writable: true,
|
|
45
|
+
configurable: true,
|
|
46
|
+
value: (query: string) => ({
|
|
47
|
+
matches,
|
|
48
|
+
media: query,
|
|
49
|
+
onchange: null,
|
|
50
|
+
addEventListener: () => {},
|
|
51
|
+
removeEventListener: () => {},
|
|
52
|
+
addListener: () => {},
|
|
53
|
+
removeListener: () => {},
|
|
54
|
+
dispatchEvent: () => false,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
42
59
|
const driveAndCount = (minCommitMs: number) => {
|
|
43
60
|
const raf: FrameRequestCallback[] = [];
|
|
44
61
|
const rafSpy = vi
|
|
@@ -85,6 +102,7 @@ const driveAndCount = (minCommitMs: number) => {
|
|
|
85
102
|
describe("useSmooth", () => {
|
|
86
103
|
afterEach(() => {
|
|
87
104
|
vi.restoreAllMocks();
|
|
105
|
+
Reflect.deleteProperty(window, "matchMedia");
|
|
88
106
|
});
|
|
89
107
|
|
|
90
108
|
it("returns the input state unchanged when disabled", () => {
|
|
@@ -115,6 +133,14 @@ describe("useSmooth", () => {
|
|
|
115
133
|
expect(result.current).toBe(state);
|
|
116
134
|
});
|
|
117
135
|
|
|
136
|
+
it("disables the reveal under prefers-reduced-motion", () => {
|
|
137
|
+
setReducedMotion(true);
|
|
138
|
+
const state = runningState("streaming");
|
|
139
|
+
const { result } = renderHook(() => useSmooth(state, true));
|
|
140
|
+
expect(result.current).toBe(state);
|
|
141
|
+
expect(result.current.text).toBe("streaming");
|
|
142
|
+
});
|
|
143
|
+
|
|
118
144
|
it("starts from an empty reveal for running parts when enabled", () => {
|
|
119
145
|
const state = {
|
|
120
146
|
type: "text",
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
MessagePartState,
|
|
10
10
|
} from "@assistant-ui/core";
|
|
11
11
|
import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
|
|
12
|
+
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
12
13
|
import { useSmoothStatusStore } from "./SmoothContext";
|
|
13
14
|
import { writableStore } from "../../context/ReadonlyStore";
|
|
14
15
|
|
|
@@ -134,6 +135,10 @@ const positiveOr = (value: number | undefined, fallback: number): number =>
|
|
|
134
135
|
* the reveal. Returns the part state with `text` replaced by the revealed
|
|
135
136
|
* prefix and `status` reporting `running` until the reveal catches up.
|
|
136
137
|
*
|
|
138
|
+
* The reveal auto-disables under `prefers-reduced-motion: reduce`,
|
|
139
|
+
* committing the full text immediately; this takes precedence over an
|
|
140
|
+
* explicit `smooth={true}`.
|
|
141
|
+
*
|
|
137
142
|
* @example
|
|
138
143
|
* ```tsx
|
|
139
144
|
* const { text, status } = useSmooth(useMessagePartText(), {
|
|
@@ -147,9 +152,10 @@ export const useSmooth = (
|
|
|
147
152
|
smooth: boolean | SmoothOptions = false,
|
|
148
153
|
): MessagePartState & (TextMessagePart | ReasoningMessagePart) => {
|
|
149
154
|
const { text } = state;
|
|
155
|
+
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
|
|
150
156
|
const options =
|
|
151
157
|
typeof smooth === "object" && smooth !== null ? smooth : undefined;
|
|
152
|
-
const enabled = smooth !== false && smooth !== null;
|
|
158
|
+
const enabled = smooth !== false && smooth !== null && !reduceMotion;
|
|
153
159
|
const drainMs = positiveOr(options?.drainMs, DEFAULT_DRAIN_MS);
|
|
154
160
|
const maxCharIntervalMs = positiveOr(
|
|
155
161
|
options?.maxCharIntervalMs,
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
type ActionButtonElement,
|
|
5
|
-
type ActionButtonProps,
|
|
6
|
-
createActionButton,
|
|
7
|
-
} from "../../utils/createActionButton";
|
|
8
|
-
import { useThreadListItemTrigger as useThreadListItemTriggerBehavior } from "@assistant-ui/core/react";
|
|
9
|
-
|
|
10
|
-
const useThreadListItemTrigger = () => {
|
|
11
|
-
const { switchTo } = useThreadListItemTriggerBehavior();
|
|
12
|
-
return switchTo;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export namespace ThreadListItemPrimitiveTrigger {
|
|
16
|
-
export type Element = ActionButtonElement;
|
|
17
|
-
export type Props = ActionButtonProps<typeof useThreadListItemTrigger>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const ThreadListItemPrimitiveTrigger = createActionButton(
|
|
21
|
-
"ThreadListItemPrimitive.Trigger",
|
|
22
|
-
useThreadListItemTrigger,
|
|
23
|
-
);
|