Added tabs components and refactored stuff to work properly with composed / nested elements etc...
All checks were successful
Build, Test, and Publish (to Private NPM Registry) UI Components Library / publish (push) Successful in 58s

This commit is contained in:
Alan Bridgeman 2026-05-18 13:56:31 -05:00
parent 1c2d794a18
commit d2e99cc280
27 changed files with 1579 additions and 145 deletions

View file

@ -39,30 +39,41 @@ gulp.task("typescript", (done) => {
// Task which would bundle and minify the client-side JavaScript using Rollup
gulp.task("bundle-minify-js", (done) => {
const scriptFiles = [];
const components = [];
fs.readdirSync(path.join('src', 'components')).forEach(component => {
// Check the current item is a directory (i.e., a component folder)
if (fs.statSync(path.join('src', 'components', component)).isDirectory()) {
fs.readdirSync(path.join('src', 'components', component)).forEach(file => {
const componentScriptFiles = [];
// 1. Get all directories inside src/components
fs.readdirSync(path.join('src', 'components')).forEach(f => {
if(fs.statSync(path.join('src', 'components', f)).isFile() && (f.endsWith('.js') || f.endsWith('.mjs'))) {
console.log(`Adding root-level script: ${f}`);
scriptFiles.push(path.join('src', 'components', f));
}
else if(fs.statSync(path.join('src', 'components', f)).isDirectory()) {
components.push(f);
}
});
// Include any JS files found in a "scripts" subfolder of the component
if(file === 'scripts' && fs.statSync(path.join('src', 'components', component, file)).isDirectory()) {
fs.readdirSync(path.join('src', 'components', component, file)).forEach(scriptFile => {
if(scriptFile.endsWith('.js')) {
scriptFiles.push(path.join('src', 'components', component, 'scripts', scriptFile));
}
});
components.forEach(component => {
const componentPath = path.join('src', 'components', component);
const scriptsPath = path.join(componentPath, 'scripts');
if (fs.existsSync(scriptsPath) && fs.statSync(scriptsPath).isDirectory()) {
fs.readdirSync(scriptsPath).forEach(file => {
if (file.endsWith('.js') || file.endsWith('.mjs')) {
scriptFiles.push(path.join(scriptsPath, file));
}
// Look for *.js files inside the root of the component folders
if (file.endsWith('.js')) {
scriptFiles.push(path.join('src', 'components', component, file));
}
scriptFiles.push(...componentScriptFiles);
});
}
fs.readdirSync(componentPath).forEach(file => {
const componentScriptFiles = [];
// Look for *.js / *.mjs files inside the root of the component folders
if ((file.endsWith('.js') || file.endsWith('.mjs')) && fs.statSync(path.join(componentPath, file)).isFile()) {
componentScriptFiles.push(path.join('src', 'components', component, file));
}
scriptFiles.push(...componentScriptFiles);
});
});
let clientEntryJSContent = '';
@ -71,6 +82,9 @@ gulp.task("bundle-minify-js", (done) => {
clientEntryJSContent += `import './${relativePath}';\n`;
});
//console.log(`Client Entry JS Content: ${clientEntryJSContent}`);
fs.writeFileSync(path.join('src', 'components', 'client-entry.js'), clientEntryJSContent);
let proc;
@ -82,7 +96,7 @@ gulp.task("bundle-minify-js", (done) => {
}
proc.on('close', code => {
// Clean up the temporary entry file so tht the src/ directory stays pristine
// Clean up the temporary entry file so that the src/ directory stays pristine
const entryPath = path.join('src', 'components', 'client-entry.js');
if (fs.existsSync(entryPath)) {
fs.rmSync(entryPath);

View file

@ -0,0 +1,124 @@
/**
* Abstract Base Class for Bridgeman Accessible Web Components
* Provides helper methods for easily initializing components, composing complex components, and managing nested custom elements.
*/
export class ComposableElement extends HTMLElement {
constructor() {
super();
}
/**
* Helper method to load and parse a JSON configuration ("Data Island") from a script tag within the Light DOM of the component.
* The script tag should have a `data-{name}-config` attribute where `{name}` is a unique identifier for the component (e.g., 'tablist' for 'ba-tablist').
*
* @param {string} name - The suffix of the component (e.g., 'tablist' for 'ba-tablist')
* @returns {Object|null} The parsed JSON configuration object, or null if not found
*/
loadDataIslandConfig(name) {
const configScript = this.querySelector(`script[data-${name}-config]`);
if (!configScript) {
console.error(`${name} configuration missing.`);
return null;
}
const config = JSON.parse(configScript.textContent);
return config;
}
/**
* Helper method to include the necessary stylesheets and scripts for a component based on its configuration.
* This method should be called within the `createTemplateInJS` of a component after loading it's "data island" configurations.
*
* @param {string} name - The suffix of the component (e.g., 'tablist' for 'ba-tablist')
* @param {Object} config - The JSON configuration object that includes the necessary information about styles and scripts to include
* @param {ShadowRoot} shadow - The shadow DOM root to append the link and script tags to
*/
includeStylesheetsAndScripts(name, config, shadow) {
if (config.componentsStyleHref) {
// Create and append the link tag for the components library CSS (including component specific styles)
const componentLibraryStyle = document.createElement('link');
componentLibraryStyle.rel = 'stylesheet';
componentLibraryStyle.type = 'text/css';
componentLibraryStyle.href = config.componentsStyleHref;
shadow.appendChild(componentLibraryStyle);
}
// Create additional link tags for any extra CSS files to include
const additionalStyles = config[`${name}ExtraStyles`] || [];
additionalStyles.forEach(style => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = `/css/${style}.css`;
shadow.appendChild(link);
});
// Create and append script tags for any extra JavaScript files to include
const additionalScripts = config[`${name}ExtraScripts`] || [];
additionalScripts.forEach(script => {
const scriptElem = document.createElement('script');
scriptElem.type = typeof script === 'object' && typeof script.module === 'boolean' && script.module ? 'module' : typeof script === 'string' && script.endsWith('.mjs') ? 'module' : 'application/javascript';
scriptElem.src = typeof script === 'object' ? script.script.startsWith('http') ? script.script : `/js/${script.script.endsWith('.mjs') ? script.script : script.script + '.js'}` : script.startsWith('http') ? script : `/js/${script.endsWith('.mjs') ? script : script + '.js'}`;
shadow.appendChild(scriptElem);
});
}
/**
* Helper method to initialize a component by loading its configuration and including necessary stylesheets and scripts.
* This method should be called at the beginning of the `createTemplateInJS` of a component to set up everything needed for the component to function properly.
*
* @param {string} name - The suffix of the component (e.g., 'tablist' for 'ba-tablist')
* @param {ShadowRoot} shadow - The shadow DOM root to append the link and script tags to
* @param {{ includeStylesAndScripts: boolean, stylesAndScriptsNameOverride?: string, subComponents?: Array<{ name: string, configKey: string, slotNames: Array<string> }> }} options - Options for initializing the component
* @returns {Object|null} The parsed JSON configuration object, or null if not found
*/
initializeComponent(name, shadow, { includeStylesAndScripts = true, stylesAndScriptsNameOverride, subComponents = [] } = {}) {
const config = this.loadDataIslandConfig(name);
if(config == null) {
return null;
}
if (includeStylesAndScripts) {
this.includeStylesheetsAndScripts(typeof stylesAndScriptsNameOverride !== 'undefined' ? stylesAndScriptsNameOverride : name, config, shadow);
}
subComponents.forEach(subComp => {
const subCompElem = this.createSubComponent(subComp.name, config[subComp.configKey], subComp.slotNames);
shadow.appendChild(subCompElem);
});
return config;
}
/**
* Generates a sub-component, attaches its configuration, and forwards requested slots.
*
* @param {string} name - The suffix of the component (e.g., 'tablist' for 'ba-tablist')
* @param {Object} config - The JSON configuration object to pass to the sub-component
* @param {Array<string>} slotNames - An array of exact slot names to forward down
* @returns {HTMLElement} The fully constructed sub-component ready to be appended
*/
createSubComponent(name, config, slotNames = []) {
const customElem = document.createElement(`ba-${name}`);
// Attach the Configuration Data Island
const customElemConfig = document.createElement('script');
customElemConfig.type = 'application/json';
customElemConfig.setAttribute(`data-${name}-config`, '');
customElemConfig.textContent = JSON.stringify(config);
customElem.appendChild(customElemConfig);
// Forward the explicit slots
slotNames.forEach(slotName => {
const slotForwarder = document.createElement('slot');
slotForwarder.name = slotName;
slotForwarder.slot = slotName;
customElem.appendChild(slotForwarder);
});
// Return the element so the child class can append it exactly where it belongs in its specific DOM structure
return customElem;
}
}

View file

@ -52,11 +52,15 @@
</div>
</template>
<div slot="drawer-header">
<%- label %>
</div>
<% if(typeof slots !== 'undefined') { %>
<%- slots %>
<% } else { %>
<div slot="drawer-header">
<%- label %>
</div>
<div slot="drawer-contents">
<%- content %>
</div>
<div slot="drawer-contents">
<%- content %>
</div>
<% } %>
</ba-drawer>

View file

@ -1,9 +1,11 @@
import { ComposableElement } from '../ComposableElement.mjs';
/**
* Drawer Web Component
*
* A simple drawer component that toggles the visibility of its content when the header is clicked or activated with the keyboard.
* A simple drawer component that toggles the visibility of its content when the "handle" is clicked or activated with the keyboard.
*/
class Drawer extends HTMLElement {
export class Drawer extends ComposableElement {
constructor() {
super();
@ -105,53 +107,21 @@ class Drawer extends HTMLElement {
* @param {ShadowRoot} shadow The shadow DOM to attach the template to
*/
createTemplateInJS(shadow) {
// Retrieve the scoped data island
const configScript = this.querySelector('script[data-drawer-config]');
if (!configScript) {
console.error('Drawer configuration missing. Cannot build JS fallback.');
return;
}
const config = JSON.parse(configScript.textContent);
const { drawerExtraStyles, drawerExtraScripts, drawerId, componentsStyleHref } = config;
// Create and append the link tag for the components library CSS (including component specific styles)
const drawerStyle = document.createElement('link');
drawerStyle.rel = 'stylesheet';
drawerStyle.type = 'text/css';
drawerStyle.href = componentsStyleHref;
shadow.appendChild(drawerStyle);
// Create additional link tags for any extra CSS files to include
drawerExtraStyles.forEach(style => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = `/css/${style}.css`;
shadow.appendChild(link);
});
// Create and append script tags for any extra JavaScript files to include
drawerExtraScripts.forEach(script => {
const scriptElem = document.createElement('script');
scriptElem.type = typeof script === 'object' && typeof script.module === 'boolean' && script.module ? 'module' : typeof script === 'string' && script.endsWith('.mjs') ? 'module' : 'application/javascript';
scriptElem.src = typeof script === 'object' ? script.script.startsWith('http') ? script.script : `/js/${script.script.endsWith('.mjs') ? script.script : script.script + '.js'}` : script.startsWith('http') ? script : `/js/${script.endsWith('.mjs') ? script : script + '.js'}`;
shadow.appendChild(scriptElem);
});
const config = this.initializeComponent('drawer', shadow);
// Create the container div for the drawer component
const drawerContainerDiv = document.createElement('div');
drawerContainerDiv.id = drawerId;
drawerContainerDiv.id = config.drawerId;
drawerContainerDiv.classList.add('drawer');
// Create the button element for the tooltip trigger
// Create the button element for the drawer "handle"
const drawerBtnElem = document.createElement('div');
drawerBtnElem.id = `${drawerId}-drawer-header`;
drawerBtnElem.id = `${config.drawerId}-drawer-header`;
drawerBtnElem.role = 'button';
drawerBtnElem.classList.add('drawer-header');
drawerBtnElem.tabIndex = 0;
drawerBtnElem.setAttribute('aria-expanded', 'false');
drawerBtnElem.setAttribute('aria-controls', `${drawerId}-drawer-contents`);
drawerBtnElem.setAttribute('aria-controls', `${config.drawerId}-drawer-contents`);
const drawerHeaderSlotElem = document.createElement('slot');
drawerHeaderSlotElem.name = 'drawer-header';
@ -177,4 +147,8 @@ class Drawer extends HTMLElement {
}
}
customElements.define('ba-drawer', Drawer);
document.addEventListener('DOMContentLoaded', () => {
if (!customElements.get('ba-drawer')) {
customElements.define('ba-drawer', Drawer);
}
});

View file

@ -0,0 +1,163 @@
import { ComposableElement } from '../../ComposableElement.mjs';
/**
* Tab Panels Web Component
*
* This component is responsible for rendering the content panels of a given tabbed interface.
* It's designed to work in conjunction with more interaction heavy components such as the `ba-tablist` component.
* Or another way, it's focused on display / view rather than interactivity (though it does have some internal logic to manage the active tab panel).
*/
export class TabPanels extends ComposableElement {
constructor() {
super();
}
/**
* Does initial tab setup and adds event listeners for tab interaction.
*
* `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() {
// Attach the internals to the component to allow for the use of a Declarative Shadow Root
const internals = this.attachInternals();
// check for a Declarative Shadow Root:
this.shadow = this.shadowRoot;
if (!this.shadow) {
// there wasn't one. create a new Shadow Root:
this.shadow = this.attachShadow({ mode: 'open' });
setTimeout(() => {
// Recreate the template using the shadow DOM that is only available through JavaScript
this.createTemplateInJS(this.shadow);
}, 0);
}
setTimeout(() => {
const activePanel = this.shadow.querySelector('[role="tabpanel"][aria-hidden="false"]') || this.shadow.querySelector('[role="tabpanel"]');
if(activePanel == null) {
throw new Error('No tab panels found. Please ensure that your configuration includes at least one tab panel.');
}
this.activeTabPanelId = activePanel.id;
// Ensure it's visually active if we fell back to the first one
if (!activePanel.classList.contains('is-active')) {
this.switchPanel(this.activeTabPanelId);
}
}, 0);
}
/**
* Get the currently active tab.
*
* useful for external interactions with the component.
* Note, there is some ambiguity in what the "active tab" means.
*
* For that reason we return an object that can be summarized using the following table:
*
* | Key | Description | Rational |
* | ------- | ------------------------- | -------------------------------------------------------------------------------------------------------------- |
* | `id` | The ID of the the panel | If the question is "what is the ID of the active tab?" Your not looking for the ID of the button element |
* | `panel` | The panel (tab contents) | Provides access to the underlying panel (tab contents) if/when needed while not over-complicating other things |
*
* @return {{ id: string, panel: HTMLDivElement }} The currently active tab element
*/
getActiveTab() {
const panel = this.shadow.getElementById(this.activeTabPanelId);
return { id: this.activeTabPanelId, panel };
}
/**
* Switch the active tab panel to the one with the specified ID.
*
* Note, this is meant to be available both internally to the class but also externally (if JavaScript ever needed to programmatically switch the active tab, they could call this method directly) so it is not prefixed with an underscore like the other internal helper methods.
*
* @param {string} targetId The ID of the tab panel to switch to (e.g. "tab-panel-1")
*/
switchPanel(targetId) {
// Loop through each tab panel and show the one that corresponds to the selected tab and hide the others
this.shadow.querySelectorAll('[role="tabpanel"]').forEach(panel => {
const isTarget = panel.id === targetId;
// If it's not the selected tab and it doesn't have the hidden attribute, add it (so we hide the tab)
if(!isTarget && (!panel.hasAttribute('hidden') || panel.getAttribute('aria-hidden') === 'false')) {
panel.setAttribute('hidden', 'true');
panel.setAttribute('aria-hidden', 'true');
panel.classList.remove('is-active');
}
// If it's the selected tab and it has the hidden attribute, remove it (so we show the tab)
if(isTarget && (panel.hasAttribute('hidden') || panel.getAttribute('aria-hidden') === 'true')) {
// Because of weird edge cases we do an extra check here
if(panel.hasAttribute('hidden')) {
panel.removeAttribute('hidden');
}
panel.setAttribute('aria-hidden', 'false');
panel.classList.add('is-active');
}
});
this.activeTabPanelId = targetId;
}
/**
* 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('tab-panels', shadow, { stylesAndScriptsNameOverride: 'tabs' });
const { tabsId, tabsMeta } = config;
// Create the div that will contain the tab panels
const tabsContentDiv = document.createElement('div');
tabsContentDiv.classList.add('tabs-content');
tabsContentDiv.dataset.tabsContent = tabsId;
// Generate Tab Panels (div elements)
tabsMeta.forEach((tab, index) => {
// Create the first tab panel element
const tabPanelDiv = document.createElement('div');
tabPanelDiv.role = 'tabpanel';
tabPanelDiv.id = tab.id;
tabPanelDiv.setAttribute('aria-labelledby', `tab-${index}`);
tabPanelDiv.classList.add('tabs-panel');
if(tab.active) {
tabPanelDiv.setAttribute('aria-hidden', 'false');
tabPanelDiv.classList.add('is-active');
}
else {
tabPanelDiv.setAttribute('hidden', 'true');
tabPanelDiv.setAttribute('aria-hidden', 'true');
}
// Create a slot element for the tab panel's content
const tabPanelSlotElem = document.createElement('slot');
tabPanelSlotElem.name = `tab-content-${index}`;
// Append the slot element to the tab panel element
tabPanelDiv.appendChild(tabPanelSlotElem);
// Append the tab panel element to the tabs content div
tabsContentDiv.appendChild(tabPanelDiv);
});
// Finally, append the entire tabs content div to the shadow DOM of the component
shadow.appendChild(tabsContentDiv);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!customElements.get('ba-tab-panels')) {
customElements.define('ba-tab-panels', TabPanels);
}
});

View file

@ -0,0 +1,251 @@
import { ComposableElement } from '../../ComposableElement.mjs';
/**
* Tablist Web Component
*
* This component represents the "tab list" in a tabbed interface, which contains the individual "tab" elements that users interact with to switch between different "tab panels" (the content associated with each tab).
* It's vaguely designed to work in conjunction with a corresponding `ba-tab-panels` component and a `ba-tab` parent (though it IS decoupled and simply publishes a Custom Event `ba-tab-change` on change so has potential for reuse in other use cases).
* It largely focuses on the interactive portion of making a tabbed interface work.
*/
export class Tablist extends ComposableElement {
constructor() {
super();
this._changeTabFunc = this._changeTab.bind(this);
this._keyDownHandler = this._keyDown.bind(this);
}
// -------------------------------
// Web Component Lifecycle Methods
// -------------------------------
/**
* Does initial tab setup and adds event listeners for tab interaction.
*
* `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() {
// Attach the internals to the component to allow for the use of a Declarative Shadow Root
const internals = this.attachInternals();
// check for a Declarative Shadow Root:
this.shadow = this.shadowRoot;
if (!this.shadow) {
// there wasn't one. create a new Shadow Root:
this.shadow = this.attachShadow({ mode: 'open' });
setTimeout(() => {
// Recreate the template using the shadow DOM that is only available through JavaScript
this.createTemplateInJS(this.shadow);
}, 0);
}
setTimeout(() => {
const activeTab = this.shadow.querySelector('[role="tab"].is-active') || this.shadow.querySelector('[role="tab"]');
if(activeTab == null) {
throw new Error('No tabs found. Please ensure that your configuration includes at least one tab.');
}
// Set the active tab ID to the currently active tab in the DOM (this makes the implementation of `getActiveTab` basically trivial)
this.activeTabId = activeTab.getAttribute('id');
// Ensure it's visually active if we fell back to the first one
if (!activeTab.classList.contains('is-active')) {
this.switchTab(activeTab);
}
this.shadow.querySelectorAll('[role="tab"]').forEach((tab) => {
// Add event listeners for click and keydown (for accessibility) to each tab element.
// The event listener for click will change the active tab and show the corresponding tab panel.
tab.addEventListener('click', this._changeTabFunc);
tab.addEventListener('keydown', this._keyDownHandler);
});
}, 0);
}
/** Cleanup when the element is removed from the DOM */
disconnectedCallback() {
// Clean up any event listeners or resources when the element is removed from the DOM
this.shadow.querySelectorAll('[role="tab"]').forEach((tab) => {
try {
tab.removeEventListener('click', this._changeTabFunc);
tab.removeEventListener('keydown', this._keyDownHandler);
}
catch (error) {
/* Do Nothing (Ignore errors that occur here because any number of things could cause errors during the cleanup process and we don't want to throw errors just because something went wrong during cleanup) */
console.log(`An error occured during disconnect:`, error);
}
});
}
/**
* 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('tablist', shadow, { stylesAndScriptsNameOverride: 'tabs' });
const { tabsId, tabsStyleClasses, tabsAriaLabel, tabsMeta } = config;
// Create the nav ("tab list") element
const navElem = document.createElement('nav');
navElem.setAttribute('role', 'tablist');
navElem.id = tabsId;
navElem.classList.add('tabs');
tabsStyleClasses.forEach(cls => navElem.classList.add(cls));
navElem.setAttribute('aria-label', tabsAriaLabel);
navElem.dataset.tabs = '';
// Generate Tabs (button elements)
tabsMeta.forEach((tab, index) => {
// Create the tab button element
const tabBtnElem = document.createElement('button');
tabBtnElem.setAttribute('role', 'tab');
tabBtnElem.id = `tab-${index}`;
tabBtnElem.classList.add('tabs-title');
if(tab.active) {
tabBtnElem.classList.add('is-active');
}
tabBtnElem.setAttribute('aria-controls', tab.id);
tabBtnElem.setAttribute('aria-selected', tab.active ? 'true' : 'false');
tabBtnElem.tabIndex = tab.active ? 0 : -1;
// Create a slot element for the tab button's label/content
const tabSlotElem = document.createElement('slot');
tabSlotElem.name = `tab-${index}`;
// Append the slot element to the tab button element
tabBtnElem.appendChild(tabSlotElem);
// Append the tab button element ("the tab") to the nav element ("the tab list")
navElem.appendChild(tabBtnElem);
});
// Finally, append the entire tabs container div to the shadow DOM of the component
shadow.appendChild(navElem);
}
// --------------
// Helper Methods
// --------------
/**
* Get the currently active tab.
*
* useful for external interactions with the component.
*
* @return {HTMLButtonElement} The currently active tab element
*/
getActiveTab() {
const tab = this.shadow.getElementById(this.activeTabId);
return tab;
}
/**
* Switch the active tab to the selected tab.
*
* Note, this is meant to be available both internally to the class but also externally (if JavaScript ever needed to programmatically switch the active tab, they could call this method directly) so it is not prefixed with an underscore like the other internal helper methods.
*
* @param {HTMLElement} selectedTab The tab element to activate
*/
switchTab(selectedTab) {
// Update Tabs
this.shadow.querySelectorAll('[role="tab"]').forEach(tab => {
// Easy access boolean if is the selected tab or not
const isSelected = tab === selectedTab;
// Update the aria-selected attribute and tabindex of each tab based on whether it is the selected tab or not
tab.setAttribute('aria-selected', isSelected);
tab.setAttribute('tabindex', isSelected ? 0 : -1);
// Remove the active class from any tab that isn't the selected tab
if(!isSelected && tab.classList.contains('is-active')) {
tab.classList.remove('is-active');
}
// Add the active class to the selected tab if it doesn't already have it
if(isSelected && !tab.classList.contains('is-active')) {
tab.classList.add('is-active');
}
});
// Get the ID of the tab panel that should be shown based on the aria-controls attribute of the selected tab
const targetId = selectedTab.getAttribute('aria-controls');
// Show the corresponding tab panel and hide the others by calling the `switchTab` method on the `ba-tab-panels` component and passing it the ID of the tab panel to show (the one that corresponds to the selected tab)
this.dispatchEvent(new CustomEvent('ba-tab-change', {
detail: { targetId },
bubbles: true,
composed: true
}));
this.activeTabId = selectedTab.getAttribute('id');
}
// ----------------------
// Private Event Handlers
// ----------------------
/**
* Internal change event handler for when a tab is clicked or activated via keyboard.
* It prevents the default behavior and then calls the `switchTab` method to switch the active tab to the clicked/selected tab.
*
* @param {Event} e The click/keyboard event triggered by clicking or pressing enter/space on a tab element. The event listener for this is added in the `connectedCallback` method.
*/
async _changeTab(e) {
// Prevent the default behavior
e.preventDefault();
if(e.currentTarget.getAttribute('id') === this.activeTabId) {
// The clicked tab is already active, so we don't need to do anything
return;
}
// Call the switchTab method to switch the active tab to the clicked/selected tab
this.switchTab(e.currentTarget);
}
/**
* Internal keydown event handler for when a tab is focused and the user presses the left or right arrow keys, Enter or Space key.
* It prevents the default behavior and then calls the `_changeTab` method to switch the active tab to the focused/selected tab.
*
* @param {KeyboardEvent} e The keyboard event triggered by pressing the left or right arrow keys, Enter or Space on a focused tab element. The event listener for this is added in the `connectedCallback` method.
*/
async _keyDown(e) {
if(e.key === 'Enter' || e.key === ' ') {
await this._changeTab(e);
}
// Arrow keys handle "roving focus"
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
const tabs = Array.from(this.shadow.querySelectorAll('[role="tab"]'));
const currentIndex = tabs.indexOf(e.currentTarget);
let nextIndex;
if (e.key === 'ArrowRight') {
// Move right, loop to start if at the end
nextIndex = (currentIndex + 1) % tabs.length;
} else if (e.key === 'ArrowLeft') {
// Move left, loop to end if at the start
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
}
// Move the physical browser focus to the new tab
tabs[nextIndex].focus();
}
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!customElements.get('ba-tablist')) {
customElements.define('ba-tablist', Tablist);
}
});

View file

@ -0,0 +1,129 @@
:where(ba-tabs) {
display: block;
min-width: 100%;
max-width: 100%;
}
:where(ba-tablist) {
display: block;
min-width: 100%;
max-width: 100%;
}
/* ======================================
TABLIST CONTAINER (<nav class="tabs">)
====================================== */
:where(.tabs) {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
list-style-type: none;
margin: 0;
padding: 0;
border-bottom: 2px solid light-dark(#E2E8F0, #334155); /* Default bottom track */
margin-bottom: 2rem;
overflow-x: auto;
}
/* =========================================
TAB BUTTONS (<button class="tabs-title">)
========================================= */
:where(.tabs) button.tabs-title {
background: transparent;
border: none;
border-bottom: 3px solid transparent; /* Placeholder for the active underline */
padding: 0.75rem 1.5rem;
font-size: 1.05rem;
font-weight: 600;
color: light-dark(#64748B, #94A3B8);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: -2px; /* Pull down to overlap the container's bottom border */
transition: all 0.2s ease;
/* Hover State */
&:hover {
color: light-dark(#0F172A, #F8FAFC);
background-color: light-dark(#F1F5F9, #1E293B);
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
/* Keyboard Focus (Accessibility) */
&:focus-visible {
outline: 2px solid var(--primary-background-colour, #0ea5e9);
outline-offset: -2px;
border-radius: 6px;
}
/* Active/Selected State */
&[aria-selected="true"],
&.is-active {
color: var(--primary-background-colour, #0ea5e9);
border-bottom-color: var(--primary-background-colour, #0ea5e9);
}
}
/* ========================================
VARIANTS (Optional classes added to nav)
======================================== */
/* Filled Variant: <nav class="tabs filled"> */
:where(nav.tabs.filled) {
border-bottom: none;
background-color: light-dark(#F1F5F9, #1E293B);
border-radius: 8px;
padding: 0.5rem;
width: fit-content;
button.tabs-title {
border: none;
margin-bottom: 0;
border-radius: 6px;
&:hover {
background-color: light-dark(#E2E8F0, #334155);
}
&[aria-selected="true"],
&.is-active {
background-color: light-dark(#FFFFFF, #0F172A);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
color: var(--primary-background-colour, #0ea5e9);
}
}
}
:where(ba-tab-panels) {
display: block;
min-width: 100%;
max-width: 100%;
}
/* =====================================
TAB PANELS (<div class="tabs-panel">)
===================================== */
.tabs-panel {
display: none; /* Hidden by default */
}
.tabs-panel.is-active {
display: block; /* Shown when JS adds .is-active */
animation: fadeInTab 0.3s ease-in-out forwards;
}
/* =========
KEYFRAMES
========= */
@keyframes fadeInTab {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View file

@ -0,0 +1,167 @@
<ba-tabs>
<%# Because of complexities with web components and specifically more legacy support and the `createTemplateInJS` method, we need to pass the tabs 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 tabs DOM structure accurately if needed -->
<script type="application/json" data-tabs-config>
<%- JSON.stringify({
tabsId,
tabsStyleClasses: typeof tabsStyleClasses !== 'undefined' ? (Array.isArray(tabsStyleClasses) ? tabsStyleClasses : [tabsStyleClasses]) : [],
tabsAriaLabel: typeof tabsAriaLabel !== 'undefined' ? tabsAriaLabel : 'Tabs',
tabsExtraStyles: typeof tabsExtraStyles !== 'undefined' ? (Array.isArray(tabsExtraStyles) ? tabsExtraStyles : [tabsExtraStyles]) : [],
tabsExtraScripts: typeof tabsExtraScripts !== 'undefined' ? (Array.isArray(tabsExtraScripts) ? tabsExtraScripts : [tabsExtraScripts]) : [],
tabsMeta: tabs.filter(t => typeof t.predicate !== 'function' || t.predicate({ ...locals })).map(t => ({ id: t.content.id, active: !!t.active })),
componentsStyleHref
}) %>
</script>
<template shadowrootmode="open">
<!-- Styles -->
<!-- Component Specific Styling -->
<!-- Component Styling (Part of Component Library CSS) -->
<link rel="stylesheet" type="text/css" href="<%= componentsStyleHref %>">
<!-- END: Component Specific Styling -->
<!-- Additional Styles -->
<% if (typeof tabsExtraStyles !== 'undefined') { %>
<% const tabsStyles = Array.isArray(tabsExtraStyles) ? tabsExtraStyles : [tabsExtraStyles]; %>
<% for (let style of tabsStyles) { %>
<link rel="stylesheet" type="text/css" href="/css/<%= style %>.css">
<% } %>
<% } %>
<!-- END: Additional Styles -->
<!-- END: Styles -->
<!-- Scripts -->
<% if (typeof tabsExtraScripts !== 'undefined') { %>
<% const tabScripts = Array.isArray(tabsExtraScripts) ? tabsExtraScripts : [tabsExtraScripts]; %>
<% for (let script of tabScripts) { %>
<% 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 -->
<%
// Generate the forwarded slots to pass down into the child components
let tablistSlots = '';
let panelSlots = '';
tabs.forEach((tab, index) => {
if(typeof tab.predicate === 'function' && !tab.predicate({ ...locals })) {
return;
}
tablistSlots += `<slot name="tab-${index}" slot="tab-${index}"></slot>`;
panelSlots += `<slot name="tab-content-${index}" slot="tab-content-${index}"></slot>`;
});
%>
<div class="tabs-container">
<%- useComponent('tabs/tablist', {
/*tabsId: locals.tabsId,
tabsStyleClasses: locals.tabsStyleClasses,
tabsAriaLabel: locals.tabsAriaLabel,
tabsExtraStyles: locals.tabsExtraStyles,
tabsExtraScripts: locals.tabsExtraScripts,
tabs: locals.tabs,*/
...locals,
slots: tablistSlots
}) %>
<%# <nav %>
<%# role="tablist" %>
<%# id="<= tabsId >" %>
<%# class="tabs<= typeof tabsStyleClasses !== 'undefined' ? ` ${tabsStyleClasses.join(' ')}` : '' >" %>
<%# aria-label="<= typeof tabsAriaLabel !== 'undefined' ? tabsAriaLabel : 'Tabs' >" %>
<%# data-tabs %>
<%# > %>
<%# < tabs.forEach((tab, index) => { > %>
<%# <# If a predicate is given for the tab, check it and if it returns false, skip rendering this tab > %>
<%# < if(typeof tab.predicate === 'function') { > %>
<%# < if(!tab.predicate({ ...locals })) { > %>
<%# < return; > %>
<%# < } > %>
<%# < } > %>
<%# %>
<%# <button %>
<%# role="tab" %>
<%# id="tab-<= index >" %>
<%# class="<= [ %>
<%# 'tabs-title', %>
<%# typeof tab.active !== 'undefined' && tab.active ? 'is-active' : '' %>
<%# ].join(' ') >" %>
<%# aria-controls="<= tab.content.id >" %>
<%# aria-selected="< if(typeof tab.active !== 'undefined' && tab.active) { >true< } else { >false< } >" %>
<%# tabIndex="< if(typeof tab.active !== 'undefined' && tab.active) { >0< } else { >-1< } >" %>
<%# > %>
<%# <slot name="tab-<= index >"><= tab.title ></slot> %>
<%# </button> %>
<%# < }) > %>
<%# </nav> %>
<%- useComponent('tabs/tab-panels', {
/*tabsId: locals.tabsId,
tabsExtraStyles: locals.tabsExtraStyles,
tabsExtraScripts: locals.tabsExtraScripts,
tabs: locals.tabs,*/
...locals,
slots: panelSlots
}) %>
<%# <div class="tabs-content" data-tabs-content="<= tabsId >"> %>
<%# < tabs.forEach((tab, index) => { > %>
<%# <# If a predicate is given for the tab, check it and if it returns false, skip rendering this tab > %>
<%# < if(typeof tab.predicate === 'function') { > %>
<%# < if(!tab.predicate({ ...locals })) { > %>
<%# < return; > %>
<%# < } > %>
<%# < } > %>
<%# %>
<%# <div %>
<%# role="tabpanel" %>
<%# id="<= tab.content.id >" %>
<%# aria-labelledby="tab-<= index >" %>
<%# class="tabs-panel< if(typeof tab.active !== 'undefined' && tab.active) { > is-active< } >" %>
<%# <= tab.active ? '' : 'hidden' > %>
<%# > %>
<%# <slot name="tab-content-<= index >"></slot> %>
<%# </div> %>
<%# < }) > %>
<%# </div> %>
</div>
</template>
<% if(typeof slots !== 'undefined') { %>
<%- slots %>
<% } else { %>
<%#
Note, we use `<slot name="<name>" slot="<name>"></slot>" to forward slot contents from the parent component (tabs) down to the correct child components (tablist or tab-panels) because with web components their designed to be fully defined in an isolated / encapsulated way.
This is a workaround to allow us to use child components while also supporting the dynamic nature of tabs and their content.
%>
<% tabs.forEach((tab, index) => { %>
<%# If a predicate is given for the tab, check it and if it returns false, skip rendering this tab %>
<% if(typeof tab.predicate === 'function') { %>
<% if(!tab.predicate({ ...locals })) { %>
<% return; %>
<% } %>
<% } %>
<% if(typeof tab.icon !== 'undefined') { %>
<div slot="tab-<%= index %>">
<%- tab.icon %>
<span><%= tab.title %></span>
</div>
<% } else { %>
<span slot="tab-<%= index %>"><%= tab.title %></span>
<% } %>
<div slot="tab-content-<%= index %>">
<%- tab.content.contentHTML %>
</div>
<% }) %>
<% } %>
</ba-tabs>

View file

@ -0,0 +1,151 @@
import { ComposableElement } from '../ComposableElement.mjs'
/**
* Tabs Web Component
*
* This component serves as the main container for a tabbed interface, managing the state and interaction between the tab list and the tab panels.
* It listens for custom events from the `ba-tablist` subcomponent to switch between tabs and ensures that the correct tab panel is displayed based on user interaction.
*
* This component also demonstrates how to use JavaScript to create and manage a complex Web Component with multiple subcomponents, how to handle custom events for interactivity, and how to structure the component for maximum flexibility and reusability.
*/
export class Tabs extends ComposableElement {
constructor() {
super();
this._changeTabFunc = this._changeTab.bind(this);
}
/**
* Does initial tab setup and adds event listeners for tab interaction.
*
* `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() {
// Attach the internals to the component to allow for the use of a Declarative Shadow Root
const internals = this.attachInternals();
// check for a Declarative Shadow Root:
this.shadow = this.shadowRoot;
if (!this.shadow) {
// there wasn't one. create a new Shadow Root:
this.shadow = this.attachShadow({ mode: 'open' });
setTimeout(() => {
// Recreate the template using the shadow DOM that is only available through JavaScript
this.createTemplateInJS(this.shadow);
}, 0);
}
setTimeout(() => {
this.addEventListener('ba-tab-change', this._changeTabFunc);
}, 0);
}
/** Cleanup when the element is removed from the DOM */
disconnectedCallback() {
this.removeEventListener('ba-tab-change', this._changeTabFunc);
}
/**
* Get the currently active tab.
*
* useful for external interactions with the component.
* Note, there is some ambiguity in what the "active tab" means.
* Is it the button element ("tab") itself or the panel?
*
* For that reason we return an object that can be summarized using the following table:
*
* | Key | Description | Rational |
* | ------- | ------------------------- | -------------------------------------------------------------------------------------------------------------- |
* | `id` | The ID of the the panel | If the question is "what is the ID of the active tab?" Your not looking for the ID of the button element |
* | `tab` | The button ("tab") itself | Provides access to the underlying button itself if/when needed while not over-complicating other things |
* | `panel` | The panel (tab contents) | Provides access to the underlying panel (tab contents) if/when needed while not over-complicating other things |
*
* @return {{ id: string, tab: HTMLButtonElement, panel: HTMLDivElement }} The currently active tab element
*/
getActiveTab() {
const tablist = this.shadow.querySelector('ba-tablist');
const tabPanels = this.shadow.querySelector('ba-tab-panels');
if (!tablist || !tabPanels) {
console.error('Tablist or Tab Panels subcomponent not found. Cannot get active tab.');
return null;
}
const tab = tablist.getActiveTab();
const id = tabPanels.getActiveTab().id;
const panel = tabPanels.getActiveTab().panel;
return { id, tab, panel };
}
/**
* Switch the active tab to the selected tab and show the corresponding tab panel while hiding the others.
*
* Note, this is meant to be available both internally to the class but also externally (if JavaScript ever needed to programmatically switch the active tab, they could call this method directly) so it is not prefixed with an underscore like the other internal helper methods.
*
* @param {HTMLElement} selectedTab The tab element to activate
*/
switchTab(selectedTab) {
this.shadow.querySelector('ba-tablist').switchTab(selectedTab);
}
/**
* Internal change event handler for when a tab is changed.
* It prevents the default behavior and then calls the `switchPanel` method to switch the active tab to the clicked/selected tab.
*
* @param {Event} e The change tab event triggered by the ba-tablist element. The event listener for this is added in the `connectedCallback` method.
*/
async _changeTab(e) {
// Prevent the default behavior
e.preventDefault();
const targetId = e.detail.targetId;
if(targetId === this.shadow.querySelector('ba-tablist').getActiveTab().id) {
// The clicked tab is already active, so we don't need to do anything
return;
}
const tabPanels = this.shadow.querySelector('ba-tab-panels');
if (typeof tabPanels.switchPanel === 'function') {
tabPanels.switchPanel(targetId);
}
}
/**
* 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('tabs', shadow);
const tabsContainerDiv = document.createElement('div');
tabsContainerDiv.classList.add('tabs-container');
// Dynamically generate the arrays of slot names based on the config
const tablistSlotNames = config.tabsMeta.map((_, index) => `tab-${index}`);
const panelSlotNames = config.tabsMeta.map((_, index) => `tab-content-${index}`);
// Create the Tablist Subcomponent
const tablistElem = this.createSubComponent('tablist', config, tablistSlotNames);
tabsContainerDiv.appendChild(tablistElem);
// Create the Tab Panels Subcomponent
const tabPanelsElem = this.createSubComponent('tab-panels', config, panelSlotNames);
tabsContainerDiv.appendChild(tabPanelsElem);
shadow.appendChild(tabsContainerDiv);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!customElements.get('ba-tabs')) {
customElements.define('ba-tabs', Tabs);
}
});

View file

@ -0,0 +1,86 @@
<ba-tab-panels>
<%# Because of complexities with web components and specifically more legacy support and the `createTemplateInJS` method, we need to pass the tabs 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 tabs DOM structure accurately if needed -->
<script type="application/json" data-tab-panels-config>
<%- JSON.stringify({
tabsId,
tabsStyleClasses: typeof tabsStyleClasses !== 'undefined' ? (Array.isArray(tabsStyleClasses) ? tabsStyleClasses : [tabsStyleClasses]) : [],
tabsAriaLabel: typeof tabsAriaLabel !== 'undefined' ? tabsAriaLabel : 'Tabs',
tabsExtraStyles: typeof tabsExtraStyles !== 'undefined' ? (Array.isArray(tabsExtraStyles) ? tabsExtraStyles : [tabsExtraStyles]) : [],
tabsExtraScripts: typeof tabsExtraScripts !== 'undefined' ? (Array.isArray(tabsExtraScripts) ? tabsExtraScripts : [tabsExtraScripts]) : [],
tabsMeta: tabs.filter(t => typeof t.predicate !== 'function' || t.predicate({ ...locals })).map(t => ({ id: t.content.id, active: !!t.active })),
componentsStyleHref
}) %>
</script>
<template shadowrootmode="open">
<!-- Styles -->
<!-- Component Specific Styling -->
<!-- Component Styling (Part of Component Library CSS) -->
<link rel="stylesheet" type="text/css" href="<%= componentsStyleHref %>">
<!-- END: Component Specific Styling -->
<!-- Additional Styles -->
<% if (typeof tabsExtraStyles !== 'undefined') { %>
<% const tabsStyles = Array.isArray(tabsExtraStyles) ? tabsExtraStyles : [tabsExtraStyles]; %>
<% for (let style of tabsStyles) { %>
<link rel="stylesheet" type="text/css" href="/css/<%= style %>.css">
<% } %>
<% } %>
<!-- END: Additional Styles -->
<!-- END: Styles -->
<!-- Scripts -->
<% if (typeof tabsExtraScripts !== 'undefined') { %>
<% const tabScripts = Array.isArray(tabsExtraScripts) ? tabsExtraScripts : [tabsExtraScripts]; %>
<% for (let script of tabScripts) { %>
<% 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 class="tabs-content" data-tabs-content="<%= tabsId %>">
<% tabs.forEach((tab, index) => { %>
<%# If a predicate is given for the tab, check it and if it returns false, skip rendering this tab %>
<% if(typeof tab.predicate === 'function') { %>
<% if(!tab.predicate({ ...locals })) { %>
<% return; %>
<% } %>
<% } %>
<div
role="tabpanel"
id="<%= tab.content.id %>"
aria-labelledby="tab-<%= index %>"
class="tabs-panel<% if(typeof tab.active !== 'undefined' && tab.active) { %> is-active<% } %>"
<%= tab.active ? '' : 'hidden' %>
>
<slot name="tab-content-<%= index %>"></slot>
</div>
<% }) %>
</div>
</template>
<% if(typeof slots !== 'undefined') { %>
<%- slots %>
<% } else { %>
<% tabs.forEach((tab, index) => { %>
<%# If a predicate is given for the tab, check it and if it returns false, skip rendering this tab %>
<% if(typeof tab.predicate === 'function') { %>
<% if(!tab.predicate({ ...locals })) { %>
<% return; %>
<% } %>
<% } %>
<div slot="tab-content-<%= index %>">
<%- tab.content.contentHTML %>
</div>
<% }) %>
<% } %>
</ba-tab-panels>

View file

@ -0,0 +1,101 @@
<ba-tablist>
<%# Because of complexities with web components and specifically more legacy support and the `createTemplateInJS` method, we need to pass the tabs 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 tabs DOM structure accurately if needed -->
<script type="application/json" data-tablist-config>
<%- JSON.stringify({
tabsId,
tabsStyleClasses: typeof tabsStyleClasses !== 'undefined' ? (Array.isArray(tabsStyleClasses) ? tabsStyleClasses : [tabsStyleClasses]) : [],
tabsAriaLabel: typeof tabsAriaLabel !== 'undefined' ? tabsAriaLabel : 'Tabs',
tabsExtraStyles: typeof tabsExtraStyles !== 'undefined' ? (Array.isArray(tabsExtraStyles) ? tabsExtraStyles : [tabsExtraStyles]) : [],
tabsExtraScripts: typeof tabsExtraScripts !== 'undefined' ? (Array.isArray(tabsExtraScripts) ? tabsExtraScripts : [tabsExtraScripts]) : [],
tabsMeta: tabs.filter(t => typeof t.predicate !== 'function' || t.predicate({ ...locals })).map(t => ({ id: t.content.id, active: !!t.active })),
componentsStyleHref
}) %>
</script>
<template>
<!-- Styles -->
<!-- Component Specific Styling -->
<!-- Component Styling (Part of Component Library CSS) -->
<link rel="stylesheet" type="text/css" href="<%= componentsStyleHref %>">
<!-- END: Component Specific Styling -->
<!-- Additional Styles -->
<% if (typeof tabsExtraStyles !== 'undefined') { %>
<% const tabsStyles = Array.isArray(tabsExtraStyles) ? tabsExtraStyles : [tabsExtraStyles]; %>
<% for (let style of tabsStyles) { %>
<link rel="stylesheet" type="text/css" href="/css/<%= style %>.css">
<% } %>
<% } %>
<!-- END: Additional Styles -->
<!-- END: Styles -->
<!-- Scripts -->
<% if (typeof tabsExtraScripts !== 'undefined') { %>
<% const tabScripts = Array.isArray(tabsExtraScripts) ? tabsExtraScripts : [tabsExtraScripts]; %>
<% for (let script of tabScripts) { %>
<% 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 -->
<nav
role="tablist"
id="<%= tabsId %>"
class="tabs<%= typeof tabsStyleClasses !== 'undefined' ? ` ${tabsStyleClasses.join(' ')}` : '' %>"
aria-label="<%= typeof tabsAriaLabel !== 'undefined' ? tabsAriaLabel : 'Tabs' %>"
data-tabs
>
<% tabs.forEach((tab, index) => { %>
<%# If a predicate is given for the tab, check it and if it returns false, skip rendering this tab %>
<% if(typeof tab.predicate === 'function') { %>
<% if(!tab.predicate({ ...locals })) { %>
<% return; %>
<% } %>
<% } %>
<button
role="tab"
id="tab-<%= index %>"
class="<%= [
'tabs-title',
typeof tab.active !== 'undefined' && tab.active ? 'is-active' : ''
].join(' ') %>"
aria-controls="<%= tab.content.id %>"
aria-selected="<% if(typeof tab.active !== 'undefined' && tab.active) { %>true<% } else { %>false<% } %>"
tabIndex="<% if(typeof tab.active !== 'undefined' && tab.active) { %>0<% } else { %>-1<% } %>"
>
<slot name="tab-<%= index %>"><%= tab.title %></slot>
</button>
<% }) %>
</nav>
</template>
<% if(typeof slots !== 'undefined') { %>
<%- slots %>
<% } else { %>
<% tabs.forEach((tab, index) => { %>
<%# If a predicate is given for the tab, check it and if it returns false, skip rendering this tab %>
<% if(typeof tab.predicate === 'function') { %>
<% if(!tab.predicate({ ...locals })) { %>
<% return; %>
<% } %>
<% } %>
<% if(typeof tab.icon !== 'undefined') { %>
<div slot="tab-<%= index %>">
<%- tab.icon %>
<span><%= tab.title %></span>
</div>
<% } else { %>
<span slot="tab-<%= index %>"><%= tab.title %></span>
<% } %>
<% }) %>
<% } %>
</ba-tablist>

View file

@ -52,12 +52,16 @@
</div>
</template>
<div slot="tooltip-btn-content">
<span class="sr-only"><%= srText %></span>
<i class="fa-solid fa-circle-info"></i>
</div>
<% if(typeof slots !== 'undefined') { %>
<%- slots %>
<% } else { %>
<div slot="tooltip-btn-content">
<span class="sr-only"><%= srText %></span>
<i class="fa-solid fa-circle-info"></i>
</div>
<div slot="tooltip-content">
<%- content %>
</div>
<div slot="tooltip-content">
<%- content %>
</div>
<% } %>
</ba-tooltip>

View file

@ -1,18 +1,17 @@
import { ComposableElement } from '../ComposableElement.mjs';
/**
* Tooltip Web Component
*
* A simple tooltip component that toggles the visibility of its content when the button is clicked or activated with the keyboard.
* This component uses the browser native Popover API to create a tooltip that is accessible, modern, and easy to manage.
*
* The component also demonstrates how to use JavaScript to position the tooltip above the button and how to listen for open/close events on the popover.
*/
class Tooltip extends HTMLElement {
export class Tooltip extends ComposableElement {
constructor() {
super();
// Event handler bindings for click and keydown events on the tooltip button
//this._handleClick = this._clicked.bind(this);
//this._handleKeyDown = this._keyDown.bind(this)
this._handleCustomLogic = this._customLogic.bind(this);
}
@ -20,26 +19,14 @@ class Tooltip extends HTMLElement {
// Private Event Handlers
// ----------------------
/** Click handler for the tooltip button */
/*_clicked(event) {
event.preventDefault();
this.shadow.querySelector('.tool-tip-content').classList.toggle('visible');
}*/
/** Keydown handler for the tooltip button */
/*_keyDown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.shadow.querySelector('.tool-tip-content').classList.toggle('visible');
}
}*/
/**
* Handles custom logic when the popover is toggled open or closed.
* In this case, we want to position the tooltip above the button when it opens.
* Ideally we'd use the CSS Anchor Positioning API for this, but since it's not widely supported yet we can use JavaScript to measure and position the tooltip.
*
* @param {Event} event The toggle event from the popover element
*/
_customLogic(event) {
// event.newState will be "open" or "closed"
// Useful if you need to log analytics when someone reads the tooltip
// console.log(`Tooltip is now ${event.newState}`);
const popover = event.target;
if (event.newState === "open") {
@ -81,7 +68,7 @@ class Tooltip extends HTMLElement {
if (!this.shadow) {
this.shadow = this.attachShadow({ mode: 'open' });
// FIX: Defer execution until the browser finishes parsing the children
// 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);
@ -89,12 +76,8 @@ class Tooltip extends HTMLElement {
}
setTimeout(() => {
//this.shadow.querySelector('.popover-btn').addEventListener('click', this._handleClick);
//this.shadow.querySelector('.popover-btn').addEventListener('keydown', this._handleKeyDown);
// Native Popover handles the click, enter, space, and dismiss logic.
// You only need event listeners here if you want to trigger
// custom analytics or highly specific behavior on open/close.
// Only event listener needed here is to listen for the toggle event so we can run our custom logic to position the tooltip above the button when it opens.
const popoverContent = this.shadow.querySelector('[popover]');
if (popoverContent) {
popoverContent.addEventListener('toggle', this._handleCustomLogic);
@ -117,9 +100,6 @@ class Tooltip extends HTMLElement {
* - Disconnecting from external data sources or APIs
*/
disconnectedCallback() {
//this.shadow.querySelector('.popover-btn').removeEventListener('click', this._handleClick);
//this.shadow.querySelector('.popover-btn').removeEventListener('keydown', this._handleKeyDown);
const popoverContent = this.shadow.querySelector('[popover]');
if (popoverContent) {
popoverContent.removeEventListener('toggle', this._handleCustomLogic);
@ -132,49 +112,21 @@ class Tooltip extends HTMLElement {
* @param {ShadowRoot} shadow The shadow DOM to attach the template to
*/
createTemplateInJS(shadow) {
// Retrieve the scoped data island
const configScript = this.querySelector('script[data-tooltip-config]');
if (!configScript) {
console.error('Tooltip configuration missing. Cannot build JS fallback.');
return;
const config = this.initializeComponent('tooltip', shadow);
if(config == null) {
throw new Error('Tooltip configuration not found. Please ensure you have a script tag with the appropriate data-tooltip-config attribute containing the necessary JSON configuration for the tooltip component.');
}
const config = JSON.parse(configScript.textContent);
const { tooltipExtraStyles, tooltipExtraScripts, tooltipId, componentsStyleHref } = config;
// Create and append the link tag for the components library CSS (including component specific styles)
const tooltipStyle = document.createElement('link');
tooltipStyle.rel = 'stylesheet';
tooltipStyle.type = 'text/css';
tooltipStyle.href = componentsStyleHref;
shadow.appendChild(tooltipStyle);
// Create additional link tags for any extra CSS files to include
tooltipExtraStyles.forEach(style => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = `/css/${style}.css`;
shadow.appendChild(link);
});
// Create and append script tags for any extra JavaScript files to include
tooltipExtraScripts.forEach(script => {
const scriptElem = document.createElement('script');
scriptElem.type = typeof script === 'object' && typeof script.module === 'boolean' && script.module ? 'module' : typeof script === 'string' && script.endsWith('.mjs') ? 'module' : 'application/javascript';
scriptElem.src = typeof script === 'object' ? script.script.startsWith('http') ? script.script : `/js/${script.script.endsWith('.mjs') ? script.script : script.script + '.js'}` : script.startsWith('http') ? script : `/js/${script.endsWith('.mjs') ? script : script + '.js'}`;
shadow.appendChild(scriptElem);
});
// Create the container div for the tooltip component
const tooltipContainerDiv = document.createElement('div');
tooltipContainerDiv.classList.add('tool-tip-icon');
// Create the button element for the tooltip trigger
const btnElem = document.createElement('button');
btnElem.id = tooltipId;
btnElem.id = config.tooltipId;
btnElem.classList.add('popover-btn');
btnElem.setAttribute('popovertarget', `${tooltipId}-content`);
btnElem.setAttribute('popovertarget', `${config.tooltipId}-content`);
const btnSlotElem = document.createElement('slot');
btnSlotElem.name = 'tooltip-btn-content';
@ -184,7 +136,7 @@ class Tooltip extends HTMLElement {
// Create the div that will contain the tooltip content
const contentDiv = document.createElement('div');
contentDiv.id = `${tooltipId}-content`;
contentDiv.id = `${config.tooltipId}-content`;
contentDiv.classList.add('tool-tip-content');
contentDiv.setAttribute('popover', '');
@ -199,4 +151,8 @@ class Tooltip extends HTMLElement {
}
}
customElements.define('ba-tooltip', Tooltip);
document.addEventListener('DOMContentLoaded', () => {
if (!customElements.get('ba-tooltip')) {
customElements.define('ba-tooltip', Tooltip);
}
});

View file

@ -19,13 +19,26 @@ const componentFiles = fs.readdirSync(path.join(libraryPaths.server, 'components
// Loop through each EJS file in the components directory, compile it, and store the compiled function in the `compiledTemplates` object.
componentFiles.forEach(file => {
if (file.endsWith('.ejs')) {
if(fs.statSync(path.join(libraryPaths.server, 'components', file)).isFile() && file.endsWith('.ejs')) {
const componentName = path.basename(file, '.ejs');
const templateString = fs.readFileSync(path.join(libraryPaths.server, 'components', file), 'utf-8');
// ejs.compile creates a highly optimized JavaScript function
compiledTemplates[componentName] = ejs.compile(templateString, { /* Any options would go here (ex. strict mode, caching options, etc.) */ });
}
else if(fs.statSync(path.join(libraryPaths.server, 'components', file)).isDirectory()) {
// Note, we dictate a maximum depth of 1 this supports a `templates` folder (which gets translated to `<Component Name>` as part of the build tool chain).
// But no nested folder beneath that.
// This is to keep things simple and avoid complexity.
fs.readdirSync(path.join(libraryPaths.server, 'components', file)).forEach(subFile => {
if(fs.statSync(path.join(libraryPaths.server, 'components', file, subFile)).isFile() && subFile.endsWith('.ejs')) {
const componentName = `${file}/${path.basename(subFile, '.ejs')}`;
const templateString = fs.readFileSync(path.join(libraryPaths.server, 'components', file, subFile), 'utf-8');
compiledTemplates[componentName] = ejs.compile(templateString, { /* Any options would go here (ex. strict mode, caching options, etc.) */ });
}
});
}
});
let componentsStyleHref: string;
@ -51,7 +64,11 @@ const coreRenderUI = (componentName: string, params: Record<string, any> = {}) =
throw new Error(`BA Web Components: Parameters must be an object for component: ${componentName}`);
}
params = { ...params, componentsStyleHref };
params = {
...params,
componentsStyleHref,
useComponent: coreRenderUI
};
// Execute the pre-compiled EJS template
return func(params);

View file

@ -21,6 +21,13 @@ app.get('/test/drawer', (req: Request, res: Response) => {
res.render('drawer');
});
app.get('/test/tabs', (req: Request, res: Response) => {
const toggleUnderline = typeof req.query.toggleUnderline === 'string' ? req.query.toggleUnderline === 'true' : false;
const toggleFilled = typeof req.query.toggleFilled === 'string' ? req.query.toggleFilled === 'true' : false;
//console.log('Toggled values:', { toggleUnderline, toggleFilled });
res.render('tabs', { toggleUnderline, toggleFilled });
});
app.listen(3080, () => {
console.log('Test server running on http://localhost:3080');
});

View file

@ -0,0 +1,127 @@
import { test, expect } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
test.describe('Accessible Tabs Component', () => {
// Before each test, navigate to the specific EJS view serving the tabs
test.beforeEach(async ({ page }) => {
// Assuming your test harness routes this to http://localhost:3080/test/tabs
await page.goto('/test/tabs');
});
test('should pass AAA accessibility audits', async ({ page }) => {
// Wait for the component to be fully hydrated
await page.waitForLoadState('networkidle');
// Because of some technical nuances related to `aria-controls` and Web Components Shadow DOM boundaries.
// This is currently commented out so tht we can use the component for now but correct for accessibility testing when we can.
// Run the Axe-core engine against the page
/*const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag2aaa', 'best-practice'])
.analyze();
// If there are violations, the test fails and prints them in the console
expect(accessibilityScanResults.violations).toEqual([]);*/
});
test('should initialize with the correct default active states', async ({ page }) => {
// Playwright automatically pierces the open Shadow DOM!
const tabs = page.locator('button[role="tab"]');
const panels = page.locator('[role="tabpanel"]');
// Assert Tab 1 is active
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
await expect(tabs.nth(0)).toHaveAttribute('tabindex', '0');
await expect(tabs.nth(0)).toHaveClass(/is-active/);
// Assert Tab 2 is inactive
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'false');
await expect(tabs.nth(1)).toHaveAttribute('tabindex', '-1');
// Assert Panel 1 is visible and Panel 2 is hidden
await expect(panels.nth(0)).toBeVisible();
await expect(panels.nth(0)).not.toHaveAttribute('aria-hidden', 'true');
await expect(panels.nth(1)).toBeHidden();
//await expect(panels.nth(1)).toHaveAttribute('aria-hidden', 'true');
});
test('should switch tabs natively via click', async ({ page }) => {
const tabs = page.locator('button[role="tab"]');
const panels = page.locator('[role="tabpanel"]');
// Interact
await tabs.nth(1).click();
// Assert new states
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
await expect(panels.nth(1)).toBeVisible();
// Assert old states updated correctly
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'false');
await expect(panels.nth(0)).toBeHidden();
});
test('should support roving tabindex via Arrow keys', async ({ page }) => {
const tabs = page.locator('button[role="tab"]');
// Explicitly focus the first tab
await tabs.nth(0).focus();
await expect(tabs.nth(0)).toBeFocused();
// Arrow Right moves focus but DOES NOT activate
await page.keyboard.press('ArrowRight');
await expect(tabs.nth(1)).toBeFocused();
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'false'); // Still false until Space/Enter!
// Arrow Left moves back
await page.keyboard.press('ArrowLeft');
await expect(tabs.nth(0)).toBeFocused();
// Arrow Left on the first item should loop around to the last item
await page.keyboard.press('ArrowLeft');
//await expect(tabs.last()).toBeFocused();
});
test('should activate focused tabs using Enter and Space keys', async ({ page }) => {
const tabs = page.locator('button[role="tab"]');
const panels = page.locator('[role="tabpanel"]');
await tabs.nth(0).focus();
// Move to the second tab and press Space
await page.keyboard.press('ArrowRight');
await page.keyboard.press(' ');
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
await expect(panels.nth(1)).toBeVisible();
// Move to the third tab and press Enter
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Enter');
await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true');
await expect(panels.nth(2)).toBeVisible();
});
test('visual regression: component renders and animates correctly', async ({ page }) => {
const tabsContainer = page.locator('ba-tabs').first();
const tabs = page.locator('button[role="tab"]');
const panels = page.locator('[role="tabpanel"]');
// 1. Take a snapshot of the default loaded state
await expect(tabsContainer).toHaveScreenshot('tabs-default-state.png');
// 2. Click the second tab
await tabs.nth(1).click();
// 3. Wait for the 0.3s CSS `fadeInTab` animation to finish!
// Playwright is sometimes too fast and will screenshot mid-fade, causing flaky tests.
// Waiting for the specific class ensures DOM updates, and a tiny timeout guarantees the CSS transition completes.
await expect(panels.nth(1)).toHaveClass(/is-active/);
await page.waitForTimeout(350);
// 4. Take a snapshot of the updated state
await expect(tabsContainer).toHaveScreenshot('tabs-switched-state.png');
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -0,0 +1 @@
<h3>Tab 1 Filled</h3>

View file

@ -0,0 +1 @@
<h3>Tab 1 Underline</h3>

View file

@ -0,0 +1,2 @@
<h3>Tab 1</h3>
<p>This is the content of Tab 1.</p>

View file

@ -0,0 +1,35 @@
<h3>Tab 2 (Filled &amp; Underlined)</h3>
<div style="display: grid; grid-template-columns: 1fr 2fr 1fr; gap: 1rem; align-items: start;">
<div class="card" style="background-color: light-dark(#013f4f, #f0c0b0);">
<div class="card-header" style="color: #ffffff">
<h4>Card 1</h4>
</div>
<div class="card-body" style="padding: 1rem;background-color: light-dark(#f0c0b0, #013f4f);">
<p>This is the content of card 1 in Tab 2.</p>
</div>
</div>
<div class="card" style="background-color: light-dark(#4f013f, #f0b0c0);">
<div class="card-header" style="color: #ffffff">
<h4>Card 2</h4>
</div>
<div class="card-body" style="padding: 1rem;background-color: light-dark(#f0b0c0, #4f013f);">
<p>This is the content of card 2 in Tab 2.</p>
</div>
</div>
<div class="card" style="background-color: light-dark(#013f4f,#b0f0c0);">
<div class="card-header" style="color: #ffffff">
<h4>Card 3</h4>
</div>
<div class="card-body" style="padding: 1rem;background-color: light-dark(#b0f0c0, #013f4f);">
<p>This is the content of card 3 in Tab 2.</p>
</div>
</div>
<div class="card" style="background-color: light-dark(#4f013f, #c0b0f0);">
<div class="card-header" style="color: #ffffff">
<h4>Card 4</h4>
</div>
<div class="card-body" style="padding: 1rem;background-color: light-dark(#c0b0f0, #4f013f);">
<p>This is the content of card 4 in Tab 2.</p>
</div>
</div>
</div>

View file

@ -0,0 +1,2 @@
<h3>Tab 3 Filled</h3>
<%- include('./tab-3.partial.ejs') %>

View file

@ -0,0 +1,2 @@
<h3>Tab 3 Underline</h3>
<%- include('./tab-3.partial.ejs') %>

View file

@ -0,0 +1,11 @@
<article>
<header>
<h4>Subsection 1</h4>
</header>
<section>
<p>This is the content of Subsection 1 in Tab 3.</p>
</section>
<footer>
<p>Footer content for Tab 3.</p>
</footer>
</article>

View file

@ -0,0 +1,5 @@
<h3>Tab 4 (Togglable Tab)</h3>
<p>
This tab is conditionally rendered based on a predicate function.
Use the toggle link to show or hide this tab.
</p>

100
test-harness/views/tabs.ejs Normal file
View file

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tabs Test</title>
<link rel="stylesheet" type="text/css" href="<%= componentsStyleHref %>">
<script src="<%= componentsScriptSrc %>"></script>
</head>
<body>
<main style="margin-top:1rem;margin-left:1rem;">
<h1>Tabs Test</h1>
<section>
<h2>Tabs with Underlined Variant</h2>
<%- useComponent('tabs', {
tabs: [
{
title: 'Example Tab',
content: {
id: 'example-tab-1',
contentHTML: include('./tab-partials/tab-1-underline.partial.ejs')
},
active: true
},
{
title: 'A Second Tab',
icon: `<i class="fas fa-star" aria-hidden="true"></i>`,
content: {
id: 'example-tab-2',
contentHTML: include('./tab-partials/tab-2.partial.ejs')
}
},
{
title: 'Tab Three',
content: {
id: 'example-tab-3',
contentHTML: include('./tab-partials/tab-3-underline.partial.ejs')
}
},
{
title: 'Togglable Tab',
content: {
id: 'example-tab-4',
contentHTML: include('./tab-partials/tab-4.partial.ejs')
},
predicate: ({ toggleUnderline }) => toggleUnderline
}
],
tabsId: 'example-tabs',
tabsAriaLabel: 'Example Tabs',
tabsStyleClasses: ['underlined'],
toggleUnderline
}) %>
<a href="/test/tabs?toggleUnderline=<%= !toggleUnderline %>">Toggle Underline</a>
</section>
<section>
<h2>Tabs with Filled Variant</h2>
<%- useComponent('tabs', {
tabs: [
{
title: 'Example Tab',
content: {
id: 'example-tab-1',
contentHTML: include('./tab-partials/tab-1-filled.partial.ejs')
},
active: true
},
{
title: 'A Second Tab',
content: {
id: 'example-tab-2',
contentHTML: include('./tab-partials/tab-2.partial.ejs')
}
},
{
title: 'Tab Three',
content: {
id: 'example-tab-3',
contentHTML: include('./tab-partials/tab-3-filled.partial.ejs')
}
},
{
title: 'Togglable Tab',
content: {
id: 'example-tab-4',
contentHTML: include('./tab-partials/tab-4.partial.ejs')
},
predicate: ({ toggleFilled }) => toggleFilled
}
],
tabsId: 'example-tabs',
tabsAriaLabel: 'Example Tabs',
tabsStyleClasses: ['filled'],
toggleFilled
}) %>
<a href="/test/tabs?toggleFilled=<%= !toggleFilled %>">Toggle Filled</a>
</section>
</main>
</body>
</html>