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,119 @@
name: Build, Test, and Publish (to Private NPM Registry) UI Components Library
on:
push:
branches:
- main
workflow_dispatch:
jobs:
publish:
runs-on: default
env:
PRIVATE_NPM_REGISTRY: 'https://npm.pkg.bridgemanaccessible.ca'
steps:
# Checkout the repository
- name: Checkout code
uses: actions/checkout@v4
# Set up NPM Auth Token
- name: Set up NPM Auth Token
run: echo "NODE_AUTH_TOKEN=${{ secrets.NPM_TOKEN }}" >> $GITHUB_ENV
# Set up Node.js
- name: Set up Node.js version
uses: actions/setup-node@v4
with:
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
#
# > Version Spec of the version to use in SemVer notation.
# > It also admits such aliases as lts/*, latest, nightly and canary builds
# > Examples: 12.x, 10.15.1, >=10.15.0, lts/Hydrogen, 16-nightly, latest, node
node-version: '22.x'
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
#
# > Optional registry to set up for auth. Will set the registry in a project level .npmrc and .yarnrc file,
# > and set up auth to read in from env.NODE_AUTH_TOKEN.
# > Default: ''
registry-url: ${{ env.PRIVATE_NPM_REGISTRY }}
# Taken from [Repo README](https://github.com/actions/setup-node#readme)
#
# > Optional scope for authenticating against scoped registries.
# > Will fall back to the repository owner when using the GitHub Packages registry (https://npm.pkg.github.com/).
scope: '@BridgemanAccessible'
# Transpile/Build the package (TypeScript -> JavaScript)
- name: Transpile/Build the package (TypeScript -> JavaScript)
run: |
# Because Yarn is used locally better to install and use it than have to debug weird inconsistencies
npm install --global yarn
# Install needed dependencies
yarn install
# Build the package
yarn build
# We only need chromium for CI to save time, unless you configured multi-browser testing
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
# This will automatically boot your server.ts, run tests, and tear it down when done
- name: Run E2E Tests & Accessibility Audits
run: yarn test:run
- name: Determine Version and Increment (if needed)
id: version_check
run: |
VERSION=$(node -p "require('./package.json').version")
echo "Version: $VERSION"
NAME=$(node -p "require('./package.json').name")
LATEST_VERSION=$(npm show $NAME version --registry ${{ env.PRIVATE_NPM_REGISTRY }})
echo "Latest version: $LATEST_VERSION"
if [ "$LATEST_VERSION" != "$VERSION" ]; then
echo "Manually updated version detected: $VERSION"
else
NEW_VERSION=$(npm version patch --no-git-tag-version)
echo "New version: $NEW_VERSION"
# Sync the newly generated version to package.lib.json so Gulp packages it correctly
node -e "const fs = require('fs'); const pkg = require('./package.lib.json'); pkg.version = require('./package.json').version; fs.writeFileSync('./package.lib.json', JSON.stringify(pkg, null, 2));"
echo "new_version=$NEW_VERSION" >> $GITHUB_ENV
echo "version_changed=true" >> $GITHUB_OUTPUT
fi
- name: Commit Version Change (if needed)
if: steps.version_check.outputs.version_changed == 'true'
run: |
# Update remote URL to use the GITHUB_TOKEN for authentication
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.bridgemanaccessible.ca/${{ github.repository }}.git
# Setup git user details for committing the version change
git config user.name "Forgejo Actions"
git config user.email "actions@git.bridgemanaccessible.ca"
# Commit the version change to the `package.json` file
git add package.json package.lib.json
git commit -m "[Forgejo Actions] Update version to ${{ env.new_version }} [skip ci]"
# Push the changes to the repository
git push origin HEAD:main
# Generates the finalized `dist/` directory with `package.json` included
- name: Package Library
run: yarn package
# Publish to private NPM registry
- name: Publish the package
run: |
# Change directory to the build output (`dist`) folder
cd dist
# Publish the package to the private NPM registry
npm publish --registry ${{ env.PRIVATE_NPM_REGISTRY }}

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# Dependency directories
node_modules/
# Build artifacts
dist/
src/components/client-entry.js
# Playwright Test Artifacts
test-results/
playwright-report/
blob-report/
playwright/.cache/
# IDE / OS Files
.DS_Store
Thumbs.db
.vscode/
.idea/
# Environment Variables
.env
.env.local
# NPM/Yarn Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bridgeman Accessible
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

91
README.md Normal file
View file

@ -0,0 +1,91 @@
# Bridgeman Accessible Web / Front-End Component Library
This package provides definitions for various different types of "complex" components that can be reused across various different web apps.
## Getting Started
The following provides a quick overview of how to start using this library. However, it's likely worth getting to know the library and needed configurations for each component before usage because the flexibility the components are built with can mean complexity in their usage (though we do try to keep it to a reasonable minimum as much as possible)
### Installing the library
This component library is packaged as a standard NPM package within our own package registry
```bash
npm install @BridgemanAccessible/ba-web-components
```
### Exposing the library
To be able to use the library you first need to register the middleware. Note, there are different ways of doing this based on level of customization required. The following only shows the simplest case.
```ts
import express from 'express';
import components from '@BridgemanAccessible/ba-web-components';
// ...
const app = express();
// ...
app.use(components(app));
```
### Using the components
To be able to use the component within a EJS template there are two parts:
1. Script Inclusion
2. Component Usage
#### Script Inclusion
Because of how the components work, a client side JavaScript file needs to be added to the page. The details of what the script's URL is will vary based on the configurations passed to the middleware. But the basics of how this works is that all client-side JavaScript across components gets bundled and minified at build / compile time and needs to be included separately because it tells the browser how to handle custom tags / "Web Components" so that the component code can run properly.
```ejs
<script src="<%= componentsScriptSrc %>"></script>
```
#### Component Usage
When ready to actually use a component the code would look something like:
```ejs
<%# ... %>
<%- useComponent('<Component Name>', { [paramName]: pramValue, ... }) %>
<%# ... %>
```
Replacing `<Component Name>`, `paramName`, and `paramValue` as appropriate, including specifying whatever parameters and configurations are needed or desired.
## Library Structure
The library roughly has 2 main parts:
1. **The middleware (`src/index.ts`)**: This does the translation, loading and other needed work to make the components as easy to use as they are
2. **The components (`src/components/*`)**: These are the components that make up the library
### Middleware
The code for the middleware is largely self-documenting so that won't be discussed here. But in broad strokes this defines a `useComponent` function on the `res.locals` object that when used takes the parameters renders the component template and passes back the string of code that makes up the component.
### Components
Components are defined within their own folders underneath the `src/components` folder. This is to keep things neat and organized as well as to allow for documentation (README) for each component that provides information on expected parameters and configurations.
That said, each component generally has a `.ejs` file, `.js` file and a `.css` file or `templates` (folder containing `.ejs` files), `scripts` (folder containing `.js` files) and `styles` (folder containing `.css` files) or some combination therein. Additionally most have a `README.md` that gives information about the component.
It's worth noting this structure described under `src/components` is very different from the structure under `dist/` this is largely for efficiency because all JS and CSS files get bundled and minified into a single `components.js` and `components.css` file. And the file structure gets slightly flattened so that file lookup only involves a single directory scan instead of multiple directory scans and changing directories.
## Adding A New Component
Adding a new component to the library is virtually as easy as following the structure described in the previous section. In particular under the `src/components` directory create a folder with the new components name and place a `.ejs`, `.js` and `.css` file within it (or some variation as described). Likely also creating a `README.md` for documentation of the individual component.
The somewhat complex but also helpful tool chain should do the rest of the work (in terms of bundling, minifying, moving needed files, etc.)
## Build Tool Chain
Because the component library is designed to be efficient but equally good Developer Experience (DX) we utilize a few different technologies in a relatively complex "tool chain" to attempt to achieve this.
### Gulp
We use Gulp as the overarching automation technology that allows us to run different tasks to create the end result.
### Typescript
Using typescript allows us to write type safe code
### Rollup
We use Rollup to bundle the client-side Web Component JS code.
## CI / CD (Forgejo Actions)
We use CI / CD to run our build change on pushes and publish the created package to our private NPM registry.

216
gulpfile.mjs Normal file
View file

@ -0,0 +1,216 @@
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import gulp from "gulp";
import rename from 'gulp-rename';
import { deleteAsync } from 'del';
import replace from 'gulp-replace';
import postcss from 'gulp-postcss';
import atImport from 'postcss-import';
import presetEnv from 'postcss-preset-env';
import cssnano from 'cssnano';
// Task which would delete the old dist directory if present
gulp.task("build-clean", () => {
return deleteAsync(["./dist"]);
});
// Task which would transpile typescript to javascript
gulp.task("typescript", (done) => {
const tsconfigPath = path.resolve(path.join('.', 'tsconfig.build.json'));
let proc;
if(process.platform === 'win32') {
proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', 'yarn', 'tsgo', '-p', `"${tsconfigPath}"`], { stdio: 'inherit' });
}
else {
proc = spawn('yarn', ['tsgo', '-p', `${tsconfigPath}`], { stdio: 'inherit' });
}
proc.on('close', code => {
if (code !== 0) {
return done(new Error(`tsgo process exited with code ${code}`));
}
done();
});
});
// Task which would bundle and minify the client-side JavaScript using Rollup
gulp.task("bundle-minify-js", (done) => {
const scriptFiles = [];
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 = [];
// 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));
}
});
}
// 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);
});
}
});
let clientEntryJSContent = '';
scriptFiles.forEach(scriptFile => {
const relativePath = path.relative(path.join('src', 'components'), scriptFile).replace(/\\/g, '/');
clientEntryJSContent += `import './${relativePath}';\n`;
});
fs.writeFileSync(path.join('src', 'components', 'client-entry.js'), clientEntryJSContent);
let proc;
if(process.platform === 'win32') {
proc = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', 'yarn', 'rollup', '-c'], { stdio: 'inherit' });
}
else {
proc = spawn('yarn', ['rollup', '-c'], { stdio: 'inherit' });
}
proc.on('close', code => {
// Clean up the temporary entry file so tht the src/ directory stays pristine
const entryPath = path.join('src', 'components', 'client-entry.js');
if (fs.existsSync(entryPath)) {
fs.rmSync(entryPath);
}
if (code !== 0) {
return done(new Error(`Rollup process exited with code ${code}`));
}
done();
});
});
// Task which would just create a copy of the current views directory in dist directory
gulp.task("copy-ejs", function () {
return gulp.src("./src/components/**/*.ejs")
.pipe(rename((parsedPath) => {
// `parsedPath.dirname` is the path relative to the glob base ("./src/components/")
// Example A: "tooltip"
// Example B: "tabs/templates" (or "tabs\templates" on Windows)
// Normalize the slashes to forward slashes to prevent Windows path bugs
const normalizedDir = parsedPath.dirname.replace(/\\/g, '/');
const pathParts = normalizedDir.split('/');
// The first folder level is always your component's name
const componentName = pathParts[0];
// If the path includes a 'templates' directory, it's a complex component
if (pathParts.includes('templates')) {
// Keep the file inside a folder named after the component
// This outputs to: dist/components/tabs/tab-content.ejs
parsedPath.dirname = componentName;
}
else {
// It's a simple component. Flatten it completely.
// This outputs to: dist/components/tooltip.ejs
parsedPath.dirname = '';
}
}))
.pipe(gulp.dest("./dist/components"));
});
// Task which will copy the assets from the static CSS directory to the dist directory
gulp.task("process-css", () => {
// Gather all the component CSS files
const styleFiles = [];
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 componentStyleFiles = [];
// Include any JS files found in a "styles" subfolder of the component
if(file === 'styles' && fs.statSync(path.join('src', 'components', component, file)).isDirectory()) {
fs.readdirSync(path.join('src', 'components', component, file)).forEach(styleFile => {
if(styleFile.endsWith('.css')) {
componentStyleFiles.push(path.join('src', 'components', component, 'styles', styleFile));
}
});
}
// Look for *.css files inside the root of the component folders
if (file.endsWith('.css')) {
componentStyleFiles.push(path.join('src', 'components', component, file));
}
styleFiles.push(...componentStyleFiles);
});
}
});
const libraryCSSAppendContent = styleFiles.map(styleFile => {
const relativePath = path.relative(path.join('src', 'components'), styleFile).replace(/\\/g, '/');
return `@import "./${relativePath}";`;
}).join('\n');
return gulp.src("./src/components/import.css")
.pipe(replace(/\/\* Component Styles \*\//g, libraryCSSAppendContent))
.pipe(
postcss([
atImport(), // Resolves @import paths (including node_modules)
presetEnv({
stage: 1, // Allows use of future CSS features today
features: {
'nesting-rules': true // Allows SCSS-like nesting natively
}
}),
cssnano({
preset: [
'default', {
calc: false
}
]
})
])
)
.on('error', function(error) {
console.error("\n❌ CSS Error:", error.message);
// Optionally print the code snippet if available
if (error.source) {
console.error(" File:", error.file);
console.error(" Line:", error.line);
}
this.emit('end'); // Prevents Gulp from crashing completely
})
.pipe(rename('components.css'))
.pipe(gulp.dest("./dist/client"));
});
// The default task which runs at start of the gulpfile.js
gulp.task("default", gulp.series("build-clean", "typescript", "bundle-minify-js", "copy-ejs", "process-css"), () => {
console.log("Done");
});
gulp.task('copy-package-files', () => {
// After the default build tasks are done, we can copy the package.json, README.md, and LICENSE to the dist directory for npm publishing
return gulp.src(["./package.lib.json", "./README.md", "./LICENSE"])
.pipe(rename((path) => {
if(path.basename === "package.lib") {
path.basename = "package";
}
}))
.pipe(gulp.dest("./dist"));
});
gulp.task('package', gulp.series('default', 'copy-package-files'), () => {
console.log("Package ready in dist/ directory");
});

49
package.json Normal file
View file

@ -0,0 +1,49 @@
{
"name": "ba-web-components",
"version": "1.0.0",
"description": "A library of front-end components that can be reused across different apps.",
"main": "dist/index.js",
"repository": "https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-web-components",
"author": "Bridgeman Accessible<info@bridgemanaccessible.ca>",
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "gulp",
"package": "gulp package",
"test:run": "playwright test",
"test:start": "tsx test-harness/server.ts",
"test:local": "yarn build && yarn playwright test --update-snapshots"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@playwright/test": "^1.60.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^1.0.0",
"@types/ejs": "^3.1.5",
"@types/express": "^5.0.6",
"@types/node": "^25.6.2",
"@typescript/native-preview": "^7.0.0-dev.20260508.1",
"cssnano": "^8.0.1",
"del": "^8.0.1",
"express": "^5.2.1",
"gulp": "^5.0.1",
"gulp-postcss": "^10.0.0",
"gulp-rename": "^2.1.0",
"gulp-replace": "^1.1.4",
"postcss": "^8.5.14",
"postcss-import": "^16.1.1",
"postcss-preset-env": "^11.2.1",
"rollup": "^4.60.3",
"tsx": "^4.21.0"
},
"dependencies": {
"ejs": "^5.0.2",
"open-props": "2.0.0-beta.5"
}
}

19
package.lib.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "ba-web-components",
"version": "1.0.0",
"description": "A library of front-end components that can be reused across different apps.",
"repository": "https://git.bridgemanaccessible.ca/Bridgeman-Accessible/ba-web-components",
"author": "Bridgeman Accessible<info@bridgemanaccessible.ca>",
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
}
},
"dependencies": {
"ejs": "^5.0.2",
"open-props": "2.0.0-beta.5"
}
}

34
playwright.config.ts Normal file
View file

@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './test-harness/tests',
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retries for flakiness in CI */
retries: process.env.CI ? 2 : 0,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3080',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
// You can add Firefox/WebKit here later if you want cross-browser testing
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'yarn test:start', // The command to boot test-harness/server.ts
url: 'http://localhost:3080', // Playwright pings this until it gets a 2xx response
reuseExistingServer: !process.env.CI
}
});

15
rollup.config.js Normal file
View file

@ -0,0 +1,15 @@
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser'; // For minification
export default {
input: 'src/components/client-entry.js',
output: {
file: 'dist/client/components.js',
format: 'es', // ES Modules are perfect for Web Components
sourcemap: true,
},
plugins: [
resolve(), // Resolves node_modules if your components use external libraries
terser() // Minifies the output for production
]
};

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

26
test-harness/server.ts Normal file
View file

@ -0,0 +1,26 @@
import express from 'express';
import type { RequestHandler, Request, Response } from 'express';
import baWebComponents from '../dist/index.js';
const app = express();
app.set('view engine', 'ejs');
app.set('views', './test-harness/views');
// Mount the library using your virtual path strategy
app.use(await baWebComponents({ expressApp: app }) as RequestHandler);
app.get('/', (req: Request, res: Response) => {
res.send('Hello from the test server! Navigate to /test/tooltip to see the tooltip component test page.');
});
app.get('/test/tooltip', (req: Request, res: Response) => {
res.render('tooltip');
});
app.get('/test/drawer', (req: Request, res: Response) => {
res.render('drawer');
});
app.listen(3080, () => {
console.log('Test server running on http://localhost:3080');
});

View file

@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
test.describe('Accessible Drawer Component', () => {
test.beforeEach(async ({ page }) => {
// Navigate to the test harness view for the drawer
await page.goto('/test/drawer');
});
test('should pass AAA accessibility audits', async ({ page }) => {
await page.waitForLoadState('networkidle');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag2aaa', 'best-practice'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('should toggle content visibility and ARIA states via mouse click', async ({ page }) => {
const drawerHeader = page.locator('#example-drawer-drawer-header');
const drawerContent = page.locator('#example-drawer-drawer-contents');
// Assert Initial State
await expect(drawerHeader).toHaveAttribute('aria-expanded', 'false');
await expect(drawerContent).toHaveClass(/hidden/);
// Interact (Open)
await drawerHeader.click();
// Assert Open State
await expect(drawerHeader).toHaveAttribute('aria-expanded', 'true');
await expect(drawerContent).not.toHaveClass(/hidden/);
// Interact (Close)
await drawerHeader.click();
// Assert Closed State
await expect(drawerHeader).toHaveAttribute('aria-expanded', 'false');
await expect(drawerContent).toHaveClass(/hidden/);
});
test('should toggle content visibility via keyboard navigation', async ({ page }) => {
const drawerHeader = page.locator('#example-drawer-drawer-header');
const drawerContent = page.locator('#example-drawer-drawer-contents');
// Focus the drawer header using the keyboard
await page.keyboard.press('Tab');
await expect(drawerHeader).toBeFocused();
// Open with Space
await page.keyboard.press(' ');
await expect(drawerHeader).toHaveAttribute('aria-expanded', 'true');
await expect(drawerContent).not.toHaveClass(/hidden/);
// Close with Enter
await page.keyboard.press('Enter');
await expect(drawerHeader).toHaveAttribute('aria-expanded', 'false');
await expect(drawerContent).toHaveClass(/hidden/);
});
test('visual regression: component renders correctly', async ({ page }) => {
const drawerContainer = page.locator('#example-drawer');
const drawerHeader = page.locator('#example-drawer-drawer-header');
// Snapshot the closed state
await expect(drawerContainer).toHaveScreenshot('drawer-closed.png');
// Snapshot the open state
await drawerHeader.click();
await expect(drawerContainer).toHaveScreenshot('drawer-open.png');
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
test.describe('Accessible Tooltip Component', () => {
// Before each test, navigate to the specific EJS view serving the tooltip
test.beforeEach(async ({ page }) => {
await page.goto('/test/tooltip'); // Resolves to http://localhost:3080/test/tooltip
});
test('should pass AAA accessibility audits', async ({ page }) => {
// Wait for the component to be fully hydrated
await page.waitForLoadState('networkidle');
// 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 toggle the popover content natively', async ({ page }) => {
// Locate the button and the content div
// Notice how we don't care about the shadow boundary, we just use standard CSS selectors
const tooltipBtn = page.locator('.popover-btn');
const tooltipContent = page.locator('.tool-tip-content');
// Assert initial state (Popover should be hidden)
await expect(tooltipContent).not.toBeVisible();
// Interact
await tooltipBtn.click();
// Assert new state (Popover should be visible)
await expect(tooltipContent).toBeVisible();
// Test native accessibility behavior (Esc key to dismiss Popover)
await page.keyboard.press('Escape');
await expect(tooltipContent).not.toBeVisible();
});
test('visual regression: component renders correctly', async ({ page }) => {
const tooltipBtn = page.locator('.popover-btn');
const tooltipContent = page.locator('.tool-tip-content');
// Take a snapshot of just the button element
await expect(tooltipBtn).toHaveScreenshot('tooltip-button-closed.png');
// Open it and take a snapshot of the whole component area to catch layout shifts
await tooltipBtn.click();
await expect(tooltipContent).toBeVisible();
await expect(page.locator('.tool-tip-icon')).toHaveScreenshot('tooltip-container-open.png');
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drawer Test</title>
<link rel="stylesheet" type="text/css" href="<%= componentsStyleHref %>">
<script src="<%= componentsScriptSrc %>"></script>
</head>
<body>
<main style="padding: 20px;">
<h1>Drawer Test</h1>
<p>The following is an example of a drawer component. Click to toggle the visibility of the content.</p>
<%- useComponent('drawer', {
id: 'example-drawer',
label: `
<h2>Example Drawer</h2>
`,
content: `
<div>
<div class="drawer-content-header">
<h3>Hello World!</h3>
</div>
<div class="drawer-content-contents">
<p>This is the body of a drawer</p>
<p>It can have multiple lines.</p>
<span>
<code>Even code snippets</code>,
<em>Emphasized</em> or <strong>Strong</strong> text,
or even
<button>Buttons!</button>
</span>
</div>
</div>
`
}) %>
</main>
</body>
</html>

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tooltip Test</title>
<link rel="stylesheet" type="text/css" href="<%= componentsStyleHref %>">
<script src="<%= componentsScriptSrc %>"></script>
</head>
<body>
<main style="margin-top:12rem;margin-left:4rem">
<h1>Tooltip Test</h1>
<span>
I need a bit of a preamble to make sure the tooltip gets positioned correctly.
This is a <%- useComponent('tooltip', {
id: 'example-tooltip',
srText: 'Example Tooltip',
content: `
<div>
<div class="tooltip-content-header">
<h2>Hello World!</h2>
</div>
<div class="tooltip-content-contents">
<p>This is the body of a tooltip</p>
<p>It can have multiple lines.</p>
<span>
<code>Even code snippets</code>,
<em>Emphasized</em> or <strong>Strong</strong> text,
or even
<button>Buttons!</button>
</span>
</div>
</div>
`
}) %>.
Isn&apos;t it cool!
</span>
</main>
</body>
</html>

22
tsconfig.build.json Normal file
View file

@ -0,0 +1,22 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": [],
// Other Outputs
"sourceMap": true,
},
"include": [
"./src/**/*.tsx",
"./src/**/*.ts"
],
"exclude": [
"node_modules",
"./tests", // <--- Explicitly exclude tests
"./**/*.test.ts",
"./**/*.spec.ts"
]
}

41
tsconfig.json Normal file
View file

@ -0,0 +1,41 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": ["node"],
"lib": ["esnext"],
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
//"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
}

3618
yarn.lock Normal file

File diff suppressed because it is too large Load diff