Files
website/src/lib/components/tui/TuiSelect.svelte
T

435 lines
9.2 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { SelectLine } from "./types";
import "$lib/assets/css/tui-select.css";
interface Props {
line: SelectLine;
inline?: boolean;
value?: string;
onchange?: (value: string) => void;
onfocus?: () => void;
onblur?: () => void;
}
let {
line,
inline = false,
value = $bindable(""),
onchange,
onfocus,
onblur,
}: Props = $props();
const labelSegments = $derived(
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
);
const options = $derived(line.selectOptions || []);
const placeholder = $derived(
line.inputPlaceholder || "Select an option...",
);
const isDisabled = $derived(line.inputDisabled || false);
const hasError = $derived(line.inputError);
const errorMessage = $derived(line.inputErrorMessage || "");
const searchable = $derived(line.selectSearchable || false);
let isOpen = $state(false);
let searchQuery = $state("");
let highlightedIndex = $state(0);
let selectRef: HTMLDivElement | undefined = $state();
const filteredOptions = $derived(
searchable && searchQuery
? options.filter(
(opt) =>
opt.label
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
opt.value
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
: options,
);
const selectedOption = $derived(options.find((opt) => opt.value === value));
const displayValue = $derived(selectedOption?.label || "");
function handleToggle() {
if (isDisabled) return;
isOpen = !isOpen;
if (isOpen) {
highlightedIndex = 0;
searchQuery = "";
onfocus?.();
} else {
onblur?.();
}
}
function handleSelect(optionValue: string) {
if (isDisabled) return;
value = optionValue;
isOpen = false;
searchQuery = "";
onchange?.(value);
onblur?.();
}
function handleKeydown(e: KeyboardEvent) {
if (isDisabled) return;
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
if (isOpen && filteredOptions[highlightedIndex]) {
handleSelect(filteredOptions[highlightedIndex].value);
} else {
handleToggle();
}
break;
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
isOpen = true;
} else {
highlightedIndex = Math.min(
highlightedIndex + 1,
filteredOptions.length - 1,
);
}
break;
case "ArrowUp":
e.preventDefault();
if (isOpen) {
highlightedIndex = Math.max(highlightedIndex - 1, 0);
}
break;
case "Escape":
isOpen = false;
searchQuery = "";
break;
case "Home":
if (isOpen) {
e.preventDefault();
highlightedIndex = 0;
}
break;
case "End":
if (isOpen) {
e.preventDefault();
highlightedIndex = filteredOptions.length - 1;
}
break;
}
}
function handleClickOutside(e: MouseEvent) {
if (selectRef && !selectRef.contains(e.target as Node)) {
isOpen = false;
searchQuery = "";
}
}
function handleSearchInput(e: Event) {
searchQuery = (e.target as HTMLInputElement).value;
highlightedIndex = 0;
}
</script>
<svelte:window onclick={handleClickOutside} />
<div
class="tui-select"
class:inline
class:open={isOpen}
class:error={hasError}
class:disabled={isDisabled}
style="--select-color: {getButtonStyle(line.style)}"
bind:this={selectRef}
>
{#if line.content}
<label class="select-label">
{#if line.icon}
<Icon icon={line.icon} width="14" class="label-icon" />
{/if}
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</label>
{/if}
<div
class="select-trigger"
role="combobox"
aria-expanded={isOpen}
aria-controls="select-listbox"
aria-haspopup="listbox"
aria-disabled={isDisabled}
tabindex={isDisabled ? -1 : 0}
onclick={handleToggle}
onkeydown={handleKeydown}
>
<span class="select-prompt"></span>
<span class="select-value" class:placeholder={!selectedOption}>
{displayValue || placeholder}
</span>
<span class="select-arrow">{isOpen ? "▲" : "▼"}</span>
</div>
{#if isOpen}
<div class="select-dropdown" role="listbox" id="select-listbox">
{#if searchable}
<div class="select-search">
<Icon icon="mdi:magnify" width="14" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
oninput={handleSearchInput}
onclick={(e) => e.stopPropagation()}
/>
</div>
{/if}
<div class="select-options">
{#each filteredOptions as option, i}
{@const optionSegments = parseColorText(option.label, $themeColors.colorMap)}
<div
class="select-option"
class:selected={value === option.value}
class:highlighted={i === highlightedIndex}
class:option-disabled={option.disabled}
role="option"
aria-selected={value === option.value}
aria-disabled={option.disabled}
tabindex={option.disabled ? -1 : 0}
onclick={() =>
!option.disabled && handleSelect(option.value)}
onkeydown={(e) =>
e.key === "Enter" &&
!option.disabled &&
handleSelect(option.value)}
onmouseenter={() => (highlightedIndex = i)}
>
{#if option.icon}
<Icon
icon={option.icon}
width="14"
class="option-icon"
/>
{/if}
<span class="option-label">
{#each optionSegments as segment}
{#if segment.icon}
<Icon
icon={segment.icon}
width={segment.iconSize ?? 14}
class="inline-icon"
/>
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}
>{segment.text}</span
>
{:else}
{segment.text}
{/if}
{/each}
</span>
{#if value === option.value}
<Icon
icon="mdi:check"
width="14"
class="check-icon"
/>
{/if}
</div>
{:else}
<div class="select-empty">No options found</div>
{/each}
</div>
</div>
{/if}
{#if hasError && errorMessage}
<div class="select-error">
<Icon icon="mdi:alert-circle" width="12" />
<span>{errorMessage}</span>
</div>
{/if}
</div>
<style>
.tui-select {
position: relative;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-select.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.5rem 0 0;
}
.select-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--select-color);
}
.select-trigger {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.tui-select.open .select-trigger,
.select-trigger:focus-visible {
border-color: var(--select-color);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--select-color) 30%, transparent);
}
.tui-select.error .select-trigger {
border-color: #f38ba8;
}
.tui-select.disabled .select-trigger {
opacity: 0.5;
cursor: not-allowed;
}
.select-prompt {
color: var(--select-color);
font-weight: bold;
}
.select-value {
flex: 1;
color: var(--terminal-text);
}
.select-value.placeholder {
color: var(--terminal-muted);
opacity: 0.6;
}
.select-arrow {
color: var(--terminal-muted);
font-size: 0.7rem;
}
.select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--terminal-bg);
border: 1px solid var(--select-color);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
overflow: hidden;
}
.select-search {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--terminal-muted);
}
.select-search input {
flex: 1;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: inherit;
outline: none;
}
.select-search input::placeholder {
color: var(--terminal-muted);
}
.select-options {
max-height: 200px;
overflow-y: auto;
}
.select-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.1s ease;
}
.select-option.highlighted {
background: color-mix(in srgb, var(--select-color) 15%, transparent);
}
.select-option.selected {
color: var(--select-color);
}
.select-option.option-disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.option-icon) {
color: var(--select-color);
}
.option-label {
flex: 1;
}
:global(.check-icon) {
color: var(--select-color);
}
.select-empty {
padding: 0.75rem;
text-align: center;
color: var(--terminal-muted);
font-style: italic;
}
.select-error {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.35rem;
color: #f38ba8;
font-size: 0.8rem;
}
</style>