From 03d748f9494394619ca00e4c464a99a6678fefe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Wed, 29 Oct 2025 19:28:20 +0100 Subject: [PATCH] Implement mj-accordion components Add accordion component family for interactive collapsible content: - mj-accordion: parent wrapper with icon configuration - mj-accordion-element: individual accordion items - mj-accordion-title: clickable headers - mj-accordion-text: collapsible content sections --- src/Elements/BodyComponents/MjAccordion.php | 178 ++++++++++++++ .../BodyComponents/MjAccordionElement.php | 158 +++++++++++++ .../BodyComponents/MjAccordionText.php | 163 +++++++++++++ .../BodyComponents/MjAccordionTitle.php | 164 +++++++++++++ .../BodyComponents/MjAccordionTest.php | 223 ++++++++++++++++++ 5 files changed, 886 insertions(+) create mode 100644 src/Elements/BodyComponents/MjAccordion.php create mode 100644 src/Elements/BodyComponents/MjAccordionElement.php create mode 100644 src/Elements/BodyComponents/MjAccordionText.php create mode 100644 src/Elements/BodyComponents/MjAccordionTitle.php create mode 100644 tests/Unit/Elements/BodyComponents/MjAccordionTest.php diff --git a/src/Elements/BodyComponents/MjAccordion.php b/src/Elements/BodyComponents/MjAccordion.php new file mode 100644 index 0000000..5028cde --- /dev/null +++ b/src/Elements/BodyComponents/MjAccordion.php @@ -0,0 +1,178 @@ +> + */ + protected array $allowedAttributes = [ + 'border' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'border', + 'default_value' => '2px solid black', + ], + 'container-background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'background color of the cell', + 'default_value' => '', + ], + 'css-class' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'class name added to root HTML element', + 'default_value' => '', + ], + 'font-family' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'font family', + 'default_value' => 'Ubuntu, Helvetica, Arial, sans-serif', + ], + 'icon-align' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'icon alignment (top/middle/bottom)', + 'default_value' => 'middle', + ], + 'icon-height' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'icon height', + 'default_value' => '32px', + ], + 'icon-position' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'icon position (left/right)', + 'default_value' => 'right', + ], + 'icon-unwrapped-url' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'icon when accordion is unwrapped', + 'default_value' => 'https://i.imgur.com/bIXv1bk.png', + ], + 'icon-wrapped-url' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'icon when accordion is wrapped', + 'default_value' => 'https://i.imgur.com/w4uTygT.png', + ], + 'icon-width' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'icon width', + 'default_value' => '32px', + ], + 'padding' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'supports up to 4 parameters', + 'default_value' => '10px 25px', + ], + 'padding-bottom' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'bottom offset', + 'default_value' => '', + ], + 'padding-left' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'left offset', + 'default_value' => '', + ], + 'padding-right' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'right offset', + 'default_value' => '', + ], + 'padding-top' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'top offset', + 'default_value' => '', + ], + ]; + + protected array $defaultAttributes = [ + 'border' => '2px solid black', + 'font-family' => 'Ubuntu, Helvetica, Arial, sans-serif', + 'icon-align' => 'middle', + 'icon-height' => '32px', + 'icon-position' => 'right', + 'icon-unwrapped-url' => 'https://i.imgur.com/bIXv1bk.png', + 'icon-wrapped-url' => 'https://i.imgur.com/w4uTygT.png', + 'icon-width' => '32px', + 'padding' => '10px 25px', + ]; + + public function render(): string + { + $children = $this->getChildren() ?? []; + $content = $this->renderChildren($children, []); + + return $content; + } + + /** + * Gets the context for child elements. + * + * @return array + */ + public function getChildContext(): array + { + return [ + ...$this->context, + 'border' => $this->getAttribute('border'), + 'font-family' => $this->getAttribute('font-family'), + 'icon-align' => $this->getAttribute('icon-align'), + 'icon-height' => $this->getAttribute('icon-height'), + 'icon-position' => $this->getAttribute('icon-position'), + 'icon-unwrapped-url' => $this->getAttribute('icon-unwrapped-url'), + 'icon-wrapped-url' => $this->getAttribute('icon-wrapped-url'), + 'icon-width' => $this->getAttribute('icon-width'), + ]; + } + + /** + * @return array> + */ + public function getStyles(): array + { + return []; + } +} diff --git a/src/Elements/BodyComponents/MjAccordionElement.php b/src/Elements/BodyComponents/MjAccordionElement.php new file mode 100644 index 0000000..47b28c0 --- /dev/null +++ b/src/Elements/BodyComponents/MjAccordionElement.php @@ -0,0 +1,158 @@ +> + */ + protected array $allowedAttributes = [ + 'background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'background color', + 'default_value' => '', + ], + 'border' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'border', + 'default_value' => '', + ], + 'css-class' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'class name added to root HTML element', + 'default_value' => '', + ], + 'font-family' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'font family', + 'default_value' => '', + ], + 'icon-align' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'icon alignment (top/middle/bottom)', + 'default_value' => '', + ], + 'icon-height' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'icon height', + 'default_value' => '', + ], + 'icon-position' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'icon position (left/right)', + 'default_value' => '', + ], + 'icon-unwrapped-url' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'icon when accordion is unwrapped', + 'default_value' => '', + ], + 'icon-wrapped-url' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'icon when accordion is wrapped', + 'default_value' => '', + ], + 'icon-width' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'icon width', + 'default_value' => '', + ], + ]; + + protected array $defaultAttributes = []; + + public function render(): string + { + $divAttributes = $this->getHtmlAttributes([ + 'class' => $this->getAttribute('css-class'), + 'style' => 'div', + ]); + + $children = $this->getChildren() ?? []; + $content = $this->renderChildren($children, []); + + return "
$content
"; + } + + /** + * Gets the context for child elements. + * + * @return array + */ + public function getChildContext(): array + { + $fontFamily = $this->getAttribute('font-family') ?: + $this->context['font-family'] ?? 'Ubuntu, Helvetica, Arial, sans-serif'; + + return [ + ...$this->context, + 'background-color' => $this->getAttribute('background-color') ?: + $this->context['background-color'] ?? '', + 'border' => $this->getAttribute('border') ?: $this->context['border'] ?? '', + 'font-family' => $fontFamily, + 'icon-align' => $this->getAttribute('icon-align') ?: + $this->context['icon-align'] ?? 'middle', + 'icon-height' => $this->getAttribute('icon-height') ?: + $this->context['icon-height'] ?? '32px', + 'icon-position' => $this->getAttribute('icon-position') ?: + $this->context['icon-position'] ?? 'right', + 'icon-unwrapped-url' => $this->getAttribute('icon-unwrapped-url') ?: + $this->context['icon-unwrapped-url'] ?? '', + 'icon-wrapped-url' => $this->getAttribute('icon-wrapped-url') ?: + $this->context['icon-wrapped-url'] ?? '', + 'icon-width' => $this->getAttribute('icon-width') ?: + $this->context['icon-width'] ?? '32px', + ]; + } + + /** + * @return array> + */ + public function getStyles(): array + { + return [ + 'div' => [ + 'background-color' => $this->getAttribute('background-color'), + 'border' => $this->getAttribute('border'), + ], + ]; + } +} diff --git a/src/Elements/BodyComponents/MjAccordionText.php b/src/Elements/BodyComponents/MjAccordionText.php new file mode 100644 index 0000000..1f1de03 --- /dev/null +++ b/src/Elements/BodyComponents/MjAccordionText.php @@ -0,0 +1,163 @@ +> + */ + protected array $allowedAttributes = [ + 'background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'background color', + 'default_value' => '', + ], + 'color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'text color', + 'default_value' => '#000000', + ], + 'css-class' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'class name added to root HTML element', + 'default_value' => '', + ], + 'font-family' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'font family', + 'default_value' => '', + ], + 'font-size' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'font size', + 'default_value' => '13px', + ], + 'font-weight' => [ + 'unit' => 'number', + 'type' => 'number', + 'description' => 'font weight', + 'default_value' => '', + ], + 'letter-spacing' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'letter spacing', + 'default_value' => 'none', + ], + 'line-height' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'line height', + 'default_value' => '1', + ], + 'padding' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'supports up to 4 parameters', + 'default_value' => '16px', + ], + 'padding-bottom' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'bottom offset', + 'default_value' => '', + ], + 'padding-left' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'left offset', + 'default_value' => '', + ], + 'padding-right' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'right offset', + 'default_value' => '', + ], + 'padding-top' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'top offset', + 'default_value' => '', + ], + ]; + + protected array $defaultAttributes = [ + 'color' => '#000000', + 'font-size' => '13px', + 'letter-spacing' => 'none', + 'line-height' => '1', + 'padding' => '16px', + ]; + + public function render(): string + { + $tdAttributes = $this->getHtmlAttributes([ + 'class' => $this->getAttribute('css-class'), + 'style' => 'td', + ]); + + $content = $this->getContent(); + + return "$content"; + } + + /** + * @return array> + */ + public function getStyles(): array + { + $fontFamily = $this->getAttribute('font-family') ?: + $this->context['font-family'] ?? 'Ubuntu, Helvetica, Arial, sans-serif'; + + return [ + 'td' => [ + 'background-color' => $this->getAttribute('background-color'), + 'color' => $this->getAttribute('color'), + 'font-family' => $fontFamily, + 'font-size' => $this->getAttribute('font-size'), + 'font-weight' => $this->getAttribute('font-weight'), + 'letter-spacing' => $this->getAttribute('letter-spacing'), + 'line-height' => $this->getAttribute('line-height'), + 'padding' => $this->getAttribute('padding'), + 'padding-bottom' => $this->getAttribute('padding-bottom'), + 'padding-left' => $this->getAttribute('padding-left'), + 'padding-right' => $this->getAttribute('padding-right'), + 'padding-top' => $this->getAttribute('padding-top'), + ], + ]; + } +} diff --git a/src/Elements/BodyComponents/MjAccordionTitle.php b/src/Elements/BodyComponents/MjAccordionTitle.php new file mode 100644 index 0000000..ac13751 --- /dev/null +++ b/src/Elements/BodyComponents/MjAccordionTitle.php @@ -0,0 +1,164 @@ +> + */ + protected array $allowedAttributes = [ + 'background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'background color', + 'default_value' => '', + ], + 'color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'text color', + 'default_value' => '#000000', + ], + 'css-class' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'class name added to root HTML element', + 'default_value' => '', + ], + 'font-family' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'font family', + 'default_value' => '', + ], + 'font-size' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'font size', + 'default_value' => '13px', + ], + 'padding' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'supports up to 4 parameters', + 'default_value' => '16px', + ], + 'padding-bottom' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'bottom offset', + 'default_value' => '', + ], + 'padding-left' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'left offset', + 'default_value' => '', + ], + 'padding-right' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'right offset', + 'default_value' => '', + ], + 'padding-top' => [ + 'unit' => 'px', + 'type' => 'measure', + 'description' => 'top offset', + 'default_value' => '', + ], + ]; + + protected array $defaultAttributes = [ + 'color' => '#000000', + 'font-size' => '13px', + 'padding' => '16px', + ]; + + public function render(): string + { + $tdAttributes = $this->getHtmlAttributes([ + 'class' => $this->getAttribute('css-class'), + 'style' => 'td', + ]); + + $content = $this->getContent(); + + $iconPosition = $this->context['icon-position'] ?? 'right'; + $iconHtml = $this->renderIcon(); + + if ($iconPosition === 'left') { + $cellContent = $iconHtml . $content; + } else { + $cellContent = $content . $iconHtml; + } + + return "$cellContent"; + } + + private function renderIcon(): string + { + $iconUrl = $this->context['icon-wrapped-url'] ?? ''; + $iconWidth = $this->context['icon-width'] ?? '32px'; + $iconHeight = $this->context['icon-height'] ?? '32px'; + $iconAlign = $this->context['icon-align'] ?? 'middle'; + + if (!$iconUrl) { + return ''; + } + + $style = "vertical-align: $iconAlign;"; + return "+"; + } + + /** + * @return array> + */ + public function getStyles(): array + { + $fontFamily = $this->getAttribute('font-family') ?: + $this->context['font-family'] ?? 'Ubuntu, Helvetica, Arial, sans-serif'; + + return [ + 'td' => [ + 'background-color' => $this->getAttribute('background-color'), + 'color' => $this->getAttribute('color'), + 'font-family' => $fontFamily, + 'font-size' => $this->getAttribute('font-size'), + 'padding' => $this->getAttribute('padding'), + 'padding-bottom' => $this->getAttribute('padding-bottom'), + 'padding-left' => $this->getAttribute('padding-left'), + 'padding-right' => $this->getAttribute('padding-right'), + 'padding-top' => $this->getAttribute('padding-top'), + ], + ]; + } +} diff --git a/tests/Unit/Elements/BodyComponents/MjAccordionTest.php b/tests/Unit/Elements/BodyComponents/MjAccordionTest.php new file mode 100644 index 0000000..6f0d35e --- /dev/null +++ b/tests/Unit/Elements/BodyComponents/MjAccordionTest.php @@ -0,0 +1,223 @@ +element = new MjAccordion(); + }); + + it('is not ending tag', function () { + expect($this->element->isEndingTag())->toBe(false); + }); + + it('returns the correct component name', function () { + expect($this->element->getTagName())->toBe('mj-accordion'); + }); + + it('returns the correct default attributes', function () { + $attributes = [ + 'border' => '2px solid black', + 'font-family' => 'Ubuntu, Helvetica, Arial, sans-serif', + 'icon-align' => 'middle', + 'icon-height' => '32px', + 'icon-position' => 'right', + 'icon-width' => '32px', + 'padding' => '10px 25px', + ]; + + foreach ($attributes as $key => $value) { + expect($this->element->getAttribute($key))->toBe($value); + } + }); + + it('will throw out of bounds exception if the allowed attribute is not existing', function () { + $this->element->getAllowedAttributeData('invalid-attribute'); + })->throws(OutOfBoundsException::class); + + it('will correctly set custom border', function () { + $accordionNode = new MjmlNode( + 'mj-accordion', + ['border' => '1px dashed red'], + null, + false, + null + ); + + $factory = new ElementFactory(); + $mjAccordionElement = $factory->create($accordionNode); + + expect($mjAccordionElement->getAttribute('border'))->toBe('1px dashed red'); + }); +}); + +describe('MjAccordionElement', function () { + beforeEach(function () { + $this->element = new MjAccordionElement(); + }); + + it('is not ending tag', function () { + expect($this->element->isEndingTag())->toBe(false); + }); + + it('returns the correct component name', function () { + expect($this->element->getTagName())->toBe('mj-accordion-element'); + }); + + it('will correctly render an accordion element', function () { + $mjElement = new MjAccordionElement(); + + expect($mjElement)->toBeInstanceOf(MjAccordionElement::class); + + $out = $mjElement->render(); + expect($out)->toContain('not->toBeEmpty(); + }); + + it('will correctly set background color', function () { + $mjElement = new MjAccordionElement(['background-color' => '#f0f0f0']); + + expect($mjElement->getAttribute('background-color'))->toBe('#f0f0f0'); + + $styles = $mjElement->getStyles(); + expect($styles['div']['background-color'])->toBe('#f0f0f0'); + }); +}); + +describe('MjAccordionTitle', function () { + beforeEach(function () { + $this->element = new MjAccordionTitle(); + }); + + it('is ending tag', function () { + expect($this->element->isEndingTag())->toBe(true); + }); + + it('returns the correct component name', function () { + expect($this->element->getTagName())->toBe('mj-accordion-title'); + }); + + it('returns the correct default attributes', function () { + $attributes = [ + 'color' => '#000000', + 'font-size' => '13px', + 'padding' => '16px', + ]; + + foreach ($attributes as $key => $value) { + expect($this->element->getAttribute($key))->toBe($value); + } + }); + + it('will correctly render a title', function () { + $titleNode = new MjmlNode( + 'mj-accordion-title', + null, + 'Accordion Title', + true, + null + ); + + $factory = new ElementFactory(); + $mjTitleElement = $factory->create($titleNode); + + expect($mjTitleElement)->toBeInstanceOf(MjAccordionTitle::class); + + $out = $mjTitleElement->render(); + expect($out)->toContain('toContain('Accordion Title'); + expect($out)->not->toBeEmpty(); + }); + + it('will correctly set custom color', function () { + $titleNode = new MjmlNode( + 'mj-accordion-title', + ['color' => '#ff0000'], + 'Red Title', + true, + null + ); + + $factory = new ElementFactory(); + $mjTitleElement = $factory->create($titleNode); + + expect($mjTitleElement->getAttribute('color'))->toBe('#ff0000'); + }); +}); + +describe('MjAccordionText', function () { + beforeEach(function () { + $this->element = new MjAccordionText(); + }); + + it('is ending tag', function () { + expect($this->element->isEndingTag())->toBe(true); + }); + + it('returns the correct component name', function () { + expect($this->element->getTagName())->toBe('mj-accordion-text'); + }); + + it('returns the correct default attributes', function () { + $attributes = [ + 'color' => '#000000', + 'font-size' => '13px', + 'letter-spacing' => 'none', + 'line-height' => '1', + 'padding' => '16px', + ]; + + foreach ($attributes as $key => $value) { + expect($this->element->getAttribute($key))->toBe($value); + } + }); + + it('will correctly render accordion text', function () { + $textNode = new MjmlNode( + 'mj-accordion-text', + null, + 'Accordion content text', + true, + null + ); + + $factory = new ElementFactory(); + $mjTextElement = $factory->create($textNode); + + expect($mjTextElement)->toBeInstanceOf(MjAccordionText::class); + + $out = $mjTextElement->render(); + expect($out)->toContain('toContain('Accordion content text'); + expect($out)->not->toBeEmpty(); + }); + + it('will correctly set custom font properties', function () { + $textNode = new MjmlNode( + 'mj-accordion-text', + [ + 'color' => '#333333', + 'font-size' => '14px', + 'line-height' => '1.5', + ], + 'Styled text', + true, + null + ); + + $factory = new ElementFactory(); + $mjTextElement = $factory->create($textNode); + + expect($mjTextElement->getAttribute('color'))->toBe('#333333'); + expect($mjTextElement->getAttribute('font-size'))->toBe('14px'); + expect($mjTextElement->getAttribute('line-height'))->toBe('1.5'); + }); +});