ポップオーバー
stimeo--popover
トリガーで開閉する非モーダルの浮遊パネル。フォーカス移動と dismiss を肩代わりする。
stimeo--popover コントローラは、WAI-ARIA の Dialog パターンを非モーダルで運用する。 aria-modal・フォーカストラップ・スクロールロックを行わないため背景は操作可能なまま。トリガークリックで aria-expanded と panel の hidden を同期し、開いたときはパネル内の最初のフォーカス可能要素へフォーカスを移す。Escape と外側クリックで閉じてトリガーへフォーカスを復帰し、Tab でパネル外へフォーカスが出た場合はフォーカスを戻さず閉じる。静的な配置はこの Playground の CSS が持ち、画面端での動的フリップ/シフトは opt-in の stimeo-ui/positioning に委譲する。ライブラリは振る舞いのみを提供する。
キーボード操作
| キー | 動作 |
|---|---|
| Enter / Space | トリガーにフォーカスがある状態で開閉する(ネイティブ button)。 |
| Esc | ポップオーバーを閉じてトリガーへフォーカスを戻す。 |
| Tab / Shift+Tab | パネル内を移動。フォーカスがパネル外へ出ると閉じる。 |
<%# Markup for the popover demo.
A non-modal floating panel toggled by a trigger click. The panel is role="dialog"
with no aria-modal (the background stays interactive). Static placement lives in
this Playground's CSS; the library only handles open/close, focus, and dismiss. %>
<div class="popover" data-controller="stimeo--popover">
<button
class="demo-trigger"
type="button"
data-stimeo--popover-target="trigger"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="popover-panel"
data-action="click->stimeo--popover#toggle">
<%= t("components.popover.demo.trigger") %>
</button>
<div
class="popover__panel"
id="popover-panel"
role="dialog"
aria-label="<%= t("components.popover.demo.title") %>"
data-stimeo--popover-target="panel"
hidden>
<label class="popover__field">
<%= t("components.popover.demo.name_label") %>
<input type="text" name="display_name" />
</label>
<button
class="demo-trigger"
type="button"
data-action="click->stimeo--popover#close">
<%= t("components.popover.demo.done") %>
</button>
</div>
</div>
/*
* Presentation-only styles for the popover demo.
* The library shows/hides the panel by toggling its hidden attribute. Placement
* (directly below the trigger) is static and the consumer's CSS responsibility;
* connect the opt-in stimeo-ui/positioning module if you need dynamic flip/shift.
*/
.popover {
position: relative;
display: inline-block;
}
.popover__panel {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
z-index: 10;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
/* Setting display: flex overrides the hidden attribute's default display:none, so
re-declare display:none while closed to honor the library's hidden toggle. */
.popover__panel[hidden] {
display: none;
}
.popover__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--fg);
}
.popover__field input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
font: inherit;
}
// popover opt-in positioning demo (consumer-side JS).
//
// The core controller (stimeo--popover) stays zero-dep and only handles open/close,
// focus, and dismiss. Viewport-edge flip/shift (collision avoidance) is optionally
// connected to the opt-in stimeo-ui/positioning module (@floating-ui/dom based) only
// when the consumer needs it. Here we watch the panel's hidden attribute: on open,
// start coordinate tracking with attachPositioning; on close, detach and clear the
// inline coordinates (falling back to demo.css's static placement).
import { attachPositioning } from 'stimeo-ui/positioning';
document.querySelectorAll('[data-controller~="stimeo--popover"]').forEach((root) => {
const trigger = root.querySelector('[data-stimeo--popover-target="trigger"]');
const panel = root.querySelector('[data-stimeo--popover-target="panel"]');
if (!trigger || !panel) return;
let detach = null;
const sync = () => {
if (!panel.hidden && !detach) {
// Opened: start tracking relative to the trigger (offset for the gap, flip/shift to
// avoid edges).
detach = attachPositioning(trigger, panel, {
placement: 'bottom-start',
offset: 8,
padding: 8,
});
} else if (panel.hidden && detach) {
// Closed: stop tracking, clear the inline coordinates, and return to the static CSS
// placement.
detach();
detach = null;
panel.style.position = panel.style.left = panel.style.top = '';
}
};
new MutationObserver(sync).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
sync();
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--popover"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
trigger
必須
|
ポップオーバーを開閉するボタン。aria-haspopup/aria-expanded を持ち、閉じるとフォーカスがここへ戻る。 |
data-stimeo--popover-target="trigger" |
panel
必須
|
hidden で表示制御する非モーダルの role="dialog" パネル。開くとフォーカスが内側へ移るが閉じ込めない。 |
data-stimeo--popover-target="panel" |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
close
|
パネルを閉じ、トリガーに折りたたみ状態を反映する。 | stimeo--popover#close |
open
|
パネルを開いて状態を反映し、内側の最初のフォーカス可能要素(なければパネル自身)へフォーカスを移す。 | stimeo--popover#open |
toggle
|
ポップオーバーの開閉をトグルする。 | stimeo--popover#toggle |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-expanded |
トリガー | 開いている間は "true"、閉じている間は "false"。 |
hidden |
パネル | 開で属性なし、閉で付与。 |