From c6589961c6af34e216b299c4fec1b27be2d8fcf0 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 00:27:43 +0530 Subject: [PATCH 1/7] 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 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..17456832 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -43,6 +43,10 @@ import { PromptInputFloat, PromptInputContext, PromptInputNote, + PanelTab, + PanelTabAdd, + PanelTabs, + useTabs, } from "@corbits/react-ui"; import { CommandPalette, @@ -854,6 +858,75 @@ 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, 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 +968,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..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 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..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 = () => ( + +); From 61c5ab93d8ae5a6154714c4b6d54b8707de80810 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:25:14 +0545 Subject: [PATCH 2/7] fix(panel-tabs): keep focus and announce moves in the tab row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the selected tab with its × or a middle-click while focus sat in its pane dropped focus to the body: neither control takes focus, and only focus in the row was put back. Keep the focused element before the close renders and refocus the newly selected tab when that element left the document with its pane. Tab moves by drag or Ctrl/Cmd+Shift+Arrow were silent to screen readers. A polite status region beside the row now says "Moved to position N of M", worded by an optional movedMessage prop. Export panelTabsHeaderClassName so hosts stop copying the header classes by hand. The story and e2e fixture now show the header "Close tab" button that touch screen-reader users need, since the tab's × is hidden from them. The docs also say why touch does not drag. --- e2e/fixtures.tsx | 21 +++- e2e/panel-tabs.spec.ts | 41 ++++++++ src/ui/panel-tabs.test.tsx | 146 +++++++++++++++++++++++++++- src/ui/panel-tabs.tsx | 68 +++++++++++-- stories/chat/panel-tabs.stories.tsx | 27 ++++- 5 files changed, 286 insertions(+), 17 deletions(-) diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 17456832..34405888 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -46,6 +46,7 @@ import { PanelTab, PanelTabAdd, PanelTabs, + panelTabsHeaderClassName, useTabs, } from "@corbits/react-ui"; import { @@ -868,9 +869,10 @@ const TAB_ITEMS: readonly TabScenarioItem[] = [ ]; function PanelTabsScenario() { - const { tabs, value, open, tabsProps, paneProps } = useTabs({ - defaultTabs: TAB_ITEMS, - }); + 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) { @@ -900,7 +902,7 @@ function PanelTabsScenario() { {value === null ? null : ( - + {tabs.map((tab) => ( @@ -909,12 +911,23 @@ function PanelTabsScenario() { ))} openMore(1)} /> + + + {tabs.map((tab) => ( {tab.title} pane diff --git a/e2e/panel-tabs.spec.ts b/e2e/panel-tabs.spec.ts index b6a68fd7..5fbcc66e 100644 --- a/e2e/panel-tabs.spec.ts +++ b/e2e/panel-tabs.spec.ts @@ -140,3 +140,44 @@ test("a long row keeps the selected tab in view", async ({ page }) => { 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/ui/panel-tabs.test.tsx b/src/ui/panel-tabs.test.tsx index 6e8a79eb..32e04fc0 100644 --- a/src/ui/panel-tabs.test.tsx +++ b/src/ui/panel-tabs.test.tsx @@ -13,11 +13,17 @@ const TABS: readonly Tab[] = [ { value: "log", title: "Log" }, ]; -function Strip({ withAdd = false }: { readonly withAdd?: boolean }) { +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} @@ -32,6 +38,33 @@ function Strip({ withAdd = false }: { readonly withAdd?: boolean }) { ); } +/** 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({ @@ -250,6 +283,115 @@ describe("PanelTabs", () => { 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 = () => {}; diff --git a/src/ui/panel-tabs.tsx b/src/ui/panel-tabs.tsx index 79c9c74c..1e35bc40 100644 --- a/src/ui/panel-tabs.tsx +++ b/src/ui/panel-tabs.tsx @@ -8,6 +8,7 @@ import { useEffect, useId, useRef, + useState, } from "react"; import type * as React from "react"; import { flushSync } from "react-dom"; @@ -23,6 +24,16 @@ const isProduction = 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; @@ -74,6 +85,13 @@ export type PanelTabsProps = Omit< 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 @@ -93,7 +111,11 @@ export type PanelTabsProps = Omit< * 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. + * 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 @@ -105,9 +127,9 @@ export type PanelTabsProps = Omit< * * 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. + * `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 function PanelTabs({ label, @@ -115,6 +137,7 @@ export function PanelTabs({ onValueChange, onClose, onMove, + movedMessage = defaultMovedMessage, idPrefix, ref, className, @@ -123,6 +146,11 @@ export function PanelTabs({ }: PanelTabsProps) { 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( @@ -177,12 +205,16 @@ export function PanelTabs({ (closing: string) => { const list = listRef.current; if (list === null || onClose === undefined) return; - const hadFocus = list.contains(document.activeElement); + 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)); - if (hadFocus) refocus(list, panel, null); + // 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], ); @@ -192,11 +224,20 @@ export function PanelTabs({ 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, moving); + 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, refocus], + [onMove, movedMessage, refocus], ); const { onPointerDown: onTabPointerDown } = useTabDrag({ @@ -281,6 +322,19 @@ export function PanelTabs({ > {children} + {onMove === undefined ? null : ( + // Beside the row, not in it: a tablist may hold only tabs. +
+ {announcement === null ? null : ( + {announcement.text} + )} +
+ )} ); } diff --git a/stories/chat/panel-tabs.stories.tsx b/stories/chat/panel-tabs.stories.tsx index 8a1df632..253ff848 100644 --- a/stories/chat/panel-tabs.stories.tsx +++ b/stories/chat/panel-tabs.stories.tsx @@ -1,4 +1,4 @@ -import { FileText, MessageSquare, Table2 } from "lucide-react"; +import { FileText, MessageSquare, Table2, X } from "lucide-react"; import { useState } from "react"; import type { ReactNode } from "react"; @@ -13,7 +13,12 @@ import { PanelHeader, PanelPane, } from "../../src/ui/panel.js"; -import { PanelTab, PanelTabAdd, PanelTabs } from "../../src/ui/panel-tabs.js"; +import { + PanelTab, + PanelTabAdd, + PanelTabs, + panelTabsHeaderClassName, +} from "../../src/ui/panel-tabs.js"; export default { title: "Chat / Panel Tabs" }; @@ -77,6 +82,10 @@ function Pane({ item }: { readonly item: Item }) { * 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 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, @@ -85,7 +94,7 @@ function TabbedPanel({ readonly initial: readonly Item[]; readonly defaultValue?: string; }) { - const { tabs, value, open, tabsProps, paneProps } = useTabs({ + const { tabs, value, open, close, tabsProps, paneProps } = useTabs({ defaultTabs: initial, defaultValue, }); @@ -116,7 +125,7 @@ function TabbedPanel({ {shown !== null ? ( - + {tabs.map((tab) => ( + setClosed(true)} /> From 5d6330c1364b0d272475f6f850aa187ad2b2b859 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:25:14 +0545 Subject: [PATCH 3/7] refactor(panel-tabs): keep the drag's onMove current from an effect useTabDrag wrote the latest onMove into a ref during render. Write it from a layout effect instead, which runs before any pointer event can reach the handler. --- src/hooks/use-tab-drag.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/hooks/use-tab-drag.ts b/src/hooks/use-tab-drag.ts index d28ef1e4..60707deb 100644 --- a/src/hooks/use-tab-drag.ts +++ b/src/hooks/use-tab-drag.ts @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useRef, type RefObject } from "react"; +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. */ @@ -43,8 +49,11 @@ export function useTabDrag({ onMove, itemSelector = "[data-slot='panel-tab']", }: UseTabDragOptions): UseTabDragResult { + // The latest onMove, for a drag that began under an earlier render. const onMoveRef = useRef(onMove); - onMoveRef.current = onMove; + useLayoutEffect(() => { + onMoveRef.current = onMove; + }); const stopRef = useRef<(() => void) | null>(null); useEffect(() => () => stopRef.current?.(), []); From 6d49c942a50f163f73c27e7b151d1d136072e68e Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 16:53:18 +0545 Subject: [PATCH 4/7] fix(panel-tabs): return focus to the selected tab after a drag The press that starts a drag focuses the pressed tab, and the drag never selects it, so after a drop focus sat on an unselected tab outside the row's tab stop: the arrows started from it and Delete closed it while another tab was shown. useTabDrag now reports the end of every drag, and PanelTabs sends focus in the row back to the selected tab whether the tab moved or not. Escape cancelled a drag while the button was still down, and the click swallow was disarmed before the release, so letting go selected the tab after all. Swallow the click of that press's own release instead, and stop waiting if another press comes first. --- e2e/panel-tabs.spec.ts | 53 +++++++++++++++++++++++++++++++- src/hooks/use-tab-drag.ts | 64 +++++++++++++++++++++++++++++++-------- src/ui/panel-tabs.tsx | 23 +++++++++++--- 3 files changed, 122 insertions(+), 18 deletions(-) diff --git a/e2e/panel-tabs.spec.ts b/e2e/panel-tabs.spec.ts index 5fbcc66e..d1ef3c0f 100644 --- a/e2e/panel-tabs.spec.ts +++ b/e2e/panel-tabs.spec.ts @@ -45,10 +45,37 @@ test("dragging a tab to the last slot moves it there", async ({ page }) => { await expect( page.locator("[data-slot='panel-tab'][style*='translate']"), ).toHaveCount(0); - // The release does not also click the tab. + // 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 }) => { @@ -64,6 +91,30 @@ test("Escape cancels a drag", async ({ page }) => { 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(); diff --git a/src/hooks/use-tab-drag.ts b/src/hooks/use-tab-drag.ts index 60707deb..45b2b0af 100644 --- a/src/hooks/use-tab-drag.ts +++ b/src/hooks/use-tab-drag.ts @@ -15,6 +15,12 @@ export type UseTabDragOptions = { 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; }; @@ -41,18 +47,22 @@ function clamp(value: number, min: number, max: number): number { * 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. + * 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 onMove, for a drag that began under an earlier render. + // 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); @@ -128,7 +138,16 @@ export function useTabDrag({ clickEvent.stopPropagation(); }; - const finish = (commit: boolean) => { + // 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); @@ -142,31 +161,50 @@ export function useTabDrag({ 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); + 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(true); + if (upEvent.pointerId === pointerId) finish("drop"); } function onCancel(cancelEvent: PointerEvent) { - if (cancelEvent.pointerId === pointerId) finish(false); + if (cancelEvent.pointerId === pointerId) finish("cancel"); } function onKeyDown(keyEvent: KeyboardEvent) { if (keyEvent.key !== "Escape" || !dragging) return; keyEvent.preventDefault(); keyEvent.stopPropagation(); - finish(false); + 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(false); + stopRef.current = () => finish("stop"); }, [listRef, itemSelector], ); diff --git a/src/ui/panel-tabs.tsx b/src/ui/panel-tabs.tsx index 1e35bc40..6a36943c 100644 --- a/src/ui/panel-tabs.tsx +++ b/src/ui/panel-tabs.tsx @@ -219,8 +219,12 @@ export function PanelTabs({ [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) => { + (moving: string, toIndex: number, focusing: string | null) => { const list = listRef.current; if (list === null || onMove === undefined) return; const hadFocus = list.contains(document.activeElement); @@ -231,7 +235,7 @@ export function PanelTabs({ const from = position(); flushSync(() => onMove(moving, toIndex)); // React re-inserts moved nodes, which can drop focus from them. - if (hadFocus) refocus(list, null, moving); + if (hadFocus) refocus(list, null, focusing); const to = position(); if (to === -1 || to === from) return; const text = movedMessage(to + 1, tabElements(list).length); @@ -240,9 +244,20 @@ export function PanelTabs({ [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 : move, + 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, }); @@ -268,7 +283,7 @@ export function PanelTabs({ if (onMove === undefined) return; event.preventDefault(); const to = Math.min(Math.max(index + step, 0), values.length - 1); - if (to !== index) move(current, to); + if (to !== index) move(current, to, current); return; } if (event.altKey || event.ctrlKey || event.metaKey) return; From 158fbb0fc5056568197ff599627efdf45b43e19a Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:48:52 +0545 Subject: [PATCH 5/7] fix(panel-tabs): report a selection on close only when it moves useTabs' close always passed an updater to the selection setter, which reports whatever the updater returns. Closing a tab that was not selected still called onValueChange with the unchanged selection. With a controlled value the updater saw the value from the last render, so several closes in one event did not see each other: closing every tab reported a selection that had already closed instead of null. Track the selection the current event has reached in a ref that every commit sets back to the rendered value, and select on close only when the closing tab is that selection. A controlled parent can turn down a plain select without rendering, so that select leaves the ref alone. --- src/hooks/use-tabs.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/hooks/use-tabs.ts | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/hooks/use-tabs.test.ts b/src/hooks/use-tabs.test.ts index 30b541cd..4ba80996 100644 --- a/src/hooks/use-tabs.test.ts +++ b/src/hooks/use-tabs.test.ts @@ -146,6 +146,43 @@ describe("useTabs", () => { 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; diff --git a/src/hooks/use-tabs.ts b/src/hooks/use-tabs.ts index 7a96f79a..86e486de 100644 --- a/src/hooks/use-tabs.ts +++ b/src/hooks/use-tabs.ts @@ -1,4 +1,4 @@ -import { useCallback, useId, useRef, useState } from "react"; +import { useCallback, useId, useLayoutEffect, useRef, useState } from "react"; import { useControllableState } from "./use-controllable-state.js"; @@ -62,7 +62,10 @@ export type UseTabsResult = { */ 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. */ + /** + * 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; @@ -88,7 +91,7 @@ export function useTabs( const [tabs, setTabsState] = useState(defaultTabs); // Mirrors the row synchronously, so two changes in one event chain. const latestTabs = useRef(tabs); - const [value, setValue] = useControllableState({ + const [value, setSelected] = useControllableState({ value: options.value, defaultValue: options.defaultValue === undefined @@ -97,8 +100,25 @@ export function useTabs( 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; @@ -143,10 +163,13 @@ export function useTabs( const select = useCallback( (next: string) => { - if (latestTabs.current.some((item) => item.value === next)) - setValue(next); + 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); }, - [setValue], + [controlled, setSelected], ); const close = useCallback( @@ -156,7 +179,7 @@ export function useTabs( 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)); + if (latestValue.current === closing) setValue(after); }, [setTabs, setValue], ); From 2ac8b1820674fdb0fd44d3e842062b9da76eb2c4 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:49:21 +0545 Subject: [PATCH 6/7] fix(stories): keep the panel tabs story's opener mounted The panel tabs story showed "Open panel" only while the panel was closed, so the button left the page in the same commit that mounted the panel. The panel found no opener to record, and closing it, or closing its last tab, sent focus to the body. Keep a toggle in the main area, with aria-expanded and aria-controls, so the panel records it and returns focus to it however it closes. --- stories/chat/panel-tabs.stories.tsx | 38 +++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/stories/chat/panel-tabs.stories.tsx b/stories/chat/panel-tabs.stories.tsx index 253ff848..280f874a 100644 --- a/stories/chat/panel-tabs.stories.tsx +++ b/stories/chat/panel-tabs.stories.tsx @@ -1,5 +1,5 @@ import { FileText, MessageSquare, Table2, X } from "lucide-react"; -import { useState } from "react"; +import { useId, useState } from "react"; import type { ReactNode } from "react"; import { useTabs } from "../../src/hooks/use-tabs.js"; @@ -83,6 +83,10 @@ function Pane({ item }: { readonly item: Item }) { * 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. @@ -98,6 +102,7 @@ function TabbedPanel({ defaultTabs: initial, defaultValue, }); + const panelId = useId(); const [added, setAdded] = useState(0); const [closed, setClosed] = useState(false); const shown = closed ? null : value; @@ -108,22 +113,25 @@ function TabbedPanel({

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

- {shown !== null ? null : ( - - )} + {shown !== null ? ( - + From 3f4621e09eefc4ca6c5258626620815c41bfb758 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:50:04 +0545 Subject: [PATCH 7/7] fix(panel-tabs): forward the PanelTabs and PanelTab refs on React 18 PanelTabs and PanelTab read `ref` from their props, which only React 19 passes to a function component. On React 18, which the peer range allows, the ref was dropped with a warning, so a host could not reach the row or a tab's root. Wrap both in forwardRef so the refs land on both versions. --- src/ui/panel-tabs.test.tsx | 11 +- src/ui/panel-tabs.tsx | 664 +++++++++++++++++++------------------ 2 files changed, 347 insertions(+), 328 deletions(-) diff --git a/src/ui/panel-tabs.test.tsx b/src/ui/panel-tabs.test.tsx index 32e04fc0..b9f6e46b 100644 --- a/src/ui/panel-tabs.test.tsx +++ b/src/ui/panel-tabs.test.tsx @@ -201,10 +201,16 @@ describe("PanelTabs", () => { expect(document.activeElement).toBe(tab(host, "Draft")); }); - test("host props cannot unlink a tab, and ref lands on its root", () => { + 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( - {}}> + {}} + > { 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 +", () => { diff --git a/src/ui/panel-tabs.tsx b/src/ui/panel-tabs.tsx index 6a36943c..a37ac649 100644 --- a/src/ui/panel-tabs.tsx +++ b/src/ui/panel-tabs.tsx @@ -3,6 +3,7 @@ import { Plus, X } from "lucide-react"; import { createContext, + forwardRef, useCallback, useContext, useEffect, @@ -131,228 +132,235 @@ export type PanelTabsProps = Omit< * 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, - movedMessage = defaultMovedMessage, - idPrefix, - ref, - className, - children, - ...props -}: PanelTabsProps) { - 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; +export const PanelTabs = forwardRef( + function PanelTabs( + { + label, + value, + onValueChange, + onClose, + onMove, + movedMessage = defaultMovedMessage, + idPrefix, + className, + children, + ...props }, - [ref], - ); + 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 ?? ""); - 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 setList = useCallback( + (element: HTMLDivElement | null) => { + listRef.current = element; + if (typeof ref === "function") ref(element); + else if (ref !== null && ref !== undefined) ref.current = element; + }, + [ref], + ); - const close = useCallback( - (closing: string) => { + useEffect(() => { 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], - ); + 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.", + ); + }); - /** - * 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, + /** 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']", ); - 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], - ); + (add ?? panel).focus(); + }, + [], + ); - // 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 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], + ); - const onTabKeyDown = ( - event: React.KeyboardEvent, - current: string, - ) => { - const list = listRef.current; - if (list === null) return; - const values = tabElements(list).map( - (element) => element.dataset["value"] ?? "", + /** + * 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], ); - 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; + // 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, + }); - if (event.key === "Delete" || event.key === "Backspace") { - if (onClose === undefined) return; + 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(); - 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 }); - }; + 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} + ref={setList} + role="tablist" + aria-label={label} + data-slot="panel-tabs" + className={cn( + "group/tabs relative -mb-px flex min-w-0 items-end overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + className, )} + {...props} + > + {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">, @@ -377,139 +385,143 @@ export type PanelTabProps = Omit< * 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; +export const PanelTab = forwardRef( + function PanelTab( + { + value, + icon, + closeLabel = "Close tab", + className, + children, + onClick, + onKeyDown, + ...props }, - [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); + 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} -
- ); -} + {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">,