From d88e77f459b61ee29b67895d970f40b8ed465a90 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 26 Aug 2026 20:36:39 +0100 Subject: [PATCH] feat(suggest): the dropdown, with combobox ARIA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presentational only — it fetches nothing and owns no state, so the fetching rules and the ARIA rules can be read separately. onMouseDown, not onClick. The input's blur handler closes the list and blur fires before click, so a click handler never runs: the classic bug where a dropdown works perfectly by keyboard and is dead to the mouse. The plan's CSS guessed at token names like --color-surface. The real tokens are --bg-card, --border, --text-muted, --bg-secondary and --shadow-soft, and all five are redefined in the dark theme — invented names would have silently fallen back to hardcoded light values and broken dark mode. Local authority is rendered because there are many schools called 'St Mary's'; a list without it is unusable for exactly the query autosuggest exists to serve, which is what the test asserts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj --- .../__tests__/components/SuggestList.test.tsx | 50 +++++++++++++++++ nextjs-app/components/SuggestList.module.css | 53 +++++++++++++++++++ nextjs-app/components/SuggestList.tsx | 53 +++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 nextjs-app/__tests__/components/SuggestList.test.tsx create mode 100644 nextjs-app/components/SuggestList.module.css create mode 100644 nextjs-app/components/SuggestList.tsx diff --git a/nextjs-app/__tests__/components/SuggestList.test.tsx b/nextjs-app/__tests__/components/SuggestList.test.tsx new file mode 100644 index 0000000..fbc68e2 --- /dev/null +++ b/nextjs-app/__tests__/components/SuggestList.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import { SuggestList, suggestOptionId } from '@/components/SuggestList'; + +const ROWS = [ + { urn: 1, school_name: "St Mary's Primary", local_authority: 'Camden', + postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Voluntary aided school' }, + { urn: 2, school_name: "St Mary's Primary", local_authority: 'Barnet', + postcode: 'EN5 2AA', phase: 'Primary', school_type: 'Community school' }, +]; + +describe('SuggestList', () => { + it('is a listbox of options', () => { + render( {}} onHover={() => {}} />); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(2); + }); + + it('shows the local authority, which is what tells two schools apart', () => { + // Both rows are "St Mary's Primary". Without the authority the list is + // unusable for exactly the query autosuggest exists to serve. + render( {}} onHover={() => {}} />); + expect(screen.getByText('Camden')).toBeInTheDocument(); + expect(screen.getByText('Barnet')).toBeInTheDocument(); + }); + + it('marks only the active option selected', () => { + render( {}} onHover={() => {}} />); + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('gives each option the id the input will point at', () => { + // aria-activedescendant on the input has to name a real element id, or + // a screen reader announces nothing as the user arrows through. + render( {}} onHover={() => {}} />); + expect(screen.getAllByRole('option')[0]).toHaveAttribute( + 'id', suggestOptionId('s', 0)); + }); + + it('renders nothing when there is nothing to suggest', () => { + const { container } = render( {}} onHover={() => {}} />); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/nextjs-app/components/SuggestList.module.css b/nextjs-app/components/SuggestList.module.css new file mode 100644 index 0000000..a785220 --- /dev/null +++ b/nextjs-app/components/SuggestList.module.css @@ -0,0 +1,53 @@ +/* + * Anchored to .omniBoxContainer, which is position: relative for this reason. + * + * Every colour is a token, so the dropdown follows the theme. The dark theme + * redefines --bg-card, --border, --text-muted and --shadow-soft, and this + * inherits all four without a second rule. + */ +.list { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + /* Above the sticky filter bar (10) and the hero layers (0–2), below the + skip-link (10000) and the modal overlay (1000). */ + z-index: 40; + margin: 0; + padding: 4px; + list-style: none; + max-height: 320px; + overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-soft); +} + +.option { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-primary); +} + +/* Hover and keyboard share one style: the active option is the active option + however it became active. Two rules would drift. */ +.option:hover, +.active { + background: var(--bg-secondary); +} + +.name { + font-weight: 500; +} + +.meta { + font-size: 0.85em; + color: var(--text-muted); + white-space: nowrap; +} diff --git a/nextjs-app/components/SuggestList.tsx b/nextjs-app/components/SuggestList.tsx new file mode 100644 index 0000000..18071c4 --- /dev/null +++ b/nextjs-app/components/SuggestList.tsx @@ -0,0 +1,53 @@ +'use client'; + +/** + * The autosuggest dropdown. Presentational only — it fetches nothing and owns + * no state, so the fetching rules and the ARIA rules can be read separately. + */ + +import type { Suggestion } from '@/lib/suggest'; +import styles from './SuggestList.module.css'; + +/** The id the input's aria-activedescendant points at. */ +export function suggestOptionId(id: string, index: number): string { + return `${id}-option-${index}`; +} + +interface Props { + /** Shared with the input's aria-controls. */ + id: string; + suggestions: Suggestion[]; + activeIndex: number; + onPick: (s: Suggestion) => void; + onHover: (index: number) => void; +} + +export function SuggestList({ id, suggestions, activeIndex, onPick, onHover }: Props) { + if (suggestions.length === 0) return null; + + return ( +
    + {suggestions.map((s, i) => ( +
  • { e.preventDefault(); onPick(s); }} + onMouseEnter={() => onHover(i)} + > + {s.school_name} + {/* Not decoration: there are many "St Mary's". */} + {s.local_authority} +
  • + ))} +
+ ); +}