File size: 5,226 Bytes
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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>
  )
}