0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-11-25 05:02:57 +01:00

Add drilldown filters interface for universal listings

This commit is contained in:
Thibaud Colas 2024-01-19 09:37:05 +00:00 committed by Sage Abdullah
parent 59479a49cc
commit 68df8a0ba3
No known key found for this signature in database
GPG Key ID: EB1A33CC51CC0217
10 changed files with 259 additions and 9 deletions

View File

@ -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});
}
}

View File

@ -25,6 +25,7 @@
}
.w-filter-button {
position: relative;
width: theme('spacing.10');
height: theme('spacing.10');
padding: 0;

View File

@ -180,7 +180,7 @@
.w-field__input {
@apply w-mt-0;
input {
> input {
@apply w-text-16;
min-height: theme('spacing.10');
}

View File

@ -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';

View File

@ -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;

View File

@ -0,0 +1,123 @@
import { Controller } from '@hotwired/stimulus';
export class DrilldownController extends Controller<HTMLElement> {
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<HTMLElement>(`#${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<HTMLElement>(
`#${toggle.getAttribute('aria-controls')}`,
);
const counter = toggle.querySelector<HTMLElement>('.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<HTMLElement>(
'.w-drilldown__count',
);
if (!sumCounter) {
return;
}
sumCounter.hidden = sum === 0;
sumCounter.textContent = sum.toString();
}
}

View File

@ -86,6 +86,11 @@ const themeOptions = {
maxWidth: 350,
placement: 'bottom',
},
'drilldown': {
arrow: false,
maxWidth: 'none',
placement: 'bottom-end',
},
'dropdown-button': {
arrow: false,
maxWidth: 'none',

View File

@ -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' },

View File

@ -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 }}
</button>
<div data-w-dropdown-target="content" class="w-dropdown__content" hidden>

View File

@ -79,14 +79,35 @@
{% endif %}
{% if filters %}
{% fragment as filters_icon %}{% icon name="sliders" title=_("Show filters") %}{% endfragment %}
{% dialog_toggle classname="w-filter-button" dialog_id="filters-dialog" text=filters_icon %}
{% dialog theme="floating" id="filters-dialog" title=_("Filters") dialog_root_selector="[data-search-form]" %}
{% for field in filters.form %}
{% formattedfield field %}
{% endfor %}
{% enddialog %}
<div class="w-drilldown" data-controller="w-drilldown">
{% fragment as toggle_suffix %}
<span class="w-drilldown__count" hidden></span>
{% endfragment %}
{% dropdown theme="drilldown" toggle_icon="sliders" toggle_classname="w-filter-button" toggle_aria_label=_("Show filters") toggle_suffix=toggle_suffix %}
<div class="w-drilldown__contents">
<div class="w-drilldown__menu" data-w-drilldown-target="menu">
<h2 class="w-help-text w-pl-5 w-py-2.5 w-my-0">{% trans "Filter by" %}</h2>
{% for field in filters.form %}
<button class="w-drilldown__toggle" data-w-drilldown-target="toggle" data-action="click->w-drilldown#open" type="button" class="w-flex w-justify-between" aria-expanded="false" aria-controls="drilldown-{{field.auto_id}}">
{{ field.label }}
<div class="w-flex w-items-center w-gap-2">
<span class="w-drilldown__count" hidden></span>
{% icon name="arrow-right" %}
</div>
</button>
{% endfor %}
</div>
{% for field in filters.form %}
<div class="w-drilldown__submenu" id="drilldown-{{field.auto_id}}" hidden tabindex="-1">
<button class="w-drilldown__back" type="button" data-action="click->w-drilldown#close" aria-label="{% trans 'Back' %}">
{% icon name="arrow-left" %}
</button>
{% formattedfield field %}
</div>
{% endfor %}
</div>
{% enddropdown %}
</div>
{% endif %}
{% comment %}