@dreamboard-games/sdk 0.2.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.md +96 -0
- package/README.md +12 -0
- package/dist/HandView-ncJIVLhN.d.ts +193 -0
- package/dist/ResourceCounter-CTREyF73.d.ts +102 -0
- package/dist/ThemeProvider-fy0_QzgO.d.ts +99 -0
- package/dist/bundle-TIZcw8LB.d.ts +281 -0
- package/dist/cards-Sl3b40Mv.d.ts +13 -0
- package/dist/chunk-7YAHLYBR.js +481 -0
- package/dist/chunk-7YAHLYBR.js.map +1 -0
- package/dist/chunk-FDNZTDD6.js +8085 -0
- package/dist/chunk-FDNZTDD6.js.map +1 -0
- package/dist/chunk-GKKBPPSW.js +598 -0
- package/dist/chunk-GKKBPPSW.js.map +1 -0
- package/dist/chunk-I46YJSOD.js +1 -0
- package/dist/chunk-I46YJSOD.js.map +1 -0
- package/dist/chunk-KAELH4KC.js +104 -0
- package/dist/chunk-KAELH4KC.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-T3ZKNUZ7.js +1 -0
- package/dist/chunk-T3ZKNUZ7.js.map +1 -0
- package/dist/chunk-T52J5RMF.js +1 -0
- package/dist/chunk-T52J5RMF.js.map +1 -0
- package/dist/chunk-TDSWKVZ4.js +5401 -0
- package/dist/chunk-TDSWKVZ4.js.map +1 -0
- package/dist/chunk-U5C6BONG.js +34 -0
- package/dist/chunk-U5C6BONG.js.map +1 -0
- package/dist/chunk-VDXOF4FW.js +69 -0
- package/dist/chunk-VDXOF4FW.js.map +1 -0
- package/dist/chunk-VFTAA4WO.js +115 -0
- package/dist/chunk-VFTAA4WO.js.map +1 -0
- package/dist/chunk-WN74KVNY.js +17 -0
- package/dist/chunk-WN74KVNY.js.map +1 -0
- package/dist/chunk-WYPQ3GG5.js +10990 -0
- package/dist/chunk-WYPQ3GG5.js.map +1 -0
- package/dist/components-D5ZRE2Hl.d.ts +1451 -0
- package/dist/generated/runtime/primitives.d.ts +12 -0
- package/dist/generated/runtime/primitives.js +180 -0
- package/dist/generated/runtime/primitives.js.map +1 -0
- package/dist/generated/runtime-api.d.ts +3 -0
- package/dist/generated/runtime-api.js +2 -0
- package/dist/generated/runtime-api.js.map +1 -0
- package/dist/generated/runtime.d.ts +14 -0
- package/dist/generated/runtime.js +18 -0
- package/dist/generated/runtime.js.map +1 -0
- package/dist/generated/workspace-contract.d.ts +14 -0
- package/dist/generated/workspace-contract.js +14 -0
- package/dist/generated/workspace-contract.js.map +1 -0
- package/dist/hex-board-view-D_07hO6O.d.ts +933 -0
- package/dist/hex-color-MhOyuY-o.d.ts +8 -0
- package/dist/index-BwqPQtBu.d.ts +1433 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/infrastructure/reducer-bundle-abi.d.ts +1083 -0
- package/dist/infrastructure/reducer-bundle-abi.js +14 -0
- package/dist/infrastructure/reducer-bundle-abi.js.map +1 -0
- package/dist/infrastructure/workspace-codegen.d.ts +53 -0
- package/dist/infrastructure/workspace-codegen.js +44 -0
- package/dist/infrastructure/workspace-codegen.js.map +1 -0
- package/dist/manifest-contract-BNHVGFtU.d.ts +9 -0
- package/dist/package-set.d.ts +13 -0
- package/dist/package-set.js +12 -0
- package/dist/package-set.js.map +1 -0
- package/dist/primitive-props-DpKs-GCr.d.ts +11 -0
- package/dist/reducer.d.ts +3786 -0
- package/dist/reducer.js +8131 -0
- package/dist/reducer.js.map +1 -0
- package/dist/runtime/primitives.d.ts +226 -0
- package/dist/runtime/primitives.js +180 -0
- package/dist/runtime/primitives.js.map +1 -0
- package/dist/runtime/types/runtime-api.d.ts +1 -0
- package/dist/runtime/types/runtime-api.js +2 -0
- package/dist/runtime/types/runtime-api.js.map +1 -0
- package/dist/runtime/workspace-contract.d.ts +172 -0
- package/dist/runtime/workspace-contract.js +14 -0
- package/dist/runtime/workspace-contract.js.map +1 -0
- package/dist/runtime-api-3dshj6kK.d.ts +101 -0
- package/dist/runtime-api-DWxvTr-O.d.ts +379 -0
- package/dist/runtime.d.ts +58 -0
- package/dist/runtime.js +13 -0
- package/dist/runtime.js.map +1 -0
- package/dist/slots-1GPGihk8.d.ts +8 -0
- package/dist/testing.d.ts +149 -0
- package/dist/testing.js +513 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +496 -0
- package/dist/types.js +28 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/components.d.ts +16 -0
- package/dist/ui/components.js +192 -0
- package/dist/ui/components.js.map +1 -0
- package/dist/ui/defaults.d.ts +19 -0
- package/dist/ui/defaults.js +104 -0
- package/dist/ui/defaults.js.map +1 -0
- package/dist/ui/plugin-styles.css +250 -0
- package/dist/ui/types/player-state.d.ts +365 -0
- package/dist/ui/types/player-state.js +1 -0
- package/dist/ui/types/player-state.js.map +1 -0
- package/dist/ui-contract-iQfTtUSL.d.ts +1161 -0
- package/dist/ui.d.ts +320 -0
- package/dist/ui.js +253 -0
- package/dist/ui.js.map +1 -0
- package/package.json +199 -0
- package/src/generated/reducer-contract/builders.ts +90 -0
- package/src/generated/reducer-contract/version.ts +9 -0
- package/src/generated/reducer-contract/wire.ts +100 -0
- package/src/generated/reducer-contract/zod.ts +101 -0
- package/src/generated/runtime/primitives.ts +2 -0
- package/src/generated/runtime-api.ts +5 -0
- package/src/generated/runtime.ts +35 -0
- package/src/generated/workspace-contract.ts +2 -0
- package/src/index.ts +7 -0
- package/src/infrastructure/reducer-bundle-abi.ts +8 -0
- package/src/infrastructure/reducer-contract/bundle.ts +37 -0
- package/src/infrastructure/workspace-codegen/hex-geometry.ts +69 -0
- package/src/infrastructure/workspace-codegen/index.ts +64 -0
- package/src/infrastructure/workspace-codegen/manifest-contract.ts +6632 -0
- package/src/infrastructure/workspace-codegen/manifest-validation.ts +795 -0
- package/src/infrastructure/workspace-codegen/ownership.ts +131 -0
- package/src/infrastructure/workspace-codegen/preset-card-sets.ts +169 -0
- package/src/infrastructure/workspace-codegen/seeds.ts +1705 -0
- package/src/infrastructure/workspace-codegen.ts +1 -0
- package/src/package-set.ts +19 -0
- package/src/reducer/authoring/contract.ts +157 -0
- package/src/reducer/authoring/effect.ts +224 -0
- package/src/reducer/authoring/game.ts +23 -0
- package/src/reducer/authoring/interaction.ts +98 -0
- package/src/reducer/authoring/phase.ts +300 -0
- package/src/reducer/authoring/types.ts +70 -0
- package/src/reducer/authoring/validation.ts +382 -0
- package/src/reducer/authoring/view-stage.ts +68 -0
- package/src/reducer/authoring.ts +29 -0
- package/src/reducer/bundle/ingress-bundle.ts +491 -0
- package/src/reducer/bundle/trusted/engine-instruction-resolver.ts +254 -0
- package/src/reducer/bundle/trusted/flow-instruction-resolver.ts +73 -0
- package/src/reducer/bundle/trusted/instruction-runner.ts +414 -0
- package/src/reducer/bundle/trusted/interaction-authorization.ts +137 -0
- package/src/reducer/bundle/trusted/interaction-collectors.ts +859 -0
- package/src/reducer/bundle/trusted/interaction-decision.ts +747 -0
- package/src/reducer/bundle/trusted/interaction-resolver.ts +95 -0
- package/src/reducer/bundle/trusted/interaction-types.ts +171 -0
- package/src/reducer/bundle/trusted/lifecycle-runner.ts +427 -0
- package/src/reducer/bundle/trusted/projection-builder.ts +356 -0
- package/src/reducer/bundle/trusted/projection-context.ts +39 -0
- package/src/reducer/bundle/trusted/rng-sampler.ts +150 -0
- package/src/reducer/bundle/trusted/runtime-registry.ts +120 -0
- package/src/reducer/bundle/trusted/runtime-scope.ts +336 -0
- package/src/reducer/bundle/trusted/simultaneous-player.ts +97 -0
- package/src/reducer/bundle/trusted/stage-resolver.ts +87 -0
- package/src/reducer/bundle/trusted/static-projection.ts +116 -0
- package/src/reducer/bundle/trusted/trusted-runtime-args.ts +97 -0
- package/src/reducer/bundle/trusted/trusted-runtime-result.ts +39 -0
- package/src/reducer/bundle/trusted/trusted-setup-profiles.ts +43 -0
- package/src/reducer/bundle/trusted/trusted-state-codec.ts +48 -0
- package/src/reducer/bundle/trusted-bundle.ts +97 -0
- package/src/reducer/bundle/types.ts +171 -0
- package/src/reducer/bundle.ts +2 -0
- package/src/reducer/client-param-schemas.ts +57 -0
- package/src/reducer/compose.ts +34 -0
- package/src/reducer/core/runtime-input.ts +30 -0
- package/src/reducer/core/runtime-instruction.ts +59 -0
- package/src/reducer/core/types.ts +62 -0
- package/src/reducer/definition-index.ts +277 -0
- package/src/reducer/derived.ts +106 -0
- package/src/reducer/effects.ts +92 -0
- package/src/reducer/engine/runtime-instruction-engine.ts +155 -0
- package/src/reducer/ingress/decode-runtime-input.ts +7 -0
- package/src/reducer/ingress/decode-session-state.ts +9 -0
- package/src/reducer/ingress/encode-session-state.ts +6 -0
- package/src/reducer/ingress/input-codec.ts +18 -0
- package/src/reducer/ingress/phase-schemas.ts +62 -0
- package/src/reducer/ingress/raw-types.ts +107 -0
- package/src/reducer/ingress/runtime-codec.ts +14 -0
- package/src/reducer/ingress/runtime-payload.ts +13 -0
- package/src/reducer/ingress/session-codec.ts +392 -0
- package/src/reducer/ingress/types.ts +6 -0
- package/src/reducer/inputs/boardInput.ts +217 -0
- package/src/reducer/inputs/boardTarget.ts +190 -0
- package/src/reducer/inputs/cardInput.ts +86 -0
- package/src/reducer/inputs/cardTarget.ts +101 -0
- package/src/reducer/inputs/choiceTarget.ts +104 -0
- package/src/reducer/inputs/defineInputs.ts +71 -0
- package/src/reducer/inputs/formInput.ts +809 -0
- package/src/reducer/inputs/many.ts +120 -0
- package/src/reducer/inputs/promptInput.ts +87 -0
- package/src/reducer/inputs/rngInput.ts +58 -0
- package/src/reducer/inputs/targetRule.ts +123 -0
- package/src/reducer/inputs.ts +41 -0
- package/src/reducer/model/definition.ts +1072 -0
- package/src/reducer/model/extract.ts +745 -0
- package/src/reducer/model/manifest.ts +570 -0
- package/src/reducer/model/queries.ts +641 -0
- package/src/reducer/model/runtime.ts +264 -0
- package/src/reducer/model/spec.ts +1386 -0
- package/src/reducer/model/table.ts +260 -0
- package/src/reducer/model.ts +7 -0
- package/src/reducer/ops.ts +1034 -0
- package/src/reducer/parse-utils.ts +28 -0
- package/src/reducer/per-player.ts +422 -0
- package/src/reducer/rng.ts +69 -0
- package/src/reducer/schema-helpers.ts +185 -0
- package/src/reducer/setup-bootstrap-helpers.ts +171 -0
- package/src/reducer/setup-bootstrap.ts +481 -0
- package/src/reducer/table-ops.ts +2671 -0
- package/src/reducer/table-queries.ts +372 -0
- package/src/reducer/transaction.ts +120 -0
- package/src/reducer.ts +314 -0
- package/src/runtime/primitives.ts +1 -0
- package/src/runtime/types/runtime-api.ts +1 -0
- package/src/runtime/workspace-contract.ts +32 -0
- package/src/runtime-internal/components/InteractionForm.tsx +1309 -0
- package/src/runtime-internal/components/PluginRuntime.tsx +103 -0
- package/src/runtime-internal/components/board/target-layer.ts +70 -0
- package/src/runtime-internal/context/ClientParamSchemaContext.tsx +44 -0
- package/src/runtime-internal/context/InteractionDraftContext.tsx +279 -0
- package/src/runtime-internal/context/PluginSessionContext.tsx +47 -0
- package/src/runtime-internal/context/PluginStateContext.tsx +262 -0
- package/src/runtime-internal/context/RuntimeContext.tsx +96 -0
- package/src/runtime-internal/defaults/components.tsx +409 -0
- package/src/runtime-internal/defaults/index.ts +11 -0
- package/src/runtime-internal/errors/ValidationError.ts +29 -0
- package/src/runtime-internal/hooks/useActivePlayers.ts +33 -0
- package/src/runtime-internal/hooks/useBoardInteractions.ts +665 -0
- package/src/runtime-internal/hooks/useGameSelector.ts +105 -0
- package/src/runtime-internal/hooks/useGameView.ts +9 -0
- package/src/runtime-internal/hooks/useInteractionByKey.ts +354 -0
- package/src/runtime-internal/hooks/useInteractionHandle.ts +438 -0
- package/src/runtime-internal/hooks/useIsMyTurn.ts +20 -0
- package/src/runtime-internal/hooks/useLobby.ts +76 -0
- package/src/runtime-internal/hooks/useMe.ts +48 -0
- package/src/runtime-internal/hooks/usePlayerInfo.ts +28 -0
- package/src/runtime-internal/hooks/usePlayerTurnOrder.ts +23 -0
- package/src/runtime-internal/hooks/usePluginRuntime.ts +147 -0
- package/src/runtime-internal/hooks/useSeatInbox.ts +61 -0
- package/src/runtime-internal/hooks/useSimultaneousPhase.ts +10 -0
- package/src/runtime-internal/index.ts +42 -0
- package/src/runtime-internal/internal.ts +43 -0
- package/src/runtime-internal/plugin-styles.css +250 -0
- package/src/runtime-internal/primitives/board.tsx +459 -0
- package/src/runtime-internal/primitives/dialog-lifecycle.ts +58 -0
- package/src/runtime-internal/primitives/dice.tsx +79 -0
- package/src/runtime-internal/primitives/game-ui-provider.tsx +35 -0
- package/src/runtime-internal/primitives/game.tsx +387 -0
- package/src/runtime-internal/primitives/hand-intent-adapter.ts +147 -0
- package/src/runtime-internal/primitives/hand-surface.tsx +594 -0
- package/src/runtime-internal/primitives/index.ts +196 -0
- package/src/runtime-internal/primitives/interaction-form-binding.tsx +56 -0
- package/src/runtime-internal/primitives/interaction-submit.ts +90 -0
- package/src/runtime-internal/primitives/interaction.tsx +987 -0
- package/src/runtime-internal/primitives/phase.tsx +43 -0
- package/src/runtime-internal/primitives/player-roster.tsx +302 -0
- package/src/runtime-internal/primitives/primitive-props.tsx +101 -0
- package/src/runtime-internal/primitives/prompt.tsx +255 -0
- package/src/runtime-internal/primitives/ui.tsx +60 -0
- package/src/runtime-internal/primitives/zone.tsx +791 -0
- package/src/runtime-internal/reducer.ts +30 -0
- package/src/runtime-internal/runtime/createPluginRuntimeAPI.ts +605 -0
- package/src/runtime-internal/types/plugin-state.ts +508 -0
- package/src/runtime-internal/types/reducer-state.ts +24 -0
- package/src/runtime-internal/types/runtime-api.ts +114 -0
- package/src/runtime-internal/ui-contract.ts +519 -0
- package/src/runtime-internal/utils/card-intent-adapter.ts +546 -0
- package/src/runtime-internal/utils/interaction-inputs.ts +492 -0
- package/src/runtime-internal/utils/interaction-labels.ts +23 -0
- package/src/runtime-internal/utils/interaction-router.ts +273 -0
- package/src/runtime-internal/utils/interaction-status.ts +74 -0
- package/src/runtime-internal/workspace-contract.ts +1170 -0
- package/src/runtime.ts +34 -0
- package/src/testing/create-expect-api.ts +352 -0
- package/src/testing/create-test-runtime.ts +381 -0
- package/src/testing/definitions.ts +127 -0
- package/src/testing/index.ts +3 -0
- package/src/testing.ts +1 -0
- package/src/type-stubs/manifest-contract.d.ts +42 -0
- package/src/type-stubs/manifest-contract.js +72 -0
- package/src/type-stubs/ui-contract.d.ts +5 -0
- package/src/type-stubs/ui-contract.js +1 -0
- package/src/types/authoring-card-properties.type-test.ts +266 -0
- package/src/types/authoring.ts +1282 -0
- package/src/types/cards.ts +19 -0
- package/src/types/contracts.ts +1550 -0
- package/src/types/generated-helpers.ts +35 -0
- package/src/types/index.ts +147 -0
- package/src/types/slots.ts +11 -0
- package/src/types.ts +1 -0
- package/src/ui/components/ActionButton.tsx +97 -0
- package/src/ui/components/ActionPanel.tsx +315 -0
- package/src/ui/components/Card.tsx +378 -0
- package/src/ui/components/CardDragSurface.tsx +1076 -0
- package/src/ui/components/ChromeSuppressionContext.tsx +70 -0
- package/src/ui/components/CostDisplay.tsx +145 -0
- package/src/ui/components/DiceRoller.tsx +581 -0
- package/src/ui/components/Drawer.tsx +180 -0
- package/src/ui/components/ErrorBoundary.tsx +275 -0
- package/src/ui/components/GameEndDisplay.tsx +398 -0
- package/src/ui/components/GameSkeleton.tsx +260 -0
- package/src/ui/components/Hand.tsx +468 -0
- package/src/ui/components/HandDock.tsx +299 -0
- package/src/ui/components/HandView.tsx +441 -0
- package/src/ui/components/MobileHandTray.tsx +381 -0
- package/src/ui/components/MoreActions.tsx +143 -0
- package/src/ui/components/PhaseIndicator.tsx +341 -0
- package/src/ui/components/PlayArea.tsx +146 -0
- package/src/ui/components/PrimaryActionButton.tsx +336 -0
- package/src/ui/components/PrimaryButton.tsx +45 -0
- package/src/ui/components/ResourceCounter.tsx +270 -0
- package/src/ui/components/StagingZone.tsx +134 -0
- package/src/ui/components/ThemedButton.tsx +113 -0
- package/src/ui/components/Toast.tsx +264 -0
- package/src/ui/components/board/HexGrid.tsx +1294 -0
- package/src/ui/components/board/NetworkGraph.tsx +476 -0
- package/src/ui/components/board/SlotSystem.tsx +388 -0
- package/src/ui/components/board/SquareGrid.tsx +1165 -0
- package/src/ui/components/board/TrackBoard.tsx +496 -0
- package/src/ui/components/board/ZoneMap.tsx +448 -0
- package/src/ui/components/board/hex-board-view.ts +123 -0
- package/src/ui/components/board/index.ts +142 -0
- package/src/ui/components/board/interaction-accessibility.ts +21 -0
- package/src/ui/components/board/target-layer.ts +66 -0
- package/src/ui/components/card-render-content.type-test.ts +27 -0
- package/src/ui/components/hand-layout-math.ts +163 -0
- package/src/ui/components/hand-pointer-engine.ts +413 -0
- package/src/ui/components/index.ts +245 -0
- package/src/ui/components.ts +1 -0
- package/src/ui/defaults/components.tsx +106 -0
- package/src/ui/defaults/index.ts +8 -0
- package/src/ui/defaults.ts +1 -0
- package/src/ui/errors/ValidationError.ts +29 -0
- package/src/ui/helpers/cards.ts +19 -0
- package/src/ui/helpers/track-board.ts +211 -0
- package/src/ui/hooks/useBoardTopology.ts +316 -0
- package/src/ui/hooks/useCards.ts +10 -0
- package/src/ui/hooks/useHandCardPointer.ts +381 -0
- package/src/ui/hooks/useHandLayout.ts +378 -0
- package/src/ui/hooks/useHandPresentation.ts +121 -0
- package/src/ui/hooks/useHexBoard.ts +74 -0
- package/src/ui/hooks/useHexGrid.ts +185 -0
- package/src/ui/hooks/useIsMobile.ts +35 -0
- package/src/ui/hooks/usePanZoom.ts +278 -0
- package/src/ui/hooks/useSquareBoard.ts +124 -0
- package/src/ui/hooks/useSquareGrid.ts +328 -0
- package/src/ui/index.ts +98 -0
- package/src/ui/internal/ui/alert.tsx +51 -0
- package/src/ui/internal/ui/button.tsx +58 -0
- package/src/ui/internal/ui/dialog.tsx +134 -0
- package/src/ui/internal/ui/input.tsx +21 -0
- package/src/ui/internal/ui/label.tsx +21 -0
- package/src/ui/internal/ui/select.tsx +129 -0
- package/src/ui/internal/ui/tooltip.tsx +54 -0
- package/src/ui/internal/ui/utils.ts +5 -0
- package/src/ui/plugin-styles.css +250 -0
- package/src/ui/primitives/dialog-lifecycle.ts +58 -0
- package/src/ui/primitives/dice.tsx +79 -0
- package/src/ui/primitives/primitive-props.tsx +101 -0
- package/src/ui/theme/ThemeProvider.tsx +252 -0
- package/src/ui/theme/board.ts +61 -0
- package/src/ui/theme/css-vars.ts +105 -0
- package/src/ui/theme/derive.ts +240 -0
- package/src/ui/theme/index.ts +61 -0
- package/src/ui/theme/presets/arcade.ts +261 -0
- package/src/ui/theme/presets/studio.ts +261 -0
- package/src/ui/theme/presets/tabletop.ts +266 -0
- package/src/ui/theme/tokens.ts +392 -0
- package/src/ui/types/hex-color.ts +20 -0
- package/src/ui/types/player-state.ts +463 -0
- package/src/ui/types/tiled-board.ts +785 -0
- package/src/ui/types/visual-state.ts +137 -0
- package/src/ui.ts +1 -0
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controlled drag-to-target surface for the SDK hand and drop-target views.
|
|
3
|
+
*
|
|
4
|
+
* `CardDragSurface` is the single owner of:
|
|
5
|
+
*
|
|
6
|
+
* - the drag-lifecycle phase (`idle`/`inspecting`/`dragging`/`settling`/
|
|
7
|
+
* `returning`)
|
|
8
|
+
* - the registry of drop targets, including their eligibility
|
|
9
|
+
* - all committed `CardIntent` emission (`activate`, `previewStart`,
|
|
10
|
+
* `previewEnd`, and `drop`)
|
|
11
|
+
* - the lifted-card overlay, settle/snap-back animation and live
|
|
12
|
+
* announcement
|
|
13
|
+
*
|
|
14
|
+
* `CardDropTargetView` is a generic controlled drop-target wrapper. It
|
|
15
|
+
* registers the underlying DOM element (and its eligible/disabled state) so
|
|
16
|
+
* the lifted pointer can be matched without exposing geometry to the
|
|
17
|
+
* caller. Its registration is stable: only the `targetId` triggers register/
|
|
18
|
+
* unregister; eligibility and label changes flow through `updateTarget`.
|
|
19
|
+
*
|
|
20
|
+
* `HandView` (and the hook it uses) drives this surface through the
|
|
21
|
+
* controller exposed by `useCardDragSurface()`. Pointer events come from
|
|
22
|
+
* `HandPointerEngine`'s lift callbacks. Keyboard pickup, target traversal
|
|
23
|
+
* and Escape are handled here so that the drag-lifecycle has exactly one
|
|
24
|
+
* authoritative owner.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createContext,
|
|
29
|
+
useCallback,
|
|
30
|
+
useContext,
|
|
31
|
+
useEffect,
|
|
32
|
+
useId,
|
|
33
|
+
useMemo,
|
|
34
|
+
useRef,
|
|
35
|
+
useState,
|
|
36
|
+
type CSSProperties,
|
|
37
|
+
type KeyboardEvent,
|
|
38
|
+
type ReactNode,
|
|
39
|
+
} from "react";
|
|
40
|
+
import { createPortal } from "react-dom";
|
|
41
|
+
import { clsx } from "clsx";
|
|
42
|
+
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
|
43
|
+
import { useTheme } from "../theme/ThemeProvider.js";
|
|
44
|
+
import {
|
|
45
|
+
dropTargetVisualStateDataAttributes,
|
|
46
|
+
type CardDropTargetVisualState,
|
|
47
|
+
type CardIntent,
|
|
48
|
+
} from "../types/visual-state.js";
|
|
49
|
+
|
|
50
|
+
interface RegisteredDropTarget {
|
|
51
|
+
targetId: string;
|
|
52
|
+
disabled: boolean;
|
|
53
|
+
eligible: boolean;
|
|
54
|
+
element: HTMLElement;
|
|
55
|
+
/** Plain-text label used for the live a11y announcement. */
|
|
56
|
+
label: string | null;
|
|
57
|
+
/** Order hint for keyboard target traversal (lower numbers focus first). */
|
|
58
|
+
order: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DragPhase =
|
|
62
|
+
| "idle"
|
|
63
|
+
| "inspecting"
|
|
64
|
+
| "dragging"
|
|
65
|
+
| "settling"
|
|
66
|
+
| "returning";
|
|
67
|
+
|
|
68
|
+
interface ActiveDragState {
|
|
69
|
+
cardId: string;
|
|
70
|
+
cardLabel: string | null;
|
|
71
|
+
source: "pointer" | "keyboard";
|
|
72
|
+
pointerId: number | null;
|
|
73
|
+
pointerX: number;
|
|
74
|
+
pointerY: number;
|
|
75
|
+
grabOffsetX: number;
|
|
76
|
+
grabOffsetY: number;
|
|
77
|
+
/**
|
|
78
|
+
* Source rectangle captured at lift time, used as the snap-back/origin
|
|
79
|
+
* geometry for animated returns.
|
|
80
|
+
*/
|
|
81
|
+
sourceRect: { left: number; top: number; width: number; height: number };
|
|
82
|
+
content: ReactNode;
|
|
83
|
+
overTargetId: string | null;
|
|
84
|
+
keyboardFocusedTargetId: string | null;
|
|
85
|
+
/** DOM node we should focus when the lifecycle ends. */
|
|
86
|
+
sourceFocus: HTMLElement | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface SettlingState {
|
|
90
|
+
cardId: string;
|
|
91
|
+
source: "pointer" | "keyboard";
|
|
92
|
+
pointerX: number;
|
|
93
|
+
pointerY: number;
|
|
94
|
+
grabOffsetX: number;
|
|
95
|
+
grabOffsetY: number;
|
|
96
|
+
sourceRect: { left: number; top: number; width: number; height: number };
|
|
97
|
+
targetRect: { left: number; top: number; width: number; height: number };
|
|
98
|
+
content: ReactNode;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ReturningState {
|
|
102
|
+
cardId: string;
|
|
103
|
+
source: "pointer" | "keyboard";
|
|
104
|
+
pointerX: number;
|
|
105
|
+
pointerY: number;
|
|
106
|
+
grabOffsetX: number;
|
|
107
|
+
grabOffsetY: number;
|
|
108
|
+
sourceRect: { left: number; top: number; width: number; height: number };
|
|
109
|
+
content: ReactNode;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CardDragSurfaceController {
|
|
113
|
+
/** Identity of the card currently in the drag-lifecycle, if any. */
|
|
114
|
+
activeCardId: string | null;
|
|
115
|
+
/** Source of the active drag, if any. */
|
|
116
|
+
activeSource: "pointer" | "keyboard" | null;
|
|
117
|
+
/** Drag-lifecycle phase. */
|
|
118
|
+
phase: DragPhase;
|
|
119
|
+
/** Currently highlighted drop target id, if any. */
|
|
120
|
+
overTargetId: string | null;
|
|
121
|
+
/** Currently keyboard-focused target id, if any. */
|
|
122
|
+
keyboardFocusedTargetId: string | null;
|
|
123
|
+
/**
|
|
124
|
+
* Begin a pointer drag session. Returns `true` if the session started.
|
|
125
|
+
*/
|
|
126
|
+
startPointerDrag: (input: PointerDragInput) => boolean;
|
|
127
|
+
/** Update the pointer coordinates of an active pointer drag. */
|
|
128
|
+
updatePointer: (point: { x: number; y: number }) => void;
|
|
129
|
+
/**
|
|
130
|
+
* Commit the active pointer drag at the supplied release position. Emits
|
|
131
|
+
* a `drop` intent if the pointer is over an eligible target, or schedules
|
|
132
|
+
* a snap back otherwise.
|
|
133
|
+
*/
|
|
134
|
+
releasePointer: (point: { x: number; y: number }) => void;
|
|
135
|
+
/** Cancel the active drag (pointer or keyboard) without committing. */
|
|
136
|
+
cancelDrag: () => void;
|
|
137
|
+
/**
|
|
138
|
+
* Begin a keyboard drag session. The first eligible registered target is
|
|
139
|
+
* focused automatically.
|
|
140
|
+
*/
|
|
141
|
+
startKeyboardDrag: (input: KeyboardDragInput) => boolean;
|
|
142
|
+
/** Move keyboard focus across registered eligible targets. */
|
|
143
|
+
moveKeyboardFocus: (direction: "next" | "prev") => void;
|
|
144
|
+
/** Commit the active keyboard drag on the focused target. */
|
|
145
|
+
commitKeyboardDrop: () => void;
|
|
146
|
+
/**
|
|
147
|
+
* Record a tap that did not produce a lift. The surface holds the
|
|
148
|
+
* `inspecting` phase until another lift, drop, or external dismissal.
|
|
149
|
+
*/
|
|
150
|
+
recordTap: (input: TapInput) => void;
|
|
151
|
+
/**
|
|
152
|
+
* Record a `previewStart` intent. Surface owns canonical intent emission
|
|
153
|
+
* so consumers subscribe in one place.
|
|
154
|
+
*/
|
|
155
|
+
recordPreviewStart: (cardId: string) => void;
|
|
156
|
+
/** Record a `previewEnd` intent. */
|
|
157
|
+
recordPreviewEnd: (cardId: string) => void;
|
|
158
|
+
/**
|
|
159
|
+
* Record a desktop/keyboard `activate` intent under the `direct-activate`
|
|
160
|
+
* policy. Drag-to-target policy never calls this; it is centralized here
|
|
161
|
+
* so that the surface remains the only ingress for `CardIntent`.
|
|
162
|
+
*/
|
|
163
|
+
recordActivate: (cardId: string, source: "tap" | "keyboard") => void;
|
|
164
|
+
/** Clear the `inspecting` phase. */
|
|
165
|
+
clearInspect: () => void;
|
|
166
|
+
/** Total number of currently registered eligible targets. */
|
|
167
|
+
eligibleTargetCount: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface TapInput {
|
|
171
|
+
cardId: string;
|
|
172
|
+
cardEligible: boolean;
|
|
173
|
+
cardDisabled: boolean;
|
|
174
|
+
sourceFocus?: HTMLElement | null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface PointerDragInput {
|
|
178
|
+
cardId: string;
|
|
179
|
+
cardLabel?: string | null;
|
|
180
|
+
pointerId: number;
|
|
181
|
+
startX: number;
|
|
182
|
+
startY: number;
|
|
183
|
+
pointerX: number;
|
|
184
|
+
pointerY: number;
|
|
185
|
+
grabOffsetX: number;
|
|
186
|
+
grabOffsetY: number;
|
|
187
|
+
sourceRect: { left: number; top: number; width: number; height: number };
|
|
188
|
+
content: ReactNode;
|
|
189
|
+
sourceFocus?: HTMLElement | null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface KeyboardDragInput {
|
|
193
|
+
cardId: string;
|
|
194
|
+
cardLabel?: string | null;
|
|
195
|
+
cardEligible: boolean;
|
|
196
|
+
sourceRect: { left: number; top: number; width: number; height: number };
|
|
197
|
+
content: ReactNode;
|
|
198
|
+
sourceFocus?: HTMLElement | null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface CardDragSurfaceContextValue {
|
|
202
|
+
registerTarget: (target: RegisteredDropTarget) => () => void;
|
|
203
|
+
updateTarget: (
|
|
204
|
+
targetId: string,
|
|
205
|
+
patch: Partial<Omit<RegisteredDropTarget, "targetId" | "element">>,
|
|
206
|
+
) => void;
|
|
207
|
+
controller: CardDragSurfaceController;
|
|
208
|
+
/** Expose the most recent active card id for visual-state computation. */
|
|
209
|
+
activeCardId: string | null;
|
|
210
|
+
/** Expose the highlighted target id for visual-state computation. */
|
|
211
|
+
overTargetId: string | null;
|
|
212
|
+
/** Expose whether any drag is in progress. */
|
|
213
|
+
dragActive: boolean;
|
|
214
|
+
keyboardFocusedTargetId: string | null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const CardDragSurfaceContext =
|
|
218
|
+
createContext<CardDragSurfaceContextValue | null>(null);
|
|
219
|
+
|
|
220
|
+
export function useCardDragSurface(): CardDragSurfaceContextValue | null {
|
|
221
|
+
return useContext(CardDragSurfaceContext);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface CardDragSurfaceProps {
|
|
225
|
+
onCardIntent?: (intent: CardIntent) => void;
|
|
226
|
+
/**
|
|
227
|
+
* Approximate inset (px) used for the deterministic hit test. Defaults to
|
|
228
|
+
* `8`. Lowering this lets edges register more aggressively; raising it
|
|
229
|
+
* makes overlapping targets less ambiguous.
|
|
230
|
+
*/
|
|
231
|
+
hitTestInsetPx?: number;
|
|
232
|
+
/** Suppress the live a11y announcement (for environments providing their own). */
|
|
233
|
+
suppressLiveAnnouncement?: boolean;
|
|
234
|
+
/** Animation transition tunable for settle/return. */
|
|
235
|
+
motionTransition?: Transition;
|
|
236
|
+
className?: string;
|
|
237
|
+
style?: CSSProperties;
|
|
238
|
+
children: ReactNode;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const SETTLE_TRANSITION: Transition = {
|
|
242
|
+
type: "spring",
|
|
243
|
+
stiffness: 380,
|
|
244
|
+
damping: 32,
|
|
245
|
+
mass: 0.9,
|
|
246
|
+
};
|
|
247
|
+
const RETURN_TRANSITION: Transition = {
|
|
248
|
+
type: "spring",
|
|
249
|
+
stiffness: 320,
|
|
250
|
+
damping: 26,
|
|
251
|
+
mass: 0.8,
|
|
252
|
+
};
|
|
253
|
+
const REDUCED_TRANSITION: Transition = { duration: 0 };
|
|
254
|
+
|
|
255
|
+
export function CardDragSurface({
|
|
256
|
+
onCardIntent,
|
|
257
|
+
hitTestInsetPx = 8,
|
|
258
|
+
suppressLiveAnnouncement = false,
|
|
259
|
+
motionTransition,
|
|
260
|
+
className,
|
|
261
|
+
style,
|
|
262
|
+
children,
|
|
263
|
+
}: CardDragSurfaceProps) {
|
|
264
|
+
const theme = useTheme();
|
|
265
|
+
const reducedMotion = theme.motion.reducedMotion === "true";
|
|
266
|
+
const liveRegionId = useId();
|
|
267
|
+
const targetsRef = useRef(new Map<string, RegisteredDropTarget>());
|
|
268
|
+
const orderCounterRef = useRef(0);
|
|
269
|
+
const activeDragRef = useRef<ActiveDragState | null>(null);
|
|
270
|
+
const inspectingRef = useRef<string | null>(null);
|
|
271
|
+
const [activeDrag, setActiveDrag] = useState<ActiveDragState | null>(null);
|
|
272
|
+
const [inspectingCardId, setInspectingCardId] = useState<string | null>(null);
|
|
273
|
+
const [settlingState, setSettlingState] = useState<SettlingState | null>(
|
|
274
|
+
null,
|
|
275
|
+
);
|
|
276
|
+
const [returningState, setReturningState] = useState<ReturningState | null>(
|
|
277
|
+
null,
|
|
278
|
+
);
|
|
279
|
+
const onIntentRef = useRef(onCardIntent);
|
|
280
|
+
onIntentRef.current = onCardIntent;
|
|
281
|
+
|
|
282
|
+
const settleTransition = motionTransition ?? SETTLE_TRANSITION;
|
|
283
|
+
const returnTransition = motionTransition ?? RETURN_TRANSITION;
|
|
284
|
+
|
|
285
|
+
const setActive = useCallback((next: ActiveDragState | null) => {
|
|
286
|
+
activeDragRef.current = next;
|
|
287
|
+
setActiveDrag(next);
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
290
|
+
const setInspecting = useCallback((next: string | null) => {
|
|
291
|
+
inspectingRef.current = next;
|
|
292
|
+
setInspectingCardId(next);
|
|
293
|
+
}, []);
|
|
294
|
+
|
|
295
|
+
const finalizeInteraction = useCallback((sourceFocus: HTMLElement | null) => {
|
|
296
|
+
if (sourceFocus) {
|
|
297
|
+
// Defer focus restoration so animation can complete first frame.
|
|
298
|
+
queueMicrotask(() => {
|
|
299
|
+
try {
|
|
300
|
+
sourceFocus.focus({ preventScroll: true });
|
|
301
|
+
} catch {
|
|
302
|
+
// Source may have been unmounted; ignore.
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
308
|
+
const registerTarget = useCallback(
|
|
309
|
+
(target: RegisteredDropTarget) => {
|
|
310
|
+
targetsRef.current.set(target.targetId, {
|
|
311
|
+
...target,
|
|
312
|
+
order: target.order || ++orderCounterRef.current,
|
|
313
|
+
});
|
|
314
|
+
return () => {
|
|
315
|
+
targetsRef.current.delete(target.targetId);
|
|
316
|
+
const active = activeDragRef.current;
|
|
317
|
+
if (active && active.keyboardFocusedTargetId === target.targetId) {
|
|
318
|
+
setActive({ ...active, keyboardFocusedTargetId: null });
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
[setActive],
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const updateTarget = useCallback(
|
|
326
|
+
(
|
|
327
|
+
targetId: string,
|
|
328
|
+
patch: Partial<Omit<RegisteredDropTarget, "targetId" | "element">>,
|
|
329
|
+
) => {
|
|
330
|
+
const existing = targetsRef.current.get(targetId);
|
|
331
|
+
if (!existing) return;
|
|
332
|
+
targetsRef.current.set(targetId, { ...existing, ...patch });
|
|
333
|
+
},
|
|
334
|
+
[],
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const isTargetUsable = useCallback((target: RegisteredDropTarget) => {
|
|
338
|
+
return !target.disabled && target.eligible !== false;
|
|
339
|
+
}, []);
|
|
340
|
+
|
|
341
|
+
const resolveDropTarget = useCallback(
|
|
342
|
+
(point: { x: number; y: number }): string | null => {
|
|
343
|
+
for (const target of targetsRef.current.values()) {
|
|
344
|
+
if (!isTargetUsable(target)) continue;
|
|
345
|
+
const rect = target.element.getBoundingClientRect();
|
|
346
|
+
if (
|
|
347
|
+
point.x >= rect.left + hitTestInsetPx &&
|
|
348
|
+
point.x <= rect.right - hitTestInsetPx &&
|
|
349
|
+
point.y >= rect.top + hitTestInsetPx &&
|
|
350
|
+
point.y <= rect.bottom - hitTestInsetPx
|
|
351
|
+
) {
|
|
352
|
+
return target.targetId;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
},
|
|
357
|
+
[hitTestInsetPx, isTargetUsable],
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const sortedUsableTargetIds = useCallback((): string[] => {
|
|
361
|
+
const entries = Array.from(targetsRef.current.values()).filter((t) =>
|
|
362
|
+
isTargetUsable(t),
|
|
363
|
+
);
|
|
364
|
+
entries.sort((a, b) => a.order - b.order);
|
|
365
|
+
return entries.map((t) => t.targetId);
|
|
366
|
+
}, [isTargetUsable]);
|
|
367
|
+
|
|
368
|
+
const recordTap = useCallback(
|
|
369
|
+
(input: TapInput) => {
|
|
370
|
+
if (input.cardDisabled || !input.cardEligible) return;
|
|
371
|
+
// Tap is non-committing in drag-to-target mode regardless of whether
|
|
372
|
+
// any usable target is currently registered. A missing target is a
|
|
373
|
+
// composition/availability problem, not a reason to silently fall
|
|
374
|
+
// back to tap-to-play. Hold the card in `inspecting` so the user can
|
|
375
|
+
// see what they tapped while the runtime decides what to do next.
|
|
376
|
+
setInspecting(input.cardId);
|
|
377
|
+
},
|
|
378
|
+
[setInspecting],
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const recordPreviewStart = useCallback((cardId: string) => {
|
|
382
|
+
onIntentRef.current?.({ type: "previewStart", cardId });
|
|
383
|
+
}, []);
|
|
384
|
+
|
|
385
|
+
const recordPreviewEnd = useCallback((cardId: string) => {
|
|
386
|
+
onIntentRef.current?.({ type: "previewEnd", cardId });
|
|
387
|
+
}, []);
|
|
388
|
+
|
|
389
|
+
const recordActivate = useCallback(
|
|
390
|
+
(cardId: string, source: "tap" | "keyboard") => {
|
|
391
|
+
onIntentRef.current?.({ type: "activate", cardId, source });
|
|
392
|
+
},
|
|
393
|
+
[],
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const clearInspect = useCallback(() => {
|
|
397
|
+
if (inspectingRef.current !== null) setInspecting(null);
|
|
398
|
+
}, [setInspecting]);
|
|
399
|
+
|
|
400
|
+
const startPointerDrag = useCallback(
|
|
401
|
+
(input: PointerDragInput): boolean => {
|
|
402
|
+
if (activeDragRef.current) return false;
|
|
403
|
+
if (inspectingRef.current) setInspecting(null);
|
|
404
|
+
const overTargetId = resolveDropTarget({
|
|
405
|
+
x: input.pointerX,
|
|
406
|
+
y: input.pointerY,
|
|
407
|
+
});
|
|
408
|
+
const next: ActiveDragState = {
|
|
409
|
+
cardId: input.cardId,
|
|
410
|
+
cardLabel: input.cardLabel ?? null,
|
|
411
|
+
source: "pointer",
|
|
412
|
+
pointerId: input.pointerId,
|
|
413
|
+
pointerX: input.pointerX,
|
|
414
|
+
pointerY: input.pointerY,
|
|
415
|
+
grabOffsetX: input.grabOffsetX,
|
|
416
|
+
grabOffsetY: input.grabOffsetY,
|
|
417
|
+
sourceRect: input.sourceRect,
|
|
418
|
+
content: input.content,
|
|
419
|
+
overTargetId,
|
|
420
|
+
keyboardFocusedTargetId: null,
|
|
421
|
+
sourceFocus: input.sourceFocus ?? null,
|
|
422
|
+
};
|
|
423
|
+
setActive(next);
|
|
424
|
+
return true;
|
|
425
|
+
},
|
|
426
|
+
[resolveDropTarget, setActive, setInspecting],
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const updatePointer = useCallback(
|
|
430
|
+
(point: { x: number; y: number }) => {
|
|
431
|
+
const active = activeDragRef.current;
|
|
432
|
+
if (!active || active.source !== "pointer") return;
|
|
433
|
+
const overTargetId = resolveDropTarget(point);
|
|
434
|
+
setActive({
|
|
435
|
+
...active,
|
|
436
|
+
pointerX: point.x,
|
|
437
|
+
pointerY: point.y,
|
|
438
|
+
overTargetId,
|
|
439
|
+
});
|
|
440
|
+
},
|
|
441
|
+
[resolveDropTarget, setActive],
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const completeWithDrop = useCallback(
|
|
445
|
+
(
|
|
446
|
+
active: ActiveDragState,
|
|
447
|
+
targetId: string,
|
|
448
|
+
releaseX: number,
|
|
449
|
+
releaseY: number,
|
|
450
|
+
) => {
|
|
451
|
+
const target = targetsRef.current.get(targetId);
|
|
452
|
+
const targetRect = target?.element.getBoundingClientRect();
|
|
453
|
+
const settling: SettlingState = {
|
|
454
|
+
cardId: active.cardId,
|
|
455
|
+
source: active.source,
|
|
456
|
+
pointerX: releaseX,
|
|
457
|
+
pointerY: releaseY,
|
|
458
|
+
grabOffsetX: active.grabOffsetX,
|
|
459
|
+
grabOffsetY: active.grabOffsetY,
|
|
460
|
+
sourceRect: active.sourceRect,
|
|
461
|
+
targetRect: targetRect
|
|
462
|
+
? {
|
|
463
|
+
left: targetRect.left,
|
|
464
|
+
top: targetRect.top,
|
|
465
|
+
width: targetRect.width,
|
|
466
|
+
height: targetRect.height,
|
|
467
|
+
}
|
|
468
|
+
: active.sourceRect,
|
|
469
|
+
content: active.content,
|
|
470
|
+
};
|
|
471
|
+
onIntentRef.current?.({
|
|
472
|
+
type: "drop",
|
|
473
|
+
cardId: active.cardId,
|
|
474
|
+
targetId,
|
|
475
|
+
source: active.source === "keyboard" ? "keyboard" : "pointer",
|
|
476
|
+
});
|
|
477
|
+
setSettlingState(settling);
|
|
478
|
+
setReturningState(null);
|
|
479
|
+
setActive(null);
|
|
480
|
+
finalizeInteraction(active.sourceFocus);
|
|
481
|
+
},
|
|
482
|
+
[finalizeInteraction, setActive],
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const completeWithReturn = useCallback(
|
|
486
|
+
(active: ActiveDragState, releaseX: number, releaseY: number) => {
|
|
487
|
+
const returning: ReturningState = {
|
|
488
|
+
cardId: active.cardId,
|
|
489
|
+
source: active.source,
|
|
490
|
+
pointerX: releaseX,
|
|
491
|
+
pointerY: releaseY,
|
|
492
|
+
grabOffsetX: active.grabOffsetX,
|
|
493
|
+
grabOffsetY: active.grabOffsetY,
|
|
494
|
+
sourceRect: active.sourceRect,
|
|
495
|
+
content: active.content,
|
|
496
|
+
};
|
|
497
|
+
setReturningState(returning);
|
|
498
|
+
setSettlingState(null);
|
|
499
|
+
setActive(null);
|
|
500
|
+
finalizeInteraction(active.sourceFocus);
|
|
501
|
+
},
|
|
502
|
+
[finalizeInteraction, setActive],
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const releasePointer = useCallback(
|
|
506
|
+
(point: { x: number; y: number }) => {
|
|
507
|
+
const active = activeDragRef.current;
|
|
508
|
+
if (!active || active.source !== "pointer") return;
|
|
509
|
+
const overTargetId = resolveDropTarget(point);
|
|
510
|
+
if (overTargetId) {
|
|
511
|
+
completeWithDrop(active, overTargetId, point.x, point.y);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
completeWithReturn(active, point.x, point.y);
|
|
515
|
+
},
|
|
516
|
+
[completeWithDrop, completeWithReturn, resolveDropTarget],
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const cancelDrag = useCallback(() => {
|
|
520
|
+
const active = activeDragRef.current;
|
|
521
|
+
if (!active) return;
|
|
522
|
+
completeWithReturn(active, active.pointerX, active.pointerY);
|
|
523
|
+
}, [completeWithReturn]);
|
|
524
|
+
|
|
525
|
+
const startKeyboardDrag = useCallback(
|
|
526
|
+
(input: KeyboardDragInput): boolean => {
|
|
527
|
+
if (activeDragRef.current) return false;
|
|
528
|
+
if (!input.cardEligible) return false;
|
|
529
|
+
if (inspectingRef.current) setInspecting(null);
|
|
530
|
+
const ids = sortedUsableTargetIds();
|
|
531
|
+
if (ids.length === 0) return false;
|
|
532
|
+
const firstFocus = ids[0]!;
|
|
533
|
+
const firstTarget = targetsRef.current.get(firstFocus);
|
|
534
|
+
const firstRect = firstTarget?.element.getBoundingClientRect();
|
|
535
|
+
const next: ActiveDragState = {
|
|
536
|
+
cardId: input.cardId,
|
|
537
|
+
cardLabel: input.cardLabel ?? null,
|
|
538
|
+
source: "keyboard",
|
|
539
|
+
pointerId: null,
|
|
540
|
+
pointerX: firstRect ? firstRect.left + firstRect.width / 2 : 0,
|
|
541
|
+
pointerY: firstRect ? firstRect.top + firstRect.height / 2 : 0,
|
|
542
|
+
grabOffsetX: 0,
|
|
543
|
+
grabOffsetY: 0,
|
|
544
|
+
sourceRect: input.sourceRect,
|
|
545
|
+
content: input.content,
|
|
546
|
+
overTargetId: firstFocus,
|
|
547
|
+
keyboardFocusedTargetId: firstFocus,
|
|
548
|
+
sourceFocus: input.sourceFocus ?? null,
|
|
549
|
+
};
|
|
550
|
+
setActive(next);
|
|
551
|
+
// Focus is moved by `CardDropTargetView` in an effect that watches
|
|
552
|
+
// `keyboardFocusedTargetId`, ensuring focus transfer happens after
|
|
553
|
+
// React commits the render that promotes this target.
|
|
554
|
+
return true;
|
|
555
|
+
},
|
|
556
|
+
[setActive, setInspecting, sortedUsableTargetIds],
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const moveKeyboardFocus = useCallback(
|
|
560
|
+
(direction: "next" | "prev") => {
|
|
561
|
+
const active = activeDragRef.current;
|
|
562
|
+
if (!active || active.source !== "keyboard") return;
|
|
563
|
+
const ids = sortedUsableTargetIds();
|
|
564
|
+
if (ids.length === 0) return;
|
|
565
|
+
const currentIdx = active.keyboardFocusedTargetId
|
|
566
|
+
? ids.indexOf(active.keyboardFocusedTargetId)
|
|
567
|
+
: -1;
|
|
568
|
+
const nextIdx =
|
|
569
|
+
direction === "next"
|
|
570
|
+
? (currentIdx + 1) % ids.length
|
|
571
|
+
: (currentIdx - 1 + ids.length) % ids.length;
|
|
572
|
+
const nextId = ids[nextIdx]!;
|
|
573
|
+
const target = targetsRef.current.get(nextId);
|
|
574
|
+
const rect = target?.element.getBoundingClientRect();
|
|
575
|
+
setActive({
|
|
576
|
+
...active,
|
|
577
|
+
keyboardFocusedTargetId: nextId,
|
|
578
|
+
overTargetId: nextId,
|
|
579
|
+
pointerX: rect ? rect.left + rect.width / 2 : active.pointerX,
|
|
580
|
+
pointerY: rect ? rect.top + rect.height / 2 : active.pointerY,
|
|
581
|
+
});
|
|
582
|
+
// Focus moved by the target's keyboardFocused effect.
|
|
583
|
+
},
|
|
584
|
+
[setActive, sortedUsableTargetIds],
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
const commitKeyboardDrop = useCallback(() => {
|
|
588
|
+
const active = activeDragRef.current;
|
|
589
|
+
if (!active || active.source !== "keyboard") return;
|
|
590
|
+
const targetId = active.keyboardFocusedTargetId;
|
|
591
|
+
if (!targetId) return;
|
|
592
|
+
const target = targetsRef.current.get(targetId);
|
|
593
|
+
if (!target || !isTargetUsable(target)) return;
|
|
594
|
+
const rect = target.element.getBoundingClientRect();
|
|
595
|
+
completeWithDrop(
|
|
596
|
+
active,
|
|
597
|
+
targetId,
|
|
598
|
+
rect.left + rect.width / 2,
|
|
599
|
+
rect.top + rect.height / 2,
|
|
600
|
+
);
|
|
601
|
+
}, [completeWithDrop, isTargetUsable]);
|
|
602
|
+
|
|
603
|
+
const phase: DragPhase = useMemo(() => {
|
|
604
|
+
if (activeDrag) return "dragging";
|
|
605
|
+
if (settlingState) return "settling";
|
|
606
|
+
if (returningState) return "returning";
|
|
607
|
+
if (inspectingCardId) return "inspecting";
|
|
608
|
+
return "idle";
|
|
609
|
+
}, [activeDrag, inspectingCardId, returningState, settlingState]);
|
|
610
|
+
|
|
611
|
+
const eligibleTargetCount = useMemo(
|
|
612
|
+
() => sortedUsableTargetIds().length,
|
|
613
|
+
// Recompute whenever any target patch changes — `activeDrag` is a cheap
|
|
614
|
+
// proxy: target updates also bump the surface re-render via parent state.
|
|
615
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
616
|
+
[activeDrag, sortedUsableTargetIds],
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const controller: CardDragSurfaceController = useMemo(
|
|
620
|
+
() => ({
|
|
621
|
+
activeCardId:
|
|
622
|
+
activeDrag?.cardId ??
|
|
623
|
+
settlingState?.cardId ??
|
|
624
|
+
returningState?.cardId ??
|
|
625
|
+
(inspectingCardId ? inspectingCardId : null),
|
|
626
|
+
activeSource: activeDrag?.source ?? null,
|
|
627
|
+
phase,
|
|
628
|
+
overTargetId: activeDrag?.overTargetId ?? null,
|
|
629
|
+
keyboardFocusedTargetId: activeDrag?.keyboardFocusedTargetId ?? null,
|
|
630
|
+
startPointerDrag,
|
|
631
|
+
updatePointer,
|
|
632
|
+
releasePointer,
|
|
633
|
+
cancelDrag,
|
|
634
|
+
startKeyboardDrag,
|
|
635
|
+
moveKeyboardFocus,
|
|
636
|
+
commitKeyboardDrop,
|
|
637
|
+
recordTap,
|
|
638
|
+
recordPreviewStart,
|
|
639
|
+
recordPreviewEnd,
|
|
640
|
+
recordActivate,
|
|
641
|
+
clearInspect,
|
|
642
|
+
eligibleTargetCount,
|
|
643
|
+
}),
|
|
644
|
+
[
|
|
645
|
+
activeDrag,
|
|
646
|
+
cancelDrag,
|
|
647
|
+
clearInspect,
|
|
648
|
+
commitKeyboardDrop,
|
|
649
|
+
eligibleTargetCount,
|
|
650
|
+
inspectingCardId,
|
|
651
|
+
moveKeyboardFocus,
|
|
652
|
+
phase,
|
|
653
|
+
recordActivate,
|
|
654
|
+
recordPreviewEnd,
|
|
655
|
+
recordPreviewStart,
|
|
656
|
+
recordTap,
|
|
657
|
+
releasePointer,
|
|
658
|
+
returningState,
|
|
659
|
+
settlingState,
|
|
660
|
+
startKeyboardDrag,
|
|
661
|
+
startPointerDrag,
|
|
662
|
+
updatePointer,
|
|
663
|
+
],
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
const contextValue: CardDragSurfaceContextValue = useMemo(
|
|
667
|
+
() => ({
|
|
668
|
+
registerTarget,
|
|
669
|
+
updateTarget,
|
|
670
|
+
controller,
|
|
671
|
+
activeCardId: controller.activeCardId,
|
|
672
|
+
overTargetId: activeDrag?.overTargetId ?? null,
|
|
673
|
+
dragActive: activeDrag !== null,
|
|
674
|
+
keyboardFocusedTargetId: activeDrag?.keyboardFocusedTargetId ?? null,
|
|
675
|
+
}),
|
|
676
|
+
[activeDrag, controller, registerTarget, updateTarget],
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
useEffect(() => {
|
|
680
|
+
if (!activeDrag) return;
|
|
681
|
+
function onKeyDown(event: globalThis.KeyboardEvent) {
|
|
682
|
+
if (event.key === "Escape") {
|
|
683
|
+
event.preventDefault();
|
|
684
|
+
cancelDrag();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
window.addEventListener("keydown", onKeyDown);
|
|
688
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
689
|
+
}, [activeDrag, cancelDrag]);
|
|
690
|
+
|
|
691
|
+
const announcement = useMemo(() => {
|
|
692
|
+
if (!activeDrag) return null;
|
|
693
|
+
const overTarget = activeDrag.overTargetId
|
|
694
|
+
? targetsRef.current.get(activeDrag.overTargetId)
|
|
695
|
+
: null;
|
|
696
|
+
const overLabel = overTarget?.label ?? null;
|
|
697
|
+
const cardLabel = activeDrag.cardLabel ?? "Card";
|
|
698
|
+
if (overLabel) {
|
|
699
|
+
return `${cardLabel} over ${overLabel}. Press Enter to drop or Escape to cancel.`;
|
|
700
|
+
}
|
|
701
|
+
return `${cardLabel} picked up. Move to a target or press Escape to cancel.`;
|
|
702
|
+
}, [activeDrag]);
|
|
703
|
+
|
|
704
|
+
const overlayContent = activeDrag ? (
|
|
705
|
+
<DragOverlay session={activeDrag} reducedMotion={reducedMotion} />
|
|
706
|
+
) : null;
|
|
707
|
+
|
|
708
|
+
const settleOverlay = settlingState ? (
|
|
709
|
+
<SettleOverlay
|
|
710
|
+
key={`settle-${settlingState.cardId}`}
|
|
711
|
+
session={settlingState}
|
|
712
|
+
transition={reducedMotion ? REDUCED_TRANSITION : settleTransition}
|
|
713
|
+
onDone={() => setSettlingState(null)}
|
|
714
|
+
/>
|
|
715
|
+
) : null;
|
|
716
|
+
|
|
717
|
+
const returnOverlay = returningState ? (
|
|
718
|
+
<ReturnOverlay
|
|
719
|
+
key={`return-${returningState.cardId}`}
|
|
720
|
+
session={returningState}
|
|
721
|
+
transition={reducedMotion ? REDUCED_TRANSITION : returnTransition}
|
|
722
|
+
onDone={() => setReturningState(null)}
|
|
723
|
+
/>
|
|
724
|
+
) : null;
|
|
725
|
+
|
|
726
|
+
const portalRoot = typeof document !== "undefined" ? document.body : null;
|
|
727
|
+
|
|
728
|
+
return (
|
|
729
|
+
<CardDragSurfaceContext.Provider value={contextValue}>
|
|
730
|
+
<div
|
|
731
|
+
data-dreamboard-card-drag-surface=""
|
|
732
|
+
data-drag-active={activeDrag ? "true" : undefined}
|
|
733
|
+
data-drag-source={activeDrag?.source}
|
|
734
|
+
data-drag-phase={phase}
|
|
735
|
+
className={clsx("relative", className)}
|
|
736
|
+
style={style}
|
|
737
|
+
>
|
|
738
|
+
{children}
|
|
739
|
+
</div>
|
|
740
|
+
{portalRoot
|
|
741
|
+
? createPortal(
|
|
742
|
+
<AnimatePresence initial={false}>
|
|
743
|
+
{overlayContent}
|
|
744
|
+
{settleOverlay}
|
|
745
|
+
{returnOverlay}
|
|
746
|
+
</AnimatePresence>,
|
|
747
|
+
portalRoot,
|
|
748
|
+
)
|
|
749
|
+
: null}
|
|
750
|
+
{!suppressLiveAnnouncement ? (
|
|
751
|
+
<div
|
|
752
|
+
id={liveRegionId}
|
|
753
|
+
role="status"
|
|
754
|
+
aria-live="polite"
|
|
755
|
+
className="sr-only"
|
|
756
|
+
data-dreamboard-card-drag-announcement=""
|
|
757
|
+
style={{
|
|
758
|
+
position: "absolute",
|
|
759
|
+
width: 1,
|
|
760
|
+
height: 1,
|
|
761
|
+
padding: 0,
|
|
762
|
+
margin: -1,
|
|
763
|
+
overflow: "hidden",
|
|
764
|
+
clip: "rect(0,0,0,0)",
|
|
765
|
+
whiteSpace: "nowrap",
|
|
766
|
+
border: 0,
|
|
767
|
+
}}
|
|
768
|
+
>
|
|
769
|
+
{announcement ?? ""}
|
|
770
|
+
</div>
|
|
771
|
+
) : null}
|
|
772
|
+
</CardDragSurfaceContext.Provider>
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
interface DragOverlayProps {
|
|
777
|
+
session: ActiveDragState;
|
|
778
|
+
reducedMotion: boolean;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function DragOverlay({ session, reducedMotion }: DragOverlayProps) {
|
|
782
|
+
if (session.source === "keyboard") {
|
|
783
|
+
return (
|
|
784
|
+
<motion.div
|
|
785
|
+
key="overlay-keyboard"
|
|
786
|
+
data-dreamboard-card-drag-overlay=""
|
|
787
|
+
data-source="keyboard"
|
|
788
|
+
initial={{ opacity: 0 }}
|
|
789
|
+
animate={{ opacity: 1 }}
|
|
790
|
+
exit={{ opacity: 0 }}
|
|
791
|
+
transition={reducedMotion ? REDUCED_TRANSITION : { duration: 0.12 }}
|
|
792
|
+
style={{
|
|
793
|
+
position: "fixed",
|
|
794
|
+
left: session.sourceRect.left,
|
|
795
|
+
top: session.sourceRect.top,
|
|
796
|
+
width: session.sourceRect.width,
|
|
797
|
+
height: session.sourceRect.height,
|
|
798
|
+
pointerEvents: "none",
|
|
799
|
+
zIndex: 1000,
|
|
800
|
+
boxShadow: reducedMotion ? "none" : "0 12px 32px rgba(0,0,0,0.18)",
|
|
801
|
+
borderRadius: 12,
|
|
802
|
+
}}
|
|
803
|
+
>
|
|
804
|
+
{session.content}
|
|
805
|
+
</motion.div>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const liftedAnimate = reducedMotion
|
|
810
|
+
? {
|
|
811
|
+
scale: 1,
|
|
812
|
+
rotate: 0,
|
|
813
|
+
filter: "drop-shadow(0 0 0 rgba(0,0,0,0))",
|
|
814
|
+
}
|
|
815
|
+
: {
|
|
816
|
+
scale: [1, 1.42, 1.35],
|
|
817
|
+
rotate: [0, -3, 3, -1.8, 1.8, 0],
|
|
818
|
+
filter: [
|
|
819
|
+
"drop-shadow(0 4px 8px rgba(0,0,0,0.18))",
|
|
820
|
+
"drop-shadow(0 24px 36px rgba(0,0,0,0.36))",
|
|
821
|
+
],
|
|
822
|
+
};
|
|
823
|
+
const liftedTransition: Transition = reducedMotion
|
|
824
|
+
? REDUCED_TRANSITION
|
|
825
|
+
: {
|
|
826
|
+
scale: { type: "spring", stiffness: 420, damping: 22, mass: 0.7 },
|
|
827
|
+
rotate: { duration: 0.55, ease: "easeInOut" },
|
|
828
|
+
filter: { duration: 0.18, ease: "easeOut" },
|
|
829
|
+
};
|
|
830
|
+
return (
|
|
831
|
+
<motion.div
|
|
832
|
+
key="overlay-pointer"
|
|
833
|
+
data-dreamboard-card-drag-overlay=""
|
|
834
|
+
data-source="pointer"
|
|
835
|
+
initial={{
|
|
836
|
+
scale: 1,
|
|
837
|
+
rotate: 0,
|
|
838
|
+
opacity: 1,
|
|
839
|
+
filter: "drop-shadow(0 2px 6px rgba(0,0,0,0.15))",
|
|
840
|
+
}}
|
|
841
|
+
animate={liftedAnimate}
|
|
842
|
+
exit={{ opacity: 0 }}
|
|
843
|
+
transition={liftedTransition}
|
|
844
|
+
style={{
|
|
845
|
+
position: "fixed",
|
|
846
|
+
left: session.pointerX - session.sourceRect.width / 2,
|
|
847
|
+
top: session.pointerY - session.sourceRect.height / 2,
|
|
848
|
+
width: session.sourceRect.width,
|
|
849
|
+
height: session.sourceRect.height,
|
|
850
|
+
zIndex: 1000,
|
|
851
|
+
pointerEvents: "none",
|
|
852
|
+
touchAction: "none",
|
|
853
|
+
transformOrigin: "center center",
|
|
854
|
+
willChange: "transform, filter",
|
|
855
|
+
}}
|
|
856
|
+
>
|
|
857
|
+
{session.content}
|
|
858
|
+
</motion.div>
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
interface SettleOverlayProps {
|
|
863
|
+
session: SettlingState;
|
|
864
|
+
transition: Transition;
|
|
865
|
+
onDone: () => void;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function SettleOverlay({ session, transition, onDone }: SettleOverlayProps) {
|
|
869
|
+
const startLeft = session.pointerX - session.sourceRect.width / 2;
|
|
870
|
+
const startTop = session.pointerY - session.sourceRect.height / 2;
|
|
871
|
+
const endLeft =
|
|
872
|
+
session.targetRect.left +
|
|
873
|
+
session.targetRect.width / 2 -
|
|
874
|
+
session.sourceRect.width / 2;
|
|
875
|
+
const endTop =
|
|
876
|
+
session.targetRect.top +
|
|
877
|
+
session.targetRect.height / 2 -
|
|
878
|
+
session.sourceRect.height / 2;
|
|
879
|
+
return (
|
|
880
|
+
<motion.div
|
|
881
|
+
data-dreamboard-card-drag-overlay=""
|
|
882
|
+
data-source={session.source}
|
|
883
|
+
data-drag-phase="settling"
|
|
884
|
+
initial={{ left: startLeft, top: startTop, scale: 1.06, opacity: 1 }}
|
|
885
|
+
animate={{ left: endLeft, top: endTop, scale: 0.92, opacity: 0 }}
|
|
886
|
+
transition={transition}
|
|
887
|
+
onAnimationComplete={onDone}
|
|
888
|
+
style={{
|
|
889
|
+
position: "fixed",
|
|
890
|
+
width: session.sourceRect.width,
|
|
891
|
+
height: session.sourceRect.height,
|
|
892
|
+
zIndex: 1000,
|
|
893
|
+
pointerEvents: "none",
|
|
894
|
+
}}
|
|
895
|
+
>
|
|
896
|
+
{session.content}
|
|
897
|
+
</motion.div>
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
interface ReturnOverlayProps {
|
|
902
|
+
session: ReturningState;
|
|
903
|
+
transition: Transition;
|
|
904
|
+
onDone: () => void;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function ReturnOverlay({ session, transition, onDone }: ReturnOverlayProps) {
|
|
908
|
+
const startLeft = session.pointerX - session.sourceRect.width / 2;
|
|
909
|
+
const startTop = session.pointerY - session.sourceRect.height / 2;
|
|
910
|
+
return (
|
|
911
|
+
<motion.div
|
|
912
|
+
data-dreamboard-card-drag-overlay=""
|
|
913
|
+
data-source={session.source}
|
|
914
|
+
data-drag-phase="returning"
|
|
915
|
+
initial={{ left: startLeft, top: startTop, scale: 1.06, opacity: 1 }}
|
|
916
|
+
animate={{
|
|
917
|
+
left: session.sourceRect.left,
|
|
918
|
+
top: session.sourceRect.top,
|
|
919
|
+
scale: 1,
|
|
920
|
+
opacity: 1,
|
|
921
|
+
}}
|
|
922
|
+
exit={{ opacity: 0 }}
|
|
923
|
+
transition={transition}
|
|
924
|
+
onAnimationComplete={onDone}
|
|
925
|
+
style={{
|
|
926
|
+
position: "fixed",
|
|
927
|
+
width: session.sourceRect.width,
|
|
928
|
+
height: session.sourceRect.height,
|
|
929
|
+
zIndex: 1000,
|
|
930
|
+
pointerEvents: "none",
|
|
931
|
+
}}
|
|
932
|
+
>
|
|
933
|
+
{session.content}
|
|
934
|
+
</motion.div>
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export interface CardDropTargetViewProps {
|
|
939
|
+
targetId: string;
|
|
940
|
+
state?: CardDropTargetVisualState;
|
|
941
|
+
/** Plain-text label used in live announcements ("Selected cards", etc.). */
|
|
942
|
+
label?: string;
|
|
943
|
+
renderTarget: (state: CardDropTargetVisualState) => ReactNode;
|
|
944
|
+
className?: string;
|
|
945
|
+
style?: CSSProperties;
|
|
946
|
+
/** Tab order hint (lower numbers focus first). */
|
|
947
|
+
order?: number;
|
|
948
|
+
/** ARIA role override; defaults to `button`. */
|
|
949
|
+
role?: string;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export function CardDropTargetView({
|
|
953
|
+
targetId,
|
|
954
|
+
state,
|
|
955
|
+
label,
|
|
956
|
+
renderTarget,
|
|
957
|
+
className,
|
|
958
|
+
style,
|
|
959
|
+
order,
|
|
960
|
+
role = "button",
|
|
961
|
+
}: CardDropTargetViewProps) {
|
|
962
|
+
const surface = useCardDragSurface();
|
|
963
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
964
|
+
const disabled = state?.disabled ?? false;
|
|
965
|
+
const baseEligible = state?.eligible ?? true;
|
|
966
|
+
const baseLabel = label ?? null;
|
|
967
|
+
const orderProp = order ?? 0;
|
|
968
|
+
|
|
969
|
+
const registerTargetRef = useRef(surface?.registerTarget);
|
|
970
|
+
registerTargetRef.current = surface?.registerTarget;
|
|
971
|
+
const updateTargetRef = useRef(surface?.updateTarget);
|
|
972
|
+
updateTargetRef.current = surface?.updateTarget;
|
|
973
|
+
|
|
974
|
+
// Stable register/unregister keyed only on `targetId`. Eligibility, label
|
|
975
|
+
// and disabled flow through `updateTarget` so changing surface context
|
|
976
|
+
// values cannot tear down the registration mid-drag.
|
|
977
|
+
useEffect(() => {
|
|
978
|
+
const element = ref.current;
|
|
979
|
+
const register = registerTargetRef.current;
|
|
980
|
+
if (!register || !element) return;
|
|
981
|
+
const unregister = register({
|
|
982
|
+
targetId,
|
|
983
|
+
disabled,
|
|
984
|
+
eligible: baseEligible,
|
|
985
|
+
element,
|
|
986
|
+
label: baseLabel,
|
|
987
|
+
order: orderProp,
|
|
988
|
+
});
|
|
989
|
+
return unregister;
|
|
990
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
991
|
+
}, [targetId]);
|
|
992
|
+
|
|
993
|
+
useEffect(() => {
|
|
994
|
+
updateTargetRef.current?.(targetId, {
|
|
995
|
+
disabled,
|
|
996
|
+
eligible: baseEligible,
|
|
997
|
+
label: baseLabel,
|
|
998
|
+
order: orderProp,
|
|
999
|
+
});
|
|
1000
|
+
}, [targetId, disabled, baseEligible, baseLabel, orderProp]);
|
|
1001
|
+
|
|
1002
|
+
const dragActive = surface?.dragActive ?? false;
|
|
1003
|
+
const overTargetId = surface?.overTargetId ?? null;
|
|
1004
|
+
const keyboardFocused = surface?.keyboardFocusedTargetId === targetId;
|
|
1005
|
+
|
|
1006
|
+
// Move focus into this target whenever the surface promotes it to the
|
|
1007
|
+
// keyboard-focused id. Doing it here, after React commits, is more
|
|
1008
|
+
// reliable than firing focus() from inside the surface's `setActive`
|
|
1009
|
+
// callback (where React batching can race the source card's commit).
|
|
1010
|
+
useEffect(() => {
|
|
1011
|
+
if (!keyboardFocused) return;
|
|
1012
|
+
const el = ref.current;
|
|
1013
|
+
if (!el) return;
|
|
1014
|
+
if (document.activeElement === el) return;
|
|
1015
|
+
try {
|
|
1016
|
+
el.focus({ preventScroll: true });
|
|
1017
|
+
} catch {
|
|
1018
|
+
// Element may have unmounted; ignore.
|
|
1019
|
+
}
|
|
1020
|
+
}, [keyboardFocused, targetId]);
|
|
1021
|
+
|
|
1022
|
+
const computedState: CardDropTargetVisualState = {
|
|
1023
|
+
...state,
|
|
1024
|
+
eligible: baseEligible,
|
|
1025
|
+
active: dragActive ? true : state?.active,
|
|
1026
|
+
over:
|
|
1027
|
+
overTargetId === targetId && baseEligible ? true : (state?.over ?? false),
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const handleKeyDown = useCallback(
|
|
1031
|
+
(event: KeyboardEvent<HTMLDivElement>) => {
|
|
1032
|
+
if (!surface) return;
|
|
1033
|
+
const controller = surface.controller;
|
|
1034
|
+
if (controller.activeSource !== "keyboard") return;
|
|
1035
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1036
|
+
event.preventDefault();
|
|
1037
|
+
controller.commitKeyboardDrop();
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
1041
|
+
event.preventDefault();
|
|
1042
|
+
controller.moveKeyboardFocus("next");
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
1046
|
+
event.preventDefault();
|
|
1047
|
+
controller.moveKeyboardFocus("prev");
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (event.key === "Escape") {
|
|
1051
|
+
event.preventDefault();
|
|
1052
|
+
controller.cancelDrag();
|
|
1053
|
+
}
|
|
1054
|
+
},
|
|
1055
|
+
[surface],
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
return (
|
|
1059
|
+
<div
|
|
1060
|
+
ref={ref}
|
|
1061
|
+
data-dreamboard-card-drop-target=""
|
|
1062
|
+
data-target-id={targetId}
|
|
1063
|
+
data-keyboard-focused={keyboardFocused ? "true" : undefined}
|
|
1064
|
+
role={role}
|
|
1065
|
+
tabIndex={disabled || !baseEligible ? -1 : 0}
|
|
1066
|
+
aria-disabled={disabled || !baseEligible || undefined}
|
|
1067
|
+
aria-label={label}
|
|
1068
|
+
onKeyDown={handleKeyDown}
|
|
1069
|
+
className={className}
|
|
1070
|
+
style={style}
|
|
1071
|
+
{...dropTargetVisualStateDataAttributes(computedState)}
|
|
1072
|
+
>
|
|
1073
|
+
{renderTarget(computedState)}
|
|
1074
|
+
</div>
|
|
1075
|
+
);
|
|
1076
|
+
}
|