Takazudo Modular Styleguide

Shared/KeywordSearchInput

Default
Empty
English Placeholder
Interactive
With Value

Component Source

keyword-search-input.tsx

/**
 * Shared keyword search input — text field with a leading search icon and an
 * inline × clear button. Promoted from `components/products/product-keyword-search.tsx`
 * (issue #1384, part of epic #1365) so `/notes/` can reuse the same look
 * instead of forking it — the #1333/#1334 shared-component pattern already
 * used for `FilterToggle` / `ClearFilterButton` / `FilterSectionHeading`.
 *
 * Purely presentational — holds NO internal state beyond the input ref used
 * to refocus after the inline clear. Callers own the debounce timing and
 * value/URL-param wiring (see `/products/` and `/notes/` filter islands).
 */

import { useRef } from 'preact/hooks';

export interface KeywordSearchInputProps {
  value: string;
  onChange: (value: string) => void;
  /** Input placeholder — locale-driven by the caller. Defaults to the JA copy. */
  placeholder?: string;
  // data-* passthrough (e.g. `data-notes-keyword-input`) so callers can hang a
  // DOM-contract hook off the rendered <input> without the component needing
  // to know about it.
  [key: `data-${string}`]: unknown;
}

const SearchIcon = () => (
  <svg
    class="w-[1.2em] h-[1.2em]"
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    stroke-width="2"
    stroke-linecap="round"
    stroke-linejoin="round"
  >
    <circle cx="11" cy="11" r="8" />
    <path d="m21 21-4.35-4.35" />
  </svg>
);

export function KeywordSearchInput({
  value,
  onChange,
  placeholder = 'キーワードでフィルタ',
  ...rest
}: KeywordSearchInputProps) {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <div class="flex items-center pt-vgap-sm">
      <div class="flex items-center pr-hgap-xs">
        <SearchIcon />
      </div>
      <div class="relative">
        <input
          {...rest}
          ref={inputRef}
          type="text"
          value={value}
          onChange={(e) => onChange((e.currentTarget as HTMLInputElement).value)}
          placeholder={placeholder}
          aria-label={placeholder}
          class="rounded-none w-[250px] sm:w-[350px] lg:w-[450px] xl:w-[550px] px-hgap-sm py-vgap-xs border-[3px] border-zd-white bg-white text-zd-literal-black"
        />
        {value && (
          <button
            onClick={() => {
              onChange('');
              inputRef.current?.focus();
            }}
            class="absolute right-[8px] top-[50%] -translate-y-1/2 text-zd-gray hover:text-zd-literal-black text-lg leading-none"
            aria-label="Clear search"
          >
            &times;
          </button>
        )}
      </div>
    </div>
  );
}

export default KeywordSearchInput;

Story Source

keyword-search-input.stories.tsx

import { useState } from 'preact/hooks';
import { KeywordSearchInput } from './keyword-search-input';
import type { ControlsMap } from '@/sub-packages/styleguide-v2/src/data/control-types';

/**
 * KeywordSearchInput Component
 *
 * Shared keyword search input — a lens icon + text field with an inline ×
 * clear button. Promoted from the `/products/` filter island's
 * `product-keyword-search.tsx` (issue #1384) so `/notes/` reuses the same
 * look instead of forking it.
 *
 * Purely presentational — holds NO debounce/URL-param state; callers own
 * that (see `products-page-content.tsx` / `notes-filter-island.tsx`).
 */
export const meta = {
  title: 'Shared/KeywordSearchInput',
};

export const controls: ControlsMap = {
  placeholder: { type: 'text', default: 'キーワードでフィルタ' },
};

/**
 * Default - controllable via props panel
 */
export const Default = {
  args: { placeholder: 'キーワードでフィルタ' },
  render: ({ placeholder }: { placeholder: string }) => {
    const [value, setValue] = useState('');
    return <KeywordSearchInput value={value} onChange={setValue} placeholder={placeholder} />;
  },
};

/**
 * Empty — no clear button (nothing typed yet)
 */
export const Empty = () => (
  <KeywordSearchInput value="" onChange={() => {}} placeholder="キーワードでフィルタ" />
);

/**
 * With a value — the inline × clear button appears
 */
export const WithValue = () => (
  <KeywordSearchInput value="OXI ONE" onChange={() => {}} placeholder="キーワードでフィルタ" />
);

/**
 * EN placeholder (`/en/notes/`, `/en/products/`)
 */
export const EnglishPlaceholder = () => (
  <KeywordSearchInput value="" onChange={() => {}} placeholder="Filter by keyword" />
);

/**
 * Interactive — type to see the value update and the clear button appear;
 * click × to clear and refocus.
 */
const InteractiveInput = () => {
  const [value, setValue] = useState('');
  return (
    <KeywordSearchInput value={value} onChange={setValue} placeholder="キーワードでフィルタ" />
  );
};

export const Interactive = () => <InteractiveInput />;