You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/routes/test/air/icons/+page.svelte

725 lines
19 KiB

<script lang="ts">
import { getContext } from 'svelte';
import { Icon } from '$uix/air';
import { AIR_ICON_AUTO_STROKE_BY_SIZE } from '$uix/air/icons/create-icon';
import { iconCategories } from '$uix/air/icons/categories';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
let search = $state('');
let selectedCategory = $state<string | null>(null);
let sizeMode = $state<'token' | 'custom'>('token');
let size = $state<'2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'>('sm');
let customSize = $state(18);
let strokeWidth = $state<'auto' | 'sm' | 'md' | 'lg'>('auto');
let tone = $state<'default' | 'accent' | 'neutral' | 'success' | 'warning' | 'danger' | 'info'>(
'default'
);
let copiedIcon = $state<string | null>(null);
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const t = (path: string, ...args: unknown[]) => demoContext?.t(path, ...args) ?? path;
const sizes = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl'] as const;
const strokeWidths = ['auto', 'sm', 'md', 'lg'] as const;
const tones = ['default', 'accent', 'neutral', 'success', 'warning', 'danger', 'info'] as const;
const resolvedPreviewSize = $derived(sizeMode === 'custom' ? customSize : size);
const previewSizeLabel = $derived(sizeMode === 'custom' ? `${customSize}px` : size);
const autoStrokeValue = $derived.by(() => {
if (sizeMode === 'token') return AIR_ICON_AUTO_STROKE_BY_SIZE[size];
if (customSize <= 12) return AIR_ICON_AUTO_STROKE_BY_SIZE['2xs'];
if (customSize <= 14) return AIR_ICON_AUTO_STROKE_BY_SIZE.xs;
if (customSize <= 16) return AIR_ICON_AUTO_STROKE_BY_SIZE.sm;
if (customSize <= 18) return AIR_ICON_AUTO_STROKE_BY_SIZE.md;
if (customSize <= 20) return AIR_ICON_AUTO_STROKE_BY_SIZE.lg;
return AIR_ICON_AUTO_STROKE_BY_SIZE.xl;
});
const previewStrokeLabel = $derived(
strokeWidth === 'auto' ? `auto (${autoStrokeValue})` : strokeWidth
);
const filteredCategories = $derived(
iconCategories
.map((category) => ({
...category,
icons: category.icons.filter(
(icon) =>
!search ||
icon.name.toLowerCase().includes(search.toLowerCase()) ||
icon.pascal.toLowerCase().includes(search.toLowerCase())
)
}))
.filter((category) => category.icons.length > 0)
);
const totalIconCount = $derived(
filteredCategories.reduce((sum, category) => sum + category.icons.length, 0)
);
function getIconComponent(pascalName: string) {
// @ts-expect-error dynamic namespace access over generated icon exports
return Icon[pascalName];
}
async function copyCode(iconName: string) {
const sizePart = `size="${previewSizeLabel}"`;
const strokePart = strokeWidth === 'auto' ? '' : ` strokeWidth="${strokeWidth}"`;
const snippet = `<Icon.${iconName} ${sizePart}${strokePart} />`;
await navigator.clipboard.writeText(snippet);
copiedIcon = iconName;
setTimeout(() => {
if (copiedIcon === iconName) copiedIcon = null;
}, 1400);
}
</script>
<svelte:head>
<title>Air Icons</title>
</svelte:head>
<div class="icons-demo-page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Icons</p>
<h1>Specimen de iconografia</h1>
<p class="lead">
Galeria real dentro de `air`, montada sobre su layout y sus tokens. Aqui validamos escala,
grosor, tono y legibilidad del set completo sin salir del sistema.
</p>
<div class="hero-signals">
<span>{totalIconCount} glyphs</span>
<span>{previewSizeLabel} size</span>
<span>{previewStrokeLabel} stroke</span>
<span>{tone} tone</span>
</div>
</div>
<div class="hero-side">
<section class="control-panel control-panel--search">
<header class="control-panel__header">
<span>Search</span>
<strong>{totalIconCount} results</strong>
</header>
<div class="search">
<span class="search-icon">
<Icon.Search size="sm" />
</span>
<input type="text" placeholder="Search icons..." bind:value={search} />
{#if search}
<button
type="button"
class="clear"
onclick={() => (search = '')}
aria-label="Clear search"
>
<Icon.X size="xs" />
</button>
{/if}
</div>
</section>
<div class="controls-grid">
<section class="control-panel">
<header class="control-panel__header">
<span>Scale</span>
<strong>{previewSizeLabel}</strong>
</header>
<div class="size-mode">
<button
type="button"
class:chip={true}
class:chip--active={sizeMode === 'token'}
onclick={() => (sizeMode = 'token')}>Tokens</button
>
<button
type="button"
class:chip={true}
class:chip--active={sizeMode === 'custom'}
onclick={() => (sizeMode = 'custom')}>Custom px</button
>
</div>
<div class="chips">
{#each sizes as item}
<button
type="button"
class:chip={true}
class:chip--active={size === item}
disabled={sizeMode !== 'token'}
onclick={() => {
sizeMode = 'token';
size = item;
}}>{item}</button
>
{/each}
</div>
<div class="slider-control">
<label for="icon-size-slider">Custom size</label>
<div class="slider-row">
<input
id="icon-size-slider"
type="range"
min="12"
max="40"
step="1"
bind:value={customSize}
oninput={() => (sizeMode = 'custom')}
/>
<strong>{customSize}px</strong>
</div>
</div>
</section>
<section class="control-panel">
<header class="control-panel__header">
<span>Stroke</span>
<strong>{previewStrokeLabel}</strong>
</header>
<div class="chips">
{#each strokeWidths as item}
<button
type="button"
class:chip={true}
class:chip--active={strokeWidth === item}
onclick={() => (strokeWidth = item)}>{item}</button
>
{/each}
</div>
<p class="control-note">
`auto` adapta el grosor al tamano del icono. `sm`, `md` y `lg` fuerzan un
override fijo.
</p>
<div class="stroke-map" aria-label="Auto stroke map">
{#each sizes as item}
<div class="stroke-map-row">
<span>{item}</span>
<strong>{AIR_ICON_AUTO_STROKE_BY_SIZE[item]}</strong>
</div>
{/each}
</div>
</section>
<section class="control-panel">
<header class="control-panel__header">
<span>Tone</span>
<strong>{tone}</strong>
</header>
<div class="chips">
{#each tones as item}
<button
type="button"
class:chip={true}
class:chip--active={tone === item}
onclick={() => (tone = item)}>{item}</button
>
{/each}
</div>
</section>
</div>
</div>
</header>
<div class="content">
<nav class="categories-nav" aria-label="Icon categories">
<button
type="button"
class:category-btn={true}
class:category-btn--active={selectedCategory === null}
onclick={() => (selectedCategory = null)}
>
<span>{t('air.icon.all') || 'All'}</span>
<strong>{totalIconCount}</strong>
</button>
{#each filteredCategories as category}
<button
type="button"
class:category-btn={true}
class:category-btn--active={selectedCategory === category.name}
onclick={() => (selectedCategory = category.name)}
>
<span>{category.label}</span>
<strong>{category.icons.length}</strong>
</button>
{/each}
</nav>
<div class="categories">
{#each filteredCategories as category}
{#if selectedCategory === null || selectedCategory === category.name}
<section class="category">
<header class="category-header">
<div>
<p>{category.name}</p>
<h2>{category.label}</h2>
</div>
<span class="count">{category.icons.length} icons</span>
</header>
<div class="icons-grid">
{#each category.icons as icon}
{@const IconComponent = getIconComponent(icon.pascal)}
{#if IconComponent}
<button
type="button"
class={`icon-card icon-card--tone-${tone}`}
onclick={() => copyCode(icon.pascal)}
aria-label={`Copy ${icon.pascal} icon snippet`}
title={`Copy <Icon.${icon.pascal} />`}
>
<span class="icon-stage">
<IconComponent
size={resolvedPreviewSize}
strokeWidth={strokeWidth === 'auto' ? undefined : strokeWidth}
/>
</span>
<span class="icon-name">{icon.pascal}</span>
{#if copiedIcon === icon.pascal}
<span class="icon-feedback">Copied</span>
{/if}
</button>
{/if}
{/each}
</div>
</section>
{/if}
{/each}
</div>
</div>
</div>
<style>
.icons-demo-page {
display: flex;
flex-direction: column;
gap: var(--air-space-5);
}
.hero,
.category,
.categories-nav,
.control-panel {
border-radius: var(--air-radius-xl);
border: 1px solid color-mix(in srgb, var(--air-color-border) 84%, transparent);
box-shadow: var(--air-shadow-1);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: var(--air-space-5);
padding: var(--air-space-5);
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--air-color-accent-muted) 18%, transparent), transparent 42%),
linear-gradient(
180deg,
color-mix(in srgb, var(--air-color-surface-raised) 96%, transparent),
color-mix(in srgb, var(--air-color-surface) 76%, transparent)
);
}
.hero-copy,
.hero-side {
display: flex;
flex-direction: column;
gap: var(--air-space-3);
}
.eyebrow {
margin: 0;
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
font-weight: var(--air-font-weight-medium);
letter-spacing: var(--air-tracking-wider);
text-transform: uppercase;
color: var(--air-color-accent-text);
}
.hero h1,
.category-header h2 {
margin: 0;
font-weight: var(--air-font-weight-semibold);
line-height: var(--air-leading-heading);
}
.hero h1 {
font-size: clamp(var(--air-font-size-2xl), 2.5vw, 34px);
letter-spacing: var(--air-tracking-tight);
}
.lead {
margin: 0;
max-width: 60ch;
font-size: var(--air-font-size-md);
line-height: var(--air-leading-text);
color: var(--air-color-text-secondary);
}
.hero-signals {
display: flex;
flex-wrap: wrap;
gap: var(--air-space-2);
}
.hero-signals span {
padding: var(--air-space-1) var(--air-space-2);
border-radius: var(--air-radius-full);
border: 1px solid color-mix(in srgb, var(--air-color-border) 76%, transparent);
background: color-mix(in srgb, var(--air-color-surface-raised) 88%, transparent);
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
color: var(--air-color-text-secondary);
}
.controls-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--air-space-3);
}
.control-panel {
display: flex;
flex-direction: column;
gap: var(--air-space-3);
padding: var(--air-space-3);
background: color-mix(in srgb, var(--air-color-surface-overlay) 72%, var(--air-color-surface-raised));
}
.control-panel--search {
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--air-color-accent-muted) 10%, var(--air-color-surface-overlay)),
transparent
),
color-mix(in srgb, var(--air-color-surface-overlay) 72%, var(--air-color-surface-raised));
}
.control-panel__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--air-space-2);
}
.control-panel__header span,
.category-header p {
margin: 0;
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
letter-spacing: var(--air-tracking-wide);
text-transform: uppercase;
color: var(--air-color-text-secondary);
}
.control-panel__header strong,
.count {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
color: var(--air-color-text-secondary);
}
.control-note {
margin: 0;
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-text);
color: var(--air-color-text-secondary);
}
.stroke-map {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--air-space-2);
padding-top: var(--air-space-1);
}
.stroke-map-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--air-space-2);
padding: var(--air-space-2);
border-radius: var(--air-radius-md);
border: 1px solid color-mix(in srgb, var(--air-color-border-subtle) 72%, transparent);
background: color-mix(in srgb, var(--air-color-surface) 78%, transparent);
}
.stroke-map-row span,
.stroke-map-row strong {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
}
.stroke-map-row span {
color: var(--air-color-text-secondary);
}
.stroke-map-row strong {
color: var(--air-color-text-primary);
}
.search {
display: flex;
align-items: center;
gap: var(--air-space-3);
padding: var(--air-space-3) var(--air-space-4);
border-radius: var(--air-radius-lg);
border: 1px solid var(--air-color-border);
background: color-mix(in srgb, var(--air-color-surface) 84%, transparent);
}
.search-icon,
.clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.search :global(svg) {
width: 100%;
height: 100%;
}
.search input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font: inherit;
font-size: var(--air-font-size-sm);
color: var(--air-color-text-primary);
outline: none;
}
.search input::placeholder {
color: var(--air-color-text-secondary);
}
.clear {
padding: 0;
border: none;
background: transparent;
color: var(--air-color-text-secondary);
cursor: pointer;
border-radius: var(--air-radius-full);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--air-space-2);
}
.size-mode {
display: flex;
gap: var(--air-space-2);
}
.chip {
border: 1px solid var(--air-color-border);
background: color-mix(in srgb, var(--air-color-surface) 82%, transparent);
color: var(--air-color-text-primary);
padding: var(--air-space-2) var(--air-space-3);
border-radius: var(--air-radius-full);
font: inherit;
font-size: var(--air-font-size-xs);
cursor: pointer;
}
.chip:disabled {
opacity: 0.45;
cursor: default;
}
.chip--active {
background: var(--air-color-accent);
border-color: var(--air-color-accent);
color: var(--air-color-surface-raised);
}
.slider-control {
display: flex;
flex-direction: column;
gap: var(--air-space-2);
}
.slider-control label {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
letter-spacing: var(--air-tracking-wide);
text-transform: uppercase;
color: var(--air-color-text-secondary);
}
.slider-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--air-space-3);
}
.slider-row input {
width: 100%;
}
.slider-row strong {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
line-height: var(--air-leading-ui);
color: var(--air-color-text-primary);
}
.content {
display: grid;
grid-template-columns: 240px 1fr;
gap: var(--air-space-5);
align-items: start;
}
.categories-nav {
position: sticky;
top: var(--air-space-4);
display: flex;
flex-direction: column;
gap: var(--air-space-1);
padding: var(--air-space-3);
background: color-mix(in srgb, var(--air-color-surface-overlay) 72%, var(--air-color-surface-raised));
max-height: calc(100vh - var(--air-space-8));
overflow-y: auto;
}
.category-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--air-space-3);
padding: var(--air-space-2) var(--air-space-3);
border: none;
border-radius: var(--air-radius-md);
background: transparent;
color: var(--air-color-text-secondary);
font: inherit;
text-align: left;
cursor: pointer;
}
.category-btn strong {
font-family: var(--air-font-mono);
font-size: var(--air-font-size-xs);
font-weight: var(--air-font-weight-medium);
}
.category-btn--active {
background: var(--air-color-accent);
color: var(--air-color-surface-raised);
}
.categories {
display: flex;
flex-direction: column;
gap: var(--air-space-4);
}
.category {
padding: var(--air-space-5);
background: color-mix(in srgb, var(--air-color-surface-overlay) 70%, var(--air-color-surface-raised));
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--air-space-4);
margin-bottom: var(--air-space-4);
padding-bottom: var(--air-space-3);
border-bottom: 1px solid color-mix(in srgb, var(--air-color-border) 80%, transparent);
}
.icons-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
gap: var(--air-space-3);
}
.icon-card {
--icon-tone-color: var(--air-color-text-primary);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--air-space-3) var(--air-space-2);
min-height: 92px;
border-radius: var(--air-radius-lg);
border: 1px solid color-mix(in srgb, var(--air-color-border) 84%, transparent);
background: color-mix(in srgb, var(--air-color-surface) 86%, transparent);
color: var(--icon-tone-color);
cursor: pointer;
}
.icon-card--tone-default { --icon-tone-color: var(--air-color-text-primary); }
.icon-card--tone-accent { --icon-tone-color: var(--air-color-accent); }
.icon-card--tone-neutral { --icon-tone-color: var(--air-color-neutral-solid); }
.icon-card--tone-success { --icon-tone-color: var(--air-color-success); }
.icon-card--tone-warning { --icon-tone-color: var(--air-color-warning); }
.icon-card--tone-danger { --icon-tone-color: var(--air-color-error); }
.icon-card--tone-info { --icon-tone-color: var(--air-color-info-solid); }
.icon-stage {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
border-radius: 0;
background: transparent;
box-shadow: none;
line-height: 0;
}
.icon-stage :global(svg) {
flex: 0 0 auto;
}
.icon-name,
.icon-feedback {
font-family: var(--air-font-mono);
line-height: var(--air-leading-ui);
text-align: center;
width: 100%;
}
.icon-name {
font-size: 9px;
font-weight: var(--air-font-weight-medium);
color: var(--air-color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-feedback {
font-size: 10px;
color: var(--air-color-accent-text);
}
@media (max-width: 960px) {
.hero,
.content,
.controls-grid {
grid-template-columns: 1fr;
}
.categories-nav {
position: static;
max-height: none;
}
}
</style>

Powered by TurnKey Linux.