diff --git a/packages/main/cypress/specs/Select.cy.tsx b/packages/main/cypress/specs/Select.cy.tsx index 54ae56d3906f..2f4ec920cd30 100644 --- a/packages/main/cypress/specs/Select.cy.tsx +++ b/packages/main/cypress/specs/Select.cy.tsx @@ -1,5 +1,6 @@ import Option from "../../src/Option.js"; import OptionCustom from "../../src/OptionCustom.js"; +import OptionGroup from "../../src/OptionGroup.js"; import Select from "../../src/Select.js"; import download from "@ui5/webcomponents-icons/dist/download.js"; @@ -1913,3 +1914,279 @@ describe("Select general interaction", () => { .should("be.focused"); }); }); + +describe("Select - OptionGroup", () => { + it("renders group headers and options within groups", () => { + cy.mount( + + ); + + cy.get("#group1") + .shadow() + .find(".ui5-option-group-header") + .should("have.text", "Oceania"); + + cy.get("#group2") + .shadow() + .find(".ui5-option-group-header") + .should("have.text", "Europe"); + + cy.get("[ui5-option]").should("have.length", 4); + }); + + it("fires change event when selecting an option inside a group", () => { + cy.mount( + + ); + + cy.get("#sel") + .as("select") + .then(($select) => { + $select[0].addEventListener("ui5-change", cy.stub().as("changeStub")); + }); + + cy.get("@select").realClick(); + cy.get("@select").should("have.attr", "opened"); + + cy.get("[ui5-option]").eq(2).realClick(); + + cy.get("@changeStub").should("have.been.calledOnce"); + cy.get("@select").should("have.prop", "value", "fr"); + cy.get("@select") + .shadow() + .find(".ui5-select-label-root") + .should("contain.text", "France"); + }); + + it("ArrowDown navigation skips group headers crossing group boundary", () => { + cy.mount( + + ); + + cy.get("#sel").as("select").realClick(); + cy.get("@select").should("have.attr", "opened"); + + // ArrowDown: Australia → New Zealand + cy.get("@select").realPress("ArrowDown"); + // ArrowDown: New Zealand → France (group header is not focusable) + cy.get("@select").realPress("ArrowDown"); + cy.get("@select").realPress("Enter"); + + cy.get("@select").should("have.prop", "value", "fr"); + cy.get("@select") + .shadow() + .find(".ui5-select-label-root") + .should("contain.text", "France"); + }); + + it("ArrowUp navigation skips group headers moving backwards", () => { + cy.mount( + + ); + + cy.get("#sel").as("select").realClick(); + cy.get("@select").should("have.attr", "opened"); + + // ArrowUp: France → New Zealand (group header is not focusable) + cy.get("@select").realPress("ArrowUp"); + cy.get("@select").realPress("Enter"); + + cy.get("@select").should("have.prop", "value", "nz"); + cy.get("@select") + .shadow() + .find(".ui5-select-label-root") + .should("contain.text", "New Zealand"); + }); + + it("group container has role=group and aria-label in shadow DOM", () => { + const GROUP_HEADER = "Oceania"; + + cy.mount( + + ); + + cy.get("#group1") + .shadow() + .find(".ui5-option-group-root") + .should("have.attr", "role", "group") + .should("have.attr", "aria-label", GROUP_HEADER); + }); + + it("group header div is aria-hidden", () => { + cy.mount( + + ); + + cy.get("#group1") + .shadow() + .find(".ui5-option-group-header") + .should("have.attr", "aria-hidden", "true"); + }); + + it("sets per-group aria-setsize and aria-posinset on options", () => { + cy.mount( + + ); + + // Group 1 has 2 items: setsize=2, posinset 1 and 2 + cy.get("#opt1") + .shadow() + .find("li.ui5-li-root") + .should("have.attr", "aria-setsize", "2") + .should("have.attr", "aria-posinset", "1"); + + cy.get("#opt2") + .shadow() + .find("li.ui5-li-root") + .should("have.attr", "aria-setsize", "2") + .should("have.attr", "aria-posinset", "2"); + + // Group 2 has 1 item: setsize=1, posinset 1 + cy.get("#opt3") + .shadow() + .find("li.ui5-li-root") + .should("have.attr", "aria-setsize", "1") + .should("have.attr", "aria-posinset", "1"); + }); + + it("trigger aria-describedby references group count message span when groups are present", () => { + cy.mount( + + ); + + cy.get("#sel") + .shadow() + .find(".ui5-select-label-root") + .should("have.attr", "aria-describedby") + .and("contain", "-groupCountDesc"); + + // 4 options in 2 groups + cy.get("#sel") + .shadow() + .find("[id$='-groupCountDesc']") + .should("contain.text", "4") + .should("contain.text", "2"); + }); + + it("trigger does not have group count span for flat (non-grouped) Select", () => { + cy.mount( + + ); + + cy.get("#sel") + .shadow() + .find("[id$='-groupCountDesc']") + .should("not.exist"); + }); + + it("Home key navigates to first option across groups", () => { + cy.mount( + + ); + + cy.get("#sel").as("select").realClick(); + cy.get("@select").should("have.attr", "opened"); + + cy.get("@select").realPress("Home"); + cy.get("@select").realPress("Enter"); + + cy.get("@select").should("have.prop", "value", "au"); + }); + + it("End key navigates to last option across groups", () => { + cy.mount( + + ); + + cy.get("#sel").as("select").realClick(); + cy.get("@select").should("have.attr", "opened"); + + cy.get("@select").realPress("End"); + cy.get("@select").realPress("Enter"); + + cy.get("@select").should("have.prop", "value", "de"); + }); +}); diff --git a/packages/main/src/ListItemBaseTemplate.tsx b/packages/main/src/ListItemBaseTemplate.tsx index 5af5ef6b3628..fb248bce31ef 100644 --- a/packages/main/src/ListItemBaseTemplate.tsx +++ b/packages/main/src/ListItemBaseTemplate.tsx @@ -4,6 +4,8 @@ import type { AriaRole } from "@ui5/webcomponents-base/"; export default function ListItemBaseTemplate(this: ListItemBase, hooks?: { listItemContent: () => void }, 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 6cd5c44cdfc2..a414ba3ba548 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 options are inside an OptionGroup. + * @private + */ + @property({ type: Number, noAttribute: true }) + _forcedSetsize?: number; + + /** + * Per-group aria-posinset, set by Select when options are inside an OptionGroup. + * @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 000000000000..434ca096d119 --- /dev/null +++ b/packages/main/src/OptionGroup.ts @@ -0,0 +1,60 @@ +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 + * 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.x.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. + * @public + */ + @slot({ + "default": true, + invalidateOnChildChange: true, + individualSlots: true, + type: HTMLElement, + }) + items!: DefaultSlot