feat(ui): icon footer button, and a button menu at the foot opens above its button

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.
This commit is contained in:
Maurice
2026-11-13 10:49:08 +01:00
parent 986dabf11f
commit e958a9a2bb
3 changed files with 44 additions and 11 deletions
@@ -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(
<ContextMenu menu={{ x: 300, y: 276, alignEnd: true, anchorTop: 240, items: [{ label: 'Open' }] }} onClose={() => {}} />
)
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(<ContextMenu menu={{ x: 0, y: 0, items: [{ label: 'Open' }] }} onClose={onClose} />)
+11 -3
View File
@@ -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)
+21 -8
View File
@@ -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<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'style' | 'children'> & {
label: string
icon: ReactNode
danger?: boolean
}) {
return (
<Tooltip label={name} placement="top">
<button type="button" onClick={onClick} disabled={disabled} aria-label={name}
className="grid h-9 w-9 place-items-center rounded-[10px] bg-surface-card text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50">
<Trash2 size={15} strokeWidth={2} />
<Tooltip label={label} placement="top">
<button type="button" {...rest} aria-label={label}
className={`${ICON_BUTTON} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content hover:bg-surface-secondary'}`}>
{icon}
</button>
</Tooltip>
)
}
/** 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 <DialogIconButton label={label ?? t('common.delete')} onClick={onClick} disabled={disabled} danger icon={<Trash2 size={15} strokeWidth={2} />} />
}