Skip to content
Draft
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
17 changes: 17 additions & 0 deletions src/shared/design-system/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ carry the shared field appearance. RadioGroup is for one choice, Checkbox for an
independent choice and Switch for an immediate on/off setting. Use the native
form semantics exposed by those Base UI primitives rather than duplicating them.

## Compositions

Dialog composes a Base UI modal with a shared title, optional description, body,
close button and actions. Pending operations set preventClose so Escape and the
close button agree. It retains the app's explicit dismissal behavior: outside
clicks do not discard a form. Provide initialFocus for search dialogs and
finalFocus when a flow has an external trigger or opens a second dialog.

Use Tooltip for short hints on labelled controls; use PreviewCard for richer
content. Tooltip owns its description link and inherits placement, focus and
Escape behavior from Base UI. Overlay layers keep menus and hints above dialogs.

Tabs with content use renderPanel, which lets Base UI connect each tab and panel.
Route navigation uses NavigationItem with aria-current instead. NavigationItem
forwards normal button events, refs and data attributes so unread observation,
preloading and product shortcuts remain with the caller.

## State

- **Design default, hover, pressed, focus, selected, disabled and loading states where they apply.** Pressed changes fill without moving the control. Loading keeps the label footprint and prevents repeated activation; CSS alone cannot enforce it.
Expand Down
4 changes: 2 additions & 2 deletions src/shared/design-system/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@
/* A preview is supplemental context; destination cards can also open it.
It is portal-rendered by Base UI so a clipped parent cannot hide it. */
.buzz-preview-card {
z-index: 1000;
z-index: var(--layer-popover);
display: grid;
width: max-content;
max-width: min(18rem, 75vw);
Expand Down Expand Up @@ -777,7 +777,7 @@
background: var(--surface-popover);
color: var(--text-standard);
box-shadow: var(--shadow-sm);
z-index: 1000;
z-index: var(--layer-popover);
}
.buzz-select-group-label {
padding: var(--space-2) var(--space-3) var(--space-1);
Expand Down
1 change: 1 addition & 0 deletions src/shared/design-system/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
@import "./typography-base.css";
@import "./components.css";
@import "./forms.css";
@import "./overlays.css";
@import "./chips.css";
@import "./bento.css";
@source "../";
Expand Down
99 changes: 99 additions & 0 deletions src/shared/design-system/styles/overlays.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
@layer components {
.buzz-dialog-backdrop {
position: fixed;
inset: 0;
z-index: var(--layer-dialog-backdrop);
background: var(--bg-scrim);
}
.buzz-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: var(--layer-dialog);
display: flex;
flex-direction: column;
gap: var(--space-4);
width: min(31rem, calc(100vw - 2rem));
max-height: calc(100dvh - 3rem);
overflow: auto;
border: 1px solid var(--border-standard);
border-radius: var(--radius-panel);
padding: var(--space-6);
background: var(--surface-popover);
color: var(--text-standard);
box-shadow: var(--shadow-sm);
}
.buzz-dialog-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--space-4);
}
.buzz-dialog-header > h2 {
margin: 0;
}
.buzz-dialog-description {
@apply text-body-sm;
margin: 0;
color: var(--text-subtle);
}
.buzz-dialog-body {
min-width: 0;
}
.buzz-dialog-actions {
display: flex;
flex-wrap: wrap;
justify-content: end;
gap: var(--space-3);
}
.buzz-tooltip-positioner {
z-index: var(--layer-tooltip);
}
.buzz-tooltip {
@apply text-label-sm;
max-width: min(24rem, calc(100vw - 2rem));
border-radius: var(--radius-pill);
padding: var(--space-2) var(--space-4);
background: var(--affordance-prominent);
color: var(--text-inverse);
box-shadow: var(--shadow-sm);
overflow-wrap: anywhere;
}
.buzz-tab-panels {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.buzz-tabs-panel {
min-width: 0;
}
html[data-keyboard-navigation] .buzz-tabs-panel:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.navigation-item[data-variant="pill"] {
width: auto;
flex-shrink: 0;
border-radius: var(--radius-pill);
padding-inline: var(--space-4);
}
.navigation-item[data-variant="pill"] .navigation-item-label {
flex: initial;
}
.navigation-item[data-variant="pill"][data-selected] {
background: var(--surface-panel);
color: var(--text-standard);
}
.navigation-item:active {
background: var(--affordance-subtle-pressed);
}
@media (max-width: 480px) {
.buzz-dialog {
padding: var(--space-4);
}
.buzz-dialog-actions > .buzz-button {
flex: 1 1 auto;
}
}
}
4 changes: 4 additions & 0 deletions src/shared/design-system/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,10 @@
inset and a 4px gap. */
--space-chip-inset: 0.1875rem;
--space-chip-gap: var(--space-1);
--layer-dialog-backdrop: 1000;
--layer-dialog: 1001;
--layer-popover: 1100;
--layer-tooltip: 1200;
--size-topbar: 3.5rem;
--size-panel-header: 3.5rem;
--size-control-sm: 2rem;
Expand Down
84 changes: 84 additions & 0 deletions src/shared/design-system/ui/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { IconX } from "@tabler/icons-react";
import type { ComponentProps, ReactNode } from "react";
import { IconButton } from "./IconButton";

type PopupProps = ComponentProps<typeof BaseDialog.Popup>;
export type DialogProps = {
open: boolean;
onOpenChange(open: boolean): void;
title: ReactNode;
description?: ReactNode;
children: ReactNode;
actions?: ReactNode;
closeLabel?: string;
/** A pending operation can prevent all user dismissal paths. */
preventClose?: boolean;
initialFocus?: PopupProps["initialFocus"];
finalFocus?: PopupProps["finalFocus"];
};

/** Base UI owns the modal, portal, focus trap, Escape and focus restoration. */
export function Dialog({
open,
onOpenChange,
title,
description,
children,
actions,
closeLabel = "Close",
preventClose = false,
initialFocus,
finalFocus,
}: DialogProps) {
return (
<BaseDialog.Root
open={open}
disablePointerDismissal
onOpenChange={(next, details) => {
if (!next && preventClose) {
details.cancel();
return;
}
onOpenChange(next);
}}
>
<BaseDialog.Portal>
<BaseDialog.Backdrop data-buzz-ui="" className="buzz-dialog-backdrop" />
<BaseDialog.Popup
data-buzz-ui=""
className="buzz-dialog"
aria-modal="true"
initialFocus={initialFocus}
finalFocus={finalFocus}
>
<header className="buzz-dialog-header">
<BaseDialog.Title className="text-heading">
{title}
</BaseDialog.Title>
<BaseDialog.Close
disabled={preventClose}
render={
<IconButton
aria-label={closeLabel}
disabled={preventClose}
size="compact"
icon={<IconX size={16} aria-hidden="true" />}
/>
}
/>
</header>
{description && (
<BaseDialog.Description className="buzz-dialog-description">
{description}
</BaseDialog.Description>
)}
<div className="buzz-dialog-body">{children}</div>
{actions && (
<footer className="buzz-dialog-actions">{actions}</footer>
)}
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
2 changes: 1 addition & 1 deletion src/shared/design-system/ui/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function Field({
</BaseField.Description>
)}
{error && (
<BaseField.Error match className="buzz-field-error">
<BaseField.Error match role="alert" className="buzz-field-error">
{error}
</BaseField.Error>
)}
Expand Down
15 changes: 9 additions & 6 deletions src/shared/design-system/ui/NavigationItem.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
import { Button as BaseButton } from "@base-ui/react/button";
import type { ReactNode } from "react";
import type { ComponentProps, ReactNode } from "react";

export function NavigationItem({
label,
icon,
trailing,
selected = false,
inset = false,
onClick,
variant = "row",
...props
}: {
label: string;
icon?: ReactNode;
trailing?: ReactNode;
selected?: boolean;
inset?: boolean;
onClick?: () => void;
}) {
variant?: "row" | "pill";
} & Omit<ComponentProps<typeof BaseButton>, "className" | "children">) {
return (
<BaseButton
{...props}
type={props.type ?? "button"}
data-variant={variant}
data-buzz-ui=""
className="navigation-item"
data-selected={selected || undefined}
data-inset={inset || undefined}
onClick={onClick}
aria-current={selected ? "page" : undefined}
aria-current={props["aria-current"] ?? (selected ? "page" : undefined)}
>
{icon}
<span className="navigation-item-label">{label}</span>
Expand Down
40 changes: 32 additions & 8 deletions src/shared/design-system/ui/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export function Tabs<Value extends string>({
variant = "chrome",
previewValue,
arrivalValue,
renderPanel,
}: {
/** Omit only when composing an existing externally owned view. */
renderPanel?: (value: Value) => ReactNode;
value: Value;
items: readonly TabItem<Value>[];
label: string;
Expand All @@ -43,14 +46,8 @@ export function Tabs<Value extends string>({
/** Brief visual confirmation of a tab arriving after a completed move. */
arrivalValue?: Value | undefined;
}) {
return (
<BaseTabs.Root
data-buzz-ui=""
className="buzz-tabs"
data-variant={variant}
value={value}
onValueChange={(nextValue) => onValueChange(nextValue as Value)}
>
const strip = (
<>
<BaseTabs.List className="buzz-tabs-list" aria-label={label}>
<BaseTabs.Indicator className="buzz-tabs-indicator" />
{items.map((item) => (
Expand Down Expand Up @@ -78,6 +75,33 @@ export function Tabs<Value extends string>({
))}
</BaseTabs.List>
{trailingAction}
</>
);
return (
<BaseTabs.Root
data-buzz-ui=""
className={renderPanel ? "buzz-tab-panels" : "buzz-tabs"}
data-variant={variant}
value={value}
onValueChange={(nextValue) => onValueChange(nextValue as Value)}
>
{renderPanel ? (
<div className="buzz-tabs" data-variant={variant}>
{strip}
</div>
) : (
strip
)}
{renderPanel &&
items.map((item) => (
<BaseTabs.Panel
key={item.value}
value={item.value}
className="buzz-tabs-panel"
>
{renderPanel(item.value)}
</BaseTabs.Panel>
))}
</BaseTabs.Root>
);
}
43 changes: 43 additions & 0 deletions src/shared/design-system/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
import { useId, useState, type ReactElement, type ReactNode } from "react";

/** A short supplementary hint. The trigger still owns its accessible name. */
export function Tooltip({
children,
content,
}: {
children: ReactElement<{ "aria-describedby"?: string }>;
content: ReactNode;
}) {
const id = useId();
const [open, setOpen] = useState(false);
return (
<BaseTooltip.Root open={open} onOpenChange={setOpen}>
<BaseTooltip.Trigger
delay={0}
render={children}
aria-describedby={
[children.props["aria-describedby"], open ? id : undefined]
.filter(Boolean)
.join(" ") || undefined
}
/>
<BaseTooltip.Portal>
<BaseTooltip.Positioner
side="top"
sideOffset={4}
className="buzz-tooltip-positioner"
>
<BaseTooltip.Popup
role="tooltip"
id={id}
data-buzz-ui=""
className="buzz-tooltip"
>
{content}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
</BaseTooltip.Root>
);
}
Loading
Loading