diff --git a/client/scss/components/_drilldown.scss b/client/scss/components/_drilldown.scss new file mode 100644 index 0000000000..8346361b53 --- /dev/null +++ b/client/scss/components/_drilldown.scss @@ -0,0 +1,86 @@ +.w-drilldown__contents { + max-height: min(480px, 70vh); + overflow-y: auto; +} + +.w-drilldown .w-drilldown__toggle { + @apply w-label-1; + display: flex; + justify-content: space-between; + padding-inline-start: theme('spacing.5'); + padding-inline-end: theme('spacing.5'); + border: 1px solid transparent; + + &:hover { + background-color: transparent; + border-color: theme('colors.border-button-outline-hover'); + color: theme('colors.text-link-hover'); + cursor: pointer; + + @media (forced-colors: active) { + border-color: Highlight; + } + } + + .icon { + pointer-events: none; + width: theme('spacing.5'); + height: theme('spacing.5'); + opacity: 1; + margin-inline-end: 0; + } +} + +.w-drilldown__submenu { + display: grid; + grid-template-columns: min-content 1fr; + padding-inline-end: theme('spacing.[2.5]'); +} + +.w-drilldown .w-drilldown__back { + @apply w-label-1; + position: relative; + padding: theme('spacing.[2.5]'); + align-self: flex-start; + + .icon { + pointer-events: none; + margin-inline-end: 0; + } + + &:hover { + background-color: transparent; + border-color: theme('colors.border-button-outline-hover'); + color: theme('colors.text-link-hover'); + cursor: pointer; + + @media (forced-colors: active) { + border-color: Highlight; + } + } +} + +.w-drilldown__submenu .w-field__label { + @apply w-label-1; + margin-top: theme('spacing.2'); + margin-bottom: theme('spacing.3'); +} + +.w-drilldown__count { + $badge-size: theme('spacing.4'); + width: $badge-size; + height: $badge-size; + line-height: $badge-size; + text-align: center; + font-size: 0.5625rem; + font-weight: theme('fontWeight.bold'); + border-radius: theme('borderRadius.full'); + background-color: theme('colors.info.100'); + color: theme('colors.white.DEFAULT'); + + .w-filter-button & { + position: absolute; + top: calc(-0.5 * #{$badge-size}); + inset-inline-end: calc(-0.5 * #{$badge-size}); + } +} diff --git a/client/scss/components/_filters.scss b/client/scss/components/_filters.scss index 34c4099143..35ac1e9ebd 100644 --- a/client/scss/components/_filters.scss +++ b/client/scss/components/_filters.scss @@ -25,6 +25,7 @@ } .w-filter-button { + position: relative; width: theme('spacing.10'); height: theme('spacing.10'); padding: 0; diff --git a/client/scss/components/_header.scss b/client/scss/components/_header.scss index 23ba821e12..ea91500f22 100644 --- a/client/scss/components/_header.scss +++ b/client/scss/components/_header.scss @@ -180,7 +180,7 @@ .w-field__input { @apply w-mt-0; - input { + > input { @apply w-text-16; min-height: theme('spacing.10'); } diff --git a/client/scss/core.scss b/client/scss/core.scss index 3a84268fe7..fa3ce693d6 100644 --- a/client/scss/core.scss +++ b/client/scss/core.scss @@ -110,6 +110,7 @@ These are classes for components. @import 'components/panel'; @import 'components/dialog'; @import 'components/dismissible'; +@import 'components/drilldown'; @import 'components/dropdown'; @import 'components/dropdown-button'; @import 'components/help-block'; diff --git a/client/scss/overrides/_vendor.tippy.scss b/client/scss/overrides/_vendor.tippy.scss index adaa4ac546..81d50c8676 100644 --- a/client/scss/overrides/_vendor.tippy.scss +++ b/client/scss/overrides/_vendor.tippy.scss @@ -31,6 +31,15 @@ } } +.tippy-box[data-theme='drilldown'] { + @apply w-rounded w-bg-surface-page w-shadow-md; + width: 300px; + + .tippy-content { + @apply w-p-0; + } +} + .tippy-box[data-theme='dropdown-button'] { @apply w-rounded-none w-w-full w-bg-transparent; diff --git a/client/src/controllers/DrilldownController.ts b/client/src/controllers/DrilldownController.ts new file mode 100644 index 0000000000..4620c07df5 --- /dev/null +++ b/client/src/controllers/DrilldownController.ts @@ -0,0 +1,123 @@ +import { Controller } from '@hotwired/stimulus'; + +export class DrilldownController extends Controller { + static targets = ['menu', 'toggle']; + + static values = { + activeSubmenu: { default: '', type: String }, + }; + + declare activeSubmenuValue: string; + declare swapSuccessListener: (e: Event) => void; + + declare readonly menuTarget: HTMLElement; + declare readonly toggleTargets: HTMLButtonElement[]; + + connect() { + this.updateToggleCounts(); + + this.swapSuccessListener = (e: Event) => { + const swapEvent = e as CustomEvent<{ requestUrl: string }>; + if ((e.target as HTMLElement)?.id === 'listing-results') { + const params = new URLSearchParams( + swapEvent.detail?.requestUrl.split('?')[1], + ); + const filteredParams = new URLSearchParams(); + params.forEach((value, key) => { + if (value.trim() !== '') { + // Check if the value is not empty after trimming white space + filteredParams.append(key, value); + } + }); + const queryString = '?' + filteredParams.toString(); + this.updateToggleCounts(); + window.history.replaceState(null, '', queryString); + } + }; + + document.addEventListener('w-swap:success', this.swapSuccessListener); + } + + disconnect() { + document.removeEventListener('w-swap:success', this.swapSuccessListener); + } + + open(e: MouseEvent) { + const toggle = (e.target as HTMLElement)?.closest( + 'button', + ) as HTMLButtonElement; + this.activeSubmenuValue = toggle.getAttribute('aria-controls') || ''; + } + + close() { + this.activeSubmenuValue = ''; + this.updateToggleCounts(); + } + + activeSubmenuValueChanged(activeSubmenu: string, prevActiveSubmenu?: string) { + if (prevActiveSubmenu) { + const toggle = document.querySelector( + `[aria-controls="${prevActiveSubmenu}"]`, + ) as HTMLButtonElement; + this.toggle(false, toggle); + } + + if (activeSubmenu) { + const toggle = document.querySelector( + `[aria-controls="${activeSubmenu}"]`, + ) as HTMLButtonElement; + this.toggle(true, toggle); + } + } + + toggle(expanded: boolean, toggle: HTMLButtonElement) { + const controls = toggle.getAttribute('aria-controls'); + const content = this.element.querySelector(`#${controls}`); + if (!content) { + return; + } + toggle.setAttribute('aria-expanded', expanded.toString()); + content.hidden = !expanded; + this.menuTarget.hidden = expanded; + this.element.classList.toggle('w-drilldown--active', expanded); + + if (expanded) { + content.focus(); + } else { + toggle.focus(); + } + } + + /** + * Placeholder function until this is more correctly set up with the backend. + */ + updateToggleCounts() { + let sum = 0; + + this.toggleTargets.forEach((toggle) => { + const content = this.element.querySelector( + `#${toggle.getAttribute('aria-controls')}`, + ); + const counter = toggle.querySelector('.w-drilldown__count'); + if (!content || !counter) { + return; + } + // Hack to detect fields with a non-default value. + const nbActiveFields = content.querySelectorAll( + '[type="checkbox"]:checked, [type="radio"]:checked:not([id$="_0"]), option:checked:not(:first-child), input:not([type="checkbox"], [type="radio"]):not(:placeholder-shown)', + ).length; + counter.hidden = nbActiveFields === 0; + counter.textContent = nbActiveFields.toString(); + sum += nbActiveFields; + }); + + const sumCounter = this.element.querySelector( + '.w-drilldown__count', + ); + if (!sumCounter) { + return; + } + sumCounter.hidden = sum === 0; + sumCounter.textContent = sum.toString(); + } +} diff --git a/client/src/controllers/DropdownController.ts b/client/src/controllers/DropdownController.ts index 0bd9d529fc..1120aa6ffe 100644 --- a/client/src/controllers/DropdownController.ts +++ b/client/src/controllers/DropdownController.ts @@ -86,6 +86,11 @@ const themeOptions = { maxWidth: 350, placement: 'bottom', }, + 'drilldown': { + arrow: false, + maxWidth: 'none', + placement: 'bottom-end', + }, 'dropdown-button': { arrow: false, maxWidth: 'none', diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts index f0d898c317..d93c74f417 100644 --- a/client/src/controllers/index.ts +++ b/client/src/controllers/index.ts @@ -9,6 +9,7 @@ import { CloneController } from './CloneController'; import { CountController } from './CountController'; import { DialogController } from './DialogController'; import { DismissibleController } from './DismissibleController'; +import { DrilldownController } from './DrilldownController'; import { DropdownController } from './DropdownController'; import { InitController } from './InitController'; import { OrderableController } from './OrderableController'; @@ -39,6 +40,7 @@ export const coreControllerDefinitions: Definition[] = [ { controllerConstructor: CountController, identifier: 'w-count' }, { controllerConstructor: DialogController, identifier: 'w-dialog' }, { controllerConstructor: DismissibleController, identifier: 'w-dismissible' }, + { controllerConstructor: DrilldownController, identifier: 'w-drilldown' }, { controllerConstructor: DropdownController, identifier: 'w-dropdown' }, { controllerConstructor: InitController, identifier: 'w-init' }, { controllerConstructor: OrderableController, identifier: 'w-orderable' }, diff --git a/wagtail/admin/templates/wagtailadmin/shared/dropdown/dropdown.html b/wagtail/admin/templates/wagtailadmin/shared/dropdown/dropdown.html index 6466fe879a..15e77cd163 100644 --- a/wagtail/admin/templates/wagtailadmin/shared/dropdown/dropdown.html +++ b/wagtail/admin/templates/wagtailadmin/shared/dropdown/dropdown.html @@ -8,6 +8,7 @@ - `attrs` (string?) - more attributes for parent element - `toggle_icon` (string?) - toggle icon identifier - `toggle_label` (string?) - Visible label for the toggle button + - `toggle_suffix` (string?) - Visible content for the toggle button after the icon - `toggle_aria_label` (string?) - aria-label for the toggle button - `toggle_describedby` (string?) - aria-describedby for the toggle button - `toggle_classname` (string?) - additional toggle classes @@ -24,6 +25,7 @@ {% if toggle_icon %} {% icon name=toggle_icon classname="w-dropdown__toggle-icon" %} {% endif %} + {{ toggle_suffix }}