From 6324b6ff8048cef7d5fc37b1460ba62902e46ad5 Mon Sep 17 00:00:00 2001 From: Vlad Jimenez Date: Fri, 29 Jul 2022 12:35:25 -0700 Subject: [PATCH 1/2] Add controller for easily attaching keyboard shortcuts to buttons --- docs/_includes/header.html | 13 ++- lib/ts/controllers/index.ts | 3 +- lib/ts/controllers/s-keyboard-shortcut.ts | 117 ++++++++++++++++++++++ lib/ts/index.ts | 12 ++- lib/ts/shared/utilities.ts | 8 ++ 5 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 lib/ts/controllers/s-keyboard-shortcut.ts create mode 100644 lib/ts/shared/utilities.ts diff --git a/docs/_includes/header.html b/docs/_includes/header.html index 1ebc867d51..66f293baa7 100644 --- a/docs/_includes/header.html +++ b/docs/_includes/header.html @@ -37,7 +37,18 @@ diff --git a/lib/ts/controllers/index.ts b/lib/ts/controllers/index.ts index 601f2a667a..1f6d25a56e 100644 --- a/lib/ts/controllers/index.ts +++ b/lib/ts/controllers/index.ts @@ -1,8 +1,9 @@ // export all controllers *with helpers* so they can be bulk re-exported by the package entry point export { ExpandableController } from './s-expandable-control'; +export { KeyboardShortcutController } from './s-keyboard-shortcut'; export { hideModal, ModalController, showModal } from './s-modal'; export { TabListController } from './s-navigation-tablist'; export { attachPopover, detachPopover, hidePopover, BasePopoverController, PopoverController, showPopover } from './s-popover'; export { TableController } from './s-table'; export { setTooltipHtml, setTooltipText, TooltipController } from './s-tooltip'; -export { UploaderController } from './s-uploader'; \ No newline at end of file +export { UploaderController } from './s-uploader'; diff --git a/lib/ts/controllers/s-keyboard-shortcut.ts b/lib/ts/controllers/s-keyboard-shortcut.ts new file mode 100644 index 0000000000..bb89ce8697 --- /dev/null +++ b/lib/ts/controllers/s-keyboard-shortcut.ts @@ -0,0 +1,117 @@ +import { StacksController } from "../stacks"; +import { shallowEquals } from "../shared/utilities"; + +interface Shortcut { + ctrl?: boolean; + shift?: boolean; + alt?: boolean; + meta?: boolean; + key: string; +} + +type ClickableElement = HTMLAnchorElement | HTMLButtonElement | HTMLDetailsElement; +const clickableElements = ['a', 'button', 'details']; + +type FocusableElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; +const focusableElements = ['input', 'select', 'textarea']; + +export class KeyboardShortcutController extends StacksController { + declare ctrlValue: boolean; + declare shiftValue: boolean; + declare altValue: boolean; + declare metaValue: boolean; + declare keyValue: string; + + static values = { + ctrl: Boolean, + meta: Boolean, + shift: Boolean, + alt: Boolean, + key: String, + }; + + private cachedShortcut: null | Shortcut = null; + + get shortcut(): Shortcut { + if (this.cachedShortcut) { + return this.cachedShortcut; + } + + return this.cachedShortcut = { + key: this.keyValue.toUpperCase(), + ...(this.ctrlValue ? { ctrl: true } : {}), + ...(this.metaValue ? { meta: true } : {}), + ...(this.shiftValue ? { shift: true } : {}), + ...(this.altValue ? { alt: true } : {}), + }; + } + + connect() { + window.addEventListener('keydown', this.handleKeyPress); + } + + disconnect() { + window.removeEventListener('keydown', this.handleKeyPress); + } + + // + // Rebuild our shortcut cache if our shortcut definition changes + // + + ctrlValueChanged() { + this.cachedShortcut = null; + } + + shiftValueChanged() { + this.cachedShortcut = null; + } + + altValueChanged() { + this.cachedShortcut = null; + } + + metaValueChanged() { + this.cachedShortcut = null; + } + + keyValueChanged() { + this.cachedShortcut = null; + } + + private handleKeyPress = (event: KeyboardEvent) => { + // If we're inside a text field, ignore any custom keyboard shortcuts + if (this.isInputInFocus()) { + return; + } + + const keyPress = { + key: event.key.toUpperCase(), + ...(event.ctrlKey ? { ctrl: true } : {}), + ...(event.metaKey ? { meta: true } : {}), + ...(event.shiftKey ? { shift: true } : {}), + ...(event.altKey ? { alt: true } : {}), + }; + + if (shallowEquals(this.shortcut, keyPress)) { + event.preventDefault(); + + const tag = this.element.tagName.toLowerCase(); + + if (clickableElements.indexOf(tag) >= 0) { + (this.element as ClickableElement).click(); + } else if (focusableElements.indexOf(tag) >= 0) { + (this.element as FocusableElement).focus(); + } + } + }; + + private isInputInFocus = (): boolean => { + const nodeName = document.activeElement?.nodeName.toLowerCase(); + + if (!nodeName) { + return false; + } + + return ['input', 'textarea', 'select'].includes(nodeName); + } +} diff --git a/lib/ts/index.ts b/lib/ts/index.ts index 930dc197c6..5de2f90bf7 100644 --- a/lib/ts/index.ts +++ b/lib/ts/index.ts @@ -1,9 +1,19 @@ import '../css/stacks.less'; -import { ExpandableController, ModalController, PopoverController, TableController, TabListController, TooltipController, UploaderController } from './controllers'; +import { + ExpandableController, + KeyboardShortcutController, + ModalController, + PopoverController, + TableController, + TabListController, + TooltipController, + UploaderController +} from './controllers'; import { application, StacksApplication } from './stacks'; // register all built-in controllers application.register("s-expandable-control", ExpandableController); +application.register("s-keyboard-shortcut", KeyboardShortcutController); application.register("s-modal", ModalController); application.register("s-navigation-tablist", TabListController); application.register("s-popover", PopoverController); diff --git a/lib/ts/shared/utilities.ts b/lib/ts/shared/utilities.ts new file mode 100644 index 0000000000..3bdb020958 --- /dev/null +++ b/lib/ts/shared/utilities.ts @@ -0,0 +1,8 @@ +type Indexable = Record; + +export function shallowEquals(obj1: Indexable, obj2: Indexable) { + return ( + Object.keys(obj1).length === Object.keys(obj2).length && + Object.keys(obj1).every(key => obj1[key] === obj2[key]) + ); +} From 2c189f0185676163a9f47c080241303a6d0b0a7f Mon Sep 17 00:00:00 2001 From: Vlad Jimenez Date: Fri, 29 Jul 2022 12:50:32 -0700 Subject: [PATCH 2/2] Remove unused data attribute --- docs/_includes/header.html | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/_includes/header.html b/docs/_includes/header.html index 66f293baa7..1da4897160 100644 --- a/docs/_includes/header.html +++ b/docs/_includes/header.html @@ -47,7 +47,6 @@ data-controller="s-keyboard-shortcut" data-s-keyboard-shortcut-ctrl-value="true" data-s-keyboard-shortcut-key-value="/" - data-s-keyboard-shortcut-action-value="focus" /> {% icon "Search", "s-input-icon s-input-icon__search" %}