@duro-app/ui 5.6.0 → 5.8.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/dist/{Table-B1sWL3GA.js → Table-CAjPFgTX.js} +398 -394
- package/dist/Table-CAjPFgTX.js.map +1 -0
- package/dist/{compiler-runtime-B2IcI8_O.js → compiler-runtime-BuugS4D0.js} +67 -59
- package/dist/{compiler-runtime-B2IcI8_O.js.map → compiler-runtime-BuugS4D0.js.map} +1 -1
- package/dist/components/AppShell/AppShell.d.ts +74 -0
- package/dist/components/AppShell/AppShell.d.ts.map +1 -0
- package/dist/components/AppShell/AppShell.meta.d.ts +3 -0
- package/dist/components/AppShell/AppShell.meta.d.ts.map +1 -0
- package/dist/components/AppShell/AppShell.stories.d.ts +186 -0
- package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -0
- package/dist/components/AppShell/styles.css.d.ts +187 -0
- package/dist/components/AppShell/styles.css.d.ts.map +1 -0
- package/dist/components/Aside/Aside.d.ts +23 -0
- package/dist/components/Aside/Aside.d.ts.map +1 -0
- package/dist/components/Aside/Aside.meta.d.ts +3 -0
- package/dist/components/Aside/Aside.meta.d.ts.map +1 -0
- package/dist/components/Aside/Aside.stories.d.ts +45 -0
- package/dist/components/Aside/Aside.stories.d.ts.map +1 -0
- package/dist/components/Aside/styles.css.d.ts +44 -0
- package/dist/components/Aside/styles.css.d.ts.map +1 -0
- package/dist/components/Button/styles.css.d.ts +2 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +3 -3
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.d.ts +29 -0
- package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.meta.d.ts +3 -0
- package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts +67 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -0
- package/dist/components/CodeBlock/styles.css.d.ts +32 -0
- package/dist/components/CodeBlock/styles.css.d.ts.map +1 -0
- package/dist/components/DragDrop/DragDrop.d.ts +95 -14
- package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDropBoard.stories.d.ts +69 -0
- package/dist/components/DragDrop/DragDropBoard.stories.d.ts.map +1 -0
- package/dist/components/DragDrop/styles.css.d.ts +80 -3
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
- package/dist/components/Grid/Grid.d.ts +13 -4
- package/dist/components/Grid/Grid.d.ts.map +1 -1
- package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
- package/dist/components/Grid/Grid.stories.d.ts +28 -0
- package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/components/Grid/styles.css.d.ts +3 -0
- package/dist/components/Grid/styles.css.d.ts.map +1 -1
- package/dist/components/Heading/Heading.d.ts +3 -1
- package/dist/components/Heading/Heading.d.ts.map +1 -1
- package/dist/components/Heading/Heading.stories.d.ts +9 -0
- package/dist/components/Heading/Heading.stories.d.ts.map +1 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts +7 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
- package/dist/components/LinkButton/styles.css.d.ts +2 -0
- package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
- package/dist/components/LiveRegion/LiveRegion.d.ts +28 -0
- package/dist/components/LiveRegion/LiveRegion.d.ts.map +1 -0
- package/dist/components/LiveRegion/LiveRegion.meta.d.ts +3 -0
- package/dist/components/LiveRegion/LiveRegion.meta.d.ts.map +1 -0
- package/dist/components/LiveRegion/LiveRegion.stories.d.ts +66 -0
- package/dist/components/LiveRegion/LiveRegion.stories.d.ts.map +1 -0
- package/dist/components/Menu/Menu.stories.d.ts +7 -0
- package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +2 -0
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Meter/Meter.d.ts +17 -0
- package/dist/components/Meter/Meter.d.ts.map +1 -0
- package/dist/components/Meter/Meter.meta.d.ts +3 -0
- package/dist/components/Meter/Meter.meta.d.ts.map +1 -0
- package/dist/components/Meter/Meter.stories.d.ts +12 -0
- package/dist/components/Meter/Meter.stories.d.ts.map +1 -0
- package/dist/components/Meter/styles.css.d.ts +34 -0
- package/dist/components/Meter/styles.css.d.ts.map +1 -0
- package/dist/components/PageNav/styles.css.d.ts +1 -1
- package/dist/components/Prose/Prose.d.ts +17 -0
- package/dist/components/Prose/Prose.d.ts.map +1 -0
- package/dist/components/Prose/Prose.meta.d.ts +3 -0
- package/dist/components/Prose/Prose.meta.d.ts.map +1 -0
- package/dist/components/Prose/Prose.stories.d.ts +63 -0
- package/dist/components/Prose/Prose.stories.d.ts.map +1 -0
- package/dist/components/SideNav/SideNav.d.ts +7 -1
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.stories.d.ts +3 -0
- package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
- package/dist/components/Time/Time.d.ts +26 -0
- package/dist/components/Time/Time.d.ts.map +1 -0
- package/dist/components/Time/Time.meta.d.ts +3 -0
- package/dist/components/Time/Time.meta.d.ts.map +1 -0
- package/dist/components/Time/Time.stories.d.ts +77 -0
- package/dist/components/Time/Time.stories.d.ts.map +1 -0
- package/dist/components/Time/relative.d.ts +27 -0
- package/dist/components/Time/relative.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.d.ts +71 -0
- package/dist/components/Timeline/Timeline.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.meta.d.ts +3 -0
- package/dist/components/Timeline/Timeline.meta.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.stories.d.ts +26 -0
- package/dist/components/Timeline/Timeline.stories.d.ts.map +1 -0
- package/dist/components/Timeline/styles.css.d.ts +158 -0
- package/dist/components/Timeline/styles.css.d.ts.map +1 -0
- package/dist/components/Toast/Toast.meta.d.ts.map +1 -1
- package/dist/components/Toast/Toast.stories.d.ts +9 -0
- package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
- package/dist/components/Toast/ToastProvider.d.ts +11 -5
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
- package/dist/components/Toast/index.d.ts +1 -1
- package/dist/components/Toast/index.d.ts.map +1 -1
- package/dist/components/Toast/styles.css.d.ts +6 -0
- package/dist/components/Toast/styles.css.d.ts.map +1 -1
- package/dist/form.js +1 -1
- package/dist/hooks/useContainerBelow.d.ts +18 -0
- package/dist/hooks/useContainerBelow.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7316 -5536
- package/dist/index.js.map +1 -1
- package/dist/table.js +2 -2
- package/package.json +2 -2
- package/src/components/AppShell/AppShell.meta.ts +62 -0
- package/src/components/AppShell/AppShell.stories.tsx +770 -0
- package/src/components/AppShell/AppShell.tsx +346 -0
- package/src/components/AppShell/styles.css.ts +219 -0
- package/src/components/Aside/Aside.meta.ts +33 -0
- package/src/components/Aside/Aside.stories.tsx +106 -0
- package/src/components/Aside/Aside.tsx +42 -0
- package/src/components/Aside/styles.css.ts +60 -0
- package/src/components/Button/styles.css.ts +7 -2
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +4 -5
- package/src/components/CodeBlock/CodeBlock.meta.ts +29 -0
- package/src/components/CodeBlock/CodeBlock.stories.tsx +162 -0
- package/src/components/CodeBlock/CodeBlock.tsx +99 -0
- package/src/components/CodeBlock/styles.css.ts +36 -0
- package/src/components/DragDrop/DragDrop.meta.ts +22 -21
- package/src/components/DragDrop/DragDrop.tsx +1147 -246
- package/src/components/DragDrop/DragDropBoard.stories.tsx +668 -0
- package/src/components/DragDrop/styles.css.ts +74 -4
- package/src/components/Grid/Grid.meta.ts +2 -1
- package/src/components/Grid/Grid.stories.tsx +202 -3
- package/src/components/Grid/Grid.tsx +35 -6
- package/src/components/Grid/styles.css.ts +11 -0
- package/src/components/Heading/Heading.stories.tsx +51 -0
- package/src/components/Heading/Heading.tsx +38 -7
- package/src/components/LinkButton/LinkButton.stories.tsx +41 -1
- package/src/components/LinkButton/styles.css.ts +6 -2
- package/src/components/LiveRegion/LiveRegion.meta.ts +29 -0
- package/src/components/LiveRegion/LiveRegion.stories.tsx +139 -0
- package/src/components/LiveRegion/LiveRegion.tsx +48 -0
- package/src/components/Menu/Menu.stories.tsx +62 -0
- package/src/components/Menu/styles.css.ts +6 -3
- package/src/components/Meter/Meter.meta.ts +24 -0
- package/src/components/Meter/Meter.stories.tsx +64 -0
- package/src/components/Meter/Meter.tsx +55 -0
- package/src/components/Meter/styles.css.ts +30 -0
- package/src/components/Prose/Prose.meta.ts +32 -0
- package/src/components/Prose/Prose.stories.tsx +233 -0
- package/src/components/Prose/Prose.tsx +25 -0
- package/src/components/Prose/prose.css +177 -0
- package/src/components/SideNav/SideNav.stories.tsx +29 -0
- package/src/components/SideNav/SideNav.tsx +14 -2
- package/src/components/Time/Time.meta.ts +17 -0
- package/src/components/Time/Time.stories.tsx +173 -0
- package/src/components/Time/Time.tsx +33 -0
- package/src/components/Time/relative.ts +89 -0
- package/src/components/Timeline/Timeline.meta.ts +46 -0
- package/src/components/Timeline/Timeline.stories.tsx +238 -0
- package/src/components/Timeline/Timeline.tsx +332 -0
- package/src/components/Timeline/styles.css.ts +162 -0
- package/src/components/Toast/Toast.meta.ts +2 -1
- package/src/components/Toast/Toast.stories.tsx +84 -0
- package/src/components/Toast/ToastProvider.tsx +49 -11
- package/src/components/Toast/index.ts +7 -1
- package/src/components/Toast/styles.css.ts +2 -0
- package/src/hooks/useContainerBelow.ts +39 -0
- package/src/index.ts +29 -1
- package/src/strict.css +4 -1
- package/dist/Table-B1sWL3GA.js.map +0 -1
|
@@ -3,33 +3,55 @@ import {
|
|
|
3
3
|
useCallback,
|
|
4
4
|
useContext,
|
|
5
5
|
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
6
8
|
useMemo,
|
|
7
9
|
useRef,
|
|
8
10
|
useState,
|
|
11
|
+
useSyncExternalStore,
|
|
9
12
|
type ReactNode,
|
|
10
13
|
} from 'react'
|
|
11
14
|
import {createPortal} from 'react-dom'
|
|
12
15
|
import {html} from 'react-strict-dom'
|
|
16
|
+
import type {SpacingToken} from '@duro-app/tokens/keys'
|
|
13
17
|
import {styles} from './styles.css'
|
|
14
18
|
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
15
19
|
import {isNative} from '../../platform'
|
|
16
20
|
import {visuallyHidden} from '../../styles/visually-hidden.css'
|
|
17
21
|
|
|
18
22
|
// ---------------------------------------------------------------------------
|
|
19
|
-
// DragDrop — move items between zones
|
|
20
|
-
//
|
|
23
|
+
// DragDrop — move items between zones, by pointer (mouse, pen and touch
|
|
24
|
+
// through one pointer-event path) or by keyboard.
|
|
21
25
|
//
|
|
22
|
-
// Pointer
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// Pointer: a press arms a drag, movement past a small threshold (or, on
|
|
27
|
+
// touch, a short hold) starts it, and release resolves the zone under the
|
|
28
|
+
// pointer. The innermost zone that accepts the item wins
|
|
29
|
+
// (document.elementsFromPoint), so a zone nested in another (a card inside a
|
|
30
|
+
// column) receives its own drops. The dragged item stays in the DOM (faded)
|
|
31
|
+
// while a ghost follows the pointer. Item rects are measured once at pick-up
|
|
32
|
+
// and shifted by how far their scrolling ancestors have scrolled since, so an
|
|
33
|
+
// optional placeholder (renderPlaceholder) never feeds back into the index,
|
|
34
|
+
// and a small hysteresis keeps the index from flickering at a midpoint.
|
|
35
|
+
// Near the edge of a scrolling ancestor (or the window) the drag scrolls it.
|
|
28
36
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// the
|
|
32
|
-
//
|
|
37
|
+
// Keyboard: put a DragDrop.Handle in an Item. Handles are one roving tab
|
|
38
|
+
// stop per zone; Up/Down and Left/Right move focus between them. Space picks
|
|
39
|
+
// the item up, Up/Down move it within its zone, Left/Right move it across
|
|
40
|
+
// zones (in `order`, else DOM order), Space or Enter drops, Escape (or Tab)
|
|
41
|
+
// cancels, and focus returns to the item's handle either way.
|
|
42
|
+
//
|
|
43
|
+
// State lives in an external store read with useSyncExternalStore and
|
|
44
|
+
// per-part selectors: a drag re-renders only the parts whose answer changed
|
|
45
|
+
// (the two items around a moving placeholder, the zones on pick-up and drop),
|
|
46
|
+
// and the ghost is moved by writing its transform, not by rendering.
|
|
47
|
+
//
|
|
48
|
+
// Every announcement (pick, move, drop, cancel, refuse) goes through the
|
|
49
|
+
// Root's live region and the optional `announce` prop, so an app can supply
|
|
50
|
+
// its own catalog strings. A zone can refuse with a reason
|
|
51
|
+
// (`accepts` → `{ok: false, reason}`): the ghost shows it and it is announced.
|
|
52
|
+
//
|
|
53
|
+
// WCAG 2.5.7 still applies to pointer-only consumers: an Item without a
|
|
54
|
+
// Handle has no keyboard path, so keep a button that does the same thing.
|
|
33
55
|
//
|
|
34
56
|
// Native: react-strict-dom does not route pointer capture on React Native,
|
|
35
57
|
// so Item renders its children without drag behaviour there and the consumer's
|
|
@@ -54,19 +76,66 @@ export interface DragDropEvent<T = unknown> {
|
|
|
54
76
|
readonly target: DragDropTarget
|
|
55
77
|
}
|
|
56
78
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
/** What a zone's `accepts` answers: `true` takes the item, `false` lets it
|
|
80
|
+
* pass silently (an outer zone may take it), and `{ok: false, reason}`
|
|
81
|
+
* refuses it out loud — the reason is shown on the ghost and announced. */
|
|
82
|
+
export type DragDropVerdict = boolean | {readonly ok: false; readonly reason: string}
|
|
83
|
+
|
|
84
|
+
export interface DragDropRefusal {
|
|
85
|
+
readonly zone: string
|
|
86
|
+
readonly reason: string
|
|
62
87
|
}
|
|
63
88
|
|
|
64
|
-
interface
|
|
65
|
-
readonly
|
|
89
|
+
export interface DragDropOverEvent<T = unknown> {
|
|
90
|
+
readonly item: DragDropItemData<T>
|
|
91
|
+
/** Where the item would land now; null over nothing that accepts it. */
|
|
92
|
+
readonly target: DragDropTarget | null
|
|
93
|
+
/** The innermost zone that refused with a reason, when nothing accepted. */
|
|
94
|
+
readonly refused: DragDropRefusal | null
|
|
95
|
+
readonly keyboard: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DragDropAnnouncement {
|
|
99
|
+
readonly kind: 'pick' | 'move' | 'drop' | 'cancel' | 'refuse'
|
|
100
|
+
/** The item's label. */
|
|
101
|
+
readonly item: string
|
|
102
|
+
/** The zone's label (pick, move, drop, refuse). */
|
|
103
|
+
readonly zone?: string
|
|
104
|
+
/** 1-based position in the zone, counting the item itself. */
|
|
105
|
+
readonly position?: number
|
|
106
|
+
/** How many items the zone holds with the item in it. */
|
|
107
|
+
readonly count?: number
|
|
108
|
+
/** The zone's refusal (refuse). */
|
|
109
|
+
readonly reason?: string
|
|
110
|
+
/** Whether the drag is a keyboard one. */
|
|
111
|
+
readonly keyboard: boolean
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface DragDropPlaceholderInfo {
|
|
66
115
|
readonly zone: string
|
|
67
|
-
readonly
|
|
116
|
+
readonly index: number
|
|
117
|
+
/** The zone's label, e.g. for "→ In progress". */
|
|
68
118
|
readonly label: string
|
|
69
|
-
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type DragDropRenderPlaceholder = (info: DragDropPlaceholderInfo) => ReactNode
|
|
122
|
+
|
|
123
|
+
interface ZoneRecord {
|
|
124
|
+
el: HTMLElement | null
|
|
125
|
+
orientation: 'horizontal' | 'vertical'
|
|
126
|
+
label: string
|
|
127
|
+
accepts?: (item: DragDropItemData) => DragDropVerdict
|
|
128
|
+
order?: number
|
|
129
|
+
renderPlaceholder?: DragDropRenderPlaceholder
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ItemRecord {
|
|
133
|
+
el: HTMLElement | null
|
|
134
|
+
zone: string
|
|
135
|
+
label: string
|
|
136
|
+
data: unknown
|
|
137
|
+
preview: ReactNode
|
|
138
|
+
disabled: boolean
|
|
70
139
|
}
|
|
71
140
|
|
|
72
141
|
interface DragSession {
|
|
@@ -85,29 +154,47 @@ interface DragSession {
|
|
|
85
154
|
holdTimer: ReturnType<typeof setTimeout> | null
|
|
86
155
|
}
|
|
87
156
|
|
|
88
|
-
|
|
157
|
+
/** The drag as the parts see it. Replaced (never mutated) on every change, so
|
|
158
|
+
* selectors can compare by reference. */
|
|
159
|
+
interface DragView {
|
|
89
160
|
readonly id: string
|
|
161
|
+
readonly keyboard: boolean
|
|
162
|
+
readonly over: DragDropTarget | null
|
|
163
|
+
readonly refused: DragDropRefusal | null
|
|
164
|
+
/** Each zone's items in DOM order at pick-up, without the dragged one. */
|
|
165
|
+
readonly order: ReadonlyMap<string, readonly string[]>
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface GhostView {
|
|
90
169
|
readonly preview: ReactNode
|
|
91
170
|
readonly width: number
|
|
92
171
|
readonly height: number
|
|
93
|
-
readonly x: number
|
|
94
|
-
readonly y: number
|
|
95
|
-
readonly over: DragDropTarget | null
|
|
96
172
|
}
|
|
97
173
|
|
|
98
|
-
interface
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
174
|
+
interface Rect {
|
|
175
|
+
readonly left: number
|
|
176
|
+
readonly top: number
|
|
177
|
+
readonly right: number
|
|
178
|
+
readonly bottom: number
|
|
103
179
|
}
|
|
104
180
|
|
|
105
|
-
|
|
181
|
+
interface Geometry {
|
|
182
|
+
readonly rect: Rect
|
|
183
|
+
readonly scrollers: readonly Element[]
|
|
184
|
+
}
|
|
106
185
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
186
|
+
/** The parts of a keydown the keyboard path reads. */
|
|
187
|
+
interface KeyLike {
|
|
188
|
+
readonly key: string
|
|
189
|
+
preventDefault(): void
|
|
190
|
+
stopPropagation(): void
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface LatestProps {
|
|
194
|
+
onDrop: (event: DragDropEvent<unknown>) => void
|
|
195
|
+
onDragOver?: (event: DragDropOverEvent<unknown>) => void
|
|
196
|
+
announce?: (event: DragDropAnnouncement) => string
|
|
197
|
+
renderPlaceholder?: DragDropRenderPlaceholder
|
|
111
198
|
}
|
|
112
199
|
|
|
113
200
|
/** How far the pointer moves before a press becomes a drag (mouse/pen), so a
|
|
@@ -116,173 +203,517 @@ const MOVE_THRESHOLD_PX = 6
|
|
|
116
203
|
/** How long a touch holds still before it becomes a drag. Below this a touch
|
|
117
204
|
* that moves is a scroll, and the browser gets it back via pointercancel. */
|
|
118
205
|
const TOUCH_HOLD_MS = 180
|
|
206
|
+
/** How far past an item's midpoint the pointer must go before the insertion
|
|
207
|
+
* index changes, so it does not flicker on the line. */
|
|
208
|
+
const HYSTERESIS_PX = 8
|
|
209
|
+
/** Auto-scroll: the band along a scroller's edge that scrolls it, and the
|
|
210
|
+
* largest step per frame (reached at the very edge). */
|
|
211
|
+
const EDGE_PX = 48
|
|
212
|
+
const MAX_STEP_PX = 16
|
|
213
|
+
|
|
214
|
+
const domOrder = (a: Element, b: Element) =>
|
|
215
|
+
a === b ? 0 : a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
|
|
216
|
+
|
|
217
|
+
const verdictOf = (v: DragDropVerdict | undefined): {ok: boolean; reason?: string} =>
|
|
218
|
+
v === undefined || v === true
|
|
219
|
+
? {ok: true}
|
|
220
|
+
: v === false
|
|
221
|
+
? {ok: false}
|
|
222
|
+
: {ok: false, reason: v.reason}
|
|
223
|
+
|
|
224
|
+
const sameTarget = (a: DragDropTarget | null, b: DragDropTarget | null) =>
|
|
225
|
+
a === b || (a !== null && b !== null && a.zone === b.zone && a.index === b.index)
|
|
226
|
+
|
|
227
|
+
const sameRefusal = (a: DragDropRefusal | null, b: DragDropRefusal | null) =>
|
|
228
|
+
a === b || (a !== null && b !== null && a.zone === b.zone && a.reason === b.reason)
|
|
229
|
+
|
|
230
|
+
function defaultAnnouncement(e: DragDropAnnouncement): string {
|
|
231
|
+
const where = e.position !== undefined ? `${e.zone}, position ${e.position} of ${e.count}` : ''
|
|
232
|
+
switch (e.kind) {
|
|
233
|
+
case 'pick':
|
|
234
|
+
return e.keyboard
|
|
235
|
+
? `Picked up ${e.item}. ${where}. Arrow keys move, Space drops, Escape cancels.`
|
|
236
|
+
: `Picked up ${e.item}.`
|
|
237
|
+
case 'move':
|
|
238
|
+
return where
|
|
239
|
+
case 'drop':
|
|
240
|
+
return e.keyboard
|
|
241
|
+
? `Dropped ${e.item} in ${where}.`
|
|
242
|
+
: `Dropped ${e.item} in ${e.zone ?? 'place'}.`
|
|
243
|
+
case 'cancel':
|
|
244
|
+
return `Cancelled moving ${e.item}.`
|
|
245
|
+
case 'refuse':
|
|
246
|
+
return `${e.zone}: ${e.reason}`
|
|
247
|
+
}
|
|
248
|
+
}
|
|
119
249
|
|
|
120
250
|
// ---------------------------------------------------------------------------
|
|
121
|
-
//
|
|
251
|
+
// Engine: the registry (stable), the drag store (subscribed to), and the
|
|
252
|
+
// pointer/keyboard state machine. One per Root, created once.
|
|
122
253
|
// ---------------------------------------------------------------------------
|
|
123
254
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
announce?: (event: {kind: 'pick' | 'drop' | 'cancel'; item: string; zone?: string}) => string
|
|
130
|
-
children: ReactNode
|
|
131
|
-
}
|
|
255
|
+
/** setPointerCapture / releasePointerCapture throw these when there is no
|
|
256
|
+
* active pointer to capture (a synthetic event, a pointer already gone) or
|
|
257
|
+
* the capture is already released. Anything else is a real error. */
|
|
258
|
+
const isPointerCaptureMiss = (err: unknown): boolean =>
|
|
259
|
+
err instanceof DOMException && (err.name === 'NotFoundError' || err.name === 'InvalidStateError')
|
|
132
260
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)
|
|
261
|
+
class Engine {
|
|
262
|
+
props: LatestProps = {onDrop: () => {}}
|
|
263
|
+
readonly zones = new Map<string, ZoneRecord>()
|
|
264
|
+
readonly items = new Map<string, ItemRecord>()
|
|
265
|
+
readonly handles = new Map<string, HTMLElement>()
|
|
266
|
+
|
|
267
|
+
drag: DragView | null = null
|
|
268
|
+
ghost: GhostView | null = null
|
|
269
|
+
ghostEl: HTMLElement | null = null
|
|
270
|
+
refusalText: string | null = null
|
|
271
|
+
announcement = ''
|
|
272
|
+
focusRequest: string | null = null
|
|
273
|
+
|
|
274
|
+
/** Roving tab stop per zone: the handle last focused there. */
|
|
275
|
+
private readonly stops = new Map<string, string>()
|
|
276
|
+
/** The first handle of each zone in DOM order, the stop until one is focused. */
|
|
277
|
+
private firstStops = new Map<string, string>()
|
|
278
|
+
private stopsDirty = false
|
|
279
|
+
|
|
280
|
+
private session: DragSession | null = null
|
|
281
|
+
private frame: number | null = null
|
|
282
|
+
private pointer = {x: 0, y: 0}
|
|
283
|
+
private geometry = new Map<string, Geometry>()
|
|
284
|
+
private baseline = new Map<Element, {left: number; top: number}>()
|
|
285
|
+
private scrollable = new Map<Element, boolean>()
|
|
286
|
+
private readonly listeners = new Set<() => void>()
|
|
156
287
|
|
|
157
|
-
|
|
158
|
-
|
|
288
|
+
// -- store ----------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
subscribe = (listener: () => void) => {
|
|
291
|
+
this.listeners.add(listener)
|
|
159
292
|
return () => {
|
|
160
|
-
|
|
293
|
+
this.listeners.delete(listener)
|
|
161
294
|
}
|
|
162
|
-
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private emit() {
|
|
298
|
+
for (const l of this.listeners) l()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
tabStop(zone: string): string | undefined {
|
|
302
|
+
const explicit = this.stops.get(zone)
|
|
303
|
+
if (explicit && this.handles.has(explicit) && this.items.get(explicit)?.zone === zone) {
|
|
304
|
+
return explicit
|
|
305
|
+
}
|
|
306
|
+
return this.firstStops.get(zone)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// -- registry -------------------------------------------------------------
|
|
163
310
|
|
|
164
|
-
|
|
165
|
-
|
|
311
|
+
registerZone(id: string, record: ZoneRecord) {
|
|
312
|
+
this.zones.set(id, record)
|
|
166
313
|
return () => {
|
|
167
|
-
|
|
314
|
+
if (this.zones.get(id) === record) this.zones.delete(id)
|
|
168
315
|
}
|
|
169
|
-
}
|
|
316
|
+
}
|
|
170
317
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
318
|
+
registerItem(id: string, record: ItemRecord) {
|
|
319
|
+
this.items.set(id, record)
|
|
320
|
+
return () => {
|
|
321
|
+
if (this.items.get(id) === record) this.items.delete(id)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
registerHandle(id: string, el: HTMLElement) {
|
|
326
|
+
this.handles.set(id, el)
|
|
327
|
+
this.recomputeStops()
|
|
328
|
+
return () => {
|
|
329
|
+
if (this.handles.get(id) === el) this.handles.delete(id)
|
|
330
|
+
this.recomputeStops()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Batched: a board mounting 300 handles recomputes once. */
|
|
335
|
+
private recomputeStops() {
|
|
336
|
+
if (this.stopsDirty) return
|
|
337
|
+
this.stopsDirty = true
|
|
338
|
+
queueMicrotask(() => {
|
|
339
|
+
this.stopsDirty = false
|
|
340
|
+
const next = new Map<string, string>()
|
|
341
|
+
const firstEl = new Map<string, HTMLElement>()
|
|
342
|
+
for (const [id, el] of this.handles) {
|
|
343
|
+
const zone = this.items.get(id)?.zone
|
|
344
|
+
if (zone === undefined) continue
|
|
345
|
+
const cur = firstEl.get(zone)
|
|
346
|
+
if (!cur || domOrder(el, cur) < 0) {
|
|
347
|
+
firstEl.set(zone, el)
|
|
348
|
+
next.set(zone, id)
|
|
349
|
+
}
|
|
188
350
|
}
|
|
189
|
-
|
|
351
|
+
this.firstStops = next
|
|
352
|
+
this.emit()
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private handlesIn(zone: string): string[] {
|
|
357
|
+
const ids: string[] = []
|
|
358
|
+
for (const [id] of this.handles) if (this.items.get(id)?.zone === zone) ids.push(id)
|
|
359
|
+
return ids.sort((a, b) => domOrder(this.handles.get(a)!, this.handles.get(b)!))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Zones in keyboard order: by `order`, then DOM order. */
|
|
363
|
+
private zoneSequence(): string[] {
|
|
364
|
+
const list = [...this.zones].filter(([, z]) => z.el)
|
|
365
|
+
list.sort(
|
|
366
|
+
([, a], [, b]) =>
|
|
367
|
+
(a.order ?? Number.POSITIVE_INFINITY) - (b.order ?? Number.POSITIVE_INFINITY) ||
|
|
368
|
+
domOrder(a.el!, b.el!),
|
|
369
|
+
)
|
|
370
|
+
return list.map(([id]) => id)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// -- announcements ----------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
private say(event: DragDropAnnouncement) {
|
|
376
|
+
const announce = this.props.announce
|
|
377
|
+
this.announcement = announce ? announce(event) : defaultAnnouncement(event)
|
|
378
|
+
this.emit()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private labelOf(itemId: string) {
|
|
382
|
+
return this.items.get(itemId)?.label ?? itemId
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private zoneLabel(zone: string) {
|
|
386
|
+
return this.zones.get(zone)?.label ?? zone
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private placeAt(target: DragDropTarget) {
|
|
390
|
+
const others = this.drag?.order.get(target.zone)?.length ?? 0
|
|
391
|
+
return {zone: this.zoneLabel(target.zone), position: target.index + 1, count: others + 1}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private payload(itemId: string): DragDropItemData | null {
|
|
395
|
+
const item = this.items.get(itemId)
|
|
396
|
+
return item ? {id: itemId, zone: item.zone, data: item.data} : null
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// -- measuring ------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
private captureOrder(exclude: string): Map<string, string[]> {
|
|
402
|
+
const byZone = new Map<string, string[]>()
|
|
403
|
+
for (const [id, item] of this.items) {
|
|
404
|
+
if (id === exclude || !item.el) continue
|
|
405
|
+
const list = byZone.get(item.zone)
|
|
406
|
+
if (list) list.push(id)
|
|
407
|
+
else byZone.set(item.zone, [id])
|
|
190
408
|
}
|
|
191
|
-
|
|
192
|
-
|
|
409
|
+
for (const list of byZone.values()) {
|
|
410
|
+
list.sort((a, b) => domOrder(this.items.get(a)!.el!, this.items.get(b)!.el!))
|
|
411
|
+
}
|
|
412
|
+
return byZone
|
|
413
|
+
}
|
|
193
414
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
415
|
+
private isScroller(el: Element): boolean {
|
|
416
|
+
const known = this.scrollable.get(el)
|
|
417
|
+
if (known !== undefined) return known
|
|
418
|
+
const s = getComputedStyle(el)
|
|
419
|
+
const scrolls = (o: string) => o === 'auto' || o === 'scroll' || o === 'overlay'
|
|
420
|
+
const yes =
|
|
421
|
+
(scrolls(s.overflowY) && el.scrollHeight > el.clientHeight) ||
|
|
422
|
+
(scrolls(s.overflowX) && el.scrollWidth > el.clientWidth)
|
|
423
|
+
this.scrollable.set(el, yes)
|
|
424
|
+
return yes
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Every item's rect, once, with the scrollers it moves with. Later reads
|
|
428
|
+
* shift the rect by how far those have scrolled since, so a placeholder
|
|
429
|
+
* inserted mid-drag never moves the geometry the index is read from. */
|
|
430
|
+
private captureGeometry() {
|
|
431
|
+
this.geometry = new Map()
|
|
432
|
+
this.baseline = new Map()
|
|
433
|
+
this.scrollable = new Map()
|
|
434
|
+
const root = document.scrollingElement
|
|
435
|
+
const chains = new Map<Element, Element[]>()
|
|
436
|
+
const chain = (el: Element | null): Element[] => {
|
|
437
|
+
if (!el || el === document.body || el === document.documentElement) return root ? [root] : []
|
|
438
|
+
const known = chains.get(el)
|
|
439
|
+
if (known) return known
|
|
440
|
+
const up = chain(el.parentElement)
|
|
441
|
+
const own = this.isScroller(el) ? [el, ...up] : up
|
|
442
|
+
chains.set(el, own)
|
|
443
|
+
return own
|
|
444
|
+
}
|
|
445
|
+
for (const [id, item] of this.items) {
|
|
446
|
+
if (!item.el) continue
|
|
447
|
+
const r = item.el.getBoundingClientRect()
|
|
448
|
+
const scrollers = chain(item.el.parentElement)
|
|
449
|
+
for (const s of scrollers) {
|
|
450
|
+
if (!this.baseline.has(s)) this.baseline.set(s, {left: s.scrollLeft, top: s.scrollTop})
|
|
210
451
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
452
|
+
this.geometry.set(id, {
|
|
453
|
+
rect: {left: r.left, top: r.top, right: r.right, bottom: r.bottom},
|
|
454
|
+
scrollers,
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private rectOf(id: string): Rect | null {
|
|
460
|
+
const g = this.geometry.get(id)
|
|
461
|
+
if (!g) return null
|
|
462
|
+
let dx = 0
|
|
463
|
+
let dy = 0
|
|
464
|
+
for (const s of g.scrollers) {
|
|
465
|
+
const b = this.baseline.get(s)
|
|
466
|
+
if (!b) continue
|
|
467
|
+
dx += s.scrollLeft - b.left
|
|
468
|
+
dy += s.scrollTop - b.top
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
left: g.rect.left - dx,
|
|
472
|
+
right: g.rect.right - dx,
|
|
473
|
+
top: g.rect.top - dy,
|
|
474
|
+
bottom: g.rect.bottom - dy,
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Where the dragged item would land at (x, y): the innermost zone that
|
|
479
|
+
* accepts it, and the index from the pick-up geometry, with hysteresis
|
|
480
|
+
* against the current index. */
|
|
481
|
+
private locate(
|
|
482
|
+
itemId: string,
|
|
483
|
+
x: number,
|
|
484
|
+
y: number,
|
|
485
|
+
): {target: DragDropTarget | null; refused: DragDropRefusal | null} {
|
|
486
|
+
const payload = this.payload(itemId)
|
|
487
|
+
const d = this.drag
|
|
488
|
+
if (!payload || !d) return {target: null, refused: null}
|
|
489
|
+
let refused: DragDropRefusal | null = null
|
|
490
|
+
const seen = new Set<string>()
|
|
491
|
+
const zoneByEl = new Map<Element, string>()
|
|
492
|
+
for (const [id, z] of this.zones) if (z.el) zoneByEl.set(z.el, id)
|
|
493
|
+
for (const el of document.elementsFromPoint(x, y)) {
|
|
494
|
+
const zoneId = zoneByEl.get(el)
|
|
495
|
+
if (zoneId === undefined || seen.has(zoneId)) continue
|
|
496
|
+
seen.add(zoneId)
|
|
497
|
+
const zone = this.zones.get(zoneId)!
|
|
498
|
+
const verdict = verdictOf(zone.accepts?.(payload))
|
|
499
|
+
if (!verdict.ok) {
|
|
500
|
+
if (verdict.reason !== undefined && !refused)
|
|
501
|
+
refused = {zone: zoneId, reason: verdict.reason}
|
|
502
|
+
continue
|
|
219
503
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
504
|
+
const horizontal = zone.orientation === 'horizontal'
|
|
505
|
+
const pos = horizontal ? x : y
|
|
506
|
+
const prev = d.over && d.over.zone === zoneId ? d.over.index : null
|
|
507
|
+
const others = d.order.get(zoneId) ?? []
|
|
508
|
+
let index = 0
|
|
509
|
+
others.forEach((otherId, k) => {
|
|
510
|
+
const r = this.rectOf(otherId)
|
|
511
|
+
if (!r) return
|
|
512
|
+
const mid = horizontal ? (r.left + r.right) / 2 : (r.top + r.bottom) / 2
|
|
513
|
+
const bias = prev === null ? 0 : k < prev ? -HYSTERESIS_PX : HYSTERESIS_PX
|
|
514
|
+
if (pos > mid + bias) index += 1
|
|
515
|
+
})
|
|
516
|
+
return {target: {zone: zoneId, index}, refused: null}
|
|
517
|
+
}
|
|
518
|
+
return {target: null, refused}
|
|
519
|
+
}
|
|
223
520
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
521
|
+
/** Point the drag at a new target/refusal; tells listeners only on change. */
|
|
522
|
+
private retarget(target: DragDropTarget | null, refused: DragDropRefusal | null) {
|
|
523
|
+
const d = this.drag
|
|
524
|
+
if (!d) return
|
|
525
|
+
if (sameTarget(d.over, target) && sameRefusal(d.refused, refused)) return
|
|
526
|
+
const wasRefused = d.refused
|
|
527
|
+
this.drag = {...d, over: target, refused}
|
|
528
|
+
this.refusalText = refused?.reason ?? null
|
|
529
|
+
this.paintGhost()
|
|
530
|
+
this.emit()
|
|
531
|
+
const payload = this.payload(d.id)
|
|
532
|
+
if (payload) this.props.onDragOver?.({item: payload, target, refused, keyboard: d.keyboard})
|
|
533
|
+
if (refused && !sameRefusal(wasRefused, refused)) {
|
|
534
|
+
this.say({
|
|
535
|
+
kind: 'refuse',
|
|
536
|
+
item: this.labelOf(d.id),
|
|
537
|
+
zone: this.zoneLabel(refused.zone),
|
|
538
|
+
reason: refused.reason,
|
|
539
|
+
keyboard: d.keyboard,
|
|
236
540
|
})
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
[locate, say],
|
|
240
|
-
)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
241
543
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
s.
|
|
273
|
-
|
|
274
|
-
|
|
544
|
+
// -- ghost ------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
/** The ghost moves by its transform, written here, so a frame of pointer
|
|
547
|
+
* movement renders nothing. */
|
|
548
|
+
paintGhost() {
|
|
549
|
+
const el = this.ghostEl
|
|
550
|
+
const s = this.session
|
|
551
|
+
if (!el || !s) return
|
|
552
|
+
el.style.transform = `translate3d(${this.pointer.x - s.offsetX}px, ${this.pointer.y - s.offsetY}px, 0)`
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// -- auto-scroll --------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
/** Scrolls the innermost scroller under the pointer whose edge band it is
|
|
558
|
+
* in and that can still move that way. True when something scrolled. */
|
|
559
|
+
private autoScroll(x: number, y: number): boolean {
|
|
560
|
+
const root = document.scrollingElement
|
|
561
|
+
const chain: Element[] = []
|
|
562
|
+
let el: Element | null = document.elementsFromPoint(x, y)[0] ?? null
|
|
563
|
+
while (el && el !== document.body && el !== document.documentElement) {
|
|
564
|
+
if (this.isScroller(el)) chain.push(el)
|
|
565
|
+
el = el.parentElement
|
|
566
|
+
}
|
|
567
|
+
if (root) chain.push(root)
|
|
568
|
+
const step = (dist: number) =>
|
|
569
|
+
dist < EDGE_PX ? Math.ceil(MAX_STEP_PX * (1 - Math.max(dist, 0) / EDGE_PX)) : 0
|
|
570
|
+
for (const s of chain) {
|
|
571
|
+
const r: Rect =
|
|
572
|
+
s === root
|
|
573
|
+
? {left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight}
|
|
574
|
+
: s.getBoundingClientRect()
|
|
575
|
+
const up = step(y - r.top)
|
|
576
|
+
const down = step(r.bottom - y)
|
|
577
|
+
const left = step(x - r.left)
|
|
578
|
+
const right = step(r.right - x)
|
|
579
|
+
const dy = up ? -up : down
|
|
580
|
+
const dx = left ? -left : right
|
|
581
|
+
const canY =
|
|
582
|
+
dy < 0 ? s.scrollTop > 0 : dy > 0 ? s.scrollTop + s.clientHeight < s.scrollHeight : false
|
|
583
|
+
const canX =
|
|
584
|
+
dx < 0 ? s.scrollLeft > 0 : dx > 0 ? s.scrollLeft + s.clientWidth < s.scrollWidth : false
|
|
585
|
+
if (canY || canX) {
|
|
586
|
+
if (canY) s.scrollTop += dy
|
|
587
|
+
if (canX) s.scrollLeft += dx
|
|
588
|
+
return true
|
|
275
589
|
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
590
|
+
}
|
|
591
|
+
return false
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// -- pointer --------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
private scheduleFrame() {
|
|
597
|
+
if (this.frame !== null) return
|
|
598
|
+
this.frame = requestAnimationFrame(this.tick)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private tick = () => {
|
|
602
|
+
this.frame = null
|
|
603
|
+
const s = this.session
|
|
604
|
+
if (!s || !s.active) return
|
|
605
|
+
const {x, y} = this.pointer
|
|
606
|
+
this.paintGhost()
|
|
607
|
+
const scrolled = this.autoScroll(x, y)
|
|
608
|
+
const {target, refused} = this.locate(s.id, x, y)
|
|
609
|
+
this.retarget(target, refused)
|
|
610
|
+
if (scrolled) this.scheduleFrame()
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private onScroll = () => {
|
|
614
|
+
if (this.session?.active) this.scheduleFrame()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private activate(s: DragSession, x: number, y: number) {
|
|
618
|
+
s.active = true
|
|
619
|
+
this.pointer = {x, y}
|
|
620
|
+
const item = this.items.get(s.id)
|
|
621
|
+
this.drag = {
|
|
622
|
+
id: s.id,
|
|
623
|
+
keyboard: false,
|
|
624
|
+
over: null,
|
|
625
|
+
refused: null,
|
|
626
|
+
order: this.captureOrder(s.id),
|
|
627
|
+
}
|
|
628
|
+
this.captureGeometry()
|
|
629
|
+
this.ghost = {preview: item?.preview ?? null, width: s.width, height: s.height}
|
|
630
|
+
this.refusalText = null
|
|
631
|
+
document.addEventListener('scroll', this.onScroll, {capture: true, passive: true})
|
|
632
|
+
this.emit()
|
|
633
|
+
this.say({kind: 'pick', item: this.labelOf(s.id), keyboard: false})
|
|
634
|
+
const {target, refused} = this.locate(s.id, x, y)
|
|
635
|
+
this.retarget(target, refused)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
begin(id: string, e: React.PointerEvent) {
|
|
639
|
+
if (this.session || this.drag || e.button !== 0) return
|
|
640
|
+
if (this.items.get(id)?.disabled) return
|
|
641
|
+
const source = e.currentTarget as HTMLElement
|
|
642
|
+
const rect = source.getBoundingClientRect()
|
|
643
|
+
const s: DragSession = {
|
|
644
|
+
id,
|
|
645
|
+
pointerId: e.pointerId,
|
|
646
|
+
downAt: e.timeStamp,
|
|
647
|
+
startX: e.clientX,
|
|
648
|
+
startY: e.clientY,
|
|
649
|
+
offsetX: e.clientX - rect.left,
|
|
650
|
+
offsetY: e.clientY - rect.top,
|
|
651
|
+
width: rect.width,
|
|
652
|
+
height: rect.height,
|
|
653
|
+
source,
|
|
654
|
+
active: false,
|
|
655
|
+
holdTimer: null,
|
|
656
|
+
}
|
|
657
|
+
this.session = s
|
|
658
|
+
// Capture keeps the stream on the item if the pointer leaves the window;
|
|
659
|
+
// the document listeners carry the drag either way, so a browser (or a
|
|
660
|
+
// synthetic event) without an active pointer is not an error.
|
|
661
|
+
try {
|
|
662
|
+
source.setPointerCapture(e.pointerId)
|
|
663
|
+
} catch (err) {
|
|
664
|
+
if (!isPointerCaptureMiss(err)) throw err
|
|
665
|
+
}
|
|
666
|
+
if (e.pointerType === 'touch') {
|
|
667
|
+
s.holdTimer = setTimeout(() => {
|
|
668
|
+
s.holdTimer = null
|
|
669
|
+
if (this.session === s && !s.active) this.activate(s, s.startX, s.startY)
|
|
670
|
+
}, TOUCH_HOLD_MS)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Ends a pointer drag. `commit` resolves the target from the release
|
|
675
|
+
* point; otherwise the drag is cancelled. The consumer's onDrop runs here,
|
|
676
|
+
* in the event handler, never inside a state updater. */
|
|
677
|
+
private finishPointer(commit: {x: number; y: number} | null) {
|
|
678
|
+
const s = this.session
|
|
679
|
+
if (!s) return
|
|
680
|
+
if (s.holdTimer) clearTimeout(s.holdTimer)
|
|
681
|
+
if (this.frame !== null) cancelAnimationFrame(this.frame)
|
|
682
|
+
this.frame = null
|
|
683
|
+
this.session = null
|
|
684
|
+
try {
|
|
685
|
+
if (s.source.hasPointerCapture(s.pointerId)) s.source.releasePointerCapture(s.pointerId)
|
|
686
|
+
} catch (err) {
|
|
687
|
+
if (!isPointerCaptureMiss(err)) throw err
|
|
688
|
+
}
|
|
689
|
+
if (!s.active) return
|
|
690
|
+
document.removeEventListener('scroll', this.onScroll, {capture: true})
|
|
691
|
+
let target: DragDropTarget | null = null
|
|
692
|
+
if (commit) {
|
|
693
|
+
const found = this.locate(s.id, commit.x, commit.y)
|
|
694
|
+
this.retarget(found.target, found.refused)
|
|
695
|
+
target = this.drag?.over ?? null
|
|
696
|
+
}
|
|
697
|
+
const announcement = target ? this.placeAt(target) : null
|
|
698
|
+
const payload = this.payload(s.id)
|
|
699
|
+
this.drag = null
|
|
700
|
+
this.ghost = null
|
|
701
|
+
this.refusalText = null
|
|
702
|
+
this.emit()
|
|
703
|
+
if (target && payload && announcement) {
|
|
704
|
+
this.props.onDrop({item: payload, target})
|
|
705
|
+
this.say({kind: 'drop', item: this.labelOf(s.id), ...announcement, keyboard: false})
|
|
706
|
+
} else {
|
|
707
|
+
this.say({kind: 'cancel', item: this.labelOf(s.id), keyboard: false})
|
|
708
|
+
}
|
|
709
|
+
}
|
|
279
710
|
|
|
280
711
|
// Move/up/cancel listen on the document: pointer capture keeps the events
|
|
281
712
|
// flowing to the source element, but a single document listener survives
|
|
282
713
|
// the item re-rendering under the pointer.
|
|
283
|
-
|
|
714
|
+
attach() {
|
|
284
715
|
const onMove = (e: PointerEvent) => {
|
|
285
|
-
const s = session
|
|
716
|
+
const s = this.session
|
|
286
717
|
if (!s || e.pointerId !== s.pointerId) return
|
|
287
718
|
if (!s.active) {
|
|
288
719
|
const moved = Math.hypot(e.clientX - s.startX, e.clientY - s.startY)
|
|
@@ -296,42 +727,34 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
|
|
|
296
727
|
if (e.timeStamp - s.downAt >= TOUCH_HOLD_MS) {
|
|
297
728
|
if (s.holdTimer) clearTimeout(s.holdTimer)
|
|
298
729
|
s.holdTimer = null
|
|
299
|
-
activate(s, s.startX, s.startY)
|
|
730
|
+
this.activate(s, s.startX, s.startY)
|
|
300
731
|
} else {
|
|
301
732
|
// Moved before the hold elapsed: a flick, not a drag.
|
|
302
|
-
if (moved > MOVE_THRESHOLD_PX)
|
|
733
|
+
if (moved > MOVE_THRESHOLD_PX) this.finishPointer(null)
|
|
303
734
|
return
|
|
304
735
|
}
|
|
305
736
|
} else {
|
|
306
737
|
if (moved < MOVE_THRESHOLD_PX) return
|
|
307
|
-
activate(s, e.clientX, e.clientY)
|
|
738
|
+
this.activate(s, e.clientX, e.clientY)
|
|
308
739
|
}
|
|
309
740
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
frame.current = null
|
|
313
|
-
const x = e.clientX
|
|
314
|
-
const y = e.clientY
|
|
315
|
-
setDrag((current) =>
|
|
316
|
-
current
|
|
317
|
-
? {...current, x: x - s.offsetX, y: y - s.offsetY, over: locate(s.id, x, y)}
|
|
318
|
-
: current,
|
|
319
|
-
)
|
|
320
|
-
})
|
|
741
|
+
this.pointer = {x: e.clientX, y: e.clientY}
|
|
742
|
+
this.scheduleFrame()
|
|
321
743
|
}
|
|
322
744
|
const onUp = (e: PointerEvent) => {
|
|
323
|
-
const s = session
|
|
745
|
+
const s = this.session
|
|
324
746
|
if (!s || e.pointerId !== s.pointerId) return
|
|
325
747
|
// Resolve the target from the release point, not the last frame.
|
|
326
|
-
|
|
748
|
+
this.pointer = {x: e.clientX, y: e.clientY}
|
|
749
|
+
this.finishPointer(s.active ? {x: e.clientX, y: e.clientY} : null)
|
|
327
750
|
}
|
|
328
751
|
const onCancel = (e: PointerEvent) => {
|
|
329
|
-
const s = session
|
|
752
|
+
const s = this.session
|
|
330
753
|
if (!s || e.pointerId !== s.pointerId) return
|
|
331
|
-
|
|
754
|
+
this.finishPointer(null)
|
|
332
755
|
}
|
|
333
756
|
const onKey = (e: KeyboardEvent) => {
|
|
334
|
-
if (e.key === 'Escape' && session
|
|
757
|
+
if (e.key === 'Escape' && this.session) this.finishPointer(null)
|
|
335
758
|
}
|
|
336
759
|
document.addEventListener('pointermove', onMove)
|
|
337
760
|
document.addEventListener('pointerup', onUp)
|
|
@@ -342,34 +765,340 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
|
|
|
342
765
|
document.removeEventListener('pointerup', onUp)
|
|
343
766
|
document.removeEventListener('pointercancel', onCancel)
|
|
344
767
|
document.removeEventListener('keydown', onKey)
|
|
768
|
+
document.removeEventListener('scroll', this.onScroll, {capture: true})
|
|
769
|
+
if (this.frame !== null) cancelAnimationFrame(this.frame)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// -- keyboard -------------------------------------------------------------
|
|
774
|
+
|
|
775
|
+
focusHandle(id: string) {
|
|
776
|
+
const zone = this.items.get(id)?.zone
|
|
777
|
+
if (zone !== undefined && this.stops.get(zone) !== id) {
|
|
778
|
+
this.stops.set(zone, id)
|
|
779
|
+
this.emit()
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private pick(id: string) {
|
|
784
|
+
const item = this.items.get(id)
|
|
785
|
+
if (!item || item.disabled || !item.el) return
|
|
786
|
+
const order = this.captureOrder(id)
|
|
787
|
+
const index = (order.get(item.zone) ?? []).filter(
|
|
788
|
+
(other) => domOrder(this.items.get(other)!.el!, item.el!) < 0,
|
|
789
|
+
).length
|
|
790
|
+
this.drag = {id, keyboard: true, over: {zone: item.zone, index}, refused: null, order}
|
|
791
|
+
this.emit()
|
|
792
|
+
const target = this.drag.over!
|
|
793
|
+
const payload = this.payload(id)
|
|
794
|
+
if (payload) this.props.onDragOver?.({item: payload, target, refused: null, keyboard: true})
|
|
795
|
+
this.say({kind: 'pick', item: item.label, ...this.placeAt(target), keyboard: true})
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private moveWithin(delta: number) {
|
|
799
|
+
const d = this.drag
|
|
800
|
+
if (!d?.over) return
|
|
801
|
+
const others = d.order.get(d.over.zone)?.length ?? 0
|
|
802
|
+
const index = Math.min(Math.max(d.over.index + delta, 0), others)
|
|
803
|
+
const target = {zone: d.over.zone, index}
|
|
804
|
+
this.retarget(target, null)
|
|
805
|
+
this.say({kind: 'move', item: this.labelOf(d.id), ...this.placeAt(target), keyboard: true})
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private moveAcross(delta: number) {
|
|
809
|
+
const d = this.drag
|
|
810
|
+
const payload = d && this.payload(d.id)
|
|
811
|
+
if (!d?.over || !payload) return
|
|
812
|
+
const zones = this.zoneSequence()
|
|
813
|
+
let refused: DragDropRefusal | null = null
|
|
814
|
+
for (let j = zones.indexOf(d.over.zone) + delta; j >= 0 && j < zones.length; j += delta) {
|
|
815
|
+
const zoneId = zones[j]
|
|
816
|
+
const verdict = verdictOf(this.zones.get(zoneId)?.accepts?.(payload))
|
|
817
|
+
if (!verdict.ok) {
|
|
818
|
+
if (verdict.reason !== undefined && !refused)
|
|
819
|
+
refused = {zone: zoneId, reason: verdict.reason}
|
|
820
|
+
continue
|
|
821
|
+
}
|
|
822
|
+
const others = d.order.get(zoneId)?.length ?? 0
|
|
823
|
+
const target = {zone: zoneId, index: Math.min(d.over.index, others)}
|
|
824
|
+
this.retarget(target, null)
|
|
825
|
+
this.say({kind: 'move', item: this.labelOf(d.id), ...this.placeAt(target), keyboard: true})
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
if (refused) {
|
|
829
|
+
// Nowhere to go that way; say why, and stay put.
|
|
830
|
+
this.drag = {...d, refused}
|
|
831
|
+
this.refusalText = refused.reason
|
|
832
|
+
this.emit()
|
|
833
|
+
this.say({
|
|
834
|
+
kind: 'refuse',
|
|
835
|
+
item: this.labelOf(d.id),
|
|
836
|
+
zone: this.zoneLabel(refused.zone),
|
|
837
|
+
reason: refused.reason,
|
|
838
|
+
keyboard: true,
|
|
839
|
+
})
|
|
840
|
+
} else {
|
|
841
|
+
this.say({kind: 'move', item: this.labelOf(d.id), ...this.placeAt(d.over), keyboard: true})
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private endKeyboard(commit: boolean) {
|
|
846
|
+
const d = this.drag
|
|
847
|
+
if (!d?.keyboard) return
|
|
848
|
+
const target = commit ? d.over : null
|
|
849
|
+
const announcement = target ? this.placeAt(target) : null
|
|
850
|
+
const payload = this.payload(d.id)
|
|
851
|
+
this.drag = null
|
|
852
|
+
this.refusalText = null
|
|
853
|
+
this.focusRequest = d.id
|
|
854
|
+
if (target) this.stops.set(target.zone, d.id)
|
|
855
|
+
this.emit()
|
|
856
|
+
if (target && payload && announcement) {
|
|
857
|
+
this.props.onDrop({item: payload, target})
|
|
858
|
+
this.say({kind: 'drop', item: this.labelOf(d.id), ...announcement, keyboard: true})
|
|
859
|
+
} else {
|
|
860
|
+
this.say({kind: 'cancel', item: this.labelOf(d.id), keyboard: true})
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Blur while picked (Tab away, a click elsewhere) cancels. */
|
|
865
|
+
blurHandle(id: string) {
|
|
866
|
+
if (this.drag?.keyboard && this.drag.id === id) this.endKeyboard(false)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private moveFocus(id: string, key: string): boolean {
|
|
870
|
+
const zone = this.items.get(id)?.zone
|
|
871
|
+
if (zone === undefined) return false
|
|
872
|
+
const inZone = this.handlesIn(zone)
|
|
873
|
+
const i = inZone.indexOf(id)
|
|
874
|
+
let next: string | undefined
|
|
875
|
+
if (key === 'ArrowUp') next = inZone[i - 1]
|
|
876
|
+
else if (key === 'ArrowDown') next = inZone[i + 1]
|
|
877
|
+
else if (key === 'Home') next = inZone[0]
|
|
878
|
+
else if (key === 'End') next = inZone[inZone.length - 1]
|
|
879
|
+
else if (key === 'ArrowLeft' || key === 'ArrowRight') {
|
|
880
|
+
const zones = this.zoneSequence().filter((z) => z === zone || this.handlesIn(z).length > 0)
|
|
881
|
+
const other = zones[zones.indexOf(zone) + (key === 'ArrowRight' ? 1 : -1)]
|
|
882
|
+
if (other !== undefined) {
|
|
883
|
+
const there = this.handlesIn(other)
|
|
884
|
+
next = there[Math.min(Math.max(i, 0), there.length - 1)]
|
|
885
|
+
}
|
|
886
|
+
} else return false
|
|
887
|
+
if (next) this.handles.get(next)?.focus()
|
|
888
|
+
return true
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
keyDown(id: string, e: KeyLike) {
|
|
892
|
+
const d = this.drag
|
|
893
|
+
if (d?.keyboard && d.id === id) {
|
|
894
|
+
switch (e.key) {
|
|
895
|
+
case 'ArrowUp':
|
|
896
|
+
this.moveWithin(-1)
|
|
897
|
+
break
|
|
898
|
+
case 'ArrowDown':
|
|
899
|
+
this.moveWithin(1)
|
|
900
|
+
break
|
|
901
|
+
case 'ArrowLeft':
|
|
902
|
+
this.moveAcross(-1)
|
|
903
|
+
break
|
|
904
|
+
case 'ArrowRight':
|
|
905
|
+
this.moveAcross(1)
|
|
906
|
+
break
|
|
907
|
+
case ' ':
|
|
908
|
+
case 'Enter':
|
|
909
|
+
this.endKeyboard(true)
|
|
910
|
+
break
|
|
911
|
+
case 'Escape':
|
|
912
|
+
this.endKeyboard(false)
|
|
913
|
+
break
|
|
914
|
+
case 'Tab':
|
|
915
|
+
this.endKeyboard(false)
|
|
916
|
+
return
|
|
917
|
+
default:
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
e.preventDefault()
|
|
921
|
+
e.stopPropagation()
|
|
922
|
+
return
|
|
923
|
+
}
|
|
924
|
+
if (d || this.session) return
|
|
925
|
+
if (e.key === ' ') {
|
|
926
|
+
e.preventDefault()
|
|
927
|
+
this.pick(id)
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
if (this.moveFocus(id, e.key)) e.preventDefault()
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
// Contexts and selectors
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
|
|
938
|
+
interface RootContextValue {
|
|
939
|
+
readonly engine: Engine
|
|
940
|
+
readonly instructionsId: string
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const RootContext = createContext<RootContextValue | null>(null)
|
|
944
|
+
|
|
945
|
+
const useRoot = (part: string): RootContextValue => {
|
|
946
|
+
const ctx = useContext(RootContext)
|
|
947
|
+
if (!ctx) throw new Error(`DragDrop.${part} must be used within DragDrop.Root`)
|
|
948
|
+
return ctx
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function useSelect<R>(engine: Engine | null | undefined, select: (e: Engine) => R, fallback: R): R {
|
|
952
|
+
const get = () => (engine ? select(engine) : fallback)
|
|
953
|
+
return useSyncExternalStore(engine ? engine.subscribe : noopSubscribe, get, get)
|
|
954
|
+
}
|
|
955
|
+
const noopSubscribe = () => () => {}
|
|
956
|
+
|
|
957
|
+
interface ZoneContextValue {
|
|
958
|
+
readonly id: string
|
|
959
|
+
readonly list: boolean
|
|
960
|
+
}
|
|
961
|
+
const ZoneContext = createContext<ZoneContextValue | null>(null)
|
|
962
|
+
|
|
963
|
+
interface ItemContextValue {
|
|
964
|
+
readonly id: string
|
|
965
|
+
readonly zone: string
|
|
966
|
+
readonly label: string
|
|
967
|
+
}
|
|
968
|
+
const ItemContext = createContext<ItemContextValue | null>(null)
|
|
969
|
+
|
|
970
|
+
/** True inside the ghost: its copy of the preview may hold Zones, Items and
|
|
971
|
+
* Handles, which must stay inert there — never registered, never focusable. */
|
|
972
|
+
const GhostContext = createContext(false)
|
|
973
|
+
|
|
974
|
+
// ---------------------------------------------------------------------------
|
|
975
|
+
// Root
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
|
|
978
|
+
export interface DragDropRootProps<T = unknown> {
|
|
979
|
+
/** Called when an item is released over a zone that accepts it (or dropped
|
|
980
|
+
* with Space/Enter). Reordering inside the item's own zone arrives here
|
|
981
|
+
* too, with the new index. */
|
|
982
|
+
onDrop: (event: DragDropEvent<T>) => void
|
|
983
|
+
/** Called whenever the target under the drag changes — a new zone or
|
|
984
|
+
* index, nothing, or a refusal. */
|
|
985
|
+
onDragOver?: (event: DragDropOverEvent<T>) => void
|
|
986
|
+
/** Supplies every live-region string (pick, move, drop, cancel, refuse),
|
|
987
|
+
* e.g. from the app's message catalog. Defaults to English. */
|
|
988
|
+
announce?: (event: DragDropAnnouncement) => string
|
|
989
|
+
/** What marks the insertion point in a zone while dragging; a Zone's own
|
|
990
|
+
* renderPlaceholder wins. Without one, only the zone lights up. */
|
|
991
|
+
renderPlaceholder?: DragDropRenderPlaceholder
|
|
992
|
+
/** Read by screen readers on a focused Handle. */
|
|
993
|
+
instructions?: string
|
|
994
|
+
children: ReactNode
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const DEFAULT_INSTRUCTIONS = 'Press Space to pick up. Arrow keys move, Space drops, Escape cancels.'
|
|
998
|
+
|
|
999
|
+
function Root<T = unknown>({
|
|
1000
|
+
onDrop,
|
|
1001
|
+
onDragOver,
|
|
1002
|
+
announce,
|
|
1003
|
+
renderPlaceholder,
|
|
1004
|
+
instructions = DEFAULT_INSTRUCTIONS,
|
|
1005
|
+
children,
|
|
1006
|
+
}: DragDropRootProps<T>) {
|
|
1007
|
+
const [engine] = useState(() => new Engine())
|
|
1008
|
+
const instructionsId = useId()
|
|
1009
|
+
|
|
1010
|
+
useLayoutEffect(() => {
|
|
1011
|
+
engine.props = {
|
|
1012
|
+
onDrop: onDrop as LatestProps['onDrop'],
|
|
1013
|
+
onDragOver: onDragOver as LatestProps['onDragOver'],
|
|
1014
|
+
announce,
|
|
1015
|
+
renderPlaceholder,
|
|
345
1016
|
}
|
|
346
|
-
}
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
useEffect(() => engine.attach(), [engine])
|
|
1020
|
+
|
|
1021
|
+
const value = useMemo<RootContextValue>(
|
|
1022
|
+
() => ({engine, instructionsId}),
|
|
1023
|
+
[engine, instructionsId],
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
return (
|
|
1027
|
+
<RootContext.Provider value={value}>
|
|
1028
|
+
{children}
|
|
1029
|
+
<Ghost engine={engine} />
|
|
1030
|
+
<LiveRegion engine={engine} />
|
|
1031
|
+
<Instructions engine={engine} id={instructionsId} text={instructions} />
|
|
1032
|
+
</RootContext.Provider>
|
|
1033
|
+
)
|
|
1034
|
+
}
|
|
347
1035
|
|
|
1036
|
+
function LiveRegion({engine}: {engine: Engine}) {
|
|
1037
|
+
const text = useSelect(engine, (e) => e.announcement, '')
|
|
1038
|
+
return (
|
|
1039
|
+
<html.div role="status" aria-live="polite" aria-atomic style={visuallyHidden.base}>
|
|
1040
|
+
{text}
|
|
1041
|
+
</html.div>
|
|
1042
|
+
)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** The handles' description; rendered only while a Handle exists, so a
|
|
1046
|
+
* pointer-only board carries no stray text. */
|
|
1047
|
+
function Instructions({engine, id, text}: {engine: Engine; id: string; text: string}) {
|
|
1048
|
+
const any = useSelect(engine, (e) => e.handles.size > 0, false)
|
|
1049
|
+
if (!any) return null
|
|
1050
|
+
return (
|
|
1051
|
+
<html.div id={id} style={visuallyHidden.base}>
|
|
1052
|
+
{text}
|
|
1053
|
+
</html.div>
|
|
1054
|
+
)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function Ghost({engine}: {engine: Engine}) {
|
|
1058
|
+
const ghost = useSelect(engine, (e) => e.ghost, null)
|
|
1059
|
+
const refusal = useSelect(engine, (e) => (e.ghost ? e.refusalText : null), null)
|
|
1060
|
+
const ref = useRef<HTMLElement | null>(null)
|
|
348
1061
|
// The ghost renders in the ThemeProvider mount: inline, a Dialog's
|
|
349
1062
|
// transformed, overflow-clipped panel would clip and offset it.
|
|
350
1063
|
const mount = usePortalMount()
|
|
351
|
-
|
|
1064
|
+
|
|
1065
|
+
useLayoutEffect(() => {
|
|
1066
|
+
engine.ghostEl = ref.current
|
|
1067
|
+
engine.paintGhost()
|
|
1068
|
+
return () => {
|
|
1069
|
+
engine.ghostEl = null
|
|
1070
|
+
}
|
|
1071
|
+
}, [engine, ghost])
|
|
1072
|
+
|
|
1073
|
+
if (!ghost) return null
|
|
1074
|
+
const node = (
|
|
352
1075
|
<html.div
|
|
1076
|
+
ref={ref as never}
|
|
353
1077
|
aria-hidden
|
|
354
|
-
style={[styles.ghost, styles.
|
|
1078
|
+
style={[styles.ghost, styles.ghostSize(ghost.width, ghost.height)]}
|
|
355
1079
|
>
|
|
356
|
-
{
|
|
1080
|
+
<html.div style={styles.ghostTilt}>
|
|
1081
|
+
<GhostContext.Provider value>{ghost.preview}</GhostContext.Provider>
|
|
1082
|
+
</html.div>
|
|
1083
|
+
{refusal !== null && <html.div style={styles.refusal}>{refusal}</html.div>}
|
|
357
1084
|
</html.div>
|
|
358
1085
|
)
|
|
1086
|
+
return mount ? createPortal(node, mount) : node
|
|
1087
|
+
}
|
|
359
1088
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
)
|
|
364
|
-
|
|
1089
|
+
function Placeholder({engine, zone, index}: {engine: Engine; zone: string; index: number}) {
|
|
1090
|
+
const record = engine.zones.get(zone)
|
|
1091
|
+
const render = record?.renderPlaceholder ?? engine.props.renderPlaceholder
|
|
1092
|
+
const ref = useRef<HTMLElement | null>(null)
|
|
1093
|
+
// A keyboard move keeps the insertion point in view.
|
|
1094
|
+
useEffect(() => {
|
|
1095
|
+
if (engine.drag?.keyboard) ref.current?.scrollIntoView?.({block: 'nearest', inline: 'nearest'})
|
|
1096
|
+
}, [engine, zone, index])
|
|
1097
|
+
if (!render) return null
|
|
365
1098
|
return (
|
|
366
|
-
<
|
|
367
|
-
{
|
|
368
|
-
|
|
369
|
-
<html.div role="status" aria-live="polite" aria-atomic style={visuallyHidden.base}>
|
|
370
|
-
{announcement}
|
|
371
|
-
</html.div>
|
|
372
|
-
</RootContext.Provider>
|
|
1099
|
+
<html.div ref={ref as never} aria-hidden style={styles.placeholder}>
|
|
1100
|
+
{render({zone, index, label: record?.label ?? zone})}
|
|
1101
|
+
</html.div>
|
|
373
1102
|
)
|
|
374
1103
|
}
|
|
375
1104
|
|
|
@@ -379,47 +1108,113 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
|
|
|
379
1108
|
|
|
380
1109
|
export interface DragDropZoneProps<T = unknown> {
|
|
381
1110
|
id: string
|
|
382
|
-
/** Read to screen readers when an item
|
|
1111
|
+
/** Read to screen readers when an item moves or drops here; the list's
|
|
1112
|
+
* accessible name with `list`. */
|
|
383
1113
|
label: string
|
|
384
1114
|
/** Which axis items flow along; decides how the insertion index is read
|
|
385
1115
|
* from the pointer. */
|
|
386
1116
|
orientation?: 'horizontal' | 'vertical'
|
|
387
|
-
/**
|
|
388
|
-
|
|
1117
|
+
/** Take the item (`true`), let it pass to an outer zone (`false`), or
|
|
1118
|
+
* refuse it with a reason that is shown and announced
|
|
1119
|
+
* (`{ok: false, reason}`). Omitted: every item is taken. */
|
|
1120
|
+
accepts?: (item: DragDropItemData<T>) => DragDropVerdict
|
|
1121
|
+
/** Keyboard order across zones (Left/Right); zones without one follow, in
|
|
1122
|
+
* DOM order. */
|
|
1123
|
+
order?: number
|
|
1124
|
+
/** Marks the insertion point in this zone; overrides the Root's. */
|
|
1125
|
+
renderPlaceholder?: DragDropRenderPlaceholder
|
|
1126
|
+
/** Render the zone as a labelled list (`role="list"`) laid out along
|
|
1127
|
+
* `orientation`, its Items as list items. Put the Items directly in it. */
|
|
1128
|
+
list?: boolean
|
|
1129
|
+
/** Gap between the Items of a `list` zone. */
|
|
1130
|
+
gap?: SpacingToken
|
|
1131
|
+
/** Stretch to fill the parent (a Timeline bar, a card) instead of sizing
|
|
1132
|
+
* to the content. */
|
|
1133
|
+
fill?: boolean
|
|
389
1134
|
children: ReactNode
|
|
390
1135
|
}
|
|
391
1136
|
|
|
1137
|
+
const gapMap = {
|
|
1138
|
+
xs: styles.gapXs,
|
|
1139
|
+
sm: styles.gapSm,
|
|
1140
|
+
ms: styles.gapMs,
|
|
1141
|
+
md: styles.gapMd,
|
|
1142
|
+
lg: styles.gapLg,
|
|
1143
|
+
xl: styles.gapXl,
|
|
1144
|
+
xxl: styles.gapXxl,
|
|
1145
|
+
xxxl: styles.gapXxxl,
|
|
1146
|
+
} as const satisfies Record<SpacingToken, unknown>
|
|
1147
|
+
|
|
392
1148
|
function Zone<T = unknown>({
|
|
393
1149
|
id,
|
|
394
1150
|
label,
|
|
395
1151
|
orientation = 'horizontal',
|
|
396
1152
|
accepts,
|
|
1153
|
+
order,
|
|
1154
|
+
renderPlaceholder,
|
|
1155
|
+
list = false,
|
|
1156
|
+
gap = 'sm',
|
|
1157
|
+
fill = false,
|
|
397
1158
|
children,
|
|
398
1159
|
}: DragDropZoneProps<T>) {
|
|
399
|
-
const {
|
|
1160
|
+
const {engine} = useRoot('Zone')
|
|
1161
|
+
const inGhost = useContext(GhostContext)
|
|
400
1162
|
const ref = useRef<HTMLDivElement>(null)
|
|
1163
|
+
const [record] = useState<ZoneRecord>(() => ({el: null, orientation, label}))
|
|
401
1164
|
|
|
1165
|
+
// The record is read at drag time, so the latest props land on it after
|
|
1166
|
+
// every render without re-registering.
|
|
402
1167
|
useEffect(() => {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
1168
|
+
record.orientation = orientation
|
|
1169
|
+
record.label = label
|
|
1170
|
+
record.accepts = accepts as ZoneRecord['accepts']
|
|
1171
|
+
record.order = order
|
|
1172
|
+
record.renderPlaceholder = renderPlaceholder
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
useEffect(() => {
|
|
1176
|
+
if (isNative || inGhost) return
|
|
1177
|
+
record.el = ref.current
|
|
1178
|
+
return engine.registerZone(id, record)
|
|
1179
|
+
}, [engine, id, record, inGhost])
|
|
412
1180
|
|
|
413
|
-
const
|
|
414
|
-
const
|
|
1181
|
+
const dragging = useSelect(engine, (e) => e.drag !== null, false)
|
|
1182
|
+
const over = useSelect(engine, (e) => e.drag?.over?.zone === id, false)
|
|
1183
|
+
const refused = useSelect(engine, (e) => e.drag?.refused?.zone === id, false)
|
|
1184
|
+
const placeholderAtEnd = useSelect(
|
|
1185
|
+
engine,
|
|
1186
|
+
(e) => {
|
|
1187
|
+
const d = e.drag
|
|
1188
|
+
if (!d?.over || d.over.zone !== id) return -1
|
|
1189
|
+
const n = d.order.get(id)?.length ?? 0
|
|
1190
|
+
return d.over.index >= n ? d.over.index : -1
|
|
1191
|
+
},
|
|
1192
|
+
-1,
|
|
1193
|
+
)
|
|
1194
|
+
const zoneContext = useMemo(() => ({id, list}), [id, list])
|
|
415
1195
|
|
|
416
1196
|
return (
|
|
417
|
-
<
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
1197
|
+
<ZoneContext.Provider value={zoneContext}>
|
|
1198
|
+
<html.div
|
|
1199
|
+
ref={ref}
|
|
1200
|
+
role={list ? 'list' : undefined}
|
|
1201
|
+
aria-label={list ? label : undefined}
|
|
1202
|
+
style={[
|
|
1203
|
+
styles.zone,
|
|
1204
|
+
list && (orientation === 'vertical' ? styles.listVertical : styles.listHorizontal),
|
|
1205
|
+
list && gapMap[gap],
|
|
1206
|
+
fill && styles.fill,
|
|
1207
|
+
dragging && styles.zoneReady,
|
|
1208
|
+
over && styles.zoneOver,
|
|
1209
|
+
refused && styles.zoneRefused,
|
|
1210
|
+
]}
|
|
1211
|
+
>
|
|
1212
|
+
{children}
|
|
1213
|
+
{placeholderAtEnd >= 0 && (
|
|
1214
|
+
<Placeholder engine={engine} zone={id} index={placeholderAtEnd} />
|
|
1215
|
+
)}
|
|
1216
|
+
</html.div>
|
|
1217
|
+
</ZoneContext.Provider>
|
|
423
1218
|
)
|
|
424
1219
|
}
|
|
425
1220
|
|
|
@@ -431,7 +1226,8 @@ export interface DragDropItemProps<T = unknown> {
|
|
|
431
1226
|
id: string
|
|
432
1227
|
/** The zone this item currently lives in. */
|
|
433
1228
|
zone: string
|
|
434
|
-
/** Read to screen readers when picked up or dropped
|
|
1229
|
+
/** Read to screen readers when picked up or dropped; a Handle's default
|
|
1230
|
+
* accessible name. */
|
|
435
1231
|
label: string
|
|
436
1232
|
/** Carried to onDrop untouched. */
|
|
437
1233
|
data?: T
|
|
@@ -450,14 +1246,34 @@ function Item<T = unknown>({
|
|
|
450
1246
|
disabled = false,
|
|
451
1247
|
children,
|
|
452
1248
|
}: DragDropItemProps<T>) {
|
|
453
|
-
const {
|
|
1249
|
+
const {engine} = useRoot('Item')
|
|
1250
|
+
const zoneCtx = useContext(ZoneContext)
|
|
1251
|
+
const inGhost = useContext(GhostContext)
|
|
454
1252
|
const ref = useRef<HTMLDivElement>(null)
|
|
1253
|
+
const [record] = useState<ItemRecord>(() => ({
|
|
1254
|
+
el: null,
|
|
1255
|
+
zone,
|
|
1256
|
+
label,
|
|
1257
|
+
data,
|
|
1258
|
+
preview: null,
|
|
1259
|
+
disabled,
|
|
1260
|
+
}))
|
|
455
1261
|
|
|
1262
|
+
// Registration depends on the id only; what changes from render to render
|
|
1263
|
+
// (data, the preview, the children) is copied onto the record instead.
|
|
456
1264
|
useEffect(() => {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
1265
|
+
record.zone = zone
|
|
1266
|
+
record.label = label
|
|
1267
|
+
record.data = data
|
|
1268
|
+
record.preview = preview ?? children
|
|
1269
|
+
record.disabled = disabled
|
|
1270
|
+
})
|
|
1271
|
+
|
|
1272
|
+
useEffect(() => {
|
|
1273
|
+
if (isNative || inGhost) return
|
|
1274
|
+
record.el = ref.current
|
|
1275
|
+
return engine.registerItem(id, record)
|
|
1276
|
+
}, [engine, id, record, inGhost])
|
|
461
1277
|
|
|
462
1278
|
// WebKit only honours the prefixed user-select, and a touch held on
|
|
463
1279
|
// selectable text becomes iOS's selection gesture (loupe and handles)
|
|
@@ -476,19 +1292,98 @@ function Item<T = unknown>({
|
|
|
476
1292
|
const onPointerDown = useCallback(
|
|
477
1293
|
(e: React.PointerEvent) => {
|
|
478
1294
|
if (disabled) return
|
|
479
|
-
begin(id, e)
|
|
1295
|
+
engine.begin(id, e)
|
|
480
1296
|
},
|
|
481
|
-
[
|
|
1297
|
+
[engine, disabled, id],
|
|
482
1298
|
)
|
|
483
1299
|
|
|
1300
|
+
const lifted = useSelect(engine, (e) => e.drag?.id === id, false)
|
|
1301
|
+
const placeholderBefore = useSelect(
|
|
1302
|
+
engine,
|
|
1303
|
+
(e) => {
|
|
1304
|
+
const d = e.drag
|
|
1305
|
+
if (!d?.over || d.over.zone !== zone) return -1
|
|
1306
|
+
return d.order.get(zone)?.[d.over.index] === id ? d.over.index : -1
|
|
1307
|
+
},
|
|
1308
|
+
-1,
|
|
1309
|
+
)
|
|
1310
|
+
const itemContext = useMemo(() => ({id, zone, label}), [id, zone, label])
|
|
1311
|
+
|
|
484
1312
|
if (isNative) return <>{children}</>
|
|
485
1313
|
|
|
486
|
-
|
|
1314
|
+
return (
|
|
1315
|
+
<ItemContext.Provider value={itemContext}>
|
|
1316
|
+
{placeholderBefore >= 0 && (
|
|
1317
|
+
<Placeholder engine={engine} zone={zone} index={placeholderBefore} />
|
|
1318
|
+
)}
|
|
1319
|
+
<html.div
|
|
1320
|
+
ref={ref}
|
|
1321
|
+
role={zoneCtx?.list && zoneCtx.id === zone ? 'listitem' : undefined}
|
|
1322
|
+
onPointerDown={onPointerDown}
|
|
1323
|
+
style={[styles.item, disabled && styles.itemDisabled, lifted && styles.itemLifted]}
|
|
1324
|
+
>
|
|
1325
|
+
{children}
|
|
1326
|
+
</html.div>
|
|
1327
|
+
</ItemContext.Provider>
|
|
1328
|
+
)
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// ---------------------------------------------------------------------------
|
|
1332
|
+
// Handle
|
|
1333
|
+
// ---------------------------------------------------------------------------
|
|
1334
|
+
|
|
1335
|
+
export interface DragDropHandleProps {
|
|
1336
|
+
/** Defaults to the Item's label. */
|
|
1337
|
+
'aria-label'?: string
|
|
1338
|
+
/** What the handle shows: a grip, or the item's whole face. Keep other
|
|
1339
|
+
* controls out of it — a handle is one widget. */
|
|
1340
|
+
children: ReactNode
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* The keyboard path into an Item: one focusable widget (`role="button"`) per
|
|
1345
|
+
* item, a roving tab stop per zone. Space picks the item up, the arrow keys
|
|
1346
|
+
* move it, Space or Enter drops it, Escape cancels.
|
|
1347
|
+
*/
|
|
1348
|
+
function Handle({'aria-label': ariaLabel, children}: DragDropHandleProps) {
|
|
1349
|
+
const {engine, instructionsId} = useRoot('Handle')
|
|
1350
|
+
const item = useContext(ItemContext)
|
|
1351
|
+
const inGhost = useContext(GhostContext)
|
|
1352
|
+
if (!item && !inGhost) throw new Error('DragDrop.Handle must be used within DragDrop.Item')
|
|
1353
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
1354
|
+
const id = item?.id ?? ''
|
|
1355
|
+
const zone = item?.zone ?? ''
|
|
1356
|
+
const label = item?.label ?? ''
|
|
1357
|
+
|
|
1358
|
+
useEffect(() => {
|
|
1359
|
+
const el = ref.current
|
|
1360
|
+
if (!el || isNative || inGhost) return
|
|
1361
|
+
return engine.registerHandle(id, el)
|
|
1362
|
+
}, [engine, id, zone, inGhost])
|
|
1363
|
+
|
|
1364
|
+
const tabStop = useSelect(engine, (e) => e.tabStop(zone) === id, false)
|
|
1365
|
+
const picked = useSelect(engine, (e) => e.drag?.keyboard === true && e.drag.id === id, false)
|
|
1366
|
+
const wantsFocus = useSelect(engine, (e) => e.focusRequest === id, false)
|
|
1367
|
+
|
|
1368
|
+
useEffect(() => {
|
|
1369
|
+
if (!wantsFocus) return
|
|
1370
|
+
engine.focusRequest = null
|
|
1371
|
+
ref.current?.focus()
|
|
1372
|
+
}, [engine, wantsFocus])
|
|
1373
|
+
|
|
1374
|
+
if (isNative || inGhost) return <>{children}</>
|
|
1375
|
+
|
|
487
1376
|
return (
|
|
488
1377
|
<html.div
|
|
489
1378
|
ref={ref}
|
|
490
|
-
|
|
491
|
-
|
|
1379
|
+
role="button"
|
|
1380
|
+
tabIndex={tabStop || picked ? 0 : -1}
|
|
1381
|
+
aria-label={ariaLabel ?? label}
|
|
1382
|
+
aria-describedby={instructionsId}
|
|
1383
|
+
onKeyDown={(e: unknown) => engine.keyDown(id, e as KeyLike)}
|
|
1384
|
+
onFocus={() => engine.focusHandle(id)}
|
|
1385
|
+
onBlur={() => engine.blurHandle(id)}
|
|
1386
|
+
style={[styles.handle, picked && styles.handlePicked]}
|
|
492
1387
|
>
|
|
493
1388
|
{children}
|
|
494
1389
|
</html.div>
|
|
@@ -499,9 +1394,15 @@ function Item<T = unknown>({
|
|
|
499
1394
|
export function useDragDrop(): {
|
|
500
1395
|
readonly dragging: string | null
|
|
501
1396
|
readonly over: DragDropTarget | null
|
|
1397
|
+
readonly refused: DragDropRefusal | null
|
|
1398
|
+
readonly keyboard: boolean
|
|
502
1399
|
} {
|
|
503
|
-
const
|
|
504
|
-
|
|
1400
|
+
const engine = useContext(RootContext)?.engine
|
|
1401
|
+
const dragging = useSelect(engine, (e) => e.drag?.id ?? null, null)
|
|
1402
|
+
const over = useSelect(engine, (e) => e.drag?.over ?? null, null)
|
|
1403
|
+
const refused = useSelect(engine, (e) => e.drag?.refused ?? null, null)
|
|
1404
|
+
const keyboard = useSelect(engine, (e) => e.drag?.keyboard ?? false, false)
|
|
1405
|
+
return {dragging, over, refused, keyboard}
|
|
505
1406
|
}
|
|
506
1407
|
|
|
507
|
-
export const DragDrop = {Root, Zone, Item}
|
|
1408
|
+
export const DragDrop = {Root, Zone, Item, Handle}
|