From 2a82eb6c3d324276941598d465abec5d063e198a Mon Sep 17 00:00:00 2001 From: "Grigorii K. Shartsev" Date: Tue, 12 Nov 2024 00:23:35 +0100 Subject: [PATCH 1/4] feat(NcActions): add submenus feature Signed-off-by: Grigorii K. Shartsev --- .../NcActionButton/NcActionButton.vue | 16 +++- src/components/NcActions/NcActions.vue | 89 ++++++++++++++++++- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/components/NcActionButton/NcActionButton.vue b/src/components/NcActionButton/NcActionButton.vue index f638495651..52d44bdf46 100644 --- a/src/components/NcActionButton/NcActionButton.vue +++ b/src/components/NcActionButton/NcActionButton.vue @@ -387,6 +387,10 @@ export default { from: 'NcActions:isSemanticMenu', default: false, }, + openSubmenu: { + from: 'NcActions:openSubmenu', + default: () => () => {}, + }, }, props: { @@ -409,11 +413,12 @@ export default { }, /** - * If this is a menu, a chevron icon will - * be added at the end of the line + * Weather the button opens a submenu + * - Boolean value makes button looks like a submenu opener + * - String value can be used to open a specific submenu slot in NcActions */ isMenu: { - type: Boolean, + type: [Boolean, String], default: false, }, @@ -518,6 +523,11 @@ export default { */ handleClick(event) { this.onClick(event) + + if (typeof this.isMenu === 'string') { + this.openSubmenu(this.isMenu) + } + // If modelValue or type is set (so modelValue might be null for tri-state) we need to update it if (this.modelValue !== null || this.type !== 'button') { if (this.type === 'radio') { diff --git a/src/components/NcActions/NcActions.vue b/src/components/NcActions/NcActions.vue index dabad14826..60b949c8d5 100644 --- a/src/components/NcActions/NcActions.vue +++ b/src/components/NcActions/NcActions.vue @@ -788,6 +788,39 @@ p { ``` +## Submenus + +To create multi-level menus: +- Add `` where NAME is the name of the submenu +- Pass submenu to a `submenu:NAME` slot + +```vue + +``` + ## NcActions children limitations `` is supposed to be used with direct `` children. @@ -959,6 +992,8 @@ import { useElementBounding, useWindowSize } from '@vueuse/core' import Vue, { ref, computed, toRef } from 'vue' import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue' +import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue' +import NcActionButton from '../NcActionButton/NcActionButton.vue' const focusableSelector = '.focusable' @@ -990,6 +1025,7 @@ export default { * @type {import('vue').ComputedRef} */ 'NcActions:isSemanticMenu': computed(() => this.actionsMenuSemanticType === 'menu'), + 'NcActions:openSubmenu': this.pushSubmenu, } }, @@ -1208,6 +1244,8 @@ export default { return { opened: this.open, focusIndex: 0, + submenuStack: [], + submenuIndexStack: [], /** * @type {'menu'|'expanded'|'dialog'|'tooltip'|'unknown'} */ @@ -1407,6 +1445,22 @@ export default { } }, + pushSubmenu(submenu) { + this.submenuStack.push(submenu) + this.submenuIndexStack.push(this.focusIndex) + this.$nextTick(() => { + this.focusFirstAction() + }) + }, + + popSubmenu() { + this.submenuStack.pop() + this.focusIndex = this.submenuIndexStack.pop() + this.$nextTick(() => { + this.focusAction() + }) + }, + // MENU STATE MANAGEMENT openMenu(e) { if (this.opened) { @@ -1432,6 +1486,12 @@ export default { return } + // Only close submenu + if (this.submenuStack.length) { + this.popSubmenu() + return + } + // Wait for the next tick to keep the menu in DOM, allowing other components to find what button in what menu was used, // for example, to implement auto set return focus. // NcPopover will actually remove the menu from DOM also on the next tick. @@ -1615,6 +1675,14 @@ export default { this.handleEscapePressed(event) }, + onKeyup(event) { + // Escape is handled globally on body by keydown + // Prevent floating-vue to handle it again on keyup + if (event.key === 'Escape') { + event.stopPropagation() + } + }, + onTriggerKeydown(event) { if (event.key === 'Escape') { // Tooltip has no focusable elements and the focus remains on the trigger button. @@ -1736,12 +1804,14 @@ export default { * @return {object|undefined} The created VNode */ render(h) { + const menuSlot = this.submenuStack.length ? `submenu:${this.submenuStack.at(-1)}` : 'default' + /** * Filter the Actions, so that we only get allowed components. * This also ensure that we don't get 'text' elements, which would * become problematic later on. */ - const actions = (this.$slots.default || []).filter(action => this.getActionName(action)) + const actions = (this.$slots[menuSlot] || []).filter(action => this.getActionName(action)) // Check that we have at least one action if (actions.length === 0) { @@ -1769,6 +1839,20 @@ export default { */ const menuActions = actions.filter(action => !inlineActions.includes(action)) + if (this.submenuStack.length) { + const backButton = h(NcActionButton, { + on: { + click: () => { + this.popSubmenu() + }, + }, + }, [ + t('Back'), + h(IconArrowLeft, { slot: 'icon' }), + ]) + menuActions.unshift(backButton) + } + /** * Determine what kind of menu we have. * It defines keyboard navigation and a11y. @@ -1968,6 +2052,7 @@ export default { ...this.config.popoverContainerA11yAttrs, }, on: { + keyup: this.onKeyup, keydown: this.onKeydown, mousemove: this.onMouseFocusAction, }, @@ -2054,7 +2139,7 @@ export default { ], }, [ - renderActionsPopover(actions), + renderActionsPopover(menuActions), ], ) }, From 6a8dbdcfed80f503cb9ed2e6c66bb95b144d4aa8 Mon Sep 17 00:00:00 2001 From: "Grigorii K. Shartsev" Date: Tue, 12 Nov 2024 13:31:40 +0100 Subject: [PATCH 2/4] fixup! feat(NcActions): add submenus feature --- src/components/NcActions/NcActions.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/NcActions/NcActions.vue b/src/components/NcActions/NcActions.vue index 60b949c8d5..135dd1736d 100644 --- a/src/components/NcActions/NcActions.vue +++ b/src/components/NcActions/NcActions.vue @@ -1446,6 +1446,11 @@ export default { }, pushSubmenu(submenu) { + // Only allow existing submenus + if (!this.$slots[`submenu:${submenu}`]) { + return + } + this.submenuStack.push(submenu) this.submenuIndexStack.push(this.focusIndex) this.$nextTick(() => { From 3b3b3b06f39536bde9aa7fe74b6a0a25e9a0e625 Mon Sep 17 00:00:00 2001 From: "Grigorii K. Shartsev" Date: Tue, 12 Nov 2024 14:55:00 +0100 Subject: [PATCH 3/4] fixup! feat(NcActions): add submenus feature --- src/components/NcActionButton/NcActionButton.vue | 14 +++++++++++++- src/components/NcActions/NcActions.vue | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/components/NcActionButton/NcActionButton.vue b/src/components/NcActionButton/NcActionButton.vue index 52d44bdf46..ba7e206130 100644 --- a/src/components/NcActionButton/NcActionButton.vue +++ b/src/components/NcActionButton/NcActionButton.vue @@ -323,7 +323,8 @@ export default { :title="title" :type="nativeType" v-bind="buttonAttributes" - @click="handleClick"> + @click="handleClick" + @keydown="handleKeydown"> diff --git a/src/components/NcActions/NcActions.vue b/src/components/NcActions/NcActions.vue index 135dd1736d..c6b5b3bbe4 100644 --- a/src/components/NcActions/NcActions.vue +++ b/src/components/NcActions/NcActions.vue @@ -1459,6 +1459,10 @@ export default { }, popSubmenu() { + if (!this.submenuStack.length) { + return + } + this.submenuStack.pop() this.focusIndex = this.submenuIndexStack.pop() this.$nextTick(() => { @@ -1851,6 +1855,13 @@ export default { this.popSubmenu() }, }, + nativeOn: { + keydown: (event) => { + if (event.key === 'ArrowLeft') { + this.popSubmenu() + } + }, + }, }, [ t('Back'), h(IconArrowLeft, { slot: 'icon' }), From 5535db146d1dd61a9e36dbe26e98e10ecf485985 Mon Sep 17 00:00:00 2001 From: "Grigorii K. Shartsev" Date: Tue, 12 Nov 2024 14:55:22 +0100 Subject: [PATCH 4/4] fixup! feat(NcActions): add submenus feature --- src/components/NcActionButton/NcActionButton.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/NcActionButton/NcActionButton.vue b/src/components/NcActionButton/NcActionButton.vue index ba7e206130..c19b2dcf8b 100644 --- a/src/components/NcActionButton/NcActionButton.vue +++ b/src/components/NcActionButton/NcActionButton.vue @@ -414,7 +414,7 @@ export default { }, /** - * Weather the button opens a submenu + * Whether the button opens a submenu * - Boolean value makes button looks like a submenu opener * - String value can be used to open a specific submenu slot in NcActions */