css-is-awesome 1.14.2 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +20 -0
- package/README.md +4 -4
- package/dist/tokens.d.ts +1 -1
- package/llm.txt +2 -2
- package/mcp/server.cjs +1 -1
- package/package.json +1 -1
- package/scss/recipes/README.md +1 -1
- package/scss/recipes/breadcrumb.md +265 -0
- package/scss/recipes/color-picker.md +605 -0
- package/scss/recipes/combobox-multiselect.md +695 -0
- package/scss/recipes/combobox.md +3 -2
- package/scss/recipes/file-upload.md +653 -0
- package/scss/recipes/pagination.md +444 -0
- package/scss/recipes/sortable-list.md +701 -0
- package/scss/recipes/toast.md +571 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sortable-list
|
|
3
|
+
description: A re-orderable list — native HTML5 drag-and-drop for the pointer, a grab/move/drop keyboard alternative on the handle, and every move announced through a live region.
|
|
4
|
+
category: data
|
|
5
|
+
complexity: medium
|
|
6
|
+
cia-version: ">=1.0.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Use this when
|
|
10
|
+
|
|
11
|
+
You need a list the user can reorder — priority queues, playlist tracks, form-builder fields, dashboard widgets. This recipe gives you the **structure and the cia styling** (drag handle, the insertion line, the "grabbed" state) plus a complete **native** implementation: HTML5 drag-and-drop for the pointer and a keyboard grab-move-drop loop on the handle. If you already use a drag library ([dnd-kit](https://dndkit.com/), [pragmatic-drag-and-drop](https://atlassian.design/components/pragmatic-drag-and-drop/), [Sortable.js](https://sortablejs.github.io/Sortable/)), keep the library and use only the Styling section — the `data-*` hooks are library-agnostic. If items never need reordering, bail: a plain list with `cia.list-item` is all you need.
|
|
12
|
+
|
|
13
|
+
## Structure (raw HTML)
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<ul class="my-sortable" data-cia-recipe="sortable-list" role="list" aria-label="Task order">
|
|
17
|
+
<li data-slot="item" draggable="true" data-id="a">
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
data-slot="handle"
|
|
21
|
+
aria-label="Reorder: Write the launch post"
|
|
22
|
+
aria-describedby="my-sortable-hint"
|
|
23
|
+
aria-pressed="false"
|
|
24
|
+
>
|
|
25
|
+
<span aria-hidden="true">⠿</span>
|
|
26
|
+
</button>
|
|
27
|
+
<span data-slot="label">Write the launch post</span>
|
|
28
|
+
</li>
|
|
29
|
+
<li data-slot="item" draggable="true" data-id="b" data-drop-position="before">
|
|
30
|
+
<button type="button" data-slot="handle" aria-label="Reorder: Record the demo" aria-describedby="my-sortable-hint" aria-pressed="false">
|
|
31
|
+
<span aria-hidden="true">⠿</span>
|
|
32
|
+
</button>
|
|
33
|
+
<span data-slot="label">Record the demo</span>
|
|
34
|
+
</li>
|
|
35
|
+
<li data-slot="item" draggable="true" data-id="c" data-grabbed>
|
|
36
|
+
<button type="button" data-slot="handle" aria-label="Reorder: Ship it" aria-describedby="my-sortable-hint" aria-pressed="true">
|
|
37
|
+
<span aria-hidden="true">⠿</span>
|
|
38
|
+
</button>
|
|
39
|
+
<span data-slot="label">Ship it</span>
|
|
40
|
+
</li>
|
|
41
|
+
</ul>
|
|
42
|
+
<p id="my-sortable-hint" hidden>Press Space to grab, arrow keys to move, Space to drop, Escape to cancel.</p>
|
|
43
|
+
<div data-slot="announce" aria-live="assertive" aria-atomic="true" class="my-sr-only"></div>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Notes on the markup:
|
|
47
|
+
|
|
48
|
+
- `role="list"` is redundant on a `<ul>` in theory but **necessary in practice** — Safari/VoiceOver drops list semantics from a `<ul>` once `list-style: none` is applied (and `cia.list-reset` applies it). The explicit role restores them.
|
|
49
|
+
- The **handle is a real `<button>`**, not the whole `<li>`. It is the single focusable, the single thing screen readers land on, and the single thing that carries `aria-pressed` ("grabbed"). A whole-row drag target steals text selection and makes every item a tab stop.
|
|
50
|
+
- `draggable="true"` sits on the `<li>` (the thing that moves), but the JS below only lets a drag start when the pointer went down on the handle, so text inside the label stays selectable.
|
|
51
|
+
- `aria-describedby` points at a hidden hint that spells out the keyboard loop once — read on focus, never repeated.
|
|
52
|
+
- Three state hooks, all attributes so the consumer keeps every class name: `data-drop-position="before|after"` on the item the pointer is hovering (draws the insertion line), `data-grabbed` on the item currently picked up by keyboard or pointer, and `aria-pressed` on the handle mirroring that grab.
|
|
53
|
+
- `[data-slot="announce"]` is an `aria-live="assertive"` region. It sits **outside** the list so reordering the DOM never re-mounts it (a re-mounted live region announces nothing).
|
|
54
|
+
|
|
55
|
+
## Styling (cia mixins)
|
|
56
|
+
|
|
57
|
+
```scss
|
|
58
|
+
// SortableList.module.scss — component stylesheet, so import the zero-emit barrel.
|
|
59
|
+
@use 'css-is-awesome/api' as cia;
|
|
60
|
+
|
|
61
|
+
.my-sortable {
|
|
62
|
+
@include cia.list-reset;
|
|
63
|
+
@include cia.stack($gap: 1);
|
|
64
|
+
|
|
65
|
+
[data-slot="item"] {
|
|
66
|
+
@include cia.list-item($py: 2, $px: 2);
|
|
67
|
+
position: relative;
|
|
68
|
+
border: 1px solid cia.color(border-default);
|
|
69
|
+
border-radius: cia.radius(md);
|
|
70
|
+
background: cia.color(surface-default);
|
|
71
|
+
@include cia.transition(box-shadow, opacity);
|
|
72
|
+
|
|
73
|
+
// The insertion line: a 2px bar hugging the top or bottom edge of the
|
|
74
|
+
// item the pointer is over. Drawn from an attribute the JS toggles, so
|
|
75
|
+
// the consumer never has to agree on a class name with the script.
|
|
76
|
+
&::before {
|
|
77
|
+
content: '';
|
|
78
|
+
position: absolute;
|
|
79
|
+
inset-inline: 0;
|
|
80
|
+
block-size: 2px;
|
|
81
|
+
background: cia.color(action-primary-default);
|
|
82
|
+
opacity: 0;
|
|
83
|
+
pointer-events: none;
|
|
84
|
+
}
|
|
85
|
+
&[data-drop-position="before"]::before { inset-block-start: -3px; opacity: 1; }
|
|
86
|
+
&[data-drop-position="after"]::before { inset-block-end: -3px; opacity: 1; }
|
|
87
|
+
|
|
88
|
+
// Picked up (keyboard grab or pointer drag): lift it and tint the border.
|
|
89
|
+
&[data-grabbed] {
|
|
90
|
+
@include cia.elevation(2);
|
|
91
|
+
border-color: cia.color(action-primary-default);
|
|
92
|
+
opacity: 0.85;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
[data-slot="handle"] {
|
|
97
|
+
@include cia.btn-icon($size: 2rem, $r: sm);
|
|
98
|
+
cursor: grab;
|
|
99
|
+
color: cia.color(text-muted);
|
|
100
|
+
touch-action: none;
|
|
101
|
+
|
|
102
|
+
&[aria-pressed="true"] { cursor: grabbing; color: cia.color(action-primary-default); }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
[data-slot="label"] {
|
|
106
|
+
flex: 1;
|
|
107
|
+
@include cia.font(reg, 2);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.my-sr-only { @include cia.sr-only; }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The handle shows a Unicode grip glyph (`⠿`) with no icon dependency. Swap it for an icon-pack glyph by replacing the `<span>` with `@include cia.svg('grip-vertical')` (folder-based icon packs) or `@include cia.fa-icon('grip-vertical')` (Font Awesome) on the handle — the `btn-icon` sizing is unchanged either way.
|
|
115
|
+
|
|
116
|
+
## Interactivity
|
|
117
|
+
|
|
118
|
+
Two input paths, one shared `move(fromIndex, toIndex)` that reorders the data and re-renders. Neither needs a library — the whole thing is ~60 lines.
|
|
119
|
+
|
|
120
|
+
**Pointer — native HTML5 drag-and-drop.**
|
|
121
|
+
|
|
122
|
+
1. `pointerdown` on the handle sets an `armed` flag. `dragstart` then fires on the **`<li>`** (the browser dispatches it on the nearest `draggable` ancestor, never on the handle), so the flag is the only reliable way to know the drag began on the grip — if it is not set, `preventDefault()` so label text stays selectable. Otherwise: remember the dragged item's index, set `data-grabbed`, and call `dataTransfer.setData('text/plain', id)` — Firefox will not start a drag without *some* data.
|
|
123
|
+
2. `dragover` on any item: `preventDefault()` (that is what makes it a valid drop target), then compare `event.clientY` to the item's vertical midpoint and set `data-drop-position` to `before` or `after`. Clear the attribute from every other item.
|
|
124
|
+
3. `drop`: read the target index and position, call `move()`, clear every attribute.
|
|
125
|
+
4. `dragend`: clear every attribute (fires whether or not a drop happened, so this is the reliable cleanup hook).
|
|
126
|
+
|
|
127
|
+
**Keyboard — grab / move / drop on the handle.**
|
|
128
|
+
|
|
129
|
+
| Key (handle focused) | Behavior |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `Space` / `Enter` | Not grabbed → **grab** (`aria-pressed="true"`, item gets `data-grabbed`, announce "Grabbed X, position 2 of 5"). Grabbed → **drop** (announce "Dropped X at position 3 of 5"). |
|
|
132
|
+
| `ArrowUp` / `ArrowDown` | Grabbed → move the item one slot, keep focus on the same handle, announce "X moved to position 3 of 5". Not grabbed → move focus to the previous/next handle (plain list navigation). |
|
|
133
|
+
| `Home` / `End` | Grabbed → move to first/last slot. |
|
|
134
|
+
| `Escape` | Grabbed → **cancel**: restore the order from before the grab, drop, announce "Reorder cancelled". |
|
|
135
|
+
| `Tab` | Leaves the list; if something is still grabbed, treat it as a drop so nothing stays half-lifted. |
|
|
136
|
+
|
|
137
|
+
Keep focus on the **same handle element** across moves. In React/Vue/Svelte that means keying the item on its stable `id`, not its index, so the framework moves the node instead of re-creating it; in the Web Component it means moving the existing `<li>` with `insertBefore` rather than re-rendering innerHTML.
|
|
138
|
+
|
|
139
|
+
**Announcements.** Every state change writes one sentence to the `aria-live="assertive"` region — assertive, not polite, because the user just pressed a key and needs the result before the next keystroke. Write the text even when it is identical to the last message: set it to `""` and then to the sentence on the next frame, or append a zero-width change, otherwise some screen readers stay silent on repeats.
|
|
140
|
+
|
|
141
|
+
**Edge cases:**
|
|
142
|
+
|
|
143
|
+
- **SSR:** render the list in its stored order with `aria-pressed="false"` on every handle and no `data-*` state attributes — that is the correct server snapshot.
|
|
144
|
+
- **Touch:** HTML5 drag-and-drop does not fire on most touch browsers. `touch-action: none` on the handle stops the page from scrolling when the user tries; the keyboard path is not reachable on a phone either, so a touch-first product should add pointer-event-based dragging (or a library — see below) rather than rely on this recipe's pointer path alone. The `move()` function and the styling are reusable as-is.
|
|
145
|
+
- **Persisting:** `move()` is the one place order changes — call your API from there (or debounce on `drop`/keyboard-drop) rather than diffing the DOM.
|
|
146
|
+
|
|
147
|
+
### With a library
|
|
148
|
+
|
|
149
|
+
The pointer path above is deliberately minimal. If you need touch dragging, auto-scroll, or cross-list moves, bring one library and keep this recipe's markup, attributes and styling:
|
|
150
|
+
|
|
151
|
+
- **dnd-kit** (React) — `useSortable` gives you `attributes`, `listeners`, `setNodeRef` and `transform`. Spread `listeners` on the **handle**, not the item, and map `isDragging` to `data-grabbed`; dnd-kit's own `KeyboardSensor` + `sortableKeyboardCoordinates` provides the keyboard loop and announces through its `DndContext` `accessibility.announcements` prop — replace the recipe's live region with that, do not run both.
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
|
|
155
|
+
<li ref={setNodeRef} data-slot="item" data-grabbed={isDragging || undefined}
|
|
156
|
+
style={{ transform: CSS.Transform.toString(transform), transition }}>
|
|
157
|
+
<button type="button" data-slot="handle" {...attributes} {...listeners} aria-label={`Reorder: ${item.label}`}>
|
|
158
|
+
<span aria-hidden="true">⠿</span>
|
|
159
|
+
</button>
|
|
160
|
+
<span data-slot="label">{item.label}</span>
|
|
161
|
+
</li>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
(The inline `style` there is dnd-kit's positional transform, a structural requirement of the library, not appearance.)
|
|
165
|
+
|
|
166
|
+
- **pragmatic-drag-and-drop** (framework-agnostic, Atlassian) — its `draggable` / `dropTargetForElements` adapters map directly onto steps 1–4 of the pointer path; its `closestEdge` hitbox helper returns the same `before|after` answer as the midpoint check.
|
|
167
|
+
- **Anything else** (Sortable.js, Vue Draggable, svelte-dnd-action) — bring your own. The contract is: your library sets `data-grabbed` and `data-drop-position`, the recipe's SCSS does the rest.
|
|
168
|
+
|
|
169
|
+
## A11y checklist
|
|
170
|
+
|
|
171
|
+
- [ ] The list keeps list semantics after `list-style: none` — `role="list"` is set explicitly on the `<ul>` ([WCAG 2.2 SC 1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html))
|
|
172
|
+
- [ ] Every reorder the pointer can do, the keyboard can do: grab, move up/down, drop, cancel — all from the handle button ([WCAG 2.2 SC 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html))
|
|
173
|
+
- [ ] Dragging is not the only way — the keyboard path is a single-pointer, no-path-based alternative ([WCAG 2.2 SC 2.5.7 Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html))
|
|
174
|
+
- [ ] Each handle has an accessible name that includes the item ("Reorder: Ship it"), not six buttons all called "Reorder" ([WCAG 2.2 SC 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html))
|
|
175
|
+
- [ ] The grabbed state is exposed as `aria-pressed` on the handle, not only by colour/shadow; `aria-grabbed` / `aria-dropeffect` are deprecated since ARIA 1.1 and are not used ([WAI-ARIA 1.2: aria-grabbed (deprecated)](https://www.w3.org/TR/wai-aria-1.2/#aria-grabbed))
|
|
176
|
+
- [ ] Every grab, move, drop and cancel is announced with the item name and its new position out of the total, via an `aria-live="assertive"` region that lives outside the reordered DOM ([WCAG 2.2 SC 4.1.3 Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html))
|
|
177
|
+
- [ ] The keyboard instructions are discoverable from the handle via `aria-describedby`, not only in surrounding prose ([WCAG 2.2 SC 3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html))
|
|
178
|
+
- [ ] Focus stays on the same handle while its item moves — the user never has to re-find what they were holding ([WCAG 2.2 SC 2.4.3 Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html))
|
|
179
|
+
- [ ] The handle is at least 24×24 CSS px — `cia.btn-icon($size: 2rem)` is 32px ([WCAG 2.2 SC 2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html))
|
|
180
|
+
- [ ] The insertion line meets 3:1 against the surface — `action-primary-default` does in every shipped cia theme ([WCAG 2.2 SC 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html))
|
|
181
|
+
|
|
182
|
+
## Framework examples
|
|
183
|
+
|
|
184
|
+
All four implement the same spec: native HTML5 pointer drag from the handle, keyboard grab/arrow/drop/Escape on the handle, one `move()` function, one live region. Items are keyed by a stable `id`.
|
|
185
|
+
|
|
186
|
+
### React
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
"use client";
|
|
190
|
+
import { useRef, useState } from "react";
|
|
191
|
+
import styles from "./SortableList.module.scss";
|
|
192
|
+
|
|
193
|
+
type Item = { id: string; label: string };
|
|
194
|
+
|
|
195
|
+
export default function SortableList({ initial, onChange }: { initial: Item[]; onChange?: (items: Item[]) => void }) {
|
|
196
|
+
const [items, setItems] = useState(initial);
|
|
197
|
+
const [grabbed, setGrabbed] = useState<string | null>(null);
|
|
198
|
+
const [drop, setDrop] = useState<{ id: string; pos: "before" | "after" } | null>(null);
|
|
199
|
+
const [announce, setAnnounce] = useState("");
|
|
200
|
+
const snapshot = useRef<Item[] | null>(null);
|
|
201
|
+
const dragIndex = useRef<number | null>(null);
|
|
202
|
+
const armed = useRef(false); // pointer went down on a handle
|
|
203
|
+
|
|
204
|
+
function say(text: string) {
|
|
205
|
+
setAnnounce("");
|
|
206
|
+
requestAnimationFrame(() => setAnnounce(text));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function move(from: number, to: number) {
|
|
210
|
+
if (from === to || to < 0 || to >= items.length) return items;
|
|
211
|
+
const next = [...items];
|
|
212
|
+
const [it] = next.splice(from, 1);
|
|
213
|
+
next.splice(to, 0, it);
|
|
214
|
+
setItems(next);
|
|
215
|
+
onChange?.(next);
|
|
216
|
+
return next;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── keyboard ──
|
|
220
|
+
function onHandleKeyDown(e: React.KeyboardEvent<HTMLButtonElement>, index: number) {
|
|
221
|
+
const item = items[index];
|
|
222
|
+
const isGrabbed = grabbed === item.id;
|
|
223
|
+
if (e.key === " " || e.key === "Enter") {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
if (!isGrabbed) {
|
|
226
|
+
snapshot.current = items;
|
|
227
|
+
setGrabbed(item.id);
|
|
228
|
+
say(`Grabbed ${item.label}, position ${index + 1} of ${items.length}.`);
|
|
229
|
+
} else {
|
|
230
|
+
setGrabbed(null);
|
|
231
|
+
say(`Dropped ${item.label} at position ${index + 1} of ${items.length}.`);
|
|
232
|
+
}
|
|
233
|
+
} else if (e.key === "Escape" && isGrabbed) {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
if (snapshot.current) { setItems(snapshot.current); onChange?.(snapshot.current); }
|
|
236
|
+
setGrabbed(null);
|
|
237
|
+
say("Reorder cancelled.");
|
|
238
|
+
} else if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Home" || e.key === "End") {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
const to = e.key === "ArrowUp" ? index - 1 : e.key === "ArrowDown" ? index + 1 : e.key === "Home" ? 0 : items.length - 1;
|
|
241
|
+
if (isGrabbed) {
|
|
242
|
+
const next = move(index, to);
|
|
243
|
+
const at = next.findIndex((i) => i.id === item.id);
|
|
244
|
+
say(`${item.label} moved to position ${at + 1} of ${next.length}.`);
|
|
245
|
+
} else {
|
|
246
|
+
const handles = e.currentTarget.closest("ul")?.querySelectorAll<HTMLButtonElement>('[data-slot="handle"]');
|
|
247
|
+
handles?.[Math.max(0, Math.min(to, items.length - 1))]?.focus();
|
|
248
|
+
}
|
|
249
|
+
} else if (e.key === "Tab" && isGrabbed) {
|
|
250
|
+
setGrabbed(null);
|
|
251
|
+
say(`Dropped ${item.label} at position ${index + 1} of ${items.length}.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── pointer (HTML5 DnD) ──
|
|
256
|
+
function onDragStart(e: React.DragEvent<HTMLLIElement>, index: number) {
|
|
257
|
+
if (!armed.current) { e.preventDefault(); return; } // dragstart targets the <li>, so ask the flag
|
|
258
|
+
dragIndex.current = index;
|
|
259
|
+
e.dataTransfer.setData("text/plain", items[index].id);
|
|
260
|
+
e.dataTransfer.effectAllowed = "move";
|
|
261
|
+
setGrabbed(items[index].id);
|
|
262
|
+
}
|
|
263
|
+
function onDragOver(e: React.DragEvent<HTMLLIElement>, id: string) {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
266
|
+
setDrop({ id, pos: e.clientY < r.top + r.height / 2 ? "before" : "after" });
|
|
267
|
+
}
|
|
268
|
+
function onDrop(e: React.DragEvent<HTMLLIElement>, index: number) {
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
const from = dragIndex.current;
|
|
271
|
+
if (from === null || !drop) return;
|
|
272
|
+
let to = drop.pos === "before" ? index : index + 1;
|
|
273
|
+
if (from < to) to -= 1;
|
|
274
|
+
const next = move(from, to);
|
|
275
|
+
say(`${items[from].label} moved to position ${to + 1} of ${next.length}.`);
|
|
276
|
+
clear();
|
|
277
|
+
}
|
|
278
|
+
function clear() { dragIndex.current = null; armed.current = false; setGrabbed(null); setDrop(null); }
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<>
|
|
282
|
+
<ul className={styles.mySortable} data-cia-recipe="sortable-list" role="list" aria-label="Task order">
|
|
283
|
+
{items.map((item, index) => (
|
|
284
|
+
<li
|
|
285
|
+
key={item.id}
|
|
286
|
+
data-slot="item"
|
|
287
|
+
draggable
|
|
288
|
+
data-grabbed={grabbed === item.id || undefined}
|
|
289
|
+
data-drop-position={drop?.id === item.id ? drop.pos : undefined}
|
|
290
|
+
onDragStart={(e) => onDragStart(e, index)}
|
|
291
|
+
onDragOver={(e) => onDragOver(e, item.id)}
|
|
292
|
+
onDragLeave={() => setDrop((d) => (d?.id === item.id ? null : d))}
|
|
293
|
+
onDrop={(e) => onDrop(e, index)}
|
|
294
|
+
onDragEnd={clear}
|
|
295
|
+
>
|
|
296
|
+
<button
|
|
297
|
+
type="button"
|
|
298
|
+
data-slot="handle"
|
|
299
|
+
aria-label={`Reorder: ${item.label}`}
|
|
300
|
+
aria-describedby="my-sortable-hint"
|
|
301
|
+
aria-pressed={grabbed === item.id}
|
|
302
|
+
onKeyDown={(e) => onHandleKeyDown(e, index)}
|
|
303
|
+
onPointerDown={() => { armed.current = true; }}
|
|
304
|
+
onPointerUp={() => { armed.current = false; }}
|
|
305
|
+
>
|
|
306
|
+
<span aria-hidden="true">⠿</span>
|
|
307
|
+
</button>
|
|
308
|
+
<span data-slot="label">{item.label}</span>
|
|
309
|
+
</li>
|
|
310
|
+
))}
|
|
311
|
+
</ul>
|
|
312
|
+
<p id="my-sortable-hint" hidden>Press Space to grab, arrow keys to move, Space to drop, Escape to cancel.</p>
|
|
313
|
+
<div data-slot="announce" aria-live="assertive" aria-atomic="true" className={styles.mySrOnly}>{announce}</div>
|
|
314
|
+
</>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Vue
|
|
320
|
+
|
|
321
|
+
```vue
|
|
322
|
+
<script setup>
|
|
323
|
+
import { ref, nextTick } from "vue";
|
|
324
|
+
|
|
325
|
+
const props = defineProps({ initial: { type: Array, required: true } });
|
|
326
|
+
const emit = defineEmits(["change"]);
|
|
327
|
+
|
|
328
|
+
const items = ref([...props.initial]);
|
|
329
|
+
const grabbed = ref(null);
|
|
330
|
+
const drop = ref(null);
|
|
331
|
+
const announce = ref("");
|
|
332
|
+
let snapshot = null;
|
|
333
|
+
let dragIndex = null;
|
|
334
|
+
let armed = false; // pointer went down on a handle
|
|
335
|
+
|
|
336
|
+
async function say(text) { announce.value = ""; await nextTick(); announce.value = text; }
|
|
337
|
+
|
|
338
|
+
function move(from, to) {
|
|
339
|
+
if (from === to || to < 0 || to >= items.value.length) return;
|
|
340
|
+
const [it] = items.value.splice(from, 1);
|
|
341
|
+
items.value.splice(to, 0, it);
|
|
342
|
+
emit("change", [...items.value]);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function onHandleKeydown(e, index) {
|
|
346
|
+
const item = items.value[index];
|
|
347
|
+
const isGrabbed = grabbed.value === item.id;
|
|
348
|
+
if (e.key === " " || e.key === "Enter") {
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
if (!isGrabbed) { snapshot = [...items.value]; grabbed.value = item.id; say(`Grabbed ${item.label}, position ${index + 1} of ${items.value.length}.`); }
|
|
351
|
+
else { grabbed.value = null; say(`Dropped ${item.label} at position ${index + 1} of ${items.value.length}.`); }
|
|
352
|
+
} else if (e.key === "Escape" && isGrabbed) {
|
|
353
|
+
e.preventDefault();
|
|
354
|
+
if (snapshot) { items.value = snapshot; emit("change", [...snapshot]); }
|
|
355
|
+
grabbed.value = null; say("Reorder cancelled.");
|
|
356
|
+
} else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
const to = e.key === "ArrowUp" ? index - 1 : e.key === "ArrowDown" ? index + 1 : e.key === "Home" ? 0 : items.value.length - 1;
|
|
359
|
+
if (isGrabbed) { move(index, to); const at = items.value.findIndex((i) => i.id === item.id); say(`${item.label} moved to position ${at + 1} of ${items.value.length}.`); }
|
|
360
|
+
else e.currentTarget.closest("ul").querySelectorAll('[data-slot="handle"]')[Math.max(0, Math.min(to, items.value.length - 1))]?.focus();
|
|
361
|
+
} else if (e.key === "Tab" && isGrabbed) {
|
|
362
|
+
grabbed.value = null; say(`Dropped ${item.label} at position ${index + 1} of ${items.value.length}.`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function onDragStart(e, index) {
|
|
367
|
+
if (!armed) { e.preventDefault(); return; } // dragstart targets the <li>, so ask the flag
|
|
368
|
+
dragIndex = index;
|
|
369
|
+
e.dataTransfer.setData("text/plain", items.value[index].id);
|
|
370
|
+
e.dataTransfer.effectAllowed = "move";
|
|
371
|
+
grabbed.value = items.value[index].id;
|
|
372
|
+
}
|
|
373
|
+
function onDragOver(e, id) {
|
|
374
|
+
e.preventDefault();
|
|
375
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
376
|
+
drop.value = { id, pos: e.clientY < r.top + r.height / 2 ? "before" : "after" };
|
|
377
|
+
}
|
|
378
|
+
function onDrop(e, index) {
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
if (dragIndex === null || !drop.value) return;
|
|
381
|
+
let to = drop.value.pos === "before" ? index : index + 1;
|
|
382
|
+
if (dragIndex < to) to -= 1;
|
|
383
|
+
const label = items.value[dragIndex].label;
|
|
384
|
+
move(dragIndex, to);
|
|
385
|
+
say(`${label} moved to position ${to + 1} of ${items.value.length}.`);
|
|
386
|
+
clear();
|
|
387
|
+
}
|
|
388
|
+
function clear() { dragIndex = null; armed = false; grabbed.value = null; drop.value = null; }
|
|
389
|
+
</script>
|
|
390
|
+
|
|
391
|
+
<template>
|
|
392
|
+
<ul class="my-sortable" data-cia-recipe="sortable-list" role="list" aria-label="Task order">
|
|
393
|
+
<li
|
|
394
|
+
v-for="(item, index) in items"
|
|
395
|
+
:key="item.id"
|
|
396
|
+
data-slot="item"
|
|
397
|
+
draggable="true"
|
|
398
|
+
:data-grabbed="grabbed === item.id ? '' : undefined"
|
|
399
|
+
:data-drop-position="drop && drop.id === item.id ? drop.pos : undefined"
|
|
400
|
+
@dragstart="onDragStart($event, index)"
|
|
401
|
+
@dragover="onDragOver($event, item.id)"
|
|
402
|
+
@dragleave="drop && drop.id === item.id && (drop = null)"
|
|
403
|
+
@drop="onDrop($event, index)"
|
|
404
|
+
@dragend="clear"
|
|
405
|
+
>
|
|
406
|
+
<button
|
|
407
|
+
type="button"
|
|
408
|
+
data-slot="handle"
|
|
409
|
+
:aria-label="`Reorder: ${item.label}`"
|
|
410
|
+
aria-describedby="my-sortable-hint"
|
|
411
|
+
:aria-pressed="grabbed === item.id"
|
|
412
|
+
@keydown="onHandleKeydown($event, index)"
|
|
413
|
+
@pointerdown="armed = true"
|
|
414
|
+
@pointerup="armed = false"
|
|
415
|
+
>
|
|
416
|
+
<span aria-hidden="true">⠿</span>
|
|
417
|
+
</button>
|
|
418
|
+
<span data-slot="label">{{ item.label }}</span>
|
|
419
|
+
</li>
|
|
420
|
+
</ul>
|
|
421
|
+
<p id="my-sortable-hint" hidden>Press Space to grab, arrow keys to move, Space to drop, Escape to cancel.</p>
|
|
422
|
+
<div data-slot="announce" aria-live="assertive" aria-atomic="true" class="my-sr-only">{{ announce }}</div>
|
|
423
|
+
</template>
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Svelte
|
|
427
|
+
|
|
428
|
+
```svelte
|
|
429
|
+
<script>
|
|
430
|
+
import { tick } from "svelte";
|
|
431
|
+
export let initial = [];
|
|
432
|
+
export let onChange = (items) => {};
|
|
433
|
+
|
|
434
|
+
let items = [...initial];
|
|
435
|
+
let grabbed = null;
|
|
436
|
+
let drop = null;
|
|
437
|
+
let announce = "";
|
|
438
|
+
let snapshot = null;
|
|
439
|
+
let dragIndex = null;
|
|
440
|
+
let armed = false; // pointer went down on a handle
|
|
441
|
+
|
|
442
|
+
async function say(text) { announce = ""; await tick(); announce = text; }
|
|
443
|
+
|
|
444
|
+
function move(from, to) {
|
|
445
|
+
if (from === to || to < 0 || to >= items.length) return;
|
|
446
|
+
const next = [...items];
|
|
447
|
+
const [it] = next.splice(from, 1);
|
|
448
|
+
next.splice(to, 0, it);
|
|
449
|
+
items = next;
|
|
450
|
+
onChange(items);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function onHandleKeydown(e, index) {
|
|
454
|
+
const item = items[index];
|
|
455
|
+
const isGrabbed = grabbed === item.id;
|
|
456
|
+
if (e.key === " " || e.key === "Enter") {
|
|
457
|
+
e.preventDefault();
|
|
458
|
+
if (!isGrabbed) { snapshot = [...items]; grabbed = item.id; say(`Grabbed ${item.label}, position ${index + 1} of ${items.length}.`); }
|
|
459
|
+
else { grabbed = null; say(`Dropped ${item.label} at position ${index + 1} of ${items.length}.`); }
|
|
460
|
+
} else if (e.key === "Escape" && isGrabbed) {
|
|
461
|
+
e.preventDefault();
|
|
462
|
+
if (snapshot) { items = snapshot; onChange(items); }
|
|
463
|
+
grabbed = null; say("Reorder cancelled.");
|
|
464
|
+
} else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
|
|
465
|
+
e.preventDefault();
|
|
466
|
+
const to = e.key === "ArrowUp" ? index - 1 : e.key === "ArrowDown" ? index + 1 : e.key === "Home" ? 0 : items.length - 1;
|
|
467
|
+
if (isGrabbed) { move(index, to); const at = items.findIndex((i) => i.id === item.id); say(`${item.label} moved to position ${at + 1} of ${items.length}.`); }
|
|
468
|
+
else e.currentTarget.closest("ul").querySelectorAll('[data-slot="handle"]')[Math.max(0, Math.min(to, items.length - 1))]?.focus();
|
|
469
|
+
} else if (e.key === "Tab" && isGrabbed) {
|
|
470
|
+
grabbed = null; say(`Dropped ${item.label} at position ${index + 1} of ${items.length}.`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function onDragStart(e, index) {
|
|
475
|
+
if (!armed) { e.preventDefault(); return; } // dragstart targets the <li>, so ask the flag
|
|
476
|
+
dragIndex = index;
|
|
477
|
+
e.dataTransfer.setData("text/plain", items[index].id);
|
|
478
|
+
e.dataTransfer.effectAllowed = "move";
|
|
479
|
+
grabbed = items[index].id;
|
|
480
|
+
}
|
|
481
|
+
function onDragOver(e, id) {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
484
|
+
drop = { id, pos: e.clientY < r.top + r.height / 2 ? "before" : "after" };
|
|
485
|
+
}
|
|
486
|
+
function onDrop(e, index) {
|
|
487
|
+
e.preventDefault();
|
|
488
|
+
if (dragIndex === null || !drop) return;
|
|
489
|
+
let to = drop.pos === "before" ? index : index + 1;
|
|
490
|
+
if (dragIndex < to) to -= 1;
|
|
491
|
+
const label = items[dragIndex].label;
|
|
492
|
+
move(dragIndex, to);
|
|
493
|
+
say(`${label} moved to position ${to + 1} of ${items.length}.`);
|
|
494
|
+
clear();
|
|
495
|
+
}
|
|
496
|
+
function clear() { dragIndex = null; armed = false; grabbed = null; drop = null; }
|
|
497
|
+
</script>
|
|
498
|
+
|
|
499
|
+
<ul class="my-sortable" data-cia-recipe="sortable-list" role="list" aria-label="Task order">
|
|
500
|
+
{#each items as item, index (item.id)}
|
|
501
|
+
<li
|
|
502
|
+
data-slot="item"
|
|
503
|
+
draggable="true"
|
|
504
|
+
data-grabbed={grabbed === item.id ? "" : undefined}
|
|
505
|
+
data-drop-position={drop && drop.id === item.id ? drop.pos : undefined}
|
|
506
|
+
on:dragstart={(e) => onDragStart(e, index)}
|
|
507
|
+
on:dragover={(e) => onDragOver(e, item.id)}
|
|
508
|
+
on:dragleave={() => { if (drop && drop.id === item.id) drop = null; }}
|
|
509
|
+
on:drop={(e) => onDrop(e, index)}
|
|
510
|
+
on:dragend={clear}
|
|
511
|
+
>
|
|
512
|
+
<button
|
|
513
|
+
type="button"
|
|
514
|
+
data-slot="handle"
|
|
515
|
+
aria-label={`Reorder: ${item.label}`}
|
|
516
|
+
aria-describedby="my-sortable-hint"
|
|
517
|
+
aria-pressed={grabbed === item.id}
|
|
518
|
+
on:keydown={(e) => onHandleKeydown(e, index)}
|
|
519
|
+
on:pointerdown={() => (armed = true)}
|
|
520
|
+
on:pointerup={() => (armed = false)}
|
|
521
|
+
>
|
|
522
|
+
<span aria-hidden="true">⠿</span>
|
|
523
|
+
</button>
|
|
524
|
+
<span data-slot="label">{item.label}</span>
|
|
525
|
+
</li>
|
|
526
|
+
{/each}
|
|
527
|
+
</ul>
|
|
528
|
+
<p id="my-sortable-hint" hidden>Press Space to grab, arrow keys to move, Space to drop, Escape to cancel.</p>
|
|
529
|
+
<div data-slot="announce" aria-live="assertive" aria-atomic="true" class="my-sr-only">{announce}</div>
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Vanilla (Web Component)
|
|
533
|
+
|
|
534
|
+
Progressive enhancement: author the `<ul>` in plain HTML, wrap it in `<sortable-list>`, and the element wires up the handles it finds. Items are moved with `insertBefore`, so the focused handle is the same node before and after a move.
|
|
535
|
+
|
|
536
|
+
```js
|
|
537
|
+
class SortableList extends HTMLElement {
|
|
538
|
+
connectedCallback() {
|
|
539
|
+
this.list = this.querySelector("ul");
|
|
540
|
+
this.live = this.querySelector('[data-slot="announce"]');
|
|
541
|
+
this.grabbed = null; // <li> currently held
|
|
542
|
+
this.snapshot = null; // order before a keyboard grab, for Escape
|
|
543
|
+
this.dragged = null; // <li> in an HTML5 drag
|
|
544
|
+
this.armed = false; // pointer went down on a handle
|
|
545
|
+
|
|
546
|
+
this.list.addEventListener("pointerdown", (e) => { this.armed = !!e.target.closest('[data-slot="handle"]'); });
|
|
547
|
+
this.list.addEventListener("pointerup", () => { this.armed = false; });
|
|
548
|
+
this.list.addEventListener("keydown", (e) => this.onKeyDown(e));
|
|
549
|
+
this.list.addEventListener("dragstart", (e) => this.onDragStart(e));
|
|
550
|
+
this.list.addEventListener("dragover", (e) => this.onDragOver(e));
|
|
551
|
+
this.list.addEventListener("dragleave", (e) => e.target.closest?.('[data-slot="item"]')?.removeAttribute("data-drop-position"));
|
|
552
|
+
this.list.addEventListener("drop", (e) => this.onDrop(e));
|
|
553
|
+
this.list.addEventListener("dragend", () => this.clear());
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
get items() { return [...this.list.querySelectorAll('[data-slot="item"]')]; }
|
|
557
|
+
labelOf(li) { return li.querySelector('[data-slot="label"]').textContent.trim(); }
|
|
558
|
+
say(text) { this.live.textContent = ""; requestAnimationFrame(() => { this.live.textContent = text; }); }
|
|
559
|
+
|
|
560
|
+
move(li, to) {
|
|
561
|
+
const items = this.items;
|
|
562
|
+
const from = items.indexOf(li);
|
|
563
|
+
if (to < 0 || to >= items.length || to === from) return;
|
|
564
|
+
const ref = to > from ? items[to].nextSibling : items[to];
|
|
565
|
+
this.list.insertBefore(li, ref);
|
|
566
|
+
this.dispatchEvent(new CustomEvent("change", { detail: this.items.map((i) => i.dataset.id) }));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
setGrabbed(li) {
|
|
570
|
+
this.grabbed?.removeAttribute("data-grabbed");
|
|
571
|
+
this.grabbed?.querySelector('[data-slot="handle"]').setAttribute("aria-pressed", "false");
|
|
572
|
+
this.grabbed = li;
|
|
573
|
+
if (li) { li.setAttribute("data-grabbed", ""); li.querySelector('[data-slot="handle"]').setAttribute("aria-pressed", "true"); }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
onKeyDown(e) {
|
|
577
|
+
const handle = e.target.closest('[data-slot="handle"]');
|
|
578
|
+
if (!handle) return;
|
|
579
|
+
const li = handle.closest('[data-slot="item"]');
|
|
580
|
+
const items = this.items;
|
|
581
|
+
const index = items.indexOf(li);
|
|
582
|
+
const held = this.grabbed === li;
|
|
583
|
+
const pos = () => `${this.items.indexOf(li) + 1} of ${items.length}`;
|
|
584
|
+
|
|
585
|
+
if (e.key === " " || e.key === "Enter") {
|
|
586
|
+
e.preventDefault();
|
|
587
|
+
if (!held) { this.snapshot = items; this.setGrabbed(li); this.say(`Grabbed ${this.labelOf(li)}, position ${pos()}.`); }
|
|
588
|
+
else { this.setGrabbed(null); this.say(`Dropped ${this.labelOf(li)} at position ${pos()}.`); }
|
|
589
|
+
} else if (e.key === "Escape" && held) {
|
|
590
|
+
e.preventDefault();
|
|
591
|
+
this.snapshot?.forEach((el) => this.list.appendChild(el));
|
|
592
|
+
this.setGrabbed(null);
|
|
593
|
+
this.say("Reorder cancelled.");
|
|
594
|
+
} else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
|
|
595
|
+
e.preventDefault();
|
|
596
|
+
const to = e.key === "ArrowUp" ? index - 1 : e.key === "ArrowDown" ? index + 1 : e.key === "Home" ? 0 : items.length - 1;
|
|
597
|
+
if (held) { this.move(li, to); this.say(`${this.labelOf(li)} moved to position ${pos()}.`); }
|
|
598
|
+
else items[Math.max(0, Math.min(to, items.length - 1))].querySelector('[data-slot="handle"]').focus();
|
|
599
|
+
} else if (e.key === "Tab" && held) {
|
|
600
|
+
this.setGrabbed(null);
|
|
601
|
+
this.say(`Dropped ${this.labelOf(li)} at position ${pos()}.`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
onDragStart(e) {
|
|
606
|
+
const li = e.target.closest('[data-slot="item"]');
|
|
607
|
+
if (!li || !this.armed) { e.preventDefault(); return; } // dragstart targets the <li>, so ask the flag
|
|
608
|
+
this.dragged = li;
|
|
609
|
+
e.dataTransfer.setData("text/plain", li.dataset.id ?? "");
|
|
610
|
+
e.dataTransfer.effectAllowed = "move";
|
|
611
|
+
this.setGrabbed(li);
|
|
612
|
+
}
|
|
613
|
+
onDragOver(e) {
|
|
614
|
+
const li = e.target.closest('[data-slot="item"]');
|
|
615
|
+
if (!li || li === this.dragged) return;
|
|
616
|
+
e.preventDefault();
|
|
617
|
+
const r = li.getBoundingClientRect();
|
|
618
|
+
this.items.forEach((i) => i.removeAttribute("data-drop-position"));
|
|
619
|
+
li.setAttribute("data-drop-position", e.clientY < r.top + r.height / 2 ? "before" : "after");
|
|
620
|
+
}
|
|
621
|
+
onDrop(e) {
|
|
622
|
+
const li = e.target.closest('[data-slot="item"]');
|
|
623
|
+
if (!li || !this.dragged) return;
|
|
624
|
+
e.preventDefault();
|
|
625
|
+
const items = this.items;
|
|
626
|
+
let to = li.getAttribute("data-drop-position") === "before" ? items.indexOf(li) : items.indexOf(li) + 1;
|
|
627
|
+
if (items.indexOf(this.dragged) < to) to -= 1;
|
|
628
|
+
const label = this.labelOf(this.dragged);
|
|
629
|
+
this.move(this.dragged, to);
|
|
630
|
+
this.say(`${label} moved to position ${to + 1} of ${items.length}.`);
|
|
631
|
+
this.clear();
|
|
632
|
+
}
|
|
633
|
+
clear() {
|
|
634
|
+
this.items.forEach((i) => i.removeAttribute("data-drop-position"));
|
|
635
|
+
this.setGrabbed(null);
|
|
636
|
+
this.dragged = null;
|
|
637
|
+
this.armed = false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
customElements.define("sortable-list", SortableList);
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
```html
|
|
644
|
+
<sortable-list>
|
|
645
|
+
<ul class="my-sortable" data-cia-recipe="sortable-list" role="list" aria-label="Task order">
|
|
646
|
+
<li data-slot="item" draggable="true" data-id="a">
|
|
647
|
+
<button type="button" data-slot="handle" aria-label="Reorder: Write the launch post" aria-describedby="my-sortable-hint" aria-pressed="false"><span aria-hidden="true">⠿</span></button>
|
|
648
|
+
<span data-slot="label">Write the launch post</span>
|
|
649
|
+
</li>
|
|
650
|
+
<!-- … -->
|
|
651
|
+
</ul>
|
|
652
|
+
<p id="my-sortable-hint" hidden>Press Space to grab, arrow keys to move, Space to drop, Escape to cancel.</p>
|
|
653
|
+
<div data-slot="announce" aria-live="assertive" aria-atomic="true" class="my-sr-only"></div>
|
|
654
|
+
</sortable-list>
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
The Web Component only lets a drag begin from the handle: `pointerdown` records whether the press landed on a grip, and `onDragStart` (which the browser dispatches on the `draggable` `<li>`, never on the button inside it) cancels the drag unless it did.
|
|
658
|
+
|
|
659
|
+
## Variants
|
|
660
|
+
|
|
661
|
+
### Horizontal (a tab strip, a kanban column header)
|
|
662
|
+
|
|
663
|
+
Swap `stack` for `cluster` and turn the insertion line vertical:
|
|
664
|
+
|
|
665
|
+
```scss
|
|
666
|
+
@use 'css-is-awesome/api' as cia;
|
|
667
|
+
|
|
668
|
+
.my-sortable-row {
|
|
669
|
+
@include cia.list-reset;
|
|
670
|
+
@include cia.cluster($gap: 1);
|
|
671
|
+
|
|
672
|
+
[data-slot="item"]::before {
|
|
673
|
+
inset-inline: auto;
|
|
674
|
+
inset-block: 0;
|
|
675
|
+
inline-size: 2px;
|
|
676
|
+
block-size: auto;
|
|
677
|
+
}
|
|
678
|
+
[data-slot="item"][data-drop-position="before"]::before { inset-inline-start: -3px; inset-block-start: 0; }
|
|
679
|
+
[data-slot="item"][data-drop-position="after"]::before { inset-inline-end: -3px; inset-block-end: 0; }
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
In the JS, compare `clientX` to the horizontal midpoint instead of `clientY`, and map `ArrowLeft` / `ArrowRight` to the same moves as up / down.
|
|
684
|
+
|
|
685
|
+
### Whole-row handle
|
|
686
|
+
|
|
687
|
+
For dense admin lists where a separate grip is visual noise, put `data-slot="handle"` on a button that fills the row and contains the label. Keep it a `<button>` — the keyboard loop and `aria-pressed` still need a control — and accept that label text is no longer selectable.
|
|
688
|
+
|
|
689
|
+
## Pitfalls
|
|
690
|
+
|
|
691
|
+
- **Keying items by index.** Every framework example keys on `item.id`. Keyed by index, a move re-renders every row between the two positions, the handle that had focus is destroyed, and focus falls to `<body>` — the keyboard user loses the item they were holding.
|
|
692
|
+
- **Firefox needs `setData`.** Without a `dataTransfer.setData(...)` call in `dragstart`, Firefox never starts the drag at all; the pointer path silently does nothing.
|
|
693
|
+
- **`dragover` without `preventDefault`.** The browser treats every element as a *non*-drop target by default; forgetting `preventDefault()` in `dragover` means `drop` never fires and the cursor shows the "not allowed" icon.
|
|
694
|
+
- **Re-mounting the live region.** If the announcer lives inside the list (or inside a component that re-renders on reorder) it re-mounts and announces nothing. Keep it a sibling of the `<ul>`, mounted once.
|
|
695
|
+
- **Dropping an item onto itself.** Guard `to === from` in `move()`, and skip `data-drop-position` on the dragged item — otherwise the insertion line flickers on the thing being dragged.
|
|
696
|
+
- **Touch.** HTML5 drag-and-drop is not available on iOS Safari and most Android browsers. If your product is touch-first, the pointer path here is not enough — reach for a library (see Interactivity) and keep the styling.
|
|
697
|
+
|
|
698
|
+
## Related recipes
|
|
699
|
+
|
|
700
|
+
- [`data-table`](./data-table.md) — the same `move()`-and-announce shape applies to reorderable table rows; pair the handle with a `<th scope="row">`
|
|
701
|
+
- [`admin-dashboard-layout`](./admin-dashboard-layout.md) — the natural home for a reorderable sidebar or widget list
|