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 type { SearchFieldInputProps } from './types';
let { children, ...rest }: SearchFieldInputProps = $props();
let { ref = $bindable(null), children, ...rest }: SearchFieldInputProps = $props();
</script>
<SearchField.Input {...rest}>
<SearchField.Input {...rest} bind:ref>
{@render children?.()}
</SearchField.Input>

@ -140,6 +140,21 @@
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 {
color: var(--search-field-placeholder-color);
}

@ -5,6 +5,8 @@
type SearchFieldSize,
type SearchFieldVariant
} 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 { compileMorfo } from '$uix/morfo';
import { searchFieldMorfo } from '@/uix/morfo/components/search-field';
@ -33,6 +35,8 @@
let lastValueChange = $state('—');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let resultsOpen = $state(false);
let inputRef = $state<HTMLInputElement | null>(null);
const compiled = compileMorfo(searchFieldMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
@ -186,6 +190,12 @@
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div style="inline-size: min(26rem, 100%);">
<Popover bind:open={resultsOpen}>
<Popover.Anchor
onfocusin={() => {
if (!disabled && !readonly) resultsOpen = true;
}}
>
<SearchField
bind:value
{placeholder}
@ -202,14 +212,21 @@
onValueChange={(next) => {
lastValueChange = next === '' ? 'empty' : next;
lastValueChangeAt = Date.now();
if (!disabled && !readonly) resultsOpen = true;
}}
onSubmit={(next) => {
lastSubmit = next || 'empty';
resultsOpen = false;
}}
onClear={() => {
lastSubmit = 'cleared';
resultsOpen = false;
}}
onSubmit={(next) => (lastSubmit = next || 'empty')}
onClear={() => (lastSubmit = 'cleared')}
>
<SearchField.Icon>
<Search size="1em" decorative />
</SearchField.Icon>
<SearchField.Input />
<SearchField.Input bind:ref={inputRef} />
<SearchField.LoadingIndicator />
<SearchField.ClearTrigger
aria-label={uix.langs.ts('#?components.search-field.clear|Clear')}
@ -217,8 +234,16 @@
<X size="1em" decorative />
</SearchField.ClearTrigger>
</SearchField>
<!-- Live result panel — filters the demo dataset by name/group. -->
</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"
@ -231,9 +256,19 @@
</li>
{:else}
{#each results as item (item.name)}
<li class="search-field-result" role="option" aria-selected="false">
<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}
@ -242,6 +277,10 @@
<span data-uix-stage-trace-key>results</span>
{results.length} / {components.length}
</div>
</PickerShell.Body>
</Popover.Content>
</Popover.Portal>
</Popover>
</div>
</div>
<div data-uix-stage-trace>
@ -636,48 +675,60 @@
<style>
.search-field-results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: var(--uix-space-1);
margin-block-start: var(--uix-space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: 0;
margin: 0;
list-style: none;
max-block-size: 14rem;
min-inline-size: 14rem;
max-block-size: 16rem;
overflow-y: auto;
}
.search-field-result {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--uix-space-2);
padding: var(--uix-space-1-5) var(--uix-space-2);
border: 1px solid var(--color-border-subtle);
gap: var(--space-3);
inline-size: 100%;
padding: var(--space-1-5) var(--space-2);
border: var(--border-width) solid transparent;
border-radius: var(--radius-md);
background: var(--color-surface-default);
font-size: var(--uix-text-sm);
background: transparent;
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 {
color: var(--color-content-primary);
font-weight: 500;
font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm);
}
.search-field-result-group {
color: var(--color-content-muted);
font-size: var(--uix-text-xs);
font-size: var(--font-size-xs);
}
.search-field-results-empty {
grid-column: 1 / -1;
padding: var(--uix-space-3);
padding: var(--space-3);
color: var(--color-content-muted);
text-align: center;
font-size: var(--uix-text-sm);
font-size: var(--font-size-sm);
}
.search-field-results-meta {
display: flex;
align-items: center;
gap: var(--uix-space-2);
margin-block-start: var(--uix-space-2);
color: var(--uix-text-muted);
font-family: var(--uix-font-mono);
font-size: var(--uix-text-xs);
gap: var(--space-2);
color: var(--color-content-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
}
</style>

Loading…
Cancel
Save

Powered by TurnKey Linux.