@talelk/newui-organisms 0.0.0-stage → 1.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.
Files changed (56) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +12 -2
  3. package/package.json +38 -3
  4. package/src/exports.test.ts +27 -0
  5. package/src/index.ts +1 -0
  6. package/src/organisms/AppSidebar/AppSidebar.test.tsx +100 -0
  7. package/src/organisms/AppSidebar/AppSidebar.tsx +214 -0
  8. package/src/organisms/AppSidebar/index.ts +1 -0
  9. package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
  10. package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
  11. package/src/organisms/AuthCard/AuthCard.tsx +80 -0
  12. package/src/organisms/AuthCard/index.ts +1 -0
  13. package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
  14. package/src/organisms/CommandPalette/CommandPalette.tsx +457 -0
  15. package/src/organisms/CommandPalette/index.ts +1 -0
  16. package/src/organisms/DataTable/DataTable.test.tsx +89 -0
  17. package/src/organisms/DataTable/DataTable.tsx +334 -0
  18. package/src/organisms/DataTable/index.ts +1 -0
  19. package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
  20. package/src/organisms/DetailHeader/DetailHeader.tsx +87 -0
  21. package/src/organisms/DetailHeader/index.ts +1 -0
  22. package/src/organisms/FilterBar/FilterBar.test.tsx +151 -0
  23. package/src/organisms/FilterBar/FilterBar.tsx +232 -0
  24. package/src/organisms/FilterBar/index.ts +1 -0
  25. package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
  26. package/src/organisms/KanbanBoard/KanbanBoard.tsx +550 -0
  27. package/src/organisms/KanbanBoard/drag.test.ts +448 -0
  28. package/src/organisms/KanbanBoard/drag.ts +407 -0
  29. package/src/organisms/KanbanBoard/index.ts +17 -0
  30. package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
  31. package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
  32. package/src/organisms/MobileNav/MobileNav.tsx +120 -0
  33. package/src/organisms/MobileNav/index.ts +6 -0
  34. package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
  35. package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
  36. package/src/organisms/PageToolbar/PageToolbar.tsx +77 -0
  37. package/src/organisms/PageToolbar/index.ts +1 -0
  38. package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
  39. package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
  40. package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
  41. package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
  42. package/src/organisms/SettingsDialog/SettingsDialog.tsx +539 -0
  43. package/src/organisms/SettingsDialog/index.ts +20 -0
  44. package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
  45. package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
  46. package/src/organisms/Sidebar/Sidebar.tsx +725 -0
  47. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
  48. package/src/organisms/Sidebar/index.ts +22 -0
  49. package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
  50. package/src/organisms/StatGrid/StatGrid.test.tsx +83 -0
  51. package/src/organisms/StatGrid/StatGrid.tsx +124 -0
  52. package/src/organisms/StatGrid/index.ts +1 -0
  53. package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
  54. package/src/organisms/VirtualList/VirtualList.tsx +79 -0
  55. package/src/organisms/VirtualList/index.ts +1 -0
  56. package/src/organisms/index.ts +13 -0
@@ -0,0 +1,448 @@
1
+ import { createRoot } from 'solid-js'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import {
4
+ createKanbanDrag,
5
+ type KanbanDrag,
6
+ type KanbanDragOptions,
7
+ type KanbanSlot,
8
+ } from './drag'
9
+
10
+ /**
11
+ * The gesture is tested against fabricated geometry, because that is the only
12
+ * way to test it at all: jsdom reports every box as a zero rect, so "is the
13
+ * pointer past this card's midpoint" would be unanswerable. Every element the
14
+ * engine touches is injected, so the boxes are just numbers here — the
15
+ * thresholds, the hit-test arithmetic, the auto-scroll edges and the drop
16
+ * payload are asserted directly rather than through a drag the harness cannot
17
+ * perform.
18
+ *
19
+ * The frame loop is stubbed rather than timed: `frame()` runs exactly one tick,
20
+ * so a test says how many frames it means instead of waiting for one.
21
+ */
22
+
23
+ interface Box {
24
+ left: number
25
+ top: number
26
+ width: number
27
+ height: number
28
+ }
29
+
30
+ function boxed(box: Box, cards: FakeCard[] = []): HTMLElement {
31
+ const node = document.createElement('div')
32
+ node.getBoundingClientRect = () => rect(box)
33
+ for (const card of cards) {
34
+ const inner = document.createElement('div')
35
+ inner.setAttribute('data-kanban-card', '')
36
+ inner.setAttribute('data-kanban-id', card.id)
37
+ if (card.dragging) inner.setAttribute('data-dragging', '')
38
+ inner.getBoundingClientRect = () =>
39
+ rect({
40
+ left: box.left,
41
+ top: card.top,
42
+ width: box.width,
43
+ height: card.height,
44
+ })
45
+ node.append(inner)
46
+ }
47
+ return node
48
+ }
49
+
50
+ interface FakeCard {
51
+ id: string
52
+ top: number
53
+ height: number
54
+ dragging?: boolean
55
+ }
56
+
57
+ function rect(box: Box): DOMRect {
58
+ return {
59
+ x: box.left,
60
+ y: box.top,
61
+ left: box.left,
62
+ top: box.top,
63
+ right: box.left + box.width,
64
+ bottom: box.top + box.height,
65
+ width: box.width,
66
+ height: box.height,
67
+ toJSON: () => ({}),
68
+ } as DOMRect
69
+ }
70
+
71
+ /** jsdom's `scrollLeft`/`scrollTop` never move without layout; make them fields. */
72
+ function scrollable(node: HTMLElement): HTMLElement {
73
+ Object.defineProperty(node, 'scrollLeft', { value: 0, writable: true })
74
+ Object.defineProperty(node, 'scrollTop', { value: 0, writable: true })
75
+ return node
76
+ }
77
+
78
+ /** A `pointerdown`-shaped event: jsdom has no PointerEvent constructor. */
79
+ function pointer(
80
+ type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
81
+ options: { x: number; y: number; pointerType?: string; button?: number },
82
+ // `window` too: the engine listens for the move and the release there.
83
+ target?: EventTarget,
84
+ ): PointerEvent {
85
+ const event = new MouseEvent(type, {
86
+ clientX: options.x,
87
+ clientY: options.y,
88
+ button: options.button ?? 0,
89
+ bubbles: true,
90
+ }) as unknown as PointerEvent
91
+ Object.defineProperty(event, 'pointerType', {
92
+ value: options.pointerType ?? 'mouse',
93
+ })
94
+ target?.dispatchEvent(event)
95
+ return event
96
+ }
97
+
98
+ interface Harness extends KanbanDragOptions<{ id: string }> {
99
+ drag: KanbanDrag<{ id: string }>
100
+ previews: { itemId: string; slot: KanbanSlot }[]
101
+ drops: { item: { id: string }; from: KanbanSlot; to: KanbanSlot }[]
102
+ cancels: number
103
+ board: HTMLElement
104
+ column: HTMLElement
105
+ card: HTMLElement
106
+ frame: () => void
107
+ press: (at?: { x: number; y: number }, pointerType?: string) => void
108
+ move: (at: { x: number; y: number }) => void
109
+ up: (at?: { x: number; y: number }) => void
110
+ dispose: () => void
111
+ }
112
+
113
+ function harness(overrides: Partial<KanbanDragOptions<{ id: string }>> = {}) {
114
+ let tick: FrameRequestCallback | undefined
115
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
116
+ tick = callback
117
+ return 1
118
+ })
119
+ vi.stubGlobal('cancelAnimationFrame', () => {
120
+ tick = undefined
121
+ })
122
+
123
+ const board = scrollable(boxed({ left: 0, top: 0, width: 1200, height: 600 }))
124
+ // The board holds the slot the ghost lands on: the element the component
125
+ // marks while a card is in flight.
126
+ board.append(
127
+ boxed({ left: 16, top: 300, width: 288, height: 100 }, [
128
+ { id: 'slot', top: 300, height: 100, dragging: true },
129
+ ]),
130
+ )
131
+ const column = scrollable(
132
+ boxed({ left: 16, top: 60, width: 288, height: 520 }, [
133
+ { id: 'a', top: 80, height: 100 },
134
+ { id: 'b', top: 190, height: 100 },
135
+ { id: 'c', top: 300, height: 100 },
136
+ ]),
137
+ )
138
+ const won = scrollable(boxed({ left: 316, top: 60, width: 288, height: 520 }))
139
+
140
+ /** The grabbed card's own box: the ghost and the slot both match it. */
141
+ const card = boxed({ left: 16, top: 80, width: 288, height: 100 })
142
+
143
+ const test = {
144
+ board,
145
+ column,
146
+ card,
147
+ previews: [] as Harness['previews'],
148
+ drops: [] as Harness['drops'],
149
+ cancels: 0,
150
+ } as Harness
151
+
152
+ /**
153
+ * Where the item sits, as the board would answer it: the preview *is* the
154
+ * placement once reported, which is what makes the engine's "has it actually
155
+ * changed?" check meaningful instead of always true.
156
+ */
157
+ let current: KanbanSlot = { column: 'open', index: 0 }
158
+
159
+ test.frame = () => {
160
+ const current = tick
161
+ tick = undefined
162
+ current?.(0)
163
+ }
164
+ test.press = (at = { x: 100, y: 100 }, pointerType = 'mouse') => {
165
+ const event = pointer('pointerdown', { ...at, pointerType }, test.card)
166
+ test.drag.start(event, { id: 'a' }, test.card)
167
+ }
168
+ test.move = (at) => pointer('pointermove', { ...at }, window)
169
+ test.up = (at = { x: 0, y: 0 }) => pointer('pointerup', { ...at }, window)
170
+
171
+ createRoot((dispose) => {
172
+ test.dispose = dispose
173
+ test.drag = createKanbanDrag<{ id: string }>({
174
+ container: () => board,
175
+ columnScroller: (id) => (id === 'open' ? column : won),
176
+ columnIds: () => ['open', 'won'],
177
+ slotOf: () => current,
178
+ onPreview: (itemId, slot) => {
179
+ current = slot
180
+ test.previews.push({ itemId, slot })
181
+ },
182
+ onDrop: (item, from, to) => test.drops.push({ item, from, to }),
183
+ onCancel: () => {
184
+ test.cancels += 1
185
+ },
186
+ ...overrides,
187
+ })
188
+ })
189
+
190
+ return test
191
+ }
192
+
193
+ /** Press, then drift past the mouse threshold: the card is in the air. */
194
+ function lift(test: Harness, at = { x: 100, y: 100 }) {
195
+ test.press(at)
196
+ test.move({ x: at.x + 6, y: at.y })
197
+ }
198
+
199
+ /** Run the settle timer the release started. */
200
+ const drop = () => vi.advanceTimersByTime(1000)
201
+
202
+ describe('createKanbanDrag', () => {
203
+ beforeEach(() => {
204
+ vi.useFakeTimers()
205
+ })
206
+ afterEach(() => {
207
+ vi.useRealTimers()
208
+ vi.unstubAllGlobals()
209
+ })
210
+
211
+ it('does not lift a card on a press that never moves', () => {
212
+ const test = harness()
213
+ test.press()
214
+ test.up({ x: 100, y: 100 })
215
+
216
+ expect(test.drag.state()).toBeUndefined()
217
+ expect(test.drops).toEqual([])
218
+ expect(test.cancels).toBe(0)
219
+ test.dispose()
220
+ })
221
+
222
+ it('lifts once the mouse travels past the threshold', () => {
223
+ const test = harness()
224
+ test.press()
225
+ test.move({ x: 103, y: 100 })
226
+ expect(test.drag.state()).toBeUndefined()
227
+
228
+ test.move({ x: 106, y: 100 })
229
+ expect(test.drag.state()?.itemId).toBe('a')
230
+ // The ghost starts exactly on the card it replaced.
231
+ expect(test.drag.state()?.x).toBe(16)
232
+ expect(test.drag.state()?.y).toBe(80)
233
+ expect(test.drag.state()?.width).toBe(288)
234
+ expect(test.drag.state()?.height).toBe(100)
235
+ test.dispose()
236
+ })
237
+
238
+ it('reads a drifting finger as a scroll, and a still one as a grab', () => {
239
+ const drifting = harness()
240
+ drifting.press({ x: 100, y: 100 }, 'touch')
241
+ drifting.move({ x: 130, y: 100 })
242
+ vi.advanceTimersByTime(400)
243
+ expect(drifting.drag.state()).toBeUndefined()
244
+ drifting.dispose()
245
+
246
+ const holding = harness()
247
+ holding.press({ x: 100, y: 100 }, 'touch')
248
+ vi.advanceTimersByTime(400)
249
+ expect(holding.drag.state()?.itemId).toBe('a')
250
+ holding.dispose()
251
+ })
252
+
253
+ it('knows which item is in the air', () => {
254
+ const test = harness()
255
+ lift(test)
256
+ expect(test.drag.isDragging('a')).toBe(true)
257
+ expect(test.drag.isDragging('b')).toBe(false)
258
+ test.dispose()
259
+ })
260
+
261
+ it('leaves a press that starts on a control inside the card alone', () => {
262
+ const test = harness()
263
+ const button = document.createElement('button')
264
+ const event = pointer('pointerdown', { x: 100, y: 100 }, button)
265
+ test.drag.start(event, { id: 'a' }, test.card)
266
+ test.move({ x: 160, y: 100 })
267
+
268
+ expect(test.drag.state()).toBeUndefined()
269
+ expect(test.cancels).toBe(0)
270
+ test.dispose()
271
+ })
272
+
273
+ it('counts the insertion index over the other cards only', () => {
274
+ const test = harness()
275
+ lift(test)
276
+
277
+ // The item is already at the top, so a frame over that spot changes nothing
278
+ // and the board is not asked to re-render.
279
+ test.frame()
280
+ expect(test.previews).toEqual([])
281
+
282
+ // Past the first card's midpoint (80 + 100 / 2 = 130), and nothing else's.
283
+ test.move({ x: 100, y: 140 })
284
+ test.frame()
285
+ expect(test.previews.at(-1)?.slot).toEqual({ column: 'open', index: 1 })
286
+
287
+ // Past the second's (240), not the third's (350).
288
+ test.move({ x: 100, y: 250 })
289
+ test.frame()
290
+ expect(test.previews.at(-1)?.slot).toEqual({ column: 'open', index: 2 })
291
+
292
+ // And back above them all.
293
+ test.move({ x: 100, y: 70 })
294
+ test.frame()
295
+ expect(test.previews.at(-1)?.slot).toEqual({ column: 'open', index: 0 })
296
+ test.dispose()
297
+ })
298
+
299
+ it('reports a slot once, not once per frame', () => {
300
+ const test = harness()
301
+ lift(test)
302
+ test.move({ x: 100, y: 140 })
303
+ test.frame()
304
+ test.frame()
305
+ test.frame()
306
+ // The first frame crossed into a new slot; the next two did not.
307
+ expect(test.previews).toEqual([
308
+ { itemId: 'a', slot: { column: 'open', index: 1 } },
309
+ ])
310
+ test.dispose()
311
+ })
312
+
313
+ it('previews nothing while the pointer is in the gap between columns', () => {
314
+ const test = harness()
315
+ lift(test)
316
+ test.move({ x: 300, y: 100 })
317
+ test.frame()
318
+
319
+ // 300 is past the first column's right edge (304 would be inside it) and
320
+ // short of the second's left one (316): neither column is a target.
321
+ expect(test.previews).toEqual([])
322
+ test.dispose()
323
+ })
324
+
325
+ it('moves the ghost with the pointer, holding where it was grabbed', () => {
326
+ const test = harness()
327
+ lift(test)
328
+ // Grabbed 90px in from the card's left edge, 20px from its top.
329
+ test.frame()
330
+ expect(test.drag.state()?.x).toBe(16)
331
+ expect(test.drag.state()?.y).toBe(80)
332
+
333
+ test.move({ x: 400, y: 260 })
334
+ test.frame()
335
+ expect(test.drag.state()?.x).toBe(310)
336
+ expect(test.drag.state()?.y).toBe(240)
337
+ test.dispose()
338
+ })
339
+
340
+ it('auto-scrolls the board and the column at their edges', () => {
341
+ const board = scrollable(
342
+ boxed({ left: 0, top: 0, width: 600, height: 600 }),
343
+ )
344
+ board.append(
345
+ boxed({ left: 16, top: 300, width: 288, height: 100 }, [
346
+ { id: 'slot', top: 300, height: 100, dragging: true },
347
+ ]),
348
+ )
349
+ const column = scrollable(
350
+ boxed({ left: 16, top: 60, width: 288, height: 520 }, [
351
+ { id: 'a', top: 80, height: 100 },
352
+ ]),
353
+ )
354
+ const test = harness({
355
+ container: () => board,
356
+ columnScroller: () => column,
357
+ })
358
+
359
+ // Top-left: both scrollers drift back.
360
+ test.press({ x: 30, y: 100 })
361
+ test.move({ x: 36, y: 100 })
362
+ test.frame()
363
+ expect(board.scrollLeft).toBeLessThan(0)
364
+ expect(column.scrollTop).toBeLessThan(0)
365
+
366
+ // Bottom-right: both drift forward.
367
+ test.move({ x: 590, y: 560 })
368
+ test.frame()
369
+ expect(board.scrollLeft).toBeGreaterThan(0)
370
+ expect(column.scrollTop).toBeGreaterThan(0)
371
+ test.dispose()
372
+ })
373
+
374
+ it('reports the drop once, with where the gesture started', () => {
375
+ const test = harness()
376
+ test.press({ x: 100, y: 70 })
377
+ test.move({ x: 400, y: 300 })
378
+ test.frame()
379
+ test.up({ x: 400, y: 300 })
380
+ drop()
381
+
382
+ expect(test.drops).toHaveLength(1)
383
+ expect(test.drops[0]?.item).toEqual({ id: 'a' })
384
+ expect(test.drops[0]?.from).toEqual({ column: 'open', index: 0 })
385
+ expect(test.drops[0]?.to).toEqual({ column: 'won', index: 0 })
386
+ expect(test.drag.state()).toBeUndefined()
387
+
388
+ // The gesture is over: a second release is not a second move.
389
+ test.up({ x: 400, y: 300 })
390
+ expect(test.drops).toHaveLength(1)
391
+ test.dispose()
392
+ })
393
+
394
+ it('cancels when the card is put back where it started', () => {
395
+ const test = harness()
396
+ lift(test)
397
+ test.up({ x: 106, y: 100 })
398
+ drop()
399
+
400
+ expect(test.drops).toEqual([])
401
+ expect(test.cancels).toBe(1)
402
+ expect(test.drag.state()).toBeUndefined()
403
+ test.dispose()
404
+ })
405
+
406
+ it('cancels on Escape, and when the browser takes the gesture back', () => {
407
+ const escaped = harness()
408
+ lift(escaped)
409
+ escaped.drag.cancel()
410
+ expect(escaped.cancels).toBe(1)
411
+ expect(escaped.drag.state()).toBeUndefined()
412
+ escaped.dispose()
413
+
414
+ const cancelled = harness()
415
+ lift(cancelled)
416
+ pointer('pointercancel', { x: 106, y: 100 }, window)
417
+ expect(cancelled.cancels).toBe(1)
418
+ expect(cancelled.drag.state()).toBeUndefined()
419
+ cancelled.dispose()
420
+ })
421
+
422
+ it('takes every listener and timer back when it is disposed', () => {
423
+ const added = vi.spyOn(window, 'addEventListener')
424
+ const removed = vi.spyOn(window, 'removeEventListener')
425
+ const cleared = vi.spyOn(window, 'clearTimeout')
426
+
427
+ const test = harness()
428
+ test.press({ x: 100, y: 100 }, 'touch')
429
+ test.move({ x: 130, y: 100 })
430
+ test.dispose()
431
+
432
+ expect(removed.mock.calls.map((call) => call[0]).sort()).toEqual(
433
+ added.mock.calls.map((call) => call[0]).sort(),
434
+ )
435
+ expect(cleared).toHaveBeenCalled()
436
+ expect(test.drag.state()).toBeUndefined()
437
+ })
438
+
439
+ it('survives a press on an item the board no longer has', () => {
440
+ const test = harness({ slotOf: () => undefined })
441
+ lift(test)
442
+
443
+ expect(test.drag.state()).toBeUndefined()
444
+ test.up({ x: 106, y: 100 })
445
+ expect(test.cancels).toBe(0)
446
+ test.dispose()
447
+ })
448
+ })