action select: patch weird focus issue, add value helper field
This commit is contained in:
@@ -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) {
|
onchange?.(val);
|
||||||
value = val;
|
if (!stateless) value = val;
|
||||||
onchange?.(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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user