diff --git a/.changeset/great-eagles-jam.md b/.changeset/great-eagles-jam.md new file mode 100644 index 000000000..f2e564ae6 --- /dev/null +++ b/.changeset/great-eagles-jam.md @@ -0,0 +1,18 @@ +--- +'@svelte-vitals/core': patch +'svelte-vitals': patch +'@svelte-vitals/vite': patch +--- + +Two more corrections from the a11y rule-validity review. + +- **`a11y/no-missing-id-ref` read a text fragment as an id reference.** `href="#:~:text=hello%20world"` + is a shipped web-platform feature — everything from `:~:` on instructs the user agent to find + text, and names no element — so the rule reported a missing id, printing the percent-decoded form + and sending the reader to look for a string that is not in their source. The directive is now + stripped in both modes, and any element fragment before it is still checked: `#section:~:text=hi` + resolves against `id="section"` exactly as `#section` would. +- **`a11y/interactive-nesting` did not say which element it meant.** A finding read + `', '
'].join('\n'); expect(parseComponentFacts(src, 'C.svelte').interactiveNestings ?? []).toEqual([]); }); + it('carries the role that made an element a container', () => { + // The role reported is the one a user agent resolves to, not the first token. + expect( + parseComponentFacts('
', 'C.svelte').interactiveNestings + ).toEqual([{ containerTag: 'div', containerRole: 'button', descendantTag: 'button', line: 1 }]); + const c = parseComponentFacts('
z
', 'C.svelte'); + expect(c.interactiveNestings).toEqual([ + { containerTag: 'div', containerRole: 'checkbox', descendantTag: 'a', line: 1 } + ]); + // A tag that is a container on its own carries no role — the message would read oddly. + expect(parseComponentFacts('', 'C.svelte').interactiveNestings).toEqual([ + { containerTag: 'a', descendantTag: 'button', line: 1 } + ]); + }); it('resolves a fallback list to its first concrete role', () => { // `future-role button` is a button: the unknown token is skipped, not applied. const c = parseComponentFacts('
', 'C.svelte'); diff --git a/packages/vite/src/providers/rendered/parse-html.ts b/packages/vite/src/providers/rendered/parse-html.ts index 454c12403..e2124c694 100644 --- a/packages/vite/src/providers/rendered/parse-html.ts +++ b/packages/vite/src/providers/rendered/parse-html.ts @@ -5,6 +5,7 @@ import { decodeFragmentId, splitTokens, isTopFragment, + stripTextDirective, LANDMARK_ROLES, IDREF_ATTRS } from '@svelte-vitals/core/internal'; @@ -119,10 +120,11 @@ function collectA11y(root: HTMLElement): CollectedA11y { if (id && id.trim()) ids.push(id); const href = el.getAttribute('href'); - if (href && href.startsWith('#') && href.length > 1) { + const target = href?.startsWith('#') ? stripTextDirective(href.slice(1)) : ''; + if (target) { // Navigation percent-decodes the fragment before matching an id (#caf%C3%A9 → café), and // the "top of the document" check compares the decoded form too (#%74op === #top). - const fragment = decodeFragmentId(href.slice(1)); + const fragment = decodeFragmentId(target); if (!isTopFragment(fragment)) idRefs.push({ id: fragment, attr: 'href' }); } for (const attr of IDREF_ATTRS) { diff --git a/packages/vite/test/parse-html.test.ts b/packages/vite/test/parse-html.test.ts index 1f0f28ea2..f28597bf7 100644 --- a/packages/vite/test/parse-html.test.ts +++ b/packages/vite/test/parse-html.test.ts @@ -242,6 +242,14 @@ describe('parse-html: a11y capture (rendered landmark/id parity)', () => { ); }); + it('strips a text-fragment directive and keeps any element fragment before it', () => { + // `:~:` onward is user-agent instructions; `#section:~:text=…` still targets id="section". + const { idRefs } = parseHtmlHead( + doc('abc') + ); + expect(idRefs.map((r) => r.id)).toEqual(['section', 'real']); + }); + it('excludes #top by its decoded form and skips whitespace-only ids', () => { const { idRefs, ids } = parseHtmlHead(doc('upup

x

')); expect(idRefs).toEqual([]);