diff --git a/country-explorer/src/components/App.css b/country-explorer/src/components/App.css index 42e6729..dab6b40 100644 --- a/country-explorer/src/components/App.css +++ b/country-explorer/src/components/App.css @@ -29,7 +29,7 @@ main > section { } section[aria-labelledby='controls-heading'] { display: grid; - grid-template-columns: 1fr 1fr 1.4fr; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr); gap: var(--space-3); } #controls-heading { @@ -57,8 +57,28 @@ footer { text-align: center; font-size: 0.85rem; } -@media (max-width: 680px) { +/* Keep individual controls wide enough on tablets and landscape phones. */ +@media (max-width: 960px) { section[aria-labelledby='controls-heading'] { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .country-selection { + grid-column: 1 / -1; + } +} +@media (max-width: 600px) { + section[aria-labelledby='controls-heading'] { + grid-template-columns: minmax(0, 1fr); + } + main > section { + padding: var(--space-2); + } +} +@media (orientation: landscape) and (max-height: 500px) { + header h1 { + font-size: 2.25rem; + } + header::before { + margin-bottom: var(--space-1); } } diff --git a/country-explorer/src/components/CountryCard.css b/country-explorer/src/components/CountryCard.css index 78c2031..e8f706e 100644 --- a/country-explorer/src/components/CountryCard.css +++ b/country-explorer/src/components/CountryCard.css @@ -61,9 +61,9 @@ color: var(--text-h); font-variant-numeric: tabular-nums; } -@media (max-width: 680px) { +@media (max-width: 800px) { .country-card { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); } .country-card__flag, .country-card__flag-placeholder { @@ -71,8 +71,8 @@ justify-self: center; } } -@media (max-width: 360px) { +@media (max-width: 400px) { .country-card__details { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); } } diff --git a/country-explorer/src/components/CountrySelector.css b/country-explorer/src/components/CountrySelector.css index 5b64015..7169595 100644 --- a/country-explorer/src/components/CountrySelector.css +++ b/country-explorer/src/components/CountrySelector.css @@ -4,7 +4,7 @@ gap: var(--space-1); min-width: 0; } -.country-selection label { +.country-selection > label { color: var(--text); font-size: 0.8rem; font-weight: 600; diff --git a/country-explorer/src/components/CountrySelector.tsx b/country-explorer/src/components/CountrySelector.tsx index 3b24caf..ec1b5e9 100644 --- a/country-explorer/src/components/CountrySelector.tsx +++ b/country-explorer/src/components/CountrySelector.tsx @@ -1,4 +1,4 @@ -import { useId } from 'react'; +import Dropdown from './Dropdown'; import type { Country } from '../types/country'; import './CountrySelector.css'; @@ -15,30 +15,19 @@ function CountrySelector({ onSelectCountry, disabled = false, }: CountrySelectorProps) { - const selectId = useId(); - return (
- - + placeholder={countries.length === 0 ? 'No countries available' : 'Select a country'} + />
); } diff --git a/country-explorer/src/components/Dropdown.css b/country-explorer/src/components/Dropdown.css new file mode 100644 index 0000000..6700865 --- /dev/null +++ b/country-explorer/src/components/Dropdown.css @@ -0,0 +1,81 @@ +.dropdown { + position: relative; + min-width: 0; + width: 100%; +} +.dropdown__label { + display: block; + margin-bottom: var(--space-1); + color: var(--text); + font-size: 0.8rem; + font-weight: 600; +} +.dropdown__trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + min-width: 0; + min-height: 50px; + text-align: left; + font-weight: 400; + background: #f8faf8; +} +.dropdown__trigger > span:first-child { + min-width: 0; + overflow-wrap: anywhere; +} +.dropdown__arrow { + flex: 0 0 0.5rem; + height: 0.5rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); + margin: -0.25rem 0.25rem 0; +} +.dropdown__trigger[aria-expanded='true'] .dropdown__arrow { + transform: rotate(225deg); + margin-top: 0.25rem; +} +.dropdown__options { + position: absolute; + z-index: 10; + top: calc(100% + 0.5rem); + inset-inline: 0; + max-height: min(18rem, 40svh); + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--bg); + box-shadow: 0 8px 24px #183c3220; +} +.dropdown__option { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 44px; + padding: 0.65rem; + border-radius: 0.4rem; + color: var(--text-h); + cursor: pointer; +} +.dropdown__option span { + min-width: 0; + overflow-wrap: anywhere; +} +.dropdown__option input { + flex-shrink: 0; + margin: 0; + accent-color: var(--accent); +} +.dropdown__option:hover, +.dropdown__option:has(input:checked) { + background: var(--accent-bg); +} +.dropdown__option:focus-within { + outline: 2px solid #286dcc; + outline-offset: -2px; +} diff --git a/country-explorer/src/components/Dropdown.test.tsx b/country-explorer/src/components/Dropdown.test.tsx new file mode 100644 index 0000000..7405b5b --- /dev/null +++ b/country-explorer/src/components/Dropdown.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import Dropdown from './Dropdown'; + +const options = [ + { value: 'NO', label: 'Norway' }, + { value: 'SE', label: 'Sweden' }, +]; + +describe('Dropdown', () => { + it('focuses the current choice and returns focus after selecting', () => { + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole('button', { name: 'Country Norway' }); + fireEvent.click(trigger); + expect(screen.getByRole('radio', { name: 'Norway' })).toHaveFocus(); + fireEvent.click(screen.getByRole('radio', { name: 'Sweden' })); + expect(onChange).toHaveBeenCalledExactlyOnceWith('SE'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).toHaveFocus(); + }); + + it('keeps option labels clickable when the browser temporarily clears focus', () => { + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole('button', { name: 'Country Norway' }); + fireEvent.click(trigger); + const label = screen.getByText('Sweden'); + fireEvent.pointerDown(label); + fireEvent.blur(screen.getByRole('radio', { name: 'Norway' }), { relatedTarget: null }); + expect(label).toBeInTheDocument(); + fireEvent.click(label); + expect(onChange).toHaveBeenCalledExactlyOnceWith('SE'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).toHaveFocus(); + }); + + it('closes with Escape without changing the selection', () => { + const onChange = vi.fn(); + render(); + const trigger = screen.getByRole('button', { name: 'Country Norway' }); + fireEvent.click(trigger); + fireEvent.keyDown(screen.getByRole('radio', { name: 'Norway' }), { key: 'Escape' }); + expect(trigger).toHaveFocus(); + expect(screen.queryByRole('group')).not.toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/country-explorer/src/components/Dropdown.tsx b/country-explorer/src/components/Dropdown.tsx new file mode 100644 index 0000000..36e0537 --- /dev/null +++ b/country-explorer/src/components/Dropdown.tsx @@ -0,0 +1,113 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import './Dropdown.css'; + +interface DropdownProps { + label: string; + value: string; + options: { value: string; label: string }[]; + onChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +/** Native radio controls retain arrow-key selection inside the bounded popup. */ +export default function Dropdown({ + label, + value, + options, + onChange, + disabled = false, + placeholder = 'Select an option', +}: DropdownProps) { + const id = useId(); + const [open, setOpen] = useState(false); + const root = useRef(null); + const trigger = useRef(null); + const expanded = open && !disabled; + + useEffect(() => { + if (!expanded) return; + const selected = root.current?.querySelector('input:checked'); + const first = root.current?.querySelector('input'); + (selected ?? first)?.focus({ preventScroll: true }); + function outside(event: PointerEvent) { + if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false); + } + document.addEventListener('pointerdown', outside); + return () => document.removeEventListener('pointerdown', outside); + }, [expanded]); + + return ( +
{ + // Clicking an option's label can briefly move focus to the document + // before the browser forwards the click to its radio input. Keep the + // popup mounted until that selection has completed. Outside pointer + // presses are handled separately by the document listener. + if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) { + setOpen(false); + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape' && expanded) { + event.preventDefault(); + setOpen(false); + trigger.current?.focus(); + } + }} + > + + {label} + + + {expanded && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} diff --git a/country-explorer/src/components/NavigationControls.css b/country-explorer/src/components/NavigationControls.css index eb24d07..e5ed689 100644 --- a/country-explorer/src/components/NavigationControls.css +++ b/country-explorer/src/components/NavigationControls.css @@ -16,3 +16,19 @@ .country-navigation button:hover:not(:disabled) { background: #174a39; } +@media (max-width: 600px) { + .country-navigation { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + } + .country-navigation p { + grid-column: 1 / -1; + grid-row: 1; + text-align: center; + } + .country-navigation button { + min-width: 0; + padding-inline: 0.5rem; + } +} diff --git a/country-explorer/src/components/RegionFilter.css b/country-explorer/src/components/RegionFilter.css index 3ae4c90..bed7bd8 100644 --- a/country-explorer/src/components/RegionFilter.css +++ b/country-explorer/src/components/RegionFilter.css @@ -4,7 +4,7 @@ gap: var(--space-1); min-width: 0; } -.region-filter label { +.region-filter > label { color: var(--text); font-size: 0.8rem; font-weight: 600; diff --git a/country-explorer/src/components/RegionFilter.tsx b/country-explorer/src/components/RegionFilter.tsx index 9f29a28..86d03a4 100644 --- a/country-explorer/src/components/RegionFilter.tsx +++ b/country-explorer/src/components/RegionFilter.tsx @@ -1,4 +1,4 @@ -import { useId } from 'react'; +import Dropdown from './Dropdown'; import './RegionFilter.css'; interface RegionFilterProps { @@ -14,24 +14,18 @@ function RegionFilter({ onRegionChange, disabled = false, }: RegionFilterProps) { - const selectId = useId(); - return (
- - + />
); } diff --git a/country-explorer/src/components/SortSelector.css b/country-explorer/src/components/SortSelector.css index 3d745fc..5e894e7 100644 --- a/country-explorer/src/components/SortSelector.css +++ b/country-explorer/src/components/SortSelector.css @@ -4,7 +4,7 @@ gap: var(--space-1); min-width: 0; } -.sort-selector label { +.sort-selector > label { color: var(--text); font-size: 0.8rem; font-weight: 600; diff --git a/country-explorer/src/components/SortSelector.tsx b/country-explorer/src/components/SortSelector.tsx index 054c69e..a44000e 100644 --- a/country-explorer/src/components/SortSelector.tsx +++ b/country-explorer/src/components/SortSelector.tsx @@ -1,4 +1,4 @@ -import { useId } from 'react'; +import Dropdown from './Dropdown'; import { SORT_OPTIONS, type SortOption } from '../utils/sortCountries'; import './SortSelector.css'; @@ -9,23 +9,15 @@ interface SortSelectorProps { } function SortSelector({ sortOption, onSortChange, disabled = false }: SortSelectorProps) { - const selectId = useId(); - return (
- - + />
); } diff --git a/country-explorer/src/index.css b/country-explorer/src/index.css index 27ae122..7624f3d 100644 --- a/country-explorer/src/index.css +++ b/country-explorer/src/index.css @@ -29,7 +29,7 @@ } body { margin: 0; - min-width: 280px; + overflow-wrap: anywhere; font-size: 1rem; line-height: 1.6; } @@ -86,6 +86,7 @@ button { } select { appearance: none; + min-width: 0; width: 100%; min-height: 50px; padding: 0.75rem 2.75rem 0.75rem 1rem;