このコミットが含まれているのは:
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { ClipboardEvent, ReactNode } from 'react'
|
||||
|
||||
type Point = { row: number; column: number }
|
||||
type Selection = { identity: string; anchor: Point; end: Point }
|
||||
const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
|
||||
|
||||
|
||||
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!)
|
||||
|
||||
|
||||
const cellAt = (table: HTMLTableElement, point: Point) =>
|
||||
table.tBodies[0]?.rows[point.row]?.cells[point.column]
|
||||
|
||||
|
||||
const CountsSelectableTable = (
|
||||
{ family, columns, identity, children, className, name }: {
|
||||
family: string
|
||||
columns: string[]
|
||||
identity: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
name?: { text: string; language: string } },
|
||||
) => {
|
||||
const id = useId ()
|
||||
const root = useRef<HTMLTableElement> (null)
|
||||
const dragging = useRef (false)
|
||||
const touchArmed = useRef (false)
|
||||
const touchPress = useRef<{ x: number; y: number
|
||||
timer: ReturnType<typeof setTimeout> } | null> (null)
|
||||
const [stored, setStored] = useState<Selection | null> (null)
|
||||
const selection = stored?.identity === identity ? stored : null
|
||||
const layout = useCountsTableLayout ()
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const bounds = useMemo (() => selection == null ? null : {
|
||||
top: Math.min (selection.anchor.row, selection.end.row),
|
||||
bottom: Math.max (selection.anchor.row, selection.end.row),
|
||||
left: Math.min (selection.anchor.column, selection.end.column),
|
||||
right: Math.max (selection.anchor.column, selection.end.column) }, [selection])
|
||||
const finish = () => {
|
||||
dragging.current = false
|
||||
touchArmed.current = false
|
||||
if (touchPress.current != null) clearTimeout (touchPress.current.timer)
|
||||
touchPress.current = null
|
||||
}
|
||||
useEffect (() => {
|
||||
const table = root.current
|
||||
const preventPan = (event: TouchEvent) => {
|
||||
if (touchArmed.current) event.preventDefault ()
|
||||
}
|
||||
table?.addEventListener ('touchmove', preventPan, { passive: false })
|
||||
return () => {
|
||||
table?.removeEventListener ('touchmove', preventPan)
|
||||
if (touchPress.current != null) clearTimeout (touchPress.current.timer)
|
||||
}
|
||||
}, [])
|
||||
useLayoutEffect (() => {
|
||||
const rows = root.current?.tBodies[0]?.rows
|
||||
if (rows == null) return
|
||||
for (const [row, tr] of Array.from (rows).entries ())
|
||||
{
|
||||
for (const [column, cell] of Array.from (tr.cells).entries ())
|
||||
{
|
||||
const selected = bounds != null && row >= bounds.top && row <= bounds.bottom
|
||||
&& column >= bounds.left && column <= bounds.right
|
||||
cell.id = `${id}-${row}-${column}`
|
||||
cell.setAttribute ('role', 'gridcell')
|
||||
cell.setAttribute ('aria-selected', String (selected))
|
||||
if (selected) cell.dataset.countsSelected = ''
|
||||
else delete cell.dataset.countsSelected
|
||||
}
|
||||
}
|
||||
}, [bounds, children, id])
|
||||
const pointFor = (element: Element | null): Point | null => {
|
||||
const table = root.current
|
||||
if (table == null || element == null || !table.contains (element)) return null
|
||||
let cell = element.closest ('td')
|
||||
while (cell != null && cell.closest ('table') !== table)
|
||||
cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null
|
||||
if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null
|
||||
return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex,
|
||||
column: (cell as HTMLTableCellElement).cellIndex }
|
||||
}
|
||||
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
|
||||
if (bounds == null || root.current == null || document.activeElement !== root.current) return
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
const rows: string[][] = []
|
||||
for (let row = bounds.top; row <= bounds.bottom; ++row)
|
||||
{
|
||||
const values: string[] = []
|
||||
for (let column = bounds.left; column <= bounds.right; ++column)
|
||||
{
|
||||
const cell = cellAt (root.current, { row, column })
|
||||
values.push (cell?.dataset.countsCopy ?? cell?.innerText ?? '')
|
||||
}
|
||||
rows.push (values)
|
||||
}
|
||||
const text = rows.map (row => row.map (value => {
|
||||
const safe = /^\s*[=+@-]/.test (value) ? `'${value}` : value
|
||||
return `"${safe.replaceAll ('"', '""')}"`
|
||||
}).join ('\t')).join ('\r\n')
|
||||
const html = '<table>' + rows.map (row => '<tr>' + row.map (value =>
|
||||
`<td style="white-space:pre-wrap">${escapeHTML (
|
||||
/^\s*[=+@-]/.test (value) ? `'${value}` : value)}</td>`).join ('')
|
||||
+ '</tr>').join ('') + '</table>'
|
||||
event.clipboardData.setData ('text/plain', text)
|
||||
try
|
||||
{
|
||||
event.clipboardData.setData ('text/html', html)
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Plain TSV remains available when the browser disallows HTML clipboard data.
|
||||
}
|
||||
}
|
||||
const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0)
|
||||
return (
|
||||
<CountsTableScope.Provider value={family}>
|
||||
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
|
||||
aria-label={name?.text ?? message ('selection.table').text}
|
||||
aria-describedby={`${id}-hint`}
|
||||
aria-activedescendant={selection == null ? undefined
|
||||
: `${id}-${selection.end.row}-${selection.end.column}`}
|
||||
style={{ tableLayout: 'fixed', width: totalWidth }}
|
||||
className={cn ('select-none border-separate border-spacing-0 outline-none',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500',
|
||||
'dark:focus-visible:outline-blue-400',
|
||||
'[&>tbody>tr>td[data-counts-selected]]:bg-blue-100',
|
||||
'[&>tbody>tr>td[data-counts-selected]]:text-slate-900',
|
||||
'dark:[&>tbody>tr>td[data-counts-selected]]:bg-blue-900',
|
||||
'dark:[&>tbody>tr>td[data-counts-selected]]:text-slate-100',
|
||||
'[&>tbody>tr>td[data-counts-selected]]:outline',
|
||||
'[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
|
||||
'[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
|
||||
onCopy={copy}
|
||||
onPointerDown={event => {
|
||||
const element = event.target as Element
|
||||
if (element.closest ('table') !== event.currentTarget
|
||||
|| element.closest (interactive) != null || event.button !== 0) return
|
||||
const point = pointFor (element)
|
||||
if (point == null) return
|
||||
event.stopPropagation ()
|
||||
event.currentTarget.focus ({ preventScroll: true })
|
||||
setStored ({ identity, anchor: event.shiftKey && selection != null
|
||||
? selection.anchor : point, end: point })
|
||||
finish ()
|
||||
if (event.pointerType === 'mouse')
|
||||
{
|
||||
event.preventDefault ()
|
||||
dragging.current = true
|
||||
event.currentTarget.setPointerCapture (event.pointerId)
|
||||
}
|
||||
else
|
||||
{
|
||||
const table = event.currentTarget
|
||||
const pointerId = event.pointerId
|
||||
touchPress.current = { x: event.clientX, y: event.clientY,
|
||||
timer: setTimeout (() => {
|
||||
dragging.current = true
|
||||
touchArmed.current = true
|
||||
table.setPointerCapture (pointerId)
|
||||
}, 450) }
|
||||
}
|
||||
}}
|
||||
onPointerMove={event => {
|
||||
if (touchPress.current != null && !touchArmed.current && Math.hypot (
|
||||
event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8)
|
||||
finish ()
|
||||
if (!dragging.current || event.buttons === 0) return
|
||||
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY))
|
||||
if (point == null) return
|
||||
event.stopPropagation ()
|
||||
setStored (previous => previous?.identity !== identity ? previous : {
|
||||
...previous, end: point })
|
||||
}}
|
||||
onPointerUp={finish} onPointerCancel={finish}
|
||||
onLostPointerCapture={event => {
|
||||
if (event.target === event.currentTarget) finish ()
|
||||
}}
|
||||
onContextMenu={event => {
|
||||
if (touchPress.current != null || touchArmed.current) event.preventDefault ()
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.key === 'Escape')
|
||||
{
|
||||
setStored (null)
|
||||
return
|
||||
}
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
||||
const rows = event.currentTarget.tBodies[0]?.rows
|
||||
if (rows == null || rows.length === 0) return
|
||||
event.preventDefault ()
|
||||
const previous = selection?.end ?? { row: 0, column: 0 }
|
||||
const key = selection == null ? '' : event.key
|
||||
const point = {
|
||||
row: Math.max (0, Math.min (rows.length - 1, previous.row
|
||||
+ (key === 'ArrowDown' ? 1 : (key === 'ArrowUp' ? -1 : 0)))),
|
||||
column: Math.max (0, Math.min (columns.length - 1, previous.column
|
||||
+ (key === 'ArrowRight' ? 1 : (key === 'ArrowLeft' ? -1 : 0)))) }
|
||||
setStored ({ identity, anchor: event.shiftKey && selection != null
|
||||
? selection.anchor : point, end: point })
|
||||
cellAt (event.currentTarget, point)?.scrollIntoView ({
|
||||
block: 'nearest', inline: 'nearest' })
|
||||
}}>
|
||||
<caption id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</caption>
|
||||
<colgroup>{columns.map (key => (
|
||||
<col key={key} style={{ width: layout.width (family, key) }}/>))}</colgroup>
|
||||
{children}
|
||||
</table>
|
||||
</CountsTableScope.Provider>)
|
||||
}
|
||||
|
||||
|
||||
export default CountsSelectableTable
|
||||
新しいイシューから参照
ユーザーをブロックする