+ );
+}
+
+function ActionRow({ record }: { record: (event: string) => void }) {
+ return (
+
+ record("copy")} />
+
+
+
+ record("pin")} />
+ record("link")} />
+ record("report")} />
+
+
+ );
+}
+
+/** An action row at the top of the page, and one at the foot of a short
+ * scroll container where a menu opening downwards would be clipped. */
+function MessageActionsScenario() {
+ const { log, record } = useEventLog();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {log}
+
+ );
+}
+
export const scenarios: Record ReactNode> = {
smoke: () => ,
"approval-card": () => ,
@@ -1113,4 +1237,12 @@ export const scenarios: Record ReactNode> = {
"panel-tabs": () => ,
"tool-approval": () => ,
"tool-group-approval": () => ,
+ "message-fold": () => ,
+ "message-fold-settle": () => (
+
+ ),
+ "message-fold-stick": () => (
+
+ ),
+ "message-actions": () => ,
};
diff --git a/e2e/message.spec.ts b/e2e/message.spec.ts
new file mode 100644
index 0000000..850bb64
--- /dev/null
+++ b/e2e/message.spec.ts
@@ -0,0 +1,196 @@
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+const scroller = (page: Page) => page.getByTestId("scroller");
+
+const toggle = (page: Page) =>
+ page.locator("[data-slot='message-fold-toggle']");
+
+/** How far the fold control sits below the top of the scroll container. */
+const toggleOffset = async (page: Page) => {
+ const box = await scroller(page).evaluate(
+ (element) => element.getBoundingClientRect().top,
+ );
+ const top = await toggle(page).evaluate(
+ (element) => element.getBoundingClientRect().top,
+ );
+ return top - box;
+};
+
+/** Waits for the container to stop moving. */
+const settled = async (page: Page) => {
+ let previous = Number.NaN;
+ await expect
+ .poll(
+ async () => {
+ const top = await scroller(page).evaluate(
+ (element) => element.scrollTop,
+ );
+ const still = top === previous;
+ previous = top;
+ return still;
+ },
+ { intervals: [100] },
+ )
+ .toBe(true);
+};
+
+/** Scrolls as the reader would, with the wheel, to put `contentTop` (a
+ * position inside the answer) at the top of the view. */
+const readInto = async (page: Page, contentTop: number) => {
+ const answerTop = await page
+ .getByTestId("long-answer")
+ .evaluate((element, scrollerTop) => {
+ const container = element.closest("[data-testid='scroller']");
+ if (container === null) throw new Error("expected the scroller");
+ return (
+ element.getBoundingClientRect().top -
+ container.getBoundingClientRect().top +
+ container.scrollTop -
+ scrollerTop
+ );
+ }, 0);
+ const current = await scroller(page).evaluate((element) => element.scrollTop);
+ const box = await scroller(page).boundingBox();
+ if (box === null) throw new Error("expected the scroller on screen");
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.wheel(0, answerTop + contentTop - current);
+ await settled(page);
+};
+
+/** Opens the answer, reads into the middle of it, and folds it from there:
+ * the control must stay where it was on screen. */
+const foldFromInside = async (page: Page) => {
+ await toggle(page).click();
+ await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
+ await readInto(page, 900);
+ const before = await toggleOffset(page);
+ const height = await scroller(page).evaluate(
+ (element) => element.clientHeight,
+ );
+ // Open, the control rides the bottom of the view while the answer is read.
+ expect(before).toBeGreaterThan(height / 2);
+ expect(before).toBeLessThan(height);
+
+ await toggle(page).click();
+ await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
+ await settled(page);
+ const after = await toggleOffset(page);
+ expect(Math.abs(after - before)).toBeLessThanOrEqual(1);
+};
+
+test("folding an answer from inside it keeps the reader's place", async ({
+ page,
+}) => {
+ await page.goto("/message-fold");
+ await expect(toggle(page)).toHaveText(/Show full answer/);
+ await foldFromInside(page);
+});
+
+for (const policy of ["settle", "stick"]) {
+ test(`folding inside a conversation (${policy}) keeps the reader's place`, async ({
+ page,
+ }) => {
+ await page.goto(`/message-fold-${policy}`);
+ await expect(toggle(page)).toBeAttached();
+ await foldFromInside(page);
+ });
+}
+
+const row = (page: Page, id: string) => page.getByTestId(id);
+const trigger = (scope: Locator) =>
+ scope.getByRole("button", { name: "More actions" });
+const menu = (scope: Locator) =>
+ scope.getByRole("menu", { name: "More actions" });
+const item = (scope: Locator, name: string) =>
+ scope.getByRole("menuitem", { name });
+
+test("a pressed action differs from a hovered one by more than colour", async ({
+ page,
+}) => {
+ await page.goto("/message-actions");
+ const top = row(page, "top");
+ const pressed = top.getByRole("button", { name: "Good response" });
+ const hovered = top.getByRole("button", { name: "Bad response" });
+ await hovered.hover();
+ const shadow = (button: Locator) =>
+ button.evaluate((element) => getComputedStyle(element).boxShadow);
+ // The pressed action carries an inset outline that hover does not draw.
+ expect(await shadow(pressed)).toContain("inset");
+ expect(await shadow(hovered)).not.toContain("inset");
+});
+
+test("the overflow menu works from the keyboard and returns focus", async ({
+ page,
+}) => {
+ await page.goto("/message-actions");
+ const top = row(page, "top");
+ await trigger(top).focus();
+ await page.keyboard.press("Enter");
+ await expect(menu(top)).toBeVisible();
+ await expect(trigger(top)).toHaveAttribute("aria-expanded", "true");
+ await expect(item(top, "Pin")).toBeFocused();
+
+ await page.keyboard.press("ArrowDown");
+ await expect(item(top, "Copy link")).toBeFocused();
+ await page.keyboard.press("ArrowDown");
+ await page.keyboard.press("ArrowDown");
+ await expect(item(top, "Pin")).toBeFocused();
+ await page.keyboard.press("ArrowUp");
+ await expect(item(top, "Report")).toBeFocused();
+ await page.keyboard.press("Home");
+ await expect(item(top, "Pin")).toBeFocused();
+ await page.keyboard.press("End");
+ await expect(item(top, "Report")).toBeFocused();
+
+ await page.keyboard.press("Escape");
+ await expect(menu(top)).toHaveCount(0);
+ await expect(trigger(top)).toBeFocused();
+ await expect(trigger(top)).toHaveAttribute("aria-expanded", "false");
+});
+
+test("ArrowUp on the trigger opens the menu on its last item", async ({
+ page,
+}) => {
+ await page.goto("/message-actions");
+ const top = row(page, "top");
+ await trigger(top).focus();
+ await page.keyboard.press("ArrowUp");
+ await expect(item(top, "Report")).toBeFocused();
+});
+
+test("choosing an item runs it, closes the menu and refocuses the trigger", async ({
+ page,
+}) => {
+ await page.goto("/message-actions");
+ const top = row(page, "top");
+ await trigger(top).focus();
+ await page.keyboard.press("ArrowDown");
+ await page.keyboard.press("ArrowDown");
+ await page.keyboard.press("Enter");
+ await expect(page.getByRole("status", { name: "Events" })).toHaveText("link");
+ await expect(menu(top)).toHaveCount(0);
+ await expect(trigger(top)).toBeFocused();
+});
+
+test("focus leaving the menu closes it", async ({ page }) => {
+ await page.goto("/message-actions");
+ const top = row(page, "top");
+ await trigger(top).click();
+ await expect(item(top, "Pin")).toBeFocused();
+ await page.keyboard.press("Tab");
+ await expect(menu(top)).toHaveCount(0);
+});
+
+test("a menu that would be clipped below opens above", async ({ page }) => {
+ await page.goto("/message-actions");
+ const bottom = row(page, "bottom");
+ await trigger(bottom).click();
+ const content = menu(bottom);
+ await expect(content).toBeVisible();
+ await expect(content).toHaveAttribute("data-side", "top");
+ const clip = await page.getByTestId("clipped").boundingBox();
+ const box = await content.boundingBox();
+ if (clip === null || box === null) throw new Error("expected both boxes");
+ expect(box.y).toBeGreaterThanOrEqual(clip.y);
+ expect(box.y + box.height).toBeLessThanOrEqual(clip.y + clip.height);
+});
diff --git a/src/hooks/use-copy.test.ts b/src/hooks/use-copy.test.ts
new file mode 100644
index 0000000..e46ce4e
--- /dev/null
+++ b/src/hooks/use-copy.test.ts
@@ -0,0 +1,169 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import { act } from "react";
+
+import { renderHook } from "../test/render-hook.js";
+import { useCopy } from "./use-copy.js";
+
+const original = Object.getOwnPropertyDescriptor(navigator, "clipboard");
+
+function stubClipboard(writeText: (text: string) => Promise) {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+}
+
+/** Stubs a clipboard whose writes settle only when the test says so. */
+function holdClipboard() {
+ const pending: { resolve: () => void; reject: (error: Error) => void }[] = [];
+ stubClipboard(
+ () =>
+ new Promise((resolve, reject) => pending.push({ resolve, reject })),
+ );
+ return pending;
+}
+
+const wait = (ms: number) =>
+ act(() => new Promise((resolve) => setTimeout(resolve, ms)));
+
+describe("useCopy", () => {
+ beforeEach(() => {
+ stubClipboard(() => Promise.resolve());
+ });
+
+ afterEach(() => {
+ if (original === undefined) {
+ Reflect.deleteProperty(navigator, "clipboard");
+ } else {
+ Object.defineProperty(navigator, "clipboard", original);
+ }
+ });
+
+ test("turns copied on after a copy and off after the timeout", async () => {
+ const writeText = mock((_text: string) => Promise.resolve());
+ stubClipboard(writeText);
+ const hook = renderHook(() => useCopy({ timeout: 30 }));
+ expect(hook.result.current.copied).toBe(false);
+
+ let written: boolean | undefined;
+ await act(async () => {
+ written = await hook.result.current.copy("hello");
+ });
+ expect(written).toBe(true);
+ expect(writeText).toHaveBeenCalledWith("hello");
+ expect(hook.result.current.copied).toBe(true);
+
+ await wait(50);
+ expect(hook.result.current.copied).toBe(false);
+ hook.unmount();
+ });
+
+ test("copying again restarts the timer", async () => {
+ const hook = renderHook(() => useCopy({ timeout: 40 }));
+ await act(async () => {
+ await hook.result.current.copy("one");
+ });
+ await wait(25);
+ await act(async () => {
+ await hook.result.current.copy("two");
+ });
+ await wait(25);
+ expect(hook.result.current.copied).toBe(true);
+ await wait(30);
+ expect(hook.result.current.copied).toBe(false);
+ hook.unmount();
+ });
+
+ test("a rejected clipboard write leaves copied false", async () => {
+ stubClipboard(() => Promise.reject(new Error("denied")));
+ const hook = renderHook(() => useCopy());
+ let written: boolean | undefined;
+ await act(async () => {
+ written = await hook.result.current.copy("hello");
+ });
+ expect(written).toBe(false);
+ expect(hook.result.current.copied).toBe(false);
+ hook.unmount();
+ });
+
+ test("a failed copy turns off an earlier copied state", async () => {
+ let fail = false;
+ stubClipboard(() =>
+ fail ? Promise.reject(new Error("denied")) : Promise.resolve(),
+ );
+ const hook = renderHook(() => useCopy({ timeout: 1000 }));
+ await act(async () => {
+ await hook.result.current.copy("one");
+ });
+ expect(hook.result.current.copied).toBe(true);
+ fail = true;
+ await act(async () => {
+ await hook.result.current.copy("two");
+ });
+ expect(hook.result.current.copied).toBe(false);
+ hook.unmount();
+ });
+
+ test("a stale write that settles last does not override a newer one", async () => {
+ const pending = holdClipboard();
+ const hook = renderHook(() => useCopy({ timeout: 1000 }));
+ let first!: Promise;
+ let second!: Promise;
+ act(() => {
+ first = hook.result.current.copy("one");
+ second = hook.result.current.copy("two");
+ });
+ await act(async () => {
+ pending[1]!.reject(new Error("denied"));
+ await second;
+ });
+ expect(hook.result.current.copied).toBe(false);
+ let written: boolean | undefined;
+ await act(async () => {
+ pending[0]!.resolve();
+ written = await first;
+ });
+ expect(written).toBe(true);
+ expect(hook.result.current.copied).toBe(false);
+ hook.unmount();
+ });
+
+ test("a stale failure that settles last does not clear a newer copy", async () => {
+ const pending = holdClipboard();
+ const hook = renderHook(() => useCopy({ timeout: 1000 }));
+ let first!: Promise;
+ let second!: Promise;
+ act(() => {
+ first = hook.result.current.copy("one");
+ second = hook.result.current.copy("two");
+ });
+ await act(async () => {
+ pending[1]!.resolve();
+ await second;
+ });
+ expect(hook.result.current.copied).toBe(true);
+ await act(async () => {
+ pending[0]!.reject(new Error("denied"));
+ await first;
+ });
+ expect(hook.result.current.copied).toBe(true);
+ hook.unmount();
+ });
+
+ test("clears its timer on unmount", async () => {
+ const cleared = mock(clearTimeout);
+ const realClear = globalThis.clearTimeout;
+ globalThis.clearTimeout = cleared as unknown as typeof clearTimeout;
+ try {
+ const hook = renderHook(() => useCopy({ timeout: 1000 }));
+ await act(async () => {
+ await hook.result.current.copy("hello");
+ });
+ const before = cleared.mock.calls.length;
+ hook.unmount();
+ expect(cleared.mock.calls.length).toBe(before + 1);
+ } finally {
+ globalThis.clearTimeout = realClear;
+ }
+ });
+});
diff --git a/src/hooks/use-copy.ts b/src/hooks/use-copy.ts
new file mode 100644
index 0000000..6bd298b
--- /dev/null
+++ b/src/hooks/use-copy.ts
@@ -0,0 +1,63 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export type UseCopyOptions = {
+ /** How long `copied` stays on after a successful copy, in ms. Default 2000. */
+ readonly timeout?: number;
+};
+
+export type UseCopyResult = {
+ /** A copy succeeded within the last `timeout` ms. */
+ readonly copied: boolean;
+ /** Writes `text` to the clipboard; resolves whether the write succeeded. */
+ readonly copy: (text: string) => Promise;
+};
+
+/**
+ * Copy to the clipboard with a short-lived "copied" state for the control
+ * that did it. A failed or unavailable clipboard leaves `copied` off, so a
+ * control never claims a copy that did not happen. Copying again restarts the
+ * timer; only the latest copy sets `copied`, even when an earlier write
+ * settles after it. Unmounting clears the timer.
+ */
+export function useCopy({
+ timeout = 2000,
+}: UseCopyOptions = {}): UseCopyResult {
+ const [copied, setCopied] = useState(false);
+ const timerRef = useRef | null>(null);
+ const mountedRef = useRef(true);
+ const latestRef = useRef(0);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ timerRef.current = null;
+ };
+ }, []);
+
+ const copy = useCallback(
+ async (text: string) => {
+ const call = ++latestRef.current;
+ let written = true;
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch {
+ written = false;
+ }
+ if (!mountedRef.current || call !== latestRef.current) return written;
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ timerRef.current = null;
+ setCopied(written);
+ if (!written) return false;
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null;
+ setCopied(false);
+ }, timeout);
+ return true;
+ },
+ [timeout],
+ );
+
+ return { copied, copy };
+}
diff --git a/src/index.ts b/src/index.ts
index 2402604..b3349ff 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -24,6 +24,7 @@ export * from "./hooks/use-prompt-input.js";
export * from "./hooks/use-resizable.js";
export * from "./hooks/use-tabs.js";
export * from "./hooks/use-tab-drag.js";
+export * from "./hooks/use-copy.js";
export * from "./lib/artifact.js";
export * from "./lib/chart-palette.js";
export * from "./lib/chat-message.js";
@@ -142,3 +143,8 @@ export * from "./ui/tool-approval.js";
export * from "./ui/tool-group.js";
export * from "./ui/reasoning.js";
export * from "./ui/activity.js";
+export * from "./ui/message.js";
+export * from "./ui/message-fold.js";
+export * from "./ui/message-actions.js";
+export * from "./ui/message-reactions.js";
+export * from "./ui/message-replies.js";
diff --git a/src/ui/message-actions.tsx b/src/ui/message-actions.tsx
new file mode 100644
index 0000000..a50da90
--- /dev/null
+++ b/src/ui/message-actions.tsx
@@ -0,0 +1,346 @@
+"use client";
+
+import { Ellipsis } from "lucide-react";
+import {
+ createContext,
+ forwardRef,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
+
+import { useDismissablePopover } from "../hooks/use-dismissable-popover.js";
+import { cn } from "../lib/utils.js";
+
+type MenuContextValue = {
+ /** Closes the menu and returns focus to its trigger. */
+ readonly close: () => void;
+};
+
+const MenuContext = createContext(null);
+
+export type MessageActionsProps = ComponentProps<"div">;
+
+/**
+ * The row of actions under a message: `MessageAction` buttons, then a
+ * `MessageActionMenu` for the extras. Named "Message actions" unless the host
+ * passes its own `aria-label`.
+ */
+export function MessageActions({
+ "aria-label": label = "Message actions",
+ className,
+ ...props
+}: MessageActionsProps) {
+ return (
+
+ );
+}
+
+export type MessageActionProps = Omit<
+ ComponentProps<"button">,
+ "aria-label" | "children"
+> & {
+ /**
+ * What the action does. In the row it is the button's accessible name and
+ * tooltip; in a `MessageActionMenu` it is the item's visible text.
+ */
+ readonly label: string;
+ /** The icon. Decorative: mark it `aria-hidden`. */
+ readonly children?: ReactNode;
+};
+
+/**
+ * One action: an icon button in `MessageActions`, or an item (icon and
+ * label) inside a `MessageActionMenu`. Pass `aria-pressed` for an action that
+ * toggles, such as a vote: pressed, the button is filled and outlined (hover
+ * only fills it); in the menu the item is a checked `menuitemcheckbox` in
+ * bold.
+ */
+export const MessageAction = forwardRef(
+ function MessageAction(
+ { label, className, children, onClick, "aria-pressed": pressed, ...props },
+ ref,
+ ) {
+ const menu = useContext(MenuContext);
+ if (menu !== null) {
+ // A toggle in a menu is a checkbox item: menus have no pressed state.
+ const toggle = pressed !== undefined;
+ const checked = pressed === true || pressed === "true";
+ return (
+
+ );
+ }
+ // Pressed shares hover's fill, so an outline in the control-boundary
+ // colour marks it apart from hover by shape, not colour alone.
+ return (
+
+ );
+ },
+);
+
+export type MessageActionMenuProps = Omit, "children"> & {
+ /** The trigger's accessible name and the menu's. Default "More actions". */
+ readonly label?: string;
+ /** The trigger's icon. Defaults to an ellipsis. */
+ readonly icon?: ReactNode;
+ /** Which side of the trigger the menu opens on. Default `bottom`. */
+ readonly side?: "top" | "bottom";
+ /** Which edge of the trigger the menu lines up with. Default `start`. */
+ readonly align?: "start" | "end";
+ /** Controlled open flag. Pair with `onOpenChange`. */
+ readonly open?: boolean | undefined;
+ readonly onOpenChange?: ((open: boolean) => void) | undefined;
+ /** `MessageAction`s, shown as menu items. */
+ readonly children: ReactNode;
+};
+
+function menuItems(menu: HTMLElement | null): HTMLElement[] {
+ if (menu === null) return [];
+ return [
+ ...menu.querySelectorAll('[role^="menuitem"]:not(:disabled)'),
+ ];
+}
+
+/** The box a menu must fit inside: its nearest clipping ancestor, or the
+ * viewport. */
+function clipBounds(element: HTMLElement): { top: number; bottom: number } {
+ for (
+ let node = element.parentElement;
+ node !== null;
+ node = node.parentElement
+ ) {
+ if (getComputedStyle(node).overflowY !== "visible") {
+ const rect = node.getBoundingClientRect();
+ return {
+ top: Math.max(rect.top, 0),
+ bottom: Math.min(rect.bottom, window.innerHeight),
+ };
+ }
+ }
+ return { top: 0, bottom: window.innerHeight };
+}
+
+/**
+ * The overflow menu at the end of `MessageActions`. Its items mount only
+ * while it is open, so a long conversation carries one button per message.
+ *
+ * Opening it moves focus to the first item (ArrowUp on the trigger: the
+ * last). Arrow keys, Home and End move between items; Escape closes it and
+ * returns focus to the trigger, as do choosing an item, Shift+Tab and
+ * pressing the trigger again; focus leaving it or a click outside closes it
+ * without taking focus back.
+ * It opens on `side` unless that side would clip it and the other has more
+ * room.
+ */
+export const MessageActionMenu = forwardRef<
+ HTMLDivElement,
+ MessageActionMenuProps
+>(function MessageActionMenu(
+ {
+ label = "More actions",
+ icon,
+ side = "bottom",
+ align = "start",
+ open: openProp,
+ onOpenChange,
+ className,
+ children,
+ ...props
+ },
+ ref,
+) {
+ const { open, setOpen, rootRef, triggerRef, close } = useDismissablePopover<
+ HTMLDivElement,
+ HTMLButtonElement
+ >({ open: openProp, onOpenChange });
+ // The host's ref must not replace the root ref: outside-click dismissal
+ // and the side flip both measure the root.
+ const setRoot = useCallback(
+ (element: HTMLDivElement | null) => {
+ rootRef.current = element;
+ if (typeof ref === "function") ref(element);
+ else if (ref != null) ref.current = element;
+ },
+ [ref, rootRef],
+ );
+ const menuRef = useRef(null);
+ // Which item the next open focuses. An ArrowUp on the trigger asks for the
+ // last; the request is dropped when the host does not open the menu at
+ // once, so a later open, by a click or by the host, starts on the first.
+ const [focusOnOpen, setFocusOnOpen] = useState<"first" | "last">("first");
+ if (focusOnOpen === "last" && !open) setFocusOnOpen("first");
+ // Set by a press on the trigger while open. Safari, and Firefox on macOS,
+ // do not focus a button on click, so the press blurs the menu with no
+ // `relatedTarget`; that blur must leave closing to the trigger's click.
+ const pressingTrigger = useRef(false);
+ const menuId = useId();
+ const [placement, setPlacement] = useState(side);
+
+ // Opens on `side` unless that would clip it and the other side has room.
+ useLayoutEffect(() => {
+ const menu = menuRef.current;
+ const root = rootRef.current;
+ if (!open || menu === null || root === null) {
+ setPlacement(side);
+ return;
+ }
+ const bounds = clipBounds(root);
+ const trigger = root.getBoundingClientRect();
+ const height = menu.getBoundingClientRect().height;
+ const below = bounds.bottom - trigger.bottom;
+ const above = trigger.top - bounds.top;
+ if (side === "bottom" && below < height && above > below) {
+ setPlacement("top");
+ } else if (side === "top" && above < height && below > above) {
+ setPlacement("bottom");
+ } else {
+ setPlacement(side);
+ }
+ }, [open, side, rootRef]);
+
+ useEffect(() => {
+ if (!open) return;
+ const items = menuItems(menuRef.current);
+ const target = focusOnOpen === "last" ? items.at(-1) : items[0];
+ (target ?? menuRef.current)?.focus();
+ }, [open, focusOnOpen]);
+
+ const onTriggerKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
+ event.preventDefault();
+ setFocusOnOpen(event.key === "ArrowUp" ? "last" : "first");
+ setOpen(true);
+ };
+
+ const onMenuKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Tab" && event.shiftKey) {
+ // Shift+Tab lands on the trigger, inside the root: close on the way.
+ event.preventDefault();
+ close();
+ return;
+ }
+ const items = menuItems(menuRef.current);
+ if (items.length === 0) return;
+ const current = items.findIndex((item) => item === document.activeElement);
+ let next: number | null = null;
+ if (event.key === "ArrowDown") next = (current + 1) % items.length;
+ else if (event.key === "ArrowUp")
+ next = current <= 0 ? items.length - 1 : current - 1;
+ else if (event.key === "Home") next = 0;
+ else if (event.key === "End") next = items.length - 1;
+ if (next === null) return;
+ event.preventDefault();
+ items[next]?.focus();
+ };
+
+ return (
+