The scaffolding is in place: the project starts up, the linter doesn't complain, and json-server responds. But there's nothing to look at yet. This lesson builds the whole inside of CicloUrbano: the design system, the application shell, and the eight screens, all navigable and with a finished look.
And it does so under a deliberate constraint: static data, no touching the network. The reason isn't pedagogical, it's methodological. An interface you can already click through gets validated in twenty minutes with the people who'll use it, and the changes that come out of that review — and they always do — cost a fraction here of what they'd cost with query hooks, mutations, and invalidations already wired on top. Separating "what it looks like" from "where the data comes from" is what makes 11-03 a lesson about wiring things up, not one about rebuilding them.
There's a second idea governing everything that follows: the empty, loading, and error states are designed now, not when they show up. Most ugly interfaces in this world are interfaces that were only ever designed for the happy path. Here, every screen is written with all four states from the very first moment, even though for now three of them are triggered with a hand-set variable.
Contents
- What gets built here and what's left for 11-03
- The design system:
index.css - Light and dark theme with
data-theme - The base components and their prop contract
Layout: the application shell- Navigation with
NavLinkand breadcrumbs - Structural accessibility: landmarks and skip-to-content
- Sample data while there's no network
- The four states of every screen
- Screen 1: the catalogue
- Screen 2: a bike's detail page
- Screens 3 and 4: stations and tabbed detail
- Screen 5: my bookings
- Screen 6: new booking
- Screens 7 and 8: sign-in and workshop
- The error screens
- Responsive design with CSS Modules
- The per-screen accessibility checklist
- Walkthrough review of the finished interface
- What gets built here and what's left for 11-03
| Built now | Left for 11-03 |
|---|---|
| Design variables, reset, and theme | — |
Base components (Button, Field, Panel, Label, Modal) |
— |
Layout, Header, Breadcrumbs, Footer |
— |
| The eight screens with their full composition | — |
| Empty, loading, and error states laid out | Getting activated with real data |
| Forms with their accessible markup | Connected validation and submission |
Data imported from src/data/domain.js |
fetch, TanStack Query, Redux |
Local interaction (useState for tabs and modals) |
The filter in the URL, the real session |
One practical consequence: in this lesson ProtectedRoute lets everyone through. The component is written, placed in the route tree, and returns <Outlet /> without checking anything, with a comment flagging it. That way you can navigate to /reservas and /taller to review how they look, and in 11-03 all that's left is to fill in the condition.
- The design system:
index.css
index.cssEverything visual in the project comes from here. One file, a handful of variables, and no color decision made twice.
/* src/index.css */
/* ---------- 1. Minimal reset ---------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img,
picture,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
h1, h2, h3, h4 {
line-height: 1.2;
text-wrap: balance;
}
/* ---------- 2. Design variables ---------- */
:root {
/* Color */
--color-brand: #12805c;
--color-brand-dark: #0d6247;
--color-rented: #b45309;
--color-maintenance: #9b1c1c;
--color-bg: #f5f7fa;
--color-surface: #ffffff;
--color-text: #1f2933;
--color-text-soft: #52606d;
--color-border: #d9e2ec;
--color-focus: #2563eb;
/* Shape */
--radius: 8px;
--shadow: 0 1px 3px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-elevated: 0 10px 25px rgba(15, 23, 42, 0.15);
/* Spacing: a scale, not loose numbers */
--space: 1rem;
--space-xs: 0.25rem;
--space-s: 0.5rem;
--space-l: 1.5rem;
--space-xl: 2.5rem;
/* Typography */
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--text-s: 0.875rem;
--text-m: 1rem;
--text-l: 1.25rem;
--text-xl: 1.75rem;
/* Other */
--max-width: 72rem;
--transition: 150ms ease;
}
/* ---------- 3. Base ---------- */
body {
font-family: var(--font);
background-color: var(--color-bg);
color: var(--color-text);
}
/* Focus always visible. Never outline: none without a replacement */
:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
border-radius: 2px;
}
/* Respect for those who ask for less motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Three decisions worth reasoning through, because they apply to every component from here on:
- The spacing scale has five values and not one more. As soon as a
padding: 13pxgets allowed because "it looked better," the system stops existing and every screen ends up with its own visual rhythm. If a value doesn't fit, adjust the scale, not the component. :focus-visiblein the reset, with its own style. The browser's ring gets lost the moment you change a button's colors; defining it once here guarantees that no part of the application can end up without a focus indicator, which is the most frequent and most serious accessibility failure in applications like this one (03-06).prefers-reduced-motionfrom day one. It costs six lines, and it keeps transitions from causing real discomfort to anyone who has turned them off at the system level.
- Light and dark theme with
data-theme
data-themeThe theme is implemented with an attribute on the root element and a redefinition of variables. Not a single component rule changes.
/* src/index.css — continued */
:root[data-theme='dark'] {
--color-bg: #131a22;
--color-surface: #1b242f;
--color-text: #e4ecf3;
--color-text-soft: #9aa5b1;
--color-border: #2c3846;
--color-brand: #1fa87a; /* brighter: needs more contrast against a dark background */
--color-rented: #d97706;
--color-maintenance: #ef4444;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-elevated: 0 10px 25px rgba(0, 0, 0, 0.5);
}The provider, which in this lesson only keeps the value in memory and in localStorage:
// src/contexts/ThemeProvider.jsx
import { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage.js';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useLocalStorage('ciclourbano:tema', 'light');
// DOM sync effect: the attribute lives outside React
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
const toggleTheme = useCallback(() => {
setTheme((current) => (current === 'light' ? 'dark' : 'light'));
}, [setTheme]);
// stabilized value: without this, every render of the provider repaints ALL consumers
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme must be used within a ThemeProvider.');
}
return context;
}Two details that were already explained and get applied here without debate: the memoized value from 07-02, which keeps the whole tree from repainting whenever the provider re-renders for any reason; and the guard inside the hook, which turns the silent failure of using useTheme outside the provider — where the context would be null and blow up with an incomprehensible message — into an explicit error that says exactly what's missing.
- The base components and their prop contract
Five components. Everything else gets built on top of them. Their value isn't in the code, which is trivial, but in the fact that they exist and get used: without them, in two weeks there are seven different buttons and none of them look alike.
First, the utility that combines classes, already familiar from module 2:
// src/utils/classNames.js
export function cx(...values) {
return values.filter(Boolean).join(' ');
}Button
// src/components/base/Button.jsx
import { cx } from '../../utils/classNames.js';
import styles from './Button.module.css';
function Button({
variant = 'primary',
size = 'medium',
type = 'button',
loading = false,
fullWidth = false,
children,
className,
...rest
}) {
return (
<button
type={type}
className={cx(
styles.button,
styles[variant],
styles[size],
fullWidth && styles.fullWidth,
className
)}
aria-busy={loading || undefined}
disabled={loading || rest.disabled}
{...rest}
>
{loading ? 'One moment…' : children}
</button>
);
}
export default Button;| Prop | Type | Default | What it's for |
|---|---|---|---|
variant |
'primary' | 'secondary' | 'danger' | 'text' |
'primary' |
Visual hierarchy of the action |
size |
'small' | 'medium' | 'large' |
'medium' |
Density depending on context |
type |
'button' | 'submit' |
'button' |
Defaults to button: avoids accidental submits |
loading |
boolean |
false |
Disables and marks aria-busy |
fullWidth |
boolean |
false |
Takes up the full width (mobile, forms) |
...rest |
— | — | onClick, disabled, aria-*, data-testid |
Three contract decisions worth explaining:
type="button"by default. HTML's default value issubmit, and that detail causes one of the hardest bugs to diagnose in web development: a secondary button inside a form that submits it when clicked. Flipping the default and requiring an explicittype="submit"removes it at the root.{...rest}at the end. It lets any native attribute be passed through without expanding the contract, and coming afterclassNamelets whoever uses it override whatever they need. It's composition, not configuration (04-02).loadingdisables in addition to indicating. A button that says "One moment…" but can still be clicked produces duplicate bookings. The visual state and the functional one aren't kept separate.
Field
The component that guarantees no control can exist without a label anywhere in the application.
// src/components/base/Field.jsx
import { useId } from 'react';
import { cx } from '../../utils/classNames.js';
import styles from './Field.module.css';
function Field({ label, error, hint, required = false, children }) {
const id = useId();
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
// Described by the hint and, if there is one, by the error
const describedBy = cx(hint && hintId, error && errorId) || undefined;
return (
<div className={cx(styles.field, error && styles.withError)}>
<label className={styles.label} htmlFor={id}>
{label}
{required && (
<span className={styles.required} aria-hidden="true">
{' '}*
</span>
)}
</label>
{hint && (
<p className={styles.hint} id={hintId}>
{hint}
</p>
)}
{/* The control is received as a function so id and aria-* can be injected into it */}
{children({
id,
'aria-invalid': error ? true : undefined,
'aria-describedby': describedBy,
'aria-required': required || undefined
})}
{error && (
<p className={styles.error} id={errorId} role="alert">
{error}
</p>
)}
</div>
);
}
export default Field;// Usage
<Field label="Duration (hours)" required error={errors.hours} hint="Between 1 and 24 hours">
{(props) => (
<input {...props} type="number" min="1" max="24" value={hours} onChange={handleHours} />
)}
</Field>The children-as-a-function pattern (04-02) solves a real problem here: Field needs to generate a unique id with useId and pass it to the control for the htmlFor, but it doesn't know whether the control is an input, a select, or a textarea. By passing the computed props to a function, whoever uses it decides the element, and Field guarantees the accessible wiring. The result is that it's easier to write an accessible field than an inaccessible one, which is the only way accessibility survives when people are in a hurry.
Panel, Label, and Modal
// src/components/base/Panel.jsx
import { cx } from '../../utils/classNames.js';
import styles from './Panel.module.css';
function Panel({ title, level = 2, actions, children, className }) {
const Heading = `h${level}`; // the screen decides the level, not the component
return (
<section className={cx(styles.panel, className)}>
{(title || actions) && (
<header className={styles.header}>
{title && <Heading className={styles.title}>{title}</Heading>}
{actions && <div className={styles.actions}>{actions}</div>}
</header>
)}
<div className={styles.body}>{children}</div>
</section>
);
}
export default Panel;// src/components/base/Label.jsx (StatusBadge is its specialization)
import { cx } from '../../utils/classNames.js';
import styles from './Label.module.css';
const LABELS = {
disponible: 'Available',
alquilada: 'Rented',
mantenimiento: 'In maintenance'
};
export function StatusBadge({ status }) {
return (
<span className={cx(styles.label, styles[status])}>
{LABELS[status] ?? status}
</span>
);
}The level prop on Panel looks like a detail and it isn't: if Panel always set a fixed <h2>, a screen with nested panels would produce a broken heading hierarchy, which is exactly how someone navigating with a screen reader gets lost. The component can't know at what level of the document it will be placed; whoever places it can.
And Modal, with the four obligations of a dialog (03-06):
// src/components/base/Modal.jsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useKeyEvent } from '../../hooks/useKeyEvent.js';
import styles from './Modal.module.css';
function Modal({ open, title, onClose, children }) {
const container = useRef(null);
const previousElement = useRef(null);
useKeyEvent('Escape', onClose, { active: open });
useEffect(() => {
if (!open) return;
// 1) Remember where focus came from, and 2) move it to the dialog
previousElement.current = document.activeElement;
container.current?.focus();
// 3) Lock background scrolling
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
// 4) Return focus to where it was
previousElement.current?.focus?.();
};
}, [open]);
if (!open) return null;
return createPortal(
<div className={styles.backdrop} onClick={onClose}>
<div
ref={container}
className={styles.dialog}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
>
<h2 id="modal-title" className={styles.title}>{title}</h2>
{children}
</div>
</div>,
document.body
);
}
export default Modal;The createPortal (05-03) is what keeps the dialog from getting trapped inside a container with overflow: hidden or a z-index that covers it. The stopPropagation on the inside keeps a click inside the dialog from reaching the backdrop and closing it.
Layout: the application shell
Layout: the application shell// src/components/Layout.jsx
import { Outlet } from 'react-router';
import Header from './Header.jsx';
import Breadcrumbs from './Breadcrumbs.jsx';
import NoticeList from './NoticeList.jsx';
import Footer from './Footer.jsx';
import styles from './Layout.module.css';
function Layout() {
return (
<div className={styles.container}>
<a className={styles.skipLink} href="#content">
Skip to main content
</a>
<Header />
<main className={styles.main} id="content" tabIndex={-1}>
<Breadcrumbs />
<NoticeList />
<Outlet />
</main>
<Footer />
</div>
);
}
export default Layout;/* src/components/Layout.module.css */
.container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main {
flex: 1;
width: 100%;
max-width: var(--max-width);
margin: 0 auto;
padding: var(--space-l) var(--space);
}
/* The skip link: off-screen until it receives focus */
.skipLink {
position: absolute;
left: -9999px;
z-index: 100;
padding: var(--space-s) var(--space);
background: var(--color-brand);
color: #fff;
border-radius: 0 0 var(--radius) 0;
text-decoration: none;
}
.skipLink:focus {
left: 0;
top: 0;
}Five things about this template that hold for the whole application:
- The skip link is the document's first focusable element. Someone navigating by keyboard doesn't have to tab through the six navigation links on every screen. It's visually hidden and appears once it receives focus: it's the standard pattern and it doesn't bother anyone else.
min-height: 100vhwithflex: 1on themainkeeps the footer at the bottom even when the page has three lines. Without this, the "no bookings" screen leaves the footer floating halfway up.tabIndex={-1}on themainmakes it focusable programmatically — not by tabbing — so the skip link can actually move focus there. Without that attribute, the browser scrolls but leaves focus where it was, and the next tab press goes back to the start.NoticeListsits insidemainand before theOutlet. Result messages need to appear in the content flow, not as a floating layer that a screen reader announces out of context.- The max width lives here, not on each screen. A screen that needs the full width will ask for it explicitly; everything else inherits it.
- Navigation with
NavLink and breadcrumbs
NavLink and breadcrumbs// src/components/Header.jsx
import { NavLink, Link } from 'react-router';
import UserMenu from './UserMenu.jsx';
import ThemeButton from './ThemeButton.jsx';
import { cx } from '../utils/classNames.js';
import { APP_NAME } from '../config.js';
import styles from './Header.module.css';
const LINKS = [
{ to: '/', label: 'Catalogue', end: true },
{ to: '/estaciones', label: 'Stations' },
{ to: '/reservas', label: 'My bookings' },
{ to: '/taller', label: 'Workshop', operatorOnly: true }
];
function Header() {
// In 11-03 this will come from the real session; for now it's hard-coded so the screen can be reviewed
const isOperator = true;
const visibleLinks = LINKS.filter((link) => !link.operatorOnly || isOperator);
return (
<header className={styles.header}>
<div className={styles.inner}>
<Link to="/" className={styles.brand}>
<span aria-hidden="true">🚲</span> {APP_NAME}
</Link>
<nav className={styles.nav} aria-label="Main">
<ul className={styles.list}>
{visibleLinks.map((link) => (
<li key={link.to}>
<NavLink
to={link.to}
end={link.end}
className={({ isActive }) =>
cx(styles.link, isActive && styles.active)
}
>
{link.label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className={styles.actions}>
<ThemeButton />
<UserMenu />
</div>
</div>
</header>
);
}
export default Header;/* src/components/Header.module.css — the active state */
.link {
display: block;
padding: var(--space-s) var(--space);
color: var(--color-text-soft);
text-decoration: none;
border-radius: var(--radius);
transition: background-color var(--transition);
}
.link:hover {
background-color: var(--color-bg);
color: var(--color-text);
}
.active {
color: var(--color-brand);
font-weight: 600;
/* Color CANNOT be the only signal: a shape marker is added */
box-shadow: inset 0 -2px 0 var(--color-brand);
}Two points that were decided in module 6 and get picked up here:
endon the link to the catalogue. Without it,/matches every route — becauseNavLinkcompares by prefix — and the catalogue link would show as active while on/reservas.- The active state isn't signaled with color alone. Someone with color blindness or a grayscale monitor can't tell the current link apart if the only difference is hue. The thick underline solves it, and it costs nothing.
The breadcrumbs make use of the handle each route was already carrying:
// src/components/Breadcrumbs.jsx
import { Link, useMatches } from 'react-router';
import styles from './Breadcrumbs.module.css';
function Breadcrumbs() {
const matches = useMatches().filter((m) => m.handle?.crumb);
if (matches.length <= 1) return null; // nothing to add on the home screen
return (
<nav aria-label="Breadcrumb" className={styles.breadcrumbs}>
<ol className={styles.list}>
{matches.map((match, index) => {
const isLast = index === matches.length - 1;
const label =
typeof match.handle.crumb === 'function'
? match.handle.crumb(match)
: match.handle.crumb;
return (
<li key={match.id} className={styles.crumb}>
{isLast ? (
<span aria-current="page">{label}</span>
) : (
<>
<Link to={match.pathname}>{label}</Link>
<span aria-hidden="true" className={styles.separator}>/</span>
</>
)}
</li>
);
})}
</ol>
</nav>
);
}
export default Breadcrumbs;The / separator is decorative and carries aria-hidden: without it, a screen reader would read "Home slash Stations slash Main Square." The last item isn't a link — there's no point linking to the page you're already on — and it's marked with aria-current="page".
- Structural accessibility: landmarks and skip-to-content
Before writing screens, it's worth pinning down the document structure they'll all share:
flowchart TD
BODY["body"]
BODY --> SKIP["a.skipLink → #content"]
BODY --> HEAD["header · banner"]
HEAD --> NAV["nav aria-label='Main'"]
HEAD --> ACTIONS["Actions: theme and user"]
BODY --> MAIN["main#content · main"]
MAIN --> CRUMBS["nav aria-label='Breadcrumb'"]
MAIN --> NOTICES["NoticeList · role='status'"]
MAIN --> OUT["Outlet: screen h1 + sections"]
BODY --> FOOT["footer · contentinfo"]
The rules that apply to every screen without exception:
| Rule | Why |
|---|---|
A single <h1> per screen, and it's the screen's title |
It's the orientation anchor for any screen reader |
Levels are never skipped (h1 → h2 → h3) |
Skipping from h1 to h3 suggests a section that doesn't exist |
Every <nav> has its aria-label |
There are two navigations; without a label they're indistinguishable in the landmarks list |
Only one <main> per document |
It's the destination of the skip link |
Decorative icons carry aria-hidden="true" |
"Bike emoji Catalogue" doesn't help anyone |
| Buttons have real text, not just an icon | And if the icon stands alone, aria-label is mandatory |
- Sample data while there's no network
// src/data/domain.js — static copy of db.json for working without an API
export const bikes = [
{ id: 'bici-001', model: 'Classic Urban', type: 'urbana', status: 'disponible', stationId: 'est-01', pricePerHour: 2.5 },
{ id: 'bici-002', model: 'Electric Pro', type: 'electrica', status: 'alquilada', stationId: 'est-01', pricePerHour: 4.0 },
{ id: 'bici-003', model: 'Cargo Max', type: 'carga', status: 'mantenimiento', stationId: 'est-02', pricePerHour: 5.5 },
{ id: 'bici-004', model: 'Classic Urban', type: 'urbana', status: 'disponible', stationId: 'est-03', pricePerHour: 2.5 },
{ id: 'bici-005', model: 'Electric Pro', type: 'electrica', status: 'disponible', stationId: 'est-02', pricePerHour: 4.0 }
];
export const stations = [
{ id: 'est-01', name: 'Main Square', district: 'Downtown', docks: 20 },
{ id: 'est-02', name: 'North Park', district: 'North', docks: 15 },
{ id: 'est-03', name: 'Central Station', district: 'Riverside', docks: 30 }
];
export const users = [
{ id: 'usr-01', name: 'Ana Ribera', email: '[email protected]', role: 'cliente' },
{ id: 'usr-02', name: 'Marc Solé', email: '[email protected]', role: 'operario' }
];
export const bookings = [
{ id: 'res-01', bicicletaId: 'bici-002', user: 'usr-01', startDate: '2026-05-04T09:00', hours: 2, status: 'activa' }
];It's a literal copy of db.json, on purpose. When 11-03 replaces these imports with query hooks, the shape of the data will be identical and no component will have to change. That's the reason for keeping the duplication instead of inventing new data here.
The file doesn't get deleted in 11-03: it goes on to be used in the tests and in Cypress's seed db.json.
- The four states of every screen
Every screen that shows data has four faces. Writing them now costs minutes; adding them later, once the logic is already tangled together, costs hours.
| State | What you see | Component |
|---|---|---|
| Loading | Gray blocks shaped like the final content | PageSkeleton |
| Empty | An explanation of why there's nothing and what to do | The screen's own block |
| Error | An understandable message and a retry button | Notice with role="alert" |
| With data | The content | The screen |
// src/components/PageSkeleton.jsx
import styles from './PageSkeleton.module.css';
function PageSkeleton({ rows = 3, withHeader = true }) {
return (
<div className={styles.skeleton} data-testid="esqueleto-pagina" aria-hidden="true">
{withHeader && <div className={styles.title} />}
{Array.from({ length: rows }, (_, index) => (
<div key={index} className={styles.row} />
))}
</div>
);
}
export default PageSkeleton;// The screen-reader announcement is kept separate, because the skeleton is aria-hidden
function LoadingIndicator({ text = 'Loading…' }) {
return (
<p role="status" aria-live="polite" className="visually-hidden">
{text}
</p>
);
}The split is deliberate: the skeleton is purely visual information, and to a screen reader it's noise — it would announce a dozen empty containers — so it's hidden with aria-hidden. The equivalent information is given through text in role="status" that's invisible but gets announced. Two channels, one message.
And the empty state, which is the one most often neglected:
// Bad: says nothing
<p>No bookings.</p>
// Good: explains and offers a way out
<div className={styles.empty}>
<h2>You don't have any bookings yet</h2>
<p>Once you book a bike it'll show up here with its start time and duration.</p>
<Button onClick={() => navigate('/')}>View the catalogue</Button>
</div>
- Screen 1: the catalogue
The most-visited screen and the one that composes the most pieces. Here, with local state; in 11-03, the filter moves to the URL and the data to TanStack Query.
// src/pages/CataloguePage.jsx
import { useState, useMemo, useDeferredValue } from 'react';
import Panel from '../components/base/Panel.jsx';
import TypeSelector from '../components/TypeSelector.jsx';
import BikeSearch from '../components/BikeSearch.jsx';
import BikeList from '../components/BikeList.jsx';
import FleetSummary from '../components/FleetSummary.jsx';
import PageSkeleton from '../components/PageSkeleton.jsx';
import Notice from '../components/Notice.jsx';
import { bikes, stations } from '../data/domain.js';
import styles from './CataloguePage.module.css';
// Temporary switches to review the states. In 11-03 they're replaced by isPending and isError
const LOADING = false;
const WITH_ERROR = false;
function CataloguePage() {
const [type, setType] = useState('todos');
const [term, setTerm] = useState('');
// The deferred term keeps the field responsive with large lists (08-03)
const deferredTerm = useDeferredValue(term);
const visible = useMemo(() => {
const text = deferredTerm.trim().toLowerCase();
return bikes
.filter((bike) => type === 'todos' || bike.type === type)
.filter((bike) => bike.model.toLowerCase().includes(text));
}, [type, deferredTerm]);
if (LOADING) return <PageSkeleton rows={5} />;
if (WITH_ERROR) {
return (
<Notice tone="error" title="The bikes couldn't be loaded">
Check your connection and try again.
</Notice>
);
}
return (
<>
<h1 className={styles.title}>Bike catalogue</h1>
<p className={styles.intro}>
{visible.length} of {bikes.length} bikes
</p>
<FleetSummary bikes={bikes} />
<Panel title="Filters" level={2} className={styles.filters}>
<TypeSelector selectedType={type} onTypeChange={setType} />
<BikeSearch term={term} onSearch={setTerm} />
</Panel>
{visible.length === 0 ? (
<div className={styles.empty}>
<h2>No bike matches your search</h2>
<p>Try a different type or clear the search text.</p>
</div>
) : (
<BikeList bikes={visible} stations={stations} />
)}
</>
);
}
export default CataloguePage;TypeSelector as a button group, with useTransition so that changing the filter doesn't block the interface (08-01):
// src/components/TypeSelector.jsx
import { useTransition } from 'react';
import { cx } from '../utils/classNames.js';
import styles from './TypeSelector.module.css';
const TYPES = [
{ value: 'todos', label: 'All' },
{ value: 'urbana', label: 'Urban' },
{ value: 'electrica', label: 'Electric' },
{ value: 'carga', label: 'Cargo' }
];
function TypeSelector({ selectedType, onTypeChange }) {
const [pending, startTransition] = useTransition();
return (
<div
className={cx(styles.group, pending && styles.pending)}
role="group"
aria-label="Filter by bike type"
>
{TYPES.map((type) => (
<button
key={type.value}
type="button"
className={cx(styles.button, selectedType === type.value && styles.active)}
aria-pressed={selectedType === type.value}
onClick={() => startTransition(() => onTypeChange(type.value))}
>
{type.label}
</button>
))}
</div>
);
}
export default TypeSelector;aria-pressed is what turns four loose buttons into an understandable toggle group: a screen reader announces "Electric, toggle button, pressed," which is exactly what someone looking at the screen sees.
And the card, memoized because it's the element that repeats the most (08-02):
// src/components/BikeCard.jsx
import { memo } from 'react';
import { Link } from 'react-router';
import { StatusBadge } from './base/Label.jsx';
import Button from './base/Button.jsx';
import styles from './BikeCard.module.css';
function BikeCard({ bike, station, onBook }) {
const available = bike.status === 'disponible';
return (
<article
className={styles.card}
data-testid="tarjeta-bicicleta"
data-bicicleta={bike.id}
>
<header className={styles.header}>
<h3 className={styles.model}>
<Link to={`/bicicletas/${bike.id}`}>{bike.model}</Link>
</h3>
<StatusBadge status={bike.status} />
</header>
<dl className={styles.data}>
<div>
<dt>Station</dt>
<dd>{station?.name ?? 'Unassigned'}</dd>
</div>
<div>
<dt>Price</dt>
<dd>€{bike.pricePerHour.toFixed(2)}/h</dd>
</div>
</dl>
<Button
variant="primary"
disabled={!available}
onClick={() => onBook?.(bike.id)}
aria-label={`Book ${bike.model}`}
>
{available ? 'Book' : 'Not available'}
</Button>
</article>
);
}
export default memo(BikeCard);Notice the <dl>: model and price aren't loose paragraphs, they're term-definition pairs, and marking them up that way makes a screen reader announce "Station: Main Square" instead of two disconnected pieces of text. The button's aria-label solves the "five buttons that all say Book" problem: when navigating through the list of buttons, each one says which is which.
- Screen 2: a bike's detail page
// src/pages/BikeDetailPage.jsx
import { useParams, useNavigate, Link } from 'react-router';
import Panel from '../components/base/Panel.jsx';
import Button from '../components/base/Button.jsx';
import { StatusBadge } from '../components/base/Label.jsx';
import { bikes, stations } from '../data/domain.js';
import styles from './BikeDetailPage.module.css';
function BikeDetailPage() {
const { bicicletaId } = useParams();
const navigate = useNavigate();
const bike = bikes.find((b) => b.id === bicicletaId);
// A nonexistent identifier is NOT a blank screen (H3)
if (!bike) {
return (
<div className={styles.notFound}>
<h1>This bike doesn't exist</h1>
<p>The identifier <code>{bicicletaId}</code> doesn't match any bike.</p>
<Link to="/">Back to the catalogue</Link>
</div>
);
}
const station = stations.find((s) => s.id === bike.stationId);
const available = bike.status === 'disponible';
return (
<>
<header className={styles.header}>
<h1>{bike.model}</h1>
<StatusBadge status={bike.status} />
</header>
<div className={styles.columns}>
<Panel title="Bike details" level={2}>
<dl className={styles.data}>
<div><dt>Identifier</dt><dd>{bike.id}</dd></div>
<div><dt>Type</dt><dd>{bike.type}</dd></div>
<div><dt>Price per hour</dt><dd>€{bike.pricePerHour.toFixed(2)}</dd></div>
</dl>
</Panel>
<Panel title="Location" level={2}>
{station ? (
<p>
<Link to={`/estaciones/${station.id}`}>{station.name}</Link>
{' · '}District {station.district} · {station.docks} docks
</p>
) : (
<p>This bike isn't assigned to any station.</p>
)}
</Panel>
</div>
<div className={styles.actions}>
<Button
disabled={!available}
onClick={() => navigate(`/reservas/nueva?bicicleta=${bike.id}`)}
>
{available ? 'Book this bike' : 'Not available right now'}
</Button>
{!available && (
<p className={styles.reason}>
{bike.status === 'alquilada'
? "It's rented. Check back later."
: "It's in maintenance. The workshop team is looking at it."}
</p>
)}
</div>
</>
);
}
export default BikeDetailPage;Two product details, not code ones: the disabled button explains why it's disabled. A grayed-out control with no explanation is the fastest way to frustrate someone. And the booking gets preselected through the URL (?bicicleta=bici-001), so whoever arrives from the detail page doesn't have to choose the bike again in the form.
- Screens 3 and 4: stations and tabbed detail
// src/pages/StationsPage.jsx
import StationCard from '../components/StationCard.jsx';
import { stations, bikes } from '../data/domain.js';
import styles from './StationsPage.module.css';
function StationsPage() {
return (
<>
<h1>Stations</h1>
<p className={styles.intro}>{stations.length} stations in service.</p>
<ul className={styles.grid}>
{stations.map((station) => {
const stationBikes = bikes.filter((b) => b.stationId === station.id);
return (
<li key={station.id}>
<StationCard
station={station}
totalBikes={stationBikes.length}
available={stationBikes.filter((b) => b.status === 'disponible').length}
/>
</li>
);
})}
</ul>
</>
);
}
export default StationsPage;The detail page uses nested routes for the tabs (06-03), not local state. That way the incidents tab is linkable and the back button works:
// src/pages/StationDetailPage.jsx
import { NavLink, Outlet, useParams } from 'react-router';
import { cx } from '../utils/classNames.js';
import { stations } from '../data/domain.js';
import styles from './StationDetailPage.module.css';
function StationDetailPage() {
const { estacionId } = useParams();
const station = stations.find((s) => s.id === estacionId);
if (!station) {
return <h1>This station doesn't exist</h1>;
}
const linkClass = ({ isActive }) => cx(styles.tab, isActive && styles.active);
return (
<>
<h1>{station.name}</h1>
<p className={styles.intro}>
District {station.district} · {station.docks} docks
</p>
<nav className={styles.tabs} aria-label="Station sections">
<NavLink to="." end className={linkClass}>Fleet</NavLink>
<NavLink to="incidencias" className={linkClass}>Incidents</NavLink>
</nav>
{/* The active tab's content */}
<Outlet context={{ station }} />
</>
);
}
export default StationDetailPage;| Tabs with local state | Tabs as nested routes |
|---|---|
| Not linkable | /estaciones/est-01/incidencias can be shared |
| The back button leaves the screen | The back button returns to the previous tab |
| Lost on reload | Survive a reload |
| All the content always loads | Each tab can be loaded with lazy |
- Screen 5: my bookings
// src/pages/BookingsPage.jsx
import { useState } from 'react';
import { Link } from 'react-router';
import BookingsPanel from '../components/BookingsPanel.jsx';
import Modal from '../components/base/Modal.jsx';
import Button from '../components/base/Button.jsx';
import { bookings, bikes } from '../data/domain.js';
import styles from './BookingsPage.module.css';
function BookingsPage() {
// Pure local state: which booking is pending cancellation confirmation
const [bookingToCancel, setBookingToCancel] = useState(null);
return (
<>
<div className={styles.header}>
<h1>My bookings</h1>
<Link to="/reservas/nueva" className={styles.actionLink}>
New booking
</Link>
</div>
{bookings.length === 0 ? (
<div className={styles.empty}>
<h2>You don't have any bookings yet</h2>
<p>Once you book a bike it'll show up here with its start time and duration.</p>
<Link to="/">View the catalogue</Link>
</div>
) : (
<BookingsPanel
bookings={bookings}
bikes={bikes}
onCancel={setBookingToCancel}
/>
)}
<Modal
open={bookingToCancel !== null}
title="Cancel the booking"
onClose={() => setBookingToCancel(null)}
>
<p>Are you sure you want to cancel this booking? This action can't be undone.</p>
<div className={styles.modalActions}>
<Button variant="secondary" onClick={() => setBookingToCancel(null)}>
Back
</Button>
<Button variant="danger" onClick={() => setBookingToCancel(null)}>
Yes, cancel
</Button>
</div>
</Modal>
</>
);
}
export default BookingsPage;The bookings table, with the test anchor already in place:
// src/components/BookingsPanel.jsx (excerpt)
<table className={styles.table}>
<caption className="visually-hidden">List of your bookings</caption>
<thead>
<tr>
<th scope="col">Bike</th>
<th scope="col">Start</th>
<th scope="col">Hours</th>
<th scope="col">Status</th>
<th scope="col"><span className="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody>
{bookings.map((booking) => (
<tr key={booking.id} data-testid="fila-reserva">
<td>{bikeFor(booking)?.model ?? 'Unknown'}</td>
<td>{formatDate(booking.startDate)}</td>
<td>{booking.hours}</td>
<td><StatusBadge status={booking.status} /></td>
<td>
<Button
variant="text"
onClick={() => onCancel(booking)}
disabled={booking.status === 'cancelada'}
>
Cancel
</Button>
</td>
</tr>
))}
</tbody>
</table>A data table must be a <table>, not a grid of <div>s. With scope="col" on the headers, a screen reader announces "Status: active" as it moves across the row; with divs, it reads five loose values with no idea what they correspond to. The visually hidden <caption> gives the context for the whole table.
- Screen 6: new booking
The form is the screen with the heaviest accessibility load in the whole project.
// src/components/BookingForm.jsx
import { useState } from 'react';
import Field from './base/Field.jsx';
import Button from './base/Button.jsx';
import styles from './BookingForm.module.css';
function BookingForm({ bikes, initialValue, submitting = false, onSubmit }) {
const [formData, setFormData] = useState(initialValue);
const [errors, setErrors] = useState({});
const bookable = bikes.filter((b) => b.status === 'disponible');
const selected = bikes.find((b) => b.id === formData.bicicletaId);
const total = selected ? selected.pricePerHour * Number(formData.hours || 0) : 0;
function handleChange(field, value) {
setFormData((current) => ({ ...current, [field]: value }));
}
function handleSubmit(event) {
event.preventDefault();
// In 11-03 this is where validateBooking and the mutation come in
onSubmit?.(formData);
}
return (
<form className={styles.form} onSubmit={handleSubmit} noValidate>
<Field label="Bike" required error={errors.bicicletaId}>
{(props) => (
<select
{...props}
value={formData.bicicletaId}
onChange={(e) => handleChange('bicicletaId', e.target.value)}
>
<option value="">Choose a bike</option>
{bookable.map((bike) => (
<option key={bike.id} value={bike.id}>
{bike.model} — €{bike.pricePerHour.toFixed(2)}/h
</option>
))}
</select>
)}
</Field>
<Field
label="Booking start"
required
error={errors.startDate}
hint="Can't start in the past"
>
{(props) => (
<input
{...props}
type="datetime-local"
value={formData.startDate}
onChange={(e) => handleChange('startDate', e.target.value)}
/>
)}
</Field>
<Field label="Duration (hours)" required error={errors.hours} hint="Between 1 and 24">
{(props) => (
<input
{...props}
type="number"
min="1"
max="24"
value={formData.hours}
onChange={(e) => handleChange('hours', e.target.value)}
/>
)}
</Field>
<Field label="I accept the terms of use" error={errors.terms}>
{(props) => (
<input
{...props}
type="checkbox"
checked={formData.terms}
onChange={(e) => handleChange('terms', e.target.checked)}
/>
)}
</Field>
<p className={styles.total} data-testid="total-reserva">
Estimated total: <strong>€{total.toFixed(2)}</strong>
</p>
<Button type="submit" loading={submitting} fullWidth>
Create booking
</Button>
</form>
);
}
export default BookingForm;The form's decisions, all inherited from 03-04 and 03-05:
| Decision | Reason |
|---|---|
noValidate on the <form> |
The browser's validation is turned off in favor of a custom one, with consistent, translatable messages |
The select only lists disponible bikes |
Preventing the error is better than reporting it afterward |
| The total is calculated during render | A derived value is never stored in state (05-01) |
Errors arrive as a prop from Field |
The aria-invalid / aria-describedby wiring is resolved in one place |
Explicit type="submit" |
A consequence of Button's flipped default value |
data-testid="total-reserva" |
Stable anchor for 11-04 |
- Screens 7 and 8: sign-in and workshop
// src/pages/SignInPage.jsx (excerpt)
function SignInPage() {
const [email, setEmail] = useState('');
const [error, setError] = useState(null);
function handleSubmit(event) {
event.preventDefault();
// In 11-03: dispatch to sessionSlice and redirect to the origin
}
return (
<div className={styles.center}>
<h1>Sign in to CicloUrbano</h1>
{error && (
<div role="alert" className={styles.error}>
{error}
</div>
)}
<form onSubmit={handleSubmit} noValidate>
<Field label="Email" required hint="Try [email protected]">
{(props) => (
<input
{...props}
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
)}
</Field>
<Button type="submit" fullWidth>Sign in</Button>
</form>
</div>
);
}autoComplete="email" isn't optional: it's what lets the password manager and the browser's autofill work, and its absence is one of the most frequent complaints from users on sign-in forms.
And the workshop, which in this lesson is accessible so it can be reviewed:
// src/components/ProtectedRoute.jsx — 11-02 version
import { Outlet } from 'react-router';
function ProtectedRoute() {
// TODO(11-03): check the real session and redirect to /acceso, preserving the origin
return <Outlet />;
}
export default ProtectedRoute;// src/pages/WorkshopPage.jsx (excerpt)
function WorkshopPage() {
const inMaintenance = bikes.filter((b) => b.status === 'mantenimiento');
const rest = bikes.filter((b) => b.status !== 'mantenimiento');
return (
<>
<h1>Workshop</h1>
<p className={styles.intro}>
Fleet status management. Operator staff only.
</p>
<Panel title={`In maintenance (${inMaintenance.length})`} level={2}>
{inMaintenance.length === 0 ? (
<p>The whole fleet is operational.</p>
) : (
<ul className={styles.list}>
{inMaintenance.map((bike) => (
<li key={bike.id}>
{bike.model} <code>{bike.id}</code>
<Button variant="secondary" size="small">Return to service</Button>
</li>
))}
</ul>
)}
</Panel>
<Panel title={`Rest of the fleet (${rest.length})`} level={2}>
{/* same pattern, with the "Send to maintenance" button */}
</Panel>
</>
);
}Notice the order: what the operator needs to see first comes first. It's a direct consequence of the user table from 11-01, and it's the kind of decision that only gets made well if that groundwork has already been done.
- The error screens
Three, and each responds to a different situation:
// src/pages/NotFoundPage.jsx — route * : the URL doesn't exist
function NotFoundPage() {
return (
<div className={styles.center}>
<h1>This page doesn't exist</h1>
<p>The link might be mistyped, or the page might have moved.</p>
<Link to="/">Go to the catalogue</Link>
</div>
);
}// src/pages/ForbiddenPage.jsx — the URL exists, but the role isn't enough
function ForbiddenPage() {
return (
<div className={styles.center}>
<h1>You don't have permission to view this page</h1>
<p>The workshop area is reserved for operator staff.</p>
<Link to="/">Back to the catalogue</Link>
</div>
);
}// src/pages/RouteErrorPage.jsx — errorElement: something failed while rendering the route
import { useRouteError, isRouteErrorResponse, Link } from 'react-router';
import { reportError } from '../utils/monitoring.js';
function RouteErrorPage() {
const error = useRouteError();
reportError(error, { source: 'errorElement' });
const message = isRouteErrorResponse(error)
? `Error ${error.status}: ${error.statusText}`
: 'A problem occurred while showing this screen.';
return (
<div className={styles.center} role="alert">
<h1>Something went wrong</h1>
<p>{message}</p>
<Link to="/">Back to the catalogue</Link>
{import.meta.env.DEV && <pre className={styles.detail}>{String(error)}</pre>}
</div>
);
}| Screen | When it appears | What triggers it |
|---|---|---|
NotFoundPage |
The URL doesn't match any route | The * route |
ForbiddenPage |
There's a session, but the role isn't enough | RequireRole (in 11-03) |
RouteErrorPage |
An exception while rendering a route | React Router's errorElement |
ErrorBoundary |
An exception outside the router | The boundary in main.jsx |
The technical detail of the error only shows up in development (import.meta.env.DEV). In production, a stack trace on screen doesn't help the user, and it does help anyone looking for holes to exploit.
- Responsive design with CSS Modules
The catalogue grid, without a single calculation in JavaScript:
/* src/components/BikeList.module.css */
.list {
list-style: none;
padding: 0;
display: grid;
gap: var(--space);
/* As many columns as fit, with a minimum of 260px each */
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}That single line replaces what used to be three media queries: auto-fill with minmax distributes the available space without anyone having to decide on breakpoints. When they really are needed, they look like this:
/* src/components/Header.module.css */
.list {
display: flex;
gap: var(--space-s);
list-style: none;
margin: 0;
padding: 0;
}
@media (max-width: 48rem) {
.inner {
flex-wrap: wrap;
}
.nav {
order: 3;
width: 100%;
}
.list {
overflow-x: auto; /* the navigation scrolls horizontally */
scrollbar-width: none;
padding-bottom: var(--space-xs);
}
}Why media queries belong in CSS and not in JavaScript, even though the project has a useWindowWidth hook:
CSS (@media) |
JavaScript (useWindowWidth) |
|
|---|---|---|
| When it applies | On the first paint | After mounting and measuring: there's a flicker |
| Cost | None: the style engine resolves it | A resize event and a React repaint |
| If JavaScript fails or is slow | It keeps working | The layout breaks |
| Resizing the window | Instant | Repaints the whole affected tree |
| When it's the right choice | Almost always | When you need to render different components, not just style them differently |
In CicloUrbano, useWindowWidth is reserved for a single case: deciding whether the user menu drops down or opens a full-screen panel, because those are different DOM structures, not a difference in styling. Everything else — grid, navigation, typography, spacing — is CSS.
The project's three breakpoints, and not one more:
| Name | Width | What changes |
|---|---|---|
| Mobile | < 48rem | One column, scrollable navigation, full-width buttons |
| Tablet | 48rem – 64rem | Two columns in the catalogue, inline navigation |
| Desktop | > 64rem | Full grid, side columns on the detail page |
- The per-screen accessibility checklist
Applied to every screen before calling it finished. Eight points, five minutes:
| # | Check | How to verify it |
|---|---|---|
| 1 | A single <h1> and a hierarchy with no gaps |
Browser accessibility extension, headings view |
| 2 | The task can be completed using only the keyboard | Put the mouse away and go through the screen with Tab, Enter, and Escape |
| 3 | Focus is visible at all times | While tabbing, you can always see where you are |
| 4 | Tab order follows visual order | If it jumps backward, the DOM and the CSS disagree |
| 5 | Every control has an accessible name | Accessibility inspector: no element without a name |
| 6 | Errors are announced (role="alert") and associated (aria-describedby) |
Trigger an error and listen with a screen reader |
| 7 | Contrast reaches 4.5:1 on normal text | Browser contrast tool, on both themes |
| 8 | Information doesn't depend on color alone | Look at the screen in grayscale |
Point 7 has a catch worth pointing out: a well-contrasted dark theme isn't obtained by inverting the colors. --color-brand: #12805c passes on white and falls short on #131a22; that's why the dark theme bumps it up to #1fa87a. Both themes have to be measured separately.
- Walkthrough review of the finished interface
Before wiring anything up, the whole application gets walked through. This is the script, with what should be visible at each stop:
| # | Route | What should be visible |
|---|---|---|
| 1 | / |
Header with four links, "Catalogue" active with its underline. Fleet summary with 3 available, 1 rented, and 1 in maintenance. Grid of five cards: bici-002 and bici-003 with their button grayed out and their color-coded label |
| 2 | / + "Electric" filter |
Two cards. The button shows as pressed |
| 3 | / + search "carga" |
One card. Clearing the text brings back all five |
| 4 | / + "Cargo" filter + search "urbana" |
Empty state with its explanation, not a blank grid |
| 5 | /bicicletas/bici-001 |
Detail page with two panels, breadcrumbs "Home / Bike detail," active booking button |
| 6 | /bicicletas/bici-003 |
Grayed-out button with the reason written underneath |
| 7 | /bicicletas/no-existe |
The dedicated nonexistent-bike message, with a link back |
| 8 | /estaciones |
Three cards with their bike count and availability |
| 9 | /estaciones/est-01 |
"Fleet" tab active, two bikes. Clicking "Incidents," the URL changes and the back button returns to it |
| 10 | /reservas |
Table with one row. Clicking "Cancel" opens the modal, Escape closes it, and focus returns to the button |
| 11 | /reservas/nueva |
Form with all four fields labeled, and the total recalculating as the hours change |
| 12 | /acceso |
Centered form, field with autoComplete |
| 13 | /taller |
Maintenance section first, rest of the fleet after |
| 14 | /ruta-inventada |
NotFoundPage with the header and footer in place |
| 15 | Theme button | The whole application changes; on reload, the theme persists |
| 16 | Window at 375px | One column, scrollable navigation, no horizontal scroll in the body |
And the five cross-cutting checks that close out the review:
- [ ] With the keyboard, end to end: the first
Tablands on "Skip to main content," and everything is reachable from there. - [ ] No horizontal scroll at 320px wide on any screen.
- [ ] With the dark theme on, no text loses contrast.
- [ ] With the console open, not a single React warning (duplicate keys, unknown props, invalid nesting).
- [ ]
npm run lintgreen, including thejsx-a11yrules.
This is the point where the application gets shown to whoever's going to use it. The changes that come out of that conversation — "the price should show before the station," "cancel should be more tucked away" — cost ten minutes today. After 11-03 they'd cost an afternoon.
Common Mistakes and Tips
- Starting with the screens instead of the design system. It's the fast track to ending up with six different grays and four button sizes. Half an hour in
index.cssand the five base components saves days of harmonizing later. - Designing only the happy path. If the empty, loading, and error states don't get laid out alongside the content, they never get laid out: they show up in production as a blank gap or a stray bit of text in the corner.
<div onClick>instead of<button>. It doesn't receive focus, doesn't respond toEnteror space, and doesn't get announced as a control.jsx-a11ycatches it, but the right habit is to use the native element and style it.outline: noneto "make it look cleaner." It leaves anyone navigating by keyboard without a focus indicator. If the default ring doesn't fit, it gets replaced with another one via:focus-visible; it never gets removed outright.- Hard-coding the heading level inside a reusable component. A
Panelthat always renders<h2>breaks the hierarchy the moment it gets nested. The level is a prop. - Media queries simulated in JavaScript. Using
useWindowWidthto decide the number of columns produces flicker on load and repaints on everyresize. CSS does it better, sooner, and for free. - Placeholder instead of
<label>. The text disappears once you start typing, many screen readers don't announce it as a name, and the contrast is usually insufficient.Fieldforces the label precisely for this reason. - A disabled button with no explanation. "Not available" without saying why turns an understandable limitation into a frustration.
- Tip: review every screen in grayscale. If you stop being able to tell a bike's status apart, or the active link, color is doing a job it can't do alone.
- Tip: leave the
LOADINGandWITH_ERRORswitches in the code for the duration of this lesson. Setting one totrueis the fastest way to review a state that can't happen yet, and in 11-03 they get replaced byisPendingandisErrorwithout touching the markup.
Exercises
Exercise 1. FleetSummary shows the bike count by status. Write it in full: it receives bikes, calculates how many are in each status, and presents them with a proportional bar. Requirements: it must be memoized, the information can't depend on color alone, the count must be readable by a screen reader, and it can't break with an empty list. Also state what heading level you'd use and why.
Exercise 2. The walkthrough from section 19 turns up three problems on the /reservas screen:
(a) when the modal closes with Escape, focus is lost and the next tab press starts from the header;
(b) on a 360px phone, the table causes horizontal scrolling across the whole page;
(c) a booking's status is distinguished only by the label's color.
Diagnose and fix each one, stating which file you'd touch.
Exercise 3. The team proposes replacing the four-button TypeSelector with a dropdown <select> "because it takes up less space." Evaluate the proposal from three angles — usability, accessibility, and what 11-03 will do with the filter — and give a reasoned recommendation. If you accept it in some case, write the code for the variant and explain under what conditions each one would be used.
Solutions
Solution 1.
// src/components/FleetSummary.jsx
import { memo, useMemo } from 'react';
import styles from './FleetSummary.module.css';
const STATUSES = [
{ key: 'disponible', label: 'Available', symbol: '●' },
{ key: 'alquilada', label: 'Rented', symbol: '▲' },
{ key: 'mantenimiento', label: 'In maintenance', symbol: '■' }
];
function FleetSummary({ bikes }) {
const count = useMemo(() => {
return bikes.reduce(
(accumulated, bike) => ({ ...accumulated, [bike.status]: (accumulated[bike.status] ?? 0) + 1 }),
{ disponible: 0, alquilada: 0, mantenimiento: 0 }
);
}, [bikes]);
const total = bikes.length;
// Empty state: without this there'd be a division by zero in the percentage
if (total === 0) {
return (
<section className={styles.summary} aria-labelledby="summary-title">
<h2 id="summary-title" className={styles.title}>Fleet status</h2>
<p>No bikes registered.</p>
</section>
);
}
return (
<section className={styles.summary} aria-labelledby="summary-title">
<h2 id="summary-title" className={styles.title}>Fleet status</h2>
<ul className={styles.list}>
{STATUSES.map(({ key, label, symbol }) => {
const quantity = count[key];
const percentage = Math.round((quantity / total) * 100);
return (
<li key={key} className={styles.item}>
<span className={styles.itemHeader}>
{/* Symbol: a second signal besides color */}
<span className={styles[key]} aria-hidden="true">{symbol}</span>
<span className={styles.label}>{label}</span>
<span className={styles.quantity}>
{quantity}
<span className="visually-hidden"> of {total} bikes</span>
</span>
</span>
{/* The bar is purely visual: the data is already in the text */}
<div className={styles.track} aria-hidden="true">
<div
className={`${styles.bar} ${styles[key]}`}
style={{ inlineSize: `${percentage}%` }}
/>
</div>
</li>
);
})}
</ul>
</section>
);
}
export default memo(FleetSummary);The decisions and their justification:
| Requirement | How it's met |
|---|---|
| Memoized | memo on the export and useMemo for the count. bikes arrives as a stable reference from the catalogue, so memo actually works (08-02) |
| Not color alone | Every status carries a distinct symbol (●, ▲, ■) besides its color, plus the written name |
| Readable by a screen reader | The data is in text (3 of 5 bikes), the bar is aria-hidden, and the number carries its context in visually hidden text |
| Doesn't break with an empty list | Early return with total === 0, which also avoids NaN% in the bar |
| Heading level | h2: the h1 is "Bike catalogue" and this is a first-level section within the screen. Also, aria-labelledby connects the <section> to its title, so it shows up named in the regions list |
A detail that's often overlooked: the percentage gets rounded for rendering, but it never replaces the number. "60%" doesn't say how many bikes that is; "3 of 5" does.
Solution 2.
(a) Focus lost when closing the modal. The diagnosis is that Modal saves document.activeElement in the effect that runs when open switches to true, but the component is already returning null before that if it mounts closed... and above all, the element that opened the modal may have unmounted. In BookingsPage the second case happens subtly: on close, setBookingToCancel(null) gets called and the row re-renders, so previousElement.current can end up pointing at a node that's no longer in the document. Focusing a disconnected node is a silent operation: it doesn't fail, it just does nothing, and focus falls back to the <body>.
The fix, in src/components/base/Modal.jsx, with a connectedness check and a fallback destination:
useEffect(() => {
if (!open) return;
previousElement.current = document.activeElement;
container.current?.focus();
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
const previous = previousElement.current;
// Only if it still exists AND is connected to the document
if (previous instanceof HTMLElement && previous.isConnected) {
previous.focus();
} else {
// Fallback: the main content, which is focusable via tabIndex={-1}
document.getElementById('content')?.focus();
}
};
}, [open]);The fallback to main isn't a patch: it's the correct behavior when the origin of the focus has disappeared, because it leaves the person at the start of the content instead of in the limbo of the <body>.
(b) Horizontal scroll caused by the table. The usual diagnostic mistake here is touching the body. The problem isn't the page: it's that the table is wider than its container and drags everything else along with it. The rule is that wide content scrolls inside its own box, never the whole page. In src/components/BookingsPanel.module.css:
.tableContainer {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table {
width: 100%;
min-width: 34rem; /* below this, the columns get cramped: better to scroll */
border-collapse: collapse;
}
/* And on mobile, less critical columns go */
@media (max-width: 30rem) {
.hoursColumn {
display: none;
}
}// BookingsPanel.jsx
<div className={styles.tableContainer}>
<table className={styles.table}>…</table>
</div>Hiding the "Hours" column on very small screens is acceptable because the data is still available in the booking's detail; if it were the only way to know it, hiding it would mean losing information, not adapting it.
(c) Status shown only through color. In src/components/base/Label.module.css and in the component: the label already carries the text ("Available," "Rented"), so if the problem persists it's because it got reduced to a color dot inside the table. The fix is to always keep the text and add a second, shape-based signal:
.label {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 0.15rem var(--space-s);
border-radius: 999px;
font-size: var(--text-s);
font-weight: 600;
border: 1px solid currentColor; /* the border gives shape in addition to color */
}
.disponible { color: var(--color-brand); background: color-mix(in srgb, var(--color-brand) 12%, transparent); }
.alquilada { color: var(--color-rented); background: color-mix(in srgb, var(--color-rented) 12%, transparent); }
.mantenimiento { color: var(--color-maintenance); background: color-mix(in srgb, var(--color-maintenance) 12%, transparent); }The definitive test is the one from section 18: put the screen in grayscale. If, with the colors switched off, you can still tell the three statuses apart — because the text names them — it's properly solved.
Solution 3.
Recommendation: keep the button group in the catalogue. The argument has three parts.
Usability. The established criterion for choosing between buttons and a dropdown is the number of options and how often they change:
| Button group | <select> |
|
|---|---|---|
| Options visible without interacting | All of them | Only the chosen one |
| Clicks to change the filter | 1 | 2 (open and choose) |
| Discoverability of what can be filtered | Immediate | Has to be opened |
| Space taken | More | Less |
| Recommended from | ≤ 5-6 options | > 6-7 options |
With four options, and being the most-used control on the most-visited screen, the dropdown makes the main task worse in order to save space that's got room to spare on desktop.
Accessibility. There's no clear winner here, and it's worth being honest: a native <select> with its <label> is perfectly accessible, and on mobile it opens the system's picker, which is an excellent experience. The button group is just as accessible if it carries role="group", aria-label, and aria-pressed, like the one in section 10. The real difference lies elsewhere: the dropdown announces only the current option, while the buttons announce the state of all of them, which helps build the mental model of what filters exist.
What 11-03 will do with the filter. This is the argument that settles it. The filter moves to the URL (?tipo=electrica) to be shareable. With buttons, each option can also be rendered as a real link if it's ever useful — with its URL in the status bar and its "open in new tab" — with a <select> that's impossible without JavaScript. The button group fits better with decision A6 from the minutes.
Where I would accept it, and the code. In the new booking form and on any narrow screen with more than six options. And in the catalogue itself, if the types ever go from four to twelve, the solution isn't the dropdown but a component that adapts:
// src/components/TypeSelector.jsx — adaptable variant
function TypeSelector({ selectedType, onTypeChange, variant = 'auto' }) {
const [pending, startTransition] = useTransition();
const change = (value) => startTransition(() => onTypeChange(value));
if (variant === 'dropdown' || TYPES.length > 6) {
return (
<Field label="Bike type">
{(props) => (
<select {...props} value={selectedType} onChange={(e) => change(e.target.value)}>
{TYPES.map((type) => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
)}
</Field>
);
}
return (
<div className={cx(styles.group, pending && styles.pending)}
role="group" aria-label="Filter by bike type">
{TYPES.map((type) => (
<button key={type.value} type="button"
className={cx(styles.button, selectedType === type.value && styles.active)}
aria-pressed={selectedType === type.value}
onClick={() => change(type.value)}>
{type.label}
</button>
))}
</div>
);
}The condition that makes this variant valid is that both forms share exactly the same prop contract (selectedType and onTypeChange). Whoever uses it never finds out which one gets rendered, and the tests in 11-04 can interact with whichever one applies without the page changing a single line. If the variant forced a change to the contract, they'd be two different components and one would have to be chosen.
Conclusion
CicloUrbano now exists. It can be opened, clicked through, shown, and critiqued, and it still hasn't made a single request: exactly the point that was aimed for.
The first thing that gets put in place is the design system, and it's what everything else rests on: an index.css with a reset, a closed spacing scale, color and shape variables, :focus-visible defined once so that no part of the application can end up without a focus indicator, and prefers-reduced-motion respected from day one. The dark theme gets resolved by redefining variables under :root[data-theme='dark'], without touching a single component rule, with the reminder that a dark theme isn't an inverted light theme: the brand colors have to be re-measured against the new background.
On top of that live the five base components, whose value lies in the fact that they exist and get used. Button flips the default value of type to eliminate accidental submits at the root and ties the visual state to the functional one in loading. Field uses children as a function to generate the id with useId and wire up htmlFor, aria-invalid, and aria-describedby in a single place, so that writing an accessible field is easier than writing one that isn't. Panel receives the heading level as a prop, because a component can't know how deep it'll be placed. Label names the status in addition to coloring it. And Modal fulfills the four obligations of a dialog: portal, aria-modal, closing with Escape, and returning focus to its origin.
The shell contributes the skip link as the first focusable element, cleanly separated landmarks, a main that's focusable programmatically, navigation with NavLink and its end on the catalogue, the active state signaled with more than just color, and breadcrumbs that read the handle of each route and mark the current one with aria-current.
Of the eight screens, what's worth taking away isn't the markup but the method: each one was written with its four states — loading with PageSkeleton marked as aria-hidden and its parallel role="status", empty with explanation and a way out, error with an understandable message and a retry, and with data —; the tables are real tables with scope and caption; the disabled buttons say why they're disabled; the station tabs are nested routes rather than local state, so they're linkable and survive the back button; and the order of the content answers to what each user needs to see first, which is a decision made in 11-01 and cashed in here.
On responsiveness, the rule is fixed: media queries belong in CSS because they apply on the first paint, cost nothing, and work even if JavaScript is slow; useWindowWidth is reserved for when different structures need to be rendered, not just different styles. And grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)) replaces three breakpoints with one line.
The lesson closes with the eight-point accessibility checklist applied screen by screen, and the sixteen-stop review walkthrough done before wiring anything up: it's the cheapest moment in the whole project to change your mind.
Right now the application is a beautiful mockup that does nothing. The data is a file, the filters only live in memory, the form doesn't submit, the session doesn't exist, and ProtectedRoute lets everyone through. State Management and API Integration brings it to life: the data-access layer that knows nothing about React, TanStack Query with its hierarchical keys and their justified defaults, the full mutation for creating a booking with validation, invalidation, and redirect, the optimistic update for confirming and cancelling with its rollback, Redux for session and catalogue, the filter moving to the URL, the protected routes actually wired up, and a decision table that answers the hardest question of all: when something fails, who shows it.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
