CATEGORIES:CSS JAVA WORDPRESS
検索フォームに最近の検索履歴を表示する

※ デモなので、実際には検索しない。検索した言葉が履歴に入るだけ。

検索窓をクリックすると、最近検索した言葉を真下に出す。履歴を押せばそのまま検索、× で1件ずつ消せる。件数や色は数字の変更だけで調整できる。

ブラウザにも入力した言葉を覚える機能はあるが、ブラウザや設定によって出たり出なかったりする。そこで、サイト側で出すようにした。

動きは次のとおり。

  • 検索した言葉を、新しい順に最大5件残す
  • 検索窓をクリックすると、入力欄の真下に同じ幅で出る
  • 履歴を押すと、その言葉で検索する
  • × を押すと、その1件だけ消える
  • 外をクリックするか Esc キーで閉じる

※ jQuery・Font Awesome は不要。WordPress 以外でも、<input name="s"> の検索フォームなら動く。履歴は見ている人のブラウザ(localStorage)にだけ残り、サーバーには送らない。

CSS

下記を CSS に配置。色は CSS変数(--sh-)で変えられる。

/* ===== ここから CSS(検索履歴) ===== */
.search-history {
	--sh-color: #222222;     /* ★ マウスを乗せた時の文字の色 */
	--sh-sub: #888888;       /* ★ 履歴の文字の色 */
	--sh-light: #cccccc;     /* ★ 「RECENT」と × の色 */
	--sh-line: #dddddd;      /* ★ 枠線の色 */
	position: absolute;      /* 位置と幅は JavaScript で入力欄に合わせる */
	z-index: 50;
	box-sizing: border-box;
	margin-top: 4px;
	padding: 8px 0;
	background: #ffffff;
	border: 1px solid var(--sh-line);
	box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
	text-align: left;
}
.search-history[hidden] {
	display: none;
}
.search-history_title {
	margin: 0 12px 4px;
	font-size: 75%;
	letter-spacing: 0.05em;
	color: var(--sh-light);
}
.search-history ul {
	list-style: none;
	margin: 0;
	padding: 0;
}
.search-history li {
	display: flex;
	align-items: center;
}
.search-history button {
	border: none;
	background: none;
	cursor: pointer;
	font: inherit;
	color: var(--sh-sub);
}
.search-history_word {
	flex: 1 1 auto;
	min-width: 0;
	padding: 4px 12px;
	text-align: left;
	font-size: 90%;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis; /* 長い言葉は … で切る */
}
.search-history_del {
	flex: 0 0 auto;
	padding: 4px 12px;
	font-size: 90%;
	color: var(--sh-light);
}
.search-history_word:hover,
.search-history_del:hover {
	color: var(--sh-color);
}
/* ===== ここまで CSS(検索履歴) ===== */

JavaScript

下記を </body> の直前(WordPress なら footer.php)に配置。WordPress ならそのままでいい。WordPress 以外で、検索結果ページの URL が ?s=〇〇 になっていないサイト(たとえば ?q=〇〇)は、PARAM を 'q' に変える。

<script>
/* ===== JavaScript(検索履歴) =====
 * ・検索結果ページ(URL に ?s=… が付いたページ)を開いた時に、その言葉を履歴に入れる
 * ・検索窓をクリックすると、入力欄の真下に最近の検索を出す(入力欄+検索ボタンと同じ幅)
 * ・履歴を押す → その言葉で検索 / × → その1件を消す / 外をクリック・Esc → 閉じる
 * ・履歴は見ている人のブラウザ(localStorage)にだけ保存。サーバーには送らない
 */
( function () {
	'use strict';

	var PARAM = 's';                // ★ 検索語の名前(WordPress は s)。<input name="〇〇"> と同じにする
	var MAX   = 5;                  // ★ 残す件数
	var LIMIT = 50;                 // ★ 1件の最大文字数(長すぎる言葉は切る)
	var TITLE = 'RECENT';           // ★ リストの見出し('' にすると出さない)
	var KEY   = 'search_history';   // 保存名(同じドメインの別サイトと分けたい時は変える)

	// ---- 保存・読み出し(プライベートモードなどで使えない時は何もしない) ----
	function load() {
		try {
			var list = JSON.parse( window.localStorage.getItem( KEY ) || '[]' );
			return Array.isArray( list ) ? list.filter( function ( w ) { return typeof w === 'string' && w; } ) : [];
		} catch ( e ) {
			return [];
		}
	}
	function save( list ) {
		try {
			window.localStorage.setItem( KEY, JSON.stringify( list.slice( 0, MAX ) ) );
		} catch ( e ) {}
	}
	function add( word ) {
		word = String( word ).replace( /\s+/g, ' ' ).trim().slice( 0, LIMIT );
		if ( ! word ) {
			return;
		}
		var list = load().filter( function ( w ) { return w !== word; } ); // 同じ言葉は先頭に移す
		list.unshift( word );
		save( list );
	}
	function remove( word ) {
		save( load().filter( function ( w ) { return w !== word; } ) );
	}

	// ---- 検索結果ページを開いた時に記録(どの検索窓から検索しても1回だけ入る) ----
	var params = new URLSearchParams( window.location.search );
	if ( params.has( PARAM ) ) {
		add( params.get( PARAM ) );
	}

	// ---- 検索フォームごとに履歴リストを付ける ----
	var inputs = document.querySelectorAll( 'form input[name="' + PARAM + '"]' );
	Array.prototype.forEach.call( inputs, function ( input ) {
		var form = input.form;
		var submit = form.querySelector( '[type="submit"]' );
		input.setAttribute( 'autocomplete', 'off' ); // ブラウザ自身の候補と二重に出ないように

		if ( window.getComputedStyle( form ).position === 'static' ) {
			form.style.position = 'relative'; // リストの位置の基準にする
		}

		var box = document.createElement( 'div' );
		box.className = 'search-history';
		box.hidden = true;
		form.appendChild( box );
		// リストを押しても入力欄からフォーカスを外さない(Safari でクリックが消えるのを防ぐ)
		box.addEventListener( 'mousedown', function ( e ) { e.preventDefault(); } );

		// 入力欄(+検索ボタン)の真下に、同じ幅で出す
		// フォーム自体が横幅いっぱいのことがあるので、入力欄の位置を測って合わせる
		function place() {
			var fr = form.getBoundingClientRect();
			var ir = input.getBoundingClientRect();
			var right = submit ? Math.max( ir.right, submit.getBoundingClientRect().right ) : ir.right;
			box.style.left  = ( ir.left - fr.left ) + 'px';
			box.style.top   = ( ir.bottom - fr.top ) + 'px';
			box.style.width = ( right - ir.left ) + 'px';
		}

		function render() {
			var list = load();
			box.textContent = '';
			if ( ! list.length ) {
				box.hidden = true;
				return;
			}
			if ( TITLE ) {
				var title = document.createElement( 'p' );
				title.className = 'search-history_title';
				title.textContent = TITLE;
				box.appendChild( title );
			}
			var ul = document.createElement( 'ul' );
			list.forEach( function ( word ) {
				var li = document.createElement( 'li' );

				var go = document.createElement( 'button' );
				go.type = 'button';
				go.className = 'search-history_word';
				go.textContent = word; // 文字だけ入れる(HTML として解釈しない)
				go.addEventListener( 'click', function () {
					input.value = word;
					HTMLFormElement.prototype.submit.call( form ); // name="submit" のボタンがあっても送れる書き方
				} );

				var del = document.createElement( 'button' );
				del.type = 'button';
				del.className = 'search-history_del';
				del.textContent = '×';
				del.setAttribute( 'aria-label', '「' + word + '」を履歴から消す' );
				del.addEventListener( 'click', function () {
					remove( word );
					render();
				} );

				li.appendChild( go );
				li.appendChild( del );
				ul.appendChild( li );
			} );
			box.appendChild( ul );
			box.hidden = false;
			place();
		}

		// 検索窓をクリック(フォーカス)したら出す
		input.addEventListener( 'focus', render );
		input.addEventListener( 'click', render ); // 閉じた後、入力欄にいたままもう一度クリックした時も出す

		// 外をクリック・Esc・Tab でフォームの外に出たら閉じる
		document.addEventListener( 'mousedown', function ( e ) {
			if ( ! form.contains( e.target ) ) {
				box.hidden = true;
			}
		} );
		form.addEventListener( 'keydown', function ( e ) {
			if ( e.key === 'Escape' ) {
				box.hidden = true;
			}
		} );
		form.addEventListener( 'focusout', function () {
			setTimeout( function () {
				if ( ! form.contains( document.activeElement ) ) {
					box.hidden = true;
				}
			}, 0 );
		} );
		// 開いている間に画面の幅が変わったら、位置を測り直す
		window.addEventListener( 'resize', function () {
			if ( ! box.hidden ) {
				place();
			}
		} );
	} );
}() );
</script>

※ 検索フォームより後ろに置くこと。先に読み込むと、検索フォームが見つからずに何も起きない。

履歴を記録するタイミング

検索ボタンを押した時ではなく、検索結果ページ(URL に ?s=〇〇 が付いたページ)を開いた時に記録する。

検索フォームがヘッダーとサイドに2つあっても、どちらから検索しても1回だけ記録される。フォームごとに送信を見張らなくていい。

入力欄の真下に出す仕組み

ここが一番引っかかった所。最初はリストをフォームの左下に出していたのだが、フォームが横幅いっぱいで入力欄が右寄せのページだと、リストが入力欄から離れた所に出てしまった。

そこで、リストを出すたびに入力欄と検索ボタンの位置を測り、入力欄の左端から検索ボタンの右端までの幅で、入力欄の真下に出すようにした。フォームの形がページごとに違っても、CSS を変えずにそろう。

WordPressの場合

JavaScript を footer.php に直接書く代わりに、ファイルにして functions.php で読み込む形もある。<script> と </script> を外した中身を、テーマの js/search-history.js として保存。

// 検索フォームの下に最近の検索を出す(js/search-history.js を全ページで読み込む)
function mysite_enqueue_search_history() {
	wp_enqueue_script(
		'search-history',
		get_stylesheet_directory_uri() . '/js/search-history.js',
		array(),
		filemtime( get_stylesheet_directory() . '/js/search-history.js' ), // 更新したら自動でキャッシュ更新
		true // フッターで読み込む(検索フォームが表示されてから動かす)
	);
}
add_action( 'wp_enqueue_scripts', 'mysite_enqueue_search_history' );

※ 親テーマを直接書き換えると、テーマの更新で消える。子テーマに置くこと(get_stylesheet_directory は子テーマがあれば子テーマを指す)。

調整するときの変更箇所

調整するときの変更箇所は次のとおり。

変えたいもの変更する場所
残す件数JavaScript の MAX(初期値 5)
1件の最大文字数JavaScript の LIMIT(初期値 50)
リストの見出しJavaScript の TITLE(初期値 'RECENT'。'' で出さない)
検索窓の nameJavaScript の PARAM(初期値 's')
文字・枠線の色CSS の --sh-color --sh-sub --sh-light --sh-line

※ ブラウザのプライベートモードや、サイトのデータを消す設定にしていると、履歴は残らない。その場合もエラーにはならず、リストが出ないだけ。

※ 同じドメインで別のサイトを動かしている場合、履歴が混ざる。KEY を変えれば分けられる。

ちなみに

s は、検索窓の入力欄に付いている名札(name)。このブログの検索窓は次のようになっている。

<input type="text" name="s">

?s=css は「s という名札の欄に css が入っていた」という意味。WordPress はこの s を見て、css で検索した結果を出す。s は WordPress が決めた名前で、どの WordPress サイトでも同じ。

部分意味
name="s"入力欄の名札が s
?s=css名札 s の欄に css が入って送られてきた

今回の JavaScript も、この s で検索窓を見つけて、URL から検索した言葉を読んでいる。

表示環境によって動きの見え方が異なる場合があるため、本番に反映する前に確認してね。

NOTICES

  • 記事内容は実装させたものがほとんどですが自己責任で参考にしてください。

TO HEADER