mtg-deck-completion / web /src /components /ExportMenu.tsx
nsaroiu's picture
Deploy 07d224d
8538f10 verified
Raw History Blame Contribute Delete
5.23 kB
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import type { Completion } from '../api'
import { exportFileName, toDecklistText } from '../lib/deck'
interface ExportOption {
label: string
hint: string
run: (completion: Completion) => Promise<string | null> // feedback text for the button, if any
}
function download(text: string, fileName: string, type: string) {
const url = URL.createObjectURL(new Blob([text], { type }))
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
// Copies, or downloads the same text if the browser blocks the clipboard.
async function copyOrDownload(text: string, completion: Completion): Promise<string> {
try {
await navigator.clipboard.writeText(text)
return 'Copied!'
} catch {
download(text, exportFileName(completion, 'txt'), 'text/plain')
return 'Downloaded'
}
}
const OPTIONS: ExportOption[] = [
{
label: 'Copy decklist',
hint: 'Plain list for Moxfield, Archidekt, and most sites',
run: (c) => copyOrDownload(toDecklistText(c), c),
},
{
label: 'Download .txt',
hint: 'Plain list as a file',
run: async (c) => {
download(toDecklistText(c), exportFileName(c, 'txt'), 'text/plain')
return null
},
},
]
// Menu button (WAI-ARIA pattern): arrow keys / Home / End move between
// items, Enter or click runs one, Escape or clicking outside closes.
export function ExportMenu({ completion }: { completion: Completion }) {
const id = useId()
const [open, setOpen] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLUListElement>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
// The menu hangs from the button's right edge; on a narrow screen that can
// push it off the left side, so nudge it back inside the window (8px margin)
// before it paints.
useLayoutEffect(() => {
const menu = menuRef.current
if (!open || !menu) return
menu.style.transform = ''
const rect = menu.getBoundingClientRect()
const margin = 8
const shift = rect.left < margin
? margin - rect.left
: Math.min(0, window.innerWidth - margin - rect.right)
if (shift) menu.style.transform = `translateX(${shift}px)`
}, [open])
useEffect(() => {
if (!open) return
itemRefs.current[0]?.focus()
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown)
return () => document.removeEventListener('pointerdown', onPointerDown)
}, [open])
useEffect(() => {
if (!feedback) return
const timer = setTimeout(() => setFeedback(null), 1800)
return () => clearTimeout(timer)
}, [feedback])
const close = (refocus: boolean) => {
setOpen(false)
if (refocus) buttonRef.current?.focus()
}
const choose = async (option: ExportOption) => {
close(true)
setFeedback(await option.run(completion))
}
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const items = itemRefs.current.filter((el): el is HTMLButtonElement => el !== null)
const index = items.indexOf(document.activeElement as HTMLButtonElement)
const focusAt = (i: number) => items[(i + items.length) % items.length]?.focus()
if (e.key === 'ArrowDown') focusAt(index + 1)
else if (e.key === 'ArrowUp') focusAt(index - 1)
else if (e.key === 'Home') focusAt(0)
else if (e.key === 'End') focusAt(items.length - 1)
else if (e.key === 'Escape') close(true)
else if (e.key === 'Tab') close(false)
else return
if (e.key !== 'Tab') e.preventDefault()
}
return (
<div className="menu-wrap">
<button
ref={buttonRef}
type="button"
className="btn btn-primary"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={`${id}-menu`}
onClick={() => setOpen((o) => !o)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' && !open) {
e.preventDefault()
setOpen(true)
}
}}
>
{feedback ?? 'Export'} <span aria-hidden="true">▾</span>
</button>
{open && (
<ul ref={menuRef} id={`${id}-menu`} role="menu" className="menu" onKeyDown={onMenuKeyDown}>
{OPTIONS.map((option, i) => (
<li key={option.label} role="none">
<button
ref={(el) => {
itemRefs.current[i] = el
}}
type="button"
role="menuitem"
className="menu-item"
tabIndex={-1}
onClick={() => choose(option)}
>
<span className="menu-item-label">{option.label}</span>
<span className="menu-item-hint">{option.hint}</span>
</button>
</li>
))}
</ul>
)}
<span className="visually-hidden" role="status">
{feedback ?? ''}
</span>
</div>
)
}