Spaces:
Running
Running
| <script lang="ts"> | |
| import { afterUpdate, createEventDispatcher, tick } from "svelte"; | |
| import { _ } from "svelte-i18n"; | |
| import { BlockTitle } from "@gradio/atoms"; | |
| import { Remove, DropdownArrow } from "@gradio/icons"; | |
| import type { KeyUpData, SelectData, I18nFormatter } from "@gradio/utils"; | |
| import DropdownOptions from "./DropdownOptions.svelte"; | |
| import { handle_filter, handle_change, handle_shared_keys } from "./utils"; | |
| type Item = string | number; | |
| export let label: string; | |
| export let info: string | undefined = undefined; | |
| export let help: string | undefined = undefined; | |
| export let value: Item | Item[] | undefined = []; | |
| let old_value: typeof value = []; | |
| export let value_is_output = false; | |
| export let max_choices: number | null = null; | |
| export let choices: [string, Item][]; | |
| let old_choices: typeof choices; | |
| export let disabled = false; | |
| export let show_label: boolean; | |
| export let container = true; | |
| export let allow_custom_value = false; | |
| export let filterable = true; | |
| export let i18n: I18nFormatter; | |
| let filter_input: HTMLElement; | |
| let input_text = ""; | |
| let old_input_text = ""; | |
| let show_options = false; | |
| let choices_names: string[]; | |
| let choices_values: (string | number)[]; | |
| // All of these are indices with respect to the choices array | |
| let filtered_indices: number[] = []; | |
| let active_index: number | null = null; | |
| // selected_index consists of indices from choices or strings if allow_custom_value is true and user types in a custom value | |
| let selected_indices: (number | string)[] = []; | |
| let old_selected_index: (number | string)[] = []; | |
| let show_tooltip = false; | |
| let tooltip_element: HTMLSpanElement; | |
| let icon_element: HTMLSpanElement; | |
| const dispatch = createEventDispatcher<{ | |
| change: string | string[] | undefined; | |
| input: undefined; | |
| select: SelectData; | |
| blur: undefined; | |
| focus: undefined; | |
| key_up: KeyUpData; | |
| }>(); | |
| // Setting the initial value of the multiselect dropdown | |
| if (Array.isArray(value)) { | |
| value.forEach((element) => { | |
| const index = choices.map((c) => c[1]).indexOf(element); | |
| if (index !== -1) { | |
| selected_indices.push(index); | |
| } else { | |
| selected_indices.push(element); | |
| } | |
| }); | |
| } | |
| $: { | |
| choices_names = choices.map((c) => c[0]); | |
| choices_values = choices.map((c) => c[1]); | |
| } | |
| $: { | |
| if (choices !== old_choices || input_text !== old_input_text) { | |
| filtered_indices = handle_filter(choices, input_text); | |
| old_choices = choices; | |
| old_input_text = input_text; | |
| if (!allow_custom_value) { | |
| active_index = filtered_indices[0]; | |
| } | |
| } | |
| } | |
| $: { | |
| if (JSON.stringify(value) != JSON.stringify(old_value)) { | |
| handle_change(dispatch, value, value_is_output); | |
| old_value = Array.isArray(value) ? value.slice() : value; | |
| } | |
| } | |
| $: { | |
| if ( | |
| JSON.stringify(selected_indices) != JSON.stringify(old_selected_index) | |
| ) { | |
| value = selected_indices.map((index) => | |
| typeof index === "number" ? choices_values[index] : index | |
| ); | |
| old_selected_index = selected_indices.slice(); | |
| } | |
| } | |
| function position_tooltip() { | |
| if (!tooltip_element || !icon_element) return; | |
| const icon_rect = icon_element.getBoundingClientRect(); | |
| tooltip_element.style.position = 'fixed'; | |
| tooltip_element.style.left = `${icon_rect.right + 8}px`; | |
| tooltip_element.style.top = `${icon_rect.top}px`; | |
| } | |
| function portal(node: HTMLElement) { | |
| document.body.appendChild(node); | |
| return { | |
| destroy() { | |
| if (node.parentNode) { | |
| node.parentNode.removeChild(node); | |
| } | |
| } | |
| }; | |
| } | |
| function handle_show_tooltip() { | |
| show_tooltip = true; | |
| tick().then(position_tooltip); | |
| } | |
| function handle_blur(): void { | |
| if (!allow_custom_value) { | |
| input_text = ""; | |
| } | |
| if (allow_custom_value && input_text !== "") { | |
| add_selected_choice(input_text); | |
| input_text = ""; | |
| } | |
| show_options = false; | |
| active_index = null; | |
| dispatch("blur"); | |
| } | |
| function remove_selected_choice(option_index: number | string): void { | |
| selected_indices = selected_indices.filter((v) => v !== option_index); | |
| dispatch("select", { | |
| index: typeof option_index === "number" ? option_index : -1, | |
| value: | |
| typeof option_index === "number" | |
| ? choices_values[option_index] | |
| : option_index, | |
| selected: false | |
| }); | |
| } | |
| function add_selected_choice(option_index: number | string): void { | |
| if (max_choices === null || selected_indices.length < max_choices) { | |
| selected_indices = [...selected_indices, option_index]; | |
| dispatch("select", { | |
| index: typeof option_index === "number" ? option_index : -1, | |
| value: | |
| typeof option_index === "number" | |
| ? choices_values[option_index] | |
| : option_index, | |
| selected: true | |
| }); | |
| } | |
| if (selected_indices.length === max_choices) { | |
| show_options = false; | |
| active_index = null; | |
| filter_input.blur(); | |
| } | |
| } | |
| function handle_option_selected(e: any): void { | |
| const option_index = parseInt(e.detail.target.dataset.index); | |
| add_or_remove_index(option_index); | |
| } | |
| function add_or_remove_index(option_index: number): void { | |
| if (selected_indices.includes(option_index)) { | |
| remove_selected_choice(option_index); | |
| } else { | |
| add_selected_choice(option_index); | |
| } | |
| input_text = ""; | |
| } | |
| function remove_all(e: any): void { | |
| selected_indices = []; | |
| input_text = ""; | |
| e.preventDefault(); | |
| } | |
| function handle_focus(e: FocusEvent): void { | |
| filtered_indices = choices.map((_, i) => i); | |
| if (max_choices === null || selected_indices.length < max_choices) { | |
| show_options = true; | |
| } | |
| dispatch("focus"); | |
| } | |
| function handle_key_down(e: KeyboardEvent): void { | |
| [show_options, active_index] = handle_shared_keys( | |
| e, | |
| active_index, | |
| filtered_indices | |
| ); | |
| if (e.key === "Enter") { | |
| if (active_index !== null) { | |
| add_or_remove_index(active_index); | |
| } else { | |
| if (allow_custom_value) { | |
| add_selected_choice(input_text); | |
| input_text = ""; | |
| } | |
| } | |
| } | |
| if (e.key === "Backspace" && input_text === "") { | |
| selected_indices = [...selected_indices.slice(0, -1)]; | |
| } | |
| if (selected_indices.length === max_choices) { | |
| show_options = false; | |
| active_index = null; | |
| } | |
| } | |
| function set_selected_indices(): void { | |
| if (value === undefined) { | |
| selected_indices = []; | |
| } else if (Array.isArray(value)) { | |
| selected_indices = value | |
| .map((v) => { | |
| const index = choices_values.indexOf(v); | |
| if (index !== -1) { | |
| return index; | |
| } | |
| if (allow_custom_value) { | |
| return v; | |
| } | |
| // Instead of returning null, skip this iteration | |
| return undefined; | |
| }) | |
| .filter((val): val is string | number => val !== undefined); | |
| } | |
| } | |
| $: value, set_selected_indices(); | |
| afterUpdate(() => { | |
| value_is_output = false; | |
| }); | |
| </script> | |
| <label class:container> | |
| <div class="label-container"> | |
| <div class="title-line"> | |
| <BlockTitle {show_label} info={undefined}>{label}</BlockTitle> | |
| {#if help && show_label} | |
| <div class="tooltip-container" | |
| role="button" | |
| tabindex="0" | |
| aria-label="Help" | |
| on:mouseenter={handle_show_tooltip} | |
| on:mouseleave={() => show_tooltip = false} | |
| on:focusin={handle_show_tooltip} | |
| on:focusout={() => show_tooltip = false} | |
| > | |
| <span class="tooltip-icon" bind:this={icon_element}>?</span> | |
| {#if show_tooltip} | |
| <span class="tooltip-text" bind:this={tooltip_element} use:portal> | |
| {help} | |
| </span> | |
| {/if} | |
| </div> | |
| {/if} | |
| </div> | |
| {#if info && show_label} | |
| <div class="info-text">{info}</div> | |
| {/if} | |
| </div> | |
| <div class="wrap"> | |
| <div class="wrap-inner" class:show_options> | |
| {#each selected_indices as s} | |
| <div class="token"> | |
| <span> | |
| {#if typeof s === "number"} | |
| {choices_names[s]} | |
| {:else} | |
| {s} | |
| {/if} | |
| </span> | |
| {#if !disabled} | |
| <div | |
| class="token-remove" | |
| on:click|preventDefault={() => remove_selected_choice(s)} | |
| on:keydown|preventDefault={(event) => { | |
| if (event.key === "Enter") { | |
| remove_selected_choice(s); | |
| } | |
| }} | |
| role="button" | |
| tabindex="0" | |
| title={i18n("common.remove") + " " + s} | |
| > | |
| <Remove /> | |
| </div> | |
| {/if} | |
| </div> | |
| {/each} | |
| <div class="secondary-wrap"> | |
| <input | |
| class="border-none" | |
| class:subdued={(!choices_names.includes(input_text) && | |
| !allow_custom_value) || | |
| selected_indices.length === max_choices} | |
| {disabled} | |
| autocomplete="off" | |
| bind:value={input_text} | |
| bind:this={filter_input} | |
| on:keydown={handle_key_down} | |
| on:keyup={(e) => | |
| dispatch("key_up", { | |
| key: e.key, | |
| input_value: input_text | |
| })} | |
| on:blur={handle_blur} | |
| on:focus={handle_focus} | |
| readonly={!filterable} | |
| /> | |
| {#if !disabled} | |
| {#if selected_indices.length > 0} | |
| <div | |
| role="button" | |
| tabindex="0" | |
| class="token-remove remove-all" | |
| title={i18n("common.clear")} | |
| on:click={remove_all} | |
| on:keydown={(event) => { | |
| if (event.key === "Enter") { | |
| remove_all(event); | |
| } | |
| }} | |
| > | |
| <Remove /> | |
| </div> | |
| {/if} | |
| <span class="icon-wrap"> <DropdownArrow /></span> | |
| {/if} | |
| </div> | |
| </div> | |
| <DropdownOptions | |
| {show_options} | |
| {choices} | |
| {filtered_indices} | |
| {disabled} | |
| {selected_indices} | |
| {active_index} | |
| remember_scroll={true} | |
| on:change={handle_option_selected} | |
| /> | |
| </div> | |
| </label> | |
| <style> | |
| .icon-wrap { | |
| color: var(--body-text-color); | |
| margin-right: var(--size-2); | |
| width: var(--size-5); | |
| } | |
| label:not(.container), | |
| label:not(.container) .wrap, | |
| label:not(.container) .wrap-inner, | |
| label:not(.container) .secondary-wrap, | |
| label:not(.container) .token, | |
| label:not(.container) input { | |
| 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); | |
| } | |
| .wrap-inner { | |
| display: flex; | |
| position: relative; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: var(--checkbox-label-gap); | |
| padding: var(--checkbox-label-padding); | |
| } | |
| .token { | |
| display: flex; | |
| align-items: center; | |
| transition: var(--button-transition); | |
| cursor: pointer; | |
| box-shadow: var(--checkbox-label-shadow); | |
| border: var(--checkbox-label-border-width) solid | |
| var(--checkbox-label-border-color); | |
| border-radius: var(--button-small-radius); | |
| background: var(--checkbox-label-background-fill); | |
| padding: var(--checkbox-label-padding); | |
| color: var(--checkbox-label-text-color); | |
| font-weight: var(--checkbox-label-text-weight); | |
| font-size: var(--checkbox-label-text-size); | |
| line-height: var(--line-md); | |
| word-break: break-word; | |
| } | |
| .token > * + * { | |
| margin-left: var(--size-2); | |
| } | |
| .token-remove { | |
| fill: var(--body-text-color); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| cursor: pointer; | |
| border: var(--checkbox-border-width) solid var(--border-color-primary); | |
| border-radius: var(--radius-full); | |
| background: var(--background-fill-primary); | |
| padding: var(--size-0-5); | |
| width: 16px; | |
| height: 16px; | |
| flex-shrink: 0; | |
| } | |
| .secondary-wrap { | |
| display: flex; | |
| flex: 1 1 0%; | |
| align-items: center; | |
| border: none; | |
| min-width: min-content; | |
| } | |
| 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); | |
| } | |
| input:disabled { | |
| -webkit-text-fill-color: var(--body-text-color); | |
| -webkit-opacity: 1; | |
| opacity: 1; | |
| cursor: not-allowed; | |
| } | |
| .remove-all { | |
| margin-left: var(--size-1); | |
| width: 20px; | |
| height: 20px; | |
| } | |
| .subdued { | |
| color: var(--body-text-color-subdued); | |
| } | |
| input[readonly] { | |
| cursor: pointer; | |
| } | |
| .label-container { | |
| display: flex; | |
| flex-direction: column; | |
| margin-bottom: var(--spacing-sm); | |
| } | |
| .title-line { | |
| display: flex; | |
| align-items: baseline; | |
| gap: var(--spacing-sm); | |
| } | |
| .info-text { | |
| color: var(--body-text-color-subdued); | |
| font-size: var(--text-sm); | |
| padding-top: var(--spacing-xs); | |
| } | |
| .tooltip-container { | |
| position: relative; | |
| display: inline-flex; | |
| } | |
| .tooltip-icon { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 14px; | |
| height: 14px; | |
| border-radius: 50%; | |
| background-color: var(--body-text-color-subdued); | |
| color: var(--background-fill-primary); | |
| font-size: 10px; | |
| font-weight: bold; | |
| cursor: help; | |
| user-select: none; | |
| } | |
| .tooltip-text { | |
| width: auto; | |
| max-width: 500px; | |
| background-color: var(--body-text-color); | |
| color: var(--background-fill-primary); | |
| text-align: center; | |
| border-radius: var(--radius-md); | |
| padding: var(--spacing-md); | |
| z-index: var(--layer-top); | |
| opacity: 1; | |
| transition: opacity 0.2s; | |
| pointer-events: none; | |
| font-weight: var(--body-text-weight); | |
| font-size: var(--body-text-size); | |
| } | |
| .tooltip-container:hover .tooltip-text { | |
| visibility: visible; | |
| opacity: 1.0; | |
| } | |
| </style> | |