diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index e8df6ac..06d0f04 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -150,7 +150,11 @@ 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` | 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-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 326e4ef..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,5 +14,6 @@ export * from './actions/defaultHaapiStepperActionElementFactory'; export * from './links/Link'; export * from './links/Links'; export * from './links/defaultHaapiStepperLinkElementFactory'; +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/Link.spec.tsx b/src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx new file mode 100644 index 0000000..7b66cca --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx @@ -0,0 +1,109 @@ +/* + * 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 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', + }); + render(); + + await user.click(screen.getByRole('button', { name: 'QR code, click to expand' })); + 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 94f9cc4..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 @@ -1,18 +1,39 @@ +/* + * 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 = ( - - ); + 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..6016114 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/ui/links/Links.spec.tsx @@ -0,0 +1,229 @@ +/* + * 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('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')); + + 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..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,6 +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 { HaapiStepperQrCodeLinkDialog } from './HaapiStepperQrCodeLinkDialog'; interface LinksProps { links?: HaapiStepperLink[]; @@ -43,13 +44,27 @@ interface LinksProps { * ``` */ export function Links({ links, onClick, renderInterceptor }: LinksProps) { - const linkElements = applyRenderInterceptor(links, renderInterceptor, link => - defaultHaapiStepperLinkElementFactory(link, onClick) - ); + return ( + + {(displayQrCodeInDialog) => { + const handleLinkClick = (link: HaapiStepperLink) => { + if (link.subtype?.startsWith('image/')) { + displayQrCodeInDialog(link); + } else { + onClick(link); + } + }; + + const linkElements = applyRenderInterceptor(links, renderInterceptor, (link) => + defaultHaapiStepperLinkElementFactory(link, handleLinkClick) + ); - return linkElements.length ? ( -
- {linkElements} -
- ) : null; + return linkElements.length ? ( +
+ {linkElements} +
+ ) : 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 d299eef..0b5999c 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,46 @@ } } +.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-dialog { + border: none; + background: transparent; + padding: 1rem; +} + +.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 { min-height: 100dvh; @extend .flex, .flex-column, .flex-center, .justify-center, .h100;