Skip to content

JavaScript Frontend

samuelgfeller edited this page Apr 10, 2024 · 11 revisions

Introduction

To keep the slim-example-project as simple and lightweight as possible, it is not dependent on any JavaScript framework or library.

The frontend is built with vanilla JavaScript and ES6 modules.

ES6 Modules

Since ES6, JavaScript has a module system. This makes it possible to handle dependencies easily and to structure the code.

Instead of having to load all the scripts in the correct order in the HTML file, the files (modules) containing relevant code can be imported in the script files themselves.

That way, the code from other JS files can be accessed easily everywhere in the frontend application by simply importing the function or class from that other file.

Exporting functions, variables and classes

Before a function or variable can be imported into another file, it has to be exported first. This is done by adding the export keyword in front of the function or variable declaration.

File: my-module.js

export const myVariable = 42;

export function myFunction() {
    console.log('Hello from myFunction');
}

export class MyClass {
    constructor() {
        console.log('Hello from MyClass');
    }
}

Importing modules

The exported elements can be imported by using the import keyword.
IDEs like PHPStorm will automatically add the import statement when a function, variable or class from another module is used.

File: main-module.js

import {myVariable, myFunction, MyClass} from './my-module.js';

console.log(myVariable); // 42
myFunction(); // Hello from myFunctions
new MyClass(); // Hello from MyClass

Loading modules in HTML

Only the main module file that imports other modules has to be loaded in the HTML file.

This is done with the usual <script> tag, but with the added attribute type="module".

<script type="module" src="main-module.js"></script>

Loading modules with versioning

The browser will automatically cache the file added via the <script> tag and all the modules it requires, which means that when there is a change in one of the modules, the browser will not load the up-to-date version.

To fix the caching issue for the main module, a version number can be added to the file path as a query parameter.

<script type="module" src="main-module.js?version=1.0.0"></script>

To facilitate the versioning of the modules added via HTML, the slim-example-project uses the template renderer to add the assets with the version number.

The templates are responsible for loading the main module files as well as the other JS and CSS assets.

The path to the required module is added to the template variables in an array at the top of the template file.

File: templates/template.html.php

// JS module
$this->addAttribute('jsModules', ['main-module.js',]);

Read more about this in Template Rendering - Asset handling.

JS module cache busting

Adding a version number to the module file that is required in the HTML file does not break the cache of the imported modules.

They are loaded by the scripts themselves, and the template renderer has nothing to do with the content of the modules.

JS import cache busting explains how a version number can be added to the import statements programmatically.

Ajax

With Ajax, the frontend can send and retrieve data from a server asynchronously (in the background) without interfering with the behavior of the loaded page.

There are two ways to send an Ajax request: XMLHttpRequest and fetch().

Initially Ajax was implemented using the XMLHttpRequest interface, but the fetch() API is more suitable for modern web applications: it is more powerful, more flexible, and integrates better with fundamental web app technologies such as service workers.

Source: mdn web docs

Request with fetch()

Mozilla has an excellent article with an example on how to fetch data using the fetch() API.

Below is an example of a fetch() request that sends a JSON PUT request to the server.

fetch('url', {
    method: 'PUT',
    headers: {"Content-type": "application/json"},
    body: JSON.stringify({key: 'value'})
}).then(response => {
    if (!response.ok) {
        // Throw error so it can be caught in catch block
        throw new Error('Response status: ' + response.status);
    }
    // Returns promise which resolves to the response body as JSON
    return response.json();
});

Ajax helper functions

The slim-example-project has helper functions to send CRUD requests to the server with the correct headers and method. They return a promise that resolves to the JSON data.

If the request fails, the fail-handler.js displays a flash message to the user with the appropriate error message.
Then, an exception is thrown so that it can be caught in a catch block.

The catch block is not implemented in the functions that make the Ajax request, so that the calling function can implement it in case there is some logic to be executed when the request fails.

Fail handler

The fail handler goes through the response and informs the user about the error.

The behaviour of the fail handler and the information in the flash message differs depending on the status code. Here is a list of common status codes:

  • 401 Unauthorized: The user is not authenticated. The user is redirected to the login page with the redirect back url from the current page in the query string.
  • 403 Forbidden: The user is authenticated but does not have the required privileges. The flash message informs the user about the missing privileges.
  • 404 Not Found: The requested resource was not found. The URL is invalid. The user is informed with a flash message.
  • 422 Unprocessable Entity: The request body contains invalid form data. The error for each field is displayed in the form and no flash message is displayed.
  • 500 Internal Server Error: There was an error on the server. The user is asked to try again and report the error.

For the other error status codes, a flash message is shown with the status code and the status text.

Fetch data - GET request

This fetchData() helper function can be used to fetch data from the server.

It sends a GET request to the server and returns a promise that resolves to the response body as JSON.

Usage example

The only parameter is the route after the base path (e.g. users/1 or users?param=1).

fetchData('users?param=1')
    .then(jsonResponse => {
        // Code
    })
    .catch(error => {
        console.error(error);
    });

Ajax function

File: public/assets/general/ajax/fetch-data.js

import {basePath} from "../general-js/config.js";
import {handleFail} from "./ajax-util/fail-handler.js";

/**
 * Sends a GET request and returns result in promise
 *
 * @param {string} route the part after base path (e.g. 'users/1'). Query params have to be added with ?param=value
 * @return {Promise<details>}
 */
export function fetchData(route) {
    return fetch(basePath + route, {method: 'GET', headers: {"Content-type": "application/json"}})
        .then(async response => {
            if (!response.ok) {
                await handleFail(response);
                throw response;
            }
            return response.json();
        });
    // Without catch block to let the calling function implement it
}

Submit update - PUT request

The submit update function sends a PUT request to the server with the given form data.

  • The first parameter is an object with the form field names as keys and the values as values
  • The second parameter is the route after the base path (e.g. users/1)
  • The third parameter is optional for the field id of the field that should display the validation error message in case the request fails with a 422 Unprocessable Entity status code.

This function is designed to submit one value at a time. It only supports the validation error placement for one field.

More complex forms in modal boxes use the submitModalForm() function.

Usage example

let select = fieldContainer.querySelector('select');

select.addEventListener('change', () => {
    submitUpdate(
        // In square brackets to use the value of the variable as key
        {[select.name]: select.value},
        `users/1`,
    ).then(responseJson => {
        // Code
    }).catch(error => {
        console.error(error);
    });
});

Ajax function

File: public/assets/general/ajax/submit-update-data.js

import {getFormData, toggleEnableDisableForm} from "../page-component/modal/modal-form.js";
import {basePath} from "../general-js/config.js";
import {handleFail} from "./ajax-util/fail-handler.js";
import {closeModal} from "../page-component/modal/modal.js";

/**
 * Send PUT update request.
 * Fail handled by handleFail() method which supports forms
 * On success validation errors are removed if there were any and response JSON returned
 *
 * @param {object} formFieldsAndValues {field: value} e.g. {[input.name]: input.value}
 * @param {string} route after base path (e.g. clients/1)
 * @param domFieldId field id to display the validation error message for the correct field
 * @return Promise with as content server response as JSON
 */
export function submitUpdate(formFieldsAndValues, route, domFieldId = null) {

    return fetch(basePath + route, {
        method: 'PUT',
        headers: {"Content-type": "application/json"},
        body: JSON.stringify(formFieldsAndValues)
    })
        .then(async response => {
            if (!response.ok) {
                await handleFail(response, domFieldId);
                throw new Error('Response status not 2xx. Status: ' + response.status);
            }
            // Remove validation error messages if there are any
            removeValidationErrorMessages();
            return response.json();
        });
}

Submit modal form - POST or PUT request

In the slim-example-project, all forms except the login form are in modal boxes, but the Ajax function can easily be adapted to support other use-cases.

The process of submitting a form in a modal box is always the same:

  • Check if the form is valid
  • Serialize form data
  • Disable form fields while request is being sent
  • Send request to server
  • Close the modal box on success
  • Show errors if request failed and enable form fields

The submitModalForm() function executes all these steps and returns a promise that resolves to the response body as JSON.

These are the parameters:

  1. HTML id of the form (to check the validity of the fields and retrieve the form data)
  2. Route after the base path (e.g. users)
  3. HTTP method (POST or PUT)

Usage example

submitModalForm('create-user-modal-form', 'users', 'POST')
    .then((responseJson) => {
        // Inform user about success
        displayFlashMessage('success', 'User created successfully');
        // Reload user list
        loadUserList();
    }).catch(error => {
        console.error(error);
    })

Ajax function

File: public/assets/general/ajax/submit-modal-form.js

import {getFormData, toggleEnableDisableForm} from "../page-component/modal/modal-form.js";
import {basePath} from "../general-js/config.js";
import {handleFail} from "./ajax-util/fail-handler.js";
import {closeModal} from "../page-component/modal/modal.js";

/**
 * Retrieves form data, checks form validity, disables form, submits modal form and closes it on success
 *
 * @param {string} modalFormId
 * @param {string} moduleRoute POST module route like "users" or "clients"
 * @param {string} httpMethod POST or PUT
 * @return Promise with as content server response as JSON
 */
export function submitModalForm(
    modalFormId, moduleRoute, httpMethod
) {
    // Check if form content is valid (frontend validation)
    let modalForm = document.getElementById(modalFormId);
    if (modalForm.checkValidity() === false) {
        // If not valid, report to user and return void
        modalForm.reportValidity();
        // If nothing is returned "then()" will not exist; add "?" before the call: submitModalForm()?.then()
        return;
    }

    // Serialize form data before disabling form elements
    let formData = getFormData(modalForm);

    // Disable form to indicate that the request is made
    // This has to be after getting the form data as FormData() doesn't consider disabled fields
    toggleEnableDisableForm(modalFormId);

    return fetch(basePath + moduleRoute, {
        method: httpMethod,
        headers: {"Content-type": "application/json"},
        body: JSON.stringify(formData)
    })
        .then(async response => {
            if (!response.ok) {
                // Re enable form if request is not successful
                toggleEnableDisableForm(modalFormId);
                // Default fail handler
                await handleFail(response);
                // Throw error so it can be caught in catch block
                throw new Error('Response status: ' + response.status);
            }
            closeModal();
            return response.json();
        });
}

Submit delete - DELETE request

To delete a resource, the submitDelete() function can be used.

It accepts the route after the base path (e.g. users/1) as parameter and returns a promise that resolves to the response body as JSON.

Usage example

document.querySelector('#delete-client-btn')?.addEventListener('click', () => {
    if(confirm('Are you sure that you want to delete this client?')){
        submitDelete(`clients/1`).then(() => {
            // Redirect to client list page if request was successful
            location.href = `clients/list`;
        });
    };
});

Ajax function

File: public/assets/general/ajax/submit-delete-request.js

import {basePath} from "../general-js/config.js";
import {handleFail} from "./ajax-util/fail-handler.js";

/**
 * Send DELETE request.
 *
 * @param {string} route after base path (e.g. 'users/1')
 * @return Promise with as content server response as JSON
 */
export function submitDelete(route) {
    return fetch(basePath + route, {
        method: 'DELETE',
        headers: {"Content-type": "application/json"}
    })
        .then(async response => {
            if (!response.ok) {
                await handleFail(response);
                // Throw error so it can be caught in catch block
                throw new Error('Response status: ' + response.status);
            }
            return response.json();
        });
}

Contenteditable Fields

Idea

I asked myself what would be the most user-friendly way to edit a text field that is part of the page content such as the name of a user and their email on the profile page.

Such information may be displayed as headers, text content or clickable links or buttons, and authorized users should be able to edit these values easily with minimal UI changes.

My first thought was to display an "edit" icon next to each field that can be edited. When the user clicks on the icon, the text span is replaced by an input field and the edit icon replaced by a save icon.

I stuck with the idea but disliked the UI change from the span, heading or other HTML element to the input field.
The solution seemed to be the contenteditable attribute because it makes any HTML element editable while keeping the exact same style.

It should still be clear when a field is currently editable, so an indication such as an added border and slight background color change is needed, but the text style doesn't change.

To keep the user interface lean, the edit button can be hidden until the mouse hovers over the field. To facilitate the modification, a double click on the field also makes it editable.

On mobile, the edit button should always be visible as there is no mouse, and the user might not know that the field is editable before tapping on it.

Design preview

Hover over the field (h1)

Editing a field value (h1)

Hover over edit icon (span)

Contenteditable usage example

HTML

The file contenteditable-main.js supports by default span and h1 as editable fields.
They must be wrapped in a container div with the class contenteditable-field-container and the attribute data-field-element with the HTML tag name of element that should be editable ("span" or "h1").

Then, the edit icon (class contenteditable-edit-icon) must be added before the editable element as the style of the field is changes on hover over the edit icon and CSS only supports next sibling styling (not previous).

The editable element itself has a data-name attribute which acts like the name of an input or other form element. This is the key that is being sent to the server when the field is updated.

It can also have data attributes for frontend validation. Currently, contenteditable-main.js supports data-required, data-minlength and data-maxlength.

When there is no content, the hoverable area to display an edit icon is quite small. Therefore, a non-breaking space &nbsp; should be added as content if the field is empty.

The examples below use PHP-View to load the data, and the edit icon is added only if the user has update privilege for the field but this isn't required.

Text field

<?php // Template file ?>

<div class="contenteditable-field-container user-field-value-container" data-field-element="span">
    <?php // Optional add edit icon to DOM if user has update privilege for this field
    if (str_contains($user->generalPrivilege, 'U')) { ?>
        <!-- Img has to be before title because only the next sibling can be styled in css -->
        <img src="assets/general/general-img/material-edit-icon.svg"
             class="contenteditable-edit-icon cursor-pointer"
             alt="Edit"
             id="edit-email-btn">
        <?php
    } ?>
    <!-- Contenteditable field -->
    <span spellcheck="false" data-name="email" data-maxlength="254"
    ><?= !empty($user->email) ? html($user->email) : '&nbsp;' ?></span>
</div>

Heading

Headings are a bit more tricky because they have a bottom margin that doesn't look good when the text wraps.
Additionally, if two editable headings are next to each other, they need a little bit of space between them for the edit icon on hover.

This is why each editable heading is in a div with the class inner-contenteditable-heading-div.
The inner contenteditable heading divs are wrapped in the container outer-contenteditable-heading-container which adds the bottom margin.

<?php // Template file ?>

<div id="outer-contenteditable-heading-container" data-deleted="<?= $clientAggregate->deletedAt ? 1 : 0 ?>">
    <div class="inner-contenteditable-heading-div contenteditable-field-container" data-field-element="h1">
        <?php // Optional add edit icon to DOM if user has update privilege for this field
        if (str_contains($clientAggregate->generalPrivilege, 'U')) { ?>
            <!-- Img has to be before title because only the next sibling can be styled in css -->
            <img src="assets/general/general-img/material-edit-icon.svg"
                 class="contenteditable-edit-icon cursor-pointer"
                 alt="Edit"
                 id="edit-first-name-btn">
            <?php
        } ?>
        <h1 data-name="first_name" data-minlength="2" data-maxlength="100" spellcheck="false"><?=
            !empty($clientAggregate->firstName) ? html($clientAggregate->firstName) : '&nbsp;' ?></h1>
    </div>
    
    <!-- Other editable headings if needed... -->
    
</div>

JavaScript

Event listeners

The listeners for the edit icon click, and double-click events are added in a JavaScript file loaded with the page.

// Null safe operator `?` as edit icon doesn't exist if not privileged
// Heading
document.querySelector('#edit-first-name-btn')?.addEventListener('click', makeUserFieldEditable);
document.querySelector('h1[data-name="first_name"]')?.addEventListener('dblclick', makeUserFieldEditable);
// Span
document.querySelector('#edit-email-btn')?.addEventListener('click', makeUserFieldEditable);
document.querySelector('[data-name="email"]')?.addEventListener('dblclick', makeUserFieldEditable);

Handler to make field editable

There must be a custom event handler for each page implementing contenteditable fields
to make the correct Ajax request on submit.

The event handler calls the general function makeFieldEditable and adds the focusout event listener which triggers the save function.

export function makeUserFieldEditable() {
    // "this" is the edit icon or the field itself
    let field = this.parentNode.querySelector(this.parentNode.dataset.fieldElement);

    // Make field editable, add save button, add enter key press event listener and focus it
    makeFieldEditable(field);

    // Save field value on focus out
    // The save btn event listener is not needed as by clicking on the button the focus 
    // goes out of the edited field
    field.addEventListener('focusout', validateContentEditableAndSaveUserValue);
}

Save changes

Upon clicking outside the editable field, the field value should be validated and saved.

For the user read page in this example, this is done by the validateContentEditableAndSaveUserValue and saveUserValueAndDisableContentEditable functions below.

validateContentEditableAndSaveUserValue is called on focusout and immediately validates the field value by calling the general function contentEditableFieldValueIsValid.

If the value is invalid, a red error message is displayed right below the concerned field and the focus is locked on the field until the input is valid.

If frontend validation succeeds, any error that might have been displayed previously is removed, and the function saveUserValueAndDisableContentEditable sends a PUT request to the server and disables contenteditable.

function validateContentEditableAndSaveUserValue() {
    // "this" is the field
    if (contentEditableFieldValueIsValid(this)) {
        // Remove validation error messages if any
        removeValidationErrorMessages();
        // Disable contenteditable and save user value
        saveUserValueAndDisableContentEditable(this);
    } else {
        // Lock the focus on the field until the input is valid
        this.focus();
    }
}

function saveUserValueAndDisableContentEditable(field) {
    disableEditableField(field);
    let userId = document.getElementById('user-id').value;
    let submitValue = field.textContent.trim();

    // Make PUT request to update user value
    submitUpdate(
        {[field.dataset.name]: submitValue},
        `users/${userId}`
    ).then(responseJson => {
        // Field disabled before save request and re enabled on error
    }).catch(errorMsg => {
        // If error message contains 422 in the string, make the field editable again
        if (errorMessage.includes('422')) {
            makeFieldEditable(field);
            return;
        }
  
        // If it's a server error, let the user read the error flash message and reloaded the page in 3 seconds
        setTimeout(() => {
            location.reload();
        }, 3000);
    });
}

Link- and select-field

The "usage example" section above shows a basic example of how the general contenteditable functions from contenteditable-main.js can be used with a <h1> or <span> but a field could also be a button opening a link or a <select> field.

The makeClientFieldEditable function in the public/assets/client/update/client-update-contenteditable.js file contains an example of a span that is an <a> tag when not editable.

And makeFieldSelectValueEditable in public/assets/client/update/client-update-dropdown.js showcases and example of a field value that can be changed via a <select> dropdown.

General contenteditable functions

The main functions used by all the different pages that use contenteditable fields are in contenteditable-main.js. The file contains functions to enable, disable and validate field values.

JS file: public/assets/general/page-component/contenteditable/contenteditable-main.js

import {displayValidationErrorMessage} from "../../validation/form-validation.js";

/**
 * Make field value editable, add save button and focus it.
 */
export function makeFieldEditable(field) {
    let editIcon = field.parentNode.querySelector('.contenteditable-edit-icon');
    let fieldContainer = field.parentNode;

    // Hide edit icon, make field editable, focus it and remove &nbsp; if empty
    editIcon.style.display = 'none';
    field.contentEditable = 'true';
    field.focus();
    if (field.innerHTML === '&nbsp;') {
        field.innerHTML = '';
    }

    // Slick would be to replace the word "edit" of the edit icon with "save" for the save button but that puts a dependency
    // on the id name that can be avoided when just appending a word
    let saveBtnId = editIcon.id + '-save';
    // Add save button if not already existing but hidden until an input is made
    if (document.querySelector('#' + saveBtnId) === null) {
        fieldContainer.insertAdjacentHTML('afterbegin', `<img src="assets/general/general-img/checkmark.svg"
                                                      class="contenteditable-save-icon cursor-pointer" alt="Save"
                                                      id="${saveBtnId}" style="display: none">`);
    }
    let saveBtn = document.getElementById(saveBtnId);

    // Save on enter key press
    fieldContainer.addEventListener('keypress', function (e) {
        // Save on enter keypress or ctrl enter / cmd enter
        if (e.key === 'Enter' || (e.ctrlKey || e.metaKey) && (e.keyCode === 13 || e.keyCode === 10)) {
            // Prevent new line on enter key press
            e.preventDefault();
            // Triggers focusout event that is caught in event listener and saves client value
            // field.contentEditable = 'false';
            field.dispatchEvent(new Event('focusout'));

        }
    });
    // Display save button after the first input
    fieldContainer.addEventListener('input', () => {
        if (saveBtn.style.display === 'none') {
            saveBtn.style.display = 'inline-block';
        }
    });
}

export function disableEditableField(field) {
    let fieldContainer = field.parentNode;

    // If empty submit value successfully submitted, and it doesn't have data-hide-if-empty="true",
    // add a &nbsp; for it to be visible on hover and edited later
    if (field.textContent.trim() === '' && fieldContainer.dataset.hideIfEmpty !== 'true') {
        fieldContainer.querySelector(fieldContainer.dataset.fieldElement).innerHTML = '&nbsp;';
    }
    field.contentEditable = 'false';
    fieldContainer.querySelector('.contenteditable-edit-icon').style.display = null; // Default display
    // I don't know why but the focusout event is triggered multiple times when clicking on the edit icon again
    let saveIcon = fieldContainer.querySelector('.contenteditable-save-icon');

    // Only remove it if it exists to prevent error in case field was unchanged and save icon not displayed
    saveIcon?.remove();
}

/**
 * Frontend validation of contenteditable field
 * and request to update value if valid.
 *
 * @return boolean
 */
export function contentEditableFieldValueIsValid(field) {
    let textContent = field.textContent.trim();
    let fieldName = field.dataset.name;

    let required = field.dataset.required;
    if (required !== undefined && required === 'true' && textContent.length === 0) {
        displayValidationErrorMessage(fieldName, 'Required');
        return false;
    }

    // Check that length is either 0 or more than given minlength (0 is checked with required above)
    let minLength = field.dataset.minlength;
    if (minLength !== undefined && (textContent.length < parseInt(minLength) && textContent.length !== 0)) {
        displayValidationErrorMessage(fieldName, 'Minimum length is' + ' ' + minLength);
        return false;
    }

    // Check that length is either 0 or more than given maxlength
    let maxLength = field.dataset.maxlength;
    if (maxLength !== undefined && (textContent.length > parseInt(maxLength) && textContent.length !== 0)) {
        displayValidationErrorMessage(fieldName, 'Maximum length is' + ' ' + maxLength);
        return false;
    }

    // If no validation error was found
    return true;
}

CSS file: public/assets/general/page-component/contenteditable/contenteditable.css

/* mobile first min-width sets base and content is adapted to computers. */
@media (min-width: 100px) {
    #outer-contenteditable-heading-container {
        margin-bottom: 25px;
    }

    #outer-contenteditable-heading-container h1 {
        display: inline-block;
        /*Remove bottom margin on h1 and put it on h1 container in case first and last name wrap*/
        margin-bottom: 0;
        padding: 5px 5px 5px 3px;
        overflow-wrap: anywhere;
        white-space: break-spaces;
    }

    #outer-contenteditable-heading-container[data-deleted="1"] h1 {
        color: orangered;
    }

    /*Clear float*/
    #outer-contenteditable-heading-container::after {
        content: "";
        clear: both;
        display: table;
    }

    /*Div containing first or last name header*/
    .inner-contenteditable-heading-div {
        float: left; /* Prevent not hoverable whitespace between partial header divs*/
    }

    .contenteditable-field-container {
        position: relative;
        display: inline-block;
        padding-right: 15px;
    }


    .contenteditable-edit-icon, .contenteditable-save-icon {
        display: none;
        position: absolute;
        width: 20px;
        padding: 2px;
        border-radius: 99px;
        border: 1px solid black; /* The actual color is set by the filter*/
        /* The filter here is so that the background is always correct (even if there is no filter otherwise) */
        /*filter: invert(20%) sepia(9%) saturate(2106%) hue-rotate(172deg) brightness(93%) contrast(86%);*/
        filter: var(--primary-color-accent-filter);
        background: rgba(93, 87, 29, 0.18); /* This is a recreation of this color #d8dee8; with the filter */
        right: -7px;
        top: -3px;
        z-index: 1;
    }

    .contenteditable-field-container:hover .contenteditable-edit-icon, .always-displayed-icon {
        display: inline-block;
    }

    /* Style next sibling https://stackoverflow.com/a/12574836/9013718 (~ works better than + actually as it doesn't
        have to be immediate next sibling. LanguageTool extension puts a <lt-highlighter> element before h1) */
    /* Display outline on h1 when hover on edit icon and when contenteditable is true */
    .contenteditable-edit-icon:hover ~ h1, .inner-contenteditable-heading-div h1[contenteditable="true"] {
        outline: 3px solid var(--primary-color);
        border-radius: 10px;
        background: var(--background-accent-color);
    }

    /* Display outline on span element */
    .contenteditable-edit-icon:hover ~ span, .contenteditable-field-container span[contenteditable="true"] {
        outline: 2px solid var(--primary-color);
        border-radius: 5px;
        background: var(--background-accent-color);
    }

    .contenteditable-placeholder[contenteditable=true]:empty:before {
        content: attr(data-placeholder);
        color: gray;
    }
}
Clone this wiki locally