mirror of
https://github.com/penpot/penpot.git
synced 2026-09-08 19:59:54 -04:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d89f80a046 | ||
|
|
70cf3b743d | ||
|
|
003a484540 | ||
|
|
cfa937acde | ||
|
|
67630c8c38 | ||
|
|
33f44dab73 |
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
SubMenu,
|
||||
ContextMenu,
|
||||
} from "./lib/menu/Menu";
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -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
|
||||
|
||||
@@ -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}]])
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]))
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user