Skip to content

Commit

Permalink
Use identifiers on modal triggers and modal component instead of inte…
Browse files Browse the repository at this point in the history
…gral trigger (#3541)

Co-authored-by: ammar92 <[email protected]>
Co-authored-by: Peter-Paul van Gemerden <[email protected]>
Co-authored-by: stephanie0x00 <[email protected]>
Co-authored-by: Jan Klopper <[email protected]>
  • Loading branch information
5 people authored Oct 1, 2024
1 parent 4cddb17 commit b3b052a
Show file tree
Hide file tree
Showing 5 changed files with 74 additions and 74 deletions.
32 changes: 18 additions & 14 deletions rocky/components/modal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,25 @@ First you need to add `{% load component_tags %}` at the top of your template. N
{% endblock html_at_end_body %}
```

After that, `{% component "modal" size="xx" %}` is enough to instantiate the dialog modal component where size should contain the appropriate class name to achieve the correct sizing. This can be either `dialog-small`, `dialog-medium` or `dialog-large`.
After that, `{% component "modal" modal_id="xx" size="xx" %}` is enough to instantiate the dialog modal component where `modal_id` should contain a unique identifier that must be also used in the triggers `data-modal-id` attribute, and `size` should contain the appropriate class name to achieve the correct sizing. This can be either `dialog-small`, `dialog-medium` or `dialog-large`.

### Slots and fills
### The trigger element

Each named `fill` corresponds with a placeholder/target `slot` in the component template. The contents between the `fill` tag will be passed to the corresponding `slot`. As shown in the below example it's possible to utilise Django template tags and `HTML` tags with these `fill` tags. This enables us to entirely build the contents of the modal in the template where we implement it. Because we can use `HTML` tags here, we can also use `forms` and leave the handling of said form up to the Django template that knows about the context and applicable data, where we implement the modal. The defaults are used when no `fill` tags are implemented for this slot at all.
The trigger element needs some explaining. This will be the element that `on click` will show the modal dialog,
the `element` gets assigned the click handler by JavaScript.
It's essential to include the data attribute `data-modal-id="xx"`, where "xx" is the same as the `id` attribute on the intended modal. This is what defines the modal dialog that this trigger is meant to target. The only exception to this is when an `<a>` is used, with an `href` containing an anchor `"#xx"`, which points to `the modal-id` of the target modal.
While it might seem obvious to use a `button` or a `a` as a trigger, the modal is set up in a way that allows for any HTML element to be used as a trigger.
The trigger doesn't need to be part of the `{% component %}` itself and can be placed anywhere in the HTML template, though it makes sense to place them adjacent or as close as possible to each other.

There's a total of four slots you can fill:
### Slots and fills

1. `trigger`: call to action `button` by default, with the caption "Open modal".
2. `header`: empty by default
3. `content`: empty by default
4. `footer_buttons`: cancel `button` by default. To have _no buttons_ show at all, it's needed to implement empty `fill` tags for this `slot`.
Each named `fill` corresponds with a placeholder/target `slot` in the component template. The contents between the `fill` tag will be passed to the corresponding `slot`. As shown in the below example it's possible to utilise Django template tags and `HTML` tags with these `fill` tags. This enables us to entirely build the contents of the modal in the template where we implement it. Because we can use `HTML` tags here, we can also use `forms` and leave the handling of said form up to the Django template that knows about the context and applicable data, where we implement the modal. The defaults are used when no `fill` tags are implemented for this slot at all.

### The trigger element
There's a total of three slots you can fill:

The trigger `slot` is a special one. This needs to contain the HTML `element` that gets assigned the click handler by JavaScript. It's essential to include the `class="modal-trigger"` attribute, because this is what we target to assign the click handler, using JS. While it might seem obvious to use a `button` as a trigger, the modal is setup in a way that allows for any HTML element to be used as a trigger.
1. `header`: empty by default
2. `content`: empty by default
3. `footer_buttons`: cancel `button` by default. To have _no buttons_ show at all, it's needed to implement empty `fill` tags for this `slot`.

### CSS dependencies

Expand All @@ -44,10 +47,11 @@ Including `{% component_css_dependencies %}` is needed to inject the reference t
### Example implementation

```
{% component "modal" size="dialog-small" %}
{% fill "trigger" %}
<button class="modal-trigger">Click here to open the modal.</button>
{% endfill %}
<a href="#" data-modal-id="rename-modal">
```

```
{% component "modal" modal_id="rename-modal" size="dialog-small" %}
{% fill "header" %}
{% translate "This is an example header." %}
{% endfill %}
Expand Down
1 change: 1 addition & 0 deletions rocky/components/modal/modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Modal(component.Component):
def get_context_data(self, **kwargs):
return {
"size": kwargs["size"],
"modal_id": kwargs["modal_id"],
}

class Media:
Expand Down
92 changes: 44 additions & 48 deletions rocky/components/modal/script.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { onDomReady } from "../js/imports/utils.js";

onDomReady(initDialogs);
onDomReady(function () {
initDialogs();
openDialogFromUrl();
});

export function openDialogFromUrl() {
// If ID is present in the URL on DomReady, open the dialog immediately.
let id = window.location.hash.slice(1);
if (id) {
showModalBasedOnAnchor(id);
}
}

export function initDialogs(element) {
let root = element || document;
Expand All @@ -10,67 +21,52 @@ export function initDialogs(element) {
}

export function initDialog(modal) {
let input_elements = [];
let dialog_element = modal.querySelector("dialog");
if (!dialog_element) return;

modal.querySelector(".modal-trigger").addEventListener("click", (event) => {
// Get and clone input elements to be able to "reset" them on "cancel".
input_elements = modal.querySelectorAll("input, textarea");
let trigger = document.querySelector(
"[data-modal-id='" + dialog_element.id + "']:not(a)",
);

// Store the initial value in a data attribute
input_elements.forEach((element) => {
element.defaultvalue = element.value;
// Check if trigger element is <a>, if not, on click,
// alter the URL to open the dialog using the onhaschange event.
if (trigger) {
trigger.addEventListener("click", (event) => {
window.location.hash = "#" + dialog_element.id;
});
}

// Used ".closest" instead of ".parentNode" to make sure we stay flexible in terms of
// HTML-structure when implementing the trigger.
event.target.closest(".modal-wrapper").querySelector("dialog").showModal();
});

modal.querySelector("dialog").addEventListener("click", (event) => {
dialog_element.addEventListener("click", (event) => {
// The actual handling (like posting) of the input values should be done when implementing the component.
if (event.target.classList.contains("confirm-modal-button")) {
if (input_elements) {
// Closing is only allowed when the inputs are 'valid'.
if (checkValidity(input_elements)) {
event.target
.closest(".modal-wrapper")
.querySelector("dialog")
.close();
}
return;
}
}
// event.target.nodeName === 'DIALOG' is needed to check if the ::backdrop is clicked.
if (
event.target.classList.contains("confirm-modal-button") ||
event.target.classList.contains("close-modal-button") ||
event.target.nodeName === "DIALOG"
) {
// When canceling or closing using the 'x' remove the "error" styles.
input_elements.forEach((element) => {
element.classList.remove("error");
});

// When canceling or closing the modal, the inputs get reset to their initial value.
input_elements.forEach((element) => {
element.value = element.defaultvalue;
});

event.target.closest(".modal-wrapper").querySelector("dialog").close();
}
});
}

export function checkValidity(elements) {
let valid = true;

elements.forEach((element) => {
if (!element.checkValidity()) {
valid = false;
element.classList.add("error");
} else {
element.classList.remove("error");
}
dialog_element.addEventListener("close", (event) => {
removeDialogAnchor();
});
}

return valid;
export function removeDialogAnchor() {
// Remove the anchor from the URL when closing the modal
let baseUrl = window.location.toString().split("#")[0];
window.history.pushState("", "Base URL", baseUrl);
}

export function showModalBasedOnAnchor(id) {
if (id && document.querySelector("dialog#" + id + ".modal")) {
// Show modal, selected by ID
document.querySelector("#" + id).showModal();
}
}

addEventListener("hashchange", function () {
let id = window.location.toString().split("#")[1];
showModalBasedOnAnchor(id);
});
9 changes: 3 additions & 6 deletions rocky/components/modal/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@
{% load compress %}

<div class="modal-wrapper">
{% slot "trigger" %}
<button class="modal-trigger">Open modal</button>
{% endslot %}

<dialog class="modal {{ size }}"
aria-modal="true"
role="dialog">
aria-modal="true"
role="dialog"
id="{{ modal_id }}">
<div class="content-wrapper">
<section class="header">
<h2>{% slot "header" %}{% endslot %}</h2>
Expand Down
14 changes: 8 additions & 6 deletions rocky/katalogus/templates/partials/plugin_tile_modal.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{% load i18n %}

{% component "modal" size="dialog-medium" %}
{% fill "trigger" %}
<a class="tile-detail-link modal-trigger">{% translate "See details" %}</a>
{% endfill %}
{% fill "header" %}
{% translate "Boefje details" %}
<a href="#{{ plugin.id }}-detail-modal"
class="tile-detail-link"
data-modal-id="{{ plugin.id }}-detail-modal">{% translate "See details" %}</a>
{% with modal_id=plugin.id|add:"-detail-modal" %}
{% component "modal" modal_id=modal_id size="dialog-medium" %}
{% fill "header" %}
{% translate "Boefje details" %}
{% endfill %}
{% fill "content" %}
<section>
Expand Down Expand Up @@ -86,3 +87,4 @@ <h3 class="heading-small">{% translate "Produces" %}</h3>
{% endfill %}
{% endcomponent %}
{% component_css_dependencies %}
{% endwith %}

0 comments on commit b3b052a

Please sign in to comment.