From c637e13937def3e741dcd2bfecf29e320a004063 Mon Sep 17 00:00:00 2001 From: lovelindhoni Date: Tue, 19 Mar 2024 10:09:42 +0530 Subject: [PATCH] feat: add support for keyboard shortcuts - Implemented most of the keyboard shortcuts specified in #26 - Moved shortcuts prop and related methods to client.jsx - Made sure to not to overlap with the webaim keyboard shortcuts for web accessibility --- public/css/docs/layout.css | 18 ++++ public/css/docs/typography.css | 12 ++- src/app.jsx | 85 +++++----------- src/client.jsx | 97 ++++++++++++++++++- src/components/help/index.jsx | 95 +++++++++++++++++- src/components/readme/index.jsx | 5 +- src/components/readme/next.jsx | 28 +++++- src/components/readme/pagination.jsx | 8 +- src/components/readme/prev.jsx | 30 +++++- src/components/search/index.jsx | 30 +++++- src/components/top-nav/docs_help.jsx | 58 +++++++++++ src/components/top-nav/index.jsx | 63 +++++++++++- .../top-nav/pkg-menu/benchmarks.jsx | 27 +++++- src/components/top-nav/pkg-menu/docs.jsx | 30 +++++- src/components/top-nav/pkg-menu/index.jsx | 12 ++- src/components/top-nav/pkg-menu/source.jsx | 24 ++++- src/components/top-nav/pkg-menu/tests.jsx | 26 ++++- .../top-nav/pkg-menu/typescript.jsx | 29 +++++- src/components/top-nav/search_input.jsx | 45 ++++++++- src/components/top-nav/settings/head.jsx | 25 ++++- src/components/top-nav/settings/index.jsx | 4 +- src/components/top-nav/side-menu/drawer.jsx | 5 +- src/components/top-nav/side-menu/filter.jsx | 42 +++++++- src/components/top-nav/side-menu/index.jsx | 39 +++++++- 24 files changed, 730 insertions(+), 107 deletions(-) create mode 100644 src/components/top-nav/docs_help.jsx diff --git a/public/css/docs/layout.css b/public/css/docs/layout.css index f63dc18e7..ef720ff72 100644 --- a/public/css/docs/layout.css +++ b/public/css/docs/layout.css @@ -1143,10 +1143,28 @@ h2 { display: flex; } +.readme.help h2 { + border-bottom: 0px; +} + .readme.help h1 span { flex-grow: 1; } +.readme.help .keyboard-shortcut { + display: flex; + margin-bottom: 2em; + flex-direction: row; + align-items: baseline; + justify-content: space-between; + border-bottom: 2px solid var(--heading-border-bottom-color); + +} + +.readme.help .keyboard-shortcut-section { + margin-bottom: 5em; +} + /* * Error decoder. */ diff --git a/public/css/docs/typography.css b/public/css/docs/typography.css index 02e401b69..0cef98c33 100644 --- a/public/css/docs/typography.css +++ b/public/css/docs/typography.css @@ -184,14 +184,12 @@ a:active { */ .readme kbd { - font-size: 0.6875em; + font-size: 1em; font-family: var(--code-font-family); color: #555555; /* charcoal */ line-height: 1em; - - vertical-align: middle; } /* @@ -338,6 +336,14 @@ a:active { color: var(--feedback-error-text-color); } +/* +* Help page. +*/ + +.help h1 button.icon-button .icon { + fill: var(--theme-text-color); +} + /* * Search results. */ diff --git a/src/app.jsx b/src/app.jsx index afae59eaf..58198f012 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -47,7 +47,7 @@ import routes from './routes.js'; // VARIABLES // var RE_INTERNAL_URL = new RegExp( '^'+config.mount ); -var RE_SEARCH_URL = /\/search\/?/; +var RE_SEARCH_OR_HELP_URL = /(\/search|\/help)\/?/; var RE_FORWARD_SLASH = /\//g; var RE_PLOT_PKG = /\/plot/; @@ -161,14 +161,11 @@ class App extends React.Component { // Boolean indicating whether to show the side menu: 'sideMenu': false, - // Boolean indicating whether keyboard shortcuts are active: - 'shortcuts': true, - // Boolean indicating whether a notification is currently displayed: 'notification': props.location.search.indexOf( 'notification' ) >= 0 }; - // Previous (non-search) location (e.g., used for navigating to previous page after closing search results): + // Previous (non-search/help) location (e.g., used for navigating to previous page after closing search results): this._prevLocation = config.mount; // default is API docs landing page // Create a `ref` to point to a DOM element for resetting focus on page change: @@ -278,9 +275,9 @@ class App extends React.Component { if ( this.state.query === '' ) { return; } - // If we are coming from a non-search page, cache the current location... + // If we are coming from a non-search/help page, cache the current location... path = this.props.location.pathname; - if ( RE_SEARCH_URL.test( path ) === false ) { + if ( RE_SEARCH_OR_HELP_URL.test( path ) === false ) { this._prevLocation = path; } // Resolve a search URL based on the search query: @@ -291,65 +288,30 @@ class App extends React.Component { } /** - * Callback invoked upon closing search results. - * - * @private - */ - _onSearchClose = () => { - // Manually update the history to trigger navigation to a previous (non-search) page: - this.props.history.push( this._prevLocation ); - - // Update the component state: - this.setState({ - 'query': '' // reset the search input element - }); - } - - /** - * Callback invoked when the search input element receives focus. + * Callback invoked upon submitting a search query. * * @private + * @returns {void} */ - _onSearchFocus = () => { - // Whenever the search input element receives focus, we want to disable keyboard shortcuts: - this.setState({ - 'shortcuts': false - }); - } + _onHelpOpen = () => { + var path = config.mount + this.props.version + '/help'; - /** - * Callback invoked when the search input element loses focus. - * - * @private - */ - _onSearchBlur = () => { - // Whenever the search input element loses focus, we can enable keyboard shortcuts: - this.setState({ - 'shortcuts': true - }); + // Manually update the history to trigger navigation to the help page: + this.props.history.push( path ); } /** - * Callback invoked when the side menu filter receives focus. + * Callback invoked upon closing search results. * * @private */ - _onFilterFocus = () => { - // Whenever the side menu filter receives focus, we want to disable keyboard shortcuts: - this.setState({ - 'shortcuts': false - }); - } + _onSearchClose = () => { + // Manually update the history to trigger navigation to a previous (non-search/help) page: + this.props.history.push( this._prevLocation ); - /** - * Callback invoked when the side menu filter loses focus. - * - * @private - */ - _onFilterBlur = () => { - // Whenever the side menu filter loses focus, we can enable keyboard shortcuts: + // Update the component state: this.setState({ - 'shortcuts': true + 'query': '' // reset the search input element }); } @@ -464,14 +426,16 @@ class App extends React.Component { onSearchChange={ this._onSearchChange } onSearchSubmit={ this._onSearchSubmit } - onSearchFocus={ this._onSearchFocus } - onSearchBlur={ this._onSearchBlur } + onSearchFocus={ this.props.onSearchFocus } + onSearchBlur={ this.props.onSearchBlur } - onFilterFocus={ this._onFilterFocus } - onFilterBlur={ this._onFilterBlur } + onFilterFocus={ this.props.onFilterFocus } + onFilterBlur={ this.props.onFilterBlur } onVersionChange={ this.props.onVersionChange } + onHelpOpen={ this._onHelpOpen } + onAllowSettingsCookiesChange={ this.props.onAllowSettingsCookiesChange } onThemeChange={ this.props.onThemeChange } onModeChange={ this.props.onModeChange } @@ -479,7 +443,7 @@ class App extends React.Component { onPrevNextNavChange={ this.props.onPrevNextNavChange } sideMenu={ this.state.sideMenu } - + shortcuts = { this.props.shortcuts } allowSettingsCookies={ this.props.allowSettingsCookies } theme={ this.props.theme } mode={ this.props.mode } @@ -565,6 +529,7 @@ class App extends React.Component { prev={ prev } next={ next } url={ match.url } + shortcuts={ this.props.shortcuts } content={ this.props.content } onClick={ this._onReadmeClick } /> @@ -748,6 +713,7 @@ class App extends React.Component { version={ match.params.version } query={ query } onClose={ this._onSearchClose } + shortcuts={ this.props.shortcuts } /> ); @@ -771,6 +737,7 @@ class App extends React.Component { /> ); diff --git a/src/client.jsx b/src/client.jsx index 5601d4db6..a7cc6b28b 100644 --- a/src/client.jsx +++ b/src/client.jsx @@ -40,6 +40,12 @@ var COOKIES = [ 'prevnextnavigation' ]; +var THEMES = [ + 'light', + 'dark' + // more to come... hopefully... +] + // MAIN // /** @@ -131,7 +137,10 @@ class ClientApp extends React.Component { 'exampleSyntax': cookies.examplesyntax || config.exampleSyntax, // Previous/next package navigation: - 'prevNextNavigation': cookies.prevnextnavigation || config.prevNextNavigation + 'prevNextNavigation': cookies.prevnextnavigation || config.prevNextNavigation, + + // Boolean indicating whether keyboard shortcuts are active: + 'shortcuts': true }; } @@ -269,6 +278,75 @@ class ClientApp extends React.Component { }); } + /** + * Callback invoked when the search input element receives focus. + * + * @private + */ + _onSearchFocus = () => { + // Whenever the search input element receives focus, we want to disable keyboard shortcuts: + this.setState({ + 'shortcuts': false + }); + } + + /** + * Callback invoked when the search input element loses focus. + * + * @private + */ + _onSearchBlur = () => { + // Whenever the search input element loses focus, we can enable keyboard shortcuts: + this.setState({ + 'shortcuts': true + }); + } + + /** + * Callback invoked when the side menu filter receives focus. + * + * @private + */ + _onFilterFocus = () => { + // Whenever the side menu filter receives focus, we want to disable keyboard shortcuts: + this.setState({ + 'shortcuts': false + }); + } + + /** + * Callback invoked when the side menu filter loses focus. + * + * @private + */ + _onFilterBlur = () => { + // Whenever the side menu filter loses focus, we can enable keyboard shortcuts: + this.setState({ + 'shortcuts': true + }); + } + + /** + * Callback invoked upon a user press down a key to cycle through available themes + * + * @private + * @param {Object} event - event object + * @returns {void} + */ + _changeTheme = ( event ) => { + if ( event.shiftKey && event.key === "A" && this.state.shortcuts ) { + var changedTheme; + var currentThemeIndex = THEMES.indexOf( this.state.theme ) + if( currentThemeIndex === THEMES.length - 1 ){ + changedTheme = THEMES[0] + } + else{ + changedTheme = THEMES[currentThemeIndex + 1] + } + this._onThemeChange( changedTheme ); + } + }; + /** * Callback invoked immediately after mounting a component (i.e., is inserted into a tree). * @@ -304,6 +382,17 @@ class ClientApp extends React.Component { 'data': data }); } + document.addEventListener( "keyup", this._changeTheme ); + } + + /** + * Callback invoked immediately after unmounting a component (i.e., is removed from a tree). + * + * @private + */ + componentWillUnmount() { + // Clean up event listener + document.removeEventListener( "keyup", this._changeTheme ); } /** @@ -337,6 +426,12 @@ class ClientApp extends React.Component { onModeChange={ this._onModeChange } onExampleSyntaxChange={ this._onExampleSyntaxChange } onPrevNextNavChange={ this._onPrevNextNavChange } + + shortcuts={ this.state.shortcuts } + onSearchFocus={ this._onSearchFocus } + onSearchBlur={ this._onSearchBlur } + onFilterFocus={ this._onFilterFocus } + onFilterBlur={ this._onFilterBlur } /> diff --git a/src/components/help/index.jsx b/src/components/help/index.jsx index 876bcd0c8..6d686f37e 100644 --- a/src/components/help/index.jsx +++ b/src/components/help/index.jsx @@ -18,7 +18,7 @@ // MODULES // -import React, { Fragment } from 'react'; +import React, { Fragment, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import ClearIcon from './../icons/close.jsx'; @@ -31,9 +31,30 @@ import ClearIcon from './../icons/close.jsx'; * @private * @param {Object} props - component properties * @param {Callback} props.onClose - callback to invoke upon closing documentation help +* @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactElement} React element */ function Help( props ) { + + const closeHelpPage = useCallback(( event ) => { + // Close help page only after the settings menu gets closed + var isSettingsClosed = document.querySelector( 'div.settings-menu-overlay.invisible' ) !== null; + + if ( event.key === "Escape" && props.shortcuts && isSettingsClosed ) { + props.onClose(); + } + }, [props]); + + useEffect(() => { + // Add event listener when the component mounts + document.addEventListener( "keydown", closeHelpPage ); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener( "keydown", closeHelpPage ); + }; + }, [closeHelpPage]); + return (
@@ -49,7 +70,74 @@ function Help( props ) {
-

TODO

+

Keyboard Shortcuts

+
+

Globals

+
+
+

Focus search input

+ / +
+
+

Display help page

+ ? +
+
+

Toggle theme

+ shift + a +
+
+

Close search results/help/settings menu

+ esc +
+
+
+
+

Side Menu

+
+
+

Toggle side menu

+ m +
+
+

Focus side menu filter

+ shift + f +
+
+

Navigate to previous package

+ ctrl + +
+
+

Navigate to next package

+ ctrl + +
+
+
+
+

Package Menu

+
+
+

Open benchmarks page

+ b +
+
+

Open tests page

+ t +
+
+

Navigate to Typescript Docs

+ shift + t +
+
+

Navigate to source code

+ s +
+
+

Navigate to package doc page

+ p +
+
+
@@ -65,7 +153,8 @@ function Help( props ) { * @type {Object} */ Help.propTypes = { - 'onClose': PropTypes.func.isRequired + 'onClose': PropTypes.func.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/readme/index.jsx b/src/components/readme/index.jsx index 83a3775ba..c11287125 100644 --- a/src/components/readme/index.jsx +++ b/src/components/readme/index.jsx @@ -51,6 +51,7 @@ class Readme extends React.Component { * @param {string} [props.content] - initial content * @param {string} [props.prev] - previous package name * @param {string} [props.next] - next package name + * @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @param {Callback} props.onClick - callback to invoke upon clicking on README content * @returns {ReactComponent} React component */ @@ -169,6 +170,7 @@ class Readme extends React.Component { prev={ this.props.prev } next={ this.props.next } version={ this.props.version } + shortcuts={ this.props.shortcuts } aria-label="pagination" /> @@ -202,7 +204,8 @@ Readme.propTypes = { 'content': PropTypes.string, 'prev': PropTypes.string, 'next': PropTypes.string, - 'onClick': PropTypes.func.isRequired + 'onClick': PropTypes.func.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/readme/next.jsx b/src/components/readme/next.jsx index a95669e31..0cd0b7fc4 100644 --- a/src/components/readme/next.jsx +++ b/src/components/readme/next.jsx @@ -18,8 +18,8 @@ // MODULES // -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useEffect, useCallback } from 'react'; +import { Link, useHistory } from 'react-router-dom'; import PropTypes from 'prop-types'; import pkgPath from 'pkg-doc-path'; import pkgKind from 'pkg-kind'; @@ -35,9 +35,13 @@ import pkgBasename from 'pkg-basename'; * @param {Object} props - component properties * @param {string} props.pkg - package * @param {string} props.version - documentation version +* @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactElement} React element */ function PaginationNext( props ) { + // For accessing the history object in functional component + var history = useHistory(); + var basename; var name; var kind; @@ -52,6 +56,23 @@ function PaginationNext( props ) { // Determine if we can resolve a package "kind": kind = pkgKind( pkg ); + const onArrowRight = useCallback(( event ) => { + // Check if the arrowright is pressed when shortcuts are active + if ( event.ctrlKey && event.key === "ArrowRight" && props.shortcuts ) { + history.push( pkgPath( name, props.version) ); + } + }, [props]); + + useEffect(() => { + // Add event listener when the component mounts + document.addEventListener( "keyup", onArrowRight ); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener( "keyup", onArrowRight ); + }; + }, [onArrowRight]); + return ( - { ( prev ) ? : } - { ( next ) ? : } + { ( prev ) ? : } + { ( next ) ? : } ); } @@ -62,7 +63,8 @@ function Pagination( props ) { Pagination.propTypes = { 'next': PropTypes.string, 'prev': PropTypes.string, - 'version': PropTypes.string.isRequired + 'version': PropTypes.string.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/readme/prev.jsx b/src/components/readme/prev.jsx index d3d5060b2..e97e9d564 100644 --- a/src/components/readme/prev.jsx +++ b/src/components/readme/prev.jsx @@ -18,8 +18,8 @@ // MODULES // -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useEffect, useCallback } from 'react'; +import { Link, useHistory } from 'react-router-dom'; import PropTypes from 'prop-types'; import pkgPath from 'pkg-doc-path'; import pkgKind from 'pkg-kind'; @@ -35,9 +35,13 @@ import pkgBasename from 'pkg-basename'; * @param {Object} props - component properties * @param {string} props.pkg - package * @param {string} props.version - documentation version +* @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactElement} React element */ function PaginationPrev( props ) { + // For accessing the history object in a functional component + var history = useHistory(); + var basename; var name; var kind; @@ -52,7 +56,24 @@ function PaginationPrev( props ) { // Determine if we can resolve a package "kind": kind = pkgKind( pkg ); - return ( + const onArrowLeft = useCallback(( event ) => { + // Check if arrowleft is pressed when shortcuts are active + if ( event.ctrlKey && event.key === "ArrowLeft" && props.shortcuts ) { + history.push( pkgPath( name, props.version) ); + } + }, [props]); + + useEffect(() => { + // Add event listener when the component mounts + document.addEventListener( "keyup", onArrowLeft ); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener( "keyup", onArrowLeft ); + }; + }, [onArrowLeft]); + + return ( { + // Close search results only after the settings menu gets closed + var isSettingsClosed = document.querySelector( 'div.settings-menu-overlay.invisible' ) !== null; + + if ( event.key === "Escape" && this.props.shortcuts && isSettingsClosed ) { + this.props.onClose(); + } + } + /** * Callback invoked immediately after mounting a component (i.e., is inserted into a tree). * * @private */ componentDidMount() { + document.addEventListener( "keydown" , this._closeSearchResults ) this._updateSearchIndex(); } + /** + * Callback invoked immediately after unmounting a component (i.e., is removed from a tree). + * + * @private + */ + componentWillUnmount() { + document.removeEventListener( "keydown", this._closeSearchResults ) + } + /** * Callback invoked immediately after updating a component. * @@ -310,7 +337,8 @@ class Search extends React.Component { Search.propTypes = { 'version': PropTypes.string.isRequired, 'query': PropTypes.string.isRequired, - 'onClose': PropTypes.func.isRequired + 'onClose': PropTypes.func.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/top-nav/docs_help.jsx b/src/components/top-nav/docs_help.jsx new file mode 100644 index 000000000..9cda34b9c --- /dev/null +++ b/src/components/top-nav/docs_help.jsx @@ -0,0 +1,58 @@ +/** +* @license Apache-2.0 +* +* Copyright (c) 2024 The Stdlib Authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +// MODULES // + +import React from "react"; +import PropTypes from 'prop-types'; +import InfoIcon from "../icons/info"; + +// MAIN // + +/** +* Component for navigating to package benchmarks. +* +* @private +* @param {Object} props - component properties +* @param {Callback} props.onOpen - callback to invoke upon clicking the help button +* @returns {ReactElement} React element +*/ +function DocsHelp( props ) { + return ( + + ); +} + +/** +* Component property types. +* +* @constant +* @name propTypes +* @memberof DocsHelp +* @type {Object} +*/ +DocsHelp.propTypes = { + 'onOpen': PropTypes.func.isRequired +}; + + +// EXPORTS // + +export default DocsHelp; \ No newline at end of file diff --git a/src/components/top-nav/index.jsx b/src/components/top-nav/index.jsx index 7f70c0d0a..52acdb1d3 100644 --- a/src/components/top-nav/index.jsx +++ b/src/components/top-nav/index.jsx @@ -20,13 +20,14 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; +import viewportWidth from 'viewport-width'; import SideMenu from './side-menu/index.jsx'; import PackageMenu from './pkg-menu/index.jsx'; import SearchInput from './search_input.jsx'; import DownloadButton from './download_button.jsx'; import DownloadProgressBar from './download_progress_bar.jsx'; import Settings from './settings/index.jsx'; - +import DocsHelp from './docs_help.jsx' // MAIN // @@ -49,6 +50,7 @@ class TopNav extends React.Component { * @param {Callback} props.onVersionChange - callback to invoke upon selecting a version * @param {Callback} props.onFilterFocus - callback to invoke when the side menu filter receives focus * @param {Callback} props.onFilterBlur - callback to invoke when the side menu filter loses focus + * @param {Callback} props.onHelpOpen - callback to invoke when the help page buttons gets clicked * @param {Callback} props.onSearchSubmit - callback to invoke upon submitting a search query * @param {Callback} props.onSearchChange - callback to invoke upon updating a search input element * @param {Callback} props.onSearchFocus - callback to invoke when search input receives focus @@ -67,6 +69,7 @@ class TopNav extends React.Component { * @param {boolean} props.typescript - boolean indicating whether to link to TypeScript type declarations * @param {boolean} props.sideMenu - boolean indicating whether to expand the side menu * @param {boolean} props.allowSettingsCookies - boolean indicating whether to allow the use of cookies for storing settings + * @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @param {string} props.theme - current documentation theme * @param {string} props.mode - current documentation "mode" * @param {string} props.exampleSyntax - current example code syntax @@ -84,6 +87,22 @@ class TopNav extends React.Component { } } + /** + * Callback invoked upon checking the device type + * + * @private + * @param {boolean} bool - boolean indicating whether a the device is PC or not + */ + _isPC = () => { + // Query the current viewport width: + var w = viewportWidth(); + + // Only render help page, based on the assumption that small devices are likely to be mobile devices: + if ( w && w >= 1080 ) { + return true; + } + return false; + } /** * Callback invoked upon a download progress update. * @@ -135,6 +154,39 @@ class TopNav extends React.Component { }); } + /** + * Callback invoked upon a user press down a key to open help page. + * + * @private + * @param {Object} event - event object + * @returns {void} + */ + openHelpPage = ( event ) => { + // Open when question mark is pressed down and shortcuts are active + if( event.key == "?" && this.props.shortcuts ){ + this.props.onHelpOpen(); + } + } + + /** + * Callback invoked immediately after mounting a component (i.e., is inserted into a tree). + * + * @private + */ + componentDidMount(){ + document.addEventListener( "keydown", this.openHelpPage ) + } + + /** + * Callback invoked immediately after unmounting a component (i.e., is removed from a tree). + * + * @private + */ + componentWillUnmount(){ + document.addEventListener( "keydown", this.openHelpPage ) + } + + /** * Renders the component. * @@ -158,6 +210,7 @@ class TopNav extends React.Component { onVersionChange={ this.props.onVersionChange } onFilterFocus={ this.props.onFilterFocus } onFilterBlur={ this.props.onFilterBlur } + shortcuts={ this.props.shortcuts } /> @@ -173,6 +227,7 @@ class TopNav extends React.Component { open={ this.state.packageMenu } pkg={ this.props.pkg } version={ this.props.version } + shortcuts={ this.props.shortcuts } home={ this.props.home } docs={ this.props.docs } benchmarks={ this.props.benchmarks } @@ -195,12 +250,16 @@ class TopNav extends React.Component { onModeChange={ this.props.onModeChange } onExampleSyntaxChange={ this.props.onExampleSyntaxChange } onPrevNextNavChange={ this.props.onPrevNextNavChange } + + shortcuts={ this.props.shortcuts } /> + + { this._isPC() ? < DocsHelp onHelpOpen={ this.props.onHelpOpen } /> : null } { progress ? : null } @@ -223,6 +282,7 @@ TopNav.propTypes = { 'query': PropTypes.string.isRequired, 'onSideMenuToggle': PropTypes.func.isRequired, 'onVersionChange': PropTypes.func.isRequired, + 'onHelpOpen': PropTypes.func.isRequired, 'onSearchSubmit': PropTypes.func.isRequired, 'onSearchChange': PropTypes.func.isRequired, 'onSearchFocus': PropTypes.func.isRequired, @@ -243,6 +303,7 @@ TopNav.propTypes = { 'typescript': PropTypes.bool.isRequired, 'sideMenu': PropTypes.bool.isRequired, 'allowSettingsCookies': PropTypes.bool.isRequired, + 'shortcuts' : PropTypes.bool.isRequired, 'theme': PropTypes.string.isRequired, 'mode': PropTypes.string.isRequired, 'exampleSyntax': PropTypes.string.isRequired, diff --git a/src/components/top-nav/pkg-menu/benchmarks.jsx b/src/components/top-nav/pkg-menu/benchmarks.jsx index 3bd9e8ae8..9f54040a1 100644 --- a/src/components/top-nav/pkg-menu/benchmarks.jsx +++ b/src/components/top-nav/pkg-menu/benchmarks.jsx @@ -18,8 +18,8 @@ // MODULES // -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useEffect, useCallback } from 'react'; +import { Link , useHistory } from 'react-router-dom'; import PropTypes from 'prop-types'; @@ -32,9 +32,29 @@ import PropTypes from 'prop-types'; * @param {Object} props - component properties * @param {string} props.pkg - package name (e.g., `math/base/special/sin`) * @param {string} props.path - package documentation URL +* @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactElement} React element */ function Benchmarks( props ) { + var history = useHistory(); + + const openBenchmarkPage = useCallback(( event ) => { + // Open when 'b' is pressed down while shortcuts are active + if ( event.key === "b" && props.shortcuts ) { + history.push( props.path + "/benchmarks" ); + } + }, [props]); + + useEffect(() => { + // Add event listener when the component mounts + document.addEventListener( "keydown", openBenchmarkPage ); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener( "keydown", openBenchmarkPage ); + }; + }, [openBenchmarkPage]); + return (
  • benchmarks @@ -52,7 +72,8 @@ function Benchmarks( props ) { */ Benchmarks.propTypes = { 'pkg': PropTypes.string.isRequired, - 'path': PropTypes.string.isRequired + 'path': PropTypes.string.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/top-nav/pkg-menu/docs.jsx b/src/components/top-nav/pkg-menu/docs.jsx index 1023c2f2e..1e1cc491b 100644 --- a/src/components/top-nav/pkg-menu/docs.jsx +++ b/src/components/top-nav/pkg-menu/docs.jsx @@ -18,10 +18,9 @@ // MODULES // -import React from 'react'; -import { Link } from 'react-router-dom'; -import PropTypes from 'prop-types'; - +import React, { useEffect, useCallback } from "react"; +import { Link, useHistory } from "react-router-dom"; +import PropTypes from "prop-types"; // MAIN // @@ -32,9 +31,29 @@ import PropTypes from 'prop-types'; * @param {Object} props - component properties * @param {string} props.pkg - package name (e.g., `math/base/special/sin`) * @param {string} props.path - package documentation URL +* @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactElement} React element */ function Docs( props ) { + var history = useHistory(); + + const openDocs = useCallback(( event ) => { + // Open when 'p' is pressed down while shortcuts are active + if ( event.key === "p" && props.shortcuts ) { + history.push( props.path ); + } + }, [props]); + + useEffect(() => { + // Add event listener when the component mounts + document.addEventListener( "keydown", openDocs ); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener( "keydown", openDocs ); + }; + }, [openDocs]); + return (
  • documentation @@ -52,7 +71,8 @@ function Docs( props ) { */ Docs.propTypes = { 'pkg': PropTypes.string.isRequired, - 'path': PropTypes.string.isRequired + 'path': PropTypes.string.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/top-nav/pkg-menu/index.jsx b/src/components/top-nav/pkg-menu/index.jsx index 46462e793..c7ec59057 100644 --- a/src/components/top-nav/pkg-menu/index.jsx +++ b/src/components/top-nav/pkg-menu/index.jsx @@ -48,6 +48,7 @@ class PackageMenu extends React.Component { * @param {string} props.pkg - package name (e.g., `math/base/special/sin`) * @param {string} props.version - documentation version * @param {boolean} props.open - boolean indicating whether to expand the menu + * @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @param {Callback} props.onToggle - callback to invoke upon a change to the package navigation menu * @param {boolean} props.home - boolean indicating whether to link to the main website * @param {boolean} props.docs - boolean indicating whether to link to package documentation @@ -118,12 +119,12 @@ class PackageMenu extends React.Component { role="menubar" > { this.props.home ? : null } - { this.props.docs ? : null } - { this.props.benchmarks ? : null } - { this.props.tests ? : null } - { this.props.src ? : null } + { this.props.docs ? : null } + { this.props.benchmarks ? : null } + { this.props.tests ? : null } + { this.props.src ? : null } { this.props.npm ? : null } - { this.props.typescript ? : null } + { this.props.typescript ? : null } ); @@ -142,6 +143,7 @@ PackageMenu.propTypes = { 'pkg': PropTypes.string.isRequired, 'version': PropTypes.string.isRequired, 'open': PropTypes.bool.isRequired, + 'shortcuts': PropTypes.bool.isRequired, 'onToggle': PropTypes.func.isRequired, 'home': PropTypes.bool, 'docs': PropTypes.bool, diff --git a/src/components/top-nav/pkg-menu/source.jsx b/src/components/top-nav/pkg-menu/source.jsx index d5bdd1995..59a1d2572 100644 --- a/src/components/top-nav/pkg-menu/source.jsx +++ b/src/components/top-nav/pkg-menu/source.jsx @@ -18,7 +18,7 @@ // MODULES // -import React from 'react'; +import React, { useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import config from 'config'; @@ -32,10 +32,29 @@ import config from 'config'; * @param {Object} props - component properties * @param {string} props.pkg - package name (e.g., `math/base/special/sin`) * @param {string} props.version - documentation version +* @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactElement} React element */ function Source( props ) { var path = config.repository+'/tree/'+props.version+'/lib/node_modules/@stdlib/'+props.pkg; + + const openSourcePage = useCallback((event) => { + // Open when 's' is pressed down while shortcuts are active + if ( event.key === "s" && props.shortcuts ) { + window.location.href = path; + } + }, [props]); + + useEffect(() => { + // Add event listener when the component mounts + document.addEventListener( "keydown", openSourcePage ); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener( "keydown", openSourcePage ); + }; + }, [openSourcePage]); + return (
  • source @@ -53,7 +72,8 @@ function Source( props ) { */ Source.propTypes = { 'pkg': PropTypes.string.isRequired, - 'version': PropTypes.string.isRequired + 'version': PropTypes.string.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/top-nav/pkg-menu/tests.jsx b/src/components/top-nav/pkg-menu/tests.jsx index 5a890a6ef..b171282f6 100644 --- a/src/components/top-nav/pkg-menu/tests.jsx +++ b/src/components/top-nav/pkg-menu/tests.jsx @@ -18,8 +18,8 @@ // MODULES // -import React from 'react'; -import { Link } from 'react-router-dom'; +import React, { useEffect, useCallback } from 'react'; +import { Link, useHistory } from 'react-router-dom'; import PropTypes from 'prop-types'; @@ -32,9 +32,28 @@ import PropTypes from 'prop-types'; * @param {Object} props - component properties * @param {string} props.pkg - package name (e.g., `math/base/special/sin`) * @param {string} props.path - package documentation URL +* @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactElement} React element */ function Tests( props ) { + var history = useHistory(); + const openTestsPage = useCallback(( event ) => { + // Open when 't' was pressed while shortcuts are active + if ( !event.shiftKey && event.key === "t" && props.shortcuts ) { + history.push( props.path + "/tests" ); + } + }, [props]); + + useEffect(() => { + // Add event listener when the component mounts + document.addEventListener( "keydown", openTestsPage ); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener( "keydown", openTestsPage ); + }; + }, [openTestsPage]); + return (
  • tests @@ -52,7 +71,8 @@ function Tests( props ) { */ Tests.propTypes = { 'pkg': PropTypes.string.isRequired, - 'path': PropTypes.string.isRequired + 'path': PropTypes.string.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/top-nav/pkg-menu/typescript.jsx b/src/components/top-nav/pkg-menu/typescript.jsx index 15a9343fa..0e12db5fc 100644 --- a/src/components/top-nav/pkg-menu/typescript.jsx +++ b/src/components/top-nav/pkg-menu/typescript.jsx @@ -18,7 +18,7 @@ // MODULES // -import React from 'react'; +import React, { useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; @@ -44,6 +44,7 @@ var PATH = [ * @param {Object} props - component properties * @param {string} props.pkg - package name (e.g., `math/base/special/sin`) * @param {string} props.version - documentation version +* @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactElement} React element */ function TypeScript( props ) { @@ -54,6 +55,29 @@ function TypeScript( props ) { pkg = pkg.replace( RE_STDLIB_PREFIX, '' ); PATH[ 3 ] = pkg.replace( RE_UNDERSCORE_REPLACE, '_' ); + var location = window.location; + var path = location.protocol + "//" + location.hostname; + // when port is available + path += location.port ? ":" + location.port : ""; + path += PATH.join( '' ); + + const openTSDocs = useCallback(( event ) => { + // Open when 'shift+T' is pressed down while shortcuts are active + if ( event.key === "T" && event.shiftKey && props.shortcuts ) { + window.location.href = path; + } + }, [props]); + + useEffect(() => { + // Add event listener when the component mounts + document.addEventListener( "keydown", openTSDocs ); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener( "keydown", openTSDocs ); + }; + }, [openTSDocs]); + return (
  • typescript @@ -71,7 +95,8 @@ function TypeScript( props ) { */ TypeScript.propTypes = { 'pkg': PropTypes.string.isRequired, - 'version': PropTypes.string.isRequired + 'version': PropTypes.string.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/top-nav/search_input.jsx b/src/components/top-nav/search_input.jsx index 56c4dff66..1992e5f6f 100644 --- a/src/components/top-nav/search_input.jsx +++ b/src/components/top-nav/search_input.jsx @@ -43,6 +43,7 @@ class SearchInput extends React.Component { * @param {Callback} props.onSubmit - callback to invoke upon a user submitting a search query * @param {Callback} props.onFocus - callback to invoke when search input element receives focus * @param {Callback} props.onBlur - callback to invoke when search input element loses focus + * @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactComponent} React component */ constructor( props ) { @@ -51,6 +52,9 @@ class SearchInput extends React.Component { // Boolean indicating whether a search input element is "active" (e.g., is focused or contains a search query): 'active': false }; + // Ref for search input element + this.searchRef = React.createRef(); + } /** @@ -71,7 +75,7 @@ class SearchInput extends React.Component { * @returns {void} */ _onKeyUp = ( event ) => { - if ( event.charCode === 13 || event.key === 'Enter' ) { + if ( event.key === 'Enter' ) { this.props.onSubmit(); } } @@ -109,6 +113,41 @@ class SearchInput extends React.Component { */ _onSubmitClick = () => { this.props.onSubmit(); + }; + + /** + * Callback invoked upon a user press down a key to focus the search input element + * + * @private + * @param {Object} event - event object + * @returns {void} + */ + _focusSearch = ( event ) => { + // Focus search input element when forward slash is pressed down while shortcuts are active + if ( event.key === "/" && this.props.shortcuts ) { + this.searchRef.current.focus(); + event.preventDefault(); + } + }; + + /** + * Callback invoked immediately after mounting a component (i.e., is inserted into a tree). + * + * @private + */ + componentDidMount() { + // Add event listener when the component mounts + document.addEventListener( "keydown", this._focusSearch ); + } + + /** + * Callback invoked immediately after unmounting a component (i.e., is removed from a tree). + * + * @private + */ + componentWillUnmount() { + // Cleanup the event listener when the component unmounts + document.removeEventListener( "keydown", this._focusSearch ); } /** @@ -125,6 +164,7 @@ class SearchInput extends React.Component { className={ 'top-nav-search ' + ( ( this.state.active || this.props.value ) ? 'top-nav-search-active' : '' ) } placeholder="Search documentation" value={ this.props.value } + inputRef={ this.searchRef } type="text" inputProps={{ 'aria-label': 'search text' @@ -160,7 +200,8 @@ SearchInput.propTypes = { 'onChange': PropTypes.func.isRequired, 'onSubmit': PropTypes.func.isRequired, 'onFocus': PropTypes.func.isRequired, - 'onBlur': PropTypes.func.isRequired + 'onBlur': PropTypes.func.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/top-nav/settings/head.jsx b/src/components/top-nav/settings/head.jsx index 8f9fe90cb..c72e779c6 100644 --- a/src/components/top-nav/settings/head.jsx +++ b/src/components/top-nav/settings/head.jsx @@ -18,7 +18,7 @@ // MODULES // -import React from 'react'; +import React, { useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import CloseIcon from './../../icons/close.jsx'; @@ -30,10 +30,30 @@ import CloseIcon from './../../icons/close.jsx'; * * @private * @param {Object} props - component properties +* @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active +* @param {boolean} props.isMenuOpen - boolean indicating whether the settings menu is open or closed * @param {Callback} props.onClose - callback to invoke upon a "close" event * @returns {ReactElement} React element */ -function Head( props ) { +function Head( props ) { + const closeSettings = useCallback(( event ) => { + // Close when Escape is clicked when settings menu is open and shortcuts are active + if ( event.key === "Escape" && props.shortcuts && props.isMenuOpen ) { + props.onClose( event ); + } + }, [props]); + + useEffect(() => { + // Add event listener when the component mounts + document.addEventListener( "keyup", closeSettings ); + + // Cleanup the event listener when the component unmounts + return () => { + document.removeEventListener( "keyup", closeSettings ); + + }; + }, [closeSettings]); + return (

    @@ -60,6 +80,7 @@ function Head( props ) { * @type {Object} */ Head.propTypes = { + 'isMenuOpen': PropTypes.bool.isRequired, 'onClose': PropTypes.func.isRequired }; diff --git a/src/components/top-nav/settings/index.jsx b/src/components/top-nav/settings/index.jsx index a4a1d4bc5..b088d18e0 100644 --- a/src/components/top-nav/settings/index.jsx +++ b/src/components/top-nav/settings/index.jsx @@ -67,6 +67,7 @@ class Settings extends React.Component { * @private * @constructor * @param {Object} props - component properties + * @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @param {boolean} props.allowSettingsCookies - boolean indicating whether to allow the use of cookies for storing settings * @param {string} props.theme - current theme * @param {string} props.mode - current documentation "mode" @@ -367,7 +368,7 @@ class Settings extends React.Component { onClick={ this._onWrapperClick } > - +
    { this._renderThemeMenu() } @@ -436,6 +437,7 @@ Settings.propTypes = { 'mode': PropTypes.string.isRequired, 'exampleSyntax': PropTypes.string.isRequired, 'prevNextNavigation': PropTypes.string.isRequired, + 'shortcuts': PropTypes.bool.isRequired, 'onAllowSettingsCookiesChange': PropTypes.func.isRequired, 'onThemeChange': PropTypes.func.isRequired, 'onModeChange': PropTypes.func.isRequired, diff --git a/src/components/top-nav/side-menu/drawer.jsx b/src/components/top-nav/side-menu/drawer.jsx index 176777898..e3c316a48 100644 --- a/src/components/top-nav/side-menu/drawer.jsx +++ b/src/components/top-nav/side-menu/drawer.jsx @@ -130,6 +130,7 @@ class SideMenuDrawer extends React.Component { * @param {Callback} props.onVersionChange - callback to invoke upon a change to the selected documentation version * @param {Callback} props.onFilterFocus - callback to invoke when the side menu filter receives focus * @param {Callback} props.onFilterBlur - callback to invoke when the side menu filter loses focus + * @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactComponent} component */ constructor( props ) { @@ -692,6 +693,7 @@ class SideMenuDrawer extends React.Component { @@ -830,7 +832,8 @@ SideMenuDrawer.propTypes = { 'onToggle': PropTypes.func.isRequired, 'onVersionChange': PropTypes.func.isRequired, 'onFilterFocus': PropTypes.func.isRequired, - 'onFilterBlur': PropTypes.func.isRequired + 'onFilterBlur': PropTypes.func.isRequired, + 'shortcuts': PropTypes.bool.isRequired }; diff --git a/src/components/top-nav/side-menu/filter.jsx b/src/components/top-nav/side-menu/filter.jsx index ef77e386e..f168b113d 100644 --- a/src/components/top-nav/side-menu/filter.jsx +++ b/src/components/top-nav/side-menu/filter.jsx @@ -40,6 +40,7 @@ class SideMenuFilter extends React.Component { * @param {Callback} props.onFocus - callback to invoke when the menu filter receives focus * @param {Callback} props.onBlur - callback to invoke when the menu filter loses focus * @param {Callback} props.onChange - callback to invoke upon a change in the filter + * @param {boolean} props.shortcuts - boolean indicating whether keyboard shortcuts are active * @returns {ReactComponent} React component */ constructor( props ) { @@ -47,6 +48,8 @@ class SideMenuFilter extends React.Component { this.state = { 'filter': '' }; + // Ref for side menu filter + this.filterRef = React.createRef(); } /** @@ -74,6 +77,41 @@ class SideMenuFilter extends React.Component { 'filter': '' }); this.props.onChange( '' ); + }; + + /** + * Callback invoked upon a user press down a key to focus the side menu filter + * + * @private + * @param {Object} event - event object + * @returns {void} + */ + _focusSideMenuFilter = ( event ) => { + // Focus when shift + F was pressed down while shortcuts are active + if ( event.shiftKey && event.key === "F" && this.props.shortcuts ) { + this.filterRef.current.focus(); + event.preventDefault(); + } + }; + + /** + * Callback invoked immediately after unmounting a component (i.e., is removed from a tree). + * + * @private + */ + componentDidMount() { + // Add event listener for key press event + document.addEventListener( "keydown", this._focusSideMenuFilter ); + } + + /** + * Callback invoked immediately after unmounting a component (i.e., is removed from a tree). + * + * @private + */ + componentWillUnmount() { + // Clean up event listener + document.removeEventListener( "keydown", this._focusSideMenuFilter ); } /** @@ -88,6 +126,7 @@ class SideMenuFilter extends React.Component { { this.props.onToggle( true ); + }; + + /** + * Callback invoked upon a user press down a key to toggle the side menu view + * + * @private + * @param {Object} event - event object + * @returns {void} + */ + _sideMenuToggle = ( event ) => { + // Toggle when 'm' was pressed down while shortcuts are active + if ( event.key === "m" && this.props.shortcuts ) { + this.props.onToggle( !this.props.open ); + } + }; + + /** + * Callback invoked immediately after unmounting a component (i.e., is removed from a tree). + * + * @private + */ + componentDidMount() { + // Add event listener for key down event + document.addEventListener( "keydown", this._sideMenuToggle ); + } + + /** + * Callback invoked immediately after unmounting a component (i.e., is removed from a tree). + * + * @private + */ + componentWillUnmount() { + // Clean up event listener + document.removeEventListener( "keydown", this._sideMenuToggle ); } /** @@ -86,6 +121,7 @@ class SideMenu extends React.Component { onVersionChange={ this.props.onVersionChange } onFilterFocus={ this.props.onFilterFocus } onFilterBlur={ this.props.onFilterBlur } + shortcuts={ this.props.shortcuts } />
    @@ -108,7 +144,8 @@ SideMenu.propTypes = { 'onVersionChange': PropTypes.func.isRequired, 'onFilterFocus': PropTypes.func.isRequired, 'onFilterBlur': PropTypes.func.isRequired, - 'open': PropTypes.bool.isRequired + 'open': PropTypes.bool.isRequired, + 'shortcuts' : PropTypes.bool.isRequired };