このコミットが含まれているのは:
@@ -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
|
||||
新しいイシューから参照
ユーザーをブロックする