Inital code commit
Some checks failed
Build, Test, and Publish (to Private NPM Registry) UI Components Library / publish (push) Failing after 52m26s

This commit is contained in:
Alan Bridgeman 2026-05-13 01:39:35 -05:00
commit 5024375e20
32 changed files with 5379 additions and 0 deletions

View file

@ -0,0 +1,19 @@
# Accessible Drawer
Drawers allow for the opening and closing of information sections.
## Usage
to use this component:
```ejs
<%- useComponent('drawer', { id: 'example-drawer', content: `<div><h2>Hello World!</h2><p>This is a drawer!</p></div>` }) %>
```
## Parameters
| Parameter | type | Description |
| -------------------- | ------- | ------------------------------------------------------------ |
| `id` | string | The ID of the drawer |
| `label` | HTML | The label on the drawer's "handle" (button) |
| `content` | HTML | The content of the drawer |
| `drawerExtraStyles` | Various | Extra CSS to be included in the components Shadow DOM |
| `drawerExtraScripts` | Various | Extra JS scripts to be included in the components Shadow DOM |

View file

@ -0,0 +1,25 @@
.drawer {
display: flex;
width: 100%;
flex-direction: column;
}
.drawer-header {
padding: 0.5rem 1rem;
cursor: pointer;
border: 1px solid light-dark(#000000, #FFFFFF);
}
.drawer-header:hover, .drawer-header:focus {
background-color: light-dark(#000000, #FFFFFF);
color: light-dark(#FFFFFF, #000000);
}
.drawer-contents {
padding: 0.5rem 1rem;
}
.hidden {
display: none;
visibility: hidden;
}

View file

@ -0,0 +1,62 @@
<ba-drawer>
<%# Because of complexities with web components and specifically more legacy support and the `createTemplateInJS` method, we need to pass the drawer 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 drawer DOM structure accurately if needed -->
<script type="application/json" data-drawer-config>
<%- JSON.stringify({
drawerId: id,
drawerExtraStyles: typeof drawerExtraStyles !== 'undefined' ? (Array.isArray(drawerExtraStyles) ? drawerExtraStyles : [drawerExtraStyles]) : [],
drawerExtraScripts: typeof drawerExtraScripts !== 'undefined' ? (Array.isArray(drawerExtraScripts) ? drawerExtraScripts : [drawerExtraScripts]) : [],
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 drawerExtraStyles !== 'undefined') { %>
<% const drawerStyles = Array.isArray(drawerExtraStyles) ? drawerExtraStyles : [drawerExtraStyles]; %>
<% for (let style of drawerStyles) { %>
<link rel="stylesheet" type="text/css" href="/css/<%= style %>.css">
<% } %>
<% } %>
<!-- END: Additional Styles -->
<!-- END: Styles -->
<!-- Scripts -->
<% if (typeof drawerExtraScripts !== 'undefined') { %>
<% const drawerScripts = Array.isArray(drawerExtraScripts) ? drawerExtraScripts : [drawerExtraScripts]; %>
<% for (let script of drawerScripts) { %>
<% if(typeof script === 'object') { %>
<script type="<%= typeof script.module === 'boolean' && script.module ? 'module' : 'application/javascript' %>" src="<%= script.script %>"></script>
<% } else if(typeof script === 'string' && (script.startsWith('http') || script.startsWith('https'))) { %>
<script type="<%= script.endsWith('.mjs') ? 'module' : 'application/javascript' %>" src="<%= script %>"></script>
<% } else if(typeof script === 'string') { %>
<script type="<%= script.endsWith('.mjs') ? 'module' : 'application/javascript' %>" src="/js/<%= script.endsWith('.mjs') ? script : script + '.js' %>"></script>
<% } %>
<% } %>
<% } %>
<!-- END: Scripts -->
<div id="<%= id %>" class="drawer">
<div id="<%= id %>-drawer-header" role="button" class="drawer-header" tabindex="0" aria-expanded="false" aria-controls="<%= id %>-drawer-contents">
<slot name="drawer-header"></slot>
</div>
<div id="<%= id %>-drawer-contents" class="drawer-contents hidden">
<slot name="drawer-contents"></slot>
</div>
</div>
</template>
<div slot="drawer-header">
<%- label %>
</div>
<div slot="drawer-contents">
<%- content %>
</div>
</ba-drawer>

View file

@ -0,0 +1,180 @@
/**
* Drawer Web Component
*
* A simple drawer component that toggles the visibility of its content when the header is clicked or activated with the keyboard.
*/
class Drawer extends HTMLElement {
constructor() {
super();
// Event handler bindings for click and keydown events on the drawer handle (button)
this._handleClick = this._clicked.bind(this);
this._handleKeyDown = this._keyDown.bind(this)
}
// ----------------------
// Private Event Handlers
// ----------------------
/** Click handler for the drawer "handle" (button) */
_clicked(event) {
event.preventDefault();
event.currentTarget.setAttribute('aria-expanded', event.currentTarget.getAttribute('aria-expanded') === 'true' ? 'false' : 'true');
this.contentDiv.classList.toggle('hidden');
}
/** Keydown handler for the drawer "handle" (button) */
_keyDown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.currentTarget.setAttribute('aria-expanded', event.currentTarget.getAttribute('aria-expanded') === 'true' ? 'false' : 'true');
this.contentDiv.classList.toggle('hidden');
}
}
// -------------------------------
// Web Component Lifecycle Methods
// -------------------------------
/**
* Does initial setup and adds event listeners for interactivity
*
* `connectedCallback` is a lifecycle method in web components that runs when the custom element is inserted into the document's Document Object Model (DOM).
* It can be invoked multiple times if the element is removed and then re-inserted into the DOM.
*
* Timing: It is called after the element's constructor() but before the element's children are necessarily connected or fully rendered.
* Purpose: It is the ideal place to set up tasks that should only occur when the element is actually present in the live document. Common uses include:
*/
connectedCallback() {
const internals = this.attachInternals();
this.shadow = this.shadowRoot;
if (!this.shadow) {
this.shadow = this.attachShadow({ mode: 'open' });
// Defer execution until the browser finishes parsing the children
setTimeout(() => {
// Recreate the template using the shadow DOM that is only available through JavaScript
this.createTemplateInJS(this.shadow);
}, 0);
}
setTimeout(() => {
// 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.
const drawerHandle = this.shadow.querySelector('[aria-controls]');
if (drawerHandle) {
// Get the content div that the drawer handle controls so we can toggle it in the click and keydown handlers
this.contentDiv = this.shadow.querySelector(`#${drawerHandle.getAttribute('aria-controls')}`);
// Add event listeners to the drawer handle (button)
drawerHandle.addEventListener('click', this._handleClick);
drawerHandle.addEventListener('keydown', this._handleKeyDown);
}
}, 0);
}
/**
* Cleans up event listeners when the component is removed from the DOM
*
* `disconnectedCallback` is a lifecycle method in web components that runs when the custom element is removed from the document's DOM.
* It can be invoked multiple times if the element is removed and then re-inserted into the DOM.
*
* Timing: It is called after the element is removed from the DOM but before it is garbage collected.
* Purpose: It is the ideal place to clean up any resources or event listeners that were set up in `connectedCallback`.
*
* Common uses include:
* - Removing event listeners to prevent memory leaks
* - Clearing timers or intervals
* - Disconnecting from external data sources or APIs
*/
disconnectedCallback() {
const drawerHandle = this.shadow.querySelector('[aria-controls]');
if (drawerHandle) {
drawerHandle.removeEventListener('click', this._handleClick);
drawerHandle.removeEventListener('keydown', this._handleKeyDown);
}
}
/**
* 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) {
// 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);
});
// Create the container div for the drawer component
const drawerContainerDiv = document.createElement('div');
drawerContainerDiv.id = drawerId;
drawerContainerDiv.classList.add('drawer');
// Create the button element for the tooltip trigger
const drawerBtnElem = document.createElement('div');
drawerBtnElem.id = `${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`);
const drawerHeaderSlotElem = document.createElement('slot');
drawerHeaderSlotElem.name = 'drawer-header';
drawerBtnElem.appendChild(drawerHeaderSlotElem);
drawerContainerDiv.appendChild(drawerBtnElem);
// Create the div that will contain the drawer content
const contentDiv = document.createElement('div');
contentDiv.id = `${drawerId}-drawer-contents`;
contentDiv.classList.add('drawer-contents', 'hidden');
const contentSlotElem = document.createElement('slot');
contentSlotElem.name = 'drawer-contents';
contentDiv.appendChild(contentSlotElem);
drawerContainerDiv.appendChild(contentDiv);
// Finally, append the entire drawer container div to the shadow DOM of the component
shadow.appendChild(drawerContainerDiv);
}
}
customElements.define('ba-drawer', Drawer);

7
src/components/import.css vendored Normal file
View file

@ -0,0 +1,7 @@
/* Import Open Props (or specific modules if you don't want the whole library) */
@import "open-props/index.css";
@import "open-props/normalize.css";
@import "./library.css";
/* Component Styles */

View file

@ -0,0 +1,10 @@
/* Define Brand Overrides */
:root {
/* Overriding an Open Prop with Bridgeman Accessible brand color */
--brand-primary: var(--indigo-7); /* Or your exact hex code */
--font-sans: 'Your Brand Font', system-ui, sans-serif;
/* You can even alias them to the generic vars your components use */
--tooltip-bg: var(--gray-9);
}

View file

@ -0,0 +1,19 @@
# Accessible Tooltip
Where many tooltips can be incredibly frustrated and inaccessible in a variety of ways to different users (ex. only available on hover so screen magnifier users struggle, not properly setup for screen readers, only icon or text not multi-modal, etc...). This component endeavors to create one that addresses these issues. It uses the browser native Popover API in conjunction with good semantics and well structured client JS code to create something that hopefully works for everyone.
## Usage
to use this component:
```ejs
<%- useComponent('tooltip', { id: 'example-tip', srText: 'Example Tooltip', content: `<div><h2>Hello World!</h2><p>This is a tooltip!</p></div>` }) %>
```
## Parameters
| Parameter | Description |
| --------------------- | ------------------------------------------------------------ |
| `id` | The ID of the tooltip |
| `content` | The content of the tooltip |
| `srText` | The screen reader text used for the tooltip |
| `tooltipExtraStyles` | Extra CSS to be included in the components Shadow DOM |
| `tooltipExtraScripts` | Extra JS scripts to be included in the components Shadow DOM |

View file

@ -0,0 +1,88 @@
/* AAA Compliant Encapsulated Styles */
:host {
display: inline-block;
--primary-color: #113c9c;
--text-main: #0f172a;
--radius-md: 0.5rem;
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--focus-ring: #b91c1c;
}
.tool-tip-icon {
position: relative;
display: inline-flex;
}
.popover-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--primary-color);
font-size: 1rem;
display: flex;
align-items: center;
min-width: 44px; /* AAA touch target */
min-height: 44px; /* AAA touch target */
justify-content: center;
border-radius: 4px;
/* Declare this button as an anchor point */
anchor-name: --tooltip-trigger;
}
.popover-btn:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.tool-tip-content {
/* Popover API handles display/hide natively */
margin: 0;
border: none;
background-color: var(--text-main);
color: #ffffff;
text-align: left;
padding: 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 400;
line-height: 1.6;
box-shadow: var(--shadow-md);
width: max-content;
max-width: 280px;
}
/* Tooltip positioning overrides for native popover */
.tool-tip-content:popover-open {
position: absolute;
inset: auto;
/*top: auto;
left: auto;
right: auto;
bottom: auto;
transform: translateX(-50%) translateY(-100%);*/
/* Tell the popover to tether to that specific anchor */
/*position-anchor: --tooltip-trigger;*/
/* Position the bottom of the popover to the top of the anchor */
/* bottom: anchor(top); */
/* Center it horizontally */
/* justify-self: anchor-center; */
/* Add the 10px gap */
/* margin-bottom: 10px; */
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; /* Prevents text from wrapping */
}

View file

@ -0,0 +1,63 @@
<ba-tooltip>
<%# Because of complexities with web components and specifically more legacy support and the `createTemplateInJS` method, we need to pass the tooltip 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 tooltip DOM structure accurately if needed -->
<script type="application/json" data-tooltip-config>
<%- JSON.stringify({
tooltipId: id,
tooltipExtraStyles: typeof tooltipExtraStyles !== 'undefined' ? (Array.isArray(tooltipExtraStyles) ? tooltipExtraStyles : [tooltipExtraStyles]) : [],
tooltipExtraScripts: typeof tooltipExtraScripts !== 'undefined' ? (Array.isArray(tooltipExtraScripts) ? tooltipExtraScripts : [tooltipExtraScripts]) : [],
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 tooltipExtraStyles !== 'undefined') { %>
<% const tooltipStyles = Array.isArray(tooltipExtraStyles) ? tooltipExtraStyles : [tooltipExtraStyles]; %>
<% for (let style of tooltipStyles) { %>
<link rel="stylesheet" type="text/css" href="/css/<%= style %>.css">
<% } %>
<% } %>
<!-- END: Additional Styles -->
<!-- END: Styles -->
<!-- Scripts -->
<% if (typeof tooltipExtraScripts !== 'undefined') { %>
<% const tooltipScripts = Array.isArray(tooltipExtraScripts) ? tooltipExtraScripts : [tooltipExtraScripts]; %>
<% for (let script of tooltipScripts) { %>
<% 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="tool-tip-icon">
<button id="<%= id %>" class="popover-btn" popovertarget="<%= id %>-content">
<slot name="tooltip-btn-content"></slot>
</button>
<div id="<%= id %>-content" popover class="tool-tip-content">
<slot name="tooltip-content"></slot>
</div>
</div>
</template>
<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>
</ba-tooltip>

View file

@ -0,0 +1,202 @@
/**
* 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 {
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);
}
// ----------------------
// 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');
}
}*/
_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") {
// Measure the physical boundaries of the Web Component (the Light DOM host)
const hostRect = this.getBoundingClientRect();
// Measure the physical boundaries of the Popover itself
const popoverRect = popover.getBoundingClientRect();
// Calculate the exact X/Y coordinates to center it above the button
// We add scrollX/scrollY just in case the page is scrolled
const topPosition = hostRect.top + window.scrollY - popoverRect.height - 10;
const leftPosition = hostRect.left + window.scrollX + (hostRect.width / 2) - (popoverRect.width / 2);
// Force the physical coordinates onto the Top Layer element
popover.style.margin = '0'; // Popovers default to margin: auto which ruins positioning
popover.style.top = `${topPosition}px`;
popover.style.left = `${leftPosition}px`;
}
}
// -------------------------------
// Web Component Lifecycle Methods
// -------------------------------
/**
* Does initial setup and adds event listeners for interactivity
*
* `connectedCallback` is a lifecycle method in web components that runs when the custom element is inserted into the document's Document Object Model (DOM).
* It can be invoked multiple times if the element is removed and then re-inserted into the DOM.
*
* Timing: It is called after the element's constructor() but before the element's children are necessarily connected or fully rendered.
* Purpose: It is the ideal place to set up tasks that should only occur when the element is actually present in the live document. Common uses include:
*/
connectedCallback() {
const internals = this.attachInternals();
this.shadow = this.shadowRoot;
if (!this.shadow) {
this.shadow = this.attachShadow({ mode: 'open' });
// FIX: Defer execution until the browser finishes parsing the children
setTimeout(() => {
// Recreate the template using the shadow DOM that is only available through JavaScript
this.createTemplateInJS(this.shadow);
}, 0);
}
setTimeout(() => {
//this.shadow.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.
const popoverContent = this.shadow.querySelector('[popover]');
if (popoverContent) {
popoverContent.addEventListener('toggle', this._handleCustomLogic);
}
}, 0);
}
/**
* Cleans up event listeners when the component is removed from the DOM
*
* `disconnectedCallback` is a lifecycle method in web components that runs when the custom element is removed from the document's DOM.
* It can be invoked multiple times if the element is removed and then re-inserted into the DOM.
*
* Timing: It is called after the element is removed from the DOM but before it is garbage collected.
* Purpose: It is the ideal place to clean up any resources or event listeners that were set up in `connectedCallback`.
*
* Common uses include:
* - Removing event listeners to prevent memory leaks
* - Clearing timers or intervals
* - Disconnecting from external data sources or APIs
*/
disconnectedCallback() {
//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);
}
}
/**
* 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) {
// 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 = 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.classList.add('popover-btn');
btnElem.setAttribute('popovertarget', `${tooltipId}-content`);
const btnSlotElem = document.createElement('slot');
btnSlotElem.name = 'tooltip-btn-content';
btnElem.appendChild(btnSlotElem);
tooltipContainerDiv.appendChild(btnElem);
// Create the div that will contain the tooltip content
const contentDiv = document.createElement('div');
contentDiv.id = `${tooltipId}-content`;
contentDiv.classList.add('tool-tip-content');
contentDiv.setAttribute('popover', '');
const contentSlotElem = document.createElement('slot');
contentSlotElem.name = 'tooltip-content';
contentDiv.appendChild(contentSlotElem);
tooltipContainerDiv.appendChild(contentDiv);
// Finally, append the entire tooltip container div to the shadow DOM of the component
shadow.appendChild(tooltipContainerDiv);
}
}
customElements.define('ba-tooltip', Tooltip);

198
src/index.ts Normal file
View file

@ -0,0 +1,198 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import type { Application, Request, Response, NextFunction, RequestHandler } from 'express';
import ejs from 'ejs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Expose the physical paths so custom build scripts can find them
const libraryPaths = {
client: path.resolve(path.join(__dirname, 'client')),
server: path.resolve(path.join(__dirname))
};
// Pre-compile templates ONCE during library initialization
const compiledTemplates: Record<string, ejs.TemplateFunction> = {};
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')) {
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.) */ });
}
});
let componentsStyleHref: string;
/**
* Framework-Agnostic Core Logic
* This is detached from Express entirely so anyone can use it
*
* @param componentName - The name of the component to render
* @param params - The parameters to pass to the component
* @returns The rendered HTML string
*/
const coreRenderUI = (componentName: string, params: Record<string, any> = {}) => {
let func = compiledTemplates[componentName];
// If a function wasn't found error
if(typeof func !== 'function') {
throw new Error(`BA Web Components: Component not found: ${componentName}`);
}
// Basic validation to ensure parameters are an object
if(typeof params !== 'object' || params == null) {
throw new Error(`BA Web Components: Parameters must be an object for component: ${componentName}`);
}
params = { ...params, componentsStyleHref };
// Execute the pre-compiled EJS template
return func(params);
};
/**
* Express Virtual Path Strategy
*
* This strategy mounts the component assets on a virtual path within the provided Express app.
* It automatically serves the component assets from the library's client directory without requiring any manual copying of files.
* The middleware also sets up the necessary variables for rendering components within EJS templates.
*
* @param expressApp - The Express application instance to mount the virtual path on.
* @returns An Express middleware function that sets up the necessary variables for rendering components.
*/
async function initializeBAComponentsExpressVirtualPath(expressApp: Application): Promise<RequestHandler> {
// Mount the static route automatically
const virtualPath = '/ba-web-components';
// Setup the virtual path to serve the component assets directly from the library's client directory.
expressApp.use(virtualPath, (await import('express')).static(libraryPaths.client));
// Return the middleware
return (req: Request, res: Response, next: NextFunction) => {
// Set the script source to the virtual path (e.g., '/ba-web-components/components.js')
res.locals.componentsScriptSrc = `${virtualPath}/components.js`;
res.locals.componentsStyleHref = componentsStyleHref = `${virtualPath}/components.css`;
// Setup the `useComponent` function for use within EJS templates
res.locals.useComponent = coreRenderUI;
next();
};
}
/**
* Manual Copy Strategy
*
* This strategy allows users to specify where the component assets should be served from and where the files should be located on disk.
* If the files don't exist at the specified location, they will be automatically copied from the library's client directory to the desired location.
* This provides a seamless experience where users don't have to worry about manually copying files, but still have full control over the file locations and URLs.
*
* @param assets - An object containing the URL and file path configurations for the component assets.
* @returns An Express middleware function that sets up the necessary variables for rendering components.
*/
async function initializeBAComponentsManualCopy(assets: { url: string | { script: string, style: string }, file: string | { script: string, style: string } }): Promise<RequestHandler> {
let absoluteScriptPath: string;
let absoluteStylePath: string;
if(typeof assets.file === 'string') { // file parameter options is a directory string
absoluteScriptPath = path.isAbsolute(assets.file) ? assets.file : path.resolve(process.cwd(), assets.file);
absoluteStylePath = path.isAbsolute(assets.file) ? assets.file : path.resolve(process.cwd(), assets.file);
}
else { // file parameter options is an object with separate script and style paths
absoluteScriptPath = path.isAbsolute(assets.file.script) ? assets.file.script : path.resolve(process.cwd(), assets.file.script);
absoluteStylePath = path.isAbsolute(assets.file.style) ? assets.file.style : path.resolve(process.cwd(), assets.file.style);
}
// If we're appending the script name (DoESN'T already end with `components.js`), then ensure the directory exists.
if(!absoluteScriptPath.endsWith('components.js') && !fs.existsSync(absoluteScriptPath)) {
throw new Error(`BA Web Components: The provided script file path does not exist: ${absoluteScriptPath}`);
}
// Determine the full script path (either the provided path if it ends with 'components.js' or the path joined with 'components.js')
const fullScriptFilePath = absoluteScriptPath.endsWith('components.js') ? absoluteScriptPath : path.join(absoluteScriptPath, 'components.js');
const fullScriptUrlPath = (typeof assets.url === 'string' ? path.join(assets.url, 'components.js') : assets.url.script.endsWith('components.js') ? assets.url.script : path.join(assets.url.script, 'components.js')).replace(/\\/g, '/');
// If we're appending the style name (DOESN'T already end with `components.css`), ensure the directory exists.
if(!absoluteStylePath.endsWith('components.css') && !fs.existsSync(absoluteStylePath)) {
throw new Error(`BA Web Components: The provided style file path does not exist: ${absoluteStylePath}`);
}
// Determine the full style path (either the provided path if it ends with 'components.css' or the path joined with 'components.css')
const fullStyleFilePath = absoluteStylePath.endsWith('components.css') ? absoluteStylePath : path.join(absoluteStylePath, 'components.css');
const fullStyleUrlPath = (typeof assets.url === 'string' ? path.join(assets.url, 'components.css') : assets.url.style.endsWith('components.css') ? assets.url.style : path.join(assets.url.style, 'components.css')).replace(/\\/g, '/');
return (req: Request, res: Response, next: NextFunction) => {
// If the file doesn't exist create it by copying from the library's client directory.
// This ensures the user has the correct file without needing to manually copy it.
if(!fs.existsSync(fullScriptFilePath)) {
fs.copyFileSync(path.join(libraryPaths.client, 'components.js'), fullScriptFilePath);
}
// If the file doesn't exist create it by copying from the library's client directory.
// This ensures the user has the correct file without needing to manually copy it.
if(!fs.existsSync(fullStyleFilePath)) {
fs.copyFileSync(path.join(libraryPaths.client, 'components.css'), fullStyleFilePath);
}
// Trust the user's path
res.locals.componentsScriptSrc = fullScriptUrlPath;
res.locals.componentsStyleHref = componentsStyleHref = fullStyleUrlPath;
// Setup the `useComponent` function for use within EJS templates
res.locals.useComponent = coreRenderUI;
next();
};
}
/**
* Main Initialization Function (middleware factory / wrapper function)
*
* This function initializes the BA Web Components library based on the provided options.
*
* It supports three strategies:
* 1. **Express Virtual Path Strategy**: Best if using Express and want a plug-and-play solution. The library will serve the assets directly from its own client directory via a virtual path, so no manual copying of files is needed.
* 2. **Manual Copy Strategy**: Best if you want full control over the URLs and file locations but still want the convenience of automatic copying if the files don't exist. You specify where the files should be located and what URLs they should be served from, and the library handles the rest.
* 3. **Framework Agnostic Strategy**: Best if you are using a framework other than Express or want to integrate the library in a custom way. You get access to the raw paths and render function to build your own integration.
*
*
* @param options - An object containing the initialization options.
* @returns A promise that resolves to either an Express middleware function or an object containing the library paths and render function.
*/
export default async function initializeBAComponents(options: { expressApp?: Application, assets?: { url: string | { script: string, style: string }, file: string | { script: string, style: string } } } = {}): Promise<RequestHandler | { paths: typeof libraryPaths; render: typeof coreRenderUI }> {
// Guard against "Both"
if(
(
typeof options.expressApp !== 'undefined'
&& options.expressApp != null
)
&& (
typeof options.assets !== 'undefined'
&& options.assets != null
)
) {
throw new Error("BA Web Components: Provide EITHER 'expressApp' OR 'assets', not both.");
}
// --- STRATEGY A: Express Virtual Path ---
if(typeof options.expressApp !== 'undefined') {
return await initializeBAComponentsExpressVirtualPath(options.expressApp);
}
// --- STRATEGY B: Manual Copy Strategy ---
if(typeof options.assets === 'object' && options.assets !== null && typeof options.assets.url !== 'undefined' && typeof options.assets.file !== 'undefined') {
return await initializeBAComponentsManualCopy(options.assets);
}
// --- STRATEGY C: "Neither" (Framework Agnostic) ---
// If no options are provided, just return the raw tools.
// A Fastify or Koa user can use these to build their own integration.
return { paths: libraryPaths, render: coreRenderUI };
};