diff --git a/README.md b/README.md
index 406ed89301..55d2f7e1db 100644
--- a/README.md
+++ b/README.md
@@ -491,14 +491,15 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
### Possible Errors
-| Name | Description | 💼 | 🔧 | 💡 |
-| :------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------- | :- | :- | :- |
-| [template-no-extra-mut-helper-argument](docs/rules/template-no-extra-mut-helper-argument.md) | disallow passing more than one argument to the mut helper | 📋 | | |
-| [template-no-jsx-attributes](docs/rules/template-no-jsx-attributes.md) | disallow JSX-style camelCase attributes | | 🔧 | |
-| [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | 📋 | | |
-| [template-no-shadowed-elements](docs/rules/template-no-shadowed-elements.md) | disallow ambiguity with block param names shadowing HTML elements | 📋 | | |
-| [template-no-unbalanced-curlies](docs/rules/template-no-unbalanced-curlies.md) | disallow unbalanced mustache curlies | 📋 | | |
-| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | 📋 | 🔧 | |
+| Name | Description | 💼 | 🔧 | 💡 |
+| :------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------- | :- | :- | :- |
+| [template-no-extra-mut-helper-argument](docs/rules/template-no-extra-mut-helper-argument.md) | disallow passing more than one argument to the mut helper | 📋 | | |
+| [template-no-jsx-attributes](docs/rules/template-no-jsx-attributes.md) | disallow JSX-style camelCase attributes | | 🔧 | |
+| [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | 📋 | | |
+| [template-no-shadowed-elements](docs/rules/template-no-shadowed-elements.md) | disallow ambiguity with block param names shadowing HTML elements | 📋 | | |
+| [template-no-unbalanced-curlies](docs/rules/template-no-unbalanced-curlies.md) | disallow unbalanced mustache curlies | 📋 | | |
+| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | 📋 | 🔧 | |
+| [template-valid-input-attributes](docs/rules/template-valid-input-attributes.md) | disallow input attributes that are incompatible with the declared type | | | |
### Routes
diff --git a/docs/rules/template-valid-input-attributes.md b/docs/rules/template-valid-input-attributes.md
new file mode 100644
index 0000000000..395fb3e415
--- /dev/null
+++ b/docs/rules/template-valid-input-attributes.md
@@ -0,0 +1,37 @@
+# ember/template-valid-input-attributes
+
+
+
+This rule flags `` attributes that are incompatible with the
+declared `type`. For example, `pattern` only applies to the text-like
+input types; on `type="number"` it is silently ignored by the browser.
+
+The attribute/type compatibility table matches the HTML living standard.
+Dynamic type values (e.g. `type={{this.inputType}}`) are skipped. Inputs
+with a missing, valueless, empty, or unknown `type` are treated as being
+in the Text state and are validated accordingly.
+
+## Examples
+
+This rule **forbids** the following:
+
+```hbs
+
+
+
+
+```
+
+This rule **allows** the following:
+
+```hbs
+
+
+
+
+```
+
+## References
+
+- [HTML spec: input element content attributes](https://html.spec.whatwg.org/multipage/input.html#the-input-element)
+- Adapted from [`html-validate`'s `input-attributes`](https://html-validate.org/rules/input-attributes.html) (MIT).
diff --git a/lib/rules/template-valid-input-attributes.js b/lib/rules/template-valid-input-attributes.js
new file mode 100644
index 0000000000..a9e60df1c8
--- /dev/null
+++ b/lib/rules/template-valid-input-attributes.js
@@ -0,0 +1,180 @@
+'use strict';
+
+const { isNativeElement } = require('../utils/is-native-element');
+
+// Data table ported from html-validate input-attributes (MIT), Copyright 2017 David Sveningsson.
+// https://html-validate.org/rules/input-attributes.html
+const RESTRICTED = new Map([
+ ['accept', new Set(['file'])],
+ ['alt', new Set(['image'])],
+ ['capture', new Set(['file'])],
+ ['checked', new Set(['checkbox', 'radio'])],
+ ['dirname', new Set(['text', 'search'])],
+ ['height', new Set(['image'])],
+ [
+ 'list',
+ new Set([
+ 'text',
+ 'search',
+ 'url',
+ 'tel',
+ 'email',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+ 'number',
+ 'range',
+ 'color',
+ ]),
+ ],
+ ['max', new Set(['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range'])],
+ ['maxlength', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
+ ['min', new Set(['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range'])],
+ ['minlength', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
+ ['multiple', new Set(['email', 'file'])],
+ ['pattern', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
+ ['placeholder', new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number'])],
+ [
+ 'readonly',
+ new Set([
+ 'text',
+ 'search',
+ 'url',
+ 'tel',
+ 'email',
+ 'password',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+ 'number',
+ ]),
+ ],
+ [
+ 'required',
+ new Set([
+ 'text',
+ 'search',
+ 'url',
+ 'tel',
+ 'email',
+ 'password',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+ 'number',
+ 'checkbox',
+ 'radio',
+ 'file',
+ ]),
+ ],
+ ['size', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
+ ['src', new Set(['image'])],
+ ['step', new Set(['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range'])],
+ ['width', new Set(['image'])],
+]);
+
+// Input types defined by the HTML spec. Per the spec, an element with a
+// missing, empty, or unknown `type` attribute falls back to the Text state, so
+// we normalize to 'text' before validating attribute compatibility.
+// https://html.spec.whatwg.org/multipage/input.html#attr-input-type
+const KNOWN_INPUT_TYPES = new Set([
+ 'hidden',
+ 'text',
+ 'search',
+ 'tel',
+ 'url',
+ 'email',
+ 'password',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'datetime-local',
+ 'number',
+ 'range',
+ 'color',
+ 'checkbox',
+ 'radio',
+ 'file',
+ 'submit',
+ 'image',
+ 'reset',
+ 'button',
+]);
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'disallow input attributes that are incompatible with the declared type',
+ category: 'Possible Errors',
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-valid-input-attributes.md',
+ templateMode: 'both',
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ incompatible: 'Attribute `{{attr}}` is not allowed on ``',
+ },
+ },
+
+ create(context) {
+ const sourceCode = context.sourceCode || context.getSourceCode();
+ return {
+ GlimmerElementNode(node) {
+ if (node.tag !== 'input') {
+ return;
+ }
+ if (!isNativeElement(node, sourceCode)) {
+ return;
+ }
+ // Per HTML §4.10.5, an with missing, valueless, empty, or
+ // unknown `type` falls back to the Text state. Only a DYNAMIC type
+ // (mustache/concat with a non-literal path) is opaque to static
+ // analysis — skip those. Each branch resolves directly to a known
+ // type or to the spec-default 'text', avoiding an intermediate
+ // sentinel.
+ const typeAttr = node.attributes?.find((a) => a.name === 'type');
+ let type;
+ if (!typeAttr || !typeAttr.value) {
+ // Missing attribute OR valueless `` — Text state.
+ type = 'text';
+ } else if (typeAttr.value.type === 'GlimmerTextNode') {
+ const raw = typeAttr.value.chars.trim().toLowerCase();
+ type = KNOWN_INPUT_TYPES.has(raw) ? raw : 'text';
+ } else if (
+ typeAttr.value.type === 'GlimmerMustacheStatement' &&
+ typeAttr.value.path?.type === 'GlimmerStringLiteral'
+ ) {
+ const raw = typeAttr.value.path.value.trim().toLowerCase();
+ type = KNOWN_INPUT_TYPES.has(raw) ? raw : 'text';
+ } else {
+ // Dynamic value — can't statically determine; skip.
+ return;
+ }
+
+ for (const attr of node.attributes || []) {
+ const validTypes = RESTRICTED.get(attr.name.toLowerCase());
+ if (!validTypes) {
+ continue;
+ }
+ if (validTypes.has(type)) {
+ continue;
+ }
+ context.report({
+ node: attr,
+ messageId: 'incompatible',
+ data: { attr: attr.name, type },
+ });
+ }
+ },
+ };
+ },
+};
diff --git a/tests/lib/rules/template-valid-input-attributes.js b/tests/lib/rules/template-valid-input-attributes.js
new file mode 100644
index 0000000000..a484692d23
--- /dev/null
+++ b/tests/lib/rules/template-valid-input-attributes.js
@@ -0,0 +1,119 @@
+const rule = require('../../../lib/rules/template-valid-input-attributes');
+const RuleTester = require('eslint').RuleTester;
+
+const err = (attr, type) => `Attribute \`${attr}\` is not allowed on \`\``;
+
+const validHbs = [
+ // Attribute matches type.
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ // Dynamic type — skip.
+ '',
+ // Mustache string literal — statically resolvable, attribute is compatible.
+ '',
+ // Missing / valueless / empty / unknown type fall back to the Text state
+ // per HTML §4.10.5. Attributes valid for text still pass.
+ '',
+ '',
+ '',
+ // Attribute name case — HTML attributes are case-insensitive; rule normalizes.
+ '',
+ '',
+ // Not an input — rule doesn't apply.
+ '',
+ // Empty/whitespace/unknown type values fall back to the Text state per HTML
+ // spec, so `pattern` (allowed on text) is valid.
+ '',
+ '',
+ '',
+];
+
+const invalidHbs = [
+ {
+ code: '',
+ errors: [{ message: err('pattern', 'number') }],
+ },
+ // Mustache string literal — statically resolvable, attribute is incompatible.
+ {
+ code: '',
+ errors: [{ message: err('pattern', 'number') }],
+ },
+ {
+ code: '',
+ errors: [{ message: err('accept', 'text') }],
+ },
+ {
+ code: '',
+ errors: [{ message: err('maxlength', 'radio') }],
+ },
+ {
+ code: '',
+ errors: [{ message: err('placeholder', 'checkbox') }],
+ },
+ {
+ code: '',
+ errors: [{ message: err('pattern', 'submit') }, { message: err('size', 'submit') }],
+ },
+ {
+ code: '',
+ errors: [{ message: err('accept', 'text') }],
+ },
+ // Uppercase attribute name — normalized before restriction lookup.
+ {
+ code: '',
+ errors: [{ message: err('PATTERN', 'number') }],
+ },
+ // Text-state fallback — with missing/valueless/empty/unknown type
+ // is the Text state per HTML spec. Attributes incompatible with text are
+ // flagged as `type="text"` in the error message.
+ {
+ code: '',
+ errors: [{ message: err('multiple', 'text') }],
+ },
+ {
+ code: '',
+ errors: [{ message: err('alt', 'text') }],
+ },
+ {
+ code: '',
+ errors: [{ message: err('accept', 'text') }],
+ },
+ {
+ code: '',
+ errors: [{ message: err('src', 'text') }],
+ },
+];
+
+const gjsValid = validHbs.map((code) => `${code}`);
+const gjsInvalid = invalidHbs.map(({ code, errors }) => ({
+ code: `${code}`,
+ errors,
+}));
+
+const gjsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+gjsRuleTester.run('template-valid-input-attributes', rule, {
+ valid: gjsValid,
+ invalid: gjsInvalid,
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+hbsRuleTester.run('template-valid-input-attributes', rule, {
+ valid: validHbs,
+ invalid: invalidHbs,
+});