SelectDropdown reverse animation + ui nits (#6733)

* text-secondary nit

* Select Dropdown reverse animation

* nit misalignment
This commit is contained in:
Diego Imbert
2025-10-02 22:31:05 +02:00
committed by GitHub
parent 7cd5f26f70
commit 9f40ce2da9
5 changed files with 115 additions and 57 deletions
@@ -27,6 +27,7 @@
defaultValues?: Record<string, any> | undefined
placeholder?: string | undefined
selectInputClass?: string
class?: string
onClear?: () => void
excludedValues?: string[]
}
@@ -44,6 +45,7 @@
defaultValues = undefined,
placeholder = undefined,
selectInputClass = '',
class: className = '',
onClear = undefined,
excludedValues = undefined
}: Props = $props()
@@ -180,7 +182,7 @@
}}
/>
<!-- {JSON.stringify({ value, collection })} -->
<div class="flex flex-col w-full items-start min-h-10">
<div class="flex flex-col w-full items-start min-h-10 {className}">
<div class="flex flex-row w-full items-center">
<Select
{disabled}
+5 -1
View File
@@ -110,7 +110,11 @@
<span
class={twMerge(
'ml-2 font-normal duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
bothOptions || textDisabled
? checked
? 'text-secondary'
: 'text-disabled'
: 'text-secondary',
size === 'xs' || size === '2sm' ? 'text-xs' : size === '2xs' ? 'text-xs' : 'text-sm',
textClass
)}
@@ -4,7 +4,6 @@
import { untrack, type Snippet } from 'svelte'
import type { ProcessedItem } from './utils.svelte'
import { twMerge } from 'tailwind-merge'
import { slide } from 'svelte/transition'
let {
processedItems: _processedItems,
@@ -59,15 +58,22 @@
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 }
function computeDropdownPos(): {
width: number
height: number
x: number
y: number
isBelow: boolean
} {
if (!getInputRect || !listEl) return { width: 0, height: 0, x: 0, y: 0, isBelow: true }
let inputR = getInputRect()
const listR = listEl.getBoundingClientRect()
const openBelow = inputR.y + inputR.height + listR.height <= window.innerHeight
const isBelow = 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 }
if (isBelow)
return { width: inputR.width, height: listR.height, x: x, y: y + inputR.height, isBelow }
else {
return { width: inputR.width, x: x, y: y - listR.height }
return { width: inputR.width, height: listR.height, x: x, y: y - listR.height, isBelow }
}
}
@@ -84,11 +90,46 @@
;[open, processedItems]
untrack(() => (keyArrowPos = open && filterText ? 0 : undefined))
})
// We do not want to render the dropdown when it is closed for performance reasons
// but we want to keep it in the DOM for a short time to allow for transitions to finish
//
// We do not use Svelte transitions because they can not animate in the opposite direction
// when the dropdown is opens above the input
// Also CSS transitions are smoother because they do not rely on JS / animation frames
let uiState = $state({ domExists: open, visible: open, timeout: null as number | null })
let initial = true
$effect(() => {
let isOpen = open && !disabled
untrack(() => {
if (initial) {
initial = false
return
}
if (uiState.timeout) clearTimeout(uiState.timeout)
uiState = {
domExists: true,
visible: !isOpen,
timeout: setTimeout(() => {
if (isOpen) {
uiState.visible = true
uiState.timeout = null
} else if (!isOpen) {
uiState.visible = false
uiState.timeout = setTimeout(() => {
uiState.domExists = false
uiState.timeout = null
}, 500) // leave time for transition to finish
}
}, 0) // We need the height to be 0 then change immediately for the transition to play
}
})
})
</script>
<svelte:window
on:keydown={(e) => {
if (!open || !processedItems?.length) return
if (!uiState.visible || !processedItems?.length) return
if (e.key === 'ArrowUp' && keyArrowPos !== undefined && processedItems.length > 0) {
keyArrowPos = keyArrowPos <= 0 ? undefined : keyArrowPos - 1
} else if (e.key === 'ArrowDown') {
@@ -108,61 +149,70 @@
/>
<ConditionalPortal condition={!disablePortal} name="select-dropdown-portal">
{#if open && !disabled}
{#if uiState.domExists}
<div
transition:slide={{ duration: 150 }}
class={twMerge(
disablePortal ? 'absolute' : 'fixed',
'z-[5001] rounded-md bg-surface-secondary text-tertiary shadow-lg text-sm select-none',
'z-[5001] text-tertiary text-sm select-none',
dropdownPos.isBelow ? '' : 'flex flex-col justify-end',
uiState.visible ? '' : 'pointer-events-none',
className
)}
style="{`top: ${dropdownPos.y}px; left: ${dropdownPos.x}px;`} {listAutoWidth
? `min-width: ${dropdownPos.width}px;`
? `min-width: ${dropdownPos.width}px; height: ${dropdownPos.height}px;`
: ''}"
>
<div bind:this={listEl} class="flex flex-col max-h-64 border rounded-md overflow-clip">
{@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}
<div
class={twMerge(
'overflow-clip rounded-md bg-surface-secondary shadow-lg transition-height',
dropdownPos.isBelow ? '' : 'flex flex-col justify-end'
)}
style="height: {uiState.visible ? dropdownPos.height : 0}px;"
>
<div bind:this={listEl} class="flex flex-col max-h-64 border rounded-md overflow-clip">
{@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',
itemButtonWrapperClasses
)}
onclick={(e) => {
e.stopImmediatePropagation()
onSelectValue(item)
}}
>
{@render startSnippet?.({ item, close: () => (open = false) })}
<span class={itemLabelWrapperClasses}>
{item.label || '\xa0'}
</span>
{@render endSnippet?.({ item, close: () => (open = false) })}
{#if item.subtitle}
<div class="text-xs text-tertiary">{item.subtitle}</div>
{/if}
</button>
</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',
itemButtonWrapperClasses
)}
onclick={(e) => {
e.stopImmediatePropagation()
onSelectValue(item)
}}
>
{@render startSnippet?.({ item, close: () => (open = false) })}
<span class={itemLabelWrapperClasses}>
{item.label || '\xa0'}
</span>
{@render endSnippet?.({ item, close: () => (open = false) })}
{#if item.subtitle}
<div class="text-xs text-tertiary">{item.subtitle}</div>
{/if}
</button>
</li>
{/each}
</ul>
{@render bottomSnippet?.({ close: () => (open = false) })}
{/each}
</ul>
{@render bottomSnippet?.({ close: () => (open = false) })}
</div>
</div>
</div>
{/if}
@@ -311,6 +311,8 @@
<ResourcePicker
bind:value={ducklake.catalog.resource_path}
resourceType={ducklake.catalog.resource_type}
selectInputClass="min-h-9"
class="min-h-9"
/>
{:else}
<TextInput
@@ -175,7 +175,7 @@
{#if adminsInstance}
<Button
btnClasses="w-full mt-2 mb-4 truncate"
btnClasses="w-full mt-2 mb-4 truncate text-secondary"
color="light"
size="sm"
on:click={async () => {
@@ -205,7 +205,7 @@
<label class="block pb-2" style:padding-left={`${depth * 24}px`}>
<button
class="w-full mx-auto py-1 px-2 rounded-md border
shadow-sm text-sm font-normal mt-1 hover:ring-1 hover:ring-indigo-300 flex items-center"
shadow-sm text-sm text-secondary font-normal mt-1 hover:ring-1 hover:ring-indigo-300 flex items-center"
on:click={async () => {
speakFriendAndEnterWorkspace(workspace.id)
}}