From b68fe54d3505f049c98854642f6e41f7dd6e0270 Mon Sep 17 00:00:00 2001 From: Plamen Ivanov Date: Tue, 11 Aug 2026 17:01:11 +0300 Subject: [PATCH] feat(ui5-select): add ui5-option-group component for grouped options Introduces ui5-option-group, a new public component that allows grouping options inside ui5-select, matching the ComboBox's ui5-combobox-item-group API pattern. Key changes: - New ui5-option-group component extending ListItemGroup, with individualSlots: true and ARIA group semantics (role="group", aria-label, aria-roledescription) - Select._flatOptions getter flattens grouped + ungrouped options for all navigation and selection logic - Per-group aria-setsize/aria-posinset injected via _forcedSetsize and _forcedPosinset on Option, rendered via ListItemBaseTemplate - Hidden live-region span on the Select trigger announces option count and group count on focus (aria-describedby) - IOptionGroup interface exported from Select.ts for consumers - Website docs sample added (OptionGroups) JIRA: BGSOFUIPIRIN-7094 --- .../cypress/specs/SelectOptionGroup.cy.tsx | 195 ++++++++++++++++++ packages/main/src/ListItemBaseTemplate.tsx | 4 + packages/main/src/Option.ts | 14 ++ packages/main/src/OptionGroup.ts | 66 ++++++ packages/main/src/OptionGroupTemplate.tsx | 20 ++ packages/main/src/OptionTemplate.tsx | 7 +- packages/main/src/Select.ts | 118 ++++++++--- packages/main/src/SelectPopoverTemplate.tsx | 2 +- packages/main/src/SelectTemplate.tsx | 6 + packages/main/src/bundle.esm.ts | 1 + .../main/src/i18n/messagebundle.properties | 3 + .../main/src/i18n/messagebundle_en.properties | 2 + packages/main/src/themes/OptionGroup.css | 28 +++ packages/main/test/pages/Select.html | 37 ++++ .../_components_pages/main/Select/Select.mdx | 7 + .../main/Select/OptionGroups/OptionGroups.md | 5 + .../_samples/main/Select/OptionGroups/main.js | 3 + .../main/Select/OptionGroups/sample.html | 27 +++ .../main/Select/OptionGroups/sample.tsx | 29 +++ 19 files changed, 543 insertions(+), 31 deletions(-) create mode 100644 packages/main/cypress/specs/SelectOptionGroup.cy.tsx create mode 100644 packages/main/src/OptionGroup.ts create mode 100644 packages/main/src/OptionGroupTemplate.tsx create mode 100644 packages/main/src/themes/OptionGroup.css create mode 100644 packages/website/docs/_samples/main/Select/OptionGroups/OptionGroups.md create mode 100644 packages/website/docs/_samples/main/Select/OptionGroups/main.js create mode 100644 packages/website/docs/_samples/main/Select/OptionGroups/sample.html create mode 100644 packages/website/docs/_samples/main/Select/OptionGroups/sample.tsx diff --git a/packages/main/cypress/specs/SelectOptionGroup.cy.tsx b/packages/main/cypress/specs/SelectOptionGroup.cy.tsx new file mode 100644 index 0000000000000..3e37fd56c6edf --- /dev/null +++ b/packages/main/cypress/specs/SelectOptionGroup.cy.tsx @@ -0,0 +1,195 @@ +import Option from "../../src/Option.js"; +import OptionGroup from "../../src/OptionGroup.js"; +import Select from "../../src/Select.js"; + +describe("Select - OptionGroup rendering", () => { + it("renders group headers with correct text", () => { + cy.mount( + + ); + + cy.get("#sel").realClick(); + cy.get("#g1").shadow().find(".ui5-option-group-header").should("have.text", "Oceania"); + cy.get("#g2").shadow().find(".ui5-option-group-header").should("have.text", "Europe"); + }); + + it("renders group container with role=group and aria-label", () => { + cy.mount( + + ); + + cy.get("#sel").realClick(); + cy.get("#g1").shadow().find(".ui5-option-group-root") + .should("have.attr", "role", "group") + .and("have.attr", "aria-label", "Oceania"); + }); +}); + +describe("Select - OptionGroup selection", () => { + it("selects option inside a group by value", () => { + cy.mount( + + ); + + cy.get("#de").should("have.attr", "selected"); + cy.get("#au").should("not.have.attr", "selected"); + }); + + it("selects option inside group via click", () => { + cy.mount( + + ); + + cy.get("#sel").realClick(); + cy.get("#de").realClick(); + cy.get("#sel").should("have.prop", "value", "de"); + }); + + it("fires change event when grouped option is selected", () => { + const changeSpy = cy.stub().as("changeSpy"); + + cy.mount( + + ); + + cy.get("#sel").realClick(); + cy.get("#fr").realClick(); + cy.get("@changeSpy").should("have.been.calledOnce"); + }); + + it("arrow navigation moves through options across groups", () => { + cy.mount( + + ); + + cy.get("#sel").shadow().find("[data-sap-focus-ref]").realClick(); + cy.get("#sel").shadow().find("[data-sap-focus-ref]").realPress("ArrowDown"); + cy.get("#nz").should("have.attr", "selected"); + cy.get("#sel").shadow().find("[data-sap-focus-ref]").realPress("ArrowDown"); + cy.get("#de").should("have.attr", "selected"); + }); +}); + +describe("Select - OptionGroup ARIA", () => { + it("options inside groups have per-group aria-setsize and aria-posinset", () => { + cy.mount( + + ); + + cy.get("#sel").realClick(); + + // Oceania group: setsize=2 + cy.get("#au").shadow().find("[role='option']") + .should("have.attr", "aria-setsize", "2") + .and("have.attr", "aria-posinset", "1"); + cy.get("#nz").shadow().find("[role='option']") + .should("have.attr", "aria-setsize", "2") + .and("have.attr", "aria-posinset", "2"); + + // Europe group: setsize=3 + cy.get("#de").shadow().find("[role='option']") + .should("have.attr", "aria-setsize", "3") + .and("have.attr", "aria-posinset", "1"); + cy.get("#es").shadow().find("[role='option']") + .should("have.attr", "aria-setsize", "3") + .and("have.attr", "aria-posinset", "3"); + }); + + it("trigger has aria-describedby pointing to group count message when groups present", () => { + cy.mount( + + ); + + cy.get("#sel").shadow().find("[role='combobox']").then($trigger => { + const describedBy = $trigger.attr("aria-describedby"); + expect(describedBy).to.include("groupCountDesc"); + }); + }); + + it("trigger has no aria-describedby group count message when no groups", () => { + cy.mount( + + ); + + cy.get("#sel").shadow().find("[role='combobox']").then($trigger => { + const describedBy = $trigger.attr("aria-describedby") ?? ""; + expect(describedBy).not.to.include("groupCountDesc"); + }); + }); + + it("group count hidden span has correct text", () => { + cy.mount( + + ); + + cy.get("#sel").shadow().find("[id$='groupCountDesc']") + .should("contain.text", "3") + .and("contain.text", "2"); + }); +}); diff --git a/packages/main/src/ListItemBaseTemplate.tsx b/packages/main/src/ListItemBaseTemplate.tsx index 93726916589a2..12869dc8d9503 100644 --- a/packages/main/src/ListItemBaseTemplate.tsx +++ b/packages/main/src/ListItemBaseTemplate.tsx @@ -4,6 +4,8 @@ import type { AriaRole, JsxTemplate } from "@ui5/webcomponents-base/"; export default function ListItemBaseTemplate(this: ListItemBase, hooks?: { listItemContent: JsxTemplate }, injectedProps?: { role?: AriaRole, title?: string, + ariaSetsize?: number, + ariaPosinset?: number, }) { const listItemContent = hooks?.listItemContent || defaultListItemContent; @@ -16,6 +18,8 @@ export default function ListItemBaseTemplate(this: ListItemBase, hooks?: { listI draggable={this.movable} role={injectedProps?.role} title={injectedProps?.title} + {...(injectedProps?.ariaSetsize !== undefined && { "aria-setsize": injectedProps.ariaSetsize })} + {...(injectedProps?.ariaPosinset !== undefined && { "aria-posinset": injectedProps.ariaPosinset })} onFocusIn={this._onfocusin} onKeyUp={this._onkeyup} onKeyDown={this._onkeydown} diff --git a/packages/main/src/Option.ts b/packages/main/src/Option.ts index 6cd5c44cdfc2f..e9f3c8a0d94df 100644 --- a/packages/main/src/Option.ts +++ b/packages/main/src/Option.ts @@ -103,6 +103,20 @@ class Option extends ListItemBase implements IOption { return !!this.icon; } + /** + * Per-group aria-setsize, set by Select when this option is inside a ui5-option-group. + * @private + */ + @property({ type: Number, noAttribute: true }) + _forcedSetsize?: number; + + /** + * Per-group aria-posinset, set by Select when this option is inside a ui5-option-group. + * @private + */ + @property({ type: Number, noAttribute: true }) + _forcedPosinset?: number; + get effectiveDisplayText() { return this.textContent || ""; } diff --git a/packages/main/src/OptionGroup.ts b/packages/main/src/OptionGroup.ts new file mode 100644 index 0000000000000..728c7c96054ff --- /dev/null +++ b/packages/main/src/OptionGroup.ts @@ -0,0 +1,66 @@ +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js"; +import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; +import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; +import type { DefaultSlot } from "@ui5/webcomponents-base/dist/UI5Element.js"; +import createInstanceChecker from "@ui5/webcomponents-base/dist/util/createInstanceChecker.js"; +import ListItemGroup from "./ListItemGroup.js"; +import type Option from "./Option.js"; +import OptionGroupTemplate from "./OptionGroupTemplate.js"; +import { LIST_ITEM_GROUP_HEADER } from "./generated/i18n/i18n-defaults.js"; +import OptionGroupCss from "./generated/themes/OptionGroup.css.js"; + +/** + * @class + * + * ### Overview + * + * The `ui5-option-group` component is used to group options within a `ui5-select`. + * + * ### ES6 Module Import + * + * `import "@ui5/webcomponents/dist/OptionGroup.js";` + * @constructor + * @extends ListItemGroup + * @public + * @since 2.10.0 + */ +@customElement({ + tag: "ui5-option-group", + languageAware: true, + template: OptionGroupTemplate, + styles: [OptionGroupCss], +}) +class OptionGroup extends ListItemGroup { + eventDetails!: ListItemGroup["eventDetails"]; + + @i18n("@ui5/webcomponents") + static i18nBundle: I18nBundle; + + /** + * Defines the options of the group. + * + * **Note:** Use the `ui5-option` component to define the desired options. + * @public + */ + @slot({ + "default": true, + invalidateOnChildChange: true, + individualSlots: true, + type: HTMLElement, + }) + items!: DefaultSlot