DaisyUIとTailwind CSSで検索UIを美しく実装する

公開日:
目次

モダンなWebアプリケーションにおいて、検索機能はユーザビリティの核心となる要素です。この記事では、DaisyUIとTailwind CSSを組み合わせて、美しく機能的な検索インターフェースを実装する方法を詳しく解説します。

DaisyUIとは

DaisyUIは、Tailwind CSSをベースとしたコンポーネントライブラリです。事前に定義されたデザインシステムを活用することで、一貫性のあるUIを素早く構築できます。

主な特徴:

  • Tailwind CSS完全互換: 既存のTailwindクラスと組み合わせ可能
  • テーマシステム: ダークモード対応やカスタムテーマが簡単
  • アクセシビリティ: WAI-ARIA準拠のコンポーネント
  • 軽量: 必要な分だけバンドルされる

セットアップ

インストール

npm install daisyui

Tailwind CSS設定

// tailwind.config.js
module.exports = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  plugins: [require('daisyui')],
  daisyui: {
    themes: ['light', 'dark'],
  },
}

基本的な検索コンポーネント

まずは、DaisyUIのinputコンポーネントを使用した基本的な検索フォームを作成します:

export const BasicSearchComponent = () => {
  const [searchQuery, setSearchQuery] = useState('')

  return (
    <div className="form-control w-full max-w-xs">
      <label className="label">
        <span className="label-text">記事を検索</span>
      </label>
      <div className="relative">
        <input
          type="text"
          placeholder="キーワードを入力..."
          value={searchQuery}
          onChange={(e) => setSearchQuery(e.target.value)}
          className="input input-bordered w-full pr-12"
        />
        <div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
          <svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
          </svg>
        </div>
      </div>
    </div>
  )
}

高度な検索UIの実装

ステート管理とインタラクション

import { useState, useEffect, useRef } from 'react'

export const AdvancedSearchComponent = () => {
  const [isSearchOpen, setIsSearchOpen] = useState(false)
  const [searchQuery, setSearchQuery] = useState('')
  const [searchResults, setSearchResults] = useState([])
  const [isLoading, setIsLoading] = useState(false)
  const [isComposing, setIsComposing] = useState(false)
  
  const searchRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLInputElement>(null)

  // 外部クリックでドロップダウンを閉じる
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
        setIsSearchOpen(false)
      }
    }

    document.addEventListener('mousedown', handleClickOutside)
    return () => document.removeEventListener('mousedown', handleClickOutside)
  }, [])

  // フォーカス時に入力フィールドを自動選択
  useEffect(() => {
    if (isSearchOpen && inputRef.current) {
      inputRef.current.focus()
    }
  }, [isSearchOpen])

  return (
    <div className="w-full max-w-xl mx-auto mb-8 relative" ref={searchRef}>
      <div className="form-control">
        <div className="relative">
          <input
            ref={inputRef}
            type="text"
            value={searchQuery}
            onChange={handleInputChange}
            onFocus={() => setIsSearchOpen(true)}
            onCompositionStart={() => setIsComposing(true)}
            onCompositionEnd={handleCompositionEnd}
            placeholder="記事を検索..."
            className="input input-bordered input-md w-full pr-12 focus:input-primary transition-all duration-200 hover:shadow-md"
          />
          
          <div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
            <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-base-content opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
            </svg>
          </div>
        </div>
        
        {/* ローディングスピナー */}
        {isLoading && (
          <div className="absolute right-10 top-1/2 transform -translate-y-1/2">
            <span className="loading loading-spinner loading-sm text-primary"></span>
          </div>
        )}
      </div>

      {/* 検索結果ドロップダウン */}
      {isSearchOpen && searchResults.length > 0 && (
        <SearchResultsDropdown 
          results={searchResults} 
          onItemClick={() => setIsSearchOpen(false)}
        />
      )}
      
      {/* 結果なしの表示 */}
      {isSearchOpen && searchQuery.length > 2 && searchResults.length === 0 && !isLoading && (
        <NoResultsDisplay />
      )}
    </div>
  )
}

検索結果ドロップダウン

interface SearchResult {
  url: string
  title: string
  excerpt: string
}

interface SearchResultsDropdownProps {
  results: SearchResult[]
  onItemClick: () => void
}

const SearchResultsDropdown = ({ results, onItemClick }: SearchResultsDropdownProps) => {
  return (
    <div className="absolute z-50 w-full mt-2 left-0 right-0">
      <div className="card bg-base-100 shadow-xl border border-base-300">
        <div className="card-body p-0 max-h-96 overflow-y-auto">
          {results.map((result, index) => (
            <a
              key={index}
              href={result.url}
              className="block px-4 py-3 hover:bg-base-200 transition-colors duration-150 border-b border-base-200 last:border-b-0"
              onClick={onItemClick}
            >
              <h3 
                className="font-semibold text-base-content text-sm mb-1 line-clamp-1"
                dangerouslySetInnerHTML={{ __html: result.title }}
              />
              <div 
                className="text-xs text-base-content opacity-60 line-clamp-2 [&_mark]:bg-yellow-200 [&_mark]:text-gray-900 [&_mark]:font-semibold [&_mark]:px-0.5 [&_mark]:rounded"
                dangerouslySetInnerHTML={{ __html: result.excerpt }}
              />
            </a>
          ))}
        </div>
      </div>
    </div>
  )
}

結果なし表示コンポーネント

const NoResultsDisplay = () => {
  return (
    <div className="absolute z-50 w-full mt-2 left-0 right-0">
      <div className="card bg-base-100 shadow-xl border border-base-300">
        <div className="card-body p-8 text-center">
          <div className="text-4xl mb-3">🔍</div>
          <p className="text-base-content font-medium">検索結果が見つかりませんでした</p>
          <p className="text-sm text-base-content opacity-60 mt-1">別のキーワードで試してみてください</p>
        </div>
      </div>
    </div>
  )
}

レスポンシブデザインとアクセシビリティ

モバイル対応

const ResponsiveSearchComponent = () => {
  return (
    <div className="w-full max-w-xl mx-auto mb-8 relative">
      {/* デスクトップ版 */}
      <div className="hidden md:block">
        <input
          className="input input-bordered input-lg w-full pr-12 focus:input-primary"
          placeholder="記事を検索..."
        />
      </div>
      
      {/* モバイル版 */}
      <div className="md:hidden">
        <input
          className="input input-bordered input-md w-full pr-10 focus:input-primary"
          placeholder="検索..."
        />
      </div>
    </div>
  )
}

キーボードナビゲーション

const AccessibleSearchComponent = () => {
  const [selectedIndex, setSelectedIndex] = useState(-1)
  const [searchResults, setSearchResults] = useState([])

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setSelectedIndex(prev => 
          prev < searchResults.length - 1 ? prev + 1 : prev
        )
        break
      case 'ArrowUp':
        e.preventDefault()
        setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)
        break
      case 'Enter':
        e.preventDefault()
        if (selectedIndex >= 0 && searchResults[selectedIndex]) {
          window.location.href = searchResults[selectedIndex].url
        }
        break
      case 'Escape':
        setIsSearchOpen(false)
        setSelectedIndex(-1)
        break
    }
  }

  return (
    <div>
      <input
        onKeyDown={handleKeyDown}
        role="combobox"
        aria-expanded={isSearchOpen}
        aria-haspopup="listbox"
        aria-autocomplete="list"
        aria-describedby="search-help"
        className="input input-bordered w-full"
      />
      
      <div id="search-help" className="sr-only">
        矢印キーで選択、Enterで開く、Escapeで閉じる
      </div>
      
      {isSearchOpen && (
        <ul role="listbox" className="menu bg-base-100 shadow-lg">
          {searchResults.map((result, index) => (
            <li
              key={index}
              role="option"
              aria-selected={selectedIndex === index}
              className={selectedIndex === index ? 'bg-base-200' : ''}
            >
              <a href={result.url}>{result.title}</a>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

カスタムテーマの作成

テーマ定義

// tailwind.config.js
module.exports = {
  daisyui: {
    themes: [
      {
        'custom-search': {
          'primary': '#3b82f6',
          'primary-focus': '#2563eb',
          'primary-content': '#ffffff',
          'secondary': '#f59e0b',
          'accent': '#10b981',
          'neutral': '#1f2937',
          'base-100': '#ffffff',
          'base-200': '#f9fafb',
          'base-300': '#e5e7eb',
          'base-content': '#1f2937',
        },
      },
    ],
  },
}

テーマ切り替え機能

const ThemeAwareSearchComponent = () => {
  const [theme, setTheme] = useState('light')

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme)
  }, [theme])

  return (
    <div className="space-y-4">
      <div className="flex justify-end">
        <div className="form-control">
          <label className="label cursor-pointer">
            <span className="label-text mr-2">ダークモード</span>
            <input
              type="checkbox"
              className="toggle toggle-primary"
              onChange={(e) => setTheme(e.target.checked ? 'dark' : 'light')}
            />
          </label>
        </div>
      </div>
      
      <div className="card bg-base-100 shadow-xl">
        <div className="card-body">
          <SearchComponent />
        </div>
      </div>
    </div>
  )
}

パフォーマンス最適化

デバウンス機能

import { useMemo } from 'react'
import { debounce } from 'lodash'

const OptimizedSearchComponent = () => {
  const [searchQuery, setSearchQuery] = useState('')

  const debouncedSearch = useMemo(
    () => debounce(async (query: string) => {
      if (query.length > 0) {
        await handleSearch(query)
      }
    }, 300),
    []
  )

  useEffect(() => {
    debouncedSearch(searchQuery)
    return () => debouncedSearch.cancel()
  }, [searchQuery, debouncedSearch])

  return (
    <input
      value={searchQuery}
      onChange={(e) => setSearchQuery(e.target.value)}
      className="input input-bordered w-full"
      placeholder="高速検索..."
    />
  )
}

バーチャルスクロール(大量の結果用)

import { FixedSizeList as List } from 'react-window'

const VirtualizedSearchResults = ({ results }: { results: SearchResult[] }) => {
  const Row = ({ index, style }: { index: number; style: any }) => (
    <div style={style} className="px-4 py-2 border-b border-base-200">
      <a href={results[index].url} className="block hover:bg-base-200">
        <h3 className="font-semibold">{results[index].title}</h3>
        <p className="text-sm opacity-60">{results[index].excerpt}</p>
      </a>
    </div>
  )

  return (
    <div className="card bg-base-100 shadow-xl">
      <List
        height={400}
        itemCount={results.length}
        itemSize={80}
        className="w-full"
      >
        {Row}
      </List>
    </div>
  )
}

まとめ

DaisyUIとTailwind CSSを組み合わせることで、以下のような利点を持つ検索UIを効率的に実装できます:

主な利点

  • 一貫性のあるデザイン: DaisyUIのデザインシステム
  • アクセシビリティ: WAI-ARIA準拠のコンポーネント
  • レスポンシブ: モバイルファーストなデザイン
  • カスタマイズ性: Tailwindの柔軟性を活用
  • パフォーマンス: 最適化された実装

実装のポイント

  1. 適切なコンポーネント選択: DaisyUIのinput、card、menuを活用
  2. 状態管理: React hooksを使った効率的な状態管理
  3. ユーザビリティ: キーボードナビゲーションとフォーカス管理
  4. パフォーマンス: デバウンス処理とバーチャルスクロール

この組み合わせにより、モダンで使いやすい検索インターフェースを短時間で構築できるでしょう。