From 1e4adc395df5b7ad19714eae5147d9ff026b5500 Mon Sep 17 00:00:00 2001 From: Jaspreet-singh-1032 Date: Tue, 23 Jan 2024 22:36:24 +0530 Subject: [PATCH 1/9] renamed KeenUiSelectOption to KSelectOption --- lib/KSelect/{KeenUiSelectOption.vue => KSelectOption.vue} | 2 +- lib/KSelect/KeenUiSelect.vue | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename lib/KSelect/{KeenUiSelectOption.vue => KSelectOption.vue} (99%) diff --git a/lib/KSelect/KeenUiSelectOption.vue b/lib/KSelect/KSelectOption.vue similarity index 99% rename from lib/KSelect/KeenUiSelectOption.vue rename to lib/KSelect/KSelectOption.vue index fa9cc859a..3d2faef2e 100644 --- a/lib/KSelect/KeenUiSelectOption.vue +++ b/lib/KSelect/KSelectOption.vue @@ -48,7 +48,7 @@ import UiIcon from '../keen/UiIcon'; export default { - name: 'KeenUiSelectOption', + name: 'KSelectOption', components: { UiIcon, }, diff --git a/lib/KSelect/KeenUiSelect.vue b/lib/KSelect/KeenUiSelect.vue index fb1211c9b..3dbc0c6a0 100644 --- a/lib/KSelect/KeenUiSelect.vue +++ b/lib/KSelect/KeenUiSelect.vue @@ -136,7 +136,7 @@ class="ui-select-options" :style="{ backgroundColor: $themeTokens.surface }" > - - +
@@ -200,13 +200,13 @@ import { looseIndexOf, looseEqual } from '../keen/helpers/util'; import { scrollIntoView, resetScroll } from '../keen/helpers/element-scroll'; import config from '../keen/config'; - import KeenUiSelectOption from './KeenUiSelectOption.vue'; + import KSelectOption from './KSelectOption.vue'; export default { name: 'KeenUiSelect', components: { UiIcon, - KeenUiSelectOption, + KSelectOption, }, props: { name: { From 361c78382bd141dbeb687332c1aa069da9141776 Mon Sep 17 00:00:00 2001 From: Jaspreet-singh-1032 Date: Sun, 18 Feb 2024 23:26:53 +0530 Subject: [PATCH 2/9] moved KeenUiSelect to Kselect and removed unused code --- lib/KSelect/KeenUiSelect.vue | 1207 ---------------------------------- lib/KSelect/index.vue | 1042 ++++++++++++++++++++++++++--- 2 files changed, 961 insertions(+), 1288 deletions(-) delete mode 100644 lib/KSelect/KeenUiSelect.vue diff --git a/lib/KSelect/KeenUiSelect.vue b/lib/KSelect/KeenUiSelect.vue deleted file mode 100644 index 3dbc0c6a0..000000000 --- a/lib/KSelect/KeenUiSelect.vue +++ /dev/null @@ -1,1207 +0,0 @@ - - - - - - - diff --git a/lib/KSelect/index.vue b/lib/KSelect/index.vue index a92a9008e..fb7fa0c54 100644 --- a/lib/KSelect/index.vue +++ b/lib/KSelect/index.vue @@ -1,40 +1,139 @@ @@ -43,7 +142,15 @@ import has from 'lodash/has'; import isObject from 'lodash/isObject'; - import UiSelect from './KeenUiSelect'; + import fuzzysearch from 'fuzzysearch'; + import startswith from 'lodash/startsWith'; + import sortby from 'lodash/sortBy'; + import UiIcon from '../keen/UiIcon'; + + import { looseIndexOf, looseEqual } from '../keen/helpers/util'; + import { scrollIntoView, resetScroll } from '../keen/helpers/element-scroll'; + import config from '../keen/config'; + import KSelectOption from './KSelectOption.vue'; function areValidOptions(array) { return array.every(object => { @@ -60,21 +167,16 @@ return has(object, 'value') && has(object, 'label'); } - /** - * Used to select or filter items - */ export default { name: 'KSelect', components: { - UiSelect, + UiIcon, + KSelectOption, }, model: { event: 'change', }, props: { - /** - * Object currently selected - */ value: { type: Object, required: true, @@ -82,10 +184,6 @@ return isValidOption(val); }, }, - /** - * Array of option objects { value, label, disabled }. - * Disabled key is optional - */ options: { type: Array, required: true, @@ -93,77 +191,256 @@ return areValidOptions(val); }, }, - /** - * Label - */ + placeholder: { + type: String, + default: '', + }, label: { type: String, default: null, }, - /** - * Whether disabled or not - */ - disabled: { + floatingLabel: { type: Boolean, - default: false, + default: true, + }, + noResultsText: { + type: String, + default: '', + }, + keys: { + type: Object, + default() { + return config.data.UiSelect.keys; + }, }, - /** - * Whether invalid or not - */ invalid: { type: Boolean, default: false, }, - /** - * Text displayed if invalid - */ invalidText: { type: String, default: null, }, - /** - * Whether or not display as inline block - */ - inline: { + disabled: { type: Boolean, default: false, }, - floatingLabel: { + clearable: { type: Boolean, - default: true, + default: false, }, - placeholder: { + clearText: { type: String, - default: null, + default: '', }, /** - * Whether to turn into a clearable state - * when an option has been selected. + * Whether or not display as inline block */ - clearable: { + inline: { type: Boolean, default: false, }, - clearText: { - type: String, - default: '', - }, }, + data() { return { + query: '', + isInsideModal: false, + isActive: false, + isTouched: false, + highlightedOption: null, + showDropdown: false, + initialValue: JSON.stringify(this.value), + quickMatchString: '', + quickMatchTimeout: null, + scrollableAncestor: null, + dropdownButtonBottom: 'auto', + maxDropdownHeight: 256, // workaround for Keen-ui not displaying floating labels for empty objects selection: Object.keys(this.value || {}).length === 0 ? '' : this.value, }; }, + computed: { name() { return `k-select-${this._uid}`; }, + classes() { + return [ + `ui-select-type-basic`, + { 'is-active': this.isActive }, + { 'is-invalid': this.invalid }, + { 'is-touched': this.isTouched }, + { 'is-disabled': this.disabled }, + { 'has-label': this.hasLabel }, + { 'has-floating-label': this.hasFloatingLabel }, + { 'k-select-inline': this.inline }, + { 'k-select-disabled': this.disabled }, + ]; + }, + + labelClasses() { + return { + 'is-inline': this.hasFloatingLabel && this.isLabelInline, + 'is-floating': this.hasFloatingLabel && !this.isLabelInline, + }; + }, + + hasLabel() { + return Boolean(this.label) || Boolean(this.$slots.default); + }, + + hasFloatingLabel() { + return this.hasLabel && this.floatingLabel; + }, + + isLabelInline() { + return this.selection.length === 0 && !this.isActive; + }, + + hasFeedback() { + return Boolean(this.invalidText) || Boolean(this.$slots.error); + }, + + showError() { + return this.invalid && (Boolean(this.invalidText) || Boolean(this.$slots.error)); + }, + + filteredOptions() { + return this.options.filter((option, index) => { + return this.defaultFilter(option, index); + }); + }, + + displayText() { + return this.selection ? this.selection[this.keys.label] || this.selection : ''; + }, + + hasDisplayText() { + return Boolean(this.displayText.length); + }, + + hasNoResults() { + if (this.query.length === 0) { + return false; + } + + return this.filteredOptions.length === 0; + }, + + submittedValue() { + // Assuming that if there is no name, then there's no + // need to computed the submittedValue + if (!this.name || !this.selection) { + return; + } + + if (Array.isArray(this.selection)) { + return this.selection.map(option => option[this.keys.value] || option).join(','); + } + + return this.selection[this.keys.value] || this.selection; + }, + + // Returns the index of the currently highlighted option + highlightedIndex() { + return this.options.findIndex(option => looseEqual(this.highlightedOption, option)); + }, + + // Returns an array containing the options and extra annotations + annotatedOptions() { + const options = JSON.parse(JSON.stringify(this.options)); + return options.map((option, index) => { + // If not object, create object + if (typeof option !== 'object') { + option = { + [this.keys.value]: option, + [this.keys.label]: option, + }; + } + + // Add index to object + option.index = index; + + // Check if valid prev/next + if (!option.disabled) { + if (index < this.highlightedIndex) { + option.validPreviousOption = true; + } else if (index > this.highlightedIndex) { + option.validNextOption = true; + } + } + + // Check if matches + option.startsWith = startswith( + option[this.keys.label].toLowerCase(), + this.quickMatchString.toLowerCase() + ); + + return option; + }); + }, + activeColorStyle() { + if (this.isActive) { + return { + color: this.$themeTokens.primary, + }; + } + + return {}; + }, + activeBorderStyle() { + if (this.isActive && !this.clearableState) { + return { + borderBottomColor: this.$themeTokens.primary, + }; + } else if (this.clearableState) { + return { + cursor: 'default', + }; + } + + return {}; + }, + clearableState() { + return ( + this.clearable && this.selection && Object.keys(this.selection).length && !this.disabled + ); + }, }, + watch: { - value(inputValue) { - this.selection = inputValue; + filteredOptions() { + this.highlightedOption = this.filteredOptions[0]; + resetScroll(this.$refs.optionsList); + }, + + showDropdown() { + if (this.showDropdown) { + this.onOpen(); + this.$emit('dropdown-open'); + } else { + this.onClose(); + this.$emit('dropdown-close'); + } }, + + query() { + this.$emit('query-change', this.query); + }, + + quickMatchString(string) { + if (string) { + if (this.quickMatchTimeout) { + clearTimeout(this.quickMatchTimeout); + this.quickMatchTimeout = null; + } + this.quickMatchTimeout = setTimeout(() => { + this.quickMatchString = ''; + }, 500); + } + }, + selection(newSelection) { /* Emits new selection.*/ if (!this.disabled) { @@ -171,12 +448,345 @@ } }, }, + + created() { + if (!this.selection || this.selection === '') { + this.setValue(null); + } + }, + + mounted() { + document.addEventListener('click', this.onExternalClick); + // Find nearest scrollable ancestor + this.scrollableAncestor = this.$el; + while ( + (this.scrollableAncestor && + this.scrollableAncestor.clientHeight < this.scrollableAncestor.scrollHeight) || + !/auto|scroll/.test(window.getComputedStyle(this.scrollableAncestor).overflowY) + ) { + if (!this.scrollableAncestor.parentNode) { + break; + } + this.scrollableAncestor = this.scrollableAncestor.parentNode; + + // Stop if we reach the body-- tagName is likely uppercase + if (/body/i.test(this.scrollableAncestor.tagName)) { + break; + } + } + + // look for KSelects nested within modals + const allSelects = document.querySelectorAll('div.modal div.ui-select'); + // create array from a nodelist [IE does not support Array.from()] + const allSelectsArr = Array.prototype.slice.call(allSelects); + this.isInsideModal = allSelectsArr.includes(this.$el); + }, + + beforeDestroy() { + document.removeEventListener('click', this.onExternalClick); + }, + methods: { - handleChange(newSelection) { - this.selection = newSelection; + setValue(value) { + value = value ? value : ''; + this.selection = value; + + this.$emit('input', value); + }, + + // Highlights the matching option on key input + highlightQuickMatch(event) { + // https://github.com/ccampbell/mousetrap/blob/master/mousetrap.js#L39 + const specialKeyCodes = [ + 8, + 9, + 13, + 16, + 17, + 18, + 20, + 27, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 45, + 46, + 91, + 93, + 224, + ]; + const keyCode = event.keyCode; + if (specialKeyCodes.includes(keyCode)) { + return; + } + + const character = event.key.toString(); + this.quickMatchString += character; + let matchingItems = this.annotatedOptions.filter( + option => option.startsWith && !option.disabled + ); + if (matchingItems.length !== 0) { + matchingItems = sortby(matchingItems, [this.keys.label]); + matchingItems = sortby(matchingItems, item => item[this.keys.label].length); + this.highlightOption(this.options[matchingItems[0].index]); + } + }, + + // Highlights the previous valid option + highlightPreviousOption() { + const options = this.annotatedOptions; + let validPreviousOptionIndex = -1; + for (let i = 0; i < options.length; i++) { + if (options[i].validPreviousOption) { + validPreviousOptionIndex = i; + } + } + if (validPreviousOptionIndex !== -1) { + this.highlightOption(this.options[validPreviousOptionIndex]); + } + }, + + // Highlights the next valid option + highlightNextOption() { + const options = this.annotatedOptions; + const validNextOptionIndex = options.findIndex(option => option.validNextOption); + if (validNextOptionIndex !== -1) { + this.highlightOption(this.options[validNextOptionIndex]); + } + }, + + // Highlights the option + highlightOption(option, options = { autoScroll: true }) { + if ( + !option || + option.disabled || + looseEqual(this.highlightedOption, option) || + this.$refs.options.length === 0 + ) { + return; + } + + this.highlightedOption = option; + this.openDropdown(); + + if (options.autoScroll) { + const index = this.filteredOptions.findIndex(option => + looseEqual(this.highlightedOption, option) + ); + const optionToScrollTo = this.$refs.options[index]; + if (optionToScrollTo) { + this.scrollOptionIntoView(optionToScrollTo.$el); + } + } + }, + + selectHighlighted() { + if ( + this.highlightedOption && + !this.highlightedOption.disabled && + this.$refs.options.length > 0 + ) { + this.selectOption(this.highlightedOption); + } + }, + + selectOption(option, options = { autoClose: true }) { + if (!option || option.disabled) { + return; + } + + this.setValue(option); + + this.$emit('select', option, { + selected: !this.isOptionSelected(option), + }); + + this.clearQuery(); + + if (options.autoClose) { + this.closeDropdown(); + } + }, + + // Checks if option is highlighted + isOptionHighlighted(option) { + return looseEqual(this.highlightedOption, option); + }, + + isOptionSelected(option) { + return looseEqual(this.selection, option); + }, + + updateOption(option, options = { select: true }) { + let value = []; + let updated = false; + const i = looseIndexOf(this.selection, option); + + if (options.select && i < 0) { + value = this.selection.concat(option); + updated = true; + } + + if (!options.select && i > -1) { + value = this.selection.slice(0, i).concat(this.selection.slice(i + 1)); + updated = true; + } + + if (updated) { + this.setValue(value); + } + }, + + defaultFilter(option) { + const query = this.query.toLowerCase(); + let text = option[this.keys.label] || option; + + if (typeof text === 'string') { + text = text.toLowerCase(); + } + + return fuzzysearch(query, text); + }, + + clearQuery() { + this.query = ''; + }, + + toggleDropdown() { + // if called on dropdown inside modal, dropdown will generally render above input/placeholder when opened, + // rather than below it: we want to render dropdown above input only in cases where there isn't enough + // space available beneath input, but when dropdown extends outside a modal the func doesn't work as intended + if (!this.isInsideModal) this.calculateSpaceBelow(); + + this[this.showDropdown ? 'closeDropdown' : 'openDropdown'](); + }, + + openDropdown() { + if (this.disabled || this.clearableState) { + return; + } + + if (this.highlightedIndex === -1) { + this.highlightNextOption(); + } + + this.showDropdown = true; + // IE: clicking label doesn't focus the select element + // to set isActive to true + if (!this.isActive) { + this.isActive = true; + } + }, + + closeDropdown(options = { autoBlur: false }) { + this.showDropdown = false; + this.query = ''; + if (!this.isTouched) { + this.isTouched = true; + this.$emit('touch'); + } + + if (options.autoBlur) { + this.isActive = false; + } else { + this.$refs.label.focus(); + } + }, + + onMouseover(option) { + if (this.showDropdown) { + this.highlightOption(option, { autoScroll: false }); + } }, - handleSelect(newSelection, options) { - this.$emit('select', newSelection, options); + + onFocus(e) { + if (this.isActive) { + return; + } + + this.isActive = true; + this.$emit('focus', e); + }, + + onBlur(e) { + this.isActive = false; + this.$emit('blur', e); + + if (this.showDropdown) { + this.closeDropdown({ autoBlur: true }); + } + }, + + onOpen() { + this.highlightedOption = this.selection; + this.$nextTick(() => { + this.$refs['dropdown'].focus(); + const selectedOption = this.$refs.optionsList.querySelector('.is-selected'); + if (selectedOption) { + this.scrollOptionIntoView(selectedOption); + } else { + this.scrollOptionIntoView( + this.$refs.optionsList.querySelector('.ui-select-option:not(.is-disabled)') + ); + } + }); + }, + + onClose() { + this.highlightedOption = this.selection; + }, + + onExternalClick(e) { + if (!this.$el.contains(e.target)) { + if (this.showDropdown) { + this.closeDropdown({ autoBlur: true }); + } else if (this.isActive) { + this.isActive = false; + } + } + }, + + scrollOptionIntoView(optionEl) { + scrollIntoView(optionEl, { + container: this.$refs.optionsList, + marginTop: 180, + }); + }, + + /** + * @public + */ + reset() { + this.setValue(JSON.parse(this.initialValue)); + this.clearQuery(); + this.resetTouched(); + this.highlightedOption = null; + }, + + resetTouched(options = { touched: false }) { + this.isTouched = options.touched; + }, + calculateSpaceBelow() { + // Get the height of element + const buttonHeight = this.$el.getBoundingClientRect().height; + + // Get the position of the element relative to the viewport + const buttonPosition = this.$el.getBoundingClientRect().top; + + // Check if there is enough space below element + // and update the "dropdownButtonBottom" data property accordingly + const notEnoughSpaceBelow = + buttonPosition > this.maxDropdownHeight && + this.scrollableAncestor.offsetHeight - buttonPosition < + buttonHeight + this.maxDropdownHeight; + + this.dropdownButtonBottom = notEnoughSpaceBelow ? buttonHeight + 'px' : 'auto'; }, }, }; @@ -186,8 +796,278 @@