/*
* Tags-group — element (display)
*
* Horizontal/wrapping collection of Tags with runtime-driven overflow:
* authors specify data-max-rows, the runtime measures wrapped layout
* and hides tags beyond the row count, appending a "+N more" Tag at
* the end.
*
* <div class="tags-group" data-max-rows="2">
* <span class="tag">React</span>
* <span class="tag">TypeScript</span>
* ... many tags ...
* </div>
*
* Without data-max-rows the group simply wraps without trimming.
*/
@layer malevich.components {
.tags-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-gap-elements-s);
}
/* While the runtime is measuring, keep contents in DOM but visually
* accurate. Hidden overflow tags get a data-tags-hidden marker.
*/
.tags-group [data-tags-hidden="true"] {
display: none;
}
/* The +N overflow tag is appended by the runtime; consumers can also
* write it manually for the static case.
*/
.tags-group__overflow {
/* Inherits .tag styles when also given the .tag class; this rule
* is a no-op placeholder for authors who want extra targeting.
*/
}
}
// Tags-group — runtime overflow.
//
// When the host carries data-max-rows="N", measure wrapped layout and
// hide tags whose offsetTop puts them beyond row N. Append (or reuse)
// a .tag.-muted "+K more" indicator with class .tags-group__overflow.
//
// Re-runs on ResizeObserver so layout follows container resizes.
const READY_ATTR = "data-malevich-ready";
const HIDDEN_ATTR = "data-tags-hidden";
const OVERFLOW_CLASS = "tags-group__overflow";
interface State {
observer?: ResizeObserver;
}
const stateMap = new WeakMap<HTMLElement, State>();
function getMaxRows(host: HTMLElement): number | null {
const raw = host.getAttribute("data-max-rows");
if (!raw) return null;
const n = parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
function tagSelector(): string {
return `.tag:not(.${OVERFLOW_CLASS})`;
}
function reset(host: HTMLElement): void {
for (const tag of host.querySelectorAll<HTMLElement>(`[${HIDDEN_ATTR}]`)) {
tag.removeAttribute(HIDDEN_ATTR);
}
const overflow = host.querySelector<HTMLElement>(`.${OVERFLOW_CLASS}`);
if (overflow && overflow.dataset.autogen === "true") overflow.remove();
}
function layout(host: HTMLElement): void {
const maxRows = getMaxRows(host);
reset(host);
if (maxRows === null) return;
const tags = Array.from(host.querySelectorAll<HTMLElement>(tagSelector()));
if (tags.length === 0) return;
// Group tags by row using offsetTop. The first row's top becomes the
// reference; subsequent unique tops are additional rows.
const rowTops: number[] = [];
const tagRows: number[] = tags.map((t) => {
const top = t.offsetTop;
let idx = rowTops.indexOf(top);
if (idx === -1) {
rowTops.push(top);
rowTops.sort((a, b) => a - b);
idx = rowTops.indexOf(top);
}
return idx;
});
if (rowTops.length <= maxRows) return;
// Find the first tag that lives beyond maxRows-1.
const firstHiddenIdx = tagRows.findIndex((r) => r >= maxRows);
if (firstHiddenIdx === -1) return;
// Append (or reuse) the overflow tag and try fitting it on the last
// visible row. We need to retry: adding the overflow tag may push
// the last visible tag onto a new row.
let overflow = host.querySelector<HTMLElement>(`.${OVERFLOW_CLASS}`);
if (!overflow) {
overflow = document.createElement("span");
overflow.className = `tag -muted ${OVERFLOW_CLASS}`;
overflow.dataset.autogen = "true";
overflow.setAttribute("aria-label", "");
host.appendChild(overflow);
}
// First pass: hide everything from firstHiddenIdx onwards, count.
let cut = firstHiddenIdx;
while (cut > 0) {
for (let i = 0; i < tags.length; i++) {
if (i >= cut) tags[i].setAttribute(HIDDEN_ATTR, "true");
else tags[i].removeAttribute(HIDDEN_ATTR);
}
overflow.textContent = `+${tags.length - cut} more`;
overflow.setAttribute("aria-label", `and ${tags.length - cut} more`);
// Did adding overflow push past row count?
const overflowRowTop = overflow.offsetTop;
if (rowTops.indexOf(overflowRowTop) === -1) {
rowTops.push(overflowRowTop);
rowTops.sort((a, b) => a - b);
}
const overflowRowIdx = rowTops.indexOf(overflowRowTop);
if (overflowRowIdx < maxRows) {
return;
}
cut -= 1;
}
// Fallback: cut hides everything. Should not happen with sensible
// inputs but keeps behavior stable.
for (const t of tags) t.setAttribute(HIDDEN_ATTR, "true");
overflow.textContent = `+${tags.length} more`;
}
/**
* Initialize tags-group overflow on a single host.
*/
export function initTagsGroup(host: HTMLElement): void {
if (host.getAttribute(READY_ATTR) === "true") return;
layout(host);
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(() => layout(host));
observer.observe(host);
stateMap.set(host, { observer });
}
host.setAttribute(READY_ATTR, "true");
}
export function initTagsGroups(root: ParentNode = document): void {
for (const host of root.querySelectorAll<HTMLElement>(".tags-group[data-max-rows]")) {
initTagsGroup(host);
}
}
# Tags-Group — AGENTS.md
> Auto-generated from `tags-group.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.
## Identity
- **Component:** `tags-group`
- **Layer:** elements
- **Status:** stable
- **Last updated:** 2026-05-19
## Summary
- Skill stacks, applied filters, label lists where space is bounded.
## Variants
| Variant | Class | Use for |
|---------|--------------------------------|--------------------------------------|
| No-cap | `.tags-group` | All tags visible, wraps naturally |
| Capped | `.tags-group[data-max-rows="N"]` | Hide tags past row N, show +K more |
## Modifier applicability
Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.
| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | — |
| `effect` | — |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | — |
| `behavior` | — |
## Anatomy
```html
<!-- Unlimited wrap -->
<div class="tags-group">
<span class="tag -muted">React</span>
<span class="tag -muted">TypeScript</span>
<span class="tag -muted">CSS</span>
</div>
<!-- Trim to 2 rows -->
<div class="tags-group" data-max-rows="2">
<span class="tag -muted">React</span>
<span class="tag -muted">TypeScript</span>
<span class="tag -muted">CSS</span>
<span class="tag -muted">Vite</span>
<span class="tag -muted">Vitest</span>
<!-- … many more … -->
</div>
```
The runtime (`initTagsGroup`, auto-registered) measures wrapped
layout, hides tags whose row index ≥ N, and appends or updates a
`.tag.-muted.tags-group__overflow` element with text `+K more`. On
container resize (via `ResizeObserver`), it re-runs.
For environments without JS, the cap is a no-op and all tags remain
visible — the page still works.
## Tokens consumed
- `--space-gap-elements-s` — gap between tags
Tag styling is inherited from the `Tag` component.
## Accessibility contract
- The overflow tag has `aria-label="and K more"` so screen readers
announce the truncated count.
- Hidden tags use `display: none` (via `data-tags-hidden="true"`), so
they are removed from the accessibility tree. Their content is not
read.
- For full-list access, pair the group with a Show all button or
Popover that reveals the hidden tags.
## Guidance
### Do
- Set `data-max-rows` only when you have a bounded space.
- Provide an alternative way to see the full list when capping
(button → Popover with all tags, or Sheet on small screens).
### Don't
- Don't manually count tags to truncate. Let the runtime measure.
- Don't apply CSS `max-height` to the group — overflow is handled by
the runtime, not CSS clipping.