Created
September 13, 2022 13:40
-
-
Save samternent/874309b987a18de776be3c904c749930 to your computer and use it in GitHub Desktop.
keymando
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# ## Keys + Commands = Keymando | |
# ## JS library to build keyboard navigation object of components which can be mapped to DOM elements. | |
# | |
# This library was designed for navigating the board view. The idea behind it is that | |
# each registered component has it's own children and navigation map. | |
# You can register events with options at all component levels | |
# | |
# This maintains a navigation state so DOM lookups aren't required on each operation | |
# or event to know the current state of our components and display. | |
# | |
# For a basic usage example see https://codepen.io/samternent/pen/NjarZN | |
# | |
# ## v1.0.0 | |
class Keymando | |
# Construct a new Keymando instance | |
# | |
# @param [String] id | |
# @param [Object] params | |
# @option params [Number] currentTarget | |
# @option params [Function] onFocus event callback | |
# @param onFocus [String] id | |
# @param onFocus [String] oldId | |
# @option params [Function] onBlur event callback | |
# @param onBlur [String] id | |
# @param onBlur [String] nextId | |
# @param onBlur [Boolean] softBlur true if the blur event hasn't unselected all other events | |
# @option params [Object] events Object of keyboard events | |
# @option events [Symbol] key Moustap key for event | |
# @option events [Boolean] allowBulk if true this will fire on multi focuses elemnts | |
# @option events [Boolean] overrideGlobal override any Mousetrap events registered in the global space | |
# @option events [Boolean] cancelBubble events won't fire up the chain to parents | |
# @option events [Function] event callback to fire on event trigger | |
# @param event [String] type Mousetrap event key | |
# @param event [Object] e Mousetrap event object | |
# @param event [String] activeId | |
# @param event [Object] activeComponent keymando instance | |
# @option params [Object] data extra data to be linked to a component | |
# @option params [Number] displayIndex for navigation order | |
# @param [Object] options | |
# @option options [Boolean] useDOM can be set to false if running headless | |
constructor: (id, params = {}, options = {}) -> | |
@components = {} | |
@events = {} | |
@inFocus = [] | |
@hasFocus = id | |
@currentTarget = id | |
@useDOM = options.useDOM ? true | |
@focusOnFirstChild = options.focusOnFirstChild ? false | |
@parent = id | |
@pausedEvents = [] | |
@mousetrap = new Mousetrap() | |
@register id, params | |
return | |
# Register a new component | |
# | |
# @param [String] id | |
# @param [Object] params | |
# @option params [Number] currentTarget | |
# @option params [Function] onFocus event callback | |
# @param onFocus [String] id | |
# @param onFocus [String] oldId | |
# @option params [Function] onBlur event callback | |
# @param onBlur [String] id | |
# @param onBlur [String] nextId | |
# @param onBlur [Boolean] softBlur true if the blur event hasn't unselected all other events | |
# @option params [Object] events Object of keyboard events | |
# @option events [Symbol] key Moustap key for event | |
# @option events [Boolean] allowBulk if true this will fire on multi focuses elemnts | |
# @option events [Boolean] overrideGlobal override any Mousetrap events registered in the global space | |
# @option events [Boolean] cancelBubble events won't fire up the chain to parents | |
# @option events [Function] event callback to fire on event trigger | |
# @param event [String] type Mousetrap event key | |
# @param event [Object] e Mousetrap event object | |
# @param event [String] activeId | |
# @param event [Object] activeComponent keymando instance | |
# @option params [Object] data extra data to be linked to a component | |
# @option params [Number] displayIndex for navigation order | |
# @option params [Boolean] initFocus | |
register: (id, params) -> | |
if @components[ id ]? | |
@removeFromNavigation @components[ id ].parent, id | |
@components[ id ] = { | |
id: id | |
parent: params.parent ? null | |
navigation: params.navigation ? [] | |
current: params.current ? -1 | |
onFocus: params.onFocus ? () -> | |
onBlur: params.onBlur ? () -> | |
events: params.events ? {} | |
data: params.data ? null | |
displayIndex: params.displayIndex ? null | |
} | |
# add component to parent navigation | |
if params.parent? and params.displayIndex? | |
@addToNavigation params.parent, id, params.displayIndex | |
# register events to component scope | |
@registerEvent type, event for type, event of params.events | |
# Set initial focus | |
@selectElement id if params.initFocus | |
return | |
# Register a new event | |
# | |
# This will only be called internally - use `register` to register a component and event | |
# | |
# @param [String] type | |
# @param [Function] event | |
registerEvent: (type, event) -> | |
return @events[ type ] = !!event?.allowBulk if @events[ type ]? | |
@events[ type ] = !!event?.allowBulk | |
action = (type, e) => | |
return unless @components[ @hasFocus ]? | |
e.preventDefault?() | |
if @events[ type ] | |
i = @inFocus.length | |
while i-- | |
@fireEventChain type, e, @inFocus[ i ] | |
return | |
@fireEventChain type, e, @hasFocus | |
return | |
@mousetrap.bind type, (e) -> action type, e | |
return | |
# Pipe up the chain of components, via parents, and fire events | |
# | |
# This will only be called internally | |
# | |
# @param [String] type | |
# @param [Object] e moustrap event object | |
# @param [Function] activeId | |
fireEventChain: (type, e, activeId) -> | |
return if @pausedEvents.indexOf(type) > -1 | |
activeComponent = @components[ activeId ] | |
return unless activeComponent? | |
if activeComponent.events?[ type ]? | |
@fireEvent type, e, activeId, activeComponent | |
if activeComponent.parent? and !activeComponent.events[ type ]?.cancelBubble | |
newOptions = @components[ activeComponent.parent ]?.events?[ type ] | |
@fireEventChain type, e, activeComponent.parent | |
return | |
# Fire event from component | |
# | |
# This will only be called internally | |
# | |
# @param [String] type | |
# @param [Object] e moustrap event object | |
# @param [Function] activeId | |
# @param [Function] activeComponent | |
fireEvent: (type, e, activeId, activeComponent) -> | |
{ event, overrideGlobal } = activeComponent.events[ type ] | |
Mousetrap.trigger(type) unless overrideGlobal | |
return unless event? | |
event e, activeId, activeComponent, { | |
back: @back.bind this | |
forward: @forward.bind this | |
} | |
return | |
# Focus on Element | |
# Updates navigation state and fire's comoponents onFocus event | |
# If DOM is in use also fires brower focus() event | |
# | |
# This will only be called internally - use the navigate methods to select and unselect elements | |
# | |
# @param [String] id | |
selectElement: (id) -> | |
return unless @components[ id ]? | |
oldId = @hasFocus | |
@inFocus.push id if @inFocus.indexOf(id) < 0 | |
@hasFocus = id | |
@components[ id ].onFocus id, oldId # fire bound focus event | |
return unless @useDOM and document.getElementById(id)? | |
document.getElementById(id).focus() # fire browser .focus() event | |
return | |
# Blur from Elements | |
# Unselects all selected components | |
# Updates navigation state and fire's comoponents onBlur event | |
# If DOM is in use also fires brower blur() event | |
# | |
# This will only be called internally - use the navigate methods to select and unselect elements | |
# | |
# @param [String] id | |
# @param [String] next | |
unselectElement: (id, next) -> | |
return unless @components[ id ]? | |
index = @inFocus.indexOf(id) | |
if index > -1 | |
@inFocus.splice index, 1 | |
@components[ id ].onBlur id, next # fire bound focus event | |
return unless @useDOM and document.getElementById(id)? | |
document.getElementById(id).blur() # fire browser .blur() event | |
return | |
# Blur from single Element | |
# Updates navigation state and fire's comoponents onBlur event | |
# If DOM is in use also fires brower blur() event | |
# | |
# This will only be called internally - use the navigate methods to select and unselect elements | |
# | |
# @param [String] id | |
# @param [String] next | |
softUnselectElement: (id, next) -> | |
return unless @components[ id ]? | |
@components[ id ].onBlur id, next, true # fire bound focus event | |
return unless @useDOM and document.getElementById(id)? | |
document.getElementById(id).blur() # fire browser .blur() event | |
return | |
# document this on open docs branch. | |
# checks if any of the components children have focus | |
# we need to know this when we're disposing of a component | |
childHasFocus: (id) -> | |
return unless @components[ id ]? | |
# check if any of the child components has focus | |
for child in @components[ id ].navigation | |
return true if child is @hasFocus | |
return false | |
# Navigate component by direction | |
# | |
# @param [String] id | |
# @param [Number] dir 1 or -1 | |
# @param [Boolean] multiselect | |
navigate: (id, dir, multiselect = false) -> | |
return unless @components[ id ]? | |
if @components[ id ].current + dir > -1 and @components[ id ].current + dir < @components[ id ].navigation.length | |
@components[ id ].current += dir | |
i = @inFocus.length | |
while i-- | |
if multiselect | |
@softUnselectElement @inFocus[ i ], @components[ id ].navigation[ @components[ id ].current ] | |
else | |
if @inFocus[i] isnt id | |
@unselectElement @inFocus[ i ], @components[ id ].navigation[ @components[ id ].current ] | |
if @components[ id ].navigation[ @components[ id ].current ]? | |
@selectElement @components[ id ].navigation[ @components[ id ].current ] | |
return | |
# Navigate forward | |
# | |
# @param [String] id | |
# @param [Boolean] multiselect | |
forward: (id, multiselect = false) -> | |
@navigate id, 1, multiselect | |
return | |
# Navigate backwards | |
# | |
# @param [String] id | |
# @param [Boolean] multiselect | |
back: (id, multiselect = false) -> | |
@navigate id, -1, multiselect | |
return | |
# Force focus on current component | |
# | |
# @param [String] id | |
# @param [Boolean] multiselect | |
focus: (id, multiselect = false) -> | |
@navigate id, 0, multiselect | |
return | |
# Navigate to specific elements | |
# | |
# @param [String] parent id to naigate in | |
# @param [String] id of child component to navigate to | |
# @param [Boolean] multiselect | |
navigateTo: (parent, id, multiselect = false) -> | |
return unless @components[ parent ]? | |
newIndex = @components[ parent ].navigation.indexOf id | |
return unless newIndex? or newIndex < 0 | |
@components[ parent ].current = newIndex | |
unless multiselect | |
i = @inFocus.length | |
while i-- | |
@unselectElement @inFocus[ i ], @components[ parent ].navigation[ @components[ parent ].current ] | |
if @components[ parent ].navigation[ @components[ parent ].current ]? | |
@selectElement @components[ parent ].navigation[ @components[ parent ].current ] | |
return | |
# Add component to navigation | |
# | |
# @param [String] parent | |
# @param [String] id | |
# @param [Number] position | |
addToNavigation: (parent, id, position) -> | |
parentComponent = @components[ parent ] | |
return unless parentComponent? | |
return if parentComponent.navigation.indexOf(id) > -1 | |
position = parentComponent.navigation.length unless position? or !position | |
@components[ parent ].navigation.splice position, 0, id | |
return | |
# Remove component to navigation | |
# | |
# @param [String] parent | |
# @param [String] id | |
removeFromNavigation: (parent, id) -> | |
parentComponent = @components[ parent ] | |
return unless parentComponent? | |
index = parentComponent.navigation.indexOf id | |
return if index < 0 | |
@components[ parent ].navigation.splice index, 1 | |
return | |
# Get navigation position of component | |
# | |
# @param [String] parent | |
# @param [String] id | |
# @return [Number] current navigation index | |
getPosition: (parent, id) -> | |
parentComponent = @components[ parent ] | |
return null unless parentComponent? | |
return parentComponent.navigation.indexOf id | |
# Get navigation position of component from the bottom | |
# | |
# @param [String] parent | |
# @param [String] id | |
# @return [Number] current navigation index from bottom | |
getPositionFromBottom: (parent, id) -> | |
position = @getPosition parent, id | |
bottom = @getNavigationLength(parent) - 1 | |
return bottom - position | |
# Get navigation length of component | |
# | |
# @param [String] id | |
# @return [Number] navigation length | |
getNavigationLength: (id) -> | |
component = @components[ id ] | |
return null unless component? | |
return component.navigation.length | |
# Reset component navigation to first element | |
# | |
# @param [String] id | |
# @param [Boolean] soft | |
resetNavigation: (id, soft) -> | |
return unless @components[ id ]? | |
@components[ id ].current = -1 | |
firstNavId = @components[ id ].navigation?[ 0 ] | |
if @focusOnFirstChild | |
# set focus to first in nav or parent | |
@hasFocus = firstNavId ? id | |
# reset this navigation | |
@inFocus = @inFocus.filter (i) => | |
!(i in @components[ id ].navigation) | |
@inFocus.push firstNavId if firstNavId? | |
return unless @useDOM and !soft | |
document.getElementById(firstNavId).focus() if document.getElementById(firstNavId)? #quicly fore first nav fcus to reset | |
document.getElementById(id).focus() if document.getElementById(id)? # fire browser .focus() event | |
return | |
# Clear navigation for component | |
# | |
# @param [String] id | |
clearNavigation: (id) -> | |
return unless @components[ id ]? | |
@components[ id ].navigation = [] | |
return | |
# Get parent component | |
# | |
# @param [String] id | |
# @return [Object] parent component | |
getParent: (id) -> | |
return unless @components[ id ]?.parent? | |
return @components[ @components[ id ].parent ] ? false | |
# Get component | |
# | |
# @param [String] id | |
# @return [Number] current navigation index | |
# @return [Object] component | |
getComponent: (id) => | |
return @components[ id ] ? false | |
# Programmatically trigger event | |
# | |
# @param [String] key | |
trigger: (key) => | |
@mousetrap.trigger key | |
return | |
# Pause keyboard events | |
# | |
# @param [Array<String>] types | |
pause: (types) => | |
return @mousetrap.pause() unless types? | |
for type in types | |
if @pausedEvents.indexOf(type) < 0 | |
@pausedEvents.push type | |
return | |
# Unpause keyboard events | |
# | |
# @param [Array<String>] types | |
unpause: (types) => | |
return @mousetrap.unpause() unless types? | |
for type in types | |
if @pausedEvents.indexOf(type) > -1 | |
@pausedEvents.splice @pausedEvents.indexOf(type), 1 | |
return | |
# Dispose of all children of a component | |
# | |
# @param [String] parent | |
disposeOfChildren: (parent) => | |
return unless @components[ parent ]? | |
for childId in @components[ parent ].navigation | |
delete @components[ childId ] | |
@components[ parent ].navigation = [] | |
return | |
# Dispose of component by id | |
# | |
# @param [String] id | |
# @param [Boolean] soft | |
disposeOf: (id, soft) => | |
return unless @components[ id ]? | |
# This removes the component reference, navigation and surpresses the event | |
{ parent } = @components[ id ] | |
# first off we need to remove and child components | |
for childId in @components[ id ].navigation | |
@disposeOf childId | |
@removeFromNavigation parent, id | |
# unselect element if it has focus | |
if (@hasFocus is id or @childHasFocus(id)) and !soft | |
# if the disposed element had focus | |
if @components[ parent ].navigation[ 0 ]? | |
# navigate to first navigation element in parent | |
@navigateTo parent, @components[ parent ].navigation[ 0 ] | |
else if @components[ @components[ parent ].parent ]?.navigation[ 0 ]? | |
# else navigate to parent | |
@navigateTo @components[ parent ].parent, parent | |
else | |
@hasFocus = null | |
delete @components[ id ] | |
return | |
# Dispose of mousetrap instance | |
# Must be called when this library has been removed | |
dispose: () => | |
@mousetrap.reset() | |
return | |
return Keymando |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment