diff --git a/.changeset/stupid-sheep-grin.md b/.changeset/stupid-sheep-grin.md new file mode 100644 index 00000000000..2c01321aa02 --- /dev/null +++ b/.changeset/stupid-sheep-grin.md @@ -0,0 +1,5 @@ +--- +"@siemens/ix": minor +--- + +In multiple-mode **ix-select** now displays "+" for the number of items that are selected if more than two items have been selected. diff --git a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-38132-button-in-cell-should-appear-above-other-rows-1-chromium---classic-dark-linux.png b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-38132-button-in-cell-should-appear-above-other-rows-1-chromium---classic-dark-linux.png index b944b893d2b..644403abb77 100644 Binary files a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-38132-button-in-cell-should-appear-above-other-rows-1-chromium---classic-dark-linux.png and b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-38132-button-in-cell-should-appear-above-other-rows-1-chromium---classic-dark-linux.png differ diff --git a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-38132-button-in-cell-should-appear-above-other-rows-1-chromium---classic-light-linux.png b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-38132-button-in-cell-should-appear-above-other-rows-1-chromium---classic-light-linux.png index d75fb5a1940..44e07a78680 100644 Binary files a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-38132-button-in-cell-should-appear-above-other-rows-1-chromium---classic-light-linux.png and b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-38132-button-in-cell-should-appear-above-other-rows-1-chromium---classic-light-linux.png differ diff --git a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-select-in-cell-should-appear-above-other-rows-1-chromium---classic-dark-linux.png b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-select-in-cell-should-appear-above-other-rows-1-chromium---classic-dark-linux.png index 4d52a54ab04..19e67099fcd 100644 Binary files a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-select-in-cell-should-appear-above-other-rows-1-chromium---classic-dark-linux.png and b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-select-in-cell-should-appear-above-other-rows-1-chromium---classic-dark-linux.png differ diff --git a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-select-in-cell-should-appear-above-other-rows-1-chromium---classic-light-linux.png b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-select-in-cell-should-appear-above-other-rows-1-chromium---classic-light-linux.png index 3bb9afdbe0b..7ae5e448f23 100644 Binary files a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-select-in-cell-should-appear-above-other-rows-1-chromium---classic-light-linux.png and b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-dropdown-top-layer-enableTopLayer-true-select-in-cell-should-appear-above-other-rows-1-chromium---classic-light-linux.png differ diff --git a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-header-checkbox-should-be-unchecked-1-chromium---classic-dark-linux.png b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-header-checkbox-should-be-unchecked-1-chromium---classic-dark-linux.png index 8fa8e6bc70c..f220373e79e 100644 Binary files a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-header-checkbox-should-be-unchecked-1-chromium---classic-dark-linux.png and b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-header-checkbox-should-be-unchecked-1-chromium---classic-dark-linux.png differ diff --git a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-pagination-1-chromium---classic-dark-linux.png b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-pagination-1-chromium---classic-dark-linux.png index af3e4d2a01b..fd5cd2de886 100644 Binary files a/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-pagination-1-chromium---classic-dark-linux.png and b/packages/aggrid/tests/aggrid.e2e.ts-snapshots/aggrid-pagination-1-chromium---classic-dark-linux.png differ diff --git a/packages/angular-standalone-test-app/src/preview-examples/select-multiple.ts b/packages/angular-standalone-test-app/src/preview-examples/select-multiple.ts index 79ba496ba1a..cc9d00f533e 100644 --- a/packages/angular-standalone-test-app/src/preview-examples/select-multiple.ts +++ b/packages/angular-standalone-test-app/src/preview-examples/select-multiple.ts @@ -18,7 +18,7 @@ import { selector: 'app-example', imports: [IxSelect, IxSelectItem, IxSelectValueAccessorDirective], template: ` - + @@ -27,5 +27,5 @@ import { `, }) export default class SelectMultiple { - value = ['1', '3']; + value = ['1', '3', '4']; } diff --git a/packages/angular-test-app/src/preview-examples/select-multiple.ts b/packages/angular-test-app/src/preview-examples/select-multiple.ts index cd67e376247..65a3873bb1b 100644 --- a/packages/angular-test-app/src/preview-examples/select-multiple.ts +++ b/packages/angular-test-app/src/preview-examples/select-multiple.ts @@ -13,7 +13,7 @@ import { Component } from '@angular/core'; standalone: false, selector: 'app-example', template: ` - + @@ -22,5 +22,5 @@ import { Component } from '@angular/core'; `, }) export default class SelectMultiple { - value = ['1', '3']; + value = ['1', '3', '4']; } diff --git a/packages/core/src/components/select/select.scss b/packages/core/src/components/select/select.scss index 295bda44373..46a1c123ee6 100644 --- a/packages/core/src/components/select/select.scss +++ b/packages/core/src/components/select/select.scss @@ -20,8 +20,6 @@ :host { display: inline-block; position: relative; - min-height: 2rem; - height: auto; border-radius: var(--theme-input--border-radius); @include component.ix-component; @@ -31,6 +29,7 @@ display: flex; align-items: center; width: 100%; + height: 2rem; background-color: var(--theme-input--background); border: var(--theme-input--border-thickness) solid var(--theme-input--border-color); @@ -87,7 +86,8 @@ .trigger { display: flex; align-items: center; - flex-grow: 1; + flex: 1 1 0; + min-width: 4rem; height: 100%; } @@ -102,13 +102,18 @@ position: relative; display: flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; + overflow: hidden; height: 100%; - max-height: 3.5rem; flex-grow: 1; - overflow-y: auto; + + > div { + flex-shrink: 0; + margin: 0.1rem; + } > ix-filter-chip { + flex-shrink: 0; margin: 0.1rem; } } @@ -212,6 +217,7 @@ .select:hover { border-color: var(--theme-input--border-color--warning--hover) !important; + background-color: var(--theme-input--background--warning--hover) !important; } .select:active { diff --git a/packages/core/src/components/select/select.tsx b/packages/core/src/components/select/select.tsx index b14c9d20530..a4cee2d8103 100644 --- a/packages/core/src/components/select/select.tsx +++ b/packages/core/src/components/select/select.tsx @@ -270,6 +270,8 @@ export class Select @State() isValid = false; @State() isInfo = false; @State() isWarning = false; + @State() pendingChipValue: string | null = null; + @State() visibleChipValues: Set | null = null; private readonly hostId = `ix-select-${selectId++}`; private readonly dropdownWrapperRef = makeRef(); @@ -280,6 +282,14 @@ export class Select private proxyListObserver: MutationObserver | null = null; private inputElement?: HTMLInputElement; private touched = false; + private chipsEl?: HTMLDivElement; + private clearButtonEl?: HTMLIxIconButtonElement; + private chevronButtonEl?: HTMLIxIconButtonElement; + private chipsResizeObserver?: ResizeObserver; + private overflowChipEl?: HTMLElement; + + private readonly chipWidths = new Map(); + private readonly chipElementRefs = new Map(); get nonShadowItems() { return Array.from(this.hostElement.querySelectorAll('ix-select-item')); @@ -366,11 +376,17 @@ export class Select private itemClick(newId: string) { const oldValue = this.value; const value = this.toggleValue(newId); + + if (this.isMultipleMode && Array.isArray(value) && value.includes(newId)) { + this.pendingChipValue = newId; + } + this.value = value; const defaultPrevented = this.emitValueChange(value); if (defaultPrevented) { this.value = oldValue; + this.pendingChipValue = null; return; } this.updateSelection(); @@ -458,6 +474,22 @@ export class Select } this.inputElement && (this.inputElement.value = this.inputValue); + + this.handleUpdateSelectionMutlipleMode(); + } + + private handleUpdateSelectionMutlipleMode() { + if (!this.isMultipleMode) { + return; + } + + const currentValues = new Set(this.selectedItems.map((i) => i.value)); + for (const key of this.chipWidths.keys()) { + if (!currentValues.has(key)) this.chipWidths.delete(key); + } + if (this.pendingChipValue === null) { + this.calculateOverflow(); + } } private emitValueChange(value: string | string[]) { @@ -501,6 +533,18 @@ export class Select this.inputChange.emit(this.inputElement?.value); }); + // Handle overflow + this.chipsResizeObserver = new ResizeObserver(() => { + this.calculateOverflow(); + }); + + if (this.chipsEl) { + this.chipsResizeObserver.observe(this.chipsEl); + } + + this.calculateOverflow(); + + // Handle ARIA this.createAddItemElement(); this.proxyListObserver = new MutationObserver(() => { @@ -519,10 +563,36 @@ export class Select this.updateFormInternalValue(this.value); } - override disconnectedCallback(): void { - super.disconnectedCallback(); + override componentDidRender(): void { + this.handleChipsOverflow(); + } - this.proxyListObserver?.disconnect(); + private handleChipsOverflow() { + if (!this.isMultipleMode) { + return; + } + + if (this.pendingChipValue === null) { + for (const [value, el] of this.chipElementRefs) { + const isOverflow = + this.visibleChipValues !== null && !this.visibleChipValues.has(value); + + if (!isOverflow) { + this.chipWidths.set(value, el.offsetWidth); + } + } + } else { + const pendingValue = this.pendingChipValue; + const pendingEl = this.chipElementRefs.get(pendingValue); + + if (pendingEl) { + requestAnimationFrameNoNgZone(() => { + this.chipWidths.set(pendingValue, pendingEl.offsetWidth); + this.calculateOverflow(); + this.pendingChipValue = null; + }); + } + } } @Listen('ix-select-item:valueChange') @@ -533,6 +603,66 @@ export class Select this.updateSelection(); } + override disconnectedCallback() { + super.disconnectedCallback(); + + this.proxyListObserver?.disconnect(); + this.chipsResizeObserver?.disconnect(); + } + + private calculateOverflow() { + if (!this.chipsEl || this.selectedItems.length === 0) { + this.visibleChipValues = null; + return; + } + + const chevronWidth = this.chevronButtonEl?.offsetWidth ?? 0; + const clearWidth = this.clearButtonEl?.offsetWidth ?? 0; + const chipGap = 4; + const reservedInputSpace = 40; + + const computeVisibleItems = (extraReservedWidth: number) => { + const availableWidth = + this.chipsEl!.clientWidth - + chevronWidth - + clearWidth - + reservedInputSpace - + extraReservedWidth; + const visibleItems = new Set(); + let usedWidth = 0; + + for (const item of this.selectedItems) { + const width = (this.chipWidths.get(item.value) ?? 60) + chipGap; + + if (usedWidth + width <= availableWidth) { + usedWidth += width; + visibleItems.add(item.value); + } + } + + if (visibleItems.size === 0) { + visibleItems.add(this.selectedItems[0].value); + } + + return visibleItems; + }; + + // Check without +N chip first + let visibleItems = computeVisibleItems(0); + + if (visibleItems.size < this.selectedItems.length) { + const overflowChipWidth = this.overflowChipEl + ? this.overflowChipEl.offsetWidth + chipGap + : 54; + + // Also take +N chip into account if overflow occurs + visibleItems = computeVisibleItems(overflowChipWidth); + } + + this.visibleChipValues = + visibleItems.size >= this.selectedItems.length ? null : visibleItems; + } + private itemExists(item: string | undefined) { return this.items.find((i) => i.label === item); } @@ -675,6 +805,33 @@ export class Select this.hasInputFocus = true; } + private onInputKeyDown(event: KeyboardEvent) { + if (event.code !== 'Enter') { + return; + } + + if (!this.editable || !this.inputFilterText) { + return; + } + + event.stopPropagation(); + + if (this.isMultipleMode) { + this.emitAddItem(this.inputFilterText); + return; + } + + const existingItem = this.itemExists(this.inputFilterText); + if (existingItem) { + this.itemClick(existingItem.value); + } else { + this.emitAddItem(this.inputFilterText); + } + + this.dropdownShow = false; + this.updateSelection(); + } + private placeholderValue() { if (this.disabled) { return ''; @@ -684,6 +841,10 @@ export class Select return ''; } + if (this.selectedLabels?.length) { + return ''; + } + if (this.editable) { return this.i18nPlaceholderEditable; } @@ -731,21 +892,60 @@ export class Select } private renderChip(item: HTMLIxSelectItemElement) { + const isPending = item.value === this.pendingChipValue; + const isOverflow = + !isPending && + this.visibleChipValues !== null && + !this.visibleChipValues.has(item.value); return ( - { - this.itemClick(item.value); - this.inputElement?.focus(); + style={{ + visibility: isPending ? 'hidden' : undefined, + display: isOverflow ? 'none' : undefined, + }} + ref={(el) => { + if (el) { + this.chipElementRefs.set(item.value, el as HTMLElement); + } else { + this.chipElementRefs.delete(item.value); + } }} > - {item.label} - + { + this.itemClick(item.value); + this.inputElement?.focus(); + }} + > + {item.label} + + ); } + private renderChips() { + const chips = this.selectedItems.map((item) => this.renderChip(item)); + const overflowCount = + this.pendingChipValue === null && this.visibleChipValues !== null + ? this.selectedItems.length - this.visibleChipValues.size + : 0; + return [ + ...chips, + overflowCount > 0 ? ( + (this.overflowChipEl = (el as HTMLElement) ?? undefined)} + > + {`+${overflowCount}`} + + ) : null, + ]; + } + @HookValidationLifecycle() onValidationChange({ isInvalid, @@ -903,12 +1103,15 @@ export class Select }} >
-
+
(this.chipsEl = el as HTMLDivElement)} + > {this.isMultipleMode && this.items.length !== 0 && (this.shouldDisplayAllChip() ? this.renderAllChip() - : this.selectedItems?.map((item) => this.renderChip(item)))} + : this.renderChips())}
this.onInputFocus()} onBlur={(e) => this.onInputBlur(e)} onInput={() => this.setItemFilter()} + onKeyDown={(e) => this.onInputKeyDown(e)} /> {this.allowClear && !this.disabled && @@ -948,6 +1152,7 @@ export class Select variant="subtle-tertiary" oval size="16" + ref={(ref) => (this.clearButtonEl = ref ?? undefined)} onClick={(e) => { e.preventDefault(); e.stopPropagation(); @@ -965,6 +1170,7 @@ export class Select } aria-hidden="true" ref={(ref) => { + this.chevronButtonEl = ref!; const element = ref as unknown as HTMLButtonElement; // VDOM issue if tabIndex is provided via property // the tabindex will be '0' after expanding the dropdown diff --git a/packages/core/src/components/select/test/select.ct.ts b/packages/core/src/components/select/test/select.ct.ts index 0f0faed9068..de2dfb94701 100644 --- a/packages/core/src/components/select/test/select.ct.ts +++ b/packages/core/src/components/select/test/select.ct.ts @@ -228,6 +228,68 @@ test('multiple mode filter reset', async ({ mount, page }) => { await expect(element.locator('.chips').getByTitle('Item 1')).toBeVisible(); }); +test('should show overflow chip when chips exceed container width in multiple mode', async ({ + mount, + page, +}) => { + await mount(` + + Item 1 + Item 2 + Item 3 + + `); + + const selectElement = page.locator('ix-select'); + const chipsContainer = selectElement.locator('.chips'); + + await page.locator('[data-select-dropdown]').click(); + + const item1 = selectElement.locator('ix-select-item').nth(0); + const item2 = selectElement.locator('ix-select-item').nth(1); + const item3 = selectElement.locator('ix-select-item').nth(2); + await item1.click(); + await item2.click(); + await item3.click(); + + const overflowChip = chipsContainer + .locator('ix-filter-chip') + .filter({ hasText: '+1' }); + + await expect(overflowChip).toBeVisible(); +}); + +test('should show overflow chip when chips exceed container width in editable multiple mode', async ({ + mount, + page, +}) => { + await mount(` + + + `); + + const selectElement = page.locator('ix-select'); + const chipsContainer = selectElement.locator('.chips'); + const input = selectElement.locator('input'); + + await page.locator('[data-select-dropdown]').click(); + + await input.fill('Item 1'); + await page.keyboard.press('Enter'); + + await input.fill('Item 2'); + await page.keyboard.press('Enter'); + + await input.fill('Item 3'); + await page.keyboard.press('Enter'); + + const overflowChip = chipsContainer + .locator('ix-filter-chip') + .filter({ hasText: '+1' }); + + await expect(overflowChip).toBeVisible(); +}); + test('filter', async ({ mount, page }) => { await mount(` @@ -382,13 +444,15 @@ test('type in a novel item name in editable mode, click outside and reopen the s page, }) => { await mount(` - - - - - - outside - `); +
+ + Test + Test + Test + + outside +
+ `); const selectCtrl = selectController(page.locator('ix-select')); const externalButton = page.getByText('outside'); @@ -439,12 +503,14 @@ test('type in a novel item name in multiple mode, click outside', async ({ page, }) => { await mount(` - - - - - - outside +
+ + Test + Test + Test + + outside +
`); const selectCtrl = selectController(page.locator('ix-select')); diff --git a/packages/html-test-app/src/preview-examples/select-multiple.html b/packages/html-test-app/src/preview-examples/select-multiple.html index 5890c8f8567..f104bb7ecbb 100644 --- a/packages/html-test-app/src/preview-examples/select-multiple.html +++ b/packages/html-test-app/src/preview-examples/select-multiple.html @@ -15,7 +15,7 @@ Select multiple example - + @@ -26,7 +26,7 @@ (async function () { await window.customElements.whenDefined('ix-select'); const select = document.querySelector('ix-select'); - select.value = ['1', '3']; + select.value = ['1', '3', '4']; })(); diff --git a/packages/react-test-app/src/preview-examples/select-multiple.tsx b/packages/react-test-app/src/preview-examples/select-multiple.tsx index 4149f466ce3..957be73416f 100644 --- a/packages/react-test-app/src/preview-examples/select-multiple.tsx +++ b/packages/react-test-app/src/preview-examples/select-multiple.tsx @@ -14,11 +14,11 @@ export default () => { const [selection, setSelection] = useState([]); useLayoutEffect(() => { - setSelection(['1', '3']); + setSelection(['1', '3', '4']); }, [setSelection]); return ( - + diff --git a/packages/vue-test-app/src/preview-examples/select-multiple.vue b/packages/vue-test-app/src/preview-examples/select-multiple.vue index 013b0c2d2fe..66755c713e8 100644 --- a/packages/vue-test-app/src/preview-examples/select-multiple.vue +++ b/packages/vue-test-app/src/preview-examples/select-multiple.vue @@ -13,11 +13,11 @@ import { nextTick, onMounted, ref } from 'vue'; const selection = ref([]); -onMounted(() => nextTick(() => (selection.value = ['1', '3']))); +onMounted(() => nextTick(() => (selection.value = ['1', '3', '4'])));