Skip to content

Commit a92d536

Browse files
authored
CodeBlock - aria arguments and screen-reader improvements (#2879)
1 parent 39fcb55 commit a92d536

File tree

11 files changed

+285
-17
lines changed

11 files changed

+285
-17
lines changed

.changeset/strong-bananas-drum.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hashicorp/design-system-components": patch
3+
---
4+
5+
`CodeBlock` - Added arguments `@ariaLabel`, `@ariaLabelledBy`, and `@ariaDescribedBy`. Added screen-reader only copy for highlighted lines.
6+
7+
`hds-clipboard` - Prevent screen-reader only text (text with the `sr-only` class) from being copied to the clipboard.

packages/components/src/components/hds/code-block/description.hbs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
SPDX-License-Identifier: MPL-2.0
44
}}
55

6-
<Hds::Text::Body @tag="p" @size="100" class="hds-code-block__description" ...attributes>
6+
<Hds::Text::Body
7+
id={{this._id}}
8+
@tag="p"
9+
@size="100"
10+
class="hds-code-block__description"
11+
...attributes
12+
{{this._setUpDescription @didInsertNode}}
13+
>
714
{{yield}}
815
</Hds::Text::Body>

packages/components/src/components/hds/code-block/description.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,33 @@
33
* SPDX-License-Identifier: MPL-2.0
44
*/
55

6-
import templateOnlyComponent from '@ember/component/template-only';
6+
import Component from '@glimmer/component';
7+
import { guidFor } from '@ember/object/internals';
8+
import { modifier } from 'ember-modifier';
79
import type { HdsTextBodySignature } from '../text/body';
810

11+
type HdsCodeBlockDescriptionElement = HdsTextBodySignature['Element'];
912
export interface HdsCodeBlockDescriptionSignature {
13+
Args: {
14+
didInsertNode: (element: HdsCodeBlockDescriptionElement) => void;
15+
};
1016
Blocks: {
1117
default: [];
1218
};
13-
Element: HdsTextBodySignature['Element'];
19+
Element: HdsCodeBlockDescriptionElement;
1420
}
1521

16-
const HdsCodeBlockDescription =
17-
templateOnlyComponent<HdsCodeBlockDescriptionSignature>();
22+
export default class HdsCodeBlockDescription extends Component<HdsCodeBlockDescriptionSignature> {
23+
private _id = 'description-' + guidFor(this);
1824

19-
export default HdsCodeBlockDescription;
25+
private _setUpDescription = modifier(
26+
(
27+
element: HTMLElement,
28+
[insertCallbackFunction]: [(element: HTMLElement) => void]
29+
) => {
30+
if (typeof insertCallbackFunction === 'function') {
31+
insertCallbackFunction(element);
32+
}
33+
}
34+
);
35+
}

packages/components/src/components/hds/code-block/index.hbs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55

66
<div class={{this.classNames}} ...attributes {{this._setUpCodeObserver}}>
77
<div class="hds-code-block__header">
8-
{{~yield (hash Title=(component "hds/code-block/title"))~}}
9-
{{~yield (hash Description=(component "hds/code-block/description"))~}}
8+
{{~yield (hash Title=(component "hds/code-block/title" didInsertNode=this.registerTitleElement))~}}
9+
{{~yield
10+
(hash Description=(component "hds/code-block/description" didInsertNode=this.registerDescriptionElement))
11+
~}}
1012
</div>
1113
<div class="hds-code-block__body">
1214
{{! content within pre tag is whitespace-sensitive; do not add new lines! }}
@@ -16,6 +18,9 @@
1618
data-line={{@highlightLines}}
1719
data-start={{@lineNumberStart}}
1820
id={{this._preCodeId}}
21+
aria-label={{@ariaLabel}}
22+
aria-labelledby={{this.ariaLabelledBy}}
23+
aria-describedby={{this.ariaDescribedBy}}
1924
tabindex="0"
2025
><code {{this._setUpCodeBlockCode}}>
2126
{{~this._prismCode~}}

packages/components/src/components/hds/code-block/index.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export const LANGUAGES: string[] = Object.values(HdsCodeBlockLanguageValues);
4545

4646
export interface HdsCodeBlockSignature {
4747
Args: {
48+
ariaLabel?: string;
49+
ariaLabelledBy?: string;
50+
ariaDescribedBy?: string;
4851
hasCopyButton?: boolean;
4952
hasLineNumbers?: boolean;
5053
hasLineWrapping?: boolean;
@@ -73,6 +76,8 @@ export default class HdsCodeBlock extends Component<HdsCodeBlockSignature> {
7376
@tracked private _isExpanded: boolean = false;
7477
@tracked private _codeContentHeight: number = 0;
7578
@tracked private _codeContainerHeight: number = 0;
79+
@tracked private _titleId: string | undefined;
80+
@tracked private _descriptionId: string | undefined;
7681

7782
// Generates a unique ID for the code content
7883
private _preCodeId = 'pre-code-' + guidFor(this);
@@ -105,6 +110,18 @@ export default class HdsCodeBlock extends Component<HdsCodeBlockSignature> {
105110
return () => {};
106111
});
107112

113+
get ariaLabelledBy(): string | undefined {
114+
if (this.args.ariaLabel !== undefined) {
115+
return;
116+
}
117+
118+
return this.args.ariaLabelledBy ?? this._titleId;
119+
}
120+
121+
get ariaDescribedBy(): string | undefined {
122+
return this.args.ariaDescribedBy ?? this._descriptionId;
123+
}
124+
108125
// code text content for the CodeBlock
109126
get code(): string {
110127
const code = this.args.value;
@@ -158,6 +175,18 @@ export default class HdsCodeBlock extends Component<HdsCodeBlockSignature> {
158175
return this.args.copyButtonText ? this.args.copyButtonText : 'Copy';
159176
}
160177

178+
@action
179+
registerTitleElement(element: HdsCodeBlockTitleSignature['Element']): void {
180+
this._titleId = element.id;
181+
}
182+
183+
@action
184+
registerDescriptionElement(
185+
element: HdsCodeBlockDescriptionSignature['Element']
186+
): void {
187+
this._descriptionId = element.id;
188+
}
189+
161190
@action
162191
setPrismCode(element: HTMLElement): void {
163192
const code = this.code;
@@ -182,6 +211,12 @@ export default class HdsCodeBlock extends Component<HdsCodeBlockSignature> {
182211
element.removeChild(lineNumbers);
183212
}
184213

214+
if (this.args.highlightLines) {
215+
this._prismCode = this._addHighlightSrOnlyText(
216+
this._prismCode.toString()
217+
);
218+
}
219+
185220
// Force prism-line-numbers plugin initialization, required for Prism.highlight usage
186221
// See https://github.com/PrismJS/prism/issues/1234
187222
Prism.hooks.run('complete', {
@@ -226,6 +261,49 @@ export default class HdsCodeBlock extends Component<HdsCodeBlockSignature> {
226261
this._isExpanded = !this._isExpanded;
227262
}
228263

264+
// Logic for determining where line highlighting starts and ends taken from Prism.js plugin source code
265+
// Context: https://github.com/PrismJS/prism/blob/19f8de66b0f3a79aedbbf096081a4060fc0e80af/src/plugins/line-highlight/prism-line-highlight.ts#L82
266+
private _addHighlightSrOnlyText(code: string): SafeString {
267+
const NEW_LINE_EXP = /\n(?!$)/g;
268+
const lines = code.split(NEW_LINE_EXP);
269+
const numLines = lines.length;
270+
const lineOffset = this.args.lineNumberStart
271+
? this.args.lineNumberStart
272+
: 0;
273+
274+
const highlightStart = '<span class="sr-only">highlight start</span>';
275+
const highlightEnd = '<span class="sr-only">highlight end</span>';
276+
277+
const ranges = this.args.highlightLines
278+
?.replace(/\s+/g, '')
279+
.split(',')
280+
.filter(Boolean);
281+
282+
if (ranges && ranges.length > 0) {
283+
const highlightedLines = [] as { start: number; end: number }[];
284+
285+
ranges.forEach((currentRange) => {
286+
const range = currentRange.split('-');
287+
const start = +range[0]! - lineOffset;
288+
let end = +range[1]! || start - lineOffset;
289+
end = Math.min(numLines, end);
290+
highlightedLines.push({
291+
start: start,
292+
end: end,
293+
});
294+
});
295+
296+
highlightedLines.forEach((line) => {
297+
lines[line.start - 1] = highlightStart + lines[line.start - 1];
298+
lines[line.end - 1] = lines[line.end - 1] + highlightEnd;
299+
});
300+
301+
return htmlSafe(lines.join('\n'));
302+
} else {
303+
return htmlSafe(code);
304+
}
305+
}
306+
229307
get classNames(): string {
230308
// Currently there is only one theme so the class name is hard-coded.
231309
// In the future, additional themes such as a "light" theme could be added.

packages/components/src/components/hds/code-block/title.hbs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
SPDX-License-Identifier: MPL-2.0
44
}}
55

6-
<Hds::Text::Body @size="200" @tag={{this.componentTag}} @weight="semibold" class="hds-code-block__title" ...attributes>
6+
<Hds::Text::Body
7+
id={{this._id}}
8+
@size="200"
9+
@tag={{this.componentTag}}
10+
@weight="semibold"
11+
class="hds-code-block__title"
12+
...attributes
13+
{{this._setUpTitle @didInsertNode}}
14+
>
715
{{yield}}
816
</Hds::Text::Body>

packages/components/src/components/hds/code-block/title.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
* SPDX-License-Identifier: MPL-2.0
44
*/
55
import Component from '@glimmer/component';
6+
import { guidFor } from '@ember/object/internals';
7+
import { modifier } from 'ember-modifier';
68
import { HdsCodeBlockTitleTagValues } from './types.ts';
79
import type { HdsCodeBlockTitleTags } from './types';
810
import type { HdsTextBodySignature } from '../text/body';
911

12+
type HdsCodeBlockTitleElement = HdsTextBodySignature['Element'];
1013
export interface HdsCodeBlockTitleSignature {
1114
Args: {
1215
tag?: HdsCodeBlockTitleTags;
16+
didInsertNode: (element: HdsCodeBlockTitleElement) => void;
1317
};
1418
Blocks: {
1519
default: [];
@@ -18,6 +22,19 @@ export interface HdsCodeBlockTitleSignature {
1822
}
1923

2024
export default class HdsCodeBlockTitle extends Component<HdsCodeBlockTitleSignature> {
25+
private _id = 'title-' + guidFor(this);
26+
27+
private _setUpTitle = modifier(
28+
(
29+
element: HTMLElement,
30+
[insertCallbackFunction]: [(element: HTMLElement) => void]
31+
) => {
32+
if (typeof insertCallbackFunction === 'function') {
33+
insertCallbackFunction(element);
34+
}
35+
}
36+
);
37+
2138
get componentTag(): HdsCodeBlockTitleTags {
2239
return this.args.tag ?? HdsCodeBlockTitleTagValues.P;
2340
}

packages/components/src/modifiers/hds-clipboard.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,20 @@ export const getTextToCopyFromTargetElement = (
8787
) {
8888
textToCopy = targetElement.value;
8989
} else {
90+
// Hide any screen reader only text from the innerText calculation
91+
const srOnlyTexts = targetElement.querySelectorAll('.sr-only');
92+
srOnlyTexts.forEach((el: Element) => {
93+
el.setAttribute('style', 'display: none;');
94+
});
95+
9096
// simplest approach
9197
textToCopy = targetElement.innerText;
9298

99+
// Restore visibility of screen reader only text
100+
srOnlyTexts.forEach((el: Element) => {
101+
el.removeAttribute('style');
102+
});
103+
93104
// approach based on text selection (left for backup just in case)
94105
// var selection = window.getSelection();
95106
// var range = document.createRange();

0 commit comments

Comments
 (0)