Skip to content
Merged
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: '',
};

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 > *'));

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());

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>
131 changes: 131 additions & 0 deletions src/lib/Twig/Components/OverflowList.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?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;
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')]
final class OverflowList
{
/** @var array<int, array<string, mixed>> */
public array $items = [];

/** @var array<int, 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))
);
}
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<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))
);
}

if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $prop)) {
throw new InvalidArgumentException(
sprintf('Invalid itemTemplateProps value "%s".', $prop)
);
}
}

return array_values($value);
}
}