@ankhorage/zora 22.1.3 → 23.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 (241) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.d.ts +2 -1
  4. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.d.ts.map +1 -1
  5. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.js +2 -1
  6. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.js.map +1 -1
  7. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.d.ts.map +1 -1
  8. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js +1 -1
  9. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js.map +1 -1
  10. package/dist/features/graph-view/adapters/inbound/web-artifact/bindGraphEvents.d.ts.map +1 -1
  11. package/dist/features/graph-view/adapters/inbound/web-artifact/bindGraphEvents.js +12 -4
  12. package/dist/features/graph-view/adapters/inbound/web-artifact/bindGraphEvents.js.map +1 -1
  13. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.d.ts +2 -1
  14. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.d.ts.map +1 -1
  15. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js +3 -3
  16. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js.map +1 -1
  17. package/dist/features/selection/adapters/inbound/SelectableItem.d.ts +1 -5
  18. package/dist/features/selection/adapters/inbound/SelectableItem.d.ts.map +1 -1
  19. package/dist/features/selection/adapters/inbound/SelectableItem.js +30 -54
  20. package/dist/features/selection/adapters/inbound/SelectableItem.js.map +1 -1
  21. package/dist/features/selection/adapters/inbound/SelectionProvider.d.ts +2 -6
  22. package/dist/features/selection/adapters/inbound/SelectionProvider.d.ts.map +1 -1
  23. package/dist/features/selection/adapters/inbound/SelectionProvider.js +36 -32
  24. package/dist/features/selection/adapters/inbound/SelectionProvider.js.map +1 -1
  25. package/dist/features/selection/application/resolveSelectionEventIntent.d.ts +5 -0
  26. package/dist/features/selection/application/resolveSelectionEventIntent.d.ts.map +1 -0
  27. package/dist/features/selection/application/resolveSelectionEventIntent.js +49 -0
  28. package/dist/features/selection/application/resolveSelectionEventIntent.js.map +1 -0
  29. package/dist/features/selection/public.d.ts +2 -0
  30. package/dist/features/selection/public.d.ts.map +1 -1
  31. package/dist/features/selection/public.js +1 -0
  32. package/dist/features/selection/public.js.map +1 -1
  33. package/dist/features/selection/utils/resolveSelectionNextIds.d.ts +4 -5
  34. package/dist/features/selection/utils/resolveSelectionNextIds.d.ts.map +1 -1
  35. package/dist/features/selection/utils/resolveSelectionNextIds.js +6 -13
  36. package/dist/features/selection/utils/resolveSelectionNextIds.js.map +1 -1
  37. package/dist/features/tree-view/adapters/inbound/TreeItem.d.ts +3 -2
  38. package/dist/features/tree-view/adapters/inbound/TreeItem.d.ts.map +1 -1
  39. package/dist/features/tree-view/adapters/inbound/TreeItem.js +11 -4
  40. package/dist/features/tree-view/adapters/inbound/TreeItem.js.map +1 -1
  41. package/dist/features/tree-view/adapters/inbound/TreeView.d.ts.map +1 -1
  42. package/dist/features/tree-view/adapters/inbound/TreeView.js +4 -5
  43. package/dist/features/tree-view/adapters/inbound/TreeView.js.map +1 -1
  44. package/dist/features/tree-view/adapters/inbound/web-artifact/TreeItemRow.d.ts +3 -2
  45. package/dist/features/tree-view/adapters/inbound/web-artifact/TreeItemRow.d.ts.map +1 -1
  46. package/dist/features/tree-view/adapters/inbound/web-artifact/TreeItemRow.js +17 -9
  47. package/dist/features/tree-view/adapters/inbound/web-artifact/TreeItemRow.js.map +1 -1
  48. package/dist/features/tree-view/adapters/inbound/web-artifact/TreeView.d.ts +4 -3
  49. package/dist/features/tree-view/adapters/inbound/web-artifact/TreeView.d.ts.map +1 -1
  50. package/dist/features/tree-view/adapters/inbound/web-artifact/TreeView.js +8 -8
  51. package/dist/features/tree-view/adapters/inbound/web-artifact/TreeView.js.map +1 -1
  52. package/dist/index.d.ts +1 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/types/graph-view.d.ts +2 -0
  56. package/dist/types/graph-view.d.ts.map +1 -1
  57. package/dist/types/graph-view.js.map +1 -1
  58. package/dist/types/selection.d.ts +3 -0
  59. package/dist/types/selection.d.ts.map +1 -1
  60. package/dist/types/selection.js.map +1 -1
  61. package/dist/types/tree-view.d.ts +3 -2
  62. package/dist/types/tree-view.d.ts.map +1 -1
  63. package/dist/types/tree-view.js.map +1 -1
  64. package/package.json +4 -4
  65. package/src/features/graph-view/adapters/inbound/web-artifact/GraphNodeOverlay.tsx +10 -2
  66. package/src/features/graph-view/adapters/inbound/web-artifact/GraphView.tsx +3 -1
  67. package/src/features/graph-view/adapters/inbound/web-artifact/bindGraphEvents.ts +22 -5
  68. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.test.ts +21 -0
  69. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.ts +10 -3
  70. package/src/features/selection/adapters/inbound/SelectableItem.tsx +33 -50
  71. package/src/features/selection/adapters/inbound/SelectionProvider.tsx +34 -35
  72. package/src/features/selection/application/resolveSelectionEventIntent.test.ts +35 -0
  73. package/src/features/selection/application/resolveSelectionEventIntent.ts +55 -0
  74. package/src/features/selection/public.ts +2 -0
  75. package/src/features/selection/utils/resolveSelectionNextIds.test.ts +1 -14
  76. package/src/features/selection/utils/resolveSelectionNextIds.ts +6 -28
  77. package/src/features/tree-view/adapters/inbound/TreeItem.tsx +25 -9
  78. package/src/features/tree-view/adapters/inbound/TreeView.tsx +4 -6
  79. package/src/features/tree-view/adapters/inbound/web-artifact/TreeItemRow.tsx +26 -8
  80. package/src/features/tree-view/adapters/inbound/web-artifact/TreeView.test.tsx +77 -7
  81. package/src/features/tree-view/adapters/inbound/web-artifact/TreeView.tsx +11 -9
  82. package/src/index.ts +1 -0
  83. package/src/types/graph-view.ts +2 -0
  84. package/src/types/selection.ts +3 -0
  85. package/src/types/tree-view.ts +3 -2
  86. package/web-dist/chunks/{Accordion-j2aw3d5b.js → Accordion-01e56hb5.js} +1 -1
  87. package/web-dist/chunks/{Accordion-emczc5rg.js → Accordion-09se459d.js} +4 -4
  88. package/web-dist/chunks/{Accordion-784f23tq.js → Accordion-14p7zanv.js} +1 -1
  89. package/web-dist/chunks/{Accordion-cecn51ry.js → Accordion-17pht8jd.js} +3 -3
  90. package/web-dist/chunks/Accordion-5df6cgyf.js +55 -0
  91. package/web-dist/chunks/{Accordion-sr353w95.js → Accordion-78mj69zp.js} +2 -2
  92. package/web-dist/chunks/{Accordion-mpnb4qqw.js → Accordion-83hfn3y4.js} +5 -5
  93. package/web-dist/chunks/{Accordion-07rdw2vh.js → Accordion-89q14src.js} +2 -2
  94. package/web-dist/chunks/{Accordion-ajydqhjp.js → Accordion-8xk2zmr5.js} +2 -2
  95. package/web-dist/chunks/{Accordion-qvt6qdqm.js → Accordion-b3ymabze.js} +4 -4
  96. package/web-dist/chunks/{Accordion-avmgvcef.js → Accordion-cn8tbdvk.js} +3 -3
  97. package/web-dist/chunks/{Accordion-8af800ts.js → Accordion-czmnsspb.js} +5 -5
  98. package/web-dist/chunks/{Accordion-nc5gnvnb.js → Accordion-dp638q63.js} +2 -2
  99. package/web-dist/chunks/{Accordion-2fm2tm5r.js → Accordion-e633pb7z.js} +1 -1
  100. package/web-dist/chunks/{Accordion-yz7b7njb.js → Accordion-f1t3rzgg.js} +4 -4
  101. package/web-dist/chunks/{Accordion-b3qy1trj.js → Accordion-fm3jxs1f.js} +8 -8
  102. package/web-dist/chunks/{Accordion-zf3kzvef.js → Accordion-gj63vb6b.js} +4 -4
  103. package/web-dist/chunks/{Accordion-mvfz28m8.js → Accordion-gkzhd3zk.js} +3 -3
  104. package/web-dist/chunks/{Accordion-jj3bg8c1.js → Accordion-k7wwyx5x.js} +4 -4
  105. package/web-dist/chunks/{Accordion-rkv7jyaj.js → Accordion-kemavggr.js} +1 -1
  106. package/web-dist/chunks/{Accordion-jhwsvx3g.js → Accordion-kpnwdpb5.js} +1 -1
  107. package/web-dist/chunks/{Accordion-yf6vseep.js → Accordion-q0st659f.js} +82 -89
  108. package/web-dist/chunks/{Accordion-555gnfmw.js → Accordion-q165k3az.js} +6 -6
  109. package/web-dist/chunks/{Accordion-9rnepkvk.js → Accordion-q3z741vt.js} +3 -3
  110. package/web-dist/chunks/{Accordion-z585n0mr.js → Accordion-rjgdrn7y.js} +1 -1
  111. package/web-dist/chunks/{Accordion-vmz8y01b.js → Accordion-rspy7gjb.js} +6 -6
  112. package/web-dist/chunks/{Accordion-ycmg06sq.js → Accordion-rx2gzadh.js} +1 -1
  113. package/web-dist/chunks/{Accordion-45ej9brb.js → Accordion-swys7qts.js} +3 -3
  114. package/web-dist/chunks/{Accordion-v1dtv6v0.js → Accordion-w0k7gnn9.js} +3 -3
  115. package/web-dist/chunks/{Accordion-pnvt10cv.js → Accordion-w1afjp87.js} +4 -4
  116. package/web-dist/chunks/{Accordion-tj61sa5s.js → Accordion-w7jv9y5t.js} +4 -4
  117. package/web-dist/chunks/{Accordion-s1jhv8gk.js → Accordion-x13vca75.js} +1 -1
  118. package/web-dist/chunks/{Accordion-73m7z73v.js → Accordion-x1dg1f47.js} +5 -5
  119. package/web-dist/chunks/{Accordion-1cgz6f8t.js → Accordion-x4rjd7yn.js} +1 -1
  120. package/web-dist/chunks/{Accordion-nn38n7q4.js → Accordion-xf8cj8z4.js} +3 -3
  121. package/web-dist/chunks/{Accordion-8hhvtcxp.js → Accordion-xzd0tx46.js} +1 -1
  122. package/web-dist/components/accordion/Accordion.js +5 -5
  123. package/web-dist/components/accordion-item/AccordionItem.js +5 -5
  124. package/web-dist/components/activity-indicator/ActivityIndicator.js +1 -1
  125. package/web-dist/components/app-bar/AppBar.js +7 -7
  126. package/web-dist/components/app-shell/AppShell.js +2 -2
  127. package/web-dist/components/avatar/Avatar.js +5 -5
  128. package/web-dist/components/avatar-group/AvatarGroup.js +5 -5
  129. package/web-dist/components/badge/Badge.js +2 -2
  130. package/web-dist/components/barcode-scanner-view/BarcodeScannerView.js +10 -10
  131. package/web-dist/components/bottom-sheet/BottomSheet.js +3 -3
  132. package/web-dist/components/bottom-sheet-flat-list/BottomSheetFlatList.js +3 -3
  133. package/web-dist/components/bottom-sheet-scroll-view/BottomSheetScrollView.js +3 -3
  134. package/web-dist/components/bottom-sheet-section-list/BottomSheetSectionList.js +3 -3
  135. package/web-dist/components/bottom-sheet-view/BottomSheetView.js +3 -3
  136. package/web-dist/components/bottom-sheet-virtualized-list/BottomSheetVirtualizedList.js +3 -3
  137. package/web-dist/components/breadcrumbs/Breadcrumbs.js +5 -5
  138. package/web-dist/components/button/Button.js +3 -3
  139. package/web-dist/components/button-group/ButtonGroup.js +3 -3
  140. package/web-dist/components/camera-permission-view/CameraPermissionView.js +10 -10
  141. package/web-dist/components/card/Card.js +9 -9
  142. package/web-dist/components/card-back/CardBack.js +2 -2
  143. package/web-dist/components/card-hand/CardHand.js +2 -2
  144. package/web-dist/components/chat-list-item/ChatListItem.js +7 -7
  145. package/web-dist/components/checkbox/Checkbox.js +4 -4
  146. package/web-dist/components/checkbox-group/CheckboxGroup.js +4 -4
  147. package/web-dist/components/chess-board/ChessBoard.js +2 -2
  148. package/web-dist/components/chip/Chip.js +5 -5
  149. package/web-dist/components/chip-group/ChipGroup.js +5 -5
  150. package/web-dist/components/collection-editor/CollectionEditor.js +9 -9
  151. package/web-dist/components/content-rail/ContentRail.js +4 -4
  152. package/web-dist/components/data-table/DataTable.js +14 -14
  153. package/web-dist/components/date-picker/DatePicker.js +5 -5
  154. package/web-dist/components/dialog/Dialog.js +4 -4
  155. package/web-dist/components/divider/Divider.js +2 -2
  156. package/web-dist/components/empty-state/EmptyState.js +10 -10
  157. package/web-dist/components/field/Field.js +4 -4
  158. package/web-dist/components/flat-list/FlatList.js +5 -5
  159. package/web-dist/components/forgot-password-form/ForgotPasswordForm.js +8 -8
  160. package/web-dist/components/form/Form.js +6 -6
  161. package/web-dist/components/form-actions/FormActions.js +6 -6
  162. package/web-dist/components/form-error/FormError.js +6 -6
  163. package/web-dist/components/game/Game.js +3 -3
  164. package/web-dist/components/game-entity/GameEntity.js +3 -3
  165. package/web-dist/components/game-field/GameField.js +3 -3
  166. package/web-dist/components/game-input-zone/GameInputZone.js +3 -3
  167. package/web-dist/components/game-measurement-probe/GameMeasurementProbe.js +3 -3
  168. package/web-dist/components/game-overlay/GameOverlay.js +3 -3
  169. package/web-dist/components/gradient/Gradient.js +3 -3
  170. package/web-dist/components/gradient-renderer-provider/GradientRendererProvider.js +3 -3
  171. package/web-dist/components/graph-view/GraphView.js +20 -11
  172. package/web-dist/components/graph-view/GraphView.types-0.d.ts +2 -0
  173. package/web-dist/components/grid/Grid.js +2 -2
  174. package/web-dist/components/heading/Heading.js +2 -2
  175. package/web-dist/components/hero/Hero.js +9 -9
  176. package/web-dist/components/icon/Icon.js +2 -2
  177. package/web-dist/components/icon-button/IconButton.js +3 -3
  178. package/web-dist/components/image/Image.js +4 -4
  179. package/web-dist/components/keyboard-avoiding-view/KeyboardAvoidingView.js +1 -1
  180. package/web-dist/components/list/List.js +5 -5
  181. package/web-dist/components/list-item/ListItem.js +5 -5
  182. package/web-dist/components/list-section/ListSection.js +5 -5
  183. package/web-dist/components/media-card/MediaCard.js +9 -9
  184. package/web-dist/components/message-bubble/MessageBubble.js +7 -7
  185. package/web-dist/components/metric-card/MetricCard.js +9 -9
  186. package/web-dist/components/missing-element/MissingElement.js +4 -4
  187. package/web-dist/components/o-auth-provider-button/OAuthProviderButton.js +8 -8
  188. package/web-dist/components/o-auth-provider-list/OAuthProviderList.js +8 -8
  189. package/web-dist/components/opening-book/OpeningBook.js +2 -2
  190. package/web-dist/components/otp-form/OtpForm.js +8 -8
  191. package/web-dist/components/pagination/Pagination.js +4 -4
  192. package/web-dist/components/palette-item/PaletteItem.js +9 -9
  193. package/web-dist/components/playing-card/PlayingCard.js +2 -2
  194. package/web-dist/components/poker-training-table/PokerTrainingTable.js +2 -2
  195. package/web-dist/components/popover-menu/PopoverMenu.js +4 -4
  196. package/web-dist/components/post-card/PostCard.js +9 -9
  197. package/web-dist/components/product-card/ProductCard.js +9 -9
  198. package/web-dist/components/progress/Progress.js +4 -4
  199. package/web-dist/components/progress-ring/ProgressRing.js +4 -4
  200. package/web-dist/components/radio/Radio.js +5 -5
  201. package/web-dist/components/radio-group/RadioGroup.js +5 -5
  202. package/web-dist/components/rating/Rating.js +3 -3
  203. package/web-dist/components/reader-surface/ReaderSurface.js +9 -9
  204. package/web-dist/components/scan-overlay/ScanOverlay.js +10 -10
  205. package/web-dist/components/screen/Screen.js +2 -2
  206. package/web-dist/components/screen-section/ScreenSection.js +4 -4
  207. package/web-dist/components/scroll-view/ScrollView.js +2 -2
  208. package/web-dist/components/search-input/SearchInput.js +4 -4
  209. package/web-dist/components/section-header/SectionHeader.js +4 -4
  210. package/web-dist/components/section-list/SectionList.js +5 -5
  211. package/web-dist/components/select/Select.js +4 -4
  212. package/web-dist/components/selectable-item/SelectableItem.js +1 -1
  213. package/web-dist/components/selection-provider/SelectionProvider.js +1 -1
  214. package/web-dist/components/sign-in-form/SignInForm.js +8 -8
  215. package/web-dist/components/sign-up-form/SignUpForm.js +8 -8
  216. package/web-dist/components/skeleton/Skeleton.js +12 -12
  217. package/web-dist/components/skeleton-card/SkeletonCard.js +12 -12
  218. package/web-dist/components/skeleton-list/SkeletonList.js +12 -12
  219. package/web-dist/components/skeleton-text/SkeletonText.js +12 -12
  220. package/web-dist/components/surface/Surface.js +2 -2
  221. package/web-dist/components/switch/Switch.js +1 -1
  222. package/web-dist/components/tab/Tab.js +2 -2
  223. package/web-dist/components/tab-list/TabList.js +2 -2
  224. package/web-dist/components/tab-panel/TabPanel.js +2 -2
  225. package/web-dist/components/tabletop-table/TabletopTable.js +2 -2
  226. package/web-dist/components/tabs/Tabs.js +2 -2
  227. package/web-dist/components/text/Text.js +2 -2
  228. package/web-dist/components/text-input/TextInput.js +4 -4
  229. package/web-dist/components/time-picker/TimePicker.js +5 -5
  230. package/web-dist/components/timeline/Timeline.js +4 -4
  231. package/web-dist/components/toast/Toast.js +2 -2
  232. package/web-dist/components/toast-provider/ToastProvider.js +2 -2
  233. package/web-dist/components/toolbar/Toolbar.js +9 -9
  234. package/web-dist/components/tree-item/TreeItem.js +41 -22
  235. package/web-dist/components/tree-view/TreeView.d.ts +4 -3
  236. package/web-dist/components/tree-view/TreeView.js +25 -12
  237. package/web-dist/components/uploader/Uploader.js +10 -10
  238. package/web-dist/components/view/View.js +2 -2
  239. package/web-dist/manifest.json +577 -568
  240. package/web-dist/runtime/ZoraProvider.js +3 -3
  241. /package/web-dist/chunks/{Accordion-k3bc4kad.js → Accordion-13y3zx87.js} +0 -0
@@ -1,3 +1,4 @@
1
+ import type { SelectionIntent } from '@ankhorage/utility/selection';
1
2
  import React from 'react';
2
3
 
3
4
  import type {
@@ -7,12 +8,17 @@ import type {
7
8
  GraphViewRenderedNode,
8
9
  GraphViewSize,
9
10
  } from '../../../../../types/graph-view';
11
+ import { resolveSelectionEventIntent } from '../../../../selection/application/resolveSelectionEventIntent';
10
12
 
11
13
  interface GraphNodeOverlayProps {
12
14
  readonly nodes: readonly GraphViewNode[];
13
15
  readonly renderedNodes: readonly GraphViewRenderedNode[];
14
16
  readonly renderNode: (context: GraphViewNodeRenderContext) => React.ReactNode;
15
- readonly onNodeEvent: (id: string, type: GraphViewElementEventType) => void;
17
+ readonly onNodeEvent: (
18
+ id: string,
19
+ type: GraphViewElementEventType,
20
+ selectionIntent?: SelectionIntent,
21
+ ) => void;
16
22
  readonly onNodeSize: (id: string, size: GraphViewSize) => void;
17
23
  }
18
24
 
@@ -89,7 +95,9 @@ function MeasuredGraphNode(props: MeasuredGraphNodeProps) {
89
95
  transform: `translate(-50%, -50%) scale(${renderedNode.zoom})`,
90
96
  transformOrigin: 'center',
91
97
  }}
92
- onClick={() => props.onNodeEvent(node.id, 'press')}
98
+ onPointerUp={(event) =>
99
+ props.onNodeEvent(node.id, 'press', resolveSelectionEventIntent(event, 'pointer'))
100
+ }
93
101
  onDoubleClick={() => props.onNodeEvent(node.id, 'double-press')}
94
102
  onMouseEnter={() => props.onNodeEvent(node.id, 'pointer-enter')}
95
103
  onMouseLeave={() => props.onNodeEvent(node.id, 'pointer-leave')}
@@ -48,7 +48,9 @@ export function GraphView(props: GraphViewProps) {
48
48
  nodes={props.nodes}
49
49
  renderedNodes={renderedNodes}
50
50
  renderNode={props.renderNode}
51
- onNodeEvent={(id, type) => runtimeRef.current?.handleOverlayNodeEvent(id, type)}
51
+ onNodeEvent={(id, type, selectionIntent) =>
52
+ runtimeRef.current?.handleOverlayNodeEvent(id, type, selectionIntent)
53
+ }
52
54
  onNodeSize={(id, size) => runtimeRef.current?.setNodeSize(id, size)}
53
55
  />
54
56
  ) : null}
@@ -3,8 +3,10 @@ import type { Core, EventObject } from 'cytoscape';
3
3
  import type {
4
4
  GraphViewCallbacks,
5
5
  GraphViewController,
6
+ GraphViewElementEvent,
6
7
  GraphViewElementEventType,
7
8
  } from '../../../../../types/graph-view';
9
+ import { resolveSelectionEventIntent } from '../../../../selection/application/resolveSelectionEventIntent';
8
10
 
9
11
  /*** Bind stable Cytoscape events that always read the latest React callbacks. */
10
12
  export function bindGraphEvents(
@@ -46,10 +48,11 @@ function bindElementEvents(
46
48
  const handler = (event: EventObject) => {
47
49
  const target: unknown = event.target;
48
50
  if (!isGraphEventTarget(target)) return;
49
- emitElementEvent(callbacksRef.current, selector, {
50
- id: target.id(),
51
- type,
52
- });
51
+ emitElementEvent(
52
+ callbacksRef.current,
53
+ selector,
54
+ createElementEvent(event, target.id(), type),
55
+ );
53
56
  onRuntimeChange();
54
57
  };
55
58
  cy.on(cytoscapeEvent, selector, handler);
@@ -57,6 +60,20 @@ function bindElementEvents(
57
60
  });
58
61
  }
59
62
 
63
+ /*** Translate one renderer event into the engine-neutral public event contract. */
64
+ function createElementEvent(
65
+ event: EventObject,
66
+ id: string,
67
+ type: GraphViewElementEventType,
68
+ ): GraphViewElementEvent {
69
+ if (type !== 'press') return { id, type };
70
+ return {
71
+ id,
72
+ type,
73
+ selectionIntent: resolveSelectionEventIntent(event, 'pointer'),
74
+ };
75
+ }
76
+
60
77
  /*** Return whether a Cytoscape event target exposes the element identifier contract. */
61
78
  function isGraphEventTarget(value: unknown): value is GraphEventTarget {
62
79
  return (
@@ -72,7 +89,7 @@ interface GraphEventTarget {
72
89
  function emitElementEvent(
73
90
  callbacks: GraphViewCallbacks,
74
91
  selector: ElementSelector,
75
- event: { readonly id: string; readonly type: GraphViewElementEventType },
92
+ event: GraphViewElementEvent,
76
93
  ) {
77
94
  if (selector === 'node') {
78
95
  callbacks.onNodeEvent?.(event);
@@ -172,6 +172,27 @@ test('preserves selection and positions through hover presentation updates witho
172
172
  }
173
173
  });
174
174
 
175
+ test('forwards semantic selection intent from rich-node press events', async () => {
176
+ const ready = Promise.withResolvers<GraphViewController>();
177
+ const events: GraphViewElementEvent[] = [];
178
+ const runtime = createGraphRuntime(undefined, {
179
+ current: { onReady: ready.resolve, onNodeEvent: (event) => events.push(event) },
180
+ });
181
+ try {
182
+ runtime.update({
183
+ nodes: [{ id: 'a' }],
184
+ edges: [],
185
+ layout: 'grid',
186
+ richNodeRendering: false,
187
+ });
188
+ await ready.promise;
189
+ runtime.handleOverlayNodeEvent('a', 'press', 'toggle');
190
+ expect(events.at(-1)).toEqual({ id: 'a', type: 'press', selectionIntent: 'toggle' });
191
+ } finally {
192
+ runtime.destroy();
193
+ }
194
+ });
195
+
175
196
  test('settles the pending initial layout when presentation changes before readiness', async () => {
176
197
  const ready = Promise.withResolvers<GraphViewController>();
177
198
  const calls = { ready: 0, sort: 0 };
@@ -1,3 +1,4 @@
1
+ import type { SelectionIntent } from '@ankhorage/utility/selection';
1
2
  import cytoscape, { type Core, type CytoscapeOptions } from 'cytoscape';
2
3
 
3
4
  import type {
@@ -30,7 +31,11 @@ type RenderedNodeListener = (nodes: readonly GraphViewRenderedNode[]) => void;
30
31
 
31
32
  export interface GraphRuntime {
32
33
  destroy(): void;
33
- handleOverlayNodeEvent(id: string, type: GraphViewElementEventType): void;
34
+ handleOverlayNodeEvent(
35
+ id: string,
36
+ type: GraphViewElementEventType,
37
+ selectionIntent?: SelectionIntent,
38
+ ): void;
34
39
  setNodeSize(id: string, size: GraphViewSize): void;
35
40
  setSelectedNodeIds(nodeIds: readonly string[] | undefined): void;
36
41
  subscribeRenderedNodes(listener: RenderedNodeListener): () => void;
@@ -80,7 +85,8 @@ export function createGraphRuntime(
80
85
 
81
86
  return {
82
87
  destroy: () => destroyRuntime(state),
83
- handleOverlayNodeEvent: (id, type) => handleOverlayNodeEvent(state, id, type),
88
+ handleOverlayNodeEvent: (id, type, selectionIntent) =>
89
+ handleOverlayNodeEvent(state, id, type, selectionIntent),
84
90
  setNodeSize: (id, size) => setNodeSize(state, id, size),
85
91
  setSelectedNodeIds: (nodeIds) => setSelectedNodeIds(state, nodeIds),
86
92
  subscribeRenderedNodes: (listener) => subscribeRenderedNodes(state, listener),
@@ -401,11 +407,12 @@ function handleOverlayNodeEvent(
401
407
  state: GraphRuntimeState,
402
408
  id: string,
403
409
  type: GraphViewElementEventType,
410
+ selectionIntent?: SelectionIntent,
404
411
  ) {
405
412
  if (type === 'pointer-enter') state.hoveredNodeIds.add(id);
406
413
  if (type === 'pointer-leave') state.hoveredNodeIds.delete(id);
407
414
  if (type === 'press') state.cy.getElementById(id).select();
408
- state.callbacksRef.current.onNodeEvent?.({ id, type });
415
+ state.callbacksRef.current.onNodeEvent?.({ id, type, selectionIntent });
409
416
  emitRenderedNodes(state);
410
417
  }
411
418
 
@@ -1,29 +1,29 @@
1
1
  import { Pressable } from '@ankhorage/surface';
2
+ import type { SelectionIntent } from '@ankhorage/utility/selection';
2
3
  import React from 'react';
3
- import type { GestureResponderEvent } from 'react-native';
4
+ import { type GestureResponderEvent, Platform } from 'react-native';
4
5
 
5
6
  import type {
6
7
  SelectableItemProps,
7
8
  SelectableItemState,
8
9
  SelectionTrigger,
9
10
  } from '../../../../types/selection';
11
+ import { resolveSelectionEventIntent } from '../../application/resolveSelectionEventIntent';
10
12
  import { useSelection } from './SelectionProvider';
11
13
 
14
+ /*** Resolve the configured selection trigger. */
12
15
  function resolveTrigger(trigger: SelectionTrigger | undefined): SelectionTrigger {
13
16
  return trigger ?? 'manual';
14
17
  }
15
18
 
19
+ /*** Narrow render-prop children without changing ordinary React-node children. */
16
20
  function isRenderProp(
17
21
  children: SelectableItemProps['children'],
18
22
  ): children is (state: SelectableItemState) => React.ReactNode {
19
23
  return typeof children === 'function';
20
24
  }
21
25
 
22
- /***
23
- * Adds selection behavior to arbitrary child content via render props.
24
- *
25
-
26
- */
26
+ /*** Adds selection behavior to arbitrary child content via render props. */
27
27
  export function SelectableItem({
28
28
  id,
29
29
  trigger,
@@ -36,67 +36,50 @@ export function SelectableItem({
36
36
  const resolvedDisabled = selection.disabled || disabled;
37
37
  const selected = selection.isSelected(id);
38
38
 
39
+ const activate = React.useCallback(
40
+ (intent: SelectionIntent) => {
41
+ if (resolvedDisabled || interactionPolicy === 'passive') return;
42
+ selection.activate(id, intent);
43
+ },
44
+ [id, interactionPolicy, resolvedDisabled, selection],
45
+ );
39
46
  const select = React.useCallback(() => {
40
- if (resolvedDisabled) return;
41
- if (interactionPolicy === 'passive') return;
47
+ if (resolvedDisabled || interactionPolicy === 'passive') return;
42
48
  selection.select(id);
43
- }, [id, resolvedDisabled, interactionPolicy, selection]);
44
-
49
+ }, [id, interactionPolicy, resolvedDisabled, selection]);
45
50
  const toggle = React.useCallback(() => {
46
- if (resolvedDisabled) return;
47
- if (interactionPolicy === 'passive') return;
51
+ if (resolvedDisabled || interactionPolicy === 'passive') return;
48
52
  selection.toggle(id);
49
- }, [id, resolvedDisabled, interactionPolicy, selection]);
50
-
53
+ }, [id, interactionPolicy, resolvedDisabled, selection]);
51
54
  const clear = React.useCallback(() => {
52
- if (selection.disabled) return;
53
- if (interactionPolicy === 'passive') return;
55
+ if (selection.disabled || interactionPolicy === 'passive') return;
54
56
  selection.clear();
55
57
  }, [interactionPolicy, selection]);
56
58
 
57
- const itemState = React.useMemo<SelectableItemState>(() => {
58
- return {
59
+ const itemState = React.useMemo<SelectableItemState>(
60
+ () => ({
59
61
  id,
60
62
  selected,
61
63
  disabled: resolvedDisabled,
62
64
  mode: selection.mode,
65
+ activate,
63
66
  select,
64
67
  toggle,
65
68
  clear,
66
- };
67
- }, [clear, id, resolvedDisabled, select, selected, selection.mode, toggle]);
69
+ }),
70
+ [activate, clear, id, resolvedDisabled, select, selected, selection.mode, toggle],
71
+ );
68
72
 
69
- // IMPORTANT:
70
- // Do not pass `children` directly into Pressable. Pressable also supports function children,
71
- // but its function signature receives interaction state, not SelectableItemState.
72
73
  const content = isRenderProp(children) ? children(itemState) : children;
74
+ if (resolvedTrigger === 'manual') return <>{content}</>;
73
75
 
74
- if (resolvedTrigger === 'manual') {
75
- return <>{content}</>;
76
- }
77
-
78
- const handlePress = (event: GestureResponderEvent) => {
76
+ const handleActivation = (event: GestureResponderEvent) => {
79
77
  event.stopPropagation();
80
- if (resolvedDisabled) return;
81
- if (interactionPolicy === 'passive') return;
82
- if (selection.mode === 'single') {
83
- selection.select(id);
84
- return;
85
- }
86
-
87
- selection.toggle(id);
88
- };
89
-
90
- const handleLongPress = (event: GestureResponderEvent) => {
91
- event.stopPropagation();
92
- if (resolvedDisabled) return;
93
- if (interactionPolicy === 'passive') return;
94
- if (selection.mode === 'single') {
95
- selection.select(id);
96
- return;
97
- }
98
-
99
- selection.toggle(id);
78
+ if (resolvedDisabled || interactionPolicy === 'passive') return;
79
+ selection.activate(
80
+ id,
81
+ resolveSelectionEventIntent(event, Platform.OS === 'web' ? 'pointer' : 'touch'),
82
+ );
100
83
  };
101
84
 
102
85
  return (
@@ -105,8 +88,8 @@ export function SelectableItem({
105
88
  accessibilityRole="button"
106
89
  accessibilityState={{ disabled: resolvedDisabled, selected }}
107
90
  disabled={resolvedDisabled}
108
- onLongPress={resolvedTrigger === 'longPress' ? handleLongPress : undefined}
109
- onPress={resolvedTrigger === 'press' ? handlePress : undefined}
91
+ onLongPress={resolvedTrigger === 'longPress' ? handleActivation : undefined}
92
+ onPress={resolvedTrigger === 'press' ? handleActivation : undefined}
110
93
  >
111
94
  {content}
112
95
  </Pressable>
@@ -1,3 +1,4 @@
1
+ import { applySelectionIntent, type SelectionIntent } from '@ankhorage/utility/selection';
1
2
  import React from 'react';
2
3
 
3
4
  import type {
@@ -5,13 +6,7 @@ import type {
5
6
  SelectionProviderProps,
6
7
  UseSelectionResult,
7
8
  } from '../../../../types/selection';
8
- import {
9
- areIdsEqual,
10
- clearIds,
11
- normalizeIds,
12
- selectId,
13
- toggleId,
14
- } from '../../utils/resolveSelectionNextIds';
9
+ import { areIdsEqual, clearIds, normalizeIds, selectId } from '../../utils/resolveSelectionNextIds';
15
10
 
16
11
  const MISSING_CONTEXT_MESSAGE =
17
12
  'ZORA selection context is missing. Wrap this tree in <SelectionProvider>.';
@@ -30,21 +25,14 @@ function resolveDisabled(disabled: boolean | undefined): boolean {
30
25
  return disabled ?? false;
31
26
  }
32
27
 
33
- /***
34
- * Accesses selection state provided by `SelectionProvider`.
35
- */
28
+ /*** Accesses selection state provided by `SelectionProvider`. */
36
29
  export function useSelection(): UseSelectionResult {
37
30
  const value = React.use(SelectionContext);
38
- if (!value) {
39
- throw new Error(MISSING_CONTEXT_MESSAGE);
40
- }
41
-
31
+ if (!value) throw new Error(MISSING_CONTEXT_MESSAGE);
42
32
  return value;
43
33
  }
44
34
 
45
- /***
46
- * Provides selection state for building selectable lists and grids.
47
- */
35
+ /*** Provides selection state for building selectable lists and grids. */
48
36
  export function SelectionProvider({
49
37
  children,
50
38
  selectedIds,
@@ -57,27 +45,20 @@ export function SelectionProvider({
57
45
  const resolvedMode = resolveMode(mode);
58
46
  const resolvedDisabled = resolveDisabled(disabled);
59
47
  const isControlled = selectedIds !== undefined;
60
-
61
48
  const [uncontrolledIds, setUncontrolledIds] = React.useState<readonly string[]>(
62
49
  defaultSelectedIds ?? [],
63
50
  );
64
51
 
65
52
  const rawIds = isControlled ? selectedIds : uncontrolledIds;
66
53
  const currentNormalizedIds = normalizeIds(rawIds, resolvedMode);
67
-
68
54
  const selectedIdSet = React.useMemo(() => new Set(currentNormalizedIds), [currentNormalizedIds]);
69
55
 
70
56
  const commitSelectionChange = React.useCallback(
71
57
  (nextNormalizedIds: readonly string[]) => {
72
- if (resolvedDisabled) return;
73
- if (interactionPolicy === 'passive') return;
58
+ if (resolvedDisabled || interactionPolicy === 'passive') return;
74
59
  if (areIdsEqual(nextNormalizedIds, currentNormalizedIds)) return;
75
-
76
60
  onSelectionChange?.(nextNormalizedIds);
77
-
78
- if (!isControlled) {
79
- setUncontrolledIds(nextNormalizedIds);
80
- }
61
+ if (!isControlled) setUncontrolledIds(nextNormalizedIds);
81
62
  },
82
63
  [currentNormalizedIds, interactionPolicy, isControlled, onSelectionChange, resolvedDisabled],
83
64
  );
@@ -89,34 +70,52 @@ export function SelectionProvider({
89
70
  const select = React.useCallback(
90
71
  (id: string) => {
91
72
  const nextIds = selectId({ mode: resolvedMode, ids: currentNormalizedIds, id });
92
- const nextNormalizedIds = normalizeIds(nextIds, resolvedMode);
93
- commitSelectionChange(nextNormalizedIds);
73
+ commitSelectionChange(normalizeIds(nextIds, resolvedMode));
94
74
  },
95
75
  [commitSelectionChange, currentNormalizedIds, resolvedMode],
96
76
  );
97
77
 
98
78
  const toggle = React.useCallback(
99
79
  (id: string) => {
100
- const nextIds = toggleId({ mode: resolvedMode, ids: currentNormalizedIds, id });
101
- const nextNormalizedIds = normalizeIds(nextIds, resolvedMode);
102
- commitSelectionChange(nextNormalizedIds);
80
+ const nextIds = applySelectionIntent(currentNormalizedIds, id, 'toggle');
81
+ commitSelectionChange(normalizeIds(nextIds, resolvedMode));
103
82
  },
104
83
  [commitSelectionChange, currentNormalizedIds, resolvedMode],
105
84
  );
106
85
 
107
- const value = React.useMemo<UseSelectionResult>(() => {
108
- return {
86
+ const activate = React.useCallback(
87
+ (id: string, intent: SelectionIntent) => {
88
+ const effectiveIntent = resolvedMode === 'single' ? 'replace' : intent;
89
+ const nextIds = applySelectionIntent(currentNormalizedIds, id, effectiveIntent);
90
+ commitSelectionChange(normalizeIds(nextIds, resolvedMode));
91
+ },
92
+ [commitSelectionChange, currentNormalizedIds, resolvedMode],
93
+ );
94
+
95
+ const value = React.useMemo<UseSelectionResult>(
96
+ () => ({
109
97
  mode: resolvedMode,
110
98
  disabled: resolvedDisabled,
111
99
  selectedIds: currentNormalizedIds,
112
100
  selectedCount: currentNormalizedIds.length,
113
101
  hasSelection: currentNormalizedIds.length > 0,
114
102
  isSelected: (id: string) => selectedIdSet.has(id),
103
+ activate,
115
104
  select,
116
105
  toggle,
117
106
  clear,
118
- };
119
- }, [clear, currentNormalizedIds, resolvedDisabled, resolvedMode, select, selectedIdSet, toggle]);
107
+ }),
108
+ [
109
+ activate,
110
+ clear,
111
+ currentNormalizedIds,
112
+ resolvedDisabled,
113
+ resolvedMode,
114
+ select,
115
+ selectedIdSet,
116
+ toggle,
117
+ ],
118
+ );
120
119
 
121
120
  return <SelectionContext value={value}>{children}</SelectionContext>;
122
121
  }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { resolveSelectionEventIntent } from './resolveSelectionEventIntent';
4
+
5
+ describe('resolveSelectionEventIntent', () => {
6
+ test('replaces for an unmodified desktop pointer event', () => {
7
+ expect(resolveSelectionEventIntent({}, 'pointer')).toBe('replace');
8
+ });
9
+
10
+ test('toggles for Command and Control modifiers', () => {
11
+ expect(resolveSelectionEventIntent({ metaKey: true }, 'pointer')).toBe('toggle');
12
+ expect(resolveSelectionEventIntent({ nativeEvent: { ctrlKey: true } }, 'pointer')).toBe(
13
+ 'toggle',
14
+ );
15
+ });
16
+
17
+ test('toggles pointer events reported as touch', () => {
18
+ expect(
19
+ resolveSelectionEventIntent({ originalEvent: { pointerType: 'touch' } }, 'pointer'),
20
+ ).toBe('toggle');
21
+ });
22
+
23
+ test('toggles renderer touch events without pointerType', () => {
24
+ expect(
25
+ resolveSelectionEventIntent(
26
+ { originalEvent: { type: 'touchend', changedTouches: [] } },
27
+ 'pointer',
28
+ ),
29
+ ).toBe('toggle');
30
+ });
31
+
32
+ test('toggles native touch fallback without modifier data', () => {
33
+ expect(resolveSelectionEventIntent({}, 'touch')).toBe('toggle');
34
+ });
35
+ });
@@ -0,0 +1,55 @@
1
+ import {
2
+ type InteractionModifiers,
3
+ resolveSelectionIntent,
4
+ type SelectionActivationKind,
5
+ type SelectionInteractionInput,
6
+ } from '@ankhorage/utility/interaction';
7
+ import { isRecord } from '@ankhorage/utility/object';
8
+ import type { SelectionIntent } from '@ankhorage/utility/selection';
9
+
10
+ /*** Normalize DOM, React Native, and renderer events into one semantic selection intent. */
11
+ export function resolveSelectionEventIntent(
12
+ event: unknown,
13
+ fallbackKind: SelectionActivationKind,
14
+ ): SelectionIntent {
15
+ const source = readEventSource(event);
16
+ const kind: SelectionActivationKind = isTouchActivation(source) ? 'touch' : fallbackKind;
17
+ const sourceModifiers = readInteractionModifiers(source);
18
+ const wrapperModifiers = readInteractionModifiers(event);
19
+ const input: SelectionInteractionInput = {
20
+ kind,
21
+ modifiers: {
22
+ altKey: sourceModifiers.altKey === true || wrapperModifiers.altKey === true,
23
+ ctrlKey: sourceModifiers.ctrlKey === true || wrapperModifiers.ctrlKey === true,
24
+ metaKey: sourceModifiers.metaKey === true || wrapperModifiers.metaKey === true,
25
+ shiftKey: sourceModifiers.shiftKey === true || wrapperModifiers.shiftKey === true,
26
+ },
27
+ };
28
+ return resolveSelectionIntent(input);
29
+ }
30
+
31
+ /*** Prefer one nested native/renderer event while preserving structural event compatibility. */
32
+ function readEventSource(event: unknown): Readonly<Record<string, unknown>> {
33
+ if (!isRecord(event)) return {};
34
+ if (isRecord(event.originalEvent)) return event.originalEvent;
35
+ if (isRecord(event.nativeEvent)) return event.nativeEvent;
36
+ return event;
37
+ }
38
+
39
+ /*** Recognize pointer and native/browser touch event shapes without depending on platform event types. */
40
+ function isTouchActivation(source: Readonly<Record<string, unknown>>): boolean {
41
+ if (source.pointerType === 'touch') return true;
42
+ if (typeof source.type === 'string' && source.type.startsWith('touch')) return true;
43
+ return 'touches' in source || 'changedTouches' in source;
44
+ }
45
+
46
+ /*** Read known modifiers explicitly so arbitrary event properties never become dynamic input keys. */
47
+ function readInteractionModifiers(value: unknown): InteractionModifiers {
48
+ if (!isRecord(value)) return {};
49
+ return {
50
+ altKey: value.altKey === true,
51
+ ctrlKey: value.ctrlKey === true,
52
+ metaKey: value.metaKey === true,
53
+ shiftKey: value.shiftKey === true,
54
+ };
55
+ }
@@ -8,3 +8,5 @@ export type {
8
8
  } from '../../types/selection';
9
9
  export { SelectableItem } from './adapters/inbound/SelectableItem';
10
10
  export { SelectionProvider, useSelection } from './adapters/inbound/SelectionProvider';
11
+ export { resolveSelectionEventIntent } from './application/resolveSelectionEventIntent';
12
+ export type { SelectionIntent } from '@ankhorage/utility/selection';
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
 
3
3
  import type { SelectionMode } from '../../../types/selection';
4
- import { areIdsEqual, clearIds, normalizeIds, selectId, toggleId } from './resolveSelectionNextIds';
4
+ import { areIdsEqual, clearIds, normalizeIds, selectId } from './resolveSelectionNextIds';
5
5
 
6
6
  describe('resolveSelectionNextIds', () => {
7
7
  test('normalizeIds removes duplicates, preserves order', () => {
@@ -40,19 +40,6 @@ describe('resolveSelectionNextIds', () => {
40
40
  expect(selectId({ mode: 'multi', ids, id: 'b' })).toBe(ids);
41
41
  });
42
42
 
43
- test('toggleId clears id when already selected (single mode)', () => {
44
- expect(toggleId({ mode: 'single', ids: ['a'], id: 'a' })).toEqual([]);
45
- });
46
-
47
- test('toggleId selects id when not selected (single mode)', () => {
48
- expect(toggleId({ mode: 'single', ids: [], id: 'a' })).toEqual(['a']);
49
- });
50
-
51
- test('toggleId toggles membership (multi mode)', () => {
52
- expect(toggleId({ mode: 'multi', ids: ['a'], id: 'b' })).toEqual(['a', 'b']);
53
- expect(toggleId({ mode: 'multi', ids: ['a', 'b'], id: 'b' })).toEqual(['a']);
54
- });
55
-
56
43
  test('mode-change normalization example', () => {
57
44
  const internalIds = ['a', 'b'];
58
45
  const mode: SelectionMode = 'single';
@@ -1,5 +1,6 @@
1
1
  import type { SelectionMode } from '../../../types/selection';
2
2
 
3
+ /*** Normalize selected ids to stable unique membership within the configured selection mode. */
3
4
  export function normalizeIds(
4
5
  ids: readonly string[] | undefined,
5
6
  mode: SelectionMode,
@@ -17,6 +18,7 @@ export function normalizeIds(
17
18
  return uniqueIds;
18
19
  }
19
20
 
21
+ /*** Compare ordered selection ids without allocating replacement arrays. */
20
22
  export function areIdsEqual(a: readonly string[], b: readonly string[]): boolean {
21
23
  if (a.length !== b.length) return false;
22
24
  for (let index = 0; index < a.length; index += 1) {
@@ -26,10 +28,12 @@ export function areIdsEqual(a: readonly string[], b: readonly string[]): boolean
26
28
  return true;
27
29
  }
28
30
 
31
+ /*** Produce the canonical empty selection. */
29
32
  export function clearIds(): readonly string[] {
30
33
  return [];
31
34
  }
32
35
 
36
+ /*** Ensure one id is selected while preserving multi-selection membership. */
33
37
  export function selectId({
34
38
  mode,
35
39
  ids,
@@ -39,33 +43,7 @@ export function selectId({
39
43
  ids: readonly string[];
40
44
  id: string;
41
45
  }): readonly string[] {
42
- if (mode === 'single') {
43
- return [id];
44
- }
45
-
46
- if (ids.includes(id)) {
47
- return ids;
48
- }
49
-
50
- return [...ids, id];
51
- }
52
-
53
- export function toggleId({
54
- mode,
55
- ids,
56
- id,
57
- }: {
58
- mode: SelectionMode;
59
- ids: readonly string[];
60
- id: string;
61
- }): readonly string[] {
62
- if (ids.includes(id)) {
63
- return ids.filter((existingId) => existingId !== id);
64
- }
65
-
66
- if (mode === 'single') {
67
- return [id];
68
- }
69
-
46
+ if (mode === 'single') return [id];
47
+ if (ids.includes(id)) return ids;
70
48
  return [...ids, id];
71
49
  }