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
195 changes: 195 additions & 0 deletions packages/main/cypress/specs/SelectOptionGroup.cy.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Select id="sel">
<OptionGroup id="g1" headerText="Oceania">
<Option value="au">Australia</Option>
<Option value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup id="g2" headerText="Europe">
<Option value="de">Germany</Option>
</OptionGroup>
</Select>
);

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

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

cy.get("#de").should("have.attr", "selected");
cy.get("#au").should("not.have.attr", "selected");
});

it("selects option inside group via click", () => {
cy.mount(
<Select id="sel">
<OptionGroup headerText="Oceania">
<Option id="au" value="au">Australia</Option>
<Option id="nz" value="nz">New Zealand</Option>
</OptionGroup>
<OptionGroup headerText="Europe">
<Option id="de" value="de">Germany</Option>
</OptionGroup>
</Select>
);

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(
<Select id="sel" onChange={changeSpy}>
<OptionGroup headerText="Europe">
<Option id="de" value="de">Germany</Option>
<Option id="fr" value="fr">France</Option>
</OptionGroup>
</Select>
);

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

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

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

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

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

cy.get("#sel").shadow().find("[id$='groupCountDesc']")
.should("contain.text", "3")
.and("contain.text", "2");
});
});
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, 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;

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 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 || "";
}
Expand Down
66 changes: 66 additions & 0 deletions packages/main/src/OptionGroup.ts
Original file line number Diff line number Diff line change
@@ -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<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;
20 changes: 20 additions & 0 deletions packages/main/src/OptionGroupTemplate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type OptionGroup from "./OptionGroup.js";
import type Option from "./Option.js";

export default function OptionGroupTemplate(this: OptionGroup) {
return (
<div
class="ui5-option-group-root"
role="group"
aria-label={this.headerText}
aria-roledescription={this._groupHeaderRoleDescription}
>
{this.headerText &&
<div class="ui5-option-group-header" aria-hidden="true">
{this.headerText}
</div>
}
{this.items.map((item: Option) => <slot name={item._individualSlot}></slot>)}
</div>
);
}
7 changes: 6 additions & 1 deletion packages/main/src/OptionTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading