From 54c4637eb3df707423dc6831b11a9277a69709e3 Mon Sep 17 00:00:00 2001
From: nod_ <nod_@598310.no-reply.drupal.org>
Date: Wed, 22 Jan 2025 12:21:36 +0100
Subject: [PATCH] Issue #3484600 by anjali rathod, plopesc, finnsky, lauriii,
 m4olivei, ckrina, berdir, smustgrave, catch: Show entity information on the
 Top Bar

---
 .../components/badge/badge.component.yml      |  27 ++++
 .../navigation/components/badge/badge.css     |  24 +++
 .../components/badge/badge.pcss.css           |  20 +++
 .../navigation/components/badge/badge.twig    |   5 +
 .../components/title/assets/database.svg      |   1 +
 .../components/title/assets/file.svg          |   1 +
 .../components/title/title.component.yml      |  54 +++++++
 .../navigation/components/title/title.css     |  52 +++++++
 .../components/title/title.pcss.css           |  54 +++++++
 .../navigation/components/title/title.twig    |  21 +++
 .../toolbar-button/toolbar-button.css         |   7 +-
 .../toolbar-button/toolbar-button.pcss.css    |   7 +-
 .../navigation/css/components/top-bar.css     |  13 +-
 .../css/components/top-bar.pcss.css           |  14 +-
 .../navigation/navigation.services.yml        |  12 +-
 .../navigation/src/EntityRouteHelper.php      | 141 +++++++++++++++++
 .../navigation/src/NavigationRenderer.php     |  83 +---------
 .../src/Plugin/TopBarItem/PageContext.php     | 147 ++++++++++++++++++
 .../NavigationTopBarPageContextTest.php       | 104 +++++++++++++
 .../FunctionalJavascript/PerformanceTest.php  |   6 +-
 20 files changed, 695 insertions(+), 98 deletions(-)
 create mode 100644 core/modules/navigation/components/badge/badge.component.yml
 create mode 100644 core/modules/navigation/components/badge/badge.css
 create mode 100644 core/modules/navigation/components/badge/badge.pcss.css
 create mode 100644 core/modules/navigation/components/badge/badge.twig
 create mode 100644 core/modules/navigation/components/title/assets/database.svg
 create mode 100644 core/modules/navigation/components/title/assets/file.svg
 create mode 100644 core/modules/navigation/components/title/title.component.yml
 create mode 100644 core/modules/navigation/components/title/title.css
 create mode 100644 core/modules/navigation/components/title/title.pcss.css
 create mode 100644 core/modules/navigation/components/title/title.twig
 create mode 100644 core/modules/navigation/src/EntityRouteHelper.php
 create mode 100644 core/modules/navigation/src/Plugin/TopBarItem/PageContext.php
 create mode 100644 core/modules/navigation/tests/src/Functional/NavigationTopBarPageContextTest.php

diff --git a/core/modules/navigation/components/badge/badge.component.yml b/core/modules/navigation/components/badge/badge.component.yml
new file mode 100644
index 000000000000..a7bb04f963eb
--- /dev/null
+++ b/core/modules/navigation/components/badge/badge.component.yml
@@ -0,0 +1,27 @@
+# This is so your IDE knows about the syntax for fixes and autocomplete.
+$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
+
+# The human readable name.
+name: Badge
+
+# Status can be: "experimental", "stable", "deprecated", "obsolete".
+status: experimental
+
+# Schema for the props. We support www.json-schema.org. Learn more about the
+# syntax there.
+props:
+  # Props are always an object with keys. Each key is a variable in your
+  # component template.
+  type: object
+
+  properties:
+    status:
+      type: string
+      default: info
+      enum:
+        - info
+        - success
+slots:
+  label:
+    type: string
+    description: Label text
diff --git a/core/modules/navigation/components/badge/badge.css b/core/modules/navigation/components/badge/badge.css
new file mode 100644
index 000000000000..ab8825a2596c
--- /dev/null
+++ b/core/modules/navigation/components/badge/badge.css
@@ -0,0 +1,24 @@
+/*
+ * DO NOT EDIT THIS FILE.
+ * See the following change record for more information,
+ * https://www.drupal.org/node/3084859
+ * @preserve
+ */
+/* cspell:ignore csvg cpath wght */
+/**
+ * @file
+ * Toolbar badge styles.
+ */
+.toolbar-badge {
+  margin-top: 0.1875rem;
+  padding: var(--admin-toolbar-space-4) var(--admin-toolbar-space-8);
+  border-radius: 1rem;
+  background-color: var(--admin-toolbar-color-gray-100);
+  font-size: var(--admin-toolbar-font-size-label-sm);
+  line-height: var(--admin-toolbar-line-height-label-sm);
+  font-variation-settings: "wght" 700;
+}
+.toolbar-badge--success {
+  color: var(--admin-toolbar-color-green-600);
+  background-color: var(--admin-toolbar-color-green-050);
+}
diff --git a/core/modules/navigation/components/badge/badge.pcss.css b/core/modules/navigation/components/badge/badge.pcss.css
new file mode 100644
index 000000000000..c7c924ab89f3
--- /dev/null
+++ b/core/modules/navigation/components/badge/badge.pcss.css
@@ -0,0 +1,20 @@
+/* cspell:ignore csvg cpath wght */
+/**
+ * @file
+ * Toolbar badge styles.
+ */
+
+.toolbar-badge {
+  margin-top: 3px;
+  padding: var(--admin-toolbar-space-4) var(--admin-toolbar-space-8);
+  border-radius: 1rem;
+  background-color: var(--admin-toolbar-color-gray-100);
+  font-size: var(--admin-toolbar-font-size-label-sm);
+  line-height: var(--admin-toolbar-line-height-label-sm);
+  font-variation-settings: "wght" 700;
+}
+
+.toolbar-badge--success {
+  color: var(--admin-toolbar-color-green-600);
+  background-color: var(--admin-toolbar-color-green-050);
+}
diff --git a/core/modules/navigation/components/badge/badge.twig b/core/modules/navigation/components/badge/badge.twig
new file mode 100644
index 000000000000..909fd9c8c1c9
--- /dev/null
+++ b/core/modules/navigation/components/badge/badge.twig
@@ -0,0 +1,5 @@
+<div{{ attributes.addClass('toolbar-badge', 'toolbar-badge--' ~ status|default('info')) }}>
+  {% block label %}
+    {{ label }}
+  {% endblock %}
+</div>
diff --git a/core/modules/navigation/components/title/assets/database.svg b/core/modules/navigation/components/title/assets/database.svg
new file mode 100644
index 000000000000..4455da4bd919
--- /dev/null
+++ b/core/modules/navigation/components/title/assets/database.svg
@@ -0,0 +1 @@
+<svg fill="none" height="12" viewBox="0 0 12 12" width="12" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m2 2.5c0 .00062.00009.00292.00153.00765.00169.00557.00588.01658.01601.03307.02122.03457.0645.08654.14558.15044.16509.13011.43496.26914.81301.39516.75088.25029 1.82002.41368 3.02387.41368 1.20386 0 2.27299-.16339 3.02387-.41368.37805-.12602.64792-.26505.81301-.39516.08108-.0639.12436-.11587.14558-.15044.01013-.01649.01431-.0275.01601-.03307.00146-.00479.00153-.00695.00153-.00754 0-.00003 0 .00002 0 0 0-.00036 0-.00258-.00153-.00776-.0017-.00557-.00588-.01658-.01601-.03307-.02122-.03457-.0645-.08654-.14558-.15044-.16509-.13011-.43496-.26914-.81301-.39516-.75088-.25029-1.82001-.41368-3.02387-.41368-1.20385 0-2.27299.16339-3.02387.41368-.37805.12602-.64792.26505-.81301.39516-.08108.0639-.12436.11587-.14558.15044-.01013.01649-.01432.0275-.01601.03307-.00154.00504-.00153.00721-.00153.00765zm8 1.2633c-.20061.10259-.42333.19284-.65991.2717-.8778.2926-2.05866.465-3.34009.465s-2.46229-.1724-3.34009-.465c-.23658-.07886-.4593-.16911-.65991-.2717v2.2367l.00008.00078c.0001.00065.00037.00219.00113.00474.0015.00503.00535.01547.01486.03134.01989.03316.06113.08421.13994.14763.16054.12919.42904.27147.82012.40183.77887.25962 1.86698.41368 3.02387.41368s2.245-.15406 3.02387-.41368c.39108-.13036.65958-.27264.82012-.40183.07881-.06342.12005-.11447.13994-.14763.00951-.01587.01336-.02631.01486-.03134.00047-.00159.00076-.00279.00093-.00362.0001-.00051.00016-.00088.0002-.00112l.00007-.00065zm1-1.2633c0-.42597-.2594-.75221-.5441-.97657-.2904-.22881-.67952-.413-1.11581-.558432-.8778-.2926-2.05866-.464998-3.34009-.464998s-2.46229.172398-3.34009.464998c-.43629.145432-.82543.329622-1.11576.558432-.2847.22436-.54415.5506-.54415.97657v7c0 .41262.2462.7359.52907.9636.28749.2313.678.4205 1.13084.5714.90895.303 2.11003.465 3.34009.465s2.43114-.162 3.34009-.465c.45283-.1509.84331-.3401 1.13081-.5714.2829-.2277.5291-.55098.5291-.9636zm-1 4.76299c-.19854.10174-.42052.19222-.65991.27201-.90895.30299-2.11003.465-3.34009.465s-2.43114-.16201-3.34009-.465c-.23939-.07979-.46137-.17027-.65991-.27201v2.23701l.00008.00078c.0001.00065.00037.00219.00113.00474.0015.00503.00535.01547.01486.03134.01989.03316.06113.08421.13994.14763.16054.12919.42904.27147.82012.40181.77887.2596 1.86698.4137 3.02387.4137s2.245-.1541 3.02387-.4137c.39108-.13034.65958-.27262.82012-.40181.07881-.06342.12005-.11447.13994-.14763.00951-.01587.01336-.02631.01486-.03134.00076-.00255.00103-.00409.00113-.00474l.00007-.00065z" fill="#000" fill-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/core/modules/navigation/components/title/assets/file.svg b/core/modules/navigation/components/title/assets/file.svg
new file mode 100644
index 000000000000..655ae03b145c
--- /dev/null
+++ b/core/modules/navigation/components/title/assets/file.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#000000" viewBox="0 0 256 256"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Z"></path></svg>
diff --git a/core/modules/navigation/components/title/title.component.yml b/core/modules/navigation/components/title/title.component.yml
new file mode 100644
index 000000000000..696960d455f5
--- /dev/null
+++ b/core/modules/navigation/components/title/title.component.yml
@@ -0,0 +1,54 @@
+# This is so your IDE knows about the syntax for fixes and autocomplete.
+$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
+
+# The human readable name.
+name: Title
+
+# Status can be: "experimental", "stable", "deprecated", "obsolete".
+status: experimental
+
+# Schema for the props. We support www.json-schema.org. Learn more about the
+# syntax there.
+props:
+  type: object
+  properties:
+    modifiers:
+      type: array
+      title: Modifier classes.
+      description:
+        Title modifiers.
+        https://en.bem.info/methodology/css/#modifiers
+      items:
+        type: string
+        enum:
+          - ellipsis
+          - xs
+    extra_classes:
+      type: array
+      title: Extra classes.
+      description:
+        External modifiers added from the placement context.
+        https://en.bem.info/methodology/css/#mixes
+      items:
+        type: string
+    html_tag:
+      type: string
+      title: HTML tag for title
+      # Limit the available options by using enums.
+      enum:
+        - h1
+        - h2
+        - h3
+        - h4
+        - h5
+        - h6
+        - span
+      # Provide a default value
+      default: h2
+    icon:
+      title: Icon
+      type: string
+slots:
+  content:
+    title: Content
+    description: Content of title.
diff --git a/core/modules/navigation/components/title/title.css b/core/modules/navigation/components/title/title.css
new file mode 100644
index 000000000000..ac78c925f14a
--- /dev/null
+++ b/core/modules/navigation/components/title/title.css
@@ -0,0 +1,52 @@
+/*
+ * DO NOT EDIT THIS FILE.
+ * See the following change record for more information,
+ * https://www.drupal.org/node/3084859
+ * @preserve
+ */
+/* cspell:ignore csvg cpath wght */
+/**
+ * @file
+ * Toolbar title styles.
+ */
+.toolbar-title {
+  font-variation-settings: "wght" 500;
+}
+/* Sizes aligned with variables from css/base/variables.pcss.css */
+.toolbar-title--xs {
+  font-size: var(--admin-toolbar-font-size-heading-xs);
+}
+.toolbar-title--ellipsis .toolbar-title__label {
+  overflow: hidden;
+  max-width: var(--toolbar--title-max-width);
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+/* Class starts with `toolbar-title--icon`  */
+[class*="toolbar-title--icon"] {
+  display: flex;
+  align-items: center;
+  gap: var(--admin-toolbar-space-8);
+}
+[class*="toolbar-title--icon"]::before {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  margin-top: 1px;
+  content: "";
+  color: currentColor;
+  background-color: currentColor;
+  inline-size: var(--admin-toolbar-space-16);
+  block-size: var(--admin-toolbar-space-16);
+  mask-repeat: no-repeat;
+  mask-position: center center;
+  mask-size: 100% auto;
+  mask-image: var(--icon);
+}
+.toolbar-title--icon--file {
+  --icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' fill='%23000000' viewBox='0 0 256 256'%3e%3cpath d='M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Z'%3e%3c/path%3e%3c/svg%3e");
+}
+.toolbar-title--icon--database {
+  --icon: url("data:image/svg+xml,%3csvg fill='none' height='12' viewBox='0 0 12 12' width='12' xmlns='http://www.w3.org/2000/svg'%3e%3cpath clip-rule='evenodd' d='m2 2.5c0 .00062.00009.00292.00153.00765.00169.00557.00588.01658.01601.03307.02122.03457.0645.08654.14558.15044.16509.13011.43496.26914.81301.39516.75088.25029 1.82002.41368 3.02387.41368 1.20386 0 2.27299-.16339 3.02387-.41368.37805-.12602.64792-.26505.81301-.39516.08108-.0639.12436-.11587.14558-.15044.01013-.01649.01431-.0275.01601-.03307.00146-.00479.00153-.00695.00153-.00754 0-.00003 0 .00002 0 0 0-.00036 0-.00258-.00153-.00776-.0017-.00557-.00588-.01658-.01601-.03307-.02122-.03457-.0645-.08654-.14558-.15044-.16509-.13011-.43496-.26914-.81301-.39516-.75088-.25029-1.82001-.41368-3.02387-.41368-1.20385 0-2.27299.16339-3.02387.41368-.37805.12602-.64792.26505-.81301.39516-.08108.0639-.12436.11587-.14558.15044-.01013.01649-.01432.0275-.01601.03307-.00154.00504-.00153.00721-.00153.00765zm8 1.2633c-.20061.10259-.42333.19284-.65991.2717-.8778.2926-2.05866.465-3.34009.465s-2.46229-.1724-3.34009-.465c-.23658-.07886-.4593-.16911-.65991-.2717v2.2367l.00008.00078c.0001.00065.00037.00219.00113.00474.0015.00503.00535.01547.01486.03134.01989.03316.06113.08421.13994.14763.16054.12919.42904.27147.82012.40183.77887.25962 1.86698.41368 3.02387.41368s2.245-.15406 3.02387-.41368c.39108-.13036.65958-.27264.82012-.40183.07881-.06342.12005-.11447.13994-.14763.00951-.01587.01336-.02631.01486-.03134.00047-.00159.00076-.00279.00093-.00362.0001-.00051.00016-.00088.0002-.00112l.00007-.00065zm1-1.2633c0-.42597-.2594-.75221-.5441-.97657-.2904-.22881-.67952-.413-1.11581-.558432-.8778-.2926-2.05866-.464998-3.34009-.464998s-2.46229.172398-3.34009.464998c-.43629.145432-.82543.329622-1.11576.558432-.2847.22436-.54415.5506-.54415.97657v7c0 .41262.2462.7359.52907.9636.28749.2313.678.4205 1.13084.5714.90895.303 2.11003.465 3.34009.465s2.43114-.162 3.34009-.465c.45283-.1509.84331-.3401 1.13081-.5714.2829-.2277.5291-.55098.5291-.9636zm-1 4.76299c-.19854.10174-.42052.19222-.65991.27201-.90895.30299-2.11003.465-3.34009.465s-2.43114-.16201-3.34009-.465c-.23939-.07979-.46137-.17027-.65991-.27201v2.23701l.00008.00078c.0001.00065.00037.00219.00113.00474.0015.00503.00535.01547.01486.03134.01989.03316.06113.08421.13994.14763.16054.12919.42904.27147.82012.40181.77887.2596 1.86698.4137 3.02387.4137s2.245-.1541 3.02387-.4137c.39108-.13034.65958-.27262.82012-.40181.07881-.06342.12005-.11447.13994-.14763.00951-.01587.01336-.02631.01486-.03134.00076-.00255.00103-.00409.00113-.00474l.00007-.00065z' fill='%23000' fill-rule='evenodd'/%3e%3c/svg%3e");
+}
diff --git a/core/modules/navigation/components/title/title.pcss.css b/core/modules/navigation/components/title/title.pcss.css
new file mode 100644
index 000000000000..52b098fb0cca
--- /dev/null
+++ b/core/modules/navigation/components/title/title.pcss.css
@@ -0,0 +1,54 @@
+/* cspell:ignore csvg cpath wght */
+/**
+ * @file
+ * Toolbar title styles.
+ */
+
+.toolbar-title {
+  font-variation-settings: "wght" 500;
+}
+
+/* Sizes aligned with variables from css/base/variables.pcss.css */
+.toolbar-title--xs {
+  font-size: var(--admin-toolbar-font-size-heading-xs);
+}
+
+.toolbar-title--ellipsis {
+  .toolbar-title__label {
+    overflow: hidden;
+    max-width: var(--toolbar--title-max-width);
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+}
+
+/* Class starts with `toolbar-title--icon`  */
+[class*="toolbar-title--icon"] {
+  display: flex;
+  align-items: center;
+  gap: var(--admin-toolbar-space-8);
+
+  &::before {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    margin-top: 1px;
+    content: "";
+    color: currentColor;
+    background-color: currentColor;
+    inline-size: var(--admin-toolbar-space-16);
+    block-size: var(--admin-toolbar-space-16);
+    mask-repeat: no-repeat;
+    mask-position: center center;
+    mask-size: 100% auto;
+    mask-image: var(--icon);
+  }
+}
+
+.toolbar-title--icon--file {
+  --icon: url(./assets/file.svg);
+}
+.toolbar-title--icon--database {
+  --icon: url(./assets/database.svg);
+}
diff --git a/core/modules/navigation/components/title/title.twig b/core/modules/navigation/components/title/title.twig
new file mode 100644
index 000000000000..169ebf2fddd1
--- /dev/null
+++ b/core/modules/navigation/components/title/title.twig
@@ -0,0 +1,21 @@
+{%
+  set classes = [
+    'toolbar-title',
+    icon ? 'toolbar-title--icon--' ~ icon : '',
+  ]
+%}
+{% if modifiers is iterable %}
+  {% set classes = classes|merge(modifiers|map(modifier => "toolbar-title--#{modifier}")) %}
+{% endif %}
+
+{% if extra_classes is iterable %}
+  {% set classes = classes|merge(extra_classes) %}
+{% endif %}
+
+<{{html_tag|default('h2')}}{{attributes.addClass(classes)}}>
+  <span class="toolbar-title__label">
+    {% block content %}
+      {{ content }}
+    {% endblock %}
+  </span>
+</{{html_tag|default('h2')}}>
diff --git a/core/modules/navigation/components/toolbar-button/toolbar-button.css b/core/modules/navigation/components/toolbar-button/toolbar-button.css
index 439287b8b85d..3ee7973abef1 100644
--- a/core/modules/navigation/components/toolbar-button/toolbar-button.css
+++ b/core/modules/navigation/components/toolbar-button/toolbar-button.css
@@ -27,9 +27,8 @@
 }
 .toolbar-button {
   z-index: 1;
+  flex-grow: 0;
   align-items: center;
-  padding-inline: var(--admin-toolbar-space-16);
-  padding-block: var(--admin-toolbar-space-10);
   min-height: var(--admin-toolbar-space-40);
   cursor: pointer;
   text-align: start;
@@ -41,8 +40,10 @@
   border-radius: var(--admin-toolbar-space-8);
   background-color: var(--toolbar-button-bg);
   font-size: var(--admin-toolbar-font-size-info-sm);
-  font-variation-settings: "wght" 700;
   line-height: var(--admin-toolbar-line-height-info-sm);
+  padding-inline: var(--admin-toolbar-space-16);
+  padding-block: var(--admin-toolbar-space-10);
+  font-variation-settings: "wght" 700;
   gap: calc(0.5 * var(--admin-toolbar-rem));
 }
 .toolbar-button:has(+ .toolbar-popover__wrapper .is-active) {
diff --git a/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css b/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css
index 26726918ad24..8adb302c6d58 100644
--- a/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css
+++ b/core/modules/navigation/components/toolbar-button/toolbar-button.pcss.css
@@ -25,9 +25,8 @@
 
 .toolbar-button {
   z-index: 1;
+  flex-grow: 0;
   align-items: center;
-  padding-inline: var(--admin-toolbar-space-16);
-  padding-block: var(--admin-toolbar-space-10);
   min-height: var(--admin-toolbar-space-40);
   cursor: pointer;
   text-align: start;
@@ -38,8 +37,10 @@
   border-radius: var(--admin-toolbar-space-8);
   background-color: var(--toolbar-button-bg);
   font-size: var(--admin-toolbar-font-size-info-sm);
-  font-variation-settings: "wght" 700;
   line-height: var(--admin-toolbar-line-height-info-sm);
+  padding-inline: var(--admin-toolbar-space-16);
+  padding-block: var(--admin-toolbar-space-10);
+  font-variation-settings: "wght" 700;
   gap: calc(0.5 * var(--admin-toolbar-rem));
 
   &:has(+ .toolbar-popover__wrapper .is-active) {
diff --git a/core/modules/navigation/css/components/top-bar.css b/core/modules/navigation/css/components/top-bar.css
index 9e0eb2f928bf..65a489cd78b7 100644
--- a/core/modules/navigation/css/components/top-bar.css
+++ b/core/modules/navigation/css/components/top-bar.css
@@ -46,6 +46,7 @@
 }
 .top-bar__actions {
   display: flex;
+  justify-content: end;
   gap: 0.5rem;
 }
 @media (min-width: 64rem) {
@@ -56,9 +57,9 @@
 }
 .top-bar__content {
   display: grid;
-  grid-auto-flow: column;
+  grid-template-columns: 1fr auto 1fr;
   align-items: center;
-  justify-content: space-between;
+  justify-content: center;
   gap: var(--admin-toolbar-space-16);
   width: 100%;
 }
@@ -73,9 +74,13 @@
 }
 .top-bar__context {
   display: flex;
-  gap: 0.5rem;
+  flex-wrap: wrap;
   align-items: center;
-  justify-content: start;
+  justify-content: center;
+  gap: var(--admin-toolbar-space-20);
+}
+.top-bar__title {
+  --toolbar--title-max-width: 40ch;
 }
 .top-bar__tools {
   display: flex;
diff --git a/core/modules/navigation/css/components/top-bar.pcss.css b/core/modules/navigation/css/components/top-bar.pcss.css
index 3a1c5cfb254e..b55604dfa569 100644
--- a/core/modules/navigation/css/components/top-bar.pcss.css
+++ b/core/modules/navigation/css/components/top-bar.pcss.css
@@ -45,6 +45,7 @@
 
 .top-bar__actions {
   display: flex;
+  justify-content: end;
   gap: 0.5rem;
 
   @media (--admin-toolbar-desktop) {
@@ -55,9 +56,9 @@
 
 .top-bar__content {
   display: grid;
-  grid-auto-flow: column;
+  grid-template-columns: 1fr auto 1fr;
   align-items: center;
-  justify-content: space-between;
+  justify-content: center;
   gap: var(--admin-toolbar-space-16);
   width: 100%;
 }
@@ -73,9 +74,14 @@
 
 .top-bar__context {
   display: flex;
-  gap: 0.5rem;
+  flex-wrap: wrap;
   align-items: center;
-  justify-content: start;
+  justify-content: center;
+  gap: var(--admin-toolbar-space-20);
+}
+
+.top-bar__title {
+  --toolbar--title-max-width: 40ch;
 }
 
 .top-bar__tools {
diff --git a/core/modules/navigation/navigation.services.yml b/core/modules/navigation/navigation.services.yml
index 871b4b6adba7..4a6b77128eec 100644
--- a/core/modules/navigation/navigation.services.yml
+++ b/core/modules/navigation/navigation.services.yml
@@ -9,7 +9,6 @@ services:
         '@module_handler',
         '@current_route_match',
         '@plugin.manager.menu.local_task',
-        '@entity_type.manager',
         '@image.factory',
         '@file_url_generator',
         '@plugin.manager.layout_builder.section_storage',
@@ -17,6 +16,7 @@ services:
         '@extension.list.module',
         '@current_user',
         '%renderer.config%',
+        '@navigation.entity_route_helper',
       ]
   Drupal\navigation\NavigationRenderer: '@navigation.renderer'
 
@@ -31,6 +31,16 @@ services:
         '@callable_resolver',
       ]
 
+  navigation.entity_route_helper:
+    class: Drupal\navigation\EntityRouteHelper
+    arguments:
+      [
+        '@current_route_match',
+        '@entity_type.manager',
+        '@cache.discovery',
+      ]
+  Drupal\navigation\EntityRouteHelper: '@navigation.entity_route_helper'
+
   navigation.user_lazy_builder:
     class: Drupal\navigation\UserLazyBuilder
     arguments: ['@current_user']
diff --git a/core/modules/navigation/src/EntityRouteHelper.php b/core/modules/navigation/src/EntityRouteHelper.php
new file mode 100644
index 000000000000..104e08b7fcf5
--- /dev/null
+++ b/core/modules/navigation/src/EntityRouteHelper.php
@@ -0,0 +1,141 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\navigation;
+
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Routing\CurrentRouteMatch;
+
+/**
+ * Helper service to detect entity routes.
+ *
+ * @internal
+ */
+class EntityRouteHelper {
+
+  const ENTITY_ROUTE_CID = 'navigation_content_entity_paths';
+
+  /**
+   * A list of all the link paths of enabled content entities.
+   *
+   * @var array
+   */
+  protected array $contentEntityPaths;
+
+  /**
+   * EntityRouteHelper constructor.
+   *
+   * @param \Drupal\Core\Routing\CurrentRouteMatch $routeMatch
+   *   The route match.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   The entity type manager.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
+   *   The cache backend.
+   */
+  public function __construct(
+    protected CurrentRouteMatch $routeMatch,
+    protected EntityTypeManagerInterface $entityTypeManager,
+    protected CacheBackendInterface $cacheBackend,
+  ) {
+  }
+
+  /**
+   * Determines if content entity route condition is met.
+   *
+   * @return bool
+   *   TRUE if the content entity route condition is met, FALSE otherwise.
+   */
+  public function isContentEntityRoute(): bool {
+    return array_key_exists($this->routeMatch->getRouteObject()->getPath(), $this->getContentEntityPaths());
+  }
+
+  public function getContentEntityFromRoute(): ?ContentEntityInterface {
+    $path = $this->routeMatch->getRouteObject()->getPath();
+    if (!$entity_type = $this->getContentEntityPaths()[$path] ?? NULL) {
+      return NULL;
+    }
+
+    $entity = $this->routeMatch->getParameter($entity_type);
+    if ($entity instanceof ContentEntityInterface && $entity->getEntityTypeId() === $entity_type) {
+      return $entity;
+    }
+
+    return NULL;
+  }
+
+  /**
+   * Returns the paths for the link templates of all content entities.
+   *
+   * @return array
+   *   An array of all content entity type IDs, keyed by the corresponding link
+   *   template paths.
+   */
+  protected function getContentEntityPaths(): array {
+    if (isset($this->contentEntityPaths)) {
+      return $this->contentEntityPaths;
+    }
+
+    $content_entity_paths = $this->cacheBackend->get(static::ENTITY_ROUTE_CID);
+
+    if (isset($content_entity_paths->data)) {
+      $this->contentEntityPaths = $content_entity_paths->data;
+      return $this->contentEntityPaths;
+    }
+
+    $this->contentEntityPaths = $this->doGetContentEntityPaths();
+    $this->cacheBackend->set(static::ENTITY_ROUTE_CID, $this->contentEntityPaths, CacheBackendInterface::CACHE_PERMANENT, ['entity_types', 'routes']);
+
+    return $this->contentEntityPaths;
+  }
+
+  protected function doGetContentEntityPaths(): array {
+    $content_entity_paths = [];
+    $entity_types = $this->entityTypeManager->getDefinitions();
+    foreach ($entity_types as $entity_type) {
+      if ($entity_type->entityClassImplements(ContentEntityInterface::class)) {
+        $entity_paths = $this->getContentEntityTypePaths($entity_type);
+        $content_entity_paths = array_merge($content_entity_paths, $entity_paths);
+      }
+    }
+
+    return $content_entity_paths;
+  }
+
+  /**
+   * Returns the path for the link template for a given content entity type.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   *
+   * @return array
+   *   Array containing the paths for the given content entity type.
+   */
+  protected function getContentEntityTypePaths(EntityTypeInterface $entity_type): array {
+    $paths = array_filter($entity_type->getLinkTemplates(), fn ($template) => $template !== 'collection', ARRAY_FILTER_USE_KEY);
+    if ($this->isLayoutBuilderEntityType($entity_type)) {
+      $paths[] = $entity_type->getLinkTemplate('canonical') . '/layout';
+    }
+    return array_fill_keys($paths, $entity_type->id());
+  }
+
+  /**
+   * Determines if a given entity type is layout builder relevant or not.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type.
+   *
+   * @return bool
+   *   Whether this entity type is a Layout builder candidate or not
+   *
+   * @see \Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage::getEntityTypes()
+   */
+  protected function isLayoutBuilderEntityType(EntityTypeInterface $entity_type): bool {
+    return $entity_type->entityClassImplements(FieldableEntityInterface::class) && $entity_type->hasHandlerClass('form', 'layout_builder') && $entity_type->hasViewBuilderClass() && $entity_type->hasLinkTemplate('canonical');
+  }
+
+}
diff --git a/core/modules/navigation/src/NavigationRenderer.php b/core/modules/navigation/src/NavigationRenderer.php
index 5c0f40b8a72e..753f53bb937e 100644
--- a/core/modules/navigation/src/NavigationRenderer.php
+++ b/core/modules/navigation/src/NavigationRenderer.php
@@ -5,13 +5,9 @@
 use Drupal\Component\Utility\NestedArray;
 use Drupal\Component\Utility\SortArray;
 use Drupal\Core\Block\BlockPluginInterface;
-use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\Entity\ContentEntityInterface;
-use Drupal\Core\Entity\EntityTypeInterface;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\Entity\FieldableEntityInterface;
 use Drupal\Core\Extension\ModuleExtensionList;
 use Drupal\Core\Extension\ModuleHandlerInterface;
 use Drupal\Core\File\FileUrlGeneratorInterface;
@@ -48,13 +44,6 @@ final class NavigationRenderer {
    */
   const LOGO_PROVIDER_CUSTOM = 'custom';
 
-  /**
-   * A list of all the link paths of enabled content entities.
-   *
-   * @var array
-   */
-  protected array $contentEntityPaths;
-
   /**
    * The navigation local tasks render array.
    *
@@ -70,7 +59,6 @@ public function __construct(
     private ModuleHandlerInterface $moduleHandler,
     private RouteMatchInterface $routeMatch,
     private LocalTaskManagerInterface $localTaskManager,
-    private EntityTypeManagerInterface $entityTypeManager,
     private ImageFactory $imageFactory,
     private FileUrlGeneratorInterface $fileUrlGenerator,
     private SectionStorageManagerInterface $sectionStorageManager,
@@ -78,6 +66,7 @@ public function __construct(
     private ModuleExtensionList $moduleExtensionList,
     private AccountInterface $currentUser,
     private array $rendererConfig,
+    private EntityRouteHelper $entityRouteHelper,
   ) {}
 
   /**
@@ -271,7 +260,7 @@ public function getLocalTasks(): array {
     ];
     // For now, we're only interested in local tasks corresponding to a content
     // entity.
-    if (!$this->meetsContentEntityRoutesCondition()) {
+    if (!$this->entityRouteHelper->isContentEntityRoute()) {
       return $this->localTasks;
     }
     $entity_local_tasks = $this->localTaskManager->getLocalTasks($this->routeMatch->getRouteName());
@@ -315,70 +304,4 @@ public function hasLocalTasks(): bool {
     return !empty($local_tasks['tasks']);
   }
 
-  /**
-   * Determines if content entity route condition is met.
-   *
-   * @return bool
-   *   TRUE if the content entity route condition is met, FALSE otherwise.
-   */
-  protected function meetsContentEntityRoutesCondition(): bool {
-    return array_key_exists($this->routeMatch->getRouteObject()->getPath(), $this->getContentEntityPaths());
-  }
-
-  /**
-   * Returns the paths for the link templates of all content entities.
-   *
-   * @return array
-   *   An array of all content entity type IDs, keyed by the corresponding link
-   *   template paths.
-   */
-  protected function getContentEntityPaths(): array {
-    if (isset($this->contentEntityPaths)) {
-      return $this->contentEntityPaths;
-    }
-
-    $this->contentEntityPaths = [];
-    $entity_types = $this->entityTypeManager->getDefinitions();
-    foreach ($entity_types as $entity_type) {
-      if ($entity_type->entityClassImplements(ContentEntityInterface::class)) {
-        $entity_paths = $this->getContentEntityTypePaths($entity_type);
-        $this->contentEntityPaths = array_merge($this->contentEntityPaths, $entity_paths);
-      }
-    }
-
-    return $this->contentEntityPaths;
-  }
-
-  /**
-   * Returns the path for the link template for a given content entity type.
-   *
-   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
-   *   The entity type definition.
-   *
-   * @return array
-   *   Array containing the paths for the given content entity type.
-   */
-  protected function getContentEntityTypePaths(EntityTypeInterface $entity_type): array {
-    $paths = array_filter($entity_type->getLinkTemplates(), fn ($template) => $template !== 'collection', ARRAY_FILTER_USE_KEY);
-    if ($this->isLayoutBuilderEntityType($entity_type)) {
-      $paths[] = $entity_type->getLinkTemplate('canonical') . '/layout';
-    }
-    return array_fill_keys($paths, $entity_type->id());
-  }
-
-  /**
-   * Determines if a given entity type is layout builder relevant or not.
-   *
-   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
-   *   The entity type.
-   *
-   * @return bool
-   *   Whether this entity type is a Layout builder candidate or not
-   *
-   * @see \Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage::getEntityTypes()
-   */
-  protected function isLayoutBuilderEntityType(EntityTypeInterface $entity_type): bool {
-    return $entity_type->entityClassImplements(FieldableEntityInterface::class) && $entity_type->hasHandlerClass('form', 'layout_builder') && $entity_type->hasViewBuilderClass() && $entity_type->hasLinkTemplate('canonical');
-  }
-
 }
diff --git a/core/modules/navigation/src/Plugin/TopBarItem/PageContext.php b/core/modules/navigation/src/Plugin/TopBarItem/PageContext.php
new file mode 100644
index 000000000000..47cad358b4a9
--- /dev/null
+++ b/core/modules/navigation/src/Plugin/TopBarItem/PageContext.php
@@ -0,0 +1,147 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\navigation\Plugin\TopBarItem;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityPublishedInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\navigation\Attribute\TopBarItem;
+use Drupal\navigation\EntityRouteHelper;
+use Drupal\navigation\TopBarItemBase;
+use Drupal\navigation\TopBarRegion;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides the Page Context top bar item.
+ */
+#[TopBarItem(
+  id: 'page_context',
+  region: TopBarRegion::Context,
+  label: new TranslatableMarkup('Page Context'),
+)]
+class PageContext extends TopBarItemBase implements ContainerFactoryPluginInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Constructs a new PageContext instance.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin ID for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   The entity type manager service.
+   * @param \Drupal\navigation\EntityRouteHelper $entityRouteHelper
+   *   The entity route helper service.
+   */
+  public function __construct(
+    array $configuration,
+    $plugin_id,
+    $plugin_definition,
+    private EntityTypeManagerInterface $entityTypeManager,
+    private EntityRouteHelper $entityRouteHelper,
+  ) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    return new static(
+    $configuration,
+    $plugin_id,
+    $plugin_definition,
+    $container->get(EntityTypeManagerInterface::class),
+    $container->get(EntityRouteHelper::class)
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build(): array {
+    $build = [
+      '#cache' => [
+        'contexts' => ['route'],
+      ],
+    ];
+
+    if (!$entity = $this->entityRouteHelper->getContentEntityFromRoute()) {
+      return $build;
+    }
+
+    $build += [
+      [
+        '#type' => 'component',
+        '#component' => 'navigation:title',
+        '#props' => [
+          'icon' => 'database',
+          'html_tag' => 'span',
+          'modifiers' => ['ellipsis', 'xs'],
+          'extra_classes' => ['top-bar__title'],
+        ],
+        '#slots' => [
+          'content' => $entity->label(),
+        ],
+      ],
+    ];
+
+    if ($label = $this->getBadgeLabel($entity)) {
+      $build += [
+        '#type' => 'component',
+        '#component' => 'navigation:badge',
+        '#props' => [
+          'status' => $this->getBadgeStatus($entity) ?? 'info',
+        ],
+        '#slots' => [
+          'label' => $label,
+        ],
+      ];
+    }
+
+    return $build;
+  }
+
+  /**
+   * Retrieves the badge label for the given entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which the label is being retrieved.
+   *
+   * @return string|null
+   *   The translated status if available. NULL otherwise.
+   *   The status if available. NULL otherwise.
+   */
+  protected function getBadgeLabel(EntityInterface $entity): ?string {
+    if (!$entity instanceof EntityPublishedInterface) {
+      return NULL;
+    }
+    return (string) ($entity->isPublished() ? $this->t('Published') : $this->t('Unpublished'));
+  }
+
+  /**
+   * Retrieves the badge status for the given entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which the status is being retrieved.
+   *
+   * @return string|null
+   *   The badge status if available. NULL otherwise.
+   */
+  protected function getBadgeStatus(EntityInterface $entity): ?string {
+    if (!$entity instanceof EntityPublishedInterface) {
+      return NULL;
+    }
+    return $entity->isPublished() ? 'success' : 'info';
+  }
+
+}
diff --git a/core/modules/navigation/tests/src/Functional/NavigationTopBarPageContextTest.php b/core/modules/navigation/tests/src/Functional/NavigationTopBarPageContextTest.php
new file mode 100644
index 000000000000..800106b1e0f4
--- /dev/null
+++ b/core/modules/navigation/tests/src/Functional/NavigationTopBarPageContextTest.php
@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\navigation\Functional;
+
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\node\Traits\NodeCreationTrait;
+use Drupal\user\UserInterface;
+
+/**
+ * Tests the PageContext top bar item functionality.
+ *
+ * @group navigation
+ */
+class NavigationTopBarPageContextTest extends BrowserTestBase {
+
+  use ContentTypeCreationTrait;
+  use NodeCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'node',
+    'navigation',
+    'navigation_top_bar',
+    'test_page_test',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * An admin user to configure the test environment.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected UserInterface $adminUser;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Create and log in an administrative user.
+    $this->adminUser = $this->drupalCreateUser([
+      'access navigation',
+      'bypass node access',
+    ]);
+    $this->drupalLogin($this->adminUser);
+
+    // Ensure the 'article' content type exists.
+    $this->createContentType(['type' => 'article', 'name' => 'Article']);
+  }
+
+  /**
+   * Tests the PageContext top bar item output for a published node.
+   */
+  public function testPageContextTopBarItemNode(): void {
+    // Create a published node entity.
+    $node = $this->createNode([
+      'type' => 'article',
+      'title' => 'No easy twist on the bow',
+      'status' => 1,
+      'uid' => $this->adminUser->id(),
+    ]);
+
+    $test_page_url = Url::fromRoute('test_page_test.test_page');
+    $this->drupalGet($test_page_url);
+    // Ensure the top bar item is not present.
+    $this->assertSession()->elementNotExists('css', '.top-bar .top-bar__context .toolbar-title');
+
+    // Test the PageContext output for the published node.
+    $this->drupalGet($node->toUrl());
+    // Ensure the top bar exists and is valid.
+    $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-title', 'No easy twist on the bow');
+    $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-badge', 'Published');
+    $this->drupalGet($node->toUrl('edit-form'));
+    // Ensure the top bar exists and is valid.
+    $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-title', 'No easy twist on the bow');
+    $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-badge', 'Published');
+
+    // Unpublish the node.
+    $node->setUnpublished();
+    $node->save();
+
+    // Test the PageContext output for the unpublished node.
+    $this->drupalGet($node->toUrl());
+    // Ensure the top bar exists and is valid.
+    $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-title', 'No easy twist on the bow');
+    $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-badge', 'Unpublished');
+    $this->drupalGet($node->toUrl('edit-form'));
+    // Ensure the top bar exists and is valid.
+    $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-title', 'No easy twist on the bow');
+    $this->assertSession()->elementTextEquals('css', '.top-bar .top-bar__context .toolbar-badge', 'Unpublished');
+  }
+
+}
diff --git a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php
index 7a4d2b68b40b..44d172b1cad9 100644
--- a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php
+++ b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php
@@ -73,11 +73,11 @@ public function testLogin(): void {
 
     $expected = [
       'QueryCount' => 4,
-      'CacheGetCount' => 60,
+      'CacheGetCount' => 61,
       'CacheSetCount' => 2,
       'CacheDeleteCount' => 0,
-      'CacheTagChecksumCount' => 2,
-      'CacheTagIsValidCount' => 29,
+      'CacheTagChecksumCount' => 3,
+      'CacheTagIsValidCount' => 31,
       'CacheTagInvalidationCount' => 0,
       'ScriptCount' => 2,
       'ScriptBytes' => 215500,
-- 
GitLab