Add live registry search and themed input states
This commit is contained in:
@@ -400,6 +400,13 @@ squeezed beside long module headings on mobile.
|
|||||||
|
|
||||||
- Labels are uppercase and letter-spaced.
|
- Labels are uppercase and letter-spaced.
|
||||||
- Inputs use dark surfaces, one-pixel borders, and clear disabled styling.
|
- 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.
|
- Related min/max fields remain visually paired.
|
||||||
- Small mutually exclusive option sets use a compact segmented radio control,
|
- Small mutually exclusive option sets use a compact segmented radio control,
|
||||||
even when visually presented like selectable checkbox cells. Include an
|
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
|
History and System Metrics. Badge text must expose an `awaiting review` label to
|
||||||
assistive technology; color supplements that meaning rather than replacing it.
|
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
|
Pending records may be used as dependencies in later proposals by any
|
||||||
contributor. Render these choices in the ordinary assignment control with a
|
contributor. Render these choices in the ordinary assignment control with a
|
||||||
restrained orange border/background and the explicit text `PENDING` plus the
|
restrained orange border/background and the explicit text `PENDING` plus the
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
--shd-text-dim:#5d6567;
|
--shd-text-dim:#5d6567;
|
||||||
--shd-orange:#f6a821;
|
--shd-orange:#f6a821;
|
||||||
--shd-orange-hover:#ffc45d;
|
--shd-orange-hover:#ffc45d;
|
||||||
|
--shd-focus-ring:#f6a821;
|
||||||
|
--shd-selection-bg:#f6a821;
|
||||||
|
--shd-selection-text:#111415;
|
||||||
--shd-success:#75c08c;
|
--shd-success:#75c08c;
|
||||||
--shd-danger:#df8069;
|
--shd-danger:#df8069;
|
||||||
--shd-info:#70b9d6;
|
--shd-info:#70b9d6;
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
--shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease;
|
--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}
|
*{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}
|
.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}
|
.site-footer{flex:0 0 auto;margin-top:auto}
|
||||||
.contact-page.contact-page{margin:0 auto}
|
.contact-page.contact-page{margin:0 auto}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<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 {
|
let {
|
||||||
pagination,
|
pagination,
|
||||||
namespace = '',
|
namespace = '',
|
||||||
@@ -14,6 +16,35 @@
|
|||||||
const pageKey = $derived(namespace ? `${namespace}Page` : 'page');
|
const pageKey = $derived(namespace ? `${namespace}Page` : 'page');
|
||||||
const perPageKey = $derived(namespace ? `${namespace}PerPage` : 'perPage');
|
const perPageKey = $derived(namespace ? `${namespace}PerPage` : 'perPage');
|
||||||
const searchKey = $derived(namespace ? `${namespace}Q` : 'q');
|
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 href = (targetPage: number, perPage = pagination.perPage) => {
|
||||||
const params = new URLSearchParams(currentPage.url.searchParams);
|
const params = new URLSearchParams(currentPage.url.searchParams);
|
||||||
params.set(pageKey, String(targetPage));
|
params.set(pageKey, String(targetPage));
|
||||||
@@ -38,10 +69,10 @@
|
|||||||
|
|
||||||
<nav class="registry-pagination" aria-label="Registry pagination">
|
<nav class="registry-pagination" aria-label="Registry pagination">
|
||||||
{#if search !== undefined}
|
{#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}
|
{#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}/>
|
<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">SEARCH</button>
|
<button class="secondary" aria-busy={searchPending} disabled={searchPending}>{searchPending ? 'SEARCHING…' : 'SEARCH'}</button>
|
||||||
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
|
{#if search}<a href={clearSearchHref}>CLEAR</a>{/if}
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user