fix(web): interaction with some filter elements closes the search panel (#31107)
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user