From 77448a72b274d258d7e80614005699f0b1937bc2 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 20:15:06 -0400 Subject: [PATCH] Make mobile composer options clearly dismissible --- .../static/js/components/compactComposer.js | 44 +++++++- .../web/von_interface/static/styles.css | 1 + tests/browser/composerControls.cjs | 35 ++++++ tests/frontend/compactComposer.test.js | 103 ++++++++++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 tests/frontend/compactComposer.test.js diff --git a/src/frontend/web/von_interface/static/js/components/compactComposer.js b/src/frontend/web/von_interface/static/js/components/compactComposer.js index 05881ea7e..e81277d2c 100644 --- a/src/frontend/web/von_interface/static/js/components/compactComposer.js +++ b/src/frontend/web/von_interface/static/js/components/compactComposer.js @@ -34,6 +34,42 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp const menu = composer.querySelector('.chat-composer-more-actions'); const panel = menu?.querySelector('.button-row'); if (!panel) return; + const summary = menu.querySelector('summary'); + const label = summary?.querySelector('.composer-more-label'); + let closeWatcher; + function dismiss(restoreFocus = false) { + if (!menu.open) return; + const focusWasInPanel = panel.contains(document.activeElement); + menu.open = false; + if (restoreFocus || focusWasInPanel) summary?.focus(); + updateDisclosure(); + } + function updateDisclosure() { + const name = menu.open ? 'Close actions' : 'More actions'; + if (label) label.textContent = name; + if (summary) summary.title = name; + // Use the browser's close-request stack (including Android Back where + // supported), without adding synthetic entries to conversation history. + if (menu.open && isCompactComposer() && menu.isConnected) { + if (!closeWatcher && typeof window.CloseWatcher === 'function') { + closeWatcher = new window.CloseWatcher(); + closeWatcher.addEventListener('close', () => dismiss(true)); + } + } else { + closeWatcher?.destroy(); + closeWatcher = undefined; + } + } + menu.addEventListener('toggle', updateDisclosure); + // Wait for the completed click: collapsing during pointer/focus events can + // move Send before touch activation reaches it. + document.addEventListener('click', event => { + if (menu.isConnected && isCompactComposer() && !menu.contains(event.target)) dismiss(); + }); + // Keyboard activation also dismisses before the existing submission handler. + composer.querySelector('#sendButton')?.addEventListener('click', () => { + if (isCompactComposer()) dismiss(); + }, { capture: true }); const active = document.createElement('div'); active.className = 'compact-composer-active'; composer.append(active); @@ -74,6 +110,7 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp } } } + updateDisclosure(); resizeDraft(input); } const observer = new MutationObserver(update); @@ -84,15 +121,14 @@ export function initialiseCompactChatComposer(composer, resizeDraft = resizeComp } menu.addEventListener('keydown', event => { if (event.key === 'Escape') { - menu.open = false; - menu.querySelector('summary').focus(); + event.preventDefault(); + dismiss(true); } }); menu.addEventListener('click', event => { if (isCompactComposer() && event.target.closest('#dictateButton, #voiceConversationButton, #uploadFileButton')) { - menu.open = false; // Recording controls become visible on the controller's next render. - menu.querySelector('summary').focus(); + dismiss(true); } }); window.matchMedia?.(COMPACT_COMPOSER_QUERY).addEventListener?.('change', update); diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 622d9a6c6..3b8918261 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18527,6 +18527,7 @@ body.settings-body { } .composer-more-icon { display: none; } .composer-add-icon { display: inline; font-size: 24px; font-weight: 400; } + .chat-composer-more-actions[open] > summary .composer-add-icon { transform: rotate(45deg); } .chat-composer-more-actions:not([open]) > .button-row { display: none; } .chat-composer-more-actions[open] > .button-row { grid-column: 1 / -1; grid-row: 2; diff --git a/tests/browser/composerControls.cjs b/tests/browser/composerControls.cjs index 2698aa6f6..edb5ca8ff 100644 --- a/tests/browser/composerControls.cjs +++ b/tests/browser/composerControls.cjs @@ -126,6 +126,41 @@ async function bounds(page, selector) { await page.keyboard.press('Space'); await expect(page.locator('.chat-composer-more-actions')).not.toHaveAttribute('open', ''); + if (!profile.desktop) { + const menu = page.locator('.chat-composer-more-actions'); + const closedHeight = (await bounds(page, '.chat-composer')).height; + for (let repeat = 0; repeat < 3; repeat++) { + await summary.tap(); + await expect(menu).toHaveAttribute('open', ''); + await expect(summary).toHaveAccessibleName('Close actions'); + await summary.tap(); + await expect(menu).not.toHaveAttribute('open', ''); + await expect(summary).toHaveAccessibleName('More actions'); + } + await summary.tap(); + await input.tap(); + await expect(menu).not.toHaveAttribute('open', ''); + await expect(input).toBeFocused(); + await expect(input).toHaveValue('Draft for the interaction check'); + await summary.tap(); + await page.locator('#uploadFileButton').focus(); + await page.keyboard.press('Escape'); + await expect(menu).not.toHaveAttribute('open', ''); + await expect(summary).toBeFocused(); + await page.evaluate(() => { + window.fixtureSubmissions = []; + document.querySelector('#sendButton').addEventListener('click', () => { + window.fixtureSubmissions.push(document.querySelector('#promptInput').value); + }); + }); + await summary.tap(); + await send.tap(); + assert.deepEqual(await page.evaluate(() => window.fixtureSubmissions), ['Draft for the interaction check']); + await expect(menu).not.toHaveAttribute('open', ''); + assert.equal((await bounds(page, '.chat-composer')).height, closedHeight, 'dismissal restores compact layout'); + if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-dismissed.png`) }); + } + // Bind the production dictation controller with a fake recorder. await page.evaluate(async () => { const { createDictationController } = await import('/static/js/dictation.js'); diff --git a/tests/frontend/compactComposer.test.js b/tests/frontend/compactComposer.test.js new file mode 100644 index 000000000..b52fdab62 --- /dev/null +++ b/tests/frontend/compactComposer.test.js @@ -0,0 +1,103 @@ +const { + initialiseCompactChatComposer, + shouldSubmitComposerKey +} = require('../../src/frontend/web/von_interface/static/js/components/compactComposer.js'); + +let menu, summary, input, send; +const flushToggle = () => menu.dispatchEvent(new Event('toggle')); + +beforeEach(() => { + window.matchMedia = jest.fn(() => ({ matches: true, addEventListener: jest.fn() })); + document.body.innerHTML = `
+ +
More actions +
+
`; + menu = document.querySelector('details'); + summary = document.querySelector('summary'); + input = document.querySelector('textarea'); + send = document.querySelector('#sendButton'); + initialiseCompactChatComposer(document.querySelector('.chat-composer'), () => {}); +}); + +afterEach(() => { + menu.open = false; + flushToggle(); + delete window.CloseWatcher; + document.body.innerHTML = ''; +}); + +function open() { + menu.open = true; + flushToggle(); +} + +test('outside click dismisses without clearing the draft; option interactions remain open', () => { + open(); + document.querySelector('select').click(); + expect(menu.open).toBe(true); + document.querySelector('#outside').click(); + expect(menu.open).toBe(false); + expect(input.value).toBe('Preserved draft'); +}); + +test('Send reaches its existing handler exactly once with the draft intact and options closed', () => { + const submit = jest.fn(() => { + expect(menu.open).toBe(false); + expect(input.value).toBe('Preserved draft'); + }); + send.addEventListener('click', submit); + open(); + send.click(); + expect(submit).toHaveBeenCalledTimes(1); +}); + +test('Escape restores toggle focus; leaving the options preserves destination focus', () => { + open(); + const option = document.querySelector('select'); + option.focus(); + option.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(menu.open).toBe(false); + expect(document.activeElement).toBe(summary); + open(); + option.focus(); + input.focus(); + expect(menu.open).toBe(true); + input.click(); + expect(menu.open).toBe(false); + expect(document.activeElement).toBe(input); +}); + +test('browser close requests close the menu, restore focus and release the watcher', () => { + const watchers = []; + window.CloseWatcher = class extends EventTarget { + constructor() { super(); this.destroy = jest.fn(); watchers.push(this); } + }; + open(); + expect(watchers).toHaveLength(1); + watchers[0].dispatchEvent(new Event('close')); + expect(menu.open).toBe(false); + expect(document.activeElement).toBe(summary); + expect(watchers[0].destroy).toHaveBeenCalledTimes(1); + open(); + expect(watchers).toHaveLength(2); + summary.click(); + flushToggle(); + expect(watchers[1].destroy).toHaveBeenCalledTimes(1); +}); + +test('desktop outside clicks keep existing disclosure behaviour', () => { + window.matchMedia.mockReturnValue({ matches: false }); + open(); + document.querySelector('#outside').click(); + expect(menu.open).toBe(true); +}); + +test('mobile Enter remains a newline; desktop submission and composition rules are preserved', () => { + expect(shouldSubmitComposerKey({ key: 'Enter' })).toBe(false); + window.matchMedia.mockReturnValue({ matches: false }); + expect(shouldSubmitComposerKey({ key: 'Enter' })).toBe(true); + expect(shouldSubmitComposerKey({ key: 'Enter', shiftKey: true })).toBe(false); + expect(shouldSubmitComposerKey({ key: 'Enter', isComposing: true })).toBe(false); + expect(shouldSubmitComposerKey({ key: 'Enter', keyCode: 229 })).toBe(false); +});