Skip to content

Commit f2e558c

Browse files
committed
history: adding multi-select
1 parent 8a043e3 commit f2e558c

File tree

5 files changed

+169
-13
lines changed

5 files changed

+169
-13
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Empty } from './Empty.js';
88
/**
99
* @param {object} props
1010
* @param {import("@preact/signals").Signal<import("../global-state/GlobalStateProvider.js").Results>} props.results
11-
* @param {import("@preact/signals").Signal<string[]>} props.selected
11+
* @param {import("@preact/signals").Signal<number[]>} props.selected
1212
*/
1313
export function Results({ results, selected }) {
1414
if (results.value.items.length === 0) {
@@ -23,7 +23,7 @@ export function Results({ results, selected }) {
2323
heights={results.value.heights}
2424
overscan={OVERSCAN_AMOUNT}
2525
renderItem={({ item, cssClassName, style, index }) => {
26-
const isSelected = selected.value.includes(item.id);
26+
const isSelected = selected.value.includes(index);
2727
return (
2828
<li key={item.id} data-id={item.id} class={cssClassName} style={style}>
2929
<Item

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

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import { h, createContext } from 'preact';
22
import { useContext } from 'preact/hooks';
33
import { signal, useSignal, useSignalEffect } from '@preact/signals';
4+
import { useQueryContext } from './QueryProvider.js';
5+
import { eventToIntention } from '../../../../shared/handlers.js';
6+
import { usePlatformName } from '../types.js';
7+
import { useGlobalState } from './GlobalStateProvider.js';
48

5-
const SelectionContext = createContext({
6-
selected: signal(/** @type {string[]} */ ([])),
7-
});
9+
/**
10+
* @typedef SelectionState
11+
* @property {import("@preact/signals").Signal<number[]>} selected
12+
*/
13+
14+
/**
15+
* @typedef {(s: (d: number[]) => number[]) => void} UpdateSelected
16+
*/
17+
18+
const SelectionContext = createContext(
19+
/** @type {SelectionState} */ ({
20+
selected: signal(/** @type {number[]} */ ([])),
21+
}),
22+
);
823

924
/**
1025
* Provides a context for the selections
@@ -13,24 +28,96 @@ const SelectionContext = createContext({
1328
* @param {import("preact").ComponentChild} props.children - The child components that will consume the history service context.
1429
*/
1530
export function SelectionProvider({ children }) {
16-
const selected = useSignal(/** @type {string[]} */ ([]));
31+
const selected = useSignal(/** @type {number[]} */ ([]));
32+
/** @type {UpdateSelected} */
33+
const update = (fn) => {
34+
selected.value = fn(selected.value);
35+
console.log(selected.value);
36+
};
37+
38+
useResetOnQueryChange(update);
39+
useRowClick(update, selected);
40+
41+
return <SelectionContext.Provider value={{ selected }}>{children}</SelectionContext.Provider>;
42+
}
43+
44+
/**
45+
* @param {UpdateSelected} update
46+
*/
47+
function useResetOnQueryChange(update) {
48+
const query = useQueryContext();
49+
useSignalEffect(() => {
50+
const unsubs = [
51+
// when anything about the query changes, reset selections
52+
query.subscribe(() => {
53+
update((prev) => []);
54+
}),
55+
];
56+
57+
return () => {
58+
for (const unsub of unsubs) {
59+
unsub();
60+
}
61+
};
62+
});
63+
}
64+
65+
/**
66+
* @param {UpdateSelected} update
67+
* @param {import("@preact/signals").Signal<number[]>} selected
68+
*/
69+
function useRowClick(update, selected) {
70+
const platformName = usePlatformName();
71+
const { results } = useGlobalState();
72+
const lastSelected = useSignal(/** @type {{index: number; id: string}|null} */ (null));
1773
useSignalEffect(() => {
1874
function handler(/** @type {MouseEvent} */ event) {
1975
if (!(event.target instanceof Element)) return;
20-
if (event.target.matches('a')) return;
2176
const itemRow = /** @type {HTMLElement|null} */ (event.target.closest('[data-history-entry][data-index]'));
2277
const selection = toRowSelection(itemRow);
23-
if (selection) {
24-
event.preventDefault();
25-
event.stopImmediatePropagation();
78+
if (!itemRow || !selection) return;
2679

27-
// MVP for getting the tests to pass. Next PRs will expand functionality
28-
selected.value = [selection.id];
80+
event.preventDefault();
81+
event.stopImmediatePropagation();
82+
83+
const intention = eventToIntention(event, platformName);
84+
const currentSelected = itemRow.getAttribute('aria-selected') === 'true';
85+
86+
switch (intention) {
87+
case 'click': {
88+
// MVP for getting the tests to pass. Next PRs will expand functionality
89+
update((prev) => [selection.index]);
90+
lastSelected.value = selection;
91+
break;
92+
}
93+
case 'ctrl+click': {
94+
update((prev) => {
95+
const index = prev.indexOf(selection.index);
96+
if (index > -1) {
97+
const next = prev.slice();
98+
next.splice(index, 1);
99+
return next;
100+
}
101+
return prev.concat(selection.index);
102+
});
103+
if (!currentSelected) {
104+
lastSelected.value = selection;
105+
} else {
106+
lastSelected.value = null;
107+
}
108+
break;
109+
}
110+
case 'shift+click': {
111+
// todo
112+
break;
113+
}
29114
}
30115
}
31116
document.addEventListener('click', handler);
117+
return () => {
118+
document.removeEventListener('click', handler);
119+
};
32120
});
33-
return <SelectionContext.Provider value={{ selected }}>{children}</SelectionContext.Provider>;
34121
}
35122

36123
// Hook for consuming the context

special-pages/pages/history/integration-tests/history-selections.spec.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,31 @@ test.describe('history selections', () => {
99
await hp.selectsRow(1);
1010
await hp.selectsRow(2);
1111
});
12+
test('resets selection with new query', async ({ page }, workerInfo) => {
13+
const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000);
14+
await hp.openPage({});
15+
await hp.selectsRow(0);
16+
await hp.types('example.com');
17+
await hp.rowIsNotSelected(0);
18+
});
19+
test('adds to selection', async ({ page }, workerInfo) => {
20+
const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000);
21+
await hp.openPage({});
22+
23+
await hp.selectsRow(0);
24+
await hp.selectsRowWithCtrl(1);
25+
26+
await hp.rowIsSelected(0);
27+
await hp.rowIsSelected(1);
28+
});
29+
test('removes from a selection', async ({ page }, workerInfo) => {
30+
const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000);
31+
await hp.openPage({});
32+
33+
await hp.selectsRow(0);
34+
await hp.selectsRowWithCtrl(1);
35+
await hp.selectsRowWithCtrl(1);
36+
await hp.rowIsSelected(0);
37+
await hp.rowIsNotSelected(1);
38+
});
1239
});

special-pages/pages/history/integration-tests/history.page.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,4 +343,31 @@ export class HistoryTestPage {
343343
await expect(rows.nth(nth)).toHaveAttribute('aria-selected', 'true');
344344
await expect(selected).toHaveCount(1);
345345
}
346+
347+
/**
348+
* @param {number} nth
349+
*/
350+
async rowIsSelected(nth) {
351+
const { page } = this;
352+
const rows = page.locator('main').locator('[aria-selected]');
353+
await expect(rows.nth(nth)).toHaveAttribute('aria-selected', 'true');
354+
}
355+
356+
/**
357+
* @param {number} nth
358+
*/
359+
async rowIsNotSelected(nth) {
360+
const { page } = this;
361+
const rows = page.locator('main').locator('[aria-selected]');
362+
await expect(rows.nth(nth)).toHaveAttribute('aria-selected', 'false');
363+
}
364+
365+
/**
366+
* @param {number} nth
367+
*/
368+
async selectsRowWithCtrl(nth) {
369+
const { page } = this;
370+
const rows = page.locator('main').locator('[aria-selected]');
371+
await rows.nth(nth).click({ modifiers: ['ControlOrMeta'] });
372+
}
346373
}

special-pages/shared/handlers.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,18 @@ export function eventToTarget(event, platformName) {
1212
}
1313
return 'same-tab';
1414
}
15+
16+
/**
17+
* @param {MouseEvent} event
18+
* @param {ImportMeta['platform']} platformName
19+
* @return {'ctrl+click' | 'shift+click' | 'click'}
20+
*/
21+
export function eventToIntention(event, platformName) {
22+
const isControlClick = platformName === 'macos' ? event.metaKey : event.ctrlKey;
23+
if (isControlClick) {
24+
return 'ctrl+click';
25+
} else if (event.shiftKey) {
26+
return 'shift+click';
27+
}
28+
return 'click';
29+
}

0 commit comments

Comments
 (0)