Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/strong-bananas-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hashicorp/design-system-components": patch
---

`CodeBlock` - Added arguments `@ariaLabel`, `@ariaLabelledBy`, and `@ariaDescribedBy`. Added screen-reader only copy for highlighted lines.

`hds-clipboard` - Prevent screen-reader only text (text with the `sr-only` class) from being copied to the clipboard.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
SPDX-License-Identifier: MPL-2.0
}}

<Hds::Text::Body @tag="p" @size="100" class="hds-code-block__description" ...attributes>
<Hds::Text::Body
id={{this._id}}
@tag="p"
@size="100"
class="hds-code-block__description"
...attributes
{{this._setUpDescription @didInsertNode}}
>
{{yield}}
</Hds::Text::Body>
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,33 @@
* SPDX-License-Identifier: MPL-2.0
*/

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

type HdsCodeBlockDescriptionElement = HdsTextBodySignature['Element'];
export interface HdsCodeBlockDescriptionSignature {
Args: {
didInsertNode: (element: HdsCodeBlockDescriptionElement) => void;
};
Blocks: {
default: [];
};
Element: HdsTextBodySignature['Element'];
Element: HdsCodeBlockDescriptionElement;
}

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

export default HdsCodeBlockDescription;
private _setUpDescription = modifier(
(
element: HTMLElement,
[insertCallbackFunction]: [(element: HTMLElement) => void]
) => {
if (typeof insertCallbackFunction === 'function') {
insertCallbackFunction(element);
}
}
);
}
9 changes: 7 additions & 2 deletions packages/components/src/components/hds/code-block/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@

<div class={{this.classNames}} ...attributes {{this._setUpCodeObserver}}>
<div class="hds-code-block__header">
{{~yield (hash Title=(component "hds/code-block/title"))~}}
{{~yield (hash Description=(component "hds/code-block/description"))~}}
{{~yield (hash Title=(component "hds/code-block/title" didInsertNode=this.registerTitleElement))~}}
{{~yield
(hash Description=(component "hds/code-block/description" didInsertNode=this.registerDescriptionElement))
~}}
</div>
<div class="hds-code-block__body">
{{! content within pre tag is whitespace-sensitive; do not add new lines! }}
Expand All @@ -16,6 +18,9 @@
data-line={{@highlightLines}}
data-start={{@lineNumberStart}}
id={{this._preCodeId}}
aria-label={{@ariaLabel}}
aria-labelledby={{this.ariaLabelledBy}}
aria-describedby={{this.ariaDescribedBy}}
tabindex="0"
><code {{this._setUpCodeBlockCode}}>
{{~this._prismCode~}}
Expand Down
78 changes: 78 additions & 0 deletions packages/components/src/components/hds/code-block/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export const LANGUAGES: string[] = Object.values(HdsCodeBlockLanguageValues);

export interface HdsCodeBlockSignature {
Args: {
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
hasCopyButton?: boolean;
hasLineNumbers?: boolean;
hasLineWrapping?: boolean;
Expand Down Expand Up @@ -73,6 +76,8 @@ export default class HdsCodeBlock extends Component<HdsCodeBlockSignature> {
@tracked private _isExpanded: boolean = false;
@tracked private _codeContentHeight: number = 0;
@tracked private _codeContainerHeight: number = 0;
@tracked private _titleId: string | undefined;
@tracked private _descriptionId: string | undefined;

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

get ariaLabelledBy(): string | undefined {
if (this.args.ariaLabel !== undefined) {
return;
}

return this.args.ariaLabelledBy ?? this._titleId;
}

get ariaDescribedBy(): string | undefined {
return this.args.ariaDescribedBy ?? this._descriptionId;
}

// code text content for the CodeBlock
get code(): string {
const code = this.args.value;
Expand Down Expand Up @@ -158,6 +175,18 @@ export default class HdsCodeBlock extends Component<HdsCodeBlockSignature> {
return this.args.copyButtonText ? this.args.copyButtonText : 'Copy';
}

@action
registerTitleElement(element: HdsCodeBlockTitleSignature['Element']): void {
this._titleId = element.id;
}

@action
registerDescriptionElement(
element: HdsCodeBlockDescriptionSignature['Element']
): void {
this._descriptionId = element.id;
}

@action
setPrismCode(element: HTMLElement): void {
const code = this.code;
Expand All @@ -182,6 +211,12 @@ export default class HdsCodeBlock extends Component<HdsCodeBlockSignature> {
element.removeChild(lineNumbers);
}

if (this.args.highlightLines) {
this._prismCode = this._addHighlightSrOnlyText(
this._prismCode.toString()
);
}

// Force prism-line-numbers plugin initialization, required for Prism.highlight usage
// See https://github.com/PrismJS/prism/issues/1234
Prism.hooks.run('complete', {
Expand Down Expand Up @@ -226,6 +261,49 @@ export default class HdsCodeBlock extends Component<HdsCodeBlockSignature> {
this._isExpanded = !this._isExpanded;
}

// Logic for determining where line highlighting starts and ends taken from Prism.js plugin source code
// Context: https://github.com/PrismJS/prism/blob/19f8de66b0f3a79aedbbf096081a4060fc0e80af/src/plugins/line-highlight/prism-line-highlight.ts#L82
private _addHighlightSrOnlyText(code: string): SafeString {
const NEW_LINE_EXP = /\n(?!$)/g;
const lines = code.split(NEW_LINE_EXP);
const numLines = lines.length;
const lineOffset = this.args.lineNumberStart
? this.args.lineNumberStart
: 0;

const highlightStart = '<span class="sr-only">highlight start</span>';
const highlightEnd = '<span class="sr-only">highlight end</span>';

const ranges = this.args.highlightLines
?.replace(/\s+/g, '')
.split(',')
.filter(Boolean);

if (ranges && ranges.length > 0) {
const highlightedLines = [] as { start: number; end: number }[];

ranges.forEach((currentRange) => {
const range = currentRange.split('-');
const start = +range[0]! - lineOffset;
let end = +range[1]! || start - lineOffset;
end = Math.min(numLines, end);
highlightedLines.push({
start: start,
end: end,
});
});

highlightedLines.forEach((line) => {
lines[line.start - 1] = highlightStart + lines[line.start - 1];
lines[line.end - 1] = lines[line.end - 1] + highlightEnd;
});

return htmlSafe(lines.join('\n'));
} else {
return htmlSafe(code);
}
}

get classNames(): string {
// Currently there is only one theme so the class name is hard-coded.
// In the future, additional themes such as a "light" theme could be added.
Expand Down
10 changes: 9 additions & 1 deletion packages/components/src/components/hds/code-block/title.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
SPDX-License-Identifier: MPL-2.0
}}

<Hds::Text::Body @size="200" @tag={{this.componentTag}} @weight="semibold" class="hds-code-block__title" ...attributes>
<Hds::Text::Body
id={{this._id}}
@size="200"
@tag={{this.componentTag}}
@weight="semibold"
class="hds-code-block__title"
...attributes
{{this._setUpTitle @didInsertNode}}
>
{{yield}}
</Hds::Text::Body>
17 changes: 17 additions & 0 deletions packages/components/src/components/hds/code-block/title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { guidFor } from '@ember/object/internals';
import { modifier } from 'ember-modifier';
import { HdsCodeBlockTitleTagValues } from './types.ts';
import type { HdsCodeBlockTitleTags } from './types';
import type { HdsTextBodySignature } from '../text/body';

type HdsCodeBlockTitleElement = HdsTextBodySignature['Element'];
export interface HdsCodeBlockTitleSignature {
Args: {
tag?: HdsCodeBlockTitleTags;
didInsertNode: (element: HdsCodeBlockTitleElement) => void;
};
Blocks: {
default: [];
Expand All @@ -18,6 +22,19 @@ export interface HdsCodeBlockTitleSignature {
}

export default class HdsCodeBlockTitle extends Component<HdsCodeBlockTitleSignature> {
private _id = 'title-' + guidFor(this);

private _setUpTitle = modifier(
(
element: HTMLElement,
[insertCallbackFunction]: [(element: HTMLElement) => void]
) => {
if (typeof insertCallbackFunction === 'function') {
insertCallbackFunction(element);
}
}
);

get componentTag(): HdsCodeBlockTitleTags {
return this.args.tag ?? HdsCodeBlockTitleTagValues.P;
}
Expand Down
11 changes: 11 additions & 0 deletions packages/components/src/modifiers/hds-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,20 @@ export const getTextToCopyFromTargetElement = (
) {
textToCopy = targetElement.value;
} else {
// Hide any screen reader only text from the innerText calculation
const srOnlyTexts = targetElement.querySelectorAll('.sr-only');
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a case for this outside of the CodeBlock component?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There isn't right now, but it could be useful in general. I don't think there's a use case for having sr only text and wanting it copied, which is what would happen currently.

srOnlyTexts.forEach((el: Element) => {
el.setAttribute('style', 'display: none;');
});

// simplest approach
textToCopy = targetElement.innerText;

// Restore visibility of screen reader only text
srOnlyTexts.forEach((el: Element) => {
el.removeAttribute('style');
});

// approach based on text selection (left for backup just in case)
// var selection = window.getSelection();
// var range = document.createRange();
Expand Down
Loading