Skip to content

Commit b852c93

Browse files
committed
make it clearer where global events are
1 parent e3e25e0 commit b852c93

File tree

12 files changed

+498
-495
lines changed

12 files changed

+498
-495
lines changed

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

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,51 @@ import styles from './App.module.css';
44
import { useEnv } from '../../../../shared/components/EnvironmentProvider.js';
55
import { Header } from './Header.js';
66
import { Results } from './Results.js';
7-
import { useRef } from 'preact/hooks';
7+
import { useEffect, useRef } from 'preact/hooks';
88
import { Sidebar } from './Sidebar.js';
9-
import { useGlobalState } from '../global-state/GlobalStateProvider.js';
10-
import { useSelectionEvents, useSelected } from '../global-state/SelectionProvider.js';
11-
import { useGlobalHandlers } from '../global-state/HistoryServiceProvider.js';
9+
import { useData } from '../global-state/DataProvider.js';
10+
import { useResetSelectionsOnQueryChange, useRowInteractions, useSelected } from '../global-state/SelectionProvider.js';
11+
import {
12+
useAuxClickHandler,
13+
useButtonClickHandler,
14+
useContextMenuForEntries,
15+
useContextMenuForTitles,
16+
useGlobalHandlers,
17+
useLinkClickHandler,
18+
} from '../global-state/HistoryServiceProvider.js';
19+
import { useQueryEvents } from '../global-state/QueryProvider.js';
1220

1321
export function App() {
1422
const { isDarkMode } = useEnv();
15-
const containerRef = useRef(/** @type {HTMLElement|null} */ (null));
16-
const { ranges, term, results } = useGlobalState();
23+
const { ranges, results } = useData();
1724
const selected = useSelected();
1825

26+
/**
27+
* The following handlers are application-global in nature, so I want them
28+
* to be registered here for visibility
29+
*/
1930
useGlobalHandlers();
20-
useSelectionEvents(containerRef);
31+
useResetSelectionsOnQueryChange();
32+
useQueryEvents();
33+
useLinkClickHandler();
34+
useButtonClickHandler();
35+
useContextMenuForTitles();
36+
useContextMenuForEntries();
37+
useAuxClickHandler();
38+
39+
/**
40+
* onClick can be passed directly to the main container,
41+
* onKeyDown will be observed at the document level.
42+
* todo: can this be resolved if the `main` element is given focus/tab-index?
43+
*/
44+
const { onClick, onKeyDown } = useRowInteractions();
45+
46+
useEffect(() => {
47+
document.addEventListener('keydown', onKeyDown);
48+
return () => {
49+
document.removeEventListener('keydown', onKeyDown);
50+
};
51+
}, [onKeyDown]);
2152

2253
return (
2354
<div class={styles.layout} data-theme={isDarkMode ? 'dark' : 'light'}>
@@ -27,7 +58,7 @@ export function App() {
2758
<header class={styles.header}>
2859
<Header />
2960
</header>
30-
<main class={cn(styles.main, styles.customScroller)} ref={containerRef} data-main-scroller data-term={term}>
61+
<main class={cn(styles.main, styles.customScroller)} data-main-scroller onClick={onClick}>
3162
<Results results={results} selected={selected} />
3263
</main>
3364
</div>

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { SearchForm } from './SearchForm.js';
55
import { Trash } from '../icons/Trash.js';
66
import { useTypedTranslation } from '../types.js';
77
import { useQueryContext } from '../global-state/QueryProvider.js';
8-
import { BTN_ACTION_DELETE_ALL } from '../constants.js';
9-
import { useGlobalState } from '../global-state/GlobalStateProvider.js';
8+
import { useData } from '../global-state/DataProvider.js';
109
import { useSelected } from '../global-state/SelectionProvider.js';
10+
import { useHistoryServiceDispatch } from '../global-state/HistoryServiceProvider.js';
1111

1212
/**
1313
*/
@@ -34,17 +34,18 @@ export function Header() {
3434
function Controls({ term }) {
3535
const { t } = useTypedTranslation();
3636
const selected = useSelected();
37+
const dispatch = useHistoryServiceDispatch();
3738
const buttonTxt = useComputed(() => {
3839
const hasTerm = term.value !== null && term.value.trim() !== '';
3940
const hasSelections = selected.value.size > 0;
4041
return hasTerm || hasSelections ? t('delete_some') : t('delete_all');
4142
});
42-
const { results } = useGlobalState();
43+
const { results } = useData();
4344
const ariaDisabled = useComputed(() => (results.value.items.length === 0 ? 'true' : 'false'));
4445
const title = useComputed(() => (results.value.items.length === 0 ? t('delete_none') : ''));
4546
return (
4647
<div class={styles.controls}>
47-
<button class={styles.largeButton} data-action={BTN_ACTION_DELETE_ALL} aria-disabled={ariaDisabled} title={title}>
48+
<button class={styles.largeButton} onClick={() => dispatch({ kind: 'delete-all' })} aria-disabled={ariaDisabled} title={title}>
4849
<Trash />
4950
<span>{buttonTxt}</span>
5051
</button>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Empty } from './Empty.js';
77

88
/**
99
* @param {object} props
10-
* @param {import("@preact/signals").Signal<import("../global-state/GlobalStateProvider.js").Results>} props.results
10+
* @param {import("@preact/signals").Signal<import("../global-state/DataProvider.js").Results>} props.results
1111
* @param {import("@preact/signals").Signal<Set<number>>} props.selected
1212
*/
1313
export function Results({ results, selected }) {

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

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import styles from './Header.module.css';
22
import { h } from 'preact';
3-
import { useTypedTranslation } from '../types.js';
4-
import { useComputed } from '@preact/signals';
3+
import { usePlatformName, useTypedTranslation } from '../types.js';
4+
import { useComputed, useSignalEffect } from '@preact/signals';
55
import { SearchIcon } from '../icons/Search.js';
66
import { useQueryDispatch } from '../global-state/QueryProvider.js';
77

@@ -16,6 +16,8 @@ export function SearchForm({ term, domain }) {
1616
const { t } = useTypedTranslation();
1717
const value = useComputed(() => term.value || domain.value || '');
1818
const dispatch = useQueryDispatch();
19+
const platformName = usePlatformName();
20+
useSearchShortcut(platformName);
1921

2022
/**
2123
* @param {InputEvent} inputEvent
@@ -28,8 +30,16 @@ export function SearchForm({ term, domain }) {
2830
invariant(term !== undefined);
2931
dispatch({ kind: 'search-by-term', value: term });
3032
}
33+
34+
/**
35+
* @param {SubmitEvent} submitEvent
36+
*/
37+
function submit(submitEvent) {
38+
console.log('todo: handle form submit?', submitEvent);
39+
}
40+
3141
return (
32-
<form role="search">
42+
<form role="search" onSubmit={submit}>
3343
<label class={styles.label}>
3444
<span class="sr-only">{t('search_your_history')}</span>
3545
<span class={styles.searchIcon}>
@@ -52,6 +62,37 @@ export function SearchForm({ term, domain }) {
5262
);
5363
}
5464

65+
/**
66+
* Listens for keyboard shortcuts to focus the search input.
67+
*
68+
* Handles platform-specific shortcuts for MacOS (Cmd+F) and Windows (Ctrl+F).
69+
* If the shortcut is triggered, it will prevent the default action and focus
70+
* on the first `input[type="search"]` element in the DOM, if available.
71+
*
72+
* @param {'macos' | 'windows'} platformName - Defines the current platform to handle the appropriate shortcut.
73+
*/
74+
function useSearchShortcut(platformName) {
75+
useSignalEffect(() => {
76+
const keydown = (e) => {
77+
const isMacOS = platformName === 'macos';
78+
const isFindShortcutMacOS = isMacOS && e.metaKey && e.key === 'f';
79+
const isFindShortcutWindows = !isMacOS && e.ctrlKey && e.key === 'f';
80+
81+
if (isFindShortcutMacOS || isFindShortcutWindows) {
82+
e.preventDefault();
83+
const searchInput = /** @type {HTMLInputElement|null} */ (document.querySelector(`input[type="search"]`));
84+
if (searchInput) {
85+
searchInput.focus();
86+
}
87+
}
88+
};
89+
document.addEventListener('keydown', keydown);
90+
return () => {
91+
document.removeEventListener('keydown', keydown);
92+
};
93+
});
94+
}
95+
5596
/**
5697
* @param {any} condition
5798
* @param {string} [message]

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { useTypedTranslation } from '../types.js';
66
import { Trash } from '../icons/Trash.js';
77
import { useTypedTranslationWith } from '../../../new-tab/app/types.js';
88
import { useQueryContext, useQueryDispatch } from '../global-state/QueryProvider.js';
9-
import { BTN_ACTION_DELETE_RANGE } from '../constants.js';
10-
import { useGlobalState } from '../global-state/GlobalStateProvider.js';
9+
import { useData } from '../global-state/DataProvider.js';
1110
import { toRange } from '../history.service.js';
1211
import { memo } from 'preact/compat';
12+
import { useHistoryServiceDispatch } from '../global-state/HistoryServiceProvider.js';
1313

1414
/**
1515
* @import json from "../strings.json"
@@ -56,7 +56,7 @@ export function Sidebar({ ranges }) {
5656
const { t } = useTypedTranslation();
5757
const search = useQueryContext();
5858
const current = useComputed(() => search.value.range);
59-
const { results } = useGlobalState();
59+
const { results } = useData();
6060
const count = useComputed(() => results.value.items.length);
6161
const dispatch = useQueryDispatch();
6262

@@ -78,6 +78,7 @@ export function Sidebar({ ranges }) {
7878
console.warn('could not determine valid range');
7979
}
8080
}
81+
8182
return (
8283
<div class={styles.stack}>
8384
<h1 class={styles.pageTitle}>{t('page_title')}</h1>
@@ -101,6 +102,7 @@ const SidebarItem = memo(SidebarItem_);
101102
*/
102103
function SidebarItem_({ range, title, current, count }) {
103104
const { t } = useTypedTranslationWith(/** @type {json} */ ({}));
105+
const dispatch = useHistoryServiceDispatch();
104106
const ariaDisabled = useComputed(() => {
105107
return range === 'all' && count.value === 0 ? 'true' : 'false';
106108
});
@@ -140,7 +142,7 @@ function SidebarItem_({ range, title, current, count }) {
140142
</a>
141143
<button
142144
class={styles.delete}
143-
data-action={BTN_ACTION_DELETE_RANGE}
145+
onClick={() => dispatch({ kind: 'delete-range', value: range })}
144146
aria-label={deleteLabel}
145147
tabindex={0}
146148
value={range}

special-pages/pages/history/app/constants.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,5 @@ export const OVERSCAN_AMOUNT = 5;
33
export const BTN_ACTION_TITLE_MENU = 'title_menu';
44
export const BTN_ACTION_ENTRIES_MENU = 'entries_menu';
55
export const BTN_ACTION_DELETE_RANGE = 'deleteRange';
6-
export const BTN_ACTION_DELETE_ALL = 'deleteAll';
7-
export const KNOWN_ACTIONS = /** @type {const} */ ([
8-
BTN_ACTION_TITLE_MENU,
9-
BTN_ACTION_ENTRIES_MENU,
10-
BTN_ACTION_DELETE_RANGE,
11-
BTN_ACTION_DELETE_ALL,
12-
]);
6+
export const KNOWN_ACTIONS = /** @type {const} */ ([BTN_ACTION_TITLE_MENU, BTN_ACTION_ENTRIES_MENU, BTN_ACTION_DELETE_RANGE]);
137
export const EVENT_RANGE_CHANGE = 'range-change';

special-pages/pages/history/app/global-state/GlobalStateProvider.js renamed to special-pages/pages/history/app/global-state/DataProvider.js

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { h, createContext } from 'preact';
22
import { useContext } from 'preact/hooks';
3-
import { batch, signal, useSignal, useSignalEffect } from '@preact/signals';
3+
import { signal, useSignal, useSignalEffect } from '@preact/signals';
44
import { generateHeights } from '../utils.js';
5+
import { useQueryContext } from './QueryProvider.js';
56

67
/**
78
* @typedef {object} Results
@@ -12,40 +13,34 @@ import { generateHeights } from '../utils.js';
1213
* @typedef {import('../../types/history.ts').Range} Range
1314
*/
1415

15-
const GlobalState = createContext({
16+
const DataState = createContext({
1617
ranges: signal(/** @type {import('../history.service.js').Range[]} */ ([])),
17-
term: signal(''),
1818
results: signal(/** @type {Results} */ ({})),
1919
});
2020

2121
/**
22-
* Provides a global state context for the application.
22+
* Provides a global state context for the application data.
2323
*
2424
* @param {Object} props
2525
* @param {import('../history.service.js').HistoryService} props.service - An instance of the history service to manage state updates.
2626
* @param {import('../history.service.js').ServiceData} props.initial - The initial state data for the history service.
2727
* @param {import('preact').ComponentChildren} props.children
2828
*/
29-
export function GlobalStateProvider({ service, initial, children }) {
29+
export function DataProvider({ service, initial, children }) {
3030
// NOTE: These states will get extracted out later, once I know all the use-cases
3131
const ranges = useSignal(initial.ranges.ranges);
32-
const term = useSignal('term' in initial.query.info.query ? initial.query.info.query.term : '');
32+
const query = useQueryContext();
3333
const results = useSignal({
3434
items: initial.query.results,
3535
heights: generateHeights(initial.query.results),
3636
});
3737

3838
useSignalEffect(() => {
3939
const unsub = service.onResults((data) => {
40-
batch(() => {
41-
if ('term' in data.info.query && data.info.query.term !== null) {
42-
term.value = data.info.query.term;
43-
}
44-
results.value = {
45-
items: data.results,
46-
heights: generateHeights(data.results),
47-
};
48-
});
40+
results.value = {
41+
items: data.results,
42+
heights: generateHeights(data.results),
43+
};
4944
});
5045

5146
// Subscribe to changes in the 'ranges' data and reflect the updates into the UI
@@ -59,17 +54,18 @@ export function GlobalStateProvider({ service, initial, children }) {
5954
});
6055

6156
useSignalEffect(() => {
62-
return term.subscribe(() => {
57+
return query.subscribe(() => {
58+
// whenever the query changes, scroll the main container back to the top
6359
document.querySelector('[data-main-scroller]')?.scrollTo(0, 0);
6460
});
6561
});
6662

67-
return <GlobalState.Provider value={{ ranges, term, results }}>{children}</GlobalState.Provider>;
63+
return <DataState.Provider value={{ ranges, results }}>{children}</DataState.Provider>;
6864
}
6965

7066
// Hook for consuming the context
71-
export function useGlobalState() {
72-
const context = useContext(GlobalState);
67+
export function useData() {
68+
const context = useContext(DataState);
7369
if (!context) {
7470
throw new Error('useSelection must be used within a SelectionProvider');
7571
}

0 commit comments

Comments
 (0)