fix: new MultiSelect component (#5979)

* rename all multiselects to Legacy

* move Select

* Separate SelectDropdown

* fix click outside for select with portal

* multiselect v0

* multiselect clear btn

* move filterText logic to SelectDropdown

* ui nits

* console.log

* draggable

* Draggable multiselect

* multiselect search

* nit refacto

* autofocus multiselect input

* Replace in AppMultiSelectV2

* search icon

* app multi select nits

* arginput update multiselect

* fix autofocus scrolling up

* replace High priority tags multiselect

* autoscaling config editor multiselect replace

* fix clear btn not in border

* replace multiselect in cron input

* replace multiselect in savedinputs

* replace EventHandlerItem multiselect

* select dropdown shadow

* more multiselect migration

* hover opacity on drag

* TokensTable UI fixes + replace multiselect

* ai settings replace multiselect

* DefaultTags Multiselect replace

* prevent multiselect from opening on drag

* nit

* app multiselect css + simplify

* console log

* safeSelectItems cleanup

* Remove svelte-multiselect

* clip when wrap not allowed

* hide duplicate app components

* CSS works better  with multiselect in app editor

* allowOverflow

* allowClear

* fix custom createText messed up with search
This commit is contained in:
Diego Imbert
2025-06-19 14:37:17 +02:00
committed by GitHub
parent 054f2c134a
commit fa8d1b47db
64 changed files with 969 additions and 1858 deletions
-10
View File
@@ -132,7 +132,6 @@
"svelte-check": "^4.0.0",
"svelte-floating-ui": "^1.5.8",
"svelte-highlight": "^7.6.0",
"svelte-multiselect": "^11.0.0-rc.1",
"svelte-popperjs": "^1.3.2",
"svelte-preprocess": "^6.0.0",
"svelte-range-slider-pips": "^2.3.1",
@@ -11862,15 +11861,6 @@
"resolved": "https://registry.npmjs.org/svelte-infinite-loading/-/svelte-infinite-loading-1.4.0.tgz",
"integrity": "sha512-Jo+f/yr/HmZQuIiiKKzAHVFXdAUWHW2RBbrcQTil8JVk1sCm/riy7KTJVzjBgQvHasrFQYKF84zvtc9/Y4lFYg=="
},
"node_modules/svelte-multiselect": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/svelte-multiselect/-/svelte-multiselect-11.1.0.tgz",
"integrity": "sha512-D93t6GlOV//gU7upR59uCd755Hq6OWhqv9ddhOXBDN/mEZj5XOAYHwDjUjePUKlE+bI+jry1BlZef8Mof2Y+YA==",
"dev": true,
"peerDependencies": {
"svelte": "^5.8.0"
}
},
"node_modules/svelte-popperjs": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/svelte-popperjs/-/svelte-popperjs-1.3.2.tgz",
-1
View File
@@ -57,7 +57,6 @@
"svelte-check": "^4.0.0",
"svelte-floating-ui": "^1.5.8",
"svelte-highlight": "^7.6.0",
"svelte-multiselect": "^11.0.0-rc.1",
"svelte-popperjs": "^1.3.2",
"svelte-preprocess": "^6.0.0",
"svelte-range-slider-pips": "^2.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { EnumType } from '$lib/common'
import { createEventDispatcher } from 'svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
interface Props {
disabled: boolean
+12 -32
View File
@@ -11,7 +11,6 @@
} from '$lib/utils'
import { DollarSign, Pipette, Plus, X, Check, Loader2 } from 'lucide-svelte'
import { createEventDispatcher, onMount, tick, untrack } from 'svelte'
import Multiselect from 'svelte-multiselect'
import { fade } from 'svelte/transition'
import { Button, SecondsInput } from './common'
import FieldHeader from './FieldHeader.svelte'
@@ -41,6 +40,8 @@
import type { Script } from '$lib/gen'
import type { SchemaDiff } from '$lib/components/schema/schemaUtils.svelte'
import type { ComponentCustomCSS } from './apps/types'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
interface Props {
label?: string
@@ -631,43 +632,22 @@
<div class="w-full">
{#if Array.isArray(itemsType?.multiselect) && Array.isArray(value)}
<div class="items-start">
<Multiselect
ulOptionsClass={'p-2 !bg-surface-secondary'}
outerDivClass={'dark:!border-gray-500 !border-gray-300'}
<MultiSelect
{disabled}
bind:selected={value}
onremove={(e) => {
if (Array.isArray(value)) value = value.filter((v) => v !== e.option)
}}
options={itemsType?.multiselect ?? []}
selectedOptionsDraggable={true}
onopen={() => {
dispatch('focus')
}}
bind:value
items={safeSelectItems(itemsType?.multiselect)}
onOpen={() => dispatch('focus')}
reorderable
/>
</div>
{:else if itemsType?.enum != undefined && Array.isArray(itemsType?.enum) && (Array.isArray(value) || value == undefined)}
<div class="items-start">
<Multiselect
ulOptionsClass={'p-2 !bg-surface-secondary'}
outerDivClass={'dark:!border-gray-500 !border-gray-300'}
<MultiSelect
{disabled}
onremove={(e) => {
if (Array.isArray(value)) value = value.filter((v) => v !== e.option)
}}
bind:selected={
() => [...(value ?? [])],
(v) => {
if (!deepEqual(v, value)) {
value = v
}
}
}
options={itemsType?.enum ?? []}
selectedOptionsDraggable={true}
onopen={() => {
dispatch('focus')
}}
bind:value
items={safeSelectItems(itemsType?.enum)}
onOpen={() => dispatch('focus')}
reorderable
/>
</div>
{:else if itemsType?.type == 'object' && itemsType?.resourceType == 's3object'}
@@ -9,7 +9,8 @@
import { ExternalLink } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import Label from './Label.svelte'
import MultiSelect from 'svelte-multiselect'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
interface Props {
config: AutoscalingConfig | undefined
@@ -209,32 +210,14 @@
{#if config}
{#if config.custom_tags}
<MultiSelect
outerDivClass="text-secondary !bg-surface-disabled !border-0"
selected={config.custom_tags}
onchange={(e) => {
console.log(e.type, config?.custom_tags)
if (e && config?.custom_tags) {
if (e.type === 'add') {
config.custom_tags = [
...config.custom_tags,
...(e.option ? [e.option.toString()] : [])
]
} else if (e.type === 'remove') {
config.custom_tags = config.custom_tags.filter((t) => t !== e.option)
if (config?.custom_tags && config.custom_tags.length == 0) {
config.custom_tags = undefined
}
} else if (e.type === 'removeAll') {
config.custom_tags = undefined
} else {
console.error(`Priority tags multiselect - unknown event type: '${e.type}'`)
}
bind:value={
() => config?.custom_tags ?? [],
(v) => {
config && (config.custom_tags = v.length ? v : undefined)
dispatch('dirty')
}
}}
options={worker_tags ?? []}
selectedOptionsDraggable={false}
ulOptionsClass={'!bg-surface-secondary'}
}
items={safeSelectItems(worker_tags)}
placeholder="Tags"
/>
{:else}
@@ -1,5 +1,5 @@
<script lang="ts">
import Select from './Select.svelte'
import Select from './select/Select.svelte'
interface ChannelItem {
channel_id?: string
+16 -19
View File
@@ -2,14 +2,14 @@
import { ScheduleService } from '$lib/gen'
import { emptyString, formatCron, sendUserToast } from '$lib/utils'
import Badge from './Badge.svelte'
// @ts-ignore
import Multiselect from 'svelte-multiselect'
import { Button } from './common'
import timezones from './timezones'
import CronBuilder from './CronBuilder.svelte'
import Label from './Label.svelte'
import CronGen from './copilot/CronGen.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
export let schedule: string
// export let offset: number = -60 * Math.floor(new Date().getTimezoneOffset() / 60)
@@ -237,7 +237,7 @@
<div class="text-secondary text-sm leading-none">Execute schedule every</div>
<div class="w-full flex gap-4">
<div class="w-full flex flex-col gap-1">
<div class="w-full flex flex-col gap-1 mb-2">
<select
{disabled}
name="execute_every"
@@ -280,26 +280,24 @@
<div class="w-full flex flex-col gap-4">
{#if executeEvery == 'month'}
<div class="w-full flex flex-col">
<Multiselect
<MultiSelect
disablePortal
{disabled}
bind:selected={monthsOfYear}
options={monthsOfYearOptions}
selectedOptionsDraggable={false}
bind:value={monthsOfYear}
items={safeSelectItems(monthsOfYearOptions)}
placeholder="Every month"
ulOptionsClass={'!bg-surface-secondary'}
/>
</div>
{/if}
{#if executeEvery == 'day-week'}
<div class="w-full flex flex-col">
<Multiselect
<MultiSelect
disablePortal
{disabled}
bind:selected={daysOfWeek}
options={daysOfWeekOptions}
selectedOptionsDraggable={false}
bind:value={daysOfWeek}
items={safeSelectItems(daysOfWeekOptions)}
placeholder="Every day"
ulOptionsClass={'!bg-surface-secondary'}
/>
</div>
{/if}
@@ -311,13 +309,12 @@
{/if}
<div class="w-full flex gap-4">
<div class="w-full flex">
<Multiselect
<MultiSelect
disablePortal
{disabled}
bind:selected={daysOfMonth}
options={daysOfMonthOptions}
selectedOptionsDraggable={false}
bind:value={daysOfMonth}
items={safeSelectItems(daysOfMonthOptions)}
placeholder="Every day"
ulOptionsClass={'!bg-surface-secondary'}
/>
</div>
@@ -71,7 +71,8 @@
import { getFlatTableNamesFromSchema, type DBSchema } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import DarkModeObserver from './DarkModeObserver.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
const { onConfirm, resourceType, previewSql, dbSchema, currentSchema }: DBTableEditorProps =
$props()
@@ -165,7 +166,7 @@
}
}
}
items={columnTypes.map((type) => ({ value: type, label: type }))}
items={safeSelectItems(columnTypes)}
class="w-48"
/>
</Cell>
@@ -7,7 +7,7 @@
import DefaultTagsInner from './DefaultTagsInner.svelte'
export let defaultTagPerWorkspace: boolean | undefined = undefined
export let defaultTagWorkspaces: string[] | undefined = undefined
export let defaultTagWorkspaces: string[] = []
let placement: 'bottom-end' | 'top-end' = 'bottom-end'
</script>
@@ -7,11 +7,12 @@
import { enterpriseLicense, superadmin } from '$lib/stores'
import { DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING } from '$lib/consts'
import Toggle from './Toggle.svelte'
import MultiSelectWrapper from './multiselect/MultiSelectWrapper.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
let defaultTags: string[] | undefined = undefined
export let defaultTagPerWorkspace: boolean | undefined = undefined
export let defaultTagWorkspaces: string[] | undefined = undefined
export let defaultTagWorkspaces: string[] = []
let limitToWorkspaces = false
let workspaces: string[] = []
@@ -62,7 +63,7 @@
</div>
{/each}
</div>
<div id="default-tags-settings" class="py-4 flex flex-col gap-2">
<div class="py-4 flex flex-col gap-2">
<Toggle
bind:checked={defaultTagPerWorkspace}
options={{ right: 'workspace specific default tags' }}
@@ -70,9 +71,9 @@
{#if defaultTagPerWorkspace}
<Toggle bind:checked={limitToWorkspaces} options={{ right: 'only for some workspaces' }} />
{#if limitToWorkspaces}
<MultiSelectWrapper
target="#default-tags-settings"
items={workspaces}
<MultiSelect
disablePortal
items={safeSelectItems(workspaces)}
bind:value={defaultTagWorkspaces}
/>
{/if}
@@ -18,7 +18,8 @@
import Label from './Label.svelte'
import { sendUserToast } from '$lib/toast'
import { createEventDispatcher } from 'svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
export let name: string
let can_write = false
@@ -197,7 +198,7 @@
ownerKind === 'user'
? usernames.filter((x) => !perms?.map((y) => y.owner_name).includes('u/' + x))
: groups.filter((x) => !perms?.map((y) => y.owner_name).includes('g/' + x))}
<Select items={items.map((x) => ({ label: x, value: x }))} bind:value={ownerItem} />
<Select items={safeSelectItems(items)} bind:value={ownerItem} />
{#if ownerKind == 'group'}
<Button
title="View Group"
@@ -17,7 +17,8 @@
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import Section from './Section.svelte'
import Label from './Label.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
export let name: string
let can_write = false
@@ -129,7 +130,7 @@
<Section label={`Members (${members?.length ?? 0})`}>
{#if can_write}
<div class="flex items-start">
<Select items={usernames?.map((u) => ({ value: u, label: u }))} bind:value={username} />
<Select items={safeSelectItems(usernames)} bind:value={username} />
<Button variant="contained" color="blue" size="sm" btnClasses="!ml-4" on:click={addToGroup}>
Add member
</Button>
@@ -8,7 +8,7 @@
import type { AppViewerContext } from './apps/types'
import { sendUserToast } from '$lib/toast'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
const dispatch = createEventDispatcher()
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
@@ -10,7 +10,7 @@
import { Pen, Plus, RotateCw } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { isDbType } from './apps/components/display/dbtable/utils'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
const dispatch = createEventDispatcher()
@@ -10,7 +10,8 @@
import { ExternalLink, Search } from 'lucide-svelte'
import { Popover } from './meltComponents'
import SchemaForm from './SchemaForm.svelte'
import MultiSelect from './multiselect/MultiSelectWrapper.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
const dispatch = createEventDispatcher()
interface Props {
@@ -142,7 +143,6 @@
{/snippet}
{#snippet content()}
<div id="multi-select-search"></div>
<div class="p-2 overflow-auto max-h-[400px] min-h-[300px] w-[400px]">
<div class="flex items-center flex-wrap gap-x-2 justify-between">
<div class="text-sm text-secondary">Search by args</div>
@@ -175,11 +175,10 @@
</div>
<div class="my-2">
<MultiSelect
topPlacement
target="#multi-select-search"
placeholder="arg fields to filter on"
items={Object.keys(schema?.properties ?? {})}
items={safeSelectItems(Object.keys(schema?.properties ?? {}))}
bind:value={searchArgsFields}
disablePortal
/>
</div>
{#key filteredSchema}
@@ -5,7 +5,7 @@
import { base } from '$lib/base'
import { createEventDispatcher, untrack } from 'svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { getScriptByPath } from '$lib/scripts'
import { Button, Drawer, DrawerContent } from './common'
-271
View File
@@ -1,271 +0,0 @@
<script lang="ts" generics="Item extends { label?: string; value: any; }">
import { clickOutside } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import CloseButton from './common/CloseButton.svelte'
import ConditionalPortal from './common/drawer/ConditionalPortal.svelte'
import { deepEqual } from 'fast-equals'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
type Value = Item['value']
let {
items,
placeholder = 'Please select',
value = $bindable(),
filterText: _filterTextBind = $bindable(undefined),
class: className = '',
clearable = false,
listAutoWidth = true,
disabled: _disabled = false,
containerStyle = '',
inputClass = '',
disablePortal = false,
loading = false,
autofocus,
RightIcon,
createText,
groupBy,
sortBy,
onFocus,
onBlur,
onClear,
onCreateItem
}: {
items?: Item[]
value: Value | undefined
placeholder?: string
class?: string
clearable?: boolean
filterText?: string
disabled?: boolean
listAutoWidth?: boolean
containerStyle?: string
inputClass?: string
disablePortal?: boolean
loading?: boolean
autofocus?: boolean
RightIcon?: any
createText?: string
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onFocus?: () => void
onBlur?: () => void
onClear?: () => void
onCreateItem?: (value: string) => void
} = $props()
let disabled = $derived(_disabled || loading)
let filterText = $state<string>('')
let open = $state<boolean>(false)
let keyArrowPos = $state<number | undefined>()
let inputEl: HTMLInputElement | undefined = $state()
let listEl: HTMLDivElement | undefined = $state()
$effect(() => {
if (_filterTextBind !== undefined) filterText = _filterTextBind
})
$effect(() => {
if (_filterTextBind !== undefined) _filterTextBind = filterText
})
$effect(() => {
;[filterText, open, processedItems]
keyArrowPos = open && filterText ? 0 : undefined
})
$effect(() => {
if (filterText) open = true
})
$effect(() => {
if (!open) filterText = ''
})
type ProcessedItem = Item & { __select_group?: string; __is_create?: true; label: string }
let processedItems: ProcessedItem[] = $derived.by(() => {
let items2 =
items?.map((item) => ({
...item,
label: getLabel(item)
})) ?? []
if (filterText) {
items2 = items2.filter((item) =>
item?.label?.toLowerCase().includes(filterText?.toLowerCase())
)
}
if (groupBy) {
items2 =
items2?.map((item) => ({
...item,
__select_group: groupBy(item)
})) ?? []
}
if (sortBy) {
items2 = items2?.sort(sortBy)
}
if (onCreateItem && filterText && !items2.some((item) => item.label === filterText)) {
items2.push({
label: createText ?? `Add new: "${filterText}"`,
value: filterText,
__is_create: true
} as any)
}
return items2
})
let valueEntry = $derived(value && processedItems?.find((item) => deepEqual(item.value, value)))
function setValue(item: ProcessedItem) {
if (item.__is_create && onCreateItem) {
onCreateItem(item.value)
} else {
value = item.value
}
filterText = ''
open = false
}
function clearValue() {
filterText = ''
if (onClear) onClear()
else value = undefined
}
function getLabel(item: Item | undefined): string {
if (!item) return ''
if (item.label) return item.label
if (typeof item.value === 'string') return item.value
if (typeof item.value == 'number' || typeof item.value == 'boolean')
return item.value.toString()
return JSON.stringify(item.value)
}
function computeDropdownPos(): { width: number; x: number; y: number } {
if (!inputEl || !listEl) return { width: 0, x: 0, y: 0 }
const r = inputEl.getBoundingClientRect()
const listR = listEl.getBoundingClientRect()
const openBelow = r.y + r.height + listR.height <= window.innerHeight
let [x, y] = disablePortal ? [0, 0] : [r.x, r.y]
if (openBelow) return { width: r.width, x: x, y: y + r.height }
else {
return { width: r.width, x: x, y: y - listR.height }
}
}
let dropdownPos = $state(computeDropdownPos())
$effect(() => {
function updateDropdownPos() {
let nPos = computeDropdownPos()
if (!deepEqual(nPos, dropdownPos)) dropdownPos = nPos
if (open) requestAnimationFrame(updateDropdownPos)
}
if (open) untrack(() => updateDropdownPos())
})
</script>
<svelte:window
on:keydown={(e) => {
if (!open || !processedItems?.length) return
if (e.key === 'ArrowUp' && keyArrowPos !== undefined && processedItems.length > 0) {
keyArrowPos = keyArrowPos <= 0 ? undefined : keyArrowPos - 1
} else if (e.key === 'ArrowDown') {
if (keyArrowPos === undefined) {
keyArrowPos = 0
} else {
keyArrowPos = Math.min(processedItems.length - 1, keyArrowPos + 1)
}
} else if (e.key === 'Enter' && keyArrowPos !== undefined && processedItems?.[keyArrowPos]) {
setValue(processedItems[keyArrowPos])
} else {
keyArrowPos = undefined
return
}
e.preventDefault()
}}
/>
<div
class={`relative ${className}`}
use:clickOutside={{ onClickOutside: () => (open = false) }}
onpointerdown={() => onFocus?.()}
onfocus={() => onFocus?.()}
onblur={() => onBlur?.()}
>
{#if loading}
<div class="absolute z-10 right-2 h-full flex items-center">
<Loader2 size={18} class="animate-spin" />
</div>
{:else if clearable && !disabled && value}
<div class="absolute z-10 right-2 h-full flex items-center">
<CloseButton noBg small on:close={clearValue} />
</div>
{:else if RightIcon}
<div class="absolute z-10 right-2 h-full flex items-center">
<RightIcon size={18} class="text-tertiary/35" />
</div>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
{autofocus}
{disabled}
type="text"
bind:value={() => filterText, (v) => (filterText = v)}
placeholder={loading ? 'Loading...' : (valueEntry?.label ?? placeholder)}
style={containerStyle}
class={twMerge(
'!bg-surface text-ellipsis',
open ? '' : 'cursor-pointer',
valueEntry && !loading ? '!placeholder-primary' : '',
(clearable || RightIcon) && !disabled && value ? '!pr-8' : '',
inputClass ?? ''
)}
autocomplete="off"
onpointerdown={() => (open = true)}
bind:this={inputEl}
/>
<ConditionalPortal condition={!disablePortal}>
{#if open && !disabled}
<div
class={twMerge(
disablePortal ? 'absolute' : 'fixed',
'flex flex-col z-[5001] max-h-64 overflow-y-auto bg-surface-secondary text-tertiary text-sm select-none border rounded-lg'
)}
style="{`top: ${dropdownPos.y}px; left: ${dropdownPos.x}px;`} {listAutoWidth
? `min-width: ${dropdownPos.width}px;`
: ''}"
bind:this={listEl}
>
{#if processedItems?.length === 0}
<div class="py-8 px-4 text-center text-primary">No items</div>
{/if}
{#each processedItems ?? [] as item, itemIndex}
{#if (item.__select_group && itemIndex === 0) || processedItems?.[itemIndex - 1]?.__select_group !== item.__select_group}
<div
class={twMerge(
'mx-4 pb-1 mb-2 text-xs font-semibold text-primary border-b',
itemIndex === 0 ? 'mt-3' : 'mt-6'
)}
>
{item.__select_group}
</div>
{/if}
<button
class={twMerge(
'py-2 px-4 w-full font-normal text-left text-primary',
itemIndex === keyArrowPos ? 'bg-surface-hover' : '',
item.value === value ? 'bg-surface-selected' : 'hover:bg-surface-hover'
)}
onclick={(e) => {
e.stopImmediatePropagation()
setValue(item)
}}
>
{item.label}
</button>
{/each}
</div>
{/if}
</ConditionalPortal>
</div>
@@ -11,14 +11,13 @@
import { sendUserToast } from '$lib/toast'
import { onDestroy, tick, untrack } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { copyToClipboard, truncateRev } from '$lib/utils'
import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils'
import LogSnippetViewer from './LogSnippetViewer.svelte'
import { Button, Drawer, DrawerContent } from './common'
import ClipboardCopy from 'lucide-svelte/icons/clipboard-copy'
import AnsiUp from 'ansi_up'
import { scroll_into_view_if_needed_polyfill } from './multiselect/utils'
import SplitPanesOrColumnOnMobile from './splitPanes/SplitPanesOrColumnOnMobile.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { goto } from '$lib/navigation'
import { page } from '$app/stores'
@@ -13,7 +13,8 @@
import { isOwner } from '$lib/utils'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
const dispatch = createEventDispatcher()
@@ -138,9 +139,9 @@
</div>
{#key ownerKind}
<Select
items={(ownerKind === 'user' ? usernames : groups)
.map((x) => x.toString())
.map((x) => ({ value: x, label: x }))}
items={safeSelectItems(
(ownerKind === 'user' ? usernames : groups).map((x) => x.toString())
)}
bind:value={owner}
/>
{/key}
@@ -4,7 +4,8 @@
import { superadmin } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { defaultTags, nativeTags } from './worker_group'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
const dispatch = createEventDispatcher()
type Props = {
@@ -51,9 +52,11 @@
{#if $superadmin}
<div class="max-w-md space-y-2">
<Select
items={[...(customTags ?? []), ...createdTags, ...defaultTags, ...nativeTags]
.filter((x) => !worker_tags?.includes(x))
.map((x) => ({ value: x, label: x }))}
items={safeSelectItems(
[...(customTags ?? []), ...createdTags, ...defaultTags, ...nativeTags].filter(
(x) => !worker_tags?.includes(x)
)
)}
{disabled}
bind:value={newTag}
onFocus={() => dispatch('focus')}
@@ -2,7 +2,7 @@
import { workspaceStore } from '$lib/stores'
import { RefreshCcw } from 'lucide-svelte'
import { WorkspaceService } from '$lib/gen'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { onMount } from 'svelte'
interface TeamItem {
+11 -30
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { Copy, Plus, RefreshCcwIcon, Settings, Trash, X } from 'lucide-svelte'
import { Alert, Badge, Button, Drawer } from './common'
import Multiselect from 'svelte-multiselect'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import { ConfigService, WorkspaceService, type WorkerPing, type Workspace } from '$lib/gen'
@@ -20,7 +19,9 @@
import { defaultTags, nativeTags, type AutoscalingConfig } from './worker_group'
import AutoscalingConfigEditor from './AutoscalingConfigEditor.svelte'
import TagsToListenTo from './TagsToListenTo.svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
function computeVCpuAndMemory(workers: [string, WorkerPing[]][]) {
let vcpus = 0
@@ -340,7 +341,7 @@
{#if defaultTagPerWorkspace}
<Select
bind:value={workspaceTag}
items={workspaces.map((w) => ({ value: w.id, label: w.id }))}
items={workspaces.map((w) => ({ value: w.id }))}
onCreateItem={(c) => (workspaceTag = c)}
placeholder="Workspace ID"
/>
@@ -358,36 +359,16 @@
{/if}
</Tooltip>
{/snippet}
<Multiselect
outerDivClass="text-secondary !bg-surface-disabled !border-0"
<MultiSelect
disabled={!$enterpriseLicense}
selected={Object.keys(nconfig?.priority_tags ?? {})}
onchange={(e) => {
if (e.type === 'add') {
if (nconfig.priority_tags) {
if (e.option && typeof e.option !== 'object') {
nconfig.priority_tags[e.option] = 100
}
}
bind:value={
() => new Array(...(nconfig?.priority_tags?.keys?.() ?? [])),
(v) => {
nconfig.priority_tags = new Map<string, number>(v.map((k) => [k, 100]))
dirty = true
} else if (e.type === 'remove') {
if (nconfig.priority_tags) {
if (e.option && typeof e.option !== 'object') {
delete nconfig.priority_tags[e.option]
}
}
dirty = true
} else if (e.type === 'removeAll') {
nconfig.priority_tags = new Map<string, number>()
dirty = true
} else {
console.error(`Priority tags multiselect - unknown event type: '${e.type}'`)
}
}}
options={nconfig?.worker_tags}
selectedOptionsDraggable={false}
ulOptionsClass={'!bg-surface-secondary'}
placeholder="High priority tags"
}
items={safeSelectItems(nconfig?.worker_tags)}
/>
</Label>
{/if}
@@ -19,7 +19,7 @@
import Tooltip from './Tooltip.svelte'
import { JobService, type QueuedJob } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { emptyString } from '$lib/utils'
let container: HTMLDivElement
@@ -3,7 +3,8 @@
import { WorkerService } from '$lib/gen'
import { createEventDispatcher } from 'svelte'
import Select from './Select.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
let {
tag = $bindable(),
@@ -41,7 +42,7 @@
class="w-full"
{disabled}
placeholder={nullTag ? `default: ${nullTag}` : 'lang default'}
items={items.map((value) => ({ value }))}
items={safeSelectItems(items)}
bind:value={() => tag, (value) => ((tag = value), dispatch('change', value))}
/>
</div>
@@ -101,7 +101,6 @@
componentStyle={$app.css?.formcomponent}
/>
{/each}
<RunnableWrapper
{recomputeIds}
{render}
@@ -11,7 +11,8 @@
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import { twMerge } from 'tailwind-merge'
import { enUS, fr, de, pt, ja } from 'date-fns/locale'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
export let id: string
export let configuration: RichConfigurations
@@ -238,9 +239,10 @@
outputs.day.set(v ? Number(v) : undefined)
}
}
items={Array.from({ length: computeDayPerMonth(selectedMonth, selectedYear) }, (_, i) => {
return { label: String(i + 1), value: String(i + 1) }
})}
items={Array.from(
{ length: computeDayPerMonth(selectedMonth, selectedYear) },
(_, i) => ({ value: String(i + 1) })
)}
class={twMerge('text-clip min-w-0', css?.input?.class, 'wm-date-select')}
containerStyle={css?.input?.style}
placeholder="Pick a day"
@@ -280,9 +282,7 @@
outputs.year.set(selectedYear ? Number(selectedYear) : undefined)
}
}
items={Array.from({ length: 201 }, (_, i) => `${1900 + i}`).map((value) => ({
value
}))}
items={safeSelectItems(Array.from({ length: 201 }, (_, i) => `${1900 + i}`))}
placeholder="Pick a year"
class={twMerge('text-clip min-w-0', css?.input?.class, 'wm-date-select')}
containerStyle={css?.input?.style}
@@ -3,9 +3,6 @@
import AlignWrapper from '../helpers/AlignWrapper.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
// @ts-ignore
import MultiSelect from 'svelte-multiselect'
export let id: string
export let configuration: RichConfigurations
export let customCss: ComponentCustomCSS<'multiselectcomponent'> | undefined = undefined
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte'
import { getContext, onDestroy, untrack } from 'svelte'
import { initConfig, initOutput } from '../../editor/appUtils'
import type {
AppViewerContext,
@@ -16,61 +16,50 @@
// @ts-ignore
import Portal from '$lib/components/Portal.svelte'
import { createFloatingActions } from 'svelte-floating-ui'
import { extractCustomProperties } from '$lib/utils'
import { tick } from 'svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import MultiSelect from '$lib/components/multiselect/MultiSelect.svelte'
import { deepEqual } from 'fast-equals'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import { twMerge } from 'tailwind-merge'
export let id: string
export let configuration: RichConfigurations
export let customCss: ComponentCustomCSS<'multiselectcomponent'> | undefined = undefined
export let render: boolean
export let verticalAlignment: 'top' | 'center' | 'bottom' | undefined = undefined
interface Props {
id: string
configuration: RichConfigurations
customCss?: ComponentCustomCSS<'multiselectcomponentv2'> | undefined
render: boolean
verticalAlignment?: 'top' | 'center' | 'bottom' | undefined
}
let {
id,
configuration,
customCss = undefined,
render,
verticalAlignment = undefined
}: Props = $props()
const iterContext = getContext<ListContext>('ListWrapperContext')
const listInputs: ListInputs | undefined = getContext<ListInputs>('ListInputs')
const [floatingRef, floatingContent] = createFloatingActions({
strategy: 'absolute',
middleware: [offset(5), flip(), shift()]
})
const { app, worldStore, selectedComponent, componentControl } =
getContext<AppViewerContext>('AppViewerContext')
let items: (string | { value: string; label: any })[] = []
const resolvedConfig = initConfig(
components['multiselectcomponent'].initialData.configuration,
configuration
let items: { value: string; label?: any }[] = $state([])
const resolvedConfig = $state(
initConfig(components['multiselectcomponentv2'].initialData.configuration, configuration)
)
const outputs = initOutput($worldStore, id, {
result: [] as string[]
})
let selectedItems: (number | string | { value: string; label: any })[] | undefined = [
...new Set(outputs?.result.peak())
] as (number | string | { value: string; label: any })[]
let selectedItems: string[] = $state([...new Set(outputs?.result.peak())].map(convertToValue))
$effect(() => setResultsFromSelectedItems(selectedItems))
function setResultsFromSelectedItems() {
const value = [
...(selectedItems?.map((item) => {
if (typeof item == 'number') {
return item.toString()
} else if (typeof item == 'object' && item.value != undefined && item.label != undefined) {
return item?.value ?? `NOT_STRING`
} else if (typeof item == 'string') {
return item
} else if (typeof item == 'object' && item.label != undefined) {
return item.label
} else {
return 'NOT_STRING'
}
}) ?? [])
]
let customItems: string[] = $state([])
function setResultsFromSelectedItems(value: string[]) {
outputs?.result.set(value)
setContextValue(value)
}
@@ -95,82 +84,60 @@
}
}
$: resolvedConfig.items && handleItems()
function handleItems() {
if (Array.isArray(resolvedConfig.items)) {
items = resolvedConfig.items?.map((item) => {
if (typeof item == 'object' && item.value != undefined && item.label != undefined) {
return item
}
if (typeof item == 'number') {
return item.toString()
}
return typeof item === 'string' ? item : `NOT_STRING`
})
items = resolvedConfig.items?.map(convertToItem)
}
}
$: resolvedConfig.defaultItems && setSelectedItemsFromValues(resolvedConfig.defaultItems)
function convertToItem(v: any) {
if (typeof v == 'object' && v.value != undefined && v.label != undefined) {
return v as { value: any; label?: string }
}
if (typeof v == 'number') return { value: v.toString() }
return { value: typeof v === 'string' ? v : `NOT_STRING` }
}
function convertToValue(item: any): string {
if (typeof item == 'object' && item.value != undefined) return item.value
if (typeof item == 'number') return item.toString()
if (typeof item == 'string') return item
return item?.toString?.() ?? 'NOT_STRING'
}
function setSelectedItemsFromValues(values: any[]) {
if (Array.isArray(values)) {
const nvalue = values
.map((value) => {
const x = items.find((item) => {
if (typeof item == 'object' && item.value != undefined && item.label != undefined) {
return deepEqual(item.value, value)
}
return item == value
})
return (
items.find((item) => {
if (typeof item == 'object' && item.value != undefined && item.label != undefined) {
return deepEqual(item.value, value)
}
return item == value
}) ??
(typeof x === 'object' ? x.value : x) ??
(typeof value == 'string' ? value : undefined) ??
(typeof value == 'number' ? value.toString() : undefined)
)
})
.filter((item) => item != undefined)
selectedItems = [...new Set(nvalue)]
setResultsFromSelectedItems()
setResultsFromSelectedItems(selectedItems)
}
}
let css = initCss($app.css?.multiselectcomponent, customCss)
let css = $state(initCss($app.css?.multiselectcomponentv2, customCss))
function setOuterDivStyle(outerDiv: HTMLDivElement, portalRef: HTMLDivElement, style: string) {
outerDiv.setAttribute('style', style)
// find ul in portalRef and set style
const ul = portalRef.querySelector('ul')
ul?.setAttribute('style', extractCustomProperties(style))
}
$: outerDiv &&
portalRef &&
css?.multiselect?.style &&
setOuterDivStyle(outerDiv, portalRef, css?.multiselect?.style)
let outerDiv: HTMLDivElement | undefined = undefined
let portalRef: HTMLDivElement | undefined = undefined
function moveOptionsToPortal() {
// Find ul element with class 'options' within the outerDiv
const ul = outerDiv?.querySelector('.options')
if (ul) {
// Move the ul element to the portal
portalRef?.appendChild(ul)
}
}
$: if (render && portalRef && outerDiv && items?.length > 0) {
tick().then(() => {
moveOptionsToPortal()
})
}
let w = 0
let open: boolean = false
$effect(() => {
resolvedConfig.items && untrack(() => handleItems())
})
$effect(() => {
;[resolvedConfig.defaultItems]
untrack(() => setSelectedItemsFromValues(resolvedConfig.defaultItems))
})
</script>
{#each Object.keys(components['multiselectcomponent'].initialData.configuration) as key (key)}
{#each Object.keys(components['multiselectcomponentv2'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
{key}
@@ -185,7 +152,7 @@
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.multiselectcomponent}
componentStyle={$app.css?.multiselectcomponentv2}
/>
{/each}
@@ -193,74 +160,33 @@
<AlignWrapper {render} hFull {verticalAlignment}>
<div
class="w-full app-editor-input"
on:pointerdown={(e) => {
class={twMerge('w-full', resolvedConfig.allowOverflow ? '' : 'absolute inset-0')}
onpointerdown={(e) => {
$selectedComponent = [id]
if (!e.shiftKey) {
e.stopPropagation()
}
if (!e.shiftKey) e.stopPropagation()
selectedComponent.set([id])
}}
use:floatingRef
bind:clientWidth={w}
>
{#if !selectedItems || Array.isArray(selectedItems)}
<MultiSelect
bind:outerDiv
outerDivClass={`${resolvedConfig.allowOverflow ? '' : 'h-full'}`}
ulSelectedClass={`${resolvedConfig.allowOverflow ? '' : 'overflow-auto max-h-full'} `}
--sms-border={'none'}
--sms-min-height={'32px'}
--sms-focus-border={'none'}
bind:selected={selectedItems}
options={items}
placeholder={resolvedConfig.placeholder}
allowUserOptions={resolvedConfig.create}
on:change={(event) => {
if (event?.detail?.type === 'removeAll') {
outputs?.result.set([])
setContextValue([])
} else {
setResultsFromSelectedItems()
<MultiSelect
style={css.multiselect?.style}
class={twMerge(
'multiselect',
resolvedConfig.allowOverflow ? '' : 'max-h-full overflow-y-scroll'
)}
selectedUlClass="selected"
items={safeSelectItems([...items, ...customItems])}
placeholder={resolvedConfig.placeholder}
bind:value={selectedItems}
disabled={resolvedConfig.disabled}
onCreateItem={resolvedConfig.create
? (item) => {
customItems.push(item)
selectedItems.push(item)
customItems = customItems
selectedItems = selectedItems
}
}}
on:open={() => {
$selectedComponent = [id]
open = true
}}
on:close={() => {
open = false
}}
let:option
>
<!-- needed because portal doesn't work for mouseup event en mobile -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="w-full"
on:mouseup|stopPropagation
on:pointerdown|stopPropagation={(e) => {
let newe = new MouseEvent('mouseup')
e.target?.['parentElement']?.dispatchEvent(newe)
}}
>
{typeof option == 'object' ? (option?.label ?? 'NO_LABEL') : option}
</div>
</MultiSelect>
<Portal name="app-multiselect-v2">
<div use:floatingContent class="z5000" hidden={!open}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={portalRef}
class="multiselect"
style={`min-width: ${w}px;`}
on:click|stopPropagation
></div>
</div>
</Portal>
{:else}
Value {selectedItems} is not an array
{/if}
: undefined}
onOpen={() => ($selectedComponent = [id])}
/>
</div>
</AlignWrapper>
@@ -19,7 +19,7 @@
import { Bug } from 'lucide-svelte'
import Popover from '$lib/components/Popover.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
interface Props {
id: string
@@ -1256,7 +1256,7 @@
<SecondaryMenu right />
{:else}
<div class="min-w-[150px] text-sm !text-secondary text-center py-8 px-2">
Select a component to see the settings&nbsp;for&nbsp;it
Select a component to see the settings for it
</div>
{/if}
</TabContent>
@@ -127,6 +127,7 @@
}
</script>
<input />
<div class="w-full z-[1000] overflow-visible h-full">
<div class={$app.hideLegacyTopBar ? 'hidden' : ''}>
<div
@@ -2354,6 +2354,11 @@ This is a paragraph.
tooltip:
'If too many items, the box overflow its container instead of having an internal scroll'
},
disabled: {
type: 'static',
value: false,
fieldType: 'boolean'
}
}
}
@@ -110,5 +110,7 @@ export const COMPONENT_SETS = [layout, tabs, buttons, inputs, tables, display, c
export const DEPRECATED_COMPONENTS = {
tablecomponent:
'We will be removing this component in the future. we recommend using the AgGrid table instead.'
'We will be removing this component in the future. we recommend using the AgGrid table instead.',
chartjscomponent: 'Use the new ChartJS v2 component instead',
multiselectcomponent: 'Use the new MultiSelect v2 component instead'
}
@@ -4,7 +4,7 @@
import { Badge, Button, ClearableInput, Tab, TabContent, Tabs } from '../../../common'
import type { AppViewerContext } from '../../types'
import ListItem from './ListItem.svelte'
import { ccomponents, components } from '../component'
import { ccomponents, components, DEPRECATED_COMPONENTS } from '../component'
import { customisationByComponent } from './cssUtils'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
@@ -60,7 +60,7 @@
ids: ['q'].map((id) => ({ id, forceStyle: true, forceClass: true }))
},
...Object.entries(ccomponents)
.filter(([key]) => key !== 'quillcomponent')
.filter(([key]) => !['quillcomponent', ...Object.keys(DEPRECATED_COMPONENTS)].includes(key))
.map(([type, { name, icon, customCss }]) => ({
type: type as keyof typeof components,
name,
@@ -676,8 +676,8 @@ export const customisationByComponent: Customisation[] = [
comment: 'buttons to remove a single or all selected options at once'
},
{
selector: '.multiselect > input[autocomplete]',
comment: 'input inside the top-level wrapper div'
selector: '.multiselect.dropdown',
comment: 'dropdown container'
},
{
selector: '.multiselect > ul.options',
@@ -686,23 +686,6 @@ export const customisationByComponent: Customisation[] = [
{
selector: '.multiselect > ul.options > li',
comment: 'dropdown list items'
},
{
selector: '.multiselect > ul.options > li.selected',
comment: 'selected options in the dropdown list'
},
{
selector: '.multiselect > ul.options > li:not(.selected):hover',
comment: 'unselected but hovered options in the dropdown list'
},
{
selector: '.multiselect > ul.options > li.active',
comment:
'active item, navigated to with up/down arrow keys and ready to be selected by pressing enter'
},
{
selector: '.multiselect > ul.options > li.disabled',
comment: 'options with disabled key set to true'
}
],
variables: [
@@ -1,11 +1,16 @@
<script lang="ts">
import Tooltip from '$lib/components/Tooltip.svelte'
import MultiSelect from '$lib/components/multiselect/MultiSelectWrapper.svelte'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import { twMerge } from 'tailwind-merge'
let { items, value = $bindable(), title, tooltip }: {
let {
items,
value = $bindable(),
title,
tooltip
}: {
items: string[]
value: string[] | undefined
title: string
@@ -34,7 +39,10 @@
No components to recompute.
</div>
{:else}
<MultiSelect {items} bind:value />
<MultiSelect
items={safeSelectItems(items)}
bind:value={() => value ?? [], (v) => (value = v?.length ? v : undefined)}
/>
{/if}
</div>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { StaticInput, StaticOptions } from '../../../inputType'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
interface Props {
componentInput: StaticInput<any> | undefined
@@ -34,8 +34,9 @@
import { onDestroy, tick, untrack } from 'svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import Select from '../Select.svelte'
import Select from '../select/Select.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { safeSelectItems } from '../select/utils.svelte'
let usernames: string[] | undefined = $state()
let resources = usePromise(() => loadResources($workspaceStore!), { loadInit: false })
@@ -397,7 +398,7 @@
onCreateItem={(r) => (resources.value?.push(r), (resource = r))}
createText="Press enter to use this value"
bind:value={resource}
items={['all', ...(resources.value ?? [])].map((r) => ({ value: r, label: r }))}
items={safeSelectItems(['all', ...(resources.value ?? [])])}
inputClass="dark:!bg-gray-700"
RightIcon={ChevronDown}
/>
@@ -18,6 +18,7 @@
<Button
on:click={() => dispatch('close')}
on:pointerdown={(e) => e.stopPropagation()}
startIcon={{ icon: Icon ?? X }}
iconOnly
size="sm"
@@ -1,12 +1,23 @@
<script lang="ts">
import Portal from '$lib/components/Portal.svelte'
export let condition = false
export let target: string | HTMLElement | undefined = undefined
interface Props {
condition?: boolean
target?: string | HTMLElement | undefined
name?: string
children?: import('svelte').Snippet
}
let {
condition = false,
target = undefined,
name = 'conditional-portal',
children
}: Props = $props()
</script>
{#if condition}
<Portal name="conditional-portal" {target}><slot /></Portal>
<Portal {name} {target}>{@render children?.()}</Portal>
{:else}
<slot />
{@render children?.()}
{/if}
@@ -1,828 +0,0 @@
<script lang="ts">
import { createEventDispatcher, tick } from 'svelte'
import { flip } from 'svelte/animate'
import VirtualList from 'svelte-tiny-virtual-list'
import { twMerge } from 'tailwind-merge'
import type { DispatchEvents, MultiSelectEvents, Option as T } from './types'
import { get_label, get_style, scroll_into_view_if_needed_polyfill } from './utils'
import { ChevronsUpDown, X } from 'lucide-svelte'
import type { FullAutoFill } from 'svelte/elements'
type Option = $$Generic<T>
export let activeIndex: number | null = null
export let activeOption: Option | null = null
export let createOptionMsg: string | null = `Create this option...`
export let allowUserOptions: boolean | 'append' = false
export let autocomplete: FullAutoFill = `off`
export let autoScroll: boolean = true
export let breakpoint: number = 800 // any screen with more horizontal pixels is considered desktop, below is mobile
export let defaultDisabledTitle: string = `This option is disabled`
export let disabled: boolean = false
export let disabledInputTitle: string = `This input is disabled`
// prettier-ignore
export let duplicateOptionMsg: string = `This option is already selected`
export let duplicates: boolean = false // whether to allow duplicate options
// takes two options and returns true if they are equal
// case-insensitive equality comparison after string coercion and looks only at the `label` key of object options by default
export let key: (opt: T) => unknown = (opt) => `${get_label(opt)}`.toLowerCase()
export let filterFunc = (opt: Option, searchText: string): boolean => {
if (!searchText) return true
return `${get_label(opt)}`.toLowerCase().includes(searchText.toLowerCase())
}
export let closeDropdownOnSelect: boolean | 'desktop' = `desktop`
export let form_input: HTMLInputElement | null = null
export let highlightMatches: boolean = true
export let id: string | null = null
export let input: HTMLInputElement | null = null
export let inputClass: string = ``
export let inputStyle: string | null = null
export let inputmode:
| 'search'
| 'text'
| 'none'
| 'tel'
| 'url'
| 'email'
| 'numeric'
| 'decimal'
| null
| undefined = null
export let invalid: boolean = false
export let liSelectedClass: string = ``
export let liSelectedStyle: string | null = null
export let loading: boolean = false
export let matchingOptions: Option[] = []
export let maxSelect: number | null = null // null means there is no upper limit for selected.length
export let name: string | null = null
export let noMatchingOptionsMsg: string = `No matching options`
export let open: boolean = false
export let options: Option[]
export let outerDiv: HTMLDivElement | null = null
export let outerDivClass: string = ``
export let parseLabelsAsHtml: boolean = false // should not be combined with allowUserOptions!
export let pattern: string | null = null
export let placeholder: string | null = null
export let removeAllTitle: string = `Remove all`
export let removeBtnTitle: string = `Remove`
export let minSelect: number | null = null // null means there is no lower limit for selected.length
export let required: boolean = false
export let resetFilterOnAdd: boolean = true
export let searchText: string = ``
export let selected: Option[] =
options
?.filter((opt: any) => opt instanceof Object && opt?.preselected)
.slice(0, maxSelect ?? undefined) ?? [] // don't allow more than maxSelect preselected options
export let sortSelected: boolean | ((op1: Option, op2: Option) => number) = false
export let selectedOptionsDraggable: boolean = !sortSelected
export let style: string | null = null
export let ulSelectedClass: string = ``
export let ulSelectedStyle: string | null = null
export let value: Option | Option[] | null = null
export let disableRemoveAll: boolean = false
const selected_to_value = (selected: Option[]) => {
value = maxSelect === 1 ? (selected[0] ?? null) : selected
}
const value_to_selected = (value: Option | Option[] | null) => {
if (maxSelect === 1) selected = value ? [value as Option] : []
else selected = (value as Option[]) ?? []
}
// if maxSelect=1, value is the single item in selected (or null if selected is empty)
// this solves both https://github.com/janosh/svelte-multiselect/issues/86 and
// https://github.com/janosh/svelte-multiselect/issues/136
$: selected_to_value(selected)
$: value_to_selected(value)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type $$Events = MultiSelectEvents // for type-safe event listening on this component
const dispatch = createEventDispatcher<DispatchEvents<Option>>()
let option_msg_is_active: boolean = false // controls active state of <li>{createOptionMsg}</li>
let window_width: number
// options matching the current search text
$: matchingOptions = options?.filter(
(opt) =>
filterFunc(opt, searchText) &&
// remove already selected options from dropdown list unless duplicate selections are allowed
(!selected.map(key).includes(key(opt)) || duplicates)
)
// raise if matchingOptions[activeIndex] does not yield a value
if (activeIndex !== null && !matchingOptions[activeIndex]) {
throw `Run time error, activeIndex=${activeIndex} is out of bounds, matchingOptions.length=${matchingOptions.length}`
}
// update activeOption when activeIndex changes
$: activeOption = matchingOptions?.[activeIndex ?? -1] ?? null
// add an option to selected list
function add(option: T, event: Event) {
if (maxSelect && maxSelect > 1 && selected.length >= maxSelect) {
return
}
if (!isNaN(Number(option)) && typeof selected.map(get_label)[0] === `number`) {
option = Number(option) as Option // convert to number if possible
}
const is_duplicate = selected.map(key).includes(key(option))
if (
(maxSelect === null || maxSelect === 1 || selected.length < maxSelect) &&
(duplicates || !is_duplicate)
) {
if (
// @ts-ignore
!options.includes(option) && // first check if we find option in the options list
// this has the side-effect of not allowing to user to add the same
// custom option twice in append mode
[true, `append`].includes(allowUserOptions) &&
searchText.length > 0
) {
// user entered text but no options match, so if allowUserOptions = true | 'append', we create
// a new option from the user-entered text
if (typeof options[0] === `object`) {
// if 1st option is an object, we create new option as object to keep type homogeneity
option = { label: searchText } as Option
} else {
if ([`number`, `undefined`].includes(typeof options[0]) && !isNaN(Number(searchText))) {
// create new option as number if it parses to a number and 1st option is also number or missing
option = Number(searchText) as Option
} else {
option = searchText as Option // else create custom option as string
}
// @ts-ignore
dispatch(`create`, { option })
}
// @ts-ignore
if (allowUserOptions === `append`) options = [...options, option]
}
if (resetFilterOnAdd) searchText = `` // reset search string on selection
if ([``, undefined, null].includes(option as string | null)) {
console.error(`MultiSelect: encountered falsy option ${option}`)
return
}
if (maxSelect === 1) {
// for maxSelect = 1 we always replace current option with new one
// @ts-ignore
selected = [option]
} else {
// @ts-ignore
selected = [...selected, option]
if (sortSelected === true) {
selected = selected.sort((op1: Option, op2: Option) => {
const [label1, label2] = [get_label(op1), get_label(op2)]
// coerce to string if labels are numbers
return `${label1}`.localeCompare(`${label2}`)
})
} else if (typeof sortSelected === `function`) {
selected = selected.sort(sortSelected)
}
}
const reached_max_select = selected.length === maxSelect
const dropdown_should_close =
closeDropdownOnSelect === true ||
(closeDropdownOnSelect === `desktop` && window_width < breakpoint)
if (reached_max_select || dropdown_should_close) {
close_dropdown(event)
} else if (!dropdown_should_close) {
input?.focus()
}
// @ts-ignore
dispatch(`add`, { option })
// @ts-ignore
dispatch(`change`, { option, type: `add` })
invalid = false // reset error status whenever new items are selected
form_input?.setCustomValidity(``)
}
}
// remove an option from selected list
function remove(to_remove: T) {
if (selected.length === 0) return
const idx = selected.findIndex((opt) => key(opt) === key(to_remove))
let [option] = selected.splice(idx, 1) // remove option from selected list
if (option === undefined && allowUserOptions) {
// if option with label could not be found but allowUserOptions is truthy,
// assume it was created by user and create corresponding option object
// on the fly for use as event payload
const other_ops_type = typeof options[0]
option = (other_ops_type ? { label: to_remove } : to_remove) as Option
}
if (option === undefined) {
return console.error(
`Multiselect can't remove selected option ${JSON.stringify(
to_remove
)}, not found in selected list`
)
}
selected = [...selected] // trigger Svelte rerender
invalid = false // reset error status whenever items are removed
form_input?.setCustomValidity(``)
dispatch(`remove`, { option })
dispatch(`change`, { option, type: `remove` })
}
function open_dropdown(event: Event) {
if (disabled) return
open = true
if (!(event instanceof FocusEvent)) {
// avoid double-focussing input when event that opened dropdown was already input FocusEvent
input?.focus()
}
dispatch(`open`, { event })
}
function close_dropdown(event: Event) {
open = false
input?.blur()
activeIndex = null
dispatch(`close`, { event })
}
// handle all keyboard events this component receives
async function handle_keydown(event: KeyboardEvent) {
// on escape or tab out of input: close options dropdown and reset search text
if (event.key === `Escape` || event.key === `Tab`) {
close_dropdown(event)
searchText = ``
}
// on enter key: toggle active option and reset search text
else if (event.key === `Enter`) {
event.preventDefault() // prevent enter key from triggering form submission
if (activeOption) {
selected.includes(activeOption) ? remove(activeOption) : add(activeOption, event)
searchText = ``
} else if (allowUserOptions && searchText.length > 0) {
// user entered text but no options match, so if allowUserOptions is truthy, we create new option
add(searchText, event)
}
// no active option and no search text means the options dropdown is closed
// in which case enter means open it
else open_dropdown(event)
}
// on up/down arrow keys: update active option
else if ([`ArrowDown`, `ArrowUp`].includes(event.key)) {
// if no option is active yet, but there are matching options, make first one active
if (activeIndex === null && matchingOptions.length > 0) {
activeIndex = 0
return
} else if (allowUserOptions && !matchingOptions.length && searchText.length > 0) {
// if allowUserOptions is truthy and user entered text but no options match, we make
// <li>{addUserMsg}</li> active on keydown (or toggle it if already active)
option_msg_is_active = !option_msg_is_active
return
} else if (activeIndex === null) {
// if no option is active and no options are matching, do nothing
return
}
event.preventDefault()
// if none of the above special cases apply, we make next/prev option
// active with wrap around at both ends
const increment = event.key === `ArrowUp` ? -1 : 1
activeIndex = (activeIndex + increment) % matchingOptions.length
// in JS % behaves like remainder operator, not real modulo, so negative numbers stay negative
// need to do manual wrap around at 0
if (activeIndex < 0) activeIndex = matchingOptions.length - 1
if (autoScroll) {
await tick()
const li = document.querySelector(`ul.options > li.active`)
if (li) scroll_into_view_if_needed_polyfill(li)
}
}
// on backspace key: remove last selected option
else if (event.key === `Backspace` && selected.length > 0 && !searchText) {
remove(selected.at(-1) as Option)
}
// make first matching option active on any keypress (if none of the above special cases match)
else if (matchingOptions.length > 0) {
activeIndex = 0
}
}
function remove_all() {
dispatch(`removeAll`, { options: selected })
dispatch(`change`, { options: selected, type: `removeAll` })
selected = []
searchText = ``
}
$: is_selected = (label: string | number) => selected.map(get_label).includes(label)
const if_enter_or_space = (handler: () => void) => (event: KeyboardEvent) => {
if ([`Enter`, `Space`].includes(event.code)) {
event.preventDefault()
handler()
}
}
function on_click_outside(event: MouseEvent | TouchEvent) {
if (outerDiv && !outerDiv.contains(event.target as Node)) {
close_dropdown(event)
}
}
let drag_idx: number | null = null
// event handlers enable dragging to reorder selected options
const drop = (target_idx: number) => (event: DragEvent) => {
if (!event.dataTransfer) return
event.dataTransfer.dropEffect = `move`
const start_idx = parseInt(event.dataTransfer.getData(`text/plain`))
const new_selected = [...selected]
if (start_idx < target_idx) {
new_selected.splice(target_idx + 1, 0, new_selected[start_idx])
new_selected.splice(start_idx, 1)
} else {
new_selected.splice(target_idx, 0, new_selected[start_idx])
new_selected.splice(start_idx + 1, 1)
}
selected = new_selected
drag_idx = null
}
const dragstart = (idx: number) => (event: DragEvent) => {
if (!event.dataTransfer) return
// only allow moving, not copying (also affects the cursor during drag)
event.dataTransfer.effectAllowed = `move`
event.dataTransfer.dropEffect = `move`
event.dataTransfer.setData(`text/plain`, `${idx}`)
}
let ul_options: HTMLUListElement
// highlight text matching user-entered search text in available options
function highlight_matching_options(
event: Event & {
currentTarget: EventTarget & HTMLInputElement
}
) {
// @ts-ignore
if (!highlightMatches || !ul_options || typeof CSS == `undefined` || !CSS.highlights) return // abort if CSS highlight API not supported
// clear previous ranges from HighlightRegistry
// @ts-ignore
CSS.highlights.clear()
// get input's search query
const query = (event?.target as HTMLInputElement)?.value.trim().toLowerCase()
if (!query) return
const tree_walker = document.createTreeWalker(ul_options, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
// don't highlight text in the "no matching options" message
if (node?.textContent === noMatchingOptionsMsg) return NodeFilter.FILTER_REJECT
return NodeFilter.FILTER_ACCEPT
}
})
const text_nodes: Node[] = []
let current_node = tree_walker.nextNode()
while (current_node) {
text_nodes.push(current_node)
current_node = tree_walker.nextNode()
}
// iterate over all text nodes and find matches
const ranges = text_nodes.map((el) => {
const text = el.textContent?.toLowerCase()
const indices: number[] = []
let start_pos = 0
while (text && start_pos < text.length) {
const index = text.indexOf(query, start_pos)
if (index === -1) break
indices.push(index)
start_pos = index + query.length
}
// create range object for each str found in the text node
return indices.map((index) => {
const range = new Range()
range.setStart(el, index)
range.setEnd(el, index + query.length)
return range
})
})
// create Highlight object from ranges and add to registry
// @ts-ignore
CSS.highlights.set(`sms-search-matches`, new Highlight(...ranges.flat()))
}
// reset form validation when required prop changes
// https://github.com/janosh/svelte-multiselect/issues/285
$: required, form_input?.setCustomValidity(``)
</script>
<svelte:window
on:click={on_click_outside}
on:touchstart={on_click_outside}
bind:innerWidth={window_width}
/>
<div
bind:this={outerDiv}
class:disabled
class:single={maxSelect === 1}
class:open
class:invalid
class="multiselect {outerDivClass}"
on:mouseup|stopPropagation={open_dropdown}
title={disabled ? disabledInputTitle : null}
data-id={id}
role="searchbox"
tabindex="-1"
{style}
>
<!-- form control input invisible to the user, only purpose is to abort form submission if this component fails data validation -->
<!-- bind:value={selected} prevents form submission if required prop is true and no options are selected -->
<input
{name}
{required}
value={selected.length >= Number(required) ? JSON.stringify(selected) : null}
tabindex="-1"
aria-hidden="true"
aria-label="ignore this, used only to prevent form submission if select is required but empty"
class="form-control"
bind:this={form_input}
on:invalid={() => {
invalid = true
let msg
if (maxSelect && maxSelect > 1 && Number(required) > 1) {
msg = `Please select between ${required} and ${maxSelect} options`
} else if (Number(required) > 1) {
msg = `Please select at least ${required} options`
} else {
msg = `Please select an option`
}
form_input?.setCustomValidity(msg)
}}
/>
<slot name="expand-icon" {open}>
<ChevronsUpDown size={16} />
</slot>
<ul class="selected {ulSelectedClass}" aria-label="selected options" style={ulSelectedStyle}>
{#each selected as option, idx (duplicates ? [key(option), idx] : key(option))}
<li
class={liSelectedClass}
role="option"
aria-selected="true"
animate:flip={{ duration: 100 }}
draggable={selectedOptionsDraggable && !disabled && selected.length > 1}
on:dragstart={dragstart(idx)}
on:drop|preventDefault={drop(idx)}
on:dragenter={() => (drag_idx = idx)}
on:dragover|preventDefault
class:active={drag_idx === idx}
style="{get_style(option, `selected`)} {liSelectedStyle}"
>
<!-- on:dragover|preventDefault needed for the drop to succeed https://stackoverflow.com/a/31085796 -->
<slot name="selected" {option} {idx}>
<slot {option} {idx}>
{#if parseLabelsAsHtml}
{@html get_label(option)}
{:else}
{get_label(option)}
{/if}
</slot>
</slot>
{#if !disabled && (minSelect === null || selected.length > minSelect)}
<button
on:mouseup|stopPropagation={() => remove(option)}
on:keydown={if_enter_or_space(() => remove(option))}
type="button"
title="{removeBtnTitle} {get_label(option)}"
class="remove"
>
<slot name="remove-icon">
<X size={20} class="text-primary dark:text-primary-inverse p-0.5" />
</slot>
</button>
{/if}
</li>
{/each}
<input
class={inputClass}
style={inputStyle}
bind:this={input}
bind:value={searchText}
on:mouseup|self|stopPropagation={(e) => open_dropdown(e)}
on:keydown|stopPropagation={(e) => handle_keydown(e)}
on:focus={(e) => open_dropdown(e)}
on:input={(e) => highlight_matching_options(e)}
{id}
{disabled}
{autocomplete}
{inputmode}
{pattern}
placeholder={selected.length === 0 ? placeholder : undefined}
aria-invalid={invalid ? 'true' : undefined}
on:blur
on:change
on:click
on:keydown
on:keyup
on:mousedown
on:mouseenter
on:mouseleave
on:touchcancel
on:touchend
on:touchmove
on:touchstart
/>
<!-- the above on:* lines forward potentially useful DOM events -->
<slot name="after-input" {selected} {disabled} {invalid} {id} {placeholder} {open} {required} />
</ul>
{#if loading}
<slot name="spinner">spinner</slot>
{/if}
{#if disabled}
<slot name="disabled-icon">disable</slot>
{:else if selected.length > 0}
{#if maxSelect !== 1 && selected.length > 1 && !disableRemoveAll}
<button
type="button"
class="remove remove-all"
title={removeAllTitle}
on:mouseup|stopPropagation={remove_all}
on:keydown={if_enter_or_space(remove_all)}
>
<slot name="remove-icon">
<X
size={24}
class="text-secondary p-1 rounded-full bg-surface hover:bg-surface-secondary"
/>
</slot>
</button>
{/if}
{/if}
<!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
{#if allowUserOptions || (searchText && noMatchingOptionsMsg) || options?.length > 0}
<div class="options bg-surface shadow-md rounded-component">
<VirtualList
width="100%"
height={Math.min(32 * matchingOptions.length, 320)}
itemCount={matchingOptions.length}
itemSize={32}
>
<div slot="item" let:index let:style {style}>
{@const option = matchingOptions[index]}
{@const {
label,
disabled = null,
title = null,
selectedTitle = null,
disabledTitle = defaultDisabledTitle
} = option instanceof Object ? option : { label: option }}
<!-- svelte-ignore a11y-interactive-supports-focus -->
<div
on:mousedown|stopPropagation
on:mouseup|stopPropagation={(event) => {
if (!disabled) add(option, event)
}}
title={disabled ? disabledTitle : (is_selected(label) && selectedTitle) || title}
class={twMerge('hover:bg-blue-100 hover:dark:bg-gray-900 cursor-pointer !px-2 py-1')}
on:mouseover={() => {
if (!disabled) activeIndex = index
}}
on:focus={() => {
if (!disabled) activeIndex = index
}}
on:mouseout={() => (activeIndex = null)}
on:blur={() => (activeIndex = null)}
role="option"
aria-selected="false"
style={get_style(option, `option`)}
>
<slot name="option" {option} {index}>
<slot {option} {index}>
{#if parseLabelsAsHtml}
{@html get_label(option)}
{:else}
{get_label(option)}
{/if}
</slot>
</slot>
</div>
</div>
<div slot="footer" class="h-0"></div>
</VirtualList>
{#if searchText}
{@const text_input_is_duplicate = selected.map(get_label).includes(searchText)}
{@const is_dupe = !duplicates && text_input_is_duplicate && `dupe`}
{@const can_create = Boolean(allowUserOptions && createOptionMsg) && `create`}
{@const no_match =
Boolean(matchingOptions?.length == 0 && noMatchingOptionsMsg) && `no-match`}
{@const msgType = is_dupe || can_create || no_match}
{#if msgType}
{@const msg = {
dupe: duplicateOptionMsg,
create: createOptionMsg,
'no-match': noMatchingOptionsMsg
}[msgType]}
<!-- svelte-ignore a11y-interactive-supports-focus -->
<div
on:mousedown|stopPropagation
on:mouseup|stopPropagation={(event) => {
if (allowUserOptions) add(searchText, event)
}}
title={createOptionMsg}
class:active={option_msg_is_active}
on:mouseover={() => (option_msg_is_active = true)}
on:focus={() => (option_msg_is_active = true)}
on:mouseout={() => (option_msg_is_active = false)}
on:blur={() => (option_msg_is_active = false)}
role="option"
aria-selected="false"
class="user-msg p-1"
style:cursor={{
dupe: `not-allowed`,
create: `pointer`,
'no-match': `default`
}[msgType]}
>
<slot name="user-msg" {searchText} {msgType} {msg}>
{msg}
</slot>
</div>
{/if}
{/if}
</div>
{/if}
</div>
<style>
:where(div.multiselect) {
position: relative;
align-items: center;
display: flex;
cursor: text;
box-sizing: border-box;
border: var(--sms-border, 1pt solid lightgray);
border-radius: var(--sms-border-radius, 3pt);
background: var(--sms-bg);
width: var(--sms-width);
max-width: var(--sms-max-width);
padding: var(--sms-padding, 0 3pt);
color: var(--sms-text-color);
font-size: var(--sms-font-size, inherit);
min-height: var(--sms-min-height, 22pt);
margin: var(--sms-margin);
}
:where(div.multiselect.open) {
/* increase z-index when open to ensure the dropdown of one <MultiSelect />
displays above that of another slightly below it on the page */
z-index: var(--sms-open-z-index, 4);
}
:where(div.multiselect:focus-within) {
border: var(--sms-focus-border, 1pt solid var(--sms-active-color, cornflowerblue));
}
:where(div.multiselect.disabled) {
background: var(--sms-disabled-bg, lightgray);
cursor: not-allowed;
}
:where(div.multiselect > ul.selected) {
display: flex;
flex: 1;
padding: 0;
margin: 0;
flex-wrap: wrap;
}
:where(div.multiselect > ul.selected > li) {
align-items: center;
border-radius: 3pt;
display: flex;
margin: 2pt;
line-height: normal;
transition: 0.3s;
white-space: nowrap;
background: var(--sms-selected-bg, rgba(0, 0, 0, 0.15));
padding: var(--sms-selected-li-padding, 1pt 5pt);
color: var(--sms-selected-text-color, var(--sms-text-color));
}
:where(div.multiselect > ul.selected > li[draggable='true']) {
cursor: grab;
}
:where(div.multiselect > ul.selected > li.active) {
background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
}
:where(div.multiselect button) {
border-radius: 50%;
display: flex;
transition: 0.2s;
color: inherit;
background: transparent;
border: none;
cursor: pointer;
outline: none;
padding: 0;
margin: 0 0 0 3pt; /* CSS reset */
}
:where(div.multiselect button.remove-all) {
margin: 0 3pt;
}
:where(ul.selected > li button:hover, button.remove-all:hover, button:focus) {
color: var(--sms-remove-btn-hover-color, lightskyblue);
background: var(--sms-remove-btn-hover-bg, rgba(0, 0, 0, 0.2));
}
:where(div.multiselect input) {
margin: auto 0; /* CSS reset */
padding: 0; /* CSS reset */
}
:where(div.multiselect > ul.selected > input) {
border: none;
outline: none;
background: none;
flex: 1; /* this + next line fix issue #12 https://git.io/JiDe3 */
min-width: 2em;
/* ensure input uses text color and not --sms-selected-text-color */
color: var(--sms-text-color);
font-size: inherit;
cursor: inherit; /* needed for disabled state */
border-radius: 0; /* reset ul.selected > li */
}
/* don't wrap ::placeholder rules in :where() as it seems to be overpowered by browser defaults i.t.o. specificity */
div.multiselect > ul.selected > input::placeholder {
padding-left: 5pt;
color: var(--sms-placeholder-color);
opacity: var(--sms-placeholder-opacity);
}
:where(div.multiselect > input.form-control) {
width: 2em;
position: absolute;
background: transparent;
border: none;
outline: none;
z-index: -1;
opacity: 0;
pointer-events: none;
}
:where(div.multiselect > ul.options) {
list-style: none;
top: 100%;
left: 0;
width: 100%;
position: absolute;
overflow: auto;
transition: all 0.2s;
box-sizing: border-box;
background: var(--sms-options-bg, white);
max-height: var(--sms-options-max-height, 50vh);
overscroll-behavior: var(--sms-options-overscroll, none);
border: var(--sms-options-border);
border-width: var(--sms-options-border-width);
border-radius: var(--sms-options-border-radius, 1ex);
padding: var(--sms-options-padding);
margin: var(--sms-options-margin, inherit);
}
:where(div.multiselect > ul.options.hidden) {
visibility: hidden;
opacity: 0;
transform: translateY(50px);
}
:where(div.multiselect > ul.options > li) {
padding: 3pt 2ex;
cursor: pointer;
scroll-margin: var(--sms-options-scroll-margin, 100px);
}
:where(div.multiselect > ul.options .user-msg) {
/* block needed so vertical padding applies to span */
display: block;
padding: 3pt 2ex;
}
:where(div.multiselect > ul.options > li.selected) {
background: var(--sms-li-selected-bg);
color: var(--sms-li-selected-color);
}
:where(div.multiselect > ul.options > li.active) {
background: var(--sms-li-active-bg, var(--sms-active-color, rgba(0, 0, 0, 0.15)));
}
:where(div.multiselect > ul.options > li.disabled) {
cursor: not-allowed;
background: var(--sms-li-disabled-bg, #f5f5f6);
color: var(--sms-li-disabled-text, #b8b8b8);
}
::highlight(sms-search-matches) {
color: mediumaquamarine;
}
</style>
@@ -1,130 +0,0 @@
<script lang="ts">
import Portal from '$lib/components/Portal.svelte'
import { createFloatingActions } from 'svelte-floating-ui'
import { tick } from 'svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import MultiSelect from '$lib/components/multiselect/MultiSelect.svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
import { deepEqual } from 'fast-equals'
let {
items,
value = $bindable(),
placeholder = undefined,
target = undefined,
topPlacement = false,
allowUserOptions = undefined
}: {
items: any[]
value?: string[]
placeholder?: string
target?: string | HTMLElement
topPlacement?: boolean
allowUserOptions?: boolean | 'append'
} = $props()
$effect.pre(() => {
if (value === undefined) value = []
})
const [floatingRef, floatingContent] = createFloatingActions({
strategy: 'absolute',
placement: topPlacement ? 'top-start' : 'bottom-start',
middleware: [offset(5), flip(), shift()]
})
let outerDiv = $state<HTMLDivElement | undefined>(undefined)
let portalRef = $state<HTMLDivElement | undefined>(undefined)
let darkMode = $state(false)
let w = $state(0)
let open = $state(false)
function moveOptionsToPortal() {
// Find ul element with class 'options' within the outerDiv
const ul = outerDiv?.querySelector('.options')
if (ul) {
// Move the ul element to the portal
portalRef?.appendChild(ul)
}
}
$effect(() => {
if (portalRef && outerDiv && (allowUserOptions || items?.length > 0)) {
tick().then(() => {
moveOptionsToPortal()
})
}
})
</script>
<DarkModeObserver bind:darkMode />
<div use:floatingRef bind:clientWidth={w}>
{#if !value || Array.isArray(value)}
<div class="border rounded-md border-gray-300 shadow-sm dark:border-gray-600 !w-full">
<MultiSelect
{allowUserOptions}
outerDivClass={`!text-xs`}
ulSelectedClass="overflow-auto"
bind:outerDiv
--sms-border={'none'}
--sms-min-height={'30px'}
--sms-focus-border={'none'}
--sms-selected-bg={darkMode ? '#c7d2fe' : '#e0e7ff'}
--sms-selected-text-color={darkMode ? '#312e81' : '#3730a3'}
bind:selected={
() => [...(value ?? [])],
(newVal) => {
if (!deepEqual(value, newVal)) {
value = newVal
}
}
}
{placeholder}
options={items}
on:close={() => {
open = false
}}
on:open={() => {
open = true
}}
let:option
disableRemoveAll
>
<div
class="w-full text-sm"
role="option"
tabindex="0"
onmouseup={(e) => {
e.stopPropagation()
}}
onpointerdown={(e) => {
e.stopPropagation()
let newe = new MouseEvent('mouseup')
e.target?.['parentElement']?.dispatchEvent(newe)
}}
aria-selected={value?.includes(option)}
>
{option}
</div>
</MultiSelect>
</div>
<Portal {target} name="multi-select">
<div use:floatingContent class="z5000" hidden={!open}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={portalRef}
class="multiselect"
style={`min-width: ${w}px;`}
onclick={(e) => {
e.stopPropagation()
}}
role="listbox"
tabindex="0"
></div>
</div>
</Portal>
{:else}
Value {value} is not an array
{/if}
</div>
@@ -1,47 +0,0 @@
export type Option = string | number | ObjectOption
// single CSS string or an object with keys 'option' and 'selected', each a string,
// which only apply to the dropdown list and list of selected options, respectively
export type OptionStyle = string | { option: string; selected: string }
export type ObjectOption = {
label: string | number // user-displayed text
value?: unknown // associated value, can be anything incl. objects (defaults to label if undefined)
title?: string // on-hover tooltip
disabled?: boolean // make this option unselectable
preselected?: boolean // make this option selected on page load (before any user interaction)
disabledTitle?: string // override the default disabledTitle = 'This option is disabled'
selectedTitle?: string // tooltip to display when this option is selected and hovered
style?: OptionStyle
[key: string]: unknown // allow any other keys users might want
}
export type DispatchEvents<T = Option> = {
add: { option: T }
create: { option: T }
remove: { option: T }
removeAll: { options: T[] }
change: {
option?: T
options?: T[]
type: 'add' | 'remove' | 'removeAll'
}
open: { event: Event }
close: { event: Event }
}
export type MultiSelectEvents = {
[key in keyof DispatchEvents]: CustomEvent<DispatchEvents[key]>
} & {
blur: FocusEvent
click: MouseEvent
focus: FocusEvent
keydown: KeyboardEvent
keyup: KeyboardEvent
mouseenter: MouseEvent
mouseleave: MouseEvent
touchcancel: TouchEvent
touchend: TouchEvent
touchmove: TouchEvent
touchstart: TouchEvent
}
@@ -1,64 +0,0 @@
import type { Option, OptionStyle } from './types'
// get the label key from an option object or the option itself if it's a string or number
export const get_label = (opt: Option) => {
if (opt instanceof Object) {
if (opt.label === undefined) {
console.error(`MultiSelect option ${JSON.stringify(opt)} is an object but has no label key`)
}
return opt.label
}
return `${opt}`
}
// this function is used extract CSS strings from a {selected, option} style object to be used in the style attribute of the option
// if the style is a string, it will be returned as is
export function get_style(
option: { style?: OptionStyle; [key: string]: unknown } | string | number,
key: 'selected' | 'option' | null = null
) {
let css_str = ``
if (![`selected`, `option`, null].includes(key)) {
console.error(`MultiSelect: Invalid key=${key} for get_style`)
}
if (typeof option == `object` && option.style) {
if (typeof option.style == `string`) {
css_str = option.style
}
if (typeof option.style == `object`) {
if (key && key in option.style) return option.style[key] ?? ``
else {
console.error(`Invalid style object for option=${JSON.stringify(option)}`)
}
}
}
// ensure css_str ends with a semicolon
if (css_str.trim() && !css_str.trim().endsWith(`;`)) css_str += `;`
return css_str
}
// Firefox lacks support for scrollIntoViewIfNeeded (https://caniuse.com/scrollintoviewifneeded).
// See https://github.com/janosh/svelte-multiselect/issues/87
// Polyfill copied from
// https://github.com/nuxodin/lazyfill/blob/a8e63/polyfills/Element/prototype/scrollIntoViewIfNeeded.js
// exported for testing
export function scroll_into_view_if_needed_polyfill(elem: Element, centerIfNeeded: boolean = true) {
const observer = new IntersectionObserver(function ([entry]) {
const ratio = entry.intersectionRatio
if (ratio < 1) {
const place = ratio <= 0 && centerIfNeeded ? `center` : `nearest`
elem.scrollIntoView({
block: place,
inline: place
})
}
observer.disconnect()
}, {
root: null, // or specify a scrolling parent if needed
rootMargin: '0px 1000px', // Essentially making horizontal checks irrelevant
threshold: 0.1 // Adjust threshold to control when observer should trigger
})
observer.observe(elem)
return observer // return for testing
}
@@ -12,7 +12,8 @@
import { createEventDispatcher, untrack } from 'svelte'
import ToggleButtonMore from '../common/toggleButton-v2/ToggleButtonMore.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Select from '../Select.svelte'
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
interface Props {
// Filters
@@ -174,7 +175,7 @@
<div class="relative">
<span class="text-xs absolute -top-4">User</span>
<Select
items={usernames.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(usernames)}
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
clearable
onClear={() => ((user = null), dispatch('reset'))}
@@ -190,7 +191,7 @@
<span class="text-xs absolute -top-4">Folder</span>
<Select
items={folders.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(folders)}
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
clearable
onClear={() => ((folder = null), dispatch('reset'))}
@@ -203,7 +204,7 @@
<div class="relative">
<span class="text-xs absolute -top-4">Path</span>
<Select
items={paths.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(paths)}
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
clearable
onClear={() => ((path = null), dispatch('reset'))}
@@ -564,7 +565,7 @@
<Label label="User">
<Select
disablePortal
items={usernames.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(usernames)}
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
clearable
onClear={() => ((user = null), dispatch('reset'))}
@@ -575,7 +576,7 @@
<Label label="Folder">
<Select
disablePortal
items={folders.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(folders)}
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
clearable
onClear={() => ((folder = null), dispatch('reset'))}
@@ -586,7 +587,7 @@
<Label label="Path">
<Select
disablePortal
items={paths.map((p) => ({ label: p, value: p }))}
items={safeSelectItems(paths)}
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
clearable
onClear={() => ((path = null), dispatch('reset'))}
@@ -8,7 +8,8 @@
import { Alert } from '../common'
import AddPropertyV2 from '$lib/components/schema/AddPropertyV2.svelte'
import { Plus } from 'lucide-svelte'
import Select from '../Select.svelte'
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
interface Props {
schema: Schema | undefined | any
@@ -146,7 +147,7 @@
File extension :
<Select
autofocus
items={suggestedFileExtensions.map((e) => ({ value: e, label: e }))}
items={safeSelectItems(suggestedFileExtensions)}
bind:value={formatExtension}
onCreateItem={(ext) => ((formatExtension = ext), suggestedFileExtensions.push(ext))}
/>
@@ -11,7 +11,7 @@
type Script,
type SearchJobsIndexResponse
} from '$lib/gen'
import { clickOutside, isMac } from '$lib/utils'
import { clickOutside, isMac, scroll_into_view_if_needed_polyfill } from '$lib/utils'
import {
AlertTriangle,
BoxesIcon,
@@ -36,7 +36,6 @@
import { devopsRole, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import uFuzzy from '@leeoniya/ufuzzy'
import BarsStaggered from '../icons/BarsStaggered.svelte'
import { scroll_into_view_if_needed_polyfill } from '../multiselect/utils'
import { Alert } from '../common'
import Popover from '../Popover.svelte'
import Logs from 'lucide-svelte/icons/logs'
@@ -0,0 +1,63 @@
<script lang="ts" generics="Item extends { label?: string; value: any }">
import { twMerge } from 'tailwind-merge'
import CloseButton from '../common/CloseButton.svelte'
type Props = {
items?: Item[]
allowClear?: boolean
onRemove: (item: Item) => void
onReorder?: (oldIndex: number, newIndex: number) => void
}
let { items, onRemove, onReorder, allowClear = true }: Props = $props()
let currentlyDraggingIndex: number | undefined = $state()
let dragPos = $state<[number, number]>([0, 0])
</script>
<svelte:window
onmousemove={(e) => {
if (currentlyDraggingIndex === undefined) return
dragPos = [dragPos[0] + e.movementX, dragPos[1] + e.movementY]
}}
onpointerup={() => {
currentlyDraggingIndex = undefined
dragPos = [0, 0]
}}
/>
{#each items ?? [] as item, index}
<li
role="listitem"
class={twMerge(
allowClear ? 'pr-1' : 'pr-3',
'pl-3 min-h-6 bg-surface-secondary rounded-full flex items-center gap-0.5',
currentlyDraggingIndex !== undefined ? 'hover:opacity-20' : ''
)}
style={currentlyDraggingIndex === index
? `transform: translate(${dragPos[0]}px, ${dragPos[1]}px); pointer-events: none;`
: ''}
draggable
onpointerdown={(e) => {
e.stopPropagation()
dragPos = [0, 0]
currentlyDraggingIndex = index
}}
onpointerup={(e) => {
if (currentlyDraggingIndex !== undefined) {
e.stopPropagation()
onReorder?.(currentlyDraggingIndex, index)
}
currentlyDraggingIndex = undefined
dragPos = [0, 0]
}}
>
<span class="text-sm select-none">{item.label || item.value}</span>
{#if allowClear}
<CloseButton
class="text-tertiary"
small
on:close={(e) => (onRemove(item), e.stopPropagation())}
/>
{/if}
</li>
{/each}
@@ -0,0 +1,176 @@
<script lang="ts" generics="Item extends { label?: string; value: any; }">
import { clickOutside, reorder } from '$lib/utils'
import { untrack } from 'svelte'
import { processItems, type ProcessedItem } from './utils.svelte'
import SelectDropdown from './SelectDropdown.svelte'
import CloseButton from '../common/CloseButton.svelte'
import DraggableTags from './DraggableTags.svelte'
import { Search } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
type Value = Item['value']
let {
items,
placeholder = 'Select items',
value = $bindable(),
class: className = '',
style,
listAutoWidth = true,
disabled = false,
disablePortal = false,
createText,
reorderable = true,
noItemsMsg,
selectedUlClass = '',
placeholderClass = '',
allowClear = true,
onOpen,
groupBy,
sortBy,
onCreateItem
}: {
items?: Item[]
value: Value[]
placeholder?: string
class?: string
style?: string
filterText?: string
disabled?: boolean
listAutoWidth?: boolean
containerStyle?: string
inputClass?: string
disablePortal?: boolean
createText?: string
reorderable?: boolean
noItemsMsg?: string
selectedUlClass?: string
placeholderClass?: string
allowClear?: boolean
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onOpen?: () => void
onClear?: () => void
onCreateItem?: (value: string) => void
} = $props()
let filterText = $state<string>('')
let open = $state<boolean>(false)
let wrapperEl: HTMLDivElement | undefined = $state()
let searchInputEl: HTMLInputElement | undefined = $state()
$effect(() => searchInputEl?.focus())
let processedItems: ProcessedItem<Value>[] = $derived.by(() => {
let args = { items, createText, filterText, groupBy, onCreateItem, sortBy }
return untrack(() => processItems(args))
})
$effect(() => {
if (!open) filterText = ''
})
$effect(() => {
open && untrack(() => onOpen?.())
})
let valueEntry = $derived(
value.map((v) => processedItems.find((item) => item.value === v)!).filter(Boolean)
)
function onAddValue(item: ProcessedItem<Value>) {
if (item.__is_create && onCreateItem) {
onCreateItem(item.value)
} else {
value = [...value, item.value]
}
}
function onRemoveValue(item: ProcessedItem<Value>) {
value = value.filter((v) => v !== item.value)
}
function clearValue() {
filterText = ''
value = []
}
</script>
<div
bind:this={wrapperEl}
class={twMerge(
'relative min-h-8 flex items-center w-full bg-surface border border-gray-300 rounded-md text-tertiary',
disabled ? 'pointer-events-none' : '',
open && !disabled ? 'open' : '',
disabled ? 'disabled' : '',
className
)}
{style}
onpointerup={() => (open = true)}
use:clickOutside={{ onClickOutside: () => (open = false) }}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
{#if value.length === 0}
<span class={twMerge('text-sm ml-2 h-full flex items-center flex-1', placeholderClass)}>
{placeholder}
</span>
{:else}
<ul
class={twMerge(
'overflow-clip overflow-x-hidden h-full cursor-pointer items-center flex flex-wrap gap-1 py-0.5 px-0.5 flex-1 text-primary',
selectedUlClass
)}
role="list"
>
<DraggableTags
items={valueEntry}
{allowClear}
onRemove={onRemoveValue}
onReorder={reorderable
? (oldIdx, newIdx) => (value = reorder(value, oldIdx, newIdx))
: undefined}
/>
</ul>
{/if}
{#if allowClear}
<CloseButton
noBg
class="mr-1 remove-all"
small
on:close={(e) => (clearValue(), e.stopPropagation())}
/>
{/if}
<SelectDropdown
{disablePortal}
onSelectValue={onAddValue}
{open}
processedItems={processedItems.filter((item) => !value.includes(item.value))}
value={undefined}
{disabled}
{filterText}
getInputRect={wrapperEl && (() => wrapperEl!.getBoundingClientRect())}
{listAutoWidth}
{noItemsMsg}
class={twMerge(
'multiselect dropdown',
open && !disabled ? 'open' : '',
disabled ? 'disabled' : ''
)}
ulClass="options"
>
{#snippet header()}
{#if processedItems.length - value.length > 0 || onCreateItem}
<div class="mx-2 mb-1 mt-2 flex items-center relative">
<input
bind:this={searchInputEl}
bind:value={filterText}
onblur={(e) => (e.preventDefault(), searchInputEl?.focus())}
placeholder="Search"
class="!pr-7"
/>
<Search size={16} class="absolute right-2 text-tertiary" />
</div>
{/if}
{/snippet}
</SelectDropdown>
</div>
@@ -0,0 +1,156 @@
<script lang="ts" generics="Item extends { label?: string; value: any; }">
import { clickOutside } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import CloseButton from '../common/CloseButton.svelte'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import { processItems, type ProcessedItem } from './utils.svelte'
import SelectDropdown from './SelectDropdown.svelte'
import { deepEqual } from 'fast-equals'
type Value = Item['value']
let {
items,
placeholder = 'Please select',
value = $bindable(),
filterText: _filterTextBind = $bindable(undefined),
class: className = '',
clearable = false,
listAutoWidth = true,
disabled: _disabled = false,
containerStyle = '',
inputClass = '',
disablePortal = false,
loading = false,
autofocus,
RightIcon,
createText,
noItemsMsg,
groupBy,
sortBy,
onFocus,
onBlur,
onClear,
onCreateItem
}: {
items?: Item[]
value: Value | undefined
placeholder?: string
class?: string
clearable?: boolean
filterText?: string
disabled?: boolean
listAutoWidth?: boolean
containerStyle?: string
inputClass?: string
disablePortal?: boolean
loading?: boolean
autofocus?: boolean
RightIcon?: any
createText?: string
noItemsMsg?: string
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onFocus?: () => void
onBlur?: () => void
onClear?: () => void
onCreateItem?: (value: string) => void
} = $props()
let disabled = $derived(_disabled || loading)
let filterText = $state<string>('')
let open = $state<boolean>(false)
let inputEl: HTMLInputElement | undefined = $state()
let processedItems: ProcessedItem<Value>[] = $derived.by(() => {
let args = { items, createText, filterText, groupBy, onCreateItem, sortBy }
return untrack(() => processItems(args))
})
$effect(() => {
if (_filterTextBind !== undefined) filterText = _filterTextBind
})
$effect(() => {
if (_filterTextBind !== undefined) _filterTextBind = filterText
})
$effect(() => {
if (filterText) open = true
})
$effect(() => {
if (!open) filterText = ''
})
let valueEntry = $derived(value && processedItems?.find((item) => deepEqual(item.value, value)))
function setValue(item: ProcessedItem<Value>) {
if (item.__is_create && onCreateItem) {
onCreateItem(item.value)
} else {
value = item.value
}
filterText = ''
open = false
}
function clearValue() {
filterText = ''
if (onClear) onClear()
else value = undefined
}
</script>
<div
class={`relative ${className}`}
use:clickOutside={{ onClickOutside: () => (open = false) }}
onpointerdown={() => onFocus?.()}
onfocus={() => onFocus?.()}
onblur={() => onBlur?.()}
>
{#if loading}
<div class="absolute z-10 right-2 h-full flex items-center">
<Loader2 size={18} class="animate-spin" />
</div>
{:else if clearable && !disabled && value}
<div class="absolute z-10 right-2 h-full flex items-center">
<CloseButton noBg small on:close={clearValue} />
</div>
{:else if RightIcon}
<div class="absolute z-10 right-2 h-full flex items-center">
<RightIcon size={18} class="text-tertiary/35" />
</div>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
{autofocus}
{disabled}
type="text"
bind:value={() => filterText, (v) => (filterText = v)}
placeholder={loading ? 'Loading...' : (valueEntry?.label ?? placeholder)}
style={containerStyle}
class={twMerge(
'!bg-surface text-ellipsis',
open ? '' : 'cursor-pointer',
valueEntry && !loading ? '!placeholder-primary' : '',
(clearable || RightIcon) && !disabled && value ? '!pr-8' : '',
inputClass ?? ''
)}
autocomplete="off"
onpointerdown={() => (open = true)}
bind:this={inputEl}
/>
<SelectDropdown
{disablePortal}
onSelectValue={setValue}
{open}
{processedItems}
{value}
{disabled}
{filterText}
getInputRect={inputEl && (() => inputEl!.getBoundingClientRect())}
{listAutoWidth}
{noItemsMsg}
/>
</div>
@@ -0,0 +1,147 @@
<script lang="ts" generics="T">
import { deepEqual } from 'fast-equals'
import ConditionalPortal from '../common/drawer/ConditionalPortal.svelte'
import { untrack, type Snippet } from 'svelte'
import type { ProcessedItem } from './utils.svelte'
import { twMerge } from 'tailwind-merge'
let {
processedItems: _processedItems,
value,
filterText,
listAutoWidth = true,
disabled,
disablePortal = false,
open,
noItemsMsg = 'No items found',
class: className = '',
ulClass = '',
header,
getInputRect,
onSelectValue
}: {
processedItems?: ProcessedItem<T>[]
value: T | undefined
filterText?: string
listAutoWidth?: Boolean
disabled?: boolean
disablePortal?: boolean
open: boolean
noItemsMsg?: string
class?: string
ulClass?: string
header?: Snippet
getInputRect?: () => DOMRect
onSelectValue: (item: ProcessedItem<T>) => void
} = $props()
let processedItems = $derived(
!filterText
? _processedItems
: _processedItems?.filter(
(item) =>
item.__is_create || item?.label?.toLowerCase().includes(filterText?.toLowerCase())
)
)
let listEl: HTMLDivElement | undefined = $state()
let dropdownPos = $state(computeDropdownPos())
let keyArrowPos = $state<number | undefined>()
function computeDropdownPos(): { width: number; x: number; y: number } {
if (!getInputRect || !listEl) return { width: 0, x: 0, y: 0 }
let inputR = getInputRect()
const listR = listEl.getBoundingClientRect()
const openBelow = inputR.y + inputR.height + listR.height <= window.innerHeight
let [x, y] = disablePortal ? [0, 0] : [inputR.x, inputR.y]
if (openBelow) return { width: inputR.width, x: x, y: y + inputR.height }
else {
return { width: inputR.width, x: x, y: y - listR.height }
}
}
$effect(() => {
function updateDropdownPos() {
let nPos = computeDropdownPos()
if (!deepEqual(nPos, dropdownPos)) dropdownPos = nPos
if (open) requestAnimationFrame(updateDropdownPos)
}
if (open) untrack(() => updateDropdownPos())
})
$effect(() => {
;[open, processedItems]
untrack(() => (keyArrowPos = open && filterText ? 0 : undefined))
})
</script>
<svelte:window
on:keydown={(e) => {
if (!open || !processedItems?.length) return
if (e.key === 'ArrowUp' && keyArrowPos !== undefined && processedItems.length > 0) {
keyArrowPos = keyArrowPos <= 0 ? undefined : keyArrowPos - 1
} else if (e.key === 'ArrowDown') {
if (keyArrowPos === undefined) {
keyArrowPos = 0
} else {
keyArrowPos = Math.min(processedItems.length - 1, keyArrowPos + 1)
}
} else if (e.key === 'Enter' && keyArrowPos !== undefined && processedItems?.[keyArrowPos]) {
onSelectValue(processedItems[keyArrowPos])
} else {
keyArrowPos = undefined
return
}
e.preventDefault()
}}
/>
<ConditionalPortal condition={!disablePortal} name="select-dropdown-portal">
{#if open && !disabled}
<div
class={twMerge(
disablePortal ? 'absolute' : 'fixed',
'flex flex-col z-[5001] max-h-64 overflow-y-auto bg-surface-secondary text-tertiary text-sm select-none border rounded-lg shadow-lg',
className
)}
style="{`top: ${dropdownPos.y}px; left: ${dropdownPos.x}px;`} {listAutoWidth
? `min-width: ${dropdownPos.width}px;`
: ''}"
bind:this={listEl}
>
{@render header?.()}
{#if processedItems?.length === 0}
<div class="py-8 px-4 text-center text-primary">{noItemsMsg}</div>
{/if}
<ul class={twMerge('flex-1 overflow-y-auto flex flex-col', ulClass)}>
{#each processedItems ?? [] as item, itemIndex}
{#if (item.__select_group && itemIndex === 0) || processedItems?.[itemIndex - 1]?.__select_group !== item.__select_group}
<li
class={twMerge(
'mx-4 pb-1 mb-2 text-xs font-semibold text-primary border-b',
itemIndex === 0 ? 'mt-3' : 'mt-6'
)}
>
{item.__select_group}
</li>
{/if}
<li>
<button
class={twMerge(
'py-2 px-4 w-full font-normal text-left text-primary',
itemIndex === keyArrowPos ? 'bg-surface-hover' : '',
item.value === value ? 'bg-surface-selected' : 'hover:bg-surface-hover'
)}
onclick={(e) => {
e.stopImmediatePropagation()
onSelectValue(item)
}}
>
{item.label}
</button>
</li>
{/each}
</ul>
</div>
{/if}
</ConditionalPortal>
@@ -0,0 +1,72 @@
export function processItems<Item extends { label?: string; value: any }>({
items,
filterText,
groupBy,
sortBy,
onCreateItem,
createText
}: {
items?: Item[]
filterText?: string
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onCreateItem?: (value: string) => void
createText?: string
}): ProcessedItem<Item['value']>[] {
let items2 =
items?.map((item) => ({
...item,
label: getLabel(item)
})) ?? []
if (groupBy) {
items2 =
items2?.map((item) => ({
...item,
__select_group: groupBy(item)
})) ?? []
}
if (sortBy) {
items2 = items2?.sort(sortBy)
}
if (onCreateItem && filterText && !items2.some((item) => item.label === filterText)) {
items2.push({
label: createText ?? `Add new: "${filterText}"`,
value: filterText,
__is_create: true
} as any)
}
return items2
}
export type ProcessedItem<T> = {
__select_group?: string
__is_create?: true
label: string
value: T
}
export function getLabel<T>(item: { label?: string; value: T } | undefined): string {
if (!item) return ''
if (item.label) return item.label
if (typeof item.value === 'string') return item.value
if (typeof item.value == 'number' || typeof item.value == 'boolean') return item.value.toString()
return JSON.stringify(item.value)
}
export function safeSelectItems<T>(
list: (T | { value: T; label?: string } | undefined | null)[] | undefined | null
): { value: T; label?: string }[] {
if (!list) return []
return list
.filter((item) => item !== undefined && item !== null)
.map((item) => {
if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') {
return { value: item }
}
if (typeof item === 'object' && 'value' in item) {
return item
}
return { value: null as any, label: 'UNKNOWN_ITEM' }
})
}
@@ -3,7 +3,6 @@
import { Button } from '../common'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import MultiSelectWrapper from '../multiselect/MultiSelectWrapper.svelte'
import TriggerableByAI from '../TriggerableByAI.svelte'
import Toggle from '../Toggle.svelte'
import { UserService, type NewToken } from '$lib/gen'
@@ -39,7 +38,7 @@
let newMcpScope = $state('favorites')
let loadingApps = $state(false)
let errorFetchApps = $state(false)
let allApps = $state<string[]>([])
// let allApps = $state<string[]>([])
function handleCopyClick() {
copyToClipboard(newToken ?? '')
@@ -205,7 +204,7 @@
{:else if errorFetchApps}
<div>Error fetching apps</div>
{:else}
<MultiSelectWrapper items={allApps} placeholder="Select apps" bind:value={newMcpApps} />
<!-- MultiSelectWrapper was confirmed not to be necessary : deleted -->
{/if}
</div>
@@ -12,8 +12,9 @@
import Toggle from '../Toggle.svelte'
import ClipboardPanel from '../details/ClipboardPanel.svelte'
import { sendUserToast } from '$lib/toast'
import MultiSelectWrapper from '../multiselect/MultiSelectWrapper.svelte'
import TriggerableByAI from '../TriggerableByAI.svelte'
import MultiSelect from '../select/MultiSelect.svelte'
import { safeSelectItems } from '../select/utils.svelte'
// --- Props ---
interface Props {
@@ -306,10 +307,11 @@
{:else if errorFetchApps}
<div>Error fetching apps</div>
{:else}
<MultiSelectWrapper
items={allApps}
<MultiSelect
items={safeSelectItems(allApps)}
placeholder="Select apps"
bind:value={newMcpApps}
class="!bg-surface"
/>
{/if}
</div>
@@ -319,7 +321,7 @@
<select
bind:value={newTokenWorkspace}
disabled={workspaces.length === 1}
class="w-full"
class="w-full !bg-surface"
>
{#each workspaces as workspace}
<option value={workspace.id}>{workspace.name}</option>
@@ -331,7 +333,7 @@
<div>
<span class="block mb-1">Label <span class="text-xs text-tertiary">(optional)</span></span
>
<input type="text" bind:value={newTokenLabel} class="w-full" />
<input type="text" bind:value={newTokenLabel} class="w-full !bg-surface" />
</div>
{#if !mcpCreationMode}
@@ -339,7 +341,7 @@
<span class="block mb-1"
>Expires In <span class="text-xs text-tertiary">(optional)</span></span
>
<select bind:value={newTokenExpiration} class="w-full">
<select bind:value={newTokenExpiration} class="w-full !bg-surface">
<option value={undefined}>No expiration</option>
<option value={15 * 60}>15m</option>
<option value={30 * 60}>30m</option>
@@ -23,7 +23,8 @@
import { RefreshCw } from 'lucide-svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import TestingBadge from '../testingBadge.svelte'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
let topic_items: string[] = $state([])
let subscription_items: string[] = $state([])
@@ -176,7 +177,7 @@
loadAllSubscriptionFromGooglePubSubTopic()
}
}
items={topic_items.map((value) => ({ value }))}
items={safeSelectItems(topic_items)}
placeholder="Choose a topic"
/>
<Button
@@ -312,7 +313,7 @@
(t) => ((subscription_id = t), (cloud_subscription_id = t))
}
onClear={() => (subscription_id = '')}
items={subscription_items.map((value) => ({ value }))}
items={safeSelectItems(subscription_items)}
placeholder="Choose a subscription"
/>
<Button
@@ -19,7 +19,7 @@
import Label from '$lib/components/Label.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { ClipboardCopy, Download, Trash } from 'lucide-svelte'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
import { sendUserToast } from '$lib/toast'
import {
copyToClipboard,
@@ -34,6 +34,7 @@
import { bash } from 'svelte-highlight/languages'
import CreateToken from '$lib/components/settings/CreateToken.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
type HttpRouteAndWebhook = WebhookFilters | OpenapiHttpRouteFilters
@@ -392,7 +393,7 @@ curl -X POST "${window.location.origin}${base}/api/w/${$workspaceStore!}/openapi
clearable={false}
class="grow shrink"
bind:value={webhookFilters.user_or_folder_regex}
items={['*', 'u', 'f'].map((value) => ({ value }))}
items={safeSelectItems(['*', 'u', 'f'])}
/>
<span class="text-xl">/</span>
<div>
@@ -9,13 +9,12 @@
import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils'
import Section from '$lib/components/Section.svelte'
import { Loader2, X } from 'lucide-svelte'
import { Loader2 } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import MultiSelect from 'svelte-multiselect'
import PublicationPicker from './PublicationPicker.svelte'
import SlotPicker from './SlotPicker.svelte'
import { random_adj } from '$lib/components/random_positive_adjetive'
@@ -30,6 +29,8 @@
import TestingBadge from '../testingBadge.svelte'
import { handleConfigChange, type Trigger } from '../utils'
import { fade } from 'svelte/transition'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
interface Props {
useDrawer?: boolean
@@ -95,7 +96,7 @@
let selectedPublicationAction: actions | undefined = $state(undefined)
let selectedSlotAction: actions | undefined = $state(undefined)
let publicationItems: string[] = $state([])
let transactionType: string[] = ['Insert', 'Update', 'Delete']
let transactionType: string[] = $state(['Insert', 'Update', 'Delete'])
let tab: 'advanced' | 'basic' = $state('basic')
let basic_mode = $derived(tab === 'basic')
let initialConfig: Record<string, any> | undefined = undefined
@@ -441,14 +442,14 @@
</Drawer>
{:else}
<Section label={!customLabel ? 'Postgres trigger' : ''} headerClass="grow min-w-0 h-[30px]">
<svelte:fragment slot="header">
{#snippet header()}
{#if customLabel}
{@render customLabel()}
{/if}
</svelte:fragment>
<svelte:fragment slot="action">
{/snippet}
{#snippet action()}
{@render actionsSnippet()}
</svelte:fragment>
{/snippet}
{@render content()}
</Section>
{/if}
@@ -551,11 +552,11 @@
</Section>
{/if}
<Section label="Database">
<svelte:fragment slot="badge">
{#snippet badge()}
{#if isEditor}
<TestingBadge />
{/if}
</svelte:fragment>
{/snippet}
<p class="text-xs text-tertiary mb-2">
Pick a database to connect to <Required required={true} />
</p>
@@ -586,26 +587,12 @@
</Tooltip>
{/snippet}
<MultiSelect
noMatchingOptionsMsg=""
createOptionMsg={null}
duplicates={false}
options={transactionType}
allowUserOptions="append"
bind:selected={transaction_to_track}
ulOptionsClass={'!bg-surface !text-sm'}
ulSelectedClass="!text-sm"
outerDivClass="!bg-surface !min-h-[38px] !border-[#d1d5db]"
bind:value={transaction_to_track}
items={safeSelectItems(transactionType)}
onCreateItem={(x) => (transactionType.push(x), transaction_to_track.push(x))}
placeholder="Select transactions"
--sms-options-margin="4px"
--sms-open-z-index="100"
disabled={!can_write}
>
<svelte:fragment slot="remove-icon">
<div class="hover:text-primary p-0.5">
<X size={12} />
</div>
</svelte:fragment>
</MultiSelect>
/>
</Label>
<Label label="Table Tracking" headerClass="grow min-w-0">
{#snippet header()}
@@ -1,6 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import type { Relations } from '$lib/gen'
import { PostgresTriggerService } from '$lib/gen/services.gen'
import { workspaceStore } from '$lib/stores'
@@ -102,7 +103,7 @@
disabled={!can_write || disabled}
class="grow shrink"
bind:value={publication_name}
items={items.map((value) => ({ value }))}
items={safeSelectItems(items)}
placeholder="Choose a publication"
clearable
disablePortal
@@ -4,12 +4,13 @@
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { Relations } from '$lib/gen'
import { Plus, Trash, X } from 'lucide-svelte'
import { Plus, Trash } from 'lucide-svelte'
import { getDefaultTableToTrack, invalidRelations } from './utils'
import AddPropertyFormV2 from '$lib/components/schema/AddPropertyFormV2.svelte'
import Label from '$lib/components/Label.svelte'
import { emptyStringTrimmed, sendUserToast } from '$lib/utils'
import MultiSelect from 'svelte-multiselect'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
interface Props {
relations?: Relations[] | undefined
@@ -170,58 +171,28 @@
{/snippet}
<div class="mt-1">
<MultiSelect
options={table_to_track.columns_name ?? []}
allowUserOptions="append"
ulOptionsClass={'!bg-surface !text-sm'}
ulSelectedClass="!text-sm"
outerDivClass="!bg-surface !min-h-[38px] !border-[#d1d5db]"
noMatchingOptionsMsg=""
createOptionMsg={null}
disabled={pg14}
duplicates={false}
selected={table_to_track.columns_name ?? []}
items={safeSelectItems(table_to_track.columns_name ?? [])}
placeholder="Select columns"
--sms-options-margin="4px"
onchange={(e) => {
const option = e.option?.toString()
updateRelationsFor(i, (rel) => {
const updatedTables = rel.table_to_track.map((t, idx) => {
if (idx !== j) return t
let updatedColumns = t.columns_name ?? []
if (e.type === 'add' && option) {
updatedColumns = [...updatedColumns, option]
} else if (e.type === 'remove') {
updatedColumns = updatedColumns.filter((col) => col !== option)
} else if (e.type === 'removeAll') {
updatedColumns = []
} else {
console.error(
`Priority tags multiselect - unknown event type: '${e.type}'`
)
}
return {
...t,
columns_name: updatedColumns
}
})
return {
disabled={pg14}
bind:value={
() => table_to_track.columns_name ?? [],
(columns_name) => {
updateRelationsFor(i, (rel) => ({
...rel,
table_to_track: updatedTables
}
})
}}
>
<!-- @migration-task: migrate this slot by hand, `remove-icon` is an invalid identifier -->
<svelte:fragment slot="remove-icon">
<div class="hover:text-primary p-0.5">
<X size={12} />
</div>
</svelte:fragment>
</MultiSelect>
table_to_track: rel.table_to_track.map((t, idx) =>
idx !== j ? t : { ...t, columns_name }
)
}))
}
}
onCreateItem={(x) =>
updateRelationsFor(i, (rel) => ({
...rel,
table_to_track: rel.table_to_track.map((t, idx) =>
idx !== j ? t : { ...t, columns_name: [...(t.columns_name ?? []), x] }
)
}))}
/>
</div>
</Label>
<Label label="Where Clause">
@@ -1,6 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import { PostgresTriggerService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
@@ -77,7 +78,7 @@
class="grow shrink max-w-full"
bind:value={replication_slot_name}
onClear={() => (replication_slot_name = '')}
items={items.map((value) => ({ value }))}
items={safeSelectItems(items)}
placeholder="Choose a slot name"
disablePortal
clearable
@@ -3,10 +3,9 @@
import Required from '$lib/components/Required.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import { emptyStringTrimmed } from '$lib/utils'
import MultiSelect from 'svelte-multiselect'
import TestTriggerConnection from '../TestTriggerConnection.svelte'
import Subsection from '$lib/components/Subsection.svelte'
import { Plus, X } from 'lucide-svelte'
import { Plus } from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ArgInput from '$lib/components/ArgInput.svelte'
@@ -16,6 +15,8 @@
import { VariableService, type AwsAuthResourceType } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import TestingBadge from '../testingBadge.svelte'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
interface Props {
can_write?: boolean
@@ -141,26 +142,13 @@
</div>
<div class="flex flex-col mt-3 gap-1">
<MultiSelect
options={message_attributes ?? []}
allowUserOptions="append"
bind:selected={message_attributes}
ulOptionsClass={'!bg-surface !text-sm'}
ulSelectedClass="!text-sm"
outerDivClass="!bg-surface !min-h-[38px] !border-[#d1d5db]"
noMatchingOptionsMsg=""
createOptionMsg={null}
duplicates={false}
bind:value={message_attributes}
items={safeSelectItems(message_attributes)}
onCreateItem={(x) => message_attributes.push(x)}
placeholder="Set message attributes"
--sms-options-margin="4px"
noItemsMsg="Add message attributes to filter on"
disabled={tab === 'all'}
>
<!-- @migration-task: migrate this slot by hand, `remove-icon` is an invalid identifier -->
<svelte:fragment slot="remove-icon">
<div class="hover:text-primary p-0.5">
<X size={12} />
</div>
</svelte:fragment>
</MultiSelect>
/>
</div>
</Subsection>
</div>
@@ -10,7 +10,8 @@
import Toggle from '../Toggle.svelte'
import ArgEnum from '../ArgEnum.svelte'
import Button from '../common/button/Button.svelte'
import MultiSelectWrapper from '../multiselect/MultiSelectWrapper.svelte'
import MultiSelect from '../select/MultiSelect.svelte'
import { safeSelectItems } from '../select/utils.svelte'
const aiProviderLabels: [AIProvider, string][] = [
['openai', 'OpenAI'],
@@ -212,13 +213,15 @@
</div>
<Label label="Enabled models">
<!-- this can be removed once the parent component moves to runes -->
<!-- svelte-ignore binding_property_non_reactive -->
<MultiSelectWrapper
items={availableAiModels[provider]}
<MultiSelect
items={safeSelectItems([
...availableAiModels[provider],
...aiProviders[provider].models
])}
bind:value={aiProviders[provider].models}
placeholder="Select models"
allowUserOptions="append"
onCreateItem={(item) =>
(aiProviders[provider].models = [...aiProviders[provider].models, item])}
/>
</Label>
<p class="text-xs">
+34 -3
View File
@@ -274,7 +274,7 @@ export function validatePassword(password: string): boolean {
return re.test(password)
}
const portalDivs = ['app-editor-select']
const portalDivs = ['#app-editor-select', '.select-dropdown-portal']
interface ClickOutsideOptions {
capture?: boolean
@@ -309,7 +309,7 @@ export function clickOutside(
})
if (node && !node.contains(target) && !event.defaultPrevented && !isExcluded) {
const portalDivsSelector = portalDivs.map((id) => `#${id}`).join(', ')
const portalDivsSelector = portalDivs.join(', ')
const parent = target.closest(portalDivsSelector)
if (!parent) {
@@ -372,7 +372,7 @@ export function pointerDownOutside(
})
if (node && !node.contains(target) && !event.defaultPrevented && !isExcluded) {
const portalDivsSelector = portalDivs.map((id) => `#${id}`).join(', ')
const portalDivsSelector = portalDivs.join(', ')
const parent = target.closest(portalDivsSelector)
if (!parent) {
@@ -1390,3 +1390,34 @@ export function readFieldsRecursively(obj: any): void {
Object.keys(obj).forEach((key) => readFieldsRecursively(obj[key]))
}
}
export function reorder<T>(items: T[], oldIndex: number, newIndex: number): T[] {
const updatedItems = [...items]
const [removedItem] = updatedItems.splice(oldIndex, 1)
updatedItems.splice(newIndex, 0, removedItem)
return updatedItems
}
export function scroll_into_view_if_needed_polyfill(elem: Element, centerIfNeeded: boolean = true) {
const observer = new IntersectionObserver(
function ([entry]) {
const ratio = entry.intersectionRatio
if (ratio < 1) {
const place = ratio <= 0 && centerIfNeeded ? `center` : `nearest`
elem.scrollIntoView({
block: place,
inline: place
})
}
observer.disconnect()
},
{
root: null, // or specify a scrolling parent if needed
rootMargin: '0px 1000px', // Essentially making horizontal checks irrelevant
threshold: 0.1 // Adjust threshold to control when observer should trigger
}
)
observer.observe(elem)
return observer // return for testing
}
@@ -41,7 +41,7 @@
import AutoscalingEvents from '$lib/components/AutoscalingEvents.svelte'
import HttpAgentWorkerDrawer from '$lib/components/HttpAgentWorkerDrawer.svelte'
import WorkerRepl from '$lib/components/WorkerRepl.svelte'
import Select from '$lib/components/Select.svelte'
import Select from '$lib/components/select/Select.svelte'
let workers: WorkerPing[] | undefined = undefined
let workerGroups: Record<string, any> | undefined = undefined
@@ -107,7 +107,7 @@
}
let defaultTagPerWorkspace: boolean | undefined = undefined
let defaultTagWorkspaces: string[] | undefined = undefined
let defaultTagWorkspaces: string[] = []
async function loadDefaultTagsPerWorkspace() {
try {
defaultTagPerWorkspace = await WorkerService.isDefaultTagsPerWorkspace()
@@ -485,27 +485,7 @@
{#if (groupedWorkers ?? []).length > 5}
<div class="flex gap-2 items-center">
<div class="text-secondary text-sm">Worker group:</div>
<Select
items={groupedWorkers.map((x) => ({ value: x[0], label: x[0] }))}
bind:value={selectedTab}
/>
<!-- <select
class="max-w-64"
bind:value={selectedTab}
on:change={() => {
search = ''
}}
>
{#each groupedWorkers.map((x) => x[0]) as name (name)}
<option value={name}
>{name} ({pluralize(
groupedWorkers.find((x) => x[0] == name)?.[1].length ?? 0,
'worker'
)})
</option>
{/each}
</select> -->
<Select items={groupedWorkers.map((x) => ({ value: x[0] }))} bind:value={selectedTab} />
</div>
{:else}
<Tabs bind:selected={selectedTab}>