Skip to content
Draft
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
186 changes: 186 additions & 0 deletions packages/fiori/cypress/specs/Search.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import Avatar from "@ui5/webcomponents/dist/Avatar.js";
import AvatarSize from "@ui5/webcomponents/dist/types/AvatarSize.js";
import type ResponsivePopover from "@ui5/webcomponents/dist/ResponsivePopover.js";
import { SEARCH_ITEM_SHOW_MORE_COUNT, SEARCH_ITEM_SHOW_MORE_NO_COUNT } from "../../src/generated/i18n/i18n-defaults.js";
import favorite from "@ui5/webcomponents-icons/dist/favorite.js";
import edit from "@ui5/webcomponents-icons/dist/edit.js";
import share from "@ui5/webcomponents-icons/dist/share.js";
import copy from "@ui5/webcomponents-icons/dist/copy.js";

describe("Properties", () => {
it("items slot with groups", () => {
Expand Down Expand Up @@ -509,6 +513,188 @@ describe("Properties", () => {
.find(".ui5-search-item-selected-delete")
.should("not.exist");
});

it("displays action buttons in actions slot", () => {
cy.mount(
<Search>
<SearchItem text="Item 1">
<Button design={ButtonDesign.Transparent} icon={favorite} slot="actions"/>
<Button design={ButtonDesign.Transparent} icon={edit} slot="actions"/>
</SearchItem>
</Search>
);

cy.get("[ui5-search]")
.shadow()
.find("input")
.realClick();

cy.realPress("I");

cy.get("[ui5-search-item]")
.eq(0)
.shadow()
.find(".ui5-search-item-actions")
.should("be.visible");

cy.get("[ui5-search-item]")
.eq(0)
.find("[ui5-button][icon='favorite']")
.should("be.visible");

cy.get("[ui5-search-item]")
.eq(0)
.find("[ui5-button][icon='edit']")
.should("be.visible");
});

it("tab navigation between action buttons works correctly", () => {
cy.mount(
<Search>
<SearchItem text="Item 1" deletable>
<Button design={ButtonDesign.Transparent} icon={favorite} slot="actions"/>
<Button design={ButtonDesign.Transparent} icon={edit} slot="actions"/>
</SearchItem>
</Search>
);

cy.get("[ui5-search]")
.shadow()
.find("input")
.realClick();

cy.realPress("I");
cy.realPress("ArrowDown");

cy.realPress("F2");

cy.get("[ui5-search-item]")
.eq(0)
.find("[ui5-button][icon='favorite']")
.should("be.focused");

cy.realPress("Tab");

cy.get("[ui5-search-item]")
.eq(0)
.find("[ui5-button][icon='edit']")
.should("be.focused");

cy.realPress("Tab");

cy.get("[ui5-search-item]")
.eq(0)
.shadow()
.find(".ui5-search-item-selected-delete")
.should("be.focused");
});

it("shift+tab navigation between action buttons works correctly", () => {
cy.mount(
<Search>
<SearchItem text="Item 1" deletable>
<Button design={ButtonDesign.Transparent} icon={favorite} slot="actions"/>
<Button design={ButtonDesign.Transparent} icon={edit} slot="actions"/>
</SearchItem>
</Search>
);

cy.get("[ui5-search]")
.shadow()
.find("input")
.realClick();

cy.realPress("I");
cy.realPress("ArrowDown");

cy.realPress("F2");

cy.get("[ui5-search-item]")
.eq(0)
.shadow()
.find(".ui5-search-item-selected-delete")
.realClick();

cy.realPress(["Shift", "Tab"]);

cy.get("[ui5-search-item]")
.eq(0)
.find("[ui5-button][icon='edit']")
.should("be.focused");

cy.realPress(["Shift", "Tab"]);

cy.get("[ui5-search-item]")
.eq(0)
.find("[ui5-button][icon='favorite']")
.should("be.focused");
});

it("action button clicks work correctly", () => {
cy.mount(
<Search>
<SearchItem text="Item 1">
<Button design={ButtonDesign.Transparent} icon={favorite} slot="actions" onClick={cy.spy().as("favoriteClick")}/>
<Button design={ButtonDesign.Transparent} icon={share} slot="actions" onClick={cy.spy().as("shareClick")}/>
</SearchItem>
</Search>
);

cy.get("[ui5-search]")
.shadow()
.find("input")
.realClick();

cy.realPress("I");

cy.get("[ui5-search-item]")
.eq(0)
.find("[ui5-button][icon='favorite']")
.realClick();

cy.get("@favoriteClick").should("have.been.calledOnce");

cy.get("[ui5-search-item]")
.eq(0)
.find("[ui5-button][icon='share']")
.realClick();

cy.get("@shareClick").should("have.been.calledOnce");
});

it("tab navigation moves to next search item when at last action button", () => {
cy.mount(
<Search>
<SearchItem text="Item 1">
<Button design={ButtonDesign.Transparent} icon={favorite} slot="actions"/>
</SearchItem>
<SearchItem text="Item 2">
<Button design={ButtonDesign.Transparent} icon={copy} slot="actions"/>
</SearchItem>
</Search>
);

cy.get("[ui5-search]")
.shadow()
.find("input")
.realClick();

cy.realPress("I");
cy.realPress("ArrowDown");

cy.realPress("F2");

cy.get("[ui5-search-item]")
.eq(0)
.find("[ui5-button][icon='favorite']")
.should("be.focused");

cy.realPress("Tab");

cy.get("[ui5-search-item]")
.eq(1)
.should("be.focused");
});
});

describe("Events", () => {
Expand Down
90 changes: 89 additions & 1 deletion packages/fiori/src/SearchItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import { SEARCH_ITEM_DELETE_BUTTON } from "./generated/i18n/i18n-defaults.js";
import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js";
import { getFirstFocusableElement } from "@ui5/webcomponents-base/dist/util/FocusableElements.js";
import { isSpace, isEnter, isF2 } from "@ui5/webcomponents-base/dist/Keys.js";
import { getTabbableElements } from "@ui5/webcomponents-base/dist/util/TabbableElements.js";
import { isSpace, isEnter, isF2, isTabNext, isTabPrevious } from "@ui5/webcomponents-base/dist/Keys.js";

Check failure on line 14 in packages/fiori/src/SearchItem.ts

View workflow job for this annotation

GitHub Actions / check

Expected a line break before this closing brace

Check failure on line 14 in packages/fiori/src/SearchItem.ts

View workflow job for this annotation

GitHub Actions / check

Expected a line break after this opening brace
import { i18n } from "@ui5/webcomponents-base/dist/decorators.js";
import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
// @ts-expect-error
Expand Down Expand Up @@ -117,6 +118,20 @@
@slot()
image!: Array<HTMLElement>;

/**
* Defines the actionable elements.
* This slot allows placing additional interactive elements (such as buttons, icons, or tags)
* next to the delete button, providing flexible customization for various user actions.
*
* **Note:** While the slot is flexible, for consistency with design guidelines,
* it's recommended to use `ui5-button` with `Transparent` design or `ui5-icon` elements.
*
* @public
* @since 2.15.0
*/
@slot()
actions!: Array<HTMLElement>;

_markupText = "";

@i18n("@ui5/webcomponents-fiori")
Expand All @@ -133,15 +148,28 @@
}

async _onkeydown(e: KeyboardEvent) {
// Handle manual tab navigation between action items
if (isTabNext(e) || isTabPrevious(e)) {
const handled = this._handleTabNavigation(e);
if (handled) {
e.preventDefault();
e.stopPropagation();
return;
}
}

// Call super for other key handling
super._onkeydown(e);

// Handle space/enter when focus is within action items
if (this.getFocusDomRef()!.matches(":has(:focus-within)")) {
if (isSpace(e) || isEnter(e)) {
e.preventDefault();
return;
}
}

// Handle F2 for focus navigation
if (isF2(e)) {
e.stopImmediatePropagation();
const activeElement = getActiveElement();
Expand All @@ -160,6 +188,51 @@
}
}

/**
* Handles manual tab navigation between action items and delete button
*/
_handleTabNavigation(e: KeyboardEvent): boolean {
const focusDomRef = this.getFocusDomRef();
if (!focusDomRef) {
return false;
}

const tabbableElements = getTabbableElements(focusDomRef);
if (tabbableElements.length === 0) {
return false;
}

const activeElement = getActiveElement() as HTMLElement;
const currentIndex = tabbableElements.indexOf(activeElement);

if (currentIndex === -1) {
return false;
}

let nextElement: HTMLElement | null = null;

if (isTabNext(e)) {
if (currentIndex < tabbableElements.length - 1) {
nextElement = tabbableElements[currentIndex + 1];
} else {
return false;
}
} else if (isTabPrevious(e)) {
if (currentIndex > 0) {
nextElement = tabbableElements[currentIndex - 1];
} else {
return false;
}
}

if (nextElement) {
nextElement.focus();
return true;
}

return false;
}

_onDeleteButtonClick() {
this.fireDecoratorEvent("delete");
}
Expand All @@ -177,9 +250,24 @@
this._markupText = this.highlightText ? generateHighlightedMarkup((this.text || ""), this.highlightText) : encodeXML(this.text || "");
}

/**
* Determines if the current search item either has no tabbable content or
* [Tab] is performed on the last tabbable content item.
* This method is crucial for proper tab navigation between action items.
*/
shouldForwardTabAfter() {
const aContent = getTabbableElements(this.getFocusDomRef()!);

return aContent.length === 0 || (aContent[aContent.length - 1] === getActiveElement());
}

get _deleteButtonTooltip() {
return SearchItem.i18nBundle.getText(SEARCH_ITEM_DELETE_BUTTON);
}

get hasActions() {
return !!this.actions.length;
}
}

SearchItem.define();
Expand Down
24 changes: 16 additions & 8 deletions packages/fiori/src/SearchItemTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,22 @@ export default function SearchFieldTemplate(this: SearchItem) {
<span part="subtitle" class="ui5-search-item-description">{this.description}</span>
</div>

{this.deletable &&
<Button class="ui5-search-item-selected-delete"
design={ButtonDesign.Transparent}
icon={decline}
onClick={this._onDeleteButtonClick}
tooltip={this._deleteButtonTooltip}
onKeyDown={this._onDeleteButtonKeyDown}></Button>
}
<div class="ui5-search-item-actions-container">
{this.hasActions &&
<div class="ui5-search-item-actions">
<slot name="actions"></slot>
</div>
}

{this.deletable &&
<Button class="ui5-search-item-selected-delete"
design={ButtonDesign.Transparent}
icon={decline}
onClick={this._onDeleteButtonClick}
tooltip={this._deleteButtonTooltip}
onKeyDown={this._onDeleteButtonKeyDown}></Button>
}
</div>
</div>
</div>
</li >
Expand Down
Loading
Loading