Skip to content
Draft
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
19 changes: 19 additions & 0 deletions src/haapi-react-app/src/shared/util/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,24 @@ svg {
@extend .center, .flex, .flex-column;
}

.haapi-stepper-bankid-qr-accessibility {
details + details {
@extend .mt2;
}

details[open] summary {
margin-bottom: var(--space-1);
}

details[open]::details-content {
padding-block-start: 0;
}

summary + * {
margin-block-start: 0;
}
}

.haapi-stepper-heading {
&:is(h1) {
font-size: calc(var(--type-base-size) * 1.75);
Expand All @@ -298,6 +316,7 @@ svg {
}

.haapi-stepper-polling-progress {
@extend .mt2;
border-radius: var(--form-field-border-radius);
background-color: var(--color-grey-subtle);
outline: 1px solid var(--color-grey-light);
Expand Down
1 change: 1 addition & 0 deletions src/haapi-react-sdk/haapi-stepper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ The Curity utility composition shown above is just how *this* project chose to i
| `.haapi-stepper-link-qr-code-dialog` | `HaapiStepperQrCodeLinkDialog` | Fullscreen QR code dialog |
| `.haapi-stepper-link-qr-code-dialog-close-button` | `HaapiStepperQrCodeLinkDialog` | Button wrapping the expanded QR code image; closes the dialog when clicked |
| `.haapi-stepper-link-qr-code-dialog-image` | `HaapiStepperQrCodeLinkDialog` | Fullscreen QR code dialog image |
| `.haapi-stepper-bankid-qr-accessibility` | `HaapiStepperBankIdQrAccessibilityMessages` | Container for the BankID QR-code accessibility messages (the collapsible "help" and "screen reader" `<details>` sections) |
| `.haapi-stepper-actions` | `HaapiStepperActionsUI` | Actions container |
| `.haapi-stepper-heading` | `HaapiStepperMessagesUI` | Heading messages |
| `.haapi-stepper-userName` | `HaapiStepperMessagesUI` | User name display |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,15 @@ export interface HaapiLink {
title?: string;
}

/**
* View-specific data attached to the response. The shape depends on the view that produced it;
*/
Comment on lines +346 to +348
export interface HaapiViewData {
/** Map of message key to its localized text, as sent by the server for this view. */
messages?: Record<string, string>;
[key: string]: unknown;
}

/**
* Object with additional information about the response. A client may ignore the information present in this object.
*/
Expand All @@ -351,4 +360,6 @@ export interface HaapiMetadata {
templateArea?: string;
/** The name for the view that produced the response */
viewName?: string;
/** View-specific data (e.g. localized accessibility messages) for the view that produced the response */
viewData?: HaapiViewData;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@

import { isQrCodeLink } from '../../util/link-predicates';
import { getLinksElement } from '../steps/step-element-factories';
import { HaapiStepperBankIdQrAccessibilityMessages } from './HaapiStepperBankIdQrAccessibilityMessages';
import type { ViewNameBuiltInUIProps } from './typings';

/**
* Built-in UI for the BankID viewName (`HaapiStepperViewNameBuiltInUI.BANKID`).
*
* - Lifts the QR code link above the actions so it's the primary element on the screen.
* - Renders the QR-code accessibility messages (`metadata.viewData.messages`) as collapsible
* sections below the QR code.
*/
export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => {
const { currentStep, linkRenderInterceptor, loadingElement, errorElement, messagesElement, actionsElement } = props;
Expand All @@ -30,6 +33,7 @@ export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => {
{errorElement}
{messagesElement}
{qrLink && getLinksElement(props, [qrLink], linkRenderInterceptor)}
<HaapiStepperBankIdQrAccessibilityMessages messages={currentStep.metadata?.viewData?.messages} />
{actionsElement}
{nonQrLinks.length > 0 && getLinksElement(props, nonQrLinks, linkRenderInterceptor)}
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* Copyright (C) 2026 Curity AB. All rights reserved.
*
* The contents of this file are the property of Curity AB.
* You may not copy or use this file, in either source code
* or executable form, except in compliance with terms
* set by Curity AB.
*
* For further information, please contact Curity AB.
*/

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

import { HaapiStepperBankIdQrAccessibilityMessages } from './HaapiStepperBankIdQrAccessibilityMessages';

const PREFIX = 'authenticator.bankid.launch.view.qr.';

const key = (suffix: string) => `${PREFIX}${suffix}`;

const INSTRUCTION_MESSAGES: Record<string, string> = {
[key('instruction.heading')]: 'Help with scanning the QR code',
[key('instruction.step1')]: 'Open the BankID app',
[key('instruction.step2')]: 'Press the Scan QR code button',
[key('instruction.step3')]: "Point your phone's camera at the QR code",
[key('instruction.step4')]: 'Follow the instructions in the app',
[key('instruction.outro')]: 'The QR code is displayed for a configurable period.',
};

const SCREEN_READER_MESSAGES: Record<string, string> = {
[key('screen-reader.heading')]: 'If you are using a screen reader',
[key('screen-reader.intro')]: 'The most common error is that the full QR code is not visible. Try to:',
[key('screen-reader.step1')]: 'Ensure the screen is on',
[key('screen-reader.step2')]: 'Zoom out in the browser',
[key('screen-reader.step3')]: 'Zoom out using magnification tools',
[key('screen-reader.step4')]: 'Make sure the browser window is maximized by:',
[key('screen-reader.step4.1')]: 'Clicking on the QR code above or',
[key('screen-reader.step4.2')]: 'Using keyboard shortcuts',
[key('screen-reader.step4.2.1')]: 'Windows: Ctrl+Arrow up',
[key('screen-reader.step4.2.2')]: 'Mac: Ctrl+Cmd+F',
[key('screen-reader.outro')]: 'Hold the phone in portrait mode about 40 cm away from the screen.',
};

const ALL_MESSAGES = { ...INSTRUCTION_MESSAGES, ...SCREEN_READER_MESSAGES };

describe('HaapiStepperBankIdQrAccessibilityMessages', () => {
it('renders both sections, collapsed by default, when all messages are present', () => {
render(<HaapiStepperBankIdQrAccessibilityMessages messages={ALL_MESSAGES} />);

const instructions = screen.getByTestId('bankid-qr-instructions');
const screenReader = screen.getByTestId('bankid-qr-screen-reader');

expect(instructions).not.toHaveAttribute('open');
expect(screenReader).not.toHaveAttribute('open');
expect(screen.getByText('Help with scanning the QR code')).toBeInTheDocument();
expect(screen.getByText('If you are using a screen reader')).toBeInTheDocument();
});

it('expands and collapses a section when its summary is toggled', async () => {
const user = userEvent.setup();
render(<HaapiStepperBankIdQrAccessibilityMessages messages={ALL_MESSAGES} />);

const instructions = screen.getByTestId('bankid-qr-instructions');
const summary = screen.getByText('Help with scanning the QR code');

expect(instructions).not.toHaveAttribute('open');

await user.click(summary);
expect(instructions).toHaveAttribute('open');

await user.click(summary);
expect(instructions).not.toHaveAttribute('open');
});

it('renders every instruction message', () => {
render(<HaapiStepperBankIdQrAccessibilityMessages messages={ALL_MESSAGES} />);

Object.values(INSTRUCTION_MESSAGES).forEach(text => {
expect(screen.getByText(text)).toBeInTheDocument();
});
});

it('renders every screen-reader message', () => {
render(<HaapiStepperBankIdQrAccessibilityMessages messages={ALL_MESSAGES} />);

Object.values(SCREEN_READER_MESSAGES).forEach(text => {
expect(screen.getByText(text)).toBeInTheDocument();
});
});

it('renders the nested screen-reader step structure', () => {
render(<HaapiStepperBankIdQrAccessibilityMessages messages={ALL_MESSAGES} />);

const nestedLeaf = screen.getByText('Windows: Ctrl+Arrow up');
expect(nestedLeaf).toBeInTheDocument();
// step4.2.1 sits two <ul> levels below step4
expect(nestedLeaf.closest('ul')?.parentElement?.textContent).toContain('Using keyboard shortcuts');
});

it('renders nothing when there are no messages', () => {
const { container } = render(<HaapiStepperBankIdQrAccessibilityMessages messages={undefined} />);
expect(container).toBeEmptyDOMElement();
});

it('renders only the instruction section when screen-reader messages are missing', () => {
render(<HaapiStepperBankIdQrAccessibilityMessages messages={INSTRUCTION_MESSAGES} />);

expect(screen.getByTestId('bankid-qr-instructions')).toBeInTheDocument();
expect(screen.queryByTestId('bankid-qr-screen-reader')).not.toBeInTheDocument();
});

it('renders only the screen-reader section when instruction messages are missing', () => {
render(<HaapiStepperBankIdQrAccessibilityMessages messages={SCREEN_READER_MESSAGES} />);

expect(screen.getByTestId('bankid-qr-screen-reader')).toBeInTheDocument();
expect(screen.queryByTestId('bankid-qr-instructions')).not.toBeInTheDocument();
});

it('does not render a section when its message set is incomplete', () => {
const incompleteInstructions = Object.fromEntries(
Object.entries(INSTRUCTION_MESSAGES).filter(([messageKey]) => messageKey !== key('instruction.outro'))
);
render(<HaapiStepperBankIdQrAccessibilityMessages messages={incompleteInstructions} />);

expect(screen.queryByTestId('bankid-qr-instructions')).not.toBeInTheDocument();
});

it('resolves messages by the ".view.qr." suffix regardless of the key prefix', () => {
// Keys are matched by their `view.qr.<suffix>` tail, so a different prefix segment
// (e.g. `wait` instead of `launch`) still renders the section.
const waitPrefixed = Object.fromEntries(
Object.entries(INSTRUCTION_MESSAGES).map(([messageKey, value]) => [
messageKey.replace('.launch.view.qr.', '.wait.view.qr.'),
value,
])
);
render(<HaapiStepperBankIdQrAccessibilityMessages messages={waitPrefixed} />);

expect(screen.getByTestId('bankid-qr-instructions')).toBeInTheDocument();
expect(screen.getByText('Help with scanning the QR code')).toBeInTheDocument();
});

it('ignores keys without the ".view.qr." marker', () => {
render(
<HaapiStepperBankIdQrAccessibilityMessages
messages={{
...INSTRUCTION_MESSAGES,
'authenticator.bankid.launch.page.title': 'Login with BankID',
}}
/>
);

expect(screen.getByTestId('bankid-qr-instructions')).toBeInTheDocument();
expect(screen.queryByText('Login with BankID')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright (C) 2026 Curity AB. All rights reserved.
*
* The contents of this file are the property of Curity AB.
* You may not copy or use this file, in either source code
* or executable form, except in compliance with terms
* set by Curity AB.
*
* For further information, please contact Curity AB.
*/

import { getQrViewDataMessages } from '../../util/qr-view-data-messages';

/**
* Renders the BankID QR-code accessibility messages carried in `metadata.viewData.messages` as two
* collapsible sections, mirroring the classic Velocity layout:
*
* - "Help with scanning the QR code" (`instruction.*`)
* - "If you are using a screen reader" (`screen-reader.*`)
*
* Rendering is defensive: a section is shown only when all of its messages are present, so nothing is
* rendered against servers that don't emit this view data (or other authenticators).
*
* Exported so consumers building a custom BankID UI can reuse it.
*/
export const HaapiStepperBankIdQrAccessibilityMessages = ({ messages }: { messages?: Record<string, string> }) => {
const qr = getQrViewDataMessages(messages);
const has = (...keys: string[]) =>
keys.every(key => {
const value = qr[key];
return typeof value === 'string' && value.length > 0;
});

const showInstruction = has(
'instruction.heading',
'instruction.step1',
'instruction.step2',
'instruction.step3',
'instruction.step4',
'instruction.outro'
);

const showScreenReader = has(
'screen-reader.heading',
'screen-reader.intro',
'screen-reader.step1',
'screen-reader.step2',
'screen-reader.step3',
'screen-reader.step4',
'screen-reader.step4.1',
'screen-reader.step4.2',
'screen-reader.step4.2.1',
'screen-reader.step4.2.2',
'screen-reader.outro'
);

if (!showInstruction && !showScreenReader) {
return null;
}

return (
<div className="haapi-stepper-bankid-qr-accessibility" data-testid="bankid-qr-accessibility">
{showInstruction && (
<details data-testid="bankid-qr-instructions">
<summary>{qr['instruction.heading']}</summary>
<ul>
<li>{qr['instruction.step1']}</li>
<li>{qr['instruction.step2']}</li>
<li>{qr['instruction.step3']}</li>
<li>{qr['instruction.step4']}</li>
</ul>
<p>{qr['instruction.outro']}</p>
</details>
)}
{showScreenReader && (
<details data-testid="bankid-qr-screen-reader">
<summary>{qr['screen-reader.heading']}</summary>
<p>{qr['screen-reader.intro']}</p>
<ul>
<li>{qr['screen-reader.step1']}</li>
<li>{qr['screen-reader.step2']}</li>
<li>{qr['screen-reader.step3']}</li>
<li>
{qr['screen-reader.step4']}
<ul>
<li>{qr['screen-reader.step4.1']}</li>
<li>
{qr['screen-reader.step4.2']}
<ul>
<li>{qr['screen-reader.step4.2.1']}</li>
<li>{qr['screen-reader.step4.2.2']}</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>{qr['screen-reader.outro']}</p>
</details>
)}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './typings';
export * from './viewname.types';
export * from './viewname-built-in-uis';
export * from './BankIdViewNameBuiltInUI';
export * from './HaapiStepperBankIdQrAccessibilityMessages';
24 changes: 24 additions & 0 deletions src/haapi-react-sdk/haapi-stepper/util/qr-view-data-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Curity AB. All rights reserved.
*
* The contents of this file are the property of Curity AB.
* You may not copy or use this file, in either source code
* or executable form, except in compliance with terms
* set by Curity AB.
*
* For further information, please contact Curity AB.
*/

/**
* Normalizes the raw `metadata.viewData.messages` map (keys like
* `authenticator.bankid.launch.view.qr.instruction.heading`) into a map keyed by the logical suffix
* after `.view.qr.` (e.g. `instruction.heading`, `screen-reader.step4.2.1`).
*/
export function getQrViewDataMessages(messages?: Record<string, string>): Record<string, string> {
const VIEW_QR_MARKER = '.view.qr.';
return Object.fromEntries(
Object.entries(messages ?? {})
.filter(([key]) => key.includes(VIEW_QR_MARKER))
.map(([key, value]) => [key.slice(key.lastIndexOf(VIEW_QR_MARKER) + VIEW_QR_MARKER.length), value])
);
}
Loading