Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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" />',
];
Comment thread
johanrd marked this conversation as resolved.

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('') }],
},
];
Comment thread
johanrd marked this conversation as resolved.

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