-
Notifications
You must be signed in to change notification settings - Fork 0
IBX-10758: Overflow list #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
83ef979
95c8202
2bd2c3e
78cba96
a8dc475
dc6fc39
0e9e6ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 > *')); | ||||||||
|
|
||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||||||
| 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()); | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
|
|
||||||||
| 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 | ||||||||
GrabowskiM marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
| } 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; | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
|
|
||||||||
| 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(); | ||||||||
| } | ||||||||
| } | ||||||||
| 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> |
| 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; | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This rather should be Ibexa
Suggested change
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 |
||||||||||||||||||
| 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')] | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? 😅 |
||||||||||||||||||
| final class OverflowList | ||||||||||||||||||
| { | ||||||||||||||||||
| /** @var list<array> */ | ||||||||||||||||||
| public array $items = []; | ||||||||||||||||||
|
|
||||||||||||||||||
| /** @var list<string> */ | ||||||||||||||||||
|
Comment on lines
+21
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| 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 | ||||||||||||||||||
| { | ||||||||||||||||||
GrabowskiM marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
| 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))); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
| foreach (array_keys($item) as $key) { | ||||||||||||||||||
| if (!is_string($key)) { | ||||||||||||||||||
| throw new InvalidArgumentException(sprintf('items[%d] must use string keys.', $i)); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
| if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { | ||||||||||||||||||
| throw new InvalidArgumentException(sprintf('Invalid key "%s" in items[%d].', $key, $i)); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| 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))); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $prop)) { | ||||||||||||||||||
| throw new InvalidArgumentException(sprintf('Invalid itemTemplateProps value "%s".', $prop)); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| return array_values($value); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.