Skip to content

Commit a7f3353

Browse files
committed
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.
1 parent 35dd216 commit a7f3353

10 files changed

Lines changed: 1677 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: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ import {
4242
PromptInputFloat,
4343
PromptInputContext,
4444
PromptInputNote,
45+
PanelTab,
46+
PanelTabAdd,
47+
PanelTabs,
48+
useTabs,
4549
} from "@corbits/react-ui";
4650
import {
4751
CommandPalette,
@@ -681,6 +685,75 @@ function PromptToolbarScenario() {
681685
);
682686
}
683687

688+
type TabScenarioItem = { readonly value: string; readonly title: string };
689+
690+
const TAB_ITEMS: readonly TabScenarioItem[] = [
691+
{ value: "alpha", title: "Alpha" },
692+
{ value: "beta", title: "Beta" },
693+
{ value: "gamma", title: "Gamma" },
694+
{ value: "delta", title: "Delta" },
695+
];
696+
697+
function PanelTabsScenario() {
698+
const { tabs, value, open, tabsProps, paneProps } = useTabs<TabScenarioItem>({
699+
defaultTabs: TAB_ITEMS,
700+
});
701+
const [added, setAdded] = useState(0);
702+
const openMore = (count: number) => {
703+
for (let index = 1; index <= count; index += 1) {
704+
open({
705+
value: `extra-${added + index}`,
706+
title: `Extra ${added + index}`,
707+
});
708+
}
709+
setAdded(added + count);
710+
};
711+
return (
712+
<div
713+
data-testid="row"
714+
style={{
715+
position: "relative",
716+
display: "flex",
717+
height: 600,
718+
containerType: "inline-size",
719+
}}
720+
>
721+
<main data-testid="main" style={{ flex: 1, minWidth: 0 }}>
722+
<Button onClick={() => openMore(12)}>Open twelve more</Button>
723+
<output aria-label="Order">
724+
{tabs.map((tab) => tab.value).join(",")}
725+
</output>
726+
<output aria-label="Selected">{value ?? "none"}</output>
727+
</main>
728+
{value === null ? null : (
729+
<Panel aria-label="Side panel">
730+
<PanelHeader className="items-end bg-muted pt-1.5 pb-0">
731+
<PanelTabs label="Open items" {...tabsProps}>
732+
{tabs.map((tab) => (
733+
<PanelTab key={tab.value} value={tab.value}>
734+
{tab.title}
735+
</PanelTab>
736+
))}
737+
</PanelTabs>
738+
<PanelTabAdd onClick={() => openMore(1)} />
739+
</PanelHeader>
740+
<PanelContent value={value} keepMounted>
741+
{tabs.map((tab) => (
742+
<PanelPane
743+
key={tab.value}
744+
value={tab.value}
745+
{...paneProps(tab.value)}
746+
>
747+
{tab.title} pane
748+
</PanelPane>
749+
))}
750+
</PanelContent>
751+
</Panel>
752+
)}
753+
</div>
754+
);
755+
}
756+
684757
export const scenarios: Record<string, () => ReactNode> = {
685758
smoke: () => <Button>Ready</Button>,
686759
"approval-card": () => <Approval state="idle" />,
@@ -717,4 +790,5 @@ export const scenarios: Record<string, () => ReactNode> = {
717790
"prompt-input-attachment": () => <PromptWithAttachment />,
718791
panel: () => <PanelScenario />,
719792
"prompt-input-toolbar": () => <PromptToolbarScenario />,
793+
"panel-tabs": () => <PanelTabsScenario />,
720794
};

‎e2e/panel-tabs.spec.ts‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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.
49+
await expect(page.getByRole("status", { name: "Selected" })).toHaveText(
50+
"alpha",
51+
);
52+
});
53+
54+
test("Escape cancels a drag", async ({ page }) => {
55+
const beta = tab(page, "Beta");
56+
const box = await beta.boundingBox();
57+
if (box === null) throw new Error("missing box");
58+
const y = box.y + box.height / 2;
59+
await page.mouse.move(box.x + box.width / 2, y);
60+
await page.mouse.down();
61+
await page.mouse.move(box.x + box.width * 3, y, { steps: 8 });
62+
await page.keyboard.press("Escape");
63+
await page.mouse.up();
64+
await expect(order(page)).toHaveText("alpha,beta,gamma,delta");
65+
});
66+
67+
test("the keyboard selects and moves tabs", async ({ page }) => {
68+
const selected = page.getByRole("status", { name: "Selected" });
69+
await tab(page, "Alpha").focus();
70+
71+
await page.keyboard.press("ArrowRight");
72+
await expect(tab(page, "Beta")).toBeFocused();
73+
await expect(tab(page, "Beta")).toHaveAttribute("aria-selected", "true");
74+
await expect(selected).toHaveText("beta");
75+
await page.keyboard.press("End");
76+
await expect(tab(page, "Delta")).toBeFocused();
77+
await page.keyboard.press("ArrowRight");
78+
await expect(tab(page, "Alpha")).toBeFocused();
79+
await page.keyboard.press("Home");
80+
await expect(selected).toHaveText("alpha");
81+
82+
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
83+
await expect(order(page)).toHaveText("beta,alpha,gamma,delta");
84+
await expect(tab(page, "Alpha")).toBeFocused();
85+
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
86+
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
87+
await page.keyboard.press("ControlOrMeta+Shift+ArrowRight");
88+
await expect(order(page)).toHaveText("beta,gamma,delta,alpha");
89+
await expect(tab(page, "Alpha")).toBeFocused();
90+
await page.keyboard.press("ControlOrMeta+Shift+ArrowLeft");
91+
await expect(order(page)).toHaveText("beta,gamma,alpha,delta");
92+
await expect(selected).toHaveText("alpha");
93+
94+
// The row is one tab stop: Tab goes on to the + beside it.
95+
await page.keyboard.press("Tab");
96+
await expect(page.getByRole("button", { name: "Open a tab" })).toBeFocused();
97+
});
98+
99+
test("middle-click and Delete close tabs", async ({ page }) => {
100+
const selected = page.getByRole("status", { name: "Selected" });
101+
await tab(page, "Gamma").click({ button: "middle" });
102+
await expect(order(page)).toHaveText("alpha,beta,delta");
103+
await expect(selected).toHaveText("alpha");
104+
105+
await tab(page, "Alpha").focus();
106+
await page.keyboard.press("Delete");
107+
await expect(order(page)).toHaveText("beta,delta");
108+
await expect(tab(page, "Beta")).toBeFocused();
109+
await expect(selected).toHaveText("beta");
110+
111+
await tab(page, "Delta").click();
112+
await tab(page, "Delta").hover();
113+
await page
114+
.locator("[data-slot='panel-tab'][data-value='delta']")
115+
.locator("[data-slot='panel-tab-close']")
116+
.click();
117+
await expect(order(page)).toHaveText("beta");
118+
await expect(selected).toHaveText("beta");
119+
await expect(tab(page, "Beta")).toBeFocused();
120+
});
121+
122+
test("a long row keeps the selected tab in view", async ({ page }) => {
123+
await page.getByRole("button", { name: "Open twelve more" }).click();
124+
await expect(tab(page, "Extra 12")).toHaveAttribute("aria-selected", "true");
125+
const row = page.getByRole("tablist", { name: "Open items" });
126+
expect(
127+
await row.evaluate((element) => element.scrollWidth > element.clientWidth),
128+
).toBe(true);
129+
await expect.poll(() => overflow(page, "Extra 12")).toBeLessThanOrEqual(1);
130+
131+
await tab(page, "Extra 12").focus();
132+
await page.keyboard.press("Home");
133+
await expect(tab(page, "Alpha")).toBeFocused();
134+
await expect.poll(() => overflow(page, "Alpha")).toBeLessThanOrEqual(1);
135+
await expect
136+
.poll(() => row.evaluate((element) => element.scrollLeft))
137+
.toBeLessThanOrEqual(1);
138+
139+
await page.keyboard.press("ArrowLeft");
140+
await expect(tab(page, "Extra 12")).toBeFocused();
141+
await expect.poll(() => overflow(page, "Extra 12")).toBeLessThanOrEqual(1);
142+
});

0 commit comments

Comments
 (0)