Skip to content

feat(select): add hideSelectedOptions prop #243

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 1 commit into from
Apr 9, 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
8 changes: 8 additions & 0 deletions docs/props.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ Whether the select should display a loading state. When `true`, the select will

A prop to control the menu open state programmatically. When set to `true`, the menu will be open. When set to `false`, the menu will be closed.

## hideSelectedOptions

**Type**: `boolean`

**Default**: `true`

When set to `true` with `isMulti`, selected options won't appear in the options menu. Set it to `false` to show selected options in the menu.

## shouldAutofocusOption

**Type**: `boolean`
Expand Down
18 changes: 14 additions & 4 deletions docs/slots.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,25 @@ If you are not familiar with Vue's slots, you can read more about them [here](ht

## option

**Type**: `slotProps: { option: Option }`
**Type**:

```ts
slotProps: {
option: Option;
index: number;
isFocused: boolean;
isSelected: boolean;
isDisabled: boolean;
}
```

Customize the rendered template of an option inside the menu. You can use the slot props to retrieve the current menu option that will be rendered.
Customize the rendered template of an option inside the menu. You can use the slot props to retrieve the current menu option that will be rendered in order to have more context and flexbility.

```vue
<template>
<VueSelect v-model="option" :options="options">
<template #option="{ option }">
{{ option.label }} - {{ option.value }}
<template #option="{ option, index }">
{{ option.label }} - {{ option.value }} (#{{ index }})
</template>
</VueSelect>
</template>
Expand Down
73 changes: 73 additions & 0 deletions src/Select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,76 @@ describe("menu closing behavior", () => {
}
});
});

describe("hideSelectedOptions prop", () => {
it("should hide selected options from menu when hideSelectedOptions is true", async () => {
const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, hideSelectedOptions: true } });

await openMenu(wrapper);
await wrapper.get("div[role='option']").trigger("click");
await openMenu(wrapper);

expect(wrapper.findAll("div[role='option']").length).toBe(options.length - 1);
expect(wrapper.findAll("div[role='option']").map((option) => option.text())).not.toContain(options[0].label);
});

it("should show selected options in menu when hideSelectedOptions is false", async () => {
const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, hideSelectedOptions: false } });

await openMenu(wrapper);
await wrapper.get("div[role='option']").trigger("click");
await openMenu(wrapper);

expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain(options[0].label);
});

it("should show all options when in single-select mode regardless of hideSelectedOptions", async () => {
const wrapper = mount(VueSelect, { props: { modelValue: null, options, hideSelectedOptions: true } });

await openMenu(wrapper);
await wrapper.get("div[role='option']").trigger("click");
await openMenu(wrapper);

expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain(options[0].label);
});

it("should correctly restore hidden options when they are deselected", async () => {
const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, hideSelectedOptions: true } });

// Select first option
await openMenu(wrapper);
await wrapper.get("div[role='option']").trigger("click");
await openMenu(wrapper);

// Verify it's hidden from dropdown
expect(wrapper.findAll("div[role='option']").length).toBe(options.length - 1);
expect(wrapper.findAll("div[role='option']").map((option) => option.text())).not.toContain(options[0].label);

// Remove the option
await wrapper.get(".multi-value-remove").trigger("click");
await openMenu(wrapper);

// Verify it's back in the dropdown
expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain(options[0].label);
});

it("should correctly filter options when searching with hideSelectedOptions enabled", async () => {
const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, hideSelectedOptions: true } });

// Select first option (France)
await openMenu(wrapper);
await wrapper.get("div[role='option']").trigger("click");

// Open menu and search for "United"
await openMenu(wrapper);
await inputSearch(wrapper, "United");

// Should only show United Kingdom and United States (not France)
expect(wrapper.findAll("div[role='option']").length).toBe(2);
expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain("United Kingdom");
expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain("United States");
});
});
23 changes: 19 additions & 4 deletions src/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const props = withDefaults(
isTaggable: false,
isLoading: false,
isMenuOpen: undefined,
hideSelectedOptions: true,
shouldAutofocusOption: true,
closeOnSelect: true,
teleport: undefined,
Expand Down Expand Up @@ -71,7 +72,7 @@ const availableOptions = computed<GenericOption[]>(() => {
// Remove already selected values from the list of options, when in multi-select mode.
// In case an invalid v-model is provided, we return all options since we can't know what options are valid.
const getNonSelectedOptions = (options: GenericOption[]) => options.filter(
(option) => Array.isArray(selected.value) ? !selected.value.includes(option.value) : true,
(option) => props.hideSelectedOptions && Array.isArray(selected.value) ? !selected.value.includes(option.value) : true,
);

if (props.isSearchable && search.value) {
Expand Down Expand Up @@ -149,7 +150,14 @@ const setOption = (option: GenericOption) => {

if (props.isMulti) {
if (Array.isArray(selected.value)) {
selected.value.push(option.value);
const isAlreadyPresent = selected.value.find((v) => v === option.value);

if (!isAlreadyPresent) {
selected.value.push(option.value);
}
else {
selected.value = selected.value.filter((v) => v !== option.value);
}
}
else {
selected.value = [option.value];
Expand Down Expand Up @@ -498,11 +506,18 @@ onBeforeUnmount(() => {
:menu="menuRef"
:index="i"
:is-focused="focusedOption === i"
:is-selected="option.value === selected"
:is-selected="Array.isArray(selected) ? selected.includes(option.value) : option.value === selected"
:is-disabled="option.disabled || false"
@select="setOption(option)"
>
<slot name="option" :option="option">
<slot
name="option"
:option="option"
:index="i"
:is-focused="focusedOption === i"
:is-selected="Array.isArray(selected) ? selected.includes(option.value) : option.value === selected"
:is-disabled="option.disabled || false"
>
{{ getOptionLabel(option) }}
</slot>
</MenuOption>
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export type Props<GenericOption, OptionValue> = {
*/
isMenuOpen?: boolean;

/**
* When set to true with `isMulti`, selected options won't be displayed in
* the menu.
*/
hideSelectedOptions?: boolean;

/**
* When set to true, focus the first option when the menu is opened.
* When set to false, no option will be focused.
Expand Down