Skip to content

Commit 4f88f58

Browse files
authored
feat(menu): implement hover to focus behavior and update styling (#315)
#312
1 parent 379fc7a commit 4f88f58

File tree

6 files changed

+87
-11
lines changed

6 files changed

+87
-11
lines changed

.cursor/rules/implementing-features-fixes.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Use this checklist for every feature or bug fix. Do not skip steps. Commands ass
5454
- **Auto-fix**: `npm run lint:fix` (then re-run `npm run lint`).
5555

5656
### 7. PR readiness checklist
57-
- Tests added/updated and `npm run test` passes.
57+
- Tests added/updated and `npm run test` passes. Ensure `npm run test` with Vitest is running smoothly inside Cursor agent (e.g. waiting for user interaction "q to exit" in terminal to resume AI flow).
5858
- `npm run type-check` passes with zero `@ts-ignore`.
5959
- New demo added in `playground/` (if new feature) and manually verified via `npm run dev:playground`.
6060
- `npm run build` completes without errors (no early exit).

docs/styling.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ For a complete list of CSS variables, we recommend to take a look at the source-
5757
--vs-option-disabled-text-color: #52525b;
5858
--vs-option-background-color: var(--vs-menu-background);
5959
--vs-option-hover-background-color: #dbeafe;
60-
--vs-option-focused-background-color: var(--vs-option-hover-background-color);
60+
--vs-option-focused-background-color: #dbeafe;
6161
--vs-option-selected-background-color: #93c5fd;
6262
--vs-option-disabled-background-color: #f4f4f5;
6363
--vs-option-opacity-menu-open: 0.4;

src/MenuOption.spec.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { mount } from "@vue/test-utils";
2-
import { describe, expect, it } from "vitest";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { ref } from "vue";
4+
import { DATA_KEY } from "./lib/provide-inject";
35
import MenuOption from "./MenuOption.vue";
46

57
describe("scrolling behavior when option is above viewport", () => {
@@ -159,3 +161,66 @@ describe("keyboard event handling", () => {
159161
expect(wrapper.emitted("select")).toHaveLength(1);
160162
});
161163
});
164+
165+
describe("hover to focus behavior", () => {
166+
it("should set focused option when hovered", async () => {
167+
const mockSetFocusedOption = vi.fn();
168+
const mockData = {
169+
setFocusedOption: mockSetFocusedOption,
170+
focusedOption: ref(-1),
171+
menuOpen: ref(true),
172+
};
173+
174+
const wrapper = mount(MenuOption, {
175+
props: {
176+
menu: null,
177+
index: 2,
178+
isFocused: false,
179+
isSelected: false,
180+
isDisabled: false,
181+
},
182+
global: {
183+
provide: {
184+
[DATA_KEY as symbol]: mockData,
185+
},
186+
},
187+
});
188+
189+
// Trigger mouse enter
190+
await wrapper.trigger("mouseenter");
191+
192+
// Verify setFocusedOption was called with the correct index
193+
expect(mockSetFocusedOption).toHaveBeenCalledWith(2);
194+
expect(mockSetFocusedOption).toHaveBeenCalledTimes(1);
195+
});
196+
197+
it("should not set focused option when hovered on disabled option", async () => {
198+
const mockSetFocusedOption = vi.fn();
199+
const mockData = {
200+
setFocusedOption: mockSetFocusedOption,
201+
focusedOption: ref(-1),
202+
menuOpen: ref(true),
203+
};
204+
205+
const wrapper = mount(MenuOption, {
206+
props: {
207+
menu: null,
208+
index: 2,
209+
isFocused: false,
210+
isSelected: false,
211+
isDisabled: true, // Option is disabled
212+
},
213+
global: {
214+
provide: {
215+
[DATA_KEY as symbol]: mockData,
216+
},
217+
},
218+
});
219+
220+
// Trigger mouse enter
221+
await wrapper.trigger("mouseenter");
222+
223+
// Verify setFocusedOption was NOT called
224+
expect(mockSetFocusedOption).not.toHaveBeenCalled();
225+
});
226+
});

src/MenuOption.vue

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
2-
import { ref, watch } from "vue";
2+
import { inject, ref, watch } from "vue";
3+
import { DATA_KEY } from "./lib/provide-inject";
34
45
const props = defineProps<{
56
menu: HTMLDivElement | null;
@@ -13,6 +14,14 @@ const emit = defineEmits<{
1314
(e: "select"): void;
1415
}>();
1516
17+
const sharedData = inject(DATA_KEY);
18+
19+
const handleMouseEnter = () => {
20+
if (!props.isDisabled && sharedData?.setFocusedOption) {
21+
sharedData.setFocusedOption(props.index);
22+
}
23+
};
24+
1625
const option = ref<HTMLButtonElement | null>(null);
1726
1827
// Scroll the focused option into view when it's out of the menu's viewport.
@@ -52,6 +61,7 @@ watch(
5261
:aria-selected="isSelected"
5362
@click="emit('select')"
5463
@keydown.enter="emit('select')"
64+
@mouseenter="handleMouseEnter"
5565
>
5666
<slot />
5767
</div>
@@ -78,12 +88,7 @@ watch(
7888
cursor: var(--vs-option-cursor);
7989
}
8090
81-
.menu-option:hover {
82-
background-color: var(--vs-option-hover-background-color);
83-
color: var(--vs-option-hover-text-color);
84-
}
85-
86-
.menu-option.focused {
91+
.menu-option.focused:not(.selected):not(.disabled) {
8792
background-color: var(--vs-option-focused-background-color);
8893
color: var(--vs-option-focused-text-color);
8994
}

src/Select.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ const createOption = () => {
223223
closeMenu();
224224
};
225225
226+
const setFocusedOption = (index: number) => {
227+
focusedOption.value = index;
228+
};
229+
226230
const handleInputKeydown = (e: KeyboardEvent) => {
227231
if (e.key === "Tab") {
228232
closeMenu();
@@ -250,6 +254,7 @@ provide(DATA_KEY, {
250254
setOption,
251255
removeOption,
252256
createOption,
257+
setFocusedOption,
253258
});
254259
255260
// Expose useful refs and methods for external component control
@@ -469,7 +474,7 @@ watch(
469474
--vs-option-disabled-text-color: #52525b;
470475
--vs-option-background-color: var(--vs-menu-background);
471476
--vs-option-hover-background-color: #dbeafe;
472-
--vs-option-focused-background-color: var(--vs-option-hover-background-color);
477+
--vs-option-focused-background-color: #dbeafe;
473478
--vs-option-selected-background-color: #93c5fd;
474479
--vs-option-disabled-background-color: #f4f4f5;
475480
--vs-option-opacity-menu-open: 0.4;

src/lib/provide-inject.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type DataInjection<GenericOption extends Option<OptionValue>, OptionValue
3232
setOption: (option: GenericOption) => void;
3333
removeOption: (option: GenericOption) => void;
3434
createOption: () => void;
35+
setFocusedOption: (index: number) => void;
3536
};
3637

3738
export const PROPS_KEY = Symbol("props") as InjectionKey<Props<any, any>>;

0 commit comments

Comments
 (0)