Forms
Search
Búsqueda con sugerencias que se filtran y resaltan al escribir
Preview
Última selección (onSelect): —
size
maxVisible
placeholder
disabled
Code
import css from "./Search.module.css";
import { useEffect, useId, useRef, useState } from "react";
import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/csr/MagnifyingGlass";
import { XIcon } from "@phosphor-icons/react/dist/csr/X";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface Option {
name: string;
value: string | number;
}
interface Match {
option: Option;
range: [number, number] | null;
}
interface SearchProps {
size: Size | ResponsiveSize;
// Named after the attribute it becomes: the magnifier and the placeholder are hints, not a name.
"aria-label": string;
placeholder?: string;
name?: string;
maxVisible: number;
options: Option[];
clearAriaLabel: string;
// Optional: without it an empty result simply keeps the popover closed.
emptyText?: string;
disabled?: boolean;
value: string;
onChange: (value: string) => void;
onSelect: (value: string | number) => void;
}
// MUST match the CSS of .search_options: row, gap, padding, border, half a row of peek, and the gap
// to the field.
const ROW = 40;
const GAP = 8;
const PADDING = 8;
const BORDER = 2;
const PEEK = ROW / 2;
const OFFSET = 8;
// Accent- and case-insensitive, so "cafe" still finds "Café".
const fold = (text: string) =>
text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
// Folds per character and keeps the index map, so the highlight lands on the ORIGINAL text.
function matchRange(name: string, query: string): [number, number] | null {
const origin: number[] = [];
let folded = "";
for (let i = 0; i < name.length; i++) {
for (const char of fold(name[i])) {
folded += char;
origin.push(i);
}
}
const start = folded.indexOf(query);
if (start < 0) return null;
return [origin[start], origin[start + query.length - 1] + 1];
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Search({
size,
"aria-label": ariaLabel,
placeholder,
name,
maxVisible,
options,
clearAriaLabel,
emptyText,
disabled,
value,
onChange,
onSelect,
}: SearchProps) {
const field = useRef<HTMLInputElement>(null);
const search = useRef<HTMLDivElement>(null);
const optionsBox = useRef<HTMLDivElement>(null);
const id = useId();
// Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
// identify.
const fieldId = `${id}-field`;
const listboxId = `${id}-listbox`;
const optionId = (i: number) => `${id}-option-${i}`;
const [open, setOpen] = useState(false);
const [openUp, setOpenUp] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const [tabbed, setTabbed] = useState(false);
const pointer = useRef(false);
const touch = useRef(false);
const findMatches = (text: string): Match[] => {
const query = fold(text.trim());
if (!query) return options.map((option) => ({ option, range: null }));
return options.flatMap((option) => {
const range = matchRange(option.name, query);
return range ? [{ option, range }] : [];
});
};
const matches = findMatches(value);
// No empty message means nothing to show, and an open flag over an empty list would still
// announce expanded.
const expanded = open && (matches.length > 0 || !!emptyText);
const openList = (count: number) => {
if (search.current) {
const spaceBelow =
window.innerHeight - search.current.getBoundingClientRect().bottom;
// The list as the CSS draws it: natural height up to the cap.
const optionsHeight = Math.min(
count * (ROW + GAP) - GAP + PADDING * 2 + BORDER * 2,
PADDING + maxVisible * (ROW + GAP) + PEEK + BORDER * 2,
);
setOpenUp(spaceBelow < OFFSET + optionsHeight);
}
setOpen(true);
};
const closeList = () => {
setOpen(false);
setFocusedIndex(-1);
};
const select = (option: Option) => {
if (disabled) return;
onChange(option.name);
onSelect(option.value);
closeList();
if (touch.current) {
field.current?.blur();
} else {
field.current?.focus();
}
};
const clear = () => {
onChange("");
closeList();
field.current?.focus();
};
const onFocus = () => setTabbed(!pointer.current);
// Focus moving to the clear button or an option is not leaving the field.
const onBlur = (ev: React.FocusEvent) => {
if (search.current?.contains(ev.relatedTarget)) return;
setTabbed(false);
pointer.current = false;
};
const onPointerDown = (ev: React.PointerEvent) => {
pointer.current = true;
touch.current = ev.pointerType === "touch";
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const next = ev.target.value;
const count = findMatches(next).length;
onChange(next);
setFocusedIndex(-1);
if (next.trim() && (count || emptyText)) {
openList(count);
} else {
closeList();
}
};
const onKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
touch.current = false;
if (disabled) return;
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
ev.preventDefault();
if (!matches.length) return;
const last = matches.length - 1;
if (!expanded) {
openList(matches.length);
setFocusedIndex(ev.key === "ArrowDown" ? 0 : last);
} else if (ev.key === "ArrowDown") {
setFocusedIndex((i) => (i >= last ? 0 : i + 1));
} else {
setFocusedIndex((i) => (i <= 0 ? last : i - 1));
}
} else if (ev.key === "Enter") {
if (expanded && focusedIndex >= 0) {
ev.preventDefault();
select(matches[focusedIndex].option);
}
} else if (ev.key === "Escape") {
if (expanded) {
closeList();
} else if (value) {
clear();
}
}
};
useEffect(() => {
if (!expanded) return;
const onOutside = (ev: Event) => {
if (search.current && !search.current.contains(ev.target as Node)) closeList();
};
document.addEventListener("click", onOutside);
document.addEventListener("focusin", onOutside);
return () => {
document.removeEventListener("click", onOutside);
document.removeEventListener("focusin", onOutside);
};
}, [expanded]);
useEffect(() => {
if (!expanded || focusedIndex < 0 || !optionsBox.current) return;
const option = optionsBox.current.children[focusedIndex] as HTMLElement;
option?.scrollIntoView({ block: "nearest" });
}, [focusedIndex, expanded]);
return (
<div
ref={search}
onPointerDown={onPointerDown}
className={`
${css.search}
${getSizeClasses(size)}
${expanded ? css.open : ""}
${openUp ? css.open_up : ""}
${disabled ? css.disabled : ""}
`}
>
<div
className={`${css.search_box} ${tabbed ? css.tabbed : ""}`}
>
<MagnifyingGlassIcon className={css.box_icon} aria-hidden="true" />
<input
ref={field}
id={fieldId}
className={css.box_field}
type="text"
name={name}
autoComplete="off"
role="combobox"
aria-label={ariaLabel}
aria-autocomplete="list"
aria-expanded={expanded}
aria-controls={listboxId}
aria-activedescendant={
expanded && focusedIndex >= 0 ? optionId(focusedIndex) : undefined
}
placeholder={placeholder}
disabled={disabled}
value={value}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
onChange={handleChange}
/>
{value && (
<button
type="button"
className={css.box_clear}
aria-label={clearAriaLabel}
onClick={clear}
>
<XIcon weight="bold" />
</button>
)}
</div>
{/* A scroller without focusable children is a Tab stop in Chrome; with aria-activedescendant
the focus never enters the list. */}
<div
ref={optionsBox}
id={listboxId}
className={css.search_options}
style={{ "--max-visible": maxVisible } as React.CSSProperties}
role={matches.length ? "listbox" : "status"}
tabIndex={-1}
aria-label={matches.length ? ariaLabel : undefined}
>
{matches.length === 0 && <p className={css.options_empty}>{emptyText}</p>}
{matches.map(({ option, range }, index) => (
<button
key={option.value}
id={optionId(index)}
className={index === focusedIndex ? css.focused : ""}
type="button"
tabIndex={-1}
role="option"
aria-selected={index === focusedIndex}
onClick={() => select(option)}
>
{range ? (
<>
{option.name.slice(0, range[0])}
<mark className={css.option_match}>
{option.name.slice(range[0], range[1])}
</mark>
{option.name.slice(range[1])}
</>
) : (
option.name
)}
</button>
))}
</div>
</div>
);
}
.search {
width: 100%;
position: relative;
-webkit-tap-highlight-color: transparent;
/* The field ladder: the height steps every tier, the font plateaus at --text-16.
The scale lives on the root: the popover is a sibling of the box and inherits the font from
here. */
&.xs {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg {
--search-height: var(--size-48);
font-size: var(--text-16);
}
&.xs_base {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_base {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_base {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_base {
--search-height: var(--size-48);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_mobile {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_mobile {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_mobile {
--search-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_tablet {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_tablet {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_tablet {
--search-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_desktop {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_desktop {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_desktop {
--search-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_wide {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_wide {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_wide {
--search-height: var(--size-48);
font-size: var(--text-16);
}
}
.search_box {
position: relative;
display: flex;
align-items: center;
height: var(--search-height);
gap: var(--space-8);
padding: 0 var(--space-12);
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-pill);
color: var(--color-text);
.box_icon {
flex-shrink: 0;
font-size: 20px;
}
.box_field {
flex: 1;
min-width: 0;
height: 100%;
/* Reserved whether the clear button is drawn or not, so the text never
jumps when it appears. */
padding: 0 var(--space-32) 0 0;
background: none;
border: 0;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: inherit;
text-overflow: ellipsis;
/* iOS zooms in on a focused field under 16px; a phone's short side is under 600, a tablet's
744+. */
@media (pointer: coarse) and (max-width: 599px),
(pointer: coarse) and (max-height: 599px) {
font-size: max(1em, 16px);
}
/* Chrome's default placeholder fails contrast in light; Firefox fades it
with opacity, hence opacity: 1. */
&::placeholder {
color: var(--color-text-secondary);
opacity: 1;
}
/* Beats the global ring at (0,2,0); the box carries the focus signal. */
&:focus-visible {
outline: none;
}
}
.box_clear {
position: absolute;
top: 50%;
right: var(--space-8);
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-28);
height: var(--size-28);
background: none;
border: none;
border-radius: var(--radius-full);
cursor: pointer;
font-size: var(--text-16);
color: inherit;
transition: background 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
}
&:active {
background: color-mix(in srgb, var(--color-text) 15%, transparent);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
svg {
width: 1em;
height: 1em;
}
}
}
/* focus-within, not the input alone: the border would blink while focus sits
on the clear button or an option. */
&:focus-within .search_box {
border-color: var(--color-contour-accent);
/* On text fields :focus-visible also fires on click, so the Tab
signal needs its own class. An inset shadow, not border-width: a
thicker border would push the icon and the clear button. */
&.tabbed {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
}
.search_options {
/* maxVisible rows and half of the next; a max-height, so the half only shows when there is
more. */
max-height: calc(
var(--space-8) + var(--max-visible) * (var(--size-40) + var(--space-8)) +
var(--size-40) / 2 + var(--border-interactive) * 2
);
display: none;
position: absolute;
top: calc(100% + var(--space-8));
width: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 25%, transparent)
transparent;
background: var(--color-surface-opaque);
border: var(--border-interactive) solid var(--color-surface-opaque);
border-radius: var(--radius-8);
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 30%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 30%, transparent)
);
z-index: 1;
padding: var(--space-8);
scroll-padding: var(--space-8);
button {
width: 100%;
/* Flex items in a capped column shrink to their content, and the 40px row
the max-height assumes is gone. */
flex-shrink: 0;
padding: var(--space-8) var(--space-12);
background: none;
border: none;
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
height: var(--size-40);
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
border-radius: var(--radius-8);
@media (hover: hover) {
&:hover {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
}
&:active {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 15%
);
}
&.focused {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
.option_match {
background: none;
color: var(--color-accent);
font-weight: var(--weight-600);
}
}
}
.options_empty {
display: flex;
flex-shrink: 0;
align-items: center;
height: var(--size-40);
padding: var(--space-8) var(--space-12);
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
}
&.open .search_options {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
&.open_up .search_options {
top: auto;
bottom: calc(100% + var(--space-8));
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
<script setup lang="ts">
import {
computed,
onWatcherCleanup,
ref,
useId,
useTemplateRef,
watch,
} from "vue";
import { PhMagnifyingGlass, PhX } from "@phosphor-icons/vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface Option {
name: string;
value: string | number;
}
interface Match {
option: Option;
range: [number, number] | null;
}
interface SearchProps {
size: Size | ResponsiveSize;
// Named after the attribute it becomes: the magnifier and the placeholder are hints, not a name.
ariaLabel: string;
placeholder?: string;
name?: string;
maxVisible: number;
options: Option[];
clearAriaLabel: string;
// Optional: without it an empty result simply keeps the popover closed.
emptyText?: string;
disabled?: boolean;
onSelect: (value: string | number) => void;
}
// MUST match the CSS of .search_options: row, gap, padding, border, half a row of peek, and the gap
// to the field.
const ROW = 40;
const GAP = 8;
const PADDING = 8;
const BORDER = 2;
const PEEK = ROW / 2;
const OFFSET = 8;
// Accent- and case-insensitive, so "cafe" still finds "Café".
const fold = (text: string) =>
text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
// Folds per character and keeps the index map, so the highlight lands on the ORIGINAL text.
function matchRange(name: string, query: string): [number, number] | null {
const origin: number[] = [];
let folded = "";
for (let i = 0; i < name.length; i++) {
for (const char of fold(name[i])) {
folded += char;
origin.push(i);
}
}
const start = folded.indexOf(query);
if (start < 0) return null;
return [origin[start], origin[start + query.length - 1] + 1];
}
const { size, maxVisible, options, emptyText, disabled, onSelect } =
defineProps<SearchProps>();
const model = defineModel<string>({ required: true });
const field = useTemplateRef<HTMLInputElement>("field");
const search = useTemplateRef<HTMLDivElement>("search");
const optionsBox = useTemplateRef<HTMLDivElement>("optionsBox");
const id = useId();
// Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
// identify.
const fieldId = `${id}-field`;
const listboxId = `${id}-listbox`;
const optionId = (i: number) => `${id}-option-${i}`;
const open = ref(false);
const openUp = ref(false);
const focusedIndex = ref(-1);
const tabbed = ref(false);
let pointer = false;
let touch = false;
const findMatches = (text: string): Match[] => {
const query = fold(text.trim());
if (!query) return options.map((option) => ({ option, range: null }));
return options.flatMap((option) => {
const range = matchRange(option.name, query);
return range ? [{ option, range }] : [];
});
};
const matches = computed(() => findMatches(model.value));
// No empty message means nothing to show, and an open flag over an empty list would still
// announce expanded.
const expanded = computed(
() => open.value && (matches.value.length > 0 || !!emptyText),
);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const openList = (count: number) => {
if (search.value) {
const spaceBelow =
window.innerHeight - search.value.getBoundingClientRect().bottom;
// The list as the CSS draws it: natural height up to the cap.
const optionsHeight = Math.min(
count * (ROW + GAP) - GAP + PADDING * 2 + BORDER * 2,
PADDING + maxVisible * (ROW + GAP) + PEEK + BORDER * 2,
);
openUp.value = spaceBelow < OFFSET + optionsHeight;
}
open.value = true;
};
const closeList = () => {
open.value = false;
focusedIndex.value = -1;
};
const select = (option: Option) => {
if (disabled) return;
model.value = option.name;
onSelect(option.value);
closeList();
if (touch) {
field.value?.blur();
} else {
field.value?.focus();
}
};
const clear = () => {
model.value = "";
closeList();
field.value?.focus();
};
const onFocus = () => {
tabbed.value = !pointer;
};
// Focus moving to the clear button or an option is not leaving the field.
const onBlur = (ev: FocusEvent) => {
if (search.value?.contains(ev.relatedTarget as Node | null)) return;
tabbed.value = false;
pointer = false;
};
const onPointerDown = (ev: PointerEvent) => {
pointer = true;
touch = ev.pointerType === "touch";
};
const handleChange = (ev: Event) => {
const next = (ev.target as HTMLInputElement).value;
const count = findMatches(next).length;
model.value = next;
focusedIndex.value = -1;
if (next.trim() && (count || emptyText)) {
openList(count);
} else {
closeList();
}
};
const onKeyDown = (ev: KeyboardEvent) => {
touch = false;
if (disabled) return;
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
ev.preventDefault();
if (!matches.value.length) return;
const last = matches.value.length - 1;
if (!expanded.value) {
openList(matches.value.length);
focusedIndex.value = ev.key === "ArrowDown" ? 0 : last;
} else if (ev.key === "ArrowDown") {
focusedIndex.value =
focusedIndex.value >= last ? 0 : focusedIndex.value + 1;
} else {
focusedIndex.value =
focusedIndex.value <= 0 ? last : focusedIndex.value - 1;
}
} else if (ev.key === "Enter") {
if (expanded.value && focusedIndex.value >= 0) {
ev.preventDefault();
select(matches.value[focusedIndex.value].option);
}
} else if (ev.key === "Escape") {
if (expanded.value) {
closeList();
} else if (model.value) {
clear();
}
}
};
watch(expanded, () => {
if (!expanded.value) return;
const onOutside = (ev: Event) => {
if (search.value && !search.value.contains(ev.target as Node)) closeList();
};
document.addEventListener("click", onOutside);
document.addEventListener("focusin", onOutside);
onWatcherCleanup(() => {
document.removeEventListener("click", onOutside);
document.removeEventListener("focusin", onOutside);
});
});
// flush "post" so the option list is already rendered when we scroll to it.
watch(
[focusedIndex, expanded],
() => {
if (!expanded.value || focusedIndex.value < 0 || !optionsBox.value) return;
const option = optionsBox.value.children[focusedIndex.value] as HTMLElement;
option?.scrollIntoView({ block: "nearest" });
},
{ flush: "post" },
);
</script>
<template>
<div
ref="search"
class="search"
:class="[sizeClasses, { open: expanded, open_up: openUp, disabled }]"
@pointerdown="onPointerDown"
>
<div
class="search_box"
:class="{ tabbed }"
>
<PhMagnifyingGlass class="box_icon" aria-hidden="true" />
<input
ref="field"
:id="fieldId"
class="box_field"
type="text"
:name="name"
autocomplete="off"
role="combobox"
:aria-label="ariaLabel"
aria-autocomplete="list"
:aria-expanded="expanded"
:aria-controls="listboxId"
:aria-activedescendant="
expanded && focusedIndex >= 0 ? optionId(focusedIndex) : undefined
"
:placeholder="placeholder"
:disabled="disabled"
:value="model"
@focus="onFocus"
@blur="onBlur"
@keydown="onKeyDown"
@input="handleChange"
/>
<button
v-if="model"
type="button"
class="box_clear"
:aria-label="clearAriaLabel"
@click="clear"
>
<PhX weight="bold" />
</button>
</div>
<!-- A scroller without focusable children is a Tab stop in Chrome; with aria-activedescendant
the focus never enters the list. -->
<div
ref="optionsBox"
:id="listboxId"
class="search_options"
:style="{ '--max-visible': maxVisible }"
:role="matches.length ? 'listbox' : 'status'"
:tabindex="-1"
:aria-label="matches.length ? ariaLabel : undefined"
>
<p v-if="!matches.length" class="options_empty">{{ emptyText }}</p>
<button
v-for="({ option, range }, index) in matches"
:key="option.value"
:id="optionId(index)"
:class="{ focused: index === focusedIndex }"
type="button"
:tabindex="-1"
role="option"
:aria-selected="index === focusedIndex"
@click="select(option)"
>
<template v-if="range">{{ option.name.slice(0, range[0]) }}<mark class="option_match">{{ option.name.slice(range[0], range[1]) }}</mark>{{ option.name.slice(range[1]) }}</template>
<template v-else>{{ option.name }}</template>
</button>
</div>
</div>
</template>
<style scoped>
.search {
width: 100%;
position: relative;
-webkit-tap-highlight-color: transparent;
/* The field ladder: the height steps every tier, the font plateaus at --text-16.
The scale lives on the root: the popover is a sibling of the box and inherits the font from
here. */
&.xs {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg {
--search-height: var(--size-48);
font-size: var(--text-16);
}
&.xs_base {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_base {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_base {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_base {
--search-height: var(--size-48);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_mobile {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_mobile {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_mobile {
--search-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_tablet {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_tablet {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_tablet {
--search-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_desktop {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_desktop {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_desktop {
--search-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--search-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_wide {
--search-height: var(--size-40);
font-size: var(--text-14);
}
&.md_wide {
--search-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_wide {
--search-height: var(--size-48);
font-size: var(--text-16);
}
}
.search_box {
position: relative;
display: flex;
align-items: center;
height: var(--search-height);
gap: var(--space-8);
padding: 0 var(--space-12);
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-pill);
color: var(--color-text);
.box_icon {
flex-shrink: 0;
font-size: 20px;
}
.box_field {
flex: 1;
min-width: 0;
height: 100%;
/* Reserved whether the clear button is drawn or not, so the text never
jumps when it appears. */
padding: 0 var(--space-32) 0 0;
background: none;
border: 0;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: inherit;
text-overflow: ellipsis;
/* iOS zooms in on a focused field under 16px; a phone's short side is under 600, a tablet's
744+. */
@media (pointer: coarse) and (max-width: 599px),
(pointer: coarse) and (max-height: 599px) {
font-size: max(1em, 16px);
}
/* Chrome's default placeholder fails contrast in light; Firefox fades it
with opacity, hence opacity: 1. */
&::placeholder {
color: var(--color-text-secondary);
opacity: 1;
}
/* Beats the global ring at (0,2,0); the box carries the focus signal. */
&:focus-visible {
outline: none;
}
}
.box_clear {
position: absolute;
top: 50%;
right: var(--space-8);
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-28);
height: var(--size-28);
background: none;
border: none;
border-radius: var(--radius-full);
cursor: pointer;
font-size: var(--text-16);
color: inherit;
transition: background 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
}
&:active {
background: color-mix(in srgb, var(--color-text) 15%, transparent);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
svg {
width: 1em;
height: 1em;
}
}
}
/* focus-within, not the input alone: the border would blink while focus sits
on the clear button or an option. */
&:focus-within .search_box {
border-color: var(--color-contour-accent);
/* On text fields :focus-visible also fires on click, so the Tab
signal needs its own class. An inset shadow, not border-width: a
thicker border would push the icon and the clear button. */
&.tabbed {
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
}
.search_options {
/* maxVisible rows and half of the next; a max-height, so the half only shows when there is
more. */
max-height: calc(
var(--space-8) + var(--max-visible) * (var(--size-40) + var(--space-8)) +
var(--size-40) / 2 + var(--border-interactive) * 2
);
display: none;
position: absolute;
top: calc(100% + var(--space-8));
width: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 25%, transparent)
transparent;
background: var(--color-surface-opaque);
border: var(--border-interactive) solid var(--color-surface-opaque);
border-radius: var(--radius-8);
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 30%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 30%, transparent)
);
z-index: 1;
padding: var(--space-8);
scroll-padding: var(--space-8);
button {
width: 100%;
/* Flex items in a capped column shrink to their content, and the 40px row
the max-height assumes is gone. */
flex-shrink: 0;
padding: var(--space-8) var(--space-12);
background: none;
border: none;
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
height: var(--size-40);
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
border-radius: var(--radius-8);
@media (hover: hover) {
&:hover {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
}
&:active {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 15%
);
}
&.focused {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
.option_match {
background: none;
color: var(--color-accent);
font-weight: var(--weight-600);
}
}
}
.options_empty {
display: flex;
flex-shrink: 0;
align-items: center;
height: var(--size-40);
padding: var(--space-8) var(--space-12);
font-family: var(--font-body);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
}
&.open .search_options {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
&.open_up .search_options {
top: auto;
bottom: calc(100% + var(--space-8));
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Altura del campo y tamaño de su texto. Fijo o responsive. |
| aria-label | string | required | Nombre del campo para lectores de pantalla; el campo no dibuja label. |
| placeholder | string | — | Pista dentro del campo mientras está vacío. No sustituye al nombre. |
| name | string | — | Nombre del campo cuando vive en un formulario; sin él no se envía. |
| maxVisible | number | required | Nº de opciones enteras visibles antes de hacer scroll. Si hay más, asoma parte de la siguiente. |
| options | { name: string; value: string | number }[] | required | Sugerencias (nombre visible + valor). Se filtran por subcadena sin distinguir mayúsculas ni acentos, y la coincidencia se resalta. |
| clearAriaLabel | string | required | Nombre del botón de limpiar para lectores de pantalla; solo se dibuja con contenido. |
| emptyText | string | — | Mensaje cuando ninguna sugerencia coincide. Sin él, en ese caso la lista se queda cerrada. |
| disabled | boolean | — | Desactiva el campo (no editable, opacidad reducida). |
| value | string | required | Valor del campo (controlado). |
| onChange | (value: string) => void | required | Callback con el nuevo valor en cada cambio. Escape (con la lista cerrada) y el botón de limpiar lo vacían. |
| onSelect | (value: string | number) => void | required | Callback con el value de la sugerencia elegida; el texto pasa a ser su name. |