Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/login-web-app/src/haapi-stepper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions src/login-web-app/src/haapi-stepper/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const dialogRef = useRef<HTMLDialogElement>(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)}
<dialog
ref={dialogRef}
className="haapi-stepper-link-qr-code-dialog"
data-testid="qr-code-dialog"
aria-label="Expanded QR code"
onClose={() => setDisplayedQrCodeKey(null)}
onClick={() => dialogRef.current?.close()}
>
{currentQrCodeLink && (
<img
src={currentQrCodeLink.href}
alt={currentQrCodeLink.title ?? 'QR code, click to close'}
className="haapi-stepper-link-qr-code-dialog-image"
onClick={() => dialogRef.current?.close()}
/>
)}
</dialog>
</>
);
}

function getLinkKey(link: HaapiStepperLink) {
return `${link.rel}:${link.title ?? ''}`;
}
109 changes: 109 additions & 0 deletions src/login-web-app/src/haapi-stepper/ui/links/Link.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn<(action: HaapiStepperLink) => void>>;
let user: ReturnType<typeof userEvent.setup>;

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(<Link link={link} onClick={onClick} />);

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(<Link link={link} onClick={onClick} />);

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(<Link link={imageLink} onClick={onClick} />);

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(<Link link={imageLink} onClick={onClick} />);

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(<Link link={imageLink} onClick={onClick} />);

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(<Link link={link} onClick={onClick} />);

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(<Link link={imageLink} onClick={onClick} />);

await user.click(screen.getByRole('button', { name: 'QR code, click to expand' }));
expect(onClick).toHaveBeenCalledWith(imageLink);
});
});
});
47 changes: 34 additions & 13 deletions src/login-web-app/src/haapi-stepper/ui/links/Link.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<figure className="haapi-stepper-link-image">
<img src={link.href} alt={link.title ?? link.rel} />
{link.title && <figcaption className="haapi-stepper-link-image-title">{link.title}</figcaption>}
</figure>
);
const buttonLinkElement = (
<button type="button" className="haapi-stepper-link" onClick={() => onClick(link)}>
{link.title ?? link.rel}
</button>
);
const isQRCodeLink = link.subtype?.startsWith('image/');

if (isQRCodeLink) {
return (
<button
type="button"
className="haapi-stepper-link-qr-code-button"
data-testid="qr-code-button"
onClick={() => onClick(link)}
aria-label="QR code, click to expand"
>
<figure className="haapi-stepper-link-qr-code">
<img src={link.href} alt={link.title ?? 'QR code, click to expand'} />
{link.title && <figcaption className="haapi-stepper-link-qr-code-title">{link.title}</figcaption>}
</figure>
</button>
);
}

return isImageLink ? imageLinkElement : buttonLinkElement;
return (
<button type="button" className="haapi-stepper-link" onClick={() => onClick(link)}>
{link.title ?? link.rel}
</button>
);
};
Loading