require('../assets/NcActions-C4SuFczn.css'); "use strict"; const Components_NcButton = require("../Components/NcButton.cjs"); const NcPopover = require("./NcPopover-BKlH1mbx.cjs"); const GenRandomId = require("./GenRandomId-BQDud3d4.cjs"); const _l10n = require("./_l10n-CjO_W5Dt.cjs"); const focusTrap = require("./focusTrap-EeXFmjdI.cjs"); const core = require("@vueuse/core"); const Vue = require("vue"); const DotsHorizontal = require("./DotsHorizontal-BoI3vnhk.cjs"); const _pluginVue2_normalizer = require("./_plugin-vue2_normalizer-V0q-tHlQ.cjs"); const _interopDefault = (e) => e && e.__esModule ? e : { default: e }; const Vue__default = /* @__PURE__ */ _interopDefault(Vue); _l10n.register(_l10n.t4); const focusableSelector = ".focusable"; const _sfc_main = { name: "NcActions", components: { NcButton: Components_NcButton, NcPopover: NcPopover.NcPopover }, provide() { return { /** * NcActions can be used as: * - Application menu (has menu role) * - Expanded block (has no specific role, should be used an element with expanded role) * - Popover with plain text or text inputs (has no specific role) * Depending on the usage (used items), the menu and its items should have different roles for a11y. * Provide the role for NcAction* components in the NcActions content. * @type {import('vue').ComputedRef} */ "NcActions:isSemanticMenu": Vue.computed(() => this.actionsMenuSemanticType === "menu") }; }, props: { /** * Specify the open state of the popover menu */ open: { type: Boolean, default: false }, /** * This disables the internal open management, * so the actions menu only respects the `open` prop. * This is e.g. necessary for the NcAvatar component * to only open the actions menu after loading it's entries has finished. */ manualOpen: { type: Boolean, default: false }, /** * Force the actions to display in a three dot menu */ forceMenu: { type: Boolean, default: false }, /** * Force the name to show for single actions */ forceName: { type: Boolean, default: false }, /** * Specify the menu name */ menuName: { type: String, default: null }, /** * NcActions can be used as: * * - Application menu (has menu role) * - Navigation (has no specific role, should be used an element with expanded role) * - Popover with plain text or text inputs (has no specific role) * * By default the used type is automatically detected by components used in the default slot.# * * With Vue this is limited to direct children of the NcActions component. * So if you use a wrapper, you have to provide the semantic type yourself (see Example) * * Choose: * * - 'dialog' if you use any of these components: NcActionInput', 'NcActionTextEditable' * - 'menu' if you use any of these components: 'NcActionButton', 'NcActionButtonGroup', 'NcActionCheckbox', 'NcActionRadio' * - 'expanded' if using one of these: 'NcActionLink', 'NcActionRouter'. This represents an expanded block. * - 'tooltip' only to be used when a text without any interactive elements is used. * - Leave this property unset otherwise */ forceSemanticType: { type: String, default: null, validator(value) { return ["dialog", "menu", "expanded", "tooltip"].includes(value); } }, /** * Apply primary styling for this menu */ primary: { type: Boolean, default: false }, /** * Specifies the button type used for trigger and single actions buttons * Accepted values: primary, secondary, tertiary, tertiary-no-background, tertiary-on-primary, error, warning, success. If left empty, * the default button style will be applied. */ type: { type: String, validator(value) { return ["primary", "secondary", "tertiary", "tertiary-no-background", "tertiary-on-primary", "error", "warning", "success"].indexOf(value) !== -1; }, default: null }, /** * Icon to show for the toggle menu button * when more than one action is inside the actions component. * Only replace the default three-dot icon if really necessary. */ defaultIcon: { type: String, default: "" }, /** * Aria label for the actions menu. * * If `menuName` is defined this will not be used to prevent * any accessible name conflicts. This ensures that the * element can be activated via voice input. */ ariaLabel: { type: String, default: _l10n.t("Actions") }, /** * @deprecated To be removed in @nextcloud/vue 9. Migration guide: remove ariaHidden prop from NcAction* components. * @todo Add a check in @nextcloud/vue 9 that this prop is not provided, * otherwise root element will inherit incorrect aria-hidden. */ ariaHidden: { type: Boolean, default: null }, /** * Wanted direction of the menu */ placement: { type: String, default: "bottom" }, /** * DOM element for the actions' popover boundaries */ boundariesElement: { type: Element, default: () => document.querySelector("#content-vue") ?? document.querySelector("body") }, /** * Selector for the actions' popover container */ container: { type: [String, Object, Element, Boolean], default: "body" }, /** * Disabled state of the main button (single action or menu toggle) */ disabled: { type: Boolean, default: false }, /** * Display x items inline out of the dropdown menu * Will be ignored if `forceMenu` is set */ inline: { type: Number, default: 0 } }, emits: [ "open", "update:open", "close", "focus", "blur", "click" ], setup(props) { const randomId = `menu-${GenRandomId.GenRandomId()}`; const triggerRandomId = `trigger-${randomId}`; const triggerButton = Vue.ref(); const { top, bottom } = core.useElementBounding(triggerButton); const { top: boundaryTop, bottom: boundaryBottom } = core.useElementBounding(Vue.toRef(props, "boundariesElement")); const { height: windowHeight } = core.useWindowSize(); const maxMenuHeight = Vue.computed(() => Math.max( // Either expand to the top Math.min( // max height is the top position of the trigger minus the header height minus the wedge and the padding top.value - 84, // and also limited to the space in the boundary top.value - boundaryTop.value ), // or expand to the bottom Math.min( // the max height is the window height minus current position of the trigger minus the wedge and padding windowHeight.value - bottom.value - 34, // and limit to the available space in the boundary boundaryBottom.value - bottom.value ) )); return { triggerButton, maxMenuHeight, randomId, triggerRandomId }; }, data() { return { opened: this.open, focusIndex: 0, /** * @type {'menu'|'expanded'|'dialog'|'tooltip'|'unknown'} */ actionsMenuSemanticType: "unknown", externalFocusTrapStack: [] }; }, computed: { triggerBtnType() { return this.type || (this.primary ? "primary" : this.menuName ? "secondary" : "tertiary"); }, /** * A11y roles and keyboard navigation configuration depending on the semantic type */ config() { const configs = { menu: { popupRole: "menu", withArrowNavigation: true, withTabNavigation: false, withFocusTrap: false, triggerA11yAttr: { "aria-controls": this.opened ? this.randomId : null }, popoverContainerA11yAttrs: {}, popoverUlA11yAttrs: { "aria-labelledby": this.triggerRandomId, id: this.randomId, role: "menu" } }, expanded: { popupRole: void 0, withArrowNavigation: false, withTabNavigation: true, withFocusTrap: false, triggerA11yAttr: {}, popoverContainerA11yAttrs: {}, popoverUlA11yAttrs: {} }, dialog: { popupRole: "dialog", withArrowNavigation: false, withTabNavigation: true, withFocusTrap: true, triggerA11yAttr: { "aria-controls": this.opened ? this.randomId : null }, popoverContainerA11yAttrs: { id: this.randomId, role: "dialog", // Dialog must have a label "aria-labelledby": this.triggerRandomId, "aria-modal": "true" }, popoverUlA11yAttrs: {} }, tooltip: { popupRole: void 0, withArrowNavigation: false, withTabNavigation: false, withFocusTrap: false, triggerA11yAttr: {}, popoverContainerA11yAttrs: {}, popoverUlA11yAttrs: {} }, // Due to Vue limitations, we sometimes cannot determine the true type // As a fallback use both arrow navigation and focus trap unknown: { popupRole: void 0, role: void 0, withArrowNavigation: true, withTabNavigation: false, withFocusTrap: true, triggerA11yAttr: {}, popoverContainerA11yAttrs: {}, popoverUlA11yAttrs: { // there is nothing against labelling a list, it is mostly recommended // so as we do not know the dialog type lets include the label "aria-labelledby": this.triggerRandomId } } }; return configs[this.actionsMenuSemanticType]; } }, watch: { // Watch parent prop open(state) { if (state === this.opened) { return; } this.opened = state; }, opened() { this.intersectIntoCurrentFocusTrapStack(); if (this.opened) { document.body.addEventListener("keydown", this.handleEscapePressed); } else { document.body.removeEventListener("keydown", this.handleEscapePressed); } } }, methods: { /** * Get the name of the action component * * @param {import('vue').VNode} action - a vnode with a NcAction* component instance * @return {string} the name of the action component */ getActionName(action) { return action?.componentOptions?.Ctor?.extendOptions?.name ?? action?.componentOptions?.tag; }, /** * When the component has its own focus trap, then it is managed by global trap stack by focus-trap. * * However if the component has no focus trap and is used inside another focus trap - there is an issue. * By default popover content is rendered in body or other container, which is likely outside the current focus trap containers. * It results in broken behavior from focus-trap. * * We need to pause all the focus traps for opening popover and then unpause them back after closing. */ intersectIntoCurrentFocusTrapStack() { if (this.config.withFocusTrap) { return; } if (this.opened) { this.externalFocusTrapStack = [...focusTrap.getTrapStack()]; for (const trap of this.externalFocusTrapStack) { trap.pause(); } } else { for (const trap of this.externalFocusTrapStack) { trap.unpause(); } this.externalFocusTrapStack = []; } }, /** * Do we have exactly one Action and * is it allowed as a standalone element? * * @param {import('vue').VNode} action The action to check * @return {boolean} */ isValidSingleAction(action) { return ["NcActionButton", "NcActionLink", "NcActionRouter"].includes(this.getActionName(action)); }, /** * Check whether a icon prop value is an URL or not * @param {string} url The icon prop value */ isIconUrl(url) { try { return !!new URL(url, url.startsWith("/") ? window.location.origin : void 0); } catch (error) { return false; } }, // MENU STATE MANAGEMENT openMenu(e) { if (this.opened) { return; } this.opened = true; this.$emit("update:open", true); this.$emit("open"); }, async closeMenu(returnFocus = true) { if (!this.opened) { return; } await this.$nextTick(); this.opened = false; this.$refs.popover?.clearFocusTrap({ returnFocus }); this.$emit("update:open", false); this.$emit("close"); this.focusIndex = 0; if (returnFocus) { this.$refs.triggerButton?.$el.focus(); } }, onClosed() { this.$emit("closed"); }, /** * Called when popover is shown after the show delay */ onOpen() { this.$nextTick(() => { this.focusFirstAction(null); this.resizePopover(); }); }, /** * Handle resizing the popover to make sure users can discover there is more to scroll */ resizePopover() { const inner = this.$refs.menu.closest(".v-popper__inner"); const height = this.$refs.menu.clientHeight; if (height > this.maxMenuHeight) { let currentHeight = 0; let actionHeight = 0; for (const action of this.$refs.menuList.children) { if (currentHeight + action.clientHeight / 2 > this.maxMenuHeight) { inner.style.height = `${currentHeight - actionHeight / 2}px`; break; } actionHeight = action.clientHeight; currentHeight += actionHeight; } } else { inner.style.height = "fit-content"; } }, // MENU KEYS & FOCUS MANAGEMENT /** * @return {HTMLElement|null} */ getCurrentActiveMenuItemElement() { return this.$refs.menu.querySelector("li.active"); }, /** * @return {NodeListOf} */ getFocusableMenuItemElements() { return this.$refs.menu.querySelectorAll(focusableSelector); }, /** * Dispatches the keydown listener to different handlers * * @param {object} event The keydown event */ onKeydown(event) { if (event.key === "Tab") { if (this.config.withFocusTrap) { return; } if (!this.config.withTabNavigation) { this.closeMenu(true); return; } event.preventDefault(); const focusList = this.getFocusableMenuItemElements(); const focusIndex = [...focusList].indexOf(document.activeElement); if (focusIndex === -1) { return; } const newFocusIndex = event.shiftKey ? focusIndex - 1 : focusIndex + 1; if (newFocusIndex < 0 || newFocusIndex === focusList.length) { this.closeMenu(true); } this.focusIndex = newFocusIndex; this.focusAction(); return; } if (this.config.withArrowNavigation) { if (event.key === "ArrowUp") { this.focusPreviousAction(event); } if (event.key === "ArrowDown") { this.focusNextAction(event); } if (event.key === "PageUp") { this.focusFirstAction(event); } if (event.key === "PageDown") { this.focusLastAction(event); } } this.handleEscapePressed(event); }, onTriggerKeydown(event) { if (event.key === "Escape") { if (this.actionsMenuSemanticType === "tooltip") { this.closeMenu(); } } }, handleEscapePressed(event) { if (event.key === "Escape") { this.closeMenu(); event.preventDefault(); } }, removeCurrentActive() { const currentActiveElement = this.$refs.menu.querySelector("li.active"); if (currentActiveElement) { currentActiveElement.classList.remove("active"); } }, focusAction() { const focusElement = this.getFocusableMenuItemElements()[this.focusIndex]; if (focusElement) { this.removeCurrentActive(); const liMenuParent = focusElement.closest("li.action"); focusElement.focus(); if (liMenuParent) { liMenuParent.classList.add("active"); } } }, focusPreviousAction(event) { if (this.opened) { if (this.focusIndex === 0) { this.focusLastAction(event); } else { this.preventIfEvent(event); this.focusIndex = this.focusIndex - 1; } this.focusAction(); } }, focusNextAction(event) { if (this.opened) { const indexLength = this.getFocusableMenuItemElements().length - 1; if (this.focusIndex === indexLength) { this.focusFirstAction(event); } else { this.preventIfEvent(event); this.focusIndex = this.focusIndex + 1; } this.focusAction(); } }, focusFirstAction(event) { if (this.opened) { this.preventIfEvent(event); const firstCheckedIndex = [...this.getFocusableMenuItemElements()].findIndex((button) => { return button.getAttribute("aria-checked") === "true" && button.getAttribute("role") === "menuitemradio"; }); this.focusIndex = firstCheckedIndex > -1 ? firstCheckedIndex : 0; this.focusAction(); } }, focusLastAction(event) { if (this.opened) { this.preventIfEvent(event); this.focusIndex = this.getFocusableMenuItemElements().length - 1; this.focusAction(); } }, preventIfEvent(event) { if (event) { event.preventDefault(); event.stopPropagation(); } }, onFocus(event) { this.$emit("focus", event); }, onBlur(event) { this.$emit("blur", event); if (this.actionsMenuSemanticType === "tooltip") { if (this.$refs.menu && this.getFocusableMenuItemElements().length === 0) { this.closeMenu(false); } } }, onClick(event) { this.$emit("click", event); } }, /** * The render function to display the component * * @param {Function} h The function to create VNodes * @return {object|undefined} The created VNode */ render(h) { const actions = (this.$slots.default || []).filter((action) => this.getActionName(action)); if (actions.length === 0) { return; } let validInlineActions = actions.filter(this.isValidSingleAction); if (this.forceMenu && validInlineActions.length > 0 && this.inline > 0) { Vue__default.default.util.warn("Specifying forceMenu will ignore any inline actions rendering."); validInlineActions = []; } const inlineActions = validInlineActions.slice(0, this.inline); const menuActions = actions.filter((action) => !inlineActions.includes(action)); if (this.forceSemanticType) { this.actionsMenuSemanticType = this.forceSemanticType; } else { const textInputActions = ["NcActionInput", "NcActionTextEditable"]; const menuItemsActions = ["NcActionButton", "NcActionButtonGroup", "NcActionCheckbox", "NcActionRadio"]; const linkActions = ["NcActionLink", "NcActionRouter"]; const hasTextInputAction = menuActions.some((action) => textInputActions.includes(this.getActionName(action))); const hasMenuItemAction = menuActions.some((action) => menuItemsActions.includes(this.getActionName(action))); const hasLinkAction = menuActions.some((action) => linkActions.includes(this.getActionName(action))); if (hasTextInputAction) { this.actionsMenuSemanticType = "dialog"; } else if (hasMenuItemAction) { this.actionsMenuSemanticType = "menu"; } else if (hasLinkAction) { this.actionsMenuSemanticType = "expanded"; } else { const ncActions = actions.filter((action) => this.getActionName(action).startsWith("NcAction")); if (ncActions.length === actions.length) { this.actionsMenuSemanticType = "tooltip"; } else { this.actionsMenuSemanticType = "unknown"; } } } const renderInlineAction = (action) => { const iconProp = action?.componentOptions?.propsData?.icon; const icon = action?.data?.scopedSlots?.icon()?.[0] ?? (this.isIconUrl(iconProp) ? h("img", { class: "action-item__menutoggle__icon", attrs: { src: iconProp, alt: "" } }) : h("span", { class: ["icon", iconProp] })); const attrs = action?.data?.attrs || {}; const clickListener = action?.componentOptions?.listeners?.click; const text = action?.componentOptions?.children?.[0]?.text?.trim?.(); const ariaLabel = action?.componentOptions?.propsData?.ariaLabel || text; const buttonText = this.forceName ? text : ""; let title = action?.componentOptions?.propsData?.title; if (!(this.forceName || title)) { title = text; } const propsToForward = { ...action?.componentOptions?.propsData ?? {} }; const nativeType = ["submit", "reset"].includes(propsToForward.type) ? propsToForward.modelValue : "button"; delete propsToForward.modelValue; delete propsToForward.type; return h( "NcButton", { class: [ "action-item action-item--single", action?.data?.staticClass, action?.data?.class ], attrs: { ...attrs, "aria-label": ariaLabel, title }, ref: action?.data?.ref, props: { // If it has a menuName, we use a secondary button type: this.type || (buttonText ? "secondary" : "tertiary"), disabled: this.disabled || action?.componentOptions?.propsData?.disabled, pressed: action?.componentOptions?.propsData?.modelValue, nativeType, ...propsToForward }, on: { focus: this.onFocus, blur: this.onBlur, // forward any pressed state from NcButton just like NcActionButton does "update:pressed": action?.componentOptions?.listeners?.["update:modelValue"] ?? (() => { }), // If we have a click listener, // we bind it to execute on click and forward the click event ...!!clickListener && { click: (event) => { if (clickListener) { clickListener(event); } } } } }, [ h("template", { slot: "icon" }, [icon]), buttonText ] ); }; const renderActionsPopover = (actions2) => { const triggerIcon = this.$slots.icon?.[0] || (this.defaultIcon ? h("span", { class: ["icon", this.defaultIcon] }) : h(DotsHorizontal.DotsHorizontal, { props: { size: 20 } })); return h( "NcPopover", { ref: "popover", props: { delay: 0, handleResize: true, shown: this.opened, placement: this.placement, boundary: this.boundariesElement, container: this.container, popoverBaseClass: "action-item__popper", popupRole: this.config.popupRole, setReturnFocus: this.config.withFocusTrap ? this.$refs.triggerButton?.$el : null, focusTrap: this.config.withFocusTrap }, // For some reason the popover component // does not react to props given under the 'props' key, // so we use both 'attrs' and 'props' attrs: { delay: 0, handleResize: true, shown: this.opened, placement: this.placement, boundary: this.boundariesElement, container: this.container, ...this.manualOpen && { triggers: [] } }, on: { show: this.openMenu, "apply-show": this.onOpen, hide: this.closeMenu, "apply-hide": this.onClosed } }, [ h("NcButton", { class: "action-item__menutoggle", props: { type: this.triggerBtnType, disabled: this.disabled }, slot: "trigger", ref: "triggerButton", attrs: { id: this.triggerRandomId, "aria-label": this.menuName ? null : this.ariaLabel, ...this.config.triggerA11yAttr }, on: { focus: this.onFocus, blur: this.onBlur, click: this.onClick, keydown: this.onTriggerKeydown } }, [ h("template", { slot: "icon" }, [triggerIcon]), this.menuName ]), h("div", { class: { open: this.opened }, attrs: { tabindex: "-1", ...this.config.popoverContainerA11yAttrs }, on: { keydown: this.onKeydown }, ref: "menu" }, [ h("ul", { attrs: { tabindex: "-1", ...this.config.popoverUlA11yAttrs }, ref: "menuList" }, [ actions2 ]) ]) ] ); }; if (actions.length === 1 && validInlineActions.length === 1 && !this.forceMenu) { return renderInlineAction(actions[0]); } this.$nextTick(() => { if (this.opened && this.$refs.menu) { this.resizePopover(); const isAnyActive = this.$refs.menu.querySelector("li.active") || []; if (isAnyActive.length === 0) { this.focusFirstAction(); } } }); if (inlineActions.length > 0 && this.inline > 0) { return h( "div", { class: [ "action-items", `action-item--${this.triggerBtnType}` ] }, [ // Render inline actions ...inlineActions.map(renderInlineAction), // render the rest within the popover menu menuActions.length > 0 ? h( "div", { class: [ "action-item", { "action-item--open": this.opened } ] }, [ renderActionsPopover(menuActions) ] ) : null ] ); } return h( "div", { class: [ "action-item action-item--default-popover", `action-item--${this.triggerBtnType}`, { "action-item--open": this.opened } ] }, [ renderActionsPopover(actions) ] ); } }; const _sfc_render = null; const _sfc_staticRenderFns = null; var __component__ = /* @__PURE__ */ _pluginVue2_normalizer.normalizeComponent( _sfc_main, _sfc_render, _sfc_staticRenderFns, false, null, "60a4c99d" ); const NcActions = __component__.exports; exports.NcActions = NcActions;