Partial SPA sidebar implementation + fix for drawer component
All checks were successful
Build, Test, and Publish (to Private NPM Registry) UI Components Library / publish (push) Successful in 1m22s

This commit is contained in:
Alan Bridgeman 2026-06-23 13:14:33 -05:00
parent b885a67353
commit e4f00fcdf6
4 changed files with 270 additions and 1 deletions

View file

@ -132,7 +132,7 @@ export class Drawer extends ComposableElement {
// Create the div that will contain the drawer content // Create the div that will contain the drawer content
const contentDiv = document.createElement('div'); const contentDiv = document.createElement('div');
contentDiv.id = `${drawerId}-drawer-contents`; contentDiv.id = `${config.drawerId}-drawer-contents`;
contentDiv.classList.add('drawer-contents', 'hidden'); contentDiv.classList.add('drawer-contents', 'hidden');
const contentSlotElem = document.createElement('slot'); const contentSlotElem = document.createElement('slot');

View file

@ -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.

View file

@ -0,0 +1,79 @@
<ba-spa-sidebar>
<%# 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) %>
<!-- JSON config data so that `createTemplateInJS` can recreate the SPA sidebar DOM structure accurately if needed -->
<script type="application/json" data-spa-sidebar-config>
<%- JSON.stringify({
sections: locals.sections,
spaSidebarExtraStyles: typeof locals.spaSidebarExtraStyles !== 'undefined' ? (Array.isArray(locals.spaSidebarExtraStyles) ? locals.spaSidebarExtraStyles : [locals.spaSidebarExtraStyles]) : [],
spaSidebarExtraScripts: typeof locals.spaSidebarExtraScripts !== 'undefined' ? (Array.isArray(locals.spaSidebarExtraScripts) ? locals.spaSidebarExtraScripts : [locals.spaSidebarExtraScripts]) : [],
componentsStyleHref: locals.componentsStyleHref
}) %>
</script>
<template shadowrootmode="open">
<!-- Styles -->
<!-- Component Specific Styling -->
<!-- Component Styling (Part of Component Library CSS) -->
<link rel="stylesheet" type="text/css" href="<%= locals.componentsStyleHref %>">
<!-- END: Component Specific Styling -->
<!-- Additional Styles -->
<% if (typeof locals.spaSidebarExtraStyles !== 'undefined') { %>
<% const spaSidebarStyles = Array.isArray(locals.spaSidebarExtraStyles) ? locals.spaSidebarExtraStyles : [locals.spaSidebarExtraStyles]; %>
<% for (let style of spaSidebarStyles) { %>
<link rel="stylesheet" type="text/css" href="/css/<%= style %>.css">
<% } %>
<% } %>
<!-- END: Additional Styles -->
<!-- END: Styles -->
<!-- Scripts -->
<% if (typeof locals.spaSidebarExtraScripts !== 'undefined') { %>
<% const spaSidebarScripts = Array.isArray(locals.spaSidebarExtraScripts) ? locals.spaSidebarExtraScripts : [locals.spaSidebarExtraScripts]; %>
<% for (let script of spaSidebarScripts) { %>
<% if(typeof script === 'object') { %>
<script type="<%= typeof script.module === 'boolean' && script.module ? 'module' : 'application/javascript' %>" src="<%= script.script %>"></script>
<% } else if(typeof script === 'string' && (script.startsWith('http') || script.startsWith('https'))) { %>
<script type="<%= script.endsWith('.mjs') ? 'module' : 'application/javascript' %>" src="<%= script %>"></script>
<% } else if(typeof script === 'string') { %>
<script type="<%= script.endsWith('.mjs') ? 'module' : 'application/javascript' %>" src="/js/<%= script.endsWith('.mjs') ? script : script + '.js' %>"></script>
<% } %>
<% } %>
<% } %>
<!-- END: Scripts -->
<div id="spa-sidebar-container">
<aside class="spa-sidebar">
<ul class="spa-sidebar-nav">
<% locals.sections.forEach(section => { %>
<li>
<a href="#<%= section.id %>" class="spa-sidebar-link active">
<slot name="link-<%= section.id %>"<% if(typeof slots !== 'undefined' && typeof slots['link-' + section.id] !== 'undefined') { %> slot="<%= 'link-' + section.id %>"<% } %>></slot>
</a>
</li>
<% }) %>
</ul>
</aside>
<div class="spa-sidebar-main" aria-live="polite">
<% locals.sections.forEach(section => { %>
<section id="<%= section.id %>" class="spa-sidebar-sect<%= section.active ? ' active-sect' : '' %>">
<slot name="content-<%= section.id %>"<% if(typeof slots !== 'undefined' && typeof slots['content-' + section.id] !== 'undefined') { %> slot="<%= 'content-' + section.id %>"<% } %>></slot>
</section>
<% }) %>
</div>
</div>
</template>
<% if (typeof slots !== 'undefined' && Object.keys(slots).length > 0) { %>
<%- slots %>
<% } else { %>
<% locals.sections.forEach(section => { %>
<div slot="link-<%= section.id %>">
<%- typeof section.linkContent !== 'undefined' ? section.linkContent : '' %>
</div>
<div slot="content-<%= section.id %>">
<%- typeof section.content !== 'undefined' ? section.content : '' %>
</div>
<% }) %>
<% } %>
</ba-spa-sidebar>

View file

@ -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);
}
});