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)
+ },
}"
>
- @foreach ($bricks as $brick)
-
+ @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)
+ },
}"
>
@@ -46,30 +80,84 @@ class="mason-actions"
- @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 () {