Hype Template Grid


Turn layouts into grids

The Hype Template Grid extension for Tumult Hype simplifies the creation and management of grid-based layouts in your Hype projects. This extension allows you to define templates and customize grid settings such as columns, rows, width, and gap, making it easier to design dynamic and flexible layouts.

Key Features:

  • Template Definition:
    Define templates using HTML elements or objects. This allows you to create reusable design components that can be easily managed and updated across your project.

  • Customizable Grid Settings:
    Configure grid settings to suit your design needs, including the number of columns, rows, grid width, and gap size. This flexibility helps you create layouts that adapt to different screen sizes and design requirements.

  • Automatic Handling of IDs and Classes:
    The extension automatically manages element IDs and classes to prevent conflicts. When elements are cloned, their IDs can be removed or renamed to ensure unique identification, making it easier to manage complex layouts without ID conflicts.

  • Attach Multiple Instances:
    Easily attach multiple instances of templates to your scenes. You can specify how many times a template should repeat, making it simple to populate your grid with multiple copies of a template. The extension supports a reserved {index} variable that can be used within templates. This variable is substituted with the number of concurrent instances being generated, allowing for dynamic data integration. This feature works well with tools like Hype Reactive Content or Hyper Magic Data, making it easy to create catalogs and repeating lists.

  • Scene Change Management:
    This extension ensures that your grid layout works seamlessly across scene changes. It performs garbage collection to clean up templates and their settings, ensuring that everything functions correctly even if you switch scenes and come back.

  • Template Activation and Deactivation:
    The activation feature is an advanced function that allows for transferring IDs from one instance to another. This ensures that dynamically created instances remain controllable at runtime. By activating a specific instance of a template, you can ensure that the correct elements are targeted and manipulated, allowing for precise control over your design's behavior.

The Hype Template Grid extension enhances your ability to create responsive and organized grid layouts within Tumult Hype, making your design process more efficient and your projects more dynamic.


Preview:

/*!
 * Hype Template Grid v1.0.8
 * Copyright (2024) Max Ziebell (https://maxziebell.de). MIT-license
 */

/*
 * Version-History
 * 1.0.0 Initial release under MIT License
 * 1.0.1 Added support for templateName as an array
 * 1.0.2 Added support for grid columns, rows, and customizable grid width and gap
 * 1.0.3 Added support for object-based template definitions with custom columns and rows
 * 1.0.4 Cleanup and added data attribute overrides for options
 * 1.0.5 Refactored for namespaced template settings and new data attributes
 * 1.0.6 Added support for data-template-repeat and refactored to defineTemplate
 * 1.0.7 Added option to remove or rename Hype IDs
 * 1.0.8 Added template activation and deactivation functionality using classes
 */

// Ensure the extension isn't redefined
if ("HypeTemplateGrid" in window === false) {
  window['HypeTemplateGrid'] = (function () {

    // Define default settings for the extension
    var _default = {
      gridSettings: 'display: grid; grid-template-columns: repeat(auto-fill, minmax({gridWidth}, 1fr)); gap: {gap};',
      gap: '10px',
      gridWidth: '100%',
      removeHypeIDs: false,
    };

    var _templates = {};

    function setDefault(key, value) {
      if (typeof key === 'object') {
        _default = Object.assign(_default, key);
      } else {
        _default[key] = value;
      }
    }

    function getDefault(key) {
      return key ? _default[key] : _default;
    }

    

    function createTemplateElement(templateElement, width, height, gridColumn, gridRow, options, templateName) {
      var wrapper = document.createElement('div');
      wrapper.style.width = width + 'px';
      wrapper.style.height = height + 'px';
      if (gridColumn) wrapper.style.gridColumn = gridColumn;
      if (gridRow) wrapper.style.gridRow = gridRow;

      var clone = templateElement.cloneNode(true);
      clone.removeAttribute('data-template-define');
      clone.setAttribute('data-template-instance', templateName); // Assign the template name

      clone.querySelectorAll('[id]').forEach(function(el) {
        if (options.removeHypeIDs) {
          el.removeAttribute('id');
        } else {
          if (!el.hasAttribute('data-id')) {
            el.setAttribute('data-id', el.getAttribute('id'));
          }
          el.removeAttribute('id');
        }
      });

      clone.style.position = 'relative';
      clone.style.transform = 'none';

      clone.querySelectorAll('*').forEach(function(el) {
        if (el.style.position === 'absolute') {
          el.style.position = 'absolute';
        }
      });

      wrapper.appendChild(clone);
      return wrapper;
    }

    function initializeTemplates(hypeDocument) {
      var templates = hypeDocument.querySelectorAll('[data-template-define]');
      templates.forEach(function(template) {
        var templateName = template.getAttribute('data-template-define');

        var removeHypeClasses = function(el) {
          var classes = el.classList;
          for (var i = 0; i < classes.length; i++) {
            if (classes[i].indexOf('HYPE_') === 0) {
              el.classList.remove(classes[i]);
            }
          }

          for (var i = 0; i < el.children.length; i++) {
            removeHypeClasses(el.children[i]);
          }
        };
        removeHypeClasses(template);

        if (templateName) {
          var templateData = {
            html: template.outerHTML,
            width: hypeDocument.getElementProperty(template, 'width'),
            height: hypeDocument.getElementProperty(template, 'height'),
            gridColumn: template.getAttribute('data-grid-column'),
            gridRow: template.getAttribute('data-grid-row')
          };
          template.style.display = 'none';
          if (!_templates[hypeDocument.documentId()]) {
            _templates[hypeDocument.documentId()] = {};
          }
          _templates[hypeDocument.documentId()][templateName] = templateData;

          var removeDataAttributes = function(el) {
            var attributes = el.attributes;
            for (var i = 0; i < attributes.length; i++) {
              if (attributes[i].name.indexOf('data-') === 0) {
                el.removeAttribute(attributes[i].name);
              }
            }

            for (var i = 0; i < el.children.length; i++) {
              removeDataAttributes(el.children[i]);
            }
          };
          removeDataAttributes(template);

          // Ensure the original template has a unique class and data-template-instance
          var originalInstanceClass = templateName + '-original';
          template.classList.add(originalInstanceClass);
          template.setAttribute('data-template-instance', templateName);
        }
      });
    }

    function attachTemplate(hypeDocument, target, templateArray, times, options) {
      options = options || {};

      var container = (typeof target === 'string') ? hypeDocument.querySelector(target) : target;
      if (!container) {
        console.error('Container to attach template not found.');
        return;
      }

      // Ensure container has only one grid
      var existingGrid = container.querySelector('div[style*="display: grid"]');
      if (!existingGrid) {
        existingGrid = document.createElement('div');
        container.appendChild(existingGrid);
      } else {
        existingGrid.innerHTML = '';
      }

      // Extend settings with container data attributes
      var settings = Object.assign({}, _default, options, {
        gap: container.getAttribute('data-grid-gap') || options.gap || _default.gap,
        gridWidth: container.getAttribute('data-grid-width') || options.gridWidth || _default.gridWidth,
        gridSettings: container.getAttribute('data-grid-settings') || options.gridSettings || _default.gridSettings
      });

      var allTemplates = Array.isArray(templateArray) ? templateArray : [templateArray];
      var templatesData = allTemplates.map(function(item) {
        var templateName = (typeof item === 'string') ? item : item.name;
        var templateData = _templates[hypeDocument.documentId()][templateName];
        if (!templateData) {
          console.error('Template with name ' + templateName + ' not found.');
          return null;
        }
        return {
          ...templateData,
          span: item.span,
          rows: item.rows
        };
      });

      if (templatesData.includes(null)) {
        return;
      }

      templatesData.forEach(function(templateData) {
        var gridSettings = settings.gridSettings.replace('{templateWidth}', templateData.width)
                                                 .replace('{templateHeight}', templateData.height)
                                                 .replace('{gap}', settings.gap)
                                                 .replace('{gridWidth}', settings.gridWidth);
        existingGrid.style = gridSettings;
      });

      for (var i = 0; i < times; i++) {
        allTemplates.forEach(function(item, index) {
          var templateName = (typeof item === 'string') ? item : item.name;
          var templateData = _templates[hypeDocument.documentId()][templateName];
          var templateElement = document.createElement('div');
         
          templateElement.innerHTML = templateData.html;
         
          templateElement = templateElement.firstElementChild;

          var gridColumn = item.span || templateData.gridColumn;
          var gridRow = item.rows || templateData.gridRow;
          var gridIndexOffset = item.indexOffset || 0;
          var gridIndex = i * allTemplates.length + index;

          var clone = createTemplateElement(templateElement, templateData.width, templateData.height, gridColumn, gridRow, settings, templateName);
          clone.innerHTML = clone.innerHTML.replace(/{index}/g, gridIndex + gridIndexOffset);
          clone.firstChild.classList.add('grid-item');
          clone.firstChild.setAttribute('data-grid-item-index', gridIndex);
          
          existingGrid.appendChild(clone);
        });
      }
    }

    function deactivateTemplates(hypeDocument, templateName) {
      var instances = document.querySelectorAll('[data-template-instance="' + templateName + '"]');
      instances.forEach(function(instance) {
        // Recursively deactivate all elements within this instance
        function deactivateElement(element) {
          if (element.hasAttribute('id')) {
            if (!element.hasAttribute('data-id')) {
              element.setAttribute('data-id', element.getAttribute('id'));
            }
            element.removeAttribute('id');
          }

          Array.from(element.children).forEach(child => {
            deactivateElement(child);
          });
        }

        deactivateElement(instance);
      });
    }

    function activateTemplate(hypeDocument, element) {
      if (_default.removeHypeIDs) {
        console.warn('Warning: Activation of templates does not work if removeHypeIDs is set to true.');
        return;
      }
      
      var templateName = element.getAttribute('data-template-instance');
      if (!templateName) {
        console.error('Element does not have a data-template-instance attribute.');
        return;
      }

      deactivateTemplates(hypeDocument, templateName);

      function activateElement(element) {
        if (element.hasAttribute('data-id')) {
          element.setAttribute('id', element.getAttribute('data-id'));
          element.removeAttribute('data-id');
        }

        Array.from(element.children).forEach(child => {
          activateElement(child);
        });
      }

      activateElement(element);
    }

    function assignTemplates(hypeDocument) {
      var containers = hypeDocument.querySelectorAll('[data-template-attach]');
      containers.forEach(function(container) {
        var templateNames = container.getAttribute('data-template-attach').split(',').map(function(name) {
          return name.trim();
        }).filter(function(name) {
          return name !== '';
        });

        var repeatCount = container.getAttribute('data-template-repeat') || 1;

        hypeDocument.attachTemplate(container, templateNames, repeatCount);
      });
    }

    function defineTemplate(hypeDocument, templateName, templateElement) {
      var templateData;
      if (typeof templateElement === 'string') {
        templateData = { html: templateElement };
      } else if (typeof templateElement === 'object' && templateElement.html) {
        templateData = templateElement;
      } else {
        templateData = {
          html: templateElement.outerHTML,
          width: hypeDocument.getElementProperty(templateElement, 'width'),
          height: hypeDocument.getElementProperty(templateElement, 'height'),
          gridColumn: templateElement.getAttribute('data-grid-column'),
          gridRow: templateElement.getAttribute('data-grid-row')
        };
      }

      if (!_templates[hypeDocument.documentId()]) {
        _templates[hypeDocument.documentId()] = {};
      }
      _templates[hypeDocument.documentId()][templateName] = templateData;
    }

    function HypeDocumentLoad(hypeDocument, element, event) {
      hypeDocument.attachTemplate = function(target, templateArray, times, options) {
        attachTemplate(hypeDocument, target, templateArray, times, options);
      };

      hypeDocument.defineTemplate = function(templateName, templateElement) {
        defineTemplate(hypeDocument, templateName, templateElement);
      };

      hypeDocument.querySelector = function(selector) {
        var currentScene = hypeDocument.getElementById(hypeDocument.currentSceneId());
        return currentScene.querySelector(selector);
      };

      hypeDocument.querySelectorAll = function(selector) {
        var currentScene = hypeDocument.getElementById(hypeDocument.currentSceneId());
        return currentScene.querySelectorAll(selector);
      };

      /* advanced usage */
      hypeDocument.activateTemplate = function(element) {
        activateTemplate(hypeDocument, element);
      };

      hypeDocument.deactivateTemplates = function(templateName) {
        deactivateTemplates(hypeDocument, templateName);
      };

      hypeDocument.closestTemplateElement = function(element) {
        var templateInstance = element.closest('[data-template-instance]');
        return templateInstance;
      };

      hypeDocument.closestTemplateName = function(element) {
        var templateInstance = element.closest('[data-template-instance]');
        return templateInstance ? templateInstance.getAttribute('data-template-instance') : null;
      };

      hypeDocument.closestTemplateIndex = function(element) {
        var templateInstance = element.closest('[data-template-instance]');
        return templateInstance ? templateInstance.getAttribute('data-grid-item-index') : null;
      };
    }

    function HypeScenePrepareForDisplay(hypeDocument, element, event) {
      
      initializeTemplates(hypeDocument);
      assignTemplates(hypeDocument);
  
      Object.keys(_templates[hypeDocument.documentId()] || {}).forEach(templateName => {
        var originalInstanceClass = templateName + '-original';
        var instance = document.querySelector('.' + originalInstanceClass);
        if (instance) {
          hypeDocument.activateTemplate(instance);
        }
      });
    }

    // Ensure the event listener is registered
    if ("HYPE_eventListeners" in window === false) {
      window.HYPE_eventListeners = Array();
    }
    window.HYPE_eventListeners.push({"type": "HypeDocumentLoad", "callback": HypeDocumentLoad});
    window.HYPE_eventListeners.push({"type": "HypeScenePrepareForDisplay", "callback": HypeScenePrepareForDisplay});

    return {
      version: '1.0.8',
      setDefault: setDefault,
      getDefault: getDefault,
    };

  })();
}

HypeTemplateGrid.hype.zip (84,6 KB)


My apologies for the oversight. Here is the corrected table with only the attributes that are actually used in the provided extension:

Data Attribute Description Example Value
data-template-define Defines the element as a template with a specific name. data-template-define="myTemplate"
data-template-attach Specifies the templates to attach to the container, separated by commas. data-template-attach="myTemplate"
data-template-repeat Specifies how many times the template should be repeated within the container. data-template-repeat="3"
data-grid-gap Sets the gap between grid items. Overrides the default gap setting. data-grid-gap="15px"
data-grid-width Sets the width of the grid container. Overrides the default gridWidth setting. data-grid-width="80%"
data-grid-column Defines the grid column span for the template. data-grid-column="span 2"
data-grid-row Defines the grid row span for the template. data-grid-row="span 1"
data-grid-settings Customizes the grid settings. Can override the default grid settings defined in the _default object. data-grid-settings="display: flex;"

hypeDocument.attachTemplate

Description: Attaches specified templates to the target container, repeating them a specified number of times with optional settings.

Parameters:

Parameter Type Description
target String or Element The container to attach the template to.
templateArray Array An array of template names or objects.
times Number The number of times to repeat the template.
options Object (optional) Additional options for customization.

hypeDocument.defineTemplate

Description: Defines a new template with the given name and element.

Parameters:

Parameter Type Description
templateName String The name of the template.
templateElement String or Object The template element or its HTML string.

hypeDocument.querySelector

Description: Searches the current scene for the specified selector and returns the first matching element.

Parameters:

Parameter Type Description
selector String The CSS selector to match the element.

hypeDocument.querySelectorAll

Description: Searches the current scene for the specified selector and returns all matching elements.

Parameters:

Parameter Type Description
selector String The CSS selector to match the elements.

hypeDocument.activateTemplate

Description: Activates the specified template element, restoring its IDs if they were previously removed.

Parameters:

Parameter Type Description
element Element The element to activate.

hypeDocument.deactivateTemplates

Description: Deactivates all instances of the specified template, removing their IDs.

Parameters:

Parameter Type Description
templateName String The name of the template to deactivate.

hypeDocument.closestTemplateElement

Description: Returns the closest template element to the specified element.

Parameters:

Parameter Type Description
element Element The reference element to find the closest template element.

hypeDocument.closestTemplateName

Description: Returns the name of the closest template to the specified element.

Parameters:

Parameter Type Description
element Element The reference element to find the closest template name.

hypeDocument.closestTemplateIndex

Description: Returns the grid item index of the closest template to the specified element.

Parameters:

Parameter Type Description
element Element The reference element to find the closest template index.

@MarkHunte did something similar in the past, if memory serves me correctly.

3 Likes

You probably mean the Clone Extension There are a few posts with some adaptions spotted around the forum. I use it a lot in my own stuff.
@jonathan Really wish Hype would introduce a native method though. It doesn't even need to do much (if you know what I mean :grinning:) apart from returning a specified number of cloned objects and which all are known to the hype runtime ever or the Hype object after initiation if the runtime has already run.

The objects would start of hidden.

It would then be up to you to manage them on the scene with a show() api and rest of the native api to control an element. The only thing that would possibly be something to think about is if an object has any actions on it or is a group with items that have actions.

In my extension I adapt things like click actions on the cloned element to use native JS onclick. But in this suggestion they would need to be able to include the native Hype actions of the original.

This implementation supports native actions, animations, and click handlers. However, it requires activation through hypeDocument.activateTemplate. This method transfers IDs from the original instance to the current one. Note that if an ID is transferred to another instance, you can only interact with one instance at a time. Therefore, it is important to reset the previous element to a desired state before transferring activation to the new instance.

Cheers, yes , used that trick in the past and your code as always is excellent in these considerations.

But ideally I feel we should be able to do this fully native with out this hack and allow multiple interactions.

1 Like