CSS Files Styling
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
131
src/lib/components/tui/TuiCheckbox.svelte
Normal file
131
src/lib/components/tui/TuiCheckbox.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
197
src/lib/components/tui/TuiInput.svelte
Normal file
197
src/lib/components/tui/TuiInput.svelte
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
188
src/lib/components/tui/TuiRadio.svelte
Normal file
188
src/lib/components/tui/TuiRadio.svelte
Normal 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>
|
||||
393
src/lib/components/tui/TuiSelect.svelte
Normal file
393
src/lib/components/tui/TuiSelect.svelte
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
|
||||
226
src/lib/components/tui/TuiTextarea.svelte
Normal file
226
src/lib/components/tui/TuiTextarea.svelte
Normal 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>
|
||||
175
src/lib/components/tui/TuiToggle.svelte
Normal file
175
src/lib/components/tui/TuiToggle.svelte
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user