mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
desktop-new: SegmentedNavigation becomes Tabs, with a surface variant
The old name described one variant's appearance — a segmented control — which is exactly why a second appearance felt like it needed a second component. `Tabs` names the behaviour, so both variants sit under it naturally. Base UI's own export is also `Tabs`, so it is imported as `BaseTabs`; that reads better anyway, since `BaseTabs.Root` says plainly which layer each part comes from. `variant="chrome" | "panel"` is the chrome/surface axis DESIGN.md had deferred until a second component made the case. `IconButton` made it once; this made it twice, on a real screen. The failure: the chrome container is `glass-2`, which over a white panel composites to pure white, and its selected pill is `neutral-1` — also pure white. Container and selection became one colour with only a shadow between them, and no guard could see it because the component had no way to state which background it expected. Retinting `--bg-chrome-selected` would only have moved the collision. One component with a variant rather than two components, because behaviour, keyboard model, accessibility, props, and the Base UI parts underneath are identical — a sibling would duplicate all of it to change how selection is drawn, and the two would drift exactly as the four hand-assembled chrome surfaces did. The panel variant still emits `role="tablist"`, correct `aria-selected`, roving `tabIndex`, and arrow-key navigation, which is the concrete payoff. Each variant states all three of its states in full rather than inheriting a colour and overriding part of it. When a shared rule and a variant rule both set a property, the more specific selector wins regardless of source order, and a variant rule is always more specific — so the shared `[data-selected]` rule lost and the selected tab silently rendered as unselected. Hover carries `:not([data-selected])` because hover and selected genuinely overlap: a cursor can sit on the selected tab, and DESIGN.md § State says a selected item in a toggle group is not interactive. Those two selectors score identical specificity, so without the exclusion only source order would keep it right. This follows Chrome's modern CSS guidance: use `:not()` to say a state is irrelevant, never to defend one rule from another. `WorkspaceSurface` is renamed to `Panel` in the same change, since both touch the same rules in components.css. Signed-off-by: Morgan Martin <morganmartin@squareup.com> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -27,12 +27,15 @@
|
||||
}
|
||||
|
||||
.buzz-button[data-variant="primary"] {
|
||||
background: var(--bg-accent);
|
||||
background: var(--purple-9);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* The accent's own hover step, not a fade. `opacity: 0.9` lightened the label
|
||||
along with the fill and composited an unnamed colour nothing could measure —
|
||||
the move check-color.mjs rejects everywhere else. */
|
||||
.buzz-button[data-variant="primary"]:hover:not([data-disabled]) {
|
||||
opacity: 0.9;
|
||||
background: var(--purple-10);
|
||||
}
|
||||
|
||||
.buzz-button[data-variant="quiet"] {
|
||||
@@ -40,9 +43,17 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Scoped with `:not([data-icon-variant])` because IconButton passes
|
||||
`variant="ghost"` down to Button and carries its real appearance in
|
||||
`data-icon-variant`. Both selectors match, and this one is more specific
|
||||
(two pseudo-classes), so an unscoped neutral hover repainted a solid accent
|
||||
IconButton light grey while its text stayed white — Lc 13, invisible. An
|
||||
icon button's hover belongs to its icon variant; see below. */
|
||||
.buzz-button[data-variant="quiet"]:hover:not([data-disabled]),
|
||||
.buzz-button[data-variant="ghost"]:hover:not([data-disabled]) {
|
||||
background: var(--bg-hover);
|
||||
.buzz-button[data-variant="ghost"]:not([data-icon-variant]):hover:not(
|
||||
[data-disabled]
|
||||
) {
|
||||
background: var(--neutral-4);
|
||||
}
|
||||
|
||||
.buzz-button[data-disabled] {
|
||||
@@ -68,25 +79,31 @@
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
/* Every icon variant states its own hover. The one that did not —
|
||||
`solid` — fell through to the neutral ghost hover and lost its accent. */
|
||||
.buzz-button[data-icon-variant="quiet"] {
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
.buzz-button[data-icon-variant="quiet"]:hover:not([data-disabled]),
|
||||
.buzz-button[data-icon-variant="ghost"]:hover:not([data-disabled]) {
|
||||
background: var(--neutral-4);
|
||||
}
|
||||
|
||||
.buzz-button[data-icon-variant="solid"] {
|
||||
background: var(--bg-accent);
|
||||
background: var(--purple-9);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.buzz-button[data-icon-variant="chrome"] {
|
||||
background: var(--bg-chrome-glass);
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--rim-lit),
|
||||
inset 0 -1px 0 var(--rim-shade);
|
||||
backdrop-filter: blur(var(--blur-md));
|
||||
.buzz-button[data-icon-variant="solid"]:hover:not([data-disabled]) {
|
||||
background: var(--purple-10);
|
||||
}
|
||||
|
||||
.buzz-button[data-icon-variant="chrome"]:hover {
|
||||
background: var(--bg-chrome-glass-hover);
|
||||
/* Chrome sits on the backdrop and is clickable, so it takes the interactive
|
||||
material whole — fill, blur, rim, and hover in one line. It used to spell
|
||||
out three of those four and get the hover from a separate rule. */
|
||||
.buzz-button[data-icon-variant="chrome"] {
|
||||
@apply glass-primary-interactive;
|
||||
}
|
||||
|
||||
.buzz-avatar {
|
||||
@@ -118,91 +135,209 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.workspace-surface {
|
||||
/* A panel is the default opaque reading surface over the atmospheric
|
||||
backdrop. Its border and threshold shadow arrive together: taking only
|
||||
bg-panel would lose the boundary that lets it sit above the gradient. */
|
||||
.panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--bg-panel-glass);
|
||||
box-shadow: var(--shadow-sm);
|
||||
backdrop-filter: blur(var(--blur-lg));
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.workspace-surface[data-variant="connected-left"] {
|
||||
.panel[data-variant="connected-left"] {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.workspace-surface[data-variant="connected-right"] {
|
||||
.panel[data-variant="connected-right"] {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.segmented-navigation {
|
||||
display: inline-flex;
|
||||
height: 2.5rem;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-chrome-glass);
|
||||
box-shadow:
|
||||
var(--shadow-xs),
|
||||
inset 0 1px 0 var(--rim-lit),
|
||||
inset 0 -1px 0 var(--rim-shade);
|
||||
backdrop-filter: blur(var(--blur-md));
|
||||
}
|
||||
/* ============================================================
|
||||
Tabs — two surfaces, one control.
|
||||
|
||||
.segmented-navigation-list {
|
||||
`data-variant="chrome"` is the glass pill for the app gradient.
|
||||
`data-variant="panel"` is an underline for a plain surface.
|
||||
|
||||
The split exists because the chrome version failed on a white panel in a
|
||||
way nothing could catch: its container is `glass-2`, which over a white
|
||||
panel composites to pure white, and its selected pill is `neutral-1`,
|
||||
also pure white. Container and selection became the same colour and only
|
||||
a shadow separated them. That was not a wrong token value — retinting
|
||||
`bg-chrome-selected` would only move the collision — it was a component
|
||||
with no way to say which background it expects.
|
||||
|
||||
What is shared sits in the unprefixed rules: geometry, the tab box, the
|
||||
Base UI wiring. What differs is only how a surface and a selection are
|
||||
drawn.
|
||||
============================================================ */
|
||||
|
||||
/* --- Shared ------------------------------------------------ */
|
||||
|
||||
.buzz-tabs-list {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.segmented-navigation-indicator {
|
||||
/* Geometry only. **Colour is not here, deliberately.**
|
||||
|
||||
Each variant states all three of its states — unselected, hover, selected —
|
||||
in full, rather than inheriting a colour from here and overriding some of it.
|
||||
That is more lines and it is the right trade: when a shared rule and a
|
||||
variant rule both set the same property, which one wins depends on how
|
||||
specific each selector is, not on which is written later. A variant rule is
|
||||
always more specific, so it silently beat the shared `[data-selected]` rule
|
||||
and repainted the *selected* tab as unselected — a state that just stopped
|
||||
working, with no error.
|
||||
|
||||
Each variant owning its own states means no two rules ever set the colour of
|
||||
the same tab, so there is nothing to resolve and no `:not()` exceptions to
|
||||
reason about. Read one variant block and you know every colour it uses. */
|
||||
.buzz-tabs-tab {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Base UI drives both indicators from the same four custom properties
|
||||
(`--active-tab-left`, `--active-tab-width`, and their siblings), so the
|
||||
travelling animation is one implementation and the two surfaces only
|
||||
differ in what travels. */
|
||||
.buzz-tabs-indicator {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
bottom: var(--space-1);
|
||||
left: 0;
|
||||
width: var(--active-tab-width);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-chrome-selected);
|
||||
box-shadow: var(--shadow-xs);
|
||||
transform: translateX(var(--active-tab-left));
|
||||
transition:
|
||||
width var(--duration-settle) var(--easing-settle),
|
||||
transform var(--duration-settle) var(--easing-settle);
|
||||
}
|
||||
|
||||
.segmented-navigation-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* --- chrome: a glass pill on the gradient ------------------ */
|
||||
|
||||
/* This was the one chrome surface carrying an outer `shadow-xs` on top of the
|
||||
rim where the other three carried none. Nothing justified the difference —
|
||||
same height, same backdrop, same depth — so it takes the same material and
|
||||
the odd shadow is gone. */
|
||||
.buzz-tabs[data-variant="chrome"] {
|
||||
@apply glass-primary;
|
||||
|
||||
display: inline-flex;
|
||||
height: 2rem;
|
||||
height: 2.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border: 0;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.buzz-tabs[data-variant="chrome"] .buzz-tabs-list {
|
||||
height: 2rem;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.buzz-tabs[data-variant="chrome"] .buzz-tabs-tab {
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 0 var(--space-4);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segmented-navigation-item:hover:not([data-selected]) {
|
||||
background: var(--bg-chrome-glass-hover);
|
||||
/* A hover *inside* a glass surface, which is not itself a material: no blur,
|
||||
because the parent already blurs what is behind both of them, and a second
|
||||
backdrop-filter here would blur an already-blurred layer.
|
||||
|
||||
So this is a fill only, taking the hover step of the material it sits on.
|
||||
Note it composites: glass-3 over the parent's glass-2 lands near 0.82
|
||||
rather than at 0.62. That is the intended direction — an item under the
|
||||
cursor should read as more solid, per DESIGN.md § Surface and depth — but it
|
||||
is why a hover inside glass cannot simply reuse a whole material. */
|
||||
/* `:not([data-selected])` because hover and selected can both be true at once —
|
||||
a cursor can sit on the selected tab — and DESIGN.md § State says a selected
|
||||
item in a toggle group is not interactive, so it must not react. These two
|
||||
selectors score identical specificity, so without the exclusion only source
|
||||
order would keep it right, and reordering them would silently give the
|
||||
selected tab a hover fill. This is the case `:not()` is *for*: stating that a
|
||||
state is irrelevant, not defending a rule from another rule. */
|
||||
.buzz-tabs[data-variant="chrome"] .buzz-tabs-tab:hover:not([data-selected]) {
|
||||
background: var(--bg-glass-primary-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.segmented-navigation-item[data-selected] {
|
||||
.buzz-tabs[data-variant="chrome"] .buzz-tabs-tab[data-selected] {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.buzz-tabs[data-variant="chrome"] .buzz-tabs-indicator {
|
||||
top: var(--space-1);
|
||||
bottom: var(--space-1);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-chrome-selected);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* --- panel: an underline on a plain surface ---------------- */
|
||||
|
||||
/* No container fill and no border: on a panel the tabs are text, and the only
|
||||
drawn element is the bar. That is what keeps this legible where the glass
|
||||
pill was not — there is no second surface to collide with the first. */
|
||||
.buzz-tabs[data-variant="panel"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.buzz-tabs[data-variant="panel"] .buzz-tabs-list {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* The three states, in full. Same size in every one — selection is carried by
|
||||
colour and the bar, never by weight, because a weight change reflows the row
|
||||
and makes the tabs shift as the selection moves.
|
||||
|
||||
Tertiary at rest, where chrome uses secondary: on glass the container groups
|
||||
the tabs so a small step reads, but here the bar is the only mark and the
|
||||
text carries more of the work. Tertiary is a meta role at the Lc 45 target
|
||||
and measures 60.6 light / 52.5 dark on these surfaces. */
|
||||
.buzz-tabs[data-variant="panel"] .buzz-tabs-tab {
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Excluded for the same reason as the chrome variant above. */
|
||||
.buzz-tabs[data-variant="panel"] .buzz-tabs-tab:hover:not([data-selected]) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.buzz-tabs[data-variant="panel"] .buzz-tabs-tab[data-selected] {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Sits on the container's bottom edge and spans the tab exactly. `height` is
|
||||
the hairline; the indicator inherits its horizontal travel from the shared
|
||||
rule above. */
|
||||
.buzz-tabs[data-variant="panel"] .buzz-tabs-indicator {
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -219,7 +354,7 @@
|
||||
bottom: 0;
|
||||
left: var(--space-panel-inset);
|
||||
height: 1px;
|
||||
background: var(--border-tertiary);
|
||||
background: var(--border-primary);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -255,7 +390,7 @@
|
||||
}
|
||||
|
||||
.search-field:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--ring-focus);
|
||||
box-shadow: 0 0 0 2px var(--purple-8);
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
@@ -281,6 +416,15 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The section owns its own separation rather than asking every consumer to
|
||||
remember a gap. Rows inside a section sit 1px apart; sections sit
|
||||
`--space-section-gap` apart, and that difference is the entire signal for
|
||||
where one group ends and the next begins. Expressed as an adjacent-sibling
|
||||
margin so a section stacked anywhere gets it, with no wrapper required. */
|
||||
.navigator-section + .navigator-section {
|
||||
margin-top: var(--space-section-gap);
|
||||
}
|
||||
|
||||
.navigator-section-label {
|
||||
min-height: var(--size-row);
|
||||
margin: 0;
|
||||
@@ -303,7 +447,7 @@
|
||||
|
||||
.buzz-button[data-navigator-row]:hover,
|
||||
.buzz-button[data-navigator-row][data-selected] {
|
||||
background: var(--bg-hover);
|
||||
background: var(--neutral-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -325,14 +469,14 @@
|
||||
}
|
||||
|
||||
.buzz-button:focus-visible,
|
||||
.segmented-navigation-item:focus-visible {
|
||||
outline: 2px solid var(--ring-focus);
|
||||
.buzz-tabs-tab:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.buzz-button,
|
||||
.segmented-navigation-indicator {
|
||||
.buzz-tabs-indicator {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ type WorkspacePanels = {
|
||||
conversation: ReactNode;
|
||||
};
|
||||
|
||||
const DEFAULT_NAVIGATOR_WIDTH = 216;
|
||||
const MIN_NAVIGATOR_WIDTH = 176;
|
||||
|
||||
const WorkspacePanelContext = createContext<WorkspacePanels | null>(null);
|
||||
|
||||
function WorkspacePanel({ id }: { id: keyof WorkspacePanels }) {
|
||||
@@ -27,6 +30,7 @@ const components = {
|
||||
),
|
||||
};
|
||||
|
||||
/** Layout-only workspace shell. Product panels own their own surface treatment. */
|
||||
export function DockWorkspace({ panels }: { panels: WorkspacePanels }) {
|
||||
const onReady = useCallback((event: DockviewReadyEvent) => {
|
||||
if (event.api.panels.length > 0) return;
|
||||
@@ -34,7 +38,8 @@ export function DockWorkspace({ panels }: { panels: WorkspacePanels }) {
|
||||
id: "navigator",
|
||||
component: "navigator",
|
||||
title: "Browse",
|
||||
initialWidth: 255,
|
||||
initialWidth: DEFAULT_NAVIGATOR_WIDTH,
|
||||
minimumWidth: MIN_NAVIGATOR_WIDTH,
|
||||
});
|
||||
navigator.group.header.hidden = true;
|
||||
const conversation = event.api.addPanel({
|
||||
@@ -44,6 +49,9 @@ export function DockWorkspace({ panels }: { panels: WorkspacePanels }) {
|
||||
position: { referencePanel: navigator, direction: "right" },
|
||||
});
|
||||
conversation.group.header.hidden = true;
|
||||
requestAnimationFrame(() => {
|
||||
navigator.group.api.setSize({ width: DEFAULT_NAVIGATOR_WIDTH });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import type { ComponentProps, ElementType, ReactNode } from "react";
|
||||
|
||||
type SurfaceVariant =
|
||||
type PanelVariant =
|
||||
| "panel"
|
||||
| "connected-left"
|
||||
| "connected-right"
|
||||
| "navigator-demo";
|
||||
|
||||
type WorkspaceSurfaceProps<T extends ElementType> = {
|
||||
type PanelProps<T extends ElementType> = {
|
||||
as?: T;
|
||||
children: ReactNode;
|
||||
variant?: SurfaceVariant;
|
||||
variant?: PanelVariant;
|
||||
} & Omit<ComponentProps<T>, "as" | "children" | "className">;
|
||||
|
||||
export function WorkspaceSurface<T extends ElementType = "section">({
|
||||
export function Panel<T extends ElementType = "section">({
|
||||
as,
|
||||
children,
|
||||
variant = "panel",
|
||||
...props
|
||||
}: WorkspaceSurfaceProps<T>) {
|
||||
}: PanelProps<T>) {
|
||||
const Component = as ?? "section";
|
||||
return (
|
||||
<Component {...props} className="workspace-surface" data-variant={variant}>
|
||||
<Component {...props} className="panel" data-variant={variant}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
@@ -1,45 +1,71 @@
|
||||
import { Tabs } from "@base-ui/react/tabs";
|
||||
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SegmentedNavigationItem<Value extends string> = {
|
||||
export type TabItem<Value extends string> = {
|
||||
value: Value;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export function SegmentedNavigation<Value extends string>({
|
||||
/**
|
||||
* Which background this control is drawn for.
|
||||
*
|
||||
* `chrome` is the glass pill that belongs on the app gradient. `panel` is an
|
||||
* underline for a plain light or dark surface.
|
||||
*
|
||||
* **A variant rather than a second component**, because behaviour, keyboard
|
||||
* model, accessibility, props, and the Base UI tabs underneath are identical —
|
||||
* only the appearance differs, which is what a variant is for. `IconButton`
|
||||
* spells the same idea the same way.
|
||||
*
|
||||
* `chrome` is the default because every existing call site is on the gradient.
|
||||
*/
|
||||
export type TabsVariant = "chrome" | "panel";
|
||||
|
||||
export function Tabs<Value extends string>({
|
||||
value,
|
||||
items,
|
||||
label,
|
||||
onValueChange,
|
||||
trailingAction,
|
||||
variant = "chrome",
|
||||
}: {
|
||||
value: Value;
|
||||
items: readonly SegmentedNavigationItem<Value>[];
|
||||
items: readonly TabItem<Value>[];
|
||||
label: string;
|
||||
onValueChange: (value: Value) => void;
|
||||
trailingAction?: ReactNode;
|
||||
variant?: TabsVariant;
|
||||
}) {
|
||||
return (
|
||||
<Tabs.Root
|
||||
className="segmented-navigation"
|
||||
<BaseTabs.Root
|
||||
className="buzz-tabs"
|
||||
data-variant={variant}
|
||||
value={value}
|
||||
onValueChange={(nextValue) => onValueChange(nextValue as Value)}
|
||||
>
|
||||
<Tabs.List className="segmented-navigation-list" aria-label={label}>
|
||||
<Tabs.Indicator className="segmented-navigation-indicator" />
|
||||
<BaseTabs.List className="buzz-tabs-list" aria-label={label}>
|
||||
<BaseTabs.Indicator className="buzz-tabs-indicator" />
|
||||
{items.map((item) => (
|
||||
<Tabs.Tab
|
||||
<BaseTabs.Tab
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className="segmented-navigation-item"
|
||||
className="buzz-tabs-tab"
|
||||
/* Base UI spells the selected tab `data-active`; every other
|
||||
selectable thing in this codebase is styled on `data-selected`.
|
||||
Restating it here keeps one spelling in the stylesheet rather than
|
||||
asking a reader to know which components happen to be Base
|
||||
UI-backed. Without it the selected label silently kept
|
||||
`--text-secondary` — no error, just a tab that never looked
|
||||
selected. */
|
||||
data-selected={item.value === value || undefined}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</Tabs.Tab>
|
||||
</BaseTabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</BaseTabs.List>
|
||||
{trailingAction}
|
||||
</Tabs.Root>
|
||||
</BaseTabs.Root>
|
||||
);
|
||||
}
|
||||
|
||||
102
desktop-new/src/shared/ui/registry.test.ts
Normal file
102
desktop-new/src/shared/ui/registry.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BASE_UI_PARTS,
|
||||
COMPONENTS,
|
||||
resolveBaseUiBacking,
|
||||
baseUiDocsUrl,
|
||||
} from "./registry";
|
||||
|
||||
/**
|
||||
* The component sources as text, read through Vite rather than `node:fs` so the
|
||||
* paths resolve the same way the app resolves them and no Node types are needed.
|
||||
*/
|
||||
const SOURCES = import.meta.glob("/src/shared/ui/*.tsx", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
|
||||
function read(source: string): string {
|
||||
const contents = SOURCES[`/src/${source}`];
|
||||
if (contents === undefined) {
|
||||
throw new Error(`No source file at src/${source}`);
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Base UI column on /design/components/base-ui is only worth reading if it
|
||||
* reports what the code imports. These tests bind it to the files themselves —
|
||||
* a component that gains or loses a Base UI import fails here rather than
|
||||
* quietly showing a stale answer on the page.
|
||||
*/
|
||||
describe("component registry — Base UI backing", () => {
|
||||
it("names a source file that exists for every component", () => {
|
||||
for (const component of COMPONENTS) {
|
||||
expect(() => read(component.source), component.slug).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("claims exactly the Base UI modules its source imports", () => {
|
||||
for (const component of COMPONENTS) {
|
||||
const source = read(component.source);
|
||||
const imported = [
|
||||
...source.matchAll(/from "(@base-ui\/react\/[a-z-]+)"/g),
|
||||
].map((match) => match[1]);
|
||||
|
||||
const claimed = component.baseUi.map((part) => part.module);
|
||||
|
||||
expect(new Set(claimed), `${component.slug} claims`).toEqual(
|
||||
new Set(imported),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("composes only siblings it actually imports", () => {
|
||||
for (const component of COMPONENTS) {
|
||||
const source = read(component.source);
|
||||
for (const slug of component.composes) {
|
||||
const sibling = COMPONENTS.find((candidate) => candidate.slug === slug);
|
||||
expect(sibling, `${component.slug} composes ${slug}`).toBeDefined();
|
||||
expect(
|
||||
source.includes(`from "./${sibling?.name}"`),
|
||||
`${component.slug} imports ${sibling?.name}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves inherited backing through composition without double-counting", () => {
|
||||
// IconButton imports no Base UI part but renders a Buzz Button, so Base UI
|
||||
// Button is underneath it — reported as inherited, not own.
|
||||
const iconButton = resolveBaseUiBacking("icon-button");
|
||||
expect(iconButton.own).toEqual([]);
|
||||
expect(iconButton.inherited).toEqual([
|
||||
{ part: BASE_UI_PARTS.button, through: "Button" },
|
||||
]);
|
||||
|
||||
// SearchField imports Field and Input directly and composes IconButton,
|
||||
// which reaches Button — three distinct parts, none listed twice.
|
||||
const searchField = resolveBaseUiBacking("search-field");
|
||||
expect(searchField.own).toEqual([BASE_UI_PARTS.field, BASE_UI_PARTS.input]);
|
||||
expect(searchField.inherited.map((entry) => entry.part.name)).toEqual([
|
||||
"Button",
|
||||
]);
|
||||
|
||||
// A component whose own import already covers a part does not also report
|
||||
// it as inherited.
|
||||
const button = resolveBaseUiBacking("button");
|
||||
expect(button.own).toEqual([BASE_UI_PARTS.button]);
|
||||
expect(button.inherited).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds a docs URL per documented Base UI component slug", () => {
|
||||
for (const part of Object.values(BASE_UI_PARTS)) {
|
||||
expect(baseUiDocsUrl(part)).toBe(
|
||||
`https://base-ui.com/react/components/${part.docs}`,
|
||||
);
|
||||
expect(part.module).toBe(`@base-ui/react/${part.docs}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,36 @@
|
||||
export type ComponentStatus = "core" | "proposed";
|
||||
|
||||
/**
|
||||
* A Base UI part a component is built on. `name` is the export as documented;
|
||||
* `docs` is the path segment on base-ui.com/react/components, and `module` is
|
||||
* the import specifier, so the two cannot be claimed independently of the code.
|
||||
*/
|
||||
export type BaseUiPart = {
|
||||
name: string;
|
||||
docs: string;
|
||||
module: string;
|
||||
};
|
||||
|
||||
export const BASE_UI_PARTS = {
|
||||
avatar: { name: "Avatar", docs: "avatar", module: "@base-ui/react/avatar" },
|
||||
button: { name: "Button", docs: "button", module: "@base-ui/react/button" },
|
||||
field: { name: "Field", docs: "field", module: "@base-ui/react/field" },
|
||||
input: { name: "Input", docs: "input", module: "@base-ui/react/input" },
|
||||
tabs: { name: "Tabs", docs: "tabs", module: "@base-ui/react/tabs" },
|
||||
accordion: {
|
||||
name: "Accordion",
|
||||
docs: "accordion",
|
||||
module: "@base-ui/react/accordion",
|
||||
},
|
||||
dialog: { name: "Dialog", docs: "dialog", module: "@base-ui/react/dialog" },
|
||||
} as const satisfies Record<string, BaseUiPart>;
|
||||
|
||||
export const BASE_UI_DOCS_ROOT = "https://base-ui.com/react/components";
|
||||
|
||||
export function baseUiDocsUrl(part: BaseUiPart): string {
|
||||
return `${BASE_UI_DOCS_ROOT}/${part.docs}`;
|
||||
}
|
||||
|
||||
export type ComponentDefinition = {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -8,6 +39,19 @@ export type ComponentDefinition = {
|
||||
variants: readonly string[];
|
||||
status: ComponentStatus;
|
||||
owner?: string;
|
||||
/** The file this component lives in, relative to `src/`. */
|
||||
source: string;
|
||||
/**
|
||||
* The Base UI parts this component imports itself. Empty means it is built
|
||||
* from native elements — which is a legitimate answer, not a gap: a header,
|
||||
* a section, and a surface have no behaviour to inherit.
|
||||
*/
|
||||
baseUi: readonly BaseUiPart[];
|
||||
/**
|
||||
* Sibling components it composes, by slug. A component with no Base UI part
|
||||
* of its own can still inherit behaviour through one of these.
|
||||
*/
|
||||
composes: readonly string[];
|
||||
};
|
||||
|
||||
export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
@@ -19,6 +63,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
variants: ["primary", "quiet", "ghost"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/Button.tsx",
|
||||
baseUi: [BASE_UI_PARTS.button],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "icon-button",
|
||||
@@ -28,6 +75,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
variants: ["quiet", "ghost", "solid", "chrome"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/IconButton.tsx",
|
||||
baseUi: [],
|
||||
composes: ["button"],
|
||||
},
|
||||
{
|
||||
slug: "avatar",
|
||||
@@ -37,6 +87,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
variants: ["small", "default", "large"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/Avatar.tsx",
|
||||
baseUi: [BASE_UI_PARTS.avatar],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "inline-chip",
|
||||
@@ -47,24 +100,34 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
variants: ["person", "agent", "channel", "message", "link"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/InlineChip.tsx",
|
||||
baseUi: [],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "workspace-surface",
|
||||
name: "WorkspaceSurface",
|
||||
slug: "panel",
|
||||
name: "Panel",
|
||||
purpose: "A major panel sitting on the atmospheric workspace backdrop.",
|
||||
behavior: "Semantic native region",
|
||||
variants: ["panel", "connected-left", "connected-right"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/Panel.tsx",
|
||||
baseUi: [],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "segmented-navigation",
|
||||
name: "SegmentedNavigation",
|
||||
purpose: "A single-select switch between top-level workspace destinations.",
|
||||
slug: "tabs",
|
||||
name: "Tabs",
|
||||
purpose:
|
||||
"A single-select switch between sibling views. `chrome` is the glass pill for the app gradient; `panel` is an underline for a plain surface. One component because only the surface differs — the behaviour, keyboard model, and props are identical.",
|
||||
behavior: "Base UI Tabs",
|
||||
variants: ["default"],
|
||||
variants: ["chrome", "panel"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/Tabs.tsx",
|
||||
baseUi: [BASE_UI_PARTS.tabs],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "panel-header",
|
||||
@@ -75,6 +138,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
variants: ["default", "compact"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/PanelHeader.tsx",
|
||||
baseUi: [],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "search-field",
|
||||
@@ -84,6 +150,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
variants: ["default"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/SearchField.tsx",
|
||||
baseUi: [BASE_UI_PARTS.field, BASE_UI_PARTS.input],
|
||||
composes: ["icon-button"],
|
||||
},
|
||||
{
|
||||
slug: "navigator-section",
|
||||
@@ -93,6 +162,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
variants: ["default"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/NavigatorSection.tsx",
|
||||
baseUi: [],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "navigator-row",
|
||||
@@ -102,5 +174,55 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
variants: ["default", "inset", "selected"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/NavigatorRow.tsx",
|
||||
baseUi: [],
|
||||
composes: ["button"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* What a component is actually built on, once composition is followed.
|
||||
*
|
||||
* `own` is what the file itself imports from Base UI. `inherited` is what it
|
||||
* gets through a sibling — IconButton has no Base UI import of its own, but it
|
||||
* renders a Buzz Button, so Base UI Button's behaviour is still underneath it.
|
||||
* Reporting only `own` would call that component unbacked, which is wrong; not
|
||||
* distinguishing them would hide where the dependency actually enters.
|
||||
*/
|
||||
export type BaseUiBacking = {
|
||||
own: readonly BaseUiPart[];
|
||||
inherited: readonly { part: BaseUiPart; through: string }[];
|
||||
};
|
||||
|
||||
export function resolveBaseUiBacking(slug: string): BaseUiBacking {
|
||||
const byPart = new Map<string, { part: BaseUiPart; through: string }>();
|
||||
const root = COMPONENTS.find((candidate) => candidate.slug === slug);
|
||||
if (!root) return { own: [], inherited: [] };
|
||||
|
||||
// Bounded by the registry itself, and `seen` makes a cycle terminate rather
|
||||
// than recurse — a component composing a component that composes it back is a
|
||||
// mistake we would rather see as a missing row than a hung page.
|
||||
const seen = new Set<string>([slug]);
|
||||
const queue = [...root.composes];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const nextSlug = queue.shift();
|
||||
if (!nextSlug || seen.has(nextSlug)) continue;
|
||||
seen.add(nextSlug);
|
||||
|
||||
const next = COMPONENTS.find((candidate) => candidate.slug === nextSlug);
|
||||
if (!next) continue;
|
||||
|
||||
for (const part of next.baseUi) {
|
||||
if (!byPart.has(part.name))
|
||||
byPart.set(part.name, { part, through: next.name });
|
||||
}
|
||||
queue.push(...next.composes);
|
||||
}
|
||||
|
||||
// A part imported directly is reported once, as its own — not twice because a
|
||||
// composed sibling happens to use it too.
|
||||
for (const part of root.baseUi) byPart.delete(part.name);
|
||||
|
||||
return { own: root.baseUi, inherited: [...byPart.values()] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user