TUI Renderer, Element Types, and Page Updates

This commit is contained in:
2025-11-30 18:26:56 -05:00
parent f09c198f17
commit 1167c686e2
31 changed files with 1642 additions and 809 deletions
+109 -69
View File
@@ -1,53 +1,71 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import { themeColors } from '$lib/stores/theme';
import type { TerminalLine, FormOption } from './types';
import { createEventDispatcher } from 'svelte';
import '$lib/assets/css/tui-select.css';
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";
export let line: TerminalLine;
export let inline: boolean = false;
export let value: string = '';
interface Props {
line: SelectLine;
inline?: boolean;
value?: string;
onchange?: (value: string) => void;
onfocus?: () => void;
onblur?: () => void;
}
const dispatch = createEventDispatcher<{
change: string;
focus: void;
blur: void;
}>();
let {
line,
inline = false,
value = $bindable(""),
onchange,
onfocus,
onblur,
}: Props = $props();
$: labelSegments = line.content ? parseColorText(line.content, $themeColors.colorMap) : [];
$: options = line.selectOptions || [];
$: placeholder = line.inputPlaceholder || 'Select an option...';
$: isDisabled = line.inputDisabled || false;
$: hasError = line.inputError;
$: errorMessage = line.inputErrorMessage || '';
$: searchable = line.selectSearchable || false;
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 = false;
let searchQuery = '';
let highlightedIndex = 0;
let selectRef: HTMLDivElement;
let isOpen = $state(false);
let searchQuery = $state("");
let highlightedIndex = $state(0);
let selectRef: HTMLDivElement | undefined = $state();
$: filteredOptions = searchable && searchQuery
? options.filter(opt =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
opt.value.toLowerCase().includes(searchQuery.toLowerCase())
)
: options;
const filteredOptions = $derived(
searchable && searchQuery
? options.filter(
(opt) =>
opt.label
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
opt.value
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
: options,
);
$: selectedOption = options.find(opt => opt.value === value);
$: displayValue = selectedOption?.label || '';
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 = '';
dispatch('focus');
searchQuery = "";
onfocus?.();
} else {
dispatch('blur');
onblur?.();
}
}
@@ -55,17 +73,17 @@
if (isDisabled) return;
value = optionValue;
isOpen = false;
searchQuery = '';
dispatch('change', value);
dispatch('blur');
searchQuery = "";
onchange?.(value);
onblur?.();
}
function handleKeydown(e: KeyboardEvent) {
if (isDisabled) return;
switch (e.key) {
case 'Enter':
case ' ':
case "Enter":
case " ":
e.preventDefault();
if (isOpen && filteredOptions[highlightedIndex]) {
handleSelect(filteredOptions[highlightedIndex].value);
@@ -73,31 +91,34 @@
handleToggle();
}
break;
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
isOpen = true;
} else {
highlightedIndex = Math.min(highlightedIndex + 1, filteredOptions.length - 1);
highlightedIndex = Math.min(
highlightedIndex + 1,
filteredOptions.length - 1,
);
}
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
if (isOpen) {
highlightedIndex = Math.max(highlightedIndex - 1, 0);
}
break;
case 'Escape':
case "Escape":
isOpen = false;
searchQuery = '';
searchQuery = "";
break;
case 'Home':
case "Home":
if (isOpen) {
e.preventDefault();
highlightedIndex = 0;
}
break;
case 'End':
case "End":
if (isOpen) {
e.preventDefault();
highlightedIndex = filteredOptions.length - 1;
@@ -109,7 +130,7 @@
function handleClickOutside(e: MouseEvent) {
if (selectRef && !selectRef.contains(e.target as Node)) {
isOpen = false;
searchQuery = '';
searchQuery = "";
}
}
@@ -119,11 +140,11 @@
}
</script>
<svelte:window on:click={handleClickOutside} />
<svelte:window onclick={handleClickOutside} />
<div
class="tui-select"
class:inline={inline}
<div
class="tui-select"
class:inline
class:open={isOpen}
class:error={hasError}
class:disabled={isDisabled}
@@ -146,8 +167,8 @@
{/each}
</label>
{/if}
<div
<div
class="select-trigger"
role="combobox"
aria-expanded={isOpen}
@@ -155,14 +176,14 @@
aria-haspopup="listbox"
aria-disabled={isDisabled}
tabindex={isDisabled ? -1 : 0}
on:click={handleToggle}
on:keydown={handleKeydown}
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>
<span class="select-arrow">{isOpen ? "▲" : "▼"}</span>
</div>
{#if isOpen}
@@ -174,15 +195,15 @@
type="text"
placeholder="Search..."
value={searchQuery}
on:input={handleSearchInput}
on:click|stopPropagation
oninput={handleSearchInput}
onclick={(e) => e.stopPropagation()}
/>
</div>
{/if}
<div class="select-options">
{#each filteredOptions as option, i}
{@const optionSegments = parseColorText(option.label)}
<div
<div
class="select-option"
class:selected={value === option.value}
class:highlighted={i === highlightedIndex}
@@ -191,26 +212,44 @@
aria-selected={value === option.value}
aria-disabled={option.disabled}
tabindex={option.disabled ? -1 : 0}
on:click={() => !option.disabled && handleSelect(option.value)}
on:keydown={(e) => e.key === 'Enter' && !option.disabled && handleSelect(option.value)}
on:mouseenter={() => highlightedIndex = i}
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" />
<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="14" class="inline-icon" />
<Icon
icon={segment.icon}
width="14"
class="inline-icon"
/>
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
<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" />
<Icon
icon="mdi:check"
width="14"
class="check-icon"
/>
{/if}
</div>
{:else}
@@ -269,7 +308,8 @@
.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);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--select-color) 30%, transparent);
}
.tui-select.error .select-trigger {