Skip to content

Commit

Permalink
Merge pull request #3162 from owid/grapher-a11y
Browse files Browse the repository at this point in the history
✨ (a11y) make time slider keyboard accessible
  • Loading branch information
sophiamersmann authored Feb 6, 2024
2 parents 82b13e9 + 36d59a1 commit d426639
Show file tree
Hide file tree
Showing 8 changed files with 146 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ export function ActionButton(props: {
onMouseLeave={(): void => {
setShowTooltip(false)
}}
aria-label={props.label}
>
<FontAwesomeIcon icon={props.icon} />
{props.showLabel && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
position: relative;
}

li > a {
li > button {
$height: $controlRowHeight - 2 * $visual-gap;

display: block;
Expand Down Expand Up @@ -69,7 +69,7 @@
}
}

li.active > a {
li.active > button {
color: $active-text;
background-color: $active-fill;

Expand Down Expand Up @@ -105,7 +105,7 @@
}

&.GrapherComponentMedium {
.ContentSwitchers:not(.iconOnly) li > a {
.ContentSwitchers:not(.iconOnly) li > button {
padding: 0 8px;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,20 @@ function Tab(props: {
tab: GrapherTabOption
icon: JSX.Element
isActive?: boolean
onClick?: React.MouseEventHandler<HTMLAnchorElement>
onClick?: React.MouseEventHandler<HTMLButtonElement>
showLabel?: boolean
}): JSX.Element {
const className = "tab clickable" + (props.isActive ? " active" : "")
return (
<li key={props.tab} className={className}>
<a
<button
onClick={props.onClick}
data-track-note={"chart_click_" + props.tab}
aria-label={props.tab}
>
{props.icon}
{props.showLabel && <span className="label">{props.tab}</span>}
</a>
</button>
</li>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export class EntitySelectionToggle extends React.Component<{
e.stopPropagation()
}}
data-track-note="chart_add_entity"
aria-label={`${label.action} ${label.entity}`}
>
{label.icon}
<label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ $lato: $sans-serif-font-stack;
}
}

input:focus-visible + .outer {
outline: 2px solid $controls-color;
}

input:checked + .outer {
background: $active-fill;
.inner {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ export class SettingsMenu extends React.Component<{
onClick={this.toggleVisibility}
data-track-note="chart_settings_menu_toggle"
title="Chart settings"
aria-label="Chart settings"
>
<FontAwesomeIcon icon={faGear} />
<span className="label"> Settings</span>
Expand Down
107 changes: 100 additions & 7 deletions packages/@ourworldindata/grapher/src/timeline/TimelineComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export class TimelineComponent extends React.Component<{
const time =
markerType === "start" ? controller.minTime : controller.maxTime
return (
<div
<button
className="date clickable"
onClick={(): void =>
markerType === "start"
Expand All @@ -267,7 +267,7 @@ export class TimelineComponent extends React.Component<{
}
>
{this.formatTime(time)}
</div>
</button>
)
}

Expand All @@ -279,6 +279,32 @@ export class TimelineComponent extends React.Component<{
this.controller.togglePlay()
}

@action.bound updateStartTimeOnKeyDown(key: string): void {
const { controller } = this
if (key === "Home") {
controller.resetStartToMin()
} else if (key === "End") {
controller.setStartToMax()
} else if (key === "ArrowLeft" || key === "ArrowDown") {
controller.decreaseStartTime()
} else if (key === "ArrowRight" || key === "ArrowUp") {
controller.increaseStartTime()
}
}

@action.bound updateEndTimeOnKeyDown(key: string): void {
const { controller } = this
if (key === "Home") {
controller.setEndToMin()
} else if (key === "End") {
controller.resetEndToMax()
} else if (key === "ArrowLeft" || key === "ArrowDown") {
controller.decreaseEndTime()
} else if (key === "ArrowRight" || key === "ArrowUp") {
controller.increaseEndTime()
}
}

convertToTime(time: number): number {
if (time === -Infinity) return this.controller.minTime
if (time === +Infinity) return this.controller.maxTime
Expand All @@ -291,6 +317,8 @@ export class TimelineComponent extends React.Component<{
controller
const { startHandleTimeBound, endHandleTimeBound } = manager

const formattedMinTime = this.formatTime(minTime)
const formattedMaxTime = this.formatTime(maxTime)
const formattedStartTime = this.formatTime(
timeFromTimebounds(startHandleTimeBound, minTime, maxTime)
)
Expand Down Expand Up @@ -337,12 +365,29 @@ export class TimelineComponent extends React.Component<{
>
<TimelineHandle
type="startMarker"
label="Start time"
offsetPercent={startTimeProgress * 100}
tooltipContent={formattedStartTime}
formattedMinTime={formattedMinTime}
formattedMaxTime={formattedMaxTime}
formattedCurrTime={formattedStartTime}
tooltipVisible={this.startTooltipVisible}
tooltipZIndex={
this.lastUpdatedTooltip === "startMarker" ? 2 : 1
}
onKeyDown={action((e) => {
// prevent browser to scroll to the top or bottom of the page
if (e.key === "Home" || e.key === "End")
e.preventDefault()

this.updateStartTimeOnKeyDown(e.key)
})}
onFocus={action(() => {
this.showTooltips()
})}
onBlur={action(() => {
this.startTooltipVisible = false
this.endTooltipVisible = false
})}
/>
<div
className="interval"
Expand All @@ -353,12 +398,29 @@ export class TimelineComponent extends React.Component<{
/>
<TimelineHandle
type="endMarker"
label="End time"
offsetPercent={endTimeProgress * 100}
tooltipContent={formattedEndTime}
formattedMinTime={formattedMinTime}
formattedMaxTime={formattedMaxTime}
formattedCurrTime={formattedEndTime}
tooltipVisible={this.endTooltipVisible}
tooltipZIndex={
this.lastUpdatedTooltip === "endMarker" ? 2 : 1
}
onKeyDown={action((e) => {
// prevent browser to scroll to the top or bottom of the page
if (e.key === "Home" || e.key === "End")
e.preventDefault()

this.updateEndTimeOnKeyDown(e.key)
})}
onFocus={action(() => {
this.showTooltips()
})}
onBlur={action(() => {
this.startTooltipVisible = false
this.endTooltipVisible = false
})}
/>
</div>
{this.timelineEdgeMarker("end")}
Expand All @@ -369,23 +431,46 @@ export class TimelineComponent extends React.Component<{

const TimelineHandle = ({
type,
label,
offsetPercent,
tooltipContent,
formattedMinTime,
formattedMaxTime,
formattedCurrTime,
tooltipVisible,
tooltipZIndex,
onKeyDown,
onFocus,
onBlur,
}: {
type: "startMarker" | "endMarker"
label: string
offsetPercent: number
tooltipContent: string
formattedMinTime: string
formattedMaxTime: string
formattedCurrTime: string
tooltipVisible: boolean
tooltipZIndex: number
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>
onFocus?: React.FocusEventHandler<HTMLDivElement>
onBlur?: React.FocusEventHandler<HTMLDivElement>
}): JSX.Element => {
return (
// @ts-expect-error aria-value* fields expect a number, but if we're dealing with daily data,
// the numeric representation of a date is meaningless, so we pass the formatted date string instead.
<div
className={classNames("handle", type)}
style={{
left: `${offsetPercent}%`,
}}
role="slider"
tabIndex={0}
aria-valuemin={castToNumberIfPossible(formattedMinTime)}
aria-valuenow={castToNumberIfPossible(formattedCurrTime)}
aria-valuemax={castToNumberIfPossible(formattedMaxTime)}
aria-label={label}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
>
<div className="icon" />
{tooltipVisible && (
Expand All @@ -398,10 +483,18 @@ const TimelineHandle = ({
className="handle-label"
style={{ zIndex: tooltipZIndex }}
>
{tooltipContent}
{formattedCurrTime}
</div>
</>
)}
</div>
)
}

function castToNumberIfPossible(s: string): string | number {
return isNumber(s) ? +s : s
}

function isNumber(s: string): boolean {
return /^\d+$/.test(s)
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export class TimelineController {
return this.timesAsc[this.timesAsc.indexOf(time) + 1] ?? this.maxTime
}

getPrevTime(time: number): number {
return this.timesAsc[this.timesAsc.indexOf(time) - 1] ?? this.minTime
}

// By default, play means extend the endTime to the right. Toggle this to play one time unit at a time.
private rangeMode = true
toggleRangeMode(): this {
Expand Down Expand Up @@ -111,6 +115,26 @@ export class TimelineController {
return tickCount
}

increaseStartTime(): void {
const nextTime = this.getNextTime(this.startTime)
this.updateStartTime(nextTime)
}

decreaseStartTime(): void {
const prevTime = this.getPrevTime(this.startTime)
this.updateStartTime(prevTime)
}

increaseEndTime(): void {
const nextTime = this.getNextTime(this.endTime)
this.updateEndTime(nextTime)
}

decreaseEndTime(): void {
const prevTime = this.getPrevTime(this.endTime)
this.updateEndTime(prevTime)
}

private stop(): void {
this.manager.isPlaying = false
}
Expand Down Expand Up @@ -215,4 +239,12 @@ export class TimelineController {
resetEndToMax(): void {
this.updateEndTime(TimeBoundValue.positiveInfinity)
}

setStartToMax(): void {
this.updateStartTime(TimeBoundValue.positiveInfinity)
}

setEndToMin(): void {
this.updateEndTime(TimeBoundValue.negativeInfinity)
}
}

0 comments on commit d426639

Please sign in to comment.