@talelk/newui-organisms 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +12 -2
- package/package.json +38 -3
- package/src/exports.test.ts +27 -0
- package/src/index.ts +1 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
- package/src/organisms/AppSidebar/index.ts +1 -0
- package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
- package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
- package/src/organisms/AuthCard/AuthCard.tsx +89 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
- package/src/organisms/CommandPalette/index.ts +1 -0
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -0
- package/src/organisms/DataTable/DataTable.test.tsx +161 -0
- package/src/organisms/DataTable/DataTable.tsx +612 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
- package/src/organisms/FilterBar/FilterBar.tsx +251 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
- package/src/organisms/KanbanBoard/drag.test.ts +448 -0
- package/src/organisms/KanbanBoard/drag.ts +407 -0
- package/src/organisms/KanbanBoard/index.ts +17 -0
- package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
- package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
- package/src/organisms/MobileNav/MobileNav.tsx +120 -0
- package/src/organisms/MobileNav/index.ts +6 -0
- package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
- package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
- package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
- package/src/organisms/PageToolbar/index.ts +1 -0
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -0
- package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
- package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
- package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
- package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
- package/src/organisms/SettingsDialog/index.ts +20 -0
- package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
- package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
- package/src/organisms/Sidebar/Sidebar.tsx +732 -0
- package/src/organisms/Sidebar/index.ts +22 -0
- package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
- package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
- package/src/organisms/StatGrid/StatGrid.tsx +151 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +113 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- package/src/organisms/index.ts +16 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { type Accessor, createMemo, createSignal, onCleanup } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The board's drag gesture.
|
|
5
|
+
*
|
|
6
|
+
* This is not a state machine and there is no Zag machine to reach for: a
|
|
7
|
+
* sortable board is pointer geometry — where the finger is, what it is over, and
|
|
8
|
+
* what has to move as a result. So the whole gesture lives here, behind a
|
|
9
|
+
* factory, with the DOM knowledge (`data-kanban-card`, `data-dragging`) kept in
|
|
10
|
+
* the component and the arithmetic kept in this file.
|
|
11
|
+
*
|
|
12
|
+
* The shape is the reference implementation's, because each piece of it was
|
|
13
|
+
* earned:
|
|
14
|
+
*
|
|
15
|
+
* - **A press is not a drag yet.** A mouse has to travel `MOUSE_THRESHOLD`;
|
|
16
|
+
* a touch has to hold still for `LIFT_DELAY`, and *stops being a candidate* if
|
|
17
|
+
* it drifts past `DRIFT_TOLERANCE`, because a finger that moves is scrolling
|
|
18
|
+
* the board, not picking up a card.
|
|
19
|
+
* - **One rAF loop, not `pointermove`.** A held pointer at the edge keeps
|
|
20
|
+
* auto-scrolling the containers under itself, which moves the slots the
|
|
21
|
+
* pointer is not moving over. Position, hit-test and auto-scroll therefore all
|
|
22
|
+
* run per frame.
|
|
23
|
+
* - **The insertion index counts the *other* cards only.** The dragged card's
|
|
24
|
+
* slot is skipped, which is what makes the choice self-stabilising: inserting
|
|
25
|
+
* pushes the boundary card away from the pointer, so it cannot oscillate
|
|
26
|
+
* between two neighbours.
|
|
27
|
+
* - **Live reordering is a preview, not a mutation.** The reference splices the
|
|
28
|
+
* real array during the drag and splices it back on cancel. Here the drop is
|
|
29
|
+
* reported once, at the end: cancelling is then just discarding a signal,
|
|
30
|
+
* there is no restore path to get wrong, and a controlled board never sees a
|
|
31
|
+
* move it did not accept.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** px a mouse must travel before a press becomes a drag. */
|
|
35
|
+
export const MOUSE_THRESHOLD = 4
|
|
36
|
+
/** px a *held touch* may drift before the gesture is read as a scroll. */
|
|
37
|
+
export const DRIFT_TOLERANCE = 8
|
|
38
|
+
/** ms a touch must hold before it becomes a drag. */
|
|
39
|
+
export const LIFT_DELAY = 250
|
|
40
|
+
/** px-wide edge zones that trigger auto-scroll. */
|
|
41
|
+
export const EDGE = 48
|
|
42
|
+
/** px per frame at the very edge. */
|
|
43
|
+
export const EDGE_SPEED = 14
|
|
44
|
+
/** ms the ghost glides onto the slot on release. */
|
|
45
|
+
export const SETTLE_MS = 150
|
|
46
|
+
|
|
47
|
+
export interface KanbanSlot {
|
|
48
|
+
column: string
|
|
49
|
+
/** Insertion index counted over the column's *other* items. */
|
|
50
|
+
index: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface KanbanDragState<T> {
|
|
54
|
+
item: T
|
|
55
|
+
itemId: string
|
|
56
|
+
/** The grabbed card's size, so the ghost and the slot match it exactly. */
|
|
57
|
+
width: number
|
|
58
|
+
height: number
|
|
59
|
+
/** Ghost top-left, in viewport coordinates. */
|
|
60
|
+
x: number
|
|
61
|
+
y: number
|
|
62
|
+
/** True while the ghost glides onto the slot; the ghost animates only then. */
|
|
63
|
+
settling: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A press that starts on one of these is a click on that control — a menu, a
|
|
68
|
+
* link, a field — not a grab. `data-kanban-no-drag` is the escape hatch for
|
|
69
|
+
* anything else interactive a card contains.
|
|
70
|
+
*/
|
|
71
|
+
const INTERACTIVE = 'button, a, input, select, textarea, [data-kanban-no-drag]'
|
|
72
|
+
|
|
73
|
+
export interface KanbanDragOptions<T extends { id: string }> {
|
|
74
|
+
/** The element that scrolls horizontally — the board's viewport. */
|
|
75
|
+
container: () => HTMLElement | undefined
|
|
76
|
+
/** The element that scrolls vertically, per column. */
|
|
77
|
+
columnScroller: (columnId: string) => HTMLElement | undefined
|
|
78
|
+
/** Column ids in render order. */
|
|
79
|
+
columnIds: () => string[]
|
|
80
|
+
/** Where the item sits right now: the live preview, else its slot in the data. */
|
|
81
|
+
slotOf: (itemId: string) => KanbanSlot | undefined
|
|
82
|
+
/** The pointer crossed into another slot. The board re-renders from this. */
|
|
83
|
+
onPreview: (itemId: string, to: KanbanSlot) => void
|
|
84
|
+
/** Released somewhere new. `from` is where the gesture started. */
|
|
85
|
+
onDrop: (item: T, from: KanbanSlot, to: KanbanSlot) => void
|
|
86
|
+
/** Released where it started, or cancelled. */
|
|
87
|
+
onCancel: () => void
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface KanbanDrag<T> {
|
|
91
|
+
state: Accessor<KanbanDragState<T> | undefined>
|
|
92
|
+
/** Begin tracking a press on a card. */
|
|
93
|
+
start: (event: PointerEvent, item: T, card: HTMLElement) => void
|
|
94
|
+
isDragging: (itemId: string) => boolean
|
|
95
|
+
/** Abandon the gesture, putting the card back where it was. */
|
|
96
|
+
cancel: () => void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface Pending<T> {
|
|
100
|
+
item: T
|
|
101
|
+
card: HTMLElement
|
|
102
|
+
startX: number
|
|
103
|
+
startY: number
|
|
104
|
+
mouse: boolean
|
|
105
|
+
timer: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createKanbanDrag<T extends { id: string }>(
|
|
109
|
+
options: KanbanDragOptions<T>,
|
|
110
|
+
): KanbanDrag<T> {
|
|
111
|
+
/**
|
|
112
|
+
* Two signals, not one: the position changes every frame, and the cards read
|
|
113
|
+
* only *which* item is lifted. One signal would re-render every card in the
|
|
114
|
+
* board sixty times a second to move one ghost.
|
|
115
|
+
*/
|
|
116
|
+
const [lifted, setLifted] = createSignal<Omit<
|
|
117
|
+
KanbanDragState<T>,
|
|
118
|
+
'x' | 'y' | 'settling'
|
|
119
|
+
> | null>(null)
|
|
120
|
+
const [ghost, setGhost] = createSignal<{
|
|
121
|
+
x: number
|
|
122
|
+
y: number
|
|
123
|
+
settling: boolean
|
|
124
|
+
} | null>(null)
|
|
125
|
+
|
|
126
|
+
const state = createMemo<KanbanDragState<T> | undefined>(() => {
|
|
127
|
+
const active = lifted()
|
|
128
|
+
const position = ghost()
|
|
129
|
+
return active && position ? { ...active, ...position } : undefined
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const pointer = { x: 0, y: 0 }
|
|
133
|
+
const grab = { x: 0, y: 0 }
|
|
134
|
+
let pending: Pending<T> | undefined
|
|
135
|
+
let from: KanbanSlot | undefined
|
|
136
|
+
let frame = 0
|
|
137
|
+
let settle = 0
|
|
138
|
+
// Whether the window listeners are on. Without this, teardown after a
|
|
139
|
+
// cancelled press removes listeners that are already gone, and "we remove
|
|
140
|
+
// exactly what we added" stops being true.
|
|
141
|
+
let watching = false
|
|
142
|
+
let holding = false
|
|
143
|
+
|
|
144
|
+
const motionAllowed = () =>
|
|
145
|
+
typeof matchMedia !== 'function' ||
|
|
146
|
+
!matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
147
|
+
|
|
148
|
+
function preventScroll(event: TouchEvent) {
|
|
149
|
+
// While a card is lifted, the gesture is ours: the only way to stop the
|
|
150
|
+
// browser from starting a scroll (which would fire `pointercancel` and kill
|
|
151
|
+
// the drag) is a non-passive listener on `touchmove`.
|
|
152
|
+
event.preventDefault()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function watch() {
|
|
156
|
+
if (watching) return
|
|
157
|
+
watching = true
|
|
158
|
+
window.addEventListener('pointermove', onPointerMove)
|
|
159
|
+
window.addEventListener('pointerup', onPointerUp)
|
|
160
|
+
window.addEventListener('pointercancel', onPointerCancel)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function releaseListeners() {
|
|
164
|
+
if (watching) {
|
|
165
|
+
watching = false
|
|
166
|
+
window.removeEventListener('pointermove', onPointerMove)
|
|
167
|
+
window.removeEventListener('pointerup', onPointerUp)
|
|
168
|
+
window.removeEventListener('pointercancel', onPointerCancel)
|
|
169
|
+
}
|
|
170
|
+
if (holding) {
|
|
171
|
+
holding = false
|
|
172
|
+
window.removeEventListener('touchmove', preventScroll)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stopFrame() {
|
|
177
|
+
cancelAnimationFrame(frame)
|
|
178
|
+
frame = 0
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Tear the gesture down. Never reports: this is what unmount does. */
|
|
182
|
+
function teardown() {
|
|
183
|
+
if (pending) {
|
|
184
|
+
clearTimeout(pending.timer)
|
|
185
|
+
pending = undefined
|
|
186
|
+
}
|
|
187
|
+
clearTimeout(settle)
|
|
188
|
+
settle = 0
|
|
189
|
+
stopFrame()
|
|
190
|
+
releaseListeners()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function cancelPending() {
|
|
194
|
+
if (pending) clearTimeout(pending.timer)
|
|
195
|
+
pending = undefined
|
|
196
|
+
releaseListeners()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function endDrag() {
|
|
200
|
+
teardown()
|
|
201
|
+
setLifted(null)
|
|
202
|
+
setGhost(null)
|
|
203
|
+
from = undefined
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function start(event: PointerEvent, item: T, card: HTMLElement) {
|
|
207
|
+
if (lifted() || pending) return
|
|
208
|
+
if (event.pointerType === 'mouse' && event.button !== 0) return
|
|
209
|
+
if ((event.target as Element | null)?.closest(INTERACTIVE)) return
|
|
210
|
+
|
|
211
|
+
pointer.x = event.clientX
|
|
212
|
+
pointer.y = event.clientY
|
|
213
|
+
const mouse = event.pointerType === 'mouse'
|
|
214
|
+
|
|
215
|
+
pending = {
|
|
216
|
+
item,
|
|
217
|
+
card,
|
|
218
|
+
startX: event.clientX,
|
|
219
|
+
startY: event.clientY,
|
|
220
|
+
mouse,
|
|
221
|
+
// A mouse lifts on drift; a finger has to hold still, because a finger
|
|
222
|
+
// that moves is scrolling.
|
|
223
|
+
timer: mouse ? 0 : window.setTimeout(() => lift(), LIFT_DELAY),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
watch()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function lift() {
|
|
230
|
+
const armed = pending
|
|
231
|
+
if (!armed) return
|
|
232
|
+
clearTimeout(armed.timer)
|
|
233
|
+
pending = undefined
|
|
234
|
+
|
|
235
|
+
const slot = options.slotOf(armed.item.id)
|
|
236
|
+
if (!slot) return releaseListeners()
|
|
237
|
+
|
|
238
|
+
const rect = armed.card.getBoundingClientRect()
|
|
239
|
+
from = slot
|
|
240
|
+
grab.x = pointer.x - rect.left
|
|
241
|
+
grab.y = pointer.y - rect.top
|
|
242
|
+
setLifted({
|
|
243
|
+
item: armed.item,
|
|
244
|
+
itemId: armed.item.id,
|
|
245
|
+
width: rect.width,
|
|
246
|
+
height: rect.height,
|
|
247
|
+
})
|
|
248
|
+
setGhost({ x: rect.left, y: rect.top, settling: false })
|
|
249
|
+
|
|
250
|
+
if (!holding) {
|
|
251
|
+
holding = true
|
|
252
|
+
window.addEventListener('touchmove', preventScroll, { passive: false })
|
|
253
|
+
}
|
|
254
|
+
navigator.vibrate?.(10)
|
|
255
|
+
frame = requestAnimationFrame(tick)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function tick() {
|
|
259
|
+
const active = lifted()
|
|
260
|
+
if (!active) return
|
|
261
|
+
setGhost((current) =>
|
|
262
|
+
current
|
|
263
|
+
? { ...current, x: pointer.x - grab.x, y: pointer.y - grab.y }
|
|
264
|
+
: current,
|
|
265
|
+
)
|
|
266
|
+
autoscroll(active.itemId)
|
|
267
|
+
updateSlot(active.itemId)
|
|
268
|
+
frame = requestAnimationFrame(tick)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Where the pointer is over, expressed as a slot for the item. */
|
|
272
|
+
function slotUnderPointer(): KanbanSlot | undefined {
|
|
273
|
+
for (const columnId of options.columnIds()) {
|
|
274
|
+
const element = options.columnScroller(columnId)
|
|
275
|
+
if (!element) continue
|
|
276
|
+
const rect = element.getBoundingClientRect()
|
|
277
|
+
// The gaps between columns are not drop targets: leaving the item where
|
|
278
|
+
// it is beats guessing which neighbour the pointer meant.
|
|
279
|
+
if (pointer.x < rect.left || pointer.x > rect.right) continue
|
|
280
|
+
|
|
281
|
+
let index = 0
|
|
282
|
+
for (const card of element.querySelectorAll(
|
|
283
|
+
'[data-kanban-card]:not([data-dragging])',
|
|
284
|
+
)) {
|
|
285
|
+
const bounds = card.getBoundingClientRect()
|
|
286
|
+
if (pointer.y > bounds.top + bounds.height / 2) index += 1
|
|
287
|
+
}
|
|
288
|
+
return { column: columnId, index }
|
|
289
|
+
}
|
|
290
|
+
return undefined
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function updateSlot(itemId: string) {
|
|
294
|
+
const slot = slotUnderPointer()
|
|
295
|
+
if (!slot) return
|
|
296
|
+
const current = options.slotOf(itemId)
|
|
297
|
+
if (
|
|
298
|
+
current &&
|
|
299
|
+
current.column === slot.column &&
|
|
300
|
+
current.index === slot.index
|
|
301
|
+
)
|
|
302
|
+
return
|
|
303
|
+
options.onPreview(itemId, slot)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function autoscroll(itemId: string) {
|
|
307
|
+
const step = (overshoot: number) =>
|
|
308
|
+
Math.ceil((Math.min(overshoot, EDGE) / EDGE) * EDGE_SPEED)
|
|
309
|
+
|
|
310
|
+
const board = options.container()
|
|
311
|
+
if (board) {
|
|
312
|
+
const rect = board.getBoundingClientRect()
|
|
313
|
+
if (pointer.x < rect.left + EDGE)
|
|
314
|
+
board.scrollLeft -= step(rect.left + EDGE - pointer.x)
|
|
315
|
+
else if (pointer.x > rect.right - EDGE)
|
|
316
|
+
board.scrollLeft += step(pointer.x - (rect.right - EDGE))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const column = options.slotOf(itemId)?.column
|
|
320
|
+
const scroller = column ? options.columnScroller(column) : undefined
|
|
321
|
+
if (scroller) {
|
|
322
|
+
const rect = scroller.getBoundingClientRect()
|
|
323
|
+
if (pointer.y < rect.top + EDGE)
|
|
324
|
+
scroller.scrollTop -= step(rect.top + EDGE - pointer.y)
|
|
325
|
+
else if (pointer.y > rect.bottom - EDGE)
|
|
326
|
+
scroller.scrollTop += step(pointer.y - (rect.bottom - EDGE))
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function commit() {
|
|
331
|
+
const active = lifted()
|
|
332
|
+
const to = active ? options.slotOf(active.itemId) : undefined
|
|
333
|
+
const started = from
|
|
334
|
+
if (active && to && started) {
|
|
335
|
+
if (to.column !== started.column || to.index !== started.index)
|
|
336
|
+
options.onDrop(active.item, started, to)
|
|
337
|
+
else options.onCancel()
|
|
338
|
+
} else {
|
|
339
|
+
options.onCancel()
|
|
340
|
+
}
|
|
341
|
+
endDrag()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function onPointerMove(event: PointerEvent) {
|
|
345
|
+
pointer.x = event.clientX
|
|
346
|
+
pointer.y = event.clientY
|
|
347
|
+
if (!pending) return
|
|
348
|
+
const drift = Math.hypot(
|
|
349
|
+
event.clientX - pending.startX,
|
|
350
|
+
event.clientY - pending.startY,
|
|
351
|
+
)
|
|
352
|
+
if (pending.mouse) {
|
|
353
|
+
if (drift > MOUSE_THRESHOLD) lift()
|
|
354
|
+
} else if (drift > DRIFT_TOLERANCE) {
|
|
355
|
+
cancelPending()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function onPointerUp() {
|
|
360
|
+
if (pending) return cancelPending()
|
|
361
|
+
const active = lifted()
|
|
362
|
+
if (!active || ghost()?.settling) return
|
|
363
|
+
|
|
364
|
+
// Land the ghost on the slot, then reveal the real card. The slot has held
|
|
365
|
+
// the item's place since it lifted, so the reveal is a swap with no entry
|
|
366
|
+
// animation and nothing else in the column moves.
|
|
367
|
+
const slot = options
|
|
368
|
+
.container()
|
|
369
|
+
?.querySelector<HTMLElement>('[data-kanban-card][data-dragging]')
|
|
370
|
+
const landing = slot?.getBoundingClientRect()
|
|
371
|
+
|
|
372
|
+
if (!landing) return commit()
|
|
373
|
+
|
|
374
|
+
if (!motionAllowed()) {
|
|
375
|
+
setGhost({ x: landing.left, y: landing.top, settling: true })
|
|
376
|
+
return commit()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
setGhost({ x: landing.left, y: landing.top, settling: true })
|
|
380
|
+
settle = window.setTimeout(commit, SETTLE_MS)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function onPointerCancel() {
|
|
384
|
+
// A cancelled pointer is the browser taking the gesture back (a scroll
|
|
385
|
+
// won, the window lost focus): put the card down.
|
|
386
|
+
if (pending) return cancelPending()
|
|
387
|
+
if (!lifted()) return
|
|
388
|
+
options.onCancel()
|
|
389
|
+
endDrag()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function cancel() {
|
|
393
|
+
if (pending) return cancelPending()
|
|
394
|
+
if (!lifted() || ghost()?.settling) return
|
|
395
|
+
options.onCancel()
|
|
396
|
+
endDrag()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
onCleanup(teardown)
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
state,
|
|
403
|
+
start,
|
|
404
|
+
isDragging: (itemId: string) => lifted()?.itemId === itemId,
|
|
405
|
+
cancel,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The block, the placement helper its controlled consumers need, and the types
|
|
3
|
+
* — not the drag engine.
|
|
4
|
+
*
|
|
5
|
+
* `drag.ts` is the implementation of the gesture: tuning constants and a
|
|
6
|
+
* pointer-state machine. They were reaching consumers through `export *`, which
|
|
7
|
+
* made `EDGE_SPEED` public API and a tuning change a breaking one. The board's
|
|
8
|
+
* tests import that module directly, which is where an internal belongs.
|
|
9
|
+
*/
|
|
10
|
+
export {
|
|
11
|
+
KanbanBoard,
|
|
12
|
+
type KanbanBoardProps,
|
|
13
|
+
type KanbanColumn,
|
|
14
|
+
type KanbanMove,
|
|
15
|
+
type KanbanTone,
|
|
16
|
+
placeKanbanItem,
|
|
17
|
+
} from './KanbanBoard'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { render } from '@solidjs/testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { MobileNav, MobileNavItem } from './MobileNav'
|
|
4
|
+
|
|
5
|
+
describe('MobileNav (browser, real CSS)', () => {
|
|
6
|
+
it('lays the bar out as a grid of equal-width tabs', () => {
|
|
7
|
+
const { container } = render(() => (
|
|
8
|
+
<MobileNav aria-label="Primary">
|
|
9
|
+
<MobileNavItem label="Home" active />
|
|
10
|
+
<MobileNavItem label="Search" />
|
|
11
|
+
</MobileNav>
|
|
12
|
+
))
|
|
13
|
+
const nav = container.querySelector(
|
|
14
|
+
'[data-slot="mobile-nav"]',
|
|
15
|
+
) as HTMLElement
|
|
16
|
+
const style = getComputedStyle(nav)
|
|
17
|
+
expect(style.display).toBe('grid')
|
|
18
|
+
expect(style.gridAutoFlow).toBe('column')
|
|
19
|
+
const item = container.querySelector(
|
|
20
|
+
'[data-slot="mobile-nav-item"]',
|
|
21
|
+
) as HTMLElement
|
|
22
|
+
expect(item.getBoundingClientRect().height).toBeGreaterThanOrEqual(56)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('paints the active tab against the inactive one', () => {
|
|
26
|
+
const { container } = render(() => (
|
|
27
|
+
<MobileNav aria-label="Primary">
|
|
28
|
+
<MobileNavItem label="Home" active />
|
|
29
|
+
<MobileNavItem label="Search" />
|
|
30
|
+
</MobileNav>
|
|
31
|
+
))
|
|
32
|
+
const [active, inactive] = Array.from(
|
|
33
|
+
container.querySelectorAll('[data-slot="mobile-nav-item"]'),
|
|
34
|
+
)
|
|
35
|
+
expect(getComputedStyle(active as HTMLElement).color).not.toBe(
|
|
36
|
+
getComputedStyle(inactive as HTMLElement).color,
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { render, screen } from '@solidjs/testing-library'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { MobileNav, MobileNavItem } from './MobileNav'
|
|
5
|
+
|
|
6
|
+
describe('MobileNav', () => {
|
|
7
|
+
it('renders a nav landmark with an accessible name', () => {
|
|
8
|
+
render(() => (
|
|
9
|
+
<MobileNav aria-label="Primary">
|
|
10
|
+
<MobileNavItem label="Home" />
|
|
11
|
+
</MobileNav>
|
|
12
|
+
))
|
|
13
|
+
|
|
14
|
+
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeTruthy()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('marks the active item and leaves the rest inactive', () => {
|
|
18
|
+
render(() => (
|
|
19
|
+
<MobileNav aria-label="Primary">
|
|
20
|
+
<MobileNavItem label="Home" active />
|
|
21
|
+
<MobileNavItem label="Search" />
|
|
22
|
+
</MobileNav>
|
|
23
|
+
))
|
|
24
|
+
|
|
25
|
+
expect(
|
|
26
|
+
screen.getByRole('button', { name: 'Home' }).getAttribute('data-state'),
|
|
27
|
+
).toBe('active')
|
|
28
|
+
expect(
|
|
29
|
+
screen.getByRole('button', { name: 'Search' }).getAttribute('data-state'),
|
|
30
|
+
).toBe('inactive')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders an anchor when it has a destination', () => {
|
|
34
|
+
render(() => (
|
|
35
|
+
<MobileNav aria-label="Primary">
|
|
36
|
+
<MobileNavItem label="Search" route="/search" />
|
|
37
|
+
<MobileNavItem label="Docs" href="https://example.com" />
|
|
38
|
+
</MobileNav>
|
|
39
|
+
))
|
|
40
|
+
|
|
41
|
+
expect(
|
|
42
|
+
screen.getByRole('link', { name: 'Search' }).getAttribute('href'),
|
|
43
|
+
).toBe('/search')
|
|
44
|
+
expect(
|
|
45
|
+
screen.getByRole('link', { name: 'Docs' }).getAttribute('href'),
|
|
46
|
+
).toBe('https://example.com')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('prefers route over href', () => {
|
|
50
|
+
render(() => (
|
|
51
|
+
<MobileNav aria-label="Primary">
|
|
52
|
+
<MobileNavItem label="Search" route="/in-app" href="/external" />
|
|
53
|
+
</MobileNav>
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
screen.getByRole('link', { name: 'Search' }).getAttribute('href'),
|
|
58
|
+
).toBe('/in-app')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('fires onClick for a button item', async () => {
|
|
62
|
+
const onClick = vi.fn()
|
|
63
|
+
render(() => (
|
|
64
|
+
<MobileNav aria-label="Primary">
|
|
65
|
+
<MobileNavItem label="More" onClick={onClick} />
|
|
66
|
+
</MobileNav>
|
|
67
|
+
))
|
|
68
|
+
|
|
69
|
+
await userEvent.click(screen.getByRole('button', { name: 'More' }))
|
|
70
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('renders the label and the icon glyph', () => {
|
|
74
|
+
render(() => (
|
|
75
|
+
<MobileNav aria-label="Primary">
|
|
76
|
+
<MobileNavItem label="Home" icon="lucide-house" />
|
|
77
|
+
</MobileNav>
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
expect(screen.getByText('Home')).toBeTruthy()
|
|
81
|
+
expect(document.querySelector('.lucide-house')).toBeTruthy()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('renders custom content in place of the icon', () => {
|
|
85
|
+
render(() => (
|
|
86
|
+
<MobileNav aria-label="Primary">
|
|
87
|
+
<MobileNavItem label="You">
|
|
88
|
+
<span data-testid="avatar">RK</span>
|
|
89
|
+
</MobileNavItem>
|
|
90
|
+
</MobileNav>
|
|
91
|
+
))
|
|
92
|
+
|
|
93
|
+
expect(screen.getByTestId('avatar')).toBeTruthy()
|
|
94
|
+
expect(screen.getByRole('button', { name: 'You' })).toBeTruthy()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('exposes aria-current when set', () => {
|
|
98
|
+
render(() => (
|
|
99
|
+
<MobileNav aria-label="Primary">
|
|
100
|
+
<MobileNavItem label="Home" active aria-current="page" />
|
|
101
|
+
</MobileNav>
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
expect(
|
|
105
|
+
screen.getByRole('button', { name: 'Home' }).getAttribute('aria-current'),
|
|
106
|
+
).toBe('page')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Icon, type IconProp } from '@talelk/newui-atoms'
|
|
2
|
+
import { cn } from '@talelk/newui-atoms/internal'
|
|
3
|
+
import { type JSX, Show, splitProps } from 'solid-js'
|
|
4
|
+
import { Dynamic } from 'solid-js/web'
|
|
5
|
+
|
|
6
|
+
export interface MobileNavProps {
|
|
7
|
+
/** Accessible name for the `<nav>` landmark. */
|
|
8
|
+
'aria-label'?: string
|
|
9
|
+
/** Merged into the root `<nav>`. */
|
|
10
|
+
class?: string
|
|
11
|
+
/** One or more `MobileNavItem`s, each becoming an equal-width tab. */
|
|
12
|
+
children?: JSX.Element
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A bottom navigation bar. A bare grid frame: each `MobileNavItem` is one
|
|
17
|
+
* equal-width column (`auto-cols-fr` + `grid-flow-col`), so the bar adapts to
|
|
18
|
+
* any item count without a hardcoded column class.
|
|
19
|
+
*
|
|
20
|
+
* In an installed PWA the bar runs to the bottom edge and its padding clears
|
|
21
|
+
* the home indicator — the safe-area inset, or `1rem` where the inset is
|
|
22
|
+
* smaller. Presentational: it owns no selection state.
|
|
23
|
+
*/
|
|
24
|
+
export function MobileNav(props: MobileNavProps): JSX.Element {
|
|
25
|
+
const [local] = splitProps(props, ['aria-label', 'class', 'children'])
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<nav
|
|
29
|
+
aria-label={local['aria-label']}
|
|
30
|
+
data-slot="mobile-nav"
|
|
31
|
+
class={cn(
|
|
32
|
+
'grid shrink-0 auto-cols-fr grid-flow-col border-t border-outline-gray-2 bg-surface-elevation-2',
|
|
33
|
+
'[@media(display-mode:standalone)]:pb-[max(1rem,env(safe-area-inset-bottom))]',
|
|
34
|
+
local.class,
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
{local.children}
|
|
38
|
+
</nav>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MobileNavItemProps {
|
|
43
|
+
/** Label under the glyph; also the accessible name. */
|
|
44
|
+
label: string
|
|
45
|
+
/** Lucide icon name or component (P11). Replaced by `children`. */
|
|
46
|
+
icon?: IconProp
|
|
47
|
+
/** In-app destination. Renders an anchor; wins over `href`. */
|
|
48
|
+
route?: string
|
|
49
|
+
/** External URL. Renders an anchor when `route` is absent. */
|
|
50
|
+
href?: string
|
|
51
|
+
/** Highlight this tab. A state prop, independent of the current route. */
|
|
52
|
+
active?: boolean
|
|
53
|
+
/** Marks the item as the current page for assistive tech. */
|
|
54
|
+
'aria-current'?:
|
|
55
|
+
| 'page'
|
|
56
|
+
| 'step'
|
|
57
|
+
| 'location'
|
|
58
|
+
| 'date'
|
|
59
|
+
| 'time'
|
|
60
|
+
| 'true'
|
|
61
|
+
| 'false'
|
|
62
|
+
/** Merged into the item. */
|
|
63
|
+
class?: string
|
|
64
|
+
/** Custom content in place of the icon — an avatar, a badge. */
|
|
65
|
+
children?: JSX.Element
|
|
66
|
+
/** Click handler. Set it on button items, and on links to navigate in-app. */
|
|
67
|
+
onClick?: (event: MouseEvent) => void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One tab of a `MobileNav`: a glyph over a label, with an active highlight.
|
|
72
|
+
* Renders a real anchor when it has a `route`/`href` and a plain button
|
|
73
|
+
* otherwise. `active` drives the highlight through `data-state`; it is a state
|
|
74
|
+
* prop, not a theme, and may stay lit across a whole section.
|
|
75
|
+
*/
|
|
76
|
+
export function MobileNavItem(props: MobileNavItemProps): JSX.Element {
|
|
77
|
+
const [local, rest] = splitProps(props, [
|
|
78
|
+
'label',
|
|
79
|
+
'icon',
|
|
80
|
+
'route',
|
|
81
|
+
'href',
|
|
82
|
+
'active',
|
|
83
|
+
'class',
|
|
84
|
+
'children',
|
|
85
|
+
'onClick',
|
|
86
|
+
])
|
|
87
|
+
|
|
88
|
+
const destination = () => local.route ?? local.href
|
|
89
|
+
const isLink = () => Boolean(destination())
|
|
90
|
+
const isActive = () => Boolean(local.active)
|
|
91
|
+
const rootProps = () =>
|
|
92
|
+
isLink() ? { href: destination() } : { type: 'button' as const }
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Dynamic
|
|
96
|
+
component={isLink() ? 'a' : 'button'}
|
|
97
|
+
{...rootProps()}
|
|
98
|
+
{...rest}
|
|
99
|
+
data-slot="mobile-nav-item"
|
|
100
|
+
data-state={isActive() ? 'active' : 'inactive'}
|
|
101
|
+
onClick={local.onClick}
|
|
102
|
+
class={cn(
|
|
103
|
+
'flex min-h-14 flex-col items-center justify-center gap-1 py-2 text-ink-gray-6 transition',
|
|
104
|
+
'active:scale-95 data-[state=active]:bg-surface-blue-1 data-[state=active]:text-ink-blue-8',
|
|
105
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3',
|
|
106
|
+
local.class,
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
<Show
|
|
110
|
+
when={local.children}
|
|
111
|
+
fallback={<Icon icon={local.icon} class="size-6" />}
|
|
112
|
+
>
|
|
113
|
+
<span aria-hidden="true" class="contents">
|
|
114
|
+
{local.children}
|
|
115
|
+
</span>
|
|
116
|
+
</Show>
|
|
117
|
+
<span class="text-xs-medium leading-tighter">{local.label}</span>
|
|
118
|
+
</Dynamic>
|
|
119
|
+
)
|
|
120
|
+
}
|