TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:00 +02:00
commit f9941541b7
1178 changed files with 135377 additions and 0 deletions
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
/**
* Base class providing common properties for UI controls in the backend.
* Provides standard HTML attributes like title, CSS classes, and data attributes
* that are shared across various component types (buttons, menu items, etc.).
*
* This class is extended by components like:
* - AbstractButton
* - MenuItem
*
* Example (inherited in MenuItem):
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* $menuItem = $this->componentFactory->createMenuItem()
* ->setTitle('My Item') // From AbstractControl
* ->setClasses('custom-class') // From AbstractControl
* ->setDataAttributes([ // From AbstractControl
* 'action' => 'do-something'
* ])
* ->setHref('/target'); // MenuItem-specific
* ```
*/
class AbstractControl
{
/**
* CSS classes to apply to the rendered element
*/
protected string $classes = '';
/**
* Title/label text for the control
*/
protected string $title = '';
/**
* HTML data-* attributes for the control
*
* @var array<string, string> Key-value pairs (e.g., ['action' => 'save'])
*/
protected array $dataAttributes = [];
public function getClasses(): string
{
return $this->classes;
}
public function getTitle(): string
{
return $this->title;
}
public function getDataAttributes(): array
{
return $this->dataAttributes;
}
public function setClasses(string $classes): static
{
$this->classes = $classes;
return $this;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function setDataAttributes(array $dataAttributes): static
{
$this->dataAttributes = $dataAttributes;
return $this;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
/**
* Defines groups for record list / file list actions.
* Currently, there are only two of them, which can be used to move Buttons between the Primary Group and the Secondary Group via Events.
*/
enum ActionGroup
{
case primary;
case secondary;
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Breadcrumb\BreadcrumbContext;
use TYPO3\CMS\Backend\Breadcrumb\BreadcrumbProviderInterface;
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
/**
* Breadcrumb component, building breadcrumbs for the backend doc header.
*
* This component uses a provider-based architecture:
* - BreadcrumbProviderInterface implementations generate breadcrumb nodes
* - Providers are selected based on the context type (record, resource, etc.)
*
* @internal This class is a specific Backend implementation and is not part of the TYPO3's Core API.
*/
#[Autoconfigure(public: true)]
final readonly class Breadcrumb
{
/**
* @param iterable<BreadcrumbProviderInterface> $providers
*/
public function __construct(
private iterable $providers,
private LoggerInterface $logger,
) {}
/**
* Generates breadcrumb nodes from a breadcrumb context.
*
* @param ServerRequestInterface|null $request The current request for module detection
* @param BreadcrumbContext|null $context The breadcrumb context containing main entity and suffix nodes
* @return BreadcrumbNode[] Array of breadcrumb nodes
*/
public function getBreadcrumb(?ServerRequestInterface $request, ?BreadcrumbContext $context): array
{
// Generate nodes from providers (works for both null and non-null context)
$nodes = $this->generateNodesFromContext($context, $request);
// Append suffix nodes only if context is not null
if ($context !== null && $context->hasSuffixNodes()) {
foreach ($context->suffixNodes as $suffixNode) {
$nodes[] = $suffixNode;
}
}
return $nodes;
}
/**
* Generates breadcrumb nodes from a context using providers.
*
* @return BreadcrumbNode[]
*/
private function generateNodesFromContext(?BreadcrumbContext $context, ?ServerRequestInterface $request): array
{
$provider = $this->findProvider($context);
if ($provider === null) {
$this->logger->warning(
'No breadcrumb provider found for context',
['context_type' => get_debug_type($context)]
);
return [];
}
try {
return $provider->generate($context, $request);
} catch (\Exception $e) {
$this->logger->error(
'Failed to generate breadcrumb from provider',
[
'provider' => get_class($provider),
'context_type' => get_debug_type($context),
'exception' => $e->getMessage(),
]
);
return [];
}
}
/**
* Finds the most suitable provider for the given context.
*
* Providers are checked in priority order (highest first).
*/
private function findProvider(?BreadcrumbContext $context): ?BreadcrumbProviderInterface
{
$providers = iterator_to_array($this->providers);
// Sort by priority (highest first)
usort($providers, static fn($a, $b) => $b->getPriority() <=> $a->getPriority());
foreach ($providers as $provider) {
if ($provider->supports($context)) {
return $provider;
}
}
return null;
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Template\Components\Buttons\ButtonInterface;
use TYPO3\CMS\Backend\Template\Components\Buttons\PositionInterface;
/**
* Container for managing buttons in the backend module document header.
* The ButtonBar organizes buttons into two positions (left and right) with multiple groups,
* allowing precise control over button placement and visual grouping.
*
* Buttons are organized in a three-level structure:
* 1. Position: LEFT or RIGHT side of the header
* 2. Group: Numerical groups within each position (1, 2, 3, etc.)
* 3. Individual buttons within each group
*
* Groups are rendered with visual spacing between them, allowing logical grouping
* of related actions. Lower group numbers appear first (left-to-right, or top-to-bottom on mobile).
*
* Example - Using pre-configured buttons from ComponentFactory:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
*
* // Use pre-configured save button
* $saveButton = $this->componentFactory->createSaveButton('editform');
* $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 1);
*
* // Use pre-configured back button
* $backButton = $this->componentFactory->createBackButton($returnUrl);
* $buttonBar->addButton($backButton, ButtonBar::BUTTON_POSITION_LEFT, 2);
* }
* ```
*
* Example - Creating custom buttons with ComponentFactory:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
*
* // Create custom link button
* $customButton = $this->componentFactory->createLinkButton()
* ->setHref('/custom-action')
* ->setTitle('Custom Action')
* ->setIcon($iconFactory->getIcon('actions-custom'));
* $buttonBar->addButton($customButton, ButtonBar::BUTTON_POSITION_LEFT, 1);
* }
* ```
*
* Example - Buttons with automatic positioning:
*
* ```
* // ShortcutButton implements PositionInterface and positions itself automatically
* $shortcutButton = $this->componentFactory->createShortcutButton()
* ->setRouteIdentifier('my_module')
* ->setDisplayName('My Module');
* $buttonBar->addButton($shortcutButton); // Position and group are automatic
* ```
*
* Example - Dropdown button:
*
* ```
* $dropdownButton = $this->componentFactory->createDropDownButton()
* ->setLabel('Actions')
* ->setIcon($iconFactory->getIcon('actions-menu'));
*
* $item1 = $this->componentFactory->createDropDownItem()
* ->setLabel('Edit')
* ->setHref('/edit');
* $dropdownButton->addItem($item1);
*
* $item2 = $this->componentFactory->createDropDownItem()
* ->setLabel('Delete')
* ->setHref('/delete');
* $dropdownButton->addItem($item2);
*
* $buttonBar->addButton($dropdownButton, ButtonBar::BUTTON_POSITION_RIGHT, 1);
* ```
*
* @phpstan-type Buttons array<self::BUTTON_POSITION_*, array<int, list<ButtonInterface>>>
*/
#[Autoconfigure(public: true)]
class ButtonBar
{
/**
* Position constant for left side of the button bar
*/
public const BUTTON_POSITION_LEFT = 'left';
/**
* Position constant for right side of the button bar
*/
public const BUTTON_POSITION_RIGHT = 'right';
/**
* Internal array of all registered buttons
*
* @var Buttons
*/
protected array $buttons = [];
public function __construct(
protected readonly EventDispatcherInterface $eventDispatcher,
) {}
/**
* Add a new button
*
* Buttons implementing PositionInterface will automatically use their own
* predefined position and group, ignoring the $buttonPosition and $buttonGroup
* parameters. This ensures buttons like ShortcutButton always appear in their
* designated location.
*
* @param ButtonInterface $button The Button Object to add
* @param self::BUTTON_POSITION_* $buttonPosition Position of the button (left/right). Ignored if button implements PositionInterface.
* @param int $buttonGroup Buttongroup of the button. Ignored if button implements PositionInterface.
*
* @throws \InvalidArgumentException In case a button is not valid
*/
public function addButton(
ButtonInterface $button,
string $buttonPosition = self::BUTTON_POSITION_LEFT,
int $buttonGroup = 1
): static {
if (!$button->isValid()) {
throw new \InvalidArgumentException('Button "' . $button->getType() . '" is not valid', 1441706370);
}
// Buttons implementing PositionInterface define their own position and group
if ($button instanceof PositionInterface) {
$buttonPosition = $button->getPosition();
$buttonGroup = $button->getGroup();
}
// We make the button immutable here
$this->buttons[$buttonPosition][$buttonGroup][] = clone $button;
return $this;
}
/**
* Returns an associative array of all buttons in the form of
* ButtonPosition > ButtonGroup > Button
*/
public function getButtons(ServerRequestInterface $request): array
{
// here we need to call the sorting methods and stuff.
foreach ($this->buttons as $position => $_) {
ksort($this->buttons[$position]);
}
// Dispatch event for manipulating the docHeaderButtons
$this->buttons = $this->eventDispatcher->dispatch(new ModifyButtonBarEvent($this->buttons, $this, $request))->getButtons();
return $this->buttons;
}
/**
* Checks whether a button of the specified type has been added to the button bar.
*
* This is primarily used for backwards compatibility detection to prevent
* duplicate automatic buttons when controllers manually add them.
*
* @param class-string<ButtonInterface> $buttonClassName Fully qualified button class name
* @return bool True if a button of this type exists, false otherwise
*/
public function hasButtonOfType(string $buttonClassName): bool
{
foreach ($this->buttons as $groups) {
foreach ($groups as $groupButtons) {
foreach ($groupButtons as $button) {
if ($button instanceof $buttonClassName) {
return true;
}
}
}
}
return false;
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
use TYPO3\CMS\Backend\Template\Components\AbstractControl;
use TYPO3\CMS\Core\Imaging\Icon;
/**
* Base class for all button types in the backend document header.
* Provides common functionality for buttons including icon handling, label text display,
* and disabled state management.
*
* This class extends AbstractControl (providing title, classes, data attributes) and
* implements ButtonInterface (providing validation, type identification, and rendering).
*
* @see ButtonInterface
* @see AbstractControl
*/
class AbstractButton extends AbstractControl implements ButtonInterface
{
/**
* Optional icon to display on the button
*/
protected ?Icon $icon = null;
/**
* Whether to show the button's label text (from title property).
* If false, only the icon is shown (label is used for title attribute).
*/
protected bool $showLabelText = false;
/**
* Whether the button is in disabled state
*/
protected bool $disabled = false;
/**
* Attributes for the button
*/
protected array $attributes = [];
public function getShowLabelText(): bool
{
return $this->showLabelText;
}
public function setShowLabelText(bool $showLabelText): static
{
$this->showLabelText = $showLabelText;
return $this;
}
public function getIcon(): ?Icon
{
return $this->icon;
}
public function getType(): string
{
return static::class;
}
public function setIcon(?Icon $icon): static
{
$this->icon = $icon;
return $this;
}
public function isDisabled(): bool
{
return $this->disabled;
}
public function setDisabled(bool $disabled): static
{
$this->disabled = $disabled;
return $this;
}
/**
* @param array<string, string> $attributes
*/
public function setAttributes(array $attributes): static
{
$this->attributes = $attributes;
return $this;
}
/**
* @return array<string, string>
*/
public function getAttributes(): array
{
return $this->attributes;
}
/**
* Implementation from ButtonInterface
* This object is an abstract, so no implementation is necessary
*/
public function isValid(): bool
{
return false;
}
/**
* Implementation from ButtonInterface
* This object is an abstract, so no implementation is necessary
*/
public function __toString(): string
{
return '';
}
/**
* Implementation from ButtonInterface
* This object is an abstract, so no implementation is necessary
*
* @return string
*/
public function render(): string
{
return '';
}
}
@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons\Action;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\Buttons\ButtonInterface;
use TYPO3\CMS\Backend\Template\Components\Buttons\PositionInterface;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Shortcut button for the DocHeader that enables bookmarking of backend module states.
*
* This button allows users to create bookmarks to specific module views
* with their context (e.g., specific page, record, or configuration). The shortcut
* preserves the route and arguments for quick access later.
*
* The button is automatically added to all backend modules by default when shortcut
* context is provided. It's positioned on the right side of the button bar as the
* last button (group 91).
*
* Example - Using automatic bookmark button (recommended):
*
* ```
* public function myAction(ServerRequestInterface $request): ResponseInterface
* {
* $view = $this->moduleTemplateFactory->create($request);
*
* // Set shortcut context - button is added automatically
* $view->getDocHeaderComponent()->setShortcutContext(
* routeIdentifier: 'my_module',
* displayName: 'My Module: ' . $pageTitle,
* arguments: ['id' => $pageId]
* );
*
* return $view->renderResponse('MyTemplate');
* }
* ```
*
* Note: Manually creating and adding a ShortcutButton to the button bar is not supported.
* It is no longer detected and does not suppress the automatic shortcut button.
* Use DocHeaderComponent::setShortcutContext() instead.
*/
class ShortcutButton implements ButtonInterface, PositionInterface
{
protected string $routeIdentifier = '';
protected string $displayName = '';
/**
* @var array List of parameter/value pairs relevant for this bookmark
*/
protected array $arguments = [];
protected bool $copyUrlToClipboard = true;
protected bool $disabled = false;
public function getRouteIdentifier(): string
{
return $this->routeIdentifier;
}
public function setRouteIdentifier(string $routeIdentifier): static
{
$this->routeIdentifier = $routeIdentifier;
return $this;
}
public function getDisplayName(): string
{
return $this->displayName;
}
public function setDisplayName(string $displayName): static
{
$this->displayName = $displayName;
return $this;
}
public function setArguments(array $arguments): static
{
$this->arguments = $arguments;
return $this;
}
/**
* Defines whether the button should be extended to also allow
* copying the current URL to the operating systems' clipboard.
*/
public function setCopyUrlToClipboard(bool $copyUrlToClipboard): static
{
$this->copyUrlToClipboard = $copyUrlToClipboard;
return $this;
}
public function isDisabled(): bool
{
return $this->disabled;
}
public function setDisabled(bool $disabled): static
{
$this->disabled = $disabled;
return $this;
}
public function getPosition(): string
{
return ButtonBar::BUTTON_POSITION_RIGHT;
}
public function getGroup(): int
{
return 91;
}
public function getType(): string
{
return static::class;
}
public function isValid(): bool
{
return $this->displayName !== '' && $this->routeExists($this->routeIdentifier);
}
public function __toString(): string
{
return $this->render();
}
public function render(): string
{
$canCreateBookmark = $this->getBackendUser()->mayMakeShortcut();
// Early return in case the current user is not allowed to create bookmarks.
// Note: This is not checked in isValid(), since it only concerns the current
// user and does not mean, the button is not configured properly.
if (!$canCreateBookmark && !$this->copyUrlToClipboard) {
return '';
}
$routeIdentifier = $this->routeIdentifier;
$arguments = $this->arguments;
$pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
// The route parameter is not needed, since this is already provided with the $routeIdentifier
unset($arguments['route']);
// returnUrl should not be stored in the database
unset($arguments['returnUrl']);
// Encode arguments to be stored in the database
$encodedArguments = json_encode($arguments) ?: '';
if ($canCreateBookmark && !$this->copyUrlToClipboard) {
// Load the bookmark button custom element
$pageRenderer->loadJavaScriptModule('@typo3/backend/bookmark/element/bookmark-button-element.js');
return $this->renderBookmarkButton($routeIdentifier, $encodedArguments);
}
$iconFactory = GeneralUtility::makeInstance(IconFactory::class);
$dropdownItems = [];
$componentFactory = GeneralUtility::makeInstance(ComponentFactory::class);
// Bookmark Button
if ($canCreateBookmark) {
// Load the bookmark button custom element
$pageRenderer->loadJavaScriptModule('@typo3/backend/bookmark/element/bookmark-button-element.js');
$bookmarkItem = $componentFactory->createDropDownGeneric();
$bookmarkItem->setTag('typo3-backend-bookmark-button');
$bookmarkItem->setAttributes([
'route' => $routeIdentifier,
'arguments' => $encodedArguments,
'display-name' => $this->displayName,
]);
$dropdownItems[] = $bookmarkItem;
}
// Clipboard Button
if ($this->copyUrlToClipboard) {
$pageRenderer->loadJavaScriptModule('@typo3/backend/copy-to-clipboard.js');
$clipboardItem = $componentFactory->createDropDownItem();
$clipboardItem->setTag('typo3-copy-to-clipboard');
$clipboardItem->setLabel($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.copyCurrentUrl'));
$clipboardItem->setAttributes([
'text' => (string)GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute(
$routeIdentifier,
$arguments,
UriBuilder::SHAREABLE_URL
),
]);
$clipboardItem->setIcon($iconFactory->getIcon('actions-link', IconSize::SMALL));
$dropdownItems[] = $clipboardItem;
}
$dropdownButton = $componentFactory->createDropDownButton();
$dropdownButton->setLabel($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.share'));
$dropdownButton->setIcon($iconFactory->getIcon('actions-share-alt', IconSize::SMALL));
$dropdownButton->setDisabled($this->isDisabled());
foreach ($dropdownItems as $dropdownItem) {
$dropdownButton->addItem($dropdownItem);
}
return (string)$dropdownButton;
}
/**
* Renders the bookmark button custom element.
*/
protected function renderBookmarkButton(string $routeIdentifier, string $encodedArguments): string
{
$attributes = [
'class' => 'btn btn-default btn-sm',
'route' => $routeIdentifier,
'arguments' => $encodedArguments,
'display-name' => $this->displayName,
'hide-label-text' => 'true',
];
return '<typo3-backend-bookmark-button ' . GeneralUtility::implodeAttributes($attributes, true) . '></typo3-backend-bookmark-button>';
}
protected function routeExists(string $routeIdentifier): bool
{
return GeneralUtility::makeInstance(Router::class)->hasRoute($routeIdentifier);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
use TYPO3\CMS\Backend\Template\Components\ComponentInterface;
/**
* Interface for buttons in the document header.
*
* All button types (LinkButton, InputButton, DropDownButton, etc.) must implement
* this interface to be added to the ButtonBar.
*
* This interface extends ComponentInterface, which provides the common contract
* for all renderable backend components.
*/
interface ButtonInterface extends ComponentInterface {}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
enum ButtonSize: string
{
case SMALL = 'btn-sm';
case MEDIUM = '';
// large does not exist on purpose, see styleguide
}
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons\DropDown;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Base class for all dropdown menu items that can be added to DropDownButton or SplitButton.
* Provides common functionality for dropdown items including icon handling, labels, links,
* custom HTML tags, and active state management.
*
* Implements DropDownItemInterface, providing validation, type identification, and rendering
* capabilities for all dropdown items.
*/
abstract class AbstractDropDownItem implements \Stringable
{
/**
* HTML tag name for the dropdown item element (e.g., 'a', 'button', custom elements)
*/
protected string $tag = 'a';
/**
* Optional icon displayed before the label
*/
protected ?Icon $icon = null;
/**
* Text content/label of the dropdown item
*/
protected ?string $label = null;
/**
* Tooltip/title attribute (defaults to label if not explicitly set)
*/
protected ?string $title = null;
/**
* URL/href for link items
*/
protected ?string $href = null;
/**
* Custom HTML attributes for the element
*
* @var array<string, string>
*/
protected array $attributes = [];
/**
* Whether this item is currently active/selected
*/
protected bool $active = false;
public function setTag(string $tag): static
{
$this->tag = htmlspecialchars(trim($tag));
return $this;
}
public function getTag(): string
{
return $this->tag;
}
public function getIcon(): ?Icon
{
return $this->icon;
}
public function setIcon(?Icon $icon): static
{
$icon?->setSize(IconSize::SMALL);
$this->icon = $icon;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
public function getTitle(): ?string
{
return $this->title ?? $this->label;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getHref(): ?string
{
return $this->href;
}
public function setHref(?string $href): static
{
$this->href = $href;
return $this;
}
/**
* @param array<string, string> $attributes
*/
public function setAttributes(array $attributes): static
{
$this->attributes = $attributes;
return $this;
}
public function setAttribute(string $name, string $value): static
{
$this->attributes[$name] = $value;
return $this;
}
/**
* @return array<string, string>
*/
public function getAttributes(): array
{
return $this->attributes;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): static
{
$this->active = $active;
return $this;
}
public function isValid(): bool
{
return $this->getLabel() !== null && trim($this->getLabel()) !== '';
}
public function getType(): string
{
return static::class;
}
protected function getAttributesString(): string
{
$attributes = $this->getAttributes();
$attributes['class'] = rtrim('dropdown-item dropdown-item-spaced ' . ($attributes['class'] ?? ''));
if ($this->isActive()) {
$attributes['aria-selected'] = 'true';
}
if ($this->getHref()) {
$attributes['href'] = $this->getHref();
}
if ($this->getTitle()) {
$attributes['title'] = $this->getTitle();
}
return GeneralUtility::implodeAttributes($attributes, true);
}
protected function getRenderedIcon(): string
{
return $this->getIcon()?->render() ?? '';
}
abstract public function render(): string;
public function __toString(): string
{
return $this->render();
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons\DropDown;
/**
* This dropdown item type renders the divider element.
*
* Example:
*
* ```
* $item = $this->componentFactory->createDropDownDivider()
* $dropDownButton->addItem($item);
* ```
*/
class DropDownDivider implements DropDownItemInterface
{
public function getType(): string
{
return static::class;
}
public function isValid(): bool
{
return true;
}
public function render(): string
{
return '<hr class="dropdown-divider">';
}
public function __toString(): string
{
return $this->render();
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons\DropDown;
/**
* Dropdown item with relaxed validation - like DropDownItem but label is optional.
*
* Use for web components or custom elements that render their own content.
*/
class DropDownGeneric extends AbstractDropDownItem implements DropDownItemInterface
{
public function isValid(): bool
{
return $this->tag !== '';
}
public function render(): string
{
return sprintf(
'<%1$s %2$s>%3$s%4$s</%1$s>',
$this->getTag(),
$this->getAttributesString(),
$this->getRenderedIcon(),
htmlspecialchars($this->getLabel() ?? '')
);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons\DropDown;
/**
* This dropdown item type renders a noninteractive text element
* to group items and gives more meaning to a set of options.
*
* Example:
*
* ```
* $item = $this->componentFactory->createDropDownHeader()->setLabel('Label');
* $dropDownButton->addItem($item);
* ```
*/
class DropDownHeader implements DropDownItemInterface
{
protected ?string $label = null;
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
public function getType(): string
{
return static::class;
}
public function isValid(): bool
{
return $this->getLabel() !== null && trim($this->getLabel()) !== '';
}
public function render(): string
{
return '<h6 class="dropdown-header">' . htmlspecialchars(trim($this->getLabel())) . '</h6>';
}
public function __toString(): string
{
return $this->render();
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons\DropDown;
/**
* This dropdown item type renders a simple element.
* Use this element if you need a link, button.
*
* Example:
*
* ```
* $item = $this->componentFactory->createDropDownItem()
* ->setTag('a')
* ->setHref('#')
* ->setLabel('Label')
* ->setTitle('Title')
* ->setIcon($this->iconFactory->getIcon('actions-heart'))
* ->setAttributes(['data-value' => '123']);
* $dropDownButton->addItem($item);
* ```
*/
class DropDownItem extends AbstractDropDownItem implements DropDownItemInterface
{
public function render(): string
{
return sprintf(
'<%1$s %2$s>%3$s%4$s</%1$s>',
$this->getTag(),
$this->getAttributesString(),
$this->getIcon()?->render() ?? '',
htmlspecialchars($this->getLabel())
);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons\DropDown;
use TYPO3\CMS\Backend\Template\Components\ComponentInterface;
/**
* Interface for dropdown items that can be added to a DropDownButton.
*
* Dropdown items include interactive elements (DropDownItem, DropDownRadio, DropDownToggle)
* and structural elements (DropDownHeader, DropDownDivider).
*
* This interface extends ComponentInterface, which provides the common contract
* for all renderable backend components.
*/
interface DropDownItemInterface extends ComponentInterface {}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons\DropDown;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This dropdown item type renders an element with an active state.
* Use this element to display a radio-like selection of a state.
* When set to active, it will show a dot in front of the icon and
* text to indicate that this is the current selection.
*
* At least 2 of these items need to exist within a dropdown button,
* so a user has a choice of a state to select.
*
* Example:
*
* ```
* $item = $this->componentFactory->createDropDownRadio()
* ->setHref('#')
* ->setActive(true)
* ->setLabel('List')
* ->setTitle('List')
* ->setIcon($this->iconFactory->getIcon('actions-viewmode-list'))
* ->setAttributes(['data-type' => 'list']);
* $dropDownButton->addItem($item);
*
* $item = $this->componentFactory->createDropDownRadio()
* ->setHref('#')
* ->setActive(false)
* ->setLabel('Tiles')
* ->setTitle('Tiles')
* ->setIcon($this->iconFactory->getIcon('actions-viewmode-tiles'))
* ->setAttributes(['data-type' => 'tiles']);
* $dropDownButton->addItem($item);
* ```
*/
class DropDownRadio extends AbstractDropDownItem implements DropDownItemInterface
{
public function render(): string
{
$iconFactory = GeneralUtility::makeInstance(IconFactory::class);
$statusIcon = $this->isActive()
? '<span class="text-primary">' . $iconFactory->getIcon('actions-dot', IconSize::SMALL)->render() . '</span>'
: $iconFactory->getIcon('empty-empty', IconSize::SMALL)->render();
return sprintf(
'<%1$s %2$s>%3$s%4$s%5$s</%1$s>',
$this->getTag(),
$this->getAttributesString(),
$statusIcon,
$this->getRenderedIcon(),
htmlspecialchars($this->getLabel())
);
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons\DropDown;
/**
* This dropdown item type renders an element with an active state.
* When set to active, it will show a checkmark in front of the icon
* and text to indicate the current state.
*
* Example:
*
* ```
* $item = $this->componentFactory->createDropDownToggle()
* ->setHref('#')
* ->setActive(true)
* ->setLabel('Label')
* ->setTitle('Title')
* ->setIcon($this->iconFactory->getIcon('actions-heart'))
* ->setAttributes(['data-value' => '123']);
* $dropDownButton->addItem($item);
* ```
*/
class DropDownToggle extends AbstractDropDownItem implements DropDownItemInterface
{
public function render(): string
{
// Status Icon
$this->setAttribute('data-dropdowntoggle-status', $this->isActive() ? 'active' : 'inactive');
return sprintf(
'<%1$s %2$s><span class="dropdown-item-status"></span>%3$s%4$s</%1$s>',
$this->getTag(),
$this->getAttributesString(),
$this->getRenderedIcon(),
htmlspecialchars($this->getLabel())
);
}
}
@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownItemInterface;
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownRadio;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This button type is a container for dropdown items.
* It will render a dropdown containing all items attached
* to it. There are different kinds available, each item
* needs to implement the DropDownItemInterface. When this
* type contains elements of type DropDownRadio it will use
* the icon of the first active item of this type.
*
* Example:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
* $dropDownButton = $this->componentFactory->createDropDownButton()
* ->setLabel('Dropdown')
* ->setTitle('Save')
* ->setIcon($this->iconFactory->getIcon('actions-heart'))
* ->setShowLabelText(true)
* ->addItem(
* $this->componentFactory->createDropDownItem()
* ->setLabel('Item')
* ->setHref('#')
* );
* $buttonBar->addButton($dropDownButton, ButtonBar::BUTTON_POSITION_RIGHT, 2);
* }
* ```
*/
class DropDownButton implements ButtonInterface
{
protected ?Icon $icon = null;
protected string $label = '';
protected ?string $title = null;
protected array $items = [];
protected bool $showLabelText = false;
protected bool $showActiveLabelText = false;
protected bool $disabled = false;
protected ButtonSize $size = ButtonSize::SMALL;
public function getIcon(): ?Icon
{
return $this->icon;
}
public function setIcon(?Icon $icon): static
{
$icon?->setSize(IconSize::SMALL);
$this->icon = $icon;
return $this;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getTitle(): string
{
return $this->title ?? $this->label;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getShowLabelText(): bool
{
return $this->showLabelText;
}
public function setShowLabelText(bool $showLabelText): static
{
$this->showLabelText = $showLabelText;
return $this;
}
public function getShowActiveLabelText(): bool
{
return $this->showActiveLabelText;
}
public function setShowActiveLabelText(bool $showActiveLabelText): static
{
$this->showActiveLabelText = $showActiveLabelText;
return $this;
}
public function isDisabled(): bool
{
return $this->disabled;
}
public function setDisabled(bool $disabled): static
{
$this->disabled = $disabled;
return $this;
}
public function addItem(DropDownItemInterface $item): static
{
if (!$item->isValid()) {
throw new \InvalidArgumentException(
'Only valid items may be assigned to a DropdownButton. "'
. $item->getType()
. '" did not pass validation',
1667645426
);
}
$this->items[] = clone $item;
return $this;
}
public function getSize(): ButtonSize
{
return $this->size;
}
public function setSize(ButtonSize $size): DropDownButton
{
$this->size = $size;
return $this;
}
/**
* @return DropDownItemInterface[]
*/
public function getItems(): array
{
return $this->items;
}
public function isValid(): bool
{
return !empty($this->getLabel())
&& ($this->getShowLabelText() || $this->getIcon())
&& !empty($this->getItems());
}
public function getType(): string
{
return static::class;
}
public function render(): string
{
$items = $this->getItems();
/**
* @var DropDownRadio|null $activeItem
*/
$activeItem = null;
/**
* @var DropDownRadio[] $activeItems
*/
$activeItems = array_filter($items, static function (DropDownItemInterface $item): bool {
return $item instanceof DropDownRadio && $item->isActive();
});
if (!empty($activeItems)) {
$activeItem = array_shift($activeItems);
}
$attributes = [
'type' => 'button',
'class' => 'btn ' . $this->getSize()->value . ' btn-default dropdown-toggle',
'data-bs-toggle' => 'dropdown',
'aria-expanded' => 'false',
];
if ($this->isDisabled()) {
$attributes['disabled'] = 'disabled';
}
$buttonLabel = '';
if ($this->getShowLabelText()) {
if ($activeItem !== null && $this->getShowActiveLabelText()) {
// Render label with visually-hidden span for screen readers
$buttonLabel = '<span class="visually-hidden">' . htmlspecialchars($this->getLabel()) . ':</span> ' . htmlspecialchars($activeItem->getLabel());
} else {
// No active item or showActiveLabelText is false, just render the label
$buttonLabel = htmlspecialchars($this->getLabel());
}
} else {
// Add aria-label and title for accessibility when label text is not shown
// Both serve different purposes: aria-label for screen readers, title for visual tooltip
if ($activeItem !== null && $this->getShowActiveLabelText()) {
$attributes['aria-label'] = htmlspecialchars($this->getTitle() . ': ' . $activeItem->getLabel());
$attributes['title'] = htmlspecialchars($this->getTitle() . ': ' . $activeItem->getLabel());
} else {
$attributes['aria-label'] = $this->getTitle();
$attributes['title'] = $this->getTitle();
}
}
$icon = $activeItem?->getIcon()?->render() ?? $this->getIcon()?->render() ?? '';
$buttonContent = $icon . ($icon !== '' && $buttonLabel !== '' ? ' ' : '') . $buttonLabel;
return sprintf(
'<div class="btn-group"><button %s>%s</button><ul class="dropdown-menu">%s</ul></div>',
GeneralUtility::implodeAttributes($attributes, true),
$buttonContent,
implode('', array_map(static fn(DropDownItemInterface $item) => '<li>' . $item->render() . '</li>', $items))
);
}
public function __toString(): string
{
return $this->render();
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
/**
* This button type is an intermediate solution for buttons that are rendered
* by methods from TYPO3 itself, like the CSH buttons or Bookmark buttons.
*
* There should be no need to use them, so do yourself a favour and don't.
*
* Example:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
* $myButton = $this->componentFactory->createFullyRenderedButton()
* ->setHtmlSource('<span class="i-should-not-be-using-this>Foo</span>');
* $buttonBar->addButton($myButton, ButtonBar::BUTTON_POSITION_LEFT, 1);
* }
* ```
*/
class FullyRenderedButton implements ButtonInterface
{
/**
* The full HTML source of the rendered button.
* This source will be passed through to the frontend as is, so keep htmlspecialchars() in mind.
*
* @var string
*/
protected string $htmlSource = '';
public function getHtmlSource(): string
{
return $this->htmlSource;
}
public function setHtmlSource(string $htmlSource): static
{
$this->htmlSource = $htmlSource;
return $this;
}
public function getType(): string
{
return static::class;
}
public function isValid(): bool
{
return trim($this->getHtmlSource()) !== '' && $this->getType() === static::class;
}
public function __toString(): string
{
return $this->render();
}
public function render(): string
{
return '<span class="btn btn-sm btn-default">' . $this->getHtmlSource() . '</span>';
}
}
@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* A flexible button type that allows rendering any HTML tag (button, anchor, or custom
* web components) with full control over attributes. Unlike other button types that
* extend AbstractButton, GenericButton implements ButtonInterface directly, providing
* maximum flexibility for custom implementations.
*
* Use cases:
* - Custom web components (e.g., <typo3-custom-element>)
* - Standard buttons with custom attributes
* - Links with button styling
* - Any HTML element that should appear as a button in the document header
*
* Key features:
* - Configurable HTML tag (button, a, custom elements)
* - Full attribute control (href, data attributes, etc.)
* - Icon and label support
* - Automatic HTML escaping for security
*
* Example - Standard button:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
* $button = $this->componentFactory->createGenericButton()
* ->setTag('button')
* ->setLabel('Save')
* ->setTitle('Save changes')
* ->setIcon($iconFactory->getIcon('actions-save'))
* ->setShowLabelText(true)
* ->setAttributes(['type' => 'submit', 'form' => 'myForm']);
* $buttonBar->addButton($button);
* }
* ```
*
* Example - Web component:
*
* ```
* $button = $this->componentFactory->createGenericButton()
* ->setTag('typo3-my-action-button')
* ->setLabel('Custom Action')
* ->setIcon($iconFactory->getIcon('actions-heart'))
* ->setAttributes([
* 'url' => '/my-action',
* 'data-value' => '123'
* ]);
* $buttonBar->addButton($button);
* ```
*
* Example - Link styled as button:
*
* ```
* $button = $this->componentFactory->createGenericButton()
* ->setTag('a')
* ->setHref('/target-page')
* ->setLabel('View Page')
* ->setTitle('Open target page')
* ->setIcon($iconFactory->getIcon('actions-view'));
* $buttonBar->addButton($button);
* ```
*/
class GenericButton implements ButtonInterface
{
protected string $tag = 'button';
protected ?Icon $icon = null;
protected string $label = '';
protected ?string $title = null;
protected ?string $href = null;
protected string $classes = '';
protected ButtonSize $size = ButtonSize::SMALL;
protected array $attributes = [];
protected bool $showLabelText = false;
public function setTag(string $tag): static
{
$this->tag = htmlspecialchars(trim($tag));
return $this;
}
public function getTag(): string
{
return $this->tag;
}
public function getIcon(): ?Icon
{
return $this->icon;
}
public function setIcon(?Icon $icon): static
{
$icon?->setSize(IconSize::SMALL);
$this->icon = $icon;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
public function getTitle(): ?string
{
return $this->title ?? $this->label;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getHref(): ?string
{
return $this->href;
}
public function setHref(?string $href): static
{
$this->href = $href;
return $this;
}
public function getClasses(): string
{
return $this->classes;
}
public function setClasses(string $classes): static
{
$this->classes = $classes;
return $this;
}
public function getSize(): ButtonSize
{
return $this->size;
}
public function setSize(ButtonSize $size): static
{
$this->size = $size;
return $this;
}
/**
* @param array<string, string> $attributes
*/
public function setAttributes(array $attributes): static
{
$this->attributes = $attributes;
return $this;
}
/**
* @return array<string, string>
*/
public function getAttributes(): array
{
return $this->attributes;
}
public function getShowLabelText(): bool
{
return $this->showLabelText;
}
public function setShowLabelText(bool $showLabelText): static
{
$this->showLabelText = $showLabelText;
return $this;
}
public function isValid(): bool
{
return trim($this->getLabel()) !== ''
&& $this->getType() === static::class
&& $this->getIcon() !== null;
}
public function getType(): string
{
return static::class;
}
protected function getAttributesString(): string
{
$attributes = $this->getAttributes();
$attributes['class'] = rtrim('btn ' . $this->getSize()->value . ' btn-default ' . $this->getClasses());
if ($this->getHref()) {
$attributes['href'] = $this->getHref();
}
if ($this->getTitle()) {
$attributes['title'] = $this->getTitle();
}
return GeneralUtility::implodeAttributes($attributes, true);
}
public function render(): string
{
return sprintf(
'<%1$s %2$s>%3$s%4$s</%1$s>',
$this->getTag(),
$this->getAttributesString(),
$this->getIcon()?->render() ?? '',
htmlspecialchars($this->getShowLabelText() ? $this->getLabel() : '')
);
}
public function __toString(): string
{
return $this->render();
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This button type renders a HTML tag <button> and takes the HTML attributes
* name and value as additional attributes to those defined in AbstractButton.
*
* Since we no longer want to have any <input type="submit" /> in the TYPO3 core
* you should use this button type to send forms
*
* Example:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
* $saveButton = $this->componentFactory->createInputButton()
* ->setName('save')
* ->setValue('1')
* ->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL))
* ->setTitle('Save');
* $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 1);
* }
* ```
*/
class InputButton extends AbstractButton
{
protected string $name = '';
protected string $value = '';
protected string $form = '';
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getValue(): string
{
return $this->value;
}
public function setValue(string $value): static
{
$this->value = $value;
return $this;
}
public function getForm(): string
{
return $this->form;
}
public function setForm(string $form): static
{
$this->form = $form;
return $this;
}
public function isValid(): bool
{
return trim($this->getName()) !== ''
&& trim($this->getValue()) !== ''
&& trim($this->getTitle()) !== ''
&& $this->getType() === static::class
&& $this->getIcon() !== null;
}
public function render(): string
{
$attributes = [
'name' => $this->getName(),
'class' => 'btn btn-sm btn-default ' . $this->getClasses(),
'value' => $this->getValue(),
'title' => $this->getTitle(),
'form' => trim($this->getForm()),
];
if ($this->isDisabled()) {
$attributes['disabled'] = 'disabled';
}
$labelText = '';
if ($this->showLabelText) {
$labelText = ' ' . $this->title;
}
foreach ($this->attributes as $attributeName => $attributeValue) {
$attributes[$attributeName] = $attributeValue;
}
foreach ($this->dataAttributes as $attributeName => $attributeValue) {
$attributes['data-' . $attributeName] = $attributeValue;
}
return sprintf(
'<button %s>%s%s</button>',
GeneralUtility::implodeAttributes($attributes, true),
$this->getIcon()?->render() ?? '',
htmlspecialchars($labelText)
);
}
public function __toString(): string
{
return $this->render();
}
}
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
use TYPO3\CMS\Backend\Context\PageContext;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
/**
* Builds the language selector dropdown for backend modules (Page, List).
*
* This centralizes the language selection UI logic to ensure consistent behavior
* across all modules. It handles both single-select (radio buttons) and multi-select
* (toggles/checkboxes) modes.
*
* @internal
*/
final readonly class LanguageSelectorBuilder
{
public function __construct(
private ComponentFactory $componentFactory,
private IconFactory $iconFactory,
) {}
public function build(
PageContext $pageContext,
LanguageSelectorMode $mode,
\Closure $urlBuilder,
bool $showToggleAll = true,
): ButtonInterface {
$languageService = $this->getLanguageService();
$languageInfo = $pageContext->languageInformation;
$selectedLanguages = $pageContext->selectedLanguageIds;
$availableLanguages = $languageInfo->availableLanguages;
// Calculate selector label and icon
$selectorIcon = null;
$selectedExistingLanguages = array_intersect($selectedLanguages, $languageInfo->getAllExistingLanguageIds());
if (count($selectedExistingLanguages) === 1 && reset($selectedExistingLanguages) === 0) {
// Only default language exists/selected - show the language title and flag
$defaultLanguage = $availableLanguages[0] ?? null;
if ($defaultLanguage) {
$selectorLabel = $defaultLanguage->getTitle();
$selectorIcon = $this->iconFactory->getIcon($defaultLanguage->getFlagIdentifier());
} else {
$selectorLabel = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.language');
}
} else {
// Multiple languages being shown
$displayedLanguageCount = count($selectedExistingLanguages);
// In comparison mode, default language (0) is always shown
// If it's not in the selected list, it was auto-added, so increment the count
if (!in_array(0, $selectedExistingLanguages, true)) {
$displayedLanguageCount++;
}
$selectorLabel = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.languages') . ' (' . $displayedLanguageCount . ')';
$selectorIcon = $this->iconFactory->getIcon('flags-multiple');
}
$languageDropDownButton = $this->componentFactory->createDropDownButton()
->setLabel($selectorLabel)
->setShowActiveLabelText(true)
->setShowLabelText(true);
if ($selectorIcon !== null) {
$languageDropDownButton->setIcon($selectorIcon);
}
$defaultLanguageItem = null;
$existingLanguageItems = [];
$newLanguageItems = [];
foreach ($languageInfo->languageItems as $languageItem) {
if (!$languageItem->isAvailable()) {
// Skip unavailable languages (no permission to create)
continue;
}
if ($languageItem->isCreatable()) {
// Language doesn't exist yet - show "Create translation" button
$createTranslationHelpText = sprintf(
$languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createTranslationFor'),
$languageItem->getTitle()
);
$item = $this->componentFactory->createDropDownItem()
->setTag('typo3-backend-localization-button')
->setIcon($this->iconFactory->getIcon($languageItem->getFlagIdentifier()))
->setLabel($languageItem->getTitle())
->setTitle($createTranslationHelpText)
->setAttributes([
'record-type' => 'pages',
'record-uid' => (string)$pageContext->pageId,
'target-language' => (string)$languageItem->getLanguageId(),
'aria-label' => $createTranslationHelpText,
]);
$newLanguageItems[] = $item;
} else {
// Language exists - build selection item
if ($mode === LanguageSelectorMode::MULTI_SELECT) {
// Multi-select mode: Use toggles/checkboxes
if ($languageItem->getLanguageId() === 0) {
// Default language is always selected and disabled in multi-select
// Store separately to add divider after it
// Add accessibility attributes to explain why it's disabled
$defaultLanguageHelpText = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.defaultLanguageAlwaysShown');
$defaultLanguageItem = $this->componentFactory->createDropDownToggle()
->setActive(true)
->setIcon($this->iconFactory->getIcon($languageItem->getFlagIdentifier()))
->setHref('#')
->setLabel($languageItem->getTitle())
->setTitle($defaultLanguageHelpText)
->setAttributes([
'disabled' => 'disabled',
'aria-label' => $languageItem->getTitle() . ' (' . $defaultLanguageHelpText . ')',
]);
// Don't add to $existingLanguageItems - will be added separately with divider
continue;
}
$isSelected = in_array($languageItem->getLanguageId(), $selectedLanguages, true);
$newSelectedLanguages = $selectedLanguages;
if ($isSelected) {
// Deselect this language
$newSelectedLanguages = array_values(array_diff($newSelectedLanguages, [$languageItem->getLanguageId()]));
// Ensure default language (0) is always present
if (!in_array(0, $newSelectedLanguages, true)) {
array_unshift($newSelectedLanguages, 0);
}
} else {
// Select this language
$newSelectedLanguages = array_unique(array_merge($newSelectedLanguages, [$languageItem->getLanguageId()]));
}
$item = $this->componentFactory->createDropDownToggle()
->setActive($isSelected)
->setIcon($this->iconFactory->getIcon($languageItem->getFlagIdentifier()))
->setHref($urlBuilder($newSelectedLanguages))
->setLabel($languageItem->getTitle());
} else {
// Single-select mode: Use radio buttons
$item = $this->componentFactory->createDropDownRadio()
->setActive($languageItem->getLanguageId() === $pageContext->getPrimaryLanguageId())
->setIcon($this->iconFactory->getIcon($languageItem->getFlagIdentifier()))
->setHref($urlBuilder([$languageItem->getLanguageId()]))
->setLabel($languageItem->getTitle());
}
$existingLanguageItems[] = $item;
}
}
// Add default language first (if in multi-select mode)
if ($defaultLanguageItem !== null) {
$languageDropDownButton->addItem($defaultLanguageItem);
// Add divider after default language to visually separate it from other languages
if (!empty($existingLanguageItems)) {
$languageDropDownButton->addItem($this->componentFactory->createDropDownDivider());
}
}
// Add other existing languages
foreach ($existingLanguageItems as $existingItem) {
$languageDropDownButton->addItem($existingItem);
}
// Add "Toggle All" option for multi-select mode AFTER the language list
// (so users see what they're toggling before the action)
if ($mode === LanguageSelectorMode::MULTI_SELECT && $showToggleAll && !empty($languageInfo->existingTranslations)) {
$languageDropDownButton->addItem($this->componentFactory->createDropDownDivider());
$areAllSelected = empty(array_diff(array_keys($languageInfo->existingTranslations), array_diff($selectedLanguages, [0])));
$toggleUrl = $urlBuilder($areAllSelected ? [0] : $languageInfo->getAllExistingLanguageIds());
// Count how many languages can be toggled (excluding default)
$toggleableCount = count($languageInfo->existingTranslations);
if ($areAllSelected) {
$toggleLabel = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.uncheckAll') . ' (' . $toggleableCount . ')';
$toggleIcon = 'actions-selection-elements-none';
$toggleTitle = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.deselectAllLanguages');
} else {
$toggleLabel = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.checkAll') . ' (' . $toggleableCount . ')';
$toggleIcon = 'actions-selection-elements-all';
$toggleTitle = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.selectAllLanguages');
}
$toggleItem = $this->componentFactory->createDropDownItem()
->setIcon($this->iconFactory->getIcon($toggleIcon))
->setHref($toggleUrl)
->setLabel($toggleLabel)
->setTitle($toggleTitle)
->setAttributes([
'aria-label' => $toggleTitle,
]);
$languageDropDownButton->addItem($toggleItem);
}
// Add separator and new languages if any with stronger visual separation
if (!empty($newLanguageItems)) {
$languageDropDownButton->addItem($this->componentFactory->createDropDownDivider());
$languageDropDownButton->addItem(
$this->componentFactory->createDropDownHeader()
->setLabel($languageService->sL('core.core:labels.new_page_translation'))
);
foreach ($newLanguageItems as $item) {
$languageDropDownButton->addItem($item);
}
}
return $languageDropDownButton;
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
/**
* Defines how the language selector dropdown should behave.
*
* @internal
*/
enum LanguageSelectorMode
{
/**
* Single language selection using radio buttons.
* Used in Page Module's "Layout" view where only one language is displayed at a time.
*/
case SINGLE_SELECT;
/**
* Multiple language selection using checkboxes/toggles.
* Used in Page Module's "Comparison" view and records module where multiple languages can be viewed simultaneously.
*/
case MULTI_SELECT;
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This button type renders a regular anchor tag with TYPO3s way to render a
* button control.
*
* Example:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
* $saveButton = $this->componentFactory->createLinkButton()
* ->setHref('#')
* ->setDataAttributes([
* 'foo' => 'bar'
* ])
* ->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL))
* ->setTitle('Save');
* $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 1);
* }
* ```
*/
class LinkButton extends AbstractButton
{
protected string $href = '';
protected string $role = 'button';
protected ButtonSize $size = ButtonSize::SMALL;
public function getHref(): string
{
return $this->href;
}
public function setHref(string $href): static
{
$this->href = $href;
return $this;
}
public function getRole(): string
{
return $this->role;
}
public function setRole(string $role): static
{
$this->role = $role;
return $this;
}
public function getSize(): ButtonSize
{
return $this->size;
}
public function setSize(ButtonSize $size): static
{
$this->size = $size;
return $this;
}
public function isValid(): bool
{
return trim($this->getHref()) !== ''
&& trim($this->getTitle()) !== ''
&& $this->getType() === static::class
&& $this->getIcon() !== null;
}
public function render(): string
{
$attributes = [
'role' => $this->getRole(),
'href' => $this->getHref(),
// @see SplitButton - hard-coded replacement for this hard-coded class-list
'class' => 'btn ' . $this->getSize()->value . ' btn-default ' . $this->getClasses(),
'title' => $this->getTitle(),
];
$labelText = '';
if ($this->showLabelText) {
$labelText = ' ' . $this->title;
}
foreach ($this->attributes as $attributeName => $attributeValue) {
$attributes[$attributeName] = $attributeValue;
}
foreach ($this->dataAttributes as $attributeName => $attributeValue) {
$attributes['data-' . $attributeName] = $attributeValue;
}
if ($this->isDisabled()) {
$attributes['aria-disabled'] = 'true';
$attributes['class'] .= ' disabled';
}
return sprintf(
'<a %s>%s%s</a>',
GeneralUtility::implodeAttributes($attributes, true),
$this->getIcon()?->render() ?? '',
htmlspecialchars($labelText),
);
}
public function __toString(): string
{
return $this->render();
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
/**
* Interface for buttons that define their own fixed position and group.
*
* Buttons implementing this interface will automatically override the position
* and group parameters passed to ButtonBar::addButton(), ensuring they always
* appear in their designated location regardless of what the developer specifies.
*
* This is useful for buttons that should always appear in a consistent location
* across the backend, such as the ShortcutButton which always appears in the
* top right corner.
*
* Example implementation:
*
* ```
* class MyButton implements ButtonInterface, PositionInterface
* {
* public function getPosition(): string
* {
* return ButtonBar::BUTTON_POSITION_RIGHT;
* }
*
* public function getGroup(): int
* {
* return 90;
* }
* }
* ```
*
* Usage:
*
* ```
* $button = $buttonBar->makeMyButton();
* // Position and group are ignored - button defines its own
* $buttonBar->addButton($button);
* ```
*/
interface PositionInterface
{
/**
* Returns the position where this button should be rendered.
*
* @return string Either ButtonBar::BUTTON_POSITION_LEFT or ButtonBar::BUTTON_POSITION_RIGHT
*/
public function getPosition(): string;
/**
* Returns the group number for this button.
*
* Groups determine the visual grouping and order of buttons within a position.
* Lower numbers appear first.
*
* @return int The group number (e.g., 1, 10, 90, 91)
*/
public function getGroup(): int;
}
@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This button type renders a bootstrap split button.
* It takes multiple button objects as parameters.
*
* The button objects must contain at least one primary
* button that is displayed as the main icon, and all other
* items will be revealed within a dropdown.
*
* If a button is of Type "LinkButton" it will not utilize a
* HTML `<button>` tag, but instead use `<a>`.
*
* Example:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
*
* $saveButton = $this->componentFactory->createInputButton()
* ->setName('save')
* ->setValue('1')
* ->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL))
* ->setTitle('Save');
*
* $saveAndCloseButton = $this->componentFactory->createInputButton()
* ->setName('save_and_close')
* ->setValue('1')
* ->setTitle('Save and close')
* ->setIcon($this->iconFactory->getIcon('actions-document-save-close', IconSize::SMALL));
*
* $saveAndShowPageButton = $this->componentFactory->createInputButton()
* ->setName('save_and_show')
* ->setValue('1')
* ->setTitle('Save and show')
* ->setIcon($this->iconFactory->getIcon('actions-document-save-view', IconSize::SMALL));
*
* $moduleLink = $this->componentFactory->createLinkButton()
* ->setHref((string)$this->uriBuilder->buildUriFromRoute('file_edit', $parameter))
* ->setDataAttributes(['customAttribute' => 'customValue'])
* ->setShowLabelText(true)
* ->setTitle('Edit file')
* ->setIcon($this->iconFactory->getIcon('file-edit', IconSize::SMALL));
*
* $splitButtonElement = $this->componentFactory->createSplitButton()
* ->addItem($saveButton, true)
* ->addItem($saveAndCloseButton)
* ->addItem($moduleLink)
* ->addItem($saveAndShowPageButton);
* }
* ```
*/
class SplitButton extends AbstractButton
{
/**
* Internal var that determines whether the split button has received any primary actions yet
*/
protected bool $containsPrimaryAction = false;
/**
* Primary action button
*/
protected ?AbstractButton $primary = null;
/**
* Array of option buttons for the dropdown
*
* @var AbstractButton[]
*/
protected array $options = [];
/**
* Adds an instance of any button to the split button
*
* @param AbstractButton $item ButtonObject to add
* @param bool $primaryAction Is the button the primary action?
*
* @throws \InvalidArgumentException In case a button is not valid
*/
public function addItem(AbstractButton $item, bool $primaryAction = false): static
{
if (!$item->isValid()) {
throw new \InvalidArgumentException(
'Only valid items may be assigned to a split Button. "'
. $item->getType()
. '" did not pass validation',
1441706330
);
}
if ($primaryAction && $this->containsPrimaryAction) {
throw new \InvalidArgumentException('A splitButton may only contain one primary action', 1441706340);
}
if ($primaryAction) {
$this->containsPrimaryAction = true;
$this->primary = clone $item;
} else {
$this->options[] = clone $item;
}
return $this;
}
/**
* Returns the split button items as a typed DTO.
*
* If no primary action was explicitly set, the first option button
* becomes the primary action.
*
* @return SplitButtonItems The typed container with primary and option buttons
*/
public function getItems(): SplitButtonItems
{
$primary = $this->primary;
$options = $this->options;
// If no primary action was set, use the first option as primary or thrown an exception if no option exists
if ($primary === null) {
if (count($options) > 0) {
$primary = array_shift($options);
} else {
throw new \RuntimeException('Split button requires at least one button', 1761311538);
}
}
return new SplitButtonItems(primary: $primary, options: $options);
}
public function isValid(): bool
{
try {
return $this->getItems()->isValid();
} catch (\RuntimeException) {
return false;
}
}
/**
* Renders the HTML markup of the button
*/
public function render(): string
{
$items = $this->getItems();
$primary = $items->primary;
$options = $items->options;
$attributes = [
'class' => 'btn btn-sm btn-default ' . $primary->getClasses(),
];
if (method_exists($primary, 'getName')) {
$attributes['name'] = $primary->getName();
}
if (method_exists($primary, 'getValue')) {
$attributes['value'] = $primary->getValue();
}
if (method_exists($primary, 'getForm') && !empty($primary->getForm())) {
$attributes['form'] = $primary->getForm();
}
if ($primary->getAttributes() !== []) {
foreach ($primary->getAttributes() as $attributeName => $attributeValue) {
$attributes[$attributeName] = $attributeValue;
}
}
if ($primary->getDataAttributes() !== []) {
foreach ($primary->getDataAttributes() as $attributeName => $attributeValue) {
$attributes['data-' . $attributeName] = $attributeValue;
}
}
if ($primary instanceof LinkButton) {
// This is needed because the LinkButton can NOT use its ->render() method,
// as we want to stick our icon in the result HTML.
$attributes['href'] = $primary->getHref();
$attributes['role'] = $primary->getRole();
$attributes['title'] = $primary->getTitle();
}
$attributesString = GeneralUtility::implodeAttributes($attributes, true);
if ($primary instanceof LinkButton) {
$primaryButtonHTML = '<a ' . $attributesString . '>
' . ($primary->getIcon()?->render('inline') ?? '') . '
' . htmlspecialchars($primary->getTitle()) . '
</a>';
} else {
$primaryButtonHTML = '<button ' . $attributesString . ' type="submit">
' . ($primary->getIcon()?->render('inline') ?? '') . '
' . htmlspecialchars($primary->getTitle()) . '
</button>';
}
$content = '
<div class="btn-group t3js-splitbutton">
' . $primaryButtonHTML . '
<button type="button" class="btn btn-sm btn-default dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">';
foreach ($options as $option) {
if ($option instanceof InputButton) {
// if the option is an InputButton we have to create a custom rendering
$optionAttributes = [
'href' => '#',
'data-name' => $option->getName(),
'data-value' => $option->getValue(),
'data-form' => $option->getForm(),
];
if (!empty($option->getClasses())) {
$optionAttributes['class'] = $option->getClasses();
}
$optionAttributes['class'] = implode(' ', [$optionAttributes['class'] ?? '', 'dropdown-item']);
$optionAttributesString = '';
foreach ($optionAttributes as $key => $value) {
$optionAttributesString .= ' ' . htmlspecialchars($key) . '="' . htmlspecialchars($value) . '"';
}
$html
= '<a' . $optionAttributesString . '>'
. '<span class="dropdown-item-columns">'
. '<span class="dropdown-item-column dropdown-item-column-icon" aria-hidden="true">'
. ($option->getIcon()?->render('inline') ?? '')
. '</span>'
. '<span class="dropdown-item-column dropdown-item-column-title">'
. htmlspecialchars($option->getTitle())
. '</span>'
. '</span>'
. '</a>';
} else {
// for any other kind of button we simply use what comes along (e.g. LinkButton)
$html = $option->render();
if ($option instanceof LinkButton) {
// Links inside a dropdown should not be displayed as a button.
// Unfortunately, the LinkButton has its class-list
// "btn btn-sm btn-default" hard-coded, which makes sense for
// its normal context, but not this context. Since it's hard via
// CSS to reset the "btn" look, we heuristically remove it here.
$html = str_replace('class="btn btn-sm btn-default', 'class="btn-sm btn-default dropdown-item', $html);
}
}
$content .= '
<li>
' . $html . '
</li>
';
}
$content .= '
</ul>
</div>
';
return $content;
}
public function __toString(): string
{
return $this->render();
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Buttons;
/**
* SplitButtonItems
*
* Type-safe container for split button items.
*
* A split button consists of one primary action button and an array of
* option buttons shown in a dropdown menu. This DTO ensures type safety
* and prevents the use of magic array keys.
*
* @internal This is a concrete implementation and not part of the TYPO3 Public API.
*/
final readonly class SplitButtonItems
{
/**
* @param AbstractButton $primary The primary action button
* @param AbstractButton[] $options Array of option buttons for the dropdown
*/
public function __construct(
public AbstractButton $primary,
public array $options,
) {}
/**
* Checks if the split button has a valid configuration.
*
* @return bool True if primary action exists and at least one option is available
*/
public function isValid(): bool
{
return $this->primary->isValid() && count($this->options) > 0;
}
}
@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton;
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownDivider;
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownGeneric;
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownHeader;
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownItem;
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownRadio;
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDown\DropDownToggle;
use TYPO3\CMS\Backend\Template\Components\Buttons\DropDownButton;
use TYPO3\CMS\Backend\Template\Components\Buttons\FullyRenderedButton;
use TYPO3\CMS\Backend\Template\Components\Buttons\GenericButton;
use TYPO3\CMS\Backend\Template\Components\Buttons\InputButton;
use TYPO3\CMS\Backend\Template\Components\Buttons\LinkButton;
use TYPO3\CMS\Backend\Template\Components\Buttons\SplitButton;
use TYPO3\CMS\Backend\Template\Components\Event\ModifyPreviewUrlForQrCodeEvent;
use TYPO3\CMS\Backend\Template\Components\Menu\Menu;
use TYPO3\CMS\Backend\Template\Components\Menu\MenuItem;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Factory for creating backend template components, e.g. buttons.
*
* This ComponentFactory serves as the central location for all component creation in the backend,
* providing both pre-configured components for common patterns and basic component factory methods.
*
* Currently focused on button creation, but designed to be extensible for other component types
* (menus, breadcrumbs, etc.) in the future.
*
* This reduces boilerplate code and ensures consistent UX across the backend by providing
* standardized component configurations for recurring use cases.
*
* Example - Creating a back button:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
*
* // Use pre-configured back button
* $backButton = $this->componentFactory->createBackButton($returnUrl);
* $buttonBar->addButton($backButton, ButtonBar::BUTTON_POSITION_LEFT, 1);
* }
* ```
*/
#[Autoconfigure(public: true)]
readonly class ComponentFactory
{
public function __construct(
protected IconFactory $iconFactory,
protected EventDispatcherInterface $eventDispatcher,
protected PageRenderer $pageRenderer,
) {}
/**
* Creates a standardized "back" navigation button.
*/
public function createBackButton(string|UriInterface $returnUrl): LinkButton
{
return $this->createLinkButton()
->setHref((string)$returnUrl)
->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.goBack'))
->setIcon($this->iconFactory->getIcon('actions-view-go-back', IconSize::SMALL))
->setShowLabelText(true);
}
/**
* Creates a standardized "close" button.
*
* Similar to back button but uses "actions-close" icon and "Close" label.
* Typically used for closing detail views or modal-like overlays.
*/
public function createCloseButton(string|UriInterface $closeUrl): LinkButton
{
return $this->createLinkButton()
->setHref((string)$closeUrl)
->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.close'))
->setIcon($this->iconFactory->getIcon('actions-close', IconSize::SMALL))
->setShowLabelText(true);
}
/**
* Creates a standardized "reload" button for reloading the current view.
*
* Uses "actions-refresh" icon without a displayed label.
*
* Note: As of TYPO3 v14, the reload button is automatically added to all modules
* by default. Controllers only need to manually create this button if they need
* custom reload behavior. In that case, use DocHeaderComponent::disableAutomaticReloadButton()
* to prevent the automatic one from being added.
*/
public function createReloadButton(string|UriInterface $requestUri): LinkButton
{
return $this->createLinkButton()
->setHref((string)$requestUri)
->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.reload'))
->setIcon($this->iconFactory->getIcon('actions-refresh', IconSize::SMALL));
}
/**
* Creates a standardized "save" button for forms.
*
* Returns an InputButton configured with:
* - "actions-document-save" icon
* - Translated "Save" label (shown as text)
* - name="_savedok" and value="1"
* - Associated with the specified form
*
* @param string $formName The HTML form ID this button belongs to
* @return InputButton Fully configured save button
*/
public function createSaveButton(string $formName = ''): InputButton
{
$button = $this->createInputButton()
->setName('_savedok')
->setValue('1')
->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.save') ?: 'Save')
->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL))
->setShowLabelText(true);
if ($formName !== '') {
$button->setForm($formName);
}
return $button;
}
public function createViewButton(array $previewDataAttributes = []): LinkButton
{
return $this->createLinkButton()
->setHref('#')
->setDataAttributes($previewDataAttributes)
->setDisabled(!$previewDataAttributes)
->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.showPage'))
->setIcon($this->iconFactory->getIcon('actions-view-page', IconSize::SMALL))
->setShowLabelText(true);
}
/**
* Creates a standardized QR code button that opens a modal with a QR code for the given URL.
*
* Uses the typo3-qrcode-modal-button web component which displays a scannable QR code
* in a modal dialog. The QR code can be downloaded as PNG or SVG.
*
* @param string|UriInterface $previewUrl The URL to encode in the QR code
* @param bool $showCopyUrl Whether to show the URL field with copy button in the modal
*/
public function createQrCodeButton(string|UriInterface $previewUrl, bool $showCopyUrl = true): GenericButton
{
$languageService = $this->getLanguageService();
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/element/qrcode-modal-button.js');
$attributes = [
'content' => (string)$previewUrl,
'modal-title' => $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:showPageQrCode.modalTitle'),
];
if ($showCopyUrl) {
$attributes['show-url'] = '1';
}
return $this->createGenericButton()
->setTag('typo3-qrcode-modal-button')
->setLabel($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:showPageQrCode'))
->setTitle($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:showPageQrCode'))
->setIcon($this->iconFactory->getIcon('actions-qrcode', IconSize::SMALL))
->setAttributes($attributes);
}
/**
* Generates a preview URL suitable for QR codes.
*
* Dispatches the ModifyPreviewUrlForQrCodeEvent to allow extensions (e.g., workspaces)
* to provide alternative URLs. For example, the workspaces extension can provide a URL
* with ADMCMD_prev parameter that works without backend authentication.
*
* @param string|UriInterface|null $fallbackUrl Fallback URL if no listener modifies the URL
*/
public function getPreviewUrlForQrCode(int $pageId, int $languageId, string|UriInterface|null $fallbackUrl = null): ?string
{
return $this->eventDispatcher->dispatch(
new ModifyPreviewUrlForQrCodeEvent($pageId, $languageId, $fallbackUrl !== null ? (string)$fallbackUrl : null)
)->getPreviewUrl();
}
public function createGenericButton(): GenericButton
{
return GeneralUtility::makeInstance(GenericButton::class);
}
public function createInputButton(): InputButton
{
return GeneralUtility::makeInstance(InputButton::class);
}
public function createSplitButton(): SplitButton
{
return GeneralUtility::makeInstance(SplitButton::class);
}
public function createDropDownButton(): DropDownButton
{
return GeneralUtility::makeInstance(DropDownButton::class);
}
public function createDropDownDivider(): DropDownDivider
{
return GeneralUtility::makeInstance(DropDownDivider::class);
}
public function createDropDownItem(): DropDownItem
{
return GeneralUtility::makeInstance(DropDownItem::class);
}
public function createDropDownRadio(): DropDownRadio
{
return GeneralUtility::makeInstance(DropDownRadio::class);
}
public function createDropDownToggle(): DropDownToggle
{
return GeneralUtility::makeInstance(DropDownToggle::class);
}
public function createDropDownHeader(): DropDownHeader
{
return GeneralUtility::makeInstance(DropDownHeader::class);
}
public function createDropDownGeneric(): DropDownGeneric
{
return GeneralUtility::makeInstance(DropDownGeneric::class);
}
public function createLinkButton(): LinkButton
{
return GeneralUtility::makeInstance(LinkButton::class);
}
public function createFullyRenderedButton(): FullyRenderedButton
{
return GeneralUtility::makeInstance(FullyRenderedButton::class);
}
public function createShortcutButton(): ShortcutButton
{
return GeneralUtility::makeInstance(ShortcutButton::class);
}
public function createMenuItem(): MenuItem
{
return GeneralUtility::makeInstance(MenuItem::class);
}
public function createMenu(): Menu
{
return GeneralUtility::makeInstance(Menu::class);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
final class ComponentGroup
{
/**
* @param array<string, ComponentInterface> $items
*/
private array $items = [];
public function __construct(
public readonly string $identifier,
) {}
public function get(string $identifier): ?ComponentInterface
{
return $this->items[$identifier] ?? null;
}
public function remove(string $identifier): true
{
if (isset($this->items[$identifier])) {
unset($this->items[$identifier]);
}
return true;
}
/**
* @return array<string, ComponentInterface>
*/
public function getItems(?ComponentInterface $ifEmptyUseThis = null): array
{
$result = [];
foreach ($this->items as $key => $item) {
$result[$key] = $item ?? $ifEmptyUseThis;
}
return array_filter($result);
}
/**
* @param array<string, ?ComponentInterface> $items
*/
public function setItems(array $items): void
{
$this->items = [];
foreach ($items as $identifier => $item) {
$this->add($identifier, $item);
}
}
/**
* $before and $after references the string keys used for $identifier
*/
public function add(string $identifier, ?ComponentInterface $item, string $before = '', string $after = ''): void
{
if ($before !== '' && $this->has($before)) {
$end = array_splice($this->items, (int)(array_search($before, array_keys($this->items), true)));
$this->items = array_merge($this->items, [$identifier => $item], $end);
} elseif ($after !== '' && $this->has($after)) {
$end = array_splice($this->items, (int)(array_search($after, array_keys($this->items), true)) + 1);
$this->items = array_merge($this->items, [$identifier => $item], $end);
} else {
$this->items[$identifier] = $item;
}
}
public function has(string $identifier): bool
{
return array_key_exists($identifier, $this->items);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
/**
* Common interface for all renderable backend components (buttons, dropdown items, etc.).
*
* This interface provides the base contract for components that can be validated,
* typed, and rendered as HTML. It is extended by more specific interfaces like
* ButtonInterface and DropDownItemInterface.
*/
interface ComponentInterface extends \Stringable
{
/**
* Validates whether the component is properly configured and can be rendered.
*
* Each implementing class defines its own validation rules (e.g., required fields).
*
* @return bool True if the component is valid and can be rendered, false otherwise
*/
public function isValid(): bool;
/**
* Returns the fully qualified class name as the component type identifier.
*
* This is used to identify the specific component type in validation and rendering.
*
* @return string The fully qualified class name (e.g., 'TYPO3\CMS\Backend\Template\Components\Buttons\LinkButton')
*/
public function getType(): string;
/**
* Renders the component as an HTML string.
*
* This method should only be called after validating the component with isValid().
* The returned HTML is ready to be output to the browser.
*
* @return string The rendered HTML markup
*/
public function render(): string;
}
@@ -0,0 +1,432 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Breadcrumb\BreadcrumbContext;
use TYPO3\CMS\Backend\Breadcrumb\BreadcrumbFactory;
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
use TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton;
use TYPO3\CMS\Backend\Template\Components\Buttons\ButtonInterface;
use TYPO3\CMS\Core\Resource\ResourceInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Document header component for backend modules.
*
* This component manages the header area of backend module views, providing:
* - Breadcrumb navigation (via BreadcrumbContext)
* - Button bar for action buttons (save, close, delete, etc.)
* - Drop-down menus for module-specific actions
*
* The component can be enabled or disabled to control visibility of the entire
* document header. It integrates with the ModuleTemplate to provide a consistent
* header across all backend modules.
*
* Usage in a controller:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $view = $this->moduleTemplateFactory->create($request);
* $docHeader = $view->getDocHeaderComponent();
*
* // Set breadcrumb for a page
* $docHeader->setPageBreadcrumb($pageInfo);
*
* // Add action buttons using ComponentFactory
* $buttonBar = $docHeader->getButtonBar();
* $saveButton = $this->componentFactory->createSaveButton('editform');
* $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 1);
* }
* ```
*/
#[Autoconfigure(public: true)]
class DocHeaderComponent
{
/**
* Button bar component for managing action buttons.
*/
protected ButtonBar $buttonBar;
/**
* Breadcrumb component for rendering navigation trails.
*/
protected Breadcrumb $breadcrumb;
/**
* Context information for breadcrumb rendering.
*
* Contains the main context (page, record, or resource) and optional suffix nodes
* for additional navigation elements.
*/
protected ?BreadcrumbContext $breadcrumbContext = null;
/**
* Whether the document header is enabled and should be rendered.
*/
protected bool $enabled = true;
/**
* Language selector component.
*/
protected ?ComponentInterface $languageSelector = null;
/**
* The automatic shortcut button instance, if configured.
*/
protected ?ShortcutButton $automaticShortcutButton = null;
/**
* Whether the automatic reload button should be added.
*/
protected bool $automaticReloadButton = true;
public function __construct(
protected readonly MenuRegistry $menuRegistry,
protected readonly BreadcrumbFactory $breadcrumbFactory,
protected readonly ComponentFactory $componentFactory,
) {
$this->buttonBar = GeneralUtility::makeInstance(ButtonBar::class);
$this->breadcrumb = GeneralUtility::makeInstance(Breadcrumb::class);
}
/**
* Sets the breadcrumb context for rendering.
*
* This is the main API for providing breadcrumb information.
*
* For common scenarios, use the convenience methods instead:
* - setPageBreadcrumb() for page records
* - setRecordBreadcrumb() for any record
* - setResourceBreadcrumb() for files or folders
*
* @param BreadcrumbContext|null $breadcrumbContext The breadcrumb context
*/
public function setBreadcrumbContext(?BreadcrumbContext $breadcrumbContext): void
{
$this->breadcrumbContext = $breadcrumbContext;
}
/**
* Sets breadcrumb from a page record array.
*
* Example:
* $view->getDocHeaderComponent()->setPageBreadcrumb($pageInfo);
*
* @param array $pageRecord The page record array (must contain 'uid')
*/
public function setPageBreadcrumb(array $pageRecord): void
{
$this->breadcrumbContext = $this->breadcrumbFactory->forPageArray($pageRecord);
}
/**
* Sets breadcrumb for editing a record.
*
* Example:
* $view->getDocHeaderComponent()->setRecordBreadcrumb('tt_content', 123);
*
* @param string $table The table name
* @param int $uid The record UID
*/
public function setRecordBreadcrumb(string $table, int $uid): void
{
$this->breadcrumbContext = $this->breadcrumbFactory->forEditAction($table, $uid);
}
/**
* Sets breadcrumb for any resource (file or folder).
*
* Example:
* $view->getDocHeaderComponent()->setResourceBreadcrumb($file);
* $view->getDocHeaderComponent()->setResourceBreadcrumb($folder);
*
* @param ResourceInterface $resource The resource (file or folder)
*/
public function setResourceBreadcrumb(ResourceInterface $resource): void
{
$this->breadcrumbContext = $this->breadcrumbFactory->forResource($resource);
}
/**
* Adds a suffix node to the current breadcrumb context.
*
* Suffix nodes are appended after the main breadcrumb trail and are useful for:
* - Indicating "Create New" actions
* - Showing "Edit Multiple" states
* - Adding custom contextual information
*
* Example:
*
* $docHeader->setPageBreadcrumb($pageInfo);
* $docHeader->addBreadcrumbSuffixNode(
* new BreadcrumbNode(
* identifier: 'new',
* label: 'Create New Content Element',
* icon: 'actions-add'
* )
* );
*
* Note: This creates or modifies the breadcrumb context. If you need to build
* a complete context, use BreadcrumbFactory instead.
*
* @param BreadcrumbNode $node The node to append
*/
public function addBreadcrumbSuffixNode(BreadcrumbNode $node): void
{
if ($this->breadcrumbContext === null) {
$this->breadcrumbContext = new BreadcrumbContext(null, [$node]);
} else {
// Create new context with added suffix node
$existingSuffixNodes = $this->breadcrumbContext->suffixNodes;
$existingSuffixNodes[] = $node;
$this->breadcrumbContext = new BreadcrumbContext(
$this->breadcrumbContext->mainContext,
$existingSuffixNodes
);
}
}
/**
* Returns the menu registry for adding drop-down menus to the document header.
*/
public function getMenuRegistry(): MenuRegistry
{
return $this->menuRegistry;
}
/**
* Returns the button bar for adding action buttons to the document header.
*
* The button bar supports multiple button positions (left, right) and groups
* to organize buttons logically.
*/
public function getButtonBar(): ButtonBar
{
return $this->buttonBar;
}
/**
* Determines whether this component is enabled and should be rendered.
*
* When disabled, the entire document header (including breadcrumbs, buttons,
* and menus) will not be displayed in the backend module.
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Enables this component for rendering.
*/
public function enable(): void
{
$this->enabled = true;
}
/**
* Disables this component to prevent rendering.
*/
public function disable(): void
{
$this->enabled = false;
}
public function setLanguageSelector(?ComponentInterface $component): void
{
$this->languageSelector = $component;
}
public function getLanguageSelector(): ?ComponentInterface
{
return $this->languageSelector;
}
/**
* Sets the context for the automatic shortcut button.
*
* Controllers can use this method to provide shortcut information without
* manually creating and adding the shortcut button. The button will be
* automatically added to the button bar in the correct position.
*
* Example:
*
* $docHeader->setShortcutContext('site_configuration.edit', sprintf('Edit site: %s', $siteIdentifier), ['site' => $siteIdentifier]);
*
* @param string $routeIdentifier The route identifier for the shortcut
* @param string $displayName The display name shown in the bookmark list
* @param array $arguments Optional arguments to include in the shortcut URL
*/
public function setShortcutContext(string $routeIdentifier, string $displayName, array $arguments = []): void
{
$this->automaticShortcutButton = $this->componentFactory->createShortcutButton()
->setRouteIdentifier($routeIdentifier)
->setDisplayName($displayName)
->setArguments($arguments);
}
/**
* Disables the automatic reload button for this module.
*
* Use this if your module needs custom reload behavior or should not
* have a reload button at all.
*/
public function disableAutomaticReloadButton(): void
{
$this->automaticReloadButton = false;
}
/**
* Disables the automatic shortcut button for this module.
*
* Use this if your module should not have a shortcut button.
*/
public function disableAutomaticShortcutButton(): void
{
$this->automaticShortcutButton = null;
}
/**
* Returns the complete document header content as an array for rendering.
*
* This method aggregates all components (buttons, breadcrumbs) into
* a structured array that can be consumed by the Fluid template rendering
* the backend module layout.
*
* The returned array structure:
* - 'enabled': Whether the document header should be rendered
* - 'buttons': Array of button configurations from the button bar
* - 'breadcrumb': Breadcrumb trail data from the breadcrumb context
* - 'languageSelector': Language Selector
*/
public function docHeaderContent(?ServerRequestInterface $request): array
{
// Process MenuRegistry and add any menus as dropdown buttons to the button bar
$moduleMenuButton = $this->processMenuRegistry();
if ($moduleMenuButton !== null) {
$this->buttonBar->addButton($moduleMenuButton, ButtonBar::BUTTON_POSITION_LEFT, 0);
}
// Add automatic buttons (reload, shortcut)
$this->addAutomaticButtons($request);
return [
'enabled' => $this->isEnabled(),
'buttons' => $this->buttonBar->getButtons($request),
'breadcrumb' => $this->breadcrumb->getBreadcrumb($request, $this->breadcrumbContext),
'languageSelector' => $this->getLanguageSelector(),
];
}
/**
* Adds automatic reload and shortcut buttons to the button bar.
*
* This method is called automatically by docHeaderContent() and handles:
* - Adding automatic reload button (if enabled)
* - Adding automatic shortcut button (if configured)
*
* The buttons are added to groups 90 and 91 on the right side, which are conventionally
* used for these system buttons. This ensures they appear at the end of the button bar
* while still allowing PSR-14 event listeners to modify or remove them via ModifyButtonBarEvent.
*/
private function addAutomaticButtons(?ServerRequestInterface $request): void
{
if ($request === null) {
return;
}
// Add automatic reload button if enabled
if ($this->automaticReloadButton) {
$reloadButton = $this->componentFactory->createReloadButton(
$request->getAttribute('normalizedParams')->getRequestUri()
);
// Add to group 90 on the right (conventionally second-to-last position)
$this->buttonBar->addButton($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT, 90);
}
// Add automatic shortcut button if configured
if ($this->automaticShortcutButton !== null) {
// Add to group 91 on the right (conventionally last position)
$this->buttonBar->addButton($this->automaticShortcutButton);
}
}
/**
* Processes registered menus from the MenuRegistry into a dropdown button component.
*
* Takes the first registered menu from the MenuRegistry and creates a dropdown button
* component that can be added to the button bar.
*
* @return ButtonInterface|null The dropdown button, or null if no menus registered
*/
private function processMenuRegistry(): ?ButtonInterface
{
$menus = $this->menuRegistry->getMenus();
if ($menus === []) {
return null;
}
if (count($menus) > 1) {
throw new \RuntimeException('The menuRegistry should only contain one menu. '
. 'Multiple DocHeaderComponents can not be displayed - prefer to add distinct dropdown '
. 'buttons to add more view possibilities, or create actual submodules instead of secondary menus.', 1783447740);
}
// Use the first menu (most controllers only register one menu)
$menu = reset($menus);
// Hide menu if it's either empty or offers only one item
if (count($menu->getMenuItems()) < 2) {
return null;
}
$label = $menu->getLabel();
$dropdownButton = $this->componentFactory->createDropDownButton()
->setShowActiveLabelText(true)
->setShowLabelText(true);
foreach ($menu->getMenuItems() as $menuItem) {
if ($label === '') {
// Previously, the menu was rendered as a <select>, which meant the first or
// currently selected <option> acted as the visible label. The menu itself had
// no separate label. As a fallback, we now use the first menu item title as the
// button label, ensuring the DropDownButton is valid. The button will still
// always display the active item, because setShowActiveLabelText(true) is set.
$label = $menuItem->getTitle();
}
$dropdownItem = $this->componentFactory->createDropDownRadio()
->setHref($menuItem->getHref())
->setLabel($menuItem->getTitle())
->setActive($menuItem->isActive());
$dropdownButton->addItem($dropdownItem);
}
$dropdownButton->setLabel($label);
return $dropdownButton;
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Event;
/**
* Listeners to this event are able to modify the preview URL used for QR codes
* in the backend. This allows extensions to provide alternative URLs, for example
* workspace-aware preview URLs that work without backend authentication.
*/
final class ModifyPreviewUrlForQrCodeEvent
{
private ?string $previewUrl = null;
public function __construct(
private readonly int $pageId,
private readonly int $languageId,
private readonly ?string $fallbackUrl,
) {}
public function getPageId(): int
{
return $this->pageId;
}
public function getLanguageId(): int
{
return $this->languageId;
}
public function getFallbackUrl(): ?string
{
return $this->fallbackUrl;
}
public function getPreviewUrl(): ?string
{
return $this->previewUrl ?? $this->fallbackUrl;
}
public function setPreviewUrl(?string $previewUrl): void
{
$this->previewUrl = $previewUrl;
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Menu;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Represents a navigation menu in the backend module document header, typically rendered
* as a dropdown selector that allows users to switch between different views or modes
* within a module.
*
* Menus consist of multiple MenuItems and are registered with the MenuRegistry in the
* DocHeaderComponent. The menu is automatically rendered in the module's document header.
*
* Example:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $menuRegistry = $this->moduleTemplate->getDocHeaderComponent()->getMenuRegistry();
* $menu = $this->componentFactory->createMenu();
* $menu->setIdentifier('myModuleMenu')
* ->setLabel('Select View');
*
* $menuItem1 = $this->componentFactory->createMenuItem()
* ->setTitle('List View')
* ->setHref($listViewUrl)
* ->setActive(true);
* $menu->addMenuItem($menuItem1);
*
* $menuItem2 = $this->componentFactory->createMenuItem()
* ->setTitle('Grid View')
* ->setHref($gridViewUrl);
* $menu->addMenuItem($menuItem2);
*
* $menuRegistry->addMenu($menu);
* }
* ```
*/
class Menu
{
protected string $identifier = '';
/**
* Label of the Menu (displayed as the dropdown label)
*/
protected string $label = '';
protected array $menuItems = [];
public function getIdentifier(): string
{
return $this->identifier;
}
public function getDataIdentifier(): string
{
$dataMenuIdentifier = GeneralUtility::camelCaseToLowerCaseUnderscored($this->identifier);
return str_replace('_', '-', $dataMenuIdentifier);
}
public function getLabel(): string
{
return $this->label;
}
public function getMenuItems(): array
{
return $this->menuItems;
}
public function setIdentifier(string $identifier): static
{
$this->identifier = $identifier;
return $this;
}
/**
* @param string $label LabelText for the menu (accepts LLL syntax)
*/
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
/**
* Adds a new menuItem
*
* @param MenuItem $menuItem The menuItem to add to the menu
*
* @throws \InvalidArgumentException In case a menuItem is not valid
*/
public function addMenuItem(MenuItem $menuItem): static
{
if (!$menuItem->isValid()) {
throw new \InvalidArgumentException('MenuItem "' . $menuItem->getTitle() . '" is not valid', 1442236317);
}
// @todo implement sorting of menu items
// @todo maybe even things like spacers/sections?
$this->menuItems[] = clone $menuItem;
return $this;
}
public function isValid(): bool
{
return trim($this->getIdentifier()) !== '';
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\Menu;
use TYPO3\CMS\Backend\Template\Components\AbstractControl;
/**
* Represents a single item within a Menu in the backend module document header.
* Each MenuItem has a title, URL (href), and can be marked as active to indicate
* the current selection.
*
* MenuItems inherit from AbstractControl, providing access to common properties like
* title, CSS classes, and data attributes.
*
* Example:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* $menu = $this->componentFactory->createMenu();
* $menuItem = $this->componentFactory->createMenuItem()
* ->setTitle('List View')
* ->setHref('/my-module?view=list')
* ->setActive(true) // Marks this item as currently selected
* ->setClasses('my-custom-class')
* ->setDataAttributes(['action' => 'switch-view']);
* $menu->addMenuItem($menuItem);
* }
* ```
*/
class MenuItem extends AbstractControl
{
protected string $href = '';
protected bool $active = false;
public function setHref(string $href): static
{
$this->href = $href;
return $this;
}
public function getHref(): string
{
return $this->href;
}
public function setActive(bool $active): static
{
$this->active = $active;
return $this;
}
public function isActive(): bool
{
return $this->active;
}
public function isValid(): bool
{
return $this->getHref() !== '' && $this->getTitle() !== '';
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
use TYPO3\CMS\Backend\Template\Components\Menu\Menu;
/**
* Central registry for managing menus in the backend module document header.
* The MenuRegistry is part of the DocHeaderComponent and holds all menus that should
* be displayed in the module's header area.
*
* Menus are typically used to provide navigation between different views or modes within
* a module (e.g., switching between list/grid view, or different content types).
*
* Example:
*
* ```
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* public function myAction(): ResponseInterface
* {
* // Get the menu registry from the module template
* $menuRegistry = $this->moduleTemplate->getDocHeaderComponent()->getMenuRegistry();
*
* // Create and configure a menu
* $menu = $this->componentFactory->createMenu();
* $menu->setIdentifier('viewSelector')->setLabel('View');
*
* // Add menu items
* $listItem = $this->componentFactory->createMenuItem()
* ->setTitle('List')
* ->setHref('/module?view=list')
* ->setActive($currentView === 'list');
* $menu->addMenuItem($listItem);
*
* // Register the menu
* $menuRegistry->addMenu($menu);
* }
* ```
*/
class MenuRegistry
{
/**
* Internal array that stores all registered menus
*
* @var array<string, Menu>
*/
protected array $menus = [];
/**
* Adds a menu to the registry
*
* @throws \InvalidArgumentException In case a menu is not valid
*/
public function addMenu(Menu $menu): static
{
if (!$menu->isValid()) {
throw new \InvalidArgumentException('Menu "' . $menu->getIdentifier() . '" is not valid', 1442236362);
}
$this->menus[$menu->getIdentifier()] = clone $menu;
return $this;
}
/**
* Returns all menus in an abstract array
*
* @return Menu[]
*/
public function getMenus(): array
{
foreach ($this->menus as $key => $menu) {
if (empty($menu->getMenuItems())) {
unset($this->menus[$key]);
}
}
return $this->menus;
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components;
use Psr\Http\Message\ServerRequestInterface;
/**
* PSR-14 event that allows listeners to modify the buttons in the backend module
* document header button bar. This event is dispatched after all buttons have been
* added to the button bar, but before they are rendered.
*
* Use cases:
* - Add custom buttons to existing modules
* - Remove or hide buttons based on conditions
* - Modify button properties (labels, icons, etc.)
* - Reorder buttons
*
* Example event listener:
*
* ```
* use TYPO3\CMS\Backend\Template\Components\ModifyButtonBarEvent;
* use TYPO3\CMS\Backend\Template\Components\ButtonBar;
* use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
* use TYPO3\CMS\Core\Attribute\AsEventListener;
*
* final class MyButtonBarListener
* {
* public function __construct(
* protected readonly ComponentFactory $componentFactory,
* ) {}
*
* #[AsEventListener]
* public function __invoke(ModifyButtonBarEvent $event): void
* {
* $buttons = $event->getButtons();
* $buttonBar = $event->getButtonBar();
*
* $myButton = $this->componentFactory->createLinkButton()
* ->setHref('/my-action')
* ->setTitle('My Action')
* ->setIcon($iconFactory->getIcon('actions-heart'));
*
* $buttons[ButtonBar::BUTTON_POSITION_RIGHT][1][] = clone $myButton;
*
* $event->setButtons($buttons);
* }
* }
* ```
*
* @phpstan-import-type Buttons from ButtonBar
*/
final class ModifyButtonBarEvent
{
/**
* @param Buttons $buttons
*/
public function __construct(private array $buttons, private readonly ButtonBar $buttonBar, private readonly ServerRequestInterface $request) {}
/**
* @return Buttons
*/
public function getButtons(): array
{
return $this->buttons;
}
/**
* @param Buttons $buttons
*/
public function setButtons(array $buttons): void
{
$this->buttons = $buttons;
}
public function getButtonBar(): ButtonBar
{
return $this->buttonBar;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Components\MultiRecordSelection;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Defines a bulk action that can be performed on multiple selected records in the backend.
* These actions appear when users select multiple records in list views or other record
* listings, allowing operations like mass delete, mass edit, etc.
*
* Actions are readonly DTOs that encapsulate the configuration,
* icon, and label for a multi-record operation.
*
* Example:
*
* ```
* $action = new Action(
* name: 'delete',
* configuration: ['action' => 'deleteRecords'],
* iconIdentifier: 'actions-delete',
* labelKey: 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete'
* );
*
* // The action provides formatted output for rendering
* $actionName = $action->getName(); // 'delete'
* $jsonConfig = $action->getConfiguration(); // JSON-encoded for HTML attributes
* $label = $action->getLabel(); // Translated label
* $icon = $action->getIcon(); // Rendered icon HTML
* ```
*
* @internal
*/
readonly class Action
{
public function __construct(
protected string $name,
protected array $configuration,
protected string $iconIdentifier,
protected string $labelKey,
) {}
public function getName(): string
{
return $this->name;
}
public function getConfiguration(): string
{
return GeneralUtility::jsonEncodeForHtmlAttribute($this->configuration);
}
public function getLabel(): string
{
return $this->getLanguageService()->sL($this->labelKey);
}
public function getIcon(): string
{
return GeneralUtility::makeInstance(IconFactory::class)->getIcon($this->iconIdentifier, IconSize::SMALL)->render();
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template\Enum;
/**
* @internal
*/
enum ModuleLayout: string
{
case WIDE = 'wide';
case NORMAL = 'normal';
}
+394
View File
@@ -0,0 +1,394 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\Buttons\ButtonInterface;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\Components\DocHeaderComponent;
use TYPO3\CMS\Backend\Template\Enum\ModuleLayout;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ResponsableViewInterface;
use TYPO3\CMS\Core\View\ViewInterface;
use TYPO3\CMS\Extbase\Mvc\ExtbaseRequestParameters;
/**
* A class taking care of the "outer" HTML of a module, especially
* the doc header and other related parts.
*/
final class ModuleTemplate implements ViewInterface, ResponsableViewInterface
{
use PageRendererBackendSetupTrait;
private bool $uiBlock = false;
private string $moduleId = '';
private string $moduleName = '';
private string $moduleClass = '';
/**
* @internal
*/
private ModuleLayout $moduleLayout = ModuleLayout::WIDE;
private string $title = '';
private string $bodyTag = '<body>';
private string $formTag = '';
private FlashMessageQueue $flashMessageQueue;
private DocHeaderComponent $docHeaderComponent;
/**
* Init PageRenderer and properties.
*/
public function __construct(
protected readonly PageRenderer $pageRenderer,
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly ModuleProvider $moduleProvider,
protected readonly FlashMessageService $flashMessageService,
protected readonly ExtensionConfiguration $extensionConfiguration,
protected readonly ViewInterface $view,
protected readonly ComponentFactory $componentFactory,
protected readonly ServerRequestInterface $request,
) {
$module = $request->getAttribute('module');
if ($module instanceof ModuleInterface) {
// third level, needs the second level in order to keep highlighting in the module menu
if ($module->getParentModule()?->getParentModule()) {
$this->setModuleName($module->getParentModule()->getIdentifier());
} else {
$this->setModuleName($module->getIdentifier());
}
$this->setModuleName($module->getIdentifier());
} else {
$this->setModuleName($request->getAttribute('route')?->getOption('_identifier') ?? '');
}
$this->flashMessageQueue = $flashMessageService->getMessageQueueByIdentifier();
$this->docHeaderComponent = GeneralUtility::makeInstance(DocHeaderComponent::class);
$this->setUpBasicPageRendererForBackend($pageRenderer, $extensionConfiguration, $request, $this->getLanguageService());
}
/**
* Add a variable to the view data collection.
*/
public function assign(string $key, mixed $value): self
{
$this->view->assign($key, $value);
return $this;
}
/**
* Add multiple variables to the view data collection.
*/
public function assignMultiple(array $values): self
{
$this->view->assignMultiple($values);
return $this;
}
/**
* Render the module.
*/
public function render(string $templateFileName = ''): string
{
$this->prepareRender($templateFileName);
return $this->pageRenderer->render($this->request);
}
/**
* Render the module and create an HTML 200 response from it. This is a
* lazy shortcut so controllers don't need to take care of this in the backend.
*/
public function renderResponse(string $templateFileName = ''): ResponseInterface
{
$this->prepareRender($templateFileName);
return $this->pageRenderer->renderResponse($this->request);
}
private function prepareRender(string $templateFileName): void
{
if ($templateFileName === '') {
$extbaseRequestMessage = '';
/** @var ExtbaseRequestParameters|null $extbaseRequestParameters */
$extbaseRequestParameters = $this->request->getAttribute('extbase');
if ($extbaseRequestParameters) {
// This extbase specific code is a helper for a more detailed exception
// message, and a tribute to extbase backend extensions being upgraded.
// Introduced with v13, it could potentially vanish at some point again.
$templateFileName = $extbaseRequestParameters->getControllerName() . '/'
. ucfirst($extbaseRequestParameters->getControllerActionName());
$extbaseRequestMessage = ' Expected template filename is "' . $templateFileName . '".';
}
throw new \InvalidArgumentException('A template filename must be provided.' . $extbaseRequestMessage, 1732184506);
}
$this->assignMultiple([
'docHeader' => $this->docHeaderComponent->docHeaderContent($this->request),
'moduleId' => $this->moduleId,
'moduleName' => $this->moduleName,
'moduleClass' => $this->moduleClass,
'moduleLayout' => $this->moduleLayout->value,
'uiBlock' => $this->uiBlock,
'flashMessageQueueIdentifier' => $this->flashMessageQueue->getIdentifier(),
'formTag' => $this->formTag,
]);
$this->pageRenderer->getJavaScriptRenderer()->includeAllImports();
$this->pageRenderer->loadJavaScriptModule('bootstrap');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/dropdown.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/context-help.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/global-event-handler.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/action-dispatcher.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/element/immediate-action-element.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/hotkeys.js');
$this->pageRenderer->addBodyContent($this->bodyTag . $this->view->render($templateFileName));
$this->pageRenderer->setTitle($this->title);
$updateSignalDetails = BackendUtility::getUpdateSignalDetails();
if (!empty($updateSignalDetails['html'])) {
$this->pageRenderer->addHeaderData(implode("\n", $updateSignalDetails['html']));
}
$this->dispatchNotificationMessages();
}
/**
* @internal
*/
public function setLayout(ModuleLayout $moduleLayout): self
{
$this->moduleLayout = $moduleLayout;
return $this;
}
/**
* Set to something like '<body id="foo">' when a special body tag is needed.
*/
public function setBodyTag(string $bodyTag): self
{
$this->bodyTag = $bodyTag;
return $this;
}
/**
* Title string of the module: "My module · Edit view"
*/
public function setTitle(string $title, string $context = ''): self
{
$titleComponents = [$title];
if ($context !== '') {
$titleComponents[] = $context;
}
$this->title = implode(' · ', $titleComponents);
return $this;
}
/**
* Get the DocHeader. Can be used in controllers to add custom
* buttons / menus / ... to the doc header.
*/
public function getDocHeaderComponent(): DocHeaderComponent
{
return $this->docHeaderComponent;
}
/**
* A "<form>" tag encapsulating the entire module, including doc-header.
*/
public function setForm(string $formTag = ''): self
{
$this->formTag = $formTag;
return $this;
}
/**
* Optional 'data-module-id="{moduleId}"' on first <div> in body.
* Can be helpful in JavaScript.
*/
public function setModuleId(string $moduleId): self
{
$this->moduleId = $moduleId;
return $this;
}
/**
* Optional 'data-module-name="{moduleName}"' on first <div> in body.
* Can be helpful in JavaScript.
*/
public function setModuleName(string $moduleName): self
{
$this->moduleName = $moduleName;
return $this;
}
/**
* Optional 'class="module {moduleClass}"' on first <div> in body.
* Can be helpful styling modules.
*/
public function setModuleClass(string $moduleClass): self
{
$this->moduleClass = $moduleClass;
return $this;
}
/**
* Creates a message object and adds it to the FlashMessageQueue.
* These messages are automatically rendered when the view is rendered.
*/
public function addFlashMessage(string $messageBody, string $messageTitle = '', ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::OK, bool $storeInSession = true): self
{
$flashMessage = new FlashMessage($messageBody, $messageTitle, $severity, $storeInSession);
$this->flashMessageQueue->enqueue($flashMessage);
return $this;
}
/**
* ModuleTemplate by default uses queue 'core.template.flashMessages'. Modules
* may want to maintain an own queue. Use this method to render flash messages
* of a non-default queue at the default position in module HTML output. Call
* this method *before* adding single messages with addFlashMessage().
*/
public function setFlashMessageQueue(FlashMessageQueue $flashMessageQueue): self
{
$this->flashMessageQueue = $flashMessageQueue;
return $this;
}
/**
* UI block is a spinner shown during browser rendering phase of the module,
* automatically removed when rendering finished. This is done by default,
* but the UI block can be turned off when needed for whatever reason.
*/
public function setUiBlock(bool $uiBlock): self
{
$this->uiBlock = $uiBlock;
return $this;
}
/**
* Generates a module actions dropdown in the docheader button bar.
*
* Creates a dropdown button on the LEFT side (group 0) containing navigation to
* submodules or module actions. The button label shows the currently active module/action.
*/
public function makeDocHeaderModuleMenu(array $additionalQueryParams = []): self
{
$currentModule = $this->request->getAttribute('module');
if (!($currentModule instanceof ModuleInterface)) {
// Early return in case the current request does not provide a module
return $this;
}
if ($currentModule->getParentModule()?->hasParentModule()) {
$menuModule = $this->moduleProvider->getModuleForMenu($currentModule->getParentIdentifier(), $this->getBackendUser());
} else {
// This is a fallback in case a second level module is called here
$menuModule = $this->moduleProvider->getModuleForMenu($currentModule->getIdentifier(), $this->getBackendUser());
}
if ($menuModule === null || !$menuModule->hasSubModules()) {
return $this;
}
$itemCount = 0;
$dropdownButton = $this->componentFactory->createDropDownButton()
->setLabel($this->getLanguageService()->sL('backend.messages:moduleMenu.dropdown.label'))
->setShowActiveLabelText(true)
->setShowLabelText(true);
// Add "Overview" link if exists
if ($menuModule->hasSubmoduleOverview()) {
$isActive = $menuModule->getIdentifier() === $currentModule->getIdentifier();
$overviewLabel = $this->getLanguageService()->sL('backend.messages:moduleMenu.dropdown.overview');
$dropdownItem = $this->componentFactory->createDropDownRadio()
->setHref((string)$this->uriBuilder->buildUriFromRoute($menuModule->getIdentifier(), $additionalQueryParams))
->setLabel($overviewLabel)
->setActive($isActive);
$dropdownButton->addItem($dropdownItem);
$itemCount++;
}
// Add all submodules
foreach ($menuModule->getSubModules() as $module) {
$isActive = $module->getIdentifier() === $currentModule->getIdentifier();
$moduleTitle = $this->getLanguageService()->sL($module->getTitle());
$dropdownItem = $this->componentFactory->createDropDownRadio()
->setHref((string)$this->uriBuilder->buildUriFromRoute($module->getIdentifier(), $additionalQueryParams))
->setLabel($moduleTitle)
->setActive($isActive);
$dropdownButton->addItem($dropdownItem);
$itemCount++;
}
// Only add dropdown if there's more than one item
if ($itemCount > 1) {
// Add to button bar at LEFT, group 0 (first position)
$this->getDocHeaderComponent()->getButtonBar()->addButton($dropdownButton, ButtonBar::BUTTON_POSITION_LEFT, 0);
}
return $this;
}
/**
* Shorthand method to add a new button to the button bar
*/
public function addButtonToButtonBar(
ButtonInterface $button,
string $buttonPosition = ButtonBar::BUTTON_POSITION_LEFT,
int $buttonGroup = 1
): self {
$this->getDocHeaderComponent()->getButtonBar()->addButton($button, $buttonPosition, $buttonGroup);
return $this;
}
/**
* Dispatches all messages in a special FlashMessageQueue to the PageRenderer to be rendered as inline notifications
*/
private function dispatchNotificationMessages(): void
{
$notificationQueue = $this->flashMessageService->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE);
foreach ($notificationQueue->getAllMessagesAndFlush() as $message) {
$notificationInstruction = JavaScriptModuleInstruction::create('@typo3/backend/notification.js');
$notificationInstruction->invoke('showMessage', $message->getTitle(), $message->getMessage(), $message->getSeverity());
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction($notificationInstruction);
}
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
/**
* A factory class creating backend related ModuleTemplate view objects.
*/
#[Autoconfigure(public: true, shared: false)]
final readonly class ModuleTemplateFactory
{
public function __construct(
private PageRenderer $pageRenderer,
private IconFactory $iconFactory,
private UriBuilder $uriBuilder,
private ModuleProvider $moduleProvider,
private FlashMessageService $flashMessageService,
private ExtensionConfiguration $extensionConfiguration,
private BackendViewFactory $viewFactory,
private ComponentFactory $componentFactory,
) {}
public function create(ServerRequestInterface $request): ModuleTemplate
{
return new ModuleTemplate(
$this->pageRenderer,
$this->iconFactory,
$this->uriBuilder,
$this->moduleProvider,
$this->flashMessageService,
$this->extensionConfiguration,
$this->viewFactory->create($request),
$this->componentFactory,
$request,
);
}
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Template;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolvePublicResourceException;
use TYPO3\CMS\Core\SystemResource\Exception\CanNotResolveSystemResourceException;
use TYPO3\CMS\Core\SystemResource\Exception\InvalidSystemResourceIdentifierException;
use TYPO3\CMS\Core\SystemResource\Identifier\PackageResourceIdentifier;
use TYPO3\CMS\Core\SystemResource\Identifier\SystemResourceIdentifierFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* This is an internal helper trait to DRY basic PageRenderer backend
* setup code in the backend. It configures the PageRenderer for general
* backend use (charset, favicon, ...).
* Most prominent use is ModuleTemplate - The View used to render backend
* modules that have doc headers. It's also used in controllers that render
* backend things that have no doc header: For instance the login, the main
* frame and link handlers - the iframes within modals.
* The PageRenderer in general is more on the "maybe we can get rid of it soon"
* side. This trait exists to simplify a possible refactoring. In general,
* controllers should strive to do as little PageRenderer calls as possible and
* move existing calls to templates using f:be.pageRenderer ViewHelper. This
* will simplify substituting PageRenderer with a slim dedicated backend solution.
*
* @internal helper. Do not use in extensions.
*/
trait PageRendererBackendSetupTrait
{
/**
* Sets mandatory parameters for the PageRenderer.
*/
protected function setUpBasicPageRendererForBackend(
PageRenderer $pageRenderer,
ExtensionConfiguration $extensionConfiguration,
ServerRequestInterface $request,
LanguageService $languageService,
): void {
$pageRenderer->setLanguage($languageService->getLocale(), $request);
$pageRenderer->setMetaTag('name', 'viewport', 'width=device-width, initial-scale=1');
$pageRenderer->setFavIcon($this->getBackendFavicon($extensionConfiguration, $request));
$nonce = $request->getAttribute('nonce');
if ($nonce !== null) {
$pageRenderer->setNonce($nonce);
$pageRenderer->setApplyNonceHint(true);
}
$this->loadStylesheets($pageRenderer);
}
/**
* Load all registered stylesheets from $GLOBALS['TYPO3_CONF_VARS']['BE']['stylesheets']
*/
protected function loadStylesheets(PageRenderer $pageRenderer): void
{
// @todo this needs to be replaced with usage of the yet to be created
// System Resource API that can handle folders
// This will then remove the need to use internal SystemResourceIdentifierFactory here
$packageManager = GeneralUtility::makeInstance(PackageManager::class);
$identifierFactory = new SystemResourceIdentifierFactory($packageManager);
foreach ($GLOBALS['TYPO3_CONF_VARS']['BE']['stylesheets'] ?? [] as $potentialResourceIdentifier) {
try {
$resourceIdentifier = $identifierFactory->create($potentialResourceIdentifier);
} catch (InvalidSystemResourceIdentifierException) {
continue;
}
if (!$resourceIdentifier instanceof PackageResourceIdentifier) {
continue;
}
$package = $resourceIdentifier->getPackage();
$relativePath = $resourceIdentifier->getRelativePath();
$absolutePath = $package->getPackagePath() . $relativePath;
if (is_dir($absolutePath)) {
// Path like 'PKG:vendor/my-extension:Resources/Public/Css/Backend'
foreach (GeneralUtility::getFilesInDir($absolutePath, 'css') as $cssFile) {
$pageRenderer->addCssFile((string)$resourceIdentifier->withRelativePath(rtrim($relativePath, '/') . '/' . $cssFile));
}
} elseif (file_exists($absolutePath)) {
// A single file 'PKG:my_extension:Resources/Public/Css/Backend/main.css' or just a single file
$pageRenderer->addCssFile((string)$resourceIdentifier);
}
}
}
/**
* Retrieves configured favicon for backend with fallback.
*/
protected function getBackendFavicon(ExtensionConfiguration $extensionConfiguration, ServerRequestInterface $request): string
{
$backendFavicon = $extensionConfiguration->get('backend', 'backendFavicon');
if (!empty($backendFavicon)) {
return $this->getUriForFileName($request, $backendFavicon);
}
return $this->getUriForFileName($request, 'EXT:backend/Resources/Public/Icons/favicon.ico');
}
/**
* Returns the uri for a system resource
*
* @throws CanNotResolvePublicResourceException
* @throws CanNotResolveSystemResourceException
*/
protected function getUriForFileName(ServerRequestInterface $request, string $resourceIdentifier): string
{
return (string)PathUtility::getSystemResourceUri($resourceIdentifier, $request);
}
}