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]) + ); +}