Skip to content

Commit

Permalink
feat(material/input): add the ability to interact with disabled inputs (
Browse files Browse the repository at this point in the history
angular#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`.
  • Loading branch information
crisbeto authored Aug 14, 2024
1 parent a5be6cc commit 1abb484
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 27 deletions.
47 changes: 47 additions & 0 deletions src/dev-app/input/input-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,53 @@ <h3>&lt;textarea&gt; with bindable autosize </h3>
</mat-card-content>
</mat-card>

<mat-card class="demo-card demo-basic">
<mat-toolbar color="primary">Disabled interactive inputs</mat-toolbar>
<mat-card-content>
@for (appearance of appearances; track $index) {
<div>
<mat-form-field [appearance]="appearance">
<mat-label>Label</mat-label>
<input
matNativeControl
disabled
disabledInteractive
value="Value"
matTooltip="I can trigger a tooltip!">
</mat-form-field>

<mat-form-field [appearance]="appearance">
<mat-label>Label</mat-label>
<input
matNativeControl
disabled
disabledInteractive
matTooltip="I can trigger a tooltip!">
</mat-form-field>

<mat-form-field [appearance]="appearance">
<mat-label>Label</mat-label>
<input
matNativeControl
disabled
disabledInteractive
placeholder="Placeholder"
matTooltip="I can trigger a tooltip!">
</mat-form-field>

<mat-form-field [appearance]="appearance">
<input
matNativeControl
disabled
disabledInteractive
matTooltip="I can trigger a tooltip!"
placeholder="Placeholder">
</mat-form-field>
</div>
}
</mat-card-content>
</mat-card>

<mat-card class="demo-card demo-basic">
<mat-toolbar color="primary">Textarea form-fields</mat-toolbar>
<mat-card-content>
Expand Down
1 change: 1 addition & 0 deletions src/dev-app/input/input-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export class InputDemo {
standardAppearance: string;
fillAppearance: string;
outlineAppearance: string;
appearances: MatFormFieldAppearance[] = ['fill', 'outline'];

hasLabel$ = new BehaviorSubject(true);

Expand Down
6 changes: 6 additions & 0 deletions src/material/form-field/_mdc-text-field-structure.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
104 changes: 90 additions & 14 deletions src/material/input/input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>(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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1650,10 +1707,15 @@ class MatInputWithId {
}

@Component({
template: `<mat-form-field><input matInput [disabled]="disabled"></mat-form-field>`,
template: `
<mat-form-field>
<input matInput [disabled]="disabled" [disabledInteractive]="disabledInteractive">
</mat-form-field>
`,
})
class MatInputWithDisabled {
disabled: boolean;
disabled = false;
disabledInteractive = false;
}

@Component({
Expand Down Expand Up @@ -1783,10 +1845,18 @@ class MatInputDateTestController {}
template: `
<mat-form-field>
<mat-label>Label</mat-label>
<input matInput type="text" placeholder="Placeholder">
<input
matInput
type="text"
placeholder="Placeholder"
[disabled]="disabled"
[disabledInteractive]="disabledInteractive">
</mat-form-field>`,
})
class MatInputTextTestController {}
class MatInputTextTestController {
disabled = false;
disabledInteractive = false;
}

@Component({
template: `
Expand Down Expand Up @@ -1837,11 +1907,17 @@ class MatInputWithStaticLabel {}
template: `
<mat-form-field [floatLabel]="shouldFloat">
<mat-label>Label</mat-label>
<input matInput placeholder="Placeholder">
<input
matInput
placeholder="Placeholder"
[disabled]="disabled"
[disabledInteractive]="disabledInteractive">
</mat-form-field>`,
})
class MatInputWithDynamicLabel {
shouldFloat: 'always' | 'auto' = 'always';
disabled = false;
disabledInteractive = false;
}

@Component({
Expand Down
62 changes: 56 additions & 6 deletions src/material/input/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<MatInputConfig>('MAT_INPUT_CONFIG');

@Directive({
selector: `input[matInput], textarea[matInput], select[matNativeControl],
input[matNativeControl], textarea[matNativeControl]`,
Expand All @@ -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',
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion src/material/input/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading

0 comments on commit 1abb484

Please sign in to comment.