From 2b8c2609402f4c996dac7e60f797fdb39161ce82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Tue, 9 Jun 2026 15:24:40 +0200 Subject: [PATCH 1/5] Implement clickable search results with improved design alignment and localized result count --- .../SearchResults/SearchResults.module.css | 39 ++++++++++++++++++ .../SearchResults/SearchResults.test.tsx | 39 ++++++++++++++++++ .../SearchResults/SearchResults.tsx | 27 ++++++++++++ packages/publicui/locales/en/common.json | 3 +- packages/publicui/locales/it/common.json | 3 +- .../news/+search-results-clickable.feature | 1 + packages/publicui/routes/search.tsx | 41 +++++-------------- 7 files changed, 120 insertions(+), 33 deletions(-) create mode 100644 packages/publicui/components/SearchResults/SearchResults.module.css create mode 100644 packages/publicui/components/SearchResults/SearchResults.test.tsx create mode 100644 packages/publicui/components/SearchResults/SearchResults.tsx create mode 100644 packages/publicui/news/+search-results-clickable.feature diff --git a/packages/publicui/components/SearchResults/SearchResults.module.css b/packages/publicui/components/SearchResults/SearchResults.module.css new file mode 100644 index 000000000..653cb6a5b --- /dev/null +++ b/packages/publicui/components/SearchResults/SearchResults.module.css @@ -0,0 +1,39 @@ +@layer custom { + .count { + padding-bottom: 16px; + border-bottom: 1px solid var(--quanta-silver); + margin-bottom: 8px; + color: var(--quanta-graphite); + font-size: 0.875rem; + } + + .item { + display: flex; + align-items: flex-start; + padding: 16px 0; + gap: 12px; + } + + .icon { + flex-shrink: 0; + color: var(--quanta-graphite); + } + + .headline { + margin: 0 0 4px; + font-size: 1.125rem; + } + + .headline a { + color: var(--quanta-sapphire); + text-decoration: none; + } + + .headline a:hover { + text-decoration: underline; + } + + .description { + margin: 0; + } +} diff --git a/packages/publicui/components/SearchResults/SearchResults.test.tsx b/packages/publicui/components/SearchResults/SearchResults.test.tsx new file mode 100644 index 000000000..8b188331a --- /dev/null +++ b/packages/publicui/components/SearchResults/SearchResults.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { SearchResults } from './SearchResults'; + +const items = [ + { '@id': '/news/a', title: 'Article A', description: 'About A' }, + { '@id': '/news/b', title: 'Article B', description: '' }, +]; + +const renderResults = (data: unknown[]) => + render( + + + , + ); + +describe('SearchResults', () => { + it('links each result to its item', () => { + renderResults(items); + expect(screen.getByRole('link', { name: 'Article A' })).toHaveAttribute( + 'href', + '/news/a', + ); + expect(screen.getByRole('link', { name: 'Article B' })).toHaveAttribute( + 'href', + '/news/b', + ); + }); + + it('shows the description when present', () => { + renderResults(items); + expect(screen.getByText('About A')).toBeInTheDocument(); + }); + + it('renders nothing for an empty result set', () => { + const { container } = renderResults([]); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/publicui/components/SearchResults/SearchResults.tsx b/packages/publicui/components/SearchResults/SearchResults.tsx new file mode 100644 index 000000000..0b8c54c4f --- /dev/null +++ b/packages/publicui/components/SearchResults/SearchResults.tsx @@ -0,0 +1,27 @@ +import { Link } from 'react-router'; +import { flattenToAppURL } from '@plone/helpers'; +import { PageIcon } from '@plone/components/Icons'; +import type { Brain } from '@plone/types'; +import styles from './SearchResults.module.css'; + +export function SearchResults({ items }: { items: Brain[] }) { + return ( + <> + {items.map((item) => ( +
+ + + +
+

+ {item.title} +

+ {item.description && ( +

{item.description}

+ )} +
+
+ ))} + + ); +} diff --git a/packages/publicui/locales/en/common.json b/packages/publicui/locales/en/common.json index 539ec7c25..38196fe1d 100644 --- a/packages/publicui/locales/en/common.json +++ b/packages/publicui/locales/en/common.json @@ -5,7 +5,8 @@ "title": "Search results for", "results": "Search results", "placeholder": "Write here...", - "noResults": "No results found" + "noResults": "No results found", + "count": "{{count}} results" } } } diff --git a/packages/publicui/locales/it/common.json b/packages/publicui/locales/it/common.json index 25a5ad33b..a876a3fc7 100644 --- a/packages/publicui/locales/it/common.json +++ b/packages/publicui/locales/it/common.json @@ -5,7 +5,8 @@ "title":"Risultati della ricerca per", "results": "Risultati della ricerca", "placeholder": "Scrivi qui...", - "noResults": "Nessun risultato" + "noResults": "Nessun risultato", + "count": "{{count}} risultati" } } } diff --git a/packages/publicui/news/+search-results-clickable.feature b/packages/publicui/news/+search-results-clickable.feature new file mode 100644 index 000000000..d2bae0acc --- /dev/null +++ b/packages/publicui/news/+search-results-clickable.feature @@ -0,0 +1 @@ +Made search results clickable and aligned the results list with the design: each result now shows a content icon, a linked title, the description, and a result count. @nils-pzr diff --git a/packages/publicui/routes/search.tsx b/packages/publicui/routes/search.tsx index 472f6edfa..c0152fe64 100644 --- a/packages/publicui/routes/search.tsx +++ b/packages/publicui/routes/search.tsx @@ -8,6 +8,8 @@ import { } from 'react-router'; import { ploneClientContext } from '@plone/aurora/app/middleware.server'; import { Container, Input } from '@plone/components/quanta'; +import { SearchResults } from '../components/SearchResults/SearchResults'; +import styles from '../components/SearchResults/SearchResults.module.css'; export const handle = { bodyClass: 'search-route', @@ -36,6 +38,7 @@ export async function loader({ return { search: results.data.items, + total: results.data.items_total, params: query, }; } catch (error: any) { @@ -51,7 +54,7 @@ export const meta = () => { export default function SearchRoute() { const { t } = useTranslation(); - const { search, params } = useLoaderData(); + const { search, total, params } = useLoaderData(); return ( @@ -69,37 +72,13 @@ export default function SearchRoute() { /> {/* */} - {/* */} - {/* */} {search?.length > 0 ? ( - search.map((item) => ( -
-

- {/* */} - {item.title} - {/* */} -

- {item.description && ( -
- {item.description} -
- )} -
- Read more - {/* - - */} -
-
-
- )) + <> +

+ {t('publicui.search.count', { count: total })} +

+ + ) : (

{t('publicui.search.noResults')}

From 0067f408499d5753f0856a7cdb4cf006f5807d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Wed, 10 Jun 2026 14:46:56 +0200 Subject: [PATCH 2/5] Remove old SearchResults implementation and refactor search route to support sorting, pagination, and tag filtering --- docs/how-to-guides/index.md | 1 + docs/how-to-guides/search-the-site.md | 252 ++++++++++++++++ .../SearchResults/SearchFacets.test.tsx | 114 ++++++++ .../components/SearchResults/SearchFacets.tsx | 103 +++++++ .../SearchResults/SearchPagination.test.tsx | 78 +++++ .../SearchResults/SearchPagination.tsx | 148 ++++++++++ .../SearchResults/SearchResults.module.css | 272 ++++++++++++++++++ .../SearchResults/SearchResults.test.tsx | 81 ++++++ .../SearchResults/SearchResults.tsx | 75 +++++ .../SearchResults/SearchSort.test.tsx | 84 ++++++ .../components/SearchResults/SearchSort.tsx | 60 ++++ packages/layout/config/settings.ts | 18 ++ packages/layout/locales/de/common.json | 16 ++ packages/layout/locales/en/common.json | 16 ++ packages/layout/locales/it/common.json | 16 ++ .../news/+search-content-type-icons.feature | 1 + packages/layout/news/+search-facets.feature | 1 + .../news/+search-filter-collapsible.feature | 1 + packages/layout/news/+search-results.feature | 1 + packages/layout/news/+search-sort.feature | 1 + .../SearchResults/SearchResults.module.css | 39 --- .../SearchResults/SearchResults.test.tsx | 39 --- .../SearchResults/SearchResults.tsx | 27 -- packages/publicui/locales/en/common.json | 3 +- packages/publicui/locales/it/common.json | 5 +- .../news/+search-no-layout-shift.feature | 1 + .../publicui/news/+search-pagination.feature | 1 + .../publicui/news/+search-result-links.bugfix | 1 + .../news/+search-site-settings-a11y.feature | 1 + packages/publicui/routes/search.tsx | 113 +++++--- packages/publicui/styles/publicui.css | 9 + 31 files changed, 1435 insertions(+), 143 deletions(-) create mode 100644 docs/how-to-guides/search-the-site.md create mode 100644 packages/layout/components/SearchResults/SearchFacets.test.tsx create mode 100644 packages/layout/components/SearchResults/SearchFacets.tsx create mode 100644 packages/layout/components/SearchResults/SearchPagination.test.tsx create mode 100644 packages/layout/components/SearchResults/SearchPagination.tsx create mode 100644 packages/layout/components/SearchResults/SearchResults.module.css create mode 100644 packages/layout/components/SearchResults/SearchResults.test.tsx create mode 100644 packages/layout/components/SearchResults/SearchResults.tsx create mode 100644 packages/layout/components/SearchResults/SearchSort.test.tsx create mode 100644 packages/layout/components/SearchResults/SearchSort.tsx create mode 100644 packages/layout/news/+search-content-type-icons.feature create mode 100644 packages/layout/news/+search-facets.feature create mode 100644 packages/layout/news/+search-filter-collapsible.feature create mode 100644 packages/layout/news/+search-results.feature create mode 100644 packages/layout/news/+search-sort.feature delete mode 100644 packages/publicui/components/SearchResults/SearchResults.module.css delete mode 100644 packages/publicui/components/SearchResults/SearchResults.test.tsx delete mode 100644 packages/publicui/components/SearchResults/SearchResults.tsx create mode 100644 packages/publicui/news/+search-no-layout-shift.feature create mode 100644 packages/publicui/news/+search-pagination.feature create mode 100644 packages/publicui/news/+search-result-links.bugfix create mode 100644 packages/publicui/news/+search-site-settings-a11y.feature diff --git a/docs/how-to-guides/index.md b/docs/how-to-guides/index.md index b42b76e4c..74a49517c 100644 --- a/docs/how-to-guides/index.md +++ b/docs/how-to-guides/index.md @@ -15,6 +15,7 @@ This section of the documentation contains how-to guides for developing with Plo :maxdepth: 2 routes +search-the-site register-an-add-on extend-vite-configuration access-registry diff --git a/docs/how-to-guides/search-the-site.md b/docs/how-to-guides/search-the-site.md new file mode 100644 index 000000000..94f636f7f --- /dev/null +++ b/docs/how-to-guides/search-the-site.md @@ -0,0 +1,252 @@ +--- +myst: + html_meta: + "description": "How to use and customize the site search in Plone Aurora" + "property=og:description": "How to use and customize the site search in Plone Aurora" + "property=og:title": "Search the site" + "keywords": "Plone Aurora, frontend, Plone, search, pagination, sorting, facets, tags, content-type icons, accessibility, @search" +--- + +# Search the site + +This guide shows you how the public search route in Plone Aurora works and how to extend it. + +Plone Aurora ships a public search route at {file}`packages/publicui/routes/search.tsx`. +It renders a list of clickable results with a per-content-type icon, a localized result count, sorting, a collapsible tag filter, and pagination. + +The search input itself lives in the site header (see issue #59, `[Aurora Slots] - Header`), so this route reads the search term from the `SearchableText` query parameter. +The route renders on the server: it queries the backend in its `loader`, so results, sorting, filtering, and pagination all work without client-side JavaScript and stay friendly to search engine crawlers. + +The route stays thin. +It delegates rendering to reusable components in `@plone/layout/components/SearchResults/`: `SearchResults`, `SearchSort`, `SearchFacets`, and `SearchPagination`. + +## How the route works + +The `loader` reads the search term from the `SearchableText` query parameter and the requested page from the `b_start` query parameter, then calls the `@search` endpoint through `@plone/client`. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx +const PAGE_SIZE = 25; + +export async function loader({ request, params, context }) { + const cli = context.get(ploneClientContext); + + const path = `/${params['*'] || ''}`; + const url = new URL(request.url); + const query = url.searchParams.get('SearchableText') || ''; + const bStart = Math.max(0, Number(url.searchParams.get('b_start')) || 0); + + const results = await cli.search({ + query: { + SearchableText: query ? `${query}*` : '', + path: { query: path || '/' }, + b_start: bStart, + b_size: PAGE_SIZE, + }, + }); + + return { + search: results.data.items, + total: results.data.items_total, + params: query, + bStart, + bSize: PAGE_SIZE, + }; +} +``` + +The loader appends a trailing `*` to the term so partial words match. +`b_size` limits each request to one page of results, and `b_start` selects the offset of the current page. +The backend returns the matching slice in `items` and the full match count in `items_total`, which is everything the page needs to paginate. + +```{note} +The `path` comes from the catch-all route segment. +So `/search` searches the whole site, and `/some/folder/search` scopes the search to that subtree. +``` + +The real loader also passes `use_site_search_settings: 1`, so the backend honors the site's search settings (for example, content types excluded from search), matching Volto. +It also returns early and renders only the form when there is no `SearchableText`, because an empty term combined with `use_site_search_settings` makes the backend return a bare list instead of a batched result. + +## Render the results + +The `SearchResults` component renders the results. +It lives in `@plone/layout`, so both the CMS UI and the Public UI can reuse and theme it. +Each result shows a content-type icon, a title that links to the item, the description, and the localized effective date. + +```{code-block} tsx +:caption: packages/layout/components/SearchResults/SearchResults.tsx +

+ {item.title} +

+``` + +### Keep result links inside Aurora + +Link the title to `item['@id']` **directly**, not to `flattenToAppURL(item['@id'])`. + +`flattenToAppURL` rewrites backend addresses (for example, `http://backend/Plone/page`) to app-relative ones (`/page`) from `config.settings.apiPath`, but that setting is only reliable on the server. +Flattening inside the component sets a correct `href` in the server-rendered markup. +It still leaves the raw backend address in the data that react-aria navigates with on the client, so a click opens the backend. + +Flatten the items **once, in the `loader`**, on the server, the same way the sitemap route does. +The serialized result data is then already app-relative, so the markup and the client navigation use the same in-app address. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx +import { flattenToAppURL } from '@plone/helpers'; + +return { + search: flattenToAppURL(results.data.items ?? []), + // other return fields +}; +``` + +## Give each content type its own icon + +`getContentIcon` from `@plone/helpers` resolves each result's icon from its `@type` by looking up `config.settings.contentIcons`, and falls back to the page icon. + +```{code-block} tsx +:caption: packages/layout/components/SearchResults/SearchResults.tsx +const Icon = getContentIcon(item['@type']) ?? PageIcon; + +// in the result markup: + +``` + +`@plone/layout` registers the `contentIcons` map in its settings, so both the CMS UI and the Public UI resolve the same icons. +The `@plone/contents` package also registers it, but only installs in the CMS UI. + +```{code-block} ts +:caption: packages/layout/config/settings.ts +import pageSVG from '@plone/components/icons/page.svg?react'; +import folderSVG from '@plone/components/icons/folder.svg?react'; + +config.settings.contentIcons = { + Document: pageSVG, + Folder: folderSVG, + 'News Item': newsSVG, + Event: calendarSVG, + Image: imageSVG, + File: pageSVG, + Link: linkSVG, +}; +``` + +To give a content type its own icon, add an entry to this map. + +## Paginate the results + +The `SearchPagination` component, also in `@plone/layout`, turns `total`, `bStart`, and `bSize` into numbered page links. +It computes the current page and the total page count, shows the first and last page with an ellipsis around the current page, and renders previous and next controls. + +Each page is a `` that updates the `b_start` query parameter and keeps every other parameter in the address, such as `SearchableText`. +The component reads the current parameters with `useSearchParams` and the current path with `useLocation`, so it carries any filters you add later across pages. + +```{code-block} tsx +:caption: packages/layout/components/SearchResults/SearchPagination.tsx +const linkTo = (page: number) => { + const next = new URLSearchParams(searchParams); + if (page <= 0) { + next.delete('b_start'); + } else { + next.set('b_start', String(page * bSize)); + } + const search = next.toString(); + return search ? `${pathname}?${search}` : pathname; +}; +``` + +When the results fit on a single page, the component renders nothing. + +To change how many results appear per page, adjust the `PAGE_SIZE` constant in the route's `loader`. + +## Add or change a sort option + +The `SearchSort` component renders a sort selector. +It stores the choice in a single `sort` query parameter (`relevance`, `date`, or `title`) and resets paging to the first page whenever the order changes. +`relevance` is the default, which the route omits from the address. + +The `loader` translates the parameter into the backend `sort_on` and `sort_order` fields with the `sortToQuery` helper, so the route stays the single source of truth for the query. + +```{code-block} ts +:caption: packages/layout/components/SearchResults/SearchSort.tsx +export function sortToQuery(sort: string | null) { + switch (sort) { + case 'date': + return { sort_on: 'effective', sort_order: 'descending' }; + case 'title': + return { sort_on: 'sortable_title', sort_order: 'ascending' }; + default: + return {}; + } +} +``` + +To add a sort option, extend `SORT_OPTIONS` and `sortToQuery`, then add the matching label under `layout.search.sort` in each locale. + +## Filter by tag + +The `SearchFacets` component renders a checkbox, styled as a pill, per `Subject` (tag) with a result count. +The component stores selected tags as repeated `Subject` query parameters, and toggling one resets paging to the first page. + +The tag list stays **collapsed by default** behind a "Filter by tag" disclosure button with a rotating chevron. +It opens automatically when the address already carries an active `Subject` filter, so a shared, pre-filtered link still shows its active tags. +The component animates the expand and collapse with the `grid-template-rows` technique (from `0fr` to `1fr`) and honors `prefers-reduced-motion`. + +The available tags can't come from the `plone.app.vocabularies.Keywords` vocabulary, because that vocabulary requires authentication and the public search is anonymous. +Instead, the `loader` runs a second, unfiltered `@search` that requests `metadata_fields=Subject`, and `aggregateFacets` counts the `Subject` values of those matches. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx +const subjects = url.searchParams.getAll('Subject'); + +const [results, facetSample] = await Promise.all([ + cli.search({ + query: { + ...baseQuery, + ...(subjects.length > 0 ? { Subject: subjects } : {}), + b_start: bStart, + b_size: PAGE_SIZE, + ...sortToQuery(url.searchParams.get('sort')), + }, + }), + cli.search({ + query: { ...baseQuery, metadata_fields: 'Subject', b_size: FACET_SAMPLE_SIZE }, + }), +]); +``` + +The loader computes the facet sample *without* the active `Subject` filter, so every tag stays visible with its full count even after you select one. +Multiple selected tags match results that carry **any** of them, because `@plone/client` serializes the array as Plone's `Subject:list` query. + +```{important} +The loader caps the facet sample at `FACET_SAMPLE_SIZE` matches per request, so the tag counts reflect the first `FACET_SAMPLE_SIZE` matches rather than every match. +``` + +## Accessibility and the loading state + +The route meets the accessibility requirements from the issue. + +- `SearchResults` renders the results in a named `region` landmark (a `
` with `aria-label`); individual results are `
` elements, which aren't landmarks. +- The result count is a `

` with `role="status"` (a polite live region) and `aria-controls` that points at the results container, so screen readers announce the new count and associate it with the list it describes. +- The tag chips, the "Filter by tag" toggle, and the sort selector all show a visible `:focus-visible` outline. + +While the loader re-runs for a new query, sort order, page, or filter, the route detects the navigation with `useNavigation` and passes `loading` to `SearchResults`. +`SearchResults` then sets `aria-busy` on the region, swaps the count for a "Searching…" status, and dims the list. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx +const navigation = useNavigation(); +const location = useLocation(); +const isLoading = + navigation.state === 'loading' && + navigation.location?.pathname === location.pathname; +``` + +The Public UI also sets `scrollbar-gutter: stable` on `html` in {file}`packages/publicui/styles/publicui.css`. +This keeps the page width steady when expanding the tag filter or changing the result count shows or hides a scroll bar. + +## Customize the query + +The route owns the query, so you can extend the search by adding fields to the `query` object that it passes to `cli.search`. +The `loader` exposes any extra query parameter you add to the address through `url.searchParams`, and `SearchSort`, `SearchFacets`, and `SearchPagination` all keep the other parameters when they navigate. diff --git a/packages/layout/components/SearchResults/SearchFacets.test.tsx b/packages/layout/components/SearchResults/SearchFacets.test.tsx new file mode 100644 index 000000000..20ae184de --- /dev/null +++ b/packages/layout/components/SearchResults/SearchFacets.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router'; +import { SearchFacets, aggregateFacets } from './SearchFacets'; + +function LocationProbe() { + const { search } = useLocation(); + return {search}; +} + +const facets = [ + { subject: 'news', count: 3 }, + { subject: 'sport', count: 2 }, +]; + +const renderFacets = (url = '/search?SearchableText=foo') => + render( + + + + + + + } + /> + + , + ); + +describe('aggregateFacets', () => { + it('counts subjects and orders by count then name', () => { + const result = aggregateFacets([ + { Subject: ['news', 'sport'] }, + { Subject: ['news'] }, + { Subject: ['sport', 'politics'] }, + { Subject: [] }, + ]); + expect(result).toEqual([ + { subject: 'news', count: 2 }, + { subject: 'sport', count: 2 }, + { subject: 'politics', count: 1 }, + ]); + }); + + it('returns an empty list when there are no subjects', () => { + expect(aggregateFacets([{ Subject: [] }])).toEqual([]); + }); +}); + +describe('SearchFacets', () => { + it('renders nothing when there are no facets', () => { + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('is collapsed by default and toggles open/closed with the button', () => { + renderFacets(); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(button); + expect(button).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(button); + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); + + it('is expanded by default when a tag filter is already active', () => { + renderFacets('/search?SearchableText=foo&Subject=news'); + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('renders a checkbox with a count per facet', () => { + renderFacets(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('checkbox', { name: /news/ })).not.toBeChecked(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('reflects the active Subject filters from the URL', () => { + renderFacets('/search?SearchableText=foo&Subject=news'); + expect(screen.getByRole('checkbox', { name: /news/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /sport/ })).not.toBeChecked(); + }); + + it('adds a Subject and resets paging when toggled on', () => { + renderFacets('/search?SearchableText=foo&b_start=25'); + fireEvent.click(screen.getByRole('button')); + + fireEvent.click(screen.getByRole('checkbox', { name: /sport/ })); + + const search = screen.getByTestId('location-search').textContent ?? ''; + expect(search).toContain('Subject=sport'); + expect(search).toContain('SearchableText=foo'); + expect(search).not.toContain('b_start'); + }); + + it('removes a Subject when toggled off but keeps the others', () => { + renderFacets('/search?SearchableText=foo&Subject=news&Subject=sport'); + + fireEvent.click(screen.getByRole('checkbox', { name: /news/ })); + + const search = screen.getByTestId('location-search').textContent ?? ''; + expect(search).not.toContain('Subject=news'); + expect(search).toContain('Subject=sport'); + }); +}); diff --git a/packages/layout/components/SearchResults/SearchFacets.tsx b/packages/layout/components/SearchResults/SearchFacets.tsx new file mode 100644 index 000000000..a23ff46bc --- /dev/null +++ b/packages/layout/components/SearchResults/SearchFacets.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router'; +import { ChevrondownIcon } from '@plone/components/Icons'; +import clsx from 'clsx'; +import type { Brain } from '@plone/types'; +import styles from './SearchResults.module.css'; + +export interface Facet { + subject: string; + count: number; +} + +export function aggregateFacets(items: Pick[]): Facet[] { + const counts = new Map(); + for (const item of items) { + for (const subject of item.Subject ?? []) { + counts.set(subject, (counts.get(subject) ?? 0) + 1); + } + } + return [...counts.entries()] + .map(([subject, count]) => ({ subject, count })) + .sort((a, b) => b.count - a.count || a.subject.localeCompare(b.subject)); +} + +export interface SearchFacetsProps { + facets: Facet[]; +} + +export function SearchFacets({ facets }: SearchFacetsProps) { + const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + + const selected = new Set(searchParams.getAll('Subject')); + // Collapsed by default; open when a tag filter is already active. + const [open, setOpen] = useState(selected.size > 0); + + if (facets.length === 0) { + return null; + } + + const toggle = (subject: string) => { + const updated = new Set(selected); + if (updated.has(subject)) { + updated.delete(subject); + } else { + updated.add(subject); + } + + const next = new URLSearchParams(searchParams); + next.delete('Subject'); + for (const value of updated) { + next.append('Subject', value); + } + next.delete('b_start'); + setSearchParams(next); + }; + + return ( +

+ +
+
+
+ {facets.map(({ subject, count }) => ( + + ))} +
+
+
+
+ ); +} diff --git a/packages/layout/components/SearchResults/SearchPagination.test.tsx b/packages/layout/components/SearchResults/SearchPagination.test.tsx new file mode 100644 index 000000000..fbbc43632 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchPagination.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { SearchPagination } from './SearchPagination'; + +const renderPagination = ( + props: { total: number; bStart: number; bSize: number }, + url = '/search?SearchableText=foo', +) => + render( + + + , + ); + +describe('SearchPagination', () => { + it('renders nothing when there is only one page', () => { + const { container } = renderPagination({ total: 10, bStart: 0, bSize: 25 }); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a page link per page', () => { + renderPagination({ total: 60, bStart: 0, bSize: 25 }); + // 60 results / 25 => 3 pages + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.queryByText('4')).not.toBeInTheDocument(); + }); + + it('marks the current page and preserves the query in links', () => { + renderPagination( + { total: 60, bStart: 25, bSize: 25 }, + '/search?SearchableText=foo&b_start=25', + ); + + // current page (page 2, index 1) is not a link + const current = screen.getByText('2'); + expect(current.closest('a')).toBeNull(); + expect(current).toHaveAttribute('aria-current', 'page'); + + // page 3 link keeps SearchableText and sets b_start, scoped to the route + const thirdPage = screen.getByText('3').closest('a'); + expect(thirdPage).toHaveAttribute( + 'href', + expect.stringContaining('/search?'), + ); + expect(thirdPage).toHaveAttribute( + 'href', + expect.stringContaining('SearchableText=foo'), + ); + expect(thirdPage).toHaveAttribute( + 'href', + expect.stringContaining('b_start=50'), + ); + + // first page link drops b_start + const firstPage = screen.getByText('1').closest('a'); + expect(firstPage?.getAttribute('href')).not.toContain('b_start'); + expect(firstPage).toHaveAttribute( + 'href', + expect.stringContaining('SearchableText=foo'), + ); + }); + + it('disables previous on the first page and next on the last', () => { + const { rerender } = renderPagination({ total: 60, bStart: 0, bSize: 25 }); + expect(screen.getByText('‹').closest('a')).toBeNull(); + expect(screen.getByText('›').closest('a')).not.toBeNull(); + + rerender( + + + , + ); + expect(screen.getByText('‹').closest('a')).not.toBeNull(); + expect(screen.getByText('›').closest('a')).toBeNull(); + }); +}); diff --git a/packages/layout/components/SearchResults/SearchPagination.tsx b/packages/layout/components/SearchResults/SearchPagination.tsx new file mode 100644 index 000000000..475d87be2 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchPagination.tsx @@ -0,0 +1,148 @@ +import { useTranslation } from 'react-i18next'; +import { useLocation, useSearchParams } from 'react-router'; +import { Link } from '@plone/components'; +import styles from './SearchResults.module.css'; + +export interface SearchPaginationProps { + total: number; + bStart: number; + bSize: number; +} + +type PageItem = number | 'ellipsis'; + +function getPageItems(currentPage: number, totalPages: number): PageItem[] { + const pages = new Set([ + 0, + totalPages - 1, + currentPage - 1, + currentPage, + currentPage + 1, + ]); + + const visible = [...pages] + .filter((page) => page >= 0 && page < totalPages) + .sort((a, b) => a - b); + + const items: PageItem[] = []; + let previous: number | undefined; + for (const page of visible) { + if (previous !== undefined && page - previous > 1) { + items.push('ellipsis'); + } + items.push(page); + previous = page; + } + return items; +} + +export function SearchPagination({ + total, + bStart, + bSize, +}: SearchPaginationProps) { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); + + const totalPages = Math.ceil(total / bSize); + const currentPage = Math.floor(bStart / bSize); + + if (totalPages <= 1) { + return null; + } + + const linkTo = (page: number) => { + const next = new URLSearchParams(searchParams); + if (page <= 0) { + next.delete('b_start'); + } else { + next.set('b_start', String(page * bSize)); + } + const search = next.toString(); + return search ? `${pathname}?${search}` : pathname; + }; + + const items = getPageItems(currentPage, totalPages); + + return ( + + ); +} diff --git a/packages/layout/components/SearchResults/SearchResults.module.css b/packages/layout/components/SearchResults/SearchResults.module.css new file mode 100644 index 000000000..840802439 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchResults.module.css @@ -0,0 +1,272 @@ +@layer custom { + .count { + padding-bottom: 16px; + border-bottom: 1px solid var(--border); + margin-bottom: 8px; + color: var(--muted-foreground); + font-size: 0.875rem; + } + + .facets { + margin: 0 0 12px; + } + + .facetsToggle { + display: inline-flex; + align-items: center; + padding: 0; + border: 0; + background: none; + color: var(--muted-foreground); + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + gap: 6px; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .facetsToggle:focus-visible { + border-radius: 2px; + outline: 2px solid var(--color-quanta-peacock); + outline-offset: 2px; + } + + .facetsChevron { + width: 14px; + height: 14px; + transition: transform 0.2s ease; + } + + .facetsChevronOpen { + transform: rotate(180deg); + } + + .facetListWrap { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; + } + + .facetListWrapOpen { + grid-template-rows: 1fr; + } + + .facetListInner { + overflow: hidden; + } + + .facetList { + display: flex; + flex-wrap: wrap; + padding-top: 10px; + gap: 8px; + } + + @media (prefers-reduced-motion: reduce) { + .facetsChevron, + .facetListWrap { + transition: none; + } + } + + .facet { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--foreground); + cursor: pointer; + font-size: 0.8125rem; + gap: 6px; + line-height: 1.4; + transition: + background-color 0.12s ease, + border-color 0.12s ease, + color 0.12s ease; + user-select: none; + } + + .facet input { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + padding: 0; + border: 0; + margin: -1px; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .facet:hover { + border-color: var(--color-quanta-peacock); + } + + .facet:has(input:checked) { + border-color: var(--color-quanta-peacock); + background-color: var(--color-quanta-peacock); + color: var(--color-quanta-air); + } + + .facet:has(input:focus-visible) { + outline: 2px solid var(--color-quanta-peacock); + outline-offset: 2px; + } + + .facetCount { + color: var(--muted-foreground); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + } + + .facet:has(input:checked) .facetCount { + color: var(--color-quanta-air); + opacity: 0.85; + } + + .sort { + display: flex; + align-items: center; + justify-content: flex-end; + margin-bottom: 8px; + gap: 8px; + } + + .sortLabel { + color: var(--muted-foreground); + font-size: 0.875rem; + } + + .sortSelect { + padding: 4px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background-color: var(--background); + color: var(--foreground); + font-size: 0.875rem; + } + + .sortSelect:focus-visible { + outline: 2px solid var(--color-quanta-peacock); + outline-offset: 2px; + } + + .item { + display: flex; + align-items: flex-start; + padding: 16px 0; + gap: 12px; + } + + .icon { + display: flex; + flex-shrink: 0; + margin-top: 2px; + color: var(--muted-foreground); + } + + .icon svg { + width: 1.5rem; + height: 1.5rem; + } + + .body { + min-width: 0; + } + + .description { + margin: 0; + color: var(--foreground); + } + + .meta { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-top: 6px; + color: var(--muted-foreground); + font-size: 0.75rem; + gap: 8px; + } + + .metaDate { + font-variant-numeric: tabular-nums; + } + + .results[aria-busy='true'] .list { + opacity: 0.5; + transition: opacity 0.15s ease; + } + + .pagination { + display: flex; + align-items: center; + padding-top: 16px; + border-top: 1px solid var(--border); + margin-top: 24px; + gap: 4px; + } + + .pageItem, + .pageNav { + display: inline-flex; + min-width: 32px; + height: 32px; + align-items: center; + justify-content: center; + padding: 0 8px; + border-radius: 4px; + color: var(--color-quanta-sapphire); + font-size: 0.875rem; + text-decoration: none; + } + + .pageItem:hover, + .pageNav:hover { + background-color: var(--muted); + } + + .pageCurrent { + background-color: var(--color-quanta-sapphire); + color: var(--primary-foreground); + font-weight: 600; + } + + .pageCurrent:hover { + background-color: var(--color-quanta-sapphire); + } + + .pageDisabled { + color: var(--muted-foreground); + pointer-events: none; + } + + .ellipsis { + display: inline-flex; + min-width: 32px; + height: 32px; + align-items: center; + justify-content: center; + color: var(--muted-foreground); + } +} + +/* Outside @layer custom on purpose: must beat the unlayered global h2 typography. */ +.headline { + margin: 0 0 4px; + font-size: 1.25rem; + line-height: 1.3; +} + +.headline a { + color: var(--color-quanta-sapphire); + font-size: 1.25rem; + font-weight: 500; + text-decoration: none; +} + +.headline a:hover { + text-decoration: underline; +} diff --git a/packages/layout/components/SearchResults/SearchResults.test.tsx b/packages/layout/components/SearchResults/SearchResults.test.tsx new file mode 100644 index 000000000..3e3f03399 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchResults.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { SearchResults } from './SearchResults'; + +const items = [ + { + '@id': '/news/a', + '@type': 'News Item', + title: 'Article A', + description: 'About A', + review_state: 'published', + effective: '2024-03-15T10:00:00+00:00', + }, + { '@id': '/news/b', title: 'Article B', description: '' }, +]; + +const renderResults = ( + data: unknown[], + { total = data.length, loading = false } = {}, +) => + render( + + + , + ); + +describe('SearchResults', () => { + it('links each result title to its item', () => { + renderResults(items); + expect(screen.getByRole('link', { name: 'Article A' })).toHaveAttribute( + 'href', + '/news/a', + ); + expect(screen.getByRole('link', { name: 'Article B' })).toHaveAttribute( + 'href', + '/news/b', + ); + }); + + it('shows the description when present', () => { + renderResults(items); + expect(screen.getByText('About A')).toBeInTheDocument(); + }); + + it('renders no result articles for an empty result set', () => { + const { container } = renderResults([], { total: 0 }); + expect(container.querySelectorAll('article')).toHaveLength(0); + }); + + it('renders an accessible results region and a status count', () => { + renderResults(items); + const region = screen.getByRole('region', { name: /resultsLabel/i }); + expect(region).toBeInTheDocument(); + const status = screen.getByRole('status'); + expect(status).toHaveAttribute('aria-controls', 'search-result-items'); + }); + + it('shows the formatted effective date as metadata', () => { + renderResults(items); + const first = screen + .getByRole('link', { name: 'Article A' }) + .closest('article') as HTMLElement; + // a localized date is rendered (year present), and not the 1969 placeholder + expect(within(first).getByText(/2024/)).toBeInTheDocument(); + }); + + it('does not render a redundant "read more" link', () => { + const { container } = renderResults(items); + expect(container.querySelector('a[aria-hidden="true"]')).toBeNull(); + }); + + it('marks the region busy and shows the loading label while loading', () => { + renderResults(items, { loading: true }); + expect( + screen.getByRole('region', { name: /resultsLabel/i }), + ).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('status')).toHaveTextContent( + 'layout.search.loading', + ); + }); +}); diff --git a/packages/layout/components/SearchResults/SearchResults.tsx b/packages/layout/components/SearchResults/SearchResults.tsx new file mode 100644 index 000000000..f92f91498 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchResults.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from 'react-i18next'; +import { Link } from '@plone/components'; +import { PageIcon } from '@plone/components/Icons'; +import { getContentIcon } from '@plone/helpers'; +import type { Brain } from '@plone/types'; +import styles from './SearchResults.module.css'; + +export interface SearchResultsProps { + items: Brain[]; + total: number; + loading?: boolean; +} + +function formatDate(value: string | undefined, locale: string) { + if (!value || value.startsWith('1969') || value.startsWith('1970')) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date); +} + +export function SearchResults({ + items, + total, + loading = false, +}: SearchResultsProps) { + const { t, i18n } = useTranslation(); + + return ( +
+

+ {loading + ? t('layout.search.loading') + : t('layout.search.resultCount', { count: total })} +

+
+ {items.map((item) => { + const Icon = getContentIcon(item['@type']) ?? PageIcon; + const date = formatDate(item.effective, i18n.language); + return ( +
+ + + +
+

+ {item.title} +

+ {item.description && ( +

{item.description}

+ )} + {date && ( +
+ {date} +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/layout/components/SearchResults/SearchSort.test.tsx b/packages/layout/components/SearchResults/SearchSort.test.tsx new file mode 100644 index 000000000..a8007b059 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchSort.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router'; +import { SearchSort, sortToQuery } from './SearchSort'; + +function LocationProbe() { + const { search } = useLocation(); + return {search}; +} + +const renderSort = (url = '/search?SearchableText=foo') => + render( + + + + + + + } + /> + + , + ); + +describe('sortToQuery', () => { + it('omits sort_on for relevance / unknown / null', () => { + expect(sortToQuery(null)).toEqual({}); + expect(sortToQuery('relevance')).toEqual({}); + expect(sortToQuery('whatever')).toEqual({}); + }); + + it('maps date to effective descending', () => { + expect(sortToQuery('date')).toEqual({ + sort_on: 'effective', + sort_order: 'descending', + }); + }); + + it('maps title to sortable_title ascending', () => { + expect(sortToQuery('title')).toEqual({ + sort_on: 'sortable_title', + sort_order: 'ascending', + }); + }); +}); + +describe('SearchSort', () => { + it('reflects the current sort from the URL', () => { + renderSort('/search?SearchableText=foo&sort=title'); + expect(screen.getByRole('combobox')).toHaveValue('title'); + }); + + it('defaults to relevance when no sort param is present', () => { + renderSort(); + expect(screen.getByRole('combobox')).toHaveValue('relevance'); + }); + + it('updates the sort param and resets paging on change', () => { + renderSort('/search?SearchableText=foo&b_start=25'); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'date' }, + }); + + const search = screen.getByTestId('location-search').textContent ?? ''; + expect(search).toContain('sort=date'); + expect(search).toContain('SearchableText=foo'); + expect(search).not.toContain('b_start'); + }); + + it('removes the sort param when switching back to relevance', () => { + renderSort('/search?SearchableText=foo&sort=title'); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'relevance' }, + }); + + const search = screen.getByTestId('location-search').textContent ?? ''; + expect(search).not.toContain('sort'); + expect(search).toContain('SearchableText=foo'); + }); +}); diff --git a/packages/layout/components/SearchResults/SearchSort.tsx b/packages/layout/components/SearchResults/SearchSort.tsx new file mode 100644 index 000000000..730142963 --- /dev/null +++ b/packages/layout/components/SearchResults/SearchSort.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router'; +import styles from './SearchResults.module.css'; + +export const SORT_OPTIONS = ['relevance', 'date', 'title'] as const; +export type SortOption = (typeof SORT_OPTIONS)[number]; + +export const DEFAULT_SORT: SortOption = 'relevance'; + +export function sortToQuery(sort: string | null): { + sort_on?: string; + sort_order?: 'ascending' | 'descending'; +} { + switch (sort) { + case 'date': + return { sort_on: 'effective', sort_order: 'descending' }; + case 'title': + return { sort_on: 'sortable_title', sort_order: 'ascending' }; + default: + return {}; + } +} + +export function SearchSort() { + const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + + const current = (searchParams.get('sort') as SortOption) || DEFAULT_SORT; + + const onChange = (event: React.ChangeEvent) => { + const next = new URLSearchParams(searchParams); + if (event.target.value === DEFAULT_SORT) { + next.delete('sort'); + } else { + next.set('sort', event.target.value); + } + next.delete('b_start'); + setSearchParams(next); + }; + + return ( +
+ + +
+ ); +} diff --git a/packages/layout/config/settings.ts b/packages/layout/config/settings.ts index 3641ac6a0..6b6405023 100644 --- a/packages/layout/config/settings.ts +++ b/packages/layout/config/settings.ts @@ -1,6 +1,24 @@ import type { ConfigType } from '@plone/registry'; +import pageSVG from '@plone/components/icons/page.svg?react'; +import folderSVG from '@plone/components/icons/folder.svg?react'; +import newsSVG from '@plone/components/icons/news.svg?react'; +import calendarSVG from '@plone/components/icons/calendar.svg?react'; +import imageSVG from '@plone/components/icons/image.svg?react'; +import linkSVG from '@plone/components/icons/link.svg?react'; + export default function install(config: ConfigType) { config.settings.hideBreadcrumbs = ['Plone Site', 'Subsite', 'LRF']; + // Registered here (not only in @plone/contents) so the public UI has it too. + config.settings.contentIcons = { + Document: pageSVG, + Folder: folderSVG, + 'News Item': newsSVG, + Event: calendarSVG, + Image: imageSVG, + File: pageSVG, + Link: linkSVG, + }; + return config; } diff --git a/packages/layout/locales/de/common.json b/packages/layout/locales/de/common.json index a9282556f..e0b79ac14 100644 --- a/packages/layout/locales/de/common.json +++ b/packages/layout/locales/de/common.json @@ -3,6 +3,22 @@ "languageSwitcher": { "switchTo": "Zu {{ lang }} wechseln" }, + "search": { + "resultCount": "{{count}} Ergebnisse", + "pagination": "Seiten der Suchergebnisse", + "previous": "Vorherige Seite", + "next": "Nächste Seite", + "page": "Seite {{page}}", + "filterByTag": "Nach Schlagwort filtern", + "resultsLabel": "Suchergebnisse", + "loading": "Suche läuft…", + "sort": { + "label": "Sortieren nach", + "relevance": "Relevanz", + "date": "Datum", + "title": "Titel" + } + }, "contenttypes": { "common": { "size": "Größe:" diff --git a/packages/layout/locales/en/common.json b/packages/layout/locales/en/common.json index 015eedda6..4ceb681ea 100644 --- a/packages/layout/locales/en/common.json +++ b/packages/layout/locales/en/common.json @@ -3,6 +3,22 @@ "languageSwitcher": { "switchTo": "Switch to {{ lang }}" }, + "search": { + "resultCount": "{{count}} results", + "pagination": "Search results pages", + "previous": "Previous page", + "next": "Next page", + "page": "Page {{page}}", + "filterByTag": "Filter by tag", + "resultsLabel": "Search results", + "loading": "Searching…", + "sort": { + "label": "Sort by", + "relevance": "Relevance", + "date": "Date", + "title": "Title" + } + }, "contenttypes": { "common": { "size": "Size:" diff --git a/packages/layout/locales/it/common.json b/packages/layout/locales/it/common.json index 6a5bf7a24..c1a6dcc1e 100644 --- a/packages/layout/locales/it/common.json +++ b/packages/layout/locales/it/common.json @@ -3,6 +3,22 @@ "languageSwitcher": { "switchTo": "Passa a {{ lang }}" }, + "search": { + "resultCount": "{{count}} risultati", + "pagination": "Pagine dei risultati di ricerca", + "previous": "Pagina precedente", + "next": "Pagina successiva", + "page": "Pagina {{page}}", + "filterByTag": "Filtra per tag", + "resultsLabel": "Risultati della ricerca", + "loading": "Ricerca in corso…", + "sort": { + "label": "Ordina per", + "relevance": "Rilevanza", + "date": "Data", + "title": "Titolo" + } + }, "contenttypes": { "common": { "size": "Dimensione:" diff --git a/packages/layout/news/+search-content-type-icons.feature b/packages/layout/news/+search-content-type-icons.feature new file mode 100644 index 000000000..c4ed02ecb --- /dev/null +++ b/packages/layout/news/+search-content-type-icons.feature @@ -0,0 +1 @@ +Search results now show a per-content-type icon (resolved via `getContentIcon`/`config.settings.contentIcons`, registered in `@plone/layout` so it is available in the public UI) and the localized effective date of each result. @nils-pzr diff --git a/packages/layout/news/+search-facets.feature b/packages/layout/news/+search-facets.feature new file mode 100644 index 000000000..d9308c216 --- /dev/null +++ b/packages/layout/news/+search-facets.feature @@ -0,0 +1 @@ +Added tag (`Subject`) facets to the search results. Available tags and their counts are aggregated from the matches, and selecting one or more filters the results via repeated `Subject` query parameters while resetting paging. @nils-pzr diff --git a/packages/layout/news/+search-filter-collapsible.feature b/packages/layout/news/+search-filter-collapsible.feature new file mode 100644 index 000000000..8d3525861 --- /dev/null +++ b/packages/layout/news/+search-filter-collapsible.feature @@ -0,0 +1 @@ +The search tag filter is now collapsed by default behind an animated "Filter by tag" disclosure (it auto-opens when a tag filter is active), and the results show a loading state (`aria-busy` + a "Searching…" status) while a new query, sort, page or filter is fetched. @nils-pzr diff --git a/packages/layout/news/+search-results.feature b/packages/layout/news/+search-results.feature new file mode 100644 index 000000000..9abcd2e66 --- /dev/null +++ b/packages/layout/news/+search-results.feature @@ -0,0 +1 @@ +Added shared `SearchResults` and `SearchPagination` components for the public search page. Results show a content icon, a linked title, and the description; pagination renders crawlable previous/next and numbered page links that preserve the active query. Styling uses themeable CSS tokens so it can be re-themed. @nils-pzr diff --git a/packages/layout/news/+search-sort.feature b/packages/layout/news/+search-sort.feature new file mode 100644 index 000000000..de75bf12a --- /dev/null +++ b/packages/layout/news/+search-sort.feature @@ -0,0 +1 @@ +Added a sort selector to the search results (relevance, date, title). The choice is kept in a `sort` query parameter, resets paging to the first page, and is preserved across pagination. @nils-pzr diff --git a/packages/publicui/components/SearchResults/SearchResults.module.css b/packages/publicui/components/SearchResults/SearchResults.module.css deleted file mode 100644 index 653cb6a5b..000000000 --- a/packages/publicui/components/SearchResults/SearchResults.module.css +++ /dev/null @@ -1,39 +0,0 @@ -@layer custom { - .count { - padding-bottom: 16px; - border-bottom: 1px solid var(--quanta-silver); - margin-bottom: 8px; - color: var(--quanta-graphite); - font-size: 0.875rem; - } - - .item { - display: flex; - align-items: flex-start; - padding: 16px 0; - gap: 12px; - } - - .icon { - flex-shrink: 0; - color: var(--quanta-graphite); - } - - .headline { - margin: 0 0 4px; - font-size: 1.125rem; - } - - .headline a { - color: var(--quanta-sapphire); - text-decoration: none; - } - - .headline a:hover { - text-decoration: underline; - } - - .description { - margin: 0; - } -} diff --git a/packages/publicui/components/SearchResults/SearchResults.test.tsx b/packages/publicui/components/SearchResults/SearchResults.test.tsx deleted file mode 100644 index 8b188331a..000000000 --- a/packages/publicui/components/SearchResults/SearchResults.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router'; -import { SearchResults } from './SearchResults'; - -const items = [ - { '@id': '/news/a', title: 'Article A', description: 'About A' }, - { '@id': '/news/b', title: 'Article B', description: '' }, -]; - -const renderResults = (data: unknown[]) => - render( - - - , - ); - -describe('SearchResults', () => { - it('links each result to its item', () => { - renderResults(items); - expect(screen.getByRole('link', { name: 'Article A' })).toHaveAttribute( - 'href', - '/news/a', - ); - expect(screen.getByRole('link', { name: 'Article B' })).toHaveAttribute( - 'href', - '/news/b', - ); - }); - - it('shows the description when present', () => { - renderResults(items); - expect(screen.getByText('About A')).toBeInTheDocument(); - }); - - it('renders nothing for an empty result set', () => { - const { container } = renderResults([]); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/packages/publicui/components/SearchResults/SearchResults.tsx b/packages/publicui/components/SearchResults/SearchResults.tsx deleted file mode 100644 index 0b8c54c4f..000000000 --- a/packages/publicui/components/SearchResults/SearchResults.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Link } from 'react-router'; -import { flattenToAppURL } from '@plone/helpers'; -import { PageIcon } from '@plone/components/Icons'; -import type { Brain } from '@plone/types'; -import styles from './SearchResults.module.css'; - -export function SearchResults({ items }: { items: Brain[] }) { - return ( - <> - {items.map((item) => ( -
- - - -
-

- {item.title} -

- {item.description && ( -

{item.description}

- )} -
-
- ))} - - ); -} diff --git a/packages/publicui/locales/en/common.json b/packages/publicui/locales/en/common.json index 38196fe1d..539ec7c25 100644 --- a/packages/publicui/locales/en/common.json +++ b/packages/publicui/locales/en/common.json @@ -5,8 +5,7 @@ "title": "Search results for", "results": "Search results", "placeholder": "Write here...", - "noResults": "No results found", - "count": "{{count}} results" + "noResults": "No results found" } } } diff --git a/packages/publicui/locales/it/common.json b/packages/publicui/locales/it/common.json index a876a3fc7..2322c5a2a 100644 --- a/packages/publicui/locales/it/common.json +++ b/packages/publicui/locales/it/common.json @@ -2,11 +2,10 @@ "publicui": { "sitemap": "Sitemap", "search": { - "title":"Risultati della ricerca per", + "title": "Risultati della ricerca per", "results": "Risultati della ricerca", "placeholder": "Scrivi qui...", - "noResults": "Nessun risultato", - "count": "{{count}} risultati" + "noResults": "Nessun risultato" } } } diff --git a/packages/publicui/news/+search-no-layout-shift.feature b/packages/publicui/news/+search-no-layout-shift.feature new file mode 100644 index 000000000..51c885671 --- /dev/null +++ b/packages/publicui/news/+search-no-layout-shift.feature @@ -0,0 +1 @@ +The public UI reserves the scrollbar gutter (`scrollbar-gutter: stable`) so the page width no longer shifts when the search result height changes (for example, when expanding the tag filter or changing the result count). @nils-pzr diff --git a/packages/publicui/news/+search-pagination.feature b/packages/publicui/news/+search-pagination.feature new file mode 100644 index 000000000..5cda856fa --- /dev/null +++ b/packages/publicui/news/+search-pagination.feature @@ -0,0 +1 @@ +Added pagination to the search results and styled the search heading in the brand teal. Results are now batched per page, with crawlable previous/next and numbered page links that preserve the active query. @nils-pzr diff --git a/packages/publicui/news/+search-result-links.bugfix b/packages/publicui/news/+search-result-links.bugfix new file mode 100644 index 000000000..3fb782c47 --- /dev/null +++ b/packages/publicui/news/+search-result-links.bugfix @@ -0,0 +1 @@ +Fixed search result links opening the backend instead of navigating within Aurora: backend URLs are now flattened to app-relative ones in the loader (server-side), so clicking a result navigates to the correct Aurora page. @nils-pzr diff --git a/packages/publicui/news/+search-site-settings-a11y.feature b/packages/publicui/news/+search-site-settings-a11y.feature new file mode 100644 index 000000000..837916770 --- /dev/null +++ b/packages/publicui/news/+search-site-settings-a11y.feature @@ -0,0 +1 @@ +The search route now honours the site's search settings (`use_site_search_settings`), matching Volto, and improves accessibility: the form sits in a native `` landmark, the results are a named `region`, and the result count is a `role="status"` live region linked to the results via `aria-controls`. @nils-pzr diff --git a/packages/publicui/routes/search.tsx b/packages/publicui/routes/search.tsx index c0152fe64..e6286ddeb 100644 --- a/packages/publicui/routes/search.tsx +++ b/packages/publicui/routes/search.tsx @@ -1,20 +1,33 @@ import { useTranslation } from 'react-i18next'; import { data, - Form, RouterContextProvider, useLoaderData, + useLocation, + useNavigation, type LoaderFunctionArgs, } from 'react-router'; import { ploneClientContext } from '@plone/aurora/app/middleware.server'; -import { Container, Input } from '@plone/components/quanta'; -import { SearchResults } from '../components/SearchResults/SearchResults'; -import styles from '../components/SearchResults/SearchResults.module.css'; +import { flattenToAppURL } from '@plone/helpers'; +import { Container } from '@plone/components/quanta'; +import { SearchResults } from '@plone/layout/components/SearchResults/SearchResults'; +import { SearchPagination } from '@plone/layout/components/SearchResults/SearchPagination'; +import { + SearchSort, + sortToQuery, +} from '@plone/layout/components/SearchResults/SearchSort'; +import { + SearchFacets, + aggregateFacets, +} from '@plone/layout/components/SearchResults/SearchFacets'; export const handle = { bodyClass: 'search-route', }; +const PAGE_SIZE = 25; +const FACET_SAMPLE_SIZE = 1000; + export async function loader({ request, params, @@ -25,21 +38,58 @@ export async function loader({ const path = `/${params['*'] || ''}`; const url = new URL(request.url); const query = url.searchParams.get('SearchableText') || ''; + const bStart = Math.max(0, Number(url.searchParams.get('b_start')) || 0); + const subjects = url.searchParams.getAll('Subject'); + + // An empty term with use_site_search_settings returns a bare list, not a + // batch, so skip the query entirely. + if (!query) { + return { + search: [], + total: 0, + params: query, + bStart, + bSize: PAGE_SIZE, + facets: [], + }; + } + + const baseQuery = { + SearchableText: `${query}*`, + path: { + query: path || '/', + }, + use_site_search_settings: 1, + }; try { - const results = await cli.search({ - query: { - SearchableText: query ? `${query}*` : '', - path: { - query: path || '/', + const [results, facetSample] = await Promise.all([ + cli.search({ + query: { + ...baseQuery, + ...(subjects.length > 0 ? { Subject: subjects } : {}), + b_start: bStart, + b_size: PAGE_SIZE, + ...sortToQuery(url.searchParams.get('sort')), }, - }, - }); + }), + cli.search({ + query: { + ...baseQuery, + metadata_fields: 'Subject', + b_size: FACET_SAMPLE_SIZE, + }, + }), + ]); return { - search: results.data.items, - total: results.data.items_total, + // Flatten on the server so result links stay in-app, not the backend. + search: flattenToAppURL(results.data.items ?? []), + total: results.data.items_total ?? 0, params: query, + bStart, + bSize: PAGE_SIZE, + facets: aggregateFacets(facetSample.data.items ?? []), }; } catch (error: any) { throw data('Search failed', { @@ -54,7 +104,14 @@ export const meta = () => { export default function SearchRoute() { const { t } = useTranslation(); - const { search, total, params } = useLoaderData(); + const { search, total, params, bStart, bSize, facets } = + useLoaderData(); + + const navigation = useNavigation(); + const location = useLocation(); + const isLoading = + navigation.state === 'loading' && + navigation.location?.pathname === location.pathname; return ( @@ -63,27 +120,17 @@ export default function SearchRoute() { ? `${t('publicui.search.title')} "${params}"` : t('publicui.search.results')} -
- - {/* */} - - {search?.length > 0 ? ( + {/* Search input lives in the site header (issue #59). */} + + {search.length > 0 ? ( <> -

- {t('publicui.search.count', { count: total })} -

- + + + - ) : ( -
-

{t('publicui.search.noResults')}

-
- )} + ) : params ? ( +

{t('publicui.search.noResults')}

+ ) : null}
); } diff --git a/packages/publicui/styles/publicui.css b/packages/publicui/styles/publicui.css index 922d9dbaa..de25165a3 100644 --- a/packages/publicui/styles/publicui.css +++ b/packages/publicui/styles/publicui.css @@ -1,5 +1,14 @@ @layer custom { + /* Reserve the scrollbar gutter so the page width doesn't shift. */ + html { + scrollbar-gutter: stable; + } + .with-toolbar #main { margin-inline-start: var(--plone-toolbar-width); } + + .route-search .documentFirstHeading { + color: var(--color-quanta-peacock); + } } From 3b39e9e4a3c4e4f6f000116f7dc97db466aa0eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Fri, 12 Jun 2026 14:04:36 +0200 Subject: [PATCH 3/5] Remove outdated "Search the site" guide and update search-related documentation and styles --- docs/conceptual-guides/index.md | 1 + docs/how-to-guides/index.md | 2 +- docs/how-to-guides/search-the-site.md | 252 ------------------ .../SearchResults/SearchResults.module.css | 5 + packages/layout/news/+search-facets.feature | 2 +- 5 files changed, 8 insertions(+), 254 deletions(-) delete mode 100644 docs/how-to-guides/search-the-site.md diff --git a/docs/conceptual-guides/index.md b/docs/conceptual-guides/index.md index 1fcd52731..468f922c3 100644 --- a/docs/conceptual-guides/index.md +++ b/docs/conceptual-guides/index.md @@ -24,4 +24,5 @@ add-on-driven-configuration cookieplone-frontend-add-on routing slots +search ``` diff --git a/docs/how-to-guides/index.md b/docs/how-to-guides/index.md index 74a49517c..b0665f471 100644 --- a/docs/how-to-guides/index.md +++ b/docs/how-to-guides/index.md @@ -15,7 +15,7 @@ This section of the documentation contains how-to guides for developing with Plo :maxdepth: 2 routes -search-the-site +customize-the-search register-an-add-on extend-vite-configuration access-registry diff --git a/docs/how-to-guides/search-the-site.md b/docs/how-to-guides/search-the-site.md deleted file mode 100644 index 94f636f7f..000000000 --- a/docs/how-to-guides/search-the-site.md +++ /dev/null @@ -1,252 +0,0 @@ ---- -myst: - html_meta: - "description": "How to use and customize the site search in Plone Aurora" - "property=og:description": "How to use and customize the site search in Plone Aurora" - "property=og:title": "Search the site" - "keywords": "Plone Aurora, frontend, Plone, search, pagination, sorting, facets, tags, content-type icons, accessibility, @search" ---- - -# Search the site - -This guide shows you how the public search route in Plone Aurora works and how to extend it. - -Plone Aurora ships a public search route at {file}`packages/publicui/routes/search.tsx`. -It renders a list of clickable results with a per-content-type icon, a localized result count, sorting, a collapsible tag filter, and pagination. - -The search input itself lives in the site header (see issue #59, `[Aurora Slots] - Header`), so this route reads the search term from the `SearchableText` query parameter. -The route renders on the server: it queries the backend in its `loader`, so results, sorting, filtering, and pagination all work without client-side JavaScript and stay friendly to search engine crawlers. - -The route stays thin. -It delegates rendering to reusable components in `@plone/layout/components/SearchResults/`: `SearchResults`, `SearchSort`, `SearchFacets`, and `SearchPagination`. - -## How the route works - -The `loader` reads the search term from the `SearchableText` query parameter and the requested page from the `b_start` query parameter, then calls the `@search` endpoint through `@plone/client`. - -```{code-block} ts -:caption: packages/publicui/routes/search.tsx -const PAGE_SIZE = 25; - -export async function loader({ request, params, context }) { - const cli = context.get(ploneClientContext); - - const path = `/${params['*'] || ''}`; - const url = new URL(request.url); - const query = url.searchParams.get('SearchableText') || ''; - const bStart = Math.max(0, Number(url.searchParams.get('b_start')) || 0); - - const results = await cli.search({ - query: { - SearchableText: query ? `${query}*` : '', - path: { query: path || '/' }, - b_start: bStart, - b_size: PAGE_SIZE, - }, - }); - - return { - search: results.data.items, - total: results.data.items_total, - params: query, - bStart, - bSize: PAGE_SIZE, - }; -} -``` - -The loader appends a trailing `*` to the term so partial words match. -`b_size` limits each request to one page of results, and `b_start` selects the offset of the current page. -The backend returns the matching slice in `items` and the full match count in `items_total`, which is everything the page needs to paginate. - -```{note} -The `path` comes from the catch-all route segment. -So `/search` searches the whole site, and `/some/folder/search` scopes the search to that subtree. -``` - -The real loader also passes `use_site_search_settings: 1`, so the backend honors the site's search settings (for example, content types excluded from search), matching Volto. -It also returns early and renders only the form when there is no `SearchableText`, because an empty term combined with `use_site_search_settings` makes the backend return a bare list instead of a batched result. - -## Render the results - -The `SearchResults` component renders the results. -It lives in `@plone/layout`, so both the CMS UI and the Public UI can reuse and theme it. -Each result shows a content-type icon, a title that links to the item, the description, and the localized effective date. - -```{code-block} tsx -:caption: packages/layout/components/SearchResults/SearchResults.tsx -

- {item.title} -

-``` - -### Keep result links inside Aurora - -Link the title to `item['@id']` **directly**, not to `flattenToAppURL(item['@id'])`. - -`flattenToAppURL` rewrites backend addresses (for example, `http://backend/Plone/page`) to app-relative ones (`/page`) from `config.settings.apiPath`, but that setting is only reliable on the server. -Flattening inside the component sets a correct `href` in the server-rendered markup. -It still leaves the raw backend address in the data that react-aria navigates with on the client, so a click opens the backend. - -Flatten the items **once, in the `loader`**, on the server, the same way the sitemap route does. -The serialized result data is then already app-relative, so the markup and the client navigation use the same in-app address. - -```{code-block} ts -:caption: packages/publicui/routes/search.tsx -import { flattenToAppURL } from '@plone/helpers'; - -return { - search: flattenToAppURL(results.data.items ?? []), - // other return fields -}; -``` - -## Give each content type its own icon - -`getContentIcon` from `@plone/helpers` resolves each result's icon from its `@type` by looking up `config.settings.contentIcons`, and falls back to the page icon. - -```{code-block} tsx -:caption: packages/layout/components/SearchResults/SearchResults.tsx -const Icon = getContentIcon(item['@type']) ?? PageIcon; - -// in the result markup: - -``` - -`@plone/layout` registers the `contentIcons` map in its settings, so both the CMS UI and the Public UI resolve the same icons. -The `@plone/contents` package also registers it, but only installs in the CMS UI. - -```{code-block} ts -:caption: packages/layout/config/settings.ts -import pageSVG from '@plone/components/icons/page.svg?react'; -import folderSVG from '@plone/components/icons/folder.svg?react'; - -config.settings.contentIcons = { - Document: pageSVG, - Folder: folderSVG, - 'News Item': newsSVG, - Event: calendarSVG, - Image: imageSVG, - File: pageSVG, - Link: linkSVG, -}; -``` - -To give a content type its own icon, add an entry to this map. - -## Paginate the results - -The `SearchPagination` component, also in `@plone/layout`, turns `total`, `bStart`, and `bSize` into numbered page links. -It computes the current page and the total page count, shows the first and last page with an ellipsis around the current page, and renders previous and next controls. - -Each page is a `` that updates the `b_start` query parameter and keeps every other parameter in the address, such as `SearchableText`. -The component reads the current parameters with `useSearchParams` and the current path with `useLocation`, so it carries any filters you add later across pages. - -```{code-block} tsx -:caption: packages/layout/components/SearchResults/SearchPagination.tsx -const linkTo = (page: number) => { - const next = new URLSearchParams(searchParams); - if (page <= 0) { - next.delete('b_start'); - } else { - next.set('b_start', String(page * bSize)); - } - const search = next.toString(); - return search ? `${pathname}?${search}` : pathname; -}; -``` - -When the results fit on a single page, the component renders nothing. - -To change how many results appear per page, adjust the `PAGE_SIZE` constant in the route's `loader`. - -## Add or change a sort option - -The `SearchSort` component renders a sort selector. -It stores the choice in a single `sort` query parameter (`relevance`, `date`, or `title`) and resets paging to the first page whenever the order changes. -`relevance` is the default, which the route omits from the address. - -The `loader` translates the parameter into the backend `sort_on` and `sort_order` fields with the `sortToQuery` helper, so the route stays the single source of truth for the query. - -```{code-block} ts -:caption: packages/layout/components/SearchResults/SearchSort.tsx -export function sortToQuery(sort: string | null) { - switch (sort) { - case 'date': - return { sort_on: 'effective', sort_order: 'descending' }; - case 'title': - return { sort_on: 'sortable_title', sort_order: 'ascending' }; - default: - return {}; - } -} -``` - -To add a sort option, extend `SORT_OPTIONS` and `sortToQuery`, then add the matching label under `layout.search.sort` in each locale. - -## Filter by tag - -The `SearchFacets` component renders a checkbox, styled as a pill, per `Subject` (tag) with a result count. -The component stores selected tags as repeated `Subject` query parameters, and toggling one resets paging to the first page. - -The tag list stays **collapsed by default** behind a "Filter by tag" disclosure button with a rotating chevron. -It opens automatically when the address already carries an active `Subject` filter, so a shared, pre-filtered link still shows its active tags. -The component animates the expand and collapse with the `grid-template-rows` technique (from `0fr` to `1fr`) and honors `prefers-reduced-motion`. - -The available tags can't come from the `plone.app.vocabularies.Keywords` vocabulary, because that vocabulary requires authentication and the public search is anonymous. -Instead, the `loader` runs a second, unfiltered `@search` that requests `metadata_fields=Subject`, and `aggregateFacets` counts the `Subject` values of those matches. - -```{code-block} ts -:caption: packages/publicui/routes/search.tsx -const subjects = url.searchParams.getAll('Subject'); - -const [results, facetSample] = await Promise.all([ - cli.search({ - query: { - ...baseQuery, - ...(subjects.length > 0 ? { Subject: subjects } : {}), - b_start: bStart, - b_size: PAGE_SIZE, - ...sortToQuery(url.searchParams.get('sort')), - }, - }), - cli.search({ - query: { ...baseQuery, metadata_fields: 'Subject', b_size: FACET_SAMPLE_SIZE }, - }), -]); -``` - -The loader computes the facet sample *without* the active `Subject` filter, so every tag stays visible with its full count even after you select one. -Multiple selected tags match results that carry **any** of them, because `@plone/client` serializes the array as Plone's `Subject:list` query. - -```{important} -The loader caps the facet sample at `FACET_SAMPLE_SIZE` matches per request, so the tag counts reflect the first `FACET_SAMPLE_SIZE` matches rather than every match. -``` - -## Accessibility and the loading state - -The route meets the accessibility requirements from the issue. - -- `SearchResults` renders the results in a named `region` landmark (a `
` with `aria-label`); individual results are `
` elements, which aren't landmarks. -- The result count is a `

` with `role="status"` (a polite live region) and `aria-controls` that points at the results container, so screen readers announce the new count and associate it with the list it describes. -- The tag chips, the "Filter by tag" toggle, and the sort selector all show a visible `:focus-visible` outline. - -While the loader re-runs for a new query, sort order, page, or filter, the route detects the navigation with `useNavigation` and passes `loading` to `SearchResults`. -`SearchResults` then sets `aria-busy` on the region, swaps the count for a "Searching…" status, and dims the list. - -```{code-block} ts -:caption: packages/publicui/routes/search.tsx -const navigation = useNavigation(); -const location = useLocation(); -const isLoading = - navigation.state === 'loading' && - navigation.location?.pathname === location.pathname; -``` - -The Public UI also sets `scrollbar-gutter: stable` on `html` in {file}`packages/publicui/styles/publicui.css`. -This keeps the page width steady when expanding the tag filter or changing the result count shows or hides a scroll bar. - -## Customize the query - -The route owns the query, so you can extend the search by adding fields to the `query` object that it passes to `cli.search`. -The `loader` exposes any extra query parameter you add to the address through `url.searchParams`, and `SearchSort`, `SearchFacets`, and `SearchPagination` all keep the other parameters when they navigate. diff --git a/packages/layout/components/SearchResults/SearchResults.module.css b/packages/layout/components/SearchResults/SearchResults.module.css index 840802439..e094d6d04 100644 --- a/packages/layout/components/SearchResults/SearchResults.module.css +++ b/packages/layout/components/SearchResults/SearchResults.module.css @@ -270,3 +270,8 @@ .headline a:hover { text-decoration: underline; } + +.headline a:focus-visible { + outline: 2px solid var(--color-quanta-peacock); + outline-offset: 2px; +} diff --git a/packages/layout/news/+search-facets.feature b/packages/layout/news/+search-facets.feature index d9308c216..bd98b0de6 100644 --- a/packages/layout/news/+search-facets.feature +++ b/packages/layout/news/+search-facets.feature @@ -1 +1 @@ -Added tag (`Subject`) facets to the search results. Available tags and their counts are aggregated from the matches, and selecting one or more filters the results via repeated `Subject` query parameters while resetting paging. @nils-pzr +Added tag (`Subject`) facets to the search results. Available tags and their counts are aggregated from the matches, and selecting one or more tags filters the results via repeated `Subject` query parameters while resetting paging. @nils-pzr From f01b79af7b5faa8cdc259a390cafee7386ec882e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Fri, 12 Jun 2026 14:10:56 +0200 Subject: [PATCH 4/5] Add new guides for customizing and understanding the search route in Plone Aurora --- docs/conceptual-guides/search.md | 180 +++++++++++++++++++++ docs/how-to-guides/customize-the-search.md | 63 ++++++++ 2 files changed, 243 insertions(+) create mode 100644 docs/conceptual-guides/search.md create mode 100644 docs/how-to-guides/customize-the-search.md diff --git a/docs/conceptual-guides/search.md b/docs/conceptual-guides/search.md new file mode 100644 index 000000000..532365630 --- /dev/null +++ b/docs/conceptual-guides/search.md @@ -0,0 +1,180 @@ +--- +myst: + html_meta: + "description": "How the public search route in Plone Aurora works" + "property=og:description": "How the public search route in Plone Aurora works" + "property=og:title": "Search" + "keywords": "Plone Aurora, frontend, Plone, search, pagination, sorting, facets, tags, accessibility, @search" +--- + +# Search + +This guide describes the public search route in Plone Aurora. +To customize or extend the search, refer to {doc}`/how-to-guides/customize-the-search`. + +Plone Aurora ships a public search route at {file}`packages/publicui/routes/search.tsx`. +The search input itself lives in the site header. +This route reads the search term from the `SearchableText` query parameter. + +The route queries the backend in its `loader` and renders on the server. +It returns search results with sorting, filtering, and pagination. +The results are friendly to accessibility tools, keyboard navigation, search engine indexers, web crawlers, and AI agents. + +The route stays thin. +It delegates rendering to reusable components in the {file}`@plone/layout/components/SearchResults/` directory, including `SearchResults`, `SearchSort`, `SearchFacets`, and `SearchPagination`. +The components live in `@plone/layout`, so both the CMS UI and the Public UI can reuse and theme them. + +## The query + +The `loader` reads the search term from the `SearchableText` query parameter, the requested page from `b_start`, the selected tags from repeated `Subject` parameters, and the sort order from `sort`. +It then runs two queries against the `@search` endpoint through `@plone/client` in parallel. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx + +const baseQuery = { + SearchableText: `${query}*`, + path: { + query: path || '/', + }, + use_site_search_settings: 1, +}; + +const [results, facetSample] = await Promise.all([ + cli.search({ + query: { + ...baseQuery, + ...(subjects.length > 0 ? { Subject: subjects } : {}), + b_start: bStart, + b_size: PAGE_SIZE, + ...sortToQuery(url.searchParams.get('sort')), + }, + }), + cli.search({ + query: { + ...baseQuery, + metadata_fields: 'Subject', + b_size: FACET_SAMPLE_SIZE, + }, + }), +]); +``` + +The first query fetches the visible page of results. +The loader appends a trailing `*` to the term so partial words match. +`b_size` limits the request to one page, `b_start` selects the offset of the current page, and the backend returns the matching slice in `items` and the full match count in `items_total`. +That count is everything the pagination needs. + +The second query feeds {ref}`the tag facets `. +The loader runs both queries inside a `try` block, and translates a backend failure into an error response that the route's error boundary renders. + +`use_site_search_settings: 1` makes the backend honor the site's search settings, such as content types excluded from search, matching Volto's behavior. +This flag is also the reason the loader returns early and renders only the heading when there is no `SearchableText`. +An empty term combined with `use_site_search_settings` makes the backend return a bare list instead of a batched result. + +```{note} +The `path` in the query comes from the catch-all route segment. +So `/search` searches the whole site, and `/some/folder/search` scopes the search to that subtree. +``` + +## Result links stay in the app + +The result titles link to `item['@id']`, and the loader flattens the items to app-relative addresses on the server before it returns them. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx + +return { + // Flatten on the server so result links stay in-app, not the backend. + search: flattenToAppURL(results.data.items ?? []), + total: results.data.items_total ?? 0, + params: query, + bStart, + bSize: PAGE_SIZE, + facets: aggregateFacets(facetSample.data.items ?? []), +}; +``` + +`flattenToAppURL` rewrites backend addresses, for example, `http://backend/Plone/page`, to app-relative ones, such as `/page`, based on `config.settings.apiPath`, and that setting is only reliable on the server. +Flattening inside a component would set a correct `href` in the server-rendered markup, but leave the raw backend address in the data that react-aria navigates with on the client, so a click would open the backend. +Flattening once in the `loader` means the serialized result data is already app-relative, so the markup and the client navigation use the same in-app address. +The sitemap route follows the same approach. + +## Content-type icons + +Each result shows an icon for its content type. +`getContentIcon` from `@plone/helpers` resolves the icon from the result's `@type` by looking up the `config.settings.contentIcons` map, and `SearchResults` falls back to the page icon. + +`@plone/layout` registers the `contentIcons` map in {file}`packages/layout/config/settings.ts`, so both the CMS UI and the Public UI resolve the same icons. +The `@plone/contents` package also registers the map, but that package only installs in the CMS UI. + +## Sorting + +The `SearchSort` component stores the choice in a single `sort` query parameter and resets paging to the first page whenever the order changes. +`relevance` is the default, which the component omits from the address. + +The `loader` translates the parameter into the backend `sort_on` and `sort_order` fields with the `sortToQuery` helper, so the route stays the single source of truth for the query. + +```{code-block} ts +:caption: packages/layout/components/SearchResults/SearchSort.tsx + +export function sortToQuery(sort: string | null): { + sort_on?: string; + sort_order?: 'ascending' | 'descending'; +} { + switch (sort) { + case 'date': + return { sort_on: 'effective', sort_order: 'descending' }; + case 'title': + return { sort_on: 'sortable_title', sort_order: 'ascending' }; + default: + return {}; + } +} +``` + +## Pagination + +The `SearchPagination` component turns `total`, `bStart`, and `bSize` into numbered page links. +It computes the current page and the total page count, shows the first and last page with an ellipsis around the current page, and renders previous and next controls. +When the results fit on a single page, the component renders nothing. + +Each page is a link that updates the `b_start` query parameter and keeps every other parameter in the address, such as `SearchableText`, `sort`, and `Subject`. +The component reads the current parameters with `useSearchParams` and the current path with `useLocation`, so it carries any additional filters across pages. + +(search-facet-sampling)= + +## Tag facets + +The `SearchFacets` component renders a checkbox, styled as a pill, per `Subject` (tag) with a result count. +The component stores selected tags as repeated `Subject` query parameters, and toggling one resets paging to the first page. +Multiple selected tags match results that carry any of them, because `@plone/client` serializes the array as Plone's `Subject:list` query. + +The tag list stays collapsed by default behind a {guilabel}`Filter by tag` disclosure button. +It opens automatically when the address already carries an active `Subject` filter, so a shared, pre-filtered link still shows its active tags. +The component animates the expand and collapse with the `grid-template-rows` technique, from `0fr` to `1fr`, and honors `prefers-reduced-motion`. + +The available tags can't come from the `plone.app.vocabularies.Keywords` vocabulary, because that vocabulary requires authentication and the public search is anonymous. +Instead, the `loader` runs the second, unfiltered `@search` query shown above, which requests `metadata_fields=Subject`, and the `aggregateFacets` helper counts the `Subject` values of those matches. +The loader computes this facet sample without the active `Subject` filter, so every tag stays visible with its full count even after you select one. + +```{important} +The loader caps the facet sample at `FACET_SAMPLE_SIZE` matches per request, so the tag counts reflect the first `FACET_SAMPLE_SIZE` matches rather than every match. +``` + +## Accessibility and the loading state + +The search markup carries the structure that assistive technology needs. + +- `SearchResults` renders the results in a named `region` landmark, a `

` with an `aria-label`. + Individual results are `
` elements, which aren't landmarks. +- The result count is a `

` with `role="status"`, a polite live region, and `aria-controls` that points at the results container. + Screen readers announce the new count and associate it with the list it describes. +- The result title links, the tag chips, the {guilabel}`Filter by tag` toggle, and the sort selector all show a visible `:focus-visible` outline for keyboard navigation. +- The pagination is a `nav` landmark with labeled previous and next controls, and the current page carries `aria-current="page"`. + +While the loader re-runs for a new query, sort order, page, or filter, the route detects the navigation with `useNavigation` and passes `loading` to `SearchResults`. +`SearchResults` then sets `aria-busy` on the region, swaps the count for a {guilabel}`Searching…` status, and dims the list. + +The Public UI also sets `scrollbar-gutter: stable` on `html` in {file}`packages/publicui/styles/publicui.css`. +This keeps the page width steady when expanding the tag filter or changing the result count shows or hides a scroll bar. diff --git a/docs/how-to-guides/customize-the-search.md b/docs/how-to-guides/customize-the-search.md new file mode 100644 index 000000000..2b3c8789d --- /dev/null +++ b/docs/how-to-guides/customize-the-search.md @@ -0,0 +1,63 @@ +--- +myst: + html_meta: + "description": "How to customize the public search route in Plone Aurora" + "property=og:description": "How to customize the public search route in Plone Aurora" + "property=og:title": "Customize the search" + "keywords": "Plone Aurora, frontend, Plone, search, customize, sorting, icons, page size, query" +--- + +# Customize the search + +This guide shows you how to customize the public search route in Plone Aurora. +Refer to {doc}`/conceptual-guides/search` for how the route works. + +## Change the number of results per page + +Adjust the `PAGE_SIZE` constant in the route's `loader` in {file}`packages/publicui/routes/search.tsx`. + +```{code-block} ts +:caption: packages/publicui/routes/search.tsx + +const PAGE_SIZE = 25; +``` + +The pagination derives the page count from this value, so no other change is needed. + +## Add a sort option + +The sort options live in {file}`packages/layout/components/SearchResults/SearchSort.tsx`. + +1. Add the option to the `SORT_OPTIONS` array. +2. Add a `case` for it in `sortToQuery` that returns the backend `sort_on` and `sort_order` fields. +3. Add the matching label under `layout.search.sort` in each locale file in {file}`packages/layout/locales/`. + +For example, to sort by creation date, add `'created'` to `SORT_OPTIONS`, and add the following `case` to `sortToQuery`. + +```{code-block} ts +:caption: new case to add in sortToQuery + +case 'created': + return { sort_on: 'created', sort_order: 'descending' }; +``` + +## Give a content type its own icon + +To give a content type its own icon in the results, add an entry for its `@type` to the `config.settings.contentIcons` map, for example, in your add-on's configuration. + +```{code-block} ts +:caption: your add-on's configuration + +import talkSVG from './icons/talk.svg?react'; + +config.settings.contentIcons = { + ...config.settings.contentIcons, + Talk: talkSVG, +}; +``` + +## Extend the query + +Extend the search by adding fields to the `query` object that the route's `loader` passes to `cli.search` in {file}`packages/publicui/routes/search.tsx`. +Read any extra query parameter you add to the address through `url.searchParams` in the `loader`. +`SearchSort`, `SearchFacets`, and `SearchPagination` keep the other parameters when they navigate, so your parameter survives sorting, filtering, and paging. From 914954a7837368e621981ec48029856a7ef35086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Pl=C3=BCtzer?= Date: Fri, 12 Jun 2026 15:07:14 +0200 Subject: [PATCH 5/5] Enhance search route with language-scoped queries, result redirection, and improved localization --- docs/conceptual-guides/search.md | 71 +++---- .../components/SearchResults/SearchFacets.tsx | 7 +- .../SearchResults/SearchPagination.test.tsx | 36 ++++ .../SearchResults/SearchPagination.tsx | 16 +- .../SearchResults/SearchResults.module.css | 17 +- .../SearchResults/SearchResults.test.tsx | 33 +++- .../SearchResults/SearchResults.tsx | 21 ++- .../components/SearchResults/SearchSort.tsx | 11 +- packages/layout/config/settings.ts | 1 + packages/layout/locales/de/common.json | 3 +- packages/layout/locales/en/common.json | 3 +- packages/layout/locales/it/common.json | 3 +- .../news/+search-result-count-plural.bugfix | 1 + packages/publicui/index.ts | 8 + packages/publicui/locales/de/common.json | 11 ++ packages/publicui/news/+german-locale.feature | 1 + .../news/+search-language-scoped.feature | 1 + packages/publicui/routes/search.test.tsx | 176 ++++++++++++++++++ packages/publicui/routes/search.tsx | 44 +++-- packages/publicui/vitest.config.ts | 3 + 20 files changed, 382 insertions(+), 85 deletions(-) create mode 100644 packages/layout/news/+search-result-count-plural.bugfix create mode 100644 packages/publicui/locales/de/common.json create mode 100644 packages/publicui/news/+german-locale.feature create mode 100644 packages/publicui/news/+search-language-scoped.feature create mode 100644 packages/publicui/routes/search.test.tsx diff --git a/docs/conceptual-guides/search.md b/docs/conceptual-guides/search.md index 532365630..e8e523ace 100644 --- a/docs/conceptual-guides/search.md +++ b/docs/conceptual-guides/search.md @@ -16,6 +16,9 @@ Plone Aurora ships a public search route at {file}`packages/publicui/routes/sear The search input itself lives in the site header. This route reads the search term from the `SearchableText` query parameter. +The route answers at `/search` for the whole site, and at `//search` for a top-level folder, such as a language root folder. +On a multilingual site, `/de/search` scopes the results to the `/de` language tree. + The route queries the backend in its `loader` and renders on the server. It returns search results with sorting, filtering, and pagination. The results are friendly to accessibility tools, keyboard navigation, search engine indexers, web crawlers, and AI agents. @@ -27,6 +30,7 @@ The components live in `@plone/layout`, so both the CMS UI and the Public UI can ## The query The `loader` reads the search term from the `SearchableText` query parameter, the requested page from `b_start`, the selected tags from repeated `Subject` parameters, and the sort order from `sort`. +On the scoped route, it adds a `path` query for the root segment, so the backend only returns matches from that subtree. It then runs two queries against the `@search` endpoint through `@plone/client` in parallel. ```{code-block} ts @@ -34,30 +38,36 @@ It then runs two queries against the `@search` endpoint through `@plone/client` const baseQuery = { SearchableText: `${query}*`, - path: { - query: path || '/', - }, use_site_search_settings: 1, + ...(root ? { path: { query: root } } : {}), }; -const [results, facetSample] = await Promise.all([ - cli.search({ - query: { - ...baseQuery, - ...(subjects.length > 0 ? { Subject: subjects } : {}), - b_start: bStart, - b_size: PAGE_SIZE, - ...sortToQuery(url.searchParams.get('sort')), - }, - }), - cli.search({ - query: { - ...baseQuery, - metadata_fields: 'Subject', - b_size: FACET_SAMPLE_SIZE, - }, - }), -]); +let results; +let facetSample; +try { + [results, facetSample] = await Promise.all([ + cli.search({ + query: { + ...baseQuery, + ...(subjects.length > 0 ? { Subject: subjects } : {}), + b_start: bStart, + b_size: PAGE_SIZE, + ...sortToQuery(url.searchParams.get('sort')), + }, + }), + cli.search({ + query: { + ...baseQuery, + metadata_fields: 'Subject', + b_size: FACET_SAMPLE_SIZE, + }, + }), + ]); +} catch (error: any) { + throw data('Search failed', { + status: typeof error.status === 'number' ? error.status : 500, + }); +} ``` The first query fetches the visible page of results. @@ -66,17 +76,12 @@ The loader appends a trailing `*` to the term so partial words match. That count is everything the pagination needs. The second query feeds {ref}`the tag facets `. -The loader runs both queries inside a `try` block, and translates a backend failure into an error response that the route's error boundary renders. +A backend failure becomes an error response that the route's error boundary renders. `use_site_search_settings: 1` makes the backend honor the site's search settings, such as content types excluded from search, matching Volto's behavior. -This flag is also the reason the loader returns early and renders only the heading when there is no `SearchableText`. +This flag is also the reason the loader trims the term, returns early, and renders only the heading when there is no `SearchableText`. An empty term combined with `use_site_search_settings` makes the backend return a bare list instead of a batched result. -```{note} -The `path` in the query comes from the catch-all route segment. -So `/search` searches the whole site, and `/some/folder/search` scopes the search to that subtree. -``` - ## Result links stay in the app The result titles link to `item['@id']`, and the loader flattens the items to app-relative addresses on the server before it returns them. @@ -85,9 +90,8 @@ The result titles link to `item['@id']`, and the loader flattens the items to ap :caption: packages/publicui/routes/search.tsx return { - // Flatten on the server so result links stay in-app, not the backend. search: flattenToAppURL(results.data.items ?? []), - total: results.data.items_total ?? 0, + total, params: query, bStart, bSize: PAGE_SIZE, @@ -142,6 +146,8 @@ When the results fit on a single page, the component renders nothing. Each page is a link that updates the `b_start` query parameter and keeps every other parameter in the address, such as `SearchableText`, `sort`, and `Subject`. The component reads the current parameters with `useSearchParams` and the current path with `useLocation`, so it carries any additional filters across pages. +When the address requests a `b_start` beyond the last page, the loader redirects to the last real page instead of rendering an empty result list. + (search-facet-sampling)= ## Tag facets @@ -168,9 +174,8 @@ The search markup carries the structure that assistive technology needs. - `SearchResults` renders the results in a named `region` landmark, a `

` with an `aria-label`. Individual results are `
` elements, which aren't landmarks. -- The result count is a `

` with `role="status"`, a polite live region, and `aria-controls` that points at the results container. - Screen readers announce the new count and associate it with the list it describes. -- The result title links, the tag chips, the {guilabel}`Filter by tag` toggle, and the sort selector all show a visible `:focus-visible` outline for keyboard navigation. +- The result count is a `

` with `role="status"`, a polite live region, so screen readers announce the new count. +- The result title links, the tag chips, the {guilabel}`Filter by tag` toggle, the sort selector, and the pagination links all show a visible `:focus-visible` outline for keyboard navigation. - The pagination is a `nav` landmark with labeled previous and next controls, and the current page carries `aria-current="page"`. While the loader re-runs for a new query, sort order, page, or filter, the route detects the navigation with `useNavigation` and passes `loading` to `SearchResults`. diff --git a/packages/layout/components/SearchResults/SearchFacets.tsx b/packages/layout/components/SearchResults/SearchFacets.tsx index a23ff46bc..a8b88c786 100644 --- a/packages/layout/components/SearchResults/SearchFacets.tsx +++ b/packages/layout/components/SearchResults/SearchFacets.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useId } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router'; import { ChevrondownIcon } from '@plone/components/Icons'; @@ -33,6 +33,7 @@ export function SearchFacets({ facets }: SearchFacetsProps) { const selected = new Set(searchParams.getAll('Subject')); // Collapsed by default; open when a tag filter is already active. + const listId = useId(); const [open, setOpen] = useState(selected.size > 0); if (facets.length === 0) { @@ -62,7 +63,7 @@ export function SearchFacets({ facets }: SearchFacetsProps) { type="button" className={styles.facetsToggle} aria-expanded={open} - aria-controls="search-facet-list" + aria-controls={listId} onClick={() => setOpen((value) => !value)} > {t('layout.search.filterByTag')} @@ -80,7 +81,7 @@ export function SearchFacets({ facets }: SearchFacetsProps) {

diff --git a/packages/layout/components/SearchResults/SearchPagination.test.tsx b/packages/layout/components/SearchResults/SearchPagination.test.tsx index fbbc43632..c856b00b5 100644 --- a/packages/layout/components/SearchResults/SearchPagination.test.tsx +++ b/packages/layout/components/SearchResults/SearchPagination.test.tsx @@ -62,6 +62,42 @@ describe('SearchPagination', () => { ); }); + it('windows long page lists with ellipses around the current page', () => { + renderPagination( + { total: 250, bStart: 100, bSize: 25 }, + '/search?SearchableText=foo&b_start=100', + ); + + for (const page of ['1', '4', '5', '6', '10']) { + expect(screen.getByText(page)).toBeInTheDocument(); + } + for (const page of ['2', '3', '7', '8', '9']) { + expect(screen.queryByText(page)).not.toBeInTheDocument(); + } + expect(screen.getAllByText('…')).toHaveLength(2); + }); + + it('windows the edges without a leading or trailing ellipsis', () => { + renderPagination({ total: 250, bStart: 0, bSize: 25 }); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.queryByText('3')).not.toBeInTheDocument(); + expect(screen.getAllByText('…')).toHaveLength(1); + }); + + it('windows the last page without a trailing ellipsis', () => { + renderPagination( + { total: 250, bStart: 225, bSize: 25 }, + '/search?SearchableText=foo&b_start=225', + ); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('9')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.queryByText('8')).not.toBeInTheDocument(); + expect(screen.getAllByText('…')).toHaveLength(1); + }); + it('disables previous on the first page and next on the last', () => { const { rerender } = renderPagination({ total: 60, bStart: 0, bSize: 25 }); expect(screen.getByText('‹').closest('a')).toBeNull(); diff --git a/packages/layout/components/SearchResults/SearchPagination.tsx b/packages/layout/components/SearchResults/SearchPagination.tsx index 475d87be2..532601cb0 100644 --- a/packages/layout/components/SearchResults/SearchPagination.tsx +++ b/packages/layout/components/SearchResults/SearchPagination.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import { useLocation, useSearchParams } from 'react-router'; import { Link } from '@plone/components'; @@ -81,10 +82,7 @@ export function SearchPagination({ ) : (