@dxos/react-ui-attention 0.8.4-main.c1de068 → 0.8.4-main.c85a9c8dae

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 (58) hide show
  1. package/dist/lib/browser/chunk-PAZBZOO2.mjs +7 -0
  2. package/dist/lib/browser/chunk-PAZBZOO2.mjs.map +7 -0
  3. package/dist/lib/browser/chunk-TAZUDNJI.mjs +705 -0
  4. package/dist/lib/browser/chunk-TAZUDNJI.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +75 -8
  6. package/dist/lib/browser/index.mjs.map +4 -4
  7. package/dist/lib/browser/meta.json +1 -1
  8. package/dist/lib/browser/testing/index.mjs +21 -5
  9. package/dist/lib/browser/testing/index.mjs.map +3 -3
  10. package/dist/lib/browser/types.mjs +7 -0
  11. package/dist/lib/browser/types.mjs.map +7 -0
  12. package/dist/lib/node-esm/chunk-FT553KKP.mjs +706 -0
  13. package/dist/lib/node-esm/chunk-FT553KKP.mjs.map +7 -0
  14. package/dist/lib/node-esm/chunk-PMGNPL4M.mjs +9 -0
  15. package/dist/lib/node-esm/chunk-PMGNPL4M.mjs.map +7 -0
  16. package/dist/lib/node-esm/index.mjs +75 -8
  17. package/dist/lib/node-esm/index.mjs.map +4 -4
  18. package/dist/lib/node-esm/meta.json +1 -1
  19. package/dist/lib/node-esm/testing/index.mjs +21 -5
  20. package/dist/lib/node-esm/testing/index.mjs.map +3 -3
  21. package/dist/lib/node-esm/types.mjs +8 -0
  22. package/dist/lib/node-esm/types.mjs.map +7 -0
  23. package/dist/types/src/attention.d.ts +22 -17
  24. package/dist/types/src/attention.d.ts.map +1 -1
  25. package/dist/types/src/components/AttentionGlyph.d.ts.map +1 -1
  26. package/dist/types/src/components/AttentionGlyph.stories.d.ts +9 -20
  27. package/dist/types/src/components/AttentionGlyph.stories.d.ts.map +1 -1
  28. package/dist/types/src/components/AttentionProvider.d.ts +8 -7
  29. package/dist/types/src/components/AttentionProvider.d.ts.map +1 -1
  30. package/dist/types/src/components/AttentionProvider.stories.d.ts +6 -4
  31. package/dist/types/src/components/AttentionProvider.stories.d.ts.map +1 -1
  32. package/dist/types/src/components/SelectionProvider.d.ts +6 -5
  33. package/dist/types/src/components/SelectionProvider.d.ts.map +1 -1
  34. package/dist/types/src/index.d.ts +1 -0
  35. package/dist/types/src/index.d.ts.map +1 -1
  36. package/dist/types/src/selection.d.ts +24 -4
  37. package/dist/types/src/selection.d.ts.map +1 -1
  38. package/dist/types/src/testing/decorators/withAttention.d.ts +5 -1
  39. package/dist/types/src/testing/decorators/withAttention.d.ts.map +1 -1
  40. package/dist/types/src/types.d.ts +10 -0
  41. package/dist/types/src/types.d.ts.map +1 -0
  42. package/dist/types/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +38 -24
  44. package/src/attention.test.ts +13 -8
  45. package/src/attention.ts +64 -41
  46. package/src/components/AttentionGlyph.stories.tsx +17 -14
  47. package/src/components/AttentionGlyph.tsx +14 -7
  48. package/src/components/AttentionProvider.stories.tsx +13 -9
  49. package/src/components/AttentionProvider.tsx +80 -70
  50. package/src/components/SelectionProvider.tsx +66 -19
  51. package/src/index.ts +1 -0
  52. package/src/selection.ts +113 -72
  53. package/src/testing/decorators/withAttention.ts +21 -4
  54. package/src/types.ts +16 -0
  55. package/dist/lib/browser/chunk-3K54PTHR.mjs +0 -650
  56. package/dist/lib/browser/chunk-3K54PTHR.mjs.map +0 -7
  57. package/dist/lib/node-esm/chunk-LVRGGYEI.mjs +0 -652
  58. package/dist/lib/node-esm/chunk-LVRGGYEI.mjs.map +0 -7
@@ -2,22 +2,32 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- // NOTE(thure): The following unused imports quell TS2742 (“likely not portable”).
6
-
7
- // eslint-disable-next-line unused-imports/no-unused-imports
8
- import { createContext, type Scope, type CreateScope } from '@radix-ui/react-context';
5
+ import { RegistryContext } from '@effect-atom/atom-react';
6
+ import { createContext } from '@radix-ui/react-context';
9
7
  import { Primitive } from '@radix-ui/react-primitive';
10
8
  import { Slot } from '@radix-ui/react-slot';
11
- import React, { useMemo, type FocusEvent, type PropsWithChildren, type ComponentPropsWithRef, forwardRef } from 'react';
12
-
13
- import { useDefaultValue, type ThemedClassName } from '@dxos/react-ui';
14
- import { mx } from '@dxos/react-ui-theme';
15
-
16
- import { ATTENDABLE_PATH_SEPARATOR, type Attention, AttentionManager, getAttendables } from '../attention';
9
+ import React, {
10
+ type ComponentPropsWithRef,
11
+ type FocusEvent,
12
+ type PropsWithChildren,
13
+ forwardRef,
14
+ useContext,
15
+ useEffect,
16
+ useMemo,
17
+ useState,
18
+ } from 'react';
19
+
20
+ import { log } from '@dxos/log';
21
+ import { useDefaultValue } from '@dxos/react-hooks';
22
+ import { type ThemedClassName } from '@dxos/react-ui';
23
+ import { mx } from '@dxos/ui-theme';
24
+
25
+ import { AttentionManager, getAttendables } from '../attention';
26
+ import { ATTENDABLE_PATH_SEPARATOR, type Attention } from '../types';
17
27
 
18
28
  const ATTENTION_NAME = 'Attention';
19
- const ATTENABLE_ATTRIBUTE = 'data-attendable-id';
20
- const ATTENTION_SOURCE_ATTRIBUTE = 'data-is-attention-source';
29
+ const ATTENDABLE_ATTRIBUTE = 'data-attendable-id';
30
+ const ATTENTION_SOURCE_ATTRIBUTE = 'data-w-attention-source';
21
31
 
22
32
  type AttentionContextValue = {
23
33
  attention: AttentionManager;
@@ -25,35 +35,70 @@ type AttentionContextValue = {
25
35
  };
26
36
 
27
37
  const [AttentionContextProvider, useAttentionContext] = createContext<AttentionContextValue>(ATTENTION_NAME, {
28
- attention: new AttentionManager(),
38
+ attention: undefined as unknown as AttentionManager,
29
39
  path: [],
30
40
  });
31
41
 
32
- const UNKNOWN_ATTENDABLE = { hasAttention: false, isAncestor: false, isRelated: false };
42
+ const UNKNOWN_ATTENDABLE = { hasAttention: false, isAncestor: false, isRelated: false } as Attention;
33
43
 
34
44
  const useAttention = (attendableId?: string): Attention => {
35
45
  const { attention, path } = useAttentionContext(ATTENTION_NAME);
36
- if (!attendableId) {
37
- return UNKNOWN_ATTENDABLE;
38
- }
46
+ const [state, setState] = useState<Attention>(UNKNOWN_ATTENDABLE);
47
+
48
+ const key = useMemo(() => {
49
+ if (!attendableId) {
50
+ return undefined;
51
+ }
52
+ return [...attendableId.split(ATTENDABLE_PATH_SEPARATOR), ...path];
53
+ }, [attendableId, path]);
39
54
 
40
- const current = [...attendableId.split(ATTENDABLE_PATH_SEPARATOR), ...path];
41
- return attention.get(current);
55
+ useEffect(() => {
56
+ if (!key || !attention) {
57
+ setState(UNKNOWN_ATTENDABLE);
58
+ return;
59
+ }
60
+
61
+ // Set initial state.
62
+ setState(attention.get(key));
63
+
64
+ // Subscribe to changes.
65
+ return attention.subscribe(key, (newState) => {
66
+ setState(newState);
67
+ });
68
+ }, [attention, key]);
69
+
70
+ return state;
42
71
  };
43
72
 
44
73
  const useAttended = () => {
45
74
  const { attention } = useAttentionContext(ATTENTION_NAME);
46
- return attention.current;
75
+ const [current, setCurrent] = useState<readonly string[]>([]);
76
+
77
+ useEffect(() => {
78
+ if (!attention) {
79
+ return;
80
+ }
81
+
82
+ // Set initial state.
83
+ setCurrent(attention.getCurrent());
84
+
85
+ // Subscribe to changes.
86
+ return attention.subscribeCurrent((newCurrent) => {
87
+ setCurrent(newCurrent);
88
+ });
89
+ }, [attention]);
90
+
91
+ return current;
47
92
  };
48
93
 
49
94
  /**
50
- * Computes HTML element attributes to apply so the attention system can detect changes
95
+ * Computes HTML element attributes to apply so the attention system can detect changes.
51
96
  * @param attendableId
52
97
  */
53
98
  const useAttentionAttributes = (attendableId?: string) => {
54
99
  const { hasAttention } = useAttention(attendableId);
55
100
  return useMemo(() => {
56
- const attributes: Record<string, string | undefined> = { [ATTENABLE_ATTRIBUTE]: attendableId };
101
+ const attributes: Record<string, string | undefined> = { [ATTENDABLE_ATTRIBUTE]: attendableId };
57
102
  if (hasAttention) {
58
103
  attributes[ATTENTION_SOURCE_ATTRIBUTE] = 'true';
59
104
  }
@@ -77,15 +122,20 @@ const RootAttentionProvider = ({
77
122
  onChange: (nextAttended: string[]) => void;
78
123
  }>
79
124
  >) => {
80
- const attention = useDefaultValue(propsAttention, () => new AttentionManager());
125
+ const registry = useContext(RegistryContext);
126
+ const attention = useDefaultValue(propsAttention, () => new AttentionManager(registry));
127
+
81
128
  const handleFocus = (event: FocusEvent) => {
129
+ // NOTE(thure): Use the following to debug focus movement across the app:
130
+ log('focus', { related: event.relatedTarget, target: event.target });
131
+
82
132
  const selector = [
83
133
  '[data-attendable-id]',
84
134
  ...Array.from(document.querySelectorAll('[aria-controls]')).map(
85
135
  (el) => `[id="${el.getAttribute('aria-controls')}"]`,
86
136
  ),
87
137
  ].join(',');
88
- const prev = attention.current;
138
+ const prev = attention.getCurrent();
89
139
  const next = getAttendables(selector, event.target);
90
140
  // TODO(wittjosiah): Not allowing empty state means that the attended item is not strictly guaranteed to be in the DOM.
91
141
  // Currently this depends on the deck in order to ensure that when the attended item is removed something else is attended.
@@ -96,49 +146,9 @@ const RootAttentionProvider = ({
96
146
  }
97
147
  };
98
148
 
99
- // NOTE(thure): Use the following to debug the macOS package issue #8540
100
-
101
- // const [startEl, setStartEl] = useState<HTMLElement | null>(null);
102
- // const [endEl, setEndEl] = useState<HTMLElement | null>(null);
103
- //
104
- // const handleEventDebug = useCallback((event: any) => {
105
- // console.log(`[${event.type}]`, event.target, event.currentTarget, [event.clientX, event.clientY]);
106
- // }, []);
107
- //
108
- // const handleStartDebug = useCallback(
109
- // (event: any) => {
110
- // setStartEl(event.target);
111
- // handleEventDebug(event);
112
- // },
113
- // [handleEventDebug],
114
- // );
115
- //
116
- // const handleEndDebug = useCallback(
117
- // (event: any) => {
118
- // setEndEl(event.target);
119
- // handleEventDebug(event);
120
- // },
121
- // [handleEventDebug],
122
- // );
123
- //
124
- // const handleClickDebug = useCallback(
125
- // (event: any) => {
126
- // console.log('[click compare]', startEl, endEl, startEl === endEl);
127
- // handleEventDebug(event);
128
- // },
129
- // [startEl, endEl, handleEventDebug],
130
- // );
131
-
132
149
  return (
133
150
  <AttentionContextProvider attention={attention} path={[]}>
134
- <div
135
- role='none'
136
- className='contents'
137
- onFocusCapture={handleFocus}
138
- // onClick={handleClickDebug}
139
- // onMouseDown={handleStartDebug}
140
- // onMouseUp={handleEndDebug}
141
- >
151
+ <div role='none' className='contents' onFocusCapture={handleFocus}>
142
152
  {children}
143
153
  </div>
144
154
  </AttentionContextProvider>
@@ -168,17 +178,17 @@ export type AttendableContainerProps = ThemedClassName<
168
178
  const AttendableContainer = forwardRef<HTMLDivElement, AttendableContainerProps>(
169
179
  ({ id, classNames, children, asChild, ...props }, forwardedRef) => {
170
180
  const attentionAttrs = useAttentionAttributes(id);
171
- const Root = asChild ? Slot : Primitive.div;
181
+ const Comp = asChild ? Slot : Primitive.div;
172
182
  return (
173
- <Root
183
+ <Comp
174
184
  role='none'
175
185
  {...attentionAttrs}
176
186
  {...props}
177
- className={mx('attention-surface', props.tabIndex === 0 && 'dx-focus-ring-inset-over-all', classNames)}
187
+ className={mx('dx-attention-surface', props.tabIndex === 0 && 'dx-focus-ring-inset-over-all', classNames)}
178
188
  ref={forwardedRef}
179
189
  >
180
190
  {children}
181
- </Root>
191
+ </Comp>
182
192
  );
183
193
  },
184
194
  );
@@ -193,6 +203,6 @@ export {
193
203
  useAttentionAttributes,
194
204
  useAttentionPath,
195
205
  ATTENTION_NAME,
196
- ATTENABLE_ATTRIBUTE,
206
+ ATTENDABLE_ATTRIBUTE,
197
207
  ATTENTION_SOURCE_ATTRIBUTE,
198
208
  };
@@ -2,18 +2,19 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ import { RegistryContext } from '@effect-atom/atom-react';
5
6
  import { createContext } from '@radix-ui/react-context';
6
- import { Match } from 'effect';
7
- import React, { useCallback, useMemo, type PropsWithChildren } from 'react';
7
+ import * as Match from 'effect/Match';
8
+ import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react';
8
9
 
9
- import { useDefaultValue } from '@dxos/react-ui';
10
+ import { useDefaultValue } from '@dxos/react-hooks';
10
11
 
11
12
  import {
12
- defaultSelection,
13
- SelectionManager,
14
- type SelectionResult,
15
13
  type Selection,
14
+ SelectionManager,
16
15
  type SelectionMode,
16
+ type SelectionResult,
17
+ defaultSelection,
17
18
  } from '../selection';
18
19
 
19
20
  const SELECTION_NAME = 'Selection';
@@ -23,7 +24,7 @@ type SelectionContextValue = {
23
24
  };
24
25
 
25
26
  const [SelectionContextProvider, useSelectionContext] = createContext<SelectionContextValue>(SELECTION_NAME, {
26
- selection: new SelectionManager(),
27
+ selection: undefined as unknown as SelectionManager,
27
28
  });
28
29
 
29
30
  /**
@@ -34,7 +35,8 @@ export const SelectionProvider = ({
34
35
  children,
35
36
  selection: propsSelection,
36
37
  }: PropsWithChildren<{ selection?: SelectionManager }>) => {
37
- const selection = useDefaultValue(propsSelection, () => new SelectionManager());
38
+ const registry = useContext(RegistryContext);
39
+ const selection = useDefaultValue(propsSelection, () => new SelectionManager(registry));
38
40
  return <SelectionContextProvider selection={selection}>{children}</SelectionContextProvider>;
39
41
  };
40
42
 
@@ -46,6 +48,18 @@ export const useSelectionManager = () => {
46
48
  return selection;
47
49
  };
48
50
 
51
+ const getDefaultResult = <T extends SelectionMode>(mode: T): SelectionResult<T> => {
52
+ return Match.type<Selection>().pipe(
53
+ Match.when({ mode: 'single' }, (selection) => selection.id),
54
+ Match.when({ mode: 'multi' }, (selection) => selection.ids),
55
+ Match.when({ mode: 'range' }, (selection) =>
56
+ selection.from && selection.to ? { from: selection.from, to: selection.to } : undefined,
57
+ ),
58
+ Match.when({ mode: 'multi-range' }, (selection) => selection.ranges),
59
+ Match.exhaustive,
60
+ )(defaultSelection(mode)) as SelectionResult<T>;
61
+ };
62
+
49
63
  /**
50
64
  * Get the selected objects for a given context.
51
65
  */
@@ -54,28 +68,49 @@ export const useSelected = <T extends SelectionMode>(
54
68
  mode: T = 'multi' as T,
55
69
  ): SelectionResult<T> => {
56
70
  const { selection } = useSelectionContext(SELECTION_NAME);
57
- if (contextId) {
58
- return selection.getSelected(contextId, mode);
59
- }
71
+ const [state, setState] = useState<SelectionResult<T>>(() =>
72
+ contextId && selection ? selection.getSelected(contextId, mode) : getDefaultResult(mode),
73
+ );
60
74
 
61
- return Match.type<Selection>().pipe(
62
- Match.when({ mode: 'single' }, (s) => s.id),
63
- Match.when({ mode: 'multi' }, (s) => s.ids),
64
- Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
65
- Match.when({ mode: 'multi-range' }, (s) => s.ranges),
66
- Match.exhaustive,
67
- )(defaultSelection(mode)) as any;
75
+ useEffect(() => {
76
+ if (!contextId || !selection) {
77
+ setState(getDefaultResult(mode));
78
+ return;
79
+ }
80
+
81
+ // Set initial state.
82
+ setState(selection.getSelected(contextId, mode));
83
+
84
+ // Subscribe to changes.
85
+ return selection.subscribe(() => {
86
+ setState(selection.getSelected(contextId, mode));
87
+ });
88
+ }, [selection, contextId, mode]);
89
+
90
+ return state;
91
+ };
92
+
93
+ export type UseSelectionActions = {
94
+ singleSelect: (id: string) => void;
95
+ multiSelect: (ids: string[]) => void;
96
+ rangeSelect: (from: string, to: string) => void;
97
+ toggle: (id: string) => void;
98
+ clear: () => void;
68
99
  };
69
100
 
70
101
  /**
71
102
  * Provides functions to manage the selection state for multiple contexts.
72
103
  */
73
- export const useSelectionActions = (contextIds: string[], mode: SelectionMode = 'multi') => {
104
+ // TODO(burdon): Mode not used.
105
+ export const useSelectionActions = (contextIds: string[], mode: SelectionMode = 'multi'): UseSelectionActions => {
74
106
  const stableContextIds = useMemo(() => contextIds, [JSON.stringify(contextIds)]); // TODO(burdon): Avoid stringify.
75
107
  const { selection } = useSelectionContext(SELECTION_NAME);
76
108
 
77
109
  const singleSelect = useCallback(
78
110
  (id: string) => {
111
+ if (!selection) {
112
+ return;
113
+ }
79
114
  for (const contextId of stableContextIds) {
80
115
  selection.updateSingle(contextId, id);
81
116
  }
@@ -85,6 +120,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
85
120
 
86
121
  const multiSelect = useCallback(
87
122
  (ids: string[]) => {
123
+ if (!selection) {
124
+ return;
125
+ }
88
126
  for (const contextId of stableContextIds) {
89
127
  selection.updateMulti(contextId, ids);
90
128
  }
@@ -94,6 +132,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
94
132
 
95
133
  const rangeSelect = useCallback(
96
134
  (from: string, to: string) => {
135
+ if (!selection) {
136
+ return;
137
+ }
97
138
  for (const contextId of stableContextIds) {
98
139
  selection.updateRange(contextId, from, to);
99
140
  }
@@ -103,6 +144,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
103
144
 
104
145
  const toggle = useCallback(
105
146
  (id: string) => {
147
+ if (!selection) {
148
+ return;
149
+ }
106
150
  for (const contextId of stableContextIds) {
107
151
  selection.toggleSelection(contextId, id);
108
152
  }
@@ -111,6 +155,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
111
155
  );
112
156
 
113
157
  const clear = useCallback(() => {
158
+ if (!selection) {
159
+ return;
160
+ }
114
161
  for (const contextId of stableContextIds) {
115
162
  selection.clearSelection(contextId);
116
163
  }
package/src/index.ts CHANGED
@@ -5,3 +5,4 @@
5
5
  export * from './attention';
6
6
  export * from './components';
7
7
  export * from './selection';
8
+ export * from './types';
package/src/selection.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { untracked } from '@preact/signals-core';
6
- import { Match, Schema } from 'effect';
5
+ import { Atom, type Registry } from '@effect-atom/atom-react';
6
+ import * as Match from 'effect/Match';
7
+ import * as Schema from 'effect/Schema';
7
8
 
8
9
  import { invariant } from '@dxos/invariant';
9
- import { live, type Live } from '@dxos/live-object';
10
10
 
11
11
  // TODO(burdon): Reconcile with @dxos/graph.
12
12
 
@@ -69,117 +69,158 @@ export const getSelectionSet = (selectionManager: SelectionManager, contextId?:
69
69
  * Each context maintains its own selection mode and state.
70
70
  */
71
71
  export class SelectionManager {
72
- private readonly _state = live<{ selections: Record<string, Selection> }>({ selections: {} });
72
+ private readonly _state: Atom.Writable<{ selections: Record<string, Selection> }>;
73
+
74
+ constructor(
75
+ private readonly _registry: Registry.Registry,
76
+ initial: Record<string, Selection> = {},
77
+ ) {
78
+ this._state = Atom.make<{ selections: Record<string, Selection> }>({
79
+ selections: { ...initial },
80
+ });
81
+ }
73
82
 
74
- constructor(initial: Record<string, Selection> = {}) {
75
- if (Object.keys(initial).length > 0) {
76
- untracked(() => {
77
- this._state.selections = initial;
78
- });
79
- }
83
+ /**
84
+ * Get the state atom for reactive access in the graph system.
85
+ */
86
+ get state(): Atom.Atom<{ selections: Record<string, Selection> }> {
87
+ return this._state;
88
+ }
89
+
90
+ /**
91
+ * Gets the current state.
92
+ */
93
+ getState(): { selections: Record<string, Selection> } {
94
+ return this._registry.get(this._state);
95
+ }
96
+
97
+ /**
98
+ * Subscribe to changes in the selection state.
99
+ */
100
+ subscribe(cb: (state: { selections: Record<string, Selection> }) => void): () => void {
101
+ this._registry.get(this._state);
102
+ return this._registry.subscribe(this._state, () => {
103
+ cb(this._registry.get(this._state));
104
+ });
80
105
  }
81
106
 
82
107
  getSelectionContexts(): string[] {
83
- return Object.keys(this._state.selections);
108
+ return Object.keys(this._registry.get(this._state).selections);
84
109
  }
85
110
 
111
+ getSelection(contextId: string): Selection | undefined;
112
+ getSelection<T extends SelectionMode>(contextId: string, mode: T): Selection;
86
113
  getSelection<T extends SelectionMode | undefined>(
87
114
  contextId: string,
88
115
  mode: T = undefined as T,
89
- ): T extends undefined ? Live<Selection> | undefined : Live<Selection> {
90
- const selection = untracked(() => this._state.selections[contextId]);
116
+ ): Selection | undefined {
117
+ const state = this._registry.get(this._state);
118
+ const selection = state.selections[contextId];
91
119
  if (!mode || selection) {
92
120
  return selection;
93
121
  }
94
122
 
95
- return untracked(() => {
96
- this._state.selections[contextId] = defaultSelection(mode);
97
- return this._state.selections[contextId];
98
- });
123
+ // Create new selection for the context.
124
+ const newSelection = defaultSelection(mode);
125
+ this._registry.update(this._state, (state) => ({
126
+ selections: { ...state.selections, [contextId]: newSelection },
127
+ }));
128
+ return newSelection;
99
129
  }
100
130
 
101
131
  // TODO(burdon): Disambiguate with getSelection?
102
132
  getSelected<T extends SelectionMode>(contextId: string, mode: T = 'multi' as T): SelectionResult<T> {
103
133
  const selection = this.getSelection(contextId, mode);
104
134
  invariant(selection?.mode === mode, 'Selection mode mismatch');
135
+ // Cast required because TypeScript can't infer the relationship between T and the matched result.
105
136
  return Match.type<Selection>().pipe(
106
137
  Match.when({ mode: 'single' }, (s) => s.id),
107
138
  Match.when({ mode: 'multi' }, (s) => s.ids),
108
139
  Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
109
140
  Match.when({ mode: 'multi-range' }, (s) => s.ranges),
110
141
  Match.exhaustive,
111
- )(selection) as any;
142
+ )(selection) as SelectionResult<T>;
112
143
  }
113
144
 
114
145
  updateSingle(contextId: string, id: string): void {
115
- untracked(() => {
116
- const selection = this.getSelection(contextId, 'single');
117
- invariant(selection?.mode === 'single', 'Selection mode is not single');
118
- selection.id = id;
119
- });
146
+ const selection = this.getSelection(contextId, 'single');
147
+ invariant(selection?.mode === 'single', 'Selection mode is not single');
148
+ this._registry.update(this._state, (state) => ({
149
+ selections: {
150
+ ...state.selections,
151
+ [contextId]: { ...selection, id },
152
+ },
153
+ }));
120
154
  }
121
155
 
122
156
  updateMulti(contextId: string, ids: string[]) {
123
- untracked(() => {
124
- const selection = this.getSelection(contextId, 'multi');
125
- invariant(selection?.mode === 'multi', 'Selection mode is not multi');
126
- selection.ids.splice(0, selection.ids.length, ...ids);
127
- });
157
+ const selection = this.getSelection(contextId, 'multi');
158
+ invariant(selection?.mode === 'multi', 'Selection mode is not multi');
159
+ this._registry.update(this._state, (state) => ({
160
+ selections: {
161
+ ...state.selections,
162
+ [contextId]: { ...selection, ids: [...ids] },
163
+ },
164
+ }));
128
165
  }
129
166
 
130
167
  updateRange(contextId: string, from: string, to: string) {
131
- untracked(() => {
132
- const selection = this.getSelection(contextId, 'range');
133
- invariant(selection?.mode === 'range', 'Selection mode is not range');
134
- selection.from = from;
135
- selection.to = to;
136
- });
168
+ const selection = this.getSelection(contextId, 'range');
169
+ invariant(selection?.mode === 'range', 'Selection mode is not range');
170
+ this._registry.update(this._state, (state) => ({
171
+ selections: {
172
+ ...state.selections,
173
+ [contextId]: { ...selection, from, to },
174
+ },
175
+ }));
137
176
  }
138
177
 
139
178
  updateMultiRange(contextId: string, ranges: { from: string; to: string }[]) {
140
- untracked(() => {
141
- const selection = this.getSelection(contextId, 'multi-range');
142
- invariant(selection?.mode === 'multi-range', 'Selection mode is not multi-range');
143
- selection.ranges.splice(0, selection.ranges.length, ...ranges);
144
- });
179
+ const selection = this.getSelection(contextId, 'multi-range');
180
+ invariant(selection?.mode === 'multi-range', 'Selection mode is not multi-range');
181
+ this._registry.update(this._state, (state) => ({
182
+ selections: {
183
+ ...state.selections,
184
+ [contextId]: { ...selection, ranges: [...ranges] },
185
+ },
186
+ }));
145
187
  }
146
188
 
147
189
  clearSelection(contextId: string): void {
148
- untracked(() => {
149
- const selection = this.getSelection(contextId);
150
- Match.type<Selection | undefined>().pipe(
151
- Match.when(undefined, () => {
152
- // No-op.
153
- }),
154
- Match.when({ mode: 'single' }, (s) => {
155
- s.id = undefined;
156
- }),
157
- Match.when({ mode: 'multi' }, (s) => {
158
- s.ids.splice(0, s.ids.length);
159
- }),
160
- Match.when({ mode: 'range' }, (s) => {
161
- s.from = undefined;
162
- s.to = undefined;
163
- }),
164
- Match.when({ mode: 'multi-range' }, (s) => {
165
- s.ranges.splice(0, s.ranges.length);
166
- }),
167
- Match.exhaustive,
168
- )(selection);
169
- });
190
+ const selection = this.getSelection(contextId);
191
+ if (!selection) {
192
+ return;
193
+ }
194
+
195
+ const clearedSelection = Match.type<Selection>().pipe(
196
+ Match.when({ mode: 'single' }, (s) => ({ ...s, id: undefined })),
197
+ Match.when({ mode: 'multi' }, (s) => ({ ...s, ids: [] })),
198
+ Match.when({ mode: 'range' }, (s) => ({ ...s, from: undefined, to: undefined })),
199
+ Match.when({ mode: 'multi-range' }, (s) => ({ ...s, ranges: [] })),
200
+ Match.exhaustive,
201
+ )(selection);
202
+
203
+ this._registry.update(this._state, (state) => ({
204
+ selections: {
205
+ ...state.selections,
206
+ [contextId]: clearedSelection,
207
+ },
208
+ }));
170
209
  }
171
210
 
172
211
  toggleSelection(contextId: string, id: string): void {
173
- untracked(() => {
174
- const selection = this.getSelection(contextId, 'multi');
175
- invariant(selection?.mode === 'multi', 'Selection mode is not multi');
176
-
177
- if (selection.ids.includes(id)) {
178
- const index = selection.ids.indexOf(id);
179
- selection.ids.splice(index, 1);
180
- } else {
181
- selection.ids.push(id);
182
- }
183
- });
212
+ const selection = this.getSelection(contextId, 'multi');
213
+ invariant(selection?.mode === 'multi', 'Selection mode is not multi');
214
+
215
+ const newIds = selection.ids.includes(id)
216
+ ? selection.ids.filter((existingId) => existingId !== id)
217
+ : [...selection.ids, id];
218
+
219
+ this._registry.update(this._state, (state) => ({
220
+ selections: {
221
+ ...state.selections,
222
+ [contextId]: { ...selection, ids: newIds },
223
+ },
224
+ }));
184
225
  }
185
226
  }
@@ -2,11 +2,28 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { Registry, RegistryContext } from '@effect-atom/atom-react';
5
6
  import { type Decorator } from '@storybook/react';
6
- import { createElement } from 'react';
7
+ import { createElement, useMemo } from 'react';
7
8
 
8
- import { RootAttentionProvider } from '../../components';
9
+ import { AttentionManager } from '../../attention';
10
+ import { RootAttentionProvider, SelectionProvider } from '../../components';
9
11
 
10
- export const withAttention: Decorator = (Story) => {
11
- return createElement(RootAttentionProvider, {}, createElement(Story));
12
+ /**
13
+ * Storybook decorator that provides attention context.
14
+ * @param initialAttendedId Optional ID to set as initially attended.
15
+ */
16
+ export const withAttention = (initialAttendedId?: string): Decorator => {
17
+ return (Story) => {
18
+ const registry = useMemo(() => Registry.make(), []);
19
+ const attention = useMemo(
20
+ () => (initialAttendedId ? new AttentionManager(registry, [initialAttendedId]) : undefined),
21
+ [registry],
22
+ );
23
+ return createElement(
24
+ RegistryContext.Provider,
25
+ { value: registry },
26
+ createElement(RootAttentionProvider, { attention }, createElement(SelectionProvider, {}, createElement(Story))),
27
+ );
28
+ };
12
29
  };