Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
153 lines
5.0 KiB
Svelte
153 lines
5.0 KiB
Svelte
<script lang="ts">
|
|
import { tick } from 'svelte';
|
|
interface Tab {
|
|
id: string;
|
|
label: string;
|
|
badge?: string | number | null;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
tabs: Tab[];
|
|
value: string;
|
|
onchange?: (id: string) => void;
|
|
ariaLabel?: string;
|
|
size?: 'sm' | 'md';
|
|
panelId?: string;
|
|
idPrefix?: string;
|
|
}
|
|
|
|
const uid = $props.id();
|
|
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
|
|
let tablist = $state<HTMLDivElement | null>(null);
|
|
|
|
// Keep a deep-linked or keyboard-selected tab visible without scrolling the page.
|
|
$effect(() => {
|
|
const selected = value;
|
|
const node = tablist;
|
|
if (!node) return;
|
|
let cancelled = false;
|
|
let observer: ResizeObserver | undefined;
|
|
void tick().then(() => {
|
|
if (cancelled) return;
|
|
const tab = document.getElementById(`${idPrefix}-${selected}`);
|
|
if (!tab) return;
|
|
const reveal = () => {
|
|
const bounds = node.getBoundingClientRect();
|
|
const target = tab.getBoundingClientRect();
|
|
if (target.left < bounds.left + 4) node.scrollLeft -= bounds.left + 4 - target.left;
|
|
else if (target.right > bounds.right - 4) node.scrollLeft += target.right - bounds.right + 4;
|
|
};
|
|
reveal();
|
|
observer = new ResizeObserver(reveal);
|
|
observer.observe(node);
|
|
observer.observe(tab);
|
|
});
|
|
return () => { cancelled = true; observer?.disconnect(); };
|
|
});
|
|
|
|
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;
|
|
value = id;
|
|
onchange?.(id);
|
|
}
|
|
</script>
|
|
|
|
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel} bind:this={tablist}>
|
|
{#each tabs as t (t.id)}
|
|
<button
|
|
type="button"
|
|
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)}
|
|
>
|
|
<span class="lbl">{t.label}</span>
|
|
{#if t.badge !== undefined && t.badge !== null && t.badge !== ''}
|
|
<span class="badge">{t.badge}</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<style>
|
|
.tabs {
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
overflow-x: auto;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 4px;
|
|
background: color-mix(in oklab, var(--surface) 80%, transparent);
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid var(--border-subtle);
|
|
}
|
|
.tab {
|
|
flex-shrink: 0;
|
|
white-space: nowrap;
|
|
appearance: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-tertiary);
|
|
font-family: var(--font-body);
|
|
font-weight: var(--weight-medium);
|
|
cursor: pointer;
|
|
border-radius: var(--radius-sm);
|
|
transition:
|
|
color var(--dur-fast) var(--ease-standard),
|
|
background-color var(--dur-fast) var(--ease-standard);
|
|
}
|
|
.s-sm .tab { padding: 6px 12px; font-size: var(--text-xs); }
|
|
.s-md .tab { padding: 8px 16px; font-size: var(--text-sm); }
|
|
|
|
.tab:hover:not(:disabled):not(.active) { color: var(--text-primary); }
|
|
.tab:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
|
.tab:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
|
.tab.active {
|
|
background: var(--surface-raised);
|
|
color: var(--text-primary);
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 18px;
|
|
height: 18px;
|
|
padding: 0 5px;
|
|
border-radius: var(--radius-pill);
|
|
background: var(--surface-overlay);
|
|
color: var(--text-secondary);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.tab.active .badge {
|
|
background: var(--accent-primary);
|
|
color: var(--text-on-accent);
|
|
}
|
|
</style>
|