diff --git a/packages/craftcms-legacy/cp/src/js/Preview.js b/packages/craftcms-legacy/cp/src/js/Preview.js
index d0ca2b57a01..3dfe02c5d3a 100644
--- a/packages/craftcms-legacy/cp/src/js/Preview.js
+++ b/packages/craftcms-legacy/cp/src/js/Preview.js
@@ -335,26 +335,8 @@ Craft.Preview = Garnish.Base.extend(
if (this.settings.standaloneMode) {
if (data.actionMenu) {
- const labelId = Craft.namespaceId(
- 'action-menu-label',
- this.namespace
- );
- const menuId = Craft.namespaceId('action-menu', this.namespace);
- $('', {
- id: labelId,
- class: 'visually-hidden',
- text: Craft.t('app', 'Actions'),
- }).appendTo(this.$editorHeader);
- const $actionBtn = $('', {
- class: 'btn action-btn header-btn',
- type: 'button',
- title: Craft.t('app', 'Actions'),
- 'aria-controls': menuId,
- 'aria-describedby': labelId,
- 'data-disclosure-trigger': 'true',
- }).appendTo(this.$editorHeader);
+ // A ``, which brings its own invoker
$(data.actionMenu).appendTo(this.$editorHeader);
- $actionBtn.disclosureMenu();
}
if (data.editUrl) {
diff --git a/resources/js/modules/slideout/cp-screen-slideout.ts b/resources/js/modules/slideout/cp-screen-slideout.ts
index 1fbd150f364..e7987cd02de 100644
--- a/resources/js/modules/slideout/cp-screen-slideout.ts
+++ b/resources/js/modules/slideout/cp-screen-slideout.ts
@@ -426,10 +426,7 @@ export class CpScreenSlideout extends Slideout {
this.unmountInertiaApp();
this.$content.html(data.content);
- if (this.$actionBtn) {
- this.$actionBtn.data('disclosureMenu')?.destroy();
- this.$actionBtn.remove();
- }
+ this.$actionBtn?.remove();
if (data.submitButtonLabel) {
this.$saveBtn.find('.label').text(data.submitButtonLabel);
@@ -450,27 +447,10 @@ export class CpScreenSlideout extends Slideout {
this.hasCpLink = false;
}
- if (data.actionMenu) {
- const labelId = Craft.namespaceId('action-menu-label', this.namespace);
- const menuId = Craft.namespaceId('action-menu', this.namespace);
- $('', {
- id: labelId,
- class: 'visually-hidden',
- text: Craft.t('app', 'Actions'),
- }).insertBefore(this.$editLink);
- this.$actionBtn = $('', {
- class: 'btn action-btn header-btn',
- type: 'button',
- title: Craft.t('app', 'Actions'),
- 'aria-controls': menuId,
- 'aria-describedby': labelId,
- 'data-disclosure-trigger': 'true',
- }).insertBefore(this.$editLink);
- $(data.actionMenu).insertBefore(this.$editLink);
- this.$actionBtn.disclosureMenu();
- } else {
- this.$actionBtn = null;
- }
+ // A ``, which brings its own invoker
+ this.$actionBtn = data.actionMenu
+ ? $(data.actionMenu).insertBefore(this.$editLink)
+ : null;
if (data.sidebar) {
this.$sidebarBtn.removeClass('hidden');
diff --git a/src/Http/Responses/CpScreenResponse.php b/src/Http/Responses/CpScreenResponse.php
index c679a178aad..79ea6abfd39 100644
--- a/src/Http/Responses/CpScreenResponse.php
+++ b/src/Http/Responses/CpScreenResponse.php
@@ -5,6 +5,7 @@
namespace CraftCms\Cms\Http\Responses;
use CraftCms\Cms\Cms;
+use CraftCms\Cms\Cp\Components\ActionMenu;
use CraftCms\Cms\Cp\Data\ActionItem;
use CraftCms\Cms\Cp\Data\NavItem;
use CraftCms\Cms\Cp\Html\MenuHtml;
@@ -853,9 +854,7 @@ private function prepareSlideout(Request $request): array
: $this->inertiaProps,
'sidebar' => $sidebar,
'errorSummary' => $errorSummary,
- 'actionMenu' => $this->actionMenu(withDestructive: false, config: [
- 'withButton' => false,
- ], namespace: $namespace),
+ 'actionMenu' => $this->actionMenu(withDestructive: false, namespace: $namespace),
];
}
@@ -1082,8 +1081,13 @@ private function contextMenu(?string $namespace = null): ?string
], $namespace);
}
- /** @param array $config */
- private function actionMenu(bool $withDestructive = true, array $config = [], ?string $namespace = null): ?string
+ /**
+ * Renders the slideout's action menu as a ``.
+ *
+ * Item collection happens inside the namespace closure, along with rendering, so JS that items register against
+ * their IDs gets the same namespaced IDs as the rendered markup.
+ */
+ private function actionMenu(bool $withDestructive = true, ?string $namespace = null): ?string
{
$itemsFactory = $this->actionMenuItemsFactory($withDestructive);
@@ -1091,9 +1095,24 @@ private function actionMenu(bool $withDestructive = true, array $config = [], ?s
return null;
}
- return $this->menu($itemsFactory, $config + [
- 'id' => 'action-menu',
- ], $namespace);
+ $render = function () use ($itemsFactory): ?string {
+ $items = $this->menuItems($itemsFactory);
+
+ if (empty($items)) {
+ return null;
+ }
+
+ return ActionMenu::make()
+ ->menuItems($items, normalize: false)
+ ->label(t('Actions'))
+ ->toHtml();
+ };
+
+ if ($namespace) {
+ return InputNamespace::namespaceInputs($render, $namespace);
+ }
+
+ return $render();
}
/** @return list>|null */
diff --git a/tests/Feature/Http/Responses/CpScreenSlideoutTest.php b/tests/Feature/Http/Responses/CpScreenSlideoutTest.php
index 6d8160cc8ca..d00d49dc509 100644
--- a/tests/Feature/Http/Responses/CpScreenSlideoutTest.php
+++ b/tests/Feature/Http/Responses/CpScreenSlideoutTest.php
@@ -6,6 +6,7 @@
use CraftCms\Cms\Http\Controllers\Settings\EntryTypesController;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Facades\HtmlStack;
+use CraftCms\Cms\Support\Facades\InputNamespace;
use CraftCms\Cms\Support\Str;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Http\Request;
@@ -120,6 +121,28 @@ function slideoutResponse(): TestResponse
]);
});
+it('renders the legacy slideout action menu as a craft-action-menu', function () {
+ $screen = new CpScreenResponse;
+ $screen->actionMenuItems(function () {
+ $id = InputNamespace::namespaceId('action-test');
+ HtmlStack::js("document.getElementById('$id').onclick = () => {};");
+
+ return [['id' => 'action-test', 'label' => 'Test action']];
+ });
+ $request = Request::create('/', server: [
+ 'HTTP_ACCEPT' => 'application/json',
+ 'HTTP_X_CRAFT_CONTAINER_ID' => 'slideout-1',
+ ]);
+
+ $data = $screen->toResponse($request)->getData(true);
+ $id = "{$data['namespace']}-action-test";
+
+ expect($data['actionMenu'])->toStartWith('not->toContain('menu--disclosure')
+ ->toContain('id="'.$id.'"')
+ ->and($data['bodyHtml'])->toContain($id);
+});
+
it('gives each slideout its own input namespace', function () {
$namespaceFor = fn (string $containerId) => withHeaders([
'X-Craft-Container-Id' => $containerId,
diff --git a/yii2-adapter/legacy/web/CpScreenResponseFormatter.php b/yii2-adapter/legacy/web/CpScreenResponseFormatter.php
index 11ee2ae725c..c50f1fd1c28 100644
--- a/yii2-adapter/legacy/web/CpScreenResponseFormatter.php
+++ b/yii2-adapter/legacy/web/CpScreenResponseFormatter.php
@@ -8,6 +8,7 @@
namespace craft\web;
use Craft;
+use CraftCms\Cms\Cp\Components\ActionMenu;
use CraftCms\Cms\Cp\Html\MenuHtml;
use CraftCms\Cms\Cp\Icons;
use CraftCms\Cms\Support\Facades\DeltaRegistry;
@@ -111,9 +112,7 @@ private function _formatJson(Request $request, YiiResponse $response, CpScreenRe
'action' => $behavior->action,
'extraToolbarItems' => $extraToolbarItems,
'submitButtonLabel' => $behavior->submitButtonLabel,
- 'actionMenu' => $this->_actionMenu($behavior, false, [
- 'withButton' => false,
- ], $namespace),
+ 'actionMenu' => $this->_slideoutActionMenu($behavior, $namespace),
'content' => $content,
'sidebar' => $sidebar,
'errorSummary' => $errorSummary,
@@ -258,6 +257,38 @@ private function _actionMenu(
], $namespace);
}
+ /**
+ * Renders a slideout's action menu as a ``.
+ */
+ private function _slideoutActionMenu(CpScreenResponseBehavior $behavior, ?string $namespace): ?string
+ {
+ if ($behavior->actionMenuItems === null) {
+ return null;
+ }
+
+ $render = function() use ($behavior): ?string {
+ $items = array_filter(
+ app(MenuHtml::class)->normalizeMenuItems(call_user_func($behavior->actionMenuItems) ?? []),
+ fn(array $item) => !($item['destructive'] ?? false),
+ );
+
+ if (empty($items)) {
+ return null;
+ }
+
+ return ActionMenu::make()
+ ->menuItems(array_values($items))
+ ->label(t('Actions'))
+ ->toHtml();
+ };
+
+ if ($namespace) {
+ return InputNamespace::namespaceInputs($render, $namespace);
+ }
+
+ return $render();
+ }
+
private function _menu(?callable $itemsFactory, array $config, ?string $namespace): ?string
{
if ($itemsFactory === null) {