Skip to content

Commit

Permalink
feat(maz-ui): MazSelect - add optgroup
Browse files Browse the repository at this point in the history
  • Loading branch information
LouisMazel committed Mar 13, 2024
1 parent d966003 commit b105334
Show file tree
Hide file tree
Showing 3 changed files with 193 additions and 66 deletions.
53 changes: 45 additions & 8 deletions packages/docs/docs/components/maz-select.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,37 @@ You can use your own template to replace the empty icon when no results are foun
/>
```

## Opt Group

Group your options like a native optgroup

<MazSelect
v-model="optGroupValue"
label="Select options"
:options="optGroup"
multiple
/>

```vue
<template>
<MazSelect
v-model="optGroupValue"
label="Select option"
:options="optGroup"
multiple
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const selectedValue = ref()
const optGroup = [
{ label: 'Basic colors', options: ['primary', 'secondary', 'danger'] },
{ label: 'Custom colors', options: [{ label: 'third', value: 'third' }] },
]
</script>
```

## Custom template options

<br />
Expand Down Expand Up @@ -137,10 +168,10 @@ You can use your own template to replace the empty icon when no results are foun
const selectedUser = ref()
const customTemplateOptions = [
{ picture: 'https://placekitten.com/100/100', label: 'James Kitten', value: 1 },
{ picture: 'https://placekitten.com/500/500', label: 'Brad Kitten', value: 2 },
{ picture: 'https://placekitten.com/300/300', label: 'Cedric Kitten', value: 3 },
{ picture: 'https://placekitten.com/400/400', label: 'Harry Kitten', value: 4 },
{ picture: 'https://api.dicebear.com/7.x/big-smile/svg?backgroundColor=1d90ff&seed=JamesSmile', label: 'James Smile', value: 1 },
{ picture: 'https://api.dicebear.com/7.x/big-smile/svg?backgroundColor=1d90ff&seed=BradSmile', label: 'Brad Smile', value: 2 },
{ picture: 'https://api.dicebear.com/7.x/big-smile/svg?backgroundColor=1d90ff&seed=CedricSmile', label: 'Cedric Smile', value: 3 },
{ picture: 'https://api.dicebear.com/7.x/big-smile/svg?backgroundColor=1d90ff&seed=HarrySmile', label: 'Harry Smile', value: 4 },
]
</script>
```
Expand Down Expand Up @@ -202,6 +233,7 @@ If you want custom keys of these options, you can use:
<script setup lang="ts">
import { ref } from 'vue'

const optGroupValue = ref()
const selectedValue = ref()
const selectedValueCustom = ref('danger')
const selectedUser = ref()
Expand All @@ -218,11 +250,16 @@ If you want custom keys of these options, you can use:
{ label: 'black', value: 'black' },
]

const optGroup = [
{ label: 'Basic colors', options: ['primary', 'secondary', 'danger'] },
{ label: 'Custom colors', options: [{ label: 'third', value: 'third' }] },
]

const customTemplateOptions = [
{ picture: 'https://placekitten.com/100/100', label: 'James Kitten', value: 1 },
{ picture: 'https://placekitten.com/500/500', label: 'Brad Kitten', value: 2 },
{ picture: 'https://placekitten.com/300/300', label: 'Cedric Kitten', value: 3 },
{ picture: 'https://placekitten.com/400/400', label: 'Harry Kitten', value: 4 },
{ picture: 'https://api.dicebear.com/7.x/big-smile/svg?backgroundColor=1d90ff&seed=JamesSmile', label: 'James Smile', value: 1 },
{ picture: 'https://api.dicebear.com/7.x/big-smile/svg?backgroundColor=1d90ff&seed=BradSmile', label: 'Brad Smile', value: 2 },
{ picture: 'https://api.dicebear.com/7.x/big-smile/svg?backgroundColor=1d90ff&seed=CedricSmile', label: 'Cedric Smile', value: 3 },
{ picture: 'https://api.dicebear.com/7.x/big-smile/svg?backgroundColor=1d90ff&seed=HarrySmile', label: 'Harry Smile', value: 4 },
]

const customOptions = [
Expand Down
153 changes: 105 additions & 48 deletions packages/lib/components/MazSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,40 +75,55 @@
</span>
</slot>
<div v-else class="m-select-list__scroll-wrapper" tabindex="-1">
<button
v-for="(option, i) in optionsList"
:key="i"
tabindex="-1"
type="button"
class="m-select-list-item maz-custom maz-flex-none"
:class="[
{
'--is-keyboard-selected': tmpModelValueIndex === i,
'--is-selected': isSelectedOption(option),
'--is-none-value': isNullOrUndefined(option[optionValueKey]),
},
]"
:style="itemHeight ? { minHeight: `${itemHeight}px` } : undefined"
@click.prevent.stop="updateValue(option)"
>
<MazCheckbox
v-if="multiple"
tabindex="-1"
:model-value="isSelectedOption(option)"
size="sm"
:color="color"
/>
<template v-for="(option, i) in optionsList" :key="i">
<!--
@slot Custom option
@binding {Object} option
@binding {Boolean} is-selected
@slot Custom optgroup label
@binding {String} label - the label of the optgroup
-->
<slot :option="option" :is-selected="isSelectedOption(option)">
<span>
{{ option[optionLabelKey] }}
<slot
v-if="option.label && !option[optionValueKey]"
name="optgroup"
:label="option.label"
>
<span class="m-select-list-optgroup">
{{ option.label }}
</span>
</slot>
</button>

<button
v-else
tabindex="-1"
type="button"
class="m-select-list-item maz-custom maz-flex-none"
:class="[
{
'--is-keyboard-selected': tmpModelValueIndex === i,
'--is-selected': isSelectedOption(option),
'--is-none-value': isNullOrUndefined(option[optionValueKey]),
},
]"
:style="itemHeight ? { minHeight: `${itemHeight}px` } : undefined"
@click.prevent.stop="updateValue(option)"
>
<MazCheckbox
v-if="multiple"
tabindex="-1"
:model-value="isSelectedOption(option)"
size="sm"
:color="color"
/>
<!--
@slot Custom option
@binding {Object} option - the option object
@binding {Boolean} is-selected - if the option is selected
-->
<slot :option="option" :is-selected="isSelectedOption(option)">
<span>
{{ option[optionLabelKey] }}
</span>
</slot>
</button>
</template>
</div>
</div>
</Transition>
Expand All @@ -131,8 +146,18 @@
import { useInstanceUniqId } from '../modules/composables'
import { debounceCallback } from './../modules/helpers/debounce-callback'
type NormalizedOption = Record<string, ModelValueSimple>
export type MazSelectOption = NormalizedOption | string | number | boolean
export type NormalizedOption = Record<string, ModelValueSimple>
export type MazSelectOptionWithOptGroup = {
label: string
options: (NormalizedOption | string | number | boolean)[]
}
export type MazSelectOption =
| NormalizedOption
| string
| number
| boolean
| MazSelectOptionWithOptGroup
export type { Color, Size, ModelValueSimple, Position }
const MazCheckbox = defineAsyncComponent(() => import('./MazCheckbox.vue'))
Expand Down Expand Up @@ -278,24 +303,48 @@
providedId: props.id,
})
const optionsNormalized = computed<NormalizedOption[] | undefined>(() =>
props.options?.map((option) => {
function getOptionPayload(option: string | number | boolean): NormalizedOption {
return {
[props.optionValueKey]: option,
[props.optionLabelKey]: option,
[props.optionInputValueKey]: option,
}
}
function getNormalizedOptionPayload(option: NormalizedOption): NormalizedOption {
return {
...option,
[props.optionValueKey]: option[props.optionValueKey],
[props.optionLabelKey]: option[props.optionLabelKey],
[props.optionInputValueKey]: option[props.optionInputValueKey],
}
}
const optionsNormalized = computed<NormalizedOption[]>(() => {
const normalizedOptions: NormalizedOption[] = []
if (!props.options?.length) {
return []
}
for (const option of props.options) {
if (typeof option === 'string' || typeof option === 'number' || typeof option === 'boolean') {
return {
[props.optionValueKey]: option,
[props.optionLabelKey]: option,
[props.optionInputValueKey]: option,
}
normalizedOptions.push(getOptionPayload(option))
} else if ('options' in option && Array.isArray(option.options)) {
normalizedOptions.push(
{ label: option.label },
...option.options.map((opt) =>
typeof opt === 'string' || typeof opt === 'number' || typeof opt === 'boolean'
? getOptionPayload(opt)
: getNormalizedOptionPayload(opt),
),
)
} else {
normalizedOptions.push(getNormalizedOptionPayload(option as NormalizedOption))
}
}
return {
...option,
[props.optionValueKey]: option[props.optionValueKey],
[props.optionLabelKey]: option[props.optionLabelKey],
[props.optionInputValueKey]: option[props.optionInputValueKey],
}
}),
)
return normalizedOptions
})
const selectedOptions = computed(
() =>
Expand Down Expand Up @@ -348,7 +397,7 @@
}
return optionsNormalized.value?.find(
(option) => option[props.optionValueKey] === props.modelValue,
(option) => props.modelValue && option[props.optionValueKey] === props.modelValue,
)?.[props.optionInputValueKey]
})
Expand Down Expand Up @@ -660,6 +709,10 @@
.m-select-list {
@apply maz-absolute maz-z-default-backdrop maz-flex maz-flex-col maz-gap-1 maz-overflow-hidden maz-rounded maz-bg-color maz-p-2 maz-elevation dark:maz-border dark:maz-border-color-light;
&-optgroup {
@apply maz-flex-none maz-p-0.5 maz-text-left maz-text-[0.875em] maz-text-muted;
}
min-width: 3.5rem;
&.--top {
Expand Down Expand Up @@ -713,6 +766,10 @@
color: v-bind('selectedTextColor');
background-color: v-bind('selectedBgColor');
&:hover {
background-color: v-bind('keyboardSelectedBgColor');
}
&.--transparent {
@apply maz-bg-color;
}
Expand Down
53 changes: 43 additions & 10 deletions packages/lib/tests/specs/components/maz-select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ describe('components/MazSelect.vue', () => {

test('Should show the option label on input for false value', async () => {
await wrapper.setProps({
modelValue: false,
options: [{ label: 'Label', value: false }],
modelValue: '1',
options: [{ label: 'Label', value: '1' }],
})

expect(wrapper.vm.mazInputValue).toBe('Label')
Expand Down Expand Up @@ -101,10 +101,43 @@ describe('components/MazSelect.vue', () => {
await wrapper.vm.$nextTick()

// Selecting the second option should update the input value and emit a change event with the option value
wrapper.findAll('.m-select-list-item').at(0).trigger('click')
wrapper.findAll('.m-select-list-item').at(0)?.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.vm.mazInputValue).toBe('Test 1')
expect(wrapper.emitted()['update:model-value'][0][0]).toBe(1)
expect(wrapper.emitted('update:model-value')?.[0][0]).toBe(1)
})

test('I can group options', async () => {
await wrapper.setProps({
modelValue: undefined,
options: [
{
label: 'Group 1',
options: [
{ label: 'Test 1', value: 1 },
{ label: 'Test 2', value: 2 },
],
},
{
label: 'Group 2',
options: [
{ label: 'Test 3', value: 3 },
{ label: 'Test 4', value: 4 },
],
},
],
})

await wrapper.vm.$nextTick()

expect(wrapper.vm.optionsNormalized).toStrictEqual([
{ label: 'Group 1' },
{ label: 'Test 1', value: 1 },
{ label: 'Test 2', value: 2 },
{ label: 'Group 2' },
{ label: 'Test 3', value: 3 },
{ label: 'Test 4', value: 4 },
])
})

test('I can select multiple values', async () => {
Expand All @@ -121,11 +154,11 @@ describe('components/MazSelect.vue', () => {

await wrapper.vm.$nextTick()

await wrapper.findAll('.m-select-list-item').at(3).trigger('click')
expect(wrapper.emitted('update:model-value')[0][0]).toEqual([4])
await wrapper.findAll('.m-select-list-item').at(4).trigger('click')
expect(wrapper.emitted('update:model-value')[1][0]).toEqual([4, 5])
await wrapper.findAll('.m-select-list-item').at(3).trigger('click')
expect(wrapper.emitted('update:model-value')[2][0]).toEqual([5])
await wrapper.findAll('.m-select-list-item').at(3)?.trigger('click')
expect(wrapper.emitted('update:model-value')?.[0][0]).toEqual([4])
await wrapper.findAll('.m-select-list-item').at(4)?.trigger('click')
expect(wrapper.emitted('update:model-value')?.[1][0]).toEqual([4, 5])
await wrapper.findAll('.m-select-list-item').at(3)?.trigger('click')
expect(wrapper.emitted('update:model-value')?.[2][0]).toEqual([5])
})
})

0 comments on commit b105334

Please sign in to comment.