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..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,6 +279,24 @@ svg { @extend .center, .flex, .flex-column; } +.haapi-stepper-bankid-qr-code-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..f8012b96 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-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/data-access/types/haapi-step.types.ts b/src/haapi-react-sdk/haapi-stepper/data-access/types/haapi-step.types.ts index 7569c918..5fe39a9c 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/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..88a76509 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 { getQrCodeViewDataMessages } from './qr-code-view-data-messages'; 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-view-data-messages.spec.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-view-data-messages.spec.ts new file mode 100644 index 00000000..54b9ecc7 --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-view-data-messages.spec.ts @@ -0,0 +1,50 @@ +/* + * 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 { getQrCodeViewDataMessages } from './qr-code-view-data-messages'; + +const PREFIX = 'authenticator.bankid.launch.view.qr.'; + +describe('getQrCodeViewDataMessages', () => { + it('returns undefined when there are no messages', () => { + expect(getQrCodeViewDataMessages(undefined)).toBeUndefined(); + expect(getQrCodeViewDataMessages({})).toBeUndefined(); + }); + + it('returns undefined when no key carries the ".view.qr." segment', () => { + expect( + getQrCodeViewDataMessages({ 'authenticator.bankid.launch.page.title': 'Login with BankID' }) + ).toBeUndefined(); + }); + + it('selects the ".view.qr." messages, keeping their keys untouched', () => { + const result = getQrCodeViewDataMessages({ + [`${PREFIX}instruction.heading`]: 'Help with scanning the QR code', + [`${PREFIX}screen-reader.step4.2.1`]: 'Windows: Ctrl+Arrow up', + }); + + expect(result).toEqual({ + [`${PREFIX}instruction.heading`]: 'Help with scanning the QR code', + [`${PREFIX}screen-reader.step4.2.1`]: 'Windows: Ctrl+Arrow up', + }); + }); + + it('excludes messages without the ".view.qr." segment', () => { + const result = getQrCodeViewDataMessages({ + [`${PREFIX}instruction.heading`]: 'Help with scanning the QR code', + 'authenticator.bankid.launch.page.title': 'Login with BankID', + }); + + expect(result).toEqual({ [`${PREFIX}instruction.heading`]: 'Help with scanning the QR code' }); + }); +}); diff --git a/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-view-data-messages.ts b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-view-data-messages.ts new file mode 100644 index 00000000..b336714e --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/stepper/data-formatters/qr-code-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. + */ + +/** + * Selects the QR-code messages from the raw `metadata.viewData.messages` map — the entries whose + * key carries the `.view.qr.` segment (e.g. `authenticator.bankid.launch.view.qr.instruction.heading`). + * + * Keys are kept exactly as sent by the server; interpreting them is up to the consumer rendering + * the QR code. Returns `undefined` when no QR-code messages are present. + */ +export function getQrCodeViewDataMessages(messages?: Record): Record | undefined { + const QR_CODE_VIEW_MARKER = '.view.qr.'; + const qrCodeMessageEntries = Object.entries(messages ?? {}).filter(([key]) => key.includes(QR_CODE_VIEW_MARKER)); + + return qrCodeMessageEntries.length > 0 ? Object.fromEntries(qrCodeMessageEntries) : undefined; +} 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..da9f06a3 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,16 @@ export type HaapiStepperUserMessage = HaapiUserMessage & /* * STEP LINK TYPINGS */ -export type HaapiStepperLink = HaapiLink & HaapiStepperDataHelpersDetails; +export type HaapiStepperLink = HaapiLink & + HaapiStepperDataHelpersDetails & { + /** + * The QR-code messages from `metadata.viewData.messages` (the entries whose key carries the + * `.view.qr.` segment), relocated here during step-data formatting with their keys untouched. + * Present only on the QR-code link, when the server supplied such messages. Interpreting the + * copy (keys, structure, completeness) is up to the consumer rendering the QR code. + */ + qrCodeMessages?: Record; + }; /* * ERROR TYPINGS @@ -257,6 +266,7 @@ export interface HaapiStepperDataHelpers { links: HaapiStepperLink[]; }; } + 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 a8ff3d18..7e632758 100644 --- a/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx @@ -11,27 +11,31 @@ import { isQrCodeLink } from '../../util/link-predicates'; import { getLinksElement } from '../steps/step-element-factories'; +import { HaapiStepperBankIdQrCodeAccessibilityMessages } from './HaapiStepperBankIdQrCodeAccessibilityMessages'; 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; 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/HaapiStepperBankIdQrCodeAccessibilityMessages.spec.tsx b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.spec.tsx new file mode 100644 index 00000000..1e03d351 --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.spec.tsx @@ -0,0 +1,137 @@ +/* + * 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 { HaapiStepperBankIdQrCodeAccessibilityMessages } from './HaapiStepperBankIdQrCodeAccessibilityMessages'; + +// Full keys, exactly as the BankID authenticator sends them in `metadata.viewData.messages`. +const key = (suffix: string) => `authenticator.bankid.launch.view.qr.${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('HaapiStepperBankIdQrCodeAccessibilityMessages', () => { + it('renders both sections, collapsed by default, when all messages are present', () => { + render(); + + const instructions = screen.getByTestId('bankid-qr-code-instructions'); + const screenReader = screen.getByTestId('bankid-qr-code-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-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 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-code-instructions')).toBeInTheDocument(); + expect(screen.queryByTestId('bankid-qr-code-screen-reader')).not.toBeInTheDocument(); + }); + + it('renders only the screen-reader section when instruction messages are missing', () => { + render(); + + expect(screen.getByTestId('bankid-qr-code-screen-reader')).toBeInTheDocument(); + expect(screen.queryByTestId('bankid-qr-code-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-code-instructions')).not.toBeInTheDocument(); + }); + + it('ignores QR-code messages it does not know about (e.g. the expiry countdown)', () => { + render( + + ); + + expect(screen.queryByText('{0} minutes left')).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..f7baaebb --- /dev/null +++ b/src/haapi-react-sdk/haapi-stepper/feature/viewnames/HaapiStepperBankIdQrCodeAccessibilityMessages.tsx @@ -0,0 +1,109 @@ +/* + * 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. + */ + +/** + * Renders the BankID QR-code accessibility messages carried by the QR-code link + * (`qrCodeLink.qrCodeMessages`, relocated from `metadata.viewData.messages` by the step-data + * formatting) 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.*`) + * + * Interpreting the copy is BankID view knowledge, so this component owns it: messages are looked + * up by their exact BankID keys, a section is shown only when all of its messages are present, and + * 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 HaapiStepperBankIdQrCodeAccessibilityMessages = ({ + qrCodeMessages, +}: { + qrCodeMessages?: Record; +}) => { + // The exact keys the BankID authenticator uses for the QR-code accessibility copy + // (see `WaitBankIdRepresentationFunction` in the server). + const qrKey = (suffix: string) => `authenticator.bankid.launch.view.qr.${suffix}`; + const qr = (suffix: string) => qrCodeMessages?.[qrKey(suffix)]; + const has = (...suffixes: string[]) => + suffixes.every(suffix => { + const value = qr(suffix); + 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..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,3 +13,4 @@ export * from './typings'; export * from './viewname.types'; export * from './viewname-built-in-uis'; export * from './BankIdViewNameBuiltInUI'; +export * from './HaapiStepperBankIdQrCodeAccessibilityMessages';