Skip to content

Commit 98d548e

Browse files
committed
move state/side-effects
1 parent ba73a35 commit 98d548e

File tree

5 files changed

+412
-194
lines changed

5 files changed

+412
-194
lines changed

special-pages/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"build": "node index.mjs",
1111
"build.dev": "npm run build -- --env development",
1212
"lint-fix": "cd ../ && npm run lint-fix",
13-
"test-unit": "node --test unit-test/translations.mjs unit-test/color.spec.mjs pages/duckplayer/unit-tests/embed-settings.mjs pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs",
13+
"test-unit": "node --test 'unit-test/*' 'pages/history/unit-tests/*' 'pages/duckplayer/unit-tests/*' pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs",
1414
"test-int": "npm run test-unit && npm run build.dev && playwright test --grep-invert '@screenshots'",
1515
"test-int-x": "npm run test-int",
1616
"test.screenshots": "npm run test-unit && npm run build.dev && playwright test --grep '@screenshots'",

special-pages/pages/history/app/components/Results.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,31 @@ import { Item } from './Item.js';
44
import styles from './VirtualizedList.module.css';
55
import { VisibleItems } from './VirtualizedList.js';
66
import { Empty } from './Empty.js';
7-
import { useSelected } from '../global/Providers/SelectionProvider.js';
7+
import { useSelected, useSelectionState } from '../global/Providers/SelectionProvider.js';
88
import { useHistoryServiceDispatch, useResultsData } from '../global/Providers/HistoryServiceProvider.js';
9-
import { useCallback } from 'preact/hooks';
9+
import { useCallback, useEffect } from 'preact/hooks';
1010

1111
/**
1212
* Access global state and render the results
1313
*/
1414
export function ResultsContainer() {
1515
const results = useResultsData();
1616
const selected = useSelected();
17+
const selectionState = useSelectionState();
1718
const dispatch = useHistoryServiceDispatch();
19+
20+
/**
21+
* When the selection state changed in a way that might cause an element to be off-screen, re-focus it
22+
*/
23+
useEffect(() => {
24+
return selectionState.subscribe(({ lastAction, focusedIndex }) => {
25+
if (lastAction === 'move-selection' || lastAction === 'inc-or-dec-selected') {
26+
const match = document.querySelector(`[aria-selected][data-index="${focusedIndex}"]`);
27+
match?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
28+
}
29+
});
30+
}, [selectionState]);
31+
1832
/**
1933
* Let the history service know when it might want to load more
2034
*/

special-pages/pages/history/app/global/Providers/SelectionProvider.js

Lines changed: 19 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,21 @@
11
import { createContext, h } from 'preact';
22
import { useCallback, useContext } from 'preact/hooks';
3-
import { signal, useComputed, useSignal } from '@preact/signals';
3+
import { signal, useComputed } from '@preact/signals';
44
import { usePlatformName } from '../../types.js';
5-
import { eventToIntention, invariant } from '../../utils.js';
5+
import { eventToIntention } from '../../utils.js';
66
import { useHistoryServiceDispatch, useResultsData } from './HistoryServiceProvider.js';
7+
import { useSelectionStateApi } from '../hooks/useSelectionState.js';
78

89
/**
910
* @typedef {(s: (d: Set<number>) => Set<number>, reason: string) => void} UpdateSelected
1011
* @typedef {import("../../utils.js").Intention} Intention
11-
* @typedef {{ focusedIndex: number|null; anchorIndex: number|null; lastShiftRange: { end:number|null; start:number|null }}} SelectionState
12+
* @typedef {import('../hooks/useSelectionState.js').Action} Action
13+
* @typedef {import('../hooks/useSelectionState.js').SelectionState} SelectionState
1214
* @import { ReadonlySignal } from '@preact/signals'
1315
*/
1416

15-
/**
16-
* @typedef {{kind: 'select-index', index: number, reason?: string}
17-
* | {kind: 'toggle-index'; index: number; reason?: string}
18-
* | {kind: 'expand-selected-to-index'; index: number; reason?: string}
19-
* | {kind: 'inc-or-dec-selected'; nextIndex: number; reason?: string}
20-
* | {kind: 'move-selection'; direction: 'up' | 'down'; total: number; reason?: string}
21-
* | {kind: 'increment-selection'; direction: 'up' | 'down'; total: number; reason?: string}
22-
* | {kind: 'reset'; reason?: string}
23-
* } Action
24-
*/
2517
const SelectionDispatchContext = createContext(/** @type {(a: Action) => void} */ ((a) => {}));
26-
const SelectionContext = createContext(/** @type {ReadonlySignal<Set<number>>} */ (signal(new Set([]))));
27-
const FocussedContext = createContext(/** @type {ReadonlySignal<number|null>} */ (signal(null)));
18+
const SelectionStateContext = createContext(/** @type {ReadonlySignal<SelectionState>} */ (signal({})));
2819

2920
/**
3021
* Provides a context for the selections + state for managing updates (like keyboard+clicks)
@@ -33,196 +24,33 @@ const FocussedContext = createContext(/** @type {ReadonlySignal<number|null>} */
3324
* @param {import("preact").ComponentChild} props.children - The child components that will consume the history service context.
3425
*/
3526
export function SelectionProvider({ children }) {
36-
const state = useSignal(defaultState);
37-
const selected = useComputed(() => state.value.selected);
38-
const focussed = useComputed(() => state.value.focusedIndex);
39-
/**
40-
* @param {Action} evt
41-
*/
42-
function dispatch(evt) {
43-
console.log(evt);
44-
const next = reducer(state.value, evt);
45-
state.value = next;
46-
47-
// after-effects?
48-
if (evt.kind === 'move-selection' || evt.kind === 'increment-selection') {
49-
const match = document.querySelector(`[aria-selected][data-index="${state.value.focusedIndex}"]`);
50-
match?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
51-
}
52-
}
53-
const dispatcher = useCallback(dispatch, [state, selected]);
27+
const { dispatch, state } = useSelectionStateApi();
5428

5529
return (
56-
<SelectionContext.Provider value={selected}>
57-
<FocussedContext.Provider value={focussed}>
58-
<SelectionDispatchContext.Provider value={dispatcher}>{children}</SelectionDispatchContext.Provider>
59-
</FocussedContext.Provider>
60-
</SelectionContext.Provider>
30+
<SelectionStateContext.Provider value={state}>
31+
<SelectionDispatchContext.Provider value={dispatch}>{children}</SelectionDispatchContext.Provider>
32+
</SelectionStateContext.Provider>
6133
);
6234
}
6335

64-
// Hook for consuming the context
36+
export function useSelectionState() {
37+
return useContext(SelectionStateContext);
38+
}
39+
6540
export function useSelected() {
66-
return useContext(SelectionContext);
41+
const state = useContext(SelectionStateContext);
42+
return useComputed(() => state.value.selected);
6743
}
6844

69-
function useFocussedIndex() {
70-
return useContext(FocussedContext);
45+
export function useFocussedIndex() {
46+
const state = useContext(SelectionStateContext);
47+
return useComputed(() => state.value.focusedIndex);
7148
}
7249

7350
export function useSelectionDispatch() {
7451
return useContext(SelectionDispatchContext);
7552
}
7653

77-
const defaultState = {
78-
anchorIndex: /** @type {null|number} */ (null),
79-
/** @type {{start: null|number; end: null|number}} */
80-
lastShiftRange: {
81-
start: null,
82-
end: null,
83-
},
84-
focusedIndex: /** @type {null|number} */ (null),
85-
selected: new Set(/** @type {number[]} */ ([])),
86-
};
87-
88-
/**
89-
* @param {typeof defaultState} prev
90-
* @param {Action} evt
91-
* @return {typeof defaultState}
92-
*/
93-
function reducer(prev, evt) {
94-
switch (evt.kind) {
95-
case 'reset': {
96-
return { ...defaultState };
97-
}
98-
case 'move-selection': {
99-
const { focusedIndex } = prev;
100-
invariant(focusedIndex !== null);
101-
const delta = evt.direction === 'up' ? -1 : 1;
102-
// either the last item, or current + 1
103-
const max = Math.min(evt.total - 1, focusedIndex + delta);
104-
const newIndex = Math.max(0, max);
105-
const newSelected = new Set([newIndex]);
106-
return {
107-
anchorIndex: newIndex,
108-
focusedIndex: newIndex,
109-
lastShiftRange: { start: null, end: null },
110-
selected: newSelected,
111-
};
112-
}
113-
case 'select-index': {
114-
const newSelected = new Set([evt.index]);
115-
return {
116-
anchorIndex: evt.index,
117-
focusedIndex: evt.index,
118-
lastShiftRange: { start: null, end: null },
119-
selected: newSelected,
120-
};
121-
}
122-
case 'toggle-index': {
123-
const newSelected = new Set(prev.selected);
124-
if (newSelected.has(evt.index)) {
125-
newSelected.delete(evt.index);
126-
} else {
127-
newSelected.add(evt.index);
128-
}
129-
return {
130-
anchorIndex: evt.index,
131-
lastShiftRange: { start: null, end: null },
132-
focusedIndex: evt.index,
133-
selected: newSelected,
134-
};
135-
}
136-
case 'expand-selected-to-index': {
137-
const { anchorIndex, lastShiftRange } = prev;
138-
const newSelected = new Set(prev.selected);
139-
140-
// If there was a previous shift selection, remove it first
141-
if (lastShiftRange.start !== null && lastShiftRange.end !== null) {
142-
for (let i = lastShiftRange.start; i <= lastShiftRange.end; i++) {
143-
newSelected.delete(i);
144-
}
145-
}
146-
147-
// Calculate new range bounds from the anchor point
148-
const start = Math.min(anchorIndex ?? 0, evt.index);
149-
const end = Math.max(anchorIndex ?? 0, evt.index);
150-
151-
// Add all items in new range to selection
152-
for (let i = start; i <= end; i++) {
153-
newSelected.add(i);
154-
}
155-
156-
return {
157-
...prev,
158-
lastShiftRange: { start, end },
159-
focusedIndex: evt.index,
160-
selected: newSelected,
161-
};
162-
}
163-
case 'inc-or-dec-selected': {
164-
const { anchorIndex, lastShiftRange } = prev;
165-
// Handle shift+arrow selection
166-
const newSelected = new Set(prev.selected);
167-
168-
// Remove previous shift range
169-
if (lastShiftRange.start !== null && lastShiftRange.end !== null) {
170-
for (let i = lastShiftRange.start; i <= lastShiftRange.end; i++) {
171-
newSelected.delete(i);
172-
}
173-
}
174-
175-
// Calculate new range
176-
const start = Math.min(anchorIndex ?? evt.nextIndex, evt.nextIndex);
177-
const end = Math.max(anchorIndex ?? evt.nextIndex, evt.nextIndex);
178-
179-
// Add new range
180-
for (let i = start; i <= end; i++) {
181-
newSelected.add(i);
182-
}
183-
return {
184-
focusedIndex: evt.nextIndex,
185-
lastShiftRange: { start, end },
186-
anchorIndex: anchorIndex === null ? evt.nextIndex : anchorIndex,
187-
selected: newSelected,
188-
};
189-
}
190-
case 'increment-selection': {
191-
const { focusedIndex, anchorIndex, lastShiftRange } = prev;
192-
invariant(focusedIndex !== null);
193-
const delta = evt.direction === 'up' ? -1 : 1;
194-
const newIndex = Math.max(0, Math.min(evt.total - 1, focusedIndex + delta));
195-
196-
// Handle shift+arrow selection
197-
const newSelected = new Set(prev.selected);
198-
199-
// Remove previous shift range
200-
if (lastShiftRange.start !== null && lastShiftRange.end !== null) {
201-
for (let i = lastShiftRange.start; i <= lastShiftRange.end; i++) {
202-
newSelected.delete(i);
203-
}
204-
}
205-
206-
// Calculate new range
207-
const start = Math.min(anchorIndex ?? newIndex, newIndex);
208-
const end = Math.max(anchorIndex ?? newIndex, newIndex);
209-
210-
// Add new range
211-
for (let i = start; i <= end; i++) {
212-
newSelected.add(i);
213-
}
214-
return {
215-
focusedIndex: newIndex,
216-
lastShiftRange: { start, end },
217-
anchorIndex: anchorIndex === null ? newIndex : anchorIndex,
218-
selected: newSelected,
219-
};
220-
}
221-
default:
222-
return prev;
223-
}
224-
}
225-
22654
/**
22755
* Handle onClick + keydown events to support most interactions with the list.
22856
* @param {import('preact/hooks').MutableRef<HTMLElement|null>} mainRef

0 commit comments

Comments
 (0)