Skip to content

Commit d232f2f

Browse files
committed
IS-11218: refactor to locate accessibilitydata formatting in the formatting layer
1 parent e2a21a4 commit d232f2f

13 files changed

Lines changed: 480 additions & 294 deletions

src/haapi-react-app/src/shared/util/css/styles.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ svg {
279279
@extend .center, .flex, .flex-column;
280280
}
281281

282-
.haapi-stepper-bankid-qr-accessibility {
282+
.haapi-stepper-bankid-qr-code-accessibility {
283283
details + details {
284284
@extend .mt2;
285285
}

src/haapi-react-sdk/haapi-stepper/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ The Curity utility composition shown above is just how *this* project chose to i
231231
| `.haapi-stepper-link-qr-code-dialog` | `HaapiStepperQrCodeLinkDialog` | Fullscreen QR code dialog |
232232
| `.haapi-stepper-link-qr-code-dialog-close-button` | `HaapiStepperQrCodeLinkDialog` | Button wrapping the expanded QR code image; closes the dialog when clicked |
233233
| `.haapi-stepper-link-qr-code-dialog-image` | `HaapiStepperQrCodeLinkDialog` | Fullscreen QR code dialog image |
234-
| `.haapi-stepper-bankid-qr-accessibility` | `HaapiStepperBankIdQrAccessibilityMessages` | Container for the BankID QR-code accessibility messages (the collapsible "help" and "screen reader" `<details>` sections) |
234+
| `.haapi-stepper-bankid-qr-code-accessibility` | `HaapiStepperBankIdQrCodeAccessibilityMessages` | Container for the BankID QR-code accessibility messages (the collapsible "help" and "screen reader" `<details>` sections) |
235235
| `.haapi-stepper-actions` | `HaapiStepperActionsUI` | Actions container |
236236
| `.haapi-stepper-heading` | `HaapiStepperMessagesUI` | Heading messages |
237237
| `.haapi-stepper-userName` | `HaapiStepperMessagesUI` | User name display |

src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ import {
3333
isWebAuthnRegistrationClientOperation,
3434
splitWebAuthnRegistrationAction,
3535
} from '../../actions/client-operation/operations/webauthn';
36+
import { isQrCodeLink } from '../../../util/link-predicates';
37+
import { getQrCodeAccessibility } from './qr-code-accessibility/qr-code-accessibility';
3638

3739
export function formatNextStepData<T extends HaapiActionStep | HaapiCompletedStep | HaapiStepperStep>(
3840
step: T
3941
): T & HaapiStepperDataHelpers {
4042
const isStepWithoutActions =
4143
step.type === HAAPI_STEPS.COMPLETED_WITH_SUCCESS || step.type === HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR;
42-
const linksWithDataHelpers = step.links?.map(link => getElementWithDataHelpers(link)) ?? [];
44+
const linksWithDataHelpers = step.links?.map(link => addLinkDataHelpers(link, step)) ?? [];
4345
const messagesWithDataHelpers = step.messages?.map(message => getElementWithDataHelpers(message)) ?? [];
4446
const dataHelpers = {
4547
messages: messagesWithDataHelpers,
@@ -66,6 +68,20 @@ export function formatNextStepData<T extends HaapiActionStep | HaapiCompletedSte
6668
};
6769
}
6870

71+
function addLinkDataHelpers(
72+
link: HaapiLink,
73+
step: HaapiActionStep | HaapiCompletedStep | HaapiStepperStep
74+
): HaapiStepperLink {
75+
const linkWithDataHelpers = getElementWithDataHelpers(link);
76+
const qrCodeAccessibility = getQrCodeAccessibility(step.metadata?.viewData?.messages);
77+
78+
if (qrCodeAccessibility && isQrCodeLink(linkWithDataHelpers)) {
79+
return { ...linkWithDataHelpers, qrCodeAccessibility };
80+
}
81+
82+
return linkWithDataHelpers;
83+
}
84+
6985
function getNextStepActions(actions: HaapiAction[]): HaapiAction[] {
7086
return actions.flatMap(action =>
7187
isWebAuthnRegistrationClientOperation(action) ? splitWebAuthnRegistrationAction(action) : [action]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright (C) 2026 Curity AB. All rights reserved.
3+
*
4+
* The contents of this file are the property of Curity AB.
5+
* You may not copy or use this file, in either source code
6+
* or executable form, except in compliance with terms
7+
* set by Curity AB.
8+
*
9+
* For further information, please contact Curity AB.
10+
*/
11+
12+
import { describe, expect, it } from 'vitest';
13+
14+
import { getQrCodeAccessibility } from './qr-code-accessibility';
15+
16+
const PREFIX = 'authenticator.bankid.launch.view.qr.';
17+
const key = (suffix: string) => `${PREFIX}${suffix}`;
18+
19+
const INSTRUCTION_MESSAGES: Record<string, string> = {
20+
[key('instruction.heading')]: 'Help with scanning the QR code',
21+
[key('instruction.step1')]: 'Open the BankID app',
22+
[key('instruction.step2')]: 'Press the Scan QR code button',
23+
[key('instruction.step3')]: "Point your phone's camera at the QR code",
24+
[key('instruction.step4')]: 'Follow the instructions in the app',
25+
[key('instruction.outro')]: 'The QR code is displayed for a configurable period.',
26+
};
27+
28+
const SCREEN_READER_MESSAGES: Record<string, string> = {
29+
[key('screen-reader.heading')]: 'If you are using a screen reader',
30+
[key('screen-reader.intro')]: 'The most common error is that the full QR code is not visible. Try to:',
31+
[key('screen-reader.step1')]: 'Ensure the screen is on',
32+
[key('screen-reader.step2')]: 'Zoom out in the browser',
33+
[key('screen-reader.step3')]: 'Zoom out using magnification tools',
34+
[key('screen-reader.step4')]: 'Make sure the browser window is maximized by:',
35+
[key('screen-reader.step4.1')]: 'Clicking on the QR code above or',
36+
[key('screen-reader.step4.2')]: 'Using keyboard shortcuts',
37+
[key('screen-reader.step4.2.1')]: 'Windows: Ctrl+Arrow up',
38+
[key('screen-reader.step4.2.2')]: 'Mac: Ctrl+Cmd+F',
39+
[key('screen-reader.outro')]: 'Hold the phone in portrait mode about 40 cm away from the screen.',
40+
};
41+
42+
describe('getQrCodeAccessibility', () => {
43+
it('builds both sections from a complete message set', () => {
44+
const result = getQrCodeAccessibility({ ...INSTRUCTION_MESSAGES, ...SCREEN_READER_MESSAGES });
45+
46+
expect(result).toEqual({
47+
instruction: {
48+
heading: 'Help with scanning the QR code',
49+
items: [
50+
{ text: 'Open the BankID app' },
51+
{ text: 'Press the Scan QR code button' },
52+
{ text: "Point your phone's camera at the QR code" },
53+
{ text: 'Follow the instructions in the app' },
54+
],
55+
outro: 'The QR code is displayed for a configurable period.',
56+
},
57+
screenReader: {
58+
heading: 'If you are using a screen reader',
59+
intro: 'The most common error is that the full QR code is not visible. Try to:',
60+
items: [
61+
{ text: 'Ensure the screen is on' },
62+
{ text: 'Zoom out in the browser' },
63+
{ text: 'Zoom out using magnification tools' },
64+
{
65+
text: 'Make sure the browser window is maximized by:',
66+
items: [
67+
{ text: 'Clicking on the QR code above or' },
68+
{
69+
text: 'Using keyboard shortcuts',
70+
items: [{ text: 'Windows: Ctrl+Arrow up' }, { text: 'Mac: Ctrl+Cmd+F' }],
71+
},
72+
],
73+
},
74+
],
75+
outro: 'Hold the phone in portrait mode about 40 cm away from the screen.',
76+
},
77+
});
78+
});
79+
80+
it('returns undefined when there are no messages', () => {
81+
expect(getQrCodeAccessibility(undefined)).toBeUndefined();
82+
});
83+
84+
it('builds only the section whose messages are present', () => {
85+
const result = getQrCodeAccessibility(INSTRUCTION_MESSAGES);
86+
87+
expect(result?.instruction).toBeDefined();
88+
expect(result?.screenReader).toBeUndefined();
89+
});
90+
91+
it('omits a section whose message set is incomplete', () => {
92+
const incompleteInstructions = Object.fromEntries(
93+
Object.entries(INSTRUCTION_MESSAGES).filter(([messageKey]) => messageKey !== key('instruction.outro'))
94+
);
95+
96+
expect(getQrCodeAccessibility(incompleteInstructions)).toBeUndefined();
97+
});
98+
99+
it('resolves messages by the ".view.qr." suffix regardless of the key prefix', () => {
100+
const waitPrefixed = Object.fromEntries(
101+
Object.entries(INSTRUCTION_MESSAGES).map(([messageKey, value]) => [
102+
messageKey.replace('.launch.view.qr.', '.wait.view.qr.'),
103+
value,
104+
])
105+
);
106+
107+
expect(getQrCodeAccessibility(waitPrefixed)?.instruction?.heading).toBe('Help with scanning the QR code');
108+
});
109+
110+
it('ignores keys without the ".view.qr." marker', () => {
111+
const result = getQrCodeAccessibility({
112+
...INSTRUCTION_MESSAGES,
113+
'authenticator.bankid.launch.page.title': 'Login with BankID',
114+
});
115+
116+
expect(result?.instruction).toBeDefined();
117+
expect(JSON.stringify(result)).not.toContain('Login with BankID');
118+
});
119+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright (C) 2026 Curity AB. All rights reserved.
3+
*
4+
* The contents of this file are the property of Curity AB.
5+
* You may not copy or use this file, in either source code
6+
* or executable form, except in compliance with terms
7+
* set by Curity AB.
8+
*
9+
* For further information, please contact Curity AB.
10+
*/
11+
12+
import { HaapiStepperQrCodeAccessibility, HaapiStepperQrCodeAccessibilitySection } from '../../haapi-stepper.types';
13+
14+
/**
15+
* Resolves the BankID QR-code accessibility copy carried in `metadata.viewData.messages` into a
16+
* render-ready structure. Returns `undefined` when neither section is complete, so consumers receive
17+
* ready-shaped data without parsing message keys or checking completeness themselves.
18+
*/
19+
export function getQrCodeAccessibility(messages?: Record<string, string>): HaapiStepperQrCodeAccessibility | undefined {
20+
const qr = stripQrCodeViewPrefix(messages);
21+
const instruction = buildInstructionSection(qr);
22+
const screenReader = buildScreenReaderSection(qr);
23+
24+
if (!instruction && !screenReader) {
25+
return undefined;
26+
}
27+
28+
return {
29+
...(instruction && { instruction }),
30+
...(screenReader && { screenReader }),
31+
};
32+
}
33+
34+
/**
35+
* Normalizes the raw `metadata.viewData.messages` map (keys like
36+
* `authenticator.bankid.launch.view.qr.instruction.heading`) into a map keyed by the logical suffix
37+
* after `.view.qr.` (e.g. `instruction.heading`, `screen-reader.step4.2.1`). Keying by the suffix
38+
* keeps this independent of the message-key prefix; keys without the marker are ignored.
39+
*/
40+
function stripQrCodeViewPrefix(messages?: Record<string, string>): Record<string, string> {
41+
const QR_CODE_VIEW_MARKER = '.view.qr.';
42+
43+
return Object.fromEntries(
44+
Object.entries(messages ?? {})
45+
.filter(([key]) => key.includes(QR_CODE_VIEW_MARKER))
46+
.map(([key, value]) => [key.slice(key.lastIndexOf(QR_CODE_VIEW_MARKER) + QR_CODE_VIEW_MARKER.length), value])
47+
);
48+
}
49+
50+
const allPresent = (qr: Record<string, string>, keys: string[]): boolean =>
51+
keys.every(key => typeof qr[key] === 'string' && qr[key].length > 0);
52+
53+
function buildInstructionSection(qr: Record<string, string>): HaapiStepperQrCodeAccessibilitySection | undefined {
54+
if (
55+
!allPresent(qr, [
56+
'instruction.heading',
57+
'instruction.step1',
58+
'instruction.step2',
59+
'instruction.step3',
60+
'instruction.step4',
61+
'instruction.outro',
62+
])
63+
) {
64+
return undefined;
65+
}
66+
return {
67+
heading: qr['instruction.heading'],
68+
items: [qr['instruction.step1'], qr['instruction.step2'], qr['instruction.step3'], qr['instruction.step4']].map(
69+
text => ({ text })
70+
),
71+
outro: qr['instruction.outro'],
72+
};
73+
}
74+
75+
function buildScreenReaderSection(qr: Record<string, string>): HaapiStepperQrCodeAccessibilitySection | undefined {
76+
if (
77+
!allPresent(qr, [
78+
'screen-reader.heading',
79+
'screen-reader.intro',
80+
'screen-reader.step1',
81+
'screen-reader.step2',
82+
'screen-reader.step3',
83+
'screen-reader.step4',
84+
'screen-reader.step4.1',
85+
'screen-reader.step4.2',
86+
'screen-reader.step4.2.1',
87+
'screen-reader.step4.2.2',
88+
'screen-reader.outro',
89+
])
90+
) {
91+
return undefined;
92+
}
93+
return {
94+
heading: qr['screen-reader.heading'],
95+
intro: qr['screen-reader.intro'],
96+
items: [
97+
{ text: qr['screen-reader.step1'] },
98+
{ text: qr['screen-reader.step2'] },
99+
{ text: qr['screen-reader.step3'] },
100+
{
101+
text: qr['screen-reader.step4'],
102+
items: [
103+
{ text: qr['screen-reader.step4.1'] },
104+
{
105+
text: qr['screen-reader.step4.2'],
106+
items: [{ text: qr['screen-reader.step4.2.1'] }, { text: qr['screen-reader.step4.2.2'] }],
107+
},
108+
],
109+
},
110+
],
111+
outro: qr['screen-reader.outro'],
112+
};
113+
}

src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,15 @@ export type HaapiStepperUserMessage = HaapiUserMessage &
164164
/*
165165
* STEP LINK TYPINGS
166166
*/
167-
export type HaapiStepperLink = HaapiLink & HaapiStepperDataHelpersDetails<HAAPI_STEPPER_ELEMENT_TYPES.LINK>;
167+
export type HaapiStepperLink = HaapiLink &
168+
HaapiStepperDataHelpersDetails<HAAPI_STEPPER_ELEMENT_TYPES.LINK> & {
169+
/**
170+
* Render-ready BankID QR-code accessibility copy, resolved from `metadata.viewData.messages`
171+
* during step-data formatting. Present only on the QR-code link when the server supplied a
172+
* complete section.
173+
*/
174+
qrCodeAccessibility?: HaapiStepperQrCodeAccessibility;
175+
};
168176

169177
/*
170178
* ERROR TYPINGS
@@ -257,6 +265,30 @@ export interface HaapiStepperDataHelpers {
257265
links: HaapiStepperLink[];
258266
};
259267
}
268+
269+
/** QR-code accessibility copy, structured for direct rendering. */
270+
export interface HaapiStepperQrCodeAccessibility {
271+
instruction?: HaapiStepperQrCodeAccessibilitySection;
272+
screenReader?: HaapiStepperQrCodeAccessibilitySection;
273+
}
274+
275+
/** One collapsible QR-code accessibility section (instruction / screen-reader). */
276+
export interface HaapiStepperQrCodeAccessibilitySection {
277+
heading: string;
278+
intro?: string;
279+
items: HaapiStepperQrCodeAccessibilityItem[];
280+
outro: string;
281+
}
282+
283+
/**
284+
* A QR-code accessibility list entry, optionally nesting sub-entries (the screen-reader section is
285+
* a nested list).
286+
*/
287+
export interface HaapiStepperQrCodeAccessibilityItem {
288+
text: string;
289+
items?: HaapiStepperQrCodeAccessibilityItem[];
290+
}
291+
260292
export type HaapiStepperDataHelpersDetails<
261293
T,
262294
ST extends T extends HAAPI_STEPPER_ELEMENT_TYPES.ACTION

src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import { isQrCodeLink } from '../../util/link-predicates';
1313
import { getLinksElement } from '../steps/step-element-factories';
14-
import { HaapiStepperBankIdQrAccessibilityMessages } from './HaapiStepperBankIdQrAccessibilityMessages';
14+
import { HaapiStepperBankIdQrCodeAccessibilityMessages } from './HaapiStepperBankIdQrCodeAccessibilityMessages';
1515
import type { ViewNameBuiltInUIProps } from './typings';
1616

1717
/**
@@ -24,18 +24,18 @@ import type { ViewNameBuiltInUIProps } from './typings';
2424
export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => {
2525
const { currentStep, linkRenderInterceptor, loadingElement, errorElement, messagesElement, actionsElement } = props;
2626
const { links } = currentStep.dataHelpers;
27-
const qrLink = links.find(isQrCodeLink);
28-
const nonQrLinks = links.filter(link => !isQrCodeLink(link));
27+
const qrCodeLink = links.find(isQrCodeLink);
28+
const nonQrCodeLinks = links.filter(link => !isQrCodeLink(link));
2929

3030
return (
3131
<>
3232
{loadingElement}
3333
{errorElement}
3434
{messagesElement}
35-
{qrLink && getLinksElement(props, [qrLink], linkRenderInterceptor)}
36-
<HaapiStepperBankIdQrAccessibilityMessages messages={currentStep.metadata?.viewData?.messages} />
35+
{qrCodeLink && getLinksElement(props, [qrCodeLink], linkRenderInterceptor)}
36+
<HaapiStepperBankIdQrCodeAccessibilityMessages qrCodeAccessibility={qrCodeLink?.qrCodeAccessibility} />
3737
{actionsElement}
38-
{nonQrLinks.length > 0 && getLinksElement(props, nonQrLinks, linkRenderInterceptor)}
38+
{nonQrCodeLinks.length > 0 && getLinksElement(props, nonQrCodeLinks, linkRenderInterceptor)}
3939
</>
4040
);
4141
};

0 commit comments

Comments
 (0)