diff --git a/core/modules/navigation/css/components/admin-toolbar.css b/core/modules/navigation/css/components/admin-toolbar.css index f5f0536a10295edd9ed723ddddaf2058800e8d7f..6ea6c5d05bddddf05d7a72cc9341128843f32258 100644 --- a/core/modules/navigation/css/components/admin-toolbar.css +++ b/core/modules/navigation/css/components/admin-toolbar.css @@ -63,7 +63,7 @@ body { z-index: var(--admin-toolbar-z-index); display: flex; flex-direction: column; - height: 100vh; + block-size: 100vh; transform: translateX(-100%); background-color: var(--admin-toolbar-color-white); font-family: var(--admin-toolbar-font-family); @@ -92,7 +92,9 @@ body { } @media (min-width: 64rem) { .admin-toolbar { + block-size: calc(100vh - var(--drupal-displace-offset-top, 0px)); transform: none; + inset-block-start: var(--drupal-displace-offset-top, 0); } } @media only screen and (max-height: 18.75rem) { diff --git a/core/modules/navigation/css/components/admin-toolbar.pcss.css b/core/modules/navigation/css/components/admin-toolbar.pcss.css index 9d9a447b2908ae799ee54a8fed8d3c180475783f..5b1516b9bf2bfdc963c8f05702e22ee51384e05b 100644 --- a/core/modules/navigation/css/components/admin-toolbar.pcss.css +++ b/core/modules/navigation/css/components/admin-toolbar.pcss.css @@ -63,7 +63,7 @@ body { z-index: var(--admin-toolbar-z-index); display: flex; flex-direction: column; - height: 100vh; + block-size: 100vh; transform: translateX(-100%); background-color: var(--admin-toolbar-color-white); font-family: var(--admin-toolbar-font-family); @@ -93,7 +93,9 @@ body { } @media (--admin-toolbar-desktop) { + block-size: calc(100vh - var(--drupal-displace-offset-top, 0px)); transform: none; + inset-block-start: var(--drupal-displace-offset-top, 0); } @media only screen and (max-height: 300px) { diff --git a/core/modules/navigation/css/components/toolbar-popover.css b/core/modules/navigation/css/components/toolbar-popover.css index c1e795b1cac9b3ba37bdce164e904b491a42e053..76f587a325e60de593652e338b7d4e5a08f60462 100644 --- a/core/modules/navigation/css/components/toolbar-popover.css +++ b/core/modules/navigation/css/components/toolbar-popover.css @@ -44,6 +44,7 @@ [data-toolbar-popover-wrapper] { --admin-toolbar-z-index-popover: var(--drupal-admin-z-index-popover, -1); + block-size: calc(100vh - var(--drupal-displace-offset-top, 0px)); padding-block-start: var(--admin-toolbar-space-16); transform: translateX(0); box-shadow: @@ -51,7 +52,7 @@ 0 0 8px rgba(0, 0, 0, 0.04), 0 0 40px rgba(0, 0, 0, 0.06); inline-size: var(--admin-toolbar-popover-width); - inset-block-start: 0; + inset-block-start: var(--drupal-displace-offset-top, 0); inset-inline-start: 1px; } } diff --git a/core/modules/navigation/css/components/toolbar-popover.pcss.css b/core/modules/navigation/css/components/toolbar-popover.pcss.css index ffdc04fa4e5fa4a48c5f8612ae003ce8f34fd3c4..6a8b2a019c24bdafc1b695c4429e223b822628d7 100644 --- a/core/modules/navigation/css/components/toolbar-popover.pcss.css +++ b/core/modules/navigation/css/components/toolbar-popover.pcss.css @@ -43,6 +43,7 @@ @media (--admin-toolbar-desktop) { --admin-toolbar-z-index-popover: var(--drupal-admin-z-index-popover, -1); + block-size: calc(100vh - var(--drupal-displace-offset-top, 0px)); padding-block-start: var(--admin-toolbar-space-16); transform: translateX(0); box-shadow: @@ -50,7 +51,7 @@ 0 0 8px rgba(0, 0, 0, 0.04), 0 0 40px rgba(0, 0, 0, 0.06); inline-size: var(--admin-toolbar-popover-width); - inset-block-start: 0; + inset-block-start: var(--drupal-displace-offset-top, 0); inset-inline-start: 1px; } } diff --git a/core/modules/navigation/css/components/tooltip.css b/core/modules/navigation/css/components/tooltip.css index 1571d00035da21f192778b8f4c74fa0b9696da3c..25a20bb34130ddf0c46081d0ea5c4d9387cff0dd 100644 --- a/core/modules/navigation/css/components/tooltip.css +++ b/core/modules/navigation/css/components/tooltip.css @@ -22,9 +22,11 @@ font-variation-settings: "wght" 600; line-height: var(--admin-toolbar-line-height-info-sm); } -[data-drupal-tooltip]:hover + .toolbar-tooltip, -[data-drupal-tooltip]:focus + .toolbar-tooltip { - display: block; +@media (min-width: 64rem) { + [data-drupal-tooltip]:hover + .toolbar-tooltip, + [data-drupal-tooltip]:focus + .toolbar-tooltip { + display: block; + } } [data-admin-toolbar="expanded"] [data-drupal-tooltip]:hover + .toolbar-block__title-tooltip { display: none; diff --git a/core/modules/navigation/css/components/tooltip.pcss.css b/core/modules/navigation/css/components/tooltip.pcss.css index 7af2010c2f4f7fd48650a760644ab566a5487b5d..35462424121c5ec5b5b65013e2fbde259275a45b 100644 --- a/core/modules/navigation/css/components/tooltip.pcss.css +++ b/core/modules/navigation/css/components/tooltip.pcss.css @@ -4,6 +4,8 @@ * Tooltip styles. */ +@import "../base/media-queries.pcss.css"; + .toolbar-tooltip { position: fixed; z-index: var(--admin-toolbar-z-index-tooltip); @@ -18,9 +20,11 @@ line-height: var(--admin-toolbar-line-height-info-sm); } -[data-drupal-tooltip]:hover + .toolbar-tooltip, -[data-drupal-tooltip]:focus + .toolbar-tooltip { - display: block; +@media (--admin-toolbar-desktop) { + [data-drupal-tooltip]:hover + .toolbar-tooltip, + [data-drupal-tooltip]:focus + .toolbar-tooltip { + display: block; + } } [data-admin-toolbar="expanded"] [data-drupal-tooltip]:hover + .toolbar-block__title-tooltip { diff --git a/core/modules/navigation/css/components/top-bar.css b/core/modules/navigation/css/components/top-bar.css index c2d15b21fb2ff0533c54b6251e7c4cec3277d3df..9e0eb2f928bfa50a9fcb555ded2573dc0a3138f4 100644 --- a/core/modules/navigation/css/components/top-bar.css +++ b/core/modules/navigation/css/components/top-bar.css @@ -11,17 +11,18 @@ position: relative; z-index: var(--admin-toolbar-z-index-top-bar); display: flex; - padding-inline: var(--admin-toolbar-space-4); - padding-block: var(--admin-toolbar-space-4); + display: none; background-color: white; box-shadow: 0 0 8px 0 var(--admin-toolbar-color-shadow-15); font-family: var(--admin-toolbar-font-family); + padding-inline: var(--admin-toolbar-space-4); + padding-block: var(--admin-toolbar-space-4); } @media (min-width: 64rem) { .top-bar { block-size: var(--admin-toolbar-top-bar-height); position: fixed; - inset-block-start: 0; + inset-block-start: var(--drupal-displace-offset-top, 0); inset-inline-start: 0; width: 100vw; padding-block: var(--admin-toolbar-space-12); @@ -35,23 +36,22 @@ padding-inline: calc(var(--drupal-displace-offset-right, var(--admin-toolbar-sidebar-width)) + var(--admin-toolbar-space-32)) var(--admin-toolbar-space-32); } } -/* When only one burger button hide top bar on desktop. */ -@media (min-width: 64rem) { - .top-bar:has(.top-bar__burger:only-child) { - display: none; - } +.top-bar:has(.top-bar__tools:not(:empty), .top-bar__context:not(:empty), .top-bar__actions:not(:empty)) { + display: block; } @media (min-width: 64rem) { - .top-bar:not(:has(.top-bar__burger:only-child)) ~ .dialog-off-canvas-main-canvas { + .top-bar:has(.top-bar__tools:not(:empty), .top-bar__context:not(:empty), .top-bar__actions:not(:empty)) ~ .dialog-off-canvas-main-canvas { margin-block-start: var(--admin-toolbar-top-bar-height); } } -.top-bar__burger { - align-self: start; +.top-bar__actions { + display: flex; + gap: 0.5rem; } @media (min-width: 64rem) { - .top-bar__burger { - display: none; + .top-bar__actions { + justify-content: end; + gap: var(--admin-toolbar-space-4); } } .top-bar__content { @@ -71,3 +71,13 @@ gap: var(--admin-toolbar-space-8); } } +.top-bar__context { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: start; +} +.top-bar__tools { + display: flex; + gap: 0.5rem; +} diff --git a/core/modules/navigation/css/components/top-bar.pcss.css b/core/modules/navigation/css/components/top-bar.pcss.css index 7fb795c1db4c46b8d5003e2b514176486a47b42a..3a1c5cfb254e03dfb46f65fbe36712a7dc366b3e 100644 --- a/core/modules/navigation/css/components/top-bar.pcss.css +++ b/core/modules/navigation/css/components/top-bar.pcss.css @@ -8,16 +8,17 @@ position: relative; z-index: var(--admin-toolbar-z-index-top-bar); display: flex; - padding-inline: var(--admin-toolbar-space-4); - padding-block: var(--admin-toolbar-space-4); + display: none; background-color: white; box-shadow: 0 0 8px 0 var(--admin-toolbar-color-shadow-15); font-family: var(--admin-toolbar-font-family); + padding-inline: var(--admin-toolbar-space-4); + padding-block: var(--admin-toolbar-space-4); @media (--admin-toolbar-desktop) { block-size: var(--admin-toolbar-top-bar-height); position: fixed; - inset-block-start: 0; + inset-block-start: var(--drupal-displace-offset-top, 0); inset-inline-start: 0; width: 100vw; padding-block: var(--admin-toolbar-space-12); @@ -32,23 +33,23 @@ } } -/* When only one burger button hide top bar on desktop. */ -.top-bar:has(.top-bar__burger:only-child) { - @media (--admin-toolbar-desktop) { - display: none; - } -} +.top-bar:has(.top-bar__tools:not(:empty), .top-bar__context:not(:empty), .top-bar__actions:not(:empty)) { + display: block; -.top-bar:not(:has(.top-bar__burger:only-child)) ~ .dialog-off-canvas-main-canvas { - @media (--admin-toolbar-desktop) { - margin-block-start: var(--admin-toolbar-top-bar-height); + ~ .dialog-off-canvas-main-canvas { + @media (--admin-toolbar-desktop) { + margin-block-start: var(--admin-toolbar-top-bar-height); + } } } -.top-bar__burger { - align-self: start; +.top-bar__actions { + display: flex; + gap: 0.5rem; + @media (--admin-toolbar-desktop) { - display: none; + justify-content: end; + gap: var(--admin-toolbar-space-4); } } @@ -69,3 +70,15 @@ gap: var(--admin-toolbar-space-8); } } + +.top-bar__context { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: start; +} + +.top-bar__tools { + display: flex; + gap: 0.5rem; +} diff --git a/core/modules/navigation/layouts/navigation.html.twig b/core/modules/navigation/layouts/navigation.html.twig index afe5152a713a717f247b1e9041a95746e9a4ca7e..3748c239ea79300d295edd624c83c1bbb9b76884 100644 --- a/core/modules/navigation/layouts/navigation.html.twig +++ b/core/modules/navigation/layouts/navigation.html.twig @@ -18,7 +18,7 @@ #} {% set control_bar_attributes = create_attribute() %} -<div {{ control_bar_attributes.addClass('admin-toolbar-control-bar').setAttribute('data-drupal-admin-styles', '').setAttribute('data-offset-top', '') }}> +<div {{ control_bar_attributes.addClass('admin-toolbar-control-bar').setAttribute('data-drupal-admin-styles', '') }}> <div class="admin-toolbar-control-bar__content"> {% include 'navigation:toolbar-button' with { attributes: create_attribute({'aria-expanded': 'false', 'aria-controls': 'admin-toolbar', 'type': 'button'}), diff --git a/core/modules/navigation/navigation.module b/core/modules/navigation/navigation.module index 74a136250832983e1ac97319154b9efb9179a7fd..8dcdee642ef71df01f79097d06f66160f152e0f1 100644 --- a/core/modules/navigation/navigation.module +++ b/core/modules/navigation/navigation.module @@ -4,6 +4,8 @@ * @file */ +use Drupal\navigation\TopBarRegion; + /** * Implements hook_module_implements_alter(). */ @@ -18,3 +20,21 @@ function navigation_module_implements_alter(&$implementations, $hook) { unset($implementations['layout_builder']); } } + +/** + * Prepares variables for navigation top bar template. + * + * Default template: top-bar.html.twig + * + * @param $variables + * An associative array containing: + * - element: An associative array containing the properties and children of + * the top bar. + */ +function template_preprocess_top_bar(&$variables): void { + $element = $variables['element']; + + foreach (TopBarRegion::cases() as $region) { + $variables[$region->value] = $element[$region->value] ?? NULL; + } +} diff --git a/core/modules/navigation/navigation.services.yml b/core/modules/navigation/navigation.services.yml index 5413372b557b0ea7e99750a4e7debc9595b6cd73..6a8c54d25197e7698305e4a717e75bbd3a0f0919 100644 --- a/core/modules/navigation/navigation.services.yml +++ b/core/modules/navigation/navigation.services.yml @@ -14,7 +14,8 @@ services: '@file_url_generator', '@plugin.manager.layout_builder.section_storage', '@request_stack', - '@extension.list.module' + '@extension.list.module', + '@current_user', ] Drupal\navigation\NavigationRenderer: '@navigation.renderer' @@ -33,3 +34,8 @@ services: class: Drupal\navigation\UserLazyBuilder arguments: ['@current_user'] Drupal\navigation\UserLazyBuilders: '@navigation.user_lazy_builder' + + plugin.manager.top_bar_item: + class: Drupal\navigation\TopBarItemManager + parent: default_plugin_manager + Drupal\navigation\TopBarItemManagerInterface: '@plugin.manager.top_bar_item' diff --git a/core/modules/navigation/src/Attribute/TopBarItem.php b/core/modules/navigation/src/Attribute/TopBarItem.php new file mode 100644 index 0000000000000000000000000000000000000000..3041d720b1030dda978be8a35b2434d5772d99be --- /dev/null +++ b/core/modules/navigation/src/Attribute/TopBarItem.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation\Attribute; + +use Drupal\Component\Plugin\Attribute\Plugin; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\navigation\TopBarRegion; + +/** + * The top bar item attribute. + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class TopBarItem extends Plugin { + + /** + * Constructs a new TopBarItem instance. + * + * @param string $id + * The top bar item ID. + * @param \Drupal\navigation\TopBarRegion $region + * The region where the top bar item belongs to. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $label + * (optional) The human-readable name of the top bar item. + * @param class-string|null $deriver + * (optional) The deriver class. + */ + public function __construct( + public readonly string $id, + public readonly TopBarRegion $region, + public readonly ?TranslatableMarkup $label = NULL, + public readonly ?string $deriver = NULL, + ) {} + +} diff --git a/core/modules/navigation/src/Element/TopBar.php b/core/modules/navigation/src/Element/TopBar.php new file mode 100644 index 0000000000000000000000000000000000000000..c664f929c2242e142f014312c55817b29421b9c5 --- /dev/null +++ b/core/modules/navigation/src/Element/TopBar.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation\Element; + +use Drupal\Core\Render\Attribute\RenderElement; +use Drupal\Core\Render\Element\RenderElementBase; +use Drupal\navigation\TopBarItemManagerInterface; +use Drupal\navigation\TopBarRegion; + +/** + * Provides a render element for the default Drupal toolbar. + */ +#[RenderElement('top_bar')] +class TopBar extends RenderElementBase { + + /** + * {@inheritdoc} + */ + public function getInfo(): array { + $class = static::class; + return [ + '#pre_render' => [ + [$class, 'preRenderTopBar'], + ], + '#theme' => 'top_bar', + '#attached' => [ + 'library' => [ + 'navigation/internal.navigation', + ], + ], + ]; + } + + /** + * Builds the TopBar as a structured array ready for rendering. + * + * Since building the TopBar takes some time, it is done just prior to + * rendering to ensure that it is built only if it will be displayed. + * + * @param array $element + * A renderable array. + * + * @return array + * A renderable array. + * + * @see navigation_page_top() + */ + public static function preRenderTopBar($element): array { + $top_bar_item_manager = static::topBarItemManager(); + + // Group the items by region. + foreach (TopBarRegion::cases() as $region) { + $items = $top_bar_item_manager->getRenderedTopBarItemsByRegion($region); + $element = array_merge($element, [$region->value => $items]); + } + + return $element; + } + + /** + * Wraps the top bar item manager. + * + * @return \Drupal\navigation\TopBarItemManager + * The top bar item manager. + */ + protected static function topBarItemManager(): TopBarItemManagerInterface { + return \Drupal::service(TopBarItemManagerInterface::class); + } + +} diff --git a/core/modules/navigation/src/Hook/NavigationHooks.php b/core/modules/navigation/src/Hook/NavigationHooks.php index 71b9a40791a036f0bb9c7594148cb50cdd280f74..a52f4fab4a1fda44cd60d8153b85cf8ddaa52cc1 100644 --- a/core/modules/navigation/src/Hook/NavigationHooks.php +++ b/core/modules/navigation/src/Hook/NavigationHooks.php @@ -76,7 +76,7 @@ public function pageTop(array &$page_top) { */ #[Hook('theme')] public function theme($existing, $type, $theme, $path) : array { - $items['top_bar'] = ['variables' => ['local_tasks' => []]]; + $items['top_bar'] = ['render element' => 'element']; $items['top_bar_local_tasks'] = ['variables' => ['local_tasks' => []]]; $items['top_bar_local_task'] = ['variables' => ['link' => []]]; $items['big_pipe_interface_preview__navigation_shortcut_lazy_builder_lazyLinks__Shortcuts'] = [ diff --git a/core/modules/navigation/src/NavigationRenderer.php b/core/modules/navigation/src/NavigationRenderer.php index 4aa5bece02dec85f835464dd8cd7e0396cade1cc..1083109f62a9ead9a9b66d0976d4519eaf7ac1c0 100644 --- a/core/modules/navigation/src/NavigationRenderer.php +++ b/core/modules/navigation/src/NavigationRenderer.php @@ -18,6 +18,7 @@ use Drupal\Core\Plugin\Context\Context; use Drupal\Core\Plugin\Context\ContextDefinition; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountInterface; use Drupal\layout_builder\SectionStorage\SectionStorageManagerInterface; use Symfony\Component\HttpFoundation\RequestStack; @@ -71,6 +72,7 @@ public function __construct( private SectionStorageManagerInterface $sectionStorageManager, private RequestStack $requestStack, private ModuleExtensionList $moduleExtensionList, + private AccountInterface $currentUser, ) {} /** @@ -170,32 +172,13 @@ public function buildTopBar(array &$page_top): void { } $page_top['top_bar'] = [ - '#theme' => 'top_bar', - '#attached' => [ - 'library' => [ - 'navigation/internal.navigation', - ], - ], + '#type' => 'top_bar', + '#access' => $this->currentUser->hasPermission('access navigation'), '#cache' => [ - 'contexts' => [ - 'url.path', - 'user.permissions', - ], + 'keys' => ['top_bar'], + 'contexts' => ['user.permissions'], ], ]; - - // Local tasks for content entities. - if ($this->hasLocalTasks()) { - $local_tasks = $this->getLocalTasks(); - $page_top['top_bar']['#local_tasks'] = [ - '#theme' => 'top_bar_local_tasks', - '#local_tasks' => $local_tasks['tasks'], - ]; - assert($local_tasks['cacheability'] instanceof CacheableMetadata); - CacheableMetadata::createFromRenderArray($page_top['top_bar']) - ->addCacheableDependency($local_tasks['cacheability']) - ->applyTo($page_top['top_bar']); - } } /** @@ -227,7 +210,7 @@ public function removeLocalTasks(array &$build, BlockPluginInterface $block): vo * @return array * Local tasks keyed by route name. */ - private function getLocalTasks(): array { + public function getLocalTasks(): array { if (isset($this->localTasks)) { return $this->localTasks; } @@ -279,7 +262,7 @@ private function getLocalTasks(): array { * @return bool * TRUE if there are local tasks available for the top bar, FALSE otherwise. */ - private function hasLocalTasks(): bool { + public function hasLocalTasks(): bool { $local_tasks = $this->getLocalTasks(); return !empty($local_tasks['tasks']); } diff --git a/core/modules/navigation/src/Plugin/TopBarItem/PageActions.php b/core/modules/navigation/src/Plugin/TopBarItem/PageActions.php new file mode 100644 index 0000000000000000000000000000000000000000..b432e6189af276fef754744070fa44d4b933abd1 --- /dev/null +++ b/core/modules/navigation/src/Plugin/TopBarItem/PageActions.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation\Plugin\TopBarItem; + +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\navigation\Attribute\TopBarItem; +use Drupal\navigation\NavigationRenderer; +use Drupal\navigation\TopBarItemBase; +use Drupal\navigation\TopBarRegion; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides the Page Actions basic top bar item. + */ +#[TopBarItem( + id: 'page_actions', + region: TopBarRegion::Actions, + label: new TranslatableMarkup('Page Actions'), +)] +final class PageActions extends TopBarItemBase implements ContainerFactoryPluginInterface { + + public function __construct(array $configuration, $plugin_id, $plugin_definition, private NavigationRenderer $navigationRenderer) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + } + + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get(NavigationRenderer::class) + ); + } + + /** + * {@inheritdoc} + */ + public function build(): array { + $build = []; + // Local tasks for content entities. + if ($this->navigationRenderer->hasLocalTasks()) { + $local_tasks = $this->navigationRenderer->getLocalTasks(); + $build = [ + '#theme' => 'top_bar_local_tasks', + '#local_tasks' => $local_tasks['tasks'], + ]; + assert($local_tasks['cacheability'] instanceof CacheableMetadata); + $local_tasks['cacheability']->applyTo($build); + } + + return $build; + } + +} diff --git a/core/modules/navigation/src/TopBarItemBase.php b/core/modules/navigation/src/TopBarItemBase.php new file mode 100644 index 0000000000000000000000000000000000000000..7e1dbd2cdfae723ffe2fc03ea60c181b7a5ac4b0 --- /dev/null +++ b/core/modules/navigation/src/TopBarItemBase.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation; + +use Drupal\Component\Plugin\PluginBase; + +/** + * Base class for top bar item plugins. + */ +abstract class TopBarItemBase extends PluginBase implements TopBarItemPluginInterface { + + /** + * {@inheritdoc} + */ + public function label(): string|\Stringable { + return $this->pluginDefinition['label']; + } + + /** + * {@inheritdoc} + */ + public function region(): TopBarRegion { + return $this->pluginDefinition['region']; + } + + /** + * {@inheritdoc} + */ + abstract public function build(): array; + +} diff --git a/core/modules/navigation/src/TopBarItemManager.php b/core/modules/navigation/src/TopBarItemManager.php new file mode 100644 index 0000000000000000000000000000000000000000..f09490c21f1173c6062904e5eb1b1a169f790d66 --- /dev/null +++ b/core/modules/navigation/src/TopBarItemManager.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation; + +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Plugin\DefaultPluginManager; +use Drupal\navigation\Attribute\TopBarItem; + +/** + * Top bar item plugin manager. + */ +final class TopBarItemManager extends DefaultPluginManager implements TopBarItemManagerInterface { + + /** + * Constructs the object. + */ + public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) { + parent::__construct('Plugin/TopBarItem', $namespaces, $module_handler, TopBarItemPluginInterface::class, TopBarItem::class); + $this->alterInfo('top_bar_item'); + $this->setCacheBackend($cache_backend, 'top_bar_item_plugins'); + } + + /** + * {@inheritdoc} + */ + public function getDefinitionsByRegion(TopBarRegion $region): array { + return array_filter($this->getDefinitions(), fn (array $definition) => $definition['region'] === $region); + } + + /** + * {@inheritdoc} + */ + public function getRenderedTopBarItemsByRegion(TopBarRegion $region): array { + $instances = []; + foreach ($this->getDefinitionsByRegion($region) as $plugin_id => $plugin_definition) { + $instances[$plugin_id] = $this->createInstance($plugin_id)->build(); + } + + return $instances; + } + +} diff --git a/core/modules/navigation/src/TopBarItemManagerInterface.php b/core/modules/navigation/src/TopBarItemManagerInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..088715ae33a77bf8e34dc31039aac9419b9db42a --- /dev/null +++ b/core/modules/navigation/src/TopBarItemManagerInterface.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation; + +/** + * Top bar item plugin manager. + */ +interface TopBarItemManagerInterface { + + /** + * Gets the top bar item plugins by region. + * + * @param \Drupal\navigation\TopBarRegion $region + * The region. + * + * @return array + * A list of top bar item plugin definitions. + */ + public function getDefinitionsByRegion(TopBarRegion $region): array; + + /** + * Gets the top bar items prepared as render array. + * + * @param \Drupal\navigation\TopBarRegion $region + * The region. + * + * @return array + * An array of rendered top bar items, keyed by the plugin ID and sorted by + * weight. + */ + public function getRenderedTopBarItemsByRegion(TopBarRegion $region): array; + +} diff --git a/core/modules/navigation/src/TopBarItemPluginInterface.php b/core/modules/navigation/src/TopBarItemPluginInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..fc73a0dc767eb5ba236b2abf313c1d3a03f7f23d --- /dev/null +++ b/core/modules/navigation/src/TopBarItemPluginInterface.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation; + +/** + * Interface for top bar plugins. + */ +interface TopBarItemPluginInterface { + + /** + * Returns the translated plugin label. + * + * @return string|\Stringable + * The translated plugin label. + */ + public function label(): string|\Stringable; + + /** + * Returns the plugin region. + * + * @return \Drupal\navigation\TopBarRegion + * The plugin region. + */ + public function region(): TopBarRegion; + + /** + * Builds and returns the renderable array for this top bar item plugin. + * + * If a top bar item should not be rendered because it has no content, then + * this method must also ensure to return no content: it must then only return + * an empty array, or an empty array with #cache set (with cacheability + * metadata indicating the circumstances for it being empty). + * + * @return array + * A renderable array representing the content of the top bar item. + */ + public function build(); + +} diff --git a/core/modules/navigation/src/TopBarRegion.php b/core/modules/navigation/src/TopBarRegion.php new file mode 100644 index 0000000000000000000000000000000000000000..bb1de467f65de6539563807747cac1932e68dffa --- /dev/null +++ b/core/modules/navigation/src/TopBarRegion.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\navigation; + +/** + * Enumeration of the Top Bar regions. + */ +enum TopBarRegion: string { + case Tools = 'tools'; + case Context = 'context'; + case Actions = 'actions'; +} diff --git a/core/modules/navigation/templates/top-bar.html.twig b/core/modules/navigation/templates/top-bar.html.twig index c840fd6b6647084fc75f30d548f6abc7635145ec..b294ecfaf2062fde91a8bc5c09cd5c2ea7177278 100644 --- a/core/modules/navigation/templates/top-bar.html.twig +++ b/core/modules/navigation/templates/top-bar.html.twig @@ -4,17 +4,25 @@ * Default theme implementation for the navigation top bar. * * Available variables: - * - local_tasks: The local tasks for the current route. + * - element: The top bar render element. + * - tools: The tools region of the top bar. + * - context: The context region of the top bar. + * - actions: The actions region of the top bar. * * @ingroup themeable */ #} {% set attributes = create_attribute() %} -{% if local_tasks %} - {% set attributes = attributes.setAttribute('data-offset-top', '') %} - <div {{ attributes.addClass('top-bar').setAttribute('data-drupal-admin-styles', '') }}> - <div class="top-bar__content"> - {{ local_tasks }} +<div {{ attributes.addClass('top-bar').setAttribute('data-drupal-admin-styles', '') }}> + <div class="top-bar__content"> + <div class="top-bar__tools"> + {{- tools -}} + </div> + <div class="top-bar__context"> + {{- context -}} + </div> + <div class="top-bar__actions"> + {{- actions -}} </div> </div> -{% endif %} +</div> diff --git a/core/modules/navigation/tests/navigation_test/src/Plugin/TopBarItem/TopBarItemInstantiation.php b/core/modules/navigation/tests/navigation_test/src/Plugin/TopBarItem/TopBarItemInstantiation.php new file mode 100644 index 0000000000000000000000000000000000000000..dd0a6de715938faac579193cb795e98e21cae882 --- /dev/null +++ b/core/modules/navigation/tests/navigation_test/src/Plugin/TopBarItem/TopBarItemInstantiation.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation_test\Plugin\TopBarItem; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\navigation\Attribute\TopBarItem; +use Drupal\navigation\TopBarItemBase; +use Drupal\navigation\TopBarRegion; + +#[TopBarItem( + id: 'test_item', + region: TopBarRegion::Actions, + label: new TranslatableMarkup('Test Item'), +)] +class TopBarItemInstantiation extends TopBarItemBase { + + /** + * {@inheritdoc} + */ + public function build(): array { + return [ + '#markup' => 'Top Bar Item', + ]; + } + +} diff --git a/core/modules/navigation/tests/src/Functional/NavigationTopBarTest.php b/core/modules/navigation/tests/src/Functional/NavigationTopBarTest.php index 84fc5a0acdc17774c27846130a5bf8184a7c92ae..18b4d5bac38ba281bcd35ed48ab301cff0842b1f 100644 --- a/core/modules/navigation/tests/src/Functional/NavigationTopBarTest.php +++ b/core/modules/navigation/tests/src/Functional/NavigationTopBarTest.php @@ -79,15 +79,15 @@ public function testTopBarVisibility(): void { $this->drupalGet($this->node->toUrl()); // Top Bar is not visible if the feature flag module is disabled. - $this->assertSession()->elementNotExists('xpath', "//div[contains(@class, 'top-bar__content')]/button/span"); + $this->assertSession()->elementNotExists('xpath', "//div[contains(@class, 'top-bar__content')]/div[contains(@class, 'top-bar__actions')]/button/span"); $this->assertSession()->elementExists('xpath', '//div[@id="block-tabs"]'); \Drupal::service('module_installer')->install(['navigation_top_bar']); // Top Bar is visible once the feature flag module is enabled. $this->drupalGet($this->node->toUrl()); - $this->assertSession()->elementExists('xpath', "//div[contains(@class, 'top-bar__content')]/button/span"); - $this->assertSession()->elementTextEquals('xpath', "//div[contains(@class, 'top-bar__content')]/button/span", 'More actions'); + $this->assertSession()->elementExists('xpath', "//div[contains(@class, 'top-bar__content')]/div[contains(@class, 'top-bar__actions')]/button/span"); + $this->assertSession()->elementTextEquals('xpath', "//div[contains(@class, 'top-bar__content')]/div[contains(@class, 'top-bar__actions')]/button/span", 'More actions'); $this->assertSession()->elementNotExists('xpath', '//div[@id="block-tabs"]'); // Find all the dropdown links and check if the top bar is there as well. @@ -95,8 +95,8 @@ public function testTopBarVisibility(): void { foreach ($toolbar_links->findAll('css', 'li') as $toolbar_link) { $this->clickLink($toolbar_link->getText()); - $this->assertSession()->elementExists('xpath', "//div[contains(@class, 'top-bar__content')]/button/span"); - $this->assertSession()->elementTextEquals('xpath', "//div[contains(@class, 'top-bar__content')]/button/span", 'More actions'); + $this->assertSession()->elementExists('xpath', "//div[contains(@class, 'top-bar__content')]/div[contains(@class, 'top-bar__actions')]/button/span"); + $this->assertSession()->elementTextEquals('xpath', "//div[contains(@class, 'top-bar__content')]/div[contains(@class, 'top-bar__actions')]/button/span", 'More actions'); $this->assertSession()->elementNotExists('xpath', '//div[@id="block-tabs"]'); } } diff --git a/core/modules/navigation/tests/src/Unit/TopBarItemBaseTest.php b/core/modules/navigation/tests/src/Unit/TopBarItemBaseTest.php new file mode 100644 index 0000000000000000000000000000000000000000..27cc1f24dad31d3ac0aefe6aa75f3ccb6fd8bb01 --- /dev/null +++ b/core/modules/navigation/tests/src/Unit/TopBarItemBaseTest.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\navigation\Unit; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\navigation\TopBarRegion; +use Drupal\navigation_test\Plugin\TopBarItem\TopBarItemInstantiation; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\navigation\TopBarItemBase + * + * @group navigation + */ +class TopBarItemBaseTest extends UnitTestCase { + + /** + * @covers ::label + * @covers ::region + */ + public function testTopBarItemBase(): void { + $definition = [ + 'label' => new TranslatableMarkup('label'), + 'region' => TopBarRegion::Tools, + ]; + + $top_bar_item_base = new TopBarItemInstantiation([], 'test_top_bar_item_base', $definition); + + $this->assertEquals($definition['label'], $top_bar_item_base->label()); + $this->assertEquals($definition['region'], $top_bar_item_base->region()); + } + +} diff --git a/core/modules/navigation/tests/src/Unit/TopBarItemManagerTest.php b/core/modules/navigation/tests/src/Unit/TopBarItemManagerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fb61242b65fce92de5173a091888fb384a9db0a8 --- /dev/null +++ b/core/modules/navigation/tests/src/Unit/TopBarItemManagerTest.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\navigation\Unit; + +use Drupal\Component\Plugin\Discovery\DiscoveryInterface; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\navigation\TopBarItemManager; +use Drupal\navigation\TopBarItemManagerInterface; +use Drupal\navigation\TopBarRegion; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\navigation\TopBarItemManager + * + * @group navigation + */ +class TopBarItemManagerTest extends UnitTestCase { + + use StringTranslationTrait; + + /** + * The top bar item manager under test. + * + * @var \Drupal\navigation\TopBarItemManagerInterface + */ + protected TopBarItemManagerInterface $manager; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $container = new ContainerBuilder(); + $container->set('string_translation', $this->getStringTranslationStub()); + \Drupal::setContainer($container); + + $cache_backend = $this->prophesize(CacheBackendInterface::class); + $module_handler = $this->prophesize(ModuleHandlerInterface::class); + $this->manager = new TopBarItemManager(new \ArrayObject(), $cache_backend->reveal(), $module_handler->reveal()); + + $discovery = $this->prophesize(DiscoveryInterface::class); + // Specify the 'broken' block, as well as 3 other blocks with admin labels + // that are purposefully not in alphabetical order. + $discovery->getDefinitions()->willReturn([ + 'tools' => [ + 'label' => $this->t('Tools'), + 'region' => TopBarRegion::Tools, + ], + 'context' => [ + 'admin_label' => $this->t('Context'), + 'region' => TopBarRegion::Context, + ], + 'actions' => [ + 'label' => $this->t('Actions'), + 'region' => TopBarRegion::Actions, + ], + 'more_actions' => [ + 'label' => $this->t('More Actions'), + 'region' => TopBarRegion::Actions, + ], + ]); + // Force the discovery object onto the block manager. + $property = new \ReflectionProperty(TopBarItemManager::class, 'discovery'); + $property->setValue($this->manager, $discovery->reveal()); + } + + /** + * @covers ::getDefinitions + */ + public function testDefinitions(): void { + $definitions = $this->manager->getDefinitions(); + $this->assertSame(['tools', 'context', 'actions', 'more_actions'], array_keys($definitions)); + } + + /** + * @covers ::getDefinitionsByRegion + */ + public function testGetDefinitionsByRegion(): void { + $tools = $this->manager->getDefinitionsByRegion(TopBarRegion::Tools); + $this->assertSame(['tools'], array_keys($tools)); + $context = $this->manager->getDefinitionsByRegion(TopBarRegion::Context); + $this->assertSame(['context'], array_keys($context)); + $actions = $this->manager->getDefinitionsByRegion(TopBarRegion::Actions); + $this->assertSame(['actions', 'more_actions'], array_keys($actions)); + } + +}