@duro-app/ui 3.2.0 → 3.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@tanstack/react-virtual": "^3.14.6",
67
- "@duro-app/tokens": "^3.2.0"
67
+ "@duro-app/tokens": "^3.2.1"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@babel/preset-typescript": "^7.28.0",
@@ -1,6 +1,6 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect, fn} from 'storybook/test'
3
- import {useState} from 'react'
2
+ import {expect, fn, waitFor} from 'storybook/test'
3
+ import {StrictMode, useState} from 'react'
4
4
  import {css, html} from 'react-strict-dom'
5
5
  import {DragDrop, type DragDropEvent} from './DragDrop'
6
6
  import {Button} from '../Button/Button'
@@ -219,21 +219,42 @@ export const TouchBurstAfterHold: Story = {
219
219
  ),
220
220
  )
221
221
  }
222
- await new Promise((r) => requestAnimationFrame(() => r(null)))
223
- // Checkpoint: the burst alone must have activated the drag.
224
- await expect(canvas.getByRole('status')).toHaveTextContent('Picked up Noor.')
222
+ // Checkpoint: the burst alone must have activated the drag. The update
223
+ // came from a native event, so React flushes it on its own task.
224
+ await waitFor(() => expect(canvas.getByRole('status')).toHaveTextContent('Picked up Noor.'))
225
225
  document.dispatchEvent(new PointerEvent('pointerup', init(ex, ey, 0)))
226
226
  } finally {
227
227
  window.setTimeout = original
228
228
  }
229
- // The release came from a native event; React flushes it asynchronously.
230
- await new Promise((r) => setTimeout(r, 50))
231
- await expect(canvas.getByRole('status')).toHaveTextContent('Dropped Noor in Gates.')
229
+ await waitFor(() =>
230
+ expect(canvas.getByRole('status')).toHaveTextContent('Dropped Noor in Gates.'),
231
+ )
232
232
  await expect(args.onDrop).toHaveBeenCalledTimes(1)
233
233
  await expect(canvas.queryByRole('button', {name: 'Noor'})).toBeNull()
234
234
  },
235
235
  }
236
236
 
237
+ /** onDrop runs from the release handler, not from a state updater, so
238
+ * StrictMode's double-invoked updaters cannot fire it twice. */
239
+ export const StrictModeDropsOnce: Story = {
240
+ args: {onDrop: fn()},
241
+ render: (args) => (
242
+ <StrictMode>
243
+ <Board onDrop={args.onDrop as never} />
244
+ </StrictMode>
245
+ ),
246
+ play: async ({canvas, args}) => {
247
+ const marie = await canvas.findByRole('button', {name: 'Marie'})
248
+ const gatesZone = canvas.getByText('Drop a person here.')
249
+ await pointerDrag(marie.parentElement as Element, gatesZone)
250
+ await waitFor(() =>
251
+ expect(canvas.getByRole('status')).toHaveTextContent('Dropped Marie in Gates.'),
252
+ )
253
+ await expect(args.onDrop).toHaveBeenCalledTimes(1)
254
+ await expect(canvas.queryByRole('button', {name: 'Marie'})).toBeNull()
255
+ },
256
+ }
257
+
237
258
  /** A plain click on an item still reaches the button inside: no drag starts
238
259
  * under the movement threshold. */
239
260
  export const TapStaysAClick: Story = {
@@ -188,8 +188,12 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
188
188
  return null
189
189
  }, [])
190
190
 
191
+ // `target` is the resolved drop target (null cancels). The consumer's
192
+ // onDrop runs here, in the event handler, never inside a state updater:
193
+ // React may call an updater more than once and forbids updating another
194
+ // component from it.
191
195
  const finish = useCallback(
192
- (dropped: boolean) => {
196
+ (target: DragDropTarget | null) => {
193
197
  const s = session.current
194
198
  if (!s) return
195
199
  if (s.holdTimer) clearTimeout(s.holdTimer)
@@ -202,17 +206,14 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
202
206
  // already released
203
207
  }
204
208
  if (!s.active) return
205
- setDrag((current) => {
206
- const target = current?.over ?? null
207
- const item = items.current.get(s.id)
208
- if (dropped && target && item) {
209
- onDrop({item: {id: s.id, zone: item.zone, data: item.data as T}, target})
210
- say('drop', s.id, target.zone)
211
- } else {
212
- say('cancel', s.id)
213
- }
214
- return null
215
- })
209
+ const item = items.current.get(s.id)
210
+ setDrag(null)
211
+ if (target && item) {
212
+ onDrop({item: {id: s.id, zone: item.zone, data: item.data as T}, target})
213
+ say('drop', s.id, target.zone)
214
+ } else {
215
+ say('cancel', s.id)
216
+ }
216
217
  },
217
218
  [onDrop, say],
218
219
  )
@@ -295,7 +296,7 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
295
296
  activate(s, s.startX, s.startY)
296
297
  } else {
297
298
  // Moved before the hold elapsed: a flick, not a drag.
298
- if (moved > MOVE_THRESHOLD_PX) finish(false)
299
+ if (moved > MOVE_THRESHOLD_PX) finish(null)
299
300
  return
300
301
  }
301
302
  } else {
@@ -319,19 +320,15 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
319
320
  const s = session.current
320
321
  if (!s || e.pointerId !== s.pointerId) return
321
322
  // Resolve the target from the release point, not the last frame.
322
- if (s.active) {
323
- const over = locate(s.id, e.clientX, e.clientY)
324
- setDrag((current) => (current ? {...current, over} : current))
325
- }
326
- finish(true)
323
+ finish(s.active ? locate(s.id, e.clientX, e.clientY) : null)
327
324
  }
328
325
  const onCancel = (e: PointerEvent) => {
329
326
  const s = session.current
330
327
  if (!s || e.pointerId !== s.pointerId) return
331
- finish(false)
328
+ finish(null)
332
329
  }
333
330
  const onKey = (e: KeyboardEvent) => {
334
- if (e.key === 'Escape' && session.current) finish(false)
331
+ if (e.key === 'Escape' && session.current) finish(null)
335
332
  }
336
333
  document.addEventListener('pointermove', onMove)
337
334
  document.addEventListener('pointerup', onUp)