Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions src/language/texts/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,9 @@ export function en() {
'validation_errors.formatMaximum': 'Maximum valid value is {0}',
'map_component.selectedLocation': 'Selected location: {0}° north, {1}° east',
'map_component.noSelectedLocation': 'No selected location',
'map_component.ariaLabel': 'Interactive map. Use the arrow keys to pan, and plus and minus to zoom.',
'map_component.zoomIn': 'Zoom in',
'map_component.zoomOut': 'Zoom out',
'multiple_select_component.no_options': 'No options available',
'multiple_select_component.placeholder': 'Select...',
'list_component.rowsPerPage': 'Rows per page',
Expand Down
3 changes: 3 additions & 0 deletions src/language/texts/nb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,9 @@ export function nb() {
'validation_errors.formatMaximum': 'Største gyldig verdi er {0}',
'map_component.selectedLocation': 'Valgt lokasjon: {0}° nord, {1}° øst',
'map_component.noSelectedLocation': 'Ingen lokasjon valgt',
'map_component.ariaLabel': 'Interaktivt kart. Bruk piltaster for å panorere, pluss og minus for å zoome.',
'map_component.zoomIn': 'Zoom inn',
'map_component.zoomOut': 'Zoom ut',
'multiple_select_component.no_options': 'Ingen valg tilgjengelig',
'multiple_select_component.placeholder': 'Velg...',
'list_component.rowsPerPage': 'Rader per side',
Expand Down
3 changes: 3 additions & 0 deletions src/language/texts/nn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,9 @@ export function nn() {
'validation_errors.formatMaximum': 'Største gyldige verdi er {0}',
'map_component.selectedLocation': 'Valt lokasjon: {0}° nord, {1}° øst',
'map_component.noSelectedLocation': 'Ingen lokasjon valt',
'map_component.ariaLabel': 'Interaktivt kart. Bruk piltastane for å panorere, pluss og minus for å zoome.',
'map_component.zoomIn': 'Zoom inn',
'map_component.zoomOut': 'Zoom ut',
'multiple_select_component.no_options': 'Ingen valg tilgjengelig',
'multiple_select_component.placeholder': 'Velg...',
'list_component.rowsPerPage': 'Rader per side',
Expand Down
24 changes: 20 additions & 4 deletions src/layout/Map/Map.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useEffect, useRef } from 'react';
import { AttributionControl, MapContainer } from 'react-leaflet';
import { MapContainer, ZoomControl } from 'react-leaflet';
import type { RefObject } from 'react';

import cn from 'classnames';
import { type Map as LeafletMap } from 'leaflet';

import { useLanguage } from 'src/features/language/useLanguage';
import { useIsPdf } from 'src/hooks/useIsPdf';
import { MapRegionA11y } from 'src/layout/Map/features/a11y/MapRegionA11y';
import { MapEditGeometries } from 'src/layout/Map/features/geometries/editable/MapEditGeometries';
import { useMapGeometryBounds } from 'src/layout/Map/features/geometries/fixed/hooks';
import { MapGeometries } from 'src/layout/Map/features/geometries/fixed/MapGeometries';
Expand All @@ -14,6 +16,7 @@ import { useSingleMarker } from 'src/layout/Map/features/singleMarker/hooks';
import { MapSingleMarker } from 'src/layout/Map/features/singleMarker/MapSingleMarker';
import classes from 'src/layout/Map/MapComponent.module.css';
import { DefaultBoundsPadding, DefaultFlyToZoomLevel, getMapStartingView, isLocationValid } from 'src/layout/Map/utils';
import utilClasses from 'src/styles/utils.module.css';
import { useExternalItem } from 'src/utils/layout/hooks';
import { useItemWhenType } from 'src/utils/layout/useNodeItem';

Expand All @@ -27,14 +30,20 @@ type MapProps = {
export function Map({ baseComponentId, className, readOnly, animate = true }: MapProps) {
const map = useRef<LeafletMap | null>(null);
const isPdf = useIsPdf();
const { langAsString } = useLanguage();
const { center, zoom, bounds } = useAutoViewport(baseComponentId, map, animate);
const { toolbar, dataModelBindings } = useItemWhenType(baseComponentId, 'Map');
const simpleBinding = dataModelBindings?.simpleBinding;

return (
<MapContainer
ref={map}
className={cn(classes.map, { [classes.mapReadOnly]: readOnly, [classes.print]: isPdf }, className)}
className={cn(
classes.map,
utilClasses.focusable,
{ [classes.mapReadOnly]: readOnly, [classes.print]: isPdf },
className,
)}
center={center}
zoom={zoom}
bounds={bounds}
Expand All @@ -45,13 +54,21 @@ export function Map({ baseComponentId, className, readOnly, animate = true }: Ma
[90, 200],
]}
fadeAnimation={animate}
zoomControl={!readOnly}
zoomControl={false}
dragging={!readOnly}
touchZoom={!readOnly}
doubleClickZoom={!readOnly}
scrollWheelZoom={!readOnly}
attributionControl={false}
>
<MapRegionA11y baseComponentId={baseComponentId} />
{!readOnly && (
<ZoomControl
position='topleft'
zoomInTitle={langAsString('map_component.zoomIn')}
zoomOutTitle={langAsString('map_component.zoomOut')}
/>
)}
{toolbar !== undefined && !readOnly && <MapEditGeometries baseComponentId={baseComponentId} />}
<MapLayers baseComponentId={baseComponentId} />
<MapGeometries
Expand All @@ -64,7 +81,6 @@ export function Map({ baseComponentId, className, readOnly, animate = true }: Ma
readOnly={readOnly}
/>
)}
<AttributionControl prefix={false} />
</MapContainer>
);
}
Expand Down
24 changes: 16 additions & 8 deletions src/layout/Map/MapComponent.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,21 @@

.map {
position: relative;
width: 100%;
max-width: 100%;
height: 600px;
}

/* Attribution rendered outside the Leaflet container (see MapAttribution.tsx). Kept visually
present as required by the OpenStreetMap/Kartverket licenses. */
.attribution {
margin: 1px 0 0 0;
padding: 2px 0;
font-size: 0.6875rem;
line-height: 1.5;
color: var(--ds-color-neutral-text-subtle);
}

/**
* This will give the map an (almost) correct width for A4 paper before calling print.
* Since it has @media only screen it will still scale to fit the page,
Expand Down Expand Up @@ -46,21 +58,17 @@
*/
font-size: 12px !important;
}
.map div:has(> :global(.leaflet-control-attribution)) {
width: 100%;
}
.map :global(.leaflet-control-attribution) {
background: white !important;
.attribution {
width: 100%;
padding: 0;
text-align: center;
}

.map :global(.leaflet-control-attribution) a {
.attribution a {
color: black;
}

.map :global(.leaflet-control-attribution) a[href]:after {
/* Print the underlying URL after each link, since hyperlinks can't be followed on paper */
.attribution a[href]:after {
content: ' [' attr(href) ']';
}
}
Expand Down
50 changes: 45 additions & 5 deletions src/layout/Map/MapComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,58 @@ describe('MapComponent', () => {
expect(screen.queryByRole('link', { name: 'Dummy link' })).toBeInTheDocument();
});

it('should show map with zoom buttons when readonly is false', async () => {
it('should show map with localized zoom buttons when readonly is false', async () => {
await render();

expect(screen.queryByRole('button', { name: 'Zoom in' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Zoom out' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Zoom inn' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Zoom ut' })).toBeInTheDocument();
});

it('should show map without zoom buttons when readonly is true', async () => {
await render({ component: { readOnly: true } });

expect(screen.queryByRole('button', { name: 'Zoom in' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Zoom out' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Zoom inn' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Zoom ut' })).not.toBeInTheDocument();
});

it('should expose the map as a region with a localized accessible name', async () => {
await render();

expect(
screen.getByRole('region', {
name: 'Interaktivt kart. Bruk piltaster for å panorere, pluss og minus for å zoome.',
}),
).toBeInTheDocument();
});

it('should announce the component label first in the map region name', async () => {
await render({
component: {
textResourceBindings: { title: 'Min Kartkomponent' },
},
});

expect(
screen.getByRole('region', {
name: 'Min Kartkomponent. Interaktivt kart. Bruk piltaster for å panorere, pluss og minus for å zoome.',
}),
).toBeInTheDocument();
});

it('should announce the selection state via a polite live region', async () => {
await render();

const status = screen.getByRole('status');
expect(status).toHaveTextContent('Ingen lokasjon valgt');
expect(status).toHaveAttribute('aria-live', 'polite');
});

it('should render map tiles as decorative (empty alt)', async () => {
const { container } = await render();

const tiles = container.querySelectorAll<HTMLImageElement>('img.leaflet-tile');
expect(tiles.length).toBeGreaterThan(0);
tiles.forEach((tile) => expect(tile.getAttribute('alt')).toBe(''));
});

it('should have expression support for latitude and longitude in centerLocation', async () => {
Expand Down
2 changes: 2 additions & 0 deletions src/layout/Map/MapComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import cn from 'classnames';

import { useIsValid } from 'src/features/validation/selectors/isValid';
import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper';
import { MapAttribution } from 'src/layout/Map/features/attribution/MapAttribution';
import { MarkerLocationText } from 'src/layout/Map/features/singleMarker/MarkerLocationText';
import { Map } from 'src/layout/Map/Map';
import classes from 'src/layout/Map/MapComponent.module.css';
Expand Down Expand Up @@ -34,6 +35,7 @@ export function MapComponent({ baseComponentId }: PropsFromGenericComponent<'Map
readOnly={readOnly ?? false}
/>
</div>
<MapAttribution baseComponentId={baseComponentId} />
{dataModelBindings?.simpleBinding && <MarkerLocationText baseComponentId={baseComponentId} />}
</ComponentStructureWrapper>
);
Expand Down
42 changes: 42 additions & 0 deletions src/layout/Map/features/a11y/MapRegionA11y.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';

import { useLanguage } from 'src/features/language/useLanguage';
import { useItemWhenType } from 'src/utils/layout/useNodeItem';

interface MapRegionA11yProps {
baseComponentId: string;
}

/**
* Sets accessibility attributes directly on the Leaflet container element.
*
* Must be rendered as a child of <MapContainer> so that `useMap()` resolves the Leaflet instance.
* We set the attributes on the real container (via `getContainer()`) rather than forwarding props,
* because MapContainer does not forward arbitrary DOM attributes.
*
* - `role="region"` keeps the screen reader in browse mode so map content (e.g. geometry tooltip
* labels) stays explorable, while still grouping the map under a single named landmark.
* - `aria-label` gives the region a localized accessible name. The component's own label (rendered
* separately by ComponentStructureWrapper) is prepended so it is announced first when the map
* gets focus, followed by the interaction instructions. This way the map is announced once
* instead of as a run-on concatenation of its inner controls.
*/
export function MapRegionA11y({ baseComponentId }: MapRegionA11yProps) {
const map = useMap();
const { langAsString } = useLanguage();
const { textResourceBindings } = useItemWhenType(baseComponentId, 'Map');
const title = textResourceBindings?.title;
const label = title ? langAsString(title) : undefined;

const instructions = langAsString('map_component.ariaLabel');
const ariaLabel = label ? `${label}. ${instructions}` : instructions;

useEffect(() => {
const container = map.getContainer();
container.setAttribute('role', 'region');
container.setAttribute('aria-label', ariaLabel);
}, [map, ariaLabel]);

return null;
}
44 changes: 44 additions & 0 deletions src/layout/Map/features/attribution/MapAttribution.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';

import { parseAndCleanText } from 'src/language/sharedLanguage';
import classes from 'src/layout/Map/MapComponent.module.css';
import { DefaultMapLayers } from 'src/layout/Map/utils';
import { useExternalItem } from 'src/utils/layout/hooks';

interface MapAttributionProps {
baseComponentId: string;
}

/**
* Renders the map layer attribution as plain text/links OUTSIDE the Leaflet container.
*
* Leaflet's built-in AttributionControl renders the attribution inside the map container, which
* causes screen readers to read it as part of the map region and puts the attribution links in the
* tab order of the map. By rendering the attribution ourselves as a sibling of the map, the map
* region stays clean for assistive technology while the attribution remains visually present (as
* required by the OpenStreetMap/Kartverket licenses) with working links.
*/
export function MapAttribution({ baseComponentId }: MapAttributionProps) {

Check warning on line 21 in src/layout/Map/features/attribution/MapAttribution.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-frontend-react&issues=AZ7QbgEFTVL8MORpBqfm&open=AZ7QbgEFTVL8MORpBqfm&pullRequest=4285
const customLayers = useExternalItem(baseComponentId, 'Map').layers;
const layers = customLayers ?? DefaultMapLayers;

// Collect unique, non-empty attributions in layer order (mirrors how Leaflet dedupes them)
const attributions = Array.from(
new Set(layers.map((layer) => layer.attribution?.trim()).filter((a): a is string => !!a)),
);

if (attributions.length === 0) {
return null;
}

return (
<p className={classes.attribution}>
{attributions.map((attribution, i) => (
<React.Fragment key={i}>

Check warning on line 37 in src/layout/Map/features/attribution/MapAttribution.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Array index in keys

See more on https://sonarcloud.io/project/issues?id=Altinn_app-frontend-react&issues=AZ7QbgEFTVL8MORpBqfn&open=AZ7QbgEFTVL8MORpBqfn&pullRequest=4285
{i > 0 && ' | '}
{parseAndCleanText(attribution)}
</React.Fragment>
))}
</p>
);
}
4 changes: 4 additions & 0 deletions src/layout/Map/features/singleMarker/MapSingleMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
iconAnchor: [12, 41],
});

// TODO(a11y): Setting a location is only possible by clicking/dragging on the map, which is not

Check warning on line 23 in src/layout/Map/features/singleMarker/MapSingleMarker.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-frontend-react&issues=AZ7QbgBzTVL8MORpBqfl&open=AZ7QbgBzTVL8MORpBqfl&pullRequest=4285
// operable without sight. To satisfy WCAG 2.1 AA (1.1.1 / non-visual alternative), we still need an
// accessible text/coordinate input (or address search) wired to the same simpleBinding so a value
// can be set without the map. Tracked as a separate task.
Comment thread
adamhaeger marked this conversation as resolved.
Outdated
function MapClickHandler({ onClick }: { onClick: (location: Location) => void }) {
useMapEvent('click', (event) => {
if (!event.originalEvent.defaultPrevented) {
Expand Down
2 changes: 2 additions & 0 deletions src/layout/Map/features/singleMarker/MarkerLocationText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export function MarkerLocationText({ baseComponentId }: { baseComponentId: strin
<Paragraph
data-size='sm'
className={classes.footer}
role='status'
aria-live='polite'
>
{isLocationValid(location) ? (
<Lang
Expand Down
9 changes: 9 additions & 0 deletions test/e2e/integration/component-library/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,13 @@ describe('Map component', () => {
cy.findByRole('link', { name: 'Save' }).click();
cy.get('g>path').should('not.exist');
});

it('should pass accessibility tests', () => {
cy.startAppInstance(appFrontend.apps.componentLibrary, { authenticationLevel: '2' });
cy.gotoNavPage('Kart');

// Wait for the map to be rendered before running the axe check
cy.get('.leaflet-container').should('be.visible');
cy.testWcag();
});
});
Loading