Files
sui/src/lib/ActionSelect.svelte

174 lines
4.1 KiB
Svelte

<script lang="ts" module>
export type ActionSelectOption = {
/** value is a convenience field, it has no internal use */
value?: string;
icon?:
| { component: Component; props: Record<string, any> }
| Snippet<[opt: ActionSelectOption]>;
label: string | Snippet<[opt: ActionSelectOption]>;
disabled?: boolean;
onchoose?: (opt: ActionSelectOption) => void;
};
</script>
<script lang="ts">
import { Select } from 'melt/builders';
import { tick, type Component, type Snippet } from 'svelte';
import Label from './Label.svelte';
import Button from './Button.svelte';
import { CaretDown, Check } from 'phosphor-svelte';
import type { ClassValue } from 'svelte/elements';
interface Props {
label?: string;
placeholder?: string;
value?: ActionSelectOption;
stateless?: boolean;
tabbable?: boolean;
sameWidth?: boolean;
options: ActionSelectOption[];
class?: ClassValue | null | undefined;
onchange?: (value: ActionSelectOption | undefined) => void;
}
let {
label,
placeholder = 'Choose an action',
value = $bindable(undefined),
stateless = false,
tabbable = true,
sameWidth = true,
options,
class: classValue,
onchange
}: Props = $props();
const select = new Select<ActionSelectOption>({
forceVisible: true,
value: () => value,
sameWidth: () => sameWidth,
onValueChange: (val) => {
if (val?.disabled) return;
val?.onchoose?.(val);
onchange?.(val);
if (!stateless) value = val;
}
});
let buttonElement = $state<HTMLButtonElement | null>(null);
let allowFocus = false;
$effect(() => {
tick().then(() => {
setTimeout(() => {
allowFocus = true;
}, 100);
});
});
$effect(() => {
if (buttonElement) {
// Prevent focus during hydration
const originalFocus = buttonElement.focus.bind(buttonElement);
buttonElement.focus = (...args) => {
if (allowFocus) {
console.log('allowing focus');
return originalFocus(...args);
}
};
}
});
</script>
<div class={[classValue]}>
{#if label}
<Label {...select.label}>{label}</Label>
{/if}
<Button
bind:ref={buttonElement}
class={['flex w-full items-center rounded-xl!']}
{...select.trigger}
{...!tabbable && { tabindex: -1 }}
>
{#if stateless || !select.value}
{placeholder}
{:else}
{@render optionIcon(select.value)}
{@render optionLabel(select.value)}
{/if}
<CaretDown
weight="bold"
size="1.1em"
class={[select.open && '-scale-y-100', 'ml-auto transition-transform']}
/>
</Button>
<div
{...select.content}
class={['border-sui-accent flex flex-col gap-1 rounded-xl border p-2 shadow-md']}
>
{#each options as option}
<div
{...select.getOption(option, typeof option.label === 'string' ? option.label : undefined)}
class={[
'flex w-full items-center gap-3 rounded px-5 py-2 text-base font-medium transition-colors',
option.disabled
? ['cursor-not-allowed opacity-50']
: stateless
? [select.highlighted?.label === option.label && 'bg-sui-text-100', 'cursor-pointer']
: [
select.highlighted?.label === option.label &&
'bg-sui-accent/80 text-sui-background',
select.value?.label === option.label && 'bg-sui-accent text-sui-background'
]
]}
>
{@render optionIcon(option)}
{@render optionLabel(option)}
{#if !stateless && select.isSelected(option)}
<Check size="1.1em" weight="bold" class={['ml-auto']} />
{/if}
</div>
{/each}
</div>
</div>
{#snippet optionLabel(opt: ActionSelectOption)}
{#if typeof opt.label === 'string'}
{opt.label}
{:else}
{@render opt.label(opt)}
{/if}
{/snippet}
{#snippet optionIcon(opt: ActionSelectOption)}
{#if opt.icon && typeof opt.icon === 'object'}
<opt.icon.component {...opt.icon.props} />
{:else if opt.icon}
{@render opt.icon(opt)}
{/if}
{/snippet}
<style lang="postcss">
[data-melt-select-content] {
position: absolute;
pointer-events: none;
opacity: 0;
transform: scale(0.975);
transition: 0.2s;
transition-property: opacity, transform;
transform-origin: var(--melt-popover-content-transform-origin, center);
}
[data-melt-select-content][data-open] {
pointer-events: auto;
opacity: 1;
transform: scale(1);
}
</style>