Skip to content

feat(SelectMenu): add clearable icon to reset modelValue #3244

New issue

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

By clicking β€œ#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 13 commits into
base: v3
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
65 changes: 65 additions & 0 deletions docs/content/3.components/select-menu.md
Original file line number Diff line number Diff line change
@@ -457,6 +457,71 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.ch
:::
::

### Clear :badge{label="Not released" class="align-text-top"}

Use the `clear` prop to add a clear icon to reset the model value.

::component-code
---
prettier: true
ignore:
- items
- modelValue
- class
external:
- items
- modelValue
props:
modelValue: 'Backlog'
clear: true
items:
- Backlog
- Todo
- In Progress
- Done
class: 'w-48'
---
::

### Clear Icon :badge{label="Not released" class="align-text-top"}

Use the `clear-icon` prop to customize the clear icon. Defaults to `i-lucide-x`.

::component-code
---
prettier: true
ignore:
- items
- modelValue
- class
external:
- items
- modelValue
props:
modelValue: 'Backlog'
clear: true
clearIcon: 'i-lucide-trash'
items:
- Backlog
- Todo
- In Progress
- Done
class: 'w-48'
---
::

::framework-only
#nuxt
:::tip{to="/getting-started/icons/nuxt#theme"}
You can customize this icon globally in your `app.config.ts` under `ui.icons.close` key.
:::

#vue
:::tip{to="/getting-started/icons/vue#theme"}
You can customize this icon globally in your `vite.config.ts` under `ui.icons.close` key.
:::
::

### Selected Icon

Use the `selected-icon` prop to customize the icon when an item is selected. Defaults to `i-lucide-check`.
4 changes: 4 additions & 0 deletions playground/app/pages/components/select-menu.vue
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']

const items = [[{ label: 'Fruits', type: 'label' }, ...fruits], [{ label: 'Vegetables', type: 'label' }, ...vegetables]] satisfies SelectMenuItem[][]
const selectedItems = ref([fruits[0]!, vegetables[0]!])
const selectedItem = ref(fruits[0]!)

const statuses = [{
label: 'Backlog',
@@ -91,6 +92,8 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<USelectMenu :items="items" placeholder="Disabled" disabled />
<USelectMenu :items="items" placeholder="Required" required />
<USelectMenu v-model="selectedItems" :items="items" placeholder="Multiple" multiple />
<USelectMenu v-model="selectedItem" :items="items" clear placeholder="Clear" />
<USelectMenu v-model="selectedItems" :items="items" placeholder="Clear Multiple" multiple clear />
<USelectMenu :items="items" loading placeholder="Search..." />
</div>
<div class="flex items-center gap-4">
@@ -100,6 +103,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
:items="items"
placeholder="Search..."
:size="size"
clear
class="w-48"
/>
</div>
36 changes: 35 additions & 1 deletion src/runtime/components/SelectMenu.vue
Original file line number Diff line number Diff line change
@@ -51,6 +51,16 @@ export interface SelectMenuProps<T extends ArrayOrNested<SelectMenuItem> = Array
*/
size?: SelectMenu['variants']['size']
required?: boolean
/**
* Determines if user can clear the `modelValue` with icon click
* @defaultValue false
*/
clear?: boolean
/**
* The icon displayed to clear the value.
* @defaultValue appConfig.ui.icons.close
*/
clearIcon?: string
/**
* The icon displayed to open the menu.
* @defaultValue appConfig.ui.icons.chevronDown
@@ -185,6 +195,7 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<SelectMenuProps<T, VK, M>>(), {
portal: true,
searchInput: true,
clear: false,
labelKey: 'label' as never,
resetSearchTermOnBlur: true,
resetSearchTermOnSelect: true
@@ -236,6 +247,13 @@ function displayValue(value: GetItemValue<T, VK> | GetItemValue<T, VK>[]): strin
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
}

const isEmpty = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.length === 0
}
return !(props.modelValue)
})

const groups = computed<SelectMenuItem[][]>(() =>
props.items?.length
? isArrayOfArray(props.items)
@@ -338,6 +356,11 @@ function onSelect(e: Event, item: SelectMenuItem) {
item.onSelect?.(e)
}

function onClear() {
const newValue = props.multiple ? [] : null
emits('update:modelValue', newValue as GetModelValue<T, VK, M>)
}

function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
return typeof item === 'object' && item !== null
}
@@ -394,7 +417,18 @@ function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {

<span v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })">
<slot name="trailing" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui">
<UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
<UIcon
v-if="props.clear && !isEmpty"
:name="clearIcon || appConfig.ui.icons.close"
:class="ui.trailingIcon({
class: [
props.ui?.trailingIcon,
ui.clearIcon({ class: props.ui?.clearIcon })
]
})"
@click.prevent.stop="onClear()"
/>
<UIcon v-else-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot>
</span>
</ComboboxTrigger>
1 change: 1 addition & 0 deletions src/theme/select-menu.ts
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ export default (options: Required<ModuleOptions>) => {
slots: {
input: 'border-b border-default',
focusScope: 'flex flex-col min-h-0',
clearIcon: ['hover:text-default', options.theme.transitions && 'transition-colors'],
content: (content: string) => [content, 'origin-(--reka-combobox-content-transform-origin) w-(--reka-combobox-trigger-width)']
}
}, select(options))
1 change: 1 addition & 0 deletions test/components/SelectMenu.spec.ts
Original file line number Diff line number Diff line change
@@ -50,6 +50,7 @@
['without searchInput', { props: { ...props, searchInput: false } }],
['with searchInput placeholder', { props: { ...props, searchInput: { placeholder: 'Filter items...' } } }],
['with searchInput icon', { props: { ...props, searchInput: { icon: 'i-lucide-search' } } }],
['with clear', { props: { clear: true } }],
['with disabled', { props: { ...props, disabled: true } }],
['with required', { props: { ...props, required: true } }],
['with icon', { props: { icon: 'i-lucide-search' } }],
@@ -85,7 +86,7 @@
['with create-item-label slot', { props: { ...props, searchTerm: 'New value', createItem: true }, slots: { 'create-item-label': () => 'Create item slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: SelectMenuProps, slots?: Partial<SelectMenuSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, SelectMenu)
expect(html).toMatchSnapshot()

Check failure on line 89 in test/components/SelectMenu.spec.ts

GitHub Actions / build (ubuntu-latest, 22)

test/components/SelectMenu.spec.ts > SelectMenu > renders with clear correctly

Error: Snapshot `SelectMenu > renders with clear correctly 1` mismatched ❯ test/components/SelectMenu.spec.ts:89:18
})

describe('emits', () => {
9 changes: 9 additions & 0 deletions test/components/__snapshots__/SelectMenu.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -164,6 +164,15 @@ exports[`SelectMenu > renders with class correctly 1`] = `
<!---->"
`;

exports[`SelectMenu > renders with clear correctly 1`] = `
"<button type="button" tabindex="0" aria-label="Show popup" aria-haspopup="listbox" aria-expanded="false" aria-controls="" data-state="closed" aria-disabled="false" class="relative group rounded-md inline-flex items-center focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-highlighted bg-default ring ring-inset ring-accented focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary pe-9" dir="ltr">
<!--v-if--><span class="truncate text-dimmed">&nbsp;</span><span class="absolute inset-y-0 end-0 flex items-center pe-2.5"><span class="iconify i-lucide:chevron-down shrink-0 text-dimmed size-5" aria-hidden="true"></span></span>
</button>
<!--teleport start-->
<!--teleport end-->
<!---->"
`;

exports[`SelectMenu > renders with create-item-label slot correctly 1`] = `
"<button type="button" tabindex="0" aria-label="Show popup" aria-haspopup="listbox" aria-expanded="true" aria-controls="" data-state="open" aria-disabled="false" class="relative group rounded-md inline-flex items-center focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-highlighted bg-default ring ring-inset ring-accented focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary pe-9" dir="ltr" style="pointer-events: auto;">
<!--v-if--><span class="truncate text-dimmed">&nbsp;</span><span class="absolute inset-y-0 end-0 flex items-center pe-2.5"><span class="iconify i-lucide:chevron-down shrink-0 text-dimmed size-5" aria-hidden="true"></span></span>
Loading