Skip to content

fix(InputMenu,InputTags): cap a tag against the field, not at 180px - #470

Merged
IgorShevchik merged 4 commits into
mainfrom
fix/input-menu-tag-width
Aug 23, 2026
Merged

IgorShevchik merged 4 commits into
mainfrom
fix/input-menu-tag-width

Conversation

@IgorShevchik

@IgorShevchik IgorShevchik commented Aug 23, 2026 •

Copy link
Copy Markdown
Collaborator

Linked issue

Resolves #342

Type of change

  • Documentation (updates to the documentation or readme)
  • Bug fix (a non-breaking change that fixes an issue)
  • Enhancement (improving an existing functionality)
  • New feature (a non-breaking change that adds functionality)
  • Chore (updates to the build process or auxiliary tools and libraries)
  • Revert (undoing a merged change — retitle this PR revert(Scope): ...)
  • Breaking change (fix or feature that would cause existing functionality to change)

Description

tagsItemText: 'truncate max-w-[180px]' cut tag labels at a constant that knows nothing about how wide the field is or what shares the row with it. #342's screenshot is the result: two tags side by side, both ellipsised, with visible empty space to their right. In a narrow modal the same cap goes the other way and tags overflow their row.

The number was ours — upstream nuxt/ui sets truncate with no cap at all.

The fix

Option B from the issue: the cap moves to the tag and becomes a share of the field.

- tagsItemText: 'truncate max-w-[180px]',
+ // on tagsItem / item:
+ 'min-w-0 max-w-[70%]',
+ tagsItemText: 'truncate min-w-0',
+ // on tagsItemDelete / itemDelete:
+ 'shrink-0',
+ // on input-tags.ts root:
+ 'max-w-full',

Applied to InputTags as well, which carried the same literal on its own itemText slot. The issue only names InputMenu; fixing one and leaving the other would have left the bug reachable through the sibling component.

min-w-0 is load-bearing rather than tidiness: a flex child will not shrink below its content width without it, so truncate never engages and the cap is silently inert while looking correct. shrink-0 on the delete control, because the tag is squeezable now and the close button must not be what gives way.

max-w-full on the InputTags root, and how it was found

Review flagged that max-w-[70%] could not work for InputTags, whose root is inline-flex with no width — a percentage against a shrink-to-fit parent is circular. Rather than argue the spec, I measured it in Chromium. A 562px parent, a tag wanting 590px:

field tag clipped past parent one row
max-w-[180px] (before) 260px 199px yes no yes
max-w-[70%], InputMenu 562px 412px yes no yes
max-w-[70%], InputTags as first written 590px 432px yes YES yes
max-w-[70%] + max-w-full on root 562px 412px yes no yes
no cap (upstream) 562px 562px yes no no

Review was right. InputMenu was fine all along — its base carries w-full, so the percentage had a definite containing block. InputTags did not, and the field overflowed its parent by 28px: an older problem that the 180px cap had been masking by keeping the row short. max-w-full on that root fixes both and makes the two components behave identically.

The last row is why the cap earns its place at all: uncapped, a long tag takes the whole row and pushes its neighbour to the next line — which is not what the issue's screenshot asks for.

The trade-off, stated plainly

70% means two short tags share a row and a long tag truncates at the field's real edge. It also means two long tags land on separate rows, since 70% + 70% exceeds the row. 50% would guarantee two per row at the cost of truncating sooner. Raised with the maintainer, 70% confirmed — and now written next to the class, so it is not reopened in six months.

Behaviour change worth knowing

The truncation point moves. Anyone who tuned around 180px — the issue mentions a max-w-[169px] workaround in a modal — will find their override still wins per-instance, but it is now unnecessary. Long values (a name, an e-mail, an entity title) will more often be visible in full rather than behind an ellipsis; that is a change in what is rendered, not in what is exposed, since truncate is purely visual and the full text was always in the DOM and in aria-labelledby.

Verification

The honest part first: no automated check can see this bug. happy-dom does no layout, so a snapshot records max-w-[70%] and cannot tell whether a label that would have fit got ellipsised anyway. That is exactly why max-w-[180px] survived — the component rendered, every test passed, and only a person looking at a real field saw it.

What a person checks. The demo pages for both components gain a case with labels long enough to want more room than the field has — the ones from the issue's report, verbatim. In the Matrix every size and colour variant renders side by side.

What the suite checks. test/utils/tag-width-cap.spec.ts pins the invariant so a port cannot quietly revert it: no absolute max-w (px/rem/em/pt/ch/viewport units) on any slot in any of the 119 theme files whose name says it renders a tag or an item; the two capping themes use a percentage paired with min-w-0; the delete control keeps shrink-0; and a self-check asserts the scanner matched something before reporting no offenders, because "nothing looked at" and "nothing wrong" read identically.

.sync/PORTING.md records the divergence in §2 with the measurements in the changelog entry. Upstream is plain truncate, so a port over these slots reverts all of it.

Review notes

Six defects found, all fixed.

# Finding Resolution
1 max-w-[70%] circular on InputTags — its root has no width, so the field grew to the untruncated tag and clipped it anyway, 28px past its parent max-w-full on the root. Settled by measurement, table above
2 The sweep read slot keys only at 4–8 spaces of indent. tagsItem is redefined once per size variant at ten, and under twMerge a variant class beats the slot-level one — a cap in variants.size.xss passed every test Any indent, and every occurrence rather than the first
3 A slot written (prev) => [prev, '…')] was invisible. Deeper than the missing .join(' ') terminator: an arrow function's parameter list closes before its body opens, so balancing brackets returned (prev: string) and stopped Value ends at a comma at depth zero — covers a bare string, an array and an arrow function alike
4 ABSOLUTE_CAP omitted viewport units Added; a field in a sidebar knows nothing about the window
5 Lint red — a double-quoted string in the spec. My own earlier "lint passes" was stale: it predated the slotSource rewrite Fixed
6 The 70%-over-50% decision was made off GitHub and invisible in the code Stated next to the class

Both blind spots (#2, #3) returned zero failing tests against a real reintroduction of #342 before the fix and fail now. Ten mutations run in total, each killing at least one case, plus a control confirming container-query units are correctly not flagged.

Earlier in this PR, mutation also caught two bugs in the spec rather than the theme: a slot slicer that credited tagsItem with the min-w-0 belonging to tagsItemText, and a toContain that matched the comment explaining min-w-0 after the class itself was deleted. Both traps are written down in the file. A spec that passes on its own prose is worse than no spec, because it reads as coverage.

Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

No docs change: the cap was never documented, and the :b24ui workaround the issue describes appears nowhere in the repository. (The tagsItemText mention in input-menu.md is a hand-written slot list with no widths in it, not a generated table — correcting my own earlier wording here.)

Gate run locally with CI=true: lint · typecheck · test · test/workflows/run.sh (37 + 17 + 49 passed).

claude added 4 commits August 23, 2026 05:18
`tagsItemText: 'truncate max-w-[180px]'` cut tag labels at a constant that
knows nothing about how wide the field is or what shares the row with it, so
#342's screenshot shows two tags side by side, both ellipsised, with visible
empty space to their right. In a narrow modal the same cap is too generous and
tags overflow their row. The number was ours: upstream nuxt/ui sets `truncate`
with no cap at all.

Option B from the issue, applied to `InputMenu` and to `InputTags`, which
carried the same literal on its own `itemText` slot and is not mentioned in
the issue. The cap moves to the tag rather than its text — a percentage on the
text would resolve against a shrink-to-fit parent — and becomes `max-w-[70%]`
of the field, which has a definite width because the tag is a direct child of
the `flex-wrap` root. Two short tags still share a row; a long one truncates
at the field's real edge instead of at 180px; no call site needs `:b24ui` with
a number tuned to one layout any more.

`min-w-0` on both the tag and its text is load-bearing rather than tidiness: a
flex child will not shrink below its content width without it, so `truncate`
never engages and the cap is inert while looking correct. `shrink-0` on the
delete control, because the tag is now squeezable and the close button must
not be what gives way.

`test/utils/tag-width-cap.spec.ts` holds it. happy-dom does no layout, so a
snapshot records the class and cannot notice that it truncates text which
would have fit — the failure and its revert are both invisible to every gate,
which is the whole reason the literal survived. The sweep over all 119 theme
files is the half with teeth: it rejects an absolute `max-w` on any slot whose
name says it renders a tag or an item, including slots that do not exist yet.

Ten mutations were run against it, each killing at least one case, and two of
them were killing bugs in the spec rather than in the theme. The first draft's
slot slicer overran the array terminator by twenty characters and credited
`tagsItem` with the `min-w-0` belonging to `tagsItemText`. The second passed on
its own prose: the comment explaining `min-w-0` contains the string `min-w-0`,
so `toContain` matched the comment after the class was deleted. Comments are
stripped now, and both traps are written down in the file.

Resolves #342

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
The fix in the previous commit is not visible in any automated check. happy-dom
does no layout, so a snapshot records `max-w-[70%]` and cannot tell whether a
label that would have fit was ellipsised anyway — which is the entire bug in
#342 and the reason `max-w-[180px]` survived as long as it did.

So the verification the issue asks for is a person looking at a field, and
these two cases are what they should look at: labels long enough to want more
room than the field has, in the demo's `Matrix` where every size and colour
variant renders side by side. Two tags should share a row and ellipsise at the
field's real edge; none should overflow it.

The labels are the ones from the issue's report, kept verbatim rather than
paraphrased into Lorem — they are what the reporter saw truncate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
…e against

Review found the fix was half-working, and a browser settled it rather than a
reading of the spec. `max-w-[70%]` needs a containing block with a definite
width. `InputMenu` has one — its `base` carries `w-full` — but `InputTags`'
root is `inline-flex` with no width at all, so the percentage was circular
there: the field grew to the untruncated tag and only then clipped it.

Measured in Chromium, a 562px parent with a tag wanting 590px:

  before, max-w-[180px]        field 260px  tag 199px  clipped
  InputMenu, max-w-[70%]       field 562px  tag 412px  clipped, both on one row
  InputTags, max-w-[70%]       field 590px  tag 432px  clipped, 28px past parent
  InputTags, + max-w-full      field 562px  tag 412px  clipped, both on one row

So `max-w-full` on that root, which also closes an older problem the `180px`
cap had been masking by keeping the row short: the field could overflow its
parent, and nothing said otherwise.

The guard had two blind spots, each returning zero failures against a real
reintroduction of #342.

`tagsItem` is redefined once per size variant at ten spaces of indent, and the
key scanner only looked at four to eight — so a cap added to
`variants.size.xss` passed every test while beating the slot-level one under
`twMerge`. Any indent now, and every occurrence rather than the first.

A slot written `(prev) => [prev, '…']` — the form `input-tags.ts`'s own root
uses — was invisible. The cause was subtler than the missing `.join(' ')`
terminator: an arrow function's parameter list closes before its body opens,
so balancing brackets returned `(prev: string)` and stopped. The value now
ends at a comma at depth zero, which covers a bare string, an array and an
arrow function alike.

Also from review: viewport units join the absolute-unit pattern, since a field
in a sidebar knows nothing about the window; a self-check asserts the scanner
matched something before the sweep reports no offenders, because "nothing
looked at" and "nothing wrong" read identically; and `--fix` on a double-quoted
string that was failing lint.

`.sync/PORTING.md` §2 records the divergence — upstream is plain `truncate`,
so a port over these slots reverts all of it — with the measurements in the
changelog entry. The choice of 70% over 50% is now stated where the class is,
since it was settled off GitHub and would otherwise be reopened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
@IgorShevchik
IgorShevchik merged commit 1db360a into main Aug 23, 2026
1 check passed
@IgorShevchik
IgorShevchik deleted the fix/input-menu-tag-width branch August 23, 2026 06:08
IgorShevchik pushed a commit that referenced this pull request Aug 23, 2026
Only the subject reaches `CHANGELOG.md` — the body is not rendered, breaking
notes aside — so it is the one place a reader of the release notes can be
handed a way back to what was actually ported. Without it a port is
indistinguishable from local work in the only artefact most consumers read.

The subject stays ours. Copying upstream's own first line was the request as
originally put, and it is not what this does: their `Slider` is this fork's
`Range`, and §1 of PORTING.md makes that class of rename mandatory, so their
wording would put a component this library does not ship into our changelog.
The reference points at the commit; the sentence describes what changed here.

    fix(Range): forward aria attributes to the thumb (nuxt/ui@d6c3802)

The trigger is a **new key in `processed`**, not the ledger being edited. That
distinction is the whole design: a reconciliation commit — §6 step 4 requires
one whenever a run's last entry has no follower — touches the same file and
ports nothing, and demanding a reference there would be wrong. Checked against
real history rather than fixtures: #467 (bookkeeping) passes, #470 (local work)
passes, #466 and #464 (real ports) are flagged.

Two mistakes worth recording, both of the shape this repository keeps hitting.
The first draft returned an empty array and tested it for truthiness, so every
commit was reported as an unnamed port; running it once was enough. The second
was quieter: without `HEAD^` the check returns nothing and passed silently, and
`actions/checkout` defaults to depth 1 — the same fail-open as the PyYAML
fallback removed in #468. It now warns when it cannot see the previous
revision, and ci.yml fetches depth 2.

`--stdin` skips it: a bare title cannot say what a commit touches, so the
PR-title job checks the parse and the type only. Mutation testing caught that
the case asserting this was vacuous — run from the repository root it passed
whether the guard respected `--stdin` or not, because HEAD there ports nothing.
It now stands on a port revision where the HEAD path does fire.

Refs #437

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

InputMenu: tag text is capped at a fixed 180px that ignores the input's own width

2 participants