Spaces:
Running
Running
Download web/src/components/ExportMenu.tsx from nsaroiu/mtg-deck-completion: direct link, hf CLI and curl.
- Browser
- Download file 5.23 kB
-
https://huggingface.co/spaces/nsaroiu/mtg-deck-completion/resolve/main/web/src/components/ExportMenu.tsx
- Command line
-
hf download hf://spaces/nsaroiu/mtg-deck-completion/web/src/components/ExportMenu.tsx
-
curl -L -o ExportMenu.tsx https://huggingface.co/spaces/nsaroiu/mtg-deck-completion/resolve/main/web/src/components/ExportMenu.tsx
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> | |
| ) | |
| } | |