Skip to content

CMS: instant and on-page search, its analytics, and a configurable page-contents nav - #446

Merged
dfe-lance merged 17 commits into
mainfrom
instant-search-widget
Sep 17, 2026
Merged

dfe-lance merged 17 commits into
mainfrom
instant-search-widget

Conversation

@dfe-lance

@dfe-lance dfe-lance commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Two strands of CMS work. The search widget learns to search as you type — including
searching just the page you are reading — and the analytics dashboard learns to count
those searches. The page-contents nav becomes configurable while it was being used to
test the first part.

Closes #426
AB#303114


Search widget

The widget could only search the whole site or a path prefix, and always sent the
visitor to a results page. It now has two independent properties.

Search in — the whole site, a section of it, or this page. "This page" resolves
to the page being viewed, so even with no JavaScript the button submits a search of just
that page.

Instant search — a tick box. Matches appear as the visitor types, with no button
press and no results page. On a page search the suggestions are the page's own sections
and choosing one moves to that heading; otherwise they are documents, and choosing one
opens it.

The two combine, which is where the request came from: instant search on this page gives
a find-as-you-type that jumps to the section, in the shape of GOV.UK finder-frontend's
search_with_autocomplete.

How it works

The widget renders exactly the form it always did. instant-search.js layers a suggestion
menu over a marked form using accessible-autocomplete, which the layout already loads. A
visitor without JavaScript, or one whose request fails, keeps the search box and button
that were always there.

A page search needs no server call — the headings are already in the DOM. A site or
section search calls the new GET /search/suggestions, which runs the same pass as
/search so scope rules, the silent filters and URL canonicalisation cannot drift between
what is suggested and what a submitted search returns.

Choosing a result marks every occurrence of the term on the page in <mark> — the same
element the results page uses for its snippets — and moves keyboard focus to the
destination heading, so the jump lands for a screen reader and not only for the viewport.

Accessibility

  • aria-controls added to the generated combobox. The library still uses the ARIA 1.0
    aria-owns spelling, which axe reports as a critical failure.
  • Each widget gets its own input id. Two search widgets on one page already collided on
    id="search"; the autocomplete extended that collision to the listbox and status ids,
    pointing a screen reader at the wrong menu.
  • The heading jumped to is outlined explicitly rather than relying on the browser's
    :focus-visible heuristic, which drew the box only sometimes.
  • An axe scan over the open menu (wcag2a/2aa/21a/21aa) reports no violations.

Search analytics

A typeahead has no server-side moment worth recording — the server sees a fetch per
settled keystroke, and nothing at all for an on-page search. What matters is the query
someone ended on, what they were shown for it, and whether they took any of it. Only the
browser knows that, so it reports it, once per settled query.

A report goes out when the query settles: they chose something, they changed the query to
something that is not a continuation of it, or they left the box, hid the tab or closed
the page. Continuations supersede rather than accumulate, so typing "evidence" reports
once. The middle trigger is the one worth having — someone shown three sections who takes
none of them and types something else has told us the results were no good, and a
selection-only signal would throw that away.

search_events gains surface (site / instant / instant-page), the host page an on-page
search ran on, the chosen key and position, and a separate count for sections. Sections
fold into the computed total, so an on-page search offering three headings is not recorded
as a zero-result search. Existing rows migrate to surface = 'site', which is what they
all were.

POST /search/instant-analytics takes the browser's report. Every value in it is
untrusted: the surface must be one the app declares, the query is sliced to the same 100
characters /search uses, the result list is capped, an unrecognised kind degrades to a
page, and a malformed report is refused rather than written as a half-row that would read
as a zero-result search. The session id comes from the server session, never the payload.

Dashboard

A surface filter on the shared window form, so every existing chart and table can be
read as submitted searches, instant searches, single-page searches, or any combination.
Every read composes one surface-filtered source rather than naming search_events, and a
test asserts that — a query naming the table directly would report across every surface
while the page around it claimed to be filtered.

A single-page search section listing the pages that carry a page-scoped widget and
what was asked of each. It separates three outcomes a site search never distinguishes:
nothing matched, something matched and was taken, and something matched and was left. The
last is the number worth reading — the page was asked a question and had nothing useful to
offer.

Reviewers: unfiltered counts everything, so the figures on the dashboard move the day
this lands. They now include searches that were always happening but had nowhere to be
counted. That is intended, but it will look like a step change on the charts.


Page-contents nav

Picked up while using the nav to test the on-page search.

The nav listed H2 and H3 only. It now carries a tick box per level, H1 to H6, with H2 and
H3 ticked
. The chosen levels form the hierarchy in the order they are chosen rather than
by their absolute numbers, so a page that uses H2s and H4s but no H3s can tick just those
two and get the H4s nested directly under the H2s. Levels fall back rather than being
dropped: a heading whose parent level has not appeared yet attaches to the nearest chosen
level above it. Authors skip levels, and dropping those headings would leave sections the
nav cannot reach, which is the job it exists to do.

The list markup recurses now instead of spelling out two levels, so depth is the nav
builder's decision rather than the view's.

Reviewers: moj-side-navigation gives every nested list zero margin and padding, so
until now the nesting existed only in the markup — an H3 sat at the same left edge, in the
same size, as the H2 it belonged to. Each level is now indented one step and set a size
smaller, so the contents nav on pages that already use it will read as the hierarchy it
always was.

This overlaps #425 (left navigation widget mis-navigates to h3 and h4 headings) without
necessarily closing it — that issue has no description, so I have not assumed it describes
this.


Notable fixes found while testing

  • An unticked checkbox posts nothing, and the widget props builder keeps the registry
    default for a field it never sees — so unticking a nav level that defaults to on did not
    turn it off. Each box now carries a hidden companion posting false, placed after the
    label so the browser's first-value-wins ordering and the GDS adjacent-sibling styling both
    still hold.
  • The on-page search index only took headings carrying an id. The CMS anchors heading
    widgets, so a heading typed inside a rich-text block arrives as plain markup with none —
    41 of the 77 headings on a page assembled from real guidance. Skipping them did not merely
    lose them: their text ran on into the previous anchored heading, so searching for a word
    underneath one offered a section some distance up the page. Every heading is indexed now,
    and given an id if it has none.
  • Keyboard selection left focus on the search box: the component returns focus to its own
    input when closing the menu, so a click passed and arrow-and-enter did not.
  • The migration's generated Down dropped a column while the computed columns that
    reference it still existed, which would have failed on its first statement.

Testing

Build (Release, full solution)  clean — no errors, no warnings on changed lines
Unit                            5283 passed, 0 failed
Integration                      811 passed, 0 failed
E2E (Category!=VisualRegression) 225 passed, 9 failed

The nine E2E failures are pre-existing and unrelated: they fail identically on unmodified
main, verified by building the web image from main and re-running them. Seven are
BackToTopTests hitting sample pages that exist with zero versions; two assert on search
hits for a term the local corpus does not contain. Both are now documented in #443.

New coverage spans every tier: widget registry and props, Razor source contracts for the
widget, the editor form and the nav partial, the suggestions endpoint's input guards, the
analytics mapper and the reporting endpoint, the surface filter and the single-page reads
against real Postgres, and browser tests for both jump paths, body-only matches, marking,
the no-results copy, section scoping, two widgets on one page, every nav level combination,
an editor round trip, a run with JavaScript disabled, and an axe scan.

The one gap worth naming: there is no JavaScript test framework in this repo, so
instant-search.js is covered only by browser tests. The fetch-failure path and the
out-of-order-response guard are not exercised.

The widget could only search the whole site or a path prefix, and always
navigated to a results page. It now carries two independent properties:

- searchIn - the whole site, a section of it, or the page the widget sits
  on. "This page" resolves to the current request path, so a submit with
  no JavaScript still lands on a search of just that page.
- instant - marks the form for progressive enhancement. The markup is
  unchanged either way; the marker attributes and the script only appear
  when it is on.

Widgets already placed carry no searchIn, so an existing scope still
reads as a section and an empty one as the whole site. No migration.

Refs #426
AB#303114
GET /search/suggestions returns up to ten {label, url} rows for a term,
optionally scoped to a path. It is the typeahead source for the search
widget's instant option; the three existing suggestion endpoints are all
journey-domain and none of them search content.

SuggestAsync runs the same pass as SearchAsync, so scope handling, the
silent filters and URL canonicalisation cannot drift between what is
suggested and what a submitted search returns. It records no telemetry:
SearchAsync is the single place a search row is written, and a
per-keystroke writer would bury the analytics store and skew the volume,
latency and zero-result charts. A data-store failure yields an empty
menu rather than breaking the page it sits on.

Refs #426
AB#303114
instant-search.js upgrades a marked search form into a suggestion menu
built on accessible-autocomplete, which the layout already loads. Two
sources behind one menu: on a page search it indexes the page's own
heading anchors and the copy beneath each one, and choosing a result
moves both the URL and keyboard focus to that heading; otherwise it
calls /search/suggestions and opens the page chosen.

The form underneath is untouched, so a visitor without JavaScript, or
one whose request fails, keeps the search box and button that were
always there. The script does nothing when there is no content region,
which is how the widget stays inert in the editor preview.

Adds aria-controls to the generated combobox. The library still uses
the ARIA 1.0 aria-owns spelling, which axe reports as a critical
failure; the attribute survives the component's re-renders.

Refs #426
AB#303114
Choosing a section with the arrow keys and enter left focus on the
search box: the component returns focus to its own input as part of
closing the menu, which undid the move. Deferring it to a later task
lands after that, so arrow-and-enter now takes the reading position to
the heading exactly as a click does.

Caught driving the widget by keyboard rather than by click, so the E2E
suite now exercises both paths.

Refs #426
AB#303114
Every search widget rendered "search" as its input id, so two of them on
one page produced duplicate ids and two labels pointing at the first
input. Instant search made that worse: the listbox and status ids the
autocomplete derives from the input collided too, which aims a screen
reader at the wrong menu.

Counted per request, so the first widget keeps the id it has always had.

Refs #426
AB#303114
A typeahead has no server-side moment worth recording. The server sees a
suggestion fetch per settled keystroke, and nothing at all for an on-page
search, and neither is the event anyone wants: what matters is the query
someone ended on, what they were shown, and whether they took any of it.
Only the browser knows that, so it reports it.

search_events gains surface (site, instant, instant-page), the host page
an on-page search ran on, and the chosen key and position. A null
selection is meaningful, not missing data: it means the menu was shown
and none of it was any good. Sections get their own count rather than
being filed as pages, and fold into the computed total so an on-page
search offering three headings is not recorded as a zero-result search.
Existing rows migrate to surface 'site', which is what they all were.

POST /search/instant-analytics takes the browser's report. Every value in
it is untrusted: the surface must be one this app declares, the query is
sliced to the same 100 characters /search uses, the result list is
capped, an unrecognised kind degrades to a page, and a malformed report
is refused rather than written as a half-row that would read as a
zero-result search. The session id comes from the server session, never
the payload.

The migration's generated Down was reordered: EF emitted the column drops
before reverting the computed columns that reference them, which fails on
the first statement.

Refs #426
AB#303114
Nothing is sent while someone is still typing. One report goes out when
the query is settled, which is any of: they chose something from the
menu, they changed the query to something that is not a continuation of
it, or they left the box, hid the tab or closed the page.

Continuations supersede rather than accumulate, so typing e-v-i-d-e-n-c-e
reports once, as "evidence". The middle trigger is the one worth having:
someone shown three sections who takes none of them and types something
else has said the results were no good, and a selection-only signal would
throw that away. A report with no selection is that finding, not missing
data.

sendBeacon carries it, because it is the only transport that survives the
page closing; it cannot set headers, so the antiforgery token travels in
a form-encoded body. A failed send is swallowed - analytics must never
break the page it is measuring.

Refs #426
AB#303114
Two additions to the search-analytics surface.

A surface filter on the shared window form, so every existing chart and
table can be read as submitted searches, instant searches, single-page
searches, or any combination. Unfiltered counts everything, which means
the figures on the dashboard move the day this lands: they now include
searches that were always happening but had nowhere to be counted.

A single-page-search section listing the pages that carry a page-scoped
widget, and what was asked of each one. It separates three outcomes a
site search never distinguishes: nothing matched, something matched and
was taken, and something matched and was left. The last is the number
worth reading - the page was asked a question and had nothing useful to
offer.

Every read composes one surface-filtered source rather than naming
search_events, and a test asserts that, because a query that named the
table directly would report across every surface while the page around it
claimed to be filtered.

A long page path is one unbroken token and was setting the table's
minimum width, pushing the numeric columns past the edge of the admin
layout; it now wraps.

Refs #426
AB#303114
The shared window filter is used by the single-page search tables, which
draw no time series, so its bucket-size radios changed nothing there. A
control that does nothing is worse than no control.

Refs #426
AB#303114
"Instant search" and "Single-page search" read as two unrelated features,
so someone who used the instant search on a page ticked "Instant search"
and filtered their own searches out of the dashboard. They are now
"Instant — the whole site or a section" and "Instant — this page only".

The single-page section also warns when the surface filter excludes it,
rather than reporting that nothing was searched for. An empty table
because of a filter and an empty table because nobody searched are
different facts and a reader could not tell them apart.

Refs #426
AB#303114
The report for that case is sent with sendBeacon while the page unloads,
so route interception is torn down before it can see it and the test has
to read the row back from the dashboard instead. Verified by hand first
against a real navigation: the row lands with the right host page and no
selection.

The single-page section test also asserted its page appeared in the
paged list, which only held while that list was short - it addresses the
terms drill-in by path now, and checks the list renders rows separately.

Refs #426
AB#303114
The nav listed H2 and H3 only, so every H4 on a page was unreachable
from it. H4 now nests under the most recent H3.

Levels fall back rather than being dropped: an H4 with no H3 above it
attaches to the current H2, and a heading with nothing above it at all
becomes top-level. Authors skip levels — several of the guidance pages
go straight from their title to H3s — and dropping those headings would
leave sections the nav cannot reach, which is the job it exists to do.
A new H2 resets the branch so a later section's H4 cannot attach to the
previous section's last H3.

The list markup recurses now instead of spelling out two levels, so the
depth is the nav builder's decision rather than the view's.

moj-side-navigation gives every nested list zero margin and padding, so
until now the nesting existed only in the markup: an H3 sat at the same
left edge, in the same size, as the H2 it belonged to. Each level is
indented one step and set a size smaller. This changes how the contents
nav looks on pages that already use it — previously flat lists will now
read as the hierarchies they always were.

AB#303114
The page-nav widget now carries a tick box per level, H1 to H6, with H2
and H3 ticked. The chosen levels form the hierarchy in the order they
are chosen rather than by their absolute numbers, so a page that uses
H2s and H4s but no H3s can tick just those two and get the H4s nested
directly under the H2s. An unchosen level is ignored without breaking
the chain around it.

The nav is built from the page's own tree in the widget now, not read
from the list the controller precomputes, because the levels are the
widget's choice and two nav widgets on one page may legitimately want
different ones. The precomputed list stays as the fallback for the
editor preview, which renders a widget with no page around it.

A widget placed before the tick boxes existed declares none of them,
which means the default pair — not the empty list that unticking
everything gives. Telling those apart is why the level reader checks
whether the props are present rather than just reading each as a
boolean.

Each box carries a hidden companion posting "false". Without it an
unticked box posts nothing, and the props builder keeps the registry
default for a field it never sees, so unticking a level that defaults
to on did not turn it off. Found by driving the editor form rather than
posting props directly, and covered by a test that does the same.

AB#303114
Three faults in the on-page search, all visible on one long page.

The index only took headings that carried an id. The CMS anchors
heading widgets, so a heading an author typed inside a rich-text block
arrives as plain markup with none — 41 of the 77 headings on a page
assembled from real guidance. Skipping them did not merely lose them:
their text ran on into the previous anchored heading, so searching for
a word underneath one offered a section some distance up the page.
Searching "pregnant" returned "Life-limiting or critical illness".
Every heading is indexed now, and given an id if it has none.

Jumping to a section answered "where" but not "where exactly" — on a
long section the word can still be paragraphs further down. Every
occurrence is now wrapped in <mark>, the same element the search
results page uses for its snippets, so the yellow means the same thing
in both places. A new search clears the marks from the last one.

The heading jumped to was outlined only when the browser's
:focus-visible heuristic chose to, so the box came and went between
jumps. It is asked for explicitly now, in GDS focus yellow.

AB#303114
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

Review app for PR 446 was deleted

The five test classes added here seed their pages under /help and build their
URLs from it. Main has since moved runtime-created browser-test content to its
own /development-testing root, so CmsSeedHelpers.HelpRootId no longer exists and
this branch stopped compiling the moment the two met.

Both halves move across: the parent the pages are created under, and the paths
and widget scope built from it. Same reason as the pages that went before them —
teardown is best-effort and the delete route is a soft delete, so anything that
leaks stays in the tree and travels to the next environment inside a content
export. Better that it leaks somewhere nobody has to ask what it is.

FixtureContent gains the root's bare segment alongside its path, because a
widget scope is written without a leading slash.

AB#303114
The section-jump test read document.activeElement the moment the URL assertion
was satisfied. Those two things do not happen together: the module sets the hash
synchronously and then moves focus in a later task, deliberately, because the
autocomplete puts focus back on its own input while closing the menu and the
move has to land after that.

So the URL matches first and the read catches whatever held focus at that
instant. On a developer's machine the round trip to the browser is slow enough
that the deferred task has already run; on the CI runner it is not, and the
assertion came back with an empty string twice.

It now uses the same retrying assertion the keyboard twin beside it already
used. Confirmed by widening the deferral to 300ms: the old form then fails
locally with exactly the CI message, the new one passes.

AB#303114
@dfe-lance
dfe-lance merged commit 653b93d into main Sep 17, 2026
8 checks passed
@dfe-lance
dfe-lance deleted the instant-search-widget branch September 17, 2026 10:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CMS - make on-page search widget per Karen

2 participants