javascriptasynchronouslazy-loadingselectors-apievent-delegation

How to create unobstrusive JavasScript when there are hundreds of entries


I read about unobstrusive JavaScript and how Javascript should be not explicitly placed inside HTML code. Fine! But I have these hundreds of search results where I placed click events in the HTML structure because the function is the same, but the arguments passed are totally different from one entry to the next. For example:

HTML:

<button id="button_details_0"
  class="search_details__button"
  onclick="get_details('002124482_2242.jpg','4','0','Brasil, São Paulo, Atibaia, Inventários e Testamentos, Parte A')">
  <span id="search_details_span_0">˅</span>
</button>

Also, when I open the details of a particular entry, the user has the option to get more information. Then, I call another function with even more different options.

The easy way of doing this is to add onclick in the HTML when the page is generated by Perl CGI. If I want to be true to unobstrusive Javascript, what do I place in the HTML to pass all this information to Javascript besides creating a form for each entry?

I tried moving the Javascript out of the HTML, but the new created details have new buttons. The progression is an entry, for example, with barebone christening data, then you click on details and more info about parents, godparents, clergy will appear. Then each name of the parents, godparents, clergy can be clicked on to obtain even more data. The initial data is presented:

HTML:

<button id="search_details_button_12" class="search_details__button" data-image_number="58" data-record_number="6" data-row_number="12" data-locality="Brasil, São Paulo, Atibaia, São João Batista, Batismos 1719-1752">
  <span id="search_details_span_12">˅</span>
</button>

Then I have this at the end of the HTML so I assure the page is loaded fully: JS:

> // When user clicks on details of search
>     const search_details_buttons = document.querySelectorAll('[id^=search_details_button');  
> console.log(search_details_buttons);   for (let j = 0; j < 
> search_details_buttons.length; j++) {
>       search_details_buttons[j].addEventListener('click', e => {
>         get_details(search_details_buttons[j]);
>       });

}

Then in the details area (after it is clicked), I get these buttons to which I want to addEventListeners:

<button id="details_ascend_button_11_1_1" class="details_ascend__button" data-row="11" data-role="1" data-individual="1">
  <span class="details_ascend__span">↑</span>
</button>

But these new buttons in details are not created when the page is loaded but after the user clicks on details.

Arrow 1 if clicked opens an window like the one below. Then if the upper arrow is clicked then I will create more details

If Arrow 1 is clicked after details shows then the innerHTML changes back to the "barebone" information and the new created buttons disappears until clicked details is clicked again.

Where do I check for the existence of these new buttons and add event handlers?

Thanks you!


Solution

  • One should follow the already given advice of utilizing both, data-* global attributes and each its related dataset property.

    A less verbose implementation, than the ones already provided, would go with just a single data-* attribute like data-details-params which serves as configuration for all of the get_details function's parameters and as selector for querying any of the OP's buttons.

    I addition, neither markup nor code rely on id's; the only thing necessary, is the reference of the triggering button.

    function get_details(currentTarget, ...args) {
      // - user implementation which in addition
      //   gets passed the current event target.
      console.log({ args, currentTarget });
    }
    
    function getSanitizedArgumentsFromParamsConfig(params) {
      return params
        .split(/'\s*,\s*'/g)
        .map(param => param.replace(/^'|'$/, ''));
    }
    function handleDetailsQuery({ currentTarget }) {
      const args = getSanitizedArgumentsFromParamsConfig(
        currentTarget.dataset.detailsParams
      );
      get_details(currentTarget, ...args);
    }
    
    document
      .querySelectorAll('[data-details-params]')
      .forEach(elm =>
        elm.addEventListener('click', handleDetailsQuery)
      );
    button.query-details {
      background-color: #fff;
      border: 1px solid rgb(0, 102, 204);
      cursor: pointer;
    }
    button.query-details:hover {
      background-color: rgba(0, 102, 204, 20%);
    }
    button.query-details > span {
      position: relative;
      display: inline-block;
      width: 1em;
      height: 1em;
      overflow: hidden;
      text-indent: 1.1em;
      color: rgb(0, 102, 204);
    }
    button.query-details > span:after {
      content: "˅";
      position: absolute;
      left: 0;
      top: 1px;
      width: 1em;
      text-indent: 0;
    }
    
    .as-console-wrapper {
      max-height: 85%!important;
    }
    body { margin: 0; }
    <button
      type="button"
      title="get details"
      class="query-details"
      data-details-params="'002124482_2242.jpg','4','0','Brasil, São Paulo, Atibaia, Inventários e Testamentos, Parte A'"
    >
      <span>get details</span>
    </button>
    
    <button
      type="button"
      title="get details"
      class="query-details"
      data-details-params="'002124482_9999.jpg','6','9','Brasil, São Paulo, Atibaia, Inventários e Testamentos, Parte B'"
    >
      <span>get details</span>
    </button>
    
    <button
      type="button"
      title="get details"
      class="query-details"
      data-details-params="'002124482_6666.jpg','8','2','Brasil, São Paulo, Atibaia, Inventários e Testamentos, Parte C'"
    >
      <span>get details</span>
    </button>

    Edit ... taking into account following of the OP's comment ...

    A side effect of using datasets in this case is that the newly created "details" which is just a change of innerHTML inside a table has new buttons. These new buttons now need to have an event listener attached to them. They didn't exist before the "details" were shown. I was trying to attach the event listener to the new buttons after the innerHTML is executed. I am not able to select the new buttons with querySelectorAll. Is that the right place to do it? – Marcos Camargo

    As already mentioned, and especially for the OP's problem/requirements, the event-handling has to be changed to event-delegation exclusively.

    Additionally one should store every fetched markup into a WeakMap instance. Thus, one has to make any button-related detail-query API-call exactly once.

    The above provided code example then might get refactored to something similar to the next provided solution ...

    function fetchContent() {
      return `
        <div class="details">
          Lorem ipsum dolor sit amet
          <button type="button" class="close-details" title="close details">
            <span>close details</span>
          </button>
        </div>`;
    }
    async function mockedFetch(args) {
      console.log({ args });
    
      return new Promise(
        resolve => setTimeout(resolve, 1_500, fetchContent()),
        // reject => { /* handle reject */ },
      );
    }
    
    // - a map based storage for every fetched content.
    const detailContentStorage = new WeakMap;
    
    async function get_details(getDetailsButton, ...args) {
      // - user implementation which in addition
      //   gets passed the detail fetching button.
    
      // - guard which prevents re-fetching while
      //   a previous fetch is still pending.
      if (getDetailsButton.matches('.pending-fetch')) {
        return;
      }
      getDetailsButton.classList.add('pending-fetch');
    
      console.log({ getDetailsButton });
    
      let detailsMarkup
        = detailContentStorage.get(getDetailsButton);
    
      if (!detailsMarkup) {
    
        // - mocked API call which returns
        //   a specific detail's content.
        detailsMarkup = await mockedFetch(args);
    
        console.log({ detailsMarkup });
    
        detailContentStorage.set(getDetailsButton, detailsMarkup);
      }
      getDetailsButton
        .closest('td')
        .appendChild(
          new DOMParser()
            .parseFromString(detailsMarkup, "text/html")
            .body
        );
    
      getDetailsButton.classList.remove('pending-fetch');
    }
    
    function getSanitizedArgumentsFromParamsConfig(params) {
      return params
        .split(/'\s*,\s*'/g)
        .map(param => param.replace(/^'|'$/, ''));
    }
    
    function handleDetailsQuery(getDetailsButton) {
      const args = getSanitizedArgumentsFromParamsConfig(
        getDetailsButton.dataset.detailsParams
      );
      get_details(getDetailsButton, ...args);
    }
    function closeDetailsContent(elmCloseDetails) {
      console.log({ elmCloseDetails });
    
      elmCloseDetails
        .closest('td')
        .querySelector('.details')
        .remove();
    }
    
    // - handle any detail related behavior via event-delegation.
    function handleDetail({ target }) {
      const whichButtonElm = target.closest('button');
    
      if (whichButtonElm?.matches('[data-details-params]')) {
    
        handleDetailsQuery(whichButtonElm);
    
      } else if (whichButtonElm?.matches('.close-details')) {
    
        closeDetailsContent(whichButtonElm);
      }
    }
    
    // - register exactly one handler by subscribing
    //   to any 'click' event of the single queried element.
    document
      .querySelector('table.overview tbody')
      .addEventListener('click', handleDetail);
    table.overview {
      width: 49%;
    }
    table.overview tbody td,
    table.overview tbody td .details {
      position: relative;
      padding: 30px 40px 10px 10px;
    }
    table.overview tbody td .details {
      position: absolute;
      top: 0;
      right: 0;
      color: #fff;
      background-color: rgba(0, 102, 204, 90%);
    }
    
    table.overview button.query-details,
    table.overview button.close-details {
      position: absolute;
      top: 10px;
      right: 10px;
    }
    
    button.query-details,
    button.close-details {
      background-color: #fff;
      border: 1px solid rgb(0, 102, 204);
      cursor: pointer;
    }
    button.query-details:hover,
    button.close-details:hover {
      background-color: rgba(0, 102, 204, 20%);
    }
    .details button.close-details:hover {
      background-color: rgba(255, 255, 255, 80%);
    }
    
    button.query-details > span,
    button.close-details > span {
      position: relative;
      display: inline-block;
      width: 1em;
      height: 1em;
      overflow: hidden;
      text-indent: 1.1em;
      color: rgb(0, 102, 204);
    }
    button.query-details > span:after,
    button.close-details > span:after {
      content: "˅";
      position: absolute;
      left: 0;
      top: 1px;
      width: 1em;
      text-indent: 0;
    }
    button.close-details > span:after {
      content: "x";
      top: -1px;
    }
    
    @keyframes rotate-while-fetching {
      from{
        transform: rotate(0deg);
      }
      to{
        transform: rotate(360deg);
      }
    }
    button.query-details.pending-fetch > span:after {
      content: "x";
      top: 0;
      animation: rotate-while-fetching 2s linear infinite;
      transform-origin: 49% 54%;
    }
    
    .as-console-wrapper {
      left: auto!important;
      right: 0;
      width: 50%;
      min-height: 100%;
    }
    body { margin: 0; }
    <table class="overview">
      <!--
      <thead>
      </thead>
      //-->
      <tbody>
        <tr>
          <td>
            foo bar baz, biz buzz booz
            <button
              type="button"
              title="get details"
              class="query-details"
              data-details-params="'002124482_2242.jpg','4','0','Brasil, São Paulo, Atibaia, Inventários e Testamentos, Parte A'"
            >
              <span>get details</span>
            </button>
          </td>
          <td>
            the quick brown fox
            <button
              type="button"
              title="get details"
              class="query-details"
              data-details-params="'002124482_9999.jpg','6','9','Brasil, São Paulo, Atibaia, Inventários e Testamentos, Parte B'"
            >
              <span>get details</span>
            </button>
          </td>
          <td>
            jumps over the lazy dog
            <button
              type="button"
              title="get details"
              class="query-details"
              data-details-params="'002124482_6666.jpg','8','2','Brasil, São Paulo, Atibaia, Inventários e Testamentos, Parte C'"
            >
              <span>get details</span>
            </button>
          </td>
        </tr>
      </tbody>
    </table>