diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34c593be1..8fac3fc02 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
` 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
diff --git a/src/components/qr-code/qr-code.spec.ts b/src/components/qr-code/qr-code.spec.ts
index 3a912ce94..20d5d60ac 100644
--- a/src/components/qr-code/qr-code.spec.ts
+++ b/src/components/qr-code/qr-code.spec.ts
@@ -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(() => {
@@ -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(
+ html``
+ );
+
+ 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(
+ html``
+ );
+
+ 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', () => {
@@ -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(
+ html``
+ );
+ 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', () => {
@@ -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. */
@@ -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(
+ html``
+ );
+
+ 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(
+ html``
+ );
+
+ 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(
html`` reads. */
+const LABEL_ATTRIBUTES: readonly string[] = ['aria-label'];
+
/**
*
* Generates a QR code based on the provided value and options.
@@ -65,6 +68,16 @@ export default class IgcQrCodeComponent extends LitElement {
registerComponent(IgcQrCodeComponent);
}
+ /**
+ * Adds the label attributes, so that a new label renders a new ``.
+ * 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})`;
@@ -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);
@@ -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.
@@ -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
@@ -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): void {
if (props.has('logoSrc')) {
@@ -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(
diff --git a/src/components/qr-code/renderer/export.ts b/src/components/qr-code/renderer/export.ts
index 11bf5f62e..7c51224eb 100644
--- a/src/components/qr-code/renderer/export.ts
+++ b/src/components/qr-code/renderer/export.ts
@@ -10,6 +10,8 @@ export const MIME_TYPES: Readonly> = {
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.
@@ -110,6 +112,29 @@ async function inlineLogo(clone: SVGSVGElement): Promise {
}
}
+/**
+ * Copies the logo `href` to `xlink:href`.
+ *
+ * `igc-qr-code` writes the logo as ``. 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.
@@ -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;
}
diff --git a/src/components/qr-code/spec.md b/src/components/qr-code/spec.md
index 618a5a22a..ee485ddb5 100644
--- a/src/components/qr-code/spec.md
+++ b/src/components/qr-code/spec.md
@@ -50,6 +50,7 @@
| ------: | ---------- | -------------------------------------------------------- |
| 1 | 2026-09-21 | Initial specification |
| 2 | 2026-09-28 | Add the property-based model suite; V30-V40/M table fix |
+| 3 | 2026-09-30 | Automatic error level, reactive label, `xlink:href` export |
## Overview
@@ -67,8 +68,8 @@ authentication setup URLs, contact sharing and linking printed material to digit
- **Automatic encoding**: the most compact mode — numeric, alphanumeric or byte — and the smallest version that
fits the value, unless a `version` is pinned.
- **Configurable error correction** across `L`, `M`, `Q` and `H`, trading capacity for resilience.
-- **Center logo** with the modules beneath it masked out, and, when `error-level` is not set explicitly, an
- automatic escalation to the smallest level that keeps the code scannable at the requested logo size.
+- **Center logo** with the modules beneath it masked out, and, when `error-level` is not set, an automatic
+ escalation from `M` to the smallest level that keeps the code scannable at the requested logo size.
- **Visual customization** of the data modules and the finder-pattern corners as `square`, `circle` or `rounded`.
- **Themeable colors** through CSS custom properties, with parts for the background, the dots and each corner
element.
@@ -160,8 +161,11 @@ The code keeps a fixed size regardless of the length of the value, as long as th
```
`logoSize` is a ratio of the area that can safely be obscured at the resolved error correction level, not of the
-whole code. When `error-level` is not set explicitly, the component escalates to the smallest level that
-accommodates the requested size. A logo box that collapses to zero renders no image and throws nothing.
+whole code. When `error-level` is not set, the component uses `M`, and a logo larger than the safe area of `M`
+escalates the code to the smallest level that accommodates the requested size; a logo never lowers the level below
+`M`. `errorLevel` still reads `M` in that case, and setting it to `undefined` or removing the attribute restores the
+automatic level. An explicit level, `M` included, caps the logo to the safe area of that level. A logo box that
+collapses to zero renders no image and throws nothing.
Logo sources are validated: `javascript:` and `vbscript:` URLs and non-image `data:` URIs are rejected, and an
image that fails to load leaves the code without a logo until a valid source is assigned.
@@ -193,7 +197,9 @@ scanning reliably.
`toBlob()` serializes the code to an `image/svg+xml` blob, with the theme colors resolved to plain `fill`
attributes and a logo that is not a data URI fetched and inlined, so that the blob renders the same outside the
component. A logo that cannot be fetched, for example a cross-origin URL without CORS headers, is dropped together
-with its mask.
+with its mask. The logo is written as both `href` and `xlink:href`: SVG 1.1 consumers, such as Illustrator, the
+Office import, Batik and older librsvg, read only `xlink:href`, and SVG 2 gives `href` priority when both are
+present.
`toImage(options)` exports a file. `scale` multiplies the `size` of the component, `format` takes `png`, `jpeg`,
`webp` or `svg`, `fileName` names the file and gets the extension of the format appended, and `download` opens the
@@ -295,7 +301,8 @@ None. The SVG is rendered entirely from the properties of the component.
### Accessibility tests
1. The component passes the accessibility audit when a value is set.
-2. The SVG carries a `` for screen readers, which uses the `aria-label` when one is provided.
+2. The SVG carries a `` for screen readers, which uses the `aria-label` when one is provided, follows a
+ change of `ariaLabel` alone, and returns to the default when the attribute is removed.
### Default property values
@@ -334,14 +341,16 @@ None. The SVG is rendered entirely from the properties of the component.
15. Unsafe schemes and non-image `data:` URIs are blocked, while `data:image/` and `https://` URLs are accepted.
16. A logo that fails to load leaves the code without one, and the component recovers once a valid source is set.
17. A higher error correction level produces a larger code, and the logo area is capped to the safe area of the
- resolved level.
+ resolved level. Without `error-level`, a large logo escalates the level while `errorLevel` reads `M`, a small
+ logo keeps `M`, an explicit `M` caps the logo, and removing the attribute restores the escalation.
18. `logoMargin` reduces the visible logo, and a margin that consumes the whole logo box renders no image.
### Export
19. `toBlob()` returns an SVG blob, resolves the theme colors to `fill` attributes and strips the parts.
20. A data URI logo is kept as it is, a fetched one is inlined as a data URI, a logo assigned right before the
- export is awaited, and one that cannot be fetched is dropped together with its mask.
+ export is awaited, and one that cannot be fetched is dropped together with its mask. The logo is written as both
+ `href` and `xlink:href`, and a code without a logo has no `xlink:href`.
21. `toImage()` exports a PNG at the component size by default, scales the raster output, and exports an opaque
JPEG, a WebP and an SVG with scaled dimensions.
22. An existing matching extension in the file name is kept, and the download dialog opens only when requested.
@@ -377,7 +386,8 @@ None. The SVG is rendered entirely from the properties of the component.
- The rendered `