mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-15 12:39:35 +01:00
Compare commits
5 Commits
v2
...
issue-1057
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21939ed333 | ||
|
|
5a414eb55a | ||
|
|
3a5960fb58 | ||
|
|
a78203ce49 | ||
|
|
592da565fe |
102
docs/components/content/examples/SelectMenuExampleClearable.vue
Normal file
102
docs/components/content/examples/SelectMenuExampleClearable.vue
Normal file
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
---
|
||||
::
|
||||
|
||||
|
||||
@@ -39,6 +39,18 @@
|
||||
<span v-if="label" :class="uiMenu.label">{{ label }}</span>
|
||||
<span v-else :class="uiMenu.label">{{ placeholder || ' ' }}</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 { useFormGroup } from '../../composables/useFormGroup'
|
||||
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'
|
||||
// @ts-expect-error
|
||||
import appConfig from '#build/app.config'
|
||||
import { select, selectMenu } from '#ui/ui.config'
|
||||
@@ -333,9 +346,18 @@ export default defineComponent({
|
||||
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 @@ export default defineComponent({
|
||||
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 @@ export default defineComponent({
|
||||
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 @@ export default defineComponent({
|
||||
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 @@ export default defineComponent({
|
||||
if (value) {
|
||||
emit('open')
|
||||
} else {
|
||||
clearOnClose()
|
||||
handleClearSearchOnClose()
|
||||
emit('close')
|
||||
emitFormBlur()
|
||||
}
|
||||
@@ -579,6 +617,28 @@ export default defineComponent({
|
||||
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 @@ export default defineComponent({
|
||||
label,
|
||||
accessor,
|
||||
isLeading,
|
||||
onClear,
|
||||
isTrailing,
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
selectClass,
|
||||
@@ -612,7 +673,11 @@ export default defineComponent({
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
query,
|
||||
onUpdate,
|
||||
onQueryChange
|
||||
onQueryChange,
|
||||
trailingSlotProps,
|
||||
canClearValue,
|
||||
clearableWrapperClass,
|
||||
clearableButtonClass
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,6 +98,18 @@ export default {
|
||||
'lg': 'px-3.5',
|
||||
'xl': 'px-3.5'
|
||||
}
|
||||
},
|
||||
clearable: {
|
||||
wrapper: 'absolute inset-y-0 end-6 flex items-center',
|
||||
pointer: 'pointer-events-auto',
|
||||
padding: {
|
||||
'2xs': 'px-2',
|
||||
'xs': 'px-2.5',
|
||||
'sm': 'px-2.5',
|
||||
'md': 'px-3',
|
||||
'lg': 'px-3.5',
|
||||
'xl': 'px-3.5'
|
||||
}
|
||||
}
|
||||
},
|
||||
default: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
color: 'white',
|
||||
variant: 'outline',
|
||||
loadingIcon: 'i-heroicons-arrow-path-20-solid',
|
||||
trailingIcon: 'i-heroicons-chevron-down-20-solid'
|
||||
trailingIcon: 'i-heroicons-chevron-down-20-solid',
|
||||
clerableIcon: 'i-heroicons-x-mark-20-solid'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user