CSS Files Styling

This commit is contained in:
2025-11-28 17:43:48 +00:00
parent 8cdb39afbe
commit 4d17b599b3
54 changed files with 4817 additions and 1764 deletions

View File

@@ -2,6 +2,7 @@
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import '$lib/assets/css/tui-accordion.css';
export let line: TerminalLine;

View File

@@ -10,7 +10,14 @@
import TuiTable from './TuiTable.svelte';
import TuiTooltip from './TuiTooltip.svelte';
import TuiCardGrid from './TuiCardGrid.svelte';
import TuiInput from './TuiInput.svelte';
import TuiTextarea from './TuiTextarea.svelte';
import TuiCheckbox from './TuiCheckbox.svelte';
import TuiRadio from './TuiRadio.svelte';
import TuiSelect from './TuiSelect.svelte';
import TuiToggle from './TuiToggle.svelte';
import type { DisplayedLine } from './types';
import '$lib/assets/css/tui-body.css';
export let displayedLines: DisplayedLine[] = [];
export let currentLineIndex = 0;
@@ -71,6 +78,12 @@
<TuiTooltip {line} />
{:else if line.type === 'progress'}
<TuiProgress {line} inline={true} />
{:else if line.type === 'input'}
<TuiInput {line} inline={true} />
{:else if line.type === 'checkbox'}
<TuiCheckbox {line} inline={true} />
{:else if line.type === 'toggle'}
<TuiToggle {line} inline={true} />
{:else}
<span class="inline-content {line.type}">
{getLinePrefix(line.type)}{#each visibleSegments 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}
@@ -113,6 +126,18 @@
<div class="tui-line">
<TuiTooltip {line} />
</div>
{:else if line.type === 'input'}
<TuiInput {line} />
{:else if line.type === 'textarea'}
<TuiTextarea {line} />
{:else if line.type === 'checkbox'}
<TuiCheckbox {line} />
{:else if line.type === 'radio'}
<TuiRadio {line} />
{:else if line.type === 'select'}
<TuiSelect {line} />
{:else if line.type === 'toggle'}
<TuiToggle {line} />
{:else}
<div class="tui-line {line.type}" class:complete id={line.id}>
{#if line.type === 'command' || line.type === 'prompt'}
@@ -167,232 +192,4 @@
{/if}
</div>
<style>
.tui-body {
flex: 1;
padding: 1rem 1.25rem 2rem 1.25rem;
overflow-y: auto;
overflow-x: hidden;
font-size: 0.9rem;
line-height: 1.7;
min-height: 0;
}
/* Inline group wrapper - groups consecutive inline elements */
.tui-inline-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.2rem;
animation: lineSlideIn 0.15s ease-out;
}
.inline-content {
display: inline;
white-space: pre-wrap;
}
.inline-content.output {
color: var(--terminal-muted);
}
.inline-content.info {
color: var(--terminal-primary);
}
.inline-content.success {
color: #a6e3a1;
}
.inline-content.error {
color: #f38ba8;
}
.inline-content.warning {
color: #f9e2af;
}
/* Lines */
.tui-line {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
margin-bottom: 0.2rem;
animation: lineSlideIn 0.15s ease-out;
min-height: 1.7em;
}
.tui-line.blank {
min-height: 0.5em;
}
@keyframes lineSlideIn {
from {
opacity: 0;
transform: translateX(-5px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Prompt styling */
.prompt {
display: inline-flex;
margin-right: 0.5rem;
flex-shrink: 0;
}
.prompt .user {
color: var(--terminal-user);
}
.prompt .at {
color: var(--terminal-muted);
}
.prompt .host {
color: var(--terminal-accent);
}
.prompt .separator {
color: var(--terminal-muted);
}
.prompt .path {
color: var(--terminal-path);
}
.prompt .symbol {
color: var(--terminal-muted);
margin-left: 0.25rem;
}
/* Content */
.content {
color: var(--terminal-text);
word-break: break-word;
white-space: pre-wrap;
}
.tui-line.output .content {
color: var(--terminal-muted);
}
.tui-line.error .content {
color: #f38ba8;
}
.tui-line.success .content {
color: #a6e3a1;
}
.tui-line.info .content {
color: var(--terminal-primary);
}
.tui-line.warning .content {
color: #f9e2af;
}
.header-text {
color: var(--terminal-accent);
font-weight: 600;
font-size: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
:global(.header-icon) {
opacity: 0.7;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
/* Divider */
.tui-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 0.75rem 0;
}
.divider-line {
flex: 1;
height: 1px;
background: linear-gradient(
to right,
transparent,
var(--terminal-border),
transparent
);
}
.divider-text {
color: var(--terminal-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* Images */
.tui-image {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0;
}
.tui-image img {
border-radius: 6px;
border: 1px solid var(--terminal-border);
background: var(--terminal-bg-light);
object-fit: contain;
}
.image-caption {
color: var(--terminal-muted);
font-size: 0.8rem;
font-style: italic;
}
/* Cursor */
.cursor {
display: inline-block;
width: 8px;
height: 1.2em;
background: var(--terminal-primary);
animation: cursorBlink 1s step-end infinite;
margin-left: 2px;
vertical-align: middle;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Scrollbar */
.tui-body::-webkit-scrollbar {
width: 6px;
}
.tui-body::-webkit-scrollbar-track {
background: transparent;
}
.tui-body::-webkit-scrollbar-thumb {
background: var(--terminal-border);
border-radius: 3px;
}
.tui-body::-webkit-scrollbar-thumb:hover {
background: var(--terminal-muted);
}
</style>

View File

@@ -2,6 +2,7 @@
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import '$lib/assets/css/tui-button.css';
export let line: TerminalLine;
export let index: number;
@@ -48,66 +49,3 @@
{/if}
{/if}
</button>
<style>
.tui-button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
margin: 0.2rem 0;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--btn-color);
font-family: inherit;
font-size: 0.9rem;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}
/* Inline button styles */
.tui-button.inline {
width: auto;
display: inline-flex;
margin: 0;
padding: 0.35rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--btn-color) 40%, transparent);
}
.tui-button.inline .btn-indicator {
display: none;
}
.tui-button:hover,
.tui-button.selected {
background: color-mix(in srgb, var(--btn-color) 15%, transparent);
border-color: var(--btn-color);
}
.btn-indicator {
color: var(--btn-color);
font-size: 0.8rem;
width: 1rem;
}
.btn-text {
flex: 1;
}
:global(.btn-arrow) {
opacity: 0.5;
}
.tui-button.selected :global(.btn-arrow) {
opacity: 1;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
</style>

View File

@@ -2,6 +2,7 @@
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import '$lib/assets/css/tui-card.css';
export let line: TerminalLine;

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { TerminalLine } from './types';
import '$lib/assets/css/tui-card-grid.css';
export let line: TerminalLine;
@@ -56,12 +57,12 @@
{#if card.tags && card.tags.length > 0}
<div class="tags">
{#each card.tags.slice(0, 5) as tag}
{#each card.tags as tag}
<span class="tag">{tag}</span>
{/each}
{#if card.tags.length > 5}
<!-- {#if card.tags.length > 5}
<span class="tag more">+{card.tags.length - 5}</span>
{/if}
{/if} -->
</div>
{/if}
@@ -261,10 +262,10 @@
text-transform: lowercase;
}
.tag.more {
/* .tag.more {
background: color-mix(in srgb, var(--terminal-muted) 20%, transparent);
color: var(--terminal-muted);
}
} */
.warning {
font-size: 0.6rem;

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import { createEventDispatcher } from 'svelte';
import '$lib/assets/css/tui-checkbox.css';
export let line: TerminalLine;
export let inline: boolean = false;
export let checked: boolean = false;
const dispatch = createEventDispatcher<{
change: boolean;
}>();
$: labelSegments = line.content ? parseColorText(line.content) : [];
$: isDisabled = line.inputDisabled || false;
$: indeterminate = line.checkboxIndeterminate || false;
function handleChange() {
if (isDisabled) return;
checked = !checked;
dispatch('change', checked);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
handleChange();
}
}
function getCheckboxSymbol(checked: boolean, indeterminate: boolean): string {
if (indeterminate) return '[-]';
return checked ? '[✓]' : '[ ]';
}
</script>
<div
class="tui-checkbox"
class:inline={inline}
class:checked={checked}
class:disabled={isDisabled}
style="--checkbox-color: {getButtonStyle(line.style)}"
role="checkbox"
aria-checked={indeterminate ? 'mixed' : checked}
aria-disabled={isDisabled}
tabindex={isDisabled ? -1 : 0}
on:click={handleChange}
on:keydown={handleKeydown}
>
<span class="checkbox-box" class:indeterminate={indeterminate}>
{getCheckboxSymbol(checked, indeterminate)}
</span>
{#if line.icon}
<Icon icon={line.icon} width="14" class="checkbox-icon" />
{/if}
{#if line.content}
<span class="checkbox-label">
{#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}
</span>
{/if}
</div>
<style>
.tui-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.35rem 0;
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.tui-checkbox.inline {
display: inline-flex;
margin: 0 0.75rem 0 0;
}
.tui-checkbox:hover:not(.disabled) {
background: color-mix(in srgb, var(--checkbox-color) 10%, transparent);
}
.tui-checkbox:focus-visible {
outline: 1px solid var(--checkbox-color);
outline-offset: 2px;
}
.tui-checkbox.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkbox-box {
font-family: inherit;
font-weight: bold;
color: var(--terminal-muted);
transition: color 0.15s ease;
}
.tui-checkbox.checked .checkbox-box,
.checkbox-box.indeterminate {
color: var(--checkbox-color);
}
:global(.checkbox-icon) {
color: var(--checkbox-color);
}
.checkbox-label {
color: var(--terminal-text);
}
.tui-checkbox.disabled .checkbox-label {
color: var(--terminal-muted);
}
</style>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import '$lib/assets/css/tui-footer.css';
export let isTyping: boolean;
export let linesCount: number;
@@ -30,64 +31,4 @@
</span>
</div>
<style>
.tui-statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
background: var(--terminal-bg-light);
border-color: var(--terminal-border);
}
.tui-statusbar.bottom {
border-top: 1px solid var(--terminal-border);
}
.status-left, .status-right {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--terminal-muted);
}
.status-center {
color: var(--terminal-primary);
font-weight: 600;
}
.skip-btn {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.15rem 0.5rem;
background: var(--terminal-border);
border: 1px solid transparent;
border-radius: 3px;
color: var(--terminal-muted);
font-family: inherit;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s ease;
}
.skip-btn:hover {
background: var(--terminal-primary);
color: var(--terminal-bg);
border-color: var(--terminal-primary);
}
.typing-indicator {
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.line-count {
color: var(--terminal-muted);
}
</style>

View File

@@ -1,16 +1,25 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { user } from '$lib/config';
import type { SpeedPreset } from '$lib/config';
import { colorTheme, type ColorTheme } from '$lib/stores/theme';
import '$lib/assets/css/tui-header.css';
export let title = 'terminal';
export let interactive = true;
export let hasButtons = false;
function getThemeIcon(theme: ColorTheme): string {
switch (theme) {
case 'arch': return 'mdi:arch';
case 'catppuccin': return 'solar:cat-bold';
default: return 'mdi:palette';
}
}
</script>
<div class="tui-statusbar top">
<span class="status-left">
<Icon icon="mdi:arch" width="14" />
<Icon icon={getThemeIcon($colorTheme)} width="14" />
<span>{user.username}@{user.hostname}</span>
</span>
<span class="status-center">{title}</span>
@@ -22,43 +31,4 @@
</span>
</div>
<style>
.tui-statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
background: var(--terminal-bg-light);
border-color: var(--terminal-border);
}
.tui-statusbar.top {
border-bottom: 1px solid var(--terminal-border);
}
.status-left, .status-right {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--terminal-muted);
}
.status-center {
color: var(--terminal-primary);
font-weight: 600;
}
.hint {
padding: 0.1rem 0.4rem;
background: var(--terminal-border);
border-radius: 3px;
font-size: 0.7rem;
}
@media (max-width: 768px) {
.hint {
display: none;
}
}
</style>

View File

@@ -0,0 +1,197 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import { createEventDispatcher } from 'svelte';
import '$lib/assets/css/tui-input.css';
export let line: TerminalLine;
export let inline: boolean = false;
export let value: string = '';
const dispatch = createEventDispatcher<{
input: string;
change: string;
focus: void;
blur: void;
}>();
$: labelSegments = line.content ? parseColorText(line.content) : [];
$: placeholder = line.inputPlaceholder || '';
$: inputType = line.inputType || 'text';
$: isDisabled = line.inputDisabled || false;
$: hasError = line.inputError;
$: errorMessage = line.inputErrorMessage || '';
$: prefix = line.inputPrefix || '';
$: suffix = line.inputSuffix || '';
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
value = target.value;
dispatch('input', value);
}
function handleChange(e: Event) {
const target = e.target as HTMLInputElement;
dispatch('change', target.value);
}
function handleFocus() {
dispatch('focus');
}
function handleBlur() {
dispatch('blur');
}
</script>
<div
class="tui-input"
class:inline={inline}
class:error={hasError}
class:disabled={isDisabled}
style="--input-color: {getButtonStyle(line.style)}"
>
{#if line.content}
<label class="input-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="input-wrapper">
<span class="input-prompt"></span>
{#if prefix}
<span class="input-affix prefix">{prefix}</span>
{/if}
<input
type={inputType}
{placeholder}
{value}
disabled={isDisabled}
on:input={handleInput}
on:change={handleChange}
on:focus={handleFocus}
on:blur={handleBlur}
/>
{#if suffix}
<span class="input-affix suffix">{suffix}</span>
{/if}
</div>
{#if hasError && errorMessage}
<div class="input-error">
<Icon icon="mdi:alert-circle" width="12" />
<span>{errorMessage}</span>
</div>
{/if}
</div>
<style>
.tui-input {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-input.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.5rem 0 0;
}
.input-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(--input-color);
}
.input-wrapper {
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;
transition: all 0.15s ease;
}
.input-wrapper:focus-within {
border-color: var(--input-color);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--input-color) 30%, transparent);
}
.tui-input.error .input-wrapper {
border-color: #f38ba8;
}
.tui-input.disabled .input-wrapper {
opacity: 0.5;
cursor: not-allowed;
}
.input-prompt {
color: var(--input-color);
font-weight: bold;
user-select: none;
}
.input-affix {
color: var(--terminal-muted);
font-size: 0.85rem;
}
.input-affix.prefix {
margin-right: 0.25rem;
}
.input-affix.suffix {
margin-left: 0.25rem;
}
input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: inherit;
outline: none;
}
input::placeholder {
color: var(--terminal-muted);
opacity: 0.6;
}
input:disabled {
cursor: not-allowed;
}
.input-error {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.35rem;
color: #f38ba8;
font-size: 0.8rem;
}
</style>

View File

@@ -2,6 +2,7 @@
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import '$lib/assets/css/tui-link.css';
export let line: TerminalLine;
export let onClick: () => void;

View File

@@ -2,6 +2,7 @@
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import '$lib/assets/css/tui-progress.css';
export let line: TerminalLine;
export let inline: boolean = false;

View File

@@ -0,0 +1,188 @@
<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-radio.css';
export let line: TerminalLine;
export let inline: boolean = false;
export let value: string = '';
const dispatch = createEventDispatcher<{
change: string;
}>();
$: labelSegments = line.content ? parseColorText(line.content) : [];
$: options = line.radioOptions || [];
$: isDisabled = line.inputDisabled || false;
$: isHorizontal = line.radioHorizontal || false;
function handleSelect(optionValue: string) {
if (isDisabled) return;
value = optionValue;
dispatch('change', value);
}
function handleKeydown(e: KeyboardEvent, optionValue: string) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
handleSelect(optionValue);
}
}
function getRadioSymbol(selected: boolean): string {
return selected ? '(●)' : '( )';
}
</script>
<div
class="tui-radio-group"
class:inline={inline}
class:disabled={isDisabled}
style="--radio-color: {getButtonStyle(line.style)}"
role="radiogroup"
>
{#if line.content}
<div class="radio-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}
</div>
{/if}
<div class="radio-options" class:horizontal={isHorizontal}>
{#each options as option}
{@const isSelected = value === option.value}
{@const optionSegments = parseColorText(option.label)}
<div
class="radio-option"
class:selected={isSelected}
class:option-disabled={option.disabled}
role="radio"
aria-checked={isSelected}
aria-disabled={isDisabled || option.disabled}
tabindex={isDisabled || option.disabled ? -1 : 0}
on:click={() => !option.disabled && handleSelect(option.value)}
on:keydown={(e) => !option.disabled && handleKeydown(e, option.value)}
>
<span class="radio-symbol">
{getRadioSymbol(isSelected)}
</span>
{#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>
</div>
{/each}
</div>
</div>
<style>
.tui-radio-group {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-radio-group.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.75rem 0 0;
}
.tui-radio-group.disabled {
opacity: 0.5;
}
.radio-label {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.5rem;
color: var(--terminal-muted);
font-size: 0.85rem;
}
:global(.label-icon) {
color: var(--radio-color);
}
.radio-options {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.radio-options.horizontal {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.radio-option:hover:not(.option-disabled) {
background: color-mix(in srgb, var(--radio-color) 10%, transparent);
}
.radio-option:focus-visible {
outline: 1px solid var(--radio-color);
outline-offset: 2px;
}
.radio-option.option-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radio-symbol {
font-family: inherit;
font-weight: bold;
color: var(--terminal-muted);
transition: color 0.15s ease;
}
.radio-option.selected .radio-symbol {
color: var(--radio-color);
}
:global(.option-icon) {
color: var(--radio-color);
}
.option-label {
color: var(--terminal-text);
}
.radio-option.option-disabled .option-label {
color: var(--terminal-muted);
}
</style>

View File

@@ -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>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import '$lib/assets/css/tui-table.css';
export let line: TerminalLine;

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import { createEventDispatcher } from 'svelte';
import '$lib/assets/css/tui-textarea.css';
export let line: TerminalLine;
export let inline: boolean = false;
export let value: string = '';
const dispatch = createEventDispatcher<{
input: string;
change: string;
focus: void;
blur: void;
}>();
$: labelSegments = line.content ? parseColorText(line.content) : [];
$: placeholder = line.inputPlaceholder || '';
$: isDisabled = line.inputDisabled || false;
$: hasError = line.inputError;
$: errorMessage = line.inputErrorMessage || '';
$: rows = line.textareaRows || 4;
$: maxLength = line.textareaMaxLength;
let isFocused = false;
let charCount = 0;
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
value = target.value;
charCount = value.length;
dispatch('input', value);
}
function handleChange(e: Event) {
const target = e.target as HTMLTextAreaElement;
dispatch('change', target.value);
}
function handleFocus() {
isFocused = true;
dispatch('focus');
}
function handleBlur() {
isFocused = false;
dispatch('blur');
}
</script>
<div
class="tui-textarea"
class:inline={inline}
class:focused={isFocused}
class:error={hasError}
class:disabled={isDisabled}
style="--input-color: {getButtonStyle(line.style)}"
>
{#if line.content}
<label class="textarea-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="textarea-wrapper">
<div class="line-numbers">
{#each Array(Math.max(rows, value.split('\n').length)) as _, i}
<span class="line-num">{i + 1}</span>
{/each}
</div>
<textarea
{placeholder}
{rows}
maxlength={maxLength}
disabled={isDisabled}
bind:value
on:input={handleInput}
on:change={handleChange}
on:focus={handleFocus}
on:blur={handleBlur}
></textarea>
</div>
<div class="textarea-footer">
{#if hasError && errorMessage}
<div class="textarea-error">
<Icon icon="mdi:alert-circle" width="12" />
<span>{errorMessage}</span>
</div>
{/if}
{#if maxLength}
<div class="char-count" class:warning={charCount > maxLength * 0.9}>
{charCount}/{maxLength}
</div>
{/if}
</div>
</div>
<style>
.tui-textarea {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.tui-textarea.inline {
display: inline-flex;
flex-direction: column;
margin: 0 0.5rem 0 0;
}
.textarea-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(--input-color);
}
.textarea-wrapper {
display: flex;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
transition: all 0.15s ease;
overflow: hidden;
}
.tui-textarea.focused .textarea-wrapper {
border-color: var(--input-color);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--input-color) 30%, transparent);
}
.tui-textarea.error .textarea-wrapper {
border-color: #f38ba8;
}
.tui-textarea.disabled .textarea-wrapper {
opacity: 0.5;
cursor: not-allowed;
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 0.5rem 0;
background: color-mix(in srgb, var(--terminal-bg) 60%, black);
border-right: 1px solid var(--terminal-muted);
user-select: none;
}
.line-num {
padding: 0 0.5rem;
color: var(--terminal-muted);
font-size: 0.8rem;
line-height: 1.5;
text-align: right;
min-width: 2rem;
}
textarea {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: inherit;
line-height: 1.5;
resize: vertical;
outline: none;
}
textarea::placeholder {
color: var(--terminal-muted);
opacity: 0.6;
}
textarea:disabled {
cursor: not-allowed;
resize: none;
}
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.35rem;
}
.textarea-error {
display: flex;
align-items: center;
gap: 0.35rem;
color: #f38ba8;
font-size: 0.8rem;
}
.char-count {
margin-left: auto;
color: var(--terminal-muted);
font-size: 0.75rem;
}
.char-count.warning {
color: #f9e2af;
}
</style>

View File

@@ -0,0 +1,175 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import { createEventDispatcher } from 'svelte';
import '$lib/assets/css/tui-toggle.css';
export let line: TerminalLine;
export let inline: boolean = false;
export let checked: boolean = false;
const dispatch = createEventDispatcher<{
change: boolean;
}>();
$: labelSegments = line.content ? parseColorText(line.content) : [];
$: isDisabled = line.inputDisabled || false;
$: onLabel = line.toggleOnLabel || 'ON';
$: offLabel = line.toggleOffLabel || 'OFF';
$: showLabels = line.toggleShowLabels !== false;
function handleToggle() {
if (isDisabled) return;
checked = !checked;
dispatch('change', checked);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
handleToggle();
}
}
</script>
<div
class="tui-toggle"
class:inline={inline}
class:checked={checked}
class:disabled={isDisabled}
style="--toggle-color: {getButtonStyle(line.style)}"
role="switch"
aria-checked={checked}
aria-disabled={isDisabled}
tabindex={isDisabled ? -1 : 0}
on:click={handleToggle}
on:keydown={handleKeydown}
>
{#if line.icon}
<Icon icon={line.icon} width="14" class="toggle-icon" />
{/if}
{#if line.content}
<span class="toggle-label">
{#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}
</span>
{/if}
<div class="toggle-track">
{#if showLabels}
<span class="toggle-off-label">{offLabel}</span>
{/if}
<span class="toggle-switch">
<span class="toggle-knob">{checked ? '●' : '○'}</span>
</span>
{#if showLabels}
<span class="toggle-on-label">{onLabel}</span>
{/if}
</div>
</div>
<style>
.tui-toggle {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.35rem 0;
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
user-select: none;
}
.tui-toggle.inline {
display: inline-flex;
margin: 0 0.75rem 0 0;
}
.tui-toggle:hover:not(.disabled) {
background: color-mix(in srgb, var(--toggle-color) 10%, transparent);
}
.tui-toggle:focus-visible {
outline: 1px solid var(--toggle-color);
outline-offset: 2px;
}
.tui-toggle.disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.toggle-icon) {
color: var(--toggle-color);
}
.toggle-label {
color: var(--terminal-text);
}
.toggle-track {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border: 1px solid var(--terminal-muted);
border-radius: 4px;
font-size: 0.75rem;
transition: all 0.15s ease;
}
.tui-toggle.checked .toggle-track {
border-color: var(--toggle-color);
background: color-mix(in srgb, var(--toggle-color) 15%, transparent);
}
.toggle-off-label,
.toggle-on-label {
color: var(--terminal-muted);
font-weight: bold;
text-transform: uppercase;
min-width: 2rem;
text-align: center;
transition: color 0.15s ease;
}
.tui-toggle:not(.checked) .toggle-off-label {
color: var(--terminal-text);
}
.tui-toggle.checked .toggle-on-label {
color: var(--toggle-color);
}
.toggle-switch {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
position: relative;
}
.toggle-knob {
font-size: 1rem;
color: var(--terminal-muted);
transition: all 0.2s ease;
transform: translateX(-0.5rem);
}
.tui-toggle.checked .toggle-knob {
color: var(--toggle-color);
transform: translateX(0.5rem);
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
import '$lib/assets/css/tui-tooltip.css';
export let line: TerminalLine;

View File

@@ -4,7 +4,16 @@ import type { Card } from '$lib/config';
export type LineType =
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
| 'card' | 'progress' | 'accordion' | 'table' | 'tooltip' | 'cardgrid';
| 'card' | 'progress' | 'accordion' | 'table' | 'tooltip' | 'cardgrid'
| 'input' | 'textarea' | 'checkbox' | 'radio' | 'select' | 'toggle';
// Option type for radio and select components
export interface FormOption {
value: string;
label: string;
icon?: string;
disabled?: boolean;
}
export interface TerminalLine {
type: LineType;
@@ -41,6 +50,29 @@ export interface TerminalLine {
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
// For cardgrid type
cards?: Card[];
// For form input types (input, textarea, checkbox, radio, select, toggle)
inputPlaceholder?: string;
inputType?: 'text' | 'email' | 'password' | 'number' | 'url' | 'tel' | 'search';
inputDisabled?: boolean;
inputError?: boolean;
inputErrorMessage?: string;
inputPrefix?: string;
inputSuffix?: string;
// For textarea type
textareaRows?: number;
textareaMaxLength?: number;
// For checkbox type
checkboxIndeterminate?: boolean;
// For radio type
radioOptions?: FormOption[];
radioHorizontal?: boolean;
// For select type
selectOptions?: FormOption[];
selectSearchable?: boolean;
// For toggle type
toggleOnLabel?: string;
toggleOffLabel?: string;
toggleShowLabels?: boolean;
}
// Pre-parsed line with segments ready for rendering