diff --git a/src/components/drawer/drawer.mjs b/src/components/drawer/drawer.mjs index fad32b1..77ab667 100644 --- a/src/components/drawer/drawer.mjs +++ b/src/components/drawer/drawer.mjs @@ -132,7 +132,7 @@ export class Drawer extends ComposableElement { // Create the div that will contain the drawer content const contentDiv = document.createElement('div'); - contentDiv.id = `${drawerId}-drawer-contents`; + contentDiv.id = `${config.drawerId}-drawer-contents`; contentDiv.classList.add('drawer-contents', 'hidden'); const contentSlotElem = document.createElement('slot'); diff --git a/src/components/spa-sidebar/README.md b/src/components/spa-sidebar/README.md new file mode 100644 index 0000000..e4c7866 --- /dev/null +++ b/src/components/spa-sidebar/README.md @@ -0,0 +1,2 @@ +# Single Page Appliction (SPA) Sidebar +Technically slightly misusing the term SPA here but the idea is that there are no loads between section. \ No newline at end of file diff --git a/src/components/spa-sidebar/spa-sidebar.ejs b/src/components/spa-sidebar/spa-sidebar.ejs new file mode 100644 index 0000000..2b6387e --- /dev/null +++ b/src/components/spa-sidebar/spa-sidebar.ejs @@ -0,0 +1,79 @@ + + <%# Because of complexities with web components and specifically more legacy support and the `createTemplateInJS` method, we need to pass the SPA sidebar data available in EJS to the JavaScript for the web component (easiest way was via a script tag) %> + + + + + + <% if (typeof slots !== 'undefined' && Object.keys(slots).length > 0) { %> + <%- slots %> + <% } else { %> + <% locals.sections.forEach(section => { %> +
+ <%- typeof section.linkContent !== 'undefined' ? section.linkContent : '' %> +
+
+ <%- typeof section.content !== 'undefined' ? section.content : '' %> +
+ <% }) %> + <% } %> +
\ No newline at end of file diff --git a/src/components/spa-sidebar/spa-sidebar.mjs b/src/components/spa-sidebar/spa-sidebar.mjs new file mode 100644 index 0000000..bf05dca --- /dev/null +++ b/src/components/spa-sidebar/spa-sidebar.mjs @@ -0,0 +1,188 @@ +import { ComposableElement } from '../ComposableElement.mjs'; + +/** + * Spinner Web Component + * + * A simple spinner component that indicates loading or processing state. + * This component uses the `aria-busy` attribute in combination with CSS tricks to create a spinner that is accessible, modern, and easy to manage. + */ +export class SPASidebar extends ComposableElement { + constructor() { + super(); + } + + // ------------------------------- + // Web Component Lifecycle Methods + // ------------------------------- + + /** + * Does initial setup and adds event listeners for interactivity + * + * `connectedCallback` is a lifecycle method in web components that runs when the custom element is inserted into the document's Document Object Model (DOM). + * It can be invoked multiple times if the element is removed and then re-inserted into the DOM. + * + * Timing: It is called after the element's constructor() but before the element's children are necessarily connected or fully rendered. + * Purpose: It is the ideal place to set up tasks that should only occur when the element is actually present in the live document. Common uses include: + */ + connectedCallback() { + const internals = this.attachInternals(); + + this.shadow = this.shadowRoot; + if (!this.shadow) { + this.shadow = this.attachShadow({ mode: 'open' }); + + // Defer execution until the browser finishes parsing the children + setTimeout(() => { + // Recreate the template using the shadow DOM that is only available through JavaScript + this.createTemplateInJS(this.shadow); + }, 0); + } + + setTimeout(() => { + this.shadow.querySelectorAll('.spa-sidebar-link').forEach(link => { + link.addEventListener('click', (e) => { + // Prevent default link behavior + e.preventDefault(); + + // Get the current section from the URL hash to determine if we are switching sections or re-clicking the same section + const currSect = window.location.hash.substring(1); + + const newSect = link.getAttribute('href').substring('#'.length); + + // Because the URL hash is what determines the active section, if the new section is the same as the current section we can just return early and do nothing + if(newSect === currSect) { + return; + } + + // Remove 'active-sect' class from all currently active sections and `active` class from links + document.querySelectorAll('.spa-sidebar-page-sect.active-sect').forEach(section => section.classList.remove('active-sect')); + document.querySelectorAll('.spa-sidebar-page-sidebar-link.active').forEach(activeLink => activeLink.classList.remove('active')); + + // Add 'active-sect' class to the new section and active class to link + link.classList.add('active'); + document.querySelector(`.spa-sidebar-page-sect#${newSect}`).classList.add('active-sect'); + + // Update the URL hash to reflect the new section + window.location.hash = newSect; + }); + }); + }, 0); + } + + /** + * Cleans up event listeners when the component is removed from the DOM + * + * `disconnectedCallback` is a lifecycle method in web components that runs when the custom element is removed from the document's DOM. + * It can be invoked multiple times if the element is removed and then re-inserted into the DOM. + * + * Timing: It is called after the element is removed from the DOM but before it is garbage collected. + * Purpose: It is the ideal place to clean up any resources or event listeners that were set up in `connectedCallback`. + * + * Common uses include: + * - Removing event listeners to prevent memory leaks + * - Clearing timers or intervals + * - Disconnecting from external data sources or APIs + */ + disconnectedCallback() {} + + /** + * Recreate the template in the shadow DOM through JavaScript instead of relying on the `shadowrootmode` attribute + * + * @param {ShadowRoot} shadow The shadow DOM to attach the template to + */ + createTemplateInJS(shadow) { + const config = this.initializeComponent('spa-sidebar', shadow); + + if(config == null) { + throw new Error('Spinner configuration not found. Please ensure you have a script tag with the appropriate data-spinner-config attribute containing the necessary JSON configuration for the spinner component.'); + } + + // Create the container div for the spinner component + const spaSidebarContainerDiv = document.createElement('div'); + spaSidebarContainerDiv.id = 'spa-sidebar-container'; + + const spaSidebarAside = document.createElement('aside'); + spaSidebarAside.classList.add('spa-sidebar'); + + const spaSidebarList = document.createElement('ul'); + spaSidebarList.classList.add('spa-sidebar-nav'); + + config.sections.forEach(section => { + const spaSidebarListItem = document.createElement('li'); + + const spaSidebarListItemLink = document.createElement('a'); + spaSidebarListItemLink.href = `#${section.id}` + spaSidebarListItemLink.classList.add('spa-sidebar-link', 'active'); + + const spaSidebarListItemSlot = document.createElement('slot'); + spaSidebarListItemSlot.name = `link-${section.id}`; + //spaSidebarListItemSlot.slot = `link-${section.id}`; + + spaSidebarListItemLink.appendChild(spaSidebarListItemSlot); + + spaSidebarListItem.appendChild(spaSidebarListItemLink); + + spaSidebarList.appendChild(spaSidebarListItem); + }); + + spaSidebarAside.appendChild(spaSidebarList); + + const spaSidebarMainDiv = document.createElement('div'); + spaSidebarMainDiv.classList.add('spa-sidebar-main'); + spaSidebarMainDiv.setAttribute('aria-live', 'polite'); + + config.sections.forEach(section => { + const spaSidebarMainSect = document.createElement('section'); + spaSidebarMainSect.id = section.id + spaSidebarMainSect.classList.add('spa-sidebar-sect') + if(section.active) { + spaSidebarMainSect.classList.add('active-sect'); + } + + const spaSidebarContentSlot = document.createElement('slot'); + spaSidebarContentSlot.name = `content-${section.id}`; + //spaSidebarContentSlot.slot = `content-${section.id}`; + + spaSidebarMainSect.appendChild(spaSidebarContentSlot); + + spaSidebarMainDiv.appendChild(spaSidebarMainSect); + }); + + spaSidebarContainerDiv.appendChild(spaSidebarAside); + + spaSidebarContainerDiv.appendChild(spaSidebarMainDiv); + + // Finally, append the entire spinner span to the shadow DOM of the component + shadow.appendChild(spaSidebarContainerDiv); + } +} + +document.addEventListener('DOMContentLoaded', () => { + // If a hash is present in the URL on initial load, use it to set the active section + // + // This is primarily so that when people use the global event switcher drop down they are put back in the same section. + // Which should help with the ideas around relatively quickly rapidly doing the same action across events. + // For example, if an organizer hs let's say 3 upcoming events and they book ASL they might do it for all 3 at once. + // When they get the confirmation they'd likely want to set it for each of them quickly so this should help support that. + if(typeof window.location.hash === 'string' && window.location.hash.substring(1).trim() !== '') { + const initialSect = window.location.hash.substring(1); + + console.log('Initial Section from URL Hash:', initialSect); + + // Remove default 'active-sect' class from all currently active sections and `active` class from links + document.querySelectorAll('.event-page-sect.active-sect').forEach(section => section.classList.remove('active-sect')); + document.querySelectorAll('.event-page-sidebar-link.active').forEach(activeLink => activeLink.classList.remove('active')); + + // Add 'active-sect' class to the new section and `active` class to link + document.querySelector(`.event-page-sidebar-link[href="#${initialSect}"]`).classList.add('active'); + document.querySelector(`.event-page-sect#${initialSect}`).classList.add('active-sect'); + } + else { + // If no hash is present, set it based on the default active section (if any) + window.location.hash = document.querySelector('.event-page-sidebar-link.active').getAttribute('href'); + } + + if (!customElements.get('ba-spa-sidebar')) { + customElements.define('ba-spa-sidebar', SPASidebar); + } +}); \ No newline at end of file