~james/core

core/web/src/lib/components/ui/SplitButton.svelte -rw-r--r-- 2.7 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
<script module lang="ts">
	import type { Component } from "svelte";
	import type { SvelteHTMLElements } from "svelte/elements";

	export interface SplitButtonOption {
		/** shown in the menu, where the options are read side by side and need to disambiguate */
		label: string;
		/** shown on the button once armed; falls back to `label` when the two can be the same */
		buttonLabel?: string;
		icon?: Component<SvelteHTMLElements["svg"]>;
		onclick?: () => void;
		danger?: boolean;
	}
</script>

<script lang="ts">
	import ChevronDown from "$icon/chevron-down";
	import Button, { buttonIcon, type ButtonVariants } from "#lib/components/ui/Button.svelte";
	import ButtonGroup from "#lib/components/ui/ButtonGroup.svelte";
	import Dropdown, {
		type DropdownProps,
		type DropdownVariants
	} from "#lib/components/ui/Dropdown.svelte";
	import DropdownItem from "#lib/components/ui/DropdownItem.svelte";

	interface Props {
		options: SplitButtonOption[];
		/** index of the armed option; two-way, so a caller can react to the choice */
		selected?: number;
		variant?: ButtonVariants["variant"];
		size?: ButtonVariants["size"];
		align?: DropdownVariants["align"];
		side?: DropdownProps["side"];
		menuLabel?: string;
		disabled?: boolean;
		loading?: boolean;
		class?: string;
		testid?: string;
	}

	let {
		options,
		selected = $bindable(0),
		variant = "default",
		size,
		align = "right",
		side,
		menuLabel = "More options",
		disabled = false,
		loading = false,
		class: className,
		testid
	}: Props = $props();

	// options can be built from a filtered list, so neither an empty array nor a
	// stale index may reach options[selected]
	const current = $derived(options[Math.min(Math.max(selected, 0), options.length - 1)]);
</script>

{#if current}
	<ButtonGroup class={className} data-testid={testid}>
		<Button
			data-testid="primaryAction"
			class="flex-1"
			{variant}
			{size}
			{disabled}
			{loading}
			icon={current.icon}
			onclick={current.onclick}
		>
			<span>{current.buttonLabel ?? current.label}</span>
		</Button>
		<!-- the menu stays open even when the armed option is disabled, so a caller
		     can gate one option (a conflicting merge, say) without trapping the user -->
		<Dropdown
			variant="button"
			square
			buttonVariant={variant}
			{size}
			{align}
			{side}
			label={menuLabel}
			testid="options"
		>
			{#snippet trigger()}
				<ChevronDown class={buttonIcon({ size })} />
			{/snippet}
			{#each options as option, index (option.label)}
				<DropdownItem
					data-testid="option"
					data-index={index}
					icon={option.icon}
					danger={option.danger}
					{size}
					onclick={() => (selected = index)}
				>
					{option.label}
				</DropdownItem>
			{/each}
		</Dropdown>
	</ButtonGroup>
{/if}