diff --git a/README.md b/README.md index 0ae869b..cae55d6 100644 --- a/README.md +++ b/README.md @@ -458,19 +458,34 @@ Vi planlegger å bruke enten: ## Tilgjengelighet +Universell utforming er en del av utviklingen gjennom hele prosjektet. Vi følger WCAG 2.2 nivå AA, og har sjekket alle sidene med axe-core i lys og mørk modus, på mobil- og skjermbredde, uten brudd. -Universell utforming skal være en del av utviklingen gjennom hele prosjektet. +**Prinsipp: synlig tekst eller `aria-label`, ikke skjulte hjelpeelementer.** Vi bruker ikke skjulte ``- eller `
`-elementer med tekst bare for skjermlesere. Der skjermlesere trenger mer enn det som står på skjermen, bruker vi `aria-label` på et semantisk element (` + +
+ + {/* role="status" announces the new count when filters change */} +

+ {!shown + ? 'Loading wines …' + : shown.totalCount > wines.length + ? `Showing ${wines.length} wines` + : `${shown.totalCount.toLocaleString('en')} ${shown.totalCount === 1 ? 'wine' : 'wines'} found.`} +

+ + {error && ( +

Could not load wines. Please try again later.

+ )} + {shown && wines.length === 0 && ( +

+ No wines match these filters. Try removing some of them.

+ )} - {error && ( -

Could not load wines. Please try again later.

- )} - {shown && wines.length === 0 && ( -

- No wines match these filters. Try removing some of them. + {view === 'map' ? ( + <> + +

+ Tip: Tab to the map and use the arrow keys to move between + wines, or switch to List.

- )} - - + ) : ( + - + )} + - - + + - - - + + ); } diff --git a/frontend/src/components/FavoritesPage.tsx b/frontend/src/components/FavoritesPage.tsx index 4a24541..fcfba29 100644 --- a/frontend/src/components/FavoritesPage.tsx +++ b/frontend/src/components/FavoritesPage.tsx @@ -6,6 +6,7 @@ import { FAVORITES_QUERY, type FavoritesData } from '../graphql/queries'; import WineList from './WineList'; import Pagination from './Pagination'; import { PAGE_SIZE } from './WineCatalog'; +import { usePageTitle } from '../hooks/usePageTitle'; /** * The logged-in user's favourites, at /favorites (issue 18). @@ -22,6 +23,7 @@ export default function FavoritesPage() { const location = useLocation(); const [page, setPage] = useState(1); const offset = (page - 1) * PAGE_SIZE; + usePageTitle('Favourites'); // Skipped for visitors: the backend would only answer "not logged in" const { data, previousData, loading, error } = useQuery( @@ -83,7 +85,7 @@ export default function FavoritesPage() { } return ( -
+

Favourites

{content}
diff --git a/frontend/src/components/FindWineByFood.css b/frontend/src/components/FindWineByFood.css index 62e6d0e..9550e67 100644 --- a/frontend/src/components/FindWineByFood.css +++ b/frontend/src/components/FindWineByFood.css @@ -81,8 +81,15 @@ /* ---------- Page ---------- */ +/* The hero goes edge to edge, so
is full width; the content keeps --page-max */ main.food-page { + max-width: none; + padding: 0; +} + +.food-content { max-width: var(--page-max); + margin: 0 auto; display: grid; gap: 2rem; padding: 1.75rem 1rem 3rem; @@ -179,7 +186,8 @@ main.food-page { } .food-dishes button { - padding: 0.35rem 0.85rem; + min-height: 2.5rem; /* easy to hit with a finger */ + padding: 0.35rem 0.95rem; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-surface); @@ -193,6 +201,11 @@ main.food-page { border-color: var(--color-muted); } +/* The chosen dish has the accent colour as fill; a light ring keeps the focus ring visible next to it */ +.food-dishes button[aria-pressed='true']:focus-visible { + outline-color: var(--color-text); +} + .food-dishes button[aria-pressed='true'] { border-color: var(--color-accent); background: var(--color-accent); @@ -380,6 +393,13 @@ main.food-page { color: var(--color-muted); } +/* Small laptops and large tablets: a narrower text column, so the photo keeps some width */ +@media (max-width: 60rem) { + .food-hero-inner { + grid-template-columns: minmax(18rem, 1fr) minmax(0, 1.2fr); + } +} + @media (max-width: 48rem) { .food-hero-inner { grid-template-columns: 1fr; @@ -401,6 +421,55 @@ main.food-page { .food-hero-media::before { display: none; } + + .food-content { + gap: 1.5rem; + padding-top: 1.5rem; + } + + /* Phones: a lower photo, so the categories are reached sooner */ + .food-hero-media, + .food-hero-image { + min-height: 0; + height: 10rem; + } +} + +/* + * Phones: three smaller categories per row (three rows instead of five + * for the nine categories), and a smaller bottle in the cards. + */ +@media (max-width: 36rem) { + .food-categories { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + } + + .food-category { + padding: 0.35rem 0.35rem 0.5rem; + font-size: 0.85rem; + } + + .food-category-image, + .food-category-emoji { + height: 4rem; + } + + .food-card { + grid-template-columns: 4rem minmax(0, 1fr); + gap: 0.75rem; + padding: 1rem; + } + + .food-bottle-wrap { + width: 4rem; + min-height: 8rem; + } + + .food-bottle { + height: 8rem; + max-width: 3.75rem; + } } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/src/components/FindWineByFood.tsx b/frontend/src/components/FindWineByFood.tsx index 2760aab..edab5aa 100644 --- a/frontend/src/components/FindWineByFood.tsx +++ b/frontend/src/components/FindWineByFood.tsx @@ -2,7 +2,7 @@ import { useSearchParams } from 'react-router'; import { useQuery } from '@apollo/client/react'; import FoodWineCard from './FoodWineCard'; import Pagination from './Pagination'; -import foodPairingImage from '../assets/food/foodPairing.png'; +import foodPairingImage from '../assets/food/foodPairing.webp'; import { FOOD_CATEGORIES_QUERY, @@ -16,6 +16,7 @@ import { FOOD_PAGE_SIZE, foodCategoryOrder, } from '../data/food'; +import { usePageTitle } from '../hooks/usePageTitle'; import './FindWineByFood.css'; /** @@ -31,12 +32,20 @@ import './FindWineByFood.css'; * * The choices live in the URL (?category=Meat&food=Beef&page=2), so the back * button works and a search can be shared as a link. + * + * Accessibility: the categories and dishes are toggle buttons with + * aria-pressed, the dishes in a group named with aria-label, and the + * number of wines is announced (role="status") when the choice changes. + * The photos are decorative (alt=""), since the name is written under each. + * After a page change, focus moves to the results heading, so keyboard and + * screen reader users start at the top of the new page, as in the catalog. */ export default function FindWineByFood() { const [params, setParams] = useSearchParams(); const category = params.get('category') ?? DEFAULT_FOOD_CATEGORY; const food = params.get('food'); const page = Math.max(1, Number(params.get('page')) || 1); + usePageTitle('Find wine by food'); const categoriesQuery = useQuery(FOOD_CATEGORIES_QUERY); const { data, previousData, loading, error } = useQuery( @@ -79,14 +88,14 @@ export default function FindWineByFood() { const p = new URLSearchParams(params); p.set('page', String(n)); setParams(p); - document - .getElementById('food-results-heading') - ?.scrollIntoView({ block: 'start' }); + const heading = document.getElementById('food-results-heading'); + heading?.focus({ preventScroll: true }); + heading?.scrollIntoView({ block: 'start' }); } return ( - <> -
+
+

Explore

@@ -100,18 +109,26 @@ export default function FindWineByFood() {
- +
-
+ -
+

Choose a food category

{categoriesQuery.error && (

Could not load the food categories.

)} -
    + {/* role="list": list-style: none makes Safari stop announcing lists */} +
      {categories.map((c) => { const cInfo = FOOD_CATEGORY_INFO[c.name]; return ( @@ -181,7 +198,10 @@ export default function FindWineByFood() { aria-busy={loading} >
      -

      Best matches for {food ?? label}

      + {/* tabIndex={-1}: gets focus from code after a page change */} +

      + Best matches for {food ?? label} +

      {/* role="status" announces the new count when the choice changes */}

      {!shown @@ -197,7 +217,7 @@ export default function FindWineByFood() {

      No wines pair with this food yet.

      )} -
        +
          {wines.map((w) => (
        • @@ -231,7 +251,7 @@ export default function FindWineByFood() {
      )} -
- + +
); } diff --git a/frontend/src/components/FoodWineCard.tsx b/frontend/src/components/FoodWineCard.tsx index 7b62d4e..1ee5f1e 100644 --- a/frontend/src/components/FoodWineCard.tsx +++ b/frontend/src/components/FoodWineCard.tsx @@ -39,10 +39,8 @@ export default function FoodWineCard({ wine }: { wine: FoodWine }) {

{wine.winery}

-

- Pairs with - {wine.matchedFoods.join(', ')} -

+ {/* "Pairs with" is visible text, so it is clear to everyone what the chip means */} +

Pairs with: {wine.matchedFoods.join(', ')}

  • @@ -69,12 +67,21 @@ export default function FoodWineCard({ wine }: { wine: FoodWine }) { {wine.type} wine
  • - - {wine.avgRating != null - ? `${wine.avgRating.toFixed(1)} (${wine.ratingCount.toLocaleString('en')} ratings)` - : 'No ratings yet'} + {wine.avgRating != null ? ( + <> + {/* Read as "Rating 3.6 out of 5", see WineList */} + + ★{' '} + {wine.avgRating.toFixed(1)} + {' '} + ({wine.ratingCount.toLocaleString('en')} ratings) + + ) : ( + 'No ratings yet' + )}
diff --git a/frontend/src/components/HomePage.css b/frontend/src/components/HomePage.css index f45c73e..9188e98 100644 --- a/frontend/src/components/HomePage.css +++ b/frontend/src/components/HomePage.css @@ -247,3 +247,15 @@ main.home { display: none; } } + +/* + * Phones: the bands only need to be as tall as their text. The 30rem + * minimum filled more than a whole phone screen per band, so the user had + * to scroll a long way past empty photo to reach the next part. + */ +@media (max-width: 36rem) { + .home-band { + min-height: 0; + padding-block: 3rem; + } +} diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx index 69b98c3..fdff897 100644 --- a/frontend/src/components/HomePage.tsx +++ b/frontend/src/components/HomePage.tsx @@ -6,6 +6,7 @@ import whiteBottle from '../assets/home/bottle-white.webp'; import redBottle from '../assets/home/bottle-red.webp'; import roseBottle from '../assets/home/bottle-rose.webp'; import sparklingBottle from '../assets/home/bottle-sparkling.webp'; +import { usePageTitle } from '../hooks/usePageTitle'; import './HomePage.css'; /** @@ -21,14 +22,19 @@ import './HomePage.css'; * later. When it does, see the note in the food band below. * * Accessibility: - * - The page has one

(hidden on screen, the bands speak for - * themselves visually) and one

per band, so screen reader users can - * jump between the bands by heading. + * - The page has one visible

, "Welcome to Wine not?", styled as the + * small line above the first band's heading, and one

per band, so + * screen reader users can jump between the bands by heading. (Earlier + * the

was hidden text for screen readers only; we use visible text + * or aria-label instead of hidden helper elements.) * - Each band is a
named by its heading (aria-labelledby). * - The photos are decorative (alt=""): the text says where each band * leads. A dark veil behind the text keeps the white text at 4.5:1 or * more on any part of the photo. * - No divs: the text sits directly in the section, placed with CSS grid. + * - The search field is named by aria-label (the placeholder disappears + * when the user types, so it can't be the name), and the button by its + * visible text. * * Sustainability: the images are WebP, sized for the page (about 200 KB * for the whole page, against about 8 MB for the original PNGs), and the @@ -38,6 +44,7 @@ import './HomePage.css'; export default function HomePage() { const navigate = useNavigate(); const [search, setSearch] = useState(''); + usePageTitle(null); /** Opens the wine catalog with the search filled in. */ function handleSearch(event: FormEvent) { @@ -47,9 +54,7 @@ export default function HomePage() { } return ( -
-

Wine not? Explore, search and pair wines

- +
{/* The first image is visible right away, so it is not lazy-loaded */} -

Explore by taste

+

Welcome to Wine not?

Find wines that match your taste

See how wines compare on body and acidity, and pick one from the map. @@ -137,12 +142,10 @@ export default function HomePage() {

{/* role="search" makes this a search landmark for screen readers */}
- setSearch(event.target.value)} placeholder="For example Barolo" diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx index e2631b1..e947362 100644 --- a/frontend/src/components/LoginPage.tsx +++ b/frontend/src/components/LoginPage.tsx @@ -5,6 +5,7 @@ import { LOGIN_MUTATION, type LoginData } from '../graphql/queries'; import { validateLogin, type FieldErrors } from '../data/auth'; import { readAuthError, useCurrentUser, useLogIn } from '../auth/useAuth'; import TextField from './TextField'; +import { usePageTitle } from '../hooks/usePageTitle'; import './AuthPage.css'; /** @@ -36,6 +37,7 @@ export default function LoginPage() { const [login, { loading }] = useMutation(LOGIN_MUTATION); const logIn = useLogIn(); + usePageTitle('Log in'); if (user) return ; @@ -62,7 +64,7 @@ export default function LoginPage() { } return ( -
+

Log in

Welcome back to Wine not?

diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx index a976a05..a175886 100644 --- a/frontend/src/components/Pagination.tsx +++ b/frontend/src/components/Pagination.tsx @@ -21,6 +21,7 @@ type Props = { * - real -

Page {page}

+

+ Page {page} of {totalPages.toLocaleString('en')} +

+

+ {collapsible ? ( + + ) : ( + 'Filters' + )} +

+ {showFields && ( + + )} -
- - -
+ {showFields && ( +
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - set('minRating', Number(e.target.value))} - /> -
+
+ + set('minRating', Number(e.target.value))} + /> +
- + +
+ )} ); } diff --git a/frontend/src/components/TasteList.tsx b/frontend/src/components/TasteList.tsx new file mode 100644 index 0000000..e8fb112 --- /dev/null +++ b/frontend/src/components/TasteList.tsx @@ -0,0 +1,50 @@ +import type { TasteWine } from '../types/wine'; +import { wineTypeColor } from '../data/taste'; + +type Props = { + wines: TasteWine[]; + selectedId: string | null; + onSelect: (id: string) => void; +}; + +/** + * The same wines as on the taste map, as a plain list. + * + * The alternative to the map that the plan promised: for screen reader + * users, for anyone who finds small dots hard to see or hit, and on a + * phone, where the list is often quicker to read. Body and acidity, which + * the map shows by position, are written out as text here. + * + * Each wine is a button with aria-pressed, the same as the dots, so the + * chosen wine is shown in the panel next to it, just like from the map. + * The order is the one the backend gives: the most-rated wines first. + * The coloured dot is decorative; the type is in the text. + */ +export default function TasteList({ wines, selectedId, onSelect }: Props) { + return ( + // role="list": list-style: none makes Safari stop announcing lists +
    + {wines.map((wine) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/frontend/src/components/TasteMap.tsx b/frontend/src/components/TasteMap.tsx index 9ecc1f8..f0cc2ed 100644 --- a/frontend/src/components/TasteMap.tsx +++ b/frontend/src/components/TasteMap.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState, type KeyboardEvent } from 'react'; import type { TasteWine } from '../types/wine'; import { ACIDITY_LEVELS, @@ -11,17 +11,26 @@ type Props = { wines: TasteWine[]; selectedId: string | null; onSelect: (id: string) => void; + /** + * True on phones. The map is then drawn with a narrower, taller shape + * and the acidity labels in the bottom corners instead of beside the + * axis, so the text and the dots keep a usable size on a small screen. + */ + compact?: boolean; }; -// Drawing area inside the SVG's viewBox (0 0 620 430). -// The space outside it is used for the labels at the arrow ends. -const VIEW_W = 620; -const VIEW_H = 430; -const PLOT = { left: 78, right: 542, top: 36, bottom: 392 }; -const CELL_W = (PLOT.right - PLOT.left) / ACIDITY_LEVELS.length; -const CELL_H = (PLOT.bottom - PLOT.top) / BODY_LEVELS.length; -const MID_X = (PLOT.left + PLOT.right) / 2; -const MID_Y = (PLOT.top + PLOT.bottom) / 2; +/** + * The size of the drawing (the SVG's viewBox) and the area inside it + * where the dots go. The space outside that area holds the labels at the + * arrow ends. The SVG scales to the width of the screen, so on a phone + * the wide desktop shape shrank the text to about 7 px; the compact shape + * is close to the real size on a phone, so 13 px text stays about 12 px. + */ +const LAYOUTS = { + wide: { w: 620, h: 430, left: 78, right: 542, top: 36, bottom: 392 }, + compact: { w: 360, h: 420, left: 14, right: 346, top: 30, bottom: 370 }, +}; +type Layout = (typeof LAYOUTS)['wide']; /** * A number between 0 and 1 that is always the same for the same wine id. @@ -43,39 +52,131 @@ function stableRandom(id: string, salt: number): number { * spread out inside it. Without the spreading, all wines with the same * levels would lie on top of each other in one point. */ -function dotPosition(wine: TasteWine) { +function dotPosition(wine: TasteWine, layout: Layout) { + const cellW = (layout.right - layout.left) / ACIDITY_LEVELS.length; + const cellH = (layout.bottom - layout.top) / BODY_LEVELS.length; const a = ACIDITY_LEVELS.indexOf(wine.acidity); const b = BODY_LEVELS.indexOf(wine.body); - const centerX = PLOT.left + CELL_W * (a + 0.5); - const centerY = PLOT.bottom - CELL_H * (b + 0.5); + const centerX = layout.left + cellW * (a + 0.5); + const centerY = layout.bottom - cellH * (b + 0.5); return { - x: centerX + (stableRandom(wine.id, 1) - 0.5) * (CELL_W - 20), - y: centerY + (stableRandom(wine.id, 2) - 0.5) * (CELL_H - 14), + x: centerX + (stableRandom(wine.id, 1) - 0.5) * (cellW - 20), + y: centerY + (stableRandom(wine.id, 2) - 0.5) * (cellH - 14), }; } +/** Which way each arrow key moves on the map, as x and y steps. */ +const DIRECTIONS: Record = { + ArrowRight: { x: 1, y: 0 }, + ArrowLeft: { x: -1, y: 0 }, + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, +}; + +/** + * The dot closest to `from` in the direction of an arrow key, or null at + * the edge of the map. Distance across the direction counts double, so + * ArrowRight prefers a dot straight to the right over one far up or down. + */ +function nextInDirection( + from: { x: number; y: number }, + direction: { x: number; y: number }, + dots: { id: string; x: number; y: number }[], +): string | null { + let best: string | null = null; + let bestScore = Infinity; + for (const dot of dots) { + const dx = dot.x - from.x; + const dy = dot.y - from.y; + const along = dx * direction.x + dy * direction.y; + if (along <= 0.5) continue; // behind, or at the same spot + const across = Math.abs(dx * direction.y - dy * direction.x); + const score = along + 2 * across; + if (score < bestScore) { + bestScore = score; + best = dot.id; + } + } + return best; +} + /** * The taste map: wines plotted by acidity (left to right) and body * (bottom to top), coloured by wine type. * - * Display only: the wines come in as props, and a click is sent up with + * Display only: the wines come in as props, and a choice is sent up with * onSelect. Hovering or focusing a dot shows a small box with the wine's * name, region and country. * - * Accessibility: every dot is a focusable button with a full description - * (name, type, body, acidity), so the map works with keyboard and screen - * reader. The legend shows wine type as text, not only as colour. + * Accessibility: + * - Every dot is a button with a full name (name, type, body, acidity) + * and aria-pressed for the chosen wine, so the map works with a screen + * reader. The wine type is never shown by colour alone: it is in the + * name, and the legend writes it out. + * - Keyboard: the map is one Tab stop, not one per dot (up to 100 stops + * would make it very slow to get past). The arrow keys move to the + * nearest dot in that direction, Home and End go to the first and last + * wine, and Enter or Space chooses the wine. This is the "roving + * tabindex" pattern: only one dot has tabIndex 0 at a time. + * - The same wines can be shown as a plain list instead (TasteList), for + * anyone who finds the map hard to use. */ -export default function TasteMap({ wines, selectedId, onSelect }: Props) { +export default function TasteMap({ + wines, + selectedId, + onSelect, + compact = false, +}: Props) { + const layout = compact ? LAYOUTS.compact : LAYOUTS.wide; + const midX = (layout.left + layout.right) / 2; + const midY = (layout.top + layout.bottom) / 2; + const [hovered, setHovered] = useState(null); - const hoveredPos = hovered ? dotPosition(hovered) : null; + // The dot that can be reached with Tab. Follows the arrow keys. + const [activeId, setActiveId] = useState(null); + const dotRefs = useRef(new Map()); + + const dots = wines.map((wine) => ({ + wine, + id: wine.id, + ...dotPosition(wine, layout), + })); + const hoveredDot = hovered ? dots.find((d) => d.id === hovered.id) : null; + + // The Tab stop: the dot moved to last, else the chosen wine, else the first + const tabStopId = + [activeId, selectedId].find((id) => dots.some((d) => d.id === id)) ?? + dots[0]?.id; + + function moveTo(id: string | null | undefined) { + if (!id) return; + setActiveId(id); + dotRefs.current.get(id)?.focus(); + } + + function handleKeyDown(event: KeyboardEvent, dot: (typeof dots)[number]) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(dot.id); + } else if (event.key in DIRECTIONS) { + // preventDefault: the arrow keys should not scroll the page as well + event.preventDefault(); + moveTo(nextInDirection(dot, DIRECTIONS[event.key], dots)); + } else if (event.key === 'Home') { + event.preventDefault(); + moveTo(dots[0]?.id); + } else if (event.key === 'End') { + event.preventDefault(); + moveTo(dots.at(-1)?.id); + } + } return (
{/* Labels at the arrow ends. aria-hidden: the aria-label above says the same. */} - {wines.map((wine) => { - const { x, y } = dotPosition(wine); + {dots.map((dot) => { + const { wine, x, y } = dot; const selected = wine.id === selectedId; return ( { + if (element) dotRefs.current.set(wine.id, element); + else dotRefs.current.delete(wine.id); + }} className={selected ? 'taste-dot is-selected' : 'taste-dot'} role="button" - tabIndex={0} + tabIndex={wine.id === tabStopId ? 0 : -1} aria-pressed={selected} aria-label={`${wine.name}, ${wine.type}, ${wine.body}, ${wine.acidity.toLowerCase()} acidity`} - onClick={() => onSelect(wine.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelect(wine.id); - } + onClick={() => { + setActiveId(wine.id); + onSelect(wine.id); }} + onKeyDown={(event) => handleKeyDown(event, dot)} onMouseEnter={() => setHovered(wine)} onMouseLeave={() => setHovered(null)} onFocus={() => setHovered(wine)} @@ -171,14 +289,16 @@ export default function TasteMap({ wines, selectedId, onSelect }: Props) { })} - {hovered && hoveredPos && ( + {hovered && hoveredDot && ( diff --git a/frontend/src/components/WineCatalog.css b/frontend/src/components/WineCatalog.css index bf0633c..117f671 100644 --- a/frontend/src/components/WineCatalog.css +++ b/frontend/src/components/WineCatalog.css @@ -357,8 +357,47 @@ color: var(--color-muted); } -/* Phones: the panels fill the width, and the filters are in one column */ +/* Tablets: two columns of filters, so the labels and choices fit */ +@media (max-width: 48rem) { + .catalog-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* + * Phones: Filter and Sort share the full width under the search field, + * so they are big and easy to hit with a thumb, and the panels fill the + * width. The filter panel can be taller than the screen, so it scrolls + * inside itself and "Show wines" can always be reached. + */ @media (max-width: 36rem) { + .catalog-actions { + width: 100%; + margin-left: 0; + } + + /* The two .catalog-popover wrappers share the row, each as wide as it needs */ + .catalog-actions > * { + flex: 1 1 auto; + min-width: 0; + } + + .catalog-button { + justify-content: center; + gap: 0.4rem; + width: 100%; + padding: 0 0.75rem; + white-space: nowrap; + } + + /* A long sort name ("Alcohol (highest first)") is cut off with … */ + .catalog-button-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .catalog-panel, .catalog-sort-menu { left: 0; @@ -366,7 +405,20 @@ width: auto; } + .catalog-panel { + max-height: calc(100dvh - 6rem); + overflow-y: auto; + } + + #catalog-filter-panel { + padding: 1.25rem 1rem; + } + .catalog-filter-grid { grid-template-columns: 1fr; } + + .wine-search input { + font-size: 1.15rem; + } } diff --git a/frontend/src/components/WineCatalog.tsx b/frontend/src/components/WineCatalog.tsx index 613bb75..838595a 100644 --- a/frontend/src/components/WineCatalog.tsx +++ b/frontend/src/components/WineCatalog.tsx @@ -248,26 +248,7 @@ export default function WineCatalog() { regions.data?.regions, ); - // role="status" and role="alert" make screen readers announce the messages. - // The page still gets its

while loading or after an error. - if (error) { - return ( - <> -

{HEADING}

-

Could not load wines. Please try again later.

- - ); - } - if (!shown) { - return ( - <> -

{HEADING}

-

Loading wines …

- - ); - } - - const totalCount = shown.wines.totalCount; + const totalCount = shown?.wines.totalCount ?? 0; const totalPages = Math.ceil(totalCount / PAGE_SIZE); return ( @@ -333,18 +314,33 @@ export default function WineCatalog() { )} - {/* role="status" announces the new number when filters change */} + {/* + role="status" announces "Loading wines …" and then the new number + when filters change, and role="alert" announces an error. The + search field and its

above are always shown, also while + loading, so they stay the same elements: focus is not lost when + the wines arrive. + */}

- {totalCount.toLocaleString('en')} {totalCount === 1 ? 'wine' : 'wines'} + {shown + ? `${totalCount.toLocaleString('en')} ${totalCount === 1 ? 'wine' : 'wines'}` + : !error && 'Loading wines …'}

+ {error && ( +

Could not load wines. Please try again later.

+ )} - - + {shown && ( + <> + + + + )}

); } diff --git a/frontend/src/components/WineDetailsPage.css b/frontend/src/components/WineDetailsPage.css index d717fea..8e8a534 100644 --- a/frontend/src/components/WineDetailsPage.css +++ b/frontend/src/components/WineDetailsPage.css @@ -433,7 +433,7 @@ main.wine-details-page { font-size: 1.35rem; } -/* Food pairings as small pills */ +/* Food pairings as small pills. The links are about 36 px high (WCAG 2.5.8 asks for at least 24 px). */ .wine-details-tags { display: flex; flex-wrap: wrap; @@ -457,7 +457,7 @@ main.wine-details-page { .wine-details-tags a { display: inline-block; - padding: 0.3rem 0.75rem; + padding: 0.4rem 0.85rem; border-radius: 999px; color: var(--color-accent); font-weight: 600; @@ -636,8 +636,9 @@ main.wine-details-page { border-left: 0; } + /* Two cards side by side on a tablet, one on a phone */ .dna-similar-list { - grid-template-columns: minmax(0, 1fr); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); } } @@ -655,8 +656,18 @@ main.wine-details-page { .dna-card { grid-template-columns: minmax(0, 1fr); + gap: 1.5rem; padding: 1.25rem; } + + /* Less of the photo band, so the wine itself is higher up on the screen */ + .dna-hero-inner { + padding-bottom: 5.5rem; + } + + .dna-similar-inner { + padding-block: 2rem 2.5rem; + } } @media (max-width: 36rem) { diff --git a/frontend/src/components/WineDetailsPage.tsx b/frontend/src/components/WineDetailsPage.tsx index 372aa1d..59d75f1 100644 --- a/frontend/src/components/WineDetailsPage.tsx +++ b/frontend/src/components/WineDetailsPage.tsx @@ -11,6 +11,7 @@ import type { WineDetails } from '../types/wine'; import { ACIDITY_LEVELS, BODY_LEVELS } from '../data/taste'; import WineTypeImage from './WineTypeImage'; import FavoriteButton from './FavoriteButton'; +import { usePageTitle } from '../hooks/usePageTitle'; import './WineDetailsPage.css'; /** @@ -35,7 +36,11 @@ import './WineDetailsPage.css'; * so the information never depends on the bar alone. * - Icons are decorative (aria-hidden); the text next to them says it all. * - The

gets focus when the wine has loaded, so screen reader and - * keyboard users start at the top of the new page. + * keyboard users start at the top of the new page, and the browser tab + * gets the wine's name as its title. + * - Ratings are read as "Rating 4.1 out of 5" (role="img" with an + * aria-label on the star and the number). + * - The photo band is inside
, so all content is in a landmark. * - Loading is announced with role="status", errors with role="alert". */ export default function WineDetailsPage() { @@ -44,10 +49,14 @@ export default function WineDetailsPage() { WINE_DETAILS_QUERY, { variables: { id }, skip: !id }, ); + usePageTitle( + data?.wine?.name ?? + (loading ? 'Loading wine' : error ? 'Error' : 'Wine not found'), + ); if (loading) { return ( -
+

Loading wine …

); @@ -55,7 +64,7 @@ export default function WineDetailsPage() { if (error) { return ( -
+

Something went wrong

Could not load this wine. Please try again later.

Go to all wines @@ -65,7 +74,7 @@ export default function WineDetailsPage() { if (!data?.wine) { return ( -
+

Wine not found

There is no wine with this address. The link may be wrong.

Go to all wines @@ -74,21 +83,19 @@ export default function WineDetailsPage() { } return ( - <> +
-
- - } - /> - -
- + + } + /> + +
); } @@ -176,6 +183,19 @@ const ICONS = { ), }; +/** + * A rating shown as "★ 4.1". role="img" with an aria-label makes screen + * readers read it as one thing, "Rating 4.1 out of 5", instead of a star + * symbol and a number without a scale. + */ +function Rating({ value }: { value: number }) { + return ( + + ★ {value.toFixed(1)} + + ); +} + function Icon({ name }: { name: keyof typeof ICONS }) { return ( (null); - // Move focus to the heading and show the wine's name in the browser tab + // Move focus to the heading when a wine has loaded. (The page title is + // set by WineDetailsPage, so this component stays display only.) useEffect(() => { headingRef.current?.focus(); - const previousTitle = document.title; - document.title = `${wine.name} | Wine not?`; - return () => { - document.title = previousTitle; - }; - }, [wine.id, wine.name]); + }, [wine.id]); const bodyStep = wine.body ? BODY_LEVELS.indexOf(wine.body) + 1 : 0; const acidityStep = wine.acidity @@ -262,13 +278,9 @@ export function WineDetailsContent({

- {' '} {wine.avgRating != null ? ( <> - {wine.avgRating.toFixed(1)} - out of 5{' '} + {' '} ({wine.ratingCount.toLocaleString('en')} ratings) @@ -526,14 +538,8 @@ function SimilarWines({ wine }: { wine: WineDetails }) {

- {' '} {other.avgRating != null ? ( - <> - {other.avgRating.toFixed(1)} - out of 5 - + ) : ( 'No ratings yet' )} diff --git a/frontend/src/components/WineFilters.tsx b/frontend/src/components/WineFilters.tsx index 5db8f92..4b0bd04 100644 --- a/frontend/src/components/WineFilters.tsx +++ b/frontend/src/components/WineFilters.tsx @@ -41,8 +41,10 @@ type Props = { * no extra request. * *

and tell screen readers that the dropdowns belong - * together, and every has its own