From e958a9a2bbc23dab7ab12ce93b5b28b8f0de4dc2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 10:49:08 +0100 Subject: [PATCH] feat(ui): icon footer button, and a button menu at the foot opens above its button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DialogIconButton is the square footer button DeleteButton already was, for any action named by its tooltip. A "…" menu near the bottom of the window used to be pushed up over its own button; it now opens above it. --- .../shared/ContextMenu.extra.test.tsx | 12 ++++++++ client/src/components/shared/ContextMenu.tsx | 14 +++++++-- client/src/components/shared/DialogShell.tsx | 29 ++++++++++++++----- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/client/src/components/shared/ContextMenu.extra.test.tsx b/client/src/components/shared/ContextMenu.extra.test.tsx index 32e4f822d..770134485 100644 --- a/client/src/components/shared/ContextMenu.extra.test.tsx +++ b/client/src/components/shared/ContextMenu.extra.test.tsx @@ -130,6 +130,18 @@ describe('ContextMenu', () => { expect(menu.style.overflowY).toBe('auto') }) + it('FE-W4CTX-010c: a menu hanging from a button near the foot opens above the button, not over it', () => { + layoutSize(160, 80) + + const { baseElement } = render( + {}} /> + ) + + const menu = baseElement.querySelector('.trek-popover-enter') as HTMLElement + expect(menu.style.left).toBe('140px') + expect(menu.style.top).toBe('154px') + }) + it('FE-W4CTX-011: Escape, a scroll and a resize close it', () => { const onClose = vi.fn() render() diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index 6abccf5ad..b761327e3 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -22,6 +22,8 @@ interface MenuState { y: number items: MenuItem[] alignEnd?: boolean + /** The top of the button it hangs from: with no room below, the menu opens above it, not over it. */ + anchorTop?: number } export function useContextMenu() { @@ -30,14 +32,18 @@ export function useContextMenu() { const open = (e: React.MouseEvent, items: MenuItem[], alignEnd = false) => { e.preventDefault() e.stopPropagation() - const anchor = alignEnd ? e.currentTarget.getBoundingClientRect() : { right: e.clientX, bottom: e.clientY } - setMenu({ x: alignEnd ? anchor.right : e.clientX, y: alignEnd ? anchor.bottom + 6 : e.clientY, items, alignEnd }) + if (!alignEnd) { + setMenu({ x: e.clientX, y: e.clientY, items, alignEnd }) + return + } + const anchor = e.currentTarget.getBoundingClientRect() + setMenu({ x: anchor.right, y: anchor.bottom + 6, items, alignEnd, anchorTop: anchor.top }) } /** Opens the menu under an element, its end on the element's end: the next step of a menu opened there. */ const openAt = (anchor: Element, items: MenuItem[]) => { const rect = anchor.getBoundingClientRect() - setMenu({ x: rect.right, y: rect.bottom + 6, items, alignEnd: true }) + setMenu({ x: rect.right, y: rect.bottom + 6, items, alignEnd: true, anchorTop: rect.top }) } const close = () => setMenu(null) @@ -119,6 +125,8 @@ function ContextMenuFrame({ menu, onClose, closing }: { menu: MenuState; onClose const height = Math.min(el.offsetHeight, viewH - 16) let { x, y } = menu if (menu.alignEnd) x = x - width + // A menu hanging from a button near the foot of the screen opens above it. + if (menu.anchorTop !== undefined && y + height > viewH - 8 && menu.anchorTop - 6 - height >= 8) y = menu.anchorTop - 6 - height // Kept inside all four edges with 8px to spare. x = Math.min(Math.max(8, x), viewW - width - 8) y = Math.min(Math.max(8, y), viewH - height - 8) diff --git a/client/src/components/shared/DialogShell.tsx b/client/src/components/shared/DialogShell.tsx index f2971f941..8ced5e0b9 100644 --- a/client/src/components/shared/DialogShell.tsx +++ b/client/src/components/shared/DialogShell.tsx @@ -346,16 +346,29 @@ export function DialogButton({ variant = 'secondary', active = false, icon, chil ) } -/** The square delete button of the footer, named by its tooltip. */ -export function DeleteButton({ onClick, label, disabled }: { onClick: () => void; label?: string; disabled?: boolean }) { - const { t } = useTranslation() - const name = label ?? t('common.delete') +const ICON_BUTTON = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ring-1 ring-edge-faint disabled:cursor-default disabled:opacity-50' + +/** + * A square footer button with an icon only, named by its tooltip: what a footer + * offers besides its one or two worded actions, so the row never wraps. + */ +export function DialogIconButton({ label, icon, danger = false, ...rest }: Omit, 'className' | 'style' | 'children'> & { + label: string + icon: ReactNode + danger?: boolean +}) { return ( - - ) } + +/** The square delete button of the footer, named by its tooltip. */ +export function DeleteButton({ onClick, label, disabled }: { onClick: () => void; label?: string; disabled?: boolean }) { + const { t } = useTranslation() + return } /> +}