From e2a21a4a525c46389d1b0704fd893ea950153c5b Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 2 Jul 2026 15:36:29 +0200 Subject: [PATCH 1/2] IS-11218 Render BankID QR accessibility messages as collapsible sections Co-Authored-By: Claude Opus 4.8 --- .../src/shared/util/css/styles.css | 19 +++ src/haapi-react-sdk/haapi-stepper/README.md | 1 + .../data-access/types/haapi-step.types.ts | 11 ++ .../viewnames/BankIdViewNameBuiltInUI.tsx | 4 + ...pperBankIdQrAccessibilityMessages.spec.tsx | 157 ++++++++++++++++++ ...piStepperBankIdQrAccessibilityMessages.tsx | 102 ++++++++++++ .../haapi-stepper/feature/viewnames/index.ts | 1 + .../util/qr-view-data-messages.ts | 24 +++ 8 files changed, 319 insertions(+) create mode 100644 src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.spec.tsx create mode 100644 src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.tsx create mode 100644 src/haapi-react-sdk/haapi-stepper/util/qr-view-data-messages.ts diff --git a/src/haapi-react-app/src/shared/util/css/styles.css b/src/haapi-react-app/src/shared/util/css/styles.css index a5780d92..0f95f299 100644 --- a/src/haapi-react-app/src/shared/util/css/styles.css +++ b/src/haapi-react-app/src/shared/util/css/styles.css @@ -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); @@ -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); diff --git a/src/haapi-react-sdk/haapi-stepper/README.md b/src/haapi-react-sdk/haapi-stepper/README.md index 0e5465e2..a880eb68 100644 --- a/src/haapi-react-sdk/haapi-stepper/README.md +++ b/src/haapi-react-sdk/haapi-stepper/README.md @@ -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" `
` sections) | | `.haapi-stepper-actions` | `HaapiStepperActionsUI` | Actions container | | `.haapi-stepper-heading` | `HaapiStepperMessagesUI` | Heading messages | | `.haapi-stepper-userName` | `HaapiStepperMessagesUI` | User name display | diff --git a/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types.ts b/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types.ts index 7569c918..7ae554b4 100644 --- a/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types.ts +++ b/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types.ts @@ -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; + */ +export interface HaapiViewData { + /** Map of message key to its localized text, as sent by the server for this view. */ + messages?: Record; + [key: string]: unknown; +} + /** * Object with additional information about the response. A client may ignore the information present in this object. */ @@ -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; } diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx index a8ff3d18..ccc76e25 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx @@ -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; @@ -30,6 +33,7 @@ export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => { {errorElement} {messagesElement} {qrLink && getLinksElement(props, [qrLink], linkRenderInterceptor)} + {actionsElement} {nonQrLinks.length > 0 && getLinksElement(props, nonQrLinks, linkRenderInterceptor)} diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.spec.tsx new file mode 100644 index 00000000..1e8c3b3b --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.spec.tsx @@ -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 = { + [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 = { + [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(); + + 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(); + + 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(); + + Object.values(INSTRUCTION_MESSAGES).forEach(text => { + expect(screen.getByText(text)).toBeInTheDocument(); + }); + }); + + it('renders every screen-reader message', () => { + render(); + + Object.values(SCREEN_READER_MESSAGES).forEach(text => { + expect(screen.getByText(text)).toBeInTheDocument(); + }); + }); + + it('renders the nested screen-reader step structure', () => { + render(); + + const nestedLeaf = screen.getByText('Windows: Ctrl+Arrow up'); + expect(nestedLeaf).toBeInTheDocument(); + // step4.2.1 sits two
    levels below step4 + expect(nestedLeaf.closest('ul')?.parentElement?.textContent).toContain('Using keyboard shortcuts'); + }); + + it('renders nothing when there are no messages', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders only the instruction section when screen-reader messages are missing', () => { + render(); + + 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(); + + 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(); + + 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.` 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(); + + 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( + + ); + + expect(screen.getByTestId('bankid-qr-instructions')).toBeInTheDocument(); + expect(screen.queryByText('Login with BankID')).not.toBeInTheDocument(); + }); +}); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.tsx new file mode 100644 index 00000000..9e51a448 --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.tsx @@ -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 }) => { + 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 ( +
    + {showInstruction && ( +
    + {qr['instruction.heading']} +
      +
    • {qr['instruction.step1']}
    • +
    • {qr['instruction.step2']}
    • +
    • {qr['instruction.step3']}
    • +
    • {qr['instruction.step4']}
    • +
    +

    {qr['instruction.outro']}

    +
    + )} + {showScreenReader && ( +
    + {qr['screen-reader.heading']} +

    {qr['screen-reader.intro']}

    +
      +
    • {qr['screen-reader.step1']}
    • +
    • {qr['screen-reader.step2']}
    • +
    • {qr['screen-reader.step3']}
    • +
    • + {qr['screen-reader.step4']} +
        +
      • {qr['screen-reader.step4.1']}
      • +
      • + {qr['screen-reader.step4.2']} +
          +
        • {qr['screen-reader.step4.2.1']}
        • +
        • {qr['screen-reader.step4.2.2']}
        • +
        +
      • +
      +
    • +
    +

    {qr['screen-reader.outro']}

    +
    + )} +
    + ); +}; diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/index.ts b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/index.ts index 57b35631..dc8b5b68 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/index.ts +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/index.ts @@ -13,3 +13,4 @@ export * from './typings'; export * from './viewname.types'; export * from './viewname-built-in-uis'; export * from './BankIdViewNameBuiltInUI'; +export * from './HaapiStepperBankIdQrAccessibilityMessages'; diff --git a/src/haapi-react-sdk/haapi-stepper/util/qr-view-data-messages.ts b/src/haapi-react-sdk/haapi-stepper/util/qr-view-data-messages.ts new file mode 100644 index 00000000..9bbb8b8e --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/util/qr-view-data-messages.ts @@ -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): Record { + 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]) + ); +} From d232f2f8cc279b24ce02c2e79fe2b5f93e75c2a4 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Tue, 7 Jul 2026 14:23:13 +0200 Subject: [PATCH 2/2] IS-11218: refactor to locate accessibilitydata formatting in the formatting layer --- .../src/shared/util/css/styles.css | 2 +- src/haapi-react-sdk/haapi-stepper/README.md | 2 +- .../data-formatters/format-next-step-data.ts | 18 +- .../qr-code-accessibility.spec.ts | 119 +++++++++++++ .../qr-code-accessibility.ts | 113 +++++++++++++ .../feature/stepper/haapi-stepper.types.ts | 34 +++- .../viewnames/BankIdViewNameBuiltInUI.tsx | 12 +- ...pperBankIdQrAccessibilityMessages.spec.tsx | 157 ------------------ ...piStepperBankIdQrAccessibilityMessages.tsx | 102 ------------ ...BankIdQrCodeAccessibilityMessages.spec.tsx | 121 ++++++++++++++ ...epperBankIdQrCodeAccessibilityMessages.tsx | 68 ++++++++ .../haapi-stepper/feature/viewnames/index.ts | 2 +- .../util/qr-view-data-messages.ts | 24 --- 13 files changed, 480 insertions(+), 294 deletions(-) create mode 100644 src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-accessibility/qr-code-accessibility.spec.ts create mode 100644 src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-accessibility/qr-code-accessibility.ts delete mode 100644 src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.spec.tsx delete mode 100644 src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.tsx create mode 100644 src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.spec.tsx create mode 100644 src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.tsx delete mode 100644 src/haapi-react-sdk/haapi-stepper/util/qr-view-data-messages.ts diff --git a/src/haapi-react-app/src/shared/util/css/styles.css b/src/haapi-react-app/src/shared/util/css/styles.css index 0f95f299..5c020aef 100644 --- a/src/haapi-react-app/src/shared/util/css/styles.css +++ b/src/haapi-react-app/src/shared/util/css/styles.css @@ -279,7 +279,7 @@ svg { @extend .center, .flex, .flex-column; } -.haapi-stepper-bankid-qr-accessibility { +.haapi-stepper-bankid-qr-code-accessibility { details + details { @extend .mt2; } diff --git a/src/haapi-react-sdk/haapi-stepper/README.md b/src/haapi-react-sdk/haapi-stepper/README.md index a880eb68..f8012b96 100644 --- a/src/haapi-react-sdk/haapi-stepper/README.md +++ b/src/haapi-react-sdk/haapi-stepper/README.md @@ -231,7 +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" `
    ` sections) | +| `.haapi-stepper-bankid-qr-code-accessibility` | `HaapiStepperBankIdQrCodeAccessibilityMessages` | Container for the BankID QR-code accessibility messages (the collapsible "help" and "screen reader" `
    ` sections) | | `.haapi-stepper-actions` | `HaapiStepperActionsUI` | Actions container | | `.haapi-stepper-heading` | `HaapiStepperMessagesUI` | Heading messages | | `.haapi-stepper-userName` | `HaapiStepperMessagesUI` | User name display | diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts index 74770346..24b2202f 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/format-next-step-data.ts @@ -33,13 +33,15 @@ import { isWebAuthnRegistrationClientOperation, splitWebAuthnRegistrationAction, } from '../../actions/client-operation/operations/webauthn'; +import { isQrCodeLink } from '../../../util/link-predicates'; +import { getQrCodeAccessibility } from './qr-code-accessibility/qr-code-accessibility'; export function formatNextStepData( step: T ): T & HaapiStepperDataHelpers { const isStepWithoutActions = step.type === HAAPI_STEPS.COMPLETED_WITH_SUCCESS || step.type === HAAPI_PROBLEM_STEPS.COMPLETED_WITH_ERROR; - const linksWithDataHelpers = step.links?.map(link => getElementWithDataHelpers(link)) ?? []; + const linksWithDataHelpers = step.links?.map(link => addLinkDataHelpers(link, step)) ?? []; const messagesWithDataHelpers = step.messages?.map(message => getElementWithDataHelpers(message)) ?? []; const dataHelpers = { messages: messagesWithDataHelpers, @@ -66,6 +68,20 @@ export function formatNextStepData isWebAuthnRegistrationClientOperation(action) ? splitWebAuthnRegistrationAction(action) : [action] diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-accessibility/qr-code-accessibility.spec.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-accessibility/qr-code-accessibility.spec.ts new file mode 100644 index 00000000..34f82257 --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-accessibility/qr-code-accessibility.spec.ts @@ -0,0 +1,119 @@ +/* + * 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 { describe, expect, it } from 'vitest'; + +import { getQrCodeAccessibility } from './qr-code-accessibility'; + +const PREFIX = 'authenticator.bankid.launch.view.qr.'; +const key = (suffix: string) => `${PREFIX}${suffix}`; + +const INSTRUCTION_MESSAGES: Record = { + [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 = { + [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.', +}; + +describe('getQrCodeAccessibility', () => { + it('builds both sections from a complete message set', () => { + const result = getQrCodeAccessibility({ ...INSTRUCTION_MESSAGES, ...SCREEN_READER_MESSAGES }); + + expect(result).toEqual({ + instruction: { + heading: 'Help with scanning the QR code', + items: [ + { text: 'Open the BankID app' }, + { text: 'Press the Scan QR code button' }, + { text: "Point your phone's camera at the QR code" }, + { text: 'Follow the instructions in the app' }, + ], + outro: 'The QR code is displayed for a configurable period.', + }, + screenReader: { + heading: 'If you are using a screen reader', + intro: 'The most common error is that the full QR code is not visible. Try to:', + items: [ + { text: 'Ensure the screen is on' }, + { text: 'Zoom out in the browser' }, + { text: 'Zoom out using magnification tools' }, + { + text: 'Make sure the browser window is maximized by:', + items: [ + { text: 'Clicking on the QR code above or' }, + { + text: 'Using keyboard shortcuts', + items: [{ text: 'Windows: Ctrl+Arrow up' }, { text: 'Mac: Ctrl+Cmd+F' }], + }, + ], + }, + ], + outro: 'Hold the phone in portrait mode about 40 cm away from the screen.', + }, + }); + }); + + it('returns undefined when there are no messages', () => { + expect(getQrCodeAccessibility(undefined)).toBeUndefined(); + }); + + it('builds only the section whose messages are present', () => { + const result = getQrCodeAccessibility(INSTRUCTION_MESSAGES); + + expect(result?.instruction).toBeDefined(); + expect(result?.screenReader).toBeUndefined(); + }); + + it('omits a section whose message set is incomplete', () => { + const incompleteInstructions = Object.fromEntries( + Object.entries(INSTRUCTION_MESSAGES).filter(([messageKey]) => messageKey !== key('instruction.outro')) + ); + + expect(getQrCodeAccessibility(incompleteInstructions)).toBeUndefined(); + }); + + it('resolves messages by the ".view.qr." suffix regardless of the key prefix', () => { + const waitPrefixed = Object.fromEntries( + Object.entries(INSTRUCTION_MESSAGES).map(([messageKey, value]) => [ + messageKey.replace('.launch.view.qr.', '.wait.view.qr.'), + value, + ]) + ); + + expect(getQrCodeAccessibility(waitPrefixed)?.instruction?.heading).toBe('Help with scanning the QR code'); + }); + + it('ignores keys without the ".view.qr." marker', () => { + const result = getQrCodeAccessibility({ + ...INSTRUCTION_MESSAGES, + 'authenticator.bankid.launch.page.title': 'Login with BankID', + }); + + expect(result?.instruction).toBeDefined(); + expect(JSON.stringify(result)).not.toContain('Login with BankID'); + }); +}); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-accessibility/qr-code-accessibility.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-accessibility/qr-code-accessibility.ts new file mode 100644 index 00000000..b211652b --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-accessibility/qr-code-accessibility.ts @@ -0,0 +1,113 @@ +/* + * 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 { HaapiStepperQrCodeAccessibility, HaapiStepperQrCodeAccessibilitySection } from '../../haapi-stepper.types'; + +/** + * Resolves the BankID QR-code accessibility copy carried in `metadata.viewData.messages` into a + * render-ready structure. Returns `undefined` when neither section is complete, so consumers receive + * ready-shaped data without parsing message keys or checking completeness themselves. + */ +export function getQrCodeAccessibility(messages?: Record): HaapiStepperQrCodeAccessibility | undefined { + const qr = stripQrCodeViewPrefix(messages); + const instruction = buildInstructionSection(qr); + const screenReader = buildScreenReaderSection(qr); + + if (!instruction && !screenReader) { + return undefined; + } + + return { + ...(instruction && { instruction }), + ...(screenReader && { screenReader }), + }; +} + +/** + * 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`). Keying by the suffix + * keeps this independent of the message-key prefix; keys without the marker are ignored. + */ +function stripQrCodeViewPrefix(messages?: Record): Record { + const QR_CODE_VIEW_MARKER = '.view.qr.'; + + return Object.fromEntries( + Object.entries(messages ?? {}) + .filter(([key]) => key.includes(QR_CODE_VIEW_MARKER)) + .map(([key, value]) => [key.slice(key.lastIndexOf(QR_CODE_VIEW_MARKER) + QR_CODE_VIEW_MARKER.length), value]) + ); +} + +const allPresent = (qr: Record, keys: string[]): boolean => + keys.every(key => typeof qr[key] === 'string' && qr[key].length > 0); + +function buildInstructionSection(qr: Record): HaapiStepperQrCodeAccessibilitySection | undefined { + if ( + !allPresent(qr, [ + 'instruction.heading', + 'instruction.step1', + 'instruction.step2', + 'instruction.step3', + 'instruction.step4', + 'instruction.outro', + ]) + ) { + return undefined; + } + return { + heading: qr['instruction.heading'], + items: [qr['instruction.step1'], qr['instruction.step2'], qr['instruction.step3'], qr['instruction.step4']].map( + text => ({ text }) + ), + outro: qr['instruction.outro'], + }; +} + +function buildScreenReaderSection(qr: Record): HaapiStepperQrCodeAccessibilitySection | undefined { + if ( + !allPresent(qr, [ + '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', + ]) + ) { + return undefined; + } + return { + heading: qr['screen-reader.heading'], + intro: qr['screen-reader.intro'], + items: [ + { text: qr['screen-reader.step1'] }, + { text: qr['screen-reader.step2'] }, + { text: qr['screen-reader.step3'] }, + { + text: qr['screen-reader.step4'], + items: [ + { text: qr['screen-reader.step4.1'] }, + { + text: qr['screen-reader.step4.2'], + items: [{ text: qr['screen-reader.step4.2.1'] }, { text: qr['screen-reader.step4.2.2'] }], + }, + ], + }, + ], + outro: qr['screen-reader.outro'], + }; +} diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts index eba10ec2..6220cb0b 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/haapi-stepper.types.ts @@ -164,7 +164,15 @@ export type HaapiStepperUserMessage = HaapiUserMessage & /* * STEP LINK TYPINGS */ -export type HaapiStepperLink = HaapiLink & HaapiStepperDataHelpersDetails; +export type HaapiStepperLink = HaapiLink & + HaapiStepperDataHelpersDetails & { + /** + * Render-ready BankID QR-code accessibility copy, resolved from `metadata.viewData.messages` + * during step-data formatting. Present only on the QR-code link when the server supplied a + * complete section. + */ + qrCodeAccessibility?: HaapiStepperQrCodeAccessibility; + }; /* * ERROR TYPINGS @@ -257,6 +265,30 @@ export interface HaapiStepperDataHelpers { links: HaapiStepperLink[]; }; } + +/** QR-code accessibility copy, structured for direct rendering. */ +export interface HaapiStepperQrCodeAccessibility { + instruction?: HaapiStepperQrCodeAccessibilitySection; + screenReader?: HaapiStepperQrCodeAccessibilitySection; +} + +/** One collapsible QR-code accessibility section (instruction / screen-reader). */ +export interface HaapiStepperQrCodeAccessibilitySection { + heading: string; + intro?: string; + items: HaapiStepperQrCodeAccessibilityItem[]; + outro: string; +} + +/** + * A QR-code accessibility list entry, optionally nesting sub-entries (the screen-reader section is + * a nested list). + */ +export interface HaapiStepperQrCodeAccessibilityItem { + text: string; + items?: HaapiStepperQrCodeAccessibilityItem[]; +} + export type HaapiStepperDataHelpersDetails< T, ST extends T extends HAAPI_STEPPER_ELEMENT_TYPES.ACTION diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx index ccc76e25..7001f182 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx @@ -11,7 +11,7 @@ import { isQrCodeLink } from '../../util/link-predicates'; import { getLinksElement } from '../steps/step-element-factories'; -import { HaapiStepperBankIdQrAccessibilityMessages } from './HaapiStepperBankIdQrAccessibilityMessages'; +import { HaapiStepperBankIdQrCodeAccessibilityMessages } from './HaapiStepperBankIdQrCodeAccessibilityMessages'; import type { ViewNameBuiltInUIProps } from './typings'; /** @@ -24,18 +24,18 @@ import type { ViewNameBuiltInUIProps } from './typings'; export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => { const { currentStep, linkRenderInterceptor, loadingElement, errorElement, messagesElement, actionsElement } = props; const { links } = currentStep.dataHelpers; - const qrLink = links.find(isQrCodeLink); - const nonQrLinks = links.filter(link => !isQrCodeLink(link)); + const qrCodeLink = links.find(isQrCodeLink); + const nonQrCodeLinks = links.filter(link => !isQrCodeLink(link)); return ( <> {loadingElement} {errorElement} {messagesElement} - {qrLink && getLinksElement(props, [qrLink], linkRenderInterceptor)} - + {qrCodeLink && getLinksElement(props, [qrCodeLink], linkRenderInterceptor)} + {actionsElement} - {nonQrLinks.length > 0 && getLinksElement(props, nonQrLinks, linkRenderInterceptor)} + {nonQrCodeLinks.length > 0 && getLinksElement(props, nonQrCodeLinks, linkRenderInterceptor)} ); }; diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.spec.tsx deleted file mode 100644 index 1e8c3b3b..00000000 --- a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.spec.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/* - * 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 = { - [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 = { - [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(); - - 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(); - - 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(); - - Object.values(INSTRUCTION_MESSAGES).forEach(text => { - expect(screen.getByText(text)).toBeInTheDocument(); - }); - }); - - it('renders every screen-reader message', () => { - render(); - - Object.values(SCREEN_READER_MESSAGES).forEach(text => { - expect(screen.getByText(text)).toBeInTheDocument(); - }); - }); - - it('renders the nested screen-reader step structure', () => { - render(); - - const nestedLeaf = screen.getByText('Windows: Ctrl+Arrow up'); - expect(nestedLeaf).toBeInTheDocument(); - // step4.2.1 sits two
      levels below step4 - expect(nestedLeaf.closest('ul')?.parentElement?.textContent).toContain('Using keyboard shortcuts'); - }); - - it('renders nothing when there are no messages', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders only the instruction section when screen-reader messages are missing', () => { - render(); - - 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(); - - 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(); - - 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.` 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(); - - 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( - - ); - - expect(screen.getByTestId('bankid-qr-instructions')).toBeInTheDocument(); - expect(screen.queryByText('Login with BankID')).not.toBeInTheDocument(); - }); -}); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.tsx deleted file mode 100644 index 9e51a448..00000000 --- a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrAccessibilityMessages.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* - * 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 }) => { - 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 ( -
      - {showInstruction && ( -
      - {qr['instruction.heading']} -
        -
      • {qr['instruction.step1']}
      • -
      • {qr['instruction.step2']}
      • -
      • {qr['instruction.step3']}
      • -
      • {qr['instruction.step4']}
      • -
      -

      {qr['instruction.outro']}

      -
      - )} - {showScreenReader && ( -
      - {qr['screen-reader.heading']} -

      {qr['screen-reader.intro']}

      -
        -
      • {qr['screen-reader.step1']}
      • -
      • {qr['screen-reader.step2']}
      • -
      • {qr['screen-reader.step3']}
      • -
      • - {qr['screen-reader.step4']} -
          -
        • {qr['screen-reader.step4.1']}
        • -
        • - {qr['screen-reader.step4.2']} -
            -
          • {qr['screen-reader.step4.2.1']}
          • -
          • {qr['screen-reader.step4.2.2']}
          • -
          -
        • -
        -
      • -
      -

      {qr['screen-reader.outro']}

      -
      - )} -
      - ); -}; diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.spec.tsx new file mode 100644 index 00000000..d01d1a5d --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.spec.tsx @@ -0,0 +1,121 @@ +/* + * 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 type { HaapiStepperQrCodeAccessibility } from '../stepper/haapi-stepper.types'; +import { HaapiStepperBankIdQrCodeAccessibilityMessages } from './HaapiStepperBankIdQrCodeAccessibilityMessages'; + +const INSTRUCTION: HaapiStepperQrCodeAccessibility['instruction'] = { + heading: 'Help with scanning the QR code', + items: [ + { text: 'Open the BankID app' }, + { text: 'Press the Scan QR code button' }, + { text: "Point your phone's camera at the QR code" }, + { text: 'Follow the instructions in the app' }, + ], + outro: 'The QR code is displayed for a configurable period.', +}; + +const SCREEN_READER: HaapiStepperQrCodeAccessibility['screenReader'] = { + heading: 'If you are using a screen reader', + intro: 'The most common error is that the full QR code is not visible. Try to:', + items: [ + { text: 'Ensure the screen is on' }, + { text: 'Zoom out in the browser' }, + { text: 'Zoom out using magnification tools' }, + { + text: 'Make sure the browser window is maximized by:', + items: [ + { text: 'Clicking on the QR code above or' }, + { + text: 'Using keyboard shortcuts', + items: [{ text: 'Windows: Ctrl+Arrow up' }, { text: 'Mac: Ctrl+Cmd+F' }], + }, + ], + }, + ], + outro: 'Hold the phone in portrait mode about 40 cm away from the screen.', +}; + +const BOTH: HaapiStepperQrCodeAccessibility = { instruction: INSTRUCTION, screenReader: SCREEN_READER }; + +describe('HaapiStepperBankIdQrCodeAccessibilityMessages', () => { + it('renders both sections, collapsed by default', () => { + render(); + + expect(screen.getByTestId('bankid-qr-code-instructions')).not.toHaveAttribute('open'); + expect(screen.getByTestId('bankid-qr-code-screen-reader')).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(); + + const instructions = screen.getByTestId('bankid-qr-code-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 message across both sections', () => { + render(); + + for (const text of [ + ...INSTRUCTION.items.map(item => item.text), + INSTRUCTION.outro, + SCREEN_READER.intro!, + SCREEN_READER.outro, + 'Ensure the screen is on', + 'Make sure the browser window is maximized by:', + 'Using keyboard shortcuts', + 'Windows: Ctrl+Arrow up', + 'Mac: Ctrl+Cmd+F', + ]) { + expect(screen.getByText(text)).toBeInTheDocument(); + } + }); + + it('renders the nested screen-reader step structure', () => { + render(); + + const nestedLeaf = screen.getByText('Windows: Ctrl+Arrow up'); + // The leaf sits two
        levels below "Using keyboard shortcuts". + expect(nestedLeaf.closest('ul')?.parentElement?.textContent).toContain('Using keyboard shortcuts'); + }); + + it('renders nothing when no accessibility copy is present', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders only the instruction section when the screen-reader section is absent', () => { + render(); + + expect(screen.getByTestId('bankid-qr-code-instructions')).toBeInTheDocument(); + expect(screen.queryByTestId('bankid-qr-code-screen-reader')).not.toBeInTheDocument(); + }); + + it('renders only the screen-reader section when the instruction section is absent', () => { + render(); + + expect(screen.getByTestId('bankid-qr-code-screen-reader')).toBeInTheDocument(); + expect(screen.queryByTestId('bankid-qr-code-instructions')).not.toBeInTheDocument(); + }); +}); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.tsx new file mode 100644 index 00000000..9909bacc --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.tsx @@ -0,0 +1,68 @@ +/* + * 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 type { HaapiStepperQrCodeAccessibility, HaapiStepperQrCodeAccessibilityItem } from '../stepper/haapi-stepper.types'; + +const QrCodeAccessibilityList = ({ items }: { items: HaapiStepperQrCodeAccessibilityItem[] }) => ( +
          + {items.map(item => ( +
        • + {item.text} + {item.items && } +
        • + ))} +
        +); + +/** + * Renders the BankID QR-code accessibility copy as two collapsible sections, mirroring the classic + * Velocity layout: + * + * - "Help with scanning the QR code" (`instruction`) + * - "If you are using a screen reader" (`screenReader`) + * + * The copy is already resolved and completeness-checked by the step-data formatter + * (`getQrCodeAccessibility`), so this component is pure presentation: it renders whichever sections are + * present and nothing at all when `qrCodeAccessibility` is absent. + * + * Exported so consumers building a custom BankID UI can reuse it. + */ +export const HaapiStepperBankIdQrCodeAccessibilityMessages = ({ + qrCodeAccessibility, +}: { + qrCodeAccessibility?: HaapiStepperQrCodeAccessibility; +}) => { + if (!qrCodeAccessibility) { + return null; + } + + const { instruction, screenReader } = qrCodeAccessibility; + + return ( +
        + {instruction && ( +
        + {instruction.heading} + +

        {instruction.outro}

        +
        + )} + {screenReader && ( +
        + {screenReader.heading} + {screenReader.intro &&

        {screenReader.intro}

        } + +

        {screenReader.outro}

        +
        + )} +
        + ); +}; diff --git a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/index.ts b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/index.ts index dc8b5b68..a3beb679 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/index.ts +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/index.ts @@ -13,4 +13,4 @@ export * from './typings'; export * from './viewname.types'; export * from './viewname-built-in-uis'; export * from './BankIdViewNameBuiltInUI'; -export * from './HaapiStepperBankIdQrAccessibilityMessages'; +export * from './HaapiStepperBankIdQrCodeAccessibilityMessages'; diff --git a/src/haapi-react-sdk/haapi-stepper/util/qr-view-data-messages.ts b/src/haapi-react-sdk/haapi-stepper/util/qr-view-data-messages.ts deleted file mode 100644 index 9bbb8b8e..00000000 --- a/src/haapi-react-sdk/haapi-stepper/util/qr-view-data-messages.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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): Record { - 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]) - ); -}