adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
357 lines
8.9 KiB
Svelte
357 lines
8.9 KiB
Svelte
<script lang="ts">
|
|
import DropdownOptions from "./DropdownOptions.svelte";
|
|
import { BlockTitle, IconButtonWrapper } from "@gradio/atoms";
|
|
import { DropdownArrow } from "@gradio/icons";
|
|
import { handle_filter, handle_shared_keys } from "./utils";
|
|
import {
|
|
type SelectData,
|
|
type KeyUpData,
|
|
type CustomButton as CustomButtonType
|
|
} from "@gradio/utils";
|
|
import { tick } from "svelte";
|
|
|
|
const is_browser = typeof window !== "undefined";
|
|
|
|
let {
|
|
label = "Dropdown",
|
|
info = undefined,
|
|
value = $bindable<string | number | null>(),
|
|
choices = [],
|
|
interactive = true,
|
|
show_label = true,
|
|
container = true,
|
|
allow_custom_value = false,
|
|
filterable = true,
|
|
buttons = null,
|
|
oncustom_button_click = null,
|
|
on_change,
|
|
on_input,
|
|
on_select,
|
|
on_focus,
|
|
on_blur,
|
|
on_key_up
|
|
}: {
|
|
label?: string;
|
|
info?: string;
|
|
value?: string | number | null;
|
|
choices?: [string, string | number][];
|
|
interactive?: boolean;
|
|
show_label?: boolean;
|
|
container?: boolean;
|
|
allow_custom_value?: boolean;
|
|
filterable?: boolean;
|
|
buttons?: (string | CustomButtonType)[] | null;
|
|
oncustom_button_click?: ((id: number) => void) | null;
|
|
on_change?: (value: string | number | null) => void;
|
|
on_input?: () => void;
|
|
on_select?: (data: SelectData) => void;
|
|
on_focus?: () => void;
|
|
on_blur?: () => void;
|
|
on_key_up?: (data: KeyUpData) => void;
|
|
} = $props();
|
|
|
|
let filter_input: HTMLElement;
|
|
|
|
const uid = $props.id();
|
|
const listbox_id = `${uid}-options`;
|
|
|
|
let show_options = $derived.by(() => {
|
|
return is_browser && filter_input === document.activeElement;
|
|
});
|
|
let choices_names = $derived(choices.map((c) => c[0]));
|
|
let choices_values = $derived(choices.map((c) => c[1]));
|
|
let input_text = $state("");
|
|
let selected_index: number | null = $state(null);
|
|
// Use last_typed_value to track when the user has typed
|
|
// on_blur we only want to update value if the user has typed
|
|
let last_typed_value = input_text;
|
|
let focused = $state(false);
|
|
|
|
$effect(() => {
|
|
const current_value = value;
|
|
const values = choices_values;
|
|
const names = choices_names;
|
|
if (focused) {
|
|
return;
|
|
}
|
|
if (
|
|
current_value === undefined ||
|
|
current_value === null ||
|
|
(Array.isArray(current_value) && current_value.length === 0)
|
|
) {
|
|
input_text = "";
|
|
selected_index = null;
|
|
} else if (values.includes(current_value as string | number)) {
|
|
input_text = names[values.indexOf(current_value as string | number)];
|
|
selected_index = values.indexOf(current_value as string | number);
|
|
} else if (allow_custom_value) {
|
|
input_text = current_value as string;
|
|
selected_index = null;
|
|
} else {
|
|
input_text = "";
|
|
selected_index = null;
|
|
}
|
|
last_typed_value = input_text;
|
|
});
|
|
let initialized = $state(false);
|
|
let disabled = $derived(!interactive);
|
|
|
|
// All of these are indices with respect to the choices array
|
|
let filtered_indices = $state(choices.map((_, i) => i));
|
|
let active_index: number | null = $state(null);
|
|
let selected_indices = $derived(
|
|
selected_index === null ? [] : [selected_index]
|
|
);
|
|
|
|
$effect(() => {
|
|
choices;
|
|
filtered_indices = choices.map((_, i) => i);
|
|
});
|
|
|
|
function handle_option_selected(index: any): void {
|
|
selected_index = parseInt(index);
|
|
if (isNaN(selected_index)) {
|
|
// This is the case when the user clicks on the scrollbar
|
|
selected_index = null;
|
|
return;
|
|
}
|
|
|
|
let [_input_text, _value] = choices[selected_index];
|
|
input_text = _input_text;
|
|
last_typed_value = input_text;
|
|
value = _value;
|
|
on_select?.({
|
|
index: selected_index,
|
|
value: choices_values[selected_index],
|
|
selected: true
|
|
});
|
|
show_options = false;
|
|
active_index = null;
|
|
on_input?.();
|
|
filter_input.blur();
|
|
}
|
|
|
|
function handle_focus(e: FocusEvent): void {
|
|
focused = true;
|
|
filtered_indices = choices.map((_, i) => i);
|
|
active_index = selected_index;
|
|
show_options = true;
|
|
on_focus?.();
|
|
}
|
|
|
|
function handle_blur(): void {
|
|
if (!allow_custom_value) {
|
|
input_text =
|
|
choices_names[choices_values.indexOf(value as string | number)];
|
|
} else if (input_text !== last_typed_value) {
|
|
if (choices_names.includes(input_text)) {
|
|
selected_index = choices_names.indexOf(input_text);
|
|
value = choices_values[selected_index];
|
|
} else {
|
|
value = input_text;
|
|
selected_index = null;
|
|
}
|
|
}
|
|
show_options = false;
|
|
active_index = null;
|
|
filtered_indices = choices.map((_, i) => i);
|
|
focused = false;
|
|
on_blur?.();
|
|
on_input?.();
|
|
}
|
|
|
|
async function handle_key_down(e: KeyboardEvent): Promise<void> {
|
|
await tick();
|
|
const is_navigation_key =
|
|
e.key === "ArrowUp" ||
|
|
e.key === "ArrowDown" ||
|
|
e.key === "Enter" ||
|
|
e.key === "Escape";
|
|
const is_filtering = input_text !== last_typed_value;
|
|
if (!is_navigation_key) {
|
|
filtered_indices = handle_filter(choices, input_text);
|
|
active_index = filtered_indices.length > 0 ? filtered_indices[0] : null;
|
|
} else {
|
|
// Recompute the visible options so navigation always spans the full
|
|
// current list. Filter by the typed text when the user is filtering,
|
|
// otherwise (a value is just displayed/selected) show every option.
|
|
filtered_indices = is_filtering
|
|
? handle_filter(choices, input_text)
|
|
: choices.map((_, i) => i);
|
|
if (active_index !== null && !filtered_indices.includes(active_index)) {
|
|
active_index = filtered_indices.length > 0 ? filtered_indices[0] : null;
|
|
}
|
|
}
|
|
[show_options, active_index] = handle_shared_keys(
|
|
e,
|
|
active_index,
|
|
filtered_indices
|
|
);
|
|
if (e.key === "Enter") {
|
|
last_typed_value = input_text;
|
|
if (active_index !== null) {
|
|
selected_index = active_index;
|
|
value = choices_values[active_index];
|
|
show_options = false;
|
|
filter_input.blur();
|
|
active_index = null;
|
|
} else if (choices_names.includes(input_text)) {
|
|
selected_index = choices_names.indexOf(input_text);
|
|
value = choices_values[selected_index];
|
|
show_options = false;
|
|
active_index = null;
|
|
filter_input.blur();
|
|
} else if (allow_custom_value) {
|
|
value = input_text;
|
|
selected_index = null;
|
|
show_options = false;
|
|
active_index = null;
|
|
filter_input.blur();
|
|
}
|
|
}
|
|
}
|
|
|
|
let old_value = $state(value);
|
|
$effect(() => {
|
|
if (old_value !== value) {
|
|
old_value = value;
|
|
on_change?.(value);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class:container>
|
|
{#if show_label && buttons && buttons.length > 0}
|
|
<IconButtonWrapper
|
|
{buttons}
|
|
on_custom_button_click={oncustom_button_click}
|
|
/>
|
|
{/if}
|
|
<BlockTitle {show_label} {info}>{label}</BlockTitle>
|
|
|
|
<div class="wrap">
|
|
<div class="wrap-inner" class:show_options>
|
|
<div class="secondary-wrap">
|
|
<input
|
|
role="combobox"
|
|
aria-controls={listbox_id}
|
|
aria-expanded={show_options}
|
|
aria-activedescendant={show_options && active_index !== null
|
|
? `${listbox_id}-option-${active_index}`
|
|
: undefined}
|
|
aria-autocomplete={filterable ? "list" : "none"}
|
|
aria-label={label}
|
|
class="border-none"
|
|
class:subdued={!choices_names.includes(input_text) &&
|
|
!allow_custom_value}
|
|
autocomplete="off"
|
|
{disabled}
|
|
bind:value={input_text}
|
|
bind:this={filter_input}
|
|
onkeydown={handle_key_down}
|
|
onkeyup={(e) => {
|
|
on_key_up?.({
|
|
key: e.key,
|
|
input_value: input_text
|
|
});
|
|
}}
|
|
onblur={handle_blur}
|
|
onfocus={handle_focus}
|
|
readonly={!filterable}
|
|
/>
|
|
{#if !disabled}
|
|
<div class="icon-wrap">
|
|
<DropdownArrow />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<DropdownOptions
|
|
{show_options}
|
|
{choices}
|
|
{filtered_indices}
|
|
{disabled}
|
|
{selected_indices}
|
|
{active_index}
|
|
{listbox_id}
|
|
onchange={handle_option_selected}
|
|
onload={() => (initialized = true)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.icon-wrap {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
right: var(--size-5);
|
|
color: var(--body-text-color);
|
|
width: var(--size-5);
|
|
pointer-events: none;
|
|
}
|
|
.container {
|
|
height: 100%;
|
|
}
|
|
.container .wrap {
|
|
box-shadow: var(--input-shadow);
|
|
border: var(--input-border-width) solid var(--border-color-primary);
|
|
}
|
|
|
|
.wrap {
|
|
position: relative;
|
|
border-radius: var(--input-radius);
|
|
background: var(--input-background-fill);
|
|
}
|
|
|
|
.wrap:focus-within {
|
|
box-shadow: var(--input-shadow-focus);
|
|
border-color: var(--input-border-color-focus);
|
|
background: var(--input-background-fill-focus);
|
|
}
|
|
|
|
.wrap-inner {
|
|
display: flex;
|
|
position: relative;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: var(--checkbox-label-gap);
|
|
padding: var(--checkbox-label-padding);
|
|
height: 100%;
|
|
}
|
|
.secondary-wrap {
|
|
display: flex;
|
|
flex: 1 1 0%;
|
|
align-items: center;
|
|
border: none;
|
|
min-width: min-content;
|
|
height: 100%;
|
|
}
|
|
|
|
input {
|
|
margin: var(--spacing-sm);
|
|
outline: none;
|
|
border: none;
|
|
background: inherit;
|
|
width: var(--size-full);
|
|
color: var(--body-text-color);
|
|
font-size: var(--input-text-size);
|
|
height: 100%;
|
|
}
|
|
|
|
input:disabled {
|
|
-webkit-text-fill-color: var(--body-text-color);
|
|
-webkit-opacity: 1;
|
|
opacity: 1;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.subdued {
|
|
color: var(--body-text-color-subdued);
|
|
}
|
|
|
|
input[readonly] {
|
|
cursor: pointer;
|
|
}
|
|
</style>
|