Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 73 additions & 0 deletions docs/rules/template-table-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# ember/template-table-groups

<!-- end auto-generated rule header -->

Requires table elements to use table grouping elements.

Tables should use `<thead>`, `<tbody>`, and `<tfoot>` 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 `<table>` elements use grouping elements (`<thead>`, `<tbody>`, `<tfoot>`) instead of having `<tr>` elements as direct children.

## Examples

Examples of **incorrect** code for this rule:

```gjs
<template>
<table>
<tr><td>Data</td></tr>
</table>
</template>
```

```gjs
<template>
<table>
<tr><th>Header</th></tr>
<tr><td>Data</td></tr>
</table>
</template>
```

Examples of **correct** code for this rule:

```gjs
<template>
<table>
<thead>
<tr><th>Header</th></tr>
</thead>
<tbody>
<tr><td>Data</td></tr>
</tbody>
</table>
</template>
```

```gjs
<template>
<table>
<tbody>
<tr><td>Data</td></tr>
</tbody>
</table>
</template>
```

## Options

| Name | Type | Default | Description |
| ----------------------------- | ---------- | ------- | ---------------------------------------------- |
| `allowed-table-components` | `string[]` | `[]` | Component names treated as `<table>` elements. |
| `allowed-caption-components` | `string[]` | `[]` | Component names treated as `<caption>`. |
| `allowed-colgroup-components` | `string[]` | `[]` | Component names treated as `<colgroup>`. |
| `allowed-thead-components` | `string[]` | `[]` | Component names treated as `<thead>`. |
| `allowed-tbody-components` | `string[]` | `[]` | Component names treated as `<tbody>`. |
| `allowed-tfoot-components` | `string[]` | `[]` | Component names treated as `<tfoot>`. |

## 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)
231 changes: 231 additions & 0 deletions lib/rules/template-table-groups.js
Original file line number Diff line number Diff line change
@@ -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('</');
if (closeStart >= 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;
}
}
},
};
},
};
Loading
Loading