Slight adjustment to tab styles + added spinner component
All checks were successful
Build, Test, and Publish (to Private NPM Registry) UI Components Library / publish (push) Successful in 1m2s

This commit is contained in:
Alan Bridgeman 2026-05-19 21:36:00 -05:00
parent d85419a0d8
commit d95bdda9ec
11 changed files with 407 additions and 14 deletions

View file

@ -0,0 +1,19 @@
# Accessible Spinner
Where many spinners can be incredibly inaccessible and ignore communicating loading state to different users (ex. screen reader users among others). This component endeavors to create one that addresses these issues. It uses the `aria-busy` attribute in combination with CSS rules to create something that hopefully works for everyone.
## Usage
to use this component:
```ejs
<%- useComponent('spinner', { id: 'example-loading', content: 'Loading...' }) %>
```
## Parameters
The following is a table describing the parameters available for this component.
| Parameter | Description |
| --------------------- | -------------------------------------------------------------------------------------------- |
| `id` | The ID of the spinner |
| `inline` | If any content after the spinner should be inline with it or not (defaults `false`) |
| `speed` | The speed at which the spinners spin (not set it's a "medium" speed, values: `slow`, `fast`) |
| `content` | The content of the spinner |

View file

@ -0,0 +1,58 @@
[aria-busy="true"]:not(
input,
select,
textarea,
html,
progress,
[aria-describedby]
) {
position: relative;
&::before {
animation: spin 0.7s linear infinite;
border-color: transparent currentColor currentColor;
border-radius: 50%;
border-style: solid;
border-width: 3px;
content: "";
display: inline-block;
block-size: 1em;
opacity: 0.5;
vertical-align: -0.14em;
inline-size: 1em;
}
&:not(button.button):not(:empty) {
&::before {
margin-inline-end: 0.5em;
}
}
&.inline {
display: inline-flex;
align-items: center;
gap: 0.5em; /* Gives a nice consistent space between the ring and text */
&::before {
margin-inline-end: 0; /* Remove the default margin since we use flex gap now */
}
}
&.slow {
&::before {
animation-duration: 1.7s;
}
}
&.fast {
&::before {
animation-duration: 0.35s;
}
}
}
@keyframes spin {
to {
transform: rotate(1turn);
}
}

View file

@ -0,0 +1,74 @@
<ba-spinner>
<%# Because of complexities with web components and specifically more legacy support and the `createTemplateInJS` method, we need to pass the spinner 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 spinner DOM structure accurately if needed -->
<script type="application/json" data-spinner-config>
<%- JSON.stringify({
spinnerId: locals.id,
inline: locals.inline || false,
speed: locals.speed,
spinnerExtraStyles: typeof locals.spinnerExtraStyles !== 'undefined' ? (Array.isArray(locals.spinnerExtraStyles) ? locals.spinnerExtraStyles : [locals.spinnerExtraStyles]) : [],
spinnerExtraScripts: typeof locals.spinnerExtraScripts !== 'undefined' ? (Array.isArray(locals.spinnerExtraScripts) ? locals.spinnerExtraScripts : [locals.spinnerExtraScripts]) : [],
componentsStyleHref: locals.componentsStyleHref
}) %>
</script>
<template shadowrootmode="open">
<!-- Styles -->
<!-- Component Specific Styling -->
<!-- Component Styling (Part of Component Library CSS) -->
<link rel="stylesheet" type="text/css" href="<%= locals.componentsStyleHref %>">
<!-- END: Component Specific Styling -->
<!-- Additional Styles -->
<% if (typeof locals.spinnerExtraStyles !== 'undefined') { %>
<% const spinnerStyles = Array.isArray(locals.spinnerExtraStyles) ? locals.spinnerExtraStyles : [locals.spinnerExtraStyles]; %>
<% for (let style of spinnerStyles) { %>
<link rel="stylesheet" type="text/css" href="/css/<%= style %>.css">
<% } %>
<% } %>
<!-- END: Additional Styles -->
<!-- END: Styles -->
<!-- Scripts -->
<% if (typeof locals.spinnerExtraScripts !== 'undefined') { %>
<% const spinnerScripts = Array.isArray(locals.spinnerExtraScripts) ? locals.spinnerExtraScripts : [locals.spinnerExtraScripts]; %>
<% for (let script of spinnerScripts) { %>
<% 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 -->
<%
// Build the class list cleanly in JS first
let classList = [];
if (locals.inline) {
classList.push('inline');
}
if (locals.speed) {
classList.push(locals.speed);
}
const classAttr = classList.length > 0 ? ` class="${classList.join(' ')}"` : '';
%>
<span id="<%= typeof locals.id !== 'undefined' ? locals.id : 'spinner' %>" aria-busy="true"<%- classAttr %>>
<slot name="spinner-content"></slot>
</span>
</template>
<% if(typeof slots !== 'undefined') { %>
<%- slots %>
<% } else { %>
<div slot="spinner-content">
<%= typeof locals.content !== 'undefined' ? locals.content : '' %>
</div>
<% } %>
</ba-spinner>

View file

@ -0,0 +1,96 @@
import { ComposableElement } from '../ComposableElement.mjs';
/**
* Spinner Web Component
*
* A simple spinner component that indicates loading or processing state.
* This component uses the `aria-busy` attribute in combination with CSS tricks to create a spinner that is accessible, modern, and easy to manage.
*/
export class Spinner extends ComposableElement {
constructor() {
super();
}
// -------------------------------
// Web Component Lifecycle Methods
// -------------------------------
/**
* Does initial setup and adds event listeners for interactivity
*
* `connectedCallback` is a lifecycle method in web components that runs when the custom element is inserted into the document's Document Object Model (DOM).
* It can be invoked multiple times if the element is removed and then re-inserted into the DOM.
*
* Timing: It is called after the element's constructor() but before the element's children are necessarily connected or fully rendered.
* Purpose: It is the ideal place to set up tasks that should only occur when the element is actually present in the live document. Common uses include:
*/
connectedCallback() {
const internals = this.attachInternals();
this.shadow = this.shadowRoot;
if (!this.shadow) {
this.shadow = this.attachShadow({ mode: 'open' });
// Defer execution until the browser finishes parsing the children
setTimeout(() => {
// Recreate the template using the shadow DOM that is only available through JavaScript
this.createTemplateInJS(this.shadow);
}, 0);
}
}
/**
* Cleans up event listeners when the component is removed from the DOM
*
* `disconnectedCallback` is a lifecycle method in web components that runs when the custom element is removed from the document's DOM.
* It can be invoked multiple times if the element is removed and then re-inserted into the DOM.
*
* Timing: It is called after the element is removed from the DOM but before it is garbage collected.
* Purpose: It is the ideal place to clean up any resources or event listeners that were set up in `connectedCallback`.
*
* Common uses include:
* - Removing event listeners to prevent memory leaks
* - Clearing timers or intervals
* - Disconnecting from external data sources or APIs
*/
disconnectedCallback() {}
/**
* Recreate the template in the shadow DOM through JavaScript instead of relying on the `shadowrootmode` attribute
*
* @param {ShadowRoot} shadow The shadow DOM to attach the template to
*/
createTemplateInJS(shadow) {
const config = this.initializeComponent('spinner', shadow);
if(config == null) {
throw new Error('Spinner configuration not found. Please ensure you have a script tag with the appropriate data-spinner-config attribute containing the necessary JSON configuration for the spinner component.');
}
// Create the container div for the spinner component
const spinnerSpan = document.createElement('span');
spinnerSpan.id = config.spinnerId || 'spinner';
spinnerSpan.setAttribute('aria-busy', 'true');
if(config.inline) {
spinnerSpan.classList.add('inline');
}
if(typeof config.speed !== 'undefined' && (config.speed === 'slow' || config.speed === 'fast')) {
spinnerSpan.classList.add(config.speed);
}
const spinnerSlot = document.createElement('slot');
spinnerSlot.name = 'spinner-content';
spinnerSpan.appendChild(spinnerSlot);
// Finally, append the entire spinner span to the shadow DOM of the component
shadow.appendChild(spinnerSpan);
}
}
document.addEventListener('DOMContentLoaded', () => {
if (!customElements.get('ba-spinner')) {
customElements.define('ba-spinner', Spinner);
}
});

View file

@ -1,3 +1,29 @@
:host {
/* Light Mode Variables */
--tabs-bottom-border-colour__light: #E2E8F0;
--tabs-tab-title-colour__light: #64748B;
--tabs-tab-title-hover-colour__light: #0F172A;
--tabs-tab-title-hover-background-colour__light: #F1F5F9;
--tabs-tab-title-focus-colour__light: #0EA5E9;
--tabs-tab-title-active-colour__light: #0EA5E9;
--tabs-tab-title-active-border-colour__light: #0EA5E9;
--tabs-filled-background-colour__light: #F1F5F9;
--tabs-filled-tab-hover-background-colour__light: #E2E8F0;
--tabs-filled-tab-active-background-colour__light: #FFFFFF;
/* Dark Mode Variables */
--tabs-bottom-border-colour__dark: #334155;
--tabs-tab-title-colour__dark: #94A3B8;
--tabs-tab-title-hover-colour__dark: #F8FAFC;
--tabs-tab-title-hover-background-colour__dark: #1E293B;
--tabs-tab-title-focus-colour__dark: #0EA5E9;
--tabs-tab-title-active-colour__dark: #0EA5E9;
--tabs-tab-title-active-border-colour__dark: #0EA5E9;
--tabs-filled-background-colour__dark: #1E293B;
--tabs-filled-tab-hover-background-colour__dark: #334155;
--tabs-filled-tab-active-background-colour__dark: #0F172A;
}
:where(ba-tabs) { :where(ba-tabs) {
display: block; display: block;
min-width: 100%; min-width: 100%;
@ -20,9 +46,10 @@
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
border-bottom: 2px solid light-dark(#E2E8F0, #334155); /* Default bottom track */ border-bottom: 2px solid light-dark(var(--tabs-bottom-border-colour__light), var(--tabs-bottom-border-colour__dark)); /* Default bottom track */
margin-bottom: 2rem; margin-bottom: 2rem;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
} }
/* ========================================= /* =========================================
@ -31,11 +58,10 @@
:where(.tabs) button.tabs-title { :where(.tabs) button.tabs-title {
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 3px solid transparent; /* Placeholder for the active underline */
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 600; font-weight: 600;
color: light-dark(#64748B, #94A3B8); color: light-dark(var(--tabs-tab-title-colour__light), var(--tabs-tab-title-colour__dark));
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@ -45,15 +71,15 @@
/* Hover State */ /* Hover State */
&:hover { &:hover {
color: light-dark(#0F172A, #F8FAFC); color: light-dark(var(--tabs-tab-title-hover-colour__light), var(--tabs-tab-title-hover-colour__dark));
background-color: light-dark(#F1F5F9, #1E293B); background-color: light-dark(var(--tabs-tab-title-hover-background-colour__light), var(--tabs-tab-title-hover-background-colour__dark));
border-top-left-radius: 6px; border-top-left-radius: 6px;
border-top-right-radius: 6px; border-top-right-radius: 6px;
} }
/* Keyboard Focus (Accessibility) */ /* Keyboard Focus (Accessibility) */
&:focus-visible { &:focus-visible {
outline: 2px solid var(--primary-background-colour, #0ea5e9); outline: 2px solid light-dark(var(--primary-background-color, var(--tabs-tab-title-focus-colour__light)), var(--primary-background-color, var(--tabs-tab-title-focus-colour__dark)));
outline-offset: -2px; outline-offset: -2px;
border-radius: 6px; border-radius: 6px;
} }
@ -61,19 +87,37 @@
/* Active/Selected State */ /* Active/Selected State */
&[aria-selected="true"], &[aria-selected="true"],
&.is-active { &.is-active {
color: var(--primary-background-colour, #0ea5e9); color: light-dark(var(--primary-background-color, var(--tabs-tab-title-active-colour__light)), var(--primary-background-color, var(--tabs-tab-title-active-colour__dark)));
border-bottom-color: var(--primary-background-colour, #0ea5e9); border-bottom-color: light-dark(var(--primary-background-color, var(--tabs-tab-title-active-border-colour__light)), var(--primary-background-color, var(--tabs-tab-title-active-border-colour__dark)));
} }
} }
/* ======================================== /* ========================================
VARIANTS (Optional classes added to nav) VARIANTS (Optional classes added to nav)
======================================== */ ======================================== */
/* -------------------------------------------------------------------------------
Underline (Bottom border / "underline" tab style)
------------------------------------------------------------------------------- */
:where(.tabs.underline) button.tabs-title {
border-bottom: 3px solid transparent; /* Placeholder for the active underline */
/* Active/Selected State */
&[aria-selected="true"],
&.is-active {
border-bottom-color: light-dark(var(--primary-background-color, var(--tabs-tab-title-active-border-colour__light)), var(--primary-background-color, var(--tabs-tab-title-active-border-colour__dark)));
}
}
/* -------------------------------------------------------------------------------
Filled (Non-transparent "filled" tab style with background and rounded corners)
------------------------------------------------------------------------------- */
/* Filled Variant: <nav class="tabs filled"> */ /* Filled Variant: <nav class="tabs filled"> */
:where(nav.tabs.filled) { :where(.tabs.filled) {
border-bottom: none; border-bottom: none;
background-color: light-dark(#F1F5F9, #1E293B); background-color: light-dark(var(--tabs-filled-background-colour__light), var(--tabs-filled-background-colour__dark));
border-radius: 8px; border-radius: 8px;
padding: 0.5rem; padding: 0.5rem;
width: fit-content; width: fit-content;
@ -84,14 +128,14 @@
border-radius: 6px; border-radius: 6px;
&:hover { &:hover {
background-color: light-dark(#E2E8F0, #334155); background-color: light-dark(var(--tabs-filled-tab-hover-background-colour__light), var(--tabs-filled-tab-hover-background-colour__dark));
} }
&[aria-selected="true"], &[aria-selected="true"],
&.is-active { &.is-active {
background-color: light-dark(#FFFFFF, #0F172A); background-color: light-dark(var(--tabs-filled-tab-active-background-colour__light), var(--tabs-filled-tab-active-background-colour__dark));
box-shadow: 0 1px 3px rgba(0,0,0,0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.1);
color: var(--primary-background-colour, #0ea5e9); color: light-dark(var(--primary-background-colour, var(--tabs-tab-title-active-colour__light)), var(--primary-background-color, var(--tabs-tab-title-active-colour__dark)));
} }
} }
} }

View file

@ -28,6 +28,10 @@ app.get('/test/tabs', (req: Request, res: Response) => {
res.render('tabs', { toggleUnderline, toggleFilled }); res.render('tabs', { toggleUnderline, toggleFilled });
}); });
app.get('/test/spinner', (req: Request, res: Response) => {
res.render('spinner');
});
app.listen(3080, () => { app.listen(3080, () => {
console.log('Test server running on http://localhost:3080'); console.log('Test server running on http://localhost:3080');
}); });

View file

@ -0,0 +1,62 @@
import { test, expect } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
test.describe('Accessible Spinner Component', () => {
// Before each test, navigate to the specific EJS view serving the spinner
test.beforeEach(async ({ page }) => {
// Assuming your test harness routes this to /test/spinner
await page.goto('/test/spinner');
});
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 render correct ARIA attributes and variant classes based on props', async ({ page }) => {
// Test Default Spinner
const defaultSpinner = page.locator('#example-spinner');
await expect(defaultSpinner).toBeVisible();
await expect(defaultSpinner).toHaveAttribute('aria-busy', 'true');
// Ensure no extra variant classes leaked into the default spinner
await expect(defaultSpinner).not.toHaveClass(/inline/);
await expect(defaultSpinner).not.toHaveClass(/fast|slow/);
// Test Inline Spinner
const inlineSpinner = page.locator('#inline-spinner');
await expect(inlineSpinner).toHaveClass(/inline/);
await expect(inlineSpinner).toHaveAttribute('aria-busy', 'true');
// Test Speed Variants
const slowSpinner = page.locator('#slow-spinner');
await expect(slowSpinner).toHaveClass(/slow/);
const fastSpinner = page.locator('#fast-spinner');
await expect(fastSpinner).toHaveClass(/fast/);
// Test Combo (Slow + Inline)
const comboSpinner = page.locator('#slow-inline-spinner');
await expect(comboSpinner).toHaveClass(/inline/);
await expect(comboSpinner).toHaveClass(/slow/);
});
test('visual regression: component variants render correctly without layout shifts', async ({ page }) => {
// We capture the entire <main> block to ensure all variants render correctly relative to standard text.
const mainContent = page.locator('main');
// CRITICAL: Because spinners use CSS @keyframes, the rotation angle will be slightly different
// every time Playwright takes a screenshot, causing the test to flake and fail.
// `animations: 'disabled'` tells Playwright to pause all CSS animations and transitions instantly.
await expect(mainContent).toHaveScreenshot('spinner-all-variants.png', {
animations: 'disabled'
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spinner Test</title>
<link rel="stylesheet" type="text/css" href="<%= componentsStyleHref %>">
<script src="<%= componentsScriptSrc %>"></script>
</head>
<body>
<main style="padding: 20px;">
<h1>Spinner Test</h1>
<p>The following is an example of a spinner component. It indicates a loading or processing state.</p>
<section>
<h2>Default Spinner</h2>
<%- useComponent('spinner', { id: 'example-spinner', content: 'Loading...' }) %>
</section>
<section>
<h2>Inline Spinner</h2>
<%- useComponent('spinner', { id: 'inline-spinner', content: 'Loading...', inline: true }) %>
</section>
<section>
<h2>Slow Spinner</h2>
<%- useComponent('spinner', { id: 'slow-spinner', content: 'Loading...', speed: 'slow' }) %>
</section>
<section>
<h2>Fast Spinner</h2>
<%- useComponent('spinner', { id: 'fast-spinner', content: 'Loading...', speed: 'fast' }) %>
</section>
<section>
<h2>Slow Inline Spinner</h2>
<%- useComponent('spinner', { id: 'slow-inline-spinner', content: 'Loading...', inline: true, speed: 'slow' }) %>
</section>
</main>
</body>
</html>