Merge pull request #6

feat: keyboard navigation
This commit is contained in:
Dawid Wysokiński 2021-05-25 20:02:45 +02:00 committed by GitHub
commit aad7f64bfe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 167 additions and 63 deletions

View File

@ -0,0 +1,42 @@
import React from 'react';
import { useLocalStorage } from 'react-use';
import { Box, IconButton, NoSsr } from '@material-ui/core';
import { Alert, AlertTitle } from '@material-ui/lab';
import { Close as CloseIcon } from '@material-ui/icons';
const LOCAL_STORAGE_KEY = 'showKeyboardNavigationNote';
const KeyboardNavigationNote = () => {
const [show, setShow] = useLocalStorage(LOCAL_STORAGE_KEY, true);
if (!show || typeof window === 'undefined') {
return null;
}
return (
<NoSsr>
<Box mb={2}>
<Alert
severity="info"
action={
<IconButton onClick={() => setShow(false)}>
<CloseIcon />
</IconButton>
}
>
<AlertTitle>Nawigacja</AlertTitle>
Czy wiesz, że{' '}
<strong>
możesz poruszać się pomiędzy pytaniami i wybierać odpowiedzi{' '}
</strong>
za pomocą klawiatury? <br />- <strong>klawisze A / B / C / D</strong>{' '}
- wybór odpowiedzi <br />- <strong>lewa strzałka</strong> - powrót do
poprzedniego pytania <br />- <strong>prawa strzałka</strong> -
przejście do następnego pytania <br />
</Alert>
</Box>
</NoSsr>
);
};
export default KeyboardNavigationNote;

View File

@ -1,3 +1,5 @@
import { useKeyPressEvent } from 'react-use';
import { makeStyles, useTheme } from '@material-ui/core/styles';
import { Button, ButtonGroup, useMediaQuery } from '@material-ui/core';
import {
@ -18,6 +20,16 @@ export interface NavigationProps {
reviewMode: boolean;
}
const NavigationKeyPressEvents = ({
onRequestNextTab,
onRequestPrevTab,
}: Pick<NavigationProps, 'onRequestNextTab' | 'onRequestPrevTab'>) => {
useKeyPressEvent('ArrowRight', onRequestNextTab);
useKeyPressEvent('ArrowLeft', onRequestPrevTab);
return null;
};
const Navigation = ({
hasPreviousTab,
hasNextTab,
@ -31,6 +43,7 @@ const Navigation = ({
const classes = useStyles();
const theme = useTheme();
const matches = useMediaQuery(theme.breakpoints.down('sm'));
return (
<div className={classes.buttonContainer}>
<div className={classes.navButtonGroup}>
@ -65,6 +78,10 @@ const Navigation = ({
</Button>
)}
</ButtonGroup>
<NavigationKeyPressEvents
onRequestNextTab={onRequestNextTab}
onRequestPrevTab={onRequestPrevTab}
/>
</div>
);
};

View File

@ -1,4 +1,5 @@
import React, { Fragment } from 'react';
import { useKeyPressEvent } from 'react-use';
import clsx from 'clsx';
import buildURL from 'utils/buildURL';
import { Answer, Question as QuestionT } from 'libs/graphql';
@ -15,18 +16,35 @@ export interface QuestionProps {
answer: Answer;
onSelectAnswer: (answer: Answer) => void;
reviewMode: boolean;
current: boolean;
}
const ANSWERS = Object.values(Answer);
const QuestionKeyPressEvents = ({
onSelectAnswer,
}: Pick<QuestionProps, 'onSelectAnswer'>) => {
useKeyPressEvent(
e => {
return (ANSWERS as string[]).includes(e.key.toLowerCase());
},
e => {
onSelectAnswer(e.key.toLowerCase() as Answer);
}
);
return null;
};
const Question = ({
question,
answer,
onSelectAnswer,
reviewMode,
current,
}: QuestionProps) => {
const classes = useStyles();
const updatedAt = new Date(question.updatedAt).getTime();
return (
<div className={classes.question}>
{question.from && (
@ -111,6 +129,7 @@ const Question = ({
</Button>
);
})}
{current && <QuestionKeyPressEvents onSelectAnswer={onSelectAnswer} />}
</div>
);
};

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState, Fragment } from 'react';
import { useUpdateEffect } from 'react-use';
import clsx from 'clsx';
import { usePrompt } from 'libs/hooks';
@ -30,6 +30,7 @@ import Question from './Question';
import Navigation from './Navigation';
import Summary from './Summary';
import FixedSpinner from './FixedSpinner';
import KeyboardNavigationNote from './KeyboardNavigationNote';
export interface TestProps {
initialQuestions: QuestionT[];
@ -50,6 +51,7 @@ const Test = ({ initialQuestions, qualification }: TestProps) => {
const [startedAt, setStartedAt] = useState(new Date());
const [endedAt, setEndedAt] = useState(new Date());
const classes = useStyles();
const maxTabIndex = questions.length + (reviewMode ? 1 : 0) - 1;
usePrompt(!reviewMode);
const analyticsParams = useMemo(
() => ({
@ -93,6 +95,10 @@ const Test = ({ initialQuestions, qualification }: TestProps) => {
};
const handleSelectAnswer = (index: number, newAnswer: Answer) => {
if (selectedAnswers[index] === newAnswer) {
return;
}
setSelectedAnswers(answers =>
answers.map((oldAnswer, index2) =>
index2 === index ? newAnswer : oldAnswer
@ -107,6 +113,10 @@ const Test = ({ initialQuestions, qualification }: TestProps) => {
};
const handleReset = async () => {
if (isFetching) {
return;
}
try {
setIsFetching(true);
const { generateTest: newQuestions } = await createClient().request<
@ -136,6 +146,20 @@ const Test = ({ initialQuestions, qualification }: TestProps) => {
setReviewMode(true);
};
const handleGoToNextTab = () => {
if (currentTab === maxTabIndex) {
return;
}
setCurrentTab(current => current + 1);
};
const handleGoToPrevTab = () => {
if (currentTab === 0) {
return;
}
setCurrentTab(current => current - 1);
};
return (
<Section>
{isFetching && <FixedSpinner />}
@ -150,74 +174,76 @@ const Test = ({ initialQuestions, qualification }: TestProps) => {
Do tej kwalifikacji nie zostały dodane żadne pytania.
</Typography>
) : (
<Paper>
<AppBar color="default" position="static">
<Tabs
value={currentTab}
textColor="primary"
indicatorColor="primary"
variant="scrollable"
onChange={(_, newTab: number) => setCurrentTab(newTab)}
>
{questions.map((question, index) => {
return (
<Tab
key={question.id}
className={clsx(
reviewMode
? {
[classes.correct]:
question.correctAnswer ===
selectedAnswers[index],
[classes.incorrect]:
question.correctAnswer !==
selectedAnswers[index],
}
: {}
)}
label={`Pytanie ${index + 1}`}
<Fragment>
<KeyboardNavigationNote />
<Paper>
<AppBar color="default" position="static">
<Tabs
value={currentTab}
textColor="primary"
indicatorColor="primary"
variant="scrollable"
onChange={(_, newTab: number) => setCurrentTab(newTab)}
>
{questions.map((question, index) => {
return (
<Tab
key={question.id}
className={clsx(
reviewMode
? {
[classes.correct]:
question.correctAnswer ===
selectedAnswers[index],
[classes.incorrect]:
question.correctAnswer !==
selectedAnswers[index],
}
: {}
)}
label={`Pytanie ${index + 1}`}
/>
);
})}
<Tab label="Podsumowanie" disabled={!reviewMode} />
</Tabs>
</AppBar>
<Box padding={3}>
{questions.map((question, index) => (
<TabPanel key={question.id} value={currentTab} index={index}>
<Question
question={question}
answer={selectedAnswers[index]}
onSelectAnswer={answer =>
handleSelectAnswer(index, answer)
}
reviewMode={reviewMode}
current={currentTab === index}
/>
);
})}
<Tab label="Podsumowanie" disabled={!reviewMode} />
</Tabs>
</AppBar>
<Box padding={3}>
{questions.map((question, index) => (
<TabPanel key={question.id} value={currentTab} index={index}>
<Question
question={question}
answer={selectedAnswers[index]}
onSelectAnswer={answer => handleSelectAnswer(index, answer)}
</TabPanel>
))}
<TabPanel value={currentTab} index={questions.length}>
<Summary
answers={selectedAnswers}
questions={questions}
reviewMode={reviewMode}
startedAt={startedAt}
endedAt={endedAt}
/>
</TabPanel>
))}
<TabPanel value={currentTab} index={questions.length}>
<Summary
answers={selectedAnswers}
questions={questions}
<Navigation
hasPreviousTab={currentTab !== 0}
hasNextTab={currentTab !== maxTabIndex}
onRequestPrevTab={handleGoToPrevTab}
onRequestNextTab={handleGoToNextTab}
isLastQuestion={currentTab === maxTabIndex}
reviewMode={reviewMode}
startedAt={startedAt}
endedAt={endedAt}
onReset={handleReset}
onFinish={handleFinish}
/>
</TabPanel>
<Navigation
hasPreviousTab={currentTab !== 0}
hasNextTab={
currentTab + 1 !== questions.length + (reviewMode ? 1 : 0)
}
onRequestPrevTab={() => setCurrentTab(currentTab - 1)}
onRequestNextTab={() => setCurrentTab(currentTab + 1)}
isLastQuestion={
currentTab + 1 === questions.length + (reviewMode ? 1 : 0)
}
reviewMode={reviewMode}
onReset={handleReset}
onFinish={handleFinish}
/>
</Box>
</Paper>
</Box>
</Paper>
</Fragment>
)}
</Container>
</Section>