htmx 逆引きレシピ
一覧の行をクリックして右側に詳細を表示するには?

公開日:
最終更新日:

このレシピでは、業務Webアプリで定番の「左:一覧(table)/右:詳細」UIを、htmxで軽量に実装します。
一覧の行クリックで右側だけ更新し、画面遷移なしで簡易SPA風の操作感を作れます。

さらに右側は「詳細→編集→保存→詳細に戻す」まで右領域だけで完結。
保存後はhx-swap-oobで左一覧の該当行も同時更新し、リロード不要で反映されます。

使用するhtmx属性

  • hx-get:行クリックで詳細断片を取得/編集フォームへ切替
  • hx-post:保存処理(SESSIONの疑似テーブル更新)
  • hx-target:右側パネルだけ更新
  • hx-swap:差し替え方法(基本はinnerHTML)
  • hx-trigger:clickに加えEnterでも行選択できるようにする
  • hx-indicator:通信中のローディング表示
  • hx-disabled-elt:保存中のボタン無効化
  • hx-swap-oob:保存後に左一覧の行も同時更新

利用シーン

  • 管理画面の「一覧→詳細」UI(取引先/ユーザー/案件など)
  • 画面遷移を減らして作業効率を上げたい業務UI
  • SPAほど重くしたくないが“右側だけ更新”の操作感が欲しいとき
  • 保存後に一覧へ即反映したい(OOBで行更新)

一覧の行をクリックして右側に詳細を表示するには?

左の一覧(table)の行をクリックすると、右側の詳細だけを更新します。
画面遷移なしで「一覧を見ながら詳細確認」ができる、業務UI定番の動きです。

HTML

<?php
// session開始
session_start();

// 疑似テーブルが無ければ初期データを投入する
if (!isset($_SESSION['SPLIT_TABLE']) || !is_array($_SESSION['SPLIT_TABLE'])) {
	// デモ用の疑似テーブル(本番ならDB)
	$_SESSION['SPLIT_TABLE'] = [
		101 => ['id'=>101,'code'=>'C-101','name'=>'株式会社みかん商事','status'=>'稼働','tel'=>'03-0000-0101','memo'=>'請求締め:月末 / 支払:翌月末'],
		102 => ['id'=>102,'code'=>'C-102','name'=>'有限会社モチモチ運輸','status'=>'要確認','tel'=>'06-0000-0102','memo'=>'配送依頼は前日17時まで'],
		103 => ['id'=>103,'code'=>'C-103','name'=>'みかんシステムズ','status'=>'停止','tel'=>'052-0000-0103','memo'=>'担当:佐藤(情シス)'],
	];
}

// HTMLエスケープ関数を用意する
$h = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');

// ステータスに応じたPILLクラスを返す
$statusClass = static function(string $status): string {
	// 稼働はOK色
	if ($status === '稼働') return 'is-ok';
	// 要確認は注意色
	if ($status === '要確認') return 'is-warn';
	// それ以外は素のPILL
	return '';
};

// 一覧行のHTMLを組み立てる
$rowsHtml = '';

// 疑似テーブルを取り出す
$table = $_SESSION['SPLIT_TABLE'];

// 1行ずつtrを作る
foreach ($table as $id => $row) {
	// idを数値にそろえる
	$id = (int)$id;

	// 行のHTMLを追加する
	$rowsHtml .=
		'<tr'
		. ' id="ROW-' . $id . '"'
		. ' class="TABLE-ROW"'
		. ' tabindex="0"'
		. ' role="button"'
		. ' aria-controls="DEMO_DETAIL"'
		. ' hx-get="/htmx/demo/_split_detail.php?id=' . $id . '"'
		. ' hx-target="#DEMO_DETAIL"'
		. ' hx-swap="innerHTML"'
		. ' hx-indicator="#LOADER_DEMO"'
		. ' hx-trigger="click, keyup[key==\'Enter\']"'
		. '>'
		. '<td class="code">' . $h($row['code']) . '</td>'
		. '<td class="name">' . $h($row['name']) . '</td>'
		. '<td><span class="PILL ' . $h($statusClass((string)$row['status'])) . '">' . $h($row['status']) . '</span></td>'
		. '</tr>';
}
?>
<div class="DEMO">

	<h4>一覧の行をクリックして右側に詳細を表示するには?</h4>

	<div id="LOADER_DEMO" class="htmx-indicator" role="status" aria-live="polite" aria-label="読み込み中"></div>

	<section class="DEMO-SPLIT GRID GRIDCOL2">

		<!-- 左:一覧 -->
		<div class="DEMO-LIST">

			<div class="TABLE_WRAPPER">
			<table class="TABLE">
				<thead>
					<tr>
						<th class="W75">コード</th>
						<th>名称</th>
						<th class="W90">状態</th>
					</tr>
				</thead>

				<tbody>
					<?php echo $rowsHtml; ?>
				</tbody>
			</table>
			</div>

			<p class="muted">行クリック(またはEnter)で、右側の詳細だけ更新します。</p>

		</div>

		<!-- 右:詳細 -->
		<div class="DEMO-DETAIL">

			<div id="DEMO_DETAIL_LOADING" class="LOADING htmx-indicator" aria-live="polite">
				読み込み中…
			</div>

			<div id="DEMO_DETAIL" class="DETAIL-BOX">
				<p class="muted">左の行を選択すると、ここに詳細が表示されます。</p>
			</div>

		</div>

	</section>

</div>

CSS

.SECTION h3{padding-top:0;}
.TABLE{width:100%;border-collapse:collapse}
.TABLE th,.TABLE td{border-bottom:1px solid rgba(0,0,0,.08);padding:.6rem .7rem}
.TABLE-ROW{cursor:pointer}
.TABLE-ROW:hover{background:rgba(0,0,0,.03)}
.TABLE-ROW.is-active{background:rgba(255,140,0,.12)}
.TABLE-ROW.is-active>td:first-child{box-shadow:inset 4px 0 0 rgba(255,140,0,.9)}
.TABLE-ROW:focus{outline:2px solid rgba(255,140,0,.7);outline-offset:-2px}
.DETAIL-BOX{padding:1rem}
.LOADING{display:none;margin:.4rem 0 .8rem;color:rgba(0,0,0,.6)}
.htmx-request #DEMO_DETAIL_LOADING{display:block}
.FORM label{display:block;margin:.75rem 0}
.FORM input,.FORM textarea{width:100%;padding:.55rem .65rem;border:1px solid rgba(0,0,0,.14);border-radius:.5rem}
.FORM-ACTIONS{display:flex;gap:.6rem;margin-top:.8rem}
.FORM-RESULT{margin:.3rem 0 .9rem;padding:.5rem .65rem;border-radius:.5rem;background:rgba(0,160,80,.12)}
.DETAIL-ERR{margin:.3rem 0 .9rem;padding:.5rem .65rem;border-radius:.5rem;background:rgba(255,0,0,.10)}
.DEMO-DETAIL{background-color:#fff; border:#000 2px solid;}
#LOADER_DEMO{
	display: flex;
	align-items: center;
	justify-content: center;

	position: fixed;
	inset: 0;

	opacity: 0;
	visibility: hidden;
	pointer-events: none;

	transition: opacity .15s ease;
	z-index: 9999;
}

/* htmx通信中だけ表示 */
#LOADER_DEMO.htmx-request{
	opacity: 1;
	visibility: visible;
	pointer-events: auto;
}

#LOADER_DEMO::before{
	content: "";
	position: absolute;
	inset: 0;
	background: rgba(0,0,0,0.35);
}

#LOADER_DEMO::after{
	content: "";
	position: relative;
	z-index: 1;

	display: block;
	flex: 0 0 250px;
	width: 250px;
	height: 250px;

	background-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20width%3D%2248%22%20height%3D%2248%22%20fill%3D%22%23ffffff%22%3E%20%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%220%22%3E%20%3Canimate%20attributeName%3D%22opacity%22%20values%3D%220%3B1%3B0%22%20keyTimes%3D%220%3B.05%3B1%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3Canimate%20attributeName%3D%22r%22%20from%3D%220%22%20to%3D%2212%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3C%2Fcircle%3E%20%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%220%22%3E%20%3Canimate%20attributeName%3D%22opacity%22%20values%3D%220%3B1%3B0%22%20keyTimes%3D%220%3B.05%3B1%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20begin%3D%22.3s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3Canimate%20attributeName%3D%22r%22%20from%3D%220%22%20to%3D%2212%22%20dur%3D%221s%22%20begin%3D%22.3s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3C%2Fcircle%3E%20%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%220%22%3E%20%3Canimate%20attributeName%3D%22opacity%22%20values%3D%220%3B1%3B0%22%20keyTimes%3D%220%3B.05%3B1%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20begin%3D%22.6s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3Canimate%20attributeName%3D%22r%22%20from%3D%220%22%20to%3D%2212%22%20dur%3D%221s%22%20begin%3D%22.6s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3C%2Fcircle%3E%3C%2Fsvg%3E');
	background-repeat: no-repeat;
	background-position: center;
	background-size: 100% 100%;
}

JavaScript

document.body.addEventListener('htmx:beforeRequest', (event) => {
	const elt = event.detail.elt;
	const tr = elt && elt.closest ? elt.closest('tr.TABLE-ROW') : null;
	if (!tr) return;

	const table = tr.closest('table');
	if (!table) return;

	table.querySelectorAll('tr.TABLE-ROW.is-active').forEach((row) => {
		row.classList.remove('is-active');
	});

	tr.classList.add('is-active');
});

PHP(詳細/_split_detail.php)

<?php declare(strict_types=1);

// 文字化け防止のため、文字コードを含むContent-Typeを返す
header('Content-Type: text/html; charset=UTF-8');

// セッションを開始する
session_start();

// 疑似テーブルが無ければ初期データを投入する
if (!isset($_SESSION['SPLIT_TABLE']) || !is_array($_SESSION['SPLIT_TABLE'])) {
	// デモ用の疑似テーブル(本番ならDB)
	$_SESSION['SPLIT_TABLE'] = [
		101 => ['id'=>101,'code'=>'C-101','name'=>'株式会社みかん商事','status'=>'稼働','tel'=>'03-0000-0101','memo'=>'請求締め:月末 / 支払:翌月末'],
		102 => ['id'=>102,'code'=>'C-102','name'=>'有限会社モチモチ運輸','status'=>'要確認','tel'=>'06-0000-0102','memo'=>'配送依頼は前日17時まで'],
		103 => ['id'=>103,'code'=>'C-103','name'=>'みかんシステムズ','status'=>'停止','tel'=>'052-0000-0103','memo'=>'担当:佐藤(情シス)'],
	];
}

// id(数値)をGETから取得する
$id = (int)($_GET['id'] ?? 0);

// HTMLエスケープ関数を用意する
$h = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');

// 疑似テーブルを取り出す
$table = $_SESSION['SPLIT_TABLE'];

// 対象データが存在しない場合はエラー断片を返す
if ($id <= 0 || !isset($table[$id])) {
	// エラー断片を返す
	echo '<div class="DETAIL-ERR"><strong>対象が見つかりません。</strong></div>';
	// ここで処理を終了する
	exit;
}

// 対象行を取り出す
$row = $table[$id];

// デモ用に600ms遅延
usleep(600000);
?>
<div class="DETAIL">

	<h3><?php echo $h($row['name']); ?></h3>

	<dl class="DETAIL-DL">
		<div><dt>取引先コード</dt><dd><?php echo $h($row['code']); ?></dd></div>
		<div><dt>状態</dt><dd><?php echo $h($row['status']); ?></dd></div>
		<div><dt>電話</dt><dd><?php echo $h($row['tel']); ?></dd></div>
		<div><dt>メモ</dt><dd><?php echo $h($row['memo']); ?></dd></div>
	</dl>

	<div class="DETAIL-ACTIONS">
		<button
			type="button"
			class="BTN is-sub"
			hx-get="/htmx/demo/_split_edit.php?id=<?php echo (int)$id; ?>"
			hx-target="#DEMO_DETAIL"
			hx-swap="innerHTML"
			hx-indicator="#DEMO_DETAIL_LOADING"
		>
			編集
		</button>
	</div>
</div>

PHP(編集/_split_edit.php)

<?php declare(strict_types=1);

// 文字化け防止のため、文字コードを含むContent-Typeを返す
header('Content-Type: text/html; charset=UTF-8');

// セッションを開始する
session_start();

// 疑似テーブルが無ければ初期データを投入する
if (!isset($_SESSION['SPLIT_TABLE']) || !is_array($_SESSION['SPLIT_TABLE'])) {
	// デモ用の疑似テーブル(本番ならDB)
	$_SESSION['SPLIT_TABLE'] = [
		101 => ['id'=>101,'code'=>'C-101','name'=>'株式会社みかん商事','status'=>'稼働','tel'=>'03-0000-0101','memo'=>'請求締め:月末 / 支払:翌月末'],
		102 => ['id'=>102,'code'=>'C-102','name'=>'有限会社モチモチ運輸','status'=>'要確認','tel'=>'06-0000-0102','memo'=>'配送依頼は前日17時まで'],
		103 => ['id'=>103,'code'=>'C-103','name'=>'みかんシステムズ','status'=>'停止','tel'=>'052-0000-0103','memo'=>'担当:佐藤(情シス)'],
	];
}

// id(数値)をGETから取得する
$id = (int)($_GET['id'] ?? 0);

// HTMLエスケープ関数を用意する
$h = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');

// 疑似テーブルを取り出す
$table = $_SESSION['SPLIT_TABLE'];

// 対象データが存在しない場合はエラー断片を返す
if ($id <= 0 || !isset($table[$id])) {
	// エラー断片を返す
	echo '<div class="DETAIL-ERR"><strong>対象が見つかりません。</strong></div>';
	// ここで処理を終了する
	exit;
}

// 対象行を取り出す
$row = $table[$id];

?>
<form
	class="FORM"
	hx-post="/htmx/demo/_split_save.php"
	hx-target="#DEMO_DETAIL"
	hx-swap="innerHTML"
	hx-indicator="#DEMO_DETAIL_LOADING"
	hx-disabled-elt="#DEMO_SAVE_BTN"
>
	<input type="hidden" name="id" value="<?php echo (int)$id; ?>">

	<h3>編集:<?php echo $h($row['name']); ?></h3>

	<label>
		<span>名称(必須)</span>
		<input type="text" name="name" value="<?php echo $h($row['name']); ?>" required>
	</label>

	<label>
		<span>電話</span>
		<input type="text" name="tel" value="<?php echo $h($row['tel']); ?>">
	</label>

	<label>
		<span>メモ</span>
		<textarea name="memo" rows="3"><?php echo $h($row['memo']); ?></textarea>
	</label>

	<div class="FORM-ACTIONS">
		<button id="DEMO_SAVE_BTN" type="submit" class="BTN">保存</button>

		<button
			type="button"
			class="BTN is-sub"
			hx-get="/htmx/demo/_split_detail.php?id=<?php echo (int)$id; ?>"
			hx-target="#DEMO_DETAIL"
			hx-swap="innerHTML"
			hx-indicator="#DEMO_DETAIL_LOADING"
		>
			キャンセル
		</button>
	</div>
</form>

PHP(保存/_split_save.php)

<?php declare(strict_types=1);

// 文字化け防止のため、文字コードを含むContent-Typeを返す
header('Content-Type: text/html; charset=UTF-8');

// セッションを開始する
session_start();

// 疑似テーブルが無ければ初期データを投入する
if (!isset($_SESSION['SPLIT_TABLE']) || !is_array($_SESSION['SPLIT_TABLE'])) {
	// デモ用の疑似テーブル(本番ならDB)
	$_SESSION['SPLIT_TABLE'] = [
		101 => ['id'=>101,'code'=>'C-101','name'=>'株式会社みかん商事','status'=>'稼働','tel'=>'03-0000-0101','memo'=>'請求締め:月末 / 支払:翌月末'],
		102 => ['id'=>102,'code'=>'C-102','name'=>'有限会社モチモチ運輸','status'=>'要確認','tel'=>'06-0000-0102','memo'=>'配送依頼は前日17時まで'],
		103 => ['id'=>103,'code'=>'C-103','name'=>'みかんシステムズ','status'=>'停止','tel'=>'052-0000-0103','memo'=>'担当:佐藤(情シス)'],
	];
}

// id(数値)をPOSTから取得する
$id = (int)($_POST['id'] ?? 0);

// 名称をPOSTから取得する
$name = (string)($_POST['name'] ?? '');

// 電話をPOSTから取得する
$tel = (string)($_POST['tel'] ?? '');

// メモをPOSTから取得する
$memo = (string)($_POST['memo'] ?? '');

// 名称の前後空白を除去する
$name = trim($name);

// HTMLエスケープ関数を用意する
$h = static fn($s) => htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');

// 疑似テーブルを取り出す
$table = $_SESSION['SPLIT_TABLE'];

// 対象が無い場合はエラー断片を返す
if ($id <= 0 || !isset($table[$id])) {
	// エラー断片を返す
	echo '<div class="DETAIL-ERR"><strong>対象が見つかりません。</strong></div>';
	// ここで処理を終了する
	exit;
}

// 必須チェックに引っかかった場合は“フォームを返す”(入力保持)
if ($name === '') {
	// 現在の行を取り出す
	$row = $table[$id];

	// エラー付きフォームを返す
	?>
	<form
		class="FORM"
		hx-post="/htmx/demo/_split_save.php"
		hx-target="#DEMO_DETAIL"
		hx-swap="innerHTML"
		hx-indicator="#DEMO_DETAIL_LOADING"
		hx-disabled-elt="#DEMO_SAVE_BTN"
	>
		<input type="hidden" name="id" value="<?php echo (int)$id; ?>">

		<div class="DETAIL-ERR"><strong>入力エラー:</strong>名称は必須です。</div>

		<h3>編集:<?php echo $h($row['name']); ?></h3>

		<label>
			<span>名称(必須)</span>
			<input type="text" name="name" value="<?php echo $h($name); ?>" required>
		</label>

		<label>
			<span>電話</span>
			<input type="text" name="tel" value="<?php echo $h($tel); ?>">
		</label>

		<label>
			<span>メモ</span>
			<textarea name="memo" rows="3"><?php echo $h($memo); ?></textarea>
		</label>

		<div class="FORM-ACTIONS">
			<button id="DEMO_SAVE_BTN" type="submit" class="BTN">保存</button>

			<button
				type="button"
				class="BTN is-sub"
				hx-get="/htmx/demo/_split_detail.php?id=<?php echo (int)$id; ?>"
				hx-target="#DEMO_DETAIL"
				hx-swap="innerHTML"
				hx-indicator="#DEMO_DETAIL_LOADING"
			>
				キャンセル
			</button>
		</div>
	</form>
	<?php
	// ここで処理を終了する
	exit;
}

// 更新対象の行を取り出す
$row = $table[$id];

// SESSIONの疑似テーブルを更新する
$row['name'] = $name;

// SESSIONの疑似テーブルを更新する
$row['tel'] = $tel;

// SESSIONの疑似テーブルを更新する
$row['memo'] = $memo;

// 更新後の行をSESSIONへ戻す
$_SESSION['SPLIT_TABLE'][$id] = $row;

// ステータスに応じたPILLクラスを返す
$statusClass = static function(string $status): string {
	// 稼働はOK色
	if ($status === '稼働') return 'is-ok';
	// 要確認は注意色
	if ($status === '要確認') return 'is-warn';
	// それ以外は素のPILL
	return '';
};

?>
<div class="DETAIL">
	<div class="FORM-RESULT"><strong>保存しました。</strong></div>

	<h3><?php echo $h($row['name']); ?></h3>

	<dl class="DETAIL-DL">
		<div><dt>取引先コード</dt><dd><?php echo $h($row['code']); ?></dd></div>
		<div><dt>状態</dt><dd><?php echo $h($row['status']); ?></dd></div>
		<div><dt>電話</dt><dd><?php echo $h($row['tel']); ?></dd></div>
		<div><dt>メモ</dt><dd><?php echo $h($row['memo']); ?></dd></div>
	</dl>

	<div class="DETAIL-ACTIONS">
		<button
			type="button"
			class="BTN is-sub"
			hx-get="/htmx/demo/_split_edit.php?id=<?php echo (int)$id; ?>"
			hx-target="#DEMO_DETAIL"
			hx-swap="innerHTML"
			hx-indicator="#DEMO_DETAIL_LOADING"
		>
			編集
		</button>
	</div>
</div>

<!-- 左一覧:該当行を同時更新(OOB) -->
<table><tbody>
<tr
	id="ROW-<?php echo (int)$id; ?>"
	hx-swap-oob="outerHTML"
	class="TABLE-ROW is-active"
	tabindex="0"
	role="button"
	aria-controls="DEMO_DETAIL"
	hx-get="/htmx/demo/_split_detail.php?id=<?php echo (int)$id; ?>"
	hx-target="#DEMO_DETAIL"
	hx-swap="innerHTML"
	hx-indicator="#LOADER"
	hx-trigger="click, keyup[key=='Enter']"
>
	<td class="code"><?php echo $h($row['code']); ?></td>
	<td class="name"><?php echo $h($row['name']); ?></td>
	<td><span class="PILL <?php echo $h($statusClass((string)$row['status'])); ?>"><?php echo $h($row['status']); ?></span></td>
</tr>
</tbody></table>

デモ

一覧の行をクリックして右側に詳細を表示するには?

コード 名称 状態
C-101株式会社みかん商事稼働
C-102有限会社モチモチ運輸要確認
C-103みかんシステムズ停止

行クリック(またはEnter)で、右側の詳細だけ更新します。

読み込み中…

左の行を選択すると、ここに詳細が表示されます。

解説

  • 一覧の trhx-get を付け、行クリックで詳細断片(PHP)を取得します。
  • hx-target="#DEMO_DETAIL" により、右側パネル(詳細表示)だけ差し替えます。
  • 詳細の「編集」も hx-get で右側だけフォームに切り替え、保存は hx-post で右側を「保存結果+詳細表示」に更新します。
  • 保存後は hx-swap-oob を使うと、左一覧の該当行(id="ROW-xxx")も同時に更新できます。
  • このとき tr は単体だとHTML断片として崩れやすいため、OOBで返す行は <table><tbody>...</tbody></table> で包んだ“文法的に完全な断片”として返すと安定します。

次に読むオススメレシピ

このページの著者

もちもちみかん(システムエンジニア)

社内SEとしてグループ企業向けの業務アプリを要件定義〜運用まで一気通貫で担当しています。

経験:Webアプリ/業務システム

得意:PHP・JavaScript・MySQL・CSS

個人実績:フォーム生成基盤クイズ学習プラットフォーム

詳しいプロフィールはこちら!  もちもちみかんのプロフィール

もちもちみかん0系くん
TOPへ

もちもちみかん.comとは


このサイトでは、コーディングがめんどうくさい人向けのお助けツールとして、フォームやCSSをノーコードで生成できる、
 もちもちみかん.forms
 もちもちみかん.css1
 もちもちみかん.css2
と言ったジェネレーターを用意してます。

また、このサイトを通じて、「もちもちみかん」のかわいさを普及したいとかんがえてます!