@notis_ai/cli 0.2.0-beta.1793038587372.1 → 0.2.0-beta.1793098461724.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.
@@ -27,6 +27,10 @@ export interface MultiSelectActionBarProps {
27
27
  actions: MultiSelectAction[];
28
28
  /** Override the "{count} selected" label units. Default: "selected" with no item word. */
29
29
  itemLabel?: { singular: string; plural: string };
30
+ /** Complete count text in the caller's language, including plural agreement. */
31
+ selectionLabel?: string;
32
+ /** Accessible toolbar name in the caller's language. */
33
+ toolbarLabel?: string;
30
34
  /** Optional extra class on the outer container (composed alongside the inline styles). */
31
35
  className?: string;
32
36
  /** Optional override for the bar's positioning style. Defaults to bottom-center floating. */
@@ -125,6 +129,8 @@ export function MultiSelectActionBar({
125
129
  selectedCount,
126
130
  actions,
127
131
  itemLabel,
132
+ selectionLabel,
133
+ toolbarLabel,
128
134
  className,
129
135
  style,
130
136
  shortcutsEnabled = true,
@@ -196,9 +202,9 @@ export function MultiSelectActionBar({
196
202
  ? itemLabel.singular
197
203
  : itemLabel.plural
198
204
  : 'selected';
199
- const countLabel = itemLabel
205
+ const countLabel = selectionLabel ?? (itemLabel
200
206
  ? `${selectedCount} ${countWord} selected`
201
- : `${selectedCount} selected`;
207
+ : `${selectedCount} selected`);
202
208
 
203
209
  const composedStyle: CSSProperties = { ...containerBaseStyle, ...(style || {}) };
204
210
 
@@ -215,7 +221,7 @@ export function MultiSelectActionBar({
215
221
  onClearSelection();
216
222
  }}
217
223
  role="toolbar"
218
- aria-label={`Bulk actions for ${selectedCount} selected ${countWord}`}
224
+ aria-label={toolbarLabel ?? `Bulk actions for ${selectedCount} selected ${countWord}`}
219
225
  className={className}
220
226
  style={composedStyle}
221
227
  >
@@ -225,8 +231,13 @@ export function MultiSelectActionBar({
225
231
  [data-notis-bulk-actions] { flex-wrap: wrap; width: calc(100% - 2rem) !important; }
226
232
  [data-notis-bulk-count] { flex-basis: 100%; padding: 0.375rem 0.5rem !important; font-size: 14px !important; }
227
233
  [data-notis-bulk-divider] { display: none; }
228
- [data-notis-bulk-action-list] { width: 100%; }
229
- [data-notis-bulk-actions] button { min-height: 48px; font-size: 16px !important; }
234
+ /* Every action gets an equal column, icon over label, so none is cut off at the edge
235
+ (longer labels such as French ones wrap inside their column instead). */
236
+ [data-notis-bulk-action-list] { display: grid !important; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); width: 100%; overflow: visible !important; }
237
+ [data-notis-bulk-actions] button { flex-direction: column; justify-content: flex-start; gap: 4px !important; min-width: 0; min-height: 56px; padding: 8px 4px 6px !important; font-size: 12px !important; line-height: 1.25; white-space: normal !important; text-align: center; }
238
+ [data-notis-bulk-actions] kbd { display: none !important; }
239
+ [data-notis-bulk-action-icon][data-has-shortcut] { display: inline-flex !important; }
240
+ [data-notis-bulk-action-icon] svg { width: 18px; height: 18px; }
230
241
  }
231
242
  @media (hover: none) and (pointer: coarse) {
232
243
  [data-notis-bulk-actions] kbd { display: none !important; }
@@ -26,6 +26,7 @@ import {
26
26
 
27
27
  import type { MultiSelectActionBarProps } from '../components/MultiSelectActionBar';
28
28
  import { isInteractionElementVisible } from '../interactions/visibility';
29
+ import { travelBetween, type TravelDirection } from '../ui/engine/step';
29
30
  import { useLongPressSelection } from './useLongPressSelection';
30
31
  import { useMarqueeSelection, type SelectionMarqueeRect } from './useMarqueeSelection';
31
32
 
@@ -117,6 +118,11 @@ export interface CollectionInteractionController<T> {
117
118
  lastClickedId: string | null;
118
119
  dragRect: SelectionMarqueeRect | null;
119
120
  actions: ResolvedCollectionAction[];
121
+ /**
122
+ * The way the active item last moved (J/K, the arrows, a click further up or down); 'next' until it moves. A page
123
+ * that removes the active item continues this way (iOS Photos), else the other way at the edge.
124
+ */
125
+ travelDirection: () => TravelDirection;
120
126
  getActionBarProps: () => Pick<MultiSelectActionBarProps, 'selectedCount' | 'actions' | 'collectionOwnerId' | 'isAvailable' | 'shortcutsEnabled' | 'onClearSelection'>;
121
127
 
122
128
  isSelected: (id: string) => boolean;
@@ -237,6 +243,7 @@ export function useCollectionInteractions<T>(
237
243
  const selectedIdsRef = useRef(selectedIds);
238
244
  const activeIdRef = useRef(activeId);
239
245
  const anchorIdRef = useRef(anchorId);
246
+ const travelRef = useRef<TravelDirection>('next');
240
247
  // Keep manually selected rows outside the current keyboard range on reversal.
241
248
  const keyboardRangeBaseRef = useRef<Set<string> | null>(null);
242
249
  const onSelectionChangeRef = useRef(onSelectionChange);
@@ -307,6 +314,9 @@ export function useCollectionInteractions<T>(
307
314
  const setActive = useCallback((id: string | null, reason: CollectionInteractionReason = 'activate') => {
308
315
  const next = id && selectableIdsRef.current.includes(id) ? id : null;
309
316
  if (activeIdRef.current === next) return;
317
+ // A click or focus further up or down sets the way; keys set it themselves (moveActive), a wrap included.
318
+ const travel = reason === 'items-changed' ? null : travelBetween(selectableIdsRef.current, activeIdRef.current, next);
319
+ if (travel) travelRef.current = travel;
310
320
  activeIdRef.current = next;
311
321
  if (controlledActiveId === undefined) setUncontrolledActiveId(next);
312
322
  onActiveIdChangeRef.current?.(next, reason);
@@ -448,6 +458,7 @@ export function useCollectionInteractions<T>(
448
458
  const ids = selectableIdsRef.current;
449
459
  if (ids.length === 0) return;
450
460
  const current = activeIdRef.current;
461
+ const forward = direction === 'next' || direction === 'down' || direction === 'right';
451
462
  // Range gestures follow the displayed order, independent of asymmetric grid geometry.
452
463
  let next = extend ? null : resolveNextIdRef.current?.({
453
464
  direction,
@@ -457,7 +468,6 @@ export function useCollectionInteractions<T>(
457
468
  }) ?? null;
458
469
  if (!next) {
459
470
  const currentIndex = current ? ids.indexOf(current) : -1;
460
- const forward = direction === 'next' || direction === 'down' || direction === 'right';
461
471
  const nextIndex = currentIndex === -1
462
472
  ? forward ? 0 : ids.length - 1
463
473
  : Math.min(ids.length - 1, Math.max(0, currentIndex + (forward ? 1 : -1)));
@@ -478,6 +488,7 @@ export function useCollectionInteractions<T>(
478
488
  setAnchor(next, 'navigate');
479
489
  }
480
490
  setActive(next, extend ? 'range' : 'navigate');
491
+ travelRef.current = forward ? 'next' : 'previous';
481
492
  const nextId = next;
482
493
  requestAnimationFrame(() => focusItem(nextId));
483
494
  }, [focusItem, replaceRange, setActive, setAnchor]);
@@ -649,6 +660,7 @@ export function useCollectionInteractions<T>(
649
660
  }), [enabled, onCheckboxClick]);
650
661
 
651
662
  const getSelectedItems = useCallback(() => getItemsForIds(selectedIdsRef.current), [getItemsForIds]);
663
+ const travelDirection = useCallback(() => travelRef.current, []);
652
664
  const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
653
665
 
654
666
  const actionContext = useMemo<CollectionActionContext<T>>(() => ({
@@ -716,6 +728,7 @@ export function useCollectionInteractions<T>(
716
728
  lastClickedId: anchorId,
717
729
  dragRect,
718
730
  actions: resolvedActions,
731
+ travelDirection,
719
732
  getActionBarProps,
720
733
  isSelected,
721
734
  getSelectedItems,
@@ -19,6 +19,8 @@ export type {
19
19
  ResolvedCollectionAction,
20
20
  } from './interactions/actions';
21
21
  export { useCollectionInteractions, COLLECTION_ITEM_ATTRIBUTE } from './hooks/useCollectionInteractions';
22
+ export { continuationOf, travelBetween } from './ui/engine/step';
23
+ export type { TravelDirection } from './ui/engine/step';
22
24
  export type {
23
25
  CollectionInteractionController,
24
26
  CollectionInteractionReason,
@@ -0,0 +1,54 @@
1
+ export interface StepOptions {
2
+ /** Wrap from the last item to the first (and back). Default true. */
3
+ wrap?: boolean;
4
+ /** The key is held (`event.repeat`): never wrap, stop at the edge. */
5
+ repeat?: boolean;
6
+ }
7
+
8
+ /**
9
+ * Next cursor id for J/K stepping. Wraps only on a fresh press, so holding J stops at the last row instead of
10
+ * spinning back to the top. No cursor: J lands on the first item, K on the last.
11
+ */
12
+ export function stepCursor(ids: readonly string[], cursor: string | null | undefined, delta: number,
13
+ options: StepOptions = {}): string | null {
14
+ if (!ids.length) return null;
15
+ const { wrap = true, repeat = false } = options;
16
+ const index = cursor == null ? -1 : ids.indexOf(cursor);
17
+ if (index === -1) return delta >= 0 ? ids[0]! : ids[ids.length - 1]!;
18
+ let next = index + delta;
19
+ if (next < 0 || next >= ids.length) {
20
+ if (wrap && !repeat) next = ((next % ids.length) + ids.length) % ids.length;
21
+ else next = Math.max(0, Math.min(ids.length - 1, next));
22
+ }
23
+ return ids[next]!;
24
+ }
25
+
26
+ /** The way the cursor last moved through a list. A removal continues that way (iOS Photos). */
27
+ export type TravelDirection = 'next' | 'previous';
28
+
29
+ /** The way a move from `from` to `to` went in `ids`; null when either is not in the list or nothing moved. */
30
+ export function travelBetween(ids: readonly string[], from: string | null | undefined,
31
+ to: string | null | undefined): TravelDirection | null {
32
+ if (from == null || to == null || from === to) return null;
33
+ const a = ids.indexOf(from);
34
+ const b = ids.indexOf(to);
35
+ if (a === -1 || b === -1) return null;
36
+ return b > a ? 'next' : 'previous';
37
+ }
38
+
39
+ /**
40
+ * The id that takes `gone`'s place after it left `previous` (`now` holds the ids still listed): the nearest one still
41
+ * there in the travel direction, else the nearest the other way. Null when none is left.
42
+ */
43
+ export function continuationOf(previous: readonly string[], now: readonly string[] | ReadonlySet<string>,
44
+ gone: string, direction: TravelDirection = 'next'): string | null {
45
+ const present = now instanceof Set ? now : new Set(now as readonly string[]);
46
+ const index = previous.indexOf(gone);
47
+ if (index === -1) return null;
48
+ const scan = (delta: number) => {
49
+ for (let i = index + delta; i >= 0 && i < previous.length; i += delta) if (present.has(previous[i]!)) return previous[i]!;
50
+ return null;
51
+ };
52
+ const first = direction === 'next' ? 1 : -1;
53
+ return scan(first) ?? scan(-first);
54
+ }