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

Merged
merged 13 commits into from
May 23, 2025
Merged
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
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export default defineConfig({
{ text: "Custom displayed options", link: "/demo/custom-displayed-options" },
{ text: "Controlled menu", link: "/demo/controlled-menu" },
{ text: "Pre-selected values", link: "/demo/pre-selected-values" },
{ text: "Infinite scroll", link: "/demo/infinite-scroll" },
],
},
],
Expand Down
108 changes: 108 additions & 0 deletions docs/demo/infinite-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
title: 'Infinite Scroll'
---

# Infinite Scroll

Due to VitePress restriction, it's not possible to show the infinite scroll demo here.

Please refer to the [demo component source-code](https://github.com/TotomInc/vue3-select-component/blob/8241306c7ddcb9840ea55ffbea9e45b59b80fbdc/playground/demos/InfiniteScroll.vue).

```vue
<script setup lang="ts">
import type { Option } from "../../src";
import { ref } from "vue";
import VueSelect from "../../src";

// Track the selected value
const selected = ref<string | null>(null);

// Track loading state when fetching more options
const isLoading = ref(false);

// Track current page for pagination
const currentPage = ref(1);

// Store all available options
const options = ref<Option<string>[]>([]);

// Simulate an API call to fetch more books
const fetchMoreBooks = async (page: number) => {
isLoading.value = true;

// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 500));

// Generate 10 new books for each page
const newBooks = Array.from({ length: 10 }, (_, i) => {
const bookNumber = (page - 1) * 10 + i + 1;
return {
label: `Book #${bookNumber}`,
value: `book_${bookNumber}`,
};
});

options.value = [...options.value, ...newBooks];
currentPage.value = page;
isLoading.value = false;
};

// Handle scroll event in the menu container
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
const isAtBottom = target.scrollHeight - target.scrollTop === target.clientHeight;

if (isAtBottom && !isLoading.value) {
fetchMoreBooks(currentPage.value + 1);
}
};

// Load initial data
fetchMoreBooks(1);
</script>

<template>
<VueSelect
v-model="selected"
:options="options"
:is-multi="false"
placeholder="Pick a book"
:classes="{ menuContainer: 'custom-menu-container' }"
>
<template #menu-container="{ defaultContent }">
<div
class="infinite-scroll-container"
@scroll="handleScroll"
>
<component :is="defaultContent" />

<div v-if="isLoading" class="loading-indicator">
Loading more books...
</div>
</div>
</template>
</VueSelect>

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

<style scoped>
:deep(.custom-menu-container) {
max-height: 202px;
}

.infinite-scroll-container {
max-height: 200px;
overflow-y: auto;
}

.loading-indicator {
text-align: center;
padding: 8px;
font-size: 14px;
color: #666;
}
</style>
```
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
Loading