diff --git a/README.md b/README.md index 33484c4067..c00551fdda 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | | | [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | | | [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | | +| [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | | | | | [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | | 🔧 | | | [template-require-iframe-title](docs/rules/template-require-iframe-title.md) | require iframe elements to have a title attribute | | | | | [template-require-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | | | | diff --git a/docs/rules/template-no-whitespace-within-word.md b/docs/rules/template-no-whitespace-within-word.md new file mode 100644 index 0000000000..1a8e6c0c41 --- /dev/null +++ b/docs/rules/template-no-whitespace-within-word.md @@ -0,0 +1,63 @@ +# ember/template-no-whitespace-within-word + + + +In practice, the predominant issue raised by inline whitespace styling is that the resultant text "formatting" is entirely visual in nature; the ability to discern the correct manner in which to read the text, and therefore, to correctly comprehend its meaning, is restricted to sighted users. + +Using in-line whitespace word formatting produces results that are explicitly mentioned in [WCAG's list of common sources of web accessibility failures](https://www.w3.org/TR/WCAG20-TECHS/failures.html). Specifically, this common whitespace-within-word-induced web accessibility issue fails to successfully achieve [WCAG Success Criterion 1.3.2: Meaningful Sequence](https://www.w3.org/TR/UNDERSTANDING-WCAG20/content-structure-separation-sequence.html). + +The `template-no-whitespace-within-word` rule operates on the assumption that artificially-spaced English words in rendered text content contain, at a minimum, two word characters fencepost-delimited by three whitespace characters (`space-char-space-char-space`) so it should be avoided. + +## Examples + +This rule **forbids** the following: + +```gjs + +``` + +`W`**` `**`e`**` `**`l`**` `**`c`**` `**`o`**` `**`m`**` `**`e` + +`Wel c o me` + +`Wel`**` `**`c`**` `**`o`**` `**`me` + +```gjs + +``` + +This rule **allows** the following: + +`Welcome` + +`Yes`**` `**`I`**` `**`am` + +`It is possible to get some examples of in-word emph a sis past this rule.` + +`However, I do not want a rule that flags annoying false positives for correctly-used single-character words.` + +```gjs + +``` + +This rule uses the heuristic of letter, whitespace character, letter, whitespace character, letter which makes it a good candidate for most use cases, but not ideal for some languages (such as Japanese). + +## Migration + +Use CSS to add letter-spacing to a word. + +## References + +- [F32: Using white space characters to create multiple columns in plain text content](https://www.w3.org/TR/WCAG20-TECHS/failures.html#F32) +- [WCAG Success Criterion 1.3.2: Meaningful Sequence](https://www.w3.org/TR/UNDERSTANDING-WCAG20/content-structure-separation-sequence.html) +- [C8: Using CSS letter-spacing to control spacing within a word](https://www.w3.org/WAI/WCAG21/Techniques/css/C8) diff --git a/lib/rules/template-no-whitespace-within-word.js b/lib/rules/template-no-whitespace-within-word.js new file mode 100644 index 0000000000..81b28c2fea --- /dev/null +++ b/lib/rules/template-no-whitespace-within-word.js @@ -0,0 +1,117 @@ +const WHITESPACE_ENTITY_LIST = [ + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + '  ', + '​', + '​', + '​', + '​', + '​', + '​', + '‌', + '‌', + '‍', + '‍', + '‎', + '‎', + '‏', + '‏', + ' ', + ' ', + '  ', + '⁠', + '⁠', + '⁡', + '⁡', + '⁡', + '⁢', + '⁢', + '⁢', + '⁣', + '⁣', + '⁣', +]; + +const CHARACTER_REGEX = '[a-zA-Z]'; + +// Build a regex that catches alternating non-whitespace/whitespace characters, +// for example, 'W e l c o m e'. The pattern requires 5 alternations to avoid +// false positives: (whitespace)(char)(whitespace)(char)(whitespace) +const whitespaceOrEntityRegex = `(?:\\s|${WHITESPACE_ENTITY_LIST.map( + (entity) => `\\${entity}` +).join('|')})+`; +const WHITESPACE_WITHIN_WORD_REGEX = new RegExp( + `${whitespaceOrEntityRegex}${CHARACTER_REGEX}${whitespaceOrEntityRegex}${CHARACTER_REGEX}${whitespaceOrEntityRegex}` +); + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'layout', + docs: { + description: 'disallow excess whitespace within words (e.g. "W e l c o m e")', + category: 'Accessibility', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-whitespace-within-word.md', + templateMode: 'both', + }, + schema: [], + messages: { + excessWhitespace: 'Excess whitespace in layout detected.', + }, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/no-whitespace-within-word.js', + docs: 'docs/rule/no-whitespace-within-word.md', + tests: 'test/unit/rules/no-whitespace-within-word-test.js', + }, + }, + + create(context) { + const sourceCode = context.getSourceCode(); + + return { + GlimmerTextNode(node) { + // Skip text inside attributes + let parent = node.parent; + while (parent) { + if (parent.type === 'GlimmerAttrNode') { + return; + } + // Skip text inside `, +]; + +const invalidHbs = [ + { + code: 'W e l c o m e', + output: null, + errors: [{ message: 'Excess whitespace in layout detected.' }], + }, + { + code: 'W e l c o m e', + output: null, + errors: [{ message: 'Excess whitespace in layout detected.' }], + }, + { + code: 'Wel c o me', + output: null, + errors: [{ message: 'Excess whitespace in layout detected.' }], + }, + { + code: 'Wel c o me', + output: null, + errors: [{ message: 'Excess whitespace in layout detected.' }], + }, + { + code: '
W e l c o m e
', + output: null, + errors: [{ message: 'Excess whitespace in layout detected.' }], + }, + { + code: '
Wel c o me
', + output: null, + errors: [{ message: 'Excess whitespace in layout detected.' }], + }, + { + code: 'A B   C ', + output: null, + errors: [{ message: 'Excess whitespace in layout detected.' }], + }, +]; + +function wrapTemplate(entry) { + if (typeof entry === 'string') { + return ``; + } + + return { + ...entry, + code: ``, + output: entry.output ? `` : entry.output, + }; +} + +const gjsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +gjsRuleTester.run('template-no-whitespace-within-word', rule, { + valid: validHbs.map(wrapTemplate), + invalid: invalidHbs.map(wrapTemplate), +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +hbsRuleTester.run('template-no-whitespace-within-word', rule, { + valid: validHbs, + invalid: invalidHbs, +});