diff --git a/packages/fiori/cypress/specs/ShellBar.cy.tsx b/packages/fiori/cypress/specs/ShellBar.cy.tsx index a8289a9fd7e2..a6479b1173ba 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 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"); + + // 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); + + // 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 +700,7 @@ describe("Events", () => { ); - cy.get("[ui5-shellbar") + cy.get("[ui5-shellbar]") .as("shellbar"); cy.get("@shellbar") @@ -662,12 +789,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 +829,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 +917,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 +1139,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 +1170,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 +1204,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 +1230,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 +1247,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 +1277,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 +1310,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 +1327,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 +1489,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 +1554,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( - - - - - ); - - cy.get("[ui5-shellbar]").then(($shellbar) => { - $shellbar[0].accessibilityAttributes = { - profile: { - name: PROFILE_BTN_CUSTOM_TOOLTIP, - }, - logo: { - name: LOGO_CUSTOM_TOOLTIP - }, - }; - }); + it("tests accessibilityTexts property", () => { + const PROFILE_BTN_CUSTOM_TOOLTIP = "John Dow"; + const LOGO_CUSTOM_TOOLTIP = "Custom logo title"; - 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 +1614,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 +1732,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 5b9e472cbcc3..f0310102f786 100644 --- a/packages/fiori/src/i18n/messagebundle.properties +++ b/packages/fiori/src/i18n/messagebundle.properties @@ -206,6 +206,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..31b1e5e8324e 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,181 @@ 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); } -.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); -} +/* ============================================================================ + OVERFLOW CONTAINER + ============================================================================ */ -.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; -} - -.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); + margin-left: auto; + /* goes to the most right when no content is present */ +} + +/* 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 +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 +203,82 @@ 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; + height: 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 +291,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..7d76bf88c0d9 --- /dev/null +++ b/packages/fiori/src/themes/ShellBarItem.css @@ -0,0 +1,43 @@ +/* ============================================================================ + ACTION BUTTON STYLING + ============================================================================ */ + +.ui5-shellbar-action-button { + width: 2.25rem; + height: 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..8504c97adc90 --- /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 */ +::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; +} + +/* 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/ShellBar_Features.html b/packages/fiori/test/pages/ShellBar_Features.html new file mode 100644 index 000000000000..61e5a4d952e8 --- /dev/null +++ b/packages/fiori/test/pages/ShellBar_Features.html @@ -0,0 +1,666 @@ + + + + + ShellBar Feature Toggle + + + + + + +

ShellBar Feature Toggle

+ +
+ +
+ 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. +
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 + + + + + + + + + +
+ +
+ + + + diff --git a/packages/fiori/test/pages/ShellBar_SearchTypes.html b/packages/fiori/test/pages/ShellBar_SearchTypes.html new file mode 100644 index 000000000000..39796a7ae8fd --- /dev/null +++ b/packages/fiori/test/pages/ShellBar_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_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 - -
- - -
- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - 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,