Skip to content

Commit c1c6444

Browse files
committed
feat(material/input): add the ability to interact with disabled inputs
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 ec35e99 commit c1c6444

File tree

8 files changed

+254
-29
lines changed

8 files changed

+254
-29
lines changed

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,73 @@ <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+
<div>
718+
<mat-form-field>
719+
<mat-label>Label</mat-label>
720+
<input
721+
matNativeControl
722+
disabled
723+
disabledInteractive
724+
value="Value"
725+
matTooltip="I can trigger a tooltip!">
726+
</mat-form-field>
727+
728+
<mat-form-field>
729+
<mat-label>Label</mat-label>
730+
<input
731+
matNativeControl
732+
disabled
733+
disabledInteractive
734+
matTooltip="I can trigger a tooltip!">
735+
</mat-form-field>
736+
737+
<mat-form-field>
738+
<mat-label>Label</mat-label>
739+
<input
740+
matNativeControl
741+
disabled
742+
disabledInteractive
743+
placeholder="Placeholder"
744+
matTooltip="I can trigger a tooltip!">
745+
</mat-form-field>
746+
</div>
747+
748+
<div>
749+
<mat-form-field appearance="outline">
750+
<mat-label>Label</mat-label>
751+
<input
752+
matNativeControl
753+
disabled
754+
disabledInteractive
755+
value="Value"
756+
matTooltip="I can trigger a tooltip!">
757+
</mat-form-field>
758+
759+
<mat-form-field appearance="outline">
760+
<mat-label>Label</mat-label>
761+
<input
762+
matNativeControl
763+
disabled
764+
disabledInteractive
765+
matTooltip="I can trigger a tooltip!">
766+
</mat-form-field>
767+
768+
<mat-form-field appearance="outline">
769+
<mat-label>Label</mat-label>
770+
<input
771+
matNativeControl
772+
disabled
773+
disabledInteractive
774+
placeholder="Placeholder"
775+
matTooltip="I can trigger a tooltip!">
776+
</mat-form-field>
777+
</div>
778+
</mat-card-content>
779+
</mat-card>
780+
714781
<mat-card class="demo-card demo-basic">
715782
<mat-toolbar color="primary">Textarea form-fields</mat-toolbar>
716783
<mat-card-content>

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@
6565
opacity: 0;
6666
}
6767

68-
.mdc-text-field--no-label &,
69-
.mdc-text-field--focused & {
68+
.mdc-text-field--no-label:not(.mdc-text-field--disabled) &,
69+
.mdc-text-field--focused:not(.mdc-text-field--disabled) & {
7070
@include vendor-prefixes.input-placeholder {
7171
opacity: 1;
7272
}

src/material/input/input.spec.ts

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,63 @@ 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+
416+
fixture.componentInstance.disabledInteractive = true;
417+
fixture.changeDetectorRef.markForCheck();
418+
fixture.detectChanges();
419+
420+
expect(input.disabled).toBe(false);
421+
expect(input.readOnly).toBe(true);
422+
expect(input.getAttribute('aria-disabled')).toBe('true');
423+
}));
424+
425+
it('should not float the label when disabled and disabledInteractive are set', fakeAsync(() => {
426+
const fixture = createComponent(MatInputTextTestController);
427+
fixture.componentInstance.disabled = fixture.componentInstance.disabledInteractive = true;
428+
fixture.detectChanges();
429+
430+
const label = fixture.nativeElement.querySelector('label');
431+
const input = fixture.debugElement
432+
.query(By.directive(MatInput))!
433+
.injector.get<MatInput>(MatInput);
434+
435+
expect(label.classList).not.toContain('mdc-floating-label--float-above');
436+
437+
// Call the focus handler directly to avoid flakyness where
438+
// browsers don't focus elements if the window is minimized.
439+
input._focusChanged(true);
440+
fixture.detectChanges();
441+
442+
expect(label.classList).not.toContain('mdc-floating-label--float-above');
443+
}));
444+
445+
it('should float the label when disabledInteractive is set and the input has a value', fakeAsync(() => {
446+
const fixture = createComponent(MatInputWithDynamicLabel);
447+
fixture.componentInstance.shouldFloat = 'auto';
448+
fixture.componentInstance.disabled = fixture.componentInstance.disabledInteractive = true;
449+
fixture.detectChanges();
450+
451+
const input = fixture.nativeElement.querySelector('input');
452+
const label = fixture.nativeElement.querySelector('label');
453+
454+
expect(label.classList).not.toContain('mdc-floating-label--float-above');
455+
456+
input.value = 'Text';
457+
dispatchFakeEvent(input, 'input');
458+
fixture.detectChanges();
459+
460+
expect(label.classList).toContain('mdc-floating-label--float-above');
461+
}));
462+
406463
it('supports the disabled attribute as binding for select', fakeAsync(() => {
407464
const fixture = createComponent(MatInputSelect);
408465
fixture.detectChanges();
@@ -719,16 +776,13 @@ describe('MatMdcInput without forms', () => {
719776
expect(labelEl.classList).not.toContain('mdc-floating-label--float-above');
720777
}));
721778

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();
779+
it('should not float labels when select has no value, no option label, no option innerHtml', fakeAsync(() => {
780+
const fixture = createComponent(MatInputSelectWithNoLabelNoValue);
781+
fixture.detectChanges();
727782

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

733787
it('should floating labels when select has no value but has option label', fakeAsync(() => {
734788
const fixture = createComponent(MatInputSelectWithLabel);
@@ -1532,6 +1586,7 @@ describe('MatFormField default options', () => {
15321586
).toBe(true);
15331587
});
15341588
});
1589+
15351590
describe('MatFormField without label', () => {
15361591
it('should not float the label when no label is defined.', () => {
15371592
let fixture = createComponent(MatInputWithoutDefinedLabel);
@@ -1650,10 +1705,15 @@ class MatInputWithId {
16501705
}
16511706

16521707
@Component({
1653-
template: `<mat-form-field><input matInput [disabled]="disabled"></mat-form-field>`,
1708+
template: `
1709+
<mat-form-field>
1710+
<input matInput [disabled]="disabled" [disabledInteractive]="disabledInteractive">
1711+
</mat-form-field>
1712+
`,
16541713
})
16551714
class MatInputWithDisabled {
1656-
disabled: boolean;
1715+
disabled = false;
1716+
disabledInteractive = false;
16571717
}
16581718

16591719
@Component({
@@ -1783,10 +1843,18 @@ class MatInputDateTestController {}
17831843
template: `
17841844
<mat-form-field>
17851845
<mat-label>Label</mat-label>
1786-
<input matInput type="text" placeholder="Placeholder">
1846+
<input
1847+
matInput
1848+
type="text"
1849+
placeholder="Placeholder"
1850+
[disabled]="disabled"
1851+
[disabledInteractive]="disabledInteractive">
17871852
</mat-form-field>`,
17881853
})
1789-
class MatInputTextTestController {}
1854+
class MatInputTextTestController {
1855+
disabled = false;
1856+
disabledInteractive = false;
1857+
}
17901858

17911859
@Component({
17921860
template: `
@@ -1837,11 +1905,17 @@ class MatInputWithStaticLabel {}
18371905
template: `
18381906
<mat-form-field [floatLabel]="shouldFloat">
18391907
<mat-label>Label</mat-label>
1840-
<input matInput placeholder="Placeholder">
1908+
<input
1909+
matInput
1910+
placeholder="Placeholder"
1911+
[disabled]="disabled"
1912+
[disabledInteractive]="disabledInteractive">
18411913
</mat-form-field>`,
18421914
})
18431915
class MatInputWithDynamicLabel {
18441916
shouldFloat: 'always' | 'auto' = 'always';
1917+
disabled = false;
1918+
disabledInteractive = false;
18451919
}
18461920

18471921
@Component({

src/material/input/input.ts

Lines changed: 55 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]`,
@@ -61,10 +73,11 @@ let nextUniqueId = 0;
6173
// Native input properties that are overwritten by Angular inputs need to be synced with
6274
// the native input element. Otherwise property bindings for those don't work.
6375
'[id]': 'id',
64-
'[disabled]': 'disabled',
76+
'[disabled]': 'disabled && !disabledInteractive',
6577
'[required]': 'required',
6678
'[attr.name]': 'name || null',
67-
'[attr.readonly]': 'readonly && !_isNativeSelect || null',
79+
'[attr.readonly]': '_getReadonlyAttribute()',
80+
'[attr.aria-disabled]': 'disabled && disabledInteractive ? "true" : null',
6881
// Only mark the input as invalid for assistive technology if it has a value since the
6982
// state usually overlaps with `aria-required` when the input is empty and can be redundant.
7083
'[attr.aria-invalid]': '(empty && required) ? null : errorState',
@@ -88,6 +101,7 @@ export class MatInput
88101
private _previousPlaceholder: string | null;
89102
private _errorStateTracker: _ErrorStateTracker;
90103
private _webkitBlinkWheelListenerAttached = false;
104+
private _config = inject(MAT_INPUT_CONFIG, {optional: true});
91105

92106
/** Whether the component is being rendered on the server. */
93107
readonly _isServer: boolean;
@@ -243,6 +257,10 @@ export class MatInput
243257
}
244258
private _readonly = false;
245259

260+
/** Whether the input should remain interactive when it is disabled. */
261+
@Input({transform: booleanAttribute})
262+
disabledInteractive: boolean;
263+
246264
/** Whether the input is in an error state. */
247265
get errorState() {
248266
return this._errorStateTracker.errorState;
@@ -306,6 +324,7 @@ export class MatInput
306324
this._isNativeSelect = nodeName === 'select';
307325
this._isTextarea = nodeName === 'textarea';
308326
this._isInFormField = !!_formField;
327+
this.disabledInteractive = this._config?.disabledInteractive || false;
309328

310329
if (this._isNativeSelect) {
311330
this.controlType = (element as HTMLSelectElement).multiple
@@ -382,10 +401,27 @@ export class MatInput
382401

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

391427
_onInput() {
@@ -481,7 +517,7 @@ export class MatInput
481517
!!(selectElement.selectedIndex > -1 && firstOption && firstOption.label)
482518
);
483519
} else {
484-
return this.focused || !this.empty;
520+
return (this.focused && !this.disabled) || !this.empty;
485521
}
486522
}
487523

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

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)