From d2e99cc280d7e2877bd7e981eb07abff4537410f Mon Sep 17 00:00:00 2001 From: Alan Bridgeman Date: Mon, 18 May 2026 13:56:31 -0500 Subject: [PATCH] Added tabs components and refactored stuff to work properly with composed / nested elements etc... --- gulpfile.mjs | 54 ++-- src/components/ComposableElement.mjs | 124 +++++++++ src/components/drawer/drawer.ejs | 16 +- .../drawer/{drawer.js => drawer.mjs} | 54 +--- src/components/tabs/scripts/tab-panels.mjs | 163 ++++++++++++ src/components/tabs/scripts/tablist.mjs | 251 ++++++++++++++++++ src/components/tabs/tabs.css | 129 +++++++++ src/components/tabs/tabs.ejs | 167 ++++++++++++ src/components/tabs/tabs.mjs | 151 +++++++++++ src/components/tabs/templates/tab-panels.ejs | 86 ++++++ src/components/tabs/templates/tablist.ejs | 101 +++++++ src/components/tooltip/tooltip.ejs | 20 +- .../tooltip/{tooltip.js => tooltip.mjs} | 94 ++----- src/index.ts | 21 +- test-harness/server.ts | 7 + test-harness/tests/tabs.spec.ts | 127 +++++++++ .../tabs-default-state-chromium-linux.png | Bin 0 -> 9410 bytes .../tabs-switched-state-chromium-linux.png | Bin 0 -> 35301 bytes .../tab-partials/tab-1-filled.partial.ejs | 1 + .../tab-partials/tab-1-underline.partial.ejs | 1 + .../views/tab-partials/tab-1.partial.ejs | 2 + .../views/tab-partials/tab-2.partial.ejs | 35 +++ .../tab-partials/tab-3-filled.partial.ejs | 2 + .../tab-partials/tab-3-underline.partial.ejs | 2 + .../views/tab-partials/tab-3.partial.ejs | 11 + .../views/tab-partials/tab-4.partial.ejs | 5 + test-harness/views/tabs.ejs | 100 +++++++ 27 files changed, 1579 insertions(+), 145 deletions(-) create mode 100644 src/components/ComposableElement.mjs rename src/components/drawer/{drawer.js => drawer.mjs} (72%) create mode 100644 src/components/tabs/scripts/tab-panels.mjs create mode 100644 src/components/tabs/scripts/tablist.mjs create mode 100644 src/components/tabs/tabs.css create mode 100644 src/components/tabs/tabs.ejs create mode 100644 src/components/tabs/tabs.mjs create mode 100644 src/components/tabs/templates/tab-panels.ejs create mode 100644 src/components/tabs/templates/tablist.ejs rename src/components/tooltip/{tooltip.js => tooltip.mjs} (61%) create mode 100644 test-harness/tests/tabs.spec.ts create mode 100644 test-harness/tests/tabs.spec.ts-snapshots/tabs-default-state-chromium-linux.png create mode 100644 test-harness/tests/tabs.spec.ts-snapshots/tabs-switched-state-chromium-linux.png create mode 100644 test-harness/views/tab-partials/tab-1-filled.partial.ejs create mode 100644 test-harness/views/tab-partials/tab-1-underline.partial.ejs create mode 100644 test-harness/views/tab-partials/tab-1.partial.ejs create mode 100644 test-harness/views/tab-partials/tab-2.partial.ejs create mode 100644 test-harness/views/tab-partials/tab-3-filled.partial.ejs create mode 100644 test-harness/views/tab-partials/tab-3-underline.partial.ejs create mode 100644 test-harness/views/tab-partials/tab-3.partial.ejs create mode 100644 test-harness/views/tab-partials/tab-4.partial.ejs create mode 100644 test-harness/views/tabs.ejs diff --git a/gulpfile.mjs b/gulpfile.mjs index 14de745..5045b02 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -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); diff --git a/src/components/ComposableElement.mjs b/src/components/ComposableElement.mjs new file mode 100644 index 0000000..56e7cd2 --- /dev/null +++ b/src/components/ComposableElement.mjs @@ -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 }> }} 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} 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; + } +} \ No newline at end of file diff --git a/src/components/drawer/drawer.ejs b/src/components/drawer/drawer.ejs index 71bd7ed..bcb8f90 100644 --- a/src/components/drawer/drawer.ejs +++ b/src/components/drawer/drawer.ejs @@ -52,11 +52,15 @@ -
- <%- label %> -
+ <% if(typeof slots !== 'undefined') { %> + <%- slots %> + <% } else { %> +
+ <%- label %> +
-
- <%- content %> -
+
+ <%- content %> +
+ <% } %> \ No newline at end of file diff --git a/src/components/drawer/drawer.js b/src/components/drawer/drawer.mjs similarity index 72% rename from src/components/drawer/drawer.js rename to src/components/drawer/drawer.mjs index bf87b97..fad32b1 100644 --- a/src/components/drawer/drawer.js +++ b/src/components/drawer/drawer.mjs @@ -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); \ No newline at end of file +document.addEventListener('DOMContentLoaded', () => { + if (!customElements.get('ba-drawer')) { + customElements.define('ba-drawer', Drawer); + } +}); \ No newline at end of file diff --git a/src/components/tabs/scripts/tab-panels.mjs b/src/components/tabs/scripts/tab-panels.mjs new file mode 100644 index 0000000..b335d77 --- /dev/null +++ b/src/components/tabs/scripts/tab-panels.mjs @@ -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); + } +}); \ No newline at end of file diff --git a/src/components/tabs/scripts/tablist.mjs b/src/components/tabs/scripts/tablist.mjs new file mode 100644 index 0000000..8317836 --- /dev/null +++ b/src/components/tabs/scripts/tablist.mjs @@ -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); + } +}); \ No newline at end of file diff --git a/src/components/tabs/tabs.css b/src/components/tabs/tabs.css new file mode 100644 index 0000000..ea7cb99 --- /dev/null +++ b/src/components/tabs/tabs.css @@ -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 (