ファイル
btrc-hub/frontend/src/components/counts/CountsFieldTree.tsx
T
2026-10-28 20:57:22 +09:00

136 行
6.2 KiB
TypeScript

import { useId, useLayoutEffect, useRef, useState } from 'react'
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
import SortableList from '@/components/common/SortableList'
import { countChildPaths, fieldsForCounts, normaliseCountsParams, orderedCountFields,
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
import { useCountsDefinition } from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountField, CountsResource } from '@/lib/counts'
type BranchProps = {
field: CountField; path: string; enabled: boolean
selected: Set<string>; params: URLSearchParams
select: (path: string, checked: boolean) => void
update: (key: string, value: string) => void }
const FieldBranch = ({ field, path, enabled, selected, params, select, update }: BranchProps) => {
const { label, message } = useUITranslation ('counts')
const [open, setOpen] = useState (false)
const id = useId ()
const motion = useClientMotion ()
const input = useRef<HTMLInputElement> (null)
const leaves = countChildPaths (field.children ?? [], path, true)
const count = leaves.filter (key => selected.has (key)).length
const checked = field.children == null ? selected.has (path)
: count > 0 && count === leaves.length
const partial = count > 0 && count < leaves.length
useLayoutEffect (() => {
if (input.current != null) input.current.indeterminate = partial
}, [partial])
return <>
<div className="flex items-center gap-1">
<label className="inline-flex items-center gap-1">
<input ref={input} type="checkbox" checked={checked} disabled={!enabled}
aria-checked={partial ? 'mixed' : checked}
onChange={event => select (path, event.target.checked)}/>
{label (field.label, field.labelValues)}
</label>
{field.children != null && <button type="button" aria-expanded={open}
aria-controls={id} aria-label={message (field.label, field.labelValues).text}
lang={message (field.label, field.labelValues).language} onClick={() => setOpen (!open)}
className="rounded px-1 focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-blue-500 dark:focus-visible:ring-blue-400">
<span aria-hidden className="inline-block" style={{
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
transition: `transform ${motion.duration ('collapse')}ms ${motion.easing}` }}>▸</span>
</button>}
</div>
{field.children != null && <AnimatedCollapse open={open} id={id}>
<div className="ml-2 border-l pl-1 dark:border-slate-700">
<FieldGroup fields={field.children} path={path} params={params}
enabled={enabled} selected={selected} select={select} update={update}/>
</div>
</AnimatedCollapse>}
</>
}
const FieldGroup = ({ fields, path, params, enabled, selected, select, update }: {
fields: CountField[]; path: string; params: URLSearchParams
enabled: boolean; selected: Set<string>; select: BranchProps['select']
update: BranchProps['update'] },
) => {
const { message } = useUITranslation ('counts')
const ordered = orderedCountFields (fields, params, path)
const prefix = path ? `${path}.` : ''
return <SortableList groups={{ items: ordered.map (field => field.key) }}
fixed={path === '' ? ['thumbnail'] : []}
name={key => message ('reorder', {
column: message (fields.find (field => field.key === key)!.label,
fields.find (field => field.key === key)!.labelValues).text })}
renderGroup={(_, content) => content}
onChange={groups => {
const current = fields.map (field => `${prefix}${field.key}`)
const other = (params.get ('order') ?? '').split (' ')
.filter (key => key && !current.includes (key))
update ('order', [...other, ...groups.items.map (key => `${prefix}${key}`)].join (' '))
}} renderItem={key => {
const field = fields.find (field => field.key === key)!
return <FieldBranch field={field} path={`${prefix}${key}`} enabled={enabled}
selected={selected} params={params} select={select} update={update}/>
}}/>
}
const CountsFieldTree = (
{ resource, params, update, fields: suppliedFields,
selectionKey = 'columns', parent = '' }: {
resource: CountsResource
fields?: CountField[]
selectionKey?: string
parent?: string
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const definition = useCountsDefinition ()
const resolved = normaliseCountsParams (resource, params, definition)
const fields = suppliedFields ?? fieldsForCounts (resource, definition)
const roots = suppliedFields == null ? selectedCountColumns (resource, resolved, definition)
: selectedCountFields (fields, resolved, selectionKey, parent).map (field => field.key)
const selected = selectedCountChildren (resolved, fieldsForCounts (resource, definition))
const rootPath = (key: string) => parent ? `${parent}.${key}` : key
for (const root of roots) selected.add (rootPath (root))
const select = (path: string, checked: boolean) => {
const next = new Set (selected)
const paths = countChildPaths (fields, parent)
.filter (key => key === path || key.startsWith (`${path}.`))
for (const key of paths)
{
if (checked) next.add (key)
else next.delete (key)
}
const nextParams = new URLSearchParams (resolved)
nextParams.set ('children', [...next].filter (key => key.includes ('.')).join (' '))
const children = selectedCountChildren (nextParams, fieldsForCounts (resource, definition))
update ('children', [...children].join (' '))
update (selectionKey, fields.filter (field => field.children == null
? next.has (rootPath (field.key)) : field.children.some (child =>
children.has (`${rootPath (field.key)}.${child.key}`)))
.map (field => field.key).join (' '))
}
const visibleFields = parent === ''
? fields.filter (field => field.key !== 'thumbnail') : fields
return (
<>
<FieldGroup fields={visibleFields} path={parent} params={resolved} enabled selected={selected}
select={select} update={update}/>
</>)
}
export default CountsFieldTree