diff --git a/packages/hotspot/configure/src/root.jsx b/packages/hotspot/configure/src/root.jsx index eb227d406c..c3484e342a 100644 --- a/packages/hotspot/configure/src/root.jsx +++ b/packages/hotspot/configure/src/root.jsx @@ -12,6 +12,8 @@ import { updateImageDimensions, generateValidationMessage, getUpdatedShapes, get const { Panel, toggle, dropdown } = settings; +const checkNullish = (value) => value !== null && value !== undefined; + const DimensionsContainer = styled('div')(({ theme }) => ({ display: 'flex', marginBottom: theme.spacing(1.5), @@ -164,6 +166,7 @@ export class Root extends React.Component { ...props, }); + return ( - - Define Hotspot - - + Define Hotspot + @@ -255,7 +251,7 @@ export class Root extends React.Component { hotspotColor={model.hotspotColor} outlineColor={model.outlineColor} selectedHotspotColor={model.selectedHotspotColor} - hoverOutlineColor={model.hoverOutlineColor} + hoverOutlineColor={checkNullish(model.hoverOutlineColor) ? model.hoverOutlineColor : 'black'} onUpdateImageDimension={onUpdateImageDimension} onUpdateShapes={onUpdateShapes} onImageUpload={onImageUpload} diff --git a/packages/hotspot/controller/src/index.js b/packages/hotspot/controller/src/index.js index 9380b63879..ef1cda9373 100644 --- a/packages/hotspot/controller/src/index.js +++ b/packages/hotspot/controller/src/index.js @@ -8,6 +8,8 @@ import defaults from './defaults'; const log = debug('pie-elements:hotspot:controller'); +const checkNullish = (value) => value !== null && value !== undefined; + export const normalize = (question) => ({ ...defaults, ...question, @@ -44,7 +46,7 @@ export function model(question, session, env) { imageUrl, outlineColor, hotspotColor, - hoverOutlineColor, + hoverOutlineColor: checkNullish(hoverOutlineColor) ? hoverOutlineColor : 'black', selectedHotspotColor, multipleCorrect, partialScoring, @@ -150,25 +152,25 @@ export const getLogTrace = (model, session, env) => { const traceLog = []; const { answers } = session || {}; const { shapes } = model || {}; - + const allShapes = []; if (shapes) { if (shapes.rectangles) allShapes.push(...shapes.rectangles); if (shapes.polygons) allShapes.push(...shapes.polygons); if (shapes.circles) allShapes.push(...shapes.circles); } - + const correctShapes = allShapes.filter(shape => shape.correct); const totalShapes = allShapes.length; - + traceLog.push(`Total of ${totalShapes} hotspot(s) defined, ${correctShapes.length} correct.`); - + if (answers && answers.length > 0) { traceLog.push(`Student selected ${answers.length} hotspot(s).`); - + let correctSelections = 0; let incorrectSelections = 0; - + answers.forEach(answer => { const shape = allShapes.find(s => s.id === answer.id); if (shape && shape.correct) { @@ -177,11 +179,11 @@ export const getLogTrace = (model, session, env) => { incorrectSelections++; } }); - - const missedCorrect = correctShapes.filter(correctShape => + + const missedCorrect = correctShapes.filter(correctShape => !answers.some(answer => answer.id === correctShape.id) ).length; - + if (correctSelections > 0) { traceLog.push(`${correctSelections} correct hotspot(s) selected.`); } @@ -196,14 +198,14 @@ export const getLogTrace = (model, session, env) => { } const partialScoringEnabled = partialScoring.enabled(model, env); - + if (partialScoringEnabled) { traceLog.push(`Score calculated using partial scoring.`); - + if (answers && answers.length > 0) { let correctSelections = 0; let incorrectSelections = 0; - + answers.forEach(answer => { const shape = allShapes.find(s => s.id === answer.id); if (shape && shape.correct) { @@ -212,10 +214,10 @@ export const getLogTrace = (model, session, env) => { incorrectSelections++; } }); - + const totalCorrectAvailable = correctShapes.length; traceLog.push(`Partial scoring calculation: ${correctSelections} correct selections out of ${totalCorrectAvailable} available.`); - + if (incorrectSelections > totalCorrectAvailable) { const extraSelections = incorrectSelections - (totalCorrectAvailable - correctSelections); traceLog.push(`${extraSelections} extra incorrect selection(s) beyond required amount are deducted from score.`); @@ -236,12 +238,12 @@ export function outcome(config, session, env = {}) { log('outcome...'); if (!session || isEmpty(session)) { - resolve({ - score: 0, - empty: true, - traceLog: ['No hotspots selected. Score: 0.'] + resolve({ + score: 0, + empty: true, + traceLog: ['No hotspots selected. Score: 0.'] }); - } + } if (session.answers) { const traceLog = getLogTrace(config, session, env); @@ -249,10 +251,10 @@ export function outcome(config, session, env = {}) { resolve({ score, empty: false, traceLog }); } else { - resolve({ - score: 0, - empty: true, - traceLog: ['No hotspots selected. Score: 0.'] + resolve({ + score: 0, + empty: true, + traceLog: ['No hotspots selected. Score: 0.'] }); } }); diff --git a/packages/hotspot/docs/demo/generate.js b/packages/hotspot/docs/demo/generate.js index a3d993a030..96daa27d54 100644 --- a/packages/hotspot/docs/demo/generate.js +++ b/packages/hotspot/docs/demo/generate.js @@ -13,6 +13,7 @@ exports.model = (id, element) => ({ 'rgba(254, 241, 96, 0.25)', 'rgba(0, 0, 0, 0.1)', ], + // hoverOutlineColor: 'black', outlineColor: 'blue', outlineList: ['blue', 'red', 'yellow'], multipleCorrect: true, @@ -25,7 +26,8 @@ exports.model = (id, element) => ({ x: 261, y: 44, index: 2, - correct: true + correct: true, + // ariaLabel: 'First rectangle', }, { id: '4', @@ -34,8 +36,8 @@ exports.model = (id, element) => ({ x: 270, y: 215.4886474609375, index: 3, - correct: false - } + correct: false, + }, ], polygons: [ { @@ -53,10 +55,11 @@ exports.model = (id, element) => ({ { x: 109, y: 294 }, { x: 106, y: 280 }, { x: 56, y: 193 }, - { x: 68, y: 139 } + { x: 68, y: 139 }, ], index: 1, - correct: false + correct: false, + // ariaLabel: 'First polygon', }, { id: '5', @@ -71,10 +74,10 @@ exports.model = (id, element) => ({ { x: 564, y: 465 }, { x: 575, y: 461 }, { x: 575, y: 438 }, - { x: 544, y: 367 } + { x: 544, y: 367 }, ], index: 4, - correct: false + correct: false, }, { id: '7', @@ -84,11 +87,11 @@ exports.model = (id, element) => ({ { x: 556, y: 287 }, { x: 575, y: 302 }, { x: 601, y: 280 }, - { x: 598, y: 246 } + { x: 598, y: 246 }, ], index: 6, - correct: true - } + correct: true, + }, ], circles: [ { @@ -97,7 +100,8 @@ exports.model = (id, element) => ({ x: 71, y: 420, index: 1, - correct: false + correct: false, + // ariaLabel: 'First circle', }, { id: '6', @@ -105,14 +109,14 @@ exports.model = (id, element) => ({ x: 479, y: 140, index: 5, - correct: false - } - ] + correct: false, + // ariaLabel: 'Second circle', + }, + ], }, rationale: null, teacherInstructions: null, toolbarEditorPosition: 'bottom', - prompt: - '
Which state(s) have names beginning in "North"?
', + prompt: '
Which state(s) have names beginning in "North"?
', rubricEnabled: false, }); diff --git a/packages/hotspot/src/hotspot/circle.jsx b/packages/hotspot/src/hotspot/circle.jsx index 1856c9f8ae..d11928219c 100644 --- a/packages/hotspot/src/hotspot/circle.jsx +++ b/packages/hotspot/src/hotspot/circle.jsx @@ -50,6 +50,7 @@ class CircleComponent extends React.Component { hoverOutlineColor, outlineColor, selected, + focused, x, y, evaluateText, @@ -88,7 +89,7 @@ class CircleComponent extends React.Component { } } - const useHoveredStyle = hovered && hoverOutlineColor; + const useHoveredStyle = (hovered || focused) && hoverOutlineColor; return ( @@ -128,6 +129,7 @@ CircleComponent.propTypes = { isCorrect: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), isEvaluateMode: PropTypes.bool.isRequired, disabled: PropTypes.bool.isRequired, + focused: PropTypes.bool, hoverOutlineColor: PropTypes.string, onClick: PropTypes.func.isRequired, outlineColor: PropTypes.string.isRequired, @@ -145,6 +147,7 @@ CircleComponent.propTypes = { CircleComponent.defaultProps = { isCorrect: false, evaluateText: null, + focused: false, strokeWidth: 5, scale: 1, }; diff --git a/packages/hotspot/src/hotspot/container.jsx b/packages/hotspot/src/hotspot/container.jsx index cf61929da3..01ed3178bf 100644 --- a/packages/hotspot/src/hotspot/container.jsx +++ b/packages/hotspot/src/hotspot/container.jsx @@ -14,8 +14,19 @@ const BaseContainer = styled('div')(({ theme }) => ({ background: theme.palette.common.white, border: `${theme.spacing(1)} solid ${theme.palette.common.white}`, width: 'fit-content', + maxWidth: '100%', + overflowX: 'auto', })); +const HiddenFocusable = styled('span')({ + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', +}); + const ImageContainer = styled('div')({ position: 'relative', width: 'fit-content', @@ -34,6 +45,13 @@ const StyledStage = styled(Stage)({ }); export class Container extends React.Component { + constructor(props) { + super(props); + this.state = { + focusedShapeId: null, + }; + } + isSelected(shape) { const selectedShape = this.props.session.answers.filter((answer) => answer.id === shape.id)[0]; return !!selectedShape; @@ -57,6 +75,44 @@ export class Container extends React.Component { return null; }; + getAllShapesSorted = () => { + const { shapes: { rectangles = [], polygons = [], circles = [] } } = this.props; + const allShapes = [ + ...rectangles.map((s) => ({ ...s, type: 'rectangle' })), + ...polygons.map((s) => ({ ...s, type: 'polygon' })), + ...circles.map((s) => ({ ...s, type: 'circle' })), + ]; + allShapes.sort((a, b) => String(a.id).localeCompare(String(b.id), undefined, { numeric: true })); + + return allShapes; + }; + + handleShapeFocus = (shapeId) => { + this.setState({ focusedShapeId: shapeId }); + }; + + handleShapeBlur = () => { + this.setState({ focusedShapeId: null }); + }; + + handleShapeKeyDown = (e, shapeId) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + + const { onSelectChoice, disabled } = this.props; + + if (!disabled) { + const shape = this.getAllShapesSorted().find((s) => s.id === shapeId); + + if (shape) { + const selected = this.isSelected(shape); + + onSelectChoice({ id: shapeId, selected: !selected, selector: 'Keyboard' }); + } + } + } + }; + render() { const { dimensions: { width: withProp, height: heightProp }, @@ -74,9 +130,13 @@ export class Container extends React.Component { showCorrect, } = this.props; + const { focusedShapeId } = this.state; + const width = withProp * SCALE; const height = heightProp * SCALE; + const sortedShapes = this.getAllShapesSorted(); + return ( {imageUrl ? ( @@ -125,6 +185,7 @@ export class Container extends React.Component { strokeWidth={strokeWidth} markAsCorrect={markAsCorrect} showCorrectEnabled={showCorrect} + focused={focusedShapeId === shape.id} /> ); })} @@ -153,6 +214,7 @@ export class Container extends React.Component { selectedHotspotColor={selectedHotspotColor} hoverOutlineColor={hoverOutlineColor} showCorrectEnabled={showCorrect} + focused={focusedShapeId === polygon.id} /> ); })} @@ -183,11 +245,29 @@ export class Container extends React.Component { selectedHotspotColor={selectedHotspotColor} hoverOutlineColor={hoverOutlineColor} showCorrectEnabled={showCorrect} + focused={focusedShapeId === shape.id} /> ); })} + + {sortedShapes.map((shape) => { + const selected = this.isSelected(shape); + + return ( + this.handleShapeFocus(shape.id)} + onBlur={this.handleShapeBlur} + onKeyDown={(e) => this.handleShapeKeyDown(e, shape.id)} + /> + ); + })} ); } diff --git a/packages/hotspot/src/hotspot/polygon.jsx b/packages/hotspot/src/hotspot/polygon.jsx index 89168583d4..27df355886 100644 --- a/packages/hotspot/src/hotspot/polygon.jsx +++ b/packages/hotspot/src/hotspot/polygon.jsx @@ -68,6 +68,7 @@ class PolygonComponent extends React.Component { hoverOutlineColor, outlineColor, selected, + focused, points, evaluateText, strokeWidth, @@ -120,7 +121,7 @@ class PolygonComponent extends React.Component { iconSrc = faWrong; } } - const useHoveredStyle = hovered && hoverOutlineColor; + const useHoveredStyle = (hovered || focused) && hoverOutlineColor; const xValues = pointsParsed.filter((_, index) => index % 2 === 0); // Even indices are x-coordinates const yValues = pointsParsed.filter((_, index) => index % 2 !== 0); // Odd indices are y-coordinates @@ -174,6 +175,7 @@ PolygonComponent.propTypes = { isEvaluateMode: PropTypes.bool.isRequired, hoverOutlineColor: PropTypes.string, disabled: PropTypes.bool.isRequired, + focused: PropTypes.bool, onClick: PropTypes.func.isRequired, outlineColor: PropTypes.string.isRequired, points: PropTypes.array.isRequired, @@ -188,6 +190,7 @@ PolygonComponent.propTypes = { PolygonComponent.defaultProps = { evaluateText: null, + focused: false, strokeWidth: 5, scale: 1, }; diff --git a/packages/hotspot/src/hotspot/rectangle.jsx b/packages/hotspot/src/hotspot/rectangle.jsx index 43dc26f58e..99108f4e6d 100644 --- a/packages/hotspot/src/hotspot/rectangle.jsx +++ b/packages/hotspot/src/hotspot/rectangle.jsx @@ -51,6 +51,7 @@ class RectComponent extends React.Component { isEvaluateMode, outlineColor, selected, + focused, width, x, y, @@ -60,6 +61,7 @@ class RectComponent extends React.Component { markAsCorrect, showCorrectEnabled, } = this.props; + const { hovered } = this.state; const outlineColorParsed = isEvaluateMode ? this.getEvaluateOutlineColor(isCorrect, markAsCorrect, outlineColor) @@ -102,8 +104,7 @@ class RectComponent extends React.Component { } } - const { hovered } = this.state; - const useHoveredStyle = hovered && hoverOutlineColor; + const useHoveredStyle = (hovered || focused) && hoverOutlineColor; return ( @@ -147,6 +148,7 @@ RectComponent.propTypes = { isEvaluateMode: PropTypes.bool.isRequired, hoverOutlineColor: PropTypes.string, disabled: PropTypes.bool.isRequired, + focused: PropTypes.bool, onClick: PropTypes.func.isRequired, outlineColor: PropTypes.string.isRequired, selected: PropTypes.bool.isRequired, @@ -164,6 +166,7 @@ RectComponent.propTypes = { RectComponent.defaultProps = { isCorrect: false, evaluateText: null, + focused: false, strokeWidth: 5, scale: 1, };