mirror of
https://github.com/block/buzz.git
synced 2026-11-13 12:51:34 +00:00
fix(design-system): unify layout details and glass tab behavior
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
@@ -34,3 +34,7 @@ Spacing and motion regressions exercise command empty-state recovery, dialog hea
|
||||
gaps, accordion density, toast entrance/exit, interrupted tab selection, and reduced
|
||||
motion in the production catalog. Animation checks stretch duration for frame
|
||||
inspection without adding transition properties that could mask missing motion.
|
||||
|
||||
Layout coverage verifies table alignment, native/custom scrollbar treatment, actual
|
||||
scrolling, pointer and keyboard resizing, grip sizing under zoom, and shared Glass
|
||||
tab motion, keyboard selection, and reduced motion.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Tabs } from "@buzz/ui";
|
||||
import { BLUR, RAMPS } from "@/shared/tokens/registry";
|
||||
|
||||
import { Note, PageHeader, Section, Swatch } from "./primitives";
|
||||
@@ -5,27 +6,23 @@ import { Note, PageHeader, Section, Swatch } from "./primitives";
|
||||
const GLASS = RAMPS.find((ramp) => ramp.id === "glass");
|
||||
|
||||
function ChromePill() {
|
||||
const items: Array<[string, boolean]> = [
|
||||
["Me", false],
|
||||
["Messages", true],
|
||||
["Projects", false],
|
||||
];
|
||||
const items = ["Me", "Messages", "Projects"];
|
||||
return (
|
||||
<div className="rim-glass blur-chrome flex items-center gap-1 rounded-full bg-chrome-glass p-1.5">
|
||||
{items.map(([label, selected]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={
|
||||
selected
|
||||
? "elevate-xs rounded-full bg-chrome-selected px-5 py-2 text-label text-primary"
|
||||
: "rounded-full px-5 py-2 text-body text-secondary transition-colors hover:bg-chrome-glass-hover hover:text-primary"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<Tabs.Root defaultValue="Messages">
|
||||
<Tabs.List variant="glass" aria-label="Glass navigation preview">
|
||||
{items.map((label) => (
|
||||
<Tabs.Tab key={label} value={label}>
|
||||
{label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
<Tabs.Indicator />
|
||||
</Tabs.List>
|
||||
{items.map((label) => (
|
||||
<Tabs.Panel key={label} value={label} className="bui-sr-only">
|
||||
{label} surface selected in this material preview.
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +36,7 @@ export function GlassPage() {
|
||||
|
||||
<Section
|
||||
title="Live"
|
||||
description="The chrome pill, rendered from the real tokens over the real backdrop. Hover an unselected item to see the glass hover move one step up the ramp."
|
||||
description="The chrome pill, rendered from the real tokens over the real backdrop. Select a tab to see the shared indicator slide; hover an unselected item to see the glass hover move one step up the ramp."
|
||||
>
|
||||
<div className="texture-dots flex items-center justify-center rounded-xl bg-app px-8 py-12">
|
||||
<ChromePill />
|
||||
|
||||
112
desktop-next/tests/layout-refinements.spec.ts
Normal file
112
desktop-next/tests/layout-refinements.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("table cells align to the reading edge and scroll surfaces use thin bars", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/design/components");
|
||||
const cells = page.locator("#table th, #table td");
|
||||
for (const cell of await cells.all())
|
||||
await expect(cell).toHaveCSS("text-align", "start");
|
||||
for (const selector of [
|
||||
"html",
|
||||
"textarea",
|
||||
"#table .bui-table-scroll",
|
||||
".catalog-source pre",
|
||||
]) {
|
||||
await expect(page.locator(selector).first()).toHaveCSS(
|
||||
"scrollbar-width",
|
||||
"thin",
|
||||
);
|
||||
}
|
||||
const viewport = page.locator("#scroll-area .bui-scroll-viewport");
|
||||
// Custom viewports must continue hiding the native bar, avoiding double scrollbars.
|
||||
await expect(viewport).toHaveCSS("scrollbar-width", "none");
|
||||
await expect(page.locator("#scroll-area .bui-scrollbar")).toHaveCSS(
|
||||
"width",
|
||||
"6px",
|
||||
);
|
||||
await viewport.press("End");
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((node) => node.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("resize grip is one rem with a usable pointer and keyboard target", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/design/components");
|
||||
const handle = page.getByRole("separator", { name: "Resize project list" });
|
||||
await handle.scrollIntoViewIfNeeded();
|
||||
const grip = () =>
|
||||
handle.evaluate((node) => getComputedStyle(node, "::after").height);
|
||||
expect(await grip()).toBe("16px");
|
||||
const bounds = await handle.boundingBox();
|
||||
if (!bounds) throw new Error("Missing resize target");
|
||||
expect(bounds.width).toBeGreaterThanOrEqual(24);
|
||||
expect(bounds.height).toBeGreaterThan(16);
|
||||
const before = await handle.getAttribute("aria-valuenow");
|
||||
await page.mouse.move(
|
||||
bounds.x + bounds.width / 2,
|
||||
bounds.y + bounds.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
bounds.x + bounds.width / 2 + 40,
|
||||
bounds.y + bounds.height / 2,
|
||||
{ steps: 5 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect(handle).not.toHaveAttribute("aria-valuenow", before ?? "");
|
||||
const after = await handle.getAttribute("aria-valuenow");
|
||||
await handle.press("ArrowLeft");
|
||||
await expect(handle).not.toHaveAttribute("aria-valuenow", after ?? "");
|
||||
await page.addStyleTag({ content: "html { font-size: 20px; }" });
|
||||
expect(await grip()).toBe("20px");
|
||||
});
|
||||
|
||||
test("Glass navigation shares selection motion and keyboard semantics", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/design/glass");
|
||||
const tabs = page.getByRole("tablist", { name: "Glass navigation preview" });
|
||||
const indicator = tabs.locator(".bui-tab-indicator");
|
||||
await expect(
|
||||
tabs.getByRole("tab", { name: "Messages", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await page.addStyleTag({
|
||||
content: ".bui-tab-indicator { transition-duration: 2s; }",
|
||||
});
|
||||
await tabs.getByRole("tab", { name: "Projects", exact: true }).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
indicator.evaluate((node) =>
|
||||
node
|
||||
.getAnimations()
|
||||
.map((a) => (a as CSSTransition).transitionProperty),
|
||||
),
|
||||
)
|
||||
.toContain("clip-path");
|
||||
await tabs.getByRole("tab", { name: "Me", exact: true }).click();
|
||||
await expect(
|
||||
tabs.getByRole("tab", { name: "Me", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await tabs.getByRole("tab", { name: "Me", exact: true }).press("ArrowRight");
|
||||
await expect(
|
||||
tabs.getByRole("tab", { name: "Messages", exact: true }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(
|
||||
tabs.getByRole("tab", { name: "Messages", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(await indicator.evaluate((node) => node.getAnimations().length)).toBe(
|
||||
0,
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await tabs.getByRole("tab", { name: "Projects", exact: true }).click();
|
||||
await expect(
|
||||
tabs.getByRole("tab", { name: "Projects", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(await indicator.evaluate((node) => node.getAnimations().length)).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,9 @@
|
||||
--control-sm: 2rem;
|
||||
--control-md: 2.5rem;
|
||||
--control-lg: 3.25rem;
|
||||
--size-scrollbar: 0.375rem;
|
||||
--size-grip-length: 1rem;
|
||||
--size-grip-thickness: 0.25rem;
|
||||
--space-action-inset: 1.25rem;
|
||||
--space-tooltip-block: 0.125rem;
|
||||
--layer-dialog: 80;
|
||||
|
||||
@@ -7,6 +7,17 @@ export const FOUNDATION_GROUPS = [
|
||||
["control-sm", "2rem", "Small actions."],
|
||||
["control-md", "2.5rem", "Standard actions and inputs."],
|
||||
["control-lg", "3.25rem", "Prominent actions."],
|
||||
[
|
||||
"size-scrollbar",
|
||||
"0.375rem",
|
||||
"Thin custom scrollbars and native scrollbar fallback.",
|
||||
],
|
||||
[
|
||||
"size-grip-length",
|
||||
"1rem",
|
||||
"Visible resize grip length, independent of its hit area.",
|
||||
],
|
||||
["size-grip-thickness", "0.25rem", "Visible resize grip thickness."],
|
||||
["space-action-inset", "1.25rem", "Standard button horizontal padding."],
|
||||
["space-tooltip-block", "0.125rem", "Compact tooltip vertical padding."],
|
||||
["layer-dialog", "80", "Modal surfaces."],
|
||||
|
||||
@@ -53,7 +53,9 @@ refs, controlled state, and events. Styled parts merge both string and state-fun
|
||||
class names. The underlying libraries own focus management, keyboard navigation,
|
||||
ARIA semantics, and dismissal. Caller-supplied labels and composition still matter.
|
||||
|
||||
Place `Tabs.Indicator` inside `Tabs.List` for a sliding selection pill. Pointer
|
||||
Place `Tabs.Indicator` inside `Tabs.List` for a sliding selection pill.
|
||||
Use `Tabs.List variant="glass"` for translucent chrome; it shares the same
|
||||
selection behavior and motion as the default solid variant. Pointer
|
||||
selection retargets the shared 120ms transition; keyboard focus and reduced motion
|
||||
keep selection immediate. Panels update immediately. Toasts use the shared
|
||||
180ms entrance and 120ms exit transitions, with no movement under reduced motion.
|
||||
@@ -72,6 +74,11 @@ or proprietary BlockUI package compatibility is implied.
|
||||
See `../../desktop-next/OPEN_SOURCE.md` for provenance and `THIRD_PARTY_NOTICES.md`
|
||||
for upstream notices. New source is covered by the repository's Apache-2.0 license.
|
||||
|
||||
Native scroll surfaces use thin scrollbars; custom ScrollArea bars share the
|
||||
`size-scrollbar` token. Resize grips use `size-grip-length` (1rem) for their visible
|
||||
length while retaining a larger pointer target. Table cells align to the reading
|
||||
edge, including row and column headings.
|
||||
|
||||
## Generated responses
|
||||
|
||||
`GeneratedResponse` accepts unknown input and validates it before rendering. The
|
||||
|
||||
@@ -10,11 +10,19 @@ function StyledRoot({
|
||||
}
|
||||
|
||||
function StyledList({
|
||||
variant = "solid",
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof Primitive.List>) {
|
||||
}: ComponentProps<typeof Primitive.List> & {
|
||||
/** Material of the tab track; behavior and indicator motion remain shared. */
|
||||
variant?: "solid" | "glass";
|
||||
}) {
|
||||
return (
|
||||
<Primitive.List {...props} className={skin("bui-tabs-list", className)} />
|
||||
<Primitive.List
|
||||
{...props}
|
||||
data-variant={variant}
|
||||
className={skin("bui-tabs-list", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@import "./styles/overlays.css";
|
||||
@import "./styles/surfaces.css";
|
||||
@import "./styles/layout.css";
|
||||
@import "./styles/scrollbars.css";
|
||||
@keyframes bui-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@@ -120,15 +121,31 @@
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.bui-resize-handle {
|
||||
background: var(--border-tertiary);
|
||||
border-radius: var(--radius-pill);
|
||||
width: 0.375rem;
|
||||
margin-inline: var(--space-control-gap);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--space-section-gap);
|
||||
background: transparent;
|
||||
}
|
||||
.bui-resize-handle:hover,
|
||||
.bui-resize-handle:focus-visible {
|
||||
.bui-resize-handle::after {
|
||||
content: "";
|
||||
width: var(--size-grip-thickness);
|
||||
height: var(--size-grip-length);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--border-primary);
|
||||
}
|
||||
.bui-resize-handle[aria-orientation="horizontal"] {
|
||||
width: auto;
|
||||
height: var(--space-section-gap);
|
||||
}
|
||||
.bui-resize-handle[aria-orientation="horizontal"]::after {
|
||||
width: var(--size-grip-length);
|
||||
height: var(--size-grip-thickness);
|
||||
}
|
||||
.bui-resize-handle:hover::after,
|
||||
.bui-resize-handle:focus-visible::after {
|
||||
background: var(--border-interactive);
|
||||
}
|
||||
.bui-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
);
|
||||
transition: clip-path var(--duration-state) var(--ease-standard);
|
||||
}
|
||||
.bui-tabs-list[data-variant="glass"] .bui-tab-indicator {
|
||||
background: var(--bg-chrome-selected);
|
||||
}
|
||||
.bui-tabs-list:has(.bui-tab:focus-visible) .bui-tab-indicator {
|
||||
transition: none;
|
||||
}
|
||||
@@ -166,6 +169,19 @@
|
||||
.bui-tab-panel {
|
||||
padding-block: var(--space-control-inset);
|
||||
}
|
||||
.bui-tabs-list[data-variant="glass"] {
|
||||
background: var(--bg-chrome-glass);
|
||||
backdrop-filter: blur(var(--blur-md));
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--rim-lit),
|
||||
inset 0 -1px 0 var(--rim-shade);
|
||||
}
|
||||
.bui-tabs-list[data-variant="glass"] .bui-tab {
|
||||
padding-inline: var(--space-action-inset);
|
||||
}
|
||||
.bui-tabs-list[data-variant="glass"] .bui-tab:not([data-active]):hover {
|
||||
background: var(--bg-chrome-glass-hover);
|
||||
}
|
||||
.bui-accordion {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
|
||||
@@ -45,16 +45,23 @@
|
||||
}
|
||||
.bui-scrollbar {
|
||||
display: flex;
|
||||
width: 0.5rem;
|
||||
width: var(--size-scrollbar);
|
||||
margin: 0.25rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-inset);
|
||||
background: transparent;
|
||||
}
|
||||
.bui-scrollbar[data-orientation="horizontal"] {
|
||||
width: auto;
|
||||
height: var(--size-scrollbar);
|
||||
}
|
||||
.bui-scroll-thumb {
|
||||
width: 100%;
|
||||
background: var(--border-primary);
|
||||
border-radius: inherit;
|
||||
}
|
||||
.bui-scrollbar[data-orientation="horizontal"] .bui-scroll-thumb {
|
||||
height: 100%;
|
||||
}
|
||||
.bui-toast-viewport {
|
||||
position: fixed;
|
||||
bottom: var(--space-panel-inset);
|
||||
@@ -133,6 +140,7 @@
|
||||
}
|
||||
.bui-table th,
|
||||
.bui-table td {
|
||||
text-align: start;
|
||||
padding: var(--space-control-inset);
|
||||
border-bottom: 1px solid var(--border-tertiary);
|
||||
white-space: nowrap;
|
||||
|
||||
26
packages/ui/src/styles/scrollbars.css
Normal file
26
packages/ui/src/styles/scrollbars.css
Normal file
@@ -0,0 +1,26 @@
|
||||
/* Apply one thin native treatment to pages, code, tables, inputs, and overlays.
|
||||
* Base UI viewports retain their own native-scrollbar suppression. */
|
||||
@layer base {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-primary) transparent;
|
||||
}
|
||||
@supports not (scrollbar-width: thin) {
|
||||
::-webkit-scrollbar {
|
||||
width: var(--size-scrollbar);
|
||||
height: var(--size-scrollbar);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-primary);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
* {
|
||||
scrollbar-color: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user