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 @@ -358,6 +358,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
| [template-require-form-method](docs/rules/template-require-form-method.md) | require form method attribute | | 🔧 | |
| [template-require-has-block-helper](docs/rules/template-require-has-block-helper.md) | require (has-block) helper usage instead of hasBlock property | 📋 | 🔧 | |
| [template-require-iframe-src-attribute](docs/rules/template-require-iframe-src-attribute.md) | require iframe elements to have src attribute | | 🔧 | |
| [template-require-input-type](docs/rules/template-require-input-type.md) | require input elements to have a valid type attribute | | 🔧 | |
| [template-require-splattributes](docs/rules/template-require-splattributes.md) | require splattributes usage in component templates | | | |
| [template-require-strict-mode](docs/rules/template-require-strict-mode.md) | require templates to be in strict mode | | | |
| [template-require-valid-named-block-naming-format](docs/rules/template-require-valid-named-block-naming-format.md) | require valid named block naming format | 📋 | 🔧 | |
Expand Down
66 changes: 66 additions & 0 deletions docs/rules/template-require-input-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# ember/template-require-input-type

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

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

This rule rejects `<input type="...">` values that are not one of the input
types defined by the HTML spec, and (optionally) requires every `<input>` to
declare a `type` attribute.

An invalid value like `<input type="foo">` silently falls back to the Text
state — the browser reports no error, but the author's intent (validation,
inputmode hint, platform keyboard) is lost. That's a genuine silent-failure
class, which this rule always flags and auto-fixes to `type="text"`.

A missing `type` attribute (`<input />`) is _spec-compliant_ — the
missing-value default is the Text state — so flagging it is a style /
consistency choice, not a correctness one. Opt in with `requireExplicit: true`
if your team wants parity with `template-require-button-type`.

## Examples

This rule **forbids** the following (always):

```hbs
<input type='' />
<input type='foo' />
<input type='TEXTY' />
```

With `requireExplicit: true` the rule **also forbids**:

```hbs
<input />
<input name='email' />
```

This rule **allows** the following:

```hbs
<input type='text' />
<input type='email' />
<input type='checkbox' />
<input type={{this.inputType}} />
```

Dynamic values such as `type={{this.inputType}}` are not flagged at lint time.

## Configuration

- `requireExplicit` (`boolean`, default `false`): when true, also flag
`<input>` elements that have no `type` attribute. Auto-fix inserts
`type="text"`.

```js
module.exports = {
rules: {
'ember/template-require-input-type': ['error', { requireExplicit: true }],
},
};
```

## References

- [HTML spec — the input element](https://html.spec.whatwg.org/multipage/input.html#the-input-element)
- Adapted from [`html-validate`'s `no-implicit-input-type`](https://html-validate.org/rules/no-implicit-input-type.html) (MIT).
145 changes: 145 additions & 0 deletions lib/rules/template-require-input-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
'use strict';

// See html-validate (https://html-validate.org) for the peer rule concept.

const { isNativeElement } = require('../utils/is-native-element');

const VALID_TYPES = new Set([
'button',
'checkbox',
'color',
'date',
'datetime-local',
'email',
'file',
'hidden',
'image',
'month',
'number',
'password',
'radio',
'range',
'reset',
'search',
'submit',
'tel',
'text',
'time',
'url',
'week',
]);

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require input elements to have a valid type attribute',
category: 'Best Practices',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-input-type.md',
templateMode: 'both',
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
requireExplicit: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
messages: {
missing: 'All `<input>` elements should have a `type` attribute',
invalid: '`<input type="{{value}}">` is not a valid input type',
},
},

create(context) {
// Flagging a missing `type` is a style/consistency check, not a correctness
// one: `<input>` without `type` is spec-compliant (defaults to the Text
// state). Opt-in so teams that want parity with template-require-button-
// type can enable it without imposing it on others.
const requireExplicit = Boolean(context.options[0]?.requireExplicit);
const sourceCode = context.sourceCode || context.getSourceCode();

return {
GlimmerElementNode(node) {
if (node.tag !== 'input') {
return;
}
// In strict GJS, a lowercase local binding can shadow the native
// `<input>` element. `isNativeElement` consults html/svg/mathml tag
// lists and checks bindings in the scope chain to filter out
// scope-shadowed cases.
if (!isNativeElement(node, sourceCode)) {
return;
}

const typeAttr = node.attributes?.find((attr) => attr.name === 'type');

if (!typeAttr) {
if (!requireExplicit) {
return;
}
context.report({
node,
messageId: 'missing',
fix(fixer) {
// Insert right after `<input` so the new attribute is the first
// one — avoids the fragile "find end of open tag" regex that can
// mis-place the attribute past the `/` in self-closing syntax.
const insertPos = node.range[0] + '<input'.length;
return fixer.insertTextBeforeRange([insertPos, insertPos], ' type="text"');
},
});
return;
}

const value = typeAttr.value;

// Valueless attribute form (`<input type />`) — per HTML spec, a
// present-but-empty type attribute resolves to the missing-value
// default ("Text state"). That's the same runtime result as
// `type=""`, which we already flag. Treat them consistently:
// flag as invalid('') and autofix to `type="text"`.
if (!value) {
context.report({
node: typeAttr,
messageId: 'invalid',
data: { value: '' },
fix(fixer) {
return fixer.replaceText(typeAttr, 'type="text"');
},
});
return;
}

if (value.type === 'GlimmerTextNode') {
const typeValue = value.chars.toLowerCase();
if (typeValue === '') {
context.report({
node: typeAttr,
messageId: 'invalid',
data: { value: '' },
fix(fixer) {
return fixer.replaceText(typeAttr, 'type="text"');
},
});
} else if (!VALID_TYPES.has(typeValue)) {
context.report({
node: typeAttr,
messageId: 'invalid',
data: { value: value.chars },
fix(fixer) {
return fixer.replaceText(typeAttr, 'type="text"');
},
});
}
}
},
};
},
};
16 changes: 7 additions & 9 deletions lib/utils/is-native-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,17 @@ const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
* MathML spec registries, reached via the `html-tags` / `svg-tags` /
* `mathml-tag-names` packages). It is NOT the same as:
*
* - "native accessibility" / "widget-ness" — see `interactive-roles.js`
* (aria-query widget taxonomy; an ARIA-tree-semantics question)
* - "native interactive content" / "focus behavior" — see
* `html-interactive-content.js` (HTML §3.2.5.2.7; an HTML-content-model
* question about which tags can be nested inside what)
* - "native accessibility" / "widget-ness" — an ARIA-tree-semantics
* question (for example, whether something maps to a widget role)
* - "native interactive content" / "focus behavior" — an HTML content-model
* question about which elements are considered interactive in the spec
* - "natively focusable" / sequential-focus — see HTML §6.6.3
*
* This util answers only: "is this tag a first-class built-in element of one
* of the three markup-language standards, rather than a component invocation
* or a shadowed local binding?" Callers compose it with the other utils
* above when they need a more specific question (see e.g. `template-no-
* noninteractive-tabindex`, which consults both this and
* `html-interactive-content`).
* or a shadowed local binding?" Callers should combine it with whatever
* accessibility, interactivity, or focusability checks they need for more
* specific questions.
*
* Returns false for:
* - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced —
Expand Down
129 changes: 129 additions & 0 deletions tests/lib/rules/template-require-input-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
const rule = require('../../../lib/rules/template-require-input-type');
const RuleTester = require('eslint').RuleTester;

const ERROR_MISSING = 'All `<input>` elements should have a `type` attribute';
const errInvalid = (value) => `\`<input type="${value}">\` is not a valid input type`;

const validHbs = [
'<input type="text" />',
'<input type="email" />',
'<input type="checkbox" />',
'<input type="submit" />',
'<input type="datetime-local" />',
'<input type="{{this.inputType}}" />',
'<input type={{this.inputType}} />',
'<div />',
'<div type="foo" />',
'<MyInput type="unknown" />',
// Default (requireExplicit=false): missing `type` is allowed.
'<input />',
'<input name="email" />',
];

const invalidHbs = [
{
code: '<input type="" />',
output: '<input type="text" />',
errors: [{ message: errInvalid('') }],
},
{
code: '<input type="foo" />',
output: '<input type="text" />',
errors: [{ message: errInvalid('foo') }],
},
{
code: '<input type="TEXTY" />',
output: '<input type="text" />',
errors: [{ message: errInvalid('TEXTY') }],
},
// Valueless type attribute — per HTML spec resolves to the missing-value
// default (Text state), same runtime result as `type=""`. Flag and autofix
// to `type="text"`. (Output loses the pre-slash space because the
// valueless attr range ends at `type`; prettier will re-insert if needed.)
{
code: '<input type />',
output: '<input type="text"/>',
errors: [{ message: errInvalid('') }],
},
];

const requireExplicitInvalid = [
{
code: '<input />',
options: [{ requireExplicit: true }],
output: '<input type="text" />',
errors: [{ message: ERROR_MISSING }],
},
{
code: '<input name="email" />',
options: [{ requireExplicit: true }],
output: '<input type="text" name="email" />',
errors: [{ message: ERROR_MISSING }],
},
{
code: '<input name="email" />',
options: [{ requireExplicit: true }],
output: '<input type="text" name="email" />',
errors: [{ message: ERROR_MISSING }],
},
];

const requireExplicitValid = [
// With requireExplicit: an explicit known type satisfies the rule.
{ code: '<input type="text" />', options: [{ requireExplicit: true }] },
// Dynamic type also satisfies — we can't know the runtime value.
{ code: '<input type={{this.inputType}} />', options: [{ requireExplicit: true }] },
];

const gjsValid = validHbs.map((code) => `<template>${code}</template>`);
const gjsInvalid = invalidHbs.map(({ code, output, errors }) => ({
code: `<template>${code}</template>`,
output: `<template>${output}</template>`,
errors,
}));

const gjsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

gjsRuleTester.run('template-require-input-type', rule, {
valid: [
...gjsValid,
...requireExplicitValid.map(({ code, options }) => ({
code: `<template>${code}</template>`,
options,
})),
// Scope-shadowed `input` — the template's `<input>` refers to the local
// const binding (a component), not the native HTML element. The rule
// skips it via `isNativeElement`'s scope check.
`const input = 'foo';
<template><input type="not-a-valid-type" /></template>`,
`const input = 'foo';
<template><input /></template>`,
// Block-param shadowing — `<Foo as |input|>` binds `input` inside the
// yield block. The inner `<input>` should resolve to the block-param,
// not the native tag.
`import Foo from 'whatever';
<template><Foo as |input|><input type="not-a-valid-type" /></Foo></template>`,
],
invalid: [
...gjsInvalid,
...requireExplicitInvalid.map(({ code, options, output, errors }) => ({
code: `<template>${code}</template>`,
options,
output: `<template>${output}</template>`,
errors,
})),
],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

hbsRuleTester.run('template-require-input-type', rule, {
valid: [...validHbs, ...requireExplicitValid],
invalid: [...invalidHbs, ...requireExplicitInvalid],
});
Loading