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: v2
Choose a base branch
from
Open
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
@@ -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'
---
::

75 changes: 70 additions & 5 deletions src/runtime/components/forms/SelectMenu.vue
Original file line number Diff line number Diff line change
@@ -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">
@@ -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

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'
@@ -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)
@@ -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
@@ -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]
)
})
@@ -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 = ''
}
@@ -559,7 +597,7 @@
if (value) {
emit('open')
} else {
clearOnClose()
handleClearSearchOnClose()
emit('close')
emitFormBlur()
}
@@ -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 {
@@ -598,6 +658,7 @@
label,
accessor,
isLeading,
onClear,
isTrailing,
// eslint-disable-next-line vue/no-dupe-keys
selectClass,
@@ -612,7 +673,11 @@
// eslint-disable-next-line vue/no-dupe-keys
query,
onUpdate,
onQueryChange
onQueryChange,
trailingSlotProps,
canClearValue,
clearableWrapperClass,
clearableButtonClass
}
}
})
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.