panelui-web 0.0.0-stage → 0.1.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/README.md +43 -2
- package/dist/components/bar-picker/index.d.ts +61 -0
- package/dist/components/bar-picker/index.js +139 -0
- package/dist/components/command-dock/index.d.ts +118 -0
- package/dist/components/command-dock/index.js +239 -0
- package/dist/components/day-grid/index.d.ts +87 -0
- package/dist/components/day-grid/index.js +251 -0
- package/dist/components/feedback/index.d.ts +135 -0
- package/dist/components/feedback/index.js +283 -0
- package/dist/components/flow/flow-paths.d.ts +94 -0
- package/dist/components/flow/flow-paths.js +270 -0
- package/dist/components/flow/index.d.ts +235 -0
- package/dist/components/flow/index.js +597 -0
- package/dist/components/kpi/index.d.ts +211 -0
- package/dist/components/kpi/index.js +243 -0
- package/dist/components/lane-board/index.d.ts +125 -0
- package/dist/components/lane-board/index.js +691 -0
- package/dist/components/planet/index.d.ts +132 -0
- package/dist/components/planet/index.js +653 -0
- package/dist/components/planet/land.d.ts +8 -0
- package/dist/components/planet/land.js +122 -0
- package/dist/components/range-meter/index.d.ts +70 -0
- package/dist/components/range-meter/index.js +170 -0
- package/dist/components/rolling-number/index.d.ts +46 -0
- package/dist/components/rolling-number/index.js +81 -0
- package/dist/components/tilt-card/index.d.ts +60 -0
- package/dist/components/tilt-card/index.js +114 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/lib/cn.d.ts +3 -0
- package/dist/lib/cn.js +6 -0
- package/package.json +72 -4
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* LaneBoard — lanes side by side, each holding cards that can be dragged
|
|
5
|
+
* within a lane and from one lane to another.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <LaneBoard
|
|
9
|
+
* lanes={lanes}
|
|
10
|
+
* onLanesChange={setLanes}
|
|
11
|
+
* renderCard={(card) => (
|
|
12
|
+
* <>
|
|
13
|
+
* <LaneBoard.Title>{card.title}</LaneBoard.Title>
|
|
14
|
+
* <LaneBoard.Code>{card.code}</LaneBoard.Code>
|
|
15
|
+
* </>
|
|
16
|
+
* )}
|
|
17
|
+
* />
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ## The data is yours
|
|
21
|
+
*
|
|
22
|
+
* The board is given the lanes and their cards and says what they should
|
|
23
|
+
* become. It keeps nothing you did not give it, so the order on screen is the
|
|
24
|
+
* order in your state, and a move can be refused by not applying it.
|
|
25
|
+
*
|
|
26
|
+
* ## A card is carried, and the rest make room
|
|
27
|
+
*
|
|
28
|
+
* A dragged card is lifted off the board and follows the pointer. Where it
|
|
29
|
+
* came from closes up, and a gap opens where it would land if it were let go
|
|
30
|
+
* now, with the cards round it sliding to their new places. Nothing is
|
|
31
|
+
* decided until the pointer is released, and Escape puts everything back.
|
|
32
|
+
*
|
|
33
|
+
* ## The board has a height
|
|
34
|
+
*
|
|
35
|
+
* The board is a fixed height and every lane fills it. A lane with more cards
|
|
36
|
+
* than fit scrolls on its own, so a card added to a long lane moves nothing
|
|
37
|
+
* else on the page. Pass `height="auto"` for a board that grows with its
|
|
38
|
+
* longest lane.
|
|
39
|
+
*
|
|
40
|
+
* ## It works without a pointer
|
|
41
|
+
*
|
|
42
|
+
* Space or Enter on a card picks it up. The arrow keys then move it, up and
|
|
43
|
+
* down within its lane and left and right between lanes. Space or Enter sets
|
|
44
|
+
* it down, Escape puts it back, and each step is said to a screen reader.
|
|
45
|
+
*/
|
|
46
|
+
import { ScrollArea } from '@base-ui/react/scroll-area';
|
|
47
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react';
|
|
48
|
+
import { tv } from 'tailwind-variants';
|
|
49
|
+
const laneBoardVariants = tv({
|
|
50
|
+
slots: {
|
|
51
|
+
root: 'relative w-full',
|
|
52
|
+
board: 'size-full',
|
|
53
|
+
// Lanes share the width when there is room and scroll sideways, one lane
|
|
54
|
+
// at a time, when there is not. Room is made under them for the bar only
|
|
55
|
+
// when there is something to scroll.
|
|
56
|
+
boardViewport: 'size-full snap-x outline-none data-has-overflow-x:pb-3',
|
|
57
|
+
lanes: 'flex h-full gap-3',
|
|
58
|
+
/*
|
|
59
|
+
* The border is the lane's own, inside its box, and not a ring round it:
|
|
60
|
+
* the board clips what is drawn outside a lane, and a ring would lose its
|
|
61
|
+
* top edge to that.
|
|
62
|
+
*/
|
|
63
|
+
lane: 'flex min-h-0 min-w-50 flex-1 shrink-0 snap-start flex-col rounded-3xl border bg-muted py-2 transition-colors duration-200',
|
|
64
|
+
laneHeader: 'flex items-center justify-between gap-3 px-4.5 pb-1.5 pt-1.5 text-sm',
|
|
65
|
+
laneScroller: 'min-h-0 flex-1',
|
|
66
|
+
/*
|
|
67
|
+
* Padded on every side, so a card's focus ring and shadow have room and
|
|
68
|
+
* are not cut by the edge they scroll under. Where cards carry on past an
|
|
69
|
+
* edge, they fade out towards it.
|
|
70
|
+
*/
|
|
71
|
+
laneViewport: 'h-full px-2 py-0.5 outline-none data-has-overflow-y:overscroll-y-contain data-has-overflow-y:[mask-image:linear-gradient(to_bottom,transparent,black_min(1.25rem,var(--scroll-area-overflow-y-start)),black_calc(100%-min(1.25rem,var(--scroll-area-overflow-y-end))),transparent)]',
|
|
72
|
+
laneFooter: 'flex flex-col px-2 pt-1.5 empty:hidden',
|
|
73
|
+
// A drawn bar, thin and rounded, that shows while the pointer is on the
|
|
74
|
+
// lane or it is being scrolled.
|
|
75
|
+
scrollbar: 'flex opacity-0 transition-opacity delay-300 data-hovering:opacity-100 data-hovering:delay-0 data-hovering:duration-100 data-scrolling:opacity-100 data-scrolling:delay-0 data-scrolling:duration-100 data-[orientation=horizontal]:mx-3 data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-px data-[orientation=vertical]:w-1.5',
|
|
76
|
+
thumb: 'relative flex-1 rounded-full bg-foreground/20',
|
|
77
|
+
laneHeading: 'flex min-w-0 items-center gap-2',
|
|
78
|
+
laneDot: 'size-2 shrink-0 rounded-full bg-[var(--pu-color)]',
|
|
79
|
+
laneTitle: 'truncate font-medium text-muted-foreground',
|
|
80
|
+
laneCount: 'tabular-nums text-muted-foreground',
|
|
81
|
+
// Tall enough when empty to be somewhere a card can be dropped.
|
|
82
|
+
list: 'flex min-h-20 flex-col gap-2',
|
|
83
|
+
card: 'flex cursor-grab touch-manipulation select-none flex-col gap-1 rounded-2xl border bg-card p-4 text-card-foreground shadow-xs outline-none transition-shadow duration-150 focus-visible:ring-2 focus-visible:ring-ring',
|
|
84
|
+
overlay: 'pointer-events-none fixed left-0 top-0 z-50 flex rotate-2 cursor-grabbing flex-col gap-1 rounded-2xl border bg-card p-4 text-card-foreground shadow-xl',
|
|
85
|
+
title: 'truncate text-base font-semibold',
|
|
86
|
+
code: 'truncate font-mono text-sm text-muted-foreground',
|
|
87
|
+
row: 'mt-2 flex items-center justify-between gap-3',
|
|
88
|
+
steps: 'flex items-center gap-1',
|
|
89
|
+
step: 'h-1 w-4 rounded-full',
|
|
90
|
+
tag: 'inline-flex items-center rounded-full border border-[color-mix(in_oklab,var(--pu-color)_35%,var(--card))] bg-[color-mix(in_oklab,var(--pu-color)_14%,transparent)] px-2 py-0.5 text-xs font-medium text-[var(--pu-color)]',
|
|
91
|
+
meta: 'font-mono text-sm tabular-nums text-foreground',
|
|
92
|
+
},
|
|
93
|
+
variants: {
|
|
94
|
+
color: {
|
|
95
|
+
primary: {
|
|
96
|
+
steps: '[--pu-color:var(--primary)]',
|
|
97
|
+
tag: '[--pu-color:var(--primary)]',
|
|
98
|
+
laneDot: '[--pu-color:var(--primary)]',
|
|
99
|
+
},
|
|
100
|
+
info: {
|
|
101
|
+
steps: '[--pu-color:var(--info)]',
|
|
102
|
+
tag: '[--pu-color:var(--info)]',
|
|
103
|
+
laneDot: '[--pu-color:var(--info)]',
|
|
104
|
+
},
|
|
105
|
+
success: {
|
|
106
|
+
steps: '[--pu-color:var(--success)]',
|
|
107
|
+
tag: '[--pu-color:var(--success)]',
|
|
108
|
+
laneDot: '[--pu-color:var(--success)]',
|
|
109
|
+
},
|
|
110
|
+
warning: {
|
|
111
|
+
steps: '[--pu-color:var(--warning)]',
|
|
112
|
+
tag: '[--pu-color:var(--warning)]',
|
|
113
|
+
laneDot: '[--pu-color:var(--warning)]',
|
|
114
|
+
},
|
|
115
|
+
destructive: {
|
|
116
|
+
steps: '[--pu-color:var(--destructive)]',
|
|
117
|
+
tag: '[--pu-color:var(--destructive)]',
|
|
118
|
+
laneDot: '[--pu-color:var(--destructive)]',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
// The lane a carried card is over: a firmer edge and a deeper tray.
|
|
122
|
+
over: { true: { lane: 'border-ring/70 bg-foreground/8' } },
|
|
123
|
+
// A lane holding more than its limit says so in its count.
|
|
124
|
+
full: { true: { laneCount: 'font-medium text-[var(--warning)]' } },
|
|
125
|
+
state: {
|
|
126
|
+
rest: {},
|
|
127
|
+
// Where a carried card would land: its room is kept and outlined, and
|
|
128
|
+
// what is on it is not drawn.
|
|
129
|
+
placeholder: { card: 'border-dashed border-ring/70 bg-transparent shadow-none *:invisible' },
|
|
130
|
+
// Picked up with the keyboard: it stays where it is and is ringed.
|
|
131
|
+
held: { card: 'cursor-grabbing shadow-lg ring-2 ring-ring' },
|
|
132
|
+
},
|
|
133
|
+
lit: {
|
|
134
|
+
true: { step: 'bg-[var(--pu-color)]' },
|
|
135
|
+
false: { step: 'bg-muted-foreground/20' },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
defaultVariants: { color: 'primary', state: 'rest' },
|
|
139
|
+
});
|
|
140
|
+
/** How far a mouse has to move before a press becomes a drag, in pixels. */
|
|
141
|
+
const SLOP = 4;
|
|
142
|
+
/** How long a finger has to rest on a card before it is picked up, in milliseconds. */
|
|
143
|
+
const HOLD = 220;
|
|
144
|
+
/** How near an edge of the board the pointer has to be for it to scroll, in pixels. */
|
|
145
|
+
const EDGE = 48;
|
|
146
|
+
/** Where something is laid out, measured from the board through whatever it is nested in. */
|
|
147
|
+
function placeIn(node, board) {
|
|
148
|
+
let x = 0;
|
|
149
|
+
let y = 0;
|
|
150
|
+
let at = node;
|
|
151
|
+
while (at && at !== board) {
|
|
152
|
+
x += at.offsetLeft;
|
|
153
|
+
y += at.offsetTop;
|
|
154
|
+
at = at.offsetParent;
|
|
155
|
+
}
|
|
156
|
+
return { x, y };
|
|
157
|
+
}
|
|
158
|
+
const reducedMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
159
|
+
/** The lanes with one card taken out and put somewhere else. */
|
|
160
|
+
function moved(lanes, id, toLane, toIndex) {
|
|
161
|
+
let card;
|
|
162
|
+
const without = lanes.map((lane) => {
|
|
163
|
+
const found = lane.cards.find((entry) => entry.id === id);
|
|
164
|
+
if (!found)
|
|
165
|
+
return lane;
|
|
166
|
+
card = found;
|
|
167
|
+
return { ...lane, cards: lane.cards.filter((entry) => entry.id !== id) };
|
|
168
|
+
});
|
|
169
|
+
if (!card)
|
|
170
|
+
return lanes;
|
|
171
|
+
const carried = card;
|
|
172
|
+
return without.map((lane) => {
|
|
173
|
+
if (lane.id !== toLane)
|
|
174
|
+
return lane;
|
|
175
|
+
const cards = [...lane.cards];
|
|
176
|
+
cards.splice(Math.min(Math.max(0, toIndex), cards.length), 0, carried);
|
|
177
|
+
return { ...lane, cards };
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
function whereIs(lanes, id) {
|
|
181
|
+
for (const lane of lanes) {
|
|
182
|
+
const index = lane.cards.findIndex((card) => card.id === id);
|
|
183
|
+
if (index >= 0)
|
|
184
|
+
return { lane: lane.id, index, card: lane.cards[index] };
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
function LaneBoardRoot({ lanes: controlled, defaultLanes, onLanesChange, onMove, renderCard, renderLaneFooter, cardName = (card) => card.id, label = 'Board', height = '28rem', className, style, ...props }) {
|
|
189
|
+
const slots = laneBoardVariants();
|
|
190
|
+
const [internal, setInternal] = useState(defaultLanes ?? []);
|
|
191
|
+
const settled = controlled ?? internal;
|
|
192
|
+
/** The lanes as they would be if the carried card were set down now. */
|
|
193
|
+
const [draft, setDraft] = useState(null);
|
|
194
|
+
/** The card being carried, and whether by the pointer or the keyboard. */
|
|
195
|
+
const [carry, setCarry] = useState(null);
|
|
196
|
+
const [said, setSaid] = useState('');
|
|
197
|
+
const shown = draft ?? settled;
|
|
198
|
+
const root = useRef(null);
|
|
199
|
+
const overlay = useRef(null);
|
|
200
|
+
const cards = useRef(new Map());
|
|
201
|
+
/** The board's own scroller, which moves sideways. */
|
|
202
|
+
const scroller = useRef(null);
|
|
203
|
+
const sections = useRef(new Map());
|
|
204
|
+
/** Each lane's scroller, which moves up and down. */
|
|
205
|
+
const viewports = useRef(new Map());
|
|
206
|
+
const places = useRef(new Map());
|
|
207
|
+
/** Everything about a pointer drag that changes too often to be state. */
|
|
208
|
+
const drag = useRef(null);
|
|
209
|
+
const latest = useRef({ settled, draft });
|
|
210
|
+
latest.current = { settled, draft };
|
|
211
|
+
const commit = useCallback((next, id) => {
|
|
212
|
+
const from = whereIs(latest.current.settled, id);
|
|
213
|
+
const to = whereIs(next, id);
|
|
214
|
+
if (!from || !to)
|
|
215
|
+
return;
|
|
216
|
+
if (from.lane === to.lane && from.index === to.index)
|
|
217
|
+
return;
|
|
218
|
+
if (controlled === undefined)
|
|
219
|
+
setInternal(next);
|
|
220
|
+
onLanesChange?.(next);
|
|
221
|
+
onMove?.({
|
|
222
|
+
card: from.card,
|
|
223
|
+
from: { lane: from.lane, index: from.index },
|
|
224
|
+
to: { lane: to.lane, index: to.index },
|
|
225
|
+
});
|
|
226
|
+
}, [controlled, onLanesChange, onMove]);
|
|
227
|
+
/*
|
|
228
|
+
* Cards slide to where they now belong. Each card's place is remembered from
|
|
229
|
+
* one drawing to the next; a card found somewhere else in its lane is
|
|
230
|
+
* started from where it was and let go. Places are where a card is laid
|
|
231
|
+
* out, so scrolling the page, the board or a lane between two drawings
|
|
232
|
+
* moves nothing. A card that has changed lanes is not slid: each lane clips
|
|
233
|
+
* its own cards, and it would be seen coming in through the lane's side.
|
|
234
|
+
*/
|
|
235
|
+
useLayoutEffect(() => {
|
|
236
|
+
const board = root.current;
|
|
237
|
+
if (!board)
|
|
238
|
+
return;
|
|
239
|
+
const still = reducedMotion();
|
|
240
|
+
const laneOf = new Map();
|
|
241
|
+
for (const lane of shown)
|
|
242
|
+
for (const card of lane.cards)
|
|
243
|
+
laneOf.set(card.id, lane.id);
|
|
244
|
+
const next = new Map();
|
|
245
|
+
cards.current.forEach((node, id) => {
|
|
246
|
+
// Where the card is laid out, measured from the board. Not its box on
|
|
247
|
+
// the page: a card that is still sliding has a box that is on its way,
|
|
248
|
+
// and what is wanted here is where it is going.
|
|
249
|
+
const place = {
|
|
250
|
+
...placeIn(node, board),
|
|
251
|
+
height: node.offsetHeight,
|
|
252
|
+
lane: laneOf.get(id) ?? '',
|
|
253
|
+
};
|
|
254
|
+
next.set(id, place);
|
|
255
|
+
const before = places.current.get(id);
|
|
256
|
+
if (!before || still || before.lane !== place.lane)
|
|
257
|
+
return;
|
|
258
|
+
const dx = before.x - place.x;
|
|
259
|
+
const dy = before.y - place.y;
|
|
260
|
+
if (Math.abs(dx) < 1 && Math.abs(dy) < 1)
|
|
261
|
+
return;
|
|
262
|
+
node.animate([{ translate: `${dx}px ${dy}px` }, { translate: '0 0' }], {
|
|
263
|
+
duration: 220,
|
|
264
|
+
easing: 'cubic-bezier(0.2, 0, 0, 1)',
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
places.current = next;
|
|
268
|
+
});
|
|
269
|
+
/*
|
|
270
|
+
* Only for a board left to grow, with `height="auto"`. It is then as tall
|
|
271
|
+
* as its tallest lane, and which lane that is changes as cards move. Left
|
|
272
|
+
* to follow, the board would grow and shrink under the pointer in the
|
|
273
|
+
* middle of a drag, and again when the card is set down. So it keeps the
|
|
274
|
+
* greatest height it has had, and only ever grows.
|
|
275
|
+
*/
|
|
276
|
+
const tallest = useRef(0);
|
|
277
|
+
useLayoutEffect(() => {
|
|
278
|
+
const lanes = [...sections.current.values()];
|
|
279
|
+
for (const lane of lanes)
|
|
280
|
+
lane.style.minHeight = '';
|
|
281
|
+
if (height !== 'auto') {
|
|
282
|
+
tallest.current = 0;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const natural = Math.max(0, ...lanes.map((lane) => lane.offsetHeight));
|
|
286
|
+
// Only what has been set down counts. A card carried across a full lane
|
|
287
|
+
// makes it taller for a moment, and that is not a height to keep.
|
|
288
|
+
if (!carry)
|
|
289
|
+
tallest.current = Math.max(tallest.current, natural);
|
|
290
|
+
const keep = Math.max(tallest.current, natural);
|
|
291
|
+
for (const lane of lanes)
|
|
292
|
+
lane.style.minHeight = `${keep}px`;
|
|
293
|
+
});
|
|
294
|
+
/*
|
|
295
|
+
* Brings a card's place into view: its lane across the board, and the card
|
|
296
|
+
* down its lane. Worked out from where things are laid out, since a card
|
|
297
|
+
* that has just moved is still drawn on its way there.
|
|
298
|
+
*/
|
|
299
|
+
const reveal = (id) => {
|
|
300
|
+
const board = root.current;
|
|
301
|
+
const spot = places.current.get(id);
|
|
302
|
+
if (!board || !spot)
|
|
303
|
+
return;
|
|
304
|
+
const across = scroller.current;
|
|
305
|
+
const section = sections.current.get(spot.lane);
|
|
306
|
+
if (across && section) {
|
|
307
|
+
const before = placeIn(section, board).x - across.scrollLeft;
|
|
308
|
+
const after = before + section.offsetWidth - across.clientWidth;
|
|
309
|
+
if (before < 0)
|
|
310
|
+
across.scrollLeft += before;
|
|
311
|
+
else if (after > 0)
|
|
312
|
+
across.scrollLeft += after;
|
|
313
|
+
}
|
|
314
|
+
const lane = viewports.current.get(spot.lane);
|
|
315
|
+
if (lane) {
|
|
316
|
+
// A little clear of the edge, where cards fade out.
|
|
317
|
+
const above = spot.y - placeIn(lane, board).y - lane.scrollTop - 8;
|
|
318
|
+
const below = above + spot.height + 16 - lane.clientHeight;
|
|
319
|
+
if (above < 0)
|
|
320
|
+
lane.scrollTop += above;
|
|
321
|
+
else if (below > 0)
|
|
322
|
+
lane.scrollTop += below;
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
/* ------------------------------------------------------------------ */
|
|
326
|
+
/* Carrying with the pointer */
|
|
327
|
+
/* ------------------------------------------------------------------ */
|
|
328
|
+
/** Which lane, and where in it, the carried card is over. */
|
|
329
|
+
const target = (x, y, id) => {
|
|
330
|
+
let lane = null;
|
|
331
|
+
let nearest = Infinity;
|
|
332
|
+
sections.current.forEach((node, laneId) => {
|
|
333
|
+
const box = node.getBoundingClientRect();
|
|
334
|
+
const distance = x < box.left ? box.left - x : x > box.right ? x - box.right : 0;
|
|
335
|
+
if (distance < nearest) {
|
|
336
|
+
nearest = distance;
|
|
337
|
+
lane = laneId;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
if (lane === null)
|
|
341
|
+
return null;
|
|
342
|
+
const over = (latest.current.draft ?? latest.current.settled).find((entry) => entry.id === lane);
|
|
343
|
+
/*
|
|
344
|
+
* Measured against where each card belongs, as last laid out, and not
|
|
345
|
+
* where it is drawn. Cards slide to their places, and a card read half
|
|
346
|
+
* way through its slide can be on the wrong side of the pointer, which
|
|
347
|
+
* moves the gap, which slides the card back: a flicker with no end.
|
|
348
|
+
* A place is where a card is laid out, so how far its lane has been
|
|
349
|
+
* scrolled is taken off the pointer and not off the card.
|
|
350
|
+
*/
|
|
351
|
+
const top = root.current?.getBoundingClientRect().top ?? 0;
|
|
352
|
+
const scrolled = viewports.current.get(lane)?.scrollTop ?? 0;
|
|
353
|
+
let index = 0;
|
|
354
|
+
for (const card of over?.cards ?? []) {
|
|
355
|
+
if (card.id === id)
|
|
356
|
+
continue;
|
|
357
|
+
const place = places.current.get(card.id);
|
|
358
|
+
if (place && y - top + scrolled > place.y + place.height / 2)
|
|
359
|
+
index += 1;
|
|
360
|
+
}
|
|
361
|
+
return { lane: lane, index };
|
|
362
|
+
};
|
|
363
|
+
const place = () => {
|
|
364
|
+
const state = drag.current;
|
|
365
|
+
const node = overlay.current;
|
|
366
|
+
if (!state || !node)
|
|
367
|
+
return;
|
|
368
|
+
node.style.translate = `${state.x - state.grabX}px ${state.y - state.grabY}px`;
|
|
369
|
+
};
|
|
370
|
+
const follow = () => {
|
|
371
|
+
const state = drag.current;
|
|
372
|
+
if (!state?.started)
|
|
373
|
+
return;
|
|
374
|
+
const over = target(state.x, state.y, state.id);
|
|
375
|
+
if (over) {
|
|
376
|
+
const now = whereIs(latest.current.draft ?? latest.current.settled, state.id);
|
|
377
|
+
if (!now || now.lane !== over.lane || now.index !== over.index) {
|
|
378
|
+
setDraft(moved(latest.current.settled, state.id, over.lane, over.index));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// Near either end of the board, it scrolls towards that end.
|
|
382
|
+
const board = scroller.current;
|
|
383
|
+
if (board) {
|
|
384
|
+
const box = board.getBoundingClientRect();
|
|
385
|
+
if (state.x < box.left + EDGE)
|
|
386
|
+
board.scrollLeft -= 10;
|
|
387
|
+
else if (state.x > box.right - EDGE)
|
|
388
|
+
board.scrollLeft += 10;
|
|
389
|
+
}
|
|
390
|
+
// And near the top or bottom of the lane it is over, that lane scrolls.
|
|
391
|
+
const lane = over ? viewports.current.get(over.lane) : undefined;
|
|
392
|
+
if (lane) {
|
|
393
|
+
const box = lane.getBoundingClientRect();
|
|
394
|
+
if (state.y < box.top + EDGE)
|
|
395
|
+
lane.scrollTop -= 8;
|
|
396
|
+
else if (state.y > box.bottom - EDGE)
|
|
397
|
+
lane.scrollTop += 8;
|
|
398
|
+
}
|
|
399
|
+
state.frame = requestAnimationFrame(follow);
|
|
400
|
+
};
|
|
401
|
+
const begin = () => {
|
|
402
|
+
const state = drag.current;
|
|
403
|
+
if (!state || state.started)
|
|
404
|
+
return;
|
|
405
|
+
state.started = true;
|
|
406
|
+
setDraft(latest.current.settled);
|
|
407
|
+
setCarry({ id: state.id, by: 'pointer' });
|
|
408
|
+
state.frame = requestAnimationFrame(follow);
|
|
409
|
+
};
|
|
410
|
+
const end = (drop) => {
|
|
411
|
+
const state = drag.current;
|
|
412
|
+
if (!state)
|
|
413
|
+
return;
|
|
414
|
+
window.clearTimeout(state.timer);
|
|
415
|
+
cancelAnimationFrame(state.frame);
|
|
416
|
+
drag.current = null;
|
|
417
|
+
if (!state.started)
|
|
418
|
+
return;
|
|
419
|
+
const next = latest.current.draft;
|
|
420
|
+
// Put back, the gap goes home first, so there is somewhere to fly to.
|
|
421
|
+
if (!drop)
|
|
422
|
+
setDraft(latest.current.settled);
|
|
423
|
+
const finish = () => {
|
|
424
|
+
if (drop && next)
|
|
425
|
+
commit(next, state.id);
|
|
426
|
+
setDraft(null);
|
|
427
|
+
setCarry(null);
|
|
428
|
+
};
|
|
429
|
+
/*
|
|
430
|
+
* The lifted card flies the last of the way into the gap that is waiting
|
|
431
|
+
* for it, and only then is the real card shown. Dropped where the pointer
|
|
432
|
+
* let go, it would vanish from one place and appear in another.
|
|
433
|
+
*/
|
|
434
|
+
requestAnimationFrame(() => {
|
|
435
|
+
const lifted = overlay.current;
|
|
436
|
+
const board = root.current;
|
|
437
|
+
const slot = places.current.get(state.id);
|
|
438
|
+
if (!lifted || !board || !slot || reducedMotion())
|
|
439
|
+
return finish();
|
|
440
|
+
// The gap may have been left under an edge of its lane. It is brought
|
|
441
|
+
// into view first, so the card flies to somewhere that can be seen.
|
|
442
|
+
reveal(state.id);
|
|
443
|
+
const lane = viewports.current.get(slot.lane);
|
|
444
|
+
const box = board.getBoundingClientRect();
|
|
445
|
+
const left = box.left + slot.x - (scroller.current?.scrollLeft ?? 0);
|
|
446
|
+
const top = box.top + slot.y - (lane?.scrollTop ?? 0);
|
|
447
|
+
const flight = lifted.animate([
|
|
448
|
+
{ translate: lifted.style.translate, rotate: '2deg' },
|
|
449
|
+
{ translate: `${left}px ${top}px`, rotate: '0deg' },
|
|
450
|
+
], { duration: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards' });
|
|
451
|
+
flight.finished.then(finish, finish);
|
|
452
|
+
});
|
|
453
|
+
};
|
|
454
|
+
useEffect(() => {
|
|
455
|
+
const onMove = (event) => {
|
|
456
|
+
const state = drag.current;
|
|
457
|
+
if (!state || event.pointerId !== state.pointerId)
|
|
458
|
+
return;
|
|
459
|
+
state.x = event.clientX;
|
|
460
|
+
state.y = event.clientY;
|
|
461
|
+
if (!state.started) {
|
|
462
|
+
const travel = Math.hypot(state.x - state.startX, state.y - state.startY);
|
|
463
|
+
// A finger that moves before the hold is up is scrolling, not lifting.
|
|
464
|
+
if (event.pointerType === 'touch') {
|
|
465
|
+
if (travel > SLOP * 2)
|
|
466
|
+
end(false);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (travel > SLOP)
|
|
470
|
+
begin();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
place();
|
|
474
|
+
};
|
|
475
|
+
const onUp = (event) => {
|
|
476
|
+
if (drag.current && event.pointerId === drag.current.pointerId)
|
|
477
|
+
end(true);
|
|
478
|
+
};
|
|
479
|
+
const onCancel = () => end(false);
|
|
480
|
+
const onKey = (event) => {
|
|
481
|
+
if (event.key === 'Escape' && drag.current?.started)
|
|
482
|
+
end(false);
|
|
483
|
+
};
|
|
484
|
+
// A carried card must not also scroll the page under the finger.
|
|
485
|
+
const onTouch = (event) => {
|
|
486
|
+
if (drag.current?.started)
|
|
487
|
+
event.preventDefault();
|
|
488
|
+
};
|
|
489
|
+
window.addEventListener('pointermove', onMove);
|
|
490
|
+
window.addEventListener('pointerup', onUp);
|
|
491
|
+
window.addEventListener('pointercancel', onCancel);
|
|
492
|
+
window.addEventListener('keydown', onKey);
|
|
493
|
+
window.addEventListener('touchmove', onTouch, { passive: false });
|
|
494
|
+
return () => {
|
|
495
|
+
window.removeEventListener('pointermove', onMove);
|
|
496
|
+
window.removeEventListener('pointerup', onUp);
|
|
497
|
+
window.removeEventListener('pointercancel', onCancel);
|
|
498
|
+
window.removeEventListener('keydown', onKey);
|
|
499
|
+
window.removeEventListener('touchmove', onTouch);
|
|
500
|
+
end(false);
|
|
501
|
+
};
|
|
502
|
+
// The handlers read everything through refs, so they are set up once.
|
|
503
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
504
|
+
}, []);
|
|
505
|
+
// The lifted card is put under the pointer as soon as it exists.
|
|
506
|
+
useLayoutEffect(() => {
|
|
507
|
+
if (carry?.by === 'pointer')
|
|
508
|
+
place();
|
|
509
|
+
});
|
|
510
|
+
const onPointerDown = (event, id) => {
|
|
511
|
+
if (event.button !== 0 || carry)
|
|
512
|
+
return;
|
|
513
|
+
// A button or a link inside a card is pressed, not dragged by.
|
|
514
|
+
if (event.target.closest('button, a, input, textarea, select, [data-no-drag]')) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
518
|
+
drag.current = {
|
|
519
|
+
id,
|
|
520
|
+
pointerId: event.pointerId,
|
|
521
|
+
startX: event.clientX,
|
|
522
|
+
startY: event.clientY,
|
|
523
|
+
grabX: event.clientX - box.left,
|
|
524
|
+
grabY: event.clientY - box.top,
|
|
525
|
+
x: event.clientX,
|
|
526
|
+
y: event.clientY,
|
|
527
|
+
width: box.width,
|
|
528
|
+
started: false,
|
|
529
|
+
timer: event.pointerType === 'touch' ? window.setTimeout(begin, HOLD) : 0,
|
|
530
|
+
frame: 0,
|
|
531
|
+
};
|
|
532
|
+
};
|
|
533
|
+
/* ------------------------------------------------------------------ */
|
|
534
|
+
/* Carrying with the keyboard */
|
|
535
|
+
/* ------------------------------------------------------------------ */
|
|
536
|
+
const say = (card, lanes, lead) => {
|
|
537
|
+
const at = whereIs(lanes, card.id);
|
|
538
|
+
const lane = lanes.find((entry) => entry.id === at?.lane);
|
|
539
|
+
if (!at || !lane)
|
|
540
|
+
return;
|
|
541
|
+
const name = typeof lane.title === 'string' ? lane.title : lane.id;
|
|
542
|
+
setSaid(`${lead} ${cardName(card)}: ${name}, place ${at.index + 1} of ${lane.cards.length}.`);
|
|
543
|
+
};
|
|
544
|
+
const onKeyDown = (event, card) => {
|
|
545
|
+
if (event.target !== event.currentTarget)
|
|
546
|
+
return;
|
|
547
|
+
const held = carry?.by === 'keys' && carry.id === card.id;
|
|
548
|
+
if (!held) {
|
|
549
|
+
if (carry || (event.key !== ' ' && event.key !== 'Enter'))
|
|
550
|
+
return;
|
|
551
|
+
event.preventDefault();
|
|
552
|
+
setDraft(settled);
|
|
553
|
+
setCarry({ id: card.id, by: 'keys' });
|
|
554
|
+
setSaid(`Picked up ${cardName(card)}. Arrow keys move it, Space sets it down, Escape puts it back.`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const lanes = draft ?? settled;
|
|
558
|
+
const at = whereIs(lanes, card.id);
|
|
559
|
+
if (!at)
|
|
560
|
+
return;
|
|
561
|
+
const laneAt = lanes.findIndex((lane) => lane.id === at.lane);
|
|
562
|
+
const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
|
|
563
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
564
|
+
event.preventDefault();
|
|
565
|
+
commit(lanes, card.id);
|
|
566
|
+
say(card, lanes, 'Set down');
|
|
567
|
+
setDraft(null);
|
|
568
|
+
setCarry(null);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (event.key === 'Escape') {
|
|
572
|
+
event.preventDefault();
|
|
573
|
+
setDraft(null);
|
|
574
|
+
setCarry(null);
|
|
575
|
+
setSaid(`Put ${cardName(card)} back.`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
let next = null;
|
|
579
|
+
if (event.key === 'ArrowUp')
|
|
580
|
+
next = moved(lanes, card.id, at.lane, at.index - 1);
|
|
581
|
+
else if (event.key === 'ArrowDown')
|
|
582
|
+
next = moved(lanes, card.id, at.lane, at.index + 1);
|
|
583
|
+
else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
584
|
+
const step = (event.key === 'ArrowRight' ? 1 : -1) * (rtl ? -1 : 1);
|
|
585
|
+
const other = lanes[laneAt + step];
|
|
586
|
+
if (other)
|
|
587
|
+
next = moved(lanes, card.id, other.id, at.index);
|
|
588
|
+
}
|
|
589
|
+
if (!next)
|
|
590
|
+
return;
|
|
591
|
+
event.preventDefault();
|
|
592
|
+
setDraft(next);
|
|
593
|
+
say(card, next, 'Moved');
|
|
594
|
+
};
|
|
595
|
+
// Moving to another lane redraws the card under a new parent, which would
|
|
596
|
+
// drop the focus it is being steered by.
|
|
597
|
+
// A move within a long lane can also take it under the lane's edge, where
|
|
598
|
+
// focus alone would not bring it back.
|
|
599
|
+
useLayoutEffect(() => {
|
|
600
|
+
if (carry?.by !== 'keys')
|
|
601
|
+
return;
|
|
602
|
+
cards.current.get(carry.id)?.focus({ preventScroll: true });
|
|
603
|
+
reveal(carry.id);
|
|
604
|
+
});
|
|
605
|
+
const carried = carry ? whereIs(shown, carry.id) : null;
|
|
606
|
+
const carriedLane = carried ? shown.find((lane) => lane.id === carried.lane) : undefined;
|
|
607
|
+
return (_jsxs("div", { ref: root, role: "group", "aria-label": label, className: slots.root({ className }), style: { height: height === 'auto' ? undefined : height, ...style }, ...props, children: [_jsxs(ScrollArea.Root, { className: slots.board(), children: [_jsx(ScrollArea.Viewport, { ref: scroller, className: slots.boardViewport(), children: _jsx("div", { className: slots.lanes(), children: shown.map((lane) => (_jsxs("section", { ref: (node) => {
|
|
608
|
+
if (node)
|
|
609
|
+
sections.current.set(lane.id, node);
|
|
610
|
+
else
|
|
611
|
+
sections.current.delete(lane.id);
|
|
612
|
+
}, "aria-label": typeof lane.title === 'string' ? lane.title : undefined, "data-over": carried?.lane === lane.id ? '' : undefined, className: laneBoardVariants({ over: carried?.lane === lane.id }).lane(), children: [_jsxs("header", { className: slots.laneHeader(), children: [_jsxs("div", { className: slots.laneHeading(), children: [lane.color ? (_jsx("span", { "aria-hidden": "true", className: laneBoardVariants({ color: lane.color }).laneDot() })) : null, _jsx("h3", { className: slots.laneTitle(), children: lane.title })] }), _jsx("span", { className: laneBoardVariants({
|
|
613
|
+
full: lane.limit !== undefined && lane.cards.length > lane.limit,
|
|
614
|
+
}).laneCount(), children: lane.limit === undefined
|
|
615
|
+
? lane.cards.length
|
|
616
|
+
: `${lane.cards.length}/${lane.limit}` })] }), _jsxs(ScrollArea.Root, { className: slots.laneScroller(), children: [_jsx(ScrollArea.Viewport, { ref: (node) => {
|
|
617
|
+
if (node)
|
|
618
|
+
viewports.current.set(lane.id, node);
|
|
619
|
+
else
|
|
620
|
+
viewports.current.delete(lane.id);
|
|
621
|
+
}, className: slots.laneViewport(), children: _jsx("ol", { className: slots.list(), children: lane.cards.map((card) => {
|
|
622
|
+
const isCarried = carry?.id === card.id;
|
|
623
|
+
const state = !isCarried
|
|
624
|
+
? 'rest'
|
|
625
|
+
: carry.by === 'pointer'
|
|
626
|
+
? 'placeholder'
|
|
627
|
+
: 'held';
|
|
628
|
+
return (_jsx("li", { ref: (node) => {
|
|
629
|
+
if (node)
|
|
630
|
+
cards.current.set(card.id, node);
|
|
631
|
+
else
|
|
632
|
+
cards.current.delete(card.id);
|
|
633
|
+
}, tabIndex: 0, "aria-roledescription": "Card that can be moved", "aria-label": cardName(card), "data-card": card.id, "data-carried": isCarried ? carry.by : undefined, className: laneBoardVariants({ state }).card(), onPointerDown: (event) => onPointerDown(event, card.id), onKeyDown: (event) => onKeyDown(event, card), onBlur: () => {
|
|
634
|
+
// Focus leaving a card held with the keyboard puts it
|
|
635
|
+
// back: nothing is steering it any more.
|
|
636
|
+
if (carry?.by === 'keys' &&
|
|
637
|
+
carry.id === card.id &&
|
|
638
|
+
!cards.current.get(card.id)?.matches(':focus')) {
|
|
639
|
+
requestAnimationFrame(() => {
|
|
640
|
+
if (document.activeElement !== cards.current.get(card.id)) {
|
|
641
|
+
setDraft(null);
|
|
642
|
+
setCarry(null);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}, children: renderCard(card, { lane, carried: isCarried }) }, card.id));
|
|
647
|
+
}) }) }), _jsx(ScrollArea.Scrollbar, { orientation: "vertical", className: slots.scrollbar(), children: _jsx(ScrollArea.Thumb, { className: slots.thumb() }) })] }), _jsx("div", { className: slots.laneFooter(), children: renderLaneFooter?.(lane) })] }, lane.id))) }) }), _jsx(ScrollArea.Scrollbar, { orientation: "horizontal", className: slots.scrollbar(), children: _jsx(ScrollArea.Thumb, { className: slots.thumb() }) })] }), carry?.by === 'pointer' && carried && carriedLane ? (_jsx("div", { ref: overlay, "aria-hidden": "true", className: slots.overlay(), style: { width: drag.current?.width }, children: renderCard(carried.card, { lane: carriedLane, carried: true }) })) : null, _jsx("span", { "aria-live": "assertive", className: "sr-only", children: said })] }));
|
|
648
|
+
}
|
|
649
|
+
LaneBoardRoot.displayName = 'LaneBoard';
|
|
650
|
+
/** A card's name. */
|
|
651
|
+
function LaneBoardTitle({ className, ...props }) {
|
|
652
|
+
return _jsx("div", { className: laneBoardVariants().title({ className }), ...props });
|
|
653
|
+
}
|
|
654
|
+
LaneBoardTitle.displayName = 'LaneBoard.Title';
|
|
655
|
+
/** A card's reference, in a fixed-width face so a column of them lines up. */
|
|
656
|
+
function LaneBoardCode({ className, ...props }) {
|
|
657
|
+
return _jsx("div", { className: laneBoardVariants().code({ className }), ...props });
|
|
658
|
+
}
|
|
659
|
+
LaneBoardCode.displayName = 'LaneBoard.Code';
|
|
660
|
+
/** The last line of a card: something at each end. */
|
|
661
|
+
function LaneBoardRow({ className, ...props }) {
|
|
662
|
+
return _jsx("div", { className: laneBoardVariants().row({ className }), ...props });
|
|
663
|
+
}
|
|
664
|
+
LaneBoardRow.displayName = 'LaneBoard.Row';
|
|
665
|
+
/** How far along a card is, as a row of dashes with the done ones lit. */
|
|
666
|
+
function LaneBoardSteps({ value, max = 5, color = 'primary', label = 'Progress', className, ...props }) {
|
|
667
|
+
const slots = laneBoardVariants({ color });
|
|
668
|
+
return (_jsx("div", { role: "img", "aria-label": `${label}: ${value} of ${max}`, className: slots.steps({ className }), ...props, children: Array.from({ length: max }, (_, index) => (_jsx("span", { className: laneBoardVariants({ lit: index < value }).step() }, index))) }));
|
|
669
|
+
}
|
|
670
|
+
LaneBoardSteps.displayName = 'LaneBoard.Steps';
|
|
671
|
+
/** A word in a pill, for a card's state. */
|
|
672
|
+
function LaneBoardTag({ color = 'primary', className, ...props }) {
|
|
673
|
+
return _jsx("span", { className: laneBoardVariants({ color }).tag({ className }), ...props });
|
|
674
|
+
}
|
|
675
|
+
LaneBoardTag.displayName = 'LaneBoard.Tag';
|
|
676
|
+
/** A short figure at the end of a card's last line: a time, a count. */
|
|
677
|
+
function LaneBoardMeta({ className, ...props }) {
|
|
678
|
+
return _jsx("span", { className: laneBoardVariants().meta({ className }), ...props });
|
|
679
|
+
}
|
|
680
|
+
LaneBoardMeta.displayName = 'LaneBoard.Meta';
|
|
681
|
+
export const LaneBoard = Object.assign(LaneBoardRoot, {
|
|
682
|
+
Title: LaneBoardTitle,
|
|
683
|
+
Code: LaneBoardCode,
|
|
684
|
+
Row: LaneBoardRow,
|
|
685
|
+
Steps: LaneBoardSteps,
|
|
686
|
+
Tag: LaneBoardTag,
|
|
687
|
+
Meta: LaneBoardMeta,
|
|
688
|
+
});
|
|
689
|
+
/* By name as well, for a Server Component, which cannot dot into a client
|
|
690
|
+
module. */
|
|
691
|
+
export { LaneBoardTitle, LaneBoardCode, LaneBoardRow, LaneBoardSteps, LaneBoardTag, LaneBoardMeta };
|