@dxos/react-ui-attention 0.9.1-staging.ee54ba693a → 0.11.0
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.
- package/dist/lib/chunk-ViewStateProvider.mjs +308 -0
- package/dist/lib/chunk-ViewStateProvider.mjs.map +1 -0
- package/dist/lib/chunk-types.mjs +351 -0
- package/dist/lib/chunk-types.mjs.map +1 -0
- package/dist/lib/index.mjs +149 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +20 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/lib/types.mjs +2 -0
- package/dist/types/src/components/AttentionProvider/AttentionProvider.d.ts +2 -15
- package/dist/types/src/components/AttentionProvider/AttentionProvider.d.ts.map +1 -1
- package/dist/types/src/components/AttentionProvider/attention-context.d.ts +20 -0
- package/dist/types/src/components/AttentionProvider/attention-context.d.ts.map +1 -0
- package/dist/types/src/components/AttentionProvider/index.d.ts +1 -0
- package/dist/types/src/components/AttentionProvider/index.d.ts.map +1 -1
- package/dist/types/src/components/ViewStateProvider/ViewStateProvider.d.ts +2 -26
- package/dist/types/src/components/ViewStateProvider/ViewStateProvider.d.ts.map +1 -1
- package/dist/types/src/components/ViewStateProvider/index.d.ts +1 -0
- package/dist/types/src/components/ViewStateProvider/index.d.ts.map +1 -1
- package/dist/types/src/components/ViewStateProvider/view-state-hooks.d.ts +32 -0
- package/dist/types/src/components/ViewStateProvider/view-state-hooks.d.ts.map +1 -0
- package/dist/types/src/core/backends.d.ts +24 -0
- package/dist/types/src/core/backends.d.ts.map +1 -0
- package/dist/types/src/core/backends.test.d.ts.map +1 -0
- package/dist/types/src/core/index.d.ts +2 -0
- package/dist/types/src/core/index.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +2 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useArticleKeyboardNavigation.d.ts.map +1 -0
- package/dist/types/src/hooks/useArticleKeyboardNavigation.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +3 -4
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/{attention.d.ts → types/Attention.d.ts} +13 -2
- package/dist/types/src/types/Attention.d.ts.map +1 -0
- package/dist/types/src/types/Attention.test.d.ts +2 -0
- package/dist/types/src/types/Attention.test.d.ts.map +1 -0
- package/dist/types/src/{selection.d.ts → types/Selection.d.ts} +12 -10
- package/dist/types/src/types/Selection.d.ts.map +1 -0
- package/dist/types/src/types/Selection.test.d.ts +2 -0
- package/dist/types/src/types/Selection.test.d.ts.map +1 -0
- package/dist/types/src/types/ViewState.d.ts +66 -0
- package/dist/types/src/types/ViewState.d.ts.map +1 -0
- package/dist/types/src/types/ViewState.test.d.ts +2 -0
- package/dist/types/src/types/ViewState.test.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +3 -3
- package/dist/types/src/types/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +18 -20
- package/src/components/AttentionProvider/AttentionProvider.stories.tsx +1 -1
- package/src/components/AttentionProvider/AttentionProvider.tsx +4 -86
- package/src/components/AttentionProvider/attention-context.ts +81 -0
- package/src/components/AttentionProvider/index.ts +2 -0
- package/src/components/ViewStateProvider/ViewStateProvider.test.tsx +7 -5
- package/src/components/ViewStateProvider/ViewStateProvider.tsx +6 -110
- package/src/components/ViewStateProvider/index.ts +9 -0
- package/src/components/ViewStateProvider/view-state-hooks.ts +115 -0
- package/src/{view-state → core}/backends.test.ts +5 -5
- package/src/{view-state → core}/backends.ts +24 -17
- package/src/{view-state → core}/index.ts +0 -1
- package/src/hooks/index.ts +5 -0
- package/src/{useArticleKeyboardNavigation.ts → hooks/useArticleKeyboardNavigation.ts} +1 -1
- package/src/index.ts +3 -4
- package/src/testing/decorators/withAttention.ts +2 -2
- package/src/{attention.test.ts → types/Attention.test.ts} +2 -2
- package/src/{attention.ts → types/Attention.ts} +17 -2
- package/src/types/Selection.test.ts +100 -0
- package/src/{selection.ts → types/Selection.ts} +27 -17
- package/src/{view-state/view-state.test.ts → types/ViewState.test.ts} +6 -6
- package/src/types/ViewState.ts +101 -0
- package/src/types/index.ts +8 -7
- package/dist/lib/browser/chunk-4HYAJ4IO.mjs +0 -434
- package/dist/lib/browser/chunk-4HYAJ4IO.mjs.map +0 -7
- package/dist/lib/browser/chunk-I4FDY4AZ.mjs +0 -236
- package/dist/lib/browser/chunk-I4FDY4AZ.mjs.map +0 -7
- package/dist/lib/browser/index.mjs +0 -218
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/testing/index.mjs +0 -30
- package/dist/lib/browser/testing/index.mjs.map +0 -7
- package/dist/lib/browser/types/index.mjs +0 -43
- package/dist/lib/browser/types/index.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-DLBZMHST.mjs +0 -237
- package/dist/lib/node-esm/chunk-DLBZMHST.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-HWN7KJDX.mjs +0 -436
- package/dist/lib/node-esm/chunk-HWN7KJDX.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -219
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/testing/index.mjs +0 -31
- package/dist/lib/node-esm/testing/index.mjs.map +0 -7
- package/dist/lib/node-esm/types/index.mjs +0 -44
- package/dist/lib/node-esm/types/index.mjs.map +0 -7
- package/dist/types/src/attention.d.ts.map +0 -1
- package/dist/types/src/attention.test.d.ts +0 -2
- package/dist/types/src/attention.test.d.ts.map +0 -1
- package/dist/types/src/selection.d.ts.map +0 -1
- package/dist/types/src/selection.test.d.ts +0 -2
- package/dist/types/src/selection.test.d.ts.map +0 -1
- package/dist/types/src/useArticleKeyboardNavigation.d.ts.map +0 -1
- package/dist/types/src/useArticleKeyboardNavigation.test.d.ts.map +0 -1
- package/dist/types/src/view-state/backends.d.ts +0 -24
- package/dist/types/src/view-state/backends.d.ts.map +0 -1
- package/dist/types/src/view-state/backends.test.d.ts.map +0 -1
- package/dist/types/src/view-state/index.d.ts +0 -3
- package/dist/types/src/view-state/index.d.ts.map +0 -1
- package/dist/types/src/view-state/view-state.d.ts +0 -54
- package/dist/types/src/view-state/view-state.d.ts.map +0 -1
- package/dist/types/src/view-state/view-state.test.d.ts +0 -2
- package/dist/types/src/view-state/view-state.test.d.ts.map +0 -1
- package/src/selection.test.ts +0 -67
- package/src/view-state/view-state.ts +0 -91
- /package/dist/types/src/{view-state → core}/backends.test.d.ts +0 -0
- /package/dist/types/src/{useArticleKeyboardNavigation.d.ts → hooks/useArticleKeyboardNavigation.d.ts} +0 -0
- /package/dist/types/src/{useArticleKeyboardNavigation.test.d.ts → hooks/useArticleKeyboardNavigation.test.d.ts} +0 -0
- /package/src/{useArticleKeyboardNavigation.test.ts → hooks/useArticleKeyboardNavigation.test.ts} +0 -0
|
@@ -2,30 +2,33 @@
|
|
|
2
2
|
// Copyright 2026 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Atom, type Registry } from '@effect-atom/atom
|
|
5
|
+
import { Atom, type Registry } from '@effect-atom/atom';
|
|
6
6
|
import * as Schema from 'effect/Schema';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { ViewState } from '../types';
|
|
9
9
|
|
|
10
|
-
// Only the stable `key` string is needed to form the map key; avoids variance issues with
|
|
10
|
+
// Only the stable `key` string is needed to form the map key; avoids variance issues with Aspect<T>.
|
|
11
11
|
const cacheKey = (aspect: { key: string }, contextId: string) => `${aspect.key}:${contextId}`;
|
|
12
12
|
|
|
13
13
|
/** In-memory backend: state is ephemeral and scoped to the session (never persisted). */
|
|
14
|
-
export class MemoryBackend implements
|
|
14
|
+
export class MemoryBackend implements ViewState.Backend {
|
|
15
15
|
readonly #atoms = new Map<string, Atom.Writable<unknown>>();
|
|
16
16
|
|
|
17
|
-
atom<T>(aspect:
|
|
17
|
+
atom<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>, contextId: string): Atom.Writable<T> {
|
|
18
18
|
const key = cacheKey(aspect, contextId);
|
|
19
19
|
let atom = this.#atoms.get(key);
|
|
20
20
|
if (!atom) {
|
|
21
|
-
|
|
21
|
+
// `keepAlive` pins the node so a written value survives for the session; without it the registry
|
|
22
|
+
// sweeps the unsubscribed atom back to its default, dropping state that no UI happens to observe
|
|
23
|
+
// (e.g. a selection read on-demand by an agent tool rather than a live subscriber).
|
|
24
|
+
atom = Atom.make<unknown>(aspect.defaultValue()).pipe(Atom.keepAlive);
|
|
22
25
|
this.#atoms.set(key, atom);
|
|
23
26
|
}
|
|
24
27
|
// Cast bridges the per-aspect value type erased by the shared atom map; safe by construction.
|
|
25
28
|
return atom as Atom.Writable<T>;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
contexts<T>(aspect:
|
|
31
|
+
contexts<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>): string[] {
|
|
29
32
|
const prefix = `${aspect.key}:`;
|
|
30
33
|
return [...this.#atoms.keys()].filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length));
|
|
31
34
|
}
|
|
@@ -52,14 +55,14 @@ export interface LocalBackendOptions {
|
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/** localStorage-backed backend: seeds atoms from storage, persists on set, syncs across tabs. */
|
|
55
|
-
export class LocalBackend implements
|
|
58
|
+
export class LocalBackend implements ViewState.Backend {
|
|
56
59
|
readonly #registry: Registry.Registry;
|
|
57
60
|
// Absent in non-browser contexts (SSR/tests without injection); the backend then degrades to
|
|
58
61
|
// ephemeral, in-memory behaviour rather than crashing on a missing `localStorage`.
|
|
59
62
|
readonly #storage: Storage | undefined;
|
|
60
63
|
readonly #atoms = new Map<string, Atom.Writable<unknown>>();
|
|
61
64
|
// Reverse map: storage key -> (aspect, contextId) so `storage` events can target the right atom.
|
|
62
|
-
readonly #byStorageKey = new Map<string, { aspect:
|
|
65
|
+
readonly #byStorageKey = new Map<string, { aspect: ViewState.Aspect<unknown, unknown>; contextId: string }>();
|
|
63
66
|
#storageListener?: (event: StorageEvent) => void;
|
|
64
67
|
|
|
65
68
|
constructor({ registry, storage }: LocalBackendOptions) {
|
|
@@ -80,26 +83,28 @@ export class LocalBackend implements ViewStateBackend {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
atom<T>(aspect:
|
|
86
|
+
atom<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>, contextId: string): Atom.Writable<T> {
|
|
84
87
|
const key = cacheKey(aspect, contextId);
|
|
85
88
|
let atom = this.#atoms.get(key);
|
|
86
89
|
if (!atom) {
|
|
87
90
|
const storageKey = storageKeyFor(aspect, contextId);
|
|
88
|
-
|
|
91
|
+
// `keepAlive` pins the node so the in-memory value stays consistent with storage without a live
|
|
92
|
+
// subscriber; otherwise the registry sweeps the unsubscribed atom back to its creation-time seed.
|
|
93
|
+
atom = Atom.make<unknown>(this.#read(aspect, storageKey)).pipe(Atom.keepAlive);
|
|
89
94
|
this.#atoms.set(key, atom);
|
|
90
95
|
// Cast erases the per-aspect value type so the reverse map can hold aspects of any `T`; the
|
|
91
96
|
// stored aspect is only used to re-read/decode its own value, so the erasure is safe.
|
|
92
|
-
this.#byStorageKey.set(storageKey, { aspect: aspect as
|
|
97
|
+
this.#byStorageKey.set(storageKey, { aspect: aspect as ViewState.Aspect<unknown, unknown>, contextId });
|
|
93
98
|
}
|
|
94
99
|
// Cast bridges the per-aspect value type erased by the shared atom map; safe by construction.
|
|
95
100
|
return atom as Atom.Writable<T>;
|
|
96
101
|
}
|
|
97
102
|
|
|
98
|
-
persist<T>(aspect:
|
|
103
|
+
persist<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>, contextId: string, value: T): void {
|
|
99
104
|
this.#storage?.setItem(storageKeyFor(aspect, contextId), JSON.stringify(Schema.encodeSync(aspect.schema)(value)));
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
contexts<T>(aspect:
|
|
107
|
+
contexts<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>): string[] {
|
|
103
108
|
if (!this.#storage) {
|
|
104
109
|
// No persistent storage: fall back to the in-memory atoms (mirrors `atom()`'s ephemeral path).
|
|
105
110
|
const prefix = `${aspect.key}:`;
|
|
@@ -125,7 +130,7 @@ export class LocalBackend implements ViewStateBackend {
|
|
|
125
130
|
this.#byStorageKey.clear();
|
|
126
131
|
}
|
|
127
132
|
|
|
128
|
-
#read<T>(aspect:
|
|
133
|
+
#read<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>, storageKey: string): T {
|
|
129
134
|
const raw = this.#storage?.getItem(storageKey);
|
|
130
135
|
if (raw == null) {
|
|
131
136
|
return aspect.defaultValue();
|
|
@@ -133,13 +138,15 @@ export class LocalBackend implements ViewStateBackend {
|
|
|
133
138
|
try {
|
|
134
139
|
return Schema.decodeUnknownSync(aspect.schema)(JSON.parse(raw));
|
|
135
140
|
} catch {
|
|
136
|
-
// Tolerate stale/corrupt entries (e.g
|
|
141
|
+
// Tolerate stale/corrupt entries (e.g., a prior schema shape) by falling back to the default.
|
|
137
142
|
return aspect.defaultValue();
|
|
138
143
|
}
|
|
139
144
|
}
|
|
140
145
|
}
|
|
141
146
|
|
|
142
|
-
export const createDefaultBackends = (
|
|
147
|
+
export const createDefaultBackends = (
|
|
148
|
+
registry: Registry.Registry,
|
|
149
|
+
): Record<ViewState.BackendName, ViewState.Backend> => ({
|
|
143
150
|
memory: new MemoryBackend(),
|
|
144
151
|
local: new LocalBackend({ registry }),
|
|
145
152
|
});
|
|
@@ -6,7 +6,7 @@ import { useEffect, useMemo } from 'react';
|
|
|
6
6
|
|
|
7
7
|
import { Keyboard, nestKeyboardContext } from '@dxos/keyboard';
|
|
8
8
|
|
|
9
|
-
import { useAttention } from '
|
|
9
|
+
import { useAttention } from '../components';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Compute the id to select after pressing 'j' (delta = 1) or 'k' (delta = -1).
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
export * from './attention';
|
|
6
5
|
export * from './components';
|
|
7
|
-
export * from './
|
|
8
|
-
export * from './
|
|
9
|
-
export * from './
|
|
6
|
+
export * from './core';
|
|
7
|
+
export * from './hooks';
|
|
8
|
+
export * from './types';
|
|
@@ -6,8 +6,8 @@ import { Registry, RegistryContext } from '@effect-atom/atom-react';
|
|
|
6
6
|
import { type Decorator } from '@storybook/react';
|
|
7
7
|
import { createElement, useMemo } from 'react';
|
|
8
8
|
|
|
9
|
-
import { AttentionManager } from '../../attention';
|
|
10
9
|
import { RootAttentionProvider, ViewStateProvider } from '../../components';
|
|
10
|
+
import { Attention } from '../../types';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Storybook decorator that provides attention context.
|
|
@@ -17,7 +17,7 @@ export const withAttention = (initialAttendedId?: string): Decorator => {
|
|
|
17
17
|
return (Story) => {
|
|
18
18
|
const registry = useMemo(() => Registry.make(), []);
|
|
19
19
|
const attention = useMemo(
|
|
20
|
-
() => (initialAttendedId ? new AttentionManager(registry, [initialAttendedId]) : undefined),
|
|
20
|
+
() => (initialAttendedId ? new Attention.AttentionManager(registry, [initialAttendedId]) : undefined),
|
|
21
21
|
[registry],
|
|
22
22
|
);
|
|
23
23
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Registry } from '@effect-atom/atom
|
|
5
|
+
import { Registry } from '@effect-atom/atom';
|
|
6
6
|
import { describe, test } from 'vitest';
|
|
7
7
|
|
|
8
8
|
import {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
getSegmentId,
|
|
14
14
|
isLinkedSegment,
|
|
15
15
|
linkedSegment,
|
|
16
|
-
} from './
|
|
16
|
+
} from './Attention';
|
|
17
17
|
|
|
18
18
|
describe('AttentionManager', () => {
|
|
19
19
|
test('takes an initial attended id', ({ expect }) => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Atom, type Registry } from '@effect-atom/atom
|
|
5
|
+
import { Atom, type Registry } from '@effect-atom/atom';
|
|
6
6
|
|
|
7
7
|
export type Attention = {
|
|
8
8
|
hasAttention: boolean;
|
|
@@ -159,6 +159,12 @@ export class AttentionManager {
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** The attribute marking an element as attendable, carrying its qualified id. */
|
|
163
|
+
export const ATTENDABLE_ATTRIBUTE = 'data-attendable-id';
|
|
164
|
+
|
|
165
|
+
/** Selector matching any attendable element. */
|
|
166
|
+
export const ATTENDABLE_SELECTOR = `[${ATTENDABLE_ATTRIBUTE}]`;
|
|
167
|
+
|
|
162
168
|
/**
|
|
163
169
|
* Accumulates all attendable IDs between the element provided and the root, inclusive.
|
|
164
170
|
* Each `data-attendable-id` value is treated as a single qualified ID (no splitting).
|
|
@@ -167,7 +173,7 @@ export const getAttendables = (selector: string, cursor: Element, acc: string[]
|
|
|
167
173
|
// Find the closest element with `data-attendable-id`, if any; start from cursor and move up the DOM tree.
|
|
168
174
|
const closestAttendable = cursor.closest(selector);
|
|
169
175
|
if (closestAttendable) {
|
|
170
|
-
const attendableId = closestAttendable.getAttribute(
|
|
176
|
+
const attendableId = closestAttendable.getAttribute(ATTENDABLE_ATTRIBUTE);
|
|
171
177
|
if (!attendableId) {
|
|
172
178
|
// This has an id of an aria-controls elsewhere on the page, move cursor to that trigger.
|
|
173
179
|
const trigger = document.querySelector(`[aria-controls="${closestAttendable.getAttribute('id')}"]`);
|
|
@@ -185,6 +191,15 @@ export const getAttendables = (selector: string, cursor: Element, acc: string[]
|
|
|
185
191
|
return [...new Set(acc)];
|
|
186
192
|
};
|
|
187
193
|
|
|
194
|
+
/**
|
|
195
|
+
* The outermost attendable ancestor of `element` — the root of any nested attendables (e.g. a section
|
|
196
|
+
* within a stack). Structural (real DOM ancestry), so it is independent of what currently has attention.
|
|
197
|
+
* Resolve it from an in-DOM element: a portaled subtree (a menu, a popover) is not under its own
|
|
198
|
+
* attendable, so walk from the trigger or anchor instead.
|
|
199
|
+
*/
|
|
200
|
+
export const getRootAttendableId = (element: Element): string | undefined =>
|
|
201
|
+
getAttendables(ATTENDABLE_SELECTOR, element).at(-1);
|
|
202
|
+
|
|
188
203
|
export type AttendableId = { attendableId?: string };
|
|
189
204
|
|
|
190
205
|
export type Related = { related?: boolean };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Registry } from '@effect-atom/atom';
|
|
6
|
+
import { describe, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { createDefaultBackends } from '../core';
|
|
9
|
+
import * as Selection from './Selection';
|
|
10
|
+
import { Manager } from './ViewState';
|
|
11
|
+
|
|
12
|
+
describe('selection helpers', () => {
|
|
13
|
+
test('aspect declares a memory-backed aspect', ({ expect }) => {
|
|
14
|
+
expect(Selection.aspect.key).toEqual('selection');
|
|
15
|
+
expect(Selection.aspect.backend).toEqual('memory');
|
|
16
|
+
expect(Selection.aspect.defaultValue()).toEqual({ mode: 'multi', ids: [] });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('resolveSelection extracts the value for the requested mode', ({ expect }) => {
|
|
20
|
+
expect(Selection.resolve({ mode: 'single', id: 'x' }, 'single')).toEqual('x');
|
|
21
|
+
expect(Selection.resolve({ mode: 'multi', ids: ['a', 'b'] }, 'multi')).toEqual(['a', 'b']);
|
|
22
|
+
expect(Selection.resolve({ mode: 'range', from: 'a', to: 'b' }, 'range')).toEqual({ from: 'a', to: 'b' });
|
|
23
|
+
expect(Selection.resolve({ mode: 'multi-range', ranges: [{ from: 'a', to: 'b' }] }, 'multi-range')).toEqual([
|
|
24
|
+
{ from: 'a', to: 'b' },
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('resolveSelection returns the requested-mode default on mismatch or undefined', ({ expect }) => {
|
|
29
|
+
expect(Selection.resolve(undefined, 'single')).toBeUndefined();
|
|
30
|
+
expect(Selection.resolve(undefined, 'multi')).toEqual([]);
|
|
31
|
+
// Stored value is the default multi but a single reader asks — yields the single default.
|
|
32
|
+
expect(Selection.resolve({ mode: 'multi', ids: [] }, 'single')).toBeUndefined();
|
|
33
|
+
expect(Selection.resolve({ mode: 'range' }, 'range')).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('toggleSelection adds/removes within a multi selection', ({ expect }) => {
|
|
37
|
+
expect(Selection.toggle({ mode: 'multi', ids: ['a'] }, 'b')).toEqual({ mode: 'multi', ids: ['a', 'b'] });
|
|
38
|
+
expect(Selection.toggle({ mode: 'multi', ids: ['a', 'b'] }, 'b')).toEqual({ mode: 'multi', ids: ['a'] });
|
|
39
|
+
// Tolerate a non-multi current value by starting fresh.
|
|
40
|
+
expect(Selection.toggle({ mode: 'single', id: 'x' }, 'b')).toEqual({ mode: 'multi', ids: ['b'] });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('toAnchors', () => {
|
|
45
|
+
test('undefined selection yields no anchors', ({ expect }) => {
|
|
46
|
+
expect(Selection.toAnchors(undefined)).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('single mode yields the id when set', ({ expect }) => {
|
|
50
|
+
expect(Selection.toAnchors({ mode: 'single', id: 'a' })).toEqual(['a']);
|
|
51
|
+
expect(Selection.toAnchors({ mode: 'single' })).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('multi mode yields all ids', ({ expect }) => {
|
|
55
|
+
expect(Selection.toAnchors({ mode: 'multi', ids: ['a', 'b'] })).toEqual(['a', 'b']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('range mode yields a cursor-pair anchor when complete', ({ expect }) => {
|
|
59
|
+
expect(Selection.toAnchors({ mode: 'range', from: 'x', to: 'y' })).toEqual(['x:y']);
|
|
60
|
+
expect(Selection.toAnchors({ mode: 'range', from: 'x' })).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('multi-range mode yields one anchor per range', ({ expect }) => {
|
|
64
|
+
expect(
|
|
65
|
+
Selection.toAnchors({
|
|
66
|
+
mode: 'multi-range',
|
|
67
|
+
ranges: [
|
|
68
|
+
{ from: 'a', to: 'b' },
|
|
69
|
+
{ from: 'c', to: 'd' },
|
|
70
|
+
],
|
|
71
|
+
}),
|
|
72
|
+
).toEqual(['a:b', 'c:d']);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('getValue', () => {
|
|
77
|
+
const makeManager = () => {
|
|
78
|
+
const registry = Registry.make();
|
|
79
|
+
return new Manager({ registry, backends: createDefaultBackends(registry) });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
test('unions multi-selected ids across every context', ({ expect }) => {
|
|
83
|
+
const manager = makeManager();
|
|
84
|
+
manager.set(Selection.aspect, 'ctx-a', { mode: 'multi', ids: ['a1', 'a2'] });
|
|
85
|
+
manager.set(Selection.aspect, 'ctx-b', { mode: 'multi', ids: ['a2', 'b1'] });
|
|
86
|
+
// Single-mode contexts contribute nothing to the set.
|
|
87
|
+
manager.set(Selection.aspect, 'ctx-c', { mode: 'single', id: 'c1' });
|
|
88
|
+
expect(Selection.getValue(manager)).toEqual(new Set(['a1', 'a2', 'b1']));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('seeds the set with the optional explicit contextId', ({ expect }) => {
|
|
92
|
+
const manager = makeManager();
|
|
93
|
+
manager.set(Selection.aspect, 'ctx-a', { mode: 'multi', ids: ['a1'] });
|
|
94
|
+
expect(Selection.getValue(manager, 'explicit')).toEqual(new Set(['explicit', 'a1']));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('returns an empty set when nothing is selected', ({ expect }) => {
|
|
98
|
+
expect(Selection.getValue(makeManager())).toEqual(new Set());
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
import * as Match from 'effect/Match';
|
|
6
6
|
import * as Schema from 'effect/Schema';
|
|
7
7
|
|
|
8
|
-
import { type
|
|
8
|
+
import { type Aspect, type Manager, define } from './ViewState';
|
|
9
9
|
|
|
10
10
|
export type SelectionMode = 'single' | 'multi' | 'range' | 'multi-range';
|
|
11
11
|
|
|
12
|
-
export const
|
|
12
|
+
export const Selection = Schema.Union(
|
|
13
13
|
Schema.Struct({
|
|
14
14
|
mode: Schema.Literal('single'),
|
|
15
15
|
id: Schema.optional(Schema.String),
|
|
@@ -29,9 +29,9 @@ export const SelectionSchema = Schema.Union(
|
|
|
29
29
|
}).pipe(Schema.mutable),
|
|
30
30
|
).pipe(Schema.mutable);
|
|
31
31
|
|
|
32
|
-
export type Selection = Schema.Schema.Type<typeof
|
|
32
|
+
export type Selection = Schema.Schema.Type<typeof Selection>;
|
|
33
33
|
|
|
34
|
-
export const
|
|
34
|
+
export const defaultValue = Match.type<SelectionMode>().pipe(
|
|
35
35
|
Match.withReturnType<Selection>(),
|
|
36
36
|
Match.when('single', () => ({ mode: 'single' })),
|
|
37
37
|
Match.when('multi', () => ({ mode: 'multi', ids: [] })),
|
|
@@ -40,7 +40,7 @@ export const defaultSelection = Match.type<SelectionMode>().pipe(
|
|
|
40
40
|
Match.exhaustive,
|
|
41
41
|
);
|
|
42
42
|
|
|
43
|
-
export type
|
|
43
|
+
export type Result<T extends SelectionMode> = T extends 'single'
|
|
44
44
|
? string | undefined
|
|
45
45
|
: T extends 'multi'
|
|
46
46
|
? string[]
|
|
@@ -51,10 +51,10 @@ export type SelectionResult<T extends SelectionMode> = T extends 'single'
|
|
|
51
51
|
: never;
|
|
52
52
|
|
|
53
53
|
/** Selection state for a context, stored in memory (ephemeral, per-device session). */
|
|
54
|
-
export const
|
|
54
|
+
export const aspect: Aspect<Selection> = define<Selection>({
|
|
55
55
|
key: 'selection',
|
|
56
56
|
backend: 'memory',
|
|
57
|
-
schema:
|
|
57
|
+
schema: Selection,
|
|
58
58
|
defaultValue: () => ({ mode: 'multi', ids: [] }),
|
|
59
59
|
});
|
|
60
60
|
|
|
@@ -62,11 +62,8 @@ export const selectionAspect: AspectDef<Selection> = defineViewState<Selection>(
|
|
|
62
62
|
* Extract the typed result for `mode` from a stored selection value, returning the requested
|
|
63
63
|
* mode's default when the stored value is absent or holds a different mode.
|
|
64
64
|
*/
|
|
65
|
-
export const
|
|
66
|
-
selection
|
|
67
|
-
mode: T,
|
|
68
|
-
): SelectionResult<T> => {
|
|
69
|
-
const value = selection?.mode === mode ? selection : defaultSelection(mode);
|
|
65
|
+
export const resolve = <T extends SelectionMode>(selection: Selection | undefined, mode: T): Result<T> => {
|
|
66
|
+
const value = selection?.mode === mode ? selection : defaultValue(mode);
|
|
70
67
|
// Cast required because TypeScript cannot relate the `Match` result to the generic `T`.
|
|
71
68
|
return Match.type<Selection>().pipe(
|
|
72
69
|
Match.when({ mode: 'single' }, (s) => s.id),
|
|
@@ -74,25 +71,38 @@ export const resolveSelection = <T extends SelectionMode>(
|
|
|
74
71
|
Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
|
|
75
72
|
Match.when({ mode: 'multi-range' }, (s) => s.ranges),
|
|
76
73
|
Match.exhaustive,
|
|
77
|
-
)(value) as
|
|
74
|
+
)(value) as Result<T>;
|
|
78
75
|
};
|
|
79
76
|
|
|
80
77
|
/** Toggle `id` within a multi selection; resets to a multi selection if the current value differs. */
|
|
81
|
-
export const
|
|
78
|
+
export const toggle = (selection: Selection | undefined, id: string): Selection => {
|
|
82
79
|
const ids = selection?.mode === 'multi' ? selection.ids : [];
|
|
83
80
|
return { mode: 'multi', ids: ids.includes(id) ? ids.filter((existing) => existing !== id) : [...ids, id] };
|
|
84
81
|
};
|
|
85
82
|
|
|
83
|
+
/** Anchor strings for a selection: ids for single/multi modes, `"${from}:${to}"` cursor pairs for range modes. */
|
|
84
|
+
export const toAnchors = (selection: Selection | undefined): string[] =>
|
|
85
|
+
selection == null
|
|
86
|
+
? []
|
|
87
|
+
: Match.type<Selection>().pipe(
|
|
88
|
+
Match.when({ mode: 'single' }, (value) => (value.id ? [value.id] : [])),
|
|
89
|
+
Match.when({ mode: 'multi' }, (value) => [...value.ids]),
|
|
90
|
+
Match.when({ mode: 'range' }, (value) => (value.from && value.to ? [`${value.from}:${value.to}`] : [])),
|
|
91
|
+
Match.when({ mode: 'multi-range' }, (value) => value.ranges.map((range) => `${range.from}:${range.to}`)),
|
|
92
|
+
Match.exhaustive,
|
|
93
|
+
)(selection);
|
|
94
|
+
|
|
86
95
|
/** Union of all multi-selected ids across every selection context, plus an optional explicit id. */
|
|
87
|
-
export const
|
|
96
|
+
export const getValue = (manager: Manager, contextId?: string): Set<string> => {
|
|
88
97
|
const ids = new Set<string>(contextId ? [contextId] : []);
|
|
89
|
-
for (const context of manager.contexts(
|
|
90
|
-
const selection = manager.get(
|
|
98
|
+
for (const context of manager.contexts(aspect)) {
|
|
99
|
+
const selection = manager.get(aspect, context);
|
|
91
100
|
if (selection.mode === 'multi') {
|
|
92
101
|
for (const id of selection.ids) {
|
|
93
102
|
ids.add(id);
|
|
94
103
|
}
|
|
95
104
|
}
|
|
96
105
|
}
|
|
106
|
+
|
|
97
107
|
return ids;
|
|
98
108
|
};
|
|
@@ -2,24 +2,24 @@
|
|
|
2
2
|
// Copyright 2026 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Registry } from '@effect-atom/atom
|
|
5
|
+
import { Registry } from '@effect-atom/atom';
|
|
6
6
|
import * as Schema from 'effect/Schema';
|
|
7
7
|
import { describe, test } from 'vitest';
|
|
8
8
|
|
|
9
|
-
import { createDefaultBackends } from '
|
|
10
|
-
import {
|
|
9
|
+
import { createDefaultBackends } from '../core';
|
|
10
|
+
import { Manager, define } from './ViewState';
|
|
11
11
|
|
|
12
|
-
const Counter =
|
|
12
|
+
const Counter = define({
|
|
13
13
|
key: 'counter',
|
|
14
14
|
backend: 'memory',
|
|
15
15
|
schema: Schema.Struct({ value: Schema.Number }).pipe(Schema.mutable),
|
|
16
16
|
defaultValue: () => ({ value: 0 }),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
describe('
|
|
19
|
+
describe('Manager', () => {
|
|
20
20
|
const make = () => {
|
|
21
21
|
const registry = Registry.make();
|
|
22
|
-
return new
|
|
22
|
+
return new Manager({ registry, backends: createDefaultBackends(registry) });
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
test('returns the aspect default for an unwritten context', ({ expect }) => {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Atom, type Registry } from '@effect-atom/atom';
|
|
6
|
+
import type * as Schema from 'effect/Schema';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Persistence backend identifier. `personal` (ECHO/personal-space) is reserved for a future backend.
|
|
10
|
+
*/
|
|
11
|
+
export type BackendName = 'memory' | 'local';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Declares a kind of per-context UI state. The value type `T` and its persisted wire type `Encoded`
|
|
15
|
+
* are inferred from the schema; `Encoded` defaults to `T` for the common no-transform case.
|
|
16
|
+
*/
|
|
17
|
+
export interface Aspect<T, Encoded = T> {
|
|
18
|
+
readonly key: string;
|
|
19
|
+
readonly backend: BackendName;
|
|
20
|
+
readonly schema: Schema.Schema<T, Encoded>;
|
|
21
|
+
readonly defaultValue: () => T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Identity helper that pins the value type from the schema while keeping the literal `key`/`backend`.
|
|
26
|
+
* Declares an aspect of per-context UI state (selection, scroll, view mode, split) read/written via
|
|
27
|
+
* {@link Manager} — through `useViewState`/`useViewStateActions` in React or the
|
|
28
|
+
* `AttentionCapabilities.ViewState` capability in operations. Durability is the `backend`'s: `local`
|
|
29
|
+
* survives reloads (best-effort — degrades to in-memory when storage is blocked), `memory` is
|
|
30
|
+
* session-only.
|
|
31
|
+
*
|
|
32
|
+
* @idiom org.dxos.react-ui-attention.viewState
|
|
33
|
+
* applies: Holding per-context UI state (selection, view mode, scroll, split) that survives navigation (and reloads with the `local` backend)
|
|
34
|
+
* instead-of: ad-hoc `useState` that resets on remount, or stuffing per-context state into the plugin Settings store
|
|
35
|
+
* uses: {@link define}, {@link Manager}
|
|
36
|
+
* related: org.dxos.effect.kvsStore
|
|
37
|
+
*/
|
|
38
|
+
export const define = <T, Encoded = T>(def: Aspect<T, Encoded>): Aspect<T, Encoded> => def;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A backend produces a reactive, writable atom for each `(aspect, contextId)` pair. Backends may
|
|
42
|
+
* hydrate asynchronously (an ECHO backend would), yielding `aspect.defaultValue()` until loaded;
|
|
43
|
+
* the memory and local backends resolve synchronously.
|
|
44
|
+
*/
|
|
45
|
+
export interface Backend {
|
|
46
|
+
/** Stable atom for the pair; created (and seeded) on first access, cached thereafter. */
|
|
47
|
+
atom: <T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string) => Atom.Writable<T>;
|
|
48
|
+
/** Persist a value after the atom is updated. No-op for in-memory backends. */
|
|
49
|
+
persist?: <T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string, value: T) => void;
|
|
50
|
+
/** Context ids that currently hold a value for the aspect. */
|
|
51
|
+
contexts: <T, Encoded>(aspect: Aspect<T, Encoded>) => string[];
|
|
52
|
+
/** Release listeners/timers (used by tests; app-lifetime managers do not call this). */
|
|
53
|
+
dispose?: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ManagerOptions {
|
|
57
|
+
readonly registry: Registry.Registry;
|
|
58
|
+
readonly backends: Record<BackendName, Backend>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Routes per-context UI state to the backend declared by each aspect. Reads/writes go through the
|
|
63
|
+
* effect-atom registry so React hooks and graph atoms observe changes uniformly.
|
|
64
|
+
*/
|
|
65
|
+
export class Manager {
|
|
66
|
+
readonly #registry: Registry.Registry;
|
|
67
|
+
readonly #backends: Record<BackendName, Backend>;
|
|
68
|
+
|
|
69
|
+
constructor({ registry, backends }: ManagerOptions) {
|
|
70
|
+
this.#registry = registry;
|
|
71
|
+
this.#backends = backends;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reactive atom for `(aspect, contextId)`; pass to `registry.get` inside derived atoms/hooks. */
|
|
75
|
+
atom<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string): Atom.Writable<T> {
|
|
76
|
+
return this.#backends[aspect.backend].atom(aspect, contextId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string): T {
|
|
80
|
+
return this.#registry.get(this.atom(aspect, contextId));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
set<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string, value: T): void {
|
|
84
|
+
const backend = this.#backends[aspect.backend];
|
|
85
|
+
this.#registry.set(backend.atom(aspect, contextId), value);
|
|
86
|
+
backend.persist?.(aspect, contextId, value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
update<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string, fn: (prev: T) => T): void {
|
|
90
|
+
this.set(aspect, contextId, fn(this.get(aspect, contextId)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
subscribe<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string, cb: (value: T) => void): () => void {
|
|
94
|
+
const atom = this.atom(aspect, contextId);
|
|
95
|
+
return this.#registry.subscribe(atom, () => cb(this.#registry.get(atom)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
contexts<T, Encoded>(aspect: Aspect<T, Encoded>): string[] {
|
|
99
|
+
return this.#backends[aspect.backend].contexts(aspect);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
//
|
|
2
|
-
// Copyright
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
// UI-free
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
export * from '
|
|
10
|
-
export * from '
|
|
5
|
+
// A UI-free entrypoint: the attention/selection/view state definitions with no React attached, so
|
|
6
|
+
// operation handlers and app-graph builders running under node or bun can use them without pulling
|
|
7
|
+
// the components.
|
|
8
|
+
|
|
9
|
+
export * as Attention from './Attention';
|
|
10
|
+
export * as Selection from './Selection';
|
|
11
|
+
export * as ViewState from './ViewState';
|