Skip to content

Commit 945172e

Browse files
Merge pull request #2107 from pie-framework/feat/PD-2453-NEW
feat(math-inline): Enhance keyboard navigation, focus and aria attributes for math editors PD-2453
2 parents 38ced53 + 593b825 commit 945172e

4 files changed

Lines changed: 1084 additions & 850 deletions

File tree

packages/math-inline/src/__tests__/main.test.js

Lines changed: 226 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Main from '../main';
44
import { mq, HorizontalKeypad } from '@pie-lib/pie-toolbox/math-input';
55
import { shallowChild } from '@pie-lib/pie-toolbox/test-utils';
66
import { Feedback } from '@pie-lib/pie-toolbox/render-ui';
7-
import {CorrectAnswerToggle} from '@pie-lib/pie-toolbox/correct-answer-toggle';
7+
import { CorrectAnswerToggle } from '@pie-lib/pie-toolbox/correct-answer-toggle';
88
import SimpleQuestionBlock from '../simple-question-block';
99

1010
const Mathquill = require('@pie-framework/mathquill');
@@ -103,6 +103,64 @@ describe('Math-Inline Main', () => {
103103
});
104104
});
105105

106+
describe('handleKeyDown', () => {
107+
let instance;
108+
let textarea;
109+
110+
beforeEach(() => {
111+
wrapper = component();
112+
instance = wrapper.instance();
113+
114+
textarea = document.createElement('textarea');
115+
textarea.setAttribute('aria-label', 'Enter answer.');
116+
document.body.appendChild(textarea);
117+
textarea.focus();
118+
});
119+
120+
afterEach(() => {
121+
document.body.innerHTML = '';
122+
});
123+
124+
it('should have handleKeyDown method', () => {
125+
expect(instance.handleKeyDown).toBeInstanceOf(Function);
126+
});
127+
128+
it('should activate the keypad when ArrowDown is pressed', () => {
129+
const event = { key: 'ArrowDown', target: document.activeElement };
130+
instance.handleKeyDown(event, 'r1');
131+
expect(wrapper.state('activeAnswerBlock')).toEqual('r1');
132+
});
133+
134+
it('should activate the keypad on click or touch event', () => {
135+
const clickEvent = { type: 'click', target: document.activeElement };
136+
instance.handleKeyDown(clickEvent, 'r1');
137+
expect(wrapper.state('activeAnswerBlock')).toEqual('r1');
138+
139+
wrapper.setState({ activeAnswerBlock: '' });
140+
const touchEvent = { type: 'touchstart', target: document.activeElement };
141+
instance.handleKeyDown(touchEvent, 'r1');
142+
expect(wrapper.state('activeAnswerBlock')).toEqual('r1');
143+
});
144+
145+
it('should deactivate the keypad on Escape key press', () => {
146+
wrapper.setState({ activeAnswerBlock: 'r1' });
147+
instance.handleKeyDown({ key: 'Escape', target: document.activeElement }, 'r1');
148+
expect(wrapper.state('activeAnswerBlock')).toEqual('');
149+
});
150+
151+
it('should deactivate the keypad if the input is not focused and a click or touch event occurs', () => {
152+
textarea.blur();
153+
wrapper.setState({ activeAnswerBlock: 'r1' });
154+
155+
const differentElement = document.createElement('div');
156+
document.body.appendChild(differentElement);
157+
158+
instance.handleKeyDown({ type: 'click', target: differentElement }, 'r2');
159+
160+
expect(wrapper.state('activeAnswerBlock')).toEqual('');
161+
});
162+
});
163+
106164
describe('logic', () => {
107165
it('prepares latex correctly and answer blocks and turns them into inputs', () => {
108166
expect(wrapper.dive().find(mq.Static).length).toEqual(1);
@@ -124,56 +182,6 @@ describe('Math-Inline Main', () => {
124182
expect(wrapper.dive().find(SimpleQuestionBlock).length).toEqual(1);
125183
});
126184

127-
it('correctly shows the keypad', () => {
128-
wrapper = component();
129-
130-
expect(wrapper.find(HorizontalKeypad).length).toEqual(0);
131-
wrapper.instance().onSubFieldFocus('r1');
132-
expect(wrapper.state()).toEqual({
133-
activeAnswerBlock: 'r1',
134-
session: {
135-
answers: {
136-
r1: {
137-
value: '',
138-
},
139-
r2: {
140-
value: '',
141-
},
142-
r3: {
143-
value: '',
144-
},
145-
r4: {
146-
value: '',
147-
},
148-
},
149-
},
150-
showCorrect: false,
151-
});
152-
});
153-
154-
it('correctly keeps the keypad open', () => {
155-
wrapper = component();
156-
157-
expect(wrapper.find(HorizontalKeypad).length).toEqual(0);
158-
wrapper.instance().onSubFieldFocus('r1');
159-
wrapper.instance().onBlur({
160-
relatedTarget: { offsetParent: { getAttribute: () => 'tooltip', children: [{ attributes: { 'data-keypad': true } }] } },
161-
currentTarget: { offsetParent: 'editor1' },
162-
});
163-
expect(wrapper.state().activeAnswerBlock).toEqual('r1');
164-
});
165-
166-
it('correctly hides the keypad', () => {
167-
wrapper = component();
168-
169-
expect(wrapper.find(HorizontalKeypad).length).toEqual(0);
170-
wrapper.instance().onBlur({
171-
relatedTarget: { offsetParent: { getAttribute: () => 'tooltip', children: [{ attributes: { 'data-keypad': false } }] } },
172-
currentTarget: { offsetParent: 'editor2' },
173-
});
174-
expect(wrapper.state().activeAnswerBlock).toEqual('');
175-
});
176-
177185
it('correctly pre-populates answers from session', () => {
178186
wrapper = component({
179187
session: {
@@ -284,4 +292,171 @@ describe('Math-Inline Main', () => {
284292
});
285293
});
286294
});
295+
296+
describe('Main component additional functions', () => {
297+
let instance;
298+
let textarea;
299+
300+
beforeEach(() => {
301+
wrapper = component();
302+
instance = wrapper.instance();
303+
304+
textarea = document.createElement('textarea');
305+
textarea.setAttribute('aria-label', 'Enter answer.');
306+
document.body.appendChild(textarea);
307+
textarea.focus();
308+
});
309+
310+
afterEach(() => {
311+
document.body.innerHTML = '';
312+
});
313+
314+
it('should handle answer block DOM update correctly', () => {
315+
const mockRoot = document.createElement('div');
316+
mockRoot.innerHTML = `
317+
<div id="r1"></div>
318+
<div id="r1Index"></div>
319+
`;
320+
instance.root = mockRoot;
321+
322+
instance.handleAnswerBlockDomUpdate();
323+
324+
expect(mockRoot.querySelector('#r1').textContent).toEqual('');
325+
});
326+
327+
it('should count response occurrences correctly', () => {
328+
const expression = '{{response}} + {{response}} = {{response}}';
329+
const count = instance.countResponseOccurrences(expression);
330+
expect(count).toEqual(3);
331+
});
332+
333+
it('should update aria attributes correctly', () => {
334+
const mockRoot = document.createElement('div');
335+
mockRoot.innerHTML = `
336+
<div class="mq-selectable"></div>
337+
<div class="mq-selectable"></div>
338+
<div class="mq-textarea">
339+
<textarea></textarea>
340+
</div>
341+
`;
342+
instance.root = mockRoot;
343+
344+
instance.updateAria();
345+
346+
const textareaElements = mockRoot.querySelectorAll('textarea');
347+
textareaElements.forEach((elem, index) => {
348+
expect(elem.getAttribute('aria-label')).toEqual('Enter answer.');
349+
expect(elem.getAttribute('aria-describedby')).not.toBeNull();
350+
const describedById = elem.getAttribute('aria-describedby');
351+
const describedByElement = mockRoot.querySelector(`#${describedById}`);
352+
expect(describedByElement).not.toBeNull();
353+
});
354+
});
355+
356+
it('should focus first keypad element correctly', () => {
357+
jest.useFakeTimers();
358+
359+
const mockRoot = document.createElement('div');
360+
mockRoot.innerHTML = `
361+
<div data-keypad="true">
362+
<button>Button 1</button>
363+
<button>Button 2</button>
364+
</div>
365+
`;
366+
367+
document.body.appendChild(mockRoot);
368+
369+
instance.root = mockRoot;
370+
371+
instance.focusFirstKeypadElement();
372+
373+
jest.runAllTimers();
374+
375+
const focusedElement = document.activeElement;
376+
expect(focusedElement.textContent).toEqual('Button 1');
377+
378+
document.body.removeChild(mockRoot);
379+
380+
jest.useRealTimers();
381+
});
382+
383+
it('should handle blur correctly', () => {
384+
wrapper.setState({ activeAnswerBlock: 'r1' });
385+
386+
const mockRelatedTarget = document.createElement('div');
387+
const parentWithTooltip = document.createElement('div');
388+
parentWithTooltip.setAttribute('role', 'tooltip');
389+
390+
const childWithKeypad = document.createElement('div');
391+
childWithKeypad.setAttribute('data-keypad', 'true');
392+
393+
parentWithTooltip.appendChild(childWithKeypad);
394+
395+
Object.defineProperty(mockRelatedTarget, 'offsetParent', {
396+
get: function () {
397+
return parentWithTooltip;
398+
},
399+
});
400+
401+
const event = {
402+
relatedTarget: mockRelatedTarget,
403+
currentTarget: document.createElement('div'),
404+
};
405+
406+
instance.onBlur(event);
407+
408+
expect(wrapper.state('activeAnswerBlock')).toEqual('r1');
409+
410+
event.relatedTarget = null;
411+
instance.onBlur(event);
412+
413+
expect(wrapper.state('activeAnswerBlock')).toEqual('');
414+
});
415+
416+
it('should call onSessionChange correctly', () => {
417+
wrapper.setState({
418+
session: {
419+
answers: {
420+
r1: { value: 'test' },
421+
},
422+
},
423+
});
424+
425+
instance.callOnSessionChange();
426+
427+
expect(defaultProps.onSessionChange).toHaveBeenCalledWith({
428+
answers: {
429+
r1: { value: 'test' },
430+
},
431+
});
432+
});
433+
434+
it('should toggle show correct state correctly', () => {
435+
instance.toggleShowCorrect(true);
436+
expect(wrapper.state('showCorrect')).toEqual(true);
437+
438+
instance.toggleShowCorrect(false);
439+
expect(wrapper.state('showCorrect')).toEqual(false);
440+
});
441+
442+
it('should handle subFieldChanged correctly', () => {
443+
instance.subFieldChanged('r1', 'new value');
444+
445+
expect(wrapper.state('session').answers.r1.value).toEqual('new value');
446+
});
447+
448+
it('should get the correct field name', () => {
449+
const fields = { r1: { id: 1 }, r2: { id: 2 } };
450+
wrapper.setState({
451+
session: {
452+
answers: {
453+
r1: { value: 'test' },
454+
},
455+
},
456+
});
457+
458+
const fieldName = instance.getFieldName({ id: 1 }, fields);
459+
expect(fieldName).toEqual('r1');
460+
});
461+
});
287462
});

packages/math-inline/src/__tests__/simple-question-block.test.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ describe('SimpleQuestionBlock', () => {
5050
response: 'sessionResponse',
5151
},
5252
onSimpleResponseChange: jest.fn(),
53+
onSubFieldFocus: jest.fn(),
54+
showKeypad: true,
5355
};
5456

5557
let wrapper;
@@ -79,25 +81,29 @@ describe('SimpleQuestionBlock', () => {
7981
component = wrapper();
8082

8183
component.instance().onFocus();
82-
expect(component.state().showKeypad).toEqual(true);
84+
expect(defaultProps.onSubFieldFocus).toHaveBeenCalledWith(component.instance().mathToolBarId);
85+
expect(component.instance().props.showKeypad).toEqual(true);
8386

84-
// hardcoded
8587
component.instance().mathToolBarContainsTarget = () => true;
8688
component.instance().handleClick();
8789

88-
expect(component.state().showKeypad).toEqual(true);
90+
expect(component.instance().props.showKeypad).toEqual(true);
8991
});
9092

9193
it('correctly hides the keypad', () => {
9294
component = wrapper();
9395

9496
component.instance().onFocus();
95-
expect(component.state().showKeypad).toEqual(true);
97+
expect(defaultProps.onSubFieldFocus).toHaveBeenCalledWith(component.instance().mathToolBarId);
98+
expect(component.instance().props.showKeypad).toEqual(true);
9699

97-
// hardcoded
100+
// Simulate clicking outside the toolbar (to hide the keypad)
98101
component.instance().mathToolBarContainsTarget = () => false;
99102
component.instance().handleClick();
100103

101-
expect(component.state().showKeypad).toEqual(false);
104+
// Simulate the parent component reacting to the event by setting showKeypad to false
105+
component.setProps({ showKeypad: false });
106+
107+
expect(component.instance().props.showKeypad).toEqual(false);
102108
});
103109
});

0 commit comments

Comments
 (0)