diff --git a/packages/main/cypress/specs/Toolbar.cy.tsx b/packages/main/cypress/specs/Toolbar.cy.tsx index 9d13c0f3ef063..51cea3570df71 100644 --- a/packages/main/cypress/specs/Toolbar.cy.tsx +++ b/packages/main/cypress/specs/Toolbar.cy.tsx @@ -5,6 +5,7 @@ import ToolbarSelectOption from "../../src/ToolbarSelectOption.js"; import ToolbarSeparator from "../../src/ToolbarSeparator.js"; import ToolbarSpacer from "../../src/ToolbarSpacer.js"; import ToolbarItem from "../../src/ToolbarItem.js"; +import type ToolbarItemBase from "../../src/ToolbarItemBase.js"; import CheckBox from "../../src/CheckBox.js"; import add from "@ui5/webcomponents-icons/dist/add.js"; import decline from "@ui5/webcomponents-icons/dist/decline.js"; @@ -1315,3 +1316,450 @@ describe("Toolbar overflow button accessible name", () => { .should("have.attr", "accessible-name", "More actions for Opportunity 123"); }); }); + +describe("Toolbar overflow group", () => { + it("overflows both members of a contiguous group together when only one would otherwise overflow", () => { + // Four buttons + one container width chosen so that without grouping + // the rightmost button (and only the rightmost) would overflow. With both + // "GroupA"/"GroupB" tagged into the same group, both must overflow together. + cy.mount( +
+ + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // Both grouped items must overflow together. At this container width an + // ungrouped clone would overflow exactly one item (the rightmost one); + // with the group present, BOTH must move into the popover. + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", true); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", true); + cy.get("[ui5-toolbar-button][text='Solo1']") + .should("have.prop", "isOverflowed", false); + cy.get("[ui5-toolbar-button][text='Solo2']") + .should("have.prop", "isOverflowed", false); + }); + + it("keeps an ungrouped item between non-contiguous group members in the bar while both group members overflow", () => { + // Source order: [GroupA(g), Solo2(ungrouped), Solo3(ungrouped), GroupB(g)]. + // With the chosen width, only the rightmost button would overflow naturally. + // With grouping, the entire group (A and B) goes to the popover and the + // ungrouped Solo2/Solo3 between them keep their slot positions in the bar. + cy.mount( +
+ + + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // Group members both in popover. + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", true); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", true); + // Ungrouped items between them stay in the visible bar. + cy.get("[ui5-toolbar-button][text='Solo2']") + .should("have.prop", "isOverflowed", false); + cy.get("[ui5-toolbar-button][text='Solo3']") + .should("have.prop", "isOverflowed", false); + + // In the popover, group members appear adjacent and in slot order + // (GroupA before GroupB), regardless of where ungrouped items sit + // between them in the source. + cy.get("#otb_noncontiguous_group").then($tb => { + const tb = $tb[0] as Toolbar; + const order = tb.overflowItems.map(it => (it as ToolbarButton).text); + expect(order).to.deep.equal(["GroupA", "GroupB"]); + }); + }); + + it("returns both group members to the bar when the toolbar widens enough to fit them again", () => { + // Start wide enough so the group fits, narrow so both group members + // overflow together, then widen again — both members must come back. + cy.mount( +
+ + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // At 600px, both group members fit in the bar. + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", false); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", false); + + // Narrow the host — both group members must overflow together. + cy.get("#otb_resize_host").invoke("attr", "style", "width: 220px;"); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", true); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", true); + + // Widen the host — both group members must return to the bar. + cy.get("#otb_resize_host").invoke("attr", "style", "width: 600px;"); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", false); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", false); + }); + + it("mirrors the popover order in reverse-overflow mode while keeping the group contiguous", () => { + // A two-member group + ungrouped items, narrow enough that the group overflows. + // With reverseOverflow=true (popover placed above the toolbar) the popover list + // is mirrored: [GroupA, GroupB] becomes [GroupB, GroupA], but the group stays + // adjacent. + cy.mount( +
+ + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // Flip reverseOverflow on and force a re-render via processOverflowLayout. + cy.get("#otb_reverse_group").then($tb => { + const tb = $tb[0] as Toolbar; + tb.reverseOverflow = true; + }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200); + + // The popover items should appear in mirrored order: GroupB before GroupA. + cy.get("#otb_reverse_group").then($tb => { + const tb = $tb[0] as Toolbar; + const order = tb.overflowItems.map(it => (it as ToolbarButton).text); + expect(order).to.deep.equal(["GroupB", "GroupA"]); + }); + }); + + it("re-distributes when overflowGroup changes on an item at runtime", () => { + // Start grouped: at this width both grouped items overflow together. + cy.mount( +
+ + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // Baseline: both group members overflow. + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", true); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", true); + + // Remove the group from GroupA at runtime. The pair is no longer yoked, + // and only the rightmost item should remain in the popover. + cy.get("[ui5-toolbar-button][text='GroupA']").then($el => { + ($el[0] as ToolbarItemBase).overflowGroup = ""; + }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", false); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", true); + }); + + it("preserves slot order in the visible bar regardless of grouping", () => { + // Layout in source order: [Solo1, GroupA(g), Solo2, Solo3, GroupB(g)]. + // At the chosen width, the group (A+B) overflows together. The visible bar + // must keep Solo1, Solo2, Solo3 in that slot order — never reordered to put + // the group's "remaining" member next to its sibling. + cy.mount( +
+ + + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // The visible bar's standardItems (items not in overflow) must be in slot order. + cy.get("#otb_slot_order").then($tb => { + const tb = $tb[0] as Toolbar; + const barOrder = tb.standardItems.map(it => (it as ToolbarButton).text); + expect(barOrder).to.deep.equal(["Solo1", "Solo2", "Solo3"]); + }); + }); + + it("pushes a whole group into overflow even when one member's worth of width would have sufficed (atomic over-shoot)", () => { + // Five buttons in source order: [Solo1, Solo2, GroupA, GroupB, GroupC] where + // GroupA/B/C share `overflow-group="g"`. At the chosen container width an + // ungrouped clone would overflow ONLY the rightmost button (GroupC) — that's + // the worth of width the overflow algorithm "needs" to recover. With the group + // in place, the entire group (≈3× one button's width) must move to the popover + // atomically. The over-shoot is accepted by design (ADR-0001). + cy.mount( +
+ + + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // All three group members go to overflow, not just the rightmost one. + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", true); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", true); + cy.get("[ui5-toolbar-button][text='GroupC']") + .should("have.prop", "isOverflowed", true); + // Solo items remain in the bar — extra empty space is the accepted cost. + cy.get("[ui5-toolbar-button][text='Solo1']") + .should("have.prop", "isOverflowed", false); + cy.get("[ui5-toolbar-button][text='Solo2']") + .should("have.prop", "isOverflowed", false); + }); + + it("warns once for AlwaysOverflow on a grouped item and treats its priority as Default for the layout pass", () => { + // GroupA has both `overflow-group="g"` AND `overflowPriority="AlwaysOverflow"`. + // ADR-0001 forbids this combination: the warning fires once, the priority is + // dropped to `Default` for the layout pass, and the group's atomic-overflow + // contract is preserved (GroupA and GroupB go together — decided by space, + // not by the now-ignored absolute priority). + cy.window().then(win => { + cy.stub(win.console, "warn").as("warn"); + }); + + cy.mount( +
+ + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // At 600px both group members fit in the bar — the AlwaysOverflow priority + // would otherwise force GroupA into the popover; under the validation rule + // it is dropped to Default and the whole group stays atomically in the bar. + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", false); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", false); + + // Force another layout pass — the warning must NOT re-fire. + cy.get("#otb_priority_violation_always").then($tb => { + ($tb[0] as Toolbar).onResize(); + }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200); + + // Exactly one warning, naming the offending element and the rule. + cy.get("@warn").should("have.been.calledOnce"); + cy.get("@warn").its("firstCall.args.0").should("match", /overflow-group/i); + cy.get("@warn").its("firstCall.args.0").should("match", /AlwaysOverflow|priority/i); + }); + + it("warns once for NeverOverflow on a grouped item and treats its priority as Default for the layout pass", () => { + // GroupA has both `overflow-group="g"` AND `overflowPriority="NeverOverflow"`. + // Under the ADR-0001 rule, the warning fires once and the priority is dropped + // to `Default` for the layout pass — so when the toolbar is narrowed enough + // for the group to need overflow, GroupA can in fact overflow (alongside GroupB). + cy.window().then(win => { + cy.stub(win.console, "warn").as("warn"); + }); + + cy.mount( +
+ + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // With the priority dropped, both grouped items overflow atomically. + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", true); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", true); + + // Force another layout pass — the warning must NOT re-fire. + cy.get("#otb_priority_violation_never").then($tb => { + ($tb[0] as Toolbar).onResize(); + }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200); + + // Exactly one warning, naming the offending element and the rule. + cy.get("@warn").should("have.been.calledOnce"); + cy.get("@warn").its("firstCall.args.0").should("match", /overflow-group/i); + cy.get("@warn").its("firstCall.args.0").should("match", /NeverOverflow|priority/i); + }); + + it("warns once for a spacer with overflow-group and leaves the spacer's overflow behavior unchanged (not yoked to the group)", () => { + // A fixed-width spacer tagged with the same group as two buttons. The buttons + // overflow together; the spacer is NOT yoked — it stays in the visible bar. + // One spacer-rule warning fires once across re-renders. + cy.window().then(win => { + cy.stub(win.console, "warn").as("warn"); + }); + + cy.mount( +
+ + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // Both grouped buttons overflow. + cy.get("[ui5-toolbar-button][text='GroupA']") + .should("have.prop", "isOverflowed", true); + cy.get("[ui5-toolbar-button][text='GroupB']") + .should("have.prop", "isOverflowed", true); + + // The spacer is NOT yoked — it stays in the bar (its existing overflow + // behavior is unaffected by the group tag). + cy.get("#otb_spacer_violation").then($tb => { + const tb = $tb[0] as Toolbar; + const spacer = tb.items.find(it => it.isSpacer)!; + expect(spacer.isOverflowed, "spacer must not be yoked into the group's overflow").to.equal(false); + expect(tb.standardItems, "spacer must remain a standard (visible) item").to.include(spacer); + }); + + // Force another layout pass — the spacer warning must NOT re-fire. + cy.get("#otb_spacer_violation").then($tb => { + ($tb[0] as Toolbar).onResize(); + }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200); + + // Exactly one warning, naming the spacer and the rule. + cy.get("@warn").should("have.been.calledOnce"); + cy.get("@warn").its("firstCall.args.0").should("match", /overflow-group/i); + cy.get("@warn").its("firstCall.args.0").should("match", /spacer/i); + }); + + it("warns for the canonical flex-spacer case (no width, default priority) with a non-empty overflow-group", () => { + // The default `` has no width and `overflow-priority="Default"` — + // so it sits in `movableItems` like a normal item. Putting `overflow-group` on it must + // still trip the spacer-rule warning even though `ignoreSpace` removes it from the + // popover render and visible bar regardless of the group. + cy.window().then(win => { + cy.stub(win.console, "warn").as("warn"); + }); + + cy.mount( +
+ + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + cy.get("@warn").should("have.been.calledOnce"); + cy.get("@warn").its("firstCall.args.0").should("match", /spacer/i); + }); + + it("does not warn for valid configurations: grouped Default-priority items and ungrouped spacers", () => { + // All combinations here are valid by ADR-0001: two items share a non-empty + // group with the default priority, and a spacer carries no group tag. + // The toolbar must remain silent — no `console.warn` calls. + cy.window().then(win => { + cy.stub(win.console, "warn").as("warn"); + }); + + cy.mount( +
+ + + + + + + +
+ ); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + + // Trigger an extra layout pass for good measure. + cy.get("#otb_no_warnings").then($tb => { + ($tb[0] as Toolbar).onResize(); + }); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200); + + cy.get("@warn").should("not.have.been.called"); + }); +}); diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts index 977fbab7554ed..3f238805e446f 100644 --- a/packages/main/src/Toolbar.ts +++ b/packages/main/src/Toolbar.ts @@ -46,6 +46,19 @@ type ToolbarMinWidthChangeEventDetail = { minWidth: number, }; +/** + * One step of the overflow distribution algorithm — either a single ungrouped item or + * all members of one non-empty `overflowGroup`, treated atomically. A unit's order key + * for the right-to-left distribution walk is the rightmost member's slot index; its + * width is the sum of member widths. + */ +type DistributionUnit = { + group: string, + members: Array, + width: number, + rightmostIndex: number, +}; + function calculateCSSREMValue(styleSet: CSSStyleDeclaration, propertyName: string): number { return Number(styleSet.getPropertyValue(propertyName).replace("rem", "")) * parseInt(getComputedStyle(document.body).getPropertyValue("font-size")); } @@ -62,6 +75,14 @@ function parsePxValue(styleSet: CSSStyleDeclaration, propertyName: string): numb * The `ui5-toolbar` component is used to create a horizontal layout with items. * The items can be overflowing in a popover, when the space is not enough to show all of them. * + * ### Grouped Overflow + * + * Items that share the same non-empty `overflowGroup` string are treated as one atomic + * unit during overflow distribution: when any member must move into the overflow + * popover, all members move together. The visible bar always preserves slot order; + * the group becomes adjacent only inside the popover. See the `overflowGroup` property + * on `ToolbarItemBase` for the full contract. + * * ### Keyboard Handling * The `ui5-toolbar` provides advanced keyboard handling. * @@ -192,6 +213,9 @@ class Toolbar extends UI5Element { itemsToOverflow: Array = []; itemsWidth = 0; minContentWidth = 0; + // Snapshot of children's `overflowGroup` values, joined with "|". Tracks whether + // the grouping decision has changed even when total content width has not. + _groupingKey = ""; _lastFocusedItem?: ToolbarItemBase | HTMLElement; ITEMS_WIDTH_MAP: Map = new Map(); @@ -223,11 +247,11 @@ class Toolbar extends UI5Element { } get alwaysOverflowItems() { - return this.items.filter(item => item.overflowPriority === ToolbarItemOverflowBehavior.AlwaysOverflow); + return this.items.filter(item => item.effectiveOverflowPriority === ToolbarItemOverflowBehavior.AlwaysOverflow); } get movableItems() { - return this.items.filter(item => item.overflowPriority !== ToolbarItemOverflowBehavior.AlwaysOverflow && item.overflowPriority !== ToolbarItemOverflowBehavior.NeverOverflow); + return this.items.filter(item => item.effectiveOverflowPriority !== ToolbarItemOverflowBehavior.AlwaysOverflow && item.effectiveOverflowPriority !== ToolbarItemOverflowBehavior.NeverOverflow); } get overflowItems() { @@ -312,7 +336,8 @@ class Toolbar extends UI5Element { onInvalidation(changeInfo: ChangeInfo) { if (changeInfo.reason === "childchange") { const currentItemsWidth = this.items.reduce((total, item) => total + this.getItemWidth(item), 0); - if (currentItemsWidth !== this.itemsWidth) { + const currentGroupingKey = this.items.map(item => item.overflowGroup).join("|"); + if (currentItemsWidth !== this.itemsWidth || currentGroupingKey !== this._groupingKey) { this.onToolbarItemChange(); } } @@ -434,7 +459,7 @@ class Toolbar extends UI5Element { this.items.forEach(item => { const itemWidth = this.getItemWidth(item); totalWidth += itemWidth; - if (item.overflowPriority === ToolbarItemOverflowBehavior.NeverOverflow) { + if (item.effectiveOverflowPriority === ToolbarItemOverflowBehavior.NeverOverflow) { minWidth += itemWidth; } this.ITEMS_WIDTH_MAP.set(item._id, itemWidth); @@ -449,38 +474,115 @@ class Toolbar extends UI5Element { this.itemsWidth = totalWidth; this.minContentWidth = minWidth; + this._groupingKey = this.items.map(item => item.overflowGroup).join("|"); } distributeItems(overflowSpace = 0) { - const movableItems = this.movableItems.reverse(); - let index = 0; - let currentItem = movableItems[index]; - this.itemsToOverflow = []; // distribute items that always overflow this.distributeItemsThatAlwaysOverflow(); - while (overflowSpace > 0 && currentItem) { - this.itemsToOverflow.unshift(currentItem); - overflowSpace -= this.getCachedItemWidth(currentItem?._id) || 0; - index++; - currentItem = movableItems[index]; - } - - // If the last bar item is a spacer, force it to the overflow even if there is enough space for it - if (index < movableItems.length) { - let lastItem = movableItems[index]; - while (index <= movableItems.length - 1 && lastItem.isSeparator) { - this.itemsToOverflow.unshift(lastItem); - index++; - lastItem = movableItems[index]; + // Bucket movable items (in slot order) into distribution units. + // A unit is either a single ungrouped item, or a group of items + // sharing the same non-empty `overflowGroup`. A unit is atomic: + // when it is pushed into overflow, all its members move together. + // The unit's representative slot position is its rightmost member's + // index — that index is what orders the unit during distribution. + const units = this.buildDistributionUnits(); + + // Walk units from rightmost to leftmost, pushing each atomically. + // A unit is pushed in full as soon as overflowSpace is still positive; + // the post-push budget is allowed to go negative — over-shoot is accepted + // by design (ADR-0001 §Consequences) because a group is indivisible. + const overflowedItems: Array = []; + let nextNonOverflowedUnitIndex = units.length - 1; + for (let i = units.length - 1; i >= 0; i--) { + if (overflowSpace <= 0) { + nextNonOverflowedUnitIndex = i; + break; + } + const unit = units[i]; + overflowedItems.push(...unit.members); + overflowSpace -= unit.width; + nextNonOverflowedUnitIndex = i - 1; + } + + // If the last bar item is a separator, force it (and any contiguous + // trailing separators) into overflow even if there is enough space. + // Only single-member separator units are considered — pushing a + // group's entire content (non-separator content included) because its + // rightmost member happens to be a separator would be wrong. + while (nextNonOverflowedUnitIndex >= 0) { + const unit = units[nextNonOverflowedUnitIndex]; + if (unit.members.length === 1 && unit.members[0].isSeparator) { + overflowedItems.push(...unit.members); + nextNonOverflowedUnitIndex--; + } else { + break; } } + // itemsToOverflow must be in slot order so popover rendering matches + // the developer's source order (group members adjacent by construction). + const slotIndex = new Map(); + this.items.forEach((item, idx) => slotIndex.set(item, idx)); + overflowedItems.sort((a, b) => (slotIndex.get(a)! - slotIndex.get(b)!)); + this.itemsToOverflow.push(...overflowedItems); + this.setSeperatorsVisibilityInOverflow(); } + /** + * Buckets `movableItems` (in slot order) into atomic distribution units. + * Each unit either holds a single ungrouped item or all members of one + * non-empty `overflowGroup`. A unit's order key is its rightmost member's + * slot index. Returned units are sorted ascending by that key. + */ + buildDistributionUnits(): Array { + const movable = this.movableItems; + const slotIndex = new Map(); + this.items.forEach((item, idx) => slotIndex.set(item, idx)); + + const groupUnits = new Map(); + const units: Array = []; + + movable.forEach(item => { + const itemWidth = this.getCachedItemWidth(item._id) || 0; + const slotIdx = slotIndex.get(item)!; + const groupKey = item.effectiveOverflowGroup; + if (groupKey === "") { + units.push({ + group: "", + members: [item], + width: itemWidth, + rightmostIndex: slotIdx, + }); + return; + } + const existing = groupUnits.get(groupKey); + if (existing) { + existing.members.push(item); + existing.width += itemWidth; + if (slotIdx > existing.rightmostIndex) { + existing.rightmostIndex = slotIdx; + } + } else { + const unit: DistributionUnit = { + group: groupKey, + members: [item], + width: itemWidth, + rightmostIndex: slotIdx, + }; + groupUnits.set(groupKey, unit); + units.push(unit); + } + }); + + units.sort((a, b) => a.rightmostIndex - b.rightmostIndex); + return units; + } + distributeItemsThatAlwaysOverflow() { this.alwaysOverflowItems.forEach((item: ToolbarItemBase) => { this.itemsToOverflow.push(item); diff --git a/packages/main/src/ToolbarItemBase.ts b/packages/main/src/ToolbarItemBase.ts index 989819ad242eb..74f80fccb8999 100644 --- a/packages/main/src/ToolbarItemBase.ts +++ b/packages/main/src/ToolbarItemBase.ts @@ -40,6 +40,32 @@ class ToolbarItemBase extends UI5Element { @property() overflowPriority: `${ToolbarItemOverflowBehavior}` = "Default"; + /** + * Co-overflow tag. Items in the same `ui5-toolbar` whose `overflowGroup` is the same + * non-empty string overflow as one atomic unit: either all visible in the bar, or all + * in the overflow popover, never split. The empty string (the default) means "no group" — + * the item participates in overflow independently. + * + * The tag is a free-form, case-sensitive string label (e.g. `"filters"`, `"search"`). It is + * layout-only and carries no ARIA, keyboard, or visual-cluster semantics. Items in a + * non-empty group must have `overflowPriority = "Default"`; `AlwaysOverflow` and + * `NeverOverflow` are forbidden inside a group — setting one of those on a grouped item + * emits a one-shot `console.warn` and the item's priority is treated as `Default` for + * the layout pass. Spacers (`ui5-toolbar-spacer`) do not participate in grouping; setting + * a non-empty `overflowGroup` on a spacer emits a one-shot `console.warn` and the spacer's + * existing overflow behavior is unchanged. + * + * The visible bar always preserves slot order — ungrouped items between group members + * keep their slot positions and the toolbar never reorders DOM children. In the popover + * group members appear adjacent in slot order. + * + * @public + * @default "" + * @since 2.27.0 + */ + @property() + overflowGroup = ""; + /** * Defines if the toolbar overflow popup should close upon interaction with the item. * It will close by default. @@ -70,6 +96,11 @@ class ToolbarItemBase extends UI5Element { _isOverflowed: boolean = false; + // One-shot guards for `overflowGroup` validation warnings — suppress repeat + // warnings across re-renders, consistent with `ToolbarItem.checkForWrapper`. + _overflowGroupPriorityWarned = false; + _overflowGroupSpacerWarned = false; + get isOverflowed(): boolean { return this._isOverflowed; } @@ -151,6 +182,63 @@ class ToolbarItemBase extends UI5Element { return false; } + /** + * Returns the `overflowPriority` actually used by the toolbar's distribution + * algorithm. Items in a non-empty `overflowGroup` must have `Default` priority + * (ADR-0001); when a developer puts `AlwaysOverflow` or `NeverOverflow` on a + * grouped non-spacer item, this getter emits a one-shot `console.warn` and + * downgrades the priority to `"Default"` for layout. Spacers are exempt + * from the priority-violation rule — they get their own spacer-rule warning + * elsewhere and keep their declared priority here. + * + * @protected + */ + get effectiveOverflowPriority(): `${ToolbarItemOverflowBehavior}` { + const declared = this.overflowPriority; + if ( + !this.isSpacer + && this.overflowGroup !== "" + && (declared === "AlwaysOverflow" || declared === "NeverOverflow") + ) { + if (!this._overflowGroupPriorityWarned) { + this._overflowGroupPriorityWarned = true; + // eslint-disable-next-line no-console + console.warn( + `[ui5-toolbar] ${this.tagName.toLowerCase()} has both overflow-group="${this.overflowGroup}" and overflow-priority="${declared}". ` + + `Items in a non-empty overflow-group must use overflow-priority="Default"; priority dropped to Default for layout.`, + this, + ); + } + return "Default"; + } + return declared; + } + + /** + * Returns the `overflowGroup` actually used by the toolbar's distribution + * algorithm. Spacers cannot participate in grouping (ADR-0001); a spacer + * with a non-empty `overflowGroup` emits a one-shot `console.warn` and this + * getter returns `""` so the spacer is treated as ungrouped by the algorithm. + * + * @protected + */ + get effectiveOverflowGroup(): string { + const declared = this.overflowGroup; + if (this.isSpacer && declared !== "") { + if (!this._overflowGroupSpacerWarned) { + this._overflowGroupSpacerWarned = true; + // eslint-disable-next-line no-console + console.warn( + `[ui5-toolbar] ${this.tagName.toLowerCase()} has overflow-group="${declared}". ` + + `Spacers cannot participate in an overflow-group; the group tag is ignored.`, + this, + ); + } + return ""; + } + return declared; + } + get stableDomRef() { return this.getAttribute("stable-dom-ref") || `${this._id}-stable-dom-ref`; } diff --git a/packages/main/test/pages/ToolbarOverflowGroup.html b/packages/main/test/pages/ToolbarOverflowGroup.html new file mode 100644 index 0000000000000..a31f40d265e06 --- /dev/null +++ b/packages/main/test/pages/ToolbarOverflowGroup.html @@ -0,0 +1,148 @@ + + + + + Toolbar — overflow group + + + + + + + +

Toolbar — grouped overflow

+

+ Items that share the same non-empty overflow-group attribute overflow + as one atomic unit. Drag the slider to narrow the toolbar and watch the group + yoke into the overflow popover together. +

+ +
+

1. Contiguous group: "filters"

+

The Filter label and its select share overflow-group="filters". They + are slotted next to each other and always overflow together.

+
+ + 800px +
+
+ + + + + + + + All + Open + Closed + + +
+
+ +
+

2. Non-contiguous group

+

Group members need not be adjacent. Here "Range" and "Date" share + overflow-group="range" with an unrelated "Sort" button between them. + The visible bar always preserves slot order; in the popover the group becomes + adjacent.

+
+ + 800px +
+
+ + + + + + +
+
+ +
+

3. Runtime regrouping

+

Toggle the group on/off at runtime. The toolbar re-distributes on the next + render cycle.

+
+ Disable group "g" +
+
+ + + + + + +
+
+ +
+

4. Reverse-overflow placement

+

When the overflow popover is placed above the toolbar (reverseOverflow + mode), the list is mirrored end-to-end. The group remains contiguous, with its + internal order reversed alongside everything else.

+
+ Flip reverseOverflow + Open popover +
+
+ + + + + + +
+
+ + + + + diff --git a/packages/main/test/pages/ToolbarOverflowGroupValidation.html b/packages/main/test/pages/ToolbarOverflowGroupValidation.html new file mode 100644 index 0000000000000..c42640ec56275 --- /dev/null +++ b/packages/main/test/pages/ToolbarOverflowGroupValidation.html @@ -0,0 +1,169 @@ + + + + + Toolbar — overflow-group validation + + + + + + + +

Toolbar — overflow-group validation

+

+ Each scenario configures the toolbar incorrectly per ADR-0001 and should produce + exactly one console.warn per offending item. Open the + DevTools console before interacting with the page. +

+ +
+

1. Mixed-priority violation: AlwaysOverflow on a grouped item

+

+ Filter button has overflow-group="filters" AND + overflow-priority="AlwaysOverflow". The warning should fire once and + the item's priority should drop to Default for layout, so the group + overflows atomically together with the select. +

+
+ + + + + + All + Open + Closed + + +
+
+ Shrink frame1 to 300px + Force re-render (toggle width) +
+
+ +
+

2. Mixed-priority violation: NeverOverflow on a grouped item

+

+ Range button has overflow-group="range" AND + overflow-priority="NeverOverflow". The warning should fire once and the + priority should drop to Default so the group can overflow as one. +

+
+ + + + + + +
+
+ Shrink frame2 to 280px + Force re-render (toggle width) +
+
+ +
+

3. Spacer violation: overflow-group on a spacer

+

+ A ui5-toolbar-spacer with overflow-group="sg". The warning + should fire once; the spacer continues its existing flex/spacer behavior and is + not yoked to the group's overflow decision. +

+
+ + + + + +
+
+ Force re-render (toggle width) +
+
+ +
+

4. Valid configuration (control case — no warnings expected)

+

+ Two grouped buttons with default priority and a spacer with no group. The console + should remain silent for this section. +

+
+ + + + + + +
+
+ +
+

Diagnostics

+

+ Click below to dump the console.warn count + the effective getters for + each test element. Useful when running this page under Chrome DevTools MCP. +

+ Dump diagnostics +

+	
+ + + + + diff --git a/packages/website/docs/_components_pages/main/Toolbar/Toolbar.mdx b/packages/website/docs/_components_pages/main/Toolbar/Toolbar.mdx index dd2cd194baa18..e19737bd25b70 100644 --- a/packages/website/docs/_components_pages/main/Toolbar/Toolbar.mdx +++ b/packages/website/docs/_components_pages/main/Toolbar/Toolbar.mdx @@ -8,6 +8,7 @@ import NeverOverflowingItems from "../../../_samples/main/Toolbar/NeverOverflowi import SpacerAndSeparator from "../../../_samples/main/Toolbar/SpacerAndSeparator/SpacerAndSeparator.md"; import ItemsAlignment from "../../../_samples/main/Toolbar/ItemsAlignment/ItemsAlignment.md"; import ToolbarItem from "../../../_samples/main/Toolbar/ToolbarItem/ToolbarItem.md"; +import GroupedOverflow from "../../../_samples/main/Toolbar/GroupedOverflow/GroupedOverflow.md"; <%COMPONENT_OVERVIEW%> @@ -39,3 +40,7 @@ You can align items to the Start, or to the End via the "align-content" property ### ToolbarItem ToolbarItem wrapper used to add any component to Toolbar + +### Grouped Overflow +Items that share the same non-empty `overflow-group` string overflow together as one atomic unit — either all visible in the bar or all in the overflow popover, never split. The value is a free-form string label. + diff --git a/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/GroupedOverflow.md b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/GroupedOverflow.md new file mode 100644 index 0000000000000..0c062a836e844 --- /dev/null +++ b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/GroupedOverflow.md @@ -0,0 +1,5 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; +import react from '!!raw-loader!./sample.tsx'; + + diff --git a/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/main.js b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/main.js new file mode 100644 index 0000000000000..ccb41cff3109b --- /dev/null +++ b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/main.js @@ -0,0 +1,7 @@ +import "@ui5/webcomponents/dist/Toolbar.js"; +import "@ui5/webcomponents/dist/ToolbarButton.js"; +import "@ui5/webcomponents/dist/ToolbarSelect.js"; +import "@ui5/webcomponents/dist/ToolbarSelectOption.js"; +import "@ui5/webcomponents/dist/ToolbarSeparator.js"; +import "@ui5/webcomponents-icons/dist/add.js"; +import "@ui5/webcomponents-icons/dist/decline.js"; diff --git a/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.html b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.html new file mode 100644 index 0000000000000..fe93ecc0a7719 --- /dev/null +++ b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.html @@ -0,0 +1,39 @@ + + + + + + + Sample + + + + + + + + + + + + All + Open + Closed + + + + + + + diff --git a/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.tsx b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.tsx new file mode 100644 index 0000000000000..0bfaa1d83d5af --- /dev/null +++ b/packages/website/docs/_samples/main/Toolbar/GroupedOverflow/sample.tsx @@ -0,0 +1,46 @@ +import createReactComponent from "@ui5/webcomponents-base/dist/createReactComponent.js"; +import ToolbarClass from "@ui5/webcomponents/dist/Toolbar.js"; +import ToolbarButtonClass from "@ui5/webcomponents/dist/ToolbarButton.js"; +import ToolbarSelectClass from "@ui5/webcomponents/dist/ToolbarSelect.js"; +import ToolbarSelectOptionClass from "@ui5/webcomponents/dist/ToolbarSelectOption.js"; +import ToolbarSeparatorClass from "@ui5/webcomponents/dist/ToolbarSeparator.js"; +import "@ui5/webcomponents-icons/dist/add.js"; +import "@ui5/webcomponents-icons/dist/decline.js"; + +const Toolbar = createReactComponent(ToolbarClass); +const ToolbarButton = createReactComponent(ToolbarButtonClass); +const ToolbarSelect = createReactComponent(ToolbarSelectClass); +const ToolbarSelectOption = createReactComponent(ToolbarSelectOptionClass); +const ToolbarSeparator = createReactComponent(ToolbarSeparatorClass); + +function App() { + return ( + <> + {/* + Items that share the same non-empty "overflowGroup" string overflow together + as one atomic unit — either all visible in the bar or all in the overflow + popover, never split. The value is a free-form string label; equality is + exact and case-sensitive. + + Here the "Filter:" label-button and its select share overflowGroup="filters". + The toolbar's width is constrained so that the group cannot fit alongside the + ungrouped Add/Reject buttons — the bar shows Add, Reject, and the overflow + button. Click the overflow button to see both group members appear adjacent + to each other inside the popover. + */} + + + + + + + All + Open + Closed + + + + ); +} + +export default App;