feat(search-field): floating results popover in demo + hide native clear

- Demo `/uix/components/search-field` wraps the SearchField in
  `<Popover.Anchor>`; results render in `<Popover.Content>` (intrinsic
  width, align="start") wrapped in `<PickerShell.Body>` so the panel
  inherits the canonical picker chrome. Items are real `<button>`s with
  `onclick` — committing a selection sets `value`, closes the popover,
  and restores focus to the input.
- Forward `bind:ref` through the eidos `<SearchField.Input>` wrapper so
  consumers can refocus the input from the demo's pick handler.
- Hide the browser's native `::-webkit-search-cancel-button` /
  `::-webkit-search-decoration` / `::-ms-clear` so only the canonical
  `<SearchField.ClearTrigger>` X is visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent afb09c2254
commit 82349f15b4

@ -2,9 +2,9 @@
import * as SearchField from '$soma/components/search-field'; import * as SearchField from '$soma/components/search-field';
import type { SearchFieldInputProps } from './types'; import type { SearchFieldInputProps } from './types';
let { children, ...rest }: SearchFieldInputProps = $props(); let { ref = $bindable(null), children, ...rest }: SearchFieldInputProps = $props();
</script> </script>
<SearchField.Input {...rest}> <SearchField.Input {...rest} bind:ref>
{@render children?.()} {@render children?.()}
</SearchField.Input> </SearchField.Input>

@ -140,6 +140,21 @@
outline: none; outline: none;
} }
/* Hide the browser's native clear button for `<input type="search">` —
* the canonical clear affordance is `<SearchField.ClearTrigger>`. Covers
* WebKit/Blink (cancel + decoration) and Edge legacy (ms-clear). */
[data-search-field-input]::-webkit-search-cancel-button,
[data-search-field-input]::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
display: none;
}
[data-search-field-input]::-ms-clear {
display: none;
inline-size: 0;
block-size: 0;
}
[data-search-field-input]::placeholder { [data-search-field-input]::placeholder {
color: var(--search-field-placeholder-color); color: var(--search-field-placeholder-color);
} }

@ -5,6 +5,8 @@
type SearchFieldSize, type SearchFieldSize,
type SearchFieldVariant type SearchFieldVariant
} from '$uix/eidos/components/search-field'; } from '$uix/eidos/components/search-field';
import { Popover } from '$uix/eidos/components/popover';
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { Search, X } from '$uix/eidos/components/icon'; import { Search, X } from '$uix/eidos/components/icon';
import { compileMorfo } from '$uix/morfo'; import { compileMorfo } from '$uix/morfo';
import { searchFieldMorfo } from '@/uix/morfo/components/search-field'; import { searchFieldMorfo } from '@/uix/morfo/components/search-field';
@ -33,6 +35,8 @@
let lastValueChange = $state('—'); let lastValueChange = $state('—');
let trace = $state<TraceEntry[]>([]); let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null); let stageRef = $state<HTMLElement | null>(null);
let resultsOpen = $state(false);
let inputRef = $state<HTMLInputElement | null>(null);
const compiled = compileMorfo(searchFieldMorfo); const compiled = compileMorfo(searchFieldMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]); const partsList = $derived([...compiled.parts.byKebab.values()]);
@ -186,62 +190,97 @@
<div data-uix-stage> <div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}> <div data-uix-stage-area bind:this={stageRef}>
<div style="inline-size: min(26rem, 100%);"> <div style="inline-size: min(26rem, 100%);">
<SearchField <Popover bind:open={resultsOpen}>
bind:value <Popover.Anchor
{placeholder} onfocusin={() => {
{clearOnEscape} if (!disabled && !readonly) resultsOpen = true;
{disabled} }}
{readonly}
{required}
{invalid}
{loading}
{debounceMs}
{size}
{variant}
{color}
onValueChange={(next) => {
lastValueChange = next === '' ? 'empty' : next;
lastValueChangeAt = Date.now();
}}
onSubmit={(next) => (lastSubmit = next || 'empty')}
onClear={() => (lastSubmit = 'cleared')}
>
<SearchField.Icon>
<Search size="1em" decorative />
</SearchField.Icon>
<SearchField.Input />
<SearchField.LoadingIndicator />
<SearchField.ClearTrigger
aria-label={uix.langs.ts('#?components.search-field.clear|Clear')}
> >
<X size="1em" decorative /> <SearchField
</SearchField.ClearTrigger> bind:value
</SearchField> {placeholder}
{clearOnEscape}
<!-- Live result panel — filters the demo dataset by name/group. --> {disabled}
<ul {readonly}
class="search-field-results" {required}
role="listbox" {invalid}
aria-label="Filtered components" {loading}
aria-live="polite" {debounceMs}
> {size}
{#if results.length === 0} {variant}
<li class="search-field-results-empty"> {color}
No matches for <strong>"{value}"</strong> onValueChange={(next) => {
</li> lastValueChange = next === '' ? 'empty' : next;
{:else} lastValueChangeAt = Date.now();
{#each results as item (item.name)} if (!disabled && !readonly) resultsOpen = true;
<li class="search-field-result" role="option" aria-selected="false"> }}
<span class="search-field-result-name">{item.name}</span> onSubmit={(next) => {
<span class="search-field-result-group">{item.group}</span> lastSubmit = next || 'empty';
</li> resultsOpen = false;
{/each} }}
{/if} onClear={() => {
</ul> lastSubmit = 'cleared';
<div class="search-field-results-meta"> resultsOpen = false;
<span data-uix-stage-trace-key>results</span> }}
{results.length} / {components.length} >
</div> <SearchField.Icon>
<Search size="1em" decorative />
</SearchField.Icon>
<SearchField.Input bind:ref={inputRef} />
<SearchField.LoadingIndicator />
<SearchField.ClearTrigger
aria-label={uix.langs.ts('#?components.search-field.clear|Clear')}
>
<X size="1em" decorative />
</SearchField.ClearTrigger>
</SearchField>
</Popover.Anchor>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<PickerShell.Body>
<!-- Live result list — filters the demo dataset by name/group. -->
<ul
class="search-field-results"
role="listbox"
aria-label="Filtered components"
aria-live="polite"
>
{#if results.length === 0}
<li class="search-field-results-empty">
No matches for <strong>"{value}"</strong>
</li>
{:else}
{#each results as item (item.name)}
<li role="option" aria-selected={value === item.name}>
<button
type="button"
class="search-field-result"
onclick={() => {
value = item.name;
resultsOpen = false;
inputRef?.focus();
}}
>
<span class="search-field-result-name">{item.name}</span>
<span class="search-field-result-group">{item.group}</span>
</button>
</li>
{/each}
{/if}
</ul>
<div class="search-field-results-meta">
<span data-uix-stage-trace-key>results</span>
{results.length} / {components.length}
</div>
</PickerShell.Body>
</Popover.Content>
</Popover.Portal>
</Popover>
</div> </div>
</div> </div>
<div data-uix-stage-trace> <div data-uix-stage-trace>
@ -636,48 +675,60 @@
<style> <style>
.search-field-results { .search-field-results {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); flex-direction: column;
gap: var(--uix-space-1); gap: var(--space-1);
margin-block-start: var(--uix-space-3);
padding: 0; padding: 0;
margin: 0;
list-style: none; list-style: none;
max-block-size: 14rem; min-inline-size: 14rem;
max-block-size: 16rem;
overflow-y: auto; overflow-y: auto;
} }
.search-field-result { .search-field-result {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: var(--uix-space-2); gap: var(--space-3);
padding: var(--uix-space-1-5) var(--uix-space-2); inline-size: 100%;
border: 1px solid var(--color-border-subtle); padding: var(--space-1-5) var(--space-2);
border: var(--border-width) solid transparent;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-surface-default); background: transparent;
font-size: var(--uix-text-sm); color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-default);
}
.search-field-result:hover {
background: var(--color-surface-overlay);
}
.search-field-result:focus-visible {
outline: var(--focus-ring-width) solid var(--color-primary-border);
outline-offset: var(--focus-ring-offset);
} }
.search-field-result-name { .search-field-result-name {
color: var(--color-content-primary); color: var(--color-content-primary);
font-weight: 500; font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm);
} }
.search-field-result-group { .search-field-result-group {
color: var(--color-content-muted); color: var(--color-content-muted);
font-size: var(--uix-text-xs); font-size: var(--font-size-xs);
} }
.search-field-results-empty { .search-field-results-empty {
grid-column: 1 / -1; padding: var(--space-3);
padding: var(--uix-space-3);
color: var(--color-content-muted); color: var(--color-content-muted);
text-align: center; text-align: center;
font-size: var(--uix-text-sm); font-size: var(--font-size-sm);
} }
.search-field-results-meta { .search-field-results-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--uix-space-2); gap: var(--space-2);
margin-block-start: var(--uix-space-2); color: var(--color-content-muted);
color: var(--uix-text-muted); font-family: var(--font-family-mono);
font-family: var(--uix-font-mono); font-size: var(--font-size-xs);
font-size: var(--uix-text-xs);
} }
</style> </style>

Loading…
Cancel
Save

Powered by TurnKey Linux.