diff --git a/frontend/package.json b/frontend/package.json index e39dbf1443..214904d58c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,7 +48,7 @@ "watch": "exit 0", "watch:app": "pnpm run clear:shadow-cache && pnpm run clear:wasm && pnpm run build:wasm && concurrently --kill-others-on-fail \"pnpm run watch:app:assets\" \"pnpm run watch:app:main\" \"pnpm run watch:app:libs\"", "watch:storybook": "pnpm run build:storybook:assets && concurrently --kill-others-on-fail \"storybook dev -p 3451 -h 0.0.0.0 --no-open\" \"node ./scripts/watch-storybook.js\"", - "postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build)" + "postinstall": "(cd ../plugins/libs/plugins-runtime; pnpm install; pnpm run build) && (cd packages/ui && pnpm run build)" }, "devDependencies": { "@penpot/draft-js": "link:packages/draft-js", diff --git a/frontend/packages/ui/package.json b/frontend/packages/ui/package.json index bd2351e197..c6642774e5 100644 --- a/frontend/packages/ui/package.json +++ b/frontend/packages/ui/package.json @@ -12,6 +12,10 @@ "import": "./dist/modal.js", "types": "./dist/modal.d.ts" }, + "./menu": { + "import": "./dist/menu.js", + "types": "./dist/menu.d.ts" + }, "./style.css": "./dist/style.css" }, "scripts": { diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index 4c5c366053..443a99cb4c 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -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"; diff --git a/frontend/packages/ui/src/lib/menu/Menu.module.scss b/frontend/packages/ui/src/lib/menu/Menu.module.scss new file mode 100644 index 0000000000..cecc92d885 --- /dev/null +++ b/frontend/packages/ui/src/lib/menu/Menu.module.scss @@ -0,0 +1,167 @@ +@use "ds/_borders" as *; +@use "ds/_sizes" as *; +@use "ds/_utils" as *; +@use "ds/spacing" as *; +@use "ds/typography" as *; +@use "ds/mixins" as *; + +.popover { + z-index: var(--z-index-dropdown); + + &[data-entering] { + animation: popover-fade-in 0.15s ease-out; + } + + &[data-exiting] { + animation: popover-fade-out 0.1s ease-in; + } +} + +.menu { + @include custom-scrollbar; + + display: flex; + flex-direction: column; + gap: var(--sp-xxs); + min-inline-size: $sz-160; + max-block-size: inherit; + padding-block: var(--sp-xs); + padding-inline: var(--sp-xxs); + margin: 0; + border-radius: $br-8; + border: $b-1 solid var(--color-background-quaternary); + background-color: var(--color-background-tertiary); + overflow-y: auto; + outline: none; + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); +} + +.menuItem { + @include use-typography("body-small"); + + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--sp-s); + block-size: $sz-32; + padding-inline: var(--sp-s); + border-radius: $br-6; + color: var(--color-foreground-primary); + cursor: pointer; + outline: none; + + &[data-hovered], + &[data-focused] { + background-color: var(--color-background-quaternary); + } + + &[data-focus-visible] { + background-color: var(--color-background-tertiary); + outline: $b-1 solid var(--color-accent-primary); + } + + &[data-disabled] { + color: var(--color-foreground-secondary); + background-color: var(--color-background-tertiary); + cursor: default; + } +} + +// Cascades into every flyout SubMenu's own .menu too (Menu.tsx threads the +// density down through context so nested flyouts don't need it repeated). +.menuDense { + .menuItem { + block-size: $sz-28; + } +} + +.subMenuItem { + justify-content: space-between; + + &[data-open] { + background-color: var(--color-background-quaternary); + } +} + +.subMenuLabel { + display: flex; + flex: 1 1 auto; + min-inline-size: 0; + align-items: center; + gap: var(--sp-s); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subMenuChevron { + flex: 0 0 auto; + inline-size: $sz-12; + block-size: $sz-12; + color: var(--color-foreground-secondary); +} + +.backItem { + color: var(--color-foreground-secondary); +} + +// Shared by the back item's own label and by MenuItem's plain-string +// children (see MenuItem in Menu.tsx) — anything else (icons, a SubMenu +// trigger's own subMenuLabel/subMenuChevron pair) handles its own +// truncation instead of being wrapped in this a second time. +.menuItemLabel { + flex: 1 1 auto; + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.separator { + flex: 0 0 auto; + block-size: $b-1; + margin-block: var(--sp-xxs); + margin-inline: 0; + border: none; + background-color: var(--color-background-quaternary); +} + +.menuTrigger { + display: inline-block; + place-self: start start; +} + +.contextMenuTrigger { + display: contents; +} + +.contextMenuAnchor { + position: fixed; + inset-block-start: 0; + inset-inline-start: 0; + inline-size: 0; + block-size: 0; + pointer-events: none; +} + +@keyframes popover-fade-in { + from { + opacity: 0; + transform: scale(0.98); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes popover-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/frontend/packages/ui/src/lib/menu/Menu.tsx b/frontend/packages/ui/src/lib/menu/Menu.tsx new file mode 100644 index 0000000000..eb2697d8b8 --- /dev/null +++ b/frontend/packages/ui/src/lib/menu/Menu.tsx @@ -0,0 +1,678 @@ +import { + Menu as RACMenu, + MenuItem as RACMenuItem, + Popover, + Separator, + SubmenuTrigger, +} from "react-aria-components"; +import type { Key } from "@react-types/shared"; +import { + createContext, + Fragment, + useCallback, + useContext, + useEffect, + useId, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type ReactNode, + type RefObject, +} from "react"; +import { createPortal } from "react-dom"; +import styles from "./Menu.module.scss"; + +// A number is treated as a pixel count, so callers can pass either a plain +// number (320) or any other valid CSS length ("20rem"). +type CssLength = number | string; + +function cssLength(value: CssLength | undefined): string | undefined { + if (value == null) return undefined; + return typeof value === "number" ? `${value}px` : value; +} + +// SubMenu's own nested flyout popover renders an independent RACMenu (see +// below), so a density set on the root Menu/ContextMenu wouldn't otherwise +// reach it — this carries it down so every level of a menu, flyouts +// included, stays visually consistent without repeating the prop on each +// SubMenu. +const MenuDensityContext = createContext(false); + +type Placement = + | "top" + | "top start" + | "top end" + | "bottom" + | "bottom start" + | "bottom end" + | "left" + | "left top" + | "left bottom" + | "right" + | "right top" + | "right bottom"; + +// SubMenu needs a way to close the whole tree (not just its own level) when +// one of its items is selected. MenuTrigger normally provides this via a +// shared RootMenuTriggerStateContext, but Menu/ContextMenu don't use +// MenuTrigger (see below), so that context is never established — this +// fills the same role explicitly. +// +// closing both the root and the submenu popovers at once (rather than just +// the submenu, which is the only thing react-aria itself does on select) +// has to skip their closing CSS animation: react-aria detects animation end +// via each popover's own `getAnimations()`, and closing both simultaneously +// leaves their animations permanently stuck at "running" — neither ever +// settles, so neither popover ever actually unmounts. shouldSkipAnimation +// sidesteps that by closing instantly instead, shared here so the root's +// own Popover and every nested SubMenu's Popover skip it together. +interface MenuCloseController { + closeAll: () => void; + shouldSkipAnimation: boolean; +} +const MenuCloseContext = createContext(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(null); + +interface NavigationLevel { + // Distinct per push, so switching levels always fully unmounts the + // previous level's items and mounts the new ones, rather than updating + // them in place — react-stately's Collection requires each item's id to + // stay stable across an update, but the back item's label and every item + // underneath it genuinely change identity between levels, so this forces + // a remount instead (React.Fragment key) rather than an update. + key: string; + label: ReactNode; + content: ReactNode; +} + +// Renders the back item + separator for whatever level of the navigation +// stack is current, and provides drillIn to the rest of `children`. Shared +// between Menu and ContextMenu, which each keep their own stack (a +// drilldown inside one popover has no bearing on the other). +// +// A drilled-in level replaces the root's content in the same popover, which +// otherwise lets react-aria re-run its own flip/collision positioning +// against the new (possibly shorter/narrower) content — jumping the popover +// to a different edge mid-navigation, even though it never moved from the +// caller's point of view. isDrilledIn lets the caller disable react-aria's +// live repositioning for as long as any level is drilled in, so the edge it +// already resolved for the root stays pinned and only the opposite edge +// grows or shrinks with each level's actual content. +function useMenuNavigation(children: ReactNode, isOpen: boolean | undefined) { + const [stack, setStack] = useState([]); + const nextLevelKey = useRef(0); + const menuRef = useRef(null); + + useEffect(() => { + if (!isOpen) setStack([]); + }, [isOpen]); + + const drillIn = useCallback((label: ReactNode, content: ReactNode) => { + nextLevelKey.current += 1; + const key = `level-${nextLevelKey.current}`; + setStack((prev) => [...prev, { key, label, content }]); + }, []); + + const drillBack = useCallback(() => { + setStack((prev) => prev.slice(0, -1)); + }, []); + + const current = stack[stack.length - 1]; + + const content = ( + + + {current && ( + <> + + + {current.label} + + + + )} + {current ? current.content : children} + + + ); + + return { content, menuRef, isDrilledIn: !!current }; +} + +// Menu/ContextMenu deliberately don't use react-aria-components' own +// MenuTrigger (see the comments on each below), so they also don't get its +// built-in RootMenuTriggerStateContext coordination that would otherwise +// close one open instance when another opens elsewhere — e.g. right-clicking +// file B while file A's context menu is still open should close A's, the +// way a native OS context menu would, but each Menu/ContextMenu here is an +// independent instance (one per row) with no shared ancestor to hold that +// state. A window-level broadcast fills the same role without requiring +// one: opening announces this instance's id, and every other mounted +// instance closes itself on hearing an id that isn't its own. +const GLOBAL_OPEN_EVENT = "penpot-ds-menu-open"; + +function useSoloOpen(isOpen: boolean | undefined, close: () => void) { + const id = useId(); + + useEffect(() => { + if (!isOpen) return; + window.dispatchEvent( + new CustomEvent(GLOBAL_OPEN_EVENT, { detail: id }), + ); + }, [isOpen, id]); + + useEffect(() => { + const onOtherOpen = (e: Event) => { + if ((e as CustomEvent).detail !== id) close(); + }; + window.addEventListener(GLOBAL_OPEN_EVENT, onOtherOpen); + return () => window.removeEventListener(GLOBAL_OPEN_EVENT, onOtherOpen); + }, [id, close]); +} + +// isNonModal (see the comment on Menu's own Popover below) has a side +// effect beyond the inert-marking it's there to avoid: react-aria's +// Popover only wires up its own click-outside-closes behavior when it +// considers itself dismissable, which — for a plain Menu/ContextMenu (not +// a SubmenuTrigger's nested flyout) — isNonModal forces off entirely, and +// that isn't something a prop can turn back on independently. This +// restores it directly: any pointerdown that lands outside the popover's +// own content closes it, exactly like a normal dismissable popover would. +function useCloseOnOutsideClick( + isOpen: boolean | undefined, + popoverRef: RefObject, + close: () => void, + ignoreRef?: RefObject, +) { + useEffect(() => { + if (!isOpen) return; + const onPointerDown = (e: PointerEvent) => { + const target = e.target as Node; + // A root Popover wraps its own content in a display:contents div and + // portals every SubmenuTrigger's nested popover into that same div, so + // a flyout submenu is a *sibling* of this popover, not a descendant. + // Testing the parent instead treats the whole popover group as inside. + const group = popoverRef.current?.parentElement ?? popoverRef.current; + if (group?.contains(target)) return; + if (ignoreRef?.current?.contains(target)) return; + close(); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [isOpen, popoverRef, close, ignoreRef]); +} + +interface MenuProps { + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + trigger?: ReactNode; + children: ReactNode; + placement?: Placement; + className?: string; + onAction?: (key: Key) => void; + // Caps how wide the popover (and every flyout SubMenu nested in it) can + // grow. A number is a pixel count; the existing min-inline-size still + // wins if it's larger than this. + // @default 250 + maxWidth?: CssLength; + // Shrinks every item (this menu's own and every nested flyout SubMenu's) + // to a 28px row, for lists dense enough that the default 32px adds up. + isDense?: boolean; +} + +// MenuTrigger normally locates the trigger's DOM node by requiring its +// child to be "pressable" (call usePress() itself, as react-aria-components' +// own Button does). Penpot's DS buttons are plain rumext components that +// don't do that, so MenuTrigger silently gets a null triggerRef and the +// Popover falls back to positioning at (0, 0). As with ContextMenu below, +// this drives the trigger ref explicitly instead of relying on that +// detection. +export function Menu({ + isOpen, + onOpenChange, + trigger, + children, + placement = "bottom start", + className, + onAction, + maxWidth = 250, + isDense = false, +}: MenuProps) { + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const triggerId = useId(); + const [shouldSkipAnimation, setShouldSkipAnimation] = useState(false); + const { + content: navigationContent, + menuRef, + isDrilledIn, + } = useMenuNavigation(children, isOpen); + + useEffect(() => { + if (isOpen) setShouldSkipAnimation(false); + }, [isOpen]); + + const closeController: MenuCloseController = { + closeAll: () => { + setShouldSkipAnimation(true); + onOpenChange?.(false); + }, + shouldSkipAnimation, + }; + + // Closing (any reason: outside click, Escape, item select) skips the exit + // animation — the trigger can be asked to reopen this same Popover at any + // moment (another click on it), and if that lands while the previous + // instance is still mid exit-fade, the Popover can fail to reopen or + // briefly show both. Skipping the exit keeps the DOM state unambiguous by + // the time any subsequent open request comes in. + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) setShouldSkipAnimation(true); + onOpenChange?.(open); + }, + [onOpenChange], + ); + + const close = useCallback(() => handleOpenChange(false), [handleOpenChange]); + useSoloOpen(isOpen, close); + // The trigger is excluded: it owns the open state, so closing here on the + // pointerdown of a click meant to toggle the menu shut would let that + // click's own handler read the already-false state and reopen it. + useCloseOnOutsideClick(isOpen, popoverRef, close, triggerRef); + + return ( + +
+ {trigger} +
+ !triggerRef.current?.contains(el)} + > + handleOpenChange(false)} + autoFocus="first" + > + + {navigationContent} + + + +
+ ); +} + +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 ( + + { + // Only a plain string is wrapped for truncation: a SubMenu trigger's + // children (subMenuLabel + subMenuChevron, see SubMenuTriggerContent + // below) already truncate on their own, and wrapping that Fragment + // in a second nowrap/ellipsis box here would clip the chevron along + // with the label instead of leaving it visible. + typeof children === "string" ? ( + {children} + ) : ( + children + ) + } + + ); +} + +function SubMenuTriggerContent({ trigger }: { trigger: ReactNode }) { + return ( + <> + {trigger} + + + ); +} + +interface SubMenuProps { + id?: Key; + trigger: ReactNode; + children: ReactNode; + isDisabled?: boolean; + textValue?: string; + className?: string; + onAction?: (key: Key) => void; + // "flyout" (default) opens a nested popover next to this item, like a + // desktop context menu. "drilldown" replaces the parent menu's own + // content with this submenu's items and adds a back item, for trees too + // deep/wide for a chain of flyouts (e.g. move-to-project's team -> + // project nesting). onAction is ignored in drilldown mode: its items sit + // in the same RACMenu as everything else, so the root Menu/ContextMenu's + // own onAction already sees them selected. + variant?: "flyout" | "drilldown"; + // Only meaningful for the "flyout" variant: its nested popover is its own + // RACMenu (see below), independent of the root's. A "drilldown" submenu + // has no popover of its own to size — it renders straight into the root's, + // which is sized by the root Menu/ContextMenu's own maxWidth instead. + // @default 250 + maxWidth?: CssLength; +} + +// The submenu's own trigger is always a MenuItem, which — unlike the +// arbitrary trigger passed to Menu/ContextMenu above — is a real +// react-aria-components element that forwards its ref properly. So +// SubmenuTrigger's built-in ref/positioning detection (the thing that +// doesn't work for Penpot's own DS buttons) works fine here, and this can +// use the plain react-aria-components composition. +export function SubMenu({ + id, + trigger, + children, + isDisabled, + textValue, + className, + onAction, + variant = "flyout", + maxWidth = 250, +}: SubMenuProps) { + const closeController = useContext(MenuCloseContext); + const navigation = useContext(MenuNavigationContext); + const isDense = useContext(MenuDensityContext); + + if (variant === "drilldown") { + return ( + navigation?.drillIn(trigger, children)} + > + + + ); + } + + return ( + + + + + + { + onAction?.(key); + closeController?.closeAll(); + }} + autoFocus="first" + > + {children} + + + + ); +} + +interface MenuSeparatorProps { + className?: string; +} + +export function MenuSeparator({ className }: MenuSeparatorProps) { + return ; +} + +interface ContextMenuProps { + trigger: ReactNode; + children: ReactNode; + "aria-label": string; + placement?: Placement; + className?: string; + isDisabled?: boolean; + onAction?: (key: Key) => void; + // See the same props on Menu above. + maxWidth?: CssLength; + isDense?: boolean; + // ContextMenu owns its open state (see the comment below on why it isn't + // controlled like Menu is) — this only notifies the caller when that state + // changes, e.g. to keep a trigger's own hover-only affordances visible for + // as long as this stays open. + onOpenChange?: (isOpen: boolean) => void; +} + +// MenuTrigger's built-in press/context-menu detection only works when its +// child calls usePress() itself (e.g. react-aria-components' own Button). +// Penpot's own DS buttons aren't react-aria components, so instead of +// relying on that, this drives everything explicitly: a plain onContextMenu +// handler opens a standalone Popover anchored to an invisible element moved +// to the click position. +export function ContextMenu({ + trigger, + children, + "aria-label": ariaLabel, + placement = "bottom start", + className, + isDisabled, + onAction, + maxWidth = 250, + isDense = false, + onOpenChange, +}: ContextMenuProps) { + const anchorRef = useRef(null); + const popoverRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [shouldSkipAnimation, setShouldSkipAnimation] = useState(false); + const { + content: navigationContent, + menuRef, + isDrilledIn, + } = useMenuNavigation(children, isOpen); + + useEffect(() => { + if (isOpen) setShouldSkipAnimation(false); + }, [isOpen]); + + // See the same handleOpenChange in Menu above: closing always skips the + // exit animation so a right-click landing while the previous instance is + // still mid exit-fade can't race it into failing to reopen. + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) setShouldSkipAnimation(true); + setIsOpen(open); + onOpenChange?.(open); + }, + [onOpenChange], + ); + + const handleContextMenu = useCallback( + (e: ReactMouseEvent) => { + if (isDisabled) return; + e.preventDefault(); + const anchor = anchorRef.current; + if (anchor) { + anchor.style.left = `${e.clientX}px`; + anchor.style.top = `${e.clientY}px`; + } + handleOpenChange(true); + }, + [isDisabled, handleOpenChange], + ); + + const closeController: MenuCloseController = { + // handleOpenChange already sets shouldSkipAnimation on close. + closeAll: () => handleOpenChange(false), + shouldSkipAnimation, + }; + + const close = useCallback(() => handleOpenChange(false), [handleOpenChange]); + useSoloOpen(isOpen, close); + useCloseOnOutsideClick(isOpen, popoverRef, close); + + return ( + +
+ {trigger} +
+ {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. +
, + document.body, + )} + + handleOpenChange(false)} + autoFocus="first" + > + + {navigationContent} + + + + + ); +} diff --git a/frontend/packages/ui/src/menu.ts b/frontend/packages/ui/src/menu.ts new file mode 100644 index 0000000000..0ac6fc3f68 --- /dev/null +++ b/frontend/packages/ui/src/menu.ts @@ -0,0 +1,7 @@ +export { + Menu, + MenuItem, + MenuSeparator, + SubMenu, + ContextMenu, +} from "./lib/menu/Menu"; diff --git a/frontend/packages/ui/vite.config.mts b/frontend/packages/ui/vite.config.mts index 5ec50406f4..9519e70987 100644 --- a/frontend/packages/ui/vite.config.mts +++ b/frontend/packages/ui/vite.config.mts @@ -1,20 +1,19 @@ /// -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, }, }, })); diff --git a/frontend/playwright/ui/specs/project-menu.spec.js b/frontend/playwright/ui/specs/project-menu.spec.js new file mode 100644 index 0000000000..c8dec33dc7 --- /dev/null +++ b/frontend/playwright/ui/specs/project-menu.spec.js @@ -0,0 +1,128 @@ +import { test, expect } from "@playwright/test"; +import DashboardPage from "../pages/DashboardPage"; + +test.beforeEach(async ({ page }) => { + await DashboardPage.init(page); +}); + +// New Project 1 is a regular project; Drafts is the is-default pseudo-project +// (see frontend/playwright/data/dashboard/get-projects-full.json), which the +// menu itself renders with rename/duplicate/pin/move-to/delete all hidden. +async function setupTwoProjects(dashboardPage) { + await dashboardPage.mockRPC( + "get-projects?team-id=*", + "dashboard/get-projects-full.json", + ); + await dashboardPage.setupDrafts(); +} + +function projectRow(page, name) { + return page.getByRole("article").filter({ hasText: name }); +} + +test("User can open a project's options menu from the \"...\" button", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await setupTwoProjects(dashboardPage); + await dashboardPage.goToDashboard(); + + const row = projectRow(page, "New Project 1"); + await row.getByTestId("project-options").click(); + + const menu = page.getByRole("menu"); + await expect(menu.getByTestId("project-rename")).toBeVisible(); + await expect(menu.getByTestId("project-duplicate")).toBeVisible(); + await expect(menu.getByTestId("project-pin")).toBeVisible(); + await expect(menu.getByTestId("project-delete")).toBeVisible(); + // project-move-to is covered separately below: it only renders once there + // is at least one other team to move to, which the default single-team + // fixture used here doesn't have. +}); + +test("User can open a project's options menu by right-clicking its title", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await setupTwoProjects(dashboardPage); + await dashboardPage.goToDashboard(); + + const row = projectRow(page, "New Project 1"); + await row.getByText("New Project 1").click({ button: "right" }); + + const menu = page.getByRole("menu"); + await expect(menu.getByTestId("project-rename")).toBeVisible(); + await expect(menu.getByTestId("project-delete")).toBeVisible(); +}); + +test("The default Drafts project has a limited options menu", async ({ + page, +}) => { + const dashboardPage = new DashboardPage(page); + await setupTwoProjects(dashboardPage); + await dashboardPage.goToDashboard(); + + const row = projectRow(page, "Drafts"); + await row.getByTestId("project-options").click(); + + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + await expect(menu.getByTestId("project-rename")).toHaveCount(0); + await expect(menu.getByTestId("project-duplicate")).toHaveCount(0); + await expect(menu.getByTestId("project-pin")).toHaveCount(0); + await expect(menu.getByTestId("project-move-to")).toHaveCount(0); + await expect(menu.getByTestId("project-delete")).toHaveCount(0); +}); + +test("User can rename a project from the options menu", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await setupTwoProjects(dashboardPage); + await dashboardPage.goToDashboard(); + + const row = projectRow(page, "New Project 1"); + await row.getByTestId("project-options").click(); + await page.getByTestId("project-rename").click(); + + // Not scoped to `row` (renaming swaps the title for an , whose + // value doesn't count as text content, so the `hasText` filter used to + // find the row in the first place would no longer match it) and matched + // by value rather than role, since the dashboard's own search field is + // also a textbox. + await expect(page.locator('input[value="New Project 1"]')).toBeVisible(); +}); + +test("User can delete a project from the options menu", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await setupTwoProjects(dashboardPage); + await dashboardPage.goToDashboard(); + + const row = projectRow(page, "New Project 1"); + await row.getByTestId("project-options").click(); + await page.getByTestId("project-delete").click(); + + await expect( + page.getByRole("heading", { name: "Delete project" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Delete project" }), + ).toBeVisible(); +}); + +test("The move-to submenu lists the user's other teams", async ({ page }) => { + const dashboardPage = new DashboardPage(page); + await setupTwoProjects(dashboardPage); + await DashboardPage.mockRPC( + page, + "get-teams", + "logged-in-user/get-teams-complete.json", + ); + await dashboardPage.goToDashboard(); + + const row = projectRow(page, "New Project 1"); + await row.getByTestId("project-options").click(); + await page.getByTestId("project-move-to").click(); + + await expect( + page.getByRole("menuitem", { name: "Second team" }), + ).toBeVisible(); +}); diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 4d9619d630..db82978768 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -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 diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index f644730dba..7a0abddea5 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -16,12 +16,10 @@ [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] - [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.context :as ctx] - [app.util.dom :as dom] + [app.main.ui.ds.layout.menu :refer [menu* menu-item* menu-separator* sub-menu*]] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) (defn- get-project-name @@ -55,18 +53,50 @@ {} projects)) -(mf/defc file-menu* - [{:keys [files on-edit on-close top left navigate origin parent-id can-edit can-restore]}] +;; The "move to" tree can be arbitrarily deep (current team's projects, then +;; every other team's own projects), so every level here uses SubMenu's +;; drilldown variant instead of a flyout: opening a chain of flyouts that +;; deep would run off-screen well before it ran out of teams. +(mf/defc move-to-items* + {::mf/private true} + [{:keys [current-projects other-teams current-team-id on-move]}] + [:* + (for [project current-projects] + [:> menu-item* {:key (get-project-id project) + :id (get-project-id project) + :on-action (on-move current-team-id (:id project))} + (get-project-name project)]) + + (when (seq other-teams) + [:> sub-menu* {:key "move-to-other-team" + :id "move-to-other-team" + :trigger (tr "dashboard.move-to-other-team") + :variant "drilldown"} + (for [team other-teams] + [:> sub-menu* {:key (get-project-id team) + :id (get-project-id team) + :trigger (get-team-name team) + :variant "drilldown"} + (for [sub-project (:projects team)] + [:> menu-item* {:key (get-project-id sub-project) + :id (get-project-id sub-project) + :on-action (on-move (:id team) (:id sub-project))} + (get-project-name sub-project)])])])]) + +;; The menu items only, with no popover of their own — shared by file-menu* +;; below (opened from the "..." button, via Menu) and by grid.cljs's own +;; right-click handling (via ContextMenu), so both triggers show the exact +;; same options. + +(mf/defc file-menu-items* + [{:keys [files on-edit navigate origin can-edit can-restore]}] (assert (seq files) "missing `files` prop") (assert (fn? on-edit) "missing `on-edit` prop") - (assert (fn? on-close) "missing `on-close` prop") (assert (boolean? navigate) "missing `navigate` prop") (let [is-lib-page? (= :libraries origin) is-search-page? (= :search origin) - top (or top 0) - left (or left 0) file (first files) file-count (count files) @@ -83,13 +113,13 @@ (:projects current-team)) on-new-tab - (fn [_] + (fn [] (st/emit! (dcm/go-to-workspace {:file-id (:id file) ::rt/new-window true}))) on-duplicate - (fn [_] + (fn [] (apply st/emit! (map dd/duplicate-file files)) (st/emit! (ntf/success (tr "dashboard.success-duplicate-file" (i18n/c file-count))))) @@ -100,8 +130,7 @@ (dd/clear-selected-files))) on-delete - (fn [event] - (dom/stop-propagation event) + (fn [] (let [num-shared (filter #(:is-shared %) files)] (if (< 0 (count num-shared)) @@ -149,7 +178,6 @@ (let [params {:ids (into #{} (map :id) files) :project-id project-id}] (fn [] - (let [num-shared (filter #(:is-shared %) files)] (if (and (< 0 (count num-shared)) (not= team-id current-team-id)) @@ -171,14 +199,11 @@ (run! #(st/emit! (dd/set-file-shared (assoc % :is-shared false))) files)) on-add-shared - (fn [event] - (dom/stop-propagation event) + (fn [] (st/emit! (dcm/show-shared-dialog (:id file) add-shared))) on-del-shared - (fn [event] - (dom/prevent-default event) - (dom/stop-propagation event) + (fn [] (st/emit! (modal/show {:type :delete-shared-libraries :origin :unpublish @@ -224,126 +249,101 @@ :on-accept accept-fn}))))] (mf/with-effect [] - (->> (rp/cmd! :get-all-projects) - (rx/map group-by-team) - (rx/subs! #(reset! teams* %)))) + (let [subs (->> (rp/cmd! :get-all-projects) + (rx/map group-by-team) + (rx/subs! #(reset! teams* %)))] + #(rx/dispose! subs))) - (mf/with-effect [on-close] - (st/emit! (ptk/data-event :dropdown/open {:id "file-menu"})) - (let [stream (->> st/stream - (rx/filter (ptk/type? :dropdown/open)) - (rx/map deref) - (rx/filter #(not= "file-menu" (:id %))) - (rx/take 1)) - subs (rx/subs! nil nil on-close stream)] - (fn [] - (rx/dispose! subs)))) + (cond + can-restore + [:* + [:> menu-item* {:id "restore-file" :on-action on-restore-immediately} + (tr "dashboard.file-menu.restore-files-option" (i18n/c file-count))] + [:> menu-item* {:id "delete-file" :on-action on-delete-immediately} + (tr "dashboard.file-menu.delete-files-permanently-option" (i18n/c file-count))]] - (let [sub-options - (concat - (for [project current-projects] - {:name (get-project-name project) - :id (get-project-id project) - :handler (on-move current-team-id (:id project))}) - (when (seq other-teams) - [{:name (tr "dashboard.move-to-other-team") - :id "move-to-other-team" - :options - (for [team other-teams] - {:name (get-team-name team) - :id (get-project-id team) - :options - (for [sub-project (:projects team)] - {:name (get-project-name sub-project) - :id (get-project-id sub-project) - :handler (on-move (:id team) - (:id sub-project))})})}])) + multi? + [:* + (when can-edit + [:> menu-item* {:id "duplicate-multi" :on-action on-duplicate :datatest-id "duplicate-multi"} + (tr "dashboard.duplicate-multi" file-count)]) - options - (if can-restore - [{:name (tr "dashboard.file-menu.restore-files-option" (i18n/c file-count)) - :id "restore-file" - :handler on-restore-immediately} - {:name (tr "dashboard.file-menu.delete-files-permanently-option" (i18n/c file-count)) - :id "delete-file" - :handler on-delete-immediately}] - (if multi? - [(when can-edit - {:name (tr "dashboard.duplicate-multi" file-count) - :id "duplicate-multi" - :handler on-duplicate}) + (when (and (or (seq current-projects) (seq other-teams)) can-edit) + [:> sub-menu* {:id "file-move-multi" :trigger (tr "dashboard.move-to-multi" file-count) :variant "drilldown"} + [:> move-to-items* {:current-projects current-projects + :other-teams other-teams + :current-team-id current-team-id + :on-move on-move}]]) - (when (and (or (seq current-projects) (seq other-teams)) can-edit) - {:name (tr "dashboard.move-to-multi" file-count) - :id "file-move-multi" - :options sub-options}) + [:> menu-item* {:id "file-binary-export-multi" :on-action on-export-binary-files} + (tr "dashboard.export-binary-multi" file-count)] - {:name (tr "dashboard.export-binary-multi" file-count) - :id "file-binary-export-multi" - :handler on-export-binary-files} + (when (and (:is-shared file) can-edit) + [:> menu-item* {:id "file-unpublish-multi" :on-action on-del-shared} + (tr "labels.unpublish-multi-files" file-count)]) - (when (and (:is-shared file) can-edit) - {:name (tr "labels.unpublish-multi-files" file-count) - :id "file-unpublish-multi" - :handler on-del-shared}) + (when (and (not is-lib-page?) can-edit) + [:* + [:> menu-separator*] + [:> menu-item* {:id "file-delete-multi" :on-action on-delete} + (tr "labels.delete-multi-files" file-count)]])] - (when (and (not is-lib-page?) can-edit) - {:name :separator} - {:name (tr "labels.delete-multi-files" file-count) - :id "file-delete-multi" - :handler on-delete})] + :else + [:* + [:> menu-item* {:id "file-open-new-tab" :on-action on-new-tab} + (tr "dashboard.open-in-new-tab")] - [{:name (tr "dashboard.open-in-new-tab") - :id "file-open-new-tab" - :handler on-new-tab} - (when (and (not is-search-page?) can-edit) - {:name (tr "labels.rename") - :id "file-rename" - :handler on-edit}) + (when (and (not is-search-page?) can-edit) + [:> menu-item* {:id "file-rename" :on-action on-edit} + (tr "labels.rename")]) - (when (and (not is-search-page?) can-edit) - {:name (tr "dashboard.duplicate") - :id "file-duplicate" - :handler on-duplicate}) + (when (and (not is-search-page?) can-edit) + [:> menu-item* {:id "file-duplicate" :on-action on-duplicate} + (tr "dashboard.duplicate")]) - (when (and (not is-lib-page?) - (not is-search-page?) - (or (seq current-projects) (seq other-teams)) - can-edit) - {:name (tr "dashboard.move-to") - :id "file-move-to" - :options sub-options}) + (when (and (not is-lib-page?) + (not is-search-page?) + (or (seq current-projects) (seq other-teams)) + can-edit) + [:> sub-menu* {:id "file-move-to" :trigger (tr "dashboard.move-to") :variant "drilldown"} + [:> move-to-items* {:current-projects current-projects + :other-teams other-teams + :current-team-id current-team-id + :on-move on-move}]]) - (when (and (not is-search-page?) - can-edit) - (if (:is-shared file) - {:name (tr "dashboard.unpublish-shared") - :id "file-del-shared" - :handler on-del-shared} - {:name (tr "dashboard.add-shared") - :id "file-add-shared" - :handler on-add-shared})) + (when (and (not is-search-page?) can-edit) + ;; Same id in both branches: :is-shared can flip while this menu + ;; instance stays mounted (the on-add-shared/on-del-shared action + ;; itself changes it), and react-stately's Collection requires an + ;; item's id to stay stable across such an update rather than swap + ;; to a differently-id'd item in the same slot. + (if (:is-shared file) + [:> menu-item* {:id "file-shared-toggle" :on-action on-del-shared} + (tr "dashboard.unpublish-shared")] + [:> menu-item* {:id "file-shared-toggle" :on-action on-add-shared} + (tr "dashboard.add-shared")])) - {:name :separator} + [:> menu-separator*] - {:name (tr "dashboard.download-binary-file") - :id "download-binary-file" - :handler on-export-binary-files} + [:> menu-item* {:id "download-binary-file" :on-action on-export-binary-files} + (tr "dashboard.download-binary-file")] - (when (and (not is-lib-page?) (not is-search-page?) can-edit) - {:name :separator}) + (when (and (not is-lib-page?) (not is-search-page?) can-edit) + [:* + [:> menu-separator*] + [:> menu-item* {:id "file-delete" :on-action on-delete} + (tr "labels.delete")]])]))) - (when (and (not is-lib-page?) (not is-search-page?) can-edit) - {:name (tr "labels.delete") - :id "file-delete" - :handler on-delete})]))] - - [:> context-menu* - {:on-close on-close - :fixed (or (not= top 0) (not= left 0)) - :show true - :min-width true - :top top - :left left - :options options - :origin parent-id}]))) +(mf/defc file-menu* + [{:keys [files on-edit is-open on-open-change trigger navigate origin can-edit can-restore is-list]}] + [:> menu* + {:is-open is-open + :on-open-change on-open-change + :placement (if is-list "bottom end" "bottom start") + :trigger trigger} + [:> file-menu-items* {:files files + :on-edit on-edit + :navigate navigate + :origin origin + :can-edit can-edit + :can-restore can-restore}]]) diff --git a/frontend/src/app/main/ui/dashboard/files.cljs b/frontend/src/app/main/ui/dashboard/files.cljs index 436691f5a1..b3318fc6f8 100644 --- a/frontend/src/app/main/ui/dashboard/files.cljs +++ b/frontend/src/app/main/ui/dashboard/files.cljs @@ -15,31 +15,31 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.dashboard.grid :refer [grid*]] + [app.main.ui.dashboard.import :as udi] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]] - [app.main.ui.dashboard.pin-button :refer [pin-button*]] [app.main.ui.dashboard.project-menu :refer [project-menu*]] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [cuerdas.core :as str] [rumext.v2 :as mf])) -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - (mf/defc header* {::mf/private true} [{:keys [project create-fn can-edit layout on-change]}] (let [project-id (:id project) local - (mf/use-state - {:menu-open false - :edition false}) + (mf/use-state {:edition false}) + + menu-open* + (mf/use-state false) on-create-click (mf/use-fn @@ -51,22 +51,27 @@ on-menu-click (mf/use-fn (fn [event] - (let [position (dom/get-client-position event)] - (dom/prevent-default event) - (swap! local assoc :menu-open true :menu-pos position)))) - - on-menu-close - (mf/use-fn #(swap! local assoc :menu-open false)) + (dom/prevent-default event) + (swap! menu-open* not))) on-edit - (mf/use-fn #(swap! local assoc :edition true :menu-open false)) + (mf/use-fn + (fn [] + (reset! menu-open* false) + (swap! local assoc :edition true))) toggle-pin (mf/use-fn (mf/deps project) #(st/emit! (dd/toggle-project-pin project))) - on-import + file-input + (mf/use-ref nil) + + on-import-click + (mf/use-fn #(dom/click (mf/ref-val file-input))) + + on-finish-import (mf/use-fn (mf/deps project-id) (fn [] @@ -101,40 +106,56 @@ :on-change on-change}] (when ^boolean can-edit - [:a {:class (stl/css :btn-secondary :btn-small :new-file) - :tab-index "0" - :on-click on-create-click - :data-testid "new-file" - :on-key-down (fn [event] - (when (kbd/enter? event) - (on-create-click event)))} + [:> button* {:variant "secondary" + :class (stl/css :new-file) + :on-click on-create-click + :on-key-down (fn [event] + (when (kbd/enter? event) + (on-create-click event))) + :tab-index "0" + :data-testid "new-file"} (tr "dashboard.new-file")]) (when-not (:is-default project) - [:> pin-button* - {:tab-index 0 - :is-pinned (:is-pinned project) - :on-click toggle-pin - :on-key-down (fn [event] (when (kbd/enter? event) (toggle-pin event)))}]) + [:> icon-button* {:icon i/pin + :variant "ghost" + :aria-label (tr "dashboard.pin-unpin") + :aria-pressed (:is-pinned project) + :tab-index 0 + :on-click toggle-pin + :on-key-down (fn [event] + (when (kbd/enter? event) + (toggle-pin event)))}]) (when ^boolean can-edit - [:div {:class (stl/css :icon) - :tab-index "0" - :on-click on-menu-click - :title (tr "dashboard.options") - :on-key-down (fn [event] - (when (kbd/enter? event) - (on-menu-click event)))} - menu-icon]) + [:* + [:> project-menu* + {:project project + :is-open (deref menu-open*) + :on-open-change #(reset! menu-open* %) + :on-edit on-edit + :on-import-click on-import-click + :placement "bottom end" + :trigger + (mf/html + [:> icon-button* {:icon i/menu + :variant "ghost" + :aria-label (tr "dashboard.options") + :aria-pressed (deref menu-open*) + :on-click on-menu-click + :on-key-down (fn [event] + (when (kbd/enter? event) + (on-menu-click event)))}])}] - (when ^boolean can-edit - [:> project-menu* {:project project - :show (:menu-open @local) - :left (- (:x (:menu-pos @local)) 180) - :top (:y (:menu-pos @local)) - :on-edit on-edit - :on-close on-menu-close - :on-import on-import}])]])) + ;; Kept mounted for as long as this header is, regardless of the + ;; menu's own open state: the popover really unmounts its content on + ;; close (unlike the old context-menu-a11y, which just hid it), and + ;; selecting "Import" closes the menu in the same tick — a ref owned + ;; inside the popover could already be gone by the time its own + ;; click handler fires. + [:> udi/import-form* {:ref file-input + :project-id project-id + :on-finish-import on-finish-import}]])]])) (mf/defc files-section* [{:keys [project team layout on-layout-change]}] diff --git a/frontend/src/app/main/ui/dashboard/files.scss b/frontend/src/app/main/ui/dashboard/files.scss index 8f69e352e9..5ab0695cad 100644 --- a/frontend/src/app/main/ui/dashboard/files.scss +++ b/frontend/src/app/main/ui/dashboard/files.scss @@ -4,43 +4,20 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; +@use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; .dashboard-container { flex: 1 0 0; - margin-right: deprecated.$s-16; + margin-inline-end: var(--sp-l); overflow-y: auto; - width: 100%; - border-top: deprecated.$s-1 solid var(--color-background-quaternary); + inline-size: 100%; + border-block-start: $b-1 solid var(--color-background-quaternary); padding-block-end: var(--sp-xxxl); - - &.dashboard-projects { - user-select: none; - } - - &.dashboard-shared { - width: calc(100vw - deprecated.$s-320); - margin-right: deprecated.$s-52; - } - - &.search { - margin-top: deprecated.$s-12; - } -} - -.new-file { - margin-inline-end: deprecated.$s-8; -} - -.menu-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); } .placeholder-placement { - margin: deprecated.$s-16 deprecated.$s-32; + margin: var(--sp-l) var(--sp-xxxl); } diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 20f9117316..b46a0c53c1 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -9,7 +9,6 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.geom.point :as gpt] [app.common.logging :as log] [app.common.time :as ct] [app.config :as cf] @@ -26,12 +25,13 @@ [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.color-bullet :as bc] - [app.main.ui.components.portal :refer [portal-on-document*]] - [app.main.ui.dashboard.file-menu :refer [file-menu*]] + [app.main.ui.dashboard.file-menu :refer [file-menu* file-menu-items*]] [app.main.ui.dashboard.import :refer [use-import-file]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.placeholder :refer [empty-grid-placeholder* loading-placeholder*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.layout.menu :refer [context-menu*]] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.hooks :as h] [app.main.worker :as mw] @@ -269,9 +269,19 @@ file-id (get file :id) - menu-pos (get state :menu-pos) - menu-open? (and (get state :menu-open) - (= file-id (:file-id state))) + menu-open* (mf/use-state false) + menu-open? (deref menu-open*) + + ;; Only act on the whole selection when this file is part of it. The + ;; selection can legitimately hold other files instead: the click that + ;; opens the menu applies its selection through a store dispatch that + ;; only lands on the next render, and toggle-file-select is a no-op + ;; across projects, so a shift-click on a file in another project + ;; never adds it. In both cases the menu must target what the user + ;; actually pointed at, not whatever happens to be selected. + menu-files (if (contains? selected-files file-id) + (vals selected-files) + [file]) selected? (contains? selected-files file-id) selected-num (count selected-files) @@ -283,10 +293,6 @@ library-view? (= origin :libraries) - on-menu-close - (mf/use-fn - #(st/emit! (dd/hide-file-menu))) - on-select (mf/use-fn (mf/deps selected? selected-num) @@ -312,7 +318,7 @@ (mf/use-fn (mf/deps selected? selected-num) (fn [event] - (st/emit! (dd/hide-file-menu)) + (reset! menu-open* false) (when can-edit (let [offset (dom/get-offset-position (dom/event->native-event event)) item-el (mf/ref-val node-ref) @@ -341,39 +347,23 @@ on-menu-click (mf/use-fn - (mf/deps file selected? menu-open?) + (mf/deps file selected?) (fn [event] (dom/stop-propagation event) - - (if menu-open? - (st/emit! (dd/hide-file-menu)) - - (do - (when-not selected? - (when-not (kbd/shift? event) - (st/emit! (dd/clear-selected-files))) - (st/emit! (dd/toggle-file-select file))) - - (let [client-position - (dom/get-client-position event) - - position - (if (and (nil? (:y client-position)) (nil? (:x client-position))) - (let [target-element (dom/get-target event) - points (dom/get-bounding-rect target-element) - y (:top points) - x (:left points)] - (gpt/point x y)) - client-position)] - - (st/emit! (dd/show-file-menu-with-position file-id position))))))) + (when-not selected? + (when-not (kbd/shift? event) + (st/emit! (dd/clear-selected-files))) + (st/emit! (dd/toggle-file-select file))) + (swap! menu-open* not))) on-context-menu (mf/use-fn - (mf/deps on-menu-click) + (mf/deps file selected?) (fn [event] - (dom/prevent-default event) - (on-menu-click event))) + (when-not selected? + (when-not (kbd/shift? event) + (st/emit! (dd/clear-selected-files))) + (st/emit! (dd/toggle-file-select file))))) edit (mf/use-fn @@ -386,9 +376,8 @@ on-edit (mf/use-fn - (mf/deps file) - (fn [event] - (dom/stop-propagation event) + (mf/deps file-id) + (fn [] (st/emit! (dd/start-edit-file-name file-id)))) on-key-down @@ -422,104 +411,115 @@ (mf/html [:div {:class (stl/css-case :project-thumbnail-actions true :is-force-display menu-open?)} - [:div {:class (stl/css :project-thumbnail-icon :menu) - :tab-index "0" - :role "button" - :aria-label (tr "dashboard.options") - :ref menu-ref - :id (dm/str file-id "-action-menu") - :on-click on-menu-click - :on-key-down on-menu-key-down} - - [:> icon* {:icon-id i/menu - :class (stl/css :menu-icon)}] - - (when (and selected? menu-open?) - ;; When the menu is open we disable events in the dashboard. We need to force pointer events - ;; so the menu can be handled - [:> portal-on-document* {} - [:> file-menu* {:files (vals selected-files) - :left (+ 24 (:x menu-pos)) - :top (:y menu-pos) - :can-edit can-edit - :navigate true - :on-edit on-edit - :on-close on-menu-close - :origin origin - :parent-id (dm/str file-id "-action-menu") - :can-restore can-restore}]])]])] + [:> file-menu* {:files menu-files + :is-open menu-open? + :on-open-change #(reset! menu-open* %) + :can-edit can-edit + :navigate true + :on-edit on-edit + :origin origin + :can-restore can-restore + :is-list list? + :trigger + (mf/html + [:> icon-button* {:icon i/menu + :variant "ghost" + :aria-label (tr "dashboard.options") + :on-click on-menu-click + :on-key-down on-menu-key-down + :tab-index "0" + :ref menu-ref + :id (dm/str file-id "-action-menu")}])}]])] (if ^boolean list? [:li {:class (stl/css-case :grid-item true :list-item true :library-item library-view?)} - [:div - {:class (stl/css-case :list-item-row true - :is-selected selected?) - :ref node-ref - :role "button" - :title (:name file) - :aria-label (:name file) - :draggable (dm/str can-edit) - :on-click on-select - :on-key-down on-key-down - :on-double-click on-navigate - :on-drag-start on-drag-start - :on-context-menu on-context-menu} + [:> context-menu* {:aria-label (tr "dashboard.options") + :trigger + (mf/html + [:div + {:class (stl/css-case :list-item-row true + :is-selected selected?) + :ref node-ref + :role "button" + :title (:name file) + :aria-label (:name file) + :draggable (dm/str can-edit) + :on-click on-select + :on-key-down on-key-down + :on-double-click on-navigate + :on-drag-start on-drag-start + :on-context-menu on-context-menu} - (if ^boolean editing? - [:& inline-edition {:content (:name file) - :on-end edit - :max-length 250}] - [:h3 {:class (stl/css :list-item-name)} (:name file)]) + (if ^boolean editing? + [:& inline-edition {:content (:name file) + :on-end edit + :max-length 250}] + [:h3 {:class (stl/css :list-item-name)} (:name file)]) - (when (and (:is-shared file) (not library-view?)) - [:span {:class (stl/css :list-item-badge) - :aria-label (tr "workspace.assets.shared-library") - :title (tr "workspace.assets.shared-library")} - [:> icon* {:icon-id i/library}]]) + (when (and (:is-shared file) (not library-view?)) + [:span {:class (stl/css :list-item-badge) + :aria-label (tr "workspace.assets.shared-library") + :title (tr "workspace.assets.shared-library")} + [:> icon* {:icon-id i/library}]]) - [:> grid-item-metadata* {:file file :layout :list}] + [:> grid-item-metadata* {:file file :layout :list}] - menu-element]] + menu-element])} + [:> file-menu-items* {:files menu-files + :can-edit can-edit + :navigate true + :on-edit on-edit + :origin origin + :can-restore can-restore}]]] [:li {:class (stl/css-case :grid-item true :project-thumbnail true :library-item library-view?)} - [:div {:class (stl/css-case :is-selected selected? - :grid-item-button true) - :ref node-ref - :role "button" - :title (:name file) - :aria-label (:name file) - :draggable (dm/str can-edit) - :on-click on-select - :on-key-down on-key-down - :on-double-click on-navigate - :on-drag-start on-drag-start - :on-context-menu on-context-menu} + [:> context-menu* {:aria-label (tr "dashboard.options") + :trigger + (mf/html + [:div {:class (stl/css-case :is-selected selected? + :grid-item-button true) + :ref node-ref + :role "button" + :title (:name file) + :aria-label (:name file) + :draggable (dm/str can-edit) + :on-click on-select + :on-key-down on-key-down + :on-double-click on-navigate + :on-drag-start on-drag-start + :on-context-menu on-context-menu} - (if ^boolean library-view? - [:> grid-item-library* {:file file - :can-restore can-restore}] - [:> grid-item-thumbnail* {:file file - :can-edit can-edit - :can-restore can-restore}]) + (if ^boolean library-view? + [:> grid-item-library* {:file file + :can-restore can-restore}] + [:> grid-item-thumbnail* {:file file + :can-edit can-edit + :can-restore can-restore}]) - (when (and (:is-shared file) (not library-view?)) - [:div {:class (stl/css :grid-item-badge)} - [:> icon* {:icon-id i/library}]]) + (when (and (:is-shared file) (not library-view?)) + [:div {:class (stl/css :grid-item-badge)} + [:> icon* {:icon-id i/library}]]) - [:div {:class (stl/css :grid-item-info)} - [:div {:class (stl/css :grid-item-meta)} - (if ^boolean editing? - [:& inline-edition {:content (:name file) - :on-end edit - :max-length 250}] - [:h3 {:class (stl/css :grid-item-title)} (:name file)]) - [:> grid-item-metadata* {:file file :layout :grid}]] + [:div {:class (stl/css :grid-item-info)} + [:div {:class (stl/css :grid-item-meta)} + (if ^boolean editing? + [:& inline-edition {:content (:name file) + :on-end edit + :max-length 250}] + [:h3 {:class (stl/css :grid-item-title)} (:name file)]) + [:> grid-item-metadata* {:file file :layout :grid}]] - menu-element]]]))) + menu-element]])} + [:> file-menu-items* {:files menu-files + :can-edit can-edit + :navigate true + :on-edit on-edit + :origin origin + :can-restore can-restore}]]]))) (mf/defc grid* [{:keys [files project origin limit create-fn can-edit selected-files can-restore layout]}] @@ -541,9 +541,6 @@ import-files (use-import-file project-id on-finish-import) - on-scroll - (mf/use-fn #(st/emit! (dd/hide-file-menu))) - on-drag-enter (mf/use-fn (fn [e] @@ -585,7 +582,6 @@ :on-drag-over on-drag-over :on-drag-leave on-drag-leave :on-drop on-drop - :on-scroll on-scroll :ref node-ref} (cond (nil? files) diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index dd23ce7128..11f25f8f86 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -187,53 +187,19 @@ $thumbnail-default-height: px2rem(168); } .project-thumbnail-actions { - align-items: center; display: flex; - block-size: 100%; + align-self: end; justify-content: center; - opacity: 0; - inset-inline-end: $sz-6; + block-size: $sz-32; inline-size: $sz-32; + margin-block-end: var(--sp-s); + opacity: 0; &.is-force-display { opacity: 1; } } -.project-thumbnail-icon { - align-items: center; - display: flex; - margin-inline-end: var(--sp-s); - margin-block-start: 0; -} - -// ─── CARD VIEW: MENU ─────────────────────────── - -.menu { - align-items: flex-end; - display: flex; - flex-direction: column; - block-size: $sz-32; - justify-content: center; - margin-inline-end: 0; - margin-block-start: var(--sp-xl); - inline-size: 100%; - - --menu-icon-color: var(--button-tertiary-foreground-color-rest); - - &:hover, - &:focus { - --menu-icon-color: var(--button-tertiary-foreground-color-hover); - } -} - -.menu-icon { - color: var(--menu-icon-color); - margin-inline-end: 0; - block-size: $sz-16; - inline-size: $sz-16; -} - // ─── LIST VIEW ───────────────────────────────── .list-item { @@ -247,22 +213,12 @@ $thumbnail-default-height: px2rem(168); // it sits inline at the end of the row. .project-thumbnail-actions { flex: 0 0 auto; + align-self: center; block-size: auto; + inline-size: auto; opacity: 1; - inline-size: auto; - } - - .project-thumbnail-icon { margin: 0; } - - .menu { - align-items: center; - flex-direction: row; - block-size: $sz-32; - margin: 0; - inline-size: auto; - } } .list-item-dragged { diff --git a/frontend/src/app/main/ui/dashboard/pin_button.cljs b/frontend/src/app/main/ui/dashboard/pin_button.cljs deleted file mode 100644 index f1250f6356..0000000000 --- a/frontend/src/app/main/ui/dashboard/pin_button.cljs +++ /dev/null @@ -1,30 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) KALEIDOS SUBSIDIARY SL - -(ns app.main.ui.dashboard.pin-button - (:require-macros - [app.common.data.macros :as dm] - [app.main.style :as stl]) - (:require - [app.main.ui.icons :as deprecated-icon] - [app.util.i18n :as i18n :refer [tr]] - [app.util.object :as obj] - [rumext.v2 :as mf])) - -(def ^:private pin-icon - (deprecated-icon/icon-xref :pin (stl/css :icon))) - -(mf/defc pin-button* - [{:keys [aria-label is-pinned class] :as props}] - (let [aria-label (or aria-label (tr "dashboard.pin-unpin")) - class (dm/str (or class "") " " (stl/css-case :button true :button-active is-pinned)) - - props (-> (obj/clone props) - (obj/unset! "isPinned") - (obj/set! "className" class) - (obj/set! "aria-label" aria-label))] - - [:> "button" props pin-icon])) diff --git a/frontend/src/app/main/ui/dashboard/pin_button.scss b/frontend/src/app/main/ui/dashboard/pin_button.scss deleted file mode 100644 index 097e7e634d..0000000000 --- a/frontend/src/app/main/ui/dashboard/pin_button.scss +++ /dev/null @@ -1,35 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. -// -// Copyright (c) KALEIDOS SUBSIDIARY SL - -@use "refactor/common-refactor.scss" as deprecated; - -.button { - --pin-button-icon-color: var(--button-icon-foreground-color); - --pin-button-bg-color: none; - --pin-button-border-color: none; - - width: deprecated.$s-32; - height: deprecated.$s-32; - background: var(--pin-button-bg-color); - border: deprecated.$s-2 solid var(--pin-button-border-color); - border-radius: deprecated.$br-8; - display: grid; - place-content: center; - cursor: pointer; -} - -.button-active { - --pin-button-icon-color: var(--button-icon-foreground-color-selected); - --pin-button-bg-color: var(--button-icon-background-color-selected); - --pin-button-border-color: var(--button-icon-border-color-selected); -} - -.icon { - width: deprecated.$s-16; - height: deprecated.$s-16; - fill: none; - stroke: var(--pin-button-icon-color); -} diff --git a/frontend/src/app/main/ui/dashboard/project_menu.cljs b/frontend/src/app/main/ui/dashboard/project_menu.cljs index ea0a1ab8e9..3b21ea4898 100644 --- a/frontend/src/app/main/ui/dashboard/project_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/project_menu.cljs @@ -12,23 +12,34 @@ [app.main.data.notifications :as ntf] [app.main.refs :as refs] [app.main.store :as st] - [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.context :as ctx] - [app.main.ui.dashboard.import :as udi] - [app.util.dom :as dom] + [app.main.ui.ds.layout.menu :refer [menu* menu-item* menu-separator* sub-menu*]] [app.util.i18n :as i18n :refer [tr]] - [beicon.v2.core :as rx] - [potok.v2.core :as ptk] [rumext.v2 :as mf])) -(mf/defc project-menu* - [{:keys [project show on-edit on-close top left on-import]}] - (let [top (or top 0) - left (or left 0) +;; The menu items only, with no popover of their own — shared by project-menu* +;; below (opened from the "..." button, via Menu) and by projects.cljs's own +;; right-click handling (via ContextMenu), so both triggers show the exact +;; same options. +;; +;; on-import-click, rather than this owning its own file input/ref: the +;; popover this renders inside really unmounts its content on close (unlike +;; the old context-menu-a11y, which just hid it), and selecting "Import" +;; closes the menu in the same tick — so a ref owned here can already be +;; gone by the time its own click handler would fire. The caller keeps the +;; hidden input mounted for as long as the row itself exists instead. +(mf/defc project-menu-items* + {::mf/private true} + [{:keys [project on-edit on-import-click]}] - current-team-id (mf/use-ctx ctx/current-team-id) - teams (mf/deref refs/teams) - teams (-> teams (dissoc current-team-id) vals vec) + (assert (some? project) "missing `project` prop") + (assert (fn? on-edit) "missing `on-edit` prop") + + (let [is-default? (:is-default project) + + current-team-id (mf/use-ctx ctx/current-team-id) + teams (mf/deref refs/teams) + other-teams (-> teams (dissoc current-team-id) vals) on-duplicate-success (fn [new-project] @@ -71,73 +82,45 @@ :title (tr "modals.delete-project-confirm.title") :message (tr "modals.delete-project-confirm.message") :accept-label (tr "modals.delete-project-confirm.accept") - :on-accept delete-fn}))) - - file-input - (mf/use-ref nil) - - on-import-files - (fn [] (dom/click (mf/ref-val file-input))) - - on-finish-import - (mf/use-fn - (fn [] (when (fn? on-import) (on-import)))) - - options - [(when-not (:is-default project) - {:name (tr "labels.rename") - :id "project-rename" - :handler on-edit}) - (when-not (:is-default project) - {:name (tr "dashboard.duplicate") - :id "project-duplicate" - :handler on-duplicate}) - (when-not (:is-default project) - {:name (tr "dashboard.pin-unpin") - :id "project-pin" - :handler toggle-pin}) - - (when (and (seq teams) (not (:is-default project))) - {:name (tr "dashboard.move-to") - :id "project-move-to" - :options (for [team teams] - {:name (:name team) - :id (str "move-to-" (:id team)) - :handler (on-move (:id team))})}) - - (when (some? on-import) - {:name (tr "dashboard.import") - :id "file-import" - :handler on-import-files}) - (when-not (:is-default project) - {:name :separator}) - (when-not (:is-default project) - {:name (tr "labels.delete") - :id "project-delete" - :handler on-delete})]] - - (mf/with-effect [show on-close] - (when ^boolean show - (st/emit! (ptk/data-event :dropdown/open {:id "project-menu"})) - (let [stream (->> st/stream - (rx/filter (ptk/type? :dropdown/open)) - (rx/map deref) - (rx/filter #(not= "project-menu" (:id %))) - (rx/take 1)) - subs (rx/subs! nil nil on-close stream)] - (fn [] - (rx/dispose! subs))))) + :on-accept delete-fn})))] [:* - [:> context-menu* - {:on-close on-close - :show show - :fixed (or (not= top 0) (not= left 0)) - :min-width true - :top top - :left left - :options options}] - [:> udi/import-form* {:ref file-input - :project-id (:id project) - :on-finish-import on-finish-import}]])) + (when-not is-default? + [:> menu-item* {:id "project-rename" :on-action on-edit} + (tr "labels.rename")]) + (when-not is-default? + [:> menu-item* {:id "project-duplicate" :on-action on-duplicate} + (tr "dashboard.duplicate")]) + + (when-not is-default? + [:> menu-item* {:id "project-pin" :on-action toggle-pin} + (tr "dashboard.pin-unpin")]) + + (when (and (seq other-teams) (not is-default?)) + [:> sub-menu* {:id "project-move-to" :trigger (tr "dashboard.move-to") :variant "drilldown"} + (for [team other-teams] + [:> menu-item* {:key (:id team) + :id (str "move-to-" (:id team)) + :on-action (on-move (:id team))} + (:name team)])]) + + (when (some? on-import-click) + [:> menu-item* {:id "file-import" :on-action on-import-click} + (tr "dashboard.import")]) + + (when-not is-default? + [:* + [:> menu-separator*] + [:> menu-item* {:id "project-delete" :on-action on-delete} + (tr "labels.delete")]])])) + +(mf/defc project-menu* + [{:keys [project is-open on-open-change on-edit on-import-click trigger placement]}] + [:> menu* {:is-open is-open + :on-open-change on-open-change + :placement placement + :trigger trigger} + [:> project-menu-items* {:project project + :on-edit on-edit + :on-import-click on-import-click}]]) diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index bd90dc96eb..063eb55afb 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.dashboard.projects (:require-macros [app.main.style :as stl]) (:require - [app.common.geom.point :as gpt] [app.common.time :as ct] [app.main.data.common :as dcm] [app.main.data.dashboard :as dd] @@ -20,14 +19,17 @@ [app.main.store :as st] [app.main.ui.dashboard.deleted :as deleted] [app.main.ui.dashboard.grid :refer [line-grid*]] + [app.main.ui.dashboard.import :as udi] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.layout-toggle :as lt :refer [layout-toggle*]] - [app.main.ui.dashboard.pin-button :refer [pin-button*]] - [app.main.ui.dashboard.project-menu :refer [project-menu*]] + [app.main.ui.dashboard.project-menu :refer [project-menu* + project-menu-items*]] [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.layout.menu :refer [context-menu*]] [app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -36,23 +38,14 @@ [okulary.core :as l] [rumext.v2 :as mf])) -(def ^:private show-more-icon - (deprecated-icon/icon-xref :arrow (stl/css :show-more-icon))) - -(def ^:private close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - -(def ^:private add-icon - (deprecated-icon/icon-xref :add (stl/css :add-icon))) - -(def ^:private menu-icon - (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) - (mf/defc header* {::mf/wrap [mf/memo] ::mf/private true} [{:keys [can-edit layout on-change]}] - (let [on-click (mf/use-fn #(st/emit! (dd/create-project)))] + (let [on-click + (mf/use-fn + #(st/emit! (dd/create-project)))] + [:header {:class (stl/css :dashboard-header) :data-testid "dashboard-header"} [:div#dashboard-projects-title {:class (stl/css :dashboard-title)} @@ -61,15 +54,17 @@ [:> layout-toggle* {:layout layout :on-change on-change}] (when can-edit - [:button {:class (stl/css :btn-secondary :btn-small) - :on-click on-click - :data-testid "new-project-button"} + [:> button* {:variant "secondary" + :on-click on-click + :data-testid "new-project-button"} (tr "dashboard.new-project")])]])) (mf/defc team-hero* {::mf/wrap [mf/memo]} [{:keys [team on-close]}] - (let [on-nav-members-click (mf/use-fn #(st/emit! (dcm/go-to-dashboard-members))) + (let [on-nav-members-click + (mf/use-fn + #(st/emit! (dcm/go-to-dashboard-members))) on-invite (mf/use-fn @@ -97,10 +92,11 @@ [:> button* {:variant "primary" :on-click on-invite} (tr "onboarding.choice.team-up.invite-members")]] - [:button {:class (stl/css :close) - :on-click on-close' - :aria-label (tr "labels.close")} - close-icon]])) + [:> icon-button* {:icon i/close + :class (stl/css :close) + :variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close'}]])) (mf/defc project-item* {::mf/private true} @@ -120,9 +116,9 @@ dstate (mf/deref refs/dashboard-local) edit-id (:project-for-edit dstate) - local (mf/use-state {:menu-open false - :menu-pos nil - :edition (= (:id project) edit-id)}) + local (mf/use-state {:edition (= (:id project) edit-id)}) + menu-open* (mf/use-state false) + context-menu-open* (mf/use-state false) [rowref limit] (hooks/use-dynamic-grid-item-width) @@ -144,24 +140,11 @@ (mf/use-fn (fn [event] (dom/prevent-default event) - - (let [client-position (dom/get-client-position event) - position (if (and (nil? (:y client-position)) (nil? (:x client-position))) - (let [target-element (dom/get-target event) - points (dom/get-bounding-rect target-element) - y (:top points) - x (:left points)] - (gpt/point x y)) - client-position)] - (swap! local assoc - :menu-open true - :menu-pos position)))) - - on-menu-close - (mf/use-fn #(swap! local assoc :menu-open false)) + (swap! menu-open* not))) on-edit-open - (mf/use-fn #(swap! local assoc :edition true)) + (mf/use-fn + #(swap! local assoc :edition true)) on-edit (mf/use-fn @@ -194,7 +177,14 @@ (fn [_] (create-file "dashboard:grid-header-plus-button"))) - on-import + file-input + (mf/use-ref nil) + + on-import-click + (mf/use-fn + #(dom/click (mf/ref-val file-input))) + + on-finish-import (mf/use-fn (mf/deps project-id team-id) (fn [] @@ -204,14 +194,14 @@ (dd/clear-selected-files)))) handle-create-click - (mf/use-callback + (mf/use-fn (mf/deps on-create-click) (fn [event] (when (kbd/enter? event) (on-create-click event)))) handle-menu-click - (mf/use-callback + (mf/use-fn (mf/deps on-menu-click) (fn [event] (when (kbd/enter? event) @@ -225,15 +215,22 @@ [:& inline-edition {:content (:name project) :on-end on-edit :max-length 250}] - [:h2 {:on-click on-nav - :class (stl/css :project-name) - :title (if (:is-default project) - (tr "labels.drafts") - (:name project)) - :on-context-menu (when can-edit on-menu-click)} - (if (:is-default project) - (tr "labels.drafts") - (:name project))]) + [:> context-menu* {:aria-label (tr "dashboard.options") + :is-disabled (not can-edit) + :on-open-change #(reset! context-menu-open* %) + :trigger + (mf/html + [:h2 {:on-click on-nav + :class (stl/css :project-name) + :title (if (:is-default project) + (tr "labels.drafts") + (:name project))} + (if (:is-default project) + (tr "labels.drafts") + (:name project))])} + [:> project-menu-items* {:project project + :on-edit on-edit-open + :on-import-click on-import-click}]]) [:div {:class (stl/css :info-wrapper)} @@ -246,40 +243,40 @@ [:span {:class (stl/css :recent-files-row-title-info)} (str ", " time)])] [:div {:class (stl/css-case :project-actions true - :pinned-project (:is-pinned project))} + :pinned-project (:is-pinned project) + :is-force-display (or (deref menu-open*) + (deref context-menu-open*)))} (when-not (:is-default project) - [:> pin-button* {:class (stl/css :pin-button) - :is-pinned (:is-pinned project) - :on-click toggle-pin - :tab-index 0}]) + [:> icon-button* {:icon i/pin + :variant "ghost" + :aria-label (tr "dashboard.pin-unpin") + :aria-pressed (:is-pinned project) + :on-click toggle-pin + :tab-index 0}]) (when ^boolean can-edit - [:button {:class (stl/css :add-file-btn) - :on-click on-create-click - :title (tr "dashboard.new-file") - :aria-label (tr "dashboard.new-file") - :data-testid "project-new-file" - :on-key-down handle-create-click} - add-icon]) + [:> icon-button* {:icon i/add + :variant "ghost" + :aria-label (tr "dashboard.new-file") + :on-click on-create-click + :on-key-down handle-create-click}]) (when ^boolean can-edit - [:button {:class (stl/css :options-btn) - :on-click on-menu-click - :title (tr "dashboard.options") - :aria-label (tr "dashboard.options") - :data-testid "project-options" - :on-key-down handle-menu-click} - menu-icon])] - - (when ^boolean can-edit - [:> project-menu* - {:project project - :show (:menu-open @local) - :left (+ 24 (:x (:menu-pos @local))) - :top (:y (:menu-pos @local)) - :on-edit on-edit-open - :on-close on-menu-close - :on-import on-import}])] + [:> project-menu* {:project project + :is-open (deref menu-open*) + :on-open-change #(reset! menu-open* %) + :on-edit on-edit-open + :on-import-click on-import-click + :placement "bottom start" + :trigger + (mf/html + [:> icon-button* {:icon i/menu + :variant "ghost" + :aria-label (tr "dashboard.options") + :aria-pressed (deref menu-open*) + :data-testid "project-options" + :on-click on-menu-click + :on-key-down handle-menu-click}])}])]] (when (and (> limit 0) (> file-count limit)) @@ -289,8 +286,18 @@ :on-key-down (fn [event] (when (kbd/enter? event) (on-nav)))} - [:span {:class (stl/css :placeholder-label)} (tr "dashboard.show-all-files")] - show-more-icon])]] + (tr "dashboard.show-all-files") + [:> icon* {:icon-id i/arrow-right}]])]] + + ;; Kept mounted for as long as this row is, shared by both menu + ;; instances above: the popover each renders inside really unmounts its + ;; content on close (unlike the old context-menu-a11y, which just hid + ;; it), and selecting "Import" closes the menu in the same tick — a ref + ;; owned inside either popover could already be gone by the time its + ;; own click handler fires. + [:> udi/import-form* {:ref file-input + :project-id project-id + :on-finish-import on-finish-import}] [:div {:class (stl/css :grid-container) :ref rowref} (if ^boolean empty? diff --git a/frontend/src/app/main/ui/dashboard/projects.scss b/frontend/src/app/main/ui/dashboard/projects.scss index 1fd92ca225..e60171caea 100644 --- a/frontend/src/app/main/ui/dashboard/projects.scss +++ b/frontend/src/app/main/ui/dashboard/projects.scss @@ -46,8 +46,7 @@ position: relative; &:hover, - &:focus, - &:focus-within { + &:has(:focus-visible) { --actions-opacity: 1; } } @@ -56,6 +55,14 @@ --actions-opacity: 1; } +// Set while either the "..." menu or the title's right-click menu is open — +// its popover can portal outside .dashboard-project-row, so :hover/:focus- +// within alone stop applying the moment the pointer leaves the row, even +// though the menu itself is still open. +.is-force-display { + --actions-opacity: 1; +} + .projects-container { display: grid; grid-auto-rows: min-content; @@ -118,23 +125,7 @@ display: flex; opacity: var(--actions-opacity); margin-inline-start: var(--sp-xxxl); -} - -.add-file-btn, -.options-btn { - @extend %button-tertiary; - - block-size: $sz-32; - inline-size: $sz-32; - margin: 0 var(--sp-s); - padding: var(--sp-s); -} - -.add-icon, -.menu-icon { - @extend %button-icon; - - stroke: var(--icon-foreground); + gap: var(--sp-l); } .grid-container { @@ -168,13 +159,6 @@ } } -.show-more-icon { - block-size: $sz-16; - inline-size: $sz-16; - fill: none; - stroke: var(--show-more-color); -} - // Team hero .team-hero { background-color: var(--color-background-tertiary); @@ -226,25 +210,9 @@ } .close { - --close-icon-foreground-color: var(--icon-foreground); - position: absolute; top: var(--sp-xl); - inset-inline-end: var(--sp-xxl); - inline-size: var(--sp-xxl); - background-color: transparent; - border: none; - cursor: pointer; - - &:hover { - --close-icon-foreground-color: var(--button-icon-foreground-color-selected); - } -} - -.close-icon { - @extend %button-icon; - - stroke: var(--close-icon-foreground-color); + right: var(--sp-xl); } .img-wrapper { diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index f3e0673ceb..fe683c8273 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -29,7 +29,7 @@ [app.main.ui.dashboard.check-updates :as dcu] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] - [app.main.ui.dashboard.project-menu :refer [project-menu*]] + [app.main.ui.dashboard.project-menu :refer [project-menu-items*]] [app.main.ui.dashboard.subscription :refer [dashboard-cta* get-subscription-type menu-team-icon* @@ -41,6 +41,7 @@ [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.main.ui.ds.layout.menu :refer [context-menu*]] [app.main.ui.hooks :as hooks :refer [use-focus-timer-ref]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.nitrate.nitrate-form] @@ -113,9 +114,7 @@ edit-id (:project-for-edit dstate) local* (mf/use-state - #(do {:menu-open false - :menu-pos nil - :edition? (= (:id item) edit-id) + #(do {:edition? (= (:id item) edit-id) :dragging? false})) local (deref local*) @@ -139,18 +138,6 @@ (st/emit! (dcm/go-to-dashboard-files :project-id project-id))))) - on-menu-click - (mf/use-fn - (fn [event] - (let [position (dom/get-client-position event)] - (dom/prevent-default event) - (swap! local* assoc - :menu-open true - :menu-pos position)))) - - on-menu-close - (mf/use-fn #(swap! local* assoc :menu-open false)) - on-edit-open (mf/use-fn #(swap! local* assoc :edition? true)) @@ -203,30 +190,26 @@ mdata {:on-success on-drop-success}] (st/emit! (dd/move-files (with-meta data mdata)))))))] - [:* - [:li {:tab-index "0" - :class (stl/css-case :project-element true - :sidebar-nav-item true - :current is-selected - :dragging (:dragging? local)) - :on-click on-click - :on-key-down on-key-down - :on-double-click on-edit-open - :on-context-menu on-menu-click - :on-drag-enter on-drag-enter - :on-drag-over on-drag-over - :on-drag-leave on-drag-leave - :on-drop on-drop} - (if (:edition? local) - [:& inline-edition {:content (:name item) - :on-end on-edit}] - [:span {:class (stl/css :element-title)} (:name item)])] - [:> project-menu* {:project item - :show (:menu-open local) - :left (:x (:menu-pos local)) - :top (:y (:menu-pos local)) - :on-edit on-edit-open - :on-close on-menu-close}]])) + [:> context-menu* {:aria-label (tr "dashboard.options") + :trigger + (mf/html + [:li {:tab-index "0" + :class (stl/css-case :project-element true + :sidebar-nav-item true + :current is-selected + :dragging (:dragging? local)) + :on-click on-click + :on-key-down on-key-down + :on-double-click on-edit-open + :on-drag-enter on-drag-enter + :on-drag-over on-drag-over + :on-drag-leave on-drag-leave + :on-drop on-drop} + (if (:edition? local) + [:& inline-edition {:content (:name item) + :on-end on-edit}] + [:span {:class (stl/css :element-title)} (:name item)])])} + [:> project-menu-items* {:project item :on-edit on-edit-open}]])) (mf/defc sidebar-search* {::mf/private true} diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs index 319a4c8178..5802dd2dde 100644 --- a/frontend/src/app/main/ui/ds.cljs +++ b/frontend/src/app/main/ui/ds.cljs @@ -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] diff --git a/frontend/src/app/main/ui/ds/layout/context_menu.mdx b/frontend/src/app/main/ui/ds/layout/context_menu.mdx new file mode 100644 index 0000000000..22ee06fa1e --- /dev/null +++ b/frontend/src/app/main/ui/ds/layout/context_menu.mdx @@ -0,0 +1,129 @@ +{ /* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + + Copyright (c) KALEIDOS SUBSIDIARY SL */ } +import { Canvas, Meta } from "@storybook/addon-docs/blocks"; +import * as ContextMenu from "./context_menu.stories"; + + + +# 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 + + + +--- + +# Usage + +```clojure +[:> context-menu* + {:aria-label (tr "workspace.shape.menu.title") + :on-action (fn [key] (handle-action key)) + :trigger [:> layer-row* {:shape shape}]} + + [:> menu-item* {:id "rename"} "Rename"] + [:> menu-item* {:id "duplicate"} "Duplicate"] + [:> menu-separator*] + [:> menu-item* {:id "delete"} "Delete"]] +``` + +`trigger` is the area that responds to a right click — it can be any content, not just a button; it's rendered as-is (no extra box around it) and only gains a right-click listener. The menu itself is entirely self-contained: opening, positioning, and closing (selection, **Escape**, outside click) are all handled internally, no `is-open` plumbing required from the caller. + +--- + +# Context menu props +## trigger + +The area that opens the menu on right click. Rendered without adding any wrapping box to the layout. + +Type: React element + +## aria-label + +Accessible name for the menu, read by screen readers. Required — a context menu has no visible trigger button to derive a label from. + +Type: string + +## on-action + +Callback invoked with the selected item's `id` when an item is chosen. + +Type: function + +## placement + +Controls where the menu grows from the click point. + +Options + +"top", "top start", "top end", "bottom", "bottom start" (default), "bottom end", "left", "left top", "left bottom", "right", "right top", "right bottom" + +The menu flips to the opposite vertical side automatically when there isn't enough room in the preferred direction — e.g. `"bottom start"` (grows right and down) becomes `"top start"` (grows right and up) near the bottom of the viewport. + +## is-disabled + +Prevents the context menu from opening. + +Default: false + +## on-open-change + +Called whenever the menu opens or closes. The context menu owns its open state — unlike Menu, no `is-open` plumbing is required to use it — this is only a notification, e.g. to keep a trigger's own hover-only affordances visible for as long as the menu stays open. + +Type: function + +## max-width + +Caps how wide the menu (and every flyout submenu nested in it) can grow. The existing minimum width still wins if it's larger than this. + +Type: number (pixels) | string (any CSS length) + +Default: 250 + +## is-dense + +Shrinks every item — this menu's own and every nested flyout submenu's — to a 28px row, for lists dense enough that the default 32px adds up. + +Default: false + +## class + +Additional CSS class applied to the menu. + +--- + +# MenuItem props + +Same as the [Menu](/docs/layout-menu--docs) component's `menu-item*` and `menu-separator*`. + +--- + +# Accessibility + +The context menu automatically provides: + +- Accessible `menu`/`menuitem` semantics +- Full keyboard navigation (arrow keys, Home/End, typeahead) +- Focus management and restoration when closed +- Dismissal with **Escape** and outside click +- Long-press support on touch devices, where right click doesn't exist + +--- + +# Best practices + +Use a context menu for: + +- Actions tied to a specific item (a layer, a file, a row) that don't need a persistently visible trigger + +Avoid using a context menu for: + +- The *only* way to reach an action — right click isn't discoverable; pair it with a visible menu trigger (see [Menu](/docs/layout-menu--docs)) or keyboard shortcut for the same actions when possible +- A long list of unrelated actions — keep it scoped to the item that was clicked diff --git a/frontend/src/app/main/ui/ds/layout/context_menu.stories.jsx b/frontend/src/app/main/ui/ds/layout/context_menu.stories.jsx new file mode 100644 index 0000000000..6ede566c2a --- /dev/null +++ b/frontend/src/app/main/ui/ds/layout/context_menu.stories.jsx @@ -0,0 +1,115 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +import Components from "@target/components"; +import { + fireEvent, + screen, + userEvent, + waitFor, + within, + expect, +} from "storybook/test"; + +const { ContextMenu, MenuItem, MenuSeparator } = Components; + +const ContextMenuWrapper = ({ children, ...props }) => { + return ( + + Right click here +
+ } + > + {children} + + ); +}; + +export default { + title: "Layout/Context Menu", + component: ContextMenuWrapper, + args: { + "aria-label": "Item actions", + placement: "bottom start", + onAction: (key) => console.log("action", key), + children: ( + <> + Rename + Duplicate + + Delete + + ), + }, + 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 }) => , +}; + +export const Default = {}; + +export const Disabled = { + args: { + isDisabled: true, + }, +}; + +const openChangeCalls = []; + +export const TestOnOpenChangeReportsState = { + args: { + onOpenChange: (isOpen) => openChangeCalls.push(isOpen), + }, + play: async ({ canvasElement, step }) => { + openChangeCalls.length = 0; + const trigger = within(canvasElement).getByText("Right click here"); + + await step("Right-clicking the trigger reports it open", async () => { + fireEvent.contextMenu(trigger); + await screen.findByRole("menu"); + await waitFor(() => expect(openChangeCalls).toEqual([true])); + }); + + await step("Escape reports it closed", async () => { + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(openChangeCalls).toEqual([true, false])); + }); + }, +}; diff --git a/frontend/src/app/main/ui/ds/layout/menu.cljs b/frontend/src/app/main/ui/ds/layout/menu.cljs new file mode 100644 index 0000000000..12a9bd7f73 --- /dev/null +++ b/frontend/src/app/main/ui/ds/layout/menu.cljs @@ -0,0 +1,136 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.ds.layout.menu + (:require + ["@penpot/ui/menu" :as menu] + [app.common.data :as d] + [rumext.v2 :as mf])) + +(def ^:private schema:menu + [:map + [:class {:optional true} [:maybe :string]] + [:is-open {:optional true} [:maybe :boolean]] + [:on-open-change {:optional true} [:maybe fn?]] + [:trigger {:optional true} [:maybe :any]] + [:placement {:optional true} + [:maybe [:enum "top" "top start" "top end" + "bottom" "bottom start" "bottom end" + "left" "left top" "left bottom" + "right" "right top" "right bottom"]]] + [:on-action {:optional true} [:maybe fn?]] + [:max-width {:optional true} [:maybe [:or :int :string]]] + [:is-dense {:optional true} [:maybe :boolean]]]) + +(mf/defc menu* + {::mf/schema schema:menu} + [{:keys [class is-open on-open-change trigger placement on-action max-width is-dense children] :rest props}] + (let [placement (d/nilv placement "bottom start") + props + (mf/spread-props props + {:class class + :is-open is-open + :on-open-change on-open-change + :trigger trigger + :placement placement + :on-action on-action + :max-width max-width + :is-dense is-dense})] + [:> menu/Menu props + children])) + +(def ^:private schema:menu-item + [:map + [:id {:optional true} [:maybe [:or :string :int]]] + [:class {:optional true} [:maybe :string]] + [:is-disabled {:optional true} [:maybe :boolean]] + [:on-action {:optional true} [:maybe fn?]] + [:text-value {:optional true} [:maybe :string]]]) + +(mf/defc menu-item* + {::mf/schema schema:menu-item} + [{:keys [id class is-disabled on-action text-value children] :rest props}] + (let [props + (mf/spread-props props + {:id id + :class class + :is-disabled is-disabled + :on-action on-action + :text-value text-value})] + [:> menu/MenuItem props + children])) + +(def ^:private schema:sub-menu + [:map + [:id {:optional true} [:maybe [:or :string :int]]] + [:class {:optional true} [:maybe :string]] + [:trigger {:optional true} [:maybe :any]] + [:is-disabled {:optional true} [:maybe :boolean]] + [:text-value {:optional true} [:maybe :string]] + [:on-action {:optional true} [:maybe fn?]] + [:variant {:optional true} [:maybe [:enum "flyout" "drilldown"]]] + [:max-width {:optional true} [:maybe [:or :int :string]]]]) + +(mf/defc sub-menu* + {::mf/schema schema:sub-menu} + [{:keys [id class trigger is-disabled text-value on-action variant max-width children] :rest props}] + (let [variant (d/nilv variant "flyout") + props + (mf/spread-props props + {:id id + :class class + :trigger trigger + :is-disabled is-disabled + :text-value text-value + :on-action on-action + :variant variant + :max-width max-width})] + [:> menu/SubMenu props + children])) + +(def ^:private schema:menu-separator + [:map + [:class {:optional true} [:maybe :string]]]) + +(mf/defc menu-separator* + {::mf/schema schema:menu-separator} + [{:keys [class] :rest props}] + (let [props (mf/spread-props props {:class class})] + [:> menu/MenuSeparator props])) + +(def ^:private schema:context-menu + [:map + [:class {:optional true} [:maybe :string]] + [:aria-label :string] + [:trigger {:optional true} [:maybe :any]] + [:placement {:optional true} + [:maybe [:enum "top" "top start" "top end" + "bottom" "bottom start" "bottom end" + "left" "left top" "left bottom" + "right" "right top" "right bottom"]]] + [:is-disabled {:optional true} [:maybe :boolean]] + [:on-action {:optional true} [:maybe fn?]] + [:max-width {:optional true} [:maybe [:or :int :string]]] + [:is-dense {:optional true} [:maybe :boolean]] + [:on-open-change {:optional true} [:maybe fn?]]]) + +(mf/defc context-menu* + {::mf/schema schema:context-menu} + [{:keys [class aria-label trigger placement is-disabled on-action max-width is-dense on-open-change children] :rest props}] + (let [placement (d/nilv placement "bottom start") + props + (mf/spread-props props + {:class class + :aria-label aria-label + :trigger trigger + :placement placement + :is-disabled is-disabled + :on-action on-action + :max-width max-width + :is-dense is-dense + :on-open-change on-open-change})] + [:> menu/ContextMenu props + children])) diff --git a/frontend/src/app/main/ui/ds/layout/menu.mdx b/frontend/src/app/main/ui/ds/layout/menu.mdx new file mode 100644 index 0000000000..8b00573e94 --- /dev/null +++ b/frontend/src/app/main/ui/ds/layout/menu.mdx @@ -0,0 +1,246 @@ +{ /* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + + Copyright (c) KALEIDOS SUBSIDIARY SL */ } +import { Canvas, Meta } from "@storybook/addon-docs/blocks"; +import * as Menu from "./menu.stories"; + + + +# 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 + + + +### With a submenu + + + +### With a drilldown submenu + + + +### Dense + + + +### With a max width + + + +--- + +# Usage + +```clojure +(let [open* (mf/use-state false) + open (deref open*) + trigger-ref (mf/use-ref nil) + on-ref (mf/use-fn (fn [node] (mf/set-ref-val! trigger-ref node))) + on-open (mf/use-fn (fn [] (reset! open* true))) + on-open-change (mf/use-fn (fn [open] (reset! open* open))) + on-action (mf/use-fn (fn [key] (handle-action key)))] + [:> menu* + {:is-open open + :on-open-change on-open-change + :trigger (mf/html + [:> button* {:variant "secondary" + :on-ref on-ref + :on-click on-open} + "Open menu"]) + :on-action on-action} + + [:> menu-item* {:id "rename"} "Rename"] + [:> menu-item* {:id "duplicate"} "Duplicate"] + [:> menu-separator*] + [:> menu-item* {:id "delete"} "Delete"]]) +``` + +`is-open` and `on-open-change` are controlled by the caller: the trigger's `on-click` opens the menu, and `on-open-change` reports closes from selection, **Escape**, or an outside click. For a context menu, skip `trigger`/`on-click` and drive `is-open` from your own `on-context-menu` handler instead. + +--- + +# Menu props +## trigger + +Element that opens the menu. + +Type: React element + +## is-open + +Controls whether the menu is open. + +Type: boolean + +## on-open-change + +Callback invoked whenever the open state changes. + +Type: function + +## on-action + +Callback invoked with the selected item's `id` when an item is chosen. + +Type: function + +## placement + +Controls where the menu opens relative to its trigger. + +Options + +"top", "top start", "top end", "bottom", "bottom start" (default), "bottom end", "left", "left top", "left bottom", "right", "right top", "right bottom" + +## max-width + +Caps how wide the menu (and every flyout submenu nested in it) can grow. The existing minimum width still wins if it's larger than this. + +Type: number (pixels) | string (any CSS length) + +Default: 250 + +## is-dense + +Shrinks every item — this menu's own and every nested flyout submenu's — to a 28px row, for lists dense enough that the default 32px adds up. + +Default: false + +## class + +Additional CSS class applied to the menu. + +--- + +# MenuItem props +## id + +Unique identifier for the item, passed to `on-action` when selected. + +Type: string | number + +## is-disabled + +Prevents the item from being selected. + +Default: false + +## on-action + +Callback invoked when this specific item is selected. + +Type: function + +## text-value + +Plain-text representation of the item, used for typeahead. Required when the item's content isn't a plain string. + +Type: string + +## class + +Additional CSS class applied to the item. + +--- + +# SubMenu props + +A `sub-menu*` nests a further list of items behind one item, opening on hover or when navigated into with the keyboard. Use it in place of `menu-item*` for that item: + +```clojure +[:> sub-menu* {:trigger "Share" :on-action (fn [key] (handle-action key))} + [:> menu-item* {:id "share-link"} "Copy link"] + [:> menu-item* {:id "share-email"} "Send by email"]] +``` + +Selecting any item inside a submenu closes the whole menu, not just that submenu. + +## variant + +`"flyout"` (default) opens a nested popover next to the trigger item. `"drilldown"` replaces the parent menu's content with this submenu's items and adds a back item — use it for deep or wide trees, e.g. move-to-project's team → project nesting. Nested drilldowns keep drilling into the same list, which is never sized smaller than the root level it was entered from. + +```clojure +[:> sub-menu* {:trigger "Move to" :variant "drilldown"} + [:> menu-item* {:id "project-a"} "Project A"] + [:> sub-menu* {:trigger "Other team" :variant "drilldown"} + [:> menu-item* {:id "project-b"} "Project B"]]] +``` + +Options + +"flyout" (default), "drilldown" + +## trigger + +Label shown for the item that opens the submenu. + +Type: React element + +## id + +Unique identifier for the submenu trigger item. + +Type: string | number + +## is-disabled + +Prevents the submenu from being opened. + +Default: false + +## on-action + +Callback invoked with the selected item's `id` for actions inside this submenu. Ignored when `variant` is `"drilldown"` — those items use the parent menu's `on-action` instead. + +Type: function + +## text-value + +Plain-text representation of the trigger, used for typeahead. Required when `trigger` isn't a plain string. + +Type: string + +## max-width + +Only meaningful for the `"flyout"` variant: its nested popover is its own menu, independent of the root's. A `"drilldown"` submenu has no popover of its own to size — it renders into the root's, sized by the root Menu/ContextMenu's own `max-width` instead. + +Type: number (pixels) | string (any CSS length) + +Default: 250 + +## class + +Additional CSS class applied to the submenu's item list. + +--- + +# Accessibility + +The menu automatically provides: + +- Accessible `menu`/`menuitem` semantics +- Full keyboard navigation (arrow keys, Home/End, typeahead) +- Focus management and restoration when closed +- Dismissal with **Escape** and outside click + +--- + +# Best practices + +Use a menu for: + +- A list of actions tied to a trigger button (dropdown) +- A contextual list of actions tied to a right click (context menu) + +Avoid using a menu for: + +- Long forms or free-text input +- Navigation between pages (use a link/nav component instead) +- A single toggleable option (use a switch or checkbox instead) diff --git a/frontend/src/app/main/ui/ds/layout/menu.stories.jsx b/frontend/src/app/main/ui/ds/layout/menu.stories.jsx new file mode 100644 index 0000000000..2f46d4ecce --- /dev/null +++ b/frontend/src/app/main/ui/ds/layout/menu.stories.jsx @@ -0,0 +1,437 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS SUBSIDIARY SL + +import * as React from "react"; +import Components from "@target/components"; +import { + userEvent, + fireEvent, + within, + screen, + waitFor, + expect, +} from "storybook/test"; + +const { Menu, MenuItem, MenuSeparator, SubMenu, Button } = Components; + +const MenuWrapper = ({ children, ...props }) => { + const [open, setOpen] = React.useState(props.isOpen ?? false); + + React.useEffect(() => { + setOpen(props.isOpen ?? false); + }, [props.isOpen]); + + return ( + setOpen((open) => !open)}> + Open menu + + } + > + {children} + + ); +}; + +export default { + title: "Layout/Menu", + component: MenuWrapper, + args: { + placement: "bottom start", + onAction: (key) => console.log("action", key), + children: ( + <> + Rename + Duplicate + + Delete + + ), + }, + argTypes: { + placement: { + control: "select", + options: [ + "top", + "top start", + "top end", + "bottom", + "bottom start", + "bottom end", + "left", + "left top", + "left bottom", + "right", + "right top", + "right bottom", + ], + }, + maxWidth: { + control: { type: "number" }, + }, + isDense: { + control: { type: "boolean" }, + }, + }, + parameters: { + controls: { exclude: ["isOpen", "onOpenChange", "trigger", "children"] }, + }, + render: ({ ...args }) => , +}; + +export const Default = {}; + +export const WithDisabledItem = { + args: { + children: ( + <> + Rename + + Duplicate + + + Delete + + ), + }, +}; + +const subMenuActionCalls = []; + +const subMenuChildren = ( + <> + Rename + subMenuActionCalls.push(key)}> + Copy link + Send by email + + + Delete + +); + +const drilldownChildren = ( + <> + Rename + Duplicate + + + Project A + Project B + + + Project A + Project B + + + Project A + + + + + Delete + +); + +export const WithSubMenu = { + args: { children: subMenuChildren }, +}; + +export const WithDrilldownSubMenu = { + args: { children: drilldownChildren }, +}; + +export const Dense = { + args: { isDense: true }, +}; + +export const WithMaxWidth = { + args: { + maxWidth: 160, + children: ( + <> + Rename this file completely + Duplicate + + Delete + + ), + }, +}; + +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) => ( +
+
+ +
+
+ ), + ], +}; + +// The popover portals out of the story root, so the menu itself is only +// reachable through screen (document-wide); the trigger stays in the canvas. +const getTrigger = (canvasElement) => + within(canvasElement).getByRole("button", { name: /open menu/i }); + +const expectMenuClosed = () => + waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); + +export const TestTriggerTogglesMenuClosed = { + play: async ({ canvasElement, step }) => { + const trigger = getTrigger(canvasElement); + + await step("Clicking the trigger opens the menu", async () => { + await userEvent.click(trigger); + await screen.findByRole("menu"); + }); + + // The trigger sits outside the popover, so a naive outside-click dismiss + // closes on its pointerdown and lets the click reopen it. + await step("Clicking the trigger again closes the menu", async () => { + await userEvent.click(trigger); + await expectMenuClosed(); + }); + }, +}; + +export const TestFlyoutSubMenuIsNotOutside = { + args: { children: subMenuChildren }, + play: async ({ canvasElement, step }) => { + const trigger = getTrigger(canvasElement); + subMenuActionCalls.length = 0; + + await step("Hovering the submenu trigger opens the flyout", async () => { + await userEvent.click(trigger); + await userEvent.hover( + await screen.findByRole("menuitem", { name: "Share" }), + ); + await screen.findByRole("menuitem", { name: "Copy link" }); + }); + + // react-aria portals a SubmenuTrigger's popover into the root popover's + // container, making the flyout a sibling of the root popover rather than + // a descendant. Testing containment against the root popover alone + // therefore counts a press anywhere in the flyout as an outside click and + // dismisses the whole menu. + await step("A press inside the flyout does not dismiss", async () => { + const [, flyout] = screen.getAllByRole("menu"); + fireEvent.pointerDown(flyout); + + await waitFor(() => + expect( + screen.getByRole("menuitem", { name: "Copy link" }), + ).toBeInTheDocument(), + ); + }); + + await step("Selecting a flyout item fires its action", async () => { + await userEvent.click( + screen.getByRole("menuitem", { name: "Copy link" }), + ); + await waitFor(() => expect(subMenuActionCalls).toEqual(["share-link"])); + }); + + await step("Selecting it closes the whole tree", expectMenuClosed); + }, +}; + +export const TestDrilldownNavigatesAndReturns = { + args: { children: drilldownChildren }, + play: async ({ canvasElement, step }) => { + const trigger = getTrigger(canvasElement); + + await step("Drilling in replaces the menu's own content", async () => { + await userEvent.click(trigger); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Move to" }), + ); + + await screen.findByRole("menuitem", { name: "Project A" }); + expect( + screen.queryByRole("menuitem", { name: "Rename" }), + ).not.toBeInTheDocument(); + }); + + await step("The back item returns to the level entered from", async () => { + await userEvent.click(screen.getByRole("menuitem", { name: /move to/i })); + + await screen.findByRole("menuitem", { name: "Rename" }); + expect( + screen.queryByRole("menuitem", { name: "Project A" }), + ).not.toBeInTheDocument(); + }); + }, +}; + +export const TestDrilldownResetsBetweenOpens = { + args: { children: drilldownChildren }, + play: async ({ canvasElement, step }) => { + const trigger = getTrigger(canvasElement); + + await step("Drill into a submenu, then close the menu", async () => { + await userEvent.click(trigger); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Move to" }), + ); + await screen.findByRole("menuitem", { name: "Project A" }); + + await userEvent.keyboard("{Escape}"); + await expectMenuClosed(); + }); + + await step("Reopening starts back at the root level", async () => { + await userEvent.click(trigger); + + await screen.findByRole("menuitem", { name: "Rename" }); + expect( + screen.queryByRole("menuitem", { name: "Project A" }), + ).not.toBeInTheDocument(); + }); + }, +}; + +export const TestClosesOnEscapeAndOutsideClick = { + play: async ({ canvasElement, step }) => { + const trigger = getTrigger(canvasElement); + + await step("Escape closes the menu", async () => { + await userEvent.click(trigger); + await screen.findByRole("menu"); + + await userEvent.keyboard("{Escape}"); + await expectMenuClosed(); + }); + + await step("A click outside closes the menu", async () => { + await userEvent.click(trigger); + await screen.findByRole("menu"); + + await userEvent.click(document.body); + await expectMenuClosed(); + }); + }, +}; + +export const TestMaxWidthCapsPopoverWidth = { + args: { + maxWidth: 160, + children: ( + <> + Rename this file completely + + Delete + + ), + }, + play: async ({ canvasElement, step }) => { + const trigger = getTrigger(canvasElement); + + await step("The popover never grows past maxWidth", async () => { + await userEvent.click(trigger); + const menu = await screen.findByRole("menu"); + + await waitFor(() => + expect(menu.getBoundingClientRect().width).toBeLessThanOrEqual(160), + ); + }); + }, +}; + +export const TestDrilldownFreezesRootEdgeAndSizesToContent = { + args: { + placement: "top start", + // Deliberately taller at the root than the level drilled into, so a + // regression (re-running react-aria's own flip/collision positioning + // against the drilled-in level's own shorter content, rather than + // freezing the edge already resolved for the root) would show up either + // as the popover jumping to the opposite edge, or as it staying put but + // padded out to the root's own height instead of sizing to its content. + children: ( + <> + Rename this file completely + Duplicate + Restore from trash + + + A + + + ), + }, + decorators: [ + // Plenty of room above the trigger, none below — forces "top start" to + // actually resolve with the popover's bottom edge pinned near the + // trigger, instead of react-aria flipping it back to "bottom" for lack + // of room above, which would defeat the point of this test. + (Story) => ( +
+
+ +
+
+ ), + ], + play: async ({ canvasElement, step }) => { + const trigger = getTrigger(canvasElement); + let rootBottom, rootHeight; + + await step("Opening resolves the popover above the trigger", async () => { + await userEvent.click(trigger); + const menu = await screen.findByRole("menu"); + const popover = menu.closest("[data-placement]"); + + await waitFor(() => expect(popover.dataset.placement).toBe("top")); + ({ bottom: rootBottom, height: rootHeight } = + popover.getBoundingClientRect()); + }); + + await step( + "Drilling into a shorter level keeps the same bottom edge and shrinks to fit", + async () => { + await userEvent.click( + screen.getByRole("menuitem", { name: "Move to" }), + ); + const menu = await screen.findByRole("menu"); + await screen.findByRole("menuitem", { name: "A" }); + const popover = menu.closest("[data-placement]"); + + await waitFor(() => { + const rect = popover.getBoundingClientRect(); + // Within a couple of px, not exact: sub-pixel layout rounding + // between the two measurements, not a regression — an actual + // regression (react-aria re-flipping back to "bottom") would move + // this by the popover's full height, hundreds of px. + expect(Math.abs(rect.bottom - rootBottom)).toBeLessThan(2); + expect(rect.height).toBeLessThan(rootHeight); + }); + }, + ); + }, +};