Skip to content

Commit 6bc691c

Browse files
authored
history: context menu for section titles (#1481)
* history: context menu for section titles * comments * docs * rename * docs * fixed tests * ux improvements for demo
1 parent 76160aa commit 6bc691c

File tree

16 files changed

+273
-23
lines changed

16 files changed

+273
-23
lines changed

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

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,14 @@ export function HistoryServiceProvider({ service, initial, children }) {
6161
if (!(event.target instanceof Element)) return;
6262
const btn = /** @type {HTMLButtonElement|null} */ (event.target.closest('button'));
6363
const anchor = /** @type {HTMLButtonElement|null} */ (event.target.closest('a[href][data-url]'));
64+
if (btn?.dataset.titleMenu) {
65+
event.stopImmediatePropagation();
66+
event.preventDefault();
67+
// eslint-disable-next-line promise/prefer-await-to-then
68+
service.menuTitle(btn.value).catch(console.error);
69+
return;
70+
}
6471
if (btn) {
65-
if (btn?.dataset.titleMenu) {
66-
event.stopImmediatePropagation();
67-
event.preventDefault();
68-
return confirm(`todo: title menu for ${btn.dataset.titleMenu}`);
69-
}
7072
if (btn?.dataset.rowMenu) {
7173
event.stopImmediatePropagation();
7274
event.preventDefault();
@@ -112,9 +114,35 @@ export function HistoryServiceProvider({ service, initial, children }) {
112114
};
113115
document.addEventListener('auxclick', handleAuxClick);
114116

117+
function contextMenu(event) {
118+
const target = /** @type {HTMLElement|null} */ (event.target);
119+
if (!(target instanceof HTMLElement)) return;
120+
121+
const actions = {
122+
'[data-section-title]': (elem) => elem.querySelector('button')?.value,
123+
};
124+
125+
for (const [selector, valueFn] of Object.entries(actions)) {
126+
const match = event.target.closest(selector);
127+
if (match) {
128+
const value = valueFn(match);
129+
if (value) {
130+
event.preventDefault();
131+
event.stopImmediatePropagation();
132+
// eslint-disable-next-line promise/prefer-await-to-then
133+
service.menuTitle(value).catch(console.error);
134+
}
135+
break;
136+
}
137+
}
138+
}
139+
140+
document.addEventListener('contextmenu', contextMenu);
141+
115142
return () => {
116143
document.removeEventListener('auxclick', handleAuxClick);
117144
document.removeEventListener('click', handler);
145+
document.removeEventListener('contextmenu', contextMenu);
118146
};
119147
});
120148
return <HistoryServiceContext.Provider value={{ service, initial }}>{children}</HistoryServiceContext.Provider>;

special-pages/pages/history/app/components/Header.module.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,9 @@
5454
border: 1px solid var(--history-surface-border-color);
5555
padding-left: 9px;
5656
padding-right: 9px;
57+
background: inherit;
58+
color: inherit;
59+
&:focus {
60+
outline: none;
61+
}
5762
}

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { END_KIND, TITLE_KIND } from '../utils.js';
44
import { Fragment, h } from 'preact';
55
import styles from './Item.module.css';
66
import { Dots } from '../icons/dots.js';
7+
import { useTypedTranslation } from '../types.js';
78

89
export const Item = memo(
910
/**
@@ -19,12 +20,18 @@ export const Item = memo(
1920
* @param {string} props.dateTimeOfDay - the time of day, like 11.00am.
2021
*/
2122
function Item({ id, url, domain, title, kind, dateRelativeDay, dateTimeOfDay }) {
23+
const { t } = useTypedTranslation();
2224
return (
2325
<Fragment>
2426
{kind === TITLE_KIND && (
25-
<div class={styles.title} tabindex={0}>
27+
<div class={styles.title} tabindex={0} data-section-title>
2628
{dateRelativeDay}
27-
<button class={cn(styles.dots, styles.titleDots)} data-title-menu={id}>
29+
<button
30+
class={cn(styles.dots, styles.titleDots)}
31+
data-title-menu
32+
value={dateRelativeDay}
33+
aria-label={t('menu_sectionTitle', { relativeTime: dateRelativeDay })}
34+
>
2835
<Dots />
2936
</button>
3037
</div>

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import styles from './Header.module.css';
22
import { createContext, h } from 'preact';
3-
import { useSettings, useTypedTranslation } from '../types.js';
3+
import { usePlatformName, useSettings, useTypedTranslation } from '../types.js';
44
import { signal, useComputed, useSignal, useSignalEffect } from '@preact/signals';
55
import { useContext } from 'preact/hooks';
66
import { toRange } from '../history.service.js';
@@ -54,6 +54,7 @@ export function SearchProvider({ children, query = { term: '' } }) {
5454
const derivedTerm = useComputed(() => searchState.value.term);
5555
const derivedRange = useComputed(() => searchState.value.range);
5656
const settings = useSettings();
57+
const platformName = usePlatformName();
5758
// todo: domain search
5859
// const derivedDomain = useComputed(() => searchState.value.domain);
5960
useSignalEffect(() => {
@@ -113,7 +114,24 @@ export function SearchProvider({ children, query = { term: '' } }) {
113114
}
114115
});
115116

117+
const keydown = (e) => {
118+
const isMacOS = platformName === 'macos';
119+
const isFindShortcutMacOS = isMacOS && e.metaKey && e.key === 'f';
120+
const isFindShortcutWindows = !isMacOS && e.ctrlKey && e.key === 'f';
121+
122+
if (isFindShortcutMacOS || isFindShortcutWindows) {
123+
e.preventDefault();
124+
const searchInput = /** @type {HTMLInputElement|null} */ (document.querySelector(`input[type="search"]`));
125+
if (searchInput) {
126+
searchInput.focus();
127+
}
128+
}
129+
};
130+
131+
document.addEventListener('keydown', keydown);
132+
116133
return () => {
134+
document.removeEventListener('keydown', keydown);
117135
controller.abort();
118136
};
119137
});

special-pages/pages/history/app/history.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,36 @@ Response, note: always return the same query I sent:
118118
}
119119
```
120120

121+
### `title_menu`
122+
{@link "History Messages".TitleMenuRequest}
123+
124+
Sent when a right-click is issued on a section title (or when the three-dots button is clicked)
125+
126+
**Types:**
127+
- Parameters: {@link "History Messages".TitleMenuParams}
128+
- Response: {@link "History Messages".TitleMenuResponse}
129+
130+
**params**
131+
```json
132+
{
133+
"dateRelativeDay": "Today - Wednesday 15 January 2025"
134+
}
135+
```
136+
137+
**response, if deleted**
138+
```json
139+
{
140+
"action": "delete"
141+
}
142+
```
143+
144+
**response, otherwise**
145+
```json
146+
{
147+
"action": "none"
148+
}
149+
```
150+
121151
## Notifications
122152

123153
### `open`

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,37 @@ export class HistoryService {
102102
return this.ranges.onData(({ data, source }) => cb(data));
103103
}
104104

105+
/**
106+
* @param {string} dateRelativeDay
107+
*/
108+
async menuTitle(dateRelativeDay) {
109+
const response = await this.history.messaging.request('title_menu', { dateRelativeDay });
110+
if (response.action === 'none') return;
111+
this.query.update((old) => {
112+
// find the first item
113+
// todo: this can be optimized by passing the index in the call
114+
const start = old.results.findIndex((x) => x.dateRelativeDay === dateRelativeDay);
115+
if (start > -1) {
116+
// now find the last item matching, starting with the first
117+
let end = start;
118+
for (let i = start; i < old.results.length; i++) {
119+
if (old.results[i]?.dateRelativeDay === dateRelativeDay) continue;
120+
end = i;
121+
break;
122+
}
123+
const next = old.results.slice();
124+
const removed = next.splice(start, end - start);
125+
console.log('did remove items:', removed);
126+
return {
127+
...old,
128+
results: next,
129+
};
130+
}
131+
return old;
132+
});
133+
// todo: should we refresh the ranges here?
134+
}
135+
105136
/**
106137
* @param {Range} range
107138
*/

special-pages/pages/history/app/mocks/mock-transport.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,26 @@ export function mockTransport() {
5858
const msg = /** @type {any} */ (_msg);
5959

6060
switch (msg.method) {
61+
case 'title_menu': {
62+
console.log('📤 [deleteRange]: ', JSON.stringify(msg.params));
63+
// prettier-ignore
64+
const lines = [
65+
`title_menu: ${JSON.stringify(msg.params)}`,
66+
`To simulate deleting this item, press confirm`
67+
].join('\n');
68+
if (confirm(lines)) {
69+
return Promise.resolve({ action: 'delete' });
70+
}
71+
return Promise.resolve({ action: 'none' });
72+
}
6173
case 'deleteRange': {
6274
console.log('📤 [deleteRange]: ', JSON.stringify(msg.params));
63-
if (confirm(`Delete range ${msg.params.range}?`)) {
75+
// prettier-ignore
76+
const lines = [
77+
`deleteRange: ${JSON.stringify(msg.params)}`,
78+
`To simulate deleting this item, press confirm`
79+
].join('\n',);
80+
if (confirm(lines)) {
6481
return Promise.resolve({ action: 'delete' });
6582
}
6683
return Promise.resolve({ action: 'none' });

special-pages/pages/history/app/strings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
{
2+
"menu_sectionTitle": {
3+
"title": "Show menu for {relativeTime}",
4+
"note": "Button text in a section heading to show a menu. The placeholder {relativeTime} will dynamically be replaced with values such as 'Today', 'Tomorrow', 'Yesterday', 'In 2 days', etc. For example, if {relativeTime} = 'Tomorrow', the title will become 'Show menu for Tomorrow'."
5+
},
26
"empty_title": {
37
"title": "Nothing to see here!",
48
"note": "Text shown where there are no remaining history items"

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,4 +266,43 @@ export class HistoryTestPage {
266266
expect(calls[0].payload.params).toStrictEqual({ range: 'all' });
267267
await expect(page.getByRole('heading', { level: 2, name: 'Nothing to see here!' })).toBeVisible();
268268
}
269+
270+
/**
271+
* @param {import('../types/history.ts').DeleteRangeResponse} resp
272+
*/
273+
async deletesFromSectionTitle(resp) {
274+
const { page } = this;
275+
276+
// Handle dialog interaction based on response action
277+
if (resp.action === 'delete') {
278+
page.on('dialog', (dialog) => {
279+
return dialog.accept();
280+
});
281+
} else {
282+
page.on('dialog', (dialog) => dialog.dismiss());
283+
}
284+
285+
// Hover over the "Today" section and open the menu
286+
const title = page.getByRole('list').getByText('Today');
287+
await title.hover();
288+
await title.getByLabel('Show menu for Today').click();
289+
290+
// Verify the call to "title_menu" with expected parameters
291+
const calls = await this.mocks.waitForCallCount({ method: 'title_menu', count: 1 });
292+
expect(calls[0].payload.params).toStrictEqual({ dateRelativeDay: 'Today' });
293+
294+
// verify the section is gone
295+
await expect(title.getByLabel('Show menu for Today')).not.toBeVisible();
296+
297+
// todo: re-enable this if it's required
298+
// await this.sideBarItemWasRemoved('Today');
299+
}
300+
301+
async rightClicksSectionTitle() {
302+
const { page } = this;
303+
const title = page.getByRole('list').getByText('Today');
304+
await title.click({ button: 'right' });
305+
const calls = await this.mocks.waitForCallCount({ method: 'title_menu', count: 1 });
306+
expect(calls[0].payload.params).toStrictEqual({ dateRelativeDay: 'Today' });
307+
}
269308
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,14 @@ test.describe('history', () => {
108108
await hp.openPage({});
109109
await hp.deletesAllHistoryFromHeader({ action: 'delete' });
110110
});
111+
test('3 dots menu on Section title', async ({ page }, workerInfo) => {
112+
const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000);
113+
await hp.openPage({});
114+
await hp.deletesFromSectionTitle({ action: 'delete' });
115+
});
116+
test('right-click on Section title', async ({ page }, workerInfo) => {
117+
const hp = HistoryTestPage.create(page, workerInfo).withEntries(2000);
118+
await hp.openPage({});
119+
await hp.rightClicksSectionTitle();
120+
});
111121
});

0 commit comments

Comments
 (0)