Skip to content
Open
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
448 changes: 448 additions & 0 deletions packages/main/cypress/specs/Toolbar.cy.tsx

Large diffs are not rendered by default.

146 changes: 124 additions & 22 deletions packages/main/src/Toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolbarItemBase>,
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"));
}
Expand All @@ -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.
*
Expand Down Expand Up @@ -192,6 +213,9 @@ class Toolbar extends UI5Element {
itemsToOverflow: Array<ToolbarItemBase> = [];
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<string, number> = new Map();
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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);
Expand All @@ -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<ToolbarItemBase> = [];
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<ToolbarItemBase, number>();
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<DistributionUnit> {
const movable = this.movableItems;
const slotIndex = new Map<ToolbarItemBase, number>();
this.items.forEach((item, idx) => slotIndex.set(item, idx));

const groupUnits = new Map<string, DistributionUnit>();
const units: Array<DistributionUnit> = [];

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);
Expand Down
88 changes: 88 additions & 0 deletions packages/main/src/ToolbarItemBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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`;
}
Expand Down
Loading
Loading