From c9e977d16b0ff9cd485c325fa2bcbcf422016419 Mon Sep 17 00:00:00 2001 From: Adam Weston Date: Wed, 3 Jun 2026 16:34:52 -0400 Subject: [PATCH] Feat: support grouping bricks, ie 'Categories' --- README.md | 25 ++++ resources/css/plugin.css | 46 +++++- .../components/brick-picker-modal.blade.php | 118 ++++++++++++--- resources/views/components/sidebar.blade.php | 136 ++++++++++++++---- resources/views/mason.blade.php | 5 +- src/BrickGroup.php | 40 ++++++ src/Concerns/HasBricks.php | 48 ++++++- tests/src/HasBricksTest.php | 58 ++++++++ 8 files changed, 424 insertions(+), 52 deletions(-) create mode 100644 src/BrickGroup.php diff --git a/README.md b/README.md index 19fd813..72f5a4d 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,31 @@ Mason::make('content') ->bricks([...]) ``` +### Grouping Bricks + +Bricks can be organized into labeled groups in the editor sidebar by wrapping them in a `BrickGroup`. Groups are collapsible, and searching by brick name or tag will automatically expand any group that contains a match. + +```php +use Awcodes\Mason\BrickGroup; + +Mason::make('content') + ->bricks([ + BrickGroup::make('Content') + ->bricks([ + Section::class, + Grid::class, + ]), + BrickGroup::make('Marketing') + ->bricks([ + Hero::class, + CallToAction::class, + ]), + LeadForm::class, // standalone bricks can sit alongside groups + ]) +``` + +Groups and standalone bricks can be freely mixed in the same `bricks` array. `sortBricks()` applies to the top-level array and will sort groups alongside standalone bricks by their respective labels. + ### Sorting Bricks By default, bricks are sorted in the order they are defined in the `bricks` array. If you would like to allow users to sort the bricks in the editor, you can chain the `sortBricks` method on the field. diff --git a/resources/css/plugin.css b/resources/css/plugin.css index f126796..a39dbc6 100644 --- a/resources/css/plugin.css +++ b/resources/css/plugin.css @@ -59,6 +59,24 @@ .mason-actions-bricks { @apply flex flex-1 flex-col gap-2 overflow-y-auto px-3 py-2; + .mason-actions-group { + &.filtered { + display: none !important; + } + } + + .mason-actions-group-header { + @apply w-full flex items-center justify-between py-2 text-xs font-semibold; + + .mason-actions-group-chevron { + @apply size-3 transition-transform; + } + } + + .mason-actions-group-bricks { + @apply flex flex-1 flex-col gap-2; + } + .mason-actions-brick { @apply flex cursor-move items-center gap-2 rounded border border-gray-300 bg-white py-2 pe-4 ps-3 text-xs dark:border-gray-700 dark:bg-gray-800; @@ -77,7 +95,15 @@ @apply w-full max-w-[13rem]; .mason-actions-bricks { - @apply grid flex-none auto-rows-fr grid-cols-1 md:grid-cols-2; + @apply grid flex-none grid-cols-1 md:grid-cols-2; + + .mason-actions-group { + @apply col-span-full; + + .mason-actions-group-bricks { + @apply grid grid-cols-1 md:grid-cols-2; + } + } .mason-actions-brick { @apply flex-col justify-center text-center; @@ -179,6 +205,24 @@ @apply flex flex-1 flex-col gap-2 overflow-y-auto p-3; } +.mason-brick-picker-group { + &.filtered { + display: none !important; + } +} + +.mason-brick-picker-group-header { + @apply w-full flex items-center justify-between py-2 text-xs font-semibold text-gray-700 dark:text-gray-300; + + .mason-brick-picker-group-chevron { + @apply size-3 transition-transform; + } +} + +.mason-brick-picker-group-bricks { + @apply flex flex-col gap-2; +} + .mason-brick-picker-brick { @apply flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-600 dark:hover:bg-gray-700; diff --git a/resources/views/components/brick-picker-modal.blade.php b/resources/views/components/brick-picker-modal.blade.php index e87ba75..4364518 100644 --- a/resources/views/components/brick-picker-modal.blade.php +++ b/resources/views/components/brick-picker-modal.blade.php @@ -3,7 +3,23 @@ ]) @php - $brickData = array_map(fn ($brick) => ['label' => $brick::getLabel(), 'tags' => $brick::getTags()], $bricks); + $brickData = []; + foreach ($bricks as $item) { + if ($item instanceof \Awcodes\Mason\BrickGroup) { + foreach ($item->getBricks() as $brick) { + $brickData[] = ['label' => $brick::getLabel(), 'tags' => $brick::getTags(), 'group' => $item->getLabel()]; + } + } else { + $brickData[] = ['label' => $item::getLabel(), 'tags' => $item::getTags(), 'group' => null]; + } + } + + $initialOpenGroups = []; + foreach ($bricks as $item) { + if ($item instanceof \Awcodes\Mason\BrickGroup) { + $initialOpenGroups[$item->getLabel()] = true; + } + } @endphp
brick.label) }, + groupHasMatch: function (label) { + const q = this.search.toLowerCase() + if (! q) return true + return this.actions + .filter((b) => b.group === label) + .some((b) => + b.label.toLowerCase().includes(q) || + b.tags.some((t) => t.toLowerCase().includes(q)), + ) + }, + isGroupOpen: function (label) { + if (this.search) return true + return this.openGroups[label] !== false + }, + toggleGroup: function (label) { + this.openGroups[label] = ! this.isGroupOpen(label) + }, }" >
@@ -96,24 +130,72 @@ class="mason-brick-picker-position-btn"
- @foreach ($bricks as $brick) - +
+ @foreach ($item->getBricks() as $brick) + + @endforeach +
+
+ @else + + {{ $item::getLabel() }} + + @endif @endforeach
diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 8856c3d..d916e50 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -5,7 +5,23 @@ ]) @php - $brickData = array_map(fn ($brick) => ['label' => $brick::getLabel(), 'tags' => $brick::getTags()], $bricks); + $brickData = []; + foreach ($bricks as $item) { + if ($item instanceof \Awcodes\Mason\BrickGroup) { + foreach ($item->getBricks() as $brick) { + $brickData[] = ['label' => $brick::getLabel(), 'tags' => $brick::getTags(), 'group' => $item->getLabel()]; + } + } else { + $brickData[] = ['label' => $item::getLabel(), 'tags' => $item::getTags(), 'group' => null]; + } + } + + $initialOpenGroups = []; + foreach ($bricks as $item) { + if ($item instanceof \Awcodes\Mason\BrickGroup) { + $initialOpenGroups[$item->getLabel()] = true; + } + } @endphp
brick.label) }, + groupHasMatch: function (label) { + const q = this.search.toLowerCase() + if (! q) return true + return this.actions + .filter((b) => b.group === label) + .some((b) => + b.label.toLowerCase().includes(q) || + b.tags.some((t) => t.toLowerCase().includes(q)), + ) + }, + isGroupOpen: function (label) { + if (this.search) return true + return this.openGroups[label] !== false + }, + toggleGroup: function (label) { + this.openGroups[label] = ! this.isGroupOpen(label) + }, }" >
- @foreach ($bricks as $brick) -
- @if (filled($brick::getIcon())) - - @endif + @foreach ($bricks as $item) + @if ($item instanceof \Awcodes\Mason\BrickGroup) +
+ +
+ @foreach ($item->getBricks() as $brick) +
+ @if (filled($brick::getIcon())) + + @endif + + {{ $brick::getLabel() }} +
+ @endforeach +
+
+ @else +
+ @if (filled($item::getIcon())) + + @endif - {{ $brick::getLabel() }} -
+ {{ $item::getLabel() }} +
+ @endif @endforeach
diff --git a/resources/views/mason.blade.php b/resources/views/mason.blade.php index b2c2eda..5774364 100644 --- a/resources/views/mason.blade.php +++ b/resources/views/mason.blade.php @@ -8,6 +8,7 @@ $statePath = $getStatePath(); $isDisabled = $isDisabled(); $bricks = $getBricks(); + $flatBricks = $getFlatBricks(); $defaultColorMode = $getDefaultColorMode(); $hasColorModeToggle = $hasColorModeToggle(); @endphp @@ -28,7 +29,7 @@ placeholder: @js($getPlaceholder()), disabled: @js($isDisabled), dblClickToEdit: @js($shouldDblClickToEdit()), - bricks: @js(array_map(fn ($brick) => is_string($brick) ? $brick : get_class($brick), $bricks)), + bricks: @js($flatBricks), previewLayout: @js($getPreviewLayout()), defaultColorMode: @js($defaultColorMode), hasColorModeToggle: @js($hasColorModeToggle), @@ -90,7 +91,7 @@ class="mason-iframe" :bricks="$bricks" :has-grid-actions="$hasGridActions()" :has-color-mode-toggle="$hasColorModeToggle" - wire:key="sidebar-{{ hash('sha256', json_encode($bricks)) }}" + wire:key="sidebar-{{ hash('sha256', json_encode($flatBricks)) }}" /> @endif diff --git a/src/BrickGroup.php b/src/BrickGroup.php new file mode 100644 index 0000000..c9993c0 --- /dev/null +++ b/src/BrickGroup.php @@ -0,0 +1,40 @@ +> $bricks + */ + public function bricks(array $bricks): static + { + $this->bricks = $bricks; + + return $this; + } + + public function getLabel(): string + { + return $this->label; + } + + /** + * @return array> + */ + public function getBricks(): array + { + return $this->bricks; + } +} diff --git a/src/Concerns/HasBricks.php b/src/Concerns/HasBricks.php index 63015bf..7a21e03 100644 --- a/src/Concerns/HasBricks.php +++ b/src/Concerns/HasBricks.php @@ -5,6 +5,7 @@ namespace Awcodes\Mason\Concerns; use Awcodes\Mason\Brick; +use Awcodes\Mason\BrickGroup; use Awcodes\Mason\Bricks\Section; use Closure; @@ -20,7 +21,7 @@ trait HasBricks protected array $cachedBricks; /** - * @param array> | Closure | null $bricks + * @param array|BrickGroup> | Closure | null $bricks */ public function bricks(array | Closure | null $bricks): static { @@ -42,7 +43,7 @@ public function getBricksSortDirection(): ?string } /** - * @return array> + * @return array|BrickGroup> */ public function getBricks(): array { @@ -53,15 +54,42 @@ public function getBricks(): array if ($this->bricksSortDirection !== null) { usort( $bricks, - fn ($a, $b): int => $this->bricksSortDirection === 'asc' - ? $a::getLabel() <=> $b::getLabel() - : $b::getLabel() <=> $a::getLabel() + function ($a, $b): int { + $labelA = $a instanceof BrickGroup ? $a->getLabel() : $a::getLabel(); + $labelB = $b instanceof BrickGroup ? $b->getLabel() : $b::getLabel(); + + return $this->bricksSortDirection === 'asc' + ? $labelA <=> $labelB + : $labelB <=> $labelA; + } ); } return $bricks; } + /** + * Returns a flat array of brick class names, unwrapping any BrickGroups. + * + * @return array> + */ + public function getFlatBricks(): array + { + $flat = []; + + foreach ($this->getBricks() as $item) { + if ($item instanceof BrickGroup) { + foreach ($item->getBricks() as $brick) { + $flat[] = $brick; + } + } else { + $flat[] = $item; + } + } + + return $flat; + } + /** * @return array> */ @@ -71,8 +99,14 @@ public function getCachedBricks(): array return $this->cachedBricks; } - foreach ($this->getBricks() as $brick) { - $this->cachedBricks[$brick::getId()] = $brick; + foreach ($this->getBricks() as $item) { + if ($item instanceof BrickGroup) { + foreach ($item->getBricks() as $brick) { + $this->cachedBricks[$brick::getId()] = $brick; + } + } else { + $this->cachedBricks[$item::getId()] = $item; + } } return $this->cachedBricks; diff --git a/tests/src/HasBricksTest.php b/tests/src/HasBricksTest.php index 8c07900..ed069a6 100644 --- a/tests/src/HasBricksTest.php +++ b/tests/src/HasBricksTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Awcodes\Mason\BrickGroup; use Awcodes\Mason\Bricks\Section; use Awcodes\Mason\Mason; use Awcodes\Mason\Support\MasonRenderer; @@ -109,6 +110,63 @@ expect($field->getBricksSortDirection())->toBe('asc'); }); + + it('accepts BrickGroup instances alongside brick classes', function () { + $group = BrickGroup::make('Content')->bricks([Section::class]); + + $field = Mason::make('content') + ->bricks([$group, TestBrick::class]); + + $bricks = $field->getBricks(); + + expect($bricks[0])->toBeInstanceOf(BrickGroup::class) + ->and($bricks[1])->toBe(TestBrick::class); + }); + + it('caches bricks from groups by id', function () { + $group = BrickGroup::make('Content')->bricks([Section::class, TestBrick::class]); + + $field = Mason::make('content') + ->bricks([$group, SimpleBrick::class]); + + $cached = $field->getCachedBricks(); + + expect($cached)->toHaveKey('section') + ->and($cached)->toHaveKey('test-brick') + ->and($cached)->toHaveKey('simple-brick'); + }); + + it('retrieves brick by id when brick is inside a group', function () { + $group = BrickGroup::make('Content')->bricks([Section::class]); + + $field = Mason::make('content') + ->bricks([$group]); + + expect($field->getBrick('section'))->toBe(Section::class); + }); + + it('returns flat bricks without groups', function () { + $group = BrickGroup::make('Content')->bricks([Section::class, TestBrick::class]); + + $field = Mason::make('content') + ->bricks([$group, SimpleBrick::class]); + + expect($field->getFlatBricks())->toBe([Section::class, TestBrick::class, SimpleBrick::class]); + }); + + it('sorts groups alongside standalone bricks by label', function () { + $group = BrickGroup::make('Alpha')->bricks([Section::class]); + + $field = Mason::make('content') + ->bricks([$group, TestBrick::class]) + ->sortBricks('asc'); + + $bricks = $field->getBricks(); + + expect($bricks[0])->toBeInstanceOf(BrickGroup::class) + ->and($bricks[0]->getLabel())->toBe('Alpha') + ->and($bricks[1])->toBe(TestBrick::class); + }); }); describe('on MasonRenderer', function () {