Skip to content

Commit edf58a7

Browse files
IBX-10758: Implemented overflow list component (#54)
Co-authored-by: mikolaj <[email protected]>
1 parent 8ec621c commit edf58a7

File tree

5 files changed

+353
-0
lines changed

5 files changed

+353
-0
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default [
55
{
66
files: ['**/*.ts'],
77
rules: {
8+
'no-magic-numbers': ['error', { ignore: [-1, 0] }],
89
'@typescript-eslint/unbound-method': 'off',
910
},
1011
},
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { Base } from '../partials';
2+
import { escapeHTML } from '@ids-core/helpers/escape';
3+
4+
const RESIZE_TIMEOUT = 200;
5+
6+
export class OverflowList extends Base {
7+
private _itemsNode: HTMLDivElement;
8+
private _moreItemNode: HTMLDivElement;
9+
private _numberOfItems = 0;
10+
private _numberOfVisibleItems = 0;
11+
private _resizeTimeoutId: number | null = null;
12+
private _templates: Record<'item' | 'itemMore', string> = {
13+
item: '',
14+
itemMore: '',
15+
};
16+
17+
private _resizeObserver = new ResizeObserver(() => {
18+
if (this._resizeTimeoutId) {
19+
clearTimeout(this._resizeTimeoutId);
20+
}
21+
22+
this._resizeTimeoutId = window.setTimeout(() => {
23+
this.setItemsContainerWidth();
24+
this.resetState();
25+
this.rerender();
26+
}, RESIZE_TIMEOUT);
27+
});
28+
29+
constructor(container: HTMLDivElement) {
30+
super(container);
31+
32+
const itemsNode = container.querySelector<HTMLDivElement>('.ids-overflow-list__items');
33+
const moreItemNode = itemsNode?.querySelector<HTMLDivElement>(':scope *:last-child');
34+
35+
if (!itemsNode || !moreItemNode) {
36+
throw new Error('OverflowList: OverflowList elements are missing in the container.');
37+
}
38+
39+
this._itemsNode = itemsNode;
40+
this._moreItemNode = moreItemNode;
41+
this._templates = {
42+
item: this.getTemplate('item'),
43+
itemMore: this.getTemplate('item_more'),
44+
};
45+
this._numberOfItems = this.getItems(false, false).length;
46+
this._numberOfVisibleItems = this._numberOfItems;
47+
}
48+
49+
private getItems(getOnlyVisible = false, withOverflow = true): HTMLDivElement[] {
50+
const items = getOnlyVisible
51+
? Array.from(this._itemsNode.querySelectorAll<HTMLDivElement>(':scope > *:not([hidden])'))
52+
: Array.from(this._itemsNode.querySelectorAll<HTMLDivElement>(':scope > *'));
53+
54+
if (withOverflow) {
55+
return items;
56+
}
57+
58+
return items.slice(0, -1);
59+
}
60+
61+
private getTemplate(type: 'item' | 'item_more'): string {
62+
const templateNode = this._container.querySelector<HTMLTemplateElement>(`.ids-overflow-list__template[data-id="${type}"]`);
63+
64+
if (!templateNode) {
65+
throw new Error(`OverflowList: Template of type "${type}" is missing in the container.`);
66+
}
67+
68+
return templateNode.innerHTML.trim();
69+
}
70+
71+
private updateMoreItem() {
72+
const hiddenCount = this._numberOfItems - this._numberOfVisibleItems;
73+
74+
if (hiddenCount > 0) {
75+
const tempMoreItem = document.createElement('div');
76+
77+
tempMoreItem.innerHTML = this._templates.itemMore.replace('{{ hidden_count }}', hiddenCount.toString());
78+
79+
if (!tempMoreItem.firstElementChild) {
80+
throw new Error('OverflowList: Error while creating more item element from template.');
81+
}
82+
83+
this._moreItemNode.replaceWith(tempMoreItem.firstElementChild);
84+
} else {
85+
this._moreItemNode.setAttribute('hidden', 'true');
86+
}
87+
}
88+
89+
private hideOverflowItems() {
90+
const itemsNodes = this.getItems(true, false);
91+
92+
itemsNodes.slice(this._numberOfVisibleItems).forEach((itemNode) => {
93+
itemNode.setAttribute('hidden', 'true');
94+
});
95+
}
96+
97+
private recalculateVisibleItems() {
98+
const itemsNodes = this.getItems(true);
99+
const { right: listRightPosition } = this._itemsNode.getBoundingClientRect();
100+
const newNumberOfVisibleItems = itemsNodes.findIndex((itemNode) => {
101+
const { right: itemRightPosition } = itemNode.getBoundingClientRect();
102+
103+
return itemRightPosition > listRightPosition;
104+
});
105+
106+
if (newNumberOfVisibleItems === -1 || newNumberOfVisibleItems === this._numberOfItems) {
107+
return true;
108+
}
109+
110+
if (newNumberOfVisibleItems === this._numberOfVisibleItems) {
111+
this._numberOfVisibleItems = newNumberOfVisibleItems - 1; // eslint-disable-line no-magic-numbers
112+
} else {
113+
this._numberOfVisibleItems = newNumberOfVisibleItems;
114+
}
115+
116+
return false;
117+
}
118+
119+
private initResizeListener() {
120+
this._resizeObserver.observe(this._container);
121+
}
122+
123+
public resetState() {
124+
this._numberOfVisibleItems = this._numberOfItems;
125+
126+
const itemsNodes = this.getItems(false);
127+
128+
itemsNodes.forEach((itemNode) => {
129+
itemNode.removeAttribute('hidden');
130+
});
131+
}
132+
133+
public rerender() {
134+
let stopRecalculating = true;
135+
136+
do {
137+
stopRecalculating = this.recalculateVisibleItems();
138+
139+
this.hideOverflowItems();
140+
this.updateMoreItem();
141+
} while (!stopRecalculating);
142+
}
143+
144+
private setItemsContainer(items: Record<string, string>[]) {
145+
const fragment = document.createDocumentFragment();
146+
147+
items.forEach((item) => {
148+
const filledItem = Object.entries(item).reduce((acc, [key, value]) => {
149+
const pattern = `{{ ${key} }}`;
150+
const escapedValue = escapeHTML(value);
151+
152+
return acc.replaceAll(pattern, escapedValue);
153+
}, this._templates.item);
154+
const container = document.createElement('div');
155+
156+
container.innerHTML = filledItem;
157+
158+
if (container.firstElementChild) {
159+
fragment.append(container.firstElementChild);
160+
}
161+
});
162+
163+
// Needs to use type assertion here as cloneNode returns a Node type https://github.com/microsoft/TypeScript/issues/283
164+
this._moreItemNode = this._moreItemNode.cloneNode(true) as HTMLDivElement; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
165+
166+
fragment.append(this._moreItemNode);
167+
168+
this._itemsNode.innerHTML = '';
169+
this._itemsNode.appendChild(fragment);
170+
this._numberOfItems = items.length;
171+
}
172+
173+
private setItemsContainerWidth() {
174+
const overflowListWidth = this._container.clientWidth;
175+
176+
this._itemsNode.style.width = `${overflowListWidth}px`;
177+
}
178+
179+
public setItems(items: Record<string, string>[]) {
180+
this.setItemsContainer(items);
181+
this.resetState();
182+
this.rerender();
183+
}
184+
185+
public init() {
186+
super.init();
187+
188+
this.initResizeListener();
189+
190+
this.setItemsContainerWidth();
191+
this.rerender();
192+
}
193+
}

src/bundle/Resources/public/ts/init_components.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { InputTextField, InputTextInput } from './components/input_text';
33
import { Accordion } from './components/accordion';
44
import { AltRadioInput } from './components/alt_radio/alt_radio_input';
55
import { DropdownSingleInput } from './components/dropdown/dropdown_single_input';
6+
import { OverflowList } from './components/overflow_list';
67

78
const accordionContainers = document.querySelectorAll<HTMLDivElement>('.ids-accordion:not([data-ids-custom-init])');
89

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

6061
inputTextInstance.init();
6162
});
63+
64+
const overflowListContainers = document.querySelectorAll<HTMLDivElement>('.ids-overflow-list:not([data-ids-custom-init])');
65+
66+
overflowListContainers.forEach((overflowListContainer: HTMLDivElement) => {
67+
const overflowListInstance = new OverflowList(overflowListContainer);
68+
69+
overflowListInstance.init();
70+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{% set overflow_list_classes = html_classes('ids-overflow-list', attributes.render('class') ?? '') %}
2+
3+
<div class="{{ overflow_list_classes }}">
4+
<div class="ids-overflow-list__items">
5+
{% for item in items %}
6+
{{ block('item') }}
7+
{% endfor %}
8+
9+
{{ block('more_item') }}
10+
</div>
11+
<template class="ids-overflow-list__template" data-id="item">
12+
{% with { item: item_template_props } %}
13+
{{ block('item') }}
14+
{% endwith %}
15+
</template>
16+
<template class="ids-overflow-list__template" data-id="item_more">
17+
{{ block('more_item') }}
18+
</template>
19+
</div>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\DesignSystemTwig\Twig\Components;
10+
11+
use InvalidArgumentException;
12+
use Symfony\Component\OptionsResolver\Options;
13+
use Symfony\Component\OptionsResolver\OptionsResolver;
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
16+
use Symfony\UX\TwigComponent\Attribute\PreMount;
17+
18+
#[AsTwigComponent('ibexa:overflow_list')]
19+
final class OverflowList
20+
{
21+
/** @var array<int, array<string, mixed>> */
22+
public array $items = [];
23+
24+
/** @var array<int, string> */
25+
public array $itemTemplateProps = [];
26+
27+
/**
28+
* @param array<string, mixed> $props
29+
*
30+
* @return array<string, mixed>
31+
*/
32+
#[PreMount]
33+
public function validate(array $props): array
34+
{
35+
$resolver = new OptionsResolver();
36+
$resolver->setIgnoreUndefined();
37+
$resolver
38+
->define('items')
39+
->allowedTypes('array')
40+
->default([])
41+
->normalize(self::normalizeItems(...));
42+
$resolver
43+
->define('itemTemplateProps')
44+
->allowedTypes('array')
45+
->default([])
46+
->normalize(self::normalizeItemTemplateProps(...));
47+
48+
return $resolver->resolve($props) + $props;
49+
}
50+
51+
/**
52+
* @return array<string, string>
53+
*/
54+
#[ExposeInTemplate('item_template_props')]
55+
public function getItemTemplateProps(): array
56+
{
57+
if (empty($this->itemTemplateProps)) {
58+
return [];
59+
}
60+
61+
$props = [];
62+
foreach ($this->itemTemplateProps as $name) {
63+
$props[$name] = '{{ ' . $name . ' }}';
64+
}
65+
66+
return $props;
67+
}
68+
69+
/**
70+
* @param Options<array<string, mixed>> $options
71+
* @param array<int, mixed> $value
72+
*
73+
* @return list<array<string, mixed>>
74+
*/
75+
private static function normalizeItems(Options $options, array $value): array
76+
{
77+
if (!array_is_list($value)) {
78+
throw new InvalidArgumentException(
79+
'Property "items" must be a list (sequential array).'
80+
);
81+
}
82+
83+
foreach ($value as $i => $item) {
84+
if (!is_array($item)) {
85+
throw new InvalidArgumentException(
86+
sprintf('items[%d] must be an array, %s given.', $i, get_debug_type($item))
87+
);
88+
}
89+
foreach (array_keys($item) as $key) {
90+
if (!is_string($key)) {
91+
throw new InvalidArgumentException(
92+
sprintf('items[%d] must use string keys.', $i)
93+
);
94+
}
95+
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
96+
throw new InvalidArgumentException(
97+
sprintf('Invalid key "%s" in items[%d].', $key, $i)
98+
);
99+
}
100+
}
101+
}
102+
103+
return $value;
104+
}
105+
106+
/**
107+
* @param Options<array<string, mixed>> $options
108+
* @param array<int|string, mixed> $value
109+
*
110+
* @return array<int, string>
111+
*/
112+
private static function normalizeItemTemplateProps(Options $options, array $value): array
113+
{
114+
foreach ($value as $key => $prop) {
115+
if (!is_string($prop)) {
116+
$index = is_int($key) ? (string) $key : sprintf('"%s"', $key);
117+
throw new InvalidArgumentException(
118+
sprintf('itemTemplateProps[%s] must be a string, %s given.', $index, get_debug_type($prop))
119+
);
120+
}
121+
122+
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $prop)) {
123+
throw new InvalidArgumentException(
124+
sprintf('Invalid itemTemplateProps value "%s".', $prop)
125+
);
126+
}
127+
}
128+
129+
return array_values($value);
130+
}
131+
}

0 commit comments

Comments
 (0)