Forms
SearchField
Búsqueda sin sugerencias, para filtrar lo que ya está en pantalla
Preview
No trae lista. Sirve para filtrar lo que ya está en pantalla, como las filas de una tabla. Para elegir entre sugerencias, el componente es Search.
size
placeholder
disabled
Code
import css from "./SearchField.module.css";
import { 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 SearchFieldProps {
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;
clearAriaLabel: string;
disabled?: boolean;
value: string;
onChange: (value: string) => void;
}
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 SearchField({
size,
"aria-label": ariaLabel,
placeholder,
name,
clearAriaLabel,
disabled,
value,
onChange,
}: SearchFieldProps) {
// Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
// identify.
const fieldId = useId();
const field = useRef<HTMLInputElement>(null);
const searchField = useRef<HTMLDivElement>(null);
const [tabbed, setTabbed] = useState(false);
const pointer = useRef(false);
const clear = () => {
onChange("");
field.current?.focus();
};
const onFocus = () => setTabbed(!pointer.current);
// Focus moving to the clear button is not leaving the field: it hands it back.
const onBlur = (ev: React.FocusEvent) => {
if (searchField.current?.contains(ev.relatedTarget)) return;
setTabbed(false);
pointer.current = false;
};
const onPointerDown = () => {
pointer.current = true;
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
onChange(ev.target.value);
};
const onKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Escape" && value) {
ev.preventDefault();
clear();
}
};
return (
<div
ref={searchField}
onPointerDown={onPointerDown}
className={`
${css.searchfield}
${getSizeClasses(size)}
${tabbed ? css.tabbed : ""}
${disabled ? css.disabled : ""}
`}
>
<MagnifyingGlassIcon className={css.searchfield_icon} aria-hidden="true" />
<input
ref={field}
id={fieldId}
className={css.searchfield_input}
type="search"
name={name}
autoComplete="off"
aria-label={ariaLabel}
placeholder={placeholder}
disabled={disabled}
value={value}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
onChange={handleChange}
/>
{value && (
<button
type="button"
className={css.searchfield_clear}
aria-label={clearAriaLabel}
onClick={clear}
>
<XIcon weight="bold" />
</button>
)}
</div>
);
}
.searchfield {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: var(--searchfield-height);
gap: var(--space-8);
padding: 0 var(--space-12);
border: var(--border-surface) solid var(--color-border-control);
border-radius: var(--radius-pill);
color: var(--color-text);
font-size: var(--text-14);
-webkit-tap-highlight-color: transparent;
&.xs {
--searchfield-height: var(--size-36);
}
&.sm {
--searchfield-height: var(--size-40);
}
&.md {
--searchfield-height: var(--size-44);
}
&.lg {
--searchfield-height: var(--size-48);
}
&.xs_base {
--searchfield-height: var(--size-36);
}
&.sm_base {
--searchfield-height: var(--size-40);
}
&.md_base {
--searchfield-height: var(--size-44);
}
&.lg_base {
--searchfield-height: var(--size-48);
}
@media (min-width: 480px) {
&.xs_mobile {
--searchfield-height: var(--size-36);
}
&.sm_mobile {
--searchfield-height: var(--size-40);
}
&.md_mobile {
--searchfield-height: var(--size-44);
}
&.lg_mobile {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--searchfield-height: var(--size-36);
}
&.sm_tablet {
--searchfield-height: var(--size-40);
}
&.md_tablet {
--searchfield-height: var(--size-44);
}
&.lg_tablet {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--searchfield-height: var(--size-36);
}
&.sm_desktop {
--searchfield-height: var(--size-40);
}
&.md_desktop {
--searchfield-height: var(--size-44);
}
&.lg_desktop {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--searchfield-height: var(--size-36);
}
&.sm_wide {
--searchfield-height: var(--size-40);
}
&.md_wide {
--searchfield-height: var(--size-44);
}
&.lg_wide {
--searchfield-height: var(--size-48);
}
}
/* focus-within, not the input alone: the contour would blink while focus sits on the clear
button. */
&:focus-within {
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);
}
}
.searchfield_icon {
flex-shrink: 0;
font-size: 20px;
}
.searchfield_input {
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;
/* type="search" brings the browser's own styling and clear button; the pill draws both. */
appearance: none;
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);
}
&::-webkit-search-cancel-button,
&::-webkit-search-decoration {
appearance: none;
}
/* 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 pill carries the focus signal. */
&:focus-visible {
outline: none;
}
}
.searchfield_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;
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
<script setup lang="ts">
import { computed, ref, useId, useTemplateRef } 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 SearchFieldProps {
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;
clearAriaLabel: string;
disabled?: boolean;
}
const { size } = defineProps<SearchFieldProps>();
const model = defineModel<string>({ required: true });
// Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
// identify.
const fieldId = useId();
const field = useTemplateRef<HTMLInputElement>("field");
const searchField = useTemplateRef<HTMLDivElement>("searchField");
const tabbed = ref(false);
let pointer = false;
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 clear = () => {
model.value = "";
field.value?.focus();
};
const onFocus = () => {
tabbed.value = !pointer;
};
// Focus moving to the clear button is not leaving the field: it hands it back.
const onBlur = (ev: FocusEvent) => {
if (searchField.value?.contains(ev.relatedTarget as Node | null)) return;
tabbed.value = false;
pointer = false;
};
const onPointerDown = () => {
pointer = true;
};
const handleChange = (ev: Event) => {
model.value = (ev.target as HTMLInputElement).value;
};
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape" && model.value) {
ev.preventDefault();
clear();
}
};
</script>
<template>
<div
ref="searchField"
class="searchfield"
:class="[sizeClasses, { tabbed, disabled }]"
@pointerdown="onPointerDown"
>
<PhMagnifyingGlass class="searchfield_icon" aria-hidden="true" />
<input
ref="field"
:id="fieldId"
class="searchfield_input"
type="search"
:name="name"
autocomplete="off"
:aria-label="ariaLabel"
:placeholder="placeholder"
:disabled="disabled"
:value="model"
@focus="onFocus"
@blur="onBlur"
@keydown="onKeyDown"
@input="handleChange"
/>
<button
v-if="model"
type="button"
class="searchfield_clear"
:aria-label="clearAriaLabel"
@click="clear"
>
<PhX weight="bold" />
</button>
</div>
</template>
<style scoped>
.searchfield {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: var(--searchfield-height);
gap: var(--space-8);
padding: 0 var(--space-12);
border: var(--border-surface) solid var(--color-border-control);
border-radius: var(--radius-pill);
color: var(--color-text);
font-size: var(--text-14);
-webkit-tap-highlight-color: transparent;
&.xs {
--searchfield-height: var(--size-36);
}
&.sm {
--searchfield-height: var(--size-40);
}
&.md {
--searchfield-height: var(--size-44);
}
&.lg {
--searchfield-height: var(--size-48);
}
&.xs_base {
--searchfield-height: var(--size-36);
}
&.sm_base {
--searchfield-height: var(--size-40);
}
&.md_base {
--searchfield-height: var(--size-44);
}
&.lg_base {
--searchfield-height: var(--size-48);
}
@media (min-width: 480px) {
&.xs_mobile {
--searchfield-height: var(--size-36);
}
&.sm_mobile {
--searchfield-height: var(--size-40);
}
&.md_mobile {
--searchfield-height: var(--size-44);
}
&.lg_mobile {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--searchfield-height: var(--size-36);
}
&.sm_tablet {
--searchfield-height: var(--size-40);
}
&.md_tablet {
--searchfield-height: var(--size-44);
}
&.lg_tablet {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--searchfield-height: var(--size-36);
}
&.sm_desktop {
--searchfield-height: var(--size-40);
}
&.md_desktop {
--searchfield-height: var(--size-44);
}
&.lg_desktop {
--searchfield-height: var(--size-48);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--searchfield-height: var(--size-36);
}
&.sm_wide {
--searchfield-height: var(--size-40);
}
&.md_wide {
--searchfield-height: var(--size-44);
}
&.lg_wide {
--searchfield-height: var(--size-48);
}
}
/* focus-within, not the input alone: the contour would blink while focus sits on the clear
button. */
&:focus-within {
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);
}
}
.searchfield_icon {
flex-shrink: 0;
font-size: 20px;
}
.searchfield_input {
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;
/* type="search" brings the browser's own styling and clear button; the pill draws both. */
appearance: none;
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);
}
&::-webkit-search-cancel-button,
&::-webkit-search-decoration {
appearance: none;
}
/* 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 pill carries the focus signal. */
&:focus-visible {
outline: none;
}
}
.searchfield_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;
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Altura del campo (36/40/44/48, la escalera de Search). El texto se queda en 14, como en TextField y Picker. 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. |
| clearAriaLabel | string | required | Nombre del botón de limpiar para lectores de pantalla; solo se dibuja con contenido. |
| 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 y el botón de limpiar lo vacían. |