diff --git a/.gitignore b/.gitignore index b608cf95c56a..3c6ff22bbcbd 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,5 @@ packages/playground/assets/*.gif packages/playground/assets/*.css packages/playground/assets/test/pages/*.css packages/playground/assets/test/pages/*.js + +AGENTS.md \ No newline at end of file diff --git a/packages/fiori/cypress/specs/ShellBarV2.cy.tsx b/packages/fiori/cypress/specs/ShellBarV2.cy.tsx new file mode 100644 index 000000000000..ca1c52462202 --- /dev/null +++ b/packages/fiori/cypress/specs/ShellBarV2.cy.tsx @@ -0,0 +1,1668 @@ +import ShellBar from "../../src/ShellBarV2.js"; +import ShellBarItem from "../../src/ShellBarV2Item.js"; +import ShellBarSpacer from "../../src/ShellBarSpacer.js"; +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 "../../src/ShellBarBranding.js"; +import ShellBarSearch from "../../src/ShellBarSearch.js"; + +const RESIZE_THROTTLE_RATE = 300; // ms + +describe("Responsiveness", () => { + function basicTemplate() { + return + + + + + + + + + + + + + + +
Instructions
+ + + + {/* PR2 */} + PR2 +
; + } + + function templateWithMenuItems() { + return + + Application 1 + Application 2 + Application 3 + Application 4 + Application 5 + + + + + + + + + + + + +
Instructions
+ +
; + } + + function templateWithOnlyOneAction() { + return + + ; + } + beforeEach(() => { + cy.mount(basicTemplate()).as("html"); + + // breakpoints are set on resize event + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(RESIZE_THROTTLE_RATE); + + cy.get("#shellbar") + .as("shellbar"); + }); + afterEach(() => { + cy.viewport(1920, 1080); + }); + + it("tests XL Breakpoint 1920px", () => { + cy.viewport(1920, 1080); + + cy.get("@shellbar").should("have.prop", "breakpointSize", "XL"); + + 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-title").as("primaryTitle"); + cy.get("@shellbar").shadow().find(".ui5-shellbar-secondary-title").as("secondaryTitle"); + 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("@primaryTitle").should("be.visible"); + cy.get("@secondaryTitle").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 S Breakpoint and overflow 500px", () => { + cy.viewport(500, 1680); + + cy.get("@shellbar").should("have.prop", "breakpointSize", "S"); + + 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", () => { + cy.viewport(1820, 1680); + + cy.get("@shellbar").should("have.prop", "breakpointSize", "L"); + }); + + it("tests L Breakpoint 1400px", () => { + cy.viewport(1400, 1680); + + cy.get("@shellbar").should("have.prop", "breakpointSize", "L"); + + 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-title").as("primaryTitle"); + cy.get("@shellbar").shadow().find(".ui5-shellbar-secondary-title").as("secondaryTitle"); + cy.get("@shellbar").shadow().find(".ui5-shellbar-search-button").as("searchIcon"); + 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("@primaryTitle").should("be.visible"); + cy.get("@secondaryTitle").should("be.visible"); + cy.get("@searchIcon").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 M Breakpoint 870px", () => { + cy.viewport(870, 1680); + + cy.get("@shellbar").should("have.prop", "breakpointSize", "M"); + + 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-title").as("primaryTitle"); + 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("@assistant").should("be.visible"); + cy.get("@backButton").should("be.visible"); + cy.get("@primaryTitle").should("be.visible"); + cy.get("@searchIcon").should("be.visible"); + cy.get("@notificationsIcon").should("be.visible"); + cy.get("@profileIcon").should("be.visible"); + cy.get("@productSwitchIcon").should("be.visible"); + }); + + it("tests S Breakpoint 320px", () => { + cy.get("html").viewport("iphone-x"); + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-overflow-button") + .should("exist"); + cy.get("@shellbar") + .shadow() + .get("ui5-switch") + .should("be.hidden"); + }); + + it("tests items visibility in Lean mode", () => { + cy.get("@shellbar") + .find("ui5-button[slot='startButton']") + .as("backButton"); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-search-button") + .as("searchButton"); + + 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"); + }); + + it("tests logo and Primary title when no menuItems are presented", () => { + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-logo-area") + .as("logoLink"); + + cy.get("@logoLink").should("exist"); + }); + + 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") + .as("menuButton"); + + cy.get("@menuButton").should("be.visible"); + }); + + it("tests XXL Breakpoint Search bar", () => { + cy.get("@shellbar").invoke("attr", "show-open-search-field", "true"); + cy.viewport(2560, 1080); + cy.get("[slot='searchField']") + .should("exist"); + }); + + it("Test overflow button not showing, when only one action is presented", () => { + 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("not.exist"); + }); + + it("Test accessibility attributes on custom action buttons", () => { + cy.mount(basicTemplate()).as("html"); + + // V2: ShellBarV2Item properly supports accessibilityAttributes property + // which are passed through to the ui5-button in its shadow root + cy.get("@shellbar") + .find(`[stable-dom-ref="call"]`) + .as("call-item") + .then($el => { + ($el.get(0) as any).accessibilityAttributes = { "hasPopup": "dialog", "expanded": "true" }; + }); + cy.get("@call-item") + .shadow() + .find("ui5-button") + .shadow() + .find("button") + .should("have.attr", "aria-expanded", "true") + .should("have.attr", "aria-haspopup", "dialog"); + }); +}); + +describe("Slots", () => { + describe("Profile slot", () => { + it("forwards click from shellbar profile button to slotted avatar (mount pattern)", () => { + const clickSpy = cy.spy().as("avatarClickSpy"); + const profileClickSpy = cy.spy().as("profileClickSpy"); + + cy.mount( +
+ + + +
+ ); + + cy.get("#test-avatar").then($el => { + $el[0].addEventListener("ui5-click", clickSpy); + }); + cy.get("#test-shellbar").then($el => { + $el[0].addEventListener("ui5-profile-click", profileClickSpy); + }); + + cy.get("#test-shellbar").shadow().find(".ui5-shellbar-image-button").realClick(); + cy.get("@profileClickSpy").should("have.been.calledOnce"); + cy.get("@avatarClickSpy").should("have.been.calledOnce"); + }); + }); + + describe("Content slot", () => { + it("Test separators visibility", () => { + function assertStartSeparatorVisibility(expectedExist: boolean) { + cy.get("#shellbar") + .shadow() + .find(".ui5-shellbar-content-items > .ui5-shellbar-separator-start") + .should(expectedExist ? "exist" : "not.exist"); + } + function assertEndSeparatorVisibility(expectedExist: boolean) { + cy.get("#shellbar") + .shadow() + .find(".ui5-shellbar-content-items > .ui5-shellbar-separator-end") + .should(expectedExist ? "exist" : "not.exist"); + } + + cy.mount( + + + + + + + + + + + + ); + + // both separators should be visible + assertStartSeparatorVisibility(true); + assertEndSeparatorVisibility(true); + + cy.viewport(420, 1080); + // only end separator should be hidden + assertStartSeparatorVisibility(true); + assertEndSeparatorVisibility(false); + + cy.viewport(320, 1080); + // both separators should be hidden + assertStartSeparatorVisibility(false); + assertEndSeparatorVisibility(false); + + // once items are hidden, both separators should be rendered with the last visible item + cy.get("#shellbar") + .shadow() + .find("div[id='content-2'] > .ui5-shellbar-separator-start") + .should("exist"); + + cy.get("#shellbar") + .shadow() + .find("div[id='content-6'] > .ui5-shellbar-separator-end") + .should("exist"); + + cy.viewport(1920, 1080); + // both separators should be visible + assertStartSeparatorVisibility(true); + assertEndSeparatorVisibility(true); + + // once items are shown, both separators shouldn't be rendered with the last visible item + cy.get("#shellbar") + .shadow() + .find("div[id='content-2'] > .ui5-shellbar-separator-start") + .should("not.exist"); + + cy.get("#shellbar") + .shadow() + .find("div[id='content-6'] > .ui5-shellbar-separator-end") + .should("not.exist"); + }); + }); + + describe("Search field slot", () => { + it("Test search button is not visible when the search field slot is empty", () => { + cy.mount( + + ); + cy.get("#shellbar") + .shadow() + .find(".ui5-shellbar-search-button") + .should("not.exist"); + }); + + it("Test search button is visible when the search field slot is not empty", () => { + cy.mount( + + + + ); + cy.get("#shellbar") + .shadow() + .find(".ui5-shellbar-search-button") + .should("exist"); + }); + + it("Test search button is not visible when the hide-search-button property is set to true", () => { + cy.mount( + + + + ); + cy.get("#shellbar") + .shadow() + .find(".ui5-shellbar-search-button") + .should("not.exist"); + }); + + it("Test search field is collapsed by default and expanded on click", () => { + cy.mount( + + + + ); + cy.get("#shellbar").shadow().as("shellbar"); + cy.get("@shellbar").find(".ui5-shellbar-search-field").should("not.exist"); + cy.get("@shellbar").find(".ui5-shellbar-search-button").click(); + cy.get("@shellbar").find(".ui5-shellbar-search-field").should("exist"); + }); + + it("Test search field is expanded by default when show-search-field is set to true", () => { + cy.mount( + + + + ); + cy.get("#shellbar") + .shadow() + .find(".ui5-shellbar-search-field") + .should("exist"); + }); + + it("Test search button is not visible when a self-collapsible search field slot is empty", () => { + cy.mount( + + + + ); + cy.get("#shellbar") + .shadow() + .find(".ui5-shellbar-search-button") + .should("not.exist"); + }); + + it("Test self-collapsible search is expanded and collapsed by the show-search-field property", () => { + cy.mount( + + + + ); + cy.get("#search").should("have.prop", "collapsed", false); + cy.get("#shellbar").invoke("prop", "showSearchField", false); + cy.get("#search").should("have.prop", "collapsed", true); + }); + + it("Test showSearchField property is false when using collapsed search field", () => { + cy.mount( + + + + ); + cy.get("#search").should("have.prop", "collapsed", true); + cy.get("#shellbar").invoke("prop", "showSearchField").should("equal", false); + }); + + it("Test search field is collapsed initially instead of being displayed in full width mode", () => { + cy.viewport(500, 1080); + cy.mount( + // needs some content to trigger the full width mode + + + + + + + + ); + cy.get("#shellbar").invoke("prop", "showSearchField").should("equal", false); + }); + + it("Test search field added after delay still works with events", () => { + cy.mount( + + ); + + cy.get("#shellbar").as("shellbar"); + + // Add search field after a timeout (simulating real-world scenario) + cy.get("@shellbar").then(shellbar => { + setTimeout(() => { + const searchField = document.createElement("ui5-shellbar-search"); + searchField.setAttribute("slot", "searchField"); + searchField.setAttribute("id", "delayed-search"); + shellbar.get(0).appendChild(searchField); + }, 100); + }); + + // Wait for the search field to be added + cy.get("#delayed-search", { timeout: 1000 }).should("exist"); + + // Search should now be visible and collapsed + cy.get("#shellbar [slot='searchField']") + .should("exist") + .should("have.prop", "collapsed", true); + + // click the searchField to expand it + cy.get("#shellbar [slot='searchField']") + .click() + .should("have.prop", "collapsed", false); + // check shellbar's showSearchField property is also updated + cy.get("@shellbar").invoke("prop", "showSearchField").should("equal", true); + }); + + it.only("Test search toggle in overflow expands search when clicked", () => { + cy.mount( + + + + + + + + + + + + ); + + // Use narrow viewport to force items into overflow + cy.viewport(320, 800); + cy.wait(RESIZE_THROTTLE_RATE); + + cy.get("#shellbar").as("shellbar"); + + // Verify overflow button exists (search should be in overflow when closed) + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-overflow-button") + .should("exist") + .click(); + + // Click search toggle in overflow popover + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-overflow-popover ui5-shellbar-v2-item[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"); + }); + }); +}); + +describe("Events", () => { + it("Test click on the search button fires search-button-click event", () => { + cy.mount( + + + + ); + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-search-button-click", cy.stub().as("searchButtonClick")); + }); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-search-button") + .as("searchButton"); + + cy.get("@searchButton") + .click(); + + cy.get("@searchButtonClick") + .should("have.been.calledOnce"); + }); + + it("Test logo click fires logo-click event only once", () => { + cy.mount( + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-logo-click", cy.stub().as("logoClick")); + }); + + // Test clicking on the logo area in large screens (combined logo layout) + cy.viewport(1920, 1080); + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-logo-area") + .as("logoArea") + .should("exist"); + + cy.get("@logoArea") + .realClick(); + + cy.get("@logoClick") + .should("have.been.calledOnce"); + + // Reset the stub for the next test + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-logo-click", cy.stub().as("logoClickSmall")); + }); + + // Test clicking on the logo in small screens (single logo layout) + cy.viewport(500, 1080); + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-logo") + .as("logo") + .should("exist"); + + cy.get("@logo") + .realClick(); + + cy.get("@logoClickSmall") + .should("have.been.calledOnce"); + }); + + it("Test search field clear event default behavior", () => { + cy.mount( + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + // Set up event listener without preventing default + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-search-field-clear", cy.stub().as("searchFieldClear")); + }); + + // Trigger full width search mode by reducing viewport + cy.viewport(400, 800); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-cancel-button") + .click(); + + // Verify the event was fired + cy.get("@searchFieldClear") + .should("have.been.calledOnce"); + + // Verify search field value is cleared (default behavior) + cy.get("#search") + .should("have.value", ""); + + // Verify search is closed + cy.get("@shellbar") + .should("have.prop", "showSearchField", false); + }); + + it("Test search field clear event can be prevented", () => { + cy.mount( + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + // Set up event listener that prevents default + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-search-field-clear", (event) => { + event.preventDefault(); + }); + shellbar.get(0).addEventListener("ui5-search-field-clear", cy.stub().as("searchFieldClear")); + }); + + // Trigger full width search mode by reducing viewport + cy.viewport(400, 800); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-cancel-button") + .click(); + + // Verify the event was fired + cy.get("@searchFieldClear") + .should("have.been.calledOnce"); + + // Verify search field value is preserved (due to preventDefault) + cy.get("#search") + .should("have.value", "test search text"); + + // Verify search is closed + cy.get("@shellbar") + .should("have.prop", "showSearchField", false); + }); + + describe("Big screen", () => { + beforeEach(() => { + cy.viewport(1920, 1680); + }); + + it("tests opening of menu", () => { + cy.mount( + + Menu Item 1 + Menu Item 2 + + + ); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-button") + .realClick(); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-popover") + .should("have.prop", "open", true); + }); + + it("tests notificationsClick event", () => { + cy.mount( + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-notifications-click", cy.stub().as("notificationsClick")); + }); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-bell-button") + .click(); + + cy.get("@notificationsClick") + .should("have.been.calledOnce"); + }); + + it("tests profileClick event", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-profile-click", cy.stub().as("profileClick")); + }); + + cy.get("@shellbar") + .shadow() + .find("[data-profile-btn]") + .click({ force: true }); + + cy.get("@profileClick") + .should("have.been.calledOnce"); + }); + + it("tests productSwitchClick event", () => { + cy.mount( + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-product-switch-click", cy.stub().as("productSwitchClick")); + }); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-button-product-switch") + .click(); + + cy.get("@productSwitchClick") + .should("have.been.calledOnce"); + }); + + it("tests logoClick event", () => { + cy.mount( + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-logo-click", cy.stub().as("logoClick")); + }); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-logo") + .realClick(); + + cy.get("@logoClick") + .should("have.been.calledOnce"); + }); + + it("tests search-button-click event", () => { + cy.viewport(870, 1680); + + cy.mount( + + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-search-button-click", (e) => { + e.preventDefault(); + cy.stub().as("searchButtonClick")(); + }); + }); + + cy.get("@shellbar").should("have.prop", "showSearchField", false); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-search-button") + .click(); + + cy.get("@shellbar").should("have.prop", "showSearchField", false); + + cy.get("@searchButtonClick") + .should("have.been.calledOnce"); + }); + + it("tests menuItemClick event", () => { + cy.mount( + + Application 1 + Application 2 + + + ); + + cy.get("[ui5-li][slot='menuItems']").each(($item) => { + const item = $item[0]; + item.addEventListener("click", cy.stub().as(`menuItemClick${item.getAttribute("data-key")}`)); + }); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-button") + .click(); + + cy.get("[ui5-li][slot='menuItems']").first().click(); + + cy.get("@menuItemClickkey1") + .should("have.been.calledOnce"); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-button") + .click(); + + cy.get("[ui5-li][slot='menuItems']").eq(1).click(); + + cy.get("@menuItemClickkey2") + .should("have.been.calledOnce"); + }); + + it("tests if searchfield toggles when altering the showSearchField property", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-search-field") + .should("exist"); + + cy.get("[ui5-shellbar-v2]").invoke("prop", "showSearchField", false); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-search-field") + .should("not.exist"); + + cy.get("[ui5-shellbar-v2]").invoke("prop", "showSearchField", true); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-search-field") + .should("exist"); + }); + }); + + describe("Small screen", () => { + beforeEach(() => { + cy.viewport(510, 1680); + }); + + it("tests logoClick event", () => { + cy.mount( + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-logo-click", cy.stub().as("logoClick")); + }); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-logo") + .realClick(); + + cy.get("@logoClick") + .should("have.been.calledOnce"); + }); + + it("tests opening of menu", () => { + cy.mount( + + Menu Item 1 + Menu Item 2 + + + ); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-button") + .realClick(); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-popover") + .should("have.prop", "open", true); + }); + + it("tests profileClick event", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-profile-click", cy.stub().as("profileClick")); + }); + + cy.get("@shellbar") + .shadow() + .find("[data-profile-btn]") + .click({ force: true }); + + cy.get("@profileClick") + .should("have.been.calledOnce"); + }); + + it("tests productSwitchClick event", () => { + cy.mount( + + + + ); + + cy.get("[ui5-shellbar-v2]") + .as("shellbar"); + + cy.get("@shellbar") + .then(shellbar => { + shellbar.get(0).addEventListener("ui5-product-switch-click", cy.stub().as("productSwitchClick")); + }); + + cy.get("@shellbar") + .shadow() + .find(".ui5-shellbar-button-product-switch") + .click(); + + cy.get("@productSwitchClick") + .should("have.been.calledOnce"); + }); + + it("tests preventDefault of click on a button with default behavior prevented", () => { + cy.viewport(320, 800); + cy.mount( + + + + + + + + + + + + + + + + ); + + 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']") + .should("exist") + .should("have.attr", "design", "AttentionDot"); + + cy.mount( + + + + + + + + + + + ); + + 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']") + .should("exist") + .should("have.attr", "text", "42"); + }); +}); + +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(); + cy.wait(RESIZE_THROTTLE_RATE); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-logo-area") + .should("not.exist"); + }); + + it("Test arrow navigation within search input respects cursor position", () => { + cy.mount( + + + + + + ); + cy.wait(RESIZE_THROTTLE_RATE); + + function placeAtStartOfInput() { + cy.get("[ui5-shellbar-v2] [slot='searchField']") + .shadow() + .find("input") + .then($input => { + $input[0].setSelectionRange(0, 0); + }); + } + function placeAtEndOfInput() { + cy.get("[ui5-shellbar-v2] [slot='searchField']") + .shadow() + .find("input") + .then($input => { + const inputLength = $input.val().toString().length; + $input[0].setSelectionRange(inputLength, inputLength); + }); + } + function placeInMiddleOfInput() { + cy.get("[ui5-shellbar-v2] [slot='searchField']") + .shadow() + .find("input") + .then($input => { + const inputLength = $input.val().toString().length; + const middlePosition = Math.floor(inputLength / 2); + $input[0].setSelectionRange(middlePosition, middlePosition); + }); + } + + // Focus the search input + cy.get("[ui5-shellbar-v2] [slot='searchField']") + .realClick() + .shadow() + .find("input") + .as("nativeInput"); + + placeAtStartOfInput(); + // Press left arrow - should move focus away from input since cursor is at start + cy.get("@nativeInput").type("{leftArrow}"); + // Verify focus is now on the button + cy.get("[ui5-shellbar-v2] [ui5-button]").should("be.focused"); + + + placeAtEndOfInput(); + // 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-v2-item]") + .should("have.focus"); + + placeInMiddleOfInput(); + // Press left arrow - should stay focused on input since cursor is in the middle + cy.get("@nativeInput").type("{leftArrow}"); + cy.get("@nativeInput").should("be.focused"); + // Press right arrow - should stay focused on input since cursor is in the middle + cy.get("@nativeInput").type("{rightArrow}"); + cy.get("@nativeInput").should("be.focused"); + }); + + it("Should focus the last ShellBar item on End key press", () => { + cy.mount( + + + + + + ); + cy.get("#button").shadow().find("button").focus().type('{end}'); + cy.get("#sbSearch").should("be.focused"); + }); + + it("Should focus the first ShellBar item on Home key press", () => { + cy.mount( + + + + + + ); + cy.get("#button2").shadow().find("button").focus().type('{home}'); + cy.get("#button1").shadow().find("button").should("be.focused"); + }); +}); + +describe("Branding slot", () => { + it("Test branding slot priority over logo", () => { + cy.mount( + + + + + Branding Comp + + + + ) + + cy.get("#shellbar") + .find("#mainLogo") + .should('exist') + .should('not.be.visible'); + + cy.get("#shellbar") + .find("#brandingLogo") + .should('exist') + .should('be.visible'); + + }); +}); + +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-v2]").then(($shellbar) => { + $shellbar[0].accessibilityAttributes = { + profile: { + name: PROFILE_BTN_CUSTOM_TOOLTIP, + }, + logo: { + name: LOGO_CUSTOM_TOOLTIP + }, + }; + }); + + cy.get("[ui5-shellbar-v2]").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"; + + cy.mount( + + + Product Title + + + + ); + + cy.get("[ui5-shellbar-v2]").then(($shellbar) => { + $shellbar[0].accessibilityAttributes = { + notifications: { + hasPopup: NOTIFICATIONS_BTN_ARIA_HASPOPUP + }, + }; + }); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-bell-button") + .shadow() + .find("button") + .should("have.attr", "aria-haspopup", NOTIFICATIONS_BTN_ARIA_HASPOPUP); + }); + }); + + describe("ui5-shellbar menu", () => { + it("tests prevents close on content click", () => { + cy.viewport(1920, 1680); + + cy.mount( + + Menu Item 1 + Menu Item 2 + + + ); + + cy.get("[ui5-li][slot='menuItems']").first().then($item => { + $item[0].addEventListener("click", cy.stub().as("menuItemClick")); + }); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-button") + .click(); + + cy.get("[ui5-li][slot='menuItems']").first().click(); + cy.get("@menuItemClick") + .should("have.been.calledOnce"); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-popover") + .should("have.prop", "open", true); + }); + + it("tests close on content click", () => { + cy.mount( + + Application 1 + Application 2 + + ); + + cy.get("[ui5-shellbar-v2]").should("exist"); + + cy.get("[slot='menuItems']").should("have.length", 2); + + cy.get("[ui5-shellbar-v2]").should(($shellbar) => { + const shellbar = $shellbar[0] as any; + expect(shellbar.menuItems).to.exist; + expect(shellbar.menuItems.length).to.be.greaterThan(0); + }); + + cy.get("[ui5-li][slot='menuItems']").first().then($item => { + $item[0].addEventListener("click", cy.stub().as("menuItemClick")); + }); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-button") + .should("exist") + .should("be.visible") + .realClick(); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-popover") + .should("have.prop", "open", true); + + cy.get("[ui5-li][slot='menuItems']") + .first() + .should("be.visible") + .realClick(); + + cy.get("@menuItemClick") + .should("have.been.calledOnce"); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(".ui5-shellbar-menu-popover") + .should("have.prop", "open", false); + }); + }); + + describe("ui5-shellbar-item", () => { + it("tests the stable-dom-ref attribute", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-shellbar-v2]") + .shadow() + .find(`[data-ui5-stable="schedule"]`) + .should("exist"); + }); + + it("tests 'click' on custom action", () => { + cy.mount( + + + + + ); + + cy.get("[ui5-shellbar-v2-item]").each(($item) => { + const item = $item[0]; + const icon = item.getAttribute("icon"); + const stubAlias = icon === "accept" ? "acceptClick" : "alertClick"; + item.addEventListener("click", cy.stub().as(stubAlias)); + }); + + cy.get("[ui5-shellbar-v2-item][icon='accept']") + .click(); + + cy.get("@acceptClick") + .should("have.been.calledOnce"); + + cy.get("[ui5-shellbar-v2-item][icon='alert']") + .click(); + + cy.get("@alertClick") + .should("have.been.calledOnce"); + }); + }); +}); \ No newline at end of file diff --git a/packages/fiori/src/ShellBarV2.ts b/packages/fiori/src/ShellBarV2.ts new file mode 100644 index 000000000000..1145ff16da87 --- /dev/null +++ b/packages/fiori/src/ShellBarV2.ts @@ -0,0 +1,1227 @@ +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.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 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 type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.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"; +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 Icon from "@ui5/webcomponents/dist/Icon.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"; + +import ShellBarV2Template from "./ShellBarV2Template.js"; +import shellBarV2Styles from "./generated/themes/ShellBarV2.css.js"; +import ShellBarPopoverCss from "./generated/themes/ShellBarPopover.css.js"; +import shellBarV2LegacyStyles from "./generated/themes/ShellBarV2Legacy.css.js"; + +import type { IShellBarSearchController } from "./shellbarv2/IShellBarSearchController.js"; + +import ShellBarV2Legacy from "./shellbarv2/ShellBarLegacy.js"; +import ShellBarV2Search from "./shellbarv2/ShellBarSearch.js"; +import ShellBarV2SearchLegacy from "./shellbarv2/ShellBarSearchLegacy.js"; +import ShellBarV2Overflow from "./shellbarv2/ShellBarOverflow.js"; +import ShellBarV2Accessibility from "./shellbarv2/ShellBarAccessibility.js"; +import ShellBarV2ItemNavigation from "./shellbarv2/ShellBarItemNavigation.js"; + +import ShellBarV2Item from "./ShellBarV2Item.js"; +import ShellBarSpacer from "./ShellBarSpacer.js"; +import type ShellBarBranding from "./ShellBarBranding.js"; +import type { ShellBarV2OverflowResult } from "./shellbarv2/ShellBarOverflow.js"; + +import type { + ShellBarV2AccessibilityInfo, + ShellBarV2AccessibilityAttributes, + ShellBarV2AreaAccessibilityAttributes, + ShellBarV2LogoAccessibilityAttributes, + ShellBarV2ProfileAccessibilityAttributes, +} from "./shellbarv2/ShellBarAccessibility.js"; + +import { + SHELLBAR_LABEL, + SHELLBAR_NOTIFICATIONS, + SHELLBAR_NOTIFICATIONS_NO_COUNT, + SHELLBAR_PROFILE, + SHELLBAR_PRODUCTS, + SHELLBAR_SEARCH, + SHELLBAR_ASSISTANT, + SHELLBAR_OVERFLOW, + SHELLBAR_ADDITIONAL_CONTEXT, +} from "./generated/i18n/i18n-defaults.js"; +import type ListItemBase from "@ui5/webcomponents/dist/ListItemBase.js"; + +type ShellBarV2Breakpoint = "S" | "M" | "L" | "XL" | "XXL"; + +// actions always visible in lean mode, order is important +const PREDEFINED_PLACE_ITEMS = ["feedback", "sys-help"]; + +const ShellBarV2Actions = { + Search: "search", + Profile: "profile", + Overflow: "overflow", + Assistant: "assistant", + ProductSwitch: "products", + Notifications: "notifications", +}; + +const ShellBarV2ActionsSelectors = { + 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 ShellBarV2ActionId = typeof ShellBarV2Actions[keyof typeof ShellBarV2Actions]; + +type ShellBarV2ActionItem = { + id: ShellBarV2ActionId; + 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 ShellBarV2NotificationsClickEventDetail = { + targetRef: HTMLElement; +}; + +type ShellBarV2ProfileClickEventDetail = { + targetRef: HTMLElement; +}; + +type ShellBarV2ProductSwitchClickEventDetail = { + targetRef: HTMLElement; +}; + +type ShellBarV2LogoClickEventDetail = { + targetRef: HTMLElement; +}; + +type ShellBarV2MenuItemClickEventDetail = { + item: HTMLElement; +}; + +type ShellBarV2ContentItemVisibilityChangeEventDetail = { + items: Array +}; + +type ShellBarV2SearchButtonEventDetail = { + targetRef: HTMLElement; + searchFieldVisible: boolean; +}; + +type ShellBarV2SearchFieldToggleEventDetail = { + expanded: boolean; +}; + +type ShellBarV2SearchFieldClearEventDetail = { + targetRef: HTMLElement; +}; + +/** + * @class + * ### Overview + * + * The `ui5-shellbar` is meant to serve as an application header + * and includes numerous built-in features, such as: logo, profile image/icon, title, search field, notifications and so on. + * + * ### Stable DOM Refs + * + * You can use the following stable DOM refs for the `ui5-shellbar`: + * + * - logo + * - notifications + * - overflow + * - profile + * - product-switch + * + * ### Keyboard Handling + * + * #### Fast Navigation + * This component provides a build in fast navigation group which can be used via [F6] / [Shift] + [F6] / [Ctrl] + [Alt/Option] / [Down] or [Ctrl] + [Alt/Option] + [Up]. + * In order to use this functionality, you need to import the following module: + * `import "@ui5/webcomponents-base/dist/features/F6Navigation.js"` + * + * ### ES6 Module Import + * `import "@ui5/webcomponents-fiori/dist/ShellBar.js";` + * @csspart root - Used to style the outermost wrapper of the `ui5-shellbar` + * @constructor + * @extends UI5Element + * @public + * @since 0.8.0 + */ + +@customElement({ + tag: "ui5-shellbar-v2", + styles: [shellBarV2Styles, shellBarV2LegacyStyles, ShellBarPopoverCss], + renderer: jsxRenderer, + template: ShellBarV2Template, + fastNavigation: true, + languageAware: true, + dependencies: [ + Icon, + List, + Button, + ButtonBadge, + Popover, + ShellBarSpacer, + ShellBarV2Item, + ListItemStandard, + // legacy dependencies + Menu, + ], +}) +/** + * + * Fired, when the notification icon is activated. + * @param {HTMLElement} targetRef dom ref of the activated element + * @public + */ +@event("notifications-click", { + cancelable: true, + bubbles: true, +}) + +/** + * Fired, when the profile slot is present. + * @param {HTMLElement} targetRef dom ref of the activated element + * @public + */ +@event("profile-click", { + bubbles: true, +}) + +/** + * Fired, when the product switch icon is activated. + * + * **Note:** You can prevent closing of overflow popover by calling `event.preventDefault()`. + * @param {HTMLElement} targetRef dom ref of the activated element + * @public + */ +@event("product-switch-click", { + cancelable: true, + bubbles: true, +}) + +/** + * Fired, when the logo is activated. + * @param {HTMLElement} targetRef dom ref of the activated element + * @since 0.10 + * @public + */ +@event("logo-click", { + bubbles: true, +}) + +/** + * Fired, when a menu item is activated + * + * **Note:** You can prevent closing of overflow popover by calling `event.preventDefault()`. + * @param {HTMLElement} item DOM ref of the activated list item + * @since 0.10 + * @public + */ +@event("menu-item-click", { + bubbles: true, + cancelable: true, +}) + +/** + * Fired, when the search button is activated. + * + * **Note:** You can prevent expanding/collapsing of the search field by calling `event.preventDefault()`. + * @param {HTMLElement} targetRef dom ref of the activated element + * @param {Boolean} searchFieldVisible whether the search field is visible + * @public + */ + +@event("search-button-click", { + cancelable: true, + bubbles: true, +}) + +/** + * Fired, when the search field is expanded or collapsed. + * @since 2.10.0 + * @param {Boolean} expanded whether the search field is expanded + * @public + */ +@event("search-field-toggle", { + bubbles: true, +}) + +/** + * Fired, when the search cancel button is activated. + * + * **Note:** You can prevent the default behavior (clearing the search field value) by calling `event.preventDefault()`. The search will still be closed. + * **Note:** The `search-field-clear` event is in an experimental state and is a subject to change. + * @param {HTMLElement} targetRef dom ref of the cancel button element + * @since 2.14.0 + * @public + */ +@event("search-field-clear", { + cancelable: true, + bubbles: true, +}) + +/** + * Fired, when an item from the content slot is hidden or shown. + * **Note:** The `content-item-visibility-change` event is in an experimental state and is a subject to change. + * + * @param {Array} array of all the items that are hidden + * @public + * @since 2.7.0 + */ +@event("content-item-visibility-change", { + bubbles: true, +}) + +class ShellBarV2 extends UI5Element { + eventDetails!: { + "notifications-click": ShellBarV2NotificationsClickEventDetail, + "profile-click": ShellBarV2ProfileClickEventDetail, + "product-switch-click": ShellBarV2ProductSwitchClickEventDetail, + "logo-click": ShellBarV2LogoClickEventDetail, + "menu-item-click": ShellBarV2MenuItemClickEventDetail, + "search-button-click": ShellBarV2SearchButtonEventDetail, + "search-field-toggle": ShellBarV2SearchFieldToggleEventDetail, + "search-field-clear": ShellBarV2SearchFieldClearEventDetail, + "content-item-visibility-change": ShellBarV2ContentItemVisibilityChangeEventDetail + } + + /** + * 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 `branding` slot is in an experimental state and is a subject to change. + * + * @since 2.12.0 + * @public + */ + @slot() + branding!: 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; + + /** + * Defines the `ui5-input`, that will be used as a search field. + * @public + */ + @slot({ type: HTMLElement }) + searchField!: Array; + + /** + * Defines the assistant slot. + * + * @since 2.0.0 + * @public + */ + @slot() + assistant!: Array; + + /** + * Defines the `ui5-shellbar` additional items. + * + * **Note:** + * You can use the ``. + * @public + */ + @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 visibility state of the search button. + * + * **Note:** The `hideSearchButton` property is in an experimental state and is a subject to change. + * @default false + * @public + */ + @property({ type: Boolean }) + hideSearchButton = false; + + /** + * Disables the automatic search field expansion/collapse when the available space is not enough. + * + * **Note:** The `disableSearchCollapse` property is in an experimental state and is a subject to change. + * @default false + * @public + */ + @property({ type: Boolean }) + disableSearchCollapse = false; + + /** + * Defines the `primaryTitle`. + * + * **Note:** The `primaryTitle` would be hidden on S screen size (less than approx. 700px). + * @default undefined + * @public + */ + @property() + primaryTitle?: string; + + /** + * Defines the `secondaryTitle`. + * + * **Note:** The `secondaryTitle` would be hidden on S and M screen sizes (less than approx. 1300px). + * @default undefined + * @public + */ + @property() + secondaryTitle?: string; + + /** + * Defines the `notificationsCount`, + * displayed in the notification icon top-right corner. + * @default undefined + * @public + */ + @property() + notificationsCount?: string; + + /** + * Defines, if the notification icon would be displayed. + * @default false + * @public + */ + @property({ type: Boolean }) + showNotifications = false; + + /** + * Defines, if the product switch icon would be displayed. + * @default false + * @public + */ + @property({ type: Boolean }) + showProductSwitch = false; + + /** + * Defines, if the Search Field would be displayed when there is a valid `searchField` slot. + * + * **Note:** By default the Search Field is not displayed. + * @default false + * @public + */ + @property({ type: Boolean }) + showSearchField = false; + + /** + * Defines additional accessibility attributes on different areas of the component. + * + * The accessibilityAttributes object has the following fields, + * where each field is an object supporting one or more accessibility attributes: + * + * - **logo** - `logo.role` and `logo.name`. + * - **notifications** - `notifications.expanded` and `notifications.hasPopup`. + * - **profile** - `profile.expanded`, `profile.hasPopup` and `profile.name`. + * - **product** - `product.expanded` and `product.hasPopup`. + * - **search** - `search.hasPopup`. + * - **overflow** - `overflow.expanded` and `overflow.hasPopup`. + * - **branding** - `branding.name`. + * + * The accessibility attributes support the following values: + * + * - **role**: Defines the accessible ARIA role of the logo area. + * Accepts the following string values: `button` or `link`. + * + * - **expanded**: Indicates whether the button, or another grouping element it controls, + * is currently expanded or collapsed. + * Accepts the following string values: `true` or `false`. + * + * - **hasPopup**: Indicates the availability and type of interactive popup element, + * such as menu or dialog, that can be triggered by the button. + * + * Accepts the following string values: `dialog`, `grid`, `listbox`, `menu` or `tree`. + * - **name**: Defines the accessible ARIA name of the area. + * Accepts any string. + * + * @default {} + * @public + * @since 1.10.0 + */ + @property({ type: Object }) + accessibilityAttributes: ShellBarV2AccessibilityAttributes = {}; + + /** + * @private + */ + @property() + breakpointSize = "S"; + + /** + * Actions computed from controllers. + * @private + */ + @property({ type: Object }) + actions: ShellBarV2ActionItem[] = []; + + /** + * Show overflow button when items are hidden. + * @private + */ + @property({ type: Boolean }) + showOverflowButton = false; + + /** + * 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 }) + hiddenItemsIds: string[] = []; + + /** + * Show full-screen search overlay. + * @private + */ + @property({ type: Boolean }) + showFullWidthSearch = false; + + /** + * Spacer element. + * @private + */ + @query(".ui5-shellbar-spacer") + spacer?: HTMLElement; + + /** + * Outer container of the overflow container. + * @private + */ + @query(".ui5-shellbar-overflow-container") + overflowOuter?: HTMLElement; + + /** + * Inner container of the overflow container. + * @private + */ + @query(".ui5-shellbar-overflow-container-inner") + overflowInner?: HTMLElement; + + @i18n("@ui5/webcomponents-fiori") + static i18nBundle: I18nBundle; + + 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 ShellBarV2ItemNavigation({ + getDomRef: () => this.getDomRef() || null, + }); + + overflow = new ShellBarV2Overflow(); + accessibility: ShellBarV2Accessibility = new ShellBarV2Accessibility(); + + private _searchAdaptor = new ShellBarV2Search(this.getSearchDeps()); + private _searchAdaptorLegacy = new ShellBarV2SearchLegacy({ + ...this.getSearchDeps(), + getDisableSearchCollapse: () => this.disableSearchCollapse, + }); + + /* =================== Legacy Members =================== */ + + /** + * Defines the logo of the `ui5-shellbar`. + * For example, you can use `ui5-avatar` or `img` elements as logo. + * @since 1.0.0-rc.8 + * @public + */ + @slot() + logo!: Array; + + /** + * Defines the items displayed in menu after a click on a start button. + * + * **Note:** You can use the `` and its ancestors. + * @since 0.10 + * @public + */ + @slot() + menuItems!: Array; + + /** + * Open state of the menu popover (legacy). + * @private + */ + @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`. + * + * **Note:** If set, the `searchField` slot is not rendered. + * @private + */ + @slot() + midContent!: Array; + + legacyAdaptor?: ShellBarV2Legacy; + + /* =================== Lifecycle Methods =================== */ + + onEnterDOM() { + ResizeHandler.register(this, this.handleResizeBound); + this.searchAdaptor?.subscribe(); + } + + onExitDOM() { + ResizeHandler.deregister(this, this.handleResizeBound); + this.searchAdaptor?.unsubscribe(); + } + + onBeforeRendering() { + if (!this.legacyAdaptor) { + this.initLegacyController(); + } + // Sync branding breakpoint state + this.branding.forEach(brandingEl => { + brandingEl._isSBreakPoint = this.isSBreakPoint; + }); + + this.buildActions(); + + this.searchAdaptor?.syncShowSearchFieldState(); + // subscribe to search adaptor for cases when search is added dynamically + this.searchAdaptor?.unsubscribe(); + this.searchAdaptor?.subscribe(); + } + + onAfterRendering() { + this.updateBreakpoint(); + this.updateOverflow(); + } + + /* =================== Actions Management =================== */ + + private buildActions() { + this.actions = [ + { + id: ShellBarV2Actions.Search, + icon: searchIcon, + enabled: this.enabledFeatures.search, + selector: ShellBarV2ActionsSelectors.Search, + isProtected: false, + stableDomRef: "toggle-search", + }, + { + id: ShellBarV2Actions.Assistant, + icon: daIcon, + enabled: this.enabledFeatures.assistant, + selector: ShellBarV2ActionsSelectors.Assistant, + isProtected: false, + }, + { + id: ShellBarV2Actions.Notifications, + icon: bellIcon, + count: this.notificationsCount, + enabled: this.enabledFeatures.notifications, + selector: ShellBarV2ActionsSelectors.Notifications, + isProtected: false, + stableDomRef: "notifications", + }, + { + id: ShellBarV2Actions.Overflow, + icon: overflowIcon, + enabled: this.enabledFeatures.overflow, + selector: ShellBarV2ActionsSelectors.Overflow, + isProtected: true, + stableDomRef: "overflow", + }, + { + id: ShellBarV2Actions.Profile, + enabled: this.enabledFeatures.profile, + selector: ShellBarV2ActionsSelectors.Profile, + isProtected: true, + stableDomRef: "profile", + }, + { + id: ShellBarV2Actions.ProductSwitch, + icon: gridIcon, + enabled: this.enabledFeatures.productSwitch, + selector: ShellBarV2ActionsSelectors.ProductSwitch, + isProtected: true, + stableDomRef: "product-switch", + }, + ].filter(action => action.enabled); + } + + getAction(actionId: ShellBarV2ActionId) { + return this.actions.find(action => action.id === actionId); + } + + getActionOverflowText(actionId: ShellBarV2ActionId): string { + const texts: Record = { + [ShellBarV2Actions.Search]: this.texts.search, + [ShellBarV2Actions.Profile]: this.texts.profile, + [ShellBarV2Actions.Overflow]: this.texts.overflow, + [ShellBarV2Actions.Assistant]: this.texts.assistant, + [ShellBarV2Actions.ProductSwitch]: this.texts.products, + [ShellBarV2Actions.Notifications]: this.texts.notificationsNoCount, + }; + return texts[actionId] || actionId; + } + + /* =================== Breakpoint Management =================== */ + + get isSBreakPoint() { + return this.breakpointSize === "S"; + } + + private updateBreakpoint() { + const width = this.getBoundingClientRect().width; + const bp = this.breakpoints.find(b => width <= b) || 10000; + const breakpoint = this.breakpointMap[bp]; + + if (this.breakpointSize !== breakpoint) { + this.breakpointSize = breakpoint; + } + } + + /* =================== Overflow Management =================== */ + + private updateOverflow() { + if (!this.overflow) { + return; + } + + const result = this.overflow.updateOverflow({ + actions: this.actions, + content: this.sortContent(this.content), + customItems: this.sortItems(this.items), + hiddenItemsIds: this.hiddenItemsIds, + showSearchField: this.enabledFeatures.search && this.showSearchField, + overflowOuter: this.overflowOuter!, + overflowInner: this.overflowInner!, + setVisible: (selector: string, visible: boolean) => { + const element = this.shadowRoot!.querySelector(selector); + if (element) { + element.classList[visible ? "remove" : "add"]("ui5-shellbar-hidden"); + } + }, + }); + + this.handleUpdateOverflowResult(result); + + return result.hiddenItemsIds; + } + + private handleUpdateOverflowResult(result: ShellBarV2OverflowResult) { + const { hiddenItemsIds, showOverflowButton } = result; + + // Update items overflow state + this.items.forEach(item => { + item.inOverflow = hiddenItemsIds.includes(item._id); + if (item.inOverflow) { + // clear the hidden class to ensure the item is visible in the overflow popover + item.classList.remove("ui5-shellbar-hidden"); + } + }); + + if (!arraysAreEqual(this.hiddenItemsIds, hiddenItemsIds)) { + this.handleContentVisibilityChanged(this.hiddenItemsIds, hiddenItemsIds); + this.hiddenItemsIds = hiddenItemsIds; + this.showOverflowButton = showOverflowButton; + } + this.showFullWidthSearch = this.searchAdaptor?.shouldShowFullScreen() || false; + } + + private handleContentVisibilityChanged(oldHiddenItemsIds: string[], newHiddenItemsIds: string[]) { + const filterContentIds = (ids: string[]) => ids.filter(id => this.content.some(item => (item as any)._individualSlot as string === id)); + const oldHiddenContentIds = filterContentIds(oldHiddenItemsIds); + const newHiddenContentIds = filterContentIds(newHiddenItemsIds); + + if (!arraysAreEqual(oldHiddenContentIds, newHiddenContentIds)) { + this.fireDecoratorEvent("content-item-visibility-change", { + items: newHiddenContentIds.map(id => this.content.find(item => (item as any)._individualSlot as string === id)!), + }); + } + } + + private handleResize() { + this.overflowPopoverOpen = false; + this.updateBreakpoint(); + const hiddenItemsIds = this.updateOverflow() ?? []; + const spacerWidth = this.spacer?.getBoundingClientRect().width || 0; + this.searchAdaptor?.autoManageSearchState(hiddenItemsIds.length, spacerWidth); + } + + isHidden(itemId: string) { + return this.hiddenItemsIds.includes(itemId); + } + + handleOverflowClick() { + this.overflowPopoverOpen = !this.overflowPopoverOpen; + } + + onPopoverClose() { + this.overflowPopoverOpen = false; + } + + /** + * Closes the overflow popover. + * @public + */ + closeOverflow(): void { + this.overflowPopoverOpen = false; + } + + handleOverflowItemClick(e: MouseEvent) { + const target = e.target as HTMLElement; + const actionId = target.getAttribute("data-action-id"); + + let prevented = e.defaultPrevented; // for custom actions + + if (actionId === ShellBarV2Actions.Notifications) { + prevented = this.handleNotificationsClick(); + } else if (actionId === ShellBarV2Actions.Search) { + prevented = this.handleSearchButtonClick(); + } + + if (!prevented) { + this.overflowPopoverOpen = false; + } + } + + get overflowItems() { + return this.overflow.getOverflowItems({ + actions: this.actions, + customItems: this.sortItems(this.items), + hiddenItemsIds: this.hiddenItemsIds, + }); + } + + /** + * Returns badge text for overflow button. + * Shows count if only one item with count is overflowed, otherwise shows attention dot. + */ + get overflowBadge(): string | undefined { + const itemsWithCount = this.overflowItems.filter(item => item.data.count); + if (itemsWithCount.length === 1) { + return itemsWithCount[0].data.count; + } + if (itemsWithCount.length > 1) { + return " "; // Attention dot + } + return undefined; + } + + /* =================== Search Management =================== */ + + get search() { + return this.searchField.length ? this.searchField[0] : null; + } + + get isSelfCollapsibleSearch(): boolean { + const searchField = this.search; + if (searchField) { + return "collapsed" in searchField && "open" in searchField; + } + return false; + } + + private getSearchDeps() { + return { + getSearchField: () => this.search, + getSearchState: () => this.enabledFeatures.search && this.showSearchField, + getCSSVariable: (cssVar: string) => this.getCSSVariable(cssVar), + setSearchState: (expanded: boolean) => this.setSearchState(expanded), + getOverflowed: () => this.overflow.isOverflowing(this.overflowOuter!, this.overflowInner!), + }; + } + + get searchAdaptor(): IShellBarSearchController { + if (this.isSelfCollapsibleSearch) { + return this._searchAdaptor; + } + return this._searchAdaptorLegacy; + } + + handleSearchButtonClick() { + const searchButton = this.shadowRoot!.querySelector + ); +} diff --git a/packages/fiori/src/ShellBarV2Template.tsx b/packages/fiori/src/ShellBarV2Template.tsx new file mode 100644 index 000000000000..cb2132ff41ab --- /dev/null +++ b/packages/fiori/src/ShellBarV2Template.tsx @@ -0,0 +1,259 @@ +import Button from "@ui5/webcomponents/dist/Button.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 ShellBarV2 from "./ShellBarV2.js"; +import ShellBarV2Item from "./ShellBarV2Item.js"; + +import { + ShellBarV2SearchField, + ShellBarV2SearchFieldFullWidth +} from "./shellbarv2/templates/ShellBarSearchTemplate.js"; + +import { + ShellBarV2SearchField as ShellBarV2SearchFieldLegacy, + ShellBarV2SearchButton as ShellBarV2SearchButtonLegacy, + ShellBarV2SearchFieldFullWidth as ShellBarV2SearchFieldFullWidthLegacy, +} from "./shellbarv2/templates/ShellBarSearchLegacyTemplate.js"; + +import { + ShellBarV2LegacyBrandingArea, +} from "./shellbarv2/templates/ShellBarLegacyTemplate.js"; + +export default function ShellBarV2Template(this: ShellBarV2) { + const isLegacySearch = !this.isSelfCollapsibleSearch; + + const SearchInBarTemplate = isLegacySearch ? ShellBarV2SearchFieldLegacy : ShellBarV2SearchField; + const SearchFullWidthTemplate = isLegacySearch ? ShellBarV2SearchFieldFullWidthLegacy : ShellBarV2SearchFieldFullWidth; + + 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 ( + <> +
+ {/* Full-width search overlay */} + {this.showFullWidthSearch && SearchFullWidthTemplate.call(this)} + + {this.enabledFeatures.startButton && ( +
+ +
+ )} + + {this.enabledFeatures.branding && ( +
+ +
+ )} + + {/* Legacy branding (logo + primaryTitle) when no menu items */} + {!this.enabledFeatures.branding && ShellBarV2LegacyBrandingArea.call(this)} + +
+
+ + {this.enabledFeatures.content && ( +
+ {/* Start separator */} + {this.separatorConfig.showStartSeparator && ( +
+ )} + + {/* Start content items */} + {this.startContent.map(item => { + const itemId = (item as any)._individualSlot as string; + const packedSep = this.getPackedSeparatorInfo(item, true); + return ( +
+ {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 itemId = (item as any)._individualSlot as string; + const packedSep = this.getPackedSeparatorInfo(item, false); + return ( +
+ + {packedSep.shouldPack && ( +
+ )} +
+ ); + })} + + {/* End separator */} + {this.separatorConfig.showEndSeparator && ( +
+ )} +
+ )} + + {this.enabledFeatures.search && SearchInBarTemplate.call(this)} + {this.enabledFeatures.search && isLegacySearch && ShellBarV2SearchButtonLegacy.call(this)} + + {assistantAction && ( +
+ +
+ )} + + {notificationsAction && ( + + )} + + {/* 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/bundle.esm.ts b/packages/fiori/src/bundle.esm.ts index 65cdeab34fa2..071202e50d27 100644 --- a/packages/fiori/src/bundle.esm.ts +++ b/packages/fiori/src/bundle.esm.ts @@ -31,6 +31,7 @@ import Page from "./Page.js"; import ProductSwitch from "./ProductSwitch.js"; import ProductSwitchItem from "./ProductSwitchItem.js"; import ShellBar from "./ShellBar.js"; +import ShellBarV2 from "./ShellBarV2.js"; import SearchField from "./SearchField.js"; import SearchScope from "./SearchScope.js"; import Search from "./Search.js"; @@ -42,6 +43,7 @@ import SearchItemGroup from "./SearchItemGroup.js"; import ShellBarBranding from "./ShellBarBranding.js"; import ShellBarSpacer from "./ShellBarSpacer.js"; import ShellBarItem from "./ShellBarItem.js"; +import ShellBarV2Item from "./ShellBarV2Item.js"; import SideNavigationItem from "./SideNavigationItem.js"; import SideNavigationSubItem from "./SideNavigationSubItem.js"; import SideNavigationGroup from "./SideNavigationGroup.js"; diff --git a/packages/fiori/src/i18n/messagebundle.properties b/packages/fiori/src/i18n/messagebundle.properties index bfbdaf2e90a9..6a57fc9650ba 100644 --- a/packages/fiori/src/i18n/messagebundle.properties +++ b/packages/fiori/src/i18n/messagebundle.properties @@ -200,6 +200,9 @@ SEARCH_ITEM_DELETE_BUTTON=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/shellbarv2/IShellBarSearchController.ts b/packages/fiori/src/shellbarv2/IShellBarSearchController.ts new file mode 100644 index 000000000000..5658177a29bb --- /dev/null +++ b/packages/fiori/src/shellbarv2/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/shellbarv2/ShellBarAccessibility.ts b/packages/fiori/src/shellbarv2/ShellBarAccessibility.ts new file mode 100644 index 000000000000..2422a78f8109 --- /dev/null +++ b/packages/fiori/src/shellbarv2/ShellBarAccessibility.ts @@ -0,0 +1,107 @@ +import type { AccessibilityAttributes, AriaRole } from "@ui5/webcomponents-base"; + +// Legacy Type logo accessibility attributes +type ShellBarV2LogoAccessibilityAttributes = { + role?: Extract; + name?: string; +}; + +type ShellBarV2ProfileAccessibilityAttributes = Pick; +type ShellBarV2AreaAccessibilityAttributes = Pick; +type ShellBarV2BrandingAccessibilityAttributes = Pick; + +type ShellBarV2AccessibilityAttributes = { + logo?: ShellBarV2LogoAccessibilityAttributes; + notifications?: ShellBarV2AreaAccessibilityAttributes; + profile?: ShellBarV2ProfileAccessibilityAttributes; + product?: ShellBarV2AreaAccessibilityAttributes; + search?: ShellBarV2AreaAccessibilityAttributes; + overflow?: ShellBarV2AreaAccessibilityAttributes; + branding?: ShellBarV2BrandingAccessibilityAttributes; +}; + +interface ShellBarV2AreaAccessibilityInfo { + title: string | undefined; + accessibilityAttributes: { + name?: string; + hasPopup?: AccessibilityAttributes["hasPopup"]; + expanded?: AccessibilityAttributes["expanded"]; + }; +} + +type ShellBarV2AccessibilityInfo = { + notifications: ShellBarV2AreaAccessibilityInfo; + profile: ShellBarV2AreaAccessibilityInfo; + products: ShellBarV2AreaAccessibilityInfo; + overflow: ShellBarV2AreaAccessibilityInfo; + search: ShellBarV2AreaAccessibilityInfo; +}; + +class ShellBarV2Accessibility { + getActionsAccessibilityAttributes( + defaultTexts: Record, + params: { + accessibilityAttributes: ShellBarV2AccessibilityAttributes; + overflowPopoverOpen: boolean; + }, + ): ShellBarV2AccessibilityInfo { + 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 ShellBarV2Accessibility; + +export type { + ShellBarV2AccessibilityInfo, + ShellBarV2AreaAccessibilityInfo, + ShellBarV2AccessibilityAttributes, + ShellBarV2LogoAccessibilityAttributes, + ShellBarV2AreaAccessibilityAttributes, + ShellBarV2ProfileAccessibilityAttributes, +}; diff --git a/packages/fiori/src/shellbarv2/ShellBarItemNavigation.ts b/packages/fiori/src/shellbarv2/ShellBarItemNavigation.ts new file mode 100644 index 000000000000..23f309fb8849 --- /dev/null +++ b/packages/fiori/src/shellbarv2/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 ShellBarV2ItemNavigationConstructorParams { + getDomRef: () => HTMLElement | null; +} + +class ShellBarV2ItemNavigation { + private params: ShellBarV2ItemNavigationConstructorParams; + + constructor(params: ShellBarV2ItemNavigationConstructorParams) { + 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 ShellBarV2ItemNavigation; +export type { + ShellBarV2ItemNavigationConstructorParams, +}; diff --git a/packages/fiori/src/shellbarv2/ShellBarLegacy.ts b/packages/fiori/src/shellbarv2/ShellBarLegacy.ts new file mode 100644 index 000000000000..efc9124e4f9e --- /dev/null +++ b/packages/fiori/src/shellbarv2/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 ShellBarV2 from "../ShellBarV2.js"; +import List from "@ui5/webcomponents/dist/List.js"; +import type Popover from "@ui5/webcomponents/dist/Popover.js"; + +type ShellBarV2LegacyDeps = { + component: ShellBarV2; + getShadowRoot: () => ShadowRoot | null; +}; + +/** + * Controller for legacy ShellBar features that will be removed in future versions. + * Handles: logo slot, primaryTitle/secondaryTitle properties, menuItems slot. + */ +class ShellBarV2Legacy { + private component: ShellBarV2; + 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: ShellBarV2LegacyDeps) { + 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 ShellBarV2Legacy; diff --git a/packages/fiori/src/shellbarv2/ShellBarOverflow.ts b/packages/fiori/src/shellbarv2/ShellBarOverflow.ts new file mode 100644 index 000000000000..fafac390dfaa --- /dev/null +++ b/packages/fiori/src/shellbarv2/ShellBarOverflow.ts @@ -0,0 +1,224 @@ +import type ShellBarV2Item from "../ShellBarV2Item.js"; +import { ShellBarV2Actions, ShellBarV2ActionsSelectors } from "../ShellBarV2.js"; +import type { ShellBarV2ActionId, ShellBarV2ActionItem } from "../ShellBarV2.js"; + +interface ShellBarV2HidableItem { + id: string; + selector: string; // CSS selector to find the element + hideOrder: number; + keepHidden: boolean; + showInOverflow?: boolean; // If true, hiding this item triggers overflow button +} + +interface ShellBarV2OverflowParams { + actions: readonly ShellBarV2ActionItem[]; + content: readonly HTMLElement[]; + customItems: readonly ShellBarV2Item[]; + overflowOuter: HTMLElement; + overflowInner: HTMLElement; + hiddenItemsIds: readonly string[]; + showSearchField: boolean; + setVisible: (selector: string, visible: boolean) => void; +} + +interface ShellBarV2OverflowResult { + hiddenItemsIds: string[]; + showOverflowButton: boolean; +} + +type ShellBarV2OverflowItem = { + type: "action"; + id: ShellBarV2ActionId; + data: ShellBarV2ActionItem + order: number; +} | { + type: "item"; + id: string; + data: ShellBarV2Item; + order: number; +} + +class ShellBarV2Overflow { + private readonly OPEN_SEARCH_STRATEGY = { + CONTENT: 0, // All content first + ACTIONS: 1000, // All actions next + SEARCH: 1000, // Search included in actions + LAST_CONTENT: 0, // Last content same as other content + }; + + private readonly CLOSED_SEARCH_STRATEGY = { + ACTIONS: 0, // All actions first + CONTENT: 1000, // Then content (except last) + SEARCH: 2000, // Then search button + LAST_CONTENT: 3000, // Last content item protected + }; + + updateOverflow(params: ShellBarV2OverflowParams): ShellBarV2OverflowResult { + 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(ShellBarV2ActionsSelectors.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(ShellBarV2ActionsSelectors.Overflow, true); + showOverflowButton = true; + } + } + + // never hide just one item as overflow button also accounts for one item + if (hiddenItemsIds.length === 1 && nextItemToHide) { + hiddenItemsIds.push(nextItemToHide.id); + } + + return { + hiddenItemsIds, + showOverflowButton, + }; + } + + isOverflowing(overflowOuter: HTMLElement, overflowInner: HTMLElement): boolean { + return overflowInner.offsetWidth > overflowOuter.offsetWidth; + } + + private buildHidableItems(params: ShellBarV2OverflowParams): ShellBarV2HidableItem[] { + const { + content, customItems, actions, showSearchField, hiddenItemsIds, + } = params; + + const items: ShellBarV2HidableItem[] = []; + const priorityStrategy = showSearchField ? this.OPEN_SEARCH_STRATEGY : this.CLOSED_SEARCH_STRATEGY; + + // 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 ? priorityStrategy.LAST_CONTENT : priorityStrategy.CONTENT; + + items.push({ + id: slotName, + selector: `#${slotName}`, + hideOrder: priority + dataHideOrder, + keepHidden: false, + showInOverflow: false, + }); + }); + + let actionIndex = 0; + + customItems.forEach(item => { + items.push({ + id: item._id, + selector: `[data-ui5-stable="${item.stableDomRef}"]`, + hideOrder: priorityStrategy.ACTIONS + actionIndex++, + keepHidden: hiddenItemsIds.includes(item._id), + showInOverflow: true, + }); + }); + + actions + .filter(a => !a.isProtected && a.id !== ShellBarV2Actions.Search) + .forEach(config => { + items.push({ + id: config.id, + selector: config.selector, + hideOrder: priorityStrategy.ACTIONS + actionIndex++, + keepHidden: hiddenItemsIds.includes(config.id), + showInOverflow: true, + }); + }); + + if (!showSearchField) { + // Only move search to overflow if it's closed + items.push({ + id: ShellBarV2Actions.Search, + selector: ShellBarV2ActionsSelectors.Search, + hideOrder: priorityStrategy.SEARCH + actionIndex++, + keepHidden: false, + showInOverflow: true, + }); + } + // 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; + }); + } + + getOverflowItems(params: { + actions: readonly ShellBarV2ActionItem[]; + customItems: readonly ShellBarV2Item[]; + hiddenItemsIds: readonly string[]; + }): ReadonlyArray { + const { actions, customItems, hiddenItemsIds } = params; + const result: ShellBarV2OverflowItem[] = []; + + // Add hidden custom items + const hiddenCustomItems = customItems.filter((item: ShellBarV2Item) => hiddenItemsIds.includes(item._id)); + hiddenCustomItems.forEach((item: ShellBarV2Item, index: number) => { + result.push({ + type: "item", id: item._id, data: item, order: 3 + index, + }); + }); + + const actionOrder: Record = { + [ShellBarV2Actions.Search]: 0, + [ShellBarV2Actions.Notifications]: 1, + [ShellBarV2Actions.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 ShellBarV2Overflow; +export type { + ShellBarV2HidableItem, + ShellBarV2OverflowParams, + ShellBarV2OverflowResult, + ShellBarV2OverflowItem, +}; diff --git a/packages/fiori/src/shellbarv2/ShellBarSearch.ts b/packages/fiori/src/shellbarv2/ShellBarSearch.ts new file mode 100644 index 000000000000..2ae29bb62eb3 --- /dev/null +++ b/packages/fiori/src/shellbarv2/ShellBarSearch.ts @@ -0,0 +1,184 @@ +import { isPhone } from "@ui5/webcomponents-base"; +import type { IShellBarSearchField } from "../ShellBarV2.js"; +import type { IShellBarSearchController } from "./IShellBarSearchController.js"; + +interface ShellBarV2SearchConstructorParams { + 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 ShellBarV2Search 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, + }: ShellBarV2SearchConstructorParams) { + 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(ShellBarV2Search.CSS_VARIABLE); + if (!width) { + return ShellBarV2Search.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 ShellBarV2Search; +export type { + ShellBarV2SearchConstructorParams, +}; diff --git a/packages/fiori/src/shellbarv2/ShellBarSearchLegacy.ts b/packages/fiori/src/shellbarv2/ShellBarSearchLegacy.ts new file mode 100644 index 000000000000..d19ababa8fbb --- /dev/null +++ b/packages/fiori/src/shellbarv2/ShellBarSearchLegacy.ts @@ -0,0 +1,163 @@ +import type { IShellBarSearchController } from "./IShellBarSearchController.js"; + +interface ShellBarV2SearchLegacyConstructorParams { + 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 ShellBarV2SearchLegacy 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, + }: ShellBarV2SearchLegacyConstructorParams) { + 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(ShellBarV2SearchLegacy.CSS_VARIABLE); + if (!width) { + return ShellBarV2SearchLegacy.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 ShellBarV2SearchLegacy; +export type { + ShellBarV2SearchLegacyConstructorParams, +}; diff --git a/packages/fiori/src/shellbarv2/templates/ShellBarLegacyTemplate.tsx b/packages/fiori/src/shellbarv2/templates/ShellBarLegacyTemplate.tsx new file mode 100644 index 000000000000..efd5931c3847 --- /dev/null +++ b/packages/fiori/src/shellbarv2/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 ShellBarV2 from "../../ShellBarV2.js"; + +function ShellBarV2LegacyBrandingArea(this: ShellBarV2) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( + <> + {legacy.hasMenuItems && ShellBarV2InteractiveMenuButton.call(this)} + {legacy.hasMenuItems && ShellBarV2LegacySecondaryTitle.call(this)} + {!legacy.hasMenuItems && ShellBarV2LegacyTitleArea.call(this)} + + {/* Menu Popover (legacy) */} + {ShellBarV2MenuPopover.call(this)} + + ); +} + +function ShellBarV2LegacyTitleArea(this: ShellBarV2) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( + <> + {!!(legacy.isSBreakPoint && legacy.hasLogo) && ShellBarV2SingleLogo.call(this)} + {!legacy.isSBreakPoint && (legacy.hasLogo || legacy.primaryTitle) && ( + <> + {ShellBarV2CombinedLogo.call(this)} + {legacy.hasSecondaryTitle && legacy.hasPrimaryTitle && ShellBarV2LegacySecondaryTitle.call(this)} + + )} + + ); +} + +/** + * Renders interactive menu button for non-S breakpoints. + * Shows primaryTitle with arrow, opens menu popover. + */ +function ShellBarV2InteractiveMenuButton(this: ShellBarV2) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( + <> + {!legacy.showLogoInMenuButton && legacy.hasLogo && ShellBarV2SingleLogo.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 ShellBarV2SingleLogo(this: ShellBarV2) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( + + ); +} + +function ShellBarV2CombinedLogo(this: ShellBarV2) { + const legacy = this.legacyAdaptor; + if (!legacy) { + return null; + } + + return ( +
+ {legacy.hasLogo && ( + + )} +
+ {legacy.primaryTitle && ( +

+ {legacy.primaryTitle} +

+ )} +
+
+ ); +} + +function ShellBarV2LegacySecondaryTitle(this: ShellBarV2) { + const legacy = this.legacyAdaptor; + if (!legacy || !legacy.showSecondaryTitle) { + return null; + } + + return ( +
+ {this.secondaryTitle} +
+ ); +} + +/** + * Renders the menu popover. + * Contains the list of menu items. + */ +function ShellBarV2MenuPopover(this: ShellBarV2) { + const legacy = this.legacyAdaptor; + if (!legacy || !legacy.hasMenuItems) { + return null; + } + + return ( + + + + + + ); +} + +export { + ShellBarV2SingleLogo, + ShellBarV2MenuPopover, + ShellBarV2LegacyTitleArea, + ShellBarV2LegacyBrandingArea, + ShellBarV2LegacySecondaryTitle, + ShellBarV2InteractiveMenuButton, +}; diff --git a/packages/fiori/src/shellbarv2/templates/ShellBarSearchLegacyTemplate.tsx b/packages/fiori/src/shellbarv2/templates/ShellBarSearchLegacyTemplate.tsx new file mode 100644 index 000000000000..d6e5312c4a59 --- /dev/null +++ b/packages/fiori/src/shellbarv2/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 ShellBarV2 from "../../ShellBarV2.js"; + +function ShellBarV2SearchField(this: ShellBarV2) { + 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 ShellBarV2SearchFieldFullWidth(this: ShellBarV2) { + return ( +
+
+ +
+ +
+ ); +} + +function ShellBarV2SearchButton(this: ShellBarV2) { + const searchAction = this.getAction("search"); + return ( + <> + {!this.hideSearchButton && ( + + + ); +} + +export { + ShellBarV2SearchField, + ShellBarV2SearchFieldFullWidth, +}; diff --git a/packages/fiori/src/themes/NavigationLayout.css b/packages/fiori/src/themes/NavigationLayout.css index caac5c89735f..8b1431ad6df2 100644 --- a/packages/fiori/src/themes/NavigationLayout.css +++ b/packages/fiori/src/themes/NavigationLayout.css @@ -59,6 +59,7 @@ transform: translateX(100%); } -:host([has-side-navigation]) ::slotted([ui5-shellbar][slot="header"]) { +:host([has-side-navigation]) ::slotted([ui5-shellbar][slot="header"]), +:host([has-side-navigation]) ::slotted([ui5-shellbar-v2][slot="header"]) { padding-inline: 0.875rem 1rem; } diff --git a/packages/fiori/src/themes/ShellBarV2.css b/packages/fiori/src/themes/ShellBarV2.css new file mode 100644 index 000000000000..f45de2c2256b --- /dev/null +++ b/packages/fiori/src/themes/ShellBarV2.css @@ -0,0 +1,353 @@ +@import "./InvisibleTextStyles.css"; +@import "./ShellBarV2SearchLegacy.css"; + +/* ============================================================================ + HOST & CSS VARIABLES + ============================================================================ */ + +:host(:not([hidden])) { + display: inline-block; + width: 100%; + max-width: 100%; + background: var(--sapShellColor); + box-sizing: border-box; + + /* 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 { + display: flex; + align-items: center; + height: var(--_ui5_shellbar_root_height); + box-shadow: inset 0 -0.0625rem var(--sapShell_BorderColor); + position: relative; + font-size: var(--sapFontSize); + font-weight: normal; +} + +/* ============================================================================ + 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; + border: 0.0625rem solid var(--sapButton_Lite_BorderColor); + background: var(--sapButton_Lite_Background); + color: var(--sapShell_TextColor); + box-sizing: border-box; + cursor: pointer; + border-radius: var(--_ui5_shellbar_button_border_radius); + font-weight: bold; +} + +/* 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); +} + +/* 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); +} + +/* 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); +} + +/* ============================================================================ + ACTION BUTTONS (Items & Internal Actions) + ============================================================================ */ + +.ui5-shellbar-action-button { + 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); +} + +::slotted([ui5-toggle-button][slot="assistant"]) { + color: var(--sapShell_TextColor); +} + +::slotted([ui5-toggle-button][slot="assistant"]:hover) { + color: var(--sapShell_TextColor); +} + +::slotted([ui5-toggle-button][slot="assistant"][active]) { + color: var(--_ui5_shellbar_button_active_color); +} + +/* ============================================================================ + START AREA (Start Button, Branding) + ============================================================================ */ + +.ui5-shellbar-start-button { + flex-shrink: 0; + display: flex; + align-items: center; +} + +.ui5-shellbar-branding-area { + flex-shrink: 0; + display: flex; + align-items: center; +} + +/* ============================================================================ + OVERFLOW CONTAINER + ============================================================================ */ + +.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; + align-items: center; + min-width: 0; + overflow: hidden; + position: relative; +} + +.ui5-shellbar-overflow-container-inner { + display: flex; + align-items: center; + justify-content: end; + flex-shrink: 0; + min-width: 100%; +} + +/* ============================================================================ + SEARCH AREA + ============================================================================ */ + +.ui5-shellbar-search-field-area { + flex: 0 1 auto; + min-width: 0; + display: flex; + align-items: center; + margin-left: auto; + /* goes to the most right when no content is present */ +} + +:host([show-search-field]) ::slotted([slot="searchField"]), +/* Search field displays in full mode if there's not enough space in bar. +Once in full screen mode the search field is rendered in another DOM. +To account for correct measurements in overflow, we should keep the min width +of the search field container in the bar even when the search is in full mode. */ +:host([show-full-width-search]) .ui5-shellbar-search-field-area { + min-width: var(--_ui5_shellbar_search_field_width); +} + +/* ============================================================================ + CONTENT AREA (Items, Spacer, Separator) + ============================================================================ */ + +.ui5-shellbar-content-area { + flex-grow: 1; + display: flex; + align-items: center; +} + +.ui5-shellbar-content-item { + flex-shrink: 0; + display: flex; + align-items: center; +} + +.ui5-shellbar-spacer { + flex-grow: 1; + height: 1px; + flex-basis: 1rem; + flex-shrink: 1; +} + +.ui5-shellbar-separator { + flex-grow: 0; + flex-shrink: 0; + height: var(--_ui5-shellbar-separator-height); + width: 1px; + background-color: var(--_ui5-shellbar_separator-color); +} + +/* ============================================================================ + 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; +} + +.ui5-shellbar-custom-item.ui5-shellbar-hidden { + display: none; +} + + +/* ============================================================================ + ACTION BUTTONS (Notifications, Assistant, Profile) + ============================================================================ */ + +.ui5-shellbar-action-button { + white-space: initial; + overflow: initial; + text-overflow: initial; + line-height: inherit; + letter-spacing: inherit; + word-spacing: inherit; + width: 2.25rem; + box-sizing: border-box; +} + +.ui5-shellbar-image-button { + display: flex; + justify-content: center; + align-items: center; + 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-assistant-button { + display: flex; + align-items: center; +} + +::slotted([ui5-toggle-button][slot="assistant"]) { + margin-inline-start: 0; +} + +::slotted([ui5-toggle-button][slot="assistant"][pressed]), +::slotted([ui5-toggle-button][slot="assistant"][pressed]:hover:not([active])) { + color: var(--sapShell_Assistant_ForegroundColor); +} + +slot[name="profile"] { + min-width: 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; +} + +/* ============================================================================ + FULL-SCREEN SEARCH OVERLAY + ============================================================================ */ + +.ui5-shellbar-search-full-width-wrapper { + position: absolute; + bottom: 0.0625rem; + left: 0; + background: var(--sapShellColor); + height: 100%; + width: 100%; + z-index: 1001; + display: flex; + align-items: center; + box-sizing: border-box; + padding: 0 1rem; +} + +.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; + width: 100%; +} + +/* ============================================================================ + BREAKPOINTS + ============================================================================ */ + +/* Responsive padding per breakpoint */ +:host([breakpoint-size="S"]) { + padding: 0 1rem; +} + +:host([breakpoint-size="M"]) { + padding: 0 2rem; +} + +:host([breakpoint-size="L"]) { + padding: 0 2rem; +} + +:host([breakpoint-size="XL"]) { + padding: 0 3rem; +} + +:host([breakpoint-size="XXL"]) { + padding: 0 3rem; +} + +/* Search overlay padding per breakpoint */ +:host([breakpoint-size="S"]) .ui5-shellbar-search-full-width-wrapper { + padding: 0 1rem; +} + +:host([breakpoint-size="M"]) .ui5-shellbar-search-full-width-wrapper { + padding: 0 2rem; +} + +/* ============================================================================ + Utilities (Keep these at the end of the file to avoid specificity issues) + ============================================================================ */ + +.ui5-shellbar-gap-start { + margin-inline-start: var(--ui5_shellbar_gap); +} + +.ui5-shellbar-gap-end { + margin-inline-end: var(--ui5_shellbar_gap); +} + +.ui5-shellbar-hidden { + display: none !important; +} \ No newline at end of file diff --git a/packages/fiori/src/themes/ShellBarV2Item.css b/packages/fiori/src/themes/ShellBarV2Item.css new file mode 100644 index 000000000000..eb14a6694e4d --- /dev/null +++ b/packages/fiori/src/themes/ShellBarV2Item.css @@ -0,0 +1,42 @@ +/* ============================================================================ + ACTION BUTTON STYLING + ============================================================================ */ + +.ui5-shellbar-action-button { + width: 2.25rem; + color: var(--sapShell_TextColor); +} + +.ui5-shellbar-action-button:hover { + color: var(--sapShell_TextColor); +} + +.ui5-shellbar-action-button[active] { + color: var(--_ui5_shellbar_button_active_color); +} + +[ui5-li]:after { + position: relative; + width: fit-content; + height: 1rem; + min-width: 1rem; + background: var(--sapContent_BadgeBackground); + border: var(--_ui5_shellbar_button_badge_border); + color: var(--sapContent_BadgeTextColor); + bottom: calc(100% + 0.0625rem); + left: 1.25rem; + padding: 0 0.3125rem; + border-radius: 0.5rem; + display: flex; + justify-content: center; + align-items: center; + font-size: var(--sapFontSmallSize); + font-family: var(--sapFontFamily); + z-index: 2; + box-sizing: border-box; + pointer-events: none; +} + +[ui5-li][data-count]:after { + content: attr(data-count); +} \ No newline at end of file diff --git a/packages/fiori/src/themes/ShellBarV2Legacy.css b/packages/fiori/src/themes/ShellBarV2Legacy.css new file mode 100644 index 000000000000..5445e9003e76 --- /dev/null +++ b/packages/fiori/src/themes/ShellBarV2Legacy.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/ShellBarV2SearchLegacy.css b/packages/fiori/src/themes/ShellBarV2SearchLegacy.css new file mode 100644 index 000000000000..b94c45f0f629 --- /dev/null +++ b/packages/fiori/src/themes/ShellBarV2SearchLegacy.css @@ -0,0 +1,44 @@ +/* Legacy Search Styles - ONLY for ui5-input and custom div search fields */ + +/* CSS variable overrides for ui5-input component */ +:host { + --_ui5_input_placeholder_color: var(--sapShell_InteractiveTextColor); + --_ui5_input_border_radius: var(--_ui5_shellbar_input_border_radius); + --_ui5_input_focus_border_radius: var(--_ui5_shellbar_input_focus_border_radius); + --_ui5_input_background_color: var(--_ui5_shellbar_input_background_color); + --_ui5_input_focus_outline_color: var(--_ui5_shellbar_input_focus_outline_color); + --_ui5_input_margin_top_bottom: 0; +} + +/* ui5-input specific styles */ +::slotted([ui5-input]) { + background: var(--_ui5_shellbar_search_field_background); + border: var(--_ui5_shellbar_search_field_border); + box-shadow: var(--_ui5_shellbar_search_field_box_shadow); + color: var(--_ui5_shellbar_search_field_color); + height: 2.25rem; + width: 100%; + min-width: var(--_ui5_shellbar_search_field_width); +} + +/* ui5-input breakpoint adjustments */ +:host([breakpoint-size="M"]) ::slotted([ui5-input]), +:host([breakpoint-size="S"]) ::slotted([ui5-input]) { + min-width: 1rem; +} + +:host([breakpoint-size="M"][show-search-field]) .ui5-shellbar-overflow-container-right-child { + flex-grow: 1; +} + +/* ui5-input hover */ +::slotted([ui5-input]:hover) { + background: var(--_ui5_shellbar_search_field_background_hover); + box-shadow: var(--_ui5_shellbar_search_field_box_shadow_hover); +} + +/* ui5-input focus */ +::slotted([ui5-input][focused]) { + outline: var(--_ui5_shellbar_search_field_outline_focused); +} + diff --git a/packages/fiori/test/pages/ShellBarV2.html b/packages/fiori/test/pages/ShellBarV2.html new file mode 100644 index 000000000000..cdb170d3f912 --- /dev/null +++ b/packages/fiori/test/pages/ShellBarV2.html @@ -0,0 +1,148 @@ + + + + + + Shell Bar V2 MVP Test + + + + + + + +

ShellBarV2 MVP Test

+

Testing modular architecture with Actions feature.

+ +

Full Features (with All Actions + Overflow)

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

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

+

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

+

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

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

ShellBarV2 - 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: ShellBarV2Search +
+ + + Self-Collapsible Search + + + + + + + Reports + Analytics + + +

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

+
+ + +
+

2. ui5-input (Legacy) ✅

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

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

+
+ + +
+

3. Custom DIV with Input (Legacy) ✅

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

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

+
+ + +
+

4. ui5-input with disableSearchCollapse

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

Resize window - search stays expanded even when items overflow.

+
+ + +
+

5. Custom DIV with hideSearchButton

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

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

+
+ + +
+

6. With Overflow + Auto-Collapse

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

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

+
+ + +
+

Event Log

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

ShellBar v1 vs ShellBarV2 Comparison

+ +
+ +
+ Theme: + + Horizon (Morning) + Horizon Dark (Evening) + Horizon High Contrast Black + Horizon High Contrast White + Fiori 3 (Quartz Light) + Fiori 3 Dark (Quartz Dark) + Fiori 3 High Contrast Black + Fiori 3 High Contrast White + +
+ + +
+ Notifications: + +
+ +
+ Product Switch: + +
+ +
+ Profile: + +
+ +
+ Assistant: + +
+ +
+ Start Button: + +
+ +
+ Notifications Count: + +
+ + +
+ Search +
+
+ Search Field: + +
+
+ Search Type: + + ui5-input + ui5-shellbar-search + +
+
+
+ + +
+ Content Items (Overflow Test) +
+
+ Content Items: + +
+
+ Show Spacer: + +
+
+
+ + +
+ Custom Items +
+
+ Custom Items: + +
+
+ More Items: + +
+
+
+ + +
+ Branding +
+
+ Branding Mode: + + Branding Slot (New) + Logo + Primary Title (Legacy) + +
+
+ Show Secondary Title: + +
+
+ Menu Items: + + (requires Legacy mode) +
+
+
+
+ +
+ How to use: Toggle switches to enable/disable features. Both ShellBars update simultaneously for easy comparison. +
Tip: Resize window to test overflow behavior with content items. Check console for event logs. +
Themes: Switch between all 8 available themes (Horizon and Fiori 3 families, including dark and high contrast variants). +
Branding Mode: Switch between new Branding Slot and legacy Logo + Primary Title modes. Menu items only available in legacy mode. +
+ + +
+ + + Product Title + + + + + Action 1 + Tag + Action 3 + End 1 + End 2 + + + + + + + + + +
+ + +
+ + + Product Title + + + + + Action 1 + Tag + Action 3 + End 1 + End 2 + + + + + + + + + +
+ +
+ + + + + diff --git a/packages/fiori/test/pages/ShellBar_evolution.html b/packages/fiori/test/pages/ShellBar_evolution.html index d425a07b1f7d..5d02d4150e08 100644 --- a/packages/fiori/test/pages/ShellBar_evolution.html +++ b/packages/fiori/test/pages/ShellBar_evolution.html @@ -46,7 +46,7 @@ - - + @@ -102,7 +102,7 @@ - - - - - + + + + - - + - - - - + + + + - + diff --git a/packages/main/src/List.ts b/packages/main/src/List.ts index 9ef8a7e49ebd..991970fa572d 100644 --- a/packages/main/src/List.ts +++ b/packages/main/src/List.ts @@ -945,6 +945,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); } @@ -1479,6 +1481,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,