@dxos/react-ui-attention 0.8.4-main.c1de068 → 0.8.4-main.c85a9c8dae
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/browser/chunk-PAZBZOO2.mjs +7 -0
- package/dist/lib/browser/chunk-PAZBZOO2.mjs.map +7 -0
- package/dist/lib/browser/chunk-TAZUDNJI.mjs +705 -0
- package/dist/lib/browser/chunk-TAZUDNJI.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +75 -8
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +21 -5
- package/dist/lib/browser/testing/index.mjs.map +3 -3
- package/dist/lib/browser/types.mjs +7 -0
- package/dist/lib/browser/types.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-FT553KKP.mjs +706 -0
- package/dist/lib/node-esm/chunk-FT553KKP.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-PMGNPL4M.mjs +9 -0
- package/dist/lib/node-esm/chunk-PMGNPL4M.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +75 -8
- 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 +21 -5
- package/dist/lib/node-esm/testing/index.mjs.map +3 -3
- package/dist/lib/node-esm/types.mjs +8 -0
- package/dist/lib/node-esm/types.mjs.map +7 -0
- package/dist/types/src/attention.d.ts +22 -17
- package/dist/types/src/attention.d.ts.map +1 -1
- package/dist/types/src/components/AttentionGlyph.d.ts.map +1 -1
- package/dist/types/src/components/AttentionGlyph.stories.d.ts +9 -20
- package/dist/types/src/components/AttentionGlyph.stories.d.ts.map +1 -1
- package/dist/types/src/components/AttentionProvider.d.ts +8 -7
- package/dist/types/src/components/AttentionProvider.d.ts.map +1 -1
- package/dist/types/src/components/AttentionProvider.stories.d.ts +6 -4
- package/dist/types/src/components/AttentionProvider.stories.d.ts.map +1 -1
- package/dist/types/src/components/SelectionProvider.d.ts +6 -5
- package/dist/types/src/components/SelectionProvider.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/selection.d.ts +24 -4
- package/dist/types/src/selection.d.ts.map +1 -1
- package/dist/types/src/testing/decorators/withAttention.d.ts +5 -1
- package/dist/types/src/testing/decorators/withAttention.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +10 -0
- package/dist/types/src/types.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +38 -24
- package/src/attention.test.ts +13 -8
- package/src/attention.ts +64 -41
- package/src/components/AttentionGlyph.stories.tsx +17 -14
- package/src/components/AttentionGlyph.tsx +14 -7
- package/src/components/AttentionProvider.stories.tsx +13 -9
- package/src/components/AttentionProvider.tsx +80 -70
- package/src/components/SelectionProvider.tsx +66 -19
- package/src/index.ts +1 -0
- package/src/selection.ts +113 -72
- package/src/testing/decorators/withAttention.ts +21 -4
- package/src/types.ts +16 -0
- package/dist/lib/browser/chunk-3K54PTHR.mjs +0 -650
- package/dist/lib/browser/chunk-3K54PTHR.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-LVRGGYEI.mjs +0 -652
- package/dist/lib/node-esm/chunk-LVRGGYEI.mjs.map +0 -7
|
@@ -2,22 +2,32 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
// eslint-disable-next-line unused-imports/no-unused-imports
|
|
8
|
-
import { createContext, type Scope, type CreateScope } from '@radix-ui/react-context';
|
|
5
|
+
import { RegistryContext } from '@effect-atom/atom-react';
|
|
6
|
+
import { createContext } from '@radix-ui/react-context';
|
|
9
7
|
import { Primitive } from '@radix-ui/react-primitive';
|
|
10
8
|
import { Slot } from '@radix-ui/react-slot';
|
|
11
|
-
import React, {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
import React, {
|
|
10
|
+
type ComponentPropsWithRef,
|
|
11
|
+
type FocusEvent,
|
|
12
|
+
type PropsWithChildren,
|
|
13
|
+
forwardRef,
|
|
14
|
+
useContext,
|
|
15
|
+
useEffect,
|
|
16
|
+
useMemo,
|
|
17
|
+
useState,
|
|
18
|
+
} from 'react';
|
|
19
|
+
|
|
20
|
+
import { log } from '@dxos/log';
|
|
21
|
+
import { useDefaultValue } from '@dxos/react-hooks';
|
|
22
|
+
import { type ThemedClassName } from '@dxos/react-ui';
|
|
23
|
+
import { mx } from '@dxos/ui-theme';
|
|
24
|
+
|
|
25
|
+
import { AttentionManager, getAttendables } from '../attention';
|
|
26
|
+
import { ATTENDABLE_PATH_SEPARATOR, type Attention } from '../types';
|
|
17
27
|
|
|
18
28
|
const ATTENTION_NAME = 'Attention';
|
|
19
|
-
const
|
|
20
|
-
const ATTENTION_SOURCE_ATTRIBUTE = 'data-
|
|
29
|
+
const ATTENDABLE_ATTRIBUTE = 'data-attendable-id';
|
|
30
|
+
const ATTENTION_SOURCE_ATTRIBUTE = 'data-w-attention-source';
|
|
21
31
|
|
|
22
32
|
type AttentionContextValue = {
|
|
23
33
|
attention: AttentionManager;
|
|
@@ -25,35 +35,70 @@ type AttentionContextValue = {
|
|
|
25
35
|
};
|
|
26
36
|
|
|
27
37
|
const [AttentionContextProvider, useAttentionContext] = createContext<AttentionContextValue>(ATTENTION_NAME, {
|
|
28
|
-
attention:
|
|
38
|
+
attention: undefined as unknown as AttentionManager,
|
|
29
39
|
path: [],
|
|
30
40
|
});
|
|
31
41
|
|
|
32
|
-
const UNKNOWN_ATTENDABLE = { hasAttention: false, isAncestor: false, isRelated: false };
|
|
42
|
+
const UNKNOWN_ATTENDABLE = { hasAttention: false, isAncestor: false, isRelated: false } as Attention;
|
|
33
43
|
|
|
34
44
|
const useAttention = (attendableId?: string): Attention => {
|
|
35
45
|
const { attention, path } = useAttentionContext(ATTENTION_NAME);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const [state, setState] = useState<Attention>(UNKNOWN_ATTENDABLE);
|
|
47
|
+
|
|
48
|
+
const key = useMemo(() => {
|
|
49
|
+
if (!attendableId) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return [...attendableId.split(ATTENDABLE_PATH_SEPARATOR), ...path];
|
|
53
|
+
}, [attendableId, path]);
|
|
39
54
|
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!key || !attention) {
|
|
57
|
+
setState(UNKNOWN_ATTENDABLE);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Set initial state.
|
|
62
|
+
setState(attention.get(key));
|
|
63
|
+
|
|
64
|
+
// Subscribe to changes.
|
|
65
|
+
return attention.subscribe(key, (newState) => {
|
|
66
|
+
setState(newState);
|
|
67
|
+
});
|
|
68
|
+
}, [attention, key]);
|
|
69
|
+
|
|
70
|
+
return state;
|
|
42
71
|
};
|
|
43
72
|
|
|
44
73
|
const useAttended = () => {
|
|
45
74
|
const { attention } = useAttentionContext(ATTENTION_NAME);
|
|
46
|
-
|
|
75
|
+
const [current, setCurrent] = useState<readonly string[]>([]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!attention) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Set initial state.
|
|
83
|
+
setCurrent(attention.getCurrent());
|
|
84
|
+
|
|
85
|
+
// Subscribe to changes.
|
|
86
|
+
return attention.subscribeCurrent((newCurrent) => {
|
|
87
|
+
setCurrent(newCurrent);
|
|
88
|
+
});
|
|
89
|
+
}, [attention]);
|
|
90
|
+
|
|
91
|
+
return current;
|
|
47
92
|
};
|
|
48
93
|
|
|
49
94
|
/**
|
|
50
|
-
* Computes HTML element attributes to apply so the attention system can detect changes
|
|
95
|
+
* Computes HTML element attributes to apply so the attention system can detect changes.
|
|
51
96
|
* @param attendableId
|
|
52
97
|
*/
|
|
53
98
|
const useAttentionAttributes = (attendableId?: string) => {
|
|
54
99
|
const { hasAttention } = useAttention(attendableId);
|
|
55
100
|
return useMemo(() => {
|
|
56
|
-
const attributes: Record<string, string | undefined> = { [
|
|
101
|
+
const attributes: Record<string, string | undefined> = { [ATTENDABLE_ATTRIBUTE]: attendableId };
|
|
57
102
|
if (hasAttention) {
|
|
58
103
|
attributes[ATTENTION_SOURCE_ATTRIBUTE] = 'true';
|
|
59
104
|
}
|
|
@@ -77,15 +122,20 @@ const RootAttentionProvider = ({
|
|
|
77
122
|
onChange: (nextAttended: string[]) => void;
|
|
78
123
|
}>
|
|
79
124
|
>) => {
|
|
80
|
-
const
|
|
125
|
+
const registry = useContext(RegistryContext);
|
|
126
|
+
const attention = useDefaultValue(propsAttention, () => new AttentionManager(registry));
|
|
127
|
+
|
|
81
128
|
const handleFocus = (event: FocusEvent) => {
|
|
129
|
+
// NOTE(thure): Use the following to debug focus movement across the app:
|
|
130
|
+
log('focus', { related: event.relatedTarget, target: event.target });
|
|
131
|
+
|
|
82
132
|
const selector = [
|
|
83
133
|
'[data-attendable-id]',
|
|
84
134
|
...Array.from(document.querySelectorAll('[aria-controls]')).map(
|
|
85
135
|
(el) => `[id="${el.getAttribute('aria-controls')}"]`,
|
|
86
136
|
),
|
|
87
137
|
].join(',');
|
|
88
|
-
const prev = attention.
|
|
138
|
+
const prev = attention.getCurrent();
|
|
89
139
|
const next = getAttendables(selector, event.target);
|
|
90
140
|
// TODO(wittjosiah): Not allowing empty state means that the attended item is not strictly guaranteed to be in the DOM.
|
|
91
141
|
// Currently this depends on the deck in order to ensure that when the attended item is removed something else is attended.
|
|
@@ -96,49 +146,9 @@ const RootAttentionProvider = ({
|
|
|
96
146
|
}
|
|
97
147
|
};
|
|
98
148
|
|
|
99
|
-
// NOTE(thure): Use the following to debug the macOS package issue #8540
|
|
100
|
-
|
|
101
|
-
// const [startEl, setStartEl] = useState<HTMLElement | null>(null);
|
|
102
|
-
// const [endEl, setEndEl] = useState<HTMLElement | null>(null);
|
|
103
|
-
//
|
|
104
|
-
// const handleEventDebug = useCallback((event: any) => {
|
|
105
|
-
// console.log(`[${event.type}]`, event.target, event.currentTarget, [event.clientX, event.clientY]);
|
|
106
|
-
// }, []);
|
|
107
|
-
//
|
|
108
|
-
// const handleStartDebug = useCallback(
|
|
109
|
-
// (event: any) => {
|
|
110
|
-
// setStartEl(event.target);
|
|
111
|
-
// handleEventDebug(event);
|
|
112
|
-
// },
|
|
113
|
-
// [handleEventDebug],
|
|
114
|
-
// );
|
|
115
|
-
//
|
|
116
|
-
// const handleEndDebug = useCallback(
|
|
117
|
-
// (event: any) => {
|
|
118
|
-
// setEndEl(event.target);
|
|
119
|
-
// handleEventDebug(event);
|
|
120
|
-
// },
|
|
121
|
-
// [handleEventDebug],
|
|
122
|
-
// );
|
|
123
|
-
//
|
|
124
|
-
// const handleClickDebug = useCallback(
|
|
125
|
-
// (event: any) => {
|
|
126
|
-
// console.log('[click compare]', startEl, endEl, startEl === endEl);
|
|
127
|
-
// handleEventDebug(event);
|
|
128
|
-
// },
|
|
129
|
-
// [startEl, endEl, handleEventDebug],
|
|
130
|
-
// );
|
|
131
|
-
|
|
132
149
|
return (
|
|
133
150
|
<AttentionContextProvider attention={attention} path={[]}>
|
|
134
|
-
<div
|
|
135
|
-
role='none'
|
|
136
|
-
className='contents'
|
|
137
|
-
onFocusCapture={handleFocus}
|
|
138
|
-
// onClick={handleClickDebug}
|
|
139
|
-
// onMouseDown={handleStartDebug}
|
|
140
|
-
// onMouseUp={handleEndDebug}
|
|
141
|
-
>
|
|
151
|
+
<div role='none' className='contents' onFocusCapture={handleFocus}>
|
|
142
152
|
{children}
|
|
143
153
|
</div>
|
|
144
154
|
</AttentionContextProvider>
|
|
@@ -168,17 +178,17 @@ export type AttendableContainerProps = ThemedClassName<
|
|
|
168
178
|
const AttendableContainer = forwardRef<HTMLDivElement, AttendableContainerProps>(
|
|
169
179
|
({ id, classNames, children, asChild, ...props }, forwardedRef) => {
|
|
170
180
|
const attentionAttrs = useAttentionAttributes(id);
|
|
171
|
-
const
|
|
181
|
+
const Comp = asChild ? Slot : Primitive.div;
|
|
172
182
|
return (
|
|
173
|
-
<
|
|
183
|
+
<Comp
|
|
174
184
|
role='none'
|
|
175
185
|
{...attentionAttrs}
|
|
176
186
|
{...props}
|
|
177
|
-
className={mx('attention-surface', props.tabIndex === 0 && 'dx-focus-ring-inset-over-all', classNames)}
|
|
187
|
+
className={mx('dx-attention-surface', props.tabIndex === 0 && 'dx-focus-ring-inset-over-all', classNames)}
|
|
178
188
|
ref={forwardedRef}
|
|
179
189
|
>
|
|
180
190
|
{children}
|
|
181
|
-
</
|
|
191
|
+
</Comp>
|
|
182
192
|
);
|
|
183
193
|
},
|
|
184
194
|
);
|
|
@@ -193,6 +203,6 @@ export {
|
|
|
193
203
|
useAttentionAttributes,
|
|
194
204
|
useAttentionPath,
|
|
195
205
|
ATTENTION_NAME,
|
|
196
|
-
|
|
206
|
+
ATTENDABLE_ATTRIBUTE,
|
|
197
207
|
ATTENTION_SOURCE_ATTRIBUTE,
|
|
198
208
|
};
|
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
+
import { RegistryContext } from '@effect-atom/atom-react';
|
|
5
6
|
import { createContext } from '@radix-ui/react-context';
|
|
6
|
-
import
|
|
7
|
-
import React, { useCallback, useMemo,
|
|
7
|
+
import * as Match from 'effect/Match';
|
|
8
|
+
import React, { type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
8
9
|
|
|
9
|
-
import { useDefaultValue } from '@dxos/react-
|
|
10
|
+
import { useDefaultValue } from '@dxos/react-hooks';
|
|
10
11
|
|
|
11
12
|
import {
|
|
12
|
-
defaultSelection,
|
|
13
|
-
SelectionManager,
|
|
14
|
-
type SelectionResult,
|
|
15
13
|
type Selection,
|
|
14
|
+
SelectionManager,
|
|
16
15
|
type SelectionMode,
|
|
16
|
+
type SelectionResult,
|
|
17
|
+
defaultSelection,
|
|
17
18
|
} from '../selection';
|
|
18
19
|
|
|
19
20
|
const SELECTION_NAME = 'Selection';
|
|
@@ -23,7 +24,7 @@ type SelectionContextValue = {
|
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const [SelectionContextProvider, useSelectionContext] = createContext<SelectionContextValue>(SELECTION_NAME, {
|
|
26
|
-
selection:
|
|
27
|
+
selection: undefined as unknown as SelectionManager,
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -34,7 +35,8 @@ export const SelectionProvider = ({
|
|
|
34
35
|
children,
|
|
35
36
|
selection: propsSelection,
|
|
36
37
|
}: PropsWithChildren<{ selection?: SelectionManager }>) => {
|
|
37
|
-
const
|
|
38
|
+
const registry = useContext(RegistryContext);
|
|
39
|
+
const selection = useDefaultValue(propsSelection, () => new SelectionManager(registry));
|
|
38
40
|
return <SelectionContextProvider selection={selection}>{children}</SelectionContextProvider>;
|
|
39
41
|
};
|
|
40
42
|
|
|
@@ -46,6 +48,18 @@ export const useSelectionManager = () => {
|
|
|
46
48
|
return selection;
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
const getDefaultResult = <T extends SelectionMode>(mode: T): SelectionResult<T> => {
|
|
52
|
+
return Match.type<Selection>().pipe(
|
|
53
|
+
Match.when({ mode: 'single' }, (selection) => selection.id),
|
|
54
|
+
Match.when({ mode: 'multi' }, (selection) => selection.ids),
|
|
55
|
+
Match.when({ mode: 'range' }, (selection) =>
|
|
56
|
+
selection.from && selection.to ? { from: selection.from, to: selection.to } : undefined,
|
|
57
|
+
),
|
|
58
|
+
Match.when({ mode: 'multi-range' }, (selection) => selection.ranges),
|
|
59
|
+
Match.exhaustive,
|
|
60
|
+
)(defaultSelection(mode)) as SelectionResult<T>;
|
|
61
|
+
};
|
|
62
|
+
|
|
49
63
|
/**
|
|
50
64
|
* Get the selected objects for a given context.
|
|
51
65
|
*/
|
|
@@ -54,28 +68,49 @@ export const useSelected = <T extends SelectionMode>(
|
|
|
54
68
|
mode: T = 'multi' as T,
|
|
55
69
|
): SelectionResult<T> => {
|
|
56
70
|
const { selection } = useSelectionContext(SELECTION_NAME);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
const [state, setState] = useState<SelectionResult<T>>(() =>
|
|
72
|
+
contextId && selection ? selection.getSelected(contextId, mode) : getDefaultResult(mode),
|
|
73
|
+
);
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!contextId || !selection) {
|
|
77
|
+
setState(getDefaultResult(mode));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Set initial state.
|
|
82
|
+
setState(selection.getSelected(contextId, mode));
|
|
83
|
+
|
|
84
|
+
// Subscribe to changes.
|
|
85
|
+
return selection.subscribe(() => {
|
|
86
|
+
setState(selection.getSelected(contextId, mode));
|
|
87
|
+
});
|
|
88
|
+
}, [selection, contextId, mode]);
|
|
89
|
+
|
|
90
|
+
return state;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type UseSelectionActions = {
|
|
94
|
+
singleSelect: (id: string) => void;
|
|
95
|
+
multiSelect: (ids: string[]) => void;
|
|
96
|
+
rangeSelect: (from: string, to: string) => void;
|
|
97
|
+
toggle: (id: string) => void;
|
|
98
|
+
clear: () => void;
|
|
68
99
|
};
|
|
69
100
|
|
|
70
101
|
/**
|
|
71
102
|
* Provides functions to manage the selection state for multiple contexts.
|
|
72
103
|
*/
|
|
73
|
-
|
|
104
|
+
// TODO(burdon): Mode not used.
|
|
105
|
+
export const useSelectionActions = (contextIds: string[], mode: SelectionMode = 'multi'): UseSelectionActions => {
|
|
74
106
|
const stableContextIds = useMemo(() => contextIds, [JSON.stringify(contextIds)]); // TODO(burdon): Avoid stringify.
|
|
75
107
|
const { selection } = useSelectionContext(SELECTION_NAME);
|
|
76
108
|
|
|
77
109
|
const singleSelect = useCallback(
|
|
78
110
|
(id: string) => {
|
|
111
|
+
if (!selection) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
79
114
|
for (const contextId of stableContextIds) {
|
|
80
115
|
selection.updateSingle(contextId, id);
|
|
81
116
|
}
|
|
@@ -85,6 +120,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
|
|
|
85
120
|
|
|
86
121
|
const multiSelect = useCallback(
|
|
87
122
|
(ids: string[]) => {
|
|
123
|
+
if (!selection) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
88
126
|
for (const contextId of stableContextIds) {
|
|
89
127
|
selection.updateMulti(contextId, ids);
|
|
90
128
|
}
|
|
@@ -94,6 +132,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
|
|
|
94
132
|
|
|
95
133
|
const rangeSelect = useCallback(
|
|
96
134
|
(from: string, to: string) => {
|
|
135
|
+
if (!selection) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
97
138
|
for (const contextId of stableContextIds) {
|
|
98
139
|
selection.updateRange(contextId, from, to);
|
|
99
140
|
}
|
|
@@ -103,6 +144,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
|
|
|
103
144
|
|
|
104
145
|
const toggle = useCallback(
|
|
105
146
|
(id: string) => {
|
|
147
|
+
if (!selection) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
106
150
|
for (const contextId of stableContextIds) {
|
|
107
151
|
selection.toggleSelection(contextId, id);
|
|
108
152
|
}
|
|
@@ -111,6 +155,9 @@ export const useSelectionActions = (contextIds: string[], mode: SelectionMode =
|
|
|
111
155
|
);
|
|
112
156
|
|
|
113
157
|
const clear = useCallback(() => {
|
|
158
|
+
if (!selection) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
114
161
|
for (const contextId of stableContextIds) {
|
|
115
162
|
selection.clearSelection(contextId);
|
|
116
163
|
}
|
package/src/index.ts
CHANGED
package/src/selection.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
5
|
+
import { Atom, type Registry } from '@effect-atom/atom-react';
|
|
6
|
+
import * as Match from 'effect/Match';
|
|
7
|
+
import * as Schema from 'effect/Schema';
|
|
7
8
|
|
|
8
9
|
import { invariant } from '@dxos/invariant';
|
|
9
|
-
import { live, type Live } from '@dxos/live-object';
|
|
10
10
|
|
|
11
11
|
// TODO(burdon): Reconcile with @dxos/graph.
|
|
12
12
|
|
|
@@ -69,117 +69,158 @@ export const getSelectionSet = (selectionManager: SelectionManager, contextId?:
|
|
|
69
69
|
* Each context maintains its own selection mode and state.
|
|
70
70
|
*/
|
|
71
71
|
export class SelectionManager {
|
|
72
|
-
private readonly _state
|
|
72
|
+
private readonly _state: Atom.Writable<{ selections: Record<string, Selection> }>;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly _registry: Registry.Registry,
|
|
76
|
+
initial: Record<string, Selection> = {},
|
|
77
|
+
) {
|
|
78
|
+
this._state = Atom.make<{ selections: Record<string, Selection> }>({
|
|
79
|
+
selections: { ...initial },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Get the state atom for reactive access in the graph system.
|
|
85
|
+
*/
|
|
86
|
+
get state(): Atom.Atom<{ selections: Record<string, Selection> }> {
|
|
87
|
+
return this._state;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Gets the current state.
|
|
92
|
+
*/
|
|
93
|
+
getState(): { selections: Record<string, Selection> } {
|
|
94
|
+
return this._registry.get(this._state);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Subscribe to changes in the selection state.
|
|
99
|
+
*/
|
|
100
|
+
subscribe(cb: (state: { selections: Record<string, Selection> }) => void): () => void {
|
|
101
|
+
this._registry.get(this._state);
|
|
102
|
+
return this._registry.subscribe(this._state, () => {
|
|
103
|
+
cb(this._registry.get(this._state));
|
|
104
|
+
});
|
|
80
105
|
}
|
|
81
106
|
|
|
82
107
|
getSelectionContexts(): string[] {
|
|
83
|
-
return Object.keys(this._state.selections);
|
|
108
|
+
return Object.keys(this._registry.get(this._state).selections);
|
|
84
109
|
}
|
|
85
110
|
|
|
111
|
+
getSelection(contextId: string): Selection | undefined;
|
|
112
|
+
getSelection<T extends SelectionMode>(contextId: string, mode: T): Selection;
|
|
86
113
|
getSelection<T extends SelectionMode | undefined>(
|
|
87
114
|
contextId: string,
|
|
88
115
|
mode: T = undefined as T,
|
|
89
|
-
):
|
|
90
|
-
const
|
|
116
|
+
): Selection | undefined {
|
|
117
|
+
const state = this._registry.get(this._state);
|
|
118
|
+
const selection = state.selections[contextId];
|
|
91
119
|
if (!mode || selection) {
|
|
92
120
|
return selection;
|
|
93
121
|
}
|
|
94
122
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
// Create new selection for the context.
|
|
124
|
+
const newSelection = defaultSelection(mode);
|
|
125
|
+
this._registry.update(this._state, (state) => ({
|
|
126
|
+
selections: { ...state.selections, [contextId]: newSelection },
|
|
127
|
+
}));
|
|
128
|
+
return newSelection;
|
|
99
129
|
}
|
|
100
130
|
|
|
101
131
|
// TODO(burdon): Disambiguate with getSelection?
|
|
102
132
|
getSelected<T extends SelectionMode>(contextId: string, mode: T = 'multi' as T): SelectionResult<T> {
|
|
103
133
|
const selection = this.getSelection(contextId, mode);
|
|
104
134
|
invariant(selection?.mode === mode, 'Selection mode mismatch');
|
|
135
|
+
// Cast required because TypeScript can't infer the relationship between T and the matched result.
|
|
105
136
|
return Match.type<Selection>().pipe(
|
|
106
137
|
Match.when({ mode: 'single' }, (s) => s.id),
|
|
107
138
|
Match.when({ mode: 'multi' }, (s) => s.ids),
|
|
108
139
|
Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
|
|
109
140
|
Match.when({ mode: 'multi-range' }, (s) => s.ranges),
|
|
110
141
|
Match.exhaustive,
|
|
111
|
-
)(selection) as
|
|
142
|
+
)(selection) as SelectionResult<T>;
|
|
112
143
|
}
|
|
113
144
|
|
|
114
145
|
updateSingle(contextId: string, id: string): void {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
146
|
+
const selection = this.getSelection(contextId, 'single');
|
|
147
|
+
invariant(selection?.mode === 'single', 'Selection mode is not single');
|
|
148
|
+
this._registry.update(this._state, (state) => ({
|
|
149
|
+
selections: {
|
|
150
|
+
...state.selections,
|
|
151
|
+
[contextId]: { ...selection, id },
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
120
154
|
}
|
|
121
155
|
|
|
122
156
|
updateMulti(contextId: string, ids: string[]) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
157
|
+
const selection = this.getSelection(contextId, 'multi');
|
|
158
|
+
invariant(selection?.mode === 'multi', 'Selection mode is not multi');
|
|
159
|
+
this._registry.update(this._state, (state) => ({
|
|
160
|
+
selections: {
|
|
161
|
+
...state.selections,
|
|
162
|
+
[contextId]: { ...selection, ids: [...ids] },
|
|
163
|
+
},
|
|
164
|
+
}));
|
|
128
165
|
}
|
|
129
166
|
|
|
130
167
|
updateRange(contextId: string, from: string, to: string) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
168
|
+
const selection = this.getSelection(contextId, 'range');
|
|
169
|
+
invariant(selection?.mode === 'range', 'Selection mode is not range');
|
|
170
|
+
this._registry.update(this._state, (state) => ({
|
|
171
|
+
selections: {
|
|
172
|
+
...state.selections,
|
|
173
|
+
[contextId]: { ...selection, from, to },
|
|
174
|
+
},
|
|
175
|
+
}));
|
|
137
176
|
}
|
|
138
177
|
|
|
139
178
|
updateMultiRange(contextId: string, ranges: { from: string; to: string }[]) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
179
|
+
const selection = this.getSelection(contextId, 'multi-range');
|
|
180
|
+
invariant(selection?.mode === 'multi-range', 'Selection mode is not multi-range');
|
|
181
|
+
this._registry.update(this._state, (state) => ({
|
|
182
|
+
selections: {
|
|
183
|
+
...state.selections,
|
|
184
|
+
[contextId]: { ...selection, ranges: [...ranges] },
|
|
185
|
+
},
|
|
186
|
+
}));
|
|
145
187
|
}
|
|
146
188
|
|
|
147
189
|
clearSelection(contextId: string): void {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
Match.exhaustive,
|
|
168
|
-
)(selection);
|
|
169
|
-
});
|
|
190
|
+
const selection = this.getSelection(contextId);
|
|
191
|
+
if (!selection) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const clearedSelection = Match.type<Selection>().pipe(
|
|
196
|
+
Match.when({ mode: 'single' }, (s) => ({ ...s, id: undefined })),
|
|
197
|
+
Match.when({ mode: 'multi' }, (s) => ({ ...s, ids: [] })),
|
|
198
|
+
Match.when({ mode: 'range' }, (s) => ({ ...s, from: undefined, to: undefined })),
|
|
199
|
+
Match.when({ mode: 'multi-range' }, (s) => ({ ...s, ranges: [] })),
|
|
200
|
+
Match.exhaustive,
|
|
201
|
+
)(selection);
|
|
202
|
+
|
|
203
|
+
this._registry.update(this._state, (state) => ({
|
|
204
|
+
selections: {
|
|
205
|
+
...state.selections,
|
|
206
|
+
[contextId]: clearedSelection,
|
|
207
|
+
},
|
|
208
|
+
}));
|
|
170
209
|
}
|
|
171
210
|
|
|
172
211
|
toggleSelection(contextId: string, id: string): void {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
212
|
+
const selection = this.getSelection(contextId, 'multi');
|
|
213
|
+
invariant(selection?.mode === 'multi', 'Selection mode is not multi');
|
|
214
|
+
|
|
215
|
+
const newIds = selection.ids.includes(id)
|
|
216
|
+
? selection.ids.filter((existingId) => existingId !== id)
|
|
217
|
+
: [...selection.ids, id];
|
|
218
|
+
|
|
219
|
+
this._registry.update(this._state, (state) => ({
|
|
220
|
+
selections: {
|
|
221
|
+
...state.selections,
|
|
222
|
+
[contextId]: { ...selection, ids: newIds },
|
|
223
|
+
},
|
|
224
|
+
}));
|
|
184
225
|
}
|
|
185
226
|
}
|
|
@@ -2,11 +2,28 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
+
import { Registry, RegistryContext } from '@effect-atom/atom-react';
|
|
5
6
|
import { type Decorator } from '@storybook/react';
|
|
6
|
-
import { createElement } from 'react';
|
|
7
|
+
import { createElement, useMemo } from 'react';
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import { AttentionManager } from '../../attention';
|
|
10
|
+
import { RootAttentionProvider, SelectionProvider } from '../../components';
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Storybook decorator that provides attention context.
|
|
14
|
+
* @param initialAttendedId Optional ID to set as initially attended.
|
|
15
|
+
*/
|
|
16
|
+
export const withAttention = (initialAttendedId?: string): Decorator => {
|
|
17
|
+
return (Story) => {
|
|
18
|
+
const registry = useMemo(() => Registry.make(), []);
|
|
19
|
+
const attention = useMemo(
|
|
20
|
+
() => (initialAttendedId ? new AttentionManager(registry, [initialAttendedId]) : undefined),
|
|
21
|
+
[registry],
|
|
22
|
+
);
|
|
23
|
+
return createElement(
|
|
24
|
+
RegistryContext.Provider,
|
|
25
|
+
{ value: registry },
|
|
26
|
+
createElement(RootAttentionProvider, { attention }, createElement(SelectionProvider, {}, createElement(Story))),
|
|
27
|
+
);
|
|
28
|
+
};
|
|
12
29
|
};
|