このコミットが含まれているのは:
@@ -0,0 +1,79 @@
|
||||
import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor,
|
||||
closestCenter, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy,
|
||||
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
||||
const SortableColumns = ({ ids, fixed = [], name, onChange, children }: {
|
||||
ids: string[]; fixed?: string[]; name: (id: string) => { text: string; language: string }
|
||||
onChange: (ids: string[]) => void; children: (ids: string[]) => ReactNode },
|
||||
) => {
|
||||
const [preview, setPreview] = useState<string[] | null> (null)
|
||||
const [active, setActive] = useState<string | null> (null)
|
||||
const latest = useRef<string[] | null> (null)
|
||||
const lastOver = useRef<string | null> (null)
|
||||
const motion = useClientMotion ()
|
||||
const sensors = useSensors (
|
||||
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
|
||||
useSensor (KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }))
|
||||
const move = (current: string[], from: string, to: string) => {
|
||||
if (fixed.includes (from) || fixed.includes (to)) return current
|
||||
const source = current.indexOf (from)
|
||||
const target = current.indexOf (to)
|
||||
return source < 0 || target < 0 ? current : arrayMove (current, source, target)
|
||||
}
|
||||
const reset = () => {
|
||||
latest.current = null
|
||||
lastOver.current = null
|
||||
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 : name (String (event.over.id)).text,
|
||||
onDragMove: () => undefined, onDragEnd: () => undefined,
|
||||
onDragCancel: () => undefined },
|
||||
screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }}
|
||||
onDragStart={event => {
|
||||
latest.current = ids
|
||||
setPreview (ids)
|
||||
setActive (String (event.active.id))
|
||||
}} onDragOver={event => {
|
||||
if (event.over == null || event.active.id === event.over.id) return
|
||||
const target = String (event.over.id)
|
||||
if (target === lastOver.current) return
|
||||
latest.current = move (latest.current ?? ids, String (event.active.id), target)
|
||||
lastOver.current = target
|
||||
setPreview (latest.current)
|
||||
}} onDragCancel={reset} onDragEnd={event => {
|
||||
if (event.over != null)
|
||||
{
|
||||
const target = String (event.over.id)
|
||||
const current = latest.current ?? ids
|
||||
onChange (target === lastOver.current || target === String (event.active.id)
|
||||
? current : move (current, String (event.active.id), target))
|
||||
}
|
||||
reset ()
|
||||
}}>
|
||||
<SortableContext items={preview ?? ids} strategy={horizontalListSortingStrategy}>
|
||||
{children (preview ?? ids)}
|
||||
</SortableContext>
|
||||
{typeof document !== 'undefined' && createPortal (<DragOverlay
|
||||
dropAnimation={motion.reduced ? null : {
|
||||
duration: motion.duration ('drop'), easing: motion.easing }}>
|
||||
{active != null && <div lang={name (active).language}
|
||||
className="rounded border border-blue-500 bg-white p-2 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 SortableColumns
|
||||
@@ -4,30 +4,28 @@ import { CSS } from '@dnd-kit/utilities'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
|
||||
type Name = { text: string; language: string }
|
||||
|
||||
|
||||
const SortableItem = ({ id, name, fixed, children, moveGroup, handleId }: {
|
||||
const SortableItem = ({ id, name, fixed, children, moveGroup, handleId, renderShell }: {
|
||||
id: string; name: Name; fixed?: boolean; children: ReactNode
|
||||
moveGroup: (direction: number) => void; handleId: string },
|
||||
moveGroup: (direction: number) => void; handleId: string
|
||||
renderShell?: (shell: { ref: (node: HTMLElement | null) => void
|
||||
style: CSSProperties; handle: ReactNode; dragging: boolean }) => ReactNode },
|
||||
) => {
|
||||
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"
|
||||
const handle = fixed ? <span className="w-6 shrink-0"/> : <button type="button"
|
||||
id={handleId} ref={item.setActivatorNodeRef} {...item.attributes} {...item.listeners}
|
||||
aria-label={name.text} lang={name.language}
|
||||
aria-keyshortcuts="Space ArrowUp ArrowDown Enter Escape Alt+ArrowLeft Alt+ArrowRight"
|
||||
aria-keyshortcuts={renderShell == null
|
||||
? 'Space ArrowUp ArrowDown Enter Escape Alt+ArrowLeft Alt+ArrowRight'
|
||||
: 'Space 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"
|
||||
@@ -41,7 +39,16 @@ const SortableItem = ({ id, name, fixed, children, moveGroup, handleId }: {
|
||||
moveGroup (event.key === 'ArrowLeft' ? -1 : 1)
|
||||
}
|
||||
else item.listeners?.onKeyDown?.(event)
|
||||
}}><span aria-hidden>⠿</span></button>}
|
||||
}}><span aria-hidden>⠿</span></button>
|
||||
const style = { transform: CSS.Transform.toString (item.transform), transition: item.transition }
|
||||
if (renderShell != null)
|
||||
return renderShell ({ ref: item.setNodeRef, style, handle, dragging: item.isDragging })
|
||||
return <div ref={item.setNodeRef} style={style}
|
||||
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">
|
||||
{handle}
|
||||
<div className="min-w-0 flex-1 py-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする