diff --git a/README.md b/README.md index c98323da..561ddb0d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", | `DictationStatusLine` | Composer dictation status row | | `MicPermissionDialog` | Microphone permission prompt (optional peer, `ui/mic-permission-dialog`) | | `Panel` | Side panel resized from its edge; hidden panes keep their state | +| `PanelTabs` | A panel's tab row: select, close, drag or keyboard to reorder; `useTabs` | #### Runs diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index e1c72e01..34405888 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -43,6 +43,11 @@ import { PromptInputFloat, PromptInputContext, PromptInputNote, + PanelTab, + PanelTabAdd, + PanelTabs, + panelTabsHeaderClassName, + useTabs, } from "@corbits/react-ui"; import { CommandPalette, @@ -854,6 +859,87 @@ function PromptToolbarInDialog() { ); } +type TabScenarioItem = { readonly value: string; readonly title: string }; + +const TAB_ITEMS: readonly TabScenarioItem[] = [ + { value: "alpha", title: "Alpha" }, + { value: "beta", title: "Beta" }, + { value: "gamma", title: "Gamma" }, + { value: "delta", title: "Delta" }, +]; + +function PanelTabsScenario() { + const { tabs, value, open, close, tabsProps, paneProps } = + useTabs({ + defaultTabs: TAB_ITEMS, + }); + const [added, setAdded] = useState(0); + const openMore = (count: number) => { + for (let index = 1; index <= count; index += 1) { + open({ + value: `extra-${added + index}`, + title: `Extra ${added + index}`, + }); + } + setAdded(added + count); + }; + return ( +
+
+ + + {tabs.map((tab) => tab.value).join(",")} + + {value ?? "none"} +
+ {value === null ? null : ( + + + + {tabs.map((tab) => ( + + {tab.title} + + ))} + + openMore(1)} /> + + + + + + {tabs.map((tab) => ( + + {tab.title} pane + + ))} + + + )} +
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -895,4 +981,5 @@ export const scenarios: Record ReactNode> = { panel: () => , "prompt-input-toolbar": () => , "prompt-input-toolbar-dialog": () => , + "panel-tabs": () => , }; diff --git a/e2e/panel-tabs.spec.ts b/e2e/panel-tabs.spec.ts new file mode 100644 index 00000000..d1ef3c0f --- /dev/null +++ b/e2e/panel-tabs.spec.ts @@ -0,0 +1,234 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +function order(page: Page): Locator { + return page.getByRole("status", { name: "Order" }); +} + +function tab(page: Page, name: string): Locator { + return page.getByRole("tab", { name, exact: true }); +} + +/** How far `name`'s tab sticks out of the row's visible part, in px. */ +function overflow(page: Page, name: string): Promise { + return tab(page, name).evaluate((element) => { + const row = element.closest("[role='tablist']"); + if (row === null) throw new Error("the tab has no row"); + const box = element.getBoundingClientRect(); + const view = row.getBoundingClientRect(); + return Math.max(0, view.left - box.left, box.right - view.right); + }); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/panel-tabs"); +}); + +test("dragging a tab to the last slot moves it there", async ({ page }) => { + const beta = tab(page, "Beta"); + const delta = tab(page, "Delta"); + const from = await beta.boundingBox(); + const to = await delta.boundingBox(); + if (from === null || to === null) throw new Error("missing boxes"); + const y = from.y + from.height / 2; + + await page.mouse.move(from.x + from.width / 2, y); + await page.mouse.down(); + await page.mouse.move(to.x + to.width - 4, y, { steps: 12 }); + await expect( + page.locator("[data-slot='panel-tab'][data-value='beta']"), + ).toHaveAttribute("data-dragging", "true"); + // The tab passed slides aside while the order is still unchanged. + await expect(order(page)).toHaveText("alpha,beta,gamma,delta"); + await page.mouse.up(); + + await expect(order(page)).toHaveText("alpha,gamma,delta,beta"); + await expect( + page.locator("[data-slot='panel-tab'][style*='translate']"), + ).toHaveCount(0); + // The release does not also click the tab, and focus goes back to the + // selected tab, the row's tab stop. + await expect(page.getByRole("status", { name: "Selected" })).toHaveText( + "alpha", + ); + await expect(tab(page, "Alpha")).toBeFocused(); +}); + +test("a drag dropped where it began leaves focus on the selected tab", async ({ + page, +}) => { + const box = await tab(page, "Beta").boundingBox(); + if (box === null) throw new Error("missing box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 12, y, { steps: 3 }); + await expect( + page.locator("[data-slot='panel-tab'][data-value='beta']"), + ).toHaveAttribute("data-dragging", "true"); + await page.mouse.up(); + + await expect(order(page)).toHaveText("alpha,beta,gamma,delta"); + await expect(page.getByRole("status", { name: "Selected" })).toHaveText( + "alpha", + ); + await expect(tab(page, "Alpha")).toBeFocused(); + // Delete acts on the selected tab, not on the one that was dragged. + await page.keyboard.press("Delete"); + await expect(order(page)).toHaveText("beta,gamma,delta"); +}); + +test("Escape cancels a drag", async ({ page }) => { + const beta = tab(page, "Beta"); + const box = await beta.boundingBox(); + if (box === null) throw new Error("missing box"); + const y = box.y + box.height / 2; + await page.mouse.move(box.x + box.width / 2, y); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 3, y, { steps: 8 }); + await page.keyboard.press("Escape"); + await page.mouse.up(); + await expect(order(page)).toHaveText("alpha,beta,gamma,delta"); +}); + +test("releasing a drag cancelled with Escape does not select the tab", async ({ + page, +}) => { + const selected = page.getByRole("status", { name: "Selected" }); + const box = await tab(page, "Beta").boundingBox(); + if (box === null) throw new Error("missing box"); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 12, y, { steps: 3 }); + await page.keyboard.press("Escape"); + // A person lets go a moment later, over the tab they pressed. + await page.waitForTimeout(50); + await page.mouse.up(); + await expect(order(page)).toHaveText("alpha,beta,gamma,delta"); + await expect(selected).toHaveText("alpha"); + await expect(tab(page, "Alpha")).toBeFocused(); + + // Only that press's click is swallowed: the next click selects. + await tab(page, "Beta").click(); + await expect(selected).toHaveText("beta"); +}); + +test("the keyboard selects and moves tabs", async ({ page }) => { + const selected = page.getByRole("status", { name: "Selected" }); + await tab(page, "Alpha").focus(); + + await page.keyboard.press("ArrowRight"); + await expect(tab(page, "Beta")).toBeFocused(); + await expect(tab(page, "Beta")).toHaveAttribute("aria-selected", "true"); + await expect(selected).toHaveText("beta"); + await page.keyboard.press("End"); + await expect(tab(page, "Delta")).toBeFocused(); + await page.keyboard.press("ArrowRight"); + await expect(tab(page, "Alpha")).toBeFocused(); + await page.keyboard.press("Home"); + await expect(selected).toHaveText("alpha"); + + await page.keyboard.press("ControlOrMeta+Shift+ArrowRight"); + await expect(order(page)).toHaveText("beta,alpha,gamma,delta"); + await expect(tab(page, "Alpha")).toBeFocused(); + await page.keyboard.press("ControlOrMeta+Shift+ArrowRight"); + await page.keyboard.press("ControlOrMeta+Shift+ArrowRight"); + await page.keyboard.press("ControlOrMeta+Shift+ArrowRight"); + await expect(order(page)).toHaveText("beta,gamma,delta,alpha"); + await expect(tab(page, "Alpha")).toBeFocused(); + await page.keyboard.press("ControlOrMeta+Shift+ArrowLeft"); + await expect(order(page)).toHaveText("beta,gamma,alpha,delta"); + await expect(selected).toHaveText("alpha"); + + // The row is one tab stop: Tab goes on to the + beside it. + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Open a tab" })).toBeFocused(); +}); + +test("middle-click and Delete close tabs", async ({ page }) => { + const selected = page.getByRole("status", { name: "Selected" }); + await tab(page, "Gamma").click({ button: "middle" }); + await expect(order(page)).toHaveText("alpha,beta,delta"); + await expect(selected).toHaveText("alpha"); + + await tab(page, "Alpha").focus(); + await page.keyboard.press("Delete"); + await expect(order(page)).toHaveText("beta,delta"); + await expect(tab(page, "Beta")).toBeFocused(); + await expect(selected).toHaveText("beta"); + + await tab(page, "Delta").click(); + await tab(page, "Delta").hover(); + await page + .locator("[data-slot='panel-tab'][data-value='delta']") + .locator("[data-slot='panel-tab-close']") + .click(); + await expect(order(page)).toHaveText("beta"); + await expect(selected).toHaveText("beta"); + await expect(tab(page, "Beta")).toBeFocused(); +}); + +test("a long row keeps the selected tab in view", async ({ page }) => { + await page.getByRole("button", { name: "Open twelve more" }).click(); + await expect(tab(page, "Extra 12")).toHaveAttribute("aria-selected", "true"); + const row = page.getByRole("tablist", { name: "Open items" }); + expect( + await row.evaluate((element) => element.scrollWidth > element.clientWidth), + ).toBe(true); + await expect.poll(() => overflow(page, "Extra 12")).toBeLessThanOrEqual(1); + + await tab(page, "Extra 12").focus(); + await page.keyboard.press("Home"); + await expect(tab(page, "Alpha")).toBeFocused(); + await expect.poll(() => overflow(page, "Alpha")).toBeLessThanOrEqual(1); + await expect + .poll(() => row.evaluate((element) => element.scrollLeft)) + .toBeLessThanOrEqual(1); + + await page.keyboard.press("ArrowLeft"); + await expect(tab(page, "Extra 12")).toBeFocused(); + await expect.poll(() => overflow(page, "Extra 12")).toBeLessThanOrEqual(1); +}); + +test("closing the selected tab from its pane moves focus to the next tab", async ({ + page, +}) => { + const selected = page.getByRole("status", { name: "Selected" }); + await page.getByRole("tabpanel", { name: "Alpha" }).click(); + await expect(page.getByRole("tabpanel", { name: "Alpha" })).toBeFocused(); + await tab(page, "Alpha").hover(); + await page + .locator("[data-slot='panel-tab'][data-value='alpha']") + .locator("[data-slot='panel-tab-close']") + .click(); + await expect(order(page)).toHaveText("beta,gamma,delta"); + await expect(selected).toHaveText("beta"); + await expect(tab(page, "Beta")).toBeFocused(); + + await page.getByRole("tabpanel", { name: "Beta" }).click(); + await tab(page, "Beta").click({ button: "middle" }); + await expect(order(page)).toHaveText("gamma,delta"); + await expect(tab(page, "Gamma")).toBeFocused(); +}); + +test("the header's Close tab closes the selected tab and keeps focus", async ({ + page, +}) => { + const close = page.getByRole("button", { name: "Close tab" }); + await close.click(); + await expect(order(page)).toHaveText("beta,gamma,delta"); + await expect(page.getByRole("status", { name: "Selected" })).toHaveText( + "beta", + ); + await expect(close).toBeFocused(); +}); + +test("a move is announced", async ({ page }) => { + await tab(page, "Alpha").focus(); + await page.keyboard.press("ControlOrMeta+Shift+ArrowRight"); + await expect(page.locator("[data-slot='panel-tabs-status']")).toHaveText( + "Moved to position 2 of 4", + ); +}); diff --git a/src/hooks/use-tab-drag.ts b/src/hooks/use-tab-drag.ts new file mode 100644 index 00000000..45b2b0af --- /dev/null +++ b/src/hooks/use-tab-drag.ts @@ -0,0 +1,213 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + type RefObject, +} from "react"; +import type * as React from "react"; + +/** How far the pointer travels before a press becomes a drag, in px. */ +const DRAG_THRESHOLD_PX = 4; + +export type UseTabDragOptions = { + /** The row; it is also what scrolls when the row is too long. */ + readonly listRef: RefObject; + /** Called once on release, when the tab lands somewhere new. */ + readonly onMove: ((value: string, toIndex: number) => void) | undefined; + /** + * Called once after every drag, after any `onMove`: dropped somewhere + * new, dropped where it began, or cancelled. The press that began it may + * have focused the item, which a drag never selects. + */ + readonly onDragEnd?: (() => void) | undefined; + /** Matches each draggable item; each carries its value in `data-value`. */ + readonly itemSelector?: string | undefined; +}; + +export type UseTabDragResult = { + /** Call from the item's `onPointerDown` with that item's value. */ + readonly onPointerDown: ( + event: React.PointerEvent, + value: string, + ) => void; +}; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Drag to reorder the items of a horizontal row, such as a tab strip. + * + * While the pointer moves, the dragged item follows it (held within the row) + * and the items it passes slide aside to make room. Positions are written + * straight to each element's `transform`, so nothing re-renders until + * release, when `onMove` runs once with the item's new index. The row and the + * dragged item get `data-dragging` for styling the slide. + * + * Mouse and pen only: a touch drag is left to scroll the row. Escape or a + * cancelled pointer puts everything back without a move. The click that + * ends a drag, or the release of one cancelled with Escape, is swallowed so + * it does not also act on the item. + */ +export function useTabDrag({ + listRef, + onMove, + onDragEnd, + itemSelector = "[data-slot='panel-tab']", +}: UseTabDragOptions): UseTabDragResult { + // The latest callbacks, for a drag that began under an earlier render. + const onMoveRef = useRef(onMove); + const onDragEndRef = useRef(onDragEnd); + useLayoutEffect(() => { + onMoveRef.current = onMove; + onDragEndRef.current = onDragEnd; + }); + const stopRef = useRef<(() => void) | null>(null); + + useEffect(() => () => stopRef.current?.(), []); + + const onPointerDown = useCallback( + (event: React.PointerEvent, value: string) => { + const list = listRef.current; + if ( + list === null || + onMoveRef.current === undefined || + event.button !== 0 || + !event.isPrimary || + event.pointerType === "touch" + ) + return; + const items = [...list.querySelectorAll(itemSelector)]; + const from = items.findIndex((item) => item.dataset["value"] === value); + const dragged = items[from]; + if (dragged === undefined) return; + stopRef.current?.(); + + // Every position is kept in the frame the drag started in, with the + // row's scroll since then added to the pointer. + const rects = items.map((item) => item.getBoundingClientRect()); + const own = rects[from]; + if (own === undefined) return; + const rowStart = Math.min(...rects.map((rect) => rect.left)); + const rowEnd = Math.max(...rects.map((rect) => rect.right)); + const style = getComputedStyle(list); + const towardEnd = style.direction === "rtl" ? -1 : 1; + const gap = Number.parseFloat(style.columnGap) || 0; + const room = own.width + gap; + const scrollStart = list.scrollLeft; + const startX = event.clientX; + const pointerId = event.pointerId; + let target = from; + let dragging = false; + + const onPointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return; + const dx = moveEvent.clientX - startX + list.scrollLeft - scrollStart; + if (!dragging) { + if (Math.abs(dx) < DRAG_THRESHOLD_PX) return; + dragging = true; + dragged.setPointerCapture(pointerId); + list.dataset["dragging"] = "true"; + dragged.dataset["dragging"] = "true"; + } + const offset = clamp(dx, rowStart - own.left, rowEnd - own.right); + dragged.style.transform = `translateX(${offset}px)`; + const center = own.left + own.width / 2 + dx; + // In right-to-left text the row's start is on the right. + target = rects.filter((rect, index) => { + if (index === from) return false; + const middle = rect.left + rect.width / 2; + return towardEnd === 1 ? middle < center : middle > center; + }).length; + items.forEach((item, index) => { + if (index === from) return; + const shift = + from < target && index > from && index <= target + ? -room * towardEnd + : from > target && index >= target && index < from + ? room * towardEnd + : 0; + item.style.transform = shift === 0 ? "" : `translateX(${shift}px)`; + }); + }; + + const swallowClick = (clickEvent: MouseEvent) => { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + }; + + // The click a release makes comes in the same task as its pointerup. + const swallowReleaseClick = () => { + window.addEventListener("click", swallowClick, true); + setTimeout(() => + window.removeEventListener("click", swallowClick, true), + ); + }; + + /** `stop` is an unmount or a new press: no click follows, no callback. */ + const finish = (end: "drop" | "cancel" | "stop") => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("keydown", onKeyDown, true); + stopRef.current = null; + if (!dragging) return; + // The transition is styled off `data-dragging`, so clearing both in + // one go puts every item back at rest without sliding. + for (const item of items) item.style.transform = ""; + delete list.dataset["dragging"]; + delete dragged.dataset["dragging"]; + if (dragged.hasPointerCapture(pointerId)) + dragged.releasePointerCapture(pointerId); + if (end === "drop") { + swallowReleaseClick(); + if (target !== from) onMoveRef.current?.(value, target); + } + if (end !== "stop") onDragEndRef.current?.(); + }; + + // After Escape the button is still down, and letting go over the item + // clicks it. Swallow that click, and no later one: a press seen first + // means the release never reached the page. + const onReleaseAfterEscape = (releaseEvent: PointerEvent) => { + if (releaseEvent.pointerId !== pointerId) return; + stopAwaitingRelease(); + if (releaseEvent.type === "pointerup") swallowReleaseClick(); + }; + const stopAwaitingRelease = () => { + window.removeEventListener("pointerup", onReleaseAfterEscape); + window.removeEventListener("pointercancel", onReleaseAfterEscape); + window.removeEventListener("pointerdown", onReleaseAfterEscape); + stopRef.current = null; + }; + + function onPointerUp(upEvent: PointerEvent) { + if (upEvent.pointerId === pointerId) finish("drop"); + } + function onCancel(cancelEvent: PointerEvent) { + if (cancelEvent.pointerId === pointerId) finish("cancel"); + } + function onKeyDown(keyEvent: KeyboardEvent) { + if (keyEvent.key !== "Escape" || !dragging) return; + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + finish("cancel"); + window.addEventListener("pointerup", onReleaseAfterEscape); + window.addEventListener("pointercancel", onReleaseAfterEscape); + window.addEventListener("pointerdown", onReleaseAfterEscape); + stopRef.current = stopAwaitingRelease; + } + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("keydown", onKeyDown, true); + stopRef.current = () => finish("stop"); + }, + [listRef, itemSelector], + ); + + return { onPointerDown }; +} diff --git a/src/hooks/use-tabs.test.ts b/src/hooks/use-tabs.test.ts new file mode 100644 index 00000000..4ba80996 --- /dev/null +++ b/src/hooks/use-tabs.test.ts @@ -0,0 +1,210 @@ +import { act } from "react"; +import { describe, expect, mock, test } from "bun:test"; + +import { renderHook } from "../test/render-hook.js"; +import { panelTabIds, useTabs, type UseTabsOptions } from "./use-tabs.js"; + +type Tab = { readonly value: string; readonly title: string }; + +const TABS: readonly Tab[] = ["a", "b", "c", "d"].map((value) => ({ + value, + title: value.toUpperCase(), +})); + +function values(tabs: readonly Tab[]): string[] { + return tabs.map((tab) => tab.value); +} + +function setup(options: UseTabsOptions = {}) { + return renderHook(() => useTabs({ defaultTabs: TABS, ...options })); +} + +describe("useTabs", () => { + test("selects the first tab unless told otherwise", () => { + expect(setup().result.current.value).toBe("a"); + expect(setup({ defaultValue: "c" }).result.current.value).toBe("c"); + expect(renderHook(() => useTabs()).result.current.value).toBeNull(); + }); + + test("closing the selected tab selects its right neighbour", () => { + const hook = setup({ defaultValue: "b" }); + act(() => hook.result.current.close("b")); + expect(values(hook.result.current.tabs)).toEqual(["a", "c", "d"]); + expect(hook.result.current.value).toBe("c"); + }); + + test("closing the selected last tab selects its left neighbour", () => { + const hook = setup({ defaultValue: "d" }); + act(() => hook.result.current.close("d")); + expect(values(hook.result.current.tabs)).toEqual(["a", "b", "c"]); + expect(hook.result.current.value).toBe("c"); + }); + + test("closing another tab keeps the selection", () => { + const hook = setup({ defaultValue: "b" }); + act(() => hook.result.current.close("d")); + expect(hook.result.current.value).toBe("b"); + }); + + test("closing every tab leaves nothing selected", () => { + const hook = setup(); + act(() => { + for (const tab of TABS) hook.result.current.close(tab.value); + }); + expect(hook.result.current.tabs).toEqual([]); + expect(hook.result.current.value).toBeNull(); + }); + + test("closing a tab that is not open changes nothing", () => { + const onTabsChange = mock(); + const hook = setup({ onTabsChange }); + act(() => hook.result.current.close("z")); + expect(values(hook.result.current.tabs)).toEqual(["a", "b", "c", "d"]); + expect(onTabsChange).not.toHaveBeenCalled(); + }); + + test("moving clamps at both ends", () => { + const hook = setup(); + act(() => hook.result.current.move("b", 99)); + expect(values(hook.result.current.tabs)).toEqual(["a", "c", "d", "b"]); + act(() => hook.result.current.move("d", -5)); + expect(values(hook.result.current.tabs)).toEqual(["d", "a", "c", "b"]); + act(() => hook.result.current.move("c", 1)); + expect(values(hook.result.current.tabs)).toEqual(["d", "c", "a", "b"]); + }); + + test("moving to where a tab already is reports nothing", () => { + const onTabsChange = mock(); + const hook = setup({ onTabsChange }); + act(() => hook.result.current.move("a", -1)); + act(() => hook.result.current.move("d", 10)); + act(() => hook.result.current.move("z", 0)); + expect(onTabsChange).not.toHaveBeenCalled(); + }); + + test("changes in one event build on each other", () => { + const hook = setup({ defaultValue: "a" }); + act(() => { + hook.result.current.close("a"); + hook.result.current.close("b"); + hook.result.current.move("d", 0); + }); + expect(values(hook.result.current.tabs)).toEqual(["d", "c"]); + expect(hook.result.current.value).toBe("c"); + }); + + test("opening adds at the end, or replaces an open tab in place", () => { + const onTabsChange = mock(); + const hook = setup({ onTabsChange }); + act(() => hook.result.current.open({ value: "e", title: "E" })); + expect(values(hook.result.current.tabs)).toEqual(["a", "b", "c", "d", "e"]); + expect(hook.result.current.value).toBe("e"); + + act(() => hook.result.current.open({ value: "b", title: "B again" })); + expect(values(hook.result.current.tabs)).toEqual(["a", "b", "c", "d", "e"]); + expect(hook.result.current.tabs[1]?.title).toBe("B again"); + expect(hook.result.current.value).toBe("b"); + expect(onTabsChange).toHaveBeenCalledTimes(2); + }); + + test("updating changes a tab's data in place and keeps the selection", () => { + const onTabsChange = mock(); + const onValueChange = mock(); + const hook = setup({ defaultValue: "a", onTabsChange, onValueChange }); + act(() => hook.result.current.update("c", { title: "C, titled late" })); + expect(values(hook.result.current.tabs)).toEqual(["a", "b", "c", "d"]); + expect(hook.result.current.tabs[2]?.title).toBe("C, titled late"); + expect(hook.result.current.value).toBe("a"); + expect(onValueChange).not.toHaveBeenCalled(); + expect(onTabsChange).toHaveBeenCalledTimes(1); + }); + + test("updating a closed tab, or with nothing new, reports nothing", () => { + const onTabsChange = mock(); + const hook = setup({ onTabsChange }); + act(() => hook.result.current.close("c")); + onTabsChange.mockClear(); + act(() => hook.result.current.update("c", { title: "C" })); + act(() => hook.result.current.update("b", { title: "B" })); + expect(values(hook.result.current.tabs)).toEqual(["a", "b", "d"]); + expect(onTabsChange).not.toHaveBeenCalled(); + }); + + test("selects only open tabs", () => { + const hook = setup(); + act(() => hook.result.current.select("c")); + expect(hook.result.current.value).toBe("c"); + act(() => hook.result.current.select("z")); + expect(hook.result.current.value).toBe("c"); + }); + + test("a controlled selection is reported, not kept", () => { + const onValueChange = mock(); + const hook = setup({ value: "b", onValueChange }); + act(() => hook.result.current.close("b")); + expect(onValueChange).toHaveBeenLastCalledWith("c"); + expect(hook.result.current.value).toBe("b"); + }); + + test("closing a tab that is not selected reports no selection", () => { + const onValueChange = mock(); + const hook = setup({ defaultValue: "a", onValueChange }); + act(() => hook.result.current.close("c")); + expect(onValueChange).not.toHaveBeenCalled(); + expect(hook.result.current.value).toBe("a"); + }); + + test("closes in one event chain with a controlled selection", () => { + const onValueChange = mock(); + const hook = setup({ value: "a", onValueChange }); + act(() => { + for (const tab of TABS) hook.result.current.close(tab.value); + }); + expect(hook.result.current.tabs).toEqual([]); + expect(onValueChange.mock.calls).toEqual([["b"], ["c"], ["d"], [null]]); + }); + + test("a select a controlled parent turns down does not steer a later close", () => { + const onValueChange = mock(); + const hook = setup({ value: "a", onValueChange }); + act(() => hook.result.current.select("c")); + expect(onValueChange).toHaveBeenLastCalledWith("c"); + onValueChange.mockClear(); + act(() => hook.result.current.close("c")); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + test("a select and a close in one event chain when uncontrolled", () => { + const hook = setup({ defaultValue: "a" }); + act(() => { + hook.result.current.select("c"); + hook.result.current.close("c"); + }); + expect(hook.result.current.value).toBe("d"); + }); + + test("tabsProps and paneProps name the same ids", () => { + const hook = setup(); + const { tabsProps, paneProps } = hook.result.current; + const ids = panelTabIds(tabsProps.idPrefix, "a"); + expect(paneProps("a")).toEqual({ + id: ids.pane, + role: "tabpanel", + "aria-labelledby": ids.tab, + }); + }); +}); + +describe("panelTabIds", () => { + test("makes one valid, distinct id per value", () => { + const spaced = panelTabIds("p", "my file.ts"); + expect(spaced.tab).not.toMatch(/\s/); + expect(spaced.pane).not.toMatch(/\s/); + const seen = new Set( + ["a b", "a.b", "a_b", "a-b", "a:b", "a.20.b"].map( + (value) => panelTabIds("p", value).tab, + ), + ); + expect(seen.size).toBe(6); + }); +}); diff --git a/src/hooks/use-tabs.ts b/src/hooks/use-tabs.ts new file mode 100644 index 00000000..86e486de --- /dev/null +++ b/src/hooks/use-tabs.ts @@ -0,0 +1,226 @@ +import { useCallback, useId, useLayoutEffect, useRef, useState } from "react"; + +import { useControllableState } from "./use-controllable-state.js"; + +/** Anything shown as a tab: its `value` names it and is unique in the row. */ +export type TabItem = { readonly value: string }; + +/** + * The element ids that tie a tab to its pane. `value` is encoded, so a value + * with spaces or punctuation still makes one valid id each. + */ +export function panelTabIds( + idPrefix: string, + value: string, +): { readonly tab: string; readonly pane: string } { + // `.` never survives as itself, so the encoding cannot collide. + const key = value.replace( + /[^A-Za-z0-9_-]/g, + (char) => `.${char.charCodeAt(0).toString(36)}.`, + ); + return { tab: `${idPrefix}-tab-${key}`, pane: `${idPrefix}-pane-${key}` }; +} + +export type UseTabsOptions = { + /** The tabs open at first, in order. */ + readonly defaultTabs?: readonly Tab[] | undefined; + /** Called with the new row whenever a tab opens, updates, closes or moves. */ + readonly onTabsChange?: ((tabs: readonly Tab[]) => void) | undefined; + /** Controlled selected tab; `null` when no tab is open. */ + readonly value?: string | null | undefined; + /** Seeds the selected tab when `value` is not supplied; the first tab by default. */ + readonly defaultValue?: string | null | undefined; + readonly onValueChange?: ((value: string | null) => void) | undefined; +}; + +/** Spread onto `PanelTabs`. */ +export type PanelTabsStateProps = { + readonly value: string | null; + readonly onValueChange: (value: string) => void; + readonly onClose: (value: string) => void; + readonly onMove: (value: string, toIndex: number) => void; + readonly idPrefix: string; +}; + +/** Spread onto the pane a tab shows, such as a `PanelPane`. */ +export type PanelTabPaneProps = { + readonly id: string; + readonly role: "tabpanel"; + readonly "aria-labelledby": string; +}; + +export type UseTabsResult = { + readonly tabs: readonly Tab[]; + /** The selected tab; `null` once every tab has closed. */ + readonly value: string | null; + /** Adds `tab` at the end and selects it; a tab already open is replaced in place and selected. */ + readonly open: (tab: Tab) => void; + /** + * Merges `patch` into the open tab named `value`, such as a title that + * arrives late. It keeps the tab's place and the selection, and does + * nothing when that tab is not open or nothing in `patch` differs. + */ + readonly update: (value: string, patch: Partial>) => void; + readonly select: (value: string) => void; + /** + * Closes a tab. Closing the selected one selects its right neighbour, else + * its left; closing another leaves the selection alone and reports nothing. + */ + readonly close: (value: string) => void; + /** Moves a tab to `toIndex`, held within the row. */ + readonly move: (value: string, toIndex: number) => void; + readonly tabsProps: PanelTabsStateProps; + readonly paneProps: (value: string) => PanelTabPaneProps; +}; + +const NO_TABS: readonly never[] = []; + +/** + * The open tabs of a panel, in order, and which one is selected: everything + * `PanelTabs` asks its parent to decide. Spread `tabsProps` on `PanelTabs` + * and `paneProps(value)` on each pane so every tab names the pane it shows. + * + * The row itself is this hook's state, reported through `onTabsChange`; the + * selected tab can also be controlled with `value` and `onValueChange`. The + * selected value is always one of the open tabs, or `null` when none is open. + */ +export function useTabs( + options: UseTabsOptions = {}, +): UseTabsResult { + const { defaultTabs = NO_TABS, onTabsChange } = options; + const [tabs, setTabsState] = useState(defaultTabs); + // Mirrors the row synchronously, so two changes in one event chain. + const latestTabs = useRef(tabs); + const [value, setSelected] = useControllableState({ + value: options.value, + defaultValue: + options.defaultValue === undefined + ? (defaultTabs[0]?.value ?? null) + : options.defaultValue, + onChange: options.onValueChange, + name: "useTabs", + }); + // The selection as this event has left it, so a close sees an open or + // close before it in the same event, controlled or not. Each commit puts + // back the rendered selection, which is what a controlled parent decided. + const latestValue = useRef(value); + useLayoutEffect(() => { + latestValue.current = value; + }); + const controlled = options.value !== undefined; + const idPrefix = useId(); + + /** Selects alongside a change to the row, which always renders. */ + const setValue = useCallback( + (next: string | null) => { + latestValue.current = next; + setSelected(next); + }, + [setSelected], + ); + + const setTabs = useCallback( + (next: readonly Tab[]) => { + latestTabs.current = next; + setTabsState(next); + onTabsChange?.(next); + }, + [onTabsChange], + ); + + const open = useCallback( + (tab: Tab) => { + const previous = latestTabs.current; + const index = previous.findIndex((item) => item.value === tab.value); + setTabs( + index === -1 + ? [...previous, tab] + : previous.map((item, at) => (at === index ? tab : item)), + ); + setValue(tab.value); + }, + [setTabs, setValue], + ); + + const update = useCallback( + (updating: string, patch: Partial>) => { + const previous = latestTabs.current; + const index = previous.findIndex((item) => item.value === updating); + const tab = previous[index]; + if (tab === undefined) return; + const changed = Object.entries(patch).some( + ([key, next]) => !Object.is(tab[key as keyof Tab], next), + ); + if (!changed) return; + setTabs( + previous.map((item, at) => + at === index ? { ...item, ...patch } : item, + ), + ); + }, + [setTabs], + ); + + const select = useCallback( + (next: string) => { + if (!latestTabs.current.some((item) => item.value === next)) return; + // A controlled parent can turn a bare select down without rendering, + // and then no commit would put the ref back. + if (!controlled) latestValue.current = next; + setSelected(next); + }, + [controlled, setSelected], + ); + + const close = useCallback( + (closing: string) => { + const previous = latestTabs.current; + const index = previous.findIndex((item) => item.value === closing); + if (index === -1) return; + const after = (previous[index + 1] ?? previous[index - 1])?.value ?? null; + setTabs(previous.filter((_, at) => at !== index)); + if (latestValue.current === closing) setValue(after); + }, + [setTabs, setValue], + ); + + const move = useCallback( + (moving: string, toIndex: number) => { + const previous = latestTabs.current; + const from = previous.findIndex((item) => item.value === moving); + const to = Math.min(Math.max(toIndex, 0), previous.length - 1); + const tab = previous[from]; + if (tab === undefined || to === from) return; + const next = previous.filter((_, at) => at !== from); + next.splice(to, 0, tab); + setTabs(next); + }, + [setTabs], + ); + + const paneProps = useCallback( + (paneValue: string): PanelTabPaneProps => { + const ids = panelTabIds(idPrefix, paneValue); + return { id: ids.pane, role: "tabpanel", "aria-labelledby": ids.tab }; + }, + [idPrefix], + ); + + return { + tabs, + value, + open, + update, + select, + close, + move, + tabsProps: { + value, + onValueChange: select, + onClose: close, + onMove: move, + idPrefix, + }, + paneProps, + }; +} diff --git a/src/index.ts b/src/index.ts index 283451cd..e7e2eff0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,8 @@ export * from "./hooks/use-prefers-reduced-motion.js"; export * from "./hooks/use-scroll-current-into-view.js"; 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 "./lib/artifact.js"; export * from "./lib/chart-palette.js"; export * from "./lib/chat-message.js"; @@ -134,3 +136,4 @@ export * from "./ui/conversation-divider.js"; export * from "./ui/prompt-input.js"; export * from "./ui/panel.js"; export * from "./ui/prompt-input-toolbar.js"; +export * from "./ui/panel-tabs.js"; diff --git a/src/ui/panel-tabs.test.tsx b/src/ui/panel-tabs.test.tsx new file mode 100644 index 00000000..b9f6e46b --- /dev/null +++ b/src/ui/panel-tabs.test.tsx @@ -0,0 +1,413 @@ +import { act, createRef, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, test } from "bun:test"; + +import { useTabs } from "../hooks/use-tabs.js"; +import { PanelTab, PanelTabAdd, PanelTabs } from "./panel-tabs.js"; + +type Tab = { readonly value: string; readonly title: string }; + +const TABS: readonly Tab[] = [ + { value: "notes", title: "Notes" }, + { value: "draft", title: "Draft" }, + { value: "log", title: "Log" }, +]; + +function Strip({ + withAdd = false, + movedMessage, +}: { + readonly withAdd?: boolean; + readonly movedMessage?: (position: number, count: number) => string; +}) { + const { tabs, tabsProps, paneProps } = useTabs({ defaultTabs: TABS }); + return ( +
+ + {tabs.map((tab) => ( + + {tab.title} + + ))} + + {withAdd ? : null} + {tabsProps.value === null ? null : ( +
{tabsProps.value}
+ )} +
+ ); +} + +/** Every pane kept mounted, each holding a control, as a host would build them. */ +function KeptPanes() { + const { tabs, value, tabsProps, paneProps } = useTabs({ + defaultTabs: TABS, + }); + return ( +
+ + {tabs.map((tab) => ( + + {tab.title} + + ))} + + {tabs.map((tab) => ( + + ))} +
+ ); +} + +function ControlledStrip() { + const [selected, setSelected] = useState("draft"); + const { tabs, tabsProps } = useTabs({ + defaultTabs: TABS, + value: selected, + onValueChange: setSelected, + }); + return ( +
+ + {tabs.map((tab) => ( + + {tab.title} + + ))} + +
+ ); +} + +let root: Root | null = null; +let container: HTMLElement | null = null; + +function mount(node: React.ReactElement): HTMLElement { + container = document.createElement("div"); + document.body.appendChild(container); + const target = container; + act(() => { + root = createRoot(target); + root.render(node); + }); + return target; +} + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +function tabs(host: HTMLElement): HTMLElement[] { + return [...host.querySelectorAll("[role='tab']")]; +} + +function tab(host: HTMLElement, name: string): HTMLElement { + const found = tabs(host).find((element) => element.textContent === name); + if (found === undefined) throw new Error(`no tab named ${name}`); + return found; +} + +function press(target: HTMLElement, key: string, init: KeyboardEventInit = {}) { + act(() => { + target.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, ...init }), + ); + }); +} + +describe("PanelTabs", () => { + test("only the selected tab is a tab stop and names its pane", () => { + const host = mount(); + expect(tabs(host).map((element) => element.tabIndex)).toEqual([0, -1, -1]); + const notes = tab(host, "Notes"); + expect(notes.getAttribute("aria-selected")).toBe("true"); + const pane = host.querySelector("[role='tabpanel']"); + expect(notes.getAttribute("aria-controls")).toBe(pane?.id ?? "missing"); + expect(pane?.getAttribute("aria-labelledby")).toBe(notes.id); + expect(tab(host, "Draft").hasAttribute("aria-controls")).toBe(false); + }); + + test("arrows, Home and End select and focus as they go", () => { + const host = mount(); + tab(host, "Notes").focus(); + press(tab(host, "Notes"), "ArrowRight"); + expect(document.activeElement).toBe(tab(host, "Draft")); + expect(tab(host, "Draft").getAttribute("aria-selected")).toBe("true"); + press(tab(host, "Draft"), "End"); + expect(document.activeElement).toBe(tab(host, "Log")); + press(tab(host, "Log"), "ArrowRight"); + expect(document.activeElement).toBe(tab(host, "Notes")); + press(tab(host, "Notes"), "ArrowLeft"); + expect(document.activeElement).toBe(tab(host, "Log")); + press(tab(host, "Log"), "Home"); + expect(document.activeElement).toBe(tab(host, "Notes")); + }); + + test("Delete closes the focused tab and focuses the one that takes over", () => { + const host = mount(); + tab(host, "Notes").focus(); + press(tab(host, "Notes"), "Delete"); + expect(tabs(host).map((element) => element.textContent)).toEqual([ + "Draft", + "Log", + ]); + expect(document.activeElement).toBe(tab(host, "Draft")); + expect(tab(host, "Draft").tabIndex).toBe(0); + }); + + test("Backspace, the Mac delete key, closes the focused tab too", () => { + const host = mount(); + expect(tab(host, "Notes").getAttribute("aria-keyshortcuts")).toStartWith( + "Delete Backspace ", + ); + tab(host, "Notes").focus(); + press(tab(host, "Notes"), "Backspace"); + expect(tabs(host).map((element) => element.textContent)).toEqual([ + "Draft", + "Log", + ]); + expect(document.activeElement).toBe(tab(host, "Draft")); + }); + + test("with a controlled selection, Delete focuses the tab that takes over", () => { + const host = mount(); + tab(host, "Draft").focus(); + press(tab(host, "Draft"), "Delete"); + expect(tabs(host).map((element) => element.textContent)).toEqual([ + "Notes", + "Log", + ]); + expect(tab(host, "Log").getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(tab(host, "Log")); + }); + + test("under dir=rtl, ArrowLeft goes to the next tab", () => { + const host = mount( +
+ +
, + ); + tab(host, "Notes").focus(); + press(tab(host, "Notes"), "ArrowLeft"); + expect(document.activeElement).toBe(tab(host, "Draft")); + }); + + test("host props cannot unlink a tab, and refs land on the row and the tab's root", () => { + const rowRef = createRef(); + const rootRef = createRef(); + const host = mount( + {}} + > + + A + + , + ); + const button = tab(host, "A"); + expect(button.id).not.toBe("mine"); + expect(button.tabIndex).toBe(0); + expect(button.getAttribute("aria-selected")).toBe("true"); + expect(button.parentElement).toBe(rootRef.current); + expect(button.parentElement?.dataset["slot"]).toBe("panel-tab"); + expect(rowRef.current).toBe(host.querySelector("[role='tablist']")); + }); + + test("closing the last tab sends focus to the +", () => { + const host = mount(); + tab(host, "Notes").focus(); + for (const _ of TABS) + press(document.activeElement as HTMLElement, "Delete"); + expect(tabs(host)).toEqual([]); + expect(document.activeElement?.getAttribute("aria-label")).toBe( + "Open a tab", + ); + }); + + test("Ctrl or Cmd with Shift and an arrow moves the tab, keeping focus", () => { + const host = mount(); + tab(host, "Notes").focus(); + press(tab(host, "Notes"), "ArrowRight", { ctrlKey: true, shiftKey: true }); + expect(tabs(host).map((element) => element.textContent)).toEqual([ + "Draft", + "Notes", + "Log", + ]); + expect(document.activeElement).toBe(tab(host, "Notes")); + press(tab(host, "Notes"), "ArrowRight", { metaKey: true, shiftKey: true }); + press(tab(host, "Notes"), "ArrowRight", { metaKey: true, shiftKey: true }); + expect(tabs(host).map((element) => element.textContent)).toEqual([ + "Draft", + "Log", + "Notes", + ]); + expect(tab(host, "Notes").getAttribute("aria-selected")).toBe("true"); + }); + + test("the × and a middle-click close a tab without taking focus", () => { + const host = mount(); + const outside = document.createElement("button"); + document.body.appendChild(outside); + outside.focus(); + const draft = tab(host, "Draft").closest( + "[data-slot='panel-tab']", + ); + act(() => { + draft + ?.querySelector("[data-slot='panel-tab-close']") + ?.click(); + }); + expect(tabs(host).map((element) => element.textContent)).toEqual([ + "Notes", + "Log", + ]); + const log = tab(host, "Log").closest( + "[data-slot='panel-tab']", + ); + act(() => { + log?.dispatchEvent( + new MouseEvent("auxclick", { button: 1, bubbles: true }), + ); + }); + expect(tabs(host).map((element) => element.textContent)).toEqual(["Notes"]); + expect(document.activeElement).toBe(outside); + outside.remove(); + }); + + describe("closing the selected tab while a control inside its pane has focus", () => { + function paneControl(host: HTMLElement, name: string): HTMLElement { + const found = [...host.querySelectorAll("button")].find( + (element) => element.textContent === `${name} control`, + ); + if (found === undefined) throw new Error(`no control named ${name}`); + return found; + } + + function tabRoot(host: HTMLElement, name: string): HTMLElement { + const root = tab(host, name).closest( + "[data-slot='panel-tab']", + ); + if (root === null) throw new Error(`no root for ${name}`); + return root; + } + + test("the × moves focus to the newly selected tab", () => { + const host = mount(); + paneControl(host, "Notes").focus(); + act(() => { + tabRoot(host, "Notes") + .querySelector("[data-slot='panel-tab-close']") + ?.click(); + }); + expect(tab(host, "Draft").getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(tab(host, "Draft")); + }); + + test("a middle-click moves focus to the newly selected tab", () => { + const host = mount(); + paneControl(host, "Notes").focus(); + act(() => { + tabRoot(host, "Notes").dispatchEvent( + new MouseEvent("auxclick", { button: 1, bubbles: true }), + ); + }); + expect(tab(host, "Draft").getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(tab(host, "Draft")); + }); + + test("closing another tab leaves focus on the pane control", () => { + const host = mount(); + paneControl(host, "Notes").focus(); + act(() => { + tabRoot(host, "Log") + .querySelector("[data-slot='panel-tab-close']") + ?.click(); + }); + expect(document.activeElement).toBe(paneControl(host, "Notes")); + }); + }); + + test("a move is announced politely with the tab's new position", () => { + const host = mount(); + const status = host.querySelector("[role='status']"); + expect(status?.getAttribute("aria-live")).toBe("polite"); + expect(status?.textContent).toBe(""); + tab(host, "Notes").focus(); + press(tab(host, "Notes"), "ArrowRight", { ctrlKey: true, shiftKey: true }); + expect(status?.textContent).toBe("Moved to position 2 of 3"); + // The same words again still land as a new node, so they are re-read. + const first = status?.firstElementChild; + press(tab(host, "Notes"), "ArrowLeft", { ctrlKey: true, shiftKey: true }); + press(tab(host, "Notes"), "ArrowRight", { ctrlKey: true, shiftKey: true }); + expect(status?.textContent).toBe("Moved to position 2 of 3"); + expect(status?.firstElementChild).not.toBe(first); + }); + + test("the move announcement can be worded by the host", () => { + const host = mount( + `Posición ${position} de ${count}`} + />, + ); + tab(host, "Notes").focus(); + press(tab(host, "Notes"), "End", {}); + press(tab(host, "Log"), "ArrowLeft", { ctrlKey: true, shiftKey: true }); + expect(host.querySelector("[role='status']")?.textContent).toBe( + "Posición 2 de 3", + ); + }); + + test("a move the host turns down is not announced", () => { + const host = mount( + {}} + onMove={() => {}} + > + A + B + , + ); + tab(host, "A").focus(); + press(tab(host, "A"), "ArrowRight", { ctrlKey: true, shiftKey: true }); + expect(host.querySelector("[role='status']")?.textContent).toBe(""); + }); + + test("a row that cannot move tabs has no status region", () => { + const host = mount( + {}}> + A + , + ); + expect(host.querySelector("[role='status']")).toBeNull(); + }); + + test("a tab outside PanelTabs throws", () => { + const original = console.error; + console.error = () => {}; + try { + expect(() => mount(X)).toThrow( + "PanelTab must be inside PanelTabs.", + ); + } finally { + console.error = original; + } + }); +}); diff --git a/src/ui/panel-tabs.tsx b/src/ui/panel-tabs.tsx new file mode 100644 index 00000000..a37ac649 --- /dev/null +++ b/src/ui/panel-tabs.tsx @@ -0,0 +1,557 @@ +"use client"; + +import { Plus, X } from "lucide-react"; +import { + createContext, + forwardRef, + useCallback, + useContext, + useEffect, + useId, + useRef, + useState, +} from "react"; +import type * as React from "react"; +import { flushSync } from "react-dom"; + +import { useScrollCurrentIntoView } from "../hooks/use-scroll-current-into-view.js"; +import { useTabDrag } from "../hooks/use-tab-drag.js"; +import { panelTabIds } from "../hooks/use-tabs.js"; +import { cn } from "../lib/utils.js"; +import { Button } from "./button.js"; + +const isProduction = + typeof process !== "undefined" && process.env?.NODE_ENV === "production"; + +const TAB_SELECTOR = "[data-slot='panel-tab']"; + +/** + * The classes for the `PanelHeader` that holds a `PanelTabs`: the row sits on + * the header's lower border, so the selected tab joins the content below. + */ +export const panelTabsHeaderClassName = "items-end bg-muted pt-1.5 pb-0"; + +function defaultMovedMessage(position: number, count: number): string { + return `Moved to position ${position} of ${count}`; +} + +type PanelTabsContextValue = { + readonly value: string | null; + readonly idPrefix: string; + /** Whether tabs point at their panes with `aria-controls`. */ + readonly linked: boolean; + readonly closable: boolean; + readonly movable: boolean; + readonly activeRef: React.RefObject; + readonly select: (value: string) => void; + readonly close: (value: string) => void; + readonly onTabKeyDown: ( + event: React.KeyboardEvent, + value: string, + ) => void; + readonly onTabPointerDown: ( + event: React.PointerEvent, + value: string, + ) => void; +}; + +const PanelTabsContext = createContext(null); + +function tabElements(list: HTMLElement): HTMLElement[] { + return [...list.querySelectorAll(TAB_SELECTOR)]; +} + +function tabButton(list: HTMLElement, value: string): HTMLElement | null { + const tab = tabElements(list).find( + (element) => element.dataset["value"] === value, + ); + return tab?.querySelector("[role='tab']") ?? null; +} + +export type PanelTabsProps = Omit< + React.ComponentProps<"div">, + "defaultValue" | "onChange" | "role" +> & { + /** Names the row for assistive technology: "Open items". */ + readonly label: string; + /** The selected tab, or `null` when none is open. */ + readonly value: string | null; + /** Called with the tab to select: by click, or by the arrow keys. */ + readonly onValueChange: (value: string) => void; + /** + * Called to close a tab: by its ×, a middle-click, or Delete or + * Backspace. The parent removes it, and selects another when it was the + * selected one. Without it tabs have no ×. + */ + readonly onClose?: ((value: string) => void) | undefined; + /** Called to move a tab to `toIndex`: by drag, or Ctrl/Cmd+Shift+Arrow. */ + readonly onMove?: ((value: string, toIndex: number) => void) | undefined; + /** + * What a screen reader hears once a tab has moved, given its new 1-based + * position and the number of tabs. "Moved to position 2 of 3" by default. + */ + readonly movedMessage?: + | ((position: number, count: number) => string) + | undefined; + /** + * The prefix of each tab's id and its pane's id (see `panelTabIds`). With + * it the selected tab points at its pane through `aria-controls`; give + * each pane its id, `role="tabpanel"` and `aria-labelledby`. `useTabs` + * supplies it in `tabsProps` and the pane's in `paneProps`. + */ + readonly idPrefix?: string | undefined; +}; + +/** + * A row of tabs for a `Panel`, holding `PanelTab`s. It owns no state: the + * parent passes which tab is selected and what to do on select, close and + * move, usually by spreading `useTabs`' `tabsProps`. + * + * Keyboard: Left/Right arrows, Home and End move between tabs, selecting as + * they go; Delete or Backspace (the Mac "delete" key) closes the focused tab; + * Ctrl/Cmd+Shift with an arrow moves it along the row. Pointer: click + * selects, drag reorders (the others make room as it passes), middle-click + * or the × closes. A long row scrolls sideways and keeps the selected tab in + * view. Every move is announced through a polite status region beside the + * row. + * + * Only a mouse or pen drags: a touch drag scrolls the row instead, since a + * long row has no other way to scroll on a touch screen. + * + * The × is pointer-only by design: a tablist may contain only tabs, so it is + * hidden from assistive technology and never takes focus. Keyboard users + * close with Delete or Backspace instead. A touch screen-reader user (no + * keyboard, and swipe navigation skips the ×) cannot close a tab from the + * row, so a host serving them should also offer a named close control for + * the selected tab, such as a button in the `PanelHeader` that calls + * `useTabs`' `close`. + * + * It sits on the panel header's lower edge, with the selected tab joined to + * the content below. Give the `PanelHeader` that holds it + * `className={panelTabsHeaderClassName}` so the row sits on the header's + * border and the selected tab covers it. A `PanelTabAdd` goes beside it in + * the header, not inside it. + */ +export const PanelTabs = forwardRef( + function PanelTabs( + { + label, + value, + onValueChange, + onClose, + onMove, + movedMessage = defaultMovedMessage, + idPrefix, + className, + children, + ...props + }, + ref, + ) { + const generatedPrefix = useId(); + const listRef = useRef(null); + // Each move is a new node, so the same words twice are still read out. + const [announcement, setAnnouncement] = useState<{ + readonly id: number; + readonly text: string; + } | null>(null); + const activeRef = useScrollCurrentIntoView(value ?? ""); + + const setList = useCallback( + (element: HTMLDivElement | null) => { + listRef.current = element; + if (typeof ref === "function") ref(element); + else if (ref !== null && ref !== undefined) ref.current = element; + }, + [ref], + ); + + useEffect(() => { + const list = listRef.current; + if (isProduction || list === null) return; + const tabs = tabElements(list); + if ( + tabs.length > 0 && + !tabs.some((tab) => tab.dataset["value"] === value) + ) + console.error( + "PanelTabs: `value` names none of its tabs, so no tab can take focus with Tab.", + ); + }); + + /** Puts focus back on the selected tab once a close or move has rendered. */ + const refocus = useCallback( + (list: HTMLElement, panel: Element | null, moved: string | null) => { + if (list.isConnected) { + const target = + moved === null + ? list.querySelector( + "[role='tab'][aria-selected='true']", + ) + : tabButton(list, moved); + if (target !== null) { + // A move scrolls the tab into view here; after a close the new + // selection's own scroll does that. + target.focus({ preventScroll: moved === null }); + return; + } + } + // The last tab closed and the panel stays open: go to its + if it has + // one, else to the panel itself. A panel that closed has already + // returned focus to whatever opened it. + if (!(panel instanceof HTMLElement) || !panel.isConnected) return; + const add = panel.querySelector( + "[data-slot='panel-tab-add']", + ); + (add ?? panel).focus(); + }, + [], + ); + + const close = useCallback( + (closing: string) => { + const list = listRef.current; + if (list === null || onClose === undefined) return; + const focused = document.activeElement; + const hadFocus = list.contains(focused); + const panel = list.closest("[data-slot='panel']"); + // Rendered before focus moves, so a held Delete always acts on the + // tab that has focus now. + flushSync(() => onClose(closing)); + // Focus was in the row, or in the closed tab's pane, which has just + // left the document: the × and a middle-click never take focus. + if (hadFocus || (focused !== null && !focused.isConnected)) + refocus(list, panel, null); + }, + [onClose, refocus], + ); + + /** + * Moves a tab and announces it. Focus that was in the row goes to + * `focusing`, or to the selected tab when it is `null`. + */ + const move = useCallback( + (moving: string, toIndex: number, focusing: string | null) => { + const list = listRef.current; + if (list === null || onMove === undefined) return; + const hadFocus = list.contains(document.activeElement); + const position = () => + tabElements(list).findIndex( + (element) => element.dataset["value"] === moving, + ); + const from = position(); + flushSync(() => onMove(moving, toIndex)); + // React re-inserts moved nodes, which can drop focus from them. + if (hadFocus) refocus(list, null, focusing); + const to = position(); + if (to === -1 || to === from) return; + const text = movedMessage(to + 1, tabElements(list).length); + setAnnouncement((previous) => ({ id: (previous?.id ?? 0) + 1, text })); + }, + [onMove, movedMessage, refocus], + ); + + // The press that starts a drag focuses its tab, but a drag never selects + // it: afterwards focus in the row goes back to the selected tab, the + // row's one tab stop, whether the tab moved or not. + const { onPointerDown: onTabPointerDown } = useTabDrag({ + listRef, + onMove: + onMove === undefined + ? undefined + : (dragged, toIndex) => move(dragged, toIndex, null), + onDragEnd: () => { + const list = listRef.current; + if (list !== null && list.contains(document.activeElement)) + refocus(list, null, null); + }, + itemSelector: TAB_SELECTOR, + }); + + const onTabKeyDown = ( + event: React.KeyboardEvent, + current: string, + ) => { + const list = listRef.current; + if (list === null) return; + const values = tabElements(list).map( + (element) => element.dataset["value"] ?? "", + ); + const index = values.indexOf(current); + const rtl = getComputedStyle(list).direction === "rtl"; + const step = + event.key === (rtl ? "ArrowLeft" : "ArrowRight") + ? 1 + : event.key === (rtl ? "ArrowRight" : "ArrowLeft") + ? -1 + : 0; + + if (step !== 0 && (event.ctrlKey || event.metaKey) && event.shiftKey) { + if (onMove === undefined) return; + event.preventDefault(); + const to = Math.min(Math.max(index + step, 0), values.length - 1); + if (to !== index) move(current, to, current); + return; + } + if (event.altKey || event.ctrlKey || event.metaKey) return; + + if (event.key === "Delete" || event.key === "Backspace") { + if (onClose === undefined) return; + event.preventDefault(); + close(current); + return; + } + const next = + step !== 0 + ? values[(index + step + values.length) % values.length] + : event.key === "Home" + ? values[0] + : event.key === "End" + ? values.at(-1) + : undefined; + if (next === undefined) return; + event.preventDefault(); + onValueChange(next); + // The selection scrolls the tab into view itself. + tabButton(list, next)?.focus({ preventScroll: true }); + }; + + return ( + +
+ {children} +
+ {onMove === undefined ? null : ( + // Beside the row, not in it: a tablist may hold only tabs. +
+ {announcement === null ? null : ( + {announcement.text} + )} +
+ )} +
+ ); + }, +); + +export type PanelTabProps = Omit< + React.ComponentProps<"button">, + "value" | "type" | "role" | "children" | "id" | "tabIndex" | "ref" +> & { + /** The tab's root element, which wraps the button and its ×. */ + readonly ref?: React.Ref | undefined; + /** Names the tab; matched against `PanelTabs`' `value`. */ + readonly value: string; + /** Shown before the label. */ + readonly icon?: React.ReactNode; + /** The tab's label. */ + readonly children: React.ReactNode; + /** The ×'s tooltip. */ + readonly closeLabel?: string; +}; + +/** + * One tab. `ref` and `className` go to its root; every other prop goes to + * its button, the element with `role="tab"`. The button's `id`, `tabIndex`, + * `role` and ARIA state tie it to its pane and the row's single tab stop, so + * they are not overridable. A long label is cut short, so pass `title` with + * the full text. + */ +export const PanelTab = forwardRef( + function PanelTab( + { + value, + icon, + closeLabel = "Close tab", + className, + children, + onClick, + onKeyDown, + ...props + }, + ref, + ) { + const context = useContext(PanelTabsContext); + if (context === null) throw new Error("PanelTab must be inside PanelTabs."); + const { + value: selectedValue, + idPrefix, + linked, + closable, + movable, + activeRef, + select, + close, + onTabKeyDown, + onTabPointerDown, + } = context; + const selected = value === selectedValue; + const ids = panelTabIds(idPrefix, value); + const setRoot = useCallback( + (element: HTMLDivElement | null) => { + if (selected) activeRef.current = element; + if (typeof ref === "function") ref(element); + else if (ref !== null && ref !== undefined) ref.current = element; + }, + [selected, activeRef, ref], + ); + const shortcuts = [ + closable ? "Delete Backspace" : null, + movable + ? "Control+Shift+ArrowLeft Control+Shift+ArrowRight Meta+Shift+ArrowLeft Meta+Shift+ArrowRight" + : null, + ].filter((shortcut) => shortcut !== null); + + return ( +
onTabPointerDown(event, value) : undefined + } + onMouseDown={ + closable + ? (event) => { + // Middle-click closes; without this it would start autoscroll. + if (event.button === 1) event.preventDefault(); + } + : undefined + } + onAuxClick={ + closable + ? (event) => { + if (event.button === 1) close(value); + } + : undefined + } + className={cn( + "group/tab relative flex h-8 max-w-45 min-w-18 items-center rounded-t-md border border-b-0 border-transparent text-muted-foreground select-none", + "hover:bg-background/60 hover:text-foreground", + "data-[state=active]:z-10 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground", + "motion-safe:group-data-[dragging]/tabs:transition-transform motion-safe:group-data-[dragging]/tabs:duration-150 motion-safe:group-data-[dragging]/tabs:ease-out", + "data-[dragging]:z-20 data-[dragging]:cursor-grabbing data-[dragging]:border-border data-[dragging]:bg-background data-[dragging]:shadow-surface motion-safe:data-[dragging]:transition-none", + className, + )} + > + + {closable ? ( + // A mouse affordance, hidden from assistive technology: the tab list + // may hold only tabs, so keyboard and screen-reader users close the + // focused tab with Delete or Backspace, which the tab announces in + // `aria-keyshortcuts`. Never focusable, so focus stays on the tab. + + ) : null} +
+ ); + }, +); + +export type PanelTabAddProps = Omit< + React.ComponentProps<"button">, + "children" +> & { + /** The button's accessible name. */ + readonly label?: string; +}; + +/** + * The + beside a `PanelTabs` row, for opening another tab. It is a plain + * button: handle `onClick`, or wrap it in a menu trigger. + */ +export function PanelTabAdd({ + label = "Open a tab", + className, + ...props +}: PanelTabAddProps) { + return ( + + ); +} diff --git a/stories/chat/panel-tabs.stories.tsx b/stories/chat/panel-tabs.stories.tsx new file mode 100644 index 00000000..280f874a --- /dev/null +++ b/stories/chat/panel-tabs.stories.tsx @@ -0,0 +1,202 @@ +import { FileText, MessageSquare, Table2, X } from "lucide-react"; +import { useId, useState } from "react"; +import type { ReactNode } from "react"; + +import { useTabs } from "../../src/hooks/use-tabs.js"; +import { Button } from "../../src/ui/button.js"; +import { + Panel, + PanelActions, + PanelClose, + PanelContent, + PanelHandle, + PanelHeader, + PanelPane, +} from "../../src/ui/panel.js"; +import { + PanelTab, + PanelTabAdd, + PanelTabs, + panelTabsHeaderClassName, +} from "../../src/ui/panel-tabs.js"; + +export default { title: "Chat / Panel Tabs" }; + +type Kind = "thread" | "artifact" | "table"; + +type Item = { + readonly value: string; + readonly title: string; + readonly kind: Kind; +}; + +const ICONS: Record = { + thread: , + artifact: , + table: , +}; + +const ITEMS: readonly Item[] = [ + { value: "thread:intro", title: "Thread: weekly summary", kind: "thread" }, + { value: "artifact:report", title: "Quarterly report", kind: "artifact" }, + { value: "table:accounts", title: "Accounts", kind: "table" }, + { value: "artifact:plan", title: "Launch plan", kind: "artifact" }, + { value: "thread:review", title: "Thread: review notes", kind: "thread" }, + { value: "table:usage", title: "Usage by region", kind: "table" }, + { value: "artifact:brief", title: "Design brief", kind: "artifact" }, + { value: "thread:budget", title: "Thread: budget questions", kind: "thread" }, + { value: "artifact:memo", title: "Hiring memo", kind: "artifact" }, + { value: "table:invoices", title: "Open invoices", kind: "table" }, + { value: "artifact:roadmap", title: "Roadmap draft", kind: "artifact" }, + { + value: "thread:support", + title: "Thread: support escalation", + kind: "thread", + }, +]; + +/** What another press of + opens. */ +function nextItem(count: number): Item { + return { + value: `artifact:new-${count}`, + title: `Untitled ${count}`, + kind: "artifact", + }; +} + +function Pane({ item }: { readonly item: Item }) { + return ( +
+

{item.title}

+ {Array.from({ length: 12 }, (_, index) => ( +

+ Line {index + 1} of {item.title.toLowerCase()}: the agent noted what + changed since the last version and what still needs a look. +

+ ))} +
+ ); +} + +/** + * A panel of tabs beside a main area. Click, drag or use the keyboard: + * arrows, Home and End move; Delete or Backspace closes; Ctrl/Cmd+Shift+Arrow moves the + * tab. Closing the last tab closes the panel. + * + * The button that opens and closes the panel stays in the main area while + * the panel is open, so the panel records it as the opener and gives it focus + * back however the panel closes. + * + * The tab's × is hidden from assistive technology, so the header also has a + * named "Close tab" button for the selected tab: the close a touch + * screen-reader user can reach. + */ +function TabbedPanel({ + initial, + defaultValue, +}: { + readonly initial: readonly Item[]; + readonly defaultValue?: string; +}) { + const { tabs, value, open, close, tabsProps, paneProps } = useTabs({ + defaultTabs: initial, + defaultValue, + }); + const panelId = useId(); + const [added, setAdded] = useState(0); + const [closed, setClosed] = useState(false); + const shown = closed ? null : value; + return ( +
+
+

Conversation

+

+ {tabs.length} open {tabs.length === 1 ? "tab" : "tabs"}. +

+ +
+ {shown !== null ? ( + + + + + {tabs.map((tab) => ( + + {tab.title} + + ))} + + { + const count = added + 1; + setAdded(count); + open(nextItem(count)); + }} + /> + + + setClosed(true)} /> + + + + {tabs.map((tab) => ( + + + + ))} + + + ) : null} +
+ ); +} + +/** A single open item: its × closes the panel. */ +export const OneTab = () => ; + +/** A few items, with the second in front. */ +export const SeveralTabs = () => ( + +); + +/** + * More tabs than fit: the row scrolls sideways and keeps the selected tab in + * view, here one near the end. + */ +export const TooManyTabs = () => ( + +);