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:
@@ -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
@@ -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 />}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user