Skip to content

Commit c9cc3e7

Browse files
authored
Merge pull request #5153 from nextcloud-libraries/fix/nc-actions--keyboard-navigation
fix(NcActions): keyboard navigation
2 parents 0965f37 + f26764a commit c9cc3e7

File tree

1 file changed

+105
-55
lines changed

1 file changed

+105
-55
lines changed

src/components/NcActions/NcActions.vue

Lines changed: 105 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- @author John Molakvoæ <[email protected]>
55
- @author Marco Ambrosini <[email protected]>
66
- @author Raimund Schlüßler <[email protected]>
7+
- @author Grigorii K. Shartsev <[email protected]>
78
-
89
- @license GNU AGPL version 3 or any later version
910
-
@@ -697,8 +698,8 @@ export default {
697698
</NcActions>
698699
</p>
699700

700-
<h2>Popover</h2>
701-
Has any elements, including text input element, or no buttons.
701+
<h2>Dialog</h2>
702+
Includes data input elements
702703
<p>
703704
<NcActions aria-label="Group management">
704705
<NcActionInput trailing-button-label="Submit" label="Rename group">
@@ -714,6 +715,19 @@ export default {
714715
</NcActionButton>
715716
</NcActions>
716717
</p>
718+
719+
<h2>Toolip</h2>
720+
Has only text and not interactive elements
721+
<p>
722+
<NcActions aria-label="Contact" :inline="1">
723+
<NcActionLink aria-label="View profile" href="/u/alice" icon="icon-user-white">
724+
View profile
725+
</NcActionLink>
726+
<NcActionText icon="icon-timezone-white">
727+
Local time: 10:12
728+
</NcActionText>
729+
</NcActions>
730+
</p>
717731
</div>
718732
</template>
719733

@@ -793,7 +807,6 @@ p {
793807
}
794808
</style>
795809
```
796-
797810
</docs>
798811

799812
<script>
@@ -835,7 +848,7 @@ export default {
835848
* Provide the role for NcAction* components in the NcActions content.
836849
* @type {import('vue').ComputedRef<boolean>}
837850
*/
838-
'NcActions:isSemanticMenu': computed(() => this.isSemanticMenu),
851+
'NcActions:isSemanticMenu': computed(() => this.actionsMenuSemanticType === 'menu'),
839852
}
840853
},
841854
@@ -992,9 +1005,10 @@ export default {
9921005
opened: this.open,
9931006
focusIndex: 0,
9941007
randomId: `menu-${GenRandomId()}`,
995-
isSemanticMenu: false,
996-
isSemanticNavigation: false,
997-
isSemanticPopoverLike: false,
1008+
/**
1009+
* @type {'menu'|'navigation'|'dialog'|'tooltip'|''}
1010+
*/
1011+
actionsMenuSemanticType: '',
9981012
}
9991013
},
10001014
@@ -1006,6 +1020,10 @@ export default {
10061020
// If it has a name, we use a secondary button
10071021
: this.menuName ? 'secondary' : 'tertiary')
10081022
},
1023+
1024+
withFocusTrap() {
1025+
return this.actionsMenuSemanticType === 'dialog'
1026+
},
10091027
},
10101028
10111029
watch: {
@@ -1020,16 +1038,25 @@ export default {
10201038
},
10211039
10221040
methods: {
1041+
/**
1042+
* Get the name of the action component
1043+
*
1044+
* @param {import('vue').VNode} action - a vnode with a NcAction* component instance
1045+
* @return {string} the name of the action component
1046+
*/
1047+
getActionName(action) {
1048+
return action?.componentOptions?.Ctor?.extendOptions?.name ?? action?.componentOptions?.tag
1049+
},
1050+
10231051
/**
10241052
* Do we have exactly one Action and
10251053
* is it allowed as a standalone element?
10261054
*
1027-
* @param {Array} action The action to check
1055+
* @param {import('vue').VNode} action The action to check
10281056
* @return {boolean}
10291057
*/
10301058
isValidSingleAction(action) {
1031-
const componentName = action?.componentOptions?.Ctor?.extendOptions?.name ?? action?.componentOptions?.tag
1032-
return ['NcActionButton', 'NcActionLink', 'NcActionRouter'].includes(componentName)
1059+
return ['NcActionButton', 'NcActionLink', 'NcActionRouter'].includes(this.getActionName(action))
10331060
},
10341061
10351062
/**
@@ -1088,8 +1115,10 @@ export default {
10881115
// close everything
10891116
this.focusIndex = 0
10901117
1091-
// focus back the menu button
1092-
this.$refs.menuButton.$el.focus()
1118+
if (returnFocus) {
1119+
// Focus back the menu button
1120+
this.$refs.menuButton.$el.focus()
1121+
}
10931122
},
10941123
10951124
onOpen(event) {
@@ -1127,8 +1156,10 @@ export default {
11271156
* @param {object} event The keydown event
11281157
*/
11291158
onKeydown(event) {
1130-
if (event.key === 'Tab' && !this.isSemanticPopoverLike) {
1131-
this.closeMenu(false)
1159+
if (event.key === 'Tab' && !this.withFocusTrap) {
1160+
// Return focus to restore Tab sequence
1161+
// So browser will correctly move focus to the next element
1162+
this.closeMenu(true)
11321163
}
11331164
11341165
if (event.key === 'ArrowUp') {
@@ -1222,6 +1253,12 @@ export default {
12221253
},
12231254
onBlur(event) {
12241255
this.$emit('blur', event)
1256+
1257+
// When there is no focusable elements to handle Tab press from actions menu
1258+
// It requries manual closing
1259+
if (this.actionsMenuSemanticType === 'tooltip') {
1260+
this.closeMenu(false)
1261+
}
12251262
},
12261263
onClick(event) {
12271264
/**
@@ -1248,46 +1285,64 @@ export default {
12481285
* This also ensure that we don't get 'text' elements, which would
12491286
* become problematic later on.
12501287
*/
1251-
const actions = (this.$slots.default || []).filter(
1252-
action => action?.componentOptions?.tag || action?.componentOptions?.Ctor?.extendOptions?.name,
1253-
)
1254-
1255-
const getActionName = (action) => action?.componentOptions?.Ctor?.extendOptions?.name ?? action?.componentOptions?.tag
1256-
1257-
const menuItemsActions = ['NcActionButton', 'NcActionButtonGroup', 'NcActionCheckbox', 'NcActionRadio']
1258-
const textInputActions = ['NcActionInput', 'NcActionTextEditable']
1259-
const linkActions = ['NcActionLink', 'NcActionRouter']
1288+
const actions = (this.$slots.default || []).filter(action => this.getActionName(action))
12601289
1261-
const hasTextInputAction = actions.some(action => textInputActions.includes(getActionName(action)))
1262-
const hasMenuItemAction = actions.some(action => menuItemsActions.includes(getActionName(action)))
1263-
const hasLinkAction = actions.some(action => linkActions.includes(getActionName(action)))
1264-
1265-
// We consider the NcActions to have role="menu" if it consists some button-like action and not text inputs
1266-
this.isSemanticMenu = hasMenuItemAction && !hasTextInputAction
1267-
// We consider the NcActions to be navigation if it consists some link-like action
1268-
this.isSemanticNavigation = hasLinkAction && !hasMenuItemAction && !hasTextInputAction
1269-
// If it is not a menu and not a navigation, it is a popover with items: a form or just a text
1270-
this.isSemanticPopoverLike = !this.isSemanticMenu && !this.isSemanticNavigation
1290+
// Check that we have at least one action
1291+
if (actions.length === 0) {
1292+
return
1293+
}
12711294
1272-
const popupRole = this.isSemanticMenu
1273-
? 'menu'
1274-
: hasTextInputAction
1275-
? 'dialog'
1276-
: 'true'
1295+
/**
1296+
* Separate the actions into inline and menu actions
1297+
*/
12771298
12781299
/**
1279-
* Filter and list actions that are allowed to be displayed inline
1300+
* @type {import('vue').VNode[]}
12801301
*/
1281-
let inlineActions = actions.filter(this.isValidSingleAction)
1282-
if (this.forceMenu && inlineActions.length > 0 && this.inline > 0) {
1302+
let validInlineActions = actions.filter(this.isValidSingleAction)
1303+
if (this.forceMenu && validInlineActions.length > 0 && this.inline > 0) {
12831304
Vue.util.warn('Specifying forceMenu will ignore any inline actions rendering.')
1284-
inlineActions = []
1305+
validInlineActions = []
12851306
}
1307+
/**
1308+
* @type {import('vue').VNode[]}
1309+
*/
1310+
const inlineActions = validInlineActions.slice(0, this.inline)
1311+
/**
1312+
* @type {import('vue').VNode[]}
1313+
*/
1314+
const menuActions = actions.filter(action => !inlineActions.includes(action))
12861315
1287-
// Check that we have at least one action
1288-
if (actions.length === 0) {
1289-
return
1316+
/**
1317+
* Determine what kind of menu we have.
1318+
* It defines focus behavior and a11y.
1319+
*/
1320+
1321+
const menuItemsActions = ['NcActionButton', 'NcActionButtonGroup', 'NcActionCheckbox', 'NcActionRadio']
1322+
const textInputActions = ['NcActionInput', 'NcActionTextEditable']
1323+
const linkActions = ['NcActionLink', 'NcActionRouter']
1324+
1325+
const hasTextInputAction = menuActions.some(action => textInputActions.includes(this.getActionName(action)))
1326+
const hasMenuItemAction = menuActions.some(action => menuItemsActions.includes(this.getActionName(action)))
1327+
const hasLinkAction = menuActions.some(action => linkActions.includes(this.getActionName(action)))
1328+
1329+
if (hasTextInputAction) {
1330+
this.actionsMenuSemanticType = 'dialog'
1331+
} else if (hasMenuItemAction) {
1332+
this.actionsMenuSemanticType = 'menu'
1333+
} else if (hasLinkAction) {
1334+
this.actionsMenuSemanticType = 'navigation'
1335+
} else {
1336+
this.actionsMenuSemanticType = 'tooltip'
1337+
}
1338+
1339+
const actionsRoleToHtmlPopupRole = {
1340+
dialog: 'dialog',
1341+
menu: 'menu',
1342+
navigation: 'true',
1343+
tooltip: 'true',
12901344
}
1345+
const popupRole = actionsRoleToHtmlPopupRole[this.actionsMenuSemanticType]
12911346
12921347
/**
12931348
* Render the provided action
@@ -1393,10 +1448,8 @@ export default {
13931448
container: this.container,
13941449
popoverBaseClass: 'action-item__popper',
13951450
popupRole,
1396-
// Menu and navigation should not have focus trap
1397-
// Tab should close the menu and move focus to the next UI element
1398-
setReturnFocus: !this.isSemanticPopoverLike ? null : this.$refs.menuButton?.$el,
1399-
focusTrap: this.isSemanticPopoverLike,
1451+
setReturnFocus: this.withFocusTrap ? this.$refs.menuButton?.$el : null,
1452+
focusTrap: this.withFocusTrap,
14001453
},
14011454
// For some reason the popover component
14021455
// does not react to props given under the 'props' key,
@@ -1470,8 +1523,8 @@ export default {
14701523
* If we have a single action only and didn't force a menu,
14711524
* we render the action as a standalone button
14721525
*/
1473-
if (actions.length === 1 && inlineActions.length === 1 && !this.forceMenu) {
1474-
return renderInlineAction(inlineActions[0])
1526+
if (actions.length === 1 && validInlineActions.length === 1 && !this.forceMenu) {
1527+
return renderInlineAction(actions[0])
14751528
}
14761529
14771530
// If we completely re-render the children
@@ -1490,9 +1543,6 @@ export default {
14901543
* If we some inline actions to render, render them, then the menu
14911544
*/
14921545
if (inlineActions.length > 0 && this.inline > 0) {
1493-
const renderedInlineActions = inlineActions.slice(0, this.inline)
1494-
// Filter already rendered actions
1495-
const menuActions = actions.filter(action => !renderedInlineActions.includes(action))
14961546
return h('div',
14971547
{
14981548
class: [
@@ -1502,7 +1552,7 @@ export default {
15021552
},
15031553
[
15041554
// Render inline actions
1505-
...renderedInlineActions.map(renderInlineAction),
1555+
...inlineActions.map(renderInlineAction),
15061556
// render the rest within the popover menu
15071557
menuActions.length > 0
15081558
? h('div',

0 commit comments

Comments
 (0)