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
2 changes: 2 additions & 0 deletions design-system/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ This file applies to `design-system/**`. Repository-wide rules in the root `AGEN
- `@openbitfun/ui` owns React anatomy, behavior, accessibility, and stable variants. It must remain independent from product routes, stores, locale catalogs, Tauri APIs, and concrete themes.
- `@openbitfun/design-tokens` owns theme-independent names and system scales. `@openbitfun/theme-openbitfun` supplies replaceable reference and semantic values.
- Components consume semantic or system CSS variables. Raw colors are forbidden in public component CSS. Component-private variables use the `--_` prefix.
- Floating surfaces use the document overlay coordinator through `Portal`. Opening order owns global ranks; keep `open` through retained exits and declare `ownerRef` for sibling/coordinate child portals. Modal focus, inert state and dismissal must use the same owner. Notification layout regions remain neutral, with one `OverlayLayer` per card. See the UI README overlay contract.
- Menu and Listbox row spacing is owned by `overlay.menu.rowGap`, separately from in-row and section spacing. Use `MenuList` around menu rows inside custom scroll/animation wrappers; do not repair individual consumers with row margins or private list-gap overrides.
- Single-line text slots use `OverflowText`: plain text defaults to fade-out truncation plus an interaction marquee; rich composition keeps its layout unless explicitly opted in. Keep icons/actions outside the text slot, mark the owning control with `data-overflow-trigger`, and preserve wrapping or native editing where appropriate. See the UI package README for the shared interaction contract.
- Files under `dist/` are generated and must not be edited or committed.
- Design Lab may alias `@openbitfun/ui` to source only during Vite development for HMR. Its production build must consume package exports.
Expand Down
4 changes: 4 additions & 0 deletions design-system/packages/design-tokens/src/system.tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,10 @@
"maxBlockSize": { "$value": "480px" },
"surfacePadding": { "$value": "{space.2}" },
"surfaceRadius": { "$value": "{radius.xl}" },
"rowGap": {
"$description": "Space between menu or listbox row surfaces, independent of spacing inside a row or between sections.",
"$value": "2px"
},
"sectionGap": { "$value": "{space.2}" },
"headingHeight": { "$value": "24px" },
"headingPaddingInline": { "$value": "{space.2}" },
Expand Down
5 changes: 5 additions & 0 deletions design-system/packages/design-tokens/tests/contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ test("Menu tokens preserve the compact grouped surface contract", async () => {
assert.equal(tokens["overlay.menu.maxBlockSize"], "480px");
assert.equal(tokens["overlay.menu.headingHeight"], "24px");
assert.equal(tokens["overlay.menu.itemHeight"], "30px");
for (const mode of tokenModes) {
assert.equal(tokenCatalog.find(token => token.name === "overlay.menu.rowGap").values[mode], "2px");
}
assert.notEqual(tokens["overlay.menu.rowGap"], tokens["overlay.menu.itemGap"]);
assert.notEqual(tokens["overlay.menu.rowGap"], tokens["overlay.menu.sectionGap"]);
assert.equal(tokens["overlay.menu.itemIconSize"], "14px");
assert.equal(systemDocument.overlay.menu.surfacePadding.$value, "{space.2}");
assert.equal(systemDocument.overlay.menu.surfaceRadius.$value, "{radius.xl}");
Expand Down
54 changes: 49 additions & 5 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,8 @@ restores focus before dispatching `onSelect`; the host owns asynchronous work
and error handling. The popup flips and clamps to the viewport, keeps keyboard
navigation in the active menu, and supports safe pointer travel to either side.

Portals resolve through `DesignSystemProvider.portalHost`, then fall back to the
nearest design-system root. Stable `parts` wrappers preserve host data hooks;
Portals resolve through `DesignSystemProvider.portalHost`, then fall back to one
document-owned overlay host above application content. Stable `parts` wrappers preserve host data hooks;
they must forward all props and refs and retain public component ownership.
`useSubmenuIntent` is available for product popovers that need the same pointer
corridor behavior.
Expand All @@ -472,6 +472,37 @@ The host should opt a presence-owned surface out of its fallback animations;
outside-pointer dismissal leaves the clicked control's focus alone, while
Escape and menu actions restore the trigger before handing off to another UI.

### Overlay ownership and ordering

`Portal` (or its expression form `createOverlayPortal`) registers each presented
surface with one coordinator per document. All providers in that document share
the same order. New openings receive a later rank; content/progress updates and
StrictMode effect replay retain their rank. Reopening a retained surface brings
it forward. Do not use category z-index values to rank menus, dialogs or notices.

Nested portals inherit ownership. Sibling portals and coordinate menus pass
`ownerRef` for their source element, including menus rendered by a global
renderer. Child menus remain above their owner, belong to its modal focus domain,
and become hidden when that owner closes. A modal may have owned child portals;
unrelated background surfaces remain inert through its exit transition.

Use `open={open}` for retained exits and unmount when the exit completes.
Custom modal portals additionally pass `modal`, `surfaceRef`, and `onDismiss`;
their surface supplies dialog semantics and a label. The coordinator owns scroll
locking, background inert state, top-layer Escape/outside dismissal and focus
return after the modal barrier is released. Use `subscribeOverlayInteraction`
for custom menu keyboard/outside handling instead of competing document listeners.
Host shortcut routers can use `hasOverlayLayers()` to defer Escape.

Background notices use `passive`. Their first presentation waits until a modal
releases the document; already visible notices retain their rank behind later
modals. Start dismissal timers inside the admitted content and pause them while
`useHasModalOverlay()` is true. A notification stack uses a layout-only
`OverlayRegion`, with one `OverlayLayer` per card. The region and scroll ancestors
must not introduce a stacking context (z-index, fixed positioning, transform,
filter, isolation or paint containment); apply material and motion to each card.
Panel-local views and noninteractive export render trees remain local content.

## FlowChat tool cards


Expand Down Expand Up @@ -543,9 +574,22 @@ leading roles scale with user typography. FieldGroup uses the form group tint,
retaining its existing row padding, dividers, and radius. The Patterns form
specimen shows both orientations and long values over a tinted container.

Menus keep contiguous 30px rows with no additional list or heading-to-item gap;
separators own their 8px vertical margins. Their keyboard focus indicator is
inset so scrolling does not clip it or require extra permanent padding.
Menu and Listbox row surfaces are separated by `overlay.menu.rowGap` (2px),
including grouped options and the Listbox used by Select, Combobox and MultiSelect.
This is distinct from `itemGap` (icon-to-label spacing) and `sectionGap` (8px).
Menu keeps its 30px row height and no extra heading-to-item gap; separators and
adjacent sections account for the row gap instead of adding it twice.
The menu keyboard focus indicator stays inset so scrolling does not clip it.

The list owns spacing; individual rows never add compensating margins. `Menu`
and `MenuSection` use `MenuList` internally. If custom scrolling, animation or
other markup wraps a collection of MenuItems, use `MenuList` immediately around
the rows (for example, `<ScrollArea><MenuList>...</MenuList></ScrollArea>`).
Flex gap only reaches immediate children, so a plain wrapper loses that contract.
Product code owns positioning and viewport limits, and must not patch private
list/section-items/group-options gaps. A deliberate density variation belongs
on the owning surface via `--openbitfun-overlay-menu-row-gap`.

ActionItem hover and pressed surfaces use the semantic neutral hover fill;
pressed text remains semibold. Menu and navigation captions consume the final
caption color directly, avoiding a second opacity multiplier. The nested-menu
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const comboboxMeta = {
"overlay.menu.inlineSize",
"overlay.menu.surfacePadding",
"overlay.menu.surfaceRadius",
"overlay.menu.rowGap",
"overlay.menu.itemIconSize",
"shadow.menu",
"type.label.selected.fontWeight",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,15 +366,6 @@
min-block-size: 0;
}

.listbox [data-openbitfun-part="list"],
.listbox [data-openbitfun-part="group-options"] {
gap: calc(var(--openbitfun-space-1) / 2);
}

.listbox [data-openbitfun-part="group"] {
gap: var(--openbitfun-overlay-menu-section-gap);
}

.listbox [data-openbitfun-listbox-option][data-selected="true"] [data-openbitfun-part="label"] {
font-weight: var(--openbitfun-type-label-selected-font-weight);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ const CollectionPicker = forwardRef<HTMLDivElement, PickerProps>(function Collec
</span>
) : null}
{popover && (
<Portal ownerDocument={triggerRef.current?.ownerDocument}>
<Portal ownerDocument={triggerRef.current?.ownerDocument} ownerRef={triggerRef} open={resolvedOpen}>
{popover}
</Portal>
)}
Expand Down
4 changes: 1 addition & 3 deletions design-system/packages/ui/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { useDesignSystem } from "../../overlay/useDesignSystem";
import { useDismissibleLayer } from "../../overlay/useDismissibleLayer";
import { useFocusScope } from "../../overlay/useFocusScope";
import { usePresence } from "../../overlay/usePresence";
import { useScrollLock } from "../../overlay/useScrollLock";
import { IconButton, type IconButtonProps } from "../IconButton";
import styles from "./Dialog.module.css";

Expand Down Expand Up @@ -163,13 +162,12 @@ const OverlaySurface = forwardRef<HTMLDivElement, OverlaySurfaceProps>(function
trapFocus,
restoreFocus,
});
useScrollLock((open || present) && preventScroll, ownerDocument);

if (!present || !resolvedPortalHost) return null;
const exiting = state === "exiting";

return (
<Portal target={resolvedPortalHost}>
<Portal target={resolvedPortalHost} open={open} modal preventScroll={preventScroll}>
<div
{...overlayProps}
className={classNames(styles.overlay, overlayProps?.className)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const listboxMeta = {
"color.focus.ring",
"overlay.menu.maxBlockSize",
"overlay.menu.scrollbarGap",
"overlay.menu.rowGap",
"overlay.menu.sectionGap",
"overlay.menu.headingHeight",
"overlay.menu.headingPaddingInline",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,19 @@
flex-direction: column;
}

.list {
.list,
.groupOptions {
gap: var(--openbitfun-overlay-menu-row-gap);
}

.group {
gap: var(--openbitfun-overlay-menu-section-gap);
}

.group + .group {
margin-block-start: max(0px, calc(var(--openbitfun-overlay-menu-section-gap) - var(--openbitfun-overlay-menu-row-gap)));
}

.groupLabel {
display: flex;
align-items: center;
Expand Down
2 changes: 2 additions & 0 deletions design-system/packages/ui/src/components/Menu/Menu.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const menuMeta = {
name: "Menu",
props: [
{ name: "children", type: "ReactNode" },
{ name: "MenuList.children", type: "ReactNode (row stack inside custom scroll or animation wrappers)" },
{ defaultValue: "false", name: "autoFocusFirstItem", type: "boolean" },
{ defaultValue: "auto", name: "scrollbarVisibility", type: "auto | always | hidden" },
{ name: "MenuPopover.items", type: "readonly MenuEntry[]" },
Expand All @@ -31,6 +32,7 @@ export const menuMeta = {
"overlay.menu.maxBlockSize",
"overlay.menu.surfacePadding",
"overlay.menu.surfaceRadius",
"overlay.menu.rowGap",
"overlay.menu.sectionGap",
"overlay.menu.headingHeight",
"overlay.menu.headingPaddingInline",
Expand Down
17 changes: 11 additions & 6 deletions design-system/packages/ui/src/components/Menu/Menu.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@
}

.list {
display: flex;
min-inline-size: 0;
flex-direction: column;
gap: 0;
padding: 0;
/* Balance the end inset with the start inset after the viewport scrollbar gap. */
padding-inline-end: calc(
Expand All @@ -62,11 +58,11 @@
}

.section + .section {
margin-block-start: var(--openbitfun-overlay-menu-section-gap);
margin-block-start: max(0px, calc(var(--openbitfun-overlay-menu-section-gap) - var(--openbitfun-overlay-menu-row-gap)));
}

.items {
gap: 0;
gap: var(--openbitfun-overlay-menu-row-gap);
}

.heading {
Expand Down Expand Up @@ -138,6 +134,15 @@
background: var(--openbitfun-color-border-subtle);
}

/* A separator owns the total section spacing, including the list's gap. */
.items > .separator:not(:first-child) {
margin-block-start: max(0px, calc(var(--openbitfun-overlay-menu-section-gap) - var(--openbitfun-overlay-menu-row-gap)));
}

.items > .separator:not(:last-child) {
margin-block-end: max(0px, calc(var(--openbitfun-overlay-menu-section-gap) - var(--openbitfun-overlay-menu-row-gap)));
}

@media (prefers-contrast: more) {
.headingLabel {
color: var(--openbitfun-color-content-muted);
Expand Down
28 changes: 25 additions & 3 deletions design-system/packages/ui/src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface MenuItemProps
role?: MenuItemRole;
}

/** A row stack for custom scroll or animation wrappers inside a Menu. */
export interface MenuListProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
"data-openbitfun-part"?: string;
}

export interface MenuSectionAction {
disabled?: boolean;
icon: ReactNode;
Expand Down Expand Up @@ -79,6 +85,22 @@ function setActiveItem(items: readonly HTMLButtonElement[], index: number, focus
}
}

export const MenuList = forwardRef<HTMLDivElement, MenuListProps>(function MenuList({
className,
"data-openbitfun-part": part = "items",
...props
}, ref) {
return (
<div
{...props}
className={classNames(styles.items, className)}
data-openbitfun-menu-list=""
data-openbitfun-part={part}
ref={ref}
/>
);
});

export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu({
autoFocusFirstItem = false,
children,
Expand Down Expand Up @@ -198,7 +220,7 @@ export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu({
orientation="vertical"
scrollbarVisibility={scrollbarVisibility}
>
<div className={styles.list} data-openbitfun-part="list">{children}</div>
<MenuList className={styles.list} data-openbitfun-part="list">{children}</MenuList>
</ScrollArea>
</div>
);
Expand Down Expand Up @@ -272,9 +294,9 @@ export const MenuSection = forwardRef<HTMLDivElement, MenuSectionProps>(function
)}
</div>
)}
<div className={styles.items} data-openbitfun-part="section-items">
<MenuList data-openbitfun-part="section-items">
{children}
</div>
</MenuList>
</div>
);
});
Expand Down
12 changes: 9 additions & 3 deletions design-system/packages/ui/src/components/Menu/MenuPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface MenuPopoverProps extends Omit<MenuProps, "children"> {
open: boolean;
onClose: () => void;
anchorRef?: RefObject<HTMLElement | null>;
/** Logical owner for coordinate menus rendered outside their source tree. */
ownerRef?: RefObject<HTMLElement | null>;
position?: { x: number; y: number };
placement?: LayerPlacement;
/** Stable wrappers must forward all props (and refs for root/item/separator). */
Expand All @@ -54,7 +56,7 @@ function ownItems(menu: HTMLElement) {
}

/** Anchored/coordinate menu with nested navigation, safe pointer corridors and focus return. */
export function MenuPopover({ items, open, onClose, anchorRef, position, placement = "bottom", autoFocusFirstItem = true, ...props }: MenuPopoverProps) {
export function MenuPopover({ items, open, onClose, anchorRef, ownerRef, position, placement = "bottom", autoFocusFirstItem = true, ...props }: MenuPopoverProps) {
const markerRef = useRef<HTMLSpanElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const treeId = useId();
Expand Down Expand Up @@ -100,7 +102,7 @@ export function MenuPopover({ items, open, onClose, anchorRef, position, placeme
return (
<>
<span ref={markerRef} hidden />
<Portal ownerDocument={markerRef.current?.ownerDocument}>{content}</Portal>
<Portal ownerDocument={markerRef.current?.ownerDocument} ownerRef={ownerRef ?? anchorRef} open={open}>{content}</Portal>
</>
);
}
Expand Down Expand Up @@ -213,6 +215,10 @@ function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, pos
<Label>{item.label}</Label>
</Item>)}
</MenuSurface>
{submenu && <Portal ownerDocument={menuRef.current?.ownerDocument}>{submenu}</Portal>}
{submenu && <Portal ownerDocument={menuRef.current?.ownerDocument} ownerRef={menuRef} open={open}
surfaceRef={submenuRef} dismissOnPointerOutside onDismiss={reason => {
if (reason === "escape-key") { intent.closeNow(); submenuAnchor.current?.focus(); }
else onClose();
}}>{submenu}</Portal>}
</>;
}
2 changes: 2 additions & 0 deletions design-system/packages/ui/src/components/Menu/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
export {
Menu,
MenuItem,
MenuList,
MenuSection,
MenuSeparator,
type MenuItemProps,
type MenuItemRole,
type MenuListProps,
type MenuProps,
type MenuSectionAction,
type MenuSectionProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const selectMeta = {
"overlay.menu.maxBlockSize",
"overlay.menu.surfacePadding",
"overlay.menu.surfaceRadius",
"overlay.menu.rowGap",
"overlay.menu.sectionGap",
"overlay.menu.headingHeight",
"overlay.menu.headingPaddingInline",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,6 @@
min-block-size: 0;
}

.listbox [data-openbitfun-part="list"],
.listbox [data-openbitfun-part="group-options"] {
gap: calc(var(--openbitfun-space-1) / 2);
}

.listbox [data-openbitfun-part="group"] {
gap: var(--openbitfun-overlay-menu-section-gap);
}

.listbox [data-openbitfun-listbox-option][data-selected="true"] [data-openbitfun-part="label"] {
font-weight: var(--openbitfun-type-label-selected-font-weight);
}
Expand Down
2 changes: 1 addition & 1 deletion design-system/packages/ui/src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
{renderTriggerContent()}
</button>
{popup && (
<Portal ownerDocument={triggerRef.current?.ownerDocument}>
<Portal ownerDocument={triggerRef.current?.ownerDocument} ownerRef={triggerRef} open={resolvedOpen}>
{popup}
</Portal>
)}
Expand Down
Loading
Loading