Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SegmentedControl): a11y - set role to radiogroup #6691

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6bc4fd8
feat(SegmentedControl): make listbox role to fix a11y
bvandercar-vt Feb 1, 2024
fbdce7c
revert yarn.lock
bvandercar-vt Feb 1, 2024
a4ba4ea
Merge branch 'develop' into bvandercar/SegmentedControl-a11y
bvandercar-vt Mar 14, 2024
5b2cb1b
a11y: use radiogroup
bvandercar-vt Mar 14, 2024
11dfac3
role radiogrup
bvandercar-vt Mar 14, 2024
373e598
Merge branch 'develop' into bvandercar/SegmentedControl-a11y
bvandercar-vt Jun 25, 2024
73a0519
add changelog entry
bvandercar-vt Jun 25, 2024
7c7083d
Merge branch 'develop' of https://github.com/palantir/blueprint into …
bvandercar-vt Aug 7, 2024
5c7812a
Merge branch 'develop' of https://github.com/palantir/blueprint into …
bvandercar-vt Aug 8, 2024
66ed61b
Merge branch 'develop' into bvandercar/SegmentedControl-a11y
bvandercar-vt Aug 12, 2024
9170ff4
delete file
bvandercar-vt Aug 12, 2024
264026a
feat: arrow control on SegmentedControl
bvandercar-vt Aug 12, 2024
69e8717
Merge branch 'develop' into bvandercar/SegmentedControl-a11y
bvandercar-vt Sep 10, 2024
77a6999
style: arg default
bvandercar-vt Sep 10, 2024
7d6da84
refactor SegmentedControl mostly back to original code
bvandercar-vt Sep 10, 2024
ae54d3b
use selector to filter
bvandercar-vt Sep 12, 2024
fd215a8
add commnent
bvandercar-vt Sep 12, 2024
88e0308
SegmentedControl: add role prop
bvandercar-vt Sep 12, 2024
a38cda0
refactor ternary and aria props
bvandercar-vt Sep 12, 2024
57644c3
Fix lint errors
ggdouglas Sep 13, 2024
01b5150
Shorten JSDoc comment
ggdouglas Sep 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/core/src/common/utils/keyboardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,21 @@ export function isKeyboardClick(event: React.KeyboardEvent<HTMLElement>) {
export function isArrowKey(event: React.KeyboardEvent<HTMLElement>) {
return ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].indexOf(event.key) >= 0;
}

/**
* Direction multiplier for component such as radiogroup, tablist
*
* @param upMovesLeft
* If true, up arrow returns same as left arrow, down arrow returns same as right arrow.
* If false, down arrow returns same as left arrow, up arrow returns same as right arrow.
* @returns -1 for left, 1 for right, undefined if not an arrow keypress.
*/
export function getArrowKeyDirection(event: React.KeyboardEvent<HTMLElement>, upMovesLeft: boolean = true) {
Copy link
Contributor

Choose a reason for hiding this comment

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

upMovesLeft is interesting - are we currently following expected behavior correctly? it definitely makes sense in the slider case for up to move right but I'm wondering if up should always be to the right

this feels like something that may change based on locale, possibly in addition to component usage

not sure we need to figure this out now since this keeps existing behavior but leaving this comment for posterity at least

Copy link
Contributor Author

@bvandercar-vt bvandercar-vt Sep 10, 2024

Choose a reason for hiding this comment

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

  • Slider: Definitely makes sense for up = right.

  • Tabs: the official aria tabs example doesn't have any effect from the up/down arrow keys, only left/right. So if anything, we should remove the effect of an up/down keypress at some point. But with this PR, the existing implementation goes unchanged.

  • SegmentedControl: since we are giving this the radiogroup role, here in the official example of this role you can see that up = left. The action description is given as well:
    image

const [leftVerticalKey, rightVerticalKey] = upMovesLeft ? ["ArrowUp", "ArrowDown"] : ["ArrowDown", "ArrowUp"];
if (event.key === "ArrowLeft" || event.key === leftVerticalKey) {
return -1;
} else if (event.key === "ArrowRight" || event.key === rightVerticalKey) {
return 1;
}
return undefined;
}
90 changes: 70 additions & 20 deletions packages/core/src/components/segmented-control/segmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
import classNames from "classnames";
import * as React from "react";

import { Classes, Intent } from "../../common";
import { Classes, Intent, mergeRefs, Utils } from "../../common";
import { getArrowKeyDirection } from "../../common/utils/keyboardUtils";
import {
type ControlledValueProps,
DISPLAYNAME_PREFIX,
Expand All @@ -26,6 +27,7 @@ import {
removeNonHTMLProps,
} from "../../common/props";
import { Button } from "../button/buttons";
import type { ButtonProps } from "../button/buttonProps";

export type SegmentedControlIntent = typeof Intent.NONE | typeof Intent.PRIMARY;

Expand Down Expand Up @@ -92,34 +94,72 @@ export const SegmentedControl: React.FC<SegmentedControlProps> = React.forwardRe
value: controlledValue,
...htmlProps
} = props;
const [selectedIndex, setSelectedIndex] = React.useState<number>(
Copy link
Contributor

Choose a reason for hiding this comment

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

we should keep this code more like it was before - where we use this local state index only if using the defaultValue option to avoid duplicating state, then on every render reconcile the index of the user provided controlledValue with this local index as a fallback

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm it's tricky because we want to add keypress control with this PR, which is much easier to change to the next index vs. the next value. I can take a shot at it though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done! Reduced the diff a ton, mostly the same code as was before, but arrow keys work as indended.

options.findIndex(option => option.value === (controlledValue ?? defaultValue)),
);

const [localValue, setLocalValue] = React.useState<string | undefined>(defaultValue);
const selectedValue = controlledValue ?? localValue;
const outerRef = React.useRef<HTMLDivElement>(null);
const optionRefs = options.map(() => React.useRef<HTMLButtonElement>(null));

const handleOptionClick = React.useCallback(
(newSelectedValue: string, targetElement: HTMLElement) => {
setLocalValue(newSelectedValue);
onValueChange?.(newSelectedValue, targetElement);
(index: number, e: React.MouseEvent<HTMLElement>) => {
setSelectedIndex(index);
onValueChange?.(options[index].value, e.currentTarget);
},
[onValueChange],
);

const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const direction = getArrowKeyDirection(e);
const { current: outerElement } = outerRef;
if (direction == undefined || !outerElement) return;

const focusedElement = Utils.getActiveElement(outerElement)?.closest<HTMLButtonElement>("button");
if (!focusedElement) return;

// must rely on DOM state because we have no way of mapping `focusedElement` to a React.JSX.Element
const enabledOptionElements = Array.from(outerElement.querySelectorAll("button")).filter(
Copy link
Contributor

@ggdouglas ggdouglas Sep 11, 2024

Choose a reason for hiding this comment

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

Could we leverage the selector query to filter out disabled buttons? e.g.

Suggested change
const enabledOptionElements = Array.from(outerElement.querySelectorAll("button")).filter(
const enabledOptionElements = Array.from(outerElement.querySelectorAll("button:not(:disabled)"));

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea, done!

el => !el.disabled,
);
const focusedIndex = enabledOptionElements.indexOf(focusedElement);
if (focusedIndex < 0) return;

e.preventDefault();
// auto-wrapping at 0 and `length`
const newIndex = (focusedIndex + direction + enabledOptionElements.length) % enabledOptionElements.length;
const newOption = enabledOptionElements[newIndex];
newOption.click();
newOption.focus();
},
[outerRef],
);

const classes = classNames(Classes.SEGMENTED_CONTROL, className, {
[Classes.FILL]: fill,
[Classes.INLINE]: inline,
});

return (
<div className={classes} ref={ref} {...removeNonHTMLProps(htmlProps)}>
{options.map(option => (
<div
role="radiogroup"
{...removeNonHTMLProps(htmlProps)}
onKeyDown={handleKeyDown}
className={classes}
ref={mergeRefs(ref, outerRef)}
>
{options.map((option, index) => (
<SegmentedControlOption
{...option}
intent={intent}
isSelected={selectedValue === option.value}
isSelected={index == selectedIndex}
key={option.value}
large={large}
onClick={handleOptionClick}
onClick={e => handleOptionClick(index, e)}
ref={optionRefs[index]}
small={small}
// selectedIndex==-1 accounts for case where passed value/defaultValue is not one of the values of the passed options.
tabIndex={selectedIndex == -1 && index == 0 ? 0 : undefined}
/>
))}
</div>
Expand All @@ -133,17 +173,27 @@ SegmentedControl.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControl`;

interface SegmentedControlOptionProps
extends OptionProps<string>,
Pick<SegmentedControlProps, "intent" | "small" | "large"> {
Pick<SegmentedControlProps, "intent" | "small" | "large">,
Pick<ButtonProps, "tabIndex" | "ref" | "onClick"> {
isSelected: boolean;
onClick: (value: string, targetElement: HTMLElement) => void;
/**
* @default 0 if isSelected else -1
*/
tabIndex?: ButtonProps["tabIndex"];
}

function SegmentedControlOption({ isSelected, label, onClick, value, ...buttonProps }: SegmentedControlOptionProps) {
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => onClick?.(value, event.currentTarget),
[onClick, value],
);

return <Button onClick={handleClick} minimal={!isSelected} text={label} {...buttonProps} />;
}
const SegmentedControlOption: React.FC<SegmentedControlOptionProps> = React.forwardRef(
({ isSelected, label, value, tabIndex, ...buttonProps }, ref) => (
<Button
role="radio"
aria-checked={isSelected}
minimal={!isSelected}
text={label}
{...buttonProps}
ref={ref}
// "roving tabIndex" on a radiogroup: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
tabIndex={tabIndex ?? (isSelected ? 0 : -1)}
/>
),
);
SegmentedControlOption.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControlOption`;
10 changes: 4 additions & 6 deletions packages/core/src/components/slider/handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import * as React from "react";
import { AbstractPureComponent, Classes, Utils } from "../../common";
import { DISPLAYNAME_PREFIX } from "../../common/props";
import { clamp } from "../../common/utils";
import { getArrowKeyDirection } from "../../common/utils/keyboardUtils";

import type { HandleProps } from "./handleProps";
import { formatPercentage } from "./sliderUtils";
Expand Down Expand Up @@ -199,14 +200,11 @@ export class Handle extends AbstractPureComponent<InternalHandleProps, HandleSta

private handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
const { stepSize, value } = this.props;
const { key } = event;
if (key === "ArrowDown" || key === "ArrowLeft") {
this.changeValue(value - stepSize);
const direction = getArrowKeyDirection(event, false);
if (direction !== undefined) {
this.changeValue(value + stepSize * direction);
// this key event has been handled! prevent browser scroll on up/down
event.preventDefault();
} else if (key === "ArrowUp" || key === "ArrowRight") {
this.changeValue(value + stepSize);
event.preventDefault();
}
};

Expand Down
27 changes: 10 additions & 17 deletions packages/core/src/components/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import classNames from "classnames";
import * as React from "react";

import { AbstractPureComponent, Classes, DISPLAYNAME_PREFIX, type Props, Utils } from "../../common";
import { getArrowKeyDirection } from "../../common/utils/keyboardUtils";

import { Tab, type TabId, type TabProps } from "./tab";
import { TabPanel } from "./tabPanel";
Expand Down Expand Up @@ -235,15 +236,6 @@ export class Tabs extends AbstractPureComponent<TabsProps, TabsState> {
}
}

private getKeyCodeDirection(e: React.KeyboardEvent<HTMLElement>) {
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
return -1;
} else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
return 1;
}
return undefined;
}

private getTabChildrenProps(props: TabsProps & { children?: React.ReactNode } = this.props) {
return this.getTabChildren(props).map(child => child.props);
}
Expand All @@ -262,6 +254,9 @@ export class Tabs extends AbstractPureComponent<TabsProps, TabsState> {
}

private handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const direction = getArrowKeyDirection(e);
if (direction == undefined) return;

const focusedElement = Utils.getActiveElement(this.tablistElement)?.closest(TAB_SELECTOR);
// rest of this is potentially expensive and futile, so bail if no tab is focused
if (focusedElement == null) {
Expand All @@ -271,15 +266,13 @@ export class Tabs extends AbstractPureComponent<TabsProps, TabsState> {
// must rely on DOM state because we have no way of mapping `focusedElement` to a React.JSX.Element
const enabledTabElements = this.getTabElements().filter(el => el.getAttribute("aria-disabled") === "false");
const focusedIndex = enabledTabElements.indexOf(focusedElement);
const direction = this.getKeyCodeDirection(e);
if (focusedIndex < 0) return;

if (focusedIndex >= 0 && direction !== undefined) {
e.preventDefault();
const { length } = enabledTabElements;
// auto-wrapping at 0 and `length`
const nextFocusedIndex = (focusedIndex + direction + length) % length;
(enabledTabElements[nextFocusedIndex] as HTMLElement).focus();
}
e.preventDefault();
const { length } = enabledTabElements;
// auto-wrapping at 0 and `length`
const nextFocusedIndex = (focusedIndex + direction + length) % length;
(enabledTabElements[nextFocusedIndex] as HTMLElement).focus();
};

private handleKeyPress = (e: React.KeyboardEvent<HTMLDivElement>) => {
Expand Down
57 changes: 47 additions & 10 deletions packages/core/test/segmented-control/segmentedControlTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { assert } from "chai";
import { mount } from "enzyme";
import * as React from "react";

import { Classes, type OptionProps, SegmentedControl } from "../../src";
import { Classes, type OptionProps, SegmentedControl, type SegmentedControlProps } from "../../src";

const OPTIONS: Array<OptionProps<string>> = [
{
Expand All @@ -28,6 +28,7 @@ const OPTIONS: Array<OptionProps<string>> = [
{
label: "Grid",
value: "grid",
disabled: true,
},
{
label: "Gallery",
Expand All @@ -36,7 +37,7 @@ const OPTIONS: Array<OptionProps<string>> = [
];

describe("<SegmentedControl>", () => {
let containerElement: HTMLElement | undefined;
let containerElement: HTMLElement;

beforeEach(() => {
containerElement = document.createElement("div");
Expand All @@ -47,14 +48,50 @@ describe("<SegmentedControl>", () => {
containerElement?.remove();
});

describe("basic rendering", () => {
it("supports className", () => {
const testClassName = "test-class-name";
const wrapper = mount(<SegmentedControl className={testClassName} options={OPTIONS} />, {
attachTo: containerElement,
});
assert.isTrue(wrapper.find(`.${Classes.SEGMENTED_CONTROL}`).hostNodes().exists());
assert.isTrue(wrapper.find(`.${testClassName}`).hostNodes().exists());
const mountSegmentedControl = (props?: Partial<SegmentedControlProps>) =>
mount(<SegmentedControl options={OPTIONS} {...props} />, {
attachTo: containerElement,
});

it("supports className", () => {
const testClassName = "test-class-name";
const wrapper = mountSegmentedControl({ className: testClassName });
assert.isTrue(wrapper.find(`.${Classes.SEGMENTED_CONTROL}`).hostNodes().exists());
assert.isTrue(wrapper.find(`.${testClassName}`).hostNodes().exists());
});

it("when no default value passed, first button gets tabIndex=0, none have aria-checked initially", () => {
const wrapper = mountSegmentedControl();
assert.lengthOf(wrapper.find("[tabIndex=0]").hostNodes(), 1);
assert.lengthOf(wrapper.find("[aria-checked=true]").hostNodes(), 0);
const optionButtons = containerElement.querySelectorAll("button")!;
assert.equal(optionButtons[0].getAttribute("tabIndex"), "0");
assert.equal(optionButtons[0].getAttribute("aria-checked"), "false");
});

it("when defaultValue passed, tabIndex=0 and aria-checked applied to correct option, no others", () => {
const wrapper = mountSegmentedControl({ defaultValue: OPTIONS[2].value });
assert.lengthOf(wrapper.find("[tabIndex=0]").hostNodes(), 1);
assert.lengthOf(wrapper.find("[aria-checked=true]").hostNodes(), 1);
const optionButtons = containerElement.querySelectorAll("button")!;
assert.equal(optionButtons[2].getAttribute("tabIndex"), "0");
assert.equal(optionButtons[2].getAttribute("aria-checked"), "true");
});

it("changes option button focus when arrow keys are pressed", () => {
const wrapper = mountSegmentedControl();
const radioGroup = wrapper.find('[role="radiogroup"]');

const optionButtons = containerElement.querySelectorAll<HTMLElement>('[role="radio"]');
optionButtons[0].focus();

radioGroup.simulate("keydown", { key: "ArrowRight" });
assert.equal(document.activeElement, optionButtons[2], "move right and skip disabled");
radioGroup.simulate("keydown", { key: "ArrowRight" });
assert.equal(document.activeElement, optionButtons[0], "wrap around to first option");
radioGroup.simulate("keydown", { key: "ArrowLeft" });
assert.equal(document.activeElement, optionButtons[2], "wrap around to last option");
radioGroup.simulate("keydown", { key: "ArrowLeft" });
assert.equal(document.activeElement, optionButtons[0], "move left and skip disabled");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,15 @@ export const SegmentedControlExample: React.FC<ExampleProps> = props => {
label: "Grid",
value: "grid",
},
{
label: "Gallery",
value: "gallery",
},
{
disabled: true,
label: "Disabled",
value: "disabled",
},
{
label: "Gallery",
value: "gallery",
},
]}
large={size === "large"}
small={size === "small"}
Expand Down