Files
deepseek-harness/packages/client/ui-primitives/src/Menu.module.css
T
imccyu 21586fe28c feat(client): compose session row actions from slot lists
A Session row's "..." menu and its hover buttons become two list slots the
WorkspaceBrowser entry declares, `sidebar.workspaces.session.menu.item` and
`sidebar.workspaces.session.row.action`. The shipped actions register from
apply the way a client plugin does: pin (menu 100, button 200), rename (200),
fork (300), archive (menu 400, button 100). An entry receives only the row
identity, reads the pin and archive sets through Set hooks its own face
injects, decides its own visibility, renders `MenuItemButton` or one icon
button, and dismisses the menu through the slot-level `useMenuOpenState`
hook bound from the row's render occurrence.

Each action's behavior lives in its injected callbacks. `UiWorkspace` gains
`pinSession` and `unpinSession`; the pin write into the saved orders happens
there, through the viewing-store instance the apply creates and hands the
browser's registration as its handle (the ui-layout pattern), reading the
memberships current at completion. The rename dialog and the row-action
notice are `shell.overlay` entries fed by apply-closure observables through
the faces' `hooks`; the archived notice switches the archived filter
directly. WorkspaceBrowser threads no action callbacks, renders no toast,
and hosts no rename dialog; its title double-click raises the same rename
request.

In ui-primitives, `MenuItemButton` is the plain `role="menuitem"` row for
menus whose rows are components: it shares a data row's markup and styling
and needs no shared state, because `Menu` decides the keyboard walk from the
DOM and now runs the post-selection focus return once on the list's click
bubble for every row kind. `Menu` accepts such rows as `children` beside
`items`, which becomes optional together with `onSelect`; `separatorBefore`
lets a row start a group with the same hairline a separator entry draws. The
context-based `MenuAction` goes away with its extension group.
2026-09-20 12:20:24 +08:00

281 lines
6.4 KiB
CSS

.root {
position: relative;
display: inline-flex;
}
/* Compact translucent dropdown card shared by feature menus. */
.list,
.submenu {
/* min-widths below are the design's outer card widths — include the pad. */
box-sizing: border-box;
padding: 3px;
display: flex;
flex-direction: column;
gap: 0;
border: 0;
border-radius: 16px;
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
/* Elevated surface: the scrollbar thumb takes the l2 elevation tokens. The
declaration sits on the card rather than on `.scrollable .viewport`
because the elevation is a property of this surface, and the custom
properties inherit down to whichever descendant actually scrolls (see
ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Primary cards grow with their content between compact and readable bounds. */
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 100;
min-width: 144px;
max-width: 360px;
}
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
* anchor rect (side/align resolved in JS, the in-place offset rules above
* don't apply). Portaled lists must layer above modal overlays (z 1000) —
* an anchor inside a dialog still expects its menu on top. */
.portal {
position: fixed;
top: auto;
left: auto;
z-index: 1100;
}
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
.sideTop {
top: auto;
bottom: calc(100% + 4px);
}
.alignEnd {
left: auto;
right: 0;
}
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
* (the portal MARGIN in Menu.tsx; the top side widens to the frame's published
* top clearance, the macOS window strip) and taller content scrolls inside
* .viewport, so a pinned .footer stays visible. Menus with submenu rows skip
* this class — the overflow clip would crop the side card, so they rely on
* staying short. */
.scrollable {
max-height: calc(100vh - 12px - max(12px, var(--dsh-frame-top-clearance, 12px)));
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
}
.scrollable .viewport {
overflow-y: auto;
}
/* Pinned rows below the scroll region; l2 hairline (l1 is near-invisible on
* the menu surface) mirrors the .separator spacing. */
.footer {
flex: none;
display: flex;
flex-direction: column;
margin-top: 3px;
padding-top: 3px;
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
.itemWrap {
position: relative;
}
/* Menu cell: compact text, icon, and selection spacing. */
.item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-height: 34px;
padding: 6px 8px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
text-align: left;
}
.item:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Arrow navigation moves real focus, so the row the keyboard is on carries the
same fill the pointer gets: the fill is the row's focus indication, and the
browser's default ring would double it. */
.item:focus-visible:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
outline: none;
}
.denseList .item {
min-height: 30px;
padding-block: 4px;
}
.denseList .label {
padding-block: 3px;
}
.list.compactList,
.submenu.compactList {
min-width: 156px;
padding: 2px;
border-radius: 7px;
}
.compactList .item {
min-height: 24px;
gap: 5px;
padding: 2px 6px;
border-radius: 5px;
font-size: 11px;
line-height: 17px;
}
.compactList .itemIcon {
width: 12px;
height: 12px;
}
.compactList .separator {
margin: 2px;
}
.compactList .label {
padding: 3px 6px;
font-size: 10px;
line-height: 15px;
}
.item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.itemIcon {
display: inline-flex;
flex: none;
width: 14px;
height: 14px;
align-items: center;
justify-content: center;
color: var(--dsw-alias-label-tertiary);
}
.itemIcon svg,
.check {
width: 14px;
height: 14px;
}
.compactList .itemIcon svg,
.compactList .check {
width: 12px;
height: 12px;
}
.itemLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.check {
flex: none;
color: var(--dsw-alias-label-primary);
}
/* Selected cell keeps the plain fill (marker is the trailing check); the
* class remains as a hook for owner-side emphasis. */
.selected {
background: transparent;
}
/* Fill-mode selection: the row holds the hover fill instead of a check. */
.selectedFill {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Destructive row: error text/icon, danger hover fill. */
.danger {
color: var(--dsw-alias-state-error-primary);
}
.danger .itemIcon {
color: var(--dsw-alias-state-error-primary);
}
.danger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
.danger:focus-visible:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
outline: none;
}
/* Heading row: non-interactive small grey text, padding aligned with items. */
.label {
padding: 6px 8px;
font-size: 11px;
line-height: 15px;
color: var(--dsw-alias-label-tertiary);
}
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. l2, not
* l1, for the same reason as .footer: l1 is near-invisible on this surface. */
.separator {
height: 0.5px;
margin: 3px 2px;
background: var(--dsw-alias-border-l2);
}
/* Two group starts in a row (a data separator followed by a row that also
* starts a group, or two such rows) draw one line, not two. */
.separator + .separator,
.separator + .itemWrap > .separator:first-child {
display: none;
}
/* Nested card to the right of the parent row.
* Bottom-aligned with the parent menu card (grows upward): itemWrap sits in
* .list's 3px pad, so bottom: -3px matches the list's outer bottom edge.
* Horizontal: list pad (3px) + 6px card gap = 9px past itemWrap.
* ::before bridges the full gap so the pointer can cross without mouseLeave. */
.submenu {
position: absolute;
top: auto;
bottom: -3px;
left: calc(100% + 9px);
z-index: 101;
min-width: 163px;
}
.submenu::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -9px;
width: 9px;
}