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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/rules.md
Original file line number Diff line number Diff line change
@@ -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 |
| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | :-: | :----: |
Expand All @@ -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 |
Expand Down
83 changes: 83 additions & 0 deletions docs/rules/require-ion-item-group.md
Original file line number Diff line number Diff line change
@@ -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
<ion-list>
<ion-item>Direct item</ion-item>
</ion-list>
```

<!-- prettier-ignore -->
```html
<ion-list>
@for (item of items; track item.id) {
<ion-item>{{ item.name }}</ion-item>
}
</ion-list>
```

### Correct

<!-- prettier-ignore -->
```html
<ion-list>
<ion-item-group>
@for (item of items; track item.id) {
<ion-item>{{ item.name }}</ion-item>
}
</ion-item-group>
</ion-list>
```

```html
<ion-list>
<ion-radio-group>
<ion-item>First choice</ion-item>
<ion-item>Second choice</ion-item>
</ion-radio-group>
</ion-list>
```

## 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)
1 change: 1 addition & 0 deletions scripts/lib/recommended-rule-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export const RECOMMENDED_RULE_NAMES = new Set([
'ionic-attr-type-check',
'deny-element',
'prefer-disable-handler',
'require-ion-item-group',
]);
1 change: 1 addition & 0 deletions scripts/lib/update-lib-configs-recommended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const recommended: Linter.Config[] = [
},
],
'@rdlabo/rules/prefer-disable-handler': 'error',
'@rdlabo/rules/require-ion-item-group': 'error',
},
},
];
Expand Down
1 change: 1 addition & 0 deletions src/configs/recommended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const recommended: Linter.Config[] = [
},
],
'@rdlabo/rules/prefer-disable-handler': 'error',
'@rdlabo/rules/require-ion-item-group': 'error',
},
},
];
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down
87 changes: 10 additions & 77 deletions src/rules/deny-element.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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);
}
});
},
};
},
Expand Down
57 changes: 20 additions & 37 deletions src/rules/no-reactive-forms.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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<MessageIds, []> = {
defaultOptions: [],
meta: {
Expand Down Expand Up @@ -85,21 +64,25 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
}
},
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'],
);
},
};
},
Expand Down
32 changes: 6 additions & 26 deletions src/rules/no-template-driven-forms.ts
Original file line number Diff line number Diff line change
@@ -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<MessageIds, [RuleOptions?]> = {
defaultOptions: [{ allowedElements: [] }],
meta: {
Expand Down Expand Up @@ -60,8 +37,11 @@ const rule: TSESLint.RuleModule<MessageIds, [RuleOptions?]> = {
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)) {
Expand Down
Loading