Skip to main content
astrocraft-ui/ components · 101

Search Field

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/search-field

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/search-field --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/search-field --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/search-field --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/search-field --theme tailwind --bridge lumos

Live demo

InputGroup & SearchField

An addon takes up width; an element sits on top of the field. The search field’s clear button appears once there is something to clear, and Escape empties it without leaving it.

https://.com

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`SearchField.astro``search-field` `search-field-clear` `search-field-input``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
—`[hidden]`

Source

What the command copies — 2 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/search-field/SearchField.astro
---
// src/components/ui/forms/search-field/SearchField.astro — headless primitive (see ../../README.md).
// A native <input type="search"> with a clear button you can actually style. `type=search` already
// carries the searchbox role and the mobile "Search" key, so none of that is re-declared here.
//
// What the script adds is the clear affordance, because the UA's own is unreachable: it is a
// `::-webkit-search-cancel-button` pseudo-element with no styling surface, it exists in exactly one
// engine, and it is mouse-only. structure.css suppresses it so there is never a second, unstyleable
// X beside ours — the same reasoning as the Accordion's duplicate disclosure marker.
//
// The button is hidden with the `hidden` ATTRIBUTE while the field is empty (never a class — see
// contract rule 4), and Escape clears the field without leaving it, which is what the pattern is for.
// With JavaScript off it is a plain, fully-usable search field with no clear button.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"input"> & {
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
  clearLabel?: string;
};

const {
  size = "md",
  state = "default",
  clearLabel = "Clear search",
  class: className,
  ...rest
} = Astro.props;
---

<div data-slot="search-field" data-size={size}>
  <input
    type="search"
    class={className}
    data-slot="search-field-input"
    data-size={size}
    data-state={state}
    {...rest}
  />
  <button type="button" data-slot="search-field-clear" aria-label={clearLabel} hidden>
    <slot name="clear"
      ><svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="M18 6 6 18M6 6l12 12"></path>
      </svg></slot
    >
  </button>
</div>

<script>
  import { onReadyOnce } from "../../_once";

  function wire(root: HTMLElement) {
    const field = root.querySelector<HTMLInputElement>('[data-slot="search-field-input"]');
    const clear = root.querySelector<HTMLButtonElement>('[data-slot="search-field-clear"]');
    if (!field || !clear) return;

    const sync = () => {
      clear.hidden = field.value === "";
    };

    const reset = () => {
      if (field.value === "") return;
      field.value = "";
      // Consumers listen for `input` to re-filter and `change` to commit; clearing is both.
      field.dispatchEvent(new Event("input", { bubbles: true }));
      field.dispatchEvent(new Event("change", { bubbles: true }));
      sync();
    };

    field.addEventListener("input", sync);
    field.addEventListener("keydown", (event) => {
      if (event.key !== "Escape") return;
      // Stop here: an Escape inside a dialog or a popover would otherwise close it out from under
      // someone who only meant to empty the field.
      event.stopPropagation();
      reset();
    });
    clear.addEventListener("click", () => {
      reset();
      field.focus(); // the button vanishes on clear, so focus has to go somewhere deliberate
    });
    sync();
  }

  onReadyOnce('[data-slot="search-field"]', wire);
</script>
src/components/ui/forms/search-field/index.ts
import SearchField from "./SearchField.astro";

export { SearchField };
export default SearchField;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/search-field — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.