From f632dcbf8491acfb09922c198e2abf16194e6616 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Mon, 16 Feb 2026 16:23:37 +0000 Subject: [PATCH] feat(view): add width, height and style props to x-icon, update tests and docs --- composer.json | 7 +- docs/1-essentials/02-views.md | 69 ++++++++ packages/view/src/Components/x-icon.view.php | 62 +++++-- .../view-with-icon-inside-named-slot.view.php | 2 +- .../View/Components/IconComponentTest.php | 158 ++++++++++++++---- 5 files changed, 243 insertions(+), 55 deletions(-) diff --git a/composer.json b/composer.json index 75b6fa585e..80739a561c 100644 --- a/composer.json +++ b/composer.json @@ -245,7 +245,10 @@ } }, "scripts": { - "phpunit": "@php -d memory_limit=2G vendor/bin/phpunit --display-warnings --display-skipped --display-deprecations --display-errors --display-notices", + "phpunit": [ + "Composer\\Config::disableProcessTimeout", + "@php -d memory_limit=2G vendor/bin/phpunit --display-warnings --display-skipped --display-deprecations --display-errors --display-notices" + ], "coverage": "vendor/bin/phpunit --coverage-html build/reports/html --coverage-clover build/reports/clover.xml", "fmt": "vendor/bin/mago fmt", "lint:fix": "vendor/bin/mago lint --fix --format-after-fix", @@ -276,4 +279,4 @@ "composer exceptions:build" ] } -} +} \ No newline at end of file diff --git a/docs/1-essentials/02-views.md b/docs/1-essentials/02-views.md index 5030bc7346..17b6536ace 100644 --- a/docs/1-essentials/02-views.md +++ b/docs/1-essentials/02-views.md @@ -559,6 +559,75 @@ This component provides the ability to inject any icon from the [Iconify](https: ```html ``` +will render +```html + + + +``` + +This component includes some optional props you can use to control width and height. As a fallback, if you specify no class, no style, no width & height, the component will render a default width and height, but you can override this behaviour in any of the following ways. + +```html + +``` +will render +```html + + + +``` + +Firstly, you can set width and height using Tailwind or custom classes. As long as you pass the `class` prop, the component will assume you are providing suitable dimensions, and will not check, or assert, any default dimensions. + +```html + +``` +will render +```html + + + +``` + +Secondly, if you aren't using Tailwind, or wish to set for a single icon without making a class, you can instead pass dimensions via the `style` prop. Again, as long as you pass `style`, the component will assume you are providing suitable dimensions, and will not check, or assert, any default dimensions. + +```html + +``` +will render +```html + + + +``` + +Finally, you may provide the width and height properties directly with the props `width` and `height`. The component requires both to be set, or will render the fallback dimensions. + +```html + +``` +will render +```html + + + +``` The first time a specific icon is being rendered, Tempest will query the [Iconify API](https://iconify.design/docs/api/queries.html) to fetch the corresponding SVG tag. The result of this query will be cached indefinitely, so it can be reused at no further cost. diff --git a/packages/view/src/Components/x-icon.view.php b/packages/view/src/Components/x-icon.view.php index 67905ea976..f6bb76c746 100644 --- a/packages/view/src/Components/x-icon.view.php +++ b/packages/view/src/Components/x-icon.view.php @@ -1,37 +1,63 @@ render($name); -} else { - $svg = null; -} - -if ($svg === null && $environment->isLocal()) { - $svg = ''; -} - -if ($class) { - $svg = str($svg) - ->replace( +$svg = str(is_string($name) ? get(Icon::class)->render($name) : null) + ->when( + fn (ImmutableString $s): bool => $s->toString() === '' && $environment->isLocal(), + fn (ImmutableString $s): ImmutableString => str(""), + ) + ->replace( + search: " width=\"1em\" height=\"1em\"", + replace: '', + ) + ->when( + $style ?? null, + fn (ImmutableString $s): ImmutableString => $s->replace( + search: 'when( + $class ?? null, + fn (ImmutableString $s): ImmutableString => $s->replace( search: 'toString(); -} + ), + ) + ->when( + isset($width, $height), + fn (ImmutableString $s): ImmutableString => $s + ->replace( + search: 'when( + ! isset($width, $height) && ! isset($style) && ! isset($class), + fn (ImmutableString $s): ImmutableString => $s + ->replace( + search: 'toString(); ?> {!! $svg !!} diff --git a/tests/Fixtures/Views/view-with-icon-inside-named-slot.view.php b/tests/Fixtures/Views/view-with-icon-inside-named-slot.view.php index 897987ec50..99fa78c423 100644 --- a/tests/Fixtures/Views/view-with-icon-inside-named-slot.view.php +++ b/tests/Fixtures/Views/view-with-icon-inside-named-slot.view.php @@ -1,6 +1,6 @@ - + Test diff --git a/tests/Integration/View/Components/IconComponentTest.php b/tests/Integration/View/Components/IconComponentTest.php index e9fb36fded..2221b9842e 100644 --- a/tests/Integration/View/Components/IconComponentTest.php +++ b/tests/Integration/View/Components/IconComponentTest.php @@ -35,14 +35,17 @@ public function test_it_renders_an_icon(): void $mockHttpClient ->expects($this->once()) ->method('get') - ->with('https://api.iconify.design/ph/eye.svg') - ->willReturn(new GenericResponse(status: Status::OK, body: '')); + ->with('https://api.iconify.design/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); $this->container->register(HttpClient::class, fn () => $mockHttpClient); $this->assertSame( - '', - $this->view->render(''), + '', + $this->view->render(''), ); } @@ -52,8 +55,11 @@ public function test_it_downloads_the_icon_from_a_custom_api(): void $mockHttpClient ->expects($this->exactly(1)) ->method('get') - ->with('https://api.iconify.test/ph/eye.svg') - ->willReturn(new GenericResponse(status: Status::OK, body: '')); + ->with('https://api.iconify.test/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); $this->container->register(HttpClient::class, fn () => $mockHttpClient); @@ -63,8 +69,8 @@ public function test_it_downloads_the_icon_from_a_custom_api(): void ); $this->assertSame( - '', - $this->view->render(''), + '', + $this->view->render(''), ); } @@ -82,18 +88,24 @@ public function test_it_caches_icons_on_the_first_render(): void $mockHttpClient ->expects($this->once()) ->method('get') - ->with('https://api.iconify.design/ph/eye.svg') - ->willReturn(new GenericResponse(status: Status::OK, body: '')); + ->with('https://api.iconify.design/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); $this->container->register(HttpClient::class, fn () => $mockHttpClient); - $this->view->render(''); + $this->view->render(''); $iconCache = $this->container->get(IconCache::class); - $cachedIcon = $iconCache->get('icon-ph-eye'); + $cachedIcon = $iconCache->get('icon-material-symbols-php'); $this->assertNotNull($cachedIcon); - $this->assertSame('', $cachedIcon); + $this->assertSame( + '', + $cachedIcon, + ); } public function test_it_renders_an_icon_from_cache(): void @@ -102,17 +114,20 @@ public function test_it_renders_an_icon_from_cache(): void $mockHttpClient ->expects($this->exactly(1)) ->method('get') - ->with('https://api.iconify.design/ph/eye.svg') - ->willReturn(new GenericResponse(status: Status::OK, body: '')); + ->with('https://api.iconify.design/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); $this->container->register(HttpClient::class, fn () => $mockHttpClient); // Trigger first render, which should cache the icon - $this->view->render(''); + $this->view->render(''); $this->assertSame( - '', - $this->view->render(''), + '', + $this->view->render(''), ); } @@ -122,15 +137,15 @@ public function test_it_renders_a_debug_comment_in_local_env_when_icon_does_not_ $mockHttpClient ->expects($this->once()) ->method('get') - ->with('https://api.iconify.design/ph/eye.svg') + ->with('https://api.iconify.design/material-symbols/php.svg') ->willReturn(new GenericResponse(status: Status::NOT_FOUND, body: '')); $this->container->register(HttpClient::class, fn () => $mockHttpClient); $this->container->singleton(Environment::class, Environment::LOCAL); $this->assertSame( - '', - $this->view->render(''), + '', + $this->view->render(''), ); } @@ -140,7 +155,7 @@ public function test_it_renders_an_empty_string__in_non_local_env_when_icon_does $mockHttpClient ->expects($this->once()) ->method('get') - ->with('https://api.iconify.design/ph/eye.svg') + ->with('https://api.iconify.design/material-symbols/php.svg') ->willReturn(new GenericResponse(status: Status::NOT_FOUND, body: '')); $this->container->register(HttpClient::class, fn () => $mockHttpClient); @@ -148,7 +163,7 @@ public function test_it_renders_an_empty_string__in_non_local_env_when_icon_does $this->assertSame( '', - $this->view->render(''), + $this->view->render(''), ); } @@ -158,15 +173,84 @@ public function test_it_forwards_the_class_attribute(): void $mockHttpClient ->expects($this->exactly(1)) ->method('get') - ->with('https://api.iconify.design/ph/eye.svg') - ->willReturn(new GenericResponse(status: Status::OK, body: '')); + ->with('https://api.iconify.design/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); + + $this->container->register(HttpClient::class, fn () => $mockHttpClient); + + $this->assertSame( + '', + $this->view->render( + '', + ), + ); + } + + public function test_it_forwards_the_style_attribute(): void + { + $mockHttpClient = $this->createMock(HttpClient::class); + $mockHttpClient + ->expects($this->exactly(1)) + ->method('get') + ->with('https://api.iconify.design/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); + + $this->container->register(HttpClient::class, fn () => $mockHttpClient); + + $this->assertSame( + '', + $this->view->render( + '', + ), + ); + } + + public function test_it_handles_width_and_height_together_props(): void + { + $mockHttpClient = $this->createMock(HttpClient::class); + $mockHttpClient + ->expects($this->exactly(1)) + ->method('get') + ->with('https://api.iconify.design/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); + + $this->container->register(HttpClient::class, fn () => $mockHttpClient); + + $this->assertSame( + '', + $this->view->render( + '', + ), + ); + } + + public function test_fallback_dimensions_when_none_defined_in_any_supported_method(): void + { + $mockHttpClient = $this->createMock(HttpClient::class); + $mockHttpClient + ->expects($this->exactly(1)) + ->method('get') + ->with('https://api.iconify.design/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); $this->container->register(HttpClient::class, fn () => $mockHttpClient); $this->assertSame( - '', + '', $this->view->render( - '', + '', ), ); } @@ -177,18 +261,21 @@ public function test_with_dynamic_data(): void $mockHttpClient ->expects($this->exactly(1)) ->method('get') - ->with('https://api.iconify.design/ph/eye.svg') - ->willReturn(new GenericResponse(status: Status::OK, body: '')); + ->with('https://api.iconify.design/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); $this->container->register(HttpClient::class, fn () => $mockHttpClient); $rendered = $this->view->render( '', - iconName: 'ph:eye', + iconName: 'material-symbols:php', ); $this->assertSame( - '', + '', $rendered, ); } @@ -201,8 +288,11 @@ public function test_icon_renders_inside_named_slot_in_a_layout(): void $mockHttpClient ->expects($this->exactly(1)) ->method('get') - ->with('https://api.iconify.design/ph/eye.svg') - ->willReturn(new GenericResponse(status: Status::OK, body: '')); + ->with('https://api.iconify.design/material-symbols/php.svg') + ->willReturn(new GenericResponse( + status: Status::OK, + body: '', + )); $this->container->register(HttpClient::class, fn () => $mockHttpClient); @@ -210,7 +300,7 @@ public function test_icon_renders_inside_named_slot_in_a_layout(): void $html = $this->view->render($view); $this->assertSnippetsMatch( - '
Test', + '
Test', $html, ); }