あふれ項目の退避メニュー
stimeo--overflow-menu
ツールバーの収まらない項目を More ドロップダウンへ退避(優先度低い順)し、空きが戻れば復帰。
stimeo--overflow-menu コントローラはツールバーの項目をコンテナ幅に収めます。ResizeObserver でコンテナを監視し、デバウンスした変化ごとに項目を実測して、収まらない分を優先度の低い順に More ドロップダウンへ退避し、空きが戻れば復帰させます。優先度は data-priority で読み(小さいほど長く残し、無印は最先に退避)、項目は複製せず移動するため、退避した項目にフォーカスがあれば追従します。メニューのアクセシビリティは Menu に委譲し、退避項目には role="menuitem" / tabindex="-1" / menu の item ターゲットを付与(authored の role / tabindex は退避時に保存し復帰時に復元)。あふれが無ければ More ボタンを hidden にします。コントローラ要素は data-overflowing / data-overflow-count を持ち、遷移ごとに change を発火します。挙動のみで装飾は持ちません。状態は毎回 DOM から判定する(モジュールスコープ状態なし)ため connect で Turbo morph 後も再同期し、ResizeObserver とデバウンスタイマは disconnect(Turbo 遷移含む)で解放します。項目を動的に追加した後は update アクションで再計算できます。
<%# Overflow-menu demo: drag the width slider to shrink the toolbar — the controller
measures the items and banks the lowest-priority ones into the More menu (delegated
to stimeo--menu), moving them back as space returns. Items carry the Menu item
data-action up front; it stays inert in the bar (no stimeo--menu ancestor there) and
activates once an item is banked into the More menu. The library moves items and sets
data-overflowing / data-overflow-count; demo.css owns the look. %>
<div class="overflow-demo">
<label class="overflow-demo__width">
<span><%= t("components.overflow_menu.demo.width") %></span>
<input type="range" min="220" max="640" value="640" data-overflow-demo-width>
</label>
<div
class="overflow-demo__bar"
data-controller="stimeo--overflow-menu"
role="toolbar"
aria-label="<%= t("components.overflow_menu.name") %>">
<div class="overflow-demo__items" data-stimeo--overflow-menu-target="items">
<% %w[save edit share archive delete].each_with_index do |id, i| %>
<button
type="button"
class="overflow-demo__item"
<%= "data-priority=\"#{i + 1}\"".html_safe if i < 3 %>
data-action="click->stimeo--menu#activate keydown->stimeo--menu#onItemKeydown">
<%= t("components.overflow_menu.demo.#{id}") %>
</button>
<% end %>
</div>
<div
class="overflow-demo__more"
data-controller="stimeo--menu"
data-stimeo--overflow-menu-target="more"
hidden>
<button
type="button"
class="overflow-demo__trigger"
aria-haspopup="menu"
aria-expanded="false"
data-stimeo--menu-target="trigger"
data-action="click->stimeo--menu#toggle keydown->stimeo--menu#onTriggerKeydown">
<%= t("components.overflow_menu.demo.more") %>
</button>
<ul class="overflow-demo__menu" role="menu" data-stimeo--menu-target="menu" hidden></ul>
</div>
</div>
</div>
/*
* Presentation-only styles for the overflow-menu demo. The library moves items between
* the bar and the More menu and sets data-overflowing / data-overflow-count; this CSS
* lays out the (clipped) toolbar, the items, the More button, and the dropdown.
*/
.overflow-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.overflow-demo__width {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-muted);
}
.overflow-demo__bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.overflow-demo__items {
display: flex;
gap: 0.5rem;
min-width: 0;
/* Clip the items row — not the whole bar — so overflow is real (items that do not
fit are what the controller banks), while the More dropdown (a sibling) can still
escape and is not cut off by the bar's bounds. */
overflow: hidden;
}
.overflow-demo__item,
.overflow-demo__trigger {
flex: none;
white-space: nowrap;
padding: 0.375rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface-subtle);
cursor: pointer;
}
.overflow-demo__more {
position: relative;
margin-left: auto;
}
.overflow-demo__menu {
position: absolute;
right: 0;
top: calc(100% + 0.25rem);
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 10rem;
margin: 0;
padding: 0.25rem;
list-style: none;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface-card);
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
}
/* Banked items become role="menuitem"; lay them out as full-width menu rows. */
.overflow-demo__menu .overflow-demo__item {
width: 100%;
text-align: left;
border: 0;
background: transparent;
}
.overflow-demo__menu .overflow-demo__item:hover,
.overflow-demo__menu .overflow-demo__item:focus {
background: var(--surface-subtle);
}
// Overflow-menu demo (consumer-side JS).
//
// No layout knobs are needed for the controller itself — it watches the bar with a
// ResizeObserver. This slider just changes the bar's width so the overflow can be seen
// happening; the controller reacts to the resize on its own.
document.querySelectorAll(".overflow-demo").forEach((root) => {
const bar = root.querySelector('[data-controller~="stimeo--overflow-menu"]');
const range = root.querySelector("[data-overflow-demo-width]");
if (!bar || !range) return;
const apply = () => {
bar.style.maxWidth = `${range.value}px`;
};
apply();
range.addEventListener("input", apply);
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--overflow-menu"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
items
必須
|
計測対象の並ぶ項目のコンテナ。 | data-stimeo--overflow-menu-target="items" |
more
必須
|
あふれ項目の退避先メニュー(Menu に委譲)。 | data-stimeo--overflow-menu-target="more" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
moreLabel
|
More トリガーのラベル。authored テキストが空のときのみ補完(既定 More)。 |
data-stimeo--overflow-menu-more-label-value |
debounce
|
幅変化の再計算デバウンス(ミリ秒、既定 100)。 | data-stimeo--overflow-menu-debounce-value |
アクション
| 名前 | アクション |
|---|---|
update
|
stimeo--overflow-menu#update |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
あふれの遷移ごとに発火。detail.visible / detail.hidden の件数を伴う。 |
stimeo--overflow-menu:change |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-overflowing |
コントローラ要素 | 1 件以上がメニューへ退避中に付与(true)。 |
data-overflow-count |
コントローラ要素 | 現在メニューへ退避している項目数。 |
hidden |
more ターゲット(More ボタン) | あふれが無いとき付与。 |