diff --git a/src/community/components/form/category-search/category-search.module.scss b/src/community/components/form/category-search/category-search.module.scss index 004f34fe2..b9cf312a5 100644 --- a/src/community/components/form/category-search/category-search.module.scss +++ b/src/community/components/form/category-search/category-search.module.scss @@ -1,9 +1,36 @@ -@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; - .tedi-category-search { position: relative; width: 100%; min-width: 0; + max-width: 100%; + + &__mobile-body { + display: grid; + gap: var(--layout-grid-gutters-16); + + .tedi-category-search__actions { + background-color: transparent; + border: 0; + border-radius: 0; + + > button:last-child { + margin-inline-start: auto; + } + } + } + + &__mobile-controls { + display: grid; + gap: var(--layout-grid-gutters-08); + padding-block-end: var(--layout-grid-gutters-16); + padding-inline: var(--modal-body-padding); + margin-inline: calc(-1 * var(--modal-body-padding)); + border-block-end: 1px solid var(--general-border-primary); + + > button { + --button-radius-default: var(--form-field-radius); + } + } &__bar { position: relative; @@ -29,8 +56,6 @@ } &__suffix { - max-width: 45%; - > button { display: flex; gap: var(--tedi-dimensions-01); @@ -38,13 +63,22 @@ justify-content: space-between; width: 100%; height: 100%; - white-space: normal; + white-space: nowrap; } } &__category-label { + display: grid; + align-items: center; min-width: 0; - overflow-wrap: anywhere; + + > span { + grid-area: 1 / 1; + } + + &--hidden { + visibility: hidden; + } } & &__suffix > button { @@ -101,7 +135,6 @@ position: absolute; z-index: var(--z-index-dropdown); box-sizing: border-box; - width: var(--category-search-panel-width); max-height: var(--category-search-panel-max-height); padding: var(--layout-grid-gutters-08); margin-block-start: var(--tedi-dimensions-01); @@ -144,12 +177,4 @@ border: 0; clip-path: inset(50%); } - - @include breakpoints.media-breakpoint-down(sm) { - &__actions { - flex-direction: column; - align-items: flex-start; - justify-content: center; - } - } } diff --git a/src/community/components/form/category-search/category-search.spec.tsx b/src/community/components/form/category-search/category-search.spec.tsx index 098a49d66..2fe60649f 100644 --- a/src/community/components/form/category-search/category-search.spec.tsx +++ b/src/community/components/form/category-search/category-search.spec.tsx @@ -1,15 +1,20 @@ -import { act, render, screen, within } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef } from 'react'; +import { createRef, type ReactNode, useState } from 'react'; import { Button } from '../../../../tedi/components/buttons/button/button'; +import type { SearchSuggestion } from '../../../../tedi/components/form/search/search'; import { TextField } from '../../../../tedi/components/form/textfield/textfield'; +import { Alert } from '../../../../tedi/components/notifications/alert/alert'; import { CategorySearch } from './category-search'; import type { CategorySearchFilterDefinition, CategorySearchTextDefinition } from './category-search.types'; import { defineCategorySearchCategory } from './define-category-search-category'; +const mockUseBreakpoint = jest.fn(() => 'lg'); + jest.mock('../../../../tedi/helpers', () => ({ ...jest.requireActual('../../../../tedi/helpers'), + useBreakpoint: () => mockUseBreakpoint(), useBreakpointProps: () => ({ getCurrentBreakpointProps: (props: T): T => props, }), @@ -22,12 +27,14 @@ interface Filters { const createCategory = ( id = 'parcels', label = 'Parcels', - onSearch: CategorySearchFilterDefinition['onSearch'] = ({ owner }) => [`${label}: ${owner}`] + onSearch: CategorySearchFilterDefinition['onSearch'] = ({ owner }) => [`${label}: ${owner}`], + validationMessage?: ReactNode ) => defineCategorySearchCategory({ id, label, mode: 'category', + validationMessage, initialFilters: () => ({ owner: '' }), renderFilters: ({ values, onChange, disabled }) => ( ['onSearch'] = (query) => [query]) => - defineCategorySearchCategory({ +const createTextCategory = ( + onSearch: CategorySearchTextDefinition['onSearch'] = (query) => [{ value: query }], + showSearchButton?: boolean +) => + defineCategorySearchCategory({ id: 'addresses', label: 'Addresses', mode: 'search', + showSearchButton, onSearch, - renderResults: ({ result, query, selectResult }) => ( - <> -

Submitted query: {query}

-
    - {result.map((address) => ( -
  • - -
  • - ))} -
- - ), }); const selectCategory = async (user: ReturnType, label: string) => { @@ -88,6 +87,263 @@ const deferred = () => { }; describe('CategorySearch', () => { + beforeEach(() => { + mockUseBreakpoint.mockReturnValue('lg'); + }); + + it.each([ + { mobile: true, breakpoint: 'lg', presentation: 'mobile' }, + { mobile: false, breakpoint: 'xs', presentation: 'desktop' }, + { mobile: undefined, breakpoint: 'sm', presentation: 'mobile' }, + { mobile: undefined, breakpoint: 'md', presentation: 'desktop' }, + ])( + 'uses $presentation presentation at $breakpoint when mobile is $mobile', + async ({ mobile, breakpoint, presentation }) => { + mockUseBreakpoint.mockReturnValue(breakpoint); + render(); + const panel = + presentation === 'mobile' + ? await screen.findByRole('dialog', { name: 'Search' }) + : await screen.findByRole('region', { name: 'Parcels: Filter' }); + + expect(within(panel).getByRole('textbox', { name: 'Parcels owner' })).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Search category: Parcels' })).toBeVisible(); + if (presentation === 'mobile') { + expect(within(panel).queryByRole('button', { name: 'Filter' })).not.toBeInTheDocument(); + } else { + expect(screen.getByRole('button', { name: 'Filter' })).toBeVisible(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + } + } + ); + + it.each(['click', 'tap'] as const)('opens Modal with a deliberate %s without searching', async (interaction) => { + const user = userEvent.setup(); + const onSearch = jest.fn(); + render(); + const launcher = screen.getByRole('searchbox', { name: 'Search' }); + expect(launcher).toHaveAttribute('readonly'); + expect(screen.queryByRole('button', { name: /^Search category:/ })).not.toBeInTheDocument(); + + if (interaction === 'tap') await user.pointer({ keys: '[TouchA]', target: launcher }); + else await user.click(launcher); + + expect(await screen.findByRole('dialog', { name: 'Search' })).toBeVisible(); + expect(onSearch).not.toHaveBeenCalled(); + }); + + it('uses autocomplete below the mobile controls and preserves selection when reopening', async () => { + const user = userEvent.setup(); + const onSearch = jest.fn(() => [ + { value: 'aasa-1', label: 'Aasa 1' }, + { value: 'aasa-2', label: 'Aasa 2' }, + ]); + render(); + await user.click(screen.getByRole('searchbox', { name: 'Search' })); + + const dialog = await screen.findByRole('dialog', { name: 'Search' }); + const input = within(dialog).getByRole('combobox', { name: 'Search' }); + const selector = within(dialog).getByRole('button', { name: 'Search category: Addresses' }); + const heading = within(dialog).getByRole('heading', { name: 'Search' }); + const close = within(dialog).getAllByRole('button', { name: 'Close' })[0]; + expect(close).toBeVisible(); + expect(Boolean(heading.compareDocumentPosition(selector) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + expect(Boolean(close.compareDocumentPosition(selector) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + expect(input).not.toHaveAttribute('readonly'); + expect(Boolean(selector.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + await user.type(input, 'A'); + await user.click(await screen.findByRole('option', { name: 'Aasa 1' })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + const launcher = screen.getByRole('searchbox', { name: 'Search' }); + expect(launcher).toHaveValue('Aasa 1'); + expect(launcher).toHaveFocus(); + await user.click(launcher); + const reopened = await screen.findByRole('dialog', { name: 'Search' }); + const reopenedInput = within(reopened).getByRole('combobox', { name: 'Search' }); + expect(reopenedInput).toHaveValue('Aasa 1'); + await user.click(reopenedInput); + expect(await screen.findByRole('option', { name: 'Aasa 1' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Aasa 2' })).toBeVisible(); + expect(onSearch).toHaveBeenCalledTimes(1); + await user.click(within(reopened).getAllByRole('button', { name: 'Close' })[0]); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Search' })).toHaveFocus(); + }); + + it.each(['Clear', 'deleting the text'] as const)( + 'keeps Modal open after %s without restoring cleared suggestions', + async (clearWith) => { + const user = userEvent.setup(); + const pending = deferred(); + const onSearch = jest + .fn() + .mockResolvedValueOnce([{ value: 'Saved address' }]) + .mockReturnValueOnce(pending.promise); + render(); + await user.click(screen.getByRole('searchbox', { name: 'Search' })); + const dialog = await screen.findByRole('dialog', { name: 'Search' }); + const input = within(dialog).getByRole('combobox', { name: 'Search' }); + await waitFor(() => expect(within(dialog).getAllByRole('button', { name: 'Close' })[0]).toHaveFocus()); + await user.type(input, 'A'); + await screen.findByRole('option', { name: 'Saved address' }); + await user.type(input, 'b'); + if (clearWith === 'Clear') await user.click(within(dialog).getByRole('button', { name: /^clear$/i })); + else await user.clear(input); + + expect(screen.getByRole('dialog', { name: 'Search' })).toBe(dialog); + expect(input).toHaveValue(''); + expect(onSearch.mock.calls[1][1].signal.aborted).toBe(true); + await act(async () => { + pending.resolve([{ value: 'Cancelled address' }]); + }); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + await user.click(within(dialog).getAllByRole('button', { name: 'Close' })[0]); + await user.click(screen.getByRole('searchbox', { name: 'Search' })); + await screen.findByRole('dialog', { name: 'Search' }); + await user.click(screen.getByRole('combobox', { name: 'Search' })); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(onSearch).toHaveBeenCalledTimes(2); + } + ); + + it('preserves categories, drafts, results and supplied messages across presentation changes', async () => { + const user = userEvent.setup(); + const onSearch = jest.fn(() => ['Found parcel']); + const categories = [ + createCategory('parcels', 'Parcels', onSearch), + createCategory('notices', 'Notices', undefined, Notice advice), + ]; + const { rerender } = render(); + await waitFor(() => expect(screen.getAllByRole('button', { name: 'Close' })[0]).toHaveFocus()); + await user.type(screen.getByRole('textbox', { name: 'Parcels owner' }), 'Aasa'); + await user.click(within(screen.getByRole('dialog', { name: 'Search' })).getByRole('button', { name: 'Search' })); + await screen.findByText('Found parcel'); + await user.click(screen.getByRole('button', { name: 'Show filters' })); + await user.clear(screen.getByRole('textbox', { name: 'Parcels owner' })); + await user.type(screen.getByRole('textbox', { name: 'Parcels owner' }), 'Kask'); + await selectCategory(user, 'Notices'); + await user.type(screen.getByRole('textbox', { name: 'Notices owner' }), 'Tamm'); + + rerender(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Notices: Filter' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Search category: Notices' })).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Notices owner' })).toHaveValue('Tamm'); + expect(screen.getByText('Notice advice')).toBeVisible(); + await selectCategory(user, 'Parcels'); + expect(screen.getByText('Found parcel')).toBeVisible(); + expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Show filters' })); + + rerender(); + expect(await screen.findByRole('dialog', { name: 'Search' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Search category: Parcels' })).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Parcels owner' })).toHaveValue('Kask'); + await user.click(screen.getByRole('button', { name: 'Show results (1)' })); + expect(screen.getByText('Found parcel')).toBeVisible(); + expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it('minimizes Modal without restoring focus and keeps pending results and validation for reopening', async () => { + const user = userEvent.setup(); + const pending = deferred(); + const onSearch = jest.fn().mockReturnValue(pending.promise); + const category = createCategory('parcels', 'Parcels', onSearch); + const { rerender } = render(); + await user.click(screen.getByRole('searchbox', { name: 'Search' })); + await waitFor(() => expect(screen.getAllByRole('button', { name: 'Close' })[0]).toHaveFocus()); + await user.type(screen.getByRole('textbox', { name: 'Parcels owner' }), 'Aasa'); + await user.click(within(screen.getByRole('dialog', { name: 'Search' })).getByRole('button', { name: 'Search' })); + expect(screen.getByRole('region', { name: 'Parcels: Results' })).toHaveFocus(); + await act(async () => { + rerender(); + }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Search' })).not.toHaveFocus(); + expect(onSearch.mock.calls[0][1].signal.aborted).toBe(false); + await act(async () => { + pending.resolve(['Found parcel']); + }); + + const message = { ...category, validationMessage: Backend advice }; + rerender(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await user.click(screen.getByRole('searchbox', { name: 'Search' })); + expect(await screen.findByRole('dialog', { name: 'Search' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Search category: Parcels' })).toBeVisible(); + expect(screen.getByText('Backend advice')).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Parcels owner' })).toHaveValue('Aasa'); + rerender(); + expect(screen.getByRole('dialog', { name: 'Search' })).toBeVisible(); + rerender(); + await user.click(screen.getByRole('button', { name: 'Show results (1)' })); + expect(screen.getByText('Found parcel')).toBeVisible(); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it('preserves an open search when automatic presentation follows breakpoint changes', async () => { + const user = userEvent.setup(); + const onSearch = jest.fn(() => ['Found parcel']); + const categories = [createCategory('parcels', 'Parcels', onSearch)]; + const { rerender } = render(); + await user.type(screen.getByRole('textbox', { name: 'Parcels owner' }), 'Aasa'); + await user.click(screen.getByRole('button', { name: 'Search' })); + await screen.findByText('Found parcel'); + + mockUseBreakpoint.mockReturnValue('sm'); + rerender(); + expect(await screen.findByRole('dialog', { name: 'Search' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Search category: Parcels' })).toBeVisible(); + expect(screen.getByText('Found parcel')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Show filters' })); + expect(screen.getByRole('textbox', { name: 'Parcels owner' })).toHaveValue('Aasa'); + + mockUseBreakpoint.mockReturnValue('md'); + rerender(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Parcels owner' })).toHaveValue('Aasa'); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it('shows delayed validation for the active mobile category in its preserved filter form', async () => { + const user = userEvent.setup(); + const pending = deferred(); + const category = createCategory('parcels', 'Parcels', () => pending.promise); + const { rerender } = render(); + await waitFor(() => expect(screen.getAllByRole('button', { name: 'Close' })[0]).toHaveFocus()); + await user.type(screen.getByRole('textbox', { name: 'Parcels owner' }), 'Aasa'); + expect(screen.getByRole('textbox', { name: 'Parcels owner' })).toHaveValue('Aasa'); + await user.click(within(screen.getByRole('dialog', { name: 'Search' })).getByRole('button', { name: 'Search' })); + await act(async () => { + pending.resolve(['Found parcel']); + }); + expect(screen.getByText('Found parcel')).toBeVisible(); + + rerender( + Backend advice }]} + mobile + /> + ); + expect(await screen.findByText('Backend advice')).toBeVisible(); + const input = screen.getByRole('textbox', { name: 'Parcels owner' }); + expect(input).toHaveValue('Aasa'); + expect(screen.getByRole('alert').closest('form')).toBe(input.closest('form')); + expect(screen.queryByText('Found parcel')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Filter' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Show results/ })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear filter' })).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Clear filter' })); + expect(screen.getByRole('dialog', { name: 'Search' })).toBeVisible(); + expect(screen.getByText('Backend advice')).toBeVisible(); + expect(input).toHaveValue(''); + expect(screen.queryByRole('button', { name: 'Clear filter' })).not.toBeInTheDocument(); + }); + it('opens a labelled, non-modal filter region from the initially closed search bar', async () => { const user = userEvent.setup(); render(); @@ -130,6 +386,60 @@ describe('CategorySearch', () => { expect(screen.queryByRole('textbox', { name: 'Notices owner' })).not.toBeInTheDocument(); }); + it('minimizes only when the counter changes without resetting the category or moving outside focus', async () => { + const user = userEvent.setup(); + const categories = [createCategory(), createCategory('notices', 'Notices')]; + const { rerender } = render( + <> + + + + ); + expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); + await selectCategory(user, 'Notices'); + await user.type(screen.getByRole('textbox', { name: 'Notices owner' }), 'Draft'); + await user.click(screen.getByRole('button', { name: 'Map action' })); + + rerender( + <> + + + + ); + expect(screen.getByRole('region', { name: 'Notices: Filter' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Map action' })).toHaveFocus(); + + rerender( + <> + + + + ); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Map action' })).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Search category: Notices' })).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Filter' })); + expect(screen.getByRole('textbox', { name: 'Notices owner' })).toHaveValue('Draft'); + + rerender( + <> + + + + ); + expect(screen.getByRole('region', { name: 'Notices: Filter' })).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Map action' })); + + rerender( + <> + + + + ); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Map action' })).toHaveFocus(); + }); + it('preserves drafts and results for an existing category ID while using its updated definition', async () => { const user = userEvent.setup(); const originalSearch = jest.fn(() => ['Original parcel']); @@ -161,6 +471,252 @@ describe('CategorySearch', () => { expect(originalSearch).toHaveBeenCalledTimes(1); }); + it.each([undefined, 'before', 'after'] as const)( + 'positions validation %p and preserves fields when the message moves or changes', + async (validationPosition) => { + const user = userEvent.setup(); + const category = createCategory('parcels', 'Parcels', undefined, Initial advice); + const { rerender } = render( + + ); + const input = screen.getByRole('textbox'); + const message = await screen.findByRole('alert'); + expect(message.closest('form')).toBe(input.closest('form')); + expect(Boolean(message.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe( + validationPosition !== 'after' + ); + await user.type(input, 'Draft'); + + const nextPosition = validationPosition === 'after' ? 'before' : 'after'; + rerender(); + expect(screen.getByText('Initial advice')).toBeVisible(); + expect(Boolean(screen.getByRole('alert').compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe( + nextPosition === 'before' + ); + expect(screen.getByRole('textbox')).toBe(input); + expect(input).toHaveValue('Draft'); + expect(input).toHaveFocus(); + + rerender( + Updated advice }]} + validationPosition={nextPosition} + defaultOpen + /> + ); + expect(await screen.findByText('Updated advice')).toBeVisible(); + expect(screen.queryByText('Initial advice')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBe(input); + expect(input).toHaveValue('Draft'); + + rerender(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBe(input); + expect(input).toHaveValue('Draft'); + expect(input).toHaveFocus(); + } + ); + + it('restores the filter form for an asynchronous message without losing drafts or saved results', async () => { + const user = userEvent.setup(); + const pending = deferred(); + const onSearch = jest.fn().mockReturnValue(pending.promise); + const category = createCategory('parcels', 'Parcels', onSearch); + const { rerender } = render(); + await user.type(screen.getByRole('textbox'), 'Aasa'); + await user.click(screen.getByRole('button', { name: 'Search' })); + await user.click(screen.getByRole('button', { name: 'Show filters' })); + await user.clear(screen.getByRole('textbox')); + await user.type(screen.getByRole('textbox'), 'Kask'); + await user.click(screen.getByRole('button', { name: 'Show results' })); + + rerender( + rerender()}> + Backend validation details + + ), + }, + ]} + defaultOpen + /> + ); + expect(await screen.findByText('Backend validation details')).toBeVisible(); + expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue('Kask'); + expect(onSearch.mock.calls[0][1].signal.aborted).toBe(false); + + await act(async () => { + pending.resolve(['Found parcel']); + }); + expect(screen.getByRole('textbox')).toHaveValue('Kask'); + expect(screen.queryByText('Found parcel')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Show results/ })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Filter' })); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Filter' })); + expect(screen.getByText('Backend validation details')).toBeVisible(); + + const input = screen.getByRole('textbox'); + await user.click(within(screen.getByRole('alert')).getByRole('button')); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); + expect(screen.getByRole('textbox')).toBe(input); + expect(input).toHaveValue('Kask'); + expect(screen.queryByText('Found parcel')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Show results (1)' })); + expect(screen.getByRole('region', { name: 'Parcels: Results' })).toBeVisible(); + expect(screen.getByText('Found parcel')).toBeVisible(); + expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); + expect(screen.getByText('Filters edited since searching.')).toBeVisible(); + expect(screen.queryByText('Backend validation details')).not.toBeInTheDocument(); + }); + + it('keeps delayed messages, replacements and removals independent between filter categories', async () => { + const user = userEvent.setup(); + const pending = deferred(); + const parcels = createCategory('parcels', 'Parcels', () => pending.promise); + const notices = createCategory('notices', 'Notices', undefined, Notice advice); + const { rerender } = render(); + await user.type(screen.getByRole('textbox'), 'Aasa'); + await user.click(screen.getByRole('button', { name: 'Search' })); + await selectCategory(user, 'Notices'); + const noticeInput = screen.getByRole('textbox', { name: 'Notices owner' }); + await user.type(noticeInput, 'Tamm'); + + await act(async () => { + pending.resolve(['Found parcel']); + }); + const parcelMessage = { ...parcels, validationMessage: Parcel advice }; + rerender(); + expect(screen.getByRole('region', { name: 'Notices: Filter' })).toBeVisible(); + expect(screen.getByRole('textbox')).toBe(noticeInput); + expect(noticeInput).toHaveValue('Tamm'); + expect(noticeInput).toHaveFocus(); + expect(screen.getByText('Notice advice')).toBeVisible(); + expect(screen.queryByText('Parcel advice')).not.toBeInTheDocument(); + + await selectCategory(user, 'Parcels'); + expect(await screen.findByText('Parcel advice')).toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue('Aasa'); + expect(screen.queryByText('Notice advice')).not.toBeInTheDocument(); + await selectCategory(user, 'Notices'); + + const updatedParcel = { ...parcels, validationMessage: Updated parcel advice }; + rerender(); + expect(screen.getByText('Notice advice')).toBeVisible(); + expect(screen.queryByText('Updated parcel advice')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('Tamm'); + + rerender(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('Tamm'); + await selectCategory(user, 'Parcels'); + expect(await screen.findByText('Updated parcel advice')).toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue('Aasa'); + }); + + it.each(['Close', 'minimizeCounter'] as const)( + 'stays closed after %s when validation arrives, changes or is removed', + async (closeWith) => { + const user = userEvent.setup(); + const pending = deferred(); + const category = createCategory('parcels', 'Parcels', () => pending.promise); + const minimizeCounter = closeWith === 'minimizeCounter' ? 1 : undefined; + const { rerender } = render(); + await user.type(screen.getByRole('textbox'), 'Aasa'); + await user.click(screen.getByRole('button', { name: 'Search' })); + if (closeWith === 'Close') await user.click(screen.getByRole('button', { name: 'Close' })); + else rerender(); + await act(async () => { + pending.resolve(['Found parcel']); + }); + + for (const message of ['First backend message', undefined, 'Latest backend message']) { + rerender( + {message} }]} + defaultOpen + minimizeCounter={minimizeCounter} + /> + ); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + } + + await user.click(screen.getByRole('button', { name: 'Filter' })); + expect(await screen.findByText('Latest backend message')).toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue('Aasa'); + + if (closeWith === 'minimizeCounter') { + rerender( + Latest backend message }]} + defaultOpen + minimizeCounter={2} + /> + ); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Filter' })); + expect(screen.getByText('Latest backend message')).toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue('Aasa'); + } + } + ); + + it.each(['danger', 'warning'] as const)('still submits with a supplied %s Alert', async (type) => { + const user = userEvent.setup(); + const onSearch = jest.fn(() => ['Found parcel']); + const category = createCategory('parcels', 'Parcels', onSearch, Application message); + const { rerender } = render(); + await user.type(screen.getByRole('textbox'), 'Aasa'); + const submit = screen.getByRole('button', { name: 'Search' }); + await user.click(submit); + + expect(onSearch).toHaveBeenCalledWith({ owner: 'Aasa' }, { signal: expect.any(AbortSignal) }); + expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); + expect(screen.getByText('Application message')).toBeVisible(); + expect(submit).toHaveFocus(); + + rerender(); + expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); + expect(screen.getByRole('textbox')).toHaveValue('Aasa'); + expect(submit).toHaveFocus(); + expect(screen.queryByText('Found parcel')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Show results (1)' })); + expect(await screen.findByText('Found parcel')).toBeVisible(); + expect(screen.queryByText('Application message')).not.toBeInTheDocument(); + }); + + it('shows results when the application clears validation while submitting', async () => { + const user = userEvent.setup(); + function Example() { + const [message, setMessage] = useState(Review the filters); + const category = createCategory( + 'parcels', + 'Parcels', + () => { + setMessage(undefined); + return ['Found parcel']; + }, + message + ); + return ; + } + render(); + await user.type(screen.getByRole('textbox'), 'Aasa'); + await user.click(screen.getByRole('button', { name: 'Search' })); + + expect(await screen.findByText('Found parcel')).toBeVisible(); + expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + it('rejects duplicate category IDs', () => { expect(() => render() @@ -277,19 +833,32 @@ describe('CategorySearch', () => { expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); }); - it('preserves results and draft filters when the panel is closed and reopened', async () => { - const user = userEvent.setup(); - render(); - await user.type(screen.getByRole('textbox'), 'Aasa'); - await user.click(screen.getByRole('button', { name: 'Search' })); - expect(await screen.findByText('Parcels: Aasa')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Close' })); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Show results (1)' })); - expect(screen.getByText('Parcels: Aasa')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Show filters' })); - expect(screen.getByRole('textbox')).toHaveValue('Aasa'); - }); + it.each(['Close', 'minimizeCounter'] as const)( + 'preserves results and draft filters when closed with %s and reopened', + async (closeWith) => { + const user = userEvent.setup(); + const category = createCategory(); + const { rerender } = render(); + await user.type(screen.getByRole('textbox'), 'Aasa'); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(await screen.findByText('Parcels: Aasa')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Show filters' })); + await user.clear(screen.getByRole('textbox')); + await user.type(screen.getByRole('textbox'), 'Kask'); + await user.click(screen.getByRole('button', { name: 'Show results (1)' })); + + if (closeWith === 'Close') await user.click(screen.getByRole('button', { name: 'Close' })); + else rerender(); + + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Show results (1)' })); + expect(screen.getByText('Parcels: Aasa')).toBeVisible(); + expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); + expect(screen.getByText('Filters edited since searching.')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Show filters' })); + expect(screen.getByRole('textbox')).toHaveValue('Kask'); + } + ); it('restores each category draft and results after switching categories', async () => { const user = userEvent.setup(); @@ -301,6 +870,9 @@ describe('CategorySearch', () => { await user.clear(screen.getByRole('textbox')); await user.type(screen.getByRole('textbox'), 'Kask'); + await selectCategory(user, 'Parcels'); + expect(screen.getByRole('textbox', { name: 'Parcels owner' })).toHaveValue('Kask'); + await selectCategory(user, 'Notices'); await user.type(screen.getByRole('textbox', { name: 'Notices owner' }), 'Tamm'); await user.click(screen.getByRole('button', { name: 'Search' })); @@ -331,6 +903,7 @@ describe('CategorySearch', () => { await user.click(screen.getByRole('button', { name: 'Show filters' })); await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(screen.getByRole('button', { name: 'Clear' })).toHaveFocus(); expect(screen.getByRole('textbox', { name: 'Parcels owner' })).toHaveValue(''); expect(screen.queryByRole('button', { name: /Show results/ })).not.toBeInTheDocument(); await selectCategory(user, 'Notices'); @@ -342,14 +915,24 @@ describe('CategorySearch', () => { expect(screen.queryByText('Parcels: Aasa')).not.toBeInTheDocument(); }); - it('clears completed results and restores the filter form with Clear filter', async () => { + it.each([false, true])('clears results and restores filters with Clear filter when mobile is %s', async (mobile) => { const user = userEvent.setup(); - render(); + render(); + if (mobile) { + await waitFor(() => expect(screen.getAllByRole('button', { name: 'Close' })[0]).toHaveFocus()); + } await user.type(screen.getByRole('textbox'), 'Aasa'); await user.click(screen.getByRole('button', { name: 'Search' })); expect(await screen.findByText('Parcels: Aasa')).toBeVisible(); await user.click(screen.getByRole('button', { name: 'Clear filter' })); + if (mobile) { + expect(screen.getByRole('dialog', { name: 'Search' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Filter' })).not.toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toHaveFocus(); + } else { + expect(screen.getByRole('button', { name: 'Filter' })).toHaveFocus(); + } expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); expect(screen.getByRole('textbox')).toHaveValue(''); expect(screen.queryByText('Parcels: Aasa')).not.toBeInTheDocument(); @@ -357,19 +940,65 @@ describe('CategorySearch', () => { expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); }); - it('replaces old matches with a completed empty result that can be reopened', async () => { + it('shows a localized empty state only after a successful empty search and allows reopening it', async () => { const user = userEvent.setup(); - const onSearch = jest.fn().mockResolvedValueOnce(['Existing parcel']).mockResolvedValueOnce([]); - render(); + const pending = deferred(); + const onSearch = jest + .fn() + .mockResolvedValueOnce(['Existing parcel']) + .mockResolvedValueOnce([]) + .mockReturnValueOnce(pending.promise); + const { container } = render( + + ); + expect(container.querySelector('[data-name="tedi-empty-state"]')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Search' })); expect(await screen.findByText('Existing parcel')).toBeVisible(); await user.click(screen.getByRole('button', { name: 'Show filters' })); await user.click(screen.getByRole('button', { name: 'Search' })); - expect(await screen.findByText('No results found.')).toBeVisible(); + expect(await screen.findByText('Tulemusi ei leitud.')).toBeVisible(); + expect(container.querySelector('[data-name="tedi-empty-state"]')).toHaveClass('tedi-empty-state--inside'); + expect(screen.queryByText('0 results')).not.toBeInTheDocument(); expect(screen.queryByText('Existing parcel')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Close' })); await user.click(screen.getByRole('button', { name: 'Show results (0)' })); - expect(screen.getByText('No results found.')).toBeVisible(); + expect(screen.getByText('Tulemusi ei leitud.')).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Show filters' })); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(screen.getByText('Searching…')).toBeVisible(); + expect(container.querySelector('[data-name="tedi-empty-state"]')).not.toBeInTheDocument(); + + await act(async () => { + pending.reject(new Error('Temporary failure')); + }); + expect(await screen.findByRole('alert')).toHaveTextContent('The search failed. Please try again.'); + expect(container.querySelector('[data-name="tedi-empty-state"]')).not.toBeInTheDocument(); + }); + + it('renders successful results without a count when the result is not an array', async () => { + const user = userEvent.setup(); + const category = defineCategorySearchCategory({ + id: 'parcels', + label: 'Parcels', + mode: 'category', + initialFilters: () => ({ owner: '' }), + renderFilters: () => null, + onSearch: () => ({ label: 'Found parcel' }), + renderResults: ({ result }) =>

{result.label}

, + }); + render(); + await user.click(screen.getByRole('button', { name: 'Search' })); + + expect(await screen.findByText('Found parcel')).toBeVisible(); + expect(screen.queryByText('No results found.')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Show filters' })); + expect(screen.getByRole('button', { name: 'Show results' })).toBeVisible(); }); it('retains earlier results after a failed search and clears the error on retry', async () => { @@ -401,9 +1030,9 @@ describe('CategorySearch', () => { expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); }); - it.each([undefined, null, '', 0, false])('shows a failed search when the rejection value is %p', async (reason) => { + it('shows a failed search when the rejection value is undefined', async () => { const user = userEvent.setup(); - const onSearch = jest.fn().mockRejectedValue(reason); + const onSearch = jest.fn().mockRejectedValue(undefined); render(); await user.click(screen.getByRole('button', { name: 'Search' })); expect(await screen.findByRole('alert')).toHaveTextContent('The search failed. Please try again.'); @@ -495,32 +1124,35 @@ describe('CategorySearch', () => { it('keeps a closed panel closed when its pending search completes', async () => { const user = userEvent.setup(); const pending = deferred(); - const onOpenChange = jest.fn(); - render( - pending.promise)]} - onOpenChange={onOpenChange} - defaultOpen - /> - ); + render( pending.promise)]} defaultOpen />); await user.click(screen.getByRole('button', { name: 'Search' })); await user.click(screen.getByRole('button', { name: 'Close' })); await act(async () => { pending.resolve(['Found parcel']); }); expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(onOpenChange).toHaveBeenLastCalledWith(false); expect(screen.getByRole('button', { name: 'Show results (1)' })).toHaveFocus(); await user.click(screen.getByRole('button', { name: 'Show results (1)' })); expect(screen.getByText('Found parcel')).toBeVisible(); }); - it('preserves edits made during a request while rendering its original submitted filters', async () => { + it('hides saved results while searching and preserves edits and submitted filters', async () => { const user = userEvent.setup(); const pending = deferred(); - render( pending.promise)]} defaultOpen />); + const onSearch = jest.fn().mockResolvedValueOnce(['Previous parcel']).mockReturnValueOnce(pending.promise); + render(); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(await screen.findByText('Previous parcel')).toBeVisible(); + expect(screen.getByText('1 results')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Show filters' })); await user.type(screen.getByRole('textbox'), 'Aasa'); await user.click(screen.getByRole('button', { name: 'Search' })); + + expect(screen.getByText('Searching…')).toBeVisible(); + expect(screen.queryByText('Previous parcel')).not.toBeInTheDocument(); + expect(screen.queryByText('1 results')).not.toBeInTheDocument(); + expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); + expect(screen.queryByText('No results found.')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Show filters' })); await user.clear(screen.getByRole('textbox')); await user.type(screen.getByRole('textbox'), 'Kask'); @@ -531,6 +1163,8 @@ describe('CategorySearch', () => { expect(screen.getByRole('textbox')).toHaveValue('Kask'); await user.click(screen.getByRole('button', { name: 'Show results (1)' })); expect(screen.getByText('Found parcel')).toBeVisible(); + expect(screen.queryByText('Previous parcel')).not.toBeInTheDocument(); + expect(screen.queryByText('Searching…')).not.toBeInTheDocument(); expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); expect(screen.getByText('Filters edited since searching.')).toBeVisible(); await user.click(screen.getByRole('button', { name: 'Show filters' })); @@ -562,77 +1196,24 @@ describe('CategorySearch', () => { expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); }); - it('discards a removed category request when the same category ID is added again', async () => { + it('reopens the first pending filter search and its error', async () => { const user = userEvent.setup(); - const older = deferred(); - const newer = deferred(); - const onSearch = jest.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); - const parcels = createCategory('parcels', 'Parcels', onSearch); - const notices = createCategory('notices', 'Notices'); - const { rerender } = render(); - await user.type(screen.getByRole('textbox'), 'Removed'); - await user.click(screen.getByRole('button', { name: 'Search' })); - rerender(); - expect(onSearch.mock.calls[0][1].signal.aborted).toBe(true); - rerender(); - expect(screen.getByRole('textbox', { name: 'Parcels owner' })).toHaveValue(''); - await user.type(screen.getByRole('textbox'), 'Re-added'); + const pending = deferred(); + render( pending.promise)]} defaultOpen />); await user.click(screen.getByRole('button', { name: 'Search' })); - - await act(async () => { - older.resolve(['Removed parcel']); - }); + await user.click(screen.getByRole('button', { name: 'Close' })); + await user.click(screen.getByRole('button', { name: 'Show results' })); expect(screen.getByText('Searching…')).toBeVisible(); - expect(screen.queryByText('Removed parcel')).not.toBeInTheDocument(); + await act(async () => { - newer.resolve(['Current parcel']); + pending.reject(new Error('First search failed')); }); - expect(await screen.findByText('Current parcel')).toBeVisible(); - expect(screen.getByText('Submitted Parcels: Re-added')).toBeVisible(); + expect(await screen.findByRole('alert')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Close' })); + await user.click(screen.getByRole('button', { name: 'Show results' })); + expect(screen.getByRole('alert')).toHaveTextContent('The search failed. Please try again.'); }); - it.each(['category', 'search'] as const)( - 'reopens the first pending search and its error in %s mode', - async (mode) => { - const user = userEvent.setup(); - const pending = deferred(); - const category = - mode === 'category' - ? createCategory('parcels', 'Parcels', () => pending.promise) - : defineCategorySearchCategory({ - id: 'addresses', - label: 'Addresses', - mode: 'search', - onSearch: () => pending.promise, - renderResults: ({ result }) =>

{result[0]}

, - }); - render(); - if (mode === 'category') await user.click(screen.getByRole('button', { name: 'Search' })); - else await user.type(screen.getByRole('searchbox'), 'Aasa{Enter}'); - await user.click(screen.getByRole('button', { name: 'Close' })); - if (mode === 'category') { - await user.click(screen.getByRole('button', { name: 'Show results' })); - } else { - expect(screen.queryByRole('button', { name: /Show results/ })).not.toBeInTheDocument(); - await selectCategory(user, 'Addresses'); - } - expect(screen.getByText('Searching…')).toBeVisible(); - - await act(async () => { - pending.reject(new Error('First search failed')); - }); - expect(await screen.findByRole('alert')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Close' })); - if (mode === 'category') { - await user.click(screen.getByRole('button', { name: 'Show results' })); - } else { - expect(screen.queryByRole('button', { name: /Show results/ })).not.toBeInTheDocument(); - await selectCategory(user, 'Addresses'); - } - expect(screen.getByRole('alert')).toHaveTextContent('The search failed. Please try again.'); - } - ); - it('aborts outstanding searches when unmounted', async () => { const user = userEvent.setup(); const pending = deferred(); @@ -650,43 +1231,6 @@ describe('CategorySearch', () => { }); }); - it('requests controlled visibility changes without overriding the parent state', async () => { - const user = userEvent.setup(); - const categories = [createCategory()]; - const onOpenChange = jest.fn(); - const { rerender } = render(); - await user.click(screen.getByRole('button', { name: 'Filter' })); - expect(onOpenChange).toHaveBeenLastCalledWith(true); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - - rerender(); - expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Close' })); - expect(onOpenChange).toHaveBeenLastCalledWith(false); - expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); - rerender(); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - }); - - it('requests a controlled category change without replacing the selected category', async () => { - const user = userEvent.setup(); - const categories = [createCategory(), createCategory('notices', 'Notices')]; - const onCategoryChange = jest.fn(); - const { rerender } = render( - - ); - await selectCategory(user, 'Notices'); - expect(onCategoryChange).toHaveBeenCalledWith('notices'); - expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); - expect(screen.queryByRole('textbox', { name: 'Notices owner' })).not.toBeInTheDocument(); - - rerender( - - ); - expect(screen.getByRole('region', { name: 'Notices: Filter' })).toBeVisible(); - expect(screen.getByRole('textbox', { name: 'Notices owner' })).toBeVisible(); - }); - it('keeps the background interactive and leaves the panel open for Escape outside it', async () => { const user = userEvent.setup(); const onClick = jest.fn(); @@ -720,15 +1264,7 @@ describe('CategorySearch', () => { it('disables category selection, filter editing and submission', async () => { const user = userEvent.setup(); const onSearch = jest.fn(); - const onCategoryChange = jest.fn(); - render( - - ); + render(); expect(screen.getByRole('textbox')).toBeDisabled(); expect(screen.getByRole('button', { name: 'Filter' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Search category: Parcels' })).toBeDisabled(); @@ -736,67 +1272,28 @@ describe('CategorySearch', () => { await user.click(screen.getByRole('button', { name: 'Search' })); await user.click(screen.getByRole('button', { name: 'Search category: Parcels' })); expect(onSearch).not.toHaveBeenCalled(); - expect(onCategoryChange).not.toHaveBeenCalled(); - }); - - it('searches text with Enter and clears the query and remembered results', async () => { - const user = userEvent.setup(); - const onSearch = jest.fn((query: string) => [`Address: ${query}`]); - const category = defineCategorySearchCategory({ - id: 'addresses', - label: 'Addresses', - mode: 'search', - onSearch, - renderResults: ({ result, query }) => ( -

- {result[0]} (submitted: {query}) -

- ), - }); - render(); - const input = screen.getByRole('searchbox'); - await user.type(input, 'Aasa{Enter}'); - expect(await screen.findByText('Address: Aasa (submitted: Aasa)')).toBeVisible(); - expect(onSearch).toHaveBeenCalledWith('Aasa', { - signal: expect.any(AbortSignal), - }); - await user.clear(input); - await user.type(input, 'Kask{Enter}'); - expect(await screen.findByText('Address: Kask (submitted: Kask)')).toBeVisible(); - expect(screen.queryByText('Address: Aasa (submitted: Aasa)')).not.toBeInTheDocument(); - expect(screen.getByRole('region', { name: 'Addresses: Results' })).toHaveFocus(); - await user.click(screen.getByRole('button', { name: 'Close' })); - expect(input).toHaveFocus(); - expect(screen.queryByRole('button', { name: /Show results/ })).not.toBeInTheDocument(); - await selectCategory(user, 'Addresses'); - expect(screen.getByText('Address: Kask (submitted: Kask)')).toBeVisible(); - expect(onSearch).toHaveBeenCalledTimes(10); - await user.click(screen.getByRole('button', { name: /^clear$/i })); - expect(input).toHaveValue(''); - expect(input).toHaveFocus(); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Show results/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); }); - it('restores an application-provided initial query when clearing a completed search', async () => { + it('uses initialQuery only for initial population and leaves Clear empty', async () => { const user = userEvent.setup(); - const category = defineCategorySearchCategory({ + const category = defineCategorySearchCategory({ id: 'cities', label: 'Cities', mode: 'search', initialQuery: 'Tartu', - onSearch: (query) => [query], - renderResults: ({ result }) =>

{result[0]}

, + onSearch: (query) => [{ value: query }], }); render(); - const input = screen.getByRole('searchbox', { name: 'Search' }); + const input = screen.getByRole('combobox', { name: 'Search' }); expect(input).toHaveValue('Tartu'); await user.clear(input); - await user.type(input, 'Tallinn{Enter}'); - expect(await screen.findByText('Tallinn')).toBeVisible(); + await user.type(input, 'Tallinn'); + expect(await screen.findByRole('option', { name: 'Tallinn' })).toBeVisible(); await user.click(screen.getByRole('button', { name: /^clear$/i })); - expect(input).toHaveValue('Tartu'); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(input).toHaveValue(''); + expect(input).toHaveFocus(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Show results/ })).not.toBeInTheDocument(); }); @@ -818,33 +1315,146 @@ describe('CategorySearch', () => { expect(screen.getByRole('button', { name: 'Map action' })).toHaveFocus(); }); - it('searches every nonempty text edit with the current query while keeping focus in the input', async () => { + it('searches every nonempty text edit and passes suggestions to TEDI Search', async () => { const user = userEvent.setup(); - const onSearch = jest.fn((query: string) => [query]); + const onSearch = jest.fn((query: string) => [{ value: query }]); render(); - const input = screen.getByRole('searchbox', { name: 'Search' }); + const input = screen.getByRole('combobox', { name: 'Search' }); await user.type(input, 'Aasa'); expect(onSearch.mock.calls.map(([query]) => query)).toEqual(['A', 'Aa', 'Aas', 'Aasa']); - expect(await screen.findByRole('button', { name: 'Aasa' })).toBeVisible(); - expect(screen.getByText('Submitted query: Aasa')).toBeVisible(); - expect(screen.getByRole('region', { name: 'Addresses: Results' })).toBeVisible(); - expect(input).toHaveAttribute('aria-controls', screen.getByRole('region').id); + expect(await screen.findByRole('option', { name: 'Aasa' })).toBeVisible(); expect(input).toHaveValue('Aasa'); expect(input).toHaveFocus(); - expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + }); + + it.each([undefined, true, false])('respects showSearchButton=%s for text categories', async (showSearchButton) => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('combobox'); + const searchButton = screen.queryByRole('button', { name: 'Search', hidden: true }); + + if (showSearchButton === false) { + expect(searchButton).not.toBeInTheDocument(); + expect(input).not.toHaveClass('tedi-search__input--has-button'); + await user.click(input); + await user.tab(); + expect(screen.getByRole('button', { name: 'Search category: Addresses' })).toHaveFocus(); + } else { + expect(searchButton).toBeVisible(); + expect(input).toHaveClass('tedi-search__input--has-button'); + } + }); + + it('submits Enter once and keeps filter submission when the text search button is hidden', async () => { + const user = userEvent.setup(); + const onSearch = jest.fn(() => [{ value: 'Aasa 1' }, { value: 'Aasa 2' }]); + const filterSearch = jest.fn(({ owner }: Filters) => [owner]); + render( + + ); + const input = screen.getByRole('combobox'); + await user.type(input, 'Aa'); + expect(onSearch).toHaveBeenCalledTimes(2); + await user.keyboard('{Enter}'); + expect(onSearch).toHaveBeenCalledTimes(3); + expect(onSearch).toHaveBeenLastCalledWith('Aa', { signal: expect.any(AbortSignal) }); + expect(input).toHaveFocus(); + + await selectCategory(user, 'Parcels'); + await user.type(screen.getByRole('textbox', { name: 'Parcels owner' }), 'Kask'); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(filterSearch).toHaveBeenCalledTimes(1); + expect(filterSearch).toHaveBeenCalledWith({ owner: 'Kask' }, { signal: expect.any(AbortSignal) }); + }); + + it.each([ + { label: 'Aasa 1', expectedQuery: 'Aasa 1' }, + { label: Aasa 1, expectedQuery: 'aasa-1' }, + ])('saves the selected query as $expectedQuery and notifies the application', async ({ label, expectedQuery }) => { + const user = userEvent.setup(); + const suggestion = { value: 'aasa-1', label, description: 'Harju county' }; + const onSearch = jest.fn(() => [suggestion]); + const onSuggestionSelect = jest.fn(); + const renderSuggestionContent = jest.fn((item: SearchSuggestion, { query }: { query: string }) => ( + + {item.label} ({query}) + + )); + render( + + ); + const input = screen.getByRole('combobox'); + await user.type(input, 'A'); + await user.click(await screen.findByRole('option', { name: 'Aasa 1 (A)' })); + + expect(onSuggestionSelect).toHaveBeenCalledWith(suggestion); + expect(renderSuggestionContent).toHaveBeenCalledWith(suggestion, { active: expect.any(Boolean), query: 'A' }); + expect(input).toHaveValue(expectedQuery); + expect(input).toHaveFocus(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it('keeps saved queries and suggestions independent between text categories', async () => { + const user = userEvent.setup(); + const addressSearch = jest.fn(() => [{ value: 'Aasa 1' }, { value: 'Aasa 2' }]); + const citySearch = jest.fn(() => [{ value: 'Tallinn' }, { value: 'Tartu' }]); + const cities = { ...createTextCategory(citySearch), id: 'cities', label: 'Cities' }; + render(); + await user.type(screen.getByRole('combobox'), 'A'); + await user.click(await screen.findByRole('option', { name: 'Aasa 1' })); + + await selectCategory(user, 'Cities'); + const cityInput = screen.getByRole('combobox'); + expect(cityInput).toHaveValue(''); + await user.click(cityInput); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(citySearch).not.toHaveBeenCalled(); + await user.type(cityInput, 'T'); + await user.click(await screen.findByRole('option', { name: 'Tartu' })); + + await selectCategory(user, 'Addresses'); + const addressInput = screen.getByRole('combobox'); + expect(addressInput).toHaveValue('Aasa 1'); + await user.click(addressInput); + expect(await screen.findByRole('option', { name: 'Aasa 1' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Aasa 2' })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Tartu' })).not.toBeInTheDocument(); + + await selectCategory(user, 'Cities'); + expect(screen.getByRole('combobox')).toHaveValue('Tartu'); + await user.click(screen.getByRole('combobox')); + expect(await screen.findByRole('option', { name: 'Tallinn' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Tartu' })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Aasa 1' })).not.toBeInTheDocument(); + expect(addressSearch).toHaveBeenCalledTimes(1); + expect(citySearch).toHaveBeenCalledTimes(1); }); it.each(['Enter', 'search button'] as const)( - 'explicitly submits the latest text query with %s and focuses the panel', + 'explicitly submits the latest text query with %s without moving focus', async (submitWith) => { const user = userEvent.setup(); - const onSearch = jest.fn((query: string) => [query]); + const onSearch = jest.fn((query: string) => [{ value: query }]); render(); - const input = screen.getByRole('searchbox'); + const input = screen.getByRole('combobox'); const searchButton = screen.getByRole('button', { name: 'Find addresses' }); - expect(searchButton).toBeInTheDocument(); await user.type(input, 'Aa'); expect(onSearch).toHaveBeenCalledTimes(2); expect(input).toHaveFocus(); @@ -854,225 +1464,217 @@ describe('CategorySearch', () => { expect(onSearch).toHaveBeenCalledTimes(3); expect(onSearch).toHaveBeenLastCalledWith('Aa', { signal: expect.any(AbortSignal) }); - expect(screen.getByRole('region', { name: 'Addresses: Results' })).toHaveFocus(); + expect(submitWith === 'Enter' ? input : searchButton).toHaveFocus(); } ); - it.each([undefined, true])( - 'cancels an emptied text query without discarding previous results when open is %p', - async (open) => { - const user = userEvent.setup(); - const pending = deferred(); - const next = deferred(); - const onSearch = jest - .fn() - .mockResolvedValueOnce(['Saved address']) - .mockReturnValueOnce(pending.promise) - .mockReturnValueOnce(next.promise); - render(); - const input = screen.getByRole('searchbox'); - await user.type(input, 'A'); - expect(await screen.findByRole('button', { name: 'Saved address' })).toBeVisible(); - await user.type(input, 'b'); - expect(within(screen.getByRole('region')).getByRole('status')).toBeVisible(); + it.each([ + ['deleting the text', 'resolve'], + ['deleting the text', 'reject'], + ['Clear', 'resolve'], + ['Clear', 'reject'], + ] as const)('clears text with %s and ignores a late %s', async (clearWith, settle) => { + const user = userEvent.setup(); + const pending = deferred(); + const next = deferred(); + const onSearch = jest + .fn() + .mockResolvedValueOnce([{ value: 'Saved address' }]) + .mockReturnValueOnce(pending.promise) + .mockReturnValueOnce(next.promise); + render(); + const input = screen.getByRole('combobox'); + await user.type(input, 'A'); + expect(await screen.findByRole('option', { name: 'Saved address' })).toBeVisible(); + await user.type(input, 'b'); + expect(within(screen.getByRole('search')).getByRole('status')).toHaveTextContent('Searching…'); - await user.clear(input); + if (clearWith === 'Clear') await user.click(screen.getByRole('button', { name: /^clear$/i })); + else await user.clear(input); - expect(onSearch.mock.calls[1][1].signal.aborted).toBe(true); - expect(input).toHaveValue(''); - expect(input).toHaveFocus(); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - await user.keyboard('{Enter}'); - await user.click(screen.getByRole('button', { name: 'Search' })); - expect(onSearch).toHaveBeenCalledTimes(2); - await act(async () => { - pending.resolve(['Cancelled address']); - }); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(onSearch.mock.calls[1][1].signal.aborted).toBe(true); + expect(input).toHaveValue(''); + expect(input).toHaveFocus(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + await user.keyboard('{Enter}'); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(onSearch).toHaveBeenCalledTimes(2); + await user.type(input, 'N'); - await user.type(input, 'N'); + expect(onSearch).toHaveBeenLastCalledWith('N', { signal: expect.any(AbortSignal) }); + expect(within(screen.getByRole('search')).getByRole('status')).toHaveTextContent('Searching…'); + expect(screen.queryByRole('option', { name: 'Saved address' })).not.toBeInTheDocument(); + await act(async () => { + if (settle === 'resolve') pending.resolve([{ value: 'Cancelled address' }]); + else pending.reject(new Error('Cancelled failure')); + }); - expect(onSearch).toHaveBeenLastCalledWith('N', { signal: expect.any(AbortSignal) }); - expect(screen.getByRole('button', { name: 'Saved address' })).toBeVisible(); - expect(screen.getByText('Submitted query: A')).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Cancelled address' })).not.toBeInTheDocument(); - expect(input).toHaveFocus(); - } - ); + expect(within(screen.getByRole('search')).getByRole('status')).toHaveTextContent('Searching…'); + expect(screen.queryByRole('option', { name: 'Cancelled address' })).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(input).toHaveValue('N'); + expect(input).toHaveFocus(); + await act(async () => { + next.resolve([{ value: 'Current address' }]); + }); + expect(await screen.findByRole('option', { name: 'Current address' })).toBeVisible(); + }); - it('retains text results while a replacement loads and fails without showing a filter-change notice', async () => { + it('hides saved suggestions while loading and retains them with an error after a failed replacement', async () => { const user = userEvent.setup(); - const pending = deferred(); - const onSearch = jest.fn().mockResolvedValueOnce(['Saved address']).mockReturnValueOnce(pending.promise); + const pending = deferred(); + const onSearch = jest + .fn() + .mockResolvedValueOnce([{ value: 'Saved address' }]) + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce([{ value: 'Recovered address' }]); render(); - const input = screen.getByRole('searchbox'); + const input = screen.getByRole('combobox'); await user.type(input, 'A'); - expect(await screen.findByRole('button', { name: 'Saved address' })).toBeVisible(); + expect(await screen.findByRole('option', { name: 'Saved address' })).toBeVisible(); await user.type(input, 'b'); - expect(within(screen.getByRole('region')).getByRole('status')).toBeVisible(); - expect(screen.getByRole('button', { name: 'Saved address' })).toBeVisible(); - expect(screen.getByText('Submitted query: A')).toBeVisible(); - expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); + expect(within(screen.getByRole('search')).getByRole('status')).toHaveTextContent('Searching…'); + expect(screen.queryByRole('option', { name: 'Saved address' })).not.toBeInTheDocument(); await act(async () => { pending.reject(new Error('Replacement failed')); }); expect(screen.getByRole('alert')).toHaveTextContent('The search failed. Please try again.'); - expect(screen.getByRole('button', { name: 'Saved address' })).toBeVisible(); - expect(screen.getByText('Submitted query: A')).toBeVisible(); - expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Saved address' })).toBeVisible(); expect(input).toHaveValue('Ab'); expect(input).toHaveFocus(); - }); - - it.each(['resolve', 'reject'] as const)( - 'selects a retained result, cancels the new query and ignores its late %s', - async (settle) => { - const user = userEvent.setup(); - const pending = deferred(); - const onSearch = jest.fn().mockResolvedValueOnce(['Saved address']).mockReturnValueOnce(pending.promise); - render(); - const input = screen.getByRole('searchbox'); - await user.type(input, 'A'); - expect(await screen.findByRole('button', { name: 'Saved address' })).toBeVisible(); - await user.type(input, 'b'); - expect(within(screen.getByRole('region')).getByRole('status')).toBeVisible(); + await user.type(input, 'c'); - await user.click(screen.getByRole('button', { name: 'Saved address' })); + expect(await screen.findByRole('option', { name: 'Recovered address' })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Saved address' })).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(input).toHaveFocus(); + }); - expect(onSearch.mock.calls[1][1].signal.aborted).toBe(true); - expect(onSearch).toHaveBeenCalledTimes(2); - expect(input).toHaveValue('Saved address'); - expect(input).toHaveFocus(); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - await act(async () => { - if (settle === 'resolve') pending.resolve(['Obsolete address']); - else pending.reject(new Error('Obsolete failure')); - }); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(input).toHaveValue('Saved address'); - expect(input).toHaveFocus(); + it('distinguishes a failed autocomplete request from a successful empty search', async () => { + const user = userEvent.setup(); + const pending = deferred(); + const onSearch = jest.fn().mockReturnValueOnce(pending.promise).mockRejectedValueOnce(undefined); + render( + + ); + const input = screen.getByRole('combobox'); + await user.type(input, 'A'); + expect(within(screen.getByRole('search')).getByRole('status')).toHaveTextContent('Searching…'); + expect(screen.queryByText('search.no-results')).not.toBeInTheDocument(); + await act(async () => { + pending.resolve([]); + }); + expect(screen.getByRole('status')).toHaveTextContent('search.no-results'); + expect(screen.queryByText('Address search failed')).not.toBeInTheDocument(); - await selectCategory(user, 'Addresses'); + await user.type(input, 'b'); - expect(screen.getByRole('button', { name: 'Saved address' })).toBeVisible(); - expect(screen.getByText('Submitted query: A')).toBeVisible(); - expect(within(screen.getByRole('region')).queryByRole('status')).not.toBeInTheDocument(); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Obsolete address' })).not.toBeInTheDocument(); - expect(onSearch).toHaveBeenCalledTimes(2); - } - ); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Address search failed')); + expect(screen.queryByText('search.no-results')).not.toBeInTheDocument(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); - it('aborts a pending text search and forgets earlier results when the clear button is used', async () => { + it('does not connect minimizeCounter to desktop autocomplete', async () => { const user = userEvent.setup(); - const pending = deferred(); - const next = deferred(); - const onSearch = jest - .fn() - .mockResolvedValueOnce(['Saved address']) - .mockReturnValueOnce(pending.promise) - .mockReturnValueOnce(next.promise); - render(); - const input = screen.getByRole('searchbox'); + const pending = deferred(); + const onSearch = jest.fn().mockReturnValue(pending.promise); + const categories = [createTextCategory(onSearch)]; + const { rerender } = render(); + const input = screen.getByRole('combobox'); await user.type(input, 'A'); - expect(await screen.findByRole('button', { name: 'Saved address' })).toBeVisible(); - await user.type(input, 'b'); - await user.click(screen.getByRole('button', { name: /^clear$/i })); - - expect(onSearch.mock.calls[1][1].signal.aborted).toBe(true); - expect(input).toHaveValue(''); + rerender(); + expect(within(screen.getByRole('search')).getByRole('status')).toHaveTextContent('Searching…'); expect(input).toHaveFocus(); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(onSearch.mock.calls[0][1].signal.aborted).toBe(false); await act(async () => { - pending.resolve(['Cancelled address']); + pending.resolve([{ value: 'Found address' }]); }); - await selectCategory(user, 'Addresses'); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(onSearch).toHaveBeenCalledTimes(2); - await user.type(input, 'N'); - - expect(within(screen.getByRole('region')).getByRole('status')).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Saved address' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Cancelled address' })).not.toBeInTheDocument(); - expect(screen.queryByText('Submitted query: A')).not.toBeInTheDocument(); + expect(await screen.findByRole('option', { name: 'Found address' })).toBeVisible(); + expect(input).toHaveValue('A'); + expect(onSearch).toHaveBeenCalledTimes(1); }); - it('keeps a text panel closed when a pending live search finishes', async () => { + it.each(['resolve', 'reject'] as const)('ignores an outdated autocomplete %s', async (settle) => { const user = userEvent.setup(); - const pending = deferred(); - const onSearch = jest.fn().mockReturnValue(pending.promise); + const older = deferred(); + const newer = deferred(); + const onSearch = jest.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); render(); - const input = screen.getByRole('searchbox'); - await user.type(input, 'A'); - await user.keyboard('{Escape}'); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(input).toHaveFocus(); + await user.type(screen.getByRole('combobox'), 'Aa'); + expect(onSearch.mock.calls[0][1].signal.aborted).toBe(true); await act(async () => { - pending.resolve(['Found address']); + if (settle === 'resolve') older.resolve([{ value: 'Outdated address' }]); + else older.reject(new Error('Outdated failure')); }); - - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(input).toHaveFocus(); - await selectCategory(user, 'Addresses'); - expect(screen.getByRole('button', { name: 'Found address' })).toBeVisible(); - expect(screen.getByText('Submitted query: A')).toBeVisible(); - expect(onSearch).toHaveBeenCalledTimes(1); + expect(within(screen.getByRole('search')).getByRole('status')).toHaveTextContent('Searching…'); + expect(screen.queryByRole('option')).not.toBeInTheDocument(); + expect(screen.queryByText('The search failed. Please try again.')).not.toBeInTheDocument(); + await act(async () => { + newer.resolve([{ value: 'Current address' }]); + }); + expect(await screen.findByRole('option', { name: 'Current address' })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Outdated address' })).not.toBeInTheDocument(); }); it('stores a live text result without switching categories or moving focus', async () => { const user = userEvent.setup(); - const pending = deferred(); + const pending = deferred(); const onSearch = jest.fn().mockReturnValue(pending.promise); render(); - await user.type(screen.getByRole('searchbox'), 'A'); + await user.type(screen.getByRole('combobox'), 'A'); await selectCategory(user, 'Parcels'); const owner = screen.getByRole('textbox', { name: 'Parcels owner' }); await user.type(owner, 'Kask'); await act(async () => { - pending.resolve(['Found address']); + pending.resolve([{ value: 'Found address' }]); }); expect(screen.getByRole('region', { name: 'Parcels: Filter' })).toBeVisible(); expect(owner).toHaveValue('Kask'); expect(owner).toHaveFocus(); - expect(screen.queryByRole('button', { name: 'Found address' })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Found address' })).not.toBeInTheDocument(); await selectCategory(user, 'Addresses'); - expect(screen.getByRole('searchbox')).toHaveValue('A'); - expect(screen.getByRole('button', { name: 'Found address' })).toBeVisible(); + expect(screen.getByRole('combobox')).toHaveValue('A'); + await user.click(screen.getByRole('combobox')); + expect(await screen.findByRole('option', { name: 'Found address' })).toBeVisible(); expect(onSearch).toHaveBeenCalledTimes(1); }); - it('does not move focus from the application when live text results arrive', async () => { + it('keeps autocomplete closed when a response arrives after focus leaves the search', async () => { const user = userEvent.setup(); - const pending = deferred(); + const pending = deferred(); + const onSearch = jest.fn(() => pending.promise); render( <> - pending.promise)]} /> + ); - await user.type(screen.getByRole('searchbox'), 'A'); + await user.type(screen.getByRole('combobox'), 'A'); await user.click(screen.getByRole('button', { name: 'Map action' })); await act(async () => { - pending.resolve(['Found address']); + pending.resolve([{ value: 'Found address' }]); }); - expect(screen.getByRole('button', { name: 'Found address' })).toBeVisible(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Map action' })).toHaveFocus(); + await user.click(screen.getByRole('combobox')); + expect(await screen.findByRole('option', { name: 'Found address' })).toBeVisible(); + expect(onSearch).toHaveBeenCalledTimes(1); }); it('does not run live or submitted text searches while disabled', async () => { const user = userEvent.setup(); const onSearch = jest.fn(); render(); - const input = screen.getByRole('searchbox'); + const input = screen.getByRole('combobox'); expect(input).toBeDisabled(); expect(screen.getByRole('button', { name: 'Search' })).toBeDisabled(); @@ -1080,7 +1682,7 @@ describe('CategorySearch', () => { await user.click(screen.getByRole('button', { name: 'Search' })); expect(onSearch).not.toHaveBeenCalled(); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); it.each([undefined, 'small', 'default', 'large'] as const)( @@ -1107,12 +1709,11 @@ describe('CategorySearch', () => { renderResults: ({ result }) =>

{result[0]}

, }); render(); - const input = screen.getByRole('searchbox'); + const input = screen.getByRole('combobox'); expect(input.closest('[data-name="textfield"]')).toHaveClass(`tedi-textfield--${expectedSize}`); expect(screen.getByRole('button', { name: 'Search' })).toHaveClass(`tedi-btn--${expectedSize}`); await user.type(input, 'A'); - expect(screen.getByRole('button', { name: 'Close' })).toHaveClass(`tedi-btn--${expectedSize}`); await selectCategory(user, 'Parcels'); @@ -1125,14 +1726,34 @@ describe('CategorySearch', () => { } ); - it('forwards the root ref and accepts per-instance panel dimensions', async () => { + it('preserves filter values and results when full panel width changes', async () => { + const user = userEvent.setup(); + const onSearch = jest.fn(() => ['Found parcel']); + const categories = [createCategory('parcels', 'Parcels', onSearch)]; + const { rerender } = render(); + await user.type(screen.getByRole('textbox'), 'Aasa'); + + rerender(); + expect(screen.getByRole('textbox')).toHaveValue('Aasa'); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(await screen.findByText('Found parcel')).toBeVisible(); + + rerender(); + expect(screen.getByText('Found parcel')).toBeVisible(); + expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Show filters' })); + expect(screen.getByRole('textbox')).toHaveValue('Aasa'); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it('forwards the root ref and applies search-bar width and filter-panel height', async () => { const ref = createRef(); render( { ); expect(ref.current).toHaveAttribute('id', 'parcel-search'); expect(ref.current).toHaveClass('map-search'); + expect(ref.current).toHaveStyle({ width: '30rem' }); expect(ref.current).toContainElement(await screen.findByRole('region', { name: 'Parcels: Filter' })); - expect(ref.current?.style.getPropertyValue('--category-search-panel-width')).toBe('420px'); expect(ref.current?.style.getPropertyValue('--category-search-panel-max-height')).toBe('60vh'); }); }); diff --git a/src/community/components/form/category-search/category-search.stories.module.scss b/src/community/components/form/category-search/category-search.stories.module.scss index 35f42b59f..831cfd936 100644 --- a/src/community/components/form/category-search/category-search.stories.module.scss +++ b/src/community/components/form/category-search/category-search.stories.module.scss @@ -9,20 +9,4 @@ gap: var(--tedi-dimensions-02); margin-block-end: var(--tedi-dimensions-04); } - - &__results { - padding: 0; - margin: 0; - list-style: none; - - li { - display: flex; - padding-block: var(--tedi-dimensions-02); - overflow-wrap: anywhere; - - + li { - border-block-start: 1px solid var(--general-border-primary); - } - } - } } diff --git a/src/community/components/form/category-search/category-search.stories.tsx b/src/community/components/form/category-search/category-search.stories.tsx index f6e595ffa..1a411b394 100644 --- a/src/community/components/form/category-search/category-search.stories.tsx +++ b/src/community/components/form/category-search/category-search.stories.tsx @@ -1,176 +1,75 @@ +/// + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useEffect, useId, useState } from 'react'; +import { type ComponentType, type ReactNode, useEffect, useId, useState } from 'react'; import { useArgs } from 'storybook/preview-api'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import { Button } from '../../../../tedi/components/buttons/button/button'; import { Checkbox } from '../../../../tedi/components/form/checkbox/checkbox'; import { TextField } from '../../../../tedi/components/form/textfield/textfield'; +import { Toggle } from '../../../../tedi/components/form/toggle/toggle'; +import { Alert } from '../../../../tedi/components/notifications/alert/alert'; import { CategorySearch } from './category-search'; import styles from './category-search.stories.module.scss'; +import storySource from './category-search.stories.tsx?raw'; import type { CategorySearchFilterContext, CategorySearchProps, CategorySearchSize } from './category-search.types'; import { defineCategorySearchCategory } from './define-category-search-category'; -interface Person { - name: string; - active: boolean; -} - +// Address example start const addresses = [ 'Kasevälja tee 127, Rohelise küla, Näidise vald, Harju maakond, 12345', 'Kasevälja tee 129a, Rohelise küla, Näidise vald, Harju maakond, 12345', 'Männimetsa põik 42, Päikeseküla, Näidise vald, Tartu maakond, 54321', ]; -const cities = ['Tallinn', 'Tartu', 'Pärnu']; -const people: Person[] = [ - { name: 'Anna Kask', active: true }, - { name: 'Karl Tamm', active: true }, - { name: 'Mari Saar', active: false }, -]; - -interface PeopleFilters { - name: string; - activeOnly: boolean; -} - -function ResultRows({ items, onSelect }: { items: string[]; onSelect?: (item: string) => void }) { - return ( -
    - {items.map((item) => ( -
  • - {onSelect ? ( - - ) : ( - item - )} -
  • - ))} -
- ); -} - -function PeopleFields({ values, onChange, disabled, size }: CategorySearchFilterContext) { - const id = useId(); - - return ( - <> - onChange({ ...values, name })} - /> - onChange({ ...values, activeOnly })} - /> - - ); -} - -const addressSearch = defineCategorySearchCategory({ +const addressSearch = defineCategorySearchCategory({ id: 'addresses', label: 'Addresses', mode: 'search', placeholder: 'Search addresses', - onSearch: (query) => addresses.filter((address) => address.toLowerCase().includes(query.trim().toLowerCase())), - renderResults: ({ result, selectResult }) => , -}); - -const citySearch = defineCategorySearchCategory({ - id: 'cities', - label: 'Cities', - mode: 'search', - placeholder: 'Search cities', - onSearch: (query) => cities.filter((city) => city.toLowerCase().includes(query.trim().toLowerCase())), - renderResults: ({ result, selectResult }) => , -}); - -const peopleSearch = defineCategorySearchCategory({ - id: 'people', - label: 'People', - mode: 'category', - initialFilters: () => ({ name: '', activeOnly: false }), - renderFilters: (context) => , - onSearch: async ({ name, activeOnly }) => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return people.filter( - (person) => person.name.toLowerCase().includes(name.trim().toLowerCase()) && (!activeOnly || person.active) - ); + onSearch: async (query) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + return addresses + .filter((address) => address.toLowerCase().includes(query.trim().toLowerCase())) + .map((address) => ({ value: address, label: address })); }, - renderResults: ({ result }) => ( - `${person.name} — ${person.active ? 'Active' : 'Inactive'}`)} /> - ), }); -const peopleWithLongLabel = { - ...peopleSearch, - id: 'people-long-label', - label: 'People registered as residents', -}; - -const searchExampleSource = `import { CategorySearch, defineCategorySearchCategory } from '@tedi-design-system/react/community'; -import { Button } from '@tedi-design-system/react/tedi'; - -// Fictional addresses for this example. -const addresses = [ - 'Kasevälja tee 127, Rohelise küla, Näidise vald, Harju maakond, 12345', - 'Kasevälja tee 129a, Rohelise küla, Näidise vald, Harju maakond, 12345', - 'Männimetsa põik 42, Päikeseküla, Näidise vald, Tartu maakond, 54321', -]; - -const addressSearch = defineCategorySearchCategory({ - id: 'addresses', - label: 'Addresses', - mode: 'search', - placeholder: 'Search addresses', - - onSearch: (query) => { - const searchText = query.trim().toLowerCase(); - return addresses.filter((address) => address.toLowerCase().includes(searchText)); - }, - - renderResults: ({ result, selectResult }) => ( -
- {result.map((address) => ( -
- -
- ))} -
- ), -}); +function AddressSearchExample(props: Partial = {}) { + const id = useId(); + const [showSearchButton, setShowSearchButton] = useState(true); + const [extraSmallSearchIcon, setExtraSmallSearchIcon] = useState(false); -export function AddressSearchExample() { return ( -
- +
+
+ + +
+
); -}`; - -const categorySearchExampleSource = `import { useId } from 'react'; - -import { - CategorySearch, - type CategorySearchFilterContext, - defineCategorySearchCategory, -} from '@tedi-design-system/react/community'; -import { Button, Checkbox, TextField } from '@tedi-design-system/react/tedi'; +} +// Address example end +// People and cities example start interface PeopleFilters { name: string; activeOnly: boolean; @@ -181,6 +80,21 @@ interface Person { active: boolean; } +interface OrganisationFilters { + name: string; + city: string; +} + +interface Organisation { + name: string; + city: string; +} + +const organisations: Organisation[] = [ + { name: 'Example School', city: 'Tallinn' }, + { name: 'Example Library', city: 'Tartu' }, +]; + const cities = ['Tallinn', 'Tartu', 'Pärnu']; const people: Person[] = [ { name: 'Anna Kask', active: true }, @@ -189,13 +103,12 @@ const people: Person[] = [ ]; function PeopleFields({ values, onChange, disabled, size }: CategorySearchFilterContext) { - const fieldId = useId(); + const id = useId(); - // Replace the filter object on each edit so saved results keep their original inputs. return ( <> onChange({ ...values, name })} /> ({ +function OrganisationFields({ values, onChange, disabled, size }: CategorySearchFilterContext) { + const id = useId(); + + return ( + <> + onChange({ ...values, name })} + /> + onChange({ ...values, city })} + /> + + ); +} + +const citySearch = defineCategorySearchCategory({ id: 'cities', label: 'Cities', mode: 'search', placeholder: 'Search cities', - - onSearch: (query) => { - const searchText = query.trim().toLowerCase(); - return cities.filter((city) => city.toLowerCase().includes(searchText)); - }, - - renderResults: ({ result, selectResult }) => ( -
- {result.map((city) => ( -
- -
- ))} -
- ), + onSearch: (query) => + cities + .filter((city) => city.toLowerCase().includes(query.trim().toLowerCase())) + .map((city) => ({ value: city, label: city })), }); -const peopleSearch = defineCategorySearchCategory({ - id: 'people', - label: 'People', - mode: 'category', - initialFilters: () => ({ name: '', activeOnly: false }), - - renderFilters: (context) => , - - onSearch: async ({ name, activeOnly }) => { - // Demo delay to make the loading state visible. - await new Promise((resolve) => setTimeout(resolve, 1000)); - const searchText = name.trim().toLowerCase(); - - return people.filter((person) => { - const matchesName = person.name.toLowerCase().includes(searchText); - const matchesActiveFilter = !activeOnly || person.active; - return matchesName && matchesActiveFilter; - }); - }, +function PeopleAndCitiesSearchExample({ + withValidation = false, + ...props +}: Partial & { withValidation?: boolean } = {}) { + const id = useId(); + const [peopleMessage, setPeopleMessage] = useState(); + const [organisationMessage, setOrganisationMessage] = useState(); + const [simulateValidation, setSimulateValidation] = useState(false); + const [validationPosition, setValidationPosition] = useState(props.validationPosition ?? 'before'); + const [minimizeCounter, setMinimizeCounter] = useState(props.minimizeCounter ?? 0); + const [fullWidthFilterPanel, setFullWidthFilterPanel] = useState(props.fullWidthFilterPanel ?? false); + + const peopleSearch = defineCategorySearchCategory({ + id: 'people', + label: 'People registered as residents', + mode: 'category', + initialFilters: () => ({ name: '', activeOnly: false }), + renderFilters: (context) => , + validationMessage: peopleMessage, + onSearch: async ({ name, activeOnly }, { signal }) => { + setPeopleMessage(undefined); + if (simulateValidation) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + if (!signal.aborted) { + setPeopleMessage( + setPeopleMessage(undefined)}> + Please review the people search for {name.trim() || 'all people'}. + + ); + } + } + return people.filter( + (person) => person.name.toLowerCase().includes(name.trim().toLowerCase()) && (!activeOnly || person.active) + ); + }, + renderResults: ({ result }) => ( +
+ {result.map((person) => ( +
+ {person.name} — {person.active ? 'Active' : 'Inactive'} +
+ ))} +
+ ), + }); + + const organisationSearch = defineCategorySearchCategory({ + id: 'organisations', + label: 'Organisations', + mode: 'category', + initialFilters: () => ({ name: '', city: '' }), + renderFilters: (context) => , + validationMessage: organisationMessage, + onSearch: async ({ name, city }, { signal }) => { + setOrganisationMessage(undefined); + if (simulateValidation) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + if (!signal.aborted) { + setOrganisationMessage( + setOrganisationMessage(undefined)}> + Please review the organisation search for {name.trim() || 'all organisations'}. + + ); + } + } + return organisations.filter( + (organisation) => + organisation.name.toLowerCase().includes(name.trim().toLowerCase()) && + organisation.city.toLowerCase().includes(city.trim().toLowerCase()) + ); + }, + renderResults: ({ result }) => ( +
+ {result.map((organisation) => ( +
+ {organisation.name} — {organisation.city} +
+ ))} +
+ ), + }); - renderResults: ({ result }) => ( + return (
- {result.map((person) => ( -
- {person.name} — {person.active ? 'Active' : 'Inactive'} +
+ +
+
+ +
+ {withValidation && ( +
+ + setValidationPosition(after ? 'after' : 'before')} + />
- ))} -
- ), -}); - -const peopleWithLongLabel = { - ...peopleSearch, - id: 'people-long-label', - label: 'People registered as residents', -}; - -export function PeopleAndCitiesSearchExample() { - // Filter panels match the left action area, excluding the category selector. - // panelWidth changes only text-search panels, such as Cities. - return ( -
+ )}
); -}`; - -const componentDescription = ` -A reusable search bar with **live text search** and **category-specific filter forms**. -Results and filters open directly beneath the bar. The page remains interactive: -there is no modal backdrop, focus trap or page scroll lock. -The category menu opens beneath the selector, aligned with its right edge. - -### What the application supplies - -The required \`categories\` prop contains definitions created with \`defineCategorySearchCategory\`. -Each category has a stable, unique ID, a visible label, a search callback and a result renderer. - -| Category mode | Application provides | -| --- | --- | -| \`search\` | \`onSearch(query, context)\`, \`renderResults({ query, result, selectResult })\`, and optional \`initialQuery\` and \`placeholder\`. | -| \`category\` | \`initialFilters()\`, \`renderFilters({ values, onChange, disabled, size })\`, \`onSearch(filters, context)\`, and \`renderResults({ filters, result })\`. | - -Use existing TEDI controls for filter fields. Return fields only: CategorySearch supplies the form, -Search, Clear and Close buttons. Replace edited values with a fresh object; never change the supplied -object in place. Create fresh objects for edited nested values too. This keeps saved results tied to -their original inputs. The helper keeps each category's field and result types checked, -even when different categories share one array. - -### Live text search and selection - -Typing searches the selected text category while focus stays in the input. Enter and the search button -can still submit explicitly. The application renders its own result rows; call \`selectResult(text)\` -when a row is chosen to put that text into the input and close the panel. Selection also cancels pending -work and returns focus to the input. It does not run another search. - -Addresses and Cities use TEDI buttons for selectable rows, so they work with a pointer or Tab and Enter. -After closing the panel, select the same category again from the menu to reopen its saved results. - -### Sizes - -\`size\` accepts \`small\`, \`default\` and \`large\`, matching TEDI Search. It defaults to \`default\`. -The component passes the resolved size to \`renderFilters\`; apply it to the TEDI fields you provide. -The People example applies it to TextField and uses Checkbox's large size only for \`large\`, because -Checkbox supports default and large sizes. - -Both examples have Small, Default and Large buttons linked to the Storybook size control. -Changing size keeps the same component mounted, preserving edited values and saved results. - -### Remembered values and results - -Each category remembers three things: the input you are editing, the input submitted for its last -successful search, and the results of that search. These are kept while CategorySearch stays mounted. - -- **Search** saves the query or submitted filters together with the successful result. -- **Edit filters** changes the form while existing results still belong to the submitted values. -- **Close** hides the panel and keeps the values and results. -- **Switch category** restores that category's values and successful results. -- **Clear / Clear filter** restores only the selected category's defaults and removes its saved search. -- **Empty results** are a completed search and can be reopened. -- **Failed replacement searches** show an error and keep the earlier successful result. - -When category filters have been edited, the results show **Filters edited since searching.** -For object filters, this checks whether the current filter object is the same object that was submitted. -It does not compare individual fields or nested contents. Editing a field back to its earlier value still -creates another object, so the notice remains. Search again to produce results from the current filters. - -Editing while a search is running keeps your latest form values. When that search succeeds, its results -remain paired with the input submitted at the start. The notice stays if you edited those filters while waiting. - -A text search has no panel before the first search. Category filters can open immediately. -Removing CategorySearch from the page resets its saved state. Removing a category forgets that category's state; -changing a definition while keeping its ID preserves the existing values. - -### Asynchronous searches and focus - -\`onSearch\` can return a result directly or a promise. Its second argument contains an \`AbortSignal\` -for fetch requests. A newer search, result selection, Clear, category removal or unmounting cancels the earlier request. -Outdated responses are ignored even when the application callback does not use the signal. - -Closing or switching categories does not cancel a pending search. Its result is saved for its own category. -A delayed response never reopens the panel, selects a category or moves focus. -Typing keeps focus in the input. Explicit submission moves focus to the results panel; -Close or Escape inside the panel returns focus to the search control. -Escape outside the component leaves the panel open. - -### Result counts and layout +} -Arrays are counted automatically. Supply \`getResultCount(result)\` for paginated or other result shapes. -A count of zero displays the no-results message. Otherwise the application renders the result rows. +function ValidationSearchExample(props: Partial = {}) { + return ; +} +// People and cities example end -Panels overlay content below the search bar. For text-search categories, \`panelWidth\` sets the result -panel width independently of the bar; it can be wider and shrinks to the available viewport space. -The Search example keeps the bar at 30rem and requests a 48rem panel for long addresses. -Category-filter panels always match the left action area, excluding the category selector; -\`panelWidth\` does not change them. +function exampleSource(name: string, imports: string) { + const start = `// ${name} example start`; + const end = `// ${name} example end`; + const example = storySource.slice(storySource.indexOf(start) + start.length, storySource.indexOf(end)).trim(); -Keep the bar within the viewport and avoid clipping ancestors. \`maxPanelHeight\` limits panel height, -with scrolling for longer content. Both dimension props accept CSS lengths or numbers in pixels. + return `${imports}\n\n${example}`; +} -### Controlled state and labels +const componentDescription = ` +A search bar with TEDI autocomplete and category-specific filter forms. Desktop panels keep the page +interactive; mobile uses a full-screen TEDI Modal. -Normally CategorySearch manages the selected category and panel visibility. -To manage them in the application, pass \`categoryId\` with \`onCategoryChange\`, or \`open\` with \`onOpenChange\`, -and apply the requested value. \`defaultCategoryId\` and \`defaultOpen\` only set the initial state. -They do not reset a mounted component. +### Category definitions and callbacks -Use \`labels\` to override the English action and status text. Category labels name the selector, -the search region and the panel. \`labels.searchInput\` names the text input. -The forwarded ref points to the outer div. +Provide at least one category with a stable, unique ID. Use \`defineCategorySearchCategory\` to keep +each category's filters and results typed, even when different types share one array. -### Examples +| Mode | Application provides | +| --- | --- | +| \`search\` | \`onSearch(query, context)\` returning TEDI \`SearchSuggestion[]\`, and optional \`onSuggestionSelect\` and \`renderSuggestionContent\`. | +| \`category\` | \`initialFilters()\`, \`renderFilters({ values, onChange, disabled, size })\`, \`onSearch(filters, context)\`, and \`renderResults({ filters, result })\`. | -**Search** types **Kase** when opened, showing two fictional full addresses immediately. The bar stays -30rem wide while the results panel requests 48rem. Long address rows wrap on narrow screens. Choose a row -to put the full address into the input and close the results. Storybook types into the real field in Canvas and Docs. -**Category search** starts with the People filter form and adds Cities to demonstrate remembered searches. -**People registered as residents** demonstrates a longer category label in the right-aligned menu. -It reuses the People fields and data with its own category ID, so its values and results are remembered separately. -People searches simulate a one-second delay so **Searching…** is visible and you can switch categories -or close the panel while a request is pending. City searches remain immediate. -Both examples show plain result rows and use the same component. Neither example calls a backend. +Return fields only from \`renderFilters\`: CategorySearch supplies the form and action buttons. +Apply its \`size\` and \`disabled\` values to your TEDI fields. Call \`onChange\` with a fresh object, +including fresh nested objects when editing them; do not mutate the supplied values. + +\`onSearch\` may return a result or a promise. Its second argument contains an \`AbortSignal\` for fetch. +A newer search, selection, Clear, deleting all text or unmounting cancels that category's pending request; +outdated responses are ignored. Closing a filter panel or the mobile modal and switching categories let +requests finish without reopening them or changing the active category. + +Filter-category arrays are counted automatically. Use \`getResultCount(result)\` for other result shapes. +A successful filter search with zero results shows TEDI EmptyState with \`labels.noResults\`; initial, +loading and failed states do not. Other filter results are rendered by the application. + +### Text search and saved results + +Typing searches immediately. TEDI Search handles the suggestion popup, keyboard selection and +focus-based opening/closing. Enter submits when no suggestion is active; the optional search button +also submits. \`showSearchButton: false\` hides only the text-category submit button. + +Return suggestions with a stable \`value\`, optional \`label\` and optional \`description\`. +Selecting a suggestion saves its string label (or value) as the query without another search. +Use \`onSuggestionSelect(suggestion)\` for application actions and \`renderSuggestionContent(suggestion, state)\` +for custom rows; \`state\` contains \`active\` and \`query\`. +Search displays loading and request-failure feedback in its popup, with its standard localized +no-results message. + +Each category retains its draft, last submitted values and successful results while mounted. +Closing, switching category or changing presentation preserves them. Clear and deleting all text remove +the active text category's query and saved results. Filter Clear resets only that category to its initial +filters. \`initialQuery\`, \`defaultCategoryId\` and \`defaultOpen\` apply only on mount. +Keep category IDs stable while mounted; definitions and callbacks may change. + +Editing filters, including during a pending search, preserves the submitted values paired with saved +results. **Filters edited since searching.** uses object identity, so editing back to the original field +values still leaves the notice until another search. A failed filter search keeps earlier results. + +### Application validation + +Supply a TEDI Alert through a filter category's \`validationMessage\`. The application owns the content, +severity and lifetime; CategorySearch reads the latest definition without copying messages into state. +A message shows that category's filter form with its entered values preserved. Messages never appear +in results, switch categories or reopen a closed panel. + +\`validationPosition\` places the message before or after fields. Moving or removing it preserves the form +and values. Clear does not remove application messages. Messages do not block searches or impose a backend +response format. Before setting a message after an asynchronous request, check \`signal.aborted\` so a +cancelled request cannot restore an obsolete message. + +### Presentation and controls + +\`mobile\` uses TEDI's below-md breakpoint when omitted; \`true\` and \`false\` override it. +The mobile launcher opens without searching. The modal header contains its title and Close control; +its body contains the category dropdown, search bar, then filters or results. +Normal closing restores focus to the search control. Text searches leave focus in place; replacing a +focused filter form with results recovers focus to the panel. + +\`searchBarWidth\` sets the whole bar's width, including the category dropdown, within its parent. +The desktop dropdown keeps the width of its widest label without wrapping; the input uses the remaining bar width. + +Text suggestions use Search's standard popup width, including inside the mobile modal. +Desktop filter panels match the bar's left action area by default. Set \`fullWidthFilterPanel\` to +include the category selector in the width of filter forms and their results. \`maxPanelHeight\` +limits desktop filter-panel height using a CSS length or pixel number. + +Every later change to \`minimizeCounter\` hides the filter panel or mobile modal without restoring focus +or discarding state. Its initial and unchanged values do nothing. Pending results and validation leave +them closed; the user can reopen normally. Desktop autocomplete uses Search's own visibility behaviour +and is not connected to the counter. + +Use \`labels\` for action/status text and \`resultCount\` formatting. \`labels.searchInput\` also names the +mobile modal. The forwarded ref points to the outer div. Story controls for size and presentation keep +the same component mounted. `; function CategorySearchExample({ args, + Example, onSizeChange, }: { args: CategorySearchProps; + Example: ComponentType>; onSizeChange: (size: CategorySearchSize) => void; }) { // Secondary Docs examples keep their initial args, so their buttons also need local state. @@ -445,7 +431,7 @@ function CategorySearchExample({ ))}
- + ); } @@ -456,11 +442,14 @@ const meta = { tags: ['autodocs'], render: function Render(args) { const [, updateArgs] = useArgs(); - return updateArgs({ size })} />; + return ( + updateArgs({ size })} /> + ); }, args: { categories: [addressSearch], size: 'default', + searchBarWidth: '30rem', }, parameters: { layout: 'padded', @@ -475,65 +464,52 @@ const meta = { categories: { control: false, type: { name: 'other', value: 'CategorySearchCategory[]', required: true }, - description: - 'Required. Category definitions with unique IDs. Use defineCategorySearchCategory to connect typed fields, search callbacks and result renderers.', + description: 'At least one typed definition with a stable, unique ID.', table: { category: 'Required props', type: { summary: 'readonly CategorySearchCategory[]' } }, }, size: { control: 'inline-radio', options: ['small', 'default', 'large'], - description: - 'Search bar size, matching TEDI Search. Also passed to renderFilters so application-provided fields can use the same size. The visible size buttons update this control without resetting the component.', + description: 'Control size, also passed to renderFilters.', table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: 'default' } }, }, - categoryId: { + searchButtonIconSize: { control: false, description: - 'Controlled category ID. Apply onCategoryChange to update it. An unknown ID falls back to the first category.', + 'Magnifying-glass size, independent of the button and input size. The first example has an Extra small search icon switch: 12 px when on, TEDI default when off.', table: { category: 'Optional props', - subcategory: 'State', - defaultValue: { summary: 'undefined (managed internally)' }, + subcategory: 'Layout', + type: { summary: 'IconSize' }, + defaultValue: { summary: '18 (small/default), 24 (large)' }, }, }, - defaultCategoryId: { - control: false, - description: 'Initial category ID, used only on mounting. Ignored when categoryId is supplied.', - table: { category: 'Optional props', subcategory: 'State', defaultValue: { summary: 'First category ID' } }, + mobile: { + control: 'inline-radio', + options: ['Automatic', 'Mobile', 'Desktop'], + mapping: { Automatic: undefined, Mobile: true, Desktop: false }, + description: 'Automatic follows the below-md breakpoint; Mobile and Desktop override it.', + table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: 'Automatic (below md)' } }, }, - onCategoryChange: { + validationPosition: { control: false, - description: 'Called when a category is selected. In controlled mode, use the requested ID to update categoryId.', - table: { - category: 'Optional props', - subcategory: 'Events', - type: { summary: '(categoryId: string) => void' }, - }, + description: 'Place application-provided validation content before or after filter fields.', + table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: 'before' } }, }, - open: { + defaultCategoryId: { control: false, - description: - 'Controlled panel visibility. Apply onOpenChange to update it. Text search still needs a search before it can show a panel.', - table: { - category: 'Optional props', - subcategory: 'State', - defaultValue: { summary: 'undefined (managed internally)' }, - }, + description: 'Initial category ID, used only on mounting.', + table: { category: 'Optional props', subcategory: 'State', defaultValue: { summary: 'First category ID' } }, }, defaultOpen: { control: false, - description: 'Initial panel visibility, used only on mounting. Text search has no panel until its first search.', + description: 'Initial visibility of the filter panel or mobile modal.', table: { category: 'Optional props', subcategory: 'State', defaultValue: { summary: 'false' } }, }, - onOpenChange: { + minimizeCounter: { control: false, - description: - 'Called when an interaction requests the panel to open or close. In controlled mode, update open with that value.', - table: { - category: 'Optional props', - subcategory: 'Events', - type: { summary: '(open: boolean) => void' }, - }, + description: 'Later changes hide the filter panel or mobile modal without clearing state or restoring focus.', + table: { category: 'Optional props', subcategory: 'State' }, }, disabled: { control: 'boolean', @@ -542,8 +518,7 @@ const meta = { }, labels: { control: false, - description: - 'Overrides English action labels, status messages and the resultCount formatter. The default edited-filter notice is "Filters edited since searching." Category names come from their definitions.', + description: 'Override action labels, status text and the resultCount formatter.', table: { category: 'Optional props', subcategory: 'Text', @@ -551,16 +526,20 @@ const meta = { defaultValue: { summary: 'English labels' }, }, }, - panelWidth: { + searchBarWidth: { control: 'text', - description: - 'Text-search result panel width, independent of the bar and limited by available viewport space. Ignored for category-filter panels, which match the left action area and exclude the category selector. Accepts a CSS length or a number in pixels; in this text control, use 480px rather than 480.', + description: 'Whole search-bar width, including the category dropdown. Use a CSS length such as 480px.', table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: '100%' } }, }, + fullWidthFilterPanel: { + control: false, + description: + 'Use the example switch to make desktop filter forms and results match the full bar, including the dropdown.', + table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: 'false' } }, + }, maxPanelHeight: { control: 'text', - description: - 'Maximum panel height. Accepts a CSS length or a number in pixels. Longer content scrolls inside the panel.', + description: 'Maximum desktop filter-panel height; longer content scrolls inside it.', table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: 'min(32rem, 70dvh)' } }, }, id: { @@ -592,26 +571,34 @@ type Story = StoryObj; export const Default: Story = { name: 'Search', - args: { - panelWidth: '48rem', - }, play: async ({ canvasElement, args }) => { if (args.disabled) return; const canvas = within(canvasElement); - const input = canvas.getByRole('searchbox', { name: 'Search' }); - await userEvent.clear(input); - await userEvent.type(input, 'Kase'); - await canvas.findByRole('button', { name: addresses[0] }); - await canvas.findByRole('button', { name: addresses[1] }); - await expect(input).toHaveFocus(); + const page = within(canvasElement.ownerDocument.body); + const launcher = canvas.queryByRole('searchbox', { name: 'Search' }); + if (launcher) await userEvent.click(launcher); + const search = launcher ? within(await page.findByRole('dialog', { name: 'Search' })) : canvas; + await userEvent.type(search.getByRole('combobox', { name: 'Search' }), 'Kase'); + await waitFor(async () => { + await expect(page.getByRole('option', { name: addresses[0] })).toBeVisible(); + await expect(page.getByRole('option', { name: addresses[1] })).toBeVisible(); + }); }, parameters: { docs: { - story: { autoplay: true }, - source: { language: 'tsx', type: 'code', code: searchExampleSource }, + source: { + language: 'tsx', + type: 'code', + code: exampleSource( + 'Address', + `import { useId, useState } from 'react'; +import { Toggle } from '@tedi-design-system/react/tedi'; +import { CategorySearch, defineCategorySearchCategory, type CategorySearchProps } from '@tedi-design-system/react/community';` + ), + }, description: { story: - 'Starts with Kase, showing two fictional full addresses. The search bar is 30rem wide and the text-results panel requests 48rem so long addresses have more room; rows wrap on narrow screens. Results update as you type. Choose a row to put the full address into the input and close the results. Try Männimetsa or a name with no matches. Close preserves the query and results; select Addresses again to reopen them. Clear removes the saved search. Small, Default and Large change control size without resetting your work.', + 'Type Kase to load two address suggestions after a short delay, or No match for the no-results message. Select an address to update the input. Blur and refocus to see the saved suggestions; on mobile, reopen the modal first. Show search button tries both input variants.', }, }, }, @@ -619,17 +606,70 @@ export const Default: Story = { export const CategoryFilters: Story = { name: 'Category search', + decorators: [ + (Story) => ( +
+ +
+ ), + ], + render: function Render(args) { + const [, updateArgs] = useArgs(); + return ( + updateArgs({ size })} + /> + ); + }, args: { - categories: [citySearch, peopleSearch, peopleWithLongLabel], defaultCategoryId: 'people', - defaultOpen: true, }, parameters: { docs: { - source: { language: 'tsx', type: 'code', code: categorySearchExampleSource }, + source: { + language: 'tsx', + type: 'code', + code: exampleSource( + 'People and cities', + `import { type ReactNode, useId, useState } from 'react'; +import { Alert, Button, Checkbox, TextField, Toggle } from '@tedi-design-system/react/tedi'; +import { + CategorySearch, + defineCategorySearchCategory, + type CategorySearchFilterContext, + type CategorySearchProps, +} from '@tedi-design-system/react/community';` + ), + }, + description: { + story: + 'People and Organisations return matching rows immediately; Cities searches as you type. Try Name Anna or School, leave fields empty for all rows, or enter No match for an empty result. Minimize all hides the filter panel or mobile modal while retaining values and results.', + }, + }, + }, +}; + +export const CategoryValidation: Story = { + ...CategoryFilters, + name: 'Category search with validation', + render: function Render(args) { + const [, updateArgs] = useArgs(); + return ( + updateArgs({ size })} + /> + ); + }, + parameters: { + docs: { + ...CategoryFilters.parameters?.docs, description: { story: - 'People starts with Name and Active only filters. Its panel matches the left action area, excluding the category selector. Search takes one second; try editing the filters while it runs. The completed results keep the submitted input, and your edits remain in the form. "Filters edited since searching." also remains when you edit a field back, because each edit creates a new filter object. Cities searches as you type. Open the right-aligned category menu and choose People registered as residents to try a longer label; it uses the same People fields and data with separate saved values and results. Switching categories and Close preserve each category\'s input and results; Clear resets only the selected category. Small, Default and Large resize the controls without resetting them.', + 'Simulation starts off for normal results. Enable Simulate validation messages, submit People, then switch to Organisations before the two-second response. Only People receives its Alert; switch back to see its preserved values. Submit Organisations for an independent message. The placement switch moves Alerts before or after fields. Resubmitting clears only the previous message for that category; dismissing leaves its form open. Minimize during the delay to check that the response keeps the panel closed.', }, }, }, diff --git a/src/community/components/form/category-search/category-search.tsx b/src/community/components/form/category-search/category-search.tsx index 8232fe0f1..07fce137e 100644 --- a/src/community/components/form/category-search/category-search.tsx +++ b/src/community/components/form/category-search/category-search.tsx @@ -1,17 +1,43 @@ -import { autoUpdate, shift, size as sizePanel, useFloating } from '@floating-ui/react'; +import { autoUpdate, size as sizePanel, useFloating } from '@floating-ui/react'; import cn from 'classnames'; import React, { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react'; import { Icon } from '../../../../tedi/components/base/icon/icon'; import { Button } from '../../../../tedi/components/buttons/button/button'; +import { EmptyState } from '../../../../tedi/components/content/empty-state/empty-state'; import { InputGroup } from '../../../../tedi/components/form/input-group/input-group'; -import { Search } from '../../../../tedi/components/form/search/search'; +import { Search, type SearchSuggestion } from '../../../../tedi/components/form/search/search'; import type { TextFieldForwardRef } from '../../../../tedi/components/form/textfield/textfield'; import { Dropdown } from '../../../../tedi/components/overlays/dropdown/dropdown'; +import { Modal } from '../../../../tedi/components/overlays/modal/modal'; +import { isBreakpointBelow, useBreakpoint } from '../../../../tedi/helpers'; import styles from './category-search.module.scss'; import type { CategorySearchCategory, CategorySearchLabels, CategorySearchProps } from './category-search.types'; import { useCategorySearch } from './use-category-search'; +// Hidden labels reserve space for the widest category name. +function renderCategoryLabel( + categories: readonly CategorySearchCategory[], + selectedCategory: CategorySearchCategory, + isMobile: boolean +) { + if (isMobile) return selectedCategory.label; + + return categories.map((category) => { + const selected = category.id === selectedCategory.id; + + return ( + + {category.label} + + ); + }); +} + const defaultLabels: CategorySearchLabels = { category: 'Search category', searchInput: 'Search', @@ -31,60 +57,86 @@ const defaultLabels: CategorySearchLabels = { }; /** - * Search categories with independent drafts and results in an anchored, non-modal panel. - * The background remains interactive. Closing, switching categories, or editing a draft - * preserves completed results; clearing resets only the active category. + * Text autocomplete and filter searches with independent drafts and results. + * Filter panels are non-modal on desktop; mobile searches use a full-screen modal. + * Closing, switching categories, or changing presentation preserves saved state. */ export const CategorySearch = forwardRef( ( { categories, id, - categoryId, defaultCategoryId, - onCategoryChange, - open, defaultOpen = false, - onOpenChange, + minimizeCounter, + mobile, disabled = false, size = 'default', + searchButtonIconSize, + validationPosition = 'before', labels: labelOverrides, - panelWidth = '100%', + searchBarWidth = '100%', + fullWidthFilterPanel = false, maxPanelHeight = 'min(32rem, 70dvh)', className, }, forwardedRef ) => { + const breakpoint = useBreakpoint(); + const isMobile = mobile ?? isBreakpointBelow(breakpoint, 'md'); const generatedId = useId(); const rootId = id ?? `category-search-${generatedId}`; const panelId = `${rootId}-panel`; const labels = { ...defaultLabels, ...labelOverrides }; - const [innerCategoryId, setInnerCategoryId] = useState(defaultCategoryId ?? categories[0]?.id); - const [innerOpen, setInnerOpen] = useState(defaultOpen); - const { states, changeDraft, search, clear, cancel } = useCategorySearch(categories); - // Normally a category with a saved search shows results. This records when - // the user explicitly chooses to edit that category's filters instead. - const [editingCategoryId, setEditingCategoryId] = useState(); + const [categoryId, setCategoryId] = useState(defaultCategoryId ?? categories[0].id); + const [open, setOpen] = useState(defaultOpen); + const { states, changeDraft, search, clear, select } = useCategorySearch(categories); + // Keep the filter view after the user opens it or validation brings it back. + const [keepFiltersVisible, setKeepFiltersVisible] = useState(false); const rootRef = useRef(null); + const panelRef = useRef(null); const triggerRef = useRef(null); const searchRef = useRef(null); - const focusPanelNext = useRef(false); - - const activeId = categoryId ?? innerCategoryId; - const category = categories.find((item) => item.id === activeId) ?? categories[0]; - const categoryState = category ? states.get(category.id) : undefined; - const completedSearch = categoryState?.completedSearch; - const loading = categoryState?.status === 'loading'; - const hasError = categoryState?.status === 'error'; + const previousMinimizeCounter = useRef(minimizeCounter); + + useEffect(() => { + if (previousMinimizeCounter.current !== minimizeCounter) { + previousMinimizeCounter.current = minimizeCounter; + setOpen(false); + } + }, [minimizeCounter]); + + const category = categories.find((item) => item.id === categoryId) ?? categories[0]; + const categoryState = states.get(category.id)!; + const completedSearch = categoryState.completedSearch; + const loading = categoryState.status === 'loading'; + const hasError = categoryState.status === 'error'; const hasSearch = Boolean(completedSearch || loading || hasError); - const textMode = category?.mode === 'search'; - const isOpen = open ?? innerOpen; + const textMode = category.mode === 'search'; + const suggestions = textMode && completedSearch ? (completedSearch.result as SearchSuggestion[]) : []; + const fullWidthPanel = !textMode && fullWidthFilterPanel; + const hasValidationMessage = !textMode && Boolean(category.validationMessage); + const canShowResults = hasSearch && !hasValidationMessage; + const searchButton = + category.showSearchButton === false + ? undefined + : { + icon: searchButtonIconSize === undefined ? 'search' : { name: 'search', size: searchButtonIconSize }, + size, + disabled, + 'aria-label': labels.search, + }; + + useEffect(() => { + if (open && hasValidationMessage && !keepFiltersVisible) { + setKeepFiltersVisible(true); + } + }, [keepFiltersVisible, hasValidationMessage, open]); + let panelView: 'closed' | 'filters' | 'results' = 'closed'; - if (isOpen && category && categoryState) { - if (textMode) { - if (categoryState.draft && hasSearch) panelView = 'results'; - } else if (hasSearch && editingCategoryId !== category.id) { + if (open && !textMode) { + if (canShowResults && !keepFiltersVisible) { panelView = 'results'; } else { panelView = 'filters'; @@ -92,26 +144,19 @@ export const CategorySearch = forwardRef( } const panelVisible = panelView !== 'closed'; const count = completedSearch - ? category?.getResultCount?.(completedSearch.result) ?? + ? category.getResultCount?.(completedSearch.result) ?? (Array.isArray(completedSearch.result) ? completedSearch.result.length : undefined) : undefined; + const showEmptyState = count === 0 && !loading && !hasError; const { refs: panelRefs, floatingStyles: panelStyles } = useFloating({ - open: panelVisible, + open: !isMobile && panelVisible, placement: 'bottom-start', whileElementsMounted: autoUpdate, middleware: [ - textMode && shift(), sizePanel({ - apply({ rects, availableWidth, elements }) { - // Text results may be wider; filter forms end before the category selector. - if (textMode) { - elements.floating.style.width = ''; - elements.floating.style.maxWidth = `${Math.max(0, availableWidth)}px`; - } else { - elements.floating.style.width = `${rects.reference.width}px`; - elements.floating.style.maxWidth = ''; - } + apply({ rects, elements }) { + elements.floating.style.width = `${rects.reference.width}px`; }, }), ], @@ -121,37 +166,18 @@ export const CategorySearch = forwardRef( let showResultCount = false; if (panelView === 'results') { toggleLabel = labels.showFilters; - } else if (hasSearch) { + } else if (canShowResults) { toggleLabel = labels.showResults; showResultCount = true; } useImperativeHandle(forwardedRef, () => rootRef.current as HTMLDivElement); - useEffect(() => { - setEditingCategoryId(undefined); - }, [category?.id]); - - // Focus moves when the user submits, never when a delayed response arrives. - useEffect(() => { - if (panelView === 'closed' || !focusPanelNext.current) return; - focusPanelNext.current = false; - const focused = document.activeElement; - if (focused === document.body || (focused && rootRef.current?.contains(focused))) { - panelRefs.floating.current?.focus(); - } - }, [panelView, category?.id, categoryState, panelRefs.floating]); - - function changeOpen(next: boolean) { - if (open === undefined) setInnerOpen(next); - onOpenChange?.(next); - } - function selectCategory(next: CategorySearchCategory) { if (disabled) return; - if (categoryId === undefined) setInnerCategoryId(next.id); - onCategoryChange?.(next.id); - changeOpen(true); + if (next.id !== category.id) setKeepFiltersVisible(false); + setCategoryId(next.id); + setOpen(true); } function focusSearchControl() { @@ -159,65 +185,69 @@ export const CategorySearch = forwardRef( else triggerRef.current?.focus(); } + function openMobile() { + if (!disabled) setOpen(true); + } + function closePanel() { - focusPanelNext.current = false; - changeOpen(false); - focusSearchControl(); + setOpen(false); + if (!isMobile) focusSearchControl(); } function clearSearch() { - if (!category || disabled) return; + if (disabled) return; clear(category); - setEditingCategoryId(undefined); - focusPanelNext.current = false; - changeOpen(!textMode); - focusSearchControl(); + setKeepFiltersVisible(hasValidationMessage); + setOpen(true); } function changeQuery(query: string) { - if (!category || disabled) return; - changeDraft(category.id, query); - focusPanelNext.current = false; - changeOpen(query.length > 0); + if (disabled) return; if (!query) { - cancel(category.id); + clear(category); return; } + changeDraft(category.id, query); // Use this change's text; categoryState.draft still belongs to the previous render. void search(category, query); } - function selectResult(text: string) { - if (!category || !textMode || disabled) return; - cancel(category.id); - changeDraft(category.id, text); - closePanel(); + function selectSuggestion(suggestion: SearchSuggestion) { + if (disabled) return; + select(category.id, typeof suggestion.label === 'string' ? suggestion.label : suggestion.value); + if (isMobile) setOpen(false); + category.onSuggestionSelect?.(suggestion); } function submitSearch() { - if (!category || !categoryState || disabled || (textMode && !categoryState.draft)) return; - setEditingCategoryId(undefined); - focusPanelNext.current = true; - changeOpen(true); + if (disabled || (textMode && !categoryState.draft)) return; + if (!textMode) { + // Recover focus before the submitted filter form disappears. + const panel = panelRef.current; + if (!hasValidationMessage && panel?.contains(document.activeElement)) panel.focus(); + setKeepFiltersVisible(false); + setOpen(true); + } void search(category, categoryState.draft); } function toggleView() { if (panelView === 'closed') { - setEditingCategoryId(undefined); - changeOpen(true); + setKeepFiltersVisible(hasValidationMessage); + setOpen(true); } else if (panelView === 'results') { - setEditingCategoryId(category?.id); - } else if (hasSearch) { - setEditingCategoryId(undefined); + setKeepFiltersVisible(true); + } else if (canShowResults) { + setKeepFiltersVisible(false); } else { closePanel(); } } function handleKeyDown(event: React.KeyboardEvent) { + if (isMobile) return; if (event.key !== 'Escape' || event.defaultPrevented || !panelVisible) return; const target = event.target as HTMLElement; if (!rootRef.current?.contains(target)) return; @@ -228,6 +258,204 @@ export const CategorySearch = forwardRef( closePanel(); } + const categorySelector = ( + + + + + + {categories.map((item, index) => ( + selectCategory(item)} + > + {item.label} + + ))} + + + ); + + const searchBar = ( +
+ +
+ {textMode ? ( + + 0 ?

{labels.searchError}

: undefined} + /> +
+ ) : ( +
+ {(!isMobile || canShowResults) && ( + + )} + {hasSearch && ( + + )} +
+ )} +
+ {!isMobile && ( + {categorySelector} + )} +
+
+ ); + + const panel = panelVisible && ( +
{ + panelRef.current = node; + if (!isMobile) panelRefs.setFloating(node); + }} + style={isMobile ? undefined : panelStyles} + tabIndex={-1} + aria-label={`${category.label}: ${panelView === 'results' ? labels.results : labels.filter}`} + className={isMobile ? undefined : styles['tedi-category-search__panel']} + > + {panelView === 'filters' ? ( +
{ + event.preventDefault(); + event.stopPropagation(); + submitSearch(); + }} + > +
+ {category.label} + {validationPosition === 'before' && category.validationMessage} + {category.renderFilters?.({ + values: categoryState.draft, + onChange: (values) => changeDraft(category.id, values), + disabled, + size, + })} + {validationPosition === 'after' && category.validationMessage} +
+
+ + + +
+
+ ) : ( + <> + {loading &&

{labels.loading}

} + {hasError &&

{labels.searchError}

} + {!loading && completedSearch && ( +
+ {categoryState.draft !== completedSearch.input &&

{labels.filtersChanged}

} + {count !== undefined && count > 0 &&

{labels.resultCount(count)}

} + {showEmptyState && {labels.noResults}} + {count !== 0 && + category.renderResults?.({ + value: completedSearch.input, + result: completedSearch.result, + })} +
+ )} +
+ +
+ + )} +
+ ); + + const announcement = !textMode && ( + + {panelVisible && !loading && !hasError && completedSearch + ? `${category.label}: ${count !== undefined ? labels.resultCount(count) : labels.results}` + : ''} + + ); + return (
( className={cn(styles['tedi-category-search'], styles[`tedi-category-search--${size}`], className)} style={ { - '--category-search-panel-width': typeof panelWidth === 'number' ? `${panelWidth}px` : panelWidth, + width: searchBarWidth, '--category-search-panel-max-height': typeof maxPanelHeight === 'number' ? `${maxPanelHeight}px` : maxPanelHeight, } as React.CSSProperties } onKeyDown={handleKeyDown} > - {category && categoryState && ( + {isMobile ? ( <> -
- -
- {textMode ? ( - - - - ) : ( -
- - {hasSearch && ( - - )} -
- )} -
- - - - - - - {categories.map((item, index) => ( - selectCategory(item)} - > - {item.label} - - ))} - - - -
-
- {panelVisible && ( -
- {panelView === 'filters' ? ( -
{ - event.preventDefault(); - event.stopPropagation(); - submitSearch(); - }} - > -
- {category.label} - {category.renderFilters?.({ - values: categoryState.draft, - onChange: (values) => changeDraft(category.id, values), - disabled, - size, - })} -
-
- - - -
-
- ) : ( - <> - {loading &&

{labels.loading}

} - {hasError &&

{labels.searchError}

} - {completedSearch && ( -
- {!textMode && categoryState.draft !== completedSearch.input &&

{labels.filtersChanged}

} - {!textMode && count !== undefined &&

{labels.resultCount(count)}

} - {count === 0 ? ( -

{labels.noResults}

- ) : ( - category.renderResults({ - value: completedSearch.input, - result: completedSearch.result, - selectResult, - }) - )} -
+ + + + + +
- - {textMode && hasError && ( - - )} + > +
+ {categorySelector} + {(textMode || hasSearch) && searchBar}
- - )} -
- )} - - {panelVisible && !loading && !hasError && completedSearch - ? `${category.label}: ${count !== undefined ? labels.resultCount(count) : labels.results}` - : ''} - + {panel} + {announcement} +
+ + + + + ) : ( + <> + {searchBar} + {panel} + {announcement} )}
diff --git a/src/community/components/form/category-search/category-search.types.ts b/src/community/components/form/category-search/category-search.types.ts index 895982667..2f7bed71f 100644 --- a/src/community/components/form/category-search/category-search.types.ts +++ b/src/community/components/form/category-search/category-search.types.ts @@ -1,9 +1,12 @@ import type { CSSProperties, ReactNode } from 'react'; +import type { IconSize } from '../../../../tedi/components/base/icon/icon'; +import type { SearchProps, SearchSuggestion } from '../../../../tedi/components/form/search/search'; + export type CategorySearchSize = 'small' | 'default' | 'large'; export interface CategorySearchRequestContext { - /** Aborted by a newer search, an empty query, selection, Clear, category removal, or unmount. */ + /** Aborted by a newer search, an empty query, selection, Clear, or unmount. */ signal: AbortSignal; } @@ -28,10 +31,16 @@ export interface CategorySearchFilterDefinition { label: string; /** Display this category's custom filter form. */ mode: 'category'; - /** Return a fresh value. Called when this category is added and whenever it is cleared. */ + /** Return a fresh value. Called on mount and whenever this category is cleared. */ initialFilters: () => Filters; /** Render fields only; CategorySearch provides the form and its action buttons. */ renderFilters: (context: CategorySearchFilterContext) => ReactNode; + /** + * Application-provided validation content, usually a TEDI Alert. + * While supplied, this category shows its filters when its panel is open. + * The application controls message updates and removal; this does not prevent searching. + */ + validationMessage?: ReactNode; /** Search a submitted draft. Do not mutate filters; honor signal when supported. */ onSearch: (filters: Filters, context: CategorySearchRequestContext) => Result | Promise; /** Render the last successful result alongside the filters that produced it. */ @@ -40,28 +49,25 @@ export interface CategorySearchFilterDefinition { getResultCount?: (result: Result) => number; } -export interface CategorySearchTextDefinition { +export interface CategorySearchTextDefinition { /** Stable and unique within one CategorySearch instance. */ id: string; /** Category name shown in the selector and used to name the panel. */ label: string; /** Display the standard text search field. */ mode: 'search'; - /** Initial query restored by clear; defaults to an empty string. */ + /** Initial query, used only on mount. Clearing always leaves an empty string. */ initialQuery?: string; /** Search input placeholder. The hidden input label comes from labels.searchInput. */ placeholder?: string; + /** Show the submit button beside the text input. Defaults to true. */ + showSearchButton?: boolean; /** Search on each non-empty text change and explicit submission; honor signal when supported. */ - onSearch: (query: string, context: CategorySearchRequestContext) => Result | Promise; - /** Render the last successful result alongside the query that produced it. */ - renderResults: (context: { - result: Result; - query: string; - /** Put a result label in the input, close the panel, and cancel any pending lookup. */ - selectResult: (text: string) => void; - }) => ReactNode; - /** Supply this when the result is paginated or is not a plain array. */ - getResultCount?: (result: Result) => number; + onSearch: (query: string, context: CategorySearchRequestContext) => SearchSuggestion[] | Promise; + /** Called after saving the selected string label, or value when the label is not a string. */ + onSuggestionSelect?: SearchProps['onSuggestionSelect']; + /** Customize suggestion row content using TEDI Search's renderer. */ + renderSuggestionContent?: SearchProps['renderSuggestionContent']; } /** @@ -77,14 +83,22 @@ export interface CategorySearchCategory { mode: 'search' | 'category'; /** Optional placeholder for a standard search field. */ placeholder?: string; + /** Show the submit button beside the text input. Defaults to true. */ + showSearchButton?: boolean; + /** @internal Handle selection from a text category. */ + onSuggestionSelect?: SearchProps['onSuggestionSelect']; + /** @internal Customize text suggestion rows. */ + renderSuggestionContent?: SearchProps['renderSuggestionContent']; /** @internal Create an initial or reset draft. */ initialValue: () => unknown; /** @internal Render the normalized category filter fields. */ renderFilters?: (context: CategorySearchFilterContext) => ReactNode; + /** @internal Current application-provided validation content for a filter category. */ + validationMessage?: ReactNode; /** @internal Execute a search with the normalized submitted draft. */ - onSearch: (value: unknown, context: CategorySearchRequestContext) => unknown | Promise; + onSearch: (value: unknown, context: CategorySearchRequestContext) => unknown; /** @internal Render completed results and their normalized submitted draft. */ - renderResults: (context: { result: unknown; value: unknown; selectResult: (text: string) => void }) => ReactNode; + renderResults?: (context: { result: unknown; value: unknown }) => ReactNode; /** @internal Return the number of matching results for the summary. */ getResultCount?: (result: unknown) => number; } @@ -94,7 +108,7 @@ export interface CategorySearchLabels { category: string; /** Search submission button label. */ search: string; - /** Hidden label for the standard text search input. */ + /** Hidden label for the standard text search input and title of the mobile modal. */ searchInput: string; /** Clear the current category action label. */ clear: string; @@ -112,7 +126,7 @@ export interface CategorySearchLabels { results: string; /** Status while a search is running. */ loading: string; - /** Message after a successful search with zero results. */ + /** Empty-state message after a successful filter search with zero results. */ noResults: string; /** Generic visible message after a failed search. */ searchError: string; @@ -128,35 +142,45 @@ export interface CategorySearchLabels { export interface CategorySearchProps { /** Optional stable DOM ID prefix; an ID is generated when omitted. */ id?: string; - /** Definitions with unique, stable IDs. Create these with defineCategorySearchCategory. */ + /** + * At least one predefined category with unique, stable IDs. Create these with defineCategorySearchCategory. + * Keep the same IDs while mounted; definitions and callbacks may be updated. + */ categories: readonly CategorySearchCategory[]; - /** Controlled selected category ID; use with onCategoryChange. */ - categoryId?: string; - /** Initially selected ID. Defaults to the first category. */ - defaultCategoryId?: string; - /** Called when the user selects another category. */ - onCategoryChange?: (categoryId: string) => void; - /** Controlled panel visibility; use with onOpenChange. */ - open?: boolean; /** - * Initial non-modal panel visibility. Defaults to false. - * A text search also requires a non-empty input and a pending search, completed search, or error. + * Use the full-screen mobile modal (true) or desktop panel (false). + * When omitted, follows TEDI's below-md breakpoint. Changing presentation preserves state. */ + mobile?: boolean; + /** Initially selected ID, used only on mount. Defaults to the first category. */ + defaultCategoryId?: string; + /** Initial filter-panel or mobile-modal visibility, used only on mount. Defaults to false. */ defaultOpen?: boolean; - /** Called when an interaction requests a visibility change. */ - onOpenChange?: (open: boolean) => void; + /** + * Application-owned minimize counter. The initial value is ignored. + * Later changes hide the filter panel or mobile modal without clearing saved state or restoring focus. + * Does not control TEDI Search's autocomplete popup. + */ + minimizeCounter?: number; /** Disable category selection, filter fields, and submission. Defaults to false. */ disabled?: boolean; /** Size of the search field and buttons; also provided to renderFilters. Defaults to 'default'. */ size?: CategorySearchSize; + /** Search-button icon size. Defaults to TEDI's 18 for small/default buttons and 24 for large buttons. */ + searchButtonIconSize?: IconSize; + /** Place validation content before or after the filter fields. Defaults to 'before'. */ + validationPosition?: 'before' | 'after'; /** Override visible and accessible labels for localization. */ labels?: Partial; + /** Width of the whole search bar, including the category selector. Defaults to '100%' and fits within its parent. */ + searchBarWidth?: CSSProperties['width']; /** - * Text-results panel width. May exceed the search bar; constrained to the available viewport space. - * Defaults to '100%' of the whole search bar. Filter panels always match the bar excluding its category selector. + * Match desktop filter forms and their results to the full bar, including the category selector. + * Defaults to false: panels match the bar excluding the selector. + * Does not affect text-search results or the mobile modal. */ - panelWidth?: CSSProperties['width']; - /** Maximum panel height; excess content scrolls. Defaults to 'min(32rem, 70dvh)'. */ + fullWidthFilterPanel?: boolean; + /** Maximum desktop filter-panel height. Defaults to 'min(32rem, 70dvh)'. Does not size autocomplete or the mobile modal. */ maxPanelHeight?: CSSProperties['maxHeight']; /** Additional CSS class applied to the outer component element. */ className?: string; diff --git a/src/community/components/form/category-search/define-category-search-category.ts b/src/community/components/form/category-search/define-category-search-category.ts index 356073de0..995dbd70c 100644 --- a/src/community/components/form/category-search/define-category-search-category.ts +++ b/src/community/components/form/category-search/define-category-search-category.ts @@ -11,17 +11,13 @@ import type { export function defineCategorySearchCategory( definition: CategorySearchFilterDefinition ): CategorySearchCategory; -export function defineCategorySearchCategory( - definition: CategorySearchTextDefinition -): CategorySearchCategory; +export function defineCategorySearchCategory(definition: CategorySearchTextDefinition): CategorySearchCategory; export function defineCategorySearchCategory( - definition: CategorySearchFilterDefinition | CategorySearchTextDefinition + definition: CategorySearchFilterDefinition | CategorySearchTextDefinition ): CategorySearchCategory { - const getResultCount = definition.getResultCount; const common = { id: definition.id, label: definition.label, - getResultCount: getResultCount ? (result: unknown) => getResultCount(result as Result) : undefined, }; if (definition.mode === 'search') { @@ -29,21 +25,25 @@ export function defineCategorySearchCategory( ...common, mode: 'search', placeholder: definition.placeholder, + showSearchButton: definition.showSearchButton, initialValue: () => definition.initialQuery ?? '', onSearch: (value, context) => definition.onSearch(value as string, context), - renderResults: ({ result, value, selectResult }) => - definition.renderResults({ result: result as Result, query: value as string, selectResult }), + onSuggestionSelect: definition.onSuggestionSelect, + renderSuggestionContent: definition.renderSuggestionContent, }; } + const getResultCount = definition.getResultCount; return { ...common, mode: 'category', initialValue: definition.initialFilters, + validationMessage: definition.validationMessage, renderFilters: ({ values, onChange, disabled, size }) => definition.renderFilters({ values: values as Filters, onChange, disabled, size }), onSearch: (value, context) => definition.onSearch(value as Filters, context), renderResults: ({ result, value }) => definition.renderResults({ result: result as Result, filters: value as Filters }), + getResultCount: getResultCount ? (result: unknown) => getResultCount(result as Result) : undefined, }; } diff --git a/src/community/components/form/category-search/use-category-search.ts b/src/community/components/form/category-search/use-category-search.ts index e6927fb82..33d560d34 100644 --- a/src/community/components/form/category-search/use-category-search.ts +++ b/src/community/components/form/category-search/use-category-search.ts @@ -9,9 +9,13 @@ interface CategorySearchState { } export function useCategorySearch(categories: readonly CategorySearchCategory[]) { - const [states, setStates] = useState>( - () => new Map(categories.map((category) => [category.id, { draft: category.initialValue(), status: 'idle' }])) - ); + const [states, setStates] = useState>(() => { + const initialStates = new Map( + categories.map((category) => [category.id, { draft: category.initialValue(), status: 'idle' }]) + ); + if (initialStates.size !== categories.length) throw new Error('CategorySearch category IDs must be unique.'); + return initialStates; + }); const requests = useRef(new Map()); useEffect(() => { @@ -22,41 +26,9 @@ export function useCategorySearch(categories: readonly CategorySearchCategory[]) }; }, []); - // Keeping an ID preserves its input and results; removing it also invalidates its request. - useEffect(() => { - const ids = new Set(categories.map((category) => category.id)); - if (ids.size !== categories.length) throw new Error('CategorySearch category IDs must be unique.'); - - requests.current.forEach((controller, categoryId) => { - if (!ids.has(categoryId)) { - controller.abort(); - requests.current.delete(categoryId); - } - }); - - setStates((previous) => { - const next = new Map(previous); - let changed = false; - for (const category of categories) { - if (!next.has(category.id)) { - next.set(category.id, { draft: category.initialValue(), status: 'idle' }); - changed = true; - } - } - for (const categoryId of previous.keys()) { - if (!ids.has(categoryId)) { - next.delete(categoryId); - changed = true; - } - } - return changed ? next : previous; - }); - }, [categories]); - function updateState(categoryId: string, changes: Partial) { setStates((previous) => { - const current = previous.get(categoryId); - if (!current) return previous; + const current = previous.get(categoryId)!; const next = new Map(previous); next.set(categoryId, { ...current, ...changes }); return next; @@ -70,12 +42,20 @@ export function useCategorySearch(categories: readonly CategorySearchCategory[]) function cancel(categoryId: string) { requests.current.get(categoryId)?.abort(); requests.current.delete(categoryId); - updateState(categoryId, { status: 'idle' }); } function clear(category: CategorySearchCategory) { cancel(category.id); - updateState(category.id, { draft: category.initialValue(), completedSearch: undefined, status: 'idle' }); + updateState(category.id, { + draft: category.mode === 'search' ? '' : category.initialValue(), + completedSearch: undefined, + status: 'idle', + }); + } + + function select(categoryId: string, text: string) { + cancel(categoryId); + updateState(categoryId, { draft: text, status: 'idle' }); } async function search(category: CategorySearchCategory, input: unknown) { @@ -99,5 +79,5 @@ export function useCategorySearch(categories: readonly CategorySearchCategory[]) } } - return { states, changeDraft, search, clear, cancel }; + return { states, changeDraft, search, clear, select }; }