From 3542554c3bb1c445bd528b51696332184bca6ff8 Mon Sep 17 00:00:00 2001 From: Maksim Kostromin Date: Thu, 8 Oct 2026 01:16:59 -0400 Subject: [PATCH] feat(app): ask what to reset in a Clear dialog, like in Stocks, and remember the choice --- README.md | 2 +- src/index.css | 27 ++++++++++ src/index.html | 2 +- src/main.tsx | 136 ++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 146 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b234a81..ca111c3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The published application is available at ="2024-06-15 09:30"`) diff --git a/src/index.css b/src/index.css index 9592df8..f73c5bd 100644 --- a/src/index.css +++ b/src/index.css @@ -307,3 +307,30 @@ } .dark #app-summary { background: #0f172a; color: #cbd5e1; border-color: #475569; } +/* Clear dialog: what to reset, in the style of the dropdown panels (see clearSelectionAndSearch in main.tsx) */ +.clear-backdrop{position:fixed;inset:0;z-index:2000;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgb(15 23 42 / .5);animation:clearFade .14s ease} +.clear-modal{width:min(26rem,100%);max-height:calc(100vh - 2rem);display:flex;flex-direction:column;gap:.75rem;padding:1.25rem;background:#fff;color:#0f172a;border:1px solid #e2e8f0;border-radius:.75rem;box-shadow:0 12px 30px rgb(15 23 42 / .16),0 2px 6px rgb(15 23 42 / .06)} +.dark .clear-modal{background:#0f172a;color:#e2e8f0;border-color:#334155;box-shadow:0 12px 30px rgb(0 0 0 / .5),0 2px 6px rgb(0 0 0 / .3)} +.clear-title{font-size:1.05rem;font-weight:600} +.clear-hint{font-size:.75rem;color:#64748b} +.dark .clear-hint{color:#94a3b8} +.clear-tools{display:flex;gap:.4rem} +.clear-list{display:flex;flex-direction:column;gap:.1rem;overflow:auto;padding:.25rem;border:1px solid #e2e8f0;border-radius:.5rem} +.dark .clear-list{border-color:#334155} +.clear-row{display:flex;align-items:center;gap:.6rem;padding:.4rem .5rem;border-radius:.4rem;font-size:.875rem;cursor:pointer;user-select:none} +.clear-row:hover{background:#f1f5f9} +.dark .clear-row:hover{background:#1e293b} +.clear-actions{display:flex;justify-content:flex-end;gap:.5rem} +.clear-btn{display:inline-flex;align-items:center;font-size:.8125rem;font-weight:500;padding:.45rem .9rem;border-radius:.5rem;border:1px solid #e2e8f0;background:#fff;color:#334155;cursor:pointer;transition:background-color .12s,border-color .12s} +.clear-btn:hover{background:#f1f5f9} +.dark .clear-btn{background:#0f172a;border-color:#334155;color:#e2e8f0} +.dark .clear-btn:hover{background:#1e293b} +.clear-btn-ok{background:#dc2626;border-color:#dc2626;color:#fff} +.clear-btn-ok:hover{background:#b91c1c} +.dark .clear-btn-ok{background:#dc2626;border-color:#dc2626;color:#fff} +.dark .clear-btn-ok:hover{background:#b91c1c} +.clear-btn:disabled{opacity:.5;cursor:not-allowed} +.clear-btn kbd{margin-left:.5rem;padding:0 .3rem;font:inherit;font-size:.65rem;opacity:.7;border:1px solid currentColor;border-radius:.25rem} +.clear-btn:focus-visible,.clear-row:focus-within{outline:2px solid #3b82f6;outline-offset:2px} +@keyframes clearFade{from{opacity:0}to{opacity:1}} +@media (prefers-reduced-motion:reduce){.clear-backdrop{animation:none}} diff --git a/src/index.html b/src/index.html index caa1738..edd8fdc 100644 --- a/src/index.html +++ b/src/index.html @@ -104,7 +104,7 @@

Export .csv - + diff --git a/src/main.tsx b/src/main.tsx index 531d505..f1c75f7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3302,25 +3302,32 @@ function activateFund(ticker: string): void { } /** - * Everything the Clear button resets: each label names what is reset, the saved keys are removed (a reload shows the - * first-visit view) and reset() puts the state back to its default. Not listed, so kept: the blacklist and the theme. + * Everything the Clear dialog can reset. Each item has a stable id (the remembered choice is stored by id), the saved keys that + * are removed (a reload shows the first-visit view) and reset(), which puts the state back to its default. The theme is not + * listed, so it is always kept. */ -const RESET_ITEMS: { label: string; keys: string[]; reset(): void }[] = [ - { label: 'selected ETFs', keys: [SELECTED_KEY, ACTIVE_FUND_KEY], reset: () => { state.selected = new Set(DEFAULT_SELECTED_TICKERS); state.activeFundTicker = [...state.selected][0] || null; } }, - { label: 'searches', keys: [FILTERS_KEY, LEGACY_FILTERS_KEY], reset: () => { state.queryByTab = {}; } }, - { label: 'sort order', keys: [SORTS_KEY], reset: () => { state.sortByTab = {}; } }, - { label: 'open tab', keys: [SITE_STATE_KEY], reset: () => { state.activeTab = 'All'; } }, - { label: 'shown asset classes', keys: [HIDDEN_CATEGORIES_KEY], reset: () => { hiddenCategories = new Set(); } }, - { label: 'shown columns', keys: [COLUMN_VISIBILITY_KEY], reset: () => { hiddenColumns = new Set(); } }, - { label: 'column filters and types', keys: [COLUMN_FILTERS_KEY, COLUMN_TYPES_KEY], reset: () => { columnFilterState.filters = {}; columnFilterState.typeOverrides = {}; } }, - { label: 'Filters on/off', keys: [SHOW_FILTERS_KEY], reset: () => { columnFilterState.show = true; } }, - { label: 'Sticky #', keys: [STICKY_RANK_KEY], reset: () => { columnFilterState.sticky = false; } }, - { label: 'remembered table views', keys: [VIEW_KEY], reset: () => { savedViews = {}; settledViewTabs.clear(); restoringViewTabs.clear(); clearTimeout(saveViewTimer); } }, +type ResetItem = { id: string; label: string; keys: string[]; reset(): void }; +const CLEAR_CHOICES_KEY = `${THEME_KEY.replace(/-theme$/, '')}-clear-choices`; // what the Clear dialog had ticked at the last OK (not reset by Clear itself) +const RESET_ITEMS: ResetItem[] = [ + { id: 'selected-etfs', label: 'Selected ETFs', keys: [SELECTED_KEY, ACTIVE_FUND_KEY], reset: () => { state.selected = new Set(DEFAULT_SELECTED_TICKERS); state.activeFundTicker = [...state.selected][0] || null; } }, + { id: 'searches', label: 'Searches', keys: [FILTERS_KEY, LEGACY_FILTERS_KEY], reset: () => { state.queryByTab = {}; } }, + { id: 'sort-order', label: 'Sort order', keys: [SORTS_KEY], reset: () => { state.sortByTab = {}; } }, + { id: 'open-tab', label: 'Open tab', keys: [SITE_STATE_KEY], reset: () => { state.activeTab = 'All'; } }, + { id: 'shown-asset-classes', label: 'Shown asset classes', keys: [HIDDEN_CATEGORIES_KEY], reset: () => { hiddenCategories = new Set(); } }, + { id: 'shown-columns', label: 'Shown columns', keys: [COLUMN_VISIBILITY_KEY], reset: () => { hiddenColumns = new Set(); } }, + { id: 'column-filters-and-types', label: 'Column filters and types', keys: [COLUMN_FILTERS_KEY, COLUMN_TYPES_KEY], reset: () => { columnFilterState.filters = {}; columnFilterState.typeOverrides = {}; } }, + { id: 'filters-on-off', label: 'Filters on/off', keys: [SHOW_FILTERS_KEY], reset: () => { columnFilterState.show = true; } }, + { id: 'sticky', label: 'Sticky #', keys: [STICKY_RANK_KEY], reset: () => { columnFilterState.sticky = false; } }, + { id: 'remembered-table-views', label: 'Remembered table views', keys: [VIEW_KEY], reset: () => { savedViews = {}; settledViewTabs.clear(); restoringViewTabs.clear(); clearTimeout(saveViewTimer); } }, + { id: 'blacklist', label: 'Blacklist', keys: [BLACKLIST_KEY], reset: () => { state.blacklist.clear(); } }, ]; -/** The Clear button: everything but the blacklist and the theme goes back to the first-visit view, no confirm dialog. */ -function clearSelectionAndSearch(): void { - RESET_ITEMS.forEach(item => { item.keys.forEach(filterStorageRemove); item.reset(); }); +/** Resets the chosen items to the first-visit state at once. */ +function applyClear(ids: Set): void { + const picked = RESET_ITEMS.filter(item => ids.has(item.id)); + if (!picked.length) return; + picked.forEach(item => item.reset()); + picked.forEach(item => { item.keys.forEach(filterStorageRemove); }); [columnsDd, categoriesDd].forEach(dd => dd?.close()); renderColumnsButton(); filterTimers.forEach(timer => clearTimeout(timer)); @@ -3331,13 +3338,104 @@ function clearSelectionAndSearch(): void { watchlistChunkSig = ''; applySortForTab(state.activeTab); resetSheetPaging(); - el.searchInput.value = ''; + if (ids.has('searches')) el.searchInput.value = ''; syncSearchInput(); - el.tableScroll.scrollTop = 0; - el.tableScroll.scrollLeft = 0; + if (ids.has('remembered-table-views')) { + el.tableScroll.scrollTop = 0; + el.tableScroll.scrollLeft = 0; + } + renderBlacklistPanel(); render(false); } +/** Which items were ticked the last time OK was pressed; an item never seen before is ticked. */ +function loadClearChoices(): Record { + try { + const saved = JSON.parse(localStorage.getItem(CLEAR_CHOICES_KEY) || '{}'); + return saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : {}; + } catch { + return {}; + } +} + +/** + * The Clear button: a dialog in the style of the app lists what can be reset (all ticked the first time, afterwards as + * it was left at the last OK). Enter is OK, Esc or a click outside is Cancel, Cancel changes and remembers nothing. + */ +function clearSelectionAndSearch(): void { + if (document.getElementById('clear-dialog')) return; + const saved = loadClearChoices(); + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const backdrop = document.createElement('div'); + backdrop.id = 'clear-dialog'; + backdrop.className = 'clear-backdrop'; + backdrop.innerHTML = ` + `; + document.body.appendChild(backdrop); + const boxes = Array.from(backdrop.querySelectorAll('input[data-clear-id]')); + const okBtn = backdrop.querySelector('[data-clear-ok]'); + const allBtn = backdrop.querySelector('[data-clear-all]'); + const noneBtn = backdrop.querySelector('[data-clear-none]'); + const cancelBtn = backdrop.querySelector('[data-clear-cancel]'); + if (!okBtn || !allBtn || !noneBtn || !cancelBtn) return; + const syncOk = (): void => { okBtn.disabled = !boxes.some(box => box.checked); }; + const close = (): void => { + document.removeEventListener('keydown', onKey, true); + backdrop.remove(); + if (opener && typeof opener.focus === 'function') opener.focus(); + }; + const ok = (): void => { + if (okBtn.disabled) return; + const choices: Record = {}; + boxes.forEach(box => { choices[box.dataset.clearId || ''] = box.checked; }); + try { localStorage.setItem(CLEAR_CHOICES_KEY, JSON.stringify(choices)); } catch { /* private mode: the choice is simply not remembered */ } + close(); + applyClear(new Set(Object.keys(choices).filter(id => choices[id]))); + }; + const onKey = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + close(); + } else if (event.key === 'Enter') { + const target = event.target instanceof HTMLElement ? event.target : null; + if (target && target.closest('[data-clear-cancel], [data-clear-all], [data-clear-none]')) return; // Enter on a focused button presses that button + event.preventDefault(); + event.stopPropagation(); + ok(); + } else if (event.key === 'Tab') { + const focusable = Array.from(backdrop.querySelectorAll('input, button:not(:disabled)')); + const index = document.activeElement instanceof HTMLElement ? focusable.indexOf(document.activeElement) : -1; + const next = event.shiftKey ? (index <= 0 ? focusable.length - 1 : index - 1) : (index === focusable.length - 1 ? 0 : index + 1); + event.preventDefault(); + focusable[next].focus(); + } + }; + document.addEventListener('keydown', onKey, true); + backdrop.addEventListener('mousedown', event => { if (event.target === backdrop) close(); }); + backdrop.addEventListener('change', syncOk); + allBtn.addEventListener('click', () => { boxes.forEach(box => { box.checked = true; }); syncOk(); }); + noneBtn.addEventListener('click', () => { boxes.forEach(box => { box.checked = false; }); syncOk(); }); + cancelBtn.addEventListener('click', close); + okBtn.addEventListener('click', ok); + syncOk(); + okBtn.focus(); +} + function blacklistTickers(rawTickers: string[]): void { const known = new Set(state.funds.map(fund => fund.ticker)); rawTickers