mintwaterfall 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +223 -0
- package/CONTRIBUTING.md +199 -0
- package/README.md +363 -0
- package/dist/index.d.ts +149 -0
- package/dist/mintwaterfall.cjs.js +7978 -0
- package/dist/mintwaterfall.esm.js +7907 -0
- package/dist/mintwaterfall.min.js +7 -0
- package/dist/mintwaterfall.umd.js +7978 -0
- package/index.d.ts +149 -0
- package/package.json +126 -0
- package/src/enterprise/enterprise-core.js +0 -0
- package/src/enterprise/enterprise-feature-template.js +0 -0
- package/src/enterprise/feature-registry.js +0 -0
- package/src/enterprise/features/breakdown.js +0 -0
- package/src/features/breakdown.js +0 -0
- package/src/features/conditional-formatting.js +0 -0
- package/src/index.js +111 -0
- package/src/mintwaterfall-accessibility.ts +680 -0
- package/src/mintwaterfall-advanced-data.ts +1034 -0
- package/src/mintwaterfall-advanced-interactions.ts +649 -0
- package/src/mintwaterfall-advanced-performance.ts +582 -0
- package/src/mintwaterfall-animations.ts +595 -0
- package/src/mintwaterfall-brush.ts +471 -0
- package/src/mintwaterfall-chart-core.ts +296 -0
- package/src/mintwaterfall-chart.ts +1915 -0
- package/src/mintwaterfall-data.ts +1100 -0
- package/src/mintwaterfall-export.ts +475 -0
- package/src/mintwaterfall-hierarchical-layouts.ts +724 -0
- package/src/mintwaterfall-layouts.ts +647 -0
- package/src/mintwaterfall-performance.ts +573 -0
- package/src/mintwaterfall-scales.ts +437 -0
- package/src/mintwaterfall-shapes.ts +385 -0
- package/src/mintwaterfall-statistics.ts +821 -0
- package/src/mintwaterfall-themes.ts +391 -0
- package/src/mintwaterfall-tooltip.ts +450 -0
- package/src/mintwaterfall-zoom.ts +399 -0
- package/src/types/js-modules.d.ts +25 -0
- package/src/utils/compatibility-layer.js +0 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
// MintWaterfall Accessibility System - TypeScript Version
|
|
2
|
+
// Provides WCAG 2.1 AA compliance features for screen readers and keyboard navigation with full type safety
|
|
3
|
+
|
|
4
|
+
import * as d3 from 'd3';
|
|
5
|
+
import { rgb } from 'd3-color';
|
|
6
|
+
|
|
7
|
+
// Type definitions for accessibility system
|
|
8
|
+
export interface AccessibilityConfig {
|
|
9
|
+
title?: string;
|
|
10
|
+
summary?: string;
|
|
11
|
+
totalItems?: number;
|
|
12
|
+
showTotal?: boolean;
|
|
13
|
+
formatNumber?: (n: number) => string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WaterfallDataItem {
|
|
17
|
+
label: string;
|
|
18
|
+
stacks: StackItem[];
|
|
19
|
+
cumulative?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StackItem {
|
|
23
|
+
label?: string;
|
|
24
|
+
value: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HierarchicalData {
|
|
28
|
+
children?: HierarchicalData[];
|
|
29
|
+
value?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ContrastResult {
|
|
33
|
+
ratio: number;
|
|
34
|
+
passesAA: boolean;
|
|
35
|
+
passesAAA: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AccessibilityResult {
|
|
39
|
+
bars: d3.Selection<d3.BaseType, any, any, any>;
|
|
40
|
+
focusableElements: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ChartAccessibilityResult {
|
|
44
|
+
accessibilitySystem: AccessibilitySystem;
|
|
45
|
+
descriptionId: string;
|
|
46
|
+
focusableElements: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AccessibilitySystem {
|
|
50
|
+
createLiveRegion(container: d3.Selection<d3.BaseType, any, any, any>): d3.Selection<HTMLDivElement, any, any, any>;
|
|
51
|
+
createChartDescription(container: d3.Selection<d3.BaseType, any, any, any>, data: WaterfallDataItem[] | HierarchicalData, config?: AccessibilityConfig): string;
|
|
52
|
+
makeAccessible(chartContainer: d3.Selection<d3.BaseType, any, any, any>, data: WaterfallDataItem[], config?: AccessibilityConfig): AccessibilityResult;
|
|
53
|
+
handleChartKeydown(event: KeyboardEvent, data: WaterfallDataItem[], config: AccessibilityConfig): void;
|
|
54
|
+
handleBarKeydown(event: KeyboardEvent, barData: WaterfallDataItem, index: number, allData: WaterfallDataItem[], config: AccessibilityConfig): void;
|
|
55
|
+
moveFocus(direction: number, data: WaterfallDataItem[], config: AccessibilityConfig): void;
|
|
56
|
+
focusElement(index: number, data: WaterfallDataItem[], config: AccessibilityConfig): void;
|
|
57
|
+
announce(message: string): void;
|
|
58
|
+
detectHighContrast(): boolean;
|
|
59
|
+
applyHighContrastStyles(chartContainer: d3.Selection<d3.BaseType, any, any, any>): void;
|
|
60
|
+
injectForcedColorsCSS(): void;
|
|
61
|
+
respectsReducedMotion(): boolean;
|
|
62
|
+
getAccessibleAnimationDuration(defaultDuration: number): number;
|
|
63
|
+
validateColorContrast(foreground: string, background: string): ContrastResult;
|
|
64
|
+
setAnnounceFunction(fn: (message: string) => void): AccessibilitySystem;
|
|
65
|
+
getCurrentFocus(): number;
|
|
66
|
+
getFocusableCount(): number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createAccessibilitySystem(): AccessibilitySystem {
|
|
70
|
+
|
|
71
|
+
let currentFocusIndex: number = -1;
|
|
72
|
+
let focusableElements: any[] = [];
|
|
73
|
+
let announceFunction: ((message: string) => void) | null = null;
|
|
74
|
+
let descriptionId: string | null = null;
|
|
75
|
+
|
|
76
|
+
// ARIA live region for dynamic announcements
|
|
77
|
+
function createLiveRegion(container: d3.Selection<d3.BaseType, any, any, any>): d3.Selection<HTMLDivElement, any, any, any> {
|
|
78
|
+
const liveRegion = container.append<HTMLDivElement>("div")
|
|
79
|
+
.attr("id", "waterfall-live-region")
|
|
80
|
+
.attr("aria-live", "polite")
|
|
81
|
+
.attr("aria-atomic", "true")
|
|
82
|
+
.style("position", "absolute")
|
|
83
|
+
.style("left", "-10000px")
|
|
84
|
+
.style("width", "1px")
|
|
85
|
+
.style("height", "1px")
|
|
86
|
+
.style("overflow", "hidden");
|
|
87
|
+
|
|
88
|
+
return liveRegion;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Create chart description for screen readers
|
|
92
|
+
function createChartDescription(
|
|
93
|
+
container: d3.Selection<d3.BaseType, any, any, any>,
|
|
94
|
+
data: WaterfallDataItem[] | HierarchicalData,
|
|
95
|
+
config: AccessibilityConfig = {}
|
|
96
|
+
): string {
|
|
97
|
+
const {
|
|
98
|
+
title = "Waterfall Chart",
|
|
99
|
+
summary = "Interactive waterfall chart showing data progression",
|
|
100
|
+
totalItems = Array.isArray(data) ? data.length : 1,
|
|
101
|
+
showTotal: hasTotal = false
|
|
102
|
+
} = config;
|
|
103
|
+
|
|
104
|
+
const descId = "waterfall-description-" + Math.random().toString(36).substr(2, 9);
|
|
105
|
+
|
|
106
|
+
const description = container.append("div")
|
|
107
|
+
.attr("id", descId)
|
|
108
|
+
.attr("class", "sr-only")
|
|
109
|
+
.style("position", "absolute")
|
|
110
|
+
.style("left", "-10000px")
|
|
111
|
+
.style("width", "1px")
|
|
112
|
+
.style("height", "1px")
|
|
113
|
+
.style("overflow", "hidden");
|
|
114
|
+
|
|
115
|
+
// Calculate summary statistics based on data structure
|
|
116
|
+
let totalValue = 0;
|
|
117
|
+
let positiveCount = 0;
|
|
118
|
+
let negativeCount = 0;
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(data)) {
|
|
121
|
+
// Waterfall chart data structure
|
|
122
|
+
totalValue = data.reduce((sum, item) => {
|
|
123
|
+
if (item.stacks && Array.isArray(item.stacks)) {
|
|
124
|
+
return sum + item.stacks.reduce((stackSum, stack) => stackSum + (stack.value || 0), 0);
|
|
125
|
+
}
|
|
126
|
+
return sum;
|
|
127
|
+
}, 0);
|
|
128
|
+
|
|
129
|
+
positiveCount = data.filter(item =>
|
|
130
|
+
item.stacks && item.stacks.some(stack => (stack.value || 0) > 0)
|
|
131
|
+
).length;
|
|
132
|
+
|
|
133
|
+
negativeCount = data.filter(item =>
|
|
134
|
+
item.stacks && item.stacks.some(stack => stack.value < 0)
|
|
135
|
+
).length;
|
|
136
|
+
} else if (data && typeof data === "object" && (data as HierarchicalData).children) {
|
|
137
|
+
// Hierarchical chart data structure
|
|
138
|
+
function calculateHierarchicalStats(node: HierarchicalData): number {
|
|
139
|
+
if (node.children && Array.isArray(node.children)) {
|
|
140
|
+
return node.children.reduce((sum, child) => sum + calculateHierarchicalStats(child), 0);
|
|
141
|
+
} else {
|
|
142
|
+
return node.value || 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
totalValue = calculateHierarchicalStats(data as HierarchicalData);
|
|
147
|
+
positiveCount = 1; // For hierarchical data, we consider it as one positive entity
|
|
148
|
+
negativeCount = 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
description.html(`
|
|
152
|
+
<h3>${title}</h3>
|
|
153
|
+
<p>${summary}</p>
|
|
154
|
+
<p>This chart contains ${totalItems} data categories${hasTotal ? " plus a total bar" : ""}.</p>
|
|
155
|
+
<p>Total value: ${config.formatNumber ? config.formatNumber(totalValue) : totalValue}</p>
|
|
156
|
+
<p>${positiveCount} categories have positive values, ${negativeCount} have negative values.</p>
|
|
157
|
+
<p>Use Tab to navigate between bars, Enter to hear details, and Arrow keys to move between bars.</p>
|
|
158
|
+
<p>Press Escape to return focus to the chart container.</p>
|
|
159
|
+
`);
|
|
160
|
+
|
|
161
|
+
descriptionId = descId;
|
|
162
|
+
return descId;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Make chart elements keyboard accessible
|
|
166
|
+
function makeAccessible(
|
|
167
|
+
chartContainer: d3.Selection<d3.BaseType, any, any, any>,
|
|
168
|
+
data: WaterfallDataItem[],
|
|
169
|
+
config: AccessibilityConfig = {}
|
|
170
|
+
): AccessibilityResult {
|
|
171
|
+
const svg = chartContainer.select("svg");
|
|
172
|
+
|
|
173
|
+
// Add main chart ARIA attributes
|
|
174
|
+
svg.attr("role", "img")
|
|
175
|
+
.attr("aria-labelledby", descriptionId)
|
|
176
|
+
.attr("tabindex", "0")
|
|
177
|
+
.attr("aria-describedby", descriptionId);
|
|
178
|
+
|
|
179
|
+
// Add keyboard event handlers to main SVG
|
|
180
|
+
svg.on("keydown", function(event: KeyboardEvent) {
|
|
181
|
+
handleChartKeydown(event, data, config);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Make individual bars focusable and accessible
|
|
185
|
+
const bars = svg.selectAll(".bar-group");
|
|
186
|
+
|
|
187
|
+
bars.each(function(d: any, i: number) {
|
|
188
|
+
const bar = d3.select(this);
|
|
189
|
+
const data = d as WaterfallDataItem;
|
|
190
|
+
|
|
191
|
+
bar.attr("role", "button")
|
|
192
|
+
.attr("tabindex", "-1")
|
|
193
|
+
.attr("aria-label", createBarAriaLabel(data, i, config))
|
|
194
|
+
.attr("aria-describedby", `bar-description-${i}`)
|
|
195
|
+
.on("keydown", function(event: KeyboardEvent) {
|
|
196
|
+
handleBarKeydown(event, data, i, [data], config);
|
|
197
|
+
})
|
|
198
|
+
.on("focus", function() {
|
|
199
|
+
currentFocusIndex = i;
|
|
200
|
+
const element = this as Element;
|
|
201
|
+
if (element) {
|
|
202
|
+
highlightFocusedElement(element);
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
.on("blur", function() {
|
|
206
|
+
const element = this as Element;
|
|
207
|
+
if (element) {
|
|
208
|
+
removeFocusHighlight(element);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Store focusable elements
|
|
214
|
+
focusableElements = bars && bars.nodes && typeof bars.nodes === 'function'
|
|
215
|
+
? bars.nodes().filter(node => node !== null)
|
|
216
|
+
: [];
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
bars,
|
|
220
|
+
focusableElements: focusableElements.length
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Create ARIA label for individual bars
|
|
225
|
+
function createBarAriaLabel(data: WaterfallDataItem, index: number, config: AccessibilityConfig = {}): string {
|
|
226
|
+
if (!data || !data.stacks || !Array.isArray(data.stacks)) {
|
|
227
|
+
return `Item ${index + 1}: Invalid data`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const totalValue = data.stacks.reduce((sum, stack) => sum + stack.value, 0);
|
|
231
|
+
const stackCount = data.stacks.length;
|
|
232
|
+
const formatNumber = config.formatNumber || ((n: number) => n.toString());
|
|
233
|
+
|
|
234
|
+
let label = `${data.label}: ${formatNumber(totalValue)}`;
|
|
235
|
+
|
|
236
|
+
if (stackCount > 1) {
|
|
237
|
+
label += `, ${stackCount} segments`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (data.cumulative !== undefined) {
|
|
241
|
+
label += `, cumulative total: ${formatNumber(data.cumulative)}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
label += ". Press Enter for details.";
|
|
245
|
+
|
|
246
|
+
return label;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Handle keyboard navigation on chart level
|
|
250
|
+
function handleChartKeydown(event: KeyboardEvent, data: WaterfallDataItem[], config: AccessibilityConfig): void {
|
|
251
|
+
switch(event.key) {
|
|
252
|
+
case "Tab":
|
|
253
|
+
// Let default tab behavior work
|
|
254
|
+
break;
|
|
255
|
+
|
|
256
|
+
case "ArrowRight":
|
|
257
|
+
case "ArrowDown":
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
moveFocus(1, data, config);
|
|
260
|
+
break;
|
|
261
|
+
|
|
262
|
+
case "ArrowLeft":
|
|
263
|
+
case "ArrowUp":
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
moveFocus(-1, data, config);
|
|
266
|
+
break;
|
|
267
|
+
|
|
268
|
+
case "Home":
|
|
269
|
+
event.preventDefault();
|
|
270
|
+
focusElement(0, data, config);
|
|
271
|
+
break;
|
|
272
|
+
|
|
273
|
+
case "End":
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
focusElement(focusableElements.length - 1, data, config);
|
|
276
|
+
break;
|
|
277
|
+
|
|
278
|
+
case "Enter":
|
|
279
|
+
case " ":
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
if (currentFocusIndex >= 0) {
|
|
282
|
+
announceBarDetails(data[currentFocusIndex], currentFocusIndex, config);
|
|
283
|
+
} else {
|
|
284
|
+
announceChartSummary(data, config);
|
|
285
|
+
}
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
case "Escape":
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
returnFocusToChart();
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Handle keyboard events on individual bars
|
|
296
|
+
function handleBarKeydown(
|
|
297
|
+
event: KeyboardEvent,
|
|
298
|
+
barData: WaterfallDataItem,
|
|
299
|
+
index: number,
|
|
300
|
+
allData: WaterfallDataItem[],
|
|
301
|
+
config: AccessibilityConfig
|
|
302
|
+
): void {
|
|
303
|
+
switch(event.key) {
|
|
304
|
+
case "Enter":
|
|
305
|
+
case " ":
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
announceBarDetails(barData, index, config);
|
|
308
|
+
// Trigger click event for compatibility
|
|
309
|
+
d3.select(event.target as Element).dispatch("click");
|
|
310
|
+
break;
|
|
311
|
+
|
|
312
|
+
case "ArrowRight":
|
|
313
|
+
case "ArrowDown":
|
|
314
|
+
event.preventDefault();
|
|
315
|
+
moveFocus(1, allData, config);
|
|
316
|
+
break;
|
|
317
|
+
|
|
318
|
+
case "ArrowLeft":
|
|
319
|
+
case "ArrowUp":
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
moveFocus(-1, allData, config);
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Move focus between chart elements
|
|
327
|
+
function moveFocus(direction: number, data: WaterfallDataItem[], config: AccessibilityConfig): void {
|
|
328
|
+
if (focusableElements.length === 0) return;
|
|
329
|
+
|
|
330
|
+
let newIndex = currentFocusIndex + direction;
|
|
331
|
+
|
|
332
|
+
// Wrap around
|
|
333
|
+
if (newIndex >= focusableElements.length) {
|
|
334
|
+
newIndex = 0;
|
|
335
|
+
} else if (newIndex < 0) {
|
|
336
|
+
newIndex = focusableElements.length - 1;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
focusElement(newIndex, data, config);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Focus specific element by index
|
|
343
|
+
function focusElement(index: number, data: WaterfallDataItem[], config: AccessibilityConfig): void {
|
|
344
|
+
if (index < 0 || index >= focusableElements.length) return;
|
|
345
|
+
|
|
346
|
+
currentFocusIndex = index;
|
|
347
|
+
const element = focusableElements[index] as HTMLElement;
|
|
348
|
+
if (element && element.focus) {
|
|
349
|
+
element.focus();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Announce the focused element
|
|
353
|
+
const barData = data[index];
|
|
354
|
+
announceBarFocus(barData, index, config);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Return focus to main chart container
|
|
358
|
+
function returnFocusToChart(): void {
|
|
359
|
+
const svg = d3.select("svg[role='img']");
|
|
360
|
+
if (!svg.empty()) {
|
|
361
|
+
const svgNode = svg.node() as HTMLElement;
|
|
362
|
+
if (svgNode) {
|
|
363
|
+
svgNode.focus();
|
|
364
|
+
}
|
|
365
|
+
currentFocusIndex = -1;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Visual focus indicators
|
|
370
|
+
function highlightFocusedElement(element: Element): void {
|
|
371
|
+
d3.select(element)
|
|
372
|
+
.style("outline", "3px solid #4A90E2")
|
|
373
|
+
.style("outline-offset", "2px");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function removeFocusHighlight(element: Element): void {
|
|
377
|
+
d3.select(element)
|
|
378
|
+
.style("outline", null)
|
|
379
|
+
.style("outline-offset", null);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Screen reader announcements
|
|
383
|
+
function announceBarFocus(data: WaterfallDataItem, index: number, config: AccessibilityConfig): void {
|
|
384
|
+
const formatNumber = config.formatNumber || ((n: number) => n.toString());
|
|
385
|
+
const totalValue = data.stacks.reduce((sum, stack) => sum + stack.value, 0);
|
|
386
|
+
|
|
387
|
+
const message = `Focused on ${data.label}, value ${formatNumber(totalValue)}`;
|
|
388
|
+
announce(message);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function announceBarDetails(data: WaterfallDataItem, index: number, config: AccessibilityConfig): void {
|
|
392
|
+
const formatNumber = config.formatNumber || ((n: number) => n.toString());
|
|
393
|
+
const totalValue = data.stacks.reduce((sum, stack) => sum + stack.value, 0);
|
|
394
|
+
|
|
395
|
+
let message = `${data.label}: Total value ${formatNumber(totalValue)}`;
|
|
396
|
+
|
|
397
|
+
if (data.stacks.length > 1) {
|
|
398
|
+
message += `. Contains ${data.stacks.length} segments: `;
|
|
399
|
+
const segments = data.stacks.map(stack =>
|
|
400
|
+
`${stack.label || formatNumber(stack.value)}`
|
|
401
|
+
).join(", ");
|
|
402
|
+
message += segments;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (data.cumulative !== undefined) {
|
|
406
|
+
message += `. Cumulative total: ${formatNumber(data.cumulative)}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
announce(message);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function announceChartSummary(data: WaterfallDataItem[], config: AccessibilityConfig): void {
|
|
413
|
+
const formatNumber = config.formatNumber || ((n: number) => n.toString());
|
|
414
|
+
const totalValue = data.reduce((sum, item) => {
|
|
415
|
+
return sum + item.stacks.reduce((stackSum, stack) => stackSum + stack.value, 0);
|
|
416
|
+
}, 0);
|
|
417
|
+
|
|
418
|
+
const message = `Waterfall chart with ${data.length} categories. Total value: ${formatNumber(totalValue)}. Use arrow keys to navigate between bars.`;
|
|
419
|
+
announce(message);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Announce message to screen readers
|
|
423
|
+
function announce(message: string): void {
|
|
424
|
+
const liveRegion = d3.select("#waterfall-live-region");
|
|
425
|
+
if (!liveRegion.empty()) {
|
|
426
|
+
liveRegion.text(message);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Also call custom announce function if provided
|
|
430
|
+
if (announceFunction) {
|
|
431
|
+
announceFunction(message);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// High contrast mode detection and support (Updated: 2025-08-28)
|
|
436
|
+
function detectHighContrast(): boolean {
|
|
437
|
+
// Check for modern forced colors mode and high contrast preferences
|
|
438
|
+
if (window.matchMedia) {
|
|
439
|
+
// First check for modern forced-colors mode (preferred)
|
|
440
|
+
if (window.matchMedia("(forced-colors: active)").matches) {
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Then check for prefers-contrast
|
|
445
|
+
if (window.matchMedia("(prefers-contrast: high)").matches) {
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Additional modern checks for high contrast scenarios
|
|
450
|
+
if (window.matchMedia("(prefers-contrast: more)").matches) {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Check for inverted colors which often indicates high contrast mode
|
|
455
|
+
if (window.matchMedia("(inverted-colors: inverted)").matches) {
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Fallback: detect if system colors are being used (indicates forced colors)
|
|
460
|
+
try {
|
|
461
|
+
const testElement = document.createElement("div");
|
|
462
|
+
testElement.style.color = "rgb(1, 2, 3)";
|
|
463
|
+
testElement.style.position = "absolute";
|
|
464
|
+
testElement.style.visibility = "hidden";
|
|
465
|
+
document.body.appendChild(testElement);
|
|
466
|
+
|
|
467
|
+
const computedColor = window.getComputedStyle(testElement).color;
|
|
468
|
+
document.body.removeChild(testElement);
|
|
469
|
+
|
|
470
|
+
// If the computed color doesn't match what we set, forced colors is likely active
|
|
471
|
+
return computedColor !== "rgb(1, 2, 3)";
|
|
472
|
+
} catch (e) {
|
|
473
|
+
// If detection fails, assume no high contrast for safety
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function applyHighContrastStyles(chartContainer: d3.Selection<d3.BaseType, any, any, any>): void {
|
|
481
|
+
if (!detectHighContrast()) return;
|
|
482
|
+
|
|
483
|
+
const svg = chartContainer.select("svg");
|
|
484
|
+
|
|
485
|
+
// Apply modern forced colors mode compatible styles using CSS system colors
|
|
486
|
+
svg.selectAll(".bar-group rect")
|
|
487
|
+
.style("stroke", "CanvasText")
|
|
488
|
+
.style("stroke-width", "2px")
|
|
489
|
+
.style("fill", "ButtonFace");
|
|
490
|
+
|
|
491
|
+
svg.selectAll(".x-axis, .y-axis")
|
|
492
|
+
.style("stroke", "CanvasText")
|
|
493
|
+
.style("stroke-width", "2px");
|
|
494
|
+
|
|
495
|
+
svg.selectAll("text")
|
|
496
|
+
.style("fill", "CanvasText")
|
|
497
|
+
.style("font-weight", "bold");
|
|
498
|
+
|
|
499
|
+
// Apply high contrast styles to trend lines if present
|
|
500
|
+
svg.selectAll(".trend-line")
|
|
501
|
+
.style("stroke", "Highlight")
|
|
502
|
+
.style("stroke-width", "3px");
|
|
503
|
+
|
|
504
|
+
// Ensure tooltips work in forced colors mode
|
|
505
|
+
svg.selectAll(".tooltip")
|
|
506
|
+
.style("background", "Canvas")
|
|
507
|
+
.style("border", "2px solid CanvasText")
|
|
508
|
+
.style("color", "CanvasText");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Inject CSS for forced colors mode support
|
|
512
|
+
function injectForcedColorsCSS(): void {
|
|
513
|
+
// Check if we're in a browser environment
|
|
514
|
+
if (typeof document === "undefined") return; // Node.js environment
|
|
515
|
+
|
|
516
|
+
const cssId = "mintwaterfall-forced-colors-css";
|
|
517
|
+
if (document.getElementById(cssId)) return; // Already injected
|
|
518
|
+
|
|
519
|
+
const css = `
|
|
520
|
+
@media (forced-colors: active) {
|
|
521
|
+
.mintwaterfall-chart svg {
|
|
522
|
+
forced-color-adjust: none;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
.mintwaterfall-chart .bar-group rect {
|
|
526
|
+
stroke: CanvasText !important;
|
|
527
|
+
stroke-width: 2px !important;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.mintwaterfall-chart .x-axis,
|
|
531
|
+
.mintwaterfall-chart .y-axis {
|
|
532
|
+
stroke: CanvasText !important;
|
|
533
|
+
stroke-width: 2px !important;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.mintwaterfall-chart text {
|
|
537
|
+
fill: CanvasText !important;
|
|
538
|
+
font-weight: bold !important;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.mintwaterfall-chart .trend-line {
|
|
542
|
+
stroke: Highlight !important;
|
|
543
|
+
stroke-width: 3px !important;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.mintwaterfall-tooltip {
|
|
547
|
+
background: Canvas !important;
|
|
548
|
+
border: 2px solid CanvasText !important;
|
|
549
|
+
color: CanvasText !important;
|
|
550
|
+
forced-color-adjust: none;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
@media (prefers-contrast: high) {
|
|
555
|
+
.mintwaterfall-chart .bar-group rect {
|
|
556
|
+
stroke-width: 2px !important;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.mintwaterfall-chart text {
|
|
560
|
+
font-weight: bold !important;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
`;
|
|
564
|
+
|
|
565
|
+
const style = document.createElement("style");
|
|
566
|
+
style.id = cssId;
|
|
567
|
+
style.textContent = css;
|
|
568
|
+
document.head.appendChild(style);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Reduced motion support
|
|
572
|
+
function respectsReducedMotion(): boolean {
|
|
573
|
+
if (window.matchMedia) {
|
|
574
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
575
|
+
}
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function getAccessibleAnimationDuration(defaultDuration: number): number {
|
|
580
|
+
return respectsReducedMotion() ? 0 : defaultDuration;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Color contrast validation
|
|
584
|
+
function validateColorContrast(foreground: string, background: string): ContrastResult {
|
|
585
|
+
// Simplified contrast ratio calculation
|
|
586
|
+
// In production, use a proper color contrast library
|
|
587
|
+
const getLuminance = (color: string): number => {
|
|
588
|
+
// This is a simplified version - use a proper color library
|
|
589
|
+
const colorRgb = rgb(color);
|
|
590
|
+
if (!colorRgb) return 0;
|
|
591
|
+
return (0.299 * colorRgb.r + 0.587 * colorRgb.g + 0.114 * colorRgb.b) / 255;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const l1 = getLuminance(foreground);
|
|
595
|
+
const l2 = getLuminance(background);
|
|
596
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
ratio,
|
|
600
|
+
passesAA: ratio >= 4.5,
|
|
601
|
+
passesAAA: ratio >= 7
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Public API
|
|
606
|
+
const accessibilitySystem: AccessibilitySystem = {
|
|
607
|
+
createLiveRegion,
|
|
608
|
+
createChartDescription,
|
|
609
|
+
makeAccessible,
|
|
610
|
+
handleChartKeydown,
|
|
611
|
+
handleBarKeydown,
|
|
612
|
+
moveFocus,
|
|
613
|
+
focusElement,
|
|
614
|
+
announce,
|
|
615
|
+
detectHighContrast,
|
|
616
|
+
applyHighContrastStyles,
|
|
617
|
+
injectForcedColorsCSS,
|
|
618
|
+
respectsReducedMotion,
|
|
619
|
+
getAccessibleAnimationDuration,
|
|
620
|
+
validateColorContrast,
|
|
621
|
+
|
|
622
|
+
// Configuration
|
|
623
|
+
setAnnounceFunction(fn: (message: string) => void): AccessibilitySystem {
|
|
624
|
+
announceFunction = fn;
|
|
625
|
+
return this;
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
getCurrentFocus(): number {
|
|
629
|
+
return currentFocusIndex;
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
getFocusableCount(): number {
|
|
633
|
+
return focusableElements.length;
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
return accessibilitySystem;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Global accessibility system instance
|
|
641
|
+
export const accessibilitySystem = createAccessibilitySystem();
|
|
642
|
+
|
|
643
|
+
// Inject CSS support immediately for global instance (only in browser)
|
|
644
|
+
if (typeof document !== "undefined") {
|
|
645
|
+
accessibilitySystem.injectForcedColorsCSS();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Utility function to make any chart accessible
|
|
649
|
+
export function makeChartAccessible(
|
|
650
|
+
chartContainer: d3.Selection<d3.BaseType, any, any, any>,
|
|
651
|
+
data: WaterfallDataItem[],
|
|
652
|
+
config: AccessibilityConfig = {}
|
|
653
|
+
): ChartAccessibilityResult {
|
|
654
|
+
const a11y = createAccessibilitySystem();
|
|
655
|
+
|
|
656
|
+
// Inject forced colors CSS support (only in browser)
|
|
657
|
+
if (typeof document !== "undefined") {
|
|
658
|
+
a11y.injectForcedColorsCSS();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Create live region for announcements
|
|
662
|
+
a11y.createLiveRegion(d3.select("body"));
|
|
663
|
+
|
|
664
|
+
// Create chart description
|
|
665
|
+
const descId = a11y.createChartDescription(chartContainer, data, config);
|
|
666
|
+
|
|
667
|
+
// Make chart elements accessible
|
|
668
|
+
const result = a11y.makeAccessible(chartContainer, data, config);
|
|
669
|
+
|
|
670
|
+
// Apply high contrast styles if needed
|
|
671
|
+
a11y.applyHighContrastStyles(chartContainer);
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
accessibilitySystem: a11y,
|
|
675
|
+
descriptionId: descId,
|
|
676
|
+
focusableElements: result.focusableElements
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export default createAccessibilitySystem;
|