action select: patch weird focus issue, add value helper field

This commit is contained in:
Elijah Duffy
2025-07-08 12:21:58 -07:00
parent 509b144553
commit 39ca78e837
3 changed files with 88 additions and 40 deletions

View File

@@ -1,9 +1,11 @@
<script lang="ts" module> <script lang="ts" module>
export type ActionSelectOption = { export type ActionSelectOption = {
/** value is a convenience field, it has no internal use */
value?: string;
icon?: icon?:
| { component: Component; props: Record<string, any> } | { component: Component; props: Record<string, any> }
| Snippet<[opt: ActionSelectOption]>; | Snippet<[opt: ActionSelectOption]>;
title: string | Snippet<[opt: ActionSelectOption]>; label: string | Snippet<[opt: ActionSelectOption]>;
disabled?: boolean; disabled?: boolean;
onchoose?: (opt: ActionSelectOption) => void; onchoose?: (opt: ActionSelectOption) => void;
}; };
@@ -11,7 +13,7 @@
<script lang="ts"> <script lang="ts">
import { Select } from 'melt/builders'; import { Select } from 'melt/builders';
import type { Component, Snippet } from 'svelte'; import { tick, type Component, type Snippet } from 'svelte';
import Label from './Label.svelte'; import Label from './Label.svelte';
import Button from './Button.svelte'; import Button from './Button.svelte';
import { CaretDown, Check } from 'phosphor-svelte'; import { CaretDown, Check } from 'phosphor-svelte';
@@ -23,6 +25,7 @@
value?: ActionSelectOption; value?: ActionSelectOption;
stateless?: boolean; stateless?: boolean;
tabbable?: boolean; tabbable?: boolean;
sameWidth?: boolean;
options: ActionSelectOption[]; options: ActionSelectOption[];
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
onchange?: (value: ActionSelectOption | undefined) => void; onchange?: (value: ActionSelectOption | undefined) => void;
@@ -34,20 +37,45 @@
value = $bindable(undefined), value = $bindable(undefined),
stateless = false, stateless = false,
tabbable = true, tabbable = true,
sameWidth = true,
options, options,
class: classValue, class: classValue,
onchange onchange
}: Props = $props(); }: Props = $props();
const select = new Select<ActionSelectOption>({ const select = new Select<ActionSelectOption>({
forceVisible: true,
value: () => value, value: () => value,
sameWidth: () => sameWidth,
onValueChange: (val) => { onValueChange: (val) => {
if (val?.disabled) return; if (val?.disabled) return;
val?.onchoose?.(val); val?.onchoose?.(val);
if (!stateless) {
value = val;
onchange?.(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> </script>
@@ -58,15 +86,16 @@
{/if} {/if}
<Button <Button
bind:ref={buttonElement}
class={['flex w-full items-center rounded-xl!']} class={['flex w-full items-center rounded-xl!']}
{...select.trigger} {...select.trigger}
tabindex={tabbable ? 0 : -1} {...!tabbable && { tabindex: -1 }}
> >
{#if stateless || !select.value} {#if stateless || !select.value}
{placeholder} {placeholder}
{:else} {:else}
{@render icon(select.value)} {@render optionIcon(select.value)}
{@render title(select.value)} {@render optionLabel(select.value)}
{/if} {/if}
<CaretDown <CaretDown
@@ -76,53 +105,69 @@
/> />
</Button> </Button>
<div class={['border-sui-accent space-y-1 rounded-xl border p-2 shadow-md']} {...select.content}> <div
{...select.content}
class={['border-sui-accent flex flex-col gap-1 rounded-xl border p-2 shadow-md']}
>
{#each options as option} {#each options as option}
<button <div
{...select.getOption(option, typeof option.label === 'string' ? option.label : undefined)}
class={[ class={[
'flex w-full items-center gap-3 rounded px-5 py-2 text-base font-medium transition-colors', 'flex w-full items-center gap-3 rounded px-5 py-2 text-base font-medium transition-colors',
option.disabled option.disabled
? ['cursor-not-allowed opacity-50'] ? ['cursor-not-allowed opacity-50']
: stateless : stateless
? [select.highlighted?.title === option.title && 'bg-sui-text-100', 'cursor-pointer'] ? [select.highlighted?.label === option.label && 'bg-sui-text-100', 'cursor-pointer']
: [ : [
select.highlighted?.title === option.title && select.highlighted?.label === option.label &&
'bg-sui-accent/80 text-sui-background', 'bg-sui-accent/80 text-sui-background',
select.value?.title === option.title && 'bg-sui-accent text-sui-background' select.value?.label === option.label && 'bg-sui-accent text-sui-background'
] ]
]} ]}
{...select.getOption(option, typeof option.title === 'string' ? option.title : undefined)}
> >
{@render icon(option)} {@render optionIcon(option)}
{@render title(option)} {@render optionLabel(option)}
{#if !stateless} {#if !stateless && select.isSelected(option)}
<Check <Check size="1.1em" weight="bold" class={['ml-auto']} />
size="1.1em"
weight="bold"
class={[
select.value?.title === option.title ? 'opacity-100' : 'opacity-0',
'ml-auto transition-opacity'
]}
/>
{/if} {/if}
</button> </div>
{/each} {/each}
</div> </div>
</div> </div>
{#snippet title(opt: ActionSelectOption)} {#snippet optionLabel(opt: ActionSelectOption)}
{#if typeof opt.title === 'string'} {#if typeof opt.label === 'string'}
{opt.title} {opt.label}
{:else} {:else}
{@render opt.title(opt)} {@render opt.label(opt)}
{/if} {/if}
{/snippet} {/snippet}
{#snippet icon(opt: ActionSelectOption)} {#snippet optionIcon(opt: ActionSelectOption)}
{#if opt.icon && typeof opt.icon === 'object'} {#if opt.icon && typeof opt.icon === 'object'}
<opt.icon.component {...opt.icon.props} /> <opt.icon.component {...opt.icon.props} />
{:else if opt.icon} {:else if opt.icon}
{@render opt.icon(opt)} {@render opt.icon(opt)}
{/if} {/if}
{/snippet} {/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>

View File

@@ -8,6 +8,7 @@
animate?: boolean; animate?: boolean;
loading?: boolean; loading?: boolean;
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
ref?: HTMLButtonElement | null;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>; onclick?: MouseEventHandler<HTMLButtonElement>;
} }
@@ -17,6 +18,7 @@
animate = true, animate = true,
loading, loading,
class: classValue, class: classValue,
ref = $bindable<HTMLButtonElement | null>(null),
children, children,
onclick, onclick,
...others ...others
@@ -70,6 +72,7 @@
</script> </script>
<button <button
bind:this={ref}
class={[ class={[
'button group relative flex gap-3 overflow-hidden rounded-sm px-5', 'button group relative flex gap-3 overflow-hidden rounded-sm px-5',
'text-sui-background cursor-pointer py-3 font-medium transition-colors', 'text-sui-background cursor-pointer py-3 font-medium transition-colors',

View File

@@ -59,21 +59,21 @@
stateless stateless
label="Stateless Action" label="Stateless Action"
options={[ options={[
{ title: 'Yeet' }, { label: 'Yeet' },
{ title: 'Yote' }, { label: 'Yote' },
{ title: 'Yote and Yeet' }, { label: 'Yote and Yeet' },
{ title: 'Disabled Action', disabled: true } { label: 'Disabled Action', disabled: true }
]} ]}
/> />
<ActionSelect <ActionSelect
class="basis-1/2" class="basis-1/2"
label="Stateful Action" label="Stateful Action"
value={{ title: 'Initial Action', onchoose: (value) => console.log('Chosen:', value) }} value={{ label: 'Initial Action', onchoose: (value) => console.log('Chosen:', value) }}
options={[ options={[
{ title: 'Action 1', onchoose: (value) => console.log('Action 1 chosen:', value) }, { label: 'Action 1', onchoose: (value) => console.log('Action 1 chosen:', value) },
{ title: 'Action 2', onchoose: (value) => console.log('Action 2 chosen:', value) }, { label: 'Action 2', onchoose: (value) => console.log('Action 2 chosen:', value) },
{ {
title: 'Disabled Action', label: 'Disabled Action',
disabled: true, disabled: true,
onchoose: (value) => console.log('Disabled action chosen:', value) onchoose: (value) => console.log('Disabled action chosen:', value)
} }