TUI Renderer, Element Types, and Page Updates
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user