UNPKG

@material/web

Version:
341 lines 13.2 kB
/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { __decorate } from "tslib"; import { html, LitElement, nothing, render, } from 'lit'; import { property, query, queryAssignedElements, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { EASING } from '../../internal/motion/animation.js'; /** * A field component. */ export class Field extends LitElement { constructor() { super(...arguments); this.disabled = false; this.error = false; this.focused = false; this.label = ''; this.populated = false; this.required = false; this.resizable = false; this.supportingText = ''; this.errorText = ''; this.count = -1; this.max = -1; /** * Whether or not the field has leading content. */ this.hasStart = false; /** * Whether or not the field has trailing content. */ this.hasEnd = false; this.isAnimating = false; /** * When set to true, the error text's `role="alert"` will be removed, then * re-added after an animation frame. This will re-announce an error message * to screen readers. */ this.refreshErrorAlert = false; this.disableTransitions = false; } get counterText() { if (this.count < 0 || this.max < 0) { return ''; } return `${this.count} / ${this.max}`; } get supportingOrErrorText() { return this.error && this.errorText ? this.errorText : this.supportingText; } /** * Re-announces the field's error supporting text to screen readers. * * Error text announces to screen readers anytime it is visible and changes. * Use the method to re-announce the message when the text has not changed, * but announcement is still needed (such as for `reportValidity()`). */ reannounceError() { this.refreshErrorAlert = true; } update(props) { // Client-side property updates const isDisabledChanging = props.has('disabled') && props.get('disabled') !== undefined; if (isDisabledChanging) { this.disableTransitions = true; } // When disabling, remove focus styles if focused. if (this.disabled && this.focused) { props.set('focused', true); this.focused = false; } // Animate if focused or populated change. this.animateLabelIfNeeded({ wasFocused: props.get('focused'), wasPopulated: props.get('populated'), }); super.update(props); } render() { const floatingLabel = this.renderLabel(/*isFloating*/ true); const restingLabel = this.renderLabel(/*isFloating*/ false); const outline = this.renderOutline?.(floatingLabel); const classes = { 'disabled': this.disabled, 'disable-transitions': this.disableTransitions, 'error': this.error && !this.disabled, 'focused': this.focused, 'with-start': this.hasStart, 'with-end': this.hasEnd, 'populated': this.populated, 'resizable': this.resizable, 'required': this.required, 'no-label': !this.label, }; return html ` <div class="field ${classMap(classes)}"> <div class="container-overflow"> ${this.renderBackground?.()} ${this.renderIndicator?.()} ${outline} <div class="container"> <div class="start"> <slot name="start"></slot> </div> <div class="middle"> <div class="label-wrapper"> ${restingLabel} ${outline ? nothing : floatingLabel} </div> <div class="content"> <slot></slot> </div> </div> <div class="end"> <slot name="end"></slot> </div> </div> </div> ${this.renderSupportingText()} </div> `; } updated(changed) { if (changed.has('supportingText') || changed.has('errorText') || changed.has('count') || changed.has('max')) { this.updateSlottedAriaDescribedBy(); } if (this.refreshErrorAlert) { // The past render cycle removed the role="alert" from the error message. // Re-add it after an animation frame to re-announce the error. requestAnimationFrame(() => { this.refreshErrorAlert = false; }); } if (this.disableTransitions) { requestAnimationFrame(() => { this.disableTransitions = false; }); } } renderSupportingText() { const { supportingOrErrorText, counterText } = this; if (!supportingOrErrorText && !counterText) { return nothing; } // Always render the supporting text span so that our `space-around` // container puts the counter at the end. const start = html `<span>${supportingOrErrorText}</span>`; // Conditionally render counter so we don't render the extra `gap`. // TODO(b/244473435): add aria-label and announcements const end = counterText ? html `<span class="counter">${counterText}</span>` : nothing; // Announce if there is an error and error text visible. // If refreshErrorAlert is true, do not announce. This will remove the // role="alert" attribute. Another render cycle will happen after an // animation frame to re-add the role. const shouldErrorAnnounce = this.error && this.errorText && !this.refreshErrorAlert; const role = shouldErrorAnnounce ? 'alert' : nothing; return html ` <div class="supporting-text" role=${role}>${start}${end}</div> <slot name="aria-describedby" @slotchange=${this.updateSlottedAriaDescribedBy}></slot> `; } updateSlottedAriaDescribedBy() { for (const element of this.slottedAriaDescribedBy) { render(html `${this.supportingOrErrorText} ${this.counterText}`, element); element.setAttribute('hidden', ''); } } renderLabel(isFloating) { if (!this.label) { return nothing; } let visible; if (isFloating) { // Floating label is visible when focused/populated or when animating. visible = this.focused || this.populated || this.isAnimating; } else { // Resting label is visible when unfocused. It is never visible while // animating. visible = !this.focused && !this.populated && !this.isAnimating; } const classes = { 'hidden': !visible, 'floating': isFloating, 'resting': !isFloating, }; // Add '*' if a label is present and the field is required const labelText = `${this.label}${this.required ? '*' : ''}`; return html ` <span class="label ${classMap(classes)}" aria-hidden=${!visible} >${labelText}</span > `; } animateLabelIfNeeded({ wasFocused, wasPopulated, }) { if (!this.label) { return; } wasFocused ?? (wasFocused = this.focused); wasPopulated ?? (wasPopulated = this.populated); const wasFloating = wasFocused || wasPopulated; const shouldBeFloating = this.focused || this.populated; if (wasFloating === shouldBeFloating) { return; } this.isAnimating = true; this.labelAnimation?.cancel(); // Only one label is visible at a time for clearer text rendering. // The floating label is visible and used during animation. At the end of // the animation, it will either remain visible (if floating) or hide and // the resting label will be shown. // // We don't use forward filling because if the dimensions of the text field // change (leading icon removed, density changes, etc), then the animation // will be inaccurate. // // Re-calculating the animation each time will prevent any visual glitches // from appearing. // TODO(b/241113345): use animation tokens this.labelAnimation = this.floatingLabelEl?.animate(this.getLabelKeyframes(), { duration: 150, easing: EASING.STANDARD }); this.labelAnimation?.addEventListener('finish', () => { // At the end of the animation, update the visible label. this.isAnimating = false; }); } getLabelKeyframes() { const { floatingLabelEl, restingLabelEl } = this; if (!floatingLabelEl || !restingLabelEl) { return []; } const { x: floatingX, y: floatingY, height: floatingHeight, } = floatingLabelEl.getBoundingClientRect(); const { x: restingX, y: restingY, height: restingHeight, } = restingLabelEl.getBoundingClientRect(); const floatingScrollWidth = floatingLabelEl.scrollWidth; const restingScrollWidth = restingLabelEl.scrollWidth; // Scale by width ratio instead of font size since letter-spacing will scale // incorrectly. Using the width we can better approximate the adjusted // scale and compensate for tracking and overflow. // (use scrollWidth instead of width to account for clipped labels) const scale = restingScrollWidth / floatingScrollWidth; const xDelta = restingX - floatingX; // The line-height of the resting and floating label are different. When // we move the floating label down to the resting label's position, it won't // exactly match because of this. We need to adjust by half of what the // final scaled floating label's height will be. const yDelta = restingY - floatingY + Math.round((restingHeight - floatingHeight * scale) / 2); // Create the two transforms: floating to resting (using the calculations // above), and resting to floating (re-setting the transform to initial // values). const restTransform = `translateX(${xDelta}px) translateY(${yDelta}px) scale(${scale})`; const floatTransform = `translateX(0) translateY(0) scale(1)`; // Constrain the floating labels width to a scaled percentage of the // resting label's width. This will prevent long clipped labels from // overflowing the container. const restingClientWidth = restingLabelEl.clientWidth; const isRestingClipped = restingScrollWidth > restingClientWidth; const width = isRestingClipped ? `${restingClientWidth / scale}px` : ''; if (this.focused || this.populated) { return [ { transform: restTransform, width }, { transform: floatTransform, width }, ]; } return [ { transform: floatTransform, width }, { transform: restTransform, width }, ]; } getSurfacePositionClientRect() { return this.containerEl.getBoundingClientRect(); } } __decorate([ property({ type: Boolean }) ], Field.prototype, "disabled", void 0); __decorate([ property({ type: Boolean }) ], Field.prototype, "error", void 0); __decorate([ property({ type: Boolean }) ], Field.prototype, "focused", void 0); __decorate([ property() ], Field.prototype, "label", void 0); __decorate([ property({ type: Boolean }) ], Field.prototype, "populated", void 0); __decorate([ property({ type: Boolean }) ], Field.prototype, "required", void 0); __decorate([ property({ type: Boolean }) ], Field.prototype, "resizable", void 0); __decorate([ property({ attribute: 'supporting-text' }) ], Field.prototype, "supportingText", void 0); __decorate([ property({ attribute: 'error-text' }) ], Field.prototype, "errorText", void 0); __decorate([ property({ type: Number }) ], Field.prototype, "count", void 0); __decorate([ property({ type: Number }) ], Field.prototype, "max", void 0); __decorate([ property({ type: Boolean, attribute: 'has-start' }) ], Field.prototype, "hasStart", void 0); __decorate([ property({ type: Boolean, attribute: 'has-end' }) ], Field.prototype, "hasEnd", void 0); __decorate([ queryAssignedElements({ slot: 'aria-describedby' }) ], Field.prototype, "slottedAriaDescribedBy", void 0); __decorate([ state() ], Field.prototype, "isAnimating", void 0); __decorate([ state() ], Field.prototype, "refreshErrorAlert", void 0); __decorate([ state() ], Field.prototype, "disableTransitions", void 0); __decorate([ query('.label.floating') ], Field.prototype, "floatingLabelEl", void 0); __decorate([ query('.label.resting') ], Field.prototype, "restingLabelEl", void 0); __decorate([ query('.container') ], Field.prototype, "containerEl", void 0); //# sourceMappingURL=field.js.map