quasarframework / quasar

Quasar Framework - Build high-performance VueJS user interfaces in record time
https://quasar.dev
MIT License
26.02k stars 3.54k forks source link

[QSelect] Group options inside select element with optgroup #5724

Open matthiasmoier opened 5 years ago

matthiasmoier commented 5 years ago

In basic HTML <select> elements, options can be grouped using the HTML <optgroup> element. I think this would be a great addition to Quasar as with the current QSelect component, it’s only possible to have 1 level of options and thus it’s not possible to structure the options.

Having grouped options helps users to find their options faster and makes using larger <select> elements easier.

Describe the solution you'd like I’d like to have the possibility to group options in optgroups so that options could be easily organized and structured inside a select element. (E.g. categories/subcategories, organizing a country list based on continents, etc.)

Also, it should be possible to use the same attributes that the HTML<optgroup> element provides:

  • disabled If this Boolean attribute is set, none of the items in this option group is selectable. Often browsers grey out such control and it won't receive any browsing events, like mouse clicks or focus-related ones.
  • label The name of the group of options, which the browser can use when labeling the options in the user interface. This attribute is mandatory if this element is used.

Optgroups could be handled as follows:

export default {
  data () {
    return {
      model: null,
      options: [
        'Some', 'Options', 'Outside', 'Of', 'Any', 'Group',
        {
            label: "Group 1",
            options: {
                'Option 1.1'
            }
        },
        {
            label: "Group 2",
            options: {
                'Option 2.1', 'Option 2.2'
            }
        },
        {
            label: "Group 3",
            disabled: true,
            options: {
                'Option 3.1', 'Option 3.2', 'Option 3.3'
            }
        },
        'Some', 'More', 'Options'
      ]
    }
  }
}

Additional context More info and example of basic HTML <optgroup> element: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/optgroup

Screenshot 2019-11-28 at 11 08 47
pjar commented 4 years ago

Any update on this one?

hawkeye64 commented 4 years ago

@pjar You can try something like this: https://codepen.io/Hawkeye64/pen/xxbXajq

x-ji commented 4 years ago

@hawkeye64 Thanks for sharing this sample solution

piperone commented 4 years ago

Since <optgroup> is a widely used HTML element and a convenient way of grouping related select-values together, support for nested structures in q-select's options-prop would be really nice. I was a little surprised to see that this wasn't already a feature, but I guess even the best frameworks have room for improvement :-)

alexb007 commented 4 years ago

Looking for this feature. Is anyone already did this?

nulele commented 3 years ago

I found this very good solution!

https://codepen.io/smolinari/pen/MdXyJR

invaderb commented 2 years ago

Anyone get something like this working in Quasar 2? the above mentioned codepens are only for quasar 1...

invaderb commented 2 years ago

Actually got this this working for Quasar 2/vue 3 Just had to change the v-on to pass the scope since there is no more itemEvents and set the ref on the model.

I also updated the nested template to reflect the correct format for q-list items

<script setup lang="ts">
let model: any = ref('');
let options = [
    {
        group: 'Group',
        disable: true
    },
    {
        label: 'Apple',
        value: 'Apple',
        description: 'iStuff',
        icon: 'golf_course'
    },
    {
        label: 'Oracle',
        value: 'Oracle',
        description: 'Databases',
        icon: 'casino'
    }
];
</script>
<template>
<q-select v-model.lazy="model" :options="options" label="Standard" clearable
options-selected-class="text-deep-orange">
    <template v-slot:option="scope">
        <q-item-label header v-if="scope.opt.group" v-bind="scope.itemProps">{{ scope.opt.group }}</q-item-label>
        <q-item v-if="!scope.opt.group" v-bind="scope.itemProps" v-on="scope">
            <q-item-section>
                <q-item-label v-html="scope.opt.label"></q-item-label>
                <q-item-label caption>{{ scope.opt.description }}</q-item-label>
            </q-item-section>
        </q-item>
    </template>
</q-select>
</template>
BenceSzalai commented 11 months ago

I found this very good solution!

https://codepen.io/smolinari/pen/MdXyJR

Note for anyone trying to implement this solution together with filtering or any other options list that is reactive:

Make sure to include :key="scope.index" on the root element of the option template, otherwise reactive updates will break and will leave orphaned DOM elements (practically duplicates) in the list.

Indeed this also means it needs a wrapper above the q-items, because it is not allowed to use the same key on both the v-if and the v-else branches. So the proper way is to wrap the q-items in a div for example, and add :key="scope.index" as well as v-bind="scope.itemProps" and v-on="scope.itemEvents" to the wrapper div. Use v-if inside that div to choose the right q-item representation.

ExaltedJim commented 6 months ago

Thanks @BenceSzalai, I just tried implementing the above and needed the parent element around it to make it work with filtering. Thanks for saving me time and effort to figure out how to remove the duplicates showing in the list!

FYI - this is how I did it with latest vue + quasar:

https://codepen.io/exaltedjim/pen/vYwEQbO

ivan006 commented 4 months ago

feel free to use my component image

    <groupable-select
      v-model="selectedTags"
      :options="options"
      :group-by="'country.name'"
      option-value="id"
      option-label="name"
      emit-value
      map-options
      use-chips
      style="width: 250px"
      multiple
      filled
    />
/// groupable-select
<template>
  <q-select
    v-bind="selectProps"
    :model-value="internalValue"
    @update:model-value="updateValue"
    :options="processedOptions"
    use-chips
    :emit-value="emitValue"
    :map-options="mapOptions"
    :option-value="optionValue"
    :option-label="optionLabel"
    :multiple="multiple"
    :filled="filled"
  >
    <template v-slot:option="scope">
      <template v-if="scope.opt.isGroup">
        <q-item-label header v-bind="scope.itemProps" style="font-weight: bold; font-size: 1.1em; color: black;">
          {{ scope.opt.group }}
        </q-item-label>
      </template>
      <template v-else>
        <q-item v-bind="scope.itemProps" v-on="scope" style="margin-left: 10px;">
          <q-item-section>
            <q-item-label>{{ scope.opt[optionLabel] }}</q-item-label>
          </q-item-section>
        </q-item>
      </template>
    </template>
  </q-select>
</template>

<script setup>
import { ref, computed, watch } from 'vue';
import { useQuasar } from 'quasar';
import { defineProps, defineEmits } from 'vue';

const props = defineProps({
  options: {
    type: Array,
    required: true,
  },
  disableGrouping: {
    type: Boolean,
    default: false,
  },
  groupBy: {
    type: String,
    required: false,
  },
  modelValue: {
    type: [String, Number, Array],
    default: () => [],
  },
  emitValue: {
    type: Boolean,
    default: false,
  },
  mapOptions: {
    type: Boolean,
    default: false,
  },
  multiple: {
    type: Boolean,
    default: false,
  },
  filled: {
    type: Boolean,
    default: false,
  },
  optionValue: {
    type: String,
    default: 'value',
  },
  optionLabel: {
    type: String,
    default: 'label',
  },
});

const emits = defineEmits(['update:modelValue']);

const $q = useQuasar();

const internalValue = ref(props.modelValue);

const updateValue = (value) => {
  internalValue.value = value;
  emits('update:modelValue', value);
};

watch(() => props.modelValue, (newValue) => {
  internalValue.value = newValue;
}, { immediate: true });

const processedOptions = computed(() => {
  if (props.disableGrouping || !props.groupBy) {
    return props.options;
  }

  const grouped = [];
  let currentGroup = null;

  const result = [...props.options];
  result.sort((a, b) => {
    const aGroupValue = props.groupBy.split('.').reduce((obj, key) => obj && obj[key], a) || '';
    const bGroupValue = props.groupBy.split('.').reduce((obj, key) => obj && obj[key], b) || '';
    if (aGroupValue < bGroupValue) return -1;
    if (aGroupValue > bGroupValue) return 1;
    if (a[props.optionLabel] < b[props.optionLabel]) return -1;
    if (a[props.optionLabel] > b[props.optionLabel]) return 1;
    return 0;
  });

  result.forEach(option => {
    const groupValue = props.groupBy.split('.').reduce((obj, key) => obj && obj[key], option);

    if (groupValue !== currentGroup) {
      currentGroup = groupValue;
      grouped.push({isGroup: true, group: currentGroup});
    }
    grouped.push(option);
  });

  return grouped;
});

const selectProps = computed(() => {
  const {
    options,
    disableGrouping,
    groupBy,
    modelValue,
    emitValue,
    mapOptions,
    optionValue,
    optionLabel,
    ...restProps
  } = props;
  return restProps;
});
</script>
ivan006 commented 4 months ago

ok heres an update when there are a lot of options (like 200) the thing can get very glitchy

like this image

it has to do with the virtual-scroll mechanism lazy loading stuff. but when i tell it to just load everything the performance is actually shocking (excuse my criticism but like a toddler designed the component) .

so i have now thrown the quasar select out completely and now made my own imitation multiselect component that performs much better

here it is

image

    <MultiSelect
      v-model="selectedTags"
      :options="vettedTags"
      :group-by="'country.name'"
      option-value="id"
      option-label="name"
      emit-value
      map-options
      use-chips
      multiple
      filled
    />

and

<template>
  <div class="MultiSelect">
    <q-input
      v-bind="inputProps"
      v-model="computedLabel"
      @focus="toggleDropdown"
      readonly
      filled
    >
      <template v-slot:append>
        <q-icon name="arrow_drop_down" />
      </template>
    </q-input>
    <q-menu fit ref="dropdown" v-model="menuVisible">
      <q-list style="min-width: 200px;">
        <template v-if="!props.disableGrouping">
          <template v-for="(group, groupIndex) in processedOptions" :key="groupIndex">
            <q-item
              clickable
              @click="toggleGroup(group.group)"
              class="q-pa-sm"
              style="font-weight: bold; font-size: 1.1em; color: black; font-weight: bold; height: 60px;"
            >
              <q-item-section>
                {{ group.group }}
              </q-item-section>
              <q-item-section side>
                <q-icon :name="expandedGroups.includes(group.group) ? 'expand_less' : 'expand_more'" />
              </q-item-section>
            </q-item>
            <div v-if="expandedGroups.includes(group.group)">
              <q-item
                v-for="option in group.children"
                :key="option[optionValue]"
                clickable
                v-ripple
                @click="toggleOption(option)"
                class="q-ml-md"
              >
                <q-checkbox
                  v-model="selectedValues"
                  :val="getOptionValue(option)"
                  :label="getOptionLabel(option)"
                  @change="emitChange"
                />
              </q-item>
            </div>
          </template>
        </template>
        <template v-else>
          <q-item
            v-for="option in props.options"
            :key="option[optionValue]"
            clickable
            v-ripple
            @click="toggleOption(option)"
          >
            <q-checkbox
              v-model="selectedValues"
              :val="getOptionValue(option)"
              :label="getOptionLabel(option)"
              @change="emitChange"
            />
          </q-item>
        </template>
      </q-list>
    </q-menu>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue';
import { QInput, QIcon, QMenu, QList, QItem, QCheckbox, QItemLabel } from 'quasar';

const props = defineProps({
  modelValue: {
    type: [String, Number, Array],
    default: () => [],
  },
  options: {
    type: Array,
    required: true,
  },
  emitValue: {
    type: Boolean,
    default: false,
  },
  mapOptions: {
    type: Boolean,
    default: false,
  },
  optionValue: {
    type: String,
    default: 'value',
  },
  optionLabel: {
    type: String,
    default: 'label',
  },
  multiple: {
    type: Boolean,
    default: false,
  },
  filled: {
    type: Boolean,
    default: false,
  },
  groupBy: {
    type: String,
    required: false,
  },
  disableGrouping: {
    type: Boolean,
    default: false,
  },
  ...QInput.props // Inherit QInput props
});

const emits = defineEmits(['update:modelValue']);

const internalValue = ref(props.modelValue);
const expandedGroups = ref([]);
const menuVisible = ref(false);

const getOptionValue = (option) => {
  return props.mapOptions ? option[props.optionValue] : option;
};

const getOptionLabel = (option) => {
  return props.mapOptions ? option[props.optionLabel] : option;
};

const selectedValues = computed({
  get() {
    return internalValue.value;
  },
  set(val) {
    internalValue.value = val;
    emits('update:modelValue', val);
  },
});

const toggleGroup = (group) => {
  const index = expandedGroups.value.indexOf(group);
  if (index === -1) {
    expandedGroups.value.push(group);
  } else {
    expandedGroups.value.splice(index, 1);
  }
};

const processedOptions = computed(() => {
  if (props.disableGrouping || !props.groupBy) {
    return [{ group: 'All Options', children: props.options }];
  }

  const grouped = {};
  const result = [...props.options];

  result.sort((a, b) => {
    const aGroupValue = props.groupBy.split('.').reduce((obj, key) => obj && obj[key], a) || '';
    const bGroupValue = props.groupBy.split('.').reduce((obj, key) => obj && obj[key], b) || '';
    if (aGroupValue < bGroupValue) return -1;
    if (aGroupValue > bGroupValue) return 1;
    if (getOptionLabel(a) < getOptionLabel(b)) return -1;
    if (getOptionLabel(a) > getOptionLabel(b)) return 1;
    return 0;
  });

  result.forEach(option => {
    const groupValue = props.groupBy.split('.').reduce((obj, key) => obj && obj[key], option) || 'Ungrouped';

    if (!grouped[groupValue]) {
      grouped[groupValue] = { isGroup: true, group: groupValue, children: [] };
    }

    grouped[groupValue].children.push(option);
  });

  return Object.values(grouped);
});

const toggleOption = (option) => {
  const value = getOptionValue(option);
  if (Array.isArray(internalValue.value)) {
    const index = internalValue.value.indexOf(value);
    if (index === -1) {
      internalValue.value.push(value);
    } else {
      internalValue.value.splice(index, 1);
    }
  } else {
    internalValue.value = value;
  }
  emitChange();
};

const emitChange = () => {
  emits('update:modelValue', internalValue.value);
};

const computedLabel = computed(() => {
  if (!Array.isArray(internalValue.value)) {
    return getOptionLabel(internalValue.value);
  }

  if (internalValue.value.length === 0) {
    return '';
  }

  return internalValue.value
    .map((val) => {
      const option = props.options.find((opt) => getOptionValue(opt) === val);
      return getOptionLabel(option);
    })
    .join(', ');
});

const inputProps = computed(() => {
  const {
    modelValue,
    options,
    emitValue,
    mapOptions,
    optionValue,
    optionLabel,
    multiple,
    filled,
    groupBy,
    disableGrouping,
    ...restProps
  } = props;
  return restProps;
});

const toggleDropdown = () => {
  menuVisible.value = !menuVisible.value;
};

watch(() => props.modelValue, (newValue) => {
  internalValue.value = newValue;
}, { immediate: true });
</script>

<style>
.MultiSelect .q-field--filled.q-field--readonly .q-field__control:before {
  border-bottom-style: solid;
  border-bottom-width: 0;
}
.MultiSelect .q-field--filled .q-field__control:before {
  border-bottom: solid 0 grey;
}
</style>
nadlambino commented 1 month ago

Here's how I did it. I just created a new component, extending the q-select.

I am using collect.js here to work with arrays the same way in Laravel, but you can disregard it and just simply work with JS array.

Some extras that I've added is to not allow selecting multiple items of the same group. It can simply be remove or overridden, it's just that it is what I needed.

// GroupSelect.vue
<script setup>
import collect from "collect.js";
import { computed, onMounted, useAttrs } from "vue";

const props = defineProps({
  options: {
    type: Array,
    required: true,
  },
  groupItemAttrs: {
    type: Object,
    default: () => ({}),
  },
  optionItemAttrs: {
    type: Object,
    default: () => ({}),
  },
});

const groups = computed(() =>
  collect(props.options).unique("group").pluck("group").sort()
);

const items = computed(() => {
  const options = collect();

  groups.value.each((group) => {
    const items = collect(props.options).where("group", group).toArray();
    options
      .push({ label: group, is_group: true, disable: true })
      .push(...items);
  });

  return options.toArray();
});

const model = defineModel();

const attrs = useAttrs();
const handleItemUniqueByGroup = (items) => {
  if (!attrs.hasOwnProperty("multiple") || attrs.multiple === false) {
    return;
  }

  model.value = collect(items)
    .unique((item) => {
      // Allow selecting multiple items that are not grouped
      if (item.group === null) {
        return item.value;
      }

      return item.group;
    })
    .toArray();
};

onMounted(() => {
  handleItemUniqueByGroup(model.value);
});
</script>

<template>
  <q-select
    v-model="model"
    :options="items"
    @update:model-value="handleItemUniqueByGroup"
    v-bind="$attrs"
  >
    <template v-slot:option="scope">
      <q-item
        v-if="scope.opt.is_group"
        v-bind="{ ...scope.itemProps, ...groupItemAttrs }"
        :key="scope.label"
        :disable="true"
        :clickable="false"
        class="text-xs bg-slate-200 !cursor-default hover:!bg-slate-200"
        dense
      >
        <q-item-section class="!cursor-default">
          {{ scope.opt.label || "Ungroup" }}
        </q-item-section>
      </q-item>
      <q-item
        v-else
        v-bind="{ ...scope.itemProps, ...optionItemAttrs }"
        :key="scope.id"
      >
        <q-item-section>{{ scope.opt.label }}</q-item-section>
      </q-item>
    </template>
  </q-select>
</template>

Then the options structure is like this:

<script setup>
const options = [
  { label: "Mango", value: 1, group: "Fruit" },
  { label: "Eggplant", value: 2, group: "Vegetable" },
  { label: "Apple", value: 3, group: "Fruit" },
  { label: "Water", value: 4, group: null },
]
</script>

<template>
  <group-select
    outlined
    dense
    multiple
    label="Food"
    v-model="selected"
    :options="options"
  />
</template>

image