diff --git a/packages/main/cypress/specs/Tokenizer.cy.ts b/packages/main/cypress/specs/Tokenizer.cy.ts new file mode 100644 index 000000000000..93296572b934 --- /dev/null +++ b/packages/main/cypress/specs/Tokenizer.cy.ts @@ -0,0 +1,114 @@ +import { html } from "lit"; +import "../../src/Tokenizer.js"; +import type Tokenizer from "../../src/Tokenizer.js"; + +describe("Tokenizer - multi-line and Clear All", () => { + it("'Clear All' link is rendered for multi-line tokenizer and show-clear-all set to true", () => { + cy.mount(html` + + + + + + + + `); + + cy.get("[ui5-tokenizer]") + .shadow() + .find(".ui5-tokenizer--clear-all") + .should("exist"); + }); + + it("'Clear All' link is rendered even for 1 token when in multi-line mode", () => { + cy.mount(html` + + `); + + cy.get("[ui5-tokenizer]") + .shadow() + .find(".ui5-tokenizer--clear-all") + .should("exist"); + }); + + it("'Clear All' link is not rendered for single-line tokenizer even when show-clear-all is set to true", () => { + cy.mount(html` + + `); + + cy.get("[ui5-tokenizer]") + .shadow() + .find(".ui5-tokenizer--clear-all") + .should("not.exist"); + }); + + it("'Clear All' link is not rendered for multi-line tokenizer when show-clear-all is set to false", () => { + cy.mount(html` + + + + + + + + `); + + cy.get("[ui5-tokenizer]") + .shadow() + .find(".ui5-tokenizer--clear-all") + .should("not.exist"); + }); + + it("'Clear All' link is not rendered for multi-line readonly tokenizer when show-clear-all 'true'", () => { + cy.mount(html` + + + + + + + + `); + + cy.get("[ui5-tokenizer]") + .shadow() + .find(".ui5-tokenizer--clear-all") + .should("not.exist"); + }); + + it("'n-more' link is not rendered for multi-line tokenizer", () => { + cy.mount(html` + + + + + + + + `); + + cy.get("[ui5-tokenizer]") + .shadow() + .find(".ui5-tokenizer--more-text") + .should("not.exist"); + }); + + it("Pressing 'Clear All' link fires token-delete event", () => { + cy.mount(html` + + + + `); + + cy.get("[ui5-tokenizer]").then($tokenizer => $tokenizer.get(0).addEventListener("token-delete", cy.stub().as("delete"))); + + cy.get("[ui5-tokenizer]") + .shadow() + .find(".ui5-tokenizer--clear-all") + .eq(0) + .click(); + + cy.get("@delete") + .should("have.been.calledOnce"); + }); +}); diff --git a/packages/main/src/Tokenizer.hbs b/packages/main/src/Tokenizer.hbs index 91479bd121dd..cabc70339d31 100644 --- a/packages/main/src/Tokenizer.hbs +++ b/packages/main/src/Tokenizer.hbs @@ -8,26 +8,37 @@ @focusout="{{_onfocusout}}" @focusin="{{_onfocusin}}" @ui5-select="{{onTokenSelect}}" - role="listbox" - aria-label="{{tokenizerLabel}}" - aria-description="{{tokenizerAriaDescription}}" - aria-disabled="{{_ariaDisabled}}" - aria-readonly="{{_ariaReadonly}}" > - {{#each tokens}} - - {{/each}} +
+ {{#each tokens}} + + {{/each}} +
+ + {{#if showEffectiveClearAll}} + + {{_clearAllText}} + {{/if}} - {{#if showNMore}} - {{_nMoreText}} - {{/if}} + {{#if showNMore}} + {{_nMoreText}} + {{/if}} {{>include "./TokenizerPopover.hbs"}} \ No newline at end of file diff --git a/packages/main/src/Tokenizer.ts b/packages/main/src/Tokenizer.ts index 1b013bde3eee..2acd01251027 100644 --- a/packages/main/src/Tokenizer.ts +++ b/packages/main/src/Tokenizer.ts @@ -2,6 +2,7 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import slot from "@ui5/webcomponents-base/dist/decorators/slot.js"; import event from "@ui5/webcomponents-base/dist/decorators/event.js"; +import getEffectiveScrollbarStyle from "@ui5/webcomponents-base/dist/util/getEffectiveScrollbarStyle.js"; import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js"; import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; @@ -66,6 +67,7 @@ import { TOKENIZER_ARIA_CONTAIN_ONE_TOKEN, TOKENIZER_ARIA_CONTAIN_SEVERAL_TOKENS, TOKENIZER_SHOW_ALL_ITEMS, + TOKENIZER_CLEAR_ALL, } from "./generated/i18n/i18n-defaults.js"; // Styles @@ -144,6 +146,7 @@ enum ClipboardDataOperation { ResponsivePopoverCommonCss, SuggestionsCss, TokenizerPopoverCss, + getEffectiveScrollbarStyle(), ], dependencies: [ ResponsivePopover, @@ -211,6 +214,26 @@ class Tokenizer extends UI5Element { @property({ type: Boolean }) readonly = false; + /** + * Defines whether tokens are displayed on multiple lines. + * + * **Note:** The `multiLine` property is in an experimental state and is a subject to change. + * @default false + * @public + */ + @property({ type: Boolean }) + multiLine = false; + + /** + * Defines whether "Clear All" button is present. Ensure `multiLine` is enabled, otherwise `showClearAll` will have no effect. + * + * **Note:** The `showClearAll` property is in an experimental state and is a subject to change. + * @default false + * @public + */ + @property({ type: Boolean }) + showClearAll = false; + /** * Defines whether the component is disabled. * @@ -324,7 +347,7 @@ class Tokenizer extends UI5Element { static i18nBundle: I18nBundle; _resizeHandler: ResizeObserverCallback; _itemNav: ItemNavigation; - _scrollEnablement: ScrollEnablement; + _scrollEnablement: ScrollEnablement | undefined; _expandedScrollWidth?: number; _tokenDeleting = false; _preventCollapse = false; @@ -347,16 +370,23 @@ class Tokenizer extends UI5Element { getItemsCallback: this._getVisibleTokens.bind(this), }); - this._scrollEnablement = new ScrollEnablement(this); this._deletedDialogItems = []; } + handleClearAll() { + this.fireDecoratorEvent("token-delete", { tokens: this._tokens }); + } + onBeforeRendering() { + if (!this.multiLine) { + this._scrollEnablement = new ScrollEnablement(this); + } + const tokensLength = this._tokens.length; this._tokensCount = tokensLength; this._tokens.forEach(token => { - token.singleToken = tokensLength === 1; + token.singleToken = (tokensLength === 1) || this.multiLine; token.readonly = this.readonly; }); } @@ -406,13 +436,19 @@ class Tokenizer extends UI5Element { } } - onTokenSelect() { + onTokenSelect(e: CustomEvent) { const tokens = this._tokens; const firstToken = tokens[0]; + const targetToken = e.target as Token; if (tokens.length === 1 && firstToken.isTruncatable) { this.open = firstToken.selected; } + + if (this.multiLine && targetToken.isTruncatable) { + this.opener = targetToken; + this.open = targetToken.selected; + } } _getVisibleTokens() { @@ -435,7 +471,9 @@ class Tokenizer extends UI5Element { firstToken.forcedTabIndex = "0"; } - this._scrollEnablement.scrollContainer = this.contentDom; + if (this._scrollEnablement) { + this._scrollEnablement.scrollContainer = this.contentDom; + } if (this.expanded) { this._expandedScrollWidth = this.contentDom.scrollWidth; @@ -575,9 +613,16 @@ class Tokenizer extends UI5Element { } handleBeforeOpen() { - this._tokens.forEach(token => { - token._isVisible = true; - }); + if (this.multiLine) { + this._resetTokensVisibility(); + + const focusedToken = this._tokens.find(token => token.focused); + focusedToken!._isVisible = true; + } else { + this._tokens.forEach(token => { + token._isVisible = true; + }); + } const list = this._getList(); const firstListItem = list.querySelectorAll("[ui5-li]")[0]! as ListItem; @@ -938,6 +983,20 @@ class Tokenizer extends UI5Element { } } + _resetTokensVisibility() { + this._tokens.forEach(token => { + token._isVisible = false; + }); + } + + get hasTokens() { + return this._tokens.length > 0; + } + + get showEffectiveClearAll() { + return this.showClearAll && this.hasTokens && this.multiLine && !this.readonly; + } + _fillClipboard(shortcutName: ClipboardDataOperation, tokens: Array) { const tokensTexts = tokens.filter(token => token.selected).map(token => token.text).join("\r\n"); @@ -960,8 +1019,8 @@ class Tokenizer extends UI5Element { * @protected */ scrollToStart() { - if (this._scrollEnablement.scrollContainer) { - this._scrollEnablement.scrollTo(0, 0); + if (this._scrollEnablement?.scrollContainer) { + this._scrollEnablement?.scrollTo(0, 0); } } @@ -972,8 +1031,8 @@ class Tokenizer extends UI5Element { */ scrollToEnd() { const expandedTokenizerScrollWidth = this.contentDom && (this.effectiveDir !== "rtl" ? this.contentDom.scrollWidth : -this.contentDom.scrollWidth); - if (this._scrollEnablement.scrollContainer) { - this._scrollEnablement.scrollTo(expandedTokenizerScrollWidth, 0, 5, 10); + if (this._scrollEnablement?.scrollContainer) { + this._scrollEnablement?.scrollTo(expandedTokenizerScrollWidth, 0, 5, 10); } } @@ -991,9 +1050,9 @@ class Tokenizer extends UI5Element { const tokenContainerRect = this.contentDom.getBoundingClientRect(); if (tokenRect.left < tokenContainerRect.left) { - this._scrollEnablement.scrollTo(this.contentDom.scrollLeft - (tokenContainerRect.left - tokenRect.left + 5), 0); + this._scrollEnablement?.scrollTo(this.contentDom.scrollLeft - (tokenContainerRect.left - tokenRect.left + 5), 0); } else if (tokenRect.right > tokenContainerRect.right) { - this._scrollEnablement.scrollTo(this.contentDom.scrollLeft + (tokenRect.right - tokenContainerRect.right + 5), 0); + this._scrollEnablement?.scrollTo(this.contentDom.scrollLeft + (tokenRect.right - tokenContainerRect.right + 5), 0); } } @@ -1025,6 +1084,10 @@ class Tokenizer extends UI5Element { return Tokenizer.i18nBundle.getText(TOKENIZER_SHOW_ALL_ITEMS, this._nMoreCount); } + get _clearAllText() { + return Tokenizer.i18nBundle.getText(TOKENIZER_CLEAR_ALL); + } + get showNMore() { return !this.expanded && !!this.overflownTokens.length; } diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index eadb967ade56..6035281c047b 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -444,6 +444,9 @@ TOKENIZER_POPOVER_REMOVE=All items #XFLD: Token number indicator which is used to show all tokens in Tokenizer when all of the tokens are hidden TOKENIZER_SHOW_ALL_ITEMS={0} Items +#XFLD: Clear All link text in Tokenizer +TOKENIZER_CLEAR_ALL=Clear All + #XACT: Label text for TreeListItem TREE_ITEM_ARIA_LABEL=Tree Item diff --git a/packages/main/src/themes/Tokenizer.css b/packages/main/src/themes/Tokenizer.css index ba08b89f3fe6..2b0f74f06d37 100644 --- a/packages/main/src/themes/Tokenizer.css +++ b/packages/main/src/themes/Tokenizer.css @@ -7,6 +7,29 @@ height: 2.25rem; } +:host([multi-line]) { + height: auto; + + .ui5-tokenizer--content { + display: flex; + align-content: baseline; + flex-wrap: wrap; + padding: .25rem; + box-sizing: border-box; + row-gap: .5rem; + column-gap: .25rem; + overflow-y: auto; + overflow-x: hidden; + } + + ::slotted(ui5-token) { + margin: 0; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; + } +} + :host([disabled]) { opacity: 40%; pointer-events: none; @@ -52,6 +75,10 @@ box-sizing: border-box; } +.ui5-tokenizer--list{ + display: contents; +} + :host([_tokens-count="1"]) .ui5-tokenizer--content { padding-inline-end: 4px; box-sizing: border-box; @@ -77,3 +104,21 @@ .ui5-tokenizer-more-text:active { text-decoration: none; } + +.ui5-tokenizer--clear-all { + color: var(--sapLinkColor); + font-family: var(--sapFontFamily); + font-size: var(--sapFontSize); + cursor: pointer; + outline: none; +} + +.ui5-tokenizer--clear-all:hover { + color: var(--sapLink_Hover_Color); + text-decoration: var(--_ui5_link_hover_text_decoration); +} + +.ui5-tokenizer--clear-all:active { + color: var(--sapLink_Active_Color); + text-decoration: var(--_ui5_link_active_text_decoration); +} \ No newline at end of file diff --git a/packages/main/test/pages/Tokenizer-multi-line.html b/packages/main/test/pages/Tokenizer-multi-line.html new file mode 100644 index 000000000000..064f91562bb9 --- /dev/null +++ b/packages/main/test/pages/Tokenizer-multi-line.html @@ -0,0 +1,161 @@ + + + + + + + Tokenizer - multi-line + + + + + + + + + +
+

Multi Line

+ + + + + + + + + + + + + + + +

Multi Line with long token

+ + + + + + + + + + + + + + + + + +

Multi Line with long token and restricted height

+ + + + + + + + + + + + + + + + +

Multi Line with clear all

+ + + + + + + + + + + + + + + +

Multi Line with restricted height and clear all

+ + + + + + + + + + + + + + + + +

Multi Line with clear all - single token

+ + + + +

Multi Line in readonly mode

+ + + + + + + + + + + + + + + +

Multi Line in disabled mode

+ + + + + + + + + + + + + + +
+ + + + + + \ No newline at end of file diff --git a/packages/main/test/specs/Tokenizer.spec.js b/packages/main/test/specs/Tokenizer.spec.js index 341ce4c763a6..d406b994179e 100644 --- a/packages/main/test/specs/Tokenizer.spec.js +++ b/packages/main/test/specs/Tokenizer.spec.js @@ -233,44 +233,45 @@ describe("Accessibility", () => { it("should test aria-readonly attribute", async () => { const tokenizer = await browser.$("#nmore-tokenizer"); - const tokenizerContent = await tokenizer.shadow$(".ui5-tokenizer--content"); + const tokenizerList = await tokenizer.shadow$(".ui5-tokenizer--list"); const readonlyTokenizer = await browser.$("#readonly-tokenizer"); - const readonlyTokenizerContent = await readonlyTokenizer.shadow$(".ui5-tokenizer--content"); + const readonlyTokenizerList = await readonlyTokenizer.shadow$(".ui5-tokenizer--list"); assert.notOk(await tokenizer.getAttribute("readonly"), "tokenizer should not be readonly"); - assert.notOk(await tokenizerContent.getAttribute("aria-readonly"), "aria-readonly should not be set on tokenizer"); + assert.notOk(await tokenizerList.getAttribute("aria-readonly"), "aria-readonly should not be set on tokenizer"); assert.ok(await readonlyTokenizer.getAttribute("readonly"), "tokenizer should be readonly"); - assert.ok(await readonlyTokenizerContent.getAttribute("aria-readonly"), "aria-readonly should be set on disabled tokenizer"); + assert.ok(await readonlyTokenizerList.getAttribute("aria-readonly"), "aria-readonly should be set on disabled tokenizer"); }); it("should test aria-disabled attribute", async () => { const tokenizer = await browser.$("#nmore-tokenizer"); - const tokenizerContent = await tokenizer.shadow$(".ui5-tokenizer--content"); + const tokenizerList = await tokenizer.shadow$(".ui5-tokenizer--list"); + const disabledTokenizer = await browser.$("#disabled-tokenizer"); - const disabledTokenizerContent = await disabledTokenizer.shadow$(".ui5-tokenizer--content"); + const disabledTokenizerList = await disabledTokenizer.shadow$(".ui5-tokenizer--list"); assert.notOk(await tokenizer.getAttribute("disabled"), "tokenizer should not be disabled"); - assert.notOk(await tokenizerContent.getAttribute("aria-disabled"), "aria-disabled should not be set on tokenizer"); + assert.notOk(await tokenizerList.getAttribute("aria-disabled"), "aria-disabled should not be set on tokenizer"); assert.ok(await disabledTokenizer.getAttribute("disabled"), "tokenizer should be disabled"); - assert.ok(await disabledTokenizerContent.getAttribute("aria-disabled"), "aria-disabled should be set on disabled tokenizer"); + assert.ok(await disabledTokenizerList.getAttribute("aria-disabled"), "aria-disabled should be set on disabled tokenizer"); }); it("should test tokenizer content aria attributes", async () => { const tokenizer = await browser.$("#nmore-tokenizer"); - const tokenizerContent = await tokenizer.shadow$(".ui5-tokenizer--content"); + const tokenizerList = await tokenizer.shadow$(".ui5-tokenizer--list"); const expandedTokenizer = await browser.$("#expanded-tokenizer"); - const expandedTokenizerContent = await expandedTokenizer.shadow$(".ui5-tokenizer--content"); + const expandedTokenizerList = await expandedTokenizer.shadow$(".ui5-tokenizer--list"); const keys = [ "TOKENIZER_ARIA_LABEL", ]; const texts = await getResourceBundleTexts(keys); - assert.strictEqual(await tokenizerContent.getAttribute("role"), "listbox", "tokenizer content should have correct role=listbox"); - assert.strictEqual(await tokenizerContent.getAttribute("aria-label"), texts.TOKENIZER_ARIA_LABEL, "tokenizer content should have correct aria-label"); - assert.strictEqual(await expandedTokenizerContent.getAttribute("aria-label"), 'Test label', "tokenizer content should have correct aria-label when accesible name is set"); - assert.strictEqual(await expandedTokenizerContent.getAttribute("aria-description"), texts.TOKENIZER_ARIA_LABEL, "tokenizer content should have correct aria-description when accesible name is set"); + assert.strictEqual(await tokenizerList.getAttribute("role"), "listbox", "tokenizer content should have correct role=listbox"); + assert.strictEqual(await tokenizerList.getAttribute("aria-label"), texts.TOKENIZER_ARIA_LABEL, "tokenizer content should have correct aria-label"); + assert.strictEqual(await expandedTokenizerList.getAttribute("aria-label"), 'Test label', "tokenizer content should have correct aria-label when accesible name is set"); + assert.strictEqual(await expandedTokenizerList.getAttribute("aria-description"), texts.TOKENIZER_ARIA_LABEL, "tokenizer content should have correct aria-description when accesible name is set"); }); it("should test nMore aria attributes", async () => { diff --git a/packages/website/docs/_components_pages/main/Tokenizer.mdx b/packages/website/docs/_components_pages/main/Tokenizer.mdx index a39a0c15f524..82a521bcdc38 100644 --- a/packages/website/docs/_components_pages/main/Tokenizer.mdx +++ b/packages/website/docs/_components_pages/main/Tokenizer.mdx @@ -4,10 +4,19 @@ sidebar_class_name: newComponentBadge --- import Basic from "../../_samples/main/Tokenizer/Basic/Basic.md"; +import MultiLine from "../../_samples/main/Tokenizer/MultiLine/Basic.md"; <%COMPONENT_OVERVIEW%> ## Basic Sample +## More Samples + +### Multi-line and Clear All +With multiLine enabled, tokens are displayed across multiple lines for improved readability. +The showClearAll option adds a convenient 'Clear All' button, allowing users to remove all tokens at once. + + + <%COMPONENT_METADATA%> diff --git a/packages/website/docs/_samples/main/Tokenizer/MultiLine/Basic.md b/packages/website/docs/_samples/main/Tokenizer/MultiLine/Basic.md new file mode 100644 index 000000000000..ffccbf6dd13e --- /dev/null +++ b/packages/website/docs/_samples/main/Tokenizer/MultiLine/Basic.md @@ -0,0 +1,4 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; + + \ No newline at end of file diff --git a/packages/website/docs/_samples/main/Tokenizer/MultiLine/main.js b/packages/website/docs/_samples/main/Tokenizer/MultiLine/main.js new file mode 100644 index 000000000000..e7554d001aac --- /dev/null +++ b/packages/website/docs/_samples/main/Tokenizer/MultiLine/main.js @@ -0,0 +1,13 @@ +import "@ui5/webcomponents/dist/Token.js"; +import "@ui5/webcomponents/dist/Tokenizer.js"; + +const clearAllTokenizer = document.getElementById('clear-all'); + + +clearAllTokenizer.addEventListener("ui5-token-delete", event => { + const tokens = event.detail?.tokens; + + if (tokens) { + tokens.forEach(token => token.remove()); + } +}); \ No newline at end of file diff --git a/packages/website/docs/_samples/main/Tokenizer/MultiLine/sample.html b/packages/website/docs/_samples/main/Tokenizer/MultiLine/sample.html new file mode 100644 index 000000000000..3dd006c60105 --- /dev/null +++ b/packages/website/docs/_samples/main/Tokenizer/MultiLine/sample.html @@ -0,0 +1,31 @@ + + + + + + + Sample + + + + + + + + + + + + + + + + + + + + + + + +