Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/app-web/e2e/app.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,78 @@ test.describe("getting around", () => {
await page.waitForTimeout(1500);
});

test.describe("the composer's menu", () => {
// The gallery's first composer: it takes files, commands and mentions, and has settings. At
// 1280 pixels its box is about 570 wide; at 390, about 310.
const open = async (page: import("@playwright/test").Page, width: number) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/app/gallery");
const tile = page.locator('section[aria-label="Composer"]');
return { tile, button: tile.getByRole("button", { name: "Composer menu" }) };
};

test("opens upward from its button, the arrows move through it, and Escape closes it onto the button", async ({
page,
}, info) => {
test.skip(info.project.name !== "dev", "the gallery exists only in development");
const { tile, button } = await open(page, 1280);
await button.click();
const menu = tile.getByRole("menu");
await expect(menu.getByRole("menuitem", { name: "Attach files" })).toBeFocused();
const menuBox = await menu.boundingBox();
const buttonBox = await button.boundingBox();
expect((menuBox?.y ?? 0) + (menuBox?.height ?? 0)).toBeLessThanOrEqual(buttonBox?.y ?? 0);
await page.keyboard.press("ArrowUp");
await expect(menu.getByRole("menuitem", { name: "Session settings" })).toBeFocused();
await page.keyboard.press("ArrowDown");
await expect(menu.getByRole("menuitem", { name: "Attach files" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(menu).toHaveCount(0);
await expect(button).toBeFocused();
});

test("a row that opens a picker hands focus back to the menu button when the picker closes", async ({
page,
}, info) => {
test.skip(info.project.name !== "dev", "the gallery exists only in development");
const { tile, button } = await open(page, 1280);
await button.focus();
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await expect(tile.getByRole("menuitem", { name: "Commands" })).toBeFocused();
await page.keyboard.press("Enter");
await expect(tile.getByRole("dialog", { name: "Commands" })).toBeVisible();
await expect(tile.getByRole("menu")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(tile.getByRole("dialog", { name: "Commands" })).toHaveCount(0);
await expect(button).toBeFocused();
});

test("in a wide composer the settings are chips beside the button; in a narrow one they are rows in the menu", async ({
page,
}, info) => {
test.skip(info.project.name !== "dev", "the gallery exists only in development");
const wide = await open(page, 1280);
await expect(wide.tile.getByRole("button", { name: "Model: Claude Opus 5.5" })).toBeVisible();
await wide.button.click();
await expect(wide.tile.getByRole("menuitem", { name: /^Model/ })).toHaveCount(0);

const narrow = await open(page, 390);
await expect(narrow.tile.getByRole("button", { name: "Model: Claude Opus 5.5" })).toHaveCount(
0,
);
await expect(narrow.tile.getByRole("textbox", { name: "Message" })).toHaveAttribute(
"placeholder",
"Message the agent",
);
await narrow.button.click();
const model = narrow.tile.getByRole("menuitem", { name: "Model: Claude Opus 5.5" });
await expect(model).toBeVisible();
await model.click();
await expect(narrow.tile.getByRole("dialog", { name: "Model" })).toBeVisible();
});
});

test("the sessions page has the same top bar as the workspace page, with the theme button at its right", async ({
page,
}, info) => {
Expand Down
7 changes: 2 additions & 5 deletions packages/ui/base/icon-button.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { Icon } from "@phosphor-icons/react";
import type { ButtonHTMLAttributes } from "react";
import type { ComponentProps } from "react";

type ButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"type" | "title" | "aria-label" | "children"
>;
type ButtonProps = Omit<ComponentProps<"button">, "type" | "title" | "aria-label" | "children">;

/**
* A button drawn as an icon alone. `label` is its accessible name and its hover text. The icon is
Expand Down
27 changes: 26 additions & 1 deletion packages/ui/base/media.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { useCallback, useSyncExternalStore } from "react";
import {
type RefObject,
useCallback,
useLayoutEffect,
useState,
useSyncExternalStore,
} from "react";

/** The media query a reader's request for reduced motion matches. */
export const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
Expand All @@ -20,3 +26,22 @@ export function useMediaQuery(query: string): boolean {
);
return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches);
}

/**
* Whether the element `ref` holds is narrower than `width` CSS pixels, padding included, kept
* current as it resizes. It is measured before the first paint, so a narrow element is never drawn
* wide first. False until the element is measured, and when rendered outside a browser.
*/
export function useNarrowerThan(ref: RefObject<HTMLElement | null>, width: number): boolean {
const [narrow, setNarrow] = useState(false);
useLayoutEffect(() => {
const element = ref.current;
if (element === null) return;
const measure = () => setNarrow(element.clientWidth < width);
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [ref, width]);
return narrow;
}
172 changes: 172 additions & 0 deletions packages/ui/composer/composer-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { CaretRightIcon, ListIcon } from "@phosphor-icons/react";
import { Fragment, type KeyboardEvent, type ReactNode, useEffect, useId, useRef } from "react";
import { IconButton } from "../base/icon-button";
import { stepIndex } from "../overlay/list";

/** One row of the composer's menu. */
export interface MenuEntry {
readonly id: string;
readonly label: string;
readonly icon: ReactNode;
/** A setting's current value: drawn at the row's end, and read as part of the row's name. */
readonly value?: string;
/** Set for a boolean setting: the row is a checkbox, and choosing it flips the setting. */
readonly checked?: boolean;
/** Whether choosing the row opens a picker dialog. */
readonly opensPicker?: boolean;
/** Whether the row cannot be chosen now. It stays in the menu, where the arrow keys reach it. */
readonly disabled?: boolean;
readonly onChoose: () => void;
}

const ROWS = '[role="menuitem"], [role="menuitemcheckbox"]';

/**
* The menu button at the composer's bottom-left, and the menu it opens upward, after the WAI-ARIA
* menu button pattern. Down or Up on the button opens the menu on its first or last row. In the
* menu, Up and Down move and wrap, and Home and End jump. Escape, Tab and a press outside close it;
* Tab leaves focus on the button rather than moving on. Choosing a row closes the menu with focus
* on the button, so a picker the row opens hands focus back to the button. The non-empty groups
* are drawn with a separator between each; with none there is no button.
*/
export function ComposerMenu({
groups,
open,
onOpenChange,
}: {
groups: readonly (readonly MenuEntry[])[];
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const triggerId = useId();
const menuId = useId();
const wrapper = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const menu = useRef<HTMLDivElement>(null);
// The row that takes focus when the menu opens: the last when Up opened it.
const startAt = useRef<"first" | "last">("first");
// The latest handler, so the effect below runs when the menu opens and closes, not every render.
const change = useRef(onOpenChange);
change.current = onOpenChange;
const shown = groups.filter((group) => group.length > 0);

const rows = () => [...(menu.current?.querySelectorAll<HTMLElement>(ROWS) ?? [])];
const close = () => {
onOpenChange(false);
trigger.current?.focus();
};

// biome-ignore lint/correctness/useExhaustiveDependencies: `rows` reads the ref, not render state
useEffect(() => {
if (!open) return;
const all = rows();
(startAt.current === "last" ? all.at(-1) : all[0])?.focus();
startAt.current = "first";
const onPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node) || !wrapper.current?.contains(event.target))
change.current(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);

if (shown.length === 0) return null;

const choose = (entry: MenuEntry) => {
if (entry.disabled) return;
close();
entry.onChoose();
};

const onMenuKey = (event: KeyboardEvent<HTMLDivElement>) => {
const all = rows();
const focused = document.activeElement;
const at = focused instanceof HTMLElement ? all.indexOf(focused) : -1;
const go = (index: number) => {
event.preventDefault();
all[index]?.focus();
};
switch (event.key) {
case "ArrowDown":
return go(stepIndex(at, 1, all.length));
case "ArrowUp":
return go(stepIndex(Math.max(at, 0), -1, all.length));
case "Home":
return go(0);
case "End":
return go(all.length - 1);
case "Escape":
case "Tab":
event.preventDefault();
close();
return;
}
};

const onTriggerKey = (event: KeyboardEvent<HTMLButtonElement>) => {
if (open || (event.key !== "ArrowDown" && event.key !== "ArrowUp")) return;
event.preventDefault();
startAt.current = event.key === "ArrowUp" ? "last" : "first";
onOpenChange(true);
};

return (
<div className="lk-composer-menu" ref={wrapper}>
<IconButton
ref={trigger}
id={triggerId}
label="Composer menu"
icon={ListIcon}
on={open}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => (open ? close() : onOpenChange(true))}
onKeyDown={onTriggerKey}
/>
{open ? (
<div
ref={menu}
id={menuId}
role="menu"
aria-labelledby={triggerId}
className="lk-menu"
onKeyDown={onMenuKey}
>
{shown.map((group, g) => (
// biome-ignore lint/suspicious/noArrayIndexKey: groups are positional
<Fragment key={g}>
{g > 0 ? <hr className="lk-menu-sep" /> : null}
{group.map((entry) => (
<MenuRow key={entry.id} entry={entry} onChoose={choose} />
))}
</Fragment>
))}
</div>
) : null}
</div>
);
}

function MenuRow({ entry, onChoose }: { entry: MenuEntry; onChoose: (entry: MenuEntry) => void }) {
return (
<button
type="button"
role={entry.checked === undefined ? "menuitem" : "menuitemcheckbox"}
{...(entry.checked === undefined ? {} : { "aria-checked": entry.checked })}
aria-label={entry.value === undefined ? entry.label : `${entry.label}: ${entry.value}`}
aria-haspopup={entry.opensPicker ? "dialog" : undefined}
aria-disabled={entry.disabled || undefined}
tabIndex={-1}
className="lk-menu-item"
onClick={() => onChoose(entry)}
>
<span className="lk-menu-icon" aria-hidden="true">
{entry.icon}
</span>
<span className="lk-menu-label">{entry.label}</span>
{entry.value === undefined ? null : <span className="lk-menu-value">{entry.value}</span>}
{entry.opensPicker ? <CaretRightIcon className="lk-menu-more" aria-hidden="true" /> : null}
</button>
);
}
Loading
Loading