fix(ui): compact context menus + viewport-clamped submenu flip

减重:全部右键菜单(FileTree 行菜单 / TabBar tab 菜单 / GitLens 文件与
历史菜单 / FreeWindow 标题菜单)与「+」菜单切到宿主 Menu 原语的 compact
密度(26px 行高 / 12px 字 / 164px 卡片),图标随槽位 16→14,「打开方式」
子菜单图钉 20→16,还原工具型右键菜单的趁手感。

越界:宿主子菜单纯 CSS 定位(从父行向上、向右生长)不受视口钳制——树顶
部右键时「打开方式 ▸」子菜单伸出视口上沿,窄面板(280–400px)时伸出右
沿。新增 menu-flip.ts:菜单打开期间在 <body> 发布 data-dsh-sidebar-submenu
="down|left" 方向 token(y < vh/2 → down;x + 400 > vw → left),layout.css
据此翻转子菜单生长方向与 ::before 悬停桥;属性存续期即作用域,宿主自有
菜单不受影响。刻意不做 max-height 滚动钳制——overflow 裁剪会切断悬停桥。

测试:menu-flip.spec.tsx(token 纯函数 + hook 生命周期 + CSS 契约跨文件
一致性);tab-bar / file-tree 两个 spec 追加翻转向量断言;mount.e2e 追加
compact 行高(≤30px)布局级证据。设计偏差记录同步至
docs/plans/2026-08-22-open-with-menu-design.md。
This commit is contained in:
Menghuan1918
2026-09-04 20:28:35 +08:00
parent f59ffd0741
commit 76b57508f4
13 changed files with 366 additions and 20 deletions
@@ -89,6 +89,8 @@ Cursor (SSH)
**实施偏差(2026-09,#517 / PR #522)**:SSH 远程编辑器链接**不再经宿主路由执行**。DSH 部署在无头远端服务器时,宿主侧 `xdg-open` 无 DISPLAY/无编辑器,`vscode://` 静默失败。`api.openExternal`(`src/client/api.ts`)现把 `<scheme>://vscode-remote/ssh-remote+…` 形态的 URL 在浏览器客户端同步触发 `window.location.assign`(处于用户点击链内,外部协议交给本机编辑器经 Remote-SSH 打开远端文件);reveal 与本地编辑器 URL 仍走本节宿主路由。普通浏览器可处理自定义协议;禁止/未处理 `vscode://` 的 WebView 壳客户端需各自适配(见 #517 补充信息)。
**实施偏差(2026-09,右键菜单减重与子菜单视口钳制)**:本菜单(及 TabBar / GitLens / FreeWindow 右键菜单、「+」菜单)整体切到 primitives `Menu` 的 **`compact` 密度**(行高 26px / 12px 字 / 164px 卡片 / 7px 圆角),菜单图标随槽位 16→14、子菜单图钉 20→16。同时发现宿主子菜单**不受视口钳制**(`bottom:-4px` 从父行向上生长、`left:calc(100%+10px)` 向右展开,主列表的钳制与 `max-height` 收缩均不覆盖它):树顶部右键时本子菜单伸出视口上沿,窄面板(280–400px)时伸出右沿。修复为插件侧方案 `src/client/menu-flip.ts`——菜单打开期间在 `<body>` 发布 `data-dsh-sidebar-submenu="down|left"` 方向 token(`y < vh/2` → down;`x + 400 > vw` → left,400 镜像宿主几何 218 卡片 + 10 间隙 + 165 子菜单 + 12 边距),`layout.css` 据此翻转子菜单生长方向与 `::before` 悬停桥;**属性的存续期即作用域**(仅我们的菜单打开时存在),宿主自有菜单不受影响。**不做** `max-height` + 滚动钳制:overflow 裁剪会切掉子菜单 `::before` 悬停桥(跨 10px 间隙会触发 mouseLeave 关卡)。残留见「已知限制」末两条。
## 已知限制
- 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。
@@ -96,13 +98,18 @@ Cursor (SSH)
- 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。
- Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。
- SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。
- 子菜单钳制为方向翻转而非高度裁剪:约 >15 个打开方式目标 + 矮视口 + 不利光标位仍可能溢出(compact 密度已把常规 4–8 目标场景收进安全区)。
- 翻转阈值镜像宿主 `Menu.module.css` 几何常量(218/10/165/12):宿主未来改尺寸时阈值偏保守(多翻一次方向),不会产生新越界;菜单打开期间窗口 resize 不重算 token,该次打开最坏退化为原始行为。
## 测试
- `tests/open-with.spec.ts`:解析容错、目标解析与 SSH 过滤、URL 构建(本地/SSH/custom/坏模板)、校验器。
- `tests/open-external.spec.ts`:三平台命令表、URL 校验、spawn 前校验。
- `tests/open-external-client.spec.ts`(#522):SSH remote URL 客户端分流(不触 fetch)、本地/reveal/http(s) 留宿主、导航抛错 reject。
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏。
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏;翻转向量(down / down left / 卸载清理)。
- `tests/menu-flip.spec.tsx`:方向 token 纯函数(四象限、边界、窄面板)+ hook 属性生命周期 + layout.css 选择器与属性名的跨文件一致性。
- `tests/tab-bar-context-menu.spec.tsx`:追加翻转向量(顶部条 down、右缘 down left、下半空 token、卸载清理)。
- `tests/e2e/mount.e2e.ts`:「+」菜单首行 computed 高度 ≤30px(compact 密度的布局级证据)。
- `tests/open-with-settings.spec.tsx`:SSH 输入、添加/删除(删除剪枝 pinned)、无效提示、VSCode 系开关。
- `tests/side-card-section.spec.tsx`:`SettingsBody` 行列表 + render 共存、仅 render 时不变。
- `tests/builtins.spec.ts`:editor 描述符断言补 `settings.render`。
+20 -14
View File
@@ -32,6 +32,7 @@ import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
import { useSubmenuFlip } from './menu-flip.ts'
import type { OpenWithTarget } from './open-with.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
@@ -147,6 +148,9 @@ export function FileTree(props: {
const [copiedPath, setCopiedPath] = useState<string | null>(null)
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
// The row menu's "open with" submenu is the one submenu that can tower past
// the viewport; publish its flip geometry for layout.css while it is open.
useSubmenuFlip(rowMenu)
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
const [dropOver, setDropOver] = useState(false)
/** The directory a drag is hovering right now (null = body, drop to root). */
@@ -373,13 +377,14 @@ export function FileTree(props: {
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
const pinnedIds = openWithPinned ?? []
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
* reveal gets the folder glyph, custom editors a generic code mark. */
* reveal gets the folder glyph, custom editors a generic code mark.
* The compact menu's icon slot is 14px, so every mark renders at 14. */
const itemIcon = (target: OpenWithTarget): ReactNode => {
if (target.kind === 'reveal') return <VscFolderOpened size={16} />
if (target.id === 'vscode') return <IconVscode16 size={16} />
if (target.id === 'cursor') return <SiCursor size={16} />
if (target.id === 'zed') return <SiZedindustries size={16} />
return <IconCodeOutline16 size={16} />
if (target.kind === 'reveal') return <VscFolderOpened size={14} />
if (target.id === 'vscode') return <IconVscode16 size={14} />
if (target.id === 'cursor') return <SiCursor size={14} />
if (target.id === 'zed') return <SiZedindustries size={14} />
return <IconCodeOutline16 size={14} />
}
const pinned = openWithTargets
.filter(target => pinnedIds.includes(target.id))
@@ -412,7 +417,7 @@ export function FileTree(props: {
onToggleOpenWithPin?.(target.id)
}}
>
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
</span>
</span>
),
@@ -433,7 +438,7 @@ export function FileTree(props: {
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
</span>
),
icon: <VscLinkExternal size={16} />,
icon: <VscLinkExternal size={14} />,
submenu,
},
]
@@ -650,22 +655,22 @@ export function FileTree(props: {
items={[
// The open escapes head the FILE menu (dirs only get copy).
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={16} /> }]
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
: []),
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={14} /> }]
: []),
...openWithEntries(),
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
: []),
// Upload into a directory (incl. the workspace root row).
...(rowMenu?.isDir === true
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
onSelect={(id) => {
const target = rowMenu
@@ -698,6 +703,7 @@ export function FileTree(props: {
)
}}
portal
compact
align="start"
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
anchor={<span />}
+1
View File
@@ -279,6 +279,7 @@ export function FreeWindow(props: {
else if (id === 'close') onClose()
}}
portal
compact
align="start"
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
anchor={<span />}
+6
View File
@@ -16,6 +16,7 @@ import {
import type { SidebarTab } from './state.ts'
import { isAgentTabId } from './state.ts'
import { isPinnedVirtualTab } from './pinned.ts'
import { useSubmenuFlip } from './menu-flip.ts'
import { IconPinOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -90,6 +91,9 @@ export function TabBar(props: {
// The tab right-click context menu: the target tab plus the cursor
// position (the portaled Menu anchors there, following the git lens/FileTree).
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
// The strip sits at the panel's top, so the pin submenu must grow downward;
// the flip hook derives that from the cursor y (upper half → "down").
useSubmenuFlip(tabMenu)
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
// The context target's index in the render-time tab snapshot; -1 when the
@@ -265,6 +269,7 @@ export function TabBar(props: {
setMenuOpen(false)
}}
portal
compact
align="end"
anchor={(
<button
@@ -355,6 +360,7 @@ export function TabBar(props: {
}
}}
portal
compact
align="start"
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
anchor={<span />}
+2
View File
@@ -671,6 +671,7 @@ export function GitLens(props: GitLensProps) {
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
}}
portal
compact
align="start"
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
anchor={<span />}
@@ -728,6 +729,7 @@ export function GitLens(props: GitLensProps) {
}
}}
portal
compact
align="start"
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
anchor={<span />}
+37
View File
@@ -111,3 +111,40 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
transition: none;
}
}
/* Context-menu submenu flip (viewport clamping for the portaled menus).
*
* The ui-primitives Menu clamps its main portal list into the viewport but
* positions nested submenus with static CSS only — anchored at the parent
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
* Docked on the window's right edge, both defaults are the worst case for
* this panel: a tall "open with" submenu escapes past the viewport top when
* the row menu opens near the top, and past the right edge on narrow panels
* (PANEL_MIN 280 < card 218 + gap + submenu 165).
*
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
* words "down" and/or "left" — for exactly the lifetime of one of OUR
* submenu-bearing menus (FileTree row menu, TabBar tab menu). The tokens
* flip the growth direction; the attribute's absence restores the
* primitive's own geometry untouched, and host-owned menus are never
* affected because they never open while the attribute exists (each side
* closes on the other's outside pointerdown).
*
* No max-height/overflow scroll cap here on purpose: overflow clipping
* would cut the submenu's ::before hover bridge (the 10px corridor that
* keeps itemWrap's mouseleave from closing the card mid-crossing). */
body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
top: -4px;
bottom: auto;
}
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] {
left: auto;
right: calc(100% + 10px);
}
/* The hover bridge follows the card to the left side of the parent row. */
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before {
left: auto;
right: -10px;
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Submenu flip geometry for the portaled context menus.
*
* The primitives `Menu` clamps its main list into the viewport (portal mode,
* 12px margin) but positions nested submenus with pure CSS — `bottom: -4px`
* (grow UP from the parent row) and `left: calc(100% + 10px)` (open RIGHT of
* the card) — with no viewport clamp at all. This sidebar lives docked on the
* RIGHT edge of the window, so both defaults are the worst case here: a tall
* "open with" submenu pokes past the viewport top when the row menu opens
* near the top, and past the right edge whenever the panel is narrow.
*
* The host primitive is not ours to patch, so the fix is a body attribute
* written while one of OUR submenu-bearing menus is open: layout.css flips
* the submenu's growth direction per token. The attribute's lifetime IS the
* scope — only our menus set it, and layout.css rules for
* `div[role="menu"] div[role="menu"]` only apply while it exists, so
* host-owned menus are never touched.
*/
import { useEffect } from 'react'
/** body attribute carrying the flip tokens (whitespace-separated words). */
export const SUBMENU_FLIP_ATTR = 'data-dsh-sidebar-submenu'
/**
* Mirror of the primitive's submenu geometry (Menu.module.css): card
* min-width 218 + card-to-submenu gap 10 + submenu min-width 163..165 +
* viewport margin 12 ≈ 400px of right-hand room a right-growing submenu
* needs. If the cursor is within that of the right edge, open left instead.
* The estimate is conservative (the primitive's own clamp can only move the
* card further left), so a wrong guess flips left unnecessarily — never into
* a new overflow — and drifting from future host metrics degrades the same
* way, back to today's behavior at worst.
*/
const SUBMENU_RIGHT_ROOM = 400
/** Below the vertical midpoint the parent row usually has more room above. */
function isUpperHalf(y: number, vh: number): boolean {
return y < vh / 2
}
/**
* The flip tokens for a menu opened at viewport coordinates (x, y):
* `down` — open the submenu downward from the parent row (cursor in the
* upper half; the sidebar's tab strip is always up there);
* `left` — open the submenu leftward of the card (not enough right-hand
* room). Empty string when the primitive's defaults already fit.
*/
export function submenuFlipTokens(x: number, y: number, vw: number, vh: number): string {
const tokens: string[] = []
if (isUpperHalf(y, vh)) tokens.push('down')
if (x + SUBMENU_RIGHT_ROOM > vw) tokens.push('left')
return tokens.join(' ')
}
/**
* Publish the flip tokens on <body> while the given cursor-anchored menu
* state is open; clear them on close and on unmount (a menu left open by an
* unmounting panel must not leave stale geometry on the shared body).
* `menu` only needs the viewport coordinates the Menu anchored to.
*/
export function useSubmenuFlip(menu: { x: number; y: number } | null): void {
useEffect(() => {
if (menu === null) return
document.body.setAttribute(SUBMENU_FLIP_ATTR, submenuFlipTokens(menu.x, menu.y, window.innerWidth, window.innerHeight))
return () => { document.body.removeAttribute(SUBMENU_FLIP_ATTR) }
}, [menu])
}
+5 -4
View File
@@ -2439,15 +2439,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
}
/* The pushpin toggle on an "open with" submenu row: brand ink once the
target is pinned to the menu's top level. */
target is pinned to the menu's top level. Sized for the compact menu's
26px rows (16px box, 12px glyph) — proportionate without overflowing. */
.openWithPin {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
width: 16px;
height: 16px;
border-radius: 5px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
+2 -1
View File
@@ -69,6 +69,7 @@ export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: Ses
id: d.id,
label: typeof d.title === 'function' ? d.title() : d.title,
disabled: !(d.available?.(ctx, scope, state) ?? true),
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
// 14 matches the compact + menu's icon slot (compactList .itemIcon).
icon: typeof d.icon === 'function' ? d.icon(14) : d.icon,
}))
}
+7
View File
@@ -281,6 +281,13 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await newTabButton.click()
const item = page.getByRole('menuitem', { name: title }).first()
await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1)
if (title === BUILTIN_TABS[0]) {
// Layout-level evidence for the compact density: rows render ~26px
// tall (the default density would be 40px). Guard loose at ≤30px so
// font/zoom differences across runners cannot flake it.
const rowHeight = await item.evaluate(el => el.getBoundingClientRect().height)
expect(rowHeight, '+ menu rows must render at the compact density (≤30px)').toBeLessThanOrEqual(30)
}
await item.click()
// Let the activation commit (including any lazy-chunk fetch) before the
// crash assertions run.
+42
View File
@@ -204,3 +204,45 @@ describe('FileTree open-with menu', () => {
expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
})
})
describe('FileTree open-with menu flip geometry (submenu clamping)', () => {
const ATTR = 'data-dsh-sidebar-submenu'
let harness: Harness
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
document.body.removeAttribute(ATTR)
})
it('publishes "down" for a top-of-tree opening and clears on selection', async () => {
harness = await mountTree()
// openMenu() right-clicks at (20, 30) — the upper half, plenty of right
// room in jsdom's default viewport.
openMenu(harness.container)
expect(document.body.getAttribute(ATTR)).toBe('down')
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
.find(item => item.getAttribute('aria-haspopup') === 'menu')
act(() => { parent!.click() })
const zedRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
.find(item => item.textContent?.trim() === 'Zed')
act(() => { zedRow!.click() })
expect(document.querySelector('[role="menuitem"]')).toBeNull()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
it('adds "left" when the cursor sits within the right-hand submenu room', async () => {
harness = await mountTree()
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: window.innerWidth - 10, clientY: 30 })
act(() => { fileRow(harness.container).dispatchEvent(nearRight) })
expect(document.body.getAttribute(ATTR)).toBe('down left')
})
it('clears the attribute on unmount with the menu still open', async () => {
harness = await mountTree()
openMenu(harness.container)
expect(document.body.getAttribute(ATTR)).toBe('down')
harness.unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})
+110
View File
@@ -0,0 +1,110 @@
/**
* The context-menu submenu flip geometry: the pure token computation behind
* the body attribute layout.css consumes (see src/client/menu-flip.ts for
* the contract) plus the attribute lifecycle of the hook itself.
*/
// @vitest-environment jsdom
import { createElement, useState } from 'react'
import { readFileSync } from 'node:fs'
import { act } from 'react-dom/test-utils'
import { describe, expect, it } from 'vitest'
import { renderRoot, setupReactAct } from './test-utils.ts'
import { SUBMENU_FLIP_ATTR, submenuFlipTokens, useSubmenuFlip } from '../src/client/menu-flip.ts'
setupReactAct()
describe('layout.css contract', () => {
it('consumes the exact attribute name menu-flip.ts publishes', () => {
// The two halves of the flip mechanism can drift silently: renaming the
// attribute here would leave layout.css matching nothing (submenus revert
// to overflowing) with every test still green. Tie them together. Specs
// run from the repo root (chunk-artifact.spec.ts reads lib/ the same way).
const css = readFileSync('src/client/layout.css', 'utf8')
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="down"] div[role="menu"] div[role="menu"]`)
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]`)
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]::before`)
})
})
describe('submenuFlipTokens', () => {
// Default geometry (1024x768): the panel is docked right, so the
// interesting split is the right-hand 400px strip and the vertical half.
it('flips down for a cursor in the upper half, keeps primitive defaults below', () => {
expect(submenuFlipTokens(500, 20, 1024, 768)).toBe('down')
expect(submenuFlipTokens(500, 700, 1024, 768)).toBe('')
})
it('flips left when the right-hand room cannot fit card + submenu', () => {
// 700 + 400 > 1024 → left; the cursor is low, so no down token.
expect(submenuFlipTokens(700, 700, 1024, 768)).toBe('left')
// Both tokens compose for a top-right opening.
expect(submenuFlipTokens(700, 20, 1024, 768)).toBe('down left')
})
it('uses the exact boundary inclusively (midpoint and width-minus-room still flip)', () => {
expect(submenuFlipTokens(500, 384, 1024, 768)).toBe('')
expect(submenuFlipTokens(500, 383, 1024, 768)).toBe('down')
expect(submenuFlipTokens(624, 700, 1024, 768)).toBe('')
expect(submenuFlipTokens(625, 700, 1024, 768)).toBe('left')
})
it('flips left throughout a narrow panel viewport (PANEL_MIN 280)', () => {
// A 600px window: any cursor past x=200 flips; the left strip cannot
// fit a right-growing submenu beside the 218px card either way.
expect(submenuFlipTokens(300, 100, 600, 800)).toBe('down left')
expect(submenuFlipTokens(500, 700, 600, 800)).toBe('left')
})
})
describe('useSubmenuFlip', () => {
const ATTR = 'data-dsh-sidebar-submenu'
/** Mount a probe whose menu state the spec drives via the returned setter. */
function mountFlipProbe(initial: { x: number; y: number } | null): { setMenu: (next: { x: number; y: number } | null) => void, unmount: () => void } {
let applyMenu: (next: { x: number; y: number } | null) => void = () => {}
function Probe(): React.ReactElement {
const [menu, setMenu] = useState<{ x: number; y: number } | null>(initial)
applyMenu = setMenu
useSubmenuFlip(menu)
return <div />
}
const rendered = renderRoot(createElement(Probe))
return {
setMenu: (next) => { act(() => { applyMenu(next) }) },
unmount: rendered.unmount,
}
}
it('publishes tokens while the menu is open and clears on close', () => {
const probe = mountFlipProbe({ x: 700, y: 20 })
try {
expect(document.body.getAttribute(ATTR)).toBe('down left')
probe.setMenu(null)
expect(document.body.hasAttribute(ATTR)).toBe(false)
} finally {
probe.unmount()
}
})
it('tracks the position across consecutive openings', () => {
const probe = mountFlipProbe({ x: 100, y: 20 })
try {
expect(document.body.getAttribute(ATTR)).toBe('down')
// Still open, now lower-left: the attribute stays (menu lifetime) but
// carries no token, so the primitive's own geometry applies.
probe.setMenu({ x: 100, y: 700 })
expect(document.body.getAttribute(ATTR)).toBe('')
probe.setMenu({ x: 700, y: 700 })
expect(document.body.getAttribute(ATTR)).toBe('left')
} finally {
probe.unmount()
}
})
it('clears the attribute when the owner unmounts with the menu still open', () => {
const probe = mountFlipProbe({ x: 100, y: 20 })
expect(document.body.getAttribute(ATTR)).toBe('down')
probe.unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})
+58
View File
@@ -309,3 +309,61 @@ describe('TabBar pin submenu (v0.17.0)', () => {
}
})
})
describe('TabBar context menu flip geometry (submenu clamping)', () => {
const ATTR = 'data-dsh-sidebar-submenu'
/** jsdom's default viewport (what the hook reads via window.innerWidth/Height). */
const VW = window.innerWidth
const VH = window.innerHeight
it('publishes "down" while the menu is open and clears it on selection', () => {
stubZh()
const { tabEls, onClose, unmount } = mountBar(fourTabs())
try {
// rightClick() dispatches at clientY 40 — the top strip, upper half.
act(() => { rightClick(tabEls[1]!) })
expect(document.body.getAttribute(ATTR)).toBe('down')
act(() => { menuItems()[1]!.click() })
expect(onClose).toHaveBeenCalledWith('t2')
expect(document.body.hasAttribute(ATTR)).toBe(false)
} finally {
unmount()
}
})
it('flips left when the cursor sits within the right-hand submenu room', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
const nearRight = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: VW - 10, clientY: 40 })
act(() => { tabEls[1]!.dispatchEvent(nearRight) })
expect(document.body.getAttribute(ATTR)).toBe('down left')
} finally {
unmount()
}
})
it('keeps the primitive defaults (empty tokens) for a lower-half opening', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
const lowLeft = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: VH - 40 })
act(() => { tabEls[1]!.dispatchEvent(lowLeft) })
// Attribute present but empty: layout.css matches no token, so the
// host primitive's own up/right geometry applies.
expect(document.body.getAttribute(ATTR)).toBe('')
} finally {
unmount()
}
})
it('clears the attribute on unmount with the menu still open', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
act(() => { rightClick(tabEls[1]!) })
expect(document.body.getAttribute(ATTR)).toBe('down')
unmount()
expect(document.body.hasAttribute(ATTR)).toBe(false)
})
})