Skip to content

Instantly share code, notes, and snippets.

@samternent
Created September 13, 2022 13:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save samternent/874309b987a18de776be3c904c749930 to your computer and use it in GitHub Desktop.
Save samternent/874309b987a18de776be3c904c749930 to your computer and use it in GitHub Desktop.
keymando
# ## 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