Compare commits

...
Author SHA1 Message Date
Eva Marco d89f80a046 🐛 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
2026-09-08 16:26:22 +02:00
Eva Marco 70cf3b743d 🐛 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
2026-09-08 14:58:07 +02:00
Eva Marco 003a484540 ♻️ 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
2026-09-08 14:56:32 +02:00
Eva Marco cfa937acde 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
2026-09-08 13:07:36 +02:00
Eva Marco 67630c8c38 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
2026-09-08 13:07:22 +02:00
Eva Marco 33f44dab73 🎉 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
2026-09-08 12:24:50 +02:00
16 changed files with 1739 additions and 313 deletions

No files matched your search

+4
View File
@@ -11,6 +11,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": {
+8 -1
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";
@@ -0,0 +1,172 @@
@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;
// react-aria sets the Popover's own max-height (inline, on .popover, our
// direct parent) to whatever actually fits between the trigger and the
// viewport edge. A fixed px value here ignores that entirely: .popover
// has no overflow of its own, so once its computed space is smaller
// than our fixed cap we'd render straight past it (and off the edge of
// the viewport, with nothing left to scroll it back into view). inherit
// picks up .popover's own computed max-height instead, so we always
// fit — at the cost of no longer capping how tall the menu gets when
// there's plenty of room, since react-aria's own value is the only
// thing to inherit here.
max-block-size: inherit;
padding: 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;
// Matches the other DS dropdowns (options-dropdown.scss et al.), and the
// legacy context-menu-a11y menu this replaces.
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-disabled] {
color: var(--color-foreground-disabled);
cursor: default;
}
}
.subMenuItem {
justify-content: space-between;
&[data-open] {
background-color: var(--color-background-quaternary);
}
}
.subMenuLabel {
display: flex;
align-items: center;
gap: var(--sp-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subMenuChevron {
flex: 0 0 auto;
inline-size: $sz-16;
block-size: $sz-16;
color: var(--color-foreground-secondary);
}
// The item a drilldown SubMenu (see Menu.tsx) shows at the top of its
// content, above a separator, to return to the level it was entered from.
.backItem {
color: var(--color-foreground-secondary);
}
.backLabel {
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);
}
// Unlike .contextMenuTrigger below, this needs its own real bounding box —
// the Popover positions itself against this element's DOM rect — so it
// can't be display: contents. A flex/grid item is blockified and, by
// default, stretched to fill its container's cross axis regardless of this
// display value, which would inflate that box past the actual trigger and
// throw off the Popover's position; place-self: start start opts this out
// so the box always shrink-wraps its content instead.
.menuTrigger {
display: inline-block;
place-self: start start;
}
.contextMenuTrigger {
display: contents;
}
// Invisible 0x0 anchor moved to the pointer position on right-click; the
// Popover positions itself relative to this instead of the trigger's own
// (possibly large) bounding box.
.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;
}
}
+560
View File
@@ -0,0 +1,560 @@
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";
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).
function useMenuNavigation(children: ReactNode, isOpen: boolean | undefined) {
const [stack, setStack] = useState<NavigationLevel[]>([]);
const nextLevelKey = useRef(0);
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.backLabel}>{current.label}</span>
</MenuItem>
<MenuSeparator />
</>
)}
{current ? current.content : children}
</Fragment>
</MenuNavigationContext.Provider>
);
return content;
}
// 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,
) {
useEffect(() => {
if (!isOpen) return;
const onPointerDown = (e: PointerEvent) => {
if (!popoverRef.current?.contains(e.target as Node)) close();
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [isOpen, popoverRef, close]);
}
interface MenuProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
trigger?: ReactNode;
children: ReactNode;
placement?: Placement;
className?: string;
onAction?: (key: Key) => void;
}
// 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,
}: MenuProps) {
const triggerRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const triggerId = useId();
const [shouldSkipAnimation, setShouldSkipAnimation] = useState(false);
const navigationContent = 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);
useCloseOnOutsideClick(isOpen, popoverRef, close);
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}
// 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
>
<RACMenu
aria-labelledby={triggerId}
className={`${styles.menu} ${className ?? ""}`}
onAction={onAction}
onClose={() => handleOpenChange(false)}
autoFocus="first"
>
{navigationContent}
</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 ?? ""}`}
>
{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";
}
// 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",
}: SubMenuProps) {
const closeController = useContext(MenuCloseContext);
const navigation = useContext(MenuNavigationContext);
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} ${className ?? ""}`}
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;
}
// 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,
}: ContextMenuProps) {
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [shouldSkipAnimation, setShouldSkipAnimation] = useState(false);
const navigationContent = useMenuNavigation(children, isOpen);
useEffect(() => {
if (isOpen) setShouldSkipAnimation(false);
}, [isOpen]);
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`;
}
setIsOpen(true);
},
[isDisabled],
);
const closeController: MenuCloseController = {
closeAll: () => {
setShouldSkipAnimation(true);
setIsOpen(false);
},
shouldSkipAnimation,
};
// 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);
}, []);
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 isNonModal comment on Menu's own Popover.
isNonModal
>
<RACMenu
aria-label={ariaLabel}
className={`${styles.menu} ${className ?? ""}`}
onAction={onAction}
onClose={() => handleOpenChange(false)}
autoFocus="first"
>
{navigationContent}
</RACMenu>
</Popover>
</MenuCloseContext.Provider>
);
}
+7
View File
@@ -0,0 +1,7 @@
export {
Menu,
MenuItem,
MenuSeparator,
SubMenu,
ContextMenu,
} from "./lib/menu/Menu";
+34 -29
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,
},
},
}));
+1 -32
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
+132 -125
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,25 +53,64 @@
{}
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.
;; The popover this renders inside only mounts file-menu-items* while it's
;; open, so a plain component-local fetch would re-run — and start every
;; single open from an empty "Move to" state — every time. Caching the last
;; response here means only the first open of a session (per tab) pays that
;; cost; later opens render with the previous list immediately while a
;; fresh fetch updates it in the background.
(defonce ^:private teams-cache (atom nil))
(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)
multi? (> file-count 1)
current-team-id (mf/use-ctx ctx/current-team-id)
teams* (mf/use-state nil)
teams* (mf/use-state #(deref teams-cache))
teams (deref teams*)
current-team (get teams current-team-id)
@@ -83,13 +120,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 +137,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 +185,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 +206,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
@@ -226,124 +258,99 @@
(mf/with-effect []
(->> (rp/cmd! :get-all-projects)
(rx/map group-by-team)
(rx/subs! #(reset! teams* %))))
(rx/subs! #(do (reset! teams-cache %)
(reset! teams* %)))))
(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}
(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]}]
[:> menu*
{:is-open is-open
:on-open-change on-open-change
:placement "bottom end"
:trigger trigger}
[:> file-menu-items* {:files files
:on-edit on-edit
:navigate navigate
:origin origin
:can-edit can-edit
:can-restore can-restore}]])
+119 -126
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,12 @@
[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.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 +268,16 @@
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*)
;; The menu can open before this file has actually been added to
;; selected-files yet (the very first click/right-click on it both
;; selects it and opens the menu in the same event, but selection is
;; applied via a store dispatch that only lands on the next render)
;; — fall back to just this file so file-menu-items* never sees an
;; empty list.
menu-files (if (seq selected-files) (vals selected-files) [file])
selected? (contains? selected-files file-id)
selected-num (count selected-files)
@@ -283,10 +289,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 +314,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 +343,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 +372,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 +407,116 @@
(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
:trigger
(mf/html
[: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)}]])}]])]
(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 +538,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 +579,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)
@@ -198,6 +198,15 @@ $thumbnail-default-height: px2rem(168);
&.is-force-display {
opacity: 1;
}
// The DS Menu component wraps its trigger in its own div with
// align-self: start (so it shrink-wraps instead of stretching in an
// arbitrary parent) — override that back to center here, since this
// container's own align-items: center above has no effect on a child
// that sets its own align-self.
> div {
align-self: center;
}
}
.project-thumbnail-icon {
+6
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]
@@ -0,0 +1,109 @@
{ /* 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
## 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
@@ -0,0 +1,85 @@
// 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";
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,
},
};
@@ -0,0 +1,126 @@
;; 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-macros
[app.main.style :as stl])
(: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?]]])
(mf/defc menu*
{::mf/schema schema:menu}
[{:keys [class is-open on-open-change trigger placement on-action 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})]
[:> 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"]]]])
(mf/defc sub-menu*
{::mf/schema schema:sub-menu}
[{:keys [id class trigger is-disabled text-value on-action variant 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})]
[:> 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?]]])
(mf/defc context-menu*
{::mf/schema schema:context-menu}
[{:keys [class aria-label trigger placement is-disabled on-action 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})]
[:> menu/ContextMenu props
children]))
+205
View File
@@ -0,0 +1,205 @@
{ /* 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} />
---
# Usage
```clojure
(let [open* (mf/use-state false)]
[:> menu*
{:is-open @open*
:on-open-change #(reset! open* %)
:trigger (mf/html
[:> button* {:variant "secondary"
:on-click #(reset! open* true)}
"Open menu"])
:on-action (fn [key] (handle-action key))}
[:> menu-item* {:id "rename"} "Rename"]
[:> menu-item* {:id "duplicate"} "Duplicate"]
[:> menu-separator*]
[:> menu-item* {:id "delete"} "Delete"]])
```
`is-open`/`on-open-change` must be driven by the caller: the trigger button opens the menu through its own `on-click`, and `on-open-change` reports back closes from selection, **Escape**, or an outside click. This mirrors the modal's usage — the menu (like the modal) doesn't assume its trigger is a react-aria-aware pressable element, so it can't wire an automatic click-to-open behavior on an arbitrary trigger by itself. For a context menu triggered by a right click, skip `trigger`/`on-click` and call `on-open-change`/set `is-open` directly from the `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"
## 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, like a desktop context menu. `"drilldown"` instead replaces the parent menu's own content with this submenu's items and adds a back item above them — use it for a list too deep or too wide for a chain of flyouts, e.g. move-to-project's team → project nesting. A drilldown submenu nested inside another drilldown submenu keeps drilling into the same list, so arbitrarily deep trees stay navigable one screen at a time.
```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` when an item inside this submenu is chosen. Separate from the parent menu's own `on-action` — it isn't called for submenu selections. Ignored when `variant` is `"drilldown"`: those items sit inside the same list as everything else, so the parent menu's own `on-action` already sees them selected.
Type: function
## text-value
Plain-text representation of the trigger, used for typeahead. Required when `trigger` isn't a plain string.
Type: string
## 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)
@@ -0,0 +1,162 @@
// 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";
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={
<Button variant="secondary" onClick={() => setOpen(true)}>
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",
],
},
},
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>
</>
),
},
};
export const WithSubMenu = {
args: {
children: (
<>
<MenuItem id="rename">Rename</MenuItem>
<SubMenu
trigger="Share"
onAction={(key) => console.log("sub-menu action", key)}
>
<MenuItem id="share-link">Copy link</MenuItem>
<MenuItem id="share-email">Send by email</MenuItem>
</SubMenu>
<MenuSeparator />
<MenuItem id="delete">Delete</MenuItem>
</>
),
},
};
export const WithDrilldownSubMenu = {
args: {
children: (
<>
<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 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>
),
],
};