From 1abb484aa72177a748eecdf9b850cc1c07d1a42b Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 14 Aug 2024 20:38:27 +0200 Subject: [PATCH] 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`. --- src/dev-app/input/input-demo.html | 47 ++++++++ src/dev-app/input/input-demo.ts | 1 + .../form-field/_mdc-text-field-structure.scss | 6 + src/material/input/input.spec.ts | 104 +++++++++++++++--- src/material/input/input.ts | 62 ++++++++++- src/material/input/public-api.ts | 2 +- .../input/testing/input-harness.spec.ts | 23 +++- src/material/input/testing/input-harness.ts | 10 +- tools/public_api_guard/material/input.md | 14 ++- 9 files changed, 242 insertions(+), 27 deletions(-) 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)