@dxos/react-ui-attention 0.8.4-main.84f28bd → 0.8.4-main.9735255

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 (57) hide show
  1. package/dist/lib/browser/chunk-HJRZWOEX.mjs +705 -0
  2. package/dist/lib/browser/chunk-HJRZWOEX.mjs.map +7 -0
  3. package/dist/lib/browser/chunk-PAZBZOO2.mjs +7 -0
  4. package/dist/lib/browser/chunk-PAZBZOO2.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +77 -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 +10 -6
  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-7TQWDE4M.mjs +706 -0
  13. package/dist/lib/node-esm/chunk-7TQWDE4M.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 +77 -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 +10 -6
  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 +7 -6
  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 +49 -17
  37. package/dist/types/src/selection.d.ts.map +1 -1
  38. package/dist/types/src/testing/decorators/withAttention.d.ts.map +1 -1
  39. package/dist/types/src/types.d.ts +10 -0
  40. package/dist/types/src/types.d.ts.map +1 -0
  41. package/dist/types/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +38 -24
  43. package/src/attention.test.ts +13 -8
  44. package/src/attention.ts +64 -41
  45. package/src/components/AttentionGlyph.stories.tsx +16 -13
  46. package/src/components/AttentionGlyph.tsx +2 -2
  47. package/src/components/AttentionProvider.stories.tsx +11 -7
  48. package/src/components/AttentionProvider.tsx +75 -65
  49. package/src/components/SelectionProvider.tsx +64 -19
  50. package/src/index.ts +1 -0
  51. package/src/selection.ts +135 -65
  52. package/src/testing/decorators/withAttention.ts +9 -3
  53. package/src/types.ts +16 -0
  54. package/dist/lib/browser/chunk-QPEFFJ4E.mjs +0 -616
  55. package/dist/lib/browser/chunk-QPEFFJ4E.mjs.map +0 -7
  56. package/dist/lib/node-esm/chunk-F2JRKDYV.mjs +0 -618
  57. package/dist/lib/node-esm/chunk-F2JRKDYV.mjs.map +0 -7
@@ -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,16 @@ 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' }, (s) => s.id),
54
+ Match.when({ mode: 'multi' }, (s) => s.ids),
55
+ Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
56
+ Match.when({ mode: 'multi-range' }, (s) => s.ranges),
57
+ Match.exhaustive,
58
+ )(defaultSelection(mode)) as SelectionResult<T>;
59
+ };
60
+
49
61
  /**
50
62
  * Get the selected objects for a given context.
51
63
  */
@@ -54,28 +66,49 @@ export const useSelected = <T extends SelectionMode>(
54
66
  mode: T = 'multi' as T,
55
67
  ): SelectionResult<T> => {
56
68
  const { selection } = useSelectionContext(SELECTION_NAME);
57
- if (contextId) {
58
- return selection.getSelected(contextId, mode);
59
- }
69
+ const [state, setState] = useState<SelectionResult<T>>(() =>
70
+ contextId && selection ? selection.getSelected(contextId, mode) : getDefaultResult(mode),
71
+ );
60
72
 
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;
73
+ useEffect(() => {
74
+ if (!contextId || !selection) {
75
+ setState(getDefaultResult(mode));
76
+ return;
77
+ }
78
+
79
+ // Set initial state.
80
+ setState(selection.getSelected(contextId, mode));
81
+
82
+ // Subscribe to changes.
83
+ return selection.subscribe(() => {
84
+ setState(selection.getSelected(contextId, mode));
85
+ });
86
+ }, [selection, contextId, mode]);
87
+
88
+ return state;
89
+ };
90
+
91
+ export type UseSelectionActions = {
92
+ singleSelect: (id: string) => void;
93
+ multiSelect: (ids: string[]) => void;
94
+ rangeSelect: (from: string, to: string) => void;
95
+ toggle: (id: string) => void;
96
+ clear: () => void;
68
97
  };
69
98
 
70
99
  /**
71
100
  * Provides functions to manage the selection state for multiple contexts.
72
101
  */
73
- export const useSelectionActions = (contextIds: string[], mode: SelectionMode = 'multi') => {
102
+ // TODO(burdon): Mode not used.
103
+ export const useSelectionActions = (contextIds: string[], mode: SelectionMode = 'multi'): UseSelectionActions => {
74
104
  const stableContextIds = useMemo(() => contextIds, [JSON.stringify(contextIds)]); // TODO(burdon): Avoid stringify.
75
105
  const { selection } = useSelectionContext(SELECTION_NAME);
76
106
 
77
107
  const singleSelect = useCallback(
78
108
  (id: string) => {
109
+ if (!selection) {
110
+ return;
111
+ }
79
112
  for (const contextId of stableContextIds) {
80
113
  selection.updateSingle(contextId, id);
81
114
  }
@@ -85,6 +118,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
85
118
 
86
119
  const multiSelect = useCallback(
87
120
  (ids: string[]) => {
121
+ if (!selection) {
122
+ return;
123
+ }
88
124
  for (const contextId of stableContextIds) {
89
125
  selection.updateMulti(contextId, ids);
90
126
  }
@@ -94,6 +130,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
94
130
 
95
131
  const rangeSelect = useCallback(
96
132
  (from: string, to: string) => {
133
+ if (!selection) {
134
+ return;
135
+ }
97
136
  for (const contextId of stableContextIds) {
98
137
  selection.updateRange(contextId, from, to);
99
138
  }
@@ -103,6 +142,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
103
142
 
104
143
  const toggle = useCallback(
105
144
  (id: string) => {
145
+ if (!selection) {
146
+ return;
147
+ }
106
148
  for (const contextId of stableContextIds) {
107
149
  selection.toggleSelection(contextId, id);
108
150
  }
@@ -111,6 +153,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
111
153
  );
112
154
 
113
155
  const clear = useCallback(() => {
156
+ if (!selection) {
157
+ return;
158
+ }
114
159
  for (const contextId of stableContextIds) {
115
160
  selection.clearSelection(contextId);
116
161
  }
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,27 +2,40 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { untracked } from '@preact/signals-core';
6
- import { Match } 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
 
13
13
  export type SelectionMode = 'single' | 'multi' | 'range' | 'multi-range';
14
14
 
15
- export type Selection =
16
- | { mode: 'single'; id?: string }
17
- | { mode: 'multi'; ids: string[] }
18
- | { mode: 'range'; from?: string; to?: string }
19
- | { mode: 'multi-range'; ranges: { from: string; to: string }[] };
15
+ export const SelectionSchema = Schema.Union(
16
+ Schema.Struct({ mode: Schema.Literal('single'), id: Schema.optional(Schema.String) }).pipe(Schema.mutable),
17
+ Schema.Struct({ mode: Schema.Literal('multi'), ids: Schema.Array(Schema.String).pipe(Schema.mutable) }).pipe(
18
+ Schema.mutable,
19
+ ),
20
+ Schema.Struct({
21
+ mode: Schema.Literal('range'),
22
+ from: Schema.optional(Schema.String),
23
+ to: Schema.optional(Schema.String),
24
+ }).pipe(Schema.mutable),
25
+ Schema.Struct({
26
+ mode: Schema.Literal('multi-range'),
27
+ ranges: Schema.Array(Schema.Struct({ from: Schema.String, to: Schema.String })).pipe(Schema.mutable),
28
+ }).pipe(Schema.mutable),
29
+ ).pipe(Schema.mutable);
30
+
31
+ export type Selection = Schema.Schema.Type<typeof SelectionSchema>;
20
32
 
21
33
  export const defaultSelection = Match.type<SelectionMode>().pipe(
22
- Match.when('single', () => ({ mode: 'single' }) as Selection),
23
- Match.when('multi', () => ({ mode: 'multi', ids: [] }) as Selection),
24
- Match.when('range', () => ({ mode: 'range' }) as Selection),
25
- Match.when('multi-range', () => ({ mode: 'multi-range', ranges: [] }) as Selection),
34
+ Match.withReturnType<Selection>(),
35
+ Match.when('single', () => ({ mode: 'single' })),
36
+ Match.when('multi', () => ({ mode: 'multi', ids: [] })),
37
+ Match.when('range', () => ({ mode: 'range' })),
38
+ Match.when('multi-range', () => ({ mode: 'multi-range', ranges: [] })),
26
39
  Match.exhaustive,
27
40
  );
28
41
 
@@ -56,101 +69,158 @@ export const getSelectionSet = (selectionManager: SelectionManager, contextId?:
56
69
  * Each context maintains its own selection mode and state.
57
70
  */
58
71
  export class SelectionManager {
59
- 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
+ }
60
82
 
61
- constructor(initial: Record<string, Selection> = {}) {
62
- if (Object.keys(initial).length > 0) {
63
- untracked(() => {
64
- this._state.selections = initial;
65
- });
66
- }
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
+ });
67
105
  }
68
106
 
69
107
  getSelectionContexts(): string[] {
70
- return Object.keys(this._state.selections);
108
+ return Object.keys(this._registry.get(this._state).selections);
71
109
  }
72
110
 
111
+ getSelection(contextId: string): Selection | undefined;
112
+ getSelection<T extends SelectionMode>(contextId: string, mode: T): Selection;
73
113
  getSelection<T extends SelectionMode | undefined>(
74
114
  contextId: string,
75
115
  mode: T = undefined as T,
76
- ): T extends undefined ? Live<Selection> | undefined : Live<Selection> {
77
- const selection = untracked(() => this._state.selections[contextId]);
116
+ ): Selection | undefined {
117
+ const state = this._registry.get(this._state);
118
+ const selection = state.selections[contextId];
78
119
  if (!mode || selection) {
79
120
  return selection;
80
121
  }
81
122
 
82
- return untracked(() => {
83
- this._state.selections[contextId] = defaultSelection(mode);
84
- return this._state.selections[contextId];
85
- });
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;
86
129
  }
87
130
 
88
131
  // TODO(burdon): Disambiguate with getSelection?
89
132
  getSelected<T extends SelectionMode>(contextId: string, mode: T = 'multi' as T): SelectionResult<T> {
90
133
  const selection = this.getSelection(contextId, mode);
91
134
  invariant(selection?.mode === mode, 'Selection mode mismatch');
135
+ // Cast required because TypeScript can't infer the relationship between T and the matched result.
92
136
  return Match.type<Selection>().pipe(
93
137
  Match.when({ mode: 'single' }, (s) => s.id),
94
138
  Match.when({ mode: 'multi' }, (s) => s.ids),
95
139
  Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
96
140
  Match.when({ mode: 'multi-range' }, (s) => s.ranges),
97
141
  Match.exhaustive,
98
- )(selection) as any;
142
+ )(selection) as SelectionResult<T>;
99
143
  }
100
144
 
101
145
  updateSingle(contextId: string, id: string): void {
102
- untracked(() => {
103
- const selection = this.getSelection(contextId, 'single');
104
- invariant(selection?.mode === 'single', 'Selection mode is not single');
105
- selection.id = id;
106
- });
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
+ }));
107
154
  }
108
155
 
109
156
  updateMulti(contextId: string, ids: string[]) {
110
- untracked(() => {
111
- const selection = this.getSelection(contextId, 'multi');
112
- invariant(selection?.mode === 'multi', 'Selection mode is not multi');
113
- selection.ids = ids;
114
- });
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
+ }));
115
165
  }
116
166
 
117
167
  updateRange(contextId: string, from: string, to: string) {
118
- untracked(() => {
119
- const selection = this.getSelection(contextId, 'range');
120
- invariant(selection?.mode === 'range', 'Selection mode is not range');
121
- selection.from = from;
122
- selection.to = to;
123
- });
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
+ }));
124
176
  }
125
177
 
126
178
  updateMultiRange(contextId: string, ranges: { from: string; to: string }[]) {
127
- untracked(() => {
128
- const selection = this.getSelection(contextId, 'multi-range');
129
- invariant(selection?.mode === 'multi-range', 'Selection mode is not multi-range');
130
- selection.ranges = ranges;
131
- });
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
+ }));
132
187
  }
133
188
 
134
189
  clearSelection(contextId: string): void {
135
- untracked(() => {
136
- const selection = this.getSelection(contextId);
137
- if (selection) {
138
- this._state.selections[contextId] = defaultSelection(selection.mode);
139
- }
140
- });
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
+ }));
141
209
  }
142
210
 
143
211
  toggleSelection(contextId: string, id: string): void {
144
- untracked(() => {
145
- const selection = this.getSelection(contextId, 'multi');
146
- invariant(selection?.mode === 'multi', 'Selection mode is not multi');
147
-
148
- if (selection.ids.includes(id)) {
149
- const index = selection.ids.indexOf(id);
150
- selection.ids.splice(index, 1);
151
- } else {
152
- selection.ids.push(id);
153
- }
154
- });
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
+ }));
155
225
  }
156
226
  }
@@ -2,11 +2,17 @@
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 { RootAttentionProvider, SelectionProvider } from '../../components';
9
10
 
10
11
  export const withAttention: Decorator = (Story) => {
11
- return createElement(RootAttentionProvider, { children: createElement(Story) });
12
+ const registry = useMemo(() => Registry.make(), []);
13
+ return createElement(
14
+ RegistryContext.Provider,
15
+ { value: registry },
16
+ createElement(RootAttentionProvider, {}, createElement(SelectionProvider, {}, createElement(Story))),
17
+ );
12
18
  };
package/src/types.ts ADDED
@@ -0,0 +1,16 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ // NOTE: Chosen from RFC 1738's `safe` characters: http://www.faqs.org/rfcs/rfc1738.html
6
+ export const ATTENDABLE_PATH_SEPARATOR = '~';
7
+
8
+ export type Attention = {
9
+ hasAttention: boolean;
10
+ isAncestor: boolean;
11
+ isRelated: boolean;
12
+ };
13
+
14
+ export type CurrentState = {
15
+ current: string[];
16
+ };