diff --git a/src/components/spinner/README.md b/src/components/spinner/README.md new file mode 100644 index 0000000..25c13e8 --- /dev/null +++ b/src/components/spinner/README.md @@ -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 | \ No newline at end of file diff --git a/src/components/spinner/spinner.css b/src/components/spinner/spinner.css new file mode 100644 index 0000000..c996c1f --- /dev/null +++ b/src/components/spinner/spinner.css @@ -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); + } +} \ No newline at end of file diff --git a/src/components/spinner/spinner.ejs b/src/components/spinner/spinner.ejs new file mode 100644 index 0000000..ca0ab54 --- /dev/null +++ b/src/components/spinner/spinner.ejs @@ -0,0 +1,74 @@ + + <%# 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) %> + + + + + + <% if(typeof slots !== 'undefined') { %> + <%- slots %> + <% } else { %> +
+ <%= typeof locals.content !== 'undefined' ? locals.content : '' %> +
+ <% } %> +
\ No newline at end of file diff --git a/src/components/spinner/spinner.mjs b/src/components/spinner/spinner.mjs new file mode 100644 index 0000000..5363422 --- /dev/null +++ b/src/components/spinner/spinner.mjs @@ -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); + } +}); \ No newline at end of file diff --git a/src/components/tabs/tabs.css b/src/components/tabs/tabs.css index ea7cb99..7f463d8 100644 --- a/src/components/tabs/tabs.css +++ b/src/components/tabs/tabs.css @@ -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,19 +87,37 @@ /* 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))); } } /* ======================================== 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: