Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .sync/PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 `<body>` after close for any overlay opened without the built-in trigger slot, because reka-ui's fallback capture of `document.activeElement` skips `<body>` 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.
21 changes: 21 additions & 0 deletions playgrounds/demo/app/pages/components/input-menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -213,6 +225,15 @@ const itemsObj = ref([
class="w-full"
@create="onCreateMultiple"
/>
<B24InputMenu
v-model="valueMultipleLong"
:items="itemsLong"
multiple
aria-label="Multiple with long labels"
placeholder="Multiple, long labels"
v-bind="{ ...singleAttrs, ...props }"
class="w-full"
/>
<B24InputMenu
:icon="TaskListIcon"
placeholder="Search virtualized..."
Expand Down
9 changes: 9 additions & 0 deletions playgrounds/demo/app/pages/components/input-tags.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ const airColors = computed(() => {
})

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([
'Интеграции (Телефония, Почта, Мессенджеры)',
'Контакт-центр (Открытые линии)'
])
</script>

<template>
Expand Down Expand Up @@ -70,6 +78,7 @@ const tags = ref(['Bitrix24', 'Crm'])
<B24InputTags v-model="tags" :icon="ALetterIcon" placeholder="Icon" v-bind="{ ...singleAttrs, ...props }" class="w-full" />
<B24InputTags v-model="tags" :trailing-icon="Search2Icon" placeholder="Trailing icon" v-bind="{ ...singleAttrs, ...props }" class="w-full" />
<B24InputTags v-model="tags" :avatar="{ src: '/b24ui/demo/avatar/employee.png' }" placeholder="Avatar" v-bind="{ ...singleAttrs, ...props }" class="w-full" />
<B24InputTags v-model="tagsLong" placeholder="Long labels" v-bind="{ ...singleAttrs, ...props }" class="w-full" />
</Matrix>
</PlaygroundPage>
</template>
11 changes: 10 additions & 1 deletion src/theme/input-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
// 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',
'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 is squeezable now; the close button must not be what gives way.
'shrink-0',
'inline-flex items-center',
'disabled:cursor-not-allowed',
'disabled:pointer-events-auto',
Expand Down
11 changes: 9 additions & 2 deletions src/theme/input-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -37,6 +40,8 @@ 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 for why a share and not a length.
'min-w-0 max-w-[70%]',
'data-disabled:cursor-not-allowed',
'data-disabled:opacity-30',
'data-disabled:pointer-events-auto',
Expand All @@ -45,10 +50,12 @@ export default () => {
'bg-(--ui-color-design-tinted-bg-alt)'
].join(' '),
itemText: [
'truncate max-w-[180px]'
'truncate min-w-0'
].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',
'disabled:pointer-events-auto',
Expand Down
4 changes: 2 additions & 2 deletions test/components/__snapshots__/InputMenu-vue.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1322,10 +1322,10 @@ exports[`InputMenu > renders with multiple and modelValue correctly 1`] = `
"<!--v-if-->
<div dir="ltr" data-slot="base" class="isolate relative inline-flex items-center flex-wrap w-full border-0 focus:outline-none disabled:cursor-not-allowed disabled:pointer-events-auto disabled:select-none disabled:opacity-30 disabled:resize-none appearance-none transition-colors duration-300 ease-linear text-(--ui-color-base-1) style-blurred-bg-input hover:text-(--ui-color-base-1) focus:text-(--ui-color-base-1) active:text-(--ui-color-base-1) placeholder:text-(--ui-color-design-plain-na-content-secondary) font-[family-name:var(--ui-font-family-primary)] font-(--ui-font-weight-regular) align-middle group-not-only:not-first:after:content-[''] group-not-only:not-first:after:absolute group-not-only:not-first:after:top-[7px] group-not-only:not-first:after:bottom-[6px] group-not-only:not-first:after:left-0 group-not-only:not-first:after:w-px group-not-only:not-first:after:bg-current/30 [--main-ui-square-item-height:24px] gap-1.5 text-(length:--ui-font-size-lg)/[normal] style-filled rounded-(--ui-border-radius-sm) min-h-[34px] h-auto py-[4px] ps-[4px] ring ring-inset ring-(--ui-color-design-outline-stroke) focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-(--b24ui-border-color) hover:not-disabled:not-data-disabled:ring-1 hover:not-disabled:not-data-disabled:ring-inset hover:not-disabled:not-data-disabled:ring-(--b24ui-border-color) data-[state=open]:ring-1 data-[state=open]:ring-inset data-[state=open]:ring-(--b24ui-border-color) pe-[34px] md:text-(length:--ui-font-size-lg)/[normal]" style="pointer-events: auto;">
<!--v-if-->
<div dir="ltr" data-slot="tagsItem" class="ps-[13px] pe-[6px] rounded-(--ui-border-radius-xs) h-(--main-ui-square-item-height) font-[family-name:var(--ui-font-family-primary)] font-(--ui-font-weight-regular) inline-flex items-center 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) text-(length:--ui-font-size-sm)/(--main-ui-square-item-height) gap-[4px]" data-reka-collection-item="" aria-labelledby="reka-tags-input-item-text-v-0" aria-current="false" data-state="inactive"><span id="reka-tags-input-item-text-v-0" data-slot="tagsItemText" class="truncate max-w-[180px]">Backlog</span><button tabindex="-1" aria-labelledby="reka-tags-input-item-text-v-0" aria-current="false" data-state="inactive" type="button" data-slot="tagsItemDelete" class="cursor-pointer inline-flex items-center disabled:cursor-not-allowed disabled:pointer-events-auto text-(--b24ui-icon-color-secondary) hover:text-(--b24ui-icon-color-secondary-hover) transition-none"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true" data-slot="icon" class="size-4.5">
<div dir="ltr" data-slot="tagsItem" class="ps-[13px] pe-[6px] rounded-(--ui-border-radius-xs) h-(--main-ui-square-item-height) font-[family-name:var(--ui-font-family-primary)] font-(--ui-font-weight-regular) inline-flex items-center 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) text-(length:--ui-font-size-sm)/(--main-ui-square-item-height) gap-[4px]" data-reka-collection-item="" aria-labelledby="reka-tags-input-item-text-v-0" aria-current="false" data-state="inactive"><span id="reka-tags-input-item-text-v-0" data-slot="tagsItemText" class="truncate min-w-0">Backlog</span><button tabindex="-1" aria-labelledby="reka-tags-input-item-text-v-0" aria-current="false" data-state="inactive" type="button" data-slot="tagsItemDelete" class="cursor-pointer shrink-0 inline-flex items-center disabled:cursor-not-allowed disabled:pointer-events-auto text-(--b24ui-icon-color-secondary) hover:text-(--b24ui-icon-color-secondary-hover) transition-none"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true" data-slot="icon" class="size-4.5">
<path fill="currentColor" d="M8.46 7.54a.65.65 0 0 0-.92.92L11.08 12l-3.54 3.54a.65.65 0 1 0 .92.92L12 12.918l3.54 3.54a.65.65 0 1 0 .919-.92L12.919 12l3.54-3.54a.65.65 0 0 0-.92-.919L12 11.08z"></path>
</svg></button></div>
<div data-slot="tagsItem" class="ps-[13px] pe-[6px] rounded-(--ui-border-radius-xs) h-(--main-ui-square-item-height) font-[family-name:var(--ui-font-family-primary)] font-(--ui-font-weight-regular) inline-flex items-center 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) text-(length:--ui-font-size-sm)/(--main-ui-square-item-height) gap-[4px]" data-reka-collection-item="" aria-labelledby="reka-tags-input-item-text-v-1" aria-current="false" data-state="inactive"><span id="reka-tags-input-item-text-v-1" data-slot="tagsItemText" class="truncate max-w-[180px]">Todo</span><button tabindex="-1" aria-labelledby="reka-tags-input-item-text-v-1" aria-current="false" data-state="inactive" type="button" data-slot="tagsItemDelete" class="cursor-pointer inline-flex items-center disabled:cursor-not-allowed disabled:pointer-events-auto text-(--b24ui-icon-color-secondary) hover:text-(--b24ui-icon-color-secondary-hover) transition-none"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true" data-slot="icon" class="size-4.5">
<div data-slot="tagsItem" class="ps-[13px] pe-[6px] rounded-(--ui-border-radius-xs) h-(--main-ui-square-item-height) font-[family-name:var(--ui-font-family-primary)] font-(--ui-font-weight-regular) inline-flex items-center 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) text-(length:--ui-font-size-sm)/(--main-ui-square-item-height) gap-[4px]" data-reka-collection-item="" aria-labelledby="reka-tags-input-item-text-v-1" aria-current="false" data-state="inactive"><span id="reka-tags-input-item-text-v-1" data-slot="tagsItemText" class="truncate min-w-0">Todo</span><button tabindex="-1" aria-labelledby="reka-tags-input-item-text-v-1" aria-current="false" data-state="inactive" type="button" data-slot="tagsItemDelete" class="cursor-pointer shrink-0 inline-flex items-center disabled:cursor-not-allowed disabled:pointer-events-auto text-(--b24ui-icon-color-secondary) hover:text-(--b24ui-icon-color-secondary-hover) transition-none"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true" data-slot="icon" class="size-4.5">
<path fill="currentColor" d="M8.46 7.54a.65.65 0 0 0-.92.92L11.08 12l-3.54 3.54a.65.65 0 1 0 .92.92L12 12.918l3.54 3.54a.65.65 0 1 0 .919-.92L12.919 12l3.54-3.54a.65.65 0 0 0-.92-.919L12 11.08z"></path>
</svg></button></div><input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" aria-disabled="false" aria-expanded="true" aria-controls="" aria-autocomplete="list" role="combobox" data-slot="tagsInput" class="text-(length:--ui-font-size-lg)/[normal] flex-1 border-0 bg-transparent ps-[6px] pe-[0px] py-0 placeholder:text-(--ui-color-design-plain-na-content-secondary) focus:outline-none disabled:cursor-not-allowed disabled:pointer-events-auto disabled:select-none disabled:opacity-30 focus:ring-0 focus-visible:ring-0" value="" aria-activedescendant="reka-combobox-item-v-5">
<!--v-if-->
Expand Down
Loading