要素の移送
stimeo--portal
connect で要素を別の DOM 位置(例: body 直下)へ移送し、disconnect で後始末する。
stimeo--portal コントローラは要素を別の DOM 位置へ移送します。祖先の overflow: hidden・transform・重なり文脈を抜け出す必要があるオーバーレイの共有土台です。connect で content ターゲット(無ければ this.element)を to(既定 body)に一致する最初の要素へ、position に従って append / prepend で移送し、原位置にコメントプレースホルダを残します。disconnect では restore が真なら原位置へ戻し、そうでなければ除去するため、孤児ノードを残しません(Turbo 遷移含む)。移送ノードは data-portaled を持ち、宛先と共に mount / unmount を発火します。挙動のみで配置計算は持たず(stimeo-ui/positioning と組む)、フォーカストラップも行いません(Focus Scope / 各オーバーレイと組む)。Turbo では content ターゲット形を推奨し、コントローラが原位置の source に残ることで disconnect が確実に発火します。移送はプレースホルダで冪等化され、観測器が要素移動時に出す connect/disconnect の churn にも耐えます。
カードは connect で <body> へ移送されるため、この切り取られたボックスを抜け出してビューポート隅に浮きます。
用途: overflow: hidden や z-index / transform に阻まれるツールチップ/ポップオーバー/モーダルを <body> 直下へ逃がし、クリップや重なり順の問題を避けます。
<%# Portal demo: the card is a content target, so on connect the controller teleports it
out of the dashed (overflow:hidden) source box and into <body>, where demo.css floats
it at the viewport corner — visibly escaping the clipping ancestor. demo.js reflects
the mount destination into the status line. On disconnect (e.g. Turbo navigation) the
card is restored, leaving no orphan. The library only moves the node and sets
data-portaled; demo.css owns the look. %>
<div class="portal-demo">
<div
class="portal-demo__source"
data-controller="stimeo--portal"
data-stimeo--portal-to-value="body">
<p class="portal-demo__hint"><%= t("components.portal.demo.hint") %></p>
<div class="portal-demo__card" data-stimeo--portal-target="content">
<%= t("components.portal.demo.card") %>
</div>
</div>
<p
class="portal-demo__status"
role="status"
data-portal-status
data-mounted-label="<%= t("components.portal.demo.mounted") %>"></p>
<p class="portal-demo__note"><%= t("components.portal.demo.note") %></p>
</div>
/*
* Presentation-only styles for the portal demo. The library moves the card to <body>
* and sets data-portaled; this CSS clips the source box (to show the card escaping it)
* and floats the teleported card at the viewport corner.
*/
.portal-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.portal-demo__source {
padding: 1rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
overflow: hidden;
}
.portal-demo__hint {
margin: 0;
color: var(--color-text-muted);
}
/* Teleported to <body>: pinned to the viewport corner, out of the clipped box. */
.portal-demo__card {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 50;
max-width: 16rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: var(--color-text);
color: var(--surface-page);
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.25);
}
.portal-demo__status {
margin: 0;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.portal-demo__note {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
// Portal demo (consumer-side JS).
//
// The controller teleports the card to <body> on connect; this JS only reflects where it
// landed into a status line. It both listens for the mount event and, in case connect
// already fired before this module ran, reports the current state once on load.
document.querySelectorAll(".portal-demo").forEach((root) => {
const status = root.querySelector("[data-portal-status]");
const source = root.querySelector('[data-controller="stimeo--portal"]');
if (!status || !source) return;
const label = status.dataset.mountedLabel ?? "Mounted into";
const report = (target) => {
status.textContent = `${label} <${(target?.tagName ?? "body").toLowerCase()}>`;
};
source.addEventListener("stimeo--portal:mount", (event) => report(event.detail.target));
// If the content has already left the source, the mount event fired before this ran.
if (!source.querySelector('[data-stimeo--portal-target="content"]')) {
report(document.body);
}
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--portal"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
content
|
移送する任意のノード。無指定ならコントローラ要素を移送する。 | data-stimeo--portal-target="content" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
to
|
移送先の CSS セレクタ(既定 body)。 |
data-stimeo--portal-to-value |
position
|
移送先での append(末尾)/ prepend(先頭)(既定 append)。 |
data-stimeo--portal-position-value |
restore
|
disconnect で原位置へ戻すか、否なら除去(既定 true)。 |
data-stimeo--portal-restore-value |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
mount
|
宛先へ挿入した後に発火。detail.target を伴う。 |
stimeo--portal:mount |
unmount
|
原位置へ戻す / 除去したとき発火。 | stimeo--portal:unmount |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-portaled |
移送されたノード | 移送中に付与(true)。 |