Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 277 additions & 0 deletions packages/main/cypress/specs/Select.cy.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -1913,3 +1914,279 @@ describe("Select general interaction", () => {
.should("be.focused");
});
});

describe("Select - OptionGroup", () => {
it("renders group headers and options within groups", () => {
cy.mount(
<Select id="sel">
<OptionGroup id="group1" headerText="Oceania">
<Option value="au">Australia</Option>
<Option value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup id="group2" headerText="Europe">
<Option value="fr">France</Option>
<Option value="de">Germany</Option>
</OptionGroup>
</Select>
);

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(
<Select id="sel">
<OptionGroup headerText="Oceania">
<Option value="au">Australia</Option>
<Option value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup headerText="Europe">
<Option value="fr">France</Option>
<Option value="de">Germany</Option>
</OptionGroup>
</Select>
);

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(
<Select id="sel">
<OptionGroup headerText="Oceania">
<Option value="au" selected>Australia</Option>
<Option value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup headerText="Europe">
<Option value="fr">France</Option>
<Option value="de">Germany</Option>
</OptionGroup>
</Select>
);

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(
<Select id="sel">
<OptionGroup headerText="Oceania">
<Option value="au">Australia</Option>
<Option value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup headerText="Europe">
<Option value="fr" selected>France</Option>
<Option value="de">Germany</Option>
</OptionGroup>
</Select>
);

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(
<Select>
<OptionGroup id="group1" headerText={GROUP_HEADER}>
<Option value="au">Australia</Option>
<Option value="nz">New Zealand</Option>
</OptionGroup>
</Select>
);

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(
<Select>
<OptionGroup id="group1" headerText="Oceania">
<Option value="au">Australia</Option>
</OptionGroup>
</Select>
);

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(
<Select>
<OptionGroup headerText="Oceania">
<Option id="opt1" value="au">Australia</Option>
<Option id="opt2" value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup headerText="Europe">
<Option id="opt3" value="fr">France</Option>
</OptionGroup>
</Select>
);

// 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(
<Select id="sel">
<OptionGroup headerText="Oceania">
<Option value="au">Australia</Option>
<Option value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup headerText="Europe">
<Option value="fr">France</Option>
<Option value="de">Germany</Option>
</OptionGroup>
</Select>
);

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(
<Select id="sel">
<Option value="a">Option A</Option>
<Option value="b">Option B</Option>
</Select>
);

cy.get("#sel")
.shadow()
.find("[id$='-groupCountDesc']")
.should("not.exist");
});

it("Home key navigates to first option across groups", () => {
cy.mount(
<Select id="sel">
<OptionGroup headerText="Oceania">
<Option value="au">Australia</Option>
<Option value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup headerText="Europe">
<Option value="fr">France</Option>
<Option value="de" selected>Germany</Option>
</OptionGroup>
</Select>
);

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(
<Select id="sel">
<OptionGroup headerText="Oceania">
<Option value="au" selected>Australia</Option>
<Option value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup headerText="Europe">
<Option value="fr">France</Option>
<Option value="de">Germany</Option>
</OptionGroup>
</Select>
);

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");
});
});
4 changes: 4 additions & 0 deletions packages/main/src/ListItemBaseTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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}
Expand Down
14 changes: 14 additions & 0 deletions packages/main/src/Option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "";
}
Expand Down
60 changes: 60 additions & 0 deletions packages/main/src/OptionGroup.ts
Original file line number Diff line number Diff line change
@@ -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<Option>;

get isOptionGroup(): boolean {
return true;
}

get _groupHeaderRoleDescription(): string {
return OptionGroup.i18nBundle.getText(LIST_ITEM_GROUP_HEADER);
}
}

OptionGroup.define();

export const isInstanceOfOptionGroup = createInstanceChecker<OptionGroup>("isOptionGroup");
export default OptionGroup;
Loading
Loading