Unify UI workflows and streamline fleet management
This commit is contained in:
@@ -45,8 +45,9 @@
|
||||
aria-label={ariaLabel}
|
||||
{title}
|
||||
href={disabled || loading ? undefined : href}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex={disabled || loading ? -1 : undefined}
|
||||
aria-busy={loading}
|
||||
onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }}
|
||||
>
|
||||
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
|
||||
<span class="label">{@render children?.()}</span>
|
||||
@@ -60,6 +61,7 @@
|
||||
{type}
|
||||
{title}
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={loading}
|
||||
disabled={disabled || loading}
|
||||
{onclick}
|
||||
>
|
||||
@@ -81,7 +83,8 @@
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: var(--tracking-normal);
|
||||
line-height: 1;
|
||||
line-height: 1.35;
|
||||
min-height: 40px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
@@ -105,7 +108,7 @@
|
||||
.btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); }
|
||||
|
||||
/* Sizes */
|
||||
.s-sm { padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
|
||||
.s-sm { min-height: 32px; padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
|
||||
.s-md { padding: 9px 16px; font-size: var(--text-sm); }
|
||||
.s-lg { padding: 12px 22px; font-size: var(--text-base); }
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
{tabindex}
|
||||
{style}
|
||||
{onclick}
|
||||
onkeydown={(event: KeyboardEvent) => { if (role === 'button' && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); (event.currentTarget as HTMLElement).click(); } }}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
@@ -56,7 +57,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklab, var(--surface) 90%, transparent);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
@@ -77,12 +79,6 @@
|
||||
|
||||
/* Backdrop blur only where the browser supports it — some rendering
|
||||
backends downgrade poorly, and we don't want blurred noise. */
|
||||
@supports (backdrop-filter: blur(4px)) {
|
||||
.card {
|
||||
backdrop-filter: blur(10px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Padding scale */
|
||||
.p-none { padding: 0; }
|
||||
@@ -91,23 +87,21 @@
|
||||
.p-lg { padding: var(--space-6); }
|
||||
|
||||
/* Elevation */
|
||||
.e-flat { box-shadow: var(--shadow-ambient); }
|
||||
.e-flat { box-shadow: none; }
|
||||
.e-raised { box-shadow: var(--shadow-raised); }
|
||||
|
||||
.e-interactive {
|
||||
box-shadow: var(--shadow-ambient);
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.e-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-raised);
|
||||
border-color: var(--border-strong);
|
||||
background: color-mix(in oklab, var(--surface-raised) 92%, transparent);
|
||||
}
|
||||
.e-interactive:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring), var(--shadow-raised);
|
||||
}
|
||||
.e-interactive:active { transform: translateY(-1px); }
|
||||
@media (max-width: 480px) {
|
||||
.p-md, .p-lg { padding: var(--space-4); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { tick, type Snippet } from 'svelte';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
let { open = $bindable(false), title, description, busy = false, wide = false, children, actions }:
|
||||
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; children?: Snippet; actions?: Snippet } = $props();
|
||||
const id = $props.id();
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let returnFocus: HTMLElement | null = null;
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) dialog.close();
|
||||
});
|
||||
function close() { if (!busy) open = false; }
|
||||
async function closed() {
|
||||
open = false;
|
||||
await tick();
|
||||
if (returnFocus?.isConnected) returnFocus.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} class:wide aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
|
||||
oncancel={event => { if (busy) event.preventDefault(); }} onclose={closed}
|
||||
onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}>
|
||||
<header><div><h2 id={`${id}-title`}>{title}</h2>{#if description}<p id={`${id}-description`}>{description}</p>{/if}</div><Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button></header>
|
||||
<div class="body">{@render children?.()}</div>
|
||||
{#if actions}<footer>{@render actions()}</footer>{/if}
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
|
||||
dialog.wide { width: min(880px, calc(100vw - 32px)); }
|
||||
header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); border-bottom: 1px solid var(--border-subtle); }
|
||||
h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-tight); }
|
||||
p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.body { padding: var(--space-6); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-subtle); background: var(--surface); position: sticky; bottom: 0; z-index: 1; }
|
||||
@media (max-width: 600px) { header, .body { padding: var(--space-4); } footer { padding: var(--space-4); } h2 { font-size: var(--text-lg); } }
|
||||
</style>
|
||||
@@ -6,20 +6,15 @@
|
||||
description?: string | null;
|
||||
icon?: Snippet;
|
||||
actions?: Snippet;
|
||||
spacious?: boolean;
|
||||
}
|
||||
|
||||
let { title, description = null, icon, actions }: Props = $props();
|
||||
let { title, description = null, icon, actions, spacious = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty" role="status">
|
||||
<div class="empty" class:spacious role="status">
|
||||
{#if icon}
|
||||
<div class="icon">{@render icon()}</div>
|
||||
{:else}
|
||||
<div class="icon default">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="title">{title}</h3>
|
||||
{#if description}<p class="desc">{description}</p>{/if}
|
||||
@@ -32,12 +27,13 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--space-16) var(--space-6);
|
||||
padding: var(--space-6);
|
||||
gap: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in oklab, var(--surface) 60%, transparent);
|
||||
}
|
||||
.spacious { padding: var(--space-12) var(--space-6); }
|
||||
.icon {
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-2);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
@@ -11,22 +12,28 @@
|
||||
}
|
||||
|
||||
let { label, hint = null, error = null, required = false, forId, children }: Props = $props();
|
||||
const id = $props.id();
|
||||
setContext<FieldContext>(fieldContext, {
|
||||
get labelId() { return `${id}-label`; },
|
||||
get messageId() { return error || hint ? `${id}-message` : undefined; },
|
||||
get invalid() { return !!error; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
{#if label}
|
||||
<label class="label" for={forId}>
|
||||
<svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
|
||||
{label}
|
||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||
</label>
|
||||
</svelte:element>
|
||||
{/if}
|
||||
<div class="control" class:has-error={!!error}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="msg msg-error" role="alert">{error}</div>
|
||||
<div id={`${id}-message`} class="msg msg-error" role="alert">{error}</div>
|
||||
{:else if hint}
|
||||
<div class="msg msg-hint">{hint}</div>
|
||||
<div id={`${id}-message`} class="msg msg-hint">{hint}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -40,9 +47,7 @@
|
||||
.label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-label);
|
||||
color: var(--text-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.req { color: var(--state-danger); margin-left: 3px; }
|
||||
.control { display: flex; flex-direction: column; }
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||
interface Props {
|
||||
value?: string;
|
||||
type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file';
|
||||
@@ -13,6 +16,7 @@
|
||||
onchange?: (e: Event) => void;
|
||||
oninput?: (e: Event) => void;
|
||||
accept?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -28,7 +32,8 @@
|
||||
mono = false,
|
||||
onchange,
|
||||
oninput,
|
||||
accept
|
||||
accept,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -44,6 +49,10 @@
|
||||
{readonly}
|
||||
{autocomplete}
|
||||
{accept}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
aria-invalid={field?.invalid || undefined}
|
||||
bind:value
|
||||
{onchange}
|
||||
{oninput}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Button from './Button.svelte';
|
||||
let { total, limit = $bindable(25), step = 25, label = 'items' }: { total: number; limit?: number; step?: number; label?: string } = $props();
|
||||
</script>
|
||||
{#if total > step}
|
||||
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => limit += step}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
|
||||
{/if}
|
||||
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
|
||||
@@ -37,7 +37,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-8);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.crumbs {
|
||||
font-size: var(--text-xs);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -14,6 +17,7 @@
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onchange?: (e: Event) => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -24,7 +28,8 @@
|
||||
required = false,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
onchange
|
||||
onchange,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -35,6 +40,10 @@
|
||||
{name}
|
||||
{required}
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
aria-invalid={field?.invalid || undefined}
|
||||
bind:value
|
||||
{onchange}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Badge from './Badge.svelte';
|
||||
import { statusPresentation } from '../../status.js';
|
||||
let { state, size = 'sm' }: { state: string; size?: 'sm' | 'md' } = $props();
|
||||
const status = $derived(statusPresentation(state));
|
||||
</script>
|
||||
<Badge variant={status.tone} {size}>{status.label}</Badge>
|
||||
@@ -12,9 +12,24 @@
|
||||
onchange?: (id: string) => void;
|
||||
ariaLabel?: string;
|
||||
size?: 'sm' | 'md';
|
||||
panelId?: string;
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md' }: Props = $props();
|
||||
const uid = $props.id();
|
||||
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const enabled = tabs.filter(t => !t.disabled);
|
||||
if (!enabled.length) return;
|
||||
const index = enabled.findIndex(t => t.id === value);
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
|
||||
: (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length;
|
||||
select(enabled[next].id, false);
|
||||
document.getElementById(`${idPrefix}-${enabled[next].id}`)?.focus();
|
||||
}
|
||||
|
||||
function select(id: string, disabled: boolean | undefined) {
|
||||
if (disabled) return;
|
||||
@@ -30,7 +45,11 @@
|
||||
class="tab"
|
||||
class:active={value === t.id}
|
||||
role="tab"
|
||||
id={`${idPrefix}-${t.id}`}
|
||||
aria-selected={value === t.id}
|
||||
aria-controls={panelId}
|
||||
tabindex={value === t.id ? 0 : -1}
|
||||
onkeydown={onKey}
|
||||
disabled={t.disabled}
|
||||
onclick={() => select(t.id, t.disabled)}
|
||||
>
|
||||
@@ -52,7 +71,7 @@
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: color-mix(in oklab, var(--surface) 80%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tab {
|
||||
@@ -68,7 +87,7 @@
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-pill);
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
color var(--dur-fast) var(--ease-standard),
|
||||
background-color var(--dur-fast) var(--ease-standard);
|
||||
@@ -97,7 +116,7 @@
|
||||
background: var(--surface-overlay);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.tab.active .badge {
|
||||
background: var(--accent-primary);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { fieldContext, type FieldContext } from '../../field-context.js';
|
||||
const field = getContext<FieldContext | undefined>(fieldContext);
|
||||
interface Props {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
@@ -10,6 +13,7 @@
|
||||
readonly?: boolean;
|
||||
mono?: boolean;
|
||||
oninput?: (e: Event) => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -22,7 +26,8 @@
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
mono = false,
|
||||
oninput
|
||||
oninput,
|
||||
ariaLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -36,6 +41,10 @@
|
||||
{required}
|
||||
{disabled}
|
||||
{readonly}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : field?.labelId}
|
||||
aria-describedby={field?.messageId}
|
||||
aria-invalid={field?.invalid || undefined}
|
||||
bind:value
|
||||
{oninput}
|
||||
></textarea>
|
||||
|
||||
Reference in New Issue
Block a user