Empty State Observer
stimeo--empty-state
Shows an empty placeholder when a list has no items and hides it once one arrives.
The stimeo--empty-state controller watches a list with a MutationObserver and toggles between the list and an empty placeholder at the 0 ↔ 1+ item boundary, keeping the consumer from hand-rolling count tracking for Turbo Stream rows. It counts the list's element children (or those matching itemSelector), toggles hidden on the list / empty targets, reflects data-empty and data-count on the controller element, and dispatches stimeo--empty-state:change when the boundary is crossed. With announce, the empty target is made a polite live region (unless the author already set one). Behavior only — the placeholder's look and copy are the consumer's; state is derived from the DOM (no module-scope state) so connect re-syncs after a Turbo Stream insertion, and the observer is severed on disconnect (Turbo navigation included).
- Item 1
- Item 2
Nothing here yet — add an item.
<%# Empty-state demo: add/remove items and the empty placeholder toggles itself. The
controller observes the list with a MutationObserver, toggles hidden on the
list / empty targets at the 0 <-> 1+ boundary, and reflects data-empty / data-count.
demo.js adds/removes <li> (standing in for Turbo Stream rows). This demo only styles
it; the empty placeholder's copy is owned here. %>
<div
class="empty-state-demo"
data-controller="stimeo--empty-state"
data-stimeo--empty-state-announce-value="true">
<div class="empty-state-demo__bar">
<button
type="button"
class="demo-trigger"
data-empty-state-add
data-item-label="<%= t("components.empty_state.demo.item") %>">
<%= t("components.empty_state.demo.add") %>
</button>
<button type="button" class="demo-trigger" data-empty-state-clear>
<%= t("components.empty_state.demo.clear") %>
</button>
</div>
<ul class="empty-state-demo__list" data-stimeo--empty-state-target="list">
<li><%= t("components.empty_state.demo.item") %> 1</li>
<li><%= t("components.empty_state.demo.item") %> 2</li>
</ul>
<p class="empty-state-demo__empty" data-stimeo--empty-state-target="empty" hidden>
<%= t("components.empty_state.demo.empty") %>
</p>
</div>
/*
* Presentation-only styles for the empty-state demo.
* The library toggles `hidden` on the list / empty targets and reflects
* data-empty / data-count; this CSS only lays out the list, items, and placeholder.
*/
.empty-state-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.empty-state-demo__bar {
display: flex;
gap: 0.5rem;
}
.empty-state-demo__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.empty-state-demo__list li {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface-subtle);
}
.empty-state-demo__empty {
margin: 0;
padding: 1.5rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
text-align: center;
color: var(--color-text-muted);
}
// Empty-state demo (consumer-side JS).
//
// The controller watches the list with a MutationObserver and toggles the empty
// placeholder on its own. This catalog has no Turbo Stream backend, so here the
// buttons add/remove <li> rows — exactly the kind of mutation Turbo Stream would
// make — and the controller reacts to them.
document.querySelectorAll(".empty-state-demo").forEach((root) => {
const list = root.querySelector('[data-stimeo--empty-state-target="list"]');
const add = root.querySelector("[data-empty-state-add]");
const label = add?.dataset.itemLabel;
if (!list) return;
let count = list.children.length;
add?.addEventListener("click", () => {
const li = document.createElement("li");
li.textContent = `${label ?? "Item"} ${(count += 1)}`;
list.appendChild(li);
});
root.querySelector("[data-empty-state-clear]")?.addEventListener("click", () => {
list.replaceChildren();
});
});
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--empty-state"
Targets
| Name | Description | Attribute |
|---|---|---|
list
required
|
The watched container whose children are counted. | data-stimeo--empty-state-target="list" |
empty
|
The placeholder shown when the list is empty. | data-stimeo--empty-state-target="empty" |
Values
| Name | Description | Attribute |
|---|---|---|
itemSelector
|
Selector that counts only matching child items; empty counts all element children. | data-stimeo--empty-state-item-selector-value |
announce
|
Make the empty target a polite live region so showing it is announced (default false). |
data-stimeo--empty-state-announce-value |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when the 0 ↔ 1+ boundary is crossed, with detail.count / detail.empty. |
stimeo--empty-state: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 |
|---|---|---|
hidden |
list / empty targets | Toggled to show the list (1+) or the empty placeholder (0). |
data-empty |
Controller element | Present when the item count is 0. |
data-count |
Controller element | The current item count. |