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')
})
})