From 74932fc468017ab93bb21aad8ec513876e0e2382 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Tue, 31 Mar 2026 14:04:11 +0200 Subject: [PATCH 1/4] IS-11140: qr code overlay --- src/login-web-app/src/haapi-stepper/README.md | 7 +- .../links/HaapiStepperQrCodeLinkOverlay.tsx | 60 +++++ .../src/haapi-stepper/ui/links/Link.spec.tsx | 111 +++++++++ .../src/haapi-stepper/ui/links/Link.tsx | 55 +++-- .../src/haapi-stepper/ui/links/Links.spec.tsx | 219 ++++++++++++++++++ .../src/haapi-stepper/ui/links/Links.tsx | 24 +- .../defaultHaapiStepperLinkElementFactory.tsx | 5 +- .../src/shared/util/css/styles.css | 47 ++++ 8 files changed, 508 insertions(+), 20 deletions(-) create mode 100644 src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx create mode 100644 src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx create mode 100644 src/login-web-app/src/haapi-stepper/ui/links/Links.spec.tsx diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index e8df6ac..e8f855e 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -150,7 +150,12 @@ The HAAPI UI components reference the CSS classes listed below but do not ship a | `.haapi-stepper-well` | `Well` | Styled content container | | `.haapi-stepper-links` | `Links` | Links container | | `.haapi-stepper-link` | `Link` | Link element | -| `.haapi-stepper-link-image` | `Link` | Linkselement | +| `.haapi-stepper-link-qr-code` | `Link` | Image link figure wrapper | +| `.haapi-stepper-link-qr-code-title` | `Link` | Image link figcaption | +| `.haapi-stepper-link-qr-code-button` | `Link` | Image link expand button | +| `.haapi-stepper-link-qr-code-overlay` | `Link` | Fullscreen image overlay container | +| `.haapi-stepper-link-qr-code-overlay-button` | `Link` | Fullscreen overlay dismiss button | +| `.haapi-stepper-link-qr-code-overlay-image` | `Link` | Fullscreen overlay image | | `.haapi-stepper-actions` | `Actions` | Actions container | | `.haapi-stepper-heading` | `Messages` | Heading messages | | `.haapi-stepper-userName` | `Messages` | User name display | diff --git a/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx new file mode 100644 index 0000000..38e2d8f --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 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 { useCallback, useEffect, useRef } from 'react'; +import { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types'; + +interface HaapiStepperQrCodeLinkOverlayProps { + link: HaapiStepperLink; + onClose: () => void; +} + +export function HaapiStepperQrCodeLinkOverlay({ link, onClose }: HaapiStepperQrCodeLinkOverlayProps) { + const overlayButtonRef = useRef(null); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }, + [onClose] + ); + + useEffect(() => { + overlayButtonRef.current?.focus(); + }, []); + + return ( +
+ +
+ ); +} diff --git a/src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx new file mode 100644 index 0000000..da2d0d5 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2025 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 { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types'; +import { createMockLink } from '../../util/tests/mocks'; +import { Link } from './Link'; + +describe('Link', () => { + let onClick: ReturnType void>>; + let user: ReturnType; + + beforeEach(() => { + onClick = vi.fn(); + user = userEvent.setup(); + }); + + describe('UI', () => { + describe('Non-image links', () => { + it('renders a button with title', () => { + const link = createMockLink({ title: 'Help', subtype: 'text/html' }); + render(); + + const button = screen.getByRole('button', { name: 'Help' }); + expect(button).toHaveClass('haapi-stepper-link'); + }); + + it('falls back to rel when title is missing', () => { + const link = createMockLink({ title: undefined, rel: 'help', subtype: 'text/html' }); + render(); + + expect(screen.getByRole('button', { name: 'help' })).toBeInTheDocument(); + }); + }); + + describe('QR code links', () => { + let imageLink: HaapiStepperLink; + + beforeEach(() => { + imageLink = createMockLink({ + href: 'data:image/svg+xml;base64,abc', + subtype: 'image/svg+xml', + title: 'QR Code', + rel: 'qr-code', + }); + }); + + it('renders a button wrapping the QR image', () => { + render(); + + const button = screen.getByRole('button', { name: 'QR code, click to expand' }); + expect(button).toHaveClass('haapi-stepper-link-qr-code-button'); + expect(button.querySelector('img')).toHaveAttribute('src', imageLink.href); + }); + + it('shows alt text from link title', () => { + render(); + + expect(screen.getByAltText('QR Code')).toBeInTheDocument(); + }); + + it('falls back to default alt text when title is missing', () => { + imageLink = createMockLink({ + href: 'data:image/svg+xml;base64,abc', + subtype: 'image/svg+xml', + title: undefined, + rel: 'qr-code', + }); + render(); + + expect(screen.getByAltText('QR code, click to expand')).toBeInTheDocument(); + }); + }); + }); + + describe('Features', () => { + it('calls onClick when a non-image link is clicked', async () => { + const link = createMockLink({ title: 'Help', subtype: 'text/html' }); + render(); + + await user.click(screen.getByRole('button', { name: 'Help' })); + expect(onClick).toHaveBeenCalledWith(link); + }); + + it('calls onExpandImage when an image link is clicked', async () => { + const imageLink = createMockLink({ + href: 'data:image/svg+xml;base64,abc', + subtype: 'image/svg+xml', + title: 'QR Code', + rel: 'qr-code', + }); + const onExpandImage = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'QR code, click to expand' })); + expect(onExpandImage).toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/login-web-app/src/haapi-stepper/ui/links/Link.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Link.tsx index 94f9cc4..4acef7b 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/Link.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/Link.tsx @@ -1,18 +1,45 @@ +/* + * Copyright (C) 2025 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 { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types'; -export const Link = ({ link, onClick }: { link: HaapiStepperLink; onClick: (action: HaapiStepperLink) => void }) => { - const isImageLink = link.subtype?.startsWith('image/'); - const imageLinkElement = ( -
- {link.title - {link.title &&
{link.title}
} -
- ); - const buttonLinkElement = ( - - ); +interface LinkProps { + link: HaapiStepperLink; + onClick: (action: HaapiStepperLink) => void; + onExpandImage?: () => void; +} + +export const Link = ({ link, onClick, onExpandImage }: LinkProps) => { + const isQRCodeLink = link.subtype?.startsWith('image/'); + + if (isQRCodeLink) { + return ( + + ); + } - return isImageLink ? imageLinkElement : buttonLinkElement; + return ( + + ); }; diff --git a/src/login-web-app/src/haapi-stepper/ui/links/Links.spec.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Links.spec.tsx new file mode 100644 index 0000000..eba351c --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/ui/links/Links.spec.tsx @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2025 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 { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types'; +import { createMockLink } from '../../util/tests/mocks'; +import { Links } from './Links'; + +describe('Links', () => { + let onClick: ReturnType void>>; + let user: ReturnType; + + beforeEach(() => { + onClick = vi.fn(); + user = userEvent.setup(); + }); + + describe('UI', () => { + describe('Default rendering', () => { + it('renders nothing when links is undefined', () => { + render(); + + expect(screen.queryByTestId('links')).not.toBeInTheDocument(); + }); + + it('renders nothing when links is empty', () => { + render(); + + expect(screen.queryByTestId('links')).not.toBeInTheDocument(); + }); + + it('renders link buttons', () => { + const links = [ + createMockLink({ title: 'Register', subtype: 'text/html', rel: 'register' }), + createMockLink({ title: 'Help', subtype: 'text/html', rel: 'help' }), + ]; + render(); + + expect(screen.getByTestId('links')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Register' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Help' })).toBeInTheDocument(); + }); + + }); + + describe('Custom rendering', () => { + it('data customization: render interceptor modifies link data before default rendering', () => { + const links = [createMockLink({ title: 'Original', subtype: 'text/html', rel: 'help' })]; + render( + ({ ...link, title: 'Modified' })} + /> + ); + + expect(screen.queryByRole('button', { name: 'Original' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Modified' })).toBeInTheDocument(); + }); + + it('UI customization: render interceptor replaces default rendering with custom element', () => { + const links = [createMockLink({ title: 'Default', subtype: 'text/html', rel: 'help' })]; + render( + {link.title}} + /> + ); + + expect(screen.queryByRole('button', { name: 'Default' })).not.toBeInTheDocument(); + expect(screen.getByTestId('custom-link')).toHaveTextContent('Default'); + }); + + it('render interceptor hides links by returning null', () => { + const links = [createMockLink({ title: 'Hidden', subtype: 'text/html', rel: 'help' })]; + render( null} />); + + expect(screen.queryByTestId('links')).not.toBeInTheDocument(); + }); + + it('render interceptor can selectively customize specific links', () => { + const links = [ + createMockLink({ title: 'Register', subtype: 'text/html', rel: 'register' }), + createMockLink({ title: 'Help', subtype: 'text/html', rel: 'help' }), + ]; + render( + + link.rel === 'register' ? Custom : link + } + /> + ); + + expect(screen.getByTestId('custom-register')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Help' })).toBeInTheDocument(); + }); + }); + }); + + describe('Features', () => { + describe('Link navigation', () => { + it('calls onClick when a link button is clicked', async () => { + const link = createMockLink({ title: 'Register', subtype: 'text/html', rel: 'register' }); + render(); + + await user.click(screen.getByRole('button', { name: 'Register' })); + + expect(onClick).toHaveBeenCalledWith(link); + }); + }); + + describe('QR code overlay', () => { + let links: HaapiStepperLink[]; + + beforeEach(() => { + links = [ + createMockLink({ + href: 'data:image/svg+xml;base64,abc', + subtype: 'image/svg+xml', + title: 'QR Code', + rel: 'qr-code', + }), + createMockLink({ title: 'Cancel', subtype: 'text/html', rel: 'cancel' }), + ]; + }); + + it('opens overlay when QR button is clicked', async () => { + render(); + + await user.click(screen.getByTestId('qr-code-button')); + + expect(screen.getByTestId('qr-code-overlay')).toBeInTheDocument(); + expect(screen.getByTestId('qr-code-overlay-button')).toBeInTheDocument(); + }); + + it('closes overlay when overlay button is clicked', async () => { + render(); + + await user.click(screen.getByTestId('qr-code-button')); + expect(screen.getByTestId('qr-code-overlay')).toBeInTheDocument(); + + await user.click(screen.getByTestId('qr-code-overlay-button')); + expect(screen.queryByTestId('qr-code-overlay')).not.toBeInTheDocument(); + }); + + it('closes overlay on Escape key', async () => { + render(); + + await user.click(screen.getByTestId('qr-code-button')); + expect(screen.getByTestId('qr-code-overlay')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByTestId('qr-code-overlay')).not.toBeInTheDocument(); + }); + + it('focuses overlay button when expanded', async () => { + render(); + + await user.click(screen.getByTestId('qr-code-button')); + + expect(screen.getByTestId('qr-code-overlay-button')).toHaveFocus(); + }); + + it('overlay uses the current image href from props', async () => { + const { rerender } = render(); + + await user.click(screen.getByTestId('qr-code-button')); + + const updatedLinks = [ + createMockLink({ + href: 'data:image/svg+xml;base64,UPDATED', + subtype: 'image/svg+xml', + title: 'QR Code', + rel: 'qr-code', + }), + links[1], + ]; + rerender(); + + const overlayImg = screen.getByTestId('qr-code-overlay-button').querySelector('img')!; + expect(overlayImg).toHaveAttribute('src', 'data:image/svg+xml;base64,UPDATED'); + }); + + it('overlay stays open across re-renders with new links', async () => { + const { rerender } = render(); + + await user.click(screen.getByTestId('qr-code-button')); + expect(screen.getByTestId('qr-code-overlay')).toBeInTheDocument(); + + const updatedLinks = [ + createMockLink({ + href: 'data:image/svg+xml;base64,NEW', + subtype: 'image/svg+xml', + title: 'QR Code', + rel: 'qr-code', + }), + links[1], + ]; + rerender(); + + expect(screen.getByTestId('qr-code-overlay')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx index da06eac..d938a78 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx @@ -9,10 +9,11 @@ * For further information, please contact Curity AB. */ -import { ReactElement } from 'react'; +import { ReactElement, useCallback, useState } from 'react'; import { HaapiStepperLink, HaapiStepperNextStep } from '../../feature/stepper/haapi-stepper.types'; import { applyRenderInterceptor } from '../../util/generic-render-interceptor'; import { defaultHaapiStepperLinkElementFactory } from './defaultHaapiStepperLinkElementFactory'; +import { HaapiStepperQrCodeLinkOverlay } from './HaapiStepperQrCodeLinkOverlay'; interface LinksProps { links?: HaapiStepperLink[]; @@ -43,13 +44,30 @@ interface LinksProps { * ``` */ export function Links({ links, onClick, renderInterceptor }: LinksProps) { - const linkElements = applyRenderInterceptor(links, renderInterceptor, link => - defaultHaapiStepperLinkElementFactory(link, onClick) + const [showQrCodeExpanded, setShowQrCodeExpanded] = useState(false); + + const currentQRCodeLink = links?.find((link) => link.subtype?.startsWith('image/')); + + const handleExpandQrCode = useCallback(() => { + setShowQrCodeExpanded(true); + }, []); + + const handleCloseQrCodeOverlay = useCallback(() => { + setShowQrCodeExpanded(false); + }, []); + + const showQRCodeOverlay = showQrCodeExpanded && currentQRCodeLink; + const linkElements = applyRenderInterceptor(links, renderInterceptor, (link) => + defaultHaapiStepperLinkElementFactory(link, onClick, handleExpandQrCode) ); + return linkElements.length ? (
{linkElements} + {showQRCodeOverlay && ( + + )}
) : null; } diff --git a/src/login-web-app/src/haapi-stepper/ui/links/defaultHaapiStepperLinkElementFactory.tsx b/src/login-web-app/src/haapi-stepper/ui/links/defaultHaapiStepperLinkElementFactory.tsx index 65c38b0..c3727f1 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/defaultHaapiStepperLinkElementFactory.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/defaultHaapiStepperLinkElementFactory.tsx @@ -3,5 +3,6 @@ import { Link } from './Link'; export const defaultHaapiStepperLinkElementFactory = ( link: HaapiStepperLink, - onClick: (link: HaapiStepperLink) => void -) => ; + onClick: (link: HaapiStepperLink) => void, + onExpandImage?: () => void +) => ; diff --git a/src/login-web-app/src/shared/util/css/styles.css b/src/login-web-app/src/shared/util/css/styles.css index d299eef..ff6faf2 100644 --- a/src/login-web-app/src/shared/util/css/styles.css +++ b/src/login-web-app/src/shared/util/css/styles.css @@ -100,6 +100,53 @@ } } +.haapi-stepper-link-qr-code-button { + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.haapi-stepper-link-qr-code { + margin: 0; +} + +.haapi-stepper-link-qr-code img { + display: block; + margin: 0 auto; + max-width: 250px; +} + +.haapi-stepper-link-qr-code-title { + text-align: center; + margin-top: 0.5rem; +} + +.haapi-stepper-link-qr-code-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: rgb(0 0 0 / 80%); + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; +} + +.haapi-stepper-link-qr-code-overlay-button { + background: none; + border: none; + padding: 1rem; + cursor: pointer; +} + +.haapi-stepper-link-qr-code-overlay-image { + max-width: 90vw; + max-height: 90vh; + width: auto; + height: auto; +} + .haapi-stepper-error-boundary-fallback { min-height: 100dvh; @extend .flex, .flex-column, .flex-center, .justify-center, .h100; From b0cb2d81aa4fc7e118758e81e752dba215bda9f6 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 1 Apr 2026 08:54:34 +0200 Subject: [PATCH 2/4] IS-11140: fix event type --- .../ui/links/HaapiStepperQrCodeLinkOverlay.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx index 38e2d8f..1a778fa 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx @@ -9,7 +9,7 @@ * For further information, please contact Curity AB. */ -import { useCallback, useEffect, useRef } from 'react'; +import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react'; import { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types'; interface HaapiStepperQrCodeLinkOverlayProps { @@ -21,8 +21,8 @@ export function HaapiStepperQrCodeLinkOverlay({ link, onClose }: HaapiStepperQrC const overlayButtonRef = useRef(null); const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { + (event: KeyboardEvent) => { + if (event.key === 'Escape') { onClose(); } }, From d487ff4ee189ec5b0352ca870c957bf8e3afb567 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 1 Apr 2026 13:45:41 +0200 Subject: [PATCH 3/4] IS-11140: encapsulate QR overlay feature in a component --- src/login-web-app/src/haapi-stepper/README.md | 12 +-- .../src/haapi-stepper/ui/index.ts | 1 + .../links/HaapiStepperQrCodeLinkOverlay.tsx | 90 ++++++++++++------- .../src/haapi-stepper/ui/links/Link.spec.tsx | 14 ++- .../src/haapi-stepper/ui/links/Link.tsx | 10 +-- .../src/haapi-stepper/ui/links/Links.spec.tsx | 12 ++- .../src/haapi-stepper/ui/links/Links.tsx | 41 ++++----- .../defaultHaapiStepperLinkElementFactory.tsx | 5 +- 8 files changed, 105 insertions(+), 80 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index e8f855e..55a22ab 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -150,12 +150,12 @@ The HAAPI UI components reference the CSS classes listed below but do not ship a | `.haapi-stepper-well` | `Well` | Styled content container | | `.haapi-stepper-links` | `Links` | Links container | | `.haapi-stepper-link` | `Link` | Link element | -| `.haapi-stepper-link-qr-code` | `Link` | Image link figure wrapper | -| `.haapi-stepper-link-qr-code-title` | `Link` | Image link figcaption | -| `.haapi-stepper-link-qr-code-button` | `Link` | Image link expand button | -| `.haapi-stepper-link-qr-code-overlay` | `Link` | Fullscreen image overlay container | -| `.haapi-stepper-link-qr-code-overlay-button` | `Link` | Fullscreen overlay dismiss button | -| `.haapi-stepper-link-qr-code-overlay-image` | `Link` | Fullscreen overlay image | +| `.haapi-stepper-link-qr-code` | `Link` | QR code link figure wrapper | +| `.haapi-stepper-link-qr-code-title` | `Link` | QR code link figcaption | +| `.haapi-stepper-link-qr-code-button` | `Link` | QR code link expand button | +| `.haapi-stepper-link-qr-code-overlay` | `HaapiStepperQrCodeLinkOverlay` | Fullscreen QR code overlay container | +| `.haapi-stepper-link-qr-code-overlay-button` | `HaapiStepperQrCodeLinkOverlay` | Fullscreen QR code overlay dismiss button | +| `.haapi-stepper-link-qr-code-overlay-image` | `HaapiStepperQrCodeLinkOverlay` | Fullscreen QR code overlay image | | `.haapi-stepper-actions` | `Actions` | Actions container | | `.haapi-stepper-heading` | `Messages` | Heading messages | | `.haapi-stepper-userName` | `Messages` | User name display | diff --git a/src/login-web-app/src/haapi-stepper/ui/index.ts b/src/login-web-app/src/haapi-stepper/ui/index.ts index 326e4ef..ee8b776 100644 --- a/src/login-web-app/src/haapi-stepper/ui/index.ts +++ b/src/login-web-app/src/haapi-stepper/ui/index.ts @@ -14,5 +14,6 @@ export * from './actions/defaultHaapiStepperActionElementFactory'; export * from './links/Link'; export * from './links/Links'; export * from './links/defaultHaapiStepperLinkElementFactory'; +export * from './links/HaapiStepperQrCodeLinkOverlay'; export * from './messages/Messages'; export * from './messages/defaultHaapiStepperMessageElementFactory'; diff --git a/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx index 1a778fa..c88652e 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx @@ -9,52 +9,82 @@ * For further information, please contact Curity AB. */ -import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react'; +import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types'; interface HaapiStepperQrCodeLinkOverlayProps { - link: HaapiStepperLink; - onClose: () => void; + links?: HaapiStepperLink[]; + children: (displayQrCodeInOverlay: (link: HaapiStepperLink) => void) => ReactNode; } -export function HaapiStepperQrCodeLinkOverlay({ link, onClose }: HaapiStepperQrCodeLinkOverlayProps) { +export function HaapiStepperQrCodeLinkOverlay({ children, links }: HaapiStepperQrCodeLinkOverlayProps) { + const [displayedQrCodeKey, setDisplayedQRCode] = useState(null); + const previouslyFocusedElementRef = useRef(null); const overlayButtonRef = useRef(null); + const displayQrCodeInOverlay = useCallback((link: HaapiStepperLink) => { + previouslyFocusedElementRef.current = document.activeElement as HTMLElement | null; + const QRCodeToDisplay = getLinkKey(link); + + setDisplayedQRCode(QRCodeToDisplay); + }, []); + + const handleClose = useCallback(() => { + setDisplayedQRCode(null); + previouslyFocusedElementRef.current?.focus(); + previouslyFocusedElementRef.current = null; + }, []); + const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose(); + handleClose(); } }, - [onClose] + [handleClose] ); useEffect(() => { - overlayButtonRef.current?.focus(); - }, []); + if (displayedQrCodeKey) { + overlayButtonRef.current?.focus(); + } + }, [displayedQrCodeKey]); + + const currentQrCodeLink = displayedQrCodeKey + ? (links?.find((link) => getLinkKey(link) === displayedQrCodeKey) ?? null) + : null; return ( -
- -
+ <> + {children(displayQrCodeInOverlay)} + {currentQrCodeLink && ( +
+ +
+ )} + ); } + +function getLinkKey(link: HaapiStepperLink) { + return `${link.rel}:${link.title ?? ''}`; +} diff --git a/src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx index da2d0d5..7b66cca 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx @@ -57,7 +57,7 @@ describe('Link', () => { }); it('renders a button wrapping the QR image', () => { - render(); + render(); const button = screen.getByRole('button', { name: 'QR code, click to expand' }); expect(button).toHaveClass('haapi-stepper-link-qr-code-button'); @@ -65,7 +65,7 @@ describe('Link', () => { }); it('shows alt text from link title', () => { - render(); + render(); expect(screen.getByAltText('QR Code')).toBeInTheDocument(); }); @@ -77,7 +77,7 @@ describe('Link', () => { title: undefined, rel: 'qr-code', }); - render(); + render(); expect(screen.getByAltText('QR code, click to expand')).toBeInTheDocument(); }); @@ -93,19 +93,17 @@ describe('Link', () => { expect(onClick).toHaveBeenCalledWith(link); }); - it('calls onExpandImage when an image link is clicked', async () => { + it('calls onClick when an image link is clicked', async () => { const imageLink = createMockLink({ href: 'data:image/svg+xml;base64,abc', subtype: 'image/svg+xml', title: 'QR Code', rel: 'qr-code', }); - const onExpandImage = vi.fn(); - render(); + render(); await user.click(screen.getByRole('button', { name: 'QR code, click to expand' })); - expect(onExpandImage).toHaveBeenCalled(); - expect(onClick).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledWith(imageLink); }); }); }); diff --git a/src/login-web-app/src/haapi-stepper/ui/links/Link.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Link.tsx index 4acef7b..6a127d2 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/Link.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/Link.tsx @@ -11,13 +11,7 @@ import { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types'; -interface LinkProps { - link: HaapiStepperLink; - onClick: (action: HaapiStepperLink) => void; - onExpandImage?: () => void; -} - -export const Link = ({ link, onClick, onExpandImage }: LinkProps) => { +export const Link = ({ link, onClick }: { link: HaapiStepperLink; onClick: (action: HaapiStepperLink) => void }) => { const isQRCodeLink = link.subtype?.startsWith('image/'); if (isQRCodeLink) { @@ -26,7 +20,7 @@ export const Link = ({ link, onClick, onExpandImage }: LinkProps) => { type="button" className="haapi-stepper-link-qr-code-button" data-testid="qr-code-button" - onClick={onExpandImage} + onClick={() => onClick(link)} aria-label="QR code, click to expand" >
diff --git a/src/login-web-app/src/haapi-stepper/ui/links/Links.spec.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Links.spec.tsx index eba351c..6016114 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/Links.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/Links.spec.tsx @@ -175,7 +175,17 @@ describe('Links', () => { expect(screen.getByTestId('qr-code-overlay-button')).toHaveFocus(); }); - it('overlay uses the current image href from props', async () => { + it('restores focus to QR button when overlay is closed', async () => { + render(); + + await user.click(screen.getByTestId('qr-code-button')); + expect(screen.getByTestId('qr-code-overlay-button')).toHaveFocus(); + + await user.click(screen.getByTestId('qr-code-overlay-button')); + expect(screen.getByTestId('qr-code-button')).toHaveFocus(); + }); + + it('overlay uses the current image href from links prop', async () => { const { rerender } = render(); await user.click(screen.getByTestId('qr-code-button')); diff --git a/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx index d938a78..51b0483 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx @@ -9,7 +9,7 @@ * For further information, please contact Curity AB. */ -import { ReactElement, useCallback, useState } from 'react'; +import { ReactElement } from 'react'; import { HaapiStepperLink, HaapiStepperNextStep } from '../../feature/stepper/haapi-stepper.types'; import { applyRenderInterceptor } from '../../util/generic-render-interceptor'; import { defaultHaapiStepperLinkElementFactory } from './defaultHaapiStepperLinkElementFactory'; @@ -44,30 +44,23 @@ interface LinksProps { * ``` */ export function Links({ links, onClick, renderInterceptor }: LinksProps) { - const [showQrCodeExpanded, setShowQrCodeExpanded] = useState(false); + return ( + + {(displayQrCodeInOverlay) => { + const handleLinkClick = (link: HaapiStepperLink) => { + link.subtype?.startsWith('image/') ? displayQrCodeInOverlay(link) : onClick(link); + }; - const currentQRCodeLink = links?.find((link) => link.subtype?.startsWith('image/')); + const linkElements = applyRenderInterceptor(links, renderInterceptor, (link) => + defaultHaapiStepperLinkElementFactory(link, handleLinkClick) + ); - const handleExpandQrCode = useCallback(() => { - setShowQrCodeExpanded(true); - }, []); - - const handleCloseQrCodeOverlay = useCallback(() => { - setShowQrCodeExpanded(false); - }, []); - - const showQRCodeOverlay = showQrCodeExpanded && currentQRCodeLink; - const linkElements = applyRenderInterceptor(links, renderInterceptor, (link) => - defaultHaapiStepperLinkElementFactory(link, onClick, handleExpandQrCode) + return linkElements.length ? ( +
+ {linkElements} +
+ ) : null; + }} +
); - - - return linkElements.length ? ( -
- {linkElements} - {showQRCodeOverlay && ( - - )} -
- ) : null; } diff --git a/src/login-web-app/src/haapi-stepper/ui/links/defaultHaapiStepperLinkElementFactory.tsx b/src/login-web-app/src/haapi-stepper/ui/links/defaultHaapiStepperLinkElementFactory.tsx index c3727f1..65c38b0 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/defaultHaapiStepperLinkElementFactory.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/defaultHaapiStepperLinkElementFactory.tsx @@ -3,6 +3,5 @@ import { Link } from './Link'; export const defaultHaapiStepperLinkElementFactory = ( link: HaapiStepperLink, - onClick: (link: HaapiStepperLink) => void, - onExpandImage?: () => void -) => ; + onClick: (link: HaapiStepperLink) => void +) => ; From 3515c53570f1b2a472ec702a1c8cea259f099551 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 1 Apr 2026 16:16:20 +0200 Subject: [PATCH 4/4] IS-11140: refactor link qr to use dialog --- src/login-web-app/src/haapi-stepper/README.md | 5 +- .../src/haapi-stepper/ui/index.ts | 2 +- .../ui/links/HaapiStepperQrCodeLinkDialog.tsx | 65 ++++++++++++++ .../links/HaapiStepperQrCodeLinkOverlay.tsx | 90 ------------------- .../src/haapi-stepper/ui/links/Links.tsx | 14 +-- .../src/shared/util/css/styles.css | 23 ++--- 6 files changed, 85 insertions(+), 114 deletions(-) create mode 100644 src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkDialog.tsx delete mode 100644 src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index 55a22ab..06d0f04 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -153,9 +153,8 @@ The HAAPI UI components reference the CSS classes listed below but do not ship a | `.haapi-stepper-link-qr-code` | `Link` | QR code link figure wrapper | | `.haapi-stepper-link-qr-code-title` | `Link` | QR code link figcaption | | `.haapi-stepper-link-qr-code-button` | `Link` | QR code link expand button | -| `.haapi-stepper-link-qr-code-overlay` | `HaapiStepperQrCodeLinkOverlay` | Fullscreen QR code overlay container | -| `.haapi-stepper-link-qr-code-overlay-button` | `HaapiStepperQrCodeLinkOverlay` | Fullscreen QR code overlay dismiss button | -| `.haapi-stepper-link-qr-code-overlay-image` | `HaapiStepperQrCodeLinkOverlay` | Fullscreen QR code overlay image | +| `.haapi-stepper-link-qr-code-dialog` | `HaapiStepperQrCodeLinkDialog` | Fullscreen QR code dialog | +| `.haapi-stepper-link-qr-code-dialog-image` | `HaapiStepperQrCodeLinkDialog` | Fullscreen QR code dialog image | | `.haapi-stepper-actions` | `Actions` | Actions container | | `.haapi-stepper-heading` | `Messages` | Heading messages | | `.haapi-stepper-userName` | `Messages` | User name display | diff --git a/src/login-web-app/src/haapi-stepper/ui/index.ts b/src/login-web-app/src/haapi-stepper/ui/index.ts index ee8b776..6fed4c1 100644 --- a/src/login-web-app/src/haapi-stepper/ui/index.ts +++ b/src/login-web-app/src/haapi-stepper/ui/index.ts @@ -14,6 +14,6 @@ export * from './actions/defaultHaapiStepperActionElementFactory'; export * from './links/Link'; export * from './links/Links'; export * from './links/defaultHaapiStepperLinkElementFactory'; -export * from './links/HaapiStepperQrCodeLinkOverlay'; +export * from './links/HaapiStepperQrCodeLinkDialog'; export * from './messages/Messages'; export * from './messages/defaultHaapiStepperMessageElementFactory'; diff --git a/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkDialog.tsx b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkDialog.tsx new file mode 100644 index 0000000..63a537f --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkDialog.tsx @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2025 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 ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types'; + +interface HaapiStepperQrCodeLinkDialogProps { + links?: HaapiStepperLink[]; + children: (displayQrCodeInDialog: (link: HaapiStepperLink) => void) => ReactNode; +} + +export function HaapiStepperQrCodeLinkDialog({ children, links }: HaapiStepperQrCodeLinkDialogProps) { + const [displayedQrCodeKey, setDisplayedQrCodeKey] = useState(null); + const dialogRef = useRef(null); + + const displayQrCodeInDialog = useCallback((link: HaapiStepperLink) => { + setDisplayedQrCodeKey(getLinkKey(link)); + }, []); + + useEffect(() => { + if (displayedQrCodeKey) { + dialogRef.current?.showModal(); + } + }, [displayedQrCodeKey]); + + // BankID QR code hrefs change every second so we need to get the latest links and refresh it accordingly + const currentQrCodeLink = displayedQrCodeKey + ? (links?.find((link) => getLinkKey(link) === displayedQrCodeKey) ?? null) + : null; + + return ( + <> + {children(displayQrCodeInDialog)} + setDisplayedQrCodeKey(null)} + onClick={() => dialogRef.current?.close()} + > + {currentQrCodeLink && ( + {currentQrCodeLink.title dialogRef.current?.close()} + /> + )} + + + ); +} + +function getLinkKey(link: HaapiStepperLink) { + return `${link.rel}:${link.title ?? ''}`; +} diff --git a/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx deleted file mode 100644 index c88652e..0000000 --- a/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperQrCodeLinkOverlay.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (C) 2025 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 KeyboardEvent, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; -import { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types'; - -interface HaapiStepperQrCodeLinkOverlayProps { - links?: HaapiStepperLink[]; - children: (displayQrCodeInOverlay: (link: HaapiStepperLink) => void) => ReactNode; -} - -export function HaapiStepperQrCodeLinkOverlay({ children, links }: HaapiStepperQrCodeLinkOverlayProps) { - const [displayedQrCodeKey, setDisplayedQRCode] = useState(null); - const previouslyFocusedElementRef = useRef(null); - const overlayButtonRef = useRef(null); - - const displayQrCodeInOverlay = useCallback((link: HaapiStepperLink) => { - previouslyFocusedElementRef.current = document.activeElement as HTMLElement | null; - const QRCodeToDisplay = getLinkKey(link); - - setDisplayedQRCode(QRCodeToDisplay); - }, []); - - const handleClose = useCallback(() => { - setDisplayedQRCode(null); - previouslyFocusedElementRef.current?.focus(); - previouslyFocusedElementRef.current = null; - }, []); - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key === 'Escape') { - handleClose(); - } - }, - [handleClose] - ); - - useEffect(() => { - if (displayedQrCodeKey) { - overlayButtonRef.current?.focus(); - } - }, [displayedQrCodeKey]); - - const currentQrCodeLink = displayedQrCodeKey - ? (links?.find((link) => getLinkKey(link) === displayedQrCodeKey) ?? null) - : null; - - return ( - <> - {children(displayQrCodeInOverlay)} - {currentQrCodeLink && ( -
- -
- )} - - ); -} - -function getLinkKey(link: HaapiStepperLink) { - return `${link.rel}:${link.title ?? ''}`; -} diff --git a/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx index 51b0483..f72f77b 100644 --- a/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx +++ b/src/login-web-app/src/haapi-stepper/ui/links/Links.tsx @@ -13,7 +13,7 @@ import { ReactElement } from 'react'; import { HaapiStepperLink, HaapiStepperNextStep } from '../../feature/stepper/haapi-stepper.types'; import { applyRenderInterceptor } from '../../util/generic-render-interceptor'; import { defaultHaapiStepperLinkElementFactory } from './defaultHaapiStepperLinkElementFactory'; -import { HaapiStepperQrCodeLinkOverlay } from './HaapiStepperQrCodeLinkOverlay'; +import { HaapiStepperQrCodeLinkDialog } from './HaapiStepperQrCodeLinkDialog'; interface LinksProps { links?: HaapiStepperLink[]; @@ -45,10 +45,14 @@ interface LinksProps { */ export function Links({ links, onClick, renderInterceptor }: LinksProps) { return ( - - {(displayQrCodeInOverlay) => { + + {(displayQrCodeInDialog) => { const handleLinkClick = (link: HaapiStepperLink) => { - link.subtype?.startsWith('image/') ? displayQrCodeInOverlay(link) : onClick(link); + if (link.subtype?.startsWith('image/')) { + displayQrCodeInDialog(link); + } else { + onClick(link); + } }; const linkElements = applyRenderInterceptor(links, renderInterceptor, (link) => @@ -61,6 +65,6 @@ export function Links({ links, onClick, renderInterceptor }: LinksProps) { ) : null; }} - + ); } diff --git a/src/login-web-app/src/shared/util/css/styles.css b/src/login-web-app/src/shared/util/css/styles.css index ff6faf2..0b5999c 100644 --- a/src/login-web-app/src/shared/util/css/styles.css +++ b/src/login-web-app/src/shared/util/css/styles.css @@ -122,29 +122,22 @@ margin-top: 0.5rem; } -.haapi-stepper-link-qr-code-overlay { - position: fixed; - inset: 0; - z-index: 9999; - background: rgb(0 0 0 / 80%); - display: flex; - align-items: center; - justify-content: center; - overflow-y: auto; -} - -.haapi-stepper-link-qr-code-overlay-button { - background: none; +.haapi-stepper-link-qr-code-dialog { border: none; + background: transparent; padding: 1rem; - cursor: pointer; } -.haapi-stepper-link-qr-code-overlay-image { +.haapi-stepper-link-qr-code-dialog::backdrop { + background: rgb(0 0 0 / 80%); +} + +.haapi-stepper-link-qr-code-dialog-image { max-width: 90vw; max-height: 90vh; width: auto; height: auto; + cursor: pointer; } .haapi-stepper-error-boundary-fallback {