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
23 changes: 23 additions & 0 deletions .changeset/loud-pugs-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@svelte-vitals/core': patch
---

Stop `a11y/accessible-name` and `a11y/label-has-control` reporting content they cannot see.

Both rules already skip content they cannot resolve — an expression, a component, `{@render}`,
`{@html}` — but four routes were read as _absent_ rather than _unknowable_:

- **A `<slot>` or `<svelte:fragment>`** supplies content from the parent, so `<button><slot /></button>`
and `<label>Name<slot name="control" /></label>` were flagged despite being named and associated
by whoever renders them.
- **A hyphenated custom element** may be form-associated (and so labelable) and may name its host
from a shadow root, so `<label>Name <my-input></my-input></label>` was flagged.
- **An expression-valued `alt`** was invisible while an expression `aria-label` was accepted, so
the idiomatic `<a href="/about"><img src="/logo.png" alt={siteName} /></a>` was "unnamed".
- **A `<label>` naming a `button` or `input type="image"`**, by wrapping it or by pointing `for` at
its `id`, is a step in the name computation ahead of the element's own subtree. `<a>` has no such
step, so links are unchanged. It counts only when the label itself contributes something — a
provably empty label leaves the control unnamed and still reported — and a wrapping label reaches
only the first labelable element inside it. The `for` route is same-file only.

All four narrow detection, so recorded suppressions keep matching.
4 changes: 2 additions & 2 deletions docs/blume.translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"ja": "bbce2ab2950b6339"
},
"src/content/docs/rules/a11y/accessible-name.md": {
"ja": "8c913ec8421b502e"
"ja": "e478d96284ecbfcc"
},
"src/content/docs/rules/a11y/doctype.md": {
"ja": "bbd2d7d3dbad907e"
Expand All @@ -76,7 +76,7 @@
"ja": "0406a5a1dcc220be"
},
"src/content/docs/rules/a11y/label-has-control.md": {
"ja": "0ee96ff758bdd77e"
"ja": "f6ff59f50700d0f3"
},
"src/content/docs/rules/a11y/no-missing-id-ref.md": {
"ja": "c970fb617aa874cb"
Expand Down
11 changes: 8 additions & 3 deletions docs/src/content/docs/ja/rules/a11y/accessible-name.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ description: ボタン・リンク・画像ボタンには、アクセシブル

- 空白以外のテキストを含む子孫、例: `<button>Save</button>`。
- `aria-label`、`aria-labelledby`、`title` 属性 — リテラルで空でない値、または式(レンダリング結果は静的にはわからなくても、属性が存在すること自体で十分とみなします)。
- 非空のリテラル `alt` を持つ子孫 `<img>`。
- `<input type="image">` 自身の非空のリテラル `alt`。
- `alt` を持つ子孫 `<img>` — 非空のリテラル、または式(上の `aria-label` と同じ扱い)。
- `<input type="image">` 自身の `alt`(同じ規則)。
- 名前を与えている `<label>` — 囲んでいる場合と、`for` で `id` を指している場合。名前の計算ではこの label の段階が要素自身のサブツリーより先に来ます。対象は `<button>` と `<input type="image">` のみで、`<a>` にこの段階はありません。有効なのは label 自身が何かを与えている場合に限られ、空だと証明できる label はコントロールを無名のままにするので引き続き検出されます。また囲む label が届くのは**最初の** labelable 要素だけなので、同じ label 内の 2 つ目のコントロールは単独で判定されます。`for` 経由の判定は同一ファイル内に限られ、他コンポーネントの label は既知の制限です。

名前の手がかりが見つからない場合でも、内容が静的に判断できない要素は検出しません — `{式}` の子、コンポーネントの子、`{@render …}`、`{@html …}`、あるいは要素自身へのスプレッド属性です。このルールは「名前がないと証明できる」ものだけを検出し、動的な内容を推測することはありません。
名前の手がかりが見つからない場合でも、次のものは検出しません。

- 内容が静的に判断できない要素 — `{式}` の子、コンポーネントの子、`{@render …}`、`{@html …}`、`<slot>` や `<svelte:fragment>`(内容は親から渡される)、ハイフンを含むカスタム要素(shadow root が内容を供給しうる)、あるいは要素自身へのスプレッド属性。

このルールは「名前がないと証明できる」ものだけを検出し、動的な内容を推測することはありません。

```svelte
<button></button>
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/ja/rules/a11y/label-has-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ description: label をフィールドと関連付けるには for 属性かラ
- `for` 属性 — リテラルまたは式(値は静的にはわからなくても、属性が存在すること自体で十分とみなします)。
- ラップされたラベル付け可能な子孫: `input`(リテラルな `type` が `hidden` の場合を除く)、`select`、`textarea`、`button`、`meter`、`output`、`progress`。

関連付けが見つからない場合でも、内容が静的に判断できない label は検出しません — `{式}` の子、コンポーネントの子、`{@render …}`、`{@html …}` です。このルールは「関連付けがないと証明できる」ものだけを検出し、動的な内容を推測することはありません。
関連付けが見つからない場合でも、内容が静的に判断できない label は検出しません — `{式}` の子、コンポーネントの子、`{@render …}`、`{@html …}`、`<slot>` や `<svelte:fragment>`(内容は親から渡される)、ハイフンを含むカスタム要素(form-associated であれば labelable になりうる)です。このルールは「関連付けがないと証明できる」ものだけを検出し、動的な内容を推測することはありません。

```svelte
<label>Name</label>
Expand Down
10 changes: 7 additions & 3 deletions docs/src/content/docs/rules/a11y/accessible-name.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ Any of the following, if present, is a name source — the element is not flagge

- A non-whitespace text descendant, e.g. `<button>Save</button>`.
- An `aria-label`, `aria-labelledby`, or `title` attribute — a literal non-empty value, or any expression (its rendered value is unknowable, but the attribute's presence is enough).
- A descendant `<img>` with a non-empty literal `alt`.
- For `<input type="image">`, its own non-empty literal `alt`.
- A descendant `<img>` with an `alt` — a non-empty literal, or any expression, on the same footing as `aria-label` above.
- For `<input type="image">`, its own `alt`, under the same rule.
- A `<label>` that names it: one wrapping it, or one pointing `for` at its `id`. This step comes ahead of the element's own subtree in the name computation, and applies to `<button>` and `<input type="image">` only — `<a>` has no such step. It counts only when the label itself contributes something: a label that is provably empty leaves the control unnamed and still reported, and a wrapping label reaches only the **first** labelable element inside it, so a second control in the same label is judged on its own. The `for` route is same-file only; a label in another component is a known limitation.

Not flagged, even with no name source found: an element whose content is unknowable — any `{expression}` child, a component child, `{@render …}`, `{@html …}`, or a spread attribute on the element itself. The rule only flags what it can prove is unnamed; it never guesses at dynamic content.
Not flagged, even with no name source found:

- An element whose content is unknowable — any `{expression}` child, a component child, `{@render …}`, `{@html …}`, a `<slot>` or `<svelte:fragment>` (its content comes from the parent), a hyphenated custom element (its shadow root may supply content), or a spread attribute on the element itself.
The rule only flags what it can prove is unnamed; it never guesses at dynamic content.

```svelte
<button></button>
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/rules/a11y/label-has-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Either of the following, if present, associates the label — it is not flagged:
- A `for` attribute — a literal or an expression (its value is unknowable, but the attribute's presence is enough).
- A wrapped labelable descendant: `input` (unless its literal `type` is `hidden`), `select`, `textarea`, `button`, `meter`, `output`, or `progress`.

Not flagged, even with no association found: a label whose content is unknowable — any `{expression}` child, a component child, `{@render …}`, or `{@html …}`. The rule only flags what it can prove is unassociated; it never guesses at dynamic content.
Not flagged, even with no association found: a label whose content is unknowable — any `{expression}` child, a component child, `{@render …}`, `{@html …}`, a `<slot>` or `<svelte:fragment>` (its content comes from the parent), or a hyphenated custom element (which may be form-associated, and so labelable). The rule only flags what it can prove is unassociated; it never guesses at dynamic content.

```svelte
<label>Name</label>
Expand Down
117 changes: 97 additions & 20 deletions packages/core/src/component-parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1193,50 +1193,65 @@ function collectInteractiveNestings(node: Node, source: string, acc: Interactive
/** `aria-label`/`aria-labelledby`/`title` present on this element: a literal non-empty value,
* or any expression (its rendered value is unknowable, but its *presence* is enough to name
* the element — a11y/accessible-name treats an expression name source as present). */
/** An attribute present with a non-blank literal, or with an expression whose value is unknowable
* and so must be assumed to carry one. */
function hasNamingValue(attributes: Node[], name: string): boolean {
const attr = findAttr(attributes, name);
if (!attr) return false;
const v = classifyAttrValue(attr.value);
return 'expression' in v || (v.literal !== undefined && v.literal.trim().length > 0);
}

function hasNamingAttr(attributes: Node[]): boolean {
return ['aria-label', 'aria-labelledby', 'title'].some((name) => {
const attr = findAttr(attributes, name);
if (!attr) return false;
const v = classifyAttrValue(attr.value);
return 'expression' in v || (v.literal !== undefined && v.literal.trim().length > 0);
});
return ['aria-label', 'aria-labelledby', 'title'].some((name) => hasNamingValue(attributes, name));
}

/** A tag name with a hyphen is a custom element: it may be form-associated, and its shadow root may
* supply content, neither of which is visible here. Treated as unknowable by both a11y scanners. */
function isCustomElement(node: Node): boolean {
return node.type === 'RegularElement' && typeof node.name === 'string' && node.name.includes('-');
}

/** Named/unknowable verdict for a candidate interactive element's descendant subtree
* (a11y/accessible-name). `named`: a non-whitespace text descendant, or a descendant `img`
* with a non-empty literal `alt`. `unknowable`: an expression-tag, `Component`, `{@render}`,
* or `{@html}` anywhere below — content the rule cannot statically resolve, so the element is
* skipped rather than risk a false positive. */
function scanAccessibleNameSubtree(node: Node): { named: boolean; unknowable: boolean } {
function scanAccessibleNameSubtree(node: Node, skip?: Node): { named: boolean; unknowable: boolean } {
if (Array.isArray(node)) {
const acc = { named: false, unknowable: false };
for (const child of node) {
const r = scanAccessibleNameSubtree(child);
const r = scanAccessibleNameSubtree(child, skip);
acc.named ||= r.named;
acc.unknowable ||= r.unknowable;
}
return acc;
}
if (!node || typeof node !== 'object') return { named: false, unknowable: false };
// HTML-AAM: when a label encapsulates its control, the control's own subtree is not part of the
// label's text.
if (skip !== undefined && node === skip) return { named: false, unknowable: false };
// A snippet's body renders at its {@render} site — its text cannot name this element.
if (node.type === 'SnippetBlock') return { named: false, unknowable: false };
if (node.type === 'Text') return { named: String(node.data ?? '').trim().length > 0, unknowable: false };
if (
node.type === 'ExpressionTag' ||
node.type === 'RenderTag' ||
node.type === 'HtmlTag' ||
COMPONENT_LIKE_TYPES.has(node.type)
node.type === 'SlotElement' ||
node.type === 'SvelteFragment' ||
COMPONENT_LIKE_TYPES.has(node.type) ||
isCustomElement(node)
) {
return { named: false, unknowable: true };
}
if (node.type === 'RegularElement' && node.name === 'img' && Array.isArray(node.attributes)) {
const alt = attrText(node.attributes, 'alt');
if (alt !== undefined && alt.trim().length > 0) return { named: true, unknowable: false };
if (hasNamingValue(node.attributes, 'alt')) return { named: true, unknowable: false };
}
const acc = { named: false, unknowable: false };
for (const key of CHILD_NODE_KEYS) {
if (key in node) {
const r = scanAccessibleNameSubtree(node[key]);
const r = scanAccessibleNameSubtree(node[key], skip);
acc.named ||= r.named;
acc.unknowable ||= r.unknowable;
}
Expand All @@ -1258,24 +1273,81 @@ function accessibleNameTarget(node: Node): 'button' | 'a' | 'input' | undefined
return undefined;
}

/** The first labelable descendant of a `<label>` — the only control an implicit association
* reaches, so a second control inside the same label is not named by it. */
function firstLabelableDescendant(node: Node): Node | undefined {
if (Array.isArray(node)) {
for (const child of node) {
const hit = firstLabelableDescendant(child);
if (hit) return hit;
}
return undefined;
}
if (!node || typeof node !== 'object') return undefined;
if (node.type === 'SnippetBlock') return undefined;
if (node.type === 'RegularElement' && isLabelableDescendant(node)) return node;
for (const key of CHILD_NODE_KEYS) {
if (key in node) {
const hit = firstLabelableDescendant(node[key]);
if (hit) return hit;
}
}
return undefined;
}

/**
* What each `<label>` in the file names: the literal `for` it points at, and the one control it
* encapsulates. HTML-AAM gives `button` and `input type="image"` this step ahead of their own
* subtree, so a label that contributes a name — or content this analysis cannot read — takes the
* element out of `a11y/accessible-name`'s reach. A label that is itself provably empty does not:
* the control stays unnamed and is still reported.
*/
function collectLabelTargets(node: Node, acc: { ids: Set<string>; nodes: Set<Node> }): void {
if (Array.isArray(node)) {
for (const child of node) collectLabelTargets(child, acc);
return;
}
if (!node || typeof node !== 'object') return;
if (node.type === 'RegularElement' && node.name === 'label' && Array.isArray(node.attributes)) {
const wrapped = firstLabelableDescendant(node);
const scan = scanAccessibleNameSubtree(node, wrapped);
if (scan.named || scan.unknowable) {
const forId = attrText(node.attributes, 'for');
if (forId !== undefined && forId.trim().length > 0) acc.ids.add(forId.trim());
if (wrapped) acc.nodes.add(wrapped);
}
}
for (const key of CHILD_NODE_KEYS) {
if (key in node) collectLabelTargets(node[key], acc);
}
}

/**
* `button`/`a href`/`input type="image"` elements with no computable accessible name
* (a11y/accessible-name). A spread attribute on the element itself makes its rendered
* attributes unknowable, so the element is skipped entirely rather than risk a false positive.
*
* `labels` carries the two `<label>` name routes, which `<a>` does not have.
*/
function collectUnnamedInteractive(node: Node, source: string, acc: UnnamedInteractiveFact[]): void {
function collectUnnamedInteractive(
node: Node,
source: string,
acc: UnnamedInteractiveFact[],
labels: { ids: Set<string>; nodes: Set<Node> }
): void {
if (Array.isArray(node)) {
for (const child of node) collectUnnamedInteractive(child, source, acc);
for (const child of node) collectUnnamedInteractive(child, source, acc, labels);
return;
}
if (!node || typeof node !== 'object') return;
if (node.type === 'RegularElement' && Array.isArray(node.attributes)) {
const target = accessibleNameTarget(node);
const hasSpread = node.attributes.some((a: Node) => a?.type === 'SpreadAttribute');
if (target && !hasSpread) {
const id = attrText(node.attributes, 'id');
const namedByLabel = target !== 'a' && (labels.nodes.has(node) || (id !== undefined && labels.ids.has(id.trim())));
if (target && !hasSpread && !namedByLabel) {
if (target === 'input') {
const alt = attrText(node.attributes, 'alt');
if (!hasNamingAttr(node.attributes) && !(alt !== undefined && alt.trim().length > 0)) {
if (!hasNamingAttr(node.attributes) && !hasNamingValue(node.attributes, 'alt')) {
acc.push({ tag: node.name, line: lineOf(source, node.start) });
}
} else {
Expand All @@ -1287,7 +1359,7 @@ function collectUnnamedInteractive(node: Node, source: string, acc: UnnamedInter
}
}
for (const key of CHILD_NODE_KEYS) {
if (key in node) collectUnnamedInteractive(node[key], source, acc);
if (key in node) collectUnnamedInteractive(node[key], source, acc, labels);
}
}

Expand Down Expand Up @@ -1322,7 +1394,10 @@ function scanLabelSubtree(node: Node): { hasControl: boolean; unknowable: boolea
node.type === 'ExpressionTag' ||
node.type === 'RenderTag' ||
node.type === 'HtmlTag' ||
COMPONENT_LIKE_TYPES.has(node.type)
node.type === 'SlotElement' ||
node.type === 'SvelteFragment' ||
COMPONENT_LIKE_TYPES.has(node.type) ||
isCustomElement(node)
) {
return { hasControl: false, unknowable: true };
}
Expand Down Expand Up @@ -2526,7 +2601,9 @@ export function parseComponentFacts(source: string, filename: string): ParsedFac
const interactiveNestings: InteractiveNestingFact[] = [];
collectInteractiveNestings(ast.fragment ?? ast, source, interactiveNestings, []);
const unnamedInteractive: UnnamedInteractiveFact[] = [];
collectUnnamedInteractive(ast.fragment ?? ast, source, unnamedInteractive);
const labelTargets = { ids: new Set<string>(), nodes: new Set<Node>() };
collectLabelTargets(ast.fragment ?? ast, labelTargets);
collectUnnamedInteractive(ast.fragment ?? ast, source, unnamedInteractive, labelTargets);
const unassociatedLabels: { line: number }[] = [];
collectUnassociatedLabels(ast.fragment ?? ast, source, unassociatedLabels);
const bulletTexts: { line: number; char: string }[] = [];
Expand Down
Loading