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} +
  • + ))} +
+ ); +}