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 (
- 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'],