Skip to content

Commit 75035c2

Browse files
authored
feat(panel): add tabs for the Panel (#119)
* 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. * fix(panel-tabs): keep focus and announce moves in the tab row 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. * 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. * 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. * 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. * 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. * 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.
1 parent 73a9348 commit 75035c2

10 files changed

Lines changed: 2146 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer",
143143
| `DictationStatusLine` | Composer dictation status row |
144144
| `MicPermissionDialog` | Microphone permission prompt (optional peer, `ui/mic-permission-dialog`) |
145145
| `Panel` | Side panel resized from its edge; hidden panes keep their state |
146+
| `PanelTabs` | A panel's tab row: select, close, drag or keyboard to reorder; `useTabs` |
146147
147148
#### Runs
148149

‎e2e/fixtures.tsx‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ import {
4343
PromptInputFloat,
4444
PromptInputContext,
4545
PromptInputNote,
46+
PanelTab,
47+
PanelTabAdd,
48+
PanelTabs,
49+
panelTabsHeaderClassName,
50+
useTabs,
4651
} from "@corbits/react-ui";
4752
import {
4853
CommandPalette,
@@ -854,6 +859,87 @@ function PromptToolbarInDialog() {
854859
);
855860
}
856861

862+
type TabScenarioItem = { readonly value: string; readonly title: string };
863+
864+
const TAB_ITEMS: readonly TabScenarioItem[] = [
865+
{ value: "alpha", title: "Alpha" },
866+
{ value: "beta", title: "Beta" },
867+
{ value: "gamma", title: "Gamma" },
868+
{ value: "delta", title: "Delta" },
869+
];
870+
871+
function PanelTabsScenario() {
872+
const { tabs, value, open, close, tabsProps, paneProps } =
873+
useTabs<TabScenarioItem>({
874+
defaultTabs: TAB_ITEMS,
875+
});
876+
const [added, setAdded] = useState(0);
877+
const openMore = (count: number) => {
878+
for (let index = 1; index <= count; index += 1) {
879+
open({
880+
value: `extra-${added + index}`,
881+
title: `Extra ${added + index}`,
882+
});
883+
}
884+
setAdded(added + count);
885+
};
886+
return (
887+
<div
888+
data-testid="row"
889+
style={{
890+
position: "relative",
891+
display: "flex",
892+
height: 600,
893+
containerType: "inline-size",
894+
}}
895+
>
896+
<main data-testid="main" style={{ flex: 1, minWidth: 0 }}>
897+
<Button onClick={() => openMore(12)}>Open twelve more</Button>
898+
<output aria-label="Order">
899+
{tabs.map((tab) => tab.value).join(",")}
900+
</output>
901+
<output aria-label="Selected">{value ?? "none"}</output>
902+
</main>
903+
{value === null ? null : (
904+
<Panel aria-label="Side panel">
905+
<PanelHeader className={panelTabsHeaderClassName}>
906+
<PanelTabs label="Open items" {...tabsProps}>
907+
{tabs.map((tab) => (
908+
<PanelTab key={tab.value} value={tab.value}>
909+
{tab.title}
910+
</PanelTab>
911+
))}
912+
</PanelTabs>
913+
<PanelTabAdd onClick={() => openMore(1)} />
914+
<PanelActions>
915+
<Button
916+
variant="ghost"
917+
size="icon"
918+
aria-label="Close tab"
919+
onClick={() => close(value)}
920+
>
921+
×
922+
</Button>
923+
</PanelActions>
924+
</PanelHeader>
925+
<PanelContent value={value} keepMounted>
926+
{tabs.map((tab) => (
927+
<PanelPane
928+
key={tab.value}
929+
value={tab.value}
930+
tabIndex={0}
931+
{...paneProps(tab.value)}
932+
>
933+
{tab.title} pane
934+
</PanelPane>
935+
))}
936+
</PanelContent>
937+
</Panel>
938+
)}
939+
</div>
940+
);
941+
}
942+
857943
export const scenarios: Record<string, () => ReactNode> = {
858944
smoke: () => <Button>Ready</Button>,
859945
"approval-card": () => <Approval state="idle" />,
@@ -895,4 +981,5 @@ export const scenarios: Record<string, () => ReactNode> = {
895981
panel: () => <PanelScenario />,
896982
"prompt-input-toolbar": () => <PromptToolbarScenario />,
897983
"prompt-input-toolbar-dialog": () => <PromptToolbarInDialog />,
984+
"panel-tabs": () => <PanelTabsScenario />,
898985
};

‎e2e/panel-tabs.spec.ts‎

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { expect, test, type Locator, type Page } from "@playwright/test";
2+
3+
function order(page: Page): Locator {
4+
return page.getByRole("status", { name: "Order" });
5+
}
6+
7+
function tab(page: Page, name: string): Locator {
8+
return page.getByRole("tab", { name, exact: true });
9+
}
10+
11+
/** How far `name`'s tab sticks out of the row's visible part, in px. */
12+
function overflow(page: Page, name: string): Promise<number> {
13+
return tab(page, name).evaluate((element) => {
14+
const row = element.closest("[role='tablist']");
15+
if (row === null) throw new Error("the tab has no row");
16+
const box = element.getBoundingClientRect();
17+
const view = row.getBoundingClientRect();
18+
return Math.max(0, view.left - box.left, box.right - view.right);
19+
});
20+
}
21+
22+
test.beforeEach(async ({ page }) => {
23+
await page.goto("/panel-tabs");
24+
});
25+
26+
test("dragging a tab to the last slot moves it there", async ({ page }) => {
27+
const beta = tab(page, "Beta");
28+
const delta = tab(page, "Delta");
29+
const from = await beta.boundingBox();
30+
const to = await delta.boundingBox();
31+
if (from === null || to === null) throw new Error("missing boxes");
32+
const y = from.y + from.height / 2;
33+
34+
await page.mouse.move(from.x + from.width / 2, y);
35+
await page.mouse.down();
36+
await page.mouse.move(to.x + to.width - 4, y, { steps: 12 });
37+
await expect(
38+
page.locator("[data-slot='panel-tab'][data-value='beta']"),
39+
).toHaveAttribute("data-dragging", "true");
40+
// The tab passed slides aside while the order is still unchanged.
41+
await expect(order(page)).toHaveText("alpha,beta,gamma,delta");
42+
await page.mouse.up();
43+
44+
await expect(order(page)).toHaveText("alpha,gamma,delta,beta");
45+
await expect(
46+
page.locator("[data-slot='panel-tab'][style*='translate']"),
47+
).toHaveCount(0);
48+
// The release does not also click the tab, and focus goes back to the
49+
// selected tab, the row's tab stop.
50+
await expect(page.getByRole("status", { name: "Selected" })).toHaveText(
51+
"alpha",
52+
);
53+
await expect(tab(page, "Alpha")).toBeFocused();
54+
});
55+
56+
test("a drag dropped where it began leaves focus on the selected tab", async ({
57+
page,
58+
}) => {
59+
const box = await tab(page, "Beta").boundingBox();
60+
if (box === null) throw new Error("missing box");
61+
const x = box.x + box.width / 2;
62+
const y = box.y + box.height / 2;
63+
await page.mouse.move(x, y);
64+
await page.mouse.down();
65+
await page.mouse.move(x + 12, y, { steps: 3 });
66+
await expect(
67+
page.locator("[data-slot='panel-tab'][data-value='beta']"),
68+
).toHaveAttribute("data-dragging", "true");
69+
await page.mouse.up();
70+
71+
await expect(order(page)).toHaveText("alpha,beta,gamma,delta");
72+
await expect(page.getByRole("status", { name: "Selected" })).toHaveText(
73+
"alpha",
74+
);
75+
await expect(tab(page, "Alpha")).toBeFocused();
76+
// Delete acts on the selected tab, not on the one that was dragged.
77+
await page.keyboard.press("Delete");
78+
await expect(order(page)).toHaveText("beta,gamma,delta");
79+
});
80+
81+
test("Escape cancels a drag", async ({ page }) => {
82+
const beta = tab(page, "Beta");
83+
const box = await beta.boundingBox();
84+
if (box === null) throw new Error("missing box");
85+
const y = box.y + box.height / 2;
86+
await page.mouse.move(box.x + box.width / 2, y);
87+
await page.mouse.down();
88+
await page.mouse.move(box.x + box.width * 3, y, { steps: 8 });
89+
await page.keyboard.press("Escape");
90+
await page.mouse.up();
91+
await expect(order(page)).toHaveText("alpha,beta,gamma,delta");
92+
});
93+
94+
test("releasing a drag cancelled with Escape does not select the tab", async ({
95+
page,
96+
}) => {
97+
const selected = page.getByRole("status", { name: "Selected" });
98+
const box = await tab(page, "Beta").boundingBox();
99+
if (box === null) throw new Error("missing box");
100+
const x = box.x + box.width / 2;
101+
const y = box.y + box.height / 2;
102+
await page.mouse.move(x, y);
103+
await page.mouse.down();
104+
await page.mouse.move(x + 12, y, { steps: 3 });
105+
await page.keyboard.press("Escape");
106+
// A person lets go a moment later, over the tab they pressed.
107+
await page.waitForTimeout(50);
108+
await page.mouse.up();
109+
await expect(order(page)).toHaveText("alpha,beta,gamma,delta");
110+
await expect(selected).toHaveText("alpha");
111+
await expect(tab(page, "Alpha")).toBeFocused();
112+
113+
// Only that press's click is swallowed: the next click selects.
114+
await tab(page, "Beta").click();
115+
await expect(selected).toHaveText("beta");
116+
});
117+
118+
test("the keyboard selects and moves tabs", async ({ page }) => {
119+
const selected = page.getByRole("status", { name: "Selected" });
120+
await tab(page, "Alpha").focus();
121+
122+
await page.keyboard.press("ArrowRight");
123+
await expect(tab(page, "Beta")).toBeFocused();
124+
await expect(tab(page, "Beta")).toHaveAttribute("aria-selected", "true");
125+
await expect(selected).toHaveText("beta");
126+
await page.keyboard.press("End");
127+
await expect(tab(page, "Delta")).toBeFocused();
128+
await page.keyboard.press("ArrowRight");
129+
await expect(tab(page, "Alpha")).toBeFocused();
130+
await page.keyboard.press("Home");
131+
await expect(selected).toHaveText("alpha");
132+
133+
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
134+
await expect(order(page)).toHaveText("beta,alpha,gamma,delta");
135+
await expect(tab(page, "Alpha")).toBeFocused();
136+
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
137+
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
138+
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
139+
await expect(order(page)).toHaveText("beta,gamma,delta,alpha");
140+
await expect(tab(page, "Alpha")).toBeFocused();
141+
await page.keyboard.press("ControlOrMeta+Shift+ArrowLeft");
142+
await expect(order(page)).toHaveText("beta,gamma,alpha,delta");
143+
await expect(selected).toHaveText("alpha");
144+
145+
// The row is one tab stop: Tab goes on to the + beside it.
146+
await page.keyboard.press("Tab");
147+
await expect(page.getByRole("button", { name: "Open a tab" })).toBeFocused();
148+
});
149+
150+
test("middle-click and Delete close tabs", async ({ page }) => {
151+
const selected = page.getByRole("status", { name: "Selected" });
152+
await tab(page, "Gamma").click({ button: "middle" });
153+
await expect(order(page)).toHaveText("alpha,beta,delta");
154+
await expect(selected).toHaveText("alpha");
155+
156+
await tab(page, "Alpha").focus();
157+
await page.keyboard.press("Delete");
158+
await expect(order(page)).toHaveText("beta,delta");
159+
await expect(tab(page, "Beta")).toBeFocused();
160+
await expect(selected).toHaveText("beta");
161+
162+
await tab(page, "Delta").click();
163+
await tab(page, "Delta").hover();
164+
await page
165+
.locator("[data-slot='panel-tab'][data-value='delta']")
166+
.locator("[data-slot='panel-tab-close']")
167+
.click();
168+
await expect(order(page)).toHaveText("beta");
169+
await expect(selected).toHaveText("beta");
170+
await expect(tab(page, "Beta")).toBeFocused();
171+
});
172+
173+
test("a long row keeps the selected tab in view", async ({ page }) => {
174+
await page.getByRole("button", { name: "Open twelve more" }).click();
175+
await expect(tab(page, "Extra 12")).toHaveAttribute("aria-selected", "true");
176+
const row = page.getByRole("tablist", { name: "Open items" });
177+
expect(
178+
await row.evaluate((element) => element.scrollWidth > element.clientWidth),
179+
).toBe(true);
180+
await expect.poll(() => overflow(page, "Extra 12")).toBeLessThanOrEqual(1);
181+
182+
await tab(page, "Extra 12").focus();
183+
await page.keyboard.press("Home");
184+
await expect(tab(page, "Alpha")).toBeFocused();
185+
await expect.poll(() => overflow(page, "Alpha")).toBeLessThanOrEqual(1);
186+
await expect
187+
.poll(() => row.evaluate((element) => element.scrollLeft))
188+
.toBeLessThanOrEqual(1);
189+
190+
await page.keyboard.press("ArrowLeft");
191+
await expect(tab(page, "Extra 12")).toBeFocused();
192+
await expect.poll(() => overflow(page, "Extra 12")).toBeLessThanOrEqual(1);
193+
});
194+
195+
test("closing the selected tab from its pane moves focus to the next tab", async ({
196+
page,
197+
}) => {
198+
const selected = page.getByRole("status", { name: "Selected" });
199+
await page.getByRole("tabpanel", { name: "Alpha" }).click();
200+
await expect(page.getByRole("tabpanel", { name: "Alpha" })).toBeFocused();
201+
await tab(page, "Alpha").hover();
202+
await page
203+
.locator("[data-slot='panel-tab'][data-value='alpha']")
204+
.locator("[data-slot='panel-tab-close']")
205+
.click();
206+
await expect(order(page)).toHaveText("beta,gamma,delta");
207+
await expect(selected).toHaveText("beta");
208+
await expect(tab(page, "Beta")).toBeFocused();
209+
210+
await page.getByRole("tabpanel", { name: "Beta" }).click();
211+
await tab(page, "Beta").click({ button: "middle" });
212+
await expect(order(page)).toHaveText("gamma,delta");
213+
await expect(tab(page, "Gamma")).toBeFocused();
214+
});
215+
216+
test("the header's Close tab closes the selected tab and keeps focus", async ({
217+
page,
218+
}) => {
219+
const close = page.getByRole("button", { name: "Close tab" });
220+
await close.click();
221+
await expect(order(page)).toHaveText("beta,gamma,delta");
222+
await expect(page.getByRole("status", { name: "Selected" })).toHaveText(
223+
"beta",
224+
);
225+
await expect(close).toBeFocused();
226+
});
227+
228+
test("a move is announced", async ({ page }) => {
229+
await tab(page, "Alpha").focus();
230+
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
231+
await expect(page.locator("[data-slot='panel-tabs-status']")).toHaveText(
232+
"Moved to position 2 of 4",
233+
);
234+
});

0 commit comments

Comments
 (0)