diff --git a/Tithe-Vue/src/components/AddressForm.vue b/Tithe-Vue/src/components/AddressForm.vue index e333c2b..17cf0bc 100644 --- a/Tithe-Vue/src/components/AddressForm.vue +++ b/Tithe-Vue/src/components/AddressForm.vue @@ -20,6 +20,7 @@ import { import FormField from "@/components/FormField.vue"; import SearchBox from "@/components/SearchBox.vue"; +import SingleSelectBox from "@/components/SearchBoxes/SingleSelectBox.vue"; const emit = defineEmits(["addressFormChange"]); @@ -54,21 +55,50 @@ const { }) ); -const loadStreets = (query, setOptions) => { - streetListQueryEnabled.value = true; - similarStreetListVariables.value = { - streetName: query, - }; - similarStreetListOnResult((queryResult) => { - setOptions( - queryResult.data?.getSimilarStreets?.map((entity) => { - return { - id: entity.streetId, - label: entity.streetName, - }; - }) ?? [] - ); - }); +// const loadStreets = (query, setOptions) => { +// streetListQueryEnabled.value = true; +// similarStreetListVariables.value = { +// streetName: query, +// }; +// similarStreetListOnResult((queryResult) => { +// setOptions( +// queryResult.data?.getSimilarStreets?.map((entity) => { +// return { +// id: entity.streetId, +// label: entity.streetName, +// }; +// }) ?? [] +// ); +// }); +// }; + +const streetOptions = ref({}); + +const streetSearchChange = (query) => { + if (query != "") { + streetListQueryEnabled.value = true; + similarStreetListVariables.value = { + streetName: query, + }; + + similarStreetListOnResult((queryResult) => { + streetOptions.value = + queryResult.data?.getSimilarStreets?.map((entity) => { + return { + id: entity.streetId, + label: entity.streetName, + value: { + id: entity.streetId, + label: entity.streetName, + }, + }; + }) ?? []; + }); + } +}; + +const changeInStreet = (entity) => { + street.value = entity; }; watch(street, (value) => { @@ -123,21 +153,50 @@ const { }) ); -const loadCities = (query, setOptions) => { - cityListQueryEnabled.value = true; - similarCityListVariables.value = { - cityName: query, - }; - similarCityListOnResult((queryResult) => { - setOptions( - queryResult.data?.getSimilarCities?.map((entity) => { - return { - id: entity.cityId, - label: entity.cityName, - }; - }) ?? [] - ); - }); +// const loadCities = (query, setOptions) => { +// cityListQueryEnabled.value = true; +// similarCityListVariables.value = { +// cityName: query, +// }; +// similarCityListOnResult((queryResult) => { +// setOptions( +// queryResult.data?.getSimilarCities?.map((entity) => { +// return { +// id: entity.cityId, +// label: entity.cityName, +// }; +// }) ?? [] +// ); +// }); +// }; + +const cityOptions = ref({}); + +const citySearchChange = (query) => { + if (query != "") { + cityListQueryEnabled.value = true; + similarCityListVariables.value = { + cityName: query, + }; + + similarCityListOnResult((queryResult) => { + cityOptions.value = + queryResult.data?.getSimilarCities?.map((entity) => { + return { + id: entity.cityId, + label: entity.cityName, + value: { + id: entity.cityId, + label: entity.cityName, + }, + }; + }) ?? []; + }); + } +}; + +const changeInCity = (entity) => { + city.value = entity; }; watch(city, (value) => { @@ -192,21 +251,33 @@ const { }) ); -const loadDistricts = (query, setOptions) => { - districtListQueryEnabled.value = true; - similarDistrictListVariables.value = { - districtName: query, - }; - similarDistrictListOnResult((queryResult) => { - setOptions( - queryResult.data?.getSimilarDistricts?.map((entity) => { - return { - id: entity.districtId, - label: entity.districtName, - }; - }) ?? [] - ); - }); +const districtOptions = ref({}); + +const districtSearchChange = (query) => { + if (query != "") { + districtListQueryEnabled.value = true; + similarDistrictListVariables.value = { + districtName: query, + }; + + similarDistrictListOnResult((queryResult) => { + districtOptions.value = + queryResult.data?.getSimilarDistricts?.map((entity) => { + return { + id: entity.districtId, + label: entity.districtName, + value: { + id: entity.districtId, + label: entity.districtName, + }, + }; + }) ?? []; + }); + } +}; + +const changeInDistrict = (entity) => { + district.value = entity; }; watch(district, (value) => { @@ -261,21 +332,33 @@ const { }) ); -const loadStates = (query, setOptions) => { - stateListQueryEnabled.value = true; - similarStateListVariables.value = { - stateName: query, - }; - similarStateListOnResult((queryResult) => { - setOptions( - queryResult.data?.getSimilarStates?.map((entity) => { - return { - id: entity.stateId, - label: entity.stateName, - }; - }) ?? [] - ); - }); +const stateOptions = ref({}); + +const stateSearchChange = (query) => { + if (query != "") { + stateListQueryEnabled.value = true; + similarStateListVariables.value = { + stateName: query, + }; + + similarStateListOnResult((queryResult) => { + stateOptions.value = + queryResult.data?.getSimilarStates?.map((entity) => { + return { + id: entity.stateId, + label: entity.stateName, + value: { + id: entity.stateId, + label: entity.stateName, + }, + }; + }) ?? []; + }); + } +}; + +const changeInState = (entity) => { + state.value = entity; }; watch(state, (value) => { @@ -330,21 +413,33 @@ const { }) ); -const loadPincodes = (query, setOptions) => { - pincodeListQueryEnabled.value = true; - similarPincodeListVariables.value = { - pincode: query, - }; - similarPincodeListOnResult((queryResult) => { - setOptions( - queryResult.data?.getSimilarPincodes?.map((entity) => { - return { - id: entity.pincodeId, - label: entity.pincode, - }; - }) ?? [] - ); - }); +const pincodeOptions = ref({}); + +const pincodeSearchChange = (query) => { + if (query != "") { + pincodeListQueryEnabled.value = true; + similarPincodeListVariables.value = { + pincode: query, + }; + + similarPincodeListOnResult((queryResult) => { + pincodeOptions.value = + queryResult.data?.getSimilarPincodes?.map((entity) => { + return { + id: entity.pincodeId, + label: entity.pincode, + value: { + id: entity.pincodeId, + label: entity.pincode, + }, + }; + }) ?? []; + }); + } +}; + +const changeInPincode = (entity) => { + pincode.value = entity; }; watch(pincode, (value) => { @@ -392,7 +487,7 @@ defineExpose({ + + diff --git a/Tithe-Vue/src/components/MultiSelectBox/Multiselect.vue b/Tithe-Vue/src/components/MultiSelectBox/Multiselect.vue new file mode 100644 index 0000000..bec7d03 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/Multiselect.vue @@ -0,0 +1,728 @@ + + + + + + + diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useA11y.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useA11y.js new file mode 100644 index 0000000..01272c2 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useA11y.js @@ -0,0 +1,182 @@ +import { toRefs, onMounted, ref, computed } from 'vue' + +export default function useA11y (props, context, dep) +{ + const { + placeholder, id, valueProp, label: labelProp, mode, groupLabel, aria, searchable , + } = toRefs(props) + + // ============ DEPENDENCIES ============ + + const pointer = dep.pointer + const iv = dep.iv + const hasSelected = dep.hasSelected + const multipleLabelText = dep.multipleLabelText + + // ================ DATA ================ + + const label = ref(null) + + // ============== COMPUTED ============== + + const ariaAssist = computed(() => { + let texts = [] + + if (id && id.value) { + texts.push(id.value) + } + + texts.push('assist') + + return texts.join('-') + }) + + const ariaControls = computed(() => { + let texts = [] + + if (id && id.value) { + texts.push(id.value) + } + + texts.push('multiselect-options') + + return texts.join('-') + }) + + const ariaActiveDescendant = computed(() => { + let texts = [] + + if (id && id.value) { + texts.push(id.value) + } + + if (pointer.value) { + texts.push(pointer.value.group ? 'multiselect-group' : 'multiselect-option') + + texts.push(pointer.value.group ? pointer.value.index : pointer.value[valueProp.value]) + + return texts.join('-') + } + }) + + + + const ariaPlaceholder = computed(() => { + return placeholder.value + }) + + const ariaMultiselectable = computed(() => { + return mode.value !== 'single' + }) + + const ariaLabel = computed(() => { + let ariaLabel = '' + + if (mode.value === 'single' && hasSelected.value) { + ariaLabel += iv.value[labelProp.value] + } + + if (mode.value === 'multiple' && hasSelected.value) { + ariaLabel += multipleLabelText.value + } + + if (mode.value === 'tags' && hasSelected.value) { + ariaLabel += iv.value.map(v => v[labelProp.value]).join(', ') + } + + return ariaLabel + }) + + const arias = computed(() => { + let arias = { ...aria.value } + + // Need to add manually because focusing + // the input won't read the selected value + if (searchable.value) { + arias['aria-labelledby'] = arias['aria-labelledby'] + ? `${ariaAssist.value} ${arias['aria-labelledby']}` + : ariaAssist.value + + if (ariaLabel.value && arias['aria-label']) { + arias['aria-label'] = `${ariaLabel.value}, ${arias['aria-label']}` + } + } + + return arias + }) + + // =============== METHODS ============== + + const ariaOptionId = (option) => { + let texts = [] + + if (id && id.value) { + texts.push(id.value) + } + + texts.push('multiselect-option') + + texts.push(option[valueProp.value]) + + return texts.join('-') + } + + const ariaGroupId = (option) => { + let texts = [] + + if (id && id.value) { + texts.push(id.value) + } + + texts.push('multiselect-group') + + texts.push(option.index) + + return texts.join('-') + } + + const ariaOptionLabel = (label) => { + let texts = [] + + texts.push(label) + + return texts.join(' ') + } + + const ariaGroupLabel = (label) => { + let texts = [] + + texts.push(label) + + return texts.join(' ') + } + + const ariaTagLabel = (label) => { + return `${label} ❎` + } + + // =============== HOOKS ================ + + onMounted(() => { + /* istanbul ignore next */ + if (id && id.value && document && document.querySelector) { + let forTag = document.querySelector(`[for="${id.value}"]`) + label.value = forTag ? forTag.innerText : null + } + }) + + return { + arias, + ariaLabel, + ariaAssist, + ariaControls, + ariaPlaceholder, + ariaMultiselectable, + ariaActiveDescendant, + ariaOptionId, + ariaOptionLabel, + ariaGroupId, + ariaGroupLabel, + ariaTagLabel, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useClasses.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useClasses.js new file mode 100644 index 0000000..46eb320 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useClasses.js @@ -0,0 +1,160 @@ +import { computed, toRefs } from 'vue' + +export default function useClasses (props, context, dependencies) +{const { + classes: classes_, disabled, openDirection, showOptions + } = toRefs(props) + + // ============ DEPENDENCIES ============ + + const isOpen = dependencies.isOpen + const isPointed = dependencies.isPointed + const isSelected = dependencies.isSelected + const isDisabled = dependencies.isDisabled + const isActive = dependencies.isActive + const canPointGroups = dependencies.canPointGroups + const resolving = dependencies.resolving + const fo = dependencies.fo + + const classes = computed(() => ({ + container: 'multiselect', + containerDisabled: 'is-disabled', + containerOpen: 'is-open', + containerOpenTop: 'is-open-top', + containerActive: 'is-active', + wrapper: 'multiselect-wrapper', + singleLabel: 'multiselect-single-label', + singleLabelText: 'multiselect-single-label-text', + multipleLabel: 'multiselect-multiple-label', + search: 'multiselect-search', + tags: 'multiselect-tags', + tag: 'multiselect-tag', + tagDisabled: 'is-disabled', + tagRemove: 'multiselect-tag-remove', + tagRemoveIcon: 'multiselect-tag-remove-icon', + tagsSearchWrapper: 'multiselect-tags-search-wrapper', + tagsSearch: 'multiselect-tags-search', + tagsSearchCopy: 'multiselect-tags-search-copy', + placeholder: 'multiselect-placeholder', + caret: 'multiselect-caret', + caretOpen: 'is-open', + clear: 'multiselect-clear', + clearIcon: 'multiselect-clear-icon', + spinner: 'multiselect-spinner', + inifinite: 'multiselect-inifite', + inifiniteSpinner: 'multiselect-inifite-spinner', + dropdown: 'multiselect-dropdown', + dropdownTop: 'is-top', + dropdownHidden: 'is-hidden', + options: 'multiselect-options', + optionsTop: 'is-top', + group: 'multiselect-group', + groupLabel: 'multiselect-group-label', + groupLabelPointable: 'is-pointable', + groupLabelPointed: 'is-pointed', + groupLabelSelected: 'is-selected', + groupLabelDisabled: 'is-disabled', + groupLabelSelectedPointed: 'is-selected is-pointed', + groupLabelSelectedDisabled: 'is-selected is-disabled', + groupOptions: 'multiselect-group-options', + option: 'multiselect-option', + optionPointed: 'is-pointed', + optionSelected: 'is-selected', + optionDisabled: 'is-disabled', + optionSelectedPointed: 'is-selected is-pointed', + optionSelectedDisabled: 'is-selected is-disabled', + noOptions: 'multiselect-no-options', + noResults: 'multiselect-no-results', + fakeInput: 'multiselect-fake-input', + assist: 'multiselect-assistive-text', + spacer: 'multiselect-spacer', + ...classes_.value, + })) + + // ============== COMPUTED ============== + + const showDropdown = computed(() => { + return !!(isOpen.value && showOptions.value && (!resolving.value || (resolving.value && fo.value.length))) + }) + + const classList = computed(() => { + const c = classes.value + + return { + container: [c.container] + .concat(disabled.value ? c.containerDisabled : []) + .concat(showDropdown.value && openDirection.value === 'top' ? c.containerOpenTop : []) + .concat(showDropdown.value && openDirection.value !== 'top' ? c.containerOpen : []) + .concat(isActive.value ? c.containerActive : []), + wrapper: c.wrapper, + spacer: c.spacer, + singleLabel: c.singleLabel, + singleLabelText: c.singleLabelText, + multipleLabel: c.multipleLabel, + search: c.search, + tags: c.tags, + tag: [c.tag] + .concat(disabled.value ? c.tagDisabled : []), + tagDisabled: c.tagDisabled, + tagRemove: c.tagRemove, + tagRemoveIcon: c.tagRemoveIcon, + tagsSearchWrapper: c.tagsSearchWrapper, + tagsSearch: c.tagsSearch, + tagsSearchCopy: c.tagsSearchCopy, + placeholder: c.placeholder, + caret: [c.caret] + .concat(isOpen.value ? c.caretOpen : []), + clear: c.clear, + clearIcon: c.clearIcon, + spinner: c.spinner, + inifinite: c.inifinite, + inifiniteSpinner: c.inifiniteSpinner, + dropdown: [c.dropdown] + .concat(openDirection.value === 'top' ? c.dropdownTop : []) + .concat(!isOpen.value || !showOptions.value || !showDropdown.value ? c.dropdownHidden : []), + options: [c.options] + .concat(openDirection.value === 'top' ? c.optionsTop : []), + group: c.group, + groupLabel: (g) => { + let groupLabel = [c.groupLabel] + + if (isPointed(g)) { + groupLabel.push(isSelected(g) ? c.groupLabelSelectedPointed : c.groupLabelPointed) + } else if (isSelected(g) && canPointGroups.value) { + groupLabel.push(isDisabled(g) ? c.groupLabelSelectedDisabled : c.groupLabelSelected) + } else if (isDisabled(g)) { + groupLabel.push(c.groupLabelDisabled) + } + + if (canPointGroups.value) { + groupLabel.push(c.groupLabelPointable) + } + + return groupLabel + }, + groupOptions: c.groupOptions, + option: (o, g) => { + let option = [c.option] + + if (isPointed(o)) { + option.push(isSelected(o) ? c.optionSelectedPointed : c.optionPointed) + } else if (isSelected(o)) { + option.push(isDisabled(o) ? c.optionSelectedDisabled : c.optionSelected) + } else if (isDisabled(o) || (g && isDisabled(g))) { + option.push(c.optionDisabled) + } + + return option + }, + noOptions: c.noOptions, + noResults: c.noResults, + assist: c.assist, + fakeInput: c.fakeInput, + } + }) + + return { + classList, + showDropdown, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useData.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useData.js new file mode 100644 index 0000000..0ac50bd --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useData.js @@ -0,0 +1,62 @@ +import { toRefs, getCurrentInstance } from 'vue' +import isNullish from '../utils/isNullish' + +export default function useData (props, context, dep) +{ + const { object, valueProp, mode } = toRefs(props) + + const $this = getCurrentInstance().proxy + + // ============ DEPENDENCIES ============ + + const iv = dep.iv + + // =============== METHODS ============== + + const update = (val, triggerInput = true) => { + // Setting object(s) as internal value + iv.value = makeInternal(val) + + // Setting object(s) or plain value as external + // value based on `option` setting + const externalVal = makeExternal(val) + + context.emit('change', externalVal, $this) + + if (triggerInput) { + context.emit('input', externalVal) + context.emit('update:modelValue', externalVal) + } + } + + // no export + const makeExternal = (val) => { + // If external value should be object + // no transformation is required + if (object.value) { + return val + } + + // No need to transform if empty value + if (isNullish(val)) { + return val + } + + // If external should be plain transform + // value object to plain values + return !Array.isArray(val) ? val[valueProp.value] : val.map(v => v[valueProp.value]) + } + + // no export + const makeInternal = (val) => { + if (isNullish(val)) { + return mode.value === 'single' ? {} : [] + } + + return val + } + + return { + update, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useDropdown.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useDropdown.js new file mode 100644 index 0000000..2030f63 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useDropdown.js @@ -0,0 +1,38 @@ +import { ref, toRefs, getCurrentInstance } from 'vue' + +export default function useDropdown (props, context, dep) +{ + const { disabled } = toRefs(props) + + const $this = getCurrentInstance().proxy + + // ================ DATA ================ + + const isOpen = ref(false) + + // =============== METHODS ============== + + const open = () => { + if (isOpen.value || disabled.value) { + return + } + + isOpen.value = true + context.emit('open', $this) + } + + const close = () => { + if (!isOpen.value) { + return + } + + isOpen.value = false + context.emit('close', $this) + } + + return { + isOpen, + open, + close, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useI18n.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useI18n.js new file mode 100644 index 0000000..796df86 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useI18n.js @@ -0,0 +1,34 @@ +import { toRefs } from 'vue' + +export default function useI18n (props, context, dep) +{ + const { + locale, fallbackLocale, + } = toRefs(props) + + // =============== METHODS ============== + + const localize = (target) => { + if (!target || typeof target !== 'object') { + return target + } + + if (target && target[locale.value]) { + return target[locale.value] + } else if (target && locale.value && target[locale.value.toUpperCase()]) { + return target[locale.value.toUpperCase()] + } else if (target && target[fallbackLocale.value]) { + return target[fallbackLocale.value] + } else if (target && fallbackLocale.value && target[fallbackLocale.value.toUpperCase()]) { + return target[fallbackLocale.value.toUpperCase()] + } else if (target && Object.keys(target)[0]) { + return target[Object.keys(target)[0]] + } else { + return '' + } + } + + return { + localize, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useKeyboard.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useKeyboard.js new file mode 100644 index 0000000..6fb5d31 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useKeyboard.js @@ -0,0 +1,258 @@ +import { toRefs, computed, getCurrentInstance } from 'vue' + +export default function useKeyboard (props, context, dep) +{ + const { + mode, addTagOn, openDirection, searchable, + showOptions, valueProp, groups: groupped, + addOptionOn: addOptionOn_, createTag, createOption: createOption_, + reverse, + } = toRefs(props) + + const $this = getCurrentInstance().proxy + + // ============ DEPENDENCIES ============ + + const iv = dep.iv + const update = dep.update + const search = dep.search + const setPointer = dep.setPointer + const selectPointer = dep.selectPointer + const backwardPointer = dep.backwardPointer + const forwardPointer = dep.forwardPointer + const multiselect = dep.multiselect + const wrapper = dep.wrapper + const tags = dep.tags + const isOpen = dep.isOpen + const open = dep.open + const blur = dep.blur + const fo = dep.fo + + // ============== COMPUTED ============== + + // no export + const createOption = computed(() => { + return createTag.value || createOption_.value || false + }) + + // no export + const addOptionOn = computed(() => { + if (addTagOn.value !== undefined) { + return addTagOn.value + } + else if (addOptionOn_.value !== undefined) { + return addOptionOn_.value + } + + return ['enter'] + }) + + // =============== METHODS ============== + + // no export + const preparePointer = () => { + // When options are hidden and creating tags is allowed + // no pointer will be set (because options are hidden). + // In such case we need to set the pointer manually to the + // first option, which equals to the option created from + // the search value. + if (mode.value === 'tags' && !showOptions.value && createOption.value && searchable.value && !groupped.value) { + setPointer(fo.value[fo.value.map(o => o[valueProp.value]).indexOf(search.value)]) + } + } + + const removeLastRemovable = (arr) => { + // Find the index of the last object in the array that doesn't have a "remove" property set to false + let indexToRemove = arr.length - 1 + while (indexToRemove >= 0 && (arr[indexToRemove].remove === false || arr[indexToRemove].disabled)) { + indexToRemove-- + } + + // If all objects have a "remove" property set to false, don't remove anything and return the original array + if (indexToRemove < 0) { + return arr + } + + // Remove the object at the found index and return the updated array + arr.splice(indexToRemove, 1); + return arr + } + + const handleKeydown = (e) => { + context.emit('keydown', e, $this) + + let tagList + let activeIndex + + if (['ArrowLeft', 'ArrowRight', 'Enter'].indexOf(e.key) !== -1 && mode.value === 'tags') { + tagList = [...(multiselect.value.querySelectorAll(`[data-tags] > *`))].filter(e => e !== tags.value) + activeIndex = tagList.findIndex(e => e === document.activeElement) + } + + switch (e.key) { + case 'Backspace': + if (mode.value === 'single') { + return + } + + if (searchable.value && [null, ''].indexOf(search.value) === -1) { + return + } + + if (iv.value.length === 0) { + return + } + + update(removeLastRemovable([...iv.value])) + break + + case 'Enter': + e.preventDefault() + + if (e.keyCode === 229) { + // ignore IME confirmation + return + } + + if (activeIndex !== -1 && activeIndex !== undefined) { + update([...iv.value].filter((v, k) => k !== activeIndex)) + + if (activeIndex === tagList.length - 1) { + if (tagList.length - 1) { + tagList[tagList.length - 2].focus() + } else if (searchable.value) { + tags.value.querySelector('input').focus() + } else { + wrapper.value.focus() + } + } + return + } + + if (addOptionOn.value.indexOf('enter') === -1 && createOption.value) { + return + } + + preparePointer() + selectPointer() + break + + case ' ': + if (!createOption.value && !searchable.value) { + e.preventDefault() + + preparePointer() + selectPointer() + return + } + + if (!createOption.value) { + return false + } + + if (addOptionOn.value.indexOf('space') === -1 && createOption.value) { + return + } + + e.preventDefault() + + preparePointer() + selectPointer() + break + + case 'Tab': + case ';': + case ',': + if (addOptionOn.value.indexOf(e.key.toLowerCase()) === -1 || !createOption.value) { + return + } + + preparePointer() + selectPointer() + e.preventDefault() + break + + case 'Escape': + blur() + break + + case 'ArrowUp': + e.preventDefault() + + if (!showOptions.value) { + return + } + + /* istanbul ignore else */ + if (!isOpen.value) { + open() + } + + backwardPointer() + break + + case 'ArrowDown': + e.preventDefault() + + if (!showOptions.value) { + return + } + + /* istanbul ignore else */ + if (!isOpen.value) { + open() + } + + forwardPointer() + break + + case 'ArrowLeft': + if ( + (searchable.value && tags.value && tags.value.querySelector('input').selectionStart) + || e.shiftKey || mode.value !== 'tags' || !iv.value || !iv.value.length + ) { + return + } + + e.preventDefault() + + if (activeIndex === -1) { + tagList[tagList.length-1].focus() + } + else if (activeIndex > 0) { + tagList[activeIndex-1].focus() + } + break + + case 'ArrowRight': + if (activeIndex === -1 || e.shiftKey || mode.value !== 'tags' || !iv.value || !iv.value.length) { + return + } + + e.preventDefault() + + /* istanbul ignore else */ + if (tagList.length > activeIndex + 1) { + tagList[activeIndex+1].focus() + } + else if (searchable.value) { + tags.value.querySelector('input').focus() + } + else if (!searchable.value) { + wrapper.value.focus() + } + + break + } + } + + const handleKeyup = (e) => { + context.emit('keyup', e, $this) + } + + return { + handleKeydown, + handleKeyup, + preparePointer, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useMultiselect.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useMultiselect.js new file mode 100644 index 0000000..267ac6f --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useMultiselect.js @@ -0,0 +1,125 @@ +import { ref, toRefs, computed, nextTick } from 'vue' + +export default function useMultiselect (props, context, dep) +{ + const { searchable, disabled, clearOnBlur } = toRefs(props) + + // ============ DEPENDENCIES ============ + + const input = dep.input + const open = dep.open + const close = dep.close + const clearSearch = dep.clearSearch + const isOpen = dep.isOpen + + // ================ DATA ================ + + const multiselect = ref(null) + + const wrapper = ref(null) + + const tags = ref(null) + + const isActive = ref(false) + + const mouseClicked = ref(false) + + // ============== COMPUTED ============== + + const tabindex = computed(() => { + return searchable.value || disabled.value ? -1 : 0 + }) + + // =============== METHODS ============== + + const blur = () => { + if (searchable.value) { + input.value.blur() + } + + wrapper.value.blur() + } + + const focus = () => { + if (searchable.value && !disabled.value) { + input.value.focus() + } + } + + const activate = (shouldOpen = true) => { + if (disabled.value) { + return + } + + isActive.value = true + + if (shouldOpen) { + open() + } + } + + const deactivate = () => { + isActive.value = false + + setTimeout(() => { + if (!isActive.value) { + close() + + if (clearOnBlur.value) { + clearSearch() + } + } + }, 1) + } + + const handleFocusIn = (e) => { + if ((e.target.closest('[data-tags]') && e.target.nodeName !== 'INPUT') || e.target.closest('[data-clear]')) { + return + } + + activate(mouseClicked.value) + } + + const handleFocusOut = () => { + deactivate() + } + + const handleCaretClick = () => { + deactivate() + blur() + } + + /* istanbul ignore next */ + const handleMousedown = (e) => { + mouseClicked.value = true + + if (isOpen.value && (e.target.isEqualNode(wrapper.value) || e.target.isEqualNode(tags.value))) { + setTimeout(() => { + deactivate() + }, 0) + } else if (document.activeElement.isEqualNode(wrapper.value) && !isOpen.value) { + activate() + } + + setTimeout(() => { + mouseClicked.value = false + }, 0) + } + + return { + multiselect, + wrapper, + tags, + tabindex, + isActive, + mouseClicked, + blur, + focus, + activate, + deactivate, + handleFocusIn, + handleFocusOut, + handleCaretClick, + handleMousedown, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useOptions.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useOptions.js new file mode 100644 index 0000000..6cc928f --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useOptions.js @@ -0,0 +1,856 @@ +import { ref, toRefs, computed, watch, getCurrentInstance } from 'vue' +import normalize from '../utils/normalize' +import isObject from '../utils/isObject' +import isNullish from '../utils/isNullish' +import arraysEqual from '../utils/arraysEqual' + +export default function useOptions (props, context, dep) +{ + const { + options, mode, trackBy: trackBy_, limit, hideSelected, createTag, createOption: createOption_, label, + appendNewTag, appendNewOption: appendNewOption_, multipleLabel, object, loading, delay, resolveOnLoad, + minChars, filterResults, clearOnSearch, clearOnSelect, valueProp, allowAbsent, groupLabel, + canDeselect, max, strict, closeOnSelect, closeOnDeselect, groups: groupped, reverse, infinite, + groupOptions, groupHideEmpty, groupSelect, onCreate, disabledProp, searchStart, searchFilter, + } = toRefs(props) + + const $this = getCurrentInstance().proxy + + // ============ DEPENDENCIES ============ + + const iv = dep.iv + const ev = dep.ev + const search = dep.search + const clearSearch = dep.clearSearch + const update = dep.update + const pointer = dep.pointer + const clearPointer = dep.clearPointer + const focus = dep.focus + const deactivate = dep.deactivate + const close = dep.close + const localize = dep.localize + + // ================ DATA ================ + + // no export + // appendedOptions + const ap = ref([]) + + // no export + // resolvedOptions + const ro = ref([]) + + const resolving = ref(false) + + // no export + const searchWatcher = ref(null) + + const offset = ref(infinite.value && limit.value === -1 ? 10 : limit.value) + + // ============== COMPUTED ============== + + // no export + const createOption = computed(() => { + return createTag.value || createOption_.value || false + }) + + // no export + const appendNewOption = computed(() => { + if (appendNewTag.value !== undefined) { + return appendNewTag.value + } else if (appendNewOption_.value !== undefined) { + return appendNewOption_.value + } + + return true + }) + + // no export + // extendedOptions + const eo = computed(() => { + if (groupped.value) { + let groups = eg.value || /* istanbul ignore next */ [] + + let eo = [] + + groups.forEach((group) => { + optionsToArray(group[groupOptions.value]).forEach((option) => { + eo.push(Object.assign({}, option, group[disabledProp.value] ? { [disabledProp.value]: true } : {})) + }) + }) + + return eo + } else { + let eo = optionsToArray(ro.value || /* istanbul ignore next */ []) + + if (ap.value.length) { + eo = eo.concat(ap.value) + } + + return eo + } + }) + + // preFilteredOptions + const pfo = computed(() => { + let options = eo.value + + if (reverse.value) { + options = options.reverse() + } + + if (createdOption.value.length) { + options = createdOption.value.concat(options) + } + + return filterOptions(options) + }) + + // filteredOptions + const fo = computed(() => { + let options = pfo.value + + if (offset.value > 0) { + options = options.slice(0, offset.value) + } + + return options + }) + + // no export + // extendedGroups + const eg = computed(() => { + if (!groupped.value) { + return [] + } + + let eg = [] + let groups = ro.value || /* istanbul ignore next */ [] + + if (ap.value.length) { + eg.push({ + [groupLabel.value]: ' ', + [groupOptions.value]: [...ap.value], + __CREATE__: true + }) + } + + return eg.concat(groups) + }) + + // preFilteredGroups + const pfg = computed(() => { + let groups = [...eg.value].map(g => ({...g})) + + if (createdOption.value.length) { + if (groups[0] && groups[0].__CREATE__) { + groups[0][groupOptions.value] = [...createdOption.value, ...groups[0][groupOptions.value]] + } else { + groups = [{ + [groupLabel.value]: ' ', + [groupOptions.value]: [...createdOption.value], + __CREATE__: true + }].concat(groups) + } + } + + return groups + }) + + // filteredGroups + const fg = computed(() => { + if (!groupped.value) { + return [] + } + + let options = pfg.value + + return filterGroups((options || /* istanbul ignore next */ []).map((group, index) => { + const arrayOptions = optionsToArray(group[groupOptions.value]) + + return { + ...group, + index, + group: true, + [groupOptions.value]: filterOptions(arrayOptions, false).map(o => Object.assign({}, o, group[disabledProp.value] ? { [disabledProp.value]: true } : {})), + __VISIBLE__: filterOptions(arrayOptions).map(o => Object.assign({}, o, group[disabledProp.value] ? { [disabledProp.value]: true } : {})), + } + // Difference between __VISIBLE__ and {groupOptions}: visible does not contain selected options when hideSelected=true + })) + }) + + const hasSelected = computed(() => { + switch (mode.value) { + case 'single': + return !isNullish(iv.value[valueProp.value]) + + case 'multiple': + case 'tags': + return !isNullish(iv.value) && iv.value.length > 0 + } + }) + + const multipleLabelText = computed(() => { + return multipleLabel !== undefined && multipleLabel.value !== undefined + ? multipleLabel.value(iv.value, $this) + : (iv.value && iv.value.length > 1 ? `${iv.value.length} options selected` : `1 option selected`) + }) + + const noOptions = computed(() => { + return !eo.value.length && !resolving.value && !createdOption.value.length + }) + + + const noResults = computed(() => { + return eo.value.length > 0 && fo.value.length == 0 && ((search.value && groupped.value) || !groupped.value) + }) + + // no export + const createdOption = computed(() => { + if (createOption.value === false || !search.value) { + return [] + } + + if (getOptionByTrackBy(search.value) !== -1) { + return [] + } + + return [{ + [valueProp.value]: search.value, + [trackBy.value]: search.value, + [label.value]: search.value, + __CREATE__: true, + }] + }) + + const trackBy = computed(() => { + return trackBy_.value || label.value + }) + + // no export + const nullValue = computed(() => { + switch (mode.value) { + case 'single': + return null + + case 'multiple': + case 'tags': + return [] + } + }) + + const busy = computed(() => { + return loading.value || resolving.value + }) + + // =============== METHODS ============== + + /** + * @param {array|object|string|number} option + */ + const select = (option) => { + if (typeof option !== 'object') { + option = getOption(option) + } + + switch (mode.value) { + case 'single': + update(option) + break + + case 'multiple': + case 'tags': + update((iv.value).concat(option)) + break + } + + context.emit('select', finalValue(option), option, $this) + } + + const deselect = (option) => { + if (typeof option !== 'object') { + option = getOption(option) + } + + switch (mode.value) { + case 'single': + clear() + break + + case 'tags': + case 'multiple': + update(Array.isArray(option) + ? iv.value.filter(v => option.map(o => o[valueProp.value]).indexOf(v[valueProp.value]) === -1) + : iv.value.filter(v => v[valueProp.value] != option[valueProp.value])) + break + } + + context.emit('deselect', finalValue(option), option, $this) + } + + // no export + const finalValue = (option) => { + return object.value ? option : option[valueProp.value] + } + + const remove = (option) => { + deselect(option) + } + + const handleTagRemove = (option, e) => { + if (e.button !== 0) { + e.preventDefault() + return + } + + remove(option) + } + + const clear = () => { + context.emit('clear', $this) + update(nullValue.value) + } + + const isSelected = (option) => { + if (option.group !== undefined) { + return mode.value === 'single' ? false : areAllSelected(option[groupOptions.value]) && option[groupOptions.value].length + } + + switch (mode.value) { + case 'single': + return !isNullish(iv.value) && iv.value[valueProp.value] == option[valueProp.value] + + case 'tags': + case 'multiple': + return !isNullish(iv.value) && iv.value.map(o => o[valueProp.value]).indexOf(option[valueProp.value]) !== -1 + } + } + + const isDisabled = (option) => { + return option[disabledProp.value] === true + } + + const isMax = () => { + if (max === undefined || max.value === -1 || (!hasSelected.value && max.value > 0)) { + return false + } + + return iv.value.length >= max.value + } + + const handleOptionClick = (option) => { + if (isDisabled(option)) { + return + } + + if (onCreate && onCreate.value && !isSelected(option) && option.__CREATE__) { + option = { ...option } + delete option.__CREATE__ + + option = onCreate.value(option, $this) + + if (option instanceof Promise) { + resolving.value = true + option.then((result) => { + resolving.value = false + handleOptionSelect(result) + }) + + return + } + } + + handleOptionSelect(option) + } + + const handleOptionSelect = (option) => { + if (option.__CREATE__) { + option = { ...option } + delete option.__CREATE__ + } + + switch (mode.value) { + case 'single': + if (option && isSelected(option)) { + if (canDeselect.value) { + deselect(option) + } + + if (closeOnDeselect.value) { + clearPointer() + close() + } + return + } + + if (option) { + handleOptionAppend(option) + } + + /* istanbul ignore else */ + if (clearOnSelect.value) { + clearSearch() + } + + if (closeOnSelect.value) { + clearPointer() + close() + } + + if (option) { + select(option) + } + break + + case 'multiple': + if (option && isSelected(option)) { + deselect(option) + + if (closeOnDeselect.value) { + clearPointer() + close() + } + return + } + + if (isMax()) { + context.emit('max', $this) + return + } + + if (option) { + handleOptionAppend(option) + select(option) + } + + if (clearOnSelect.value) { + clearSearch() + } + + if (hideSelected.value) { + clearPointer() + } + + if (closeOnSelect.value) { + close() + } + break + + case 'tags': + if (option && isSelected(option)) { + deselect(option) + + if (closeOnDeselect.value) { + clearPointer() + close() + } + return + } + + if (isMax()) { + context.emit('max', $this) + return + } + + if (option) { + handleOptionAppend(option) + } + + if (clearOnSelect.value) { + clearSearch() + } + + if (option) { + select(option) + } + + if (hideSelected.value) { + clearPointer() + } + + if (closeOnSelect.value) { + close() + } + break + } + + if (!closeOnSelect.value) { + focus() + } + } + + const handleGroupClick = (group) => { + if (isDisabled(group) || mode.value === 'single' || !groupSelect.value) { + return + } + + switch (mode.value) { + case 'multiple': + case 'tags': + if (areAllEnabledSelected(group[groupOptions.value])) { + deselect(group[groupOptions.value]) + } else { + select(group[groupOptions.value] + .filter(o => iv.value.map(v => v[valueProp.value]).indexOf(o[valueProp.value]) === -1) + .filter(o => !o[disabledProp.value]) + .filter((o, k) => iv.value.length + 1 + k <= max.value || max.value === -1) + ) + } + break + } + + if (closeOnSelect.value) { + deactivate() + } + } + + const handleOptionAppend = (option) => { + if (getOption(option[valueProp.value]) === undefined && createOption.value) { + context.emit('tag', option[valueProp.value], $this) + context.emit('option', option[valueProp.value], $this) + context.emit('create', option[valueProp.value], $this) + + if (appendNewOption.value) { + appendOption(option) + } + + clearSearch() + } + } + + const selectAll = () => { + if (mode.value === 'single') { + return + } + + select(fo.value.filter(o => !o.disabled && !isSelected(o))) + } + + // no export + const areAllEnabledSelected = (options) => { + return options.find(o => !isSelected(o) && !o[disabledProp.value]) === undefined + } + + // no export + const areAllSelected = (options) => { + return options.find(o => !isSelected(o)) === undefined + } + + const getOption = (val) => { + return eo.value[eo.value.map(o => String(o[valueProp.value])).indexOf(String(val))] + } + + // no export + const getOptionByTrackBy = (val, norm = true) => { + return eo.value.map(o => parseInt(o[trackBy.value]) == o[trackBy.value] ? parseInt(o[trackBy.value]) : o[trackBy.value]).indexOf( + parseInt(val) == val ? parseInt(val) : val + ) + } + + // no export + const shouldHideOption = (option) => { + return ['tags', 'multiple'].indexOf(mode.value) !== -1 && hideSelected.value && isSelected(option) + } + + // no export + const appendOption = (option) => { + ap.value.push(option) + } + + // no export + const filterGroups = (groups) => { + // If the search has value we need to filter among + // the ones that are visible to the user to avoid + // displaying groups which technically have options + // based on search but that option is already selected. + return groupHideEmpty.value + ? groups.filter(g => search.value + ? g.__VISIBLE__.length + : g[groupOptions.value].length + ) + : groups.filter(g => search.value ? g.__VISIBLE__.length : true) + } + + // no export + const filterOptions = (options, excludeHideSelected = true) => { + let fo = options + + if (search.value && filterResults.value) { + let filter = searchFilter.value + + if (!filter) { + filter = (option, $this) => { + let target = normalize(localize(option[trackBy.value]), strict.value) + + return searchStart.value + ? target.startsWith(normalize(search.value, strict.value)) + : target.indexOf(normalize(search.value, strict.value)) !== -1 + } + } + + fo = fo.filter(filter) + } + + if (hideSelected.value && excludeHideSelected) { + fo = fo.filter((option) => !shouldHideOption(option)) + } + + return fo + } + + // no export + const optionsToArray = (options) => { + let uo = options + + // Transforming an object to an array of objects + if (isObject(uo)) { + uo = Object.keys(uo).map((key) => { + let val = uo[key] + + return { [valueProp.value]: key, [trackBy.value]: val, [label.value]: val} + }) + } + + // Transforming an plain arrays to an array of objects + uo = uo.map((val) => { + return typeof val === 'object' ? val : { [valueProp.value]: val, [trackBy.value]: val, [label.value]: val} + }) + + return uo + } + + // no export + const initInternalValue = () => { + if (!isNullish(ev.value)) { + iv.value = makeInternal(ev.value) + } + } + + const resolveOptions = (callback) => { + resolving.value = true + + return new Promise((resolve, reject) => { + options.value(search.value, $this).then((response) => { + ro.value = response || [] + + if (typeof callback == 'function') { + callback(response) + } + + resolving.value = false + }).catch((e) => { + console.error(e) + + ro.value = [] + + resolving.value = false + }).finally(() => { + resolve() + }) + }) + } + + // no export + const refreshLabels = () => { + if (!hasSelected.value) { + return + } + + if (mode.value === 'single') { + let option = getOption(iv.value[valueProp.value]) + + /* istanbul ignore else */ + if (option !== undefined) { + let newLabel = option[label.value] + + iv.value[label.value] = newLabel + + if (object.value) { + ev.value[label.value] = newLabel + } + } + } else { + iv.value.forEach((val, i) => { + let option = getOption(iv.value[i][valueProp.value]) + + /* istanbul ignore else */ + if (option !== undefined) { + let newLabel = option[label.value] + + iv.value[i][label.value] = newLabel + + if (object.value) { + ev.value[i][label.value] = newLabel + } + } + }) + } + } + + const refreshOptions = (callback) => { + resolveOptions(callback) + } + + // no export + const makeInternal = (val) => { + if (isNullish(val)) { + return mode.value === 'single' ? {} : [] + } + + if (object.value) { + return val + } + + // If external should be plain transform value object to plain values + return mode.value === 'single' ? getOption(val) || (allowAbsent.value ? { + [label.value]: val, + [valueProp.value]: val, + [trackBy.value]: val, + } : {}) : val.filter(v => !!getOption(v) || allowAbsent.value).map(v => getOption(v) || { + [label.value]: v, + [valueProp.value]: v, + [trackBy.value]: v, + }) + } + + // no export + const initSearchWatcher = () => { + searchWatcher.value = watch(search, (query) => { + if (query.length < minChars.value || (!query && minChars.value !== 0)) { + return + } + + resolving.value = true + + if (clearOnSearch.value) { + ro.value = [] + } + setTimeout(() => { + if (query != search.value) { + return + } + + options.value(search.value, $this).then((response) => { + if (query == search.value || !search.value) { + ro.value = response + pointer.value = fo.value.filter(o => o[disabledProp.value] !== true)[0] || null + resolving.value = false + } + }).catch( /* istanbul ignore next */ (e) => { + console.error(e) + }) + }, delay.value) + + }, { flush: 'sync' }) + } + + // ================ HOOKS =============== + + if (mode.value !== 'single' && !isNullish(ev.value) && !Array.isArray(ev.value)) { + throw new Error(`v-model must be an array when using "${mode.value}" mode`) + } + + if (options && typeof options.value == 'function') { + if (resolveOnLoad.value) { + resolveOptions(initInternalValue) + } else if (object.value == true) { + initInternalValue() + } + } + else { + ro.value = options.value + + initInternalValue() + } + + // ============== WATCHERS ============== + + if (delay.value > -1) { + initSearchWatcher() + } + + watch(delay, (value, old) => { + /* istanbul ignore else */ + if (searchWatcher.value) { + searchWatcher.value() + } + + if (value >= 0) { + initSearchWatcher() + } + }) + + watch(ev, (newValue) => { + if (isNullish(newValue)) { + update(makeInternal(newValue), false) + return + } + + switch (mode.value) { + case 'single': + if (object.value ? newValue[valueProp.value] != iv.value[valueProp.value] : newValue != iv.value[valueProp.value]) { + update(makeInternal(newValue), false) + } + break + + case 'multiple': + case 'tags': + if (!arraysEqual(object.value ? newValue.map(o => o[valueProp.value]) : newValue, iv.value.map(o => o[valueProp.value]))) { + update(makeInternal(newValue), false) + } + break + } + }, { deep: true }) + + watch(options, (n, o) => { + if (typeof props.options === 'function') { + if (resolveOnLoad.value && (!o || (n && n.toString() !== o.toString()))) { + resolveOptions() + } + } else { + ro.value = props.options + + if (!Object.keys(iv.value).length) { + initInternalValue() + } + + refreshLabels() + } + }) + + watch(label, refreshLabels) + + return { + pfo, + fo, + filteredOptions: fo, + hasSelected, + multipleLabelText, + eo, + extendedOptions: eo, + eg, + extendedGroups: eg, + fg, + filteredGroups: fg, + noOptions, + noResults, + resolving, + busy, + offset, + select, + deselect, + remove, + selectAll, + clear, + isSelected, + isDisabled, + isMax, + getOption, + handleOptionClick, + handleGroupClick, + handleTagRemove, + refreshOptions, + resolveOptions, + refreshLabels, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/usePointer.js b/Tithe-Vue/src/components/MultiSelectBox/composables/usePointer.js new file mode 100644 index 0000000..7cd2b91 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/usePointer.js @@ -0,0 +1,34 @@ +import { ref, toRefs } from 'vue' + +export default function usePointer (props, context, dep) +{ + const { groupSelect, mode, groups, disabledProp } = toRefs(props) + + // ================ DATA ================ + + const pointer = ref(null) + + // =============== METHODS ============== + + const setPointer = (option) => { + if (option === undefined || (option !== null && option[disabledProp.value])) { + return + } + + if (groups.value && option && option.group && (mode.value === 'single' || !groupSelect.value)) { + return + } + + pointer.value = option + } + + const clearPointer = () => { + setPointer(null) + } + + return { + pointer, + setPointer, + clearPointer, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/usePointerAction.js b/Tithe-Vue/src/components/MultiSelectBox/composables/usePointerAction.js new file mode 100644 index 0000000..e324ae8 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/usePointerAction.js @@ -0,0 +1,272 @@ +import { toRefs, watch, nextTick, computed } from 'vue' + +export default function usePointer (props, context, dep) +{ + const { + valueProp, showOptions, searchable, groupLabel, + groups: groupped, mode, groupSelect, disabledProp, + groupOptions, + } = toRefs(props) + + // ============ DEPENDENCIES ============ + + const fo = dep.fo + const fg = dep.fg + const handleOptionClick = dep.handleOptionClick + const handleGroupClick = dep.handleGroupClick + const search = dep.search + const pointer = dep.pointer + const setPointer = dep.setPointer + const clearPointer = dep.clearPointer + const multiselect = dep.multiselect + const isOpen = dep.isOpen + + // ============== COMPUTED ============== + + // no export + const options = computed(() => { + return fo.value.filter(o => !o[disabledProp.value]) + }) + + const groups = computed(() => { + return fg.value.filter(g => !g[disabledProp.value]) + }) + + const canPointGroups = computed(() => { + return mode.value !== 'single' && groupSelect.value + }) + + const isPointerGroup = computed(() => { + return pointer.value && pointer.value.group + }) + + const currentGroup = computed(() => { + return getParentGroup(pointer.value) + }) + + const prevGroup = computed(() => { + const group = isPointerGroup.value ? pointer.value : /* istanbul ignore next */ getParentGroup(pointer.value) + const groupIndex = groups.value.map(g => g[groupLabel.value]).indexOf(group[groupLabel.value]) + let prevGroup = groups.value[groupIndex - 1] + + if (prevGroup === undefined) { + prevGroup = lastGroup.value + } + + return prevGroup + }) + + const nextGroup = computed(() => { + let nextIndex = groups.value.map(g => g.label).indexOf(isPointerGroup.value + ? pointer.value[groupLabel.value] + : getParentGroup(pointer.value)[groupLabel.value]) + 1 + + if (groups.value.length <= nextIndex) { + nextIndex = 0 + } + + return groups.value[nextIndex] + }) + + const lastGroup = computed(() => { + return [...groups.value].slice(-1)[0] + }) + + const currentGroupFirstEnabledOption = computed(() => { + return pointer.value.__VISIBLE__.filter(o => !o[disabledProp.value])[0] + }) + + const currentGroupPrevEnabledOption = computed(() => { + const options = currentGroup.value.__VISIBLE__.filter(o => !o[disabledProp.value]) + return options[options.map(o => o[valueProp.value]).indexOf(pointer.value[valueProp.value]) - 1] + }) + + const currentGroupNextEnabledOption = computed(() => { + const options = getParentGroup(pointer.value).__VISIBLE__.filter(o => !o[disabledProp.value]) + return options[options.map(o => o[valueProp.value]).indexOf(pointer.value[valueProp.value]) + 1] + }) + + const prevGroupLastEnabledOption = computed(() => { + return [...prevGroup.value.__VISIBLE__.filter(o => !o[disabledProp.value])].slice(-1)[0] + }) + + const lastGroupLastEnabledOption = computed(() => { + return [...lastGroup.value.__VISIBLE__.filter(o => !o[disabledProp.value])].slice(-1)[0] + }) + + // =============== METHODS ============== + + const isPointed = (option) => { + return (!!pointer.value && ( + (!option.group && pointer.value[valueProp.value] === option[valueProp.value]) || + (option.group !== undefined && pointer.value[groupLabel.value] === option[groupLabel.value]) + )) ? true : undefined + } + + const setPointerFirst = () => { + setPointer(options.value[0] || null) + } + + const selectPointer = () => { + if (!pointer.value || pointer.value[disabledProp.value] === true) { + return + } + + if (isPointerGroup.value) { + handleGroupClick(pointer.value) + } else { + handleOptionClick(pointer.value) + } + } + + const forwardPointer = () => { + if (pointer.value === null) { + setPointer((groupped.value && canPointGroups.value ? (!groups.value[0].__CREATE__ ? groups.value[0] : options.value[0]) : options.value[0]) || null) + } + else if (groupped.value && canPointGroups.value) { + let nextPointer = isPointerGroup.value ? currentGroupFirstEnabledOption.value : currentGroupNextEnabledOption.value + + if (nextPointer === undefined) { + nextPointer = nextGroup.value + + if (nextPointer.__CREATE__) { + nextPointer = nextPointer[groupOptions.value][0] + } + } + + setPointer(nextPointer || /* istanbul ignore next */ null) + } else { + let next = options.value.map(o => o[valueProp.value]).indexOf(pointer.value[valueProp.value]) + 1 + + if (options.value.length <= next) { + next = 0 + } + + setPointer(options.value[next] || null) + } + + nextTick(() => { + adjustWrapperScrollToPointer() + }) + } + + const backwardPointer = () => { + if (pointer.value === null) { + let prevPointer = options.value[options.value.length - 1] + + if (groupped.value && canPointGroups.value) { + prevPointer = lastGroupLastEnabledOption.value + + if (prevPointer === undefined) { + prevPointer = lastGroup.value + } + } + + setPointer(prevPointer || null) + } + else if (groupped.value && canPointGroups.value) { + let prevPointer = isPointerGroup.value ? prevGroupLastEnabledOption.value : currentGroupPrevEnabledOption.value + + if (prevPointer === undefined) { + prevPointer = isPointerGroup.value ? prevGroup.value : currentGroup.value + + if (prevPointer.__CREATE__) { + prevPointer = prevGroupLastEnabledOption.value + + if (prevPointer === undefined) { + prevPointer = prevGroup.value + } + } + } + + setPointer(prevPointer || /* istanbul ignore next */ null) + } else { + let prevIndex = options.value.map(o => o[valueProp.value]).indexOf(pointer.value[valueProp.value]) - 1 + + if (prevIndex < 0) { + prevIndex = options.value.length - 1 + } + + setPointer(options.value[prevIndex] || null) + } + + nextTick(() => { + adjustWrapperScrollToPointer() + }) + } + + const getParentGroup = (option) => { + return groups.value.find((group) => { + return group.__VISIBLE__.map(o => o[valueProp.value]).indexOf(option[valueProp.value]) !== -1 + }) + } + + // no export + /* istanbul ignore next */ + const adjustWrapperScrollToPointer = () => { + let pointedOption = multiselect.value.querySelector(`[data-pointed]`) + + if (!pointedOption) { + return + } + + let wrapper = pointedOption.parentElement.parentElement + + if (groupped.value) { + wrapper = isPointerGroup.value + ? pointedOption.parentElement.parentElement.parentElement + : pointedOption.parentElement.parentElement.parentElement.parentElement + } + + if (pointedOption.offsetTop + pointedOption.offsetHeight > wrapper.clientHeight + wrapper.scrollTop) { + wrapper.scrollTop = pointedOption.offsetTop + pointedOption.offsetHeight - wrapper.clientHeight + } + + if (pointedOption.offsetTop < wrapper.scrollTop) { + wrapper.scrollTop = pointedOption.offsetTop + } + } + + // ============== WATCHERS ============== + + watch(search, (val) => { + if (searchable.value) { + if (val.length && showOptions.value) { + setPointerFirst() + } else { + clearPointer() + } + } + }) + + watch(isOpen, (val) => { + if (val) { + let firstSelected = multiselect.value.querySelectorAll(`[data-selected]`)[0] + + if (!firstSelected) { + return + } + + let wrapper = firstSelected.parentElement.parentElement + + nextTick(() => { + /* istanbul ignore next */ + if (wrapper.scrollTop > 0) { + return + } + + wrapper.scrollTop = firstSelected.offsetTop + }) + } + }) + + return { + pointer, + canPointGroups, + isPointed, + setPointerFirst, + selectPointer, + forwardPointer, + backwardPointer, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useScroll.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useScroll.js new file mode 100644 index 0000000..e0a4815 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useScroll.js @@ -0,0 +1,99 @@ +import { toRefs, watch, nextTick, onMounted, ref, computed } from 'vue' + +export default function useScroll (props, context, dep) +{ + const { + limit, infinite, + } = toRefs(props) + + // ============ DEPENDENCIES ============ + + const isOpen = dep.isOpen + const offset = dep.offset + const search = dep.search + const pfo = dep.pfo + const eo = dep.eo + + // ================ DATA ================ + + // no export + const observer = ref(null) + + const infiniteLoader = ref(null) + + // ============== COMPUTED ============== + + const hasMore = computed(() => { + return offset.value < pfo.value.length + }) + + // =============== METHODS ============== + + // no export + /* istanbul ignore next */ + const handleIntersectionObserver = (entries) => { + const { isIntersecting, target } = entries[0] + + if (isIntersecting) { + const parent = target.offsetParent + const scrollTop = parent.scrollTop + + offset.value += limit.value == -1 ? 10 : limit.value + + nextTick(() => { + parent.scrollTop = scrollTop + }) + } + } + + const observe = () => { + /* istanbul ignore else */ + if (isOpen.value && offset.value < pfo.value.length) { + observer.value.observe(infiniteLoader.value) + } else if (!isOpen.value && observer.value) { + observer.value.disconnect() + } + } + + // ============== WATCHERS ============== + + watch(isOpen, () => { + if (!infinite.value) { + return + } + + observe() + }) + + watch(search, () => { + if (!infinite.value) { + return + } + + offset.value = limit.value + + observe() + }, { flush: 'post' }) + + watch(eo, () => { + if (!infinite.value) { + return + } + + observe() + }, { immediate: false, flush: 'post' }) + + // ================ HOOKS =============== + + onMounted(() => { + /* istanbul ignore else */ + if (window && window.IntersectionObserver) { + observer.value = new IntersectionObserver(handleIntersectionObserver) + } + }) + + return { + hasMore, + infiniteLoader, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useSearch.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useSearch.js new file mode 100644 index 0000000..9887d29 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useSearch.js @@ -0,0 +1,81 @@ +import { ref, getCurrentInstance, watch, toRefs } from 'vue' + +export default function useSearch (props, context, dep) +{ + const { regex } = toRefs(props) + + const $this = getCurrentInstance().proxy + + // ============ DEPENDENCIES ============ + + const isOpen = dep.isOpen + const open = dep.open + + // ================ DATA ================ + + const search = ref(null) + + const input = ref(null) + + // =============== METHODS ============== + + const clearSearch = () => { + search.value = '' + } + + const handleSearchInput = (e) => { + search.value = e.target.value + } + + const handleKeypress = (e) => { + if (regex && regex.value) { + let regexp = regex.value + + if (typeof regexp === 'string') { + regexp = new RegExp(regexp) + } + + if (!e.key.match(regexp)) { + e.preventDefault() + } + } + } + + const handlePaste = (e) => { + if (regex && regex.value) { + let clipboardData = e.clipboardData || /* istanbul ignore next */ window.clipboardData + let pastedData = clipboardData.getData('Text') + + let regexp = regex.value + + if (typeof regexp === 'string') { + regexp = new RegExp(regexp) + } + + if (!pastedData.split('').every(c => !!c.match(regexp))) { + e.preventDefault() + } + } + + context.emit('paste', e, $this) + } + + // ============== WATCHERS ============== + + watch(search, (val) => { + if (!isOpen.value && val) { + open() + } + + context.emit('search-change', val, $this) + }) + + return { + search, + input, + clearSearch, + handleSearchInput, + handleKeypress, + handlePaste, + } +} diff --git a/Tithe-Vue/src/components/MultiSelectBox/composables/useValue.js b/Tithe-Vue/src/components/MultiSelectBox/composables/useValue.js new file mode 100644 index 0000000..779ca54 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/composables/useValue.js @@ -0,0 +1,34 @@ +import { computed, toRefs, ref } from 'vue' + +export default function useValue (props, context) +{ + const { value, modelValue, mode, valueProp } = toRefs(props) + + // ================ DATA ================ + + // internalValue + const iv = ref(mode.value !== 'single' ? [] : {}) + + // ============== COMPUTED ============== + + /* istanbul ignore next */ + // externalValue + const ev = modelValue && modelValue.value !== undefined ? modelValue : value + + const plainValue = computed(() => { + return mode.value === 'single' ? iv.value[valueProp.value] : iv.value.map(v=>v[valueProp.value]) + }) + + const textValue = computed(() => { + return mode.value !== 'single' ? iv.value.map(v=>v[valueProp.value]).join(',') : iv.value[valueProp.value] + }) + + return { + iv, + internalValue: iv, + ev, + externalValue: ev, + textValue, + plainValue, + } +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/default.css b/Tithe-Vue/src/components/MultiSelectBox/default.css new file mode 100644 index 0000000..7fdf03c --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/default.css @@ -0,0 +1 @@ +.multiselect{align-items:center;background:var(--ms-bg,#fff);border:var(--ms-border-width,1px) solid var(--ms-border-color,#d1d5db);border-radius:var(--ms-radius,4px);box-sizing:border-box;cursor:pointer;display:flex;font-size:var(--ms-font-size,1rem);justify-content:flex-end;margin:0 auto;min-height:calc(var(--ms-border-width, 1px)*2 + var(--ms-font-size, 1rem)*var(--ms-line-height, 1.375) + var(--ms-py, .5rem)*2);outline:none;position:relative;width:100%}.multiselect.is-open{border-radius:var(--ms-radius,4px) var(--ms-radius,4px) 0 0}.multiselect.is-open-top{border-radius:0 0 var(--ms-radius,4px) var(--ms-radius,4px)}.multiselect.is-disabled{background:var(--ms-bg-disabled,#f3f4f6);cursor:default}.multiselect.is-active{border:var(--ms-border-width-active,var(--ms-border-width,1px)) solid var(--ms-border-color-active,var(--ms-border-color,#d1d5db));box-shadow:0 0 0 var(--ms-ring-width,3px) var(--ms-ring-color,rgba(16,185,129,.188))}.multiselect-wrapper{align-items:center;box-sizing:border-box;cursor:pointer;display:flex;justify-content:flex-end;margin:0 auto;min-height:calc(var(--ms-border-width, 1px)*2 + var(--ms-font-size, 1rem)*var(--ms-line-height, 1.375) + var(--ms-py, .5rem)*2);outline:none;position:relative;width:100%}.multiselect-multiple-label,.multiselect-placeholder,.multiselect-single-label{align-items:center;background:transparent;box-sizing:border-box;display:flex;height:100%;left:0;line-height:var(--ms-line-height,1.375);max-width:100%;padding-left:var(--ms-px,.875rem);padding-right:calc(1.25rem + var(--ms-px, .875rem)*3);pointer-events:none;position:absolute;top:0}.multiselect-placeholder{color:var(--ms-placeholder-color,#9ca3af)}.multiselect-single-label-text{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.multiselect-search{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:var(--ms-bg,#fff);border:0;border-radius:var(--ms-radius,4px);bottom:0;box-sizing:border-box;font-family:inherit;font-size:inherit;height:100%;left:0;outline:none;padding-left:var(--ms-px,.875rem);position:absolute;right:0;top:0;width:100%}.multiselect-search::-webkit-search-cancel-button,.multiselect-search::-webkit-search-decoration,.multiselect-search::-webkit-search-results-button,.multiselect-search::-webkit-search-results-decoration{-webkit-appearance:none}.multiselect-tags{align-items:center;display:flex;flex-grow:1;flex-shrink:1;flex-wrap:wrap;margin:var(--ms-tag-my,.25rem) 0 0;padding-left:var(--ms-py,.5rem)}.multiselect-tag{align-items:center;background:var(--ms-tag-bg,#10b981);border-radius:var(--ms-tag-radius,4px);color:var(--ms-tag-color,#fff);display:flex;font-size:var(--ms-tag-font-size,.875rem);font-weight:var(--ms-tag-font-weight,600);line-height:var(--ms-tag-line-height,1.25rem);margin-bottom:var(--ms-tag-my,.25rem);margin-right:var(--ms-tag-mx,.25rem);padding:var(--ms-tag-py,.125rem) 0 var(--ms-tag-py,.125rem) var(--ms-tag-px,.5rem);white-space:nowrap}.multiselect-tag.is-disabled{background:var(--ms-tag-bg-disabled,#9ca3af);color:var(--ms-tag-color-disabled,#fff);padding-right:var(--ms-tag-px,.5rem)}.multiselect-tag-remove{align-items:center;border-radius:var(--ms-tag-remove-radius,4px);display:flex;justify-content:center;margin:var(--ms-tag-remove-my,0) var(--ms-tag-remove-mx,.125rem);padding:var(--ms-tag-remove-py,.25rem) var(--ms-tag-remove-px,.25rem)}.multiselect-tag-remove:hover{background:rgba(0,0,0,.063)}.multiselect-tag-remove-icon{background-color:currentColor;display:inline-block;height:.75rem;-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 320 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m207.6 256 107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 320 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m207.6 256 107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z'/%3E%3C/svg%3E");-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;opacity:.8;width:.75rem}.multiselect-tags-search-wrapper{display:inline-block;flex-grow:1;flex-shrink:1;height:100%;margin:0 var(--ms-tag-mx,4px) var(--ms-tag-my,4px);position:relative}.multiselect-tags-search-copy{display:inline-block;height:1px;visibility:hidden;white-space:pre-wrap;width:100%}.multiselect-tags-search{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:0;bottom:0;box-sizing:border-box;font-family:inherit;font-size:inherit;left:0;outline:none;padding:0;position:absolute;right:0;top:0;width:100%}.multiselect-tags-search::-webkit-search-cancel-button,.multiselect-tags-search::-webkit-search-decoration,.multiselect-tags-search::-webkit-search-results-button,.multiselect-tags-search::-webkit-search-results-decoration{-webkit-appearance:none}.multiselect-inifite{align-items:center;display:flex;justify-content:center;min-height:calc(var(--ms-border-width, 1px)*2 + var(--ms-font-size, 1rem)*var(--ms-line-height, 1.375) + var(--ms-py, .5rem)*2);width:100%}.multiselect-inifite-spinner,.multiselect-spinner{animation:multiselect-spin 1s linear infinite;background-color:var(--ms-spinner-color,#10b981);flex-grow:0;flex-shrink:0;height:1rem;-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 512 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m456.433 371.72-27.79-16.045c-7.192-4.152-10.052-13.136-6.487-20.636 25.82-54.328 23.566-118.602-6.768-171.03-30.265-52.529-84.802-86.621-144.76-91.424C262.35 71.922 256 64.953 256 56.649V24.56c0-9.31 7.916-16.609 17.204-15.96 81.795 5.717 156.412 51.902 197.611 123.408 41.301 71.385 43.99 159.096 8.042 232.792-4.082 8.369-14.361 11.575-22.424 6.92z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 512 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m456.433 371.72-27.79-16.045c-7.192-4.152-10.052-13.136-6.487-20.636 25.82-54.328 23.566-118.602-6.768-171.03-30.265-52.529-84.802-86.621-144.76-91.424C262.35 71.922 256 64.953 256 56.649V24.56c0-9.31 7.916-16.609 17.204-15.96 81.795 5.717 156.412 51.902 197.611 123.408 41.301 71.385 43.99 159.096 8.042 232.792-4.082 8.369-14.361 11.575-22.424 6.92z'/%3E%3C/svg%3E");-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:1rem;z-index:10}.multiselect-spinner{margin:0 var(--ms-px,.875rem) 0 0}.multiselect-clear{display:flex;flex-grow:0;flex-shrink:0;opacity:1;padding:0 var(--ms-px,.875rem) 0 0;position:relative;transition:.3s;z-index:10}.multiselect-clear:hover .multiselect-clear-icon{background-color:var(--ms-clear-color-hover,#000)}.multiselect-clear-icon{background-color:var(--ms-clear-color,#999);display:inline-block;-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 320 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m207.6 256 107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 320 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m207.6 256 107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z'/%3E%3C/svg%3E");transition:.3s}.multiselect-caret,.multiselect-clear-icon{height:1.125rem;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:.625rem}.multiselect-caret{background-color:var(--ms-caret-color,#999);flex-grow:0;flex-shrink:0;margin:0 var(--ms-px,.875rem) 0 0;-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 320 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 320 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z'/%3E%3C/svg%3E");pointer-events:none;position:relative;transform:rotate(0deg);transition:transform .3s;z-index:10}.multiselect-caret.is-open{pointer-events:auto;transform:rotate(180deg)}.multiselect-dropdown{-webkit-overflow-scrolling:touch;background:var(--ms-dropdown-bg,#fff);border:var(--ms-dropdown-border-width,1px) solid var(--ms-dropdown-border-color,#d1d5db);border-radius:0 0 var(--ms-dropdown-radius,4px) var(--ms-dropdown-radius,4px);bottom:0;display:flex;flex-direction:column;left:calc(var(--ms-border-width, 1px)*-1);margin-top:calc(var(--ms-border-width, 1px)*-1);max-height:var(--ms-max-height,10rem);outline:none;overflow-y:scroll;position:absolute;right:calc(var(--ms-border-width, 1px)*-1);transform:translateY(100%);z-index:100}.multiselect-dropdown.is-top{border-radius:var(--ms-dropdown-radius,4px) var(--ms-dropdown-radius,4px) 0 0;bottom:auto;top:var(--ms-border-width,1px);transform:translateY(-100%)}.multiselect-dropdown.is-hidden{display:none}.multiselect-options{display:flex;flex-direction:column;list-style:none;margin:0;padding:0}.multiselect-group{margin:0;padding:0}.multiselect-group-label{align-items:center;background:var(--ms-group-label-bg,#e5e7eb);box-sizing:border-box;color:var(--ms-group-label-color,#374151);cursor:default;display:flex;font-size:.875rem;font-weight:600;justify-content:flex-start;line-height:var(--ms-group-label-line-height,1.375);padding:var(--ms-group-label-py,.3rem) var(--ms-group-label-px,.75rem);text-align:left;text-decoration:none}.multiselect-group-label.is-pointable{cursor:pointer}.multiselect-group-label.is-pointed{background:var(--ms-group-label-bg-pointed,#d1d5db);color:var(--ms-group-label-color-pointed,#374151)}.multiselect-group-label.is-selected{background:var(--ms-group-label-bg-selected,#059669);color:var(--ms-group-label-color-selected,#fff)}.multiselect-group-label.is-disabled{background:var(--ms-group-label-bg-disabled,#f3f4f6);color:var(--ms-group-label-color-disabled,#d1d5db);cursor:not-allowed}.multiselect-group-label.is-selected.is-pointed{background:var(--ms-group-label-bg-selected-pointed,#0c9e70);color:var(--ms-group-label-color-selected-pointed,#fff)}.multiselect-group-label.is-selected.is-disabled{background:var(--ms-group-label-bg-selected-disabled,#75cfb1);color:var(--ms-group-label-color-selected-disabled,#d1fae5)}.multiselect-group-options{margin:0;padding:0}.multiselect-option{align-items:center;box-sizing:border-box;cursor:pointer;display:flex;font-size:var(--ms-option-font-size,1rem);justify-content:flex-start;line-height:var(--ms-option-line-height,1.375);padding:var(--ms-option-py,.5rem) var(--ms-option-px,.75rem);text-align:left;text-decoration:none}.multiselect-option.is-pointed{background:var(--ms-option-bg-pointed,#f3f4f6);color:var(--ms-option-color-pointed,#1f2937)}.multiselect-option.is-selected{background:var(--ms-option-bg-selected,#10b981);color:var(--ms-option-color-selected,#fff)}.multiselect-option.is-disabled{background:var(--ms-option-bg-disabled,#fff);color:var(--ms-option-color-disabled,#d1d5db);cursor:not-allowed}.multiselect-option.is-selected.is-pointed{background:var(--ms-option-bg-selected-pointed,#26c08e);color:var(--ms-option-color-selected-pointed,#fff)}.multiselect-option.is-selected.is-disabled{background:var(--ms-option-bg-selected-disabled,#87dcc0);color:var(--ms-option-color-selected-disabled,#d1fae5)}.multiselect-no-options,.multiselect-no-results{color:var(--ms-empty-color,#4b5563);padding:var(--ms-option-py,.5rem) var(--ms-option-px,.75rem)}.multiselect-fake-input{background:transparent;border:0;bottom:-1px;font-size:0;height:1px;left:0;outline:none;padding:0;position:absolute;right:0;width:100%}.multiselect-fake-input:active,.multiselect-fake-input:focus{outline:none}.multiselect-assistive-text{clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;position:absolute;width:1px}.multiselect-spacer{display:none}[dir=rtl] .multiselect-multiple-label,[dir=rtl] .multiselect-placeholder,[dir=rtl] .multiselect-single-label{left:auto;padding-left:calc(1.25rem + var(--ms-px, .875rem)*3);padding-right:var(--ms-px,.875rem);right:0}[dir=rtl] .multiselect-search{padding-left:0;padding-right:var(--ms-px,.875rem)}[dir=rtl] .multiselect-tags{padding-left:0;padding-right:var(--ms-py,.5rem)}[dir=rtl] .multiselect-tag{margin-left:var(--ms-tag-mx,.25rem);margin-right:0;padding:var(--ms-tag-py,.125rem) var(--ms-tag-px,.5rem) var(--ms-tag-py,.125rem) 0}[dir=rtl] .multiselect-tag.is-disabled{padding-left:var(--ms-tag-px,.5rem)}[dir=rtl] .multiselect-caret,[dir=rtl] .multiselect-spinner{margin:0 0 0 var(--ms-px,.875rem)}[dir=rtl] .multiselect-clear{padding:0 0 0 var(--ms-px,.875rem)}@keyframes multiselect-spin{0%{transform:rotate(0)}to{transform:rotate(1turn)}} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/utils/arraysEqual.js b/Tithe-Vue/src/components/MultiSelectBox/utils/arraysEqual.js new file mode 100644 index 0000000..9db76e5 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/utils/arraysEqual.js @@ -0,0 +1,7 @@ +export default function arraysEqual (array1, array2) { + const array2Sorted = array2.slice().sort() + + return array1.length === array2.length && array1.slice().sort().every(function(value, index) { + return value === array2Sorted[index]; + }) +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/utils/isNullish.js b/Tithe-Vue/src/components/MultiSelectBox/utils/isNullish.js new file mode 100644 index 0000000..5bb4900 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/utils/isNullish.js @@ -0,0 +1,3 @@ +export default function isNullish (val) { + return [null, undefined].indexOf(val) !== -1 +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/utils/isObject.js b/Tithe-Vue/src/components/MultiSelectBox/utils/isObject.js new file mode 100644 index 0000000..b594b15 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/utils/isObject.js @@ -0,0 +1,3 @@ +export default function isObject (variable) { + return Object.prototype.toString.call(variable) === '[object Object]' +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/utils/normalize.js b/Tithe-Vue/src/components/MultiSelectBox/utils/normalize.js new file mode 100644 index 0000000..6024aeb --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/utils/normalize.js @@ -0,0 +1,11 @@ +export default function normalize (str, strict = true) { + return strict + ? String(str).toLowerCase().trim() + : String(str).toLowerCase() + .normalize('NFD') + .trim() + .replace(new RegExp(/æ/g), 'ae') + .replace(new RegExp(/œ/g), 'oe') + .replace(new RegExp(/ø/g), 'o') + .replace(/\p{Diacritic}/gu, '') +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/MultiSelectBox/utils/resolveDeps.js b/Tithe-Vue/src/components/MultiSelectBox/utils/resolveDeps.js new file mode 100644 index 0000000..c0d8557 --- /dev/null +++ b/Tithe-Vue/src/components/MultiSelectBox/utils/resolveDeps.js @@ -0,0 +1,14 @@ +export default function (props, context, features, deps = {}) { + features.forEach((composable) => { + /* istanbul ignore else */ + if (composable) { + deps = { + ...deps, + ...composable(props, context, deps) + } + } + + }) + + return deps +} \ No newline at end of file diff --git a/Tithe-Vue/src/components/SearchBoxes/ForaneSingleSelectBox.vue b/Tithe-Vue/src/components/SearchBoxes/ForaneSingleSelectBox.vue new file mode 100644 index 0000000..841c70c --- /dev/null +++ b/Tithe-Vue/src/components/SearchBoxes/ForaneSingleSelectBox.vue @@ -0,0 +1,77 @@ + + + diff --git a/Tithe-Vue/src/components/SearchBoxes/KoottaymaByParishSingleSelectBox.vue b/Tithe-Vue/src/components/SearchBoxes/KoottaymaByParishSingleSelectBox.vue new file mode 100644 index 0000000..686878a --- /dev/null +++ b/Tithe-Vue/src/components/SearchBoxes/KoottaymaByParishSingleSelectBox.vue @@ -0,0 +1,72 @@ + + + diff --git a/Tithe-Vue/src/components/SearchBoxes/ParishByForaneSingleSelectBox.vue b/Tithe-Vue/src/components/SearchBoxes/ParishByForaneSingleSelectBox.vue new file mode 100644 index 0000000..8c23ac6 --- /dev/null +++ b/Tithe-Vue/src/components/SearchBoxes/ParishByForaneSingleSelectBox.vue @@ -0,0 +1,75 @@ + + + diff --git a/Tithe-Vue/src/components/SearchBoxes/SingleSelectBox.vue b/Tithe-Vue/src/components/SearchBoxes/SingleSelectBox.vue new file mode 100644 index 0000000..4def614 --- /dev/null +++ b/Tithe-Vue/src/components/SearchBoxes/SingleSelectBox.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/Tithe-Vue/src/externalized-data/graphqlQueries.js b/Tithe-Vue/src/externalized-data/graphqlQueries.js index 5ae1aeb..bd43e51 100644 --- a/Tithe-Vue/src/externalized-data/graphqlQueries.js +++ b/Tithe-Vue/src/externalized-data/graphqlQueries.js @@ -87,6 +87,11 @@ export const foraneAllForaneListQuery = `query foranePageActiveForane{ getAllForanes{ foraneId foraneName + address{ + street{ + streetName + } + } } }`; @@ -162,6 +167,11 @@ export const parishAllParishListQuery = `query parishPageActiveParish ($foraneId getAllParishesByForane (foraneId: $foraneId){ parishId parishName + address{ + street{ + streetName + } + } } }`; diff --git a/Tithe-Vue/src/main.js b/Tithe-Vue/src/main.js index f8cbdc2..e75c9b4 100644 --- a/Tithe-Vue/src/main.js +++ b/Tithe-Vue/src/main.js @@ -14,6 +14,8 @@ import { useStyleStore } from "@/stores/style.js"; // import { darkModeKey, styleKey } from "@/config.js"; import { styleKey } from "@/config.js"; +import Multiselect from "@/components/MultiSelectBox/Multiselect.vue"; + import "./css/main.css"; /* Init Pinia */ @@ -44,7 +46,7 @@ const app = createApp({ /* Create Vue app */ // createApp(App).use(router).use(pinia).mount("#app"); -app.use(router).use(pinia).mount("#app"); +app.use(router).use(pinia).component("Multiselect", Multiselect).mount("#app"); /* Init Pinia stores */ const mainStore = useMainStore(pinia); diff --git a/Tithe-Vue/src/views/FamilyView.vue b/Tithe-Vue/src/views/FamilyView.vue index 769be9d..5253c16 100644 --- a/Tithe-Vue/src/views/FamilyView.vue +++ b/Tithe-Vue/src/views/FamilyView.vue @@ -17,6 +17,8 @@ import { } from "@mdi/js"; import SearchBox from "@/components/SearchBox.vue"; +import ForaneSingleSelectBox from "@/components/SearchBoxes/ForaneSingleSelectBox.vue"; +import ParishByForaneSingleSelectBox from "@/components/SearchBoxes/ParishByForaneSingleSelectBox.vue"; import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue"; import SectionMain from "@/components/SectionMain.vue"; import FormField from "@/components/FormField.vue"; diff --git a/Tithe-Vue/src/views/ForaneView.vue b/Tithe-Vue/src/views/ForaneView.vue index fc85861..a359f79 100644 --- a/Tithe-Vue/src/views/ForaneView.vue +++ b/Tithe-Vue/src/views/ForaneView.vue @@ -19,7 +19,7 @@ import { mdiTableLarge, } from "@mdi/js"; -import SearchBox from "@/components/SearchBox.vue"; +import ForaneSingleSelectBox from "@/components/SearchBoxes/ForaneSingleSelectBox.vue"; import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue"; import SectionMain from "@/components/SectionMain.vue"; import FormField from "@/components/FormField.vue"; @@ -91,25 +91,8 @@ const tableTabTitle = foranePageTableTabTitle; const forane = ref(); -const ACTIVE_FORANE_LIST_QUERY = gql` - ${foraneAllForaneListQuery} -`; - -const { - result: activeForaneList, - load: activeForaneListLoad, - refetch: activeForaneListRefetch, -} = useLazyQuery(ACTIVE_FORANE_LIST_QUERY); -activeForaneListLoad(); -const loadForanes = (query, setOptions) => { - setOptions( - activeForaneList.value?.getAllForanes?.map((entity) => { - return { - id: entity.foraneId, - label: entity.foraneName, - }; - }) ?? [] - ); +const changeInForane = (entity) => { + forane.value = entity; }; // Entity Count in Forane Page @@ -346,7 +329,7 @@ const getActivePersonRows = computed(() => { Forane
- + + +
@@ -375,6 +360,7 @@ const getActivePersonRows = computed(() => { v-model="createForaneForm.foraneName" type="text" :icon="mdiChurch" + :borderless="true" placeholder="St. Peter's Church" /> @@ -387,6 +373,7 @@ const getActivePersonRows = computed(() => { @@ -562,6 +549,8 @@ const getActivePersonRows = computed(() => { + + diff --git a/Tithe-Vue/src/views/KoottaymaView.vue b/Tithe-Vue/src/views/KoottaymaView.vue index 1f82c7f..8bb89aa 100644 --- a/Tithe-Vue/src/views/KoottaymaView.vue +++ b/Tithe-Vue/src/views/KoottaymaView.vue @@ -17,6 +17,9 @@ import { mdiTableLarge, } from "@mdi/js"; +import ForaneSingleSelectBox from "@/components/SearchBoxes/ForaneSingleSelectBox.vue"; +import ParishByForaneSingleSelectBox from "@/components/SearchBoxes/ParishByForaneSingleSelectBox.vue"; +import KoottaymaByParishSingleSelectBox from "@/components/SearchBoxes/KoottaymaByParishSingleSelectBox.vue"; import SearchBox from "@/components/SearchBox.vue"; import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue"; import SectionMain from "@/components/SectionMain.vue"; @@ -80,69 +83,16 @@ const forane = ref(); const parish = ref(); const koottayma = ref(); -const ACTIVE_FORANE_LIST_QUERY = gql` - ${koottaymaAllForaneListQuery} -`; - -const { - result: activeForaneList, - load: activeForaneListLoad, - refetch: activeForaneListRefetch, -} = useLazyQuery(ACTIVE_FORANE_LIST_QUERY); -activeForaneListLoad(); -const loadForanes = (query, setOptions) => { - setOptions( - activeForaneList.value?.getAllForanes?.map((entity) => { - return { - id: entity.foraneId, - label: entity.foraneName, - }; - }) ?? [] - ); +const changeInForane = (entity) => { + forane.value = entity; }; -const ACTIVE_PARISH_BY_FORANE_LIST_QUERY = gql` - ${koottaymaAllParishListQuery} -`; - -const { - result: activeParishList, - load: activeParishListLoad, - refetch: activeParishListRefetch, -} = useLazyQuery(ACTIVE_PARISH_BY_FORANE_LIST_QUERY, () => ({ - foraneId: forane.value.id, -})); -const loadParishesByForane = (query, setOptions) => { - setOptions( - activeParishList.value?.getAllParishesByForane?.map((entity) => { - return { - id: entity.parishId, - label: entity.parishName, - }; - }) ?? [] - ); +const changeInParish = (entity) => { + parish.value = entity; }; -const ACTIVE_KOOTTAYMA_BY_PARISH_LIST_QUERY = gql` - ${koottaymaAllKoottaymaListQuery} -`; - -const { - result: activeKoottaymaList, - load: activeKoottaymaListLoad, - refetch: activeKoottaymaListRefetch, -} = useLazyQuery(ACTIVE_KOOTTAYMA_BY_PARISH_LIST_QUERY, () => ({ - parishId: parish.value.id, -})); -const loadKoottaymasByParish = (query, setOptions) => { - setOptions( - activeKoottaymaList.value?.getAllKoottaymasByParish?.map((entity) => { - return { - id: entity.koottaymaId, - label: entity.koottaymaName, - }; - }) ?? [] - ); +const changeInKoottayma = (entity) => { + koottayma.value = entity; }; // Entity Count in Koottayma Page @@ -167,14 +117,6 @@ const activePersonCount = computed( () => activeEntityByKoottaymaCount.value?.getPersonCountByKoottayma ?? 0 ); -watch(forane, () => { - activeParishListLoad(); -}); - -watch(parish, () => { - activeKoottaymaListLoad(); -}); - watch(koottayma, () => { activeEntityByKoottaymaCountEnabled.value = true; }); @@ -190,27 +132,13 @@ const formForane = ref(); // Form Parish Search Box const formParish = ref(); -const { - result: activeFormParishList, - load: activeFormParishListLoad, - refetch: activeFormParishListRefetch, -} = useLazyQuery(ACTIVE_PARISH_BY_FORANE_LIST_QUERY, () => ({ - foraneId: formForane.value.id, -})); -const loadFormParishesByForane = (query, setOptions) => { - setOptions( - activeFormParishList.value?.getAllParishesByForane?.map((entity) => { - return { - id: entity.parishId, - label: entity.parishName, - }; - }) ?? [] - ); +const changeInFormForane = (entity) => { + formForane.value = entity; }; -watch(formForane, () => { - activeFormParishListLoad(); -}); +const changeInFormParish = (entity) => { + formParish.value = entity; +}; watch(formParish, (value) => { createKoottaymaForm.parishId = value.id; @@ -372,32 +300,21 @@ const getActivePersonRows = computed(() => {
- - - - - - - - + + + + + + + +
@@ -422,24 +339,19 @@ const getActivePersonRows = computed(() => { placeholder="St. George Koottayma" /> - - - - - - + + + + { .baseButtonStyle { width: 100%; } + +.multipleSelectAddressBox :deep(.multiselect-theme) { + --ms-bg: #1e293b; + --ms-dropdown-bg: #1e293b; + --ms-dropdown-border-color: #1e293b; + + --ms-py: 0.757rem; +} diff --git a/Tithe-Vue/src/views/ParishView.vue b/Tithe-Vue/src/views/ParishView.vue index 1a37455..fe5950c 100644 --- a/Tithe-Vue/src/views/ParishView.vue +++ b/Tithe-Vue/src/views/ParishView.vue @@ -18,7 +18,8 @@ import { mdiTableLarge, } from "@mdi/js"; -import SearchBox from "@/components/SearchBox.vue"; +import ForaneSingleSelectBox from "@/components/SearchBoxes/ForaneSingleSelectBox.vue"; +import ParishByForaneSingleSelectBox from "@/components/SearchBoxes/ParishByForaneSingleSelectBox.vue"; import LayoutAuthenticated from "@/layouts/LayoutAuthenticated.vue"; import SectionMain from "@/components/SectionMain.vue"; import FormField from "@/components/FormField.vue"; @@ -82,47 +83,12 @@ const tableTabTitle = parishPageTableTabTitle; const forane = ref(); const parish = ref(); -const ACTIVE_FORANE_LIST_QUERY = gql` - ${parishAllForaneListQuery} -`; - -const { - result: activeForaneList, - load: activeForaneListLoad, - refetch: activeForaneListRefetch, -} = useLazyQuery(ACTIVE_FORANE_LIST_QUERY); -activeForaneListLoad(); -const loadForanes = (query, setOptions) => { - setOptions( - activeForaneList.value?.getAllForanes?.map((entity) => { - return { - id: entity.foraneId, - label: entity.foraneName, - }; - }) ?? [] - ); +const changeInForane = (entity) => { + forane.value = entity; }; -const ACTIVE_PARISH_BY_FORANE_LIST_QUERY = gql` - ${parishAllParishListQuery} -`; - -const { - result: activeParishList, - load: activeParishListLoad, - refetch: activeParishListRefetch, -} = useLazyQuery(ACTIVE_PARISH_BY_FORANE_LIST_QUERY, () => ({ - foraneId: forane.value.id, -})); -const loadParishesByForane = (query, setOptions) => { - setOptions( - activeParishList.value?.getAllParishesByForane?.map((entity) => { - return { - id: entity.parishId, - label: entity.parishName, - }; - }) ?? [] - ); +const changeInParish = (entity) => { + parish.value = entity; }; // Entity Count in Parish Page @@ -150,9 +116,9 @@ const activePersonCount = computed( () => activeEntityByParishCount.value?.getPersonCountByParish ?? 0 ); -watch(forane, () => { - activeParishListLoad(); -}); +// watch(forane, () => { +// activeParishListLoad(); +// }); watch(parish, () => { activeEntityByParishCountEnabled.value = true; @@ -175,6 +141,10 @@ const createParishForm = reactive({ // Form Forane Search Box const formForane = ref(); +const changeInFormForane = (entity) => { + formForane.value = entity; +}; + watch(formForane, (value) => { createParishForm.foraneId = value.id; }); @@ -302,6 +272,10 @@ const moveParishForm = reactive({ foraneId: "", }); +const changeInNewForaneMove = (entity) => { + newForane.value = entity; +}; + watch(newForane, (value) => { if (value.id === forane.value.id) { newForane.value = ""; @@ -373,24 +347,13 @@ const getActivePersonRows = computed(() => {
- - - - - + + - +
@@ -411,6 +374,7 @@ const getActivePersonRows = computed(() => { v-model="createParishForm.parishName" type="text" :icon="mdiChurch" + :borderless="true" placeholder="St. Peter's Church" /> @@ -420,19 +384,16 @@ const getActivePersonRows = computed(() => { v-model="createParishForm.address.buildingName" /> --> - - - + @@ -459,15 +420,11 @@ const getActivePersonRows = computed(() => { /> - - - + { .baseButtonStyle { width: 100%; } + +.multipleSelectAddressBox :deep(.multiselect-theme) { + --ms-bg: #1e293b; + --ms-dropdown-bg: #1e293b; + --ms-dropdown-border-color: #1e293b; + + --ms-py: 0.757rem; +}