diff --git a/README.md b/README.md
index 98b42f8..765133d 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,8 @@ The package root provides Angular and Ionic rules. Install `@angular-eslint/temp
The recommended preset is designed for ESLint Flat Config. Add it at the top level so its TypeScript and HTML file selectors remain intact.
+For Ionic templates, the preset also requires `ion-item` elements inside `ion-list` to use `ion-item-group`, `ion-reorder-group`, `ion-radio-group`, or `ion-accordion` within `ion-accordion-group`, matching the iOS 26 and Material Design 3 list structure.
+
## Next step
Continue to [Configuration](./docs/configuration.md) to enable the recommended preset or individual rules.
diff --git a/docs/rules.md b/docs/rules.md
index edc0e64..c7e5cca 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -1,4 +1,4 @@
-The package exposes 18 rules. Rules marked “recommended” are enabled by `rdlabo.configs.recommended`; the remaining rules are opt-in.
+The package exposes 19 rules. Rules marked “recommended” are enabled by `rdlabo.configs.recommended`; the remaining rules are opt-in.
| Rule | Purpose | Fix | Preset |
| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | :-: | :----: |
@@ -16,6 +16,7 @@ The package exposes 18 rules. Rules marked “recommended” are enabled by `rdl
| [`prefer-disable-handler`](./rules/prefer-disable-handler.md) | Wrap configured event handlers to prevent duplicate async actions. | No | Yes |
| [`prefer-ionic-standalone`](./rules/prefer-ionic-standalone.md) | Prefer Ionic 9 standalone imports and disallow `IonicModule`. | Yes | Yes |
| [`prefer-modal-launcher`](./rules/prefer-modal-launcher.md) | Restrict `presentModal` calls to `launch*` functions. | No | Yes |
+| [`require-ion-item-group`](./rules/require-ion-item-group.md) | Require grouped Ionic list items for iOS 26 and Material Design 3. | Yes | Yes |
| [`require-viewmodel`](./rules/require-viewmodel.md) | Enforce component ownership and the `ViewModelStore` boundary. | No | Yes |
| [`restrict-try-block`](./rules/restrict-try-block.md) | Keep `try` blocks small and exclude Promise, RxJS, and Signal contexts by policy. | No | Yes |
| [`signal-use-as-signal-template`](./rules/signal-use-as-signal-template.md) | Require `()` when reading Angular Signals in templates. | No | Yes |
diff --git a/docs/rules/require-ion-item-group.md b/docs/rules/require-ion-item-group.md
new file mode 100644
index 0000000..4f359a0
--- /dev/null
+++ b/docs/rules/require-ion-item-group.md
@@ -0,0 +1,83 @@
+# @rdlabo/rules/require-ion-item-group
+
+> Require ion-item elements in ion-list to be wrapped by a supported Ionic item group.
+>
+> - ⭐️ This rule is included in `plugin:@rdlabo/rules/recommended` preset.
+> - ✒️ The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
+
+Ionic's iOS 26 and Material Design 3 list styling expects list items to be organized through the group component that matches their behavior. This rule prevents a bare `ion-item` from being rendered directly under `ion-list`.
+
+## Rule Details
+
+An `ion-item` within `ion-list` must use exactly one of these structures:
+
+- `ion-list > ion-item-group > ion-item`
+- `ion-list > ion-reorder-group > ion-item`
+- `ion-list > ion-accordion-group > ion-accordion > ion-item`
+- `ion-list > ion-radio-group > ion-item`
+
+Angular control-flow blocks such as `@if`, `@for`, `@empty`, `@switch`, and `@defer` are transparent for this structural check because they do not render an element. `ng-container` and `ng-template` are also transparent. Rendered HTML or Angular elements are not transparent: inserting a `div` between the list, group, or item is reported.
+
+The rule only checks `ion-item` elements contained by `ion-list`. An `ion-item` outside a list is not reported, and `.spec.html` files are ignored.
+
+## Examples
+
+### Incorrect
+
+```html
+
+ Direct item
+
+```
+
+
+```html
+
+ @for (item of items; track item.id) {
+ {{ item.name }}
+ }
+
+```
+
+### Correct
+
+
+```html
+
+
+ @for (item of items; track item.id) {
+ {{ item.name }}
+ }
+
+
+```
+
+```html
+
+
+ First choice
+ Second choice
+
+
+```
+
+## Options
+
+This rule has no options.
+
+## Automatic fixes
+
+When a list contains only ungrouped `ion-item` elements, including through transparent Angular control-flow blocks or `ng-container`, the rule can wrap the entire list contents in one `ion-item-group`.
+
+The automatic fix is available when the same template already uses `ion-item-group`, which indicates that the standalone `IonItemGroup` component is available to the template. Otherwise, the rule offers an editor suggestion that also reminds you to add `IonItemGroup` to the component imports if needed.
+
+No fix or suggestion is offered when the list mixes grouped and ungrouped content, contains other rendered content, contains a reusable `ng-template` definition, contains a nested list, has an intervening rendered element, or uses an invalid accordion structure. In these cases, the intended group boundary cannot be determined safely.
+
+## When to enable
+
+Enable this rule in Ionic Angular applications that target the iOS 26 and Material Design 3 list designs. It is included in the recommended preset and has no effect when a template does not contain an `ion-item` within `ion-list`.
+
+## Implementation
+
+- [Rule source](../../src/rules/require-ion-item-group.ts)
+- [Test source](../../tests/rules/require-ion-item-group.ts)
diff --git a/scripts/lib/recommended-rule-names.ts b/scripts/lib/recommended-rule-names.ts
index ac82dd1..19ec554 100644
--- a/scripts/lib/recommended-rule-names.ts
+++ b/scripts/lib/recommended-rule-names.ts
@@ -14,4 +14,5 @@ export const RECOMMENDED_RULE_NAMES = new Set([
'ionic-attr-type-check',
'deny-element',
'prefer-disable-handler',
+ 'require-ion-item-group',
]);
diff --git a/scripts/lib/update-lib-configs-recommended.ts b/scripts/lib/update-lib-configs-recommended.ts
index 2283349..aa4c265 100644
--- a/scripts/lib/update-lib-configs-recommended.ts
+++ b/scripts/lib/update-lib-configs-recommended.ts
@@ -48,6 +48,7 @@ const recommended: Linter.Config[] = [
},
],
'@rdlabo/rules/prefer-disable-handler': 'error',
+ '@rdlabo/rules/require-ion-item-group': 'error',
},
},
];
diff --git a/src/configs/recommended.ts b/src/configs/recommended.ts
index 05407a6..456ea9f 100644
--- a/src/configs/recommended.ts
+++ b/src/configs/recommended.ts
@@ -37,6 +37,7 @@ const recommended: Linter.Config[] = [
},
],
'@rdlabo/rules/prefer-disable-handler': 'error',
+ '@rdlabo/rules/require-ion-item-group': 'error',
},
},
];
diff --git a/src/index.ts b/src/index.ts
index 1b260c5..ef1399f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -15,6 +15,7 @@ import notemplatedrivenforms from './rules/no-template-driven-forms';
import preferdisablehandler from './rules/prefer-disable-handler';
import preferionicstandalone from './rules/prefer-ionic-standalone';
import prefermodallauncher from './rules/prefer-modal-launcher';
+import requireionitemgroup from './rules/require-ion-item-group';
import requireviewmodel from './rules/require-viewmodel';
import restricttryblock from './rules/restrict-try-block';
import signaluseassignaltemplate from './rules/signal-use-as-signal-template';
@@ -39,6 +40,7 @@ export = {
'prefer-disable-handler': preferdisablehandler,
'prefer-ionic-standalone': preferionicstandalone,
'prefer-modal-launcher': prefermodallauncher,
+ 'require-ion-item-group': requireionitemgroup,
'require-viewmodel': requireviewmodel,
'restrict-try-block': restricttryblock,
'signal-use-as-signal-template': signaluseassignaltemplate,
diff --git a/src/rules/deny-element.ts b/src/rules/deny-element.ts
index badaec8..b39dc3b 100644
--- a/src/rules/deny-element.ts
+++ b/src/rules/deny-element.ts
@@ -1,16 +1,6 @@
import { TSESLint } from '@typescript-eslint/utils';
import type { TSESTree } from '@typescript-eslint/utils';
-
-interface TemplateNode {
- name: string;
- type: string;
- loc: {
- start: { line: number; column: number };
- end: { line: number; column: number };
- };
- children: TemplateNodes;
-}
-type TemplateNodes = TemplateNode[];
+import { type TemplateAstNode, walkTemplateNodes } from './template-ast-utils';
interface Scheme {
elements: string[];
}
@@ -46,10 +36,8 @@ const rule: TSESLint.RuleModule<'denyElement', [Scheme]> = {
create: (context) => {
const isHtmlFile = (filename: string) => !filename.includes('.spec') && filename.includes('.html');
- const isElementNode = (node: TemplateNode) => node.type.includes('Element');
-
- const checkElement = (node: TemplateNode, deniedElements: string[]) => {
- if (deniedElements.includes(node.name)) {
+ const checkElement = (node: TemplateAstNode, deniedElements: string[]) => {
+ if (node.name && deniedElements.includes(node.name)) {
context.report({
node: node as unknown as TSESTree.Node,
loc: node.loc,
@@ -61,65 +49,6 @@ const rule: TSESLint.RuleModule<'denyElement', [Scheme]> = {
}
};
- const processNode = (node: TemplateNode, deniedElements: string[]) => {
- checkElement(node, deniedElements);
-
- // 子ノードを再帰的に処理
- if (node.children) {
- node.children.filter(isElementNode).forEach((child) => processNode(child, deniedElements));
- }
- };
-
- // 制御フロー構文を含む汎用的なノード処理
- const processTemplateNodes = (templateNodes: TemplateNode[]) => {
- const traverseTemplateNodes = (nodes: TemplateNode[]) => {
- if (!Array.isArray(nodes)) return;
-
- for (const node of nodes) {
- // Element ノードの場合、属性をチェック
- if (isElementNode(node)) {
- processNode(node, context.options[0]?.elements || []);
- }
-
- // その他のノード(制御フロー構文など)の子ノードを再帰的に処理
- else if (node && typeof node === 'object' && 'type' in node) {
- const nodeWithChildren = node as unknown as {
- children?: TemplateNode[];
- branches?: TemplateNode[];
- then?: { children?: TemplateNode[] };
- else?: { children?: TemplateNode[] };
- [key: string]: unknown;
- };
-
- // 制御フロー構文でよく使われる子ノードプロパティのみを探索
- const childProperties = ['children', 'branches'];
- const nestedChildProperties = ['then', 'else'];
-
- // 直接の子ノードプロパティを処理
- for (const prop of childProperties) {
- const childNodes = nodeWithChildren[prop];
- if (Array.isArray(childNodes)) {
- traverseTemplateNodes(childNodes);
- }
- }
-
- // ネストした子ノードプロパティを処理
- for (const prop of nestedChildProperties) {
- const nestedNode = nodeWithChildren[prop];
- if (nestedNode && typeof nestedNode === 'object' && 'children' in nestedNode) {
- const childObj = nestedNode as { children?: TemplateNode[] };
- if (Array.isArray(childObj.children)) {
- traverseTemplateNodes(childObj.children);
- }
- }
- }
- }
- }
- };
-
- traverseTemplateNodes(templateNodes);
- };
-
return {
Program(node) {
const filename = context.filename;
@@ -130,12 +59,16 @@ const rule: TSESLint.RuleModule<'denyElement', [Scheme]> = {
throw new Error('elements is not defined. Please define elements using array.');
}
- const templateNodes: TemplateNodes = (
+ const templateNodes = (
node as unknown as {
- templateNodes: TemplateNodes;
+ templateNodes: TemplateAstNode[];
}
).templateNodes;
- processTemplateNodes(templateNodes);
+ walkTemplateNodes(templateNodes, (templateNode) => {
+ if (templateNode.type.includes('Element')) {
+ checkElement(templateNode, scheme.elements);
+ }
+ });
},
};
},
diff --git a/src/rules/no-reactive-forms.ts b/src/rules/no-reactive-forms.ts
index 6ec0902..d94273d 100644
--- a/src/rules/no-reactive-forms.ts
+++ b/src/rules/no-reactive-forms.ts
@@ -1,4 +1,5 @@
import { TSESLint, TSESTree } from '@typescript-eslint/utils';
+import { type TemplateAstNode, walkTemplateNodes } from './template-ast-utils';
const REACTIVE_IMPORTS = new Set([
'AbstractControl',
@@ -28,28 +29,6 @@ function importedName(node: TSESTree.Identifier | TSESTree.StringLiteral): strin
return node.type === 'Identifier' ? node.name : node.value;
}
-interface TemplateNode {
- type: string;
- name?: string;
- loc: TSESTree.SourceLocation;
- children?: TemplateNode[];
- branches?: TemplateNode[];
- cases?: TemplateNode[];
- inputs?: TemplateNode[];
- attributes?: TemplateNode[];
-}
-
-function visitTemplate(nodes: TemplateNode[] | undefined, visit: (node: TemplateNode) => void): void {
- for (const node of nodes ?? []) {
- visit(node);
- visitTemplate(node.inputs, visit);
- visitTemplate(node.attributes, visit);
- visitTemplate(node.children, visit);
- visitTemplate(node.branches, visit);
- visitTemplate(node.cases, visit);
- }
-}
-
const rule: TSESLint.RuleModule = {
defaultOptions: [],
meta: {
@@ -85,21 +64,25 @@ const rule: TSESLint.RuleModule = {
}
},
Program(node) {
- const templateNodes = (node as unknown as { templateNodes?: TemplateNode[] }).templateNodes;
- visitTemplate(templateNodes, (templateNode) => {
- if (
- (templateNode.type === 'BoundAttribute' || templateNode.type === 'TextAttribute') &&
- templateNode.name &&
- REACTIVE_TEMPLATE_BINDINGS.has(templateNode.name)
- ) {
- context.report({
- node: templateNode as unknown as TSESTree.Node,
- loc: templateNode.loc,
- messageId: 'reactiveFormsBinding',
- data: { name: templateNode.name },
- });
- }
- });
+ const templateNodes = (node as unknown as { templateNodes?: TemplateAstNode[] }).templateNodes;
+ walkTemplateNodes(
+ templateNodes,
+ (templateNode) => {
+ if (
+ (templateNode.type === 'BoundAttribute' || templateNode.type === 'TextAttribute') &&
+ templateNode.name &&
+ REACTIVE_TEMPLATE_BINDINGS.has(templateNode.name)
+ ) {
+ context.report({
+ node: templateNode as unknown as TSESTree.Node,
+ loc: templateNode.loc,
+ messageId: 'reactiveFormsBinding',
+ data: { name: templateNode.name },
+ });
+ }
+ },
+ ['inputs', 'attributes'],
+ );
},
};
},
diff --git a/src/rules/no-template-driven-forms.ts b/src/rules/no-template-driven-forms.ts
index dd94898..b39aa99 100644
--- a/src/rules/no-template-driven-forms.ts
+++ b/src/rules/no-template-driven-forms.ts
@@ -1,35 +1,12 @@
import { TSESLint, TSESTree } from '@typescript-eslint/utils';
+import { type TemplateAstNode, walkTemplateNodes } from './template-ast-utils';
interface RuleOptions {
allowedElements?: string[];
}
-interface TemplateNode {
- type: string;
- name?: string;
- loc: TSESTree.SourceLocation;
- children?: TemplateNode[];
- branches?: TemplateNode[];
- cases?: TemplateNode[];
- inputs?: TemplateNode[];
- attributes?: TemplateNode[];
- references?: TemplateNode[];
- value?: string;
-}
-
type MessageIds = 'templateDrivenForms' | 'templateDrivenFormsDirective';
-function visitElements(nodes: TemplateNode[] | undefined, visit: (node: TemplateNode) => void): void {
- for (const node of nodes ?? []) {
- if (node.type === 'Element') {
- visit(node);
- }
- visitElements(node.children, visit);
- visitElements(node.branches, visit);
- visitElements(node.cases, visit);
- }
-}
-
const rule: TSESLint.RuleModule = {
defaultOptions: [{ allowedElements: [] }],
meta: {
@@ -60,8 +37,11 @@ const rule: TSESLint.RuleModule = {
const allowedElements = new Set(context.options[0]?.allowedElements ?? []);
return {
Program(node) {
- const templateNodes = (node as unknown as { templateNodes?: TemplateNode[] }).templateNodes;
- visitElements(templateNodes, (element) => {
+ const templateNodes = (node as unknown as { templateNodes?: TemplateAstNode[] }).templateNodes;
+ walkTemplateNodes(templateNodes, (element) => {
+ if (element.type !== 'Element') {
+ return;
+ }
const attributes = [...(element.inputs ?? []), ...(element.attributes ?? [])];
const hasNgModel = attributes.some((attribute) => attribute.name === 'ngModel');
if (hasNgModel && element.name && !allowedElements.has(element.name)) {
diff --git a/src/rules/prefer-disable-handler.ts b/src/rules/prefer-disable-handler.ts
index e85de9e..4edb09a 100644
--- a/src/rules/prefer-disable-handler.ts
+++ b/src/rules/prefer-disable-handler.ts
@@ -1,19 +1,14 @@
import { TSESLint } from '@typescript-eslint/utils';
import type { TSESTree } from '@typescript-eslint/utils';
+import { type TemplateAstNode as SharedTemplateAstNode, walkTemplateNodes } from './template-ast-utils';
interface TemplateLoc {
start: { line: number; column: number };
end: { line: number; column: number };
}
-interface TemplateAstNode {
- type: string;
- name?: string;
+interface TemplateAstNode extends SharedTemplateAstNode {
loc?: TemplateLoc;
- children?: TemplateAstNode[];
- branches?: TemplateAstNode[];
- then?: { children?: TemplateAstNode[] };
- else?: { children?: TemplateAstNode[] };
outputs?: BoundEventNode[];
handler?: TemplateExpression;
[key: string]: unknown;
@@ -213,31 +208,6 @@ const rule: TSESLint.RuleModule<'preferDisableHandler', [Scheme]> = {
}
};
- const traverseTemplateNodes = (nodes: TemplateAstNode[] | undefined) => {
- if (!Array.isArray(nodes)) return;
-
- for (const node of nodes) {
- if (!node || typeof node !== 'object' || !('type' in node)) continue;
-
- if (String(node.type).includes('Element')) {
- processElement(node);
- }
-
- if (Array.isArray(node.children)) {
- traverseTemplateNodes(node.children);
- }
- if (Array.isArray(node.branches)) {
- traverseTemplateNodes(node.branches);
- }
- if (node.then?.children) {
- traverseTemplateNodes(node.then.children);
- }
- if (node.else?.children) {
- traverseTemplateNodes(node.else.children);
- }
- }
- };
-
return {
Program(node) {
if (!isHtmlFile(context.filename)) return;
@@ -248,7 +218,11 @@ const rule: TSESLint.RuleModule<'preferDisableHandler', [Scheme]> = {
}
).templateNodes;
- traverseTemplateNodes(templateNodes);
+ walkTemplateNodes(templateNodes, (templateNode) => {
+ if (templateNode.type.includes('Element')) {
+ processElement(templateNode as TemplateAstNode);
+ }
+ });
},
};
},
diff --git a/src/rules/require-ion-item-group.ts b/src/rules/require-ion-item-group.ts
new file mode 100644
index 0000000..df5dd0a
--- /dev/null
+++ b/src/rules/require-ion-item-group.ts
@@ -0,0 +1,150 @@
+import { TSESLint } from '@typescript-eslint/utils';
+import type { TSESTree } from '@typescript-eslint/utils';
+import { isRenderedElement, isRenderedText, isTransparentTemplateStructure, type TemplateAstNode, visitTemplateChildren } from './template-ast-utils';
+
+const DIRECT_ITEM_GROUPS = new Set(['ion-item-group', 'ion-reorder-group', 'ion-radio-group']);
+
+type MessageIds = 'requireIonItemGroup' | 'wrapIonItemGroup';
+
+const rule: TSESLint.RuleModule = {
+ defaultOptions: [],
+ meta: {
+ docs: {
+ description: 'Require ion-item elements in ion-list to be wrapped by a supported Ionic item group.',
+ url: '',
+ },
+ messages: {
+ requireIonItemGroup:
+ 'ion-item inside ion-list must be wrapped by ion-item-group, ion-reorder-group, ion-radio-group, or ion-accordion within ion-accordion-group.',
+ wrapIonItemGroup: 'Wrap the list items in ion-item-group. Add IonItemGroup to the component imports if needed.',
+ },
+ schema: [],
+ type: 'problem',
+ fixable: 'code',
+ hasSuggestions: true,
+ },
+ create(context) {
+ const isHtmlFile = (filename: string) => filename.includes('.html') && !filename.includes('.spec');
+
+ const containsElement = (nodes: TemplateAstNode[] | undefined, name: string): boolean => {
+ for (const node of nodes ?? []) {
+ if (isRenderedElement(node) && node.name === name) {
+ return true;
+ }
+ let found = false;
+ visitTemplateChildren(node, (children) => {
+ found ||= containsElement(children, name);
+ });
+ if (found) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ const renderedRoots = (nodes: TemplateAstNode[] | undefined): TemplateAstNode[] => {
+ const roots: TemplateAstNode[] = [];
+ for (const node of nodes ?? []) {
+ const isReusableTemplate = node.type === 'Template' && node.tagName === 'ng-template';
+ if (isRenderedElement(node) || isRenderedText(node) || isReusableTemplate || !isTransparentTemplateStructure(node)) {
+ roots.push(node);
+ } else {
+ visitTemplateChildren(node, (children) => {
+ roots.push(...renderedRoots(children));
+ });
+ }
+ }
+ return roots;
+ };
+
+ const groupableListRange = (list: TemplateAstNode): TSESTree.Range | undefined => {
+ const roots = renderedRoots(list.children);
+ if (
+ roots.length === 0 ||
+ roots.some((node) => !isRenderedElement(node) || node.name !== 'ion-item') ||
+ roots.some((node) => containsElement(node.children, 'ion-item')) ||
+ containsElement(list.children, 'ion-list') ||
+ !list.startSourceSpan ||
+ !list.endSourceSpan
+ ) {
+ return undefined;
+ }
+ return [list.startSourceSpan.end.offset, list.endSourceSpan.start.offset];
+ };
+
+ const wrapListContents = (range: TSESTree.Range): string => `${context.sourceCode.text.slice(...range)}`;
+
+ const nearestListIndex = (ancestors: TemplateAstNode[]): number => {
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
+ if (ancestors[index].name === 'ion-list') {
+ return index;
+ }
+ }
+ return -1;
+ };
+
+ function visit(
+ nodes: TemplateAstNode[] | undefined,
+ ancestors: TemplateAstNode[],
+ templateHasItemGroup: boolean,
+ handledLists: Set,
+ ): void {
+ for (const node of nodes ?? []) {
+ const nextAncestors = isRenderedElement(node) && node.name ? [...ancestors, node] : ancestors;
+
+ if (isRenderedElement(node) && node.name === 'ion-item') {
+ const listIndex = nearestListIndex(ancestors);
+ if (listIndex >= 0) {
+ const nearestList = ancestors[listIndex];
+ const elementsAfterList = ancestors.slice(listIndex + 1).map((ancestor) => ancestor.name);
+ const hasDirectItemGroup = elementsAfterList.length === 1 && DIRECT_ITEM_GROUPS.has(elementsAfterList[0] ?? '');
+ const hasAccordionGroup =
+ elementsAfterList.length === 2 && elementsAfterList[0] === 'ion-accordion-group' && elementsAfterList[1] === 'ion-accordion';
+ const hasRequiredStructure = hasDirectItemGroup || hasAccordionGroup;
+
+ if (!hasRequiredStructure) {
+ const reportNode = node as unknown as TSESTree.Node;
+ const groupRange = elementsAfterList.length === 0 ? groupableListRange(nearestList) : undefined;
+ const canOfferGroup = groupRange && !handledLists.has(nearestList);
+ if (canOfferGroup) {
+ handledLists.add(nearestList);
+ }
+ context.report({
+ node: reportNode,
+ loc: node.loc,
+ messageId: 'requireIonItemGroup',
+ fix: canOfferGroup && templateHasItemGroup ? (fixer) => fixer.replaceTextRange(groupRange, wrapListContents(groupRange)) : undefined,
+ suggest:
+ canOfferGroup && !templateHasItemGroup
+ ? [
+ {
+ messageId: 'wrapIonItemGroup',
+ fix: (fixer) => fixer.replaceTextRange(groupRange, wrapListContents(groupRange)),
+ },
+ ]
+ : undefined,
+ });
+ }
+ }
+ }
+
+ visitTemplateChildren(node, (children) => {
+ visit(children, nextAncestors, templateHasItemGroup, handledLists);
+ });
+ }
+ }
+
+ return {
+ Program(node) {
+ if (!isHtmlFile(context.filename)) {
+ return;
+ }
+
+ const templateNodes = (node as unknown as { templateNodes?: TemplateAstNode[] }).templateNodes;
+ visit(templateNodes, [], containsElement(templateNodes, 'ion-item-group'), new Set());
+ },
+ };
+ },
+};
+
+export = rule;
diff --git a/src/rules/template-ast-utils.ts b/src/rules/template-ast-utils.ts
new file mode 100644
index 0000000..3a06aa2
--- /dev/null
+++ b/src/rules/template-ast-utils.ts
@@ -0,0 +1,98 @@
+import type { TSESTree } from '@typescript-eslint/utils';
+
+export interface TemplateAstNode {
+ type: string;
+ name?: string;
+ tagName?: string;
+ value?: unknown;
+ loc?: TSESTree.SourceLocation;
+ sourceSpan?: {
+ start: { offset?: number; line?: number; col?: number };
+ end: { offset?: number; line?: number; col?: number };
+ };
+ startSourceSpan?: { end: { offset: number } };
+ endSourceSpan?: { start: { offset: number } };
+ children?: TemplateAstNode[];
+ inputs?: TemplateAstNode[];
+ attributes?: TemplateAstNode[];
+ references?: TemplateAstNode[];
+ templateAttrs?: TemplateAstNode[];
+ outputs?: TemplateAstNode[];
+ branches?: TemplateAstNode[];
+ cases?: TemplateAstNode[];
+ groups?: TemplateAstNode[];
+ then?: { children?: TemplateAstNode[] };
+ else?: { children?: TemplateAstNode[] };
+ empty?: { children?: TemplateAstNode[] };
+ placeholder?: { children?: TemplateAstNode[] };
+ loading?: { children?: TemplateAstNode[] };
+ error?: { children?: TemplateAstNode[] };
+}
+
+const TRANSPARENT_CONTROL_FLOW_NODES: ReadonlySet = new Set([
+ 'DeferredBlock',
+ 'ForLoopBlock',
+ 'IfBlock',
+ 'IfBlockBranch',
+ 'SwitchBlock',
+ 'SwitchBlockCase',
+ 'SwitchBlockCaseGroup',
+]);
+
+const DYNAMIC_OUTLETS = new Set(['ngComponentOutlet', 'ngTemplateOutlet']);
+const CHILD_ARRAY_KEYS = ['children', 'branches', 'cases', 'groups'] as const;
+const CHILD_BLOCK_KEYS = ['then', 'else', 'empty', 'placeholder', 'loading', 'error'] as const;
+
+export function visitTemplateChildren(node: TemplateAstNode, visit: (nodes: TemplateAstNode[] | undefined) => void): void {
+ for (const key of CHILD_ARRAY_KEYS) {
+ visit(node[key]);
+ }
+ for (const key of CHILD_BLOCK_KEYS) {
+ visit(node[key]?.children);
+ }
+}
+
+export function walkTemplateNodes(
+ nodes: TemplateAstNode[] | undefined,
+ visit: (node: TemplateAstNode) => void,
+ // Metadata arrays are opt-in because most rules only need rendered template structure.
+ additionalArrayKeys: readonly ('attributes' | 'inputs' | 'outputs' | 'references' | 'templateAttrs')[] = [],
+): void {
+ const visited = new Set();
+
+ const walk = (currentNodes: TemplateAstNode[] | undefined): void => {
+ for (const node of currentNodes ?? []) {
+ if (visited.has(node)) {
+ continue;
+ }
+ visited.add(node);
+ visit(node);
+ visitTemplateChildren(node, walk);
+ for (const key of additionalArrayKeys) {
+ walk(node[key]);
+ }
+ }
+ };
+
+ walk(nodes);
+}
+
+export function isRenderedElement(node: TemplateAstNode): boolean {
+ return node.type.includes('Element') && node.name !== 'ng-container';
+}
+
+export function isRenderedText(node: TemplateAstNode): boolean {
+ return (node.type === 'Text' && typeof node.value === 'string' && Boolean(node.value.trim())) || node.type.includes('BoundText');
+}
+
+export function isTransparentTemplateStructure(node: TemplateAstNode): boolean {
+ const bindings = [...(node.inputs ?? []), ...(node.templateAttrs ?? [])];
+ const hasDynamicOutlet = bindings.some((binding) => DYNAMIC_OUTLETS.has(binding.name ?? ''));
+
+ return (
+ (node.type === 'Text' && (typeof node.value !== 'string' || !node.value.trim())) ||
+ (node.type.includes('Element') && node.name === 'ng-container' && !hasDynamicOutlet) ||
+ (node.type === 'Template' && !hasDynamicOutlet) ||
+ TRANSPARENT_CONTROL_FLOW_NODES.has(node.type)
+ );
+}
diff --git a/tests/rules/no-reactive-forms.ts b/tests/rules/no-reactive-forms.ts
index e038cc3..4a795b0 100644
--- a/tests/rules/no-reactive-forms.ts
+++ b/tests/rules/no-reactive-forms.ts
@@ -49,5 +49,10 @@ new TemplateRuleTester({
filename: 'template.html',
errors: [{ messageId: 'reactiveFormsBinding', data: { name: 'formControl' } }],
},
+ {
+ code: '',
+ filename: 'template.html',
+ errors: [{ messageId: 'reactiveFormsBinding', data: { name: 'formGroup' } }],
+ },
],
});
diff --git a/tests/rules/require-ion-item-group.ts b/tests/rules/require-ion-item-group.ts
new file mode 100644
index 0000000..a118b6b
--- /dev/null
+++ b/tests/rules/require-ion-item-group.ts
@@ -0,0 +1,443 @@
+import { RuleTester } from '@angular-eslint/test-utils';
+import rule from '../../src/rules/require-ion-item-group';
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ parser: require('@angular-eslint/template-parser'),
+ },
+});
+
+ruleTester.run('require-ion-item-group', rule, {
+ valid: [
+ { code: '', filename: 'template.html' },
+ { code: '', filename: 'template.html' },
+ {
+ code: '',
+ filename: 'template.html',
+ },
+ {
+ code: '',
+ filename: 'template.html',
+ },
+ {
+ code: '',
+ filename: 'template.html',
+ },
+ {
+ code: '',
+ filename: 'template.html',
+ },
+ {
+ code: `
+
+
+ @for (item of items; track item.id) {
+ {{ item.name }}
+ }
+
+
+ `,
+ filename: 'template.html',
+ },
+ {
+ code: `
+
+
+ @switch (selected) {
+ @case ('first') {
+ First
+ }
+ @default {
+ Default
+ }
+ }
+
+
+ `,
+ filename: 'template.html',
+ },
+ {
+ code: `
+
+ @if (grouped) {
+
+ @if (visible) {
+ Choice
+ }
+
+ }
+
+ `,
+ filename: 'template.html',
+ },
+ {
+ code: '',
+ filename: 'template.spec.html',
+ },
+ {
+ code: '',
+ filename: 'template.html',
+ },
+ {
+ code: '',
+ filename: 'template.html',
+ },
+ ],
+ invalid: [
+ {
+ code: '',
+ filename: 'template.html',
+ errors: [
+ {
+ messageId: 'requireIonItemGroup',
+ suggestions: [
+ {
+ messageId: 'wrapIonItemGroup',
+ output: '',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: '
',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup' }],
+ },
+ {
+ code: '
',
+ filename: 'template.html',
+ errors: [{ messageId: 'requireIonItemGroup' }],
+ },
+ {
+ code: '',
+ filename: 'template.html',
+ errors: [{ messageId: 'requireIonItemGroup' }],
+ },
+ {
+ code: '',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup' }],
+ },
+ {
+ code: `
+
+ @for (item of items; track item.id) {
+ {{ item.name }}
+ }
+
+ `,
+ filename: 'template.html',
+ errors: [
+ {
+ messageId: 'requireIonItemGroup',
+ line: 4,
+ suggestions: [
+ {
+ messageId: 'wrapIonItemGroup',
+ output: `
+
+ @for (item of items; track item.id) {
+ {{ item.name }}
+ }
+
+ `,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: `
+
+ @if (visible) {
+ First
+ } @else {
+ Second
+ }
+
+ `,
+ filename: 'template.html',
+ errors: [
+ {
+ messageId: 'requireIonItemGroup',
+ line: 4,
+ suggestions: [
+ {
+ messageId: 'wrapIonItemGroup',
+ output: `
+
+ @if (visible) {
+ First
+ } @else {
+ Second
+ }
+
+ `,
+ },
+ ],
+ },
+ { messageId: 'requireIonItemGroup', line: 6, suggestions: null },
+ ],
+ },
+ {
+ code: `
+
+ @for (item of items; track item.id) {
+ {{ item.name }}
+ } @empty {
+ Empty
+ }
+
+ `,
+ filename: 'template.html',
+ errors: [{ messageId: 'requireIonItemGroup', line: 6, suggestions: null }],
+ },
+ {
+ code: `
+
+ @defer (when ready) {
+ Ready
+ } @placeholder {
+ Placeholder
+ } @loading {
+ Loading
+ } @error {
+ Error
+ }
+
+ `,
+ filename: 'template.html',
+ errors: [
+ { messageId: 'requireIonItemGroup', line: 6, suggestions: null },
+ { messageId: 'requireIonItemGroup', line: 8, suggestions: null },
+ { messageId: 'requireIonItemGroup', line: 10, suggestions: null },
+ ],
+ },
+ {
+ code: `
+
+ @switch (selected) {
+ @case ('first') { First }
+ @default { Default }
+ }
+
+ `,
+ filename: 'template.html',
+ errors: [
+ {
+ messageId: 'requireIonItemGroup',
+ line: 4,
+ suggestions: [
+ {
+ messageId: 'wrapIonItemGroup',
+ output: `
+
+ @switch (selected) {
+ @case ('first') { First }
+ @default { Default }
+ }
+
+ `,
+ },
+ ],
+ },
+ { messageId: 'requireIonItemGroup', line: 5, suggestions: null },
+ ],
+ },
+ {
+ code: `
+
+ Outer
+
+ Inner
+
+
+ `,
+ filename: 'template.html',
+ errors: [
+ { messageId: 'requireIonItemGroup', line: 3, suggestions: null },
+ {
+ messageId: 'requireIonItemGroup',
+ line: 5,
+ suggestions: [
+ {
+ messageId: 'wrapIonItemGroup',
+ output: `
+
+ Outer
+
+ Inner
+
+
+ `,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: 'FirstSecond',
+ filename: 'template.html',
+ errors: [
+ {
+ messageId: 'requireIonItemGroup',
+ suggestions: [
+ {
+ messageId: 'wrapIonItemGroup',
+ output: 'FirstSecond',
+ },
+ ],
+ },
+ { messageId: 'requireIonItemGroup', suggestions: null },
+ ],
+ },
+ {
+ code: '😀',
+ filename: 'template.html',
+ errors: [
+ {
+ messageId: 'requireIonItemGroup',
+ suggestions: [
+ {
+ messageId: 'wrapIonItemGroup',
+ output: '😀',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: '',
+ filename: 'template.html',
+ errors: [
+ {
+ messageId: 'requireIonItemGroup',
+ suggestions: [
+ {
+ messageId: 'wrapIonItemGroup',
+ output: '',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: 'GroupedBare',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup', suggestions: null }],
+ },
+ {
+ code: 'OuterInner',
+ filename: 'template.html',
+ errors: [
+ { messageId: 'requireIonItemGroup', suggestions: null },
+ {
+ messageId: 'requireIonItemGroup',
+ suggestions: [
+ {
+ messageId: 'wrapIonItemGroup',
+ output: 'OuterInner',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ code: 'AB',
+ filename: 'template.html',
+ output: 'AB',
+ errors: [{ messageId: 'requireIonItemGroup' }, { messageId: 'requireIonItemGroup' }],
+ },
+ {
+ code: `
+
+
+ @for (item of items; track item.id) {
+ {{ item.name }}
+ }
+
+ `,
+ filename: 'template.html',
+ output: `
+
+
+ @for (item of items; track item.id) {
+ {{ item.name }}
+ }
+
+ `,
+ errors: [{ messageId: 'requireIonItemGroup' }],
+ },
+ {
+ code: 'HeadingItem',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup', suggestions: null }],
+ },
+ {
+ code: '{{ heading }}Item',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup', suggestions: null }],
+ },
+ {
+ code: 'Item',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup', suggestions: null }],
+ },
+ {
+ code: '{count, plural, =0 {none} other {{{count}} items}}Item',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup', suggestions: null }],
+ },
+ {
+ code: 'Item',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup', suggestions: null }],
+ },
+ {
+ code: 'Item',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup', suggestions: null }],
+ },
+ {
+ code: 'OuterInner',
+ filename: 'template.html',
+ output: null,
+ errors: [
+ { messageId: 'requireIonItemGroup', suggestions: null },
+ { messageId: 'requireIonItemGroup', suggestions: null },
+ ],
+ },
+ {
+ code: 'OuterInner
',
+ filename: 'template.html',
+ output: null,
+ errors: [
+ { messageId: 'requireIonItemGroup', suggestions: null },
+ { messageId: 'requireIonItemGroup', suggestions: null },
+ ],
+ },
+ {
+ code: 'Item',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup', suggestions: null }],
+ },
+ {
+ code: 'Item',
+ filename: 'template.html',
+ output: null,
+ errors: [{ messageId: 'requireIonItemGroup', suggestions: null }],
+ },
+ ],
+});
diff --git a/tests/rules/template-ast-utils.ts b/tests/rules/template-ast-utils.ts
new file mode 100644
index 0000000..a445680
--- /dev/null
+++ b/tests/rules/template-ast-utils.ts
@@ -0,0 +1,90 @@
+import { type TemplateAstNode, isTransparentTemplateStructure, walkTemplateNodes } from '../../src/rules/template-ast-utils';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const parser = require('@angular-eslint/template-parser') as {
+ parseForESLint(code: string, options: { filePath: string }): { ast: { templateNodes?: TemplateAstNode[] } };
+};
+
+function parseTemplate(code: string): TemplateAstNode[] | undefined {
+ return parser.parseForESLint(code, { filePath: 'template.html' }).ast.templateNodes;
+}
+
+describe('template AST utilities', () => {
+ it('walks every Angular control-flow branch', () => {
+ const nodes = parseTemplate(`
+ @if (visible) {
+ If
+ } @else {
+ Else
+ }
+ @for (item of items; track item.id) {
+ For
+ } @empty {
+ Empty
+ }
+ @switch (selected) {
+ @case ('first') { Case }
+ @default { Default }
+ }
+ @defer (when ready) {
+ Deferred
+ } @placeholder {
+ Placeholder
+ } @loading {
+ Loading
+ } @error {
+ Error
+ }
+ `);
+ const itemLabels: string[] = [];
+
+ walkTemplateNodes(nodes, (node) => {
+ if (node.type === 'Element' && node.name === 'ion-item') {
+ const text = node.children?.find((child) => child.type === 'Text')?.value;
+ if (typeof text === 'string') {
+ itemLabels.push(text);
+ }
+ }
+ });
+
+ expect(itemLabels).toEqual(['If', 'Else', 'For', 'Empty', 'Case', 'Default', 'Deferred', 'Placeholder', 'Loading', 'Error']);
+ });
+
+ it('visits a binding shared by a structural Template and Element once', () => {
+ const nodes = parseTemplate('');
+ const formGroupBindings: TemplateAstNode[] = [];
+
+ walkTemplateNodes(
+ nodes,
+ (node) => {
+ if (node.type === 'BoundAttribute' && node.name === 'formGroup') {
+ formGroupBindings.push(node);
+ }
+ },
+ ['inputs'],
+ );
+
+ expect(formGroupBindings).toHaveLength(1);
+ });
+
+ it('only treats statically transparent containers as transparent', () => {
+ const nodes = parseTemplate(`
+
+
+
+
+
+ `);
+ const structures: TemplateAstNode[] = [];
+ walkTemplateNodes(nodes, (node) => {
+ if (node.type !== 'Text') {
+ structures.push(node);
+ }
+ });
+
+ expect(isTransparentTemplateStructure(structures[0])).toBe(true);
+ expect(isTransparentTemplateStructure(structures[1])).toBe(true);
+ expect(structures.some((node) => node.type === 'Template' && !isTransparentTemplateStructure(node))).toBe(true);
+ expect(structures.some((node) => node.type === 'Content' && !isTransparentTemplateStructure(node))).toBe(true);
+ });
+});