mintwaterfall
Version:
A powerful, D3.js-compatible waterfall chart component with enterprise features including breakdown analysis, conditional formatting, stacking capabilities, animations, and extensive customization options
681 lines (566 loc) • 25.1 kB
text/typescript
// MintWaterfall Accessibility System - TypeScript Version
// Provides WCAG 2.1 AA compliance features for screen readers and keyboard navigation with full type safety
import * as d3 from 'd3';
import { rgb } from 'd3-color';
// Type definitions for accessibility system
export interface AccessibilityConfig {
title?: string;
summary?: string;
totalItems?: number;
showTotal?: boolean;
formatNumber?: (n: number) => string;
}
export interface WaterfallDataItem {
label: string;
stacks: StackItem[];
cumulative?: number;
}
export interface StackItem {
label?: string;
value: number;
}
export interface HierarchicalData {
children?: HierarchicalData[];
value?: number;
}
export interface ContrastResult {
ratio: number;
passesAA: boolean;
passesAAA: boolean;
}
export interface AccessibilityResult {
bars: d3.Selection<d3.BaseType, any, any, any>;
focusableElements: number;
}
export interface ChartAccessibilityResult {
accessibilitySystem: AccessibilitySystem;
descriptionId: string;
focusableElements: number;
}
export interface AccessibilitySystem {
createLiveRegion(container: d3.Selection<d3.BaseType, any, any, any>): d3.Selection<HTMLDivElement, any, any, any>;
createChartDescription(container: d3.Selection<d3.BaseType, any, any, any>, data: WaterfallDataItem[] | HierarchicalData, config?: AccessibilityConfig): string;
makeAccessible(chartContainer: d3.Selection<d3.BaseType, any, any, any>, data: WaterfallDataItem[], config?: AccessibilityConfig): AccessibilityResult;
handleChartKeydown(event: KeyboardEvent, data: WaterfallDataItem[], config: AccessibilityConfig): void;
handleBarKeydown(event: KeyboardEvent, barData: WaterfallDataItem, index: number, allData: WaterfallDataItem[], config: AccessibilityConfig): void;
moveFocus(direction: number, data: WaterfallDataItem[], config: AccessibilityConfig): void;
focusElement(index: number, data: WaterfallDataItem[], config: AccessibilityConfig): void;
announce(message: string): void;
detectHighContrast(): boolean;
applyHighContrastStyles(chartContainer: d3.Selection<d3.BaseType, any, any, any>): void;
injectForcedColorsCSS(): void;
respectsReducedMotion(): boolean;
getAccessibleAnimationDuration(defaultDuration: number): number;
validateColorContrast(foreground: string, background: string): ContrastResult;
setAnnounceFunction(fn: (message: string) => void): AccessibilitySystem;
getCurrentFocus(): number;
getFocusableCount(): number;
}
export function createAccessibilitySystem(): AccessibilitySystem {
let currentFocusIndex: number = -1;
let focusableElements: any[] = [];
let announceFunction: ((message: string) => void) | null = null;
let descriptionId: string | null = null;
// ARIA live region for dynamic announcements
function createLiveRegion(container: d3.Selection<d3.BaseType, any, any, any>): d3.Selection<HTMLDivElement, any, any, any> {
const liveRegion = container.append<HTMLDivElement>("div")
.attr("id", "waterfall-live-region")
.attr("aria-live", "polite")
.attr("aria-atomic", "true")
.style("position", "absolute")
.style("left", "-10000px")
.style("width", "1px")
.style("height", "1px")
.style("overflow", "hidden");
return liveRegion;
}
// Create chart description for screen readers
function createChartDescription(
container: d3.Selection<d3.BaseType, any, any, any>,
data: WaterfallDataItem[] | HierarchicalData,
config: AccessibilityConfig = {}
): string {
const {
title = "Waterfall Chart",
summary = "Interactive waterfall chart showing data progression",
totalItems = Array.isArray(data) ? data.length : 1,
showTotal: hasTotal = false
} = config;
const descId = "waterfall-description-" + Math.random().toString(36).substr(2, 9);
const description = container.append("div")
.attr("id", descId)
.attr("class", "sr-only")
.style("position", "absolute")
.style("left", "-10000px")
.style("width", "1px")
.style("height", "1px")
.style("overflow", "hidden");
// Calculate summary statistics based on data structure
let totalValue = 0;
let positiveCount = 0;
let negativeCount = 0;
if (Array.isArray(data)) {
// Waterfall chart data structure
totalValue = data.reduce((sum, item) => {
if (item.stacks && Array.isArray(item.stacks)) {
return sum + item.stacks.reduce((stackSum, stack) => stackSum + (stack.value || 0), 0);
}
return sum;
}, 0);
positiveCount = data.filter(item =>
item.stacks && item.stacks.some(stack => (stack.value || 0) > 0)
).length;
negativeCount = data.filter(item =>
item.stacks && item.stacks.some(stack => stack.value < 0)
).length;
} else if (data && typeof data === "object" && (data as HierarchicalData).children) {
// Hierarchical chart data structure
function calculateHierarchicalStats(node: HierarchicalData): number {
if (node.children && Array.isArray(node.children)) {
return node.children.reduce((sum, child) => sum + calculateHierarchicalStats(child), 0);
} else {
return node.value || 0;
}
}
totalValue = calculateHierarchicalStats(data as HierarchicalData);
positiveCount = 1; // For hierarchical data, we consider it as one positive entity
negativeCount = 0;
}
description.html(`
<h3>${title}</h3>
<p>${summary}</p>
<p>This chart contains ${totalItems} data categories${hasTotal ? " plus a total bar" : ""}.</p>
<p>Total value: ${config.formatNumber ? config.formatNumber(totalValue) : totalValue}</p>
<p>${positiveCount} categories have positive values, ${negativeCount} have negative values.</p>
<p>Use Tab to navigate between bars, Enter to hear details, and Arrow keys to move between bars.</p>
<p>Press Escape to return focus to the chart container.</p>
`);
descriptionId = descId;
return descId;
}
// Make chart elements keyboard accessible
function makeAccessible(
chartContainer: d3.Selection<d3.BaseType, any, any, any>,
data: WaterfallDataItem[],
config: AccessibilityConfig = {}
): AccessibilityResult {
const svg = chartContainer.select("svg");
// Add main chart ARIA attributes
svg.attr("role", "img")
.attr("aria-labelledby", descriptionId)
.attr("tabindex", "0")
.attr("aria-describedby", descriptionId);
// Add keyboard event handlers to main SVG
svg.on("keydown", function(event: KeyboardEvent) {
handleChartKeydown(event, data, config);
});
// Make individual bars focusable and accessible
const bars = svg.selectAll(".bar-group");
bars.each(function(d: any, i: number) {
const bar = d3.select(this);
const data = d as WaterfallDataItem;
bar.attr("role", "button")
.attr("tabindex", "-1")
.attr("aria-label", createBarAriaLabel(data, i, config))
.attr("aria-describedby", `bar-description-${i}`)
.on("keydown", function(event: KeyboardEvent) {
handleBarKeydown(event, data, i, [data], config);
})
.on("focus", function() {
currentFocusIndex = i;
const element = this as Element;
if (element) {
highlightFocusedElement(element);
}
})
.on("blur", function() {
const element = this as Element;
if (element) {
removeFocusHighlight(element);
}
});
});
// Store focusable elements
focusableElements = bars && bars.nodes && typeof bars.nodes === 'function'
? bars.nodes().filter(node => node !== null)
: [];
return {
bars,
focusableElements: focusableElements.length
};
}
// Create ARIA label for individual bars
function createBarAriaLabel(data: WaterfallDataItem, index: number, config: AccessibilityConfig = {}): string {
if (!data || !data.stacks || !Array.isArray(data.stacks)) {
return `Item ${index + 1}: Invalid data`;
}
const totalValue = data.stacks.reduce((sum, stack) => sum + stack.value, 0);
const stackCount = data.stacks.length;
const formatNumber = config.formatNumber || ((n: number) => n.toString());
let label = `${data.label}: ${formatNumber(totalValue)}`;
if (stackCount > 1) {
label += `, ${stackCount} segments`;
}
if (data.cumulative !== undefined) {
label += `, cumulative total: ${formatNumber(data.cumulative)}`;
}
label += ". Press Enter for details.";
return label;
}
// Handle keyboard navigation on chart level
function handleChartKeydown(event: KeyboardEvent, data: WaterfallDataItem[], config: AccessibilityConfig): void {
switch(event.key) {
case "Tab":
// Let default tab behavior work
break;
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
moveFocus(1, data, config);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
moveFocus(-1, data, config);
break;
case "Home":
event.preventDefault();
focusElement(0, data, config);
break;
case "End":
event.preventDefault();
focusElement(focusableElements.length - 1, data, config);
break;
case "Enter":
case " ":
event.preventDefault();
if (currentFocusIndex >= 0) {
announceBarDetails(data[currentFocusIndex], currentFocusIndex, config);
} else {
announceChartSummary(data, config);
}
break;
case "Escape":
event.preventDefault();
returnFocusToChart();
break;
}
}
// Handle keyboard events on individual bars
function handleBarKeydown(
event: KeyboardEvent,
barData: WaterfallDataItem,
index: number,
allData: WaterfallDataItem[],
config: AccessibilityConfig
): void {
switch(event.key) {
case "Enter":
case " ":
event.preventDefault();
announceBarDetails(barData, index, config);
// Trigger click event for compatibility
d3.select(event.target as Element).dispatch("click");
break;
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
moveFocus(1, allData, config);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
moveFocus(-1, allData, config);
break;
}
}
// Move focus between chart elements
function moveFocus(direction: number, data: WaterfallDataItem[], config: AccessibilityConfig): void {
if (focusableElements.length === 0) return;
let newIndex = currentFocusIndex + direction;
// Wrap around
if (newIndex >= focusableElements.length) {
newIndex = 0;
} else if (newIndex < 0) {
newIndex = focusableElements.length - 1;
}
focusElement(newIndex, data, config);
}
// Focus specific element by index
function focusElement(index: number, data: WaterfallDataItem[], config: AccessibilityConfig): void {
if (index < 0 || index >= focusableElements.length) return;
currentFocusIndex = index;
const element = focusableElements[index] as HTMLElement;
if (element && element.focus) {
element.focus();
}
// Announce the focused element
const barData = data[index];
announceBarFocus(barData, index, config);
}
// Return focus to main chart container
function returnFocusToChart(): void {
const svg = d3.select("svg[role='img']");
if (!svg.empty()) {
const svgNode = svg.node() as HTMLElement;
if (svgNode) {
svgNode.focus();
}
currentFocusIndex = -1;
}
}
// Visual focus indicators
function highlightFocusedElement(element: Element): void {
d3.select(element)
.style("outline", "3px solid #4A90E2")
.style("outline-offset", "2px");
}
function removeFocusHighlight(element: Element): void {
d3.select(element)
.style("outline", null)
.style("outline-offset", null);
}
// Screen reader announcements
function announceBarFocus(data: WaterfallDataItem, index: number, config: AccessibilityConfig): void {
const formatNumber = config.formatNumber || ((n: number) => n.toString());
const totalValue = data.stacks.reduce((sum, stack) => sum + stack.value, 0);
const message = `Focused on ${data.label}, value ${formatNumber(totalValue)}`;
announce(message);
}
function announceBarDetails(data: WaterfallDataItem, index: number, config: AccessibilityConfig): void {
const formatNumber = config.formatNumber || ((n: number) => n.toString());
const totalValue = data.stacks.reduce((sum, stack) => sum + stack.value, 0);
let message = `${data.label}: Total value ${formatNumber(totalValue)}`;
if (data.stacks.length > 1) {
message += `. Contains ${data.stacks.length} segments: `;
const segments = data.stacks.map(stack =>
`${stack.label || formatNumber(stack.value)}`
).join(", ");
message += segments;
}
if (data.cumulative !== undefined) {
message += `. Cumulative total: ${formatNumber(data.cumulative)}`;
}
announce(message);
}
function announceChartSummary(data: WaterfallDataItem[], config: AccessibilityConfig): void {
const formatNumber = config.formatNumber || ((n: number) => n.toString());
const totalValue = data.reduce((sum, item) => {
return sum + item.stacks.reduce((stackSum, stack) => stackSum + stack.value, 0);
}, 0);
const message = `Waterfall chart with ${data.length} categories. Total value: ${formatNumber(totalValue)}. Use arrow keys to navigate between bars.`;
announce(message);
}
// Announce message to screen readers
function announce(message: string): void {
const liveRegion = d3.select("#waterfall-live-region");
if (!liveRegion.empty()) {
liveRegion.text(message);
}
// Also call custom announce function if provided
if (announceFunction) {
announceFunction(message);
}
}
// High contrast mode detection and support (Updated: 2025-08-28)
function detectHighContrast(): boolean {
// Check for modern forced colors mode and high contrast preferences
if (window.matchMedia) {
// First check for modern forced-colors mode (preferred)
if (window.matchMedia("(forced-colors: active)").matches) {
return true;
}
// Then check for prefers-contrast
if (window.matchMedia("(prefers-contrast: high)").matches) {
return true;
}
// Additional modern checks for high contrast scenarios
if (window.matchMedia("(prefers-contrast: more)").matches) {
return true;
}
// Check for inverted colors which often indicates high contrast mode
if (window.matchMedia("(inverted-colors: inverted)").matches) {
return true;
}
// Fallback: detect if system colors are being used (indicates forced colors)
try {
const testElement = document.createElement("div");
testElement.style.color = "rgb(1, 2, 3)";
testElement.style.position = "absolute";
testElement.style.visibility = "hidden";
document.body.appendChild(testElement);
const computedColor = window.getComputedStyle(testElement).color;
document.body.removeChild(testElement);
// If the computed color doesn't match what we set, forced colors is likely active
return computedColor !== "rgb(1, 2, 3)";
} catch (e) {
// If detection fails, assume no high contrast for safety
return false;
}
}
return false;
}
function applyHighContrastStyles(chartContainer: d3.Selection<d3.BaseType, any, any, any>): void {
if (!detectHighContrast()) return;
const svg = chartContainer.select("svg");
// Apply modern forced colors mode compatible styles using CSS system colors
svg.selectAll(".bar-group rect")
.style("stroke", "CanvasText")
.style("stroke-width", "2px")
.style("fill", "ButtonFace");
svg.selectAll(".x-axis, .y-axis")
.style("stroke", "CanvasText")
.style("stroke-width", "2px");
svg.selectAll("text")
.style("fill", "CanvasText")
.style("font-weight", "bold");
// Apply high contrast styles to trend lines if present
svg.selectAll(".trend-line")
.style("stroke", "Highlight")
.style("stroke-width", "3px");
// Ensure tooltips work in forced colors mode
svg.selectAll(".tooltip")
.style("background", "Canvas")
.style("border", "2px solid CanvasText")
.style("color", "CanvasText");
}
// Inject CSS for forced colors mode support
function injectForcedColorsCSS(): void {
// Check if we're in a browser environment
if (typeof document === "undefined") return; // Node.js environment
const cssId = "mintwaterfall-forced-colors-css";
if (document.getElementById(cssId)) return; // Already injected
const css = `
@media (forced-colors: active) {
.mintwaterfall-chart svg {
forced-color-adjust: none;
}
.mintwaterfall-chart .bar-group rect {
stroke: CanvasText !important;
stroke-width: 2px !important;
}
.mintwaterfall-chart .x-axis,
.mintwaterfall-chart .y-axis {
stroke: CanvasText !important;
stroke-width: 2px !important;
}
.mintwaterfall-chart text {
fill: CanvasText !important;
font-weight: bold !important;
}
.mintwaterfall-chart .trend-line {
stroke: Highlight !important;
stroke-width: 3px !important;
}
.mintwaterfall-tooltip {
background: Canvas !important;
border: 2px solid CanvasText !important;
color: CanvasText !important;
forced-color-adjust: none;
}
}
@media (prefers-contrast: high) {
.mintwaterfall-chart .bar-group rect {
stroke-width: 2px !important;
}
.mintwaterfall-chart text {
font-weight: bold !important;
}
}
`;
const style = document.createElement("style");
style.id = cssId;
style.textContent = css;
document.head.appendChild(style);
}
// Reduced motion support
function respectsReducedMotion(): boolean {
if (window.matchMedia) {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
return false;
}
function getAccessibleAnimationDuration(defaultDuration: number): number {
return respectsReducedMotion() ? 0 : defaultDuration;
}
// Color contrast validation
function validateColorContrast(foreground: string, background: string): ContrastResult {
// Simplified contrast ratio calculation
// In production, use a proper color contrast library
const getLuminance = (color: string): number => {
// This is a simplified version - use a proper color library
const colorRgb = rgb(color);
if (!colorRgb) return 0;
return (0.299 * colorRgb.r + 0.587 * colorRgb.g + 0.114 * colorRgb.b) / 255;
};
const l1 = getLuminance(foreground);
const l2 = getLuminance(background);
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
return {
ratio,
passesAA: ratio >= 4.5,
passesAAA: ratio >= 7
};
}
// Public API
const accessibilitySystem: AccessibilitySystem = {
createLiveRegion,
createChartDescription,
makeAccessible,
handleChartKeydown,
handleBarKeydown,
moveFocus,
focusElement,
announce,
detectHighContrast,
applyHighContrastStyles,
injectForcedColorsCSS,
respectsReducedMotion,
getAccessibleAnimationDuration,
validateColorContrast,
// Configuration
setAnnounceFunction(fn: (message: string) => void): AccessibilitySystem {
announceFunction = fn;
return this;
},
getCurrentFocus(): number {
return currentFocusIndex;
},
getFocusableCount(): number {
return focusableElements.length;
}
};
return accessibilitySystem;
}
// Global accessibility system instance
export const accessibilitySystem = createAccessibilitySystem();
// Inject CSS support immediately for global instance (only in browser)
if (typeof document !== "undefined") {
accessibilitySystem.injectForcedColorsCSS();
}
// Utility function to make any chart accessible
export function makeChartAccessible(
chartContainer: d3.Selection<d3.BaseType, any, any, any>,
data: WaterfallDataItem[],
config: AccessibilityConfig = {}
): ChartAccessibilityResult {
const a11y = createAccessibilitySystem();
// Inject forced colors CSS support (only in browser)
if (typeof document !== "undefined") {
a11y.injectForcedColorsCSS();
}
// Create live region for announcements
a11y.createLiveRegion(d3.select("body"));
// Create chart description
const descId = a11y.createChartDescription(chartContainer, data, config);
// Make chart elements accessible
const result = a11y.makeAccessible(chartContainer, data, config);
// Apply high contrast styles if needed
a11y.applyHighContrastStyles(chartContainer);
return {
accessibilitySystem: a11y,
descriptionId: descId,
focusableElements: result.focusableElements
};
}
export default createAccessibilitySystem;