Adds K-5 schedule tab jump to navigation button

Replaces the hidden duplicate weekly nav toolbar with a separate "Jump
to weekly navigation" button that returns focus to the lone navigation
toolbar (for better keyboard-only/screenreader usability).

fixes LS-2131
flag = canvas_for_elementary

Test plan:
  - As a student enrolled in a K-5 course, go to the dashboard
  - Switch to the schedule tab
  - Tab to the bottom of the planner
  - Expect a hidden button to appear reading "Jump to navigation
    toolbar"
  - Press enter, and expect the button to take you back to the "Today"
    button
  - Press left or right to switch to one of the next/previous buttons
  - Tab back to the "Jump to navigation toolbar" button
  - Press enter, expect focus to jump to whatever button was last
    active on the toolbar (next/previous)

Change-Id: Ic715d64603253412d2b97e961988ff9fcd6e92a5
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/263277
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Robin Kuss <rkuss@instructure.com>
QA-Review: Robin Kuss <rkuss@instructure.com>
Product-Review: Jeff Largent <jeff.largent@instructure.com>
This commit is contained in:
Jeff Largent 2021-04-20 16:09:27 -04:00
parent 947836e149
commit d659447da7
7 changed files with 110 additions and 47 deletions

View File

@ -135,3 +135,7 @@ if (!('matchMedia' in window)) {
})
window.matchMedia._mocked = true
}
if (!('scrollIntoView' in window.HTMLElement.prototype)) {
window.HTMLElement.prototype.scrollIntoView = () => {}
}

View File

@ -0,0 +1,63 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, {PureComponent} from 'react'
import {Button} from '@instructure/ui-buttons'
import formatMessage from '../../format-message'
import {WEEKLY_PLANNER_ACTIVE_BTN_ID} from '../WeeklyPlannerHeader'
export const WEEKLY_PLANNER_JUMP_TO_NAV_BUTTON = 'jump-to-weekly-nav-button'
export default class JumpToHeaderButton extends PureComponent {
buttonRef = null
state = {focused: false}
setFocused = focused => () => {
this.setState({focused}, () => this.buttonRef.scrollIntoView(false))
}
focusHeader = () => {
document.getElementById(WEEKLY_PLANNER_ACTIVE_BTN_ID)?.focus()
}
render = () => (
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
opacity: this.state.focused ? '1' : '0',
position: this.state.focused ? 'static' : 'absolute'
}}
>
<Button
id={WEEKLY_PLANNER_JUMP_TO_NAV_BUTTON}
data-testid={WEEKLY_PLANNER_JUMP_TO_NAV_BUTTON}
onClick={this.focusHeader}
onBlur={this.setFocused(false)}
onFocus={this.setFocused(true)}
elementRef={e => {
this.buttonRef = e
}}
>
{formatMessage('Jump to navigation toolbar')}
</Button>
</div>
)
}

View File

@ -35,6 +35,8 @@ import {isInMomentRange} from '../../utilities/dateUtils'
import theme from './theme'
import styles from './styles.css'
export const WEEKLY_PLANNER_ACTIVE_BTN_ID = 'weekly-header-active-button'
// Breaking our encapsulation by reaching outside our dom sub-tree
// I suppose we could wire up the event handlers in K5Dashboard.js
// and pass the height as a prop to all the pages. Maybe it will be
@ -69,7 +71,6 @@ export class WeeklyPlannerHeader extends Component {
loadingError: PropTypes.string
}).isRequired,
visible: PropTypes.bool,
isFooter: PropTypes.bool,
todayMoment: momentObj,
weekStartMoment: momentObj,
weekEndMoment: momentObj,
@ -88,9 +89,7 @@ export class WeeklyPlannerHeader extends Component {
prevEnabled: true,
nextEnabled: true,
focusedButtonIndex: 1, // start with the today button
buttons: [this.prevButtonRef, this.todayButtonRef, this.nextButtonRef],
focused: false,
activeButton: 0 // -1 for prev, 0 for today, 1 for next
buttons: [this.prevButtonRef, this.todayButtonRef, this.nextButtonRef]
}
handleStickyOffset = () => {
@ -100,14 +99,14 @@ export class WeeklyPlannerHeader extends Component {
handlePrev = () => {
this.prevButtonRef.current.focus()
this.props.loadPastWeekItems()
this.setState({focusedButtonIndex: 0, activeButton: -1})
this.setState({focusedButtonIndex: 0})
}
handleToday = () => {
this.todayButtonRef.current.focus()
this.props.loadThisWeekItems()
this.setState((state, _props) => {
return {focusedButtonIndex: state.prevEnabled ? 1 : 0, activeButton: 0}
return {focusedButtonIndex: state.prevEnabled ? 1 : 0}
})
}
@ -115,7 +114,7 @@ export class WeeklyPlannerHeader extends Component {
this.nextButtonRef.current.focus()
this.props.loadNextWeekItems({loadMoreButtonClicked: true})
this.setState((state, _props) => {
return {focusedButtonIndex: state.prevEnabled ? 2 : 1, activeButton: 1}
return {focusedButtonIndex: state.prevEnabled ? 2 : 1}
})
}
@ -133,14 +132,6 @@ export class WeeklyPlannerHeader extends Component {
this.setState({focusedButtonIndex: newFocusedIndex})
}
handleFocus = () => {
this.setState({focused: true})
}
handleBlur = () => {
this.setState({focused: false})
}
updateButtons() {
const buttons = []
@ -187,7 +178,7 @@ export class WeeklyPlannerHeader extends Component {
// 2. the window becomes narrow enough for the tabs to wrap.
// We need to relocate the WeeklyPlannerHeader so it sticks
// to the bottom of the tabs panel.
if (!this.props.isFooter && this.props.visible !== prevProps.visible) {
if (this.props.visible !== prevProps.visible) {
if (this.props.visible) {
const focusTarget = processFocusTarget()
this.handleStickyOffset()
@ -215,7 +206,7 @@ export class WeeklyPlannerHeader extends Component {
) {
const buttons = this.updateButtons()
if (!this.props.isFooter && prevState.buttons.length === 3 && buttons.length === 2) {
if (prevState.buttons.length === 3 && buttons.length === 2) {
// when prev or next buttons go away, move focus to Today
this.todayButtonRef.current.focus()
}
@ -237,30 +228,21 @@ export class WeeklyPlannerHeader extends Component {
}
}
render() {
let prevButtonId, todayButtonId, nextButtonId
if (!this.props.isFooter) {
prevButtonId = this.state.activeButton === -1 ? 'weekly-header-active-button' : undefined
todayButtonId = this.state.activeButton === 0 ? 'weekly-header-active-button' : undefined
nextButtonId = this.state.activeButton === 1 ? 'weekly-header-active-button' : undefined
getButtonId(which) {
return this.getButtonTabIndex(which) === 0 ? WEEKLY_PLANNER_ACTIVE_BTN_ID : undefined
}
render() {
return (
<div
id={this.props.isFooter ? 'weekly_planner_footer' : 'weekly_planner_header'}
data-testid={this.props.isFooter ? 'WeeklyPlannerFooter' : 'WeeklyPlannerHeader'}
className={`${styles.root} ${
this.props.isFooter ? 'WeeklyPlannerFooter' : 'WeeklyPlannerHeader'
}`}
style={{
top: `${this.state.stickyOffset}px`,
opacity: `${this.props.isFooter && !this.state.focused ? 0 : 1}`
}}
id="weekly_planner_header"
data-testid="WeeklyPlannerHeader"
className={`${styles.root} WeeklyPlannerHeader`}
style={{top: `${this.state.stickyOffset}px`}}
role="toolbar"
aria-label={formatMessage('Weekly schedule navigation')}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
>
{this.props.loading.loadingError && !this.props.isFooter && (
{this.props.loading.loadingError && (
<div className={styles.errorbox}>
<ErrorAlert error={this.props.loading.loadingError} margin="xx-small">
{formatMessage('Error loading items')}
@ -275,7 +257,7 @@ export class WeeklyPlannerHeader extends Component {
onKeyDown={this.handleKey}
>
<IconButton
id={prevButtonId}
id={this.getButtonId('prev')}
onClick={this.handlePrev}
screenReaderLabel={formatMessage('View previous week')}
interaction={this.state.prevEnabled ? 'enabled' : 'disabled'}
@ -285,7 +267,7 @@ export class WeeklyPlannerHeader extends Component {
<IconArrowOpenStartLine />
</IconButton>
<Button
id={todayButtonId}
id={this.getButtonId('today')}
margin="0 xx-small"
onClick={this.handleToday}
ref={this.todayButtonRef}
@ -296,7 +278,7 @@ export class WeeklyPlannerHeader extends Component {
</AccessibleContent>
</Button>
<IconButton
id={nextButtonId}
id={this.getButtonId('next')}
onClick={this.handleNext}
screenReaderLabel={formatMessage('View next week')}
interaction={this.state.nextEnabled ? 'enabled' : 'disabled'}

View File

@ -33,10 +33,6 @@
background-image: linear-gradient(to top, rgba(255, 255, 255, 0), #fafafa);
}
.WeeklyPlannerFooter {
opacity: 0;
}
.errorbox {
background: var(--backgroundPrimary);
z-index: 2;

View File

@ -20,4 +20,5 @@
// to make them themeable by consumers.
export {default as Day} from './Day'
export {default as Grouping} from './Grouping'
export {default as JumpToHeaderButton} from './JumpToHeaderButton'
export {default as PlannerItem} from './PlannerItem'

View File

@ -394,14 +394,27 @@ describe('K-5 Dashboard', () => {
const header = await findByTestId('WeeklyPlannerHeader')
expect(header).toBeInTheDocument()
})
const footer = await findByTestId('WeeklyPlannerFooter')
expect(footer).toBeInTheDocument()
it('renders an "jump to navigation" button at the bottom of the schedule tab', async () => {
const {findByRole} = render(
<K5Dashboard {...defaultProps} defaultTab="tab-schedule" plannerEnabled />
)
const jumpToNavButton = await findByRole('button', {name: 'Jump to navigation toolbar'})
expect(jumpToNavButton).not.toBeVisible()
act(() => jumpToNavButton.focus())
expect(jumpToNavButton).toBeVisible()
act(() => jumpToNavButton.click())
expect(document.activeElement.id).toBe('weekly-header-active-button')
expect(jumpToNavButton).not.toBeVisible()
})
it('displays a teacher preview if the user has no student enrollments', async () => {
const {findByTestId, getByText} = render(
<K5Dashboard {...defaultProps} defaultTab="tab-schedule" plannerEnable={false} />
<K5Dashboard {...defaultProps} defaultTab="tab-schedule" plannerEnabled={false} />
)
expect(await findByTestId('kinder-panda')).toBeInTheDocument()

View File

@ -19,7 +19,11 @@
import React, {useEffect, useRef, useState} from 'react'
import PropTypes from 'prop-types'
import {createPlannerApp, renderWeeklyPlannerHeader} from '@instructure/canvas-planner'
import {
createPlannerApp,
renderWeeklyPlannerHeader,
JumpToHeaderButton
} from '@instructure/canvas-planner'
const SchedulePage = ({visible = false}) => {
const [isPlannerCreated, setPlannerCreated] = useState(false)
@ -41,7 +45,7 @@ const SchedulePage = ({visible = false}) => {
>
{renderWeeklyPlannerHeader({visible})}
{isPlannerCreated && plannerApp.current}
{isPlannerCreated && renderWeeklyPlannerHeader({visible, isFooter: true})}
{isPlannerCreated && <JumpToHeaderButton />}
</section>
)
}