Skip to content
Open
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
29 changes: 29 additions & 0 deletions .changeset/brave-lists-gather.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@siteimprove/alfa-rules": minor
---

**Added:** A new experimental rule SIA-R121 is available. It checks that `<ul>`, `<ol>` and `<dl>` elements only contain the children allowed by the HTML content model.

The rule follows the content model as the HTML specification states it, rather than a relaxed reading of it:

- A `<dl>` either wraps every name-value group in a `<div>` or wraps none of them. The two forms cannot be mixed in one list.
- A `<div>` inside a `<dl>` holds exactly one group. Its content model is one or more `<dt>` elements followed by one or more `<dd>` elements, so packing a second group into the same wrapper is reported and each group needs its own.
- Every group must be well formed, not only the first, so a trailing `<dt>` with no `<dd>` of its own is reported.
- Children are matched by element name rather than by role, so a `<div role="listitem">` does not satisfy a content model asking for an `<li>`. See the [`listitem` role best practices](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/listitem_role#best_practices). Pasting the following into <https://validator.w3.org/nu> reports `Element "div" not allowed as child of element "ul" in this context`:

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>List content model check</title>
</head>
<body>
<ul>
<div role="listitem">List item 1</div>
<div role="listitem">List item 2</div>
</ul>
</body>
</html>
```

11 changes: 11 additions & 0 deletions .changeset/lucky-keys-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@siteimprove/alfa-rules": minor
---

**Added:** A new experimental rule SIA-R122 is available. It checks that the access keys an element declares are usable.

It makes three checks. No other element may declare the same key, since two elements competing for one key means at most one of them can be reached and which one wins is left to the user agent. Each key must be a single character, since anything longer is not a key a user can press. And an element may not declare the same key twice.

Case is folded throughout, so `A` and `a` are the same key. Two elements declaring them compete, and `accesskey="a A"` on one element declares that key twice and is reported for the repeat. That is stricter than the W3C checker, which compares the tokens of a single value literally and accepts it.

The attribute holds a set of space-separated tokens, so `accesskey="a b"` declares two keys and each is checked separately.
30 changes: 19 additions & 11 deletions docs/review/api/alfa-rules.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,26 @@ const _default: Rule.Atomic<Page, Element_2<string>, {}, Element_2<string>>;
// @public
const _default_10: Rule.Atomic<Page, Element_2<string>, Question.Metadata, Element_2<string>>;

// @public @deprecated (undocumented)
// @public
const _default_11: Rule.Atomic<Page, Element_2<string>, {}, Element_2<string>>;

// @public @deprecated (undocumented)
// @public
const _default_12: Rule.Atomic<Page, Element_2<string>, {}, Element_2<string>>;

// @public @deprecated (undocumented)
const _default_13: Rule.Atomic<Page, Element_2<string>, Question.Metadata, Element_2<string>>;
const _default_13: Rule.Atomic<Page, Element_2<string>, {}, Element_2<string>>;

// @public @deprecated (undocumented)
const _default_14: Rule.Atomic<Page, Element_2<string>, {}, Element_2<string>>;

// @public @deprecated (undocumented)
const _default_15: Rule.Atomic<Page, Element_2<string>, Question.Metadata, Element_2<string>>;

// @public @deprecated (undocumented)
const _default_14: Rule.Atomic<Page, Element_2<string>, Question.Metadata, Element_2<string>>;
const _default_16: Rule.Atomic<Page, Element_2<string>, Question.Metadata, Element_2<string>>;

// @public @deprecated (undocumented)
const _default_15: Rule.Atomic<Page, Text_2, {}, Text_2>;
const _default_17: Rule.Atomic<Page, Text_2, {}, Text_2>;

// @public
const _default_2: Rule.Atomic<Page, Document_2, Question.Metadata, Element_2<string>>;
Expand Down Expand Up @@ -112,11 +118,11 @@ const _default_9: Rule.Atomic<Page, Element_2<string>, Question.Metadata, Elemen

declare namespace deprecatedRules {
export {
_default_11 as DR3,
_default_12 as DR6,
_default_13 as DR34,
_default_14 as DR36,
_default_15 as DR83
_default_13 as DR3,
_default_14 as DR6,
_default_15 as DR34,
_default_16 as DR36,
_default_17 as DR83
}
}
export { deprecatedRules }
Expand Down Expand Up @@ -156,7 +162,9 @@ declare namespace experimentalRules {
_default_7 as R114,
_default_8 as R115,
_default_9 as R117,
_default_10 as R118
_default_10 as R118,
_default_11 as R121,
_default_12 as R122
}
}
export { experimentalRules }
Expand Down
17 changes: 16 additions & 1 deletion packages/alfa-rules/src/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,20 @@ import R114 from "./sia-r114/rule.ts";
import R115 from "./sia-r115/rule.ts";
import R117 from "./sia-r117/rule.ts";
import R118 from "./sia-r118/rule.ts";
import R121 from "./sia-r121/rule.ts";
import R122 from "./sia-r122/rule.ts";

export { ER8, ER87, R82, R98, R101, R109, R114, R115, R117, R118 };
export {
ER8,
ER87,
R82,
R98,
R101,
R109,
R114,
R115,
R117,
R118,
R121,
R122,
};
230 changes: 230 additions & 0 deletions packages/alfa-rules/src/sia-r121/rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { Diagnostic, Rule } from "@siteimprove/alfa-act";
import {
Element,
Namespace,
Node,
Query,
Shadow,
Text,
} from "@siteimprove/alfa-dom";
import { EAA } from "@siteimprove/alfa-eaa";
import { Refinement } from "@siteimprove/alfa-refinement";
import type { Result } from "@siteimprove/alfa-result";
import { Err, Ok } from "@siteimprove/alfa-result";
import type { Sequence } from "@siteimprove/alfa-sequence";
import { String } from "@siteimprove/alfa-string";
import { Style } from "@siteimprove/alfa-style";
import { Criterion, Technique } from "@siteimprove/alfa-wcag";
import type { Page } from "@siteimprove/alfa-web";

import { expectation } from "../common/act/index.ts";
import { WithBadElements } from "../common/diagnostic/with-bad-elements.ts";

import { Scope, Stability } from "../tags/index.ts";

const { hasName, hasNamespace, isElement, isSlot } = Element;
const { and } = Refinement;
const { isRendered } = Style;
const { isText } = Text;
const { getElementDescendants } = Query;

/**
* This rule checks that `<ul>`, `<ol>` and `<dl>` elements only contain the
* children the HTML content model allows.
*
* Four readings of that content model are deliberate, and each one makes the
* rule stricter than a relaxed reading would:
*
* - A `<dl>` wraps every name-value group in a `<div>` or wraps none of them.
* - A `<div>` inside a `<dl>` holds exactly one group, so a second group packed
* into the same wrapper is reported.
* - Every group must be well formed, not only the first, so a trailing `<dt>`
* with no `<dd>` is reported.
* - Children are matched by element name, so a `<div role="listitem">` does not
* satisfy a content model asking for an `<li>`.
*
* {@link https://html.spec.whatwg.org/multipage/grouping-content.html#the-dl-element}
*/
export default Rule.Atomic.of<Page, Element>({
uri: "https://alfa.siteimprove.com/rules/sia-r121",
requirements: [
Criterion.of("1.3.1"),
EAA.of("9.1.3.1"),
Technique.of("H40"),
Technique.of("H48"),
],
tags: [Scope.Component, Stability.Experimental],
evaluate({ device, document }) {
return {
applicability() {
return getElementDescendants(document, Node.fullTree).filter(
and(
hasNamespace(Namespace.HTML),
and(hasName("ul", "ol", "dl"), isRendered(device)),
),
);
},

expectations(target) {
return {
1: expectation(
hasTextContent(target),
() => Outcomes.HasDisallowedText,
() =>
expectation(
hasName("dl")(target),
() => descriptionListContent(target),
() => listContent(target),
),
),
};
},
};
},
});

function hasHtmlName<N extends string>(name: N, ...rest: Array<N>) {
return and(hasNamespace(Namespace.HTML), hasName(name, ...rest));
}

const isScriptSupporting = hasHtmlName("script", "template");

// A <slot> only slots inside a shadow tree. Anywhere else no assignment
// algorithm reaches it, so it is an inert element in a position the content
// model forbids. The flat tree replaces it with nothing, which is why this is
// the one check that has to read the node tree.
function straySlots(element: Element): Sequence<Element> {
return element
.children()
.filter(isElement)
.filter(isSlot)
.reject((slot) => Shadow.isShadow(slot.root()));
}

function elementChildren(element: Element): Sequence<Element> {
return element
.children(Node.fullTree)
.filter(isElement)
.reject(isScriptSupporting);
}

function hasTextContent(element: Element): boolean {
return element
.children(Node.fullTree)
.filter(isText)
.some((text) => !String.isWhitespace(text.data));
}

function listContent(target: Element): Result<Diagnostic> {
const disallowed = elementChildren(target)
.reject(hasHtmlName("li"))
.concat(straySlots(target));

return disallowed.isEmpty()
? Outcomes.HasValidContent
: Outcomes.HasDisallowedElements(disallowed);
}

function descriptionListContent(target: Element): Result<Diagnostic> {
const children = elementChildren(target);
const disallowed = children
.reject(hasHtmlName("div", "dt", "dd"))
.concat(straySlots(target));

if (!disallowed.isEmpty()) {
return Outcomes.HasDisallowedElements(disallowed);
}

const wrappers = children.filter(hasName("div"));
const items = children.filter(hasName("dt", "dd"));

if (!wrappers.isEmpty()) {
if (!items.isEmpty()) {
return Outcomes.HasMixedGroups(items);
}

const malformed = wrappers.reject(isWellFormedGroup);

return malformed.isEmpty()
? Outcomes.HasValidContent
: Outcomes.HasMalformedGroups(malformed);
}

const ungrouped = ungroupedItems(items);

return ungrouped.isEmpty()
? Outcomes.HasValidContent
: Outcomes.HasMalformedGroups(ungrouped);
}

function isWellFormedGroup(wrapper: Element): boolean {
if (hasTextContent(wrapper)) {
return false;
}

const children = elementChildren(wrapper);

if (
!children.reject(hasHtmlName("dt", "dd")).isEmpty() ||
!straySlots(wrapper).isEmpty()
) {
return false;
}

const terms = children.takeWhile(hasName("dt"));
const descriptions = children.skip(terms.size);

return (
!terms.isEmpty() &&
!descriptions.isEmpty() &&
descriptions.every(hasName("dd"))
);
}

function ungroupedItems(items: Sequence<Element>): Sequence<Element> {
const leadingDescriptions: Sequence<Element> = items.takeWhile(hasName("dd"));
const trailingTerms = items.takeLastWhile(hasName("dt"));

return leadingDescriptions.concat(trailingTerms);
}

/**
* @public
*/
export namespace Outcomes {
export const HasValidContent = Ok.of(
Diagnostic.of(
`The element only contains the content allowed by its content model.`,
),
);

export const HasDisallowedText = Err.of(
Diagnostic.of(
`The element contains text that its content model does not allow.`,
),
);

export const HasDisallowedElements = (errors: Iterable<Element>) =>
Err.of(
WithBadElements.of(
`The element contains child elements that its content model does not allow.`,
errors,
),
);

export const HasMixedGroups = (errors: Iterable<Element>) =>
Err.of(
WithBadElements.of(
`The <dl> element mixes name-value groups wrapped in a <div> with groups that are not wrapped.`,
errors,
),
);

export const HasMalformedGroups = (errors: Iterable<Element>) =>
Err.of(
WithBadElements.of(
`The <dl> element contains a name-value group that is not one or more <dt> elements followed by one or more <dd> elements.`,
errors,
),
);
}
Loading
Loading