Add live registry search and themed input states

This commit is contained in:
JB
2026-07-25 21:36:05 -04:00
parent c0a8e05e78
commit f0fbdc2853
3 changed files with 54 additions and 4 deletions
+15
View File
@@ -400,6 +400,13 @@ squeezed beside long module headings on mobile.
- Labels are uppercase and letter-spaced.
- Inputs use dark surfaces, one-pixel borders, and clear disabled styling.
- Text selection uses the SHD orange selection token with dark contrasting text;
never rely on the browser's white or platform-blue highlight. Focused text,
number, search, select, and textarea controls use a one-pixel orange border,
while keyboard `focus-visible` adds a restrained one-pixel external orange
ring with a one-pixel offset. Do not stack inset accent edges with this focus
treatment. Carets and native checkbox, radio, and range accents use orange.
Browser-autofilled fields retain the dark SHD surface and light text.
- Related min/max fields remain visually paired.
- Small mutually exclusive option sets use a compact segmented radio control,
even when visually presented like selectable checkbox cells. Include an
@@ -742,6 +749,14 @@ Do not add zero badges or counts to informational registries such as Audit
History and System Metrics. Badge text must expose an `awaiting review` label to
assistive technology; color supplements that meaning rather than replacing it.
Registry name search is server-backed and updates as the user types after a
short debounce; it must search the complete filtered registry rather than only
the visible page. Preserve active filters and row-count settings, reset results
to page one, keep input focus, avoid scrolling, and replace browser history so
each keystroke does not create a navigation entry. Retain an explicit Search
button for immediate submission and keyboard/accessibility fallback. During
navigation, expose a textual pending state and suppress duplicate submissions.
Pending records may be used as dependencies in later proposals by any
contributor. Render these choices in the ordinary assignment control with a
restrained orange border/background and the explicit text `PENDING` plus the
+4
View File
@@ -10,6 +10,9 @@
--shd-text-dim:#5d6567;
--shd-orange:#f6a821;
--shd-orange-hover:#ffc45d;
--shd-focus-ring:#f6a821;
--shd-selection-bg:#f6a821;
--shd-selection-text:#111415;
--shd-success:#75c08c;
--shd-danger:#df8069;
--shd-info:#70b9d6;
@@ -29,6 +32,7 @@
--shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease;
}
*{box-sizing:border-box} html{scroll-behavior:smooth} body{min-height:calc(100vh / var(--shd-display-scale));display:flex;flex-direction:column;margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:Arial,Helvetica,sans-serif} button,input{font:inherit} a,a:hover,a:focus,a:focus-visible,a:active,a:visited{text-decoration:none}
::selection{background:var(--shd-selection-bg);color:var(--shd-selection-text)}input,textarea{caret-color:var(--shd-orange)}input[type=checkbox],input[type=radio],input[type=range]{accent-color:var(--shd-orange)}:where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]):not([type=hidden]),select,textarea):focus{border-color:var(--shd-focus-ring);outline:none}:where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]):not([type=hidden]),select,textarea):focus-visible{outline:1px solid var(--shd-focus-ring);outline-offset:1px}:where(input,textarea):-webkit-autofill,:where(input,textarea):-webkit-autofill:hover,:where(input,textarea):-webkit-autofill:focus{-webkit-text-fill-color:var(--shd-text);caret-color:var(--shd-orange);box-shadow:inset 0 0 0 1000px #15130e;transition:background-color 9999s ease-out}
.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
.site-footer{flex:0 0 auto;margin-top:auto}
.contact-page.contact-page{margin:0 auto}
+35 -4
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { page as currentPage } from '$app/state';
import { goto } from '$app/navigation';
import { navigating, page as currentPage } from '$app/state';
import { onDestroy } from 'svelte';
let {
pagination,
namespace = '',
@@ -14,6 +16,35 @@
const pageKey = $derived(namespace ? `${namespace}Page` : 'page');
const perPageKey = $derived(namespace ? `${namespace}PerPage` : 'perPage');
const searchKey = $derived(namespace ? `${namespace}Q` : 'q');
let draftSearch = $state<string | null>(null);
const searchValue = $derived(draftSearch ?? search ?? '');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const searchPending = $derived(Boolean(navigating.to) && navigating.to?.url.searchParams.get(searchKey) !== currentPage.url.searchParams.get(searchKey));
$effect(() => {
if (draftSearch !== null && draftSearch.trim() === (search ?? '').trim()) draftSearch = null;
});
const searchHref = (query: string) => {
const params = new URLSearchParams(currentPage.url.searchParams);
const normalized = query.trim();
if (normalized) params.set(searchKey, normalized);
else params.delete(searchKey);
params.delete(pageKey);
const serialized = params.toString();
return `${currentPage.url.pathname}${serialized ? `?${serialized}` : ''}`;
};
const submitSearch = (query = searchValue) => {
if (searchTimer) clearTimeout(searchTimer);
if (query.trim() === (search ?? '').trim()) return;
void goto(searchHref(query), { replaceState: true, keepFocus: true, noScroll: true });
};
const scheduleSearch = (query: string) => {
draftSearch = query;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => submitSearch(query), 350);
};
onDestroy(() => {
if (searchTimer) clearTimeout(searchTimer);
});
const href = (targetPage: number, perPage = pagination.perPage) => {
const params = new URLSearchParams(currentPage.url.searchParams);
params.set(pageKey, String(targetPage));
@@ -38,10 +69,10 @@
<nav class="registry-pagination" aria-label="Registry pagination">
{#if search !== undefined}
<form class="pagination-search">
<form class="pagination-search" role="search" onsubmit={(event) => { event.preventDefault(); submitSearch(); }}>
{#each retainedParams as [key, value]}<input type="hidden" name={key} {value}/>{/each}
<input type="search" name={searchKey} value={search} maxlength="80" placeholder={searchPlaceholder} aria-label={searchPlaceholder}/>
<button class="secondary">SEARCH</button>
<input type="search" name={searchKey} value={searchValue} maxlength="80" placeholder={searchPlaceholder} aria-label={`${searchPlaceholder} — results update as you type`} oninput={(event) => scheduleSearch(event.currentTarget.value)}/>
<button class="secondary" aria-busy={searchPending} disabled={searchPending}>{searchPending ? 'SEARCHING…' : 'SEARCH'}</button>
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
</form>
{/if}