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)で、右側の詳細だけ更新します。
読み込み中…
左の行を選択すると、ここに詳細が表示されます。
解説
- 一覧の tr に hx-get を付け、行クリックで詳細断片(PHP)を取得します。
- hx-target="#DEMO_DETAIL" により、右側パネル(詳細表示)だけ差し替えます。
- 詳細の「編集」も hx-get で右側だけフォームに切り替え、保存は hx-post で右側を「保存結果+詳細表示」に更新します。
- 保存後は hx-swap-oob を使うと、左一覧の該当行(id="ROW-xxx")も同時に更新できます。
- このとき tr は単体だとHTML断片として崩れやすいため、OOBで返す行は <table><tbody>...</tbody></table> で包んだ“文法的に完全な断片”として返すと安定します。
次に読むオススメレシピ
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール