From a12b4c8b4754ecb5fef74d66cc9ba937a85377ec Mon Sep 17 00:00:00 2001 From: Kristjan Prints Date: Fri, 2 Oct 2026 13:53:22 +0300 Subject: [PATCH 1/5] feat(category-search): Improve Search Behaviour And Simplify API #955 Add empty-state and validation feedback, remembered-result reopening, optional search buttons and minimizeCounter support. BREAKING CHANGE: Remove open, categoryId, onOpenChange and onCategoryChange. Categories can no longer be added or removed after mounting. --- .../category-search.module.scss | 31 +- .../category-search/category-search.spec.tsx | 1163 +++++++++++++---- .../category-search.stories.module.scss | 16 - .../category-search.stories.tsx | 723 +++++----- .../form/category-search/category-search.tsx | 605 +++++---- .../category-search/category-search.types.ts | 70 +- .../define-category-search-category.ts | 2 + .../category-search/use-category-search.ts | 58 +- 8 files changed, 1700 insertions(+), 968 deletions(-) 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..1b1f7bfc2 100644 --- a/src/community/components/form/category-search/category-search.module.scss +++ b/src/community/components/form/category-search/category-search.module.scss @@ -4,6 +4,20 @@ position: relative; width: 100%; min-width: 0; + max-width: 100%; + + &__mobile-body { + display: grid; + gap: var(--layout-grid-gutters-08); + + .tedi-category-search__suffix { + min-width: 0; + } + + .tedi-category-search__actions { + border-radius: var(--form-field-radius); + } + } &__bar { position: relative; @@ -29,8 +43,6 @@ } &__suffix { - max-width: 45%; - > button { display: flex; gap: var(--tedi-dimensions-01); @@ -38,13 +50,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 +122,7 @@ position: absolute; z-index: var(--z-index-dropdown); box-sizing: border-box; - width: var(--category-search-panel-width); + width: var(--category-search-text-results-panel-width); max-height: var(--category-search-panel-max-height); padding: var(--layout-grid-gutters-08); margin-block-start: var(--tedi-dimensions-01); 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..a438579b7 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,19 @@ -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 { 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 +26,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]) => +const createTextCategory = ( + onSearch: CategorySearchTextDefinition['onSearch'] = (query) => [query], + showSearchButton?: boolean +) => defineCategorySearchCategory({ id: 'addresses', label: 'Addresses', mode: 'search', + showSearchButton, onSearch, renderResults: ({ result, query, selectResult }) => ( <> @@ -88,6 +98,244 @@ 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 === 'desktop') 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('orders the mobile controls and reopens saved results after selection or closing', async () => { + const user = userEvent.setup(); + const onSearch = jest.fn(() => ['Aasa 1', 'Aasa 2']); + render(); + await user.click(screen.getByRole('searchbox', { name: 'Search' })); + + const dialog = await screen.findByRole('dialog', { name: 'Search' }); + const input = within(dialog).getByRole('searchbox', { 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'); + const results = await within(dialog).findByRole('list', { name: 'Address matches' }); + expect(Boolean(input.compareDocumentPosition(results) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + await user.click(within(dialog).getByRole('button', { name: 'Aasa 1' })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Search' })).toHaveValue('Aasa 1'); + expect(screen.getByRole('searchbox', { name: 'Search' })).toHaveFocus(); + await user.click(screen.getByRole('searchbox', { name: 'Search' })); + const reopened = await screen.findByRole('dialog', { name: 'Search' }); + expect(within(reopened).getByRole('searchbox', { name: 'Search' })).toHaveValue('Aasa 1'); + expect(within(reopened).getByRole('button', { name: 'Aasa 1' })).toBeVisible(); + expect(within(reopened).getByRole('button', { 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(); + await user.click(screen.getByRole('searchbox', { name: 'Search' })); + await screen.findByRole('dialog', { name: 'Search' }); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it.each(['Clear', 'deleting the text'] as const)( + 'keeps Modal open after %s without restoring cleared results', + async (clearWith) => { + const user = userEvent.setup(); + const pending = deferred(); + const onSearch = jest.fn().mockResolvedValueOnce(['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('searchbox', { name: 'Search' }); + await user.type(input, 'A'); + await within(dialog).findByRole('button', { 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(['Cancelled address']); + }); + expect(within(dialog).queryByRole('list')).not.toBeInTheDocument(); + await user.click(within(dialog).getAllByRole('button', { name: 'Close' })[0]); + await user.click(screen.getByRole('searchbox', { name: 'Search' })); + expect(within(await screen.findByRole('dialog', { name: 'Search' })).queryByRole('list')).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(); + }); + it('opens a labelled, non-modal filter region from the initially closed search bar', async () => { const user = userEvent.setup(); render(); @@ -130,6 +378,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 +463,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 +825,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 +862,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 +895,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'); @@ -350,6 +915,7 @@ describe('CategorySearch', () => { expect(await screen.findByText('Parcels: Aasa')).toBeVisible(); await user.click(screen.getByRole('button', { name: 'Clear filter' })); + 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 +923,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 +1013,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 +1107,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 +1146,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' })); @@ -551,44 +1168,15 @@ describe('CategorySearch', () => { await selectCategory(user, 'Notices'); await user.type(screen.getByRole('textbox', { name: 'Notices owner' }), 'Tamm'); await act(async () => { - pending.resolve(['Found parcel']); - }); - expect(screen.getByRole('region', { name: 'Notices: Filter' })).toBeVisible(); - expect(screen.getByRole('textbox', { name: 'Notices owner' })).toHaveValue('Tamm'); - expect(screen.getByRole('textbox', { name: 'Notices owner' })).toHaveFocus(); - expect(screen.queryByText('Found parcel')).not.toBeInTheDocument(); - await selectCategory(user, 'Parcels'); - expect(screen.getByText('Found parcel')).toBeVisible(); - expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); - }); - - it('discards a removed category request when the same category ID is added again', 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'); - await user.click(screen.getByRole('button', { name: 'Search' })); - - await act(async () => { - older.resolve(['Removed parcel']); - }); - expect(screen.getByText('Searching…')).toBeVisible(); - expect(screen.queryByText('Removed parcel')).not.toBeInTheDocument(); - await act(async () => { - newer.resolve(['Current parcel']); + pending.resolve(['Found parcel']); }); - expect(await screen.findByText('Current parcel')).toBeVisible(); - expect(screen.getByText('Submitted Parcels: Re-added')).toBeVisible(); + expect(screen.getByRole('region', { name: 'Notices: Filter' })).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Notices owner' })).toHaveValue('Tamm'); + expect(screen.getByRole('textbox', { name: 'Notices owner' })).toHaveFocus(); + expect(screen.queryByText('Found parcel')).not.toBeInTheDocument(); + await selectCategory(user, 'Parcels'); + expect(screen.getByText('Found parcel')).toBeVisible(); + expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); }); it.each(['category', 'search'] as const)( @@ -650,43 +1238,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 +1271,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,49 +1279,10 @@ 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({ id: 'cities', @@ -795,7 +1299,8 @@ describe('CategorySearch', () => { await user.type(input, 'Tallinn{Enter}'); expect(await screen.findByText('Tallinn')).toBeVisible(); await user.click(screen.getByRole('button', { name: /^clear$/i })); - expect(input).toHaveValue('Tartu'); + expect(input).toHaveValue(''); + expect(input).toHaveFocus(); expect(screen.queryByRole('region')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Show results/ })).not.toBeInTheDocument(); }); @@ -836,136 +1341,132 @@ describe('CategorySearch', () => { expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); }); - it.each(['Enter', 'search button'] as const)( - 'explicitly submits the latest text query with %s and focuses the panel', - async (submitWith) => { - const user = userEvent.setup(); - const onSearch = jest.fn((query: string) => [query]); - render(); - const input = screen.getByRole('searchbox'); - const searchButton = screen.getByRole('button', { name: 'Find addresses' }); - expect(searchButton).toBeInTheDocument(); - await user.type(input, 'Aa'); - expect(onSearch).toHaveBeenCalledTimes(2); - expect(input).toHaveFocus(); - - if (submitWith === 'Enter') await user.keyboard('{Enter}'); - else await user.click(searchButton); - - expect(onSearch).toHaveBeenCalledTimes(3); - expect(onSearch).toHaveBeenLastCalledWith('Aa', { signal: expect.any(AbortSignal) }); - expect(screen.getByRole('region', { name: 'Addresses: Results' })).toHaveFocus(); + it.each([undefined, true, false])('respects showSearchButton=%s for text categories', async (showSearchButton) => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('searchbox'); + 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.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('submits Enter once and keeps filter submission when the text search button is hidden', async () => { + const user = userEvent.setup(); + const onSearch = jest.fn(() => ['Aasa 1', 'Aasa 2']); + const filterSearch = jest.fn(({ owner }: Filters) => [owner]); + render( + + ); + const input = screen.getByRole('searchbox'); + 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 user.clear(input); + 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) }); + }); - 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(); + it.each(['click', 'tap'] as const)('reopens the full saved text results with an input %s', async (interaction) => { + const user = userEvent.setup(); + const onSearch = jest.fn(() => ['Aasa 1', 'Aasa 2']); + render(); + const input = screen.getByRole('searchbox'); + await user.type(input, 'A'); + await user.click(await screen.findByRole('button', { name: 'Aasa 1' })); - await user.type(input, 'N'); + expect(input).toHaveValue('Aasa 1'); + expect(input).toHaveFocus(); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + if (interaction === 'tap') await user.pointer({ keys: '[TouchA]', target: input }); + else await user.click(input); - 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(screen.getByRole('button', { name: 'Aasa 1' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Aasa 2' })).toBeVisible(); + expect(screen.getByText('Submitted query: A')).toBeVisible(); + expect(input).toHaveValue('Aasa 1'); + expect(onSearch).toHaveBeenCalledTimes(1); + }); - it('retains text results while a replacement loads and fails without showing a filter-change notice', async () => { + it('keeps saved text results independent when reopening categories', async () => { const user = userEvent.setup(); - const pending = deferred(); - const onSearch = jest.fn().mockResolvedValueOnce(['Saved address']).mockReturnValueOnce(pending.promise); - render(); + const addressSearch = jest.fn(() => ['Aasa 1', 'Aasa 2']); + const citySearch = jest.fn(() => ['Tallinn', 'Tartu']); + const cities = { ...createTextCategory(citySearch), id: 'cities', label: 'Cities' }; + 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'); + await user.click(await screen.findByRole('button', { name: 'Aasa 1' })); - 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(); - await act(async () => { - pending.reject(new Error('Replacement failed')); - }); + await selectCategory(user, 'Cities'); + expect(input).toHaveValue(''); + await user.click(input); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(citySearch).not.toHaveBeenCalled(); + await user.type(input, 'T'); + await user.click(await screen.findByRole('button', { name: 'Tartu' })); + await user.click(input); + expect(input).toHaveValue('Tartu'); + expect(screen.getByRole('button', { name: 'Tallinn' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Tartu' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Aasa 1' })).not.toBeInTheDocument(); - 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(input).toHaveValue('Ab'); - expect(input).toHaveFocus(); + await selectCategory(user, 'Addresses'); + await user.click(screen.getByRole('button', { name: 'Close' })); + await user.click(input); + expect(input).toHaveValue('Aasa 1'); + expect(screen.getByRole('button', { name: 'Aasa 1' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Aasa 2' })).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Tartu' })).not.toBeInTheDocument(); + expect(addressSearch).toHaveBeenCalledTimes(1); + expect(citySearch).toHaveBeenCalledTimes(1); }); - it.each(['resolve', 'reject'] as const)( - 'selects a retained result, cancels the new query and ignores its late %s', - async (settle) => { + it.each(['Enter', 'search button'] as const)( + 'explicitly submits the latest text query with %s without moving focus', + async (submitWith) => { const user = userEvent.setup(); - const pending = deferred(); - const onSearch = jest.fn().mockResolvedValueOnce(['Saved address']).mockReturnValueOnce(pending.promise); - render(); + const onSearch = jest.fn((query: string) => [query]); + 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.click(screen.getByRole('button', { name: 'Saved address' })); - - expect(onSearch.mock.calls[1][1].signal.aborted).toBe(true); + const searchButton = screen.getByRole('button', { name: 'Find addresses' }); + expect(searchButton).toBeInTheDocument(); + await user.type(input, 'Aa'); 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(); - await selectCategory(user, 'Addresses'); + if (submitWith === 'Enter') await user.keyboard('{Enter}'); + else await user.click(searchButton); - 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); + expect(onSearch).toHaveBeenCalledTimes(3); + expect(onSearch).toHaveBeenLastCalledWith('Aa', { signal: expect.any(AbortSignal) }); + expect(screen.getByRole('region', { name: 'Addresses: Results' })).toBeVisible(); + expect(submitWith === 'Enter' ? input : searchButton).toHaveFocus(); } ); - it('aborts a pending text search and forgets earlier results when the clear button is used', async () => { + 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(); @@ -979,51 +1480,117 @@ describe('CategorySearch', () => { 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.click(screen.getByRole('button', { name: /^clear$/i })); + 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 act(async () => { - pending.resolve(['Cancelled address']); - }); - await selectCategory(user, 'Addresses'); + await user.click(input); expect(screen.queryByRole('region')).not.toBeInTheDocument(); + await user.keyboard('{Enter}'); + await user.click(screen.getByRole('button', { name: 'Search' })); expect(onSearch).toHaveBeenCalledTimes(2); + await selectCategory(user, 'Addresses'); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); await user.type(input, 'N'); + expect(onSearch).toHaveBeenLastCalledWith('N', { signal: expect.any(AbortSignal) }); 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(); + + await act(async () => { + if (settle === 'resolve') pending.resolve(['Cancelled address']); + else pending.reject(new Error('Cancelled failure')); + }); + + expect(within(screen.getByRole('region')).getByRole('status')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Cancelled address' })).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(input).toHaveValue('N'); + expect(input).toHaveFocus(); + + await act(async () => { + next.resolve(['Current address']); + }); + + expect(await screen.findByRole('button', { name: 'Current address' })).toBeVisible(); + expect(screen.getByText('Submitted query: N')).toBeVisible(); }); - it('keeps a text panel closed when a pending live search finishes', async () => { + it('retains text results after a failed replacement and restores input focus when retry disappears', async () => { const user = userEvent.setup(); const pending = deferred(); - const onSearch = jest.fn().mockReturnValue(pending.promise); + const onSearch = jest + .fn() + .mockResolvedValueOnce(['Saved address']) + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(['Recovered address']); render(); const input = screen.getByRole('searchbox'); await user.type(input, 'A'); - await user.keyboard('{Escape}'); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(input).toHaveFocus(); + expect(await screen.findByRole('button', { name: 'Saved address' })).toBeVisible(); + await user.type(input, 'b'); + expect(within(screen.getByRole('region')).getByRole('status')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Saved address' })).not.toBeInTheDocument(); + expect(screen.queryByText('Submitted query: A')).not.toBeInTheDocument(); + expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); await act(async () => { - pending.resolve(['Found address']); + pending.reject(new Error('Replacement failed')); }); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(input).toHaveFocus(); - await selectCategory(user, 'Addresses'); - expect(screen.getByRole('button', { name: 'Found address' })).toBeVisible(); + 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(onSearch).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Filters edited since searching.')).not.toBeInTheDocument(); + expect(input).toHaveValue('Ab'); + expect(input).toHaveFocus(); + + await user.click(within(screen.getByRole('region')).getByRole('button', { name: 'Search' })); + + expect(await screen.findByRole('button', { name: 'Recovered address' })).toBeVisible(); + expect(screen.getByText('Submitted query: Ab')).toBeVisible(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(input).toHaveFocus(); }); + it.each(['Escape', 'minimizeCounter'] as const)( + 'keeps a text panel closed after %s when a pending live search finishes', + async (closeWith) => { + const user = userEvent.setup(); + const pending = deferred(); + const onSearch = jest.fn().mockReturnValue(pending.promise); + const category = createTextCategory(onSearch); + const { rerender } = render(); + const input = screen.getByRole('searchbox'); + await user.type(input, 'A'); + + if (closeWith === 'Escape') await user.keyboard('{Escape}'); + else rerender(); + + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(input).toHaveValue('A'); + expect(input).toHaveFocus(); + expect(onSearch.mock.calls[0][1].signal.aborted).toBe(false); + await act(async () => { + pending.resolve(['Found address']); + }); + + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(input).toHaveFocus(); + await user.click(input); + expect(screen.getByRole('button', { name: 'Found address' })).toBeVisible(); + expect(screen.getByText('Submitted query: A')).toBeVisible(); + expect(onSearch).toHaveBeenCalledTimes(1); + } + ); + it('stores a live text result without switching categories or moving focus', async () => { const user = userEvent.setup(); const pending = deferred(); @@ -1125,14 +1692,35 @@ 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 independent bar and text-results dimensions', 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-text-results-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..cb38dd879 100644 --- a/src/community/components/form/category-search/category-search.stories.tsx +++ b/src/community/components/form/category-search/category-search.stories.tsx @@ -1,146 +1,34 @@ +/// + 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({ 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) - ); - }, - 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) => ( @@ -154,23 +42,33 @@ const addressSearch = defineCategorySearchCategory({ ), }); -export function AddressSearchExample() { +function AddressSearchExample(props: Partial = {}) { + const id = useId(); + const [showSearchButton, setShowSearchButton] = useState(true); + 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 +79,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 +102,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 })} /> ) { + 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)); - }, - + onSearch: (query) => cities.filter((city) => city.toLowerCase().includes(query.trim().toLowerCase())), renderResults: ({ result, selectResult }) => (
{result.map((city) => ( @@ -240,180 +172,235 @@ const citySearch = defineCategorySearchCategory({ ), }); -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. +function ValidationSearchExample(props: Partial = {}) { + return ; +} +// People and cities example end -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. +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(); -### Sizes + return `${imports}\n\n${example}`; +} -\`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. +const componentDescription = ` +A search bar with live text search and category-specific filter forms. Desktop panels keep the page +interactive; mobile uses a full-screen TEDI Modal. -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. +### Category definitions and callbacks -### Remembered values and results +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. -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. +| Mode | Application provides | +| --- | --- | +| \`search\` | \`onSearch(query, context)\`, \`renderResults({ query, result, selectResult })\`, and optional \`initialQuery\`, \`placeholder\` and \`showSearchButton\`. | +| \`category\` | \`initialFilters()\`, \`renderFilters({ values, onChange, disabled, size })\`, \`onSearch(filters, context)\`, and \`renderResults({ filters, result })\`. | -- **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. +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. -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. +\`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, minimizing and category switching let requests finish without +reopening the panel or changing the active category. -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. +Arrays are counted automatically. Use \`getResultCount(result)\` for other result shapes. +A successful search with zero results shows TEDI EmptyState with \`labels.noResults\`; initial, loading +and failed states do not. Other results are rendered by the application. -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. +### Text search and saved results -### Asynchronous searches and focus +Typing searches immediately. Enter and the optional search button submit explicitly. +\`showSearchButton: false\` hides only the text-category submit button. +Call \`selectResult(text)\` from a result row to set the input text and close the panel without another search. +Click or tap the input to reopen its complete saved list, including when the input already has focus. +Returning focus alone does not reopen it. -\`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. +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. -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. +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 replacement search keeps earlier results. -### Result counts and layout +### Application validation -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. +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. -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. +\`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. -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. +### Presentation and controls -### Controlled state and labels +\`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. -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. +\`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. -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. +Desktop text results use \`textResultsPanelWidth\`, constrained to available viewport space. 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 panel height. +\`textResultsPanelWidth\` and \`maxPanelHeight\` accept CSS lengths or pixel numbers; neither changes the full-screen mobile modal. -### Examples +Every later change to \`minimizeCounter\` hides the panel without restoring focus or discarding state. +Its initial and unchanged values do nothing. Pending results and validation leave a minimized panel closed; +the user can reopen normally. -**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. +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 +432,7 @@ function CategorySearchExample({ ))}
- + ); } @@ -456,11 +443,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 +465,41 @@ 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: { + 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)' } }, + }, + validationPosition: { control: false, - description: - 'Controlled category ID. Apply onCategoryChange to update it. An unknown ID falls back to the first category.', - table: { - category: 'Optional props', - subcategory: 'State', - defaultValue: { summary: 'undefined (managed internally)' }, - }, + description: 'Place application-provided validation content before or after filter fields.', + table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: 'before' } }, }, defaultCategoryId: { control: false, - description: 'Initial category ID, used only on mounting. Ignored when categoryId is supplied.', + description: 'Initial category ID, used only on mounting.', table: { category: 'Optional props', subcategory: 'State', defaultValue: { summary: 'First category ID' } }, }, - onCategoryChange: { - 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' }, - }, - }, - open: { - 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)' }, - }, - }, defaultOpen: { control: false, - description: 'Initial panel visibility, used only on mounting. Text search has no panel until its first search.', + description: 'Initial visibility. Desktop text results also need a search.', 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: 'Application-owned counter. Later changes hide the panel without clearing state or restoring focus.', + table: { category: 'Optional props', subcategory: 'State' }, }, disabled: { control: 'boolean', @@ -542,8 +508,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 +516,25 @@ 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%' } }, }, - maxPanelHeight: { + textResultsPanelWidth: { control: 'text', + description: 'Desktop text-results width. Use a CSS length such as 480px in this text control.', + table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: '100%' } }, + }, + fullWidthFilterPanel: { + control: false, description: - 'Maximum panel height. Accepts a CSS length or a number in pixels. Longer content scrolls inside the panel.', + '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 desktop panel height; longer content scrolls inside it.', table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: 'min(32rem, 70dvh)' } }, }, id: { @@ -593,25 +567,37 @@ type Story = StoryObj; export const Default: Story = { name: 'Search', args: { - panelWidth: '48rem', + textResultsPanelWidth: '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.getByRole('searchbox', { name: 'Search' }); + const mobile = launcher.hasAttribute('readonly'); + if (mobile) await userEvent.click(launcher); + const search = mobile ? within(await page.findByRole('dialog', { name: 'Search' })) : canvas; + await userEvent.type(search.getByRole('searchbox', { name: 'Search' }), 'Kase'); + await waitFor(async () => { + await expect(search.getByRole('button', { name: addresses[0] })).toBeVisible(); + await expect(search.getByRole('button', { 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 { Button, 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 find two addresses, or No match for the empty state. Select an address and click the input to reopen the full saved list. Show search button tries both input variants; desktop results may be wider than the bar.', }, }, }, @@ -619,17 +605,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 panel 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..1ef78f628 100644 --- a/src/community/components/form/category-search/category-search.tsx +++ b/src/community/components/form/category-search/category-search.tsx @@ -4,14 +4,40 @@ import React, { forwardRef, useEffect, useId, useImperativeHandle, useRef, useSt 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 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,80 @@ 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. + * Search categories with independent drafts and results in a desktop panel or full-screen mobile modal. + * The desktop panel is non-modal. Closing, switching categories, or changing presentation + * preserves drafts and results; clearing or emptying text resets only the active category. */ export const CategorySearch = forwardRef( ( { categories, id, - categoryId, defaultCategoryId, - onCategoryChange, - open, defaultOpen = false, - onOpenChange, + minimizeCounter, + mobile, disabled = false, size = 'default', + validationPosition = 'before', labels: labelOverrides, - panelWidth = '100%', + searchBarWidth = '100%', + textResultsPanelWidth = '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 fullWidthPanel = !textMode && fullWidthFilterPanel; + const hasValidationMessage = !textMode && Boolean(category.validationMessage); + const canShowResults = hasSearch && !hasValidationMessage; + const searchButton = + category.showSearchButton === false ? undefined : { icon: 'search', 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 (open) { if (textMode) { if (categoryState.draft && hasSearch) panelView = 'results'; - } else if (hasSearch && editingCategoryId !== category.id) { + } else if (canShowResults && !keepFiltersVisible) { panelView = 'results'; } else { panelView = 'filters'; @@ -92,19 +138,20 @@ 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. + // Text results may be wider; filter panels match their reference. if (textMode) { elements.floating.style.width = ''; elements.floating.style.maxWidth = `${Math.max(0, availableWidth)}px`; @@ -121,37 +168,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 +187,70 @@ 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; + setOpen(isMobile || query.length > 0); 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); + if (!textMode || disabled) return; + select(category.id, text); closePanel(); } function submitSearch() { - if (!category || !categoryState || disabled || (textMode && !categoryState.draft)) return; - setEditingCategoryId(undefined); - focusPanelNext.current = true; - changeOpen(true); + if (disabled || (textMode && !categoryState.draft)) return; + // Recover focus before the submitted form or text retry button disappears. + const panel = panelRef.current; + if (!hasValidationMessage && panel?.contains(document.activeElement)) { + if (textMode) focusSearchControl(); + else 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 +261,196 @@ export const CategorySearch = forwardRef( closePanel(); } + const categorySelector = ( + + + + + + {categories.map((item, index) => ( + selectCategory(item)} + > + {item.label} + + ))} + + + ); + + const searchBar = ( +
+ +
+ {textMode ? ( + + { + if (!disabled && completedSearch) setOpen(true); + }, + }} + /> + + ) : ( +
+ + {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 && ( +
+ {!textMode && categoryState.draft !== completedSearch.input &&

{labels.filtersChanged}

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

{labels.resultCount(count)}

} + {showEmptyState && {labels.noResults}} + {count !== 0 && + category.renderResults({ + value: completedSearch.input, + result: completedSearch.result, + selectResult, + })} +
+ )} +
+ + {textMode && hasError && ( + + )} +
+ + )} +
+ ); + + const announcement = ( + + {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-text-results-panel-width': + typeof textResultsPanelWidth === 'number' ? `${textResultsPanelWidth}px` : textResultsPanelWidth, '--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 && ( - - )} -
- - )} -
- )} - - {panelVisible && !loading && !hasError && completedSearch - ? `${category.label}: ${count !== undefined ? labels.resultCount(count) : labels.results}` - : ''} - + > +
{categorySelector}
+ {searchBar} + {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..7c0a3e022 100644 --- a/src/community/components/form/category-search/category-search.types.ts +++ b/src/community/components/form/category-search/category-search.types.ts @@ -3,7 +3,7 @@ import type { CSSProperties, ReactNode } from 'react'; 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 +28,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. */ @@ -47,10 +53,12 @@ export interface CategorySearchTextDefinition { 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. */ @@ -77,12 +85,16 @@ 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 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; /** @internal Return the number of matching results for the summary. */ @@ -94,7 +106,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; @@ -128,35 +140,51 @@ 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. */ + /** + * 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; - /** 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. + * Initial panel or modal visibility, used only on mount. Defaults to false. + * Desktop text results also require a non-empty input and a pending search, completed search, or error. */ 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 panel or modal without clearing saved state or restoring focus. + */ + 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; + /** 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']; + /** + * Desktop text-results panel width. May exceed the search bar; constrained to the available viewport space. + * Defaults to '100%' of the whole search bar. Does not set filter-category panel width. + * The mobile modal fills the viewport width. + */ + textResultsPanelWidth?: 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 panel height. Defaults to 'min(32rem, 70dvh)'. The mobile modal fills the viewport height. */ 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..acac76a08 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 @@ -29,6 +29,7 @@ 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 }) => @@ -40,6 +41,7 @@ export function defineCategorySearchCategory( ...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), 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 }; } From 1e18582686986c594ce7d3bd5d711227fa46c3bb Mon Sep 17 00:00:00 2001 From: Kristjan Prints Date: Tue, 6 Oct 2026 16:57:51 +0300 Subject: [PATCH 2/5] feat(category-search): Improve Search Behaviour And Simplify API #955 Fix --- .../category-search.module.scss | 19 ++++++++----------- .../form/category-search/category-search.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 13 deletions(-) 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 1b1f7bfc2..ad4ca4283 100644 --- a/src/community/components/form/category-search/category-search.module.scss +++ b/src/community/components/form/category-search/category-search.module.scss @@ -1,5 +1,3 @@ -@use '@tedi-design-system/core/bootstrap-utility/breakpoints'; - .tedi-category-search { position: relative; width: 100%; @@ -8,7 +6,7 @@ &__mobile-body { display: grid; - gap: var(--layout-grid-gutters-08); + gap: var(--layout-grid-gutters-16); .tedi-category-search__suffix { min-width: 0; @@ -19,6 +17,13 @@ } } + &__mobile-controls { + display: grid; + gap: var(--layout-grid-gutters-08); + padding-block-end: var(--layout-grid-gutters-16); + border-block-end: 1px solid var(--general-border-primary); + } + &__bar { position: relative; } @@ -165,12 +170,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.tsx b/src/community/components/form/category-search/category-search.tsx index 1ef78f628..a2b0705a0 100644 --- a/src/community/components/form/category-search/category-search.tsx +++ b/src/community/components/form/category-search/category-search.tsx @@ -499,8 +499,10 @@ export const CategorySearch = forwardRef( styles['tedi-category-search__mobile-body'] )} > -
{categorySelector}
- {searchBar} +
+
{categorySelector}
+ {searchBar} +
{panel} {announcement}
From 510d8868a3e3a3804e66a92524798601654e8804 Mon Sep 17 00:00:00 2001 From: Kristjan Prints Date: Wed, 7 Oct 2026 12:19:06 +0300 Subject: [PATCH 3/5] feat(category-search): Improve Search Behaviour And Simplify API #955 Tweak --- .../category-search.module.scss | 18 +++++-- .../category-search/category-search.spec.tsx | 31 +++++++++-- .../form/category-search/category-search.tsx | 52 ++++++++++++------- 3 files changed, 72 insertions(+), 29 deletions(-) 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 ad4ca4283..c17f595d8 100644 --- a/src/community/components/form/category-search/category-search.module.scss +++ b/src/community/components/form/category-search/category-search.module.scss @@ -8,12 +8,14 @@ display: grid; gap: var(--layout-grid-gutters-16); - .tedi-category-search__suffix { - min-width: 0; - } - .tedi-category-search__actions { - border-radius: var(--form-field-radius); + background-color: transparent; + border: 0; + border-radius: 0; + + > button:last-child { + margin-inline-start: auto; + } } } @@ -21,7 +23,13 @@ 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 { 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 a438579b7..9be85e729 100644 --- a/src/community/components/form/category-search/category-search.spec.tsx +++ b/src/community/components/form/category-search/category-search.spec.tsx @@ -119,7 +119,12 @@ describe('CategorySearch', () => { expect(within(panel).getByRole('textbox', { name: 'Parcels owner' })).toHaveValue(''); expect(screen.getByRole('button', { name: 'Search category: Parcels' })).toBeVisible(); - if (presentation === 'desktop') expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + 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(); + } } ); @@ -334,6 +339,15 @@ describe('CategorySearch', () => { 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 () => { @@ -907,15 +921,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' })); - expect(screen.getByRole('button', { name: 'Filter' })).toHaveFocus(); + 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(); diff --git a/src/community/components/form/category-search/category-search.tsx b/src/community/components/form/category-search/category-search.tsx index a2b0705a0..91318e7e7 100644 --- a/src/community/components/form/category-search/category-search.tsx +++ b/src/community/components/form/category-search/category-search.tsx @@ -265,15 +265,24 @@ export const CategorySearch = forwardRef( - @@ -330,20 +339,22 @@ export const CategorySearch = forwardRef( ) : (
- + {(!isMobile || canShowResults) && ( + + )} {hasSearch && ( - - ))} - - - ), }); const selectCategory = async (user: ReturnType, label: string) => { @@ -143,14 +132,17 @@ describe('CategorySearch', () => { expect(onSearch).not.toHaveBeenCalled(); }); - it('orders the mobile controls and reopens saved results after selection or closing', async () => { + it('uses autocomplete below the mobile controls and preserves selection when reopening', async () => { const user = userEvent.setup(); - const onSearch = jest.fn(() => ['Aasa 1', 'Aasa 2']); + 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('searchbox', { 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]; @@ -160,41 +152,40 @@ describe('CategorySearch', () => { expect(input).not.toHaveAttribute('readonly'); expect(Boolean(selector.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); await user.type(input, 'A'); - const results = await within(dialog).findByRole('list', { name: 'Address matches' }); - expect(Boolean(input.compareDocumentPosition(results) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); - await user.click(within(dialog).getByRole('button', { name: 'Aasa 1' })); + await user.click(await screen.findByRole('option', { name: 'Aasa 1' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(screen.getByRole('searchbox', { name: 'Search' })).toHaveValue('Aasa 1'); - expect(screen.getByRole('searchbox', { name: 'Search' })).toHaveFocus(); - await user.click(screen.getByRole('searchbox', { name: 'Search' })); + 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' }); - expect(within(reopened).getByRole('searchbox', { name: 'Search' })).toHaveValue('Aasa 1'); - expect(within(reopened).getByRole('button', { name: 'Aasa 1' })).toBeVisible(); - expect(within(reopened).getByRole('button', { name: 'Aasa 2' })).toBeVisible(); + 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(); - await user.click(screen.getByRole('searchbox', { name: 'Search' })); - await screen.findByRole('dialog', { name: 'Search' }); - await user.keyboard('{Escape}'); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(onSearch).toHaveBeenCalledTimes(1); }); it.each(['Clear', 'deleting the text'] as const)( - 'keeps Modal open after %s without restoring cleared results', + 'keeps Modal open after %s without restoring cleared suggestions', async (clearWith) => { 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); render(); await user.click(screen.getByRole('searchbox', { name: 'Search' })); const dialog = await screen.findByRole('dialog', { name: 'Search' }); - const input = within(dialog).getByRole('searchbox', { name: 'Search' }); + const input = within(dialog).getByRole('combobox', { name: 'Search' }); await user.type(input, 'A'); - await within(dialog).findByRole('button', { name: 'Saved address' }); + 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); @@ -203,12 +194,14 @@ describe('CategorySearch', () => { expect(input).toHaveValue(''); expect(onSearch.mock.calls[1][1].signal.aborted).toBe(true); await act(async () => { - pending.resolve(['Cancelled address']); + pending.resolve([{ value: 'Cancelled address' }]); }); - expect(within(dialog).queryByRole('list')).not.toBeInTheDocument(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); await user.click(within(dialog).getAllByRole('button', { name: 'Close' })[0]); await user.click(screen.getByRole('searchbox', { name: 'Search' })); - expect(within(await screen.findByRole('dialog', { name: 'Search' })).queryByRole('list')).not.toBeInTheDocument(); + await screen.findByRole('dialog', { name: 'Search' }); + await user.click(screen.getByRole('combobox', { name: 'Search' })); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); expect(onSearch).toHaveBeenCalledTimes(2); } ); @@ -1202,47 +1195,23 @@ describe('CategorySearch', () => { expect(screen.getByText('Submitted Parcels: Aasa')).toBeVisible(); }); - 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(); + it('reopens the first pending filter search and its error', async () => { + const user = userEvent.setup(); + const pending = deferred(); + render( pending.promise)]} defaultOpen />); + await user.click(screen.getByRole('button', { name: 'Search' })); + await user.click(screen.getByRole('button', { name: 'Close' })); + await user.click(screen.getByRole('button', { name: 'Show results' })); + 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.'); - } - ); + await act(async () => { + pending.reject(new Error('First search failed')); + }); + 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('aborts outstanding searches when unmounted', async () => { const user = userEvent.setup(); @@ -1307,24 +1276,23 @@ describe('CategorySearch', () => { 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(''); expect(input).toHaveFocus(); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Show results/ })).not.toBeInTheDocument(); }); @@ -1346,28 +1314,25 @@ 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('searchbox'); + const input = screen.getByRole('combobox'); const searchButton = screen.queryByRole('button', { name: 'Search', hidden: true }); if (showSearchButton === false) { @@ -1384,14 +1349,14 @@ describe('CategorySearch', () => { it('submits Enter once and keeps filter submission when the text search button is hidden', async () => { const user = userEvent.setup(); - const onSearch = jest.fn(() => ['Aasa 1', 'Aasa 2']); + const onSearch = jest.fn(() => [{ value: 'Aasa 1' }, { value: 'Aasa 2' }]); const filterSearch = jest.fn(({ owner }: Filters) => [owner]); render( ); - const input = screen.getByRole('searchbox'); + const input = screen.getByRole('combobox'); await user.type(input, 'Aa'); expect(onSearch).toHaveBeenCalledTimes(2); await user.keyboard('{Enter}'); @@ -1406,57 +1371,77 @@ describe('CategorySearch', () => { expect(filterSearch).toHaveBeenCalledWith({ owner: 'Kask' }, { signal: expect.any(AbortSignal) }); }); - it.each(['click', 'tap'] as const)('reopens the full saved text results with an input %s', async (interaction) => { + 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 onSearch = jest.fn(() => ['Aasa 1', 'Aasa 2']); - render(); - const input = screen.getByRole('searchbox'); + 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('button', { name: 'Aasa 1' })); + await user.click(await screen.findByRole('option', { name: 'Aasa 1 (A)' })); - expect(input).toHaveValue('Aasa 1'); + expect(onSuggestionSelect).toHaveBeenCalledWith(suggestion); + expect(renderSuggestionContent).toHaveBeenCalledWith(suggestion, { active: expect.any(Boolean), query: 'A' }); + expect(input).toHaveValue(expectedQuery); expect(input).toHaveFocus(); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - if (interaction === 'tap') await user.pointer({ keys: '[TouchA]', target: input }); - else await user.click(input); - - expect(screen.getByRole('button', { name: 'Aasa 1' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Aasa 2' })).toBeVisible(); - expect(screen.getByText('Submitted query: A')).toBeVisible(); - expect(input).toHaveValue('Aasa 1'); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); expect(onSearch).toHaveBeenCalledTimes(1); }); - it('keeps saved text results independent when reopening categories', async () => { + it('keeps saved queries and suggestions independent between text categories', async () => { const user = userEvent.setup(); - const addressSearch = jest.fn(() => ['Aasa 1', 'Aasa 2']); - const citySearch = jest.fn(() => ['Tallinn', 'Tartu']); + 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(); - const input = screen.getByRole('searchbox'); - await user.type(input, 'A'); - await user.click(await screen.findByRole('button', { name: 'Aasa 1' })); + await user.type(screen.getByRole('combobox'), 'A'); + await user.click(await screen.findByRole('option', { name: 'Aasa 1' })); await selectCategory(user, 'Cities'); - expect(input).toHaveValue(''); - await user.click(input); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); + 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(input, 'T'); - await user.click(await screen.findByRole('button', { name: 'Tartu' })); - await user.click(input); - expect(input).toHaveValue('Tartu'); - expect(screen.getByRole('button', { name: 'Tallinn' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Tartu' })).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Aasa 1' })).not.toBeInTheDocument(); + await user.type(cityInput, 'T'); + await user.click(await screen.findByRole('option', { name: 'Tartu' })); await selectCategory(user, 'Addresses'); - await user.click(screen.getByRole('button', { name: 'Close' })); - await user.click(input); - expect(input).toHaveValue('Aasa 1'); - expect(screen.getByRole('button', { name: 'Aasa 1' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Aasa 2' })).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Tartu' })).not.toBeInTheDocument(); + 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); }); @@ -1465,11 +1450,10 @@ describe('CategorySearch', () => { '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(); @@ -1479,7 +1463,6 @@ describe('CategorySearch', () => { expect(onSearch).toHaveBeenCalledTimes(3); expect(onSearch).toHaveBeenLastCalledWith('Aa', { signal: expect.any(AbortSignal) }); - expect(screen.getByRole('region', { name: 'Addresses: Results' })).toBeVisible(); expect(submitWith === 'Enter' ? input : searchButton).toHaveFocus(); } ); @@ -1491,19 +1474,19 @@ describe('CategorySearch', () => { ['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 pending = deferred(); + const next = deferred(); const onSearch = jest .fn() - .mockResolvedValueOnce(['Saved address']) + .mockResolvedValueOnce([{ value: 'Saved address' }]) .mockReturnValueOnce(pending.promise) .mockReturnValueOnce(next.promise); 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(within(screen.getByRole('search')).getByRole('status')).toHaveTextContent('Searching…'); if (clearWith === 'Clear') await user.click(screen.getByRole('button', { name: /^clear$/i })); else await user.clear(input); @@ -1511,158 +1494,186 @@ describe('CategorySearch', () => { expect(onSearch.mock.calls[1][1].signal.aborted).toBe(true); expect(input).toHaveValue(''); expect(input).toHaveFocus(); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - await user.click(input); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); await user.keyboard('{Enter}'); await user.click(screen.getByRole('button', { name: 'Search' })); expect(onSearch).toHaveBeenCalledTimes(2); - await selectCategory(user, 'Addresses'); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - await user.type(input, 'N'); expect(onSearch).toHaveBeenLastCalledWith('N', { signal: expect.any(AbortSignal) }); - expect(within(screen.getByRole('region')).getByRole('status')).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Saved address' })).not.toBeInTheDocument(); - expect(screen.queryByText('Submitted query: A')).not.toBeInTheDocument(); - + 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(['Cancelled address']); + if (settle === 'resolve') pending.resolve([{ value: 'Cancelled address' }]); else pending.reject(new Error('Cancelled failure')); }); - expect(within(screen.getByRole('region')).getByRole('status')).toBeVisible(); - expect(screen.queryByRole('button', { name: 'Cancelled address' })).not.toBeInTheDocument(); + 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(['Current address']); + next.resolve([{ value: 'Current address' }]); }); - - expect(await screen.findByRole('button', { name: 'Current address' })).toBeVisible(); - expect(screen.getByText('Submitted query: N')).toBeVisible(); + expect(await screen.findByRole('option', { name: 'Current address' })).toBeVisible(); }); - it('retains text results after a failed replacement and restores input focus when retry disappears', 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 pending = deferred(); const onSearch = jest .fn() - .mockResolvedValueOnce(['Saved address']) + .mockResolvedValueOnce([{ value: 'Saved address' }]) .mockReturnValueOnce(pending.promise) - .mockResolvedValueOnce(['Recovered address']); + .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.queryByRole('button', { name: 'Saved address' })).not.toBeInTheDocument(); - expect(screen.queryByText('Submitted query: A')).not.toBeInTheDocument(); - 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(); + await user.type(input, 'c'); - await user.click(within(screen.getByRole('region')).getByRole('button', { name: 'Search' })); - - expect(await screen.findByRole('button', { name: 'Recovered address' })).toBeVisible(); - expect(screen.getByText('Submitted query: Ab')).toBeVisible(); + 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(); }); - it.each(['Escape', 'minimizeCounter'] as const)( - 'keeps a text panel closed after %s when a pending live search finishes', - async (closeWith) => { - const user = userEvent.setup(); - const pending = deferred(); - const onSearch = jest.fn().mockReturnValue(pending.promise); - const category = createTextCategory(onSearch); - const { rerender } = render(); - const input = screen.getByRole('searchbox'); - await user.type(input, 'A'); + 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(); - if (closeWith === 'Escape') await user.keyboard('{Escape}'); - else rerender(); + await user.type(input, 'b'); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(input).toHaveValue('A'); - expect(input).toHaveFocus(); - expect(onSearch.mock.calls[0][1].signal.aborted).toBe(false); - await act(async () => { - pending.resolve(['Found address']); - }); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Address search failed')); + expect(screen.queryByText('search.no-results')).not.toBeInTheDocument(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); - expect(screen.queryByRole('region')).not.toBeInTheDocument(); - expect(input).toHaveFocus(); - await user.click(input); - expect(screen.getByRole('button', { name: 'Found address' })).toBeVisible(); - expect(screen.getByText('Submitted query: A')).toBeVisible(); - expect(onSearch).toHaveBeenCalledTimes(1); - } - ); + it('does not connect minimizeCounter to desktop autocomplete', async () => { + const user = userEvent.setup(); + 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'); + + rerender(); + expect(within(screen.getByRole('search')).getByRole('status')).toHaveTextContent('Searching…'); + expect(input).toHaveFocus(); + expect(onSearch.mock.calls[0][1].signal.aborted).toBe(false); + await act(async () => { + pending.resolve([{ value: 'Found address' }]); + }); + + expect(await screen.findByRole('option', { name: 'Found address' })).toBeVisible(); + expect(input).toHaveValue('A'); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it.each(['resolve', 'reject'] as const)('ignores an outdated autocomplete %s', async (settle) => { + const user = userEvent.setup(); + const older = deferred(); + const newer = deferred(); + const onSearch = jest.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + render(); + await user.type(screen.getByRole('combobox'), 'Aa'); + expect(onSearch.mock.calls[0][1].signal.aborted).toBe(true); + + await act(async () => { + if (settle === 'resolve') older.resolve([{ value: 'Outdated address' }]); + else older.reject(new Error('Outdated failure')); + }); + 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(); @@ -1670,7 +1681,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)( @@ -1697,12 +1708,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'); @@ -1735,7 +1745,7 @@ describe('CategorySearch', () => { expect(onSearch).toHaveBeenCalledTimes(1); }); - it('forwards the root ref and applies independent bar and text-results dimensions', async () => { + it('forwards the root ref and applies search-bar width and filter-panel height', async () => { const ref = createRef(); render( { id="parcel-search" categories={[createCategory()]} searchBarWidth="30rem" - textResultsPanelWidth={420} maxPanelHeight="60vh" className="map-search" defaultOpen @@ -1753,7 +1762,6 @@ describe('CategorySearch', () => { 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-text-results-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.tsx b/src/community/components/form/category-search/category-search.stories.tsx index cb38dd879..1a411b394 100644 --- a/src/community/components/form/category-search/category-search.stories.tsx +++ b/src/community/components/form/category-search/category-search.stories.tsx @@ -23,28 +23,23 @@ const addresses = [ 'Männimetsa põik 42, Päikeseküla, Näidise vald, Tartu maakond, 54321', ]; -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 }) => ( -
- {result.map((address) => ( -
- -
- ))} -
- ), + 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 })); + }, }); function AddressSearchExample(props: Partial = {}) { const id = useId(); const [showSearchButton, setShowSearchButton] = useState(true); + const [extraSmallSearchIcon, setExtraSmallSearchIcon] = useState(false); return (
@@ -55,12 +50,18 @@ function AddressSearchExample(props: Partial = {}) { checked={showSearchButton} onChange={setShowSearchButton} /> +
@@ -153,23 +154,15 @@ function OrganisationFields({ values, onChange, disabled, size }: CategorySearch ); } -const citySearch = defineCategorySearchCategory({ +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 }) => ( -
- {result.map((city) => ( -
- -
- ))} -
- ), + onSearch: (query) => + cities + .filter((city) => city.toLowerCase().includes(query.trim().toLowerCase())) + .map((city) => ({ value: city, label: city })), }); function PeopleAndCitiesSearchExample({ @@ -313,7 +306,7 @@ function exampleSource(name: string, imports: string) { } const componentDescription = ` -A search bar with live text search and category-specific filter forms. Desktop panels keep the page +A search bar with TEDI autocomplete and category-specific filter forms. Desktop panels keep the page interactive; mobile uses a full-screen TEDI Modal. ### Category definitions and callbacks @@ -323,7 +316,7 @@ each category's filters and results typed, even when different types share one a | Mode | Application provides | | --- | --- | -| \`search\` | \`onSearch(query, context)\`, \`renderResults({ query, result, selectResult })\`, and optional \`initialQuery\`, \`placeholder\` and \`showSearchButton\`. | +| \`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 })\`. | Return fields only from \`renderFilters\`: CategorySearch supplies the form and action buttons. @@ -332,20 +325,25 @@ including fresh nested objects when editing them; do not mutate the supplied val \`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, minimizing and category switching let requests finish without -reopening the panel or changing the active category. +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. -Arrays are counted automatically. Use \`getResultCount(result)\` for other result shapes. -A successful search with zero results shows TEDI EmptyState with \`labels.noResults\`; initial, loading -and failed states do not. Other results are rendered by the application. +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. Enter and the optional search button submit explicitly. -\`showSearchButton: false\` hides only the text-category submit button. -Call \`selectResult(text)\` from a result row to set the input text and close the panel without another search. -Click or tap the input to reopen its complete saved list, including when the input already has focus. -Returning focus alone does not reopen it. +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 @@ -355,7 +353,7 @@ 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 replacement search keeps earlier results. +values still leaves the notice until another search. A failed filter search keeps earlier results. ### Application validation @@ -380,14 +378,15 @@ 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. -Desktop text results use \`textResultsPanelWidth\`, constrained to available viewport space. 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 panel height. -\`textResultsPanelWidth\` and \`maxPanelHeight\` accept CSS lengths or pixel numbers; neither changes the full-screen mobile modal. +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 panel without restoring focus or discarding state. -Its initial and unchanged values do nothing. Pending results and validation leave a minimized panel closed; -the user can reopen normally. +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 @@ -474,6 +473,17 @@ const meta = { description: 'Control size, also passed to renderFilters.', table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: 'default' } }, }, + searchButtonIconSize: { + control: false, + description: + '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: 'Layout', + type: { summary: 'IconSize' }, + defaultValue: { summary: '18 (small/default), 24 (large)' }, + }, + }, mobile: { control: 'inline-radio', options: ['Automatic', 'Mobile', 'Desktop'], @@ -493,12 +503,12 @@ const meta = { }, defaultOpen: { control: false, - description: 'Initial visibility. Desktop text results also need a search.', + description: 'Initial visibility of the filter panel or mobile modal.', table: { category: 'Optional props', subcategory: 'State', defaultValue: { summary: 'false' } }, }, minimizeCounter: { control: false, - description: 'Application-owned counter. Later changes hide the panel without clearing state or restoring focus.', + description: 'Later changes hide the filter panel or mobile modal without clearing state or restoring focus.', table: { category: 'Optional props', subcategory: 'State' }, }, disabled: { @@ -521,11 +531,6 @@ const meta = { 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%' } }, }, - textResultsPanelWidth: { - control: 'text', - description: 'Desktop text-results width. Use a CSS length such as 480px in this text control.', - table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: '100%' } }, - }, fullWidthFilterPanel: { control: false, description: @@ -534,7 +539,7 @@ const meta = { }, maxPanelHeight: { control: 'text', - description: 'Maximum desktop panel height; longer content scrolls inside it.', + description: 'Maximum desktop filter-panel height; longer content scrolls inside it.', table: { category: 'Optional props', subcategory: 'Layout', defaultValue: { summary: 'min(32rem, 70dvh)' } }, }, id: { @@ -566,21 +571,17 @@ type Story = StoryObj; export const Default: Story = { name: 'Search', - args: { - textResultsPanelWidth: '48rem', - }, play: async ({ canvasElement, args }) => { if (args.disabled) return; const canvas = within(canvasElement); const page = within(canvasElement.ownerDocument.body); - const launcher = canvas.getByRole('searchbox', { name: 'Search' }); - const mobile = launcher.hasAttribute('readonly'); - if (mobile) await userEvent.click(launcher); - const search = mobile ? within(await page.findByRole('dialog', { name: 'Search' })) : canvas; - await userEvent.type(search.getByRole('searchbox', { name: 'Search' }), 'Kase'); + 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(search.getByRole('button', { name: addresses[0] })).toBeVisible(); - await expect(search.getByRole('button', { name: addresses[1] })).toBeVisible(); + await expect(page.getByRole('option', { name: addresses[0] })).toBeVisible(); + await expect(page.getByRole('option', { name: addresses[1] })).toBeVisible(); }); }, parameters: { @@ -591,13 +592,13 @@ export const Default: Story = { code: exampleSource( 'Address', `import { useId, useState } from 'react'; -import { Button, Toggle } from '@tedi-design-system/react/tedi'; +import { Toggle } from '@tedi-design-system/react/tedi'; import { CategorySearch, defineCategorySearchCategory, type CategorySearchProps } from '@tedi-design-system/react/community';` ), }, description: { story: - 'Type Kase to find two addresses, or No match for the empty state. Select an address and click the input to reopen the full saved list. Show search button tries both input variants; desktop results may be wider than the bar.', + '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.', }, }, }, @@ -644,7 +645,7 @@ import { }, 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 panel while retaining values and results.', + '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.', }, }, }, diff --git a/src/community/components/form/category-search/category-search.tsx b/src/community/components/form/category-search/category-search.tsx index 91318e7e7..07fce137e 100644 --- a/src/community/components/form/category-search/category-search.tsx +++ b/src/community/components/form/category-search/category-search.tsx @@ -1,4 +1,4 @@ -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'; @@ -6,7 +6,7 @@ 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'; @@ -57,9 +57,9 @@ const defaultLabels: CategorySearchLabels = { }; /** - * Search categories with independent drafts and results in a desktop panel or full-screen mobile modal. - * The desktop panel is non-modal. Closing, switching categories, or changing presentation - * preserves drafts and results; clearing or emptying text 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( ( @@ -72,10 +72,10 @@ export const CategorySearch = forwardRef( mobile, disabled = false, size = 'default', + searchButtonIconSize, validationPosition = 'before', labels: labelOverrides, searchBarWidth = '100%', - textResultsPanelWidth = '100%', fullWidthFilterPanel = false, maxPanelHeight = 'min(32rem, 70dvh)', className, @@ -114,11 +114,19 @@ export const CategorySearch = forwardRef( const hasError = categoryState.status === 'error'; const hasSearch = Boolean(completedSearch || loading || hasError); 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: 'search', size, disabled, 'aria-label': labels.search }; + category.showSearchButton === false + ? undefined + : { + icon: searchButtonIconSize === undefined ? 'search' : { name: 'search', size: searchButtonIconSize }, + size, + disabled, + 'aria-label': labels.search, + }; useEffect(() => { if (open && hasValidationMessage && !keepFiltersVisible) { @@ -127,10 +135,8 @@ export const CategorySearch = forwardRef( }, [keepFiltersVisible, hasValidationMessage, open]); let panelView: 'closed' | 'filters' | 'results' = 'closed'; - if (open) { - if (textMode) { - if (categoryState.draft && hasSearch) panelView = 'results'; - } else if (canShowResults && !keepFiltersVisible) { + if (open && !textMode) { + if (canShowResults && !keepFiltersVisible) { panelView = 'results'; } else { panelView = 'filters'; @@ -148,17 +154,9 @@ export const CategorySearch = forwardRef( placement: 'bottom-start', whileElementsMounted: autoUpdate, middleware: [ - textMode && shift(), sizePanel({ - apply({ rects, availableWidth, elements }) { - // Text results may be wider; filter panels match their reference. - 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`; }, }), ], @@ -205,7 +203,6 @@ export const CategorySearch = forwardRef( function changeQuery(query: string) { if (disabled) return; - setOpen(isMobile || query.length > 0); if (!query) { clear(category); @@ -217,22 +214,22 @@ export const CategorySearch = forwardRef( void search(category, query); } - function selectResult(text: string) { - if (!textMode || disabled) return; - select(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 (disabled || (textMode && !categoryState.draft)) return; - // Recover focus before the submitted form or text retry button disappears. - const panel = panelRef.current; - if (!hasValidationMessage && panel?.contains(document.activeElement)) { - if (textMode) focusSearchControl(); - else panel.focus(); + 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); } - setKeepFiltersVisible(false); - setOpen(true); void search(category, categoryState.draft); } @@ -329,12 +326,14 @@ export const CategorySearch = forwardRef( onClear={focusSearchControl} onSearch={submitSearch} button={searchButton} - input={{ - 'aria-controls': panelVisible ? panelId : undefined, - onClick: () => { - if (!disabled && completedSearch) setOpen(true); - }, - }} + suggestions={suggestions} + loading={loading} + loadingText={labels.loading} + noResultsText={hasError ? labels.searchError : undefined} + resultsCountText={labels.resultCount} + onSuggestionSelect={selectSuggestion} + renderSuggestionContent={category.renderSuggestionContent} + footer={hasError && suggestions.length > 0 ?

{labels.searchError}

: undefined} /> ) : ( @@ -429,14 +428,13 @@ export const CategorySearch = forwardRef( {hasError &&

{labels.searchError}

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

{labels.filtersChanged}

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

{labels.resultCount(count)}

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

{labels.filtersChanged}

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

{labels.resultCount(count)}

} {showEmptyState && {labels.noResults}} {count !== 0 && - category.renderResults({ + category.renderResults?.({ value: completedSearch.input, result: completedSearch.result, - selectResult, })}
)} @@ -444,18 +442,13 @@ export const CategorySearch = forwardRef( - {textMode && hasError && ( - - )}
)} ); - const announcement = ( + const announcement = !textMode && ( {panelVisible && !loading && !hasError && completedSearch ? `${category.label}: ${count !== undefined ? labels.resultCount(count) : labels.results}` @@ -471,8 +464,6 @@ export const CategorySearch = forwardRef( style={ { width: searchBarWidth, - '--category-search-text-results-panel-width': - typeof textResultsPanelWidth === 'number' ? `${textResultsPanelWidth}px` : textResultsPanelWidth, '--category-search-panel-max-height': typeof maxPanelHeight === 'number' ? `${maxPanelHeight}px` : maxPanelHeight, } as React.CSSProperties 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 7c0a3e022..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,5 +1,8 @@ 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 { @@ -46,7 +49,7 @@ 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. */ @@ -60,16 +63,11 @@ export interface CategorySearchTextDefinition { /** 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']; } /** @@ -87,6 +85,10 @@ export interface CategorySearchCategory { 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. */ @@ -96,7 +98,7 @@ export interface CategorySearchCategory { /** @internal Execute a search with the normalized submitted draft. */ 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; } @@ -124,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; @@ -152,39 +154,33 @@ export interface CategorySearchProps { mobile?: boolean; /** Initially selected ID, used only on mount. Defaults to the first category. */ defaultCategoryId?: string; - /** - * Initial panel or modal visibility, used only on mount. Defaults to false. - * Desktop text results also require a non-empty input and a pending search, completed search, or error. - */ + /** Initial filter-panel or mobile-modal visibility, used only on mount. Defaults to false. */ defaultOpen?: boolean; /** * Application-owned minimize counter. The initial value is ignored. - * Later changes hide the panel or modal without clearing saved state or restoring focus. + * 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']; - /** - * Desktop text-results panel width. May exceed the search bar; constrained to the available viewport space. - * Defaults to '100%' of the whole search bar. Does not set filter-category panel width. - * The mobile modal fills the viewport width. - */ - textResultsPanelWidth?: CSSProperties['width']; /** * 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. */ fullWidthFilterPanel?: boolean; - /** Maximum desktop panel height. Defaults to 'min(32rem, 70dvh)'. The mobile modal fills the viewport height. */ + /** 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 acac76a08..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') { @@ -32,11 +28,12 @@ export function defineCategorySearchCategory( 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', @@ -47,5 +44,6 @@ export function defineCategorySearchCategory( 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, }; } From c1e29a7cd90bcf489178154b927d3b8fd28dab6f Mon Sep 17 00:00:00 2001 From: Kristjan Prints Date: Tue, 13 Oct 2026 17:57:43 +0300 Subject: [PATCH 5/5] feat(category-search): Improve Search Behaviour And Simplify API #955 --- .../components/form/category-search/category-search.spec.tsx | 1 + 1 file changed, 1 insertion(+) 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 aa38fe3ce..2fe60649f 100644 --- a/src/community/components/form/category-search/category-search.spec.tsx +++ b/src/community/components/form/category-search/category-search.spec.tsx @@ -184,6 +184,7 @@ describe('CategorySearch', () => { 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');