このコミットが含まれているのは:
2026-10-26 12:19:43 +09:00
コミット 3e8f209542
31個のファイルの変更、1676行の追加、394行の削除
+125
ファイルの表示
@@ -0,0 +1,125 @@
import { DndContext, MouseSensor, TouchSensor, closestCenter,
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
import { countsAxisFields, countsFilterFields, countsGrains, readCountsJSON }
from '@/lib/countsDefinitions'
import { cn, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { ReactNode } from 'react'
import type { CountsResource } from '@/lib/counts'
import type { CountsAxis } from '@/lib/countsDefinitions'
const AxisItem = ({ id, name, children }: { id: string; name: string; children: ReactNode }) => {
const drag = useDraggable ({ id })
const drop = useDroppable ({ id })
return <div ref={drop.setNodeRef} className={cn (
'flex flex-wrap items-center gap-1 rounded border p-1 dark:border-slate-600',
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
<button ref={drag.setNodeRef} type="button" {...drag.listeners} {...drag.attributes}
className="cursor-grab touch-none" aria-label={name}>
<span aria-hidden>⠿</span>
</button>{children}
</div>
}
const AxisZone = ({ id, children }: { id: string; children: ReactNode }) => {
const drop = useDroppable ({ id })
return <div ref={drop.setNodeRef} className={cn (
'flex min-h-12 flex-wrap items-start gap-1 rounded border p-1 dark:border-slate-600',
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
{children}
</div>
}
const CountsAxes = ({ resource, params, update }: {
resource: CountsResource; params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { label, message } = useUITranslation ('counts')
const history = params.get ('definition') === 'history'
const available = countsAxisFields (resource, history)
const rows = readCountsJSON<CountsAxis[]> (params, 'rows', [])
const cols = readCountsJSON<CountsAxis[]> (params, 'cols', [])
const groups = { rows, cols }
const sensors = useSensors (
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }))
const place = (field: string, target: string, position?: string) => {
for (const key of ['rows', 'cols'] as const)
{
const existing = [...rows, ...cols].find (axis => axis.field === field)
const next = groups[key].filter (axis => axis.field !== field)
if (target === key)
{
const index = position == null ? next.length : next.findIndex (
axis => axis.field === position)
next.splice (index < 0 ? next.length : index, 0, existing ?? {
field, ...(countsFilterFields (resource)[field] === 'datetime'
? { grain: 'month' } : { }) })
}
update (key, JSON.stringify (next))
}
}
return <DndContext sensors={sensors} collisionDetection={closestCenter}
accessibility={{ announcements: {
onDragStart: () => undefined, onDragOver: () => undefined,
onDragMove: () => undefined, onDragEnd: () => undefined,
onDragCancel: () => undefined },
screenReaderInstructions: { draggable: message ('axis').text } }}
onDragEnd={({ active, over }) => {
if (over == null) return
const [, field] = String (active.id).split ('/')
const [zone, position] = String (over.id).split ('/')
place (field, zone, position)
}}>
<div className="space-y-2">
<fieldset><legend>{label ('axisItems')}</legend><AxisZone id="available">
{available.filter (field => ![...rows, ...cols].some (
axis => axis.field === field.key)).map (field => (
<AxisItem key={field.key} id={`available/${field.key}`} name={message (field.label).text}>
{label (field.label)}
{(['rows', 'cols'] as const).map (target => <button key={target} type="button"
aria-label={message ('axisPlace', {
field: message (field.label).text, target: message (target).text }).text}
onClick={() => place (field.key, target)}>{label (target)}</button>)}
</AxisItem>))}
</AxisZone></fieldset>
{(['rows', 'cols'] as const).map (zone => <fieldset key={zone}>
<legend>{label (zone)}</legend><AxisZone id={zone}>
{groups[zone].map ((axis, index) => {
const field = available.find (field => field.key === axis.field)
return <AxisItem key={axis.field} id={`${zone}/${axis.field}`}
name={field == null ? message ('axis').text : message (field.label).text}>
{field == null ? null : label (field.label)}
{countsFilterFields (resource)[axis.field] === 'datetime' && <select
className={inputClass (false, 'w-auto py-0')}
value={axis.grain ?? 'month'} aria-label={message ('grain').text}
onChange={event => update (zone, JSON.stringify (groups[zone].map (
item => item === axis ? { ...item, grain: event.target.value } : item)))}>
{countsGrains.map (grain => <option key={grain} value={grain}>
{message (`grains.${grain}`).text}</option>)}
</select>}
<button type="button" aria-label={message ('reorder', {
column: field == null ? '' : message (field.label).text }).text}
disabled={index === 0}
onClick={() => place (axis.field, zone, groups[zone][index - 1]?.field)}>↑</button>
<button type="button" onClick={() => place (
axis.field, zone === 'rows' ? 'cols' : 'rows')}>
{label (zone === 'rows' ? 'cols' : 'rows')}</button>
<button type="button" aria-label={message ('remove').text}
onClick={() => place (axis.field, 'available')}>×</button>
</AxisItem>
})}
</AxisZone>
</fieldset>)}
</div>
</DndContext>
}
export default CountsAxes