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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions country-explorer/src/components/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
8 changes: 4 additions & 4 deletions country-explorer/src/components/CountryCard.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,18 @@
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 {
max-width: 22rem;
justify-self: center;
}
}
@media (max-width: 360px) {
@media (max-width: 400px) {
.country-card__details {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
}
}
2 changes: 1 addition & 1 deletion country-explorer/src/components/CountrySelector.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 10 additions & 21 deletions country-explorer/src/components/CountrySelector.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useId } from 'react';
import Dropdown from './Dropdown';
import type { Country } from '../types/country';
import './CountrySelector.css';

Expand All @@ -15,30 +15,19 @@ function CountrySelector({
onSelectCountry,
disabled = false,
}: CountrySelectorProps) {
const selectId = useId();

return (
<div className="country-selection">
<label htmlFor={selectId}>Country</label>
<select
id={selectId}
<Dropdown
label="Country"
value={selectedCountryCode ?? ''}
onChange={(event) => 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 ? (
<option value="">No countries available</option>
) : (
<>
{selectedCountryCode === null && <option value="">Select a country</option>}
{countries.map((country) => (
<option key={country.code} value={country.code}>
{country.name || country.code}
</option>
))}
</>
)}
</select>
placeholder={countries.length === 0 ? 'No countries available' : 'Select a country'}
/>
</div>
);
}
Expand Down
81 changes: 81 additions & 0 deletions country-explorer/src/components/Dropdown.css
Original file line number Diff line number Diff line change
@@ -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;
}
48 changes: 48 additions & 0 deletions country-explorer/src/components/Dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Dropdown label="Country" value="NO" options={options} onChange={onChange} />);
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(<Dropdown label="Country" value="NO" options={options} onChange={onChange} />);
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(<Dropdown label="Country" value="NO" options={options} onChange={onChange} />);
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();
});
});
113 changes: 113 additions & 0 deletions country-explorer/src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const expanded = open && !disabled;

useEffect(() => {
if (!expanded) return;
const selected = root.current?.querySelector<HTMLInputElement>('input:checked');
const first = root.current?.querySelector<HTMLInputElement>('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 (
<div
className="dropdown"
ref={root}
onBlur={(event) => {
// 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();
}
}}
>
<span id={`${id}-label`} className="dropdown__label">
{label}
</span>
<button
ref={trigger}
type="button"
className="dropdown__trigger"
disabled={disabled}
aria-labelledby={`${id}-label ${id}-value`}
aria-expanded={expanded}
aria-controls={`${id}-options`}
onClick={() => setOpen(!expanded)}
>
<span id={`${id}-value`}>
{options.find((option) => option.value === value)?.label ?? placeholder}
</span>
<span className="dropdown__arrow" aria-hidden="true" />
</button>
{expanded && (
<div
id={`${id}-options`}
className="dropdown__options"
role="group"
aria-labelledby={`${id}-label`}
>
{options.map((option) => (
<label className="dropdown__option" key={option.value}>
<input
type="radio"
name={id}
value={option.value}
checked={option.value === value}
onChange={() => onChange(option.value)}
onClick={() => {
setOpen(false);
trigger.current?.focus();
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
setOpen(false);
trigger.current?.focus();
}
}}
/>
<span>{option.label}</span>
</label>
))}
</div>
)}
</div>
);
}
16 changes: 16 additions & 0 deletions country-explorer/src/components/NavigationControls.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion country-explorer/src/components/RegionFilter.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading