From 9ac18dc66267f631678025a06072cd04b4b9b328 Mon Sep 17 00:00:00 2001 From: Shevchik Igor Date: Sun, 23 Aug 2026 05:18:01 +0000 Subject: [PATCH 1/3] fix(InputMenu,InputTags): cap a tag against the field, not at 180px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc --- src/theme/input-menu.ts | 11 +- src/theme/input-tags.ts | 6 +- .../__snapshots__/InputMenu.spec.ts.snap | 4 +- .../__snapshots__/InputTags-vue.spec.ts.snap | 4 +- .../__snapshots__/InputTags.spec.ts.snap | 4 +- test/utils/tag-width-cap.spec.ts | 155 ++++++++++++++++++ 6 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 test/utils/tag-width-cap.spec.ts diff --git a/src/theme/input-menu.ts b/src/theme/input-menu.ts index 291564e6e..b7c262380 100644 --- a/src/theme/input-menu.ts +++ b/src/theme/input-menu.ts @@ -123,14 +123,23 @@ export default () => { 'leading-(--main-ui-square-item-height)', 'font-[family-name:var(--ui-font-family-primary)] font-(--ui-font-weight-regular)', 'inline-flex items-center gap-1', + // `min-w-0` so the tag may shrink below its content width — without it + // `truncate` on the text has nothing to truncate into. The cap is a + // share of the field rather than a pixel count: a tag only ellipsises + // when it genuinely wants more than most of the row, instead of at a + // constant that knows nothing about how wide the field is. + 'min-w-0 max-w-[70%]', 'data-disabled:cursor-not-allowed data-disabled:opacity-30', 'data-disabled:select-none', 'text-(--ui-color-design-tinted-content)', 'bg-(--ui-color-design-tinted-bg-alt)' ].join(' '), - tagsItemText: 'truncate max-w-[180px]', + tagsItemText: 'truncate min-w-0', tagsItemDelete: [ 'cursor-pointer', + // The tag can now be squeezed to 70% of the field; the delete control + // must not be what gives way when it is. + 'shrink-0', 'inline-flex items-center', 'disabled:cursor-not-allowed', 'disabled:pointer-events-auto', diff --git a/src/theme/input-tags.ts b/src/theme/input-tags.ts index 40161edc7..ef43d2a9f 100644 --- a/src/theme/input-tags.ts +++ b/src/theme/input-tags.ts @@ -37,6 +37,9 @@ export default () => { 'leading-(--main-ui-square-item-height)', 'font-[family-name:var(--ui-font-family-primary)] font-(--ui-font-weight-regular)', 'inline-flex items-center gap-1', + // See input-menu.ts: the cap is a share of the field, and `min-w-0` is + // what lets `truncate` on the text actually take effect. + 'min-w-0 max-w-[70%]', 'data-disabled:cursor-not-allowed', 'data-disabled:opacity-30', 'data-disabled:pointer-events-auto', @@ -45,10 +48,11 @@ export default () => { 'bg-(--ui-color-design-tinted-bg-alt)' ].join(' '), itemText: [ - 'truncate max-w-[180px]' + 'truncate min-w-0' ].join(' '), itemDelete: [ 'cursor-pointer', + 'shrink-0', 'inline-flex items-center', 'disabled:cursor-not-allowed', 'disabled:pointer-events-auto', diff --git a/test/components/__snapshots__/InputMenu.spec.ts.snap b/test/components/__snapshots__/InputMenu.spec.ts.snap index 8bd4ee1bc..87e4aca28 100644 --- a/test/components/__snapshots__/InputMenu.spec.ts.snap +++ b/test/components/__snapshots__/InputMenu.spec.ts.snap @@ -1322,10 +1322,10 @@ exports[`InputMenu > renders with multiple and modelValue correctly 1`] = ` "
-
Backlog
-
Todo
diff --git a/test/components/__snapshots__/InputTags-vue.spec.ts.snap b/test/components/__snapshots__/InputTags-vue.spec.ts.snap index cae23def6..f3cedcd4f 100644 --- a/test/components/__snapshots__/InputTags-vue.spec.ts.snap +++ b/test/components/__snapshots__/InputTags-vue.spec.ts.snap @@ -48,7 +48,7 @@ exports[`InputTags > renders with default slot correctly 1`] = ` exports[`InputTags > renders with defaultValue correctly 1`] = ` "
-
test
@@ -138,7 +138,7 @@ exports[`InputTags > renders with loading correctly 1`] = ` exports[`InputTags > renders with modelValue correctly 1`] = ` "
-
test
diff --git a/test/components/__snapshots__/InputTags.spec.ts.snap b/test/components/__snapshots__/InputTags.spec.ts.snap index 393d5e7fa..a757e0585 100644 --- a/test/components/__snapshots__/InputTags.spec.ts.snap +++ b/test/components/__snapshots__/InputTags.spec.ts.snap @@ -48,7 +48,7 @@ exports[`InputTags > renders with default slot correctly 1`] = ` exports[`InputTags > renders with defaultValue correctly 1`] = ` "
-
test
@@ -138,7 +138,7 @@ exports[`InputTags > renders with loading correctly 1`] = ` exports[`InputTags > renders with modelValue correctly 1`] = ` "
-
test
diff --git a/test/utils/tag-width-cap.spec.ts b/test/utils/tag-width-cap.spec.ts new file mode 100644 index 000000000..0366f9e6d --- /dev/null +++ b/test/utils/tag-width-cap.spec.ts @@ -0,0 +1,155 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, it, expect } from 'vitest' + +/** + * A tag's width cap is a share of the field, never a pixel count. + * + * `max-w-[180px]` was b24ui's own addition — upstream nuxt/ui sets + * `truncate` with no cap at all — and it produced the bug in #342: two tags + * side by side, both ellipsised, with visible empty space to the right of + * them. The cut had nothing to do with how much room the tag had, because a + * constant cannot know how wide the field is or what shares its row. + * + * The reason this is a spec and not just a fixed theme file is that the + * failure is invisible to every gate. happy-dom does no layout, so a snapshot + * records the class and never notices that it truncates text which would have + * fit; the component renders, the tests pass, and only a human looking at a + * real field sees it. The same is true of the revert: a port that takes an + * upstream hunk touching these slots, or anyone reaching for a quick fix, + * puts a pixel back and nothing goes red. + * + * Three properties, in order of how much they carry: + * + * 1. No pixel cap on any tag slot in any theme. This is the sweep, and it is + * the half with teeth — it holds for slots that do not exist yet. + * 2. The two themes that cap tags do so as a percentage, and pair it with + * `min-w-0`. Without `min-w-0` the flex child refuses to shrink below its + * content width, `truncate` never engages, and the cap silently does + * nothing — the failure mode that looks most like working code. + * 3. The delete control does not shrink. The tag can now be squeezed; the + * close button must not be what gives way. + */ +const repoRoot = process.cwd() +const themeDir = join(repoRoot, 'src/theme') + +/** Theme file → the slots that carry a tag's own width constraints. */ +const TAG_SLOTS: Record = { + 'input-menu.ts': { item: 'tagsItem', text: 'tagsItemText', del: 'tagsItemDelete' }, + 'input-tags.ts': { item: 'item', text: 'itemText', del: 'itemDelete' } +} + +/** + * Any `max-w-[]` where the unit is absolute. Percentages, `full` + * and container-query units are the point of the fix, so they are not matched; + * `rem`/`em` are, because they are just as blind to the field's width as `px`. + */ +const ABSOLUTE_CAP = /max-w-\[\d+(?:\.\d+)?(?:px|rem|em|pt|ch)\]/g + +function read(file: string): string { + return readFileSync(join(themeDir, file), 'utf8') +} + +/** + * Every slot whose name says it renders a tag or an item, paired with its + * source. Keyed on the slot name rather than on proximity in the file: an + * earlier draft looked for `tag`/`item` within a few lines of a `max-w` and + * reported `empty.ts`'s `max-w-[384px]`, which caps a block of body copy and + * has nothing to do with this. + */ +function tagSlots(source: string): Array<[string, string]> { + const found: Array<[string, string]> = [] + // Slot keys sit at a known depth inside `slots: { ... }`; matching the key + // and taking the rest of its definition is enough, and avoids parsing TS. + for (const match of source.matchAll(/^\s{4,8}(\w*(?:[Tt]ag|[Ii]tem)\w*):/gm)) { + found.push([match[1]!, slotSource(source, match[1]!)]) + } + return found +} + +/** + * Exactly one slot's definition, whether it is written as a string or as an + * array joined with spaces. + * + * The bound has to be exact, and comments have to go. Two drafts of this + * helper were wrong in the same direction, both found by mutation and neither + * by reading. The first ended the slice twenty characters past the array + * terminator as slop, so `tagsItem` was credited with the `min-w-0` belonging + * to `tagsItemText`. The second kept the `//` comments — which explain the + * `min-w-0` and therefore contain the string — so deleting the class still + * satisfied `toContain('min-w-0')`. A spec that passes on its own prose is + * worse than no spec, because it reads as coverage. + */ +function slotSource(source: string, slot: string): string { + const start = source.search(new RegExp(`^\\s+${slot}:`, 'm')) + expect(start, `slot \`${slot}\` not found — was it renamed?`).toBeGreaterThan(-1) + const rest = source.slice(start) + const array = rest.indexOf("].join(' '),") + const line = rest.indexOf('\n') + // An array definition runs to its terminator; a string one ends at its line. + // Whichever comes first is this slot's; anything past it is the next slot's. + if (array !== -1 && (line === -1 || array < rest.indexOf('\n', array))) { + const nextKey = rest.slice(0, array).search(/\n\s+\w+:\s/) + if (nextKey === -1) return stripComments(rest.slice(0, array)) + } + return stripComments(rest.slice(0, line === -1 ? undefined : line)) +} + +/** Line comments, gone: prose about a class is not the class. */ +function stripComments(source: string): string { + return source.split('\n').filter(l => !l.trim().startsWith('//')).join('\n') +} + +describe('tag width caps', () => { + it('no theme caps a tag slot at an absolute width', () => { + const offenders: string[] = [] + + for (const file of readdirSync(themeDir).filter(f => f.endsWith('.ts'))) { + for (const [slot, value] of tagSlots(read(file))) { + for (const cap of value.match(ABSOLUTE_CAP) ?? []) { + offenders.push(`src/theme/${file}: ${slot} has ${cap}`) + } + } + } + + expect(offenders, [ + 'A tag width cap must be relative to the field, not an absolute length.', + 'See #342: `max-w-[180px]` ellipsised tags that had room to spare, because', + 'a constant knows nothing about the field width or what shares the row.' + ].join('\n')).toEqual([]) + }) + + for (const [file, slots] of Object.entries(TAG_SLOTS)) { + describe(file, () => { + const source = read(file) + + it('caps the tag as a share of the field', () => { + const item = slotSource(source, slots.item) + expect(item, `${slots.item} should cap at a percentage of the field`).toMatch(/max-w-\[\d+%\]/) + }) + + it('lets the tag shrink, or the cap does nothing', () => { + const item = slotSource(source, slots.item) + expect(item, [ + `${slots.item} needs \`min-w-0\`: a flex child will not shrink below its`, + 'content width without it, so `truncate` on the text never engages and', + 'the cap is inert while looking correct.' + ].join('\n')).toContain('min-w-0') + }) + + it('truncates the label rather than the tag box', () => { + const text = slotSource(source, slots.text) + expect(text).toContain('truncate') + expect(text, `${slots.text} needs \`min-w-0\` for the same reason as the item`).toContain('min-w-0') + }) + + it('keeps the delete control at full size', () => { + const del = slotSource(source, slots.del) + expect(del, [ + `${slots.del} needs \`shrink-0\`: the tag is now squeezable, and without`, + 'this the close button is what collapses first.' + ].join('\n')).toContain('shrink-0') + }) + }) + } +}) From 0d7ea6c8affce21b2ae13ed7004cc8e1e2d266dd Mon Sep 17 00:00:00 2001 From: Shevchik Igor Date: Sun, 23 Aug 2026 05:22:18 +0000 Subject: [PATCH 2/3] docs(demo): add a long-label tags case to InputMenu and InputTags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc --- .../demo/app/pages/components/input-menu.vue | 21 +++++++++++++++++++ .../demo/app/pages/components/input-tags.vue | 9 ++++++++ .../__snapshots__/InputMenu-vue.spec.ts.snap | 4 ++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/playgrounds/demo/app/pages/components/input-menu.vue b/playgrounds/demo/app/pages/components/input-menu.vue index ecc2c6574..a7b6c8d66 100644 --- a/playgrounds/demo/app/pages/components/input-menu.vue +++ b/playgrounds/demo/app/pages/components/input-menu.vue @@ -35,6 +35,18 @@ const itemsSimple = ref(['Prospecting', 'Qualifying', 'Presenting', 'Negotiating const valueForAdd = ref('Prospecting') const valueMultiple = ref(['Prospecting', 'Presenting']) +// #342 is only visible with labels long enough to want more room than the field +// has. happy-dom does no layout, so no snapshot can show this — the check is a +// person looking at the field: two tags should sit side by side and ellipsise +// at the field's real edge, not at a fixed width with empty space beside them. +const itemsLong = ref([ + 'Интеграции (Телефония, Почта, Мессенджеры)', + 'Контакт-центр (Открытые линии)', + 'Пользователи и структура компании', + 'Автоматизация (роботы и триггеры)' +]) +const valueMultipleLong = ref([itemsLong.value[0]!, itemsLong.value[1]!]) + function onCreate(item: string) { itemsSimple.value.unshift(item) valueForAdd.value = item @@ -213,6 +225,15 @@ const itemsObj = ref([ class="w-full" @create="onCreateMultiple" /> + { }) const tags = ref(['Bitrix24', 'Crm']) + +// See input-menu.vue: #342 only shows with labels long enough to want more room +// than the field has, and no snapshot can show it. The check is visual — tags +// ellipsise at the field's real edge, not at a constant. +const tagsLong = ref([ + 'Интеграции (Телефония, Почта, Мессенджеры)', + 'Контакт-центр (Открытые линии)' +]) diff --git a/test/components/__snapshots__/InputMenu-vue.spec.ts.snap b/test/components/__snapshots__/InputMenu-vue.spec.ts.snap index 689e2f450..987593d91 100644 --- a/test/components/__snapshots__/InputMenu-vue.spec.ts.snap +++ b/test/components/__snapshots__/InputMenu-vue.spec.ts.snap @@ -1322,10 +1322,10 @@ exports[`InputMenu > renders with multiple and modelValue correctly 1`] = ` "
-
Backlog
-
Todo
From b6fd9c67caf8f774bb63212de2fcda5ba7a9b19e Mon Sep 17 00:00:00 2001 From: Shevchik Igor Date: Sun, 23 Aug 2026 05:56:11 +0000 Subject: [PATCH 3/3] fix(InputTags): bound the root so the tag cap has something to resolve against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc --- .sync/PORTING.md | 14 ++++ src/theme/input-menu.ts | 14 ++-- src/theme/input-tags.ts | 9 ++- .../__snapshots__/InputTags-vue.spec.ts.snap | 54 +++++++-------- .../__snapshots__/InputTags.spec.ts.snap | 54 +++++++-------- test/utils/tag-width-cap.spec.ts | 69 ++++++++++++++----- 6 files changed, 133 insertions(+), 81 deletions(-) diff --git a/.sync/PORTING.md b/.sync/PORTING.md index 256c1cbf6..645539f05 100644 --- a/.sync/PORTING.md +++ b/.sync/PORTING.md @@ -493,6 +493,19 @@ material. Reproduce its *intent* in b24ui by editing files under `src/` only. - **a11y** — keep the `axe` test case for the component green. - **Tests** — for every new/changed prop, add a `renderEach` case so a snapshot exercises it; update snapshots with `pnpm run test run -u` when markup changes. +- **Tag width caps are relative to the field, never an absolute length.** + Upstream's `tagsItemText` / `itemText` are plain `truncate` with no cap; + b24ui caps the **tag** at `max-w-[70%]` with `min-w-0`, marks the delete + control `shrink-0`, and bounds `input-tags.ts`'s root with `max-w-full` + (fix of #342 — a `180px` constant ellipsised tags that had room to spare). + Reapplying an upstream hunk over these slots reverts it, and so does swapping + the percentage for any length the field cannot see. `max-w-full` on the root + is load-bearing rather than cosmetic: that root is `inline-flex` with no + width, and a percentage against a shrink-to-fit parent is circular — the + field grows to the untruncated tag and only then clips it. Guarded by + `test/utils/tag-width-cap.spec.ts`, which sweeps every theme file for an + absolute `max-w` on any tag- or item-named slot, nested size variants + included. ## 3. Examples (before → after) @@ -749,3 +762,4 @@ forward, since every commit between the two would then never be judged. - 2026-08-21 — fix of #73 (PR #430): added the §2 rule **popup height caps are tokens, and `40rem` is ours**. The value was hardcoded in six theme files — three at `40rem` for combobox/select lists, two at `40vh` for menus, and one more in `editor-suggestion-menu.ts` that review caught, which the editor's slash, mention and emoji menus all extend — which made it unthemeable and, more to the point, indistinguishable from an upstream literal during a port. Kept as two tokens rather than one: the units differ and nobody lengthening an autocomplete should be resizing context menus. Regenerating 350 snapshots was the whole cost; every changed line is the cap class and nothing else, checked rather than assumed. Last reviewed: 2026-08-21. - 2026-08-21 — checked #159 and added the §2 **`Modal`/`Slideover`/`Drawer` wrap `emits` with `useBlurOnOpen`** invariant. Upstream is not fixed: unovue/reka-ui#1280 has been open since 2024-08-23 with no assignee or PR, and `reka-ui@2.10.3` still calls `hideOthers`, so nothing was removed. Two defects turned up around the workaround instead. The issue documents `grep -rn 'reka-ui#1280' src/ test/` as the way to find every site to revisit, and it matched nothing — only the composable was annotated, as a URL. And the workaround stranded focus on `` after close for any overlay opened without the built-in trigger slot, because reka-ui's fallback capture of `document.activeElement` skips `` and the blur had just produced it; confirmed by A/B, fixed by restoring focus on close, and now covered at the component and composable level. `test/utils/blur-on-open-workaround.spec.ts` turns the manual re-check into a build signal. Last reviewed: 2026-08-21. - 2026-08-23 — closed #98 (PR #468) by adding the §6 rule **carry upstream's breaking marker into our subject**, and the matching §7 reviewer check. The issue's other two actions were already done and are recorded elsewhere: the version arithmetic table in `releasing.md` maps any `BREAKING CHANGE` or `!` to a major, with no v2-line exception, and release automation landed as release-please. What was missing is the one step automation cannot supply. release-please derives the bump from the squashed subject, so the marker is now load-bearing in a way it was not when the CHANGELOG was hand-written and a human read the diff — a dropped `!` used to be a cosmetic slip and is now a semver violation that every gate passes. The rule is stated in both directions on purpose, because a port is not a copy: a §2 divergence can absorb a break that upstream had, and this fork's own prop surface can break where upstream's did not. Last reviewed: 2026-08-23. +- 2026-08-23 — fix of #342 (PR #470): added the §2 rule **tag width caps are relative to the field**. `max-w-[180px]` on the tag label was ours — upstream is plain `truncate` — and it ellipsised tags that had room to spare, because a constant knows nothing about the field's width or what shares its row. Settled by measuring in Chromium rather than by reading the spec, which was the only way to tell three plausible readings apart: in a 562px field a tag wanting 590px renders at 199px under the old cap, 412px under `max-w-[70%]` with both tags still on one row, and 562px uncapped with the second tag pushed to the next line. The same measurement caught what review had flagged and reading had not — `input-tags.ts`'s root is `inline-flex` with no width, so the percentage was circular there: the field grew to 590px, overflowing its 562px parent, and clipped the label anyway. `max-w-full` on that root fixes both and is now part of the invariant. The guard went through three drafts, each corrected by mutation rather than by review: it credited one slot with a neighbour's class, then passed on its own comment (which contains the string `min-w-0`), then missed both the six per-size `tagsItem` overrides at deeper indentation and the `(prev) => [...]` slot form — an arrow function's parameter list closes before its body opens, so balancing brackets returned `(prev: string)` and nothing else. It ends at a comma at depth zero, and a self-check asserts the scanner matched something before reporting no offenders. Last reviewed: 2026-08-23. diff --git a/src/theme/input-menu.ts b/src/theme/input-menu.ts index b7c262380..1b3a99546 100644 --- a/src/theme/input-menu.ts +++ b/src/theme/input-menu.ts @@ -123,11 +123,12 @@ export default () => { 'leading-(--main-ui-square-item-height)', 'font-[family-name:var(--ui-font-family-primary)] font-(--ui-font-weight-regular)', 'inline-flex items-center gap-1', - // `min-w-0` so the tag may shrink below its content width — without it - // `truncate` on the text has nothing to truncate into. The cap is a - // share of the field rather than a pixel count: a tag only ellipsises - // when it genuinely wants more than most of the row, instead of at a - // constant that knows nothing about how wide the field is. + // A share of the field, not a length (#342): a constant ellipsised tags + // that had room to spare. `min-w-0` is what lets `truncate` engage — + // a flex child will not shrink below its content width without it. + // 70% over 50% is a deliberate call: two long tags then land on + // separate rows, and truncating sooner to avoid that is the worse + // trade. See .sync/PORTING.md §2, tag width caps. 'min-w-0 max-w-[70%]', 'data-disabled:cursor-not-allowed data-disabled:opacity-30', 'data-disabled:select-none', @@ -137,8 +138,7 @@ export default () => { tagsItemText: 'truncate min-w-0', tagsItemDelete: [ 'cursor-pointer', - // The tag can now be squeezed to 70% of the field; the delete control - // must not be what gives way when it is. + // The tag is squeezable now; the close button must not be what gives way. 'shrink-0', 'inline-flex items-center', 'disabled:cursor-not-allowed', diff --git a/src/theme/input-tags.ts b/src/theme/input-tags.ts index ef43d2a9f..f9883594d 100644 --- a/src/theme/input-tags.ts +++ b/src/theme/input-tags.ts @@ -11,7 +11,10 @@ import { fieldGroupVariant } from './field-group' export default () => { return defuFn({ slots: { - root: (prev: string) => [prev, 'flex-wrap'], + // `max-w-full`: this root is `inline-flex` with no width, so without it + // the field grows past its parent and the tag's percentage cap resolves + // against nothing. See .sync/PORTING.md §2, tag width caps. + root: (prev: string) => [prev, 'flex-wrap', 'max-w-full'], base: [ 'border-0 focus:outline-none', 'disabled:cursor-not-allowed', @@ -37,8 +40,7 @@ export default () => { 'leading-(--main-ui-square-item-height)', 'font-[family-name:var(--ui-font-family-primary)] font-(--ui-font-weight-regular)', 'inline-flex items-center gap-1', - // See input-menu.ts: the cap is a share of the field, and `min-w-0` is - // what lets `truncate` on the text actually take effect. + // See input-menu.ts for why a share and not a length. 'min-w-0 max-w-[70%]', 'data-disabled:cursor-not-allowed', 'data-disabled:opacity-30', @@ -52,6 +54,7 @@ export default () => { ].join(' '), itemDelete: [ 'cursor-pointer', + // The tag is squeezable now; the close button must not be what gives way. 'shrink-0', 'inline-flex items-center', 'disabled:cursor-not-allowed', diff --git a/test/components/__snapshots__/InputTags-vue.spec.ts.snap b/test/components/__snapshots__/InputTags-vue.spec.ts.snap index f3cedcd4f..e397d3714 100644 --- a/test/components/__snapshots__/InputTags-vue.spec.ts.snap +++ b/test/components/__snapshots__/InputTags-vue.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`InputTags > renders with ariaLabel correctly 1`] = ` -"
+"
@@ -10,7 +10,7 @@ exports[`InputTags > renders with ariaLabel correctly 1`] = ` `; exports[`InputTags > renders with as correctly 1`] = ` -"
+"
@@ -19,7 +19,7 @@ exports[`InputTags > renders with as correctly 1`] = ` `; exports[`InputTags > renders with b24ui correctly 1`] = ` -"
+"
@@ -28,7 +28,7 @@ exports[`InputTags > renders with b24ui correctly 1`] = ` `; exports[`InputTags > renders with class correctly 1`] = ` -"
+"
@@ -37,7 +37,7 @@ exports[`InputTags > renders with class correctly 1`] = ` `; exports[`InputTags > renders with default slot correctly 1`] = ` -"
+"
Default slot @@ -46,7 +46,7 @@ exports[`InputTags > renders with default slot correctly 1`] = ` `; exports[`InputTags > renders with defaultValue correctly 1`] = ` -"
+"
test