element-web/src/components/views/elements/AccessibleButton.tsx

242 lines
7.4 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2016 Jani Mustonen
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, {
ComponentProps,
ComponentPropsWithoutRef,
forwardRef,
FunctionComponent,
ReactElement,
KeyboardEvent,
Ref,
} from "react";
import classnames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
export type ButtonEvent = React.MouseEvent<Element> | React.KeyboardEvent<Element> | React.FormEvent<Element>;
/**
* The kind of button, similar to how Bootstrap works.
*/
export type AccessibleButtonKind =
| "primary"
| "primary_outline"
| "primary_sm"
| "secondary"
| "secondary_content"
| "content_inline"
| "danger"
| "danger_outline"
| "danger_sm"
| "danger_inline"
| "link"
| "link_inline"
| "link_sm"
| "confirm_sm"
| "cancel_sm"
| "icon"
| "icon_primary"
| "icon_primary_outline";
type ElementType = keyof HTMLElementTagNameMap;
const defaultElement = "div";
type TooltipProps = ComponentProps<typeof Tooltip>;
/**
* Type of props accepted by {@link AccessibleButton}.
*
* Extends props accepted by the underlying element specified using the `element` prop.
*/
type Props<T extends ElementType = "div"> = {
/**
* The base element type. "div" by default.
*/
element?: T;
/**
* The kind of button, similar to how Bootstrap works.
*/
kind?: AccessibleButtonKind;
/**
* Whether the button should be disabled.
*/
disabled?: boolean;
/**
* Whether the button should trigger on mousedown event instead of on click event. Defaults to false (click event).
*/
triggerOnMouseDown?: boolean;
/**
* Event handler for button activation. Should be implemented exactly like a normal `onClick` handler.
*/
onClick: ((e: ButtonEvent) => void | Promise<void>) | null;
/**
* The tooltip to show on hover or focus.
*/
title?: string;
/**
* The caption is a secondary text displayed under the `title` of the tooltip.
* Only valid when used in conjunction with `title`.
*/
caption?: TooltipProps["caption"];
/**
* The placement of the tooltip.
*/
placement?: TooltipProps["placement"];
/**
* Callback for when the tooltip is opened or closed.
*/
onTooltipOpenChange?: TooltipProps["onOpenChange"];
/**
* Whether the tooltip should be disabled.
*/
disableTooltip?: TooltipProps["disabled"];
};
export type ButtonProps<T extends ElementType> = Props<T> & Omit<ComponentPropsWithoutRef<T>, keyof Props<T>>;
/**
* Type of the props passed to the element that is rendered by AccessibleButton.
*/
type RenderedElementProps<T extends ElementType> = React.InputHTMLAttributes<Element> & RefProp<T>;
/**
* AccessibleButton is a generic wrapper for any element that should be treated
* as a button. Identifies the element as a button, setting proper tab
* indexing and keyboard activation behavior.
*
* If a ref is passed, it will be forwarded to the rendered element as specified using the `element` prop.
*
* @param {Object} props react element properties
* @returns {Object} rendered react
*/
const AccessibleButton = forwardRef(function <T extends ElementType = typeof defaultElement>(
{
element,
onClick,
children,
kind,
disabled,
className,
onKeyDown,
onKeyUp,
triggerOnMouseDown,
title,
caption,
placement = "right",
onTooltipOpenChange,
disableTooltip,
...restProps
}: ButtonProps<T>,
ref: Ref<HTMLElementTagNameMap[T]>,
): JSX.Element {
const newProps = restProps as RenderedElementProps<T>;
newProps["aria-label"] = newProps["aria-label"] ?? title;
if (disabled) {
newProps["aria-disabled"] = true;
newProps["disabled"] = true;
} else {
if (triggerOnMouseDown) {
newProps.onMouseDown = onClick ?? undefined;
} else {
newProps.onClick = onClick ?? undefined;
}
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
// that might receive focus as a result of the AccessibleButtonClick action
// It's because we are using html buttons at a few places e.g. inside dialogs
// And divs which we report as role button to assistive technologies.
// Browsers handle space and enter key presses differently and we are only adjusting to the
// inconsistencies here
newProps.onKeyDown = (e: KeyboardEvent<never>) => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Enter:
e.stopPropagation();
e.preventDefault();
return onClick?.(e);
case KeyBindingAction.Space:
e.stopPropagation();
e.preventDefault();
break;
default:
onKeyDown?.(e);
}
};
newProps.onKeyUp = (e: KeyboardEvent<never>) => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Enter:
e.stopPropagation();
e.preventDefault();
break;
case KeyBindingAction.Space:
e.stopPropagation();
e.preventDefault();
return onClick?.(e);
default:
onKeyUp?.(e);
break;
}
};
}
// Pass through the ref - used for keyboard shortcut access to some buttons
newProps.ref = ref;
newProps.className = classnames("mx_AccessibleButton", className, {
mx_AccessibleButton_hasKind: kind,
[`mx_AccessibleButton_kind_${kind}`]: kind,
mx_AccessibleButton_disabled: disabled,
});
// React.createElement expects InputHTMLAttributes
const button = React.createElement(element ?? defaultElement, newProps, children);
if (title) {
return (
<Tooltip
description={title}
caption={caption}
isTriggerInteractive={true}
placement={placement}
onOpenChange={onTooltipOpenChange}
disabled={disableTooltip}
>
{button}
</Tooltip>
);
}
return button;
});
// Type assertion required due to forwardRef type workaround in react.d.ts
(AccessibleButton as FunctionComponent).defaultProps = {
role: "button",
tabIndex: 0,
};
(AccessibleButton as FunctionComponent).displayName = "AccessibleButton";
interface RefProp<T extends ElementType> {
ref?: Ref<HTMLElementTagNameMap[T]>;
}
interface ButtonComponent {
// With the explicit `element` prop
<C extends ElementType>(props: { element?: C } & ButtonProps<C> & RefProp<C>): ReactElement;
// Without the explicit `element` prop
(props: ButtonProps<"div"> & RefProp<"div">): ReactElement;
}
export default AccessibleButton as ButtonComponent;