Skip to content

Commit

Permalink
fix(Tag): Add keyboard accessibility (#7060)
Browse files Browse the repository at this point in the history
Co-authored-by: svc-changelog <[email protected]>
  • Loading branch information
jscheiny and svc-changelog authored Nov 13, 2024
1 parent 96f1ae4 commit b7b44d0
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 72 deletions.
5 changes: 5 additions & 0 deletions packages/core/changelog/@unreleased/pr-7060.v2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type: fix
fix:
description: 'fix(Tag): Add keyboard accessibility'
links:
- https://github.com/palantir/blueprint/pull/7060
88 changes: 88 additions & 0 deletions packages/core/src/accessibility/useInteractiveAttributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* !
* (c) Copyright 2024 Palantir Technologies Inc. All rights reserved.
*/

import * as React from "react";

import { mergeRefs, Utils } from "../common";

type InteractiveHTMLAttributes<E extends HTMLElement> = Pick<
React.HTMLAttributes<E>,
"onBlur" | "onClick" | "onFocus" | "onKeyDown" | "onKeyUp" | "tabIndex"
>;

interface InteractiveComponentProps extends InteractiveHTMLAttributes<HTMLElement> {
active?: boolean | undefined;
}

interface InteractiveAttributes<E extends HTMLElement> extends InteractiveHTMLAttributes<E> {
ref: React.Ref<E>;
}

export function useInteractiveAttributes<E extends HTMLElement>(
interactive: boolean,
props: InteractiveComponentProps,
ref: React.Ref<E>,
defaultTabIndex?: number,
): [active: boolean, interactiveProps: InteractiveAttributes<E>] {
const { active, onClick, onFocus, onKeyDown, onKeyUp, onBlur, tabIndex = defaultTabIndex } = props;
// the current key being pressed
const [currentKeyPressed, setCurrentKeyPressed] = React.useState<string | undefined>();
// whether the button is in "active" state
const [isActive, setIsActive] = React.useState(false);
// our local ref for the interactive element, merged with the consumer's own ref in this hook's return value
const elementRef = React.useRef<E | null>(null);

const handleBlur = React.useCallback(
(e: React.FocusEvent<E>) => {
if (isActive) {
setIsActive(false);
}

onBlur?.(e);
},
[isActive, onBlur],
);

const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<E>) => {
if (Utils.isKeyboardClick(e)) {
e.preventDefault();
if (e.key !== currentKeyPressed) {
setIsActive(true);
}
}

setCurrentKeyPressed(e.key);
onKeyDown?.(e);
},
[currentKeyPressed, onKeyDown],
);

const handleKeyUp = React.useCallback(
(e: React.KeyboardEvent<E>) => {
if (Utils.isKeyboardClick(e)) {
setIsActive(false);
elementRef.current?.click();
}
setCurrentKeyPressed(undefined);
onKeyUp?.(e);
},
[onKeyUp, elementRef],
);

const resolvedActive = interactive && (active || isActive);

return [
resolvedActive,
{
onBlur: handleBlur,
onClick: interactive ? onClick : undefined,
onFocus: interactive ? onFocus : undefined,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
ref: mergeRefs(elementRef, ref),
tabIndex: interactive ? tabIndex : -1,
},
];
}
75 changes: 6 additions & 69 deletions packages/core/src/components/button/buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
import classNames from "classnames";
import * as React from "react";

import { useInteractiveAttributes } from "../../accessibility/useInteractiveAttributes";
import { Classes, Utils } from "../../common";
import { DISPLAYNAME_PREFIX, removeNonHTMLProps } from "../../common/props";
import { mergeRefs } from "../../common/refs";
import { Icon } from "../icon/icon";
import { Spinner, SpinnerSize } from "../spinner/spinner";
import { Text } from "../text/text";
Expand Down Expand Up @@ -49,7 +49,7 @@ Button.displayName = `${DISPLAYNAME_PREFIX}.Button`;
*/
export const AnchorButton: React.FC<AnchorButtonProps> = React.forwardRef<HTMLAnchorElement, AnchorButtonProps>(
(props, ref) => {
const { href, tabIndex = 0 } = props;
const { href } = props;
const commonProps = useSharedButtonAttributes(props, ref);

return (
Expand All @@ -59,7 +59,6 @@ export const AnchorButton: React.FC<AnchorButtonProps> = React.forwardRef<HTMLAn
{...commonProps}
aria-disabled={commonProps.disabled}
href={commonProps.disabled ? undefined : href}
tabIndex={commonProps.disabled ? -1 : tabIndex}
>
{renderButtonContents(props)}
</a>
Expand All @@ -75,67 +74,15 @@ function useSharedButtonAttributes<E extends HTMLAnchorElement | HTMLButtonEleme
props: E extends HTMLAnchorElement ? AnchorButtonProps : ButtonProps,
ref: React.Ref<E>,
) {
const {
active = false,
alignText,
fill,
large,
loading = false,
minimal,
onBlur,
onKeyDown,
onKeyUp,
outlined,
small,
tabIndex,
} = props;
const { alignText, fill, large, loading = false, minimal, outlined, small } = props;
const disabled = props.disabled || loading;

// the current key being pressed
const [currentKeyPressed, setCurrentKeyPressed] = React.useState<string | undefined>();
// whether the button is in "active" state
const [isActive, setIsActive] = React.useState(false);
// our local ref for the button element, merged with the consumer's own ref (if supplied) in this hook's return value
const buttonRef = React.useRef<E | null>(null);

const handleBlur = React.useCallback(
(e: React.FocusEvent<any>) => {
if (isActive) {
setIsActive(false);
}
onBlur?.(e);
},
[isActive, onBlur],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<any>) => {
if (Utils.isKeyboardClick(e)) {
e.preventDefault();
if (e.key !== currentKeyPressed) {
setIsActive(true);
}
}
setCurrentKeyPressed(e.key);
onKeyDown?.(e);
},
[currentKeyPressed, onKeyDown],
);
const handleKeyUp = React.useCallback(
(e: React.KeyboardEvent<any>) => {
if (Utils.isKeyboardClick(e)) {
setIsActive(false);
buttonRef.current?.click();
}
setCurrentKeyPressed(undefined);
onKeyUp?.(e);
},
[onKeyUp],
);
const [active, interactiveProps] = useInteractiveAttributes(!disabled, props, ref);

const className = classNames(
Classes.BUTTON,
{
[Classes.ACTIVE]: !disabled && (active || isActive),
[Classes.ACTIVE]: active,
[Classes.DISABLED]: disabled,
[Classes.FILL]: fill,
[Classes.LARGE]: large,
Expand All @@ -150,15 +97,9 @@ function useSharedButtonAttributes<E extends HTMLAnchorElement | HTMLButtonEleme
);

return {
...interactiveProps,
className,
disabled,
onBlur: handleBlur,
onClick: disabled ? undefined : props.onClick,
onFocus: disabled ? undefined : props.onFocus,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
ref: mergeRefs(buttonRef, ref),
tabIndex: disabled ? -1 : tabIndex,
};
}

Expand All @@ -184,10 +125,6 @@ function renderButtonContents<E extends HTMLAnchorElement | HTMLButtonElement>(
{text}
{children}
</Text>
// <span key="text" className={classNames(Classes.BUTTON_TEXT, textClassName)}>
// {text}
// {children}
// </span>
)}
<Icon key="rightIcon" icon={rightIcon} />
</>
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/components/tag/tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as React from "react";

import type { IconName } from "@blueprintjs/icons";

import { useInteractiveAttributes } from "../../accessibility/useInteractiveAttributes";
import { Classes, DISPLAYNAME_PREFIX, type IntentProps, type MaybeElement, type Props, Utils } from "../../common";
import { isReactNodeEmpty } from "../../common/utils";
import { Icon } from "../icon/icon";
Expand Down Expand Up @@ -72,13 +73,12 @@ export interface TagProps
*/
export const Tag: React.FC<TagProps> = React.forwardRef((props, ref) => {
const {
active,
children,
className,
fill,
icon,
intent,
interactive,
interactive = false,
large,
minimal,
multiline,
Expand All @@ -92,6 +92,8 @@ export const Tag: React.FC<TagProps> = React.forwardRef((props, ref) => {

const isRemovable = Utils.isFunction(onRemove);

const [active, interactiveProps] = useInteractiveAttributes(interactive, props, ref, 0);

const tagClasses = classNames(
Classes.TAG,
Classes.intentClass(intent),
Expand All @@ -107,7 +109,7 @@ export const Tag: React.FC<TagProps> = React.forwardRef((props, ref) => {
);

return (
<span {...htmlProps} className={tagClasses} tabIndex={interactive ? tabIndex : undefined} ref={ref}>
<span {...htmlProps} {...interactiveProps} className={tagClasses}>
<Icon icon={icon} />
{!isReactNodeEmpty(children) && (
<Text className={Classes.FILL} ellipsize={!multiline} tagName="span" title={htmlTitle}>
Expand Down

1 comment on commit b7b44d0

@svc-palantir-github
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix(Tag): Add keyboard accessibility (#7060)

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Please sign in to comment.