Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- #### QR code
- Versions 30 to 40 at the `M` error correction level now use the data codeword counts of ISO/IEC 18004. Before, these codes had the wrong block structure.
- Without `error-level`, a logo larger than the safe area of level `M` now raises the error correction level to the smallest one that holds the logo, as documented. Before, the default `M` always applied, so the logo shrank. An explicit `error-level`, `M` included, still caps the logo.
- A new `aria-label` alone now updates the `<title>` of the code. Before, the title changed only on the next change of another property.
- The logo in an exported SVG now also has `xlink:href`, so SVG 1.1 consumers, such as Illustrator, the Office import, Batik and older librsvg, show it. Before, they dropped the logo and left a blank area in the code.
- #### Mask input, Date time input, Date range picker
- A mask position holds one UTF-16 code unit, so an astral character, such as an emoji, is now rejected as input and as a prompt. Before, it shifted the positions after it or split into two halves. A mask or input format with an astral literal now edits at the correct positions.
- #### Mask input
Expand Down
118 changes: 118 additions & 0 deletions src/components/qr-code/qr-code.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import { ifDefined } from 'lit/directives/if-defined.js';
import { restore, stub } from 'sinon';
import { defineComponents } from '#internals/definitions/defineComponents.js';
import { asNumber } from '#internals/utils/math.js';
import { configureTheme } from '#theming/config.js';
import IgcQrCodeComponent from './qr-code.js';
import type { QrErrorCorrectionLevel } from './types.js';

describe('IgcQrCodeComponent', () => {
before(() => {
Expand Down Expand Up @@ -41,6 +43,35 @@ describe('IgcQrCodeComponent', () => {
const title = el.renderRoot.querySelector('svg title');
expect(title?.textContent).to.equal('Scan me');
});

it('updates the SVG title when only ariaLabel changes', async () => {
const el = await fixture<IgcQrCodeComponent>(
html`<igc-qr-code value="https://example.com"></igc-qr-code>`
);

el.ariaLabel = 'Scan to visit our product page';
await elementUpdated(el);

expect(el.renderRoot.querySelector('svg title')?.textContent).to.equal(
'Scan to visit our product page'
);
});

it('restores the default SVG title when aria-label is removed', async () => {
const el = await fixture<IgcQrCodeComponent>(
html`<igc-qr-code
value="https://example.com"
aria-label="Scan me"
></igc-qr-code>`
);

el.removeAttribute('aria-label');
await elementUpdated(el);

expect(el.renderRoot.querySelector('svg title')?.textContent).to.equal(
'QR code: https://example.com'
);
});
});

describe('Default property values', () => {
Expand Down Expand Up @@ -563,6 +594,63 @@ describe('IgcQrCodeComponent', () => {

expect(widthL).to.be.lessThan(widthH);
});

/** Renders a code with `VALID_LOGO`, and returns it with its viewBox and logo width. */
async function renderLogo(
logoSize: number,
errorLevel?: QrErrorCorrectionLevel
): Promise<{ el: IgcQrCodeComponent; viewBox: string; width: string }> {
const el = await fixture<IgcQrCodeComponent>(
html`<igc-qr-code
value="https://example.com"
logo-size=${logoSize}
error-level=${ifDefined(errorLevel)}
logo-src=${VALID_LOGO}
></igc-qr-code>`
);
await elementUpdated(el);

return {
el,
viewBox: getSvg(el)!.getAttribute('viewBox')!,
width: el.renderRoot.querySelector('image')!.getAttribute('width')!,
};
}

it('raises the level for a large logo when error-level is not set', async () => {
const auto = await renderLogo(1);
const high = await renderLogo(1, 'H');

expect(auto.viewBox).to.equal(high.viewBox);
expect(auto.width).to.equal(high.width);
expect(auto.el.errorLevel).to.equal('M');
});

it('keeps level M for a small logo when error-level is not set', async () => {
const auto = await renderLogo(0.2);
const medium = await renderLogo(0.2, 'M');

expect(auto.viewBox).to.equal(medium.viewBox);
expect(auto.width).to.equal(medium.width);
});

it('caps a large logo to the safe area of an explicit error-level="M"', async () => {
const auto = await renderLogo(1);
const medium = await renderLogo(1, 'M');

expect(asNumber(medium.width)).to.be.lessThan(asNumber(auto.width));
});

it('raises the level again after error-level is removed', async () => {
const high = await renderLogo(1, 'H');
const { el } = await renderLogo(1, 'L');

el.removeAttribute('error-level');
await elementUpdated(el);

expect(el.errorLevel).to.equal('M');
expect(getSvg(el)!.getAttribute('viewBox')).to.equal(high.viewBox);
});
});

describe('logoMargin', () => {
Expand Down Expand Up @@ -606,6 +694,8 @@ describe('IgcQrCodeComponent', () => {
const LOGO =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink';

let logoUrl: string | undefined;

/** Serves the logo through an object URL so the export has to fetch and inline it. */
Expand Down Expand Up @@ -701,6 +791,34 @@ describe('IgcQrCodeComponent', () => {
expect(svg.querySelector('mask')).to.exist;
});

it('writes the logo as both href and xlink:href', async () => {
const el = await fixture<IgcQrCodeComponent>(
html`<igc-qr-code
value="https://example.com"
logo-src=${createLogoUrl()}
></igc-qr-code>`
);

const blob = await el.toBlob();
const image = (await parseSvg(blob)).querySelector('image')!;
const href = image.getAttribute('href');

expect(href).to.match(/^data:image\/png/);
expect(image.getAttributeNS(XLINK_NAMESPACE, 'href')).to.equal(href);
expect(await blob.text()).to.include(
`xmlns:xlink="${XLINK_NAMESPACE}"`
);
});

it('writes no xlink:href without a logo', async () => {
const el = await fixture<IgcQrCodeComponent>(
html`<igc-qr-code value="https://example.com"></igc-qr-code>`
);

const markup = await (await el.toBlob()).text();
expect(markup).not.to.include('xlink');
});

it('inlines a fetched logo as a data URI', async () => {
const el = await fixture<IgcQrCodeComponent>(
html`<igc-qr-code
Expand Down
75 changes: 57 additions & 18 deletions src/components/qr-code/qr-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ import type {

const nextMaskId = createIdGenerator('igc-qr-code-mask');

/** The native ARIA attributes that the SVG `<title>` reads. */
const LABEL_ATTRIBUTES: readonly string[] = ['aria-label'];

/**
*
* Generates a QR code based on the provided value and options.
Expand Down Expand Up @@ -65,6 +68,16 @@ export default class IgcQrCodeComponent extends LitElement {
registerComponent(IgcQrCodeComponent);
}

/**
* Adds the label attributes, so that a new label renders a new `<title>`.
* The spread keeps the manifest analyzer from listing them as attributes of
* the component.
* @internal
*/
public static override get observedAttributes(): string[] {
return [...super.observedAttributes, ...LABEL_ATTRIBUTES];
}

private readonly _abortHandle = createAbortHandle();
private readonly _maskId = nextMaskId();
private readonly _maskUrl = `url(#${this._maskId})`;
Expand All @@ -82,6 +95,9 @@ export default class IgcQrCodeComponent extends LitElement {
@state()
private _logoLoadFailed = false;

/** The error correction level that the application set. */
private _errorLevel?: QrErrorCorrectionLevel;

constructor() {
super();
addThemingController(this, all);
Expand Down Expand Up @@ -111,11 +127,21 @@ export default class IgcQrCodeComponent extends LitElement {
* The error correction level for the QR code, which determines the QR code's ability to be read if it is partially obscured or damaged.
* Valid values are 'L', 'M', 'Q', and 'H', where 'L' provides the lowest level of error correction and 'H' provides the highest level.
*
* When the level is not set, the code uses 'M'. A logo that is larger than the safe area of 'M'
* raises the level to the smallest level that holds the logo. To restore this behavior, set
* `undefined` or remove the attribute.
*
* @attr error-level
* @default 'M'
*/
@property({ attribute: 'error-level' })
public errorLevel?: QrErrorCorrectionLevel = 'M';
public set errorLevel(value: QrErrorCorrectionLevel | null | undefined) {
this._errorLevel = value || undefined;
}

public get errorLevel(): QrErrorCorrectionLevel {
return this._errorLevel ?? 'M';
}

/**
* The size of the QR code in pixels. This determines the width and height of the generated QR code. The default value is 128 pixels.
Expand Down Expand Up @@ -153,8 +179,8 @@ export default class IgcQrCodeComponent extends LitElement {
* means the logo will cover the full safe area (not the entire QR code).
* The default value is 0.4, meaning the logo covers 40% of that safe area (~3.6% of the QR code).
*
* When `error-level` is not explicitly set, the smallest error correction level that can
* accommodate the requested logo size is chosen automatically.
* When `error-level` is not set and the logo is larger than the safe area of level 'M', the
* component uses the smallest error correction level that holds the logo.
*
* @attr logo-size
* @default 0.4
Expand Down Expand Up @@ -190,6 +216,20 @@ export default class IgcQrCodeComponent extends LitElement {
@property({ attribute: 'square-style' })
public squareStyle: QrCornerSquareStyle = 'square';

/** @internal */
public override attributeChangedCallback(
name: string,
previous: string | null,
current: string | null
): void {
super.attributeChangedCallback(name, previous, current);

// A native ARIA attribute is not a reactive property.
if (LABEL_ATTRIBUTES.includes(name)) {
this.requestUpdate();
}
}

/** @internal */
protected override update(props: PropertyValues<this>): void {
if (props.has('logoSrc')) {
Expand Down Expand Up @@ -254,34 +294,33 @@ export default class IgcQrCodeComponent extends LitElement {
return true;
}

/**
* Returns the smallest level, from 'M' up, that holds a logo of `area`.
* A logo removes modules, so it does not lower the level below the default.
*/
private _pickErrorLevel(area: number): QrErrorCorrectionLevel {
if (area <= SAFE_AREAS.L) return 'L';
if (area <= SAFE_AREAS.M) return 'M';
if (area <= SAFE_AREAS.Q) return 'Q';
return 'H';
}

private _getErrorLevelAndArea(hasLogo: boolean) {
const userErrorLevel = this.errorLevel;
private _getErrorLevelAndArea(hasLogo: boolean): {
errorLevel: QrErrorCorrectionLevel;
area: number;
} {
const userErrorLevel = this._errorLevel;
const size = this.logoSize;
const sizeRatio = hasLogo ? clamp(size ?? DEFAULT_SIZE_RATIO, 0, 1) : 0;
const targetArea = sizeRatio * MAX_SAFE_AREA;

let errorLevel: QrErrorCorrectionLevel;
let area: number;

if (userErrorLevel) {
errorLevel = userErrorLevel;
area = Math.min(targetArea, SAFE_AREAS[userErrorLevel]);
} else if (targetArea > 0) {
errorLevel = this._pickErrorLevel(targetArea);
area = targetArea;
} else {
errorLevel = 'M';
area = 0;
return {
errorLevel: userErrorLevel,
area: Math.min(targetArea, SAFE_AREAS[userErrorLevel]),
};
}

return { errorLevel, area };
return { errorLevel: this._pickErrorLevel(targetArea), area: targetArea };
}

private _getMatrix(
Expand Down
26 changes: 26 additions & 0 deletions src/components/qr-code/renderer/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const MIME_TYPES: Readonly<Record<QrCodeExportFormat, string>> = {
webp: 'image/webp',
};

const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink';

/**
* Maximum side length in pixels of an exported raster image.
* Browsers fail to allocate or encode canvases past this bound.
Expand Down Expand Up @@ -110,6 +112,29 @@ async function inlineLogo(clone: SVGSVGElement): Promise<void> {
}
}

/**
* Copies the logo `href` to `xlink:href`.
*
* `igc-qr-code` writes the logo as `<image href="data:...">`. Browsers resolve
* that, so the export looks right in a browser, but SVG 1.1 consumers -
* Illustrator, the Office import, Batik, older librsvg - read only
* `xlink:href` and drop the logo, which leaves a blank hole in the middle of
* an otherwise correct QR code. Writing both keeps either kind of consumer
* happy; SVG 2 gives `href` priority when the two are present.
*
* `setAttributeNS` puts the attribute in the XLink namespace, so the
* serializer declares `xmlns:xlink`. A plain `setAttribute` leaves the prefix
* undeclared, and the exported file does not parse as XML.
*/
function addLegacyLogoHref(clone: SVGSVGElement): void {
const image = clone.querySelector('image');
const href = image?.getAttribute('href');

if (image && href) {
image.setAttributeNS(XLINK_NAMESPACE, 'xlink:href', href);
}
}

/**
* Creates a self-contained copy of the component SVG: theme colors are resolved
* to presentation attributes and the logo is inlined as a data URI.
Expand All @@ -120,6 +145,7 @@ export async function createSvgSnapshot(
const clone = source.cloneNode(true) as SVGSVGElement;
resolveStyles(source, clone);
await inlineLogo(clone);
addLegacyLogoHref(clone);
return clone;
}

Expand Down
Loading
Loading