diff --git a/packages/main/bundle.common.js b/packages/main/bundle.common.js index 2b0b47eb9cef..998227c0a7be 100644 --- a/packages/main/bundle.common.js +++ b/packages/main/bundle.common.js @@ -33,6 +33,7 @@ import "./dist/features/ColorPaletteMoreColors.js"; import Avatar from "./dist/Avatar.js"; import AvatarGroup from "./dist/AvatarGroup.js"; import Badge from "./dist/Badge.js"; +import Breadcrumbs from "./dist/Breadcrumbs.js"; import BusyIndicator from "./dist/BusyIndicator.js"; import Button from "./dist/Button.js"; import Card from "./dist/Card.js"; diff --git a/packages/main/src/Breadcrumbs.hbs b/packages/main/src/Breadcrumbs.hbs new file mode 100644 index 000000000000..ed8a69d769e6 --- /dev/null +++ b/packages/main/src/Breadcrumbs.hbs @@ -0,0 +1,41 @@ + diff --git a/packages/main/src/Breadcrumbs.js b/packages/main/src/Breadcrumbs.js new file mode 100644 index 000000000000..3035a4439cfc --- /dev/null +++ b/packages/main/src/Breadcrumbs.js @@ -0,0 +1,520 @@ +import ItemNavigation from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js"; +import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js"; +import { + isSpace, + isShow, +} from "@ui5/webcomponents-base/dist/Keys.js"; +import Integer from "@ui5/webcomponents-base/dist/types/Integer.js"; +import { getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; +import NavigationMode from "@ui5/webcomponents-base/dist/types/NavigationMode.js"; +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import BreadcrumbsDesign from "./types/BreadcrumbsDesign.js"; +import BreadcrumbsSeparatorStyle from "./types/BreadcrumbsSeparatorStyle.js"; + +// Template +import BreadcrumbsTemplate from "./generated/templates/BreadcrumbsTemplate.lit.js"; +import BreadcrumbsPopoverTemplate from "./generated/templates/BreadcrumbsPopoverTemplate.lit.js"; + +import { + BREADCRUMBS_ARIA_LABEL, + BREADCRUMBS_OVERFLOW_ARIA_LABEL, + BREADCRUMBS_CANCEL_BUTTON, +} from "./generated/i18n/i18n-defaults.js"; + +// Styles +import breadcrumbsCss from "./generated/themes/Breadcrumbs.css.js"; +import breadcrumbsPopoverCss from "./generated/themes/BreadcrumbsPopover.css.js"; + +/** + * @public + */ +const metadata = { + tag: "ui5-breadcrumbs", + managedSlots: true, + languageAware: true, + slots: /** @lends sap.ui.webcomponents.main.Breadcrumbs.prototype */ { + + /** + * Defines the links of the ui5-breadcrumbs. + *

+ * Note: Use ui5-link component to define the required anchors. + * + * @type {HTMLElement[]} + * @slot + * @public + */ + "default": { + propertyName: "links", + type: HTMLElement, + individualSlots: true, + invalidateOnChildChange: { + properties: false, + slots: true, + }, + }, + }, + properties: /** @lends sap.ui.webcomponents.main.Breadcrumbs.prototype */ { + + /** + * Defines the visual indication and behavior of the breadcrumbs. + * Available options are Standard (by default) and NoCurrentPage. + *

+ * Note: The Standard breadcrumbs show the current page as the last item in the trail. + * The last item contains only plain text and not a link. + * + * @type {BreadcrumbsDesign} + * @defaultvalue "Standard" + * @public + */ + design: { + type: BreadcrumbsDesign, + defaultValue: BreadcrumbsDesign.Standard, + }, + + /** + * Determines the visual style of the separator between the Breadcrumbs elements. + * + * @type {BreadcrumbsSeparatorStyle} + * @defaultvalue "Slash" + * @public + */ + separatorStyle: { + type: BreadcrumbsSeparatorStyle, + defaultValue: BreadcrumbsSeparatorStyle.Slash, + }, + + /** + * Defines the ui5-breadcrumbs current location text. + * + * @type {string} + * @defaultvalue "" + * @private + */ + _endingLabelText: { + type: String, + noAttribute: true, + defaultValue: "", + }, + + /** + * Holds the number of link-items in the overflow + * + * @type {string} + * @defaultvalue "0" + * @private + */ + _countLinksInOverflow: { + type: Integer, + noAttribute: true, + defaultValue: 0, + }, + + }, + events: /** @lends sap.ui.webcomponents.main.Breadcrumbs.prototype */ { + + /** + * Fired when a link is activated. + * + * @event sap.ui.webcomponents.main.Breadcrumbs#item-click + * @param {HTMLElement} item The clicked item. + * @public + */ + "link-click": { + detail: { + link: { type: HTMLElement }, + }, + }, + }, +}; + +/** + * @class + * + *

Overview

+ * + * + * @constructor + * @author SAP SE + * @alias sap.ui.webcomponents.main.Breadcrumbs + * @extends UI5Element + * @tagname ui5-breadcrumbs + * @public + */ +class Breadcrumbs extends UI5Element { + static get metadata() { + return metadata; + } + + static get render() { + return litRender; + } + + static get template() { + return BreadcrumbsTemplate; + } + + static get staticAreaTemplate() { + return BreadcrumbsPopoverTemplate; + } + + static get styles() { + return breadcrumbsCss; + } + + static get staticAreaStyles() { + return breadcrumbsPopoverCss; + } + + constructor() { + super(); + this.i18nBundle = getI18nBundle("@ui5/webcomponents"); + this.initItemNavigation(); + + this._onContainerResizeHandler = this._onContainerResize.bind(this); + this._onContentResizeHandler = this._onContentResize.bind(this); + + // maps links to their widths + this._linkWidths = new WeakMap(); + // the width of the interactive element that opens the overflow + this._overflowBtnWidth = 0; + // a list of the two elements that wrap the breadcrumps content + // and allow to monitor content-resize + this._contentWrappers = null; + } + + onAfterRendering() { + this._endingLabelText = this._parseEndingLabelText(); + this._contentWrappers = this.shadowRoot.querySelectorAll(".ui5-breadcrumbs-root > ol"); + this._cacheWidths(); + this._updateOverflow(); + } + + onEnterDOM() { + ResizeHandler.register(this, this._onContainerResizeHandler); + this._contentWrappers.forEach(el => ResizeHandler.register(el, this._onContentResizeHandler)); + } + + onExitDOM() { + ResizeHandler.deregister(this, this._onContainerResizeHandler); + if (this._contentWrappers) { + this._contentWrappers.forEach(el => ResizeHandler.deregister(el, this._onContentResizeHandler)); + } + } + + initItemNavigation() { + if (!this._itemNavigation) { + this._itemNavigation = new ItemNavigation(this, { + navigationMode: NavigationMode.Horizontal, + getItemsCallback: () => this._getFocusableItems(), + }); + } + } + + /** + * Obtains the items for navigation via keyboard + * @private + */ + _getFocusableItems() { + const items = this._nonOverflowingLinks; + + if (this._hasLinksInOverflow) { + items.unshift(this._overflowBtn); + } + if (this._endingLabelComponent) { + items.push(this._endingLabelComponent); + } + return items; + } + + _onfocusin(event) { + this._itemNavigation.setCurrentItem(event.target); + } + + _onkeydown(event) { + if (isShow(event)) { + event.preventDefault(); + this._toggleRespPopover(); + } + if (isSpace(event) && !this._isPickerOpen) { + event.preventDefault(); + } + } + + _onkeyup(event) { + if (isSpace(event) && !this._isPickerOpen) { + this._toggleRespPopover(); + } + } + + _onContainerResize() { + this._updateOverflow(); + } + + _onContentResize() { + this._endingLabelText = this._parseEndingLabelText(); + this._cacheWidths(); + this._updateOverflow(); + } + + /** + * Caches the space required to render the content + * @private + */ + _cacheWidths() { + const map = this._linkWidths; + const linkItems = this.shadowRoot.querySelectorAll(".ui5-breadcrumbs-link-wrapper"); + + for (let i = 0; i < linkItems.length; i++) { + const link = linkItems[i].querySelector("slot").assignedElements()[0]; + map.set(link, this._getElementWidth(linkItems[i])); + } + + if (this._hasLinksInOverflow) { + const overflow = this.shadowRoot.querySelector(".ui5-breadcrumbs-overflow-opener"); + this._overflowBtnWidth = this._getElementWidth(overflow); + } + } + + _updateOverflow() { + const links = this._interactiveLinks.filter(link => !link.hidden), + availableWidth = this.shadowRoot.querySelector(".ui5-breadcrumbs-root").offsetWidth; + let requiredWidth = this._getTotalContentWidth(), + countLinksInOverflow = 0; + + if (requiredWidth > availableWidth) { + // need to show the overflow button as well + requiredWidth += this._overflowBtnWidth; + } + + while ((requiredWidth > availableWidth) && (countLinksInOverflow < this._maxCountLinksInOverflow)) { + const linkToOverflow = links[countLinksInOverflow], + linkWidth = this._linkWidths.get(linkToOverflow) || 0; + + // move the link to the overflow + requiredWidth -= linkWidth; + countLinksInOverflow++; + } + + this._countLinksInOverflow = countLinksInOverflow; + + // if overflow was emptied while picker was open => close redundant view + if (this._countLinksInOverflow === 0 && this._isPickerOpen) { + this._toggleRespPopover(); + } + + // if the last focused link has done into the overflow => + // ensure the first visible link is focusable + const focusableItems = this._getFocusableItems(); + if (!focusableItems.some(x => x._tabIndex === "0")) { + this._itemNavigation.setCurrentItem(focusableItems[0]); + } + + links.forEach(link => link.classList.toggle("ui5-breadcrumbs-empty-link", !link.innerHTML)); + } + + _getElementWidth(element) { + return Math.ceil(element.getBoundingClientRect().width); + } + + _getTotalContentWidth() { + const links = this._interactiveLinks, + map = this._linkWidths, + totalLinksWidth = links.reduce((sum, link) => sum + map.get(link), 0), + currentLocationWidth = this._endingLabelComponent ? this._getElementWidth(this._endingLabelComponent) : 0; + + return totalLinksWidth + currentLocationWidth; + } + + _parseEndingLabelText() { + const link = this._endingLabelLink; + return (link) ? link.innerText : ""; + } + + _onLinkClick(event) { + const link = event.target; + this.fireEvent("link-click", { link }); + } + + _getSelectedItemIndex(item) { + return [].indexOf.call(item.parentElement.children, item); + } + + _onOverflowListItemSelect(event) { + const item = event.detail.item, + selectedItemIndex = this._getSelectedItemIndex(item), + selectedLink = this._overflowingLinks[selectedItemIndex], + selectedLinkTarget = selectedLink.target || "_self", + windowFeatures = (selectedLinkTarget !== "_self") ? "noopener,noreferrer" : ""; + + window.open(selectedLink.href, selectedLinkTarget, windowFeatures); + this._toggleRespPopover(); + this.fireEvent("link-click", { link: selectedLink }); + } + + /** + * Returns all slotted links except the link that represents the ending label + */ + get _interactiveLinks() { + const links = this.getSlottedNodes("links"); + if (this._endsWithCurrentLocationLabel) { + links.pop(); + } + return links; + } + + get _endsWithCurrentLocationLabel() { + return this.design === BreadcrumbsDesign.Standard; + } + + get _endingLabelComponent() { + return this.shadowRoot.querySelector(".ui5-breadcrumbs-current-location ui5-label"); + } + + /** + * Returns the maximum allowed count of links in the overflow + */ + get _maxCountLinksInOverflow() { + const interactiveLinks = this._interactiveLinks.filter(link => !link.hidden); + // UX requirement: the last visible breadcrumbs item should never overflow + // so all items before the last visible are allowed to overflow + if (this.design === BreadcrumbsDesign.Standard) { + // the last visible item is the label + // => all interactive links are allowed to overflow + return interactiveLinks.length; + } + return interactiveLinks.length - 1; + } + + async _respPopover() { + const staticAreaItem = await this.getStaticAreaItemDomRef(); + return staticAreaItem.querySelector("[ui5-responsive-popover]"); + } + + async _toggleRespPopover() { + this.responsivePopover = await this._respPopover(); + + if (this._isPickerOpen) { + this.responsivePopover.close(); + } else { + this.responsivePopover.openBy(this); + } + } + + get _endingLabelLink() { + let lastLink; + if (this._endsWithCurrentLocationLabel) { + const links = this.getSlottedNodes("links"); + lastLink = links[links.length - 1]; + } + return lastLink; + } + + get _endingLabelLinkIndividualSlot() { + const link = this._endingLabelLink; + if (link) { + return link._individualSlot; + } + return null; + } + + /** + * Getter for the interactive element that opens the overflow + * @private + */ + get _overflowBtn() { + return this.shadowRoot.querySelector(".ui5-breadcrumbs-overflow-opener ui5-link"); + } + + /** + * Getter for the list of links to be rendered outside the overflow + */ + get _nonOverflowingLinks() { + const links = this._interactiveLinks, + indexOfLastOveflowingLink = this._indexOfLastOveflowingLink; + + // if there is at least one overflowing link + // => extract the remaining and return as non-overflowing + if (indexOfLastOveflowingLink > -1) { + return links.slice(indexOfLastOveflowingLink + 1); + } + return links; + } + + /** + * Getter for the list of links to be rendered inside the overflow + */ + get _overflowingLinks() { + const indexOfLastOveflowingLink = this._indexOfLastOveflowingLink; + + if (indexOfLastOveflowingLink > -1) { + return this._interactiveLinks + .slice(0, indexOfLastOveflowingLink + 1) + .reverse(); + } + return []; + } + + /** + * Getter for the list of non-hidden links to be rendered inside the overflow + */ + get _visibleOverflowingLinks() { + return this._interactiveLinks + .filter(link => !link.hidden) + .slice(0, this._countLinksInOverflow) + .reverse(); + } + + get _indexOfLastOveflowingLink() { + const visibleOverflowingLinks = this._visibleOverflowingLinks; + let lastVisibleOverflowingLink; + + if (visibleOverflowingLinks.length) { + // visible links appear in reverse order in the dropdown + // => we obtain the first item + lastVisibleOverflowingLink = visibleOverflowingLinks[0]; + return this._getSelectedItemIndex(lastVisibleOverflowingLink); + } + + return -1; + } + + get _ariaHasPopup() { + if (this._hasLinksInOverflow) { + return "listbox"; + } + return undefined; + } + + get _isPickerOpen() { + return !!this.responsivePopover && this.responsivePopover.opened; + } + + get _hasLinksInOverflow() { + return this._countLinksInOverflow > 0; + } + + get _isOverflowEmpty() { + return !this._countLinksInOverflow; + } + + get _accessibleNameText() { + return this.i18nBundle.getText(BREADCRUMBS_ARIA_LABEL); + } + + get _overflowAccessibleNameText() { + return this.i18nBundle.getText(BREADCRUMBS_OVERFLOW_ARIA_LABEL); + } + + get _cancelButtonText() { + return this.i18nBundle.getText(BREADCRUMBS_CANCEL_BUTTON); + } + + static get dependencies() { + return []; + } +} + +Breadcrumbs.define(); + +export default Breadcrumbs; diff --git a/packages/main/src/BreadcrumbsPopover.hbs b/packages/main/src/BreadcrumbsPopover.hbs new file mode 100644 index 000000000000..d8b27ee4f495 --- /dev/null +++ b/packages/main/src/BreadcrumbsPopover.hbs @@ -0,0 +1,26 @@ + + + + {{#each _visibleOverflowingLinks}} + + {{this.textContent}} + + {{/each}} + + + \ No newline at end of file diff --git a/packages/main/src/Label.hbs b/packages/main/src/Label.hbs index 7ab809bdd8ca..b04ab0828a4a 100644 --- a/packages/main/src/Label.hbs +++ b/packages/main/src/Label.hbs @@ -3,6 +3,7 @@ dir="{{effectiveDir}}" @click={{_onclick}} for="{{for}}" + tabindex={{tabIndex}} > diff --git a/packages/main/src/Label.js b/packages/main/src/Label.js index e7fd69ed6c41..767966224074 100644 --- a/packages/main/src/Label.js +++ b/packages/main/src/Label.js @@ -71,6 +71,11 @@ const metadata = { "for": { type: String, }, + + _tabIndex: { + type: String, + noAttribute: true, + }, }, slots: /** @lends sap.ui.webcomponents.main.Label.prototype */ { /** @@ -139,6 +144,13 @@ class Label extends UI5Element { }; } + get tabIndex() { + if (this._tabIndex.length) { + return this._tabIndex; + } + return undefined; + } + _onclick() { const elementToFocus = document.getElementById(this.for); if (elementToFocus) { diff --git a/packages/main/src/Link.hbs b/packages/main/src/Link.hbs index 87fcd5bcb112..628dd230aabc 100644 --- a/packages/main/src/Link.hbs +++ b/packages/main/src/Link.hbs @@ -8,6 +8,7 @@ ?disabled="{{disabled}}" aria-label="{{ariaLabelText}}" @focusin={{_onfocusin}} + @focusout={{_onfocusout}} @click={{_onclick}} @keydown={{_onkeydown}} @keyup={{_onkeyup}}> diff --git a/packages/main/src/Link.js b/packages/main/src/Link.js index 152d7bed5449..58c8943f5767 100644 --- a/packages/main/src/Link.js +++ b/packages/main/src/Link.js @@ -19,6 +19,7 @@ import linkCss from "./generated/themes/Link.css.js"; */ const metadata = { tag: "ui5-link", + managedSlots: true, languageAware: true, properties: /** @lends sap.ui.webcomponents.main.Link.prototype */ { @@ -131,6 +132,19 @@ const metadata = { type: String, noAttribute: true, }, + + _tabIndex: { + type: String, + noAttribute: true, + }, + + /** + * Indicates if the elements is on focus + * @private + */ + focused: { + type: Boolean, + }, }, slots: /** @lends sap.ui.webcomponents.main.Link.prototype */ { /** @@ -241,6 +255,9 @@ class Link extends UI5Element { } get tabIndex() { + if (this._tabIndex.length) { + return this._tabIndex; + } return (this.disabled || !this.textContent.length) ? "-1" : "0"; } @@ -277,6 +294,11 @@ class Link extends UI5Element { _onfocusin(event) { event.isMarked = "link"; + this.focused = true; + } + + _onfocusout(event) { + this.focused = false; } _onkeydown(event) { diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index ed43c9d7fded..05c30fc33ba8 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -34,6 +34,15 @@ AVATAR_GROUP_MOVE=Press ARROW keys to move. #XACT: ARIA announcement for the badge BADGE_DESCRIPTION=Badge +#XACT: ARIA announcement for the breadcrumbs +BREADCRUMBS_ARIA_LABEL=Breadcrumb Trail + +#XACT: ARIA announcement for the breadcrumbs overflow button +BREADCRUMBS_OVERFLOW_ARIA_LABEL=More + +#XFLD: Breadcrumbs popover cancel button +BREADCRUMBS_CANCEL_BUTTON=Cancel + #XTOL: text that could be show if BusyIndicator is active BUSY_INDICATOR_TITLE=Please wait diff --git a/packages/main/src/themes/Breadcrumbs.css b/packages/main/src/themes/Breadcrumbs.css new file mode 100644 index 000000000000..fe95779d9323 --- /dev/null +++ b/packages/main/src/themes/Breadcrumbs.css @@ -0,0 +1,102 @@ +.ui5-breadcrumbs-root { + white-space: nowrap; + outline: none; + margin: 0 0 0.5rem 0; +} + +.ui5-breadcrumbs-root > ol { + margin: 0; + padding: 0; + list-style-type: none; +} + +.ui5-breadcrumbs-root > ol, +.ui5-breadcrumbs-root > ol > li { + display: inline-block; +} + +.ui5-breadcrumbs-overflow-opener[hidden] { + display: none +} + +.ui5-breadcrumbs-hidden-overflow { + position: absolute; + top: -10000px; + left: -10000px; +} + +.ui5-breadcrumbs-overflow-opener ui5-icon { + width: 0.875rem; + height: 0.875rem; + padding-left: .675rem; + vertical-align: text-top; + color: var(--sapLinkColor); +} + +.ui5-breadcrumbs-overflow-opener ui5-icon::before { + content: "..."; + vertical-align: middle; + position: absolute; + left: 0; + bottom: 0; +} + +/* icon-decoration on hover */ +.ui5-breadcrumbs-overflow-opener ui5-link[focused] ui5-icon::after, +.ui5-breadcrumbs-overflow-opener:hover ui5-icon::after { + content: ""; + position: absolute; + border-bottom: 0.0625rem solid; + top: 0; + left: 0; + bottom: 1px; + right: 0; +} + +/* links separator */ +ui5-link::after, +::slotted([ui5-link]:not(.ui5-breadcrumbs-empty-link))::after { + content: ""; + padding: 0 .25rem; + cursor: auto; + color: var(--sapContent_LabelColor); + display: flex; + align-items: flex-end; +} + +.ui5-breadcrumbs-popover-footer { + display: flex; + justify-content: flex-end; + width: 100%; +} + +/* separator styles */ +:host([separator-style="Slash"]) ::slotted([ui5-link]:not(.ui5-breadcrumbs-empty-link))::after, +:host([separator-style="Slash"]) ui5-link::after { + content: "/"; +} + +:host([separator-style="BackSlash"]) ::slotted([ui5-link]:not(.ui5-breadcrumbs-empty-link))::after, +:host([separator-style="BackSlash"]) ui5-link::after { + content: "\\"; +} + +:host([separator-style="DoubleBackSlash"]) ::slotted([ui5-link]:not(.ui5-breadcrumbs-empty-link))::after, +:host([separator-style="DoubleBackSlash"]) ui5-link::after { + content: "\\\\"; +} + +:host([separator-style="DoubleGreaterThan"]) ::slotted([ui5-link]:not(.ui5-breadcrumbs-empty-link))::after, +:host([separator-style="DoubleGreaterThan"]) ui5-link::after { + content: ">>"; +} + +:host([separator-style="DoubleSlash"]) ::slotted([ui5-link]:not(.ui5-breadcrumbs-empty-link))::after, +:host([separator-style="DoubleSlash"]) ui5-link::after { + content: "//"; +} + +:host([separator-style="GreaterThan"]) ::slotted([ui5-link]:not(.ui5-breadcrumbs-empty-link))::after, +:host([separator-style="GreaterThan"]) ui5-link::after { + content: ">"; +} \ No newline at end of file diff --git a/packages/main/src/themes/BreadcrumbsPopover.css b/packages/main/src/themes/BreadcrumbsPopover.css new file mode 100644 index 000000000000..a343ec9b1b75 --- /dev/null +++ b/packages/main/src/themes/BreadcrumbsPopover.css @@ -0,0 +1,6 @@ +.ui5-breadcrumbs-popover-footer { + display: flex; + justify-content: flex-end; + width: 100%; + padding-right: 0.5rem; +} diff --git a/packages/main/src/themes/Label.css b/packages/main/src/themes/Label.css index 61e4099148bb..318a489ed93c 100644 --- a/packages/main/src/themes/Label.css +++ b/packages/main/src/themes/Label.css @@ -77,3 +77,9 @@ bdi { margin-right: .125rem; margin-left: 0; } + +.ui5-label-root:focus { + outline-offset: -1px; + outline: 1px dotted var(--sapContent_FocusColor); + text-decoration: underline; +} diff --git a/packages/main/src/types/BreadcrumbsDesign.js b/packages/main/src/types/BreadcrumbsDesign.js new file mode 100644 index 000000000000..c2f5843e2627 --- /dev/null +++ b/packages/main/src/types/BreadcrumbsDesign.js @@ -0,0 +1,42 @@ +import DataType from "@ui5/webcomponents-base/dist/types/DataType.js"; + +/** + * @lends sap.ui.webcomponents.main.types.BreadcrumbsDesign.prototype + * @public + */ +const BreadcrumbsTypes = { + /** + * Shows the current page as the last item in the trail. + * The last item contains only plain text and not a link. + * + * @public + * @type {Standard} + */ + Standard: "Standard", + + /** + * All items in the breadcrumb are links. + * @public + * @type {NoCurrentPage} + */ + NoCurrentPage: "NoCurrentPage", +}; + +/** + * @class + * Different types of Breadcrumbs. + * @constructor + * @author SAP SE + * @alias sap.ui.webcomponents.main.types.BreadcrumbsDesign + * @public + * @enum {string} + */ +class BreadcrumbsDesign extends DataType { + static isValid(value) { + return !!BreadcrumbsTypes[value]; + } +} + +BreadcrumbsDesign.generateTypeAccessors(BreadcrumbsTypes); + +export default BreadcrumbsDesign; diff --git a/packages/main/src/types/BreadcrumbsSeparatorStyle.js b/packages/main/src/types/BreadcrumbsSeparatorStyle.js new file mode 100644 index 000000000000..a4640db99fc0 --- /dev/null +++ b/packages/main/src/types/BreadcrumbsSeparatorStyle.js @@ -0,0 +1,69 @@ +import DataType from "@ui5/webcomponents-base/dist/types/DataType.js"; + +/** + * @lends sap.ui.webcomponents.main.types.BreadcrumbsSeparatorStyle.prototype + * @public + */ +const SeparatorTypes = { + + /** + * The separator will appear as "/" + * @public + * @type {Slash} + */ + Slash: "Slash", + + /** + * The separator will appear as "\" + * @public + * @type {BackSlash} + */ + BackSlash: "BackSlash", + + /** + * The separator will appear as "\\" + * @public + * @type {DoubleBackSlash} + */ + DoubleBackSlash: "DoubleBackSlash", + + /** + * The separator will appear as ">>" + * @public + * @type {DoubleGreaterThan} + */ + DoubleGreaterThan: "DoubleGreaterThan", + + /** + * The separator will appear as "//" + * @public + * @type {DoubleSlash} + */ + DoubleSlash: "DoubleSlash", + + /** + * The separator will appear as ">" + * @public + * @type {GreaterThan} + */ + GreaterThan: "GreaterThan", +}; + +/** + * @class + * Different types of Breadcrumbs separator. + * @constructor + * @author SAP SE + * @alias sap.ui.webcomponents.main.types.BreadcrumbsSeparatorStyle + * @public + * @enum {string} + */ +class BreadcrumbsSeparatorStyle extends DataType { + static isValid(value) { + return !!SeparatorTypes[value]; + } +} + +BreadcrumbsSeparatorStyle.generateTypeAccessors(SeparatorTypes); + +export default BreadcrumbsSeparatorStyle; diff --git a/packages/main/test/pages/Breadcrumbs.html b/packages/main/test/pages/Breadcrumbs.html new file mode 100644 index 000000000000..b2fd963d5407 --- /dev/null +++ b/packages/main/test/pages/Breadcrumbs.html @@ -0,0 +1,144 @@ + + + + + + + + Breadcrumbs + + + + + + + + + + + + + + + + + turn lights +
+
+ + + + +
+ +
+
+ +
+ + + + +
+
+ +

Breadcrumbs with current location

+ + + Link1 + Link2 + Link3 + Location + + +

Breadcrumbs with no current location

+ + + Link1 + Link2 + Link3 + + +
+ +

Breadcrumbs with overflowing links

+ + + Link1 + Link2 + Link3 + Link4 + Link5 + Link6 + aaaaa + Location + +
+ + Last pressed link: + + + + + + + diff --git a/packages/main/test/specs/Breadcrumbs.spec.js b/packages/main/test/specs/Breadcrumbs.spec.js new file mode 100644 index 000000000000..9de9c98757bd --- /dev/null +++ b/packages/main/test/specs/Breadcrumbs.spec.js @@ -0,0 +1,255 @@ +const assert = require("chai").assert; +const PORT = require("./_port.js"); + +describe("Breadcrumbs general interaction", () => { + before(() => { + browser.url(`http://localhost:${PORT}/test-resources/pages/Breadcrumbs.html`); + }); + + it("fires link-click event", () => { + const breadcrumbs = $("#breadcrumbs1"), + link = breadcrumbs.$$("ui5-link")[6]; + + // Act + link.click(); + + // Check + const eventResult = browser.$("#result"); + assert.strictEqual(eventResult.innerText, link.innerText, "label for pressed link is correct"); + }); + + it("fires link-click event when link in overflow", () => { + const breadcrumbs = $("#breadcrumbs1"), + overflowArrowLink = breadcrumbs.shadow$$("ui5-link")[0]; + link = breadcrumbs.$$("ui5-link")[5]; + + + // Act + overflowArrowLink.click(); // open the overflow + + const staticAreaItemClassName = browser.getStaticAreaItemClassName("#breadcrumbs1"); + const firstItem = browser.$(`.${staticAreaItemClassName}`).shadow$$("ui5-li")[0]; + + firstItem.click(); + + // Check + const eventResult = browser.$("#result"); + assert.strictEqual(eventResult.innerText, link.innerText, "label for pressed link is correct"); + }); + + it("updates layout on container resize", () => { + const breadcrumbs = $("#breadcrumbs1"), + shrinkSizeBtn = $("#shrinkSizeBtn"), + countLinksInOverflowBefore = breadcrumbs.getProperty("_countLinksInOverflow"), + expectedCountLinksInOverflowAfter = countLinksInOverflowBefore + 1; + + // Act: shrink the breadcrumbs container + // to cause one more item to overflow + shrinkSizeBtn.click(); + + // Check links inside overflow + assert.strictEqual(breadcrumbs.getProperty("_countLinksInOverflow"), expectedCountLinksInOverflowAfter, "one link is added to the overflow"); + }); + + it("updates layout on resize of content outside overflow", () => { + const breadcrumbs = $("#breadcrumbs1"), + extendLinkTextBtn = $("#extendLinkTextBtn"), + countLinksInOverflowBefore = breadcrumbs.getProperty("_countLinksInOverflow"), + expectedCountLinksInOverflowAfter = countLinksInOverflowBefore + 1; + + // Act: + // extend the length of the last link, + // so that it becomes too big to be rendered outside the overflow + extendLinkTextBtn.click(); + + // Check + assert.strictEqual(breadcrumbs.getProperty("_countLinksInOverflow"), expectedCountLinksInOverflowAfter, "the link is added to the overflow"); + }); + + it("updates layout on resize of content inside overflow", () => { + const breadcrumbs = $("#breadcrumbs1"), + shortenLinkTextBtn = $("#shortenLinkTextBtn"), + countLinksInOverflowBefore = breadcrumbs.getProperty("_countLinksInOverflow"), + expectedCountLinksInOverflowAfter = countLinksInOverflowBefore - 1; + + // Act: + // shrink the length of the last link from the overflow, + // to make it small enough => eligible to be moved outside the overflow + shortenLinkTextBtn.click(); + + // Check + assert.strictEqual(breadcrumbs.getProperty("_countLinksInOverflow"), expectedCountLinksInOverflowAfter, "the link is taken out of the overflow"); + }); + + it("updates layout when link content removed", () => { + const breadcrumbs = $("#breadcrumbs1"), + shortenLinkTextBtn = $("#shortenLinkTextBtn"), + link = breadcrumbs.$$("ui5-link")[6]; + + // Check initial state + assert.ok(link.getText().length, "the link has text"); + + // Act: + // shrink the length of the last link to make it empty + shortenLinkTextBtn.click(); + + // verify result => the link is now empty + assert.strictEqual(link.getText(), "", "the link is empty"); + + // Check + assert.strictEqual(breadcrumbs.$("ui5-link.ui5-breadcrumbs-empty-link").id, link.id, "the link is marked as empty"); + }); + + it("updates layout when content added to empty link", () => { + const breadcrumbs = $("#breadcrumbs1"), + extendLinkTextBtn = $("#extendLinkTextBtn"), + link = breadcrumbs.$$("ui5-link")[6]; + + // Check initial state + assert.strictEqual(link.getText(), "", "the link is empty"); + + // Act: + // add content to the empty link + extendLinkTextBtn.click(); + + // verify result => the link is now empty + assert.ok(link.getText().length, "the link has text"); + + // Check + assert.strictEqual(breadcrumbs.$$("ui5-link.ui5-breadcrumbs-empty-link").length, 0, "the link is no longer marked as empty"); + }); + + it("updates layout when a non-overflowing link is hidden", () => { + const breadcrumbs = $("#breadcrumbs1"), + link = breadcrumbs.$$("ui5-link")[6], + countLinksInOverflowBefore = breadcrumbs.getProperty("_countLinksInOverflow"); + + // Check initial state + assert.strictEqual(link.getProperty("hidden"), false, "the link is visible"); + + // Act: + // hide the first link + browser.execute(() => { + document.querySelector("#breadcrumbs1 ui5-link:nth-child(7)").hidden = true; + }); + + // verify result => the link is now hidden + assert.strictEqual(link.getProperty("hidden"), true, "the link is hidden"); + + // Check + assert.ok(breadcrumbs.getProperty("_countLinksInOverflow") < countLinksInOverflowBefore, "a link-item is taken out of the overflow"); + }); + + it("updates layout when a hidden non-overflowing link is made visible", () => { + const breadcrumbs = $("#breadcrumbs1"), + link = breadcrumbs.$$("ui5-link")[6], + countLinksInOverflowBefore = breadcrumbs.getProperty("_countLinksInOverflow"); + + // Check initial state + assert.strictEqual(link.getProperty("hidden"), true, "the link is hidden"); + + // Act: + // show the first link + browser.execute(() => { + document.querySelector("#breadcrumbs1 ui5-link:nth-child(7)").hidden = false; + }); + + // verify result => the link is now visible + assert.strictEqual(link.getProperty("hidden"), false, "the link is visible"); + + // Check + assert.ok(breadcrumbs.getProperty("_countLinksInOverflow") > countLinksInOverflowBefore, "a new link-item is added to the overflow"); + }); + + it("updates layout when an overflowing link is hidden", () => { + const breadcrumbs = $("#breadcrumbs1"), + link = breadcrumbs.$$("ui5-link")[0], + countLinksInOverflowBefore = breadcrumbs.getProperty("_countLinksInOverflow"), + expectedCountLinksInOverflowAfter = countLinksInOverflowBefore - 1; + + // Check initial state + assert.strictEqual(link.getProperty("hidden"), false, "the link is visible"); + + // Act: + // hide the first link + browser.execute(() => { + document.querySelector("#breadcrumbs1 ui5-link:nth-child(1)").hidden = true; + }); + + // verify result => the link is now hidden + assert.strictEqual(link.getProperty("hidden"), true, "the link is hidden"); + + // Check + assert.strictEqual(breadcrumbs.getProperty("_countLinksInOverflow"), expectedCountLinksInOverflowAfter, "the link-item is taken out of the overflow"); + }); + + it("updates layout when a hidden overflowing link is made visible", () => { + const breadcrumbs = $("#breadcrumbs1"), + link = breadcrumbs.$$("ui5-link")[0], + countLinksInOverflowBefore = breadcrumbs.getProperty("_countLinksInOverflow"), + expectedCountLinksInOverflowAfter = countLinksInOverflowBefore + 1; + + // Check initial state + assert.strictEqual(link.getProperty("hidden"), true, "the link is hidden"); + + // Act: + // show the first link + browser.execute(() => { + document.querySelector("#breadcrumbs1 ui5-link:nth-child(1)").hidden = false; + }); + + // verify result => the link is now visible + assert.strictEqual(link.getProperty("hidden"), false, "the link is visible"); + + // Check + assert.strictEqual(breadcrumbs.getProperty("_countLinksInOverflow"), expectedCountLinksInOverflowAfter, "the link-item is added to the overflow"); + }); + + it("opens upon space", () => { + browser.url(`http://localhost:${PORT}/test-resources/pages/Breadcrumbs.html`); + + const externalElement = $("#breadcrumbs3").$$("ui5-link")[2]; + const staticAreaItemClassName = browser.getStaticAreaItemClassName("#breadcrumbs1"); + const popover = browser.$(`.${staticAreaItemClassName}`).shadow$("ui5-responsive-popover"); + + externalElement.click(); + externalElement.keys("Tab"); + + browser.keys("Space"); + assert.ok(popover.getProperty("opened"), "Dropdown is opened."); + }); + + it("toggles upon F4", () => { + browser.url(`http://localhost:${PORT}/test-resources/pages/Breadcrumbs.html`); + + const externalElement = $("#breadcrumbs3").$$("ui5-link")[2]; + const staticAreaItemClassName = browser.getStaticAreaItemClassName("#breadcrumbs1"); + const popover = browser.$(`.${staticAreaItemClassName}`).shadow$("ui5-responsive-popover"); + + externalElement.click(); + externalElement.keys("Tab"); + + browser.keys("F4"); + assert.ok(popover.getProperty("opened"), "Dropdown is opened."); + + browser.keys("F4"); + assert.ok(!popover.getProperty("opened"), "Dropdown is closed."); + }); + + it("toggles upon ALT + DOWN", () => { + browser.url(`http://localhost:${PORT}/test-resources/pages/Breadcrumbs.html`); + + const externalElement = $("#breadcrumbs3").$$("ui5-link")[2]; + const staticAreaItemClassName = browser.getStaticAreaItemClassName("#breadcrumbs1"); + const popover = browser.$(`.${staticAreaItemClassName}`).shadow$("ui5-responsive-popover"); + + externalElement.click(); + externalElement.keys("Tab"); + + browser.keys(["Alt", "ArrowDown", "NULL"]); + assert.ok(popover.getProperty("opened"), "Dropdown is opened."); + + browser.keys(["Alt", "ArrowDown", "NULL"]); + assert.ok(!popover.getProperty("opened"), "Dropdown is closed."); + }); +});