From 0ce657f1d5955c859bd720e224b2414bf224047e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=BDoljom?= Date: Wed, 29 Oct 2025 18:05:40 +0100 Subject: [PATCH] Implement mj-hero element Add hero image component with overlay content support and flexible height modes (fluid-height and fixed-height). --- src/Elements/BodyComponents/MjHero.php | 214 ++++++++++++++++++ .../Elements/BodyComponents/MjHeroTest.php | 210 +++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 src/Elements/BodyComponents/MjHero.php create mode 100644 tests/Unit/Elements/BodyComponents/MjHeroTest.php diff --git a/src/Elements/BodyComponents/MjHero.php b/src/Elements/BodyComponents/MjHero.php new file mode 100644 index 0000000..9281630 --- /dev/null +++ b/src/Elements/BodyComponents/MjHero.php @@ -0,0 +1,214 @@ +> + */ + protected array $allowedAttributes = [ + 'background-color' => [ + 'unit' => 'color', + 'type' => 'color', + 'description' => 'hero background color', + 'default_value' => '#ffffff', + ], + 'background-height' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'height of the image used', + 'default_value' => '', + ], + 'background-position' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'background image position', + 'default_value' => 'center center', + ], + 'background-url' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'background image url', + 'default_value' => '', + ], + 'background-width' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'width of the image used', + 'default_value' => '', + ], + 'border-radius' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'border radius', + 'default_value' => '', + ], + 'height' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'hero section height', + 'default_value' => '', + ], + 'mode' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'fluid-height or fixed-height', + 'default_value' => 'fluid-height', + ], + 'padding' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'supports up to 4 parameters', + 'default_value' => '0px', + ], + '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' => '', + ], + 'vertical-align' => [ + 'unit' => 'string', + 'type' => 'string', + 'description' => 'content vertical alignment (top/middle/bottom)', + 'default_value' => 'top', + ], + 'width' => [ + 'unit' => 'px', + 'type' => 'string', + 'description' => 'hero container width', + 'default_value' => '', + ], + ]; + + protected array $defaultAttributes = [ + 'background-color' => '#ffffff', + 'background-position' => 'center center', + 'mode' => 'fluid-height', + 'padding' => '0px', + 'vertical-align' => 'top', + ]; + + public function render(): string + { + $divAttributes = $this->getHtmlAttributes([ + 'style' => 'div', + ]); + + $tableAttributes = $this->getHtmlAttributes([ + 'background' => $this->getAttribute('background-url'), + 'border' => '0', + 'cellpadding' => '0', + 'cellspacing' => '0', + 'role' => 'presentation', + 'style' => 'table', + ]); + + $trAttributes = $this->getHtmlAttributes([ + 'style' => 'tr', + ]); + + $tdAttributes = $this->getHtmlAttributes([ + 'style' => 'td', + ]); + + $children = $this->getChildren() ?? []; + $content = $this->renderChildren($children, []); + + return "
+ + + + + + +
+ $content +
+
"; + } + + /** + * @return array> + */ + public function getStyles(): array + { + $isFixedHeight = $this->getAttribute('mode') === 'fixed-height'; + $height = $this->getAttribute('height'); + + return [ + 'div' => [ + 'margin' => '0 auto', + 'max-width' => $this->getAttribute('width'), + 'border-radius' => $this->getAttribute('border-radius'), + ], + 'table' => [ + 'width' => '100%', + 'background-color' => $this->getAttribute('background-color'), + 'background-position' => $this->getAttribute('background-position'), + 'background-repeat' => 'no-repeat', + 'background-size' => 'cover', + 'border-radius' => $this->getAttribute('border-radius'), + ], + 'tr' => [ + 'vertical-align' => $this->getAttribute('vertical-align'), + ], + 'td' => array_merge( + [ + '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'), + ], + $isFixedHeight && $height ? ['height' => $height] : [] + ), + ]; + } +} diff --git a/tests/Unit/Elements/BodyComponents/MjHeroTest.php b/tests/Unit/Elements/BodyComponents/MjHeroTest.php new file mode 100644 index 0000000..a2fe01a --- /dev/null +++ b/tests/Unit/Elements/BodyComponents/MjHeroTest.php @@ -0,0 +1,210 @@ +element = new MjHero(); +}); + +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-hero'); +}); + +it('returns the correct default attributes', function () { + $attributes = [ + 'background-color' => '#ffffff', + 'background-position' => 'center center', + 'mode' => 'fluid-height', + 'padding' => '0px', + 'vertical-align' => 'top', + ]; + + 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 return allowed attribute data', function () { + $data = $this->element->getAllowedAttributeData('background-color'); + expect($data)->toBeArray(); + expect($data)->toHaveKey('type'); + expect($data)->toHaveKey('unit'); +}); + +it('will correctly render a simple hero', function () { + $heroNode = new MjmlNode( + 'mj-hero', + null, + null, + false, + null + ); + + $factory = new ElementFactory(); + $mjHeroElement = $factory->create($heroNode); + + expect($mjHeroElement)->toBeInstanceOf(MjHero::class); + + $out = $mjHeroElement->render(); + + expect($out)->toContain('toContain('toContain('role="presentation"'); + expect($out)->not->toBeEmpty(); +}); + +it('will correctly set background color', function () { + $heroNode = new MjmlNode( + 'mj-hero', + ['background-color' => '#ff0000'], + null, + false, + null + ); + + $factory = new ElementFactory(); + $mjHeroElement = $factory->create($heroNode); + + expect($mjHeroElement->getAttribute('background-color'))->toBe('#ff0000'); + + $styles = $mjHeroElement->getStyles(); + expect($styles['table']['background-color'])->toBe('#ff0000'); +}); + +it('will correctly set background URL', function () { + $heroNode = new MjmlNode( + 'mj-hero', + ['background-url' => 'https://example.com/hero.jpg'], + null, + false, + null + ); + + $factory = new ElementFactory(); + $mjHeroElement = $factory->create($heroNode); + + expect($mjHeroElement->getAttribute('background-url'))->toBe('https://example.com/hero.jpg'); + + $out = $mjHeroElement->render(); + expect($out)->toContain('https://example.com/hero.jpg'); +}); + +it('will correctly set height in fixed-height mode', function () { + $heroNode = new MjmlNode( + 'mj-hero', + [ + 'mode' => 'fixed-height', + 'height' => '500px', + ], + null, + false, + null + ); + + $factory = new ElementFactory(); + $mjHeroElement = $factory->create($heroNode); + + expect($mjHeroElement->getAttribute('mode'))->toBe('fixed-height'); + expect($mjHeroElement->getAttribute('height'))->toBe('500px'); + + $styles = $mjHeroElement->getStyles(); + expect($styles['td']['height'])->toBe('500px'); +}); + +it('will not set height in fluid-height mode', function () { + $heroNode = new MjmlNode( + 'mj-hero', + [ + 'mode' => 'fluid-height', + 'height' => '500px', + ], + null, + false, + null + ); + + $factory = new ElementFactory(); + $mjHeroElement = $factory->create($heroNode); + + $styles = $mjHeroElement->getStyles(); + expect($styles['td'])->not->toHaveKey('height'); +}); + +it('will correctly set vertical alignment', function () { + $heroNode = new MjmlNode( + 'mj-hero', + ['vertical-align' => 'middle'], + null, + false, + null + ); + + $factory = new ElementFactory(); + $mjHeroElement = $factory->create($heroNode); + + $styles = $mjHeroElement->getStyles(); + expect($styles['tr']['vertical-align'])->toBe('middle'); +}); + +it('will correctly set padding', function () { + $heroNode = new MjmlNode( + 'mj-hero', + ['padding' => '20px 10px'], + null, + false, + null + ); + + $factory = new ElementFactory(); + $mjHeroElement = $factory->create($heroNode); + + $styles = $mjHeroElement->getStyles(); + expect($styles['td']['padding'])->toBe('20px 10px'); +}); + +it('will correctly set all custom properties', function () { + $heroNode = new MjmlNode( + 'mj-hero', + [ + 'background-color' => '#333333', + 'background-url' => 'https://example.com/bg.jpg', + 'background-position' => 'top left', + 'mode' => 'fixed-height', + 'height' => '400px', + 'padding' => '30px', + 'vertical-align' => 'bottom', + 'width' => '600px', + ], + null, + false, + null + ); + + $factory = new ElementFactory(); + $mjHeroElement = $factory->create($heroNode); + + expect($mjHeroElement->getAttribute('background-color'))->toBe('#333333'); + expect($mjHeroElement->getAttribute('background-url'))->toBe('https://example.com/bg.jpg'); + expect($mjHeroElement->getAttribute('mode'))->toBe('fixed-height'); + + $styles = $mjHeroElement->getStyles(); + expect($styles['table']['background-color'])->toBe('#333333'); + expect($styles['table']['background-position'])->toBe('top left'); + expect($styles['tr']['vertical-align'])->toBe('bottom'); + expect($styles['td']['height'])->toBe('400px'); + expect($styles['td']['padding'])->toBe('30px'); + expect($styles['div']['max-width'])->toBe('600px'); +});