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..486ceb14 --- /dev/null +++ b/src/bundle/Resources/public/ts/components/overflow_list.ts @@ -0,0 +1,193 @@ +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; + private _resizeTimeoutId: number | null = null; + private _templates: Record<'item' | 'itemMore', string> = { + item: '', + itemMore: '', + }; + + private _resizeObserver = new ResizeObserver(() => { + if (this._resizeTimeoutId) { + clearTimeout(this._resizeTimeoutId); + } + + this._resizeTimeoutId = window.setTimeout(() => { + this.setItemsContainerWidth(); + this.resetState(); + this.rerender(); + }, RESIZE_TIMEOUT); + }); + + constructor(container: HTMLDivElement) { + super(container); + + const itemsNode = container.querySelector('.ids-overflow-list__items'); + const moreItemNode = itemsNode?.querySelector(':scope *:last-child'); + + 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; + } + + private getItems(getOnlyVisible = false, withOverflow = true): HTMLDivElement[] { + const items = getOnlyVisible + ? Array.from(this._itemsNode.querySelectorAll(':scope > *:not([hidden])')) + : Array.from(this._itemsNode.querySelectorAll(':scope > *')); + + 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 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._itemsNode.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._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(); + this.rerender(); + } + + public init() { + super.init(); + + this.initResizeListener(); + + this.setItemsContainerWidth(); + 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..94f1a94c --- /dev/null +++ b/src/bundle/Resources/views/themes/standard/design_system/components/overflow_list.html.twig @@ -0,0 +1,19 @@ +{% 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..8a7e0cc0 --- /dev/null +++ b/src/lib/Twig/Components/OverflowList.php @@ -0,0 +1,131 @@ +> */ + public array $items = []; + + /** @var array */ + public array $itemTemplateProps = []; + + /** + * @param array $props + * + * @return array + */ + #[PreMount] + public function validate(array $props): array + { + $resolver = new OptionsResolver(); + $resolver->setIgnoreUndefined(); + $resolver + ->define('items') + ->allowedTypes('array') + ->default([]) + ->normalize(self::normalizeItems(...)); + $resolver + ->define('itemTemplateProps') + ->allowedTypes('array') + ->default([]) + ->normalize(self::normalizeItemTemplateProps(...)); + + return $resolver->resolve($props) + $props; + } + + /** + * @return array + */ + #[ExposeInTemplate('item_template_props')] + public function getItemTemplateProps(): array + { + if (empty($this->itemTemplateProps)) { + return []; + } + + $props = []; + foreach ($this->itemTemplateProps as $name) { + $props[$name] = '{{ ' . $name . ' }}'; + } + + return $props; + } + + /** + * @param Options> $options + * @param array $value + * + * @return list> + */ + private static function normalizeItems(Options $options, array $value): array + { + if (!array_is_list($value)) { + throw new InvalidArgumentException( + 'Property "items" must be a list (sequential array).' + ); + } + + foreach ($value as $i => $item) { + if (!is_array($item)) { + throw new InvalidArgumentException( + sprintf('items[%d] must be an array, %s given.', $i, get_debug_type($item)) + ); + } + foreach (array_keys($item) as $key) { + if (!is_string($key)) { + throw new InvalidArgumentException( + sprintf('items[%d] must use string keys.', $i) + ); + } + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { + throw new InvalidArgumentException( + sprintf('Invalid key "%s" in items[%d].', $key, $i) + ); + } + } + } + + return $value; + } + + /** + * @param Options> $options + * @param array $value + * + * @return array + */ + private static function normalizeItemTemplateProps(Options $options, array $value): array + { + foreach ($value as $key => $prop) { + if (!is_string($prop)) { + $index = is_int($key) ? (string) $key : sprintf('"%s"', $key); + throw new InvalidArgumentException( + sprintf('itemTemplateProps[%s] must be a string, %s given.', $index, get_debug_type($prop)) + ); + } + + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $prop)) { + throw new InvalidArgumentException( + sprintf('Invalid itemTemplateProps value "%s".', $prop) + ); + } + } + + return array_values($value); + } +}