Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .agents/skills/gradio/references/api-signatures.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Creates a checkbox that can be set to `True` or `False`. Can be used as an input
## `Dropdown`

```python
Dropdown(choices: Sequence[str | int | float | tuple[str | I18nData, str | int | float]] | None = None, value: str | int | float | Sequence[str | int | float] | Callable | DefaultValue | None = DefaultValue(), type: Literal['value', 'index'] = "value", multiselect: bool | None = None, allow_custom_value: bool = False, max_choices: int | None = None, filterable: bool = True, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", buttons: list[Button] | None = None)
Dropdown(choices: Sequence[str | int | float | tuple[str | I18nData, str | int | float]] | None = None, value: str | int | float | Sequence[str | int | float] | Callable | DefaultValue | None = DefaultValue(), type: Literal['value', 'index'] = "value", multiselect: bool | None = None, allow_custom_value: bool = False, max_choices: int | None = None, num_choices_shown: int | None = 100, filterable: bool = True, label: str | I18nData | None = None, info: str | I18nData | None = None, every: Timer | float | None = None, inputs: Component | Sequence[Component] | set[Component] | None = None, show_label: bool | None = None, container: bool = True, scale: int | None = None, min_width: int = 160, interactive: bool | None = None, visible: bool | Literal['hidden'] = True, elem_id: str | None = None, elem_classes: list[str] | str | None = None, render: bool = True, key: int | str | tuple[int | str, ...] | None = None, preserved_by_key: list[str] | str | None = "value", buttons: list[Button] | None = None)
```

Creates a dropdown of choices from which a single entry or multiple entries can be selected (as an input component) or displayed (as an output component).
Expand Down
6 changes: 6 additions & 0 deletions .changeset/great-pigs-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@gradio/dropdown": minor
"gradio": minor
---

feat:Load large Dropdown choices progressively on scroll
Comment on lines +1 to +6

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset was generated by gradio-pr-bot (commits f13d293 and a8ef3dc), not hand-authored. Keeping the bot-generated file is consistent with the repository guidance and avoids replacing or fighting the automated changelog entry.

7 changes: 7 additions & 0 deletions gradio/components/dropdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def __init__(
multiselect: bool | None = None,
allow_custom_value: bool = False,
max_choices: int | None = None,
num_choices_shown: int | None = 100,
filterable: bool = True,
label: str | I18nData | None = None,
info: str | I18nData | None = None,
Expand All @@ -88,6 +89,7 @@ def __init__(
multiselect: if True, multiple choices can be selected.
allow_custom_value: if True, allows user to enter a custom value that is not in the list of choices.
max_choices: maximum number of choices that can be selected. If None, no limit is enforced.
num_choices_shown: number of matching choices to show initially. More choices are loaded automatically as the user scrolls. If None, all matching choices are shown immediately.
filterable: if True, user will be able to type into the dropdown and filter the choices by typing. Can only be set to False if `allow_custom_value` is False.
label: the label for this component, displayed above the component if `show_label` is `True` and is also used as the header if there are a table of examples for this component. If None and used in a `gr.Interface`, the label will be the name of the parameter this component corresponds to.
info: additional component description, appears below the label in smaller font. Supports markdown / HTML syntax.
Expand Down Expand Up @@ -138,7 +140,12 @@ def __init__(
warnings.warn(
"The `filterable` parameter cannot be set to False when `allow_custom_value` is True. Setting `filterable` to True."
)
if num_choices_shown is not None and num_choices_shown <= 0:
raise ValueError(
"The `num_choices_shown` parameter must be greater than 0."
)
self.max_choices = max_choices
self.num_choices_shown = num_choices_shown
self.allow_custom_value = allow_custom_value
self.filterable = filterable
super().__init__(
Expand Down
1 change: 1 addition & 0 deletions js/dropdown/Index.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
container={gradio.shared.container}
allow_custom_value={gradio.props.allow_custom_value}
filterable={gradio.props.filterable}
num_choices_shown={gradio.props.num_choices_shown}
buttons={gradio.props.buttons}
oncustom_button_click={(id) => {
gradio.dispatch("custom_button_click", { id });
Expand Down
11 changes: 8 additions & 3 deletions js/dropdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

```html
<script>
import {BaseDropdown, BaseMultiselect, BaseExample } from "@gradio/dropdown";
import { BaseDropdown, BaseMultiselect, BaseExample } from "@gradio/dropdown";
</script>
```

BaseDropdown

```javascript
export let label: string;
export let info: string | undefined = undefined;
Expand All @@ -18,9 +19,11 @@ BaseDropdown
export let container = true;
export let allow_custom_value = false;
export let filterable = true;
export let num_choices_shown: number | null = 100; // Initial matches shown; scrolling loads more. null shows all matches.
```

BaseMultiselect

```javascript
export let label: string;
export let info: string | undefined = undefined;
Expand All @@ -33,12 +36,14 @@ BaseMultiselect
export let container = true;
export let allow_custom_value = false;
export let filterable = true;
export let num_choices_shown: number | null = 100; // Initial matches shown; scrolling loads more. null shows all matches.
export let i18n: I18nFormatter;
```

BaseExample

```javascript
export let value: string;
export let type: "gallery" | "table";
export let selected = false;
```
export let selected = false;
```
239 changes: 237 additions & 2 deletions js/dropdown/dropdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { setupi18n, changeLocale } from "../core/src/i18n";
import { formatter, reactive_formatter } from "../core/src/gradio_helper";

import Dropdown from "./Index.svelte";
import { handle_filter } from "./shared/utils";
import { handle_filter, handle_filter_with_count } from "./shared/utils";

// Build a real i18n marker the way the backend's I18nData does.
const marker = (key: string): string =>
Expand All @@ -25,6 +25,7 @@ const single_select_props = {
interactive: true,
multiselect: false,
max_choices: null,
num_choices_shown: 100,
allow_custom_value: false
};

Expand All @@ -34,6 +35,11 @@ const tuple_choices: [string, string | number][] = [
["Cherry Display", "cherry_val"]
];

const many_choices = Array.from(
{ length: 105 },
(_, index) => [`choice-${index}`, `choice-${index}`] as [string, string]
);

const multiselect_props = {
label: "Multiselect",
show_label: true,
Expand All @@ -47,6 +53,7 @@ const multiselect_props = {
interactive: true,
multiselect: true,
max_choices: null,
num_choices_shown: 100,
allow_custom_value: false
};

Expand All @@ -62,7 +69,8 @@ run_shared_prop_tests({
filterable: true,
interactive: true,
multiselect: false,
max_choices: null
max_choices: null,
num_choices_shown: 100
}
});

Expand Down Expand Up @@ -157,6 +165,120 @@ describe("Single-select: Options display", () => {
expect(options).toHaveLength(3);
});

test("num_choices_shown limits the initially displayed options", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
num_choices_shown: 2
});

const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();

const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(2);
expect(options[0]).toHaveAttribute("aria-label", "apple");
expect(options[1]).toHaveAttribute("aria-label", "banana");
});

test("scrolling to the bottom automatically loads the next batch", async () => {
const { getByLabelText, getAllByTestId, getByRole, getByText } =
await render(Dropdown, {
...single_select_props,
value: null,
choices: many_choices,
num_choices_shown: 4
});

const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
expect(getAllByTestId("dropdown-option")).toHaveLength(4);
expect(getByText("4 choices shown, 101 remaining")).toBeInTheDocument();

const listbox = getByRole("listbox");
await waitFor(() => {
expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight);
});
listbox.scrollTop = listbox.scrollHeight;
await fireEvent.scroll(listbox);

await waitFor(() => {
expect(getAllByTestId("dropdown-option")).toHaveLength(8);
expect(getByText("8 choices shown, 97 remaining")).toBeInTheDocument();
});

listbox.scrollTop = listbox.scrollHeight;
await fireEvent.scroll(listbox);
await waitFor(() => {
expect(getAllByTestId("dropdown-option")).toHaveLength(12);
});
});

test("keyboard navigation loads and selects from the next batch", async () => {
const { getByLabelText, getAllByTestId, get_data } = await render(
Dropdown,
{
...single_select_props,
value: null,
choices: many_choices,
num_choices_shown: 4
}
);

const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
for (let index = 0; index < 5; index++) {
await event.keyboard("{ArrowDown}");
}

await waitFor(() => {
expect(getAllByTestId("dropdown-option")).toHaveLength(8);
expect(input).toHaveAttribute(
"aria-activedescendant",
expect.stringContaining("-option-4")
);
});

await event.keyboard("{Enter}");
expect((await get_data()).value).toBe("choice-4");
});

test("a selected option beyond the initial batch does not replace a visible choice", async () => {
const { getByLabelText, getAllByTestId, get_data } = await render(
Dropdown,
{
...single_select_props,
choices: many_choices,
value: "choice-104",
num_choices_shown: 100
}
);

const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();

const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(100);
expect(options[0]).toHaveAttribute("aria-label", "choice-0");
expect(options[99]).toHaveAttribute("aria-label", "choice-99");
expect(input).not.toHaveAttribute("aria-activedescendant");

await event.keyboard("{Enter}");
expect((await get_data()).value).toBe("choice-104");
});

test("num_choices_shown=null displays every matching option", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
choices: many_choices,
num_choices_shown: null
});

const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();

expect(getAllByTestId("dropdown-option")).toHaveLength(105);
});

test("options display names, not internal values", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
Expand Down Expand Up @@ -271,6 +393,28 @@ describe("Single-select: Filtering", () => {
const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(2);
});

test("num_choices_shown limits the initially filtered options", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
value: null,
num_choices_shown: 2,
choices: [
["apple", "apple"],
["banana", "banana"],
["grape", "grape"]
] as [string, string][]
});

const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("a");

const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(2);
expect(options[0]).toHaveAttribute("aria-label", "apple");
expect(options[1]).toHaveAttribute("aria-label", "banana");
});
});

describe("Single-select: Selection", () => {
Expand Down Expand Up @@ -1002,6 +1146,86 @@ describe("Multiselect: Options display", () => {
expect(options).toHaveLength(3);
});

test("num_choices_shown limits the initially displayed options", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...multiselect_props,
num_choices_shown: 2
});

const input = getByLabelText("Multiselect") as HTMLInputElement;
await input.focus();

const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(2);
});

test("scrolling to the bottom automatically loads the next multiselect batch", async () => {
const { getByLabelText, getAllByTestId, getByRole } = await render(
Dropdown,
{
...multiselect_props,
choices: many_choices,
num_choices_shown: 4
}
);

const input = getByLabelText("Multiselect") as HTMLInputElement;
await input.focus();
expect(getAllByTestId("dropdown-option")).toHaveLength(4);

const listbox = getByRole("listbox");
await waitFor(() => {
expect(listbox.scrollHeight).toBeGreaterThan(listbox.clientHeight);
});
listbox.scrollTop = listbox.scrollHeight;
await fireEvent.scroll(listbox);

await waitFor(() => {
expect(getAllByTestId("dropdown-option")).toHaveLength(8);
});
});

test("keyboard navigation loads and selects from the next multiselect batch", async () => {
const { getByLabelText, getAllByTestId, get_data } = await render(
Dropdown,
{
...multiselect_props,
choices: many_choices,
num_choices_shown: 4
}
);

const input = getByLabelText("Multiselect") as HTMLInputElement;
await input.focus();
for (let index = 0; index < 4; index++) {
await event.keyboard("{ArrowDown}");
}

await waitFor(() => {
expect(getAllByTestId("dropdown-option")).toHaveLength(8);
expect(input).toHaveAttribute(
"aria-activedescendant",
expect.stringContaining("-option-4")
);
});

await event.keyboard("{Enter}");
expect((await get_data()).value).toEqual(["choice-4"]);
});

test("num_choices_shown=null displays every matching option", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...multiselect_props,
choices: many_choices,
num_choices_shown: null
});

const input = getByLabelText("Multiselect") as HTMLInputElement;
await input.focus();

expect(getAllByTestId("dropdown-option")).toHaveLength(105);
});

test("selected options are marked as selected", async () => {
const { container, getAllByTestId } = await render(Dropdown, {
...multiselect_props,
Expand Down Expand Up @@ -1647,6 +1871,17 @@ describe("handle_filter", () => {
test("matches substring anywhere in display name", () => {
expect(handle_filter(choices, "an")).toEqual([1]);
});

test("returns only the requested prefix of matching choices", () => {
expect(handle_filter(choices, "a", 2)).toEqual([0, 1]);
});

test("counts all matches while returning only the requested prefix", () => {
expect(handle_filter_with_count(choices, "a", 2)).toEqual({
filtered_indices: [0, 1],
total_matches: 3
});
});
});

describe("i18n choices", () => {
Expand Down
Loading
Loading