@dxos/react-ui-attention 0.8.4-staging.60fe92afc8 → 0.9.1-main.c7dcc2e112

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 (76) hide show
  1. package/dist/lib/browser/chunk-4HYAJ4IO.mjs +434 -0
  2. package/dist/lib/browser/chunk-4HYAJ4IO.mjs.map +7 -0
  3. package/dist/lib/browser/{chunk-I4VO2RQM.mjs → chunk-KCQFVRZ6.mjs} +88 -112
  4. package/dist/lib/browser/chunk-KCQFVRZ6.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +29 -11
  6. package/dist/lib/browser/index.mjs.map +1 -1
  7. package/dist/lib/browser/meta.json +1 -1
  8. package/dist/lib/browser/testing/index.mjs +4 -4
  9. package/dist/lib/browser/testing/index.mjs.map +2 -2
  10. package/dist/lib/browser/types/index.mjs +19 -5
  11. package/dist/lib/node-esm/{chunk-ZTUVSH4P.mjs → chunk-5JUZQDCR.mjs} +88 -112
  12. package/dist/lib/node-esm/chunk-5JUZQDCR.mjs.map +7 -0
  13. package/dist/lib/node-esm/chunk-HWN7KJDX.mjs +436 -0
  14. package/dist/lib/node-esm/chunk-HWN7KJDX.mjs.map +7 -0
  15. package/dist/lib/node-esm/index.mjs +29 -11
  16. package/dist/lib/node-esm/index.mjs.map +1 -1
  17. package/dist/lib/node-esm/meta.json +1 -1
  18. package/dist/lib/node-esm/testing/index.mjs +4 -4
  19. package/dist/lib/node-esm/testing/index.mjs.map +2 -2
  20. package/dist/lib/node-esm/types/index.mjs +19 -5
  21. package/dist/types/src/components/ViewStateProvider/ViewStateProvider.d.ts +29 -0
  22. package/dist/types/src/components/ViewStateProvider/ViewStateProvider.d.ts.map +1 -0
  23. package/dist/types/src/components/ViewStateProvider/ViewStateProvider.test.d.ts +2 -0
  24. package/dist/types/src/components/ViewStateProvider/ViewStateProvider.test.d.ts.map +1 -0
  25. package/dist/types/src/components/ViewStateProvider/index.d.ts +2 -0
  26. package/dist/types/src/components/ViewStateProvider/index.d.ts.map +1 -0
  27. package/dist/types/src/components/index.d.ts +1 -1
  28. package/dist/types/src/index.d.ts +1 -0
  29. package/dist/types/src/index.d.ts.map +1 -1
  30. package/dist/types/src/selection.d.ts +10 -40
  31. package/dist/types/src/selection.d.ts.map +1 -1
  32. package/dist/types/src/selection.test.d.ts +2 -0
  33. package/dist/types/src/selection.test.d.ts.map +1 -0
  34. package/dist/types/src/types/index.d.ts +1 -0
  35. package/dist/types/src/types/index.d.ts.map +1 -1
  36. package/dist/types/src/view-state/backends.d.ts +24 -0
  37. package/dist/types/src/view-state/backends.d.ts.map +1 -0
  38. package/dist/types/src/view-state/backends.test.d.ts +2 -0
  39. package/dist/types/src/view-state/backends.test.d.ts.map +1 -0
  40. package/dist/types/src/view-state/index.d.ts +3 -0
  41. package/dist/types/src/view-state/index.d.ts.map +1 -0
  42. package/dist/types/src/view-state/view-state.d.ts +54 -0
  43. package/dist/types/src/view-state/view-state.d.ts.map +1 -0
  44. package/dist/types/src/view-state/view-state.test.d.ts +2 -0
  45. package/dist/types/src/view-state/view-state.test.d.ts.map +1 -0
  46. package/dist/types/src/vitest-setup.d.ts +2 -0
  47. package/dist/types/src/vitest-setup.d.ts.map +1 -0
  48. package/dist/types/tsconfig.tsbuildinfo +1 -1
  49. package/package.json +15 -13
  50. package/src/components/ViewStateProvider/ViewStateProvider.test.tsx +45 -0
  51. package/src/components/ViewStateProvider/ViewStateProvider.tsx +123 -0
  52. package/src/components/ViewStateProvider/index.ts +5 -0
  53. package/src/components/index.ts +1 -1
  54. package/src/index.ts +1 -0
  55. package/src/selection.test.ts +67 -0
  56. package/src/selection.ts +39 -169
  57. package/src/testing/decorators/withAttention.ts +2 -2
  58. package/src/types/index.ts +1 -0
  59. package/src/view-state/backends.test.ts +104 -0
  60. package/src/view-state/backends.ts +145 -0
  61. package/src/view-state/index.ts +6 -0
  62. package/src/view-state/view-state.test.ts +54 -0
  63. package/src/view-state/view-state.ts +91 -0
  64. package/src/vitest-setup.ts +6 -0
  65. package/dist/lib/browser/chunk-D2BLVJ6Z.mjs +0 -421
  66. package/dist/lib/browser/chunk-D2BLVJ6Z.mjs.map +0 -7
  67. package/dist/lib/browser/chunk-I4VO2RQM.mjs.map +0 -7
  68. package/dist/lib/node-esm/chunk-6INLBC62.mjs +0 -423
  69. package/dist/lib/node-esm/chunk-6INLBC62.mjs.map +0 -7
  70. package/dist/lib/node-esm/chunk-ZTUVSH4P.mjs.map +0 -7
  71. package/dist/types/src/components/SelectionProvider/SelectionProvider.d.ts +0 -28
  72. package/dist/types/src/components/SelectionProvider/SelectionProvider.d.ts.map +0 -1
  73. package/dist/types/src/components/SelectionProvider/index.d.ts +0 -2
  74. package/dist/types/src/components/SelectionProvider/index.d.ts.map +0 -1
  75. package/src/components/SelectionProvider/SelectionProvider.tsx +0 -174
  76. package/src/components/SelectionProvider/index.ts +0 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-attention",
3
- "version": "0.8.4-staging.60fe92afc8",
3
+ "version": "0.9.1-main.c7dcc2e112",
4
4
  "description": "Signifier components and providers for attention system.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -47,30 +47,32 @@
47
47
  "@radix-ui/react-slot": "1.1.2",
48
48
  "@radix-ui/react-use-controllable-state": "1.1.0",
49
49
  "effect": "3.21.3",
50
- "@dxos/echo": "0.8.4-staging.60fe92afc8",
51
- "@dxos/keyboard": "0.8.4-staging.60fe92afc8",
52
- "@dxos/invariant": "0.8.4-staging.60fe92afc8",
53
- "@dxos/log": "0.8.4-staging.60fe92afc8",
54
- "@dxos/react-hooks": "0.8.4-staging.60fe92afc8",
55
- "@dxos/util": "0.8.4-staging.60fe92afc8"
50
+ "@dxos/echo": "0.9.1-main.c7dcc2e112",
51
+ "@dxos/util": "0.9.1-main.c7dcc2e112",
52
+ "@dxos/log": "0.9.1-main.c7dcc2e112",
53
+ "@dxos/keyboard": "0.9.1-main.c7dcc2e112",
54
+ "@dxos/invariant": "0.9.1-main.c7dcc2e112",
55
+ "@dxos/react-hooks": "0.9.1-main.c7dcc2e112"
56
56
  },
57
57
  "devDependencies": {
58
+ "@testing-library/jest-dom": "^6.9.1",
59
+ "@testing-library/react": "^16.3.0",
58
60
  "@types/react": "~19.2.7",
59
61
  "@types/react-dom": "~19.2.3",
60
62
  "react": "~19.2.3",
61
63
  "react-dom": "~19.2.3",
62
64
  "vite": "^8.0.16",
63
- "@dxos/react-ui": "0.8.4-staging.60fe92afc8",
64
- "@dxos/random": "0.8.4-staging.60fe92afc8",
65
- "@dxos/storybook-utils": "0.8.4-staging.60fe92afc8",
66
- "@dxos/ui-theme": "0.8.4-staging.60fe92afc8"
65
+ "@dxos/random": "0.9.1-main.c7dcc2e112",
66
+ "@dxos/react-ui": "0.9.1-main.c7dcc2e112",
67
+ "@dxos/storybook-utils": "0.9.1-main.c7dcc2e112",
68
+ "@dxos/ui-theme": "0.9.1-main.c7dcc2e112"
67
69
  },
68
70
  "peerDependencies": {
69
71
  "effect": "3.21.3",
70
72
  "react": "~19.2.3",
71
73
  "react-dom": "~19.2.3",
72
- "@dxos/react-ui": "0.8.4-staging.60fe92afc8",
73
- "@dxos/ui-theme": "0.8.4-staging.60fe92afc8"
74
+ "@dxos/ui-theme": "0.9.1-main.c7dcc2e112",
75
+ "@dxos/react-ui": "0.9.1-main.c7dcc2e112"
74
76
  },
75
77
  "publishConfig": {
76
78
  "access": "public"
@@ -0,0 +1,45 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Registry, RegistryContext } from '@effect-atom/atom-react';
6
+ import { act, renderHook } from '@testing-library/react';
7
+ import React, { type PropsWithChildren } from 'react';
8
+ import { describe, test } from 'vitest';
9
+
10
+ import { ViewStateManager, createDefaultBackends } from '../../view-state';
11
+ import { ViewStateProvider, useSelection, useSelectionActions } from './ViewStateProvider';
12
+
13
+ describe('useSelection / useSelectionActions', () => {
14
+ test('single select updates the resolved value', ({ expect }) => {
15
+ const registry = Registry.make();
16
+ const manager = new ViewStateManager({ registry, backends: createDefaultBackends(registry) });
17
+ const Wrapper = wrapper(manager, registry);
18
+ const { result: value } = renderHook(() => useSelection('ctx', 'single'), { wrapper: Wrapper });
19
+ const { result: actions } = renderHook(() => useSelectionActions('ctx'), { wrapper: Wrapper });
20
+ expect(value.current).toBeUndefined();
21
+ act(() => actions.current.single('item-1'));
22
+ expect(value.current).toEqual('item-1');
23
+ });
24
+
25
+ test('toggle within multi selection', ({ expect }) => {
26
+ const registry = Registry.make();
27
+ const manager = new ViewStateManager({ registry, backends: createDefaultBackends(registry) });
28
+ const Wrapper = wrapper(manager, registry);
29
+ const { result: value } = renderHook(() => useSelection('ctx', 'multi'), { wrapper: Wrapper });
30
+ const { result: actions } = renderHook(() => useSelectionActions('ctx'), { wrapper: Wrapper });
31
+ act(() => actions.current.toggle('a'));
32
+ act(() => actions.current.toggle('b'));
33
+ expect(value.current).toEqual(['a', 'b']);
34
+ act(() => actions.current.toggle('a'));
35
+ expect(value.current).toEqual(['b']);
36
+ });
37
+ });
38
+
39
+ const wrapper =
40
+ (manager: ViewStateManager, registry: Registry.Registry) =>
41
+ ({ children }: PropsWithChildren) => (
42
+ <RegistryContext.Provider value={registry}>
43
+ <ViewStateProvider manager={manager}>{children}</ViewStateProvider>
44
+ </RegistryContext.Provider>
45
+ );
@@ -0,0 +1,123 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { RegistryContext } from '@effect-atom/atom-react';
6
+ import { createContext } from '@radix-ui/react-context';
7
+ import React, { type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react';
8
+
9
+ import { invariant } from '@dxos/invariant';
10
+ import { useDefaultValue } from '@dxos/react-hooks';
11
+
12
+ import {
13
+ type SelectionMode,
14
+ type SelectionResult,
15
+ resolveSelection,
16
+ selectionAspect,
17
+ toggleSelection,
18
+ } from '../../selection';
19
+ import { type AspectDef, ViewStateManager, createDefaultBackends } from '../../view-state';
20
+
21
+ const VIEW_STATE_NAME = 'ViewState';
22
+
23
+ type ViewStateContextValue = { manager?: ViewStateManager };
24
+
25
+ // Default value lets consumers render outside a provider (isolated stories/tests) without throwing;
26
+ // `manager` reads as `undefined` and hooks fall back to aspect defaults / no-op actions.
27
+ const [ViewStateContextProvider, useViewStateContext] = createContext<ViewStateContextValue>(VIEW_STATE_NAME, {
28
+ manager: undefined,
29
+ });
30
+
31
+ /** Provides the per-context UI state manager. Replaces the former `SelectionProvider`. */
32
+ export const ViewStateProvider = ({
33
+ children,
34
+ manager: managerProp,
35
+ }: PropsWithChildren<{ manager?: ViewStateManager }>) => {
36
+ const registry = useContext(RegistryContext);
37
+ const manager = useDefaultValue(
38
+ managerProp,
39
+ () => new ViewStateManager({ registry, backends: createDefaultBackends(registry) }),
40
+ );
41
+ return <ViewStateContextProvider manager={manager}>{children}</ViewStateContextProvider>;
42
+ };
43
+
44
+ /** Access the underlying ViewStateManager from context. Throws when used outside a `ViewStateProvider`. */
45
+ export const useViewStateManager = (): ViewStateManager => {
46
+ const { manager } = useViewStateContext(VIEW_STATE_NAME);
47
+ invariant(manager, 'useViewStateManager() requires a ViewStateProvider ancestor.');
48
+ return manager;
49
+ };
50
+
51
+ /** Reactive read of an aspect value for a context; yields the aspect default when unset or unprovided. */
52
+ export const useViewState = <T,>(aspect: AspectDef<T>, contextId?: string): T => {
53
+ const { manager } = useViewStateContext(VIEW_STATE_NAME);
54
+ const [value, setValue] = useState<T>(() =>
55
+ contextId && manager ? manager.get(aspect, contextId) : aspect.defaultValue(),
56
+ );
57
+ useEffect(() => {
58
+ if (!contextId || !manager) {
59
+ setValue(aspect.defaultValue());
60
+ return;
61
+ }
62
+ setValue(manager.get(aspect, contextId));
63
+ return manager.subscribe(aspect, contextId, setValue);
64
+ }, [manager, aspect, contextId]);
65
+ return value;
66
+ };
67
+
68
+ export type UseViewStateActions<T> = {
69
+ set: (value: T) => void;
70
+ update: (fn: (prev: T) => T) => void;
71
+ clear: () => void;
72
+ };
73
+
74
+ export const useViewStateActions = <T,>(aspect: AspectDef<T>, contextId?: string): UseViewStateActions<T> => {
75
+ const { manager } = useViewStateContext(VIEW_STATE_NAME);
76
+ return useMemo<UseViewStateActions<T>>(
77
+ () => ({
78
+ set: (value) => {
79
+ if (contextId) {
80
+ manager?.set(aspect, contextId, value);
81
+ }
82
+ },
83
+ update: (fn) => {
84
+ if (contextId) {
85
+ manager?.update(aspect, contextId, fn);
86
+ }
87
+ },
88
+ clear: () => {
89
+ if (contextId) {
90
+ manager?.set(aspect, contextId, aspect.defaultValue());
91
+ }
92
+ },
93
+ }),
94
+ [manager, aspect, contextId],
95
+ );
96
+ };
97
+
98
+ /** Resolved selection value for `contextId` in the requested `mode` (default `multi`). */
99
+ export const useSelection = <T extends SelectionMode>(contextId?: string, mode: T = 'multi' as T): SelectionResult<T> =>
100
+ resolveSelection(useViewState(selectionAspect, contextId), mode);
101
+
102
+ export type UseSelectionActions = {
103
+ single: (id: string) => void;
104
+ multi: (ids: string[]) => void;
105
+ range: (from: string, to: string) => void;
106
+ toggle: (id: string) => void;
107
+ clear: () => void;
108
+ };
109
+
110
+ /** Selection mutators for a single context, built on the generic ViewState actions. */
111
+ export const useSelectionActions = (contextId?: string): UseSelectionActions => {
112
+ const { update, clear } = useViewStateActions(selectionAspect, contextId);
113
+ return useMemo<UseSelectionActions>(
114
+ () => ({
115
+ single: (id) => update(() => ({ mode: 'single', id })),
116
+ multi: (ids) => update(() => ({ mode: 'multi', ids: [...ids] })),
117
+ range: (from, to) => update(() => ({ mode: 'range', from, to })),
118
+ toggle: (id) => update((prev) => toggleSelection(prev, id)),
119
+ clear,
120
+ }),
121
+ [update, clear],
122
+ );
123
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './ViewStateProvider';
@@ -4,4 +4,4 @@
4
4
 
5
5
  export * from './AttentionGlyph';
6
6
  export * from './AttentionProvider';
7
- export * from './SelectionProvider';
7
+ export * from './ViewStateProvider';
package/src/index.ts CHANGED
@@ -6,3 +6,4 @@ export * from './attention';
6
6
  export * from './components';
7
7
  export * from './selection';
8
8
  export * from './useArticleKeyboardNavigation';
9
+ export * from './view-state';
@@ -0,0 +1,67 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Registry } from '@effect-atom/atom-react';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { getSelectionSet, resolveSelection, selectionAspect, toggleSelection } from './selection';
9
+ import { ViewStateManager, createDefaultBackends } from './view-state';
10
+
11
+ describe('selection helpers', () => {
12
+ test('selectionAspect declares a memory-backed aspect', ({ expect }) => {
13
+ expect(selectionAspect.key).toEqual('selection');
14
+ expect(selectionAspect.backend).toEqual('memory');
15
+ expect(selectionAspect.defaultValue()).toEqual({ mode: 'multi', ids: [] });
16
+ });
17
+
18
+ test('resolveSelection extracts the value for the requested mode', ({ expect }) => {
19
+ expect(resolveSelection({ mode: 'single', id: 'x' }, 'single')).toEqual('x');
20
+ expect(resolveSelection({ mode: 'multi', ids: ['a', 'b'] }, 'multi')).toEqual(['a', 'b']);
21
+ expect(resolveSelection({ mode: 'range', from: 'a', to: 'b' }, 'range')).toEqual({ from: 'a', to: 'b' });
22
+ expect(resolveSelection({ mode: 'multi-range', ranges: [{ from: 'a', to: 'b' }] }, 'multi-range')).toEqual([
23
+ { from: 'a', to: 'b' },
24
+ ]);
25
+ });
26
+
27
+ test('resolveSelection returns the requested-mode default on mismatch or undefined', ({ expect }) => {
28
+ expect(resolveSelection(undefined, 'single')).toBeUndefined();
29
+ expect(resolveSelection(undefined, 'multi')).toEqual([]);
30
+ // Stored value is the default multi but a single reader asks — yields the single default.
31
+ expect(resolveSelection({ mode: 'multi', ids: [] }, 'single')).toBeUndefined();
32
+ expect(resolveSelection({ mode: 'range' }, 'range')).toBeUndefined();
33
+ });
34
+
35
+ test('toggleSelection adds/removes within a multi selection', ({ expect }) => {
36
+ expect(toggleSelection({ mode: 'multi', ids: ['a'] }, 'b')).toEqual({ mode: 'multi', ids: ['a', 'b'] });
37
+ expect(toggleSelection({ mode: 'multi', ids: ['a', 'b'] }, 'b')).toEqual({ mode: 'multi', ids: ['a'] });
38
+ // Tolerate a non-multi current value by starting fresh.
39
+ expect(toggleSelection({ mode: 'single', id: 'x' }, 'b')).toEqual({ mode: 'multi', ids: ['b'] });
40
+ });
41
+ });
42
+
43
+ describe('getSelectionSet', () => {
44
+ const makeManager = () => {
45
+ const registry = Registry.make();
46
+ return new ViewStateManager({ registry, backends: createDefaultBackends(registry) });
47
+ };
48
+
49
+ test('unions multi-selected ids across every context', ({ expect }) => {
50
+ const manager = makeManager();
51
+ manager.set(selectionAspect, 'ctx-a', { mode: 'multi', ids: ['a1', 'a2'] });
52
+ manager.set(selectionAspect, 'ctx-b', { mode: 'multi', ids: ['a2', 'b1'] });
53
+ // Single-mode contexts contribute nothing to the set.
54
+ manager.set(selectionAspect, 'ctx-c', { mode: 'single', id: 'c1' });
55
+ expect(getSelectionSet(manager)).toEqual(new Set(['a1', 'a2', 'b1']));
56
+ });
57
+
58
+ test('seeds the set with the optional explicit contextId', ({ expect }) => {
59
+ const manager = makeManager();
60
+ manager.set(selectionAspect, 'ctx-a', { mode: 'multi', ids: ['a1'] });
61
+ expect(getSelectionSet(manager, 'explicit')).toEqual(new Set(['explicit', 'a1']));
62
+ });
63
+
64
+ test('returns an empty set when nothing is selected', ({ expect }) => {
65
+ expect(getSelectionSet(makeManager())).toEqual(new Set());
66
+ });
67
+ });
package/src/selection.ts CHANGED
@@ -2,11 +2,10 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, type Registry } from '@effect-atom/atom-react';
6
5
  import * as Match from 'effect/Match';
7
6
  import * as Schema from 'effect/Schema';
8
7
 
9
- import { invariant } from '@dxos/invariant';
8
+ import { type AspectDef, type ViewStateManager, defineViewState } from './view-state';
10
9
 
11
10
  export type SelectionMode = 'single' | 'multi' | 'range' | 'multi-range';
12
11
 
@@ -51,178 +50,49 @@ export type SelectionResult<T extends SelectionMode> = T extends 'single'
51
50
  ? { from: string; to: string }[]
52
51
  : never;
53
52
 
54
- export const getSelectionSet = (selectionManager: SelectionManager, contextId?: string) => {
53
+ /** Selection state for a context, stored in memory (ephemeral, per-device session). */
54
+ export const selectionAspect: AspectDef<Selection> = defineViewState<Selection>({
55
+ key: 'selection',
56
+ backend: 'memory',
57
+ schema: SelectionSchema,
58
+ defaultValue: () => ({ mode: 'multi', ids: [] }),
59
+ });
60
+
61
+ /**
62
+ * Extract the typed result for `mode` from a stored selection value, returning the requested
63
+ * mode's default when the stored value is absent or holds a different mode.
64
+ */
65
+ export const resolveSelection = <T extends SelectionMode>(
66
+ selection: Selection | undefined,
67
+ mode: T,
68
+ ): SelectionResult<T> => {
69
+ const value = selection?.mode === mode ? selection : defaultSelection(mode);
70
+ // Cast required because TypeScript cannot relate the `Match` result to the generic `T`.
71
+ return Match.type<Selection>().pipe(
72
+ Match.when({ mode: 'single' }, (s) => s.id),
73
+ Match.when({ mode: 'multi' }, (s) => s.ids),
74
+ Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
75
+ Match.when({ mode: 'multi-range' }, (s) => s.ranges),
76
+ Match.exhaustive,
77
+ )(value) as SelectionResult<T>;
78
+ };
79
+
80
+ /** Toggle `id` within a multi selection; resets to a multi selection if the current value differs. */
81
+ export const toggleSelection = (selection: Selection | undefined, id: string): Selection => {
82
+ const ids = selection?.mode === 'multi' ? selection.ids : [];
83
+ return { mode: 'multi', ids: ids.includes(id) ? ids.filter((existing) => existing !== id) : [...ids, id] };
84
+ };
85
+
86
+ /** Union of all multi-selected ids across every selection context, plus an optional explicit id. */
87
+ export const getSelectionSet = (manager: ViewStateManager, contextId?: string): Set<string> => {
55
88
  const ids = new Set<string>(contextId ? [contextId] : []);
56
- for (const context of selectionManager.getSelectionContexts()) {
57
- const selection = selectionManager.getSelection(context);
58
- if (selection?.mode === 'multi') {
89
+ for (const context of manager.contexts(selectionAspect)) {
90
+ const selection = manager.get(selectionAspect, context);
91
+ if (selection.mode === 'multi') {
59
92
  for (const id of selection.ids) {
60
93
  ids.add(id);
61
94
  }
62
95
  }
63
96
  }
64
-
65
97
  return ids;
66
98
  };
67
-
68
- /**
69
- * Manages selection state for different contexts.
70
- * Each context maintains its own selection mode and state.
71
- */
72
- export class SelectionManager {
73
- private readonly _state: Atom.Writable<{ selections: Record<string, Selection> }>;
74
-
75
- constructor(
76
- private readonly _registry: Registry.Registry,
77
- initial: Record<string, Selection> = {},
78
- ) {
79
- this._state = Atom.make<{ selections: Record<string, Selection> }>({
80
- selections: { ...initial },
81
- });
82
- }
83
-
84
- /**
85
- * Get the state atom for reactive access in the graph system.
86
- */
87
- get state(): Atom.Atom<{ selections: Record<string, Selection> }> {
88
- return this._state;
89
- }
90
-
91
- /**
92
- * Gets the current state.
93
- */
94
- getState(): { selections: Record<string, Selection> } {
95
- return this._registry.get(this._state);
96
- }
97
-
98
- /**
99
- * Subscribe to changes in the selection state.
100
- */
101
- subscribe(cb: (state: { selections: Record<string, Selection> }) => void): () => void {
102
- this._registry.get(this._state);
103
- return this._registry.subscribe(this._state, () => {
104
- cb(this._registry.get(this._state));
105
- });
106
- }
107
-
108
- getSelectionContexts(): string[] {
109
- return Object.keys(this._registry.get(this._state).selections);
110
- }
111
-
112
- getSelection(contextId: string): Selection | undefined;
113
- getSelection<T extends SelectionMode>(contextId: string, mode: T): Selection;
114
- getSelection<T extends SelectionMode | undefined>(
115
- contextId: string,
116
- mode: T = undefined as T,
117
- ): Selection | undefined {
118
- const state = this._registry.get(this._state);
119
- const selection = state.selections[contextId];
120
- if (!mode || selection) {
121
- return selection;
122
- }
123
-
124
- // Create new selection for the context.
125
- const newSelection = defaultSelection(mode);
126
- this._registry.update(this._state, (state) => ({
127
- selections: { ...state.selections, [contextId]: newSelection },
128
- }));
129
-
130
- return newSelection;
131
- }
132
-
133
- getSelected<T extends SelectionMode>(contextId: string, mode: T = 'multi' as T): SelectionResult<T> {
134
- const selection = this.getSelection(contextId, mode);
135
- invariant(selection?.mode === mode, 'Selection mode mismatch');
136
-
137
- // Cast required because TypeScript can't infer the relationship between T and the matched result.
138
- return Match.type<Selection>().pipe(
139
- Match.when({ mode: 'single' }, (s) => s.id),
140
- Match.when({ mode: 'multi' }, (s) => s.ids),
141
- Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
142
- Match.when({ mode: 'multi-range' }, (s) => s.ranges),
143
- Match.exhaustive,
144
- )(selection) as SelectionResult<T>;
145
- }
146
-
147
- updateSingle(contextId: string, id: string): void {
148
- const selection = this.getSelection(contextId, 'single');
149
- invariant(selection?.mode === 'single', 'Selection mode is not single');
150
- this._registry.update(this._state, (state) => ({
151
- selections: {
152
- ...state.selections,
153
- [contextId]: { ...selection, id },
154
- },
155
- }));
156
- }
157
-
158
- updateMulti(contextId: string, ids: string[]) {
159
- const selection = this.getSelection(contextId, 'multi');
160
- invariant(selection?.mode === 'multi', 'Selection mode is not multi');
161
- this._registry.update(this._state, (state) => ({
162
- selections: {
163
- ...state.selections,
164
- [contextId]: { ...selection, ids: [...ids] },
165
- },
166
- }));
167
- }
168
-
169
- updateRange(contextId: string, from: string, to: string) {
170
- const selection = this.getSelection(contextId, 'range');
171
- invariant(selection?.mode === 'range', 'Selection mode is not range');
172
- this._registry.update(this._state, (state) => ({
173
- selections: {
174
- ...state.selections,
175
- [contextId]: { ...selection, from, to },
176
- },
177
- }));
178
- }
179
-
180
- updateMultiRange(contextId: string, ranges: { from: string; to: string }[]) {
181
- const selection = this.getSelection(contextId, 'multi-range');
182
- invariant(selection?.mode === 'multi-range', 'Selection mode is not multi-range');
183
- this._registry.update(this._state, (state) => ({
184
- selections: {
185
- ...state.selections,
186
- [contextId]: { ...selection, ranges: [...ranges] },
187
- },
188
- }));
189
- }
190
-
191
- clearSelection(contextId: string): void {
192
- const selection = this.getSelection(contextId);
193
- if (!selection) {
194
- return;
195
- }
196
-
197
- const clearedSelection = Match.type<Selection>().pipe(
198
- Match.when({ mode: 'single' }, (s) => ({ ...s, id: undefined })),
199
- Match.when({ mode: 'multi' }, (s) => ({ ...s, ids: [] })),
200
- Match.when({ mode: 'range' }, (s) => ({ ...s, from: undefined, to: undefined })),
201
- Match.when({ mode: 'multi-range' }, (s) => ({ ...s, ranges: [] })),
202
- Match.exhaustive,
203
- )(selection);
204
-
205
- this._registry.update(this._state, (state) => ({
206
- selections: {
207
- ...state.selections,
208
- [contextId]: clearedSelection,
209
- },
210
- }));
211
- }
212
-
213
- toggleSelection(contextId: string, id: string): void {
214
- const selection = this.getSelection(contextId, 'multi');
215
- invariant(selection?.mode === 'multi', 'Selection mode is not multi');
216
-
217
- const newIds = selection.ids.includes(id)
218
- ? selection.ids.filter((existingId) => existingId !== id)
219
- : [...selection.ids, id];
220
-
221
- this._registry.update(this._state, (state) => ({
222
- selections: {
223
- ...state.selections,
224
- [contextId]: { ...selection, ids: newIds },
225
- },
226
- }));
227
- }
228
- }
@@ -7,7 +7,7 @@ import { type Decorator } from '@storybook/react';
7
7
  import { createElement, useMemo } from 'react';
8
8
 
9
9
  import { AttentionManager } from '../../attention';
10
- import { RootAttentionProvider, SelectionProvider } from '../../components';
10
+ import { RootAttentionProvider, ViewStateProvider } from '../../components';
11
11
 
12
12
  /**
13
13
  * Storybook decorator that provides attention context.
@@ -24,7 +24,7 @@ export const withAttention = (initialAttendedId?: string): Decorator => {
24
24
  return createElement(
25
25
  RegistryContext.Provider,
26
26
  { value: registry },
27
- createElement(RootAttentionProvider, { attention }, createElement(SelectionProvider, {}, createElement(Story))),
27
+ createElement(RootAttentionProvider, { attention }, createElement(ViewStateProvider, {}, createElement(Story))),
28
28
  );
29
29
  };
30
30
  };
@@ -7,3 +7,4 @@
7
7
  // other non-DOM code that only needs the data model and pure helpers.
8
8
  export * from '../attention';
9
9
  export * from '../selection';
10
+ export * from '../view-state';
@@ -0,0 +1,104 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Registry } from '@effect-atom/atom-react';
6
+ import * as Schema from 'effect/Schema';
7
+ import { afterEach, describe, test } from 'vitest';
8
+
9
+ import { LocalBackend } from './backends';
10
+ import { ViewStateManager, defineViewState } from './view-state';
11
+
12
+ const Editor = defineViewState({
13
+ key: 'editor',
14
+ backend: 'local',
15
+ schema: Schema.Struct({ scrollTo: Schema.optional(Schema.Number) }).pipe(Schema.mutable),
16
+ defaultValue: () => ({}),
17
+ });
18
+
19
+ // Minimal in-memory Storage stand-in (no real localStorage in the test runner).
20
+ const fakeStorage = (): Storage => {
21
+ const map = new Map<string, string>();
22
+ return {
23
+ get length() {
24
+ return map.size;
25
+ },
26
+ clear: () => map.clear(),
27
+ getItem: (key) => (map.has(key) ? map.get(key)! : null),
28
+ key: (index) => [...map.keys()][index] ?? null,
29
+ removeItem: (key) => void map.delete(key),
30
+ setItem: (key, value) => void map.set(key, value),
31
+ };
32
+ };
33
+
34
+ describe('LocalBackend', () => {
35
+ // Dispose every backend after each test so its global `storage` listener does not leak across the suite.
36
+ const disposables: LocalBackend[] = [];
37
+ afterEach(() => {
38
+ disposables.splice(0).forEach((backend) => backend.dispose());
39
+ });
40
+
41
+ const make = () => {
42
+ const registry = Registry.make();
43
+ const storage = fakeStorage();
44
+ const local = new LocalBackend({ registry, storage });
45
+ disposables.push(local);
46
+ const manager = new ViewStateManager({ registry, backends: { memory: local, local } });
47
+ return { manager, storage };
48
+ };
49
+
50
+ test('default until written', ({ expect }) => {
51
+ const { manager } = make();
52
+ expect(manager.get(Editor, 'doc-1')).toEqual({});
53
+ });
54
+
55
+ test('set persists encoded JSON under the namespaced key', ({ expect }) => {
56
+ const { manager, storage } = make();
57
+ manager.set(Editor, 'doc-1', { scrollTo: 42 });
58
+ expect(JSON.parse(storage.getItem('dxos:view-state:editor:doc-1')!)).toEqual({ scrollTo: 42 });
59
+ });
60
+
61
+ test('reading does not write defaults to storage', ({ expect }) => {
62
+ const { manager, storage } = make();
63
+ manager.get(Editor, 'doc-1');
64
+ expect(storage.getItem('dxos:view-state:editor:doc-1')).toBeNull();
65
+ });
66
+
67
+ test('seeds atom from pre-existing storage value', ({ expect }) => {
68
+ const { manager, storage } = make();
69
+ storage.setItem('dxos:view-state:editor:doc-2', JSON.stringify({ scrollTo: 7 }));
70
+ expect(manager.get(Editor, 'doc-2')).toEqual({ scrollTo: 7 });
71
+ });
72
+
73
+ test('falls back to default on unparseable storage value', ({ expect }) => {
74
+ const { manager, storage } = make();
75
+ storage.setItem('dxos:view-state:editor:doc-3', '{not json');
76
+ expect(manager.get(Editor, 'doc-3')).toEqual({});
77
+ });
78
+
79
+ test('contexts enumerates persisted contexts', ({ expect }) => {
80
+ const { manager } = make();
81
+ manager.set(Editor, 'doc-1', { scrollTo: 1 });
82
+ manager.set(Editor, 'doc-2', { scrollTo: 2 });
83
+ expect(new Set(manager.contexts(Editor))).toEqual(new Set(['doc-1', 'doc-2']));
84
+ });
85
+
86
+ test('a cross-tab storage event re-reads the atom from storage', ({ expect }) => {
87
+ const registry = Registry.make();
88
+ const storage = fakeStorage();
89
+ const local = new LocalBackend({ registry, storage });
90
+ const manager = new ViewStateManager({ registry, backends: { memory: local, local } });
91
+
92
+ // Touch the context so its atom is registered and observed by the storage listener.
93
+ const key = 'dxos:view-state:editor:doc-4';
94
+ expect(manager.get(Editor, 'doc-4')).toEqual({});
95
+
96
+ // Simulate another tab's write: update storage, then fire the `storage` event (the listener
97
+ // re-reads from storage rather than trusting the event payload).
98
+ storage.setItem(key, JSON.stringify({ scrollTo: 99 }));
99
+ globalThis.dispatchEvent(new StorageEvent('storage', { key, newValue: storage.getItem(key) }));
100
+
101
+ expect(manager.get(Editor, 'doc-4')).toEqual({ scrollTo: 99 });
102
+ local.dispose();
103
+ });
104
+ });