diff --git a/.changeset/brave-lists-gather.md b/.changeset/brave-lists-gather.md
new file mode 100644
index 0000000000..dcdfe1bcf6
--- /dev/null
+++ b/.changeset/brave-lists-gather.md
@@ -0,0 +1,29 @@
+---
+"@siteimprove/alfa-rules": minor
+---
+
+**Added:** A new experimental rule SIA-R121 is available. It checks that `
`, `` and `
` 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 `
` either wraps every name-value group in a `
` or wraps none of them. The two forms cannot be mixed in one list.
+- A `
` inside a `
` holds exactly one group. Its content model is one or more `
` elements followed by one or more `
` 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 `
` with no `
` of its own is reported.
+- Children are matched by element name rather than by role, so a `
` does not satisfy a content model asking for an `
`. 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 reports `Element "div" not allowed as child of element "ul" in this context`:
+
+ ```html
+
+
+
+
+ List content model check
+
+
+
+
List item 1
+
List item 2
+
+
+
+ ```
+
diff --git a/.changeset/lucky-keys-listen.md b/.changeset/lucky-keys-listen.md
new file mode 100644
index 0000000000..74d6f0ccbd
--- /dev/null
+++ b/.changeset/lucky-keys-listen.md
@@ -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.
diff --git a/docs/review/api/alfa-rules.api.md b/docs/review/api/alfa-rules.api.md
index fd61503c0f..f4a989046e 100644
--- a/docs/review/api/alfa-rules.api.md
+++ b/docs/review/api/alfa-rules.api.md
@@ -71,20 +71,26 @@ const _default: Rule.Atomic, {}, Element_2>;
// @public
const _default_10: Rule.Atomic, Question.Metadata, Element_2>;
-// @public @deprecated (undocumented)
+// @public
const _default_11: Rule.Atomic, {}, Element_2>;
-// @public @deprecated (undocumented)
+// @public
const _default_12: Rule.Atomic, {}, Element_2>;
// @public @deprecated (undocumented)
-const _default_13: Rule.Atomic, Question.Metadata, Element_2>;
+const _default_13: Rule.Atomic, {}, Element_2>;
+
+// @public @deprecated (undocumented)
+const _default_14: Rule.Atomic, {}, Element_2>;
+
+// @public @deprecated (undocumented)
+const _default_15: Rule.Atomic, Question.Metadata, Element_2>;
// @public @deprecated (undocumented)
-const _default_14: Rule.Atomic, Question.Metadata, Element_2>;
+const _default_16: Rule.Atomic, Question.Metadata, Element_2>;
// @public @deprecated (undocumented)
-const _default_15: Rule.Atomic;
+const _default_17: Rule.Atomic;
// @public
const _default_2: Rule.Atomic>;
@@ -112,11 +118,11 @@ const _default_9: Rule.Atomic, 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 }
@@ -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 }
diff --git a/packages/alfa-rules/src/experimental.ts b/packages/alfa-rules/src/experimental.ts
index f1b4062fcf..d1e3fd427f 100755
--- a/packages/alfa-rules/src/experimental.ts
+++ b/packages/alfa-rules/src/experimental.ts
@@ -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 };
\ No newline at end of file
+export {
+ ER8,
+ ER87,
+ R82,
+ R98,
+ R101,
+ R109,
+ R114,
+ R115,
+ R117,
+ R118,
+ R121,
+ R122,
+};
diff --git a/packages/alfa-rules/src/sia-r121/rule.ts b/packages/alfa-rules/src/sia-r121/rule.ts
new file mode 100644
index 0000000000..70df51f90c
--- /dev/null
+++ b/packages/alfa-rules/src/sia-r121/rule.ts
@@ -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 `
`, `` and `
` 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 `
` wraps every name-value group in a `
` or wraps none of them.
+ * - A `
` inside a `
` 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 `
`
+ * with no `
` is reported.
+ * - Children are matched by element name, so a `
` does not
+ * satisfy a content model asking for an `
`.
+ *
+ * {@link https://html.spec.whatwg.org/multipage/grouping-content.html#the-dl-element}
+ */
+export default Rule.Atomic.of({
+ 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(name: N, ...rest: Array) {
+ return and(hasNamespace(Namespace.HTML), hasName(name, ...rest));
+}
+
+const isScriptSupporting = hasHtmlName("script", "template");
+
+// A 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 {
+ return element
+ .children()
+ .filter(isElement)
+ .filter(isSlot)
+ .reject((slot) => Shadow.isShadow(slot.root()));
+}
+
+function elementChildren(element: Element): Sequence {
+ 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 {
+ const disallowed = elementChildren(target)
+ .reject(hasHtmlName("li"))
+ .concat(straySlots(target));
+
+ return disallowed.isEmpty()
+ ? Outcomes.HasValidContent
+ : Outcomes.HasDisallowedElements(disallowed);
+}
+
+function descriptionListContent(target: Element): Result {
+ 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): Sequence {
+ const leadingDescriptions: Sequence = 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) =>
+ Err.of(
+ WithBadElements.of(
+ `The element contains child elements that its content model does not allow.`,
+ errors,
+ ),
+ );
+
+ export const HasMixedGroups = (errors: Iterable) =>
+ Err.of(
+ WithBadElements.of(
+ `The
element mixes name-value groups wrapped in a
with groups that are not wrapped.`,
+ errors,
+ ),
+ );
+
+ export const HasMalformedGroups = (errors: Iterable) =>
+ Err.of(
+ WithBadElements.of(
+ `The
element contains a name-value group that is not one or more
elements followed by one or more
elements.`,
+ errors,
+ ),
+ );
+}
diff --git a/packages/alfa-rules/src/sia-r122/rule.ts b/packages/alfa-rules/src/sia-r122/rule.ts
new file mode 100644
index 0000000000..65b10aa09f
--- /dev/null
+++ b/packages/alfa-rules/src/sia-r122/rule.ts
@@ -0,0 +1,178 @@
+import { Diagnostic, Rule } from "@siteimprove/alfa-act";
+import { Element, Namespace, Node, Query } from "@siteimprove/alfa-dom";
+import { Predicate } from "@siteimprove/alfa-predicate";
+import { Err, Ok } from "@siteimprove/alfa-result";
+import { Sequence } from "@siteimprove/alfa-sequence";
+import { Style } from "@siteimprove/alfa-style";
+import type { Page } from "@siteimprove/alfa-web";
+
+import { expectation } from "../common/act/index.ts";
+import { BestPractice } from "../requirements/index.ts";
+
+import { Scope, Stability } from "../tags/index.ts";
+
+const { hasNamespace } = Element;
+const { and } = Predicate;
+const { isRendered } = Style;
+const { getElementDescendants } = Query;
+
+/**
+ * This rule checks that the access keys an element declares are usable: that no
+ * other element declares the same key, that each key is a single character, and
+ * that the element does not declare the same key twice.
+ *
+ * Two elements competing for one key means at most one of them can be reached
+ * by it, and which one wins is left to the user agent.
+ *
+ * Case is folded throughout, since `A` and `a` reach the same physical key, so
+ * `accesskey="a A"` declares one key twice and is reported for the repeat. That
+ * is stricter than the specification, which asks only that the tokens not be
+ * "identical to another token", a literal comparison the W3C checker
+ * implements with a case-sensitive equality.
+ *
+ * Elements that are not rendered are left out, since their access keys cannot
+ * be activated, and a key declared only on such an element competes with
+ * nothing.
+ *
+ * {@link https://html.spec.whatwg.org/multipage/interaction.html#the-accesskey-attribute}
+ */
+export default Rule.Atomic.of({
+ uri: "https://alfa.siteimprove.com/rules/sia-r122",
+ requirements: [BestPractice.of("accesskey-unique")],
+ tags: [Scope.Page, Stability.Experimental],
+ evaluate({ device, document }) {
+ const elements = getElementDescendants(document, Node.fullTree)
+ .filter(and(hasNamespace(Namespace.HTML), declaresAccesskey))
+ .filter(isRendered(device));
+
+ const accessKeyElementPairs = new Map>();
+
+ for (const element of elements) {
+ for (const key of accesskeys(element)) {
+ const elementsForKey = accessKeyElementPairs.get(key) ?? [];
+
+ elementsForKey.push(element);
+ accessKeyElementPairs.set(key, elementsForKey);
+ }
+ }
+
+ return {
+ applicability() {
+ return elements;
+ },
+
+ expectations(target) {
+ const contested = accesskeys(target).filter(
+ (key) => (accessKeyElementPairs.get(key)?.length ?? 0) > 1,
+ );
+ const tooLong = tokens(target).reject((token) => token.length === 1);
+
+ const duplicated = repeatedTokens(target);
+
+ return {
+ 1: expectation(
+ contested.isEmpty(),
+ () => Outcomes.HasUniqueAccesskeys,
+ () => Outcomes.HasNonUniqueAccesskeys(contested),
+ ),
+ 2: expectation(
+ tooLong.isEmpty(),
+ () => Outcomes.HasSingleCharacterAccesskeys,
+ () => Outcomes.HasMultiCharacterAccesskeys(tooLong),
+ ),
+ 3: expectation(
+ duplicated.isEmpty(),
+ () => Outcomes.HasDistinctAccesskeys,
+ () => Outcomes.HasRepeatedAccesskeys(duplicated),
+ ),
+ };
+ },
+ };
+ },
+});
+
+function tokens(element: Element): Sequence {
+ return Sequence.from(element.attribute("accesskey")).flatMap((attribute) =>
+ attribute.tokens(),
+ );
+}
+
+function accesskeys(element: Element): Sequence {
+ return tokens(element)
+ .map((token) => token.toLowerCase())
+ .distinct();
+}
+
+function repeatedTokens(element: Element): Sequence {
+ const seen = new Set();
+ const repeated = new Set();
+
+ for (const token of tokens(element).map((token) => token.toLowerCase())) {
+ if (seen.has(token)) {
+ repeated.add(token);
+ } else {
+ seen.add(token);
+ }
+ }
+
+ return Sequence.from(repeated);
+}
+
+const declaresAccesskey: Predicate = (element) =>
+ !tokens(element).isEmpty();
+
+/**
+ * @public
+ */
+export namespace Outcomes {
+ export const HasUniqueAccesskeys = Ok.of(
+ Diagnostic.of(
+ `No access key of the element is declared by another element.`,
+ ),
+ );
+
+ export const HasNonUniqueAccesskeys = (keys: Iterable) =>
+ Err.of(
+ Diagnostic.of(
+ many(keys)
+ ? `More than one element declares the access keys ${list(keys)}.`
+ : `More than one element declares the access key ${list(keys)}.`,
+ ),
+ );
+
+ export const HasSingleCharacterAccesskeys = Ok.of(
+ Diagnostic.of(`Every access key of the element is a single character.`),
+ );
+
+ export const HasMultiCharacterAccesskeys = (keys: Iterable) =>
+ Err.of(
+ Diagnostic.of(
+ many(keys)
+ ? `The access keys ${list(keys)} are not single characters.`
+ : `The access key ${list(keys)} is not a single character.`,
+ ),
+ );
+
+ export const HasDistinctAccesskeys = Ok.of(
+ Diagnostic.of(`The element declares each of its access keys once.`),
+ );
+
+ export const HasRepeatedAccesskeys = (keys: Iterable) =>
+ Err.of(
+ Diagnostic.of(
+ many(keys)
+ ? `The element declares the access keys ${list(keys)} more than once.`
+ : `The element declares the access key ${list(keys)} more than once.`,
+ ),
+ );
+}
+
+function list(keys: Iterable): string {
+ return Sequence.from(keys)
+ .map((key) => `"${key}"`)
+ .join(", ");
+}
+
+function many(keys: Iterable): boolean {
+ return Sequence.from(keys).size > 1;
+}
diff --git a/packages/alfa-rules/src/tsconfig.json b/packages/alfa-rules/src/tsconfig.json
index ba0cf4a786..4d6f8e10fb 100644
--- a/packages/alfa-rules/src/tsconfig.json
+++ b/packages/alfa-rules/src/tsconfig.json
@@ -165,6 +165,8 @@
"./sia-r116/rule.ts",
"./sia-r117/rule.ts",
"./sia-r118/rule.ts",
+ "./sia-r121/rule.ts",
+ "./sia-r122/rule.ts",
"./tags/index.ts",
"./tags/stability.ts",
"./tags/scope.ts",
diff --git a/packages/alfa-rules/test/sia-r121/rule.spec.tsx b/packages/alfa-rules/test/sia-r121/rule.spec.tsx
new file mode 100644
index 0000000000..e098c91e16
--- /dev/null
+++ b/packages/alfa-rules/test/sia-r121/rule.spec.tsx
@@ -0,0 +1,620 @@
+import { h, Namespace } from "@siteimprove/alfa-dom";
+import { test } from "@siteimprove/alfa-test";
+
+import R121, { Outcomes } from "../../src/sia-r121/rule.ts";
+
+import { evaluate } from "../common/evaluate.ts";
+import { failed, inapplicable, passed } from "../common/outcome.ts";
+
+/*
child`, async (t) => {
+ // Content models are written in terms of HTML elements, so sharing a local
+ // name is not enough. No HTML syntax produces this; it has to be built with
+ // createElementNS or moved out of an inline SVG.
+ const error = h.element("li", [], ["Foo"], [], Namespace.SVG);
+
+ const target =
{error}
;
+
+ const document = h.document([target]);
+
+ t.deepEqual(await evaluate(R121, { document }), [
+ failed(R121, target, {
+ 1: Outcomes.HasDisallowedElements([error]),
+ }),
+ ]);
+});
+
+/*
+ * Children are read from the flat tree, so a slot is replaced by whatever is
+ * assigned to it. The first two cases below are the benefit: a list assembled by
+ * a web component passes, and text slotted into one is still reported as text
+ * outside a list item rather than being blamed on the slot.
+ *
+ * The third case is the one the flat tree cannot see on its own. A slot outside
+ * any shadow tree is never assigned anything, so flattening replaces it with
+ * nothing and the list looks empty. `straySlots` reads the node tree for exactly
+ * that case, which is why it is reported without the first two being affected.
+ */
+
+test(`evaluate() passes a list whose items are slotted in from light DOM`, async (t) => {
+ const target = (
+
,
+ ]);
+
+ t.deepEqual(await evaluate(R121, { document }), [inapplicable(R121)]);
+});
+
+test(`evaluate() is inapplicable to a list that is not rendered`, async (t) => {
+ const document = h.document([
+