Skip to content

Commit cc6d497

Browse files
authored
Merge pull request #3187 from kev1n77/fmy/ui-main
fix(ui): shrink-wrap floating menus and align terminal font and global search spacing
2 parents 178e9b5 + d1476df commit cc6d497

27 files changed

Lines changed: 184 additions & 16 deletions

File tree

‎design-system/packages/design-tokens/src/system.tokens.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
},
3434
"mono": {
3535
"$type": "fontFamily",
36-
"$value": "'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace"
36+
"$value": "'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', monospace"
3737
}
3838
},
3939
"size": {
@@ -859,6 +859,10 @@
859859
"menu": {
860860
"$type": "dimension",
861861
"inlineSize": { "$value": "220px" },
862+
"minInlineSize": {
863+
"$description": "Lower bound for content-sized menus; overlay.menu.inlineSize stays their upper bound.",
864+
"$value": "160px"
865+
},
862866
"maxBlockSize": { "$value": "480px" },
863867
"surfacePadding": { "$value": "{space.2}" },
864868
"surfaceRadius": { "$value": "{radius.xl}" },

‎design-system/packages/design-tokens/tests/contract.test.mjs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ test("Menu tokens preserve the compact grouped surface contract", async () => {
242242
const systemDocument = await readSource("system.tokens.json");
243243

244244
assert.equal(tokens["overlay.menu.inlineSize"], "220px");
245+
assert.equal(tokens["overlay.menu.minInlineSize"], "160px");
246+
assert.ok(
247+
Number.parseFloat(tokens["overlay.menu.minInlineSize"]) < Number.parseFloat(tokens["overlay.menu.inlineSize"]),
248+
"content-sized menus need a minimum strictly below the fixed width",
249+
);
245250
assert.equal(tokens["overlay.menu.maxBlockSize"], "480px");
246251
assert.equal(tokens["overlay.menu.headingHeight"], "24px");
247252
assert.equal(tokens["overlay.menu.itemHeight"], "30px");

‎design-system/packages/ui/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,12 @@ Product code owns positioning and viewport limits, and must not patch private
598598
list/section-items/group-options gaps. A deliberate density variation belongs
599599
on the owning surface via `--openbitfun-overlay-menu-row-gap`.
600600

601+
Menus use `overlay.menu.inlineSize` by default. `Menu` / `MenuPopover` accept
602+
`inlineSize="content"` for short, product-owned surfaces such as a context menu:
603+
the surface then hugs its widest row, stays at or above
604+
`overlay.menu.minInlineSize`, and never exceeds the fixed token. Long menus that
605+
share a column with the same triggering control keep the fixed width.
606+
601607
ActionItem hover and pressed surfaces use the semantic neutral hover fill;
602608
pressed text remains semibold. Menu and navigation captions consume the final
603609
caption color directly, avoiding a second opacity multiplier. The nested-menu

‎design-system/packages/ui/src/components/ActionItem/ActionItem.module.css‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@
126126
white-space: nowrap;
127127
}
128128

129+
/* A text shortcut hint is secondary text, so it must not inherit the surface body size. */
130+
.shortcut {
131+
font-family: var(--openbitfun-type-meta-font-family);
132+
font-size: var(--openbitfun-type-meta-font-size);
133+
font-weight: var(--openbitfun-type-meta-font-weight);
134+
line-height: var(--openbitfun-type-meta-line-height);
135+
letter-spacing: var(--openbitfun-type-meta-letter-spacing);
136+
white-space: nowrap;
137+
}
138+
129139
.root[data-disabled="true"] .metadata {
130140
color: inherit;
131141
}

‎design-system/packages/ui/src/components/Menu/Menu.meta.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const menuMeta = {
1010
{ name: "MenuList.children", type: "ReactNode (row stack inside custom scroll or animation wrappers)" },
1111
{ defaultValue: "false", name: "autoFocusFirstItem", type: "boolean" },
1212
{ defaultValue: "auto", name: "scrollbarVisibility", type: "auto | always | hidden" },
13+
{ defaultValue: "fixed", name: "inlineSize", type: "fixed | content" },
1314
{ name: "MenuPopover.items", type: "readonly MenuEntry[]" },
1415
{ name: "MenuPopover.open / onClose", type: "boolean / () => void" },
1516
{ name: "MenuPopover.anchorRef / position", type: "RefObject<HTMLElement> / { x: number; y: number }" },
@@ -29,6 +30,7 @@ export const menuMeta = {
2930
"color.selection.surface",
3031
"color.focus.ring",
3132
"overlay.menu.inlineSize",
33+
"overlay.menu.minInlineSize",
3234
"overlay.menu.maxBlockSize",
3335
"overlay.menu.surfacePadding",
3436
"overlay.menu.surfaceRadius",

‎design-system/packages/ui/src/components/Menu/Menu.module.css‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
box-shadow: var(--openbitfun-shadow-menu);
2424
}
2525

26+
/* Content-driven menus hug their widest row; the fixed token stays the upper bound. */
27+
.root[data-openbitfun-inline-size="content"] {
28+
inline-size: max-content;
29+
min-inline-size: var(--openbitfun-overlay-menu-min-inline-size);
30+
max-inline-size: min(var(--openbitfun-overlay-menu-inline-size), 100%);
31+
}
32+
2633
.viewport {
2734
flex: 1 1 auto;
2835
min-inline-size: 0;

‎design-system/packages/ui/src/components/Menu/Menu.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ import styles from "./Menu.module.css";
2121

2222
export type MenuItemRole = "menuitem" | "menuitemcheckbox" | "menuitemradio";
2323

24+
/** `fixed` keeps the menu width token; `content` fits the rows between the menu minimum and that token. */
25+
export type MenuInlineSize = "fixed" | "content";
26+
2427
export interface MenuProps
2528
extends Omit<HTMLAttributes<HTMLDivElement>, "autoFocus" | "role"> {
2629
autoFocusFirstItem?: boolean;
2730
children: ReactNode;
31+
inlineSize?: MenuInlineSize;
2832
scrollbarVisibility?: ScrollbarVisibility;
2933
}
3034

@@ -105,6 +109,7 @@ export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu({
105109
autoFocusFirstItem = false,
106110
children,
107111
className,
112+
inlineSize = "fixed",
108113
onFocusCapture,
109114
onKeyDown,
110115
scrollbarVisibility = "auto",
@@ -210,6 +215,7 @@ export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu({
210215
{...props}
211216
className={classNames(styles.root, className)}
212217
data-openbitfun-component="menu"
218+
data-openbitfun-inline-size={inlineSize}
213219
onFocusCapture={handleFocusCapture}
214220
onKeyDown={handleKeyDown}
215221
ref={setRootRef}

‎design-system/packages/ui/src/components/Menu/MenuPopover.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ interface MenuLevelProps extends Omit<MenuProps, "children"> {
121121
parts?: MenuPopoverParts;
122122
}
123123

124-
function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, position, placement, menuRef: externalRef, autoFocusFirstItem, className, style, parts, ...props }: MenuLevelProps) {
124+
function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, position, placement, menuRef: externalRef, autoFocusFirstItem, className, inlineSize, style, parts, ...props }: MenuLevelProps) {
125125
const MenuSurface = parts?.root ?? Menu;
126126
const Item = parts?.item ?? MenuItem;
127127
const Separator = parts?.separator ?? MenuSeparator;
@@ -197,12 +197,12 @@ function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, pos
197197
return () => doc?.removeEventListener("keydown", keyboard, true);
198198
});
199199

200-
const submenu = activeEntry ? <SubmenuBoundary className={styles.submenuBoundary}><MenuLevel key={activeEntry.id} id={submenuId} aria-label={activeEntry.label} menuRef={submenuRef} items={activeEntry.submenu!} open={open} phase={phase} treeId={treeId} onClose={onClose} parts={parts}
200+
const submenu = activeEntry ? <SubmenuBoundary className={styles.submenuBoundary}><MenuLevel key={activeEntry.id} id={submenuId} aria-label={activeEntry.label} menuRef={submenuRef} items={activeEntry.submenu!} open={open} phase={phase} treeId={treeId} onClose={onClose} parts={parts} inlineSize={inlineSize}
201201
onBack={() => { intent.closeNow(); submenuAnchor.current?.focus(); }} anchorRef={submenuAnchor} placement="right" autoFocusFirstItem={keyboardOpen.current}
202202
onPointerEnter={intent.keepOpen} onPointerLeave={intent.requestClose} /></SubmenuBoundary> : null;
203203

204204
return <>
205-
<MenuSurface {...props} ref={node => { (menuRef as { current: HTMLDivElement | null }).current = node; }} className={classNames(styles.popup, className)} autoFocusFirstItem={open && autoFocusFirstItem && Boolean(layout)} tabIndex={-1}
205+
<MenuSurface {...props} ref={node => { (menuRef as { current: HTMLDivElement | null }).current = node; }} className={classNames(styles.popup, className)} inlineSize={inlineSize} autoFocusFirstItem={open && autoFocusFirstItem && Boolean(layout)} tabIndex={-1}
206206
style={{ ...layout?.style, ...style, visibility: layout ? undefined : "hidden" }} data-openbitfun-native-webview-occlusion data-openbitfun-menu-tree={treeId} data-placement={layout?.placement ?? placement} data-state={phase}
207207
aria-hidden={!open || undefined} {...(!open ? { inert: "" } : {})} onContextMenu={event => event.preventDefault()}>
208208
{items.map(item => item.separator ? <Separator key={item.id} /> : <Item key={item.id} data-menu-id={item.id} leading={item.icon ? <Leading className={styles.icon} data-openbitfun-icon-slot="true">{item.icon}</Leading> : undefined} shortcut={item.shortcut ? <Shortcut>{item.shortcut}</Shortcut> : undefined} tone={item.tone} role={item.role} checked={item.checked}

‎design-system/packages/ui/tests/menu.test.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,24 @@ test("Menu keeps equal item insets while its scrollbar stays on the surface edge
143143
);
144144
assert.doesNotMatch(styles, /scrollbar-gutter:\s*stable/);
145145
});
146+
147+
test("content-sized menus hug their widest row inside the shared menu bounds", async () => {
148+
const styles = await readFile(new URL("../src/components/Menu/Menu.module.css", import.meta.url), "utf8");
149+
const popover = await readFile(new URL("../src/components/Menu/MenuPopover.tsx", import.meta.url), "utf8");
150+
const contentRule = styles.match(/\.root\[data-openbitfun-inline-size="content"\]\s*\{[^}]*\}/)?.[0] ?? "";
151+
152+
assert.notEqual(contentRule, "");
153+
assert.match(contentRule, /inline-size:\s*max-content/);
154+
assert.match(contentRule, /min-inline-size:\s*var\(--openbitfun-overlay-menu-min-inline-size\)/);
155+
assert.match(contentRule, /max-inline-size:\s*min\(var\(--openbitfun-overlay-menu-inline-size\), 100%\)/);
156+
// The default surface keeps the fixed token instead of hugging content.
157+
assert.match(styles, /\.root\s*\{[^}]*inline-size:\s*var\(--openbitfun-overlay-menu-inline-size\)/);
158+
159+
const contentMarkup = renderToStaticMarkup(createElement(Menu, { inlineSize: "content" }, createElement(MenuItem, null, "Paste")));
160+
const defaultMarkup = renderToStaticMarkup(createElement(Menu, null, createElement(MenuItem, null, "Paste")));
161+
assert.match(contentMarkup, /data-openbitfun-inline-size="content"/);
162+
assert.match(defaultMarkup, /data-openbitfun-inline-size="fixed"/);
163+
// Submenus are separate surfaces and must inherit the requested sizing mode.
164+
assert.match(popover, /items=\{activeEntry\.submenu!\}[^>]*inlineSize=\{inlineSize\}/);
165+
});
166+

‎src/apps/data-migrator/ui/generated/design-system.css‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
--openbitfun-focus-offset: 2px;
164164
--openbitfun-focus-width: 2px;
165165
--openbitfun-font-family-control: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
166-
--openbitfun-font-family-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace;
166+
--openbitfun-font-family-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', monospace;
167167
--openbitfun-font-family-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
168168
--openbitfun-font-size-2xl: 18px;
169169
--openbitfun-font-size-2xl-plus: 20px;
@@ -388,6 +388,7 @@
388388
--openbitfun-overlay-menu-item-padding-inline: var(--openbitfun-space-2);
389389
--openbitfun-overlay-menu-item-radius: var(--openbitfun-radius-base);
390390
--openbitfun-overlay-menu-max-block-size: 480px;
391+
--openbitfun-overlay-menu-min-inline-size: 160px;
391392
--openbitfun-overlay-menu-row-gap: 2px;
392393
--openbitfun-overlay-menu-scrollbar-gap: 2px;
393394
--openbitfun-overlay-menu-section-gap: var(--openbitfun-space-2);

0 commit comments

Comments
 (0)