Commit 9aa02026 authored by Robert Knight's avatar Robert Knight

Move `useElementShouldClose` and `normalizeKeyName` into frontend-shared package

`useElementShouldClose` is a useful generic hook for implementing non-modal dialogs. We
also use it in the LMS frontend, so it makes sense to share the implementation.

This function depends on `normalizeKeyName`, so I moved that as well.

`useElementShouldClose` also depended on a `listen` helper. This helper
is not currently used by any other code so I moved it into
`use-element-should-close.js` as a non-exported helper function and
simplified it.

In the process I found it was necessary to change the gulp task that
builds the frontend-shared/lib/ directory to handle subdirectories under
frontend-shared/src/.
parent 17ef1942
...@@ -3,7 +3,7 @@ import { createElement } from 'preact'; ...@@ -3,7 +3,7 @@ import { createElement } from 'preact';
import { useRef } from 'preact/hooks'; import { useRef } from 'preact/hooks';
import { act } from 'preact/test-utils'; import { act } from 'preact/test-utils';
import useElementShouldClose from '../use-element-should-close'; import { useElementShouldClose } from '../use-element-should-close';
describe('useElementShouldClose', () => { describe('useElementShouldClose', () => {
let handleClose; let handleClose;
......
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { normalizeKeyName } from '../../../shared/browser-compatibility-utils'; import { normalizeKeyName } from '../browser-compatibility-utils';
import { listen } from '../../util/dom';
/**
* Attach listeners for one or multiple events to an element and return a
* function that removes the listeners.
*
* @param {HTMLElement} element
* @param {string[]} events
* @param {EventListener} listener
* @param {Object} options
* @param {boolean} [options.useCapture]
* @return {() => void} Function which removes the event listeners.
*/
function listen(element, events, listener, { useCapture = false } = {}) {
events.forEach(event =>
element.addEventListener(event, listener, useCapture)
);
return () => {
events.forEach(event =>
element.removeEventListener(event, listener, useCapture)
);
};
}
/** /**
* @template T * @template T
...@@ -22,11 +43,7 @@ import { listen } from '../../util/dom'; ...@@ -22,11 +43,7 @@ import { listen } from '../../util/dom';
* @param {boolean} isOpen - Whether the popup is currently visible/open * @param {boolean} isOpen - Whether the popup is currently visible/open
* @param {() => void} handleClose - Callback invoked to close the popup * @param {() => void} handleClose - Callback invoked to close the popup
*/ */
export default function useElementShouldClose( export function useElementShouldClose(closeableEl, isOpen, handleClose) {
closeableEl,
isOpen,
handleClose
) {
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return () => {}; return () => {};
...@@ -44,7 +61,7 @@ export default function useElementShouldClose( ...@@ -44,7 +61,7 @@ export default function useElementShouldClose(
// (key press, programmatic focus change). // (key press, programmatic focus change).
const removeFocusListener = listen( const removeFocusListener = listen(
document.body, document.body,
'focus', ['focus'],
event => { event => {
if (!closeableEl.current.contains(/** @type {Node} */ (event.target))) { if (!closeableEl.current.contains(/** @type {Node} */ (event.target))) {
handleClose(); handleClose();
......
module.exports = { // Components
...require('./svg-icon'), export { SvgIcon, registerIcons } from './svg-icon';
};
// Hooks
export { useElementShouldClose } from './hooks/use-element-should-close';
// Utilities
export { normalizeKeyName } from './browser-compatibility-utils';
...@@ -15,7 +15,7 @@ const buildFrontendSharedJs = () => { ...@@ -15,7 +15,7 @@ const buildFrontendSharedJs = () => {
return ( return (
gulp gulp
.src('frontend-shared/src/*') .src('frontend-shared/src/**/*.js')
// Transpile the js source files and write the output in the frontend-shared/lib dir. // Transpile the js source files and write the output in the frontend-shared/lib dir.
// Additionally, add the sourcemaps into the same dir. // Additionally, add the sourcemaps into the same dir.
.pipe(sourcemaps.init()) .pipe(sourcemaps.init())
......
import { useEffect } from 'preact/hooks'; import { normalizeKeyName } from '@hypothesis/frontend-shared';
import { normalizeKeyName } from './browser-compatibility-utils.js'; import { useEffect } from 'preact/hooks';
// Bit flags indicating modifiers required by a shortcut or pressed in a key event. // Bit flags indicating modifiers required by a shortcut or pressed in a key event.
const modifiers = { const modifiers = {
......
import { normalizeKeyName } from '@hypothesis/frontend-shared';
import { createElement } from 'preact'; import { createElement } from 'preact';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
import { normalizeKeyName } from '../../shared/browser-compatibility-utils';
import { withServices } from '../service-context'; import { withServices } from '../service-context';
import { applyTheme } from '../helpers/theme'; import { applyTheme } from '../helpers/theme';
import { useStoreProxy } from '../store/use-store'; import { useStoreProxy } from '../store/use-store';
......
import { SvgIcon } from '@hypothesis/frontend-shared'; import { SvgIcon, useElementShouldClose } from '@hypothesis/frontend-shared';
import { createElement } from 'preact'; import { createElement } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
...@@ -11,7 +11,6 @@ import { isIOS } from '../../shared/user-agent'; ...@@ -11,7 +11,6 @@ import { isIOS } from '../../shared/user-agent';
import Button from './Button'; import Button from './Button';
import ShareLinks from './ShareLinks'; import ShareLinks from './ShareLinks';
import useElementShouldClose from './hooks/use-element-should-close';
/** /**
* @typedef {import('../../types/api').Annotation} Annotation * @typedef {import('../../types/api').Annotation} Annotation
......
import classnames from 'classnames'; import classnames from 'classnames';
import { SvgIcon } from '@hypothesis/frontend-shared'; import { SvgIcon, normalizeKeyName } from '@hypothesis/frontend-shared';
import { createElement, createRef } from 'preact'; import { createElement, createRef } from 'preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
...@@ -10,7 +10,6 @@ import { ...@@ -10,7 +10,6 @@ import {
toggleBlockStyle, toggleBlockStyle,
toggleSpanStyle, toggleSpanStyle,
} from '../markdown-commands'; } from '../markdown-commands';
import { normalizeKeyName } from '../../shared/browser-compatibility-utils';
import { isMacOS } from '../../shared/user-agent'; import { isMacOS } from '../../shared/user-agent';
import MarkdownView from './MarkdownView'; import MarkdownView from './MarkdownView';
......
import classnames from 'classnames'; import classnames from 'classnames';
import { SvgIcon } from '@hypothesis/frontend-shared'; import {
SvgIcon,
normalizeKeyName,
useElementShouldClose,
} from '@hypothesis/frontend-shared';
import { Fragment, createElement } from 'preact'; import { Fragment, createElement } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
import useElementShouldClose from './hooks/use-element-should-close';
import { normalizeKeyName } from '../../shared/browser-compatibility-utils';
import MenuKeyboardNavigation from './MenuKeyboardNavigation'; import MenuKeyboardNavigation from './MenuKeyboardNavigation';
// The triangular indicator below the menu toggle button that visually links it // The triangular indicator below the menu toggle button that visually links it
......
import classnames from 'classnames'; import classnames from 'classnames';
import { SvgIcon } from '@hypothesis/frontend-shared'; import { SvgIcon, normalizeKeyName } from '@hypothesis/frontend-shared';
import { Fragment, createElement } from 'preact'; import { Fragment, createElement } from 'preact';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
import { normalizeKeyName } from '../../shared/browser-compatibility-utils';
import MenuKeyboardNavigation from './MenuKeyboardNavigation'; import MenuKeyboardNavigation from './MenuKeyboardNavigation';
import Slider from './Slider'; import Slider from './Slider';
......
import { normalizeKeyName } from '@hypothesis/frontend-shared';
import { createElement } from 'preact'; import { createElement } from 'preact';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
import { normalizeKeyName } from '../../shared/browser-compatibility-utils';
function isElementVisible(element) { function isElementVisible(element) {
return element.offsetParent !== null; return element.offsetParent !== null;
} }
......
import { SvgIcon } from '@hypothesis/frontend-shared'; import {
SvgIcon,
normalizeKeyName,
useElementShouldClose,
} from '@hypothesis/frontend-shared';
import { createElement } from 'preact'; import { createElement } from 'preact';
import { useRef, useState } from 'preact/hooks'; import { useRef, useState } from 'preact/hooks';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
...@@ -6,8 +10,6 @@ import propTypes from 'prop-types'; ...@@ -6,8 +10,6 @@ import propTypes from 'prop-types';
import { withServices } from '../service-context'; import { withServices } from '../service-context';
import AutocompleteList from './AutocompleteList'; import AutocompleteList from './AutocompleteList';
import { normalizeKeyName } from '../../shared/browser-compatibility-utils';
import useElementShouldClose from './hooks/use-element-should-close';
/** @typedef {import("preact").JSX.Element} JSXElement */ /** @typedef {import("preact").JSX.Element} JSXElement */
......
...@@ -89,10 +89,12 @@ describe('AnnotationShareControl', () => { ...@@ -89,10 +89,12 @@ describe('AnnotationShareControl', () => {
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
$imports.$mock({ $imports.$mock({
'@hypothesis/frontend-shared': {
useElementShouldClose: sinon.stub(),
},
'../helpers/annotation-sharing': { isShareableURI: fakeIsShareableURI }, '../helpers/annotation-sharing': { isShareableURI: fakeIsShareableURI },
'../util/copy-to-clipboard': fakeCopyToClipboard, '../util/copy-to-clipboard': fakeCopyToClipboard,
'../helpers/permissions': { isPrivate: fakeIsPrivate }, '../helpers/permissions': { isPrivate: fakeIsPrivate },
'./hooks/use-element-should-close': sinon.stub(),
'../../shared/user-agent': { isIOS: fakeIsIOS }, '../../shared/user-agent': { isIOS: fakeIsIOS },
}); });
}); });
......
...@@ -22,28 +22,3 @@ export function getElementHeightWithMargins(element) { ...@@ -22,28 +22,3 @@ export function getElementHeightWithMargins(element) {
return elementHeight + marginHeight; return elementHeight + marginHeight;
} }
/**
* Attach listeners for one or multiple events to an element and return a
* function that removes the listeners.
*
* @param {HTMLElement} element
* @param {string[]|string} events
* @param {EventListener} listener
* @param {Object} options
* @param {boolean} [options.useCapture]
* @return {function} Function which removes the event listeners.
*/
export function listen(element, events, listener, { useCapture = false } = {}) {
if (!Array.isArray(events)) {
events = [events];
}
events.forEach(event =>
element.addEventListener(event, listener, useCapture)
);
return () => {
/** @type {string[]} */ (events).forEach(event =>
element.removeEventListener(event, listener, useCapture)
);
};
}
import { listen, getElementHeightWithMargins } from '../dom'; import { getElementHeightWithMargins } from '../dom';
describe('sidebar/util/dom', () => { describe('sidebar/util/dom', () => {
describe('getElementHeightWithMargins', () => { describe('getElementHeightWithMargins', () => {
...@@ -23,60 +23,4 @@ describe('sidebar/util/dom', () => { ...@@ -23,60 +23,4 @@ describe('sidebar/util/dom', () => {
assert.equal(getElementHeightWithMargins(testElement), 470); assert.equal(getElementHeightWithMargins(testElement), 470);
}); });
}); });
describe('listen', () => {
const createFakeElement = () => ({
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
});
[true, false].forEach(useCapture => {
it('adds listeners for specified events', () => {
const element = createFakeElement();
const handler = sinon.stub();
listen(element, ['click', 'mousedown'], handler, { useCapture });
assert.calledWith(
element.addEventListener,
'click',
handler,
useCapture
);
assert.calledWith(
element.addEventListener,
'mousedown',
handler,
useCapture
);
});
});
[true, false].forEach(useCapture => {
it('removes listeners when returned function is invoked', () => {
const element = createFakeElement();
const handler = sinon.stub();
const removeListeners = listen(
element,
['click', 'mousedown'],
handler,
{ useCapture }
);
removeListeners();
assert.calledWith(
element.removeEventListener,
'click',
handler,
useCapture
);
assert.calledWith(
element.removeEventListener,
'mousedown',
handler,
useCapture
);
});
});
});
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment