diff --git a/special-pages/pages/history/app/HistoryProvider.js b/special-pages/pages/history/app/HistoryProvider.js
deleted file mode 100644
index b1fa8c7d4..000000000
--- a/special-pages/pages/history/app/HistoryProvider.js
+++ /dev/null
@@ -1,166 +0,0 @@
-import { h, createContext } from 'preact';
-import { useContext } from 'preact/hooks';
-import { useSignalEffect } from '@preact/signals';
-import { paramsToQuery, toRange } from './history.service.js';
-import { OVERSCAN_AMOUNT } from './constants.js';
-import { usePlatformName } from './types.js';
-import { eventToTarget } from '../../../shared/handlers.js';
-
-// Create the context
-const HistoryServiceContext = createContext({
- service: /** @type {import("./history.service").HistoryService} */ ({}),
- initial: /** @type {import("./history.service").ServiceData} */ ({}),
-});
-
-// Provider component
-/**
- * Provides a context for the history service, allowing dependent components to access it.
- *
- * @param {Object} props - The properties object for the HistoryServiceProvider component.
- * @param {import("./history.service").HistoryService} props.service - The history service instance to be provided through the context.
- * @param {import("./history.service").ServiceData} props.initial - The history service instance to be provided through the context.
- * @param {import("preact").ComponentChild} props.children - The child components that will consume the history service context.
- */
-export function HistoryServiceProvider({ service, initial, children }) {
- const platFormName = usePlatformName();
- useSignalEffect(() => {
- // Add a listener for the 'search-commit' event
- window.addEventListener('search-commit', (/** @type {CustomEvent<{params: URLSearchParams}>} */ event) => {
- const detail = event.detail;
- if (detail && detail.params instanceof URLSearchParams) {
- const asQuery = paramsToQuery(detail.params);
- service.trigger(asQuery);
- } else {
- console.error('missing detail.params from search-commit event');
- }
- });
-
- // Cleanup the event listener on unmount
- return () => {
- window.removeEventListener('search-commit', this);
- };
- });
-
- useSignalEffect(() => {
- function handler(/** @type {CustomEvent<{start: number, end: number}>} */ event) {
- if (!service.query.data) throw new Error('unreachable');
- const { end } = event.detail;
- const memory = service.query.data.results;
- if (memory.length - end < OVERSCAN_AMOUNT) {
- service.requestMore();
- }
- }
- window.addEventListener('range-change', handler);
- return () => {
- window.removeEventListener('range-change', handler);
- };
- });
-
- useSignalEffect(() => {
- function handler(/** @type {MouseEvent} */ event) {
- if (!(event.target instanceof Element)) return;
- const btn = /** @type {HTMLButtonElement|null} */ (event.target.closest('button'));
- const anchor = /** @type {HTMLButtonElement|null} */ (event.target.closest('a[href][data-url]'));
- if (btn?.dataset.titleMenu) {
- event.stopImmediatePropagation();
- event.preventDefault();
- // eslint-disable-next-line promise/prefer-await-to-then
- service.menuTitle(btn.value).catch(console.error);
- return;
- }
- if (btn) {
- if (btn?.dataset.rowMenu) {
- event.stopImmediatePropagation();
- event.preventDefault();
- // eslint-disable-next-line promise/prefer-await-to-then
- service.entriesMenu([btn.value], [Number(btn.dataset.index)]).catch(console.error);
- return;
- }
- if (btn?.dataset.deleteRange) {
- event.stopImmediatePropagation();
- event.preventDefault();
- const range = toRange(btn.value);
- if (range) {
- // eslint-disable-next-line promise/prefer-await-to-then
- service.deleteRange(range).catch(console.error);
- }
- }
- if (btn?.dataset.deleteAll) {
- event.stopImmediatePropagation();
- event.preventDefault();
- // eslint-disable-next-line promise/prefer-await-to-then
- service.deleteRange('all').catch(console.error);
- }
- } else if (anchor) {
- const url = anchor.dataset.url;
- if (!url) return;
- event.preventDefault();
- event.stopImmediatePropagation();
- const target = eventToTarget(event, platFormName);
- service.openUrl(url, target);
- return;
- }
- return null;
- }
- document.addEventListener('click', handler);
-
- const handleAuxClick = (event) => {
- const anchor = /** @type {HTMLButtonElement|null} */ (event.target.closest('a[href][data-url]'));
- const url = anchor?.dataset.url;
- if (anchor && url && event.button === 1) {
- event.preventDefault();
- event.stopImmediatePropagation();
- const target = eventToTarget(event, platFormName);
- service.openUrl(url, target);
- }
- };
- document.addEventListener('auxclick', handleAuxClick);
-
- function contextMenu(event) {
- const target = /** @type {HTMLElement|null} */ (event.target);
- if (!(target instanceof HTMLElement)) return;
-
- const actions = {
- '[data-section-title]': (elem) => elem.querySelector('button')?.value,
- '[data-history-entry]': (elem) => elem.querySelector('button')?.value,
- };
-
- for (const [selector, valueFn] of Object.entries(actions)) {
- const match = event.target.closest(selector);
- if (match) {
- const value = valueFn(match);
- if (value) {
- event.preventDefault();
- event.stopImmediatePropagation();
- if (match.dataset.sectionTitle) {
- // eslint-disable-next-line promise/prefer-await-to-then
- service.menuTitle(value).catch(console.error);
- } else if (match.dataset.historyEntry) {
- // eslint-disable-next-line promise/prefer-await-to-then
- service.entriesMenu([value], [Number(match.dataset.index)]).catch(console.error);
- }
- }
- break;
- }
- }
- }
-
- document.addEventListener('contextmenu', contextMenu);
-
- return () => {
- document.removeEventListener('auxclick', handleAuxClick);
- document.removeEventListener('click', handler);
- document.removeEventListener('contextmenu', contextMenu);
- };
- });
- return {children} ;
-}
-
-// Hook for consuming the context
-export function useHistory() {
- const context = useContext(HistoryServiceContext);
- if (!context) {
- throw new Error('useHistoryService must be used within a HistoryServiceProvider');
- }
- return context;
-}
diff --git a/special-pages/pages/history/app/components/App.jsx b/special-pages/pages/history/app/components/App.jsx
index 04b082a12..17965c092 100644
--- a/special-pages/pages/history/app/components/App.jsx
+++ b/special-pages/pages/history/app/components/App.jsx
@@ -2,60 +2,20 @@ import { h } from 'preact';
import styles from './App.module.css';
import { useEnv } from '../../../../shared/components/EnvironmentProvider.js';
import { Header } from './Header.js';
-import { batch, useSignal, useSignalEffect } from '@preact/signals';
import { Results } from './Results.js';
import { useRef } from 'preact/hooks';
-import { useHistory } from '../HistoryProvider.js';
-import { generateHeights } from '../utils.js';
import { Sidebar } from './Sidebar.js';
-
-/**
- * @typedef {object} Results
- * @property {import('../../types/history').HistoryItem[]} items
- * @property {number[]} heights
- */
+import { useGlobalState } from '../global-state/GlobalStateProvider.js';
+import { useSelected } from '../global-state/SelectionProvider.js';
+import { useGlobalHandlers } from '../global-state/HistoryServiceProvider.js';
export function App() {
const { isDarkMode } = useEnv();
const containerRef = useRef(/** @type {HTMLElement|null} */ (null));
- const { initial, service } = useHistory();
-
- // NOTE: These states will get extracted out later, once I know all the use-cases
- const ranges = useSignal(initial.ranges.ranges);
- const term = useSignal('term' in initial.query.info.query ? initial.query.info.query.term : '');
- const results = useSignal({
- items: initial.query.results,
- heights: generateHeights(initial.query.results),
- });
-
- useSignalEffect(() => {
- const unsub = service.onResults((data) => {
- batch(() => {
- if ('term' in data.info.query && data.info.query.term !== null) {
- term.value = data.info.query.term;
- }
- results.value = {
- items: data.results,
- heights: generateHeights(data.results),
- };
- });
- });
-
- // Subscribe to changes in the 'ranges' data and reflect the updates into the UI
- const unsubRanges = service.onRanges((data) => {
- ranges.value = data.ranges;
- });
- return () => {
- unsub();
- unsubRanges();
- };
- });
+ const { ranges, term, results } = useGlobalState();
+ const selected = useSelected();
- useSignalEffect(() => {
- term.subscribe((t) => {
- containerRef.current?.scrollTo(0, 0);
- });
- });
+ useGlobalHandlers();
return (
@@ -66,7 +26,7 @@ export function App() {
-
+
);
diff --git a/special-pages/pages/history/app/components/Empty.js b/special-pages/pages/history/app/components/Empty.js
new file mode 100644
index 000000000..637f0bfab
--- /dev/null
+++ b/special-pages/pages/history/app/components/Empty.js
@@ -0,0 +1,17 @@
+import { h } from 'preact';
+import { useTypedTranslation } from '../types.js';
+import cn from 'classnames';
+import styles from './VirtualizedList.module.css';
+
+/**
+ * Empty state component displayed when no results are available
+ */
+export function Empty() {
+ const { t } = useTypedTranslation();
+ return (
+
+
+
{t('empty_title')}
+
+ );
+}
diff --git a/special-pages/pages/history/app/components/Header.js b/special-pages/pages/history/app/components/Header.js
index 0cac81bf1..0ad2a8e26 100644
--- a/special-pages/pages/history/app/components/Header.js
+++ b/special-pages/pages/history/app/components/Header.js
@@ -1,20 +1,22 @@
import styles from './Header.module.css';
import { h } from 'preact';
import { useComputed } from '@preact/signals';
-import { SearchForm, useSearchContext } from './SearchForm.js';
+import { SearchForm } from './SearchForm.js';
import { Trash } from '../icons/Trash.js';
import { useTypedTranslation } from '../types.js';
+import { useQueryContext } from '../global-state/QueryProvider.js';
+import { BTN_ACTION_DELETE_ALL } from '../constants.js';
/**
*/
export function Header() {
const { t } = useTypedTranslation();
- const search = useSearchContext();
+ const search = useQueryContext();
const term = useComputed(() => search.value.term);
return (
-
+
{t('delete_all')}
diff --git a/special-pages/pages/history/app/components/Item.js b/special-pages/pages/history/app/components/Item.js
index 1b1681a71..8afa6a479 100644
--- a/special-pages/pages/history/app/components/Item.js
+++ b/special-pages/pages/history/app/components/Item.js
@@ -5,6 +5,7 @@ import { Fragment, h } from 'preact';
import styles from './Item.module.css';
import { Dots } from '../icons/dots.js';
import { useTypedTranslation } from '../types.js';
+import { BTN_ACTION_ENTRIES_MENU, BTN_ACTION_TITLE_MENU } from '../constants.js';
export const Item = memo(
/**
@@ -19,31 +20,38 @@ export const Item = memo(
* @param {string} props.dateRelativeDay - The relative day information to display (shown when kind is equal to TITLE_KIND).
* @param {string} props.dateTimeOfDay - the time of day, like 11.00am.
* @param {number} props.index - original index
+ * @param {boolean} props.selected - whether this item is selected
*/
- function Item({ id, url, domain, title, kind, dateRelativeDay, dateTimeOfDay, index }) {
+ function Item({ id, url, domain, title, kind, dateRelativeDay, dateTimeOfDay, index, selected }) {
const { t } = useTypedTranslation();
return (
{kind === TITLE_KIND && (
-
+
{dateRelativeDay}
)}
-
+
{title}
{domain}
{dateTimeOfDay}
-
+
diff --git a/special-pages/pages/history/app/components/Item.module.css b/special-pages/pages/history/app/components/Item.module.css
index e80be9d14..9a4deb7bc 100644
--- a/special-pages/pages/history/app/components/Item.module.css
+++ b/special-pages/pages/history/app/components/Item.module.css
@@ -14,16 +14,6 @@
padding-left: 8px;
border-radius: 5px;
position: relative;
-
- &:hover {
- background: var(--color-black-at-6);
- }
- [data-theme="dark"] &:hover {
- background: var(--color-white-at-6);
- .titleDots {
- color: var(--color-white-at-84)
- }
- }
}
.row {
@@ -31,15 +21,44 @@
display: flex;
gap: 8px;
align-items: center;
- color: var(--history-text-normal);
border-radius: 5px;
padding-left: 9px;
padding-right: 5px;
position: relative;
+}
- &:hover, &:focus-visible {
- background: #2565D9;
- color: var(--color-white-at-84);
+.hover {
+ --row-bg: inherit;
+ --row-color: var(--history-text-normal);
+ --dots-bg-hover: var(--color-black-at-9);
+ --dots-opacity: 0;
+ --time-opacity: 0.6;
+ --time-visibility: visible;
+
+ background: var(--row-bg);
+ color: var(--row-color);
+
+ &:hover, &:focus-within {
+ --dots-opacity: visible;
+ --time-opacity: 0;
+ --time-visibility: hidden;
+ }
+
+ &:hover:not([aria-selected="true"]) {
+ --row-bg: var(--color-black-at-6);
+ [data-theme="dark"] & {
+ --row-bg: var(--color-white-at-6);
+ }
+ }
+
+ [data-theme="dark"] & {
+ --dots-bg-hover: var(--color-white-at-12);
+ }
+
+ &[aria-selected="true"] {
+ --row-bg: #2565D9;
+ --row-color: var(--color-white-at-84);
+ --dots-bg-hover: var(--color-white-at-9);
}
}
@@ -71,17 +90,11 @@
.time {
margin-left: auto;
flex-shrink: 0;
- opacity: 0.6;
-
- .row:hover &, .row:focus-visible & {
- opacity: 0;
- visibility: hidden;
- }
+ opacity: var(--time-opacity);
+ visibility: var(--time-visibility);
}
.dots {
- opacity: 0;
- visibility: hidden;
position: absolute;
top: 50%;
transform: translateY(-50%);
@@ -92,6 +105,8 @@
background: transparent;
border: 0;
z-index:1;
+ color: inherit;
+ opacity: var(--dots-opacity);
svg {
width: 16px;
@@ -99,29 +114,11 @@
}
&:hover {
- background: var(--color-white-at-9);
- }
- &:active {
- background: var(--color-white-at-18);
- }
-
- .row:hover &, .row:focus-visible & {
- opacity: 1;
- visibility: visible;
- color: var(--color-white-at-84);
+ background: var(--dots-bg-hover);
}
- .title:hover & {
+ &:focus-visible {
opacity: 1;
- visibility: visible;
- }
-
- .title &:hover {
- background: var(--color-black-at-6);
- }
-
- .title &:active {
- background: var(--color-black-at-9);
}
}
@@ -129,7 +126,6 @@
width: 28px;
height: 20px;
right: 6px;
- /*color: */
}
.last {
diff --git a/special-pages/pages/history/app/components/Results.js b/special-pages/pages/history/app/components/Results.js
index ad3253cf7..781f7f35d 100644
--- a/special-pages/pages/history/app/components/Results.js
+++ b/special-pages/pages/history/app/components/Results.js
@@ -1,16 +1,16 @@
import { h } from 'preact';
-import cn from 'classnames';
import { OVERSCAN_AMOUNT } from '../constants.js';
import { Item } from './Item.js';
import styles from './VirtualizedList.module.css';
import { VisibleItems } from './VirtualizedList.js';
-import { useTypedTranslation } from '../types.js';
+import { Empty } from './Empty.js';
/**
* @param {object} props
- * @param {import("@preact/signals").Signal
} props.results
+ * @param {import("@preact/signals").Signal} props.results
+ * @param {import("@preact/signals").Signal} props.selected
*/
-export function Results({ results }) {
+export function Results({ results, selected }) {
if (results.value.items.length === 0) {
return ;
}
@@ -23,6 +23,7 @@ export function Results({ results }) {
heights={results.value.heights}
overscan={OVERSCAN_AMOUNT}
renderItem={({ item, cssClassName, style, index }) => {
+ const isSelected = selected.value.includes(item.id);
return (
);
@@ -42,16 +44,3 @@ export function Results({ results }) {
);
}
-
-/**
- * Empty state component displayed when no results are available
- */
-function Empty() {
- const { t } = useTypedTranslation();
- return (
-
-
-
{t('empty_title')}
-
- );
-}
diff --git a/special-pages/pages/history/app/components/SearchForm.js b/special-pages/pages/history/app/components/SearchForm.js
index d929e9746..0695fcf09 100644
--- a/special-pages/pages/history/app/components/SearchForm.js
+++ b/special-pages/pages/history/app/components/SearchForm.js
@@ -1,9 +1,6 @@
import styles from './Header.module.css';
-import { createContext, h } from 'preact';
-import { usePlatformName, useSettings, useTypedTranslation } from '../types.js';
-import { signal, useComputed, useSignal, useSignalEffect } from '@preact/signals';
-import { useContext } from 'preact/hooks';
-import { toRange } from '../history.service.js';
+import { h } from 'preact';
+import { useTypedTranslation } from '../types.js';
/**
* @param {object} props
@@ -21,184 +18,3 @@ export function SearchForm({ term }) {
);
}
-
-const SearchContext = createContext(
- signal({
- term: /** @type {string|null} */ (null),
- range: /** @type {import('../../types/history.js').Range|null} */ (null),
- domain: /** @type {string|null} */ (null),
- }),
-);
-
-/**
- * A custom hook to access the SearchContext.
- */
-export function useSearchContext() {
- return useContext(SearchContext);
-}
-
-/**
- * A provider component that sets up the search context for its children. Allows access to and updates of the search term within the context.
- *
- * @param {Object} props - The props object for the component.
- * @param {import("preact").ComponentChild} props.children - The child components wrapped within the provider.
- * @param {import('../../types/history.js').QueryKind} [props.query=''] - The initial search term for the context.
- */
-export function SearchProvider({ children, query = { term: '' } }) {
- const initial = {
- term: 'term' in query ? query.term : null,
- range: 'range' in query ? query.range : null,
- domain: 'domain' in query ? query.domain : null,
- };
- const searchState = useSignal(initial);
- const derivedTerm = useComputed(() => searchState.value.term);
- const derivedRange = useComputed(() => searchState.value.range);
- const settings = useSettings();
- const platformName = usePlatformName();
- // todo: domain search
- // const derivedDomain = useComputed(() => searchState.value.domain);
- useSignalEffect(() => {
- const controller = new AbortController();
-
- // @ts-expect-error - later
- window._accept = (v) => {
- searchState.value = { ...searchState.value, term: v };
- };
-
- document.addEventListener(
- 'submit',
- (e) => {
- e.preventDefault();
- console.log('re-issue search plz', [searchState.value.term]);
- },
- { signal: controller.signal },
- );
-
- document.addEventListener(
- 'input',
- (e) => {
- if (e.target instanceof HTMLInputElement && e.target.form instanceof HTMLFormElement) {
- const data = new FormData(e.target.form);
- const q = data.get('q')?.toString();
- if (q === undefined) return console.log('missing q field');
- searchState.value = {
- term: q,
- range: null,
- domain: null,
- };
- }
- },
- { signal: controller.signal },
- );
-
- document.addEventListener('click', (e) => {
- if (!(e.target instanceof HTMLElement)) return;
- const anchor = /** @type {HTMLAnchorElement|null} */ (e.target.closest('a[data-filter]'));
- if (anchor) {
- e.preventDefault();
- const range = toRange(anchor.dataset.filter);
- // todo: where should this rule live?
- if (range === 'all') {
- searchState.value = {
- term: '',
- domain: null,
- range: null,
- };
- } else if (range) {
- searchState.value = {
- term: null,
- domain: null,
- range,
- };
- }
- }
- });
-
- const keydown = (e) => {
- const isMacOS = platformName === 'macos';
- const isFindShortcutMacOS = isMacOS && e.metaKey && e.key === 'f';
- const isFindShortcutWindows = !isMacOS && e.ctrlKey && e.key === 'f';
-
- if (isFindShortcutMacOS || isFindShortcutWindows) {
- e.preventDefault();
- const searchInput = /** @type {HTMLInputElement|null} */ (document.querySelector(`input[type="search"]`));
- if (searchInput) {
- searchInput.focus();
- }
- }
- };
-
- document.addEventListener('keydown', keydown);
-
- return () => {
- document.removeEventListener('keydown', keydown);
- controller.abort();
- };
- });
-
- useSignalEffect(() => {
- let timer;
- let counter = 0;
- const sub = derivedTerm.subscribe((nextValue) => {
- if (counter === 0) {
- counter += 1;
- return;
- }
- clearTimeout(timer);
- timer = setTimeout(() => {
- console.log('next VALUE', [nextValue]);
- const url = new URL(window.location.href);
-
- url.searchParams.delete('q');
- url.searchParams.delete('range');
-
- if (nextValue) {
- url.searchParams.set('q', nextValue);
- window.history.replaceState(null, '', url.toString());
- } else if (nextValue === '') {
- window.history.replaceState(null, '', url.toString());
- }
- if (nextValue === null) {
- /** no-op */
- } else {
- // console.log('will dispatch it', url.searchParams.get('q'));
- window.dispatchEvent(new CustomEvent('search-commit', { detail: { params: new URLSearchParams(url.searchParams) } }));
- }
- }, settings.typingDebounce);
- });
-
- return () => {
- sub();
- clearTimeout(timer);
- };
- });
-
- useSignalEffect(() => {
- let timer;
- let counter = 0;
- const sub = derivedRange.subscribe((nextRange) => {
- if (counter === 0) {
- counter += 1;
- return;
- }
- // window.dispatchEvent(new CustomEvent('search-commit', { detail: { params: new URLSearchParams(url.searchParams) } }));
- const url = new URL(window.location.href);
-
- url.searchParams.delete('q');
- url.searchParams.delete('range');
-
- if (nextRange !== null) {
- url.searchParams.set('range', nextRange);
- window.history.replaceState(null, '', url.toString());
- window.dispatchEvent(new CustomEvent('search-commit', { detail: { params: new URLSearchParams(url.searchParams) } }));
- }
- });
-
- return () => {
- sub();
- clearTimeout(timer);
- };
- });
-
- return {children} ;
-}
diff --git a/special-pages/pages/history/app/components/Sidebar.js b/special-pages/pages/history/app/components/Sidebar.js
index 81158637a..c636fe92e 100644
--- a/special-pages/pages/history/app/components/Sidebar.js
+++ b/special-pages/pages/history/app/components/Sidebar.js
@@ -1,11 +1,12 @@
import { h } from 'preact';
import cn from 'classnames';
import styles from './Sidebar.module.css';
-import { useSearchContext } from './SearchForm.js';
import { useComputed } from '@preact/signals';
import { useTypedTranslation } from '../types.js';
import { Trash } from '../icons/Trash.js';
import { useTypedTranslationWith } from '../../../new-tab/app/types.js';
+import { useQueryContext } from '../global-state/QueryProvider.js';
+import { BTN_ACTION_DELETE_RANGE } from '../constants.js';
/**
* @import json from "../strings.json"
@@ -50,7 +51,7 @@ const titleMap = {
*/
export function Sidebar({ ranges }) {
const { t } = useTypedTranslation();
- const search = useSearchContext();
+ const search = useQueryContext();
const current = useComputed(() => search.value.range);
return (
@@ -106,7 +107,7 @@ function Item({ range, title, current }) {
{title}
-
+
diff --git a/special-pages/pages/history/app/components/VirtualizedList.js b/special-pages/pages/history/app/components/VirtualizedList.js
index dfaa1704c..65635eb52 100644
--- a/special-pages/pages/history/app/components/VirtualizedList.js
+++ b/special-pages/pages/history/app/components/VirtualizedList.js
@@ -2,6 +2,7 @@ import { Fragment, h } from 'preact';
import { memo } from 'preact/compat';
import styles from './VirtualizedList.module.css';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
+import { EVENT_RANGE_CHANGE } from '../constants.js';
/**
* @template T
@@ -90,7 +91,7 @@ function useVisibleRows(rows, heights, scrollerSelector, overscan = 5) {
setVisibleRange((prev) => {
if (withOverScan.start !== prev.start || withOverScan.end !== prev.end) {
// todo: find a better place to emit this!
- window.dispatchEvent(new CustomEvent('range-change', { detail: { start: withOverScan.start, end: withOverScan.end } }));
+ window.dispatchEvent(new CustomEvent(EVENT_RANGE_CHANGE, { detail: { start: withOverScan.start, end: withOverScan.end } }));
return { start: withOverScan.start, end: withOverScan.end };
}
return prev;
diff --git a/special-pages/pages/history/app/constants.js b/special-pages/pages/history/app/constants.js
index f232d6c40..672fba1d3 100644
--- a/special-pages/pages/history/app/constants.js
+++ b/special-pages/pages/history/app/constants.js
@@ -1 +1,13 @@
export const OVERSCAN_AMOUNT = 5;
+export const BTN_ACTION_TITLE_MENU = 'title_menu';
+export const BTN_ACTION_ENTRIES_MENU = 'entries_menu';
+export const BTN_ACTION_DELETE_RANGE = 'deleteRange';
+export const BTN_ACTION_DELETE_ALL = 'deleteAll';
+export const KNOWN_ACTIONS = /** @type {const} */ ([
+ BTN_ACTION_TITLE_MENU,
+ BTN_ACTION_ENTRIES_MENU,
+ BTN_ACTION_DELETE_RANGE,
+ BTN_ACTION_DELETE_ALL,
+]);
+export const EVENT_RANGE_CHANGE = 'range-change';
+export const EVENT_SEARCH_COMMIT = 'search-commit';
diff --git a/special-pages/pages/history/app/global-state/GlobalStateProvider.js b/special-pages/pages/history/app/global-state/GlobalStateProvider.js
new file mode 100644
index 000000000..8da9025e7
--- /dev/null
+++ b/special-pages/pages/history/app/global-state/GlobalStateProvider.js
@@ -0,0 +1,77 @@
+import { h, createContext } from 'preact';
+import { useContext } from 'preact/hooks';
+import { batch, signal, useSignal, useSignalEffect } from '@preact/signals';
+import { generateHeights } from '../utils.js';
+
+/**
+ * @typedef {object} Results
+ * @property {import('../../types/history.js').HistoryItem[]} items
+ * @property {number[]} heights
+ */
+/**
+ * @typedef {import('../../types/history.ts').Range} Range
+ */
+
+const GlobalState = createContext({
+ ranges: signal(/** @type {import('../history.service.js').Range[]} */ ([])),
+ term: signal(''),
+ results: signal(/** @type {Results} */ ({})),
+});
+
+/**
+ * Provides a global state context for the application.
+ *
+ * @param {Object} props
+ * @param {import('../history.service.js').HistoryService} props.service - An instance of the history service to manage state updates.
+ * @param {import('../history.service.js').ServiceData} props.initial - The initial state data for the history service.
+ * @param {import('preact').ComponentChildren} props.children
+ */
+export function GlobalStateProvider({ service, initial, children }) {
+ // NOTE: These states will get extracted out later, once I know all the use-cases
+ const ranges = useSignal(initial.ranges.ranges);
+ const term = useSignal('term' in initial.query.info.query ? initial.query.info.query.term : '');
+ const results = useSignal({
+ items: initial.query.results,
+ heights: generateHeights(initial.query.results),
+ });
+
+ useSignalEffect(() => {
+ const unsub = service.onResults((data) => {
+ batch(() => {
+ if ('term' in data.info.query && data.info.query.term !== null) {
+ term.value = data.info.query.term;
+ }
+ results.value = {
+ items: data.results,
+ heights: generateHeights(data.results),
+ };
+ });
+ });
+
+ // Subscribe to changes in the 'ranges' data and reflect the updates into the UI
+ const unsubRanges = service.onRanges((data) => {
+ ranges.value = data.ranges;
+ });
+ return () => {
+ unsub();
+ unsubRanges();
+ };
+ });
+
+ useSignalEffect(() => {
+ return term.subscribe(() => {
+ document.querySelector('[data-main-scroller]')?.scrollTo(0, 0);
+ });
+ });
+
+ return {children} ;
+}
+
+// Hook for consuming the context
+export function useGlobalState() {
+ const context = useContext(GlobalState);
+ if (!context) {
+ throw new Error('useSelection must be used within a SelectionProvider');
+ }
+ return context;
+}
diff --git a/special-pages/pages/history/app/global-state/HistoryServiceProvider.js b/special-pages/pages/history/app/global-state/HistoryServiceProvider.js
new file mode 100644
index 000000000..e6d6bf5ee
--- /dev/null
+++ b/special-pages/pages/history/app/global-state/HistoryServiceProvider.js
@@ -0,0 +1,265 @@
+import { createContext, h } from 'preact';
+import { useSignalEffect } from '@preact/signals';
+import { paramsToQuery, toRange } from '../history.service.js';
+import { EVENT_RANGE_CHANGE, EVENT_SEARCH_COMMIT, KNOWN_ACTIONS, OVERSCAN_AMOUNT } from '../constants.js';
+import { usePlatformName } from '../types.js';
+import { eventToTarget } from '../../../../shared/handlers.js';
+import { useContext } from 'preact/hooks';
+
+// Create the context
+const HistoryServiceContext = createContext({
+ service: /** @type {import("../history.service.js").HistoryService} */ ({}),
+});
+
+/**
+ * Provides a context for the history service, allowing dependent components to access it.
+ * Everything that interacts with the service should be registered here
+ *
+ * @param {Object} props
+ * @param {import("../history.service.js").HistoryService} props.service
+ * @param {import("preact").ComponentChild} props.children
+ */
+export function HistoryServiceProvider({ service, children }) {
+ return {children} ;
+}
+
+export function useGlobalHandlers() {
+ const { service } = useContext(HistoryServiceContext);
+ const platformName = usePlatformName();
+ useSearchCommit(service);
+ useRangeChange(service);
+ useLinkClickHandler(service, platformName);
+ useButtonClickHandler(service);
+ useAuxClickHandler(service, platformName);
+ useContextMenu(service);
+}
+
+/**
+ * A hook that listens to the "range-change" custom event and triggers fetching additional data
+ * from the service based on the event's range values.
+ *
+ * @param {import('../history.service.js').HistoryService} service
+ */
+function useRangeChange(service) {
+ useSignalEffect(() => {
+ function handler(/** @type {CustomEvent<{start: number, end: number}>} */ event) {
+ if (!service.query.data) throw new Error('unreachable');
+ const { end } = event.detail;
+ const memory = service.query.data.results;
+ if (memory.length - end < OVERSCAN_AMOUNT) {
+ service.requestMore();
+ }
+ }
+ window.addEventListener(EVENT_RANGE_CHANGE, handler);
+ return () => {
+ window.removeEventListener(EVENT_RANGE_CHANGE, handler);
+ };
+ });
+}
+
+/**
+ * A hook that listens to the "search-commit" custom event and triggers the history service
+ * with the parsed query parameter values from the event's detail object.
+ *
+ * This hook is used to bind the EVENT_SEARCH_COMMIT event with the associated service
+ * logic for handling search parameters.
+ *
+ * @param {import('../history.service.js').HistoryService} service
+ */
+function useSearchCommit(service) {
+ useSignalEffect(() => {
+ function handler(/** @type {CustomEvent<{params: URLSearchParams}>} */ event) {
+ const detail = event.detail;
+ if (detail && detail.params instanceof URLSearchParams) {
+ const asQuery = paramsToQuery(detail.params);
+ service.trigger(asQuery);
+ } else {
+ console.error('missing detail.params from search-commit event');
+ }
+ }
+
+ window.addEventListener(EVENT_SEARCH_COMMIT, handler);
+
+ // Cleanup the event listener on unmount
+ return () => {
+ window.removeEventListener(EVENT_SEARCH_COMMIT, handler);
+ };
+ });
+}
+
+/**
+ * @param {import('../history.service.js').HistoryService} service
+ */
+function useContextMenu(service) {
+ useSignalEffect(() => {
+ function contextMenu(event) {
+ const target = /** @type {HTMLElement|null} */ (event.target);
+ if (!(target instanceof HTMLElement)) return;
+
+ const actions = {
+ '[data-section-title]': (elem) => elem.querySelector('button')?.value,
+ '[data-history-entry]': (elem) => elem.querySelector('button')?.value,
+ };
+
+ for (const [selector, valueFn] of Object.entries(actions)) {
+ const match = event.target.closest(selector);
+ if (match) {
+ const value = valueFn(match);
+ if (value) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (match.dataset.sectionTitle) {
+ // eslint-disable-next-line promise/prefer-await-to-then
+ service.menuTitle(value).catch(console.error);
+ } else if (match.dataset.historyEntry) {
+ // eslint-disable-next-line promise/prefer-await-to-then
+ service.entriesMenu([value], [Number(match.dataset.index)]).catch(console.error);
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ document.addEventListener('contextmenu', contextMenu);
+
+ return () => {
+ document.removeEventListener('contextmenu', contextMenu);
+ };
+ });
+}
+
+/**
+ * @param {import('../history.service.js').HistoryService} service
+ * @param {'macos' | 'windows'} platformName
+ */
+function useAuxClickHandler(service, platformName) {
+ useSignalEffect(() => {
+ const handleAuxClick = (event) => {
+ const anchor = /** @type {HTMLButtonElement|null} */ (event.target.closest('a[href][data-url]'));
+ const url = anchor?.dataset.url;
+ if (anchor && url && event.button === 1) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const target = eventToTarget(event, platformName);
+ service.openUrl(url, target);
+ }
+ };
+ document.addEventListener('auxclick', handleAuxClick);
+ return () => {
+ document.removeEventListener('auxclick', handleAuxClick);
+ };
+ });
+}
+
+/**
+ * This function registers button click handlers that communicate with the history service.
+ * Depending on the `data-action` attribute of the clicked button, it triggers a specific action
+ * in the service, such as opening a menu, deleting a range, or deleting all entries.
+ *
+ * - "title_menu": Triggers the `menuTitle` method with the value of the button.
+ * - "entries_menu": Triggers the `entriesMenu` method with the button value and dataset index.
+ * - "deleteRange": Triggers the `deleteRange` method with a parsed range.
+ * - "deleteAll": Triggers the `deleteRange` method with 'all'.
+ *
+ * @param {import('../history.service.js').HistoryService} service - The history service instance.
+ */
+function useButtonClickHandler(service) {
+ useSignalEffect(() => {
+ function clickHandler(/** @type {MouseEvent} */ event) {
+ if (!(event.target instanceof Element)) return;
+ const btn = /** @type {HTMLButtonElement|null} */ (event.target.closest('button[data-action]'));
+ const action = toKnownAction(btn);
+ if (btn === null || action === null) return;
+ event.stopImmediatePropagation();
+ event.preventDefault();
+
+ switch (action) {
+ case 'title_menu': {
+ // eslint-disable-next-line promise/prefer-await-to-then
+ service.menuTitle(btn.value).catch(console.error);
+ return;
+ }
+ case 'entries_menu': {
+ // eslint-disable-next-line promise/prefer-await-to-then
+ service.entriesMenu([btn.value], [Number(btn.dataset.index)]).catch(console.error);
+ return;
+ }
+ case 'deleteRange': {
+ const range = toRange(btn.value);
+ if (range) {
+ // eslint-disable-next-line promise/prefer-await-to-then
+ service.deleteRange(range).catch(console.error);
+ }
+ return;
+ }
+ case 'deleteAll': {
+ // eslint-disable-next-line promise/prefer-await-to-then
+ service.deleteRange('all').catch(console.error);
+ return;
+ }
+ default:
+ return null;
+ }
+ }
+ document.addEventListener('click', clickHandler);
+ return () => {
+ document.removeEventListener('click', clickHandler);
+ };
+ });
+}
+
+/**
+ * Converts an HTML button element with a `data-action` attribute
+ * into a known action type, based on the `KNOWN_ACTIONS` array.
+ *
+ * @param {HTMLButtonElement|null} elem - The button element to parse.
+ * @return {KNOWN_ACTIONS[number] | null} - The corresponding known action, or null if invalid.
+ */
+function toKnownAction(elem) {
+ if (!elem) return null;
+ const action = elem.dataset.action;
+ if (!action) return null;
+ if (KNOWN_ACTIONS.includes(/** @type {any} */ (action))) return /** @type {KNOWN_ACTIONS[number]} */ (action);
+ return null;
+}
+
+/**
+ * Registers click event handlers for anchor links (`` elements) having `href` and `data-url` attributes.
+ * Directs the `click` events with these links to interact with the provided history service.
+ *
+ * - Anchors with `data-url` attribute are intercepted, and their URLs are processed to determine
+ * the target action (`new-tab`, `same-tab`, or `new-window`) based on the click event details.
+ * - Prevents default navigation and propagation for handled events.
+ *
+ * @param {import('../history.service.js').HistoryService} service - The history service instance.
+ * @param {'macos' | 'windows'} platformName - The platform name, used to determine click modifiers.
+ */
+function useLinkClickHandler(service, platformName) {
+ useSignalEffect(() => {
+ /**
+ * Handles click events on the document, intercepting interactions with anchor elements
+ * that specify both `href` and `data-url` attributes.
+ *
+ * @param {MouseEvent} event - The mouse event triggered by a click.
+ * @returns {void} - No return value.
+ */
+ function clickHandler(event) {
+ if (!(event.target instanceof Element)) return;
+ const anchor = /** @type {HTMLAnchorElement|null} */ (event.target.closest('a[href][data-url]'));
+ if (anchor) {
+ const url = anchor.dataset.url;
+ if (!url) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const target = eventToTarget(event, platformName);
+ service.openUrl(url, target);
+ }
+ }
+
+ document.addEventListener('click', clickHandler);
+ return () => {
+ document.removeEventListener('click', clickHandler);
+ };
+ });
+}
diff --git a/special-pages/pages/history/app/global-state/QueryProvider.js b/special-pages/pages/history/app/global-state/QueryProvider.js
new file mode 100644
index 000000000..ff7641928
--- /dev/null
+++ b/special-pages/pages/history/app/global-state/QueryProvider.js
@@ -0,0 +1,288 @@
+import { createContext, h } from 'preact';
+import { useContext } from 'preact/hooks';
+import { signal, useComputed, useSignal, useSignalEffect } from '@preact/signals';
+import { usePlatformName, useSettings } from '../types.js';
+import { toRange } from '../history.service.js';
+import { EVENT_SEARCH_COMMIT } from '../constants.js';
+
+/**
+ * @typedef {import('../../types/history.js').Range} Range
+ * @typedef {{
+ * term: string | null,
+ * range: Range | null,
+ * domain: string | null,
+ * }} QueryState
+ */
+
+const QueryContext = createContext(
+ signal(
+ /** @type {QueryState} */ ({
+ term: /** @type {string|null} */ (null),
+ range: /** @type {import('../../types/history.ts').Range|null} */ (null),
+ domain: /** @type {string|null} */ (null),
+ }),
+ ),
+);
+
+/**
+ * A custom hook to access the SearchContext.
+ */
+export function useQueryContext() {
+ return useContext(QueryContext);
+}
+
+/**
+ * A provider component that sets up the search context for its children. Allows access to and updates of the search term within the context.
+ *
+ * @param {Object} props - The props object for the component.
+ * @param {import('preact').ComponentChild} props.children - The child components wrapped within the provider.
+ * @param {import('../../types/history.ts').QueryKind} [props.query=''] - The initial search term for the context.
+ */
+export function QueryProvider({ children, query = { term: '' } }) {
+ const initial = {
+ term: 'term' in query ? query.term : null,
+ range: 'range' in query ? query.range : null,
+ domain: 'domain' in query ? query.domain : null,
+ };
+ const searchState = useSignal(initial);
+ const derivedTerm = useComputed(() => searchState.value.term);
+ const derivedRange = useComputed(() => {
+ return /** @type {Range|null} */ (searchState.value.range);
+ });
+ const settings = useSettings();
+ const platformName = usePlatformName();
+
+ useClickHandlerForFilters(searchState);
+ useInputHandler(searchState);
+ useSearchShortcut(platformName);
+ useFormSubmit();
+ useURLReflection(derivedTerm, settings);
+ useSearchCommitForRange(derivedRange);
+
+ return {children} ;
+}
+
+/**
+ * Synchronizes the `derivedRange` signal with the browser's URL and dispatches
+ * a custom `EVENT_SEARCH_COMMIT` event when the range changes.
+ *
+ * This effect updates the URL's search parameters to add or remove the `range` query parameter
+ * based on the value of `derivedRange`. It handles the subscription to `derivedRange` and ensures
+ * the URL reflects the latest state of the signal. Only subsequent changes (after the first signal
+ * value) are processed to avoid re-initialization effects.
+ *
+ * @param {import('@preact/signals').ReadonlySignal} derivedRange - A readonly signal representing the range value.
+ */
+function useSearchCommitForRange(derivedRange) {
+ useSignalEffect(() => {
+ let timer;
+ let counter = 0;
+ const sub = derivedRange.subscribe((nextRange) => {
+ if (counter === 0) {
+ counter += 1;
+ return;
+ }
+ const url = new URL(window.location.href);
+
+ url.searchParams.delete('q');
+ url.searchParams.delete('range');
+
+ if (nextRange !== null) {
+ url.searchParams.set('range', nextRange);
+ window.history.replaceState(null, '', url.toString());
+ window.dispatchEvent(new CustomEvent(EVENT_SEARCH_COMMIT, { detail: { params: new URLSearchParams(url.searchParams) } }));
+ }
+ });
+
+ return () => {
+ sub();
+ clearTimeout(timer);
+ };
+ });
+}
+
+/**
+ * Updates the URL with the latest search term (if present) and dispatches a custom event with the updated query parameters.
+ * Debounces the updates based on the `settings.typingDebounce` value to avoid frequent URL state changes during typing.
+ *
+ * This hook uses a signal effect to listen for changes in the `derivedTerm` and updates the browser's URL accordingly, with debounce support.
+ * It dispatches an `EVENT_SEARCH_COMMIT` event to notify other components or parts of the application about the updated search parameters.
+ *
+ * @param {import('@preact/signals').Signal} derivedTerm - A signal of the current search term to watch for changes.
+ * @param {import('../Settings.js').Settings} settings - The settings for the behavior, including the debounce duration.
+ */
+function useURLReflection(derivedTerm, settings) {
+ useSignalEffect(() => {
+ let timer;
+ let counter = 0;
+ const unsubscribe = derivedTerm.subscribe((nextValue) => {
+ if (counter === 0) {
+ counter += 1;
+ return;
+ }
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ const url = new URL(window.location.href);
+
+ url.searchParams.delete('q');
+ url.searchParams.delete('range');
+
+ if (nextValue) {
+ url.searchParams.set('q', nextValue);
+ window.history.replaceState(null, '', url.toString());
+ } else if (nextValue === '') {
+ window.history.replaceState(null, '', url.toString());
+ }
+ if (nextValue === null) {
+ /** no-op */
+ } else {
+ window.dispatchEvent(
+ new CustomEvent(EVENT_SEARCH_COMMIT, { detail: { params: new URLSearchParams(url.searchParams) } }),
+ );
+ }
+ }, settings.typingDebounce);
+ });
+
+ return () => {
+ unsubscribe();
+ clearTimeout(timer);
+ };
+ });
+}
+
+/**
+ * Handles the `submit` event on the document and prevents the default form submission behavior.
+ *
+ * This effect is used to intercept form submissions and extract the form data
+ * for further processing or integration into the application's query state management.
+ *
+ * Currently, this functionality is not fully implemented. The intercepted form data
+ * will need to be used to trigger or re-issue a search, but the specifics of that behavior
+ * remain a TODO.
+ */
+function useFormSubmit() {
+ useSignalEffect(() => {
+ const submitHandler = (e) => {
+ e.preventDefault();
+ if (!e.target || !(e.target instanceof HTMLFormElement)) return;
+ const formData = new FormData(e.target);
+ console.log('todo: re-issue search here?', [formData.get('q')?.toString()]);
+ };
+
+ document.addEventListener('submit', submitHandler);
+ return () => {
+ document.removeEventListener('submit', submitHandler);
+ };
+ });
+}
+
+/**
+ * Monitors clicks on filter links (`a[data-filter]`) and updates the `queryState` signal
+ * with the appropriate range filter value extracted from the link's `data-filter` attribute.
+ *
+ * If a filter with `data-filter="all"` is clicked, it resets the `queryState` to its default values.
+ * Otherwise, it updates the `range` field in `queryState` with the parsed value from the clicked filter.
+ *
+ * The click event is prevented to avoid default link navigation behavior.
+ *
+ * Cleans up the event listener when the effect is disposed.
+ *
+ * @param {import('@preact/signals').Signal} queryState - A signal representing the query state to update.
+ */
+function useClickHandlerForFilters(queryState) {
+ useSignalEffect(() => {
+ function clickHandler(e) {
+ if (!(e.target instanceof HTMLElement)) return;
+ const anchor = /** @type {HTMLAnchorElement|null} */ (e.target.closest('a[data-filter]'));
+ if (anchor) {
+ e.preventDefault();
+ const range = toRange(anchor.dataset.filter);
+ // todo: where should this rule live?
+ if (range === 'all') {
+ queryState.value = {
+ term: '',
+ domain: null,
+ range: null,
+ };
+ } else if (range) {
+ queryState.value = {
+ term: null,
+ domain: null,
+ range,
+ };
+ }
+ }
+ }
+ document.addEventListener('click', clickHandler);
+ return () => {
+ document.removeEventListener('click', clickHandler);
+ };
+ });
+}
+
+/**
+ * Handles the `input` event on the document.
+ *
+ * When user input is detected on an `HTMLInputElement` within an `HTMLFormElement`,
+ * it retrieves the form's data and updates the `queryState` signal with the new query term.
+ *
+ * This function modifies the `queryState` by setting the `term` to the value of the `q` field
+ * from the form. The `range` and `domain` values are cleared (set to `null`).
+ *
+ * If the `q` field is missing in the form, a log message will indicate it as such.
+ *
+ * Resources are properly cleaned up when the effect is disposed (removes event listener).
+ *
+ * @param {import('@preact/signals').Signal} queryState - A signal representing the query state.
+ */
+function useInputHandler(queryState) {
+ useSignalEffect(() => {
+ function handler(e) {
+ if (e.target instanceof HTMLInputElement && e.target.form instanceof HTMLFormElement) {
+ const data = new FormData(e.target.form);
+ const q = data.get('q')?.toString();
+ if (q === undefined) return console.log('missing q field');
+ queryState.value = {
+ term: q,
+ range: null,
+ domain: null,
+ };
+ }
+ }
+ document.addEventListener('input', handler);
+ return () => {
+ document.removeEventListener('input', handler);
+ };
+ });
+}
+
+/**
+ * Listens for keyboard shortcuts to focus the search input.
+ *
+ * Handles platform-specific shortcuts for MacOS (Cmd+F) and Windows (Ctrl+F).
+ * If the shortcut is triggered, it will prevent the default action and focus
+ * on the first `input[type="search"]` element in the DOM, if available.
+ *
+ * @param {'macos' | 'windows'} platformName - Defines the current platform to handle the appropriate shortcut.
+ */
+function useSearchShortcut(platformName) {
+ useSignalEffect(() => {
+ const keydown = (e) => {
+ const isMacOS = platformName === 'macos';
+ const isFindShortcutMacOS = isMacOS && e.metaKey && e.key === 'f';
+ const isFindShortcutWindows = !isMacOS && e.ctrlKey && e.key === 'f';
+
+ if (isFindShortcutMacOS || isFindShortcutWindows) {
+ e.preventDefault();
+ const searchInput = /** @type {HTMLInputElement|null} */ (document.querySelector(`input[type="search"]`));
+ if (searchInput) {
+ searchInput.focus();
+ }
+ }
+ };
+ document.addEventListener('keydown', keydown);
+ return () => {
+ document.removeEventListener('keydown', keydown);
+ };
+ });
+}
diff --git a/special-pages/pages/history/app/global-state/SelectionProvider.js b/special-pages/pages/history/app/global-state/SelectionProvider.js
new file mode 100644
index 000000000..d87f23e5d
--- /dev/null
+++ b/special-pages/pages/history/app/global-state/SelectionProvider.js
@@ -0,0 +1,56 @@
+import { h, createContext } from 'preact';
+import { useContext } from 'preact/hooks';
+import { signal, useSignal, useSignalEffect } from '@preact/signals';
+
+const SelectionContext = createContext({
+ selected: signal(/** @type {string[]} */ ([])),
+});
+
+/**
+ * Provides a context for the selections
+ *
+ * @param {Object} props - The properties object for the SelectionProvider component.
+ * @param {import("preact").ComponentChild} props.children - The child components that will consume the history service context.
+ */
+export function SelectionProvider({ children }) {
+ const selected = useSignal(/** @type {string[]} */ ([]));
+ useSignalEffect(() => {
+ function handler(/** @type {MouseEvent} */ event) {
+ if (!(event.target instanceof Element)) return;
+ if (event.target.matches('a')) return;
+ const itemRow = /** @type {HTMLElement|null} */ (event.target.closest('[data-history-entry][data-index]'));
+ const selection = toRowSelection(itemRow);
+ if (selection) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+
+ // MVP for getting the tests to pass. Next PRs will expand functionality
+ selected.value = [selection.id];
+ }
+ }
+ document.addEventListener('click', handler);
+ });
+ return {children} ;
+}
+
+// Hook for consuming the context
+export function useSelected() {
+ const context = useContext(SelectionContext);
+ if (!context) {
+ throw new Error('useSelection must be used within a SelectionProvider');
+ }
+ return context.selected;
+}
+
+/**
+ * @param {null|HTMLElement} elem
+ * @returns {{id: string; index: number} | null}
+ */
+function toRowSelection(elem) {
+ if (elem === null) return null;
+ const { index, historyEntry } = elem.dataset;
+ if (typeof historyEntry !== 'string') return null;
+ if (typeof index !== 'string') return null;
+ if (!index.trim().match(/^\d+$/)) return null;
+ return { id: historyEntry, index: parseInt(index, 10) };
+}
diff --git a/special-pages/pages/history/app/index.js b/special-pages/pages/history/app/index.js
index 3aca31664..8ae7d45f1 100644
--- a/special-pages/pages/history/app/index.js
+++ b/special-pages/pages/history/app/index.js
@@ -10,9 +10,11 @@ import { callWithRetry } from '../../../shared/call-with-retry.js';
import { MessagingContext, SettingsContext } from './types.js';
import { HistoryService, paramsToQuery } from './history.service.js';
-import { HistoryServiceProvider } from './HistoryProvider.js';
-import { SearchProvider } from './components/SearchForm.js';
-import { Settings } from './Settings.js'; // global styles
+import { HistoryServiceProvider } from './global-state/HistoryServiceProvider.js';
+import { Settings } from './Settings.js';
+import { SelectionProvider } from './global-state/SelectionProvider.js';
+import { QueryProvider } from './global-state/QueryProvider.js';
+import { GlobalStateProvider } from './global-state/GlobalStateProvider.js'; // global styles
/**
* @param {Element} root
@@ -73,11 +75,15 @@ export async function init(root, messaging, baseEnvironment) {
-
-
-
+
+
+
+
+
+
+
-
+
diff --git a/special-pages/pages/history/integration-tests/history-selections.spec.js b/special-pages/pages/history/integration-tests/history-selections.spec.js
new file mode 100644
index 000000000..8da5dfcc0
--- /dev/null
+++ b/special-pages/pages/history/integration-tests/history-selections.spec.js
@@ -0,0 +1,12 @@
+import { test } from '@playwright/test';
+import { HistoryTestPage } from './history.page.js';
+
+test.describe('history selections', () => {
+ test('selects one item at a time', async ({ page }, workerInfo) => {
+ const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000);
+ await hp.openPage({});
+ await hp.selectsRow(0);
+ await hp.selectsRow(1);
+ await hp.selectsRow(2);
+ });
+});
diff --git a/special-pages/pages/history/integration-tests/history.page.js b/special-pages/pages/history/integration-tests/history.page.js
index 9a6cc815a..7f7dc11c2 100644
--- a/special-pages/pages/history/integration-tests/history.page.js
+++ b/special-pages/pages/history/integration-tests/history.page.js
@@ -318,7 +318,7 @@ export class HistoryTestPage {
const first = data[0];
const row = page.getByText(first.title);
await row.hover();
- await page.locator(`[data-row-menu][value=${data[0].id}]`).click();
+ await page.locator(`[data-action="entries_menu"][value=${data[0].id}]`).click();
const calls = await this.mocks.waitForCallCount({ method: 'entries_menu', count: 1 });
expect(calls[0].payload.params).toStrictEqual({ ids: [data[0].id] });
@@ -331,4 +331,16 @@ export class HistoryTestPage {
const calls = await this.mocks.waitForCallCount({ method: 'title_menu', count: 1 });
expect(calls[0].payload.params).toStrictEqual({ dateRelativeDay: 'Today' });
}
+
+ /**
+ * @param {number} nth
+ */
+ async selectsRow(nth) {
+ const { page } = this;
+ const rows = page.locator('main').locator('[aria-selected]');
+ const selected = page.locator('main').locator('[aria-selected="true"]');
+ await rows.nth(nth).click();
+ await expect(rows.nth(nth)).toHaveAttribute('aria-selected', 'true');
+ await expect(selected).toHaveCount(1);
+ }
}
diff --git a/special-pages/playwright.config.js b/special-pages/playwright.config.js
index 7f50c5977..eeb863eb7 100644
--- a/special-pages/playwright.config.js
+++ b/special-pages/playwright.config.js
@@ -25,7 +25,8 @@ export default defineConfig({
'update-notification.spec.js',
'customizer.spec.js',
'activity.spec.js',
- 'history.spec.js'
+ 'history.spec.js',
+ 'history-selections.spec.js'
],
use: {
...devices['Desktop Chrome'],