From 7fe7a37daf76962c94d4bd63d810864faf3e11c3 Mon Sep 17 00:00:00 2001 From: Ernest Date: Wed, 24 Mar 2021 22:10:46 +0800 Subject: [PATCH] feat: group Ref #103 --- cypress/fixtures/example.json | 5 + .../disabled/group/disabled-by-group.html | 51 ++++++ .../disabled/group/disabled-by-values.html | 51 ++++++ .../props/disabled/group/index.spec.js | 19 +++ .../props/options/group/index.html | 51 ++++++ .../props/options/group/index.spec.js | 37 +++++ docs/group.js | 150 ++++++++++++++++++ docs/index.html | 12 ++ src/components/dropdown.vue | 7 +- src/index.vue | 106 +++++++++++-- src/normalize.js | 2 + 11 files changed, 475 insertions(+), 16 deletions(-) create mode 100644 cypress/fixtures/example.json create mode 100644 cypress/integration/props/disabled/group/disabled-by-group.html create mode 100644 cypress/integration/props/disabled/group/disabled-by-values.html create mode 100644 cypress/integration/props/disabled/group/index.spec.js create mode 100644 cypress/integration/props/options/group/index.html create mode 100644 cypress/integration/props/options/group/index.spec.js create mode 100644 docs/group.js diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 00000000..02e42543 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/cypress/integration/props/disabled/group/disabled-by-group.html b/cypress/integration/props/disabled/group/disabled-by-group.html new file mode 100644 index 00000000..714e27c8 --- /dev/null +++ b/cypress/integration/props/disabled/group/disabled-by-group.html @@ -0,0 +1,51 @@ + + + + + + + + + + +
+ + + + diff --git a/cypress/integration/props/disabled/group/disabled-by-values.html b/cypress/integration/props/disabled/group/disabled-by-values.html new file mode 100644 index 00000000..d432315c --- /dev/null +++ b/cypress/integration/props/disabled/group/disabled-by-values.html @@ -0,0 +1,51 @@ + + + + + + + + + + +
+ + + + diff --git a/cypress/integration/props/disabled/group/index.spec.js b/cypress/integration/props/disabled/group/index.spec.js new file mode 100644 index 00000000..734dbc3b --- /dev/null +++ b/cypress/integration/props/disabled/group/index.spec.js @@ -0,0 +1,19 @@ +/// +import path from 'path' + +context('disabled group', () => { + it('all of values should be disabled if group is disabled', () => { + cy.visit(path.join(__dirname, 'disabled-by-group.html')) + cy.get('.vue-select').click() + + cy.get('.vue-dropdown-item.disabled').should('have.length', 3) + cy.get('.vue-dropdown-item').last().should('not.have.class', 'disabled') + }) + + it("should be disabled if all of group's value are disabled", () => { + cy.visit(path.join(__dirname, 'disabled-by-values.html')) + cy.get('.vue-select').click() + + cy.get('.vue-dropdown-item.group.disabled').should('have.length', 1) + }) +}) diff --git a/cypress/integration/props/options/group/index.html b/cypress/integration/props/options/group/index.html new file mode 100644 index 00000000..d2531958 --- /dev/null +++ b/cypress/integration/props/options/group/index.html @@ -0,0 +1,51 @@ + + + + + + + + + + +
+ + + + diff --git a/cypress/integration/props/options/group/index.spec.js b/cypress/integration/props/options/group/index.spec.js new file mode 100644 index 00000000..7fa6c9dd --- /dev/null +++ b/cypress/integration/props/options/group/index.spec.js @@ -0,0 +1,37 @@ +/// +import path from 'path' + +context('group-options', () => { + it('should select values of group option when click an group option', () => { + cy.visit(path.join(__dirname, 'index.html')) + cy.get('.vue-select').click() + + cy.get('.vue-dropdown-item.group').trigger('click') + + cy.get('.vue-dropdown-item.selected').should('have.length', 4) + }) + + it('should deselect values of group option when all of its values are selected', () => { + cy.visit(path.join(__dirname, 'index.html')) + cy.get('.vue-select').click() + cy.get('.vue-dropdown-item.group').trigger('click') + + cy.get('.vue-dropdown-item.group').trigger('click') + + cy.get('.vue-dropdown-item.selected').should('have.length', 0) + }) + + it('group should be select if and only if all of its values are selected', () => { + cy.visit(path.join(__dirname, 'index.html')) + cy.get('.vue-select').click() + + cy.get('.vue-dropdown-item:not(.group)').first().trigger('click').should('have.class', 'selected') + cy.get('.vue-dropdown-item.group').should('not.have.class', 'selected') + + cy.get('.vue-dropdown-item:not(.group)').first().next().trigger('click').should('have.class', 'selected') + cy.get('.vue-dropdown-item.group').should('not.have.class', 'selected') + + cy.get('.vue-dropdown-item:not(.group)').first().next().next().trigger('click').should('have.class', 'selected') + cy.get('.vue-dropdown-item.group').should('have.class', 'selected') + }) +}) diff --git a/docs/group.js b/docs/group.js new file mode 100644 index 00000000..ff3c544d --- /dev/null +++ b/docs/group.js @@ -0,0 +1,150 @@ +{ + const jsCode = ` +import { ref, createApp } from 'vue' +import VueSelect from 'vue-next-select' + +// for composition API +export default createApp({ + name: 'app', + components: { + VueSelect + }, + setup() { + const model = ref(['express', 'koa']) + + const searchInput = ref('') + const hanldeSearchInput = event => { + searchInput.value = event.target.value + } + const visibleOptions = computed(() => { + if (searchInput.value === '') return options + return options.filter(option => option.group || option.label.indexOf(searchInput.value) >= 0) + }) + + return { + model, + options, + visibleOptions, + hanldeSearchInput, + } + }, +}) + +// for option API +export default createApp({ + name: 'app', + components: { + VueSelect + }, + data() { + return { + model: ['express', 'koa'], + options: [ + { label: 'All', value: ['express', 'koa', 'django', 'flask'], group: true, level: 0 }, + { label: 'NodeJS', value: ['express', 'koa'], group: true, level: 1 }, + { label: 'Express', value: 'express', level: 2 }, + { label: 'Koa', value: 'koa', level: 2 }, + { label: 'Python', value: ['django', 'flask'], group: true, level: 1 }, + { label: 'Django', value: 'django', level: 2 }, + { label: 'Flask', value: 'flask', level: 2 }, + ], + searchInput: '', + } + }, + computed: { + visibleOptions () { + if (this.searchInput === '') return this.options + return this.options.filter(option => option.group || option.label.indexOf(this.searchInput) >= 0) + }, + }, + methods: { + hanldeSearchInput (event) { + this.searchInput = event.target.value + }, + }, +}) +`.trim() + + const htmlCode = ` + + + +`.trim() + + const { ref, computed, createApp } = Vue + + const app = createApp({ + name: 'app', + setup() { + const model = ref(['express', 'koa']) + + const options = [ + { label: 'All', value: ['express', 'koa', 'django', 'flask'], group: true, level: 0 }, + { label: 'NodeJS', value: ['express', 'koa'], group: true, level: 1 }, + { label: 'Express', value: 'express', level: 2 }, + { label: 'Koa', value: 'koa', level: 2 }, + { label: 'Python', value: ['django', 'flask'], group: true, level: 1 }, + { label: 'Django', value: 'django', level: 2 }, + { label: 'Flask', value: 'flask', level: 2 }, + ] + + const searchInput = ref('') + const hanldeSearchInput = event => { + searchInput.value = event.target.value + } + const visibleOptions = computed(() => { + if (searchInput.value === '') return options + return options.filter(option => option.group || option.label.indexOf(searchInput.value) >= 0) + }) + + return { + model, + options, + visibleOptions, + hanldeSearchInput, + + jsCode, + htmlCode, + } + }, + template: ` + + + +
{{ model }}
+ +

Code sample:

+
{{ htmlCode }}
+
{{ jsCode }}
+ `, + }) + + app.component('vue-select', VueNextSelect) + app.mount(document.querySelector('#group')) +} diff --git a/docs/index.html b/docs/index.html index 0dc3c0e9..bb6363c1 100644 --- a/docs/index.html +++ b/docs/index.html @@ -66,6 +66,7 @@

vue-next-select

  • Single select
  • Multiple select
  • Tagging
  • +
  • Group
  • Filtering / Searching
  • @@ -89,6 +90,7 @@

    Examples

  • Select with search
  • Multiple select
  • Tagging
  • +
  • Group
  • Asynchronous select
  • Creatable
  • Vuex support
  • @@ -187,6 +189,10 @@

    Tagging

    +

    Group

    +
    + +

    Asynchronous select

    @@ -354,6 +360,12 @@

    Props

    null Only works with single mode, identify with strict comparator (===) internally + + group-by + Function, String + 'group' + See label-by + diff --git a/src/components/dropdown.vue b/src/components/dropdown.vue index 7f8fd20b..ea577964 100644 --- a/src/components/dropdown.vue +++ b/src/components/dropdown.vue @@ -5,7 +5,12 @@ v-if="option.visible && option.hidden === false" @click="handleClickItem($event, option)" class="vue-dropdown-item" - :class="{ selected: option.selected, disabled: option.disabled, highlighted: option.highlighted }" + :class="{ + selected: option.selected, + disabled: option.disabled, + highlighted: option.highlighted, + group: option.group, + }" @mousemove="handleMousemove($event, option)" > diff --git a/src/index.vue b/src/index.vue index ea643d39..111f2983 100644 --- a/src/index.vue +++ b/src/index.vue @@ -221,6 +221,11 @@ const VueSelect = { default: false, type: Boolean, }, + + groupBy: { + default: 'group', + type: [String, Function], + }, }, emits: [ 'update:modelValue', @@ -234,7 +239,7 @@ const VueSelect = { 'search:blur', ], setup(props, context) { - const { labelBy, valueBy, disabledBy, min, max, options } = normalize(props) + const { labelBy, valueBy, disabledBy, groupBy, min, max, options } = normalize(props) const instance = getCurrentInstance() const wrapper = ref() @@ -390,6 +395,40 @@ const VueSelect = { const addOrRemoveOption = (event, option) => { if (props.disabled) return + if (option.group && props.multiple) addOrRemoveOptionForGroupOption(event, option) + else addOrRemoveOptionForNonGroupOption(event, option) + + if (props.closeOnSelect === true) isFocusing.value = false + if (props.clearOnSelect === true && searchingInputValue.value) clearInput() + } + const addOrRemoveOptionForGroupOption = (event, option) => { + option = option.originalOption + const has = option.value.every(value => { + const option = getOptionByValue(options.value, value, { valueBy: valueBy.value }) + return hasOption(normalizedModelValue.value, option, { valueBy: valueBy.value }) + }) + if (has) { + option.value.forEach(value => { + const option = getOptionByValue(options.value, value, { valueBy: valueBy.value }) + normalizedModelValue.value = removeOption(normalizedModelValue.value, option, { + min: min.value, + valueBy: valueBy.value, + }) + context.emit('removed', option) + }) + } else { + option.value.forEach(value => { + const option = getOptionByValue(options.value, value, { valueBy: valueBy.value }) + if (hasOption(normalizedModelValue.value, option, { valueBy: valueBy.value })) return + normalizedModelValue.value = addOption(normalizedModelValue.value, option, { + max: max.value, + valueBy: valueBy.value, + }) + context.emit('selected', option) + }) + } + } + const addOrRemoveOptionForNonGroupOption = (event, option) => { option = option.originalOption if (hasOption(normalizedModelValue.value, option, { valueBy: valueBy.value })) { normalizedModelValue.value = removeOption(normalizedModelValue.value, option, { @@ -412,8 +451,6 @@ const VueSelect = { }) context.emit('selected', option) } - if (props.closeOnSelect === true) isFocusing.value = false - if (props.clearOnSelect === true && searchingInputValue.value) clearInput() } const clearInput = () => { @@ -430,17 +467,56 @@ const VueSelect = { const selectedValueSet = new Set(normalizedModelValue.value.map(option => valueBy.value(option))) const visibleValueSet = new Set(renderedOptions.value.map(option => valueBy.value(option))) - return options.value.map((option, index) => ({ - key: valueBy.value(option), - label: labelBy.value(option), - selected: selectedValueSet.has(valueBy.value(option)), - disabled: disabledBy.value(option), - visible: visibleValueSet.has(valueBy.value(option)), - hidden: props.hideSelected ? selectedValueSet.has(valueBy.value(option)) : false, - highlighted: index === highlightedOriginalIndex.value, - originalIndex: index, - originalOption: option, - })) + const optionsWithInfo = options.value.map((option, index) => { + const optionWithInfo = { + key: valueBy.value(option), + label: labelBy.value(option), + // selected: selectedValueSet.has(valueBy.value(option)), + // disabled: disabledBy.value(option), + group: groupBy.value(option), + // visible: visibleValueSet.has(valueBy.value(option)), + // hidden: props.hideSelected ? selectedValueSet.has(valueBy.value(option)) : false, + highlighted: index === highlightedOriginalIndex.value, + originalIndex: index, + originalOption: option, + } + + optionWithInfo.selected = optionWithInfo.group + ? option.value.every(value => selectedValueSet.has(value)) + : selectedValueSet.has(valueBy.value(option)) + + optionWithInfo.disabled = optionWithInfo.group + ? disabledBy.value(option) || + option.value.every(value => { + const option = getOptionByValue(options.value, value, { valueBy: valueBy.value }) + return disabledBy.value(option) + }) + : disabledBy.value(option) + + optionWithInfo.visible = optionWithInfo.group + ? option.value.some(value => visibleValueSet.has(value)) + : visibleValueSet.has(valueBy.value(option)) + + optionWithInfo.hidden = props.hideSelected + ? optionWithInfo.group + ? option.value.every(value => selectedValueSet.has(value)) + : selectedValueSet.has(valueBy.value(option)) + : false + + return optionWithInfo + }) + + for (const option of optionsWithInfo) { + if (option.group === false) continue + if (option.disabled) { + const values = new Set(option.originalOption.value) + optionsWithInfo + .filter(optionWithInfo => values.has(valueBy.value(optionWithInfo.originalOption))) + .forEach(optionWithInfo => (optionWithInfo.disabled = true)) + } + } + + return optionsWithInfo }) const { pointerForward: _pointerForward, pointerBackward: _pointerBackward, pointerSet } = usePointer( optionsWithInfo, @@ -495,7 +571,7 @@ const VueSelect = { provide('dataAttrs', dataAttrs) const innerPlaceholder = computed(() => { - const selectedOptions = optionsWithInfo.value.filter(option => option.selected) + const selectedOptions = optionsWithInfo.value.filter(option => option.selected).filter(option => !option.group) if (props.multiple) { if (selectedOptions.length === 0) { diff --git a/src/normalize.js b/src/normalize.js index 932a3c3b..523d46c7 100644 --- a/src/normalize.js +++ b/src/normalize.js @@ -13,6 +13,7 @@ export default props => { const labelBy = createComputedForGetterFunction(toRef(props, 'labelBy')) const valueBy = createComputedForGetterFunction(toRef(props, 'valueBy')) const disabledBy = createComputedForGetterFunction(toRef(props, 'disabledBy')) + const groupBy = createComputedForGetterFunction(toRef(props, 'groupBy')) const min = computed(() => (props.multiple ? props.min : Math.min(1, props.min))) const max = computed(() => (props.multiple ? props.max : 1)) @@ -23,6 +24,7 @@ export default props => { labelBy, valueBy, disabledBy, + groupBy, min, max, options,