From deb1289cff60451b824bfa71e679b48aa490e6c6 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Tue, 3 Feb 2026 22:42:40 +0200 Subject: [PATCH 1/6] refactor(ui5-shellbar): split into modules --- packages/fiori/cypress/specs/ShellBar.cy.tsx | 452 ++-- packages/fiori/src/ShellBar.ts | 1840 ++++++----------- packages/fiori/src/ShellBarItem.ts | 78 +- packages/fiori/src/ShellBarItemTemplate.tsx | 34 + .../fiori/src/ShellBarPopoverTemplate.tsx | 50 - packages/fiori/src/ShellBarTemplate.tsx | 522 ++--- .../fiori/src/i18n/messagebundle.properties | 3 + .../src/shellbar/IShellBarSearchController.ts | 32 + .../src/shellbar/ShellBarAccessibility.ts | 107 + .../src/shellbar/ShellBarItemNavigation.ts | 115 ++ packages/fiori/src/shellbar/ShellBarLegacy.ts | 187 ++ .../fiori/src/shellbar/ShellBarOverflow.ts | 243 +++ packages/fiori/src/shellbar/ShellBarSearch.ts | 184 ++ .../src/shellbar/ShellBarSearchLegacy.ts | 163 ++ .../templates/ShellBarLegacyTemplate.tsx | 190 ++ .../ShellBarSearchLegacyTemplate.tsx | 61 + .../templates/ShellBarSearchTemplate.tsx | 40 + .../fiori/src/themes/NavigationLayout.css | 1 + packages/fiori/src/themes/ShellBar.css | 553 ++--- packages/fiori/src/themes/ShellBarItem.css | 42 + packages/fiori/src/themes/ShellBarLegacy.css | 174 ++ .../fiori/src/themes/ShellBarSearchLegacy.css | 44 + packages/fiori/test/pages/ShellBarV2.html | 148 ++ .../test/pages/ShellBarV2_SearchTypes.html | 319 +++ .../fiori/test/pages/ShellBar_Comparison.html | 891 ++++++++ packages/main/src/List.ts | 11 + 26 files changed, 4370 insertions(+), 2114 deletions(-) create mode 100644 packages/fiori/src/ShellBarItemTemplate.tsx delete mode 100644 packages/fiori/src/ShellBarPopoverTemplate.tsx create mode 100644 packages/fiori/src/shellbar/IShellBarSearchController.ts create mode 100644 packages/fiori/src/shellbar/ShellBarAccessibility.ts create mode 100644 packages/fiori/src/shellbar/ShellBarItemNavigation.ts create mode 100644 packages/fiori/src/shellbar/ShellBarLegacy.ts create mode 100644 packages/fiori/src/shellbar/ShellBarOverflow.ts create mode 100644 packages/fiori/src/shellbar/ShellBarSearch.ts create mode 100644 packages/fiori/src/shellbar/ShellBarSearchLegacy.ts create mode 100644 packages/fiori/src/shellbar/templates/ShellBarLegacyTemplate.tsx create mode 100644 packages/fiori/src/shellbar/templates/ShellBarSearchLegacyTemplate.tsx create mode 100644 packages/fiori/src/shellbar/templates/ShellBarSearchTemplate.tsx create mode 100644 packages/fiori/src/themes/ShellBarItem.css create mode 100644 packages/fiori/src/themes/ShellBarLegacy.css create mode 100644 packages/fiori/src/themes/ShellBarSearchLegacy.css create mode 100644 packages/fiori/test/pages/ShellBarV2.html create mode 100644 packages/fiori/test/pages/ShellBarV2_SearchTypes.html create mode 100644 packages/fiori/test/pages/ShellBar_Comparison.html diff --git a/packages/fiori/cypress/specs/ShellBar.cy.tsx b/packages/fiori/cypress/specs/ShellBar.cy.tsx index a8289a9fd7e2..0ba292c7fd74 100644 --- a/packages/fiori/cypress/specs/ShellBar.cy.tsx +++ b/packages/fiori/cypress/specs/ShellBar.cy.tsx @@ -5,13 +5,17 @@ import activities from "@ui5/webcomponents-icons/dist/activities.js"; import navBack from "@ui5/webcomponents-icons/dist/nav-back.js"; import sysHelp from "@ui5/webcomponents-icons/dist/sys-help.js"; import da from "@ui5/webcomponents-icons/dist/da.js"; +import "@ui5/webcomponents-icons/dist/accept.js"; +import "@ui5/webcomponents-icons/dist/alert.js"; +import "@ui5/webcomponents-icons/dist/disconnected.js"; +import "@ui5/webcomponents-icons/dist/incoming-call.js"; import Input from "@ui5/webcomponents/dist/Input.js"; import Button from "@ui5/webcomponents/dist/Button.js"; import ToggleButton from "@ui5/webcomponents/dist/ToggleButton.js"; import ListItemStandard from "@ui5/webcomponents/dist/ListItemStandard.js"; import Avatar from "@ui5/webcomponents/dist/Avatar.js"; import Switch from "@ui5/webcomponents/dist/Switch.js"; -import ShellBarBranding from "@ui5/webcomponents-fiori/dist/ShellBarBranding.js" +import ShellBarBranding from "../../src/ShellBarBranding.js"; import ShellBarSearch from "../../src/ShellBarSearch.js"; const RESIZE_THROTTLE_RATE = 300; // ms @@ -26,8 +30,7 @@ describe("Responsiveness", () => { showNotifications={true} showProductSwitch={true} > - {/* */} - Button3 + @@ -114,7 +117,6 @@ describe("Responsiveness", () => { cy.get("@shellbar").should("have.prop", "breakpointSize", "XL"); cy.get("@shellbar").find("ui5-toggle-button[slot='assistant']").as("assistant"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-overflow-button").as("overflowButton"); cy.get("@shellbar").find("ui5-button[slot='startButton']").as("backButton"); cy.get("@shellbar").shadow().find(".ui5-shellbar-title").as("primaryTitle"); cy.get("@shellbar").shadow().find(".ui5-shellbar-secondary-title").as("secondaryTitle"); @@ -124,7 +126,8 @@ describe("Responsiveness", () => { cy.get("@shellbar").shadow().find(".ui5-shellbar-button-product-switch").as("productSwitchIcon"); cy.get("@assistant").should("be.visible"); - cy.get("@overflowButton").should("not.be.visible"); + // V2: Overflow button uses conditional rendering - not rendered when nothing overflows + cy.get("@shellbar").shadow().find(".ui5-shellbar-overflow-button").should("not.exist"); cy.get("@backButton").should("be.visible"); cy.get("@primaryTitle").should("be.visible"); cy.get("@secondaryTitle").should("be.visible"); @@ -134,14 +137,26 @@ describe("Responsiveness", () => { cy.get("@productSwitchIcon").should("be.visible"); }); - it("tests M Breakpoint and overflow 500px", () => { + it("tests S Breakpoint and overflow 500px", () => { cy.viewport(500, 1680); - cy.get("@shellbar").shadow().find(".ui5-shellbar-overflow-button").as("overflowButton"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-search-button").as("searchIcon"); + cy.get("@shellbar").should("have.prop", "breakpointSize", "S"); - cy.get("@searchIcon").should("be.visible"); - cy.get("@overflowButton").should("be.visible"); + cy.get("@shellbar").find("ui5-toggle-button[slot='assistant']").as("assistant"); + cy.get("@shellbar").find("ui5-button[slot='startButton']").as("backButton"); + cy.get("@shellbar").shadow().find(".ui5-shellbar-custom-item").as("customActionIcon1"); + cy.get("@shellbar").shadow().find(".ui5-shellbar-bell-button").as("notificationsIcon"); + cy.get("@shellbar").shadow().find(".ui5-shellbar-image-button").as("profileIcon"); + cy.get("@shellbar").shadow().find(".ui5-shellbar-button-product-switch").as("productSwitchIcon"); + + cy.get("@assistant").should("be.visible"); + // V2: Overflow button uses conditional rendering - not rendered when nothing overflows + cy.get("@shellbar").shadow().find(".ui5-shellbar-overflow-button").should("not.exist"); + cy.get("@backButton").should("be.visible"); + cy.get("@customActionIcon1").should("be.visible"); + cy.get("@notificationsIcon").should("be.visible"); + cy.get("@profileIcon").should("be.visible"); + cy.get("@productSwitchIcon").should("be.visible"); }); it("tests XL Breakpoint 1820px", () => { @@ -156,7 +171,6 @@ describe("Responsiveness", () => { cy.get("@shellbar").should("have.prop", "breakpointSize", "L"); cy.get("@shellbar").find("ui5-toggle-button[slot='assistant']").as("assistant"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-overflow-button").as("overflowButton"); cy.get("@shellbar").find("ui5-button[slot='startButton']").as("backButton"); cy.get("@shellbar").shadow().find(".ui5-shellbar-title").as("primaryTitle"); cy.get("@shellbar").shadow().find(".ui5-shellbar-secondary-title").as("secondaryTitle"); @@ -167,7 +181,8 @@ describe("Responsiveness", () => { cy.get("@shellbar").shadow().find(".ui5-shellbar-button-product-switch").as("productSwitchIcon"); cy.get("@assistant").should("be.visible"); - cy.get("@overflowButton").should("not.be.visible"); + // V2: Overflow button uses conditional rendering - not rendered when nothing overflows + cy.get("@shellbar").shadow().find(".ui5-shellbar-overflow-button").should("not.exist"); cy.get("@backButton").should("be.visible"); cy.get("@primaryTitle").should("be.visible"); cy.get("@secondaryTitle").should("be.visible"); @@ -200,33 +215,6 @@ describe("Responsiveness", () => { cy.get("@productSwitchIcon").should("be.visible"); }); - it("tests S Breakpoint and overflow 510px", () => { - cy.viewport(510, 1680); - - cy.get("@shellbar").should("have.prop", "breakpointSize", "S"); - - cy.get("@shellbar").find("[slot='assistant']").as("assistant"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-overflow-button").as("overflowButton"); - cy.get("@shellbar").find("ui5-button[slot='startButton']").as("backButton"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-search-button").as("searchIcon"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-bell-button").as("notificationsIcon"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-image-button").as("profileIcon"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-button-product-switch").as("productSwitchIcon"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-overflow-popover").as("overflowPopover"); - - cy.get("@assistant").should("be.visible"); - cy.get("@overflowButton").should("be.visible"); - cy.get("@backButton").should("be.visible"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-title").should("not.exist"); - cy.get("@shellbar").shadow().find(".ui5-shellbar-secondary-title").should("not.exist"); - cy.get("@searchIcon").should("be.visible"); - cy.get("@notificationsIcon").should("be.visible"); - cy.get("@profileIcon").should("be.visible"); - cy.get("@productSwitchIcon").should("be.visible"); - - cy.get("@overflowPopover").find("ui5-li").should("have.length", 2); - }); - it("tests S Breakpoint 320px", () => { cy.get("html").viewport("iphone-x"); cy.get("@shellbar") @@ -277,6 +265,10 @@ describe("Responsiveness", () => { it("tests Primary title when menuItems are presented", () => { cy.mount(templateWithMenuItems()).as("html1"); + // V2: Menu button only renders at S breakpoint when menuItems exist + // This is by design - menu button is mobile-only feature + cy.viewport(510, 1680); + cy.get("@shellbar") .shadow() .find(".ui5-shellbar-menu-button") @@ -296,27 +288,32 @@ describe("Responsiveness", () => { cy.mount(templateWithOnlyOneAction()).as("html1"); cy.get("html").viewport("iphone-6"); + // V2: Overflow button uses conditional rendering - not rendered when nothing overflows + // This is more efficient than V1 which rendered but hid with CSS cy.get("@shellbar") .shadow() .find(".ui5-shellbar-overflow-button") - .should("be.hidden"); + .should("not.exist"); }); it("Test accessibility attributes on custom action buttons", () => { cy.mount(basicTemplate()).as("html"); + // V2: ShellBarItem properly supports accessibilityAttributes property + // which are passed through to the ui5-button in its shadow root cy.get("@shellbar") - .shadow() - .find + + + + + + + + + ); + + // Use narrow viewport to force items into overflow + cy.viewport(320, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + cy.get("#shellbar").as("shellbar"); + + // Verify overflow button exists (search should be in overflow when closed) + cy.get("@shellbar").should("have.prop", "breakpointSize", "S"); + + // Open overflow popover + cy.get("@shellbar").invoke("prop", "overflowPopoverOpen", true); + + // verify popover is open + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-overflow-popover") + .should("exist") + .and("have.attr", "open"); + + // Click search toggle in overflow popover + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-overflow-popover [data-action-id='search']") + .should("exist") + .click(); + + // Verify search is expanded + cy.get("@shellbar") + .should("have.prop", "showSearchField", true); + + // Verify search field is visible + cy.get("@shellbar") + .find("[slot='searchField']") + .should("be.visible"); + }); + + it("Test search button hide/show priority", () => { + cy.mount( + + + + + + + + + + + + + ); + + cy.get("#shellbar").as("shellbar"); + + // wide viewport - search, content, actions all visible + cy.viewport(1200, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + // Assert all elements are visible + cy.get("@shellbar").shadow().find(".ui5-shellbar-search-toggle").should("be.visible"); + cy.get("#content1").should("be.visible"); + cy.get("#content2").should("be.visible"); + + cy.get("#action1").should("be.visible"); + cy.get("#action2").should("be.visible"); + cy.get("#action3").should("be.visible"); + + + // Act - reduce viewport to hide action buttons + cy.get("@shellbar").invoke("prop", "showSearchField", false); + cy.viewport(350, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + // Assert action buttons are hidden and search are hidden, before the last content item + cy.get("@shellbar").shadow().find(".ui5-shellbar-search-toggle").should("not.be.visible"); + cy.get("#content1").should("be.visible"); + + // Act - increase viewport + cy.viewport(400, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + // Assert search is visible again before with highest priority + cy.get("#content1").should("be.visible"); + cy.get("@shellbar").shadow().find(".ui5-shellbar-search-toggle").should("be.visible"); + }); }); }); @@ -573,7 +682,7 @@ describe("Events", () => { ); - cy.get("[ui5-shellbar") + cy.get("[ui5-shellbar]") .as("shellbar"); cy.get("@shellbar") @@ -662,12 +771,10 @@ describe("Events", () => { // Trigger full width search mode by reducing viewport cy.viewport(400, 800); - // Manually call the cancel button handler - cy.get("@shellbar").then(shellbar => { - const shellbarInstance = shellbar.get(0); - // Call the private method directly to simulate cancel button press - shellbarInstance._handleCancelButtonPress(); - }); + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-cancel-button") + .click(); // Verify the event was fired cy.get("@searchFieldClear") @@ -704,12 +811,10 @@ describe("Events", () => { // Trigger full width search mode by reducing viewport cy.viewport(400, 800); - // Manually call the cancel button handler - cy.get("@shellbar").then(shellbar => { - const shellbarInstance = shellbar.get(0); - // Call the private method directly to simulate cancel button press - shellbarInstance._handleCancelButtonPress(); - }); + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-cancel-button") + .click(); // Verify the event was fired cy.get("@searchFieldClear") @@ -794,7 +899,7 @@ describe("Events", () => { cy.get("@shellbar") .shadow() .find("[data-profile-btn]") - .click({ force: true }); + .click({ force: true }); cy.get("@profileClick") .should("have.been.calledOnce"); @@ -1016,7 +1121,7 @@ describe("Events", () => { cy.get("@shellbar") .shadow() .find("[data-profile-btn]") - .click({ force: true }); + .click({ force: true }); cy.get("@profileClick") .should("have.been.calledOnce"); @@ -1047,6 +1152,7 @@ describe("Events", () => { }); it("tests preventDefault of click on a button with default behavior prevented", () => { + cy.viewport(320, 800); cy.mount( { notificationsCount="99+" showNotifications showProductSwitch - showSearchField={false} > @@ -1081,17 +1186,20 @@ describe("Events", () => { cy.get("[ui5-shellbar]") .shadow() .find(".ui5-shellbar-overflow-popover") - .should("be.visible"); + .should("to.exist") + .invoke("prop", "open", true); cy.get("[ui5-shellbar]") .shadow() - .find(".ui5-shellbar-overflow-popover [ui5-list] [ui5-li]:nth-child(3)") + .find(".ui5-shellbar-overflow-popover [ui5-list] [ui5-shellbar-item]:nth-child(1)") .realClick(); cy.get("[ui5-shellbar]") .shadow() .find(".ui5-shellbar-overflow-popover") - .should("be.visible"); + .should("to.exist") + .invoke("prop", "open", true); + }); }); }); @@ -1104,9 +1212,10 @@ describe("ButtonBadge in ShellBar", () => { ); - cy.get("#shellbarwithitems") + // V2: Badge is inside ShellBarItem's shadow DOM, not directly in ShellBar's shadow + cy.get("#test-item") .shadow() - .find(".ui5-shellbar-custom-item ui5-button-badge[slot='badge']") + .find("ui5-button-badge[slot='badge']") .should("exist") .should("have.attr", "text", "42"); }); @@ -1120,9 +1229,10 @@ describe("ButtonBadge in ShellBar", () => { cy.get("#test-invalidation-item").invoke("attr", "count", "3"); - cy.get("#test-invalidation") + // V2: Badge is inside ShellBarItem's shadow DOM + cy.get("#test-invalidation-item") .shadow() - .find(".ui5-shellbar-custom-item ui5-button-badge[slot='badge']") + .find("ui5-button-badge[slot='badge']") .should("have.attr", "text", "3"); }); @@ -1149,11 +1259,15 @@ describe("ButtonBadge in ShellBar", () => { cy.viewport(320, 800); + // Wait for overflow calculation to complete + cy.wait(RESIZE_THROTTLE_RATE); + cy.get("#shellbar-with-overflow") .shadow() .find(".ui5-shellbar-overflow-button") .should("be.visible"); + // V2: Overflow button badge - check if it's rendered cy.get("#shellbar-with-overflow") .shadow() .find(".ui5-shellbar-overflow-button ui5-button-badge[slot='badge']") @@ -1178,11 +1292,15 @@ describe("ButtonBadge in ShellBar", () => { cy.viewport(320, 800); + // Wait for overflow calculation to complete + cy.wait(RESIZE_THROTTLE_RATE); + cy.get("#shellbar-with-single-overflow") .shadow() .find(".ui5-shellbar-overflow-button") .should("be.visible"); + // V2: Overflow button badge - check if it's rendered cy.get("#shellbar-with-single-overflow") .shadow() .find(".ui5-shellbar-overflow-button ui5-button-badge[slot='badge']") @@ -1191,6 +1309,101 @@ describe("ButtonBadge in ShellBar", () => { }); }); +describe("Search Controllers", () => { + it("Test search doesn't collapse in full-screen mode during resize", () => { + cy.mount( + + + + + + + ); + + // search not focused + cy.get("#search").should("not.be.focused"); + // search field is empty + cy.get("#search").should("have.value", ""); + + cy.viewport(400, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + cy.get("#shellbar").should("have.prop", "showSearchField", true); + + cy.viewport(360, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + cy.get("#shellbar").should("have.prop", "showSearchField", true); + }); + + it("Test legacy search doesn't collapse in full-screen mode during resize", () => { + cy.mount( + + + + + + + ); + + // search not focused + cy.get("#search").should("not.be.focused"); + // search field is empty + cy.get("#search").should("have.value", ""); + + cy.viewport(400, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + cy.get("#shellbar").should("have.prop", "showSearchField", true); + + cy.viewport(360, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + cy.get("#shellbar").should("have.prop", "showSearchField", true); + }); +}); + +describe("Overflow", () => { + it("Test hidden actions stay hidden when search expands", () => { + cy.mount( + + + + + + + + + + + + + + + + + + + + + ); + + cy.viewport(1000, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + // Verify actions are hidden in overflow + cy.get("#shellbar").shadow().find(".ui5-shellbar-overflow-button").should("exist"); + cy.get("#item1").should("not.be.visible"); + + // Expand search + cy.get("#shellbar").invoke("prop", "showSearchField", true); + cy.wait(RESIZE_THROTTLE_RATE); + + // Hidden actions should stay hidden (no flicker) + cy.get("#item1").should("not.be.visible"); + }); +}); + describe("Keyboard Navigation", () => { it("Test logo area elements are not rendered when no logo and primaryTitle are provided", () => { cy.mount(); @@ -1258,10 +1471,8 @@ describe("Keyboard Navigation", () => { // Press right arrow - should move focus away from input since cursor is at end cy.get("@nativeInput").type("{rightArrow}"); // Verify focus is now on the ShellBarItem - cy.get("[ui5-shellbar]") - .shadow() - .find(".ui5-shellbar-custom-item") - .should("be.focused"); + cy.get("[ui5-shellbar-item]") + .should("have.focus"); placeInMiddleOfInput(); // Press left arrow - should stay focused on input since cursor is in the middle @@ -1325,45 +1536,34 @@ describe("Branding slot", () => { describe("Component Behavior", () => { describe("Accessibility", () => { - it("tests accessibilityTexts property", () => { - const PROFILE_BTN_CUSTOM_TOOLTIP = "John Dow"; - const LOGO_CUSTOM_TOOLTIP = "Custom logo title"; - cy.mount( - - - - - ); + it("tests accessibilityTexts property", () => { + const PROFILE_BTN_CUSTOM_TOOLTIP = "John Dow"; + const LOGO_CUSTOM_TOOLTIP = "Custom logo title"; - cy.get("[ui5-shellbar]").then(($shellbar) => { - $shellbar[0].accessibilityAttributes = { - profile: { - name: PROFILE_BTN_CUSTOM_TOOLTIP, - }, - logo: { - name: LOGO_CUSTOM_TOOLTIP - }, - }; - }); - - cy.get("[ui5-shellbar]").should("have.prop", "_profileText", PROFILE_BTN_CUSTOM_TOOLTIP); + cy.mount( + + + + + ); - cy.get("[ui5-shellbar]").should("have.prop", "_logoText", LOGO_CUSTOM_TOOLTIP); + cy.get("[ui5-shellbar]").then(($shellbar) => { + $shellbar[0].accessibilityAttributes = { + profile: { + name: PROFILE_BTN_CUSTOM_TOOLTIP, + }, + logo: { + name: LOGO_CUSTOM_TOOLTIP + }, + }; }); - it("tests acc default roles", () => { - cy.mount( - - - - ); - - cy.get("[ui5-shellbar]") - .shadow() - .find(".ui5-shellbar-logo-area") - .should("have.attr", "role", "link"); + cy.get("[ui5-shellbar]").then(($shellbar) => { + expect($shellbar[0].actionsAccessibilityInfo.profile.title).to.equal(PROFILE_BTN_CUSTOM_TOOLTIP); + expect($shellbar[0].legacyAdaptor.logoAriaLabel).to.equal(LOGO_CUSTOM_TOOLTIP); }); + }); it("tests accessibilityAttributes property", () => { const NOTIFICATIONS_BTN_ARIA_HASPOPUP = "dialog"; @@ -1396,38 +1596,6 @@ describe("Component Behavior", () => { .find("button") .should("have.attr", "aria-haspopup", NOTIFICATIONS_BTN_ARIA_HASPOPUP); }); - - it("tests imageBtnText logical OR fallback - uses default i18n text when no custom text provided", () => { - cy.mount( - - - - ); - - // When no aria-label is provided, imageBtnText should fallback to SHELLBAR_IMAGE_BTN i18n text - cy.get("[ui5-shellbar]").should("have.prop", "imageBtnText", "User Menu"); - }); - - it("tests SHELLBAR_IMAGE_BTN i18n key is properly used as fallback", () => { - cy.mount( - - - - ); - - // Verify that the exact i18n text from SHELLBAR_IMAGE_BTN is used - cy.get("[ui5-shellbar]").then(($shellbar) => { - const imageBtnText = $shellbar.prop("imageBtnText"); - // This should be exactly "User Menu" from messagebundle.properties SHELLBAR_IMAGE_BTN - expect(imageBtnText).to.equal("User Menu"); - }); - - // Verify the profile button actually uses this text in its aria-label - cy.get("[ui5-shellbar]") - .shadow() - .find(".ui5-shellbar-image-button") - .should("have.attr", "aria-label", "User Menu"); - }); }); describe("ui5-shellbar menu", () => { @@ -1546,17 +1714,13 @@ describe("Component Behavior", () => { item.addEventListener("click", cy.stub().as(stubAlias)); }); - cy.get("[ui5-shellbar]") - .shadow() - .find(`.ui5-shellbar-custom-item[icon="accept"]`) + cy.get("[ui5-shellbar-item][icon='accept']") .click(); cy.get("@acceptClick") .should("have.been.calledOnce"); - cy.get("[ui5-shellbar]") - .shadow() - .find(`.ui5-shellbar-custom-item[icon="alert"]`) + cy.get("[ui5-shellbar-item][icon='alert']") .click(); cy.get("@alertClick") diff --git a/packages/fiori/src/ShellBar.ts b/packages/fiori/src/ShellBar.ts index c621c301e620..c0c3bc33ede3 100644 --- a/packages/fiori/src/ShellBar.ts +++ b/packages/fiori/src/ShellBar.ts @@ -1,95 +1,117 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; -import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; -import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; +import query from "@ui5/webcomponents-base/dist/decorators/query.js"; import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; -import { - isSpace, - isEnter, - isLeft, - isRight, - isHome, - isEnd, -} from "@ui5/webcomponents-base/dist/Keys.js"; -import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js"; -import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js"; -import ListItemStandard from "@ui5/webcomponents/dist/ListItemStandard.js"; -import List from "@ui5/webcomponents/dist/List.js"; -import type { ListItemClickEventDetail } from "@ui5/webcomponents/dist/List.js"; import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; -import Popover from "@ui5/webcomponents/dist/Popover.js"; +import { getScopedVarName } from "@ui5/webcomponents-base/dist/CustomElementsScopeUtils.js"; +import arraysAreEqual from "@ui5/webcomponents-base/dist/util/arraysAreEqual.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js"; +import throttle from "@ui5/webcomponents-base/dist/util/throttle.js"; + +import type { IButton } from "@ui5/webcomponents/dist/Button.js"; import Button from "@ui5/webcomponents/dist/Button.js"; import ButtonBadge from "@ui5/webcomponents/dist/ButtonBadge.js"; -import Menu from "@ui5/webcomponents/dist/Menu.js"; import Icon from "@ui5/webcomponents/dist/Icon.js"; -import type { IButton } from "@ui5/webcomponents/dist/Button.js"; -import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; -import { isDesktop, isPhone } from "@ui5/webcomponents-base/dist/Device.js"; -import search from "@ui5/webcomponents-icons/dist/search.js"; -import da from "@ui5/webcomponents-icons/dist/da.js"; -import bell from "@ui5/webcomponents-icons/dist/bell.js"; -import overflow from "@ui5/webcomponents-icons/dist/overflow.js"; -import grid from "@ui5/webcomponents-icons/dist/grid.js"; -import type { - ClassMap, - AccessibilityAttributes, - AriaRole, - UI5CustomEvent, -} from "@ui5/webcomponents-base"; -import type ListItemBase from "@ui5/webcomponents/dist/ListItemBase.js"; -import throttle from "@ui5/webcomponents-base/dist/util/throttle.js"; -import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; -import type ShellBarItem from "./ShellBarItem.js"; -import type { ShellBarItemAccessibilityAttributes } from "./ShellBarItem.js"; -import type ShellBarBranding from "./ShellBarBranding.js"; +import Popover from "@ui5/webcomponents/dist/Popover.js"; +import Menu from "@ui5/webcomponents/dist/Menu.js"; +import List from "@ui5/webcomponents/dist/List.js"; +import ListItemStandard from "@ui5/webcomponents/dist/ListItemStandard.js"; +import searchIcon from "@ui5/webcomponents-icons/dist/search.js"; +import bellIcon from "@ui5/webcomponents-icons/dist/bell.js"; +import gridIcon from "@ui5/webcomponents-icons/dist/grid.js"; +import daIcon from "@ui5/webcomponents-icons/dist/da.js"; +import overflowIcon from "@ui5/webcomponents-icons/dist/overflow.js"; -// Templates import ShellBarTemplate from "./ShellBarTemplate.js"; - -// Styles import shellBarStyles from "./generated/themes/ShellBar.css.js"; import ShellBarPopoverCss from "./generated/themes/ShellBarPopover.css.js"; +import shellBarLegacyStyles from "./generated/themes/ShellBarLegacy.css.js"; + +import type { IShellBarSearchController } from "./shellbar/IShellBarSearchController.js"; + +import ShellBarLegacy from "./shellbar/ShellBarLegacy.js"; +import ShellBarSearch from "./shellbar/ShellBarSearch.js"; +import ShellBarSearchLegacy from "./shellbar/ShellBarSearchLegacy.js"; +import ShellBarOverflow from "./shellbar/ShellBarOverflow.js"; +import ShellBarAccessibility from "./shellbar/ShellBarAccessibility.js"; +import ShellBarItemNavigation from "./shellbar/ShellBarItemNavigation.js"; + +import ShellBarItem from "./ShellBarItem.js"; +import ShellBarSpacer from "./ShellBarSpacer.js"; +import type ShellBarBranding from "./ShellBarBranding.js"; +import type { ShellBarOverflowResult } from "./shellbar/ShellBarOverflow.js"; + +import type { + ShellBarAccessibilityInfo, + ShellBarAccessibilityAttributes, + ShellBarAreaAccessibilityAttributes, + ShellBarLogoAccessibilityAttributes, + ShellBarProfileAccessibilityAttributes, +} from "./shellbar/ShellBarAccessibility.js"; import { SHELLBAR_LABEL, - SHELLBAR_LOGO, SHELLBAR_NOTIFICATIONS, SHELLBAR_NOTIFICATIONS_NO_COUNT, - SHELLBAR_CANCEL, SHELLBAR_PROFILE, SHELLBAR_PRODUCTS, SHELLBAR_SEARCH, - SHELLBAR_SEARCH_FIELD, + SHELLBAR_ASSISTANT, SHELLBAR_OVERFLOW, - SHELLBAR_LOGO_AREA, SHELLBAR_ADDITIONAL_CONTEXT, - SHELLBAR_SEARCHFIELD_DESCRIPTION, - SHELLBAR_SEARCH_BTN_OPEN, - SHELLBAR_PRODUCT_SWITCH_BTN, - SHELLBAR_IMAGE_BTN, } from "./generated/i18n/i18n-defaults.js"; +import type ListItemBase from "@ui5/webcomponents/dist/ListItemBase.js"; -type ShellBarLogoAccessibilityAttributes = { - role?: Extract, - name?: string, -} -type ShellBarProfileAccessibilityAttributes = Pick; -type ShellBarAreaAccessibilityAttributes = Pick; -type ShellBarBrandingAccessibilityAttributes = Pick; -type ShellBarAccessibilityAttributes = { - logo?: ShellBarLogoAccessibilityAttributes - notifications?: ShellBarAreaAccessibilityAttributes - profile?: ShellBarProfileAccessibilityAttributes, - product?: ShellBarAreaAccessibilityAttributes - search?: ShellBarAreaAccessibilityAttributes - overflow?: ShellBarAreaAccessibilityAttributes - branding?: ShellBarBrandingAccessibilityAttributes +type ShellBarBreakpoint = "S" | "M" | "L" | "XL" | "XXL"; + +// actions always visible in lean mode, order is important +const PREDEFINED_PLACE_ITEMS = ["feedback", "sys-help"]; + +const ShellBarActions = { + Search: "search", + Profile: "profile", + Overflow: "overflow", + Assistant: "assistant", + ProductSwitch: "products", + Notifications: "notifications", +}; + +const ShellBarActionsSelectors = { + Search: ".ui5-shellbar-search-toggle", + Profile: ".ui5-shellbar-image-button", + Overflow: ".ui5-shellbar-overflow-button", + Assistant: ".ui5-shellbar-assistant-button", + ProductSwitch: ".ui5-shellbar-button-product-switch", + Notifications: ".ui5-shellbar-bell-button", +}; + +type ShellBarActionId = typeof ShellBarActions[keyof typeof ShellBarActions]; + +type ShellBarActionItem = { + id: ShellBarActionId; + icon?: string; + count?: string; + enabled: boolean; // Whether the action is enabled and should be displayed + selector: string; // The selector by which we can target the action + isProtected: boolean // Whether the action can go into the overflow + stableDomRef?: string; }; +interface IShellBarSearchField extends HTMLElement { + focused: boolean; + value: string; + collapsed?: boolean; + open?: boolean; +} + +// Event Types + type ShellBarNotificationsClickEventDetail = { targetRef: HTMLElement; }; @@ -127,44 +149,6 @@ type ShellBarSearchFieldClearEventDetail = { targetRef: HTMLElement; }; -interface IShellBarSearchField extends HTMLElement { - focused: boolean; - value: string; - collapsed?: boolean; - open?: boolean; -} - -interface IShellBarHidableItem { - classes: string, - id: string, - show: boolean, -} - -interface IShelBarItemInfo extends IShellBarHidableItem { - icon?: string, - text?: string, - count?: string, - custom?: boolean, - title?: string, - stableDomRef?: string, - refItemid?: string, - press: (e: UI5CustomEvent) => void, - order?: number, - profile?: boolean, - tooltip?: string, - accessibilityAttributes?: ShellBarItemAccessibilityAttributes, - accessibleName?: string, -} - -interface IShellBarContentItem extends IShellBarHidableItem { - hideOrder: number, -} - -const RESIZE_THROTTLE_RATE = 200; // ms - -// actions always visible in lean mode, order is important -const PREDEFINED_PLACE_ACTIONS = ["feedback", "sys-help"]; - /** * @class * ### Overview @@ -200,19 +184,22 @@ const PREDEFINED_PLACE_ACTIONS = ["feedback", "sys-help"]; @customElement({ tag: "ui5-shellbar", - fastNavigation: true, - languageAware: true, + styles: [shellBarStyles, shellBarLegacyStyles, ShellBarPopoverCss], renderer: jsxRenderer, template: ShellBarTemplate, - styles: [shellBarStyles, ShellBarPopoverCss], + fastNavigation: true, + languageAware: true, dependencies: [ - Button, Icon, List, + Button, + ButtonBadge, Popover, + ShellBarSpacer, + ShellBarItem, ListItemStandard, + // legacy dependencies Menu, - ButtonBadge, ], }) /** @@ -334,44 +321,78 @@ class ShellBar extends UI5Element { } /** - * Defines the visibility state of the search button. + * Defines a `ui5-button` in the bar that will be placed in the beginning. + * We encourage this slot to be used for a menu button. + * It gets overstyled to match ShellBar's styling. + * @public + */ + @slot() + startButton!: Array; + + /** + * Defines the branding slot. + * The `ui5-shellbar-branding` component is intended to be placed inside this slot. + * Content placed here takes precedence over the `primaryTitle` property and the `logo` content slot. * - * **Note:** The `hideSearchButton` property is in an experimental state and is a subject to change. - * @default false + * **Note:** The `branding` slot is in an experimental state and is a subject to change. + * + * @since 2.12.0 * @public */ - @property({ type: Boolean }) - hideSearchButton = false; + @slot() + branding!: Array; /** - * Disables the automatic search field expansion/collapse when the available space is not enough. + * Define the items displayed in the content area. + * + * Use the `data-hide-order` attribute with numeric value to specify the order of the items to be hidden when the space is not enough. + * Lower values will be hidden first. + * + * **Note:** The `content` slot is in an experimental state and is a subject to change. * - * **Note:** The `disableSearchCollapse` property is in an experimental state and is a subject to change. - * @default false * @public + * @since 2.7.0 */ - @property({ type: Boolean }) - disableSearchCollapse = false; + @slot({ type: HTMLElement, individualSlots: true }) + content!: Array; /** - * Defines the `primaryTitle`. + * Defines the `ui5-input`, that will be used as a search field. + * @public + */ + @slot({ type: HTMLElement }) + searchField!: Array; + + /** + * Defines the assistant slot. * - * **Note:** The `primaryTitle` would be hidden on S screen size (less than approx. 700px). - * @default undefined + * @since 2.0.0 * @public */ - @property() - primaryTitle?: string; + @slot() + assistant!: Array; /** - * Defines the `secondaryTitle`. + * Defines the `ui5-shellbar` additional items. * - * **Note:** The `secondaryTitle` would be hidden on S and M screen sizes (less than approx. 1300px). - * @default undefined + * **Note:** + * You can use the ``. * @public */ - @property() - secondaryTitle?: string; + @slot({ type: HTMLElement, "default": true, individualSlots: true }) + items!: Array; + + /** + * You can pass `ui5-avatar` to set the profile image/icon. + * If no profile slot is set - profile will be excluded from actions. + * + * **Note:** We recommend not using the `size` attribute of `ui5-avatar` because + * it should have specific size by design in the context of `ui5-shellbar` profile. + * @since 1.0.0-rc.6 + * @public + */ + @slot() + profile!: Array; /** * Defines the `notificationsCount`, @@ -452,71 +473,131 @@ class ShellBar extends UI5Element { breakpointSize = "S"; /** + * Actions computed from controllers. + * @private + */ + @property({ type: Object }) + actions: ShellBarActionItem[] = []; + + /** + * Show overflow button when items are hidden. * @private */ @property({ type: Boolean }) - withLogo = false; + showOverflowButton = false; - @property({ type: Object }) - _itemsInfo: Array = []; + /** + * Open state of the overflow popover. + * @private + */ + @property({ type: Boolean }) + overflowPopoverOpen = false; + /** + * IDs of items currently hidden due to overflow. + * Used to trigger rerender for conditional rendering. + * @private + */ @property({ type: Object }) - _contentInfo: Array = []; + hiddenItemsIds: string[] = []; + + /** + * Show full-screen search overlay. + * @private + */ + @property({ type: Boolean }) + showFullWidthSearch = false; + + /** + * Spacer element. + * @private + */ + @query(".ui5-shellbar-spacer") + spacer?: HTMLElement; - @property({ type: Boolean, noAttribute: true }) - _menuPopoverExpanded = false; + /** + * Outer container of the overflow container. + * @private + */ + @query(".ui5-shellbar-overflow-container") + overflowOuter?: HTMLElement; - @property({ type: Boolean, noAttribute: true }) - _overflowPopoverExpanded = false; + /** + * Inner container of the overflow container. + * @private + */ + @query(".ui5-shellbar-overflow-container-inner") + overflowInner?: HTMLElement; - @property({ type: Boolean, noAttribute: true }) - showFullWidthSearch = false; + @i18n("@ui5/webcomponents-fiori") + static i18nBundle: I18nBundle; - _cachedHiddenContent: Array = []; + private readonly RESIZE_THROTTLE_RATE = 100; // ms + private handleResizeBound: ResizeObserverCallback = throttle(this.handleResize.bind(this), this.RESIZE_THROTTLE_RATE); + + private readonly breakpoints = [599, 1023, 1439, 1919, 10000]; + private readonly breakpointMap: Record = { + 599: "S", + 1023: "M", + 1439: "L", + 1919: "XL", + 10000: "XXL", + }; + + itemNavigation = new ShellBarItemNavigation({ + getDomRef: () => this.getDomRef() || null, + }); + + overflow = new ShellBarOverflow(); + accessibility: ShellBarAccessibility = new ShellBarAccessibility(); + + private _searchAdaptor = new ShellBarSearch(this.getSearchDeps()); + private _searchAdaptorLegacy = new ShellBarSearchLegacy({ + ...this.getSearchDeps(), + getDisableSearchCollapse: () => this.disableSearchCollapse, + }); + + /* =================== Legacy Members =================== */ /** - * Defines the assistant slot. + * Defines the visibility state of the search button. * - * @since 2.0.0 + * **Note:** The `hideSearchButton` property is in an experimental state and is a subject to change. + * @default false * @public */ - @slot() - assistant!: Array; + @property({ type: Boolean }) + hideSearchButton = false; /** - * Defines the branding slot. - * The `ui5-shellbar-branding` component is intended to be placed inside this slot. - * Content placed here takes precedence over the `primaryTitle` property and the `logo` content slot. - * - * **Note:** The `branding` slot is in an experimental state and is a subject to change. + * Disables the automatic search field expansion/collapse when the available space is not enough. * - * @since 2.12.0 + * **Note:** The `disableSearchCollapse` property is in an experimental state and is a subject to change. + * @default false * @public */ - @slot() - branding!: Array; + @property({ type: Boolean }) + disableSearchCollapse = false; /** - * Defines the `ui5-shellbar` additional items. + * Defines the `primaryTitle`. * - * **Note:** - * You can use the ``. + * **Note:** The `primaryTitle` would be hidden on S screen size (less than approx. 700px). + * @default undefined * @public */ - @slot({ type: HTMLElement, "default": true, invalidateOnChildChange: true }) - items!: Array; + @property() + primaryTitle?: string; /** - * You can pass `ui5-avatar` to set the profile image/icon. - * If no profile slot is set - profile will be excluded from actions. + * Defines the `secondaryTitle`. * - * **Note:** We recommend not using the `size` attribute of `ui5-avatar` because - * it should have specific size by design in the context of `ui5-shellbar` profile. - * @since 1.0.0-rc.6 + * **Note:** The `secondaryTitle` would be hidden on S and M screen sizes (less than approx. 1300px). + * @default undefined * @public */ - @slot() - profile!: Array; + @property() + secondaryTitle?: string; /** * Defines the logo of the `ui5-shellbar`. @@ -538,23 +619,11 @@ class ShellBar extends UI5Element { menuItems!: Array; /** - * Defines the `ui5-input`, that will be used as a search field. - * @public - */ - @slot({ - type: HTMLElement, - invalidateOnChildChange: true, - }) - searchField!: Array; - - /** - * Defines a `ui5-button` in the bar that will be placed in the beginning. - * We encourage this slot to be used for a menu button. - * It gets overstyled to match ShellBar's styling. - * @public + * Open state of the menu popover (legacy). + * @private */ - @slot() - startButton!: Array; + @property({ type: Boolean }) + menuPopoverOpen = false; /** * The container is positioned in the center of the `ui5-shellbar` and occupies one-third of the total length of the `ui5-shellbar`. @@ -565,340 +634,314 @@ class ShellBar extends UI5Element { @slot() midContent!: Array; - /** - * Define the items displayed in the content area. - * - * Use the `data-hide-order` attribute with numeric value to specify the order of the items to be hidden when the space is not enough. - * Lower values will be hidden first. - * - * **Note:** The `content` slot is in an experimental state and is a subject to change. - * - * @public - * @since 2.7.0 - */ - @slot({ type: HTMLElement, individualSlots: true }) - content!: Array; + legacyAdaptor?: ShellBarLegacy; - @i18n("@ui5/webcomponents-fiori") - static i18nBundle: I18nBundle; - overflowPopover?: Popover | null; - menuPopover?: Popover | null; - _isInitialRendering: boolean; - _defaultItemPressPrevented: boolean; - contentItemsObserver: MutationObserver; - _hiddenIcons: Array; - _handleResize: ResizeObserverCallback; - _overflowNotifications: string | null; - _lastOffsetWidth = 0; - _observableContent: Array = []; - _autoRestoreSearchField = false; - - _onSearchOpenBound = this._onSearchOpen.bind(this); - _onSearchCloseBound = this._onSearchClose.bind(this); - _onSearchBound = this._onSearch.bind(this); - - _headerPress: () => void; - - static get FIORI_3_BREAKPOINTS() { - return [ - 599, - 1023, - 1439, - 1919, - 10000, - ]; + /* =================== Lifecycle Methods =================== */ + + onEnterDOM() { + ResizeHandler.register(this, this.handleResizeBound); + this.searchAdaptor?.subscribe(); } - static get FIORI_3_BREAKPOINTS_MAP(): Record { - return { - "599": "S", - "1023": "M", - "1439": "L", - "1919": "XL", - "10000": "XXL", - }; + onExitDOM() { + ResizeHandler.deregister(this, this.handleResizeBound); + this.searchAdaptor?.unsubscribe(); } - constructor() { - super(); + onBeforeRendering() { + if (!this.legacyAdaptor) { + this.initLegacyController(); + } + // Sync branding breakpoint state + this.branding.forEach(brandingEl => { + brandingEl._isSBreakPoint = this.isSBreakPoint; + }); - this._hiddenIcons = []; - this._isInitialRendering = true; - this._overflowNotifications = null; + this.buildActions(); - // marks if preventDefault() is called in item's press handler - this._defaultItemPressPrevented = false; + this.searchAdaptor?.syncShowSearchFieldState(); + // subscribe to search adaptor for cases when search is added dynamically + this.searchAdaptor?.unsubscribe(); + this.searchAdaptor?.subscribe(); + } - this.contentItemsObserver = new MutationObserver(() => { - this._handleActionsOverflow(); - }); + onAfterRendering() { + this.updateBreakpoint(); + this.updateOverflow(); + } - this._headerPress = () => { - if (this.hasMenuItems) { - const menuPopover = this._getMenuPopover(); - menuPopover.opener = this.shadowRoot!.querySelector + ); +} diff --git a/packages/fiori/src/ShellBarPopoverTemplate.tsx b/packages/fiori/src/ShellBarPopoverTemplate.tsx deleted file mode 100644 index f523ef439460..000000000000 --- a/packages/fiori/src/ShellBarPopoverTemplate.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import Popover from "@ui5/webcomponents/dist/Popover.js"; -import List from "@ui5/webcomponents/dist/List.js"; -import PopoverHorizontalAlign from "@ui5/webcomponents/dist/types/PopoverHorizontalAlign.js"; -import ListItemStandard from "@ui5/webcomponents/dist/ListItemStandard.js"; -import type ShellBar from "./ShellBar.js"; - -export default function PopoversTemplate(this: ShellBar) { - return ( - <> - - - - - - - - - {this._hiddenIcons.map((icon, index) => ( - - {icon.text} - - ))} - - - - ); -} diff --git a/packages/fiori/src/ShellBarTemplate.tsx b/packages/fiori/src/ShellBarTemplate.tsx index a70676e1d85f..885e03aae131 100644 --- a/packages/fiori/src/ShellBarTemplate.tsx +++ b/packages/fiori/src/ShellBarTemplate.tsx @@ -1,337 +1,259 @@ -import Icon from "@ui5/webcomponents/dist/Icon.js"; import Button from "@ui5/webcomponents/dist/Button.js"; -import type ShellBar from "./ShellBar.js"; -import ShellBarPopoverTemplate from "./ShellBarPopoverTemplate.js"; -import slimArrowDown from "@ui5/webcomponents-icons/dist/slim-arrow-down.js"; import ButtonBadge from "@ui5/webcomponents/dist/ButtonBadge.js"; +import Popover from "@ui5/webcomponents/dist/Popover.js"; +import List from "@ui5/webcomponents/dist/List.js"; +import type ShellBar from "./ShellBar.js"; +import ShellBarItem from "./ShellBarItem.js"; + +import { + ShellBarSearchField, + ShellBarSearchFieldFullWidth +} from "./shellbar/templates/ShellBarSearchTemplate.js"; + +import { + ShellBarSearchField as ShellBarSearchFieldLegacy, + ShellBarSearchButton as ShellBarSearchButtonLegacy, + ShellBarSearchFieldFullWidth as ShellBarSearchFieldFullWidthLegacy, +} from "./shellbar/templates/ShellBarSearchLegacyTemplate.js"; + +import { + ShellBarLegacyBrandingArea, +} from "./shellbar/templates/ShellBarLegacyTemplate.js"; export default function ShellBarTemplate(this: ShellBar) { + const isLegacySearch = !this.isSelfCollapsibleSearch; + + const SearchInBarTemplate = isLegacySearch ? ShellBarSearchFieldLegacy : ShellBarSearchField; + const SearchFullWidthTemplate = isLegacySearch ? ShellBarSearchFieldFullWidthLegacy : ShellBarSearchFieldFullWidth; + + const profileAction = this.getAction("profile"); + const overflowAction = this.getAction("overflow"); + const assistantAction = this.getAction("assistant"); + const notificationsAction = this.getAction("notifications"); + const productSwitchAction = this.getAction("products"); + + const actionsAccInfo = this.actionsAccessibilityInfo; + return ( <> -
-
- {this.startButton.length > 0 && } +
+ {/* Full-width search overlay */} + {this.showFullWidthSearch && SearchFullWidthTemplate.call(this)} - {this.hasBranding && ( + {this.enabledFeatures.startButton && ( +
+ +
+ )} + + {this.enabledFeatures.branding && ( +
- )} +
+ )} - {this.hasMenuItems && !this.hasBranding && ( - <> - {!this.showLogoInMenuButton && this.hasLogo && singleLogo.call(this)} - {this.showTitleInMenuButton &&

{this.primaryTitle}

} - {this.showMenuButton && ( - <> - - - )} - - )} + {/* Legacy branding (logo + primaryTitle) when no menu items */} + {!this.enabledFeatures.branding && ShellBarLegacyBrandingArea.call(this)} - {this.hasMenuItems && ( - // The secondary title remains visible when both menu items and the branding slot are present, - // as the branding slot has higher priority and takes precedence in visibility. - <> - {this.secondaryTitle && !this.isSBreakPoint && ( -
- {this.secondaryTitle} -
- )} - - )} +
+
- {!this.hasMenuItems && ( - <> - {this.isSBreakPoint && this.hasLogo && !this.hasBranding && singleLogo.call(this)} - {!this.isSBreakPoint && (this.hasLogo || this.primaryTitle) && ( - <> - {!this.hasBranding && combinedLogo.call(this)} - {this.secondaryTitle && (this.primaryTitle || this.hasBranding) && ( -

- {this.secondaryTitle} -

- )} - - )} - - )} -
- {this.hasMidContent && ( -
- -
- )} -
-
- {this.hasContentItems && ( + {this.enabledFeatures.content && (
- {this.showStartSeparator && ( -
+ {/* Start separator */} + {this.separatorConfig.showStartSeparator && ( +
)} + + {/* Start content items */} {this.startContent.map(item => { - const itemInfo = this._contentInfo.find(info => info.id === (item as any)._individualSlot); + const itemId = (item as any)._individualSlot as string; + const packedSep = this.getPackedSeparatorInfo(item, true); return ( -
- {this.shouldIncludeSeparator(itemInfo, this.startContentInfoSorted) && ( - // never displayed, only "packed" with last item that was hidden, used for measurement purposes -
+
+ {packedSep.shouldPack && ( +
)}
); })} + + {/* Spacer: Grows to fill available space, used to measure if space is tight, should be in DOM always */}
+ + {/* End content items */} {this.endContent.map(item => { - const itemInfo = this._contentInfo.find(info => info.id === (item as any)._individualSlot); + const itemId = (item as any)._individualSlot as string; + const packedSep = this.getPackedSeparatorInfo(item, false); return ( -
- - {this.shouldIncludeSeparator(itemInfo, this.endContentInfoSorted) && ( - // never displayed, only "packed" with last item that was hidden, used for measurement purposes -
+
+ + {packedSep.shouldPack && ( +
)}
); })} - {this.showEndSeparator && ( -
+ + {/* End separator */} + {this.separatorConfig.showEndSeparator && ( +
)}
)} - {!this.hasContentItems &&
} -
- {this.hasSearchField && ( - <> - {this.showFullWidthSearch && ( -
-
- -
- -
- )} -
- -
- {!(this.hasSelfCollapsibleSearch || this.hideSearchButton) && ( - - )} - {this.customItemsInfo.map(item => ( - - ))} -
-
-
- - {this.hasProfile && profileButton.call(this)} - {this.showProductSwitch && ( -
- {ShellBarPopoverTemplate.call(this)} - - ); -} + {this.enabledFeatures.search && SearchInBarTemplate.call(this)} + {this.enabledFeatures.search && isLegacySearch && ShellBarSearchButtonLegacy.call(this)} -function profileButton(this: ShellBar) { - return ( - - ); -} + {assistantAction && ( +
+ +
+ )} -function singleLogo(this: ShellBar) { - return ( - - ); -} + {notificationsAction && ( + + )} -function combinedLogo(this: ShellBar) { - return ( -
- {this.hasLogo && ( - - )} -
- {this.primaryTitle && ( -

- {this.primaryTitle} -

- )} -
-
+ {/* Custom Items */} + {this.sortItems(this.items).map(item => ( +
+ {!item.inOverflow ? : null} +
+ ))} + + {overflowAction && ( + + )} + + {profileAction && ( + + )} + + {productSwitchAction && ( + + )} +
+ +
+ + {/* Overflow Popover */} + + + {this.overflowItems.map(item => { + if (item.type === "action") { + const actionData = item.data; + return ( + + ); + } + return ; + })} + + + ); } diff --git a/packages/fiori/src/i18n/messagebundle.properties b/packages/fiori/src/i18n/messagebundle.properties index 8fb014de096a..521ff9a66ff0 100644 --- a/packages/fiori/src/i18n/messagebundle.properties +++ b/packages/fiori/src/i18n/messagebundle.properties @@ -203,6 +203,9 @@ SEARCH_ITEM_DELETE_BUTTON_TOOLTIP=Remove Suggestion #XACT: ARIA announcement for the more button SHELLBAR_OVERFLOW = More +#XACT: ARIA announcement for the assistant button +SHELLBAR_ASSISTANT=Assistant + #XACT: ARIA announcement for the cancel button SHELLBAR_CANCEL = Cancel diff --git a/packages/fiori/src/shellbar/IShellBarSearchController.ts b/packages/fiori/src/shellbar/IShellBarSearchController.ts new file mode 100644 index 000000000000..5658177a29bb --- /dev/null +++ b/packages/fiori/src/shellbar/IShellBarSearchController.ts @@ -0,0 +1,32 @@ +/** + * Interface for ShellBar search controllers. + */ +export interface IShellBarSearchController { + /** + * Subscribe to search field events. + */ + subscribe(): void; + + /** + * Unsubscribe from search field events. + */ + unsubscribe(): void; + + /** + * Auto-collapse or expand search based on available space. + * @param hiddenItems Number of items currently hidden due to overflow + * @param availableSpace Available space in pixels (spacer width) + */ + autoManageSearchState(hiddenItems: number, availableSpace: number): void; + + /** + * Sync component state (showSearchField) to search field. + */ + syncShowSearchFieldState(): void; + + /** + * Check if full-screen search should be shown. + * Returns true when shellbar is overflowing AND search is visible. + */ + shouldShowFullScreen(): boolean; +} diff --git a/packages/fiori/src/shellbar/ShellBarAccessibility.ts b/packages/fiori/src/shellbar/ShellBarAccessibility.ts new file mode 100644 index 000000000000..4c042ff80634 --- /dev/null +++ b/packages/fiori/src/shellbar/ShellBarAccessibility.ts @@ -0,0 +1,107 @@ +import type { AccessibilityAttributes, AriaRole } from "@ui5/webcomponents-base"; + +// Legacy Type logo accessibility attributes +type ShellBarLogoAccessibilityAttributes = { + role?: Extract; + name?: string; +}; + +type ShellBarProfileAccessibilityAttributes = Pick; +type ShellBarAreaAccessibilityAttributes = Pick; +type ShellBarBrandingAccessibilityAttributes = Pick; + +type ShellBarAccessibilityAttributes = { + logo?: ShellBarLogoAccessibilityAttributes; + notifications?: ShellBarAreaAccessibilityAttributes; + profile?: ShellBarProfileAccessibilityAttributes; + product?: ShellBarAreaAccessibilityAttributes; + search?: ShellBarAreaAccessibilityAttributes; + overflow?: ShellBarAreaAccessibilityAttributes; + branding?: ShellBarBrandingAccessibilityAttributes; +}; + +interface ShellBarAreaAccessibilityInfo { + title: string | undefined; + accessibilityAttributes: { + name?: string; + hasPopup?: AccessibilityAttributes["hasPopup"]; + expanded?: AccessibilityAttributes["expanded"]; + }; +} + +type ShellBarAccessibilityInfo = { + notifications: ShellBarAreaAccessibilityInfo; + profile: ShellBarAreaAccessibilityInfo; + products: ShellBarAreaAccessibilityInfo; + overflow: ShellBarAreaAccessibilityInfo; + search: ShellBarAreaAccessibilityInfo; +}; + +class ShellBarAccessibility { + getActionsAccessibilityAttributes( + defaultTexts: Record, + params: { + accessibilityAttributes: ShellBarAccessibilityAttributes; + overflowPopoverOpen: boolean; + }, + ): ShellBarAccessibilityInfo { + const { overflowPopoverOpen, accessibilityAttributes } = params; + const overflowExpanded = accessibilityAttributes.overflow?.expanded; + + return { + notifications: { + title: defaultTexts.notifications, + accessibilityAttributes: { + expanded: accessibilityAttributes.notifications?.expanded, + hasPopup: accessibilityAttributes.notifications?.hasPopup, + }, + }, + profile: { + title: accessibilityAttributes.profile?.name || defaultTexts.profile, + accessibilityAttributes: { + hasPopup: accessibilityAttributes.profile?.hasPopup, + expanded: accessibilityAttributes.profile?.expanded, + }, + }, + products: { + title: defaultTexts.products, + accessibilityAttributes: { + hasPopup: accessibilityAttributes.product?.hasPopup, + expanded: accessibilityAttributes.product?.expanded, + }, + }, + search: { + title: defaultTexts.search, + accessibilityAttributes: { + hasPopup: accessibilityAttributes.search?.hasPopup, + }, + }, + overflow: { + title: defaultTexts.overflow, + accessibilityAttributes: { + hasPopup: accessibilityAttributes.overflow?.hasPopup || "menu" as const, + expanded: overflowExpanded === undefined ? overflowPopoverOpen : overflowExpanded, + }, + }, + }; + } + + getActionsRole(visibleItemsCount: number): "toolbar" | undefined { + return visibleItemsCount > 1 ? "toolbar" : undefined; + } + + getContentRole(visibleItemsCount: number): "group" | undefined { + return visibleItemsCount > 1 ? "group" : undefined; + } +} + +export default ShellBarAccessibility; + +export type { + ShellBarAccessibilityInfo, + ShellBarAreaAccessibilityInfo, + ShellBarAccessibilityAttributes, + ShellBarLogoAccessibilityAttributes, + ShellBarAreaAccessibilityAttributes, + ShellBarProfileAccessibilityAttributes, +}; diff --git a/packages/fiori/src/shellbar/ShellBarItemNavigation.ts b/packages/fiori/src/shellbar/ShellBarItemNavigation.ts new file mode 100644 index 000000000000..ab7faa016907 --- /dev/null +++ b/packages/fiori/src/shellbar/ShellBarItemNavigation.ts @@ -0,0 +1,115 @@ +import { + isEnd, + isHome, + isLeft, + isRight, +} from "@ui5/webcomponents-base/dist/Keys.js"; +import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js"; +import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; + +interface ShellBarItemNavigationConstructorParams { + getDomRef: () => HTMLElement | null; +} + +class ShellBarItemNavigation { + private params: ShellBarItemNavigationConstructorParams; + + constructor(params: ShellBarItemNavigationConstructorParams) { + this.params = params; + } + + handleKeyDown(e: KeyboardEvent): void { + if (!this.shouldHandle(e)) { + return; + } + + const domRef = this.params.getDomRef(); + if (!domRef) { + return; + } + + const activeElement = getActiveElement(); + if (!activeElement) { + return; + } + + if (this.shouldChildHandleNavigation(activeElement as HTMLElement, e)) { + return; + } + + const items = this.getTabbableItems(domRef); + const currentIndex = items.findIndex(el => el === activeElement); + + if (currentIndex !== -1) { + e.preventDefault(); + this.navigateToItem(items, currentIndex, e); + } + } + + private shouldHandle(e: KeyboardEvent): boolean { + return isLeft(e) || isRight(e) || isHome(e) || isEnd(e); + } + + private shouldChildHandleNavigation(element: HTMLElement, e: KeyboardEvent): boolean { + if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") { + return this.shouldInputHandleNavigation(element as HTMLInputElement | HTMLTextAreaElement, e); + } + return false; + } + + private shouldInputHandleNavigation(input: HTMLInputElement | HTMLTextAreaElement, e: KeyboardEvent): boolean { + const cursorPos = input.selectionStart || 0; + const textLength = input.value.length; + + if (isLeft(e) && cursorPos > 0) { + return true; + } + + if (isRight(e) && cursorPos < textLength) { + return true; + } + + return false; + } + + private getTabbableItems(domRef: HTMLElement): HTMLElement[] { + return getTabbableElements(domRef).filter(el => this.isVisible(el)); + } + + private isVisible(element: HTMLElement): boolean { + const style = getComputedStyle(element); + return style.display !== "none" + && style.visibility !== "hidden" + && element.offsetWidth > 0 + && element.offsetHeight > 0; + } + + private navigateToItem(items: HTMLElement[], currentIndex: number, e: KeyboardEvent): void { + if (isLeft(e)) { + this.focusPrevious(items, currentIndex); + } else if (isRight(e)) { + this.focusNext(items, currentIndex); + } else if (isHome(e)) { + items[0]?.focus(); + } else if (isEnd(e)) { + items[items.length - 1]?.focus(); + } + } + + private focusPrevious(items: HTMLElement[], currentIndex: number): void { + if (currentIndex > 0) { + items[currentIndex - 1].focus(); + } + } + + private focusNext(items: HTMLElement[], currentIndex: number): void { + if (currentIndex < items.length - 1) { + items[currentIndex + 1].focus(); + } + } +} + +export default ShellBarItemNavigation; +export type { + ShellBarItemNavigationConstructorParams, +}; diff --git a/packages/fiori/src/shellbar/ShellBarLegacy.ts b/packages/fiori/src/shellbar/ShellBarLegacy.ts new file mode 100644 index 000000000000..c4e5635b331b --- /dev/null +++ b/packages/fiori/src/shellbar/ShellBarLegacy.ts @@ -0,0 +1,187 @@ +import { + isSpace, + isEnter, +} from "@ui5/webcomponents-base/dist/Keys.js"; +import type { ListItemClickEventDetail } from "@ui5/webcomponents/dist/List.js"; +import type ShellBar from "../ShellBar.js"; +import List from "@ui5/webcomponents/dist/List.js"; +import type Popover from "@ui5/webcomponents/dist/Popover.js"; + +type ShellBarLegacyDeps = { + component: ShellBar; + getShadowRoot: () => ShadowRoot | null; +}; + +/** + * Controller for legacy ShellBar features that will be removed in future versions. + * Handles: logo slot, primaryTitle/secondaryTitle properties, menuItems slot. + */ +class ShellBarLegacy { + private component: ShellBar; + private getShadowRoot: () => ShadowRoot | null; + + // Bound handlers for event listeners + handleLogoClickBound = this.handleLogoClick.bind(this); + handleLogoKeyupBound = this.handleLogoKeyup.bind(this); + handleLogoKeydownBound = this.handleLogoKeydown.bind(this); + handleMenuItemClickBound = this.handleMenuItemClick.bind(this); + handleMenuButtonClickBound = this.handleMenuButtonClick.bind(this); + handleMenuPopoverBeforeOpenBound = this.handleMenuPopoverBeforeOpen.bind(this); + handleMenuPopoverAfterCloseBound = this.handleMenuPopoverAfterClose.bind(this); + + constructor(deps: ShellBarLegacyDeps) { + this.component = deps.component; + this.getShadowRoot = deps.getShadowRoot; + } + + /* ------------- Menu Management -------------- */ + + handleMenuButtonClick() { + const shadowRoot = this.getShadowRoot(); + if (!shadowRoot) { + return; + } + + const menuButton = shadowRoot.querySelector(".ui5-shellbar-menu-button"); + const menuPopover = this.getMenuPopover(); + + if (menuPopover && menuButton) { + menuPopover.opener = menuButton as HTMLElement; + menuPopover.open = true; + } + } + + handleMenuItemClick(e: CustomEvent) { + const shouldContinue = this.component.fireDecoratorEvent("menu-item-click", { + item: e.detail.item, + }); + + if (shouldContinue) { + const menuPopover = this.getMenuPopover(); + if (menuPopover) { + menuPopover.open = false; + } + } + } + + handleMenuPopoverBeforeOpen() { + this.component.menuPopoverOpen = true; + const menuPopover = this.getMenuPopover(); + if (menuPopover?.content && menuPopover.content.length) { + const list = menuPopover.content[0]; + if (list instanceof List) { + list.focusFirstItem(); + } + } + } + + handleMenuPopoverAfterClose() { + this.component.menuPopoverOpen = false; + } + + private getMenuPopover() { + const shadowRoot = this.getShadowRoot(); + return shadowRoot?.querySelector(".ui5-shellbar-menu-popover"); + } + + get hasMenuItems(): boolean { + return this.component.menuItems.length > 0; + } + + get menuPopoverExpanded(): boolean { + return this.component.menuPopoverOpen; + } + + /* ------------- Logo Management -------------- */ + + handleLogoClick() { + const shadowRoot = this.getShadowRoot(); + if (!shadowRoot) { + return; + } + + const logoElement = shadowRoot.querySelector(".ui5-shellbar-logo"); + if (logoElement) { + this.component.fireDecoratorEvent("logo-click", { + targetRef: logoElement as HTMLElement, + }); + } + } + + handleLogoKeydown(e: KeyboardEvent) { + if (isSpace(e)) { + e.preventDefault(); + return; + } + + if (isEnter(e)) { + this.handleLogoClick(); + } + } + + handleLogoKeyup(e: KeyboardEvent) { + if (isSpace(e)) { + this.handleLogoClick(); + } + } + + get hasLogo(): boolean { + return this.component.logo.length > 0; + } + + get logoRole(): "button" | "link" { + return this.component.accessibilityAttributes.logo?.role || "link"; + } + + get logoAriaLabel(): string { + return this.component.accessibilityAttributes.logo?.name || "Logo"; + } + + get brandingText(): string { + return this.component.accessibilityAttributes.branding?.name || this.primaryTitle; + } + + /* ------------- Title Management -------------- */ + + get hasPrimaryTitle(): boolean { + return !!this.component.primaryTitle; + } + + get hasSecondaryTitle(): boolean { + return !!this.component.secondaryTitle; + } + + get showSecondaryTitle(): boolean { + return this.hasSecondaryTitle && !this.component.isSBreakPoint; + } + + get primaryTitle(): string { + return this.component.primaryTitle || ""; + } + + get secondaryTitle(): string { + return this.component.secondaryTitle || ""; + } + + /* ------------- Menu Button -------------- */ + + get showMenuButton(): boolean { + return this.hasPrimaryTitle || this.showLogoInMenuButton; + } + + get showLogoInMenuButton(): boolean { + return this.hasLogo && this.isSBreakPoint; + } + + get showTitleInMenuButton(): boolean { + return this.hasPrimaryTitle && !this.showLogoInMenuButton; + } + + /* ------------- Common -------------- */ + + get isSBreakPoint(): boolean { + return this.component.isSBreakPoint; + } +} + +export default ShellBarLegacy; diff --git a/packages/fiori/src/shellbar/ShellBarOverflow.ts b/packages/fiori/src/shellbar/ShellBarOverflow.ts new file mode 100644 index 000000000000..aa232fcf5c22 --- /dev/null +++ b/packages/fiori/src/shellbar/ShellBarOverflow.ts @@ -0,0 +1,243 @@ +import type ShellBarItem from "../ShellBarItem.js"; +import { ShellBarActions, ShellBarActionsSelectors } from "../ShellBar.js"; +import type { ShellBarActionId, ShellBarActionItem } from "../ShellBar.js"; + +interface ShellBarHidableItem { + id: string; + selector: string; // CSS selector to find the element + hideOrder: number; // Priority for hiding - later adjusted based on search field state + keepHidden: boolean; // Keep item hidden to prevent flickering when searchfield expands/collapses + showInOverflow?: boolean; // If true, hiding this item triggers overflow button +} + +interface ShellBarOverflowParams { + actions: readonly ShellBarActionItem[]; + content: readonly HTMLElement[]; + customItems: readonly ShellBarItem[]; + overflowOuter: HTMLElement; + overflowInner: HTMLElement; + hiddenItemsIds: readonly string[]; + showSearchField: boolean; + setVisible: (selector: string, visible: boolean) => void; +} + +interface ShellBarOverflowResult { + hiddenItemsIds: string[]; + showOverflowButton: boolean; +} + +type ShellBarOverflowItem = { + type: "action"; + id: ShellBarActionId; + data: ShellBarActionItem + order: number; +} | { + type: "item"; + id: string; + data: ShellBarItem; + order: number; +} + +class ShellBarOverflow { + private readonly CLOSED_SEARCH_STRATEGY = { + ACTIONS: 0, // All actions hide first + CONTENT: 1000, // Then content (except last) + SEARCH: 2000, // Then search button + LAST_CONTENT: 3000, // Last content item hides last + }; + + private readonly OPEN_SEARCH_STRATEGY = { + CONTENT: 0, // All content hide first + ACTIONS: 1000, // All actions next + SEARCH: 2000, // Then search button + LAST_CONTENT: 0, // Last content same as other content + }; + + updateOverflow(params: ShellBarOverflowParams): ShellBarOverflowResult { + const { + overflowOuter, overflowInner, setVisible, + } = params; + + if (!overflowOuter || !overflowInner) { + return { hiddenItemsIds: [], showOverflowButton: false }; + } + + const sortedItems = this.buildHidableItems(params); + + // set initial state, to account for isOverflowing calculation + setVisible(ShellBarActionsSelectors.Overflow, false); + sortedItems.forEach(item => { + // show all items to account for isOverflowing calculation + setVisible(item.selector, true); + }); + + let nextItemToHide = null; + let showOverflowButton = false; + const hiddenItemsIds: string[] = []; + + // Iteratively hide items until no overflow + for (let indexToHide = 0; indexToHide < sortedItems.length; indexToHide++) { + nextItemToHide = sortedItems[indexToHide]; + + if (!this.isOverflowing(overflowOuter, overflowInner)) { + break; // No more overflow, stop hiding + } + + setVisible(nextItemToHide.selector, false); + hiddenItemsIds.push(nextItemToHide.id); + + if (nextItemToHide.showInOverflow) { + // show overflow button to account in isOverflowing calculation + setVisible(ShellBarActionsSelectors.Overflow, true); + showOverflowButton = true; + } + } + + return { + hiddenItemsIds, + showOverflowButton, + }; + } + + isOverflowing(overflowOuter: HTMLElement, overflowInner: HTMLElement): boolean { + return overflowInner.offsetWidth > overflowOuter.offsetWidth; + } + + private getOverflowStrategy(showSearchField: boolean) { + return showSearchField ? this.OPEN_SEARCH_STRATEGY : this.CLOSED_SEARCH_STRATEGY; + } + + private buildHidableItems(params: ShellBarOverflowParams): ShellBarHidableItem[] { + const items: ShellBarHidableItem[] = [ + ...this.buildContent(params), + ...this.buildActions(params), + ]; + + // sort by hideOrder first then by keepHidden keepHidden items are at the start + return items.sort((a, b) => { + if (a.keepHidden && !b.keepHidden) { + return -1; + } + if (!a.keepHidden && b.keepHidden) { + return 1; + } + return a.hideOrder - b.hideOrder; + }); + } + + private buildContent(params: ShellBarOverflowParams): readonly ShellBarHidableItem[] { + const { + content, showSearchField, + } = params; + + const items: ShellBarHidableItem[] = []; + const overflowStrategy = this.getOverflowStrategy(showSearchField); + + // Build content items + content.forEach((item, index) => { + const slotName = (item as any)._individualSlot as string; + const dataHideOrder = parseInt(item.getAttribute("data-hide-order") || String(index)); + const isLast = index === content.length - 1; + + const priority = isLast ? overflowStrategy.LAST_CONTENT : overflowStrategy.CONTENT; + + items.push({ + id: slotName, + selector: `#${slotName}`, + hideOrder: priority + dataHideOrder, + keepHidden: false, // Content items don't cause flickering + showInOverflow: false, + }); + }); + + return items; + } + + private buildActions(params: ShellBarOverflowParams): readonly ShellBarHidableItem[] { + const { + customItems, actions, showSearchField, hiddenItemsIds, + } = params; + + const items: ShellBarHidableItem[] = []; + const overflowStrategy = this.getOverflowStrategy(showSearchField); + let actionIndex = 0; + + customItems.forEach(item => { + items.push({ + id: item._id, + selector: `[data-ui5-stable="${item.stableDomRef}"]`, + hideOrder: overflowStrategy.ACTIONS + actionIndex++, + keepHidden: hiddenItemsIds.includes(item._id), + showInOverflow: true, + }); + }); + + actions + // skip protected actions and search (handled separately) + .filter(a => !a.isProtected && a.id !== ShellBarActions.Search) + .forEach(config => { + items.push({ + id: config.id, + selector: config.selector, + hideOrder: overflowStrategy.ACTIONS + actionIndex++, + keepHidden: hiddenItemsIds.includes(config.id), + showInOverflow: true, + }); + }); + + if (!showSearchField) { + // Only move search to overflow if it's closed + items.push({ + id: ShellBarActions.Search, + selector: ShellBarActionsSelectors.Search, + hideOrder: overflowStrategy.SEARCH + actionIndex++, + keepHidden: false, // Search button can be shown/hidden freely + showInOverflow: true, + }); + } + return items; + } + + getOverflowItems(params: { + actions: readonly ShellBarActionItem[]; + customItems: readonly ShellBarItem[]; + hiddenItemsIds: readonly string[]; + }): ReadonlyArray { + const { actions, customItems, hiddenItemsIds } = params; + const result: ShellBarOverflowItem[] = []; + + // Add hidden custom items + const hiddenCustomItems = customItems.filter((item: ShellBarItem) => hiddenItemsIds.includes(item._id)); + hiddenCustomItems.forEach((item: ShellBarItem, index: number) => { + result.push({ + type: "item", id: item._id, data: item, order: 3 + index, + }); + }); + + const actionOrder: Record = { + [ShellBarActions.Search]: 0, + [ShellBarActions.Notifications]: 1, + [ShellBarActions.Assistant]: 2, + }; + + const hiddenActions = actions.filter(action => hiddenItemsIds.includes(action.id)); + hiddenActions.forEach(action => { + result.push({ + type: "action", + id: action.id, + data: action, + order: actionOrder[action.id] ?? 0, + }); + }); + + return result.sort((a, b) => a.order - b.order); + } +} + +export default ShellBarOverflow; +export type { + ShellBarHidableItem, + ShellBarOverflowParams, + ShellBarOverflowResult, + ShellBarOverflowItem, +}; diff --git a/packages/fiori/src/shellbar/ShellBarSearch.ts b/packages/fiori/src/shellbar/ShellBarSearch.ts new file mode 100644 index 000000000000..58b0d8f6aa6f --- /dev/null +++ b/packages/fiori/src/shellbar/ShellBarSearch.ts @@ -0,0 +1,184 @@ +import { isPhone } from "@ui5/webcomponents-base/dist/Device.js"; +import type { IShellBarSearchField } from "../ShellBar.js"; +import type { IShellBarSearchController } from "./IShellBarSearchController.js"; + +interface ShellBarSearchConstructorParams { + getOverflowed: () => boolean; + getSearchState: () => boolean; + setSearchState: (expanded: boolean) => void; + getSearchField: () => IShellBarSearchField | null; + getCSSVariable: (variable: string) => string; +} + +/** + * Search controller for self-collapsible search (ui5-shellbar-search). + * Handles search fields with collapsed/open properties and ui5-open/close/search events. + */ +class ShellBarSearch implements IShellBarSearchController { + static CSS_VARIABLE = "--_ui5_shellbar_search_field_width"; + static FALLBACK_WIDTH = 400; + + private onSearchBound = this.onSearch.bind(this); + private onSearchOpenBound = this.onSearchOpen.bind(this); + private onSearchCloseBound = this.onSearchClose.bind(this); + + private getOverflowed: () => boolean; + private getSearchField: () => IShellBarSearchField | null; + private getSearchState: () => boolean; + private setSearchState: (expanded: boolean) => void; + private getCSSVariable: (variable: string) => string; + private initialRender = true; + + constructor({ + getOverflowed, + setSearchState, + getSearchField, + getSearchState, + getCSSVariable, + }: ShellBarSearchConstructorParams) { + this.getOverflowed = getOverflowed; + this.getCSSVariable = getCSSVariable; + this.getSearchField = getSearchField; + this.getSearchState = getSearchState; + this.setSearchState = setSearchState; + } + + subscribe(searchField: HTMLElement | null = this.getSearchField()) { + if (!searchField) { + return; + } + searchField.addEventListener("ui5-open", this.onSearchOpenBound); + searchField.addEventListener("ui5-close", this.onSearchCloseBound); + searchField.addEventListener("ui5-search", this.onSearchBound); + } + + unsubscribe(searchField: HTMLElement | null = this.getSearchField()) { + if (!searchField) { + return; + } + searchField.removeEventListener("ui5-open", this.onSearchOpenBound); + searchField.removeEventListener("ui5-close", this.onSearchCloseBound); + searchField.removeEventListener("ui5-search", this.onSearchBound); + } + + /** + * Auto-collapse/restore search field based on available space. + * Delegates decision logic to SearchController. + */ + autoManageSearchState(hiddenItems: number, availableSpace: number) { + if (!this.hasSearchField) { + return; + } + + // Get search field min width from CSS variable + const searchFieldWidth = this.getSearchFieldWidth(); + + const searchHasFocus = document.activeElement === this.getSearchField(); + const searchHasValue = !!this.getSearchField()?.value; + + // On initial load, allow search to collapse even if it would trigger full-screen mode. + // This prevents search from showing in full-screen when page loads on small screens. + // After initial render, prevent collapse in full-screen mode during resize. + const inFullScreen = !this.initialRender && this.shouldShowFullScreen(); + const preventCollapse = searchHasFocus || searchHasValue || inFullScreen; + + if (hiddenItems > 0 && !preventCollapse) { + this.setSearchState(false); + } else if (availableSpace + this.getSearchButtonSize() > searchFieldWidth) { + this.setSearchState(true); + } + + this.initialRender = false; + } + + /** + * Applies the show-search-field state to the search field. + */ + syncShowSearchFieldState() { + const search = this.getSearchField(); + if (!search) { + return; + } + if (isPhone()) { + search.open = this.getSearchState(); + } else { + search.collapsed = !this.getSearchState(); + } + } + + /** + * Determines if full-screen search should be shown. + * Full-screen search activates when overflow happens AND search is visible. + */ + shouldShowFullScreen(): boolean { + return this.getOverflowed() && this.getSearchState(); + } + + private onSearchOpen(e: Event) { + if (e.target !== this.getSearchField()) { + this.unsubscribe(e.target as HTMLElement); + return; + } + if (isPhone()) { + this.setSearchState(true); + } + } + + private onSearchClose(e: Event) { + if (e.target !== this.getSearchField()) { + this.unsubscribe(e.target as HTMLElement); + return; + } + if (isPhone()) { + this.setSearchState(false); + } + } + + private onSearch(e: Event) { + if (e.target !== this.getSearchField()) { + this.unsubscribe(e.target as HTMLElement); + return; + } + + // On mobile or if has value, don't toggle + if (isPhone() || (this.getSearchField()?.value && this.getSearchState())) { + return; + } + + this.setSearchState(!this.getSearchState()); + } + + /** + * Gets the minimum width needed for search field from CSS variable. + */ + private getSearchFieldWidth(): number { + const width = this.getCSSVariable(ShellBarSearch.CSS_VARIABLE); + if (!width) { + return ShellBarSearch.FALLBACK_WIDTH; + } + // Convert rem to px + if (width.endsWith("rem")) { + const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); + return parseFloat(width) * fontSize; + } + return parseFloat(width); + } + + get hasSearchField() { + return !!this.getSearchField(); + } + + /** + * Gets the size of the search button. + * If the search field is visible, the size is 0. + * Otherwise, it is the width of the search field (just a button in collapsed state). + */ + private getSearchButtonSize(): number { + return this.getSearchState() ? 0 : this.getSearchField()?.getBoundingClientRect().width || 0; + } +} + +export default ShellBarSearch; +export type { + ShellBarSearchConstructorParams, +}; diff --git a/packages/fiori/src/shellbar/ShellBarSearchLegacy.ts b/packages/fiori/src/shellbar/ShellBarSearchLegacy.ts new file mode 100644 index 000000000000..622015f25850 --- /dev/null +++ b/packages/fiori/src/shellbar/ShellBarSearchLegacy.ts @@ -0,0 +1,163 @@ +import type { IShellBarSearchController } from "./IShellBarSearchController.js"; + +interface ShellBarSearchLegacyConstructorParams { + getOverflowed: () => boolean; + getSearchState: () => boolean; + setSearchState: (expanded: boolean) => void; + getSearchField: () => HTMLElement | null; + getCSSVariable: (variable: string) => string; + getDisableSearchCollapse: () => boolean; +} + +/** + * Search controller for legacy search fields (ui5-input, custom div). + * Handles search fields that don't have collapsed/open properties. + * Supports disableSearchCollapse for preventing auto-collapse. + */ +class ShellBarSearchLegacy implements IShellBarSearchController { + static CSS_VARIABLE = "--_ui5_shellbar_search_field_width"; + static FALLBACK_WIDTH = 400; + + private getOverflowed: () => boolean; + private getSearchField: () => HTMLElement | null; + private getSearchState: () => boolean; + private setSearchState: (expanded: boolean) => void; + private getCSSVariable: (variable: string) => string; + private getDisableSearchCollapse: () => boolean; + private initialRender = true; + + constructor({ + getOverflowed, + setSearchState, + getSearchField, + getSearchState, + getCSSVariable, + getDisableSearchCollapse, + }: ShellBarSearchLegacyConstructorParams) { + this.getOverflowed = getOverflowed; + this.getCSSVariable = getCSSVariable; + this.getSearchField = getSearchField; + this.getSearchState = getSearchState; + this.setSearchState = setSearchState; + this.getDisableSearchCollapse = getDisableSearchCollapse; + } + + /** + * No-op for legacy search - legacy fields don't emit ui5-open/close/search events. + */ + subscribe(): void { + // No events to subscribe to for legacy search fields + } + + /** + * No-op for legacy search - no event listeners to clean up. + */ + unsubscribe(): void { + // No events to unsubscribe from + } + + /** + * Auto-collapse/restore search field based on available space. + * Respects disableSearchCollapse flag, focus state, and field value. + */ + autoManageSearchState(hiddenItems: number, availableSpace: number): void { + if (!this.hasSearchField) { + return; + } + + // Check if auto-collapse is disabled + if (this.getDisableSearchCollapse()) { + return; + } + + const searchFieldWidth = this.getSearchFieldWidth(); + + // Check focus and value to prevent collapse + const searchField = this.getSearchField(); + const searchHasFocus = searchField?.contains(document.activeElement) || false; + const searchHasValue = this.hasValue(searchField); + + // On initial load, allow search to collapse even if it would trigger full-screen mode. + // This prevents search from showing in full-screen when page loads on small screens. + // After initial render, prevent collapse in full-screen mode during resize. + const inFullScreen = !this.initialRender && this.shouldShowFullScreen(); + const preventCollapse = searchHasFocus || searchHasValue || inFullScreen; + + if (hiddenItems > 0 && !preventCollapse) { + this.setSearchState(false); + } else if (availableSpace + this.getSearchButtonSize() > searchFieldWidth) { + this.setSearchState(true); + } + + this.initialRender = false; + } + + /** + * No-op for legacy search - legacy fields don't have collapsed/open properties. + */ + syncShowSearchFieldState(): void { + // Legacy search fields don't have collapsed/open properties to sync + } + + /** + * Determines if full-screen search should be shown. + * Full-screen search activates when overflow happens AND search is visible. + */ + shouldShowFullScreen(): boolean { + return this.getOverflowed() && this.getSearchState(); + } + + /** + * Get value from various field types. + * Supports ui5-input (value property) and custom div (nested input element). + */ + private hasValue(searchField: HTMLElement | null): boolean { + if (!searchField) { + return false; + } + + // ui5-input or similar components with value property + if ("value" in searchField) { + return !!(searchField as any).value; + } + + // Custom div - find input inside + const input = searchField.querySelector("input"); + return input ? !!input.value : false; + } + + /** + * Get minimum width needed for search field from CSS variable. + */ + private getSearchFieldWidth(): number { + const width = this.getCSSVariable(ShellBarSearchLegacy.CSS_VARIABLE); + if (!width) { + return ShellBarSearchLegacy.FALLBACK_WIDTH; + } + + // Convert rem to px + if (width.endsWith("rem")) { + const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); + return parseFloat(width) * fontSize; + } + + return parseFloat(width); + } + + private get hasSearchField(): boolean { + return !!this.getSearchField(); + } + + /** + * Get search button size for overflow calculation. + * Returns 0 if search is expanded, otherwise returns button width. + */ + private getSearchButtonSize(): number { + return this.getSearchState() ? 0 : this.getSearchField()?.getBoundingClientRect().width || 0; + } +} + +export default ShellBarSearchLegacy; +export type { + ShellBarSearchLegacyConstructorParams, +}; diff --git a/packages/fiori/src/shellbar/templates/ShellBarLegacyTemplate.tsx b/packages/fiori/src/shellbar/templates/ShellBarLegacyTemplate.tsx new file mode 100644 index 000000000000..a5a48de23638 --- /dev/null +++ b/packages/fiori/src/shellbar/templates/ShellBarLegacyTemplate.tsx @@ -0,0 +1,190 @@ +import Icon from "@ui5/webcomponents/dist/Icon.js"; +import List from "@ui5/webcomponents/dist/List.js"; +import Popover from "@ui5/webcomponents/dist/Popover.js"; +import slimArrowDown from "@ui5/webcomponents-icons/dist/slim-arrow-down.js"; +import type ShellBar from "../../ShellBar.js"; + +function ShellBarLegacyBrandingArea(this: ShellBar) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( + <> + {legacy.hasMenuItems && ShellBarInteractiveMenuButton.call(this)} + {legacy.hasMenuItems && ShellBarLegacySecondaryTitle.call(this)} + {!legacy.hasMenuItems && ShellBarLegacyTitleArea.call(this)} + + {/* Menu Popover (legacy) */} + {ShellBarMenuPopover.call(this)} + + ); +} + +function ShellBarLegacyTitleArea(this: ShellBar) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( + <> + {!!(legacy.isSBreakPoint && legacy.hasLogo) && ShellBarSingleLogo.call(this)} + {!legacy.isSBreakPoint && (legacy.hasLogo || legacy.primaryTitle) && ( + <> + {ShellBarCombinedLogo.call(this)} + {legacy.hasSecondaryTitle && legacy.hasPrimaryTitle && ShellBarLegacySecondaryTitle.call(this)} + + )} + + ); +} + +/** + * Renders interactive menu button for non-S breakpoints. + * Shows primaryTitle with arrow, opens menu popover. + */ +function ShellBarInteractiveMenuButton(this: ShellBar) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( + <> + {!legacy.showLogoInMenuButton && legacy.hasLogo && ShellBarSingleLogo.call(this)} + {legacy.showTitleInMenuButton &&

{legacy.primaryTitle}

} + {legacy.showMenuButton && ( + + )} + + ); +} + +/** + * Renders single logo on S breakpoint when no menu items. + * Used on S breakpoint when no menu items and no branding slot. + */ +function ShellBarSingleLogo(this: ShellBar) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( + + ); +} + +function ShellBarCombinedLogo(this: ShellBar) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( +
+ {legacy.hasLogo && ( + + )} +
+ {legacy.primaryTitle && ( +

+ {legacy.primaryTitle} +

+ )} +
+
+ ); +} + +function ShellBarLegacySecondaryTitle(this: ShellBar) { + const legacy = this.legacyAdaptor; + if (!legacy || !legacy.showSecondaryTitle) { + return null; + } + + return ( +
+ {this.secondaryTitle} +
+ ); +} + +/** + * Renders the menu popover. + * Contains the list of menu items. + */ +function ShellBarMenuPopover(this: ShellBar) { + const legacy = this.legacyAdaptor; + if (!legacy || !legacy.hasMenuItems) { + return null; + } + + return ( + + + + + + ); +} + +export { + ShellBarSingleLogo, + ShellBarMenuPopover, + ShellBarLegacyTitleArea, + ShellBarLegacyBrandingArea, + ShellBarLegacySecondaryTitle, + ShellBarInteractiveMenuButton, +}; diff --git a/packages/fiori/src/shellbar/templates/ShellBarSearchLegacyTemplate.tsx b/packages/fiori/src/shellbar/templates/ShellBarSearchLegacyTemplate.tsx new file mode 100644 index 000000000000..ab27a656ec6c --- /dev/null +++ b/packages/fiori/src/shellbar/templates/ShellBarSearchLegacyTemplate.tsx @@ -0,0 +1,61 @@ +import Button from "@ui5/webcomponents/dist/Button.js"; +import ButtonDesign from "@ui5/webcomponents/dist/types/ButtonDesign.js"; +import type ShellBar from "../../ShellBar.js"; + +function ShellBarSearchField(this: ShellBar) { + return ( + // .ui5-shellbar-search-field-area is used to measure the width of + // the search field. It must be present even if the search is in full-width mode. +
+ {this.showSearchField && !this.showFullWidthSearch && ( +
+ +
+ )} +
+ ); +} + +function ShellBarSearchFieldFullWidth(this: ShellBar) { + return ( +
+
+ +
+ +
+ ); +} + +function ShellBarSearchButton(this: ShellBar) { + const searchAction = this.getAction("search"); + return ( + <> + {!this.hideSearchButton && ( + + + ); +} + +export { + ShellBarSearchField, + ShellBarSearchFieldFullWidth, +}; diff --git a/packages/fiori/src/themes/NavigationLayout.css b/packages/fiori/src/themes/NavigationLayout.css index e5895ca3e25e..9641cef6fb39 100644 --- a/packages/fiori/src/themes/NavigationLayout.css +++ b/packages/fiori/src/themes/NavigationLayout.css @@ -86,6 +86,7 @@ transform: translateX(100%); } +:host([has-side-navigation]) ::slotted([ui5-shellbar][slot="header"]), :host([has-side-navigation]) ::slotted([ui5-shellbar][slot="header"]) { padding-inline: 0.875rem 1rem; } diff --git a/packages/fiori/src/themes/ShellBar.css b/packages/fiori/src/themes/ShellBar.css index dfbc68b745da..3d81d25a295c 100644 --- a/packages/fiori/src/themes/ShellBar.css +++ b/packages/fiori/src/themes/ShellBar.css @@ -1,4 +1,9 @@ @import "./InvisibleTextStyles.css"; +@import "./ShellBarSearchLegacy.css"; + +/* ============================================================================ + HOST & CSS VARIABLES + ============================================================================ */ :host(:not([hidden])) { display: inline-block; @@ -6,376 +11,179 @@ max-width: 100%; background: var(--sapShellColor); box-sizing: border-box; -} - -:host { - box-shadow: inset 0 -0.0625rem 0 0 var(--sapPageHeader_BorderColor); -} -::slotted([ui5-input]) { - --_ui5_input_placeholder_color: var(--sapShell_InteractiveTextColor); - --_ui5_input_border_radius: var(--_ui5_shellbar_input_border_radius); - --_ui5_input_focus_border_radius: var(--_ui5_shellbar_input_focus_border_radius); - --_ui5_input_background_color: var(--_ui5_shellbar_input_background_color); - --_ui5_input_focus_outline_color: var(--_ui5_shellbar_input_focus_outline_color); - --_ui5_input_margin_top_bottom: 0; -} - -::slotted([ui5-button]), -[ui5-button], -::slotted([ui5-toggle-button]), -[ui5-toggle-button] { - /* Overwrite the default button styles to fulfill no "compact" mode of ui5-shellbar */ - --_ui5_button_base_min_width: 2.25rem; - --_ui5_button_base_padding: 0.5625rem; + /* CSS variable overrides for ui5-button */ --_ui5_button_base_height: var(--sapElement_Height); + --_ui5_button_base_padding: 0.5625rem; + --_ui5_button_base_min_width: 2.25rem; --_ui5-button-badge-diameter: 0.75rem; + + /* ShellBar-specific variables */ + --_ui5-shellbar_separator-color: var(--sapGroup_ContentBorderColor); + --_ui5-shellbar-separator-height: 2rem; + --_ui5_shellbar_search_field_width: 25rem; + + --ui5_shellbar_gap: 0.5rem; } +/* ============================================================================ + ROOT CONTAINER + ============================================================================ */ + .ui5-shellbar-root { - position: relative; display: flex; - justify-content: space-between; align-items: center; height: var(--_ui5_shellbar_root_height); - font-family: var(--sapFontFamily); + box-shadow: inset 0 -0.0625rem var(--sapShell_BorderColor); + position: relative; font-size: var(--sapFontSize); font-weight: normal; - box-sizing: border-box; } -.ui5-shellbar-menu-button, -.ui5-shellbar-button, -.ui5-shellbar-image-button, -::slotted([ui5-toggle-button]:not([slot^="content"])), -::slotted([ui5-button]:not([slot^="content"])) { +/* ============================================================================ + SLOTTED BUTTONS (General Styles) Assistant and Start Button slots + ============================================================================ */ + +::slotted([ui5-button]:not([slot^="content"])), +::slotted([ui5-toggle-button]:not([slot^="content"])) { height: 2.25rem; + width: 2.25rem; padding: 0; - margin-inline-start: var(--_ui5-shellbar-overflow-button-margin); border: 0.0625rem solid var(--sapButton_Lite_BorderColor); background: var(--sapButton_Lite_Background); - outline-color: var(--_ui5_shellbar_logo_outline_color); color: var(--sapShell_TextColor); box-sizing: border-box; cursor: pointer; border-radius: var(--_ui5_shellbar_button_border_radius); - position: relative; font-weight: bold; - white-space: initial; - overflow: initial; - text-overflow: initial; - line-height: inherit; - letter-spacing: inherit; - word-spacing: inherit; -} - -::slotted([ui5-toggle-button][slot="assistant"]) { - margin-inline-start: 0; -} - -.ui5-shellbar-assistant-button { - margin-inline-start: var(--_ui5-shellbar-overflow-button-margin); -} - -::slotted([ui5-button][slot="startButton"]) { - margin-inline-start: 0; } -::slotted([ui5-toggle-button]:hover), -::slotted([ui5-button]:hover), -.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:hover, -.ui5-shellbar-button:hover, -.ui5-shellbar-image-button:hover, -::slotted([ui5-button][slot="midContent"]:hover) { +/* Button States - Hover */ +::slotted([ui5-button]:not([slot^="content"]):hover), +::slotted([ui5-toggle-button]:not([slot^="content"]):hover) { background: var(--sapShell_Hover_Background); border-color: var(--sapButton_Lite_Hover_BorderColor); color: var(--sapShell_TextColor); } -::slotted([ui5-toggle-button][slot="assistant"][pressed]), -::slotted([ui5-toggle-button][slot="assistant"][pressed]:hover:not([active])) { - color: var(--sapShell_Assistant_ForegroundColor); -} - -::slotted([ui5-toggle-button][active]), -::slotted([ui5-button][active]), -.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:active, -.ui5-shellbar-button[active], -.ui5-shellbar-image-button:active { +/* Button States - Active */ +::slotted([ui5-button]:not([slot^="content"])[active]), +::slotted([ui5-toggle-button]:not([slot^="content"])[active]) { background: var(--sapShell_Active_Background); border-color: var(--sapButton_Lite_Active_BorderColor); color: var(--_ui5_shellbar_button_active_color); } -:host([desktop]) .ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:focus, -.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:focus-visible { - outline: var(--_ui5_shellbar_logo_outline); - outline-offset: var(--_ui5_shellbar_outline_offset); +/* Button States - Focus */ +::slotted([ui5-button]:not([slot^="content"])), +::slotted([ui5-toggle-button]:not([slot^="content"])) { + --_ui5_button_focused_border: var(--_ui5_shellbar_button_focused_border); } -slot[name="profile"] { - min-width: 0; -} +/* ============================================================================ + ACTION BUTTONS (Items & Internal Actions) + ============================================================================ */ -::slotted([ui5-avatar][slot="profile"]) { - display: block; - width: 2rem; - height: 2rem; - min-width: 0; - min-height: 2rem; - font-size: var(--_ui5_avatar_fontsize_XS); - font-weight: normal; +.ui5-shellbar-action-button { + color: var(--sapShell_TextColor); } -.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive::-moz-focus-inner { - border: none; +.ui5-shellbar-action-button:hover { + color: var(--sapShell_TextColor); } -.ui5-shellbar-menu-button-arrow, -.ui5-shellbar-menu-button-title, -.ui5-shellbar-title { - display: inline-block; - font-family: var(--sapFontSemiboldDuplexFamily); - margin: 0; - font-size: var(--_ui5_shellbar_menu_button_title_font_size); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--sapShell_SubBrand_TextColor); +.ui5-shellbar-action-button[active] { + color: var(--_ui5_shellbar_button_active_color); } -:host(:not([primary-title])) .ui5-shellbar-menu-button { - min-width: 2.25rem; - justify-content: center; +::slotted([ui5-toggle-button][slot="assistant"]) { + color: var(--sapShell_TextColor); } -.ui5-shellbar-secondary-title { - display: inline-block; - font-size: var(--sapFontSmallSize); +::slotted([ui5-toggle-button][slot="assistant"]:hover) { color: var(--sapShell_TextColor); - font-weight: normal; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - margin: 0; - text-align: start; } -.ui5-shellbar-headings { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; - overflow: hidden; - margin-inline-start: 0.25rem; +::slotted([ui5-toggle-button][slot="assistant"][active]) { + color: var(--_ui5_shellbar_button_active_color); } -.ui5-shellbar-menu-button--interactive .ui5-shellbar-menu-button-arrow { - margin-inline-start: 0.375rem; -} +/* ============================================================================ + START AREA (Start Button, Branding) + ============================================================================ */ -.ui5-shellbar-overflow-container { +.ui5-shellbar-start-button { + flex-shrink: 0; display: flex; - justify-content: center; align-items: center; - height: 100%; - overflow: hidden; } -.ui5-shellbar-overflow-container-middle { - align-self: flex-start; - height: var(--_ui5_shellbar_overflow_container_middle_height); - width: 0; +.ui5-shellbar-branding-area { flex-shrink: 0; -} - -.ui5-shellbar-mid-content { - height: var(--_ui5_shellbar_overflow_container_middle_height); -} - - -:host([breakpoint-size="S"]) .ui5-shellbar-menu-button { - margin-inline-start: 0; -} - -:host([breakpoint-size="S"]) { - padding: 0 1rem; -} - -:host([breakpoint-size="S"]) .ui5-shellbar-search-full-width-wrapper { - padding: 0 1rem; -} - -:host([breakpoint-size="M"]) { - padding: 0 2rem; -} - -:host([breakpoint-size="M"]) .ui5-shellbar-search-full-width-wrapper { - padding: 0 2rem; -} - -:host([breakpoint-size="L"]) { - padding: 0 2rem; -} - -:host([breakpoint-size="XL"]) { - padding: 0 3rem; -} - -:host([breakpoint-size="XXL"]) { - padding: 0 3rem; -} - -.ui5-shellbar-logo { - overflow: hidden; - cursor: pointer; -} - -.ui5-shellbar-logo-area { - overflow: hidden; display: flex; align-items: center; - padding: .25rem .5rem .25rem .25rem; - box-sizing: border-box; - cursor: pointer; - background: var(--sapButton_Lite_Background); - border: 1px solid var(--sapButton_Lite_BorderColor); - color: var(--sapShell_TextColor); - /* fix cutting of the focus outline */ - margin-inline-start: 0.125rem; -} - -.ui5-shellbar-logo:focus, -.ui5-shellbar-logo-area:focus { - outline: var(--_ui5_shellbar_logo_outline); - outline-offset: calc(-1 * var(--sapContent_FocusWidth)); - border-radius: var(--_ui5_shellbar_logo_border_radius); } -.ui5-shellbar-overflow-container>.ui5-shellbar-logo:hover, -.ui5-shellbar-logo-area:hover { - box-shadow: var(--_ui5_shellbar_button_box_shadow); - border-radius: var(--_ui5_shellbar_logo_border_radius); -} - -.ui5-shellbar-logo-area:active:focus { - background: var(--sapShell_Active_Background); - border: 1px solid var(--sapButton_Lite_Active_BorderColor); - color: var(--sapShell_Active_TextColor); -} - -.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:hover { - box-shadow: var(--_ui5_shellbar_button_box_shadow); -} - -.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:active { - box-shadow: var(--_ui5_shellbar_button_box_shadow_active); -} +/* ============================================================================ + OVERFLOW CONTAINER + ============================================================================ */ -.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:active .ui5-shellbar-menu-button-arrow, -.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:active .ui5-shellbar-menu-button-title { - color: var(--sapShell_Active_TextColor); -} - -.ui5-shellbar-menu-button .ui5-shellbar-logo:hover { - box-shadow: none; -} - -.ui5-shellbar-button { - width: 2.5rem; - box-sizing: border-box; -} - -.ui5-shellbar-button, -::slotted([ui5-button][slot="startButton"]) { - --_ui5_button_focused_border: var(--_ui5_shellbar_button_focused_border); -} - -.ui5-shellbar-cancel-button { - color: var(--_ui5-shellbar_cancel-button-color); -} - -.ui5-shellbar-cancel-button:hover { - color: var(--_ui5-shellbar_cancel-button-color); -} - -.ui5-shellbar-image-button { +.ui5-shellbar-overflow-container { + /* makes the container grow on the left side thus preventing search from flickering */ + flex-direction: row-reverse; + height: 100%; + flex: 1; display: flex; - justify-content: center; align-items: center; - min-width: auto; - height: 2.5rem; - --_ui5_button_focused_border_radius: var(--_ui5_shellbar_image_button_border_radius); - border-radius: var(--_ui5_shellbar_image_button_border_radius); + min-width: 0; + overflow: hidden; + position: relative; } -.ui5-shellbar-overflow-container-left { - padding: 0; - justify-content: flex-start; - max-width: 75%; +.ui5-shellbar-overflow-container-inner { + display: flex; + align-items: center; + justify-content: end; flex-shrink: 0; + min-width: 100%; } -.ui5-shellbar-overflow-container-left> :nth-child(n) { - margin-inline-end: 0.5rem; -} - -/* :host([breakpoint-size="XXL"]) .ui5-shellbar-with-searchfield .ui5-shellbar-overflow-container-left { - flex-basis: 50%; - max-width: calc(50% - 18.25rem); -} */ +/* ============================================================================ + SEARCH AREA + ============================================================================ */ -.ui5-shellbar-menu-button { - white-space: nowrap; - overflow: hidden; +.ui5-shellbar-search-field-area { + flex: 0 1 auto; + min-width: 0; display: flex; align-items: center; - padding: 0.25rem 0.5rem; - cursor: text; - -webkit-user-select: text; - -moz-user-select: text; - user-select: text; + margin-left: auto; + /* goes to the most right when no content is present */ } -.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive { - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - cursor: pointer; - background: var(--sapButton_Lite_Background); - border: var(--_ui5_shellbar_button_border); - color: var(--sapShell_TextColor); +:host([show-search-field]) ::slotted([slot="searchField"]), +/* Search field displays in full mode if there's not enough space in bar. +Once in full screen mode the search field is rendered in another DOM. +To account for correct measurements in overflow, we should keep the min width +of the search field container in the bar even when the search is in full mode. */ +:host([show-full-width-search]) .ui5-shellbar-search-field-area { + min-width: var(--_ui5_shellbar_search_field_width); } -:host(:not([with-logo])) .ui5-shellbar-menu-button { - margin-inline-start: 0; -} +/* ============================================================================ + CONTENT AREA (Items, Spacer, Separator) + ============================================================================ */ -.ui5-shellbar-overflow-container-right { +.ui5-shellbar-content-area { flex-grow: 1; - justify-content: flex-end; -} - -.ui5-shellbar-overflow-container-right .ui5-shellbar-overflow-container-right-child { display: flex; - justify-content: flex-end; - height: inherit; align-items: center; } -.ui5-shellbar-overflow-container-right-inner { - display: flex; - flex-grow: 1; -} - -.ui5-shellbar-content-items { +.ui5-shellbar-content-item { + flex-shrink: 0; display: flex; - justify-content: center; align-items: center; - flex-grow: 1; - /* max-content prevents the container from overflowing - which is needed for the responsive logic */ - min-width: max-content; } .ui5-shellbar-spacer { @@ -393,55 +201,81 @@ slot[name="profile"] { background-color: var(--_ui5-shellbar_separator-color); } -.ui5-shellbar-separator-end { - margin-inline-start: 0.5rem; +/* ============================================================================ + CUSTOM ITEMS + ============================================================================ */ + +.ui5-shellbar-custom-item { + /* having width here is important to ensure item can be measured even when it is in overflow */ + width: 2.25rem; + flex-shrink: 0; + display: flex; + align-items: center; } -:host([breakpoint-size="S"]) .ui5-shellbar-overflow-container-right { - padding-inline-start: 0; +.ui5-shellbar-custom-item.ui5-shellbar-hidden { + display: none; } -::slotted([hidden]) { - visibility: hidden; - order: -1; - position: absolute; + +/* ============================================================================ + ACTION BUTTONS (Notifications, Assistant, Profile) + ============================================================================ */ + +.ui5-shellbar-action-button { + white-space: initial; + overflow: initial; + text-overflow: initial; + line-height: inherit; + letter-spacing: inherit; + word-spacing: inherit; + width: 2.25rem; + box-sizing: border-box; } -.ui5-shellbar-content-item { +.ui5-shellbar-image-button { display: flex; + justify-content: center; align-items: center; - flex-shrink: 0; - padding-inline-start: var(--_ui5-shellbar-content-margin-start); + width: 2.25rem; + height: 2.25rem; + min-width: auto; + box-sizing: border-box; + --_ui5_button_focused_border_radius: var(--_ui5_shellbar_image_button_border_radius); + border-radius: var(--_ui5_shellbar_image_button_border_radius); } -.ui5-shellbar-overflow-container-right-child .ui5-shellbar-bell-button [slot="badge"] { - inset-inline-end: var(--_ui5_shellbar_notification_btn_count_offset); +.ui5-shellbar-assistant-button { + display: flex; + align-items: center; } -.ui5-shellbar-overflow-container-right-child .ui5-shellbar-custom-item [slot="badge"] { - inset-inline-end: var(--_ui5_shellbar_notification_btn_count_offset); +::slotted([ui5-toggle-button][slot="assistant"]) { + margin-inline-start: 0; } -.ui5-shellbar-menu-button { - margin-inline-start: 0.5rem; +::slotted([ui5-toggle-button][slot="assistant"][pressed]), +::slotted([ui5-toggle-button][slot="assistant"][pressed]:hover:not([active])) { + color: var(--sapShell_Assistant_ForegroundColor); } -.ui5-shellbar-search-field { - padding-inline-start: var(--_ui5-shellbar-content-margin-start); - min-width: var(--_ui5_shellbar_search_field_width); - align-items: center; - flex-grow: 1; - margin-inline-start: 0.5rem; +slot[name="profile"] { + min-width: 0; } -.ui5-shellbar-overflow-container-right-child> :first-child { - margin-inline-start: 0; +::slotted([ui5-avatar][slot="profile"]) { + display: block; + width: 2rem; + height: 2rem; + min-width: 0; + min-height: 2rem; + font-size: var(--_ui5_avatar_fontsize_XS); + font-weight: normal; } -.ui5-shellbar-search-full-width-wrapper .ui5-shellbar-search-full-field { - height: 2.25rem; - width: 100%; -} +/* ============================================================================ + FULL-SCREEN SEARCH OVERLAY + ============================================================================ */ .ui5-shellbar-search-full-width-wrapper { position: absolute; @@ -454,85 +288,66 @@ slot[name="profile"] { display: flex; align-items: center; box-sizing: border-box; + padding: 0 1rem; } -.ui5-shellbar-search-full-width-wrapper .ui5-shellbar-button { - width: auto; +.ui5-shellbar-search-full-width-wrapper .ui5-shellbar-search-full-field { + height: 2.25rem; + width: 100%; + flex: 1; } .ui5-shellbar-search-full-width-wrapper ::slotted([ui5-shellbar-search]) { max-width: unset; -} - -::slotted([ui5-input]) { - background: var(--_ui5_shellbar_search_field_background); - border: var(--_ui5_shellbar_search_field_border); - box-shadow: var(--_ui5_shellbar_search_field_box_shadow); - color: var(--_ui5_shellbar_search_field_color); - height: 2.25rem; width: 100%; - min-width: var(--_ui5_shellbar_search_field_width); } -:host([breakpoint-size="M"]) ::slotted([ui5-input]), -:host([breakpoint-size="S"]) ::slotted([ui5-input]) { - min-width: 1rem; -} +/* ============================================================================ + BREAKPOINTS + ============================================================================ */ -:host([breakpoint-size="M"][show-search-field]) .ui5-shellbar-overflow-container-right-child { - flex-grow: 1; +/* Responsive padding per breakpoint */ +:host([breakpoint-size="S"]) { + padding: 0 1rem; } -::slotted([ui5-input]:hover) { - background: var(--_ui5_shellbar_search_field_background_hover); - box-shadow: var(--_ui5_shellbar_search_field_box_shadow_hover); +:host([breakpoint-size="M"]) { + padding: 0 2rem; } -::slotted([ui5-input][focused]) { - outline: var(--_ui5_shellbar_search_field_outline_focused); +:host([breakpoint-size="L"]) { + padding: 0 2rem; } -::slotted([slot="logo"]) { - max-height: 2rem; - vertical-align: middle; +:host([breakpoint-size="XL"]) { + padding: 0 3rem; } -::slotted([slot="logo"]):active { - pointer-events: none; +:host([breakpoint-size="XXL"]) { + padding: 0 3rem; } -.ui5-shellbar-co-pilot-placeholder { - width: 2.75rem; - height: 2.75rem; +/* Search overlay padding per breakpoint */ +:host([breakpoint-size="S"]) .ui5-shellbar-search-full-width-wrapper { + padding: 0 1rem; } -.ui5-shellbar-coPilot-pressed, -.ui5-shellbar-coPilot-pressed:hover { - color: var(--sapShell_Assistant_ForegroundColor); +:host([breakpoint-size="M"]) .ui5-shellbar-search-full-width-wrapper { + padding: 0 2rem; } -::slotted([ui5-button][slot="startButton"]) { - margin-inline: 0 0.5rem; - justify-content: center; - align-items: center; +/* ============================================================================ + Utilities (Keep these at the end of the file to avoid specificity issues) + ============================================================================ */ + +.ui5-shellbar-gap-start { + margin-inline-start: var(--ui5_shellbar_gap); } -::slotted([ui5-button][data-profile-btn]) { - width: auto; +.ui5-shellbar-gap-end { + margin-inline-end: var(--ui5_shellbar_gap); } -/* if class contains ui5-shellbar-hidden-button */ -::slotted(.ui5-shellbar-hidden-button), -.ui5-shellbar-hidden-button, -.ui5-shellbar-invisible-button { - visibility: hidden; - order: -1; - opacity: 0; - min-width: 0; - width: 0; - margin: 0; - padding: 0; - padding-inline-start: 0; - border: 0; - margin-inline-start: 0; +.ui5-shellbar-hidden { + display: none !important; } \ No newline at end of file diff --git a/packages/fiori/src/themes/ShellBarItem.css b/packages/fiori/src/themes/ShellBarItem.css new file mode 100644 index 000000000000..eb14a6694e4d --- /dev/null +++ b/packages/fiori/src/themes/ShellBarItem.css @@ -0,0 +1,42 @@ +/* ============================================================================ + ACTION BUTTON STYLING + ============================================================================ */ + +.ui5-shellbar-action-button { + width: 2.25rem; + color: var(--sapShell_TextColor); +} + +.ui5-shellbar-action-button:hover { + color: var(--sapShell_TextColor); +} + +.ui5-shellbar-action-button[active] { + color: var(--_ui5_shellbar_button_active_color); +} + +[ui5-li]:after { + position: relative; + width: fit-content; + height: 1rem; + min-width: 1rem; + background: var(--sapContent_BadgeBackground); + border: var(--_ui5_shellbar_button_badge_border); + color: var(--sapContent_BadgeTextColor); + bottom: calc(100% + 0.0625rem); + left: 1.25rem; + padding: 0 0.3125rem; + border-radius: 0.5rem; + display: flex; + justify-content: center; + align-items: center; + font-size: var(--sapFontSmallSize); + font-family: var(--sapFontFamily); + z-index: 2; + box-sizing: border-box; + pointer-events: none; +} + +[ui5-li][data-count]:after { + content: attr(data-count); +} \ No newline at end of file diff --git a/packages/fiori/src/themes/ShellBarLegacy.css b/packages/fiori/src/themes/ShellBarLegacy.css new file mode 100644 index 000000000000..5445e9003e76 --- /dev/null +++ b/packages/fiori/src/themes/ShellBarLegacy.css @@ -0,0 +1,174 @@ +/* Legacy Features CSS - Logo, Titles, Menu */ + +/* Logo */ +.ui5-shellbar-logo { + overflow: hidden; + cursor: pointer; +} + +.ui5-shellbar-logo-area, +.ui5-shellbar-legacy-branding { + overflow: hidden; + display: flex; + align-items: center; + padding: .25rem .5rem .25rem .25rem; + box-sizing: border-box; + cursor: pointer; + background: var(--sapButton_Lite_Background); + border: 1px solid var(--sapButton_Lite_BorderColor); + color: var(--sapShell_TextColor); + margin-inline-start: 0.125rem; +} + +.ui5-shellbar-logo:focus, +.ui5-shellbar-logo-area:focus { + outline: var(--_ui5_shellbar_logo_outline); + outline-offset: calc(-1 * var(--sapContent_FocusWidth)); + border-radius: var(--_ui5_shellbar_logo_border_radius); +} + +.ui5-shellbar-overflow-container > .ui5-shellbar-logo:hover, +.ui5-shellbar-logo-area:hover { + box-shadow: var(--_ui5_shellbar_button_box_shadow); + border-radius: var(--_ui5_shellbar_logo_border_radius); +} + +.ui5-shellbar-logo-area:active:focus { + background: var(--sapShell_Active_Background); + border: 1px solid var(--sapButton_Lite_Active_BorderColor); + color: var(--sapShell_Active_TextColor); +} + +::slotted([slot="logo"]) { + max-height: 2rem; +} + +::slotted([slot="logo"]):active { + pointer-events: none; +} + +/* Title Area */ +.ui5-shellbar-headings { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + overflow: hidden; + margin-inline-start: 0.25rem; +} + +.ui5-shellbar-primary-title, +.ui5-shellbar-menu-button-title, +.ui5-shellbar-title { + display: inline-block; + font-family: var(--sapFontSemiboldDuplexFamily); + margin: 0; + font-size: var(--_ui5_shellbar_menu_button_title_font_size); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--sapShell_SubBrand_TextColor); +} + +.ui5-shellbar-secondary-title { + display: inline-block; + font-size: var(--sapFontSmallSize); + color: var(--sapShell_TextColor); + font-weight: normal; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + text-align: start; +} + +/* Menu Button */ +.ui5-shellbar-menu-button { + white-space: nowrap; + overflow: hidden; + display: flex; + align-items: center; + padding: 0.25rem 0.5rem; + cursor: text; + -webkit-user-select: text; + -moz-user-select: text; + user-select: text; + margin-inline-start: 0.5rem; + height: 2.25rem; + border: 0.0625rem solid var(--sapButton_Lite_BorderColor); + background: var(--sapButton_Lite_Background); + outline-color: var(--_ui5_shellbar_logo_outline_color); + color: var(--sapShell_TextColor); + box-sizing: border-box; + border-radius: var(--_ui5_shellbar_button_border_radius); + position: relative; + font-weight: bold; +} + +.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: pointer; + background: var(--sapButton_Lite_Background); + border: var(--_ui5_shellbar_button_border); + color: var(--sapShell_TextColor); +} + +.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:hover { + background: var(--sapShell_Hover_Background); + border-color: var(--sapButton_Lite_Hover_BorderColor); + color: var(--sapShell_TextColor); + box-shadow: var(--_ui5_shellbar_button_box_shadow); +} + +.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:active { + background: var(--sapShell_Active_Background); + border-color: var(--sapButton_Lite_Active_BorderColor); + color: var(--_ui5_shellbar_button_active_color); + box-shadow: var(--_ui5_shellbar_button_box_shadow_active); +} + +.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:active .ui5-shellbar-menu-button-arrow, +.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:active .ui5-shellbar-menu-button-title { + color: var(--sapShell_Active_TextColor); +} + +:host([desktop]) .ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:focus, +.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive:focus-visible { + outline: var(--_ui5_shellbar_logo_outline); + outline-offset: var(--_ui5_shellbar_outline_offset); +} + +.ui5-shellbar-menu-button.ui5-shellbar-menu-button--interactive::-moz-focus-inner { + border: none; +} + +.ui5-shellbar-menu-button .ui5-shellbar-logo:hover { + box-shadow: none; +} + +.ui5-shellbar-menu-button-arrow { + display: inline-block; + font-family: var(--sapFontSemiboldDuplexFamily); + margin: 0; + font-size: var(--_ui5_shellbar_menu_button_title_font_size); + color: var(--sapShell_SubBrand_TextColor); +} + +.ui5-shellbar-menu-button--interactive .ui5-shellbar-menu-button-arrow { + margin-inline-start: 0.375rem; +} + +:host(:not([primary-title])) .ui5-shellbar-menu-button { + min-width: 2.25rem; + justify-content: center; +} + +:host(:not([with-logo])) .ui5-shellbar-menu-button { + margin-inline-start: 0; +} + +:host([breakpoint-size="S"]) .ui5-shellbar-menu-button { + margin-inline-start: 0; +} + diff --git a/packages/fiori/src/themes/ShellBarSearchLegacy.css b/packages/fiori/src/themes/ShellBarSearchLegacy.css new file mode 100644 index 000000000000..b94c45f0f629 --- /dev/null +++ b/packages/fiori/src/themes/ShellBarSearchLegacy.css @@ -0,0 +1,44 @@ +/* Legacy Search Styles - ONLY for ui5-input and custom div search fields */ + +/* CSS variable overrides for ui5-input component */ +:host { + --_ui5_input_placeholder_color: var(--sapShell_InteractiveTextColor); + --_ui5_input_border_radius: var(--_ui5_shellbar_input_border_radius); + --_ui5_input_focus_border_radius: var(--_ui5_shellbar_input_focus_border_radius); + --_ui5_input_background_color: var(--_ui5_shellbar_input_background_color); + --_ui5_input_focus_outline_color: var(--_ui5_shellbar_input_focus_outline_color); + --_ui5_input_margin_top_bottom: 0; +} + +/* ui5-input specific styles */ +::slotted([ui5-input]) { + background: var(--_ui5_shellbar_search_field_background); + border: var(--_ui5_shellbar_search_field_border); + box-shadow: var(--_ui5_shellbar_search_field_box_shadow); + color: var(--_ui5_shellbar_search_field_color); + height: 2.25rem; + width: 100%; + min-width: var(--_ui5_shellbar_search_field_width); +} + +/* ui5-input breakpoint adjustments */ +:host([breakpoint-size="M"]) ::slotted([ui5-input]), +:host([breakpoint-size="S"]) ::slotted([ui5-input]) { + min-width: 1rem; +} + +:host([breakpoint-size="M"][show-search-field]) .ui5-shellbar-overflow-container-right-child { + flex-grow: 1; +} + +/* ui5-input hover */ +::slotted([ui5-input]:hover) { + background: var(--_ui5_shellbar_search_field_background_hover); + box-shadow: var(--_ui5_shellbar_search_field_box_shadow_hover); +} + +/* ui5-input focus */ +::slotted([ui5-input][focused]) { + outline: var(--_ui5_shellbar_search_field_outline_focused); +} + diff --git a/packages/fiori/test/pages/ShellBarV2.html b/packages/fiori/test/pages/ShellBarV2.html new file mode 100644 index 000000000000..02f43b59c86b --- /dev/null +++ b/packages/fiori/test/pages/ShellBarV2.html @@ -0,0 +1,148 @@ + + + + + + Shell Bar V2 MVP Test + + + + + + + +

ShellBar MVP Test

+

Testing modular architecture with Actions feature.

+ +

Full Features (with All Actions + Overflow)

+ + + + + My Application + + + Action 1 + Action 2 + Action 3 + Action 4 + Action 5 + + + + + +

Resize window to see overflow in action. Items hide based on data-hide-order. Profile and Product Switch never hide.

+

Keyboard navigation: Use Arrow Left/Right to navigate between items. Home/End to jump to first/last item.

+

Full-screen search: When overflow happens and search is visible, full-screen search overlay appears. Click Cancel to close.

+ + + + + \ No newline at end of file diff --git a/packages/fiori/test/pages/ShellBarV2_SearchTypes.html b/packages/fiori/test/pages/ShellBarV2_SearchTypes.html new file mode 100644 index 000000000000..39796a7ae8fd --- /dev/null +++ b/packages/fiori/test/pages/ShellBarV2_SearchTypes.html @@ -0,0 +1,319 @@ + + + + + ShellBar - Search Types Demo + + + + + + + + +

ShellBar - Search Types Demonstration

+

This page demonstrates all 3 supported search field types and legacy properties.

+ + +
+

1. Self-Collapsible Search (ui5-shellbar-search) ✅

+
+ Type: ui5-shellbar-search
+ Features: Auto-expands/collapses, search suggestions, scopes
+ Properties: collapsed, open (managed by component)
+ Controller: ShellBarSearch +
+ + + Self-Collapsible Search + + + + + + + Reports + Analytics + + +

Click the search icon to toggle. Supports auto-collapse when space is tight (except when focused or has value).

+
+ + +
+

2. ui5-input (Legacy) ✅

+
+ Type: ui5-input
+ Features: Basic input field, shows search button
+ Properties: hideSearchButton, disableSearchCollapse supported
+ Controller: ShellBarSearchLegacy +
+ + + ui5-input Search + + +
Search for products, customers, or orders
+
+ Products + Customers + Orders + +
+

Separate search button appears. Auto-collapse works based on focus and value.

+
+ + +
+

3. Custom DIV with Input (Legacy) ✅

+
+ Type: Custom HTML (div with input)
+ Features: Full control over markup, shows search button
+ Properties: hideSearchButton, disableSearchCollapse supported
+ Controller: ShellBarSearchLegacy +
+ + + Custom Search + +
+ + +
+ Dashboard + Settings + +
+

Custom HTML allows full control. Search button provided by ShellBar. Custom close button included.

+
+ + +
+

4. ui5-input with disableSearchCollapse

+
+ Type: ui5-input
+ Properties: disableSearchCollapse=true
+ Behavior: Search field always stays expanded, never auto-collapses +
+ + + Always Expanded + + + Action 1 + Action 2 + Action 3 + Action 4 + Action 5 + + +

Resize window - search stays expanded even when items overflow.

+
+ + +
+

5. Custom DIV with hideSearchButton

+
+ Type: Custom div
+ Properties: hideSearchButton=true
+ Behavior: No search button shown, field always visible +
+ + + No Search Button + +
+ +
+ Home + About + +
+

Search button hidden. Field is always visible (useful when managing state externally).

+
+ + +
+

6. With Overflow + Auto-Collapse

+
+ Type: ui5-input
+ Behavior: Search auto-collapses when content overflows (unless focused/has value) +
+ + + Overflow Demo + + + Reports + Analytics + Dashboard + Settings + Help + + Export + Import + Print + + +

Resize window to see auto-collapse. Type something or focus the field to prevent collapse.

+
+ + +
+

Event Log

+
+
+ + + + + diff --git a/packages/fiori/test/pages/ShellBar_Comparison.html b/packages/fiori/test/pages/ShellBar_Comparison.html new file mode 100644 index 000000000000..7ddf6d759d62 --- /dev/null +++ b/packages/fiori/test/pages/ShellBar_Comparison.html @@ -0,0 +1,891 @@ + + + + + ShellBar vs ShellBar Comparison + + + + + + +

ShellBar v1 vs ShellBar Comparison

+ +
+ +
+ Theme: + + Horizon (Morning) + Horizon Dark (Evening) + Horizon High Contrast Black + Horizon High Contrast White + Fiori 3 (Quartz Light) + Fiori 3 Dark (Quartz Dark) + Fiori 3 High Contrast Black + Fiori 3 High Contrast White + +
+ + +
+ Notifications: + +
+ +
+ Product Switch: + +
+ +
+ Profile: + +
+ +
+ Assistant: + +
+ +
+ Start Button: + +
+ +
+ Notifications Count: + +
+ + +
+ Search +
+
+ Search Field: + +
+
+ Search Type: + + ui5-input + ui5-shellbar-search + +
+
+
+ + +
+ Content Items (Overflow Test) +
+
+ Content Items: + +
+
+ Show Spacer: + +
+
+
+ + +
+ Custom Items +
+
+ Custom Items: + +
+
+ More Items: + +
+
+
+ + +
+ Branding +
+
+ Branding Mode: + + Branding Slot (New) + Logo + Primary Title (Legacy) + +
+
+ Show Secondary Title: + +
+
+ Menu Items: + + (requires Legacy mode) +
+
+
+
+ +
+ How to use: Toggle switches to enable/disable features. Both ShellBars update simultaneously for easy comparison. +
Tip: Resize window to test overflow behavior with content items. Check console for event logs. +
Themes: Switch between all 8 available themes (Horizon and Fiori 3 families, including dark and high contrast variants). +
Branding Mode: Switch between new Branding Slot and legacy Logo + Primary Title modes. Menu items only available in legacy mode. +
+ + +
+ + + Product Title + + + + + Action 1 + Tag + Action 3 + End 1 + End 2 + + + + + + + + + +
+ + +
+ + + Product Title + + + + + Action 1 + Tag + Action 3 + End 1 + End 2 + + + + + + + + + +
+ +
+ + + + + diff --git a/packages/main/src/List.ts b/packages/main/src/List.ts index 887d557cd9a2..b504a8b58b6d 100644 --- a/packages/main/src/List.ts +++ b/packages/main/src/List.ts @@ -959,6 +959,8 @@ class List extends UI5Element { groupCount++; // subtract group itself for proper group header item count groupItemCount += groupItems.length - 1; + } else if (hasListItems(item)) { + item.assignedSlot && items.push(...item.listItems); } else { item.assignedSlot && items.push(item); } @@ -1559,6 +1561,15 @@ class List extends UI5Element { List.define(); +type ListItemWrapper = { + hasListItems: boolean; + listItems: Array; +} + +const hasListItems = (item: object): item is ListItemWrapper => { + return "hasListItems" in item && (item as ListItemWrapper).hasListItems; +}; + export default List; export type { ListItemClickEventDetail, From ddf0a43aa504d539bc505e3ba221049b1cbf15fa Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Tue, 3 Feb 2026 22:49:39 +0200 Subject: [PATCH 2/6] refactor(ui5-shellbar): remove redundant samples --- packages/fiori/test/pages/ShellBarV2.html | 148 ----- ...Comparison.html => ShellBar_Features.html} | 531 +++++------------- ...chTypes.html => ShellBar_SearchTypes.html} | 0 .../fiori/test/pages/ShellBar_evolution.html | 325 ----------- 4 files changed, 153 insertions(+), 851 deletions(-) delete mode 100644 packages/fiori/test/pages/ShellBarV2.html rename packages/fiori/test/pages/{ShellBar_Comparison.html => ShellBar_Features.html} (51%) rename packages/fiori/test/pages/{ShellBarV2_SearchTypes.html => ShellBar_SearchTypes.html} (100%) delete mode 100644 packages/fiori/test/pages/ShellBar_evolution.html diff --git a/packages/fiori/test/pages/ShellBarV2.html b/packages/fiori/test/pages/ShellBarV2.html deleted file mode 100644 index 02f43b59c86b..000000000000 --- a/packages/fiori/test/pages/ShellBarV2.html +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - Shell Bar V2 MVP Test - - - - - - - -

ShellBar MVP Test

-

Testing modular architecture with Actions feature.

- -

Full Features (with All Actions + Overflow)

- - - - - My Application - - - Action 1 - Action 2 - Action 3 - Action 4 - Action 5 - - - - - -

Resize window to see overflow in action. Items hide based on data-hide-order. Profile and Product Switch never hide.

-

Keyboard navigation: Use Arrow Left/Right to navigate between items. Home/End to jump to first/last item.

-

Full-screen search: When overflow happens and search is visible, full-screen search overlay appears. Click Cancel to close.

- - - - - \ No newline at end of file diff --git a/packages/fiori/test/pages/ShellBar_Comparison.html b/packages/fiori/test/pages/ShellBar_Features.html similarity index 51% rename from packages/fiori/test/pages/ShellBar_Comparison.html rename to packages/fiori/test/pages/ShellBar_Features.html index 7ddf6d759d62..61e5a4d952e8 100644 --- a/packages/fiori/test/pages/ShellBar_Comparison.html +++ b/packages/fiori/test/pages/ShellBar_Features.html @@ -2,7 +2,7 @@ - ShellBar vs ShellBar Comparison + ShellBar Feature Toggle - diff --git a/packages/fiori/test/pages/ShellBarV2_SearchTypes.html b/packages/fiori/test/pages/ShellBar_SearchTypes.html similarity index 100% rename from packages/fiori/test/pages/ShellBarV2_SearchTypes.html rename to packages/fiori/test/pages/ShellBar_SearchTypes.html diff --git a/packages/fiori/test/pages/ShellBar_evolution.html b/packages/fiori/test/pages/ShellBar_evolution.html deleted file mode 100644 index 74b31f09c158..000000000000 --- a/packages/fiori/test/pages/ShellBar_evolution.html +++ /dev/null @@ -1,325 +0,0 @@ - - - - - Shell Bar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - S/4HANA Cloud - - - - - - - EMEA - Deliveries overdue for billing neeed more text because of a bug - - - -
- New Version - -
- - -
Instructions
-
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - SAP Labs Bulgaria - - - - - PR10 - - PR 4 - PR3 - - - - - - PR2 - - PR1 - - PR6 - - PR7 - - PR8 - - PR9 - - - - - - - - - - - - - - - - - - - - - - - - - - SAP Labs Bulgaria - - - - - PR10 - - PR 4 - PR3 - - - - - - PR2 - - PR1 - - PR6 - - PR7 - - PR8 - - PR9 - -
- - -
- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - From b8f513c6553689c1071d31e1926cb4a211cba3d5 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Tue, 3 Feb 2026 23:11:26 +0200 Subject: [PATCH 3/6] fix: action items height --- packages/fiori/src/themes/ShellBar.css | 1 + packages/fiori/src/themes/ShellBarItem.css | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/fiori/src/themes/ShellBar.css b/packages/fiori/src/themes/ShellBar.css index 3d81d25a295c..5c2105db271d 100644 --- a/packages/fiori/src/themes/ShellBar.css +++ b/packages/fiori/src/themes/ShellBar.css @@ -230,6 +230,7 @@ of the search field container in the bar even when the search is in full mode. * letter-spacing: inherit; word-spacing: inherit; width: 2.25rem; + height: 2.25rem; box-sizing: border-box; } diff --git a/packages/fiori/src/themes/ShellBarItem.css b/packages/fiori/src/themes/ShellBarItem.css index eb14a6694e4d..7d76bf88c0d9 100644 --- a/packages/fiori/src/themes/ShellBarItem.css +++ b/packages/fiori/src/themes/ShellBarItem.css @@ -4,6 +4,7 @@ .ui5-shellbar-action-button { width: 2.25rem; + height: 2.25rem; color: var(--sapShell_TextColor); } From a86407458374c61ae01640d693d5ff3a3644baa7 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Wed, 4 Feb 2026 22:10:47 +0200 Subject: [PATCH 4/6] fix: input overrides --- packages/fiori/src/themes/ShellBarSearchLegacy.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiori/src/themes/ShellBarSearchLegacy.css b/packages/fiori/src/themes/ShellBarSearchLegacy.css index b94c45f0f629..8504c97adc90 100644 --- a/packages/fiori/src/themes/ShellBarSearchLegacy.css +++ b/packages/fiori/src/themes/ShellBarSearchLegacy.css @@ -1,7 +1,7 @@ /* Legacy Search Styles - ONLY for ui5-input and custom div search fields */ /* CSS variable overrides for ui5-input component */ -:host { +::slotted([ui5-input]) { --_ui5_input_placeholder_color: var(--sapShell_InteractiveTextColor); --_ui5_input_border_radius: var(--_ui5_shellbar_input_border_radius); --_ui5_input_focus_border_radius: var(--_ui5_shellbar_input_focus_border_radius); From c7947e3ccf0cf96471ecd2a31a71400708eb6ea4 Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Thu, 5 Feb 2026 09:48:43 +0200 Subject: [PATCH 5/6] fix: failing testS --- packages/fiori/cypress/specs/ShellBar.cy.tsx | 30 ++++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/fiori/cypress/specs/ShellBar.cy.tsx b/packages/fiori/cypress/specs/ShellBar.cy.tsx index 0ba292c7fd74..a6479b1173ba 100644 --- a/packages/fiori/cypress/specs/ShellBar.cy.tsx +++ b/packages/fiori/cypress/specs/ShellBar.cy.tsx @@ -563,26 +563,37 @@ describe("Slots", () => { it("Test search toggle in overflow expands search when clicked", () => { cy.mount( - - + + + + + + + + + + ); - // Use narrow viewport to force items into overflow - cy.viewport(320, 800); + // Use narrow viewport to force search into overflow + // 280px is needed because search button only overflows after all other items + cy.viewport(280, 800); cy.wait(RESIZE_THROTTLE_RATE); cy.get("#shellbar").as("shellbar"); @@ -590,6 +601,13 @@ describe("Slots", () => { // Verify overflow button exists (search should be in overflow when closed) cy.get("@shellbar").should("have.prop", "breakpointSize", "S"); + // Verify search is collapsed and in overflow + cy.get("@shellbar").should("have.prop", "showSearchField", false); + cy.get("@shellbar").then(($shellbar) => { + const shellbar = $shellbar[0] as any; + expect(shellbar.hiddenItemsIds, "search should be hidden").to.include("search"); + }); + // Open overflow popover cy.get("@shellbar").invoke("prop", "overflowPopoverOpen", true); From e4f16b53cde535081ae4cb38ce1d4e871bf4060f Mon Sep 17 00:00:00 2001 From: Dobrin Dimchev Date: Thu, 5 Feb 2026 15:12:00 +0200 Subject: [PATCH 6/6] fix: search width in full screen --- packages/fiori/src/themes/ShellBar.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/fiori/src/themes/ShellBar.css b/packages/fiori/src/themes/ShellBar.css index 5c2105db271d..31b1e5e8324e 100644 --- a/packages/fiori/src/themes/ShellBar.css +++ b/packages/fiori/src/themes/ShellBar.css @@ -161,7 +161,9 @@ /* goes to the most right when no content is present */ } -:host([show-search-field]) ::slotted([slot="searchField"]), +/* this width is only applied when not in full screen mode as +in full screen the search fills the available space */ +:host([show-search-field]:not([show-full-width-search])) ::slotted([slot="searchField"]), /* Search field displays in full mode if there's not enough space in bar. Once in full screen mode the search field is rendered in another DOM. To account for correct measurements in overflow, we should keep the min width