Skip to content

Commit 22443c2

Browse files
committed
feat: add teleport on the menu
1 parent 6034d73 commit 22443c2

File tree

2 files changed

+66
-22
lines changed

2 files changed

+66
-22
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ Whether the select should allow multiple selections. If `true`, the `v-model` sh
9393

9494
Whether the dropdown should close after an option is selected.
9595

96+
**teleport**: `string` (default: `undefined`)
97+
98+
Teleport the menu outside of the component DOM tree. You can pass a valid string according to the official Vue 3 Teleport documentation (e.g. `teleport="body"` will teleport the menu into the `<body>` tree). This can be used in case you are having `z-index` issues within your DOM tree structure.
99+
100+
**Note**: top and left properties are calculated using a ref on the `.vue-select` with a `container.getBoundingClientRect()`.
101+
96102
**getOptionLabel**: `(option: Option) => string` (default: `option => option.label`)
97103

98104
A function to get the label of an option. This is useful when you want to use a property different from `label` as the label of the option.

src/Select.vue

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ const props = withDefaults(
3939
* When set to true, clear the search input when an option is selected.
4040
*/
4141
closeOnSelect?: boolean;
42+
/**
43+
* Teleport the menu to another part of the DOM with higher priority such as `body`.
44+
* This way, you can avoid z-index issues. Menu position will be calculated using
45+
* JavaScript, instead of using CSS absolute & relative positioning.
46+
*/
47+
teleport?: string;
4248
/**
4349
* A function to get the label of an option. By default, it assumes the option is an
4450
* object with a `label` property. Used to display the selected option in the input &
@@ -63,6 +69,7 @@ const props = withDefaults(
6369
isSearchable: true,
6470
isMulti: false,
6571
closeOnSelect: true,
72+
teleport: undefined,
6673
getOptionLabel: (option: Option) => option.label,
6774
getMultiValueLabel: (option: Option) => option.label,
6875
},
@@ -210,6 +217,21 @@ const handleClickOutside = (event: MouseEvent) => {
210217
}
211218
};
212219
220+
const calculateMenuPosition = () => {
221+
if (container.value) {
222+
const rect = container.value.getBoundingClientRect();
223+
224+
return {
225+
left: `${rect.x}px`,
226+
top: `${rect.y + rect.height}px`,
227+
};
228+
}
229+
230+
console.warn("Unable to calculate dynamic menu position because of missing internal DOM reference.");
231+
232+
return { top: "0px", left: "0px" };
233+
};
234+
213235
onMounted(() => {
214236
document.addEventListener("click", handleClickOutside);
215237
document.addEventListener("keydown", handleNavigation);
@@ -304,33 +326,43 @@ onBeforeUnmount(() => {
304326
</div>
305327
</div>
306328

307-
<div v-if="menuOpen" class="menu">
308-
<MenuOption
309-
v-for="(option, i) in filteredOptions"
310-
:key="option.value"
311-
type="button"
312-
class="menu-option"
313-
:class="{ focused: focusedOption === i, selected: option.value === selected }"
314-
:is-focused="focusedOption === i"
315-
:is-selected="option.value === selected"
316-
@select="setOption(option.value)"
329+
<Teleport :to="teleport" :disabled="!teleport">
330+
<div
331+
v-if="menuOpen"
332+
class="menu"
333+
:style="{
334+
width: props.teleport ? `${container?.getBoundingClientRect().width}px` : '100%',
335+
top: props.teleport ? calculateMenuPosition().top : 'unset',
336+
left: props.teleport ? calculateMenuPosition().left : 'unset',
337+
}"
317338
>
318-
<slot name="option" :option="option">
319-
{{ getOptionLabel(option) }}
320-
</slot>
321-
</MenuOption>
322-
323-
<div v-if="filteredOptions.length === 0" class="no-results">
324-
<slot name="no-options">
325-
No results found
326-
</slot>
339+
<MenuOption
340+
v-for="(option, i) in filteredOptions"
341+
:key="option.value"
342+
type="button"
343+
class="menu-option"
344+
:class="{ focused: focusedOption === i, selected: option.value === selected }"
345+
:is-focused="focusedOption === i"
346+
:is-selected="option.value === selected"
347+
@select="setOption(option.value)"
348+
>
349+
<slot name="option" :option="option">
350+
{{ getOptionLabel(option) }}
351+
</slot>
352+
</MenuOption>
353+
354+
<div v-if="filteredOptions.length === 0" class="no-results">
355+
<slot name="no-options">
356+
No results found
357+
</slot>
358+
</div>
327359
</div>
328-
</div>
360+
</Teleport>
329361
</div>
330362
</template>
331363

332-
<style lang="scss" scoped>
333-
.vue-select {
364+
<style>
365+
:root {
334366
--vs-input-bg: #fff;
335367
--vs-input-outline: #3b82f6;
336368
--vs-padding: 0.25rem 0.5rem;
@@ -348,6 +380,7 @@ onBeforeUnmount(() => {
348380
--vs-menu-border: 1px solid #e4e4e7;
349381
--vs-menu-bg: #fff;
350382
--vs-menu-box-shadow: none;
383+
--vs-menu-z-index: 1;
351384
352385
--vs-option-padding: 8px 12px;
353386
--vs-option-font-size: var(--vs-font-size);
@@ -373,7 +406,11 @@ onBeforeUnmount(() => {
373406
--vs-icon-color: var(--vs-text-color);
374407
375408
--vs-dropdown-transition: transform 0.25s ease-out;
409+
}
410+
</style>
376411

412+
<style lang="scss" scoped>
413+
.vue-select {
377414
position: relative;
378415
box-sizing: border-box;
379416
width: 100%;
@@ -528,6 +565,7 @@ onBeforeUnmount(() => {
528565
border-radius: var(--vs-border-radius);
529566
box-shadow: var(--vs-menu-box-shadow);
530567
background-color: var(--vs-menu-bg);
568+
z-index: var(--vs-menu-z-index);
531569
}
532570
533571
.menu-option {

0 commit comments

Comments
 (0)