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 all 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 = false) {
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;
}
100 changes: 85 additions & 15 deletions packages/core/src/components/segmented-control/segmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@
import classNames from "classnames";
import * as React from "react";

import { Classes, Intent } from "../../common";
import { Classes, Intent, mergeRefs, Utils } from "../../common";
import {
type ControlledValueProps,
DISPLAYNAME_PREFIX,
type OptionProps,
type Props,
removeNonHTMLProps,
} from "../../common/props";
import { getArrowKeyDirection } from "../../common/utils/keyboardUtils";
import type { ButtonProps } from "../button/buttonProps";
import { Button } from "../button/buttons";

export type SegmentedControlIntent = typeof Intent.NONE | typeof Intent.PRIMARY;
Expand Down Expand Up @@ -65,6 +67,15 @@ export interface SegmentedControlProps
*/
options: Array<OptionProps<string>>;

/**
* Aria role for the overall component. Child buttons get appropriate roles.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/examples/toolbar
*
* @default 'radiogroup'
*/
role?: Extract<React.AriaRole, "radiogroup" | "group" | "toolbar">;

/**
* Whether this control should use small buttons.
*
Expand All @@ -88,6 +99,7 @@ export const SegmentedControl: React.FC<SegmentedControlProps> = React.forwardRe
large,
onValueChange,
options,
role = "radiogroup",
small,
value: controlledValue,
...htmlProps
Expand All @@ -96,6 +108,8 @@ export const SegmentedControl: React.FC<SegmentedControlProps> = React.forwardRe
const [localValue, setLocalValue] = React.useState<string | undefined>(defaultValue);
const selectedValue = controlledValue ?? localValue;

const outerRef = React.useRef<HTMLDivElement>(null);

const handleOptionClick = React.useCallback(
(newSelectedValue: string, targetElement: HTMLElement) => {
setLocalValue(newSelectedValue);
Expand All @@ -104,24 +118,78 @@ export const SegmentedControl: React.FC<SegmentedControlProps> = React.forwardRe
[onValueChange],
);

const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (role === "radiogroup") {
// in a `radiogroup`, arrow keys select next item, not tab key.
const direction = getArrowKeyDirection(e, true);
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<HTMLButtonElement>("button:not(: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, role],
);

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

const isAnySelected = options.some(option => selectedValue === option.value);

return (
<div className={classes} ref={ref} {...removeNonHTMLProps(htmlProps)}>
{options.map(option => (
<SegmentedControlOption
{...option}
intent={intent}
isSelected={selectedValue === option.value}
key={option.value}
large={large}
onClick={handleOptionClick}
small={small}
/>
))}
<div
{...removeNonHTMLProps(htmlProps)}
role={role}
onKeyDown={handleKeyDown}
className={classes}
ref={mergeRefs(ref, outerRef)}
>
{options.map((option, index) => {
const isSelected = selectedValue === option.value;
return (
<SegmentedControlOption
{...option}
intent={intent}
isSelected={isSelected}
key={option.value}
large={large}
onClick={handleOptionClick}
small={small}
{...(role === "radiogroup"
? {
"aria-checked": isSelected,
role: "radio",
// "roving tabIndex" on a radiogroup: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
// `!isAnySelected` accounts for case where no value is currently selected
// (passed value/defaultValue is not one of the values of the passed options.)
// In this case, set first item to be tabbable even though it's unselected.
tabIndex: isSelected || (index === 0 && !isAnySelected) ? 0 : -1,
}
: {
"aria-pressed": isSelected,
})}
/>
);
})}
</div>
);
});
Expand All @@ -133,7 +201,9 @@ SegmentedControl.displayName = `${DISPLAYNAME_PREFIX}.SegmentedControl`;

interface SegmentedControlOptionProps
extends OptionProps<string>,
Pick<SegmentedControlProps, "intent" | "small" | "large"> {
Pick<SegmentedControlProps, "intent" | "small" | "large">,
Pick<ButtonProps, "role" | "tabIndex">,
React.AriaAttributes {
isSelected: boolean;
onClick: (value: string, targetElement: HTMLElement) => void;
}
Expand All @@ -144,6 +214,6 @@ function SegmentedControlOption({ isSelected, label, onClick, value, ...buttonPr
[onClick, value],
);

return <Button onClick={handleClick} minimal={!isSelected} text={label} {...buttonProps} />;
return <Button {...buttonProps} onClick={handleClick} minimal={!isSelected} text={label} />;
}
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
29 changes: 11 additions & 18 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,24 +254,25 @@ export class Tabs extends AbstractPureComponent<TabsProps, TabsState> {
}

private handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const direction = getArrowKeyDirection(e, true);
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) {
return;
}

// 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 enabledTabElements = this.getTabElements('[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,14 +18,15 @@ 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>> = [
{
label: "List",
value: "list",
},
{
disabled: true,
label: "Grid",
value: "grid",
},
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