Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/app-components/Label/Fieldset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { IGridStyling } from 'src/layout/common.generated';

export type FieldsetProps = {
id?: string;
'aria-describedby'?: string;
legend: string | ReactElement | undefined;
legendSize?: Extract<DesignsystemetLabelProps['data-size'], 'sm' | 'md' | 'lg' | 'xl'>;
className?: string;
Expand All @@ -26,6 +27,7 @@ export type FieldsetProps = {

export function Fieldset({
id,
'aria-describedby': ariaDescribedBy,
children,
className,
legend,
Expand Down Expand Up @@ -76,6 +78,7 @@ export function Fieldset({
className={cn(className)}
data-size={size}
aria-labelledby={`${legendId} ${descriptionId}`}
aria-describedby={ariaDescribedBy}
>
<DesignsystemetFieldset.Legend
id={legendId}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';

import { render, screen } from '@testing-library/react';

import { LiveValidationMessage } from 'src/app-components/ValidationMessage/LiveValidationMessage';

describe('LiveValidationMessage', () => {
it('keeps a polite aria-live region (with the given id) mounted, and only shows the message when show is true', () => {
const { container, rerender } = render(
<LiveValidationMessage
show={false}
id='my-error'
>
Error message
</LiveValidationMessage>,
);

const region = container.querySelector('#my-error');
expect(region).toBeInTheDocument();
expect(region).toHaveAttribute('aria-live', 'polite');
expect(screen.queryByText('Error message')).not.toBeInTheDocument();

rerender(
<LiveValidationMessage
show
id='my-error'
>
Error message
</LiveValidationMessage>,
);

expect(screen.getByText('Error message')).toBeInTheDocument();
});

it('supports an assertive live region', () => {
const { container } = render(
<LiveValidationMessage
show={false}
live='assertive'
>
Error message
</LiveValidationMessage>,
);

expect(container.querySelector('[aria-live]')).toHaveAttribute('aria-live', 'assertive');
});
});
28 changes: 28 additions & 0 deletions src/app-components/ValidationMessage/LiveValidationMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import type { ComponentProps } from 'react';

import { ValidationMessage } from '@digdir/designsystemet-react';

type LiveValidationMessageProps = {
/** Whether the validation message should be shown. */
show: boolean;
id?: string;
/** Politeness of the live region. Defaults to `polite`. */
live?: 'polite' | 'assertive';
} & ComponentProps<typeof ValidationMessage>;

/**
* Renders a {@link ValidationMessage} inside an always-present `aria-live` region. Keeping the
* region mounted (and toggling only the message inside it) is what allows screen readers to
* announce the message when it appears.
*/
export function LiveValidationMessage({ show, id, live = 'polite', children, ...rest }: LiveValidationMessageProps) {
return (
<div
id={id}
aria-live={live}
>
{show && <ValidationMessage {...rest}>{children}</ValidationMessage>}
</div>
);
}
58 changes: 58 additions & 0 deletions src/core/contexts/ElementFocusProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React, { useState } from 'react';

import { fireEvent, render, screen } from '@testing-library/react';

import { ElementFocusProvider, useFocusOnRequest, useRequestFocus } from 'src/core/contexts/ElementFocusProvider';

/**
* Renders a heading wired up with the focus hooks, plus buttons to request focus and to remount the
* heading (simulating a view that swaps as state changes). The heading keeps a stable test id while
* its text changes per remount.
*/
function Harness() {
const requestFocus = useRequestFocus();
const focusRef = useFocusOnRequest();
const [remountCount, setRemountCount] = useState(0);

return (
<div>
<button onClick={() => requestFocus()}>request</button>
<button onClick={() => setRemountCount((count) => count + 1)}>remount</button>
<h2
key={remountCount}
ref={focusRef}
data-testid='heading'
>
Heading {remountCount}
</h2>
</div>
);
}

describe('ElementFocusProvider', () => {
it('focuses elements that mount after focus is requested, and stays active across remounts', () => {
render(
<ElementFocusProvider>
<Harness />
</ElementFocusProvider>,
);

// No focus on initial mount when focus has not been requested.
expect(screen.getByTestId('heading')).not.toHaveFocus();

// Requesting focus does not move focus to an already-mounted element (its ref callback does not
// run again).
fireEvent.click(screen.getByText('request'));
expect(screen.getByTestId('heading')).not.toHaveFocus();

// The next element to mount after the request receives focus, and is made focusable.
fireEvent.click(screen.getByText('remount'));
expect(screen.getByTestId('heading')).toHaveFocus();
expect(screen.getByTestId('heading')).toHaveTextContent('Heading 1');

// The request stays active across further remounts, so the final element wins.
fireEvent.click(screen.getByText('remount'));
expect(screen.getByTestId('heading')).toHaveFocus();
expect(screen.getByTestId('heading')).toHaveTextContent('Heading 2');
});
});
68 changes: 68 additions & 0 deletions src/core/contexts/ElementFocusProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { createContext, useCallback, useContext, useMemo, useRef } from 'react';
import type { PropsWithChildren } from 'react';

type ElementFocusContextValue = {
setRequestFocus: (value: boolean) => void;
getRequestFocus: () => boolean;
};

const ElementFocusContext = createContext<ElementFocusContextValue | null>(null);

/**
* Provider to coordinates moving focus to an element after a user action. The element receiving focus may be in
* a component that unmounts/remounts (e.g. a view that swaps as state changes), so the request to focus lives in a context
* above it and survives those transitions.
*
* Usage :
* 1. Wrap the part of the component tree where you want to manage focus with `ElementFocusProvider`.
* 2. Use `useFocusOnRequest` to get a ref callback to attach to the element that should receive focus after the user action.
* 3. Call the function returned by `useRequestFocus` after the user action that should trigger the focus change
*/
export function ElementFocusProvider({ children }: PropsWithChildren) {

Check warning on line 21 in src/core/contexts/ElementFocusProvider.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=AZ7QQRpa6EJ9mH8o4NZU&open=AZ7QQRpa6EJ9mH8o4NZU&pullRequest=4284
const focusRequestedRef = useRef(false);
const value = useMemo<ElementFocusContextValue>(
() => ({
setRequestFocus: (value: boolean) => {
focusRequestedRef.current = value;
},
getRequestFocus: () => focusRequestedRef.current,
}),
[],
);
return <ElementFocusContext.Provider value={value}>{children}</ElementFocusContext.Provider>;
}

/**
* Returns a ref callback to attach to an element. Once focus has been requested (via
* {@link useRequestFocus}), the element that mounts with this ref receives focus so screen readers
* announce it.
*/
export function useFocusOnRequest() {
const context = useContext(ElementFocusContext);

return useCallback(
(node: HTMLElement | null) => {
if (!node || !context || !context.getRequestFocus()) {

Check warning on line 45 in src/core/contexts/ElementFocusProvider.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-frontend-react&issues=AZ7QQRpa6EJ9mH8o4NZV&open=AZ7QQRpa6EJ9mH8o4NZV&pullRequest=4284
return;
}

// A non-focusable element needs a tabindex to receive focus.
if (!node.hasAttribute('tabindex')) {
node.setAttribute('tabindex', '-1');
node.addEventListener('blur', () => node.removeAttribute('tabindex'), { once: true });
}
node.focus();
},
[context],
);
}

/**
* Returns a function to call after a user action that changes which element should be focused (e.g.
* after a mutation succeeds), so the element registered with {@link useFocusOnRequest} receives
* focus and is announced by screen readers.
*/
export function useRequestFocus() {
const context = useContext(ElementFocusContext);
return useCallback(() => context?.setRequestFocus(true), [context]);
}
19 changes: 11 additions & 8 deletions src/layout/SigningActions/OnBehalfOfChooser.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React from 'react';
import React, { useId } from 'react';
import type { ChangeEvent } from 'react';

import { ValidationMessage } from '@digdir/designsystemet-react';

import { Fieldset } from 'src/app-components/Label/Fieldset';
import { LiveValidationMessage } from 'src/app-components/ValidationMessage/LiveValidationMessage';
import { RadioButton } from 'src/components/form/RadioButton';
import { RequiredIndicator } from 'src/components/form/RequiredIndicator';
import { Lang } from 'src/features/language/Lang';
Expand All @@ -27,13 +26,15 @@ export const OnBehalfOfChooser = ({
error = false,
}: Readonly<OnBehalfOfChooserProps>) => {
const mySelf = useLanguage().langAsString('signing.submit_panel_myself_choice');
const errorId = useId();

return (
<Fieldset
legend={<Lang id='signing.submit_panel_radio_group_legend' />}
description={<Lang id='signing.submit_panel_radio_group_description' />}
required={true}
requiredIndicator={<RequiredIndicator />}
aria-describedby={errorId}
>
{currentUserSignee && (
<RadioButton
Expand All @@ -56,11 +57,13 @@ export const OnBehalfOfChooser = ({
checked={onBehalfOfOrg === org.orgNumber}
/>
))}
{error && (
<ValidationMessage data-size='sm'>
<Lang id='signing.error_signing_no_on_behalf_of' />
</ValidationMessage>
)}
<LiveValidationMessage
show={error}
id={errorId}
data-size='sm'
>
<Lang id='signing.error_signing_no_on_behalf_of' />
</LiveValidationMessage>
</Fieldset>
);
};
25 changes: 18 additions & 7 deletions src/layout/SigningActions/PanelAwaitingCurrentUserSignature.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useId, useState } from 'react';
import { useParams } from 'react-router-dom';

import { Checkbox, Heading, ValidationMessage } from '@digdir/designsystemet-react';
import { Checkbox, Heading } from '@digdir/designsystemet-react';

import { Button } from 'src/app-components/Button/Button';
import { Spinner } from 'src/app-components/loading/Spinner/Spinner';
import { Panel } from 'src/app-components/Panel/Panel';
import { LiveValidationMessage } from 'src/app-components/ValidationMessage/LiveValidationMessage';
import { useRequestFocus } from 'src/core/contexts/ElementFocusProvider';
import { useIsAuthorized } from 'src/features/instance/useProcessQuery';
import { UnknownError } from 'src/features/instantiate/containers/UnknownError';
import { Lang } from 'src/features/language/Lang';
Expand Down Expand Up @@ -51,6 +53,8 @@ export function AwaitingCurrentUserSignaturePanel({
const [onBehalfOf, setOnBehalfOf] = useState<string | null>(null);
const [onBehalfOfError, setOnBehalfOfError] = useState(false);
const [confirmReadDocumentsError, setConfirmReadDocumentsError] = useState(false);
const confirmReadDocumentsErrorId = useId();
const requestPanelFocus = useRequestFocus();

const { data: authorizedOrganizationDetails, isLoading: isApiLoading } = useAuthorizedOrganizationDetails(
instanceOwnerPartyId!,
Expand Down Expand Up @@ -85,6 +89,9 @@ export function AwaitingCurrentUserSignaturePanel({
onSuccess: () => {
setConfirmReadDocuments(false);
setOnBehalfOf(null);
// Move focus to the heading of the panel shown after signing, so screen readers announce
// the new state.
requestPanelFocus();
},
});
}
Expand Down Expand Up @@ -178,12 +185,16 @@ export function AwaitingCurrentUserSignaturePanel({
}}
className={classes.checkbox}
label={<Lang id={checkboxLabel} />}
aria-invalid={confirmReadDocumentsError}
aria-describedby={confirmReadDocumentsErrorId}
/>
{confirmReadDocumentsError && (
<ValidationMessage data-size='sm'>
<Lang id='signing.error_signing_not_confirmed_documents' />
</ValidationMessage>
)}
<LiveValidationMessage
show={confirmReadDocumentsError}
id={confirmReadDocumentsErrorId}
data-size='sm'
>
<Lang id='signing.error_signing_not_confirmed_documents' />
</LiveValidationMessage>
</div>
</SigningPanel>
);
Expand Down
8 changes: 6 additions & 2 deletions src/layout/SigningActions/PanelSigning.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React, { useRef } from 'react';
import type { PropsWithChildren, ReactElement } from 'react';

import { Dialog, Heading, Paragraph, ValidationMessage } from '@digdir/designsystemet-react';
import { Dialog, Heading, Paragraph } from '@digdir/designsystemet-react';

import { Button } from 'src/app-components/Button/Button';
import { Panel } from 'src/app-components/Panel/Panel';
import { LiveValidationMessage } from 'src/app-components/ValidationMessage/LiveValidationMessage';
import { useFocusOnRequest } from 'src/core/contexts/ElementFocusProvider';
import { useProcessNext } from 'src/features/instance/useProcessNext';
import { useIsAuthorized } from 'src/features/instance/useProcessQuery';
import { Lang } from 'src/features/language/Lang';
Expand All @@ -31,6 +33,7 @@ export function SigningPanel({
children,
}: PropsWithChildren<SigningPanelProps>) {
const canReject = useIsAuthorized()('reject');
const focusHeadingOnChange = useFocusOnRequest();

return (
<Panel
Expand All @@ -40,6 +43,7 @@ export function SigningPanel({
>
<div className={classes.contentContainer}>
<Heading
ref={focusHeadingOnChange}
level={4}
data-size='xs'
>
Expand All @@ -53,7 +57,7 @@ export function SigningPanel({
{actionButton}
{canReject && <RejectButton baseComponentId={baseComponentId} />}
</div>
{errorMessage && <ValidationMessage>{errorMessage}</ValidationMessage>}
<LiveValidationMessage show={!!errorMessage}>{errorMessage}</LiveValidationMessage>
</div>
</div>
</Panel>
Expand Down
11 changes: 10 additions & 1 deletion src/layout/SigningActions/SigningActionsComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { FatalError } from 'src/app-components/error/FatalError/FatalError';
import { Spinner } from 'src/app-components/loading/Spinner/Spinner';
import { Panel } from 'src/app-components/Panel/Panel';
import { ElementFocusProvider } from 'src/core/contexts/ElementFocusProvider';
import { useIsAuthorized } from 'src/features/instance/useProcessQuery';
import { Lang } from 'src/features/language/Lang';
import { useLanguage } from 'src/features/language/useLanguage';
Expand All @@ -19,7 +20,15 @@
import { getCurrentUserStatus } from 'src/layout/SigningActions/utils';
import type { PropsFromGenericComponent } from 'src/layout';

export function SigningActionsComponent({ baseComponentId }: PropsFromGenericComponent<'SigningActions'>) {
export function SigningActionsComponent(props: PropsFromGenericComponent<'SigningActions'>) {

Check warning on line 23 in src/layout/SigningActions/SigningActionsComponent.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=AZ7QQRpR6EJ9mH8o4NZS&open=AZ7QQRpR6EJ9mH8o4NZS&pullRequest=4284
return (
<ElementFocusProvider>
<SigningActionsPanels {...props} />
</ElementFocusProvider>
);
}

function SigningActionsPanels({ baseComponentId }: PropsFromGenericComponent<'SigningActions'>) {

Check warning on line 31 in src/layout/SigningActions/SigningActionsComponent.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=AZ7QQRpR6EJ9mH8o4NZT&open=AZ7QQRpR6EJ9mH8o4NZT&pullRequest=4284
const { instanceOwnerPartyId, instanceGuid, taskId } = useParams();
const {
data: signeeList,
Expand Down
Loading