ui_elements_drawer.mjs

/**
### /ui/elements/drawer

The drawer element module exports the drawer method for the `mapp.ui.elements{}` library object.

@module /ui/elements/drawer
*/

/**
@function drawer

@description
The drawer method will create and return drawer element with a header and content.

@param {Object} params The configuration params for the drawer element.
@property {string} params.data_id The data-id for drawer element.
@property {HTML} params.header The header element[s].
@property {HTML} params.content The content element[s] for the drawer.
@property {boolean} [params.popout] Whether the drawer can be popped out into a dialog.
@property {boolean} [params.drawer] Optional flag to skip creation of the expandable container and/or header. Set to `false` creates header and content. Set to `null` creates content only.
@property {HTMLElement} [params.view] The layer view the drawer is in.
@returns {HTMLElement} The drawer element.
*/

export function drawer(params) {
  params.data_id ??= 'drawer';

  // Instead of a drawer a button to toggle a dialog should be returned.
  if (params.dialog) {
    return drawerDialog(params);
  }

  if (params.drawer === false) {
    // card with header
    params.drawer = mapp.utils.html.node`<div
      class="${'drawer ' + (params.class || '')}"
      data-id=${params.data_id}>
      <div class="header">${params.header}</div>
      <div class="content">${params.content}`;

    return params.drawer;
  }

  if (params.drawer === null) {
    // card with no header
    params.drawer = mapp.utils.html.node`<div
    data-id=${params.data_id}
    class="${params.class || ''}">
    <div class="content">${params.content}`;

    return params.drawer;
  }

  params.class = `drawer expandable ${params.class || ''}`;

  //Add a caret element if not already present in the header.
  const header = params.header?.template || params.header;
  const caret = header?.find?.((el) => {
    el = el?.template?.[0] || el;
    return el?.includes?.('caret') || el?.classList?.contains?.('caret');
  })
    ? ''
    : mapp.utils.html`
      <div class="notranslate material-symbols-outlined caret">`;

  params.drawer = mapp.utils.html.node`
  <div
    data-id=${params.data_id}
    class=${params.class}>
    <div
      class="header"
      onclick=${onClick}>
      ${params.header}
      ${caret}
    </div>
    <div class="content">
      ${params.content}
    </div>`;

  if (params.popout) {
    params.popout = {};

    //used for keeping track of where the content came from.
    params.originalTarget = params.drawer.querySelector('.content');

    params.popoutBtn = params.drawer
      .querySelector('.header')
      .querySelector('[data-id=popout-btn]');

    //Append the button if it is not present in the header already.
    if (!params.popoutBtn) {
      params.popoutBtn = mapp.utils.html.node`<button
      data-id="popout-btn"
      class="notranslate material-symbols-outlined">
      open_in_new`;

      //Identify the caret element.
      const beforeElement = params.drawer
        .querySelector('.header')
        .querySelector('.caret');

      //Add the popoutBtn to the drawer header.
      params.drawer
        .querySelector('.header')
        .insertBefore(params.popoutBtn, beforeElement);
    }

    params.popoutBtn.onclick = () => {
      //Hide the original drawer the popout came from.
      params.drawer.style.display = 'none';

      params.view = params.drawer.parentElement;
      popoutDialog(params);

      //If view is provided, check whether there is anything else in the view.
      if (params.view) {
        params.viewChildren = Array.from(params.view.children || []).filter(
          (el) => el.checkVisibility(),
        );
      }

      //Hide the caret and close the drawer if theres nothing else in the layer-view
      if (params.viewChildren && !params.viewChildren.length) {
        params.view.previousElementSibling.dispatchEvent(new Event('click'));
        params.view.parentElement.classList.add('empty');

        params.view.previousElementSibling
          .querySelector('.caret')
          .style.setProperty('display', 'none');
      }
    };
  }

  return params.drawer;

  /**
  @function onClick

  @description
  The [drawer] onClick event method will shortcircuit if the parentElement has the `empty` class.

  @param {Object} e The click event.
  */
  function onClick(e) {
    if (e.target.parentElement.classList.contains('empty')) return;

    e.target.parentElement.classList.toggle('expanded');
  }
}

/**
@function popoutDialog

@description
the popoutDialog creates the popout element for a drawer and appends the popout button to the header of the drawer.

@param {Object} params The configuration params for the popout element.
@property {string} params.data_id The data-id for popout element.
@property {HTML} params.header The header element[s].
@property {HTML} params.content The content element[s] for the popout.
@property {HTML} params.drawer The drawer element being popped out.
@property {HTML} params.originalTarget The element that holds the content within the drawer being popped out.
*/
function popoutDialog(params) {
  if (params.popout?.dialog) {
    //Reappend the content and header
    params.popout.node
      .querySelector('.content')
      .appendChild(
        mapp.utils.html
          .node`${Array.from(params.drawer.querySelector('.content').children)}`,
      );

    //The dialog closebtn is not part of params.header
    //Add it to the header when the dialog is shown
    params.popout.node
      .querySelector('header')
      .replaceChildren(
        mapp.utils.html
          .node`${Array.from(params.drawer.querySelector('.header').children)}${params.popout.minimizeBtn}${params.popout.closeBtn}`,
      );
    return params.popout.show();
  }

  // Spread params.popout object into defaults for custom config.
  params.popout = {
    data_id: `${params.data_id}-popout`,
    target: document.getElementById('Map'),
    height: 'auto',
    left: '5%',
    top: '0.5em',
    class: 'box-shadow popout',
    css_style: 'width: 300px; height 300px',
    containedCentre: true,
    contained: true,
    headerDrag: true,
    closeBtn: true,
    minimizeBtn: true,
    onClose: () => {
      //Hide the drawer being poppped out

      //Show the caret and open the drawer
      if (params.viewChildren && !params.viewChildren.length) {
        params.view.parentElement.classList.remove('empty');
        params.view.previousElementSibling.dispatchEvent(new Event('click'));

        params.view.previousElementSibling
          .querySelector('.caret')
          .style.removeProperty('display');
      }
      params.drawer.style.removeProperty('display');

      //Reappend the header and the content, without the minimizeBtn and closeBtn
      params.drawer
        .querySelector('.header')
        .replaceChildren(
          mapp.utils.html
            .node`${Array.from(params.popout.node.querySelector('header').children).filter((el) => !['close', 'minimize'].includes(el.dataset.id))}`,
        );

      params.originalTarget.appendChild(
        mapp.utils.html
          .node`${Array.from(params.popout.node.querySelector('.content').children)}`,
      );
    },
    ...params.popout,
  };

  mapp.ui.elements.dialog(params.popout);

  //Replace the children of the header and content elements
  params.popout.node
    .querySelector('header')
    .replaceChildren(
      mapp.utils.html
        .node`${Array.from(params.drawer.querySelector('.header').children)}${params.popout.minimizeBtn}${params.popout.closeBtn}`,
    );

  params.popout.node
    .querySelector('.content')
    .appendChild(
      mapp.utils.html
        .node`${Array.from(params.drawer.querySelector('.content').children)}`,
    );

  params.popout.view = params.popout.node.querySelector('.content');
}

/**
@function drawerDialog

@description
Creates the button for creating the dialog, which contains the content of the drawer.

A dialog configuration object with defaults will be created if the dialog property value is `true`.

Several options can be specified in the dialog:
```JSON
dialog: {
  btn_title: "My panel Dialog",
  btn_label: "Open my panel dialog",
  title: "My dialog title",
  icon: "thumbs_up" 
}
```

A dialog associated with a layer will be closed in the layer hideCallbacks methods.

A dialog will be displayed in the layer showCallbacks methods with the showOnLayerDisplay flag.

@param {object} params
@property {layer} params.layer A mapp layer associated with the dialog.
@property {object} params.dialog The configuration for the dialog.
@property {boolean} dialog.showOnLayerDisplay Show dialog when the layer display is toggled on.
@property {string} dialog.icon material-symbols-outlined icon to show in the button.
@property {string} dialog.btn_label Text shown in the button.
@property {string} dialog.btn_title Title text assigned to the button element.
@returns {HTMLElement} The button element to toggle the dialog display.
*/
export function drawerDialog(params) {
  if (params.dialog === true) {
    params.dialog = {};
  }

  params.dialog.btn_label ??= `Open dialog`;
  params.dialog.btn_title ??= params.dialog.btn_label;

  const icon =
    params.dialog.icon_name || params.dialog.btnIcon || params.dialog.icon;

  params.dialog.btn = mapp.utils.html.node`<button
    class="wide flat action multi_hover"
    data-id=${params.data_id}
    title=${params.dialog.btn_title}
    onclick=${() => {
      // classList.toggle resolves as true when the class is added.
      if (params.dialog.btn.classList.toggle('active')) {
        openDialog(params);
      } else {
        // The decorated dialog object has a close method.
        params.dialog.close();
      }
    }}>
    <span class="material-symbols-outlined notranslate">${icon}</span>
    ${params.dialog.btn_label}`;

  // Hide the dialog when the layer is hidden
  params.layer?.hideCallbacks?.push(() => {
    params.dialog.close?.();
  });

  //Show the dialog on layer display
  if (params.dialog.showOnLayerDisplay) {
    params.layer?.showCallbacks.push(() => {
      params.dialog.show?.();
    });
  }

  return params.dialog.btn;
}

/**
@function openDialog
@description
Instatiates the dialog for the drawer, or if the dialog already exists calls the dialog show function.

@param {object} params
@property {layer} params.layer The layer holding the configuration for the drawer.
@property {object} params.dialog Configuration for the dialog element.
@property {string} [dialog.title="Dialog"] Text shown in the dialog header.
@property {HTMLElement} [dialog.header] Header element foor the dialog.
*/
function openDialog(params) {
  params.layer?.show();

  if (params.dialog.show) {
    return params.dialog.show();
  }

  params.dialog.data_id ??= `${params.data_id}-dialog`;

  params.dialog.title ??= `Dialog`;

  params.dialog.header ??= mapp.utils.html`<h1>${params.dialog.title}`;

  // Spread params.dialog into defaults to allow for custom config.
  params.dialog = {
    target: document.getElementById('Map'),
    content: params.content,
    height: 'auto',
    left: '5em',
    top: '0.5em',
    class: 'box-shadow',
    css_style: 'min-width: 300px; width: 350px',
    containedCentre: true,
    contained: true,
    headerDrag: true,
    minimizeBtn: true,
    closeBtn: true,
    onClose: () => {
      // Toggle the active class on the button
      params.dialog.btn.classList.remove('active');
    },
    ...params.dialog,
  };

  mapp.ui.elements.dialog(params.dialog);
}