このコミットが含まれているのは:
@@ -1,124 +1,82 @@
|
||||
import { DndContext, MouseSensor, TouchSensor, closestCenter,
|
||||
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
|
||||
|
||||
import { countsAxisFields, countsFilterFields, countsGrains, readCountsJSON }
|
||||
import SortableList from '@/components/common/SortableList'
|
||||
import { countsAxisFields, countsFilterFields, readCountsJSON, useCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import { cn, inputClass } from '@/lib/utils'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { 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 CountsAxes = ({ params, update }: {
|
||||
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 definition = useCountsDefinition ()
|
||||
const motion = useClientMotion ()
|
||||
const available = countsAxisFields (definition, params.get ('definition') === '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))
|
||||
}
|
||||
const axes = [...rows, ...cols]
|
||||
const groups = {
|
||||
available: available.map (field => field.key).filter (id => !axes.some (
|
||||
axis => axis.field === id)),
|
||||
rows: rows.map (axis => axis.field), cols: cols.map (axis => axis.field) }
|
||||
const name = (id: string) => message (
|
||||
available.find (field => field.key === id)?.label ?? 'axis')
|
||||
const commit = (next: Record<string, string[]>) => {
|
||||
for (const group of ['rows', 'cols'] as const)
|
||||
update (group, JSON.stringify (next[group].map (field => axes.find (
|
||||
axis => axis.field === field) ?? { field,
|
||||
...(countsFilterFields (definition)[field] === 'datetime' ? { grain: 'month' } : { }) })))
|
||||
}
|
||||
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>
|
||||
const place = (id: string, target?: 'rows' | 'cols') => {
|
||||
const next = Object.fromEntries (Object.entries (groups).map (([key, ids]) => [
|
||||
key, ids.filter (value => value !== id)]))
|
||||
if (target != null) next[target].push (id)
|
||||
else next.available.push (id)
|
||||
commit (next)
|
||||
}
|
||||
return <SortableList groups={groups} name={name} onChange={commit}
|
||||
renderGroup={(id, content) => <fieldset className="mb-2 min-w-0">
|
||||
<legend>{label (id === 'available' ? 'axisItems' : id)}</legend>
|
||||
<div className="rounded border dark:border-slate-700">{content}</div>
|
||||
</fieldset>} renderItem={(id, group) => {
|
||||
const axis = axes.find (axis => axis.field === id)
|
||||
return <div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{group === 'available' && <span className="inline-flex gap-1">
|
||||
{(['rows', 'cols'] as const).map (target => <button key={target} type="button"
|
||||
className="h-7 w-7 rounded-full border border-slate-300 bg-slate-100
|
||||
text-xs text-slate-700 hover:bg-blue-100 hover:text-blue-900
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
|
||||
dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100
|
||||
dark:hover:bg-blue-900 dark:hover:text-blue-100 dark:focus-visible:ring-blue-400"
|
||||
style={{ transition: `background-color ${motion.duration ('feedback')}ms ${
|
||||
motion.easing}, color ${motion.duration ('feedback')}ms ${motion.easing}` }}
|
||||
aria-label={message ('axisPlace', {
|
||||
field: name (id).text, target: message (target).text }).text}
|
||||
lang={message ('axisPlace').language} onClick={() => place (id, target)}>
|
||||
{label (target)}</button>)}
|
||||
</span>}
|
||||
{label (available.find (field => field.key === id)?.label ?? 'axis')}
|
||||
{group !== 'available' && <>
|
||||
{countsFilterFields (definition)[id] === 'datetime' && <select
|
||||
className={inputClass (false, 'w-auto py-0')}
|
||||
value={axis?.grain ?? 'month'} aria-label={message ('grain').text}
|
||||
onChange={event => update (group, JSON.stringify (
|
||||
(group === 'rows' ? rows : cols).map (item => item.field === id
|
||||
? { ...item, grain: event.target.value } : item)))}>
|
||||
{definition.grains.map (grain => <option key={grain} value={grain}
|
||||
lang={message (`grains.${grain}`).language}>
|
||||
{message (`grains.${grain}`).text}</option>)}
|
||||
</select>}
|
||||
<button type="button" aria-label={message ('remove').text}
|
||||
lang={message ('remove').language} className="rounded px-1
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
|
||||
dark:focus-visible:ring-blue-400" onClick={() => place (id)}>×</button>
|
||||
</>}
|
||||
</div>
|
||||
}}/>
|
||||
}
|
||||
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする