diff --git a/web/src/lib/components/shared-components/search-bar/SearchBar.svelte b/web/src/lib/components/shared-components/search-bar/SearchBar.svelte index a126a7526..0e9c18a42 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchBar.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchBar.svelte @@ -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" > -
+
({ + 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); + }); +}); diff --git a/web/src/lib/components/shared-components/search-bar/SearchTagsSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchTagsSection.svelte index 2579a13a2..d2512554b 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchTagsSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchTagsSection.svelte @@ -17,6 +17,7 @@ // eslint-disable-next-line no-useless-assignment let { title = $bindable(), parentPromise }: Props = $props(); + let container = $state(); 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); }; {#if authManager.authenticated && authManager.preferences.tags.enabled} -
+
{$t('search_filter_tags_description')} { + 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); + }); +}); diff --git a/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts b/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts index 9ae4fe0df..7b56d1a25 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts +++ b/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts @@ -179,3 +179,8 @@ export const getSearchTagsTitle = (tags: TagResponseDto[], selected: SvelteSet { + const element = event.relatedTarget; + return element instanceof Element && element.closest('[data-popover-content]') !== null; +};