UNPKG

@ishitatsuyuki/oruga-next

Version:

UI components for Vue.js and CSS framework agnostic

841 lines (835 loc) 32.8 kB
import { defineComponent, resolveComponent, openBlock, createBlock, createVNode, mergeProps, withKeys, withModifiers, Transition, withCtx, withDirectives, renderSlot, createCommentVNode, Fragment, renderList, toDisplayString, vShow } from 'vue'; import { getValueByPath, toCssDimension, debounce, createAbsoluteElement, removeElement } from './helpers.mjs'; import { getOptions } from './config.mjs'; import { B as BaseComponentMixin } from './BaseComponentMixin-d78ed3dc.mjs'; import { F as FormElementMixin } from './FormElementMixin-386fdfd0.mjs'; import { s as script$1 } from './Input-299afe8a.mjs'; /** * Extended input that provide suggestions while the user types * @displayName Autocomplete * @style _autocomplete.scss */ var script = defineComponent({ name: 'OAutocomplete', configField: 'autocomplete', components: { [script$1.name]: script$1 }, mixins: [BaseComponentMixin, FormElementMixin], inheritAttrs: false, emits: ['update:modelValue', 'select', 'infinite-scroll', 'typing', 'focus', 'blur', 'icon-click', 'icon-right-click'], props: { /** @model */ modelValue: [Number, String], /** Options / suggestions */ data: { type: Array, default: () => [] }, /** Native options to use in HTML5 validation */ autocomplete: String, /** * Vertical size of input, optional * @values small, medium, large */ size: String, /** Property of the object (if data is array of objects) to use as display text, and to keep track of selected option */ field: { type: String, default: 'value' }, /** The first option will always be pre-selected (easier to just hit enter or tab) */ keepFirst: Boolean, /** Clear input text on select */ clearOnSelect: Boolean, /** Open dropdown list on focus */ openOnFocus: Boolean, /** Function to format an option to a string for display in the input as alternative to field prop) */ customFormatter: Function, /** Makes the component check if list reached scroll end and emit infinite-scroll event. */ checkInfiniteScroll: Boolean, /** Keep open dropdown list after select */ keepOpen: Boolean, /** Add a button/icon to clear the inputed text */ clearable: Boolean, /** Max height of dropdown content */ maxHeight: [String, Number], /** * Position of dropdown * @values auto, top, bottom */ menuPosition: { type: String, default: 'auto' }, /** Transition name to apply on dropdown list */ animation: { type: String, default: () => { return getValueByPath(getOptions(), 'autocomplete.animation', 'fade'); } }, /** Property of the object (if <code>data</code> is array of objects) to use as display text of group */ groupField: String, /** Property of the object (if <code>data</code> is array of objects) to use as key to get items array of each group, optional */ groupOptions: String, /** Number of milliseconds to delay before to emit typing event */ debounceTyping: Number, /** Icon name to be added on the right side */ iconRight: String, /** Clickable icon right if exists */ iconRightClickable: Boolean, /** Append autocomplete content to body */ appendToBody: Boolean, /** Array of keys (https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) which will add a tag when typing (default tab and enter) */ confirmKeys: { type: Array, default: () => ['Tab', 'Enter'] }, /** Input type */ type: { type: String, default: 'text' }, /** * Menu tag name */ menuTag: { type: String, default: () => { return getValueByPath(getOptions(), 'autocomplete.menuTag', 'div'); } }, /** * Menu item tag name */ itemTag: { type: String, default: () => { return getValueByPath(getOptions(), 'autocomplete.itemTag', 'div'); } }, /** Trigger the select event for the first pre-selected option when clicking outside and <code>keep-first</code> is enabled */ selectOnClickOutside: Boolean, /** Allows the header in the autocomplete to be selectable */ selectableHeader: Boolean, /** Allows the footer in the autocomplete to be selectable */ selectableFooter: Boolean, rootClass: [String, Function, Array], menuClass: [String, Function, Array], expandedClass: [String, Function, Array], menuPositionClass: [String, Function, Array], itemClass: [String, Function, Array], itemHoverClass: [String, Function, Array], itemGroupTitleClass: [String, Function, Array], itemEmptyClass: [String, Function, Array], itemHeaderClass: [String, Function, Array], itemFooterClass: [String, Function, Array], inputClasses: { type: Object, default: () => { return getValueByPath(getOptions(), 'autocomplete.inputClasses', {}); } } }, data() { return { selected: null, hovered: null, headerHovered: null, footerHovered: null, isActive: false, newValue: this.modelValue, ariaAutocomplete: this.keepFirst ? 'both' : 'list', newAutocomplete: this.autocomplete || 'off', isListInViewportVertically: true, hasFocus: false, itemRefs: [], width: undefined, bodyEl: undefined, }; }, computed: { rootClasses() { return [ this.computedClass('rootClass', 'o-acp'), { [this.computedClass('expandedClass', 'o-acp--expanded')]: this.expanded } ]; }, menuClasses() { return [ this.computedClass('menuClass', 'o-acp__menu'), { [this.computedClass('menuPositionClass', 'o-acp__menu--', this.newDropdownPosition)]: !this.appendToBody }, ]; }, itemClasses() { return [ this.computedClass('itemClass', 'o-acp__item') ]; }, itemEmptyClasses() { return [ ...this.itemClasses, this.computedClass('itemEmptyClass', 'o-acp__item--empty') ]; }, itemGroupClasses() { return [ ...this.itemClasses, this.computedClass('itemGroupTitleClass', 'o-acp__item-group-title') ]; }, itemHeaderClasses() { return [ ...this.itemClasses, this.computedClass('itemHeaderClass', 'o-acp__item-header'), { [this.computedClass('itemHoverClass', 'o-acp__item--hover')]: this.headerHovered } ]; }, itemFooterClasses() { return [ ...this.itemClasses, this.computedClass('itemFooterClass', 'o-acp__item-footer'), { [this.computedClass('itemHoverClass', 'o-acp__item--hover')]: this.footerHovered } ]; }, inputBind() { return { ...this.$attrs, ...this.inputClasses }; }, computedData() { if (this.groupField) { if (this.groupOptions) { const newData = []; this.data.forEach((option) => { const group = getValueByPath(option, this.groupField); const items = getValueByPath(option, this.groupOptions); newData.push({ group, items }); }); return newData; } else { const tmp = {}; this.data.forEach((option) => { const group = getValueByPath(option, this.groupField); if (!tmp[group]) tmp[group] = []; tmp[group].push(option); }); const newData = []; Object.keys(this.data).forEach((group) => { newData.push({ group, items: this.data[group] }); }); return newData; } } return [{ items: this.data }]; }, isEmpty() { if (!this.computedData) return true; return !this.computedData.some((element) => element.items && element.items.length); }, /** * White-listed items to not close when clicked. * Add input, dropdown and all children. */ whiteList() { const whiteList = []; whiteList.push(this.$refs.input.$el.querySelector('input')); whiteList.push(this.$refs.dropdown); // Add all children from dropdown if (this.$refs.dropdown !== undefined) { const children = this.$refs.dropdown.querySelectorAll('*'); for (const child of children) { whiteList.push(child); } } return whiteList; }, newDropdownPosition() { if (this.menuPosition === 'top' || (this.menuPosition === 'auto' && !this.isListInViewportVertically)) { return 'top'; } return 'bottom'; }, newIconRight() { if (this.clearable && this.newValue) { return 'close-circle'; } return this.iconRight; }, newIconRightClickable() { if (this.clearable) { return true; } return this.iconRightClickable; }, menuStyle() { return { maxHeight: toCssDimension(this.maxHeight) }; }, $elementRef() { return 'input'; } }, watch: { /** * When v-model is changed: * 1. Update internal value. * 2. If it's invalid, validate again. */ modelValue(value) { this.newValue = value; }, /** * When dropdown is toggled, check the visibility to know when * to open upwards. */ isActive(active) { if (this.menuPosition === 'auto') { if (active) { this.calcDropdownInViewportVertical(); } else { // Timeout to wait for the animation to finish before recalculating setTimeout(() => { this.calcDropdownInViewportVertical(); }, 100); } } }, /** * When updating input's value * 1. Emit changes * 2. If value isn't the same as selected, set null * 3. Close dropdown if value is clear or else open it */ newValue(value) { this.$emit('update:modelValue', value); // Check if selected is invalid const currentValue = this.getValue(this.selected); if (currentValue && currentValue !== value) { this.setSelected(null, false); } // Close dropdown if input is clear or else open it if (this.hasFocus && (!this.openOnFocus || value)) { this.isActive = !!value; } }, /** * Select first option if "keep-first */ data() { // Keep first option always pre-selected if (this.keepFirst) { this.$nextTick(() => { if (this.isActive) { this.selectFirstOption(this.computedData); } else { this.setHovered(null); } }); } else { if (this.hovered) { // reset hovered if list doesn't contain it const hoveredValue = this.getValue(this.hovered); const data = this.computedData.map((d) => d.items).reduce((a, b) => ([...a, ...b]), []); if (!data.some(d => this.getValue(d) === hoveredValue)) { this.setHovered(null); } } } }, debounceTyping: { handler(value) { this.debouncedEmitTyping = debounce(this.emitTyping, value); }, immediate: true } }, methods: { itemOptionClasses(option) { return [ ...this.itemClasses, { [this.computedClass('itemHoverClass', 'o-acp__item--hover')]: option === this.hovered } ]; }, /** * Set which option is currently hovered. */ setHovered(option) { if (option === undefined) return; this.hovered = option; }, /** * Set which option is currently selected, update v-model, * update input value and close dropdown. */ setSelected(option, closeDropdown = true, event = undefined) { if (option === undefined) return; this.selected = option; /** * @property {Object} selected selected option * @property {Event} event native event */ this.$emit('select', this.selected, event); if (this.selected !== null) { if (this.clearOnSelect) { const input = this.$refs.input; input.newValue = ''; input.$refs.input.value = ''; } else { this.newValue = this.getValue(this.selected); } this.setHovered(null); } closeDropdown && this.$nextTick(() => { this.isActive = false; }); this.checkValidity(); }, /** * Select first option */ selectFirstOption(computedData) { this.$nextTick(() => { const nonEmptyElements = computedData.filter((element) => element.items && element.items.length); if (nonEmptyElements.length) { const option = nonEmptyElements[0].items[0]; this.setHovered(option); } else { this.setHovered(null); } }); }, /** * Key listener. * Select the hovered option. */ keydown(event) { const { key } = event; // cannot destructure preventDefault (https://stackoverflow.com/a/49616808/2774496) // prevent emit submit event if (key === 'Enter') event.preventDefault(); // Close dropdown on Tab & no hovered if (key === 'Escape' || key === 'Tab') { this.isActive = false; } if (this.confirmKeys.indexOf(key) >= 0) { // If adding by comma, don't add the comma to the input if (key === ',') event.preventDefault(); // Close dropdown on select by Tab const closeDropdown = !this.keepOpen || key === 'Tab'; if (this.hovered === null) { // header and footer uses headerHovered && footerHovered. If header or footer // was selected then fire event otherwise just return so a value isn't selected this.checkIfHeaderOrFooterSelected(event, null, closeDropdown); return; } this.setSelected(this.hovered, closeDropdown, event); } }, selectHeaderOrFoterByClick(event, origin) { this.checkIfHeaderOrFooterSelected(event, { origin: origin }); }, /** * Check if header or footer was selected. */ checkIfHeaderOrFooterSelected(event, triggerClick, closeDropdown = true) { if (this.selectableHeader && (this.headerHovered || (triggerClick && triggerClick.origin === 'header'))) { this.$emit('select-header', event); this.headerHovered = false; if (triggerClick) this.setHovered(null); if (closeDropdown) this.isActive = false; } if (this.selectableFooter && (this.footerHovered || (triggerClick && triggerClick.origin === 'header'))) { this.$emit('select-footer', event); this.footerHovered = false; if (triggerClick) this.setHovered(null); if (closeDropdown) this.isActive = false; } }, /** * Close dropdown if clicked outside. */ clickedOutside(event) { if (!this.hasFocus && this.whiteList.indexOf(event.target) < 0) { if (this.keepFirst && this.hovered && this.selectOnClickOutside) { this.setSelected(this.hovered, true); } else { this.isActive = false; } } }, /** * Return display text for the input. * If object, get value from path, or else just the value. */ getValue(option) { if (option === null) return; if (typeof this.customFormatter !== 'undefined') { return this.customFormatter(option); } return typeof option === 'object' ? getValueByPath(option, this.field) : option; }, /** * Check if the scroll list inside the dropdown * reached it's end. */ checkIfReachedTheEndOfScroll() { const list = this.$refs.dropdown; if (list.clientHeight !== list.scrollHeight && list.scrollTop + list.clientHeight >= list.scrollHeight) { this.$emit('infinite-scroll'); } }, /** * Calculate if the dropdown is vertically visible when activated, * otherwise it is openened upwards. */ calcDropdownInViewportVertical() { this.$nextTick(() => { /** * this.$refs.dropdown may be undefined * when Autocomplete is conditional rendered */ if (!this.$refs.dropdown) return; const rect = this.$refs.dropdown.getBoundingClientRect(); this.isListInViewportVertically = (rect.top >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight)); if (this.appendToBody) { this.updateAppendToBody(); } }); }, /** * Arrows keys listener. * If dropdown is active, set hovered option, or else just open. */ keyArrows(direction) { const sum = direction === 'down' ? 1 : -1; if (this.isActive) { const data = this.computedData.map((d) => d.items).reduce((a, b) => ([...a, ...b]), []); if (this.$slots.header && this.selectableHeader) { data.unshift(undefined); } if (this.$slots.footer && this.selectableFooter) { data.push(undefined); } let index; if (this.headerHovered) { index = 0 + sum; } else if (this.footerHovered) { index = (data.length - 1) + sum; } else { index = data.indexOf(this.hovered) + sum; } index = index > data.length - 1 ? data.length - 1 : index; index = index < 0 ? 0 : index; this.footerHovered = false; this.headerHovered = false; this.setHovered(data[index] !== undefined ? data[index] : null); if (this.$slots.footer && this.selectableFooter && index === data.length - 1) { this.footerHovered = true; } if (this.$slots.header && this.selectableHeader && index === 0) { this.headerHovered = true; } const list = this.$refs.dropdown; let items = this.itemRefs || []; if (this.$slots.header && this.selectableHeader) { items = [this.$refs.header, ...items]; } if (this.$slots.footer && this.selectableFooter) { items = [...items, this.$refs.footer]; } const element = items[index]; if (!element) return; const visMin = list.scrollTop; const visMax = list.scrollTop + list.clientHeight - element.clientHeight; if (element.offsetTop < visMin) { list.scrollTop = element.offsetTop; } else if (element.offsetTop >= visMax) { list.scrollTop = (element.offsetTop - list.clientHeight + element.clientHeight); } } else { this.isActive = true; } }, /** * Focus listener. * If value is the same as selected, select all text. */ focused(event) { if (this.getValue(this.selected) === this.newValue) { this.$el.querySelector('input').select(); } if (this.openOnFocus) { this.isActive = true; if (this.keepFirst) { // If open on focus, update the hovered this.selectFirstOption(this.computedData); } } this.hasFocus = true; this.$emit('focus', event); }, /** * Blur listener. */ onBlur(event) { this.hasFocus = false; this.$emit('blur', event); }, onInput() { const currentValue = this.getValue(this.selected); if (currentValue && currentValue === this.newValue) return; if (this.debounceTyping) { this.debouncedEmitTyping(); } else { this.emitTyping(); } }, emitTyping() { this.$emit('typing', this.newValue); this.checkValidity(); }, rightIconClick(event) { if (this.clearable) { this.newValue = ''; this.setSelected(null, false); if (this.openOnFocus) { this.$refs.input.$el.focus(); } } else { this.$emit('icon-right-click', event); } }, checkValidity() { if (this.useHtml5Validation) { this.$nextTick(() => { this.checkHtml5Validity(); }); } }, setItemRef(el) { if (el) { this.itemRefs.push(el); } }, updateAppendToBody() { const dropdownMenu = this.$refs.dropdown; const trigger = this.$refs.input.$el; if (dropdownMenu && trigger) { // update wrapper dropdown const root = this.$data.bodyEl; root.classList.forEach((item) => root.classList.remove(...item.split(' '))); this.rootClasses.forEach((item) => { if (item) { if (typeof item === 'object') { Object.keys(item).filter(key => key && item[key]).forEach(key => root.classList.add(key)); } else { root.classList.add(...item.split(' ')); } } }); const rect = trigger.getBoundingClientRect(); let top = rect.top + window.scrollY; const left = rect.left + window.scrollX; if (this.newDropdownPosition !== 'top') { top += trigger.clientHeight; } else { top -= dropdownMenu.clientHeight; } dropdownMenu.style.position = 'absolute'; dropdownMenu.style.top = `${top}px`; dropdownMenu.style.left = `${left}px`; dropdownMenu.style.width = `${trigger.clientWidth}px`; dropdownMenu.style.maxWidth = `${trigger.clientWidth}px`; dropdownMenu.style.zIndex = '9999'; } } }, created() { if (typeof window !== 'undefined') { document.addEventListener('click', this.clickedOutside); if (this.menuPosition === 'auto') window.addEventListener('resize', this.calcDropdownInViewportVertical); } }, mounted() { const list = this.$refs.dropdown; if (this.checkInfiniteScroll && list) { list.addEventListener('scroll', this.checkIfReachedTheEndOfScroll); } if (this.appendToBody) { this.$data.bodyEl = createAbsoluteElement(list); this.updateAppendToBody(); } }, beforeUpdate() { this.width = this.$refs.input ? this.$refs.input.$el.clientWidth : undefined; this.itemRefs = []; }, beforeUnmount() { if (typeof window !== 'undefined') { document.removeEventListener('click', this.clickedOutside); if (this.menuPosition === 'auto') window.removeEventListener('resize', this.calcDropdownInViewportVertical); } if (this.checkInfiniteScroll && this.$refs.dropdown) { const list = this.$refs.dropdown; list.removeEventListener('scroll', this.checkIfReachedTheEndOfScroll); } if (this.appendToBody) { removeElement(this.$data.bodyEl); } } }); const _hoisted_1 = { key: 1 }; const _hoisted_2 = { key: 1 }; function render(_ctx, _cache, $props, $setup, $data, $options) { const _component_o_input = resolveComponent("o-input"); return openBlock(), createBlock("div", { class: _ctx.rootClasses }, [createVNode(_component_o_input, mergeProps(_ctx.inputBind, { modelValue: _ctx.newValue, "onUpdate:modelValue": [_cache[1] || (_cache[1] = $event => _ctx.newValue = $event), _ctx.onInput], ref: "input", type: _ctx.type, size: _ctx.size, rounded: _ctx.rounded, icon: _ctx.icon, "icon-right": _ctx.newIconRight, "icon-right-clickable": _ctx.newIconRightClickable, "icon-pack": _ctx.iconPack, maxlength: _ctx.maxlength, autocomplete: _ctx.newAutocomplete, "use-html5-validation": false, "aria-autocomplete": _ctx.ariaAutocomplete, expanded: _ctx.expanded, onFocus: _ctx.focused, onBlur: _ctx.onBlur, onKeydown: [_ctx.keydown, _cache[2] || (_cache[2] = withKeys(withModifiers($event => _ctx.keyArrows('up'), ["prevent"]), ["up"])), _cache[3] || (_cache[3] = withKeys(withModifiers($event => _ctx.keyArrows('down'), ["prevent"]), ["down"]))], "onIcon-right-click": _ctx.rightIconClick, "onIcon-click": _cache[4] || (_cache[4] = event => _ctx.$emit('icon-click', event)) }), null, 16 /* FULL_PROPS */ , ["modelValue", "type", "size", "rounded", "icon", "icon-right", "icon-right-clickable", "icon-pack", "maxlength", "autocomplete", "aria-autocomplete", "expanded", "onUpdate:modelValue", "onFocus", "onBlur", "onKeydown", "onIcon-right-click"]), createVNode(Transition, { name: _ctx.animation }, { default: withCtx(() => [withDirectives(createVNode("div", { class: _ctx.menuClasses, is: _ctx.menuTag, style: _ctx.menuStyle, ref: "dropdown" }, [_ctx.$slots.header ? (openBlock(), createBlock("div", { key: 0, is: _ctx.itemTag, ref: "header", role: "button", tabindex: 0, onClick: _cache[5] || (_cache[5] = $event => _ctx.selectHeaderOrFoterByClick($event, 'header')), class: _ctx.itemHeaderClasses }, [renderSlot(_ctx.$slots, "header")], 10 /* CLASS, PROPS */ , ["is"])) : createCommentVNode("v-if", true), (openBlock(true), createBlock(Fragment, null, renderList(_ctx.computedData, (element, groupindex) => { return openBlock(), createBlock(Fragment, null, [element.group ? (openBlock(), createBlock("div", { is: _ctx.itemTag, key: groupindex + 'group', class: _ctx.itemGroupClasses }, [_ctx.$slots.group ? renderSlot(_ctx.$slots, "group", { key: 0, group: element.group, index: groupindex }) : (openBlock(), createBlock("span", _hoisted_1, toDisplayString(element.group), 1 /* TEXT */ ))], 10 /* CLASS, PROPS */ , ["is"])) : createCommentVNode("v-if", true), (openBlock(true), createBlock(Fragment, null, renderList(element.items, (option, index) => { return openBlock(), createBlock("div", { key: groupindex + ':' + index, is: _ctx.itemTag, class: _ctx.itemOptionClasses(option), onClick: withModifiers($event => _ctx.setSelected(option, !_ctx.keepOpen, $event), ["stop"]), ref: _ctx.setItemRef }, [_ctx.$slots.default ? renderSlot(_ctx.$slots, "default", { key: 0, option: option, index: index }) : (openBlock(), createBlock("span", _hoisted_2, toDisplayString(_ctx.getValue(option)), 1 /* TEXT */ ))], 10 /* CLASS, PROPS */ , ["is", "onClick"]); }), 128 /* KEYED_FRAGMENT */ ))], 64 /* STABLE_FRAGMENT */ ); }), 256 /* UNKEYED_FRAGMENT */ )), _ctx.isEmpty && _ctx.$slots.empty ? (openBlock(), createBlock("div", { key: 1, is: _ctx.itemTag, class: _ctx.itemEmptyClasses }, [renderSlot(_ctx.$slots, "empty")], 10 /* CLASS, PROPS */ , ["is"])) : createCommentVNode("v-if", true), _ctx.$slots.footer ? (openBlock(), createBlock("div", { key: 2, is: _ctx.itemTag, ref: "footer", role: "button", tabindex: 0, onClick: _cache[6] || (_cache[6] = $event => _ctx.selectHeaderOrFoterByClick($event, 'footer')), class: _ctx.itemFooterClasses }, [renderSlot(_ctx.$slots, "footer")], 10 /* CLASS, PROPS */ , ["is"])) : createCommentVNode("v-if", true)], 14 /* CLASS, STYLE, PROPS */ , ["is"]), [[vShow, _ctx.isActive && (!_ctx.isEmpty || _ctx.$slots.empty || _ctx.$slots.header || _ctx.$slots.footer)]])]), _: 1 }, 8 /* PROPS */ , ["name"])], 2 /* CLASS */ ); } script.render = render; script.__file = "src/components/autocomplete/Autocomplete.vue"; export { script as s };