diff --git a/.changeset/quick-months-do.md b/.changeset/quick-months-do.md new file mode 100644 index 0000000000..214a2fb5a3 --- /dev/null +++ b/.changeset/quick-months-do.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-dom": minor +--- + +**Added:** An `Attribute.Autocomplete` namespace is now available, grouping functionalities around the `autocomplete` attribute. diff --git a/docs/review/api/alfa-dom.api.md b/docs/review/api/alfa-dom.api.md index 2c1a5a08ab..da4364faa1 100644 --- a/docs/review/api/alfa-dom.api.md +++ b/docs/review/api/alfa-dom.api.md @@ -93,6 +93,7 @@ export namespace Attribute { // // (undocumented) hasName: typeof predicate_3.hasName; + import Autocomplete = autocomplete.Autocomplete; } // @public (undocumented) diff --git a/docs/review/api/alfa-rules.api.md b/docs/review/api/alfa-rules.api.md index 7f2e40ac3a..9a96be3fcd 100644 --- a/docs/review/api/alfa-rules.api.md +++ b/docs/review/api/alfa-rules.api.md @@ -6,7 +6,7 @@ import * as act from '@siteimprove/alfa-act'; import { Array as Array_2 } from '@siteimprove/alfa-array'; -import type { Attribute } from '@siteimprove/alfa-dom'; +import { Attribute } from '@siteimprove/alfa-dom'; import type { Document } from '@siteimprove/alfa-dom'; import * as earl from '@siteimprove/alfa-earl'; import { Element } from '@siteimprove/alfa-dom'; diff --git a/packages/alfa-dom/package.json b/packages/alfa-dom/package.json index 8a0cd356ac..27de8a3fba 100644 --- a/packages/alfa-dom/package.json +++ b/packages/alfa-dom/package.json @@ -49,12 +49,15 @@ "@siteimprove/alfa-lazy": "workspace:^0.95.0", "@siteimprove/alfa-map": "workspace:^0.95.0", "@siteimprove/alfa-option": "workspace:^0.95.0", + "@siteimprove/alfa-parser": "workspace:^0.95.0", "@siteimprove/alfa-predicate": "workspace:^0.95.0", "@siteimprove/alfa-rectangle": "workspace:^0.95.0", "@siteimprove/alfa-refinement": "workspace:^0.95.0", + "@siteimprove/alfa-result": "workspace:^0.95.0", "@siteimprove/alfa-sarif": "workspace:^0.95.0", "@siteimprove/alfa-selective": "workspace:^0.95.0", "@siteimprove/alfa-sequence": "workspace:^0.95.0", + "@siteimprove/alfa-slice": "workspace:^0.95.0", "@siteimprove/alfa-string": "workspace:^0.95.0", "@siteimprove/alfa-trampoline": "workspace:^0.95.0", "@siteimprove/alfa-tree": "workspace:^0.95.0" diff --git a/packages/alfa-dom/src/node/attribute.ts b/packages/alfa-dom/src/node/attribute.ts index 0248bf0e88..d747d6a78e 100644 --- a/packages/alfa-dom/src/node/attribute.ts +++ b/packages/alfa-dom/src/node/attribute.ts @@ -11,6 +11,7 @@ import { Node } from "../node.js"; import type { Element } from "./element.js"; import * as predicate from "./attribute/predicate.js"; +import * as autocomplete from "./attribute/autocomplete.js"; const { isEmpty } = Iterable; const { equals, not } = Predicate; @@ -282,4 +283,6 @@ export namespace Attribute { } export const { hasName } = predicate; + + export import Autocomplete = autocomplete.Autocomplete; } diff --git a/packages/alfa-dom/src/node/attribute/autocomplete.ts b/packages/alfa-dom/src/node/attribute/autocomplete.ts new file mode 100644 index 0000000000..3385b3800b --- /dev/null +++ b/packages/alfa-dom/src/node/attribute/autocomplete.ts @@ -0,0 +1,141 @@ +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Slice } from "@siteimprove/alfa-slice"; +import { Array } from "@siteimprove/alfa-array"; +import { Err, Ok } from "@siteimprove/alfa-result"; + +const { either, end, option, right, parseIf } = Parser; + +/** + * @public + */ +export namespace Autocomplete { + + /** + * Autofill detail tokens from steps 2-4 of the list in {@link https://html.spec.whatwg.org/multipage/#autofill-detail-tokens}. + */ + export namespace AutofillDetailTokens { + export const unmodifiables = [ + "name", + "honorific-prefix", + "given-name", + "additional-name", + "family-name", + "honorific-suffix", + "nickname", + "username", + "new-password", + "current-password", + "one-time-code", + "organization-title", + "organization", + "street-address", + "address-line1", + "address-line2", + "address-line3", + "address-level4", + "address-level3", + "address-level2", + "address-level1", + "country", + "country-name", + "postal-code", + "cc-name", + "cc-given-name", + "cc-additional-name", + "cc-family-name", + "cc-number", + "cc-exp", + "cc-exp-month", + "cc-exp-year", + "cc-csc", + "cc-type", + "transaction-currency", + "transaction-amount", + "language", + "bday", + "bday-day", + "bday-month", + "bday-year", + "sex", + "url", + "photo", + ]; + + export const modifiables = [ + "tel", + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-local-prefix", + "tel-local-suffix", + "tel-extension", + "email", + "impp", + ]; + + export const modifiers = ["home", "work", "mobile", "fax", "pager"]; + export const addressTypes = ["shipping", "billing"]; + export const webauthn = "webauthn"; + } + + /** + * Checks if the provided string contains a valid autocomplete string according to {@link https://html.spec.whatwg.org/multipage/#autofill-detail-tokens} + */ + export const isValid: Predicate = (autocomplete) => { + // The following line comments each refers to the corresponding position in the HTML specification linked above at the time of writing + const parse = right( + option(section), // 1. + right( + option(addressType), // 2. + right( + // 3. + either( + unmodifiable, // 3.a + right(option(modifier) /*3.b.1*/, modifiable /*3.b.2*/), + ), + right( + option(webauthn), // 4. + end((token) => `Expected EOF, but got ${token}`), + ), + ), + ), + ); + + return parse(Slice.of(tokenize(autocomplete))).isOk(); + } + + export function tokenize(autocomplete: string): Array { + return Array.from(autocomplete.toLowerCase().trim().split(/\s+/)); + } + + const parseFirst: Parser, string, string> = ( + input: Slice, + ) => + input + .first() + .map((token) => Ok.of<[Slice, string]>([input.rest(), token])) + .getOr(Err.of("No token left")); + + function parserOf( + tokens: Array, + ): Parser, string, string> { + return parseIf( + (token): token is string => tokens.includes(token), + parseFirst, + (token) => `Expected valid token, but got ${token}`, + ); + } + + const addressType = parserOf(AutofillDetailTokens.addressTypes); + const unmodifiable = parserOf(AutofillDetailTokens.unmodifiables); + const section: Parser, string, string> = parseIf( + (token): token is string => token.startsWith("section-"), + parseFirst, + (token) => `Expected token beginning with \`section-\`, but got ${token}`, + ); + const modifiable = parserOf(AutofillDetailTokens.modifiables); + const modifier = parserOf(AutofillDetailTokens.modifiers); + const webauthn = parserOf([AutofillDetailTokens.webauthn]); +} diff --git a/packages/alfa-dom/src/tsconfig.json b/packages/alfa-dom/src/tsconfig.json index 2cae892571..47e86d805c 100644 --- a/packages/alfa-dom/src/tsconfig.json +++ b/packages/alfa-dom/src/tsconfig.json @@ -13,6 +13,7 @@ "./node/attribute.ts", "./node/attribute/predicate.ts", "./node/attribute/predicate/has-name.ts", + "./node/attribute/autocomplete.ts", "./node/comment.ts", "./node/document.ts", "./node/element.ts", @@ -88,12 +89,15 @@ { "path": "../../alfa-lazy" }, { "path": "../../alfa-map" }, { "path": "../../alfa-option" }, + { "path": "../../alfa-parser" }, { "path": "../../alfa-predicate" }, { "path": "../../alfa-rectangle" }, { "path": "../../alfa-refinement" }, + { "path": "../../alfa-result" }, { "path": "../../alfa-sarif" }, { "path": "../../alfa-selective" }, { "path": "../../alfa-sequence" }, + { "path": "../../alfa-slice" }, { "path": "../../alfa-string" }, { "path": "../../alfa-trampoline" }, { "path": "../../alfa-tree" } diff --git a/packages/alfa-rules/package.json b/packages/alfa-rules/package.json index 513695c52d..14b9eb0c27 100644 --- a/packages/alfa-rules/package.json +++ b/packages/alfa-rules/package.json @@ -55,7 +55,6 @@ "@siteimprove/alfa-map": "workspace:^0.95.0", "@siteimprove/alfa-math": "workspace:^0.95.0", "@siteimprove/alfa-option": "workspace:^0.95.0", - "@siteimprove/alfa-parser": "workspace:^0.95.0", "@siteimprove/alfa-predicate": "workspace:^0.95.0", "@siteimprove/alfa-record": "workspace:^0.95.0", "@siteimprove/alfa-rectangle": "workspace:^0.95.0", @@ -65,7 +64,6 @@ "@siteimprove/alfa-selector": "workspace:^0.95.0", "@siteimprove/alfa-sequence": "workspace:^0.95.0", "@siteimprove/alfa-set": "workspace:^0.95.0", - "@siteimprove/alfa-slice": "workspace:^0.95.0", "@siteimprove/alfa-string": "workspace:^0.95.0", "@siteimprove/alfa-style": "workspace:^0.95.0", "@siteimprove/alfa-table": "workspace:^0.95.0", diff --git a/packages/alfa-rules/src/sia-r10/rule.ts b/packages/alfa-rules/src/sia-r10/rule.ts index faaf2b5e93..c5fc2f6499 100644 --- a/packages/alfa-rules/src/sia-r10/rule.ts +++ b/packages/alfa-rules/src/sia-r10/rule.ts @@ -1,12 +1,8 @@ import { Diagnostic, Rule } from "@siteimprove/alfa-act"; import { DOM, Node } from "@siteimprove/alfa-aria"; -import { Array } from "@siteimprove/alfa-array"; -import type { Attribute} from "@siteimprove/alfa-dom"; -import { Element, Namespace, Query } from "@siteimprove/alfa-dom"; -import { Parser } from "@siteimprove/alfa-parser"; +import { Element, Namespace, Query, Attribute } from "@siteimprove/alfa-dom"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Err, Ok } from "@siteimprove/alfa-result"; -import { Slice } from "@siteimprove/alfa-slice"; import { String } from "@siteimprove/alfa-string"; import { Style } from "@siteimprove/alfa-style"; import { Criterion } from "@siteimprove/alfa-wcag"; @@ -22,7 +18,6 @@ const { hasRole, isPerceivableForAll } = DOM; const { hasAttribute, hasInputType, hasName, hasNamespace } = Element; const { and, or, not } = Predicate; const { isTabbable } = Style; -const { either, end, option, right, parseIf } = Parser; const { getElementDescendants } = Query; export default Rule.Atomic.of({ @@ -65,7 +60,7 @@ export default Rule.Atomic.of({ expectations(target) { return { 1: expectation( - isValidAutocomplete(target), + Attribute.Autocomplete.isValid(target.value), () => Outcomes.HasValidValue, () => Outcomes.HasNoValidValue, ), @@ -76,128 +71,9 @@ export default Rule.Atomic.of({ }); function hasTokens(input: string): boolean { - return input.trim() !== "" && input.split(/\s+/).length > 0; + return input.trim() !== "" && Attribute.Autocomplete.tokenize(input).length > 0; } -/** - * {@link https://html.spec.whatwg.org/multipage/#autofill-detail-tokens} - */ -const isValidAutocomplete: Predicate = (autocomplete) => { - const tokens = autocomplete.value.toLowerCase().trim().split(/\s+/); - - // The following line comments each refers to the corresponding position in the HTML specification linked above at the time of writing - const parse = right( - option(section), // 1. - right( - option(addressType), // 2. - right( - // 3. - either( - unmodifiable, // 3.a - right(option(modifier) /*3.b.1*/, modifiable /*3.b.2*/), - ), - right( - option(webauthn), // 4. - end((token) => `Expected EOF, but got ${token}`), - ), - ), - ), - ); - - return parse(Slice.of(tokens)).isOk(); -}; - -const unmodifiables = Array.from([ - "name", - "honorific-prefix", - "given-name", - "additional-name", - "family-name", - "honorific-suffix", - "nickname", - "username", - "new-password", - "current-password", - "one-time-code", - "organization-title", - "organization", - "street-address", - "address-line1", - "address-line2", - "address-line3", - "address-level4", - "address-level3", - "address-level2", - "address-level1", - "country", - "country-name", - "postal-code", - "cc-name", - "cc-given-name", - "cc-additional-name", - "cc-family-name", - "cc-number", - "cc-exp", - "cc-exp-month", - "cc-exp-year", - "cc-csc", - "cc-type", - "transaction-currency", - "transaction-amount", - "language", - "bday", - "bday-day", - "bday-month", - "bday-year", - "sex", - "url", - "photo", -]); - -const modifiables = Array.from([ - "tel", - "tel-country-code", - "tel-national", - "tel-area-code", - "tel-local", - "tel-local-prefix", - "tel-local-suffix", - "tel-extension", - "email", - "impp", -]); - -const modifiers = Array.from(["home", "work", "mobile", "fax", "pager"]); - -const parseFirst: Parser, string, string> = ( - input: Slice, -) => - input - .first() - .map((token) => Ok.of<[Slice, string]>([input.rest(), token])) - .getOr(Err.of("No token left")); - -function parserOf( - tokens: Array, -): Parser, string, string> { - return parseIf( - (token): token is string => tokens.includes(token), - parseFirst, - (token) => `Expected valid token, but got ${token}`, - ); -} - -const addressType = parserOf(["shipping", "billing"]); -const unmodifiable = parserOf(unmodifiables); -const section: Parser, string, string> = parseIf( - (token): token is string => token.startsWith("section-"), - parseFirst, - (token) => `Expected token beginning with \`section-\`, but got ${token}`, -); -const modifiable = parserOf(modifiables); -const modifier = parserOf(modifiers); -const webauthn = parserOf(["webauthn"]); - /** * @public */ diff --git a/packages/alfa-rules/src/tsconfig.json b/packages/alfa-rules/src/tsconfig.json index 801c880c39..c646743535 100644 --- a/packages/alfa-rules/src/tsconfig.json +++ b/packages/alfa-rules/src/tsconfig.json @@ -190,7 +190,6 @@ { "path": "../../alfa-map" }, { "path": "../../alfa-math" }, { "path": "../../alfa-option" }, - { "path": "../../alfa-parser" }, { "path": "../../alfa-predicate" }, { "path": "../../alfa-record" }, { "path": "../../alfa-rectangle" }, @@ -200,7 +199,6 @@ { "path": "../../alfa-selector" }, { "path": "../../alfa-sequence" }, { "path": "../../alfa-set" }, - { "path": "../../alfa-slice" }, { "path": "../../alfa-string" }, { "path": "../../alfa-style" }, { "path": "../../alfa-table" }, diff --git a/yarn.lock b/yarn.lock index 5abbdcb995..a3e7dfaa99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1247,12 +1247,15 @@ __metadata: "@siteimprove/alfa-lazy": "workspace:^0.95.0" "@siteimprove/alfa-map": "workspace:^0.95.0" "@siteimprove/alfa-option": "workspace:^0.95.0" + "@siteimprove/alfa-parser": "workspace:^0.95.0" "@siteimprove/alfa-predicate": "workspace:^0.95.0" "@siteimprove/alfa-rectangle": "workspace:^0.95.0" "@siteimprove/alfa-refinement": "workspace:^0.95.0" + "@siteimprove/alfa-result": "workspace:^0.95.0" "@siteimprove/alfa-sarif": "workspace:^0.95.0" "@siteimprove/alfa-selective": "workspace:^0.95.0" "@siteimprove/alfa-sequence": "workspace:^0.95.0" + "@siteimprove/alfa-slice": "workspace:^0.95.0" "@siteimprove/alfa-string": "workspace:^0.95.0" "@siteimprove/alfa-test": "workspace:^0.95.0" "@siteimprove/alfa-trampoline": "workspace:^0.95.0" @@ -1736,7 +1739,6 @@ __metadata: "@siteimprove/alfa-map": "workspace:^0.95.0" "@siteimprove/alfa-math": "workspace:^0.95.0" "@siteimprove/alfa-option": "workspace:^0.95.0" - "@siteimprove/alfa-parser": "workspace:^0.95.0" "@siteimprove/alfa-predicate": "workspace:^0.95.0" "@siteimprove/alfa-record": "workspace:^0.95.0" "@siteimprove/alfa-rectangle": "workspace:^0.95.0" @@ -1746,7 +1748,6 @@ __metadata: "@siteimprove/alfa-selector": "workspace:^0.95.0" "@siteimprove/alfa-sequence": "workspace:^0.95.0" "@siteimprove/alfa-set": "workspace:^0.95.0" - "@siteimprove/alfa-slice": "workspace:^0.95.0" "@siteimprove/alfa-string": "workspace:^0.95.0" "@siteimprove/alfa-style": "workspace:^0.95.0" "@siteimprove/alfa-table": "workspace:^0.95.0"