Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<span>`- eller `<div>`-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 (`<button>`, `<input>`, `<ul>`, `<section>`, `<svg role="group">`). `aria-label` på en vanlig `<span>` eller `<div>` uten rolle blir ignorert av skjermlesere, så for vurderinger («★ 4.1») bruker vi `role="img"` med `aria-label="Rating 4.1 out of 5"`. Noen steder har vi heller gjort teksten synlig for alle, for eksempel «Pairs with: Pasta» på Find wine by food og «3 fields need fixing.» på registreringssiden.

| Område | Slik har vi løst det |
|---|---|
| Semantisk HTML | Hver side har én `<main id="main-content">` og én `<h1>`, og alt innhold ligger i et landemerke (også bildebåndene øverst). Lister er `<ul>`, egenskaper er `<dl>`, kort er `<article>`, og alt som kan klikkes er `<button>` eller `<a>`. |
| Sidetitler | Hver side setter sin egen tittel i fanen, for eksempel «Explore by taste \| Wine not?» (`usePageTitle`), så skjermlesere sier hvilken side som åpnet. |
| Tastatur | «Skip to main content» er første lenke på hver side. Ved bytte av side flyttes fokus til sidens `<h1>` og siden starter på toppen (`App.tsx`); Tilbake-knappen i nettleseren lar posisjonen være. Filter- og sorteringspanelet lukkes med Escape, og fokus går tilbake til knappen. |
| Smakskartet | Kartet er ett Tab-stopp i stedet for opptil 100. Piltastene flytter til nærmeste vin i den retningen, Home/End til første og siste, og Enter/mellomrom velger vinen («roving tabindex»). En synlig tekst under kartet forklarer dette. |
| Alternativ til kartet | «Map / List»-bryteren viser de samme vinene som en liste med knapper, der fylde og syre står som tekst. Listen er et alternativ for skjermlesere, for dem som synes små prikker er vanskelige å se eller treffe, og på mobil. |
| Fokusmarkering | Tydelig ring (3 px, vinrød) på alt med `:focus-visible`, hvit der bakgrunnen er mørk. Kortene får ringen rundt hele kortet. |
| Skjemaer | Alle felt har en synlig `<label>`. Feil kobles til feltet med `aria-describedby` og `aria-invalid`, og glidebryteren for vurdering har `aria-valuetext` («3.5 or higher»). |
| Kontrast | Tekst har minst 4,5:1 og kanter på skjemafelt minst 3:1 (WCAG 1.4.3 og 1.4.11), i begge moduser. Vintype vises aldri bare med farge; den står også som tekst. |
| Trykkflater | Knapper, lenker i menyen, nedtrekkslister og avkrysningsbokser er minst 44 px høye, så de er lette å treffe med en finger. |
| Bevegelse | Brukere som har slått på «redusert bevegelse» i operativsystemet får ingen animasjoner eller myk rulling (`prefers-reduced-motion`). |

### Tilpasning til mobil og nettbrett

Alle sidene er testet på 320, 390, 768, 1024 og 1440 px bredde, uten vannrett rulling. Vi bruker vanlige CSS media queries og `min()` i rutenett, ingen ekstra bibliotek.

- **Toppmenyen** har logo, mørk modus og konto på første linje og menyen på egen linje under på smale skjermer, i stedet for tre linjer. Menyen er ikke gjemt bak en hamburgerknapp, siden den bare har tre–fire lenker. Lange brukernavn kuttes med «…».
- **Vinkatalogen** viser kompakte kort på mobil, med flasken til venstre og fakta på én linje, så siden med tolv viner er halvparten så lang som før. «Filter wines» og «Sort» deler bredden, og filterpanelet ruller inni seg selv, så «Show wines» alltid kan nås.
- **Explore by taste** har tre kolonner på skjerm, filtrene i en rad over kartet på nettbrett, og én kolonne på mobil. På mobil er filtrene skjult bak en «Filters»-knapp (`aria-expanded`), kartet tegnes i en form som passer skjermen så teksten ikke blir mikroskopisk, og valgt vin rulles inn i bildet. Komponenten bruker `useMediaQuery` der markupen må endres, ikke bare layouten.
- **Find wine by food** viser tre matkategorier per rad og mindre flasker på mobil, og et lavere bilde øverst.
- **Detaljsiden (Wine DNA)** viser «Wines like this» i to kolonner på nettbrett og én på mobil.

Vi planlegger blant annet å:
- bruke semantiske HTML-elementer
- støtte tastaturnavigasjon
- bruke labels på skjemaelementer
- ha tydelig fokusmarkering
- sikre god fargekontrast
- bruke alternativ tekst på relevante bilder og illustrasjoner
- tilby en listevisning som alternativ til den visuelle **Explore by taste**-visningen på hjem-siden.
- sørge for at interaktive elementer kan brukes uten mus
Bildene er også gjort mye mindre, som betyr mest på mobilnett: flaskebildene var PNG på 1,4 MB hver og er nå WebP på rundt 12 kB, logoen gikk fra 459 kB til 9 kB og bildet på Find wine by food fra 2 MB til 69 kB. Flaskebildene lastes først når de rulles inn i bildet (`loading="lazy"`).

### Mørk modus

Expand All @@ -495,7 +510,7 @@ Dette kan blant annet innebære:
- kun hente data som faktisk skal vises
- bruke kontrollert søk eller debouncing
- bruke pagination eller dynamisk lasting
- optimalisere bilder
- optimalisere bilder (flaskebildene, logoen og bildet på Find wine by food er WebP i riktig størrelse, se «Tilpasning til mobil og nettbrett»)
- unngå unødvendig tunge biblioteker
- gjenbruke komponenter

Expand Down
70 changes: 59 additions & 11 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Route, Routes } from 'react-router';
import { useEffect, useRef } from 'react';
import { Route, Routes, useLocation, useNavigationType } from 'react-router';
import SiteHeader from './components/SiteHeader';
import SiteFooter from './components/SiteFooter';
import HomePage from './components/HomePage';
Expand All @@ -9,6 +10,51 @@ import WineDetailsPage from './components/WineDetailsPage';
import LoginPage from './components/LoginPage';
import RegisterPage from './components/RegisterPage';
import FavoritesPage from './components/FavoritesPage';
import { usePageTitle } from './hooks/usePageTitle';

/**
* When the user goes to a new page with a link, start at the top of it:
* scroll up, and move focus to the page's <h1>.
*
* In an app like this the browser never loads a new page, so without this
* a screen reader would not notice that the page changed, keyboard focus
* would stay on a link that is gone, and on a phone a wine opened from
* the bottom of the list would open scrolled halfway down.
*
* Back and Forward (navigation type POP) are left alone, so the user gets
* back to where they were in the list. Only the path counts: a new search
* or filter in the URL is the same page and keeps focus in the form.
* The wine page focuses its own <h1> once the wine has loaded.
*/
function useStartAtTopOnNewPage() {
const { pathname } = useLocation();
const navigationType = useNavigationType();
const previousPath = useRef(pathname);

useEffect(() => {
if (pathname === previousPath.current) return;
previousPath.current = pathname;
if (navigationType === 'POP') return;

window.scrollTo(0, 0);
const heading = document.querySelector<HTMLElement>('main h1');
if (heading) {
heading.tabIndex = -1; // can get focus from code, but is no Tab stop
heading.focus({ preventScroll: true });
}
}, [pathname, navigationType]);
}

/** The /wines route: <main> and title here, the search and list in WineCatalog. */
function WinesPage() {
usePageTitle('Wines');
return (
<main id="main-content" tabIndex={-1}>
{/* The <h1> is the search field's label, inside WineCatalog */}
<WineCatalog />
</main>
);
}

/**
* Root component for Wine not?
Expand All @@ -23,25 +69,27 @@ import FavoritesPage from './components/FavoritesPage';
* - /register create an account
* - /favorites the logged-in user's favourites
*
* Each page has its own <main> and one <h1>, so the heading hierarchy is
* Each page has its own <main id="main-content">, one <h1> and its own
* title in the browser tab (usePageTitle), so the heading hierarchy is
* correct on every page.
*
* The "Skip to main content" link comes first on every page (WCAG 2.4.1).
* It jumps to #main-content; tabIndex={-1} on each <main> lets it take the
* focus, so the next Tab continues inside the page, not in the menu.
*/
export default function App() {
useStartAtTopOnNewPage();

return (
<>
<a className="skip-link" href="#main-content">
Skip to main content
</a>
<SiteHeader />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/explore" element={<ExploreByTaste />} />
<Route
path="/wines"
element={
<main>
{/* The <h1> is the search field's label, inside WineCatalog */}
<WineCatalog />
</main>
}
/>
<Route path="/wines" element={<WinesPage />} />
<Route path="/food" element={<FindWineByFood />} />
<Route path="/wines/:id" element={<WineDetailsPage />} />
<Route path="/login" element={<LoginPage />} />
Expand Down
Binary file removed frontend/src/assets/food/foodPairing.png
Binary file not shown.
Binary file added frontend/src/assets/food/foodPairing.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed frontend/src/assets/logo.png
Binary file not shown.
Binary file added frontend/src/assets/logo.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed frontend/src/assets/wines/dessertWine.png
Binary file not shown.
Binary file added frontend/src/assets/wines/dessertWine.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed frontend/src/assets/wines/portWine.png
Binary file not shown.
Binary file added frontend/src/assets/wines/portWine.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed frontend/src/assets/wines/redWine.png
Binary file not shown.
Binary file added frontend/src/assets/wines/redWine.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed frontend/src/assets/wines/roseWine.png
Binary file not shown.
Binary file added frontend/src/assets/wines/roseWine.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed frontend/src/assets/wines/sparklingWine.png
Binary file not shown.
Binary file added frontend/src/assets/wines/sparklingWine.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed frontend/src/assets/wines/whiteWine.png
Binary file not shown.
Binary file added frontend/src/assets/wines/whiteWine.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 8 additions & 4 deletions frontend/src/components/AccountMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,16 @@ export default function AccountMenu() {

return (
<p className="site-account">
<span>
<span className="sr-only">Logged in as </span>
<strong>{user.username}</strong>
</span>
{/* title: the full name as a tooltip, since CSS may cut a long one short */}
<strong title={user.username}>{user.username}</strong>
{/*
aria-label says who is logged in, so screen readers get the context
the eye gets from the name next to the button. It starts with the
visible text "Log out", so voice control ("click Log out") still works.
*/}
<button
type="button"
aria-label={`Log out, logged in as ${user.username}`}
onClick={() => {
focusLoginLink.current = true;
logOut();
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/components/AuthPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@
background: var(--color-surface);
}

/* "2 fields need fixing." Takes no space while it is empty. */
.auth-form-summary {
margin: 0;
color: var(--color-accent);
font-weight: 600;
}

.auth-form-summary:empty {
margin-top: -1.1rem; /* cancels the form's gap, so empty means no space */
}

.auth-form-error {
margin: 0;
padding: 0.75rem 1rem;
Expand Down Expand Up @@ -76,7 +87,8 @@
.text-field-toggle {
min-height: 44px;
padding: 0 0.9rem;
border: 1px solid var(--color-border);
/* --color-border-strong: the button's edge has 3:1 contrast, like the input */
border: 1px solid var(--color-border-strong);
border-radius: 0.5rem;
background: var(--color-panel);
color: var(--color-text);
Expand Down
Loading