UNPKG

@nextcloud/vue

Version:
767 lines (766 loc) 23.6 kB
import '../assets/NcActions-DOI7rRa0.css'; import { createElementBlock, openBlock, mergeProps, createElementVNode, createCommentVNode, toDisplayString, Comment, Fragment, Text, warn, h, computed } from "vue"; import { _ as _export_sfc } from "./_plugin-vue_export-helper-1tPrXgE0.mjs"; import { u as useTrapStackControl } from "./useTrapStackControl-B6cEicto.mjs"; import { r as register, o as t4, a as t } from "./_l10n-DrTiip5c.mjs"; import { c as createElementId } from "./createElementId-DhjFt1I9.mjs"; import { N as NcButton } from "./NcButton-Dc8V4Urj.mjs"; import { N as NcPopover } from "./NcPopover-C-MTaPCs.mjs"; import { N as NC_ACTIONS_CLOSE_MENU, a as NC_ACTIONS_IS_SEMANTIC_MENU } from "./useNcActions-CiGWxAJE.mjs"; const _sfc_main$1 = { name: "DotsHorizontalIcon", emits: ["click"], props: { title: { type: String }, fillColor: { type: String, default: "currentColor" }, size: { type: Number, default: 24 } } }; const _hoisted_1 = ["aria-hidden", "aria-label"]; const _hoisted_2 = ["fill", "width", "height"]; const _hoisted_3 = { d: "M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z" }; const _hoisted_4 = { key: 0 }; function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { return openBlock(), createElementBlock("span", mergeProps(_ctx.$attrs, { "aria-hidden": $props.title ? null : "true", "aria-label": $props.title, class: "material-design-icon dots-horizontal-icon", role: "img", onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("click", $event)) }), [ (openBlock(), createElementBlock("svg", { fill: $props.fillColor, class: "material-design-icon__svg", width: $props.size, height: $props.size, viewBox: "0 0 24 24" }, [ createElementVNode("path", _hoisted_3, [ $props.title ? (openBlock(), createElementBlock("title", _hoisted_4, toDisplayString($props.title), 1)) : createCommentVNode("", true) ]) ], 8, _hoisted_2)) ], 16, _hoisted_1); } const IconDotsHorizontal = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["render", _sfc_render]]); register(t4); function isSlotPopulated(vnodes) { return Array.isArray(vnodes) && vnodes.some((node) => { if (node === null) { return false; } else if (typeof node === "object") { const vnode = node; if (vnode.type === Comment) { return false; } else if (vnode.type === Fragment && !isSlotPopulated(vnode.children)) { return false; } else if (vnode.type === Text && !vnode.children.trim()) { return false; } } return true; }); } const focusableSelector = ".focusable"; const _sfc_main = { name: "NcActions", components: { NcButton, NcPopover }, provide() { return { /** * NcActions can be used as: * - Application menu (has menu role) * - Navigation (has no specific role, should be used an element with navigation 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<boolean>} */ [NC_ACTIONS_IS_SEMANTIC_MENU]: computed(() => this.actionsMenuSemanticType === "menu"), [NC_ACTIONS_CLOSE_MENU]: this.closeMenu }; }, 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 }, /** * Apply primary styling for this menu */ primary: { type: Boolean, default: false }, /** * 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: t("Actions") }, /** * Wanted direction of the menu */ placement: { type: String, default: "bottom" }, /** * DOM element for the actions' popover boundaries */ boundariesElement: { type: Element, default: () => document.getElementById("content-vue") ?? document.querySelector("body") }, /** * Selector for the actions' popover container */ container: { type: [Boolean, String, Object, Element], 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 }, /** * Specifies the button variant used for trigger and single actions buttons. * * If left empty, the default button style will be applied. * * @since 8.23.0 */ variant: { type: String, validator(value) { return ["primary", "secondary", "tertiary", "tertiary-no-background", "tertiary-on-primary", "error", "warning", "success"].includes(value); }, default: null }, /** * Specify the size used for trigger and single actions buttons. * * If left empty, the default button size will be applied. */ size: { type: String, default: "normal", validator(value) { return ["small", "normal", "large"].includes(value); } } }, emits: [ "click", "blur", "focus", "close", "closed", "open", "opened", "update:open" ], setup() { const randomId = createElementId(); return { randomId }; }, data() { return { opened: this.open, focusIndex: 0, /** * @type {'menu'|'navigation'|'dialog'|'tooltip'|'unknown'} */ actionsMenuSemanticType: "unknown" }; }, computed: { triggerButtonVariant() { return this.variant || (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 }, navigation: { popupRole: void 0, withArrowNavigation: false, withTabNavigation: true, withFocusTrap: false }, dialog: { popupRole: "dialog", withArrowNavigation: false, withTabNavigation: true, withFocusTrap: true }, tooltip: { popupRole: void 0, withArrowNavigation: false, withTabNavigation: false, withFocusTrap: false }, // 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 } }; return configs[this.actionsMenuSemanticType]; }, withFocusTrap() { return this.config.withFocusTrap; } }, watch: { // Watch parent prop open(state) { if (state === this.opened) { return; } this.opened = state; }, opened() { if (this.opened) { document.body.addEventListener("keydown", this.handleEscapePressed); } else { document.body.removeEventListener("keydown", this.handleEscapePressed); } } }, created() { useTrapStackControl(() => this.opened, { disabled: () => this.config.withFocusTrap }); if ("ariaHidden" in this.$attrs) { warn("[NcActions]: Do not set the ariaHidden attribute as the root element will inherit the incorrect aria-hidden."); } }, 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?.type?.name; }, /** * 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)); }, isAction(action) { return this.getActionName(action)?.startsWith?.("NcAction"); }, /** * 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 { return false; } }, // MENU STATE MANAGEMENT toggleMenu(state) { if (state) { this.openMenu(); } else { this.closeMenu(); } }, openMenu() { 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(); } }, /** * Called when popover is shown after the show delay */ onOpened() { this.$nextTick(() => { this.focusFirstAction(null); this.$emit("opened"); }); }, onClosed() { this.$emit("closed"); }, // MENU KEYS & FOCUS MANAGEMENT /** * @return {HTMLElement|null} */ getCurrentActiveMenuItemElement() { return this.$refs.menu.querySelector("li.active"); }, /** * @return {NodeList<HTMLElement>} */ 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 * * @return {object|undefined} The created VNode */ render() { const actions = []; const findActions = (vnodes, actions2) => { vnodes.forEach((vnode) => { if (this.isAction(vnode)) { actions2.push(vnode); return; } if (vnode.type === Fragment) { findActions(vnode.children, actions2); } }); }; findActions(this.$slots.default?.(), actions); if (actions.length === 0) { return; } let validInlineActions = actions.filter(this.isValidSingleAction); if (this.forceMenu && validInlineActions.length > 0 && this.inline > 0) { 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)); const menuItemsActions = ["NcActionButton", "NcActionButtonGroup", "NcActionCheckbox", "NcActionRadio"]; const textInputActions = ["NcActionInput", "NcActionTextEditable"]; 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 = "navigation"; } 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?.props?.icon; const icon = action?.children?.icon?.()?.[0] ?? (this.isIconUrl(iconProp) ? h("img", { class: "action-item__menutoggle__icon", src: iconProp, alt: "" }) : h("span", { class: ["icon", iconProp] })); const text = action?.children?.default?.()?.[0]?.children?.trim(); const buttonText = this.forceName ? text : ""; let title = action?.props?.title; if (!(this.forceName || title)) { title = text; } const propsToForward = { ...action?.props ?? {} }; const type = ["submit", "reset"].includes(propsToForward.type) ? propsToForward.modelValue : "button"; delete propsToForward.modelValue; delete propsToForward.type; return h( NcButton, mergeProps( propsToForward, { class: "action-item action-item--single", "aria-label": action?.props?.["aria-label"] || text, title, disabled: this.disabled || action?.props?.disabled, pressed: action?.props?.modelValue, size: this.size, type, // If it has a menuName, we use a secondary button variant: this.variant || (buttonText ? "secondary" : "tertiary"), onFocus: this.onFocus, onBlur: this.onBlur, // forward any pressed state from NcButton just like NcActionButton does "onUpdate:pressed": action?.props?.["onUpdate:modelValue"] ?? (() => { }) } ), { default: () => buttonText, icon: () => icon } ); }; const renderActionsPopover = (actions2) => { const triggerIcon = isSlotPopulated(this.$slots.icon?.()) ? this.$slots.icon?.() : this.defaultIcon ? h("span", { class: ["icon", this.defaultIcon] }) : h(IconDotsHorizontal, { size: 20 }); const triggerRandomId = `${this.randomId}-trigger`; return h( NcPopover, { ref: "popover", delay: 0, shown: this.opened, placement: this.placement, boundary: this.boundariesElement, autoBoundaryMaxSize: true, container: this.container, ...this.manualOpen && { triggers: [] }, noCloseOnClickOutside: this.manualOpen, popoverBaseClass: "action-item__popper", popupRole: this.config.popupRole, setReturnFocus: this.config.withFocusTrap ? this.$refs.triggerButton?.$el : void 0, noFocusTrap: !this.config.withFocusTrap, "onUpdate:shown": this.toggleMenu, onAfterShow: this.onOpened, onAfterClose: this.onClosed }, { trigger: () => h(NcButton, { id: triggerRandomId, class: "action-item__menutoggle", disabled: this.disabled, size: this.size, variant: this.triggerButtonVariant, ref: "triggerButton", "aria-label": this.menuName ? null : this.ariaLabel, // 'aria-controls' should only present together with a valid aria-haspopup "aria-controls": this.opened && this.config.popupRole ? this.randomId : null, onFocus: this.onFocus, onBlur: this.onBlur, onClick: this.onClick, onKeydown: this.onTriggerKeydown }, { icon: () => triggerIcon, default: () => this.menuName }), default: () => h("div", { class: { open: this.opened }, tabindex: "-1", onKeydown: this.onKeydown, ref: "menu" }, [ h("ul", { id: this.randomId, tabindex: "-1", ref: "menuList", role: this.config.popupRole, // For most roles a label is required (dialog, menu), but also in general nothing speaks against labelling a list. // It is even recommended to do so. "aria-labelledby": triggerRandomId, "aria-modal": this.actionsMenuSemanticType === "dialog" ? "true" : void 0 }, [ actions2 ]) ]) } ); }; if (actions.length === 1 && validInlineActions.length === 1 && !this.forceMenu) { return renderInlineAction(actions[0]); } this.$nextTick(() => { if (this.opened && this.$refs.menu) { 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.triggerButtonVariant}` ] }, [ // 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.triggerButtonVariant}`, { "action-item--open": this.opened } ] }, [ renderActionsPopover(actions) ] ); } }; const NcActions = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-5f7eed6b"]]); export { IconDotsHorizontal as I, NcActions as N, isSlotPopulated as i }; //# sourceMappingURL=NcActions-DWmvh7-Y.mjs.map