("isOptionGroup");
+export default OptionGroup;
diff --git a/packages/main/src/OptionGroupTemplate.tsx b/packages/main/src/OptionGroupTemplate.tsx
new file mode 100644
index 0000000000000..c256cf9196e82
--- /dev/null
+++ b/packages/main/src/OptionGroupTemplate.tsx
@@ -0,0 +1,20 @@
+import type OptionGroup from "./OptionGroup.js";
+import type Option from "./Option.js";
+
+export default function OptionGroupTemplate(this: OptionGroup) {
+ return (
+
+ {this.headerText &&
+
+ }
+ {this.items.map((item: Option) => )}
+
+ );
+}
diff --git a/packages/main/src/OptionTemplate.tsx b/packages/main/src/OptionTemplate.tsx
index 56bf7e404af7c..d61573ef120ee 100644
--- a/packages/main/src/OptionTemplate.tsx
+++ b/packages/main/src/OptionTemplate.tsx
@@ -3,7 +3,12 @@ import ListItemBaseTemplate from "./ListItemBaseTemplate.js";
import type Option from "./Option.js";
export default function OptionTemplate(this: Option) {
- return ListItemBaseTemplate.call(this, { listItemContent }, { role: "option", title: this.tooltip });
+ return ListItemBaseTemplate.call(this, { listItemContent }, {
+ role: "option",
+ title: this.tooltip,
+ ariaSetsize: this._forcedSetsize,
+ ariaPosinset: this._forcedPosinset,
+ });
}
function listItemContent(this: Option) {
diff --git a/packages/main/src/Select.ts b/packages/main/src/Select.ts
index 6baa950aa14e3..ce5297d31e28f 100644
--- a/packages/main/src/Select.ts
+++ b/packages/main/src/Select.ts
@@ -55,6 +55,7 @@ import {
SELECT_LISTBOX_LABEL,
SELECT_DIALOG_CANCEL_BUTTON,
FORM_SELECTABLE_REQUIRED,
+ SELECT_OPTIONS_IN_GROUPS,
} from "./generated/i18n/i18n-defaults.js";
import Label from "./Label.js";
import ResponsivePopover from "./ResponsivePopover.js";
@@ -62,6 +63,7 @@ import Popover from "./Popover.js";
import Icon from "./Icon.js";
import Button from "./Button.js";
import type ListItemBase from "./ListItemBase.js";
+import OptionGroup, { isInstanceOfOptionGroup } from "./OptionGroup.js";
// Templates
import SelectTemplate from "./SelectTemplate.js";
@@ -83,6 +85,18 @@ interface IOption extends ListItemBase {
additionalText?: string,
focused: boolean,
effectiveDisplayText: string,
+ _forcedSetsize?: number,
+ _forcedPosinset?: number,
+}
+
+/**
+ * Interface for group containers slotted inside `ui5-select`
+ * @public
+ */
+interface IOptionGroup {
+ isOptionGroup: boolean,
+ items: Array,
+ headerText?: string,
}
type SelectChangeEventDetail = {
@@ -174,6 +188,7 @@ const isPrintableCharacter = (e: KeyboardEvent) => {
List,
Icon,
Button,
+ OptionGroup,
],
})
/**
@@ -404,10 +419,11 @@ class Select extends UI5Element implements IFormInputElement {
* If more than one option is defined as selected, the last one would be considered as the selected one.
*
* **Note:** Use the `ui5-option` component to define the desired options.
+ * Use the `ui5-option-group` component to group options.
* @public
*/
@slot({ "default": true, type: HTMLElement, invalidateOnChildChange: true })
- options!: DefaultSlot;
+ options!: DefaultSlot;
/**
* Defines the value state message that will be displayed as pop up under the component.
@@ -474,9 +490,49 @@ class Select extends UI5Element implements IFormInputElement {
deregisterUI5Element(this);
}
+ get _flatOptions(): Array {
+ return this.options.flatMap(item => {
+ if (isInstanceOfOptionGroup(item as OptionGroup)) {
+ return (item as IOptionGroup).items;
+ }
+ return [item as IOption];
+ });
+ }
+
+ get hasGroups(): boolean {
+ return this.options.some(item => isInstanceOfOptionGroup(item as OptionGroup));
+ }
+
+ get _groupCountMessageId(): string {
+ return `${this._id}-groupCountDesc`;
+ }
+
+ get _groupCountText(): string {
+ if (!this.hasGroups) {
+ return "";
+ }
+ const groups = this.options.filter(item => isInstanceOfOptionGroup(item as OptionGroup)) as Array;
+ return Select.i18nBundle.getText(SELECT_OPTIONS_IN_GROUPS, this._flatOptions.length, groups.length);
+ }
+
+ _applyGroupAriaPositions() {
+ if (!this.hasGroups) {
+ return;
+ }
+ this.options
+ .filter(item => isInstanceOfOptionGroup(item as OptionGroup))
+ .forEach(item => {
+ const group = item as IOptionGroup;
+ group.items.forEach((option, idx) => {
+ option._forcedSetsize = group.items.length;
+ option._forcedPosinset = idx + 1;
+ });
+ });
+ }
+
onBeforeRendering() {
this._applySelection();
-
+ this._applyGroupAriaPositions();
this.style.setProperty("--_ui5-input-icons-count", `${this.iconsCount}`);
}
@@ -510,8 +566,7 @@ class Select extends UI5Element implements IFormInputElement {
*/
_applySelectionByValue(value: string) {
if (value !== (this.selectedOption?.value || this.selectedOption?.textContent)) {
- const options = Array.from(this.children) as Array;
- options.forEach(option => {
+ this._flatOptions.forEach(option => {
option.selected = !!((option.getAttribute("value") || option.textContent) === value);
});
}
@@ -522,10 +577,10 @@ class Select extends UI5Element implements IFormInputElement {
* or selects the last option if multiple options are selected.
*/
_applyAutoSelection() {
- let selectedIndex = this.options.findLastIndex(option => option.selected);
+ let selectedIndex = this._flatOptions.findLastIndex(option => option.selected);
selectedIndex = selectedIndex === -1 ? 0 : selectedIndex;
- for (let i = 0; i < this.options.length; i++) {
- this.options[i].selected = selectedIndex === i;
+ for (let i = 0; i < this._flatOptions.length; i++) {
+ this._flatOptions[i].selected = selectedIndex === i;
if (selectedIndex === i) {
break;
}
@@ -589,7 +644,7 @@ class Select extends UI5Element implements IFormInputElement {
}
get _selectedIndex() {
- return this.options.findIndex(option => option.selected);
+ return this._flatOptions.findIndex(option => option.selected);
}
/**
@@ -598,7 +653,7 @@ class Select extends UI5Element implements IFormInputElement {
* @default undefined
*/
get selectedOption(): IOption | undefined {
- return this.options.find(option => option.selected);
+ return this._flatOptions.find(option => option.selected);
}
/**
@@ -740,7 +795,7 @@ class Select extends UI5Element implements IFormInputElement {
const itemToSelect = this._searchNextItemByText(text);
if (itemToSelect) {
- const nextIndex = this.options.indexOf(itemToSelect);
+ const nextIndex = this._flatOptions.indexOf(itemToSelect);
this._changeSelectedItem(this._selectedIndex, nextIndex);
@@ -752,7 +807,7 @@ class Select extends UI5Element implements IFormInputElement {
}
_searchNextItemByText(text: string) {
- let orderedOptions = this.options.slice(0);
+ let orderedOptions = this._flatOptions.slice(0);
const optionsAfterSelected = orderedOptions.splice(this._selectedIndex + 1, orderedOptions.length - this._selectedIndex);
const optionsBeforeSelected = orderedOptions.splice(0, orderedOptions.length - 1);
@@ -778,7 +833,7 @@ class Select extends UI5Element implements IFormInputElement {
return;
}
- const lastIndex = this.options.length - 1;
+ const lastIndex = this._flatOptions.length - 1;
this._changeSelectedItem(this._selectedIndex, lastIndex);
}
@@ -793,19 +848,19 @@ class Select extends UI5Element implements IFormInputElement {
}
_getItemIndex(item: IOption) {
- return this.options.indexOf(item);
+ return this._flatOptions.indexOf(item);
}
_select(index: number) {
const selectedIndex = this._selectedIndex;
- if (index < 0 || index >= this.options.length || this.options.length === 0) {
+ if (index < 0 || index >= this._flatOptions.length || this._flatOptions.length === 0) {
return;
}
- if (this.options[selectedIndex]) {
- this.options[selectedIndex].selected = false;
+ if (this._flatOptions[selectedIndex]) {
+ this._flatOptions[selectedIndex].selected = false;
}
- const selectedOption = this.options[index];
+ const selectedOption = this._flatOptions[index];
if (selectedIndex !== index) {
this.fireDecoratorEvent("live-change", { selectedOption });
}
@@ -891,7 +946,7 @@ class Select extends UI5Element implements IFormInputElement {
}
_changeSelectedItem(oldIndex: number, newIndex: number) {
- const options: Array = this.options;
+ const options: Array = this._flatOptions;
// Normalize: first navigation with Up when nothing selected -> last item
if (oldIndex === -1 && newIndex < 0 && options.length) {
@@ -931,7 +986,7 @@ class Select extends UI5Element implements IFormInputElement {
}
_getNextOptionIndex() {
- return this._selectedIndex === (this.options.length - 1) ? this._selectedIndex : (this._selectedIndex + 1);
+ return this._selectedIndex === (this._flatOptions.length - 1) ? this._selectedIndex : (this._selectedIndex + 1);
}
_getPreviousOptionIndex() {
@@ -940,7 +995,7 @@ class Select extends UI5Element implements IFormInputElement {
_beforeOpen() {
this._selectedIndexBeforeOpen = this._selectedIndex;
- this._lastSelectedOption = this.options[this._selectedIndex];
+ this._lastSelectedOption = this._flatOptions[this._selectedIndex];
}
_afterOpen() {
@@ -952,7 +1007,7 @@ class Select extends UI5Element implements IFormInputElement {
}
_applyFocusToSelectedItem() {
- this.options.forEach(option => {
+ this._flatOptions.forEach(option => {
option.focused = option.selected;
if (option.focused) {
// move focus to the selected option so screen readers
@@ -970,9 +1025,9 @@ class Select extends UI5Element implements IFormInputElement {
if (this._escapePressed) {
this._select(this._selectedIndexBeforeOpen);
this._escapePressed = false;
- } else if (this._lastSelectedOption !== this.options[this._selectedIndex]) {
- this._fireChangeEvent(this.options[this._selectedIndex]);
- this._lastSelectedOption = this.options[this._selectedIndex];
+ } else if (this._lastSelectedOption !== this._flatOptions[this._selectedIndex]) {
+ this._fireChangeEvent(this._flatOptions[this._selectedIndex]);
+ this._lastSelectedOption = this._flatOptions[this._selectedIndex];
}
this.fireDecoratorEvent("close");
}
@@ -1058,7 +1113,7 @@ class Select extends UI5Element implements IFormInputElement {
}
get _currentlySelectedOption() {
- return this.options[this._selectedIndex];
+ return this._flatOptions[this._selectedIndex];
}
get _effectiveTabIndex() {
@@ -1108,8 +1163,8 @@ class Select extends UI5Element implements IFormInputElement {
"display": "block",
},
responsivePopoverHeader: {
- "display": this.options.length && this._listWidth === 0 ? "none" : "inline-block",
- "width": `${this.options.length ? this._listWidth : this.offsetWidth}px`,
+ "display": this._flatOptions.length && this._listWidth === 0 ? "none" : "inline-block",
+ "width": `${this._flatOptions.length ? this._listWidth : this.offsetWidth}px`,
"max-width": "100%",
},
responsivePopover: {
@@ -1160,7 +1215,7 @@ class Select extends UI5Element implements IFormInputElement {
itemSelectionAnnounce() {
let text;
- const optionsCount = this.options.length;
+ const optionsCount = this._flatOptions.length;
const itemPositionText = Select.i18nBundle.getText(LIST_ITEM_POSITION, this._selectedIndex + 1, optionsCount);
if (this.focused && this._currentlySelectedOption) {
@@ -1203,7 +1258,11 @@ class Select extends UI5Element implements IFormInputElement {
}
get ariaDescribedByIds() {
- const ids = [this.valueStateTextId, this.ariaDescriptionTextId].filter(Boolean);
+ const ids = [
+ this.valueStateTextId,
+ this.ariaDescriptionTextId,
+ this.hasGroups ? this._groupCountMessageId : undefined,
+ ].filter(Boolean);
return ids.length ? ids.join(" ") : undefined;
}
@@ -1233,6 +1292,7 @@ Select.define();
export default Select;
export type {
IOption,
+ IOptionGroup,
SelectChangeEventDetail,
SelectLiveChangeEventDetail,
};
diff --git a/packages/main/src/SelectPopoverTemplate.tsx b/packages/main/src/SelectPopoverTemplate.tsx
index 8a9ee02b66f2a..6d2768a7766d9 100644
--- a/packages/main/src/SelectPopoverTemplate.tsx
+++ b/packages/main/src/SelectPopoverTemplate.tsx
@@ -9,7 +9,7 @@ import Title from "./Title.js";
export default function SelectPopoverTemplate(this: Select) {
return (
<>
- {this.options.length > 0 &&
+ {this._flatOptions.length > 0 &&
}
+
+ {this.hasGroups &&
+
+ {this._groupCountText}
+
+ }
{SelectPopoverTemplate.call(this)}
>
diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts
index 6ee70761c593b..c7cb5482bc7b2 100644
--- a/packages/main/src/bundle.esm.ts
+++ b/packages/main/src/bundle.esm.ts
@@ -101,6 +101,7 @@ import SegmentedButton from "./SegmentedButton.js";
import SegmentedButtonItem from "./SegmentedButtonItem.js";
import Select from "./Select.js";
import Option from "./Option.js";
+import OptionGroup from "./OptionGroup.js";
import CustomOption from "./OptionCustom.js";
import Slider from "./Slider.js";
import SliderHandle from "./SliderHandle.js";
diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties
index c43151f060cba..3be6f958b1282 100644
--- a/packages/main/src/i18n/messagebundle.properties
+++ b/packages/main/src/i18n/messagebundle.properties
@@ -321,6 +321,9 @@ SELECT_ROLE_DESCRIPTION=Listbox
#XTXT: MultiComboBox and ComboBox icon accessible name
SELECT_OPTIONS=Select Options
+#XTXT: Announced by screen reader when Select with grouped options is opened; {0} is the option count, {1} is the group count
+SELECT_OPTIONS_IN_GROUPS={0} results are available in {1} groups
+
#XACT: ARIA announcement prefix for Select dialog accessible name on mobile (without trailing space)
SELECT_POPOVER_ACCESSIBLE_NAME_PREFIX=Select:
diff --git a/packages/main/src/i18n/messagebundle_en.properties b/packages/main/src/i18n/messagebundle_en.properties
index 68c2a90966f20..73f6e65fe314c 100644
--- a/packages/main/src/i18n/messagebundle_en.properties
+++ b/packages/main/src/i18n/messagebundle_en.properties
@@ -222,6 +222,8 @@ SELECT_ROLE_DESCRIPTION=Listbox
SELECT_OPTIONS=Select Options
+SELECT_OPTIONS_IN_GROUPS={0} results are available in {1} groups
+
SHOW_SELECTED_BUTTON=Show Selected Items Only
INPUT_SUGGESTIONS=Suggestions Available
diff --git a/packages/main/src/themes/OptionGroup.css b/packages/main/src/themes/OptionGroup.css
new file mode 100644
index 0000000000000..bf195f51e16ad
--- /dev/null
+++ b/packages/main/src/themes/OptionGroup.css
@@ -0,0 +1,28 @@
+:host {
+ display: block;
+}
+
+.ui5-option-group-root {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0;
+ margin: 0;
+}
+
+.ui5-option-group-header {
+ display: block;
+ height: var(--_ui5_group_header_list_item_height);
+ line-height: var(--_ui5_group_header_list_item_height);
+ padding: 0 1rem;
+ box-sizing: border-box;
+ background: var(--sapList_GroupHeaderBackground);
+ color: var(--sapList_TableGroupHeaderTextColor);
+ font-family: var(--sapFontHeaderFamily);
+ font-size: var(--sapFontHeader6Size);
+ font-weight: bold;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ pointer-events: none;
+ user-select: none;
+}
diff --git a/packages/main/test/pages/Select.html b/packages/main/test/pages/Select.html
index 2bed8c9f6759e..b0a974942fc16 100644
--- a/packages/main/test/pages/Select.html
+++ b/packages/main/test/pages/Select.html
@@ -324,6 +324,42 @@ Separator configuration
+Select with option groups
+
+
+
+ Australia
+ New Zealand
+
+
+ France
+ Germany
+
+
+
+Select with option groups and additional text
+
+
+
+ Australia
+ Japan
+
+
+ Bulgaria
+ Portugal
+
+
+
+Mixed: flat options and groups
+
+
+ All Regions
+
+ Germany
+ France
+
+
+