From 2cf60df7d790a40b4f705f5c6e3b5f8d36ba759a Mon Sep 17 00:00:00 2001 From: WIND <1652029918@qq.com> Date: Fri, 7 Aug 2026 00:35:24 +0800 Subject: [PATCH 1/2] fix vocabulary list for v2 data layer --- css/main.css | 248 +++++++- .../js/integration/vocabSessionView.test.js | 352 ++++++++++- developer/tests/js/vocabDataIO.test.js | 51 +- developer/tests/js/vocabStore.test.js | 65 ++ js/bundles/more.bundle.js | 580 ++++++++++++++++-- js/components/vocabSessionView.js | 486 ++++++++++++++- js/core/vocabStore.js | 42 +- js/utils/vocabDataIO.js | 52 +- 8 files changed, 1776 insertions(+), 100 deletions(-) diff --git a/css/main.css b/css/main.css index 61a69d4a..d446c41e 100644 --- a/css/main.css +++ b/css/main.css @@ -7157,6 +7157,229 @@ body.theme-blue ::-webkit-scrollbar-thumb { min-width: 96px; } +.vocab-list-modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(92, 70, 46, 0.35); + backdrop-filter: blur(6px); + z-index: 1200; + padding: 24px; +} + +.vocab-list-modal[hidden] { + display: none; +} + +.vocab-list-modal__backdrop { + position: absolute; + inset: 0; + z-index: 0; +} + +.vocab-list-modal__dialog { + width: min(1120px, 100%); + max-height: min(82vh, 760px); + display: flex; + flex-direction: column; + background: #fffaf2; + border-radius: 18px; + border: 1px solid var(--vocab-line); + box-shadow: var(--vocab-shadow-strong); + color: var(--vocab-ink); + position: relative; + z-index: 1; + overflow: hidden; +} + +.vocab-list-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 22px 14px; + border-bottom: 1px solid rgba(120, 90, 60, 0.16); +} + +.vocab-list-modal__header h3 { + margin: 0; + font-size: 1.25rem; + font-family: var(--vocab-font-title); +} + +.vocab-list-modal__subtitle { + margin: 6px 0 0; + color: var(--vocab-muted); + font-size: 0.9rem; +} + +.vocab-list-modal__toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto auto; + align-items: center; + gap: 12px; + padding: 14px 22px; + border-bottom: 1px solid rgba(120, 90, 60, 0.12); +} + +.vocab-list-modal__toolbar input[type="search"] { + width: 100%; + min-width: 0; + border: 1px solid var(--vocab-line); + border-radius: 10px; + background: var(--vocab-paper-bright); + color: var(--vocab-ink); + padding: 10px 12px; + font-size: 0.95rem; +} + +.vocab-list-modal__toolbar input[type="search"]:focus { + outline: none; + border-color: var(--vocab-accent); + box-shadow: 0 0 0 3px rgba(196, 104, 75, 0.18); +} + +.vocab-list-modal__filter { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; + color: var(--vocab-ink); + font-size: 0.9rem; +} + +.vocab-list-modal__filter input { + width: 17px; + height: 17px; +} + +.vocab-list-modal__stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 22px 0; +} + +.vocab-list-modal__stats span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(196, 104, 75, 0.1); + color: var(--vocab-accent-strong); + font-size: 0.86rem; + font-weight: 600; +} + +.vocab-list-modal__body { + flex: 1 1 220px; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 14px 22px 22px; +} + +.vocab-list-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 180px; + color: var(--vocab-muted); + border: 1px dashed var(--vocab-line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.5); +} + +.vocab-list-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid rgba(120, 90, 60, 0.16); + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); +} + +.vocab-list-pagination { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 40px; + padding-top: 8px; + color: var(--vocab-muted); + font-size: 0.86rem; +} + +.vocab-list-pagination .btn { + width: 32px; + height: 32px; + min-height: 32px; +} + +.vocab-list-table { + width: 100%; + min-width: 980px; + border-collapse: collapse; + font-size: 0.9rem; +} + +.vocab-list-table th, +.vocab-list-table td { + padding: 10px 12px; + border-bottom: 1px solid rgba(120, 90, 60, 0.12); + text-align: left; + vertical-align: top; +} + +.vocab-list-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f7ebdc; + color: var(--vocab-muted); + font-weight: 700; +} + +.vocab-list-table tbody tr:hover { + background: rgba(196, 104, 75, 0.08); +} + +.vocab-list-status { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; +} + +.vocab-list-status--mastered { + background: rgba(44, 143, 98, 0.14); + color: #257a55; +} + +.vocab-list-status--due { + background: rgba(191, 82, 72, 0.14); + color: #a54841; +} + +.vocab-list-status--reviewing { + background: rgba(196, 104, 75, 0.14); + color: var(--vocab-accent-strong); +} + +.vocab-list-status--new { + background: rgba(92, 70, 46, 0.09); + color: var(--vocab-muted); +} + @media (max-width: 768px) { .vocab-settings-modal { padding: 16px; @@ -7166,6 +7389,30 @@ body.theme-blue ::-webkit-scrollbar-thumb { width: 100%; padding: 20px; } + + .vocab-list-modal { + padding: 12px; + align-items: stretch; + } + + .vocab-list-modal__dialog { + max-height: none; + height: 100%; + border-radius: 14px; + } + + .vocab-list-modal__toolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .vocab-list-modal__toolbar .btn { + width: 100%; + } + + .vocab-list-modal__body { + padding: 12px; + } } /* Developer Easter Egg - Connected Heart */ @@ -8068,4 +8315,3 @@ body.blue-dark-mode .practice-settings-block__head .hero-panel__muted { } - diff --git a/developer/tests/js/integration/vocabSessionView.test.js b/developer/tests/js/integration/vocabSessionView.test.js index b927a1cf..e91d162e 100644 --- a/developer/tests/js/integration/vocabSessionView.test.js +++ b/developer/tests/js/integration/vocabSessionView.test.js @@ -12,6 +12,8 @@ const repoRoot = path.resolve(__dirname, '../../../..'); const originalConsoleLog = (console && typeof console.log === 'function') ? console.log.bind(console) : null; +let activeDocumentStub = null; +const modalFocusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; function emitResult(payload) { const text = JSON.stringify(payload, null, 2); @@ -65,6 +67,12 @@ function patchVocabSessionView(source) { closeMenu, bindEvents, toggleMenu, + getWordStatus, + analyzeListWords, + openListModal, + closeListModal, + exportCurrentList, + renderListBrowser, setElements: (elements) => { state.elements = elements || {}; }, setStore: (store) => { state.store = store; }, setScheduler: (scheduler) => { state.scheduler = scheduler; }, @@ -134,6 +142,7 @@ function createElementStub(tag = 'div', overrides = {}) { textContent: '', value: '', hidden: false, + disabled: false, appendChild(child) { if (!child) { return child; @@ -214,6 +223,9 @@ function createElementStub(tag = 'div', overrides = {}) { }, focus() { this._focused = true; + if (activeDocumentStub) { + activeDocumentStub.activeElement = this; + } }, click() { this._clicked = true; @@ -231,7 +243,7 @@ function createDocumentStub() { const listeners = new Map(); const body = createElementStub('body'); - return { + const documentStub = { body, activeElement: null, addEventListener(type, handler) { @@ -271,6 +283,8 @@ function createDocumentStub() { return element; } }; + activeDocumentStub = documentStub; + return documentStub; } function createWindowStub(documentStub) { @@ -471,6 +485,7 @@ function createSessionElements() { const settingsModal = createElementStub('div'); const settingsDialog = createElementStub('div'); + const settingsClose = createElementStub('button'); const settingsError = createElementStub('div'); const settingsForm = createElementStub('form'); @@ -482,6 +497,28 @@ function createSessionElements() { const listSwitcher = createElementStub('div'); const menuButton = createElementStub('button'); const menu = createElementStub('div'); + const listModal = createElementStub('div'); + const listDialog = createElementStub('div'); + const listSubtitle = createElementStub('p'); + const listSearch = createElementStub('input'); + const listLearnedOnly = createElementStub('input'); + const listStats = createElementStub('div'); + const listBody = createElementStub('div'); + const listClose = createElementStub('button'); + + settingsDialog.appendChild(dailyField); + settingsDialog.appendChild(settingsClose); + settingsDialog.__queryMap = { + 'input, button, select, textarea': dailyField + }; + settingsDialog.__queryListMap = { + [modalFocusableSelector]: [dailyField, settingsClose] + }; + listDialog.appendChild(listSearch); + listDialog.appendChild(listClose); + listDialog.__queryListMap = { + [modalFocusableSelector]: [listSearch, listClose] + }; return { root: createElementStub('div'), @@ -509,7 +546,16 @@ function createSessionElements() { }, listSwitcher, menuButton, - menu + menu, + listModal, + listDialog, + listSubtitle, + listSearch, + listLearnedOnly, + listStats, + listBody, + settingsClose, + listClose }; } @@ -605,6 +651,10 @@ async function run() { 'data-action="toggle-side-panel"', 'data-action="save-note"', 'data-vocab-role="import-input"', + 'data-action="menu-view-list"', + 'data-vocab-role="list-modal"', + 'data-vocab-role="list-dialog"', + 'data-action="export-current-list"', 'data-vocab-role="settings-modal"' ]; markers.forEach((marker) => { @@ -612,6 +662,22 @@ async function run() { }); }); + await record('list modal body owns the table scroll', () => { + const css = readSource('css/main.css'); + const bodyRule = css.match(/\.vocab-list-modal__body\s*\{([^}]*)\}/); + const tableWrapRule = css.match(/\.vocab-list-table-wrap\s*\{([^}]*)\}/); + assert.ok(bodyRule, 'Missing list modal body rule'); + assert.ok(tableWrapRule, 'Missing table wrapper rule'); + assert.match(bodyRule[1], /flex:\s*1 1 220px/); + assert.match(bodyRule[1], /min-height:\s*0/); + assert.match(bodyRule[1], /overflow:\s*hidden/); + assert.match(tableWrapRule[1], /flex:\s*1/); + assert.match(tableWrapRule[1], /min-height:\s*0/); + assert.match(tableWrapRule[1], /overflow:\s*auto/); + assert.doesNotMatch(css, /@media\s*\(max-height:\s*480px\)/); + assert.doesNotMatch(css, /@media\s*\(max-height:\s*300px\)/); + }); + const vocabContext = createVocabContext(); const hooks = vocabContext.hooks; const windowStub = vocabContext.window; @@ -986,6 +1052,7 @@ async function run() { }; hooks.state.session.batchSize = 1; + hooks.openSettingsModal(elements.menuButton); await hooks.handleSettingsSubmit({ preventDefault() {}, currentTarget: elements.settingsForm @@ -1008,6 +1075,287 @@ async function run() { assert.ok(elements.menu.hidden); }); + await record('settings modal restores focus to visible menu trigger', () => { + const hiddenMenuItem = createElementStub('button'); + const trigger = createElementStub('button'); + trigger.dataset.action = 'menu-settings'; + documentStub.activeElement = hiddenMenuItem; + elements.menuButton._focused = false; + + elements.menu.dispatchEvent({ + type: 'click', + target: { + closest(selector) { + return selector === 'button[data-action]' ? trigger : null; + } + } + }); + hooks.closeSettingsModal(); + + assert.strictEqual(elements.menuButton._focused, true); + assert.ok(!hiddenMenuItem._focused); + }); + + await record('list status keeps due mastered words in the review queue', () => { + const config = { masteryCount: 4 }; + const dueMastered = hooks.getWordStatus({ + correctCount: 4, + nextReview: new Date(Date.now() - 60_000).toISOString() + }, config); + const futureMastered = hooks.getWordStatus({ + correctCount: 4, + nextReview: new Date(Date.now() + 60_000).toISOString() + }, config); + + assert.strictEqual(dueMastered.tone, 'due'); + assert.strictEqual(futureMastered.tone, 'mastered'); + + hooks.setStore(createMockStore([ + { word: 'alpha', meaning: 'A', correctCount: 4, nextReview: new Date(Date.now() - 60_000).toISOString() } + ], config)); + const analysis = hooks.analyzeListWords(); + assert.strictEqual(analysis.masteredCount, 1); + assert.strictEqual(analysis.dueCount, 1); + }); + + await record('list modal restores focus to visible menu trigger', async () => { + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + const hiddenMenuItem = createElementStub('button'); + const trigger = createElementStub('button'); + trigger.dataset.action = 'menu-view-list'; + hooks.setStore(store); + documentStub.activeElement = hiddenMenuItem; + elements.menuButton._focused = false; + + elements.menu.dispatchEvent({ + type: 'click', + target: { + closest(selector) { + return selector === 'button[data-action]' ? trigger : null; + } + } + }); + await flushPromises(); + hooks.closeListModal(); + + assert.strictEqual(elements.menuButton._focused, true); + assert.ok(!hiddenMenuItem._focused); + }); + + await record('Escape cancels a pending list modal open', async () => { + let resolveInit; + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + store.init = () => new Promise((resolve) => { + resolveInit = resolve; + }); + hooks.setStore(store); + const opening = hooks.openListModal(elements.menuButton); + let prevented = false; + + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Escape', + preventDefault() { + prevented = true; + } + }); + resolveInit(true); + await opening; + + assert.strictEqual(prevented, true); + assert.notStrictEqual(elements.listModal.dataset.open, 'true'); + assert.ok(elements.listModal.hidden); + }); + + await record('list and settings modals remain mutually exclusive', async () => { + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + hooks.setStore(store); + + await hooks.openListModal(elements.menuButton); + hooks.openSettingsModal(elements.menuButton); + assert.strictEqual(elements.settingsModal.dataset.open, 'true'); + assert.strictEqual(elements.listModal.dataset.open, 'false'); + + await hooks.openListModal(elements.menuButton); + assert.strictEqual(elements.listModal.dataset.open, 'true'); + assert.strictEqual(elements.settingsModal.dataset.open, 'false'); + hooks.closeListModal(); + }); + + await record('settings writes keep the latest submitted values', async () => { + const pendingSaves = []; + const store = createMockStore(); + store.setConfig = (config) => new Promise((resolve) => { + pendingSaves.push(() => { + store.config = { ...store.config, ...config }; + resolve(true); + }); + }); + hooks.setStore(store); + elements.settingsForm.__fields = { + dailyNew: '10', + reviewLimit: '50', + masteryCount: '3' + }; + hooks.openSettingsModal(elements.menuButton); + const pendingSave = hooks.handleSettingsSubmit({ + preventDefault() {}, + currentTarget: elements.settingsForm + }); + await flushPromises(); + + hooks.closeSettingsModal(); + hooks.openSettingsModal(elements.menuButton); + elements.settingsForm.__fields = { + dailyNew: '30', + reviewLimit: '80', + masteryCount: '5', + notify: '1' + }; + const latestSave = hooks.handleSettingsSubmit({ + preventDefault() {}, + currentTarget: elements.settingsForm + }); + assert.strictEqual(pendingSaves.length, 1); + + pendingSaves.shift()(); + await pendingSave; + await flushPromises(); + assert.strictEqual(pendingSaves.length, 1); + pendingSaves.shift()(); + await latestSave; + + assert.strictEqual(store.config.dailyNew, 30); + assert.strictEqual(store.config.reviewLimit, 80); + assert.strictEqual(store.config.masteryCount, 5); + assert.strictEqual(store.config.notify, true); + assert.strictEqual(elements.settingsModal.dataset.open, 'false'); + }); + + await record('Tab stays inside the active modal', async () => { + let prevented = false; + hooks.openSettingsModal(elements.menuButton); + elements.settingsClose.focus(); + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Tab', + key: 'Tab', + shiftKey: false, + preventDefault() { + prevented = true; + } + }); + assert.strictEqual(prevented, true); + assert.strictEqual(documentStub.activeElement, elements.settingsFields.dailyNew); + + prevented = false; + elements.settingsFields.dailyNew.focus(); + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Tab', + key: 'Tab', + shiftKey: true, + preventDefault() { + prevented = true; + } + }); + assert.strictEqual(prevented, true); + assert.strictEqual(documentStub.activeElement, elements.settingsClose); + hooks.closeSettingsModal(); + + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + hooks.setStore(store); + await hooks.openListModal(elements.menuButton); + elements.listClose.focus(); + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Tab', + key: 'Tab', + shiftKey: false, + preventDefault() {} + }); + assert.strictEqual(documentStub.activeElement, elements.listSearch); + hooks.closeListModal(); + }); + + await record('list rendering is paged and resets after search', async () => { + const words = Array.from({ length: 401 }, (_, index) => ({ + word: `word-${String(index + 1).padStart(3, '0')}`, + meaning: `Meaning ${index + 1}` + })); + const store = createMockStore(words); + let getWordsCalls = 0; + const originalGetWords = store.getWords.bind(store); + store.getWords = () => { + getWordsCalls += 1; + return originalGetWords(); + }; + hooks.setStore(store); + hooks.state.ui.listBrowserQuery = ''; + hooks.state.ui.listBrowserLearnedOnly = false; + hooks.state.ui.listBrowserPage = 1; + + hooks.renderListBrowser(); + const firstBody = elements.listBody.innerHTML.match(/
([\s\S]*?)<\/tbody>/)[1]; + assert.strictEqual((firstBody.match(/