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<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', () => { @@ -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', () => { @@ -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<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 diff --git a/src/components/qr-code/qr-code.ts b/src/components/qr-code/qr-code.ts index 1e76ee0e2..92ae50b06 100644 --- a/src/components/qr-code/qr-code.ts +++ b/src/components/qr-code/qr-code.ts @@ -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. @@ -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})`; @@ -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<this>): 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<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. @@ -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. @@ -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 `<title>` for screen readers, which uses the `aria-label` when one is provided. +2. The SVG carries a `<title>` 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 `<svg>` has `role="img"`. - The `<svg>` holds a `<title>` describing the code, taken from `ariaLabel` when it is set and defaulting to - `QR code: <value>`. + `QR code: <value>`. The component observes the `aria-label` attribute, so a new label alone renders a new + `<title>`. ### Keyboard support