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
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:
parent
d85419a0d8
commit
d95bdda9ec
11 changed files with 407 additions and 14 deletions
19
src/components/spinner/README.md
Normal file
19
src/components/spinner/README.md
Normal 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 |
|
||||
58
src/components/spinner/spinner.css
Normal file
58
src/components/spinner/spinner.css
Normal 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);
|
||||
}
|
||||
}
|
||||
74
src/components/spinner/spinner.ejs
Normal file
74
src/components/spinner/spinner.ejs
Normal 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>
|
||||
96
src/components/spinner/spinner.mjs
Normal file
96
src/components/spinner/spinner.mjs
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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) {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
|
|
@ -20,9 +46,10 @@
|
|||
list-style-type: none;
|
||||
margin: 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;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
|
|
@ -31,11 +58,10 @@
|
|||
:where(.tabs) button.tabs-title {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent; /* Placeholder for the active underline */
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: light-dark(#64748B, #94A3B8);
|
||||
color: light-dark(var(--tabs-tab-title-colour__light), var(--tabs-tab-title-colour__dark));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -45,15 +71,15 @@
|
|||
|
||||
/* Hover State */
|
||||
&:hover {
|
||||
color: light-dark(#0F172A, #F8FAFC);
|
||||
background-color: light-dark(#F1F5F9, #1E293B);
|
||||
color: light-dark(var(--tabs-tab-title-hover-colour__light), var(--tabs-tab-title-hover-colour__dark));
|
||||
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-right-radius: 6px;
|
||||
}
|
||||
|
||||
/* Keyboard Focus (Accessibility) */
|
||||
&: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;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
|
@ -61,8 +87,8 @@
|
|||
/* Active/Selected State */
|
||||
&[aria-selected="true"],
|
||||
&.is-active {
|
||||
color: var(--primary-background-colour, #0ea5e9);
|
||||
border-bottom-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: 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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,10 +96,28 @@
|
|||
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"> */
|
||||
:where(nav.tabs.filled) {
|
||||
:where(.tabs.filled) {
|
||||
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;
|
||||
padding: 0.5rem;
|
||||
width: fit-content;
|
||||
|
|
@ -84,14 +128,14 @@
|
|||
border-radius: 6px;
|
||||
|
||||
&: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"],
|
||||
&.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);
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ app.get('/test/tabs', (req: Request, res: Response) => {
|
|||
res.render('tabs', { toggleUnderline, toggleFilled });
|
||||
});
|
||||
|
||||
app.get('/test/spinner', (req: Request, res: Response) => {
|
||||
res.render('spinner');
|
||||
});
|
||||
|
||||
app.listen(3080, () => {
|
||||
console.log('Test server running on http://localhost:3080');
|
||||
});
|
||||
62
test-harness/tests/spinner.spec.ts
Normal file
62
test-harness/tests/spinner.spec.ts
Normal 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 |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
36
test-harness/views/spinner.ejs
Normal file
36
test-harness/views/spinner.ejs
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue