组件 · 操作
Drag
Raft 里每一种拖动都由这些部件搭成:一套传感器、跟着手走的被拿起的副本、标出一项将落在哪里的虚线位置、某个位置拒绝它的原因,以及按名称的播报。
也叫 Drag and drop, DnD, DragLift, DropPlaceholder, DragRefusalHint, useDragSensors, useDragLists, Sortable
import { Drag } from "@botiverse/raft-ui/wip";Raft 里每一种拖动都由 Drag 这组部件搭成,底层是 dnd-kit:useDragSensors 让拖动在各处以同样的方式开始,DragLift 是跟着手走、在放下或取消时落定的副本,DropPlaceholder 是一项将要落下的位置唯一的样子,DragRefusalHint 说明某个位置为什么不接受它。它们实现的模型及其理由,见拖放。从 @botiverse/raft-ui/wip 导入它们:Raft Chat 把自己的拖动迁移到这些部件上的期间,API 还可能变化。
何时使用
结构
useDragSensors():DndContext唯一的一套传感器。鼠标或触控笔移动 6px 后开始(MOUSE_DRAG_DISTANCE_PX);手指按住 500ms、再移动 10px 后开始(useHoldThenMoveTouchSensor);键盘按 Space 开始(DRAG_KEYBOARD_CODES),从不是 Enter。传入coordinateGetter(或者只传这个 getter:useDragSensors(getter)),决定方向键怎么移动。不管拖动来自哪一种传感器,拖动中的 Escape 都只把这一项放回原处:它在 window 的捕获阶段被接住并阻止传播,所以拖动结束之前,应用里任何 Escape 处理函数(面板、对话框、弹出层的)都收不到它。DragLift:被拿起的副本,放在DndContext里面。它的子元素就是副本:这一项本身,或一个紧凑替身。它默认用控件的圆角;在className里传入这一项自己的圆角。它还会设置 grabbing 光标,在窗口失去焦点和浏览器标签页被隐藏时取消拖动,滚动指针靠近其边缘的区域,并让键盘拖动时的副本偏离它的位置 6px。把 dnd-kit 自带的自动滚动关掉(autoScroll={false})。副本默认渲染到document.body,放在一个保留它渲染时所在主题作用域(light/dark)和这一项书写方向(dir)的 portal 里,所以这一项周围的东西都困不住它:既没有它升不出去的层叠上下文(ScrollArea、isolate),也没有会让它错位的 transform(抽屉、对话框)。在 body 的各层之中它在最上面(DRAG_LIFT_Z_INDEX,999:高于弹出层、叠加的对话框和轻提示)。container把它渲染到另一个元素里,null让它原地渲染,这时它最高只能到它周围的层叠上下文。除此之外,副本不会从拿起它的地方继承任何东西(字体、颜色,或者容器设置的自定义属性,比如行高),所以要给副本单独设样式。keepGrabInside(proxyWidth):给紧凑替身用的修饰器(modifier),这样在离这一项开头很远的地方抓住它时,手仍然停在替身上。LIFT_CLASS是不用DragLift绘制的副本所用的拿起样式。DropPlaceholder:这一项将落下的位置,大小与这一项相同。在布局的空隙里或新一列的虚线条里时用opaque,让后面那一列不会透出来。DragRefusalHint:某个位置拒绝这一项的原因,显示在指针旁边的 HUD 标签片里,并配上 not-allowed 光标。只在手下面的位置拒绝时渲染它。标签片在指针的右下方;靠近窗口右边缘时移到指针和副本的左边,靠近下边缘时移到指针上方。它从不在副本下面,并且始终留在窗口边缘以内 8px。它的尺寸在内容变化时测量,而不是每一帧都测。DraggedSlot:包住列表里每一项的内容;传入dragging={isDragging}(默认是false)。被拖动的项自己的槽位,在这一项还在原处时是 40% 的不透明度(ORIGIN_OPACITY),这一项移到别处后就成为那个位置,带这一项的radius。useDragLists:一个或多个列表里的项,可以重新排序、在列表之间移动,带拒绝和按名称的播报。它返回DndContext的处理函数、碰撞检测和accessibility,以及给useDragSensors的coordinateGetter。在这个 context 里渲染DragRemeasure version={items}。useDragAcrossLists、listKeyboardCoordinates和DragRemeasure:useDragLists所基于的键盘修正,给有自己列表的表面用。createDragAnnouncements、dragInstructions、DEFAULT_DRAG_MESSAGES:给 dnd-kit 的accessibility用的播报和操作说明,按名称,用你的语言。name为空的拖动源什么都不说。useDragAnnouncer和DragLiveRegion给自己播报的表面用。useDragSiblingTransition()(或DRAG_SIBLING_TRANSITION):给useSortable({ transition })用的 160ms 滑动,开启减弱动态效果时没有。DROP_GLIDE是DragLift用的滑行,DRAG_SETTLE是它的时长和缓动。cancelDrag():取消正在进行的拖动,不管它在哪个传感器手里。它给 window 发一个resize(每个 dnd-kit 传感器收到它都会取消),从不发合成的 Escape,所以按 Escape 关闭的对话框、面板或弹出层都收不到。- 样式表读取的 data 属性:拖动来源的那一项上的
data-drag-origin(40%),这一项进入的容器上的data-drag-into(fill-subtle)。
状态
行为
- 副本从抓住的那一点起 1:1 跟随指针;不要用修饰器限制它的范围,或把它锁在一个轴上。紧凑替身可以用一个让它保持在手下面的修饰器。
- dnd-kit 测量的是浮层的第一个元素,所以键盘拖动的偏移画在它里面,不会移动任何放置目标。
- 开启
prefers-reduced-motion时,副本和相邻项都没有动画。 - 鼠标指针或键盘拖动时按 Escape 会取消拖动:副本滑回原处,这次 keydown 不再往下传(
preventDefault、stopImmediatePropagation)。拖动结束后,Escape 和在其他地方一样。 - 拒绝标签片留在窗口里,并避开副本:在右边缘翻到指针和副本的左边,在下边缘翻到指针上方,四边都离窗口边缘 8px。
无障碍
- 这些部件封装的是 dnd-kit。可拖动的项带有 dnd-kit 的属性:它可以获得焦点,带有角色描述和操作说明。
- 键盘:Space 拿起,方向键移动一个位置,Space 放下,Escape 取消(而且只是取消:应用里的 Escape 处理函数都收不到它)。Enter 从不开始拖动。用
useDragLists,或者useDragAcrossLists加DragRemeasure时,每按一次方向键正好移动一个位置,刚移进另一个列表之后也一样。 - 传入名称,从不传 ID:
createDragAnnouncements({ name, place, refusal }, messages)。英文默认文案在DEFAULT_DRAG_MESSAGES里;把你的翻译作为messages传入,每一条都用同样的信息拼成。这一项没有名称时,什么都不说,而不是说出它的 ID:SortableTabsList用标签页的aria-label或文字称呼它,SortableComposerAttachments用附件的label,TaskDndProvider用卡片的describe,没有它就不出声。 DropPlaceholder和DragRefusalHint对辅助技术隐藏:原因也要播报出来。
规则
- 每一种拖动都用
useDragSensors,这样表面从不自己挑选距离、按住时长或按键。 - 在
DragLift里渲染副本,从不靠移动这一项本身。 - 拖动时把这一项留在原处,让
data-drag-origin把它调淡。
API
DragRemeasure
Measures every drop target again once an item's move into another list has rendered. dnd-kit's own observers skip targets whose size didn't change, even when they moved. Render it inside the `DndContext`.
| Prop | 类型 | 默认值 |
|---|---|---|
| Anything that changes when an item moves list, such as the lists' items. | {} |
DragLift
The copy of a dragged item that follows the hand (design/patterns/drag-and-drop.md#what-follows-the-hand): an opaque popover surface with `shadow-float` and a `line-seam` ring, no scale and no tilt, 1:1 with the pointer from the grab point, never clamped to its container and never held to one axis. On a drop it glides into its place, and on a cancel back home (160ms, `motion-drag-settle-*`; none with reduced motion).
| Prop | 类型 | 默认值 |
|---|---|---|
| Scroll whatever scrollable area the pointer comes within 48px of the edge of. Turn dnd-kit's own off (`<DndContext autoScroll={false}>`) so the two don't scroll together. | boolean | true |
| The lifted copy: the dragged item itself, or a compact proxy for a large one. Nothing while nothing is lifted. | ReactNode | |
| Where the copy renders: `document.body` by default, so no ancestor holds it in, neither a stacking context (a ScrollArea, an `isolate`) it couldn't rise out of nor a transform (a drawer, a dialog) that would offset a `position: fixed` copy. Another element renders it there, and `null` in place. It keeps the theme scope it was rendered in (`light` / `dark`), as raft-ui's portals do, and the item's writing direction (`dir`). Nothing else is inherited from where it was picked up (font, colour, custom properties a container sets): give the copy its own styles. | Element | DocumentFragment | |
| Modifiers for the copy only, such as one that keeps a compact proxy under the hand. | Modifier[] | |
| number | DRAG_LIFT_Z_INDEX |
DragRefusalHint
Shown while the hand is over a place that refuses the item (design/patterns/drag-and-drop.md#refused-places): the reason, in a HUD chip by the pointer (by the lifted copy in a keyboard drag), and the not-allowed cursor. Render it only while the place is refused, inside the `DndContext`, with no placeholder anywhere. Announce the reason as well: the chip is not read out.
| Prop | 类型 | 默认值 |
|---|---|---|
| Why the place under the hand won't take the item, such as "Can't move from Todo to Done". | string | |
| Render the hint in another element; by default it renders in `document.body`. | Element | DocumentFragment |
Also takes the attributes of its element.
DropPlaceholder
Where a dragged item will land (design/patterns/drag-and-drop.md#where-it-will-land): a 1.5px dashed `line-strong` outline on `fill-subtle`, with the item's radius and size. One look for every place. The control radius is the default; pass the item's own (`rounded-container` for a card). Hidden from assistive technology: the announcements say where the item is.
| Prop | 类型 | 默认值 |
|---|---|---|
| In a layout (the gap a column makes, a new column's strip): an opaque `layer-canvas` under the `fill-subtle` wash, so the column behind the gap doesn't show through while its sections reflow. Nothing is drawn over live content (design/patterns/drag-and-drop.md#where-it-will-land). | boolean | false |
| Allows you to replace the component's HTML element with a different tag, or compose it with another component. | ReactElement | ((props, state) => ReactElement) |
Also takes the attributes of its element.
DragLiveRegion
A polite, visually hidden live region for drag announcements.
| Prop | 类型 | 默认值 |
|---|---|---|
| string |
DraggedSlot
Wraps each item's content in a list. The dragged item's own node is at 40% while it is still home, and the dashed place (its size, its radius) once it stands anywhere else. Every other item renders as it is: the wrapper stays the same, so the item keeps focus when its drag starts.
| Prop | 类型 | 默认值 |
|---|---|---|
| ReactNode | ||
| This item is the one being dragged. Render the slot around every item and pass `isDragging`, so the dragged one keeps its node (and focus). | boolean | false |
| The dragged item still stands at its own place. | boolean | |
| The item's radius class, for the place: `rounded-control` by default. | string |
相关组件
改动
- 0.81.0 · Fixed
DragLiftrenders the copy indocument.bodyby default: the lifted copy goes through a portal to the body unlesscontainernames another element, ornullkeeps it in place; the portal keeps the theme scope it was rendered in.TaskDragOverlayfollows, asSortableTabsListandSortableComposerAttachmentsalready did. - 0.81.0 · FixedIn a right-to-left layout the dragged copy keeps its direction:
DragLift's portal carries the item's computed writing direction (dir) to the body, as it carries the theme scope.