@nextcloud/vue
Version:
Nextcloud vue components
767 lines (766 loc) • 23.6 kB
JavaScript
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