Browse Source

Implement collapsible and linkable panels

Co-authored-by: LB Johnston <mail@lb.ee>
Thibaud Colas 2 years ago
parent
commit
3d96e7fbe7
34 changed files with 324 additions and 113 deletions
  1. 5 0
      client/scss/components/_header.scss
  2. 77 0
      client/scss/components/_panel.scss
  3. 28 0
      client/scss/components/forms/_field-panel.scss
  4. 2 0
      client/scss/core.scss
  5. 6 0
      client/scss/generic/_normalize.scss
  6. 1 1
      client/src/components/Sidebar/Sidebar.scss
  7. 0 44
      client/src/entrypoints/admin/collapsible.js
  8. 1 1
      client/src/entrypoints/admin/comments.js
  9. 13 0
      client/src/entrypoints/admin/wagtailadmin.js
  10. 68 0
      client/src/includes/panels.ts
  11. 6 1
      client/src/includes/tabs.js
  12. 4 1
      client/tailwind.config.js
  13. 0 1
      client/webpack.config.js
  14. 1 1
      docs/reference/contrib/modeladmin/primer.md
  15. 6 13
      docs/reference/pages/panels.md
  16. 1 0
      wagtail/admin/templates/wagtailadmin/icons/caret-down.svg
  17. 0 1
      wagtail/admin/templates/wagtailadmin/pages/_editor_js.html
  18. 1 1
      wagtail/admin/templates/wagtailadmin/pages/create.html
  19. 1 1
      wagtail/admin/templates/wagtailadmin/pages/edit.html
  20. 1 1
      wagtail/admin/templates/wagtailadmin/pages/index.html
  21. 1 1
      wagtail/admin/templates/wagtailadmin/pages/page_listing_header.html
  22. 6 3
      wagtail/admin/templates/wagtailadmin/panels/field_panel.html
  23. 6 2
      wagtail/admin/templates/wagtailadmin/panels/multi_field_panel.html
  24. 3 29
      wagtail/admin/templates/wagtailadmin/panels/object_list.html
  25. 2 2
      wagtail/admin/templates/wagtailadmin/shared/breadcrumbs.html
  26. 1 1
      wagtail/admin/templates/wagtailadmin/shared/headers/page_create_header.html
  27. 1 1
      wagtail/admin/templates/wagtailadmin/shared/headers/page_edit_header.html
  28. 3 2
      wagtail/admin/templates/wagtailadmin/shared/headers/slim_header.html
  29. 66 0
      wagtail/admin/templates/wagtailadmin/shared/panel.html
  30. 8 1
      wagtail/admin/templatetags/wagtailadmin_tags.py
  31. 1 0
      wagtail/admin/wagtail_hooks.py
  32. 2 2
      wagtail/snippets/templates/wagtailsnippets/snippets/headers/_base_header.html
  33. 1 1
      wagtail/snippets/templates/wagtailsnippets/snippets/headers/create_header.html
  34. 1 1
      wagtail/snippets/templates/wagtailsnippets/snippets/headers/edit_header.html

+ 5 - 0
client/scss/components/_header.scss

@@ -217,3 +217,8 @@
     }
   }
 }
+
+// For pages with the slim header, make sure the header is accounted for when scrolling to an anchor.
+.page-slim-header {
+  scroll-padding-top: calc(theme('spacing.slim-header') + 1rem);
+}

+ 77 - 0
client/scss/components/_panel.scss

@@ -0,0 +1,77 @@
+.w-panel {
+  margin-bottom: theme('spacing.10');
+}
+
+.w-panel__header {
+  display: flex;
+  align-items: center;
+  gap: theme('spacing.2');
+  margin-bottom: theme('spacing.5');
+  margin-left: calc(-1 * 2 * (theme('spacing.6') + theme('spacing.2')));
+}
+
+.w-panel__heading {
+  @apply w-h3;
+  display: inline-block;
+  margin: 0;
+}
+
+.w-panel__heading--label {
+  @apply w-label-1;
+}
+
+.w-panel__anchor,
+.w-panel__toggle {
+  display: inline-grid;
+  justify-content: center;
+  align-content: center;
+  color: theme('colors.primary.DEFAULT');
+  padding: 0;
+  width: theme('spacing.6');
+  height: theme('spacing.6');
+}
+
+.w-panel__anchor {
+  visibility: hidden;
+
+  .w-panel__header:hover &,
+  .w-panel__header:focus-within & {
+    visibility: visible;
+  }
+}
+
+.w-panel__toggle {
+  appearance: none;
+  background: transparent;
+}
+
+.w-panel__icon {
+  width: theme('spacing.5');
+  height: theme('spacing.5');
+
+  [aria-expanded='false'] & {
+    transform: rotate(-90deg);
+  }
+
+  &.icon-link {
+    width: theme('spacing.[3.5]');
+    height: theme('spacing.[3.5]');
+  }
+}
+
+.w-panel__icon--custom {
+  // Only display the default icon when closed.
+  [aria-expanded='false'] & {
+    display: none;
+  }
+
+  // Hide the default icon when expanded.
+  [aria-expanded='true'] & + .w-panel__icon {
+    display: none;
+  }
+}
+
+// TODO Forms
+.w-panel--row .w-panel__content {
+  @apply w-grid lg:w-grid-flow-col lg:w-grid-cols-3;
+}

+ 28 - 0
client/scss/components/forms/_field-panel.scss

@@ -0,0 +1,28 @@
+.w-field {
+  position: relative;
+}
+
+.w-field__errors {
+  @apply w-label-2;
+  color: theme('colors.critical.200');
+  margin-bottom: theme('spacing.[2.5]');
+}
+
+.w-field__errors-icon {
+  width: 1em;
+  height: 1em;
+  position: relative;
+  top: 0.125em;
+  margin-inline-end: 0.375em;
+}
+
+.w-field__label {
+  @apply w-label-3;
+  display: block;
+  margin-bottom: theme('spacing.3');
+}
+
+// Only add space in-between fields when there is more than one.
+.w-field__wrapper + .w-field__wrapper {
+  margin-top: theme('spacing.5');
+}

+ 2 - 0
client/scss/core.scss

@@ -120,7 +120,9 @@ These are classes for components.
 @import 'components/forms/file';
 @import 'components/forms/switch';
 @import 'components/forms/title';
+@import 'components/forms/field-panel';
 @import 'components/tabs';
+@import 'components/panel';
 @import 'components/dialog';
 @import 'components/dropdown';
 @import 'components/dropdown.legacy';

+ 6 - 0
client/scss/generic/_normalize.scss

@@ -56,6 +56,12 @@ audio:not([controls]) {
   display: none !important;
 }
 
+// See https://github.com/necolas/normalize.css/pull/879.
+[hidden='until-found'] {
+  /* stylelint-disable-next-line declaration-no-important */
+  display: revert !important;
+}
+
 /* ==========================================================================
    Base
    ========================================================================== */

+ 1 - 1
client/src/components/Sidebar/Sidebar.scss

@@ -93,7 +93,7 @@
   display: none; // Nav toggle is for mobile only
 
   &--mobile {
-    @apply w-bg-primary w-top-0 w-left-0 w-h-[50px] w-w-[50px] w-rounded-none hover:w-bg-primary-200;
+    @apply w-bg-primary w-top-0 w-left-0 w-h-slim-header w-w-slim-header w-rounded-none hover:w-bg-primary-200;
     display: grid;
   }
 

+ 0 - 44
client/src/entrypoints/admin/collapsible.js

@@ -1,44 +0,0 @@
-import $ from 'jquery';
-
-function initCollapsibleBlocks() {
-  // eslint-disable-next-line func-names
-  $('.object.collapsible').each(function () {
-    const $target = $(this);
-    const $content = $target.find('.object-layout');
-    const onAnimationComplete = () =>
-      $content
-        .get(0)
-        .dispatchEvent(
-          new CustomEvent('commentAnchorVisibilityChange', { bubbles: true }),
-        );
-    if (
-      $target.hasClass('collapsed') &&
-      $target.find('.error-message').length === 0
-    ) {
-      $content.hide({
-        complete: onAnimationComplete,
-      });
-    }
-
-    $target.find('> .title-wrapper').on('click', () => {
-      if (!$target.hasClass('collapsed')) {
-        $target.addClass('collapsed');
-        $content.hide({
-          duration: 'slow',
-          complete: onAnimationComplete,
-        });
-      } else {
-        $target.removeClass('collapsed');
-        $content.show({
-          duration: 'slow',
-          complete: onAnimationComplete,
-        });
-      }
-    });
-  });
-}
-window.initCollapsibleBlocks = initCollapsibleBlocks;
-
-$(() => {
-  initCollapsibleBlocks();
-});

+ 1 - 1
client/src/entrypoints/admin/comments.js

@@ -345,7 +345,7 @@ window.comments = (() => {
     commentCounter.className =
       '-w-mr-3 w-py-0.5 w-px-[0.325rem] w-translate-y-[-8px] w-translate-x-[-4px] w-text-[0.5625rem] w-font-bold w-bg-secondary-100 w-text-white w-border w-border-white w-rounded-[1rem]';
     commentToggle.className =
-      'w-h-[50px] w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary';
+      'w-h-slim-header w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary';
     commentToggle.appendChild(commentCounter);
 
     const updateCommentCount = () => {

+ 13 - 0
client/src/entrypoints/admin/wagtailadmin.js

@@ -6,6 +6,10 @@ import { initTabs } from '../../includes/tabs';
 import { dialog } from '../../includes/dialog';
 import initCollapsibleBreadcrumbs from '../../includes/breadcrumbs';
 import initSidePanel from '../../includes/sidePanel';
+import {
+  initAnchoredPanels,
+  initCollapsiblePanels,
+} from '../../includes/panels';
 
 if (process.env.NODE_ENV === 'development') {
   // Run react-axe in development only, so it does not affect performance
@@ -33,4 +37,13 @@ document.addEventListener('DOMContentLoaded', () => {
   dialog();
   initCollapsibleBreadcrumbs();
   initSidePanel();
+  initCollapsiblePanels();
+});
+
+/**
+ * Prefer the document’s DOMContentLoaded if possible.
+ * window `load` only fires once the page’s resources are loaded.
+ */
+window.addEventListener('load', () => {
+  initAnchoredPanels();
 });

+ 68 - 0
client/src/includes/panels.ts

@@ -0,0 +1,68 @@
+/**
+ * Make panels collapsible, and collapse panels already marked as `collapsed`.
+ */
+export function initCollapsiblePanels() {
+  const toggles = document.querySelectorAll<HTMLButtonElement>(
+    '[data-panel-toggle]',
+  );
+
+  toggles.forEach((toggle) => {
+    const panel = toggle.closest<HTMLElement>('[data-panel]');
+    const content = document.querySelector<HTMLDivElement>(
+      `#${toggle.getAttribute('aria-controls')}`,
+    );
+
+    if (!content || !panel) {
+      return;
+    }
+
+    const onAnimationComplete = () => {
+      content.dispatchEvent(
+        new CustomEvent('commentAnchorVisibilityChange', { bubbles: true }),
+      );
+    };
+
+    const hasCollapsed = panel.classList.contains('collapsed');
+    const hasError = content.querySelector('[aria-invalid="true"]');
+
+    // Collapse panels marked as `collapsed`, unless they contain invalid fields.
+    if (hasCollapsed && !hasError) {
+      // Use experimental `until-found` value.
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      content.hidden = 'until-found';
+      toggle.setAttribute('aria-expanded', 'false');
+      onAnimationComplete();
+    }
+
+    toggle.addEventListener('click', () => {
+      const wasExpanded = toggle.getAttribute('aria-expanded') === 'true';
+      const isExpanded = !wasExpanded;
+      // Use experimental `until-found` value, so users can search inside the panels.
+      // Browsers without support for `until-found` will ignore the value.
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      content.hidden = !isExpanded ? 'until-found' : '';
+      onAnimationComplete();
+
+      toggle.setAttribute('aria-expanded', `${isExpanded}`);
+    });
+
+    // Set the toggle back to expanded upon reveal.
+    content.addEventListener('beforematch', () => {
+      toggle.setAttribute('aria-expanded', 'true');
+    });
+  });
+}
+
+/**
+ * Smooth scroll onto any active panel.
+ * Needs to run after the whole page is loaded so the browser can resolve any
+ * JS-driven :target.
+ */
+export function initAnchoredPanels() {
+  const anchorTarget = document.querySelector('[data-panel]:target');
+  if (anchorTarget) {
+    anchorTarget.scrollIntoView({ behavior: 'smooth' });
+  }
+}

+ 6 - 1
client/src/includes/tabs.js

@@ -278,7 +278,12 @@ class Tabs {
   selectTabByURLHash() {
     if (window.location.hash) {
       const cleanedHash = window.location.hash.replace(/[^\w\-#]/g, '');
-      const tab = this.getTabElementByHref(cleanedHash);
+      // Support linking straight to a tab, or to an element within a tab.
+      const tabID = document
+        .querySelector(cleanedHash)
+        ?.closest('[role="tabpanel"]')
+        ?.getAttribute('aria-labelledby');
+      const tab = document.getElementById(tabID);
       if (tab) {
         this.selectTab(tab);
       } else {

+ 4 - 1
client/tailwind.config.js

@@ -83,7 +83,10 @@ module.exports = {
       ...boxShadow,
       none: 'none',
     },
-    spacing,
+    spacing: {
+      ...spacing,
+      'slim-header': '50px',
+    },
     extend: {
       outlineOffset: {
         inside: '-3px',

+ 0 - 1
client/webpack.config.js

@@ -33,7 +33,6 @@ module.exports = function exports(env, argv) {
     'admin': [
       'chooser-modal',
       'chooser-widget',
-      'collapsible',
       'comments',
       'core',
       'date-time-chooser',

+ 1 - 1
docs/reference/contrib/modeladmin/primer.md

@@ -96,7 +96,7 @@ To add extra information to a block within one of the above Wagtail templates, u
 
     {% block content %}
         {{ block.super }}
-        <div class="object">
+        <div>
             <img src="{% get_media_prefix %}{{ instance.image }}"/>
         </div>
     {% endblock %}

+ 6 - 13
docs/reference/pages/panels.md

@@ -90,8 +90,7 @@ This is a powerful but complex feature which will take some space to cover, so w
 
 #### Collapsing InlinePanels to save space
 
-Note that you can use `classname="collapsible collapsed"` to load the panel collapsed under its heading in order to save space in the Wagtail admin.
-See {ref}`collapsible` for more details on `collapsible` usage.
+Note that you can use `classname="collapsed"` to load the panel collapsed under its heading in order to save space in the Wagtail admin.
 
 ### FieldRowPanel
 
@@ -240,11 +239,6 @@ See {ref}`collapsible` for more details on `collapsible` usage.
 By adding CSS classes to your panel definitions or adding extra parameters to your field definitions, you can control much of how your fields will display in the Wagtail page editing interface. Wagtail's page editing interface takes much of its behaviour from Django's admin, so you may find many options for customisation covered there.
 (See [Django model field reference](django:ref/models/fields)).
 
-### Full-Width Input
-
-Use `classname="full"` to make a field (input element) stretch the full width of the Wagtail page editor. This will not work if the field is encapsulated in a
-[`MultiFieldPanel`](wagtail.admin.panels.MultiFieldPanel), which places its child fields into a formset.
-
 ### Titles
 
 Use `classname="title"` to make Page's built-in title field stand out with more vertical padding.
@@ -253,12 +247,11 @@ Use `classname="title"` to make Page's built-in title field stand out with more
 
 ### Collapsible
 
-By default, panels are expanded and not collapsible.
-Use `classname="collapsible"` to enable the collapse control.
-Use `classname="collapsible collapsed"` will load the editor page with the panel collapsed under its heading.
+```{versionchanged} 4.0
+All panels are now collapsible by default.
+```
 
-You must define a `heading` when using `collapsible` with `MultiFieldPanel`.
-You must define a `heading` or `label` when using `collapsible` with `InlinePanel`.
+Using `classname="collapsed"` will load the editor page with the panel collapsed under its heading.
 
 ```python
     content_panels = [
@@ -269,7 +262,7 @@ You must define a `heading` or `label` when using `collapsible` with `InlinePane
                 FieldPanel('publisher'),
             ],
             heading="Collection of Book Fields",
-            classname="collapsible collapsed"
+            classname="collapsed"
         ),
     ]
 ```

+ 1 - 0
wagtail/admin/templates/wagtailadmin/icons/caret-down.svg

@@ -0,0 +1 @@
+<svg id="icon-caret-down" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"/></svg>

+ 0 - 1
wagtail/admin/templates/wagtailadmin/pages/_editor_js.html

@@ -16,7 +16,6 @@
     window.unicodeSlugsEnabled = {% if unicode_slugs_enabled %}true{% else %}false{% endif %};
 </script>
 
-<script src="{% versioned_static 'wagtailadmin/js/collapsible.js' %}"></script>
 <script src="{% versioned_static 'wagtailadmin/js/comments.js' %}"></script>
 <script src="{% versioned_static 'wagtailadmin/js/vendor/rangy-core.js' %}"></script>
 <script src="{% versioned_static 'wagtailadmin/js/vendor/mousetrap.min.js' %}"></script>

+ 1 - 1
wagtail/admin/templates/wagtailadmin/pages/create.html

@@ -3,7 +3,7 @@
 {% load i18n %}
 
 {% block titletag %}{% blocktrans trimmed with page_type=content_type.model_class.get_verbose_name %}New {{ page_type }}{% endblocktrans %}{% endblock %}
-{% block bodyclass %}page-editor create model-{{ content_type.model }}{% endblock %}
+{% block bodyclass %}page-slim-header{% endblock %}
 
 {% block content %}
     <div id="comments"></div>

+ 1 - 1
wagtail/admin/templates/wagtailadmin/pages/edit.html

@@ -3,7 +3,7 @@
 {% load i18n %}
 {% load l10n %}
 {% block titletag %}{% blocktrans trimmed with title=page.get_admin_display_title page_type=content_type.model_class.get_verbose_name %}Editing {{ page_type }}: {{ title }}{% endblocktrans %}{% endblock %}
-{% block bodyclass %}page-editor {% if page.live %}page-is-live{% endif %} model-{{ content_type.model }} {% if page_locked %}page-locked{% endif %}{% endblock %}
+{% block bodyclass %}page-slim-header {% if page.live %}page-is-live{% endif %} {% if page_locked %}page-locked{% endif %}{% endblock %}
 
 {% block content %}
     <div id="comments"></div>

+ 1 - 1
wagtail/admin/templates/wagtailadmin/pages/index.html

@@ -1,7 +1,7 @@
 {% extends "wagtailadmin/base.html" %}
 {% load wagtailadmin_tags i18n %}
 {% block titletag %}{% blocktrans trimmed with title=parent_page.get_admin_display_title %}Exploring {{ title }}{% endblocktrans %}{% endblock %}
-{% block bodyclass %}page-explorer {% if ordering == 'ord' %}reordering{% endif %}{% endblock %}
+{% block bodyclass %}page-explorer page-slim-header {% if ordering == 'ord' %}reordering{% endif %}{% endblock %}
 
 {% block content %}
     {% page_permissions parent_page as parent_page_perms %}

+ 1 - 1
wagtail/admin/templates/wagtailadmin/pages/page_listing_header.html

@@ -16,7 +16,7 @@
     {% page_header_buttons parent_page page_perms=page_perms %}
 {% endblock %}
 {% block actions %}
-    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-[50px] w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
+    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-slim-header w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
         {% if not parent_page.is_root %}
             {% include "wagtailadmin/shared/side_panel_toggles.html" %}
             {# Page history #}

+ 6 - 3
wagtail/admin/templates/wagtailadmin/panels/field_panel.html

@@ -1,12 +1,15 @@
 {% load wagtailadmin_tags i18n %}
-<div id="{{ self.prefix }}-field" class="w-field {% if field.errors %}w-field--error{% endif %}" data-contentpath="{{ field.name }}">
+<div class="w-field w-field--{{ field|fieldtype }} w-field--{{ field|widgettype }} {% if field.errors %}w-field--error{% endif %}" data-contentpath="{{ field.name }}" id="{{ self.prefix }}-field">
     {% if errors %}
-        <p id="{{ error_message_id }}" class="w-error"><svg width="16" height="16"><use href="#icon-cross"></use></svg>{% for error in errors %}{{ error }} {% endfor %}</p>
+        <p class="w-field__errors" id="{{ error_message_id }}">
+            {% icon name="warning" class_name="w-field__errors-icon" %}
+            {% for error in errors %}{{ error }} {% endfor %}
+        </p>
     {% endif %}
 
     {{ rendered_field }}
     {% if help_text %}
-        <p id="{{ help_text_id }}" class="w-help-text">{{ help_text }}</p>
+        <div id="{{ help_text_id }}" class="help">{{ help_text }}</div>
     {% endif %}
 
     {% if show_add_comment_button %}

+ 6 - 2
wagtail/admin/templates/wagtailadmin/panels/multi_field_panel.html

@@ -1,6 +1,10 @@
 {% load wagtailadmin_tags %}
 {% for child in self.visible_children %}
-    <label {% if child.id_for_label %}for="{{ child.id_for_label }}"{% endif %} class="w-field__label">{{ child.heading }}{% if child.is_required %}<span class="w-error" aria-hidden="true">*</span>{% endif %}</label>
+    <div class="w-field__wrapper">
+        {% if child.heading %}
+            <label {% if child.id_for_label %}for="{{ child.id_for_label }}"{% endif %} class="w-field__label">{{ child.heading }}{% if child.is_required %}<span class="w-required-mark">*</span>{% endif %}</label>
+        {% endif %}
 
-    {% component child %}
+        {% component child %}
+    </div>
 {% endfor %}

+ 3 - 29
wagtail/admin/templates/wagtailadmin/panels/object_list.html

@@ -1,33 +1,7 @@
 {% load wagtailadmin_tags %}
 
 {% for child, identifier in self.visible_children_with_identifiers %}
-    <section
-        id="{{ self.prefix }}-childsection-{{ identifier }}"
-        aria-labelledby="{{ self.prefix }}-childheading-{{ identifier }}"
-        class="w-panel"
-    >
-        <div class="w-panel__header">
-            <a href="#{{ self.prefix }}-childsection-{{ identifier }}" aria-label="Link to {{ child.heading }}">#</a>
-            <button
-                type="button"
-                class="w-panel__toggle"
-                aria-label="Toggle {{ child.heading }}"
-                aria-controls="{{ self.prefix }}-childcontent-{{ identifier }}"
-                aria-expanded="true"
-            >
-                <svg width="16" height="16"><use href="#icon-pilcrow"></use></svg>
-            </button>
-            <h2 id="{{ self.prefix }}-childheading-{{ identifier }}" class="w-panel__heading">
-                {% if child.id_for_label %}
-                    <label for="{{ child.id_for_label }}">{{ child.heading }}{% if child.is_required %}<span class="w-error" aria-hidden="true">*</span>{% endif %}</label>
-                {% else %}
-                    {{ child.heading }}{% if child.is_required %}<span class="w-error" aria-hidden="true">*</span>{% endif %}
-                {% endif %}
-            </h2>
-        </div>
-
-        <div id="{{ self.prefix }}-childcontent-{{ identifier }}" class="w-panel__content">
-            {% component child %}
-        </div>
-    </section>
+    {% panel id_prefix=self.prefix id=identifier class_name=child.classes|join:' ' heading=child.heading heading_size="label" icon=child.icon id_for_label=child.id_for_label is_required=child.is_required %}
+        {% component child %}
+    {% endpanel %}
 {% endfor %}

+ 2 - 2
wagtail/admin/templates/wagtailadmin/shared/breadcrumbs.html

@@ -13,14 +13,14 @@
             <button
                 type="button"
                 data-toggle-breadcrumbs
-                class="w-flex w-items-center w-justify-center w-box-border w-ml-0 w-p-4 w-w-[50px] w-h-full w-bg-transparent w-text-grey-400 w-transition hover:w-scale-110 hover:w-text-primary w-outline-offset-inside"
+                class="w-flex w-items-center w-justify-center w-box-border w-ml-0 w-p-4 w-w-slim-header w-h-full w-bg-transparent w-text-grey-400 w-transition hover:w-scale-110 hover:w-text-primary w-outline-offset-inside"
                 aria-label="{% trans 'Toggle breadcrumbs' %}"
                 aria-expanded="false"
             >
                 {% icon name="breadcrumb-expand" class_name="w-w-4 w-h-4" %}
             </button>
         {% endif %}
-        <div class="w-relative w-h-[50px] w-mr-4 w-top-0 w-z-20 w-flex w-items-center w-flex-row w-flex-1 sm:w-flex-none w-transition w-duration-300">
+        <div class="w-relative w-h-slim-header w-mr-4 w-top-0 w-z-20 w-flex w-items-center w-flex-row w-flex-1 sm:w-flex-none w-transition w-duration-300">
             <nav class="w-flex w-items-center w-flex-row w-h-full"
                 aria-label="{% trans 'Breadcrumb' %}">
                 <ol class="w-flex w-flex-row w-justify-start w-items-center w-h-full w-pl-0 w-my-0 w-gap-2 sm:w-gap-0 sm:w-space-x-2">

+ 1 - 1
wagtail/admin/templates/wagtailadmin/shared/headers/page_create_header.html

@@ -11,7 +11,7 @@
 {% endblock %}
 
 {% block actions %}
-    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-[50px] w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
+    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-slim-header w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
         {% include "wagtailadmin/shared/side_panel_toggles.html" %}
     {% endwith %}
 {% endblock %}

+ 1 - 1
wagtail/admin/templates/wagtailadmin/shared/headers/page_edit_header.html

@@ -15,7 +15,7 @@
 {% endblock %}
 
 {% block actions %}
-    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-[50px] w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
+    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-slim-header w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
         {% include "wagtailadmin/shared/side_panel_toggles.html" %}
 
         {# Page history #}

+ 3 - 2
wagtail/admin/templates/wagtailadmin/shared/headers/slim_header.html

@@ -1,9 +1,10 @@
 {% load wagtailadmin_tags i18n %}
+{# Sticky header – make sure any view using this also sets the `page-slim-header` class on its body. #}
 {# Z index 99 to ensure header is always above  #}
-<header class="w-flex w-flex-col sm:w-flex-row w-items-center w-justify-between w-bg-grey-50 w-border-b w-border-grey-100 w-px-0 w-py-0 w-mb-0 w-relative w-top-0 w-z-header sm:w-sticky w-min-h-[50px]">
+<header class="w-flex w-flex-col sm:w-flex-row w-items-center w-justify-between w-bg-grey-50 w-border-b w-border-grey-100 w-px-0 w-py-0 w-mb-0 w-relative w-top-0 w-z-header sm:w-sticky w-min-h-slim-header">
 
     {# Padding left on mobile to give space for navigation toggle, #}
-    <div class="w-pl-[50px] w-min-h-[50px] sm:w-pl-0 sm:w-pr-2 w-w-full w-flex-1 w-overflow-x-auto w-box-border">
+    <div class="w-pl-slim-header w-min-h-slim-header sm:w-pl-0 sm:w-pr-2 w-w-full w-flex-1 w-overflow-x-auto w-box-border">
         <div class="w-flex w-flex-1 w-items-center w-overflow-hidden">
             {% block header_content %}
             {% endblock %}

+ 66 - 0
wagtail/admin/templates/wagtailadmin/shared/panel.html

@@ -0,0 +1,66 @@
+{% load wagtailadmin_tags i18n %}
+{% comment %}
+    Variables this template accepts:
+
+    id_prefix - A prefix for all id attributes.
+    classes - List of CSS classes to use for the panel.
+    class_name - String of CSS classes to use for the panel.
+    id - Unique to the page.
+    heading - The text of the panel’s heading.
+    heading_size - The size of the heading.
+    icon - Displayed alongside the heading.
+    id_for_label - id of an associated field.
+    is_required - If the panel contains a required field.
+    children - Where to insert the panel’s contents.
+
+{% endcomment %}
+{% fragment as prefix %}{% if id_prefix %}{{ id_prefix }}-{% endif %}{{ id }}{% endfragment %}
+{% fragment as panel_id %}{{ prefix }}-section{% endfragment %}
+{% fragment as heading_id %}{{ prefix }}-heading{% endfragment %}
+{% fragment as content_id %}{{ prefix }}-content{% endfragment %}
+<section
+    class="w-panel {{ classes|join:' ' }}{{ class_name }}"
+    id="{{ panel_id }}"
+    aria-labelledby="{{ heading_id }}"
+    data-panel
+>
+    {# If a panel has no heading, we don’t want any of the associated UI. #}
+    {% if heading %}
+        <div class="w-panel__header">
+            <a
+                class="w-panel__anchor"
+                href="#{{ panel_id }}"
+                aria-label="{% trans 'Anchor section' %}"
+                aria-describedby="{{ heading_id }}"
+            >
+                {% icon name="link" class_name="w-panel__icon" %}
+            </a>
+            <button
+                class="w-panel__toggle"
+                type="button"
+                aria-label="{% trans 'Toggle section' %}"
+                aria-describedby="{{ heading_id }}"
+                data-panel-toggle
+                aria-controls="{{ content_id }}"
+                aria-expanded="true"
+            >
+                {# If there is a custom icon, we display it when the panel is expanded. #}
+                {% if icon %}
+                    {% icon name=icon class_name="w-panel__icon w-panel__icon--custom" %}
+                {% endif %}
+                {% icon name="caret-down" class_name="w-panel__icon" %}
+            </button>
+            <h2 class="w-panel__heading {% if heading_size == "label" %}w-panel__heading--label{% endif %}" id="{{ heading_id }}">
+                {% if id_for_label %}
+                    <label for="{{ id_for_label }}">{{ heading }}{% if is_required %}<span class="w-required-mark">*</span>{% endif %}</label>
+                {% else %}
+                    {{ heading }}{% if is_required %}<span class="w-required-mark">*</span>{% endif %}
+                {% endif %}
+            </h2>
+        </div>
+    {% endif %}
+
+    <div id="{{ content_id }}" class="w-panel__content">
+        {{ children }}
+    </div>
+</section>

+ 8 - 1
wagtail/admin/templatetags/wagtailadmin_tags.py

@@ -514,7 +514,7 @@ def page_header_buttons(context, page, page_perms):
             "w-flex",
             "w-justify-center",
             "w-items-center",
-            "w-h-[50px]",
+            "w-h-slim-header",
         ],
         "button_classes": [
             "w-p-0",
@@ -1019,6 +1019,13 @@ class HelpBlockNode(BlockInclusionNode):
 register.tag("help_block", HelpBlockNode.handle)
 
 
+class PanelNode(BlockInclusionNode):
+    template = "wagtailadmin/shared/panel.html"
+
+
+register.tag("panel", PanelNode.handle)
+
+
 # Button used to open dialogs
 @register.inclusion_tag("wagtailadmin/shared/dialog/dialog_toggle.html")
 def dialog_toggle(dialog_id, class_name="", text=None):

+ 1 - 0
wagtail/admin/wagtail_hooks.py

@@ -1006,6 +1006,7 @@ def register_icons(icons):
         "bin.svg",
         "bold.svg",
         "breadcrumb-expand.svg",
+        "caret-down.svg",
         "chain-broken.svg",
         "check.svg",
         "chevron-down.svg",

+ 2 - 2
wagtail/snippets/templates/wagtailsnippets/snippets/headers/_base_header.html

@@ -10,14 +10,14 @@
             <button
                 type="button"
                 data-toggle-breadcrumbs
-                class="w-flex w-items-center w-justify-center w-box-border w-ml-0 w-p-4 w-w-[50px] w-h-full w-bg-transparent w-text-grey-400 w-transition hover:w-scale-110 hover:w-text-primary w-outline-offset-inside"
+                class="w-flex w-items-center w-justify-center w-box-border w-ml-0 w-p-4 w-w-slim-header w-h-full w-bg-transparent w-text-grey-400 w-transition hover:w-scale-110 hover:w-text-primary w-outline-offset-inside"
                 aria-label="{% trans 'Toggle breadcrumbs' %}"
                 aria-expanded="false"
             >
                 {% icon name="breadcrumb-expand" class_name="w-w-4 w-h-4" %}
             </button>
 
-            <div class="w-relative w-h-[50px] w-mr-4 w-top-0 w-z-20 w-flex w-items-center w-flex-row w-flex-1 sm:w-flex-none w-transition w-duration-300">
+            <div class="w-relative w-h-slim-header w-mr-4 w-top-0 w-z-20 w-flex w-items-center w-flex-row w-flex-1 sm:w-flex-none w-transition w-duration-300">
                 <nav class="w-flex w-items-center w-flex-row w-h-full"
                     aria-label="{% trans 'Breadcrumb' %}">
                     <ol class="w-flex w-flex-row w-justify-start w-items-center w-h-full w-pl-0 w-my-0 w-gap-2 sm:w-gap-0 sm:w-space-x-2">

+ 1 - 1
wagtail/snippets/templates/wagtailsnippets/snippets/headers/create_header.html

@@ -22,7 +22,7 @@
 {% endblock %}
 
 {% block actions %}
-    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-[50px] w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
+    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-slim-header w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
         {% include "wagtailadmin/shared/side_panel_toggles.html" %}
     {% endwith %}
 {% endblock %}

+ 1 - 1
wagtail/snippets/templates/wagtailsnippets/snippets/headers/edit_header.html

@@ -19,7 +19,7 @@
 {% endblock %}
 
 {% block actions %}
-    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-[50px] w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
+    {% with nav_icon_classes='w-w-4 w-h-4' nav_icon_button_classes='w-h-slim-header w-bg-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-grey-400 w-transition hover:w-transform hover:w-scale-110 hover:w-text-primary focus:w-text-primary' %}
         {% include "wagtailadmin/shared/side_panel_toggles.html" %}
 
         {# Object history #}