@dxos/react-ui-attention 0.8.4-main.ef1bc66f44 → 0.8.4-main.f466a3d56e
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/LICENSE +102 -5
- package/dist/lib/browser/chunk-D2BLVJ6Z.mjs +421 -0
- package/dist/lib/browser/chunk-D2BLVJ6Z.mjs.map +7 -0
- package/dist/lib/browser/chunk-I4VO2RQM.mjs +255 -0
- package/dist/lib/browser/chunk-I4VO2RQM.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +24 -25
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +4 -3
- package/dist/lib/browser/testing/index.mjs.map +2 -2
- package/dist/lib/browser/types/index.mjs +29 -0
- package/dist/lib/node-esm/chunk-6INLBC62.mjs +423 -0
- package/dist/lib/node-esm/chunk-6INLBC62.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-ZTUVSH4P.mjs +256 -0
- package/dist/lib/node-esm/chunk-ZTUVSH4P.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +24 -25
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/testing/index.mjs +4 -3
- package/dist/lib/node-esm/testing/index.mjs.map +2 -2
- package/dist/lib/node-esm/types/index.mjs +30 -0
- package/dist/types/src/attention.d.ts +46 -11
- package/dist/types/src/attention.d.ts.map +1 -1
- package/dist/types/src/components/AttentionGlyph/AttentionGlyph.d.ts.map +1 -0
- package/dist/types/src/components/AttentionGlyph/AttentionGlyph.stories.d.ts.map +1 -0
- package/dist/types/src/components/AttentionGlyph/index.d.ts +2 -0
- package/dist/types/src/components/AttentionGlyph/index.d.ts.map +1 -0
- package/dist/types/src/components/AttentionProvider/AttentionProvider.d.ts +34 -0
- package/dist/types/src/components/AttentionProvider/AttentionProvider.d.ts.map +1 -0
- package/dist/types/src/components/AttentionProvider/AttentionProvider.stories.d.ts.map +1 -0
- package/dist/types/src/components/AttentionProvider/index.d.ts +2 -0
- package/dist/types/src/components/AttentionProvider/index.d.ts.map +1 -0
- package/dist/types/src/components/{SelectionProvider.d.ts → SelectionProvider/SelectionProvider.d.ts} +3 -3
- package/dist/types/src/components/SelectionProvider/SelectionProvider.d.ts.map +1 -0
- package/dist/types/src/components/SelectionProvider/index.d.ts +2 -0
- package/dist/types/src/components/SelectionProvider/index.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/selection.d.ts.map +1 -1
- package/dist/types/src/testing/decorators/withAttention.d.ts.map +1 -1
- package/dist/types/src/types/index.d.ts +3 -0
- package/dist/types/src/types/index.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +22 -32
- package/src/attention.test.ts +179 -23
- package/src/attention.ts +136 -54
- package/src/components/{AttentionGlyph.stories.tsx → AttentionGlyph/AttentionGlyph.stories.tsx} +2 -2
- package/src/components/{AttentionGlyph.tsx → AttentionGlyph/AttentionGlyph.tsx} +12 -5
- package/src/components/AttentionGlyph/index.ts +5 -0
- package/src/components/{AttentionProvider.stories.tsx → AttentionProvider/AttentionProvider.stories.tsx} +2 -3
- package/src/components/AttentionProvider/AttentionProvider.tsx +176 -0
- package/src/components/AttentionProvider/index.ts +5 -0
- package/src/components/{SelectionProvider.tsx → SelectionProvider/SelectionProvider.tsx} +15 -8
- package/src/components/SelectionProvider/index.ts +5 -0
- package/src/index.ts +0 -1
- package/src/selection.ts +10 -8
- package/src/testing/decorators/withAttention.ts +1 -0
- package/src/types/index.ts +9 -0
- package/dist/lib/browser/chunk-PAZBZOO2.mjs +0 -7
- package/dist/lib/browser/chunk-PAZBZOO2.mjs.map +0 -7
- package/dist/lib/browser/chunk-W5HPVX5Q.mjs +0 -705
- package/dist/lib/browser/chunk-W5HPVX5Q.mjs.map +0 -7
- package/dist/lib/browser/types.mjs +0 -7
- package/dist/lib/node-esm/chunk-NFJ45DDP.mjs +0 -706
- package/dist/lib/node-esm/chunk-NFJ45DDP.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-PMGNPL4M.mjs +0 -9
- package/dist/lib/node-esm/chunk-PMGNPL4M.mjs.map +0 -7
- package/dist/lib/node-esm/types.mjs +0 -8
- package/dist/types/src/components/AttentionGlyph.d.ts.map +0 -1
- package/dist/types/src/components/AttentionGlyph.stories.d.ts.map +0 -1
- package/dist/types/src/components/AttentionProvider.d.ts +0 -39
- package/dist/types/src/components/AttentionProvider.d.ts.map +0 -1
- package/dist/types/src/components/AttentionProvider.stories.d.ts.map +0 -1
- package/dist/types/src/components/SelectionProvider.d.ts.map +0 -1
- package/dist/types/src/types.d.ts +0 -10
- package/dist/types/src/types.d.ts.map +0 -1
- package/src/components/AttentionProvider.tsx +0 -208
- package/src/types.ts +0 -16
- /package/dist/lib/browser/{types.mjs.map → types/index.mjs.map} +0 -0
- /package/dist/lib/node-esm/{types.mjs.map → types/index.mjs.map} +0 -0
- /package/dist/types/src/components/{AttentionGlyph.d.ts → AttentionGlyph/AttentionGlyph.d.ts} +0 -0
- /package/dist/types/src/components/{AttentionGlyph.stories.d.ts → AttentionGlyph/AttentionGlyph.stories.d.ts} +0 -0
- /package/dist/types/src/components/{AttentionProvider.stories.d.ts → AttentionProvider/AttentionProvider.stories.d.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/react-ui-attention",
|
|
3
|
-
"version": "0.8.4-main.
|
|
3
|
+
"version": "0.8.4-main.f466a3d56e",
|
|
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",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/dxos/dxos"
|
|
10
10
|
},
|
|
11
|
-
"license": "
|
|
11
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
12
12
|
"author": "DXOS.org",
|
|
13
13
|
"sideEffects": false,
|
|
14
14
|
"type": "module",
|
|
@@ -19,30 +19,20 @@
|
|
|
19
19
|
"browser": "./dist/lib/browser/index.mjs",
|
|
20
20
|
"node": "./dist/lib/node-esm/index.mjs"
|
|
21
21
|
},
|
|
22
|
-
"./types": {
|
|
23
|
-
"source": "./src/types.ts",
|
|
24
|
-
"types": "./dist/types/src/types.d.ts",
|
|
25
|
-
"browser": "./dist/lib/browser/types.mjs",
|
|
26
|
-
"node": "./dist/lib/node-esm/types.mjs"
|
|
27
|
-
},
|
|
28
22
|
"./testing": {
|
|
29
23
|
"source": "./src/testing/index.ts",
|
|
30
24
|
"types": "./dist/types/src/testing/index.d.ts",
|
|
31
25
|
"browser": "./dist/lib/browser/testing/index.mjs",
|
|
32
26
|
"node": "./dist/lib/node-esm/testing/index.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./types": {
|
|
29
|
+
"source": "./src/types/index.ts",
|
|
30
|
+
"types": "./dist/types/src/types/index.d.ts",
|
|
31
|
+
"browser": "./dist/lib/browser/types/index.mjs",
|
|
32
|
+
"node": "./dist/lib/node-esm/types/index.mjs"
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"types": "dist/types/src/index.d.ts",
|
|
36
|
-
"typesVersions": {
|
|
37
|
-
"*": {
|
|
38
|
-
"types": [
|
|
39
|
-
"dist/types/src/types.d.ts"
|
|
40
|
-
],
|
|
41
|
-
"testing": [
|
|
42
|
-
"dist/types/src/testing/index.d.ts"
|
|
43
|
-
]
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
36
|
"files": [
|
|
47
37
|
"dist",
|
|
48
38
|
"src"
|
|
@@ -56,30 +46,30 @@
|
|
|
56
46
|
"@radix-ui/react-primitive": "2.0.2",
|
|
57
47
|
"@radix-ui/react-slot": "1.1.2",
|
|
58
48
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
|
59
|
-
"effect": "3.
|
|
60
|
-
"@dxos/echo": "0.8.4-main.
|
|
61
|
-
"@dxos/invariant": "0.8.4-main.
|
|
62
|
-
"@dxos/
|
|
63
|
-
"@dxos/
|
|
64
|
-
"@dxos/util": "0.8.4-main.
|
|
49
|
+
"effect": "3.20.0",
|
|
50
|
+
"@dxos/echo": "0.8.4-main.f466a3d56e",
|
|
51
|
+
"@dxos/invariant": "0.8.4-main.f466a3d56e",
|
|
52
|
+
"@dxos/react-hooks": "0.8.4-main.f466a3d56e",
|
|
53
|
+
"@dxos/log": "0.8.4-main.f466a3d56e",
|
|
54
|
+
"@dxos/util": "0.8.4-main.f466a3d56e"
|
|
65
55
|
},
|
|
66
56
|
"devDependencies": {
|
|
67
57
|
"@types/react": "~19.2.7",
|
|
68
58
|
"@types/react-dom": "~19.2.3",
|
|
69
59
|
"react": "~19.2.3",
|
|
70
60
|
"react-dom": "~19.2.3",
|
|
71
|
-
"vite": "
|
|
72
|
-
"@dxos/random": "0.8.4-main.
|
|
73
|
-
"@dxos/
|
|
74
|
-
"@dxos/
|
|
75
|
-
"@dxos/
|
|
61
|
+
"vite": "^8.0.13",
|
|
62
|
+
"@dxos/random": "0.8.4-main.f466a3d56e",
|
|
63
|
+
"@dxos/storybook-utils": "0.8.4-main.f466a3d56e",
|
|
64
|
+
"@dxos/ui-theme": "0.8.4-main.f466a3d56e",
|
|
65
|
+
"@dxos/react-ui": "0.8.4-main.f466a3d56e"
|
|
76
66
|
},
|
|
77
67
|
"peerDependencies": {
|
|
78
|
-
"effect": "3.
|
|
68
|
+
"effect": "3.20.0",
|
|
79
69
|
"react": "~19.2.3",
|
|
80
70
|
"react-dom": "~19.2.3",
|
|
81
|
-
"@dxos/react-ui": "0.8.4-main.
|
|
82
|
-
"@dxos/ui-theme": "0.8.4-main.
|
|
71
|
+
"@dxos/react-ui": "0.8.4-main.f466a3d56e",
|
|
72
|
+
"@dxos/ui-theme": "0.8.4-main.f466a3d56e"
|
|
83
73
|
},
|
|
84
74
|
"publishConfig": {
|
|
85
75
|
"access": "public"
|
package/src/attention.test.ts
CHANGED
|
@@ -3,50 +3,206 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { Registry } from '@effect-atom/atom-react';
|
|
6
|
-
import { describe,
|
|
6
|
+
import { describe, test } from 'vitest';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
AttentionManager,
|
|
10
|
+
expandAttendableId,
|
|
11
|
+
getLinkedVariant,
|
|
12
|
+
getParentId,
|
|
13
|
+
getSegmentId,
|
|
14
|
+
isLinkedSegment,
|
|
15
|
+
linkedSegment,
|
|
16
|
+
} from './attention';
|
|
9
17
|
|
|
10
18
|
describe('AttentionManager', () => {
|
|
11
|
-
test('takes an initial
|
|
19
|
+
test('takes an initial attended id', ({ expect }) => {
|
|
12
20
|
const registry = Registry.make();
|
|
13
|
-
const attention = new AttentionManager(registry, ['a
|
|
14
|
-
|
|
15
|
-
expect(attention.
|
|
16
|
-
expect(attention.get(
|
|
17
|
-
expect(attention.get(
|
|
18
|
-
expect(attention.get(
|
|
19
|
-
expect(attention.get(
|
|
20
|
-
expect(attention.get(['b'])).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: false });
|
|
21
|
+
const attention = new AttentionManager(registry, ['root/a/b']);
|
|
22
|
+
|
|
23
|
+
expect(attention.getCurrent()).to.deep.equal(['root/a/b']);
|
|
24
|
+
expect(attention.get('root')).to.deep.equal({ hasAttention: false, isAncestor: true, isRelated: false });
|
|
25
|
+
expect(attention.get('root/a')).to.deep.equal({ hasAttention: false, isAncestor: true, isRelated: false });
|
|
26
|
+
expect(attention.get('root/a/b')).to.deep.equal({ hasAttention: true, isAncestor: false, isRelated: false });
|
|
27
|
+
expect(attention.get('unrelated')).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: false });
|
|
21
28
|
});
|
|
22
29
|
|
|
23
|
-
test('current returns last updated
|
|
30
|
+
test('current returns last updated ids', ({ expect }) => {
|
|
24
31
|
const registry = Registry.make();
|
|
25
32
|
const attention = new AttentionManager(registry);
|
|
26
33
|
expect(attention.getCurrent()).to.deep.equal([]);
|
|
27
34
|
|
|
28
|
-
attention.update(['a
|
|
29
|
-
expect(attention.getCurrent()).to.deep.equal(['a
|
|
35
|
+
attention.update(['root/a/b/c']);
|
|
36
|
+
expect(attention.getCurrent()).to.deep.equal(['root/a/b/c']);
|
|
30
37
|
});
|
|
31
38
|
|
|
32
|
-
test('keys returns all
|
|
39
|
+
test('keys returns all tracked qualified ids', ({ expect }) => {
|
|
33
40
|
const registry = Registry.make();
|
|
34
41
|
const attention = new AttentionManager(registry);
|
|
35
42
|
expect(attention.keys()).to.deep.equal([]);
|
|
36
43
|
|
|
37
|
-
attention.update(['a']);
|
|
38
|
-
expect(attention.keys()).to.
|
|
44
|
+
attention.update(['root/a']);
|
|
45
|
+
expect(attention.keys()).to.include('root');
|
|
46
|
+
expect(attention.keys()).to.include('root/a');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('get returns attention state for a given qualified id', ({ expect }) => {
|
|
50
|
+
const registry = Registry.make();
|
|
51
|
+
const attention = new AttentionManager(registry);
|
|
52
|
+
expect(attention.get('root/a')).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: false });
|
|
53
|
+
|
|
54
|
+
attention.update(['root/a']);
|
|
55
|
+
expect(attention.get('root/a')).to.deep.equal({ hasAttention: true, isAncestor: false, isRelated: false });
|
|
56
|
+
expect(attention.get('root')).to.deep.equal({ hasAttention: false, isAncestor: true, isRelated: false });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('clears previous attention on update', ({ expect }) => {
|
|
60
|
+
const registry = Registry.make();
|
|
61
|
+
const attention = new AttentionManager(registry);
|
|
62
|
+
|
|
63
|
+
attention.update(['root/a/b']);
|
|
64
|
+
expect(attention.get('root/a/b')).to.deep.equal({ hasAttention: true, isAncestor: false, isRelated: false });
|
|
65
|
+
expect(attention.get('root/a')).to.deep.equal({ hasAttention: false, isAncestor: true, isRelated: false });
|
|
66
|
+
|
|
67
|
+
attention.update(['root/x/y']);
|
|
68
|
+
expect(attention.get('root/a/b')).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: false });
|
|
69
|
+
expect(attention.get('root/a')).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: false });
|
|
70
|
+
expect(attention.get('root/x/y')).to.deep.equal({ hasAttention: true, isAncestor: false, isRelated: false });
|
|
71
|
+
expect(attention.get('root/x')).to.deep.equal({ hasAttention: false, isAncestor: true, isRelated: false });
|
|
72
|
+
expect(attention.get('root')).to.deep.equal({ hasAttention: false, isAncestor: true, isRelated: false });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('linked segment marks parent with isRelated and isAncestor', ({ expect }) => {
|
|
76
|
+
const registry = Registry.make();
|
|
77
|
+
const attention = new AttentionManager(registry);
|
|
39
78
|
|
|
40
|
-
attention.update(['
|
|
41
|
-
expect(attention.
|
|
79
|
+
attention.update(['root/space/obj/~settings']);
|
|
80
|
+
expect(attention.get('root/space/obj/~settings')).to.deep.equal({
|
|
81
|
+
hasAttention: true,
|
|
82
|
+
isAncestor: false,
|
|
83
|
+
isRelated: false,
|
|
84
|
+
});
|
|
85
|
+
expect(attention.get('root/space/obj')).to.deep.equal({
|
|
86
|
+
hasAttention: false,
|
|
87
|
+
isAncestor: true,
|
|
88
|
+
isRelated: true,
|
|
89
|
+
});
|
|
90
|
+
expect(attention.get('root/space')).to.deep.equal({
|
|
91
|
+
hasAttention: false,
|
|
92
|
+
isAncestor: true,
|
|
93
|
+
isRelated: false,
|
|
94
|
+
});
|
|
95
|
+
expect(attention.get('root')).to.deep.equal({
|
|
96
|
+
hasAttention: false,
|
|
97
|
+
isAncestor: true,
|
|
98
|
+
isRelated: false,
|
|
99
|
+
});
|
|
42
100
|
});
|
|
43
101
|
|
|
44
|
-
test('
|
|
102
|
+
test('switching from linked segment to non-linked clears parent isRelated', ({ expect }) => {
|
|
45
103
|
const registry = Registry.make();
|
|
46
104
|
const attention = new AttentionManager(registry);
|
|
47
|
-
expect(attention.get(['a'])).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: false });
|
|
48
105
|
|
|
49
|
-
attention.update(['
|
|
50
|
-
expect(attention.get(
|
|
106
|
+
attention.update(['root/space/obj/~settings']);
|
|
107
|
+
expect(attention.get('root/space/obj')).to.deep.equal({
|
|
108
|
+
hasAttention: false,
|
|
109
|
+
isAncestor: true,
|
|
110
|
+
isRelated: true,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
attention.update(['root/space/obj']);
|
|
114
|
+
expect(attention.get('root/space/obj')).to.deep.equal({
|
|
115
|
+
hasAttention: true,
|
|
116
|
+
isAncestor: false,
|
|
117
|
+
isRelated: false,
|
|
118
|
+
});
|
|
119
|
+
expect(attention.get('root/space/obj/~settings')).to.deep.equal({
|
|
120
|
+
hasAttention: false,
|
|
121
|
+
isAncestor: false,
|
|
122
|
+
isRelated: false,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('keys sharing the same segment id are marked isRelated', ({ expect }) => {
|
|
127
|
+
const registry = Registry.make();
|
|
128
|
+
const attention = new AttentionManager(registry);
|
|
129
|
+
|
|
130
|
+
// Pre-register the alternate path so the manager knows about it.
|
|
131
|
+
attention.get('root/z/y/c');
|
|
132
|
+
|
|
133
|
+
attention.update(['root/a/b/c']);
|
|
134
|
+
expect(attention.get('root/a/b/c')).to.deep.equal({ hasAttention: true, isAncestor: false, isRelated: false });
|
|
135
|
+
expect(attention.get('root/z/y/c')).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('related keys are cleared when attention moves to a different segment id', ({ expect }) => {
|
|
139
|
+
const registry = Registry.make();
|
|
140
|
+
const attention = new AttentionManager(registry);
|
|
141
|
+
|
|
142
|
+
attention.get('root/z/y/c');
|
|
143
|
+
attention.update(['root/a/b/c']);
|
|
144
|
+
expect(attention.get('root/z/y/c')).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: true });
|
|
145
|
+
|
|
146
|
+
attention.update(['root/x/y/d']);
|
|
147
|
+
expect(attention.get('root/z/y/c')).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: false });
|
|
148
|
+
expect(attention.get('root/a/b/c')).to.deep.equal({ hasAttention: false, isAncestor: false, isRelated: false });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('expandAttendableId', () => {
|
|
153
|
+
test('produces progressive prefixes', ({ expect }) => {
|
|
154
|
+
expect(expandAttendableId('root/space/types/doc')).to.deep.equal([
|
|
155
|
+
'root',
|
|
156
|
+
'root/space',
|
|
157
|
+
'root/space/types',
|
|
158
|
+
'root/space/types/doc',
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('single segment returns itself', ({ expect }) => {
|
|
163
|
+
expect(expandAttendableId('root')).to.deep.equal(['root']);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('isLinkedSegment', () => {
|
|
168
|
+
test('detects linked prefix on last segment', ({ expect }) => {
|
|
169
|
+
expect(isLinkedSegment('root/space/obj/~settings')).toBe(true);
|
|
170
|
+
expect(isLinkedSegment('root/space/obj')).toBe(false);
|
|
171
|
+
expect(isLinkedSegment('~standalone')).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('linkedSegment', () => {
|
|
176
|
+
test('builds a linked segment', ({ expect }) => {
|
|
177
|
+
expect(linkedSegment('settings')).toBe('~settings');
|
|
178
|
+
expect(linkedSegment('comments')).toBe('~comments');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('getLinkedVariant', () => {
|
|
183
|
+
test('extracts variant from linked last segment', ({ expect }) => {
|
|
184
|
+
expect(getLinkedVariant('root/space/obj/~settings')).toBe('settings');
|
|
185
|
+
expect(getLinkedVariant('root/a/~comments')).toBe('comments');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('returns segment as-is when not linked', ({ expect }) => {
|
|
189
|
+
expect(getLinkedVariant('root/space/obj')).toBe('obj');
|
|
190
|
+
expect(getLinkedVariant('root')).toBe('root');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('getParentId', () => {
|
|
195
|
+
test('returns parent qualified id', ({ expect }) => {
|
|
196
|
+
expect(getParentId('root/space/obj/~settings')).toBe('root/space/obj');
|
|
197
|
+
expect(getParentId('root/space')).toBe('root');
|
|
198
|
+
expect(getParentId('root')).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('getSegmentId', () => {
|
|
203
|
+
test('returns the last path segment', ({ expect }) => {
|
|
204
|
+
expect(getSegmentId('root/space/obj')).toBe('obj');
|
|
205
|
+
expect(getSegmentId('root')).toBe('root');
|
|
206
|
+
expect(getSegmentId('root/a/~settings')).toBe('~settings');
|
|
51
207
|
});
|
|
52
208
|
});
|
package/src/attention.ts
CHANGED
|
@@ -4,48 +4,46 @@
|
|
|
4
4
|
|
|
5
5
|
import { Atom, type Registry } from '@effect-atom/atom-react';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const stringKey = (key: string[]) => key.join(',');
|
|
7
|
+
export type Attention = {
|
|
8
|
+
hasAttention: boolean;
|
|
9
|
+
isAncestor: boolean;
|
|
10
|
+
isRelated: boolean;
|
|
11
|
+
};
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* Manages attention state for an application.
|
|
15
|
+
* Attention keys are slash-qualified graph IDs; ancestry is derived from progressive prefixes.
|
|
16
16
|
*/
|
|
17
17
|
export class AttentionManager {
|
|
18
|
-
|
|
19
|
-
// The lookup is not a reactive object to ensure that attention for each path is subscribable independently.
|
|
20
|
-
private readonly _map = new ComplexMap<string[], Atom.Writable<Attention>>(stringKey);
|
|
18
|
+
private readonly _map = new Map<string, Atom.Writable<Attention>>();
|
|
21
19
|
private readonly _currentAtom: Atom.Writable<string[]>;
|
|
22
20
|
|
|
23
21
|
constructor(
|
|
24
22
|
private readonly _registry: Registry.Registry,
|
|
25
23
|
initial: string[] = [],
|
|
26
24
|
) {
|
|
27
|
-
this._currentAtom = Atom.make<string[]>([]);
|
|
25
|
+
this._currentAtom = Atom.make<string[]>([]).pipe(Atom.keepAlive);
|
|
28
26
|
if (initial.length > 0) {
|
|
29
27
|
this.update(initial);
|
|
30
28
|
}
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
/**
|
|
34
|
-
* Atom for the currently attended element
|
|
32
|
+
* Atom for the currently attended element IDs.
|
|
35
33
|
*/
|
|
36
34
|
get current(): Atom.Atom<string[]> {
|
|
37
35
|
return this._currentAtom;
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
/**
|
|
41
|
-
* Gets the currently attended element
|
|
39
|
+
* Gets the currently attended element IDs.
|
|
42
40
|
*/
|
|
43
41
|
getCurrent(): readonly string[] {
|
|
44
42
|
return this._registry.get(this._currentAtom);
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
/**
|
|
48
|
-
* Subscribe to changes in the current attention
|
|
46
|
+
* Subscribe to changes in the current attention IDs.
|
|
49
47
|
*/
|
|
50
48
|
subscribeCurrent(cb: (current: readonly string[]) => void): () => void {
|
|
51
49
|
this._registry.get(this._currentAtom);
|
|
@@ -55,79 +53,104 @@ export class AttentionManager {
|
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
/**
|
|
58
|
-
* All
|
|
56
|
+
* All tracked qualified IDs.
|
|
59
57
|
*/
|
|
60
|
-
keys(): string[]
|
|
58
|
+
keys(): string[] {
|
|
61
59
|
return Array.from(this._map.keys());
|
|
62
60
|
}
|
|
63
61
|
|
|
64
62
|
/**
|
|
65
|
-
* Get the attention state for a given
|
|
63
|
+
* Get the attention state for a given qualified ID.
|
|
66
64
|
*/
|
|
67
|
-
get(
|
|
68
|
-
const atom = this._getAtom(
|
|
65
|
+
get(id: string): Attention {
|
|
66
|
+
const atom = this._getAtom(id);
|
|
69
67
|
return this._registry.get(atom);
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
/**
|
|
73
|
-
* Subscribe to changes in the attention state for a given
|
|
71
|
+
* Subscribe to changes in the attention state for a given qualified ID.
|
|
74
72
|
*/
|
|
75
|
-
subscribe(
|
|
76
|
-
const atom = this._getAtom(
|
|
73
|
+
subscribe(id: string, cb: (attention: Attention) => void): () => void {
|
|
74
|
+
const atom = this._getAtom(id);
|
|
77
75
|
this._registry.get(atom);
|
|
78
76
|
return this._registry.subscribe(atom, () => {
|
|
79
77
|
cb(this._registry.get(atom));
|
|
80
78
|
});
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
private _getAtom(
|
|
84
|
-
const existing = this._map.get(
|
|
81
|
+
private _getAtom(id: string): Atom.Writable<Attention> {
|
|
82
|
+
const existing = this._map.get(id);
|
|
85
83
|
if (existing) {
|
|
86
84
|
return existing;
|
|
87
85
|
}
|
|
88
86
|
|
|
89
|
-
const newAtom = Atom.make<Attention>({ hasAttention: false, isAncestor: false, isRelated: false })
|
|
90
|
-
|
|
87
|
+
const newAtom = Atom.make<Attention>({ hasAttention: false, isAncestor: false, isRelated: false }).pipe(
|
|
88
|
+
Atom.keepAlive,
|
|
89
|
+
);
|
|
90
|
+
this._map.set(id, newAtom);
|
|
91
91
|
return newAtom;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
95
|
* Update the currently attended element.
|
|
96
|
+
* Takes the array of qualified IDs collected from the DOM; the first element is the primary attended item.
|
|
97
|
+
* Ancestry is derived from the progressive prefixes of the primary ID.
|
|
98
|
+
* Relatedness is derived from the segment ID: any tracked key whose last `/` segment matches the
|
|
99
|
+
* attended ID's segment ID is marked `isRelated`. Additionally, if the primary ID is a linked segment
|
|
100
|
+
* (starts with `~`), its immediate parent gets `isRelated` alongside `isAncestor`.
|
|
96
101
|
*
|
|
97
102
|
* @internal
|
|
98
103
|
*/
|
|
99
|
-
update(
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
update(nextIds: string[]): void {
|
|
105
|
+
const primaryId = nextIds[0];
|
|
106
|
+
if (!primaryId) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
const currentIds = this.getCurrent();
|
|
111
|
+
const prevPrimaryId = currentIds[0];
|
|
112
|
+
// Clear previous attention state: ancestors, primary, and related keys.
|
|
113
|
+
if (prevPrimaryId) {
|
|
114
|
+
const prevPrefixes = expandAttendableId(prevPrimaryId);
|
|
115
|
+
for (const prefix of prevPrefixes) {
|
|
116
|
+
this._set(prefix, {});
|
|
117
|
+
}
|
|
118
|
+
const prevSegmentId = getSegmentId(prevPrimaryId);
|
|
119
|
+
for (const key of this.keys()) {
|
|
120
|
+
if (getSegmentId(key) === prevSegmentId) {
|
|
121
|
+
this._set(key, {});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
106
125
|
|
|
107
|
-
|
|
108
|
-
const nextRelatedKeys = this.keys().filter(
|
|
109
|
-
(key) => key[0] === nextRelatedTo && stringKey(key) !== stringKey(nextKey),
|
|
110
|
-
);
|
|
111
|
-
const prevRelatedKeys = currentRelatedKeys.filter(
|
|
112
|
-
(curr) => !nextRelatedKeys.find((next) => stringKey(curr) === stringKey(next)),
|
|
113
|
-
);
|
|
126
|
+
this._registry.set(this._currentAtom, nextIds);
|
|
114
127
|
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
);
|
|
128
|
+
// Set ancestors and primary.
|
|
129
|
+
const prefixes = expandAttendableId(primaryId);
|
|
130
|
+
const prefixSet = new Set(prefixes);
|
|
131
|
+
const linkedParent = isLinkedSegment(primaryId) ? getParentId(primaryId) : undefined;
|
|
120
132
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
for (const prefix of prefixes) {
|
|
134
|
+
if (prefix === primaryId) {
|
|
135
|
+
this._set(prefix, { hasAttention: true });
|
|
136
|
+
} else if (prefix === linkedParent) {
|
|
137
|
+
this._set(prefix, { isAncestor: true, isRelated: true });
|
|
138
|
+
} else {
|
|
139
|
+
this._set(prefix, { isAncestor: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Set related keys: any tracked key sharing the same segment ID.
|
|
144
|
+
const segmentId = getSegmentId(primaryId);
|
|
145
|
+
for (const key of this.keys()) {
|
|
146
|
+
if (!prefixSet.has(key) && getSegmentId(key) === segmentId) {
|
|
147
|
+
this._set(key, { isRelated: true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
127
150
|
}
|
|
128
151
|
|
|
129
|
-
private _set(
|
|
130
|
-
const atom = this._getAtom(
|
|
152
|
+
private _set(id: string, attention: Partial<Attention>): void {
|
|
153
|
+
const atom = this._getAtom(id);
|
|
131
154
|
this._registry.set(atom, {
|
|
132
155
|
hasAttention: attention.hasAttention ?? false,
|
|
133
156
|
isAncestor: attention.isAncestor ?? false,
|
|
@@ -137,7 +160,8 @@ export class AttentionManager {
|
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
/**
|
|
140
|
-
* Accumulates all attendable
|
|
163
|
+
* Accumulates all attendable IDs between the element provided and the root, inclusive.
|
|
164
|
+
* Each `data-attendable-id` value is treated as a single qualified ID (no splitting).
|
|
141
165
|
*/
|
|
142
166
|
export const getAttendables = (selector: string, cursor: Element, acc: string[] = []): string[] => {
|
|
143
167
|
// Find the closest element with `data-attendable-id`, if any; start from cursor and move up the DOM tree.
|
|
@@ -153,8 +177,7 @@ export const getAttendables = (selector: string, cursor: Element, acc: string[]
|
|
|
153
177
|
return getAttendables(selector, trigger, acc);
|
|
154
178
|
}
|
|
155
179
|
} else {
|
|
156
|
-
acc.push(
|
|
157
|
-
// TODO: Attempt tail recursion.
|
|
180
|
+
acc.push(attendableId);
|
|
158
181
|
return !closestAttendable.parentElement ? acc : getAttendables(selector, closestAttendable.parentElement, acc);
|
|
159
182
|
}
|
|
160
183
|
}
|
|
@@ -165,3 +188,62 @@ export const getAttendables = (selector: string, cursor: Element, acc: string[]
|
|
|
165
188
|
export type AttendableId = { attendableId?: string };
|
|
166
189
|
|
|
167
190
|
export type Related = { related?: boolean };
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Prefix for linked segments. A linked segment's attention state is shared with its parent:
|
|
194
|
+
* when a linked node has attention, the parent is marked as both ancestor and related.
|
|
195
|
+
*/
|
|
196
|
+
const LINKED_PREFIX = '~';
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Decompose a qualified graph ID into progressive prefixes for ancestry tracking.
|
|
200
|
+
* e.g. `root/a/b/c` yields `['root', 'root/a', 'root/a/b', 'root/a/b/c']`.
|
|
201
|
+
*/
|
|
202
|
+
export const expandAttendableId = (qualifiedId: string): string[] => {
|
|
203
|
+
const segments = qualifiedId.split('/');
|
|
204
|
+
return segments.map((_, index) => segments.slice(0, index + 1).join('/'));
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether the last segment of a qualified ID is a linked segment (starts with `~`).
|
|
209
|
+
* Linked segments share their attention state with the parent node.
|
|
210
|
+
*/
|
|
211
|
+
export const isLinkedSegment = (qualifiedId: string): boolean => {
|
|
212
|
+
const lastSegment = qualifiedId.split('/').pop() ?? '';
|
|
213
|
+
return lastSegment.startsWith(LINKED_PREFIX);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build a linked segment ID from a variant name (e.g., `'settings'` -> `'~settings'`).
|
|
218
|
+
* Nodes identified by linked segments share their attention state with the parent node.
|
|
219
|
+
*/
|
|
220
|
+
export const linkedSegment = (variant: string): string => `${LINKED_PREFIX}${variant}`;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Extract the variant name from the last segment of a qualified ID, stripping the linked prefix.
|
|
224
|
+
* e.g. `'root/space/obj/~settings'` -> `'settings'`.
|
|
225
|
+
* If the segment is not linked (no `~` prefix), returns the segment as-is.
|
|
226
|
+
*/
|
|
227
|
+
export const getLinkedVariant = (qualifiedId: string): string => {
|
|
228
|
+
const lastSegment = qualifiedId.split('/').pop() ?? '';
|
|
229
|
+
return lastSegment.startsWith(LINKED_PREFIX) ? lastSegment.slice(LINKED_PREFIX.length) : lastSegment;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get the parent qualified ID (everything before the last `/` segment).
|
|
234
|
+
*/
|
|
235
|
+
export const getParentId = (qualifiedId: string | undefined): string | undefined => {
|
|
236
|
+
if (!qualifiedId) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const lastSlash = qualifiedId.lastIndexOf('/');
|
|
241
|
+
return lastSlash > 0 ? qualifiedId.slice(0, lastSlash) : undefined;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Extract the segment ID (last segment) of a qualified ID.
|
|
246
|
+
*/
|
|
247
|
+
export const getSegmentId = (qualifiedId: string): string => {
|
|
248
|
+
return qualifiedId.split('/').pop() ?? qualifiedId;
|
|
249
|
+
};
|
package/src/components/{AttentionGlyph.stories.tsx → AttentionGlyph/AttentionGlyph.stories.tsx}
RENAMED
|
@@ -13,7 +13,7 @@ import { AttentionGlyph, type AttentionGlyphProps } from './AttentionGlyph';
|
|
|
13
13
|
|
|
14
14
|
const DefaultStory = (props: AttentionGlyphProps) => {
|
|
15
15
|
return (
|
|
16
|
-
<ul className='flex gap-2
|
|
16
|
+
<ul className='flex gap-2 mb-2'>
|
|
17
17
|
<li>
|
|
18
18
|
<AttentionGlyph presence='none' {...props} />
|
|
19
19
|
</li>
|
|
@@ -76,7 +76,7 @@ export const Syncing: Story = {
|
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
return (
|
|
79
|
-
<div className='flex flex-col p-2
|
|
79
|
+
<div className='flex flex-col p-2 w-[200px]'>
|
|
80
80
|
<Button onClick={handleChangeAttended}>Change attended</Button>
|
|
81
81
|
{Array.from(spaces.entries()).map(([space, sync]) => (
|
|
82
82
|
<div key={space} className='flex items-center'>
|
|
@@ -8,11 +8,18 @@ import React, { type CSSProperties, type ComponentPropsWithRef, forwardRef, useM
|
|
|
8
8
|
import { Icon, type ThemedClassName } from '@dxos/react-ui';
|
|
9
9
|
import { mx } from '@dxos/ui-theme';
|
|
10
10
|
|
|
11
|
-
const attentionGlyphStyles =
|
|
12
|
-
'inline-block rounded-
|
|
11
|
+
const attentionGlyphStyles = mx(
|
|
12
|
+
'inline-block rounded-xs w-3 h-3 bg-transparent text-accent-text transition-colors',
|
|
13
|
+
'[[data-contains-attended=true]_&]:bg-attention-contains',
|
|
14
|
+
'[[data-attention=true]_&]:bg-accent-surface',
|
|
15
|
+
'[[data-attention=true]_&]:text-accent-foreground',
|
|
16
|
+
'[[aria-current][data-attention=true]_&]:bg-accent-surface',
|
|
17
|
+
'[[aria-current][data-attention=true]_&]:text-accent-foreground',
|
|
18
|
+
'[[aria-selected="true"][data-attention=true]_&]:bg-accent-surface',
|
|
19
|
+
'[[aria-selected="true"][data-attention=true]_&]:text-accent-foreground',
|
|
20
|
+
);
|
|
13
21
|
|
|
14
|
-
const presenceIconStyles =
|
|
15
|
-
'is-3 bs-3 group-[[aria-current]_&:hover]/attentionGlyphButton:hidden group-[[aria-current]_&:focus]/attentionGlyphButton:hidden group-[[data-attention=true]_&:hover]/attentionGlyphButton:hidden group-[[data-attention=true]_&:focus]/attentionGlyphButton:hidden group-[[aria-current][data-attention=true]_&:hover]/attentionGlyphButton:hidden group-[[aria-current][data-attention=true]_&:focus]/attentionGlyphButton:hidden';
|
|
22
|
+
const presenceIconStyles = mx('w-3 h-3');
|
|
16
23
|
|
|
17
24
|
const PresenceOne = () => {
|
|
18
25
|
return (
|
|
@@ -87,7 +94,7 @@ export const AttentionGlyph = forwardRef<HTMLSpanElement, AttentionGlyphProps>(
|
|
|
87
94
|
) : null;
|
|
88
95
|
|
|
89
96
|
return (
|
|
90
|
-
<div
|
|
97
|
+
<div className='flex group' data-attention={attended} data-contains-attended={containsAttended}>
|
|
91
98
|
<span role='none' {...props} className={mx(attentionGlyphStyles, classNames)} ref={forwardedRef}>
|
|
92
99
|
{icon}
|
|
93
100
|
</span>
|