@dxos/react-ui-attention 0.8.4-main.8360d9e660 → 0.8.4-main.8baae0fced

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 (79) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/{chunk-Q5SLNQ4B.mjs → chunk-D2BLVJ6Z.mjs} +24 -320
  3. package/dist/lib/browser/chunk-D2BLVJ6Z.mjs.map +7 -0
  4. package/dist/lib/browser/chunk-I4VO2RQM.mjs +255 -0
  5. package/dist/lib/browser/chunk-I4VO2RQM.mjs.map +7 -0
  6. package/dist/lib/browser/index.mjs +19 -24
  7. package/dist/lib/browser/index.mjs.map +4 -4
  8. package/dist/lib/browser/meta.json +1 -1
  9. package/dist/lib/browser/testing/index.mjs +4 -3
  10. package/dist/lib/browser/testing/index.mjs.map +2 -2
  11. package/dist/lib/browser/types/index.mjs +29 -0
  12. package/dist/lib/node-esm/{chunk-LSWTWT3W.mjs → chunk-6INLBC62.mjs} +24 -319
  13. package/dist/lib/node-esm/chunk-6INLBC62.mjs.map +7 -0
  14. package/dist/lib/node-esm/chunk-ZTUVSH4P.mjs +256 -0
  15. package/dist/lib/node-esm/chunk-ZTUVSH4P.mjs.map +7 -0
  16. package/dist/lib/node-esm/index.mjs +19 -24
  17. package/dist/lib/node-esm/index.mjs.map +4 -4
  18. package/dist/lib/node-esm/meta.json +1 -1
  19. package/dist/lib/node-esm/testing/index.mjs +4 -3
  20. package/dist/lib/node-esm/testing/index.mjs.map +2 -2
  21. package/dist/lib/node-esm/types/index.mjs +30 -0
  22. package/dist/types/src/attention.d.ts +19 -3
  23. package/dist/types/src/attention.d.ts.map +1 -1
  24. package/dist/types/src/components/AttentionGlyph/AttentionGlyph.d.ts.map +1 -0
  25. package/dist/types/src/components/AttentionGlyph/AttentionGlyph.stories.d.ts.map +1 -0
  26. package/dist/types/src/components/AttentionGlyph/index.d.ts +2 -0
  27. package/dist/types/src/components/AttentionGlyph/index.d.ts.map +1 -0
  28. package/dist/types/src/components/{AttentionProvider.d.ts → AttentionProvider/AttentionProvider.d.ts} +7 -10
  29. package/dist/types/src/components/AttentionProvider/AttentionProvider.d.ts.map +1 -0
  30. package/dist/types/src/components/AttentionProvider/AttentionProvider.stories.d.ts.map +1 -0
  31. package/dist/types/src/components/AttentionProvider/index.d.ts +2 -0
  32. package/dist/types/src/components/AttentionProvider/index.d.ts.map +1 -0
  33. package/dist/types/src/components/{SelectionProvider.d.ts → SelectionProvider/SelectionProvider.d.ts} +3 -3
  34. package/dist/types/src/components/SelectionProvider/SelectionProvider.d.ts.map +1 -0
  35. package/dist/types/src/components/SelectionProvider/index.d.ts +2 -0
  36. package/dist/types/src/components/SelectionProvider/index.d.ts.map +1 -0
  37. package/dist/types/src/index.d.ts +0 -1
  38. package/dist/types/src/index.d.ts.map +1 -1
  39. package/dist/types/src/selection.d.ts.map +1 -1
  40. package/dist/types/src/testing/decorators/withAttention.d.ts.map +1 -1
  41. package/dist/types/src/types/index.d.ts +3 -0
  42. package/dist/types/src/types/index.d.ts.map +1 -0
  43. package/dist/types/tsconfig.tsbuildinfo +1 -1
  44. package/package.json +22 -32
  45. package/src/attention.test.ts +37 -10
  46. package/src/attention.ts +35 -8
  47. package/src/components/{AttentionGlyph.tsx → AttentionGlyph/AttentionGlyph.tsx} +4 -4
  48. package/src/components/AttentionGlyph/index.ts +5 -0
  49. package/src/components/{AttentionProvider.stories.tsx → AttentionProvider/AttentionProvider.stories.tsx} +1 -2
  50. package/src/components/{AttentionProvider.tsx → AttentionProvider/AttentionProvider.tsx} +33 -38
  51. package/src/components/AttentionProvider/index.ts +5 -0
  52. package/src/components/{SelectionProvider.tsx → SelectionProvider/SelectionProvider.tsx} +15 -8
  53. package/src/components/SelectionProvider/index.ts +5 -0
  54. package/src/index.ts +0 -1
  55. package/src/selection.ts +10 -8
  56. package/src/testing/decorators/withAttention.ts +1 -0
  57. package/src/types/index.ts +9 -0
  58. package/dist/lib/browser/chunk-PAZBZOO2.mjs +0 -7
  59. package/dist/lib/browser/chunk-PAZBZOO2.mjs.map +0 -7
  60. package/dist/lib/browser/chunk-Q5SLNQ4B.mjs.map +0 -7
  61. package/dist/lib/browser/types.mjs +0 -7
  62. package/dist/lib/node-esm/chunk-LSWTWT3W.mjs.map +0 -7
  63. package/dist/lib/node-esm/chunk-PMGNPL4M.mjs +0 -9
  64. package/dist/lib/node-esm/chunk-PMGNPL4M.mjs.map +0 -7
  65. package/dist/lib/node-esm/types.mjs +0 -8
  66. package/dist/types/src/components/AttentionGlyph.d.ts.map +0 -1
  67. package/dist/types/src/components/AttentionGlyph.stories.d.ts.map +0 -1
  68. package/dist/types/src/components/AttentionProvider.d.ts.map +0 -1
  69. package/dist/types/src/components/AttentionProvider.stories.d.ts.map +0 -1
  70. package/dist/types/src/components/SelectionProvider.d.ts.map +0 -1
  71. package/dist/types/src/types.d.ts +0 -10
  72. package/dist/types/src/types.d.ts.map +0 -1
  73. package/src/types.ts +0 -16
  74. /package/dist/lib/browser/{types.mjs.map → types/index.mjs.map} +0 -0
  75. /package/dist/lib/node-esm/{types.mjs.map → types/index.mjs.map} +0 -0
  76. /package/dist/types/src/components/{AttentionGlyph.d.ts → AttentionGlyph/AttentionGlyph.d.ts} +0 -0
  77. /package/dist/types/src/components/{AttentionGlyph.stories.d.ts → AttentionGlyph/AttentionGlyph.stories.d.ts} +0 -0
  78. /package/dist/types/src/components/{AttentionProvider.stories.d.ts → AttentionProvider/AttentionProvider.stories.d.ts} +0 -0
  79. /package/src/components/{AttentionGlyph.stories.tsx → AttentionGlyph/AttentionGlyph.stories.tsx} +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.8360d9e660",
3
+ "version": "0.8.4-main.8baae0fced",
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": "MIT",
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.19.16",
60
- "@dxos/echo": "0.8.4-main.8360d9e660",
61
- "@dxos/invariant": "0.8.4-main.8360d9e660",
62
- "@dxos/log": "0.8.4-main.8360d9e660",
63
- "@dxos/react-hooks": "0.8.4-main.8360d9e660",
64
- "@dxos/util": "0.8.4-main.8360d9e660"
49
+ "effect": "3.20.0",
50
+ "@dxos/echo": "0.8.4-main.8baae0fced",
51
+ "@dxos/react-hooks": "0.8.4-main.8baae0fced",
52
+ "@dxos/util": "0.8.4-main.8baae0fced",
53
+ "@dxos/log": "0.8.4-main.8baae0fced",
54
+ "@dxos/invariant": "0.8.4-main.8baae0fced"
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": "^7.1.11",
72
- "@dxos/random": "0.8.4-main.8360d9e660",
73
- "@dxos/ui-theme": "0.8.4-main.8360d9e660",
74
- "@dxos/react-ui": "0.8.4-main.8360d9e660",
75
- "@dxos/storybook-utils": "0.8.4-main.8360d9e660"
61
+ "vite": "^8.0.13",
62
+ "@dxos/react-ui": "0.8.4-main.8baae0fced",
63
+ "@dxos/storybook-utils": "0.8.4-main.8baae0fced",
64
+ "@dxos/ui-theme": "0.8.4-main.8baae0fced",
65
+ "@dxos/random": "0.8.4-main.8baae0fced"
76
66
  },
77
67
  "peerDependencies": {
78
- "effect": "3.19.16",
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.8360d9e660",
82
- "@dxos/ui-theme": "0.8.4-main.8360d9e660"
71
+ "@dxos/react-ui": "0.8.4-main.8baae0fced",
72
+ "@dxos/ui-theme": "0.8.4-main.8baae0fced"
83
73
  },
84
74
  "publishConfig": {
85
75
  "access": "public"
@@ -3,9 +3,17 @@
3
3
  //
4
4
 
5
5
  import { Registry } from '@effect-atom/atom-react';
6
- import { describe, expect, test } from 'vitest';
7
-
8
- import { AttentionManager, expandAttendableId, getSegmentId, getParentId, isSeparatorPrefixed } from './attention';
6
+ import { describe, test } from 'vitest';
7
+
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
19
  test('takes an initial attended id', ({ expect }) => {
@@ -64,7 +72,7 @@ describe('AttentionManager', () => {
64
72
  expect(attention.get('root')).to.deep.equal({ hasAttention: false, isAncestor: true, isRelated: false });
65
73
  });
66
74
 
67
- test('separator-prefixed segment marks parent with isRelated and isAncestor', ({ expect }) => {
75
+ test('linked segment marks parent with isRelated and isAncestor', ({ expect }) => {
68
76
  const registry = Registry.make();
69
77
  const attention = new AttentionManager(registry);
70
78
 
@@ -91,7 +99,7 @@ describe('AttentionManager', () => {
91
99
  });
92
100
  });
93
101
 
94
- test('switching from separator-prefixed to non-prefixed clears parent isRelated', ({ expect }) => {
102
+ test('switching from linked segment to non-linked clears parent isRelated', ({ expect }) => {
95
103
  const registry = Registry.make();
96
104
  const attention = new AttentionManager(registry);
97
105
 
@@ -156,11 +164,30 @@ describe('expandAttendableId', () => {
156
164
  });
157
165
  });
158
166
 
159
- describe('isSeparatorPrefixed', () => {
160
- test('detects separator prefix on last segment', ({ expect }) => {
161
- expect(isSeparatorPrefixed('root/space/obj/~settings')).toBe(true);
162
- expect(isSeparatorPrefixed('root/space/obj')).toBe(false);
163
- expect(isSeparatorPrefixed('~standalone')).toBe(true);
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');
164
191
  });
165
192
  });
166
193
 
package/src/attention.ts CHANGED
@@ -4,7 +4,11 @@
4
4
 
5
5
  import { Atom, type Registry } from '@effect-atom/atom-react';
6
6
 
7
- import { ATTENDABLE_PATH_SEPARATOR, type Attention } from './types';
7
+ export type Attention = {
8
+ hasAttention: boolean;
9
+ isAncestor: boolean;
10
+ isRelated: boolean;
11
+ };
8
12
 
9
13
  /**
10
14
  * Manages attention state for an application.
@@ -92,8 +96,8 @@ export class AttentionManager {
92
96
  * Takes the array of qualified IDs collected from the DOM; the first element is the primary attended item.
93
97
  * Ancestry is derived from the progressive prefixes of the primary ID.
94
98
  * Relatedness is derived from the segment ID: any tracked key whose last `/` segment matches the
95
- * attended ID's segment ID is marked `isRelated`. Additionally, if the primary ID has a
96
- * separator-prefixed last segment (starts with `~`), its immediate parent gets `isRelated` alongside `isAncestor`.
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`.
97
101
  *
98
102
  * @internal
99
103
  */
@@ -124,12 +128,12 @@ export class AttentionManager {
124
128
  // Set ancestors and primary.
125
129
  const prefixes = expandAttendableId(primaryId);
126
130
  const prefixSet = new Set(prefixes);
127
- const separatedParent = isSeparatorPrefixed(primaryId) ? getParentId(primaryId) : undefined;
131
+ const linkedParent = isLinkedSegment(primaryId) ? getParentId(primaryId) : undefined;
128
132
 
129
133
  for (const prefix of prefixes) {
130
134
  if (prefix === primaryId) {
131
135
  this._set(prefix, { hasAttention: true });
132
- } else if (prefix === separatedParent) {
136
+ } else if (prefix === linkedParent) {
133
137
  this._set(prefix, { isAncestor: true, isRelated: true });
134
138
  } else {
135
139
  this._set(prefix, { isAncestor: true });
@@ -185,6 +189,12 @@ export type AttendableId = { attendableId?: string };
185
189
 
186
190
  export type Related = { related?: boolean };
187
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
+
188
198
  /**
189
199
  * Decompose a qualified graph ID into progressive prefixes for ancestry tracking.
190
200
  * e.g. `root/a/b/c` yields `['root', 'root/a', 'root/a/b', 'root/a/b/c']`.
@@ -195,11 +205,28 @@ export const expandAttendableId = (qualifiedId: string): string[] => {
195
205
  };
196
206
 
197
207
  /**
198
- * Whether the last segment of a qualified ID starts with the attendable path separator.
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.
199
226
  */
200
- export const isSeparatorPrefixed = (qualifiedId: string): boolean => {
227
+ export const getLinkedVariant = (qualifiedId: string): string => {
201
228
  const lastSegment = qualifiedId.split('/').pop() ?? '';
202
- return lastSegment.startsWith(ATTENDABLE_PATH_SEPARATOR);
229
+ return lastSegment.startsWith(LINKED_PREFIX) ? lastSegment.slice(LINKED_PREFIX.length) : lastSegment;
203
230
  };
204
231
 
205
232
  /**
@@ -12,11 +12,11 @@ const attentionGlyphStyles = mx(
12
12
  'inline-block rounded-xs w-3 h-3 bg-transparent text-accent-text transition-colors',
13
13
  '[[data-contains-attended=true]_&]:bg-attention-contains',
14
14
  '[[data-attention=true]_&]:bg-accent-surface',
15
- '[[data-attention=true]_&]:text-accent-surface-text',
15
+ '[[data-attention=true]_&]:text-accent-foreground',
16
16
  '[[aria-current][data-attention=true]_&]:bg-accent-surface',
17
- '[[aria-current][data-attention=true]_&]:text-accent-surface-text',
17
+ '[[aria-current][data-attention=true]_&]:text-accent-foreground',
18
18
  '[[aria-selected="true"][data-attention=true]_&]:bg-accent-surface',
19
- '[[aria-selected="true"][data-attention=true]_&]:text-accent-surface-text',
19
+ '[[aria-selected="true"][data-attention=true]_&]:text-accent-foreground',
20
20
  );
21
21
 
22
22
  const presenceIconStyles = mx('w-3 h-3');
@@ -94,7 +94,7 @@ export const AttentionGlyph = forwardRef<HTMLSpanElement, AttentionGlyphProps>(
94
94
  ) : null;
95
95
 
96
96
  return (
97
- <div role='none' className='flex group' data-attention={attended} data-contains-attended={containsAttended}>
97
+ <div className='flex group' data-attention={attended} data-contains-attended={containsAttended}>
98
98
  <span role='none' {...props} className={mx(attentionGlyphStyles, classNames)} ref={forwardedRef}>
99
99
  {icon}
100
100
  </span>
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './AttentionGlyph';
@@ -7,8 +7,7 @@ import React from 'react';
7
7
 
8
8
  import { withTheme } from '@dxos/react-ui/testing';
9
9
 
10
- import { withAttention } from '../testing';
11
-
10
+ import { withAttention } from '../../testing';
12
11
  import { useAttentionAttributes } from './AttentionProvider';
13
12
 
14
13
  const Attendable = ({ id }: { id: string }) => {
@@ -11,6 +11,7 @@ import React, {
11
11
  type FocusEvent,
12
12
  type PropsWithChildren,
13
13
  forwardRef,
14
+ useCallback,
14
15
  useContext,
15
16
  useEffect,
16
17
  useMemo,
@@ -22,8 +23,7 @@ import { useDefaultValue } from '@dxos/react-hooks';
22
23
  import { type ThemedClassName } from '@dxos/react-ui';
23
24
  import { mx } from '@dxos/ui-theme';
24
25
 
25
- import { AttentionManager, getAttendables } from '../attention';
26
- import { type Attention } from '../types';
26
+ import { type Attention, AttentionManager, getAttendables } from '../../attention';
27
27
 
28
28
  const ATTENTION_NAME = 'Attention';
29
29
  const ATTENDABLE_ATTRIBUTE = 'data-attendable-id';
@@ -42,10 +42,10 @@ const UNKNOWN_ATTENDABLE = { hasAttention: false, isAncestor: false, isRelated:
42
42
  /**
43
43
  * Subscribe to the attention state for a qualified graph ID.
44
44
  */
45
+ // TODO(burdon): Unify with seleciton and change to contextId?
45
46
  const useAttention = (attendableId?: string): Attention => {
46
47
  const { attention } = useAttentionContext(ATTENTION_NAME);
47
48
  const [state, setState] = useState<Attention>(UNKNOWN_ATTENDABLE);
48
-
49
49
  useEffect(() => {
50
50
  if (!attendableId || !attention) {
51
51
  setState(UNKNOWN_ATTENDABLE);
@@ -66,7 +66,6 @@ const useAttention = (attendableId?: string): Attention => {
66
66
  const useAttended = () => {
67
67
  const { attention } = useAttentionContext(ATTENTION_NAME);
68
68
  const [current, setCurrent] = useState<readonly string[]>([]);
69
-
70
69
  useEffect(() => {
71
70
  if (!attention) {
72
71
  return;
@@ -97,43 +96,42 @@ const useAttentionAttributes = (attendableId?: string) => {
97
96
  }, [attendableId, hasAttention]);
98
97
  };
99
98
 
100
- const RootAttentionProvider = ({
101
- children,
102
- attention: propsAttention,
103
- onChange,
104
- }: PropsWithChildren<
105
- Partial<{
106
- attention: AttentionManager;
107
- onChange: (nextAttended: string[]) => void;
108
- }>
109
- >) => {
99
+ type RootAttentionProviderProps = PropsWithChildren<{
100
+ attention?: AttentionManager;
101
+ onChange?: (nextAttended: string[]) => void;
102
+ }>;
103
+
104
+ const RootAttentionProvider = ({ children, attention: propsAttention, onChange }: RootAttentionProviderProps) => {
110
105
  const registry = useContext(RegistryContext);
111
106
  const attention = useDefaultValue(propsAttention, () => new AttentionManager(registry));
112
107
 
113
- const handleFocus = (event: FocusEvent) => {
114
- // NOTE(thure): Use the following to debug focus movement across the app:
115
- log('focus', { related: event.relatedTarget, target: event.target });
116
-
117
- const selector = [
118
- '[data-attendable-id]',
119
- ...Array.from(document.querySelectorAll('[aria-controls]')).map(
120
- (el) => `[id="${el.getAttribute('aria-controls')}"]`,
121
- ),
122
- ].join(',');
123
- const prev = attention.getCurrent();
124
- const next = getAttendables(selector, event.target);
125
- // TODO(wittjosiah): Not allowing empty state means that the attended item is not strictly guaranteed to be in the DOM.
126
- // Currently this depends on the deck in order to ensure that when the attended item is removed something else is attended.
127
- // Only update state if the result is different and not empty.
128
- if (next.length > 0 && (prev.length !== next.length || !!prev.find((id, index) => next[index] !== id))) {
129
- attention.update(next);
130
- onChange?.(next);
131
- }
132
- };
108
+ const handleFocus = useCallback(
109
+ (event: FocusEvent) => {
110
+ // NOTE(thure): Use the following to debug focus movement across the app:
111
+ log('focus', { related: event.relatedTarget, target: event.target });
112
+
113
+ const selector = [
114
+ '[data-attendable-id]',
115
+ ...Array.from(document.querySelectorAll('[aria-controls]')).map(
116
+ (el) => `[id="${el.getAttribute('aria-controls')}"]`,
117
+ ),
118
+ ].join(',');
119
+ const prev = attention.getCurrent();
120
+ const next = getAttendables(selector, event.target);
121
+ // TODO(wittjosiah): Not allowing empty state means that the attended item is not strictly guaranteed to be in the DOM.
122
+ // Currently this depends on the deck in order to ensure that when the attended item is removed something else is attended.
123
+ // Only update state if the result is different and not empty.
124
+ if (next.length > 0 && (prev.length !== next.length || !!prev.find((id, index) => next[index] !== id))) {
125
+ attention.update(next);
126
+ onChange?.(next);
127
+ }
128
+ },
129
+ [attention, onChange],
130
+ );
133
131
 
134
132
  return (
135
133
  <AttentionContextProvider attention={attention}>
136
- <div role='none' className='contents' onFocusCapture={handleFocus}>
134
+ <div className='contents' onFocusCapture={handleFocus}>
137
135
  {children}
138
136
  </div>
139
137
  </AttentionContextProvider>
@@ -175,7 +173,4 @@ export {
175
173
  useAttention,
176
174
  useAttended,
177
175
  useAttentionAttributes,
178
- ATTENTION_NAME,
179
- ATTENDABLE_ATTRIBUTE,
180
- ATTENTION_SOURCE_ATTRIBUTE,
181
176
  };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './AttentionProvider';
@@ -15,7 +15,7 @@ import {
15
15
  type SelectionMode,
16
16
  type SelectionResult,
17
17
  defaultSelection,
18
- } from '../selection';
18
+ } from '../../selection';
19
19
 
20
20
  const SELECTION_NAME = 'Selection';
21
21
 
@@ -23,6 +23,11 @@ type SelectionContextValue = {
23
23
  selection: SelectionManager;
24
24
  };
25
25
 
26
+ // Default value lets consumers like `useSelected` render outside a
27
+ // `SelectionProvider` (e.g. isolated stories) without throwing —
28
+ // `selection` reads as `undefined` and the hook falls back to the
29
+ // per-mode default result. Without this default, Radix `createContext`
30
+ // throws "Selection must be used within Selection" on every consumer.
26
31
  const [SelectionContextProvider, useSelectionContext] = createContext<SelectionContextValue>(SELECTION_NAME, {
27
32
  selection: undefined as unknown as SelectionManager,
28
33
  });
@@ -30,20 +35,20 @@ const [SelectionContextProvider, useSelectionContext] = createContext<SelectionC
30
35
  /**
31
36
  * Manages selection state across the app for multiple contexts.
32
37
  */
33
- // TODO(burdon): When is the selection removed?
34
38
  export const SelectionProvider = ({
35
39
  children,
36
- selection: propsSelection,
40
+ selection: selectionProp,
37
41
  }: PropsWithChildren<{ selection?: SelectionManager }>) => {
38
42
  const registry = useContext(RegistryContext);
39
- const selection = useDefaultValue(propsSelection, () => new SelectionManager(registry));
43
+ const selection = useDefaultValue(selectionProp, () => new SelectionManager(registry));
44
+
40
45
  return <SelectionContextProvider selection={selection}>{children}</SelectionContextProvider>;
41
46
  };
42
47
 
43
48
  /**
44
49
  * Get the selection contexts.
45
50
  */
46
- export const useSelectionManager = () => {
51
+ export const useSelectionManager = (): SelectionManager => {
47
52
  const { selection } = useSelectionContext(SELECTION_NAME);
48
53
  return selection;
49
54
  };
@@ -101,11 +106,13 @@ export type UseSelectionActions = {
101
106
  /**
102
107
  * Provides functions to manage the selection state for multiple contexts.
103
108
  */
104
- // TODO(burdon): Mode not used.
105
- export const useSelectionActions = (contextIds: string[], mode: SelectionMode = 'multi'): UseSelectionActions => {
106
- const stableContextIds = useMemo(() => contextIds, [JSON.stringify(contextIds)]); // TODO(burdon): Avoid stringify.
109
+ export const useSelectionActions = (contextIds: string[]): UseSelectionActions => {
110
+ const stableContextIds = useMemo(() => contextIds, [JSON.stringify(contextIds)]);
107
111
  const { selection } = useSelectionContext(SELECTION_NAME);
108
112
 
113
+ // No-op when rendered outside a `SelectionProvider` (e.g. an isolated
114
+ // story or a test harness) — `selection` is `undefined` from the
115
+ // context default value. Matches the pre-co-locate behaviour.
109
116
  const singleSelect = useCallback(
110
117
  (id: string) => {
111
118
  if (!selection) {
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './SelectionProvider';
package/src/index.ts CHANGED
@@ -5,4 +5,3 @@
5
5
  export * from './attention';
6
6
  export * from './components';
7
7
  export * from './selection';
8
- export * from './types';
package/src/selection.ts CHANGED
@@ -8,15 +8,17 @@ import * as Schema from 'effect/Schema';
8
8
 
9
9
  import { invariant } from '@dxos/invariant';
10
10
 
11
- // TODO(burdon): Reconcile with @dxos/graph.
12
-
13
11
  export type SelectionMode = 'single' | 'multi' | 'range' | 'multi-range';
14
12
 
15
13
  export const SelectionSchema = Schema.Union(
16
- Schema.Struct({ mode: Schema.Literal('single'), id: Schema.optional(Schema.String) }).pipe(Schema.mutable),
17
- Schema.Struct({ mode: Schema.Literal('multi'), ids: Schema.Array(Schema.String).pipe(Schema.mutable) }).pipe(
18
- Schema.mutable,
19
- ),
14
+ Schema.Struct({
15
+ mode: Schema.Literal('single'),
16
+ id: Schema.optional(Schema.String),
17
+ }).pipe(Schema.mutable),
18
+ Schema.Struct({
19
+ mode: Schema.Literal('multi'),
20
+ ids: Schema.Array(Schema.String).pipe(Schema.mutable),
21
+ }).pipe(Schema.mutable),
20
22
  Schema.Struct({
21
23
  mode: Schema.Literal('range'),
22
24
  from: Schema.optional(Schema.String),
@@ -49,7 +51,6 @@ export type SelectionResult<T extends SelectionMode> = T extends 'single'
49
51
  ? { from: string; to: string }[]
50
52
  : never;
51
53
 
52
- // TODO(burdon): Refactor.
53
54
  export const getSelectionSet = (selectionManager: SelectionManager, contextId?: string) => {
54
55
  const ids = new Set<string>(contextId ? [contextId] : []);
55
56
  for (const context of selectionManager.getSelectionContexts()) {
@@ -125,13 +126,14 @@ export class SelectionManager {
125
126
  this._registry.update(this._state, (state) => ({
126
127
  selections: { ...state.selections, [contextId]: newSelection },
127
128
  }));
129
+
128
130
  return newSelection;
129
131
  }
130
132
 
131
- // TODO(burdon): Disambiguate with getSelection?
132
133
  getSelected<T extends SelectionMode>(contextId: string, mode: T = 'multi' as T): SelectionResult<T> {
133
134
  const selection = this.getSelection(contextId, mode);
134
135
  invariant(selection?.mode === mode, 'Selection mode mismatch');
136
+
135
137
  // Cast required because TypeScript can't infer the relationship between T and the matched result.
136
138
  return Match.type<Selection>().pipe(
137
139
  Match.when({ mode: 'single' }, (s) => s.id),
@@ -20,6 +20,7 @@ export const withAttention = (initialAttendedId?: string): Decorator => {
20
20
  () => (initialAttendedId ? new AttentionManager(registry, [initialAttendedId]) : undefined),
21
21
  [registry],
22
22
  );
23
+
23
24
  return createElement(
24
25
  RegistryContext.Provider,
25
26
  { value: registry },
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ // UI-free re-exports of @dxos/react-ui-attention. Importing from here does not
6
+ // pull in React components, so it is safe to use from operations, schemas, and
7
+ // other non-DOM code that only needs the data model and pure helpers.
8
+ export * from '../attention';
9
+ export * from '../selection';
@@ -1,7 +0,0 @@
1
- // src/types.ts
2
- var ATTENDABLE_PATH_SEPARATOR = "~";
3
-
4
- export {
5
- ATTENDABLE_PATH_SEPARATOR
6
- };
7
- //# sourceMappingURL=chunk-PAZBZOO2.mjs.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../src/types.ts"],
4
- "sourcesContent": ["//\n// Copyright 2025 DXOS.org\n//\n\n// NOTE: Chosen from RFC 1738's `safe` characters: http://www.faqs.org/rfcs/rfc1738.html\nexport const ATTENDABLE_PATH_SEPARATOR = '~';\n\nexport type Attention = {\n hasAttention: boolean;\n isAncestor: boolean;\n isRelated: boolean;\n};\n\nexport type CurrentState = {\n current: string[];\n};\n"],
5
- "mappings": ";AAKO,IAAMA,4BAA4B;",
6
- "names": ["ATTENDABLE_PATH_SEPARATOR"]
7
- }