Skip to content
This repository has been archived by the owner on Mar 4, 2020. It is now read-only.

[RFC]Feat(Accessibility): Add keyboard action handlers #121

Merged
merged 21 commits into from
Sep 7, 2018

Conversation

sophieH29
Copy link
Contributor

This PR introduces the initial implementation for keyboard action handlers.

Actions are defined in Component - Component does know what it can do
Keys mapping to actions are defined in Accessibility behavior (actionsDefinition) - Component's actions can be invoked by different keys depending on Accessibility behavior needs

If the Component has matching actions with it's accessibility behavior actionsDefinition, then onKeyDown handler will be added to the rest props in renderComponent.tsx with appropriate actions called and filtered by keys.

Actions were added to MenuItem for now, so by pressing Enter or Space, click handler will be invoked.

@@ -58,11 +60,47 @@ const getAccessibility = <P extends {}>(props, state) => {
})
}

/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this function is now long enough to be separated to a separate file (similarly to how renderComponent is in its own ts file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, will move to separate file

@codecov
Copy link

codecov bot commented Aug 23, 2018

Codecov Report

Merging #121 into master will decrease coverage by 0.09%.
The diff coverage is 50%.

Impacted file tree graph

@@            Coverage Diff            @@
##           master     #121     +/-   ##
=========================================
- Coverage   89.32%   89.23%   -0.1%     
=========================================
  Files          50       50             
  Lines         834      836      +2     
  Branches      119      119             
=========================================
+ Hits          745      746      +1     
- Misses         85       86      +1     
  Partials        4        4
Impacted Files Coverage Δ
src/components/Menu/MenuItem.tsx 89.28% <50%> (-3.03%) ⬇️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 012fcad...c7af853. Read the comment docs.

if (!actions || !actionsDefinition) return

let hasCommonActions = false
for (const actionName in actionsDefinition) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not significantly shorter, but easier to read?

const hasCommonActions = _.intersection(_.keys(actionsDefinition), _.keys(actions)).length > 0

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely, thanks! Still can't get used to lodash, like vanilla :)

)
eventHandler && eventHandler(event)
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also refactor this with iterating the elements of the .intersection(.keys(actionsDefinition), _.keys(actions)) that Miro suggested

@mnajdova
Copy link
Contributor

Everything looks ok so far, but I would suggest adding some tests for covering this, especially because is something general used in the renderComponent.

@sophieH29
Copy link
Contributor Author

@mnajdova sure, working on it right now


export interface KeyCombinations {
keyCode: number
shiftKey?: boolean
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather use default values instead of optional, as this will cause compiler error when we enable strict null check options.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smykhailov Make sense. But as it's interface, we'll need to set these values for each key combination. Or you meant some other usage?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is OK to leave it as it is. This should work with strict null checks, see the condition in keyboardHandlerFilter

@sophieH29 sophieH29 added the RFC label Aug 24, 2018
@@ -76,6 +77,9 @@ const renderComponent = <P extends {}>(
const ElementType = getElementType({ defaultProps }, props)
const rest = getUnhandledProps({ handledProps }, props)

const accessibility = getAccessibility(props, state)
addKeyDownHandler(rest, actions, accessibility, props)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since rest/props are component centric ideas, proposing we merge these in the addKeyDownHandler signature so that the method is not aware of internal component implementations.

addKeyDownHandler({ ...rest, ...props }, actions, accessibility)

Now, there is just a props argument if you will.

export interface IAccessibilityDefinition {
attributes?: AccessibilityAttributes
keyHandlers?: AccessibilityKeyHandlers
actionsDefinition?: ActionsDefinition
handlers?: ActionsHandler
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's separate handlers to a separate interface so that they can't be defined in the behavior but can only be a result of getAccessibility

Copy link
Contributor Author

@sophieH29 sophieH29 Aug 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jurokapsiar, If I understood correctly, we should have something like

export interface IAccessibilityBehavior {
  attributes?: AccessibilityAttributes
  handlers?: ActionsHandler
}

or

export interface IAccessibilityBehavior extends IAccessibilityDefinition {
  handlers?: ActionsHandler
}

as a result of getAccessibility?

accessibility: IAccessibilityDefinition,
props: IRenderConfigProps,
) => {
accessibility.handlers = {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might lead to a state when global accessibility object is modified. instead of modification, create a copy of the object, for example using

const result = {
  {...accessibility},
  handlers: {}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, will change

/**
* Assigns onKeyDown handler to the Component's part element, based on Component's actions
* and keys mappings defined in Accessibility behavior
* @param {AccessibilityActions} actions The input element which is to loose focus.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update param descriptions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, thx :)

@@ -11,6 +12,14 @@ const MenuItemBehavior: Accessibility = (props: any) => ({
tabIndex: '0',
},
},

actionsDefinition: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we could use simpler name like just actions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but it can break the meaning of it, especially during mapping real actions of a component to these. As for me, this name really means what it does.

Fix description for method param

# Conflicts:
#	src/lib/accessibility/Behaviors/Menu/MenuItemBehavior.ts
#	src/lib/accessibility/interfaces.ts
#	src/lib/renderComponent.tsx
Copy link
Member

@miroslavstastny miroslavstastny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving, please take a look into the comments added

(keysCombinations.metaKey && !metaKey) ||
(keysCombinations.ctrlKey && !ctrlKey)
) {
return null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The predicate should return boolean, so false here.
You can also use lodash some to improve performance, and (nit) put keyCode comparison first in the condition for short-circuit evaluation:

const { shiftKey, altKey, metaKey, ctrlKey } = event
const isHandled = _.some(keysCombinations, keysCombination => (
    keysCombination.keyCode === keyboardKey.getCode(event) &&
    (!keysCombination.altKey || altKey) &&
    (!keysCombination.shiftKey || shiftKey) &&
    (!keysCombination.metaKey || metaKey) &&
    (!keysCombination.ctrlKey || ctrlKey) 
  ))
if (isHandled) {
  handler(event)
}


export interface KeyCombinations {
keyCode: number
shiftKey?: boolean
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is OK to leave it as it is. This should work with strict null checks, see the condition in keyboardHandlerFilter


actionsDefinition: {
anchor: {
performClick: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this name seems to be a bit confusing: essentially, it reads like make a click when key is pressed - while, essentially, the intent is to select menu item (this would describe what we'd like to achieve, not how - by click or something else). What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes and no :) Totally agree with your point to have a defined intent in actions' namings. But, in the current case, we want to trigger click event by pressing Enter or Space. Let's assume that the click handler will be changed to make some other action (not selecting a menu item).
But our intent will stay the same on Enter and Space do whatever click does
And then, if we'll need to have an action for selecting a menu item, we can create it separately.

* Assigns onKeyDown handler to the Component's part element, based on Component's actions
* and keys mappings defined in Accessibility behavior
* @param {AccessibilityActions} actions Actions defined in a component.
* @param {IAccessibilityDefinition} accessibility The input element which is to loose focus.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems that description of this param is incorrect

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like I am blind :) thank you, people, for reading it!

{childrenExist(children) ? (
children
) : (
<a
className={cx('ui-menu__item__anchor', classes.anchor)}
onClick={this.handleClick}
{...accessibility.attributes.anchor}
{...accessibility.handlers.anchor}
Copy link
Contributor

@kuzhelov kuzhelov Sep 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handlers seems to be a name that quite loosely describes the purpose of this member (as the name is very generic) - so it is hard to get the intent of the client code by just reading it. Maybe we could make it more expressive by introducing name like keyHandlers, which it, in fact, is

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree, I'll rename it if everyone is ok with that.


class UIComponent<P, S> extends React.Component<P, S> {
private readonly childClass = this.constructor as typeof UIComponent
static defaultProps: { [key: string]: any }
static displayName: string
static className: string
static handledProps: any
protected actions: AccessibilityActions
Copy link
Contributor

@kuzhelov kuzhelov Sep 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest to make this name to be a bit more precise in terms of semantics. Here we are not defining the actions, but rather defining action handlers - the logic that should be performed by the component if specific action (or 'intent') has been raised by behavior. So, would suggest to rename to actionHandlers - this will also make it easy to read all the code snippets where this prop is used

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having a bit different point of view, you might disagree :)
A component has actions - a map of things it can do. They can be raised or not, based on behavior now. The behavior could even don't exist. But the intent Hey, I am component and can do next things which you can call stays. In some time we can change our mind, and replace behaviors with something else, but still component will have actions which can be called directly or indirectly - by adding it to keyboard handlers.

* @param {IAccessibilityDefinition} accessibility The input element which is to loose focus.
* @param {IState & IPropsWithVarsAndStyles} props The props which are used to invoke onKeyDown handler passed from top.
*/
const getKeyDownHandlers = (
Copy link
Contributor

@kuzhelov kuzhelov Sep 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here also would suggest to do couple of renamings, so that it would be easier to read:

  • actions -> componentActionHandlers

  • actionsDefinition -> behaviorActionDefinitions (which those, in fact, are)

  • elementName -> componentPart (this naming corresponds to the common semantics of anatomy)

  • currentActionDef -> componentPartActionDefinitions (as a consequence of previous renamings)

  • commonActions -> handledActions (as common term rather describes 'how' those are fetched, and not the intent - obtain actions that are supported/handled by component)

this will result in the following code - this one seems to be more readable, as well as its intent is much easier to grasp:

const getKeyDownHandlers = (
  componentActionHandlers: AccessibilityActions,
  accessibility: IAccessibilityDefinition,
  props: IRenderConfigProps,
): ActionsHandler => {
  const keyDownHandlers = {}
  const behaviorActionDefinitions = accessibility.actionsDefinition

  if (!componentActionHandlers || !behaviorActionDefinitions) return keyDownHandlers

  for (const componentPartName in behaviorActionDefinitions) {
    const componentPartActionDefinitions = behaviorActionDefinitions[componentPartName]
    const handledActions = _.intersection(_.keys(componentPartActionDefinitions), _.keys(componentActionHandlers))
    if (!handledActions.length) continue

    keyDownHandlers[componentPartName] = {
      onKeyDown: (event: React.KeyboardEvent) => {
        handledActions.forEach(actionName => {
          const eventHandler = keyboardHandlerFilter(
            componentActionHandlers[actionName],
            componentPartActionDefinitions[actionName].keyCombinations,
          )
          eventHandler && eventHandler(event)
        })

        _.invoke(props, 'onKeyDown', event)
      },
    }
  }

  return keyDownHandlers
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please, let me know about your thoughts on that

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree on this suggestions. But referring to comment above, I would make componentActionHandlers -> componentActions

props: IState & IPropsWithVarsAndStyles,
): ActionsHandler => {
const handlers = {}
const actionsDefinition = accessibility.actionsDefinition
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, but still am haunted by thought of renaming actionsDefinition part for the following reasons:

  • those actions are currently provided for key events - it is better to reflect this in naming
  • it seems that we could achieve better consistency if we'll use more specific actions <-> handlers part

Regarding second point, wouldn't it be better to use accessibility.keyActions for defining them, and accessibility.keyHandlers for those handlers that were fetched from supported component's actions? This seems to be much more expressive, due to now we do immediately see (just by reading the code) that only key handling concept is handled by accessibility, as well as handlers are populated from general actionHandlers defined by component (this 'general' part is necessary, due to these component's action handlers could be consumed later to map actions from other sources, like joystick or whatever :)

handledProps: ['aria-label', 'aria-labelledby'],

actionsDefinition: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one should be keyActions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@sophieH29 sophieH29 merged commit 534829f into master Sep 7, 2018
@sophieH29 sophieH29 deleted the feat/keydown-action-handlers branch September 7, 2018 09:01
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants