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)
+
+ + + +