fix(web): interaction with some filter elements closes the search panel (#31107)

This commit is contained in:
Alex
2026-08-31 19:58:57 +02:00
committed by GitHub
parent 06f6104e1a
commit 9dead7857f
5 changed files with 119 additions and 4 deletions
@@ -11,7 +11,7 @@
import { t } from 'svelte-i18n';
import SearchFilters from './SearchFilters.svelte';
import { searchManager } from '$lib/managers/search-manager.svelte';
import { getSearchTypePlaceholder } from './search-bar-utils';
import { getSearchTypePlaceholder, isPopoverContent } from './search-bar-utils';
type Props = {
grayTheme: boolean;
@@ -67,10 +67,22 @@
searchStore.isSearchEnabled = true;
};
const onFocusOut = () => {
const onFocusOut = (event: FocusEvent) => {
if (isPopoverContent(event)) {
return;
}
searchStore.isSearchEnabled = false;
};
const onDropdownFocusOut = (event: FocusEvent) => {
if (isPopoverContent(event)) {
return;
}
closeDropdown();
};
const onHistoryTermClick = async (searchTerm: string) => {
searchManager.filter.query = searchTerm;
await handleSearch();
@@ -138,7 +150,7 @@
onfocusin={onFocusIn}
role="search"
>
<div use:focusOutside={{ onFocusOut: closeDropdown }} tabindex="-1">
<div use:focusOutside={{ onFocusOut: onDropdownFocusOut }} tabindex="-1">
<label for="main-search-bar" class="sr-only">{$t('search_your_photos')}</label>
<input
type="text"
@@ -0,0 +1,60 @@
import type { TagResponseDto } from '@immich/sdk';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
import SearchTagsSection from '$lib/components/shared-components/search-bar/SearchTagsSection.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { searchManager } from '$lib/managers/search-manager.svelte';
import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
const tag = (id: string, value: string): TagResponseDto => ({
id,
name: value,
value,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
});
describe('SearchTagsSection component', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', getVisualViewportMock());
authManager.setUser(userAdminFactory.build());
authManager.setPreferences(preferencesFactory.build({ tags: { enabled: true, sidebarWeb: true } }));
searchManager.setQuery({ tagIds: ['tag-1', 'tag-2'] });
});
afterEach(() => {
authManager.reset();
searchManager.reset();
});
it('removes the tag when its chip is clicked', async () => {
const user = userEvent.setup();
render(SearchTagsSection, {
props: { title: undefined, parentPromise: Promise.resolve([tag('tag-1', 'holiday'), tag('tag-2', 'family')]) },
});
await user.click(await screen.findByRole('button', { name: 'holiday' }));
expect(screen.queryByRole('button', { name: 'holiday' })).not.toBeInTheDocument();
expect([...(searchManager.filter.tagIds ?? [])]).toEqual(['tag-2']);
});
// removing the focused chip would otherwise drop focus to the body, which the
// search bar treats as focus leaving the search panel
it('keeps focus inside the section when a chip is removed', async () => {
const user = userEvent.setup();
const { container } = render(SearchTagsSection, {
props: { title: undefined, parentPromise: Promise.resolve([tag('tag-1', 'holiday'), tag('tag-2', 'family')]) },
});
await user.click(await screen.findByRole('button', { name: 'holiday' }));
expect(document.activeElement).not.toBe(document.body);
expect(container.contains(document.activeElement)).toBe(true);
});
});
@@ -17,6 +17,7 @@
// eslint-disable-next-line no-useless-assignment
let { title = $bindable(), parentPromise }: Props = $props();
let container = $state<HTMLDivElement>();
let selectedTags = $derived(searchManager.filter.tagIds);
let allTags: TagResponseDto[] = $state([]);
let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag])));
@@ -45,13 +46,15 @@
return;
}
// Move focus back to the container so it doesn't fallback to the body and closes the search bar
container?.focus();
selectedTags.delete(tag);
title = getSearchTagsTitle(allTags, selectedTags);
};
</script>
{#if authManager.authenticated && authManager.preferences.tags.enabled}
<div id="location-selection">
<div id="location-selection" bind:this={container} tabindex="-1">
<form autocomplete="off" id="create-tag-form">
<Text class="pb-5">{$t('search_filter_tags_description')}</Text>
<Combobox
@@ -0,0 +1,35 @@
import { isPopoverContent } from '$lib/components/shared-components/search-bar/search-bar-utils';
describe('isPopoverContent', () => {
const focusOutEventTo = (relatedTarget: EventTarget | null) => new FocusEvent('focusout', { relatedTarget });
const createCalendarPopup = () => {
const popup = document.createElement('div');
popup.dataset.popoverContent = '';
return popup;
};
it('returns true when focus moves to an element inside a calendar popup', () => {
const popup = createCalendarPopup();
const dayButton = document.createElement('button');
popup.append(dayButton);
expect(isPopoverContent(focusOutEventTo(dayButton))).toBe(true);
});
it('returns true when focus moves to the calendar popup itself', () => {
const popup = createCalendarPopup();
expect(isPopoverContent(focusOutEventTo(popup))).toBe(true);
});
it('returns false when focus moves to an element outside a calendar popup', () => {
const button = document.createElement('button');
expect(isPopoverContent(focusOutEventTo(button))).toBe(false);
});
it('returns false when focus does not move to another element', () => {
expect(isPopoverContent(focusOutEventTo(null))).toBe(false);
});
});
@@ -179,3 +179,8 @@ export const getSearchTagsTitle = (tags: TagResponseDto[], selected: SvelteSet<s
return selected.size === 1 ? tag : $t('tag_plus_more_tags', { values: { tag, count: selected.size - 1 } });
};
export const isPopoverContent = (event: FocusEvent): boolean => {
const element = event.relatedTarget;
return element instanceof Element && element.closest('[data-popover-content]') !== null;
};