このコミットが含まれているのは:
@@ -0,0 +1,22 @@
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
||||
const AnimatedCollapse = ({ open, children, id, className }: {
|
||||
open: boolean; children: ReactNode; id?: string; className?: string },
|
||||
) => {
|
||||
const motionSettings = useClientMotion ()
|
||||
return <motion.div id={id} initial={false} aria-hidden={!open} inert={!open}
|
||||
animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
|
||||
transition={motionSettings.transition ('collapse')}
|
||||
className={cn ('min-h-0 overflow-hidden', className)}>
|
||||
{children}
|
||||
</motion.div>
|
||||
}
|
||||
|
||||
|
||||
export default AnimatedCollapse
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defaultAnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type Name = { text: string; language: string }
|
||||
|
||||
|
||||
const SortableItem = ({ id, name, fixed, children, moveGroup }: {
|
||||
id: string; name: Name; fixed?: boolean; children: ReactNode
|
||||
moveGroup: (direction: number) => void },
|
||||
) => {
|
||||
const motion = useClientMotion ()
|
||||
const item = useSortable ({ id, disabled: fixed,
|
||||
transition: { duration: motion.duration ('reorder'), easing: motion.easing },
|
||||
animateLayoutChanges: options => !motion.reduced
|
||||
&& defaultAnimateLayoutChanges ({ ...options, wasDragging: true }) })
|
||||
return <div ref={item.setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString (item.transform), transition: item.transition }}
|
||||
className={cn ('relative max-w-full rounded border border-slate-300 bg-white',
|
||||
'text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100',
|
||||
item.isDragging && 'opacity-30 ring-2 ring-blue-500 dark:ring-blue-400')}>
|
||||
<div className="flex min-w-0 items-start gap-1 px-1">
|
||||
{fixed ? <span className="w-6 shrink-0"/> : <button type="button"
|
||||
ref={item.setActivatorNodeRef} {...item.attributes} {...item.listeners}
|
||||
aria-label={name.text} lang={name.language}
|
||||
aria-keyshortcuts="Space ArrowUp ArrowDown ArrowLeft ArrowRight Enter Escape"
|
||||
className="mt-1 w-6 shrink-0 cursor-grab touch-none rounded
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
|
||||
active:cursor-grabbing dark:focus-visible:ring-blue-400"
|
||||
onClick={event => event.stopPropagation ()}
|
||||
onKeyDown={event => {
|
||||
if (event.altKey && ['ArrowLeft', 'ArrowRight'].includes (event.key))
|
||||
{
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
moveGroup (event.key === 'ArrowLeft' ? -1 : 1)
|
||||
}
|
||||
else item.listeners?.onKeyDown?.(event)
|
||||
}}><span aria-hidden>⠿</span></button>}
|
||||
<div className="min-w-0 flex-1 py-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
export default SortableItem
|
||||
@@ -0,0 +1,104 @@
|
||||
import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor,
|
||||
closestCenter, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { SortableContext, arrayMove, rectSortingStrategy,
|
||||
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import SortableItem from '@/components/common/SortableItem'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type Groups = Record<string, string[]>
|
||||
type Name = { text: string; language: string }
|
||||
|
||||
|
||||
const Group = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||
const drop = useDroppable ({ id: `group:${id}` })
|
||||
return <div ref={drop.setNodeRef} className={cn (
|
||||
'flex min-h-10 flex-wrap items-start gap-1 rounded p-1',
|
||||
drop.isOver && 'ring-1 ring-blue-500 dark:ring-blue-400')}>{children}</div>
|
||||
}
|
||||
|
||||
|
||||
const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed = [] }: {
|
||||
groups: Groups; name: (id: string) => Name
|
||||
renderItem: (id: string, group: string) => ReactNode
|
||||
renderGroup: (id: string, content: ReactNode) => ReactNode
|
||||
onChange: (groups: Groups) => void; fixed?: string[] },
|
||||
) => {
|
||||
const [preview, setPreview] = useState<Groups | null> (null)
|
||||
const [active, setActive] = useState<string | null> (null)
|
||||
const motion = useClientMotion ()
|
||||
const current = preview ?? groups
|
||||
const sensors = useSensors (
|
||||
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
|
||||
useSensor (KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }))
|
||||
const owner = (id: string, value: Groups) => Object.keys (value)
|
||||
.find (group => value[group].includes (id))
|
||||
const relocate = (value: Groups, id: string, over: string): Groups => {
|
||||
const source = owner (id, value)
|
||||
const target = over.startsWith ('group:') ? over.slice (6) : owner (over, value)
|
||||
if (source == null || target == null || fixed.includes (id) || fixed.includes (over))
|
||||
return value
|
||||
if (source === target)
|
||||
{
|
||||
const from = value[source].indexOf (id)
|
||||
const to = over.startsWith ('group:') ? value[target].length - 1 : value[target].indexOf (over)
|
||||
if (from === to || to < 0) return value
|
||||
return { ...value, [source]: arrayMove (value[source], from, to) }
|
||||
}
|
||||
const destination = value[target].filter (item => item !== id)
|
||||
const position = destination.indexOf (over)
|
||||
destination.splice (position < 0 ? destination.length : position, 0, id)
|
||||
return { ...value, [source]: value[source].filter (item => item !== id), [target]: destination }
|
||||
}
|
||||
const moveGroup = (id: string, direction: number) => {
|
||||
const keys = Object.keys (groups)
|
||||
const group = owner (id, groups)
|
||||
const target = keys[keys.indexOf (group ?? '') + direction]
|
||||
if (target != null) onChange (relocate (groups, id, `group:${target}`))
|
||||
}
|
||||
const reset = () => { setPreview (null); setActive (null) }
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter}
|
||||
accessibility={{ announcements: {
|
||||
onDragStart: event => name (String (event.active.id)).text,
|
||||
onDragOver: event => event.over == null ? undefined : (
|
||||
event.over.id.toString ().startsWith ('group:') ? undefined
|
||||
: name (String (event.over.id)).text),
|
||||
onDragMove: () => undefined, onDragEnd: () => undefined,
|
||||
onDragCancel: () => undefined },
|
||||
screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }}
|
||||
onDragStart={event => { setPreview (groups); setActive (String (event.active.id)) }}
|
||||
onDragOver={event => {
|
||||
if (event.over == null || event.active.id === event.over.id) return
|
||||
setPreview (previous => relocate (
|
||||
previous ?? groups, String (event.active.id), String (event.over!.id)))
|
||||
}} onDragCancel={reset} onDragEnd={event => {
|
||||
if (event.over != null && preview != null) onChange (preview)
|
||||
reset ()
|
||||
}}>
|
||||
{Object.entries (current).map (([group, ids]) => <div key={group}>
|
||||
{renderGroup (group, <SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||
<Group id={group}>{ids.map (id => <SortableItem key={id} id={id}
|
||||
name={name (id)} fixed={fixed.includes (id)} moveGroup={direction => moveGroup (id, direction)}>
|
||||
{renderItem (id, group)}
|
||||
</SortableItem>)}</Group>
|
||||
</SortableContext>)}
|
||||
</div>)}
|
||||
{typeof document !== 'undefined' && createPortal (
|
||||
<DragOverlay dropAnimation={motion.reduced ? null : {
|
||||
duration: motion.duration ('drop'), easing: motion.easing }}>
|
||||
{active == null ? null : <div lang={name (active).language}
|
||||
className="rounded border border-blue-500 bg-white px-3 py-2 text-sm
|
||||
text-slate-900 shadow-lg dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">
|
||||
{name (active).text}</div>}
|
||||
</DragOverlay>, document.body)}
|
||||
</DndContext>
|
||||
}
|
||||
|
||||
|
||||
export default SortableList
|
||||
@@ -2,24 +2,24 @@ import { useState } from 'react'
|
||||
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { countsFieldLabel, countsFilterFields, countsOperators, readCountsJSON }
|
||||
import { countsFieldLabel, countsFilterFields, readCountsJSON, useCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountsResource } from '@/lib/counts'
|
||||
import type { CountsCondition } from '@/lib/countsDefinitions'
|
||||
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
const ConditionEditor = ({ resource, initial, save }: {
|
||||
resource: CountsResource; initial?: CountsCondition
|
||||
const ConditionEditor = ({ definition, initial, save }: {
|
||||
definition: CountsDefinition; initial?: CountsCondition
|
||||
save: (condition: CountsCondition) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const fields = countsFilterFields (resource)
|
||||
const fields = countsFilterFields (definition)
|
||||
const [field, setField] = useState (initial?.field ?? 'resource_id')
|
||||
const [operator, setOperator] = useState (initial?.operator ?? 'eq')
|
||||
const operators = definition.fields.find (item => item.key === field)?.operators ?? []
|
||||
return <div className="space-y-2">
|
||||
<select value={field} aria-label={message ('field').text} className={inputClass ()}
|
||||
onChange={event => { setField (event.target.value); setOperator ('eq') }}>
|
||||
@@ -28,7 +28,7 @@ const ConditionEditor = ({ resource, initial, save }: {
|
||||
</select>
|
||||
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
|
||||
onChange={event => setOperator (event.target.value)}>
|
||||
{countsOperators[fields[field]].map (key => <option key={key} value={key}>
|
||||
{operators.map (key => <option key={key} value={key}>
|
||||
{message (`operators.${key}`).text}</option>)}
|
||||
</select>
|
||||
<Button type="button" onClick={() => save ({ ...initial, field, operator })}>
|
||||
@@ -37,17 +37,19 @@ const ConditionEditor = ({ resource, initial, save }: {
|
||||
}
|
||||
|
||||
|
||||
const CountsAdditionalConditions = ({ resource, params, update }: {
|
||||
resource: CountsResource; params: URLSearchParams
|
||||
const CountsAdditionalConditions = ({ params, update }: {
|
||||
params: URLSearchParams
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const definition = useCountsDefinition ()
|
||||
const dialogue = useDialogue ()
|
||||
const conditions = readCountsJSON<CountsCondition[]> (params, 'conditions', [])
|
||||
const write = (next: CountsCondition[]) => update ('conditions', JSON.stringify (next))
|
||||
const edit = (index?: number) => {
|
||||
void dialogue.form ({ title: label ('additionalConditions'), body: controls => (
|
||||
<ConditionEditor resource={resource} initial={index == null ? undefined : conditions[index]}
|
||||
<ConditionEditor definition={definition}
|
||||
initial={index == null ? undefined : conditions[index]}
|
||||
save={condition => {
|
||||
write (index == null ? [...conditions, condition]
|
||||
: conditions.map ((item, position) => position === index ? condition : item))
|
||||
@@ -61,10 +63,11 @@ const CountsAdditionalConditions = ({ resource, params, update }: {
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => edit ()}>
|
||||
{label ('add')}</Button></legend>
|
||||
{conditions.map ((condition, index) => {
|
||||
const datetime = countsFilterFields (resource)[condition.field] === 'datetime'
|
||||
const datetime = countsFilterFields (definition)[condition.field] === 'datetime'
|
||||
return <div key={index} className="flex flex-wrap items-center gap-1">
|
||||
<button type="button" onClick={() => edit (index)}>
|
||||
{label (countsFieldLabel (condition.field))} · {label (`operators.${condition.operator}`)}
|
||||
{label (countsFieldLabel (condition.field))}
|
||||
{' · '}{label (`operators.${condition.operator}`)}
|
||||
</button>
|
||||
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const)
|
||||
.map (key => datetime ? <DateTimeField precision="second" key={key}
|
||||
@@ -80,7 +83,8 @@ const CountsAdditionalConditions = ({ resource, params, update }: {
|
||||
onChange={event => patch (index, { blank: event.target.checked })}/>
|
||||
{label ('blank')}</label>
|
||||
<button type="button" aria-label={message ('remove').text}
|
||||
onClick={() => write (conditions.filter ((_, position) => index !== position))}>×</button>
|
||||
onClick={() => write (conditions.filter (
|
||||
(_, position) => index !== position))}>×</button>
|
||||
</div>
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
@@ -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>
|
||||
}}/>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useEffect, useId, useState } from 'react'
|
||||
import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions'
|
||||
import CountsAxes from '@/components/counts/CountsAxes'
|
||||
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
||||
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import HelpPopover from '@/components/common/HelpPopover'
|
||||
import TagInput from '@/components/common/TagInput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { normaliseCountsParams } from '@/lib/counts'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
@@ -27,6 +29,7 @@ const CountsControls = (
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const id = useId ()
|
||||
const motion = useClientMotion ()
|
||||
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
|
||||
const order = applied.get ('order')
|
||||
const nestedJSON = applied.get ('nested_json')
|
||||
@@ -75,7 +78,9 @@ const CountsControls = (
|
||||
<Button type="submit" size="sm">{label ('run')}</Button>
|
||||
<Button type="button" size="sm" variant="outline" aria-expanded={open}
|
||||
aria-controls={id} onClick={() => setOpen (!open)}>
|
||||
{label ('conditions')}<span aria-hidden>{open ? '▴' : '▾'}</span>
|
||||
{label ('conditions')}<span aria-hidden className="inline-block" style={{
|
||||
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transition: `transform ${motion.duration ('collapse')}ms ${motion.easing}` }}>▸</span>
|
||||
</Button>
|
||||
<select className={inputClass (false, 'w-auto py-1')}
|
||||
aria-label={message ('mode').text} lang={message ('mode').language}
|
||||
@@ -84,11 +89,12 @@ const CountsControls = (
|
||||
{message (value).text}</option>)}
|
||||
</select>{actions}
|
||||
</div>
|
||||
<div id={id} hidden={!open} className="max-h-[35dvh] overflow-y-auto overscroll-contain
|
||||
<AnimatedCollapse id={id} open={open}>
|
||||
<div className="max-h-[35dvh] overflow-y-auto overscroll-contain
|
||||
border-b py-2 dark:border-slate-700 md:max-h-[40dvh]">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<fieldset className="min-w-0" data-counts-output>
|
||||
{mode === 'summary' ? <CountsAxes resource={resource} params={draft} update={update}/>
|
||||
{mode === 'summary' ? <CountsAxes params={draft} update={update}/>
|
||||
: <>
|
||||
<legend className="mb-1 font-semibold">{label ('output')}</legend>
|
||||
<label className="mb-1 flex items-center gap-1">
|
||||
@@ -120,10 +126,11 @@ const CountsControls = (
|
||||
{() => <TagInput includeNico includeUnused value={draft.get ('tags') ?? ''}
|
||||
setValue={value => update ('tags', value)}/>}
|
||||
</FormField>}
|
||||
<CountsAdditionalConditions resource={resource} params={draft} update={update}/>
|
||||
<CountsAdditionalConditions params={draft} update={update}/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedCollapse>
|
||||
</form>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,116 +1,78 @@
|
||||
import { DndContext, MouseSensor, TouchSensor, closestCenter,
|
||||
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { useId, useState } from 'react'
|
||||
|
||||
import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields,
|
||||
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
|
||||
import SortableList from '@/components/common/SortableList'
|
||||
import { countChildPaths, fieldsForCounts, 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
|
||||
field: CountField; path: string; enabled: boolean
|
||||
selected: Set<string>; params: URLSearchParams
|
||||
select: (path: string, checked: boolean) => void
|
||||
update: (key: string, value: string) => void
|
||||
move: (from: string, to: string) => void
|
||||
previous?: string
|
||||
next?: string }
|
||||
update: (key: string, value: string) => void }
|
||||
|
||||
|
||||
const FieldBranch = (
|
||||
{ field, path, enabled, selected, params, select, update, move, previous, next }: BranchProps,
|
||||
) => {
|
||||
const FieldBranch = ({ field, path, enabled, selected, params, select, update }: BranchProps) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const fixed = path === 'thumbnail'
|
||||
const drag = useDraggable ({ id: field.key, disabled: fixed })
|
||||
const drop = useDroppable ({ id: field.key, disabled: fixed })
|
||||
const [open, setOpen] = useState (false)
|
||||
const id = useId ()
|
||||
const motion = useClientMotion ()
|
||||
const checked = selected.has (path)
|
||||
const control = <label className="inline-flex items-center gap-1">
|
||||
<input type="checkbox" checked={checked} disabled={!enabled}
|
||||
onClick={event => event.stopPropagation ()}
|
||||
onChange={event => select (path, event.target.checked)}/>
|
||||
{label (field.label)}
|
||||
</label>
|
||||
return (
|
||||
<div ref={drop.setNodeRef} className={cn ('max-w-full rounded border border-slate-200 px-1',
|
||||
'dark:border-slate-700',
|
||||
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
|
||||
<div ref={drag.setNodeRef} className="flex min-w-0 flex-1 items-start gap-1"
|
||||
style={drag.transform == null ? undefined : {
|
||||
position: 'relative', zIndex: 1,
|
||||
transform: `translate3d(${drag.transform.x}px, ${drag.transform.y}px, 0)` }}>
|
||||
{fixed ? <span className="w-6 shrink-0"/> : (
|
||||
<button type="button" ref={drag.setActivatorNodeRef}
|
||||
className="mt-1 w-6 shrink-0 cursor-grab touch-none active:cursor-grabbing"
|
||||
{...drag.attributes} {...drag.listeners}
|
||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||
aria-label={message ('reorder', { column: message (field.label).text }).text}
|
||||
lang={message ('reorder').language}
|
||||
onClick={event => event.stopPropagation ()}
|
||||
onKeyDown={event => {
|
||||
if (!['ArrowUp', 'ArrowDown'].includes (event.key)) return
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
const target = event.key === 'ArrowUp' ? previous : next
|
||||
if (target != null) move (field.key, target)
|
||||
}}><span aria-hidden>⠿</span></button>)}
|
||||
{field.children ? (
|
||||
<details className="my-1 min-w-0 flex-1">
|
||||
<summary className="cursor-pointer">{control}</summary>
|
||||
<div className="ml-2 border-l pl-1 dark:border-slate-700">
|
||||
<FieldGroup fields={field.children} path={path} params={params}
|
||||
enabled={enabled && checked} selected={selected} select={select}
|
||||
update={update}/>
|
||||
</div>
|
||||
</details>) : <div className="my-1">{control}</div>}
|
||||
</div>
|
||||
</div>)
|
||||
return <>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<input type="checkbox" checked={checked} disabled={!enabled}
|
||||
onChange={event => select (path, event.target.checked)}/>
|
||||
{label (field.label)}
|
||||
</label>
|
||||
{field.children != null && <button type="button" aria-expanded={open}
|
||||
aria-controls={id} aria-label={message (field.label).text}
|
||||
lang={message (field.label).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 && checked} 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 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 movable = ordered.filter (field => path !== '' || field.key !== 'thumbnail')
|
||||
const sensors = useSensors (
|
||||
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }))
|
||||
const move = (from: string, to: string) =>
|
||||
update ('order', reorderCountFields (fields, params, path, from, to))
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter}
|
||||
accessibility={{ announcements: {
|
||||
onDragStart: () => undefined, onDragOver: () => undefined,
|
||||
onDragMove: () => undefined,
|
||||
onDragEnd: () => undefined, onDragCancel: () => undefined },
|
||||
screenReaderInstructions: { draggable: message ('output').text } }}
|
||||
onDragEnd={({ active, over }) => {
|
||||
if (over != null && active.id !== over.id) move (String (active.id), String (over.id))
|
||||
}}>
|
||||
<div className="flex flex-wrap items-start gap-1">
|
||||
{ordered.map (field => {
|
||||
const position = movable.findIndex (item => item.key === field.key)
|
||||
return <FieldBranch key={field.key} field={field}
|
||||
path={path ? `${path}.${field.key}` : field.key}
|
||||
enabled={enabled} selected={selected} params={params}
|
||||
select={select} update={update} move={move}
|
||||
previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/>
|
||||
})}
|
||||
</div>
|
||||
</DndContext>)
|
||||
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).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}/>
|
||||
}}/>
|
||||
}
|
||||
|
||||
|
||||
@@ -124,8 +86,9 @@ const CountsFieldTree = (
|
||||
params: URLSearchParams
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const fields = suppliedFields ?? fieldsForCounts (resource)
|
||||
const roots = suppliedFields == null ? selectedCountColumns (resource, params)
|
||||
const definition = useCountsDefinition ()
|
||||
const fields = suppliedFields ?? fieldsForCounts (resource, definition)
|
||||
const roots = suppliedFields == null ? selectedCountColumns (resource, params, definition)
|
||||
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
|
||||
const selected = selectedCountChildren (params)
|
||||
const rootPath = (key: string) => parent ? `${parent}.${key}` : key
|
||||
|
||||
@@ -10,6 +10,7 @@ import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts,
|
||||
import { countsCellCopy, countsCellText, countsImageURL, countsNestedRows } from '@/lib/countsDisplay'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useCountsDefinition } from '@/lib/countsDefinitions'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountColumn, CountsResource,
|
||||
@@ -24,11 +25,12 @@ const CountsResults = (
|
||||
pending: boolean
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const definition = useCountsDefinition ()
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const locale = useLocale ()
|
||||
const dialogue = useDialogue ()
|
||||
const columns = selectedCountColumns (resource, params)
|
||||
const fields = fieldsForCounts (resource)
|
||||
const columns = selectedCountColumns (resource, params, definition)
|
||||
const fields = fieldsForCounts (resource, definition)
|
||||
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
|
||||
const sort = (key: string, additive: boolean) =>
|
||||
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする