diff --git a/.github/workflows/test-storybook.yaml b/.github/workflows/test-storybook.yaml new file mode 100644 index 000000000000..5e89465cf177 --- /dev/null +++ b/.github/workflows/test-storybook.yaml @@ -0,0 +1,23 @@ +name: CI - Storybook + +on: + pull_request: + push: + branches: + - 'main' +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'yarn' + + - name: Install and Build + run: | + export NODE_OPTIONS="--max_old_space_size=4096" + yarn install + yarn build:playground diff --git a/packages/base/index.js b/packages/base/index.js index 1c3c405bd293..3eb60551aa4a 100644 --- a/packages/base/index.js +++ b/packages/base/index.js @@ -1,7 +1,7 @@ // animations/ import scroll from "./dist/animations/scroll.js"; import slideDown from "./dist/animations/slideDown.js"; -import slideUp from "./dist/animations/slideup.js"; +import slideUp from "./dist/animations/slideUp.js"; // config/ import { getAnimationMode, setAnimationMode } from "./dist/config/AnimationMode.js"; diff --git a/packages/base/package.json b/packages/base/package.json index bfb0b0846854..8733fcb0d644 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -38,7 +38,7 @@ "@buxlabs/amd-to-es6": "0.16.1", "@openui5/sap.ui.core": "1.116.0", "@ui5/webcomponents-tools": "1.17.0-rc.2", - "chromedriver": "114.0.0", + "chromedriver": "116.0.0", "clean-css": "^5.2.2", "copy-and-watch": "^0.1.5", "cross-env": "^7.0.3", diff --git a/packages/base/src/connectToComponent.ts b/packages/base/src/connectToComponent.ts new file mode 100644 index 000000000000..b374ff467903 --- /dev/null +++ b/packages/base/src/connectToComponent.ts @@ -0,0 +1,77 @@ +import type UI5Element from "./UI5Element.js"; +import { renderDeferred } from "./Render.js"; +import { Interval } from "./types.js"; + +const MISSING_ELEMENT_POLL_TIMEOUT = 500; // how often to poll for not-yet-in-DOM friend elements +const connections = new Map(); +const intervals = new Map(); + +type ConnectOptions = { + host: UI5Element; + propName: string; + onConnect?: (friend: HTMLElement) => void; + onDisconnect?: (friend: HTMLElement) => void; +} + +const connectToComponent = (options: ConnectOptions): HTMLElement | undefined => { + const host = options.host; + const propName = options.propName; + const friend = host[propName as keyof typeof host] as HTMLElement | string | undefined; + + let connectedTo: HTMLElement | undefined; + if (friend === undefined || friend === "") { + connectedTo = undefined; // do not return early even if a "menu" property is not set - it may have been set before and cleanup must run + } else if (friend instanceof HTMLElement) { + connectedTo = friend; + } else { + const rootNode = host.getRootNode() as Document; + connectedTo = (rootNode.getElementById && rootNode.getElementById(friend)) || undefined; + } + + const key = `${host._id}-${propName}`; + const prevConnectedTo = connections.get(key); + + // Not connected - return undefined + if (!connectedTo) { + if (prevConnectedTo) { // but first disconnect, if needed + options.onDisconnect && options.onDisconnect(prevConnectedTo); + connections.delete(key); + } + + // if friend element not in DOM yet, start polling + if (typeof friend === "string" && friend && !intervals.has(key)) { + const interval = setInterval(() => { + const rootNode = host.getRootNode() as Document; + const found = (rootNode.getElementById && rootNode.getElementById(friend)); + + if (found) { + clearInterval(intervals.get(key)); + intervals.delete(key); + renderDeferred(host); + } + }, MISSING_ELEMENT_POLL_TIMEOUT); + intervals.set(key, interval); + } + + return; + } + + // If connected, but still polling, stop polling + if (intervals.has(key)) { + clearInterval(intervals.get(key)); + intervals.delete(key); + } + + // Connected - either for the first time, or to something else + if (prevConnectedTo !== connectedTo) { + if (prevConnectedTo) { + options.onDisconnect && options.onDisconnect(prevConnectedTo); + } + options.onConnect && options.onConnect(connectedTo); + connections.set(key, connectedTo); + } + + return connections.get(key); +}; + +export default connectToComponent; diff --git a/packages/base/src/theming/applyTheme.ts b/packages/base/src/theming/applyTheme.ts index e42f398a3fb6..6e4688d6efb9 100644 --- a/packages/base/src/theming/applyTheme.ts +++ b/packages/base/src/theming/applyTheme.ts @@ -4,7 +4,7 @@ import getThemeDesignerTheme from "./getThemeDesignerTheme.js"; import { fireThemeLoaded } from "./ThemeLoaded.js"; import { getFeature } from "../FeaturesRegistry.js"; import { attachCustomThemeStylesToHead, getThemeRoot } from "../config/ThemeRoot.js"; -import OpenUI5Support from "../features/OpenUI5Support.js"; +import type OpenUI5Support from "../features/OpenUI5Support.js"; import { DEFAULT_THEME } from "../generated/AssetParameters.js"; import { getCurrentRuntimeIndex } from "../Runtimes.js"; @@ -56,7 +56,7 @@ const detectExternalTheme = async (theme: string) => { // If OpenUI5Support is enabled, try to find out if it loaded variables const openUI5Support = getFeature("OpenUI5Support"); - if (openUI5Support && OpenUI5Support.isOpenUI5Detected()) { + if (openUI5Support && openUI5Support.isOpenUI5Detected()) { const varsLoaded = openUI5Support.cssVariablesLoaded(); if (varsLoaded) { return { diff --git a/packages/fiori/package.json b/packages/fiori/package.json index 90e6c86fe97f..1d9ced3b822e 100644 --- a/packages/fiori/package.json +++ b/packages/fiori/package.json @@ -49,6 +49,6 @@ }, "devDependencies": { "@ui5/webcomponents-tools": "1.17.0-rc.2", - "chromedriver": "114.0.0" + "chromedriver": "116.0.0" } } diff --git a/packages/fiori/src/ShellBarItem.ts b/packages/fiori/src/ShellBarItem.ts index cd8fb9bbb0f2..62ada4889dcf 100644 --- a/packages/fiori/src/ShellBarItem.ts +++ b/packages/fiori/src/ShellBarItem.ts @@ -32,7 +32,6 @@ type ShellBarItemClickEventDetail = { * @allowPreventDefault * @param {HTMLElement} targetRef DOM ref of the clicked element * @public - * @native */ @event("click", { detail: { @@ -53,6 +52,8 @@ class ShellBarItem extends UI5Element { /** * Defines the item text. + *

+ * Note: The text is only displayed inside the overflow popover list view. * @type {string} * @defaultvalue "" * @name sap.ui.webc.fiori.ShellBarItem.prototype.text diff --git a/packages/localization/package.json b/packages/localization/package.json index 7c03d30c82f4..78fe21935153 100644 --- a/packages/localization/package.json +++ b/packages/localization/package.json @@ -30,7 +30,7 @@ "devDependencies": { "@openui5/sap.ui.core": "1.116.0", "@ui5/webcomponents-tools": "1.17.0-rc.2", - "chromedriver": "114.0.0", + "chromedriver": "116.0.0", "mkdirp": "^1.0.4", "resolve": "^1.20.0" }, diff --git a/packages/main/bundle.common.js b/packages/main/bundle.common.js index 2dea95e78e52..daf14400b286 100644 --- a/packages/main/bundle.common.js +++ b/packages/main/bundle.common.js @@ -60,6 +60,8 @@ import ResponsivePopover from "./dist/ResponsivePopover.js"; import SegmentedButton from "./dist/SegmentedButton.js"; import SegmentedButtonItem from "./dist/SegmentedButtonItem.js"; import Select from "./dist/Select.js"; +import SelectMenu from "./dist/SelectMenu.js"; +import SelectMenuOption from "./dist/SelectMenuOption.js"; import Slider from "./dist/Slider.js"; import SplitButton from "./dist/SplitButton.js"; import StepInput from "./dist/StepInput.js"; @@ -85,6 +87,11 @@ import TimeSelectionClocks from "./dist/TimeSelectionClocks.js"; import Title from "./dist/Title.js"; import Toast from "./dist/Toast.js"; import ToggleButton from "./dist/ToggleButton.js"; +import Toolbar from "./dist/Toolbar.js"; +import ToolbarButton from "./dist/ToolbarButton.js"; +import ToolbarSeparator from "./dist/ToolbarSeparator.js"; +import ToolbarSpacer from "./dist/ToolbarSpacer.js"; +import ToolbarSelect from "./dist/ToolbarSelect.js"; import Tree from "./dist/Tree.js"; import TreeList from "./dist/TreeList.js"; import TreeItem from "./dist/TreeItem.js"; diff --git a/packages/main/package.json b/packages/main/package.json index b5b6da951073..1a8143660549 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -50,6 +50,6 @@ }, "devDependencies": { "@ui5/webcomponents-tools": "1.17.0-rc.2", - "chromedriver": "114.0.0" + "chromedriver": "116.0.0" } } diff --git a/packages/main/src/CustomListItem.ts b/packages/main/src/CustomListItem.ts index e82a30849fe4..7f4373de0206 100644 --- a/packages/main/src/CustomListItem.ts +++ b/packages/main/src/CustomListItem.ts @@ -16,6 +16,20 @@ import customListItemCss from "./generated/themes/CustomListItem.css.js"; * * The component accepts arbitrary HTML content to allow full customization. * + *

CSS Shadow Parts

+ * + * CSS Shadow Parts allow developers to style elements inside the Shadow DOM. + *
+ * The ui5-li-custom exposes the following CSS Shadow Parts: + *
    + *
  • native-li - Used to style the main li tag of the list item
  • + *
  • content - Used to style the content area of the list item
  • + *
  • detail-button - Used to style the button rendered when the list item is of type detail
  • + *
  • delete-button - Used to style the button rendered when the list item is in delete mode
  • + *
  • radio - Used to style the radio button rendered when the list item is in single selection mode
  • + *
  • checkbox - Used to style the checkbox rendered when the list item is in multiple selection mode
  • + *
+ * * @constructor * @author SAP SE * @alias sap.ui.webc.main.CustomListItem diff --git a/packages/main/src/Interfaces.ts b/packages/main/src/Interfaces.ts index b296ae536ffd..6240ff43baaf 100644 --- a/packages/main/src/Interfaces.ts +++ b/packages/main/src/Interfaces.ts @@ -133,6 +133,15 @@ const ISegmentedButtonItem = "sap.ui.webc.main.ISegmentedButtonItem"; */ const ISelectOption = "sap.ui.webc.main.ISelectOption"; +/** + * Interface for components that may be slotted inside ui5-select-menu as options + * + * @name sap.ui.webc.main.ISelectMenuOption + * @interface + * @public + */ +const ISelectMenuOption = "sap.ui.webc.main.ISelectMenuOption"; + /** * Interface for components that may be slotted inside ui5-tabcontainer * @@ -187,6 +196,24 @@ const IToken = "sap.ui.webc.main.IToken"; */ const ITreeItem = "sap.ui.webc.main.ITreeItem"; +/** + * Interface for toolbar items for the purpose of ui5-toolbar + * + * @name sap.ui.webc.main.IToolbarItem + * @interface + * @public + */ +const IToolbarItem = "sap.ui.webc.main.IToolbarItem"; + +/** + * Interface for toolbar select items for the purpose of ui5-toolbar-select + * + * @name sap.ui.webc.main.IToolbarSelectOption + * @interface + * @public + */ +const IToolbarSelectOption = "sap.ui.webc.main.IToolbarSelectOption"; + export { IAvatar, IBreadcrumbsItem, @@ -203,10 +230,13 @@ export { IMultiComboBoxItem, ISegmentedButtonItem, ISelectOption, + ISelectMenuOption, ITab, ITableCell, ITableColumn, ITableRow, IToken, ITreeItem, + IToolbarItem, + IToolbarSelectOption, }; diff --git a/packages/main/src/ListItem.hbs b/packages/main/src/ListItem.hbs index 51d9b7886018..a68cfa30000c 100644 --- a/packages/main/src/ListItem.hbs +++ b/packages/main/src/ListItem.hbs @@ -43,7 +43,7 @@ {{#if typeDetail}}
- {{_text}} + {{#if hasCustomLabel}} + + {{else}} + {{_text}} + {{/if}}

+ * Note: Usage of ui5-select-menu is recommended. + * + * @type {sap.ui.webc.base.types.DOMReference} + * @defaultvalue undefined + * @name sap.ui.webc.main.Select.prototype.menu + * @public + * @since 1.17.0 + */ + @property({ validator: DOMReference }) + menu?: HTMLElement | string; + /** * Defines whether the component is in disabled state. *

@@ -293,11 +331,17 @@ class Select extends UI5Element implements IFormElement { @property({ type: Boolean }) focused!: boolean; + /** + * @type {sap.ui.webc.base.types.Integer} + * @private + */ + @property({ validator: Integer, defaultValue: -1, noAttribute: true }) + _selectedIndex!: number; + _syncedOptions: Array; - _selectedIndex: number; _selectedIndexBeforeOpen: number; _escapePressed: boolean; - _lastSelectedOption: Option | null; + _lastSelectedOption: IOption | null; _typedChars: string; _typingTimeoutID?: Timeout | number; responsivePopover!: ResponsivePopover; @@ -305,6 +349,8 @@ class Select extends UI5Element implements IFormElement { valueStatePopover?: Popover; value!: string; + selectMenu?: SelectMenu; + /** * Defines the component options. * @@ -351,19 +397,62 @@ class Select extends UI5Element implements IFormElement { @slot() valueStateMessage!: Array; + /** + * Defines the HTML element that will be displayed in the component input part, + * representing the selected option. + *

+ * + * Note: If not specified and ui5-select-menu-option is used, + * either the option's display-text or its textContent will be displayed. + *

+ * + * Note: If not specified and ui5-opton is used, + * the option's textContent will be displayed. + * + * @type {HTMLElement[]} + * @name sap.ui.webc.main.Select.prototype.label + * @slot label + * @public + * @since 1.17.0 + */ + @slot() + label!: Array; + + _onMenuClick: (e: CustomEvent) => void; + _onMenuClose: () => void; + _onMenuOpen: () => void; + _onMenuBeforeOpen: () => void; + _onMenuChange: (e: CustomEvent) => void; + _attachMenuListeners: (menu: HTMLElement) => void; + _detachMenuListeners: (menu: HTMLElement) => void; + constructor() { super(); this._syncedOptions = []; - this._selectedIndex = -1; this._selectedIndexBeforeOpen = -1; this._escapePressed = false; this._lastSelectedOption = null; this._typedChars = ""; + + this._onMenuClick = this.onMenuClick.bind(this); + this._onMenuClose = this.onMenuClose.bind(this); + this._onMenuOpen = this.onMenuOpen.bind(this); + this._onMenuBeforeOpen = this.onMenuBeforeOpen.bind(this); + this._onMenuChange = this.onMenuChange.bind(this); + this._attachMenuListeners = this.attachMenuListeners.bind(this); + this._detachMenuListeners = this.detachMenuListeners.bind(this); } onBeforeRendering() { - this._syncSelection(); + const menu = this._getSelectMenu(); + + if (menu) { + menu.value = this.value; + } else { + this._syncSelection(); + } + this._enableFormSupport(); this.style.setProperty(getScopedVarName("--_ui5-input-icons-count"), `${this.iconsCount}`); @@ -390,6 +479,12 @@ class Select extends UI5Element implements IFormElement { } get _isPickerOpen() { + const menu = this._getSelectMenu(); + + if (menu) { + return menu.open; + } + return !!this.responsivePopover && this.responsivePopover.opened; } @@ -402,20 +497,70 @@ class Select extends UI5Element implements IFormElement { * Currently selected ui5-option element. * @readonly * @type {sap.ui.webc.main.ISelectOption} - * @name sap.ui.webc.main.Select.prototype.selectedOption + * @name sap.ui.webc.main.Select.prototype.selectedOption * @public */ get selectedOption() { - return this._filteredItems.find(option => option.selected); + return this.selectOptions.find(option => option.selected); + } + + onMenuClick(e: CustomEvent) { + const optionIndex: number = e.detail.optionIndex; + this._handleSelectionChange(optionIndex); + } + + onMenuBeforeOpen() { + this._beforeOpen(); + } + + onMenuOpen() { + this._afterOpen(); + } + + onMenuClose() { + this._afterClose(); + } + + onMenuChange(e: CustomEvent) { + this._text = e.detail.text; + this._selectedIndex = e.detail.selectedIndex; + } + + _toggleSelectMenu() { + const menu = this._getSelectMenu(); + + if (!menu) { + return; + } + + if (menu.open) { + menu.close(); + } else { + menu.showAt(this, this.offsetWidth); + } + } + + onExitDOM(): void { + const menu = this._getSelectMenu(); + if (menu) { + this._detachMenuListeners(menu); + } } async _toggleRespPopover() { - this._iconPressed = true; - this.responsivePopover = await this._respPopover(); if (this.disabled) { return; } + this._iconPressed = true; + + const menu = this._getSelectMenu(); + if (menu) { + this._toggleSelectMenu(); + return; + } + + this.responsivePopover = await this._respPopover(); if (this._isPickerOpen) { this.responsivePopover.close(); } else { @@ -483,6 +628,35 @@ class Select extends UI5Element implements IFormElement { this._syncedOptions = syncOpts as Array; } + _getSelectMenu(): SelectMenu | undefined { + return connectToComponent({ + host: this, + propName: "menu", + onConnect: this._attachMenuListeners, + onDisconnect: this._detachMenuListeners, + }) as SelectMenu; + } + + attachMenuListeners(menu: HTMLElement) { + menu.addEventListener("ui5-after-close", this._onMenuClose); + menu.addEventListener("ui5-after-open", this._onMenuOpen); + menu.addEventListener("ui5-before-open", this._onMenuBeforeOpen); + // @ts-ignore + menu.addEventListener("ui5-option-click", this._onMenuClick); + // @ts-ignore + menu.addEventListener("ui5-menu-change", this._onMenuChange); + } + + detachMenuListeners(menu: HTMLElement) { + menu.removeEventListener("ui5-after-close", this._onMenuClose); + menu.removeEventListener("ui5-after-open", this._onMenuOpen); + menu.removeEventListener("ui5-before-open", this._onMenuBeforeOpen); + // @ts-ignore + menu.removeEventListener("ui5-option-click", this._onMenuClick); + // @ts-ignore + menu.removeEventListener("ui5-menu-change", this._onMenuChange); + } + _enableFormSupport() { const formSupport = getFeature("FormSupport"); if (formSupport) { @@ -499,11 +673,14 @@ class Select extends UI5Element implements IFormElement { _onkeydown(e: KeyboardEvent) { const isTab = (isTabNext(e) || isTabPrevious(e)); - if (isTab && this.responsivePopover && this.responsivePopover.opened) { - this.responsivePopover.close(); - } - - if (isShow(e)) { + if (isTab && this._isPickerOpen) { + const menu = this._getSelectMenu(); + if (menu) { + menu.close(false, false, true /* preventFocusRestore */); + } else { + this.responsivePopover.close(); + } + } else if (isShow(e)) { e.preventDefault(); this._toggleRespPopover(); } else if (isSpace(e)) { @@ -551,7 +728,7 @@ class Select extends UI5Element implements IFormElement { const itemToSelect = this._searchNextItemByText(text); if (itemToSelect) { - const nextIndex = this._filteredItems.indexOf(itemToSelect); + const nextIndex = this.selectOptions.indexOf(itemToSelect); this._changeSelectedItem(this._selectedIndex, nextIndex); @@ -562,13 +739,13 @@ class Select extends UI5Element implements IFormElement { } _searchNextItemByText(text: string) { - let orderedOptions = this._filteredItems.slice(0); + let orderedOptions = this.selectOptions.slice(0); const optionsAfterSelected = orderedOptions.splice(this._selectedIndex + 1, orderedOptions.length - this._selectedIndex); const optionsBeforeSelected = orderedOptions.splice(0, orderedOptions.length - 1); orderedOptions = optionsAfterSelected.concat(optionsBeforeSelected); - return orderedOptions.find(option => (option.textContent || "").toLowerCase().startsWith(text)); + return orderedOptions.find(option => (option.displayText || option.textContent || "").toLowerCase().startsWith(text)); } _handleHomeKey(e: KeyboardEvent) { @@ -577,7 +754,7 @@ class Select extends UI5Element implements IFormElement { } _handleEndKey(e: KeyboardEvent) { - const lastIndex = this._filteredItems.length - 1; + const lastIndex = this.selectOptions.length - 1; e.preventDefault(); this._changeSelectedItem(this._selectedIndex, lastIndex); @@ -594,13 +771,18 @@ class Select extends UI5Element implements IFormElement { } _getSelectedItemIndex(item: ListItemBase) { - return this._filteredItems.findIndex(option => `${option._id}-li` === item.id); + return this.selectOptions.findIndex(option => `${option._id}-li` === item.id); } _select(index: number) { - this._filteredItems[this._selectedIndex].selected = false; + this.selectOptions[this._selectedIndex].selected = false; + + if (this._selectedIndex !== index) { + this.fireEvent("live-change", { selectedOption: this.selectOptions[index] }); + } + this._selectedIndex = index; - this._filteredItems[index].selected = true; + this.selectOptions[index].selected = true; } /** @@ -671,23 +853,33 @@ class Select extends UI5Element implements IFormElement { } _changeSelectedItem(oldIndex: number, newIndex: number) { - const options = this._filteredItems; + const options: Array = this.selectOptions; - options[oldIndex].selected = false; - options[oldIndex]._focused = false; + const previousOption = options[oldIndex]; + previousOption.selected = false; + previousOption._focused = false; + previousOption.focused = false; - options[newIndex].selected = true; - options[newIndex]._focused = true; + const nextOption = options[newIndex]; + nextOption.selected = true; + nextOption._focused = true; + nextOption.focused = true; this._selectedIndex = newIndex; + this.fireEvent("live-change", { selectedOption: nextOption }); + if (!this._isPickerOpen) { // arrow pressed on closed picker - do selection change - this._fireChangeEvent(options[newIndex]); + this._fireChangeEvent(nextOption); } } _getNextOptionIndex() { + const menu = this._getSelectMenu(); + if (menu) { + return this._selectedIndex === (menu.options.length - 1) ? this._selectedIndex : (this._selectedIndex + 1); + } return this._selectedIndex === (this.options.length - 1) ? this._selectedIndex : (this._selectedIndex + 1); } @@ -697,7 +889,7 @@ class Select extends UI5Element implements IFormElement { _beforeOpen() { this._selectedIndexBeforeOpen = this._selectedIndex; - this._lastSelectedOption = this._filteredItems[this._selectedIndex]; + this._lastSelectedOption = this.selectOptions[this._selectedIndex]; } _afterOpen() { @@ -715,19 +907,32 @@ class Select extends UI5Element implements IFormElement { if (this._escapePressed) { this._select(this._selectedIndexBeforeOpen); this._escapePressed = false; - } else if (this._lastSelectedOption !== this._filteredItems[this._selectedIndex]) { - this._fireChangeEvent(this._filteredItems[this._selectedIndex]); - this._lastSelectedOption = this._filteredItems[this._selectedIndex]; + } else if (this._lastSelectedOption !== this.selectOptions[this._selectedIndex]) { + this._fireChangeEvent(this.selectOptions[this._selectedIndex]); + this._lastSelectedOption = this.selectOptions[this._selectedIndex]; } this.fireEvent("close"); } - _fireChangeEvent(selectedOption: Option) { + get selectOptions(): Array { + const menu = this._getSelectMenu(); + if (menu) { + return menu.options; + } + return this._filteredItems; + } + + get hasCustomLabel() { + return !!this.label.length; + } + + _fireChangeEvent(selectedOption: IOption) { const changePrevented = !this.fireEvent("change", { selectedOption }, true); // Angular two way data binding this.selectedItem = selectedOption.textContent; this.fireEvent("selected-item-changed"); + if (changePrevented) { this.selectedItem = this._lastSelectedOption!.textContent; this._select(this._selectedIndexBeforeOpen); @@ -789,7 +994,7 @@ class Select extends UI5Element implements IFormElement { } get _currentlySelectedOption() { - return this._filteredItems[this._selectedIndex]; + return this.selectOptions[this._selectedIndex]; } get _effectiveTabIndex() { @@ -881,7 +1086,7 @@ class Select extends UI5Element implements IFormElement { itemSelectionAnnounce() { let text; - const optionsCount = this._filteredItems.length; + const optionsCount = this.selectOptions.length; const itemPositionText = Select.i18nBundle.getText(LIST_ITEM_POSITION, this._selectedIndex + 1, optionsCount); if (this.focused && this._currentlySelectedOption) { @@ -929,5 +1134,6 @@ Select.define(); export default Select; export type { SelectChangeEventDetail, + SelectLiveChangeEventDetail, IOption, }; diff --git a/packages/main/src/SelectMenu.hbs b/packages/main/src/SelectMenu.hbs new file mode 100644 index 000000000000..bc08ff919a67 --- /dev/null +++ b/packages/main/src/SelectMenu.hbs @@ -0,0 +1,57 @@ + + {{#if _isPhone}} +
+
+ {{_headerTitleText}} + + +
+ {{#if hasValueState}} +
+ {{> valueStateMessage}} +
+ {{/if}} +
+ {{/if}} + {{#unless _isPhone}} + {{#if hasValueState}} +
+ + {{> valueStateMessage}} +
+ {{/if}} + {{/unless}} + + + + +
+ + +{{#*inline "valueStateMessage"}} + {{#if hasValueStateSlot}} + {{#each valueStateMessageText}} + {{this}} + {{/each}} + {{else}} + {{ valueStateText }} + {{/if}} +{{/inline}} \ No newline at end of file diff --git a/packages/main/src/SelectMenu.ts b/packages/main/src/SelectMenu.ts new file mode 100644 index 000000000000..ed16f35794f0 --- /dev/null +++ b/packages/main/src/SelectMenu.ts @@ -0,0 +1,301 @@ +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import Integer from "@ui5/webcomponents-base/dist/types/Integer.js"; +import slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; +import event from "@ui5/webcomponents-base/dist/decorators/event.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js"; +import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; +import { isPhone } from "@ui5/webcomponents-base/dist/Device.js"; + +// Template +import SelectMenuTemplate from "./generated/templates/SelectMenuTemplate.lit.js"; + +// Styles +import SelectMenuCss from "./generated/themes/SelectMenu.css.js"; +import ValueStateMessageCss from "./generated/themes/ValueStateMessage.css.js"; +import ResponsivePopoverCommonCss from "./generated/themes/ResponsivePopoverCommon.css.js"; + +// Deps +import ResponsivePopover from "./ResponsivePopover.js"; +import List from "./List.js"; +import Button from "./Button.js"; + +// Types +import type Select from "./Select.js"; +import type SelectMenuOption from "./SelectMenuOption.js"; + +type SelectMenuOptionClick = { + option: SelectMenuOption, + optionIndex: number, +}; + +type SelectMenuChange = { + text: string, + selectedIndex: number, +}; + +/** + * @class + * + *

Overview

+ * + * The ui5-select-menu is meant to be used together with the ui5-select component as alternative + * to define the select's dropdown. It acts as a popover on desktop and tablet, and as a Dialog on phone. + *

+ * The component gives the possibility to the user to customize the ui5-select's dropdown + * by slotting custom options and adding custom styles. + * + *

Usage

+ * + * To use ui5-select with a ui5-select-menu, + * you need to set the ui5-select menu property to reference ui5-select-menu either by ID or DOM reference. + *

+ * + * For the ui5-select-menu + *

ES6 Module Import

+ * + * import @ui5/webcomponents/dist/SelectMenu.js"; + * + * @constructor + * @author SAP SE + * @alias sap.ui.webc.main.SelectMenu + * @extends sap.ui.webc.base.UI5Element + * @tagname ui5-select-menu + * @public + * @since 1.17.0 + */ +@customElement({ + tag: "ui5-select-menu", + renderer: litRender, + styles: [SelectMenuCss, ValueStateMessageCss, ResponsivePopoverCommonCss], + template: SelectMenuTemplate, + dependencies: [ + ResponsivePopover, + List, + Button, + ], +}) +@event("option-click", { + detail: { + option: { type: HTMLElement }, + optionIndex: { type: Integer }, + }, +}) +@event("before-open") +@event("after-open") +@event("after-close") +@event("menu-change", { + detail: { + text: { type: String }, + selectedIndex: { type: Integer }, + }, +}) +class SelectMenu extends UI5Element { + constructor() { + super(); + + this.valueStateMessageText = []; + } + + /** + * Defines the options of the component. + * + * @type {sap.ui.webc.main.ISelectMenuOption[]} + * @name sap.ui.webc.main.SelectMenu.prototype.default + * @slot + * @public + */ + @slot({ + "default": true, + type: HTMLElement, + invalidateOnChildChange: true, + }) + options!: Array; + + /** + * Defines the width of the component. + * + * @type { number } + * @name sap.ui.webc.main.SelectMenu.prototype.selectWidth + * @private + */ + @property({ validator: Integer }) + selectWidth?: number; + + @property({ type: Boolean }) + hasValueState!: boolean; + + @property({ type: Boolean }) + hasValueStateSlot!: boolean; + + @property({ type: ValueState, defaultValue: ValueState.None }) + valueState!: `${ValueState}`; + + @property() + valueStateText!: string; + + @property() + value!: string; + + valueStateMessageText: Array; + + _headerTitleText?: string; + + select?: Select; + + /** + * Shows the dropdown at the given element. + */ + showAt(opener: Select, openerWidth: number) { + this.selectWidth = openerWidth; + this.respPopover.open = true; + this.respPopover.opener = opener; + this.hasValueState = !!opener.hasValueState; + this.hasValueStateSlot = opener.valueStateMessageText.length > 0; + this.valueStateText = opener.valueStateText; + this.valueStateMessageText = opener.valueStateMessageText; + this.valueState = opener.valueState; + + this._headerTitleText = opener._headerTitleText; + } + + /** + * Closes the dropdown. + */ + close(escPressed = false, preventRegistryUpdate = false, preventFocusRestore = false) { + this.respPopover.close(escPressed, preventRegistryUpdate, preventFocusRestore); + } + + onBeforeRendering() { + this._syncSelection(); + } + + _syncSelection() { + let lastSelectedOptionIndex = -1, + firstEnabledOptionIndex = -1, + text, + selectedIndex; + const options = this.options; + options.forEach((opt, index) => { + if (opt.selected || opt.textContent === this.value) { + // The second condition in the IF statement is added because of Angular Reactive Forms Support(Two way data binding) + lastSelectedOptionIndex = index; + } + if (firstEnabledOptionIndex === -1) { + firstEnabledOptionIndex = index; + } + + opt.selected = false; + opt.focused = false; + return opt; + }); + + if (lastSelectedOptionIndex > -1) { + const lastSelectedOption = options[lastSelectedOptionIndex]; + lastSelectedOption.selected = true; + lastSelectedOption.focused = true; + text = lastSelectedOption.displayText || String(lastSelectedOption.textContent); + selectedIndex = lastSelectedOptionIndex; + } else { + text = ""; + selectedIndex = -1; + const firstSelectedOption = options[firstEnabledOptionIndex]; + if (firstSelectedOption) { + firstSelectedOption.selected = true; + firstSelectedOption.focused = true; + selectedIndex = firstEnabledOptionIndex; + text = firstSelectedOption.displayText || String(firstSelectedOption.textContent); + } + } + + this.fireEvent("menu-change", { + text, + selectedIndex, + }); + } + + _onOptionClick(e: CustomEvent) { + const option = e.detail.item; + const optionIndex = this.options.findIndex(_option => option.__id === _option.__id); + + this.fireEvent("option-click", { + option, + optionIndex, + }); + } + + _onBeforeOpen() { + this.fireEvent("before-open"); + } + + _onAfterOpen() { + this.fireEvent("after-open"); + } + + _onAfterClose() { + this.fireEvent("after-close"); + } + + _onCloseBtnClick() { + this.close(); + } + + get open() { + return !!this.respPopover?.open; + } + + get respPopover() { + return this.shadowRoot!.querySelector(".ui5-select-menu")!; + } + + get classes() { + return { + popoverValueState: { + "ui5-valuestatemessage-root": true, + "ui5-valuestatemessage--success": this.valueState === ValueState.Success, + "ui5-valuestatemessage--error": this.valueState === ValueState.Error, + "ui5-valuestatemessage--warning": this.valueState === ValueState.Warning, + "ui5-valuestatemessage--information": this.valueState === ValueState.Information, + }, + popover: { + "ui5-select-popover-valuestate": this.hasValueState, + }, + }; + } + + get styles() { + return { + responsivePopoverHeader: { + "display": this.options.length && this.respPopover?.offsetWidth === 0 ? "none" : "inline-block", + "width": `${this.options.length ? this.respPopover?.offsetWidth : this.selectWidth || "auto"}px`, + }, + responsivePopover: { + "min-width": `${this.selectWidth!}px`, + }, + }; + } + + get _valueStateMessageInputIcon() { + const iconPerValueState = { + Error: "error", + Warning: "alert", + Success: "sys-enter-2", + Information: "information", + }; + + return this.valueState !== ValueState.None ? iconPerValueState[this.valueState] : ""; + } + + get _isPhone() { + return isPhone(); + } +} + +SelectMenu.define(); + +export default SelectMenu; +export type { + SelectMenuOptionClick, + SelectMenuChange, +}; diff --git a/packages/main/src/SelectMenuOption.ts b/packages/main/src/SelectMenuOption.ts new file mode 100644 index 000000000000..ef3c3a7539fa --- /dev/null +++ b/packages/main/src/SelectMenuOption.ts @@ -0,0 +1,130 @@ +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js"; + +import type { IOption } from "./Select.js"; + +// Template +import CustomListItemTemplate from "./generated/templates/CustomListItemTemplate.lit.js"; + +// Styles +import CustomListItem from "./CustomListItem.js"; +import ListItemType from "./types/ListItemType.js"; +import type { AccessibilityAttributes } from "./ListItem.js"; + +/** + * @class + * + *

Overview

+ * The code>ui5-select-menu-option component represents an option in the ui5-select-menu. + * + *

Usage

+ * + * For the ui5-select-menu-option + *

ES6 Module Import

+ * + * import @ui5/webcomponents/dist/SelectMenuOption.js"; + * + * @constructor + * @author SAP SE + * @alias sap.ui.webc.main.SelectMenuOption + * @extends sap.ui.webc.base.UI5Element + * @implements sap.ui.webc.main.ISelectMenuOption + * @tagname ui5-select-menu-option + * @public + * @since 1.17.0 + */ +@customElement({ + tag: "ui5-select-menu-option", + renderer: litRender, + styles: CustomListItem.styles, + template: CustomListItemTemplate, + dependencies: [], +}) +class SelectMenuOption extends CustomListItem implements IOption { + /** + * Defines the text, displayed inside the ui5-select input filed + * when the option gets selected. + * + * @type {string} + * @name sap.ui.webc.main.SelectMenuOption.prototype.displayText + * @public + */ + @property() + displayText!: string; + + /** + * Defines the value of the ui5-select inside an HTML Form element when this component is selected. + * For more information on HTML Form support, see the name property of ui5-select. + * + * @type {string} + * @name sap.ui.webc.main.SelectMenuOption.prototype.value + * @public + */ + @property() + value!: string; + + /** + * Note: The property is inherited and not supported. If set, it won't take any effect. + * + * @type {sap.ui.webc.main.types.ListItemType} + * @name sap.ui.webc.main.SelectMenuOption.prototype.type + * @defaultvalue "Active" + * @public + * @deprecated + */ + @property({ type: ListItemType, defaultValue: ListItemType.Active }) + type!: `${ListItemType}`; + + /** + * Note: The property is inherited and not supported. If set, it won't take any effect. + * + * @type {object} + * @name sap.ui.webc.main.SelectMenuOption.prototype.accessibilityAttributes + * @public + * @deprecated + */ + @property({ type: Object }) + accessibilityAttributes!: AccessibilityAttributes; + + /** + * Note: The property is inherited and not supported. If set, it won't take any effect. + * + * @public + * @type {boolean} + * @name sap.ui.webc.main.SelectMenuOption.prototype.navigated + * @deprecated + */ + @property({ type: Boolean }) + navigated!: boolean; + + /** + * Defines the content of the component. + *

+ * + * @type {Node[]} + * @name sap.ui.webc.main.SelectMenuOption.prototype.default + * @slot + * @public + */ + + /** + * Note: The slot is inherited and not supported. If set, it won't take any effect. + * + * @name sap.ui.webc.main.SelectMenuOption.prototype.deleteButton + * @slot + * @public + * @deprecated + */ + + get _accInfo() { + const accInfoSettings = { + ariaSelected: this.selected, + }; + return { ...super._accInfo, ...accInfoSettings }; + } +} + +SelectMenuOption.define(); + +export default SelectMenuOption; diff --git a/packages/main/src/StandardListItem.ts b/packages/main/src/StandardListItem.ts index 50eec29de488..f4d6b33a531d 100644 --- a/packages/main/src/StandardListItem.ts +++ b/packages/main/src/StandardListItem.ts @@ -27,6 +27,12 @@ import StandardListItemTemplate from "./generated/templates/StandardListItemTemp *
  • description - Used to style the description of the list item
  • *
  • additional-text - Used to style the additionalText of the list item
  • *
  • icon - Used to style the icon of the list item
  • + *
  • native-li - Used to style the main li tag of the list item
  • + *
  • content - Used to style the content area of the list item
  • + *
  • detail-button - Used to style the button rendered when the list item is of type detail
  • + *
  • delete-button - Used to style the button rendered when the list item is in delete mode
  • + *
  • radio - Used to style the radio button rendered when the list item is in single selection mode
  • + *
  • checkbox - Used to style the checkbox rendered when the list item is in multiple selection mode
  • * * * @constructor diff --git a/packages/main/src/TextArea.ts b/packages/main/src/TextArea.ts index 1a98abad7d5c..3a9225934e66 100644 --- a/packages/main/src/TextArea.ts +++ b/packages/main/src/TextArea.ts @@ -460,7 +460,7 @@ class TextArea extends UI5Element implements IFormElement { _onfocusout(e: FocusEvent) { const eTarget = e.relatedTarget as HTMLElement; - const focusedOutToValueStateMessage = eTarget?.shadowRoot!.querySelector(".ui5-valuestatemessage-root"); + const focusedOutToValueStateMessage = eTarget?.shadowRoot?.querySelector(".ui5-valuestatemessage-root"); this.focused = false; diff --git a/packages/main/src/Toolbar.hbs b/packages/main/src/Toolbar.hbs new file mode 100644 index 000000000000..396b46538e35 --- /dev/null +++ b/packages/main/src/Toolbar.hbs @@ -0,0 +1,20 @@ + +
    + {{#each standardItems}} + {{this.toolbarTemplate}} + {{/each}} + + +
    \ No newline at end of file diff --git a/packages/main/src/Toolbar.ts b/packages/main/src/Toolbar.ts new file mode 100644 index 000000000000..de760c8f36e0 --- /dev/null +++ b/packages/main/src/Toolbar.ts @@ -0,0 +1,594 @@ +import UI5Element, { ChangeInfo } from "@ui5/webcomponents-base/dist/UI5Element.js"; +import slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import executeTemplate from "@ui5/webcomponents-base/dist/renderer/executeTemplate.js"; +import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js"; +import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js"; +import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; +import type { ResizeObserverCallback } from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; +import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AriaLabelHelper.js"; +import Integer from "@ui5/webcomponents-base/dist/types/Integer.js"; +import "@ui5/webcomponents-icons/dist/overflow.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { getI18nBundle } from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import { getScopedVarName } from "@ui5/webcomponents-base/dist/CustomElementsScope.js"; + +import { + TOOLBAR_OVERFLOW_BUTTON_ARIA_LABEL, +} from "./generated/i18n/i18n-defaults.js"; + +import ToolbarTemplate from "./generated/templates/ToolbarTemplate.lit.js"; +import ToolbarCss from "./generated/themes/Toolbar.css.js"; + +import ToolbarPopoverTemplate from "./generated/templates/ToolbarPopoverTemplate.lit.js"; +import ToolbarPopoverCss from "./generated/themes/ToolbarPopover.css.js"; + +import ToolbarAlign from "./types/ToolbarAlign.js"; +import ToolbarItemOverflowBehavior from "./types/ToolbarItemOverflowBehavior.js"; +import HasPopup from "./types/HasPopup.js"; + +import type ToolbarItem from "./ToolbarItem.js"; +import { + getRegisteredToolbarItem, + getRegisteredStyles, + getRegisteredStaticAreaStyles, + getRegisteredDependencies, +} from "./ToolbarRegistry.js"; + +import Button from "./Button.js"; +import Popover from "./Popover.js"; + +function calculateCSSREMValue(styleSet: CSSStyleDeclaration, propertyName: string): number { + return Number(styleSet.getPropertyValue(propertyName).replace("rem", "")) * parseInt(getComputedStyle(document.body).getPropertyValue("font-size")); +} + +function parsePxValue(styleSet: CSSStyleDeclaration, propertyName: string): number { + return Number(styleSet.getPropertyValue(propertyName).replace("px", "")); +} + +/** + * @class + * + *

    Overview

    + * + * The ui5-toolbar component is used to create a horizontal layout with items. + * The items can be overflowing in a popover, when the space is not enough to show all of them. + * + *

    Keyboard Handling

    + * The ui5-toolbar provides advanced keyboard handling. + *
    + *
      + *
    • The control is not interactive, but can contain of interactive elements
    • + *
    • [TAB] - iterates through elements
    • + *
    + *
    + * + *

    ES6 Module Import

    + * import "@ui5/webcomponents/dist/Toolbar"; + * @constructor + * @author SAP SE + * @alias sap.ui.webc.main.Toolbar + * @extends sap.ui.webc.base.UI5Element + * @tagname ui5-toolbar + * @appenddocs sap.ui.webc.main.ToolbarButton sap.ui.webc.main.ToolbarSelect + * @public + * @since 1.17.0 + */ +@customElement({ + tag: "ui5-toolbar", + languageAware: true, + renderer: litRender, + template: ToolbarTemplate, + staticAreaTemplate: ToolbarPopoverTemplate, +}) +class Toolbar extends UI5Element { + static i18nBundle: I18nBundle; + + /** + * Indicated the direction in which the Toolbar items will be aligned. + * Available options are: + *
      + *
    • End
    • + *
    • Start
    • + *
    + * @type {sap.ui.webc.main.types.ToolbarAlign} + * @public + * @defaultvalue: "End" + * @name sap.ui.webc.main.Toolbar.prototype.alignContent + */ + @property({ type: ToolbarAlign, defaultValue: ToolbarAlign.End }) + alignContent!: `${ToolbarAlign}`; + + /** + * Calculated width of the whole toolbar. + * @private + * @name sap.ui.webc.main.Toolbar.prototype.width + * @type {sap.ui.webc.base.types.Integer} + * @defaultvalue false + */ + @property({ type: Integer }) + width?: number; + + /** + * Calculated width of the toolbar content. + * @private + * @name sap.ui.webc.main.Toolbar.prototype.contentWidth + * @type {sap.ui.webc.base.types.Integer} + * @defaultvalue 0 + */ + @property({ type: Integer }) + contentWidth?: number; + + /** + * Notifies the toolbar if it should show the items in a reverse way if Toolbar Popover needs to be placed on "Top" position. + * @private + * @type {Boolean} + */ + @property({ type: Boolean }) + reverseOverflow!: boolean; + + /** + * Defines the accessible ARIA name of the component. + * + * @type {string} + * @name sap.ui.webc.main.Toolbar.prototype.accessibleName + * @defaultvalue: "" + * @public + */ + @property() + accessibleName!: string; + + /** + * Receives id(or many ids) of the elements that label the input. + * + * @type {string} + * @name sap.ui.webc.main.Toolbar.prototype.accessibleNameRef + * @defaultvalue "" + * @public + */ + @property({ defaultValue: "" }) + accessibleNameRef!: string; + + /** + * Defines the items of the component. + * + * @type {sap.ui.webc.main.IToolbarItem[]} + * @name sap.ui.webc.main.Toolbar.prototype.default + * @slot items + * @public + */ + @slot({ "default": true, type: HTMLElement, invalidateOnChildChange: true }) + items!: Array + + _onResize!: ResizeObserverCallback; + _onInteract!: EventListener; + itemsToOverflow: Array = []; + itemsWidth = 0; + popoverOpen = false; + itemsWidthMeasured = false; + + ITEMS_WIDTH_MAP: Map = new Map(); + + static get styles() { + const styles = getRegisteredStyles(); + return [ + ToolbarCss, + ...styles, + ]; + } + + static get staticAreaStyles() { + const styles = getRegisteredStaticAreaStyles(); + return [ + ToolbarPopoverCss, + ...styles, + ]; + } + + static get dependencies() { + const deps = getRegisteredDependencies(); + return [ + Popover, + Button, + ...deps, + ]; + } + + static async onDefine() { + Toolbar.i18nBundle = await getI18nBundle("@ui5/webcomponents"); + } + + constructor() { + super(); + + this._onResize = this.onResize.bind(this); + this._onInteract = (e: Event) => this.onInteract(e as CustomEvent); + } + + /** + * Read-only members + */ + + get overflowButtonSize(): number { + return this.overflowButtonDOM?.getBoundingClientRect().width || 0; + } + + get padding(): number { + const toolbarComputedStyle = getComputedStyle(this.getDomRef()!); + return calculateCSSREMValue(toolbarComputedStyle, getScopedVarName("--_ui5-toolbar-padding-left")) + + calculateCSSREMValue(toolbarComputedStyle, getScopedVarName("--_ui5-toolbar-padding-right")); + } + + get subscribedEvents() { + return this.items + .map((item: ToolbarItem) => Array.from(item.subscribedEvents.keys())) + .flat() + // remove duplicates + .filter((value, index, self) => self.indexOf(value) === index); + } + + get alwaysOverflowItems() { + return this.items.filter((item: ToolbarItem) => item.overflowPriority === ToolbarItemOverflowBehavior.AlwaysOverflow); + } + + get movableItems() { + return this.items.filter((item: ToolbarItem) => item.overflowPriority !== ToolbarItemOverflowBehavior.AlwaysOverflow && item.overflowPriority !== ToolbarItemOverflowBehavior.NeverOverflow); + } + + get overflowItems() { + // spacers and separators are ignored + const overflowItems = this.getItemsInfo(this.itemsToOverflow.filter(item => !item.ignoreSpace)); + return this.reverseOverflow ? overflowItems.reverse() : overflowItems; + } + + get standardItems() { + return this.getItemsInfo(this.items.filter(item => this.itemsToOverflow.indexOf(item) === -1)); + } + + get hideOverflowButton() { + return this.overflowItems.length === 0; + } + + get classes() { + return { + items: { + "ui5-tb-items": true, + "ui5-tb-items-full-width": this.hasFlexibleSpacers, + }, + overflow: { + "ui5-overflow-list--alignleft": this.hasItemWithText, + }, + overflowButton: { + "ui5-tb-item": true, + "ui5-tb-overflow-btn": true, + "ui5-tb-overflow-btn-hidden": this.hideOverflowButton, + }, + }; + } + + get interactiveItemsCount() { + return this.items.filter((item: ToolbarItem) => item.isInteractive).length; + } + + /** + * Accessibility + */ + + get hasAriaSemantics() { + return this.interactiveItemsCount > 1; + } + + get accessibleRole() { + return this.hasAriaSemantics ? "toolbar" : undefined; + } + + get ariaLabelText() { + return this.hasAriaSemantics ? getEffectiveAriaLabelText(this) : undefined; + } + + get accInfo() { + return { + root: { + role: this.accessibleRole, + accessibleName: this.ariaLabelText, + }, + overflowButton: { + accessibleName: Toolbar.i18nBundle.getText(TOOLBAR_OVERFLOW_BUTTON_ARIA_LABEL), + tooltip: Toolbar.i18nBundle.getText(TOOLBAR_OVERFLOW_BUTTON_ARIA_LABEL), + accessibilityAttributes: { + expanded: this.overflowButtonDOM?.accessibilityAttributes.expanded, + hasPopup: HasPopup.Menu, + }, + }, + }; + } + + /** + * Toolbar Overflow Popover + */ + + get overflowButtonDOM(): Button | null { + return this.shadowRoot!.querySelector(".ui5-tb-overflow-btn"); + } + + get itemsDOM() { + return this.shadowRoot!.querySelector(".ui5-tb-items"); + } + + get hasItemWithText(): boolean { + return this.itemsToOverflow.some((item: ToolbarItem) => item.containsText); + } + + get hasFlexibleSpacers() { + return this.items.some((item: ToolbarItem) => item.hasFlexibleWidth); + } + + /** + * Lifecycle methods + */ + onEnterDOM() { + ResizeHandler.register(this, this._onResize); + } + + onExitDOM() { + ResizeHandler.deregister(this, this._onResize); + } + + onInvalidation(changeInfo: ChangeInfo) { + if (changeInfo.reason === "childchange" && changeInfo.child === this.itemsToOverflow[0]) { + this.onToolbarItemChange(); + } + } + + onBeforeRendering() { + this.detachListeners(); + this.attachListeners(); + } + + async onAfterRendering() { + await renderFinished(); + + this.storeItemsWidth(); + this.processOverflowLayout(); + } + + /** + * Returns if the overflow popup is open. + * + * @public + * @return { Promise } + */ + async isOverflowOpen(): Promise { + const overflowPopover = await this.getOverflowPopover(); + return overflowPopover!.isOpen(); + } + + async openOverflow(): Promise { + const overflowPopover = await this.getOverflowPopover(); + overflowPopover!.showAt(this.overflowButtonDOM!); + this.reverseOverflow = overflowPopover!.actualPlacementType === "Top"; + } + + async closeOverflow() { + const overflowPopover = await this.getOverflowPopover(); + overflowPopover!.close(); + } + + toggleOverflow() { + if (this.popoverOpen) { + this.closeOverflow(); + } else { + this.openOverflow(); + } + } + + async getOverflowPopover(): Promise { + const staticAreaItem = await this.getStaticAreaItemDomRef(); + return staticAreaItem!.querySelector(".ui5-overflow-popover"); + } + + /** + * Layout management + */ + + processOverflowLayout() { + const containerWidth = this.offsetWidth - this.padding; + const contentWidth = this.itemsWidth; + const overflowSpace = contentWidth - containerWidth + this.overflowButtonSize; + + // skip calculation if the width has not been changed or if the items width has not been changed + if (this.width === containerWidth && this.contentWidth === contentWidth) { + return; + } + + this.distributeItems(overflowSpace); + this.width = containerWidth; + this.contentWidth = contentWidth; + } + + storeItemsWidth() { + let totalWidth = 0; + + this.items.forEach((item: ToolbarItem) => { + const itemWidth = this.getItemWidth(item); + totalWidth += itemWidth; + this.ITEMS_WIDTH_MAP.set(item._id, itemWidth); + }); + + this.itemsWidth = totalWidth; + } + + distributeItems(overflowSpace = 0) { + const movableItems = this.movableItems.reverse(); + let index = 0; + let currentItem = movableItems[index]; + + this.itemsToOverflow = []; + + // distribute items that always overflow + this.distributeItemsThatAlwaysOverflow(); + + while (overflowSpace > 0 && currentItem) { + this.itemsToOverflow.unshift(currentItem); + overflowSpace -= this.getCachedItemWidth(currentItem?._id) || 0; + index++; + currentItem = movableItems[index]; + } + + // If the last bar item is a spacer, force it to the overflow even if there is enough space for it + if (index < movableItems.length) { + let lastItem = movableItems[index]; + while (lastItem?.ignoreSpace) { + this.itemsToOverflow.unshift(lastItem); + index++; + lastItem = movableItems[index]; + } + } + } + + distributeItemsThatAlwaysOverflow() { + this.alwaysOverflowItems.forEach((item: ToolbarItem) => { + this.itemsToOverflow.push(item); + }); + } + + /** + * Event Handlers + */ + + onOverflowPopoverClosed() { + this.popoverOpen = false; + if (this.overflowButtonDOM) { + this.overflowButtonDOM.accessibilityAttributes.expanded = "false"; + } + } + + onOverflowPopoverOpened() { + this.popoverOpen = true; + if (this.overflowButtonDOM) { + this.overflowButtonDOM.accessibilityAttributes.expanded = "true"; + } + } + + onResize() { + if (!this.itemsWidth) { + return; + } + + this.closeOverflow(); + this.processOverflowLayout(); + } + + onInteract(e: CustomEvent) { + const target = e.target as HTMLElement; + const item = target.closest(".ui5-tb-item") || target.closest(".ui5-tb-popover-item"); + const eventType: string = e.type; + + if (target === this.overflowButtonDOM) { + this.toggleOverflow(); + return; + } + + if (!item) { + return; + } + + const refItemId = target.getAttribute("data-ui5-external-action-item-id"); + + if (refItemId) { + const abstractItem = this.getItemByID(refItemId); + const prevented = !abstractItem?.fireEvent(eventType, e.detail, true); + const eventOptions = abstractItem?.subscribedEvents.get(eventType); + + if (prevented || abstractItem?.preventOverflowClosing || eventOptions?.preventClosing) { + return; + } + + this.closeOverflow(); + } + } + + /** + * Private members + */ + + async attachListeners() { + const popover = await this.getOverflowPopover(); + + this.subscribedEvents.forEach((e: string) => { + this.itemsDOM?.addEventListener(e, this._onInteract); + popover?.addEventListener(e, this._onInteract); + }); + } + + async detachListeners() { + const popover = await this.getOverflowPopover(); + + this.subscribedEvents.forEach((e: string) => { + this.itemsDOM?.removeEventListener(e, this._onInteract); + popover?.removeEventListener(e, this._onInteract); + }); + } + + onToolbarItemChange() { + // some items were updated reset the cache and trigger a re-render + this.itemsToOverflow = []; + this.contentWidth = 0; // re-render + } + + getItemsInfo(items: Array) { + return items.map((item: ToolbarItem) => { + const ElementClass = getRegisteredToolbarItem(item.constructor.name); + + if (!ElementClass) { + return null; + } + + const toolbarItem = { + toolbarTemplate: executeTemplate(ElementClass.toolbarTemplate, item), + toolbarPopoverTemplate: executeTemplate(ElementClass.toolbarPopoverTemplate, item), + }; + + return toolbarItem; + }); + } + + getItemWidth(item: ToolbarItem): number { + // Spacer width - always 0 for flexible spacers, so that they shrink, otherwise - measure the width normally + if (item.ignoreSpace) { + return 0; + } + const id: string = item._id; + // Measure rendered width for spacers with width, and for normal items + const renderedItem = this.getRegisteredToolbarItemByID(id); + + let itemWidth = 0; + + if (renderedItem) { + const ItemCSSStyleSet = getComputedStyle(renderedItem); + itemWidth = renderedItem.offsetWidth + parsePxValue(ItemCSSStyleSet, "margin-inline-end") + + parsePxValue(ItemCSSStyleSet, "margin-inline-start"); + } else { + itemWidth = this.getCachedItemWidth(id) || 0; + } + + return Math.ceil(itemWidth); + } + + getCachedItemWidth(id: string) { + return this.ITEMS_WIDTH_MAP.get(id); + } + + getItemByID(id: string) { + return this.items.find(item => item._id === id); + } + + getRegisteredToolbarItemByID(id: string): HTMLElement | null { + return this.itemsDOM!.querySelector(`[data-ui5-external-action-item-id="${id}"]`); + } +} + +Toolbar.define(); + +export default Toolbar; diff --git a/packages/main/src/ToolbarButton.hbs b/packages/main/src/ToolbarButton.hbs new file mode 100644 index 000000000000..a9f596fe4718 --- /dev/null +++ b/packages/main/src/ToolbarButton.hbs @@ -0,0 +1,19 @@ + + {{this.text}} + \ No newline at end of file diff --git a/packages/main/src/ToolbarButton.ts b/packages/main/src/ToolbarButton.ts new file mode 100644 index 000000000000..752737456379 --- /dev/null +++ b/packages/main/src/ToolbarButton.ts @@ -0,0 +1,230 @@ +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import event from "@ui5/webcomponents-base/dist/decorators/event.js"; +import Button from "./Button.js"; +import ButtonDesign from "./types/ButtonDesign.js"; + +import ToolbarItem from "./ToolbarItem.js"; +import type { IEventOptions } from "./ToolbarItem.js"; +import ToolbarButtonTemplate from "./generated/templates/ToolbarButtonTemplate.lit.js"; +import ToolbarPopoverButtonTemplate from "./generated/templates/ToolbarPopoverButtonTemplate.lit.js"; + +import ToolbarButtonPopoverCss from "./generated/themes/ToolbarButtonPopover.css.js"; + +import { registerToolbarItem } from "./ToolbarRegistry.js"; + +/** + * @class + * + *

    Overview

    + * The ui5-toolbar-button represents an abstract action, + * used in the ui5-toolbar. + * + *

    ES6 Module Import

    + * import "@ui5/webcomponents/dist/ToolbarButton"; + * + * @constructor + * @author SAP SE + * @alias sap.ui.webc.main.ToolbarButton + * @extends sap.ui.webc.main.ToolbarItem + * @tagname ui5-toolbar-button + * @public + * @since 1.17.0 + */ +@customElement({ + tag: "ui5-toolbar-button", + dependencies: [Button], +}) + +/** + * Fired when the component is activated either with a + * mouse/tap or by using the Enter or Space key. + *

    + * Note: The event will not be fired if the disabled + * property is set to true. + * + * @event sap.ui.webc.main.ToolbarButton#click + * @public + */ +@event("click") +class ToolbarButton extends ToolbarItem { + /** + * Defines if the action is disabled. + *

    + * Note: a disabled action can't be pressed or focused, and it is not in the tab chain. + * + * @type {boolean} + * @defaultvalue false + * @name sap.ui.webc.main.ToolbarButton.prototype.disabled + * @public + */ + @property({ type: Boolean }) + disabled!: boolean; + + /** + * Defines the action design. + * The available values are: + * + *
      + *
    • Default
    • + *
    • Emphasized
    • + *
    • Positive
    • + *
    • Negative
    • + *
    • Transparent
    • + *
    • Attention
    • + *
    + * + * @type {ButtonDesign} + * @defaultvalue "Default" + * @name sap.ui.webc.main.ToolbarButton.prototype.design + * @public + */ + @property({ type: ButtonDesign, defaultValue: ButtonDesign.Default }) + design!: `${ButtonDesign}`; + + /** + * Defines the icon source URI. + *

    + * Note: + * SAP-icons font provides numerous buil-in icons. To find all the available icons, see the + * Icon Explorer. + * + * @type {string} + * @defaultvalue "" + * @name sap.ui.webc.main.ToolbarButton.prototype.icon + * @public + */ + @property({ type: String }) + icon!: string; + + /** + * Defines whether the icon should be displayed after the component text. + * + * @type {boolean} + * @name sap.ui.webc.main.ToolbarButton.prototype.iconEnd + * @defaultvalue false + * @public + */ + @property({ type: Boolean }) + iconEnd!: boolean; + + /** + * Defines the tooltip of the component. + *
    + * Note: A tooltip attribute should be provided for icon-only buttons, in order to represent their exact meaning/function. + * @type {string} + * @name sap.ui.webc.main.ToolbarButton.prototype.tooltip + * @defaultvalue "" + * @public + */ + @property() + tooltip!: string; + + /** + * Defines the accessible ARIA name of the component. + * + * @type {string} + * @name sap.ui.webc.main.ToolbarButton.prototype.accessibleName + * @defaultvalue undefined + * @public + */ + @property({ defaultValue: undefined }) + accessibleName?: string; + + /** + * Receives id(or many ids) of the elements that label the component. + * + * @type {string} + * @name sap.ui.webc.main.ToolbarButton.prototype.accessibleNameRef + * @defaultvalue "" + * @public + */ + @property({ defaultValue: "" }) + accessibleNameRef!: string; + + /** + * An object of strings that defines several additional accessibility attribute values + * for customization depending on the use case. + * + * It supports the following fields: + * + *
      + *
    • expanded: Indicates whether the button, or another grouping element it controls, is currently expanded or collapsed. Accepts the following string values: + *
        + *
      • true
      • + *
      • 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
      • + *
      • Tree
      • + *
      + *
    • + *
    • controls: Identifies the element (or elements) whose contents or presence are controlled by the button element. Accepts a string value.
    • + *
    + * @type {object} + * @name sap.ui.webc.main.ToolbarButton.prototype.accessibilityAttributes + * @public + */ + @property({ type: Object }) + accessibilityAttributes!: { expanded: "true" | "false", hasPopup: "Dialog" | "Grid" | "ListBox" | "Menu" | "Tree", controls: string}; + + /** + * Button text + * @public + * @defaultvalue "" + * @type {string} + * @name sap.ui.webc.main.ToolbarButton.prototype.text + */ + @property({ type: String }) + text!: string; + + /** + * Button width + * @name sap.ui.webc.main.ToolbarButton.prototype.width + * @defaultvalue "" + * @type {string} + * @public + */ + @property({ type: String }) + width!: string; + + static get staticAreaStyles() { + return ToolbarButtonPopoverCss; + } + + get styles() { + return { + width: this.width, + display: this.hidden ? "none" : "inline-block", + }; + } + + get containsText() { + return true; + } + + static get toolbarTemplate() { + return ToolbarButtonTemplate; + } + + static get toolbarPopoverTemplate() { + return ToolbarPopoverButtonTemplate; + } + + get subscribedEvents(): Map { + const map = new Map(); + map.set("click", { preventClosing: false }); + return map; + } +} + +registerToolbarItem(ToolbarButton); + +ToolbarButton.define(); + +export default ToolbarButton; diff --git a/packages/main/src/ToolbarItem.ts b/packages/main/src/ToolbarItem.ts new file mode 100644 index 000000000000..6d476b924745 --- /dev/null +++ b/packages/main/src/ToolbarItem.ts @@ -0,0 +1,143 @@ +import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import { TemplateFunction } from "@ui5/webcomponents-base/dist/renderer/executeTemplate.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; + +import ToolbarItemOverflowBehavior from "./types/ToolbarItemOverflowBehavior.js"; + +type IEventOptions = { + preventClosing: boolean; +} + +interface IToolbarItem { + overflowPriority: `${ToolbarItemOverflowBehavior}`; + preventOverflowClosing: boolean; + ignoreSpace?: boolean; + containsText?: boolean; + hasFlexibleWidth?: boolean; + stableDomRef: string; +} + +/** + * @class + * + * The ui5-tb-item represents an abstract class for items, + * used in the ui5-toolbar. + * + * @constructor + * @author SAP SE + * @alias sap.ui.webc.main.ToolbarItem + * @extends sap.ui.webc.base.UI5Element + * @abstract + * @public + * @since 1.17.0 + */ +@customElement("ui5-tb-item") +class ToolbarItem extends UI5Element implements IToolbarItem { + /** + * Property used to define the access of the item to the overflow Popover. If "NeverOverflow" option is set, + * the item never goes in the Popover, if "AlwaysOverflow" - it never comes out of it. + * Available options are: + *
      + *
    • NeverOverflow
    • + *
    • AlwaysOverflow
    • + *
    • Default
    • + *
    + * @public + * @name sap.ui.webc.main.ToolbarItem.prototype.overflowPriority + * @defaultvalue "Default" + * @type {ToolbarItemOverflowBehavior} + */ + @property({ type: ToolbarItemOverflowBehavior, defaultValue: ToolbarItemOverflowBehavior.Default }) + overflowPriority!: `${ToolbarItemOverflowBehavior}`; + + /** + * Defines if the toolbar overflow popup should close upon intereaction with the item. + * It will close by default. + * @type {Boolean} + * @defaultvalue false + * @public + * @name sap.ui.webc.main.ToolbarItem.prototype.preventOverflowClosing + */ + @property({ type: Boolean }) + preventOverflowClosing!: boolean; + + /** + * Defines if the width of the item should be ignored in calculating the whole width of the toolbar + * @returns {Boolean} + * @protected + */ + get ignoreSpace(): boolean { + return false; + } + + /** + * Returns if the item contains text. Used to position the text properly inside the popover. + * Aligned left if the item has text, default aligned otherwise. + * @protected + * @returns {Boolean} + */ + get containsText(): boolean { + return false; + } + + /** + * Returns if the item is flexible. An item that is returning true for this property will make + * the toolbar expand to fill the 100% width of its container. + * @protected + * @returns {Boolean} + */ + get hasFlexibleWidth(): boolean { + return false; + } + + /** + * Returns if the item is interactive. + * This value is used to determinate if the toolbar should have its accessibility role and attributes set. + * At least two interactive items are needed for the toolbar to have the role="toolbar" attribute set. + * @protected + * @returns {Boolean} + */ + get isInteractive(): boolean { + return true; + } + + /** + * Returns the template for the toolbar item. + * @protected + * @returns {TemplateFunction} + */ + static get toolbarTemplate(): TemplateFunction { + throw new Error("Template must be defined"); + } + + /** + * Returns the template for the toolbar item popover. + * @protected + * @returns {TemplateFunction} + */ + static get toolbarPopoverTemplate(): TemplateFunction { + throw new Error("Popover template must be defined"); + } + + /** + * Returns the events that the item is subscribed to. + * @protected + * @returns {Map} + */ + get subscribedEvents(): Map { + return new Map(); + } + + get stableDomRef() { + return this.getAttribute("stable-dom-ref") || `${this._id}-stable-dom-ref`; + } +} + +ToolbarItem.define(); + +export type { + IToolbarItem, + IEventOptions, +}; +export default ToolbarItem; diff --git a/packages/main/src/ToolbarPopover.hbs b/packages/main/src/ToolbarPopover.hbs new file mode 100644 index 000000000000..4d7d0420825a --- /dev/null +++ b/packages/main/src/ToolbarPopover.hbs @@ -0,0 +1,14 @@ + +
    + {{#each overflowItems}} + {{this.toolbarPopoverTemplate}} + {{/each}} +
    +
    diff --git a/packages/main/src/ToolbarPopoverButton.hbs b/packages/main/src/ToolbarPopoverButton.hbs new file mode 100644 index 000000000000..4296eeadc8ce --- /dev/null +++ b/packages/main/src/ToolbarPopoverButton.hbs @@ -0,0 +1,16 @@ + + {{this.text}} + \ No newline at end of file diff --git a/packages/main/src/ToolbarPopoverSelect.hbs b/packages/main/src/ToolbarPopoverSelect.hbs new file mode 100644 index 000000000000..38011e45caaa --- /dev/null +++ b/packages/main/src/ToolbarPopoverSelect.hbs @@ -0,0 +1,12 @@ + + {{#each options}} + {{this.textContent}} + {{/each}} + \ No newline at end of file diff --git a/packages/main/src/ToolbarRegistry.ts b/packages/main/src/ToolbarRegistry.ts new file mode 100644 index 000000000000..e62cebd76688 --- /dev/null +++ b/packages/main/src/ToolbarRegistry.ts @@ -0,0 +1,37 @@ +import getSharedResource from "@ui5/webcomponents-base/dist/getSharedResource.js"; + +import type IToolbarItem from "./ToolbarItem.js"; + +const registry = getSharedResource>("ToolbarItem.registry", new Map()); + +const registerToolbarItem = (ElementClass: typeof IToolbarItem) => { + registry.set(ElementClass.name, ElementClass); +}; + +const getRegisteredToolbarItem = (name: string) => { + if (!registry.has(name)) { + throw new Error(`No template found for ${name}`); + } + + return registry.get(name); +}; + +const getRegisteredStyles = () => { + return [...registry.values()].map((ElementClass: typeof IToolbarItem) => ElementClass.styles); +}; + +const getRegisteredStaticAreaStyles = () => { + return [...registry.values()].map((ElementClass: typeof IToolbarItem) => ElementClass.staticAreaStyles); +}; + +const getRegisteredDependencies = () => { + return [...registry.values()].map((ElementClass: typeof IToolbarItem) => ElementClass.dependencies).flat(); +}; + +export { + registerToolbarItem, + getRegisteredToolbarItem, + getRegisteredStyles, + getRegisteredStaticAreaStyles, + getRegisteredDependencies, +}; diff --git a/packages/main/src/ToolbarSelect.hbs b/packages/main/src/ToolbarSelect.hbs new file mode 100644 index 000000000000..072ea9c4f170 --- /dev/null +++ b/packages/main/src/ToolbarSelect.hbs @@ -0,0 +1,12 @@ + + {{#each options}} + {{this.textContent}} + {{/each}} + \ No newline at end of file diff --git a/packages/main/src/ToolbarSelect.ts b/packages/main/src/ToolbarSelect.ts new file mode 100644 index 000000000000..63924d3f801b --- /dev/null +++ b/packages/main/src/ToolbarSelect.ts @@ -0,0 +1,208 @@ +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.js"; +import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; + +import { registerToolbarItem } from "./ToolbarRegistry.js"; + +// Templates + +import ToolbarSelectTemplate from "./generated/templates/ToolbarSelectTemplate.lit.js"; +import ToolbarPopoverSelectTemplate from "./generated/templates/ToolbarPopoverSelectTemplate.lit.js"; +import ToolbarItem from "./ToolbarItem.js"; +import Select from "./Select.js"; +import Option from "./Option.js"; +import "./ToolbarSelectOption.js"; +import type { SelectChangeEventDetail } from "./Select.js"; + +/** + * @class + * + *

    Overview

    + * The ui5-toolbar-select component is used to create a toolbar drop-down list. + * The items inside the ui5-toolbar-select define the available options by using the ui5-toolbar-select-option component. + * + *

    ES6 Module Import

    + * import "@ui5/webcomponents/dist/ToolbarSelect"; + *
    + * import "@ui5/webcomponents/dist/ToolbarSelectOption"; (comes with ui5-toolbar-select) + * @constructor + * @author SAP SE + * @alias sap.ui.webc.main.ToolbarSelect + * @extends sap.ui.webc.base.UI5Element + * @tagname ui5-toolbar-select + * @appenddocs sap.ui.webc.main.ToolbarSelectOption + * @public + * @since 1.17.0 + */ +@customElement({ + tag: "ui5-toolbar-select", + dependencies: [Select, Option], +}) + +/** + * Fired when the selected option changes. + * + * @event sap.ui.webc.main.ToolbarSelect#change + * @allowPreventDefault + * @param {HTMLElement} selectedOption the selected option. + * @public + */ +@event("change", { + detail: { + selectedOption: { type: HTMLElement }, + }, +}) + +/** + * Fired after the component's dropdown menu opens. + * + * @event sap.ui.webc.main.ToolbarSelect#open + * @public + */ +@event("open") +/** + * Fired after the component's dropdown menu closes. + * + * @event sap.ui.webc.main.ToolbarSelect#close + * @public + */ +@event("close") + +class ToolbarSelect extends ToolbarItem { + @property({ type: String }) + width!: string; + + /** + * Defines the component options. + * + *

    + * Note: Only one selected option is allowed. + * If more than one option is defined as selected, the last one would be considered as the selected one. + * + *

    + * Note: Use the ui5-toolbar-select-option component to define the desired options. + * @type {sap.ui.webc.main.ISelectOption[]} + * @slot options + * @name sap.ui.webc.main.ToolbarSelect.prototype.default + * @public + */ + @slot({ "default": true, type: HTMLElement, invalidateOnChildChange: true }) + options!: Array