フォーカス管理ユーティリティ
stimeo--focus
任意の領域にフォーカスの境界を作ります。Tab が中を巡り、抜けたら元へ戻ります。
ダイアログを作らなくても、好きな領域にフォーカスの境界を引けます。有効にしている間は Tab がその中を巡ります。開いたときに最初にフォーカスする場所も決められます。Esc で解除でき、解除したときは元いた場所へフォーカスが戻ります。境界の強さは 2 段階です。強くすると背景全体が触れなくなり、弱くすると Tab は巡りつつ背景にも到達できます。中の操作要素はそのつど数え直すので、あとから足した要素も次の Tab で拾われます。開閉やオーバーレイの表示はしないので、ダイアログと組み合わせて使います。
有効にして Tab を押すと、フォーカスが枠の中を巡ります。Esc か解除のボタンで抜けられます。
キーボード操作
| キー | 動作 |
|---|---|
| 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. The
// marker is a property, not an attribute: Turbo copies attributes into its page snapshot,
// so an attribute one comes back set on a restored page whose elements carry no listeners.
if (root.demoWired) return;
root.demoWired = true;
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 設定時、有効中に付与。 |