From abc4ef0e3079fd1d03e5f12da36c56f8331c6639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 13 Mar 2026 10:38:42 +0100 Subject: [PATCH] Add Callout widget Introduces a Callout widget that renders an information box with a bordered, tinted background and icon determined by its CalloutType (Info, Success, Warning, Error). Supports an optional title and a fit-content sizing mode. Includes LESS variables for theming, and unit tests. --- asset/css/callout.less | 60 +++++++++++++++++ asset/css/variables.less | 10 +++ src/Common/CalloutType.php | 31 +++++++++ src/Widget/Callout.php | 79 ++++++++++++++++++++++ tests/Widget/CalloutTest.php | 124 +++++++++++++++++++++++++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 asset/css/callout.less create mode 100644 src/Common/CalloutType.php create mode 100644 src/Widget/Callout.php create mode 100644 tests/Widget/CalloutTest.php diff --git a/asset/css/callout.less b/asset/css/callout.less new file mode 100644 index 000000000..ad03d7676 --- /dev/null +++ b/asset/css/callout.less @@ -0,0 +1,60 @@ +// Layout +.callout { + display: flex; + column-gap: 1em; + justify-content: start; + + &.callout-fit-content { + width: fit-content; + } + + i.icon::before { + margin-right: 0; + } + + p { + margin: 0; + } + + .callout-title { + margin-bottom: .5em; + } + + .callout-text { + display: flex; + flex-direction: column; + } +} + +// Style +.callout { + padding: .5em 1em; + border: 1px solid var(--callout-color); + background-color: var(--callout-bg-color); + border-radius: .25em; + + i.icon { + color: var(--callout-color); + font-size: 1.5em; + } + + &.callout-type-info { + --callout-color: @callout-info-color; + --callout-bg-color: @callout-info-bg; + } + + &.callout-type-success { + --callout-color: @callout-success-color; + --callout-bg-color: @callout-success-bg; + } + + &.callout-type-warning { + --callout-color: @callout-warning-color; + --callout-bg-color: @callout-warning-bg; + } + + &.callout-type-error { + --callout-color: @callout-error-color; + --callout-bg-color: @callout-error-bg; + } +} diff --git a/asset/css/variables.less b/asset/css/variables.less index 1e0efbdd7..45d176ae9 100644 --- a/asset/css/variables.less +++ b/asset/css/variables.less @@ -130,6 +130,16 @@ @schedule-element-fields-disabled-selected-bg: @base-gray-light; @schedule-element-keyboard-note-bg: @base-gray-light; +@callout-success-color: @state-ok; +@callout-info-color: #008fe8; +@callout-warning-color: @state-warning; +@callout-error-color: @state-critical; + +@callout-success-bg: fade(@callout-success-color, 10%); +@callout-info-bg: fade(@callout-info-color, 10%); +@callout-warning-bg: fade(@callout-warning-color, 10%); +@callout-error-bg: fade(@callout-error-color, 10%); + @empty-state-color: @base-gray-semilight; @empty-state-bar-bg: @base-gray-lighter; diff --git a/src/Common/CalloutType.php b/src/Common/CalloutType.php new file mode 100644 index 000000000..ea86d5679 --- /dev/null +++ b/src/Common/CalloutType.php @@ -0,0 +1,31 @@ + 'circle-info', + self::Success => 'circle-check', + self::Warning => 'warning', + self::Error => 'circle-xmark', + }); + } +} diff --git a/src/Widget/Callout.php b/src/Widget/Callout.php new file mode 100644 index 000000000..7ddf3ab7e --- /dev/null +++ b/src/Widget/Callout.php @@ -0,0 +1,79 @@ + 'callout']; + + /** + * Create a new callout + * + * The $type parameter determines the color and icon of the callout. + * + * @param CalloutType $type The type of the callout + * @param ValidHtml|string $content The content of the callout + * @param ?string $title An optional title, displayed above the content + */ + public function __construct( + protected CalloutType $type, + protected ValidHtml|string $content, + protected ?string $title = null + ) { + $this->addAttributes(Attributes::create(['class' => $type->value])); + } + + protected function assemble(): void + { + $this->addHtml($this->type->getIcon()); + + $this->addHtml(HtmlElement::create( + 'div', + ['class' => 'callout-text'], + [ + $this->title === null || trim($this->title) === '' + ? null + : HtmlElement::create('strong', ['class' => 'callout-title'], Text::create($this->title)), + is_string($this->content) ? Text::create($this->content) : $this->content, + ], + )); + } + + /** + * Set the callout width to 100% of its parent container + * + * Callouts are by default sized to fill their parent container. + * + * @param bool $isFitContent Whether the callout size should be dependent on its content + * + * @return $this + */ + public function setFitContent(bool $isFitContent = true): static + { + if ($isFitContent) { + $this->addAttributes(Attributes::create(['class' => static::CLASS_FIT_CONTENT])); + } else { + $this->removeAttribute('class', static::CLASS_FIT_CONTENT); + } + + return $this; + } +} diff --git a/tests/Widget/CalloutTest.php b/tests/Widget/CalloutTest.php new file mode 100644 index 000000000..e797b2b57 --- /dev/null +++ b/tests/Widget/CalloutTest.php @@ -0,0 +1,124 @@ + + +
+ Content +
+ +HTML; + + $this->assertHtml($html, $callout); + } + + public function testCalloutWithTitle(): void + { + $callout = new Callout(CalloutType::Info, 'Content', 'Title'); + + $html = <<<'HTML' +
+ +
+ Title + Content +
+
+HTML; + + $this->assertHtml($html, $callout); + } + + public function testCalloutFalsyTitle(): void + { + $callout = new Callout(CalloutType::Warning, 'Content', '0'); + + $html = <<<'HTML' +
+ +
+ 0 + Content +
+
+HTML; + $this->assertHtml($html, $callout); + } + + public function testCalloutEmptyTitle(): void + { + $callout = new Callout(CalloutType::Error, 'Content', ''); + + $html = <<<'HTML' +
+ +
+ Content +
+
+HTML; + $this->assertHtml($html, $callout); + } + + public function testCalloutValidHtmlContent(): void + { + $callout = new Callout( + CalloutType::Success, + Html::tag('p', ['class' => 'test-class'], 'This is a Test'), + 'Test Title', + ); + + $html = <<<'HTML' +
+ +
+ Test Title +

This is a Test

+
+
+HTML; + + $this->assertHtml($html, $callout); + } + + public function testCalloutFitContent(): void + { + $callout = (new Callout(CalloutType::Error, 'Content')) + ->setFitContent(true); + + $html = <<<'HTML' +
+ +
+ Content +
+
+HTML; + $this->assertHtml($html, $callout); + + $callout->setFitContent(false); + + $html2 = <<<'HTML' +
+ +
+ Content +
+
+HTML; + $this->assertHtml($html2, $callout); + } +}