Skip to content

Commit d140264

Browse files
committed
POC
1 parent 1f07543 commit d140264

File tree

17 files changed

+474
-80
lines changed

17 files changed

+474
-80
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { h, createContext } from 'preact';
2+
import { useContext } from 'preact/hooks';
3+
import { useSearchContext } from './app/components/SearchForm.js';
4+
import { useSignalEffect } from '@preact/signals';
5+
import { paramsToQuery } from './history.service.js';
6+
import { OVERSCAN_AMOUNT } from './app/constants.js';
7+
8+
// Create the context
9+
const HistoryServiceContext = createContext({
10+
service: /** @type {import("./history.service").HistoryService} */ ({}),
11+
initial: /** @type {import("./history.service").ServiceData} */ ({}),
12+
});
13+
14+
// Provider component
15+
/**
16+
* Provides a context for the history service, allowing dependent components to access it.
17+
*
18+
* @param {Object} props - The properties object for the HistoryServiceProvider component.
19+
* @param {import("./history.service").HistoryService} props.service - The history service instance to be provided through the context.
20+
* @param {import("./history.service").ServiceData} props.initial - The history service instance to be provided through the context.
21+
* @param {import("preact").ComponentChild} props.children - The child components that will consume the history service context.
22+
*/
23+
export function HistoryServiceProvider({ service, initial, children }) {
24+
useSignalEffect(() => {
25+
let seen = 0;
26+
// Add a listener for the 'search-commit' event
27+
window.addEventListener('search-commit', (/** @type {CustomEvent<{params: URLSearchParams}>} */ event) => {
28+
const detail = event.detail;
29+
if (seen > 0) {
30+
if (detail && detail.params instanceof URLSearchParams) {
31+
const asQuery = paramsToQuery(detail.params);
32+
service.trigger(asQuery);
33+
} else {
34+
console.error('missing detail.params from search-commit event');
35+
}
36+
}
37+
seen += 1;
38+
});
39+
40+
// Cleanup the event listener on unmount
41+
return () => {
42+
window.removeEventListener('search-commit', this);
43+
};
44+
});
45+
46+
useSignalEffect(() => {
47+
function handler(/** @type {CustomEvent<{start: number, end: number}>} */ event) {
48+
if (!service.query.data) throw new Error('unreachable');
49+
const { end } = event.detail;
50+
const memory = service.query.data.results;
51+
if (memory.length - end < OVERSCAN_AMOUNT) {
52+
service.requestMore();
53+
}
54+
}
55+
window.addEventListener('range-change', handler);
56+
return () => {
57+
window.removeEventListener('range-change', handler);
58+
};
59+
});
60+
return <HistoryServiceContext.Provider value={{ service, initial }}>{children}</HistoryServiceContext.Provider>;
61+
}
62+
63+
// Hook for consuming the context
64+
export function useHistory() {
65+
const context = useContext(HistoryServiceContext);
66+
if (!context) {
67+
throw new Error('useHistoryService must be used within a HistoryServiceProvider');
68+
}
69+
return context;
70+
}

special-pages/pages/history/app/components/App.jsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import styles from './App.module.css';
33
import { useTypedTranslation } from '../types.js';
44
import { useEnv } from '../../../../shared/components/EnvironmentProvider.js';
55
import { Header } from './Header.js';
6-
import { useSignal } from '@preact/signals';
6+
import { batch, useSignal, useSignalEffect } from '@preact/signals';
77
import { Results } from './Results.js';
88
import { useRef } from 'preact/hooks';
9+
import { SearchProvider } from './SearchForm.js';
10+
import { useHistory } from '../../HistoryProvider.js';
11+
import { generateHeights } from '../utils.js';
912

1013
/**
1114
* @typedef {object} Results
@@ -16,21 +19,47 @@ import { useRef } from 'preact/hooks';
1619
export function App() {
1720
const { t } = useTypedTranslation();
1821
const { isDarkMode } = useEnv();
19-
const containerRef = useRef(null);
22+
const containerRef = useRef(/** @type {HTMLElement|null} */ (null));
23+
const { initial, service } = useHistory();
24+
2025
const results = useSignal({
21-
items: [],
22-
heights: [],
26+
items: initial.query.results,
27+
heights: generateHeights(initial.query.results),
28+
});
29+
30+
const term = useSignal(initial.query.info.term);
31+
32+
useSignalEffect(() => {
33+
const unsub = service.onResults((data) => {
34+
batch(() => {
35+
term.value = data.info.term;
36+
results.value = {
37+
items: data.results,
38+
heights: generateHeights(data.results),
39+
};
40+
});
41+
});
42+
return () => {
43+
unsub();
44+
};
2345
});
46+
47+
useSignalEffect(() => {
48+
term.subscribe((t) => {
49+
containerRef.current?.scrollTo(0, 0);
50+
});
51+
});
52+
2453
return (
2554
<div class={styles.layout} data-theme={isDarkMode ? 'dark' : 'light'}>
2655
<header class={styles.header}>
27-
<Header results={results} />
56+
<Header />
2857
</header>
2958
<aside class={styles.aside}>
3059
<h1 class={styles.pageTitle}>History</h1>
3160
</aside>
32-
<main class={styles.main} ref={containerRef} data-main-scroller>
33-
<Results results={results} containerRef={containerRef} />
61+
<main class={styles.main} ref={containerRef} data-main-scroller data-term={term}>
62+
<Results results={results} term={term} containerRef={containerRef} />
3463
</main>
3564
</div>
3665
);

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

Lines changed: 6 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,15 @@
11
import styles from './Header.module.css';
22
import { Fire } from '../icons/Fire.js';
33
import { h } from 'preact';
4-
import { useMessaging, useTypedTranslation } from '../types.js';
54
import { Cross } from '../icons/Cross.js';
6-
import { useSignalEffect } from '@preact/signals';
7-
import { generateHeights } from '../utils.js';
5+
import { useComputed } from '@preact/signals';
6+
import { SearchForm, useSearchContext } from './SearchForm.js';
87

98
/**
10-
* @param {object} props
11-
* @param {import("@preact/signals").Signal<import("./App.jsx").Results>} props.results
129
*/
13-
export function Header({ results }) {
14-
const { t } = useTypedTranslation();
15-
const historyPage = useMessaging();
16-
useSignalEffect(() => {
17-
historyPage
18-
.query({ term: '', limit: 150, offset: 0 })
19-
// eslint-disable-next-line promise/prefer-await-to-then
20-
.then((next) => {
21-
const combined = results.value.items.concat(next.value);
22-
results.value = {
23-
items: combined,
24-
heights: generateHeights(combined),
25-
};
26-
})
27-
// eslint-disable-next-line promise/prefer-await-to-then
28-
.catch((e) => {
29-
console.log('did catch...', e);
30-
});
31-
let fetching = false;
32-
function handler(/** @type {CustomEvent<{start: number, end: number}>} */ event) {
33-
const { end } = event.detail;
34-
if (results.value.items.length - end < 20) {
35-
if (fetching) return;
36-
fetching = true;
37-
historyPage
38-
.query({ term: '', limit: 150, offset: results.value.items.length })
39-
// eslint-disable-next-line promise/prefer-await-to-then
40-
.then((next) => {
41-
const combined = results.value.items.concat(next.value);
42-
results.value = {
43-
items: combined,
44-
heights: generateHeights(combined),
45-
};
46-
fetching = false;
47-
})
48-
// eslint-disable-next-line promise/prefer-await-to-then
49-
.catch((e) => {
50-
console.log('did catch...', e);
51-
});
52-
}
53-
}
54-
window.addEventListener('range-change', handler);
55-
return () => {
56-
window.removeEventListener('range-change', handler);
57-
};
58-
});
10+
export function Header() {
11+
const search = useSearchContext();
12+
const term = useComputed(() => search.value.term);
5913
return (
6014
<div class={styles.root}>
6115
<div class={styles.controls}>
@@ -69,14 +23,7 @@ export function Header({ results }) {
6923
</button>
7024
</div>
7125
<div class={styles.search}>
72-
<form
73-
action=""
74-
onSubmit={(e) => {
75-
e.preventDefault();
76-
}}
77-
>
78-
<input type="search" placeholder={t('search')} class={styles.searchInput} name="term" />
79-
</form>
26+
<SearchForm term={term} />
8027
</div>
8128
</div>
8229
);
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import { h } from 'preact';
22
import { VirtualizedHistoryList } from './VirtualizedHistoryList.js';
3+
import { OVERSCAN_AMOUNT } from '../constants.js';
34

45
/**
56
* @param {object} props
67
* @param {import("@preact/signals").Signal<import("./App.jsx").Results>} props.results
8+
* @param {import("@preact/signals").Signal<string>} props.term
79
* @param {any} props.containerRef
810
*/
9-
export function Results({ results, containerRef }) {
11+
export function Results({ results, term, containerRef }) {
1012
return (
1113
<div>
12-
<VirtualizedHistoryList items={results.value.items} heights={results.value.heights} containerRef={containerRef} />
14+
<VirtualizedHistoryList
15+
items={results.value.items}
16+
heights={results.value.heights}
17+
containerRef={containerRef}
18+
overscan={OVERSCAN_AMOUNT}
19+
/>
1320
</div>
1421
);
1522
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import styles from './Header.module.css';
2+
import { createContext, h } from 'preact';
3+
import { useTypedTranslation } from '../types.js';
4+
import { signal, useSignal, useSignalEffect } from '@preact/signals';
5+
import { useContext } from 'preact/hooks';
6+
7+
/**
8+
* @param {object} props
9+
* @param {import("@preact/signals").Signal<string>} props.term
10+
*/
11+
export function SearchForm({ term }) {
12+
const { t } = useTypedTranslation();
13+
// const onInput = (event) => (term.value = event.currentTarget.value);
14+
return (
15+
<form role="search">
16+
<label>
17+
<span class="sr-only">{t('search_your_history')}</span>
18+
<input type="search" placeholder={t('search')} className={styles.searchInput} name="term" value={term} />
19+
</label>
20+
</form>
21+
);
22+
}
23+
24+
const SearchContext = createContext(signal({ term: '' }));
25+
26+
/**
27+
* A custom hook to access the SearchContext.
28+
*
29+
* @returns {import('@preact/signals').Signal<{ term: string }>} The current search context value.
30+
*/
31+
export function useSearchContext() {
32+
return useContext(SearchContext);
33+
}
34+
35+
/**
36+
* A provider component that sets up the search context for its children. Allows access to and updates of the search term within the context.
37+
*
38+
* @param {Object} props - The props object for the component.
39+
* @param {import("preact").ComponentChild} props.children - The child components wrapped within the provider.
40+
* @param {string} [props.term=''] - The initial search term for the context.
41+
*/
42+
export function SearchProvider({ children, term = '' }) {
43+
const searchState = useSignal({ term });
44+
useSignalEffect(() => {
45+
const controller = new AbortController();
46+
47+
// @ts-expect-error - later
48+
window._accept = (v) => {
49+
searchState.value = { ...searchState.value, term: v };
50+
};
51+
52+
document.addEventListener(
53+
'submit',
54+
(e) => {
55+
e.preventDefault();
56+
console.log('re-issue search plz', [searchState.value.term]);
57+
},
58+
{ signal: controller.signal },
59+
);
60+
61+
document.addEventListener(
62+
'input',
63+
(e) => {
64+
if (e.target instanceof HTMLInputElement) {
65+
searchState.value = { ...searchState.value, term: e.target.value };
66+
}
67+
},
68+
{ signal: controller.signal },
69+
);
70+
71+
return () => {
72+
controller.abort();
73+
};
74+
});
75+
76+
useSignalEffect(() => {
77+
let timer;
78+
const sub = searchState.subscribe((v) => {
79+
clearTimeout(timer);
80+
timer = setTimeout(() => {
81+
const url = new URL(window.location.href);
82+
url.searchParams.delete('q');
83+
if (searchState.value.term) {
84+
console.log('update URL with term:', searchState.value.term);
85+
url.searchParams.set('q', searchState.value.term);
86+
window.history.replaceState(null, '', url.toString());
87+
} else {
88+
const url = new URL(window.location.href);
89+
window.history.replaceState(null, '', url.toString());
90+
}
91+
window.dispatchEvent(new CustomEvent('search-commit', { detail: { params: new URLSearchParams(url.searchParams) } }));
92+
}, 500);
93+
});
94+
95+
return () => {
96+
sub();
97+
clearTimeout(timer);
98+
};
99+
});
100+
101+
return <SearchContext.Provider value={searchState}>{children}</SearchContext.Provider>;
102+
}

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ export const VirtualizedHistoryList = memo(
99
* @param {object} props
1010
* @param {import('../../types/history').HistoryItem[]} props.items
1111
* @param {number[]} props.heights
12+
* @param {number} props.overscan
1213
* @param {any} props.containerRef
1314
*/
14-
function VirtualizedPreact({ items, heights, containerRef }) {
15+
function VirtualizedPreact({ items, heights, overscan, containerRef }) {
1516
const totalHeight = heights.reduce((acc, item) => acc + item, 0);
1617
return (
1718
<div class={styles.container} style={{ height: totalHeight + 'px' }}>
18-
<Inner items={items} heights={heights} containerRef={containerRef} />
19+
<Inner items={items} heights={heights} containerRef={containerRef} overscan={overscan} />
1920
</div>
2021
);
2122
},
@@ -26,10 +27,11 @@ const Inner = memo(
2627
* @param {object} props
2728
* @param {import("../../types/history").HistoryItem[]} props.items
2829
* @param {number[]} props.heights
30+
* @param {number} props.overscan
2931
* @param {any} props.containerRef
3032
*/
31-
function Inner({ items, heights, containerRef }) {
32-
const { start, end } = useVisibleRows(items, heights, containerRef);
33+
function Inner({ items, heights, overscan, containerRef }) {
34+
const { start, end } = useVisibleRows(items, heights, containerRef, overscan);
3335
const subset = items.slice(start, end + 1);
3436
return (
3537
<ul>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const OVERSCAN_AMOUNT = 5;

0 commit comments

Comments
 (0)