mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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} />)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user