Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Accessibility improvements #126

Merged
merged 9 commits into from
Apr 12, 2021
2 changes: 2 additions & 0 deletions .github/workflows/deploy-demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
push:
branches:
- master
# TODO temporary
- a11y-1

jobs:
deploy:
Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
<!-- ### Fixed -->
<!-- ### Removed -->

<!-- ## Unreleased -->
## Unreleased

### Changed

- [**BREAKING**] Focusing the editor using the Tab key no longer activates edit
mode immediately. This prevents the Tab key from being trapped, which was an
accessibility problem for keyboard users.

Instead, when the editor is focused, users can now press Enter to begin
editing, and Escape to stop editing. A prompt is displayed with these
instructions when focused. Focusing the editor with a mouse click continues to
activate edit mode immediately, as before.

- The editor now uses the CodeMirror `contenteditable` input style, which has
much better screen reader support.

- Line numbers are now annotated with `aria-hidden` so that they are not voiced
by screen readers.

## [0.8.0] - 2021-04-02

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,12 +534,12 @@ project element.
### Properties

| Name         |  Type | Default   | Description |
| ---------------- | ----------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- | --- |
| ---------------- | ----------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- |
| `projectSrc` | `string` | `undefined` | URL of a [project files manifest](#option-2-json-manifest) to load. |
| `files` | `SampleFile[]` | `undefined` | Get or set the array of project files ([details](#option-3-files-property)). |
| `sandboxScope` | `string` | `"playground-elements"` | The service worker scope to register on. |
| `sandboxBaseUrl` | `string` | _module parent directory_ | Base URL for script execution sandbox ([details](#sandbox)). |
| `diagnostics` | `Map<string, lsp.Diagnostic>` | `undefined` | Map from filename to array of Language Server Protocol diagnostics resulting from the latest compilation. | ` |
| `diagnostics` | `Map<string, lsp.Diagnostic>` | `undefined` | Map from filename to array of Language Server Protocol diagnostics resulting from the latest compilation. |

### Methods

Expand Down
236 changes: 119 additions & 117 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"@web/test-runner-playwright": "^0.8.0",
"@web/test-runner-puppeteer": "^0.9.0",
"clean-css": "^4.2.3",
"codemirror": "^5.58.1",
"codemirror": "git+https://github.com/codemirror/CodeMirror.git#2997167571787db3da8a15e5ac65c8ddaf316f0a",
"codemirror-grammar-mode": "^0.1.10",
"google_modes": "git+https://github.com/codemirror/google-modes.git#b78e1c3841a567505c41ad7befa6ca2c289e889e",
"playwright": "^1.5.1",
Expand Down
105 changes: 99 additions & 6 deletions src/playground-code-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
customElement,
css,
property,
query,
internalProperty,
PropertyValues,
html,
} from 'lit-element';
import {nothing} from 'lit-html';
import {ifDefined} from 'lit-html/directives/if-defined.js';
import {CodeMirror} from './lib/codemirror.js';
import codemirrorStyles from './_codemirror/codemirror-styles.js';
Expand Down Expand Up @@ -49,12 +51,40 @@ export class PlaygroundCodeEditor extends LitElement {
position: relative;
}

#focusContainer {
height: 100%;
position: relative;
}

.CodeMirror {
height: 100% !important;
font-family: inherit !important;
border-radius: inherit;
}

#keyboardHelpScrim {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdyt about some background shading here so that the help message is more obviously associated with the source editor?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #127

position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
z-index: 999;
pointer-events: none;
}

#keyboardHelp {
background: #00000099;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we get a little more line spacing and a little less left/right padding?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

padding: 20px 80px;
border-radius: 10px;
color: white;
font-family: sans-serif;
font-size: 18px;
}

.CodeMirror-foldmarker {
font-family: sans-serif;
}
Expand Down Expand Up @@ -164,6 +194,15 @@ export class PlaygroundCodeEditor extends LitElement {
position: string;
};

@internalProperty()
private _showKeyboardHelp = false;

@query('#focusContainer')
private _focusContainer?: HTMLDivElement;

@query('.CodeMirror-code')
private _codemirrorEditable?: HTMLDivElement;

private _resizeObserver?: ResizeObserver;
private _resizing = false;
private _valueChangingFromOutside = false;
Expand Down Expand Up @@ -212,15 +251,34 @@ export class PlaygroundCodeEditor extends LitElement {
}

render() {
if (this.readonly) {
return this._cmDom;
}
return html`
${this._cmDom}
<div
id="tooltip"
?hidden=${!this._tooltipDiagnostic}
style=${ifDefined(this._tooltipDiagnostic?.position)}
id="focusContainer"
tabindex="0"
@mousedown=${this._onMousedown}
@focus=${this._onFocus}
@blur=${this._onBlur}
@keydown=${this._onKeyDown}
>
<div part="diagnostic-tooltip">
${this._tooltipDiagnostic?.diagnostic.message}
${this._showKeyboardHelp
? html`<div id="keyboardHelpScrim">
<p id="keyboardHelp">
Press Enter to start editing<br />Press Escape to exit editor
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider styling on "Enter" and "Escape" to make them stand out a bit? Maybe bold or monospace?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #127

</p>
</div>`
: nothing}
${this._cmDom}
<div
id="tooltip"
?hidden=${!this._tooltipDiagnostic}
style=${ifDefined(this._tooltipDiagnostic?.position)}
>
<div part="diagnostic-tooltip">
${this._tooltipDiagnostic?.diagnostic.message}
</div>
</div>
</div>
`;
Expand Down Expand Up @@ -273,6 +331,11 @@ export class PlaygroundCodeEditor extends LitElement {
lineNumbers: this.lineNumbers,
mode: this._getLanguageMode(),
readOnly: this.readonly,
inputStyle: 'contenteditable',
// Don't allow naturally tabbing into the editor, because it's a
// tab-trap. Instead, the container is focusable, and Enter/Escape are
// used to explicitly enter the editable area.
tabindex: -1,
}
);
cm.on('change', () => {
Expand All @@ -295,6 +358,36 @@ export class PlaygroundCodeEditor extends LitElement {
this._codemirror = cm;
}

private _onMousedown() {
// Directly focus editable region.
this._codemirrorEditable?.focus();
}

private _onFocus() {
// Outer container was focused, either by tabbing from outside, or by
// pressing Escape.
this._showKeyboardHelp = true;
}

private _onBlur() {
// Outer container was unfocused, either by tabbing away from it, or by
// pressing Enter.
this._showKeyboardHelp = false;
}

private _onKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && event.target === this._focusContainer) {
this._codemirrorEditable?.focus();
// Prevent typing a newline from this same event.
event.preventDefault();
} else if (event.key === 'Escape') {
// Note there is no API for "select the next naturally focusable element",
// so instead we just re-focus the outer container, from which point the
// user can tab to move focus entirely elsewhere.
this._focusContainer?.focus();
}
}

/**
* Create hidden and folded regions for playground-hide and playground-fold
* comments.
Expand Down
1 change: 1 addition & 0 deletions src/playground-ide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class PlaygroundIde extends LitElement {

playground-file-editor {
flex: 1;
height: calc(100% - var(--playground-bar-height), 35px);
}

#rhs {
Expand Down
126 changes: 126 additions & 0 deletions src/test/playground-ide_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ suite('playground-ide', () => {
assert.instanceOf(document.createElement('playground-ide'), PlaygroundIde);
});

const raf = async () => new Promise((r) => requestAnimationFrame(r));

const pierce = async (...selectors: string[]) => {
let node = document.body;
for (const selector of selectors) {
Expand Down Expand Up @@ -217,4 +219,128 @@ suite('playground-ide', () => {
};
await assertPreviewContains('Hello HTML');
});

test('a11y: is contenteditable', async () => {
const ide = document.createElement('playground-ide');
ide.config = {
files: {
'index.html': {
content: 'Foo',
},
},
};
container.appendChild(ide);
await assertPreviewContains('Foo');

const cmCode = await pierce(
'playground-ide',
'playground-file-editor',
'playground-code-editor',
'.CodeMirror-code'
);

assert.equal(cmCode.getAttribute('contenteditable'), 'true');
});

test('a11y: line numbers get aria-hidden attribute', async () => {
const ide = document.createElement('playground-ide');
ide.lineNumbers = true;
ide.config = {
files: {
'index.html': {
content: 'Foo\nBar',
},
},
};
container.appendChild(ide);
await assertPreviewContains('Foo\nBar');

const editor = (await pierce(
'playground-ide',
'playground-file-editor',
'playground-code-editor'
)) as PlaygroundCodeEditor;

const queryHiddenLineNumbers = () =>
[
...editor.shadowRoot!.querySelectorAll('.CodeMirror-gutter-wrapper'),
].filter((gutter) => gutter.getAttribute('aria-hidden') === 'true');

// Initial render with line-numbers enabled.
assert.equal(queryHiddenLineNumbers().length, 2);

// Disable line numbers.
ide.lineNumbers = false;
await raf();
assert.equal(queryHiddenLineNumbers().length, 0);

// Re-enable line numbers.
ide.lineNumbers = true;
await raf();
assert.equal(queryHiddenLineNumbers().length, 2);

// Add a line.
const editorInternals = (editor as unknown) as {
_codemirror: PlaygroundCodeEditor['_codemirror'];
};
editorInternals._codemirror!.setValue(editor.value + '\nBaz');
await raf();
assert.equal(queryHiddenLineNumbers().length, 3);
});

test('a11y: focusing shows keyboard prompt', async () => {
const ide = document.createElement('playground-ide');
ide.config = {
files: {
'index.html': {
content: 'Foo',
},
},
};
container.appendChild(ide);
await assertPreviewContains('Foo');

const editor = (await pierce(
'playground-ide',
'playground-file-editor',
'playground-code-editor'
)) as PlaygroundCodeEditor;
const focusContainer = editor.shadowRoot!.querySelector(
'#focusContainer'
) as HTMLElement;
const editableRegion = editor.shadowRoot!.querySelector(
'.CodeMirror-code'
) as HTMLElement;
const keyboardHelp = 'Press Enter';

// Not focused initially
assert.notInclude(focusContainer.textContent, keyboardHelp);

// When the inner container is focused, show the keyboard prompt
focusContainer.focus();
await raf();
assert.isTrue(focusContainer.matches(':focus'));
assert.include(focusContainer.textContent, keyboardHelp);

// Press Enter to start editing
focusContainer.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'}));
await raf();
assert.isTrue(editableRegion.matches(':focus'));
assert.notInclude(focusContainer.textContent, keyboardHelp);

// Press Escape to stop editing
editableRegion.dispatchEvent(
new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})
);
await raf();
assert.isTrue(focusContainer.matches(':focus'));
assert.include(focusContainer.textContent, keyboardHelp);

// Focus something else entirely
focusContainer.blur();
await raf();
assert.isFalse(focusContainer.matches(':focus'));
assert.isFalse(editableRegion.matches(':focus'));
assert.notInclude(focusContainer.textContent, keyboardHelp);
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"incremental": true,
"target": "es2019",
"module": "esnext",
"lib": ["ES2020", "ES2015.iterable", "DOM"],
"lib": ["ES2020", "ES2015.iterable", "DOM", "DOM.Iterable"],
"declaration": true,
"declarationMap": true,
"sourceMap": false,
Expand Down
4 changes: 2 additions & 2 deletions web-test-runner.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default {
nodeResolve: true,
browsers: [
playwrightLauncher({product: 'chromium'}),
// playwrightLauncher({product: 'webkit'}),
playwrightLauncher({product: 'webkit'}),
// Playwright Firefox does not seem to work at all with Service Workers. The
// issue can be reproduced by launching Firefox with flag "-juggler 0". So
// for now we'll use Puppeteer for Firefox.
Expand All @@ -27,7 +27,7 @@ export default {
//
// TODO(aomarks) Look into this a little more and file an issue on
// Playwright (or Firefox).
// puppeteerLauncher({launchOptions: {product: 'firefox'}}),
puppeteerLauncher({launchOptions: {product: 'firefox'}}),
],
browserStartTimeout: 30000, // default 30000
testsStartTimeout: 20000, // default 10000
Expand Down