color-swatch
Version:
A web component for displaying color swatches.
289 lines (247 loc) • 10.4 kB
JavaScript
import Color from 'colorjs.io';
import { staticStyles } from './styles.js';
// WCAG 2.1 contrast thresholds
const WCAG21_LARGE_TEXT_CONTRAST = 3;
const WCAG21_NORMAL_TEXT_CONTRAST = 4.5;
/**
* Determines if contrast ratio meets the accessibility threshold
* @param {number} contrastResult - The calculated contrast ratio
* @param {number} accessibleContrastAmount - The WCAG threshold
* @returns {'pass' | 'fail'}
*/
const determineContrastStatus = (contrastResult, accessibleContrastAmount) =>
contrastResult >= accessibleContrastAmount ? 'pass' : 'fail';
/**
* Formats a contrast ratio for display
* @param {number} result - The contrast ratio
* @returns {string} Formatted ratio (e.g., "4.50:1")
*/
const formatContrastRatio = (result) => `${Number(result).toFixed(2)}:1`;
/** Default contrast info when color is invalid or not set */
const DEFAULT_CONTRAST_INFO = {
largeText: {
lightText: { status: 'fail', ratio: 'N/A' },
darkText: { status: 'fail', ratio: 'N/A' },
},
normalText: {
lightText: { status: 'fail', ratio: 'N/A' },
darkText: { status: 'fail', ratio: 'N/A' },
},
};
/**
* Escapes HTML special characters to prevent XSS
* @param {string} str - The string to escape
* @returns {string} The escaped string
*/
const escapeHtml = (str) => {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
const toggleIcon = `<svg aria-hidden="true" focusable="false" style="width: 1em; height: 1em; vertical-align: middle; fill: #000; overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="M981.333333 512c0 260.266667-170.666667 469.333333-469.333333 469.333333L512 42.666667C810.666667 42.666667 981.333333 251.733333 981.333333 512z" />
<path d="M512 1024c-25.6 0-42.666667-17.066667-42.666667-42.666667L469.333333 42.666667c0-25.6 17.066667-42.666667 42.666667-42.666667 302.933333 0 512 209.066667 512 512S814.933333 1024 512 1024zM554.666667 85.333333 554.666667 938.666667c264.533333-21.333333 384-221.866667 384-426.666667S819.2 106.666667 554.666667 85.333333z" />
<path d="M512 1024C230.4 1024 0 793.6 0 512S230.4 0 512 0c25.6 0 42.666667 17.066667 42.666667 42.666667l0 938.666667C554.666667 1006.933333 537.6 1024 512 1024zM469.333333 85.333333C256 110.933333 85.333333 290.133333 85.333333 512s170.666667 401.066667 384 426.666667L469.333333 85.333333z" />
</svg>`;
const render = x => {
const info = x.contrastInfo || DEFAULT_CONTRAST_INFO;
const isExpanded = x.getAttribute('show-contrast-info') === 'true';
return `<div part="cs-swatch" class="cs-swatch" style="background-color: ${escapeHtml(x.colorValue)};">
<div part="cs-contrast-info" aria-hidden="${!isExpanded}">
<div part="cs-contrast-info-container">
<dl part="cs-contrast-list" title="Large Light Text Contrast ${info.largeText.lightText.ratio}">
<dt part="cs-large-light-text" style="color: ${escapeHtml(x.lightTextColor)};">AA</dt>
<dd part="cs-large-light-text-status">${x.renderContrastPassFail(info.largeText.lightText.status)}</dd>
</dl>
<dl part="cs-contrast-list" title="Normal Light Text Contrast ${info.normalText.lightText.ratio}">
<dt part="cs-normal-light-text" style="color: ${escapeHtml(x.lightTextColor)};">AA</dt>
<dd part="cs-normal-light-text-status">${x.renderContrastPassFail(info.normalText.lightText.status)}</dd>
</dl>
</div>
<div part="cs-contrast-info-container">
<dl part="cs-contrast-list" title="Large Dark Text Contrast ${info.largeText.darkText.ratio}">
<dt part="cs-large-dark-text" style="color: ${escapeHtml(x.darkTextColor)};">AA</dt>
<dd part="cs-large-dark-text-status">${x.renderContrastPassFail(info.largeText.darkText.status)}</dd>
</dl>
<dl part="cs-contrast-list" title="Normal Dark Text Contrast ${info.normalText.darkText.ratio}">
<dt part="cs-normal-dark-text" style="color: ${escapeHtml(x.darkTextColor)};">AA</dt>
<dd part="cs-normal-dark-text-status">${x.renderContrastPassFail(info.normalText.darkText.status)}</dd>
</dl>
</div>
</div>
</div>
<div part="cs-body">
<div part="cs-slot">
<div part="cs-color-name" class="cs-color-name">${escapeHtml(x.colorName)}</div>
<slot />
</div>
<button type="button" part="cs-contrast-toggle" class="cs-contrast-toggle" title="Toggle Contrast Info" aria-expanded="${isExpanded}">
<span part="cs-visually-hidden">Toggle Contrast Info</span>
${toggleIcon}
</button>
</div>`;
};
class ColorSwatch extends HTMLElement {
static get observedAttributes() {
return [
'color-value',
'color-name',
'light-text-color',
'dark-text-color',
'show-contrast-info',
];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Bind event handlers
this.handleContrastToggle = this.handleContrastToggle.bind(this);
this.contrastInfo = null;
/**
* CSS
* Only append CSS to head if we haven't already added the stylesheet
* Avoids duplicated styles when multiple color-swatches are added to the page
*/
if (!document.querySelector('#color-swatch-styles')) {
const styles = document.createElement('style');
styles.id = 'color-swatch-styles';
styles.textContent = staticStyles;
document.head.appendChild(styles);
}
}
/**
* Helper to set or remove an attribute based on value
* @param {string} name - The attribute name
* @param {string|null} val - The value to set, or falsy to remove
*/
_setAttr(name, val) {
if (val) {
this.setAttribute(name, val);
} else {
this.removeAttribute(name);
}
}
get colorValue() {
return this.getAttribute('color-value');
}
set colorValue(val) {
this._setAttr('color-value', val);
}
get colorName() {
return this.getAttribute('color-name');
}
set colorName(val) {
this._setAttr('color-name', val);
}
get lightTextColor() {
return this.getAttribute('light-text-color') || '#ffffff';
}
set lightTextColor(val) {
this._setAttr('light-text-color', val);
}
get darkTextColor() {
return this.getAttribute('dark-text-color') || '#000000';
}
set darkTextColor(val) {
this._setAttr('dark-text-color', val);
}
get showContrastInfo() {
return this.getAttribute('show-contrast-info') === 'true';
}
set showContrastInfo(val) {
if(val) {
this.setAttribute('show-contrast-info', 'true');
} else {
this.removeAttribute('show-contrast-info');
}
}
determineContrast() {
// Early return if no color value is set
if (!this.colorValue) {
return DEFAULT_CONTRAST_INFO;
}
try {
const swatchColor = new Color(this.colorValue);
const lightTextColor = new Color(this.lightTextColor);
const darkTextColor = new Color(this.darkTextColor);
const lightTextColorContrast = swatchColor.contrast(lightTextColor, "WCAG21");
const darkTextColorContrast = swatchColor.contrast(darkTextColor, "WCAG21");
return {
largeText: {
lightText: {
status: determineContrastStatus(lightTextColorContrast, WCAG21_LARGE_TEXT_CONTRAST),
ratio: formatContrastRatio(lightTextColorContrast),
},
darkText: {
status: determineContrastStatus(darkTextColorContrast, WCAG21_LARGE_TEXT_CONTRAST),
ratio: formatContrastRatio(darkTextColorContrast),
},
},
normalText: {
lightText: {
status: determineContrastStatus(lightTextColorContrast, WCAG21_NORMAL_TEXT_CONTRAST),
ratio: formatContrastRatio(lightTextColorContrast),
},
darkText: {
status: determineContrastStatus(darkTextColorContrast, WCAG21_NORMAL_TEXT_CONTRAST),
ratio: formatContrastRatio(darkTextColorContrast),
},
},
};
} catch (error) {
console.error('ColorSwatch: Invalid color value', error);
return DEFAULT_CONTRAST_INFO;
}
}
renderContrastPassFail(status) {
if (status === 'pass') {
return '<span role="img" aria-label="Pass">✅</span>';
}
return '<span role="img" aria-label="Fail">❌</span>';
}
handleContrastToggle() {
const isExpanded = this.showContrastInfo;
this.showContrastInfo = !isExpanded;
// Update aria attributes without full re-render
const toggleBtn = this.shadowRoot.querySelector('[part="cs-contrast-toggle"]');
const contrastInfo = this.shadowRoot.querySelector('[part="cs-contrast-info"]');
toggleBtn?.setAttribute('aria-expanded', String(!isExpanded));
contrastInfo?.setAttribute('aria-hidden', String(isExpanded));
}
connectedCallback() {
// Initial render when element is added to DOM
this.contrastInfo = this.determineContrast();
this.shadowRoot.innerHTML = render(this);
const toggleBtn = this.shadowRoot.querySelector('[part="cs-contrast-toggle"]');
toggleBtn?.addEventListener('click', this.handleContrastToggle);
}
disconnectedCallback() {
const toggleBtn = this.shadowRoot.querySelector('[part="cs-contrast-toggle"]');
toggleBtn?.removeEventListener('click', this.handleContrastToggle);
}
attributeChangedCallback(name, oldValue, newValue) {
// Guard against early calls before connectedCallback
if (!this.isConnected) {
return;
}
if (newValue !== oldValue) {
// Skip re-render for show-contrast-info since CSS handles visibility
if (name === 'show-contrast-info') {
return;
}
this.contrastInfo = this.determineContrast();
// Remove old event listener before replacing DOM to prevent memory leak
const oldToggleBtn = this.shadowRoot.querySelector('[part="cs-contrast-toggle"]');
oldToggleBtn?.removeEventListener('click', this.handleContrastToggle);
this.shadowRoot.innerHTML = render(this);
// Re-add event listener when DOM is updated
const toggleBtn = this.shadowRoot.querySelector('[part="cs-contrast-toggle"]');
toggleBtn?.addEventListener('click', this.handleContrastToggle);
}
}
}
window.customElements.get("color-swatch") || window.customElements.define("color-swatch", ColorSwatch);