mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 for 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
-1
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user