From fb4e75f9503c14122f659280ff8bff1d980dd3e4 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 00:27:43 +0530 Subject: [PATCH] feat(panel): add tabs for the Panel Add PanelTabs, PanelTab and PanelTabAdd. Tabs select by click, close by the close button, middle-click or Delete, reorder by drag and by Ctrl/Cmd+Shift+Arrow, and move by Arrow, Home and End; a long row keeps the selected tab in view. useTabs owns the open tabs and which one is selected, and useTabDrag reorders without rendering until release. These are separate from the existing Tabs. --- README.md | 1 + e2e/fixtures.tsx | 74 +++++ e2e/panel-tabs.spec.ts | 142 +++++++++ src/hooks/use-tab-drag.ts | 166 ++++++++++ src/hooks/use-tabs.test.ts | 173 ++++++++++ src/hooks/use-tabs.ts | 203 ++++++++++++ src/index.ts | 3 + src/ui/panel-tabs.test.tsx | 264 +++++++++++++++ src/ui/panel-tabs.tsx | 476 ++++++++++++++++++++++++++++ stories/chat/panel-tabs.stories.tsx | 175 ++++++++++ 10 files changed, 1677 insertions(+) create mode 100644 e2e/panel-tabs.spec.ts create mode 100644 src/hooks/use-tab-drag.ts create mode 100644 src/hooks/use-tabs.test.ts create mode 100644 src/hooks/use-tabs.ts create mode 100644 src/ui/panel-tabs.test.tsx create mode 100644 src/ui/panel-tabs.tsx create mode 100644 stories/chat/panel-tabs.stories.tsx diff --git a/README.md b/README.md index 51e7f7b6..23366e66 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 caef68d0..3c33977e 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -42,6 +42,10 @@ import { PromptInputFloat, PromptInputContext, PromptInputNote, + PanelTab, + PanelTabAdd, + PanelTabs, + useTabs, } from "@corbits/react-ui"; import { CommandPalette, @@ -681,6 +685,75 @@ function PromptToolbarScenario() { ); } +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, 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": () => , @@ -717,4 +790,5 @@ export const scenarios: Record ReactNode> = { "prompt-input-attachment": () => , panel: () => , "prompt-input-toolbar": () => , + "panel-tabs": () => , }; diff --git a/e2e/panel-tabs.spec.ts b/e2e/panel-tabs.spec.ts new file mode 100644 index 00000000..b6a68fd7 --- /dev/null +++ b/e2e/panel-tabs.spec.ts @@ -0,0 +1,142 @@ +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. + await expect(page.getByRole("status", { name: "Selected" })).toHaveText( + "alpha", + ); +}); + +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("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); +}); diff --git a/src/hooks/use-tab-drag.ts b/src/hooks/use-tab-drag.ts new file mode 100644 index 00000000..d28ef1e4 --- /dev/null +++ b/src/hooks/use-tab-drag.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, 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; + /** 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, and the click that + * ends a drag is swallowed so it does not also act on the item. + */ +export function useTabDrag({ + listRef, + onMove, + itemSelector = "[data-slot='panel-tab']", +}: UseTabDragOptions): UseTabDragResult { + const onMoveRef = useRef(onMove); + onMoveRef.current = onMove; + 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(); + }; + + const finish = (commit: boolean) => { + 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); + window.addEventListener("click", swallowClick, true); + setTimeout(() => + window.removeEventListener("click", swallowClick, true), + ); + if (commit && target !== from) onMoveRef.current?.(value, target); + }; + + function onPointerUp(upEvent: PointerEvent) { + if (upEvent.pointerId === pointerId) finish(true); + } + function onCancel(cancelEvent: PointerEvent) { + if (cancelEvent.pointerId === pointerId) finish(false); + } + function onKeyDown(keyEvent: KeyboardEvent) { + if (keyEvent.key !== "Escape" || !dragging) return; + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + finish(false); + } + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("keydown", onKeyDown, true); + stopRef.current = () => finish(false); + }, + [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..30b541cd --- /dev/null +++ b/src/hooks/use-tabs.test.ts @@ -0,0 +1,173 @@ +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("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..7a96f79a --- /dev/null +++ b/src/hooks/use-tabs.ts @@ -0,0 +1,203 @@ +import { useCallback, useId, 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. */ + 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, setValue] = useControllableState({ + value: options.value, + defaultValue: + options.defaultValue === undefined + ? (defaultTabs[0]?.value ?? null) + : options.defaultValue, + onChange: options.onValueChange, + name: "useTabs", + }); + const idPrefix = useId(); + + 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)) + setValue(next); + }, + [setValue], + ); + + 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)); + setValue((current) => (current === closing ? after : current)); + }, + [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 a5549eac..64bc22f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,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"; @@ -133,3 +135,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..6e8a79eb --- /dev/null +++ b/src/ui/panel-tabs.test.tsx @@ -0,0 +1,264 @@ +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 }: { readonly withAdd?: boolean }) { + const { tabs, tabsProps, paneProps } = useTabs({ defaultTabs: TABS }); + return ( +
+ + {tabs.map((tab) => ( + + {tab.title} + + ))} + + {withAdd ? : null} + {tabsProps.value === null ? null : ( +
{tabsProps.value}
+ )} +
+ ); +} + +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 ref lands on its root", () => { + 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"); + }); + + 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(); + }); + + 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..79c9c74c --- /dev/null +++ b/src/ui/panel-tabs.tsx @@ -0,0 +1,476 @@ +"use client"; + +import { Plus, X } from "lucide-react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useRef, +} 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']"; + +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; + /** + * 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. + * + * 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="items-end bg-muted pt-1.5 pb-0"` 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 function PanelTabs({ + label, + value, + onValueChange, + onClose, + onMove, + idPrefix, + ref, + className, + children, + ...props +}: PanelTabsProps) { + const generatedPrefix = useId(); + const listRef = useRef(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 hadFocus = list.contains(document.activeElement); + 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)); + if (hadFocus) refocus(list, panel, null); + }, + [onClose, refocus], + ); + + const move = useCallback( + (moving: string, toIndex: number) => { + const list = listRef.current; + if (list === null || onMove === undefined) return; + const hadFocus = list.contains(document.activeElement); + flushSync(() => onMove(moving, toIndex)); + // React re-inserts moved nodes, which can drop focus from them. + if (hadFocus) refocus(list, null, moving); + }, + [onMove, refocus], + ); + + const { onPointerDown: onTabPointerDown } = useTabDrag({ + listRef, + onMove: onMove === undefined ? undefined : move, + 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); + 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} +
+
+ ); +} + +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 function PanelTab({ + value, + icon, + closeLabel = "Close tab", + ref, + className, + children, + onClick, + onKeyDown, + ...props +}: PanelTabProps) { + 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..8a1df632 --- /dev/null +++ b/stories/chat/panel-tabs.stories.tsx @@ -0,0 +1,175 @@ +import { FileText, MessageSquare, Table2 } from "lucide-react"; +import { 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 } 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. + */ +function TabbedPanel({ + initial, + defaultValue, +}: { + readonly initial: readonly Item[]; + readonly defaultValue?: string; +}) { + const { tabs, value, open, tabsProps, paneProps } = useTabs({ + defaultTabs: initial, + defaultValue, + }); + 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 ? null : ( + + )} +
+ {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 = () => ( + +);