From 5d9dca2ab5118e208d7afba6373273a4e16b14fa Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:28:16 -0400 Subject: [PATCH] Extract rule: template-table-groups --- README.md | 1 + docs/rules/template-table-groups.md | 73 ++ lib/rules/template-table-groups.js | 231 ++++++ tests/lib/rules/template-table-groups.js | 893 +++++++++++++++++++++++ 4 files changed, 1198 insertions(+) create mode 100644 docs/rules/template-table-groups.md create mode 100644 lib/rules/template-table-groups.js create mode 100644 tests/lib/rules/template-table-groups.js diff --git a/README.md b/README.md index 4afd8f124e..fc82162cce 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-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | | ### Best Practices diff --git a/docs/rules/template-table-groups.md b/docs/rules/template-table-groups.md new file mode 100644 index 0000000000..da5f9053b7 --- /dev/null +++ b/docs/rules/template-table-groups.md @@ -0,0 +1,73 @@ +# ember/template-table-groups + + + +Requires table elements to use table grouping elements. + +Tables should use ``, ``, and `` elements to group related content. This improves accessibility for screen reader users and makes the table structure more semantic. + +## Rule Details + +This rule requires that `` elements use grouping elements (``, ``, ``) instead of having `` elements as direct children. + +## Examples + +Examples of **incorrect** code for this rule: + +```gjs + +``` + +```gjs + +``` + +Examples of **correct** code for this rule: + +```gjs + +``` + +```gjs + +``` + +## Options + +| Name | Type | Default | Description | +| ----------------------------- | ---------- | ------- | ---------------------------------------------- | +| `allowed-table-components` | `string[]` | `[]` | Component names treated as `
` elements. | +| `allowed-caption-components` | `string[]` | `[]` | Component names treated as ``. | +| `allowed-thead-components` | `string[]` | `[]` | Component names treated as ``. | +| `allowed-tbody-components` | `string[]` | `[]` | Component names treated as ``. | +| `allowed-tfoot-components` | `string[]` | `[]` | Component names treated as ``. | + +## References + +- [eslint-plugin-ember template-table-groups](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-table-groups.md) +- [MDN - Table structure](https://developer.mozilla.org/en-US/docs/Learn/HTML/Tables/Advanced) diff --git a/lib/rules/template-table-groups.js b/lib/rules/template-table-groups.js new file mode 100644 index 0000000000..4d24cfead8 --- /dev/null +++ b/lib/rules/template-table-groups.js @@ -0,0 +1,231 @@ +const ALLOWED_TABLE_CHILDREN = ['caption', 'colgroup', 'thead', 'tbody', 'tfoot']; +const CONTROL_FLOW_START_MARK = 0; +const CONTROL_FLOW_END_MARK = 1; + +function dasherize(str) { + return str + .replaceAll('::', '/') + .replaceAll(/([\da-z])([A-Z])/g, '$1-$2') + .toLowerCase(); +} + +function isControlFlowHelper(node) { + if (node.type !== 'GlimmerBlockStatement' && node.type !== 'GlimmerMustacheStatement') { + return false; + } + const name = node.path?.original; + return ['if', 'unless', 'each', 'each-in', 'let', 'with'].includes(name); +} + +function isIfOrUnless(node) { + const name = node.path?.original; + return name === 'if' || name === 'unless'; +} + +function getEffectiveChildren(children) { + return (children || []).flatMap((child) => { + if (isControlFlowHelper(child)) { + if (isIfOrUnless(child) && child.program && child.inverse) { + return [ + CONTROL_FLOW_START_MARK, + ...getEffectiveChildren(child.program?.body || child.children || []), + CONTROL_FLOW_END_MARK, + CONTROL_FLOW_START_MARK, + ...getEffectiveChildren(child.inverse?.body || []), + CONTROL_FLOW_END_MARK, + ]; + } + const body = child.program?.body || child.children || child.body?.body || []; + return getEffectiveChildren(body); + } + return [child]; + }); +} + +function isAllowedTableChild(child, internalTags) { + switch (child.type) { + case 'GlimmerElementNode': { + const idx = ALLOWED_TABLE_CHILDREN.indexOf(child.tag); + if (idx > -1) { + return { allowed: true, indices: [idx] }; + } + // Check @tagName attribute + const tagNameAttr = child.attributes?.find((a) => a.name === '@tagName'); + if (tagNameAttr) { + const val = tagNameAttr.value?.type === 'GlimmerTextNode' ? tagNameAttr.value.chars : null; + const tIdx = ALLOWED_TABLE_CHILDREN.indexOf(val); + return { allowed: tIdx > -1, indices: tIdx > -1 ? [tIdx] : [] }; + } + // Check custom component mapping + const dasherized = dasherize(child.tag); + const possibleIndices = internalTags.get(dasherized) || []; + if (possibleIndices.length > 0) { + return { allowed: true, indices: possibleIndices }; + } + return { allowed: false }; + } + case 'GlimmerBlockStatement': + case 'GlimmerMustacheStatement': { + // Check tagName hash pair + const tagNamePair = child.hash?.pairs?.find((p) => p.key === 'tagName'); + if (tagNamePair) { + const val = tagNamePair.value?.value || tagNamePair.value?.chars; + const idx = ALLOWED_TABLE_CHILDREN.indexOf(val); + return { allowed: idx > -1, indices: idx > -1 ? [idx] : [] }; + } + if (child.path?.original === 'yield') { + return { allowed: true, indices: [] }; + } + const possibleIndices = internalTags.get(child.path?.original) || []; + if (possibleIndices.length > 0) { + return { allowed: true, indices: possibleIndices }; + } + return { allowed: false }; + } + case 'GlimmerCommentStatement': + case 'GlimmerMustacheCommentStatement': { + return { allowed: true, indices: [] }; + } + case 'GlimmerTextNode': { + return { allowed: !/\S/.test(child.chars || ''), indices: [] }; + } + default: { + return { allowed: false }; + } + } +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'require table elements to use table grouping elements', + category: 'Accessibility', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-table-groups.md', + templateMode: 'both', + }, + fixable: null, + schema: [ + { + type: 'object', + properties: { + 'allowed-table-components': { type: 'array', items: { type: 'string' } }, + 'allowed-caption-components': { type: 'array', items: { type: 'string' } }, + 'allowed-colgroup-components': { type: 'array', items: { type: 'string' } }, + 'allowed-thead-components': { type: 'array', items: { type: 'string' } }, + 'allowed-tbody-components': { type: 'array', items: { type: 'string' } }, + 'allowed-tfoot-components': { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + ], + messages: { + missing: 'Tables must have a table group (thead, tbody or tfoot).', + ordering: + 'Tables must have table groups in the correct order (caption, colgroup, thead, tbody then tfoot).', + }, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/table-groups.js', + docs: 'docs/rule/table-groups.md', + tests: 'test/unit/rules/table-groups-test.js', + }, + }, + + create(context) { + const options = context.options[0] || {}; + const outerTags = new Set(options['allowed-table-components'] || []); + const internalTags = new Map(); + + const componentKeys = [ + 'allowed-caption-components', + 'allowed-colgroup-components', + 'allowed-thead-components', + 'allowed-tbody-components', + 'allowed-tfoot-components', + ]; + + for (const [index, key] of componentKeys.entries()) { + if (options[key]) { + for (const comp of options[key]) { + if (!internalTags.has(comp)) { + internalTags.set(comp, []); + } + internalTags.get(comp).push(index); + } + } + } + + function isTableElement(node) { + if (node.tag === 'table') { + return true; + } + if (outerTags.has(dasherize(node.tag))) { + return true; + } + const tagNameAttr = node.attributes?.find((a) => a.name === '@tagName'); + if (tagNameAttr) { + const val = tagNameAttr.value?.type === 'GlimmerTextNode' ? tagNameAttr.value.chars : null; + return val === 'table'; + } + return false; + } + + return { + GlimmerElementNode(node) { + if (!isTableElement(node)) { + return; + } + + // Truly empty table (no content at all between tags) must have table groups + if (!node.children || node.children.length === 0) { + const sourceCode = context.getSourceCode(); + const text = sourceCode.getText(node); + const openEnd = text.indexOf('>') + 1; + const closeStart = text.lastIndexOf('= 0 && closeStart <= openEnd) { + context.report({ node, messageId: 'missing' }); + return; + } + } + + const children = getEffectiveChildren(node.children); + + let currentAllowedMinimumIndices = new Set([0]); + const scopedIndices = []; + + for (const child of children) { + if (child === CONTROL_FLOW_START_MARK) { + scopedIndices.push(currentAllowedMinimumIndices); + currentAllowedMinimumIndices = new Set(currentAllowedMinimumIndices); + continue; + } + if (child === CONTROL_FLOW_END_MARK) { + currentAllowedMinimumIndices = scopedIndices.pop(); + continue; + } + + const { allowed, indices } = isAllowedTableChild(child, internalTags); + if (!allowed) { + context.report({ node, messageId: 'missing' }); + return; + } + + if (indices.length > 0) { + const newAllowedMinimumIndices = new Set( + [...currentAllowedMinimumIndices].flatMap((currentIndex) => + indices.filter((newIndex) => newIndex >= currentIndex) + ) + ); + if (newAllowedMinimumIndices.size === 0) { + context.report({ node, messageId: 'ordering' }); + return; + } + currentAllowedMinimumIndices = newAllowedMinimumIndices; + } + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-table-groups.js b/tests/lib/rules/template-table-groups.js new file mode 100644 index 0000000000..4e3799b4e8 --- /dev/null +++ b/tests/lib/rules/template-table-groups.js @@ -0,0 +1,893 @@ +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/template-table-groups'); +const RuleTester = require('eslint').RuleTester; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-table-groups', rule, { + valid: [ + ``, + ``, + ``, + ``, + + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ``, + ``, + ], + + invalid: [ + { + code: ``, + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: ``, + output: null, + errors: [{ messageId: 'missing' }], + }, + + { + code: ``, + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: ``, + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: ``, + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: ``, + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: ``, + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: ``, + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: ``, + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'missing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'ordering' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'ordering' }], + }, + { + code: ``, + output: null, + errors: [{ messageId: 'ordering' }], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}); + +hbsRuleTester.run('template-table-groups', rule, { + valid: [ + ` +
`. | +| `allowed-colgroup-components` | `string[]` | `[]` | Component names treated as `
+ {{#if showCaption}} + + {{/if}} + + {{#if foo}} + + + + {{else}} + + + + {{/if}} +
Some Name
+ `, + ` + + {{#if foo}} + + + + {{/if}} +
+ `, + ` + + {{#unless foo}} + + + + {{/unless}} +
+ `, + ` + + {{#each foo as |bar|}} + + bar + + {{/each}} +
+ `, + ` + + {{#each-in foo as |bar|}} + + bar + + {{/each-in}} +
+ `, + ` + + {{#let foo as |bar|}} + + bar + + {{/let}} +
+ `, + ` + + {{#with foo as |bar|}} + + bar + + {{/with}} +
+ `, + ` + + {{#each foo as |bar|}} + {{#if bar}} + {{#unless baz}} + + bar + + {{/unless}} + {{/if}} + {{/each}} +
+ `, + '{{some-component tagName="tbody"}}
', + '{{some-component tagName="thead"}}
', + '{{some-component tagName="tfoot"}}
', + '{{#some-component tagName="tbody"}}{{/some-component}}
', + '{{#some-component tagName="thead"}}{{/some-component}}
', + '{{#some-component tagName="tfoot"}}{{/some-component}}
', + '{{component "some-component" tagName="tbody"}}
', + '{{component "some-component" tagName="thead"}}
', + '{{component "some-component" tagName="tfoot"}}
', + '
', + '
', + '
', + '
', + '
', + '
', + ' {{yield}}
', + '
', + '{{! or this }}
', + '
', + '
Foo
', + '
', + '
Header
', + '
Body
', + '
Footer
', + '{{! this is a comment }}', + 'Header', + 'Body', + 'Footer', + '', + '
', + ` + + {{#if someCondition}} + + + {{else}} + + + {{/if}} +
+
+ `, + ` + + {{#unless someCondition}} + + + {{else}} + + + {{/unless}} +
+
+ `, + ` + + + + + + + + + `, + '
', + // allowed-*-components config tests (curly-brace invocation) + { + code: ` + + {{nested/my-caption}} + {{nested/my-colgroup}} + {{nested/my-thead}} + {{nested/my-tbody}} + {{nested/my-tfoot}} +
+ `, + options: [ + { + 'allowed-caption-components': ['nested/my-caption'], + 'allowed-colgroup-components': ['nested/my-colgroup'], + 'allowed-thead-components': ['nested/my-thead'], + 'allowed-tbody-components': ['nested/my-tbody'], + 'allowed-tfoot-components': ['nested/my-tfoot'], + }, + ], + }, + // allowed-*-components config tests (angle-bracket invocation) + { + code: ` + + + + + + +
+ `, + options: [ + { + 'allowed-caption-components': ['nested/my-caption'], + 'allowed-colgroup-components': ['nested/my-colgroup'], + 'allowed-thead-components': ['nested/my-thead'], + 'allowed-tbody-components': ['nested/my-tbody'], + 'allowed-tfoot-components': ['nested/my-tfoot'], + }, + ], + }, + { + code: ` + + + + +
+ `, + options: [ + { + 'allowed-thead-components': ['nested/head-or-foot'], + 'allowed-tbody-components': ['nested/body'], + 'allowed-tfoot-components': ['nested/head-or-foot'], + }, + ], + }, + { + code: ` + + + + + +
+ `, + options: [ + { + 'allowed-caption-components': ['nested/my-caption'], + }, + ], + }, + ], + invalid: [ + { + code: ` + + {{#if showCaption}} + Some Name + {{/if}} + {{#if foo}} + 12 + {{else}} +

text

+ {{/if}} + +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + {{#if showCaption}} +
Some Name
+ {{/if}} + {{#if foo}} + 12 + {{else}} +

text

+ {{/if}} + +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + {{#if foo}} + {{else}} +
+ {{/if}} +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + {{#unless foo}} +
+
+ + {{/unless}} +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + {{#if foo}} +
+
+ + {{/if}} +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + {{#unless foo}} + {{some-component}} + {{/unless}} +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + {{#something foo}} + + {{/something}} +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: '
Foo
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: '
Foo
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: '{{some-component}}
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: '{{#each foo as |bar|}}{{bar}}{{/each}}
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ' whitespace
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: '{{some-component tagName="div"}}
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: '{{some-component otherProp="tbody"}}
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: '
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: '
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: 'some text
', + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: '
', + output: null, + errors: [ + { + message: + 'Tables must have table groups in the correct order (caption, colgroup, thead, tbody then tfoot).', + }, + ], + }, + { + code: '
', + output: null, + errors: [ + { + message: + 'Tables must have table groups in the correct order (caption, colgroup, thead, tbody then tfoot).', + }, + ], + }, + { + code: ` + + + +
+ `, + output: null, + errors: [ + { + message: + 'Tables must have table groups in the correct order (caption, colgroup, thead, tbody then tfoot).', + }, + ], + }, + { + code: ` + + +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + + +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + + + + +
+ `, + output: null, + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + + + +
+ `, + output: null, + options: [ + { + 'allowed-tbody-components': ['nested/my-tbody'], + }, + ], + errors: [ + { + message: + 'Tables must have table groups in the correct order (caption, colgroup, thead, tbody then tfoot).', + }, + ], + }, + // Config: allowed-*-components invalid tests + { + code: ` + + +
+ `, + output: null, + options: [{ 'allowed-caption-components': ['nested/allowed'] }], + errors: [{ message: 'Tables must have a table group (thead, tbody or tfoot).' }], + }, + { + code: ` + + + +
+ `, + output: null, + options: [ + { + 'allowed-thead-components': ['nested/my-thead'], + 'allowed-tfoot-components': ['nested/my-tfoot'], + }, + ], + errors: [ + { + message: + 'Tables must have table groups in the correct order (caption, colgroup, thead, tbody then tfoot).', + }, + ], + }, + { + code: ` + + + + + +
+ `, + output: null, + options: [ + { + 'allowed-thead-components': ['nested/head-or-foot'], + 'allowed-tbody-components': ['nested/body'], + 'allowed-tfoot-components': ['nested/head-or-foot'], + }, + ], + errors: [ + { + message: + 'Tables must have table groups in the correct order (caption, colgroup, thead, tbody then tfoot).', + }, + ], + }, + ], +});