-
Notifications
You must be signed in to change notification settings - Fork 55
[RFC]Feat(Accessibility): Add keyboard action handlers #121
Conversation
src/lib/renderComponent.tsx
Outdated
@@ -58,11 +60,47 @@ const getAccessibility = <P extends {}>(props, state) => { | |||
}) | |||
} | |||
|
|||
/** |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 Report
@@ 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
Continue to review full report at Codecov.
|
src/lib/addKeyDownHandler.ts
Outdated
if (!actions || !actionsDefinition) return | ||
|
||
let hasCommonActions = false | ||
for (const actionName in actionsDefinition) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 :)
src/lib/addKeyDownHandler.ts
Outdated
) | ||
eventHandler && eventHandler(event) | ||
} | ||
|
There was a problem hiding this comment.
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
Everything looks ok so far, but I would suggest adding some tests for covering this, especially because is something general used in the renderComponent. |
@mnajdova sure, working on it right now |
|
||
export interface KeyCombinations { | ||
keyCode: number | ||
shiftKey?: boolean |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
src/lib/renderComponent.tsx
Outdated
@@ -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) |
There was a problem hiding this comment.
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.
src/lib/accessibility/interfaces.ts
Outdated
export interface IAccessibilityDefinition { | ||
attributes?: AccessibilityAttributes | ||
keyHandlers?: AccessibilityKeyHandlers | ||
actionsDefinition?: ActionsDefinition | ||
handlers?: ActionsHandler |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
src/lib/addKeyDownHandler.ts
Outdated
accessibility: IAccessibilityDefinition, | ||
props: IRenderConfigProps, | ||
) => { | ||
accessibility.handlers = {} |
There was a problem hiding this comment.
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: {}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, will change
src/lib/getKeyDownHandlers.ts
Outdated
/** | ||
* 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please update param descriptions
There was a problem hiding this comment.
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: { |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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
There was a problem hiding this 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
src/lib/keyboardHandlerFilter.ts
Outdated
(keysCombinations.metaKey && !metaKey) || | ||
(keysCombinations.ctrlKey && !ctrlKey) | ||
) { | ||
return null |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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: { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
src/lib/getKeyDownHandlers.ts
Outdated
* 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. |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
src/components/Menu/MenuItem.tsx
Outdated
{childrenExist(children) ? ( | ||
children | ||
) : ( | ||
<a | ||
className={cx('ui-menu__item__anchor', classes.anchor)} | ||
onClick={this.handleClick} | ||
{...accessibility.attributes.anchor} | ||
{...accessibility.handlers.anchor} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
src/lib/UIComponent.tsx
Outdated
|
||
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
src/lib/getKeyDownHandlers.ts
Outdated
* @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 = ( |
There was a problem hiding this comment.
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
(ascommon
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
}
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
src/lib/getKeyDownHandlers.ts
Outdated
props: IState & IPropsWithVarsAndStyles, | ||
): ActionsHandler => { | ||
const handlers = {} | ||
const actionsDefinition = accessibility.actionsDefinition |
There was a problem hiding this comment.
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: { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you!
This PR introduces the initial implementation for keyboard action handlers.
Actions
are defined in Component - Component does know what it can doKeys mapping
to actions are defined in Accessibility behavior (actionsDefinition
) - Component's actions can be invoked by different keys depending on Accessibility behavior needsIf the Component has matching
actions
with it's accessibility behavioractionsDefinition
, thenonKeyDown
handler will be added to therest
props inrenderComponent.tsx
with appropriate actions called and filtered by keys.Actions
were added toMenuItem
for now, so by pressingEnter
orSpace
, click handler will be invoked.