tethys.backend/resources/js/Components/NcActions.vue

775 lines
26 KiB
Vue
Raw Permalink Normal View History

<script lang="ts">
// import NcButton from '../NcButton/index.js'
import BaseButton from '@/Components/BaseButton.vue';
// import NcPopover from '../NcPopover/index.js'
import GenRandomId from '../utils/GenRandomId';
// import { t } from '../../l10n.js'
import { computed } from 'vue';
import DotsHorizontal from '@/Components/Icons/DotsHorizontal.vue';
const focusableSelector = '.focusable';
/**
* The Actions component can be used to display one ore more actions.
* If only a single action is provided, it will be rendered as an inline icon.
* For more, a menu indicator will be shown and a popovermenu containing the
* actions will be opened on click.
*
* @since 0.10.0
*/
export default {
name: 'NcActions',
components: {
// NcButton,
BaseButton,
DotsHorizontal,
// 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>}
*/
'NcActions:isSemanticMenu': computed(() => this.isSemanticMenu),
};
},
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,
},
/**
* 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: string) {
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: '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('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'],
data() {
return {
opened: this.open,
focusIndex: 0,
randomId: `menu-${GenRandomId()}`,
isSemanticMenu: false,
isSemanticNavigation: false,
isSemanticPopoverLike: false,
};
},
computed: {
triggerBtnType() {
// If requested, we use a primary button
return (
this.type ||
(this.primary
? 'primary'
: // If it has a name, we use a secondary button
this.menuName
? 'secondary'
: 'tertiary')
);
},
},
watch: {
// Watch parent prop
open(state) {
if (state === this.opened) {
return;
}
this.opened = state;
},
},
methods: {
/**
* Do we have exactly one Action and
* is it allowed as a standalone element?
*
* @param {Array} action The action to check
* @return {boolean}
*/
isValidSingleAction(action) {
const componentName = action?.componentOptions?.Ctor?.extendOptions?.name ?? action?.componentOptions?.tag;
return ['NcActionButton', 'NcActionLink', 'NcActionRouter'].includes(componentName);
},
// MENU STATE MANAGEMENT
openMenu() {
if (this.opened) {
return;
}
this.opened = true;
/**
* Event emitted when the popover menu open state is changed
*
* @type {boolean}
*/
this.$emit('update:open', true);
/**
* Event emitted when the popover menu is opened
*/
this.$emit('open');
},
closeMenu(returnFocus = true) {
if (!this.opened) {
return;
}
this.opened = false;
this.$refs.popover.clearFocusTrap({ returnFocus });
/**
* Event emitted when the popover menu open state is changed
*
* @type {boolean}
*/
this.$emit('update:open', false);
/**
* Event emitted when the popover menu is closed
*/
this.$emit('close');
// close everything
this.focusIndex = 0;
// focus back the menu button
this.$refs.menuButton.$el.focus();
},
onOpen(event) {
this.$nextTick(() => {
this.focusFirstAction(event);
});
},
// MENU KEYS & FOCUS MANAGEMENT
// focus nearest focusable item on mouse move
// DO NOT change the focus if the target is already focused
// this will prevent issues with input being unfocused
// on mouse move
onMouseFocusAction(event) {
if (document.activeElement === event.target) {
return;
}
const menuItem = event.target.closest('li');
if (menuItem && this.$refs.menu.contains(menuItem)) {
const focusableItem = menuItem.querySelector(focusableSelector);
if (focusableItem) {
const focusList = this.$refs.menu.querySelectorAll(focusableSelector);
const focusIndex = [...focusList].indexOf(focusableItem);
if (focusIndex > -1) {
this.focusIndex = focusIndex;
this.focusAction();
}
}
}
},
/**
* Dispatches the keydown listener to different handlers
*
* @param {object} event The keydown event
*/
onKeydown(event) {
if (event.key === 'Tab' && !this.isSemanticPopoverLike) {
this.closeMenu(false);
}
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);
}
if (event.key === 'Escape') {
this.closeMenu();
event.preventDefault();
}
},
removeCurrentActive() {
const currentActiveElement = this.$refs.menu.querySelector('li.active');
if (currentActiveElement) {
currentActiveElement.classList.remove('active');
}
},
focusAction() {
// TODO: have a global disabled state for non input elements
const focusElement = this.$refs.menu.querySelectorAll(focusableSelector)[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.$refs.menu.querySelectorAll(focusableSelector).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);
// In case a button is considered aria-selected we will use this one as a initial focus
const firstSelectedIndex = [...this.$refs.menu.querySelectorAll(focusableSelector)].findIndex((button) => {
return button.parentElement.getAttribute('aria-selected');
});
this.focusIndex = firstSelectedIndex > -1 ? firstSelectedIndex : 0;
this.focusAction();
}
},
focusLastAction(event) {
if (this.opened) {
this.preventIfEvent(event);
this.focusIndex = this.$refs.menu.querySelectorAll(focusableSelector).length - 1;
this.focusAction();
}
},
preventIfEvent(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
},
onFocus(event) {
this.$emit('focus', event);
},
onBlur(event) {
this.$emit('blur', event);
},
},
/**
* The render function to display the component
*
* @param {Function} h The function to create VNodes
* @return {object|undefined} The created VNode
*/
render(h) {
/**
* 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) => action?.componentOptions?.tag || action?.componentOptions?.Ctor?.extendOptions?.name,
);
const getActionName = (action) => action?.componentOptions?.Ctor?.extendOptions?.name ?? action?.componentOptions?.tag;
const menuItemsActions = ['NcActionButton', 'NcActionButtonGroup', 'NcActionCheckbox', 'NcActionRadio'];
const textInputActions = ['NcActionInput', 'NcActionTextEditable'];
const linkActions = ['NcActionLink', 'NcActionRouter'];
const hasTextInputAction = actions.some((action) => textInputActions.includes(getActionName(action)));
const hasMenuItemAction = actions.some((action) => menuItemsActions.includes(getActionName(action)));
const hasLinkAction = actions.some((action) => linkActions.includes(getActionName(action)));
// We consider the NcActions to have role="menu" if it consists some button-like action and not text inputs
this.isSemanticMenu = hasMenuItemAction && !hasTextInputAction;
// We consider the NcActions to be navigation if it consists some link-like action
this.isSemanticNavigation = hasLinkAction && !hasMenuItemAction && !hasTextInputAction;
// If it is no a manu and not a navigation, it is a popover with items: a form or just a text
this.isSemanticPopoverLike = !this.isSemanticMenu && !this.isSemanticNavigation;
/**
* Filter and list actions that are allowed to be displayed inline
*/
let inlineActions = actions.filter(this.isValidSingleAction);
if (this.forceMenu && inlineActions.length > 0 && this.inline > 0) {
// Vue.util.warn('Specifying forceMenu will ignore any inline actions rendering.');
inlineActions = [];
}
// Check that we have at least one action
if (actions.length === 0) {
return;
}
/**
* Render the provided action
*
* @param {import('vue').VNode} action the action to render
* @return {Function} the vue render function
*/
const renderInlineAction = (action) => {
const icon =
action?.data?.scopedSlots?.icon()?.[0] || h('span', { class: ['icon', action?.componentOptions?.propsData?.icon] });
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;
// Show a default title for single actions if none is present
if (!(this.forceName || title)) {
title = text;
}
return h(
'BaseButton',
{
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,
...action?.componentOptions?.propsData,
},
on: {
focus: this.onFocus,
blur: this.onBlur,
// 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],
);
};
/**
* Render the actions popover
*
* @param {Array} actions the actions to render within
* @return {Function} the vue render function
*/
const renderActionsPopover = (actions) => {
const triggerIcon =
this.$slots.icon?.[0] ||
(this.defaultIcon
? h('span', { class: ['icon', this.defaultIcon] })
: h('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',
// Menu and navigation should not have focus trap
// Tab should close the menu and move focus to the next UI element
setReturnFocus: !this.isSemanticPopoverLike ? null : this.$refs.menuButton?.$el,
focusTrap: this.isSemanticPopoverLike,
},
// 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,
'after-show': this.onOpen,
'hide': this.closeMenu,
},
},
[
h(
'BaseButton',
{
class: 'action-item__menutoggle',
props: {
type: this.triggerBtnType,
disabled: this.disabled,
},
slot: 'trigger',
ref: 'menuButton',
attrs: {
'aria-haspopup': this.isSemanticMenu ? 'menu' : null,
'aria-label': this.menuName ? null : this.ariaLabel,
'aria-controls': this.opened ? this.randomId : null,
'aria-expanded': this.opened ? 'true' : 'false',
},
on: {
focus: this.onFocus,
blur: this.onBlur,
},
},
[h('template', { slot: 'icon' }, [triggerIcon]), this.menuName],
),
h(
'div',
{
class: {
open: this.opened,
},
attrs: {
tabindex: '-1',
},
on: {
keydown: this.onKeydown,
mousemove: this.onMouseFocusAction,
},
ref: 'menu',
},
[
h(
'ul',
{
attrs: {
id: this.randomId,
tabindex: '-1',
role: this.isSemanticMenu ? 'menu' : undefined,
},
},
[actions],
),
],
),
],
);
};
/**
* If we have a single action only and didn't force a menu,
* we render the action as a standalone button
*/
if (actions.length === 1 && inlineActions.length === 1 && !this.forceMenu) {
return renderInlineAction(inlineActions[0]);
}
// If we completely re-render the children
// we need to focus the first action again
// Mostly used when clicking a menu item
this.$nextTick(() => {
if (this.opened && this.$refs.menu) {
const isAnyActive = this.$refs.menu.querySelector('li.active') || [];
if (isAnyActive.length === 0) {
this.focusFirstAction();
}
}
});
/**
* If we some inline actions to render, render them, then the menu
*/
if (inlineActions.length > 0 && this.inline > 0) {
const renderedInlineActions = inlineActions.slice(0, this.inline);
// Filter already rendered actions
const menuActions = actions.filter((action) => !renderedInlineActions.includes(action));
return h(
'div',
{
class: ['action-items', `action-item--${this.triggerBtnType}`],
},
[
// Render inline actions
...renderedInlineActions.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,
],
);
}
/**
* Otherwise, we render the actions in a popover
*/
return h(
'div',
{
class: [
'action-item action-item--default-popover',
`action-item--${this.triggerBtnType}`,
{
'action-item--open': this.opened,
},
],
},
[renderActionsPopover(actions)],
);
},
};
</script>
<style lang="css" scoped>
/* Inline buttons */
.action-items {
display: flex;
align-items: center;
/* Spacing between buttons */
/* &>button {
margin-right: math.div(28, 2);
} */
}
.action-item {
/* --open-background-color: var(--color-background-hover, $action-background-hover); */
position: relative;
display: inline-block;
&.action-item--primary {
--open-background-color: var(--color-primary-element-hover);
}
&.action-item--secondary {
--open-background-color: var(--color-primary-element-light-hover);
}
&.action-item--error {
--open-background-color: var(--color-error-hover);
}
&.action-item--warning {
--open-background-color: var(--color-warning-hover);
}
&.action-item--success {
--open-background-color: var(--color-success-hover);
}
&.action-item--tertiary-no-background {
--open-background-color: transparent;
}
&.action-item--open .action-item__menutoggle {
background-color: var(--open-background-color);
}
}
</style>
<style lang="css">
/* // We overwrote the popover base class, so we can style
// the popover__inner for actions only. */
.v-popper--theme-dropdown.v-popper__popper.action-item__popper .v-popper__wrapper {
border-radius: var(--border-radius-large);
overflow: hidden;
.v-popper__inner {
border-radius: var(--border-radius-large);
padding: 4px;
max-height: calc(50vh - 16px);
overflow: auto;
}
}
</style>