Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs-site/src/ui/context/actions/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,12 @@ the application owns confirmation, item removal, and the next selected value.
Delete or Backspace closes the focused tab when a close handler is present.
Close controls appear on hover, keyboard focus, or touch devices.

The tab list scrolls horizontally. Subtle overflow fades appear only at edges
The tab list scrolls horizontally and never vertically; the selected
underline and focus ring stay whole inside it. Where the platform shows
scrollbars, the horizontal one is thin. Subtle overflow fades appear only at edges
with hidden content and disappear when the list fits. Vertical tabs are not
masked; forced-color mode leaves every orientation unmasked. The active tab
masked; forced-color mode leaves every orientation unmasked and draws the
line baseline in the system text color. The active tab
remains visible. Use
`trailing` for a fixed action outside the scrollport, such as an IconButton or
Dropdown trigger. The application owns that action and its menu entries.
Expand Down
120 changes: 120 additions & 0 deletions packages/ui/src/actions/tabs.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { createConfig } from "@k2b/ssr";
import { type Browser, chromium } from "playwright";
import { createComponent } from "solid-js";
import { renderToString } from "solid-js/web";

// Scroll overflow depends on layout, which happy-dom does not model, so a real
// engine renders the shipped stylesheet.
const root = mkdtempSync(resolve(tmpdir(), "k2b-ui-tabs-"));
const { plugin } = createConfig({ dev: true, rootDir: root });
Bun.plugin(plugin());
process.once("exit", () => rmSync(root, { recursive: true, force: true }));

const { Tabs } = await import("./Tabs");

const css = readFileSync(resolve(import.meta.dir, "../../dist/styles.css"), "utf8");
const viewports = {
desktop: { viewport: { width: 1440, height: 900 } },
phone: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true },
};

let browser: Browser;
beforeAll(async () => {
browser = await chromium.launch();
}, 30_000);
afterAll(async () => {
await browser?.close();
});

const lists = () =>
renderToString(() =>
(["line", "pill"] as const).flatMap((variant) =>
[3, 24].map((count) =>
createComponent(Tabs, {
value: "tab-0",
onValueChange: () => {},
ariaLabel: `${variant} with ${count} tabs`,
variant,
options: Array.from({ length: count }, (_, index) => ({ value: `tab-${index}`, label: `Section ${index + 1}` })),
}),
),
),
);
const html = () =>
`<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style></head>` +
`<body class="k2b-ui" style="margin:0"><main style="display:grid;gap:1rem;padding:1.5rem">${lists()}</main></body></html>`;

describe("@k2b/ui tab list scrolling", () => {
for (const options of Object.values(viewports)) {
test(`at ${options.viewport.width} px the list scrolls only sideways and keeps the selected underline whole`, async () => {
const page = await browser.newPage(options);
try {
await page.setContent(html());
const measured = await page.evaluate(() =>
Array.from(document.querySelectorAll<HTMLElement>(".k2b-tabs__list")).map((list) => {
const style = getComputedStyle(list);
const selected = list.querySelector('[aria-selected="true"]')!.getBoundingClientRect();
const clipBottom = list.getBoundingClientRect().top + list.clientTop + list.clientHeight;
return {
list: list.getAttribute("aria-label"),
overflow: `${style.overflowX} ${style.overflowY}`,
scrollbar: style.scrollbarWidth,
verticalOverflow: list.scrollHeight - list.clientHeight,
scrollsSideways: list.scrollWidth > list.clientWidth,
underlineCut: Math.max(0, selected.bottom - clipBottom),
};
}),
);
expect(measured).toEqual(
["line", "pill"].flatMap((variant) =>
[3, 24].map((count) => ({
list: `${variant} with ${count} tabs`,
overflow: "auto hidden",
scrollbar: "thin",
verticalOverflow: 0,
// Twenty-four tabs overflow both widths; three fit on a phone too.
scrollsSideways: count === 24,
underlineCut: 0,
})),
),
);
} finally {
await page.close();
}
});
}

test("in forced colours the line baseline stays drawn in CanvasText and the tabs stay forced", async () => {
const page = await browser.newPage(viewports.desktop);
try {
await page.emulateMedia({ forcedColors: "active" });
await page.setContent(html());
const measured = await page.evaluate(() => {
const probe = document.body.appendChild(document.createElement("i"));
probe.style.color = "CanvasText";
const canvasText = getComputedStyle(probe).color;
const named = (value: string) => value.replaceAll(canvasText, "CanvasText");
return Array.from(document.querySelectorAll<HTMLElement>(".k2b-tabs__list")).map((list) => ({
list: list.getAttribute("aria-label"),
baseline: named(getComputedStyle(list).boxShadow),
selectedUnderline: named(getComputedStyle(list.querySelector('[aria-selected="true"]')!).borderBottomColor),
}));
});
expect(measured).toEqual(
["line", "pill"].flatMap((variant) =>
[3, 24].map((count) => ({
list: `${variant} with ${count} tabs`,
baseline: variant === "line" ? "CanvasText 0px -1px 0px 0px inset" : "none",
selectedUnderline: "CanvasText",
})),
),
);
} finally {
await page.close();
}
});
});
42 changes: 25 additions & 17 deletions packages/ui/src/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -3234,7 +3234,7 @@
@apply flex shrink-0 items-center overflow-x-auto;
gap: 0.25rem;
padding: 0.375rem 1rem 0.625rem;
border-bottom: 0;
box-shadow: none;
}

.k2b-ui .k2b-panel-dialog__tabs .k2b-tabs__list button {
Expand Down Expand Up @@ -7865,25 +7865,39 @@
align-items: flex-start;
}

/* The list scrolls sideways only. Its baseline is drawn inside the padding box,
where the selected tab's underline can cover it without leaving the scrollport. */
.k2b-ui .k2b-tabs__list {
display: flex;
min-width: 0;
flex: 1;
gap: 0.25rem;
overflow-x: auto;
border-bottom: 1px solid var(--k2b-border);
scrollbar-width: none;
}

.k2b-ui .k2b-tabs__list::-webkit-scrollbar {
display: none;
padding-bottom: 1px;
overflow: auto hidden;
box-shadow: inset 0 -1px 0 var(--k2b-border);
scrollbar-width: thin;
}

.k2b-ui .k2b-tabs[data-orientation="vertical"] .k2b-tabs__list {
flex: 0 0 auto;
flex-direction: column;
overflow-x: visible;
padding-bottom: 0;
overflow: visible;
border-right: 1px solid var(--k2b-border);
border-bottom: 0;
box-shadow: none;
}

/* Forced colours drop box-shadow. The horizontal list opts out to keep its
baseline in CanvasText; its tabs stay in forced colours. */
@media (forced-colors: active) {
.k2b-ui .k2b-tabs[data-orientation="horizontal"] .k2b-tabs__list {
--k2b-border: CanvasText;
forced-color-adjust: none;
}

.k2b-ui .k2b-tabs__list > * {
forced-color-adjust: auto;
}
}

.k2b-ui .k2b-tabs__list button {
Expand Down Expand Up @@ -7927,10 +7941,6 @@
outline: var(--k2b-focus-width) solid var(--k2b-focus-ring);
outline-offset: calc(-1 * var(--k2b-focus-width));
}
/* A line tab overlaps the list's bottom border by 1px, which the scrolling list clips, so its ring sits 1px deeper. */
.k2b-ui .k2b-tabs[data-orientation="horizontal"][data-variant="line"] .k2b-tabs__list [role="tab"]:focus-visible {
outline-offset: calc(-1 * var(--k2b-focus-width) - 1px);
}
.k2b-ui .k2b-tabs__panel {
min-width: 0;
}
Expand Down Expand Up @@ -8274,14 +8284,12 @@

/* Tab controls keep overflow separate from fixed actions. */
.k2b-ui .k2b-tabs__bar { display:flex; min-width:0; align-items:center; gap:.25rem; }
.k2b-ui .k2b-tabs__list { flex:1; scrollbar-width:thin; }
.k2b-ui .k2b-tabs__list::-webkit-scrollbar { display:block; height:3px; }
.k2b-ui .k2b-tabs__item { display:flex; flex:none; align-items:center; min-width:0; }
.k2b-ui .k2b-tabs__trailing { flex:none; }
.k2b-ui .k2b-tabs__list .k2b-tabs__close { padding:.25rem; min-height:1.5rem; border:0; margin:0; opacity:0; }
.k2b-ui .k2b-tabs__item:hover .k2b-tabs__close, .k2b-ui .k2b-tabs__item:focus-within .k2b-tabs__close { opacity:1; }
@media (hover:none) { .k2b-ui .k2b-tabs__list .k2b-tabs__close { opacity:1; } }
.k2b-ui .k2b-tabs[data-variant="pill"] .k2b-tabs__list { border:0; }
.k2b-ui .k2b-tabs[data-variant="pill"] .k2b-tabs__list { padding-bottom:0; border:0; box-shadow:none; }
.k2b-ui .k2b-tabs[data-variant="pill"] .k2b-tabs__item { border-radius:.75rem; padding-right:.25rem; background:var(--k2b-surface-canvas); }
.k2b-ui .k2b-tabs[data-variant="pill"] .k2b-tabs__item:hover,
.k2b-ui .k2b-tabs[data-variant="pill"] .k2b-tabs__item:has([aria-selected="true"]) { background:var(--k2b-surface-muted); }
Expand Down
Loading