@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +433 -133
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/config/notis_app_design_rules.json +135 -0
  4. package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
  5. package/dist/base-skills/notis-apps/SKILL.md +70 -0
  6. package/dist/base-skills/notis-apps/references/architecture.md +164 -0
  7. package/dist/base-skills/notis-apps/references/context.md +81 -0
  8. package/dist/base-skills/notis-apps/references/design.md +165 -0
  9. package/dist/base-skills/notis-apps/references/reading.md +89 -0
  10. package/dist/base-skills/notis-apps/references/release.md +99 -0
  11. package/dist/base-skills/notis-apps/references/sdk.md +62 -0
  12. package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +140 -0
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  15. package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  17. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  18. package/dist/base-skills/notis-query/SKILL.md +67 -0
  19. package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
  20. package/dist/base-skills/notis-query/references/documents.md +50 -0
  21. package/dist/base-skills/notis-query/references/query.md +543 -0
  22. package/dist/skill-sync/index.js +1626 -0
  23. package/dist/skill-sync/index.js.map +7 -0
  24. package/dist/skill-sync-worker.mjs +2990 -0
  25. package/package.json +16 -6
  26. package/skills/notis-apps/cli.md +313 -0
  27. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  28. package/skills/notis-onboarding/BRIEF.md +129 -0
  29. package/skills/notis-query/cli.md +39 -0
  30. package/src/agent-hook-entry.js +5 -0
  31. package/src/cli.js +294 -25
  32. package/src/command-specs/agents.js +392 -0
  33. package/src/command-specs/apps.js +1470 -202
  34. package/src/command-specs/auth.js +114 -137
  35. package/src/command-specs/diagnostics.js +716 -0
  36. package/src/command-specs/handover.js +374 -0
  37. package/src/command-specs/helpers.js +84 -82
  38. package/src/command-specs/index.js +25 -6
  39. package/src/command-specs/meta.js +150 -18
  40. package/src/command-specs/onboarding.js +290 -0
  41. package/src/command-specs/profile.js +358 -0
  42. package/src/command-specs/reports.js +86 -0
  43. package/src/command-specs/skills.js +75 -0
  44. package/src/command-specs/smoke.js +386 -0
  45. package/src/command-specs/tools.js +455 -139
  46. package/src/runtime/agent-browser.js +632 -0
  47. package/src/runtime/agent-memory-state.js +126 -0
  48. package/src/runtime/agent-setup.js +383 -0
  49. package/src/runtime/app-boundary-validator.js +404 -0
  50. package/src/runtime/app-changelog.js +79 -0
  51. package/src/runtime/app-platform.js +2633 -210
  52. package/src/runtime/app-registry-scaffolds.js +367 -0
  53. package/src/runtime/app-test-server.js +292 -0
  54. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  55. package/src/runtime/auth-recovery.js +110 -0
  56. package/src/runtime/base-skills.d.ts +20 -0
  57. package/src/runtime/base-skills.js +167 -0
  58. package/src/runtime/channel.js +133 -0
  59. package/src/runtime/delegated-context.js +68 -0
  60. package/src/runtime/errors.js +1 -0
  61. package/src/runtime/git.js +233 -0
  62. package/src/runtime/login-listener.js +15 -0
  63. package/src/runtime/oauth.js +2622 -0
  64. package/src/runtime/output.js +37 -5
  65. package/src/runtime/ports.js +31 -0
  66. package/src/runtime/profiles.js +906 -55
  67. package/src/runtime/skill-sync/cloud-client.ts +99 -0
  68. package/src/runtime/skill-sync/index.ts +697 -0
  69. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  70. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  71. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  72. package/src/runtime/skill-sync/types.ts +110 -0
  73. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  74. package/src/runtime/skill-sync-service.js +109 -0
  75. package/src/runtime/store-screenshot.js +143 -0
  76. package/src/runtime/sync-skills.d.ts +37 -0
  77. package/src/runtime/sync-skills.js +231 -0
  78. package/src/runtime/telemetry.js +92 -0
  79. package/src/runtime/transport.js +324 -45
  80. package/src/skill-sync-worker-entry.js +2 -0
  81. package/src/skill-sync-worker.js +50 -0
  82. package/template/.harness/index.html.tmpl +430 -0
  83. package/template/CHANGELOG.md +5 -0
  84. package/template/app/layout.tsx +5 -2
  85. package/template/app/page.tsx +49 -42
  86. package/template/components/page-heading.tsx +23 -0
  87. package/template/components/ui/badge.tsx +7 -4
  88. package/template/components/ui/card.tsx +24 -11
  89. package/template/components/ui/native-select.tsx +24 -0
  90. package/template/notis.config.ts +24 -6
  91. package/template/package-lock.json +4137 -0
  92. package/template/package.json +5 -5
  93. package/template/packages/{notis-sdk → sdk}/package.json +13 -3
  94. package/template/packages/sdk/src/agentContext.ts +36 -0
  95. package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
  96. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  97. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  98. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  99. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  100. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  101. package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  102. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  103. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  104. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  105. package/template/packages/sdk/src/config.ts +257 -0
  106. package/template/packages/sdk/src/documents.ts +256 -0
  107. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  108. package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
  109. package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
  110. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
  111. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  112. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  113. package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
  114. package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
  115. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  116. package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
  117. package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
  118. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
  119. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
  120. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  121. package/template/packages/sdk/src/hooks/useTool.ts +65 -0
  122. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  123. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
  124. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  125. package/template/packages/sdk/src/index.ts +161 -0
  126. package/template/packages/sdk/src/interactions/actions.ts +59 -0
  127. package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
  128. package/template/packages/sdk/src/interactions/visibility.ts +13 -0
  129. package/template/packages/sdk/src/interactions.ts +45 -0
  130. package/template/packages/sdk/src/provider.tsx +44 -0
  131. package/template/packages/sdk/src/queryCache.ts +170 -0
  132. package/template/packages/sdk/src/runtime.ts +451 -0
  133. package/template/packages/sdk/src/styles.css +213 -0
  134. package/template/packages/sdk/src/tailwind.ts +56 -0
  135. package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
  136. package/template/tailwind.config.ts +1 -0
  137. package/src/command-specs/db.js +0 -163
  138. package/src/runtime/app-preview-server.js +0 -312
  139. package/template/packages/notis-sdk/src/config.ts +0 -48
  140. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  141. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  142. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  143. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  144. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  145. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  146. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  147. package/template/packages/notis-sdk/src/index.ts +0 -47
  148. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  149. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  150. package/template/packages/notis-sdk/src/styles.css +0 -123
  151. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  152. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  153. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  154. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -0,0 +1,836 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type MouseEvent as ReactMouseEvent,
11
+ } from 'react';
12
+ import {
13
+ collectionActionDefaults,
14
+ type CollectionAction,
15
+ type CollectionActionContext,
16
+ type ResolvedCollectionAction,
17
+ } from '../interactions/actions';
18
+ import {
19
+ activateShortcutCollection,
20
+ releaseShortcutCollection,
21
+ createShortcutCollectionOwner,
22
+ useShortcuts,
23
+ type ShortcutDefinition,
24
+ type ShortcutScope,
25
+ } from '../interactions/shortcuts';
26
+
27
+ import type { MultiSelectActionBarProps } from '../components/MultiSelectActionBar';
28
+ import { isInteractionElementVisible } from '../interactions/visibility';
29
+ import { useLongPressSelection } from './useLongPressSelection';
30
+
31
+ export const COLLECTION_ITEM_ATTRIBUTE = 'data-notis-collection-item-id';
32
+ const LEGACY_ROW_ATTRIBUTE = 'data-notis-row-id';
33
+ const INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, [role="button"], [contenteditable]:not([contenteditable="false"])';
34
+
35
+ export interface SelectionMarqueeRect {
36
+ left: number;
37
+ top: number;
38
+ width: number;
39
+ height: number;
40
+ }
41
+
42
+ export type CollectionInteractionReason =
43
+ | 'activate'
44
+ | 'clear'
45
+ | 'drag'
46
+ | 'items-changed'
47
+ | 'navigate'
48
+ | 'range'
49
+ | 'select-all'
50
+ | 'toggle';
51
+
52
+ export interface CollectionSelectionChange<T> {
53
+ reason: CollectionInteractionReason;
54
+ selectedIds: ReadonlySet<string>;
55
+ selectedItems: T[];
56
+ anchorId: string | null;
57
+ }
58
+
59
+ export type CollectionNavigationDirection = 'next' | 'previous' | 'up' | 'down' | 'left' | 'right';
60
+
61
+ export interface CollectionNavigationContext<T> {
62
+ direction: CollectionNavigationDirection;
63
+ currentId: string | null;
64
+ items: T[];
65
+ orderedIds: string[];
66
+ }
67
+
68
+ export interface CollectionKeyboardShortcuts {
69
+ clear: string | false;
70
+ selectAll: string | false;
71
+ toggle: string | false;
72
+ extendNext: string | string[] | false;
73
+ extendPrevious: string | string[] | false;
74
+ next: string | string[] | false;
75
+ previous: string | string[] | false;
76
+ up: string | false;
77
+ down: string | false;
78
+ left: string | false;
79
+ right: string | false;
80
+ activate: string | false;
81
+ }
82
+
83
+ export interface UseCollectionInteractionsOptions<T> {
84
+ items: T[];
85
+ getId: (item: T) => string;
86
+ selectionMode?: 'multiple' | 'single' | 'none';
87
+ selectedIds?: ReadonlySet<string>;
88
+ defaultSelectedIds?: Iterable<string>;
89
+ onSelectionChange?: (change: CollectionSelectionChange<T>) => void;
90
+ activeId?: string | null;
91
+ defaultActiveId?: string | null;
92
+ onActiveIdChange?: (id: string | null, reason: CollectionInteractionReason) => void;
93
+ anchorId?: string | null;
94
+ defaultAnchorId?: string | null;
95
+ onAnchorIdChange?: (id: string | null, reason: CollectionInteractionReason) => void;
96
+ onActivate?: (item: T, id: string) => void;
97
+ isItemDisabled?: (item: T) => boolean;
98
+ enabled?: boolean;
99
+ bindKeyboardShortcuts?: boolean;
100
+ enableDragSelect?: boolean;
101
+ /** Touch-only long press; optional per view. */
102
+ enableLongPressSelection?: boolean;
103
+ /** Plain item clicks activate the item and clear checkbox selection by default. */
104
+ clearSelectionOnPlainClick?: boolean;
105
+ dragThreshold?: number;
106
+ shortcuts?: Partial<CollectionKeyboardShortcuts>;
107
+ shortcutScope?: ShortcutScope;
108
+ shortcutPriority?: number;
109
+ resolveNextId?: (context: CollectionNavigationContext<T>) => string | null;
110
+ onActiveItemChange?: (id: string | null) => void;
111
+ actions?: CollectionAction<T>[];
112
+ }
113
+
114
+ export interface CollectionInteractionController<T> {
115
+ selectedIds: ReadonlySet<string>;
116
+ selectedCount: number;
117
+ activeId: string | null;
118
+ anchorId: string | null;
119
+ /** Compatibility aliases used by the original multi-select API. */
120
+ headId: string | null;
121
+ lastClickedId: string | null;
122
+ dragRect: SelectionMarqueeRect | null;
123
+ actions: ResolvedCollectionAction[];
124
+ getActionBarProps: () => Pick<MultiSelectActionBarProps, 'selectedCount' | 'actions' | 'collectionOwnerId' | 'isAvailable' | 'shortcutsEnabled' | 'onClearSelection'>;
125
+
126
+ isSelected: (id: string) => boolean;
127
+ getSelectedItems: () => T[];
128
+ setActive: (id: string | null, reason?: CollectionInteractionReason) => void;
129
+ activate: (id: string) => void;
130
+ toggle: (id: string) => void;
131
+ select: (ids: string[]) => void;
132
+ clear: () => void;
133
+ selectAll: () => void;
134
+ selectRange: (anchorId: string, targetId: string) => void;
135
+
136
+ onCheckboxClick: (id: string) => (event: ReactMouseEvent) => void;
137
+ onRowMouseDown: (id: string) => (event: ReactMouseEvent) => void;
138
+ getRowProps: (id: string) => Record<string, string>;
139
+ getItemProps: (id: string) => Partial<ReturnType<ReturnType<typeof useLongPressSelection>>> & {
140
+ [COLLECTION_ITEM_ATTRIBUTE]: string;
141
+ [LEGACY_ROW_ATTRIBUTE]: string;
142
+ tabIndex: number;
143
+ 'aria-selected': boolean;
144
+ 'aria-disabled': boolean;
145
+ 'data-notis-active': 'true' | 'false';
146
+ onFocus: () => void;
147
+ onMouseDown: (event: ReactMouseEvent) => void;
148
+ onClick: (event: ReactMouseEvent) => void;
149
+ onKeyDown: (event: ReactKeyboardEvent) => void;
150
+ };
151
+ getCheckboxProps: (id: string) => {
152
+ isSelected: boolean;
153
+ disabled: boolean;
154
+ onClick: (event: ReactMouseEvent) => void;
155
+ };
156
+ getContainerProps: () => {
157
+ ref: (node: HTMLElement | null) => void;
158
+ onMouseDown: (event: ReactMouseEvent) => void;
159
+ onFocusCapture: () => void;
160
+ onClickCapture: (event: ReactMouseEvent) => void;
161
+ style: { userSelect: 'none' };
162
+ };
163
+ }
164
+
165
+ const DEFAULT_SHORTCUTS: CollectionKeyboardShortcuts = {
166
+ clear: 'Escape',
167
+ selectAll: 'Mod+A',
168
+ toggle: 'X',
169
+ extendNext: ['Shift+ArrowDown', 'Shift+ArrowRight'],
170
+ extendPrevious: ['Shift+ArrowUp', 'Shift+ArrowLeft'],
171
+ next: 'J',
172
+ previous: 'K',
173
+ up: 'ArrowUp',
174
+ down: 'ArrowDown',
175
+ left: false,
176
+ right: false,
177
+ activate: 'Enter',
178
+ };
179
+
180
+ function filterShortcutKeys(
181
+ keys: string | string[] | false,
182
+ predicate: (key: string) => boolean,
183
+ ): string | string[] | false {
184
+ if (!keys) return false;
185
+ const filtered = (Array.isArray(keys) ? keys : [keys]).filter(predicate);
186
+ if (filtered.length === 0) return false;
187
+ return filtered.length === 1 ? filtered[0] : filtered;
188
+ }
189
+
190
+ function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
191
+ if (a.size !== b.size) return false;
192
+ for (const id of a) if (!b.has(id)) return false;
193
+ return true;
194
+ }
195
+
196
+ function rectsIntersect(
197
+ a: { left: number; top: number; right: number; bottom: number },
198
+ b: { left: number; top: number; right: number; bottom: number },
199
+ ): boolean {
200
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
201
+ }
202
+
203
+ export function useCollectionInteractions<T>(
204
+ options: UseCollectionInteractionsOptions<T>,
205
+ ): CollectionInteractionController<T> {
206
+ const {
207
+ items,
208
+ getId,
209
+ selectionMode = 'multiple',
210
+ selectedIds: controlledSelectedIds,
211
+ defaultSelectedIds,
212
+ onSelectionChange,
213
+ activeId: controlledActiveId,
214
+ defaultActiveId = null,
215
+ onActiveIdChange,
216
+ anchorId: controlledAnchorId,
217
+ defaultAnchorId,
218
+ onAnchorIdChange,
219
+ onActivate,
220
+ isItemDisabled,
221
+ enabled = true,
222
+ bindKeyboardShortcuts = true,
223
+ enableDragSelect = selectionMode === 'multiple',
224
+ enableLongPressSelection = false,
225
+ clearSelectionOnPlainClick = true,
226
+ dragThreshold = 5,
227
+ shortcuts: shortcutOverrides,
228
+ shortcutScope = 'collection',
229
+ shortcutPriority = 0,
230
+ resolveNextId,
231
+ onActiveItemChange,
232
+ actions: actionDefinitions = [],
233
+ } = options;
234
+
235
+ const [uncontrolledSelectedIds, setUncontrolledSelectedIds] = useState<Set<string>>(
236
+ () => new Set(defaultSelectedIds ?? []),
237
+ );
238
+ const selectedIds = controlledSelectedIds ?? uncontrolledSelectedIds;
239
+ const [uncontrolledActiveId, setUncontrolledActiveId] = useState<string | null>(defaultActiveId);
240
+ const activeId = controlledActiveId === undefined ? uncontrolledActiveId : controlledActiveId;
241
+ const [uncontrolledAnchorId, setUncontrolledAnchorId] = useState<string | null>(
242
+ defaultAnchorId === undefined ? defaultActiveId : defaultAnchorId,
243
+ );
244
+ const anchorId = controlledAnchorId === undefined ? uncontrolledAnchorId : controlledAnchorId;
245
+ const [dragRect, setDragRect] = useState<SelectionMarqueeRect | null>(null);
246
+
247
+ const itemsRef = useRef(items);
248
+ const getIdRef = useRef(getId);
249
+ const selectedIdsRef = useRef(selectedIds);
250
+ const activeIdRef = useRef(activeId);
251
+ const anchorIdRef = useRef(anchorId);
252
+ // Keep manually selected rows outside the current keyboard range on reversal.
253
+ const keyboardRangeBaseRef = useRef<Set<string> | null>(null);
254
+ const onSelectionChangeRef = useRef(onSelectionChange);
255
+ const onActiveIdChangeRef = useRef(onActiveIdChange);
256
+ const onAnchorIdChangeRef = useRef(onAnchorIdChange);
257
+ const onActiveItemChangeRef = useRef(onActiveItemChange);
258
+ const onActivateRef = useRef(onActivate);
259
+ const resolveNextIdRef = useRef(resolveNextId);
260
+ const containerRef = useRef<HTMLElement | null>(null);
261
+ const [containerElement, setContainerElement] = useState<HTMLElement | null>(null);
262
+ const setContainerRef = useCallback((node: HTMLElement | null) => {
263
+ containerRef.current = node;
264
+ setContainerElement(node);
265
+ }, []);
266
+ const shortcutCollectionOwnerRef = useRef<string | null>(null);
267
+ if (!shortcutCollectionOwnerRef.current) {
268
+ shortcutCollectionOwnerRef.current = createShortcutCollectionOwner();
269
+ }
270
+
271
+ itemsRef.current = items;
272
+ getIdRef.current = getId;
273
+ if (!setsEqual(selectedIdsRef.current, selectedIds) || anchorIdRef.current !== anchorId) {
274
+ keyboardRangeBaseRef.current = null;
275
+ }
276
+ selectedIdsRef.current = selectedIds;
277
+ activeIdRef.current = activeId;
278
+ anchorIdRef.current = anchorId;
279
+ onSelectionChangeRef.current = onSelectionChange;
280
+ onActiveIdChangeRef.current = onActiveIdChange;
281
+ onAnchorIdChangeRef.current = onAnchorIdChange;
282
+ onActiveItemChangeRef.current = onActiveItemChange;
283
+ onActivateRef.current = onActivate;
284
+ resolveNextIdRef.current = resolveNextId;
285
+
286
+ const itemById = useMemo(() => new Map(items.map((item) => [getId(item), item])), [getId, items]);
287
+ const selectableIds = useMemo(
288
+ () => items.filter((item) => !isItemDisabled?.(item)).map((item) => getId(item)),
289
+ [getId, isItemDisabled, items],
290
+ );
291
+ const selectableIdsRef = useRef(selectableIds);
292
+ selectableIdsRef.current = selectableIds;
293
+
294
+ const getItemsForIds = useCallback((ids: ReadonlySet<string>) => {
295
+ return itemsRef.current.filter((item) => ids.has(getIdRef.current(item)));
296
+ }, []);
297
+
298
+ const commitSelection = useCallback((next: Set<string>, reason: CollectionInteractionReason) => {
299
+ const allowed = new Set(selectableIdsRef.current);
300
+ const normalized = selectionMode === 'none'
301
+ ? new Set<string>()
302
+ : new Set(Array.from(next).filter((id) => allowed.has(id)));
303
+ if (selectionMode === 'single' && normalized.size > 1) {
304
+ const first = normalized.values().next().value as string | undefined;
305
+ normalized.clear();
306
+ if (first) normalized.add(first);
307
+ }
308
+ if (setsEqual(normalized, selectedIdsRef.current)) return;
309
+ selectedIdsRef.current = normalized;
310
+ if (controlledSelectedIds === undefined) setUncontrolledSelectedIds(normalized);
311
+ onSelectionChangeRef.current?.({
312
+ reason,
313
+ selectedIds: normalized,
314
+ selectedItems: getItemsForIds(normalized),
315
+ anchorId: anchorIdRef.current,
316
+ });
317
+ }, [controlledSelectedIds, getItemsForIds, selectionMode]);
318
+
319
+ const setActive = useCallback((id: string | null, reason: CollectionInteractionReason = 'activate') => {
320
+ const next = id && selectableIdsRef.current.includes(id) ? id : null;
321
+ if (activeIdRef.current === next) return;
322
+ activeIdRef.current = next;
323
+ if (controlledActiveId === undefined) setUncontrolledActiveId(next);
324
+ onActiveIdChangeRef.current?.(next, reason);
325
+ onActiveItemChangeRef.current?.(next);
326
+ }, [controlledActiveId]);
327
+
328
+ const setAnchor = useCallback((id: string | null, reason: CollectionInteractionReason) => {
329
+ if (anchorIdRef.current === id) return;
330
+ anchorIdRef.current = id;
331
+ if (controlledAnchorId === undefined) setUncontrolledAnchorId(id);
332
+ onAnchorIdChangeRef.current?.(id, reason);
333
+ }, [controlledAnchorId]);
334
+
335
+ const activate = useCallback((id: string) => {
336
+ const item = itemsRef.current.find((candidate) => getIdRef.current(candidate) === id);
337
+ if (!item || !selectableIdsRef.current.includes(id)) return;
338
+ keyboardRangeBaseRef.current = null;
339
+ setActive(id, 'activate');
340
+ setAnchor(id, 'activate');
341
+ onActivateRef.current?.(item, id);
342
+ }, [setActive, setAnchor]);
343
+
344
+ const toggle = useCallback((id: string) => {
345
+ if (selectionMode === 'none' || !selectableIdsRef.current.includes(id)) return;
346
+ keyboardRangeBaseRef.current = null;
347
+ const next = new Set(selectedIdsRef.current);
348
+ if (next.has(id)) next.delete(id);
349
+ else {
350
+ if (selectionMode === 'single') next.clear();
351
+ next.add(id);
352
+ }
353
+ setAnchor(id, 'toggle');
354
+ commitSelection(next, 'toggle');
355
+ }, [commitSelection, selectionMode, setAnchor]);
356
+
357
+ const select = useCallback((ids: string[]) => {
358
+ keyboardRangeBaseRef.current = null;
359
+ const next = new Set(ids);
360
+ const last = ids[ids.length - 1] ?? null;
361
+ setAnchor(last, 'toggle');
362
+ commitSelection(next, 'toggle');
363
+ }, [commitSelection, setAnchor]);
364
+
365
+ const clear = useCallback(() => {
366
+ keyboardRangeBaseRef.current = null;
367
+ setAnchor(null, 'clear');
368
+ commitSelection(new Set(), 'clear');
369
+ }, [commitSelection, setAnchor]);
370
+
371
+ const selectAll = useCallback(() => {
372
+ if (selectionMode === 'none') return;
373
+ keyboardRangeBaseRef.current = null;
374
+ const ids = selectionMode === 'single' ? selectableIdsRef.current.slice(0, 1) : selectableIdsRef.current;
375
+ const last = ids[ids.length - 1] ?? null;
376
+ setAnchor(last, 'select-all');
377
+ commitSelection(new Set(ids), 'select-all');
378
+ }, [commitSelection, selectionMode, setAnchor]);
379
+
380
+ const replaceRange = useCallback((fromId: string, toId: string, reason: CollectionInteractionReason) => {
381
+ const ids = selectableIdsRef.current;
382
+ const from = ids.indexOf(fromId);
383
+ const to = ids.indexOf(toId);
384
+ if (from === -1 || to === -1) {
385
+ commitSelection(new Set([toId]), reason);
386
+ return;
387
+ }
388
+ commitSelection(new Set([
389
+ ...(keyboardRangeBaseRef.current ?? []),
390
+ ...ids.slice(Math.min(from, to), Math.max(from, to) + 1),
391
+ ]), reason);
392
+ }, [commitSelection]);
393
+
394
+ const selectRange = useCallback((fromId: string, toId: string) => {
395
+ const ids = selectableIdsRef.current;
396
+ if (selectionMode === 'none' || !ids.includes(toId)) return;
397
+ keyboardRangeBaseRef.current = null;
398
+ const resolvedFromId = ids.includes(fromId)
399
+ ? fromId
400
+ : activeIdRef.current && ids.includes(activeIdRef.current)
401
+ ? activeIdRef.current
402
+ : toId;
403
+ setAnchor(toId, 'range');
404
+ if (selectionMode === 'single') {
405
+ commitSelection(new Set([toId]), 'range');
406
+ return;
407
+ }
408
+ const from = ids.indexOf(resolvedFromId);
409
+ const to = ids.indexOf(toId);
410
+ if (from === -1 || to === -1) {
411
+ commitSelection(new Set([toId]), 'range');
412
+ } else {
413
+ const next = new Set(selectedIdsRef.current);
414
+ for (const id of ids.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(id);
415
+ commitSelection(next, 'range');
416
+ }
417
+ }, [commitSelection, selectionMode, setAnchor]);
418
+
419
+ useEffect(() => {
420
+ const allowed = new Set(selectableIds);
421
+ if (keyboardRangeBaseRef.current) {
422
+ keyboardRangeBaseRef.current = new Set(Array.from(keyboardRangeBaseRef.current).filter((id) => allowed.has(id)));
423
+ }
424
+ const pruned = new Set(Array.from(selectedIdsRef.current).filter((id) => allowed.has(id)));
425
+ if (!setsEqual(pruned, selectedIdsRef.current)) commitSelection(pruned, 'items-changed');
426
+ if (activeIdRef.current && !allowed.has(activeIdRef.current)) setActive(null, 'items-changed');
427
+ if (anchorIdRef.current && !allowed.has(anchorIdRef.current)) {
428
+ setAnchor(null, 'items-changed');
429
+ }
430
+ }, [commitSelection, selectableIds, setActive, setAnchor]);
431
+
432
+ const focusItem = useCallback((id: string) => {
433
+ const nodes = containerRef.current?.querySelectorAll<HTMLElement>(`[${COLLECTION_ITEM_ATTRIBUTE}]`);
434
+ const node = nodes ? Array.from(nodes).find(
435
+ (candidate) => candidate.getAttribute(COLLECTION_ITEM_ATTRIBUTE) === id && isInteractionElementVisible(candidate),
436
+ ) : null;
437
+ node?.focus({ preventScroll: true });
438
+ node?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
439
+ }, []);
440
+
441
+ const focusPointerItem = useCallback((id: string) => {
442
+ setActive(id, 'activate');
443
+ focusItem(id);
444
+ }, [focusItem, setActive]);
445
+
446
+ const selectPointerRange = useCallback((id: string) => {
447
+ const rangeAnchor = anchorIdRef.current ?? activeIdRef.current ?? id;
448
+ const ids = selectableIdsRef.current;
449
+ const from = ids.indexOf(rangeAnchor);
450
+ const to = ids.indexOf(id);
451
+ const range = new Set(ids.slice(Math.min(from, to), Math.max(from, to) + 1));
452
+ const base = keyboardRangeBaseRef.current ?? new Set(Array.from(selectedIdsRef.current).filter((selected) => !range.has(selected)));
453
+ keyboardRangeBaseRef.current = base;
454
+ setAnchor(rangeAnchor, 'range');
455
+ replaceRange(rangeAnchor, id, 'range');
456
+ focusPointerItem(id);
457
+ }, [focusPointerItem, replaceRange, setAnchor]);
458
+
459
+ const moveActive = useCallback((direction: CollectionNavigationDirection, extend = false) => {
460
+ const ids = selectableIdsRef.current;
461
+ if (ids.length === 0) return;
462
+ const current = activeIdRef.current;
463
+ // Range gestures follow the displayed order, independent of asymmetric grid geometry.
464
+ let next = extend ? null : resolveNextIdRef.current?.({
465
+ direction,
466
+ currentId: current,
467
+ items: itemsRef.current,
468
+ orderedIds: ids,
469
+ }) ?? null;
470
+ if (!next) {
471
+ const currentIndex = current ? ids.indexOf(current) : -1;
472
+ const forward = direction === 'next' || direction === 'down' || direction === 'right';
473
+ const nextIndex = currentIndex === -1
474
+ ? forward ? 0 : ids.length - 1
475
+ : Math.min(ids.length - 1, Math.max(0, currentIndex + (forward ? 1 : -1)));
476
+ next = ids[nextIndex] ?? null;
477
+ }
478
+ if (!next || next === current) return;
479
+ const stableAnchor = anchorIdRef.current ?? current ?? next;
480
+ if (extend) {
481
+ keyboardRangeBaseRef.current ??= new Set(
482
+ Array.from(selectedIdsRef.current).filter((id) => id !== stableAnchor),
483
+ );
484
+ if (!anchorIdRef.current) {
485
+ setAnchor(stableAnchor, 'range');
486
+ }
487
+ replaceRange(stableAnchor, next, 'range');
488
+ } else {
489
+ keyboardRangeBaseRef.current = new Set();
490
+ setAnchor(next, 'navigate');
491
+ }
492
+ setActive(next, extend ? 'range' : 'navigate');
493
+ const nextId = next;
494
+ requestAnimationFrame(() => focusItem(nextId));
495
+ }, [focusItem, replaceRange, setActive, setAnchor]);
496
+
497
+ const keyboard = useMemo<CollectionKeyboardShortcuts>(
498
+ () => ({ ...DEFAULT_SHORTCUTS, ...(shortcutOverrides || {}) }),
499
+ [shortcutOverrides],
500
+ );
501
+ const keyDefinitions = useMemo<ShortcutDefinition[]>(() => {
502
+ const definitions: ShortcutDefinition[] = [];
503
+ const add = (
504
+ id: string,
505
+ label: string,
506
+ keys: string | string[] | false,
507
+ onTrigger: () => void,
508
+ ) => {
509
+ if (!keys) return;
510
+ definitions.push({ id, label, keys, onTrigger });
511
+ };
512
+ const extendRight = filterShortcutKeys(keyboard.extendNext, (key) => key.toLowerCase().endsWith('arrowright'));
513
+ const extendDown = filterShortcutKeys(keyboard.extendNext, (key) => !key.toLowerCase().endsWith('arrowright'));
514
+ const extendLeft = filterShortcutKeys(keyboard.extendPrevious, (key) => key.toLowerCase().endsWith('arrowleft'));
515
+ const extendUp = filterShortcutKeys(keyboard.extendPrevious, (key) => !key.toLowerCase().endsWith('arrowleft'));
516
+ add('collection.clear', 'Clear selection', keyboard.clear, clear);
517
+ add('collection.select-all', 'Select all visible items', selectionMode === 'none' ? false : keyboard.selectAll, selectAll);
518
+ add('collection.toggle', 'Toggle active item', selectionMode === 'none' ? false : keyboard.toggle, () => {
519
+ const id = activeIdRef.current ?? anchorIdRef.current;
520
+ if (id) toggle(id);
521
+ });
522
+ add('collection.extend-down', 'Extend selection down', selectionMode !== 'multiple' ? false : extendDown, () => moveActive('down', true));
523
+ add('collection.extend-right', 'Extend selection right', selectionMode !== 'multiple' ? false : extendRight, () => moveActive('right', true));
524
+ add('collection.extend-up', 'Extend selection up', selectionMode !== 'multiple' ? false : extendUp, () => moveActive('up', true));
525
+ add('collection.extend-left', 'Extend selection left', selectionMode !== 'multiple' ? false : extendLeft, () => moveActive('left', true));
526
+ add('collection.next', 'Next item', keyboard.next, () => moveActive('next'));
527
+ add('collection.previous', 'Previous item', keyboard.previous, () => moveActive('previous'));
528
+ add('collection.up', 'Move up', keyboard.up, () => moveActive('up'));
529
+ add('collection.down', 'Move down', keyboard.down, () => moveActive('down'));
530
+ add('collection.left', 'Move left', keyboard.left, () => moveActive('left'));
531
+ add('collection.right', 'Move right', keyboard.right, () => moveActive('right'));
532
+ add('collection.activate', 'Open active item', keyboard.activate, () => {
533
+ const id = activeIdRef.current ?? selectableIdsRef.current[0];
534
+ if (id) activate(id);
535
+ });
536
+ return definitions;
537
+ }, [activate, clear, keyboard, moveActive, selectAll, selectionMode, toggle]);
538
+ const isAvailable = useCallback(() => isInteractionElementVisible(containerRef.current), []);
539
+ useEffect(() => {
540
+ const owner = shortcutCollectionOwnerRef.current!;
541
+ const container = containerElement;
542
+ const Observer = container?.ownerDocument.defaultView?.MutationObserver;
543
+ const observer = Observer ? new Observer(() => {
544
+ if (!isAvailable()) releaseShortcutCollection(owner);
545
+ }) : null;
546
+ let element: Element | null = container;
547
+ while (element) {
548
+ observer?.observe(element, { attributes: true, attributeFilter: ['hidden', 'inert', 'aria-hidden', 'style', 'class'] });
549
+ const root: Node = element.getRootNode();
550
+ element = element.parentElement ?? ('host' in root ? (root as ShadowRoot).host : null);
551
+ }
552
+ return () => { observer?.disconnect(); releaseShortcutCollection(owner); };
553
+ }, [containerElement, isAvailable]);
554
+
555
+ useShortcuts(keyDefinitions, {
556
+ enabled: enabled && bindKeyboardShortcuts,
557
+ scope: shortcutScope,
558
+ priority: shortcutPriority,
559
+ collectionOwnerId: shortcutCollectionOwnerRef.current,
560
+ isAvailable,
561
+ });
562
+
563
+ const activateCollectionShortcuts = useCallback(() => {
564
+ activateShortcutCollection(shortcutCollectionOwnerRef.current!);
565
+ }, []);
566
+
567
+ const dragPendingRef = useRef(false);
568
+ const dragActiveRef = useRef(false);
569
+ const dragStartRef = useRef({ x: 0, y: 0 });
570
+ const dragCurrentRef = useRef({ x: 0, y: 0 });
571
+ const clickShouldClearRef = useRef(false);
572
+ const suppressNextClickRef = useRef(false);
573
+
574
+ const armSuppressNextClick = useCallback(() => {
575
+ suppressNextClickRef.current = true;
576
+ setTimeout(() => { suppressNextClickRef.current = false; }, 0);
577
+ }, []);
578
+
579
+ const computeDragRect = useCallback((): SelectionMarqueeRect => ({
580
+ left: Math.min(dragStartRef.current.x, dragCurrentRef.current.x),
581
+ top: Math.min(dragStartRef.current.y, dragCurrentRef.current.y),
582
+ width: Math.abs(dragCurrentRef.current.x - dragStartRef.current.x),
583
+ height: Math.abs(dragCurrentRef.current.y - dragStartRef.current.y),
584
+ }), []);
585
+
586
+ const computeIntersectingIds = useCallback((rect: {
587
+ left: number; top: number; right: number; bottom: number;
588
+ }) => {
589
+ const nodes = containerRef.current?.querySelectorAll<HTMLElement>(`[${COLLECTION_ITEM_ATTRIBUTE}]`);
590
+ if (!nodes) return [];
591
+ const ids: string[] = [];
592
+ nodes.forEach((node) => {
593
+ if (!rectsIntersect(rect, node.getBoundingClientRect())) return;
594
+ const id = node.getAttribute(COLLECTION_ITEM_ATTRIBUTE);
595
+ if (id && selectableIdsRef.current.includes(id)) ids.push(id);
596
+ });
597
+ return ids;
598
+ }, []);
599
+
600
+ const handleContainerMouseDown = useCallback((event: ReactMouseEvent) => {
601
+ if (!enabled || event.button !== 0) return;
602
+ const target = event.target as HTMLElement | null;
603
+ if (!target) return;
604
+ const itemTarget = target.closest(`[${COLLECTION_ITEM_ATTRIBUTE}]`);
605
+ const interactiveTarget = target.closest(INTERACTIVE_SELECTOR);
606
+ if (interactiveTarget && interactiveTarget !== itemTarget) {
607
+ clickShouldClearRef.current = false;
608
+ return;
609
+ }
610
+ clickShouldClearRef.current = !itemTarget;
611
+ if (itemTarget) return;
612
+ if (!enableDragSelect || selectionMode !== 'multiple') return;
613
+ dragPendingRef.current = true;
614
+ dragStartRef.current = { x: event.clientX, y: event.clientY };
615
+ dragCurrentRef.current = { x: event.clientX, y: event.clientY };
616
+ }, [enableDragSelect, enabled, selectionMode]);
617
+
618
+ const handleContainerClickCapture = useCallback((event: ReactMouseEvent) => {
619
+ if (!suppressNextClickRef.current) return;
620
+ suppressNextClickRef.current = false;
621
+ event.preventDefault();
622
+ event.stopPropagation();
623
+ }, []);
624
+
625
+ useEffect(() => {
626
+ if (!enabled || !enableDragSelect || selectionMode !== 'multiple') return;
627
+ const handleMouseMove = (event: MouseEvent) => {
628
+ if (dragPendingRef.current) {
629
+ const dx = event.clientX - dragStartRef.current.x;
630
+ const dy = event.clientY - dragStartRef.current.y;
631
+ if (Math.abs(dx) > dragThreshold || Math.abs(dy) > dragThreshold) {
632
+ dragPendingRef.current = false;
633
+ dragActiveRef.current = true;
634
+ window.getSelection()?.removeAllRanges();
635
+ } else return;
636
+ }
637
+ if (!dragActiveRef.current) return;
638
+ event.preventDefault();
639
+ dragCurrentRef.current = { x: event.clientX, y: event.clientY };
640
+ const rect = computeDragRect();
641
+ setDragRect(rect);
642
+ const ids = computeIntersectingIds({
643
+ left: rect.left,
644
+ top: rect.top,
645
+ right: rect.left + rect.width,
646
+ bottom: rect.top + rect.height,
647
+ });
648
+ commitSelection(new Set(ids), 'drag');
649
+ const last = ids[ids.length - 1] ?? null;
650
+ setAnchor(last, 'drag');
651
+ };
652
+ const handleMouseUp = () => {
653
+ const wasPending = dragPendingRef.current;
654
+ const wasActive = dragActiveRef.current;
655
+ dragPendingRef.current = false;
656
+ dragActiveRef.current = false;
657
+ if (wasActive) {
658
+ setDragRect(null);
659
+ clickShouldClearRef.current = false;
660
+ armSuppressNextClick();
661
+ } else if (wasPending && clickShouldClearRef.current) {
662
+ clickShouldClearRef.current = false;
663
+ clear();
664
+ }
665
+ };
666
+ document.addEventListener('mousemove', handleMouseMove);
667
+ document.addEventListener('mouseup', handleMouseUp);
668
+ return () => {
669
+ document.removeEventListener('mousemove', handleMouseMove);
670
+ document.removeEventListener('mouseup', handleMouseUp);
671
+ };
672
+ }, [armSuppressNextClick, clear, commitSelection, computeDragRect, computeIntersectingIds, dragThreshold, enableDragSelect, enabled, selectionMode, setAnchor]);
673
+
674
+ const onRowMouseDown = useCallback((id: string) => (event: ReactMouseEvent) => {
675
+ if (!enabled || event.button !== 0 || event.shiftKey || !(event.metaKey || event.ctrlKey)) return;
676
+ const target = event.target as HTMLElement | null;
677
+ const interactiveTarget = target?.closest(INTERACTIVE_SELECTOR);
678
+ if (interactiveTarget && interactiveTarget !== event.currentTarget) return;
679
+ event.preventDefault();
680
+ event.stopPropagation();
681
+ toggle(id);
682
+ focusPointerItem(id);
683
+ armSuppressNextClick();
684
+ }, [armSuppressNextClick, enabled, focusPointerItem, toggle]);
685
+
686
+ const onCheckboxClick = useCallback((id: string) => (event: ReactMouseEvent) => {
687
+ event.stopPropagation();
688
+ if (!enabled) return;
689
+ if (event.shiftKey && selectionMode === 'multiple') {
690
+ selectPointerRange(id);
691
+ } else {
692
+ toggle(id);
693
+ focusPointerItem(id);
694
+ }
695
+ }, [enabled, focusPointerItem, selectPointerRange, selectionMode, toggle]);
696
+
697
+ const getRowProps = useCallback((id: string) => ({
698
+ [COLLECTION_ITEM_ATTRIBUTE]: id,
699
+ [LEGACY_ROW_ATTRIBUTE]: id,
700
+ }), []);
701
+
702
+ const longPress = useLongPressSelection((id) => {
703
+ if (!enabled || selectionMode === 'none') return;
704
+ activateCollectionShortcuts();
705
+ toggle(id);
706
+ focusPointerItem(id);
707
+ });
708
+ const getItemProps = useCallback((id: string) => ({
709
+ ...(enableLongPressSelection ? longPress(id) : {}),
710
+ [COLLECTION_ITEM_ATTRIBUTE]: id,
711
+ [LEGACY_ROW_ATTRIBUTE]: id,
712
+ tabIndex: activeIdRef.current === id || (!activeIdRef.current && selectableIdsRef.current[0] === id) ? 0 : -1,
713
+ 'aria-selected': selectedIdsRef.current.has(id),
714
+ 'aria-disabled': !selectableIdsRef.current.includes(id),
715
+ 'data-notis-active': (activeIdRef.current === id ? 'true' : 'false') as 'true' | 'false',
716
+ onFocus: () => {
717
+ activateCollectionShortcuts();
718
+ setActive(id, 'activate');
719
+ },
720
+ onMouseDown: onRowMouseDown(id),
721
+ onClick: (event: ReactMouseEvent) => {
722
+ if (!enabled || event.defaultPrevented) return;
723
+ if (!selectableIdsRef.current.includes(id)) return;
724
+ const target = event.target as HTMLElement | null;
725
+ const interactiveTarget = target?.closest(INTERACTIVE_SELECTOR);
726
+ if (interactiveTarget && interactiveTarget !== event.currentTarget) return;
727
+ if (event.shiftKey && selectionMode === 'multiple') {
728
+ event.preventDefault();
729
+ selectPointerRange(id);
730
+ return;
731
+ }
732
+ if (event.metaKey || event.ctrlKey) return;
733
+ if (clearSelectionOnPlainClick) clear();
734
+ activate(id);
735
+ },
736
+ onKeyDown: (event: ReactKeyboardEvent) => {
737
+ if (!enabled || event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) return;
738
+ if (event.target !== event.currentTarget) return;
739
+ event.preventDefault();
740
+ if (selectionMode === 'none') activate(id);
741
+ else toggle(id);
742
+ },
743
+ }), [activate, activateCollectionShortcuts, clear, clearSelectionOnPlainClick, enabled, enableLongPressSelection, longPress, onRowMouseDown, selectPointerRange, selectionMode, setActive, toggle]);
744
+
745
+ const getCheckboxProps = useCallback((id: string) => ({
746
+ isSelected: selectedIdsRef.current.has(id),
747
+ disabled: !enabled || !selectableIdsRef.current.includes(id),
748
+ onClick: onCheckboxClick(id),
749
+ }), [enabled, onCheckboxClick]);
750
+
751
+ const getSelectedItems = useCallback(() => getItemsForIds(selectedIdsRef.current), [getItemsForIds]);
752
+ const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
753
+
754
+ const actionContext = useMemo<CollectionActionContext<T>>(() => ({
755
+ selectedIds: Array.from(selectedIds),
756
+ selectedItems: getItemsForIds(selectedIds),
757
+ clearSelection: clear,
758
+ }), [clear, getItemsForIds, selectedIds]);
759
+ const resolvedActions = useMemo<ResolvedCollectionAction[]>(() => actionDefinitions.map((action) => {
760
+ const defaults = collectionActionDefaults(action.intent);
761
+ const disabled = typeof action.disabled === 'function' ? action.disabled(actionContext) : action.disabled;
762
+ const shortcut = action.shortcut === false ? undefined : action.shortcut ?? defaults.shortcut;
763
+ return {
764
+ id: action.id,
765
+ label: action.label ?? defaults.label ?? action.id,
766
+ icon: action.icon,
767
+ shortcut,
768
+ destructive: action.destructive ?? action.intent === 'delete',
769
+ disabled: !enabled || disabled,
770
+ pending: action.pending,
771
+ onRun: () => { if (!enabled || disabled || action.pending) return; return action.onRun(actionContext); },
772
+ };
773
+ }), [actionContext, actionDefinitions, enabled]);
774
+
775
+ // Optimistic filtering can remove the last selected row before a request settles.
776
+ // Keep pending keys on the visible controller, independently of toolbar mounting.
777
+ useShortcuts(resolvedActions.flatMap((action): ShortcutDefinition[] => (
778
+ action.shortcut && (action.pending || action.disabled)
779
+ ? [{ id: `collection.pending.${action.id}`, keys: action.shortcut, allowRepeat: true, onTrigger: () => undefined }]
780
+ : []
781
+ )), {
782
+ enabled: bindKeyboardShortcuts && resolvedActions.some((action) => action.pending),
783
+ scope: shortcutScope,
784
+ priority: shortcutPriority + 25,
785
+ collectionOwnerId: shortcutCollectionOwnerRef.current!,
786
+ isAvailable,
787
+ });
788
+
789
+ const getActionBarProps = useCallback(() => ({
790
+ selectedCount: selectedIds.size,
791
+ actions: resolvedActions,
792
+ collectionOwnerId: shortcutCollectionOwnerRef.current!,
793
+ isAvailable,
794
+ // Pending work may disable collection gestures but must retain its action keys.
795
+ shortcutsEnabled: bindKeyboardShortcuts && (enabled || resolvedActions.some((action) => action.pending)),
796
+ onClearSelection: enabled ? clear : undefined,
797
+ }), [selectedIds.size, resolvedActions, isAvailable, enabled, bindKeyboardShortcuts, clear]);
798
+
799
+ const getContainerProps = useCallback(() => ({
800
+ ref: setContainerRef,
801
+ onMouseDown: (event: ReactMouseEvent) => {
802
+ activateCollectionShortcuts();
803
+ handleContainerMouseDown(event);
804
+ },
805
+ onFocusCapture: activateCollectionShortcuts,
806
+ onClickCapture: handleContainerClickCapture,
807
+ style: { userSelect: 'none' as const },
808
+ }), [activateCollectionShortcuts, handleContainerClickCapture, handleContainerMouseDown, setContainerRef]);
809
+
810
+ return {
811
+ selectedIds,
812
+ selectedCount: selectedIds.size,
813
+ activeId,
814
+ anchorId,
815
+ headId: activeId,
816
+ lastClickedId: anchorId,
817
+ dragRect,
818
+ actions: resolvedActions,
819
+ getActionBarProps,
820
+ isSelected,
821
+ getSelectedItems,
822
+ setActive,
823
+ activate,
824
+ toggle,
825
+ select,
826
+ clear,
827
+ selectAll,
828
+ selectRange,
829
+ onCheckboxClick,
830
+ onRowMouseDown,
831
+ getRowProps,
832
+ getItemProps,
833
+ getCheckboxProps,
834
+ getContainerProps,
835
+ };
836
+ }