From 65ac1989639c4d845698ebc156a2834b4f144277 Mon Sep 17 00:00:00 2001 From: Vitaliy Bravikov Date: Mon, 19 Oct 2026 09:40:32 +0200 Subject: [PATCH] Polishing the detail page is complete, bug #65 --- frontend/src/pages/SightingDetailPage.css | 12 ++ frontend/src/pages/SightingDetailPage.tsx | 115 ++++++++++-------- .../src/tests/SightingDetailPage.test.tsx | 41 ++++++- 3 files changed, 114 insertions(+), 54 deletions(-) diff --git a/frontend/src/pages/SightingDetailPage.css b/frontend/src/pages/SightingDetailPage.css index 3e52210..6f0e58e 100644 --- a/frontend/src/pages/SightingDetailPage.css +++ b/frontend/src/pages/SightingDetailPage.css @@ -3,6 +3,18 @@ gap: 24px; } +.sighting-detail__announcement { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + .sighting-detail__back { justify-self: start; color: var(--accent); diff --git a/frontend/src/pages/SightingDetailPage.tsx b/frontend/src/pages/SightingDetailPage.tsx index 93e45c9..0866594 100644 --- a/frontend/src/pages/SightingDetailPage.tsx +++ b/frontend/src/pages/SightingDetailPage.tsx @@ -9,6 +9,7 @@ import { formatLocation, formatShape, } from '../utils/format-sighting' +import '../components/SightingList.css' import './SightingDetailPage.css' /** @@ -59,63 +60,77 @@ function SightingDetailPage() { ) - if (!sighting) { - return ( -
+ return ( + <> +

+ {error ? '' : loading ? 'Loading sighting…' : title} +

+
{backLink} -

{title}

- {error ? ( -
- {CombinedGraphQLErrors.is(error) ? ( -

The server could not load this sighting. Try again later.

+ {!sighting ? ( + <> +

{title}

+ {error ? ( +
+ {CombinedGraphQLErrors.is(error) ? ( +

+ The server could not load this sighting. Try again later. +

+ ) : ( + <> +

Check your connection and try again.

+ + + )} +
) : ( - <> -

Check your connection and try again.

- - + !loading &&

There is no sighting with id “{id}”.

)} -
+ ) : ( - !loading &&

There is no sighting with id “{id}”.

- )} -
- ) - } + <> +
+

{title}

+ +
- return ( -
- {backLink} -
-

{title}

- -
+ - +
+
+
Shape
+
{formatShape(sighting.shape)}
+
+
+
Duration
+
{formatDuration(sighting.durationSeconds)}
+
+
-
-
-
Shape
-
{formatShape(sighting.shape)}
-
-
-
Duration
-
{formatDuration(sighting.durationSeconds)}
-
-
- - {sighting.comments && ( -

{sighting.comments}

- )} -
+ {sighting.comments && ( +

{sighting.comments}

+ )} + + )} + + ) } diff --git a/frontend/src/tests/SightingDetailPage.test.tsx b/frontend/src/tests/SightingDetailPage.test.tsx index 3fcf2a8..cc832b9 100644 --- a/frontend/src/tests/SightingDetailPage.test.tsx +++ b/frontend/src/tests/SightingDetailPage.test.tsx @@ -3,7 +3,7 @@ import { type MockedProviderProps, } from '@apollo/client/testing/react' import { GraphQLError } from 'graphql' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { SIGHTING_QUERY } from '../graphql/queries' @@ -56,7 +56,16 @@ describe('SightingDetailPage', () => { it('renders sighting details and preserves the originating search link', async () => { vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) - renderDetailPage('/sightings/1', [found], { fromSearch: '?q=San+Marcos' }) + const { container } = renderDetailPage('/sightings/1', [found], { + fromSearch: '?q=San+Marcos', + }) + const announcement = container.querySelector('[aria-live="polite"]') + const content = screen.getByRole('article') + + expect(announcement).toHaveTextContent('Loading sighting…') + expect(announcement).toHaveAttribute('aria-atomic', 'true') + expect(content).toHaveAttribute('aria-busy', 'true') + expect(content).not.toContainElement(announcement as HTMLElement) expect( screen.getByRole('heading', { name: 'Loading sighting…' }), @@ -64,6 +73,11 @@ describe('SightingDetailPage', () => { expect( await screen.findByRole('heading', { name: 'San Marcos, TX, US' }), ).toBeInTheDocument() + expect(container.querySelectorAll('[aria-live]')).toHaveLength(1) + expect(container.querySelector('[aria-live]')).toBe(announcement) + expect(announcement).toHaveTextContent('San Marcos, TX, US') + expect(screen.getByRole('article')).toBe(content) + expect(content).toHaveAttribute('aria-busy', 'false') expect(screen.getByText('45 min')).toBeInTheDocument() expect(screen.getByText(/This event took place/)).toBeInTheDocument() expect( @@ -129,14 +143,19 @@ describe('SightingDetailPage', () => { ).toHaveAttribute('href', '/') expect(screen.queryByTestId('detail-map')).not.toBeInTheDocument() expect(document.title).toBe('Sighting not found · UFOund it') + expect(screen.getByRole('article')).toHaveAttribute('aria-busy', 'false') + expect(document.querySelector('[aria-live]')).toHaveTextContent( + 'Sighting not found', + ) }) it('shows an error and lets the user retry', async () => { vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) - renderDetailPage('/sightings/1', [ + const { container } = renderDetailPage('/sightings/1', [ { ...found, error: new Error('Network down') }, found, ]) + const announcement = container.querySelector('[aria-live]') expect(await screen.findByRole('alert')).toHaveTextContent( 'Check your connection', @@ -145,10 +164,24 @@ describe('SightingDetailPage', () => { screen.getByRole('heading', { name: 'Could not load sighting' }), ).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(announcement).toBeEmptyDOMElement() + expect(screen.getByRole('article')).toHaveAttribute('aria-busy', 'false') + const retryButton = screen.getByRole('button', { name: 'Try again' }) + expect(retryButton).toHaveClass('sighting-results__button') + fireEvent.click(retryButton) + + await waitFor(() => + expect(announcement).toHaveTextContent('Loading sighting…'), + ) + expect(screen.getByRole('article')).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect( await screen.findByRole('heading', { name: 'San Marcos, TX, US' }), ).toBeInTheDocument() + expect(container.querySelectorAll('[aria-live]')).toHaveLength(1) + expect(container.querySelector('[aria-live]')).toBe(announcement) + expect(announcement).toHaveTextContent('San Marcos, TX, US') + expect(screen.getByRole('article')).toHaveAttribute('aria-busy', 'false') }) })