From 3fb84a17271679ba1d54114ed62ef79c2af21edf Mon Sep 17 00:00:00 2001 From: Nikolay Deshev Date: Wed, 10 Jun 2026 08:44:30 +0300 Subject: [PATCH 1/9] feat(ui5-tabular-input): introduce new tabular suggestions input component (draft) poc --- packages/main/src/TabularInput.ts | 749 ++++++++++++++++++ .../main/src/TabularInputPopoverTemplate.tsx | 118 +++ packages/main/src/TabularInputTemplate.tsx | 105 +++ packages/main/src/bundle.esm.ts | 1 + packages/main/src/themes/TabularInput.css | 92 +++ packages/main/test/pages/TabularInput.html | 324 ++++++++ 6 files changed, 1389 insertions(+) create mode 100644 packages/main/src/TabularInput.ts create mode 100644 packages/main/src/TabularInputPopoverTemplate.tsx create mode 100644 packages/main/src/TabularInputTemplate.tsx create mode 100644 packages/main/src/themes/TabularInput.css create mode 100644 packages/main/test/pages/TabularInput.html diff --git a/packages/main/src/TabularInput.ts b/packages/main/src/TabularInput.ts new file mode 100644 index 000000000000..eae49f0db61f --- /dev/null +++ b/packages/main/src/TabularInput.ts @@ -0,0 +1,749 @@ +import type UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; +import type { Slot } from "@ui5/webcomponents-base/dist/UI5Element.js"; +import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; +import property from "@ui5/webcomponents-base/dist/decorators/property.js"; +import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js"; +import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; +import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import { isPhone, isAndroid } from "@ui5/webcomponents-base/dist/Device.js"; +import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; +// @ts-expect-error +import encodeXML from "@ui5/webcomponents-base/dist/sap/base/security/encodeXML.js"; + +import Input from "./Input.js"; +import type { IInputSuggestionItem } from "./Input.js"; +import type TableHeaderCell from "./TableHeaderCell.js"; +import type TableCell from "./TableCell.js"; +import type ResponsivePopover from "./ResponsivePopover.js"; + +import TabularInputTemplate from "./TabularInputTemplate.js"; +import tabularInputStyles from "./generated/themes/TabularInput.css.js"; +import SuggestionsCss from "./generated/themes/Suggestions.css.js"; + +import { + INPUT_SUGGESTIONS, + INPUT_SUGGESTIONS_MORE_HITS, +} from "./generated/i18n/i18n-defaults.js"; + +/** + * Represents highlighted cell content for a row + * @private + */ +type HighlightedCellContent = { + text: string; + highlightedMarkup: string; +}; + +/** + * Represents a processed suggestion row with highlighted content + * @private + */ +type ProcessedSuggestionRow = { + row: ITabularSuggestionRow; + cells: HighlightedCellContent[]; +}; + +/** + * Interface for tabular suggestion row items + * @public + */ +interface ITabularSuggestionRow extends UI5Element { + cells: TableCell[]; + selected?: boolean; + focused?: boolean; +} + +type TabularInputRowSelectEventDetail = { + row: ITabularSuggestionRow; +} + +type TabularInputSelectionChangeEventDetail = { + row: ITabularSuggestionRow | null; +} + +/** + * @class + * ### Overview + * + * The `ui5-tabular-input` component is an input field with tabular suggestions support. + * It displays suggestions in a table format with multiple columns, allowing users to + * see more information about each suggestion before selecting it. + * + * Similar to the OpenUI5 sap.m.Input with tabular suggestions, this component supports: + * - Multiple columns via `suggestionColumns` slot + * - Tabular rows via `suggestionRows` slot + * - Automatic popin mode for responsive behavior + * + * ### Usage + * + * Use this component when: + * - Users need to see additional information in columns for each suggestion + * - A simple text-based suggestion list is not sufficient + * - You want to display data in a tabular format + * + * ### Difference from ui5-input + * + * This component uses its own tabular suggestion mechanism instead of the standard + * `showSuggestions` / `suggestionItems` from ui5-input. The tabular suggestions + * are defined via: + * - `suggestionColumns`: Table header cells defining the columns + * - `suggestionRows`: Table rows with cells containing the suggestion data + * + * ### Keyboard Handling + * + * The component inherits keyboard handling from `ui5-input`: + * - [Down] - Navigates to the next suggestion row + * - [Up] - Navigates to the previous suggestion row + * - [Enter] - Selects the focused suggestion row + * - [Escape] - Closes the suggestion popover + * + * ### ES6 Module Import + * + * `import "@ui5/webcomponents/dist/TabularInput.js";` + * + * @constructor + * @extends Input + * @public + * @experimental + */ +@customElement({ + tag: "ui5-tabular-input", + languageAware: true, + formAssociated: true, + renderer: jsxRenderer, + template: TabularInputTemplate, + styles: [Input.styles, SuggestionsCss, tabularInputStyles], +}) + +/** + * Fired when a suggestion row is selected. + * @param {ITabularSuggestionRow} row The selected row instance + * @public + */ +@event("row-select", { + bubbles: true, +}) + +class TabularInput extends Input { + // @ts-expect-error - Intentionally override selection-change to use 'row' instead of 'item' + eventDetails!: Omit & { + "row-select": TabularInputRowSelectEventDetail, + "selection-change": TabularInputSelectionChangeEventDetail, + } + + /** + * Defines the columns for the tabular suggestions. + * Use `ui5-table-header-cell` components to define the column headers. + * + * **Note:** The columns define the structure of the suggestion table header. + * Each column can have properties like `width`, `minWidth`, `importance` (for popin), + * and `popinText`. + * + * @public + */ + @slot({ type: HTMLElement }) + suggestionColumns!: Slot; + + /** + * Defines the rows for the tabular suggestions. + * Use `ui5-table-row` components with `ui5-table-cell` children to define each suggestion row. + * + * **Note:** The cells in each row should correspond to the columns defined in `suggestionColumns`. + * + * @public + */ + @slot({ type: HTMLElement }) + suggestionRows!: Slot; + + /** + * Internal property to track if table suggestions are being used + * @private + */ + @property({ type: Boolean, noAttribute: true }) + _useTabularSuggestions = false; + + /** + * Internal property reflecting whether a suggestion row has focus. + * Used by CSS to hide input focus outline during row navigation. + * @private + */ + @property({ type: Boolean }) + _rowFocused = false; + + /** + * Stores processed rows with highlighted cell content + * @private + */ + _processedRows: ProcessedSuggestionRow[] = []; + + /** + * Stores the matched row for typeahead (similar to Input's _matchedSuggestionItem) + * @private + */ + _matchedTabularRow?: ITabularSuggestionRow; + + /** + * Override: For tabular suggestions, we always show suggestions + * (we don't use the parent's showSuggestions property) + */ + get _effectiveShowSuggestions() { + if (this._useTabularSuggestions) { + return true; + } + return super._effectiveShowSuggestions; + } + + /** + * Override: Return tabular rows as suggestion items for the parent's hasItems check + * This ensures the parent's open logic works correctly + */ + get _flattenItems(): Array { + if (this._useTabularSuggestions) { + return this.suggestionRows as unknown as Array; + } + return super._flattenItems; + } + + onBeforeRendering() { + this._useTabularSuggestions = this.suggestionColumns.length > 0 && this.suggestionRows.length > 0; + + if (this._useTabularSuggestions) { + if (this.filter !== "None" && this.typedInValue) { + this._filterTabularRows(); + } else { + this._resetRowVisibility(); + } + + this._handleTabularPopoverOpen(); + this._handleTabularTypeAhead(); + + this._effectiveShowClearIcon = (this.showClearIcon && !!this.value && !this.readonly && !this.disabled); + this.style.setProperty("--_ui5-input-icons-count", `${this.iconsCount}`); + return; + } + + super.onBeforeRendering(); + } + + /** + * @private + */ + _handleTabularPopoverOpen() { + const hasItems = this._visibleRows.length > 0; + const hasValue = !!this.value; + const isFocused = this.shadowRoot?.querySelector("input") === getActiveElement(); + const preventOpenPicker = this.disabled || this.readonly; + + if (preventOpenPicker) { + this.open = false; + } else if (!this._isPhone) { + this.open = hasItems && (this.open || (hasValue && isFocused && this.isTyping)); + } + } + + /** + * @private + */ + _handleTabularTypeAhead() { + const innerInput = this.getInputDOMRefSync(); + if (!innerInput || !this.value) { + return; + } + + const autoCompletedChars = innerInput.selectionEnd! - innerInput.selectionStart!; + + if (this._shouldAutocomplete && !isAndroid() && !autoCompletedChars && !this._isKeyNavigation) { + const matchingRow = this._getFirstMatchingRow(this.value); + if (matchingRow) { + if (!this._isComposing) { + this._performRowTypeAhead(matchingRow); + } + this._selectMatchingRow(matchingRow); + } else { + this._matchedTabularRow = undefined; + } + } + } + + /** + * @private + */ + _getFirstMatchingRow(current: string): ITabularSuggestionRow | undefined { + const visibleRows = this._visibleRows; + if (!visibleRows.length) { + return; + } + + const currentLower = current.toLowerCase(); + + return visibleRows.find(row => { + const firstCellText = this._getRowValue(row).toLowerCase(); + return firstCellText.startsWith(currentLower); + }); + } + + /** + * @private + */ + _performRowTypeAhead(row: ITabularSuggestionRow) { + const suggestionText = this._getRowValue(row); + const typedValue = this.typedInValue; + + if (suggestionText.toLowerCase().startsWith(typedValue.toLowerCase())) { + this.value = typedValue + suggestionText.substring(typedValue.length); + } + + this._performTextSelection = true; + this._shouldAutocomplete = false; + } + + /** + * @private + */ + _selectMatchingRow(row: ITabularSuggestionRow) { + this._deselectAllRows(); + + row.selected = true; + this._matchedTabularRow = row; + + this.fireDecoratorEvent("selection-change", { + row, + }); + } + + onAfterRendering() { + if (this._useTabularSuggestions) { + if (this._performTextSelection) { + if (this.typedInValue.length && this.value.length) { + this._adjustSelectionRange(); + } + this.fireDecoratorEvent("type-ahead"); + } + this._performTextSelection = false; + return; + } + + super.onAfterRendering(); + } + + /** + * @private + */ + _adjustSelectionRange() { + if (this._useTabularSuggestions) { + const innerInput = this.getInputDOMRefSync(); + if (innerInput) { + innerInput.setSelectionRange(this.typedInValue.length, this.value.length); + } + return; + } + super._adjustSelectionRange(); + } + + /** + * @private + */ + _filterTabularRows() { + const typedValue = this.typedInValue; + const typedValueLower = typedValue.toLowerCase(); + + this._processedRows = []; + + this.suggestionRows.forEach(row => { + const cells = row.cells || []; + let matches = false; + + const processedCells: HighlightedCellContent[] = cells.map(cell => { + const cellText = cell.textContent?.trim() || ""; + const cellMatches = this._matchesStartsWithPerTerm(cellText, typedValueLower); + + if (cellMatches) { + matches = true; + } + + const highlightedMarkup = typedValue + ? this._generateStartsWithPerTermHighlight(cellText, typedValue) + : encodeXML(cellText); + + return { + text: cellText, + highlightedMarkup, + }; + }); + + (row as UI5Element).hidden = !matches; + + if (matches) { + this._processedRows.push({ + row, + cells: processedCells, + }); + } + }); + } + + /** + * @private + */ + _matchesStartsWithPerTerm(text: string, valueLower: string): boolean { + if (!valueLower) { + return true; + } + const textLower = text.toLowerCase(); + const reg = new RegExp(`(^|\\s)${this._escapeRegExp(valueLower)}`, "i"); + return reg.test(textLower); + } + + /** + * @private + */ + _escapeRegExp(str: string): string { + return str.replace(/[[\]{}()*+?.\\^$|]/g, "\\$&"); + } + + /** + * Generates highlighted markup using StartsWithPerTerm logic. + * Highlights the typed value when it appears at the start of the text or at the start of any word. + * @private + */ + _generateStartsWithPerTermHighlight(text: string, value: string): string { + if (!text || !value) { + return encodeXML(text); + } + + const valueLower = value.toLowerCase(); + const valueLength = value.length; + + // Find all positions where the value starts at beginning of text or after whitespace + const positions: Array<{ start: number; end: number }> = []; + const textLower = text.toLowerCase(); + + // Check start of string + if (textLower.startsWith(valueLower)) { + positions.push({ start: 0, end: valueLength }); + } + + // Check after each whitespace + let searchStart = 0; + while (searchStart < text.length) { + const spaceIndex = text.indexOf(" ", searchStart); + if (spaceIndex === -1) { + break; + } + + const wordStart = spaceIndex + 1; + if (wordStart < text.length && textLower.substring(wordStart).startsWith(valueLower)) { + positions.push({ start: wordStart, end: wordStart + valueLength }); + } + searchStart = spaceIndex + 1; + } + + if (positions.length === 0) { + return encodeXML(text); + } + + let result = ""; + let lastEnd = 0; + + for (const pos of positions) { + if (pos.start > lastEnd) { + result += encodeXML(text.substring(lastEnd, pos.start)); + } + result += `${encodeXML(text.substring(pos.start, pos.end))}`; + lastEnd = pos.end; + } + + if (lastEnd < text.length) { + result += encodeXML(text.substring(lastEnd)); + } + + return result; + } + + /** + * @private + */ + _resetRowVisibility() { + const typedValue = this.typedInValue; + this._processedRows = []; + + this.suggestionRows.forEach(row => { + (row as UI5Element).hidden = false; + + const cells = row.cells || []; + const processedCells: HighlightedCellContent[] = cells.map(cell => { + const cellText = cell.textContent?.trim() || ""; + const highlightedMarkup = typedValue + ? this._generateStartsWithPerTermHighlight(cellText, typedValue) + : encodeXML(cellText); + + return { + text: cellText, + highlightedMarkup, + }; + }); + + this._processedRows.push({ + row, + cells: processedCells, + }); + }); + } + + get _visibleRows(): ITabularSuggestionRow[] { + return this.suggestionRows.filter(row => !(row as UI5Element).hidden); + } + + get _visibleProcessedRows(): ProcessedSuggestionRow[] { + return this._processedRows.filter(pr => !(pr.row as UI5Element).hidden); + } + + _onSuggestionRowClick(row: ITabularSuggestionRow) { + this._selectRow(row, false); + } + + _selectRow(row: ITabularSuggestionRow, keyboardUsed: boolean) { + const rowValue = this._getRowValue(row); + + this.value = rowValue; + this.typedInValue = rowValue; + this.open = false; + + this.fireDecoratorEvent("selection-change", { + row, + }); + this.fireDecoratorEvent("row-select", { row }); + this.fireDecoratorEvent("change"); + this.fireDecoratorEvent("input", { inputType: "" }); + + this._deselectAllRows(); + row.selected = true; + this._matchedTabularRow = undefined; + this._rowFocused = false; + this.isTyping = false; + + if (!keyboardUsed && !isPhone()) { + this.focus(); + } + } + + _getRowValue(row: ITabularSuggestionRow): string { + const cells = row.cells || []; + + if (cells.length > 0) { + return cells[0].textContent?.trim() || ""; + } + + return ""; + } + + _deselectAllRows() { + this.suggestionRows.forEach(row => { + row.selected = false; + row.focused = false; + }); + } + + _handleDown(e: KeyboardEvent) { + if (this._useTabularSuggestions && this.open) { + e.preventDefault(); + this._navigateRows(true); + return; + } + super._handleDown(e); + } + + _handleUp(e: KeyboardEvent) { + if (this._useTabularSuggestions && this.open) { + e.preventDefault(); + this._navigateRows(false); + return; + } + super._handleUp(e); + } + + _navigateRows(forward: boolean) { + const visibleRows = this._visibleRows; + + if (visibleRows.length === 0) { + return; + } + + const currentIndex = visibleRows.findIndex(row => row.focused || row.selected); + + let nextIndex: number; + if (forward) { + if (currentIndex >= visibleRows.length - 1) { + return; + } + nextIndex = currentIndex < 0 ? 0 : currentIndex + 1; + } else { + if (currentIndex <= 0) { + this._deselectAllRows(); + this._matchedTabularRow = undefined; + this._rowFocused = false; + return; + } + nextIndex = currentIndex - 1; + } + + this._deselectAllRows(); + this._matchedTabularRow = undefined; + + visibleRows[nextIndex].focused = true; + this._rowFocused = true; + + const previewValue = this._getRowValue(visibleRows[nextIndex]); + this.value = previewValue; + + this.fireDecoratorEvent("selection-change", { + row: visibleRows[nextIndex], + }); + } + + _handleEnter(e: KeyboardEvent) { + if (this._useTabularSuggestions) { + const visibleRows = this._visibleRows; + const focusedRow = visibleRows.find(row => row.focused); + const innerInput = this.getInputDOMRefSync()!; + + let rowToSelect = focusedRow || this._matchedTabularRow; + + if (!rowToSelect) { + rowToSelect = visibleRows.find(row => { + return this._getRowValue(row).toLowerCase() === this.value.toLowerCase(); + }); + } + + if (rowToSelect) { + const rowValue = this._getRowValue(rowToSelect); + innerInput.setSelectionRange(rowValue.length, rowValue.length); + + if (this.open) { + e.preventDefault(); + this._selectRow(rowToSelect, true); + } else { + this.fireSelectionChange(rowToSelect as unknown as IInputSuggestionItem, true); + this._selectRow(rowToSelect, true); + } + return; + } + + if (this.open) { + this.open = false; + } + this.lastConfirmedValue = this.value; + return; + } + super._handleEnter(e); + } + + _handleEscape() { + if (this._useTabularSuggestions && this.open) { + this.value = this.typedInValue || this.valueBeforeSelectionStart; + this.open = false; + this._deselectAllRows(); + this._matchedTabularRow = undefined; + this._rowFocused = false; + this.isTyping = false; + return; + } + super._handleEscape(); + } + + _clearPopoverFocusAndSelection() { + if (this._useTabularSuggestions) { + this._deselectAllRows(); + this.hasSuggestionItemSelected = false; + return; + } + super._clearPopoverFocusAndSelection(); + } + + get _hasTabularSuggestions(): boolean { + return this._useTabularSuggestions && this._visibleRows.length > 0; + } + + get _columnsCount(): number { + return this.suggestionColumns.length; + } + + get _isRowFocused(): boolean { + return this._useTabularSuggestions && this._visibleRows.some(row => row.focused); + } + + override get _isSuggestionsFocused(): boolean { + if (this._useTabularSuggestions) { + return this._isRowFocused; + } + return super._isSuggestionsFocused || false; + } + + /** + * Returns the accessible name for the suggestions popover + * @private + */ + get _tabularSuggestionsAccessibleName(): string { + return Input.i18nBundle.getText(INPUT_SUGGESTIONS); + } + + /** + * Returns the count text for available suggestions + * @private + */ + get _tabularSuggestionsCountText(): string { + return Input.i18nBundle.getText(INPUT_SUGGESTIONS_MORE_HITS, this._visibleRows.length); + } + + /** + * Returns the ID of the currently focused row for aria-activedescendant + * @private + */ + get _activeDescendantId(): string | undefined { + const focusedRow = this._visibleRows.find(row => row.focused); + if (focusedRow) { + const index = this._visibleRows.indexOf(focusedRow); + return `${this._id}-row-${index}`; + } + return undefined; + } + + /** + * Returns the tabular suggestions popover element + * @private + */ + _getTabularPopover() { + return this.shadowRoot?.querySelector(".ui5-suggestions-popover"); + } + + /** + * Override focusout handler to prevent closing popover when clicking inside it + * @private + */ + _onfocusout(e: FocusEvent) { + if (this._useTabularSuggestions) { + const toBeFocused = e.relatedTarget as HTMLElement; + const popover = this._getTabularPopover(); + + if (popover?.contains(toBeFocused) || this.contains(toBeFocused)) { + return; + } + + this.focused = false; + this.open = false; + this._clearPopoverFocusAndSelection(); + return; + } + + super._onfocusout(e); + } +} + +TabularInput.define(); + +export default TabularInput; +export type { + ITabularSuggestionRow, + TabularInputRowSelectEventDetail, + TabularInputSelectionChangeEventDetail, +}; diff --git a/packages/main/src/TabularInputPopoverTemplate.tsx b/packages/main/src/TabularInputPopoverTemplate.tsx new file mode 100644 index 000000000000..16b3a4c77774 --- /dev/null +++ b/packages/main/src/TabularInputPopoverTemplate.tsx @@ -0,0 +1,118 @@ +import type TabularInput from "./TabularInput.js"; +import type { JsxTemplateResult } from "@ui5/webcomponents-base/dist/index.js"; + +import ResponsivePopover from "./ResponsivePopover.js"; +import Button from "./Button.js"; +import Title from "./Title.js"; +import Input from "./Input.js"; + +/** + * Renders the tabular suggestions popover for TabularInput. + * Follows the same pattern as ComboBoxPopoverTemplate. + */ +export default function TabularInputPopoverTemplate(this: TabularInput): JsxTemplateResult { + return ( + + {this._isPhone && +
+
+ + {this._headerTitleText} + +
+
+
+ +
+
+
+ } + + {tabularSuggestionsList.call(this)} + + {this._isPhone && + + } +
+ ); +} + +/** + * Renders the tabular suggestions list (table with header and body). + */ +function tabularSuggestionsList(this: TabularInput): JsxTemplateResult { + return ( +
+ + + + {this.suggestionColumns.map((col, index) => ( + + ))} + + + + {this._visibleProcessedRows.map((processedRow, rowIndex) => ( + this._onSuggestionRowClick(processedRow.row)} + > + {processedRow.cells.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {col.textContent} +
+
+
+ ); +} diff --git a/packages/main/src/TabularInputTemplate.tsx b/packages/main/src/TabularInputTemplate.tsx new file mode 100644 index 000000000000..dd355c4df87e --- /dev/null +++ b/packages/main/src/TabularInputTemplate.tsx @@ -0,0 +1,105 @@ +import type TabularInput from "./TabularInput.js"; + +import Icon from "./Icon.js"; +import decline from "@ui5/webcomponents-icons/dist/decline.js"; + +import TabularInputPopoverTemplate from "./TabularInputPopoverTemplate.js"; + +export default function TabularInputTemplate(this: TabularInput) { + return ( + <> +
+
+ 0} + disabled={this.disabled} + readonly={this._readonly} + value={this.value} + required={this.required} + placeholder={this._placeholder} + maxlength={this.maxlength} + role={this.accInfo.role} + enterkeyhint={this.hint} + aria-controls={this.accInfo.ariaControls} + aria-invalid={this.accInfo.ariaInvalid} + aria-haspopup="listbox" + aria-describedby={this.accInfo.ariaDescribedBy} + aria-roledescription={this.accInfo.ariaRoledescription} + aria-autocomplete="list" + aria-expanded={this.open} + aria-label={this.accInfo.ariaLabel} + aria-required={this.required} + aria-activedescendant={this._activeDescendantId} + autocomplete="off" + data-sap-focus-ref + step={this.nativeInputAttributes.step} + min={this.nativeInputAttributes.min} + max={this.nativeInputAttributes.max} + onInput={this._handleNativeInput} + onChange={this._handleChange} + onSelect={this._handleSelect} + onKeyDown={this._onkeydown} + onKeyUp={this._onkeyup} + onClick={this._click} + onFocusIn={this.innerFocusIn} + /> + + {this._effectiveShowClearIcon && +
+ + +
+ } + + {this.icon.length > 0 && +
+ +
+ } + +
+ {this._valueStateInputIcon} +
+ + {this._hasTabularSuggestions && + <> + {this._tabularSuggestionsAccessibleName} + + + {this._tabularSuggestionsCountText} + + + } + + {this.hasValueState && + {this.ariaValueStateHiddenText} + } +
+
+ + {this._useTabularSuggestions && TabularInputPopoverTemplate.call(this)} + + ); +} diff --git a/packages/main/src/bundle.esm.ts b/packages/main/src/bundle.esm.ts index 747c29e96aec..77964dc0c248 100644 --- a/packages/main/src/bundle.esm.ts +++ b/packages/main/src/bundle.esm.ts @@ -83,6 +83,7 @@ import Icon from "./Icon.js"; import Input from "./Input.js"; import SuggestionItemCustom from "./SuggestionItemCustom.js"; import MultiInput from "./MultiInput.js"; +import TabularInput from "./TabularInput.js"; import Label from "./Label.js"; import LastOptions from "./dynamic-date-range-options/LastOptions.js"; import Link from "./Link.js"; diff --git a/packages/main/src/themes/TabularInput.css b/packages/main/src/themes/TabularInput.css new file mode 100644 index 000000000000..6ab3e7b2733d --- /dev/null +++ b/packages/main/src/themes/TabularInput.css @@ -0,0 +1,92 @@ +:host([open][focused][_row-focused]) .ui5-input-focusable-element::after { + content: none; +} + +:host([open][focused][_row-focused]) { + border-color: var(--sapField_BorderColor); + background-color: var(--sapField_Background); +} + +.ui5-tabular-input-suggestions-table { + width: 100%; + max-height: var(--_ui5_tabular_suggestions_max_height, 20rem); + overflow: auto; +} + +.ui5-tabular-suggestions-table { + width: 100%; + border-collapse: collapse; + font-family: var(--sapFontFamily); + font-size: var(--sapFontSize); + table-layout: fixed; +} + +.ui5-tabular-suggestions-header { + position: sticky; + top: 0; + z-index: 1; +} + +.ui5-tabular-suggestions-header-cell { + padding: var(--_ui5_tabular_suggestions_cell_padding, 0.5rem 1rem); + text-align: left; + font-weight: normal; + color: var(--sapList_HeaderTextColor); + background-color: var(--sapList_HeaderBackground); + border-bottom: 1px solid var(--sapList_HeaderBorderColor); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ui5-tabular-suggestions-body { + background-color: var(--sapList_Background); +} + +.ui5-tabular-suggestions-row { + cursor: pointer; + transition: background-color 0.1s ease; + outline: none; + position: relative; +} + +.ui5-tabular-suggestions-row:hover { + background-color: var(--sapList_Hover_Background); +} + +.ui5-tabular-suggestions-row--focused, +.ui5-tabular-suggestions-row--selected { + background-color: var(--sapList_SelectionBackgroundColor); +} + +.ui5-tabular-suggestions-row--focused { + outline: var(--sapContent_FocusWidth) var(--sapContent_FocusStyle) var(--sapContent_FocusColor); + outline-offset: var(--_ui5_tabular_suggestions_focus_offset, -0.125rem); +} + +.ui5-tabular-suggestions-row--focused:hover, +.ui5-tabular-suggestions-row--selected:hover { + background-color: var(--sapList_Hover_SelectionBackground); +} + +.ui5-tabular-suggestions-cell { + padding: var(--_ui5_tabular_suggestions_cell_padding, 0.5rem 1rem); + color: var(--sapList_TextColor); + border-bottom: 1px solid var(--sapList_BorderColor); + vertical-align: middle; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.ui5-tabular-suggestions-row td:first-child { + font-weight: 500; +} + +.ui5-tabular-suggestions-cell b { + font-weight: bold; + color: var(--sapList_TextColor); +} + +:host([_isPhone]) .ui5-tabular-input-suggestions-table { + max-height: none; +} diff --git a/packages/main/test/pages/TabularInput.html b/packages/main/test/pages/TabularInput.html new file mode 100644 index 000000000000..7fd86b4162d7 --- /dev/null +++ b/packages/main/test/pages/TabularInput.html @@ -0,0 +1,324 @@ + + + + + + TabularInput - Test Page + + + + + TabularInput Component - POC Test Page + This page demonstrates the TabularInput component with tabular suggestions. + + +
+ Example 1: Product Search +

+ Search for products. The suggestions show Product ID, Name, Category, and Price in columns. + Try typing "Laptop" or "Phone". +

+ + + + Product ID + Name + Category + Price + + + + PRD-001 + Laptop Pro 15 + Electronics + $1,299 + + + PRD-002 + Smartphone X + Electronics + $899 + + + PRD-003 + Wireless Mouse + Accessories + $49 + + + PRD-004 + USB-C Hub + Accessories + $79 + + + PRD-005 + Phone Case + Accessories + $29 + + + +
Selected: (none)
+
+ + +
+ Example 2: Employee Directory +

+ Search for employees by name, department, or location. + Uses StartsWith filter. +

+ + + + Name + Department + Location + Extension + + + + John Smith + Engineering + Building A + x1234 + + + Jane Doe + Marketing + Building B + x2345 + + + James Wilson + Sales + Building A + x3456 + + + Sarah Johnson + HR + Building C + x4567 + + + Mike Brown + Engineering + Building A + x5678 + + + +
Selected: (none)
+
+ + +
+ Example 3: Responsive Popin Mode +

+ This example is in a narrow container to demonstrate the popin behavior. + Columns will stack vertically when there's not enough space. +

+ +
+ + + Code + Description + Quantity + Status + + + + A001 + Widget Alpha + 150 + In Stock + + + B002 + Widget Beta + 75 + Low Stock + + + C003 + Widget Gamma + 0 + Out of Stock + + +
+ +
Selected: (none)
+
+ + +
+ Example 4: No Filtering +

+ This example shows all suggestions without filtering. + Useful when the server handles filtering. +

+ + + + Country + Capital + Population + + + + Germany + Berlin + 83M + + + France + Paris + 67M + + + Spain + Madrid + 47M + + + Italy + Rome + 60M + + + +
Selected: (none)
+
+ + +
+ Example 5: With Clear Icon +

+ TabularInput with show-clear-icon enabled for easy value clearing. +

+ + + + ID + Task + Priority + + + + T-101 + Review PR #123 + High + + + T-102 + Update documentation + Medium + + + T-103 + Fix login bug + Critical + + + +
Selected: (none)
+
+ + + + From ecf14439fe012cdbe47a7aa793a8a03fcb309046 Mon Sep 17 00:00:00 2001 From: Nikolay Deshev Date: Thu, 11 Jun 2026 11:06:41 +0300 Subject: [PATCH 2/9] feat(ui5-tabular-input): introduce new tabular suggestions input (wip) This POC uses ui5-table rendering instead of a native one JIRA: BGSOFUIRILA-4203 related to #13666 --- packages/main/src/TabularInput.ts | 66 ++++++++++++- .../main/src/TabularInputPopoverTemplate.tsx | 92 ++++++++++--------- packages/main/src/themes/TabularInput.css | 74 ++++----------- 3 files changed, 130 insertions(+), 102 deletions(-) diff --git a/packages/main/src/TabularInput.ts b/packages/main/src/TabularInput.ts index eae49f0db61f..62e788faca32 100644 --- a/packages/main/src/TabularInput.ts +++ b/packages/main/src/TabularInput.ts @@ -5,6 +5,9 @@ import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js"; import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; +import { + isF2, isF7, isLeft, isRight, isPageUp, isPageDown, isHome, isEnd, +} from "@ui5/webcomponents-base/dist/Keys.js"; import { isPhone, isAndroid } from "@ui5/webcomponents-base/dist/Device.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; // @ts-expect-error @@ -14,6 +17,7 @@ import Input from "./Input.js"; import type { IInputSuggestionItem } from "./Input.js"; import type TableHeaderCell from "./TableHeaderCell.js"; import type TableCell from "./TableCell.js"; +import type TableRow from "./TableRow.js"; import type ResponsivePopover from "./ResponsivePopover.js"; import TabularInputTemplate from "./TabularInputTemplate.js"; @@ -313,6 +317,8 @@ class TabularInput extends Input { onAfterRendering() { if (this._useTabularSuggestions) { + this._applyTabularAriaOverrides(); + if (this._performTextSelection) { if (this.typedInValue.length && this.value.length) { this._adjustSelectionRange(); @@ -326,6 +332,35 @@ class TabularInput extends Input { super.onAfterRendering(); } + /** + * Applies ARIA role overrides to make the ui5-table behave as a listbox. + * @private + */ + _applyTabularAriaOverrides() { + const table = this.shadowRoot?.querySelector(".ui5-tabular-suggestions-table"); + if (!table) { + return; + } + + const tableInner = table.shadowRoot?.querySelector("[role='grid']"); + if (tableInner) { + tableInner.setAttribute("role", "listbox"); + tableInner.removeAttribute("aria-colcount"); + tableInner.removeAttribute("aria-multiselectable"); + } + + const visibleRows = this._visibleRows; + table.querySelectorAll("[ui5-table-row]").forEach((rowElement, index) => { + const rowInner = rowElement.shadowRoot?.querySelector("[role='row']"); + if (rowInner) { + rowInner.setAttribute("role", "option"); + rowInner.removeAttribute("aria-rowindex"); + const isSelected = visibleRows[index]?.focused || visibleRows[index]?.selected; + rowInner.setAttribute("aria-selected", String(!!isSelected)); + } + }); + } + /** * @private */ @@ -403,7 +438,6 @@ class TabularInput extends Input { /** * Generates highlighted markup using StartsWithPerTerm logic. - * Highlights the typed value when it appears at the start of the text or at the start of any word. * @private */ _generateStartsWithPerTermHighlight(text: string, value: string): string { @@ -413,17 +447,13 @@ class TabularInput extends Input { const valueLower = value.toLowerCase(); const valueLength = value.length; - - // Find all positions where the value starts at beginning of text or after whitespace const positions: Array<{ start: number; end: number }> = []; const textLower = text.toLowerCase(); - // Check start of string if (textLower.startsWith(valueLower)) { positions.push({ start: 0, end: valueLength }); } - // Check after each whitespace let searchStart = 0; while (searchStart < text.length) { const spaceIndex = text.indexOf(" ", searchStart); @@ -502,6 +532,32 @@ class TabularInput extends Input { this._selectRow(row, false); } + /** + * Intercepts keyboard events on the table to prevent grid navigation. + * @private + */ + _onTableKeyDown(e: KeyboardEvent) { + if (isF2(e) || isF7(e) || isLeft(e) || isRight(e) || + isPageUp(e) || isPageDown(e) || isHome(e) || isEnd(e)) { + e.stopPropagation(); + e.preventDefault(); + } + } + + /** + * Handles row-click event from the table to select the corresponding suggestion. + * @private + */ + _onTableRowClick(e: CustomEvent<{ row: TableRow }>) { + const clickedRow = e.detail.row; + const rowIndex = parseInt(clickedRow.dataset.rowIndex || "0", 10); + const suggestionRow = this._visibleRows[rowIndex]; + + if (suggestionRow) { + this._selectRow(suggestionRow, false); + } + } + _selectRow(row: ITabularSuggestionRow, keyboardUsed: boolean) { const rowValue = this._getRowValue(row); diff --git a/packages/main/src/TabularInputPopoverTemplate.tsx b/packages/main/src/TabularInputPopoverTemplate.tsx index 16b3a4c77774..f7dfd7204984 100644 --- a/packages/main/src/TabularInputPopoverTemplate.tsx +++ b/packages/main/src/TabularInputPopoverTemplate.tsx @@ -5,10 +5,15 @@ import ResponsivePopover from "./ResponsivePopover.js"; import Button from "./Button.js"; import Title from "./Title.js"; import Input from "./Input.js"; +import Table from "./Table.js"; +import TableHeaderRow from "./TableHeaderRow.js"; +import TableHeaderCell from "./TableHeaderCell.js"; +import TableRow from "./TableRow.js"; +import TableCell from "./TableCell.js"; /** * Renders the tabular suggestions popover for TabularInput. - * Follows the same pattern as ComboBoxPopoverTemplate. + * Uses ui5-table for rendering with ARIA overrides for listbox semantics. */ export default function TabularInputPopoverTemplate(this: TabularInput): JsxTemplateResult { return ( @@ -66,53 +71,54 @@ export default function TabularInputPopoverTemplate(this: TabularInput): JsxTemp } /** - * Renders the tabular suggestions list (table with header and body). + * Renders the tabular suggestions list using ui5-table. + * ARIA roles are overridden in TabularInput.ts to provide listbox semantics. */ function tabularSuggestionsList(this: TabularInput): JsxTemplateResult { return ( -
- - - - {this.suggestionColumns.map((col, index) => ( - - ))} - - - - {this._visibleProcessedRows.map((processedRow, rowIndex) => ( - this._onSuggestionRowClick(processedRow.row)} +
+
- {col.textContent} -
+ + {this.suggestionColumns.map((col, index) => ( + - {processedRow.cells.map((cell, cellIndex) => ( - - ))} - + {col.textContent} + ))} - -
-
+ + + {this._visibleProcessedRows.map((processedRow, rowIndex) => ( + + {processedRow.cells.map((cell, cellIndex) => ( + + + ))} + + ))} +
); } diff --git a/packages/main/src/themes/TabularInput.css b/packages/main/src/themes/TabularInput.css index 6ab3e7b2733d..d2043652b4e7 100644 --- a/packages/main/src/themes/TabularInput.css +++ b/packages/main/src/themes/TabularInput.css @@ -7,86 +7,52 @@ background-color: var(--sapField_Background); } -.ui5-tabular-input-suggestions-table { +/* Wrapper to control table sizing in popover */ +.ui5-tabular-input-suggestions-wrapper { width: 100%; max-height: var(--_ui5_tabular_suggestions_max_height, 20rem); overflow: auto; } +/* Let ui5-table handle its own base styling */ .ui5-tabular-suggestions-table { width: 100%; - border-collapse: collapse; - font-family: var(--sapFontFamily); - font-size: var(--sapFontSize); - table-layout: fixed; } -.ui5-tabular-suggestions-header { - position: sticky; - top: 0; - z-index: 1; +/* Hide Table's sentinel focus elements in suggestion context */ +.ui5-tabular-suggestions-table::part(before), +.ui5-tabular-suggestions-table::part(after) { + display: none; } -.ui5-tabular-suggestions-header-cell { - padding: var(--_ui5_tabular_suggestions_cell_padding, 0.5rem 1rem); - text-align: left; - font-weight: normal; - color: var(--sapList_HeaderTextColor); - background-color: var(--sapList_HeaderBackground); - border-bottom: 1px solid var(--sapList_HeaderBorderColor); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.ui5-tabular-suggestions-body { - background-color: var(--sapList_Background); -} - -.ui5-tabular-suggestions-row { - cursor: pointer; - transition: background-color 0.1s ease; - outline: none; - position: relative; -} - -.ui5-tabular-suggestions-row:hover { - background-color: var(--sapList_Hover_Background); -} - -.ui5-tabular-suggestions-row--focused, -.ui5-tabular-suggestions-row--selected { +/* Focus/selection styles for suggestion rows */ +.ui5-tabular-suggestions-table [ui5-table-row].ui5-tabular-suggestions-row--focused, +.ui5-tabular-suggestions-table [ui5-table-row].ui5-tabular-suggestions-row--selected { background-color: var(--sapList_SelectionBackgroundColor); } -.ui5-tabular-suggestions-row--focused { +.ui5-tabular-suggestions-table [ui5-table-row].ui5-tabular-suggestions-row--focused { outline: var(--sapContent_FocusWidth) var(--sapContent_FocusStyle) var(--sapContent_FocusColor); outline-offset: var(--_ui5_tabular_suggestions_focus_offset, -0.125rem); } -.ui5-tabular-suggestions-row--focused:hover, -.ui5-tabular-suggestions-row--selected:hover { +.ui5-tabular-suggestions-table [ui5-table-row].ui5-tabular-suggestions-row--focused:hover, +.ui5-tabular-suggestions-table [ui5-table-row].ui5-tabular-suggestions-row--selected:hover { background-color: var(--sapList_Hover_SelectionBackground); } -.ui5-tabular-suggestions-cell { - padding: var(--_ui5_tabular_suggestions_cell_padding, 0.5rem 1rem); +/* Highlighted text in cells */ +.ui5-tabular-suggestions-table [ui5-table-cell] b { + font-weight: bold; color: var(--sapList_TextColor); - border-bottom: 1px solid var(--sapList_BorderColor); - vertical-align: middle; - word-wrap: break-word; - overflow-wrap: break-word; } -.ui5-tabular-suggestions-row td:first-child { +/* First cell emphasis */ +.ui5-tabular-suggestions-table [ui5-table-row] [ui5-table-cell]:first-of-type { font-weight: 500; } -.ui5-tabular-suggestions-cell b { - font-weight: bold; - color: var(--sapList_TextColor); -} - -:host([_isPhone]) .ui5-tabular-input-suggestions-table { +/* Phone mode adjustments */ +:host([_isPhone]) .ui5-tabular-input-suggestions-wrapper { max-height: none; } From f416819a171c0f539048c948c9e90eb32a51afe3 Mon Sep 17 00:00:00 2001 From: Nikolay Deshev Date: Wed, 8 Jul 2026 17:11:02 +0300 Subject: [PATCH 3/9] feat(ui5-tabular-input): introduce new tabular suggestions input (wip) major refactoring and improvements, added tests --- .../main/cypress/specs/TabularInput.cy.tsx | 479 ++++++++++++++++++ packages/main/src/InputTemplate.tsx | 5 +- packages/main/src/TabularInput.ts | 241 ++------- .../main/src/TabularInputPopoverTemplate.tsx | 35 +- packages/main/src/TabularInputTemplate.tsx | 115 +---- packages/main/test/pages/TabularInput.html | 88 +++- 6 files changed, 630 insertions(+), 333 deletions(-) create mode 100644 packages/main/cypress/specs/TabularInput.cy.tsx diff --git a/packages/main/cypress/specs/TabularInput.cy.tsx b/packages/main/cypress/specs/TabularInput.cy.tsx new file mode 100644 index 000000000000..edd596f40ee0 --- /dev/null +++ b/packages/main/cypress/specs/TabularInput.cy.tsx @@ -0,0 +1,479 @@ +import TabularInput from "../../src/TabularInput.js"; +import TableHeaderCell from "../../src/TableHeaderCell.js"; +import TableRow from "../../src/TableRow.js"; +import TableCell from "../../src/TableCell.js"; +import type ResponsivePopover from "../../src/ResponsivePopover.js"; + +describe("TabularInput - Basic Rendering", () => { + it("renders with tabular suggestions", () => { + cy.mount( + + Name + Country + + John + USA + + + Jane + UK + + + ); + + cy.get("[ui5-tabular-input]").should("exist"); + cy.get("[ui5-tabular-input]").find("[ui5-table-header-cell]").should("have.length", 2); + cy.get("[ui5-tabular-input]").find("[ui5-table-row]").should("have.length", 2); + }); + + it("opens suggestions popover on focus and type", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("have.attr", "open"); + }); + + it("closes suggestions popover on Escape", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("have.attr", "open"); + + cy.realPress("Escape"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("not.have.attr", "open"); + }); +}); + +describe("TabularInput - Highlighting", () => { + it("highlights matching text in cells", () => { + cy.mount( + + Name + + John Smith + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("john"); + + cy.get("@input") + .shadow() + .find("[ui5-table-cell]") + .first() + .find("b") + .should("exist") + .and("have.text", "John"); + }); +}); + +describe("TabularInput - Keyboard Navigation", () => { + it("navigates through rows with Arrow Down/Up", () => { + cy.mount( + + Name + + John + + + Jane + + + Jack + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + // First ArrowDown selects first row + cy.realPress("ArrowDown"); + cy.get("@input").should("have.value", "John"); + + // Second ArrowDown moves to second row + cy.realPress("ArrowDown"); + cy.get("@input").should("have.value", "Jane"); + + // Third ArrowDown moves to third row + cy.realPress("ArrowDown"); + cy.get("@input").should("have.value", "Jack"); + + // ArrowUp goes back + cy.realPress("ArrowUp"); + cy.get("@input").should("have.value", "Jane"); + }); + + it("selects text during navigation (typeahead preservation)", () => { + cy.mount( + + Name + + John + + + Jane + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.realPress("ArrowDown"); + + cy.get("@input").should("have.value", "John"); + + // Check that the autocompleted part is selected + cy.window().then(win => { + const selection = win.getSelection()?.toString(); + expect(selection).to.contain("ohn"); + }); + }); + + it("restores typed value when pressing Arrow Up from first row", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("jo"); + + cy.realPress("ArrowDown"); + cy.get("@input").should("have.value", "John"); + + cy.realPress("ArrowUp"); + cy.get("@input").should("have.value", "jo"); + }); + + it("selects row with Enter key", () => { + const onSelectionChange = cy.spy().as("onSelectionChange"); + + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.realPress("ArrowDown"); + cy.realPress("Enter"); + + cy.get("@input").should("have.value", "John"); + cy.get("@onSelectionChange").should("have.been.called"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("not.have.attr", "open"); + }); +}); + +describe("TabularInput - Row Selection", () => { + it("selects row on click", () => { + const onSelectionChange = cy.spy().as("onSelectionChange"); + + cy.mount( + + Name + + John + + + Jane + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.get("@input") + .shadow() + .find("[ui5-table-row]") + .eq(1) + .realClick(); + + cy.get("@input").should("have.value", "Jane"); + cy.get("@onSelectionChange").should("have.been.called"); + }); + + it("fires selection-change event during navigation", () => { + const onSelectionChange = cy.spy().as("onSelectionChange"); + + cy.mount( + + Name + + John + + + Jane + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.realPress("ArrowDown"); + cy.get("@onSelectionChange").should("have.been.called"); + }); +}); + +describe("TabularInput - Typeahead", () => { + it("performs typeahead with first matching row", () => { + cy.mount( + + Name + + John + + + Jane + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("jo"); + + // Typeahead autocompletes - preserves user's typed case + rest from suggestion + // "jo" + "hn" = "john" + cy.get("@input").should("have.value", "john"); + + // Check that the autocompleted part is selected + cy.window().then(win => { + const selection = win.getSelection()?.toString(); + expect(selection).to.contain("hn"); + }); + }); + + it("disables typeahead with noTypeahead property", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("jo"); + + cy.get("@input").should("have.value", "jo"); + }); +}); + +describe("TabularInput - Clear Icon", () => { + it("shows clear icon when value is present", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("test"); + + cy.get("@input") + .shadow() + .find(".ui5-input-clear-icon-wrapper") + .should("exist"); + }); + + it("clears value when clicking clear icon", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("test"); + + cy.get("@input") + .shadow() + .find(".ui5-input-clear-icon-wrapper") + .realClick(); + + cy.get("@input").should("have.value", ""); + }); +}); + +describe("TabularInput - Overflow Mode", () => { + it("renders with Popin overflow mode by default", () => { + cy.mount( + + Col1 + Col2 + + Cell1 + Cell2 + + + ); + + cy.get("[ui5-tabular-input]") + .should("have.attr", "overflow-mode", "Popin"); + }); + + it("accepts Scroll overflow mode", () => { + cy.mount( + + Col1 + Col2 + + Cell1 + Cell2 + + + ); + + cy.get("[ui5-tabular-input]") + .should("have.attr", "overflow-mode", "Scroll"); + }); +}); + +describe("TabularInput - Value State", () => { + it("displays value state", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .should("have.attr", "value-state", "Negative"); + }); +}); + +describe("TabularInput - Disabled and Readonly", () => { + it("does not open popover when disabled", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick({ force: true }); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("not.have.attr", "open"); + }); + + it("does not open popover when readonly", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("not.have.attr", "open"); + }); +}); diff --git a/packages/main/src/InputTemplate.tsx b/packages/main/src/InputTemplate.tsx index 4a6ce63f14fc..23cfd7bae790 100644 --- a/packages/main/src/InputTemplate.tsx +++ b/packages/main/src/InputTemplate.tsx @@ -6,11 +6,12 @@ import InputPopoverTemplate from "./InputPopoverTemplate.js"; type TemplateHook = () => JsxTemplateResult; -export default function InputTemplate(this: Input, hooks?: { preContent: TemplateHook, postContent: TemplateHook, suggestionsList?: TemplateHook, mobileHeader?: TemplateHook }) { +export default function InputTemplate(this: Input, hooks?: { preContent: TemplateHook, postContent: TemplateHook, suggestionsList?: TemplateHook, mobileHeader?: TemplateHook, popoverTemplate?: TemplateHook }) { const suggestionsList = hooks?.suggestionsList; const mobileHeader = hooks?.mobileHeader; const preContent = hooks?.preContent || defaultPreContent; const postContent = hooks?.postContent || defaultPostContent; + const popoverTemplate = hooks?.popoverTemplate; return ( <> @@ -120,7 +121,7 @@ export default function InputTemplate(this: Input, hooks?: { preContent: Templat - { InputPopoverTemplate.call(this, { suggestionsList, mobileHeader }) } + { popoverTemplate ? popoverTemplate.call(this) : InputPopoverTemplate.call(this, { suggestionsList, mobileHeader }) } ); } diff --git a/packages/main/src/TabularInput.ts b/packages/main/src/TabularInput.ts index 62e788faca32..e32d356666cb 100644 --- a/packages/main/src/TabularInput.ts +++ b/packages/main/src/TabularInput.ts @@ -10,8 +10,7 @@ import { } from "@ui5/webcomponents-base/dist/Keys.js"; import { isPhone, isAndroid } from "@ui5/webcomponents-base/dist/Device.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; -// @ts-expect-error -import encodeXML from "@ui5/webcomponents-base/dist/sap/base/security/encodeXML.js"; +import generateHighlightedMarkup from "@ui5/webcomponents-base/dist/util/generateHighlightedMarkupFirstMatch.js"; import Input from "./Input.js"; import type { IInputSuggestionItem } from "./Input.js"; @@ -19,6 +18,7 @@ import type TableHeaderCell from "./TableHeaderCell.js"; import type TableCell from "./TableCell.js"; import type TableRow from "./TableRow.js"; import type ResponsivePopover from "./ResponsivePopover.js"; +import type TableOverflowMode from "./types/TableOverflowMode.js"; import TabularInputTemplate from "./TabularInputTemplate.js"; import tabularInputStyles from "./generated/themes/TabularInput.css.js"; @@ -27,6 +27,7 @@ import SuggestionsCss from "./generated/themes/Suggestions.css.js"; import { INPUT_SUGGESTIONS, INPUT_SUGGESTIONS_MORE_HITS, + LIST_ITEM_POSITION, } from "./generated/i18n/i18n-defaults.js"; /** @@ -57,10 +58,6 @@ interface ITabularSuggestionRow extends UI5Element { focused?: boolean; } -type TabularInputRowSelectEventDetail = { - row: ITabularSuggestionRow; -} - type TabularInputSelectionChangeEventDetail = { row: ITabularSuggestionRow | null; } @@ -119,19 +116,9 @@ type TabularInputSelectionChangeEventDetail = { styles: [Input.styles, SuggestionsCss, tabularInputStyles], }) -/** - * Fired when a suggestion row is selected. - * @param {ITabularSuggestionRow} row The selected row instance - * @public - */ -@event("row-select", { - bubbles: true, -}) - class TabularInput extends Input { // @ts-expect-error - Intentionally override selection-change to use 'row' instead of 'item' eventDetails!: Omit & { - "row-select": TabularInputRowSelectEventDetail, "selection-change": TabularInputSelectionChangeEventDetail, } @@ -159,6 +146,18 @@ class TabularInput extends Input { @slot({ type: HTMLElement }) suggestionRows!: Slot; + /** + * Defines the overflow behavior of the suggestion table. + * + * **Note:** When set to `Popin`, columns that don't fit will be shown as pop-in content. + * When set to `Scroll`, a horizontal scrollbar will appear. + * + * @default "Popin" + * @public + */ + @property() + overflowMode: `${TableOverflowMode}` = "Popin"; + /** * Internal property to track if table suggestions are being used * @private @@ -212,11 +211,7 @@ class TabularInput extends Input { this._useTabularSuggestions = this.suggestionColumns.length > 0 && this.suggestionRows.length > 0; if (this._useTabularSuggestions) { - if (this.filter !== "None" && this.typedInValue) { - this._filterTabularRows(); - } else { - this._resetRowVisibility(); - } + this._processRows(); this._handleTabularPopoverOpen(); this._handleTabularTypeAhead(); @@ -317,8 +312,6 @@ class TabularInput extends Input { onAfterRendering() { if (this._useTabularSuggestions) { - this._applyTabularAriaOverrides(); - if (this._performTextSelection) { if (this.typedInValue.length && this.value.length) { this._adjustSelectionRange(); @@ -332,35 +325,6 @@ class TabularInput extends Input { super.onAfterRendering(); } - /** - * Applies ARIA role overrides to make the ui5-table behave as a listbox. - * @private - */ - _applyTabularAriaOverrides() { - const table = this.shadowRoot?.querySelector(".ui5-tabular-suggestions-table"); - if (!table) { - return; - } - - const tableInner = table.shadowRoot?.querySelector("[role='grid']"); - if (tableInner) { - tableInner.setAttribute("role", "listbox"); - tableInner.removeAttribute("aria-colcount"); - tableInner.removeAttribute("aria-multiselectable"); - } - - const visibleRows = this._visibleRows; - table.querySelectorAll("[ui5-table-row]").forEach((rowElement, index) => { - const rowInner = rowElement.shadowRoot?.querySelector("[role='row']"); - if (rowInner) { - rowInner.setAttribute("role", "option"); - rowInner.removeAttribute("aria-rowindex"); - const isSelected = visibleRows[index]?.focused || visibleRows[index]?.selected; - rowInner.setAttribute("aria-selected", String(!!isSelected)); - } - }); - } - /** * @private */ @@ -376,140 +340,21 @@ class TabularInput extends Input { } /** + * Processes rows and generates highlighted markup for cell content. * @private */ - _filterTabularRows() { - const typedValue = this.typedInValue; - const typedValueLower = typedValue.toLowerCase(); - - this._processedRows = []; - - this.suggestionRows.forEach(row => { - const cells = row.cells || []; - let matches = false; - - const processedCells: HighlightedCellContent[] = cells.map(cell => { - const cellText = cell.textContent?.trim() || ""; - const cellMatches = this._matchesStartsWithPerTerm(cellText, typedValueLower); - - if (cellMatches) { - matches = true; - } - - const highlightedMarkup = typedValue - ? this._generateStartsWithPerTermHighlight(cellText, typedValue) - : encodeXML(cellText); - - return { - text: cellText, - highlightedMarkup, - }; - }); - - (row as UI5Element).hidden = !matches; - - if (matches) { - this._processedRows.push({ - row, - cells: processedCells, - }); - } - }); - } - - /** - * @private - */ - _matchesStartsWithPerTerm(text: string, valueLower: string): boolean { - if (!valueLower) { - return true; - } - const textLower = text.toLowerCase(); - const reg = new RegExp(`(^|\\s)${this._escapeRegExp(valueLower)}`, "i"); - return reg.test(textLower); - } - - /** - * @private - */ - _escapeRegExp(str: string): string { - return str.replace(/[[\]{}()*+?.\\^$|]/g, "\\$&"); - } - - /** - * Generates highlighted markup using StartsWithPerTerm logic. - * @private - */ - _generateStartsWithPerTermHighlight(text: string, value: string): string { - if (!text || !value) { - return encodeXML(text); - } - - const valueLower = value.toLowerCase(); - const valueLength = value.length; - const positions: Array<{ start: number; end: number }> = []; - const textLower = text.toLowerCase(); - - if (textLower.startsWith(valueLower)) { - positions.push({ start: 0, end: valueLength }); - } - - let searchStart = 0; - while (searchStart < text.length) { - const spaceIndex = text.indexOf(" ", searchStart); - if (spaceIndex === -1) { - break; - } - - const wordStart = spaceIndex + 1; - if (wordStart < text.length && textLower.substring(wordStart).startsWith(valueLower)) { - positions.push({ start: wordStart, end: wordStart + valueLength }); - } - searchStart = spaceIndex + 1; - } - - if (positions.length === 0) { - return encodeXML(text); - } - - let result = ""; - let lastEnd = 0; - - for (const pos of positions) { - if (pos.start > lastEnd) { - result += encodeXML(text.substring(lastEnd, pos.start)); - } - result += `${encodeXML(text.substring(pos.start, pos.end))}`; - lastEnd = pos.end; - } - - if (lastEnd < text.length) { - result += encodeXML(text.substring(lastEnd)); - } - - return result; - } - - /** - * @private - */ - _resetRowVisibility() { + _processRows() { const typedValue = this.typedInValue; this._processedRows = []; this.suggestionRows.forEach(row => { - (row as UI5Element).hidden = false; - const cells = row.cells || []; const processedCells: HighlightedCellContent[] = cells.map(cell => { const cellText = cell.textContent?.trim() || ""; - const highlightedMarkup = typedValue - ? this._generateStartsWithPerTermHighlight(cellText, typedValue) - : encodeXML(cellText); return { text: cellText, - highlightedMarkup, + highlightedMarkup: generateHighlightedMarkup(cellText, typedValue), }; }); @@ -525,25 +370,14 @@ class TabularInput extends Input { } get _visibleProcessedRows(): ProcessedSuggestionRow[] { - return this._processedRows.filter(pr => !(pr.row as UI5Element).hidden); + const visibleRowSet = new Set(this._visibleRows); + return this._processedRows.filter(pr => visibleRowSet.has(pr.row)); } _onSuggestionRowClick(row: ITabularSuggestionRow) { this._selectRow(row, false); } - /** - * Intercepts keyboard events on the table to prevent grid navigation. - * @private - */ - _onTableKeyDown(e: KeyboardEvent) { - if (isF2(e) || isF7(e) || isLeft(e) || isRight(e) || - isPageUp(e) || isPageDown(e) || isHome(e) || isEnd(e)) { - e.stopPropagation(); - e.preventDefault(); - } - } - /** * Handles row-click event from the table to select the corresponding suggestion. * @private @@ -568,7 +402,6 @@ class TabularInput extends Input { this.fireDecoratorEvent("selection-change", { row, }); - this.fireDecoratorEvent("row-select", { row }); this.fireDecoratorEvent("change"); this.fireDecoratorEvent("input", { inputType: "" }); @@ -638,6 +471,8 @@ class TabularInput extends Input { this._deselectAllRows(); this._matchedTabularRow = undefined; this._rowFocused = false; + this._clearAnnouncement(); + this.value = this.typedInValue; return; } nextIndex = currentIndex - 1; @@ -651,6 +486,9 @@ class TabularInput extends Input { const previewValue = this._getRowValue(visibleRows[nextIndex]); this.value = previewValue; + this._performTextSelection = true; + + this._announceSelectedRow(nextIndex); this.fireDecoratorEvent("selection-change", { row: visibleRows[nextIndex], @@ -702,6 +540,7 @@ class TabularInput extends Input { this._matchedTabularRow = undefined; this._rowFocused = false; this.isTyping = false; + this._clearAnnouncement(); return; } super._handleEscape(); @@ -752,16 +591,27 @@ class TabularInput extends Input { } /** - * Returns the ID of the currently focused row for aria-activedescendant + * Announces the currently selected row for screen readers using a live region. + * @private + */ + _announceSelectedRow(rowIndex: number) { + const invisibleText = this.shadowRoot?.querySelector("#selectionText"); + if (invisibleText) { + const rowText = this._getRowValue(this._visibleRows[rowIndex]); + const positionText = Input.i18nBundle.getText(LIST_ITEM_POSITION, rowIndex + 1, this._visibleRows.length); + invisibleText.textContent = `${rowText} ${positionText}`; + } + } + + /** + * Clears the announcement text when closing the popover. * @private */ - get _activeDescendantId(): string | undefined { - const focusedRow = this._visibleRows.find(row => row.focused); - if (focusedRow) { - const index = this._visibleRows.indexOf(focusedRow); - return `${this._id}-row-${index}`; + _clearAnnouncement() { + const invisibleText = this.shadowRoot?.querySelector("#selectionText"); + if (invisibleText) { + invisibleText.textContent = ""; } - return undefined; } /** @@ -800,6 +650,5 @@ TabularInput.define(); export default TabularInput; export type { ITabularSuggestionRow, - TabularInputRowSelectEventDetail, TabularInputSelectionChangeEventDetail, }; diff --git a/packages/main/src/TabularInputPopoverTemplate.tsx b/packages/main/src/TabularInputPopoverTemplate.tsx index f7dfd7204984..6dbfcd3dc56f 100644 --- a/packages/main/src/TabularInputPopoverTemplate.tsx +++ b/packages/main/src/TabularInputPopoverTemplate.tsx @@ -13,7 +13,7 @@ import TableCell from "./TableCell.js"; /** * Renders the tabular suggestions popover for TabularInput. - * Uses ui5-table for rendering with ARIA overrides for listbox semantics. + * Uses ui5-table for rendering tabular suggestions. */ export default function TabularInputPopoverTemplate(this: TabularInput): JsxTemplateResult { return ( @@ -72,30 +72,35 @@ export default function TabularInputPopoverTemplate(this: TabularInput): JsxTemp /** * Renders the tabular suggestions list using ui5-table. - * ARIA roles are overridden in TabularInput.ts to provide listbox semantics. */ function tabularSuggestionsList(this: TabularInput): JsxTemplateResult { + const isScrollMode = this.overflowMode === "Scroll"; + const lastColumnIndex = this.suggestionColumns.length - 1; + return (
- {this.suggestionColumns.map((col, index) => ( - - {col.textContent} - - ))} + {this.suggestionColumns.map((col, index) => { + // In Scroll mode, make last column flexible to prevent dummy cell + const width = (isScrollMode && index === lastColumnIndex) ? undefined : col.width; + return ( + + {col.textContent} + + ); + })} {this._visibleProcessedRows.map((processedRow, rowIndex) => ( diff --git a/packages/main/src/TabularInputTemplate.tsx b/packages/main/src/TabularInputTemplate.tsx index dd355c4df87e..f46429836ed1 100644 --- a/packages/main/src/TabularInputTemplate.tsx +++ b/packages/main/src/TabularInputTemplate.tsx @@ -1,105 +1,28 @@ +import InputTemplate from "./InputTemplate.js"; +import type Input from "./Input.js"; import type TabularInput from "./TabularInput.js"; - -import Icon from "./Icon.js"; -import decline from "@ui5/webcomponents-icons/dist/decline.js"; - import TabularInputPopoverTemplate from "./TabularInputPopoverTemplate.js"; export default function TabularInputTemplate(this: TabularInput) { - return ( - <> -
-
- 0} - disabled={this.disabled} - readonly={this._readonly} - value={this.value} - required={this.required} - placeholder={this._placeholder} - maxlength={this.maxlength} - role={this.accInfo.role} - enterkeyhint={this.hint} - aria-controls={this.accInfo.ariaControls} - aria-invalid={this.accInfo.ariaInvalid} - aria-haspopup="listbox" - aria-describedby={this.accInfo.ariaDescribedBy} - aria-roledescription={this.accInfo.ariaRoledescription} - aria-autocomplete="list" - aria-expanded={this.open} - aria-label={this.accInfo.ariaLabel} - aria-required={this.required} - aria-activedescendant={this._activeDescendantId} - autocomplete="off" - data-sap-focus-ref - step={this.nativeInputAttributes.step} - min={this.nativeInputAttributes.min} - max={this.nativeInputAttributes.max} - onInput={this._handleNativeInput} - onChange={this._handleChange} - onSelect={this._handleSelect} - onKeyDown={this._onkeydown} - onKeyUp={this._onkeyup} - onClick={this._click} - onFocusIn={this.innerFocusIn} - /> - - {this._effectiveShowClearIcon && -
- - -
- } - - {this.icon.length > 0 && -
- -
- } + return InputTemplate.call(this as unknown as Input, { + preContent: tabularPreContent.bind(this), + postContent: tabularPostContent.bind(this), + popoverTemplate: tabularPopoverTemplate.bind(this), + }); +} -
- {this._valueStateInputIcon} -
+function tabularPreContent(this: TabularInput) { + // No pre-content needed for TabularInput +} - {this._hasTabularSuggestions && - <> - {this._tabularSuggestionsAccessibleName} - - - {this._tabularSuggestionsCountText} - - - } +function tabularPostContent(this: TabularInput) { + // No post-content needed for TabularInput +} - {this.hasValueState && - {this.ariaValueStateHiddenText} - } -
-
+function tabularPopoverTemplate(this: TabularInput) { + if (!this._useTabularSuggestions) { + return; + } - {this._useTabularSuggestions && TabularInputPopoverTemplate.call(this)} - - ); + return TabularInputPopoverTemplate.call(this); } diff --git a/packages/main/test/pages/TabularInput.html b/packages/main/test/pages/TabularInput.html index 7fd86b4162d7..c5cd0b05e204 100644 --- a/packages/main/test/pages/TabularInput.html +++ b/packages/main/test/pages/TabularInput.html @@ -56,14 +56,13 @@ Example 1: Product Search

Search for products. The suggestions show Product ID, Name, Category, and Price in columns. - Try typing "Laptop" or "Phone". + Try typing "Laptop" or "Phone". Matching text is highlighted in the suggestions.

Product ID @@ -111,15 +110,13 @@
Example 2: Employee Directory

- Search for employees by name, department, or location. - Uses StartsWith filter. + Search for employees by name. Typed text is highlighted in all cells.

Name @@ -165,9 +162,9 @@
- Example 3: Responsive Popin Mode + Example 3: Popin Overflow Mode (Default)

- This example is in a narrow container to demonstrate the popin behavior. + This example uses overflow-mode="Popin" (the default) in a narrow container. Columns will stack vertically when there's not enough space.

@@ -176,6 +173,7 @@ id="popinSearch" placeholder="Search in narrow space..." show-suggestions + overflow-mode="Popin" > Code @@ -208,19 +206,64 @@
Selected: (none)
- +
- Example 4: No Filtering + Example 4: Scroll Overflow Mode

- This example shows all suggestions without filtering. - Useful when the server handles filtering. + This example uses overflow-mode="Scroll" in a narrow container. + A horizontal scrollbar appears when columns don't fit. +

+ +
+ + + Code + Description + Quantity + Status + + + + A001 + Widget Alpha + 150 + In Stock + + + B002 + Widget Beta + 75 + Low Stock + + + C003 + Widget Gamma + 0 + Out of Stock + + +
+ +
Selected: (none)
+
+ + +
+ Example 5: Server-Side Filtering +

+ Filtering is the app developer's responsibility. Listen to the "input" event to filter + the suggestion rows based on the typed value (e.g., via server request).

Country @@ -253,9 +296,9 @@
Selected: (none)
- +
- Example 5: With Clear Icon + Example 6: With Clear Icon

TabularInput with show-clear-icon enabled for easy value clearing.

@@ -265,7 +308,6 @@ placeholder="Search with clear icon..." show-suggestions show-clear-icon - filter="Contains" > ID @@ -299,12 +341,14 @@ const outputId = input.id.replace('Search', 'Output'); const output = document.getElementById(outputId); - input.addEventListener('row-select', (e) => { + input.addEventListener('selection-change', (e) => { const row = e.detail.row; - const cells = row.querySelectorAll('ui5-table-cell'); - const cellValues = Array.from(cells).map(cell => cell.textContent.trim()); - output.textContent = `Selected Row: ${cellValues.join(' | ')}`; - console.log('row-select event:', e.detail); + if (row) { + const cells = row.querySelectorAll('ui5-table-cell'); + const cellValues = Array.from(cells).map(cell => cell.textContent.trim()); + output.textContent = `Selected Row: ${cellValues.join(' | ')}`; + } + console.log('selection-change event:', e.detail); }); input.addEventListener('change', (e) => { @@ -314,10 +358,6 @@ input.addEventListener('input', (e) => { console.log('input event - value:', e.target.value); }); - - input.addEventListener('selection-change', (e) => { - console.log('selection-change event:', e.detail); - }); }); From a573e4dcaa9b3194fbaae14e732857312db3eab6 Mon Sep 17 00:00:00 2001 From: Nikolay Deshev Date: Fri, 10 Jul 2026 11:28:45 +0300 Subject: [PATCH 4/9] feat(ui5-tabular-input): introduce new tabular suggestions input minor fixes, value state functionality implementation --- .../main/cypress/specs/TabularInput.cy.tsx | 230 +++++++++++++++- packages/main/src/TabularInput.ts | 47 +++- .../main/src/TabularInputPopoverTemplate.tsx | 170 ++++++++---- packages/main/src/TabularInputTemplate.tsx | 9 +- packages/main/test/pages/TabularInput.html | 252 +++++++++++++++++- 5 files changed, 620 insertions(+), 88 deletions(-) diff --git a/packages/main/cypress/specs/TabularInput.cy.tsx b/packages/main/cypress/specs/TabularInput.cy.tsx index edd596f40ee0..4351cbfa7dbe 100644 --- a/packages/main/cypress/specs/TabularInput.cy.tsx +++ b/packages/main/cypress/specs/TabularInput.cy.tsx @@ -128,24 +128,20 @@ describe("TabularInput - Keyboard Navigation", () => { cy.get("@input").realType("j"); - // First ArrowDown selects first row cy.realPress("ArrowDown"); cy.get("@input").should("have.value", "John"); - // Second ArrowDown moves to second row cy.realPress("ArrowDown"); cy.get("@input").should("have.value", "Jane"); - // Third ArrowDown moves to third row cy.realPress("ArrowDown"); cy.get("@input").should("have.value", "Jack"); - // ArrowUp goes back cy.realPress("ArrowUp"); cy.get("@input").should("have.value", "Jane"); }); - it("selects text during navigation (typeahead preservation)", () => { + it("selects text during navigation", () => { cy.mount( Name @@ -168,7 +164,6 @@ describe("TabularInput - Keyboard Navigation", () => { cy.get("@input").should("have.value", "John"); - // Check that the autocompleted part is selected cy.window().then(win => { const selection = win.getSelection()?.toString(); expect(selection).to.contain("ohn"); @@ -307,11 +302,8 @@ describe("TabularInput - Typeahead", () => { cy.get("@input").realType("jo"); - // Typeahead autocompletes - preserves user's typed case + rest from suggestion - // "jo" + "hn" = "john" cy.get("@input").should("have.value", "john"); - // Check that the autocompleted part is selected cy.window().then(win => { const selection = win.getSelection()?.toString(); expect(selection).to.contain("hn"); @@ -434,6 +426,226 @@ describe("TabularInput - Value State", () => { cy.get("[ui5-tabular-input]") .should("have.attr", "value-state", "Negative"); }); + + it("shows value state header in suggestions popover", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("have.attr", "open"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover] [slot='header']") + .should("exist") + .find("[ui5-icon]") + .should("exist"); + }); + + it("shows custom value state message from slot", () => { + cy.mount( + + Name + + John + +
Custom info message
+
+ ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover] [slot='header'] slot[name='valueStateMessage']") + .should("exist"); + }); + + it("shows value state icon for Critical state", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover] [slot='header'] [ui5-icon]") + .should("exist"); + }); + + it("shows value state icon for Information state", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover] [slot='header'] [ui5-icon]") + .should("exist"); + }); + + it("applies Positive value state styling without header message", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .should("have.attr", "value-state", "Positive"); + + cy.get("@input").realClick(); + cy.get("@input").realType("j"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("have.attr", "open"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover] [slot='header']") + .should("not.exist"); + }); + + it("shows value state header on re-focus after blur", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input").realType("j"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover] [slot='header'] [ui5-icon]") + .should("exist"); + + cy.get("body").realClick(); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("not.have.attr", "open"); + + cy.get("@input").realClick(); + cy.get("@input").realType("o"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover] [slot='header'] [ui5-icon]") + .should("exist"); + }); + + it("shows standalone value state popover when focused without typing", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .should("not.have.attr", "open"); + + cy.get("@input") + .shadow() + .find("[ui5-popover].ui5-valuestatemessage-popover") + .should("have.attr", "open"); + }); + + it("shows standalone value state popover on re-focus without typing", () => { + cy.mount( + + Name + + John + + + ); + + cy.get("[ui5-tabular-input]") + .as("input") + .realClick(); + + cy.get("@input") + .shadow() + .find("[ui5-popover].ui5-valuestatemessage-popover") + .should("have.attr", "open"); + + cy.get("body").realClick(); + + cy.get("@input") + .shadow() + .find("[ui5-popover].ui5-valuestatemessage-popover") + .should("not.have.attr", "open"); + + cy.get("@input").realClick(); + + cy.get("@input") + .shadow() + .find("[ui5-popover].ui5-valuestatemessage-popover") + .should("have.attr", "open"); + }); }); describe("TabularInput - Disabled and Readonly", () => { diff --git a/packages/main/src/TabularInput.ts b/packages/main/src/TabularInput.ts index e32d356666cb..d2516ddc9cff 100644 --- a/packages/main/src/TabularInput.ts +++ b/packages/main/src/TabularInput.ts @@ -3,11 +3,7 @@ import type { Slot } from "@ui5/webcomponents-base/dist/UI5Element.js"; import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; import property from "@ui5/webcomponents-base/dist/decorators/property.js"; import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js"; -import event from "@ui5/webcomponents-base/dist/decorators/event-strict.js"; import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; -import { - isF2, isF7, isLeft, isRight, isPageUp, isPageDown, isHome, isEnd, -} from "@ui5/webcomponents-base/dist/Keys.js"; import { isPhone, isAndroid } from "@ui5/webcomponents-base/dist/Device.js"; import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; import generateHighlightedMarkup from "@ui5/webcomponents-base/dist/util/generateHighlightedMarkupFirstMatch.js"; @@ -212,13 +208,8 @@ class TabularInput extends Input { if (this._useTabularSuggestions) { this._processRows(); - this._handleTabularPopoverOpen(); this._handleTabularTypeAhead(); - - this._effectiveShowClearIcon = (this.showClearIcon && !!this.value && !this.readonly && !this.disabled); - this.style.setProperty("--_ui5-input-icons-count", `${this.iconsCount}`); - return; } super.onBeforeRendering(); @@ -319,6 +310,7 @@ class TabularInput extends Input { this.fireDecoratorEvent("type-ahead"); } this._performTextSelection = false; + return; } @@ -374,6 +366,9 @@ class TabularInput extends Input { return this._processedRows.filter(pr => visibleRowSet.has(pr.row)); } + /** + * @private + */ _onSuggestionRowClick(row: ITabularSuggestionRow) { this._selectRow(row, false); } @@ -392,6 +387,9 @@ class TabularInput extends Input { } } + /** + * @private + */ _selectRow(row: ITabularSuggestionRow, keyboardUsed: boolean) { const rowValue = this._getRowValue(row); @@ -416,6 +414,9 @@ class TabularInput extends Input { } } + /** + * @private + */ _getRowValue(row: ITabularSuggestionRow): string { const cells = row.cells || []; @@ -426,6 +427,9 @@ class TabularInput extends Input { return ""; } + /** + * @private + */ _deselectAllRows() { this.suggestionRows.forEach(row => { row.selected = false; @@ -433,6 +437,9 @@ class TabularInput extends Input { }); } + /** + * @private + */ _handleDown(e: KeyboardEvent) { if (this._useTabularSuggestions && this.open) { e.preventDefault(); @@ -442,6 +449,9 @@ class TabularInput extends Input { super._handleDown(e); } + /** + * @private + */ _handleUp(e: KeyboardEvent) { if (this._useTabularSuggestions && this.open) { e.preventDefault(); @@ -451,6 +461,9 @@ class TabularInput extends Input { super._handleUp(e); } + /** + * @private + */ _navigateRows(forward: boolean) { const visibleRows = this._visibleRows; @@ -495,6 +508,9 @@ class TabularInput extends Input { }); } + /** + * @private + */ _handleEnter(e: KeyboardEvent) { if (this._useTabularSuggestions) { const visibleRows = this._visibleRows; @@ -515,11 +531,8 @@ class TabularInput extends Input { if (this.open) { e.preventDefault(); - this._selectRow(rowToSelect, true); - } else { - this.fireSelectionChange(rowToSelect as unknown as IInputSuggestionItem, true); - this._selectRow(rowToSelect, true); } + this._selectRow(rowToSelect, true); return; } @@ -532,6 +545,9 @@ class TabularInput extends Input { super._handleEnter(e); } + /** + * @private + */ _handleEscape() { if (this._useTabularSuggestions && this.open) { this.value = this.typedInValue || this.valueBeforeSelectionStart; @@ -546,6 +562,9 @@ class TabularInput extends Input { super._handleEscape(); } + /** + * @private + */ _clearPopoverFocusAndSelection() { if (this._useTabularSuggestions) { this._deselectAllRows(); @@ -637,6 +656,8 @@ class TabularInput extends Input { this.focused = false; this.open = false; + this.isTyping = false; + this.lastConfirmedValue = ""; this._clearPopoverFocusAndSelection(); return; } diff --git a/packages/main/src/TabularInputPopoverTemplate.tsx b/packages/main/src/TabularInputPopoverTemplate.tsx index 6dbfcd3dc56f..c6880e9a4bad 100644 --- a/packages/main/src/TabularInputPopoverTemplate.tsx +++ b/packages/main/src/TabularInputPopoverTemplate.tsx @@ -1,6 +1,15 @@ import type TabularInput from "./TabularInput.js"; import type { JsxTemplateResult } from "@ui5/webcomponents-base/dist/index.js"; +import Icon from "./Icon.js"; +import error from "@ui5/webcomponents-icons/dist/error.js"; +import alert from "@ui5/webcomponents-icons/dist/alert.js"; +import sysEnter2 from "@ui5/webcomponents-icons/dist/sys-enter-2.js"; +import information from "@ui5/webcomponents-icons/dist/information.js"; + +import PopoverHorizontalAlign from "./types/PopoverHorizontalAlign.js"; +import Popover from "./Popover.js"; +import ValueState from "@ui5/webcomponents-base/dist/types/ValueState.js"; import ResponsivePopover from "./ResponsivePopover.js"; import Button from "./Button.js"; import Title from "./Title.js"; @@ -11,68 +20,124 @@ import TableHeaderCell from "./TableHeaderCell.js"; import TableRow from "./TableRow.js"; import TableCell from "./TableCell.js"; -/** - * Renders the tabular suggestions popover for TabularInput. - * Uses ui5-table for rendering tabular suggestions. - */ export default function TabularInputPopoverTemplate(this: TabularInput): JsxTemplateResult { return ( - - {this._isPhone && -
-
- - {this._headerTitleText} - -
-
-
- + <> + + {this._isPhone && +
+
+ + {this._headerTitleText} + +
+
+
+ +
+
+ {this.hasValueStateMessage && +
+ + {this.open && valueStateMessage.call(this)}
+ }
-
- } + } + + {!this._isPhone && this.hasValueStateMessage && +
+ + {this.open && valueStateMessage.call(this)} +
+ } + + {tabularSuggestionsList.call(this)} - {tabularSuggestionsList.call(this)} + {this._isPhone && + + } + - {this._isPhone && - + {this.hasValueStateMessage && ( + +
+ + {this.valueStateOpen && valueStateMessage.call(this)} +
+
+ )} + + ); +} + +function valueStateMessage(this: TabularInput) { + return ( + <> + { + this.shouldDisplayDefaultValueStateMessage ? this.valueStateText : } - + ); } -/** - * Renders the tabular suggestions list using ui5-table. - */ +function valueStateMessageInputIcon(this: TabularInput) { + const iconPerValueState = { + Negative: error, + Critical: alert, + Positive: sysEnter2, + Information: information, + }; + + return this.valueState !== ValueState.None ? iconPerValueState[this.valueState as keyof typeof iconPerValueState] : ""; +} + function tabularSuggestionsList(this: TabularInput): JsxTemplateResult { const isScrollMode = this.overflowMode === "Scroll"; const lastColumnIndex = this.suggestionColumns.length - 1; @@ -87,7 +152,6 @@ function tabularSuggestionsList(this: TabularInput): JsxTemplateResult { > {this.suggestionColumns.map((col, index) => { - // In Scroll mode, make last column flexible to prevent dummy cell const width = (isScrollMode && index === lastColumnIndex) ? undefined : col.width; return ( Selected: (none)
+ +
+ Example 7: Value States +

+ TabularInput supports all value states: Negative (Error), Critical (Warning), Positive (Success), and Information. +

+ +
+ + Name + Status + + Item A + Invalid + + + + + Name + Status + + Item B + Pending + + + + + Name + Status + + Item C + Approved + + + + + Name + Status + + Item D + Info + + +
+
+ + +
+ Example 8: Value State with Custom Message +

+ Custom value state messages are shown in a popover when the input is focused. + Focus on each input to see the message. +

+ +
+ +
+ This product ID is invalid. Please enter a valid ID starting with "PRD-". +
+ Product + Price + + PRD-001 + $99 + +
+ + +
+ Stock is running low. Consider reordering soon. +
+ Product + Stock + + Widget X + 5 left + +
+ + +
+ Tip: You can use wildcards (*) for broader search results. +
+ Search + Results + + Widget* + 25 matches + +
+
+
+ + +
+ Example 9: Value State with Formatted Text +

+ Value state messages can contain formatted text with bold, italic, and other styling. +

+ +
+ +
+ Invalid Entry: The value "XYZ-999" does not match any record. + Please check your input and try again. +
+ Code + Description + + ABC-001 + Valid Code + +
+ + +
+ Attention: This item will be discontinued on December 31, 2026. + Consider selecting an alternative. +
+ Item + End Date + + Legacy Widget + Dec 31, 2026 + + + New Widget + Active + +
+
+
+ + +
+ Example 10: Value State with Links +

+ Value state messages can contain interactive links for help, documentation, or actions. + Focus on the input to see the message with links. +

+ +
+ +
+ Product not found in catalog. + View product guidelines + or + contact support. +
+ Product + Category + + PRD-001 + Electronics + +
+ + +
+ This vendor requires approval. + Request approval + or + choose alternative vendor. +
+ Vendor + Status + + Acme Corp + Pending + + + Beta Inc + Approved + +
+ + +
+ New search syntax available! + Learn about advanced search operators. +
+ Query + Results + + name:Widget + 15 matches + +
+
+
+ + + + + diff --git a/packages/website/docs/_samples/main/TabularInput/Basic/sample.tsx b/packages/website/docs/_samples/main/TabularInput/Basic/sample.tsx new file mode 100644 index 000000000000..89fd8b96fe00 --- /dev/null +++ b/packages/website/docs/_samples/main/TabularInput/Basic/sample.tsx @@ -0,0 +1,42 @@ +import createReactComponent from "@ui5/webcomponents-base/dist/createReactComponent.js"; +import TabularInputClass from "@ui5/webcomponents/dist/TabularInput.js"; +import TableHeaderCellClass from "@ui5/webcomponents/dist/TableHeaderCell.js"; +import TableRowClass from "@ui5/webcomponents/dist/TableRow.js"; +import TableCellClass from "@ui5/webcomponents/dist/TableCell.js"; + +const TabularInput = createReactComponent(TabularInputClass); +const TableHeaderCell = createReactComponent(TableHeaderCellClass); +const TableRow = createReactComponent(TableRowClass); +const TableCell = createReactComponent(TableCellClass); + +function App() { + return ( + + Product ID + Name + Category + Price + + + PRD-001 + Laptop Pro 15 + Electronics + $1,299 + + + PRD-002 + Smartphone X + Electronics + $899 + + + PRD-003 + Wireless Mouse + Accessories + $49 + + + ); +} + +export default App; diff --git a/packages/website/docs/_samples/main/TabularInput/ScrollMode/ScrollMode.md b/packages/website/docs/_samples/main/TabularInput/ScrollMode/ScrollMode.md new file mode 100644 index 000000000000..0c062a836e84 --- /dev/null +++ b/packages/website/docs/_samples/main/TabularInput/ScrollMode/ScrollMode.md @@ -0,0 +1,5 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; +import react from '!!raw-loader!./sample.tsx'; + + diff --git a/packages/website/docs/_samples/main/TabularInput/ScrollMode/main.js b/packages/website/docs/_samples/main/TabularInput/ScrollMode/main.js new file mode 100644 index 000000000000..200b6d6b2e34 --- /dev/null +++ b/packages/website/docs/_samples/main/TabularInput/ScrollMode/main.js @@ -0,0 +1,4 @@ +import "@ui5/webcomponents/dist/TabularInput.js"; +import "@ui5/webcomponents/dist/TableHeaderCell.js"; +import "@ui5/webcomponents/dist/TableRow.js"; +import "@ui5/webcomponents/dist/TableCell.js"; diff --git a/packages/website/docs/_samples/main/TabularInput/ScrollMode/sample.html b/packages/website/docs/_samples/main/TabularInput/ScrollMode/sample.html new file mode 100644 index 000000000000..fddd8ce2a679 --- /dev/null +++ b/packages/website/docs/_samples/main/TabularInput/ScrollMode/sample.html @@ -0,0 +1,45 @@ + + + + + + + + Sample + + + + + + + Name + Department + Location + Extension + + + John Smith + Engineering + Building A + x1234 + + + Jane Doe + Marketing + Building B + x2345 + + + James Wilson + Sales + Building A + x3456 + + + + + + + + + diff --git a/packages/website/docs/_samples/main/TabularInput/ScrollMode/sample.tsx b/packages/website/docs/_samples/main/TabularInput/ScrollMode/sample.tsx new file mode 100644 index 000000000000..0e6d090bfac0 --- /dev/null +++ b/packages/website/docs/_samples/main/TabularInput/ScrollMode/sample.tsx @@ -0,0 +1,42 @@ +import createReactComponent from "@ui5/webcomponents-base/dist/createReactComponent.js"; +import TabularInputClass from "@ui5/webcomponents/dist/TabularInput.js"; +import TableHeaderCellClass from "@ui5/webcomponents/dist/TableHeaderCell.js"; +import TableRowClass from "@ui5/webcomponents/dist/TableRow.js"; +import TableCellClass from "@ui5/webcomponents/dist/TableCell.js"; + +const TabularInput = createReactComponent(TabularInputClass); +const TableHeaderCell = createReactComponent(TableHeaderCellClass); +const TableRow = createReactComponent(TableRowClass); +const TableCell = createReactComponent(TableCellClass); + +function App() { + return ( + + Name + Department + Location + Extension + + + John Smith + Engineering + Building A + x1234 + + + Jane Doe + Marketing + Building B + x2345 + + + James Wilson + Sales + Building A + x3456 + + + ); +} + +export default App; diff --git a/packages/website/docs/_samples/main/TabularInput/ValueState/ValueState.md b/packages/website/docs/_samples/main/TabularInput/ValueState/ValueState.md new file mode 100644 index 000000000000..0c062a836e84 --- /dev/null +++ b/packages/website/docs/_samples/main/TabularInput/ValueState/ValueState.md @@ -0,0 +1,5 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; +import react from '!!raw-loader!./sample.tsx'; + + diff --git a/packages/website/docs/_samples/main/TabularInput/ValueState/main.js b/packages/website/docs/_samples/main/TabularInput/ValueState/main.js new file mode 100644 index 000000000000..200b6d6b2e34 --- /dev/null +++ b/packages/website/docs/_samples/main/TabularInput/ValueState/main.js @@ -0,0 +1,4 @@ +import "@ui5/webcomponents/dist/TabularInput.js"; +import "@ui5/webcomponents/dist/TableHeaderCell.js"; +import "@ui5/webcomponents/dist/TableRow.js"; +import "@ui5/webcomponents/dist/TableCell.js"; diff --git a/packages/website/docs/_samples/main/TabularInput/ValueState/sample.html b/packages/website/docs/_samples/main/TabularInput/ValueState/sample.html new file mode 100644 index 000000000000..07256aa7e270 --- /dev/null +++ b/packages/website/docs/_samples/main/TabularInput/ValueState/sample.html @@ -0,0 +1,51 @@ + + + + + + + + Sample + + + + + +
+ +
Product ID is invalid. Please enter a valid ID.
+ Product + Price + + PRD-001 + $99 + +
+ + +
Stock is running low. Consider reordering soon.
+ Product + Stock + + Widget X + 5 left + +
+ + +
Tip: You can use wildcards (*) for broader search.
+ Search + Results + + Widget* + 25 matches + +
+
+ + + + + + + diff --git a/packages/website/docs/_samples/main/TabularInput/ValueState/sample.tsx b/packages/website/docs/_samples/main/TabularInput/ValueState/sample.tsx new file mode 100644 index 000000000000..6034a8fee3ad --- /dev/null +++ b/packages/website/docs/_samples/main/TabularInput/ValueState/sample.tsx @@ -0,0 +1,48 @@ +import createReactComponent from "@ui5/webcomponents-base/dist/createReactComponent.js"; +import TabularInputClass from "@ui5/webcomponents/dist/TabularInput.js"; +import TableHeaderCellClass from "@ui5/webcomponents/dist/TableHeaderCell.js"; +import TableRowClass from "@ui5/webcomponents/dist/TableRow.js"; +import TableCellClass from "@ui5/webcomponents/dist/TableCell.js"; + +const TabularInput = createReactComponent(TabularInputClass); +const TableHeaderCell = createReactComponent(TableHeaderCellClass); +const TableRow = createReactComponent(TableRowClass); +const TableCell = createReactComponent(TableCellClass); + +function App() { + return ( +
+ +
Product ID is invalid. Please enter a valid ID.
+ Product + Price + + PRD-001 + $99 + +
+ + +
Stock is running low. Consider reordering soon.
+ Product + Stock + + Widget X + 5 left + +
+ + +
Tip: You can use wildcards (*) for broader search.
+ Search + Results + + Widget* + 25 matches + +
+
+ ); +} + +export default App; From c6942c257b5b6f131c5f90035280022ea3de98b5 Mon Sep 17 00:00:00 2001 From: Nikolay Deshev Date: Tue, 14 Jul 2026 09:14:27 +0300 Subject: [PATCH 8/9] feat(ui5-tabular-input): introduce new tabular suggestions input add mdx file --- .../main/TabularInput/TabularInput.mdx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 packages/website/docs/_components_pages/main/TabularInput/TabularInput.mdx diff --git a/packages/website/docs/_components_pages/main/TabularInput/TabularInput.mdx b/packages/website/docs/_components_pages/main/TabularInput/TabularInput.mdx new file mode 100644 index 000000000000..f1e3f6b6725a --- /dev/null +++ b/packages/website/docs/_components_pages/main/TabularInput/TabularInput.mdx @@ -0,0 +1,26 @@ +--- +slug: ../../TabularInput +--- + +import Basic from "../../../_samples/main/TabularInput/Basic/Basic.md"; +import ScrollMode from "../../../_samples/main/TabularInput/ScrollMode/ScrollMode.md"; +import ValueState from "../../../_samples/main/TabularInput/ValueState/ValueState.md"; + +<%COMPONENT_OVERVIEW%> + +## Basic Sample + + +<%COMPONENT_METADATA%> + +## More Samples + +### Scroll Mode +When the number of suggestion columns exceeds the available width, you can enable horizontal scrolling using the `overflowMode="Scroll"` property. + + + +### Value State +The component supports different value states to indicate validation status. + + From 98f06c29458e0f05b9381999ed4060ae1b3036de Mon Sep 17 00:00:00 2001 From: Nikolay Deshev Date: Tue, 14 Jul 2026 14:55:40 +0300 Subject: [PATCH 9/9] feat(ui5-tabular-input): introduce new tabular suggestions input fix table scrolling --- packages/main/src/TabularInput.ts | 38 +++++++++++++++ packages/main/test/pages/TabularInput.html | 54 ++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/packages/main/src/TabularInput.ts b/packages/main/src/TabularInput.ts index 9f701d6287a5..8f51b101bad6 100644 --- a/packages/main/src/TabularInput.ts +++ b/packages/main/src/TabularInput.ts @@ -10,6 +10,7 @@ import generateHighlightedMarkup from "@ui5/webcomponents-base/dist/util/generat import Input from "./Input.js"; import type { IInputSuggestionItem } from "./Input.js"; +import type Table from "./Table.js"; import type TableHeaderCell from "./TableHeaderCell.js"; import type TableCell from "./TableCell.js"; import type TableRow from "./TableRow.js"; @@ -502,6 +503,7 @@ class TabularInput extends Input { this._performTextSelection = true; this._announceSelectedRow(nextIndex); + this._scrollRowIntoView(nextIndex); this.fireDecoratorEvent("selection-change", { row: visibleRows[nextIndex], @@ -641,6 +643,42 @@ class TabularInput extends Input { return this.shadowRoot?.querySelector(".ui5-suggestions-popover"); } + /** + * Scrolls the row at the given index into view within the suggestions popover. + * @private + */ + _scrollRowIntoView(rowIndex: number) { + const popover = this._getTabularPopover(); + if (!popover) { + return; + } + + const table = popover.querySelector
("[ui5-table]"); + const rowElement = table?.rows[rowIndex]; + + if (!rowElement) { + return; + } + + const scrollContainer = popover.querySelector(".ui5-tabular-input-suggestions-wrapper"); + if (!scrollContainer) { + return; + } + + const containerRect = scrollContainer.getBoundingClientRect(); + const rowRect = rowElement.getBoundingClientRect(); + + const isRowAboveView = rowRect.top < containerRect.top; + const isRowBelowView = rowRect.bottom > containerRect.bottom; + + if (isRowAboveView || isRowBelowView) { + rowElement.scrollIntoView({ + behavior: "auto", + block: "nearest", + }); + } + } + /** * Override focusout handler to prevent closing popover when clicking inside it * @private diff --git a/packages/main/test/pages/TabularInput.html b/packages/main/test/pages/TabularInput.html index f38e566aaba0..804bddf9310d 100644 --- a/packages/main/test/pages/TabularInput.html +++ b/packages/main/test/pages/TabularInput.html @@ -577,6 +577,60 @@ + +
+ Example 12: Many Suggestions (112 rows) +

+ This example demonstrates performance with a large number of suggestions (112 rows). + The popover should handle scrolling smoothly. +

+ + + SKU + Product Name + Category + Price + + +
Selected: (none)
+
+ + +