From 83ef979161d9cac49b86df4c61e867a48d559225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= Date: Mon, 20 Oct 2025 08:57:11 +0200 Subject: [PATCH 1/9] IBX-10758: Overflow list --- eslint.config.mjs | 1 + .../public/ts/components/overflow_list.ts | 180 ++++++++++++++++++ .../Resources/public/ts/init_components.ts | 9 + .../components/overflow_list.html.twig | 18 ++ src/lib/Twig/Components/OverflowList.php | 53 ++++++ 5 files changed, 261 insertions(+) create mode 100644 src/bundle/Resources/public/ts/components/overflow_list.ts create mode 100644 src/bundle/Resources/views/themes/standard/design_system/components/overflow_list.html.twig create mode 100644 src/lib/Twig/Components/OverflowList.php diff --git a/eslint.config.mjs b/eslint.config.mjs index 7d987c70..b2d8f484 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,7 @@ export default [ { files: ['**/*.ts'], rules: { + 'no-magic-numbers': ['error', { ignore: [-1, 0] }], '@typescript-eslint/unbound-method': 'off', }, }, diff --git a/src/bundle/Resources/public/ts/components/overflow_list.ts b/src/bundle/Resources/public/ts/components/overflow_list.ts new file mode 100644 index 00000000..82634f02 --- /dev/null +++ b/src/bundle/Resources/public/ts/components/overflow_list.ts @@ -0,0 +1,180 @@ +import { Base } from '../partials'; +import { escapeHTML } from '@ids-core/helpers/escape'; + +export class OverflowList extends Base { + private _moreItemNode: HTMLDivElement; + private _numberOfItems = 0; + private _numberOfVisibleItems = 0; + private _templates: Record<'item' | 'itemMore', string> = { + item: '', + itemMore: '', + }; + + private _resizeObserver = new ResizeObserver(() => { + this.resetState(); + this.rerender(); + }); + + constructor(container: HTMLDivElement) { + super(container); + + this._templates = { + item: this.getTemplate('item'), + itemMore: this.getTemplate('item_more'), + }; + + this.removeTemplate('item'); + this.removeTemplate('item_more'); + + const moreItemNode = container.querySelector(':scope *:last-child'); + + if (!moreItemNode) { + throw new Error('OverflowList: OverflowList elements are missing in the container.'); + } + + this._moreItemNode = moreItemNode; + + this._numberOfItems = this.getItems(false, false).length; + this._numberOfVisibleItems = this._numberOfItems; + } + + private getItems(getOnlyVisible = false, withOverflow = true): HTMLDivElement[] { + const itemsSelector = `:scope > *${getOnlyVisible ? ':not([hidden])' : ''}`; + const items = Array.from(this._container.querySelectorAll(itemsSelector)); + + if (withOverflow) { + return items; + } + + return items.slice(0, -1); + } + + private getTemplate(type: 'item' | 'item_more'): string { + const templateNode = this._container.querySelector(`.ids-overflow-list__template[data-id="${type}"]`); + + if (!templateNode) { + throw new Error(`OverflowList: Template of type "${type}" is missing in the container.`); + } + + return templateNode.innerHTML.trim(); + } + + private removeTemplate(type: 'item' | 'item_more') { + const templateNode = this._container.querySelector(`.ids-overflow-list__template[data-id="${type}"]`); + + templateNode?.remove(); + } + + private updateMoreItem() { + const hiddenCount = this._numberOfItems - this._numberOfVisibleItems; + + if (hiddenCount > 0) { + const tempMoreItem = document.createElement('div'); + tempMoreItem.innerHTML = this._templates.itemMore.replace('{{ hidden_count }}', hiddenCount.toString()); + + if (!tempMoreItem.firstElementChild) { + throw new Error('OverflowList: Error while creating more item element from template.'); + } + + this._moreItemNode.replaceWith(tempMoreItem.firstElementChild); + } else { + this._moreItemNode.setAttribute('hidden', 'true'); + } + } + + private hideOverflowItems() { + const itemsNodes = this.getItems(true, false); + + itemsNodes.slice(this._numberOfVisibleItems).forEach((itemNode) => { + itemNode.setAttribute('hidden', 'true'); + }); + } + + private recalculateVisibleItems() { + const itemsNodes = this.getItems(true); + const { right: listRightPosition } = this._container.getBoundingClientRect(); + const newNumberOfVisibleItems = itemsNodes.findIndex((itemNode) => { + const { right: itemRightPosition } = itemNode.getBoundingClientRect(); + + return itemRightPosition > listRightPosition; + }); + + if (newNumberOfVisibleItems === -1 || newNumberOfVisibleItems === this._numberOfItems) { + return true; + } + + if (newNumberOfVisibleItems === this._numberOfVisibleItems) { + this._numberOfVisibleItems = newNumberOfVisibleItems - 1; // eslint-disable-line no-magic-numbers + } else { + this._numberOfVisibleItems = newNumberOfVisibleItems; + } + + return false; + } + + private initResizeListener() { + this._resizeObserver.observe(this._container); + } + + public resetState() { + this._numberOfVisibleItems = this._numberOfItems; + + const itemsNodes = this.getItems(false); + + itemsNodes.forEach((itemNode) => { + itemNode.removeAttribute('hidden'); + }); + } + + public rerender() { + let stopRecalculating = true; + do { + stopRecalculating = this.recalculateVisibleItems(); + + this.hideOverflowItems(); + this.updateMoreItem(); + } while (!stopRecalculating); + } + + private setItemsContainer(items: Record[]) { + const fragment = document.createDocumentFragment(); + + items.forEach((item) => { + const filledItem = Object.entries(item).reduce((acc, [key, value]) => { + const pattern = `{{ ${key} }}`; + const escapedValue = escapeHTML(value); + return acc.replaceAll(pattern, escapedValue); + }, this._templates.item); + const container = document.createElement('div'); + + container.innerHTML = filledItem; + + if (container.firstElementChild) { + fragment.append(container.firstElementChild); + } + }); + + // Needs to use type assertion here as cloneNode returns a Node type https://github.com/microsoft/TypeScript/issues/283 + this._moreItemNode = this._moreItemNode.cloneNode(true) as HTMLDivElement; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + + fragment.append(this._moreItemNode); + + this._container.innerHTML = ''; + this._container.appendChild(fragment); + this._numberOfItems = items.length; + } + + public setItems(items: Record[]) { + this.setItemsContainer(items); + this.resetState(); + this.rerender(); + } + + public init() { + super.init(); + + this.initResizeListener(); + + this.rerender(); + } +} diff --git a/src/bundle/Resources/public/ts/init_components.ts b/src/bundle/Resources/public/ts/init_components.ts index cd4aea0a..1371fb55 100644 --- a/src/bundle/Resources/public/ts/init_components.ts +++ b/src/bundle/Resources/public/ts/init_components.ts @@ -3,6 +3,7 @@ import { InputTextField, InputTextInput } from './components/input_text'; import { Accordion } from './components/accordion'; import { AltRadioInput } from './components/alt_radio/alt_radio_input'; import { DropdownSingleInput } from './components/dropdown/dropdown_single_input'; +import { OverflowList } from './components/overflow_list'; const accordionContainers = document.querySelectorAll('.ids-accordion:not([data-ids-custom-init])'); @@ -59,3 +60,11 @@ inputTextContainers.forEach((inputTextContainer: HTMLDivElement) => { inputTextInstance.init(); }); + +const overflowListContainers = document.querySelectorAll('.ids-overflow-list:not([data-ids-custom-init])'); + +overflowListContainers.forEach((overflowListContainer: HTMLDivElement) => { + const overflowListInstance = new OverflowList(overflowListContainer); + + overflowListInstance.init(); +}); diff --git a/src/bundle/Resources/views/themes/standard/design_system/components/overflow_list.html.twig b/src/bundle/Resources/views/themes/standard/design_system/components/overflow_list.html.twig new file mode 100644 index 00000000..dd746ea6 --- /dev/null +++ b/src/bundle/Resources/views/themes/standard/design_system/components/overflow_list.html.twig @@ -0,0 +1,18 @@ +{% set overflow_list_classes = html_classes('ids-overflow-list', attributes.render('class') ?? '') %} + +
+ {% for item in items %} + {{ block('item') }} + {% endfor %} + + {{ block('more_item') }} + + + +
diff --git a/src/lib/Twig/Components/OverflowList.php b/src/lib/Twig/Components/OverflowList.php new file mode 100644 index 00000000..671a4c30 --- /dev/null +++ b/src/lib/Twig/Components/OverflowList.php @@ -0,0 +1,53 @@ + + */ + public array $items = []; + + /** + * @param array $props + * + * @return array + */ + #[PreMount] + public function validate(array $props): array + { + $resolver = new OptionsResolver(); + $resolver->setIgnoreUndefined(); + $resolver + ->define('items') + ->allowedTypes('array') + ->default([]); + + return $resolver->resolve($props) + $props; + } + + #[ExposeInTemplate('item_template_props')] + public function getItemTemplateProps(): array + { + $item_props_names = array_keys($this->items[0]); + $item_props_patterns = array_map( + fn (string $name): string => '{{ ' . $name . ' }}', + $item_props_names + ); + + return array_combine($item_props_names, $item_props_patterns) ?? []; + } +} From 95c82020ce99d14b1a9b441eff8a82b742e85a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Grabowski?= Date: Tue, 21 Oct 2025 16:57:41 +0200 Subject: [PATCH 2/9] added debounce; added const width --- .../public/ts/components/overflow_list.ts | 55 +++++++++++-------- .../components/overflow_list.html.twig | 11 ++-- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/bundle/Resources/public/ts/components/overflow_list.ts b/src/bundle/Resources/public/ts/components/overflow_list.ts index 82634f02..64864e21 100644 --- a/src/bundle/Resources/public/ts/components/overflow_list.ts +++ b/src/bundle/Resources/public/ts/components/overflow_list.ts @@ -1,7 +1,10 @@ import { Base } from '../partials'; import { escapeHTML } from '@ids-core/helpers/escape'; +const RESIZE_TIMEOUT = 200; + export class OverflowList extends Base { + private _itemsNode: HTMLDivElement; private _moreItemNode: HTMLDivElement; private _numberOfItems = 0; private _numberOfVisibleItems = 0; @@ -10,29 +13,35 @@ export class OverflowList extends Base { itemMore: '', }; + private _resizeTimeoutId: number | null = null; private _resizeObserver = new ResizeObserver(() => { - this.resetState(); - this.rerender(); + if (this._resizeTimeoutId) { + clearTimeout(this._resizeTimeoutId); + } + + this._resizeTimeoutId = window.setTimeout(() => { + this.setItemsContainerWidth(); + this.resetState(); + this.rerender(); + }, RESIZE_TIMEOUT); }); constructor(container: HTMLDivElement) { super(container); - this._templates = { - item: this.getTemplate('item'), - itemMore: this.getTemplate('item_more'), - }; - - this.removeTemplate('item'); - this.removeTemplate('item_more'); - - const moreItemNode = container.querySelector(':scope *:last-child'); + const itemsNode = container.querySelector('.ids-overflow-list__items'); + const moreItemNode = itemsNode?.querySelector(':scope *:last-child'); - if (!moreItemNode) { + if (!itemsNode || !moreItemNode) { throw new Error('OverflowList: OverflowList elements are missing in the container.'); } + this._itemsNode = itemsNode; this._moreItemNode = moreItemNode; + this._templates = { + item: this.getTemplate('item'), + itemMore: this.getTemplate('item_more'), + }; this._numberOfItems = this.getItems(false, false).length; this._numberOfVisibleItems = this._numberOfItems; @@ -40,7 +49,7 @@ export class OverflowList extends Base { private getItems(getOnlyVisible = false, withOverflow = true): HTMLDivElement[] { const itemsSelector = `:scope > *${getOnlyVisible ? ':not([hidden])' : ''}`; - const items = Array.from(this._container.querySelectorAll(itemsSelector)); + const items = Array.from(this._itemsNode.querySelectorAll(itemsSelector)); if (withOverflow) { return items; @@ -59,12 +68,6 @@ export class OverflowList extends Base { return templateNode.innerHTML.trim(); } - private removeTemplate(type: 'item' | 'item_more') { - const templateNode = this._container.querySelector(`.ids-overflow-list__template[data-id="${type}"]`); - - templateNode?.remove(); - } - private updateMoreItem() { const hiddenCount = this._numberOfItems - this._numberOfVisibleItems; @@ -92,7 +95,7 @@ export class OverflowList extends Base { private recalculateVisibleItems() { const itemsNodes = this.getItems(true); - const { right: listRightPosition } = this._container.getBoundingClientRect(); + const { right: listRightPosition } = this._itemsNode.getBoundingClientRect(); const newNumberOfVisibleItems = itemsNodes.findIndex((itemNode) => { const { right: itemRightPosition } = itemNode.getBoundingClientRect(); @@ -143,6 +146,7 @@ export class OverflowList extends Base { const filledItem = Object.entries(item).reduce((acc, [key, value]) => { const pattern = `{{ ${key} }}`; const escapedValue = escapeHTML(value); + return acc.replaceAll(pattern, escapedValue); }, this._templates.item); const container = document.createElement('div'); @@ -159,11 +163,17 @@ export class OverflowList extends Base { fragment.append(this._moreItemNode); - this._container.innerHTML = ''; - this._container.appendChild(fragment); + this._itemsNode.innerHTML = ''; + this._itemsNode.appendChild(fragment); this._numberOfItems = items.length; } + private setItemsContainerWidth() { + const overflowListWidth = this._container.clientWidth; + + this._itemsNode.style.width = `${overflowListWidth}px`; + } + public setItems(items: Record[]) { this.setItemsContainer(items); this.resetState(); @@ -175,6 +185,7 @@ export class OverflowList extends Base { this.initResizeListener(); + this.setItemsContainerWidth(); this.rerender(); } } diff --git a/src/bundle/Resources/views/themes/standard/design_system/components/overflow_list.html.twig b/src/bundle/Resources/views/themes/standard/design_system/components/overflow_list.html.twig index dd746ea6..94f1a94c 100644 --- a/src/bundle/Resources/views/themes/standard/design_system/components/overflow_list.html.twig +++ b/src/bundle/Resources/views/themes/standard/design_system/components/overflow_list.html.twig @@ -1,12 +1,13 @@ {% set overflow_list_classes = html_classes('ids-overflow-list', attributes.render('class') ?? '') %}
- {% for item in items %} - {{ block('item') }} - {% endfor %} - - {{ block('more_item') }} +
+ {% for item in items %} + {{ block('item') }} + {% endfor %} + {{ block('more_item') }} +