@dxos/react-ui-attention 0.8.4-main.fd6878d → 0.8.4-staging.60fe92afc8
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 +165 -23
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +22 -5
- package/dist/lib/browser/testing/index.mjs.map +3 -3
- package/dist/lib/browser/types/index.mjs +29 -0
- package/dist/lib/browser/types/index.mjs.map +7 -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 +165 -23
- 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 +22 -5
- package/dist/lib/node-esm/testing/index.mjs.map +3 -3
- package/dist/lib/node-esm/types/index.mjs +30 -0
- package/dist/lib/node-esm/types/index.mjs.map +7 -0
- package/dist/types/src/attention.d.ts +60 -20
- 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 +16 -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 +12 -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} +7 -6
- 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 +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/index.d.ts +3 -0
- package/dist/types/src/types/index.d.ts.map +1 -0
- package/dist/types/src/useArticleKeyboardNavigation.d.ts +42 -0
- package/dist/types/src/useArticleKeyboardNavigation.d.ts.map +1 -0
- package/dist/types/src/useArticleKeyboardNavigation.test.d.ts +2 -0
- package/dist/types/src/useArticleKeyboardNavigation.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +35 -32
- package/src/attention.test.ts +188 -27
- package/src/attention.ts +179 -74
- package/src/components/{AttentionGlyph.stories.tsx → AttentionGlyph/AttentionGlyph.stories.tsx} +17 -14
- package/src/components/{AttentionGlyph.tsx → AttentionGlyph/AttentionGlyph.tsx} +14 -7
- package/src/components/AttentionGlyph/index.ts +5 -0
- package/src/components/{AttentionProvider.stories.tsx → AttentionProvider/AttentionProvider.stories.tsx} +12 -10
- package/src/components/AttentionProvider/AttentionProvider.tsx +176 -0
- package/src/components/AttentionProvider/index.ts +5 -0
- package/src/components/SelectionProvider/SelectionProvider.tsx +174 -0
- package/src/components/SelectionProvider/index.ts +5 -0
- package/src/index.ts +1 -0
- package/src/selection.ts +123 -80
- package/src/testing/decorators/withAttention.ts +22 -4
- package/src/types/index.ts +9 -0
- package/src/useArticleKeyboardNavigation.test.ts +50 -0
- package/src/useArticleKeyboardNavigation.ts +116 -0
- package/dist/lib/browser/chunk-PXMGIYAQ.mjs +0 -650
- package/dist/lib/browser/chunk-PXMGIYAQ.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-V2PNA7ZV.mjs +0 -652
- package/dist/lib/node-esm/chunk-V2PNA7ZV.mjs.map +0 -7
- package/dist/types/src/components/AttentionGlyph.d.ts.map +0 -1
- package/dist/types/src/components/AttentionGlyph.stories.d.ts +0 -27
- package/dist/types/src/components/AttentionGlyph.stories.d.ts.map +0 -1
- package/dist/types/src/components/AttentionProvider.d.ts +0 -38
- package/dist/types/src/components/AttentionProvider.d.ts.map +0 -1
- package/dist/types/src/components/AttentionProvider.stories.d.ts +0 -10
- package/dist/types/src/components/AttentionProvider.stories.d.ts.map +0 -1
- package/dist/types/src/components/SelectionProvider.d.ts.map +0 -1
- package/src/components/AttentionProvider.tsx +0 -198
- package/src/components/SelectionProvider.tsx +0 -120
- /package/dist/types/src/components/{AttentionGlyph.d.ts → AttentionGlyph/AttentionGlyph.d.ts} +0 -0
package/src/selection.ts
CHANGED
|
@@ -2,21 +2,23 @@
|
|
|
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 { type Live, live } from '@dxos/live-object';
|
|
10
|
-
|
|
11
|
-
// TODO(burdon): Reconcile with @dxos/graph.
|
|
12
10
|
|
|
13
11
|
export type SelectionMode = 'single' | 'multi' | 'range' | 'multi-range';
|
|
14
12
|
|
|
15
13
|
export const SelectionSchema = Schema.Union(
|
|
16
|
-
Schema.Struct({
|
|
17
|
-
|
|
18
|
-
Schema.
|
|
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()) {
|
|
@@ -69,117 +70,159 @@ export const getSelectionSet = (selectionManager: SelectionManager, contextId?:
|
|
|
69
70
|
* Each context maintains its own selection mode and state.
|
|
70
71
|
*/
|
|
71
72
|
export class SelectionManager {
|
|
72
|
-
private readonly _state
|
|
73
|
+
private readonly _state: Atom.Writable<{ selections: Record<string, Selection> }>;
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
private readonly _registry: Registry.Registry,
|
|
77
|
+
initial: Record<string, Selection> = {},
|
|
78
|
+
) {
|
|
79
|
+
this._state = Atom.make<{ selections: Record<string, Selection> }>({
|
|
80
|
+
selections: { ...initial },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Get the state atom for reactive access in the graph system.
|
|
86
|
+
*/
|
|
87
|
+
get state(): Atom.Atom<{ selections: Record<string, Selection> }> {
|
|
88
|
+
return this._state;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gets the current state.
|
|
93
|
+
*/
|
|
94
|
+
getState(): { selections: Record<string, Selection> } {
|
|
95
|
+
return this._registry.get(this._state);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Subscribe to changes in the selection state.
|
|
100
|
+
*/
|
|
101
|
+
subscribe(cb: (state: { selections: Record<string, Selection> }) => void): () => void {
|
|
102
|
+
this._registry.get(this._state);
|
|
103
|
+
return this._registry.subscribe(this._state, () => {
|
|
104
|
+
cb(this._registry.get(this._state));
|
|
105
|
+
});
|
|
80
106
|
}
|
|
81
107
|
|
|
82
108
|
getSelectionContexts(): string[] {
|
|
83
|
-
return Object.keys(this._state.selections);
|
|
109
|
+
return Object.keys(this._registry.get(this._state).selections);
|
|
84
110
|
}
|
|
85
111
|
|
|
112
|
+
getSelection(contextId: string): Selection | undefined;
|
|
113
|
+
getSelection<T extends SelectionMode>(contextId: string, mode: T): Selection;
|
|
86
114
|
getSelection<T extends SelectionMode | undefined>(
|
|
87
115
|
contextId: string,
|
|
88
116
|
mode: T = undefined as T,
|
|
89
|
-
):
|
|
90
|
-
const
|
|
117
|
+
): Selection | undefined {
|
|
118
|
+
const state = this._registry.get(this._state);
|
|
119
|
+
const selection = state.selections[contextId];
|
|
91
120
|
if (!mode || selection) {
|
|
92
121
|
return selection;
|
|
93
122
|
}
|
|
94
123
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
// Create new selection for the context.
|
|
125
|
+
const newSelection = defaultSelection(mode);
|
|
126
|
+
this._registry.update(this._state, (state) => ({
|
|
127
|
+
selections: { ...state.selections, [contextId]: newSelection },
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
return newSelection;
|
|
99
131
|
}
|
|
100
132
|
|
|
101
|
-
// TODO(burdon): Disambiguate with getSelection?
|
|
102
133
|
getSelected<T extends SelectionMode>(contextId: string, mode: T = 'multi' as T): SelectionResult<T> {
|
|
103
134
|
const selection = this.getSelection(contextId, mode);
|
|
104
135
|
invariant(selection?.mode === mode, 'Selection mode mismatch');
|
|
136
|
+
|
|
137
|
+
// Cast required because TypeScript can't infer the relationship between T and the matched result.
|
|
105
138
|
return Match.type<Selection>().pipe(
|
|
106
139
|
Match.when({ mode: 'single' }, (s) => s.id),
|
|
107
140
|
Match.when({ mode: 'multi' }, (s) => s.ids),
|
|
108
141
|
Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
|
|
109
142
|
Match.when({ mode: 'multi-range' }, (s) => s.ranges),
|
|
110
143
|
Match.exhaustive,
|
|
111
|
-
)(selection) as
|
|
144
|
+
)(selection) as SelectionResult<T>;
|
|
112
145
|
}
|
|
113
146
|
|
|
114
147
|
updateSingle(contextId: string, id: string): void {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
148
|
+
const selection = this.getSelection(contextId, 'single');
|
|
149
|
+
invariant(selection?.mode === 'single', 'Selection mode is not single');
|
|
150
|
+
this._registry.update(this._state, (state) => ({
|
|
151
|
+
selections: {
|
|
152
|
+
...state.selections,
|
|
153
|
+
[contextId]: { ...selection, id },
|
|
154
|
+
},
|
|
155
|
+
}));
|
|
120
156
|
}
|
|
121
157
|
|
|
122
158
|
updateMulti(contextId: string, ids: string[]) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
159
|
+
const selection = this.getSelection(contextId, 'multi');
|
|
160
|
+
invariant(selection?.mode === 'multi', 'Selection mode is not multi');
|
|
161
|
+
this._registry.update(this._state, (state) => ({
|
|
162
|
+
selections: {
|
|
163
|
+
...state.selections,
|
|
164
|
+
[contextId]: { ...selection, ids: [...ids] },
|
|
165
|
+
},
|
|
166
|
+
}));
|
|
128
167
|
}
|
|
129
168
|
|
|
130
169
|
updateRange(contextId: string, from: string, to: string) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
170
|
+
const selection = this.getSelection(contextId, 'range');
|
|
171
|
+
invariant(selection?.mode === 'range', 'Selection mode is not range');
|
|
172
|
+
this._registry.update(this._state, (state) => ({
|
|
173
|
+
selections: {
|
|
174
|
+
...state.selections,
|
|
175
|
+
[contextId]: { ...selection, from, to },
|
|
176
|
+
},
|
|
177
|
+
}));
|
|
137
178
|
}
|
|
138
179
|
|
|
139
180
|
updateMultiRange(contextId: string, ranges: { from: string; to: string }[]) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
181
|
+
const selection = this.getSelection(contextId, 'multi-range');
|
|
182
|
+
invariant(selection?.mode === 'multi-range', 'Selection mode is not multi-range');
|
|
183
|
+
this._registry.update(this._state, (state) => ({
|
|
184
|
+
selections: {
|
|
185
|
+
...state.selections,
|
|
186
|
+
[contextId]: { ...selection, ranges: [...ranges] },
|
|
187
|
+
},
|
|
188
|
+
}));
|
|
145
189
|
}
|
|
146
190
|
|
|
147
191
|
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
|
-
});
|
|
192
|
+
const selection = this.getSelection(contextId);
|
|
193
|
+
if (!selection) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const clearedSelection = Match.type<Selection>().pipe(
|
|
198
|
+
Match.when({ mode: 'single' }, (s) => ({ ...s, id: undefined })),
|
|
199
|
+
Match.when({ mode: 'multi' }, (s) => ({ ...s, ids: [] })),
|
|
200
|
+
Match.when({ mode: 'range' }, (s) => ({ ...s, from: undefined, to: undefined })),
|
|
201
|
+
Match.when({ mode: 'multi-range' }, (s) => ({ ...s, ranges: [] })),
|
|
202
|
+
Match.exhaustive,
|
|
203
|
+
)(selection);
|
|
204
|
+
|
|
205
|
+
this._registry.update(this._state, (state) => ({
|
|
206
|
+
selections: {
|
|
207
|
+
...state.selections,
|
|
208
|
+
[contextId]: clearedSelection,
|
|
209
|
+
},
|
|
210
|
+
}));
|
|
170
211
|
}
|
|
171
212
|
|
|
172
213
|
toggleSelection(contextId: string, id: string): void {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
214
|
+
const selection = this.getSelection(contextId, 'multi');
|
|
215
|
+
invariant(selection?.mode === 'multi', 'Selection mode is not multi');
|
|
216
|
+
|
|
217
|
+
const newIds = selection.ids.includes(id)
|
|
218
|
+
? selection.ids.filter((existingId) => existingId !== id)
|
|
219
|
+
: [...selection.ids, id];
|
|
220
|
+
|
|
221
|
+
this._registry.update(this._state, (state) => ({
|
|
222
|
+
selections: {
|
|
223
|
+
...state.selections,
|
|
224
|
+
[contextId]: { ...selection, ids: newIds },
|
|
225
|
+
},
|
|
226
|
+
}));
|
|
184
227
|
}
|
|
185
228
|
}
|
|
@@ -2,11 +2,29 @@
|
|
|
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
|
+
|
|
24
|
+
return createElement(
|
|
25
|
+
RegistryContext.Provider,
|
|
26
|
+
{ value: registry },
|
|
27
|
+
createElement(RootAttentionProvider, { attention }, createElement(SelectionProvider, {}, createElement(Story))),
|
|
28
|
+
);
|
|
29
|
+
};
|
|
12
30
|
};
|
|
@@ -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';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { advance } from './useArticleKeyboardNavigation';
|
|
8
|
+
|
|
9
|
+
describe('advance', () => {
|
|
10
|
+
const ids = ['a', 'b', 'c', 'd'];
|
|
11
|
+
|
|
12
|
+
test('returns undefined for an empty list', ({ expect }) => {
|
|
13
|
+
expect(advance({ ids: [], currentId: undefined, delta: 1 })).to.equal(undefined);
|
|
14
|
+
expect(advance({ ids: [], currentId: 'x', delta: -1 })).to.equal(undefined);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('with no current selection, delta=+1 enters at the first item', ({ expect }) => {
|
|
18
|
+
expect(advance({ ids, currentId: undefined, delta: 1 })).to.equal('a');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('with no current selection, delta=-1 enters at the last item', ({ expect }) => {
|
|
22
|
+
expect(advance({ ids, currentId: undefined, delta: -1 })).to.equal('d');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('advances forward from the middle of the list', ({ expect }) => {
|
|
26
|
+
expect(advance({ ids, currentId: 'b', delta: 1 })).to.equal('c');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('advances backward from the middle of the list', ({ expect }) => {
|
|
30
|
+
expect(advance({ ids, currentId: 'c', delta: -1 })).to.equal('b');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('clamps at the last item when pressing next', ({ expect }) => {
|
|
34
|
+
expect(advance({ ids, currentId: 'd', delta: 1 })).to.equal('d');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('clamps at the first item when pressing previous', ({ expect }) => {
|
|
38
|
+
expect(advance({ ids, currentId: 'a', delta: -1 })).to.equal('a');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('treats an unknown currentId like no selection', ({ expect }) => {
|
|
42
|
+
expect(advance({ ids, currentId: 'unknown', delta: 1 })).to.equal('a');
|
|
43
|
+
expect(advance({ ids, currentId: 'unknown', delta: -1 })).to.equal('d');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('single-item list clamps both directions to that item', ({ expect }) => {
|
|
47
|
+
expect(advance({ ids: ['only'], currentId: 'only', delta: 1 })).to.equal('only');
|
|
48
|
+
expect(advance({ ids: ['only'], currentId: 'only', delta: -1 })).to.equal('only');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useEffect, useMemo } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Keyboard } from '@dxos/keyboard';
|
|
8
|
+
|
|
9
|
+
import { useAttention } from './components';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compute the id to select after pressing 'j' (delta = 1) or 'k' (delta = -1).
|
|
13
|
+
* Clamps at the list ends — pressing 'j' on the last item keeps the last item
|
|
14
|
+
* selected (vim-style), and 'k' on the first item keeps the first.
|
|
15
|
+
*
|
|
16
|
+
* When no current selection exists, returns the first id (for delta = 1) or the
|
|
17
|
+
* last id (for delta = -1) so the user can enter the list with either key.
|
|
18
|
+
*/
|
|
19
|
+
export const advance = ({
|
|
20
|
+
ids,
|
|
21
|
+
currentId,
|
|
22
|
+
delta,
|
|
23
|
+
}: {
|
|
24
|
+
ids: readonly string[];
|
|
25
|
+
currentId: string | undefined;
|
|
26
|
+
delta: 1 | -1;
|
|
27
|
+
}): string | undefined => {
|
|
28
|
+
if (ids.length === 0) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const idx = currentId ? ids.indexOf(currentId) : -1;
|
|
33
|
+
if (idx === -1) {
|
|
34
|
+
return delta === 1 ? ids[0] : ids[ids.length - 1];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const next = Math.max(0, Math.min(ids.length - 1, idx + delta));
|
|
38
|
+
return ids[next];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type UseArticleKeyboardNavigationOptions<T> = {
|
|
42
|
+
/** Stable id for the article (used as the keyboard context path). */
|
|
43
|
+
articleId: string;
|
|
44
|
+
/** Ordered list of selectable items as they appear in the article. */
|
|
45
|
+
items: readonly T[];
|
|
46
|
+
/** Currently-selected id, if any. */
|
|
47
|
+
currentId: string | undefined;
|
|
48
|
+
/** Extracts the id from an item. Defaults to `item.id`. */
|
|
49
|
+
getId?: (item: T) => string;
|
|
50
|
+
/** Called with the id to select when the user presses 'j' or 'k'. */
|
|
51
|
+
onSelect: (id: string) => void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Wire 'j' (next) and 'k' (previous) keyboard shortcuts for an article that
|
|
56
|
+
* navigates a list of items (e.g., messages, events). Bindings are scoped to
|
|
57
|
+
* the article's keyboard context so they only fire while the article has attention.
|
|
58
|
+
*
|
|
59
|
+
* Pass the items directly; ids are derived via `getId` (defaults to `item.id`).
|
|
60
|
+
* Clamps at list boundaries. Active only when the article is attended.
|
|
61
|
+
*/
|
|
62
|
+
export const useArticleKeyboardNavigation: {
|
|
63
|
+
<T extends { id: string }>(options: UseArticleKeyboardNavigationOptions<T>): void;
|
|
64
|
+
<T>(options: UseArticleKeyboardNavigationOptions<T> & { getId: (item: T) => string }): void;
|
|
65
|
+
} = <T>({ articleId, items, currentId, getId, onSelect }: UseArticleKeyboardNavigationOptions<T>): void => {
|
|
66
|
+
const { hasAttention } = useAttention(articleId);
|
|
67
|
+
|
|
68
|
+
// `getId` is optional only when `T extends { id: string }` (enforced by the overloads above),
|
|
69
|
+
// so the fallback is sound; the cast bridges the generic erased by the implementation signature.
|
|
70
|
+
const ids = useMemo(() => items.map((item) => (getId ? getId(item) : (item as { id: string }).id)), [items, getId]);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!hasAttention) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const context = Keyboard.singleton.getContext(articleId);
|
|
78
|
+
const prevContext = Keyboard.singleton.getCurrentContext();
|
|
79
|
+
Keyboard.singleton.setCurrentContext(articleId);
|
|
80
|
+
|
|
81
|
+
const nextBinding = {
|
|
82
|
+
shortcut: 'j',
|
|
83
|
+
handler: () => {
|
|
84
|
+
const target = advance({ ids, currentId, delta: 1 });
|
|
85
|
+
if (target !== undefined) {
|
|
86
|
+
onSelect(target);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
data: 'Next item',
|
|
90
|
+
disableInput: true,
|
|
91
|
+
};
|
|
92
|
+
const prevBinding = {
|
|
93
|
+
shortcut: 'k',
|
|
94
|
+
handler: () => {
|
|
95
|
+
const target = advance({ ids, currentId, delta: -1 });
|
|
96
|
+
if (target !== undefined) {
|
|
97
|
+
onSelect(target);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
data: 'Previous item',
|
|
101
|
+
disableInput: true,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
context.bind(nextBinding);
|
|
105
|
+
context.bind(prevBinding);
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
context.unbind('j');
|
|
109
|
+
context.unbind('k');
|
|
110
|
+
// Restore the prior context if we were the ones who set it.
|
|
111
|
+
if (Keyboard.singleton.getCurrentContext() === articleId) {
|
|
112
|
+
Keyboard.singleton.setCurrentContext(prevContext);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}, [articleId, ids, currentId, onSelect, hasAttention]);
|
|
116
|
+
};
|