Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default [
{
files: ['**/*.ts'],
rules: {
'no-magic-numbers': ['error', { ignore: [-1, 0] }],
'@typescript-eslint/unbound-method': 'off',
},
},
Expand Down
193 changes: 193 additions & 0 deletions src/bundle/Resources/public/ts/components/overflow_list.ts
Original file line number Diff line number Diff line change
@@ -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: '',
};

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

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<HTMLDivElement>('.ids-overflow-list__items');
const moreItemNode = itemsNode?.querySelector<HTMLDivElement>(':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<HTMLDivElement>(':scope > *:not([hidden])'))
: Array.from(this._itemsNode.querySelectorAll<HTMLDivElement>(':scope > *'));

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: WDYT about changing to this? then no need to create an additional string, and it looks a bit more readable.

let items = [];
if(getOnlyVisible) {
	items = Array.from(this._itemsNode.querySelectorAll(':scope > *:not([hidden])'))
} else {
 	items = Array.from(this._itemsNode.children);
}

if (withOverflow) {
return items;
}

return items.slice(0, -1);
}

private getTemplate(type: 'item' | 'item_more'): string {
const templateNode = this._container.querySelector<HTMLTemplateElement>(`.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());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
tempMoreItem.innerHTML = this._templates.itemMore.replace('{{ hidden_count }}', hiddenCount.toString());
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let stopRecalculating = true;
let stopRecalculating = true;


do {
stopRecalculating = this.recalculateVisibleItems();

this.hideOverflowItems();
this.updateMoreItem();
} while (!stopRecalculating);
}

private setItemsContainer(items: Record<string, string>[]) {
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<string, string>[]) {
this.setItemsContainer(items);
this.resetState();
this.rerender();
}

public init() {
super.init();

this.initResizeListener();

this.setItemsContainerWidth();
this.rerender();
}
}
9 changes: 9 additions & 0 deletions src/bundle/Resources/public/ts/init_components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>('.ids-accordion:not([data-ids-custom-init])');

Expand Down Expand Up @@ -59,3 +60,11 @@ inputTextContainers.forEach((inputTextContainer: HTMLDivElement) => {

inputTextInstance.init();
});

const overflowListContainers = document.querySelectorAll<HTMLDivElement>('.ids-overflow-list:not([data-ids-custom-init])');

overflowListContainers.forEach((overflowListContainer: HTMLDivElement) => {
const overflowListInstance = new OverflowList(overflowListContainer);

overflowListInstance.init();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{% set overflow_list_classes = html_classes('ids-overflow-list', attributes.render('class') ?? '') %}

<div class="{{ overflow_list_classes }}">
<div class="ids-overflow-list__items">
{% for item in items %}
{{ block('item') }}
{% endfor %}

{{ block('more_item') }}
</div>
<template class="ids-overflow-list__template" data-id="item">
{% with { item: item_template_props } %}
{{ block('item') }}
{% endwith %}
</template>
<template class="ids-overflow-list__template" data-id="item_more">
{{ block('more_item') }}
</template>
</div>
119 changes: 119 additions & 0 deletions src/lib/Twig/Components/OverflowList.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\DesignSystemTwig\Twig\Components;

use InvalidArgumentException;
Copy link
Member

@alongosz alongosz Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rather should be Ibexa InvalidArgumentException

Suggested change
use InvalidArgumentException;
use Ibexa\Core\Base\Exceptions\InvalidArgumentException;

and for methods trowing it annotate it with API

/**
  * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException
  */

Unless there are some other Twig/Symfony requirements forcing this to be generic InvalidArgumentException?

use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
use Symfony\UX\TwigComponent\Attribute\PreMount;

#[AsTwigComponent('ibexa:overflow_list')]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having hard time finding similar components in a current DXP instance. Is this new thing or do I need to update something? 😅
If you were to explain this to someone who's new to Twig, what would be usage instruction?

final class OverflowList
{
/** @var list<array> */
public array $items = [];

/** @var list<string> */
Comment on lines +21 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list type is PHPStan-specific. Generic phpDocumentor might not understand it (not relevant since it's not a contract, but as a principle)

Suggested change
/** @var list<array> */
public array $items = [];
/** @var list<string> */
/** @phpstan-var list<array> */
public array $items = [];
/** @phpstan-var list<string> */

public array $itemTemplateProps = [];

/**
* @param array<string, mixed> $props
*
* @return array<string, mixed>
*/
#[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<string, string>
*/
#[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<array<string, mixed>> $options
* @param array<int, mixed> $value
*
* @return list<array<string, mixed>>
*/
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)));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new InvalidArgumentException(sprintf('items[%d] must be an array, %s given.', $i, get_debug_type($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));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new InvalidArgumentException(sprintf('items[%d] must use string keys.', $i));
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));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new InvalidArgumentException(sprintf('Invalid key "%s" in items[%d].', $key, $i));
throw new InvalidArgumentException(
sprintf('Invalid key "%s" in items[%d].', $key, $i)
);

}
}
}

return $value;
}

/**
* @param Options<array<string, mixed>> $options
* @param array<int|string, mixed> $value
*
* @return array<int, string>
*/
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)));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new InvalidArgumentException(sprintf('itemTemplateProps[%s] must be a string, %s given.', $index, get_debug_type($prop)));
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));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new InvalidArgumentException(sprintf('Invalid itemTemplateProps value "%s".', $prop));
throw new InvalidArgumentException(
sprintf('Invalid itemTemplateProps value "%s".', $prop)
);

}
}

return array_values($value);
}
}