@tapestry-ui/sortable 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Minton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,114 @@
1
- # Temporary Holding Version
1
+ # @tapestry-ui/sortable
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Reordering by hand, for Preact. **Headless: you render, it reorders.**
4
+
5
+ Part of [Tapestry UI](https://github.com/ryurage/brandonminton). Extracted from a
6
+ kanban board where the touch behaviour was worked out the hard way.
7
+
8
+ ```bash
9
+ npm i @tapestry-ui/sortable
10
+ ```
11
+
12
+ ```tsx
13
+ import { useSortable } from '@tapestry-ui/sortable';
14
+ import '@tapestry-ui/sortable/styles.css'; // two rules, both behavioural
15
+
16
+ const sortable = useSortable({ ids, onReorder: setIds, labelOf, onAnnounce: setSaid });
17
+
18
+ <ul>
19
+ {ids.map((id) => (
20
+ <li key={id} {...sortable.itemProps(id)}
21
+ class={sortable.dragging === id ? 'tui-sortable__item--lifted' : undefined}>
22
+ <span {...sortable.gripProps}>⠿</span>
23
+ {label(id)}
24
+ <button onClick={() => sortable.moveBy(id, -1)}>up</button>
25
+ </li>
26
+ ))}
27
+ </ul>
28
+ <p role="status" aria-live="polite">{said}</p>
29
+ ```
30
+
31
+ ## Three ways to move the same item
32
+
33
+ One is never enough.
34
+
35
+ | | How |
36
+ |---|---|
37
+ | **Mouse / pen** | press anywhere on the item and drag |
38
+ | **Touch** | press **the grip** and drag |
39
+ | **Keyboard / button** | `moveBy(id, ±1)` |
40
+
41
+ **A finger only drags from the grip, and that is the whole design.** A list you
42
+ cannot scroll is worse than a list you cannot reorder, so a finger anywhere else
43
+ must scroll the page. A mouse has no competing gesture, so it drags from
44
+ anywhere.
45
+
46
+ The keyboard path is not a fallback — it is the one that works one-handed, and
47
+ the one a screen reader can follow. **Every move announces the same way, however
48
+ it was made**, so wire `onAnnounce` to a live region and all three are equal.
49
+
50
+ ## Two things that are easy to get wrong
51
+
52
+ **The drag is held in a ref, not in state.** A `pointerup` can arrive in the same
53
+ frame as the last `pointermove`, before any re-render — and a handler created
54
+ during render would close over a stale `null` and silently drop the drop. This
55
+ package was shipped with that bug for about an hour; the test that caught it
56
+ dispatches move and up with no await between them, which is the realistic case.
57
+
58
+ **`touch-action: none` on the grip is behaviour, not decoration.** Without it the
59
+ browser claims the gesture for scrolling and `pointermove` stops arriving. It is
60
+ in `styles.css`, along with `pointer-events: none` for the lifted item — without
61
+ *that*, `elementFromPoint` keeps finding the thing being dragged instead of the
62
+ thing underneath it.
63
+
64
+ ## API
65
+
66
+ ```ts
67
+ useSortable({
68
+ ids: string[], // the order; the hook holds no state of its own
69
+ onReorder: (ids: string[]) => void,
70
+ disabled?: boolean,
71
+ labelOf?: (id: string) => string, // for announcements; defaults to the id
72
+ onAnnounce?: (message: string) => void,
73
+ threshold?: number, // px before a press is a drag (default 4)
74
+ gripSelector?: string, // default '[data-sortable-grip]'
75
+ ignoreSelector?: string, // default 'input, button, select, textarea, a, [data-no-drag]'
76
+ }) => {
77
+ dragging: string | null, // lifted item, once past the threshold
78
+ overId: string | null, // what it is over, for a placeholder
79
+ offset: { dx, dy }, // for a transform
80
+ itemProps(id), // spread onto each item
81
+ gripProps, // spread onto the grip
82
+ moveBy(id, step),
83
+ }
84
+ ```
85
+
86
+ The pure parts are exported and are where the logic actually lives:
87
+ `reorder(ids, id, index)`, `nudge(ids, id, step)`, `dropOn(ids, id, overId)`.
88
+ Each returns **the same array** when nothing moves, so a consumer can skip the
89
+ write.
90
+
91
+ Escape always abandons a drag, including one the pointer never returned from.
92
+
93
+ ## Testing against it
94
+
95
+ jsdom implements no Pointer Events, and that bites in a non-obvious way: Preact
96
+ picks an event's registered name with `name.toLowerCase() in dom`, so without
97
+ `onpointerdown` on the element it registers `"PointerDown"` — capital P — and a
98
+ dispatched `"pointerdown"` never arrives. Declare the handler properties first:
99
+
100
+ ```ts
101
+ for (const name of ['onpointerdown', 'onpointermove', 'onpointerup', 'onpointercancel']) {
102
+ if (!(name in HTMLElement.prototype)) {
103
+ Object.defineProperty(HTMLElement.prototype, name, { value: null, writable: true });
104
+ }
105
+ }
106
+ HTMLElement.prototype.setPointerCapture ??= function () {};
107
+ ```
108
+
109
+ You will also want to stub `document.elementFromPoint`, which is how the drop
110
+ target is found and which jsdom cannot answer without layout.
111
+
112
+ ## License
113
+
114
+ MIT © Brandon Minton
@@ -0,0 +1,74 @@
1
+ import { type JSX } from 'preact';
2
+ export interface Drag {
3
+ id: string;
4
+ /** The one pointer that owns this drag; a second finger is ignored. */
5
+ pointerId: number;
6
+ startX: number;
7
+ startY: number;
8
+ dx: number;
9
+ dy: number;
10
+ /** Item the pointer is currently over, or null when it is over nothing. */
11
+ overId: string | null;
12
+ }
13
+ /**
14
+ * Everything this hook says out loud. Functions rather than templates, because
15
+ * a string with a placeholder in it cannot pluralise: "1 item" / "2 items" is an
16
+ * English rule, and Polish has three forms. The target language decides.
17
+ */
18
+ export interface SortableStrings {
19
+ moved: (label: string, position: number, total: number) => string;
20
+ alreadyFirst: (label: string) => string;
21
+ alreadyLast: (label: string) => string;
22
+ cancelled: string;
23
+ }
24
+ export declare const EN_SORTABLE: SortableStrings;
25
+ export interface SortableOptions {
26
+ /** The order, as ids. This is the single source of truth; the hook holds none. */
27
+ ids: string[];
28
+ /** The new order, when a move happens. */
29
+ onReorder: (ids: string[]) => void;
30
+ disabled?: boolean;
31
+ /** Human-readable name for an id, used in announcements. Defaults to the id. */
32
+ labelOf?: (id: string) => string;
33
+ /** Said after every move, however it was made. Wire it to a live region. */
34
+ onAnnounce?: (message: string) => void;
35
+ /** Replace any of the words this hook says. Defaults are English. */
36
+ strings?: Partial<SortableStrings>;
37
+ threshold?: number;
38
+ gripSelector?: string;
39
+ ignoreSelector?: string;
40
+ }
41
+ /** Move the item with `id` to `index`, clamped. Pure, so it is the testable part. */
42
+ export declare function reorder(ids: string[], id: string, index: number): string[];
43
+ /** Nudge an item by `step` places. Off either end is a no-op, never a wrap. */
44
+ export declare function nudge(ids: string[], id: string, step: number): string[];
45
+ /** Put `id` directly where `overId` sits — what a drop means. */
46
+ export declare function dropOn(ids: string[], id: string, overId: string): string[];
47
+ type Spreadable = Omit<JSX.HTMLAttributes<HTMLElement>, 'ref'>;
48
+ /** What goes on an item's root: the drag handlers plus the id the drop lookup reads. */
49
+ export type ItemProps = Spreadable & {
50
+ 'data-sortable-id': string;
51
+ };
52
+ /** What goes on the grip: the marker the touch rule looks for. */
53
+ export type GripProps = Spreadable & {
54
+ 'data-sortable-grip': true;
55
+ };
56
+ export interface Sortable {
57
+ /** The lifted item, or null. Only set once the press has travelled far enough. */
58
+ dragging: string | null;
59
+ /** Item the lifted one is currently over, for drawing a placeholder. */
60
+ overId: string | null;
61
+ /** How far the lifted item has travelled, for a transform. */
62
+ offset: {
63
+ dx: number;
64
+ dy: number;
65
+ };
66
+ /** Spread onto each item's root element. */
67
+ itemProps: (id: string) => ItemProps;
68
+ /** Spread onto the grip inside an item — the only place a finger may drag from. */
69
+ gripProps: GripProps;
70
+ /** Move by keyboard or a button. Announces, like every other path. */
71
+ moveBy: (id: string, step: number) => void;
72
+ }
73
+ export declare function useSortable(options: SortableOptions): Sortable;
74
+ export {};
@@ -0,0 +1,167 @@
1
+ // @tapestry-ui/sortable — reordering by hand (Tapestry UI citizen #7).
2
+ //
3
+ // Extracted from the kanban board's editor, where the touch behaviour was
4
+ // worked out the hard way: a finger anywhere on a card has to scroll the page,
5
+ // because a list you cannot scroll is worse than a list you cannot reorder. So
6
+ // TOUCH DRAGS ONLY FROM A GRIP. A mouse or pen drags from anywhere, because a
7
+ // mouse has no competing gesture.
8
+ //
9
+ // Headless, like the rest: this hook never renders anything. It hands you props
10
+ // to spread and tells you what is lifted and what it is over; the markup, the
11
+ // placeholder and every pixel of styling are yours.
12
+ //
13
+ // THREE WAYS TO MOVE THE SAME ITEM, because one is never enough:
14
+ // mouse/pen — press anywhere on the item and drag
15
+ // touch — press the grip and drag
16
+ // keyboard — moveBy(id, ±1), which you wire to buttons or arrow keys
17
+ // The keyboard path is not a fallback. It is the one that works while holding a
18
+ // coffee, and the one a screen reader can follow, which is why every move —
19
+ // however it was made — goes through the same announcement.
20
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
21
+ /** Pointer travel, in px, before a press counts as a drag rather than a tap. */
22
+ const DEFAULT_THRESHOLD_PX = 4;
23
+ /** Presses on these belong to the control, not to the drag. */
24
+ const DEFAULT_IGNORE = 'input, button, select, textarea, a, [data-no-drag]';
25
+ /** A touch starts a drag only from here, so a finger elsewhere still scrolls. */
26
+ const DEFAULT_GRIP = '[data-sortable-grip]';
27
+ export const EN_SORTABLE = {
28
+ moved: (label, position, total) => `${label} moved to position ${position} of ${total}`,
29
+ alreadyFirst: (label) => `${label} is already first`,
30
+ alreadyLast: (label) => `${label} is already last`,
31
+ cancelled: 'Move cancelled',
32
+ };
33
+ /** Move the item with `id` to `index`, clamped. Pure, so it is the testable part. */
34
+ export function reorder(ids, id, index) {
35
+ const from = ids.indexOf(id);
36
+ if (from === -1)
37
+ return ids;
38
+ const to = Math.max(0, Math.min(index, ids.length - 1));
39
+ if (to === from)
40
+ return ids;
41
+ const next = [...ids];
42
+ next.splice(from, 1);
43
+ next.splice(to, 0, id);
44
+ return next;
45
+ }
46
+ /** Nudge an item by `step` places. Off either end is a no-op, never a wrap. */
47
+ export function nudge(ids, id, step) {
48
+ const from = ids.indexOf(id);
49
+ if (from === -1)
50
+ return ids;
51
+ const to = from + step;
52
+ if (to < 0 || to >= ids.length)
53
+ return ids;
54
+ return reorder(ids, id, to);
55
+ }
56
+ /** Put `id` directly where `overId` sits — what a drop means. */
57
+ export function dropOn(ids, id, overId) {
58
+ const to = ids.indexOf(overId);
59
+ return to === -1 ? ids : reorder(ids, id, to);
60
+ }
61
+ export function useSortable(options) {
62
+ const { ids, onReorder, disabled = false, threshold = DEFAULT_THRESHOLD_PX, gripSelector = DEFAULT_GRIP, ignoreSelector = DEFAULT_IGNORE, } = options;
63
+ // The drag lives in a REF, and the state is only a mirror for rendering.
64
+ // Handlers created during render close over whatever `drag` was at the time,
65
+ // and a pointerup can arrive in the same frame as the last pointermove — before
66
+ // any re-render — so a handler reading state would see a stale null and drop
67
+ // the drop. The ref is always current; the state only has to redraw.
68
+ const dragRef = useRef(null);
69
+ const say_ = { ...EN_SORTABLE, ...options.strings };
70
+ const [drag, setDragState] = useState(null);
71
+ const setDrag = useCallback((next) => {
72
+ dragRef.current = next;
73
+ setDragState(next);
74
+ }, []);
75
+ const labelOf = options.labelOf ?? ((id) => id);
76
+ // the list can change under a drag (an item removed elsewhere); keep the
77
+ // handlers reading the current one rather than the one they closed over
78
+ const latest = useRef({ ids, onReorder, labelOf, onAnnounce: options.onAnnounce, words: say_ });
79
+ latest.current = { ids, onReorder, labelOf, onAnnounce: options.onAnnounce, words: say_ };
80
+ const announce = useCallback((message) => {
81
+ latest.current.onAnnounce?.(message);
82
+ }, []);
83
+ const say = useCallback((id, next) => {
84
+ const at = next.indexOf(id);
85
+ announce(latest.current.words.moved(latest.current.labelOf(id), at + 1, next.length));
86
+ }, [announce]);
87
+ const moveBy = useCallback((id, step) => {
88
+ const next = nudge(latest.current.ids, id, step);
89
+ if (next === latest.current.ids) {
90
+ const label = latest.current.labelOf(id);
91
+ announce(step < 0 ? latest.current.words.alreadyFirst(label) : latest.current.words.alreadyLast(label));
92
+ return;
93
+ }
94
+ latest.current.onReorder(next);
95
+ say(id, next);
96
+ }, [announce, say]);
97
+ // A drag can be left mid-air if the pointer never comes back; Escape always
98
+ // abandons it. Registered once rather than per-drag, so there is no window
99
+ // where the drag has started and the listener has not been attached yet.
100
+ useEffect(() => {
101
+ const abandon = (event) => {
102
+ if (event.key !== 'Escape' || !dragRef.current)
103
+ return;
104
+ setDrag(null);
105
+ announce(latest.current.words.cancelled);
106
+ };
107
+ window.addEventListener('keydown', abandon);
108
+ return () => window.removeEventListener('keydown', abandon);
109
+ }, [announce, setDrag]);
110
+ /** Which item is under this point — the drop target. */
111
+ const idAtPoint = (x, y) => {
112
+ const under = document.elementFromPoint?.(x, y);
113
+ return under?.closest?.('[data-sortable-id]')?.getAttribute('data-sortable-id') ?? null;
114
+ };
115
+ /** A mouse or pen drags from anywhere on the item; a finger only from the grip,
116
+ * so the page still scrolls. Presses on a control belong to that control. */
117
+ const startsADrag = (event) => {
118
+ const pressed = event.target;
119
+ if (pressed.closest?.(ignoreSelector))
120
+ return false;
121
+ return event.pointerType !== 'touch' || Boolean(pressed.closest?.(gripSelector));
122
+ };
123
+ const itemProps = (id) => ({
124
+ 'data-sortable-id': id,
125
+ onPointerDown: (event) => {
126
+ if (disabled || dragRef.current || !startsADrag(event))
127
+ return;
128
+ event.currentTarget.setPointerCapture?.(event.pointerId);
129
+ event.preventDefault(); // no text selection under a mouse, no page scroll under a finger
130
+ setDrag({ id, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, dx: 0, dy: 0, overId: id });
131
+ },
132
+ onPointerMove: (event) => {
133
+ const current = dragRef.current;
134
+ if (!current || current.pointerId !== event.pointerId)
135
+ return;
136
+ setDrag({
137
+ ...current,
138
+ dx: event.clientX - current.startX,
139
+ dy: event.clientY - current.startY,
140
+ overId: idAtPoint(event.clientX, event.clientY),
141
+ });
142
+ },
143
+ onPointerUp: (event) => {
144
+ const current = dragRef.current;
145
+ if (!current || current.pointerId !== event.pointerId)
146
+ return;
147
+ setDrag(null);
148
+ if (current.overId === null || current.overId === current.id)
149
+ return;
150
+ const next = dropOn(latest.current.ids, current.id, current.overId);
151
+ if (next === latest.current.ids)
152
+ return;
153
+ latest.current.onReorder(next);
154
+ say(current.id, next);
155
+ },
156
+ onPointerCancel: () => setDrag(null),
157
+ });
158
+ const lifted = drag !== null && Math.hypot(drag.dx, drag.dy) > threshold;
159
+ return {
160
+ dragging: lifted ? drag.id : null,
161
+ overId: lifted ? drag.overId : null,
162
+ offset: drag ? { dx: drag.dx, dy: drag.dy } : { dx: 0, dy: 0 },
163
+ itemProps,
164
+ gripProps: { 'data-sortable-grip': true, 'aria-hidden': 'true' },
165
+ moveBy,
166
+ };
167
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@tapestry-ui/sortable",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "A WCAG 2.1 AA sortable list for Preact \u2014 pointer drag, touch from a grip so the page still scrolls, keyboard moves, announcements. Headless: you render, it reorders.",
5
+ "license": "MIT",
6
+ "author": "Brandon Minton",
7
+ "type": "module",
8
+ "sideEffects": [
9
+ "*.css"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/sortable.d.ts",
14
+ "default": "./dist/sortable.js"
15
+ },
16
+ "./styles.css": "./styles.css"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "styles.css",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.build.json",
28
+ "test": "vitest run",
29
+ "prepublishOnly": "npm run test && npm run build"
30
+ },
31
+ "peerDependencies": {
32
+ "preact": ">=10.24.0"
33
+ },
34
+ "devDependencies": {
35
+ "jsdom": "^25.0.1",
36
+ "preact": "^10.29.4",
37
+ "typescript": "^6.0.3",
38
+ "vitest": "^4.1.10"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/ryurage/brandonminton.git",
43
+ "directory": "packages/tapestry-ui/sortable"
44
+ },
45
+ "keywords": [
46
+ "preact",
47
+ "sortable",
48
+ "drag-and-drop",
49
+ "reorder",
50
+ "dnd",
51
+ "touch",
52
+ "a11y",
53
+ "wcag",
54
+ "headless",
55
+ "tapestry-ui"
56
+ ]
57
+ }
package/styles.css ADDED
@@ -0,0 +1,19 @@
1
+ /* @tapestry-ui/sortable — STRUCTURAL styles only, and barely any: the hook
2
+ renders nothing, so there is almost nothing to style. These exist because two
3
+ of them are not decoration but behaviour. */
4
+
5
+ /* touch-action:none is REQUIRED, not cosmetic. Without it the browser claims the
6
+ gesture for scrolling and the pointermove events stop arriving mid-drag. */
7
+ [data-sortable-grip] {
8
+ touch-action: none;
9
+ cursor: grab;
10
+ }
11
+ [data-sortable-grip]:active { cursor: grabbing; }
12
+
13
+ /* A lifted item must stop answering hit tests, or elementFromPoint keeps finding
14
+ the thing being dragged instead of the thing underneath it. */
15
+ .tui-sortable__item--lifted {
16
+ pointer-events: none;
17
+ position: relative;
18
+ z-index: var(--tui-sortable-lifted-z, 5);
19
+ }