fix: keep the open message when / focuses Mountain View's search field in the top bar

This commit is contained in:
Linus Rath
2026-11-03 14:47:08 +01:00
parent 0f799454e4
commit fcfae58a70
3 changed files with 33 additions and 6 deletions
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
@@ -22,6 +22,7 @@ vi.mock('@/components/layout/mv-top-bar', () => ({
<>
<button onClick={() => search.onSubmit('invoice')}>stub search</button>
<button onClick={() => search.onSelectContact({ email: 'bob@example.org' }, 'from')}>stub contact</button>
<input aria-label="stub search field" data-search-input />
</>
),
}));
@@ -129,4 +130,14 @@ describe('searching in Mountain View while a message is open', () => {
await waitFor(() => expect(advancedSearch).toHaveBeenCalled());
expect(selectedId()).toBeNull();
});
it('leaves the message open for the / shortcut, whose field is in the top bar', async () => {
render(<MailApp />);
await screen.findByText('message viewer');
act(() => { fireEvent.keyDown(window, { key: '/' }); });
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('stub search field')));
// Long enough for a go-back that runs after the field is focused.
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
expect(selectedId()).toBe('m1');
});
});
@@ -47,7 +47,7 @@ vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => <input aria-label="stub search field" data-search-input /> }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
@@ -288,6 +288,17 @@ describe('A list collapsed under an open message (focused layout)', () => {
act(() => { fireEvent.keyDown(window, { key: '/' }); });
await waitFor(() => expect(selectedId()).toBeNull());
await waitFor(() => expect(list()).not.toHaveAttribute('inert'));
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('stub search field')));
});
it('keeps the selection for the / shortcut while a linked message is still arriving', async () => {
// The mailboxes never arrive, so the link stays unapplied and the list collapsed for it.
useEmailStore.setState({ fetchMailboxes: vi.fn(() => new Promise(() => {})) } as never);
render(<MailApp linkSegments={['message', 'm2']} />);
expect(list()).toHaveAttribute('inert');
act(() => { fireEvent.keyDown(window, { key: '/' }); });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
expect(selectedId()).toBe('m1');
});
});
+9 -4
View File
@@ -1125,15 +1125,20 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
onFocusSearch: () => {
if (isScheduledView) return;
const findSearchInput = () => document.querySelector<HTMLInputElement>('[data-search-input]');
const focusSearch = () => {
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
const searchInput = findSearchInput();
if (searchInput) {
searchInput.focus();
searchInput.select();
}
};
// The field is in the list, which an open message can have collapsed.
if (shouldCollapseListPane) {
// The field can be in the list, which an open message has collapsed, and
// that message has to close first. Mountain View's is in the top bar.
if (findSearchInput()?.closest('[inert]')) {
// A linked message still arriving holds the list collapsed: nothing to
// go back to yet.
if (deepLinkPane !== null) return;
handleMobileBack();
requestAnimationFrame(() => requestAnimationFrame(focusSearch));
} else {
@@ -1220,7 +1225,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled]);
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane]);
// Initialize keyboard shortcuts
// Mountain View keeps the ticked rows while a message is read (#1231). The