Skip to content

feat(select): introduce menu-container slot for better flexibility over menu behavior #272

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

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
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
36 changes: 33 additions & 3 deletions docs/slots.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,36 @@ Customize the rendered template for the menu header. This slot is placed **befor
</template>
```

## menu-container

**Type**: `slotProps: { defaultContent: JSX.Element }`

Wrap the entire menu content with a custom container without disrupting the default behavior. This slot is particularly useful for implementing advanced scrolling techniques such as:

- Virtual scrolling for large option lists
- Infinite scrolling to dynamically load more options
- Custom scrollbars or scroll behavior
- Any other UI enhancements that need to wrap the menu options

The `defaultContent` prop is a render function that returns all the default menu content (options, no-options message, etc.). You must call this function within your custom implementation to preserve the component's original content and functionality.

```vue
<template>
<VueSelect v-model="option" :options="options">
<template #menu-container="{ defaultContent }">
<MyVirtualScroller>
<!-- Render the default menu content inside your custom container -->
<component :is="defaultContent" />
</MyVirtualScroller>
</template>
</VueSelect>
</template>
```

::: tip
This slot doesn't replace individual option customization. For that, use the `option` slot. The `menu-container` slot is specifically for wrapping the entire menu content.
:::

## no-options

**Type**: `slotProps: {}`
Expand Down Expand Up @@ -160,7 +190,7 @@ Customize the rendered template when the select component is in a loading state.

## taggable-no-options

**Type**: `slotProps: { option: string }`
**Type**: `slotProps: { value: string }`

Customize the rendered template when there are no matching options and the `taggable` prop is set to `true`. You can use the slot props to retrieve the current search value.

Expand All @@ -171,8 +201,8 @@ Customize the rendered template when there are no matching options and the `tagg
:options="options"
:taggable="true"
>
<template #taggable-no-options="{ option }">
Press enter to add {{ option }} option
<template #taggable-no-options="{ value }">
Press enter to add {{ value }} option
</template>
</VueSelect>
</template>
Expand Down
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@types/jsdom": "21.1.7",
"@types/node": "22.15.14",
"@vitejs/plugin-vue": "5.2.3",
"@vitejs/plugin-vue-jsx": "4.1.2",
"@vitest/coverage-v8": "3.1.3",
"@vue/test-utils": "2.4.6",
"@vue/tsconfig": "0.7.0",
Expand Down
3 changes: 2 additions & 1 deletion playground/PlaygroundLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ const links = [
{ value: "/single-select", label: "Single Select" },
{ value: "/multi-select", label: "Multi Select" },
{ value: "/multi-select-taggable", label: "Multi Select Taggable" },
{ value: "/extra-option-properties", label: "Extra Option Properties" },
{ value: "/custom-menu-container", label: "Custom Menu Container" },
{ value: "/custom-menu-option", label: "Custom Menu Option" },
{ value: "/custom-option-label-value", label: "Custom Option Label/Value" },
{ value: "/custom-search-filter", label: "Custom Search Filter" },
{ value: "/select-is-loading", label: "Select isLoading" },
Expand Down
42 changes: 42 additions & 0 deletions playground/demos/CustomMenuContainer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { Option } from "../../src";
import { ref } from "vue";
import VueSelect from "../../src";

const selected = ref<string | null>(null);

const options: Option<string>[] = [
{ label: "Alice's Adventures in Wonderland", value: "alice" },
{ label: "A Wizard of Earthsea", value: "wizard" },
{ label: "Harry Potter and the Philosopher's Stone", value: "harry_potter_1" },
{ label: "Harry Potter and the Chamber of Secrets", value: "harry_potter_2" },
];
</script>

<template>
<VueSelect
v-model="selected"
:options="options"
:is-multi="false"
placeholder="Pick a book"
>
<template #menu-container="{ defaultContent }">
<div class="custom-menu-container">
<template v-if="defaultContent">
<component :is="defaultContent" />
</template>
</div>
</template>
</VueSelect>

<p class="selected-value">
Selected book value: {{ selected || "none" }}
</p>
</template>

<style lang="css" scoped>
.custom-menu-container {
background-color: #eff6ff;
padding: 4px;
}
</style>
44 changes: 44 additions & 0 deletions playground/demos/CustomMenuOption.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { Option } from "../../src";
import { ref } from "vue";
import VueSelect from "../../src";

const selected = ref<string | null>(null);

const options: Option<string>[] = [
{ label: "France", value: "FR", countryFlag: "🇫🇷" },
{ label: "Germany", value: "DE", countryFlag: "🇩🇪" },
{ label: "Spain", value: "ES", countryFlag: "🇪🇺" },
{ label: "Italy", value: "IT", countryFlag: "🇮🇹" },
{ label: "United States", value: "US", countryFlag: "🇺🇸" },
{ label: "United Kingdom", value: "GB", countryFlag: "🇬🇧" },
{ label: "Canada", value: "CA", countryFlag: "🇨🇦" },
{ label: "Australia", value: "AU", countryFlag: "🇦🇺" },
{ label: "New Zealand", value: "NZ", countryFlag: "🇳🇿" },
{ label: "Brazil", value: "BR", countryFlag: "🇧🇷" },
{ label: "Argentina", value: "AR", countryFlag: "🇦🇷" },
{ label: "Chile", value: "CL", countryFlag: "🇨🇱" },
];
</script>

<template>
<VueSelect
v-model="selected"
:options="options"
:is-multi="false"
:is-taggable="true"
placeholder="Pick a country"
>
<template #option="{ option }">
{{ option.label }} {{ option.countryFlag }} <small>({{ option.value }})</small>
</template>

<template #value="{ option }">
{{ option.label }} {{ option.countryFlag }}
</template>
</VueSelect>

<p class="selected-value">
Selected country value: {{ selected || "none" }}
</p>
</template>
41 changes: 0 additions & 41 deletions playground/demos/ExtraOptionProperties.vue

This file was deleted.

9 changes: 1 addition & 8 deletions playground/demos/SingleSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,8 @@ const options: Option<string>[] = [
v-model="selected"
:options="options"
:is-multi="false"
:is-taggable="true"
placeholder="Pick a book"
>
<template #taggable-no-options="{ option }">
<div class="custom-taggable-no-options">
Create option: {{ option }}
</div>
</template>
</VueSelect>
/>

<p class="selected-value">
Selected book value: {{ selected || "none" }}
Expand Down
6 changes: 4 additions & 2 deletions playground/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";

import ControlledMenu from "./demos/ControlledMenu.vue";
import CustomMenuContainer from "./demos/CustomMenuContainer.vue";
import CustomMenuOption from "./demos/CustomMenuOption.vue";
import CustomOptionLabelValue from "./demos/CustomOptionLabelValue.vue";
import CustomSearchFilter from "./demos/CustomSearchFilter.vue";
import ExtraOptionProperties from "./demos/ExtraOptionProperties.vue";
import MenuHeader from "./demos/MenuHeader.vue";
import MultiSelect from "./demos/MultiSelect.vue";
import MultiSelectTaggable from "./demos/MultiSelectTaggable.vue";
Expand All @@ -20,7 +21,8 @@ const router = createRouter({
{ path: "/single-select", component: SingleSelect },
{ path: "/multi-select", component: MultiSelect },
{ path: "/multi-select-taggable", component: MultiSelectTaggable },
{ path: "/extra-option-properties", component: ExtraOptionProperties },
{ path: "/custom-menu-container", component: CustomMenuContainer },
{ path: "/custom-menu-option", component: CustomMenuOption },
{ path: "/custom-option-label-value", component: CustomOptionLabelValue },
{ path: "/custom-search-filter", component: CustomSearchFilter },
{ path: "/select-is-loading", component: SelectIsLoading },
Expand Down
30 changes: 30 additions & 0 deletions src/Menu.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
:global(*) {
box-sizing: border-box;
}

.menu {
position: absolute;
margin-top: var(--vs-menu-offset-top);
max-height: var(--vs-menu-height);
overflow-y: auto;
border: var(--vs-menu-border);
border-radius: var(--vs-border-radius);
box-shadow: var(--vs-menu-box-shadow);
background-color: var(--vs-menu-background-color);
z-index: var(--vs-menu-z-index);
}

.no-results {
padding: var(--vs-option-padding);
font-size: var(--vs-font-size);
font-family: var(--vs-font-family);
color: var(--vs-text-color);
}

.taggable-no-options {
padding: var(--vs-option-padding);
font-size: var(--vs-font-size);
font-family: var(--vs-font-family);
color: var(--vs-text-color);
cursor: pointer;
}
Loading