Reference Source

coral-spectrum/coral-component-search/src/scripts/Search.js

/**
 * Copyright 2019 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {BaseComponent} from '../../../coral-base-component';
import {BaseFormField} from '../../../coral-base-formfield';
import '../../../coral-component-textfield';
import '../../../coral-component-button';
import {Icon} from '../../../coral-component-icon';
import base from '../templates/base';
import {transform, validate, commons, i18n} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';

const CLASSNAME = '_coral-Search';

/**
 Enumeration for {@link Search} variants.

 @typedef {Object} SearchVariantEnum

 @property {String} DEFAULT
 A default, gray search input.
 @property {String} QUIET
 A search with no border, no background, no glow.
 */
const variant = {
  DEFAULT: 'default',
  QUIET: 'quiet'
};

/**
 @class Coral.Search
 @classdesc A Search component is a search styled form field.
 @htmltag coral-search
 @extends {HTMLElement}
 @extends {BaseComponent}
 @extends {BaseFormField}
 */
const Search = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
  /** @ignore */
  constructor() {
    super();

    this._delegateEvents(commons.extend(this._events, {
      // @todo use Coral.keys when key combos don't interfere with single key execution
      'keydown [handle=input]': '_onEnterKey',
      'keyup [handle=input]': '_onKeyUp',

      // @todo use coralinternalinput from Autocomplete
      'input [handle=input]': '_triggerInputEvent',

      'key:escape [handle=input]': '_clearInput',
      'click [handle=clearButton]:not(:disabled)': '_clearInput'
    }));

    // Prepare templates
    this._elements = {};
    base.call(this._elements, {i18n, Icon});

    // Pre-define labellable element
    this._labellableElement = this._elements.input;
  }

  /**
   Name used to submit the data in a form.
   @type {String}
   @default ""
   @htmlattribute name
   @htmlattributereflected
   */
  get name() {
    return this._elements.input.name;
  }

  set name(value) {
    this._reflectAttribute('name', value);

    this._elements.input.name = value;
  }

  /**
   The submitted input value. Changing this value will not trigger an event.

   @type {String}
   @default ""
   @htmlattribute value
   */
  get value() {
    return this._elements.input.value || '';
  }

  set value(value) {
    this._elements.input.value = value;
    this._updateClearButton();
  }

  /**
   Whether this field is disabled or not.
   @type {Boolean}
   @default false
   @htmlattribute disabled
   @htmlattributereflected
   */
  get disabled() {
    return this._disabled || false;
  }

  set disabled(value) {
    this._disabled = transform.booleanAttr(value);
    this._reflectAttribute('disabled', this._disabled);

    this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
    this.classList.toggle('is-disabled', this._disabled);
    this._elements.input.disabled = this._disabled;
    this._elements.clearButton.disabled = this._disabled;
  }

  /**
   Whether this field is required or not.
   @type {Boolean}
   @default false
   @htmlattribute required
   @htmlattributereflected
   */
  get required() {
    return this._required || false;
  }

  set required(value) {
    this._required = transform.booleanAttr(value);
    this._reflectAttribute('required', this._required);

    this._elements.input.required = this._required;
  }

  /**
   Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
   @type {Boolean}
   @default false
   @htmlattribute readonly
   @htmlattributereflected
   */
  get readOnly() {
    return this._readOnly || false;
  }

  set readOnly(value) {
    this._readOnly = transform.booleanAttr(value);
    this._reflectAttribute('readonly', this._readOnly);

    this._elements.input.readOnly = this._readOnly;
    this._elements.clearButton.disabled = this._readOnly;
  }

  /**
   Inherited from {@link BaseFormField#labelledBy}.
   */
  get labelledBy() {
    return super.labelledBy;
  }

  set labelledBy(value) {
    super.labelledBy = value;
    // in case the user focuses the buttons, he will still get a notion of the usage of the component
    this[this.labelledBy ? 'setAttribute' : 'removeAttribute']('aria-labelledby', this.labelledBy);
  }

  /**
   Short hint that describes the expected value of the Search. It is displayed when the Search is empty.

   @type {String}
   @default ""
   @htmlattribute placeholder
   @htmlattributereflected
   */
  get placeholder() {
    return this._elements.input.placeholder || '';
  }

  set placeholder(value) {
    value = transform.string(value);
    this._reflectAttribute('placeholder', value);

    this._elements.input.placeholder = value;
  }

  /**
   Max length for the Input field.
   @type {Number}
   @htmlattribute maxlength
   @htmlattributereflected
   */
  get maxLength() {
    return this._elements.input.maxLength;
  }

  set maxLength(value) {
    this._elements.input.maxLength = value;
    this._reflectAttribute('maxlength', this.maxLength);
  }

  /**
   The search's variant. See {@link SearchVariantEnum}.

   @type {String}
   @default SearchVariantEnum.DEFAULT
   @htmlattribute variant
   @htmlattributereflected
   */
  get variant() {
    return this._variant || variant.DEFAULT;
  }

  set variant(value) {
    value = transform.string(value).toLowerCase();
    this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
    this._reflectAttribute('variant', this._variant);

    this._elements.input.variant = value;
  }

  /**
   @ignore

   Not supported anymore.
   */
  get icon() {
    return this._icon || 'search';
  }

  set icon(value) {
    this._icon = transform.string(value);
    this._reflectAttribute('icon', this._icon);
  }

  /**
   Inherited from {@link BaseFormField#invalid}.
   */
  get invalid() {
    return super.invalid;
  }

  set invalid(value) {
    super.invalid = value;
  }

  /** @ignore */
  _triggerInputEvent() {
    this.trigger('coral-search:input');
  }

  /**
   Handles the up action by steping up the Search. It prevents the default action.

   @ignore
   */
  _onEnterKey(event) {
    if (event.which === 13) {
      event.preventDefault();

      // stops interaction if the search is disabled
      if (this.disabled) {
        return;
      }

      this.trigger('coral-search:submit');
    }
  }

  /**
   Handles the keydown action.

   @ignore
   */
  _onKeyUp() {
    this._updateClearButton();
  }

  /**
   Updates the clear button's display status.

   @ignore
   */
  _updateClearButton() {
    this._elements.clearButton.style.display = this._elements.input.value === '' ? 'none' : '';
  }

  /**
   Clears the text in the input box.

   @ignore
   */
  _clearInput() {
    this._elements.input.value = '';
    this._updateClearButton();
    this._elements.input.focus();

    // If we've been cleared, trigger the event
    this.trigger('coral-search:clear');
  }

  // overrides the behavior from BaseFormField
  reset() {
    // since there is an internal value, this one handles the reset
    this._elements.input.reset();
    this._updateClearButton();
  }

  // overrides the behavior from BaseFormField
  clear() {
    // since there is an internal value, this one handles the clear
    this._elements.input.clear();
    this._updateClearButton();
  }

  /**
   Returns {@link Search} variants.

   @return {SearchVariantEnum}
   */
  static get variant() {
    return variant;
  }

  static get _attributePropertyMap() {
    return commons.extend(super._attributePropertyMap, {
      maxlength: 'maxLength'
    });
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat(['placeholder', 'icon', 'variant', 'maxlength']);
  }

  /** @ignore */
  render() {
    super.render();

    this.classList.add(CLASSNAME);

    // Default reflected attributes
    if (!this._icon) {
      this.icon = 'search';
    }
    if (!this._variant) {
      this.variant = variant.DEFAULT;
    }

    // Support cloneNode
    const templates = this.querySelectorAll('._coral-Search-input, ._coral-Search-icon, ._coral-Search-clear');
    for (let i = 0 ; i < templates.length ; i++) {
      templates[i].remove();
    }

    // Create a fragment
    const fragment = document.createDocumentFragment();

    // Render the main template
    fragment.appendChild(this._elements.input);
    fragment.appendChild(this._elements.clearButton);

    // Add the frag to the component
    this.appendChild(fragment);

    // Insert search icon
    this._elements.input.insertAdjacentHTML('afterend', Icon._renderSVG('spectrum-css-icon-Magnifier', ['_coral-Search-icon', '_coral-UIIcon-Magnifier']));

    this._updateClearButton();
  }

  /**
   Triggered when {@link Search} input is given.

   @typedef {CustomEvent} coral-search:input
   */

  /**
   Triggered when the user presses {@link Search} enter.

   @typedef {CustomEvent} coral-search:submit
   */

  /**
   Triggered when the {@link Search} is cleared.

   @typedef {CustomEvent} coral-search:clear
   */
});

export default Search;