152 行
6.4 KiB
TypeScript
152 行
6.4 KiB
TypeScript
import { useContext, useEffect, useRef } from 'react'
|
|
|
|
import SortableItem from '@/components/common/SortableItem'
|
|
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
|
import { Button } from '@/components/ui/button'
|
|
import { cn } from '@/lib/utils'
|
|
import { useUITranslation } from '@/lib/useUITranslation'
|
|
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
|
|
|
|
import type { CountSort } from '@/lib/counts'
|
|
|
|
|
|
const CountsSortHeader = (
|
|
{ field, label, sorts, change, structured = false, rowSpan, colSpan,
|
|
resizable = true, dragId, totalKind, disabled = false }: {
|
|
field: string
|
|
label: { text: string; language: string }
|
|
sorts: CountSort[]
|
|
change: (key: string, additive: boolean) => void
|
|
structured?: boolean
|
|
rowSpan?: number; colSpan?: number; resizable?: boolean
|
|
dragId?: string; totalKind?: string; disabled?: boolean },
|
|
) => {
|
|
const { message } = useUITranslation ('counts')
|
|
const family = useContext (CountsTableScope)
|
|
const layout = useCountsTableLayout ()
|
|
const heading = useRef<HTMLDivElement> (null)
|
|
useEffect (() => {
|
|
if (!resizable || colSpan != null && colSpan > 1) return
|
|
const node = heading.current
|
|
const text = node?.querySelector<HTMLElement> ('[data-counts-label]')
|
|
const button = text?.closest ('button')
|
|
if (node == null || text == null || button == null) return
|
|
const canvas = document.createElement ('canvas')
|
|
const context = canvas.getContext ('2d')
|
|
if (context == null) return
|
|
const measure = () => {
|
|
context.font = getComputedStyle (text).font
|
|
const padding = (element: Element) => {
|
|
const style = getComputedStyle (element)
|
|
return parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
|
|
+ parseFloat (style.borderLeftWidth) + parseFloat (style.borderRightWidth)
|
|
}
|
|
const handles = Array.from (node.children).filter (child => child !== button)
|
|
.reduce ((sum, child) => sum + Math.max (
|
|
child.scrollWidth, child.getBoundingClientRect ().width), 0)
|
|
const resize = node.parentElement?.querySelector ('[data-counts-resize]')
|
|
const resizeWidth = resize?.getBoundingClientRect ().width ?? 0
|
|
const indicator = button.querySelector ('[data-counts-sort-indicator]')
|
|
const sortWidth = indicator?.getBoundingClientRect ().width ?? 0
|
|
const gap = parseFloat (getComputedStyle (button).columnGap) || 0
|
|
layout.fit (family, field, context.measureText (label.text).width
|
|
+ handles + padding (button) + padding (node.parentElement!)
|
|
+ sortWidth + gap + resizeWidth)
|
|
}
|
|
measure ()
|
|
let active = true
|
|
void document.fonts.ready.then (() => { if (active) measure () })
|
|
return () => { active = false }
|
|
}, [family, field, label.text, dragId, resizable, colSpan, layout])
|
|
const press = useRef<{ x: number; y: number
|
|
timer: ReturnType<typeof setTimeout> } | null> (null)
|
|
const suppressClick = useRef (false)
|
|
const cancel = () => {
|
|
if (press.current != null) clearTimeout (press.current.timer)
|
|
press.current = null
|
|
}
|
|
useEffect (() => () => {
|
|
if (press.current != null) clearTimeout (press.current.timer)
|
|
}, [])
|
|
const priority = sorts.findIndex (sort => sort.key === field)
|
|
const direction = sorts[priority]?.direction
|
|
const state = priority < 0 ? 'none' : (direction === 'asc' ? 'ascending' : 'descending')
|
|
const title = message (structured ? 'sort.structured' : 'sort.action')
|
|
const aria = message (priority < 0 ? 'sort.unsortedLabel'
|
|
: (sorts.length === 1 ? 'sort.singleLabel' : 'sort.label'), {
|
|
column: label.text, priority: priority + 1,
|
|
direction: message (`sort.${state}`).text })
|
|
|
|
const render = (shell?: Parameters<NonNullable<
|
|
Parameters<typeof SortableItem>[0]['renderShell']>>[0]) => (
|
|
<th ref={shell?.ref} style={shell?.style} data-counts-total={totalKind}
|
|
data-counts-heading={label.text} rowSpan={rowSpan} colSpan={colSpan}
|
|
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
|
|
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
|
<div ref={heading} className="flex min-w-0 items-center">
|
|
{shell?.handle}
|
|
<Button type="button" variant="ghost" size="sm" disabled={disabled}
|
|
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
|
|
lang={aria.language} aria-label={aria.text} title={title.text}
|
|
onPointerDown={event => {
|
|
if (!event.isPrimary)
|
|
{
|
|
cancel ()
|
|
return
|
|
}
|
|
suppressClick.current = false
|
|
if (event.pointerType !== 'touch' && event.pointerType !== 'pen')
|
|
return
|
|
cancel ()
|
|
const x = event.clientX
|
|
const y = event.clientY
|
|
press.current = { x, y, timer: setTimeout (() => {
|
|
press.current = null
|
|
suppressClick.current = true
|
|
change (field, true)
|
|
}, 500) }
|
|
}}
|
|
onPointerMove={event => {
|
|
if (press.current != null && Math.hypot (
|
|
event.clientX - press.current.x, event.clientY - press.current.y) > 8)
|
|
cancel ()
|
|
}}
|
|
onPointerUp={cancel} onPointerCancel={cancel} onPointerLeave={cancel}
|
|
onContextMenu={event => {
|
|
if (press.current != null || suppressClick.current)
|
|
event.preventDefault ()
|
|
}}
|
|
onKeyDown={event => {
|
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
event.preventDefault ()
|
|
suppressClick.current = false
|
|
change (field, event.ctrlKey || event.metaKey)
|
|
}}
|
|
onClick={event => {
|
|
if (suppressClick.current)
|
|
{
|
|
suppressClick.current = false
|
|
event.preventDefault ()
|
|
return
|
|
}
|
|
change (field, event.ctrlKey || event.metaKey)
|
|
}}>
|
|
<span data-counts-label className={cn ('truncate', label.text === '********'
|
|
&& 'text-red-600 dark:text-red-400')}
|
|
title={label.text === '********' ? message ('maskedReason').text : undefined}
|
|
lang={label.language}>{label.text}</span>
|
|
<span aria-hidden data-counts-sort-indicator className="inline-block w-10 shrink-0">
|
|
{priority >= 0 && <>{direction === 'asc' ? '▲' : '▼'}
|
|
{sorts.length > 1 && priority + 1}</>}</span>
|
|
</Button>
|
|
</div>
|
|
{resizable && <CountsResizeHandle field={field} label={label}/>}
|
|
</th>)
|
|
return dragId == null ? render () : <SortableItem id={dragId}
|
|
name={message ('reorder', { column: label.text })} handleId={`column-${dragId}`}
|
|
moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
|
|
}
|
|
|
|
|
|
export default CountsSortHeader
|