CSS Files Styling
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
|
||||
import type { TerminalLine, FormOption } from './types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import '$lib/assets/css/tui-select.css';
|
||||
|
||||
export let line: TerminalLine;
|
||||
export let inline: boolean = false;
|
||||
export let value: string = '';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: string;
|
||||
focus: void;
|
||||
blur: void;
|
||||
}>();
|
||||
|
||||
$: labelSegments = line.content ? parseColorText(line.content) : [];
|
||||
$: options = line.selectOptions || [];
|
||||
$: placeholder = line.inputPlaceholder || 'Select an option...';
|
||||
$: isDisabled = line.inputDisabled || false;
|
||||
$: hasError = line.inputError;
|
||||
$: errorMessage = line.inputErrorMessage || '';
|
||||
$: searchable = line.selectSearchable || false;
|
||||
|
||||
let isOpen = false;
|
||||
let searchQuery = '';
|
||||
let highlightedIndex = 0;
|
||||
let selectRef: HTMLDivElement;
|
||||
|
||||
$: filteredOptions = 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 || '';
|
||||
|
||||
function handleToggle() {
|
||||
if (isDisabled) return;
|
||||
isOpen = !isOpen;
|
||||
if (isOpen) {
|
||||
highlightedIndex = 0;
|
||||
searchQuery = '';
|
||||
dispatch('focus');
|
||||
} else {
|
||||
dispatch('blur');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(optionValue: string) {
|
||||
if (isDisabled) return;
|
||||
value = optionValue;
|
||||
isOpen = false;
|
||||
searchQuery = '';
|
||||
dispatch('change', value);
|
||||
dispatch('blur');
|
||||
}
|
||||
|
||||
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 on:click={handleClickOutside} />
|
||||
|
||||
<div
|
||||
class="tui-select"
|
||||
class:inline={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="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}
|
||||
on:click={handleToggle}
|
||||
on:keydown={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}
|
||||
on:input={handleSearchInput}
|
||||
on:click|stopPropagation
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="select-options">
|
||||
{#each filteredOptions as option, i}
|
||||
{@const optionSegments = parseColorText(option.label)}
|
||||
<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}
|
||||
on:click={() => !option.disabled && handleSelect(option.value)}
|
||||
on:keydown={(e) => e.key === 'Enter' && !option.disabled && handleSelect(option.value)}
|
||||
on:mouseenter={() => 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="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>
|
||||
Reference in New Issue
Block a user