Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/webviews/webview-side/ipywidgets/kernel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,62 @@ class WidgetManagerComponent {

const outputDisposables = new Map<string, { dispose(): void }>();
const renderedWidgets = new Map<string, { container: HTMLElement; widget?: { dispose: Function }; modelId?: string }>();
/**
* Ensures all interactive elements within a widget are keyboard accessible.
* This function adds tabindex and proper ARIA attributes to buttons and interactive elements
* that may be created by widget libraries (like matplotlib toolbars).
*/
function ensureWidgetKeyboardAccessibility(container: HTMLElement): void {
// Wait a bit for the widget to fully render before modifying it
setTimeout(() => {
// Find all buttons, links, and interactive elements that don't have tabindex
const interactiveSelectors = [
'button:not([tabindex])',
'a[href]:not([tabindex])',
'[role="button"]:not([tabindex])',
'[onclick]:not([tabindex])',
'.jupyter-button:not([tabindex])',
'.widget-button:not([tabindex])',
'.toolbar button',
'.toolbar-button'
];

interactiveSelectors.forEach((selector) => {
const elements = container.querySelectorAll(selector);
elements.forEach((element) => {
if (element instanceof HTMLElement) {
// Make element keyboard accessible
if (!element.hasAttribute('tabindex') || element.getAttribute('tabindex') === '-1') {
element.setAttribute('tabindex', '0');
}
// Add appropriate ARIA role if missing
if (!element.hasAttribute('role') && element.tagName !== 'BUTTON' && element.tagName !== 'A') {
element.setAttribute('role', 'button');
}
// Ensure buttons can be activated with Enter/Space keys
if (!element.hasAttribute('data-keyboard-listener')) {
element.setAttribute('data-keyboard-listener', 'true');
element.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
(element as HTMLElement).click();
}
});
}
}
});
});

// Also check for matplotlib toolbar buttons (they use specific classes)
const matplotlibToolbarButtons = container.querySelectorAll('.mpl-toolbar button, canvas + div button');
matplotlibToolbarButtons.forEach((button) => {
if (button instanceof HTMLElement && !button.hasAttribute('tabindex')) {
button.setAttribute('tabindex', '0');
}
});
}, 500); // Wait 500ms for widget rendering to complete
}

/**
* Called from renderer to render output.
* This will be exposed as a public method on window for renderer to render output.
Expand Down Expand Up @@ -171,6 +227,10 @@ function renderIPyWidget(
}
const ele = document.createElement('div');
ele.className = 'cell-output-ipywidget-background';
// Make the widget container keyboard accessible
ele.setAttribute('tabindex', '0');
ele.setAttribute('role', 'region');
ele.setAttribute('aria-label', 'Interactive widget output');
container.appendChild(ele);
ele.appendChild(output);
renderedWidgets.set(outputId, { container, modelId: model.model_id });
Expand All @@ -184,6 +244,8 @@ function renderIPyWidget(
if (renderedWidgets.has(outputId)) {
renderedWidgets.get(outputId)!.widget = w;
}
// Ensure keyboard accessibility for all interactive elements in the widget
ensureWidgetKeyboardAccessibility(ele);
const disposable = {
dispose: () => {
// What if we render the same model in two cells.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { assert } from 'chai';

/* eslint-disable , @typescript-eslint/no-explicit-any */

// This test verifies the keyboard accessibility enhancements for IPyWidgets
suite('IPyWidgets Keyboard Accessibility', () => {
let mockDocument: any;
let mockElements: any[];

setup(() => {
mockElements = [];
// Create a minimal mock document for testing
mockDocument = {
createElement: (tag: string) => {
const element: any = {
tagName: tag.toUpperCase(),
attributes: new Map<string, string>(),
children: [],
eventListeners: new Map<string, Function[]>(),
className: '',
textContent: '',
getAttribute: function (name: string) {
return this.attributes.get(name);
},
setAttribute: function (name: string, value: string) {
this.attributes.set(name, value);
},
hasAttribute: function (name: string) {
return this.attributes.has(name);
},
appendChild: function (_child: any) {
this.children.push(_child);
},
addEventListener: function (event: string, handler: Function) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, []);
}
this.eventListeners.get(event).push(handler);
},
click: function () {
const clickHandlers = this.eventListeners.get('click') || [];
clickHandlers.forEach((h: Function) => h());
},
dispatchEvent: function (event: any) {
const handlers = this.eventListeners.get(event.type) || [];
handlers.forEach((h: Function) => h(event));
}
};
mockElements.push(element);
return element;
},
body: {
appendChild: function (_child: any) {
// No-op for mock
}
}
};
(global as any).document = mockDocument;
});

teardown(() => {
delete (global as any).document;
mockElements = [];
});

test('Widget container has keyboard accessibility attributes', () => {
// Create a widget container
const container = mockDocument.createElement('div');
container.className = 'cell-output-ipywidget-background';

// Simulate what the renderIPyWidget function does
container.setAttribute('tabindex', '0');
container.setAttribute('role', 'region');
container.setAttribute('aria-label', 'Interactive widget output');

// Verify attributes are set
assert.equal(container.getAttribute('tabindex'), '0', 'Container should have tabindex="0"');
assert.equal(container.getAttribute('role'), 'region', 'Container should have role="region"');
assert.equal(
container.getAttribute('aria-label'),
'Interactive widget output',
'Container should have aria-label'
);
});

test('Buttons without tabindex get tabindex="0"', () => {
const container = mockDocument.createElement('div');
const button = mockDocument.createElement('button');
button.textContent = 'Test Button';
container.appendChild(button);
mockDocument.body.appendChild(container);

// Verify button initially has no tabindex
assert.isFalse(button.hasAttribute('tabindex'), 'Button should not have tabindex initially');

// Simulate the ensureWidgetKeyboardAccessibility function
if (!button.hasAttribute('tabindex')) {
button.setAttribute('tabindex', '0');
}

// Verify button now has tabindex
assert.equal(button.getAttribute('tabindex'), '0', 'Button should have tabindex="0" after fix');
});

test('Links without tabindex get tabindex="0"', () => {
const container = mockDocument.createElement('div');
const link = mockDocument.createElement('a');
link.href = '#';
link.textContent = 'Test Link';
container.appendChild(link);
mockDocument.body.appendChild(container);

// Verify link initially has no tabindex
assert.isFalse(link.hasAttribute('tabindex'), 'Link should not have tabindex initially');

// Simulate the ensureWidgetKeyboardAccessibility function
if (!link.hasAttribute('tabindex')) {
link.setAttribute('tabindex', '0');
}

// Verify link now has tabindex
assert.equal(link.getAttribute('tabindex'), '0', 'Link should have tabindex="0" after fix');
});

test('Elements with role="button" without tabindex get tabindex="0"', () => {
const container = mockDocument.createElement('div');
const customButton = mockDocument.createElement('div');
customButton.setAttribute('role', 'button');
customButton.textContent = 'Custom Button';
container.appendChild(customButton);
mockDocument.body.appendChild(container);

// Verify custom button initially has no tabindex
assert.isFalse(customButton.hasAttribute('tabindex'), 'Custom button should not have tabindex initially');

// Simulate the ensureWidgetKeyboardAccessibility function
if (!customButton.hasAttribute('tabindex')) {
customButton.setAttribute('tabindex', '0');
}

// Verify custom button now has tabindex
assert.equal(customButton.getAttribute('tabindex'), '0', 'Custom button should have tabindex="0" after fix');
});

test('Elements with onclick without tabindex get accessibility enhancements', () => {
const container = mockDocument.createElement('div');
const clickableDiv = mockDocument.createElement('div');
clickableDiv.setAttribute('onclick', 'doSomething()');
clickableDiv.textContent = 'Clickable Div';
container.appendChild(clickableDiv);
mockDocument.body.appendChild(container);

// Simulate the ensureWidgetKeyboardAccessibility function
if (!clickableDiv.hasAttribute('tabindex')) {
clickableDiv.setAttribute('tabindex', '0');
}
if (!clickableDiv.hasAttribute('role')) {
clickableDiv.setAttribute('role', 'button');
}

// Verify enhancements
assert.equal(clickableDiv.getAttribute('tabindex'), '0', 'Clickable div should have tabindex="0"');
assert.equal(clickableDiv.getAttribute('role'), 'button', 'Clickable div should have role="button"');
});

test('Toolbar buttons get keyboard accessibility', () => {
const container = mockDocument.createElement('div');
const toolbar = mockDocument.createElement('div');
toolbar.className = 'toolbar';
const toolbarButton = mockDocument.createElement('button');
toolbarButton.textContent = 'Toolbar Action';
toolbar.appendChild(toolbarButton);
container.appendChild(toolbar);
mockDocument.body.appendChild(container);

// Simulate the ensureWidgetKeyboardAccessibility function for toolbar buttons
if (!toolbarButton.hasAttribute('tabindex')) {
toolbarButton.setAttribute('tabindex', '0');
}

// Verify toolbar button has tabindex
assert.equal(toolbarButton.getAttribute('tabindex'), '0', 'Toolbar button should have tabindex="0"');
});

test('Matplotlib toolbar buttons get keyboard accessibility', () => {
const container = mockDocument.createElement('div');
const mplToolbar = mockDocument.createElement('div');
mplToolbar.className = 'mpl-toolbar';
const mplButton = mockDocument.createElement('button');
mplButton.textContent = 'Plot Action';
mplToolbar.appendChild(mplButton);
container.appendChild(mplToolbar);
mockDocument.body.appendChild(container);

// Simulate the ensureWidgetKeyboardAccessibility function for matplotlib buttons
if (!mplButton.hasAttribute('tabindex')) {
mplButton.setAttribute('tabindex', '0');
}

// Verify matplotlib toolbar button has tabindex
assert.equal(mplButton.getAttribute('tabindex'), '0', 'Matplotlib toolbar button should have tabindex="0"');
});

test('Keyboard event handler activates button on Enter key', () => {
const button = mockDocument.createElement('button');
button.textContent = 'Test Button';
button.setAttribute('tabindex', '0');
mockDocument.body.appendChild(button);

let clicked = false;
button.addEventListener('click', () => {
clicked = true;
});

// Simulate keyboard handler
button.addEventListener('keydown', (event: any) => {
if (event.key === 'Enter') {
button.click();
}
});

// Create and dispatch Enter key event
const enterEvent = {
type: 'keydown',
key: 'Enter',
preventDefault: () => {
// No-op for test
}
};
button.dispatchEvent(enterEvent);

// Verify button was clicked
assert.isTrue(clicked, 'Button should be clicked on Enter key');
});

test('Keyboard event handler activates button on Space key', () => {
const button = mockDocument.createElement('button');
button.textContent = 'Test Button';
button.setAttribute('tabindex', '0');
mockDocument.body.appendChild(button);

let clicked = false;
button.addEventListener('click', () => {
clicked = true;
});

// Simulate keyboard handler
button.addEventListener('keydown', (event: any) => {
if (event.key === ' ') {
button.click();
}
});

// Create and dispatch Space key event
const spaceEvent = {
type: 'keydown',
key: ' ',
preventDefault: () => {
// No-op for test
}
};
button.dispatchEvent(spaceEvent);

// Verify button was clicked
assert.isTrue(clicked, 'Button should be clicked on Space key');
});
});
29 changes: 29 additions & 0 deletions src/webviews/webview-side/ipywidgets/renderer/styles.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
.cell-output-ipywidget-background {
background: white !important;
}

/* Ensure all interactive elements in widgets are keyboard accessible */
.cell-output-ipywidget-background button,
.cell-output-ipywidget-background a,
.cell-output-ipywidget-background input,
.cell-output-ipywidget-background select,
.cell-output-ipywidget-background textarea,
.cell-output-ipywidget-background [role="button"],
.cell-output-ipywidget-background [tabindex="0"],
.cell-output-ipywidget-background .widget-button,
.cell-output-ipywidget-background .jupyter-button,
.cell-output-ipywidget-background .jupyter-widgets {
/* Ensure elements are focusable */
outline: none;
}

/* Add visible focus indicator for keyboard navigation */
.cell-output-ipywidget-background button:focus,
.cell-output-ipywidget-background a:focus,
.cell-output-ipywidget-background input:focus,
.cell-output-ipywidget-background select:focus,
.cell-output-ipywidget-background textarea:focus,
.cell-output-ipywidget-background [role="button"]:focus,
.cell-output-ipywidget-background [tabindex="0"]:focus,
.cell-output-ipywidget-background .widget-button:focus,
.cell-output-ipywidget-background .jupyter-button:focus {
outline: 2px solid var(--vscode-focusBorder, #007ACC) !important;
outline-offset: 2px !important;
}