Skip to content

Commit

Permalink
Feat: QrCode Component (#1731)
Browse files Browse the repository at this point in the history
  • Loading branch information
brendan-defi authored Dec 16, 2024
1 parent 769e84b commit 185905e
Show file tree
Hide file tree
Showing 16 changed files with 1,109 additions and 1 deletion.
3 changes: 2 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"tabWidth": 2,
"singleQuote": true
"singleQuote": true,
"trailingComma": "all"
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"clsx": "^2.1.1",
"graphql": "^14 || ^15 || ^16",
"graphql-request": "^6.1.0",
"qrcode": "^1.5.4",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"viem": "^2.21.33",
Expand All @@ -59,6 +60,7 @@
"@storybook/test-runner": "^0.19.1",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^14.2.0",
"@types/qrcode": "^1",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitest/coverage-v8": "^2.0.5",
Expand Down
43 changes: 43 additions & 0 deletions src/internal/components/QrCode/QrCodeSvg.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { cbwSvg } from '../../svg/cbwSvg';
import { QrCodeSvg } from './QrCodeSvg';

vi.mock('../../../core-react/internal/hooks/useTheme', () => ({
useTheme: vi.fn(() => 'default'),
}));

describe('QRCodeSVG', () => {
it('renders nothing when no value is provided', () => {
render(<QrCodeSvg value="" />);
expect(screen.queryByTitle('QR Code')).toBeNull();
});

it('renders SVG with default props', () => {
render(<QrCodeSvg value="test" />);

const svg = screen.getByTitle('QR Code');

expect(svg).toBeInTheDocument();
});

it('renders with logo', () => {
render(<QrCodeSvg value="test" logo={cbwSvg} />);

expect(screen.getByTestId('qr-code-logo')).toBeInTheDocument();
});

it('renders with linear gradient', () => {
render(<QrCodeSvg value="test" gradientType="linear" />);

expect(screen.queryByTestId('linearGrad')).toBeInTheDocument();
expect(screen.queryByTestId('radialGrad')).toBeNull();
});

it('renders with radial gradient', () => {
render(<QrCodeSvg value="test" gradientType="radial" />);

expect(screen.queryByTestId('radialGrad')).toBeInTheDocument();
expect(screen.queryByTestId('linearGrad')).not.toBeInTheDocument();
});
});
183 changes: 183 additions & 0 deletions src/internal/components/QrCode/QrCodeSvg.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { useId, useMemo } from 'react';
import { useTheme } from '../../../core-react/internal/hooks/useTheme';
import {
GRADIENT_END_COORDINATES,
GRADIENT_START_COORDINATES,
QR_CODE_SIZE,
QR_LOGO_BACKGROUND_COLOR,
QR_LOGO_RADIUS,
QR_LOGO_SIZE,
linearGradientStops,
ockThemeToLinearGradientColorMap,
ockThemeToRadiamGradientColorMap,
presetGradients,
} from './gradientConstants';
import { useCorners } from './useCorners';
import { useDotsPath } from './useDotsPath';
import { useLogo } from './useLogo';
import { useMatrix } from './useMatrix';

function coordinateAsPercentage(coordinate: number) {
return `${coordinate * 100}%`;
}

export type QRCodeSVGProps = {
value: string;
size?: number;
backgroundColor?: string;
logo?: React.ReactNode;
logoSize?: number;
logoBackgroundColor?: string;
logoMargin?: number;
logoBorderRadius?: number;
quietZone?: number;
quietZoneBorderRadius?: number;
ecl?: 'L' | 'M' | 'Q' | 'H';
gradientType?: 'radial' | 'linear';
};

export function QrCodeSvg({
value,
size = QR_CODE_SIZE,
backgroundColor = '#ffffff',
logo,
logoSize = QR_LOGO_SIZE,
logoBackgroundColor = QR_LOGO_BACKGROUND_COLOR,
logoMargin = 5,
logoBorderRadius = QR_LOGO_RADIUS,
quietZone = 12,
quietZoneBorderRadius = 10,
ecl = 'Q',
gradientType = 'radial',
}: QRCodeSVGProps) {
const gradientRadius = size * 0.55;
const gradientCenterPoint = size / 2;
const uid = useId();

const theme = useTheme();
const themeName = theme.split('-')[0];

const isRadialGradient = gradientType === 'radial';
const fillColor = isRadialGradient ? `url(#radialGrad-${uid})` : '#000000';
const bgColor = isRadialGradient
? backgroundColor
: `url(#linearGrad-${uid})`;

const linearGradientColor =
ockThemeToLinearGradientColorMap[
themeName as keyof typeof ockThemeToLinearGradientColorMap
];
const linearColors = [
linearGradientStops[linearGradientColor].startColor,
linearGradientStops[linearGradientColor].endColor,
];

const presetGradientForColor =
presetGradients[
ockThemeToRadiamGradientColorMap[
themeName as keyof typeof ockThemeToLinearGradientColorMap
] as keyof typeof presetGradients
];

const matrix = useMatrix(value, ecl);
const corners = useCorners(size, matrix.length, bgColor, fillColor, uid);
const { x: x1, y: y1 } = GRADIENT_START_COORDINATES;
const { x: x2, y: y2 } = GRADIENT_END_COORDINATES;

const viewBox = useMemo(() => {
return [
-quietZone,
-quietZone,
size + quietZone * 2,
size + quietZone * 2,
].join(' ');
}, [quietZone, size]);

const svgLogo = useLogo({
size,
logo,
logoSize,
logoBackgroundColor,
logoMargin,
logoBorderRadius,
});

const path = useDotsPath({
matrix,
size,
logoSize,
logoMargin,
logoBorderRadius,
hasLogo: !!logo,
});

if (!path || !value) {
return null;
}

return (
<svg viewBox={viewBox} width={size} height={size}>
<title>QR Code</title>
<defs>
{isRadialGradient ? (
<radialGradient
id={`radialGrad-${uid}`}
data-testid="radialGrad"
rx={gradientRadius}
ry={gradientRadius}
cx={gradientCenterPoint}
cy={gradientCenterPoint}
gradientUnits="userSpaceOnUse"
>
{presetGradientForColor.map(([gradientColor, offset]) => (
<stop
key={`${gradientColor}${offset}`}
offset={offset}
stopColor={gradientColor}
stopOpacity={1}
/>
))}
</radialGradient>
) : (
<linearGradient
id={`linearGrad-${uid}`}
data-testid="linearGrad"
x1={coordinateAsPercentage(x1)}
y1={coordinateAsPercentage(y1)}
x2={coordinateAsPercentage(x2)}
y2={coordinateAsPercentage(y2)}
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor={linearColors[0]} />
<stop offset="1" stopColor={linearColors[1]} />
</linearGradient>
)}
</defs>
<g>
<rect
rx={quietZoneBorderRadius}
ry={quietZoneBorderRadius}
x={-quietZone}
y={-quietZone}
width={size + quietZone * 2}
height={size + quietZone * 2}
fill={backgroundColor}
stroke={bgColor}
strokeWidth={2}
/>
</g>
<g>
<path
d={path}
fill={fillColor}
strokeLinecap="butt"
stroke={fillColor}
strokeWidth={0}
opacity={0.6}
/>
{corners}
{svgLogo}
</g>
</svg>
);
}
115 changes: 115 additions & 0 deletions src/internal/components/QrCode/gradientConstants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import {
GRADIENT_END_COORDINATES,
GRADIENT_END_STYLE,
GRADIENT_START_COORDINATES,
QR_CODE_SIZE,
QR_LOGO_BACKGROUND_COLOR,
QR_LOGO_RADIUS,
QR_LOGO_SIZE,
linearGradientStops,
ockThemeToLinearGradientColorMap,
ockThemeToRadiamGradientColorMap,
presetGradients,
} from './gradientConstants';

describe('QR Code Constants', () => {
it('should have correct size constants', () => {
expect(QR_CODE_SIZE).toBe(237);
expect(QR_LOGO_SIZE).toBe(50);
expect(QR_LOGO_RADIUS).toBe(10);
});

it('should have correct logo background color', () => {
expect(QR_LOGO_BACKGROUND_COLOR).toBe('#ffffff');
});

it('should have correct gradient coordinates', () => {
expect(GRADIENT_START_COORDINATES).toEqual({ x: 0, y: 0 });
expect(GRADIENT_END_COORDINATES).toEqual({ x: 1, y: 0 });
});

it('should have correct gradient end style', () => {
expect(GRADIENT_END_STYLE).toEqual({ borderRadius: 32 });
});
});

describe('Theme Maps', () => {
it('should have correct linear gradient theme mappings', () => {
expect(ockThemeToLinearGradientColorMap).toEqual({
default: 'blue',
base: 'baseBlue',
cyberpunk: 'pink',
hacker: 'black',
});
});

it('should have correct radial gradient theme mappings', () => {
expect(ockThemeToRadiamGradientColorMap).toEqual({
default: 'default',
base: 'blue',
cyberpunk: 'magenta',
hacker: 'black',
});
});
});

describe('Linear Gradient Stops', () => {
it('should have correct blue gradient colors', () => {
expect(linearGradientStops.blue).toEqual({
startColor: '#266EFF',
endColor: '#45E1E5',
});
});

it('should have correct pink gradient colors', () => {
expect(linearGradientStops.pink).toEqual({
startColor: '#EE5A67',
endColor: '#CE46BD',
});
});

it('should have all required gradient themes', () => {
const expectedThemes = ['blue', 'pink', 'black', 'baseBlue'];

for (const theme of expectedThemes) {
expect(linearGradientStops[theme]).toBeDefined();
expect(linearGradientStops[theme].startColor).toBeDefined();
expect(linearGradientStops[theme].endColor).toBeDefined();
}
});
});

describe('Preset Gradients', () => {
it('should have correct default gradient stops', () => {
expect(presetGradients.default).toEqual([
['#0F27FF', '39.06%'],
['#6100FF', '76.56%'],
['#201F1D', '100%'],
]);
});

it('should have all required preset themes', () => {
const expectedPresets = ['default', 'blue', 'magenta', 'black'];

for (const preset of expectedPresets) {
expect(
presetGradients[preset as keyof typeof presetGradients],
).toBeDefined();
expect(
Array.isArray(presetGradients[preset as keyof typeof presetGradients]),
).toBe(true);
expect(
presetGradients[preset as keyof typeof presetGradients].length,
).toBe(3);
}
});

it('should have valid percentage formats for all gradients', () => {
for (const gradient of Object.values(presetGradients)) {
for (const [_, percentage] of gradient) {
expect(percentage).toMatch(/^\d+(\.\d+)?%$/);
}
}
});
});
Loading

0 comments on commit 185905e

Please sign in to comment.