Skip to content

Commit 09b0932

Browse files
committed
IBX-10758: Overflow list
1 parent 8c029dc commit 09b0932

File tree

5 files changed

+269
-1
lines changed

5 files changed

+269
-1
lines changed

eslint.config.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
import getIbexaConfig from '@ibexa/eslint-config/eslint';
22

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

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { InputTextField, InputTextInput } from './components/input_text';
22
import { Accordion } from './components/accordion';
33
import { AltRadioInput } from './components/alt_radio/alt_radio_input';
44
import { CheckboxInput } from './components/checkbox';
5+
import { OverflowList } from './components/oveflow_list';
56

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

@@ -42,3 +43,11 @@ inputTextContainers.forEach((inputTextContainer: HTMLDivElement) => {
4243

4344
inputTextInstance.init();
4445
});
46+
47+
const overflowListContainers = document.querySelectorAll<HTMLDivElement>('.ids-overflow-list:not([data-ids-custom-init])');
48+
49+
overflowListContainers.forEach((overflowListContainer: HTMLDivElement) => {
50+
const overflowListInstance = new OverflowList(overflowListContainer);
51+
52+
overflowListInstance.init();
53+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{% set overflow_list_classes = html_classes('ids-overflow-list', attributes.render('class') ?? '') %}
2+
3+
<div class="{{ overflow_list_classes }}">
4+
{% for item in items %}
5+
{{ block('item') }}
6+
{% endfor %}
7+
8+
{{ block('more_item') }}
9+
10+
<template class="ids-overflow-list__template" data-id="item">
11+
{% with { item: item_template_props } %}
12+
{{ block('item') }}
13+
{% endwith %}
14+
</template>
15+
<template class="ids-overflow-list__template" data-id="item_more">
16+
{{ block('more_item') }}
17+
</template>
18+
</div>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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 Symfony\Component\OptionsResolver\OptionsResolver;
12+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
13+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
14+
use Symfony\UX\TwigComponent\Attribute\PreMount;
15+
16+
#[AsTwigComponent('ibexa:overflow_list')]
17+
final class OverflowList
18+
{
19+
/**
20+
* @var list<array>
21+
*/
22+
public array $items = [];
23+
24+
/**
25+
* @param array<string, mixed> $props
26+
*
27+
* @return array<string, mixed>
28+
*/
29+
#[PreMount]
30+
public function validate(array $props): array
31+
{
32+
$resolver = new OptionsResolver();
33+
$resolver->setIgnoreUndefined();
34+
$resolver
35+
->define('items')
36+
->allowedTypes('array')
37+
->default([]);
38+
39+
return $resolver->resolve($props) + $props;
40+
}
41+
42+
#[ExposeInTemplate('item_template_props')]
43+
public function getItemTemplateProps(): array
44+
{
45+
$item_props_names = array_keys($this->items[0]);
46+
$item_props_patterns = array_map(
47+
fn (string $name): string => '{{ ' . $name . ' }}',
48+
$item_props_names
49+
);
50+
51+
return array_combine($item_props_names, $item_props_patterns) ?? [];
52+
}
53+
}

0 commit comments

Comments
 (0)