diff --git a/src/dev-app/input/input-demo.html b/src/dev-app/input/input-demo.html
index 3fbdf726be63..3061892ebad0 100644
--- a/src/dev-app/input/input-demo.html
+++ b/src/dev-app/input/input-demo.html
@@ -711,6 +711,53 @@
<textarea> with bindable autosize
+
+ Disabled interactive inputs
+
+ @for (appearance of appearances; track $index) {
+
+
+ Label
+
+
+
+
+ Label
+
+
+
+
+ Label
+
+
+
+
+
+
+
+ }
+
+
+
Textarea form-fields
diff --git a/src/dev-app/input/input-demo.ts b/src/dev-app/input/input-demo.ts
index 49d5af853834..86edd41ce841 100644
--- a/src/dev-app/input/input-demo.ts
+++ b/src/dev-app/input/input-demo.ts
@@ -100,6 +100,7 @@ export class InputDemo {
standardAppearance: string;
fillAppearance: string;
outlineAppearance: string;
+ appearances: MatFormFieldAppearance[] = ['fill', 'outline'];
hasLabel$ = new BehaviorSubject(true);
diff --git a/src/material/form-field/_mdc-text-field-structure.scss b/src/material/form-field/_mdc-text-field-structure.scss
index 17ff47182005..536dd9f1a280 100644
--- a/src/material/form-field/_mdc-text-field-structure.scss
+++ b/src/material/form-field/_mdc-text-field-structure.scss
@@ -72,6 +72,12 @@
}
}
+ .mdc-text-field--disabled:not(.mdc-text-field--no-label) &.mat-mdc-input-disabled-interactive {
+ @include vendor-prefixes.input-placeholder {
+ opacity: 0;
+ }
+ }
+
.mdc-text-field--outlined &,
.mdc-text-field--filled.mdc-text-field--no-label & {
height: 100%;
diff --git a/src/material/input/input.spec.ts b/src/material/input/input.spec.ts
index e5bd47857ba1..b39f6eeb74f1 100644
--- a/src/material/input/input.spec.ts
+++ b/src/material/input/input.spec.ts
@@ -403,6 +403,65 @@ describe('MatMdcInput without forms', () => {
expect(inputEl.disabled).toBe(true);
}));
+ it('should be able to set an input as being disabled and interactive', fakeAsync(() => {
+ const fixture = createComponent(MatInputWithDisabled);
+ fixture.componentInstance.disabled = true;
+ fixture.detectChanges();
+
+ const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
+ expect(input.disabled).toBe(true);
+ expect(input.readOnly).toBe(false);
+ expect(input.hasAttribute('aria-disabled')).toBe(false);
+ expect(input.classList).not.toContain('mat-mdc-input-disabled-interactive');
+
+ fixture.componentInstance.disabledInteractive = true;
+ fixture.changeDetectorRef.markForCheck();
+ fixture.detectChanges();
+
+ expect(input.disabled).toBe(false);
+ expect(input.readOnly).toBe(true);
+ expect(input.getAttribute('aria-disabled')).toBe('true');
+ expect(input.classList).toContain('mat-mdc-input-disabled-interactive');
+ }));
+
+ it('should not float the label when disabled and disabledInteractive are set', fakeAsync(() => {
+ const fixture = createComponent(MatInputTextTestController);
+ fixture.componentInstance.disabled = fixture.componentInstance.disabledInteractive = true;
+ fixture.detectChanges();
+
+ const label = fixture.nativeElement.querySelector('label');
+ const input = fixture.debugElement
+ .query(By.directive(MatInput))!
+ .injector.get(MatInput);
+
+ expect(label.classList).not.toContain('mdc-floating-label--float-above');
+
+ // Call the focus handler directly to avoid flakyness where
+ // browsers don't focus elements if the window is minimized.
+ input._focusChanged(true);
+ fixture.detectChanges();
+
+ expect(label.classList).not.toContain('mdc-floating-label--float-above');
+ }));
+
+ it('should float the label when disabledInteractive is set and the input has a value', fakeAsync(() => {
+ const fixture = createComponent(MatInputWithDynamicLabel);
+ fixture.componentInstance.shouldFloat = 'auto';
+ fixture.componentInstance.disabled = fixture.componentInstance.disabledInteractive = true;
+ fixture.detectChanges();
+
+ const input = fixture.nativeElement.querySelector('input');
+ const label = fixture.nativeElement.querySelector('label');
+
+ expect(label.classList).not.toContain('mdc-floating-label--float-above');
+
+ input.value = 'Text';
+ dispatchFakeEvent(input, 'input');
+ fixture.detectChanges();
+
+ expect(label.classList).toContain('mdc-floating-label--float-above');
+ }));
+
it('supports the disabled attribute as binding for select', fakeAsync(() => {
const fixture = createComponent(MatInputSelect);
fixture.detectChanges();
@@ -719,16 +778,13 @@ describe('MatMdcInput without forms', () => {
expect(labelEl.classList).not.toContain('mdc-floating-label--float-above');
}));
- it(
- 'should not float labels when select has no value, no option label, ' + 'no option innerHtml',
- fakeAsync(() => {
- const fixture = createComponent(MatInputSelectWithNoLabelNoValue);
- fixture.detectChanges();
+ it('should not float labels when select has no value, no option label, no option innerHtml', fakeAsync(() => {
+ const fixture = createComponent(MatInputSelectWithNoLabelNoValue);
+ fixture.detectChanges();
- const labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement;
- expect(labelEl.classList).not.toContain('mdc-floating-label--float-above');
- }),
- );
+ const labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement;
+ expect(labelEl.classList).not.toContain('mdc-floating-label--float-above');
+ }));
it('should floating labels when select has no value but has option label', fakeAsync(() => {
const fixture = createComponent(MatInputSelectWithLabel);
@@ -1532,6 +1588,7 @@ describe('MatFormField default options', () => {
).toBe(true);
});
});
+
describe('MatFormField without label', () => {
it('should not float the label when no label is defined.', () => {
let fixture = createComponent(MatInputWithoutDefinedLabel);
@@ -1650,10 +1707,15 @@ class MatInputWithId {
}
@Component({
- template: ``,
+ template: `
+
+
+
+ `,
})
class MatInputWithDisabled {
- disabled: boolean;
+ disabled = false;
+ disabledInteractive = false;
}
@Component({
@@ -1783,10 +1845,18 @@ class MatInputDateTestController {}
template: `
Label
-
+
`,
})
-class MatInputTextTestController {}
+class MatInputTextTestController {
+ disabled = false;
+ disabledInteractive = false;
+}
@Component({
template: `
@@ -1837,11 +1907,17 @@ class MatInputWithStaticLabel {}
template: `
Label
-
+
`,
})
class MatInputWithDynamicLabel {
shouldFloat: 'always' | 'auto' = 'always';
+ disabled = false;
+ disabledInteractive = false;
}
@Component({
diff --git a/src/material/input/input.ts b/src/material/input/input.ts
index ad0a96b58a3f..a34705997a2c 100644
--- a/src/material/input/input.ts
+++ b/src/material/input/input.ts
@@ -11,10 +11,13 @@ import {getSupportedInputTypes, Platform} from '@angular/cdk/platform';
import {AutofillMonitor} from '@angular/cdk/text-field';
import {
AfterViewInit,
+ booleanAttribute,
Directive,
DoCheck,
ElementRef,
+ inject,
Inject,
+ InjectionToken,
Input,
NgZone,
OnChanges,
@@ -44,6 +47,15 @@ const MAT_INPUT_INVALID_TYPES = [
let nextUniqueId = 0;
+/** Object that can be used to configure the default options for the input. */
+export interface MatInputConfig {
+ /** Whether disabled inputs should be interactive. */
+ disabledInteractive?: boolean;
+}
+
+/** Injection token that can be used to provide the default options for the input. */
+export const MAT_INPUT_CONFIG = new InjectionToken('MAT_INPUT_CONFIG');
+
@Directive({
selector: `input[matInput], textarea[matInput], select[matNativeControl],
input[matNativeControl], textarea[matNativeControl]`,
@@ -56,15 +68,17 @@ let nextUniqueId = 0;
'[class.mat-input-server]': '_isServer',
'[class.mat-mdc-form-field-textarea-control]': '_isInFormField && _isTextarea',
'[class.mat-mdc-form-field-input-control]': '_isInFormField',
+ '[class.mat-mdc-input-disabled-interactive]': 'disabledInteractive',
'[class.mdc-text-field__input]': '_isInFormField',
'[class.mat-mdc-native-select-inline]': '_isInlineSelect()',
// Native input properties that are overwritten by Angular inputs need to be synced with
// the native input element. Otherwise property bindings for those don't work.
'[id]': 'id',
- '[disabled]': 'disabled',
+ '[disabled]': 'disabled && !disabledInteractive',
'[required]': 'required',
'[attr.name]': 'name || null',
- '[attr.readonly]': 'readonly && !_isNativeSelect || null',
+ '[attr.readonly]': '_getReadonlyAttribute()',
+ '[attr.aria-disabled]': 'disabled && disabledInteractive ? "true" : null',
// Only mark the input as invalid for assistive technology if it has a value since the
// state usually overlaps with `aria-required` when the input is empty and can be redundant.
'[attr.aria-invalid]': '(empty && required) ? null : errorState',
@@ -88,6 +102,7 @@ export class MatInput
private _previousPlaceholder: string | null;
private _errorStateTracker: _ErrorStateTracker;
private _webkitBlinkWheelListenerAttached = false;
+ private _config = inject(MAT_INPUT_CONFIG, {optional: true});
/** Whether the component is being rendered on the server. */
readonly _isServer: boolean;
@@ -243,6 +258,10 @@ export class MatInput
}
private _readonly = false;
+ /** Whether the input should remain interactive when it is disabled. */
+ @Input({transform: booleanAttribute})
+ disabledInteractive: boolean;
+
/** Whether the input is in an error state. */
get errorState() {
return this._errorStateTracker.errorState;
@@ -306,6 +325,7 @@ export class MatInput
this._isNativeSelect = nodeName === 'select';
this._isTextarea = nodeName === 'textarea';
this._isInFormField = !!_formField;
+ this.disabledInteractive = this._config?.disabledInteractive || false;
if (this._isNativeSelect) {
this.controlType = (element as HTMLSelectElement).multiple
@@ -382,10 +402,27 @@ export class MatInput
/** Callback for the cases where the focused state of the input changes. */
_focusChanged(isFocused: boolean) {
- if (isFocused !== this.focused) {
- this.focused = isFocused;
- this.stateChanges.next();
+ if (isFocused === this.focused) {
+ return;
}
+
+ if (!this._isNativeSelect && isFocused && this.disabled && this.disabledInteractive) {
+ const element = this._elementRef.nativeElement as HTMLInputElement;
+
+ // Focusing an input that has text will cause all the text to be selected. Clear it since
+ // the user won't be able to change it. This is based on the internal implementation.
+ if (element.type === 'number') {
+ // setSelectionRange doesn't work on number inputs so it needs to be set briefly to text.
+ element.type = 'text';
+ element.setSelectionRange(0, 0);
+ element.type = 'number';
+ } else {
+ element.setSelectionRange(0, 0);
+ }
+ }
+
+ this.focused = isFocused;
+ this.stateChanges.next();
}
_onInput() {
@@ -481,7 +518,7 @@ export class MatInput
!!(selectElement.selectedIndex > -1 && firstOption && firstOption.label)
);
} else {
- return this.focused || !this.empty;
+ return (this.focused && !this.disabled) || !this.empty;
}
}
@@ -566,4 +603,17 @@ export class MatInput
this._webkitBlinkWheelListenerAttached = true;
}
}
+
+ /** Gets the value to set on the `readonly` attribute. */
+ protected _getReadonlyAttribute(): string | null {
+ if (this._isNativeSelect) {
+ return null;
+ }
+
+ if (this.readonly || (this.disabled && this.disabledInteractive)) {
+ return 'true';
+ }
+
+ return null;
+ }
}
diff --git a/src/material/input/public-api.ts b/src/material/input/public-api.ts
index 175e097010ff..259f8af7fe98 100644
--- a/src/material/input/public-api.ts
+++ b/src/material/input/public-api.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-export {MatInput} from './input';
+export {MatInput, MatInputConfig, MAT_INPUT_CONFIG} from './input';
export {MatInputModule} from './module';
export * from './input-value-accessor';
export * from './input-errors';
diff --git a/src/material/input/testing/input-harness.spec.ts b/src/material/input/testing/input-harness.spec.ts
index 4cc75f52117f..0224d6dc2500 100644
--- a/src/material/input/testing/input-harness.spec.ts
+++ b/src/material/input/testing/input-harness.spec.ts
@@ -220,6 +220,17 @@ describe('MatInputHarness', () => {
await input.setValue('#00ff00');
expect((await input.getValue()).toLowerCase()).toBe('#00ff00');
});
+
+ it('should be able to get disabled state when disabledInteractive is enabled', async () => {
+ const input = (await loader.getAllHarnesses(MatInputHarness))[1];
+
+ fixture.componentInstance.disabled.set(false);
+ fixture.componentInstance.disabledInteractive.set(true);
+ expect(await input.isDisabled()).toBe(false);
+
+ fixture.componentInstance.disabled.set(true);
+ expect(await input.isDisabled()).toBe(true);
+ });
});
@Component({
@@ -229,10 +240,13 @@ describe('MatInputHarness', () => {
-
+
@@ -272,6 +286,7 @@ class InputHarnessTest {
inputType = signal('number');
readonly = signal(false);
disabled = signal(false);
+ disabledInteractive = signal(false);
required = signal(false);
ngModelValue = '';
ngModelName = 'has-ng-model';
diff --git a/src/material/input/testing/input-harness.ts b/src/material/input/testing/input-harness.ts
index 7e52cd0ef015..f21c9e55287b 100644
--- a/src/material/input/testing/input-harness.ts
+++ b/src/material/input/testing/input-harness.ts
@@ -8,6 +8,7 @@
import {HarnessPredicate, parallel} from '@angular/cdk/testing';
import {MatFormFieldControlHarness} from '@angular/material/form-field/testing/control';
+import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {InputHarnessFilters} from './input-harness-filters';
/** Harness for interacting with a standard Material inputs in tests. */
@@ -35,7 +36,14 @@ export class MatInputHarness extends MatFormFieldControlHarness {
/** Whether the input is disabled. */
async isDisabled(): Promise {
- return (await this.host()).getProperty('disabled');
+ const host = await this.host();
+ const disabled = await host.getAttribute('disabled');
+
+ if (disabled !== null) {
+ return coerceBooleanProperty(disabled);
+ }
+
+ return (await host.getAttribute('aria-disabled')) === 'true';
}
/** Whether the input is required. */
diff --git a/tools/public_api_guard/material/input.md b/tools/public_api_guard/material/input.md
index 36ff273b47a2..296715879191 100644
--- a/tools/public_api_guard/material/input.md
+++ b/tools/public_api_guard/material/input.md
@@ -34,6 +34,9 @@ import { Subject } from 'rxjs';
// @public
export function getMatInputUnsupportedTypeError(type: string): Error;
+// @public
+export const MAT_INPUT_CONFIG: InjectionToken;
+
// @public
export const MAT_INPUT_VALUE_ACCESSOR: InjectionToken<{
value: any;
@@ -55,6 +58,7 @@ export class MatInput implements MatFormFieldControl, OnChanges, OnDestroy,
set disabled(value: BooleanInput);
// (undocumented)
protected _disabled: boolean;
+ disabledInteractive: boolean;
// (undocumented)
protected _elementRef: ElementRef;
get empty(): boolean;
@@ -68,6 +72,7 @@ export class MatInput implements MatFormFieldControl, OnChanges, OnDestroy,
// (undocumented)
protected _formField?: MatFormField | undefined;
protected _getPlaceholder(): string | null;
+ protected _getReadonlyAttribute(): string | null;
get id(): string;
set id(value: string);
// (undocumented)
@@ -83,6 +88,8 @@ export class MatInput implements MatFormFieldControl, OnChanges, OnDestroy,
// (undocumented)
protected _neverEmptyInputTypes: string[];
// (undocumented)
+ static ngAcceptInputType_disabledInteractive: unknown;
+ // (undocumented)
ngAfterViewInit(): void;
// (undocumented)
ngControl: NgControl;
@@ -121,11 +128,16 @@ export class MatInput implements MatFormFieldControl, OnChanges, OnDestroy,
get value(): string;
set value(value: any);
// (undocumented)
- static ɵdir: i0.ɵɵDirectiveDeclaration;
+ static ɵdir: i0.ɵɵDirectiveDeclaration;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration;
}
+// @public
+export interface MatInputConfig {
+ disabledInteractive?: boolean;
+}
+
// @public (undocumented)
export class MatInputModule {
// (undocumented)