Improve operational UX, recovery flows, and action safety
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.
This commit is contained in:
@@ -153,7 +153,7 @@
|
||||
|
||||
/* Loading */
|
||||
.is-loading .label,
|
||||
.is-loading .slot { visibility: hidden; }
|
||||
.is-loading .slot { opacity: 0; }
|
||||
.spinner {
|
||||
position: absolute;
|
||||
width: 14px; height: 14px;
|
||||
|
||||
@@ -18,14 +18,28 @@
|
||||
get messageId() { return error || hint ? `${id}-message` : undefined; },
|
||||
get invalid() { return !!error; }
|
||||
});
|
||||
|
||||
function connectLabel(node: HTMLDivElement) {
|
||||
const connect = () => {
|
||||
const label = node.querySelector<HTMLLabelElement>('label.label');
|
||||
const control = node.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('input:not([type="hidden"]), select, textarea');
|
||||
if (!label || !control || forId) return;
|
||||
if (!control.id) control.id = `${id}-control`;
|
||||
label.htmlFor = control.id;
|
||||
};
|
||||
connect();
|
||||
const observer = new MutationObserver(connect);
|
||||
observer.observe(node, { childList: true, subtree: true });
|
||||
return { destroy: () => observer.disconnect() };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<div class="field" use:connectLabel>
|
||||
{#if label}
|
||||
<svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
|
||||
<label class="label" id={`${id}-label`} for={forId ?? `${id}-control`}>
|
||||
{label}
|
||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||
</svelte:element>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="control" class:has-error={!!error}>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
@@ -77,7 +77,7 @@
|
||||
.input.mono { font-family: var(--font-mono); }
|
||||
.input::placeholder { color: var(--text-quaternary); }
|
||||
|
||||
.input:hover { border-color: var(--border-strong); }
|
||||
.input:hover { border-color: var(--border-control-hover); }
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
|
||||
@@ -1,8 +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();
|
||||
let { total, limit = $bindable(25), step = 25, label = 'items', onchange }: { total: number; limit?: number; step?: number; label?: string; onchange?: (limit: number) => void } = $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>
|
||||
<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; onchange?.(limit); }}>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>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
appearance: none;
|
||||
padding: 9px 32px 9px 12px;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
@@ -81,7 +81,7 @@
|
||||
background-color var(--dur-fast) var(--ease-standard),
|
||||
box-shadow var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
.select:hover { border-color: var(--border-strong); }
|
||||
.select:hover { border-color: var(--border-control-hover); }
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -18,6 +19,32 @@
|
||||
|
||||
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;
|
||||
@@ -38,7 +65,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel}>
|
||||
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel} bind:this={tablist}>
|
||||
{#each tabs as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-3);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
.textarea.mono { font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); }
|
||||
.textarea::placeholder { color: var(--text-quaternary); }
|
||||
.textarea:hover { border-color: var(--border-strong); }
|
||||
.textarea:hover { border-color: var(--border-control-hover); }
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
|
||||
Reference in New Issue
Block a user