From 0ca16168a273ddb5c4e2fd983daf841ffc243f98 Mon Sep 17 00:00:00 2001 From: Chase Miller Date: Thu, 30 Apr 2026 18:34:51 -0600 Subject: [PATCH 1/4] Add BaseLivewireWidget alongside BaseBladeWidget for dual-render widgets Splits the widget rendering tech from widget identity so a downstream package can swap any widget's frontend render path from Blade to Livewire without redeclaring form schema, defaults, lifecycle hooks, or preview. - Extract HasWidgetDefaults trait holding all static metadata defaults shared between Blade and Livewire bases. - Rename current BaseWidget body to BaseBladeWidget; keep BaseWidget as a back-compat shim so existing widgets and stubs are untouched. - Add BaseLivewireWidget that mounts a Livewire component via , passing $data as a prop and pre-rendered children as the default slot. livewire/livewire stays a soft dependency (composer suggest). - Widen render() return type on BaseView to View|Htmlable|string so both bases satisfy the contract; the call site already stringifies output. - Add BaseView::renderChildrenToHtml() for the Livewire slot path. - Switch type checks in RegistersWidgets, LayupContent, and WidgetDefaultCompletenessTest from BaseWidget class to the Widget interface so Livewire widgets are recognised everywhere. - Update LayupAssertions to coerce View|Htmlable|string render results. - Demonstrate the identity-trait pattern on Heading, Button, NumberCounter, and Newsletter; remaining widgets unchanged. - Add docs/advanced/livewire-widgets.md describing when to use which base, the slot-based children model, and how a downstream package re-registers a type to swap render tech. All 1130 tests pass. --- composer.json | 3 + docs/advanced/custom-widgets.md | 4 +- docs/advanced/livewire-widgets.md | 198 ++++++++++++++++++ src/Support/Concerns/RegistersWidgets.php | 9 +- src/Support/LayupContent.php | 4 +- src/Testing/LayupAssertions.php | 25 ++- src/View/BaseBladeWidget.php | 56 +++++ src/View/BaseLivewireWidget.php | 76 +++++++ src/View/BaseView.php | 27 ++- src/View/BaseWidget.php | 177 ++-------------- src/View/ButtonWidget.php | 85 +------- src/View/Concerns/HasWidgetDefaults.php | 147 +++++++++++++ src/View/Concerns/Identity/ButtonIdentity.php | 93 ++++++++ .../Concerns/Identity/HeadingIdentity.php | 80 +++++++ .../Concerns/Identity/NewsletterIdentity.php | 85 ++++++++ .../Identity/NumberCounterIdentity.php | 77 +++++++ src/View/HeadingWidget.php | 63 +----- src/View/NewsletterWidget.php | 77 +------ src/View/NumberCounterWidget.php | 69 +----- tests/Unit/WidgetDefaultCompletenessTest.php | 7 +- 20 files changed, 896 insertions(+), 466 deletions(-) create mode 100644 docs/advanced/livewire-widgets.md create mode 100644 src/View/BaseBladeWidget.php create mode 100644 src/View/BaseLivewireWidget.php create mode 100644 src/View/Concerns/HasWidgetDefaults.php create mode 100644 src/View/Concerns/Identity/ButtonIdentity.php create mode 100644 src/View/Concerns/Identity/HeadingIdentity.php create mode 100644 src/View/Concerns/Identity/NewsletterIdentity.php create mode 100644 src/View/Concerns/Identity/NumberCounterIdentity.php diff --git a/composer.json b/composer.json index 2767636..b9e67e3 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,9 @@ "laravel/pint": "^1.27", "rector/rector": "^2.3" }, + "suggest": { + "livewire/livewire": "Required only when using BaseLivewireWidget to render widgets through Livewire components (^3.0)." + }, "config": { "allow-plugins": { "pestphp/pest-plugin": true diff --git a/docs/advanced/custom-widgets.md b/docs/advanced/custom-widgets.md index f24552c..3fcc827 100644 --- a/docs/advanced/custom-widgets.md +++ b/docs/advanced/custom-widgets.md @@ -22,9 +22,11 @@ Add `--with-test` to also generate a Pest test file: php artisan layup:make-widget BannerWidget --with-test ``` +> **Need server-side state?** Widgets can also render through Livewire components instead of Blade. See [Livewire-rendered widgets](livewire-widgets.md). + ## Widget class structure -Every custom widget extends `BaseWidget` and implements two required methods: +Every custom widget extends `BaseWidget` (or `BaseLivewireWidget` for Livewire-rendered output) and implements two required methods: ```php required(), + TextInput::make('start')->numeric()->default(0), + ]; + } + + public static function getDefaultData(): array + { + return ['label' => 'Clicks', 'start' => 0]; + } +} +``` + +The Livewire component itself is whatever you'd write normally. It receives the widget data as a `$data` prop and a `$slot` containing the pre-rendered children HTML: + +```php +data = $data; + $this->count = (int) ($data['start'] ?? 0); + } + + public function increment(): void + { + $this->count++; + } + + public function render() + { + return view('livewire.live-counter'); + } +} +``` + +```blade +{{-- resources/views/livewire/live-counter.blade.php --}} +
+

{{ $data['label'] }}: {{ $count }}

+ + {{ $slot }} +
+``` + +Register the widget the same way as any other: + +```php +// config/layup.php +'widgets' => [ + \App\Layup\Widgets\LiveCounterWidget::class, + // ... +], +``` + +## Children inside a Livewire widget + +`BaseLivewireWidget::render()` recursively renders the widget's children to a single HTML string (`renderChildrenToHtml()`) and passes the result as the Livewire slot. Inside the Livewire view, `{{ $slot }}` emits children wherever they belong. + +Children remain polymorphic: a child of a Livewire widget can be a Blade widget, another Livewire widget, or any mix. Each child manages its own rendering independently. + +When the parent Livewire component re-renders (in response to a `wire:click`, etc.), the slot content is preserved -- children are not re-executed on the server. This is the right default: structural data in children is part of the page, not the parent's state. + +## Swapping a built-in widget for a Livewire flavour + +Layup ships identity traits for several widgets so a downstream package can swap rendering without redeclaring the editor experience. For example: + +```php +register(\MyPackage\Widgets\NewsletterLivewireWidget::class); +``` + +`WidgetRegistry::register()` logs a warning when overriding an existing type so the swap is visible in logs. + +## Identity traits + +The traits that ship in `Crumbls\Layup\View\Concerns\Identity\` hold the static metadata for a widget -- type, label, icon, category, form schema, defaults, preview. They have no rendering logic. Using one in a Blade-rendered widget: + +```php +class HeadingWidget extends BaseWidget +{ + use HeadingIdentity; +} +``` + +The same trait is what a Livewire flavour uses: + +```php +class HeadingLivewireWidget extends BaseLivewireWidget +{ + use HeadingIdentity; + + public static function getLivewireComponent(): string + { + return 'layup-livewire.heading'; + } +} +``` + +Adding identity traits to your own widgets is optional -- it only matters if you intend to ship multiple render flavours of the same widget. For one-off widgets, define the static methods directly on the class. + +## Installation note + +`livewire/livewire` is a soft dependency. Layup loads cleanly without it. Install it only if you actually use `BaseLivewireWidget`: + +```bash +composer require livewire/livewire +``` + +Calling `render()` on a `BaseLivewireWidget` instance without Livewire installed will fail at the Blade compile step. diff --git a/src/Support/Concerns/RegistersWidgets.php b/src/Support/Concerns/RegistersWidgets.php index 857baf4..e4167aa 100644 --- a/src/Support/Concerns/RegistersWidgets.php +++ b/src/Support/Concerns/RegistersWidgets.php @@ -4,8 +4,8 @@ namespace Crumbls\Layup\Support\Concerns; +use Crumbls\Layup\Contracts\Widget; use Crumbls\Layup\Support\WidgetRegistry; -use Crumbls\Layup\View\BaseWidget; /** * Populates the WidgetRegistry from config and auto-discovery. @@ -46,7 +46,12 @@ protected function discoverAppWidgets(WidgetRegistry $registry): void $className = "{$namespace}\\{$file->getBasename('.php')}"; - if (class_exists($className) && is_subclass_of($className, BaseWidget::class) && ! $registry->has($className::getType())) { + if ( + class_exists($className) + && is_subclass_of($className, Widget::class) + && ! (new \ReflectionClass($className))->isAbstract() + && ! $registry->has($className::getType()) + ) { $registry->register($className); } } diff --git a/src/Support/LayupContent.php b/src/Support/LayupContent.php index 5693db9..8ae75a0 100644 --- a/src/Support/LayupContent.php +++ b/src/Support/LayupContent.php @@ -145,7 +145,7 @@ protected function serializeNodes(array $nodes): array $entry['span'] = $node->getSpan(); } - if ($node instanceof \Crumbls\Layup\View\BaseWidget) { + if ($node instanceof \Crumbls\Layup\Contracts\Widget) { $entry['data'] = $node->getData(); } @@ -169,7 +169,7 @@ protected function resolveNodeType(\Crumbls\Layup\View\BaseView $node): string return 'column'; } - if ($node instanceof \Crumbls\Layup\View\BaseWidget) { + if ($node instanceof \Crumbls\Layup\Contracts\Widget) { return $node::getType(); } diff --git a/src/Testing/LayupAssertions.php b/src/Testing/LayupAssertions.php index e62366f..f52c080 100644 --- a/src/Testing/LayupAssertions.php +++ b/src/Testing/LayupAssertions.php @@ -8,7 +8,8 @@ use Crumbls\Layup\Support\ContentWalker; use Crumbls\Layup\Support\WidgetRegistry; use Crumbls\Layup\View\BaseView; -use Crumbls\Layup\View\BaseWidget; +use Illuminate\Contracts\Support\Htmlable; +use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Model; trait LayupAssertions @@ -65,12 +66,24 @@ public function assertWidgetRenders(string $type, array $data = []): void ); $widget = $class::make($data ?: $class::getDefaultData()); - $html = $widget->render()->render(); + $html = $this->renderToString($widget->render()); $this->assertIsString($html, "Failed asserting that widget '{$type}' renders a string."); $this->assertNotEmpty($html, "Failed asserting that widget '{$type}' renders non-empty HTML."); } + /** + * Coerce a widget render() result (View | Htmlable | string) into a string. + */ + protected function renderToString(View|Htmlable|string $rendered): string + { + return match (true) { + is_string($rendered) => $rendered, + $rendered instanceof View => $rendered->render(), + $rendered instanceof Htmlable => $rendered->toHtml(), + }; + } + /** * Assert that a page renders without errors. */ @@ -130,7 +143,7 @@ public function assertWidgetContractValid(string $class): void /** * Assert that a widget's getDefaultData() covers all fields in getContentFormSchema(). * - * @param class-string $class + * @param class-string $class */ public function assertDefaultsCoverFormFields(string $class): void { @@ -148,14 +161,14 @@ public function assertDefaultsCoverFormFields(string $class): void /** * Assert that a widget renders successfully with its default data. * - * @param class-string $class + * @param class-string $class */ public function assertWidgetRendersWithDefaults(string $class): void { $defaults = $class::getDefaultData(); $prepared = $class::prepareForRender($defaults); $widget = $class::make($prepared); - $html = $widget->render()->render(); + $html = $this->renderToString($widget->render()); $this->assertIsString($html, "Failed asserting that {$class} renders a string with default data."); $this->assertNotEmpty($html, "Failed asserting that {$class} renders non-empty HTML with default data."); @@ -164,7 +177,7 @@ public function assertWidgetRendersWithDefaults(string $class): void /** * Extract field names from a widget's content form schema. * - * @param class-string $class + * @param class-string $class * @return array */ protected function extractWidgetFieldNames(string $class): array diff --git a/src/View/BaseBladeWidget.php b/src/View/BaseBladeWidget.php new file mode 100644 index 0000000..224526f --- /dev/null +++ b/src/View/BaseBladeWidget.php @@ -0,0 +1,56 @@ +render() !!}`) accepts output from either base. + */ +abstract class BaseBladeWidget extends BaseView implements Widget +{ + use HasWidgetDefaults; + + abstract public static function getType(): string; + + abstract public static function getLabel(): string; + + /** + * Widget-specific content fields. Every widget must override this. + * + * @return array<\Filament\Forms\Components\Component> + */ + public static function getContentFormSchema(): array + { + return []; + } + + /** + * Get the view name for frontend rendering. + * Convention: layup::components.{type} + * Override for custom view paths. + */ + protected function getViewName(): string + { + return 'layup::components.' . static::getType(); + } + + public function render(): View|Htmlable|string + { + return view($this->getViewName(), [ + 'data' => $this->data, + 'children' => $this->children, + ]); + } +} diff --git a/src/View/BaseLivewireWidget.php b/src/View/BaseLivewireWidget.php new file mode 100644 index 0000000..3ff5562 --- /dev/null +++ b/src/View/BaseLivewireWidget.php @@ -0,0 +1,76 @@ + + */ + public static function getContentFormSchema(): array + { + return []; + } + + public function render(): string + { + $template = <<<'BLADE' + + {!! $childrenHtml !!} + + BLADE; + + return Blade::render($template, [ + 'component' => static::getLivewireComponent(), + 'key' => static::getType() . '-' . spl_object_id($this), + 'data' => $this->data, + 'childrenHtml' => $this->renderChildrenToHtml(), + ]); + } +} diff --git a/src/View/BaseView.php b/src/View/BaseView.php index e6e5997..8723db6 100644 --- a/src/View/BaseView.php +++ b/src/View/BaseView.php @@ -15,6 +15,7 @@ use Filament\Schemas\Components\Component as SchemaComponent; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; +use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\View\View; use Illuminate\View\Component; @@ -91,6 +92,25 @@ public function hasChildren(): bool return count($this->children) > 0; } + /** + * Recursively render all children to a single HTML string. + * + * Used by render() implementations that need a pre-rendered children + * blob (e.g. when mounting a Livewire component and passing children + * through the slot). Each child's render() result is cast to string, + * which works for View, Htmlable, and string return types alike. + */ + public function renderChildrenToHtml(): string + { + $html = ''; + + foreach ($this->children as $child) { + $html .= (string) $child->render(); + } + + return $html; + } + /** * Get the data array. */ @@ -489,6 +509,11 @@ public static function animationAttributes(array $data): string /** * Get the view / contents that represent the component. + * + * Return type is intentionally wide: BaseBladeWidget returns a View, + * BaseLivewireWidget returns a string from Blade::render(), and + * downstream extensions may return any Htmlable. The frontend call + * site stringifies the result. */ - abstract public function render(): View; + abstract public function render(): View|Htmlable|string; } diff --git a/src/View/BaseWidget.php b/src/View/BaseWidget.php index 5e8de72..8a2d426 100644 --- a/src/View/BaseWidget.php +++ b/src/View/BaseWidget.php @@ -4,165 +4,18 @@ namespace Crumbls\Layup\View; -use Crumbls\Layup\Contracts\Widget; -use Crumbls\Layup\Support\WidgetContext; -use Illuminate\Contracts\View\View; - -abstract class BaseWidget extends BaseView implements Widget -{ - abstract public static function getType(): string; - - abstract public static function getLabel(): string; - - /** - * Widget-specific content fields. - * Every widget must override this. - * - * @return array<\Filament\Forms\Components\Component> - */ - public static function getContentFormSchema(): array - { - return []; - } - - public static function getIcon(): string - { - return 'heroicon-o-puzzle-piece'; - } - - public static function getCategory(): string - { - return 'content'; - } - - public static function getDefaultData(): array - { - return []; - } - - /** - * Generate preview text for the builder canvas. - * Override in subclasses for richer previews. - */ - public static function getPreview(array $data): string - { - if (! empty($data['content'])) { - $text = strip_tags((string) $data['content']); - - return mb_strlen($text) > 60 ? mb_substr($text, 0, 60) . "\u{2026}" : $text; - } - - if (! empty($data['label'])) { - return $data['label']; - } - - if (! empty($data['src'])) { - return "\u{1F5BC} " . basename((string) $data['src']); - } - - return '(empty)'; - } - - public static function prepareForRender(array $data): array - { - return $data; - } - - /** - * @return array - */ - public static function getValidationRules(): array - { - return []; - } - - /** - * @return array - */ - public static function getSearchTerms(): array - { - return []; - } - - public static function isDeprecated(): bool - { - return false; - } - - public static function getDeprecationMessage(): string - { - return ''; - } - - /** - * Called after save. Override to transform or validate data. - * Context is provided when available (page, row/column/widget IDs). - */ - public static function onSave(array $data, ?WidgetContext $context = null): array - { - return $data; - } - - /** - * Called on widget creation. Override for init logic. - * Context is provided when available. - */ - public static function onCreate(array $data, ?WidgetContext $context = null): array - { - return $data; - } - - /** - * Called on widget deletion. Override for cleanup. - * Context is provided when available. - */ - public static function onDelete(array $data, ?WidgetContext $context = null): void - { - // No-op by default - } - - public static function onDuplicate(array $data, ?WidgetContext $context = null): array - { - return $data; - } - - /** - * @return array{js?: array, css?: array} - */ - public static function getAssets(): array - { - return []; - } - - public static function toArray(): array - { - return [ - 'type' => static::getType(), - 'label' => static::getLabel(), - 'icon' => static::getIcon(), - 'category' => static::getCategory(), - 'defaults' => static::getDefaultData(), - 'search_terms' => static::getSearchTerms(), - 'deprecated' => static::isDeprecated(), - 'deprecation_message' => static::getDeprecationMessage(), - ]; - } - - /** - * Get the view name for frontend rendering. - * Convention: layup::components.{type} - * Override for custom view paths. - */ - protected function getViewName(): string - { - return 'layup::components.' . static::getType(); - } - - public function render(): View - { - return view($this->getViewName(), [ - 'data' => $this->data, - 'children' => $this->children, - ]); - } -} +/** + * Backwards-compatible alias for BaseBladeWidget. + * + * Originally the only widget base. Kept as an empty subclass so that all + * existing widgets (`extends BaseWidget`), stubs, docs, and downstream + * packages continue to work without modification. New widgets may extend + * either BaseBladeWidget directly (clearer intent) or BaseLivewireWidget + * (Livewire-rendered output). + * + * Note for runtime checks: prefer `instanceof Widget` (the interface) over + * `instanceof BaseWidget` so that BaseLivewireWidget instances are also + * recognised. The Widget interface is the contract — BaseWidget is just + * one base implementation. + */ +abstract class BaseWidget extends BaseBladeWidget {} diff --git a/src/View/ButtonWidget.php b/src/View/ButtonWidget.php index 4b73626..25e88d5 100644 --- a/src/View/ButtonWidget.php +++ b/src/View/ButtonWidget.php @@ -4,90 +4,9 @@ namespace Crumbls\Layup\View; -use Crumbls\Layup\Forms\Components\ColorPicker; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; +use Crumbls\Layup\View\Concerns\Identity\ButtonIdentity; class ButtonWidget extends BaseWidget { - public static function getType(): string - { - return 'button'; - } - - public static function getLabel(): string - { - return __('layup::widgets.labels.button'); - } - - public static function getIcon(): string - { - return 'heroicon-o-cursor-arrow-rays'; - } - - public static function getCategory(): string - { - return 'interactive'; - } - - public static function getContentFormSchema(): array - { - return [ - TextInput::make('label') - ->label(__('layup::widgets.button.button_text')) - ->required() - ->default('Click Me'), - TextInput::make('url') - ->label(__('layup::widgets.button.url')) - ->url(), - Select::make('style') - ->label(__('layup::widgets.button.style')) - ->options(['primary' => __('layup::widgets.button.primary'), - 'secondary' => __('layup::widgets.button.secondary'), - 'outline' => __('layup::widgets.button.outline'), - 'ghost' => __('layup::widgets.button.ghost'), ]) - ->default('primary'), - Select::make('size') - ->label(__('layup::widgets.button.size')) - ->options(['sm' => __('layup::widgets.button.small'), - 'md' => __('layup::widgets.button.medium'), - 'lg' => __('layup::widgets.button.large'), ]) - ->default('md'), - Toggle::make('new_tab') - ->label(__('layup::widgets.button.open_in_new_tab')) - ->default(false), - ColorPicker::make('bg_color') - ->label(__('layup::widgets.button.custom_background_color')), - ColorPicker::make('text_color_override') - ->label(__('layup::widgets.button.custom_text_color')), - ColorPicker::make('hover_bg_color') - ->label(__('layup::widgets.button.hover_background_color')), - ColorPicker::make('hover_text_color') - ->label(__('layup::widgets.button.hover_text_color')), - ]; - } - - public static function getDefaultData(): array - { - return [ - 'label' => 'Click Me', - 'url' => '#', - 'style' => 'primary', - 'size' => 'md', - 'new_tab' => false, - 'bg_color' => null, - 'text_color_override' => null, - 'hover_bg_color' => null, - 'hover_text_color' => null, - ]; - } - - public static function getPreview(array $data): string - { - $label = $data['label'] ?? 'Button'; - $url = $data['url'] ?? '#'; - - return "🔘 {$label}" . ($url !== '#' ? " → {$url}" : ''); - } + use ButtonIdentity; } diff --git a/src/View/Concerns/HasWidgetDefaults.php b/src/View/Concerns/HasWidgetDefaults.php new file mode 100644 index 0000000..200096b --- /dev/null +++ b/src/View/Concerns/HasWidgetDefaults.php @@ -0,0 +1,147 @@ + 60 ? mb_substr($text, 0, 60) . "\u{2026}" : $text; + } + + if (! empty($data['label'])) { + return $data['label']; + } + + if (! empty($data['src'])) { + return "\u{1F5BC} " . basename((string) $data['src']); + } + + return '(empty)'; + } + + public static function prepareForRender(array $data): array + { + return $data; + } + + /** + * @return array + */ + public static function getValidationRules(): array + { + return []; + } + + /** + * @return array + */ + public static function getSearchTerms(): array + { + return []; + } + + public static function isDeprecated(): bool + { + return false; + } + + public static function getDeprecationMessage(): string + { + return ''; + } + + /** + * Called after save. Override to transform or validate data. + * Context is provided when available (page, row/column/widget IDs). + */ + public static function onSave(array $data, ?WidgetContext $context = null): array + { + return $data; + } + + /** + * Called on widget creation. Override for init logic. + * Context is provided when available. + */ + public static function onCreate(array $data, ?WidgetContext $context = null): array + { + return $data; + } + + /** + * Called on widget deletion. Override for cleanup. + * Context is provided when available. + */ + public static function onDelete(array $data, ?WidgetContext $context = null): void + { + // No-op by default + } + + public static function onDuplicate(array $data, ?WidgetContext $context = null): array + { + return $data; + } + + /** + * @return array{js?: array, css?: array} + */ + public static function getAssets(): array + { + return []; + } + + public static function toArray(): array + { + return [ + 'type' => static::getType(), + 'label' => static::getLabel(), + 'icon' => static::getIcon(), + 'category' => static::getCategory(), + 'defaults' => static::getDefaultData(), + 'search_terms' => static::getSearchTerms(), + 'deprecated' => static::isDeprecated(), + 'deprecation_message' => static::getDeprecationMessage(), + ]; + } +} diff --git a/src/View/Concerns/Identity/ButtonIdentity.php b/src/View/Concerns/Identity/ButtonIdentity.php new file mode 100644 index 0000000..b4c74dc --- /dev/null +++ b/src/View/Concerns/Identity/ButtonIdentity.php @@ -0,0 +1,93 @@ +label(__('layup::widgets.button.button_text')) + ->required() + ->default('Click Me'), + TextInput::make('url') + ->label(__('layup::widgets.button.url')) + ->url(), + Select::make('style') + ->label(__('layup::widgets.button.style')) + ->options(['primary' => __('layup::widgets.button.primary'), + 'secondary' => __('layup::widgets.button.secondary'), + 'outline' => __('layup::widgets.button.outline'), + 'ghost' => __('layup::widgets.button.ghost'), ]) + ->default('primary'), + Select::make('size') + ->label(__('layup::widgets.button.size')) + ->options(['sm' => __('layup::widgets.button.small'), + 'md' => __('layup::widgets.button.medium'), + 'lg' => __('layup::widgets.button.large'), ]) + ->default('md'), + Toggle::make('new_tab') + ->label(__('layup::widgets.button.open_in_new_tab')) + ->default(false), + ColorPicker::make('bg_color') + ->label(__('layup::widgets.button.custom_background_color')), + ColorPicker::make('text_color_override') + ->label(__('layup::widgets.button.custom_text_color')), + ColorPicker::make('hover_bg_color') + ->label(__('layup::widgets.button.hover_background_color')), + ColorPicker::make('hover_text_color') + ->label(__('layup::widgets.button.hover_text_color')), + ]; + } + + public static function getDefaultData(): array + { + return [ + 'label' => 'Click Me', + 'url' => '#', + 'style' => 'primary', + 'size' => 'md', + 'new_tab' => false, + 'bg_color' => null, + 'text_color_override' => null, + 'hover_bg_color' => null, + 'hover_text_color' => null, + ]; + } + + public static function getPreview(array $data): string + { + $label = $data['label'] ?? 'Button'; + $url = $data['url'] ?? '#'; + + return "🔘 {$label}" . ($url !== '#' ? " → {$url}" : ''); + } +} diff --git a/src/View/Concerns/Identity/HeadingIdentity.php b/src/View/Concerns/Identity/HeadingIdentity.php new file mode 100644 index 0000000..e9c0ec7 --- /dev/null +++ b/src/View/Concerns/Identity/HeadingIdentity.php @@ -0,0 +1,80 @@ +label(__('layup::widgets.heading.heading_text')) + ->required(), + Select::make('level') + ->label(__('layup::widgets.heading.level')) + ->options(['h1' => __('layup::widgets.heading.h1'), + 'h2' => __('layup::widgets.heading.h2'), + 'h3' => __('layup::widgets.heading.h3'), + 'h4' => __('layup::widgets.heading.h4'), + 'h5' => __('layup::widgets.heading.h5'), + 'h6' => __('layup::widgets.heading.h6'), ]) + ->default('h2'), + TextInput::make('link_url') + ->label(__('layup::widgets.heading.link_url')) + ->url() + ->placeholder(__('layup::widgets.heading.https')) + ->nullable(), + ]; + } + + public static function getDefaultData(): array + { + return [ + 'content' => '', + 'level' => 'h2', + 'link_url' => '', + ]; + } + + public static function getPreview(array $data): string + { + $level = strtoupper($data['level'] ?? 'H2'); + $text = $data['content'] ?? ''; + + return $text ? "[{$level}] {$text}" : '(empty heading)'; + } +} diff --git a/src/View/Concerns/Identity/NewsletterIdentity.php b/src/View/Concerns/Identity/NewsletterIdentity.php new file mode 100644 index 0000000..04b201f --- /dev/null +++ b/src/View/Concerns/Identity/NewsletterIdentity.php @@ -0,0 +1,85 @@ +label(__('layup::widgets.newsletter.heading')) + ->default('Stay in the loop'), + TextInput::make('description') + ->label(__('layup::widgets.newsletter.description')) + ->default('Get the latest updates delivered to your inbox.') + ->nullable(), + TextInput::make('action') + ->label(__('layup::widgets.newsletter.form_action_url')) + ->helperText(__('layup::widgets.newsletter.mailchimp_convertkit_or_custom_endpoint')) + ->required(), + TextInput::make('placeholder') + ->label(__('layup::widgets.newsletter.email_placeholder')) + ->default('Enter your email'), + TextInput::make('submit_text') + ->label(__('layup::widgets.newsletter.button_text')) + ->default('Subscribe'), + TextInput::make('success_message') + ->label(__('layup::widgets.newsletter.success_message')) + ->default("You're in! Check your inbox."), + Select::make('layout') + ->label(__('layup::widgets.newsletter.layout')) + ->options(['inline' => __('layup::widgets.newsletter.inline_side_by_side'), + 'stacked' => __('layup::widgets.newsletter.stacked'), ]) + ->default('inline'), + ColorPicker::make('button_color') + ->label(__('layup::widgets.newsletter.button_color')) + ->default(null), + ]; + } + + public static function getDefaultData(): array + { + return [ + 'heading' => 'Stay in the loop', + 'description' => 'Get the latest updates delivered to your inbox.', + 'action' => '', + 'placeholder' => 'Enter your email', + 'submit_text' => 'Subscribe', + 'success_message' => "You're in! Check your inbox.", + 'layout' => 'inline', + 'button_color' => null, + ]; + } + + public static function getPreview(array $data): string + { + return '📬 Newsletter Signup'; + } +} diff --git a/src/View/Concerns/Identity/NumberCounterIdentity.php b/src/View/Concerns/Identity/NumberCounterIdentity.php new file mode 100644 index 0000000..890a2f6 --- /dev/null +++ b/src/View/Concerns/Identity/NumberCounterIdentity.php @@ -0,0 +1,77 @@ +label(__('layup::widgets.number-counter.number')) + ->numeric() + ->required() + ->default(100), + TextInput::make('prefix') + ->label(__('layup::widgets.number-counter.prefix')) + ->placeholder(__('layup::widgets.number-counter.e_g')) + ->nullable(), + TextInput::make('suffix') + ->label(__('layup::widgets.number-counter.suffix')) + ->placeholder(__('layup::widgets.number-counter.e_g_or')) + ->nullable(), + TextInput::make('title') + ->label(__('layup::widgets.number-counter.title')) + ->nullable(), + Toggle::make('animate') + ->label(__('layup::widgets.number-counter.animate_on_scroll')) + ->default(true), + ]; + } + + public static function getDefaultData(): array + { + return [ + 'number' => 100, + 'prefix' => '', + 'suffix' => '', + 'title' => '', + 'animate' => true, + ]; + } + + public static function getPreview(array $data): string + { + $prefix = $data['prefix'] ?? ''; + $number = $data['number'] ?? 0; + $suffix = $data['suffix'] ?? ''; + $title = $data['title'] ?? ''; + + return "🔢 {$prefix}{$number}{$suffix}" . ($title ? " — {$title}" : ''); + } +} diff --git a/src/View/HeadingWidget.php b/src/View/HeadingWidget.php index 295a79e..23aeb07 100644 --- a/src/View/HeadingWidget.php +++ b/src/View/HeadingWidget.php @@ -4,68 +4,9 @@ namespace Crumbls\Layup\View; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; +use Crumbls\Layup\View\Concerns\Identity\HeadingIdentity; class HeadingWidget extends BaseWidget { - public static function getType(): string - { - return 'heading'; - } - - public static function getLabel(): string - { - return __('layup::widgets.labels.heading'); - } - - public static function getIcon(): string - { - return 'heroicon-o-h1'; - } - - public static function getCategory(): string - { - return 'content'; - } - - public static function getContentFormSchema(): array - { - return [ - TextInput::make('content') - ->label(__('layup::widgets.heading.heading_text')) - ->required(), - Select::make('level') - ->label(__('layup::widgets.heading.level')) - ->options(['h1' => __('layup::widgets.heading.h1'), - 'h2' => __('layup::widgets.heading.h2'), - 'h3' => __('layup::widgets.heading.h3'), - 'h4' => __('layup::widgets.heading.h4'), - 'h5' => __('layup::widgets.heading.h5'), - 'h6' => __('layup::widgets.heading.h6'), ]) - ->default('h2'), - TextInput::make('link_url') - ->label(__('layup::widgets.heading.link_url')) - ->url() - ->placeholder(__('layup::widgets.heading.https')) - ->nullable(), - ]; - } - - public static function getDefaultData(): array - { - return [ - 'content' => '', - 'level' => 'h2', - 'link_url' => '', - ]; - } - - public static function getPreview(array $data): string - { - $level = strtoupper($data['level'] ?? 'H2'); - $text = $data['content'] ?? ''; - - return $text ? "[{$level}] {$text}" : '(empty heading)'; - } + use HeadingIdentity; } diff --git a/src/View/NewsletterWidget.php b/src/View/NewsletterWidget.php index e526c0c..d90602c 100644 --- a/src/View/NewsletterWidget.php +++ b/src/View/NewsletterWidget.php @@ -4,82 +4,9 @@ namespace Crumbls\Layup\View; -use Crumbls\Layup\Forms\Components\ColorPicker; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; +use Crumbls\Layup\View\Concerns\Identity\NewsletterIdentity; class NewsletterWidget extends BaseWidget { - public static function getType(): string - { - return 'newsletter'; - } - - public static function getLabel(): string - { - return __('layup::widgets.labels.newsletter'); - } - - public static function getIcon(): string - { - return 'heroicon-o-envelope-open'; - } - - public static function getCategory(): string - { - return 'interactive'; - } - - public static function getContentFormSchema(): array - { - return [ - TextInput::make('heading') - ->label(__('layup::widgets.newsletter.heading')) - ->default('Stay in the loop'), - TextInput::make('description') - ->label(__('layup::widgets.newsletter.description')) - ->default('Get the latest updates delivered to your inbox.') - ->nullable(), - TextInput::make('action') - ->label(__('layup::widgets.newsletter.form_action_url')) - ->helperText(__('layup::widgets.newsletter.mailchimp_convertkit_or_custom_endpoint')) - ->required(), - TextInput::make('placeholder') - ->label(__('layup::widgets.newsletter.email_placeholder')) - ->default('Enter your email'), - TextInput::make('submit_text') - ->label(__('layup::widgets.newsletter.button_text')) - ->default('Subscribe'), - TextInput::make('success_message') - ->label(__('layup::widgets.newsletter.success_message')) - ->default("You're in! Check your inbox."), - Select::make('layout') - ->label(__('layup::widgets.newsletter.layout')) - ->options(['inline' => __('layup::widgets.newsletter.inline_side_by_side'), - 'stacked' => __('layup::widgets.newsletter.stacked'), ]) - ->default('inline'), - ColorPicker::make('button_color') - ->label(__('layup::widgets.newsletter.button_color')) - ->default(null), - ]; - } - - public static function getDefaultData(): array - { - return [ - 'heading' => 'Stay in the loop', - 'description' => 'Get the latest updates delivered to your inbox.', - 'action' => '', - 'placeholder' => 'Enter your email', - 'submit_text' => 'Subscribe', - 'success_message' => "You're in! Check your inbox.", - 'layout' => 'inline', - 'button_color' => null, - ]; - } - - public static function getPreview(array $data): string - { - return '📬 Newsletter Signup'; - } + use NewsletterIdentity; } diff --git a/src/View/NumberCounterWidget.php b/src/View/NumberCounterWidget.php index 02cd94d..81ad5ca 100644 --- a/src/View/NumberCounterWidget.php +++ b/src/View/NumberCounterWidget.php @@ -4,74 +4,9 @@ namespace Crumbls\Layup\View; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; +use Crumbls\Layup\View\Concerns\Identity\NumberCounterIdentity; class NumberCounterWidget extends BaseWidget { - public static function getType(): string - { - return 'number-counter'; - } - - public static function getLabel(): string - { - return __('layup::widgets.labels.number-counter'); - } - - public static function getIcon(): string - { - return 'heroicon-o-chart-bar'; - } - - public static function getCategory(): string - { - return 'content'; - } - - public static function getContentFormSchema(): array - { - return [ - TextInput::make('number') - ->label(__('layup::widgets.number-counter.number')) - ->numeric() - ->required() - ->default(100), - TextInput::make('prefix') - ->label(__('layup::widgets.number-counter.prefix')) - ->placeholder(__('layup::widgets.number-counter.e_g')) - ->nullable(), - TextInput::make('suffix') - ->label(__('layup::widgets.number-counter.suffix')) - ->placeholder(__('layup::widgets.number-counter.e_g_or')) - ->nullable(), - TextInput::make('title') - ->label(__('layup::widgets.number-counter.title')) - ->nullable(), - Toggle::make('animate') - ->label(__('layup::widgets.number-counter.animate_on_scroll')) - ->default(true), - ]; - } - - public static function getDefaultData(): array - { - return [ - 'number' => 100, - 'prefix' => '', - 'suffix' => '', - 'title' => '', - 'animate' => true, - ]; - } - - public static function getPreview(array $data): string - { - $prefix = $data['prefix'] ?? ''; - $number = $data['number'] ?? 0; - $suffix = $data['suffix'] ?? ''; - $title = $data['title'] ?? ''; - - return "🔢 {$prefix}{$number}{$suffix}" . ($title ? " — {$title}" : ''); - } + use NumberCounterIdentity; } diff --git a/tests/Unit/WidgetDefaultCompletenessTest.php b/tests/Unit/WidgetDefaultCompletenessTest.php index 45eaea0..c4c7540 100644 --- a/tests/Unit/WidgetDefaultCompletenessTest.php +++ b/tests/Unit/WidgetDefaultCompletenessTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Crumbls\Layup\Contracts\Widget; -use Crumbls\Layup\View\BaseWidget; use Filament\Forms\Components\Builder; use Filament\Forms\Components\Repeater; @@ -66,10 +65,6 @@ function discoverShippedWidgets(): array foreach ($files as $file) { $base = basename($file, '.php'); - if ($base === 'BaseWidget') { - continue; - } - $class = 'Crumbls\\Layup\\View\\' . $base; if (! class_exists($class)) { @@ -78,7 +73,7 @@ function discoverShippedWidgets(): array $ref = new ReflectionClass($class); - if ($ref->isAbstract() || ! $ref->isSubclassOf(BaseWidget::class)) { + if ($ref->isAbstract() || ! $ref->implementsInterface(Widget::class)) { continue; } From fe0c16147e599261c070645e692c37f2b0a13303 Mon Sep 17 00:00:00 2001 From: Chase Miller Date: Thu, 30 Apr 2026 19:04:40 -0600 Subject: [PATCH 2/4] Expand Livewire widgets docs, widget contract reference, and CHANGELOG --- CHANGELOG.md | 13 ++ docs/advanced/extending-widgets.md | 2 + docs/advanced/livewire-widgets.md | 300 ++++++++++++++++++++++---- docs/api-reference/widget-contract.md | 83 ++++++- 4 files changed, 352 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47716e4..87e21e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Dual-render widget architecture.** Widgets can now render through either a Blade view component (the default) or a Livewire component, opt-in via a new `BaseLivewireWidget` base class. Same `Widget` contract, same editor experience, same registration -- only the frontend render path differs. + - `Crumbls\Layup\View\BaseLivewireWidget` mounts a Livewire component via ``, passing the widget's `$data` as a prop and recursively-rendered children as the default slot. Children remain polymorphic (Blade, Livewire, or any mix). + - `Crumbls\Layup\View\BaseBladeWidget` is the renamed body of the previous `BaseWidget`. The default Blade rendering path is unchanged. + - `Crumbls\Layup\View\BaseWidget` remains as an abstract alias of `BaseBladeWidget` so every existing widget, stub, doc, and downstream package continues to work without modification. + - `Crumbls\Layup\View\Concerns\HasWidgetDefaults` trait extracts the static metadata defaults shared between both bases. + - `Crumbls\Layup\View\Concerns\Identity\` ships per-widget identity traits (`HeadingIdentity`, `ButtonIdentity`, `NumberCounterIdentity`, `NewsletterIdentity`) so a downstream package can ship a Livewire flavour of those built-ins without redeclaring form schema, defaults, or preview. + - `BaseView::renderChildrenToHtml()` recursively renders all children to a single HTML string. Used internally by `BaseLivewireWidget` for the slot path and available to any custom render path. + - `livewire/livewire` is a soft dependency (declared in `composer.json` `suggest`). The package loads cleanly without it; install it only when actually using `BaseLivewireWidget`. + - New `docs/advanced/livewire-widgets.md` covering when to choose each base, the render flow, the slot-based children model, identity-trait swapping, asset pipeline, performance, testing, and security considerations. - New `` Blade component. Drop it once into your layout's `` and Layup emits the full meta block (description, OG, Twitter, canonical, robots, article timestamps, JSON-LD) on every layup-rendered request. On non-layup routes the component renders nothing, so it's safe in shared layouts. - `Crumbls\Layup\Http\Controllers\AbstractController` now shares the resolved record as `layupPage` in view scope, so the component resolves the page automatically on any host layout. Custom controllers can pass `:page="$myPage"` explicitly. - Page Settings modal exposes Meta Description (160-char) and a "Hide from search engines" toggle (`meta.noindex`). @@ -18,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `docs/advanced/seo-meta.md` documents the component, per-page settings, and config knobs. ### Changed +- `BaseView::render()` return type widened from `Illuminate\Contracts\View\View` to `View|Htmlable|string`. Existing Blade widgets are unaffected (returning a `View` still satisfies the wider type via covariance); the wider type lets `BaseLivewireWidget::render()` return a `string` from `Blade::render()`. Subclasses that previously declared `: View` continue to work. +- Internal type checks switched from `instanceof BaseWidget` / `is_subclass_of(..., BaseWidget::class)` to interface-based `instanceof Widget` / `is_subclass_of(..., Widget::class)` in `Crumbls\Layup\Support\Concerns\RegistersWidgets`, `Crumbls\Layup\Support\LayupContent`, `Crumbls\Layup\Testing\LayupAssertions`, and `tests/Unit/WidgetDefaultCompletenessTest.php`. Downstream code that introspects widgets at runtime should follow suit if it intends to support Livewire widgets, but no immediate change is required for code that only sees the Blade lineage. +- `Crumbls\Layup\Testing\LayupAssertions::assertWidgetRenders()` and `assertWidgetRendersWithDefaults()` now accept any `View|Htmlable|string` from `render()` (a new `renderToString()` helper coerces the result). Tests that previously called `$widget->render()->render()` directly continue to work because Blade widgets still return a `View`. +- Four built-in widgets (`HeadingWidget`, `ButtonWidget`, `NumberCounterWidget`, `NewsletterWidget`) refactored to consume identity traits. Public surface unchanged -- all static methods resolve identically. The remaining ~95 built-in widgets are untouched. - SEO meta is now emitted on every published page. Previously the entire block was gated on `meta.description` being set, so pages without a description silently dropped all SEO meta. - Twitter card type is now `summary_large_image` when a featured image is present, `summary` otherwise. Was hardcoded to `summary`. - `og:type` is now `article` for pages with `published_at`, `website` otherwise. Was always implicitly `website`. diff --git a/docs/advanced/extending-widgets.md b/docs/advanced/extending-widgets.md index 599855d..9cd58de 100644 --- a/docs/advanced/extending-widgets.md +++ b/docs/advanced/extending-widgets.md @@ -48,6 +48,8 @@ class CustomHeadingWidget extends HeadingWidget To completely replace a built-in widget, use `withoutWidgets()` on the plugin and register your replacement: +> **Swapping render technology only?** If you want to keep the editor experience identical (same form, same defaults, same preview) but render through a Livewire component instead of a Blade view, see [Livewire-rendered widgets](livewire-widgets.md). The four built-in widgets that ship identity traits (`HeadingWidget`, `ButtonWidget`, `NumberCounterWidget`, `NewsletterWidget`) can be swapped without redeclaring any metadata. + ```php use Crumbls\Layup\LayupPlugin; use Crumbls\Layup\View\HeadingWidget; diff --git a/docs/advanced/livewire-widgets.md b/docs/advanced/livewire-widgets.md index 5709600..8ed0b31 100644 --- a/docs/advanced/livewire-widgets.md +++ b/docs/advanced/livewire-widgets.md @@ -3,25 +3,62 @@ title: Livewire-rendered widgets weight: 4 --- -By default, Layup widgets render through Blade view components. Frontend output for `HeadingWidget` resolves to `layup::components.heading`, the data array is passed as `$data`, and the result is plain HTML. +By default, Layup widgets render through Blade view components. Frontend output for `HeadingWidget` resolves to `layup::components.heading`, the data array is passed as `$data`, and the result is plain HTML. This is the right model for static content -- headings, images, text, buttons -- where there is nothing to react to and no server-side state to maintain. -For widgets that need state, server actions, or reactive UI -- counters, contact forms, search boxes, anything stateful -- you can render through a Livewire component instead. The Widget contract is unchanged: form schema, defaults, lifecycle hooks, preview, and registration all work identically. Only the frontend render path differs. +For widgets that need state, server actions, or reactive UI -- counters, contact forms, search-as-you-type, polling notifiers, anything stateful -- you can render through a Livewire component instead. The Widget contract is unchanged: form schema, defaults, lifecycle hooks, preview, and registration all work identically. Only the frontend render path differs. + +This guide covers when to choose which base, the architecture that makes both bases interchangeable, the slot-based children model, the identity-trait pattern that lets a downstream package swap a built-in widget's render path without redeclaring its editor experience, and the operational considerations -- assets, performance, testing, security -- that come with each path. + +## Architecture overview + +Three concerns live in three orthogonal places: + +1. **Identity & editor experience** -- the static metadata that defines a widget: type, label, icon, category, form schema, default data, preview, validation rules, search terms, lifecycle hooks. This is the contract the editor and registry care about. +2. **Rendering technology** -- how the widget produces HTML for the frontend. Either a Blade view component (the default) or a Livewire component (opt-in). +3. **Data plumbing** -- the runtime mechanics of `$data`, `$children`, position-in-parent, and recursive child rendering. + +Identity lives in either the widget class itself or, for widgets meant to ship in multiple render flavours, an identity trait. Rendering tech lives in the base class (`BaseBladeWidget` or `BaseLivewireWidget`). Data plumbing lives in `BaseView`, which both bases extend. The `Widget` contract (`Crumbls\Layup\Contracts\Widget`) is the interface both bases implement, so anywhere code accepts "a widget" it accepts either flavour. + +``` +Crumbls\Layup\Contracts\Widget (interface, rendering-agnostic) + | + v +Crumbls\Layup\View\BaseView (data plumbing: $data, $children) + | + +-------+--------+ + | | +BaseBladeWidget BaseLivewireWidget + | | + (your widget) (your widget) + +BaseWidget = abstract alias for BaseBladeWidget (back-compat shim). +``` + +The editor-side machinery -- form rendering, builder canvas previews, the WidgetRegistry, exports/imports, content serialization, `assertWidgetRenders()` in tests -- treats both bases identically because all checks go through the `Widget` interface, not a specific base class. ## When to choose which -| Concern | Use `BaseWidget` (Blade) | Use `BaseLivewireWidget` | +| Concern | `BaseWidget` (Blade, default) | `BaseLivewireWidget` | |---|---|---| -| Static content (heading, image, text) | Yes | No | -| Server-rendered HTML, no interactivity | Yes | No | -| Form submissions, polling, server-side state | Possible via Alpine + endpoints | Yes -- this is what Livewire is for | -| Live counters, tabs with persistent state, search-as-you-type | Possible but awkward | Yes | -| Rows, columns, sections (structural) | Required | Not supported -- structural nodes recurse over child objects | +| Static content (heading, image, text, divider) | Yes | Overkill | +| Server-rendered HTML, no interactivity | Yes | Overkill | +| Alpine-only interactivity (toggles, modals, animations) | Yes | Overkill | +| Form submissions handled server-side | Awkward (need separate endpoint) | Yes | +| Server-side state that survives across actions (counters, multi-step flows) | Not feasible | Yes | +| Polling for data (live ticker, notification feed) | Possible via `wire:poll` only inside a Livewire component | Yes | +| Search-as-you-type with debounced server query | Yes | Yes | +| Auth-aware UI (logged-in user data) | Yes (server-rendered) | Yes | +| Rows, columns, sections (structural containers) | Required | Not supported | + +**Use the default Blade base unless you have a concrete reason to upgrade.** Livewire adds a per-widget hydration cost on every page render, an extra round-trip on every action, and an extra dependency. If a widget has no state and no server actions, none of that pays for itself. -Container widgets (Section, Row, Column) must stay Blade-rendered because they recurse through `$children` as PHP objects. Leaf widgets are free to choose. +Container widgets (`Section`, `Row`, `Column`) are not eligible for Livewire rendering because they recurse over `$children` as PHP `BaseView` objects -- Livewire props would have to serialise that object graph across requests, which is not a thing Livewire props do. Leaf widgets (any `BaseWidget` subclass that doesn't accept children) are free to choose. See [Children inside a Livewire widget](#children-inside-a-livewire-widget) below for how children flow through Livewire-rendered widgets that *do* have children -- the slot pattern handles it cleanly. -## The shape of a Livewire widget +## Anatomy of a Livewire-rendered widget -A Livewire-rendered widget looks like a normal Layup widget plus a `getLivewireComponent()` method: +A Livewire widget is two pieces: the **widget class** (Layup-side, owns editor identity) and the **Livewire component** (Livewire-side, owns runtime behaviour). The widget class declares the Livewire component name via `getLivewireComponent()`; the rest of the class looks identical to any Blade widget. + +### The widget class ```php required(), TextInput::make('start')->numeric()->default(0), + Toggle::make('animate')->default(true), ]; } public static function getDefaultData(): array { - return ['label' => 'Clicks', 'start' => 0]; + return [ + 'label' => 'Clicks', + 'start' => 0, + 'animate' => true, + ]; + } + + public static function getPreview(array $data): string + { + return "Counter: {$data['label']}"; } } ``` -The Livewire component itself is whatever you'd write normally. It receives the widget data as a `$data` prop and a `$slot` containing the pre-rendered children HTML: +The shape is the same as any Layup widget: required `getType()` and `getLabel()`, optional metadata overrides, `getContentFormSchema()` for the editor form, `getDefaultData()` for new instances. The one Livewire-specific addition is `getLivewireComponent()`, which returns either a Livewire component alias (kebab-case) or a fully-qualified class name. + +### The Livewire component + +The Livewire component is whatever you'd write normally. It receives the widget data as a `$data` prop (typed as `array`) and a `$slot` containing the pre-rendered children HTML. ```php count++; } + public function reset(): void + { + $this->count = (int) ($this->data['start'] ?? 0); + } + public function render() { return view('livewire.live-counter'); @@ -101,14 +168,20 @@ class LiveCounter extends Component ```blade {{-- resources/views/livewire/live-counter.blade.php --}} -
-

{{ $data['label'] }}: {{ $count }}

- +
+

+ {{ $data['label'] }}: {{ $count }} +

+
+ + +
+ {{-- Children, if any. Static unless replaced by another Livewire flow. --}} {{ $slot }}
``` -Register the widget the same way as any other: +Register the widget like any other: ```php // config/layup.php @@ -118,17 +191,68 @@ Register the widget the same way as any other: ], ``` +Or programmatically in a service provider: + +```php +app(\Crumbls\Layup\Support\WidgetRegistry::class) + ->register(\App\Layup\Widgets\LiveCounterWidget::class); +``` + +That's the full integration. The widget shows up in the editor with its form schema, the editor saves data into the page JSON the same way it always has, and on the frontend the page renders a `` tag with the data prop populated. Livewire takes over from there. + +## How the render flow works + +When the frontend renders a page, Layup walks the content tree section by section. Inside a section, rows render their columns, columns render their widgets. Each widget's `render()` method is called and its output is echoed via `{!! ... !!}`. + +For a Blade-rendered widget (`BaseBladeWidget::render()`), this returns a Blade `View` instance, which stringifies to HTML. For a Livewire-rendered widget (`BaseLivewireWidget::render()`), this returns a `string` produced by `Blade::render('{!! $childrenHtml !!}', [...])`. Both results are valid output for the call site -- the parent template doesn't care which path produced them. + +The `` tag is the standard Livewire 3 mechanism for mounting a component when the name is determined at runtime. The component name comes from `static::getLivewireComponent()`, the data prop comes from the widget's `$this->data`, and the slot content comes from `BaseView::renderChildrenToHtml()` -- which recursively calls `render()` on each child and concatenates the results. + +A Livewire `wire:key` is set automatically based on the widget type and `spl_object_id($this)`. This is unique within a single request, which is enough for Livewire to distinguish sibling widgets of the same type. It is **not** stable across requests, so if your widget needs persistent identity across re-renders (rare) you'll want to override `render()` and supply your own key derived from the widget's persisted data. + ## Children inside a Livewire widget -`BaseLivewireWidget::render()` recursively renders the widget's children to a single HTML string (`renderChildrenToHtml()`) and passes the result as the Livewire slot. Inside the Livewire view, `{{ $slot }}` emits children wherever they belong. +`BaseLivewireWidget::render()` recursively renders the widget's children to a single HTML string and passes the result as the Livewire slot. Inside the Livewire component's view, `{{ $slot }}` emits children wherever they belong. -Children remain polymorphic: a child of a Livewire widget can be a Blade widget, another Livewire widget, or any mix. Each child manages its own rendering independently. +```blade +{{-- A Livewire-rendered tabs widget --}} +
+ +
+ {{ $slot }} +
+
+``` + +Children remain polymorphic. A Livewire-rendered widget can contain Blade-rendered children, other Livewire-rendered children, or any mix. Each child manages its own rendering independently. Nested Livewire components mount with their own `wire:id` and rehydrate independently of the parent. + +When the parent Livewire component re-renders in response to its own action (`wire:click`, `wire:submit`, etc.), the slot content is preserved by Livewire -- children are not re-executed on the server. This is the right default: the structural data in children belongs to the persisted page content, not to the parent's reactive state. If you need children to react to the parent's state, use Livewire events to talk between components rather than expecting the slot to recompute. + +## Identity traits: shipping multiple flavours of the same widget + +Layup ships identity traits in `Crumbls\Layup\View\Concerns\Identity\` for select widgets. An identity trait holds the static metadata for a widget -- type, label, icon, category, form schema, defaults, preview -- with no rendering logic. The traits exist so a downstream package can implement a Livewire flavour of the same widget without redeclaring the editor experience. -When the parent Livewire component re-renders (in response to a `wire:click`, etc.), the slot content is preserved -- children are not re-executed on the server. This is the right default: structural data in children is part of the page, not the parent's state. +Built-in widgets that already use identity traits: -## Swapping a built-in widget for a Livewire flavour +- `HeadingWidget` -- `HeadingIdentity` +- `ButtonWidget` -- `ButtonIdentity` +- `NumberCounterWidget` -- `NumberCounterIdentity` +- `NewsletterWidget` -- `NewsletterIdentity` -Layup ships identity traits for several widgets so a downstream package can swap rendering without redeclaring the editor experience. For example: +Each of those widget classes is now a four-line shell. For example: + +```php +class NewsletterWidget extends BaseWidget +{ + use NewsletterIdentity; +} +``` + +A downstream package can ship a Livewire flavour by extending `BaseLivewireWidget` and pulling in the same trait: ```php register(\MyPackage\Widgets\NewsletterLivewireWidget::class); ``` -`WidgetRegistry::register()` logs a warning when overriding an existing type so the swap is visible in logs. +`WidgetRegistry::register()` logs a warning when overriding an existing type so the swap is visible in logs: + +``` +Layup: Widget type 'newsletter' already registered by ... Overriding with ... +``` + +Adding identity traits to your own widgets is optional. Use one only when you intend to ship multiple render flavours of the same widget. For one-off widgets, define the static methods directly on the class -- it's less ceremony. + +## Migrating an existing custom widget + +If you already have a Blade-rendered custom widget and want to add a Livewire flavour for an interactive variant: -## Identity traits +1. Extract the widget's static metadata (everything from `getType()` through `getPreview()`, plus any custom `getValidationRules()`, `getSearchTerms()`, lifecycle hooks) into a trait. Keep the trait in `App\Layup\Widgets\Concerns\` or wherever fits your namespace. +2. Replace those methods on the original widget class with `use FooIdentity;`. Run your tests -- they should all still pass; nothing about the public surface has changed. +3. Create a new Livewire-flavoured widget class extending `BaseLivewireWidget` that uses the same trait and adds `getLivewireComponent()`. +4. Build the Livewire component (separate file, standard Livewire 3 component class plus a Blade view). +5. Decide whether the Livewire flavour replaces the Blade flavour everywhere or coexists with it. If replacing, register the Livewire flavour with `WidgetRegistry` -- the same `getType()` will trigger the override warning. If coexisting, give the Livewire flavour a different `getType()` (e.g. `newsletter-live`) so both show up in the editor as distinct widgets. -The traits that ship in `Crumbls\Layup\View\Concerns\Identity\` hold the static metadata for a widget -- type, label, icon, category, form schema, defaults, preview. They have no rendering logic. Using one in a Blade-rendered widget: +## Asset pipeline + +Layup widgets can declare JavaScript and CSS dependencies via `getAssets()`. Those declarations are picked up at registry time and injected into the page when the widget is present. For example: ```php -class HeadingWidget extends BaseWidget +public static function getAssets(): array { - use HeadingIdentity; + return [ + 'js' => ['/vendor/my-package/counter.js'], + 'css' => ['/vendor/my-package/counter.css'], + ]; } ``` -The same trait is what a Livewire flavour uses: +For a Livewire widget, you have two asset pipelines in play: Layup's `getAssets()` and Livewire's own asset injection (Livewire styles and scripts). Recommended approach: + +- Use Livewire's pipeline for any JS or CSS that ships with the Livewire component itself. Livewire 3's `@livewireStyles` / `@livewireScripts` are already in place if the host app uses Livewire normally, and `@assets` blocks inside Livewire component views work as expected. +- Use Layup's `getAssets()` for assets that should load only when the widget is present on a page, regardless of Livewire. For pure-Livewire widgets you can usually return `[]`. + +There's no conflict between the two -- they target different injection points. Just don't double-register the same file in both. + +## Editor preview vs. frontend render + +The Livewire path runs only on the frontend. In the builder canvas, widget previews come from the static `getPreview(array $data)` method, which returns a plain string -- Livewire is not involved. This is intentional: the editor canvas would have to mount and tear down a Livewire instance per widget per state change, which would make the builder slow and fragile. A static preview is enough for the editor. + +If you want a richer-looking preview in the canvas, override `getPreview()` to return a more detailed string or use a custom canvas preview view. The Livewire component is for the published page only. + +## Performance considerations + +Each Livewire-rendered widget on a page incurs: + +- Server-side: one `Blade::render()` call to mount the component, one `mount()` invocation, one component `render()` invocation. +- Client-side: a `wire:id` wrapper and Livewire's morphdom diffing on every action. +- Network: every action that touches the component sends a request to Livewire's backend endpoint. + +For pages with one or two interactive widgets among mostly static content, this is unnoticeable. For pages where every widget is Livewire, the overhead adds up -- both initial render time and JS bundle weight. The default Blade base is the right choice for static widgets because it has none of these costs. + +If you find yourself reaching for Livewire on every widget, consider whether the page-builder model is the right tool for that page. Genuinely application-shaped UIs (multi-step wizards, dashboards, complex forms) are usually better served by hand-built Livewire components or full SPAs that consume Layup data via API rather than by rendering through the page builder. + +## Testing + +Layup's test helpers in `Crumbls\Layup\Testing\LayupAssertions` work for both bases. The two assertions that exercise rendering -- `assertWidgetRenders($type, $data)` and `assertWidgetRendersWithDefaults($class)` -- coerce whatever `render()` returns (View, Htmlable, or string) into a string before asserting non-empty output. So: ```php -class HeadingLivewireWidget extends BaseLivewireWidget +$this->assertWidgetRenders('live-counter'); +$this->assertWidgetRendersWithDefaults(LiveCounterWidget::class); +``` + +both work. + +For deeper Livewire-specific testing (actions, state transitions, events), use Livewire's own test helpers (`Livewire::test('live-counter', ['data' => [...]])` and friends) and write the tests against the Livewire component class, not the Layup widget class. The widget class is just a thin adapter -- it has no behaviour to test beyond its static metadata, which `assertWidgetContractValid($class)` already covers. + +When asserting that a Livewire-rendered page renders correctly end-to-end, install `livewire/livewire` in your test environment (it ships its own service provider that registers required routes and middleware). Without it, the page render will fail on the `` tag. + +## Security + +Two things to watch for: + +1. **Data prop content**: the widget's saved `$data` array is passed verbatim to the Livewire component's `mount()`. If editors have been allowed to put HTML in any field, that HTML is the Livewire component's responsibility to escape. `{{ $data['label'] }}` in Blade auto-escapes; `{!! $data['raw_html'] !!}` does not. Treat editor-provided strings as untrusted unless your editor explicitly sanitises them. +2. **Action authorisation**: any `wire:click` or `wire:submit` handler on a Livewire widget can be invoked by anyone who can load the page that contains the widget. If the action mutates state or performs work that should be authenticated, gate it inside the Livewire component (`if (! Auth::check()) { abort(403); }`). The page builder doesn't provide implicit authorisation -- the page is public. + +## Installation + +`livewire/livewire` is a soft dependency declared in Layup's `composer.json` `suggest` block. Layup loads cleanly without it -- you only need to install it if you actually use `BaseLivewireWidget`: + +```bash +composer require livewire/livewire +``` + +Once installed, Livewire's standard setup applies: the package registers its own service provider, routes, and middleware via Laravel's auto-discovery. Add `@livewireScripts` to your layout if Livewire didn't auto-inject them. + +Calling `render()` on a `BaseLivewireWidget` instance without Livewire installed will fail at the Blade compile step (the `` tag is unrecognised). The Layup package itself, including its config, migrations, commands, and any Blade widgets you register, continues to work normally regardless of whether Livewire is present. + +## API reference + +### `BaseLivewireWidget` + +```php +namespace Crumbls\Layup\View; + +abstract class BaseLivewireWidget extends BaseView implements Widget { - use HeadingIdentity; + use HasWidgetDefaults; - public static function getLivewireComponent(): string - { - return 'layup-livewire.heading'; - } + abstract public static function getType(): string; + abstract public static function getLabel(): string; + abstract public static function getLivewireComponent(): string; + + public static function getContentFormSchema(): array; + public function render(): string; } ``` -Adding identity traits to your own widgets is optional -- it only matters if you intend to ship multiple render flavours of the same widget. For one-off widgets, define the static methods directly on the class. +`getLivewireComponent()` is the only Livewire-specific method. It returns either a Livewire alias (`'live-counter'`) or a fully-qualified class name (`\App\Livewire\LiveCounter::class`). Either form is accepted by ``. -## Installation note +`render()` is the only method that differs from `BaseBladeWidget`. It always returns a `string`. Override it only if you need a non-default `wire:key` strategy or want to add prop bindings beyond `data` and the default slot. -`livewire/livewire` is a soft dependency. Layup loads cleanly without it. Install it only if you actually use `BaseLivewireWidget`: +### `HasWidgetDefaults` trait -```bash -composer require livewire/livewire +Holds the default implementations of every `Widget` interface method that is not widget-specific: `getIcon()`, `getCategory()`, `getDefaultData()`, `getPreview()`, `prepareForRender()`, `getValidationRules()`, `getSearchTerms()`, `isDeprecated()`, `getDeprecationMessage()`, `onSave()`, `onCreate()`, `onDelete()`, `onDuplicate()`, `getAssets()`, `toArray()`. Used by both `BaseBladeWidget` and `BaseLivewireWidget`. You will not normally use this trait directly -- it's an implementation detail of the bases. + +### `BaseView::renderChildrenToHtml()` + +```php +public function renderChildrenToHtml(): string ``` -Calling `render()` on a `BaseLivewireWidget` instance without Livewire installed will fail at the Blade compile step. +Recursively renders all of this view's children to a single HTML string. Used by `BaseLivewireWidget::render()` to produce the slot content. Available on every `BaseView` subclass, so you can call it from a custom render path if you build one. + +### `Widget` interface vs. `BaseWidget` class + +When you write code that inspects widgets at runtime (custom registries, content walkers, exporters), prefer `instanceof Widget` over `instanceof BaseWidget`. The interface matches both `BaseBladeWidget` subclasses and `BaseLivewireWidget` subclasses; the class only matches the Blade lineage. Layup's own internals were updated for this in the same release that introduced `BaseLivewireWidget`. diff --git a/docs/api-reference/widget-contract.md b/docs/api-reference/widget-contract.md index 84af765..10f4771 100644 --- a/docs/api-reference/widget-contract.md +++ b/docs/api-reference/widget-contract.md @@ -3,7 +3,12 @@ title: Widget Contract weight: 1 --- -All widgets implement `Crumbls\Layup\Contracts\Widget`. This interface defines the full API that the builder, registry, and renderer depend on. +All widgets implement `Crumbls\Layup\Contracts\Widget`. This interface defines the full API that the builder, registry, and renderer depend on. The interface is rendering-agnostic -- it specifies what a widget *is* and how it's *edited*, not how it produces HTML on the frontend. Two base classes implement the interface in concrete form: + +- `Crumbls\Layup\View\BaseBladeWidget` -- renders through a Blade view component (the default). +- `Crumbls\Layup\View\BaseLivewireWidget` -- renders through a Livewire component (opt-in, requires `livewire/livewire`). + +`Crumbls\Layup\View\BaseWidget` is an abstract alias for `BaseBladeWidget` retained for backwards compatibility. New code may extend either base directly. See [Livewire-rendered widgets](../advanced/livewire-widgets.md) for when each base applies. ## Interface methods @@ -46,9 +51,11 @@ interface Widget } ``` -## BaseWidget +The interface deliberately omits `render()`. Each base class adds it with the appropriate return type (`View|Htmlable|string` for Blade, `string` for Livewire). The frontend call site stringifies whatever comes back, so any compliant return value works. + +## BaseWidget / BaseBladeWidget -`Crumbls\Layup\View\BaseWidget` provides sensible defaults for every method. Custom widgets should extend `BaseWidget` and override only what they need. +`Crumbls\Layup\View\BaseBladeWidget` provides the default Blade rendering path. Custom widgets may extend it directly, or extend `BaseWidget` (an abstract alias retained for backwards compatibility -- existing widgets and stubs need no changes). **Minimum required overrides:** @@ -61,13 +68,68 @@ interface Widget - `getDefaultData()` -- initial widget data - `getPreview()` -- canvas preview text +**Render resolution:** + +- Default: `view('layup::components.{type}')`. The `{type}` segment comes from `getType()`. +- Override `getViewName()` to point at a different view name. + +## BaseLivewireWidget + +`Crumbls\Layup\View\BaseLivewireWidget` renders through a Livewire component instead of a Blade view component. It implements the same `Widget` contract as `BaseBladeWidget` and shares the same metadata defaults via the `HasWidgetDefaults` trait. + +**Minimum required overrides:** + +- `getType()` -- unique string identifier +- `getLabel()` -- display name +- `getLivewireComponent()` -- Livewire alias (kebab-case) or fully-qualified class name + +**Render mechanism:** + +The base mounts the named Livewire component via ``, passing the widget's `$data` as a `data` prop and the recursively-rendered children as the default slot. See [Livewire-rendered widgets](../advanced/livewire-widgets.md) for the full architecture, children-handling model, and migration patterns. + +## HasWidgetDefaults trait + +`Crumbls\Layup\View\Concerns\HasWidgetDefaults` holds the default implementations of every `Widget` interface method that doesn't depend on rendering tech: `getIcon()`, `getCategory()`, `getDefaultData()`, `getPreview()`, `prepareForRender()`, `getValidationRules()`, `getSearchTerms()`, `isDeprecated()`, `getDeprecationMessage()`, `onSave()`, `onCreate()`, `onDelete()`, `onDuplicate()`, `getAssets()`, `toArray()`. Used by both `BaseBladeWidget` and `BaseLivewireWidget`. You will not normally use this trait directly -- it's an implementation detail. + +## Identity traits + +`Crumbls\Layup\View\Concerns\Identity\` ships per-widget identity traits for select widgets that may have multiple render flavours. Each trait holds the static metadata for a widget -- type, label, icon, category, form schema, defaults, preview -- with no rendering logic. The traits are consumed by both the built-in Blade widget class and any downstream Livewire flavour: + +```php +class HeadingWidget extends BaseWidget +{ + use HeadingIdentity; // sets type, label, icon, category, form, defaults, preview +} + +// Downstream package, same trait, different render path: +class HeadingLivewireWidget extends BaseLivewireWidget +{ + use HeadingIdentity; + + public static function getLivewireComponent(): string + { + return 'my-package.heading'; + } +} +``` + +Identity traits currently shipped: + +- `HeadingIdentity` +- `ButtonIdentity` +- `NumberCounterIdentity` +- `NewsletterIdentity` + +For one-off widgets that will only ever ship in one render flavour, define the static methods directly on the widget class -- the trait pattern is overkill. + ## BaseView -`Crumbls\Layup\View\BaseView` is the parent of `BaseWidget`, `Row`, `Column`, and `Section`. It provides: +`Crumbls\Layup\View\BaseView` is the parent of `BaseBladeWidget`, `BaseLivewireWidget`, `Row`, `Column`, and `Section`. It provides: - The three-tab form structure (Content, Design, Advanced) - Shared Design tab fields (colors, borders, spacing, shadows, opacity) - Shared Advanced tab fields (ID, classes, animations, visibility) +- `$data` and `$children` storage with fluent constructors and child-management methods - Static helper methods: ```php @@ -80,3 +142,16 @@ BaseView::buildInlineStyles($data); // Build Alpine.js animation attributes BaseView::animationAttributes($data); ``` + +- Instance helper used by Livewire-rendered widgets and any custom render path: + +```php +// Recursively render children to a single HTML string +$widget->renderChildrenToHtml(); +``` + +- The abstract `render()` method whose return type is `View|Htmlable|string`. Subclasses may narrow the return type (Section/Row/Column return `View`; `BaseBladeWidget` returns `View|Htmlable|string`; `BaseLivewireWidget` returns `string`). The wide parent type lets both rendering paths satisfy the contract while preserving covariance. + +## Type-checking widgets at runtime + +When code inspects widgets at runtime (custom registries, content walkers, exporters, test assertions), prefer `instanceof Widget` (the interface) over `instanceof BaseWidget` (a class). The interface matches both Blade and Livewire bases; the class only matches the Blade lineage. Layup's own internals (`RegistersWidgets`, `LayupContent`, `LayupAssertions`, `WidgetDefaultCompletenessTest`) were switched to interface-based checks alongside the introduction of `BaseLivewireWidget` so that all rendering paths are equally discoverable. From f454d284da678bbe3f221d2ed8ce46319a62a01f Mon Sep 17 00:00:00 2001 From: Chase Miller Date: Thu, 30 Apr 2026 19:19:52 -0600 Subject: [PATCH 3/4] Add test coverage for the dual-render widget refactor The first commit shipped BaseLivewireWidget, HasWidgetDefaults, four identity traits, and switched internal type checks to the Widget interface -- all without tests. Closing the gap. - tests/Unit/BaseLivewireWidgetTest.php: contract conformance, Widget interface polymorphism, render() output structure including pre-rendered children in the slot, registry registration, LayupContent serialization, HasWidgetDefaults defaults, lifecycle pass-through. - tests/Unit/IdentityTraitCompositionTest.php: pins the load-bearing refactor claim that "same identity trait + different base = identical metadata" by comparing every static method (type, label, icon, category, defaults, preview, content form schema field names, toArray()) between the four shipped Blade widgets and Livewire-flavour fixtures using the same trait. - tests/Unit/RenderChildrenToHtmlTest.php: exercises BaseView's new child-rendering helper across all three legal render() return types (View | Htmlable | string) including a heterogeneous mix. - tests/TestCase.php: register Livewire's service provider so render tests can mount real components. Filament normally registers it transitively but the Testbench environment only loads what we ask for. 37 new tests, 85 new assertions. Full suite: 1167 passed. --- tests/TestCase.php | 2 + tests/Unit/BaseLivewireWidgetTest.php | 172 ++++++++++++++++++++ tests/Unit/IdentityTraitCompositionTest.php | 117 +++++++++++++ tests/Unit/RenderChildrenToHtmlTest.php | 94 +++++++++++ tests/views/render-child-fixture.blade.php | 1 + 5 files changed, 386 insertions(+) create mode 100644 tests/Unit/BaseLivewireWidgetTest.php create mode 100644 tests/Unit/IdentityTraitCompositionTest.php create mode 100644 tests/Unit/RenderChildrenToHtmlTest.php create mode 100644 tests/views/render-child-fixture.blade.php diff --git a/tests/TestCase.php b/tests/TestCase.php index 3dd3d89..60ec194 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,7 @@ namespace Crumbls\Layup\Tests; use Crumbls\Layup\LayupServiceProvider; +use Livewire\LivewireServiceProvider; use Orchestra\Testbench\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase @@ -12,6 +13,7 @@ abstract class TestCase extends BaseTestCase protected function getPackageProviders($app): array { return [ + LivewireServiceProvider::class, LayupServiceProvider::class, ]; } diff --git a/tests/Unit/BaseLivewireWidgetTest.php b/tests/Unit/BaseLivewireWidgetTest.php new file mode 100644 index 0000000..02180a8 --- /dev/null +++ b/tests/Unit/BaseLivewireWidgetTest.php @@ -0,0 +1,172 @@ + 'hello']; + } + + public static function getPreview(array $data): string + { + return $data['message'] ?? ''; + } +} + +/** + * Real Livewire component the fake widget points at. Trivial render so we + * can exercise Layup's Blade::render('') path + * end-to-end without depending on any other view files. + */ +class FakeLivewireComponent extends LivewireComponent +{ + public array $data = []; + + public function mount(array $data = []): void + { + $this->data = $data; + } + + public function render() + { + return <<<'BLADE' +
{{ $data['message'] ?? '' }}{{ $slot ?? '' }}
+ BLADE; + } +} + +beforeEach(function (): void { + Livewire::component('fake-livewire-widget', FakeLivewireComponent::class); +}); + +it('implements the Widget contract', function (): void { + expect(is_subclass_of(FakeLivewireWidget::class, BaseLivewireWidget::class))->toBeTrue(); + expect((new ReflectionClass(FakeLivewireWidget::class))->implementsInterface(Widget::class)) + ->toBeTrue(); +}); + +it('is recognised by instanceof Widget but not by instanceof BaseWidget', function (): void { + $widget = FakeLivewireWidget::make(['message' => 'hi']); + + // The interface check is the load-bearing polymorphism for RegistersWidgets, + // LayupContent, and LayupAssertions. It must accept Livewire widgets. + expect($widget)->toBeInstanceOf(Widget::class); + expect($widget)->toBeInstanceOf(BaseView::class); + + // BaseWidget is the Blade lineage. Livewire widgets must NOT match it - + // they have a different render path, and code that key off of "is this a + // Blade widget?" should reject them. + expect($widget)->not->toBeInstanceOf(BaseWidget::class); +}); + +it('renders a Livewire-mounted output containing the configured component', function (): void { + $widget = FakeLivewireWidget::make(['message' => 'rendered-payload']); + + $html = $widget->render(); + + expect($html)->toBeString(); + // Livewire 4 wraps the mount with its own attributes; the rendered slot + // content from FakeLivewireComponent must appear. + expect($html)->toContain('data-fake-livewire'); + expect($html)->toContain('rendered-payload'); +}); + +it('includes pre-rendered children HTML in the Livewire slot', function (): void { + $child = FakeLivewireWidget::make(['message' => 'child-payload']); + $parent = FakeLivewireWidget::make(['message' => 'parent-payload'], [$child]); + + $html = $parent->render(); + + expect($html)->toContain('parent-payload'); + expect($html)->toContain('child-payload'); +}); + +it('is registrable through WidgetRegistry alongside Blade widgets', function (): void { + $registry = new WidgetRegistry; + $registry->register(FakeLivewireWidget::class); + + expect($registry->has('fake-livewire-widget'))->toBeTrue(); + expect($registry->get('fake-livewire-widget'))->toBe(FakeLivewireWidget::class); + expect($registry->getDefaultData('fake-livewire-widget')) + ->toBe(['message' => 'hello']); +}); + +it('serializes through LayupContent like any other widget', function (): void { + $registry = app(WidgetRegistry::class); + $registry->register(FakeLivewireWidget::class); + + $content = new \Crumbls\Layup\Support\LayupContent([ + 'sections' => [[ + 'rows' => [[ + 'columns' => [[ + 'span' => 12, + 'widgets' => [[ + 'type' => 'fake-livewire-widget', + 'data' => ['message' => 'serialized'], + ]], + ]], + ]], + ]], + ]); + + $array = $content->toArray(); + + // toArray() returns rows; widget sits at row -> column -> widget. + $widgetNode = $array[0]['children'][0]['children'][0]; + + expect($widgetNode['type'])->toBe('fake-livewire-widget'); + expect($widgetNode['data'])->toBe(['message' => 'serialized']); +}); + +it('exposes HasWidgetDefaults defaults so toArray() includes the standard keys', function (): void { + $payload = FakeLivewireWidget::toArray(); + + expect($payload)->toHaveKeys([ + 'type', 'label', 'icon', 'category', 'defaults', + 'search_terms', 'deprecated', 'deprecation_message', + ]); + + // Defaults from HasWidgetDefaults that the fixture didn't override + expect($payload['icon'])->toBe('heroicon-o-puzzle-piece'); + expect($payload['category'])->toBe('content'); + expect($payload['deprecated'])->toBeFalse(); +}); + +it('passes lifecycle hooks through unchanged when not overridden', function (): void { + $data = ['message' => 'unchanged']; + + expect(FakeLivewireWidget::onCreate($data))->toBe($data); + expect(FakeLivewireWidget::onSave($data))->toBe($data); + expect(FakeLivewireWidget::onDuplicate($data))->toBe($data); + expect(FakeLivewireWidget::prepareForRender($data))->toBe($data); +}); diff --git a/tests/Unit/IdentityTraitCompositionTest.php b/tests/Unit/IdentityTraitCompositionTest.php new file mode 100644 index 0000000..30036b2 --- /dev/null +++ b/tests/Unit/IdentityTraitCompositionTest.php @@ -0,0 +1,117 @@ + [HeadingWidget::class, HeadingLivewireFixture::class], + 'button' => [ButtonWidget::class, ButtonLivewireFixture::class], + 'number-counter' => [NumberCounterWidget::class, NumberCounterLivewireFixture::class], + 'newsletter' => [NewsletterWidget::class, NewsletterLivewireFixture::class], +]); + +it('produces identical type, label, icon and category across both bases', function (string $bladeClass, string $livewireClass): void { + expect($bladeClass::getType())->toBe($livewireClass::getType()); + expect($bladeClass::getLabel())->toBe($livewireClass::getLabel()); + expect($bladeClass::getIcon())->toBe($livewireClass::getIcon()); + expect($bladeClass::getCategory())->toBe($livewireClass::getCategory()); +})->with('identity_pairs'); + +it('produces identical defaults across both bases', function (string $bladeClass, string $livewireClass): void { + expect($bladeClass::getDefaultData())->toBe($livewireClass::getDefaultData()); +})->with('identity_pairs'); + +it('produces identical preview output across both bases', function (string $bladeClass, string $livewireClass): void { + $defaults = $bladeClass::getDefaultData(); + expect($bladeClass::getPreview($defaults))->toBe($livewireClass::getPreview($defaults)); +})->with('identity_pairs'); + +it('produces identical content form schemas (same field names, same defaults)', function (string $bladeClass, string $livewireClass): void { + $bladeFields = collect($bladeClass::getContentFormSchema()) + ->filter(fn ($c) => method_exists($c, 'getName')) + ->map(fn ($c) => $c->getName()) + ->all(); + + $livewireFields = collect($livewireClass::getContentFormSchema()) + ->filter(fn ($c) => method_exists($c, 'getName')) + ->map(fn ($c) => $c->getName()) + ->all(); + + expect($livewireFields)->toBe($bladeFields); +})->with('identity_pairs'); + +it('produces identical toArray() metadata across both bases', function (string $bladeClass, string $livewireClass): void { + expect($bladeClass::toArray())->toBe($livewireClass::toArray()); +})->with('identity_pairs'); + +it('keeps both flavours discoverable through the Widget interface', function (string $bladeClass, string $livewireClass): void { + $bladeRef = new ReflectionClass($bladeClass); + $livewireRef = new ReflectionClass($livewireClass); + + expect($bladeRef->implementsInterface(\Crumbls\Layup\Contracts\Widget::class))->toBeTrue(); + expect($livewireRef->implementsInterface(\Crumbls\Layup\Contracts\Widget::class))->toBeTrue(); + + // Sibling bases - neither is a subclass of the other + expect(is_subclass_of($livewireClass, BaseWidget::class))->toBeFalse(); + expect(is_subclass_of($bladeClass, BaseLivewireWidget::class))->toBeFalse(); +})->with('identity_pairs'); diff --git a/tests/Unit/RenderChildrenToHtmlTest.php b/tests/Unit/RenderChildrenToHtmlTest.php new file mode 100644 index 0000000..8ba7835 --- /dev/null +++ b/tests/Unit/RenderChildrenToHtmlTest.php @@ -0,0 +1,94 @@ +str'; + } +} + +class HtmlableChild extends BaseView +{ + public function render(): Htmlable + { + return new HtmlString('htm'); + } +} + +class ViewChild extends BaseView +{ + public function render(): \Illuminate\Contracts\View\View + { + return view('render-child-fixture', ['marker' => 'view']); + } +} + +it('returns an empty string when there are no children', function (): void { + $parent = StringChild::make(); + + expect($parent->renderChildrenToHtml())->toBe(''); +}); + +it('concatenates string-returning children in order', function (): void { + $parent = StringChild::make([], [ + StringChild::make(), + StringChild::make(), + ]); + + $html = $parent->renderChildrenToHtml(); + + expect($html)->toBe( + 'strstr' + ); +}); + +it('coerces Htmlable-returning children into the concatenated string', function (): void { + $parent = StringChild::make([], [ + HtmlableChild::make(), + ]); + + expect($parent->renderChildrenToHtml()) + ->toBe('htm'); +}); + +it('coerces View-returning children into the concatenated string', function (): void { + $parent = StringChild::make([], [ + ViewChild::make(), + ]); + + expect($parent->renderChildrenToHtml()) + ->toContain('data-marker="view"'); +}); + +it('handles a heterogeneous mix of View, Htmlable, and string children', function (): void { + $parent = StringChild::make([], [ + StringChild::make(), + HtmlableChild::make(), + ViewChild::make(), + ]); + + $html = $parent->renderChildrenToHtml(); + + expect($html)->toContain('data-marker="string"'); + expect($html)->toContain('data-marker="htmlable"'); + expect($html)->toContain('data-marker="view"'); +}); diff --git a/tests/views/render-child-fixture.blade.php b/tests/views/render-child-fixture.blade.php new file mode 100644 index 0000000..ae3cfcb --- /dev/null +++ b/tests/views/render-child-fixture.blade.php @@ -0,0 +1 @@ +vw From 76189b22c5df3c0efd3d37d5bcb3ddc54906520e Mon Sep 17 00:00:00 2001 From: Chase Miller Date: Thu, 30 Apr 2026 19:27:49 -0600 Subject: [PATCH 4/4] Address two PR review findings - BaseView::renderChildrenToHtml(): Htmlable implementations are not required to define __toString(), so the previous (string) cast would crash on a custom Htmlable that lacks it. Now branches on instanceof Htmlable and calls toHtml() directly. View and string returns are unchanged (concrete Illuminate\View\View defines both toHtml() and __toString()). New regression test in RenderChildrenToHtmlTest exercises an inline anonymous Htmlable that intentionally has no __toString(). - docs/advanced/livewire-widgets.md: tag the architecture-diagram and log-output fenced blocks with `text` so markdownlint MD040 passes. Suite: 1168 passed. --- docs/advanced/livewire-widgets.md | 4 ++-- src/View/BaseView.php | 11 +++++++--- tests/Unit/RenderChildrenToHtmlTest.php | 28 +++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/advanced/livewire-widgets.md b/docs/advanced/livewire-widgets.md index 8ed0b31..06f5c7d 100644 --- a/docs/advanced/livewire-widgets.md +++ b/docs/advanced/livewire-widgets.md @@ -19,7 +19,7 @@ Three concerns live in three orthogonal places: Identity lives in either the widget class itself or, for widgets meant to ship in multiple render flavours, an identity trait. Rendering tech lives in the base class (`BaseBladeWidget` or `BaseLivewireWidget`). Data plumbing lives in `BaseView`, which both bases extend. The `Widget` contract (`Crumbls\Layup\Contracts\Widget`) is the interface both bases implement, so anywhere code accepts "a widget" it accepts either flavour. -``` +```text Crumbls\Layup\Contracts\Widget (interface, rendering-agnostic) | v @@ -284,7 +284,7 @@ app(\Crumbls\Layup\Support\WidgetRegistry::class) `WidgetRegistry::register()` logs a warning when overriding an existing type so the swap is visible in logs: -``` +```text Layup: Widget type 'newsletter' already registered by ... Overriding with ... ``` diff --git a/src/View/BaseView.php b/src/View/BaseView.php index 8723db6..0c78ba8 100644 --- a/src/View/BaseView.php +++ b/src/View/BaseView.php @@ -97,15 +97,20 @@ public function hasChildren(): bool * * Used by render() implementations that need a pre-rendered children * blob (e.g. when mounting a Livewire component and passing children - * through the slot). Each child's render() result is cast to string, - * which works for View, Htmlable, and string return types alike. + * through the slot). Htmlable results go through toHtml() so custom + * Htmlable implementations that don't define __toString() still work; + * View and string returns are handled by the (string) cast (concrete + * Illuminate\View\View defines __toString()). */ public function renderChildrenToHtml(): string { $html = ''; foreach ($this->children as $child) { - $html .= (string) $child->render(); + $rendered = $child->render(); + $html .= $rendered instanceof Htmlable + ? $rendered->toHtml() + : (string) $rendered; } return $html; diff --git a/tests/Unit/RenderChildrenToHtmlTest.php b/tests/Unit/RenderChildrenToHtmlTest.php index 8ba7835..7dc6b17 100644 --- a/tests/Unit/RenderChildrenToHtmlTest.php +++ b/tests/Unit/RenderChildrenToHtmlTest.php @@ -34,6 +34,25 @@ public function render(): Htmlable } } +/** + * Custom Htmlable that intentionally does not define __toString(). Casting + * an instance with (string) would throw -- the helper must reach toHtml() + * via instanceof Htmlable instead. + */ +class HtmlableWithoutToStringChild extends BaseView +{ + public function render(): Htmlable + { + return new class implements Htmlable + { + public function toHtml(): string + { + return 'cust'; + } + }; + } +} + class ViewChild extends BaseView { public function render(): \Illuminate\Contracts\View\View @@ -70,6 +89,15 @@ public function render(): \Illuminate\Contracts\View\View ->toBe('htm'); }); +it('handles Htmlable implementations that do not define __toString()', function (): void { + $parent = StringChild::make([], [ + HtmlableWithoutToStringChild::make(), + ]); + + expect($parent->renderChildrenToHtml()) + ->toBe('cust'); +}); + it('coerces View-returning children into the concatenated string', function (): void { $parent = StringChild::make([], [ ViewChild::make(),