Skip to content

Commit 1abb484

Browse files
authoredAug 14, 2024··
feat(material/input): add the ability to interact with disabled inputs (#29574)
Adds the `disabledInteractive` input to `MatInput` which allows users to opt into having disabled input receive focus and dispatch events. Changing the value is prevented through the `readonly` attribute while disabled state is conveyed via `aria-disabled`.
1 parent a5be6cc commit 1abb484

File tree

9 files changed

+242
-27
lines changed

9 files changed

+242
-27
lines changed
 

‎src/dev-app/input/input-demo.html

+47
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,53 @@ <h3>&lt;textarea&gt; with bindable autosize </h3>
711711
</mat-card-content>
712712
</mat-card>
713713

714+
<mat-card class="demo-card demo-basic">
715+
<mat-toolbar color="primary">Disabled interactive inputs</mat-toolbar>
716+
<mat-card-content>
717+
@for (appearance of appearances; track $index) {
718+
<div>
719+
<mat-form-field [appearance]="appearance">
720+
<mat-label>Label</mat-label>
721+
<input
722+
matNativeControl
723+
disabled
724+
disabledInteractive
725+
value="Value"
726+
matTooltip="I can trigger a tooltip!">
727+
</mat-form-field>
728+
729+
<mat-form-field [appearance]="appearance">
730+
<mat-label>Label</mat-label>
731+
<input
732+
matNativeControl
733+
disabled
734+
disabledInteractive
735+
matTooltip="I can trigger a tooltip!">
736+
</mat-form-field>
737+
738+
<mat-form-field [appearance]="appearance">
739+
<mat-label>Label</mat-label>
740+
<input
741+
matNativeControl
742+
disabled
743+
disabledInteractive
744+
placeholder="Placeholder"
745+
matTooltip="I can trigger a tooltip!">
746+
</mat-form-field>
747+
748+
<mat-form-field [appearance]="appearance">
749+
<input
750+
matNativeControl
751+
disabled
752+
disabledInteractive
753+
matTooltip="I can trigger a tooltip!"
754+
placeholder="Placeholder">
755+
</mat-form-field>
756+
</div>
757+
}
758+
</mat-card-content>
759+
</mat-card>
760+
714761
<mat-card class="demo-card demo-basic">
715762
<mat-toolbar color="primary">Textarea form-fields</mat-toolbar>
716763
<mat-card-content>

‎src/dev-app/input/input-demo.ts

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export class InputDemo {
100100
standardAppearance: string;
101101
fillAppearance: string;
102102
outlineAppearance: string;
103+
appearances: MatFormFieldAppearance[] = ['fill', 'outline'];
103104

104105
hasLabel$ = new BehaviorSubject(true);
105106

‎src/material/form-field/_mdc-text-field-structure.scss

+6
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@
7272
}
7373
}
7474

75+
.mdc-text-field--disabled:not(.mdc-text-field--no-label) &.mat-mdc-input-disabled-interactive {
76+
@include vendor-prefixes.input-placeholder {
77+
opacity: 0;
78+
}
79+
}
80+
7581
.mdc-text-field--outlined &,
7682
.mdc-text-field--filled.mdc-text-field--no-label & {
7783
height: 100%;

‎src/material/input/input.spec.ts

+90-14
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,65 @@ describe('MatMdcInput without forms', () => {
403403
expect(inputEl.disabled).toBe(true);
404404
}));
405405

406+
it('should be able to set an input as being disabled and interactive', fakeAsync(() => {
407+
const fixture = createComponent(MatInputWithDisabled);
408+
fixture.componentInstance.disabled = true;
409+
fixture.detectChanges();
410+
411+
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
412+
expect(input.disabled).toBe(true);
413+
expect(input.readOnly).toBe(false);
414+
expect(input.hasAttribute('aria-disabled')).toBe(false);
415+
expect(input.classList).not.toContain('mat-mdc-input-disabled-interactive');
416+
417+
fixture.componentInstance.disabledInteractive = true;
418+
fixture.changeDetectorRef.markForCheck();
419+
fixture.detectChanges();
420+
421+
expect(input.disabled).toBe(false);
422+
expect(input.readOnly).toBe(true);
423+
expect(input.getAttribute('aria-disabled')).toBe('true');
424+
expect(input.classList).toContain('mat-mdc-input-disabled-interactive');
425+
}));
426+
427+
it('should not float the label when disabled and disabledInteractive are set', fakeAsync(() => {
428+
const fixture = createComponent(MatInputTextTestController);
429+
fixture.componentInstance.disabled = fixture.componentInstance.disabledInteractive = true;
430+
fixture.detectChanges();
431+
432+
const label = fixture.nativeElement.querySelector('label');
433+
const input = fixture.debugElement
434+
.query(By.directive(MatInput))!
435+
.injector.get<MatInput>(MatInput);
436+
437+
expect(label.classList).not.toContain('mdc-floating-label--float-above');
438+
439+
// Call the focus handler directly to avoid flakyness where
440+
// browsers don't focus elements if the window is minimized.
441+
input._focusChanged(true);
442+
fixture.detectChanges();
443+
444+
expect(label.classList).not.toContain('mdc-floating-label--float-above');
445+
}));
446+
447+
it('should float the label when disabledInteractive is set and the input has a value', fakeAsync(() => {
448+
const fixture = createComponent(MatInputWithDynamicLabel);
449+
fixture.componentInstance.shouldFloat = 'auto';
450+
fixture.componentInstance.disabled = fixture.componentInstance.disabledInteractive = true;
451+
fixture.detectChanges();
452+
453+
const input = fixture.nativeElement.querySelector('input');
454+
const label = fixture.nativeElement.querySelector('label');
455+
456+
expect(label.classList).not.toContain('mdc-floating-label--float-above');
457+
458+
input.value = 'Text';
459+
dispatchFakeEvent(input, 'input');
460+
fixture.detectChanges();
461+
462+
expect(label.classList).toContain('mdc-floating-label--float-above');
463+
}));
464+
406465
it('supports the disabled attribute as binding for select', fakeAsync(() => {
407466
const fixture = createComponent(MatInputSelect);
408467
fixture.detectChanges();
@@ -719,16 +778,13 @@ describe('MatMdcInput without forms', () => {
719778
expect(labelEl.classList).not.toContain('mdc-floating-label--float-above');
720779
}));
721780

722-
it(
723-
'should not float labels when select has no value, no option label, ' + 'no option innerHtml',
724-
fakeAsync(() => {
725-
const fixture = createComponent(MatInputSelectWithNoLabelNoValue);
726-
fixture.detectChanges();
781+
it('should not float labels when select has no value, no option label, no option innerHtml', fakeAsync(() => {
782+
const fixture = createComponent(MatInputSelectWithNoLabelNoValue);
783+
fixture.detectChanges();
727784

728-
const labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement;
729-
expect(labelEl.classList).not.toContain('mdc-floating-label--float-above');
730-
}),
731-
);
785+
const labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement;
786+
expect(labelEl.classList).not.toContain('mdc-floating-label--float-above');
787+
}));
732788

733789
it('should floating labels when select has no value but has option label', fakeAsync(() => {
734790
const fixture = createComponent(MatInputSelectWithLabel);
@@ -1532,6 +1588,7 @@ describe('MatFormField default options', () => {
15321588
).toBe(true);
15331589
});
15341590
});
1591+
15351592
describe('MatFormField without label', () => {
15361593
it('should not float the label when no label is defined.', () => {
15371594
let fixture = createComponent(MatInputWithoutDefinedLabel);
@@ -1650,10 +1707,15 @@ class MatInputWithId {
16501707
}
16511708

16521709
@Component({
1653-
template: `<mat-form-field><input matInput [disabled]="disabled"></mat-form-field>`,
1710+
template: `
1711+
<mat-form-field>
1712+
<input matInput [disabled]="disabled" [disabledInteractive]="disabledInteractive">
1713+
</mat-form-field>
1714+
`,
16541715
})
16551716
class MatInputWithDisabled {
1656-
disabled: boolean;
1717+
disabled = false;
1718+
disabledInteractive = false;
16571719
}
16581720

16591721
@Component({
@@ -1783,10 +1845,18 @@ class MatInputDateTestController {}
17831845
template: `
17841846
<mat-form-field>
17851847
<mat-label>Label</mat-label>
1786-
<input matInput type="text" placeholder="Placeholder">
1848+
<input
1849+
matInput
1850+
type="text"
1851+
placeholder="Placeholder"
1852+
[disabled]="disabled"
1853+
[disabledInteractive]="disabledInteractive">
17871854
</mat-form-field>`,
17881855
})
1789-
class MatInputTextTestController {}
1856+
class MatInputTextTestController {
1857+
disabled = false;
1858+
disabledInteractive = false;
1859+
}
17901860

17911861
@Component({
17921862
template: `
@@ -1837,11 +1907,17 @@ class MatInputWithStaticLabel {}
18371907
template: `
18381908
<mat-form-field [floatLabel]="shouldFloat">
18391909
<mat-label>Label</mat-label>
1840-
<input matInput placeholder="Placeholder">
1910+
<input
1911+
matInput
1912+
placeholder="Placeholder"
1913+
[disabled]="disabled"
1914+
[disabledInteractive]="disabledInteractive">
18411915
</mat-form-field>`,
18421916
})
18431917
class MatInputWithDynamicLabel {
18441918
shouldFloat: 'always' | 'auto' = 'always';
1919+
disabled = false;
1920+
disabledInteractive = false;
18451921
}
18461922

18471923
@Component({

‎src/material/input/input.ts

+56-6
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import {getSupportedInputTypes, Platform} from '@angular/cdk/platform';
1111
import {AutofillMonitor} from '@angular/cdk/text-field';
1212
import {
1313
AfterViewInit,
14+
booleanAttribute,
1415
Directive,
1516
DoCheck,
1617
ElementRef,
18+
inject,
1719
Inject,
20+
InjectionToken,
1821
Input,
1922
NgZone,
2023
OnChanges,
@@ -44,6 +47,15 @@ const MAT_INPUT_INVALID_TYPES = [
4447

4548
let nextUniqueId = 0;
4649

50+
/** Object that can be used to configure the default options for the input. */
51+
export interface MatInputConfig {
52+
/** Whether disabled inputs should be interactive. */
53+
disabledInteractive?: boolean;
54+
}
55+
56+
/** Injection token that can be used to provide the default options for the input. */
57+
export const MAT_INPUT_CONFIG = new InjectionToken<MatInputConfig>('MAT_INPUT_CONFIG');
58+
4759
@Directive({
4860
selector: `input[matInput], textarea[matInput], select[matNativeControl],
4961
input[matNativeControl], textarea[matNativeControl]`,
@@ -56,15 +68,17 @@ let nextUniqueId = 0;
5668
'[class.mat-input-server]': '_isServer',
5769
'[class.mat-mdc-form-field-textarea-control]': '_isInFormField && _isTextarea',
5870
'[class.mat-mdc-form-field-input-control]': '_isInFormField',
71+
'[class.mat-mdc-input-disabled-interactive]': 'disabledInteractive',
5972
'[class.mdc-text-field__input]': '_isInFormField',
6073
'[class.mat-mdc-native-select-inline]': '_isInlineSelect()',
6174
// Native input properties that are overwritten by Angular inputs need to be synced with
6275
// the native input element. Otherwise property bindings for those don't work.
6376
'[id]': 'id',
64-
'[disabled]': 'disabled',
77+
'[disabled]': 'disabled && !disabledInteractive',
6578
'[required]': 'required',
6679
'[attr.name]': 'name || null',
67-
'[attr.readonly]': 'readonly && !_isNativeSelect || null',
80+
'[attr.readonly]': '_getReadonlyAttribute()',
81+
'[attr.aria-disabled]': 'disabled && disabledInteractive ? "true" : null',
6882
// Only mark the input as invalid for assistive technology if it has a value since the
6983
// state usually overlaps with `aria-required` when the input is empty and can be redundant.
7084
'[attr.aria-invalid]': '(empty && required) ? null : errorState',
@@ -88,6 +102,7 @@ export class MatInput
88102
private _previousPlaceholder: string | null;
89103
private _errorStateTracker: _ErrorStateTracker;
90104
private _webkitBlinkWheelListenerAttached = false;
105+
private _config = inject(MAT_INPUT_CONFIG, {optional: true});
91106

92107
/** Whether the component is being rendered on the server. */
93108
readonly _isServer: boolean;
@@ -243,6 +258,10 @@ export class MatInput
243258
}
244259
private _readonly = false;
245260

261+
/** Whether the input should remain interactive when it is disabled. */
262+
@Input({transform: booleanAttribute})
263+
disabledInteractive: boolean;
264+
246265
/** Whether the input is in an error state. */
247266
get errorState() {
248267
return this._errorStateTracker.errorState;
@@ -306,6 +325,7 @@ export class MatInput
306325
this._isNativeSelect = nodeName === 'select';
307326
this._isTextarea = nodeName === 'textarea';
308327
this._isInFormField = !!_formField;
328+
this.disabledInteractive = this._config?.disabledInteractive || false;
309329

310330
if (this._isNativeSelect) {
311331
this.controlType = (element as HTMLSelectElement).multiple
@@ -382,10 +402,27 @@ export class MatInput
382402

383403
/** Callback for the cases where the focused state of the input changes. */
384404
_focusChanged(isFocused: boolean) {
385-
if (isFocused !== this.focused) {
386-
this.focused = isFocused;
387-
this.stateChanges.next();
405+
if (isFocused === this.focused) {
406+
return;
388407
}
408+
409+
if (!this._isNativeSelect && isFocused && this.disabled && this.disabledInteractive) {
410+
const element = this._elementRef.nativeElement as HTMLInputElement;
411+
412+
// Focusing an input that has text will cause all the text to be selected. Clear it since
413+
// the user won't be able to change it. This is based on the internal implementation.
414+
if (element.type === 'number') {
415+
// setSelectionRange doesn't work on number inputs so it needs to be set briefly to text.
416+
element.type = 'text';
417+
element.setSelectionRange(0, 0);
418+
element.type = 'number';
419+
} else {
420+
element.setSelectionRange(0, 0);
421+
}
422+
}
423+
424+
this.focused = isFocused;
425+
this.stateChanges.next();
389426
}
390427

391428
_onInput() {
@@ -481,7 +518,7 @@ export class MatInput
481518
!!(selectElement.selectedIndex > -1 && firstOption && firstOption.label)
482519
);
483520
} else {
484-
return this.focused || !this.empty;
521+
return (this.focused && !this.disabled) || !this.empty;
485522
}
486523
}
487524

@@ -566,4 +603,17 @@ export class MatInput
566603
this._webkitBlinkWheelListenerAttached = true;
567604
}
568605
}
606+
607+
/** Gets the value to set on the `readonly` attribute. */
608+
protected _getReadonlyAttribute(): string | null {
609+
if (this._isNativeSelect) {
610+
return null;
611+
}
612+
613+
if (this.readonly || (this.disabled && this.disabledInteractive)) {
614+
return 'true';
615+
}
616+
617+
return null;
618+
}
569619
}

‎src/material/input/public-api.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
export {MatInput} from './input';
9+
export {MatInput, MatInputConfig, MAT_INPUT_CONFIG} from './input';
1010
export {MatInputModule} from './module';
1111
export * from './input-value-accessor';
1212
export * from './input-errors';

‎src/material/input/testing/input-harness.spec.ts

+19-4
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,17 @@ describe('MatInputHarness', () => {
220220
await input.setValue('#00ff00');
221221
expect((await input.getValue()).toLowerCase()).toBe('#00ff00');
222222
});
223+
224+
it('should be able to get disabled state when disabledInteractive is enabled', async () => {
225+
const input = (await loader.getAllHarnesses(MatInputHarness))[1];
226+
227+
fixture.componentInstance.disabled.set(false);
228+
fixture.componentInstance.disabledInteractive.set(true);
229+
expect(await input.isDisabled()).toBe(false);
230+
231+
fixture.componentInstance.disabled.set(true);
232+
expect(await input.isDisabled()).toBe(true);
233+
});
223234
});
224235

225236
@Component({
@@ -229,10 +240,13 @@ describe('MatInputHarness', () => {
229240
</mat-form-field>
230241
231242
<mat-form-field>
232-
<input matInput [type]="inputType()"
233-
[readonly]="readonly()"
234-
[disabled]="disabled()"
235-
[required]="required()">
243+
<input
244+
matInput
245+
[type]="inputType()"
246+
[readonly]="readonly()"
247+
[disabled]="disabled()"
248+
[disabledInteractive]="disabledInteractive()"
249+
[required]="required()">
236250
</mat-form-field>
237251
238252
<mat-form-field>
@@ -272,6 +286,7 @@ class InputHarnessTest {
272286
inputType = signal('number');
273287
readonly = signal(false);
274288
disabled = signal(false);
289+
disabledInteractive = signal(false);
275290
required = signal(false);
276291
ngModelValue = '';
277292
ngModelName = 'has-ng-model';

‎src/material/input/testing/input-harness.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {HarnessPredicate, parallel} from '@angular/cdk/testing';
1010
import {MatFormFieldControlHarness} from '@angular/material/form-field/testing/control';
11+
import {coerceBooleanProperty} from '@angular/cdk/coercion';
1112
import {InputHarnessFilters} from './input-harness-filters';
1213

1314
/** Harness for interacting with a standard Material inputs in tests. */
@@ -35,7 +36,14 @@ export class MatInputHarness extends MatFormFieldControlHarness {
3536

3637
/** Whether the input is disabled. */
3738
async isDisabled(): Promise<boolean> {
38-
return (await this.host()).getProperty<boolean>('disabled');
39+
const host = await this.host();
40+
const disabled = await host.getAttribute('disabled');
41+
42+
if (disabled !== null) {
43+
return coerceBooleanProperty(disabled);
44+
}
45+
46+
return (await host.getAttribute('aria-disabled')) === 'true';
3947
}
4048

4149
/** Whether the input is required. */

‎tools/public_api_guard/material/input.md

+13-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ import { Subject } from 'rxjs';
3434
// @public
3535
export function getMatInputUnsupportedTypeError(type: string): Error;
3636

37+
// @public
38+
export const MAT_INPUT_CONFIG: InjectionToken<MatInputConfig>;
39+
3740
// @public
3841
export const MAT_INPUT_VALUE_ACCESSOR: InjectionToken<{
3942
value: any;
@@ -55,6 +58,7 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
5558
set disabled(value: BooleanInput);
5659
// (undocumented)
5760
protected _disabled: boolean;
61+
disabledInteractive: boolean;
5862
// (undocumented)
5963
protected _elementRef: ElementRef<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>;
6064
get empty(): boolean;
@@ -68,6 +72,7 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
6872
// (undocumented)
6973
protected _formField?: MatFormField | undefined;
7074
protected _getPlaceholder(): string | null;
75+
protected _getReadonlyAttribute(): string | null;
7176
get id(): string;
7277
set id(value: string);
7378
// (undocumented)
@@ -83,6 +88,8 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
8388
// (undocumented)
8489
protected _neverEmptyInputTypes: string[];
8590
// (undocumented)
91+
static ngAcceptInputType_disabledInteractive: unknown;
92+
// (undocumented)
8693
ngAfterViewInit(): void;
8794
// (undocumented)
8895
ngControl: NgControl;
@@ -121,11 +128,16 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
121128
get value(): string;
122129
set value(value: any);
123130
// (undocumented)
124-
static ɵdir: i0.ɵɵDirectiveDeclaration<MatInput, "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", ["matInput"], { "disabled": { "alias": "disabled"; "required": false; }; "id": { "alias": "id"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "name": { "alias": "name"; "required": false; }; "required": { "alias": "required"; "required": false; }; "type": { "alias": "type"; "required": false; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; }; "userAriaDescribedBy": { "alias": "aria-describedby"; "required": false; }; "value": { "alias": "value"; "required": false; }; "readonly": { "alias": "readonly"; "required": false; }; }, {}, never, never, true, never>;
131+
static ɵdir: i0.ɵɵDirectiveDeclaration<MatInput, "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", ["matInput"], { "disabled": { "alias": "disabled"; "required": false; }; "id": { "alias": "id"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "name": { "alias": "name"; "required": false; }; "required": { "alias": "required"; "required": false; }; "type": { "alias": "type"; "required": false; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; }; "userAriaDescribedBy": { "alias": "aria-describedby"; "required": false; }; "value": { "alias": "value"; "required": false; }; "readonly": { "alias": "readonly"; "required": false; }; "disabledInteractive": { "alias": "disabledInteractive"; "required": false; }; }, {}, never, never, true, never>;
125132
// (undocumented)
126133
static ɵfac: i0.ɵɵFactoryDeclaration<MatInput, [null, null, { optional: true; self: true; }, { optional: true; }, { optional: true; }, null, { optional: true; self: true; }, null, null, { optional: true; }]>;
127134
}
128135

136+
// @public
137+
export interface MatInputConfig {
138+
disabledInteractive?: boolean;
139+
}
140+
129141
// @public (undocumented)
130142
export class MatInputModule {
131143
// (undocumented)

0 commit comments

Comments
 (0)
Please sign in to comment.