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 (
- Country
- onSelectCountry(event.target.value)}
+ options={countries.map((country) => ({
+ value: country.code,
+ label: country.name || country.code,
+ }))}
+ onChange={onSelectCountry}
disabled={disabled || countries.length === 0}
- >
- {countries.length === 0 ? (
- No countries available
- ) : (
- <>
- {selectedCountryCode === null && Select a country }
- {countries.map((country) => (
-
- {country.name || country.code}
-
- ))}
- >
- )}
-
+ 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}
+
+
setOpen(!expanded)}
+ >
+
+ {options.find((option) => option.value === value)?.label ?? placeholder}
+
+
+
+ {expanded && (
+
+ {options.map((option) => (
+
+ onChange(option.value)}
+ onClick={() => {
+ setOpen(false);
+ trigger.current?.focus();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ setOpen(false);
+ trigger.current?.focus();
+ }
+ }}
+ />
+ {option.label}
+
+ ))}
+
+ )}
+
+ );
+}
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 (
- Region
- onRegionChange(event.target.value)}
+ options={[
+ { value: '', label: 'All regions' },
+ ...regions.map((region) => ({ value: region, label: region })),
+ ]}
+ onChange={onRegionChange}
disabled={disabled}
- >
- All regions
- {regions.map((region) => (
-
- {region}
-
- ))}
-
+ />
);
}
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 (
- Sort by
- onSortChange(event.target.value as SortOption)}
+ options={SORT_OPTIONS.map((option) => ({ ...option }))}
+ onChange={(value) => onSortChange(value as SortOption)}
disabled={disabled}
- >
- {SORT_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
+ />
);
}
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;