Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/fxa-settings/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@
"postcss-loader": "^8.1.1",
"postcss-normalize": "^13.0.1",
"postcss-preset-env": "^10.0.5",
"qrcode.react": "^4.2.0",
"react-app-polyfill": "^3.0.0",
"react-async-hook": "^4.0.0",
"react-dev-utils": "^12.0.1",
Expand Down
37 changes: 37 additions & 0 deletions packages/fxa-settings/src/components/QRCode/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { Meta } from '@storybook/react';
import { withLocalization } from 'fxa-react/lib/storybooks';
import QRCode from '.';
import firefoxLogo from '../../pages/Pair/Index/firefox-logo-browser.svg';

export default {
title: 'Components/QRCode',
component: QRCode,
decorators: [withLocalization],
} as Meta;

const value = 'https://app.adjust.com/2uo1qc?campaign=send-tab&creative=demo';

export const Default = () => (
<QRCode value={value} localizedLabel="Scan to download Firefox for mobile" />
);

export const WithLogo = () => (
<QRCode
value={value}
localizedLabel="Scan to download Firefox for mobile"
logoSrc={firefoxLogo}
/>
);

export const Loading = () => (
<QRCode
value={value}
localizedLabel="Scan to download Firefox for mobile"
logoSrc={firefoxLogo}
loading
/>
);
91 changes: 91 additions & 0 deletions packages/fxa-settings/src/components/QRCode/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import React from 'react';
import { act, screen } from '@testing-library/react';
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
import QRCode from './index';

const VALUE = 'https://app.adjust.com/2uo1qc?campaign=send-tab';
const LABEL = 'QR code to download Firefox for mobile';
const IMAGE_DATA =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';

describe('QRCode', () => {
it('renders an accessible image labelled by localizedLabel', () => {
renderWithLocalizationProvider(
<QRCode value={VALUE} localizedLabel={LABEL} />
);
expect(screen.getByRole('img', { name: LABEL })).toBeInTheDocument();
});

it('renders a pre-rendered QR image when imageData is provided', () => {
renderWithLocalizationProvider(
<QRCode imageData={IMAGE_DATA} localizedLabel={LABEL} />
);
expect(screen.getByRole('img', { name: LABEL })).toHaveAttribute(
'src',
IMAGE_DATA
);
});

it('does not generate an SVG QR code when imageData is provided', () => {
const { container } = renderWithLocalizationProvider(
<QRCode imageData={IMAGE_DATA} localizedLabel={LABEL} />
);
expect(container.querySelector('svg')).not.toBeInTheDocument();
});

it('forwards the provided className to the container', () => {
const { container } = renderWithLocalizationProvider(
<QRCode value={VALUE} localizedLabel={LABEL} className="my-10 mx-auto" />
);
expect(container.firstChild).toHaveClass('my-10', 'mx-auto');
});

it('overlays the logo when logoSrc is provided', () => {
const { container } = renderWithLocalizationProvider(
<QRCode value={VALUE} localizedLabel={LABEL} logoSrc="logo.svg" />
);
expect(container.querySelector('img')).toHaveAttribute('src', 'logo.svg');
});

it('renders no logo when logoSrc is omitted', () => {
const { container } = renderWithLocalizationProvider(
<QRCode value={VALUE} localizedLabel={LABEL} />
);
expect(container.querySelector('img')).not.toBeInTheDocument();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible there would be another img element on the page here and produce a false-negative? Maybe there's a tag, aria-label or similar that could be used to target the specific logo image?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do see a test above is doing something similar, so probably worth targeting the image the same way here too

});

it('shows the loading indicator while loading', () => {
renderWithLocalizationProvider(
<QRCode value={VALUE} localizedLabel={LABEL} loading />
);
expect(screen.getByTestId('qrcode-loading')).toBeInTheDocument();
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
});

it('does not show the loading indicator when not loading', () => {
renderWithLocalizationProvider(
<QRCode value={VALUE} localizedLabel={LABEL} />
);
expect(screen.queryByTestId('qrcode-loading')).not.toBeInTheDocument();
});

it('shows the loading indicator for loadingDelayMs, then reveals the QR', () => {
jest.useFakeTimers();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

try {
renderWithLocalizationProvider(
<QRCode value={VALUE} localizedLabel={LABEL} loadingDelayMs={1000} />
);
expect(screen.getByTestId('qrcode-loading')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.queryByTestId('qrcode-loading')).not.toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
});
139 changes: 139 additions & 0 deletions packages/fxa-settings/src/components/QRCode/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { QRCodeSVG } from 'qrcode.react';
import LoadingSpinner from 'fxa-react/components/LoadingSpinner';

// Transparent 1x1 PNG; excavates modules behind the logo so its chip never clips them.
const TRANSPARENT_PIXEL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';

/** Logo footprint as a fraction of the QR size. */
const LOGO_RATIO = 0.2;
/** White padding (px) around the logo, matching the chip's Tailwind `p-3`. */
const LOGO_PADDING_PX = 12;

type QRCodeBaseProps = {
/** Accessible label for the rendered QR image. */
localizedLabel: string;
/** Rendered size in pixels. */
size?: number;
/** Error correction level; defaults to 'H' so an overlaid logo stays scannable. */
level?: 'L' | 'M' | 'Q' | 'H';
/** Optional logo to overlay in the center of the QR code (generated QR only). */
logoSrc?: string;
/** Minimum QR version (1-40); higher means more, smaller modules. Defaults to 1 for larger modules. */
minVersion?: number;
/** Show a loading indicator in place of the QR (e.g. while the value is fetched). */
loading?: boolean;
/** Artificial delay (ms) holding the loading state so the animation registers. Omit for none. */
loadingDelayMs?: number;
className?: string;
};

/**
* Exactly one source is required: either a `value` to encode, or a pre-rendered
* QR as an image data URI (`imageData`, e.g. base64 PNG/SVG) rendered as-is.
*/
export type QRCodeProps = QRCodeBaseProps &
({ value: string; imageData?: never } | { imageData: string; value?: never });

/**
* Domain-agnostic QR code. While loading, a centered spinner replaces the QR,
* which fades in once loading ends.
*/
const QRCode = ({
value,
imageData,
localizedLabel,
size = 224,
level = 'H',
logoSrc,
minVersion = 1,
loading = false,
loadingDelayMs,
className,
}: QRCodeProps) => {
const [delaying, setDelaying] = useState(!!loadingDelayMs);
useEffect(() => {
if (!loadingDelayMs) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a delay here so that page feels a bit more "alive", otw the code shows almost instantly.

setDelaying(false);
return;
}
setDelaying(true);
const timer = setTimeout(() => setDelaying(false), loadingDelayMs);
return () => clearTimeout(timer);
}, [loadingDelayMs]);

const isLoading = loading || delaying;
const logoSize = Math.round(size * LOGO_RATIO);
const excavateSize = logoSize + LOGO_PADDING_PX * 2;
const imageSettings = logoSrc
? {
src: TRANSPARENT_PIXEL,
width: excavateSize,
height: excavateSize,
excavate: true,
}
: undefined;
return (
<div
className={classNames(
'relative w-fit rounded-xl bg-white p-4',
!isLoading && 'border border-black',
className
)}
>
{isLoading && (
<div
className="absolute inset-0 flex items-center justify-center rounded-xl bg-white"
data-testid="qrcode-loading"
>
<LoadingSpinner imageClassName="w-10 h-10 animate-spin" />
</div>
)}
<div
className={classNames(
'relative transition-opacity duration-300',
isLoading ? 'opacity-0' : 'opacity-100'
)}
>
{imageData ? (
<img
src={imageData}
alt={localizedLabel}
width={size}
height={size}
className="block"
/>
) : (
<QRCodeSVG
value={value ?? ''}
size={size}
level={level}
minVersion={minVersion}
imageSettings={imageSettings}
role="img"
aria-label={localizedLabel}
className="block"
/>
)}
{logoSrc && !imageData && (
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-3">
<img
src={logoSrc}
alt=""
className="block"
style={{ width: logoSize, height: logoSize }}
/>
</span>
)}
</div>
</div>
);
};

export default QRCode;
Loading
Loading