diff --git a/libs/triangle/form-field/public-api.ts b/libs/triangle/form-field/public-api.ts index 693acc648..0a4738101 100644 --- a/libs/triangle/form-field/public-api.ts +++ b/libs/triangle/form-field/public-api.ts @@ -5,15 +5,14 @@ */ -export * from './src/form-field-module'; -export * from './src/error'; -export * from './src/form-field-component'; -export { TriFormFieldControl } from './src/form-field-control'; + +export * from './src/directives/label'; +export * from './src/directives/error'; +export * from './src/directives/hint'; +export * from './src/directives/prefix'; +export * from './src/directives/suffix'; +export * from './src/form-field'; +export * from './src/module'; +export * from './src/form-field-control'; export * from './src/form-field-errors'; -export * from './src/hint'; -export * from './src/prefix'; -export * from './src/suffix'; -export * from './src/label'; export * from './src/form-field-animations'; - -export * from './src/input-form-field/input-form-field-directive'; diff --git a/libs/triangle/form-field/src/directives/error.ts b/libs/triangle/form-field/src/directives/error.ts new file mode 100644 index 000000000..93fe2364a --- /dev/null +++ b/libs/triangle/form-field/src/directives/error.ts @@ -0,0 +1,41 @@ +/** + * @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.io/license + */ + +import {Attribute, Directive, ElementRef, InjectionToken, Input} from '@angular/core'; + +let nextUniqueId = 0; + +/** + * Injection token that can be used to reference instances of `MatError`. It serves as + * alternative token to the actual `MatError` class which could cause unnecessary + * retention of the class and its directive metadata. + */ +export const TRI_ERROR = new InjectionToken('TriError'); + +/** Single error message to be shown underneath the form-field. */ +@Directive({ + selector: 'tri-error, [triError]', + host: { + 'class': 'tri-form-field-error tri-form-field-bottom-align', + 'aria-atomic': 'true', + '[id]': 'id', + }, + providers: [{provide: TRI_ERROR, useExisting: TriError}], + standalone: true, +}) +export class TriError { + @Input() id: string = `tri-error-${nextUniqueId++}`; + + constructor(@Attribute('aria-live') ariaLive: string, elementRef: ElementRef) { + // If no aria-live value is set add 'polite' as a default. This is preferred over setting + // role='alert' so that screen readers do not interrupt the current task to read this aloud. + if (!ariaLive) { + elementRef.nativeElement.setAttribute('aria-live', 'polite'); + } + } +} diff --git a/libs/triangle/form-field/src/directives/floating-label.ts b/libs/triangle/form-field/src/directives/floating-label.ts new file mode 100644 index 000000000..5b20921c8 --- /dev/null +++ b/libs/triangle/form-field/src/directives/floating-label.ts @@ -0,0 +1,152 @@ +/** + * @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.io/license + */ + +import { + Directive, + ElementRef, + inject, + Input, + NgZone, + OnDestroy, + InjectionToken, +} from '@angular/core'; +import {SharedResizeObserver} from '@angular/cdk/observers/private'; +import {Subscription} from 'rxjs'; + +/** An interface that the parent form-field should implement to receive resize events. */ +export interface FloatingLabelParent { + _handleLabelResized(): void; +} + +/** An injion token for the parent form-field. */ +export const FLOATING_LABEL_PARENT = new InjectionToken('FloatingLabelParent'); + +/** + * Internal directive that maintains a MDC floating label. This directive does not + * use the `MDCFloatingLabelFoundation` class, as it is not worth the size cost of + * including it just to measure the label width and toggle some classes. + * + * The use of a directive allows us to conditionally render a floating label in the + * template without having to manually manage instantiation and destruction of the + * floating label component based on. + * + * The component is responsible for setting up the floating label styles, measuring label + * width for the outline notch, and providing inputs that can be used to toggle the + * label's floating or required state. + */ +@Directive({ + selector: 'label[triFormFieldFloatingLabel]', + host: { + 'class': 'tri-floating-label mat-mdc-floating-label', + '[class.tri-floating-label--float-above]': 'floating', + }, + standalone: true, +}) +export class TriFormFieldFloatingLabel implements OnDestroy { + /** Whether the label is floating. */ + @Input() + get floating() { + return this._floating; + } + set floating(value: boolean) { + this._floating = value; + if (this.monitorResize) { + this._handleResize(); + } + } + private _floating = false; + + /** Whether to monitor for resize events on the floating label. */ + @Input() + get monitorResize() { + return this._monitorResize; + } + set monitorResize(value: boolean) { + this._monitorResize = value; + if (this._monitorResize) { + this._subscribeToResize(); + } else { + this._resizeSubscription.unsubscribe(); + } + } + private _monitorResize = false; + + /** The shared ResizeObserver. */ + private _resizeObserver = inject(SharedResizeObserver); + + /** The Angular zone. */ + private _ngZone = inject(NgZone); + + /** The parent form-field. */ + private _parent = inject(FLOATING_LABEL_PARENT); + + /** The current resize event subscription. */ + private _resizeSubscription = new Subscription(); + + constructor(private _elementRef: ElementRef) {} + + ngOnDestroy() { + this._resizeSubscription.unsubscribe(); + } + + /** Gets the width of the label. Used for the outline notch. */ + getWidth(): number { + return estimateScrollWidth(this._elementRef.nativeElement); + } + + /** Gets the HTML element for the floating label. */ + get element(): HTMLElement { + return this._elementRef.nativeElement; + } + + /** Handles resize events from the ResizeObserver. */ + private _handleResize() { + // In the case where the label grows in size, the following sequence of events occurs: + // 1. The label grows by 1px triggering the ResizeObserver + // 2. The notch is expanded to accommodate the entire label + // 3. The label expands to its full width, triggering the ResizeObserver again + // + // This is expected, but If we allow this to all happen within the same macro task it causes an + // error: `ResizeObserver loop limit exceeded`. Therefore we push the notch resize out until + // the next macro task. + setTimeout(() => this._parent._handleLabelResized()); + } + + /** Subscribes to resize events. */ + private _subscribeToResize() { + this._resizeSubscription.unsubscribe(); + this._ngZone.runOutsideAngular(() => { + this._resizeSubscription = this._resizeObserver + .observe(this._elementRef.nativeElement, {box: 'border-box'}) + .subscribe(() => this._handleResize()); + }); + } +} + +/** + * Estimates the scroll width of an element. + * via https://github.com/material-components/material-components-web/blob/c0a11ef0d000a098fd0c372be8f12d6a99302855/packages/mdc-dom/ponyfill.ts + */ +function estimateScrollWidth(element: HTMLElement): number { + // Check the offsetParent. If the element inherits display: none from any + // parent, the offsetParent property will be null (see + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent). + // This check ensures we only clone the node when necessary. + const htmlEl = element as HTMLElement; + if (htmlEl.offsetParent !== null) { + return htmlEl.scrollWidth; + } + + const clone = htmlEl.cloneNode(true) as HTMLElement; + clone.style.setProperty('position', 'absolute'); + clone.style.setProperty('transform', 'translate(-9999px, -9999px)'); + document.documentElement.appendChild(clone); + const scrollWidth = clone.scrollWidth; + clone.remove(); + return scrollWidth; +} diff --git a/libs/triangle/form-field/src/directives/hint.ts b/libs/triangle/form-field/src/directives/hint.ts new file mode 100644 index 000000000..3f8424f17 --- /dev/null +++ b/libs/triangle/form-field/src/directives/hint.ts @@ -0,0 +1,31 @@ +/** + * @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.io/license + */ + +import {Directive, Input} from '@angular/core'; + +let nextUniqueId = 0; + +/** Hint text to be shown underneath the form field control. */ +@Directive({ + selector: 'tri-hint', + host: { + 'class': 'tri-form-field-hint tri-form-field-bottom-align', + '[class.tri-form-field-hint-end]': 'align === "end"', + '[id]': 'id', + // Remove align attribute to prevent it from interfering with layout. + '[attr.align]': 'null', + }, + standalone: true, +}) +export class TriHint { + /** Whether to align the hint label at the start or end of the line. */ + @Input() align: 'start' | 'end' = 'start'; + + /** Unique ID for the hint. Used for the aria-describedby on the form field control. */ + @Input() id: string = `tri-hint-${nextUniqueId++}`; +} diff --git a/libs/triangle/form-field/src/directives/label.ts b/libs/triangle/form-field/src/directives/label.ts new file mode 100644 index 000000000..62077677e --- /dev/null +++ b/libs/triangle/form-field/src/directives/label.ts @@ -0,0 +1,16 @@ +/** + * @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.io/license + */ + +import {Directive} from '@angular/core'; + +/** The floating label for a `mat-form-field`. */ +@Directive({ + selector: 'tri-label', + standalone: true, +}) +export class TriLabel {} diff --git a/libs/triangle/form-field/src/directives/line-ripple.ts b/libs/triangle/form-field/src/directives/line-ripple.ts new file mode 100644 index 000000000..aaf68eb20 --- /dev/null +++ b/libs/triangle/form-field/src/directives/line-ripple.ts @@ -0,0 +1,64 @@ +/** + * @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.io/license + */ + +import {Directive, ElementRef, NgZone, OnDestroy} from '@angular/core'; + +/** Class added when the line ripple is active. */ +const ACTIVATE_CLASS = 'tri-line-ripple--active'; + +/** Class added when the line ripple is being deactivated. */ +const DEACTIVATING_CLASS = 'tri-line-ripple--deactivating'; + +/** + * Internal directive that creates an instance of the MDC line-ripple component. Using a + * directive allows us to conditionally render a line-ripple in the template without having + * to manually create and destroy the `MDCLineRipple` component whenever the condition changes. + * + * The directive sets up the styles for the line-ripple and provides an API for activating + * and deactivating the line-ripple. + */ +@Directive({ + selector: 'div[triFormFieldLineRipple]', + host: { + 'class': 'tri-line-ripple', + }, + standalone: true, +}) +export class TriFormFieldLineRipple implements OnDestroy { + constructor( + private _elementRef: ElementRef, + ngZone: NgZone, + ) { + ngZone.runOutsideAngular(() => { + _elementRef.nativeElement.addEventListener('transitionend', this._handleTransitionEnd); + }); + } + + activate() { + const classList = this._elementRef.nativeElement.classList; + classList.remove(DEACTIVATING_CLASS); + classList.add(ACTIVATE_CLASS); + } + + deactivate() { + this._elementRef.nativeElement.classList.add(DEACTIVATING_CLASS); + } + + private _handleTransitionEnd = (event: TransitionEvent) => { + const classList = this._elementRef.nativeElement.classList; + const isDeactivating = classList.contains(DEACTIVATING_CLASS); + + if (event.propertyName === 'opacity' && isDeactivating) { + classList.remove(ACTIVATE_CLASS, DEACTIVATING_CLASS); + } + }; + + ngOnDestroy() { + this._elementRef.nativeElement.removeEventListener('transitionend', this._handleTransitionEnd); + } +} diff --git a/libs/triangle/form-field/src/directives/notched-outline.html b/libs/triangle/form-field/src/directives/notched-outline.html new file mode 100644 index 000000000..640daa981 --- /dev/null +++ b/libs/triangle/form-field/src/directives/notched-outline.html @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/libs/triangle/form-field/src/directives/notched-outline.ts b/libs/triangle/form-field/src/directives/notched-outline.ts new file mode 100644 index 000000000..e0de20a10 --- /dev/null +++ b/libs/triangle/form-field/src/directives/notched-outline.ts @@ -0,0 +1,77 @@ +/** + * @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.io/license + */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + NgZone, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; + +/** + * Internal component that creates an instance of the MDC notched-outline component. + * + * The component sets up the HTML structure and styles for the notched-outline. It provides + * inputs to toggle the notch state and width. + */ +@Component({ + selector: 'div[triFormFieldNotchedOutline]', + templateUrl: './notched-outline.html', + host: { + 'class': 'tri-notched-outline', + // Besides updating the notch state through the MDC component, we toggle this class through + // a host binding in order to ensure that the notched-outline renders correctly on the server. + '[class.tri-notched-outline--notched]': 'open', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + standalone: true, +}) +export class TriFormFieldNotchedOutline implements AfterViewInit { + /** Whether the notch should be opened. */ + @Input('triFormFieldNotchedOutlineOpen') open: boolean = false; + + @ViewChild('notch') _notch: ElementRef; + + constructor( + private _elementRef: ElementRef, + private _ngZone: NgZone, + ) {} + + ngAfterViewInit(): void { + const label = this._elementRef.nativeElement.querySelector('.mdc-floating-label'); + if (label) { + this._elementRef.nativeElement.classList.add('tri-notched-outline--upgraded'); + + if (typeof requestAnimationFrame === 'function') { + label.style.transitionDuration = '0s'; + this._ngZone.runOutsideAngular(() => { + requestAnimationFrame(() => (label.style.transitionDuration = '')); + }); + } + } else { + this._elementRef.nativeElement.classList.add('tri-notched-outline--no-label'); + } + } + + _setNotchWidth(labelWidth: number) { + if (!this.open || !labelWidth) { + this._notch.nativeElement.style.width = ''; + } else { + const NOTCH_ELEMENT_PADDING = 8; + const NOTCH_ELEMENT_BORDER = 1; + this._notch.nativeElement.style.width = `calc(${labelWidth}px * var(--mat-mdc-form-field-floating-label-scale, 0.75) + ${ + NOTCH_ELEMENT_PADDING + NOTCH_ELEMENT_BORDER + }px)`; + } + } +} diff --git a/libs/triangle/form-field/src/directives/prefix.ts b/libs/triangle/form-field/src/directives/prefix.ts new file mode 100644 index 000000000..3cb90b744 --- /dev/null +++ b/libs/triangle/form-field/src/directives/prefix.ts @@ -0,0 +1,31 @@ +/** + * @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.io/license + */ + +import {Directive, InjectionToken, Input} from '@angular/core'; + +/** + * Injection token that can be used to reference instances of `MatPrefix`. It serves as + * alternative token to the actual `MatPrefix` class which could cause unnecessary + * retention of the class and its directive metadata. + */ +export const TRI_PREFIX = new InjectionToken('MatPrefix'); + +/** Prefix to be placed in front of the form field. */ +@Directive({ + selector: '[triPrefix], [triIconPrefix], [triTextPrefix]', + providers: [{provide: TRI_PREFIX, useExisting: TriPrefix}], + standalone: true, +}) +export class TriPrefix { + @Input('triTextPrefix') + set _isTextSelector(value: '') { + this._isText = true; + } + + _isText = false; +} diff --git a/libs/triangle/form-field/src/directives/suffix.ts b/libs/triangle/form-field/src/directives/suffix.ts new file mode 100644 index 000000000..61e560fc8 --- /dev/null +++ b/libs/triangle/form-field/src/directives/suffix.ts @@ -0,0 +1,31 @@ +/** + * @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.io/license + */ + +import {Directive, InjectionToken, Input} from '@angular/core'; + +/** + * Injection token that can be used to reference instances of `MatSuffix`. It serves as + * alternative token to the actual `MatSuffix` class which could cause unnecessary + * retention of the class and its directive metadata. + */ +export const TRI_SUFFIX = new InjectionToken('TriSuffix'); + +/** Suffix to be placed at the end of the form field. */ +@Directive({ + selector: '[triSuffix], [triIconSuffix], [triTextSuffix]', + providers: [{provide: TRI_SUFFIX, useExisting: TriSuffix}], + standalone: true, +}) +export class TriSuffix { + @Input('triTextSuffix') + set _isTextSelector(value: '') { + this._isText = true; + } + + _isText = false; +} diff --git a/libs/triangle/form-field/src/form-field-animations.ts b/libs/triangle/form-field/src/form-field-animations.ts index 68b593ebf..e1ce02310 100644 --- a/libs/triangle/form-field/src/form-field-animations.ts +++ b/libs/triangle/form-field/src/form-field-animations.ts @@ -1,16 +1,21 @@ /** * @license + * Copyright Google LLC All Rights Reserved. * - * Use of this source code is governed by an MIT-style license + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license */ - - import { - animate, AnimationTriggerMetadata, state, style, transition, trigger, + animate, + state, + style, + transition, + trigger, + AnimationTriggerMetadata, } from '@angular/animations'; /** - * Animations used by the TriFormField. + * Animations used by the MatFormField. * @docs-private */ export const triFormFieldAnimations: { diff --git a/libs/triangle/form-field/src/form-field-component.ts b/libs/triangle/form-field/src/form-field-component.ts deleted file mode 100644 index b3313137e..000000000 --- a/libs/triangle/form-field/src/form-field-component.ts +++ /dev/null @@ -1,449 +0,0 @@ -/** - * @license - * - * Use of this source code is governed by an MIT-style license - */ - - -import { Directionality } from '@angular/cdk/bidi'; -import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; -import { Platform } from '@angular/cdk/platform'; -import { - AfterContentChecked, AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, - Component, ContentChild, ContentChildren, ElementRef, Inject, InjectionToken, Input, NgZone, - OnDestroy, Optional, QueryList, ViewChild, ViewEncapsulation, -} from '@angular/core'; -import { NgControl } from '@angular/forms'; -import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; -import { CanColor, mixinColor } from '@gradii/triangle/core'; -import { fromEvent, Subject } from 'rxjs'; -import { startWith, take, takeUntil } from 'rxjs/operators'; -import { TRI_ERROR, TriError } from './error'; -import { triFormFieldAnimations } from './form-field-animations'; -import { TriFormFieldControl } from './form-field-control'; -import { - getTriFormFieldDuplicatedHintError, getTriFormFieldMissingControlError, -} from './form-field-errors'; -import { _TRI_HINT, TriHint } from './hint'; -import { TriLabel } from './label'; -import { TRI_PREFIX, TriPrefix } from './prefix'; -import { TRI_SUFFIX, TriSuffix } from './suffix'; - -declare const ngDevMode: object | null; - -let nextUniqueId = 0; -const floatingLabelScale = 0.75; - -/** - * Boilerplate for applying mixins to TriFormField. - * @docs-private - */ -const _TriFormFieldBase = mixinColor( - class { - constructor(public _elementRef: ElementRef) { - } - }, - 'primary', -); - -/** Possible appearance styles for the form field. */ -export type TriFormFieldVariant = 'fill' | null; - -export type LabelOrientation = - 'auto-float' - | 'float' - | 'inline' - | 'none' - | 'horizontal' - | 'vertical'; - -export interface TriFormFieldDefaultOptions { - variant?: TriFormFieldVariant; - hideRequiredMarker?: boolean; - /** - * Whether the label for form-fields should by default float `always`, - * `never`, or `auto` (only when necessary). - */ - labelOrientation?: LabelOrientation; -} - -export const TRI_FORM_FIELD_DEFAULT_OPTIONS = new InjectionToken( - 'TRI_FORM_FIELD_DEFAULT_OPTIONS', -); - -export const TRI_FORM_FIELD = new InjectionToken('TriFormField'); - -@Component({ - selector : 'tri-form-field', - exportAs : 'triFormField', - templateUrl: 'form-field.html', - // MatInput is a directive and can't have styles, so we need to include its styles here - // in form-field-input.css. The MatInput styles are fairly minimal so it shouldn't be a - // big deal for people who aren't using MatInput. - styleUrls : [ - '../style/form-field.scss', - '../style/form-field-fill.scss', - '../style/form-field-input.scss', - ], - animations : [triFormFieldAnimations.transitionMessages], - host : { - 'class' : 'tri-form-field', - '[class.tri-form-field-variant-horizontal]': 'labelOrientation == "horizontal"', - '[class.tri-form-field-variant-vertical]' : 'labelOrientation == "vertical"', - '[class.tri-form-field-variant-float]' : 'labelOrientation == "float" || labelOrientation == "auto-float"', - '[class.tri-form-field-appearance-fill]' : 'variant == "fill"', - '[class.tri-form-field-invalid]' : '_control.errorState', - '[class.tri-form-field-can-float]' : '_canLabelFloat()', - '[class.tri-form-field-should-float]' : '_shouldLabelFloat()', - '[class.tri-form-field-has-label]' : '_hasFloatingLabel()', - '[class.tri-form-field-hide-placeholder]' : '_hideControlPlaceholder()', - '[class.tri-form-field-disabled]' : '_control.disabled', - '[class.tri-form-field-autofilled]' : '_control.autofilled', - '[class.tri-focused]' : '_control.focused', - '[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")', - '[class._tri-animation-noopable]' : '!_animationsEnabled', - }, - inputs : ['color'], - encapsulation : ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - providers : [{provide: TRI_FORM_FIELD, useExisting: FormFieldComponent}], -}) -export class TriFormField extends _TriFormFieldBase - implements AfterContentInit, AfterContentChecked, AfterViewInit, OnDestroy, CanColor { - - private readonly _destroyed = new Subject(); - - /** The form-field appearance style. */ - @Input() - get variant(): TriFormFieldVariant { - return this._variant; - } - - set variant(value: TriFormFieldVariant) { - this._variant = value || 'fill'; - } - - _variant: TriFormFieldVariant; - - /** Whether the required marker should be hidden. */ - @Input() - get hideRequiredMarker(): boolean { - return this._hideRequiredMarker; - } - - set hideRequiredMarker(value: BooleanInput) { - this._hideRequiredMarker = coerceBooleanProperty(value); - } - - private _hideRequiredMarker: boolean; - - /** Override for the logic that disables the label animation in certain cases. */ - private _showAlwaysAnimate = false; - - /** Whether the floating label should always float or not. */ - _shouldAlwaysFloat(): boolean { - return this._labelOrientation === 'float' && !this._showAlwaysAnimate; - } - - /** Whether the label can float or not. */ - _canLabelFloat(): boolean { - return this._labelOrientation === 'float' || this._labelOrientation === 'auto-float'; - } - - /** State of the tri-hint and tri-error animations. */ - _subscriptAnimationState: string = ''; - - /** Text for the form field hint. */ - @Input() - get hintLabel(): string { - return this._hintLabel; - } - - set hintLabel(value: string) { - this._hintLabel = value; - this._processHints(); - } - - private _hintLabel = ''; - - // Unique id for the hint label. - readonly _hintLabelId: string = `tri-hint-${nextUniqueId++}`; - - // Unique id for the label element. - readonly _labelId = `tri-form-field-label-${nextUniqueId++}`; - - @Input() - get labelOrientation(): LabelOrientation { - return this._labelOrientation; - } - - set labelOrientation(value: LabelOrientation) { - if (value !== this._labelOrientation) { - this._labelOrientation = value || this._getDefaultLabelOrientation(); - this._changeDetectorRef.markForCheck(); - } - } - - private _labelOrientation: LabelOrientation; - - /** Whether the Angular animations are enabled. */ - _animationsEnabled: boolean; - - @ViewChild('connectionContainer', {static: true}) _connectionContainerRef: ElementRef; - @ViewChild('inputContainer') _inputContainerRef: ElementRef; - @ViewChild('label') private _label: ElementRef; - - @ContentChild(TriFormFieldControl) _controlNonStatic: TriFormFieldControl; - @ContentChild(TriFormFieldControl, {static: true}) _controlStatic: TriFormFieldControl; - - get _control(): TriFormFieldControl { - // TODO(crisbeto): we need this workaround in order to support both Ivy and ViewEngine. - // We should clean this up once Ivy is the default renderer. - return this._explicitFormFieldControl || this._controlNonStatic || this._controlStatic; - } - - set _control(value) { - this._explicitFormFieldControl = value; - } - - private _explicitFormFieldControl: TriFormFieldControl; - - @ContentChild(TriLabel) _labelChildNonStatic: TriLabel; - @ContentChild(TriLabel, {static: true}) _labelChildStatic: TriLabel; - - @ContentChildren(TRI_ERROR, {descendants: true}) _errorChildren: QueryList; - @ContentChildren(_TRI_HINT, {descendants: true}) _hintChildren: QueryList; - @ContentChildren(TRI_PREFIX, {descendants: true}) _prefixChildren: QueryList; - @ContentChildren(TRI_SUFFIX, {descendants: true}) _suffixChildren: QueryList; - - constructor( - elementRef: ElementRef, - private _changeDetectorRef: ChangeDetectorRef, - @Optional() private _dir: Directionality, - @Optional() - @Inject(TRI_FORM_FIELD_DEFAULT_OPTIONS) - private _defaults: TriFormFieldDefaultOptions, - private _platform: Platform, - private _ngZone: NgZone, - @Optional() @Inject(ANIMATION_MODULE_TYPE) _animationMode: string, - ) { - super(elementRef); - - this.labelOrientation = this._getDefaultLabelOrientation(); - this._animationsEnabled = _animationMode !== 'NoopAnimations'; - - // Set the default through here so we invoke the setter on the first run. - this.variant = _defaults && _defaults.variant ? _defaults.variant : 'fill'; - this._hideRequiredMarker = - _defaults && _defaults.hideRequiredMarker != null ? _defaults.hideRequiredMarker : false; - } - - /** - * Gets the id of the label element. If no label is present, returns `null`. - */ - getLabelId(): string | null { - return 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._connectionContainerRef || this._elementRef; - } - - ngAfterContentInit() { - this._validateControlChild(); - - const control = this._control; - - if (control.controlType) { - this._elementRef.nativeElement.classList.add(`tri-form-field-type-${control.controlType}`); - } - - // Subscribe to changes in the child control state in order to update the form field UI. - control.stateChanges.pipe(startWith(null)).subscribe(() => { - this._syncDescribedByIds(); - this._changeDetectorRef.markForCheck(); - }); - - // Run change detection if the value changes. - if (control.ngControl && control.ngControl.valueChanges) { - control.ngControl.valueChanges - .pipe(takeUntil(this._destroyed)) - .subscribe(() => this._changeDetectorRef.markForCheck()); - } - - // Re-validate when the number of hints changes. - this._hintChildren.changes.pipe(startWith(null)).subscribe(() => { - this._processHints(); - this._changeDetectorRef.markForCheck(); - }); - - // Update the aria-described by when the number of errors changes. - this._errorChildren.changes.pipe(startWith(null)).subscribe(() => { - this._syncDescribedByIds(); - this._changeDetectorRef.markForCheck(); - }); - } - - ngAfterContentChecked() { - this._validateControlChild(); - } - - ngAfterViewInit() { - // Avoid animations on load. - this._subscriptAnimationState = 'enter'; - this._changeDetectorRef.detectChanges(); - } - - ngOnDestroy() { - this._destroyed.next(); - this._destroyed.complete(); - } - - /** Determines whether a class from the NgControl should be forwarded to the host element. */ - _shouldForward(prop: keyof NgControl): boolean { - const ngControl = this._control ? this._control.ngControl : null; - return ngControl && ngControl[prop]; - } - - _hasLabel() { - return !!(this._labelChildNonStatic || this._labelChildStatic); - } - - _shouldLabelFloat() { - return ( - this._canLabelFloat() && - ((this._control && this._control.shouldLabelFloat) || this._shouldAlwaysFloat()) - ); - } - - _hideControlPlaceholder() { - // In the legacy appearance the placeholder is promoted to a label if no label is given. - return ( - (this._hasLabel() && !this._shouldLabelFloat()) - ); - } - - _hasFloatingLabel() { - // In the legacy appearance the placeholder is promoted to a label if no label is given. - return this._hasLabel(); - } - - /** Determines whether to display hints or errors. */ - _getDisplayedMessages(): 'error' | 'hint' { - return this._errorChildren && this._errorChildren.length > 0 && this._control.errorState - ? 'error' - : 'hint'; - } - - /** Animates the placeholder up and locks it in position. */ - _animateAndLockLabel(): void { - if (this._hasFloatingLabel() && this._canLabelFloat()) { - // If animations are disabled, we shouldn't go in here, - // because the `transitionend` will never fire. - if (this._animationsEnabled && this._label) { - this._showAlwaysAnimate = true; - - fromEvent(this._label.nativeElement, 'transitionend') - .pipe(take(1)) - .subscribe(() => { - this._showAlwaysAnimate = false; - }); - } - - this.labelOrientation = 'float'; - this._changeDetectorRef.markForCheck(); - } - } - - /** 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 `` alignment specified, with the - * attribute being considered as `align="start"`. - */ - private _validateHints() { - if (this._hintChildren && (typeof ngDevMode === 'undefined' || ngDevMode)) { - let startHint: TriHint; - let endHint: TriHint; - this._hintChildren.forEach((hint: TriHint) => { - if (hint.align === 'start') { - if (startHint || this.hintLabel) { - throw getTriFormFieldDuplicatedHintError('start'); - } - startHint = hint; - } else if (hint.align === 'end') { - if (endHint) { - throw getTriFormFieldDuplicatedHintError('end'); - } - endHint = hint; - } - }); - } - } - - /** Gets the default float label state. */ - private _getDefaultLabelOrientation(): LabelOrientation { - return (this._defaults && this._defaults.labelOrientation) || 'auto-float'; - } - - /** - * 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) { - const ids: string[] = []; - - 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); - } - } - - /** Throws an error if the form field's control is missing. */ - protected _validateControlChild() { - if (!this._control && (typeof ngDevMode === 'undefined' || ngDevMode)) { - throw getTriFormFieldMissingControlError(); - } - } -} diff --git a/libs/triangle/form-field/src/form-field-control.ts b/libs/triangle/form-field/src/form-field-control.ts index eb0e9fa19..482814361 100644 --- a/libs/triangle/form-field/src/form-field-control.ts +++ b/libs/triangle/form-field/src/form-field-control.ts @@ -1,22 +1,23 @@ /** * @license + * Copyright Google LLC All Rights Reserved. * - * Use of this source code is governed by an MIT-style license + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license */ +import {Observable} from 'rxjs'; +import {AbstractControlDirective, NgControl} from '@angular/forms'; +import {Directive} from '@angular/core'; -import { Directive } from '@angular/core'; -import { NgControl } from '@angular/forms'; -import { Observable } from 'rxjs'; - -/** An interface which allows a control to work inside of a `TriFormField`. */ +/** An interface which allows a control to work inside of a `MatFormField`. */ @Directive() export abstract class TriFormFieldControl { /** The value of the control. */ value: T | null; /** - * Stream that emits whenever the state of the control changes such that the parent `TriFormField` + * Stream that emits whenever the state of the control changes such that the parent `MatFormField` * needs to run change detection. */ readonly stateChanges: Observable; @@ -27,8 +28,8 @@ export abstract class TriFormFieldControl { /** The placeholder for this control. */ readonly placeholder: string; - /** Gets the NgControl for this control. */ - readonly ngControl: NgControl | null; + /** Gets the AbstractControlDirective for this control. */ + readonly ngControl: NgControl | AbstractControlDirective | null; /** Whether the control is focused. */ readonly focused: boolean; @@ -36,7 +37,7 @@ export abstract class TriFormFieldControl { /** Whether the control is empty. */ readonly empty: boolean; - /** Whether the `TriFormField` label should try to float. */ + /** Whether the `MatFormField` label should try to float. */ readonly shouldLabelFloat: boolean; /** Whether the control is required. */ @@ -49,9 +50,9 @@ export abstract class TriFormFieldControl { readonly errorState: boolean; /** - * An optional name for the control type that can be used to distinguish `tri-form-field` elements + * An optional name for the control type that can be used to distinguish `mat-form-field` elements * based on their control type. The form field will add a class, - * `tri-form-field-type-{{controlType}}` to its root element. + * `mat-form-field-type-{{controlType}}` to its root element. */ readonly controlType?: string; @@ -67,6 +68,13 @@ export abstract class TriFormFieldControl { */ readonly userAriaDescribedBy?: string; + /** + * Whether to automatically assign the ID of the form field as the `for` attribute + * on the `