Skip to content

Commit 1abb484

Browse files
authored
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

Lines changed: 47 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 6 additions & 0 deletions
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

Lines changed: 90 additions & 14 deletions
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

Lines changed: 56 additions & 6 deletions
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

Lines changed: 1 addition & 1 deletion
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';

0 commit comments

Comments
 (0)