764 lines
30 KiB
TypeScript
764 lines
30 KiB
TypeScript
|
|
/**
|
||
|
|
* @license
|
||
|
|
* Copyright Google LLC All Rights Reserved.
|
||
|
|
*
|
||
|
|
* Use of this source code is governed by an MIT-style license that can be
|
||
|
|
* found in the LICENSE file at https://angular.dev/license
|
||
|
|
*/
|
||
|
|
import {Directionality} from '@angular/cdk/bidi';
|
||
|
|
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
|
||
|
|
import {Platform} from '@angular/cdk/platform';
|
||
|
|
import {NgTemplateOutlet} from '@angular/common';
|
||
|
|
import {
|
||
|
|
ANIMATION_MODULE_TYPE,
|
||
|
|
AfterContentChecked,
|
||
|
|
AfterContentInit,
|
||
|
|
AfterViewInit,
|
||
|
|
ChangeDetectionStrategy,
|
||
|
|
ChangeDetectorRef,
|
||
|
|
Component,
|
||
|
|
ContentChild,
|
||
|
|
ContentChildren,
|
||
|
|
ElementRef,
|
||
|
|
InjectionToken,
|
||
|
|
Injector,
|
||
|
|
Input,
|
||
|
|
OnDestroy,
|
||
|
|
QueryList,
|
||
|
|
ViewChild,
|
||
|
|
ViewEncapsulation,
|
||
|
|
afterRender,
|
||
|
|
computed,
|
||
|
|
contentChild,
|
||
|
|
inject,
|
||
|
|
} from '@angular/core';
|
||
|
|
import {AbstractControlDirective} from '@angular/forms';
|
||
|
|
import {ThemePalette} from '@angular/material/core';
|
||
|
|
import {_IdGenerator} from '@angular/cdk/a11y';
|
||
|
|
import {Subject, Subscription, merge} from 'rxjs';
|
||
|
|
import {map, pairwise, takeUntil, filter, startWith} from 'rxjs/operators';
|
||
|
|
import {MAT_ERROR, MatError} from './directives/error';
|
||
|
|
import {
|
||
|
|
FLOATING_LABEL_PARENT,
|
||
|
|
FloatingLabelParent,
|
||
|
|
MatFormFieldFloatingLabel,
|
||
|
|
} from './directives/floating-label';
|
||
|
|
import {MatHint} from './directives/hint';
|
||
|
|
import {MatLabel} from './directives/label';
|
||
|
|
import {MatFormFieldLineRipple} from './directives/line-ripple';
|
||
|
|
import {MatFormFieldNotchedOutline} from './directives/notched-outline';
|
||
|
|
import {MAT_PREFIX, MatPrefix} from './directives/prefix';
|
||
|
|
import {MAT_SUFFIX, MatSuffix} from './directives/suffix';
|
||
|
|
import {matFormFieldAnimations} from './form-field-animations';
|
||
|
|
import {MatFormFieldControl as _MatFormFieldControl} from './form-field-control';
|
||
|
|
import {
|
||
|
|
getMatFormFieldDuplicatedHintError,
|
||
|
|
getMatFormFieldMissingControlError,
|
||
|
|
} from './form-field-errors';
|
||
|
|
|
||
|
|
/** Type for the available floatLabel values. */
|
||
|
|
export type FloatLabelType = 'always' | 'auto';
|
||
|
|
|
||
|
|
/** Possible appearance styles for the form field. */
|
||
|
|
export type MatFormFieldAppearance = 'fill' | 'outline';
|
||
|
|
|
||
|
|
/** Behaviors for how the subscript height is set. */
|
||
|
|
export type SubscriptSizing = 'fixed' | 'dynamic';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Represents the default options for the form field that can be configured
|
||
|
|
* using the `MAT_FORM_FIELD_DEFAULT_OPTIONS` injection token.
|
||
|
|
*/
|
||
|
|
export interface MatFormFieldDefaultOptions {
|
||
|
|
/** Default form field appearance style. */
|
||
|
|
appearance?: MatFormFieldAppearance;
|
||
|
|
/**
|
||
|
|
* Default theme color of the form field. This API is supported in M2 themes only, it has no
|
||
|
|
* effect in M3 themes.
|
||
|
|
*
|
||
|
|
* For information on applying color variants in M3, see
|
||
|
|
* https://material.angular.io/guide/theming#using-component-color-variants
|
||
|
|
*/
|
||
|
|
color?: ThemePalette;
|
||
|
|
/** Whether the required marker should be hidden by default. */
|
||
|
|
hideRequiredMarker?: boolean;
|
||
|
|
/**
|
||
|
|
* Whether the label for form fields should by default float `always`,
|
||
|
|
* `never`, or `auto` (only when necessary).
|
||
|
|
*/
|
||
|
|
floatLabel?: FloatLabelType;
|
||
|
|
/** Whether the form field should reserve space for one line by default. */
|
||
|
|
subscriptSizing?: SubscriptSizing;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Injection token that can be used to inject an instances of `MatFormField`. It serves
|
||
|
|
* as alternative token to the actual `MatFormField` class which would cause unnecessary
|
||
|
|
* retention of the `MatFormField` class and its component metadata.
|
||
|
|
*/
|
||
|
|
export const MAT_FORM_FIELD = new InjectionToken<MatFormField>('MatFormField');
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Injection token that can be used to configure the
|
||
|
|
* default options for all form field within an app.
|
||
|
|
*/
|
||
|
|
export const MAT_FORM_FIELD_DEFAULT_OPTIONS = new InjectionToken<MatFormFieldDefaultOptions>(
|
||
|
|
'MAT_FORM_FIELD_DEFAULT_OPTIONS',
|
||
|
|
);
|
||
|
|
|
||
|
|
/** Default appearance used by the form field. */
|
||
|
|
const DEFAULT_APPEARANCE: MatFormFieldAppearance = 'fill';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether the label for form fields should by default float `always`,
|
||
|
|
* `never`, or `auto`.
|
||
|
|
*/
|
||
|
|
const DEFAULT_FLOAT_LABEL: FloatLabelType = 'auto';
|
||
|
|
|
||
|
|
/** Default way that the subscript element height is set. */
|
||
|
|
const DEFAULT_SUBSCRIPT_SIZING: SubscriptSizing = 'fixed';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Default transform for docked floating labels in a MDC text-field. This value has been
|
||
|
|
* extracted from the MDC text-field styles because we programmatically modify the docked
|
||
|
|
* label transform, but do not want to accidentally discard the default label transform.
|
||
|
|
*/
|
||
|
|
const FLOATING_LABEL_DEFAULT_DOCKED_TRANSFORM = `translateY(-50%)`;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Despite `MatFormFieldControl` being an abstract class, most of our usages enforce its shape
|
||
|
|
* using `implements` instead of `extends`. This appears to be problematic when Closure compiler
|
||
|
|
* is configured to use type information to rename properties, because it can't figure out which
|
||
|
|
* class properties are coming from. This interface seems to work around the issue while preserving
|
||
|
|
* our type safety (alternative being using `any` everywhere).
|
||
|
|
* @docs-private
|
||
|
|
*/
|
||
|
|
interface MatFormFieldControl<T> extends _MatFormFieldControl<T> {}
|
||
|
|
|
||
|
|
/** Container for form controls that applies Material Design styling and behavior. */
|
||
|
|
@Component({
|
||
|
|
selector: 'mat-form-field',
|
||
|
|
exportAs: 'matFormField',
|
||
|
|
templateUrl: './form-field.html',
|
||
|
|
styleUrl: './form-field.css',
|
||
|
|
animations: [matFormFieldAnimations.transitionMessages],
|
||
|
|
host: {
|
||
|
|
'class': 'mat-mdc-form-field',
|
||
|
|
'[class.mat-mdc-form-field-label-always-float]': '_shouldAlwaysFloat()',
|
||
|
|
'[class.mat-mdc-form-field-has-icon-prefix]': '_hasIconPrefix',
|
||
|
|
'[class.mat-mdc-form-field-has-icon-suffix]': '_hasIconSuffix',
|
||
|
|
// Note that these classes reuse the same names as the non-MDC version, because they can be
|
||
|
|
// considered a public API since custom form controls may use them to style themselves.
|
||
|
|
// See https://github.com/angular/components/pull/20502#discussion_r486124901.
|
||
|
|
'[class.mat-form-field-invalid]': '_control.errorState',
|
||
|
|
'[class.mat-form-field-disabled]': '_control.disabled',
|
||
|
|
'[class.mat-form-field-autofilled]': '_control.autofilled',
|
||
|
|
'[class.mat-form-field-no-animations]': '_animationMode === "NoopAnimations"',
|
||
|
|
'[class.mat-form-field-appearance-fill]': 'appearance == "fill"',
|
||
|
|
'[class.mat-form-field-appearance-outline]': 'appearance == "outline"',
|
||
|
|
'[class.mat-form-field-hide-placeholder]': '_hasFloatingLabel() && !_shouldLabelFloat()',
|
||
|
|
'[class.mat-focused]': '_control.focused',
|
||
|
|
'[class.mat-primary]': 'color !== "accent" && color !== "warn"',
|
||
|
|
'[class.mat-accent]': 'color === "accent"',
|
||
|
|
'[class.mat-warn]': 'color === "warn"',
|
||
|
|
'[class.ng-untouched]': '_shouldForward("untouched")',
|
||
|
|
'[class.ng-touched]': '_shouldForward("touched")',
|
||
|
|
'[class.ng-pristine]': '_shouldForward("pristine")',
|
||
|
|
'[class.ng-dirty]': '_shouldForward("dirty")',
|
||
|
|
'[class.ng-valid]': '_shouldForward("valid")',
|
||
|
|
'[class.ng-invalid]': '_shouldForward("invalid")',
|
||
|
|
'[class.ng-pending]': '_shouldForward("pending")',
|
||
|
|
},
|
||
|
|
encapsulation: ViewEncapsulation.None,
|
||
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
|
|
providers: [
|
||
|
|
{provide: MAT_FORM_FIELD, useExisting: MatFormField},
|
||
|
|
{provide: FLOATING_LABEL_PARENT, useExisting: MatFormField},
|
||
|
|
],
|
||
|
|
imports: [
|
||
|
|
MatFormFieldFloatingLabel,
|
||
|
|
MatFormFieldNotchedOutline,
|
||
|
|
NgTemplateOutlet,
|
||
|
|
MatFormFieldLineRipple,
|
||
|
|
MatHint,
|
||
|
|
],
|
||
|
|
})
|
||
|
|
export class MatFormField
|
||
|
|
implements FloatingLabelParent, AfterContentInit, AfterContentChecked, AfterViewInit, OnDestroy
|
||
|
|
{
|
||
|
|
_elementRef = inject(ElementRef);
|
||
|
|
private _changeDetectorRef = inject(ChangeDetectorRef);
|
||
|
|
private _dir = inject(Directionality);
|
||
|
|
private _platform = inject(Platform);
|
||
|
|
private _idGenerator = inject(_IdGenerator);
|
||
|
|
private _defaults = inject<MatFormFieldDefaultOptions>(MAT_FORM_FIELD_DEFAULT_OPTIONS, {
|
||
|
|
optional: true,
|
||
|
|
});
|
||
|
|
_animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
|
||
|
|
|
||
|
|
@ViewChild('textField') _textField: ElementRef<HTMLElement>;
|
||
|
|
@ViewChild('iconPrefixContainer') _iconPrefixContainer: ElementRef<HTMLElement>;
|
||
|
|
@ViewChild('textPrefixContainer') _textPrefixContainer: ElementRef<HTMLElement>;
|
||
|
|
@ViewChild('iconSuffixContainer') _iconSuffixContainer: ElementRef<HTMLElement>;
|
||
|
|
@ViewChild('textSuffixContainer') _textSuffixContainer: ElementRef<HTMLElement>;
|
||
|
|
@ViewChild(MatFormFieldFloatingLabel) _floatingLabel: MatFormFieldFloatingLabel | undefined;
|
||
|
|
@ViewChild(MatFormFieldNotchedOutline) _notchedOutline: MatFormFieldNotchedOutline | undefined;
|
||
|
|
@ViewChild(MatFormFieldLineRipple) _lineRipple: MatFormFieldLineRipple | undefined;
|
||
|
|
|
||
|
|
@ContentChild(_MatFormFieldControl) _formFieldControl: MatFormFieldControl<any>;
|
||
|
|
@ContentChildren(MAT_PREFIX, {descendants: true}) _prefixChildren: QueryList<MatPrefix>;
|
||
|
|
@ContentChildren(MAT_SUFFIX, {descendants: true}) _suffixChildren: QueryList<MatSuffix>;
|
||
|
|
@ContentChildren(MAT_ERROR, {descendants: true}) _errorChildren: QueryList<MatError>;
|
||
|
|
@ContentChildren(MatHint, {descendants: true}) _hintChildren: QueryList<MatHint>;
|
||
|
|
|
||
|
|
private readonly _labelChild = contentChild(MatLabel);
|
||
|
|
|
||
|
|
/** Whether the required marker should be hidden. */
|
||
|
|
@Input()
|
||
|
|
get hideRequiredMarker(): boolean {
|
||
|
|
return this._hideRequiredMarker;
|
||
|
|
}
|
||
|
|
set hideRequiredMarker(value: BooleanInput) {
|
||
|
|
this._hideRequiredMarker = coerceBooleanProperty(value);
|
||
|
|
}
|
||
|
|
private _hideRequiredMarker = false;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Theme color of the form field. This API is supported in M2 themes only, it
|
||
|
|
* has no effect in M3 themes.
|
||
|
|
*
|
||
|
|
* For information on applying color variants in M3, see
|
||
|
|
* https://material.angular.io/guide/theming#using-component-color-variants.
|
||
|
|
*/
|
||
|
|
@Input() color: ThemePalette = 'primary';
|
||
|
|
|
||
|
|
/** Whether the label should always float or float as the user types. */
|
||
|
|
@Input()
|
||
|
|
get floatLabel(): FloatLabelType {
|
||
|
|
return this._floatLabel || this._defaults?.floatLabel || DEFAULT_FLOAT_LABEL;
|
||
|
|
}
|
||
|
|
set floatLabel(value: FloatLabelType) {
|
||
|
|
if (value !== this._floatLabel) {
|
||
|
|
this._floatLabel = value;
|
||
|
|
// For backwards compatibility. Custom form field controls or directives might set
|
||
|
|
// the "floatLabel" input and expect the form field view to be updated automatically.
|
||
|
|
// e.g. autocomplete trigger. Ideally we'd get rid of this and the consumers would just
|
||
|
|
// emit the "stateChanges" observable. TODO(devversion): consider removing.
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
private _floatLabel: FloatLabelType;
|
||
|
|
|
||
|
|
/** The form field appearance style. */
|
||
|
|
@Input()
|
||
|
|
get appearance(): MatFormFieldAppearance {
|
||
|
|
return this._appearance;
|
||
|
|
}
|
||
|
|
set appearance(value: MatFormFieldAppearance) {
|
||
|
|
const oldValue = this._appearance;
|
||
|
|
const newAppearance = value || this._defaults?.appearance || DEFAULT_APPEARANCE;
|
||
|
|
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
||
|
|
if (newAppearance !== 'fill' && newAppearance !== 'outline') {
|
||
|
|
throw new Error(
|
||
|
|
`MatFormField: Invalid appearance "${newAppearance}", valid values are "fill" or "outline".`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this._appearance = newAppearance;
|
||
|
|
if (this._appearance === 'outline' && this._appearance !== oldValue) {
|
||
|
|
// If the appearance has been switched to `outline`, the label offset needs to be updated.
|
||
|
|
// The update can happen once the view has been re-checked, but not immediately because
|
||
|
|
// the view has not been updated and the notched-outline floating label is not present.
|
||
|
|
this._needsOutlineLabelOffsetUpdate = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
private _appearance: MatFormFieldAppearance = DEFAULT_APPEARANCE;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether the form field should reserve space for one line of hint/error text (default)
|
||
|
|
* or to have the spacing grow from 0px as needed based on the size of the hint/error content.
|
||
|
|
* Note that when using dynamic sizing, layout shifts will occur when hint/error text changes.
|
||
|
|
*/
|
||
|
|
@Input()
|
||
|
|
get subscriptSizing(): SubscriptSizing {
|
||
|
|
return this._subscriptSizing || this._defaults?.subscriptSizing || DEFAULT_SUBSCRIPT_SIZING;
|
||
|
|
}
|
||
|
|
set subscriptSizing(value: SubscriptSizing) {
|
||
|
|
this._subscriptSizing = value || this._defaults?.subscriptSizing || DEFAULT_SUBSCRIPT_SIZING;
|
||
|
|
}
|
||
|
|
private _subscriptSizing: SubscriptSizing | null = null;
|
||
|
|
|
||
|
|
/** Text for the form field hint. */
|
||
|
|
@Input()
|
||
|
|
get hintLabel(): string {
|
||
|
|
return this._hintLabel;
|
||
|
|
}
|
||
|
|
set hintLabel(value: string) {
|
||
|
|
this._hintLabel = value;
|
||
|
|
this._processHints();
|
||
|
|
}
|
||
|
|
private _hintLabel = '';
|
||
|
|
|
||
|
|
_hasIconPrefix = false;
|
||
|
|
_hasTextPrefix = false;
|
||
|
|
_hasIconSuffix = false;
|
||
|
|
_hasTextSuffix = false;
|
||
|
|
|
||
|
|
// Unique id for the internal form field label.
|
||
|
|
readonly _labelId = this._idGenerator.getId('mat-mdc-form-field-label-');
|
||
|
|
|
||
|
|
// Unique id for the hint label.
|
||
|
|
readonly _hintLabelId = this._idGenerator.getId('mat-mdc-hint-');
|
||
|
|
|
||
|
|
/** State of the mat-hint and mat-error animations. */
|
||
|
|
_subscriptAnimationState = '';
|
||
|
|
|
||
|
|
/** Gets the current form field control */
|
||
|
|
get _control(): MatFormFieldControl<any> {
|
||
|
|
return this._explicitFormFieldControl || this._formFieldControl;
|
||
|
|
}
|
||
|
|
set _control(value) {
|
||
|
|
this._explicitFormFieldControl = value;
|
||
|
|
}
|
||
|
|
|
||
|
|
private _destroyed = new Subject<void>();
|
||
|
|
private _isFocused: boolean | null = null;
|
||
|
|
private _explicitFormFieldControl: MatFormFieldControl<any>;
|
||
|
|
private _needsOutlineLabelOffsetUpdate = false;
|
||
|
|
private _previousControl: MatFormFieldControl<unknown> | null = null;
|
||
|
|
private _stateChanges: Subscription | undefined;
|
||
|
|
private _valueChanges: Subscription | undefined;
|
||
|
|
private _describedByChanges: Subscription | undefined;
|
||
|
|
|
||
|
|
private _injector = inject(Injector);
|
||
|
|
|
||
|
|
constructor(...args: unknown[]);
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
const defaults = this._defaults;
|
||
|
|
|
||
|
|
if (defaults) {
|
||
|
|
if (defaults.appearance) {
|
||
|
|
this.appearance = defaults.appearance;
|
||
|
|
}
|
||
|
|
this._hideRequiredMarker = Boolean(defaults?.hideRequiredMarker);
|
||
|
|
if (defaults.color) {
|
||
|
|
this.color = defaults.color;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ngAfterViewInit() {
|
||
|
|
// Initial focus state sync. This happens rarely, but we want to account for
|
||
|
|
// it in case the form field control has "focused" set to true on init.
|
||
|
|
this._updateFocusState();
|
||
|
|
// Enable animations now. This ensures we don't animate on initial render.
|
||
|
|
this._subscriptAnimationState = 'enter';
|
||
|
|
// Because the above changes a value used in the template after it was checked, we need
|
||
|
|
// to trigger CD or the change might not be reflected if there is no other CD scheduled.
|
||
|
|
this._changeDetectorRef.detectChanges();
|
||
|
|
}
|
||
|
|
|
||
|
|
ngAfterContentInit() {
|
||
|
|
this._assertFormFieldControl();
|
||
|
|
this._initializeSubscript();
|
||
|
|
this._initializePrefixAndSuffix();
|
||
|
|
this._initializeOutlineLabelOffsetSubscriptions();
|
||
|
|
}
|
||
|
|
|
||
|
|
ngAfterContentChecked() {
|
||
|
|
this._assertFormFieldControl();
|
||
|
|
|
||
|
|
if (this._control !== this._previousControl) {
|
||
|
|
this._initializeControl(this._previousControl);
|
||
|
|
this._previousControl = this._control;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ngOnDestroy() {
|
||
|
|
this._stateChanges?.unsubscribe();
|
||
|
|
this._valueChanges?.unsubscribe();
|
||
|
|
this._describedByChanges?.unsubscribe();
|
||
|
|
this._destroyed.next();
|
||
|
|
this._destroyed.complete();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Gets the id of the label element. If no label is present, returns `null`.
|
||
|
|
*/
|
||
|
|
getLabelId = computed(() => (this._hasFloatingLabel() ? this._labelId : null));
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Gets an ElementRef for the element that a overlay attached to the form field
|
||
|
|
* should be positioned relative to.
|
||
|
|
*/
|
||
|
|
getConnectedOverlayOrigin(): ElementRef {
|
||
|
|
return this._textField || this._elementRef;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Animates the placeholder up and locks it in position. */
|
||
|
|
_animateAndLockLabel(): void {
|
||
|
|
// This is for backwards compatibility only. Consumers of the form field might use
|
||
|
|
// this method. e.g. the autocomplete trigger. This method has been added to the non-MDC
|
||
|
|
// form field because setting "floatLabel" to "always" caused the label to float without
|
||
|
|
// animation. This is different in MDC where the label always animates, so this method
|
||
|
|
// is no longer necessary. There doesn't seem any benefit in adding logic to allow changing
|
||
|
|
// the floating label state without animations. The non-MDC implementation was inconsistent
|
||
|
|
// because it always animates if "floatLabel" is set away from "always".
|
||
|
|
// TODO(devversion): consider removing this method when releasing the MDC form field.
|
||
|
|
if (this._hasFloatingLabel()) {
|
||
|
|
this.floatLabel = 'always';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Initializes the registered form field control. */
|
||
|
|
private _initializeControl(previousControl: MatFormFieldControl<unknown> | null) {
|
||
|
|
const control = this._control;
|
||
|
|
const classPrefix = 'mat-mdc-form-field-type-';
|
||
|
|
|
||
|
|
if (previousControl) {
|
||
|
|
this._elementRef.nativeElement.classList.remove(classPrefix + previousControl.controlType);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (control.controlType) {
|
||
|
|
this._elementRef.nativeElement.classList.add(classPrefix + control.controlType);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Subscribe to changes in the child control state in order to update the form field UI.
|
||
|
|
this._stateChanges?.unsubscribe();
|
||
|
|
this._stateChanges = control.stateChanges.subscribe(() => {
|
||
|
|
this._updateFocusState();
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Updating the `aria-describedby` touches the DOM. Only do it if it actually needs to change.
|
||
|
|
this._describedByChanges?.unsubscribe();
|
||
|
|
this._describedByChanges = control.stateChanges
|
||
|
|
.pipe(
|
||
|
|
startWith([undefined, undefined] as const),
|
||
|
|
map(() => [control.errorState, control.userAriaDescribedBy] as const),
|
||
|
|
pairwise(),
|
||
|
|
filter(([[prevErrorState, prevDescribedBy], [currentErrorState, currentDescribedBy]]) => {
|
||
|
|
return prevErrorState !== currentErrorState || prevDescribedBy !== currentDescribedBy;
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
.subscribe(() => this._syncDescribedByIds());
|
||
|
|
|
||
|
|
this._valueChanges?.unsubscribe();
|
||
|
|
|
||
|
|
// Run change detection if the value changes.
|
||
|
|
if (control.ngControl && control.ngControl.valueChanges) {
|
||
|
|
this._valueChanges = control.ngControl.valueChanges
|
||
|
|
.pipe(takeUntil(this._destroyed))
|
||
|
|
.subscribe(() => this._changeDetectorRef.markForCheck());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _checkPrefixAndSuffixTypes() {
|
||
|
|
this._hasIconPrefix = !!this._prefixChildren.find(p => !p._isText);
|
||
|
|
this._hasTextPrefix = !!this._prefixChildren.find(p => p._isText);
|
||
|
|
this._hasIconSuffix = !!this._suffixChildren.find(s => !s._isText);
|
||
|
|
this._hasTextSuffix = !!this._suffixChildren.find(s => s._isText);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Initializes the prefix and suffix containers. */
|
||
|
|
private _initializePrefixAndSuffix() {
|
||
|
|
this._checkPrefixAndSuffixTypes();
|
||
|
|
// Mark the form field as dirty whenever the prefix or suffix children change. This
|
||
|
|
// is necessary because we conditionally display the prefix/suffix containers based
|
||
|
|
// on whether there is projected content.
|
||
|
|
merge(this._prefixChildren.changes, this._suffixChildren.changes).subscribe(() => {
|
||
|
|
this._checkPrefixAndSuffixTypes();
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initializes the subscript by validating hints and synchronizing "aria-describedby" ids
|
||
|
|
* with the custom form field control. Also subscribes to hint and error changes in order
|
||
|
|
* to be able to validate and synchronize ids on change.
|
||
|
|
*/
|
||
|
|
private _initializeSubscript() {
|
||
|
|
// Re-validate when the number of hints changes.
|
||
|
|
this._hintChildren.changes.subscribe(() => {
|
||
|
|
this._processHints();
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update the aria-described by when the number of errors changes.
|
||
|
|
this._errorChildren.changes.subscribe(() => {
|
||
|
|
this._syncDescribedByIds();
|
||
|
|
this._changeDetectorRef.markForCheck();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Initial mat-hint validation and subscript describedByIds sync.
|
||
|
|
this._validateHints();
|
||
|
|
this._syncDescribedByIds();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Throws an error if the form field's control is missing. */
|
||
|
|
private _assertFormFieldControl() {
|
||
|
|
if (!this._control && (typeof ngDevMode === 'undefined' || ngDevMode)) {
|
||
|
|
throw getMatFormFieldMissingControlError();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private _updateFocusState() {
|
||
|
|
// Usually the MDC foundation would call "activateFocus" and "deactivateFocus" whenever
|
||
|
|
// certain DOM events are emitted. This is not possible in our implementation of the
|
||
|
|
// form field because we support abstract form field controls which are not necessarily
|
||
|
|
// of type input, nor do we have a reference to a native form field control element. Instead
|
||
|
|
// we handle the focus by checking if the abstract form field control focused state changes.
|
||
|
|
if (this._control.focused && !this._isFocused) {
|
||
|
|
this._isFocused = true;
|
||
|
|
this._lineRipple?.activate();
|
||
|
|
} else if (!this._control.focused && (this._isFocused || this._isFocused === null)) {
|
||
|
|
this._isFocused = false;
|
||
|
|
this._lineRipple?.deactivate();
|
||
|
|
}
|
||
|
|
|
||
|
|
this._textField?.nativeElement.classList.toggle(
|
||
|
|
'mdc-text-field--focused',
|
||
|
|
this._control.focused,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The floating label in the docked state needs to account for prefixes. The horizontal offset
|
||
|
|
* is calculated whenever the appearance changes to `outline`, the prefixes change, or when the
|
||
|
|
* form field is added to the DOM. This method sets up all subscriptions which are needed to
|
||
|
|
* trigger the label offset update.
|
||
|
|
*/
|
||
|
|
private _initializeOutlineLabelOffsetSubscriptions() {
|
||
|
|
// Whenever the prefix changes, schedule an update of the label offset.
|
||
|
|
// TODO(mmalerba): Use ResizeObserver to better support dynamically changing prefix content.
|
||
|
|
this._prefixChildren.changes.subscribe(() => (this._needsOutlineLabelOffsetUpdate = true));
|
||
|
|
|
||
|
|
// TODO(mmalerba): Split this into separate `afterRender` calls using the `EarlyRead` and
|
||
|
|
// `Write` phases.
|
||
|
|
afterRender(
|
||
|
|
() => {
|
||
|
|
if (this._needsOutlineLabelOffsetUpdate) {
|
||
|
|
this._needsOutlineLabelOffsetUpdate = false;
|
||
|
|
this._updateOutlineLabelOffset();
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{
|
||
|
|
injector: this._injector,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
this._dir.change
|
||
|
|
.pipe(takeUntil(this._destroyed))
|
||
|
|
.subscribe(() => (this._needsOutlineLabelOffsetUpdate = true));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether the floating label should always float or not. */
|
||
|
|
_shouldAlwaysFloat() {
|
||
|
|
return this.floatLabel === 'always';
|
||
|
|
}
|
||
|
|
|
||
|
|
_hasOutline() {
|
||
|
|
return this.appearance === 'outline';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether the label should display in the infix. Labels in the outline appearance are
|
||
|
|
* displayed as part of the notched-outline and are horizontally offset to account for
|
||
|
|
* form field prefix content. This won't work in server side rendering since we cannot
|
||
|
|
* measure the width of the prefix container. To make the docked label appear as if the
|
||
|
|
* right offset has been calculated, we forcibly render the label inside the infix. Since
|
||
|
|
* the label is part of the infix, the label cannot overflow the prefix content.
|
||
|
|
*/
|
||
|
|
_forceDisplayInfixLabel() {
|
||
|
|
return !this._platform.isBrowser && this._prefixChildren.length && !this._shouldLabelFloat();
|
||
|
|
}
|
||
|
|
|
||
|
|
_hasFloatingLabel = computed(() => !!this._labelChild());
|
||
|
|
|
||
|
|
_shouldLabelFloat(): boolean {
|
||
|
|
if (!this._hasFloatingLabel()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return this._control.shouldLabelFloat || this._shouldAlwaysFloat();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Determines whether a class from the AbstractControlDirective
|
||
|
|
* should be forwarded to the host element.
|
||
|
|
*/
|
||
|
|
_shouldForward(prop: keyof AbstractControlDirective): boolean {
|
||
|
|
const control = this._control ? this._control.ngControl : null;
|
||
|
|
return control && control[prop];
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Determines whether to display hints or errors. */
|
||
|
|
_getDisplayedMessages(): 'error' | 'hint' {
|
||
|
|
return this._errorChildren && this._errorChildren.length > 0 && this._control.errorState
|
||
|
|
? 'error'
|
||
|
|
: 'hint';
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Handle label resize events. */
|
||
|
|
_handleLabelResized() {
|
||
|
|
this._refreshOutlineNotchWidth();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Refreshes the width of the outline-notch, if present. */
|
||
|
|
_refreshOutlineNotchWidth() {
|
||
|
|
if (!this._hasOutline() || !this._floatingLabel || !this._shouldLabelFloat()) {
|
||
|
|
this._notchedOutline?._setNotchWidth(0);
|
||
|
|
} else {
|
||
|
|
this._notchedOutline?._setNotchWidth(this._floatingLabel.getWidth());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Does any extra processing that is required when handling the hints. */
|
||
|
|
private _processHints() {
|
||
|
|
this._validateHints();
|
||
|
|
this._syncDescribedByIds();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Ensure that there is a maximum of one of each "mat-hint" alignment specified. The hint
|
||
|
|
* label specified set through the input is being considered as "start" aligned.
|
||
|
|
*
|
||
|
|
* This method is a noop if Angular runs in production mode.
|
||
|
|
*/
|
||
|
|
private _validateHints() {
|
||
|
|
if (this._hintChildren && (typeof ngDevMode === 'undefined' || ngDevMode)) {
|
||
|
|
let startHint: MatHint;
|
||
|
|
let endHint: MatHint;
|
||
|
|
this._hintChildren.forEach((hint: MatHint) => {
|
||
|
|
if (hint.align === 'start') {
|
||
|
|
if (startHint || this.hintLabel) {
|
||
|
|
throw getMatFormFieldDuplicatedHintError('start');
|
||
|
|
}
|
||
|
|
startHint = hint;
|
||
|
|
} else if (hint.align === 'end') {
|
||
|
|
if (endHint) {
|
||
|
|
throw getMatFormFieldDuplicatedHintError('end');
|
||
|
|
}
|
||
|
|
endHint = hint;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sets the list of element IDs that describe the child control. This allows the control to update
|
||
|
|
* its `aria-describedby` attribute accordingly.
|
||
|
|
*/
|
||
|
|
private _syncDescribedByIds() {
|
||
|
|
if (this._control) {
|
||
|
|
let ids: string[] = [];
|
||
|
|
|
||
|
|
// TODO(wagnermaciel): Remove the type check when we find the root cause of this bug.
|
||
|
|
if (
|
||
|
|
this._control.userAriaDescribedBy &&
|
||
|
|
typeof this._control.userAriaDescribedBy === 'string'
|
||
|
|
) {
|
||
|
|
ids.push(...this._control.userAriaDescribedBy.split(' '));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (this._getDisplayedMessages() === 'hint') {
|
||
|
|
const startHint = this._hintChildren
|
||
|
|
? this._hintChildren.find(hint => hint.align === 'start')
|
||
|
|
: null;
|
||
|
|
const endHint = this._hintChildren
|
||
|
|
? this._hintChildren.find(hint => hint.align === 'end')
|
||
|
|
: null;
|
||
|
|
|
||
|
|
if (startHint) {
|
||
|
|
ids.push(startHint.id);
|
||
|
|
} else if (this._hintLabel) {
|
||
|
|
ids.push(this._hintLabelId);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (endHint) {
|
||
|
|
ids.push(endHint.id);
|
||
|
|
}
|
||
|
|
} else if (this._errorChildren) {
|
||
|
|
ids.push(...this._errorChildren.map(error => error.id));
|
||
|
|
}
|
||
|
|
|
||
|
|
this._control.setDescribedByIds(ids);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Updates the horizontal offset of the label in the outline appearance. In the outline
|
||
|
|
* appearance, the notched-outline and label are not relative to the infix container because
|
||
|
|
* the outline intends to surround prefixes, suffixes and the infix. This means that the
|
||
|
|
* floating label by default overlaps prefixes in the docked state. To avoid this, we need to
|
||
|
|
* horizontally offset the label by the width of the prefix container. The MDC text-field does
|
||
|
|
* not need to do this because they use a fixed width for prefixes. Hence, they can simply
|
||
|
|
* incorporate the horizontal offset into their default text-field styles.
|
||
|
|
*/
|
||
|
|
private _updateOutlineLabelOffset() {
|
||
|
|
if (!this._hasOutline() || !this._floatingLabel) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const floatingLabel = this._floatingLabel.element;
|
||
|
|
// If no prefix is displayed, reset the outline label offset from potential
|
||
|
|
// previous label offset updates.
|
||
|
|
if (!(this._iconPrefixContainer || this._textPrefixContainer)) {
|
||
|
|
floatingLabel.style.transform = '';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// If the form field is not attached to the DOM yet (e.g. in a tab), we defer
|
||
|
|
// the label offset update until the zone stabilizes.
|
||
|
|
if (!this._isAttachedToDom()) {
|
||
|
|
this._needsOutlineLabelOffsetUpdate = true;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const iconPrefixContainer = this._iconPrefixContainer?.nativeElement;
|
||
|
|
const textPrefixContainer = this._textPrefixContainer?.nativeElement;
|
||
|
|
const iconSuffixContainer = this._iconSuffixContainer?.nativeElement;
|
||
|
|
const textSuffixContainer = this._textSuffixContainer?.nativeElement;
|
||
|
|
const iconPrefixContainerWidth = iconPrefixContainer?.getBoundingClientRect().width ?? 0;
|
||
|
|
const textPrefixContainerWidth = textPrefixContainer?.getBoundingClientRect().width ?? 0;
|
||
|
|
const iconSuffixContainerWidth = iconSuffixContainer?.getBoundingClientRect().width ?? 0;
|
||
|
|
const textSuffixContainerWidth = textSuffixContainer?.getBoundingClientRect().width ?? 0;
|
||
|
|
// If the directionality is RTL, the x-axis transform needs to be inverted. This
|
||
|
|
// is because `transformX` does not change based on the page directionality.
|
||
|
|
const negate = this._dir.value === 'rtl' ? '-1' : '1';
|
||
|
|
const prefixWidth = `${iconPrefixContainerWidth + textPrefixContainerWidth}px`;
|
||
|
|
const labelOffset = `var(--mat-mdc-form-field-label-offset-x, 0px)`;
|
||
|
|
const labelHorizontalOffset = `calc(${negate} * (${prefixWidth} + ${labelOffset}))`;
|
||
|
|
|
||
|
|
// Update the translateX of the floating label to account for the prefix container,
|
||
|
|
// but allow the CSS to override this setting via a CSS variable when the label is
|
||
|
|
// floating.
|
||
|
|
floatingLabel.style.transform = `var(
|
||
|
|
--mat-mdc-form-field-label-transform,
|
||
|
|
${FLOATING_LABEL_DEFAULT_DOCKED_TRANSFORM} translateX(${labelHorizontalOffset})
|
||
|
|
)`;
|
||
|
|
|
||
|
|
// Prevent the label from overlapping the suffix when in resting position.
|
||
|
|
const prefixAndSuffixWidth =
|
||
|
|
iconPrefixContainerWidth +
|
||
|
|
textPrefixContainerWidth +
|
||
|
|
iconSuffixContainerWidth +
|
||
|
|
textSuffixContainerWidth;
|
||
|
|
this._elementRef.nativeElement.style.setProperty(
|
||
|
|
'--mat-form-field-notch-max-width',
|
||
|
|
`calc(100% - ${prefixAndSuffixWidth}px)`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Checks whether the form field is attached to the DOM. */
|
||
|
|
private _isAttachedToDom(): boolean {
|
||
|
|
const element: HTMLElement = this._elementRef.nativeElement;
|
||
|
|
if (element.getRootNode) {
|
||
|
|
const rootNode = element.getRootNode();
|
||
|
|
// If the element is inside the DOM the root node will be either the document
|
||
|
|
// or the closest shadow root, otherwise it'll be the element itself.
|
||
|
|
return rootNode && rootNode !== element;
|
||
|
|
}
|
||
|
|
// Otherwise fall back to checking if it's in the document. This doesn't account for
|
||
|
|
// shadow DOM, however browser that support shadow DOM should support `getRootNode` as well.
|
||
|
|
return document.documentElement!.contains(element);
|
||
|
|
}
|
||
|
|
}
|