fix(sections): add unique key (#185)

fix(sections): add unique key (#185)

<template>
  <div :id="componentAttrIdAutosuggest">
    <slot name="before-input" /><input
      :type="internal_inputProps.type"
      :value="internalValue"
      :autocomplete="internal_inputProps.autocomplete"
      role="combobox"
      :class="[isOpen ? `${componentAttrPrefix}__input--open` : '', internal_inputProps['class']]"
      v-bind="internal_inputProps"
      aria-autocomplete="list"
      :aria-owns="`${componentAttrPrefix}__results`"
      :aria-activedescendant="isOpen && currentIndex !== null ? `${componentAttrPrefix}__results-item--${currentIndex}` : ''"
      :aria-haspopup="isOpen ? 'true' : 'false'"
      :aria-expanded="isOpen ? 'true' : 'false'"
      @input="inputHandler"
      @keydown="handleKeyStroke"
      v-on="listeners"
    ><slot name="after-input" />
    <div :class="_componentAttrClassAutosuggestResultsContainer">
      <div
        v-if="isOpen"
        :class="_componentAttrClassAutosuggestResults"
        :aria-labelledby="componentAttrIdAutosuggest"
      >
        <slot name="before-suggestions" />
        <component
          :is="cs.type"
          v-for="(cs, key) in computedSections"
          :ref="getSectionRef(`${cs.name}${key}`)"
          :key="getSectionRef(`${cs.name}${key}`)"
          :current-index="currentIndex"
          :normalize-item-function="normalizeItem"
          :render-suggestion="renderSuggestion"
          :section="cs"
          :component-attr-prefix="componentAttrPrefix"
          @updateCurrentIndex="updateCurrentIndex"
        >
          <template
            :slot="`before-section-${cs.name || cs.label}`"
            slot-scope="{section, className}"
          >
            <slot
              :name="`before-section-${cs.name || cs.label}`"
              :section="section"
              :className="className"
            />
          </template>
          <template slot-scope="{ suggestion, _key }">
            <slot
              :suggestion="suggestion"
              :index="_key"
            >
              {{ suggestion.item }}
            </slot>
          </template>
          <template
            :slot="`after-section-${cs.name || cs.label}`"
            slot-scope="{section}"
          >
            <slot
              :name="`after-section-${cs.name || cs.label}`"
              :section="section"
            />
          </template>
          <template
            slot="after-section"
            slot-scope="{section}"
          >
            <slot
              name="after-section"
              :section="section"
            />
          </template>
        </component>
        <slot name="after-suggestions" />
      </div>
      <slot name="after-suggestions-container" />
    </div>
  </div>
</template>

<script>

/**
 * @typedef {Object} ResultSection
 * @prop {String} name - Name of the section
 * @prop {String} label - What is displayed in the section header, is exists
 * @prop {String} type - Used to decide which component to use for section
 * @prop {Number} limit - max number of results
 * @prop {Array} data - the results
 * @prop {Number} start_index - tracks section start reference point
 * @prop {Number} end_index - tracks section end reference point
 * @prop {Object} ulClass - class for <ul> of section e.g. { 'bg-blue': true }
 * @prop {Object} liClass - class for all <li>'s in section
 */

/**
 * @typedef {Object} ResultItem
 * @prop {Object<any>} item - data object
 * @prop {ResultSection.liClass} liClass
 * @prop {ResultSection.label} label
 * @prop {ResultSection.type} type
 */

import DefaultSection from "./parts/DefaultSection.js";
import { addClass, removeClass } from "./utils";

const defaultSectionConfig = {
  name: "default",
  type: "default-section"
}

export default {
  name: "Autosuggest",
  components: {
    /* eslint-disable-next-line vue/no-unused-components */
    DefaultSection
  },
  props: {
    /** Allows for v-model support */
    value: {
      type: String,
      default: null
    },
    /** v-binds to the <input /> tag for fine-grain control */
    inputProps: {
      type: Object,
      required: true
    },
    /** limits the number of suggestions for all sections */
    limit: {
      type: Number,
      required: false,
      default: Infinity
    },
    suggestions: {
      type: Array,
      required: true
    },
    renderSuggestion: {
      type: Function,
      required: false,
      default: null
    },
    getSuggestionValue: {
      type: Function,
      required: false,
      default: suggestion => {
        const item = suggestion.item;
        if (typeof item === "object" && item.hasOwnProperty("name")) {
          return item.name;
        } else {
          return item;
        }
      }
    },
    shouldRenderSuggestions: {
      type: Function,
      required: false,
      default: (totalResults, loading) => {
        return totalResults > 0 && !loading;
      }
    },
    sectionConfigs: {
      type: Object,
      required: false,
      default: () => {
        return {
          default: {
            onSelected: null
          }
        };
      }
    },
    onSelected: {
      type: Function,
      required: false,
      default: null
    },
    componentAttrIdAutosuggest: {
      type: String,
      required: false,
      default: "autosuggest"
    },
    componentAttrClassAutosuggestResultsContainer: {
      type: String,
      required: false,
      default: null  // `${componentAttrPrefix}__results-container`
    },
    componentAttrClassAutosuggestResults: {
      type: String,
      required: false,
      default: null // `${componentAttrPrefix}__results`
    },
    componentAttrPrefix: {
      type: String,
      required: false,
      default: "autosuggest"
    }
  },
  data() {
    return {
      internalValue: null,
      searchInputOriginal: null,
      currentIndex: null,
      /** @type ResultItem|null */
      currentItem: null,
      // TODO use event states instead of generic "loading" variable
      loading: false /** Helps with making sure the dropdown doesn't stay open after certain actions */,
      didSelectFromOptions: false,
      defaultInputProps: {
        type: 'text',
        autocomplete: "off",
      },
      /** @type Number */
      clientXMouseDownInitial: null
    };
  },
  computed: {
    /**
     * Merged object for defaults + user defined `<input/>` props
     */
    internal_inputProps() {
      return {
        ...this.defaultInputProps,
        ...this.inputProps
      }
    },
    listeners() {
      return {
        ...this.$listeners,
        input: e => {
          // Don't do anything native here, since we have inputHandler
          return
        },
        /**
         * Wrap native click handler to allow for added behavior
         */
        click: () => {
          /* eslint-disable-next-line vue/no-side-effects-in-computed-properties */
          this.loading = false;
          this.$listeners.click && this.$listeners.click(this.currentItem);
          this.$nextTick(() => {
            this.ensureItemVisible(this.currentItem, this.currentIndex);
          })
        },
        selected: () => {
          /**
           * Determine which onSelected to fire. This can be either from inside
           * a section's object, from the `@selected` event
           */
          if (
            this.currentItem &&
            this.sectionConfigs[this.currentItem.name] &&
            this.sectionConfigs[this.currentItem.name].onSelected
          ) {
            this.sectionConfigs[this.currentItem.name].onSelected(
              this.currentItem,
              this.searchInputOriginal
            );
          } else if (this.sectionConfigs["default"].onSelected) {
            this.sectionConfigs["default"].onSelected(null, this.searchInputOriginal);
          } else if (this.$listeners.selected) {
            this.$emit('selected', this.currentItem, this.currentIndex);
          }
          this.setChangeItem(null)
        }
      };
    },
    /**
     * @returns {Boolean}
     */
    isOpen() {
      return this.shouldRenderSuggestions(this.totalResults, this.loading)
    },
    /**
     * Normalize suggestions into sections based on defaults and section
     * configs.
     * @returns {Array<ResultSection>}
     */
    computedSections() {
      let tmpSize = 0
      return this.suggestions.map(section => {
        if (!section.data) return;

        const name = section.name ? section.name : defaultSectionConfig.name;
        let limit, label, type, ulClass, liClass = null

        if (this.sectionConfigs[name]) {
          limit = this.sectionConfigs[name].limit
          type = this.sectionConfigs[name].type
          label = this.sectionConfigs[name].label
          ulClass = this.sectionConfigs[name].ulClass
          liClass = this.sectionConfigs[name].liClass
        }

        /** Set defaults for section configs. */
        type = type ? type : defaultSectionConfig.type;

        limit = limit || this.limit
        limit = limit ? limit : Infinity;
        limit = section.data.length < limit ? section.data.length : limit;
        label = label ? label : section.label;

        const computedSection = {
          name,
          label,
          type,
          limit,
          data: section.data,
          start_index: tmpSize,
          end_index: tmpSize + limit - 1,
          ulClass,
          liClass
        }

        tmpSize += limit;

        return computedSection
      })
    },
    /**
     * Calculate number of results in each section.
     * @returns {Number}
     */
    totalResults () {
      return this.computedSections.reduce((acc, section) => {
        // For each section, make sure we calculate the size
        // based on how many are rendered, which maxes out at
        // the limit but can be less than the limit.
        if (!section) return acc
        const { limit, data } = section
        return acc + (data.length >= limit ? limit : data.length)
      }, 0)
    },

    _componentAttrClassAutosuggestResultsContainer () {
      return this.componentAttrClassAutosuggestResultsContainer || `${this.componentAttrPrefix}__results-container`
    },
    _componentAttrClassAutosuggestResults () {
      return this.componentAttrClassAutosuggestResults || `${this.componentAttrPrefix}__results`
    },
  },
  watch: {
    /**
     * Support initialValue
     */
    value: {
      handler(newValue){
        this.internalValue = newValue
      },
      immediate: true
    },
    /**
     * Emits opened/closed events
     * @returns {Boolean}
     */
    isOpen: {
      handler(newValue, oldValue){
        if (newValue !== oldValue) {
          this.$emit(newValue ? 'opened' : 'closed');
        }
      },
      immediate: true
    }
  },
  created() {
    this.loading = true;
  },
  mounted() {
    document.addEventListener("mouseup", this.onDocumentMouseUp);
    document.addEventListener("mousedown", this.onDocumentMouseDown);
  },
  beforeDestroy() {
    document.removeEventListener("mouseup", this.onDocumentMouseUp)
    document.removeEventListener("mousedown", this.onDocumentMouseDown)
  },
  methods: {
    /**
     * handler for @input <input /> events to support v-model behavior.
     * @param {InputEvent} e
     */
    inputHandler(e) {
      const newValue = e.target.value
      this.$emit('input', newValue)
      this.internalValue = newValue
      if (!this.didSelectFromOptions) {
        this.searchInputOriginal = newValue;
        this.currentIndex = null;
      }
    },
    /**
     * Helper for making sure the sectionRef getter is consistent
     * @returns {String}
     */
    getSectionRef(i) {
      return "computed_section_" + i;
    },
    /**
     * Helper for getting a suggestion item by index.
     * @returns {ResultItem}
     */
    getItemByIndex(index) {
      let obj = false;
      if (index === null) return obj;
      for (var i = 0; i < this.computedSections.length; i++) {
        if (
          index >= this.computedSections[i].start_index &&
          index <= this.computedSections[i].end_index
        ) {
          let trueIndex = index - this.computedSections[i].start_index;
          const sectionName = this.computedSections[i].name
          let childSection = this.$refs[this.getSectionRef(`${sectionName}${i}`)][0];
          if (childSection) {
            obj = this.normalizeItem(
              this.computedSections[i].name,
              this.computedSections[i].type,
              childSection.section.label,
              childSection.section.liClass,
              childSection.getItemByIndex(trueIndex)
            );
            break;
          }
        }
      }

      return obj;
    },
    /**
     * Handler for 'keydown' event. Does a number of things, including making
     * sure to ignore keycodes, ensure items are visible and also that the input
     * value is updated/reset according to where the user has keyed to.
     * @param {MouseEvent} e
     * @returns {void}
     */
    handleKeyStroke(e) {
      const { keyCode } = e;

      const ignoredKeyCodes = [
        16, // Shift
        9,  // Tab
        17, // ctrl
        18, // alt/option
        91, // OS Key
        93  // Right OS Key
      ];

      if (ignoredKeyCodes.indexOf(keyCode) > -1) {
        return;
      }

      const wasClosed = !this.isOpen
      this.loading = false;
      this.didSelectFromOptions = false;
      if (this.isOpen) {
        switch (keyCode) {
          case 40: // ArrowDown
          case 38: // ArrowUp
            e.preventDefault();
            if (keyCode === 38 && this.currentIndex === null) {
              break;
            }
            // Determine direction of arrow up/down and determine new currentIndex
            const direction = keyCode === 40 ? 1 : -1;
            const newIndex = (parseInt(this.currentIndex) || 0) + (wasClosed ? 0 : direction);

            this.setCurrentIndex(newIndex, this.totalResults);
            this.didSelectFromOptions = true;
            if (this.totalResults > 0 && this.currentIndex >= 0) {
              this.setChangeItem(this.getItemByIndex(this.currentIndex));
              this.didSelectFromOptions = true;
            } else if (this.currentIndex == -1) {
              this.setChangeItem(null)
              this.internalValue = this.searchInputOriginal;
              e.preventDefault();
            }

            this.$nextTick(() => {
              this.ensureItemVisible(this.currentItem, this.currentIndex);
            })
            break;
          case 13: // Enter
            e.preventDefault();

            if (this.totalResults > 0 && this.currentIndex >= 0) {
              this.setChangeItem(this.getItemByIndex(this.currentIndex), true);
              this.didSelectFromOptions = true;
            }

            this.loading = true;
            this.listeners.selected(this.didSelectFromOptions);
            break;
          case 27: // Escape
            /**
             * For 'search' input type, make sure the browser doesn't clear the
             * input when Escape is pressed.
             */
            this.loading = true;
            this.currentIndex = null;
            this.internalValue = this.searchInputOriginal;
            this.$emit('input', this.searchInputOriginal);
            e.preventDefault();
            break;
        }
      }
    },
    /**
     * Wrapper around currentItem setter to emit events and ensure to update the
     * searchInputOriginal when a user selects an option.
     * @param {ResultItem} item
     * @param {Boolean} overrideOriginalInput determine if the 'saved' original
     *   input should be updated. When a user selects an option, this will be
     *   updated, but if a user keys into the <input/> then the input will be
     *   reset to the searchInputOriginal.
     * @return {void}
     */
    setChangeItem(item, overrideOriginalInput = false) {
      if (this.currentIndex === null || !item) {
        this.currentItem = null;
        this.$emit('item-changed', null, null)
      } else if (item) {
        this.currentItem = item;
        this.$emit('item-changed', item, this.currentIndex)
        const v = this.getSuggestionValue(item)
        this.internalValue = v;
        if (overrideOriginalInput) {
          this.searchInputOriginal = v;
        }
        this.ensureItemVisible(item, this.currentIndex);
      }
    },

    /**
     * Function to standardize suggestion item object picked from sections
     * @returns {ResultItem}
     */
    normalizeItem(name, type, label, className, item) {
      return {
        name,
        type,
        label,
        liClass: item.liClass || className,
        item
      };
    },

    /**
     * Adjust the scroll position to the item in the suggestions overflow
     * @param {ResultItem} item - suggestion item
     * @param {Number} index - item index
     * @param {String} selector - selector of item that is overflowed
     */
    ensureItemVisible(item, index, selector) {
      const resultsScrollElement = this.$el.querySelector(
        selector || `.${this._componentAttrClassAutosuggestResults}`
      );

      if (!resultsScrollElement) {
        return
      }

      const itemElement = resultsScrollElement.querySelector(`#${this.componentAttrPrefix}__results-item--${index}`);
      if (!itemElement) {
        return;
      }

      const resultsScrollWindowHeight = resultsScrollElement.clientHeight;
      const resultsScrollScrollTop = resultsScrollElement.scrollTop;

      const itemHeight = itemElement.clientHeight;
      const currentItemScrollOffset = itemElement.offsetTop;

      if (
        itemHeight + currentItemScrollOffset >=
        resultsScrollScrollTop + resultsScrollWindowHeight
      ) {
        /** Current item goes below visible scroll window */
        resultsScrollElement.scrollTop = itemHeight + currentItemScrollOffset - resultsScrollWindowHeight;
      } else if (currentItemScrollOffset < resultsScrollScrollTop && resultsScrollScrollTop > 0) {
        /** Current item goes above visible scroll window */
        resultsScrollElement.scrollTop = currentItemScrollOffset;
      }
    },
    /**
     * @param {Number} index
     */
    updateCurrentIndex(index) {
      this.setCurrentIndex(index, -1, true);
    },
    /**
     * Helper to detect if the user clicked on the scrollbar
     * @param {MouseEvent} e
     * @param {Number} mouseX - horizontal position of the mouse relative to
     *   results e.g. an offset of clientX
     */
    clickedOnScrollbar(e, mouseX){
      const results = this.$el.querySelector(`.${this._componentAttrClassAutosuggestResults}`);

      const mouseIsInsideScrollbar = results && results.clientWidth <= (mouseX + 17) &&
        mouseX + 17 <= results.clientWidth + 34
      return e.target.tagName === 'DIV' && results && mouseIsInsideScrollbar || false;
    },
    /**
     * Capture mousedown position so we can use it to detect if the scrollbar
     * was clicked
     * @param {MouseEvent} e
     */
    onDocumentMouseDown(e) {
      var rect = e.target.getBoundingClientRect ? e.target.getBoundingClientRect() : 0;
      this.clientXMouseDownInitial = e.clientX - rect.left;
    },
    /**
     * 'mouseup' event handler
     * @param {MouseEvent} e
     */
    onDocumentMouseUp(e) {
      /** Do not re-render list on input click  */
      const isChild = this.$el.contains(e.target);

      /* Clicks outside of dropdown */
      if (!isChild) {
        this.loading = true;
        this.currentIndex = null;
        return;
      }

      if (e.target.tagName === 'INPUT' ||
        (this.clickedOnScrollbar(e, this.clientXMouseDownInitial))) {
        return;
      }

      /** Selects an item in the dropdown */
      this.loading = true;
      this.didSelectFromOptions = true;
      this.setChangeItem(this.getItemByIndex(this.currentIndex), true);
      this.listeners.selected(true);
    },
    /**
     * Sets the current index of the highlighted object, useful for aria
     * attributes like `aria-activedescendant` and toggling which result item
     * is highlighted.
     * @param {Number} newIndex
     * @param {Number} limit
     * @param {Boolean} onHover detects if the user is hovering vs. selected
     */
    setCurrentIndex(newIndex, limit = -1, onHover = false) {
      let adjustedValue = newIndex;

      /**
       * If you're not hovering, you might be keying outside of the bounds, so
       * we need to make sure that we adjust for the limits.
       */
      if (!onHover){
        const hitLowerLimt = this.currentIndex === null
        const hitUpperLimit = newIndex >= limit
        if (hitLowerLimt || hitUpperLimit) {
          adjustedValue = 0;
        }
      }

      this.currentIndex = adjustedValue;
      const element = this.$el.querySelector(`#${this.componentAttrPrefix}__results-item--${this.currentIndex}`);
      const hoverClass = `${this.componentAttrPrefix}__results-item--highlighted`;

      if (this.$el.querySelector(`.${hoverClass}`)) {
        removeClass(this.$el.querySelector(`.${hoverClass}`), hoverClass);
      }
      if (element) {
        addClass(element, hoverClass);
      }
    }
  },
};
</script>

<template>
  <div :id="componentAttrIdAutosuggest">
    <slot name="before-input" /><input
      :type="internal_inputProps.type"
      :value="internalValue"
      :autocomplete="internal_inputProps.autocomplete"
      role="combobox"
      :class="[isOpen ? `${componentAttrPrefix}__input--open` : '', internal_inputProps['class']]"
      v-bind="internal_inputProps"
      aria-autocomplete="list"
      :aria-owns="`${componentAttrPrefix}__results`"
      :aria-activedescendant="isOpen && currentIndex !== null ? `${componentAttrPrefix}__results-item--${currentIndex}` : ''"
      :aria-haspopup="isOpen ? 'true' : 'false'"
      :aria-expanded="isOpen ? 'true' : 'false'"
      @input="inputHandler"
      @keydown="handleKeyStroke"
      v-on="listeners"
    ><slot name="after-input" />
    <div :class="_componentAttrClassAutosuggestResultsContainer">
      <div
        v-if="isOpen"
        :class="_componentAttrClassAutosuggestResults"
        :aria-labelledby="componentAttrIdAutosuggest"
      >
        <slot name="before-suggestions" />
        <component
          :is="cs.type"
          v-for="(cs, key) in computedSections"
          :ref="getSectionRef(key)"
          :key="getSectionRef(key)"
          :current-index="currentIndex"
          :normalize-item-function="normalizeItem"
          :render-suggestion="renderSuggestion"
          :section="cs"
          :component-attr-prefix="componentAttrPrefix"
          @updateCurrentIndex="updateCurrentIndex"
        >
          <template
            :slot="`before-section-${cs.name || cs.label}`"
            slot-scope="{section, className}"
          >
            <slot
              :name="`before-section-${cs.name || cs.label}`"
              :section="section"
              :className="className"
            />
          </template>
          <template slot-scope="{ suggestion, _key }">
            <slot
              :suggestion="suggestion"
              :index="_key"
            >
              {{ suggestion.item }}
            </slot>
          </template>
          <template
            :slot="`after-section-${cs.name || cs.label}`"
            slot-scope="{section}"
          >
            <slot
              :name="`after-section-${cs.name || cs.label}`"
              :section="section"
            />
          </template>
          <template
            slot="after-section"
            slot-scope="{section}"
          >
            <slot
              name="after-section"
              :section="section"
            />
          </template>
        </component>
        <slot name="after-suggestions" />
      </div>
      <slot name="after-suggestions-container" />
    </div>
  </div>
</template>

<script>

/**
 * @typedef {Object} ResultSection
 * @prop {String} name - Name of the section
 * @prop {String} label - What is displayed in the section header, is exists
 * @prop {String} type - Used to decide which component to use for section
 * @prop {Number} limit - max number of results
 * @prop {Array} data - the results
 * @prop {Number} start_index - tracks section start reference point
 * @prop {Number} end_index - tracks section end reference point
 * @prop {Object} ulClass - class for <ul> of section e.g. { 'bg-blue': true }
 * @prop {Object} liClass - class for all <li>'s in section
 */

/**
 * @typedef {Object} ResultItem
 * @prop {Object<any>} item - data object
 * @prop {ResultSection.liClass} liClass
 * @prop {ResultSection.label} label
 * @prop {ResultSection.type} type
 */

import DefaultSection from "./parts/DefaultSection.js";
import { addClass, removeClass } from "./utils";

const defaultSectionConfig = {
  name: "default",
  type: "default-section"
}

export default {
  name: "Autosuggest",
  components: {
    /* eslint-disable-next-line vue/no-unused-components */
    DefaultSection
  },
  props: {
    /** Allows for v-model support */
    value: {
      type: String,
      default: null
    },
    /** v-binds to the <input /> tag for fine-grain control */
    inputProps: {
      type: Object,
      required: true
    },
    /** limits the number of suggestions for all sections */
    limit: {
      type: Number,
      required: false,
      default: Infinity
    },
    suggestions: {
      type: Array,
      required: true
    },
    renderSuggestion: {
      type: Function,
      required: false,
      default: null
    },
    getSuggestionValue: {
      type: Function,
      required: false,
      default: suggestion => {
        const item = suggestion.item;
        if (typeof item === "object" && item.hasOwnProperty("name")) {
          return item.name;
        } else {
          return item;
        }
      }
    },
    shouldRenderSuggestions: {
      type: Function,
      required: false,
      default: (totalResults, loading) => {
        return totalResults > 0 && !loading;
      }
    },
    sectionConfigs: {
      type: Object,
      required: false,
      default: () => {
        return {
          default: {
            onSelected: null
          }
        };
      }
    },
    onSelected: {
      type: Function,
      required: false,
      default: null
    },
    componentAttrIdAutosuggest: {
      type: String,
      required: false,
      default: "autosuggest"
    },
    componentAttrClassAutosuggestResultsContainer: {
      type: String,
      required: false,
      default: null  // `${componentAttrPrefix}__results-container`
    },
    componentAttrClassAutosuggestResults: {
      type: String,
      required: false,
      default: null // `${componentAttrPrefix}__results`
    },
    componentAttrPrefix: {
      type: String,
      required: false,
      default: "autosuggest"
    }
  },
  data() {
    return {
      internalValue: null,
      searchInputOriginal: null,
      currentIndex: null,
      /** @type ResultItem|null */
      currentItem: null,
      // TODO use event states instead of generic "loading" variable
      loading: false /** Helps with making sure the dropdown doesn't stay open after certain actions */,
      didSelectFromOptions: false,
      defaultInputProps: {
        type: 'text',
        autocomplete: "off",
      },
      /** @type Number */
      clientXMouseDownInitial: null
    };
  },
  computed: {
    /**
     * Merged object for defaults + user defined `<input/>` props
     */
    internal_inputProps() {
      return {
        ...this.defaultInputProps,
        ...this.inputProps
      }
    },
    listeners() {
      return {
        ...this.$listeners,
        input: e => {
          // Don't do anything native here, since we have inputHandler
          return
        },
        /**
         * Wrap native click handler to allow for added behavior
         */
        click: () => {
          /* eslint-disable-next-line vue/no-side-effects-in-computed-properties */
          this.loading = false;
          this.$listeners.click && this.$listeners.click(this.currentItem);
          this.$nextTick(() => {
            this.ensureItemVisible(this.currentItem, this.currentIndex);
          })
        },
        selected: () => {
          /**
           * Determine which onSelected to fire. This can be either from inside
           * a section's object, from the `@selected` event
           */
          if (
            this.currentItem &&
            this.sectionConfigs[this.currentItem.name] &&
            this.sectionConfigs[this.currentItem.name].onSelected
          ) {
            this.sectionConfigs[this.currentItem.name].onSelected(
              this.currentItem,
              this.searchInputOriginal
            );
          } else if (this.sectionConfigs["default"].onSelected) {
            this.sectionConfigs["default"].onSelected(null, this.searchInputOriginal);
          } else if (this.$listeners.selected) {
            this.$emit('selected', this.currentItem, this.currentIndex);
          }
          this.setChangeItem(null)
        }
      };
    },
    /**
     * @returns {Boolean}
     */
    isOpen() {
      return this.shouldRenderSuggestions(this.totalResults, this.loading)
    },
    /**
     * Normalize suggestions into sections based on defaults and section
     * configs.
     * @returns {Array<ResultSection>}
     */
    computedSections() {
      let tmpSize = 0
      return this.suggestions.map(section => {
        if (!section.data) return;

        const name = section.name ? section.name : defaultSectionConfig.name;
        let limit, label, type, ulClass, liClass = null

        if (this.sectionConfigs[name]) {
          limit = this.sectionConfigs[name].limit
          type = this.sectionConfigs[name].type
          label = this.sectionConfigs[name].label
          ulClass = this.sectionConfigs[name].ulClass
          liClass = this.sectionConfigs[name].liClass
        }

        /** Set defaults for section configs. */
        type = type ? type : defaultSectionConfig.type;

        limit = limit || this.limit
        limit = limit ? limit : Infinity;
        limit = section.data.length < limit ? section.data.length : limit;
        label = label ? label : section.label;

        const computedSection = {
          name,
          label,
          type,
          limit,
          data: section.data,
          start_index: tmpSize,
          end_index: tmpSize + limit - 1,
          ulClass,
          liClass
        }

        tmpSize += limit;

        return computedSection
      })
    },
    /**
     * Calculate number of results in each section.
     * @returns {Number}
     */
    totalResults () {
      return this.computedSections.reduce((acc, section) => {
        // For each section, make sure we calculate the size
        // based on how many are rendered, which maxes out at
        // the limit but can be less than the limit.
        if (!section) return acc
        const { limit, data } = section
        return acc + (data.length >= limit ? limit : data.length)
      }, 0)
    },

    _componentAttrClassAutosuggestResultsContainer () {
      return this.componentAttrClassAutosuggestResultsContainer || `${this.componentAttrPrefix}__results-container`
    },
    _componentAttrClassAutosuggestResults () {
      return this.componentAttrClassAutosuggestResults || `${this.componentAttrPrefix}__results`
    },
  },
  watch: {
    /**
     * Support initialValue
     */
    value: {
      handler(newValue){
        this.internalValue = newValue
      },
      immediate: true
    },
    /**
     * Emits opened/closed events
     * @returns {Boolean}
     */
    isOpen: {
      handler(newValue, oldValue){
        if (newValue !== oldValue) {
          this.$emit(newValue ? 'opened' : 'closed');
        }
      },
      immediate: true
    }
  },
  created() {
    this.loading = true;
  },
  mounted() {
    document.addEventListener("mouseup", this.onDocumentMouseUp);
    document.addEventListener("mousedown", this.onDocumentMouseDown);
  },
  beforeDestroy() {
    document.removeEventListener("mouseup", this.onDocumentMouseUp)
    document.removeEventListener("mousedown", this.onDocumentMouseDown)
  },
  methods: {
    /**
     * handler for @input <input /> events to support v-model behavior.
     * @param {InputEvent} e
     */
    inputHandler(e) {
      const newValue = e.target.value
      this.$emit('input', newValue)
      this.internalValue = newValue
      if (!this.didSelectFromOptions) {
        this.searchInputOriginal = newValue;
        this.currentIndex = null;
      }
    },
    /**
     * Helper for making sure the sectionRef getter is consistent
     * @returns {String}
     */
    getSectionRef(i) {
      return "computed_section_" + i;
    },
    /**
     * Helper for getting a suggestion item by index.
     * @returns {ResultItem}
     */
    getItemByIndex(index) {
      let obj = false;
      if (index === null) return obj;
      for (var i = 0; i < this.computedSections.length; i++) {
        if (
          index >= this.computedSections[i].start_index &&
          index <= this.computedSections[i].end_index
        ) {
          let trueIndex = index - this.computedSections[i].start_index;
          let childSection = this.$refs[this.getSectionRef(i)][0];
          if (childSection) {
            obj = this.normalizeItem(
              this.computedSections[i].name,
              this.computedSections[i].type,
              childSection.section.label,
              childSection.section.liClass,
              childSection.getItemByIndex(trueIndex)
            );
            break;
          }
        }
      }

      return obj;
    },
    /**
     * Handler for 'keydown' event. Does a number of things, including making
     * sure to ignore keycodes, ensure items are visible and also that the input
     * value is updated/reset according to where the user has keyed to.
     * @param {MouseEvent} e
     * @returns {void}
     */
    handleKeyStroke(e) {
      const { keyCode } = e;

      const ignoredKeyCodes = [
        16, // Shift
        9,  // Tab
        17, // ctrl
        18, // alt/option
        91, // OS Key
        93  // Right OS Key
      ];

      if (ignoredKeyCodes.indexOf(keyCode) > -1) {
        return;
      }

      const wasClosed = !this.isOpen
      this.loading = false;
      this.didSelectFromOptions = false;
      if (this.isOpen) {
        switch (keyCode) {
          case 40: // ArrowDown
          case 38: // ArrowUp
            e.preventDefault();
            if (keyCode === 38 && this.currentIndex === null) {
              break;
            }
            // Determine direction of arrow up/down and determine new currentIndex
            const direction = keyCode === 40 ? 1 : -1;
            const newIndex = (parseInt(this.currentIndex) || 0) + (wasClosed ? 0 : direction);

            this.setCurrentIndex(newIndex, this.totalResults);
            this.didSelectFromOptions = true;
            if (this.totalResults > 0 && this.currentIndex >= 0) {
              this.setChangeItem(this.getItemByIndex(this.currentIndex));
              this.didSelectFromOptions = true;
            } else if (this.currentIndex == -1) {
              this.setChangeItem(null)
              this.internalValue = this.searchInputOriginal;
              e.preventDefault();
            }

            this.$nextTick(() => {
              this.ensureItemVisible(this.currentItem, this.currentIndex);
            })
            break;
          case 13: // Enter
            e.preventDefault();

            if (this.totalResults > 0 && this.currentIndex >= 0) {
              this.setChangeItem(this.getItemByIndex(this.currentIndex), true);
              this.didSelectFromOptions = true;
            }

            this.loading = true;
            this.listeners.selected(this.didSelectFromOptions);
            break;
          case 27: // Escape
            /**
             * For 'search' input type, make sure the browser doesn't clear the
             * input when Escape is pressed.
             */
            this.loading = true;
            this.currentIndex = null;
            this.internalValue = this.searchInputOriginal;
            this.$emit('input', this.searchInputOriginal);
            e.preventDefault();
            break;
        }
      }
    },
    /**
     * Wrapper around currentItem setter to emit events and ensure to update the
     * searchInputOriginal when a user selects an option.
     * @param {ResultItem} item
     * @param {Boolean} overrideOriginalInput determine if the 'saved' original
     *   input should be updated. When a user selects an option, this will be
     *   updated, but if a user keys into the <input/> then the input will be
     *   reset to the searchInputOriginal.
     * @return {void}
     */
    setChangeItem(item, overrideOriginalInput = false) {
      if (this.currentIndex === null || !item) {
        this.currentItem = null;
        this.$emit('item-changed', null, null)
      } else if (item) {
        this.currentItem = item;
        this.$emit('item-changed', item, this.currentIndex)
        const v = this.getSuggestionValue(item)
        this.internalValue = v;
        if (overrideOriginalInput) {
          this.searchInputOriginal = v;
        }
        this.ensureItemVisible(item, this.currentIndex);
      }
    },

    /**
     * Function to standardize suggestion item object picked from sections
     * @returns {ResultItem}
     */
    normalizeItem(name, type, label, className, item) {
      return {
        name,
        type,
        label,
        liClass: item.liClass || className,
        item
      };
    },

    /**
     * Adjust the scroll position to the item in the suggestions overflow
     * @param {ResultItem} item - suggestion item
     * @param {Number} index - item index
     * @param {String} selector - selector of item that is overflowed
     */
    ensureItemVisible(item, index, selector) {
      const resultsScrollElement = this.$el.querySelector(
        selector || `.${this._componentAttrClassAutosuggestResults}`
      );

      if (!resultsScrollElement) {
        return
      }

      const itemElement = resultsScrollElement.querySelector(`#${this.componentAttrPrefix}__results-item--${index}`);
      if (!itemElement) {
        return;
      }

      const resultsScrollWindowHeight = resultsScrollElement.clientHeight;
      const resultsScrollScrollTop = resultsScrollElement.scrollTop;

      const itemHeight = itemElement.clientHeight;
      const currentItemScrollOffset = itemElement.offsetTop;

      if (
        itemHeight + currentItemScrollOffset >=
        resultsScrollScrollTop + resultsScrollWindowHeight
      ) {
        /** Current item goes below visible scroll window */
        resultsScrollElement.scrollTop = itemHeight + currentItemScrollOffset - resultsScrollWindowHeight;
      } else if (currentItemScrollOffset < resultsScrollScrollTop && resultsScrollScrollTop > 0) {
        /** Current item goes above visible scroll window */
        resultsScrollElement.scrollTop = currentItemScrollOffset;
      }
    },
    /**
     * @param {Number} index
     */
    updateCurrentIndex(index) {
      this.setCurrentIndex(index, -1, true);
    },
    /**
     * Helper to detect if the user clicked on the scrollbar
     * @param {MouseEvent} e
     * @param {Number} mouseX - horizontal position of the mouse relative to
     *   results e.g. an offset of clientX
     */
    clickedOnScrollbar(e, mouseX){
      const results = this.$el.querySelector(`.${this._componentAttrClassAutosuggestResults}`);

      const mouseIsInsideScrollbar = results && results.clientWidth <= (mouseX + 17) &&
        mouseX + 17 <= results.clientWidth + 34
      return e.target.tagName === 'DIV' && results && mouseIsInsideScrollbar || false;
    },
    /**
     * Capture mousedown position so we can use it to detect if the scrollbar
     * was clicked
     * @param {MouseEvent} e
     */
    onDocumentMouseDown(e) {
      var rect = e.target.getBoundingClientRect ? e.target.getBoundingClientRect() : 0;
      this.clientXMouseDownInitial = e.clientX - rect.left;
    },
    /**
     * 'mouseup' event handler
     * @param {MouseEvent} e
     */
    onDocumentMouseUp(e) {
      /** Do not re-render list on input click  */
      const isChild = this.$el.contains(e.target);

      /* Clicks outside of dropdown */
      if (!isChild) {
        this.loading = true;
        this.currentIndex = null;
        return;
      }

      if (e.target.tagName === 'INPUT' ||
        (this.clickedOnScrollbar(e, this.clientXMouseDownInitial))) {
        return;
      }

      /** Selects an item in the dropdown */
      this.loading = true;
      this.didSelectFromOptions = true;
      this.setChangeItem(this.getItemByIndex(this.currentIndex), true);
      this.listeners.selected(true);
    },
    /**
     * Sets the current index of the highlighted object, useful for aria
     * attributes like `aria-activedescendant` and toggling which result item
     * is highlighted.
     * @param {Number} newIndex
     * @param {Number} limit
     * @param {Boolean} onHover detects if the user is hovering vs. selected
     */
    setCurrentIndex(newIndex, limit = -1, onHover = false) {
      let adjustedValue = newIndex;

      /**
       * If you're not hovering, you might be keying outside of the bounds, so
       * we need to make sure that we adjust for the limits.
       */
      if (!onHover){
        const hitLowerLimt = this.currentIndex === null
        const hitUpperLimit = newIndex >= limit
        if (hitLowerLimt || hitUpperLimit) {
          adjustedValue = 0;
        }
      }

      this.currentIndex = adjustedValue;
      const element = this.$el.querySelector(`#${this.componentAttrPrefix}__results-item--${this.currentIndex}`);
      const hoverClass = `${this.componentAttrPrefix}__results-item--highlighted`;

      if (this.$el.querySelector(`.${hoverClass}`)) {
        removeClass(this.$el.querySelector(`.${hoverClass}`), hoverClass);
      }
      if (element) {
        addClass(element, hoverClass);
      }
    }
  },
};
</script>


Нижний регистр в поле тренажера

Нижний регистр в поле тренажера

<template>

  <transition appear name="fade" mode="out-in">

Выберите столбцы для практики.

          <template v-for="(cells, i) in elements">
            <template v-for="cell in cells">

{{ cell[syllabaryIndex] ? cell[syllabaryIndex] : ' ' }}

{{ cell[answerIndex] ? cell[answerIndex] : ' ' }}

            </template>
          </template>
          <template v-for="i in elements[0].length">

                <input class="cursor-pointer" type="checkbox" :value="i - 1" v-model="checkedColumns" @change="pickElements">

          </template>

          <button class="btn btn-primary" :disabled="this.picked.length < 2" @click="practice">Практиковаться</button>
          <button class="btn btn-default" @click="checkAll">Выбрать все</button>
          <button class="btn btn-default" @click="uncheckAll">Снять выделение</button>
          <transition name="fade-fast" mode="out-in">
            <button class="btn btn-default" @click="switchSyllabary" :key="syllabaryLabel">{{ syllabaryLabel }}</button>
          </transition>

Ответ засчитывается автоматически без нажатия клавиши ввода. Пробел подсказывает ответ.

{{ question }}

{{ answer }}

            <input class="form-control text-center" autocapitalize="none" :autofocus="focus" :value="input" @input="checkInput($event.target.value)" @keydown.space.prevent="revealAnswer">

<button class="btn btn-default" @click="pick">Назад к выбору</button>

 0">Отвечено: {{ answered }}

  </transition>

</template>

<template>

  <transition appear name="fade" mode="out-in">

Выберите столбцы для практики.

          <template v-for="(cells, i) in elements">
            <template v-for="cell in cells">

{{ cell[syllabaryIndex] ? cell[syllabaryIndex] : ' ' }}

{{ cell[answerIndex] ? cell[answerIndex] : ' ' }}

            </template>
          </template>
          <template v-for="i in elements[0].length">

                <input class="cursor-pointer" type="checkbox" :value="i - 1" v-model="checkedColumns" @change="pickElements">

          </template>

          <button class="btn btn-primary" :disabled="this.picked.length < 2" @click="practice">Практиковаться</button>
          <button class="btn btn-default" @click="checkAll">Выбрать все</button>
          <button class="btn btn-default" @click="uncheckAll">Снять выделение</button>
          <transition name="fade-fast" mode="out-in">
            <button class="btn btn-default" @click="switchSyllabary" :key="syllabaryLabel">{{ syllabaryLabel }}</button>
          </transition>

Ответ засчитывается автоматически без нажатия клавиши ввода. Пробел подсказывает ответ.

{{ question }}

{{ answer }}

            <input class="form-control text-center" :autofocus="focus" :value="input" @input="checkInput($event.target.value)" @keydown.space.prevent="revealAnswer">

<button class="btn btn-default" @click="pick">Назад к выбору</button>

 0">Отвечено: {{ answered }}

  </transition>

</template>


Add canvas

Add canvas

<template>

{{ msg }}

    <files :files="files"></files>
    <canvas width="400" height="300" ref="canvas">
    </canvas>

</template>

<template>

{{ msg }}

    <files :files="files"></files>

</template>


Add subtitle

Add subtitle

<template>

Nepohualtzintzin

      Ábaco Maya

    <abacus :initCols="cols" @numberChange="changeNumber"></abacus>

Vigecimal: {{ number | toUpperCase }}

Decimal: {{ number | toBaseTen }}

</template>

<template>

      Nepohualtzintzin

      Ábaco Maya por Diego Said Anaya Mancilla

    <abacus :initCols="cols" @numberChange="changeNumber"></abacus>

Vigecimal: {{ number | toUpperCase }}

Decimal: {{ number | toBaseTen }}

</template>


docs(Howto): changed help text a bit

docs(Howto): changed help text a bit

<template>

adding stuff to tildetv

to add videos to tildetv you can use the command-line tool provided by ~karlen.

$ ~karlen/tv/addtotv -h

Usage: addtotv [options] arg

Options:
  -h, --help            show this help message and exit
  -l LINK, --link=LINK  URL to youtube video quoted. e.g. -l
                        "https://www.youtube.com/watch?v=z7VYVjR_nwE"
  -t TAGS, --tags=TAGS  Tags, comma separated and quoted. e.g. -t "Train, Ice,
                        cold"
  -c CW, --contentwarning=CW
                        Content warning, comma separated and quoted. e.g. -c
                        "Doggo, pupper, Joyce"
  -d DESC, --description=DESC
                        Description of the video, quoted. e.g. "A real
                        angerery pupper writing about pubs"

</template>

<template>

adding stuff to tildetv

to add videos to tildetv you can use the command-line tool provided by ~karlen.

$ ~karlen/tv/addtotv -h

Usage: addtotv [options] arg

Options:
  -h, --help            show this help message and exit
  -l LINK, --link=LINK  URL to youtube video quoted -l
                        "https://www.youtube.com/watch?v=z7VYVjR_nwE"
  -t TAGS, --tags=TAGS  Tags, comma separated and quoted, e.g. -t "Train, Ice,
                        cold"
  -c CW, --contentwarning=CW
                        Content warning, comma separatedand quoted, e.g -c
                        "Doggo, pupper, Joyce"
  -d DESC, --desc=DESC  Description of the video, quoted "A real angerery
                        pupper writing about pubs"

</template>


Use a processor to get around the VueJS's "no template variables" limitation

<template>

October 1st — Church Picnic

             Register today

You are registered.

You must update your Profile with your name before you can register. To do so, click <router-link to="/profile">here</router-link>.

You must register your name and phone number in order to participate in this event. To do so, click <router-link to="/login">here</router-link>.

          <form>

To start, click the button to Register yourself.

After that, you can add and register other persons, for example: your family members, or your friends (Adventist or not).

The Church will cover both Transportation and the Entry fees when we get there, but only if the person is a Member of the Church or a Sabbath School class.

                  <button @click="showSubForm = true" class="btn btn-info" type="button">
                    <template v-if="!selfInSubform && !editMode">Register myself</template>
                    <template v-else>Register another person</template>
                  </button>
                  <button :disabled="subFormSelectedIndex === -1" @click="removePersonFromSubform"
                          class="btn btn-danger" type="button">Remove</button>

                    Short-name of the person
                    <input v-model="subFormData.name" type="text" placeholder="(mandatory)" class="form-control" />

                    Select the appropriate age

                        <input v-model="subFormData.mustPayEntryFee" :value="false" name="yearCategory" type="radio" />Less than 5 years old

                        <input v-model="subFormData.mustPayEntryFee" :value="true" name="yearCategory" type="radio" />5 years or Older

                    Please check what applies

                        <input v-model="subFormData.wantsTransportation" type="checkbox" />Will go with the Common Transportation Bus (don't have own Car/Transportation)

                        <input v-model="subFormData.isChurchMember" type="checkbox" />Church member

                        <input v-model="subFormData.isSabbathSchoolMember" type="checkbox" />Sabbath School member

                    <button @click.prevent.stop="addPersonToSubform"
                        :disabled="selfInSubform && subFormData.name.length < 2" class="btn btn-primary" type="button">Add to the List</button>
                    <button @click="showSubForm = false" class="btn btn-default" type="button">Do not add</button>

                      Name
                      Fees

                        {{registration.name}}
                        {{memberName(registration.memberId)}}

                        (Covered by the Church)
                        {{(registration.mustPayEntryFee ? 2000 : 0) + (registration.wantsTransportation ? 4000 : 0)}} Ar

                      TOTAL
                      {{subFormTotalFees}} Ar

                  <button @click.prevent.stop="confirmRegistration" :disabled="!selfInSubform && !editMode"
                          class="btn btn-primary" type="button"> Confirm registration</button>
                  <button @click="showRegistrationForm = false, showSubForm = false" class="btn btn-default" type="button">Cancel registration</button>

          </form>

 0" class="lead text-center bg-danger">But you need to pay the fees, remaining {{userPayments.due - userPayments.paid}} Ar (of {{userPayments.due}} Ar). Remember, the deadline is on Wednesday.

Here are the list of those who are going ...

Those who registered first will enter first in the Transportation and when we arrive.

          <form>

              Amount
              <input v-model.number="paymentFormAmount" type="text" class="form-control" />

              <button @click="addPayment" :disabled="paymentFormAmount === 0 || registrationSelected === ''" class="btn btn-primary" type="button">Add</button>

          </form>

              Registration by
              Names

              Entry fees
              Common Bus Transportation

              Due

                  Loading ...

                {{memberName(row.memberId)}}

                ({{membersLookup(row.memberId).phoneNumber}})

                  —
                   Edit
                   Remove

                  ({{moment(row.timestamp).format('Do MMMM, HH:mm:ss')}})

                    {{memberName(reg.memberId)}}
                    {{reg.name}}

                    (Church)
                    2000 Ar

                    (Church)
                    4000 Ar

                 0" class="text-danger">{{row.totalDue}} Ar

              TOTAL
              {{processedRegistrationTree.totalRegistrantsCount}} registrants

</template>

<template>

October 1st — Church Picnic

             Register today

You are registered.

You must update your Profile with your name before you can register. To do so, click <router-link to="/profile">here</router-link>.

You must register your name and phone number in order to participate in this event. To do so, click <router-link to="/login">here</router-link>.

          <form>

To start, click the button to Register yourself.

After that, you can add and register other persons, for example: your family members, or your friends (Adventist or not).

The Church will cover both Transportation and the Entry fees when we get there, but only if the person is a Member of the Church or a Sabbath School class.

                  <button @click="showSubForm = true" class="btn btn-info" type="button">
                    <template v-if="!selfInSubform && !editMode">Register myself</template>
                    <template v-else>Register another person</template>
                  </button>
                  <button :disabled="subFormSelectedIndex === -1" @click="removePersonFromSubform"
                          class="btn btn-danger" type="button">Remove</button>

                    Short-name of the person
                    <input v-model="subFormData.name" type="text" placeholder="(mandatory)" class="form-control" />

                    Select the appropriate age

                        <input v-model="subFormData.mustPayEntryFee" :value="false" name="yearCategory" type="radio" />Less than 5 years old

                        <input v-model="subFormData.mustPayEntryFee" :value="true" name="yearCategory" type="radio" />5 years or Older

                    Please check what applies

                        <input v-model="subFormData.wantsTransportation" type="checkbox" />Will go with the Common Transportation Bus (don't have own Car/Transportation)

                        <input v-model="subFormData.isChurchMember" type="checkbox" />Church member

                        <input v-model="subFormData.isSabbathSchoolMember" type="checkbox" />Sabbath School member

                    <button @click.prevent.stop="addPersonToSubform"
                        :disabled="selfInSubform && subFormData.name.length < 2" class="btn btn-primary" type="button">Add to the List</button>
                    <button @click="showSubForm = false" class="btn btn-default" type="button">Do not add</button>

                      Name
                      Fees

                        {{registration.name}}
                        {{memberName(registration.memberId)}}

                        (Covered by the Church)
                        {{(registration.mustPayEntryFee ? 2000 : 0) + (registration.wantsTransportation ? 4000 : 0)}} Ar

                      TOTAL
                      {{subFormTotalFees}} Ar

                  <button @click.prevent.stop="confirmRegistration" :disabled="!selfInSubform && !editMode"
                          class="btn btn-primary" type="button"> Confirm registration</button>
                  <button @click="showRegistrationForm = false, showSubForm = false" class="btn btn-default" type="button">Cancel registration</button>

          </form>

 0" class="lead text-center bg-danger">But you need to pay the fees, remaining {{userPayments.due - userPayments.paid}} Ar (of {{userPayments.due}} Ar). Remember, the deadline is on Wednesday.

Here are the list of those who are going ...

Those who registered first will enter first in the Transportation and when we arrive.

          <form>

              Amount
              <input v-model.number="paymentFormAmount" type="text" class="form-control" />

              <button @click="addPayment" :disabled="paymentFormAmount === 0 || registrationSelected === ''" class="btn btn-primary" type="button">Add</button>

          </form>

              Registration by
              Names

              Entry fees
              Common Bus Transportation

              Due

                  Loading ...

                {{memberName(memberId)}}

                ({{membersLookup(memberId).phoneNumber}})

                  —
                   Edit
                   Remove

                  ({{moment(data.timestamp).format('Do MMMM, HH:mm:ss')}})

                    {{memberName(reg.memberId)}}
                    {{reg.name}}

                    (Church)
                    2000 Ar

                    (Church)
                    4000 Ar

                {{computeRemainingDue(data.details, memberId)}} Ar

</template>


Background image on login

Background image on login

<template>

Speak, friend, and enter

            <input type="text" placeholder="Email" v-model="email">

            <input type="password" placeholder="Password" v-model="password">

        <button class="ui fluid large orange submit button" @click="login">Connection</button>

Don't have an account?
        <router-link to="/signup">Create one!</router-link>

</template>


Fix #483

Fix #483

Add `text-overflow: ellipsis` for too long tags.

<template>

      <slot name="caret" :toggle="toggle">

      </slot>
      <slot name="clear" :search="search"></slot>

 0">
          <template v-for="option of visibleValue" @mousedown.prevent>
            <slot name="tag" :option="option" :search="search" :remove="removeElement">

            </slot>
          </template>

        <template v-if="internalValue && internalValue.length > limit">

        </template>
        <transition name="multiselect__loading">
          <slot name="loading">

</slot>
        </transition>
        <input
          ref="search"
          :name="name"
          :id="id"
          type="text"
          autocomplete="off"
          :placeholder="placeholder"
          v-if="searchable"
          :style="inputStyle"
          :value="isOpen ? search : currentOptionLabel"
          :disabled="disabled"
          :tabindex="tabindex"
          @input="updateSearch($event.target.value)"
          @focus.prevent="activate()"
          @blur.prevent="deactivate()"
          @keyup.esc="deactivate()"
          @keydown.down.prevent="pointerForward()"
          @keydown.up.prevent="pointerBackward()"
          @keydown.enter.prevent.stop.self="addPointerElement($event)"
          @keydown.delete.stop="removeLastElement()"
          class="multiselect__input"/>

      <transition name="multiselect">

            <slot name="beforeList"></slot>

                <slot name="maxElements">Maximum of {{ max }} options selected. First remove a selected option to select another.</slot>

            <template v-if="!max || internalValue.length < max">

                    <slot name="option" :option="option" :search="search">
                      {{ getOptionLabel(option) }}
                    </slot>

                    <slot name="option" :option="option" :search="search">
                      {{ getOptionLabel(option) }}
                    </slot>

            </template>

                <slot name="noResult">No elements found. Consider changing the search query.</slot>

            <slot name="afterList"></slot>

      </transition>

</template>


Update reddit address

Update reddit address

<template>

        Stardew Community Checklist by John Cleaver. The source code is licensed
        MIT.

        <router-link to="/changelog">Changelog</router-link>

        Bundle and Item information is from the Stardew Valley Wiki
        and is used under the CC BY-NC-SA 3.0 License.

        Stardew Valley © Chucklefish LTD.
        Developed by ConcernedApe.

</template>


fix form text color in dialog

fix form text color in dialog

<template>
  <form class="form">
    <slot></slot>
  </form>
</template>

<script>
export default {
  name: 'ctForm',
  props: {
    labelWidth: {
      type: String,
      default: '8em',
    },
    labelPosition: {
      type: String,
      default: 'right',
    },
    // 是否显示分号，默认显示
    semicolon: {
      type: Boolean,
      default: true,
    },
    model: Object,
    rules: Object,
    showMessage: {
      type: Boolean,
      default: true,
    },
  },
  watch: {
    rules() {
      this.validate()
    },
  },
  data() {
    return {
      fields: [],
    }
  },
  created() {
    // 监听增加验证规则
    this.$on('ct.form.addField', (field) => {
      if (field) {
        this.fields.push(field)
      }
    })
    // 监听移除验证规则
    this.$on('ct.form.removeField', (field) => {
      if (field.prop) {
        this.fields.splice(this.fields.indexOf(field), 1)
      }
    })
  },
  methods: {
    resetFields() {
      if (!this.model) {
        if (process.env.NODE_ENV !== 'production') console.warn('[Form]model is required for resetFields to work.')
        return
      }
      this.fields.forEach(field => {
        field.resetField()
      })
    },
    validate(callback) {
      if (!this.model) {
        console.warn('[Form]model is required for validate to work!')
        return
      }
      let valid = true
      let count = 0
      // 如果需要验证的fields为空，调用验证时立刻返回callback
      if (this.fields.length === 0 && callback) {
        callback(true)
      }
      this.fields.forEach((field, index) => {
        field.validate('', errors => {
          if (errors) {
            valid = false
          }
          if (typeof callback === 'function' && ++count === this.fields.length) {
            callback(valid)
          }
        })
      })
    },
    validateField(prop, cb) {
      const field = this.fields.filter((_field) => _field.prop === prop)[0]
      if (!field) {
        throw new Error('must call validateField with valid prop string!')
      }
      field.validate('', cb)
    },
  },
}
</script>

<style lang="stylus">
@import '../../assets/stylus/color'

.form
  display: block
  border-radius: 4px
  font-size: 12px
  color: #000
  .form-title
    height 28px
    line-height 28px
    margin-bottom 8px
    font-size 0
    .title
      font-size 12px
      display inline-block
      // padding-right: 12px
      min-width: 6em
      margin-right 12px
      &.active
        color: $color-main
        border-bottom 1px solid $color-main
    .btn
      margin-right 8px
  &.form-group
    padding: 8px
    & > section
      padding: 8px
      border: 1px solid #ccc
      border-radius: 1px
  .section
    margin-bottom 12px
    transition: all 0.3s
  & > section
    margin-bottom 12px
    transition: all 0.3s
    &.is-required > label::before
      content: "*"
      color: $color-red
      margin-right: 4px
    & > label
      display block
      padding: 7px 0
      position: relative
      & > .text-red
        position: absolute
        right: 4px
        top: 0
    & > .form-content
      display block
      line-height: 32px
      position: relative
      & > .ct-input,
      & > .ct-select
        width: 300px
      .ct-form-content-error
        font-size: 12px
        color: $color-red
        line-height: 1
        margin-top: 3px
        position: absolute
        top: 100%
        left: 0
    // TODO del
    // & > label + .form-content
    //   margin-left: 6em
.form-action
  margin: 0 auto
  padding: 8px 0
  font-size: 0
  .btn
    margin-right: 8px
    &:last-child
      margin-right: 0

// 错误提示
.form section.is-error
  margin-bottom: 20px
  .ct-input > input,
  .ct-input > textarea
  .ct-select > .ct-select-name
    border-color: $color-red
</style>

<template>
  <form class="form">
    <slot></slot>
  </form>
</template>

<script>
export default {
  name: 'ctForm',
  props: {
    labelWidth: {
      type: String,
      default: '8em',
    },
    labelPosition: {
      type: String,
      default: 'right',
    },
    // 是否显示分号，默认显示
    semicolon: {
      type: Boolean,
      default: true,
    },
    model: Object,
    rules: Object,
    showMessage: {
      type: Boolean,
      default: true,
    },
  },
  watch: {
    rules() {
      this.validate()
    },
  },
  data() {
    return {
      fields: [],
    }
  },
  created() {
    // 监听增加验证规则
    this.$on('ct.form.addField', (field) => {
      if (field) {
        this.fields.push(field)
      }
    })
    // 监听移除验证规则
    this.$on('ct.form.removeField', (field) => {
      if (field.prop) {
        this.fields.splice(this.fields.indexOf(field), 1)
      }
    })
  },
  methods: {
    resetFields() {
      if (!this.model) {
        if (process.env.NODE_ENV !== 'production') console.warn('[Form]model is required for resetFields to work.')
        return
      }
      this.fields.forEach(field => {
        field.resetField()
      })
    },
    validate(callback) {
      if (!this.model) {
        console.warn('[Form]model is required for validate to work!')
        return
      }
      let valid = true
      let count = 0
      // 如果需要验证的fields为空，调用验证时立刻返回callback
      if (this.fields.length === 0 && callback) {
        callback(true)
      }
      this.fields.forEach((field, index) => {
        field.validate('', errors => {
          if (errors) {
            valid = false
          }
          if (typeof callback === 'function' && ++count === this.fields.length) {
            callback(valid)
          }
        })
      })
    },
    validateField(prop, cb) {
      const field = this.fields.filter((_field) => _field.prop === prop)[0]
      if (!field) {
        throw new Error('must call validateField with valid prop string!')
      }
      field.validate('', cb)
    },
  },
}
</script>

<style lang="stylus">
@import '../../assets/stylus/color'

.form
  display: block
  border-radius: 4px
  font-size: 12px
  .form-title
    height 28px
    line-height 28px
    margin-bottom 8px
    font-size 0
    .title
      font-size 12px
      display inline-block
      // padding-right: 12px
      min-width: 6em
      margin-right 12px
      &.active
        color: $color-main
        border-bottom 1px solid $color-main
    .btn
      margin-right 8px
  &.form-group
    padding: 8px
    & > section
      padding: 8px
      border: 1px solid #ccc
      border-radius: 1px
  .section
    margin-bottom 12px
    transition: all 0.3s
  & > section
    margin-bottom 12px
    transition: all 0.3s
    &.is-required > label::before
      content: "*"
      color: $color-red
      margin-right: 4px
    & > label
      display block
      padding: 7px 0
      position: relative
      & > .text-red
        position: absolute
        right: 4px
        top: 0
    & > .form-content
      display block
      line-height: 32px
      position: relative
      & > .ct-input,
      & > .ct-select
        width: 300px
      .ct-form-content-error
        font-size: 12px
        color: $color-red
        line-height: 1
        margin-top: 3px
        position: absolute
        top: 100%
        left: 0
    // TODO del
    // & > label + .form-content
    //   margin-left: 6em
.form-action
  margin: 0 auto
  padding: 8px 0
  font-size: 0
  .btn
    margin-right: 8px
    &:last-child
      margin-right: 0

// 错误提示
.form section.is-error
  margin-bottom: 20px
  .ct-input > input,
  .ct-input > textarea
  .ct-select > .ct-select-name
    border-color: $color-red
</style>


get data and load map

get data and load map

<template>

      <l-map
        ref="map"
        :center="center"
        :options="{
          scrollWheelZoom: false
        }"
      >
        <l-tile-layer
          :url="url"
          :options="tileOptions"
          :detect-retina="true"
        />
        <l-feature-group
          v-if="geojsons"
          ref="features"
        >
          <l-geo-json
            v-for="geojson in geojsons"
            :key="geojson.index"
            :geojson="geojson"
            :options="geojsonOptions"
          />
        </l-feature-group>

      </l-map>

    <button
      class="btn-tour-virtual"
      @click="backInvestments"
      >
      {{ titleButton }}
    </button>

</template>


lint fixes

lint fixes

Signed-off-by: Rohit Yadav <902b9eb5dd62819678163bd5e00fbf0cbc7c264b@shapeblue.com>

<template>

                  {{ opt.name }}

                Cancel

                Submit

        {{ key }}:
        {{ value }}

       record.id"
        :loading="loading"
        :columns="columns"
        :dataSource="items"
        :scroll="{ x: '100%' }"
        :pagination="{ position: 'bottom', size: 'small', showSizeChanger: true }"
        :rowSelection="{selectedRowKeys: selectedRowKeys, onChange: onSelectChange}"
        :rowClassName="getRowClassName"
      >
        <template slot="footer">

            {{ `Selected ${selectedRowKeys.length} items` }}

        </template>

          <router-link :to="{ path: $route.name + '/' + record.id }">{{ text }}</router-link>

          <router-link :to="{ path: $route.name + '/' + record.id }">{{ text }}</router-link>

          <router-link :to="{ path: '/vm/' + record.virtualmachineid }">{{ text }}</router-link>

        <template slot="state" slot-scope="text">

            <template slot="title">
              {{ text }}
            </template>

        </template>

          <router-link :to="{ path: '/account/' + record.accountid }">{{ text }}</router-link>

          <router-link :to="{ path: '/domain/' + record.domainid }">{{ text }}</router-link>

          <router-link :to="{ path: '/zone/' + record.zoneid }">{{ text }}</router-link>

          <router-link :to="{ path: '/guestnetwork/' + record.guestnetworkid }">{{ text }}</router-link>

</template>

<template>

                  {{ opt.name }}

                Cancel

                Submit

        {{ key }}:
        {{ value }}

       record.id"
        :loading="loading"
        :columns="columns"
        :dataSource="items"
        :scroll="{ x: '100%' }"
        :pagination="{ position: 'bottom', size: 'small', showSizeChanger: true }"
        :rowSelection="{selectedRowKeys: selectedRowKeys, onChange: onSelectChange}"
        :rowClassName="getRowClassName"
      >
        <template slot="footer">

            {{ `Selected ${selectedRowKeys.length} items` }}

        </template>

          <router-link :to="{ path: $route.name + '/' + record.id }">{{ text }}</router-link>

          <router-link :to="{ path: $route.name + '/' + record.id }">{{ text }}</router-link>

          <router-link :to="{ path: '/vm/' + record.virtualmachineid }">{{ text }}</router-link>

        <template slot="state" slot-scope="text, record">

            <template slot='title'>
              {{ text }}
            </template>

        </template>

          <router-link :to="{ path: '/account/' + record.accountid }">{{ text }}</router-link>

          <router-link :to="{ path: '/domain/' + record.domainid }">{{ text }}</router-link>

          <router-link :to="{ path: '/zone/' + record.zoneid }">{{ text }}</router-link>

          <router-link :to="{ path: '/guestnetwork/' + record.guestnetworkid }">{{ text }}</router-link>

</template>


Smaller box shadow in chat

Smaller box shadow in chat

<template>

    <loading v-if="loading" />

          <from-now :date="message.sentAt"></from-now>

{{ message.body }}

      <form class="row small-gutter" v-on:submit.stop.prevent="send()">

          <input type="text" class="chat-input full-width" v-model="newMessage">

          <button class="primary circular" type="submit">
            send
          </button>

      </form>

</template>


Add a proper method to get total users and on blur method

On blur method in search input

<template>

		<loading v-if="showLoadBar"></loading>

We are {{ totalUsers }} programmers and organizations based in Angola 🙌 👏.

					Followers
					Joined date
					Number of repositories

					Ascending
					Descending

					Clear

					<input type="text" v-model="searchTerm" v-on:keyup.enter="LoadProfiles()" v-on:blur="LoadProfiles()" placeholder="Search by name" class="input">

						<router-link :to="{ name: 'Profile', params: { username: user.login } }" :title="'Open ' + user.login + '`s profile'">

						</router-link>

							@{{ user.login }}

			Load more profiles

</template>

<template>

		<loading v-if="showLoadBar"></loading>

We are {{ totalUsers }} programmers and organizations based in Angola 🙌 👏.

					Followers
					Joined date
					Number of repositories

					Ascending
					Descending

					Clear

					<input type="text" v-model="searchTerm" v-on:keyup.enter="LoadProfiles()" placeholder="Search by name" class="input">

						<router-link :to="{ name: 'Profile', params: { username: user.login } }" :title="'Open ' + user.login + '`s profile'">

						</router-link>

							@{{ user.login }}

			Load more profiles

</template>


Added intro to the homepage

Added intro to the homepage

<template>

{{ intro }}

{{ item.Title.value }}

</template>

<template>

{{ item.Title.value }}

</template>


working

working

<template>

        <slot></slot>

</template>

<template>

        <slot></slot>

</template>


Fix missing quotes in style value

Fix missing quotes in style value

<template>

  <CardLink
    v-if="channel"
    :to="to"
    class="base-channel-card"
  >
    <h3 class="title">
      <TextTruncatorCss
        dir="auto"
        :text="channel.name"
        :maxLines="1"
      />
    </h3>

    <KFixedGrid
      numCols="4"
      gutter="16"
      class="content"
      :style="{ borderTop: `1px solid ${$themeTokens.fineLine}` }"
    >
      <KFixedGridItem span="1">
        <ChannelThumbnail
          v-if="channel.thumbnail"
          :thumbnailUrl="channel.thumbnail"
          class="channel-thumbnail-wrapper"
        />
      </KFixedGridItem>
      <!--
        unset text-align to fix `KFixedGridItem`'s `text-align: right`
        conflicting with  `dir="auto"`
      -->
      <KFixedGridItem
        span="3"
        :style="{ textAlign: 'unset' }"
      >
        <TextTruncatorCss
          v-if="description"
          dir="auto"
          :text="description"
          :maxLines="6"
        />
      </KFixedGridItem>
    </KFixedGrid>
  </CardLink>

</template>

<script>

  import TextTruncatorCss from 'kolibri.coreVue.components.TextTruncatorCss';
  import ChannelThumbnail from '../thumbnails/ChannelThumbnail';
  import CardLink from './CardLink';

  export default {
    name: 'BaseChannelCard',
    components: {
      TextTruncatorCss,
      ChannelThumbnail,
      CardLink,
    },
    props: {
      channel: {
        type: Object,
        required: true,
      },
      /**
       * vue-router link object
       */
      to: {
        type: Object,
        required: true,
      },
    },
    computed: {
      description() {
        return this.channel ? this.channel.tagline || this.channel.description : '';
      },
    },
  };

</script>

<style lang="scss" scoped>

  .base-channel-card {
    height: 218px;
  }

  .title {
    margin-top: 6px;
    margin-bottom: 12px;
  }

  .content {
    padding: 14px;
    // make the top border take the full card width
    margin-right: -16px;
    margin-left: -16px;
  }

  .channel-thumbnail-wrapper {
    height: 132px;
  }

</style>

<template>

  <CardLink
    v-if="channel"
    :to="to"
    class="base-channel-card"
  >
    <h3 class="title">
      <TextTruncatorCss
        dir="auto"
        :text="channel.name"
        :maxLines="1"
      />
    </h3>

    <KFixedGrid
      numCols="4"
      gutter="16"
      class="content"
      :style="{ borderTop: `1px solid ${$themeTokens.fineLine}` }"
    >
      <KFixedGridItem span="1">
        <ChannelThumbnail
          v-if="channel.thumbnail"
          :thumbnailUrl="channel.thumbnail"
          class="channel-thumbnail-wrapper"
        />
      </KFixedGridItem>
      <!--
        unset text-align to fix `KFixedGridItem`'s `text-align: right`
        conflicting with  `dir="auto"`
      -->
      <KFixedGridItem
        span="3"
        :style="{ textAlign: unset }"
      >
        <TextTruncatorCss
          v-if="description"
          dir="auto"
          :text="description"
          :maxLines="6"
        />
      </KFixedGridItem>
    </KFixedGrid>
  </CardLink>

</template>

<script>

  import TextTruncatorCss from 'kolibri.coreVue.components.TextTruncatorCss';
  import ChannelThumbnail from '../thumbnails/ChannelThumbnail';
  import CardLink from './CardLink';

  export default {
    name: 'BaseChannelCard',
    components: {
      TextTruncatorCss,
      ChannelThumbnail,
      CardLink,
    },
    props: {
      channel: {
        type: Object,
        required: true,
      },
      /**
       * vue-router link object
       */
      to: {
        type: Object,
        required: true,
      },
    },
    computed: {
      description() {
        return this.channel ? this.channel.tagline || this.channel.description : '';
      },
    },
  };

</script>

<style lang="scss" scoped>

  .base-channel-card {
    height: 218px;
  }

  .title {
    margin-top: 6px;
    margin-bottom: 12px;
  }

  .content {
    padding: 14px;
    // make the top border take the full card width
    margin-right: -16px;
    margin-left: -16px;
  }

  .channel-thumbnail-wrapper {
    height: 132px;
  }

</style>


DatePicker: fix initial value of [] in range type

<template>
  <transition name="el-zoom-in-top" @after-leave="$emit('dodestroy')">

        <slot name="sidebar" class="el-picker-panel__sidebar"></slot>

          <button
            type="button"
            class="el-picker-panel__shortcut"
            v-for="shortcut in shortcuts"
            @click="handleShortcutClick(shortcut)">{{shortcut.text}}</button>

                <el-input
                  size="small"
                  ref="minInput"
                  :placeholder="t('el.datepicker.startDate')"
                  class="el-date-range-picker__editor"
                  :value="minVisibleDate"
                  @input.native="handleDateInput($event, 'min')"
                  @change.native="handleDateChange($event, 'min')" />

                <el-input
                  size="small"
                  :placeholder="t('el.datepicker.startTime')"
                  class="el-date-range-picker__editor"
                  :value="minVisibleTime"
                  @focus="minTimePickerVisible = !minTimePickerVisible"
                  @change.native="handleTimeChange($event, 'min')" />
                <time-picker
                  :picker-width="minPickerWidth"
                  ref="minTimePicker"
                  :date="minDate"
                  @pick="handleMinTimePick"
                  :visible="minTimePickerVisible">
                </time-picker>

                <el-input
                  size="small"
                  :placeholder="t('el.datepicker.endDate')"
                  class="el-date-range-picker__editor"
                  :value="maxVisibleDate"
                  :readonly="!minDate"
                  @input.native="handleDateInput($event, 'max')"
                  @change.native="handleDateChange($event, 'max')" />

                <el-input
                  size="small"
                  ref="maxInput"
                  :placeholder="t('el.datepicker.endTime')"
                  class="el-date-range-picker__editor"
                  :value="maxVisibleTime"
                  @focus="minDate && (maxTimePickerVisible = !maxTimePickerVisible)"
                  :readonly="!minDate"
                  @change.native="handleTimeChange($event, 'max')" />
                <time-picker
                  :picker-width="maxPickerWidth"
                  ref="maxTimePicker"
                  :date="maxDate"
                  @pick="handleMaxTimePick"
                  :visible="maxTimePickerVisible"></time-picker>

              <button
                type="button"
                @click="prevYear"
                class="el-picker-panel__icon-btn el-icon-d-arrow-left"></button>
              <button
                type="button"
                @click="prevMonth"
                class="el-picker-panel__icon-btn el-icon-arrow-left"></button>

{{ leftLabel }}

            <date-table
              selection-mode="range"
              :date="date"
              :year="leftYear"
              :month="leftMonth"
              :min-date="minDate"
              :max-date="maxDate"
              :range-state="rangeState"
              :disabled-date="disabledDate"
              @changerange="handleChangeRange"
              :first-day-of-week="firstDayOfWeek"
              @pick="handleRangePick">
            </date-table>

              <button
                type="button"
                @click="nextYear"
                class="el-picker-panel__icon-btn el-icon-d-arrow-right"></button>
              <button
                type="button"
                @click="nextMonth"
                class="el-picker-panel__icon-btn el-icon-arrow-right"></button>

{{ rightLabel }}

            <date-table
              selection-mode="range"
              :date="rightDate"
              :year="rightYear"
              :month="rightMonth"
              :min-date="minDate"
              :max-date="maxDate"
              :range-state="rangeState"
              :disabled-date="disabledDate"
              @changerange="handleChangeRange"
              :first-day-of-week="firstDayOfWeek"
              @pick="handleRangePick">
            </date-table>

        {{ t('el.datepicker.clear') }}
        <button
          type="button"
          class="el-picker-panel__btn"
          @click="handleConfirm()"
          :disabled="btnDisabled">{{ t('el.datepicker.confirm') }}</button>

  </transition>
</template>


docs(example): name update to the data attribute

<template>

    <cdr-text
      tag="h2"
      modifier="heading-medium"
    >
      Inset space classes for all around padding
    </cdr-text>

          {{ item }}

</template>


Garde-manger

Garde-manger

<template>

    <template v-for="foodkeeper in foodkeepers">

        <svg v-if="foodkeeper.favorite" viewBox="0 0 50 50" class="foodkeeper-star-icon">
          <use xlink:href="#app-icon-star"></use>
        </svg>

{{ foodkeeper.name }}

{{ foodkeeper.description }}

        <input id="user-dispo" type="checkbox">

    </template>

          +

Créer un garde-manger

          Pour rejoindre un garde-manger existant,
          demandez à son administrateur de vous
          ajouter en tant que compère.

</template>

<template>

    <template v-for="foodkeeper in foodkeepers">

        <svg v-if="foodkeeper.favorite" viewBox="0 0 50 50" class="foodkeeper-star-icon">
          <use xlink:href="#app-icon-star"></use>
        </svg>
        <input id="user-dispo" type="checkbox">

{{ foodkeeper.name }}

{{ foodkeeper.description }}

    </template>

          +

Créer un garde-manger

          Pour rejoindre un garde-manger existant,
          demandez à son administrateur de vous
          ajouter en tant que compère.

</template>


[#146918641] Style changes for event date and times.


fix: ad 组件无法销毁的问题

fix: ad 组件无法销毁的问题

<template>
  <uni-ad
    v-bind="attrs"
    v-on="$listeners">
    <div
      ref="container"
      class="uni-ad-container" />
  </uni-ad>
</template>
<script>
import {
  subscriber
} from 'uni-mixins'
import native from '../../mixins/native'

const _adDataCache = {}
function getAdData (adpid, adWidth, onsuccess, onerror) {
  const key = adpid + '-' + adWidth
  const adDataList = _adDataCache[key]
  if (adDataList && adDataList.length > 0) {
    onsuccess(adDataList.splice(0, 1)[0])
    return
  }

  plus.ad.getAds(
    {
      adpid: adpid,
      count: 10,
      width: adWidth
    },
    (res) => {
      const list = res.ads
      onsuccess(list.splice(0, 1)[0])
      _adDataCache[key] = adDataList ? adDataList.concat(list) : list
    },
    (err) => {
      onerror({
        errCode: err.code,
        errMsg: err.message
      })
    }
  )
}

const methods = [
  'draw'
]

const attrs = [
  'adpid',
  'data'
]

export default {
  name: 'Ad',
  mixins: [subscriber, native],
  props: {
    adpid: {
      type: [Number, String],
      default: ''
    },
    data: {
      type: Object,
      default: null
    }
  },
  data () {
    return {
      hidden: false
    }
  },
  computed: {
    attrs () {
      const obj = {}
      attrs.forEach(key => {
        let val = this.$props[key]
        val = key === 'src' ? this.$getRealPath(val) : val
        obj[key.replace(/[A-Z]/g, str => '-' + str.toLowerCase())] = val
      })
      return obj
    }
  },
  watch: {
    hidden (val) {
      this.adView && this.adView[val ? 'hide' : 'show']()
    },
    adpid (val) {
      if (val) {
        this._loadData(val)
      }
    },
    data (val) {
      if (val) {
        this._fillData(val)
      }
    }
  },
  mounted () {
    let adStyle = Object.assign({
      id: 'AdView' + Date.now()
    }, this.position)
    let adView = this.adView = plus.ad.createAdView(adStyle)
    adView.interceptTouchEvent(false)
    plus.webview.currentWebview().append(adView)
    if (this.hidden) {
      adView.hide()
    }
    this.$watch('attrs', () => {
      this._request()
    }, {
      deep: true
    })
    this.$watch('position', () => {
      this.adView && this.adView.setStyle(this.position)
    }, {
      deep: true
    })
    // 模板渲染有效
    adView.setDislikeListener && adView.setDislikeListener((data) => {
      this.adView && this.adView.close()
      this.$refs.container.style.height = '0px'

      this._updateView()

      this.$trigger('close', {}, data)
    })
    adView.setRenderingListener && adView.setRenderingListener((data) => {
      if (data.result === 0) {
        this.$refs.container.style.height = data.height + 'px'
        this._updateView()
      } else {
        this.$trigger('error', {}, {
          errCode: data.result
        })
      }
    })
    // 仅 Android 下载类广告
    adView.setDownloadListener && adView.setDownloadListener((data) => {
      this.$trigger('downloadchange', {}, data)
    })
    this._request()
  },
  beforeDestroy () {
    this.adView && this.adView.close()
    delete this.adView
  },
  methods: {
    _handleSubscribe ({
      type,
      data = {}
    }) {
      if (methods.includes(type)) {
        this.adView && this.adView[type](data)
      }
    },
    _request () {
      if (!this.adView) {
        return
      }

      if (this.data) {
        this._fillData(this.data)
      } else if (this.adpid) {
        this._loadData()
      }
    },
    _loadData (adpid) {
      getAdData(adpid || this.adpid, this.position.width, (data) => {
        this._fillData(data)
      }, (err) => {
        this.$trigger('error', {}, err)
      })
    },
    _fillData (data) {
      this.adView.renderingBind(data)

      // const height = plus.ad.measureAdHeight(this.position.width.replace('px', ''), data)
      // this.$refs.container.style.height = height + 'px'

      this.$trigger('load', {}, {})
    },
    _updateView () {
      window.dispatchEvent(new CustomEvent('updateview'))
    }
  }
}
</script>

<style>
  uni-ad {
    display: block;
    overflow: hidden;
  }

  uni-ad[hidden] {
    display: none;
  }

  uni-ad .uni-ad-container {
    width: 100%;
    min-height: 1px;
  }
</style>

<template>
  <uni-ad
    v-bind="attrs"
    v-on="$listeners">
    <div
      ref="container"
      class="uni-ad-container" />
  </uni-ad>
</template>
<script>
import {
  subscriber
} from 'uni-mixins'
import native from '../../mixins/native'

const _adDataCache = {}
function getAdData (adpid, adWidth, onsuccess, onerror) {
  const key = adpid + '-' + adWidth
  const adDataList = _adDataCache[key]
  if (adDataList && adDataList.length > 0) {
    onsuccess(adDataList.splice(0, 1)[0])
    return
  }

  plus.ad.getAds(
    {
      adpid: adpid,
      count: 10,
      width: adWidth
    },
    (res) => {
      const list = res.ads
      onsuccess(list.splice(0, 1)[0])
      _adDataCache[key] = adDataList ? adDataList.concat(list) : list
    },
    (err) => {
      onerror({
        errCode: err.code,
        errMsg: err.message
      })
    }
  )
}

const methods = [
  'draw'
]

const attrs = [
  'adpid',
  'data'
]

export default {
  name: 'Ad',
  mixins: [subscriber, native],
  props: {
    adpid: {
      type: [Number, String],
      default: ''
    },
    data: {
      type: Object,
      default: null
    }
  },
  data () {
    return {
      hidden: false
    }
  },
  computed: {
    attrs () {
      const obj = {}
      attrs.forEach(key => {
        let val = this.$props[key]
        val = key === 'src' ? this.$getRealPath(val) : val
        obj[key.replace(/[A-Z]/g, str => '-' + str.toLowerCase())] = val
      })
      return obj
    }
  },
  watch: {
    hidden (val) {
      this.adView && this.adView[val ? 'hide' : 'show']()
    },
    adpid (val) {
      if (val) {
        this._loadData(val)
      }
    },
    data (val) {
      if (val) {
        this._fillData(val)
      }
    }
  },
  mounted () {
    let adStyle = Object.assign({
      id: 'AdView' + Date.now()
    }, this.position)
    let adView = this.adView = plus.ad.createAdView(adStyle)
    adView.interceptTouchEvent(false)
    plus.webview.currentWebview().append(adView)
    if (this.hidden) {
      adView.hide()
    }
    this.$watch('attrs', () => {
      this._request()
    }, {
      deep: true
    })
    this.$watch('position', () => {
      this.adView && this.adView.setStyle(this.position)
    }, {
      deep: true
    })
    // 模板渲染有效
    adView.setDislikeListener && adView.setDislikeListener((data) => {
      this.adView && this.adView.close()
      this.$refs.container.style.height = '0px'

      this._updateView()

      this.$trigger('close', {}, data)
    })
    adView.setRenderingListener && adView.setRenderingListener((data) => {
      if (data.result === 0) {
        this.$refs.container.style.height = data.height + 'px'
        this._updateView()
      } else {
        this.$trigger('error', {}, {
          errCode: data.result
        })
      }
    })
    // 仅 Android 下载类广告
    adView.setDownloadListener && adView.setDownloadListener((data) => {
      this.$trigger('downloadchange', {}, data)
    })
    this._request()
  },
  beforeDestroy () {
    delete this.adView
  },
  methods: {
    _handleSubscribe ({
      type,
      data = {}
    }) {
      if (methods.includes(type)) {
        this.adView && this.adView[type](data)
      }
    },
    _request () {
      if (!this.adView) {
        return
      }

      if (this.data) {
        this._fillData(this.data)
      } else if (this.adpid) {
        this._loadData()
      }
    },
    _loadData (adpid) {
      getAdData(adpid || this.adpid, this.position.width, (data) => {
        this._fillData(data)
      }, (err) => {
        this.$trigger('error', {}, err)
      })
    },
    _fillData (data) {
      this.adView.renderingBind(data)

      // const height = plus.ad.measureAdHeight(this.position.width.replace('px', ''), data)
      // this.$refs.container.style.height = height + 'px'

      this.$trigger('load', {}, {})
    },
    _updateView () {
      window.dispatchEvent(new CustomEvent('updateview'))
    }
  }
}
</script>

<style>
  uni-ad {
    display: block;
    overflow: hidden;
  }

  uni-ad[hidden] {
    display: none;
  }

  uni-ad .uni-ad-container {
    width: 100%;
    min-height: 1px;
  }
</style>


empty state for learner classes - fixes #4355

<template>

{{ $tr('allClassesHeader') }}

{{ $tr('noClasses') }}

        <ContentCard
          v-for="c in classrooms"
          :key="c.id"
          class="content-card"
          :link="classAssignmentsLink(c.id)"
          :showContentIcon="false"
          :title="c.name"
          :kind="CLASSROOM"
          :isMobile="windowIsSmall"
        />

    <AuthMessage v-else authorizedRole="learner" />

</template>

<template>

{{ $tr('allClassesHeader') }}

        <ContentCard
          v-for="c in classrooms"
          :key="c.id"
          class="content-card"
          :link="classAssignmentsLink(c.id)"
          :showContentIcon="false"
          :title="c.name"
          :kind="CLASSROOM"
          :isMobile="windowIsSmall"
        />

    <AuthMessage v-else authorizedRole="learner" />

</template>


Avoid null error on destroy

Avoid null error on destroy

If the component is never mounted, or if there is an error in instantiation, there will be another error because `this.timeline` is null.