Skip to content

feat(Overflow): add new component#220

Merged
johnleider merged 39 commits into
masterfrom
feat/overflow-component
Apr 30, 2026
Merged

feat(Overflow): add new component#220
johnleider merged 39 commits into
masterfrom
feat/overflow-component

Conversation

@johnleider
Copy link
Copy Markdown
Member

Summary

  • Adds a new compound Overflow component (Root + Item + Indicator) under packages/0/src/components/Overflow/, wrapping the existing createOverflow composable for general-purpose responsive truncation (tag rows, avatar groups, toolbar action lists).
  • Overflow.Root owns the registry + capacity computation, supports priority="start" | "end" and a Root-level disabled short-circuit. Overflow.Item self-registers, self-measures, and toggles data-hidden + structural display: none when off-capacity. Overflow.Indicator renders only when overflowing, exposes count + hidden tickets, and self-measures so its width is reserved.
  • Bisect-style truncation (first + last, hide middle) is intentionally out of scope — that lives in the specialized BreadcrumbsRoot.
  • Full docs page at /components/semantic/overflow with four examples: basic tag row, multi-file avatar group, indicator-as-popover-activator, and priority="end" for trailing-priority lists. Maturity bumped from draftpreview.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 28, 2026

Open in StackBlitz

commit: 41a44ad

@johnleider johnleider self-assigned this Apr 28, 2026
@johnleider johnleider added this to the v0.2.x milestone Apr 28, 2026
… Root

- isVisible now ranks within non-disabled siblings instead of using raw
  registry index, fixing items past a disabled sibling being incorrectly
  hidden when they still fit within capacity
- bind slotProps.attrs to Root's Atom so non-renderless consumers see
  data-overflow / data-priority hooks on the DOM
- forward gap as a getter to createOverflow so prop changes recompute
  capacity instead of capturing the initial snapshot
- enable reactive registry so ticket.index tracks reindexes; OverflowItem
  cleans the previous index before writing a new one to prevent orphan
  widths from accumulating after a sibling unmounts
- re-measure the indicator with useResizeObserver when its content or
  styles change so the reserved width stays accurate as the count grows
- align data-attr typing with Tabs precedent (true | undefined), add
  @module/@remarks on OverflowRoot, @see on barrel exports, default
  renderless to false, and remove docs FAQ guidance to use gap for
  reservation
Replaces the hand-rolled `:style="{ display: isHidden ? 'none' : null }"`
with `v-show="isVisible"`. Functionally equivalent — Vue's v-show does
the same display-none-with-original-restore — but matches the precedent
used by Breadcrumbs and Avatar for components that must stay mounted to
preserve registered ticket state.

Codifies the convention in PHILOSOPHY §10.11 and components.md: v-show is
correct (not the exception) for registry-driven visibility, load-state
preservation, and virtualization. Bans the hand-rolled :style form.
Drops the `_priority` / `_disabled` named consts in favor of inline
`toRef(() => prop)` at the provide call site, matching TabsRoot,
BreadcrumbsRoot, ImageRoot, and the rest of the codebase. The temps
were used exactly once each — no readability or reuse benefit.
Anatomy is for visualizing the available components, not a runnable
example — drop the items array, v-for, slot vars, and props so the
fence reads as a component-tree map matching ExpansionPanel's pattern.
…dent

- Rename `measureSelf` → `measure` to match the single-verb convention
  used by every component-internal function (close, open, toggle, …).
- Route Atom element access through `toElement(atomRef.value?.element)`
  per the Image / Carousel × 4 precedent instead of a raw HTMLElement
  cast — joins the `toElement-template-refs.md` cleanup family.
- Rename the resulting ref `elementRef` → `el` to match the same
  precedents (single-word `el` everywhere else in the codebase).
- Inline the now-redundant element extraction in `measure` to read
  `el.value` directly.
Adds a "Consuming AtomExpose in the same SFC" subsection covering the
canonical form for component authors who wrap an Atom and need its
element for measurement, observers, focus, or popover anchoring:

- Always route through `toElement` (#v0/composables/toElement)
- Wrap in `toRef(() => …)` so downstream consumers stay reactive
- Name the ref `el` (or `{position}El` when there are multiple atoms)

Cites worked precedents (Image, Carousel × 4, OverflowIndicator) and
points at the `toElement-template-refs.md` saved memory for the legacy
raw-cast holdouts (Tabs, Treeview, …).

Also fixes an inaccurate inline comment in the existing AtomExpose
section: `rootAtom.value?.element` resolves to the HTMLElement directly
because Vue auto-unwraps refs surfaced via `defineExpose` — no second
`.value` required.
Tickets no longer carry a per-instance `isVisible` Ref. The Root's
`registry.register` override (which wrapped the base register to attach
`ticket.isVisible = toRef(() => isVisible(ticket.index))`) was the only
place in v0 that mutated registry internals — zero precedent in source.

Replaces the post-registration mutation with consumer-side computation:
- OverflowItem reads `root.isVisible(ticket.index)` directly.
- OverflowIndicator filters `(_, index) => !root.isVisible(index)`.
- OverflowTicket loses its `isVisible` field; the `disabled` field stays.

Identical observable behavior, simpler ticket type, no override of the
createRegistry public surface. Test renamed to assert the Root context
exposes `isVisible(index)` instead of the dropped per-ticket field.

Also drops a redundant `// reactive: true wraps each ticket…` comment
from OverflowRoot — Splitter and Carousel pass the same option without
commentary, and the rule is documented in composables.md.
Replaces raw `!== null` comparisons on `lastMeasuredIndex` with
`!isNull(...)` per `#v0/utilities`, matching style.md and PHILOSOPHY
§2.3 ("Utility guards over raw comparisons"). Two sites: the watch
deduper and the onBeforeUnmount cleanup.
The earlier "Consuming AtomExpose" subsection covered the toRef-derived
element ref but left the `useTemplateRef<AtomExpose>` holder undefined.
Adds a leading bullet codifying the holder name (`atomRef` for a single
Atom, `{role}Ref` / `{role}Atom` / role-only for multiple) and lists
the precedents — plus the Carousel files (CarouselNext, CarouselItem,
CarouselLiveRegion) as anti-precedent because they call the holder
`rootEl` even though it carries an AtomExpose, not an HTMLElement.
Two undocumented conventions caught in review:

- Anatomy fences are component-tree shells, not runnable examples — no
  data arrays, v-for, props beyond `:as`, slot vars, or runtime values.
  The runnable preview lives in `## Examples`; Anatomy is structural.
  Cites ExpansionPanel as reference.
- Reactive context fields derived from destructured props are written
  inline at the provide call (`disabled: toRef(() => disabled)`), not
  pre-bound to a `_prop` const. The named local is only justified when
  the Ref is reused. Cites Tabs/Breadcrumbs/Image/Overflow precedent.
…arding

§4.3 already specifies the receiver side ("type as MaybeRefOrGetter,
read via toValue()") but said nothing about how a destructured prop
gets forwarded into one. With Vue 3.5+ reactive props destructure,
`prop` is reactive when read inside a getter but evaluated once when
read inline at object-literal construction. A real bug surfaced from
exactly this miss in OverflowRoot. Adds a "Caller side" subsection
naming the rule and citing OverflowRoot as the worked example.

Also refreshes §2.3's anti-example. The two cited raw comparisons
(createOverflow:195 and color.ts:17,25,30) have both been migrated;
the only remaining holdouts are inside JSDoc @example blocks in
useNotifications:503,537. Updates the citation accordingly.
OverflowRoot routed `containerRef.value?.element` through a raw
`as Element | undefined` cast and OverflowItem read `atomRef.value?.element`
twice without an `el` ref. Both miss the just-codified components.md
"Consuming AtomExpose" rule.

- OverflowRoot.vue:80 — `container: () => toElement(containerRef.value?.element)`.
- OverflowItem.vue — `el = toRef(() => toElement(atomRef.value?.element) ?? null)`,
  consumed in the watch dep array and the measure call. Folds the
  if/else around `measure` into a ternary now that the casts are gone.
- OverflowRoot.vue:82 — `reserved` passes the `indicatorWidth` Ref
  directly. The `() => indicatorWidth.value` getter wrapper did nothing
  the Ref didn't already do via toValue and broke the visual symmetry
  by being the only param that wrapped a Ref's .value.
`since` ossifies a fictional ship version on pre-release entries — a
later alpha/beta cut can land before the feature actually ships and
nobody re-checks the field. The version a feature shipped in is only
known the moment it goes stable.

- new-feature-checklist.md: only `level` and `category` (plus optional
  `notes`) on draft/preview. `since` is added in the same PR that
  promotes the entry to stable. Explanatory paragraph + revised
  example reflect this.
- maturity.json: strip `since: "1.0.0-alpha.1"` from Overflow's preview
  entry — the version was a placeholder for an unshipped release.
The basic, popover, and priority-end blocks were carrying `.vue`
extensions and `1` order numbers — that's the multi-file form. The
single-file form drops both: the renderer auto-appends `.vue` and
order is meaningless when there's only one file. Matches Breadcrumbs,
ExpansionPanel, Checkbox precedent.
Two rules contradicted what every doc page on the site actually does:

- meta-indent rule said `  - name:` (2-space). Every doc page on the
  site (overflow, pagination, breadcrumbs, …) uses `- name:` at
  column 0. Both forms are valid YAML and parse identically; column 0
  is the de-facto standard. Aligns the rule to practice rather than
  reformatting every page.
- Usage section had two contradictory specs: the structure list said
  "code fence (not a live example)", the section-content table said
  "::: example with basic.vue". Pages diverge — Breadcrumbs/
  ExpansionPanel/Checkbox use ::: example; Pagination/Overflow use a
  code fence with prose. Both are legitimate (the latter when the
  section has explanatory text). Rule now permits either form with
  guidance on when to pick which.
…tting

Updates the maturity rule from "omit since on draft/preview" to "set
since: null". Two reasons to keep the key with a null value over
dropping it:

- Schema uniformity. Every entry has the same shape regardless of
  level, so tooling can iterate without branching on key presence.
- Visibility. `"since": null` reads as a deliberate "fill in on
  release" placeholder. An omitted key is invisible and easy to forget
  during the stable-promotion PR.

Updates the example, the rationale subsection, and the level-mapping
list. Restores `since: null` on the Overflow entry that was stripped
in the previous pass.
Earlier guidance tied `since` to the stable-promotion PR. That's wrong:
`since` records the first release version the feature appeared in,
regardless of level. A `preview` feature gets a real `since` the
moment a release including it is cut — `level` stays `preview` until
the API stabilises, but `since` is permanent.

Updated example shows two preview entries side-by-side: one already
shipped (real version), one not yet released (still null). Updated
rationale and level-mapping list reflect:

- author writes `since: null` regardless of level
- maintainer cutting the first release flips null → the release version
- promotion `preview` → `stable` does not touch `since`
Two related restrictions added to docs.md:

- No backticks in markdown headers. Headers feed the TOC and the URL
  anchor; inline-code styling renders inconsistently in both and the
  anchor slug picks up the surrounding chars.
- No backticks wrapping link text. The link styling already signals the
  identifier; the backticks layer redundant — and in many themes
  broken — formatting.

Inline code in regular prose stays fine.

Lists existing violations (select.md, composables index.md, why-vuetify0.md,
create-trinity.md) as a sweep candidate. Fixes the one violation in the
Overflow page that motivated the rule.
The previous chain `toElement(atomRef.value?.element) as HTMLElement | null ?? null`
papered over two distinct conversions with one cast. toElement returns
Element | undefined; the cast asserted HTMLElement | null. TS thought
the value was already nullable so the `?? null` looked redundant, but
runtime-wise it was the load-bearing piece — it converted undefined to
null. The cast was a lie that obscured what the chain was doing.

Drops the cast. `el` is now `Ref<Element | null>` honestly. The single
HTMLElement-only access (`el.value.offsetWidth` in measure) gets a
boundary cast at the use site, which is honest about why HTMLElement
is needed there.

Image/ImageRoot.vue and Carousel × 4 still carry the legacy chain —
sweep candidate.
The codified "Consuming AtomExpose" rule recommended the same chain
that was just fixed in OverflowIndicator. Updates the canonical
example to drop the cast and adds a bullet explaining why: toElement
returns Element | undefined, so the historical
`as HTMLElement | null ?? null` chain is misleading — TS thinks the
?? null is no-op but it's actually doing the undefined→null conversion
the cast lied about.

Renames "Worked precedents" to split current form (Overflow) from
legacy form (Image, Carousel × 4) for the inevitable sweep.
basic.vue, priority-end.vue, and avatar-group.vue each rendered an
Input.Root range slider that drove a `:style="{ width: ... }"` on the
Overflow.Root. The DocsExample frame already exposes a resize handle,
so the in-example slider was a redundant second control mechanism.

Removes:
- The slider markup, label, and width state from each example
- The wrapping `<div class="space-y-4">` (single child now)
- The `Input` import and `shallowRef` import where they became unused
- `transition-all` on the Overflow.Root (was there to smooth the slider
  driven width changes; the resize handle doesn't need it)

Updates the basic.vue role caption in overflow.md to drop the now-stale
"width slider" mention.
…ders

- users.ts grows from 8 to 40 entries (CS pioneers, hues spread evenly
  every 9° around the wheel) so the avatar-group demo actually shows
  a `+N` indicator at typical container widths.
- popover.vue popover items get `whitespace-nowrap` — the `block` class
  let long tag names wrap mid-word inside the popover; nowrap keeps
  each value on a single line.
- Examples headers shortened to scannable labels: "Basic tag row" →
  "Tags", "Avatar group with overflow indicator" → "Avatar group",
  "Indicator opens a popover of hidden items" → "Popover of hidden
  items", `priority="end" for trailing-priority lists` → "End
  priority". The prose under each header still carries the long-form
  explanation.
The Overflow.Root carried `:gap="-8"` while each Item had
`marginInlineStart: -8px`. The margin was already captured in each
item's measured width via getComputedStyle().marginLeft (so per-item
width = 32 + -8 = 24px). The gap prop subtracted the overlap a
second time, telling capacity the 40 avatars take ~648px when they
actually take ~960px — wider than the example container, so overflow
never triggered.

createOverflow's `gap` mirrors the container's CSS gap, not visual
overlap. With no `gap-*` utility on the container, the prop should be
omitted (defaults to 0). The marginInlineStart still produces the
visual overlap and is correctly counted once in the per-item measure.

Updates the example prose accordingly.
…ty-end to recipes

Page restructure per feedback: Examples section trimmed from four
demos to the two strongest, both reworked into copy-paste-ready
patterns. priority-end becomes a Recipe.

Examples (kept and enhanced):

- avatar-group: now renders as a semantic <ul>/<li> with `aria-label`
  on the list, `sr-only` full names alongside the visible initials,
  and a labelled `+N` indicator. Comments left out of the file but
  the prose now explains the no-gap rule (mirror only CSS gap, never
  the per-item visual overlap) so readers don't repeat the bug we
  hit during review.
- popover: same semantic <ul>/<li> upgrade; activator gets
  type="button" + `aria-label="Show {N} more topics"`; popover
  content gets `max-h-64 overflow-y-auto` for the many-hidden edge
  case. Tag list trimmed to 16 realistic department names.

Recipes (added):

- "Trailing-priority lists with priority='end'" now lives here as a
  collapsed `::: example` referencing priority-end.vue. The full
  prose about the announce-order tradeoff and breadcrumb redirect
  comes along.

Removed:

- basic.vue example. The Usage section's inline code fence already
  shows the minimal usage; basic was a less-interesting subset of
  the popover demo and the page reads tighter without it.

Usage section prose expanded from one paragraph (mechanics only) to
two — the first explains why CSS `text-overflow: ellipsis`,
`overflow: scroll`, and container queries can't replace this; the
second covers the three-component skeleton and the ResizeObserver-
driven mount/unmount of the indicator.

priority-end.vue itself updated to a relative-timestamp message list
(2 weeks ago → 1 hour ago) — clearer narrative of what `priority="end"`
does than the prior generic message strings.
Both examples (avatar-group and popover) had been switched to
`<Overflow.Root as="ul">` + `<Overflow.Item as="li">` for semantic
list announcements. In the live page, every item rendered with
`display: none` and stayed hidden — `total: 0`, `capacity: 0`,
`registry.size: 40` per Vue devtools introspection. The widths Map
in createOverflow never populated.

The `as="span"` form had worked. Reverting to `<div>`/`<span>` and
using `role="list"` + `role="listitem"` gives the same a11y
semantics without triggering the measurement chicken-egg. Both
examples now render correctly: 27/41 contributors visible (`+14`
indicator), 9/17 topics visible (`+8 more` popover trigger).

Updates the Accessibility table on the docs page to recommend the
role-based form too. The `as="ul"`/`as="li"` advice was unsafe.

The underlying createOverflow/OverflowItem bug is logged separately
in `~/.claude/projects/-home-john-sites-0/memory/overflow-li-rendering-bug.md`
for follow-up.
Backs out the role-list / consolidation pair from earlier in this
session:

- Re-adds basic.vue and re-promotes it to the leading example.
  priority-end moves back from Recipes to Examples. Examples are now
  basic / avatar-group / popover / priority-end again.
- Drops the role="list" + role="listitem" + aria-label additions from
  avatar-group.vue and popover.vue, plus the contributor-count header
  and sr-only name span on avatar-group. Accessibility table reverts to
  "pass as='ul'/as='li' for list semantics".
- popover.vue tag list swaps the data/devops/security/qa entries for
  salesforce/zendesk/jira/confluence.
- Trims the verbose Usage prose back to the two-sentence form and
  drops the inline overflow/scroll/container-query justification — the
  intro covers it.
@johnleider johnleider changed the title feat(Overflow): add headless responsive truncation primitive feat(Overflow): add new component Apr 30, 2026
@johnleider johnleider merged commit 923d3f7 into master Apr 30, 2026
16 of 17 checks passed
@johnleider johnleider deleted the feat/overflow-component branch April 30, 2026 05:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant