Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SelectMenu): add clearable #2564

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions docs/components/content/examples/SelectMenuExampleClearable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<script setup lang="ts">
const options = ref([
{ id: 1, name: 'bug', color: 'd73a4a' },
{ id: 2, name: 'documentation', color: '0075ca' },
{ id: 3, name: 'duplicate', color: 'cfd3d7' },
{ id: 4, name: 'enhancement', color: 'a2eeef' },
{ id: 5, name: 'good first issue', color: '7057ff' },
{ id: 6, name: 'help wanted', color: '008672' },
{ id: 7, name: 'invalid', color: 'e4e669' },
{ id: 8, name: 'question', color: 'd876e3' },
{ id: 9, name: 'wontfix', color: 'ffffff' }
])

const selected = ref([])

const labels = computed({
get: () => selected.value,
set: async (labels) => {
const promises = labels.map(async (label) => {
if (label.id) {
return label
}

// In a real app, you would make an API call to create the label
const response = {
id: options.value.length + 1,
name: label.name,
color: generateColorFromString(label.name)
}

options.value.push(response)

return response
})

selected.value = await Promise.all(promises)
}
})

function hashCode(str) {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
return hash
}

function intToRGB(i) {
const c = (i & 0x00FFFFFF)
.toString(16)
.toUpperCase()

return '00000'.substring(0, 6 - c.length) + c
}

function generateColorFromString(str) {
return intToRGB(hashCode(str))
}
</script>

<template>
<USelectMenu
v-model="labels"
by="id"
name="labels"
:options="options"
option-attribute="name"
clearable
multiple
searchable
creatable
>
<template #label>
<template v-if="labels.length">
<span class="flex items-center -space-x-1">
<span v-for="label of labels" :key="label.id" class="flex-shrink-0 w-2 h-2 mt-px rounded-full" :style="{ background: `#${label.color}` }" />
</span>
<span>{{ labels.length }} label{{ labels.length > 1 ? 's' : '' }}</span>
</template>
<template v-else>
<span class="text-gray-500 dark:text-gray-400 truncate">Select labels</span>
</template>
</template>

<template #option="{ option }">
<span
class="flex-shrink-0 w-2 h-2 mt-px rounded-full"
:style="{ background: `#${option.color}` }"
/>
<span class="truncate">{{ option.name }}</span>
</template>

<template #option-create="{ option }">
<span class="flex-shrink-0">New label:</span>
<span
class="flex-shrink-0 w-2 h-2 mt-px rounded-full -mx-1"
:style="{ background: `#${generateColorFromString(option.name)}` }"
/>
<span class="block truncate">{{ option.name }}</span>
</template>
</USelectMenu>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<script setup lang="ts">
const options = ref([
{ id: 1, name: 'bug', color: 'd73a4a' },
{ id: 2, name: 'documentation', color: '0075ca' },
{ id: 3, name: 'duplicate', color: 'cfd3d7' },
{ id: 4, name: 'enhancement', color: 'a2eeef' },
{ id: 5, name: 'good first issue', color: '7057ff' },
{ id: 6, name: 'help wanted', color: '008672' },
{ id: 7, name: 'invalid', color: 'e4e669' },
{ id: 8, name: 'question', color: 'd876e3' },
{ id: 9, name: 'wontfix', color: 'ffffff' }
])

const selected = ref([])

const labels = computed({
get: () => selected.value,
set: async (labels) => {
const promises = labels.map(async (label) => {
if (label.id) {
return label
}

// In a real app, you would make an API call to create the label
const response = {
id: options.value.length + 1,
name: label.name,
color: generateColorFromString(label.name)
}

options.value.push(response)

return response
})

selected.value = await Promise.all(promises)
}
})

function hashCode(str) {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
return hash
}

function intToRGB(i) {
const c = (i & 0x00FFFFFF)
.toString(16)
.toUpperCase()

return '00000'.substring(0, 6 - c.length) + c
}

function generateColorFromString(str) {
return intToRGB(hashCode(str))
}
</script>

<template>
<USelectMenu
v-model="labels"
by="id"
name="labels"
:options="options"
option-attribute="name"
clearable
multiple
searchable
creatable
>
<template #label>
<template v-if="labels.length">
<span class="flex items-center -space-x-1">
<span v-for="label of labels" :key="label.id" class="flex-shrink-0 w-2 h-2 mt-px rounded-full" :style="{ background: `#${label.color}` }" />
</span>
<span>{{ labels.length }} label{{ labels.length > 1 ? 's' : '' }}</span>
</template>
<template v-else>
<span class="text-gray-500 dark:text-gray-400 truncate">Select labels</span>
</template>
</template>

<template #option="{ option }">
<span
class="flex-shrink-0 w-2 h-2 mt-px rounded-full"
:style="{ background: `#${option.color}` }"
/>
<span class="truncate">{{ option.name }}</span>
</template>

<template #option-create="{ option }">
<span class="flex-shrink-0">New label:</span>
<span
class="flex-shrink-0 w-2 h-2 mt-px rounded-full -mx-1"
:style="{ background: `#${generateColorFromString(option.name)}` }"
/>
<span class="block truncate">{{ option.name }}</span>
</template>
<template #clearable="{ onClear }">
<UButton icon="i-heroicons-trash-20-solid" size="xs" class="text-gray-400 dark:text-gray-500" variant="ghost" @click.capture.stop="onClear" />
</template>
</USelectMenu>
</template>
33 changes: 32 additions & 1 deletion docs/content/2.components/select-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,38 @@ Use the `searchableLazy` prop to control the immediacy of data requests.
---
component: 'select-menu-example-search-async'
componentProps:
class: 'w-full lg:w-48'
class: 'w-full lg:w-48'
---
::

## Clearable
The `clearable` prop allows users to easily remove their selected option(s) with a clear button.

::component-example
---
component: 'select-menu-example-clearable'
componentProps:
class: 'w-full lg:w-52'
---
::


### Customization
#### Slot Props
The slot provides four key props:

| Prop | Type | Description |
|------|------|-------------|
| `selected` | `Object` | The currently selected value/item in the component |
| `disabled` | `Boolean` | Whether the component is in a disabled state |
| `loading` | `Boolean` | Whether the component is in a loading state |
| `onClear` | `Function` | Callback function to clear the selected value when the clear button is clicked |

::component-example
---
component: 'select-menu-example-clearable-customization'
componentProps:
class: 'w-full lg:w-52'
---
::

Expand Down
75 changes: 70 additions & 5 deletions src/runtime/components/forms/SelectMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@
<span v-if="label" :class="uiMenu.label">{{ label }}</span>
<span v-else :class="uiMenu.label">{{ placeholder || '&nbsp;' }}</span>
</slot>
<span v-if="canClearValue" :class="clearableWrapperClass">
<slot name="clearable" :selected="selected" :disabled="disabled" :loading="loading" @clear="onClear">
<UButton
:icon="clearableIcon"
size="xs"
class="p-0"
:class="clearableButtonClass"
variant="ghost"
@click.capture.stop="onClear"
/>
</slot>
</span>

<span v-if="(isTrailing && trailingIconName) || $slots.trailing" :class="trailingWrapperIconClass">
<slot name="trailing" :selected="selected" :disabled="disabled" :loading="loading">
Expand Down Expand Up @@ -149,6 +161,7 @@
import { get, mergeConfig } from '../../utils'
import { useInjectButtonGroup } from '../../composables/useButtonGroup'
import type { SelectSize, SelectColor, SelectVariant, PopperOptions, Strategy, DeepPartial } from '../../types/index'
import type { Button } from '../../types/button'

Check failure on line 164 in src/runtime/components/forms/SelectMenu.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 20)

'Button' is defined but never used. Allowed unused vars must match /^_/u
// @ts-expect-error
import appConfig from '#build/app.config'
import { select, selectMenu } from '#ui/ui.config'
Expand Down Expand Up @@ -333,9 +346,18 @@
uiMenu: {
type: Object as PropType<DeepPartial<typeof configMenu> & { strategy?: Strategy }>,
default: () => ({})
},
clearable: {
type: Boolean,
default: false
},
clearableIcon: {
type: String,
default: () => config.default.clerableIcon
}

},
emits: ['update:modelValue', 'update:query', 'open', 'close', 'change'],
emits: ['update:modelValue', 'update:query', 'open', 'close', 'change', 'clear'],
setup(props, { emit, slots }) {
if (import.meta.dev && props.multiple && !Array.isArray(props.modelValue)) {
console.warn(`[@nuxt/ui] The USelectMenu components needs to have a modelValue of type Array when using the multiple prop. Got '${typeof props.modelValue}' instead.`, props.modelValue)
Expand Down Expand Up @@ -446,6 +468,23 @@
return props.leadingIcon || props.icon
})

const canClearValue = computed(() => props.clearable && (Array.isArray(selected.value) ? selected.value.length > 0 : !!selected.value))

const clearableWrapperClass = computed(() => {
return twJoin(
ui.value.icon.clearable.wrapper,
ui.value.icon.clearable.padding[size.value]
)
})

const clearableButtonClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
props.loading && ui.value.icon.loading
)
})

const trailingIconName = computed(() => {
if (props.loading && !isLeading.value) {
return props.loadingIcon
Expand Down Expand Up @@ -474,7 +513,6 @@
const trailingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.pointer,
ui.value.icon.trailing.padding[size.value]
)
})
Expand Down Expand Up @@ -549,7 +587,7 @@
return ['string', 'number'].includes(typeof props.modelValue) ? query.value : { [props.optionAttribute]: query.value }
})

function clearOnClose() {
function handleClearSearchOnClose() {
if (props.clearSearchOnClose) {
query.value = ''
}
Expand All @@ -559,7 +597,7 @@
if (value) {
emit('open')
} else {
clearOnClose()
handleClearSearchOnClose()
emit('close')
emitFormBlur()
}
Expand All @@ -579,6 +617,28 @@
query.value = event.target.value
}

function onClear() {
if (canClearValue.value) {
emit('update:modelValue', props.multiple ? [] : null)
emit('clear')
emitFormChange()
}
}

function trailingSlotProps() {
const slotProps: Record<string, any> = {
selected: selected.value,
loading: props.loading,
disabled: props.disabled
}

if (props.clearable) {
slotProps.onClear = onClear
}

return slotProps
}

provideUseId(() => useId())

return {
Expand All @@ -598,6 +658,7 @@
label,
accessor,
isLeading,
onClear,
isTrailing,
// eslint-disable-next-line vue/no-dupe-keys
selectClass,
Expand All @@ -612,7 +673,11 @@
// eslint-disable-next-line vue/no-dupe-keys
query,
onUpdate,
onQueryChange
onQueryChange,
trailingSlotProps,
canClearValue,
clearableWrapperClass,
clearableButtonClass
}
}
})
Expand Down
Loading
Loading