Overflow Menu
stimeo--overflow-menu
Moves toolbar items that no longer fit into a More dropdown (lowest priority first) and back as space returns.
The stimeo--overflow-menu controller keeps a toolbar's items within their container width: it watches the container with a ResizeObserver and, on each debounced change, measures the items and banks the lowest-priority ones into a More dropdown until the rest fit beside the button, moving them back as space returns. Priority is read from data-priority (lower is kept longer; items without one drop first), and items are moved — not cloned — so a focused item that retreats keeps focus. The menu's accessibility is delegated to Menu: banked items get role="menuitem", tabindex="-1", and the menu's item target, with any authored role / tabindex saved and restored on the way back. When nothing overflows the More button is hidden. The controller element carries data-overflowing and data-overflow-count, and a change event fires on each transition. Behavior only — no styling. State is derived from the DOM each pass (no module-scope state), so connect re-syncs after a Turbo morph; the ResizeObserver and debounce timer are released on disconnect (Turbo navigation included). Call the update action to re-measure after adding items dynamically.
<%# 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);
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--overflow-menu"
Targets
| Name | Description | Attribute |
|---|---|---|
items
required
|
The container of the inline items being measured. | data-stimeo--overflow-menu-target="items" |
more
required
|
The More menu (delegated to Menu) that overflowed items are banked into. | data-stimeo--overflow-menu-target="more" |
Values
| Name | Description | Attribute |
|---|---|---|
moreLabel
|
Label for the More trigger; fills it only when the authored text is empty (default More). |
data-stimeo--overflow-menu-more-label-value |
debounce
|
Milliseconds to debounce resize-driven recomputation (default 100). | data-stimeo--overflow-menu-debounce-value |
Actions
| Name | Action |
|---|---|
update
|
stimeo--overflow-menu#update |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires on each overflow transition, with detail.visible / detail.hidden counts. |
stimeo--overflow-menu:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-overflowing |
Controller element | Present (true) while one or more items are banked into the menu. |
data-overflow-count |
Controller element | The number of items currently banked into the menu. |
hidden |
more target (the More button) | Present when nothing overflows. |