diff --git a/rollup.config.js b/rollup.config.js index b506580..bc532c8 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,48 +1,51 @@ -import resolve from 'rollup-plugin-node-resolve'; -import commonjs from 'rollup-plugin-commonjs'; -import babel from 'rollup-plugin-babel'; -import vue from 'rollup-plugin-vue'; -import autoprefixer from 'autoprefixer'; -import { terser } from 'rollup-plugin-terser'; -import pkg from './package.json'; +import resolve from "rollup-plugin-node-resolve"; +import commonjs from "rollup-plugin-commonjs"; +import babel from "rollup-plugin-babel"; +import vue from "rollup-plugin-vue"; +import autoprefixer from "autoprefixer"; +import { terser } from "rollup-plugin-terser"; +import pkg from "./package.json"; const babelOptions = { - extensions: ['.js', '.jsx', '.es6', '.es', '.mjs', '.vue', '.ts'], -} + extensions: [".js", ".jsx", ".es6", ".es", ".mjs", ".vue", ".ts"] +}; export default [ - // browser-friendly UMD build - { - input: pkg.main, - output: { - name: pkg.name, - file: pkg.browser, - format: 'umd', - sourcemap: false - }, - plugins: [ - resolve(), - commonjs(), - vue({ - postcssPlugins: [autoprefixer()] - }), - babel(babelOptions), - //terser() - ] - }, - { - input: pkg.main, - output: [ - { file: pkg.module, format: 'es' } - ], - plugins: [ - resolve(), - commonjs(), - vue({ - postcssPlugins: [autoprefixer ()] - }), - babel(babelOptions), - // terser() - ] - } + // browser-friendly UMD build + { + input: pkg.main, + output: { + name: pkg.name, + file: pkg.browser, + exports: "named", + format: "umd", + sourcemap: false + }, + plugins: [ + resolve(), + commonjs(), + vue({ + postcssPlugins: [autoprefixer()] + }), + babel(babelOptions), + terser() + ] + }, + { + input: pkg.main, + output: [ + { + exports: "named", + file: pkg.module, + format: "es" + } + ], + plugins: [ + resolve(), + commonjs(), + vue({ + postcssPlugins: [autoprefixer()] + }), + babel(babelOptions) ] + } ]; diff --git a/src/RenderlessVueSelect.vue b/src/RenderlessVueSelect.vue index d636bec..1679366 100644 --- a/src/RenderlessVueSelect.vue +++ b/src/RenderlessVueSelect.vue @@ -4,6 +4,9 @@ * [1] - ENHACEMENT: Add highlighted substrings (like the wesbos course) * [2] - Loading states and async work compatibility out-of-the-boz */ +const ATTRS_NAMESPACE = "data-renderless-vue-select"; +const SUPPORTS_SCROLLBEHAVIOR = 'scrollBehavior' in document.documentElement.style + export default { name: "renderless-vue-select", props: { @@ -12,125 +15,157 @@ export default { default: null }, options: { - type: [Array], + type: Array, required: true - // we use a serializer to validate }, /** * Default Filter by start matching, pass custom filters or fuzzy search engin like Fuse.js */ filterFunction: { type: Function, - default: (query, options) => { - return options.filter(o => o.label.toLowerCase().startsWith(query.toLowerCase())) + default: (query, options, currentValue) => { + if (query === "" || query === currentValue.label) { + return options; + } else { + return options.filter(o => + o.label.toLowerCase().startsWith(query.toLowerCase()) + ); + } } + }, + /** + * Keeping state open after selecting option by click + * or keyboard, also after clicking outside the list + */ + keepOpen: { + type: Boolean, + default: true } }, model: { prop: "value", event: "select" }, - data(){ + data() { return { - query: '', - isOpen: false, + query: "", + isOpen: this.keepOpen ? true : false, highlightedIndex: 0, - } + selectedIndex: null + }; }, computed: { // [check if should allow bad option formating and serialize or enforce a proper format] serializedValue() { - if( typeof this.value === "string" || this.value === null){ - return {label: this.value, value: this.value} - }; + if (typeof this.value === "string" || this.value === null) { + return { + label: this.value, + value: this.value + }; + } - if (this._validateFormat(this.value)){ - return this.value + if (this._validateFormat(this.value)) { + return this.value; } else { - throw new Error("Label and value are required keys in your value object") + throw new Error( + "Label and value are required keys in your value object" + ); } }, // to allow ["option", "adasdad"] source when really it should be [{"label", "value"}] - serializedOptions(){ - if( typeof this.options[0] === "string"){ - console.warn('We accept this but you should use {label: "Label", value: "Value} since it matches the native format') + serializedOptions() { + if (typeof this.options[0] === "string") { + console.warn( + 'We accept this but you should use {label: "Label", value: "Value} since it matches the native format' + ); /** * If no value provided, value === "textContent2"of options as per spec * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select */ - return this.options.map((o,i) => ({label: o, value: o})) + return this.options.map((o, i) => ({ label: o, value: o })); } // we optimistically hope if the first option matches the rest as // it is expensive to be validating long arrays - if(this._validateFormat(this.options[0]) - ) { - return this.options + if (this._validateFormat(this.options[0])) { + return this.options; } else { - throw new Error("Label and value are required keys in your option object") + throw new Error( + "Label and value are required keys in your option object" + ); } }, - filteredOptions(){ + filteredOptions() { // [1] [2] - return this.filterFunction(this.query, this.serializedOptions); + return this.filterFunction( + this.query, + this.serializedOptions, + this.serializedValue + ); } }, watch: { - value(newVal){ - if(!this.serializedValue || (this.serializedValue && this.serializedValue.label === null)){ - this.query = "" - } else { - this.query = this.serializedValue.label; - } + value() { + this._updateQuery(this.serializedValue); + this._setSelectedIndex(); }, - query(typedValue){ - if(typedValue !== "" && typedValue !== this.serializedValue.label){ + query(typedValue) { + if (typedValue !== "" && typedValue !== this.serializedValue.label) { // if closed, open it - if(!this.isOpen) this.open(); + if (!this.isOpen) this.open(); } } }, methods: { - open(){ + open() { this.isOpen = true; + this.$nextTick(() => { + this._scrollListToHighlighted(); + }); }, - close(){ - if(!this.isOpen) return; - this.highlightedIndex = 0; - this.isOpen = false; + close() { + if (!this.isOpen) return; + + // if nothing was select, but some keyboard playing occurred, reset to selected index + this._updateQuery(this.serializedValue); + if(!this.keepOpen) { + this.isOpen = false + } }, - selectOption(option){ + selectOption(option) { this.query = option.label; - this.$emit('select', option) + this._setSelectedIndex(); + this._updateQuery(this.serializedValue); + this.$emit("select", option); + this.close(); }, - selectHighlighted(){ + selectHighlighted() { this.selectOption(this.filteredOptions[this.highlightedIndex]); - this.close() }, - reset(){ - this.query = ""; + reset() { this.highlightedIndex = 0; - this.$emit('select', null); + this.inputRef.focus(); + this.selectOption({ label: '', value: null }); }, // Keyboard navigation - highlight(index){ - if(this.filteredOptions.length === 0) return; + highlight(index) { + if (this.filteredOptions.length === 0) return; this.highlightedIndex = index; // reaches first scrolls to last - if( this.highlightedIndex < 0){ + if (this.highlightedIndex < 0) { this.highlightedIndex = this.filteredOptions.length - 1; } // reaches last goes scrolls to first - if( this.highlightedIndex > this.filteredOptions.length - 1 ){ + if (this.highlightedIndex > this.filteredOptions.length - 1) { this.highlightedIndex = 0; } // i don't know a pretty way of doing this but will find out in the future // this will break if we use v-if instead of v-show - this.optionsScroller.children[this.highlightedIndex].scrollIntoView({block: 'nearest'}); + this._scrollListToHighlighted(); }, highlightPrev() { this.highlight(this.highlightedIndex - 1); @@ -139,84 +174,150 @@ export default { this.highlight(this.highlightedIndex + 1); }, handleClickOutside(e) { + if (this.keepOpen) this._updateQuery(this.serializedValue); if (!this.isOpen) return; if (!this.$el.contains(e.target)) { - this.close() + this.close(); } }, - _validateFormat(obj){ - if( - obj.hasOwnProperty("label") && - obj.hasOwnProperty("value") - ) { - return true + _validateFormat(obj) { + if (obj.hasOwnProperty("label") && obj.hasOwnProperty("value")) { + return true; } else { - false + false; + } + }, + + /** + * for both initial loading (value watch) + * and onClose to check if the user just randomly searching terms + * But then cancelled and we reset query val to the current selected option (this.value) + * + * TODO: this serialization checks fill dirty. enhance + */ + _updateQuery(value) { + if (!value || (value && value.label === null)) { + this.query = ""; + } else { + this.query = value.label; + } + this.$nextTick(() => { + this._scrollListToHighlighted('instant'); + }) + }, + + /** + * Pleas make this human readable + */ + _setSelectedIndex() { + let i = this.filteredOptions + .map(o => o.label) + .indexOf(this.serializedValue.label); + let _i = i < 0 ? 0 : i; + this.highlightedIndex = _i; + this.selectedIndex = i < 0 ? null : _i; + }, + + // [noteperf] check if ele is scrollable before trigger + _scrollListToHighlighted(behavior = 'instant') { + if (this.listboxRef) { + let target = this.listboxRef.children[this.highlightedIndex]; + target && + SUPPORTS_SCROLLBEHAVIOR + ? target.scrollIntoView({ block: "nearest", behavior}) + : target.scrollIntoView() + } + }, + // dangerous + _getFakeRefs() { + this.inputRef = this.$el.querySelector(`[${ATTRS_NAMESPACE}=input]`); + this.listboxRef = this.$el.querySelector(`[${ATTRS_NAMESPACE}=listbox]`); + if (!this.listboxRef) { + console.warn( + 'For accessibility purposes make sure you v-bind="listboxProps to your role="listbox" element aka the list that scrolls throught he options' + ); } } }, - created(){ + created() { // look if a selected option is already passed to v-model // if not null - if(this.value){ + if (this.value) { this.selectOption(this.serializedValue); } }, - mounted(){ - this.optionsScroller = this.$el.querySelector('[data-vue-select-scroller]'); - document.addEventListener('click', this.handleClickOutside); + updated() { + this.$emit("updated"); + }, + mounted() { + this._getFakeRefs(); + this._scrollListToHighlighted(); + document.addEventListener("click", this.handleClickOutside); }, beforeDestroy() { - document.removeEventListener('click', this.handleClickOutside); + document.removeEventListener("click", this.handleClickOutside); }, - render(){ + render() { return this.$scopedSlots.default({ state: { isOpen: this.isOpen, highlightedIndex: this.highlightedIndex, + selectedIndex: this.selectedIndex }, options: this.filteredOptions, - optionEvents: (option) => ({ + optionEvents: option => ({ click: () => { this.selectOption(option); + this.inputRef.focus(); this.close(); } }), inputProps: { - value: this.query + value: this.query, + // this is kind of hack but, don't know a better way yet + [`${ATTRS_NAMESPACE}`]: "input" }, inputEvents: { click: this.open, - input: (e) => this.query = e.target.value, - keydown: (e) => { - if(e.key === 'ArrowDown'){ - this.highlightNext() + input: e => (this.query = e.target.value), + keydown: e => { + if (e.key === "ArrowDown") { + if (!this.isOpen) { + this.open(); + } else { + this.highlightNext(); + } } - if(e.key === 'ArrowUp') { - this.highlightPrev() + if (e.key === "ArrowUp") { + this.highlightPrev(); } - if(e.key === "Escape" || e.key === "Tab") { - this.close() + if (e.key === "Escape" || e.key === "Tab") { + // do not close other components that might be listening for the escape key + if (this.isOpen) e.stopPropagation(); + this._updateQuery(this.serializedValue); + this.close(); } - if( e.key === "Enter") { + if (e.key === "Enter") { e.preventDefault(); - if( this.isOpen && this.filteredOptions.length){ + if (this.isOpen && this.filteredOptions.length) { this.selectHighlighted(); - } else { - this.open() } } } }, actions: { reset: () => this.reset(), - toggleOptions: () => this.isOpen ? this.close() : this.open() + toggleOptions: () => + this.isOpen ? this.close() : (this.open(), this.inputRef.focus()) + }, + listboxProps: { + [`${ATTRS_NAMESPACE}`]: "listbox" } - }) + }); } -} +}; </script> \ No newline at end of file diff --git a/src/index.js b/src/index.js index 087d019..5ab9d85 100644 --- a/src/index.js +++ b/src/index.js @@ -18,4 +18,5 @@ var Plugin = { } } +export { RenderlessVueSelect } export default Plugin;