Skip to content
This repository has been archived by the owner on May 24, 2024. It is now read-only.

Commit

Permalink
[terra-embedded-content-consumer] - Show focus indicator on iframe of…
Browse files Browse the repository at this point in the history
… the embedded content when content can scroll, and has no interactable element (#1874)
  • Loading branch information
kolkheang authored Nov 30, 2023
1 parent cd2b1cd commit f3a5bff
Show file tree
Hide file tree
Showing 111 changed files with 520 additions and 148 deletions.
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,6 @@
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^4.11.1",
"webpack-merge": "^5.1.4",
"xfc": "^1.2.1"
"xfc": "^1.12.0"
}
}
1 change: 1 addition & 0 deletions packages/terra-embedded-content-consumer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

* Added
* Added screenreader support to announce context of embedded iframe content.
* Added visual focus indicator on the iframe when the content doesn't have any interactable element, iframe is scrollable, and content is scrollable for keyboard only users.

## 3.39.0 - (October 3, 2023)

Expand Down
2 changes: 1 addition & 1 deletion packages/terra-embedded-content-consumer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"react": "^16.8.5",
"react-dom": "^16.8.5",
"react-intl": ">=2.8.0, <6.0.0",
"xfc": "^1.2.1"
"xfc": "^1.12.0"
},
"dependencies": {
"classnames": "^2.2.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import React from 'react';
import { injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import classNames from 'classnames/bind';

import { Consumer } from 'xfc';
import VisuallyHiddenText from 'terra-visually-hidden-text';

import styles from './EmbeddedContentConsumer.module.scss';

const cx = classNames.bind(styles);
Expand Down Expand Up @@ -111,14 +109,28 @@ class EmbeddedContentConsumer extends React.Component {
Object.assign(frameOptions.iframeAttrs, { title: this.props.title });
}

// Pass className to XFC library to set the style when the iframe has focus
frameOptions.focusIndicator = {
classNameFocusStyle: cx('iframe-focus-style'),
};

// Mount the provided source as the application into the content wrapper.
this.xfcFrame = Consumer.mount(this.embeddedContentWrapper, this.props.src, frameOptions);

// Set additional style on xfcFrame
this.xfcFrame.iframe.classList.add(cx('iframe-style'));

// Notify that the consumer frame has mounted.
if (this.props.onMount) {
this.props.onMount(this.xfcFrame);
}

// Handle visual focus indicator for iframes with inline HTML using `srcdoc` attribute.
// For iframes using `src` and a URL, it's already being handled in XFC library.
if (frameOptions.iframeAttrs.srcdoc) {
this.handleFrameVisualFocusIndicator();
}

// Attach the event handlers to the xfc frame.
this.addEventListener('xfc.launched', this.props.onLaunch);
this.addEventListener('xfc.authorized', this.props.onAuthorize);
Expand All @@ -127,6 +139,92 @@ class EmbeddedContentConsumer extends React.Component {
this.addEventListeners(this.props.eventHandlers);
}

/**
* Handles listening for events, and display visual focus indicator on the
* iframe, specifically for iframe using `srcdoc` attribute to display
* inline HTML content.
*
* This is needed here since the XFC library does not fully support
* `srcdoc`, and postMessage communication for inline HTML content inside of the frame.
*
* The XFC library already handles all scenario with `src` attribute.
*/
handleFrameVisualFocusIndicator() {
// reference to the xfc iframe's contentWindow
this.contentWindow = this.xfcFrame?.iframe?.contentWindow;

/**
* Check if the iframe has `scrolling` attribute set or not
* The default `scrolling` attribute is `auto`.
*
* Then check if the content is scrollable
* `documentElement` is the <html> element of the document
* `body` is the <body> element of the document
* if `scrollHeight` > `clientHeight` or `scrollWidth` > `clientWidth`
* then there is scrolling, and the content won't fit, and will need to scroll.
*/
const isContentScrollable = () => {
const frameDocument = this.contentWindow?.document;
return (this.xfcFrame?.iframe?.getAttribute('scrolling') !== 'no')
&& (frameDocument.documentElement.scrollHeight > frameDocument.documentElement.clientHeight
|| frameDocument.body.scrollHeight > frameDocument.body.clientHeight
|| frameDocument.documentElement.scrollWidth > frameDocument.documentElement.clientWidth
|| frameDocument.body.scrollWidth > frameDocument.body.clientWidth);
};

// Event listener and callback function for when `load` is completed for the content in the iframe
this.contentWindow?.addEventListener('load', () => {
// Selectors for interactable elements
const interactableElementSelector = 'a[href]:not([tabindex=\'-1\']), area[href]:not([tabindex=\'-1\']), input:not([disabled]):not([tabindex=\'-1\']), '
+ "select:not([disabled]):not([tabindex='-1']), textarea:not([disabled]):not([tabindex='-1']), button:not([disabled]):not([tabindex='-1']), "
+ "[contentEditable=true]:not([tabindex='-1'])";

this.hasInteractableElement = [...this.contentWindow.document.body.querySelectorAll(`${interactableElementSelector}`)].some(
(element) => !element.hasAttribute('disabled')
&& !element.getAttribute('aria-hidden')
&& !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)
&& window.getComputedStyle(element).visibility !== 'hidden'
&& element.closest('[inert]') === null,
);

// Initialize and save the original tabIndex value
this.originalTabIndexValue = this.contentWindow.document.body.getAttribute('tabIndex');

if (isContentScrollable() && !this.hasInteractableElement) {
// Set tabIndex="0" so focus can go into the document when
// using tab key when scrolling is enabled
this.contentWindow.document.body.tabIndex = 0;
}
});

// Event listener and callback function for `resize` event of the iframe
this.contentWindow?.addEventListener('resize', () => {
if (isContentScrollable() && !this.hasInteractableElement) {
// Set tabIndex="0" so focus can go into the document when
// using tab key when scrolling is enabled
this.contentWindow.document.body.tabIndex = 0;
} else if (this.originalTabIndexValue === null) {
this.contentWindow.document.body.removeAttribute('tabIndex');
} else {
this.contentWindow.document.body.tabIndex = this.originalTabIndexValue;
}
});

// Event listener and callback function for `focus` event is in the iframe
this.contentWindow?.addEventListener('focus', () => {
if (this.hasInteractableElement) {
return;
}

this.xfcFrame.iframe.classList.add(cx('iframe-focus-style'));
}, true);

// Event Listener and callback function for `blur` event in the iframe
this.contentWindow?.addEventListener('blur', () => {
this.xfcFrame.iframe.classList.remove(cx('iframe-focus-style'));
}, true);
}

addEventListener(eventName, eventHandler) {
if (eventName && eventHandler) {
this.xfcFrame.on(eventName, eventHandler);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
// Themes
@import './clinical-lowlight-theme/EmbeddedContentConsumer.module';
@import './orion-fusion-theme/EmbeddedContentConsumer.module';

:local {
.iframe-style {
margin-bottom: 4px;
margin-top: 4px;
}

.iframe-focus-style {
outline: var(--terra-embedded-content-consumer-focus-outline, 2px dashed #000);
outline-offset: 1px;
}

.visually-hidden-text {
height: 0;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';

import '../../../initalizeXFC';
import './ProviderIframe.module.scss';
import './ProviderTestTemplate.module.scss';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:local {
.clinical-lowlight-theme {
--terra-embedded-content-consumer-focus-outline: 2px dashed #b2b5b6;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:local {
.orion-fusion-theme {
--terra-embedded-content-consumer-focus-outline: 2px solid #3496cf;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { Consumer } from 'xfc';
/* eslint-disable-next-line import/no-extraneous-dependencies */
import { mountWithIntl } from 'terra-enzyme-intl';
import { mountWithIntl, shallowWithIntl } from 'terra-enzyme-intl';
import EmbeddedContentConsumer from '../../lib/EmbeddedContentConsumer';

beforeAll(() => {
Expand Down Expand Up @@ -42,6 +42,7 @@ describe(EmbeddedContentConsumer, () => {
const inlineHtml = '<p><b>Inline HTML Content</b></p><p>This is an inline html content that can be used to render the content into the frame.</p>';
const frameOptions = {
iframeAttrs: {
id: 'iframe-srcdoc-id',
srcdoc: inlineHtml,
width: '100%',
height: '100px',
Expand Down Expand Up @@ -187,32 +188,34 @@ describe(EmbeddedContentConsumer, () => {
expect(wrapper.find('VisuallyHiddenText').at(1).prop('text')).toEqual('Terra.embeddedContentConsumer.endEmbeddedContent');
expect(wrapper).toMatchSnapshot();
});
});

it('sets appropriate config option when resizeConfig.scrolling is true', () => {
const embeddedContentConsumer = (
<div>
<EmbeddedContentConsumer
src="https://www.google.com/"
options={{ resizeConfig: { scrolling: true } }}
/>
</div>
);

const wrapper = shallow(embeddedContentConsumer);
expect(wrapper).toMatchSnapshot();
});

it('sets appropriate config option when resizeConfig.scrolling is false', () => {
const embeddedContentConsumer = (
<div>
<EmbeddedContentConsumer
src="https://www.google.com/"
options={{ resizeConfig: { scrolling: false } }}
/>
</div>
);

const wrapper = shallow(embeddedContentConsumer);
expect(wrapper).toMatchSnapshot();
describe('scrolling', () => {
it('sets appropriate config option when resizeConfig.scrolling is true', () => {
const embeddedContentConsumer = (
<div>
<EmbeddedContentConsumer
src="https://www.google.com/"
options={{ resizeConfig: { scrolling: true } }}
/>
</div>
);

const wrapper = shallowWithIntl(embeddedContentConsumer);
expect(wrapper).toMatchSnapshot();
});

it('sets appropriate config option when resizeConfig.scrolling is false', () => {
const embeddedContentConsumer = (
<div>
<EmbeddedContentConsumer
src="https://www.google.com/"
options={{ resizeConfig: { scrolling: false } }}
/>
</div>
);

const wrapper = shallowWithIntl(embeddedContentConsumer);
expect(wrapper).toMatchSnapshot();
});
});
});
Loading

0 comments on commit f3a5bff

Please sign in to comment.