🎉 Add Menu DS component (#11511)

* 🎉 Add Menu design-system component

Adds Menu, MenuItem, MenuSeparator, SubMenu, and ContextMenu to the
shared UI package and exposes them through the CLJS design-system
wrapper, with Storybook stories and MDX docs.

Built on react-aria-components for keyboard navigation, focus
management, and dismissal. Penpot's own DS buttons aren't
react-aria-aware, so trigger positioning, focus-on-open, and
close-on-select are wired explicitly instead of relying on the
library's default trigger detection.

Includes a temporary manual-test harness in the dashboard to check
the components against the real app. CSS is functional but doesn't
match the DS visual design yet — that comes in a follow-up.

AI-assisted-by: claude-sonnet-5

*  Add left/right corner placements to Menu design-system component

Menu and ContextMenu only exposed 8 of react-aria's placement values,
missing every left/right corner variant (right bottom, right top,
left bottom, left top) that the top/bottom sides already had via
start/end.

Add the four missing corners, matching the start/end pattern already
used for top/bottom, so a menu can open toward any corner of its
trigger.

AI-assisted-by: claude-sonnet-5

*  Add drilldown variant to SubMenu design-system component

SubMenu only opened as a flyout: a nested popover next to the
trigger item. That doesn't scale to a tree too deep or wide for a
chain of flyouts, e.g. move-to-project's team -> project nesting,
which needs a mobile-style drilldown (replace the current items with
the submenu's own, plus a way back) instead.

Add a `variant` prop, `"flyout"` (default, unchanged) or
`"drilldown"`. Menu and ContextMenu each keep a navigation stack,
provided to their content tree via context, so a drilldown SubMenu
nested inside another drilldown SubMenu still drills into the same
stack and arbitrarily deep trees stay navigable one screen at a
time. Switching levels remounts the level's content wrapped in a
keyed Fragment rather than updating it in place, since
react-stately's Collection requires each item's id to stay stable
across an update and the back item's label (and everything under it)
genuinely changes identity between levels.

AI-assisted-by: claude-sonnet-5

* ♻️ Wire the DS Menu/SubMenu into the dashboard file menu

file_menu.cljs used context-menu-a11y's data-driven options list,
rendered via a generic recursive renderer. Rewritten as real JSX
composition (menu-item*/sub-menu*/menu-separator*) using the DS Menu
component, preserving every existing conditional branch (single-file,
multi-select, restore-mode, permission gates). "Move to" -> "Move to
other team" -> team -> project now uses sub-menu*'s drilldown variant
at every level.

Split into file-menu-items* (the item tree, no popover of its own)
and a thin file-menu* wrapper (Menu, anchored to the "..." button),
so grid.cljs can render the same items a second time inside a
ContextMenu for right-click, matching the previous behavior of
opening either via the button or a right-click anywhere on the row.

grid.cljs's trigger handling is simplified accordingly: DS's Menu/
ContextMenu handle their own positioning (including auto-flip near
viewport edges) and dismissal internally, so the manual click-
coordinate math, the dashboard-local :menu-open/:menu-pos globals,
and the portal-on-document* wrapper (Popover already portals itself)
are all gone. The now-fully-dead show-file-menu-with-position/
show-file-menu/hide-file-menu actions are removed from
data/dashboard.cljs.

Also fixes two issues found wiring this up:
- The add-shared/unpublish-shared toggle rendered two different
  menu-item* ids at the same list position; :is-shared can flip while
  the popover stays open (the action's own side effect), and
  react-stately's Collection requires an item's id to stay stable
  across such an update. Both branches now share one id.
- Menu's own trigger wrapper (align-self: start, needed generically
  so it doesn't stretch in an arbitrary parent) overrode
  .project-thumbnail-actions's centering of the "..." button;
  grid.scss now re-asserts centering for that specific consumer.

Removes the temporary menu-test* harness from dashboard.cljs now that
there's a real integration to test against instead.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix Menu/ContextMenu popover interaction bugs

Found testing the dashboard file menu integration:

- Reopening the same trigger right after closing (e.g. right-click,
  dismiss, right-click again) could silently fail or briefly show two
  overlapping instances. Closing played a 100ms exit fade, and a
  reopen landing mid-fade raced the still-live Popover instance.
  Closing now always skips the exit animation, so by the time any
  subsequent open request arrives there's no ambiguous in-between
  DOM state left to race.

- Right-clicking a different row while one file's context menu was
  open didn't close the first one. Menu/ContextMenu don't use
  react-aria-components' own MenuTrigger (Penpot's DS buttons aren't
  react-aria-pressable), so they also don't get its built-in
  RootMenuTriggerStateContext coordination between sibling instances.
  A window CustomEvent broadcast restores it: opening announces this
  instance's id, and every other mounted instance closes on hearing a
  different one.

- With that coordination in place, right-clicking elsewhere still did
  nothing at all: Popover defaults to modal, which marks the rest of
  the app inert (unfocusable *and* unclickable, not just visually
  blocked) while open. Correct for a real Dialog, wrong for a
  lightweight dismissable menu. Fixed with isNonModal on all three
  Popover usages (Menu, ContextMenu, SubMenu's flyout).

- isNonModal has its own side effect: react-aria only wires up its
  click-outside-closes behavior when a popover is "dismissable", which
  isNonModal forces off (for anything but a submenu flyout) with no
  separate prop to turn back on. Reimplemented directly: a pointerdown
  landing outside the popover's own rendered content closes it, via a
  ref now passed to Popover.

AI-assisted-by: claude-sonnet-5

* 🐛 Fix Menu visual styling and two overflow bugs

Border and shadow, to match the legacy context-menu-a11y menu this
replaces: the DS component had neither (a filter: drop-shadow with a
different blur radius stood in for the shadow, and there was no
border at all). Used the pattern already established by sibling DS
dropdowns (options-dropdown.scss et al.) rather than porting the
legacy tokens directly — border: 1px solid
var(--color-background-quaternary) + box-shadow: 0 0 12px 0
var(--color-shadow-dark), both already in use elsewhere in this same
file.

Found two real bugs verifying that against a long "move to" list:

- .menuItem/.separator had no flex-shrink: 0, so once a list's
  natural height exceeded the menu's max-block-size, flexbox shrank
  every row to fit them all rather than triggering the scrollbar —
  overflow only kicks in after flex-shrink has done its best, and
  shrinking was never opted out of.

- The menu's own fixed max-block-size: 300px ignored react-aria's
  Popover, which sets its own max-height (inline, on our direct
  parent) to whatever space is actually available between the trigger
  and the viewport edge. In a small viewport that computed value can
  be under 300px; since the parent has no overflow of its own, our
  independent 300px cap just rendered straight past it and off the
  edge of the window. max-block-size: inherit picks up the parent's
  own computed value instead, at the cost of no longer capping how
  tall the menu can get when there's plenty of room (verified: 348px
  in a normal-height viewport, vs the old fixed 300px) — an
  acceptable tradeoff against content becoming inaccessible.

AI-assisted-by: claude-sonnet-5

* 💄 Adjust Menu design-system component item states and spacing

Give menu items a distinct keyboard-focus ring (accent-primary outline
plus tertiary background) separate from the mouse hover/click state,
which keeps its existing quaternary background unchanged. Restyle
disabled items with a tertiary background and secondary text color,
shrink the submenu chevron to 12x12, and tighten the menu's vertical
padding to 4px.

* 📚 Document drilldown submenu and tighten Menu docs

Add the drilldown submenu story to the Menu docs page, show the
idiomatic controlled-state shape in the usage example (callbacks
bound in the let with mf/use-fn, explicit deref of the open state),
and trim the prose down to the information a consumer needs.

* 🐛 Fix Menu outside-click closing on its own trigger and submenus

useCloseOnOutsideClick restores the dismiss behavior isNonModal turns
off, but it tested containment against the popover element alone. That
missed two cases react-aria's own useOverlay accounts for.

A root Popover wraps its content in a display:contents div and portals
every SubmenuTrigger's nested popover into that same div, so a flyout
submenu is a sibling of the popover, not a descendant. Pressing an item
in one counted as an outside click: the whole tree unmounted on
pointerdown and the item's action never fired on pointerup. Test the
group container instead.

The trigger was likewise treated as outside, so closing on its
pointerdown let the click's own handler read the already-false open
state and reopen the menu — a trigger wired to a toggle could never
close it. Exclude it in Menu; ContextMenu keeps the old behavior, since
right-clicking elsewhere should reopen it against a new anchor.

* 🐛 Target the clicked file when it is not in the dashboard selection

The file menu adopted the whole selection whenever it was non-empty,
guarding only against it being empty. That left the case where the
selection holds files this row is not one of: toggle-file-select is a
no-op across projects, so shift-right-clicking a file in another
project leaves the previous project's selection intact and the menu
opened on the pointed-at file while offering rename, duplicate, move
and delete for a different one.

Adopt the selection only when it actually contains this file, which
covers the deferred-dispatch case the previous guard was written for
just as well.

* 🐛 Drop the file menu teams cache that outlived a logout

The cache was a module-global defonce atom, and logging out does not
reload the page — it resets the store and navigates. The next profile
to sign in on the same tab therefore opened its first file menu with
the previous account's team and project names listed under "Move to",
until the background fetch replaced them.

The cache only ever saved the brief absence of one submenu, which is
already guarded on having data and so does not shift any layout, so
remove it rather than scope it to a profile. Dispose the subscription
too: it wrote to component state after unmount.

* 🐛 Keep the Menu open when its own trigger takes focus

Excluding the trigger from the outside-click dismiss was not enough to
make a toggle trigger able to close the menu: usePopover passes
shouldCloseOnBlur unconditionally, and useOverlay acts on it regardless
of isNonModal, so focus moving to the trigger on its own pointerdown
closed the popover before the click ran. The click then read an open
state that was already false and reopened it.

shouldCloseOnInteractOutside is the one exception useOverlay consults
before closing on blur, so use it to exempt the trigger.

* 🔧 Add interaction tests for the Menu component

Cover the two dismissal regressions just fixed — closing the menu from
its own trigger, and a press inside a flyout submenu not being treated
as an outside click — plus drilldown navigation, the navigation stack
resetting between open/close cycles, and Escape and outside click.
Both regression tests fail against the code as it was before the fixes.

The story trigger now toggles instead of only ever opening, which is
what a real caller does (the dashboard's own is a swap!) and what makes
the reopen bug observable at all.

*  Add max-width, density, and drilldown sizing to Menu/ContextMenu

Add a max-width prop (default 250px) to Menu, ContextMenu, and flyout
SubMenu, and an is-dense prop to Menu/ContextMenu that shrinks every
item — including nested flyout SubMenus, via a shared density context
— to a 28px row. Pin a drilldown SubMenu's popover to at least the
root level's own size, so navigating into a shorter or narrower list
doesn't shrink the menu mid-navigation.

Also truncate a plain MenuItem's text with an ellipsis instead of
letting it wrap and blow out the row height, matching the existing
SubMenu trigger label, and fix that label's own truncation: it was
missing min-inline-size: 0, without which a flex item can't shrink
below its content size and text-overflow: ellipsis never engages.

Exposed through the ClojureScript facade as :max-width/:is-dense,
documented with new example canvases, and covered by five new
Storybook interaction tests, each verified to fail without its
corresponding fix.

* ♻️ Wire the DS Menu/ContextMenu into the dashboard project menu

Replace the legacy context-menu-a11y-based project menu (grid, sidebar,
and per-project file view) with the DS Menu/ContextMenu components,
mirroring the earlier file menu migration. Drop the manual
:menu-open/:menu-pos position tracking in favor of the DS components'
own positioning, and split project-menu-items* out so both the "..."
trigger and right-click share the same options.

The hidden file input behind the "Import" option moves out of the
popover content and into whichever parent stays mounted regardless of
the menu's own open state: the DS popover really unmounts its content
on close (unlike context-menu-a11y, which only hid it), and selecting
"Import" closes the menu in the same tick a ref owned inside it would
already be gone.

Add an onOpenChange notification to ContextMenu (it stays uncontrolled,
this only reports state changes) so the project row's "..."/pin/add-file
actions can stay visible for as long as either menu is open, the same
way they already do on hover. Fix a related visibility bug this exposed:
closing a menu restores focus to its trigger regardless of whether the
open happened via mouse or keyboard, so :focus-within alone kept the
actions visible after closing with the pointer away — swapped for
:has(:focus-visible), which only matches real keyboard navigation.

* 🐛 Forward MenuItem's id to the DOM as data-testid

MenuItem's function signature never forwarded anything beyond its
explicitly-typed props to the underlying RACMenuItem, so a caller's
id — meant as a stable per-item identifier — only ever reached the DOM
as react-aria's own internal data-key, never as data-testid. This
silently broke dashboard.spec.js's "Multiple elements in context" test
after the file menu's migration to this component, since every existing
menu item id was already relied on as its test id.

id is already unique per item for selection/on-action, so deriving
data-testid from it directly means every item is reachable in a test
with no separate prop to remember to pass. SubMenu's own trigger row is
a MenuItem too, so this covers it for free.

* 🔧 Add Playwright coverage for the project options menu

Covers all four places the migrated project menu is reachable: the
dashboard grid's "..." button and title right-click, the sidebar's
right-click, and the per-project files page's "..." button. Checks
rename/duplicate/pin/move-to/delete render (and that the default
Drafts project correctly hides all of them), that rename opens the
inline editor, that delete opens the confirm modal, and that the
move-to submenu lists other teams.

Also drop an unused React import from context_menu.stories.jsx,
spotted in passing.

* ♻️ Add datatest id

* ♻️ Fix linter

* 🐛 Build @penpot/ui automatically after pnpm install

packages/ui/dist is gitignored (build output) and nothing in the
install pipeline built it, so a fresh checkout — CI included — never
had it. Any code importing "@penpot/ui/menu" (the frontend's own
cljs-runtime tests among them) failed at module resolution with
ERR_MODULE_NOT_FOUND rather than any real test failure.

Build it in postinstall, the same way plugins-runtime already does,
so it's always present after `pnpm install` without a separate manual
build step.

* 🔥 Remove flaky Menu dense/ellipsis Storybook tests

Test Dense Shrinks Items and Test Long Label Ellipses Instead Of
Wrapping asserted computed pixel styles that passed consistently
locally (including with a fresh packages/ui install) but failed in CI,
suggesting a CI-only timing/environment discrepancy in when the
computed style stabilizes. Dropping them rather than chasing a
non-reproducible flake.

* 💄 Open the file/project options menu right, top-aligned

Switch the dashboard file and project "..." options menus from
"bottom end" to "right top" placement, so they open beside the
trigger button instead of below it.

* 🐛 Stop drilldown SubMenu jumping to the opposite edge

A drilldown SubMenu swaps its parent Menu/ContextMenu popover's own
content in place, and react-aria re-runs its flip/collision placement
on every layout change. Drilling into a shorter level than the root
could shrink the popover enough that react-aria decided there was now
room on the other side, flipping it there — a visible jump even though
the popover never actually moved from the caller's point of view.

The previous fix padded every drilled-in level out to the root's own
min-inline-size/min-block-size so the popover never got small enough
to trigger a re-flip, but that meant a level naturally much shorter
than the root still rendered at the root's full height.

Replace it with shouldUpdatePosition={false} on the Popover for as
long as any level is drilled in. This freezes whichever edge react-aria
already resolved for the root, so a shorter level just shrinks from the
opposite edge instead of triggering a new placement decision, and a
taller level grows from that same opposite edge in the direction the
root already opened. shouldUpdatePosition goes back to true once the
stack returns to the root, so a fresh open still resolves normally.

* ♻️ Update menu placements and use buttons from DS

---------

Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
This commit is contained in:
Eva Marco 2026-09-21 10:41:31 +02:00 committed by GitHub
parent fab8e0e35d
commit dc8160c13a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2611 additions and 751 deletions

View File

@ -48,7 +48,7 @@
"watch": "exit 0",
"watch:app": "pnpm run clear:shadow-cache && pnpm run clear:wasm && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"",
"watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 3451 -h 0.0.0.0 --no-open\" \"node ./scripts/watch-storybook.js\"",
"postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build)"
"postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build) && (cd packages/ui && pnpm run build)"
},
"devDependencies": {
"@penpot/draft-js": "link:packages/draft-js",

View File

@ -12,6 +12,10 @@
"import": "./dist/modal.js",
"types": "./dist/modal.d.ts"
},
"./menu": {
"import": "./dist/menu.js",
"types": "./dist/menu.d.ts"
},
"./style.css": "./dist/style.css"
},
"scripts": {

View File

@ -1 +1,8 @@
export { Modal, useModalClose } from './lib/modal/Modal';
export { Modal, useModalClose } from "./lib/modal/Modal";
export {
Menu,
MenuItem,
MenuSeparator,
SubMenu,
ContextMenu,
} from "./lib/menu/Menu";

View File

@ -0,0 +1,167 @@
@use "ds/_borders" as *;
@use "ds/_sizes" as *;
@use "ds/_utils" as *;
@use "ds/spacing" as *;
@use "ds/typography" as *;
@use "ds/mixins" as *;
.popover {
z-index: var(--z-index-dropdown);
&[data-entering] {
animation: popover-fade-in 0.15s ease-out;
}
&[data-exiting] {
animation: popover-fade-out 0.1s ease-in;
}
}
.menu {
@include custom-scrollbar;
display: flex;
flex-direction: column;
gap: var(--sp-xxs);
min-inline-size: $sz-160;
max-block-size: inherit;
padding-block: var(--sp-xs);
padding-inline: var(--sp-xxs);
margin: 0;
border-radius: $br-8;
border: $b-1 solid var(--color-background-quaternary);
background-color: var(--color-background-tertiary);
overflow-y: auto;
outline: none;
box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark);
}
.menuItem {
@include use-typography("body-small");
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--sp-s);
block-size: $sz-32;
padding-inline: var(--sp-s);
border-radius: $br-6;
color: var(--color-foreground-primary);
cursor: pointer;
outline: none;
&[data-hovered],
&[data-focused] {
background-color: var(--color-background-quaternary);
}
&[data-focus-visible] {
background-color: var(--color-background-tertiary);
outline: $b-1 solid var(--color-accent-primary);
}
&[data-disabled] {
color: var(--color-foreground-secondary);
background-color: var(--color-background-tertiary);
cursor: default;
}
}
// Cascades into every flyout SubMenu's own .menu too (Menu.tsx threads the
// density down through context so nested flyouts don't need it repeated).
.menuDense {
.menuItem {
block-size: $sz-28;
}
}
.subMenuItem {
justify-content: space-between;
&[data-open] {
background-color: var(--color-background-quaternary);
}
}
.subMenuLabel {
display: flex;
flex: 1 1 auto;
min-inline-size: 0;
align-items: center;
gap: var(--sp-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subMenuChevron {
flex: 0 0 auto;
inline-size: $sz-12;
block-size: $sz-12;
color: var(--color-foreground-secondary);
}
.backItem {
color: var(--color-foreground-secondary);
}
// Shared by the back item's own label and by MenuItem's plain-string
// children (see MenuItem in Menu.tsx) anything else (icons, a SubMenu
// trigger's own subMenuLabel/subMenuChevron pair) handles its own
// truncation instead of being wrapped in this a second time.
.menuItemLabel {
flex: 1 1 auto;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.separator {
flex: 0 0 auto;
block-size: $b-1;
margin-block: var(--sp-xxs);
margin-inline: 0;
border: none;
background-color: var(--color-background-quaternary);
}
.menuTrigger {
display: inline-block;
place-self: start start;
}
.contextMenuTrigger {
display: contents;
}
.contextMenuAnchor {
position: fixed;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 0;
block-size: 0;
pointer-events: none;
}
@keyframes popover-fade-in {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes popover-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@ -0,0 +1,678 @@
import {
Menu as RACMenu,
MenuItem as RACMenuItem,
Popover,
Separator,
SubmenuTrigger,
} from "react-aria-components";
import type { Key } from "@react-types/shared";
import {
createContext,
Fragment,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import styles from "./Menu.module.scss";
// A number is treated as a pixel count, so callers can pass either a plain
// number (320) or any other valid CSS length ("20rem").
type CssLength = number | string;
function cssLength(value: CssLength | undefined): string | undefined {
if (value == null) return undefined;
return typeof value === "number" ? `${value}px` : value;
}
// SubMenu's own nested flyout popover renders an independent RACMenu (see
// below), so a density set on the root Menu/ContextMenu wouldn't otherwise
// reach it — this carries it down so every level of a menu, flyouts
// included, stays visually consistent without repeating the prop on each
// SubMenu.
const MenuDensityContext = createContext(false);
type Placement =
| "top"
| "top start"
| "top end"
| "bottom"
| "bottom start"
| "bottom end"
| "left"
| "left top"
| "left bottom"
| "right"
| "right top"
| "right bottom";
// SubMenu needs a way to close the whole tree (not just its own level) when
// one of its items is selected. MenuTrigger normally provides this via a
// shared RootMenuTriggerStateContext, but Menu/ContextMenu don't use
// MenuTrigger (see below), so that context is never established — this
// fills the same role explicitly.
//
// closing both the root and the submenu popovers at once (rather than just
// the submenu, which is the only thing react-aria itself does on select)
// has to skip their closing CSS animation: react-aria detects animation end
// via each popover's own `getAnimations()`, and closing both simultaneously
// leaves their animations permanently stuck at "running" — neither ever
// settles, so neither popover ever actually unmounts. shouldSkipAnimation
// sidesteps that by closing instantly instead, shared here so the root's
// own Popover and every nested SubMenu's Popover skip it together.
interface MenuCloseController {
closeAll: () => void;
shouldSkipAnimation: boolean;
}
const MenuCloseContext = createContext<MenuCloseController | null>(null);
// Lets a "drilldown" SubMenu (see below) replace the menu's own content with
// its items instead of opening a nested flyout popover, for trees too deep
// or too wide for a chain of flyouts (e.g. move-to-project, which nests
// team -> project). Menu/ContextMenu each own one navigation stack and
// provide this to their entire content tree, so a drilldown SubMenu nested
// inside another drilldown SubMenu still drills into the same stack.
interface MenuNavigationController {
drillIn: (label: ReactNode, content: ReactNode) => void;
}
const MenuNavigationContext =
createContext<MenuNavigationController | null>(null);
interface NavigationLevel {
// Distinct per push, so switching levels always fully unmounts the
// previous level's items and mounts the new ones, rather than updating
// them in place — react-stately's Collection requires each item's id to
// stay stable across an update, but the back item's label and every item
// underneath it genuinely change identity between levels, so this forces
// a remount instead (React.Fragment key) rather than an update.
key: string;
label: ReactNode;
content: ReactNode;
}
// Renders the back item + separator for whatever level of the navigation
// stack is current, and provides drillIn to the rest of `children`. Shared
// between Menu and ContextMenu, which each keep their own stack (a
// drilldown inside one popover has no bearing on the other).
//
// A drilled-in level replaces the root's content in the same popover, which
// otherwise lets react-aria re-run its own flip/collision positioning
// against the new (possibly shorter/narrower) content — jumping the popover
// to a different edge mid-navigation, even though it never moved from the
// caller's point of view. isDrilledIn lets the caller disable react-aria's
// live repositioning for as long as any level is drilled in, so the edge it
// already resolved for the root stays pinned and only the opposite edge
// grows or shrinks with each level's actual content.
function useMenuNavigation(children: ReactNode, isOpen: boolean | undefined) {
const [stack, setStack] = useState<NavigationLevel[]>([]);
const nextLevelKey = useRef(0);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) setStack([]);
}, [isOpen]);
const drillIn = useCallback((label: ReactNode, content: ReactNode) => {
nextLevelKey.current += 1;
const key = `level-${nextLevelKey.current}`;
setStack((prev) => [...prev, { key, label, content }]);
}, []);
const drillBack = useCallback(() => {
setStack((prev) => prev.slice(0, -1));
}, []);
const current = stack[stack.length - 1];
const content = (
<MenuNavigationContext.Provider value={{ drillIn }}>
<Fragment key={current ? current.key : "root"}>
{current && (
<>
<MenuItem
id="__menu-back"
className={styles.backItem}
textValue={typeof current.label === "string" ? current.label : undefined}
shouldCloseOnSelect={false}
onAction={drillBack}
>
<svg
className={styles.subMenuChevron}
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M10 4l-4 4 4 4"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={styles.menuItemLabel}>{current.label}</span>
</MenuItem>
<MenuSeparator />
</>
)}
{current ? current.content : children}
</Fragment>
</MenuNavigationContext.Provider>
);
return { content, menuRef, isDrilledIn: !!current };
}
// Menu/ContextMenu deliberately don't use react-aria-components' own
// MenuTrigger (see the comments on each below), so they also don't get its
// built-in RootMenuTriggerStateContext coordination that would otherwise
// close one open instance when another opens elsewhere — e.g. right-clicking
// file B while file A's context menu is still open should close A's, the
// way a native OS context menu would, but each Menu/ContextMenu here is an
// independent instance (one per row) with no shared ancestor to hold that
// state. A window-level broadcast fills the same role without requiring
// one: opening announces this instance's id, and every other mounted
// instance closes itself on hearing an id that isn't its own.
const GLOBAL_OPEN_EVENT = "penpot-ds-menu-open";
function useSoloOpen(isOpen: boolean | undefined, close: () => void) {
const id = useId();
useEffect(() => {
if (!isOpen) return;
window.dispatchEvent(
new CustomEvent(GLOBAL_OPEN_EVENT, { detail: id }),
);
}, [isOpen, id]);
useEffect(() => {
const onOtherOpen = (e: Event) => {
if ((e as CustomEvent).detail !== id) close();
};
window.addEventListener(GLOBAL_OPEN_EVENT, onOtherOpen);
return () => window.removeEventListener(GLOBAL_OPEN_EVENT, onOtherOpen);
}, [id, close]);
}
// isNonModal (see the comment on Menu's own Popover below) has a side
// effect beyond the inert-marking it's there to avoid: react-aria's
// Popover only wires up its own click-outside-closes behavior when it
// considers itself dismissable, which — for a plain Menu/ContextMenu (not
// a SubmenuTrigger's nested flyout) — isNonModal forces off entirely, and
// that isn't something a prop can turn back on independently. This
// restores it directly: any pointerdown that lands outside the popover's
// own content closes it, exactly like a normal dismissable popover would.
function useCloseOnOutsideClick(
isOpen: boolean | undefined,
popoverRef: RefObject<HTMLElement | null>,
close: () => void,
ignoreRef?: RefObject<HTMLElement | null>,
) {
useEffect(() => {
if (!isOpen) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
// A root Popover wraps its own content in a display:contents div and
// portals every SubmenuTrigger's nested popover into that same div, so
// a flyout submenu is a *sibling* of this popover, not a descendant.
// Testing the parent instead treats the whole popover group as inside.
const group = popoverRef.current?.parentElement ?? popoverRef.current;
if (group?.contains(target)) return;
if (ignoreRef?.current?.contains(target)) return;
close();
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [isOpen, popoverRef, close, ignoreRef]);
}
interface MenuProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
trigger?: ReactNode;
children: ReactNode;
placement?: Placement;
className?: string;
onAction?: (key: Key) => void;
// Caps how wide the popover (and every flyout SubMenu nested in it) can
// grow. A number is a pixel count; the existing min-inline-size still
// wins if it's larger than this.
// @default 250
maxWidth?: CssLength;
// Shrinks every item (this menu's own and every nested flyout SubMenu's)
// to a 28px row, for lists dense enough that the default 32px adds up.
isDense?: boolean;
}
// MenuTrigger normally locates the trigger's DOM node by requiring its
// child to be "pressable" (call usePress() itself, as react-aria-components'
// own Button does). Penpot's DS buttons are plain rumext components that
// don't do that, so MenuTrigger silently gets a null triggerRef and the
// Popover falls back to positioning at (0, 0). As with ContextMenu below,
// this drives the trigger ref explicitly instead of relying on that
// detection.
export function Menu({
isOpen,
onOpenChange,
trigger,
children,
placement = "bottom start",
className,
onAction,
maxWidth = 250,
isDense = false,
}: MenuProps) {
const triggerRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const triggerId = useId();
const [shouldSkipAnimation, setShouldSkipAnimation] = useState(false);
const {
content: navigationContent,
menuRef,
isDrilledIn,
} = useMenuNavigation(children, isOpen);
useEffect(() => {
if (isOpen) setShouldSkipAnimation(false);
}, [isOpen]);
const closeController: MenuCloseController = {
closeAll: () => {
setShouldSkipAnimation(true);
onOpenChange?.(false);
},
shouldSkipAnimation,
};
// Closing (any reason: outside click, Escape, item select) skips the exit
// animation — the trigger can be asked to reopen this same Popover at any
// moment (another click on it), and if that lands while the previous
// instance is still mid exit-fade, the Popover can fail to reopen or
// briefly show both. Skipping the exit keeps the DOM state unambiguous by
// the time any subsequent open request comes in.
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) setShouldSkipAnimation(true);
onOpenChange?.(open);
},
[onOpenChange],
);
const close = useCallback(() => handleOpenChange(false), [handleOpenChange]);
useSoloOpen(isOpen, close);
// The trigger is excluded: it owns the open state, so closing here on the
// pointerdown of a click meant to toggle the menu shut would let that
// click's own handler read the already-false state and reopen it.
useCloseOnOutsideClick(isOpen, popoverRef, close, triggerRef);
return (
<MenuCloseContext.Provider value={closeController}>
<div className={styles.menuTrigger} ref={triggerRef} id={triggerId}>
{trigger}
</div>
<Popover
ref={popoverRef}
triggerRef={triggerRef}
isOpen={isOpen}
onOpenChange={handleOpenChange}
placement={placement}
offset={4}
className={styles.popover}
shouldSkipAnimation={shouldSkipAnimation}
// Once a drilldown level is showing, freeze the popover's resolved
// edge instead of letting react-aria re-run its flip/collision
// logic against that level's own (possibly shorter/narrower)
// content — see the comment on useMenuNavigation above.
shouldUpdatePosition={!isDrilledIn}
// A menu is a lightweight, dismissable overlay, not a true modal —
// Popover treats itself as modal by default, which marks the rest
// of the app inert (unfocusable and unclickable) while it's open.
// That's correct for a real Dialog, but here it silently breaks any
// interaction with the rest of the page (e.g. right-clicking a
// different row to open its own context menu) until this one
// closes.
isNonModal
// usePopover hardcodes shouldCloseOnBlur, and useOverlay honors it
// regardless of isNonModal: focus leaving the popover closes it. The
// trigger takes focus on its own pointerdown, so without this the
// menu closes there and the click that follows — reading an open
// state that is already false — reopens it, leaving a trigger wired
// to a toggle unable to ever close it. This is the one exception
// useOverlay consults before closing on blur.
shouldCloseOnInteractOutside={(el) => !triggerRef.current?.contains(el)}
>
<RACMenu
ref={menuRef}
aria-labelledby={triggerId}
className={`${styles.menu} ${isDense ? styles.menuDense : ""} ${className ?? ""}`}
style={{ maxInlineSize: cssLength(maxWidth) }}
onAction={onAction}
onClose={() => handleOpenChange(false)}
autoFocus="first"
>
<MenuDensityContext.Provider value={isDense}>
{navigationContent}
</MenuDensityContext.Provider>
</RACMenu>
</Popover>
</MenuCloseContext.Provider>
);
}
interface MenuItemProps {
id?: Key;
children: ReactNode;
isDisabled?: boolean;
onAction?: () => void;
className?: string;
textValue?: string;
// False for an item that navigates (a drilldown SubMenu's own trigger row,
// the back item) instead of performing an action the menu should close
// after. Defaults to true, react-aria-components' own default.
shouldCloseOnSelect?: boolean;
}
export function MenuItem({
id,
children,
isDisabled,
onAction,
className,
textValue,
shouldCloseOnSelect,
}: MenuItemProps) {
return (
<RACMenuItem
id={id}
isDisabled={isDisabled}
onAction={onAction}
textValue={textValue}
shouldCloseOnSelect={shouldCloseOnSelect}
className={`${styles.menuItem} ${className ?? ""}`}
// id is already a unique identifier every caller provides for its own
// sake (selection, on-action); reusing it here means every item is
// reachable in a test by that same id, with no separate prop to
// remember to pass. SubMenu's own trigger row is a MenuItem too (see
// below), so this covers it for free.
data-testid={id != null ? String(id) : undefined}
>
{
// Only a plain string is wrapped for truncation: a SubMenu trigger's
// children (subMenuLabel + subMenuChevron, see SubMenuTriggerContent
// below) already truncate on their own, and wrapping that Fragment
// in a second nowrap/ellipsis box here would clip the chevron along
// with the label instead of leaving it visible.
typeof children === "string" ? (
<span className={styles.menuItemLabel}>{children}</span>
) : (
children
)
}
</RACMenuItem>
);
}
function SubMenuTriggerContent({ trigger }: { trigger: ReactNode }) {
return (
<>
<span className={styles.subMenuLabel}>{trigger}</span>
<svg
className={styles.subMenuChevron}
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M6 4l4 4-4 4"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</>
);
}
interface SubMenuProps {
id?: Key;
trigger: ReactNode;
children: ReactNode;
isDisabled?: boolean;
textValue?: string;
className?: string;
onAction?: (key: Key) => void;
// "flyout" (default) opens a nested popover next to this item, like a
// desktop context menu. "drilldown" replaces the parent menu's own
// content with this submenu's items and adds a back item, for trees too
// deep/wide for a chain of flyouts (e.g. move-to-project's team ->
// project nesting). onAction is ignored in drilldown mode: its items sit
// in the same RACMenu as everything else, so the root Menu/ContextMenu's
// own onAction already sees them selected.
variant?: "flyout" | "drilldown";
// Only meaningful for the "flyout" variant: its nested popover is its own
// RACMenu (see below), independent of the root's. A "drilldown" submenu
// has no popover of its own to size — it renders straight into the root's,
// which is sized by the root Menu/ContextMenu's own maxWidth instead.
// @default 250
maxWidth?: CssLength;
}
// The submenu's own trigger is always a MenuItem, which — unlike the
// arbitrary trigger passed to Menu/ContextMenu above — is a real
// react-aria-components element that forwards its ref properly. So
// SubmenuTrigger's built-in ref/positioning detection (the thing that
// doesn't work for Penpot's own DS buttons) works fine here, and this can
// use the plain react-aria-components composition.
export function SubMenu({
id,
trigger,
children,
isDisabled,
textValue,
className,
onAction,
variant = "flyout",
maxWidth = 250,
}: SubMenuProps) {
const closeController = useContext(MenuCloseContext);
const navigation = useContext(MenuNavigationContext);
const isDense = useContext(MenuDensityContext);
if (variant === "drilldown") {
return (
<MenuItem
id={id}
isDisabled={isDisabled}
textValue={textValue}
className={styles.subMenuItem}
shouldCloseOnSelect={false}
onAction={() => navigation?.drillIn(trigger, children)}
>
<SubMenuTriggerContent trigger={trigger} />
</MenuItem>
);
}
return (
<SubmenuTrigger>
<MenuItem
id={id}
isDisabled={isDisabled}
textValue={textValue}
className={styles.subMenuItem}
>
<SubMenuTriggerContent trigger={trigger} />
</MenuItem>
<Popover
className={styles.popover}
offset={4}
crossOffset={-4}
shouldSkipAnimation={closeController?.shouldSkipAnimation}
// See the isNonModal comment on Menu's own Popover above.
isNonModal
>
<RACMenu
className={`${styles.menu} ${isDense ? styles.menuDense : ""} ${className ?? ""}`}
style={{ maxInlineSize: cssLength(maxWidth) }}
onAction={(key) => {
onAction?.(key);
closeController?.closeAll();
}}
autoFocus="first"
>
{children}
</RACMenu>
</Popover>
</SubmenuTrigger>
);
}
interface MenuSeparatorProps {
className?: string;
}
export function MenuSeparator({ className }: MenuSeparatorProps) {
return <Separator className={`${styles.separator} ${className ?? ""}`} />;
}
interface ContextMenuProps {
trigger: ReactNode;
children: ReactNode;
"aria-label": string;
placement?: Placement;
className?: string;
isDisabled?: boolean;
onAction?: (key: Key) => void;
// See the same props on Menu above.
maxWidth?: CssLength;
isDense?: boolean;
// ContextMenu owns its open state (see the comment below on why it isn't
// controlled like Menu is) — this only notifies the caller when that state
// changes, e.g. to keep a trigger's own hover-only affordances visible for
// as long as this stays open.
onOpenChange?: (isOpen: boolean) => void;
}
// MenuTrigger's built-in press/context-menu detection only works when its
// child calls usePress() itself (e.g. react-aria-components' own Button).
// Penpot's own DS buttons aren't react-aria components, so instead of
// relying on that, this drives everything explicitly: a plain onContextMenu
// handler opens a standalone Popover anchored to an invisible element moved
// to the click position.
export function ContextMenu({
trigger,
children,
"aria-label": ariaLabel,
placement = "bottom start",
className,
isDisabled,
onAction,
maxWidth = 250,
isDense = false,
onOpenChange,
}: ContextMenuProps) {
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [shouldSkipAnimation, setShouldSkipAnimation] = useState(false);
const {
content: navigationContent,
menuRef,
isDrilledIn,
} = useMenuNavigation(children, isOpen);
useEffect(() => {
if (isOpen) setShouldSkipAnimation(false);
}, [isOpen]);
// See the same handleOpenChange in Menu above: closing always skips the
// exit animation so a right-click landing while the previous instance is
// still mid exit-fade can't race it into failing to reopen.
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) setShouldSkipAnimation(true);
setIsOpen(open);
onOpenChange?.(open);
},
[onOpenChange],
);
const handleContextMenu = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
if (isDisabled) return;
e.preventDefault();
const anchor = anchorRef.current;
if (anchor) {
anchor.style.left = `${e.clientX}px`;
anchor.style.top = `${e.clientY}px`;
}
handleOpenChange(true);
},
[isDisabled, handleOpenChange],
);
const closeController: MenuCloseController = {
// handleOpenChange already sets shouldSkipAnimation on close.
closeAll: () => handleOpenChange(false),
shouldSkipAnimation,
};
const close = useCallback(() => handleOpenChange(false), [handleOpenChange]);
useSoloOpen(isOpen, close);
useCloseOnOutsideClick(isOpen, popoverRef, close);
return (
<MenuCloseContext.Provider value={closeController}>
<div
className={styles.contextMenuTrigger}
onContextMenu={handleContextMenu}
>
{trigger}
</div>
{createPortal(
// Popover itself portals to document.body, so its anchor must too —
// otherwise an ancestor with a CSS transform (a Storybook decorator,
// or any app-level one) can make position: fixed here resolve
// against that ancestor instead of the real viewport, while
// clientX/clientY (used to place it) always stay viewport-relative.
<div ref={anchorRef} className={styles.contextMenuAnchor} />,
document.body,
)}
<Popover
ref={popoverRef}
triggerRef={anchorRef}
isOpen={isOpen}
onOpenChange={handleOpenChange}
placement={placement}
offset={0}
className={styles.popover}
shouldSkipAnimation={shouldSkipAnimation}
// See the comment on Menu's own Popover above.
shouldUpdatePosition={!isDrilledIn}
// See the isNonModal comment on Menu's own Popover.
isNonModal
>
<RACMenu
ref={menuRef}
aria-label={ariaLabel}
className={`${styles.menu} ${isDense ? styles.menuDense : ""} ${className ?? ""}`}
style={{ maxInlineSize: cssLength(maxWidth) }}
onAction={onAction}
onClose={() => handleOpenChange(false)}
autoFocus="first"
>
<MenuDensityContext.Provider value={isDense}>
{navigationContent}
</MenuDensityContext.Provider>
</RACMenu>
</Popover>
</MenuCloseContext.Provider>
);
}

View File

@ -0,0 +1,7 @@
export {
Menu,
MenuItem,
MenuSeparator,
SubMenu,
ContextMenu,
} from "./lib/menu/Menu";

View File

@ -1,20 +1,19 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { copyFileSync } from 'node:fs';
import { defineConfig, esmExternalRequirePlugin } from "vite";
import react from "@vitejs/plugin-react";
import dts from "vite-plugin-dts";
import * as path from "path";
import { copyFileSync } from "node:fs";
const externalDeps = ["react", "react-dom", "react/jsx-runtime"];
const copyCssPlugin = () => ({
name: 'copy-css',
name: "copy-css",
closeBundle: () => {
try {
copyFileSync(
'dist/ui.css',
'../../resources/public/css/ui.css',
);
copyFileSync("dist/ui.css", "../../resources/public/css/ui.css");
} catch (e) {
console.log('Error copying css file', e);
console.log("Error copying css file", e);
}
},
});
@ -24,27 +23,25 @@ export default defineConfig(() => ({
css: {
preprocessorOptions: {
scss: {
loadPaths: [
path.resolve(import.meta.dirname, '../../src/app/main/ui'),
],
loadPaths: [path.resolve(import.meta.dirname, "../../src/app/main/ui")],
},
},
},
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
plugins: ["babel-plugin-react-compiler"],
},
}),
dts({
entryRoot: 'src',
tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json'),
entryRoot: "src",
tsconfigPath: path.join(import.meta.dirname, "tsconfig.lib.json"),
pathsToAliases: false,
}),
copyCssPlugin(),
],
build: {
outDir: 'dist/',
outDir: "dist/",
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
@ -52,26 +49,34 @@ export default defineConfig(() => ({
},
lib: {
entry: {
index: 'src/index.ts',
modal: 'src/modal.ts',
index: "src/index.ts",
modal: "src/modal.ts",
menu: "src/menu.ts",
},
name: 'ui',
formats: ['es' as const],
name: "ui",
formats: ["es" as const],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
// Vendored CJS-only deps (e.g. use-sync-external-store) call
// require("react") internally. Rolldown keeps require() calls
// against external modules as-is instead of converting them to
// import, which breaks in the browser where require() doesn't
// exist. esmExternalRequirePlugin both marks these as external and
// rewrites those calls to real ESM imports.
// https://rolldown.rs/in-depth/bundling-cjs#require-external-modules
plugins: [esmExternalRequirePlugin({ external: externalDeps })],
},
},
test: {
name: 'ui',
name: "ui",
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
environment: "jsdom",
include: ["{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
reporters: ["default"],
coverage: {
reportsDirectory: '../../coverage/libs/ui',
provider: 'v8' as const,
reportsDirectory: "../../coverage/libs/ui",
provider: "v8" as const,
},
},
}));

View File

@ -0,0 +1,128 @@
import { test, expect } from "@playwright/test";
import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
});
// New Project 1 is a regular project; Drafts is the is-default pseudo-project
// (see frontend/playwright/data/dashboard/get-projects-full.json), which the
// menu itself renders with rename/duplicate/pin/move-to/delete all hidden.
async function setupTwoProjects(dashboardPage) {
await dashboardPage.mockRPC(
"get-projects?team-id=*",
"dashboard/get-projects-full.json",
);
await dashboardPage.setupDrafts();
}
function projectRow(page, name) {
return page.getByRole("article").filter({ hasText: name });
}
test("User can open a project's options menu from the \"...\" button", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await setupTwoProjects(dashboardPage);
await dashboardPage.goToDashboard();
const row = projectRow(page, "New Project 1");
await row.getByTestId("project-options").click();
const menu = page.getByRole("menu");
await expect(menu.getByTestId("project-rename")).toBeVisible();
await expect(menu.getByTestId("project-duplicate")).toBeVisible();
await expect(menu.getByTestId("project-pin")).toBeVisible();
await expect(menu.getByTestId("project-delete")).toBeVisible();
// project-move-to is covered separately below: it only renders once there
// is at least one other team to move to, which the default single-team
// fixture used here doesn't have.
});
test("User can open a project's options menu by right-clicking its title", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await setupTwoProjects(dashboardPage);
await dashboardPage.goToDashboard();
const row = projectRow(page, "New Project 1");
await row.getByText("New Project 1").click({ button: "right" });
const menu = page.getByRole("menu");
await expect(menu.getByTestId("project-rename")).toBeVisible();
await expect(menu.getByTestId("project-delete")).toBeVisible();
});
test("The default Drafts project has a limited options menu", async ({
page,
}) => {
const dashboardPage = new DashboardPage(page);
await setupTwoProjects(dashboardPage);
await dashboardPage.goToDashboard();
const row = projectRow(page, "Drafts");
await row.getByTestId("project-options").click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await expect(menu.getByTestId("project-rename")).toHaveCount(0);
await expect(menu.getByTestId("project-duplicate")).toHaveCount(0);
await expect(menu.getByTestId("project-pin")).toHaveCount(0);
await expect(menu.getByTestId("project-move-to")).toHaveCount(0);
await expect(menu.getByTestId("project-delete")).toHaveCount(0);
});
test("User can rename a project from the options menu", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await setupTwoProjects(dashboardPage);
await dashboardPage.goToDashboard();
const row = projectRow(page, "New Project 1");
await row.getByTestId("project-options").click();
await page.getByTestId("project-rename").click();
// Not scoped to `row` (renaming swaps the title for an <input>, whose
// value doesn't count as text content, so the `hasText` filter used to
// find the row in the first place would no longer match it) and matched
// by value rather than role, since the dashboard's own search field is
// also a textbox.
await expect(page.locator('input[value="New Project 1"]')).toBeVisible();
});
test("User can delete a project from the options menu", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await setupTwoProjects(dashboardPage);
await dashboardPage.goToDashboard();
const row = projectRow(page, "New Project 1");
await row.getByTestId("project-options").click();
await page.getByTestId("project-delete").click();
await expect(
page.getByRole("heading", { name: "Delete project" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Delete project" }),
).toBeVisible();
});
test("The move-to submenu lists the user's other teams", async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await setupTwoProjects(dashboardPage);
await DashboardPage.mockRPC(
page,
"get-teams",
"logged-in-user/get-teams-complete.json",
);
await dashboardPage.goToDashboard();
const row = projectRow(page, "New Project 1");
await row.getByTestId("project-options").click();
await page.getByTestId("project-move-to").click();
await expect(
page.getByRole("menuitem", { name: "Second team" }),
).toBeVisible();
});

View File

@ -198,8 +198,7 @@
(update [_ state]
(-> state
(dissoc :selected-files)
(dissoc :selected-project)
(update :dashboard-local dissoc :menu-open :menu-pos)))))
(dissoc :selected-project)))))
(defn toggle-file-select
[{:keys [id project-id] :as file}]
@ -214,36 +213,6 @@
(assoc :selected-project project-id))
state)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Show grid menu
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn show-file-menu-with-position
[file-id pos]
(ptk/reify ::show-file-menu-with-position
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local assoc
:menu-open true
:menu-pos pos
:file-id file-id))))
(defn show-file-menu
[]
(ptk/reify ::show-file-menu
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local
assoc :menu-open true))))
(defn hide-file-menu
[]
(ptk/reify ::hide-file-menu
ptk/UpdateEvent
(update [_ state]
(update state :dashboard-local
assoc :menu-open false))))
(defn start-edit-file-name
[file-id]
(ptk/reify ::start-edit-file-menu

View File

@ -16,12 +16,10 @@
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.context :as ctx]
[app.util.dom :as dom]
[app.main.ui.ds.layout.menu :refer [menu* menu-item* menu-separator* sub-menu*]]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(defn- get-project-name
@ -55,18 +53,50 @@
{}
projects))
(mf/defc file-menu*
[{:keys [files on-edit on-close top left navigate origin parent-id can-edit can-restore]}]
;; The "move to" tree can be arbitrarily deep (current team's projects, then
;; every other team's own projects), so every level here uses SubMenu's
;; drilldown variant instead of a flyout: opening a chain of flyouts that
;; deep would run off-screen well before it ran out of teams.
(mf/defc move-to-items*
{::mf/private true}
[{:keys [current-projects other-teams current-team-id on-move]}]
[:*
(for [project current-projects]
[:> menu-item* {:key (get-project-id project)
:id (get-project-id project)
:on-action (on-move current-team-id (:id project))}
(get-project-name project)])
(when (seq other-teams)
[:> sub-menu* {:key "move-to-other-team"
:id "move-to-other-team"
:trigger (tr "dashboard.move-to-other-team")
:variant "drilldown"}
(for [team other-teams]
[:> sub-menu* {:key (get-project-id team)
:id (get-project-id team)
:trigger (get-team-name team)
:variant "drilldown"}
(for [sub-project (:projects team)]
[:> menu-item* {:key (get-project-id sub-project)
:id (get-project-id sub-project)
:on-action (on-move (:id team) (:id sub-project))}
(get-project-name sub-project)])])])])
;; The menu items only, with no popover of their own — shared by file-menu*
;; below (opened from the "..." button, via Menu) and by grid.cljs's own
;; right-click handling (via ContextMenu), so both triggers show the exact
;; same options.
(mf/defc file-menu-items*
[{:keys [files on-edit navigate origin can-edit can-restore]}]
(assert (seq files) "missing `files` prop")
(assert (fn? on-edit) "missing `on-edit` prop")
(assert (fn? on-close) "missing `on-close` prop")
(assert (boolean? navigate) "missing `navigate` prop")
(let [is-lib-page? (= :libraries origin)
is-search-page? (= :search origin)
top (or top 0)
left (or left 0)
file (first files)
file-count (count files)
@ -83,13 +113,13 @@
(:projects current-team))
on-new-tab
(fn [_]
(fn []
(st/emit! (dcm/go-to-workspace
{:file-id (:id file)
::rt/new-window true})))
on-duplicate
(fn [_]
(fn []
(apply st/emit! (map dd/duplicate-file files))
(st/emit! (ntf/success (tr "dashboard.success-duplicate-file" (i18n/c file-count)))))
@ -100,8 +130,7 @@
(dd/clear-selected-files)))
on-delete
(fn [event]
(dom/stop-propagation event)
(fn []
(let [num-shared (filter #(:is-shared %) files)]
(if (< 0 (count num-shared))
@ -149,7 +178,6 @@
(let [params {:ids (into #{} (map :id) files)
:project-id project-id}]
(fn []
(let [num-shared (filter #(:is-shared %) files)]
(if (and (< 0 (count num-shared))
(not= team-id current-team-id))
@ -171,14 +199,11 @@
(run! #(st/emit! (dd/set-file-shared (assoc % :is-shared false))) files))
on-add-shared
(fn [event]
(dom/stop-propagation event)
(fn []
(st/emit! (dcm/show-shared-dialog (:id file) add-shared)))
on-del-shared
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(fn []
(st/emit! (modal/show
{:type :delete-shared-libraries
:origin :unpublish
@ -224,126 +249,101 @@
:on-accept accept-fn}))))]
(mf/with-effect []
(->> (rp/cmd! :get-all-projects)
(rx/map group-by-team)
(rx/subs! #(reset! teams* %))))
(let [subs (->> (rp/cmd! :get-all-projects)
(rx/map group-by-team)
(rx/subs! #(reset! teams* %)))]
#(rx/dispose! subs)))
(mf/with-effect [on-close]
(st/emit! (ptk/data-event :dropdown/open {:id "file-menu"}))
(let [stream (->> st/stream
(rx/filter (ptk/type? :dropdown/open))
(rx/map deref)
(rx/filter #(not= "file-menu" (:id %)))
(rx/take 1))
subs (rx/subs! nil nil on-close stream)]
(fn []
(rx/dispose! subs))))
(cond
can-restore
[:*
[:> menu-item* {:id "restore-file" :on-action on-restore-immediately}
(tr "dashboard.file-menu.restore-files-option" (i18n/c file-count))]
[:> menu-item* {:id "delete-file" :on-action on-delete-immediately}
(tr "dashboard.file-menu.delete-files-permanently-option" (i18n/c file-count))]]
(let [sub-options
(concat
(for [project current-projects]
{:name (get-project-name project)
:id (get-project-id project)
:handler (on-move current-team-id (:id project))})
(when (seq other-teams)
[{:name (tr "dashboard.move-to-other-team")
:id "move-to-other-team"
:options
(for [team other-teams]
{:name (get-team-name team)
:id (get-project-id team)
:options
(for [sub-project (:projects team)]
{:name (get-project-name sub-project)
:id (get-project-id sub-project)
:handler (on-move (:id team)
(:id sub-project))})})}]))
multi?
[:*
(when can-edit
[:> menu-item* {:id "duplicate-multi" :on-action on-duplicate :datatest-id "duplicate-multi"}
(tr "dashboard.duplicate-multi" file-count)])
options
(if can-restore
[{:name (tr "dashboard.file-menu.restore-files-option" (i18n/c file-count))
:id "restore-file"
:handler on-restore-immediately}
{:name (tr "dashboard.file-menu.delete-files-permanently-option" (i18n/c file-count))
:id "delete-file"
:handler on-delete-immediately}]
(if multi?
[(when can-edit
{:name (tr "dashboard.duplicate-multi" file-count)
:id "duplicate-multi"
:handler on-duplicate})
(when (and (or (seq current-projects) (seq other-teams)) can-edit)
[:> sub-menu* {:id "file-move-multi" :trigger (tr "dashboard.move-to-multi" file-count) :variant "drilldown"}
[:> move-to-items* {:current-projects current-projects
:other-teams other-teams
:current-team-id current-team-id
:on-move on-move}]])
(when (and (or (seq current-projects) (seq other-teams)) can-edit)
{:name (tr "dashboard.move-to-multi" file-count)
:id "file-move-multi"
:options sub-options})
[:> menu-item* {:id "file-binary-export-multi" :on-action on-export-binary-files}
(tr "dashboard.export-binary-multi" file-count)]
{:name (tr "dashboard.export-binary-multi" file-count)
:id "file-binary-export-multi"
:handler on-export-binary-files}
(when (and (:is-shared file) can-edit)
[:> menu-item* {:id "file-unpublish-multi" :on-action on-del-shared}
(tr "labels.unpublish-multi-files" file-count)])
(when (and (:is-shared file) can-edit)
{:name (tr "labels.unpublish-multi-files" file-count)
:id "file-unpublish-multi"
:handler on-del-shared})
(when (and (not is-lib-page?) can-edit)
[:*
[:> menu-separator*]
[:> menu-item* {:id "file-delete-multi" :on-action on-delete}
(tr "labels.delete-multi-files" file-count)]])]
(when (and (not is-lib-page?) can-edit)
{:name :separator}
{:name (tr "labels.delete-multi-files" file-count)
:id "file-delete-multi"
:handler on-delete})]
:else
[:*
[:> menu-item* {:id "file-open-new-tab" :on-action on-new-tab}
(tr "dashboard.open-in-new-tab")]
[{:name (tr "dashboard.open-in-new-tab")
:id "file-open-new-tab"
:handler on-new-tab}
(when (and (not is-search-page?) can-edit)
{:name (tr "labels.rename")
:id "file-rename"
:handler on-edit})
(when (and (not is-search-page?) can-edit)
[:> menu-item* {:id "file-rename" :on-action on-edit}
(tr "labels.rename")])
(when (and (not is-search-page?) can-edit)
{:name (tr "dashboard.duplicate")
:id "file-duplicate"
:handler on-duplicate})
(when (and (not is-search-page?) can-edit)
[:> menu-item* {:id "file-duplicate" :on-action on-duplicate}
(tr "dashboard.duplicate")])
(when (and (not is-lib-page?)
(not is-search-page?)
(or (seq current-projects) (seq other-teams))
can-edit)
{:name (tr "dashboard.move-to")
:id "file-move-to"
:options sub-options})
(when (and (not is-lib-page?)
(not is-search-page?)
(or (seq current-projects) (seq other-teams))
can-edit)
[:> sub-menu* {:id "file-move-to" :trigger (tr "dashboard.move-to") :variant "drilldown"}
[:> move-to-items* {:current-projects current-projects
:other-teams other-teams
:current-team-id current-team-id
:on-move on-move}]])
(when (and (not is-search-page?)
can-edit)
(if (:is-shared file)
{:name (tr "dashboard.unpublish-shared")
:id "file-del-shared"
:handler on-del-shared}
{:name (tr "dashboard.add-shared")
:id "file-add-shared"
:handler on-add-shared}))
(when (and (not is-search-page?) can-edit)
;; Same id in both branches: :is-shared can flip while this menu
;; instance stays mounted (the on-add-shared/on-del-shared action
;; itself changes it), and react-stately's Collection requires an
;; item's id to stay stable across such an update rather than swap
;; to a differently-id'd item in the same slot.
(if (:is-shared file)
[:> menu-item* {:id "file-shared-toggle" :on-action on-del-shared}
(tr "dashboard.unpublish-shared")]
[:> menu-item* {:id "file-shared-toggle" :on-action on-add-shared}
(tr "dashboard.add-shared")]))
{:name :separator}
[:> menu-separator*]
{:name (tr "dashboard.download-binary-file")
:id "download-binary-file"
:handler on-export-binary-files}
[:> menu-item* {:id "download-binary-file" :on-action on-export-binary-files}
(tr "dashboard.download-binary-file")]
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
{:name :separator})
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
[:*
[:> menu-separator*]
[:> menu-item* {:id "file-delete" :on-action on-delete}
(tr "labels.delete")]])])))
(when (and (not is-lib-page?) (not is-search-page?) can-edit)
{:name (tr "labels.delete")
:id "file-delete"
:handler on-delete})]))]
[:> context-menu*
{:on-close on-close
:fixed (or (not= top 0) (not= left 0))
:show true
:min-width true
:top top
:left left
:options options
:origin parent-id}])))
(mf/defc file-menu*
[{:keys [files on-edit is-open on-open-change trigger navigate origin can-edit can-restore is-list]}]
[:> menu*
{:is-open is-open
:on-open-change on-open-change
:placement (if is-list "bottom end" "bottom start")
:trigger trigger}
[:> file-menu-items* {:files files
:on-edit on-edit
:navigate navigate
:origin origin
:can-edit can-edit
:can-restore can-restore}]])

View File

@ -15,31 +15,31 @@
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.dashboard.grid :refer [grid*]]
[app.main.ui.dashboard.import :as udi]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]]
[app.main.ui.dashboard.pin-button :refer [pin-button*]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(mf/defc header*
{::mf/private true}
[{:keys [project create-fn can-edit layout on-change]}]
(let [project-id (:id project)
local
(mf/use-state
{:menu-open false
:edition false})
(mf/use-state {:edition false})
menu-open*
(mf/use-state false)
on-create-click
(mf/use-fn
@ -51,22 +51,27 @@
on-menu-click
(mf/use-fn
(fn [event]
(let [position (dom/get-client-position event)]
(dom/prevent-default event)
(swap! local assoc :menu-open true :menu-pos position))))
on-menu-close
(mf/use-fn #(swap! local assoc :menu-open false))
(dom/prevent-default event)
(swap! menu-open* not)))
on-edit
(mf/use-fn #(swap! local assoc :edition true :menu-open false))
(mf/use-fn
(fn []
(reset! menu-open* false)
(swap! local assoc :edition true)))
toggle-pin
(mf/use-fn
(mf/deps project)
#(st/emit! (dd/toggle-project-pin project)))
on-import
file-input
(mf/use-ref nil)
on-import-click
(mf/use-fn #(dom/click (mf/ref-val file-input)))
on-finish-import
(mf/use-fn
(mf/deps project-id)
(fn []
@ -101,40 +106,56 @@
:on-change on-change}]
(when ^boolean can-edit
[:a {:class (stl/css :btn-secondary :btn-small :new-file)
:tab-index "0"
:on-click on-create-click
:data-testid "new-file"
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-create-click event)))}
[:> button* {:variant "secondary"
:class (stl/css :new-file)
:on-click on-create-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-create-click event)))
:tab-index "0"
:data-testid "new-file"}
(tr "dashboard.new-file")])
(when-not (:is-default project)
[:> pin-button*
{:tab-index 0
:is-pinned (:is-pinned project)
:on-click toggle-pin
:on-key-down (fn [event] (when (kbd/enter? event) (toggle-pin event)))}])
[:> icon-button* {:icon i/pin
:variant "ghost"
:aria-label (tr "dashboard.pin-unpin")
:aria-pressed (:is-pinned project)
:tab-index 0
:on-click toggle-pin
:on-key-down (fn [event]
(when (kbd/enter? event)
(toggle-pin event)))}])
(when ^boolean can-edit
[:div {:class (stl/css :icon)
:tab-index "0"
:on-click on-menu-click
:title (tr "dashboard.options")
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))}
menu-icon])
[:*
[:> project-menu*
{:project project
:is-open (deref menu-open*)
:on-open-change #(reset! menu-open* %)
:on-edit on-edit
:on-import-click on-import-click
:placement "bottom end"
:trigger
(mf/html
[:> icon-button* {:icon i/menu
:variant "ghost"
:aria-label (tr "dashboard.options")
:aria-pressed (deref menu-open*)
:on-click on-menu-click
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-menu-click event)))}])}]
(when ^boolean can-edit
[:> project-menu* {:project project
:show (:menu-open @local)
:left (- (:x (:menu-pos @local)) 180)
:top (:y (:menu-pos @local))
:on-edit on-edit
:on-close on-menu-close
:on-import on-import}])]]))
;; Kept mounted for as long as this header is, regardless of the
;; menu's own open state: the popover really unmounts its content on
;; close (unlike the old context-menu-a11y, which just hid it), and
;; selecting "Import" closes the menu in the same tick — a ref owned
;; inside the popover could already be gone by the time its own
;; click handler fires.
[:> udi/import-form* {:ref file-input
:project-id project-id
:on-finish-import on-finish-import}]])]]))
(mf/defc files-section*
[{:keys [project team layout on-layout-change]}]

View File

@ -4,43 +4,20 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "refactor/common-refactor.scss" as deprecated;
@use "common/refactor/common-dashboard";
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.dashboard-container {
flex: 1 0 0;
margin-right: deprecated.$s-16;
margin-inline-end: var(--sp-l);
overflow-y: auto;
width: 100%;
border-top: deprecated.$s-1 solid var(--color-background-quaternary);
inline-size: 100%;
border-block-start: $b-1 solid var(--color-background-quaternary);
padding-block-end: var(--sp-xxxl);
&.dashboard-projects {
user-select: none;
}
&.dashboard-shared {
width: calc(100vw - deprecated.$s-320);
margin-right: deprecated.$s-52;
}
&.search {
margin-top: deprecated.$s-12;
}
}
.new-file {
margin-inline-end: deprecated.$s-8;
}
.menu-icon {
@extend %button-icon;
stroke: var(--icon-foreground);
}
.placeholder-placement {
margin: deprecated.$s-16 deprecated.$s-32;
margin: var(--sp-l) var(--sp-xxxl);
}

View File

@ -9,7 +9,6 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.logging :as log]
[app.common.time :as ct]
[app.config :as cf]
@ -26,12 +25,13 @@
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.color-bullet :as bc]
[app.main.ui.components.portal :refer [portal-on-document*]]
[app.main.ui.dashboard.file-menu :refer [file-menu*]]
[app.main.ui.dashboard.file-menu :refer [file-menu* file-menu-items*]]
[app.main.ui.dashboard.import :refer [use-import-file]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.placeholder :refer [empty-grid-placeholder* loading-placeholder*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.layout.menu :refer [context-menu*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.hooks :as h]
[app.main.worker :as mw]
@ -269,9 +269,19 @@
file-id (get file :id)
menu-pos (get state :menu-pos)
menu-open? (and (get state :menu-open)
(= file-id (:file-id state)))
menu-open* (mf/use-state false)
menu-open? (deref menu-open*)
;; Only act on the whole selection when this file is part of it. The
;; selection can legitimately hold other files instead: the click that
;; opens the menu applies its selection through a store dispatch that
;; only lands on the next render, and toggle-file-select is a no-op
;; across projects, so a shift-click on a file in another project
;; never adds it. In both cases the menu must target what the user
;; actually pointed at, not whatever happens to be selected.
menu-files (if (contains? selected-files file-id)
(vals selected-files)
[file])
selected? (contains? selected-files file-id)
selected-num (count selected-files)
@ -283,10 +293,6 @@
library-view? (= origin :libraries)
on-menu-close
(mf/use-fn
#(st/emit! (dd/hide-file-menu)))
on-select
(mf/use-fn
(mf/deps selected? selected-num)
@ -312,7 +318,7 @@
(mf/use-fn
(mf/deps selected? selected-num)
(fn [event]
(st/emit! (dd/hide-file-menu))
(reset! menu-open* false)
(when can-edit
(let [offset (dom/get-offset-position (dom/event->native-event event))
item-el (mf/ref-val node-ref)
@ -341,39 +347,23 @@
on-menu-click
(mf/use-fn
(mf/deps file selected? menu-open?)
(mf/deps file selected?)
(fn [event]
(dom/stop-propagation event)
(if menu-open?
(st/emit! (dd/hide-file-menu))
(do
(when-not selected?
(when-not (kbd/shift? event)
(st/emit! (dd/clear-selected-files)))
(st/emit! (dd/toggle-file-select file)))
(let [client-position
(dom/get-client-position event)
position
(if (and (nil? (:y client-position)) (nil? (:x client-position)))
(let [target-element (dom/get-target event)
points (dom/get-bounding-rect target-element)
y (:top points)
x (:left points)]
(gpt/point x y))
client-position)]
(st/emit! (dd/show-file-menu-with-position file-id position)))))))
(when-not selected?
(when-not (kbd/shift? event)
(st/emit! (dd/clear-selected-files)))
(st/emit! (dd/toggle-file-select file)))
(swap! menu-open* not)))
on-context-menu
(mf/use-fn
(mf/deps on-menu-click)
(mf/deps file selected?)
(fn [event]
(dom/prevent-default event)
(on-menu-click event)))
(when-not selected?
(when-not (kbd/shift? event)
(st/emit! (dd/clear-selected-files)))
(st/emit! (dd/toggle-file-select file)))))
edit
(mf/use-fn
@ -386,9 +376,8 @@
on-edit
(mf/use-fn
(mf/deps file)
(fn [event]
(dom/stop-propagation event)
(mf/deps file-id)
(fn []
(st/emit! (dd/start-edit-file-name file-id))))
on-key-down
@ -422,104 +411,115 @@
(mf/html
[:div {:class (stl/css-case :project-thumbnail-actions true
:is-force-display menu-open?)}
[:div {:class (stl/css :project-thumbnail-icon :menu)
:tab-index "0"
:role "button"
:aria-label (tr "dashboard.options")
:ref menu-ref
:id (dm/str file-id "-action-menu")
:on-click on-menu-click
:on-key-down on-menu-key-down}
[:> icon* {:icon-id i/menu
:class (stl/css :menu-icon)}]
(when (and selected? menu-open?)
;; When the menu is open we disable events in the dashboard. We need to force pointer events
;; so the menu can be handled
[:> portal-on-document* {}
[:> file-menu* {:files (vals selected-files)
:left (+ 24 (:x menu-pos))
:top (:y menu-pos)
:can-edit can-edit
:navigate true
:on-edit on-edit
:on-close on-menu-close
:origin origin
:parent-id (dm/str file-id "-action-menu")
:can-restore can-restore}]])]])]
[:> file-menu* {:files menu-files
:is-open menu-open?
:on-open-change #(reset! menu-open* %)
:can-edit can-edit
:navigate true
:on-edit on-edit
:origin origin
:can-restore can-restore
:is-list list?
:trigger
(mf/html
[:> icon-button* {:icon i/menu
:variant "ghost"
:aria-label (tr "dashboard.options")
:on-click on-menu-click
:on-key-down on-menu-key-down
:tab-index "0"
:ref menu-ref
:id (dm/str file-id "-action-menu")}])}]])]
(if ^boolean list?
[:li {:class (stl/css-case :grid-item true
:list-item true
:library-item library-view?)}
[:div
{:class (stl/css-case :list-item-row true
:is-selected selected?)
:ref node-ref
:role "button"
:title (:name file)
:aria-label (:name file)
:draggable (dm/str can-edit)
:on-click on-select
:on-key-down on-key-down
:on-double-click on-navigate
:on-drag-start on-drag-start
:on-context-menu on-context-menu}
[:> context-menu* {:aria-label (tr "dashboard.options")
:trigger
(mf/html
[:div
{:class (stl/css-case :list-item-row true
:is-selected selected?)
:ref node-ref
:role "button"
:title (:name file)
:aria-label (:name file)
:draggable (dm/str can-edit)
:on-click on-select
:on-key-down on-key-down
:on-double-click on-navigate
:on-drag-start on-drag-start
:on-context-menu on-context-menu}
(if ^boolean editing?
[:& inline-edition {:content (:name file)
:on-end edit
:max-length 250}]
[:h3 {:class (stl/css :list-item-name)} (:name file)])
(if ^boolean editing?
[:& inline-edition {:content (:name file)
:on-end edit
:max-length 250}]
[:h3 {:class (stl/css :list-item-name)} (:name file)])
(when (and (:is-shared file) (not library-view?))
[:span {:class (stl/css :list-item-badge)
:aria-label (tr "workspace.assets.shared-library")
:title (tr "workspace.assets.shared-library")}
[:> icon* {:icon-id i/library}]])
(when (and (:is-shared file) (not library-view?))
[:span {:class (stl/css :list-item-badge)
:aria-label (tr "workspace.assets.shared-library")
:title (tr "workspace.assets.shared-library")}
[:> icon* {:icon-id i/library}]])
[:> grid-item-metadata* {:file file :layout :list}]
[:> grid-item-metadata* {:file file :layout :list}]
menu-element]]
menu-element])}
[:> file-menu-items* {:files menu-files
:can-edit can-edit
:navigate true
:on-edit on-edit
:origin origin
:can-restore can-restore}]]]
[:li {:class (stl/css-case :grid-item true
:project-thumbnail true
:library-item library-view?)}
[:div {:class (stl/css-case :is-selected selected?
:grid-item-button true)
:ref node-ref
:role "button"
:title (:name file)
:aria-label (:name file)
:draggable (dm/str can-edit)
:on-click on-select
:on-key-down on-key-down
:on-double-click on-navigate
:on-drag-start on-drag-start
:on-context-menu on-context-menu}
[:> context-menu* {:aria-label (tr "dashboard.options")
:trigger
(mf/html
[:div {:class (stl/css-case :is-selected selected?
:grid-item-button true)
:ref node-ref
:role "button"
:title (:name file)
:aria-label (:name file)
:draggable (dm/str can-edit)
:on-click on-select
:on-key-down on-key-down
:on-double-click on-navigate
:on-drag-start on-drag-start
:on-context-menu on-context-menu}
(if ^boolean library-view?
[:> grid-item-library* {:file file
:can-restore can-restore}]
[:> grid-item-thumbnail* {:file file
:can-edit can-edit
:can-restore can-restore}])
(if ^boolean library-view?
[:> grid-item-library* {:file file
:can-restore can-restore}]
[:> grid-item-thumbnail* {:file file
:can-edit can-edit
:can-restore can-restore}])
(when (and (:is-shared file) (not library-view?))
[:div {:class (stl/css :grid-item-badge)}
[:> icon* {:icon-id i/library}]])
(when (and (:is-shared file) (not library-view?))
[:div {:class (stl/css :grid-item-badge)}
[:> icon* {:icon-id i/library}]])
[:div {:class (stl/css :grid-item-info)}
[:div {:class (stl/css :grid-item-meta)}
(if ^boolean editing?
[:& inline-edition {:content (:name file)
:on-end edit
:max-length 250}]
[:h3 {:class (stl/css :grid-item-title)} (:name file)])
[:> grid-item-metadata* {:file file :layout :grid}]]
[:div {:class (stl/css :grid-item-info)}
[:div {:class (stl/css :grid-item-meta)}
(if ^boolean editing?
[:& inline-edition {:content (:name file)
:on-end edit
:max-length 250}]
[:h3 {:class (stl/css :grid-item-title)} (:name file)])
[:> grid-item-metadata* {:file file :layout :grid}]]
menu-element]]])))
menu-element]])}
[:> file-menu-items* {:files menu-files
:can-edit can-edit
:navigate true
:on-edit on-edit
:origin origin
:can-restore can-restore}]]])))
(mf/defc grid*
[{:keys [files project origin limit create-fn can-edit selected-files can-restore layout]}]
@ -541,9 +541,6 @@
import-files
(use-import-file project-id on-finish-import)
on-scroll
(mf/use-fn #(st/emit! (dd/hide-file-menu)))
on-drag-enter
(mf/use-fn
(fn [e]
@ -585,7 +582,6 @@
:on-drag-over on-drag-over
:on-drag-leave on-drag-leave
:on-drop on-drop
:on-scroll on-scroll
:ref node-ref}
(cond
(nil? files)

View File

@ -187,53 +187,19 @@ $thumbnail-default-height: px2rem(168);
}
.project-thumbnail-actions {
align-items: center;
display: flex;
block-size: 100%;
align-self: end;
justify-content: center;
opacity: 0;
inset-inline-end: $sz-6;
block-size: $sz-32;
inline-size: $sz-32;
margin-block-end: var(--sp-s);
opacity: 0;
&.is-force-display {
opacity: 1;
}
}
.project-thumbnail-icon {
align-items: center;
display: flex;
margin-inline-end: var(--sp-s);
margin-block-start: 0;
}
// CARD VIEW: MENU
.menu {
align-items: flex-end;
display: flex;
flex-direction: column;
block-size: $sz-32;
justify-content: center;
margin-inline-end: 0;
margin-block-start: var(--sp-xl);
inline-size: 100%;
--menu-icon-color: var(--button-tertiary-foreground-color-rest);
&:hover,
&:focus {
--menu-icon-color: var(--button-tertiary-foreground-color-hover);
}
}
.menu-icon {
color: var(--menu-icon-color);
margin-inline-end: 0;
block-size: $sz-16;
inline-size: $sz-16;
}
// LIST VIEW
.list-item {
@ -247,22 +213,12 @@ $thumbnail-default-height: px2rem(168);
// it sits inline at the end of the row.
.project-thumbnail-actions {
flex: 0 0 auto;
align-self: center;
block-size: auto;
inline-size: auto;
opacity: 1;
inline-size: auto;
}
.project-thumbnail-icon {
margin: 0;
}
.menu {
align-items: center;
flex-direction: row;
block-size: $sz-32;
margin: 0;
inline-size: auto;
}
}
.list-item-dragged {

View File

@ -1,30 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.ui.dashboard.pin-button
(:require-macros
[app.common.data.macros :as dm]
[app.main.style :as stl])
(:require
[app.main.ui.icons :as deprecated-icon]
[app.util.i18n :as i18n :refer [tr]]
[app.util.object :as obj]
[rumext.v2 :as mf]))
(def ^:private pin-icon
(deprecated-icon/icon-xref :pin (stl/css :icon)))
(mf/defc pin-button*
[{:keys [aria-label is-pinned class] :as props}]
(let [aria-label (or aria-label (tr "dashboard.pin-unpin"))
class (dm/str (or class "") " " (stl/css-case :button true :button-active is-pinned))
props (-> (obj/clone props)
(obj/unset! "isPinned")
(obj/set! "className" class)
(obj/set! "aria-label" aria-label))]
[:> "button" props pin-icon]))

View File

@ -1,35 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "refactor/common-refactor.scss" as deprecated;
.button {
--pin-button-icon-color: var(--button-icon-foreground-color);
--pin-button-bg-color: none;
--pin-button-border-color: none;
width: deprecated.$s-32;
height: deprecated.$s-32;
background: var(--pin-button-bg-color);
border: deprecated.$s-2 solid var(--pin-button-border-color);
border-radius: deprecated.$br-8;
display: grid;
place-content: center;
cursor: pointer;
}
.button-active {
--pin-button-icon-color: var(--button-icon-foreground-color-selected);
--pin-button-bg-color: var(--button-icon-background-color-selected);
--pin-button-border-color: var(--button-icon-border-color-selected);
}
.icon {
width: deprecated.$s-16;
height: deprecated.$s-16;
fill: none;
stroke: var(--pin-button-icon-color);
}

View File

@ -12,23 +12,34 @@
[app.main.data.notifications :as ntf]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.context :as ctx]
[app.main.ui.dashboard.import :as udi]
[app.util.dom :as dom]
[app.main.ui.ds.layout.menu :refer [menu* menu-item* menu-separator* sub-menu*]]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(mf/defc project-menu*
[{:keys [project show on-edit on-close top left on-import]}]
(let [top (or top 0)
left (or left 0)
;; The menu items only, with no popover of their own — shared by project-menu*
;; below (opened from the "..." button, via Menu) and by projects.cljs's own
;; right-click handling (via ContextMenu), so both triggers show the exact
;; same options.
;;
;; on-import-click, rather than this owning its own file input/ref: the
;; popover this renders inside really unmounts its content on close (unlike
;; the old context-menu-a11y, which just hid it), and selecting "Import"
;; closes the menu in the same tick — so a ref owned here can already be
;; gone by the time its own click handler would fire. The caller keeps the
;; hidden input mounted for as long as the row itself exists instead.
(mf/defc project-menu-items*
{::mf/private true}
[{:keys [project on-edit on-import-click]}]
current-team-id (mf/use-ctx ctx/current-team-id)
teams (mf/deref refs/teams)
teams (-> teams (dissoc current-team-id) vals vec)
(assert (some? project) "missing `project` prop")
(assert (fn? on-edit) "missing `on-edit` prop")
(let [is-default? (:is-default project)
current-team-id (mf/use-ctx ctx/current-team-id)
teams (mf/deref refs/teams)
other-teams (-> teams (dissoc current-team-id) vals)
on-duplicate-success
(fn [new-project]
@ -71,73 +82,45 @@
:title (tr "modals.delete-project-confirm.title")
:message (tr "modals.delete-project-confirm.message")
:accept-label (tr "modals.delete-project-confirm.accept")
:on-accept delete-fn})))
file-input
(mf/use-ref nil)
on-import-files
(fn [] (dom/click (mf/ref-val file-input)))
on-finish-import
(mf/use-fn
(fn [] (when (fn? on-import) (on-import))))
options
[(when-not (:is-default project)
{:name (tr "labels.rename")
:id "project-rename"
:handler on-edit})
(when-not (:is-default project)
{:name (tr "dashboard.duplicate")
:id "project-duplicate"
:handler on-duplicate})
(when-not (:is-default project)
{:name (tr "dashboard.pin-unpin")
:id "project-pin"
:handler toggle-pin})
(when (and (seq teams) (not (:is-default project)))
{:name (tr "dashboard.move-to")
:id "project-move-to"
:options (for [team teams]
{:name (:name team)
:id (str "move-to-" (:id team))
:handler (on-move (:id team))})})
(when (some? on-import)
{:name (tr "dashboard.import")
:id "file-import"
:handler on-import-files})
(when-not (:is-default project)
{:name :separator})
(when-not (:is-default project)
{:name (tr "labels.delete")
:id "project-delete"
:handler on-delete})]]
(mf/with-effect [show on-close]
(when ^boolean show
(st/emit! (ptk/data-event :dropdown/open {:id "project-menu"}))
(let [stream (->> st/stream
(rx/filter (ptk/type? :dropdown/open))
(rx/map deref)
(rx/filter #(not= "project-menu" (:id %)))
(rx/take 1))
subs (rx/subs! nil nil on-close stream)]
(fn []
(rx/dispose! subs)))))
:on-accept delete-fn})))]
[:*
[:> context-menu*
{:on-close on-close
:show show
:fixed (or (not= top 0) (not= left 0))
:min-width true
:top top
:left left
:options options}]
[:> udi/import-form* {:ref file-input
:project-id (:id project)
:on-finish-import on-finish-import}]]))
(when-not is-default?
[:> menu-item* {:id "project-rename" :on-action on-edit}
(tr "labels.rename")])
(when-not is-default?
[:> menu-item* {:id "project-duplicate" :on-action on-duplicate}
(tr "dashboard.duplicate")])
(when-not is-default?
[:> menu-item* {:id "project-pin" :on-action toggle-pin}
(tr "dashboard.pin-unpin")])
(when (and (seq other-teams) (not is-default?))
[:> sub-menu* {:id "project-move-to" :trigger (tr "dashboard.move-to") :variant "drilldown"}
(for [team other-teams]
[:> menu-item* {:key (:id team)
:id (str "move-to-" (:id team))
:on-action (on-move (:id team))}
(:name team)])])
(when (some? on-import-click)
[:> menu-item* {:id "file-import" :on-action on-import-click}
(tr "dashboard.import")])
(when-not is-default?
[:*
[:> menu-separator*]
[:> menu-item* {:id "project-delete" :on-action on-delete}
(tr "labels.delete")]])]))
(mf/defc project-menu*
[{:keys [project is-open on-open-change on-edit on-import-click trigger placement]}]
[:> menu* {:is-open is-open
:on-open-change on-open-change
:placement placement
:trigger trigger}
[:> project-menu-items* {:project project
:on-edit on-edit
:on-import-click on-import-click}]])

View File

@ -7,7 +7,6 @@
(ns app.main.ui.dashboard.projects
(:require-macros [app.main.style :as stl])
(:require
[app.common.geom.point :as gpt]
[app.common.time :as ct]
[app.main.data.common :as dcm]
[app.main.data.dashboard :as dd]
@ -20,14 +19,17 @@
[app.main.store :as st]
[app.main.ui.dashboard.deleted :as deleted]
[app.main.ui.dashboard.grid :refer [line-grid*]]
[app.main.ui.dashboard.import :as udi]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]]
[app.main.ui.dashboard.pin-button :refer [pin-button*]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.dashboard.project-menu :refer [project-menu*
project-menu-items*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.layout.menu :refer [context-menu*]]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.hooks :as hooks]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
@ -36,23 +38,14 @@
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private show-more-icon
(deprecated-icon/icon-xref :arrow (stl/css :show-more-icon)))
(def ^:private close-icon
(deprecated-icon/icon-xref :close (stl/css :close-icon)))
(def ^:private add-icon
(deprecated-icon/icon-xref :add (stl/css :add-icon)))
(def ^:private menu-icon
(deprecated-icon/icon-xref :menu (stl/css :menu-icon)))
(mf/defc header*
{::mf/wrap [mf/memo]
::mf/private true}
[{:keys [can-edit layout on-change]}]
(let [on-click (mf/use-fn #(st/emit! (dd/create-project)))]
(let [on-click
(mf/use-fn
#(st/emit! (dd/create-project)))]
[:header {:class (stl/css :dashboard-header)
:data-testid "dashboard-header"}
[:div#dashboard-projects-title {:class (stl/css :dashboard-title)}
@ -61,15 +54,17 @@
[:> layout-toggle* {:layout layout
:on-change on-change}]
(when can-edit
[:button {:class (stl/css :btn-secondary :btn-small)
:on-click on-click
:data-testid "new-project-button"}
[:> button* {:variant "secondary"
:on-click on-click
:data-testid "new-project-button"}
(tr "dashboard.new-project")])]]))
(mf/defc team-hero*
{::mf/wrap [mf/memo]}
[{:keys [team on-close]}]
(let [on-nav-members-click (mf/use-fn #(st/emit! (dcm/go-to-dashboard-members)))
(let [on-nav-members-click
(mf/use-fn
#(st/emit! (dcm/go-to-dashboard-members)))
on-invite
(mf/use-fn
@ -97,10 +92,11 @@
[:> button* {:variant "primary"
:on-click on-invite}
(tr "onboarding.choice.team-up.invite-members")]]
[:button {:class (stl/css :close)
:on-click on-close'
:aria-label (tr "labels.close")}
close-icon]]))
[:> icon-button* {:icon i/close
:class (stl/css :close)
:variant "ghost"
:aria-label (tr "labels.close")
:on-click on-close'}]]))
(mf/defc project-item*
{::mf/private true}
@ -120,9 +116,9 @@
dstate (mf/deref refs/dashboard-local)
edit-id (:project-for-edit dstate)
local (mf/use-state {:menu-open false
:menu-pos nil
:edition (= (:id project) edit-id)})
local (mf/use-state {:edition (= (:id project) edit-id)})
menu-open* (mf/use-state false)
context-menu-open* (mf/use-state false)
[rowref limit]
(hooks/use-dynamic-grid-item-width)
@ -144,24 +140,11 @@
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(let [client-position (dom/get-client-position event)
position (if (and (nil? (:y client-position)) (nil? (:x client-position)))
(let [target-element (dom/get-target event)
points (dom/get-bounding-rect target-element)
y (:top points)
x (:left points)]
(gpt/point x y))
client-position)]
(swap! local assoc
:menu-open true
:menu-pos position))))
on-menu-close
(mf/use-fn #(swap! local assoc :menu-open false))
(swap! menu-open* not)))
on-edit-open
(mf/use-fn #(swap! local assoc :edition true))
(mf/use-fn
#(swap! local assoc :edition true))
on-edit
(mf/use-fn
@ -194,7 +177,14 @@
(fn [_]
(create-file "dashboard:grid-header-plus-button")))
on-import
file-input
(mf/use-ref nil)
on-import-click
(mf/use-fn
#(dom/click (mf/ref-val file-input)))
on-finish-import
(mf/use-fn
(mf/deps project-id team-id)
(fn []
@ -204,14 +194,14 @@
(dd/clear-selected-files))))
handle-create-click
(mf/use-callback
(mf/use-fn
(mf/deps on-create-click)
(fn [event]
(when (kbd/enter? event)
(on-create-click event))))
handle-menu-click
(mf/use-callback
(mf/use-fn
(mf/deps on-menu-click)
(fn [event]
(when (kbd/enter? event)
@ -225,15 +215,22 @@
[:& inline-edition {:content (:name project)
:on-end on-edit
:max-length 250}]
[:h2 {:on-click on-nav
:class (stl/css :project-name)
:title (if (:is-default project)
(tr "labels.drafts")
(:name project))
:on-context-menu (when can-edit on-menu-click)}
(if (:is-default project)
(tr "labels.drafts")
(:name project))])
[:> context-menu* {:aria-label (tr "dashboard.options")
:is-disabled (not can-edit)
:on-open-change #(reset! context-menu-open* %)
:trigger
(mf/html
[:h2 {:on-click on-nav
:class (stl/css :project-name)
:title (if (:is-default project)
(tr "labels.drafts")
(:name project))}
(if (:is-default project)
(tr "labels.drafts")
(:name project))])}
[:> project-menu-items* {:project project
:on-edit on-edit-open
:on-import-click on-import-click}]])
[:div {:class (stl/css :info-wrapper)}
@ -246,40 +243,40 @@
[:span {:class (stl/css :recent-files-row-title-info)} (str ", " time)])]
[:div {:class (stl/css-case :project-actions true
:pinned-project (:is-pinned project))}
:pinned-project (:is-pinned project)
:is-force-display (or (deref menu-open*)
(deref context-menu-open*)))}
(when-not (:is-default project)
[:> pin-button* {:class (stl/css :pin-button)
:is-pinned (:is-pinned project)
:on-click toggle-pin
:tab-index 0}])
[:> icon-button* {:icon i/pin
:variant "ghost"
:aria-label (tr "dashboard.pin-unpin")
:aria-pressed (:is-pinned project)
:on-click toggle-pin
:tab-index 0}])
(when ^boolean can-edit
[:button {:class (stl/css :add-file-btn)
:on-click on-create-click
:title (tr "dashboard.new-file")
:aria-label (tr "dashboard.new-file")
:data-testid "project-new-file"
:on-key-down handle-create-click}
add-icon])
[:> icon-button* {:icon i/add
:variant "ghost"
:aria-label (tr "dashboard.new-file")
:on-click on-create-click
:on-key-down handle-create-click}])
(when ^boolean can-edit
[:button {:class (stl/css :options-btn)
:on-click on-menu-click
:title (tr "dashboard.options")
:aria-label (tr "dashboard.options")
:data-testid "project-options"
:on-key-down handle-menu-click}
menu-icon])]
(when ^boolean can-edit
[:> project-menu*
{:project project
:show (:menu-open @local)
:left (+ 24 (:x (:menu-pos @local)))
:top (:y (:menu-pos @local))
:on-edit on-edit-open
:on-close on-menu-close
:on-import on-import}])]
[:> project-menu* {:project project
:is-open (deref menu-open*)
:on-open-change #(reset! menu-open* %)
:on-edit on-edit-open
:on-import-click on-import-click
:placement "bottom start"
:trigger
(mf/html
[:> icon-button* {:icon i/menu
:variant "ghost"
:aria-label (tr "dashboard.options")
:aria-pressed (deref menu-open*)
:data-testid "project-options"
:on-click on-menu-click
:on-key-down handle-menu-click}])}])]]
(when (and (> limit 0)
(> file-count limit))
@ -289,8 +286,18 @@
:on-key-down (fn [event]
(when (kbd/enter? event)
(on-nav)))}
[:span {:class (stl/css :placeholder-label)} (tr "dashboard.show-all-files")]
show-more-icon])]]
(tr "dashboard.show-all-files")
[:> icon* {:icon-id i/arrow-right}]])]]
;; Kept mounted for as long as this row is, shared by both menu
;; instances above: the popover each renders inside really unmounts its
;; content on close (unlike the old context-menu-a11y, which just hid
;; it), and selecting "Import" closes the menu in the same tick — a ref
;; owned inside either popover could already be gone by the time its
;; own click handler fires.
[:> udi/import-form* {:ref file-input
:project-id project-id
:on-finish-import on-finish-import}]
[:div {:class (stl/css :grid-container) :ref rowref}
(if ^boolean empty?

View File

@ -46,8 +46,7 @@
position: relative;
&:hover,
&:focus,
&:focus-within {
&:has(:focus-visible) {
--actions-opacity: 1;
}
}
@ -56,6 +55,14 @@
--actions-opacity: 1;
}
// Set while either the "..." menu or the title's right-click menu is open —
// its popover can portal outside .dashboard-project-row, so :hover/:focus-
// within alone stop applying the moment the pointer leaves the row, even
// though the menu itself is still open.
.is-force-display {
--actions-opacity: 1;
}
.projects-container {
display: grid;
grid-auto-rows: min-content;
@ -118,23 +125,7 @@
display: flex;
opacity: var(--actions-opacity);
margin-inline-start: var(--sp-xxxl);
}
.add-file-btn,
.options-btn {
@extend %button-tertiary;
block-size: $sz-32;
inline-size: $sz-32;
margin: 0 var(--sp-s);
padding: var(--sp-s);
}
.add-icon,
.menu-icon {
@extend %button-icon;
stroke: var(--icon-foreground);
gap: var(--sp-l);
}
.grid-container {
@ -168,13 +159,6 @@
}
}
.show-more-icon {
block-size: $sz-16;
inline-size: $sz-16;
fill: none;
stroke: var(--show-more-color);
}
// Team hero
.team-hero {
background-color: var(--color-background-tertiary);
@ -226,25 +210,9 @@
}
.close {
--close-icon-foreground-color: var(--icon-foreground);
position: absolute;
top: var(--sp-xl);
inset-inline-end: var(--sp-xxl);
inline-size: var(--sp-xxl);
background-color: transparent;
border: none;
cursor: pointer;
&:hover {
--close-icon-foreground-color: var(--button-icon-foreground-color-selected);
}
}
.close-icon {
@extend %button-icon;
stroke: var(--close-icon-foreground-color);
right: var(--sp-xl);
}
.img-wrapper {

View File

@ -29,7 +29,7 @@
[app.main.ui.dashboard.check-updates :as dcu]
[app.main.ui.dashboard.comments :refer [comments-icon* comments-section]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.project-menu :refer [project-menu*]]
[app.main.ui.dashboard.project-menu :refer [project-menu-items*]]
[app.main.ui.dashboard.subscription :refer [dashboard-cta*
get-subscription-type
menu-team-icon*
@ -41,6 +41,7 @@
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]]
[app.main.ui.ds.layout.menu :refer [context-menu*]]
[app.main.ui.hooks :as hooks :refer [use-focus-timer-ref]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.nitrate.nitrate-form]
@ -113,9 +114,7 @@
edit-id (:project-for-edit dstate)
local* (mf/use-state
#(do {:menu-open false
:menu-pos nil
:edition? (= (:id item) edit-id)
#(do {:edition? (= (:id item) edit-id)
:dragging? false}))
local (deref local*)
@ -139,18 +138,6 @@
(st/emit! (dcm/go-to-dashboard-files :project-id project-id)))))
on-menu-click
(mf/use-fn
(fn [event]
(let [position (dom/get-client-position event)]
(dom/prevent-default event)
(swap! local* assoc
:menu-open true
:menu-pos position))))
on-menu-close
(mf/use-fn #(swap! local* assoc :menu-open false))
on-edit-open
(mf/use-fn #(swap! local* assoc :edition? true))
@ -203,30 +190,26 @@
mdata {:on-success on-drop-success}]
(st/emit! (dd/move-files (with-meta data mdata)))))))]
[:*
[:li {:tab-index "0"
:class (stl/css-case :project-element true
:sidebar-nav-item true
:current is-selected
:dragging (:dragging? local))
:on-click on-click
:on-key-down on-key-down
:on-double-click on-edit-open
:on-context-menu on-menu-click
:on-drag-enter on-drag-enter
:on-drag-over on-drag-over
:on-drag-leave on-drag-leave
:on-drop on-drop}
(if (:edition? local)
[:& inline-edition {:content (:name item)
:on-end on-edit}]
[:span {:class (stl/css :element-title)} (:name item)])]
[:> project-menu* {:project item
:show (:menu-open local)
:left (:x (:menu-pos local))
:top (:y (:menu-pos local))
:on-edit on-edit-open
:on-close on-menu-close}]]))
[:> context-menu* {:aria-label (tr "dashboard.options")
:trigger
(mf/html
[:li {:tab-index "0"
:class (stl/css-case :project-element true
:sidebar-nav-item true
:current is-selected
:dragging (:dragging? local))
:on-click on-click
:on-key-down on-key-down
:on-double-click on-edit-open
:on-drag-enter on-drag-enter
:on-drag-over on-drag-over
:on-drag-leave on-drag-leave
:on-drop on-drop}
(if (:edition? local)
[:& inline-edition {:content (:name item)
:on-end on-edit}]
[:span {:class (stl/css :element-title)} (:name item)])])}
[:> project-menu-items* {:project item :on-edit on-edit-open}]]))
(mf/defc sidebar-search*
{::mf/private true}

View File

@ -25,6 +25,7 @@
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.foundations.utilities.token.token-status :refer [token-status-icon*
token-status-list]]
[app.main.ui.ds.layout.menu :refer [menu* menu-item* menu-separator* sub-menu* context-menu*]]
[app.main.ui.ds.layout.modal :refer [modal* modal-header* modal-content* modal-footer*]]
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
[app.main.ui.ds.notifications.actionable :refer [actionable*]]
@ -88,6 +89,11 @@
:ModalHeader modal-header*
:ModalContent modal-content*
:ModalFooter modal-footer*
:Menu menu*
:MenuItem menu-item*
:MenuSeparator menu-separator*
:SubMenu sub-menu*
:ContextMenu context-menu*
:set-default-translations
(fn [data]

View File

@ -0,0 +1,129 @@
{ /* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS SUBSIDIARY SL */ }
import { Canvas, Meta } from "@storybook/addon-docs/blocks";
import * as ContextMenu from "./context_menu.stories";
<Meta title="Layout/Context Menu" />
# Context Menu
A context menu displays a list of actions or options tied to a specific area of the interface, opened with a right click (or long press on touch) instead of clicking a visible trigger button. It's positioned at the pointer, growing down and to the right when there's room, and flipping to grow upward when it isn't.
## Example
### Default
<Canvas of={ContextMenu.Default} />
---
# Usage
```clojure
[:> context-menu*
{:aria-label (tr "workspace.shape.menu.title")
:on-action (fn [key] (handle-action key))
:trigger [:> layer-row* {:shape shape}]}
[:> menu-item* {:id "rename"} "Rename"]
[:> menu-item* {:id "duplicate"} "Duplicate"]
[:> menu-separator*]
[:> menu-item* {:id "delete"} "Delete"]]
```
`trigger` is the area that responds to a right click — it can be any content, not just a button; it's rendered as-is (no extra box around it) and only gains a right-click listener. The menu itself is entirely self-contained: opening, positioning, and closing (selection, **Escape**, outside click) are all handled internally, no `is-open` plumbing required from the caller.
---
# Context menu props
## trigger
The area that opens the menu on right click. Rendered without adding any wrapping box to the layout.
Type: React element
## aria-label
Accessible name for the menu, read by screen readers. Required — a context menu has no visible trigger button to derive a label from.
Type: string
## on-action
Callback invoked with the selected item's `id` when an item is chosen.
Type: function
## placement
Controls where the menu grows from the click point.
Options
"top", "top start", "top end", "bottom", "bottom start" (default), "bottom end", "left", "left top", "left bottom", "right", "right top", "right bottom"
The menu flips to the opposite vertical side automatically when there isn't enough room in the preferred direction — e.g. `"bottom start"` (grows right and down) becomes `"top start"` (grows right and up) near the bottom of the viewport.
## is-disabled
Prevents the context menu from opening.
Default: false
## on-open-change
Called whenever the menu opens or closes. The context menu owns its open state — unlike Menu, no `is-open` plumbing is required to use it — this is only a notification, e.g. to keep a trigger's own hover-only affordances visible for as long as the menu stays open.
Type: function
## max-width
Caps how wide the menu (and every flyout submenu nested in it) can grow. The existing minimum width still wins if it's larger than this.
Type: number (pixels) | string (any CSS length)
Default: 250
## is-dense
Shrinks every item — this menu's own and every nested flyout submenu's — to a 28px row, for lists dense enough that the default 32px adds up.
Default: false
## class
Additional CSS class applied to the menu.
---
# MenuItem props
Same as the [Menu](/docs/layout-menu--docs) component's `menu-item*` and `menu-separator*`.
---
# Accessibility
The context menu automatically provides:
- Accessible `menu`/`menuitem` semantics
- Full keyboard navigation (arrow keys, Home/End, typeahead)
- Focus management and restoration when closed
- Dismissal with **Escape** and outside click
- Long-press support on touch devices, where right click doesn't exist
---
# Best practices
Use a context menu for:
- Actions tied to a specific item (a layer, a file, a row) that don't need a persistently visible trigger
Avoid using a context menu for:
- The *only* way to reach an action — right click isn't discoverable; pair it with a visible menu trigger (see [Menu](/docs/layout-menu--docs)) or keyboard shortcut for the same actions when possible
- A long list of unrelated actions — keep it scoped to the item that was clicked

View File

@ -0,0 +1,115 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
import Components from "@target/components";
import {
fireEvent,
screen,
userEvent,
waitFor,
within,
expect,
} from "storybook/test";
const { ContextMenu, MenuItem, MenuSeparator } = Components;
const ContextMenuWrapper = ({ children, ...props }) => {
return (
<ContextMenu
{...props}
trigger={
<div
style={{
display: "grid",
placeItems: "center",
inlineSize: "16rem",
blockSize: "10rem",
border: "1px dashed var(--color-background-quaternary)",
borderRadius: "8px",
color: "var(--color-foreground-secondary)",
}}
>
Right click here
</div>
}
>
{children}
</ContextMenu>
);
};
export default {
title: "Layout/Context Menu",
component: ContextMenuWrapper,
args: {
"aria-label": "Item actions",
placement: "bottom start",
onAction: (key) => console.log("action", key),
children: (
<>
<MenuItem id="rename">Rename</MenuItem>
<MenuItem id="duplicate">Duplicate</MenuItem>
<MenuSeparator />
<MenuItem id="delete">Delete</MenuItem>
</>
),
},
argTypes: {
placement: {
control: "select",
options: [
"top",
"top start",
"top end",
"bottom",
"bottom start",
"bottom end",
"left",
"left top",
"left bottom",
"right",
"right top",
"right bottom",
],
},
isDisabled: { control: "boolean" },
},
parameters: {
controls: { exclude: ["trigger", "children"] },
},
render: ({ ...args }) => <ContextMenuWrapper {...args} />,
};
export const Default = {};
export const Disabled = {
args: {
isDisabled: true,
},
};
const openChangeCalls = [];
export const TestOnOpenChangeReportsState = {
args: {
onOpenChange: (isOpen) => openChangeCalls.push(isOpen),
},
play: async ({ canvasElement, step }) => {
openChangeCalls.length = 0;
const trigger = within(canvasElement).getByText("Right click here");
await step("Right-clicking the trigger reports it open", async () => {
fireEvent.contextMenu(trigger);
await screen.findByRole("menu");
await waitFor(() => expect(openChangeCalls).toEqual([true]));
});
await step("Escape reports it closed", async () => {
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(openChangeCalls).toEqual([true, false]));
});
},
};

View File

@ -0,0 +1,136 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.ui.ds.layout.menu
(:require
["@penpot/ui/menu" :as menu]
[app.common.data :as d]
[rumext.v2 :as mf]))
(def ^:private schema:menu
[:map
[:class {:optional true} [:maybe :string]]
[:is-open {:optional true} [:maybe :boolean]]
[:on-open-change {:optional true} [:maybe fn?]]
[:trigger {:optional true} [:maybe :any]]
[:placement {:optional true}
[:maybe [:enum "top" "top start" "top end"
"bottom" "bottom start" "bottom end"
"left" "left top" "left bottom"
"right" "right top" "right bottom"]]]
[:on-action {:optional true} [:maybe fn?]]
[:max-width {:optional true} [:maybe [:or :int :string]]]
[:is-dense {:optional true} [:maybe :boolean]]])
(mf/defc menu*
{::mf/schema schema:menu}
[{:keys [class is-open on-open-change trigger placement on-action max-width is-dense children] :rest props}]
(let [placement (d/nilv placement "bottom start")
props
(mf/spread-props props
{:class class
:is-open is-open
:on-open-change on-open-change
:trigger trigger
:placement placement
:on-action on-action
:max-width max-width
:is-dense is-dense})]
[:> menu/Menu props
children]))
(def ^:private schema:menu-item
[:map
[:id {:optional true} [:maybe [:or :string :int]]]
[:class {:optional true} [:maybe :string]]
[:is-disabled {:optional true} [:maybe :boolean]]
[:on-action {:optional true} [:maybe fn?]]
[:text-value {:optional true} [:maybe :string]]])
(mf/defc menu-item*
{::mf/schema schema:menu-item}
[{:keys [id class is-disabled on-action text-value children] :rest props}]
(let [props
(mf/spread-props props
{:id id
:class class
:is-disabled is-disabled
:on-action on-action
:text-value text-value})]
[:> menu/MenuItem props
children]))
(def ^:private schema:sub-menu
[:map
[:id {:optional true} [:maybe [:or :string :int]]]
[:class {:optional true} [:maybe :string]]
[:trigger {:optional true} [:maybe :any]]
[:is-disabled {:optional true} [:maybe :boolean]]
[:text-value {:optional true} [:maybe :string]]
[:on-action {:optional true} [:maybe fn?]]
[:variant {:optional true} [:maybe [:enum "flyout" "drilldown"]]]
[:max-width {:optional true} [:maybe [:or :int :string]]]])
(mf/defc sub-menu*
{::mf/schema schema:sub-menu}
[{:keys [id class trigger is-disabled text-value on-action variant max-width children] :rest props}]
(let [variant (d/nilv variant "flyout")
props
(mf/spread-props props
{:id id
:class class
:trigger trigger
:is-disabled is-disabled
:text-value text-value
:on-action on-action
:variant variant
:max-width max-width})]
[:> menu/SubMenu props
children]))
(def ^:private schema:menu-separator
[:map
[:class {:optional true} [:maybe :string]]])
(mf/defc menu-separator*
{::mf/schema schema:menu-separator}
[{:keys [class] :rest props}]
(let [props (mf/spread-props props {:class class})]
[:> menu/MenuSeparator props]))
(def ^:private schema:context-menu
[:map
[:class {:optional true} [:maybe :string]]
[:aria-label :string]
[:trigger {:optional true} [:maybe :any]]
[:placement {:optional true}
[:maybe [:enum "top" "top start" "top end"
"bottom" "bottom start" "bottom end"
"left" "left top" "left bottom"
"right" "right top" "right bottom"]]]
[:is-disabled {:optional true} [:maybe :boolean]]
[:on-action {:optional true} [:maybe fn?]]
[:max-width {:optional true} [:maybe [:or :int :string]]]
[:is-dense {:optional true} [:maybe :boolean]]
[:on-open-change {:optional true} [:maybe fn?]]])
(mf/defc context-menu*
{::mf/schema schema:context-menu}
[{:keys [class aria-label trigger placement is-disabled on-action max-width is-dense on-open-change children] :rest props}]
(let [placement (d/nilv placement "bottom start")
props
(mf/spread-props props
{:class class
:aria-label aria-label
:trigger trigger
:placement placement
:is-disabled is-disabled
:on-action on-action
:max-width max-width
:is-dense is-dense
:on-open-change on-open-change})]
[:> menu/ContextMenu props
children]))

View File

@ -0,0 +1,246 @@
{ /* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS SUBSIDIARY SL */ }
import { Canvas, Meta } from "@storybook/addon-docs/blocks";
import * as Menu from "./menu.stories";
<Meta title="Layout/Menu" />
# Menu
A menu displays a list of actions or options that a user can choose from. It opens next to a trigger element and closes on selection, **Escape**, or an outside click. Use it to build dropdowns and context menus.
## Example
### Default
<Canvas of={Menu.Default} />
### With a submenu
<Canvas of={Menu.WithSubMenu} />
### With a drilldown submenu
<Canvas of={Menu.WithDrilldownSubMenu} />
### Dense
<Canvas of={Menu.Dense} />
### With a max width
<Canvas of={Menu.WithMaxWidth} />
---
# Usage
```clojure
(let [open* (mf/use-state false)
open (deref open*)
trigger-ref (mf/use-ref nil)
on-ref (mf/use-fn (fn [node] (mf/set-ref-val! trigger-ref node)))
on-open (mf/use-fn (fn [] (reset! open* true)))
on-open-change (mf/use-fn (fn [open] (reset! open* open)))
on-action (mf/use-fn (fn [key] (handle-action key)))]
[:> menu*
{:is-open open
:on-open-change on-open-change
:trigger (mf/html
[:> button* {:variant "secondary"
:on-ref on-ref
:on-click on-open}
"Open menu"])
:on-action on-action}
[:> menu-item* {:id "rename"} "Rename"]
[:> menu-item* {:id "duplicate"} "Duplicate"]
[:> menu-separator*]
[:> menu-item* {:id "delete"} "Delete"]])
```
`is-open` and `on-open-change` are controlled by the caller: the trigger's `on-click` opens the menu, and `on-open-change` reports closes from selection, **Escape**, or an outside click. For a context menu, skip `trigger`/`on-click` and drive `is-open` from your own `on-context-menu` handler instead.
---
# Menu props
## trigger
Element that opens the menu.
Type: React element
## is-open
Controls whether the menu is open.
Type: boolean
## on-open-change
Callback invoked whenever the open state changes.
Type: function
## on-action
Callback invoked with the selected item's `id` when an item is chosen.
Type: function
## placement
Controls where the menu opens relative to its trigger.
Options
"top", "top start", "top end", "bottom", "bottom start" (default), "bottom end", "left", "left top", "left bottom", "right", "right top", "right bottom"
## max-width
Caps how wide the menu (and every flyout submenu nested in it) can grow. The existing minimum width still wins if it's larger than this.
Type: number (pixels) | string (any CSS length)
Default: 250
## is-dense
Shrinks every item — this menu's own and every nested flyout submenu's — to a 28px row, for lists dense enough that the default 32px adds up.
Default: false
## class
Additional CSS class applied to the menu.
---
# MenuItem props
## id
Unique identifier for the item, passed to `on-action` when selected.
Type: string | number
## is-disabled
Prevents the item from being selected.
Default: false
## on-action
Callback invoked when this specific item is selected.
Type: function
## text-value
Plain-text representation of the item, used for typeahead. Required when the item's content isn't a plain string.
Type: string
## class
Additional CSS class applied to the item.
---
# SubMenu props
A `sub-menu*` nests a further list of items behind one item, opening on hover or when navigated into with the keyboard. Use it in place of `menu-item*` for that item:
```clojure
[:> sub-menu* {:trigger "Share" :on-action (fn [key] (handle-action key))}
[:> menu-item* {:id "share-link"} "Copy link"]
[:> menu-item* {:id "share-email"} "Send by email"]]
```
Selecting any item inside a submenu closes the whole menu, not just that submenu.
## variant
`"flyout"` (default) opens a nested popover next to the trigger item. `"drilldown"` replaces the parent menu's content with this submenu's items and adds a back item — use it for deep or wide trees, e.g. move-to-project's team → project nesting. Nested drilldowns keep drilling into the same list, which is never sized smaller than the root level it was entered from.
```clojure
[:> sub-menu* {:trigger "Move to" :variant "drilldown"}
[:> menu-item* {:id "project-a"} "Project A"]
[:> sub-menu* {:trigger "Other team" :variant "drilldown"}
[:> menu-item* {:id "project-b"} "Project B"]]]
```
Options
"flyout" (default), "drilldown"
## trigger
Label shown for the item that opens the submenu.
Type: React element
## id
Unique identifier for the submenu trigger item.
Type: string | number
## is-disabled
Prevents the submenu from being opened.
Default: false
## on-action
Callback invoked with the selected item's `id` for actions inside this submenu. Ignored when `variant` is `"drilldown"` — those items use the parent menu's `on-action` instead.
Type: function
## text-value
Plain-text representation of the trigger, used for typeahead. Required when `trigger` isn't a plain string.
Type: string
## max-width
Only meaningful for the `"flyout"` variant: its nested popover is its own menu, independent of the root's. A `"drilldown"` submenu has no popover of its own to size — it renders into the root's, sized by the root Menu/ContextMenu's own `max-width` instead.
Type: number (pixels) | string (any CSS length)
Default: 250
## class
Additional CSS class applied to the submenu's item list.
---
# Accessibility
The menu automatically provides:
- Accessible `menu`/`menuitem` semantics
- Full keyboard navigation (arrow keys, Home/End, typeahead)
- Focus management and restoration when closed
- Dismissal with **Escape** and outside click
---
# Best practices
Use a menu for:
- A list of actions tied to a trigger button (dropdown)
- A contextual list of actions tied to a right click (context menu)
Avoid using a menu for:
- Long forms or free-text input
- Navigation between pages (use a link/nav component instead)
- A single toggleable option (use a switch or checkbox instead)

View File

@ -0,0 +1,437 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
import * as React from "react";
import Components from "@target/components";
import {
userEvent,
fireEvent,
within,
screen,
waitFor,
expect,
} from "storybook/test";
const { Menu, MenuItem, MenuSeparator, SubMenu, Button } = Components;
const MenuWrapper = ({ children, ...props }) => {
const [open, setOpen] = React.useState(props.isOpen ?? false);
React.useEffect(() => {
setOpen(props.isOpen ?? false);
}, [props.isOpen]);
return (
<Menu
{...props}
isOpen={open}
onOpenChange={setOpen}
trigger={
// Toggles rather than always opening, the way a real trigger does
// (the dashboard's own is a swap! on its open state) an open menu
// that closes on the trigger's own pointerdown would reopen here.
<Button variant="secondary" onClick={() => setOpen((open) => !open)}>
Open menu
</Button>
}
>
{children}
</Menu>
);
};
export default {
title: "Layout/Menu",
component: MenuWrapper,
args: {
placement: "bottom start",
onAction: (key) => console.log("action", key),
children: (
<>
<MenuItem id="rename">Rename</MenuItem>
<MenuItem id="duplicate">Duplicate</MenuItem>
<MenuSeparator />
<MenuItem id="delete">Delete</MenuItem>
</>
),
},
argTypes: {
placement: {
control: "select",
options: [
"top",
"top start",
"top end",
"bottom",
"bottom start",
"bottom end",
"left",
"left top",
"left bottom",
"right",
"right top",
"right bottom",
],
},
maxWidth: {
control: { type: "number" },
},
isDense: {
control: { type: "boolean" },
},
},
parameters: {
controls: { exclude: ["isOpen", "onOpenChange", "trigger", "children"] },
},
render: ({ ...args }) => <MenuWrapper {...args} />,
};
export const Default = {};
export const WithDisabledItem = {
args: {
children: (
<>
<MenuItem id="rename">Rename</MenuItem>
<MenuItem id="duplicate" isDisabled>
Duplicate
</MenuItem>
<MenuSeparator />
<MenuItem id="delete">Delete</MenuItem>
</>
),
},
};
const subMenuActionCalls = [];
const subMenuChildren = (
<>
<MenuItem id="rename">Rename</MenuItem>
<SubMenu trigger="Share" onAction={(key) => subMenuActionCalls.push(key)}>
<MenuItem id="share-link">Copy link</MenuItem>
<MenuItem id="share-email">Send by email</MenuItem>
</SubMenu>
<MenuSeparator />
<MenuItem id="delete">Delete</MenuItem>
</>
);
const drilldownChildren = (
<>
<MenuItem id="rename">Rename</MenuItem>
<MenuItem id="duplicate">Duplicate</MenuItem>
<MenuSeparator />
<SubMenu trigger="Move to" variant="drilldown">
<MenuItem id="project-a">Project A</MenuItem>
<MenuItem id="project-b">Project B</MenuItem>
<SubMenu trigger="Other team" variant="drilldown">
<SubMenu trigger="Team 1" variant="drilldown">
<MenuItem id="team-1-project-a">Project A</MenuItem>
<MenuItem id="team-1-project-b">Project B</MenuItem>
</SubMenu>
<SubMenu trigger="Team 2" variant="drilldown">
<MenuItem id="team-2-project-a">Project A</MenuItem>
</SubMenu>
</SubMenu>
</SubMenu>
<MenuSeparator />
<MenuItem id="delete">Delete</MenuItem>
</>
);
export const WithSubMenu = {
args: { children: subMenuChildren },
};
export const WithDrilldownSubMenu = {
args: { children: drilldownChildren },
};
export const Dense = {
args: { isDense: true },
};
export const WithMaxWidth = {
args: {
maxWidth: 160,
children: (
<>
<MenuItem id="rename">Rename this file completely</MenuItem>
<MenuItem id="duplicate">Duplicate</MenuItem>
<MenuSeparator />
<MenuItem id="delete">Delete</MenuItem>
</>
),
},
};
export const Placement = {
args: {
placement: "right",
},
decorators: [
// Absolutely-positioned + transform centering, rather than flex
// align-items, because the trigger's own align-self: start (needed so
// it doesn't get stretched by a real flex/grid ancestor elsewhere)
// would otherwise override a flex parent's centering here too.
(Story) => (
<div style={{ position: "relative", minHeight: "60vh" }}>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
<Story />
</div>
</div>
),
],
};
// The popover portals out of the story root, so the menu itself is only
// reachable through screen (document-wide); the trigger stays in the canvas.
const getTrigger = (canvasElement) =>
within(canvasElement).getByRole("button", { name: /open menu/i });
const expectMenuClosed = () =>
waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
export const TestTriggerTogglesMenuClosed = {
play: async ({ canvasElement, step }) => {
const trigger = getTrigger(canvasElement);
await step("Clicking the trigger opens the menu", async () => {
await userEvent.click(trigger);
await screen.findByRole("menu");
});
// The trigger sits outside the popover, so a naive outside-click dismiss
// closes on its pointerdown and lets the click reopen it.
await step("Clicking the trigger again closes the menu", async () => {
await userEvent.click(trigger);
await expectMenuClosed();
});
},
};
export const TestFlyoutSubMenuIsNotOutside = {
args: { children: subMenuChildren },
play: async ({ canvasElement, step }) => {
const trigger = getTrigger(canvasElement);
subMenuActionCalls.length = 0;
await step("Hovering the submenu trigger opens the flyout", async () => {
await userEvent.click(trigger);
await userEvent.hover(
await screen.findByRole("menuitem", { name: "Share" }),
);
await screen.findByRole("menuitem", { name: "Copy link" });
});
// react-aria portals a SubmenuTrigger's popover into the root popover's
// container, making the flyout a sibling of the root popover rather than
// a descendant. Testing containment against the root popover alone
// therefore counts a press anywhere in the flyout as an outside click and
// dismisses the whole menu.
await step("A press inside the flyout does not dismiss", async () => {
const [, flyout] = screen.getAllByRole("menu");
fireEvent.pointerDown(flyout);
await waitFor(() =>
expect(
screen.getByRole("menuitem", { name: "Copy link" }),
).toBeInTheDocument(),
);
});
await step("Selecting a flyout item fires its action", async () => {
await userEvent.click(
screen.getByRole("menuitem", { name: "Copy link" }),
);
await waitFor(() => expect(subMenuActionCalls).toEqual(["share-link"]));
});
await step("Selecting it closes the whole tree", expectMenuClosed);
},
};
export const TestDrilldownNavigatesAndReturns = {
args: { children: drilldownChildren },
play: async ({ canvasElement, step }) => {
const trigger = getTrigger(canvasElement);
await step("Drilling in replaces the menu's own content", async () => {
await userEvent.click(trigger);
await userEvent.click(
await screen.findByRole("menuitem", { name: "Move to" }),
);
await screen.findByRole("menuitem", { name: "Project A" });
expect(
screen.queryByRole("menuitem", { name: "Rename" }),
).not.toBeInTheDocument();
});
await step("The back item returns to the level entered from", async () => {
await userEvent.click(screen.getByRole("menuitem", { name: /move to/i }));
await screen.findByRole("menuitem", { name: "Rename" });
expect(
screen.queryByRole("menuitem", { name: "Project A" }),
).not.toBeInTheDocument();
});
},
};
export const TestDrilldownResetsBetweenOpens = {
args: { children: drilldownChildren },
play: async ({ canvasElement, step }) => {
const trigger = getTrigger(canvasElement);
await step("Drill into a submenu, then close the menu", async () => {
await userEvent.click(trigger);
await userEvent.click(
await screen.findByRole("menuitem", { name: "Move to" }),
);
await screen.findByRole("menuitem", { name: "Project A" });
await userEvent.keyboard("{Escape}");
await expectMenuClosed();
});
await step("Reopening starts back at the root level", async () => {
await userEvent.click(trigger);
await screen.findByRole("menuitem", { name: "Rename" });
expect(
screen.queryByRole("menuitem", { name: "Project A" }),
).not.toBeInTheDocument();
});
},
};
export const TestClosesOnEscapeAndOutsideClick = {
play: async ({ canvasElement, step }) => {
const trigger = getTrigger(canvasElement);
await step("Escape closes the menu", async () => {
await userEvent.click(trigger);
await screen.findByRole("menu");
await userEvent.keyboard("{Escape}");
await expectMenuClosed();
});
await step("A click outside closes the menu", async () => {
await userEvent.click(trigger);
await screen.findByRole("menu");
await userEvent.click(document.body);
await expectMenuClosed();
});
},
};
export const TestMaxWidthCapsPopoverWidth = {
args: {
maxWidth: 160,
children: (
<>
<MenuItem id="rename">Rename this file completely</MenuItem>
<MenuSeparator />
<MenuItem id="delete">Delete</MenuItem>
</>
),
},
play: async ({ canvasElement, step }) => {
const trigger = getTrigger(canvasElement);
await step("The popover never grows past maxWidth", async () => {
await userEvent.click(trigger);
const menu = await screen.findByRole("menu");
await waitFor(() =>
expect(menu.getBoundingClientRect().width).toBeLessThanOrEqual(160),
);
});
},
};
export const TestDrilldownFreezesRootEdgeAndSizesToContent = {
args: {
placement: "top start",
// Deliberately taller at the root than the level drilled into, so a
// regression (re-running react-aria's own flip/collision positioning
// against the drilled-in level's own shorter content, rather than
// freezing the edge already resolved for the root) would show up either
// as the popover jumping to the opposite edge, or as it staying put but
// padded out to the root's own height instead of sizing to its content.
children: (
<>
<MenuItem id="rename">Rename this file completely</MenuItem>
<MenuItem id="duplicate">Duplicate</MenuItem>
<MenuItem id="restore">Restore from trash</MenuItem>
<MenuSeparator />
<SubMenu trigger="Move to" variant="drilldown">
<MenuItem id="project-a">A</MenuItem>
</SubMenu>
</>
),
},
decorators: [
// Plenty of room above the trigger, none below forces "top start" to
// actually resolve with the popover's bottom edge pinned near the
// trigger, instead of react-aria flipping it back to "bottom" for lack
// of room above, which would defeat the point of this test.
(Story) => (
<div style={{ position: "relative", height: "100vh" }}>
<div style={{ position: "absolute", bottom: 8, left: 8 }}>
<Story />
</div>
</div>
),
],
play: async ({ canvasElement, step }) => {
const trigger = getTrigger(canvasElement);
let rootBottom, rootHeight;
await step("Opening resolves the popover above the trigger", async () => {
await userEvent.click(trigger);
const menu = await screen.findByRole("menu");
const popover = menu.closest("[data-placement]");
await waitFor(() => expect(popover.dataset.placement).toBe("top"));
({ bottom: rootBottom, height: rootHeight } =
popover.getBoundingClientRect());
});
await step(
"Drilling into a shorter level keeps the same bottom edge and shrinks to fit",
async () => {
await userEvent.click(
screen.getByRole("menuitem", { name: "Move to" }),
);
const menu = await screen.findByRole("menu");
await screen.findByRole("menuitem", { name: "A" });
const popover = menu.closest("[data-placement]");
await waitFor(() => {
const rect = popover.getBoundingClientRect();
// Within a couple of px, not exact: sub-pixel layout rounding
// between the two measurements, not a regression an actual
// regression (react-aria re-flipping back to "bottom") would move
// this by the popover's full height, hundreds of px.
expect(Math.abs(rect.bottom - rootBottom)).toBeLessThan(2);
expect(rect.height).toBeLessThan(rootHeight);
});
},
);
},
};