Skip to content

Commit 482d85b

Browse files
committed
feat(select): add menu positioning data attribute (#353)
1 parent d51d603 commit 482d85b

File tree

6 files changed

+240
-2
lines changed

6 files changed

+240
-2
lines changed

docs/styling.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,70 @@ You can also use the `:deep` selector to apply the CSS variables to the componen
123123
</style>
124124
```
125125

126+
## Menu positioning data attribute
127+
128+
The dropdown menu automatically includes a `data-state-position` attribute that reflects the current position of the menu relative to the select control. This attribute is powered by [Floating UI](https://floating-ui.com/) and updates dynamically when the menu flips or adjusts its position to stay within the viewport.
129+
130+
::: tip
131+
The component automatically adjusts the menu offset based on position. When the menu opens below (`bottom-*`), it uses `margin-top`, and when it opens above (`top-*`), it uses `margin-bottom` to maintain consistent spacing.
132+
:::
133+
134+
### Available positions
135+
136+
The `data-state-position` attribute can have the following values:
137+
138+
- `bottom-start` - Default position, menu below the control aligned to the left
139+
- `bottom-end` - Menu below the control aligned to the right
140+
- `top-start` - Menu above the control aligned to the left (when flipped)
141+
- `top-end` - Menu above the control aligned to the right (when flipped)
142+
- Other [Floating UI placement values](https://floating-ui.com/docs/computeposition#placement)
143+
144+
### Styling based on position
145+
146+
You can use this data attribute to apply position-specific styles:
147+
148+
```css
149+
/* Different border radius when menu opens upward */
150+
.menu[data-state-position^="top"] {
151+
border-bottom-left-radius: 0;
152+
border-bottom-right-radius: 0;
153+
}
154+
155+
.menu[data-state-position^="bottom"] {
156+
border-top-left-radius: 0;
157+
border-top-right-radius: 0;
158+
}
159+
160+
/* Add arrow indicator based on position */
161+
.menu[data-state-position="bottom-start"]::before {
162+
content: "";
163+
position: absolute;
164+
top: -8px;
165+
left: 20px;
166+
border: 4px solid transparent;
167+
border-bottom-color: var(--vs-menu-background-color);
168+
}
169+
170+
.menu[data-state-position="top-start"]::before {
171+
content: "";
172+
position: absolute;
173+
bottom: -8px;
174+
left: 20px;
175+
border: 4px solid transparent;
176+
border-top-color: var(--vs-menu-background-color);
177+
}
178+
```
179+
180+
With TailwindCSS, you can use arbitrary values:
181+
182+
```vue
183+
<VueSelect
184+
:classes="{
185+
menuContainer: '[&[data-state-position^=top]]:rounded-t-none [&[data-state-position^=bottom]]:rounded-b-none'
186+
}"
187+
/>
188+
```
189+
126190
## Custom classes with TailwindCSS
127191

128192
The component provides a `classes` prop that allows you to apply custom TailwindCSS classes to different parts of the select component. This is particularly useful when you want to customize the appearance without overriding the default CSS variables.

playground/PlaygroundLayout.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const links = [
1616
{ value: "/taggable-no-options-slot", label: "Taggable No Options Slot" },
1717
{ value: "/controlled-menu", label: "Controlled Menu" },
1818
{ value: "/menu-header", label: "Menu Header" },
19+
{ value: "/menu-positioning", label: "Menu Positioning" },
1920
];
2021
2122
const router = useRouter();
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<script setup lang="ts">
2+
import { ref } from "vue";
3+
import VueSelect from "../../src/Select.vue";
4+
5+
const options = [
6+
{ label: "France", value: "FR" },
7+
{ label: "Germany", value: "DE" },
8+
{ label: "Spain", value: "ES" },
9+
{ label: "Italy", value: "IT" },
10+
{ label: "Portugal", value: "PT" },
11+
];
12+
13+
const selected = ref<string | null>(null);
14+
</script>
15+
16+
<template>
17+
<div class="demo-container">
18+
<h2>Menu Positioning with data-state-position</h2>
19+
<p>
20+
The dropdown menu includes a <code>data-state-position</code> attribute that reflects
21+
its current position. This demo shows how the attribute changes based on available space.
22+
</p>
23+
24+
<div class="demo-section">
25+
<h3>Normal Position (bottom-start)</h3>
26+
<p>Scroll down to see the menu flip to the top when there's no space below.</p>
27+
<VueSelect
28+
v-model="selected"
29+
:options="options"
30+
placeholder="Select a country"
31+
class="custom-position-demo"
32+
/>
33+
</div>
34+
35+
<div style="height: 100vh; display: flex; align-items: center;">
36+
<div class="demo-section">
37+
<h3>Middle of Page</h3>
38+
<p>The menu should open downward here.</p>
39+
<VueSelect
40+
v-model="selected"
41+
:options="options"
42+
placeholder="Select a country"
43+
class="custom-position-demo"
44+
/>
45+
</div>
46+
</div>
47+
48+
<div style="height: 100vh; display: flex; align-items: flex-end; padding-bottom: 50px;">
49+
<div class="demo-section">
50+
<h3>Bottom of Page (menu flips to top-start)</h3>
51+
<p>The menu should open upward here with different styling.</p>
52+
<VueSelect
53+
v-model="selected"
54+
:options="options"
55+
placeholder="Select a country"
56+
class="custom-position-demo"
57+
/>
58+
</div>
59+
</div>
60+
</div>
61+
</template>
62+
63+
<style scoped>
64+
.demo-container {
65+
padding: 20px;
66+
max-width: 600px;
67+
}
68+
69+
.demo-section {
70+
margin-bottom: 30px;
71+
width: 100%;
72+
}
73+
74+
h2 {
75+
margin-bottom: 10px;
76+
}
77+
78+
h3 {
79+
margin-top: 20px;
80+
margin-bottom: 10px;
81+
font-size: 18px;
82+
}
83+
84+
p {
85+
margin-bottom: 15px;
86+
color: #666;
87+
}
88+
89+
code {
90+
background-color: #f4f4f5;
91+
padding: 2px 6px;
92+
border-radius: 3px;
93+
font-family: monospace;
94+
font-size: 14px;
95+
}
96+
97+
:deep(.custom-position-demo) {
98+
--vs-border-radius: 8px;
99+
}
100+
101+
/* Style menu differently based on position */
102+
:deep(.menu[data-state-position^="top"]) {
103+
border-bottom-left-radius: 0;
104+
border-bottom-right-radius: 0;
105+
border-top-left-radius: 8px;
106+
border-top-right-radius: 8px;
107+
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1);
108+
}
109+
110+
:deep(.menu[data-state-position^="bottom"]) {
111+
border-top-left-radius: 0;
112+
border-top-right-radius: 0;
113+
border-bottom-left-radius: 8px;
114+
border-bottom-right-radius: 8px;
115+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
116+
}
117+
118+
/* Add visual indicator for position */
119+
:deep(.menu[data-state-position="bottom-start"]::before) {
120+
content: "↓ bottom-start";
121+
position: absolute;
122+
top: 4px;
123+
right: 8px;
124+
font-size: 10px;
125+
color: #999;
126+
font-weight: 500;
127+
text-transform: uppercase;
128+
letter-spacing: 0.5px;
129+
}
130+
131+
:deep(.menu[data-state-position="top-start"]::before) {
132+
content: "↑ top-start";
133+
position: absolute;
134+
bottom: 4px;
135+
right: 8px;
136+
font-size: 10px;
137+
color: #999;
138+
font-weight: 500;
139+
text-transform: uppercase;
140+
letter-spacing: 0.5px;
141+
}
142+
</style>

playground/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import CustomPlaceholder from "./demos/CustomPlaceholder.vue";
77
import CustomSearchFilter from "./demos/CustomSearchFilter.vue";
88
import ExtraOptionProperties from "./demos/ExtraOptionProperties.vue";
99
import MenuHeader from "./demos/MenuHeader.vue";
10+
import MenuPositioning from "./demos/MenuPositioning.vue";
1011
import MultiSelect from "./demos/MultiSelect.vue";
1112
import MultiSelectTaggable from "./demos/MultiSelectTaggable.vue";
1213
import SelectIsLoading from "./demos/SelectIsLoading.vue";
@@ -29,6 +30,7 @@ const router = createRouter({
2930
{ path: "/taggable-no-options-slot", component: TaggableNoOptionsSlot },
3031
{ path: "/controlled-menu", component: ControlledMenu },
3132
{ path: "/menu-header", component: MenuHeader },
33+
{ path: "/menu-positioning", component: MenuPositioning },
3234
],
3335
});
3436

src/Menu.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const sharedData = inject<DataInjection<GenericOption, OptionValue>>(DATA_KEY)!;
2121
2222
const menuRef = useTemplateRef("menu");
2323
24-
const { floatingStyles } = useFloating(sharedData.containerRef, menuRef, {
24+
const { floatingStyles, placement } = useFloating(sharedData.containerRef, menuRef, {
2525
whileElementsMounted: autoUpdate,
2626
placement: "bottom-start",
2727
middleware: [
@@ -142,6 +142,7 @@ onBeforeUnmount(() => {
142142
role="listbox"
143143
:aria-label="sharedProps.aria?.labelledby"
144144
:aria-multiselectable="sharedProps.isMulti"
145+
:data-state-position="placement"
145146
:style="{
146147
...floatingStyles,
147148
}"
@@ -218,7 +219,6 @@ onBeforeUnmount(() => {
218219
219220
.menu {
220221
position: absolute;
221-
margin-top: var(--vs-menu-offset-top);
222222
max-height: var(--vs-menu-height);
223223
overflow-y: auto;
224224
border: var(--vs-menu-border);
@@ -228,6 +228,14 @@ onBeforeUnmount(() => {
228228
z-index: var(--vs-menu-z-index);
229229
}
230230
231+
.menu[data-state-position^="bottom"] {
232+
margin-top: var(--vs-menu-offset-top);
233+
}
234+
235+
.menu[data-state-position^="top"] {
236+
margin-bottom: var(--vs-menu-offset-top);
237+
}
238+
231239
.no-results {
232240
padding: var(--vs-option-padding);
233241
font-size: var(--vs-font-size);

src/Select.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,27 @@ describe("hideSelectedOptions prop", () => {
755755
});
756756
});
757757

758+
describe("menu positioning data attribute", () => {
759+
it("should have data-state-position attribute on menu element", async () => {
760+
const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
761+
762+
await openMenu(wrapper);
763+
764+
const menu = wrapper.find(".menu");
765+
expect(menu.exists()).toBe(true);
766+
expect(menu.attributes("data-state-position")).toBeDefined();
767+
});
768+
769+
it("should set data-state-position to bottom-start by default", async () => {
770+
const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
771+
772+
await openMenu(wrapper);
773+
774+
const menu = wrapper.find(".menu");
775+
expect(menu.attributes("data-state-position")).toBe("bottom-start");
776+
});
777+
});
778+
758779
describe("exposed component methods and refs", () => {
759780
it("should expose inputRef for direct DOM access", async () => {
760781
const wrapper = mount(VueSelect, { props: { modelValue: null, options } });

0 commit comments

Comments
 (0)