Overflow Menu
stimeo--overflow-menu
Moves toolbar items that no longer fit into a More menu, and back again when the width returns.
Lays out a toolbar's items as far as the width allows. Whatever does not fit moves into a More menu and comes back when the width returns. Which items go first follows the priority you gave them, lowest first, so the important buttons stay visible longest. A moved item still works from inside the menu with no setup of its own, and an item you had deliberately hidden is never brought out. Being a menu is delegated to the menu part, so the keyboard behaves exactly as it does there. The measuring happens whenever the width changes, with small jitters smoothed out.
Keyboard
| Key | Action |
|---|---|
| Enter / Space / ↓ | Open the More menu and focus the first item. |
| ↑ | Open the More menu and focus the last item. |
| ↓ / ↑ | Move focus between items (wrapping). |
| Home / End | Focus the first / last item. |
| Esc | Close the menu and return focus to the More button. |
<%# 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. The items need no data-action of
their own: Menu handles clicks and keys from its own element, so an item becomes
operable the moment it lands in the menu. Only the More trigger is bound. The library
moves items and sets data-overflowing / data-overflow-count; demo.css owns the look. %>
<div class="overflow-demo">
<label class="demo-width-control">
<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"
<%# A group, not a toolbar: these buttons stay in the normal Tab order. The
single-tab-stop roving an APG Toolbar implies belongs to stimeo--toolbar,
which this controller does not provide. %>
role="group"
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="demo-menuitem overflow-demo__item"
<%= "data-priority=\"#{i + 1}\"".html_safe if i < 3 %>>
<%= 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
id="overflow-menu-trigger"
type="button"
class="demo-trigger 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>
<%# A div, not a ul: the controller appends the items themselves, and a ul may only
hold li children. One container shape keeps the markup contract unambiguous. %>
<div class="overflow-demo__menu" role="menu" aria-labelledby="overflow-menu-trigger"
data-stimeo--menu-target="menu" hidden></div>
</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__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;
}
/*
* In the bar the items are chips rather than menu rows. The rule is scoped to the
* bar container because the controller moves the very same buttons into the More
* menu — once banked, the chip look stops matching and the shared .demo-menuitem
* takes over, so a banked row is identical to every other menu row in the catalog.
*/
.overflow-demo__items .overflow-demo__item,
.overflow-demo__trigger {
flex: none;
width: auto;
white-space: nowrap;
padding: 0.375rem 0.75rem;
border-color: var(--border);
border-radius: 0.375rem;
background: var(--surface-subtle);
}
.overflow-demo__items .overflow-demo__item:hover,
.overflow-demo__trigger:hover {
background: var(--surface-card);
}
/* The items row is clipped so that overflow is real, which would slice an outset
ring off the last chip that fits. Inset it, as the shared bar item does. */
.overflow-demo__items .overflow-demo__item:focus-visible {
outline-offset: -2px;
}
.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;
padding: 0.25rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface-card);
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
}
// 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; written only when the trigger is bare — no text, no element child, no aria-label/aria-labelledby (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 | Description | Action |
|---|---|---|
update
|
Re-measures and rebalances the bar; call it after adding or removing items dynamically. | stimeo--overflow-menu#update |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires on connect and on each later 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 wrapper holding the More button and its menu) | Present when nothing overflows; the wrapper hides the menu along with the button. |