コミットを比較
4 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 5cf46406a9 | |||
| 29c385ed9f | |||
| abeee76ddd | |||
| 347a0ebbba |
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
||||
import { buildTag } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const dndKit = vi.hoisted (() => ({
|
||||
useDraggable: vi.fn (),
|
||||
useDroppable: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@dnd-kit/core', () => dndKit)
|
||||
|
||||
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
|
||||
|
||||
const renderRow = (activeDndId?: string) => {
|
||||
renderWithProviders (
|
||||
<DraggableDroppableTagRow
|
||||
activeDndId={activeDndId}
|
||||
tag={tag}
|
||||
nestLevel={2}
|
||||
pathKey="cat-general-7"
|
||||
suppressClickRef={{ current: false }}/>,
|
||||
)
|
||||
}
|
||||
|
||||
const tagBody = (): HTMLElement => {
|
||||
const link = document.querySelector<HTMLElement> ('a[title="ドラッグ元"]')
|
||||
|
||||
if (link == null)
|
||||
throw new Error ('tag link not found')
|
||||
|
||||
const body = link.closest ('div')
|
||||
|
||||
if (body == null)
|
||||
throw new Error ('tag body not found')
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
describe ('DraggableDroppableTagRow', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
dndKit.useDraggable.mockReturnValue ({
|
||||
attributes: { 'aria-describedby': 'drag-source' },
|
||||
listeners: { onPointerDown: vi.fn () },
|
||||
setNodeRef: vi.fn (),
|
||||
transform: null })
|
||||
dndKit.useDroppable.mockReturnValue ({
|
||||
isOver: false,
|
||||
setNodeRef: vi.fn () })
|
||||
})
|
||||
|
||||
it ('passes dndId through draggable data for active-row tracking', () => {
|
||||
renderRow ()
|
||||
|
||||
expect (dndKit.useDraggable).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({
|
||||
id: 'tag-node:cat-general-7',
|
||||
data: expect.objectContaining ({
|
||||
dndId: 'tag-node:cat-general-7',
|
||||
nestLevel: 2,
|
||||
tagId: 7 }) }))
|
||||
})
|
||||
|
||||
it ('hides only the active drag source from the explicit active dnd id', () => {
|
||||
renderRow ('tag-node:cat-general-7')
|
||||
expect (tagBody ()).toHaveStyle ({ visibility: 'hidden' })
|
||||
})
|
||||
|
||||
it ('keeps inactive tag rows visible while another tag is dragged', () => {
|
||||
renderRow ('tag-node:other')
|
||||
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import type { CSSProperties, FC, MutableRefObject } from 'react'
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
type Props = {
|
||||
activeDndId?: string
|
||||
tag: Tag
|
||||
nestLevel: number
|
||||
pathKey: string
|
||||
@@ -21,7 +22,15 @@ type Props = {
|
||||
sp?: boolean }
|
||||
|
||||
|
||||
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => {
|
||||
const DraggableDroppableTagRow: FC<Props> = ({
|
||||
activeDndId,
|
||||
tag,
|
||||
nestLevel,
|
||||
pathKey,
|
||||
parentTagId,
|
||||
suppressClickRef,
|
||||
sp,
|
||||
}) => {
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const layoutTransition = clientAnimationTransition (
|
||||
@@ -53,9 +62,9 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
||||
const { attributes,
|
||||
listeners,
|
||||
setNodeRef: setDragRef,
|
||||
transform,
|
||||
isDragging: dragging } = useDraggable ({ id: dndId,
|
||||
transform } = useDraggable ({ id: dndId,
|
||||
data: { kind: 'tag',
|
||||
dndId,
|
||||
tagId: tag.id,
|
||||
parentTagId,
|
||||
nestLevel } })
|
||||
@@ -64,8 +73,12 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
||||
id: dndId,
|
||||
data: { kind: 'tag', tagId: tag.id } })
|
||||
|
||||
const activeDragging = activeDndId === dndId
|
||||
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
|
||||
visibility: dragging ? 'hidden' : 'visible' }
|
||||
visibility: activeDragging ? 'hidden' : 'visible' }
|
||||
const innerClassName = cn (
|
||||
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
|
||||
sp && 'touch-pan-y')
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -101,12 +114,21 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
||||
sp && 'touch-pan-y',
|
||||
over && 'ring-2 ring-offset-2')}
|
||||
>
|
||||
{activeDragging
|
||||
? (
|
||||
<div
|
||||
ref={setDragRef}
|
||||
style={style}
|
||||
className={innerClassName}
|
||||
{...attributes}
|
||||
{...listeners}>
|
||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||
</div>)
|
||||
: (
|
||||
<motion.div
|
||||
ref={setDragRef}
|
||||
style={style}
|
||||
className={cn (
|
||||
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
|
||||
sp && 'touch-pan-y')}
|
||||
className={innerClassName}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
transition={{ layout: layoutTransition }}
|
||||
@@ -114,7 +136,7 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
||||
? undefined
|
||||
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||
</motion.div>
|
||||
</motion.div>)}
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DndContext,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors } from '@dnd-kit/core'
|
||||
import { restrictToWindowEdges } from '@dnd-kit/modifiers'
|
||||
import { restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -43,6 +43,7 @@ const alwaysMeasureDroppables = {
|
||||
|
||||
|
||||
const renderTagTree = (
|
||||
activeDndId: string | undefined,
|
||||
tag: TagWithSections,
|
||||
nestLevel: number,
|
||||
path: string,
|
||||
@@ -54,6 +55,7 @@ const renderTagTree = (
|
||||
const self = (
|
||||
<li key={key} className="mb-1">
|
||||
<DraggableDroppableTagRow
|
||||
activeDndId={activeDndId}
|
||||
tag={tag}
|
||||
nestLevel={nestLevel}
|
||||
pathKey={key}
|
||||
@@ -67,7 +69,14 @@ const renderTagTree = (
|
||||
...((tag.children
|
||||
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
||||
.flatMap (child =>
|
||||
renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp)))
|
||||
renderTagTree (
|
||||
activeDndId,
|
||||
child,
|
||||
nestLevel + 1,
|
||||
key,
|
||||
suppressClickRef,
|
||||
tag.id,
|
||||
sp)))
|
||||
?? [])]
|
||||
}
|
||||
|
||||
@@ -211,6 +220,7 @@ type Props = {
|
||||
sp?: boolean }
|
||||
|
||||
type ActiveTagDrag = {
|
||||
dndId: string
|
||||
tagId: number
|
||||
nestLevel: number }
|
||||
|
||||
@@ -251,6 +261,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||
() => buildFlatTagByCategory (tags),
|
||||
[tags],
|
||||
)
|
||||
const activeDndId = activeTagDrag?.dndId
|
||||
|
||||
const suppressClickRef = useRef (false)
|
||||
|
||||
@@ -359,17 +370,18 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||
<TagSearch/>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={sp ? tagCollisionDetection : undefined}
|
||||
measuring={sp ? alwaysMeasureDroppables : undefined}
|
||||
collisionDetection={tagCollisionDetection}
|
||||
measuring={alwaysMeasureDroppables}
|
||||
onDragStart={e => {
|
||||
if (e.active.data.current?.kind === 'tag')
|
||||
{
|
||||
const tagId = e.active.data.current?.tagId
|
||||
const nestLevel = e.active.data.current?.nestLevel
|
||||
const dndId = e.active.data.current?.dndId
|
||||
setActiveTagDrag (
|
||||
tagId == null || nestLevel == null
|
||||
tagId == null || nestLevel == null || dndId == null
|
||||
? null
|
||||
: { tagId, nestLevel })
|
||||
: { dndId, tagId, nestLevel })
|
||||
}
|
||||
setDragging (true)
|
||||
suppressClickRef.current = true
|
||||
@@ -426,6 +438,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||
{(tagRelationDisplay === 'grouped'
|
||||
? (tags[cat] ?? []).flatMap (tag =>
|
||||
renderTagTree (
|
||||
activeDndId,
|
||||
tag,
|
||||
0,
|
||||
`cat-${ cat }`,
|
||||
@@ -437,6 +450,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||
: (flatTagsByCategory[cat] ?? []).map (tag => (
|
||||
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
|
||||
<DraggableDroppableTagRow
|
||||
activeDndId={activeDndId}
|
||||
tag={tag}
|
||||
nestLevel={0}
|
||||
pathKey={`flat-${ cat }-${ tag.id }`}
|
||||
@@ -489,7 +503,10 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||
</ul>
|
||||
</motion.div>)}
|
||||
|
||||
<DragOverlay adjustScale={false}>
|
||||
<DragOverlay
|
||||
adjustScale={false}
|
||||
modifiers={[snapCenterToCursor]}
|
||||
dropAnimation={animationMode === 'off' ? null : undefined}>
|
||||
<div className="pointer-events-none">
|
||||
{activeTagDrag != null && (() => {
|
||||
const tag = findTag (tags, activeTagDrag.tagId)
|
||||
|
||||
新しい課題から参照
ユーザをブロックする