フォーカス管理ユーティリティ
stimeo--focus
任意の領域に使えるフォーカス境界 — Tab 循環・初期フォーカス・復帰。
stimeo--focus コントローラは共有 FocusTrap を、任意の領域に宣言的に適用できるフォーカス境界として公開します(完全なモーダルを作らずに。Alpine focus / Headless UI のトラップ相当)。trap 有効時は Tab / Shift+Tab で領域内を循環し、auto なら初期ターゲット(または先頭のフォーカス可能要素)へフォーカスを当て、Escape で解除、restore なら解除時に発生元へ復帰します。inert を付けると残りのページを inert 化(モーダル相当の強い隔離)、付けなければソフト境界(Tab は循環するが背景には到達可能)です。要素は有効中 data-focus-trapped を持ち、activate / deactivate を発火します。挙動のみで、開閉やオーバーレイ表示は行わず(Dialog と組む)、DOM 移送もしません(Portal と組む)。共有 focus_trap util を再利用するため、モーダルオーバーレイと異なりページスクロールはロックせず、フォーカス可能な子要素は都度参照します(動的追加も次の Tab で拾う)。すべて disconnect(Turbo 遷移含む)でフォーカスを奪わずに解除します。
有効化して Tab を押すと、フォーカスが枠内を循環します。Escape か「解除」、または同じボタンの「無効化」で抜けます。
キーボード操作
| キー | 動作 |
|---|---|
| Tab / Shift+Tab | 領域内でフォーカスを循環(端で折り返し)。 |
| Escape | トラップを解除し発生元へフォーカスを復帰。 |
<%# Focus-scope demo: the Activate button sits outside the scope, so it invokes the
controller's activate action through the playground's exposed Stimulus app
(window.Stimulus) in demo.js. Once active, Tab cycles within the box, focus lands on
the initial target, and Escape or the Release button (a descendant, wired with a plain
data-action) ends it and restores focus. inert is left off so the soft boundary does
not isolate the rest of the catalog page. The library only manages focus and reflects
data-focus-trapped; demo.css owns the look. %>
<div class="focus-demo">
<button
type="button"
class="demo-trigger"
data-focus-demo-activate
aria-pressed="false"
data-activate-label="<%= t('components.focus.demo.activate') %>"
data-deactivate-label="<%= t('components.focus.demo.deactivate') %>">
<%= t("components.focus.demo.activate") %>
</button>
<div class="focus-demo__scope" data-controller="stimeo--focus">
<p class="focus-demo__hint"><%= t("components.focus.demo.hint") %></p>
<label class="focus-demo__field">
<span><%= t("components.focus.demo.field") %></span>
<input type="text" class="demo-input" data-stimeo--focus-target="initial" />
</label>
<div class="focus-demo__bar">
<button type="button" class="demo-trigger"><%= t("components.focus.demo.inner") %></button>
<button type="button" class="demo-trigger" data-action="click->stimeo--focus#deactivate">
<%= t("components.focus.demo.release") %>
</button>
</div>
</div>
</div>
/*
* Presentation-only styles for the focus-scope demo. The library manages focus and
* reflects data-focus-trapped; this CSS lays out the scope and highlights it while the
* trap is active so the boundary is visible.
*/
.focus-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.focus-demo__scope {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
max-width: 24rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
/* Make the active boundary obvious. */
.focus-demo__scope[data-focus-trapped] {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(var(--vital-rgb), 0.2);
}
.focus-demo__hint {
margin: 0;
color: var(--color-text-muted);
}
.focus-demo__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.focus-demo__field input {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
}
.focus-demo__bar {
display: flex;
gap: 0.5rem;
}
// Focus-scope demo (consumer-side JS).
//
// The Activate button sits outside the (trapped) scope, so a plain data-action can't
// reach the controller. The playground exposes its Stimulus application as
// window.Stimulus, so we fetch the controller instance and call its activate() action.
// Release (inside the scope) uses a normal data-action, and Escape works on its own.
document.querySelectorAll(".focus-demo").forEach((root) => {
const scope = root.querySelector('[data-controller~="stimeo--focus"]');
const button = root.querySelector("[data-focus-demo-activate]");
if (!scope || !button) return;
// Idempotent: Turbo can re-run this inline module on navigation; wire each root once.
if (root.dataset.demoWired) return;
root.dataset.demoWired = "1";
const controller = () =>
window.Stimulus?.getControllerForElementAndIdentifier(scope, "stimeo--focus");
// The external button toggles the scope on/off and stays in sync with the controller's
// activate/deactivate events, so its label is correct even when Escape or the inner
// Release ends the scope.
const sync = (active) => {
button.textContent = active ? button.dataset.deactivateLabel : button.dataset.activateLabel;
button.setAttribute("aria-pressed", String(active));
};
button.addEventListener("click", () => {
if (scope.hasAttribute("data-focus-trapped")) controller()?.deactivate();
else controller()?.activate();
});
scope.addEventListener("stimeo--focus:activate", () => sync(true));
scope.addEventListener("stimeo--focus:deactivate", () => sync(false));
sync(false);
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--focus"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
initial
|
有効化時にフォーカスする任意の要素(既定は先頭のフォーカス可能要素)。 | data-stimeo--focus-target="initial" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
trap
|
フォーカストラップを有効にするか(既定 false)。 |
data-stimeo--focus-trap-value |
auto
|
有効化時に初期フォーカスを当てるか(既定 true)。 |
data-stimeo--focus-auto-value |
restore
|
解除時に発生元へ復帰するか(既定 true)。 |
data-stimeo--focus-restore-value |
inert
|
有効中に背景を inert 化して強く隔離するか(既定 false)。 |
data-stimeo--focus-inert-value |
アクション
| 名前 | アクション |
|---|---|
activate
|
stimeo--focus#activate |
deactivate
|
stimeo--focus#deactivate |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
activate
|
トラップ有効化時に発火。 | stimeo--focus:activate |
deactivate
|
トラップ解除時に発火。 | stimeo--focus:deactivate |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-focus-trapped |
コントローラ要素 | トラップ有効中に付与(true)。 |
inert |
背景の兄弟要素 | inert Value 設定時、有効中に付与。 |