Skip to content

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

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

Open
wants to merge 13 commits into
base: v3
Choose a base branch
from
Open
65 changes: 65 additions & 0 deletions docs/content/3.components/select-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions playground/app/pages/components/select-menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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">
Expand All @@ -100,6 +103,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
:items="items"
placeholder="Search..."
:size="size"
clear
class="w-48"
/>
</div>
Expand Down
36 changes: 35 additions & 1 deletion src/runtime/components/SelectMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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>
Expand Down
1 change: 1 addition & 0 deletions src/theme/select-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions test/components/SelectMenu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }],
Expand Down Expand Up @@ -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

View workflow job for this annotation

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', () => {
Expand Down
9 changes: 9 additions & 0 deletions test/components/__snapshots__/SelectMenu.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading