Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add controller for easily attaching keyboard shortcuts to buttons and fields #1043

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/_includes/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,17 @@

<div class="s-topbar--searchbar w100 wmx3 sm:wmx-initial js-search">
<div class="s-topbar--searchbar--input-group">
<input id="searchbox" type="text" placeholder="Search Stacks…" value="" autocomplete="off" class="s-input s-input__search" />
<input
id="searchbox"
type="text"
placeholder="Search Stacks…"
value=""
autocomplete="off"
class="s-input s-input__search"
data-controller="s-keyboard-shortcut"
data-s-keyboard-shortcut-ctrl-value="true"
data-s-keyboard-shortcut-key-value="/"
/>
{% icon "Search", "s-input-icon s-input-icon__search" %}
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion lib/ts/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -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';
export { UploaderController } from './s-uploader';
117 changes: 117 additions & 0 deletions lib/ts/controllers/s-keyboard-shortcut.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
12 changes: 11 additions & 1 deletion lib/ts/index.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
8 changes: 8 additions & 0 deletions lib/ts/shared/utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
type Indexable = Record<string | number | symbol, any>;

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])
);
}