Skip to content

Commit 448eced

Browse files
authored
Merge pull request #3005 from pie-framework/feat/PIE-153-PIE-154
feat(multiple-choice / ebsr): handle logic for player heading attribu…
2 parents 68850d0 + 3530ce6 commit 448eced

4 files changed

Lines changed: 178 additions & 12 deletions

File tree

packages/ebsr/src/index.js

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,38 @@ export const isSessionComplete = (session) => {
2626
return isNonEmptyArray(a) && isNonEmptyArray(b);
2727
};
2828

29+
function getPlayerAttributes(element) {
30+
const player =
31+
element.closest('pie-player') ||
32+
element.closest('pie-item-player');
33+
34+
if (!player) {
35+
return { baseHeadingLevel: undefined, includeSrHeading: true };
36+
}
37+
38+
const getRaw = (camelCaseName, hyphenatedName, allLowerName) => {
39+
let raw = player[camelCaseName];
40+
41+
// fallback in case someone sets via HTML attribute manually
42+
if (raw == null) {
43+
raw =
44+
player.getAttribute(hyphenatedName) ??
45+
player.getAttribute(allLowerName);
46+
}
47+
48+
return raw;
49+
};
50+
51+
const levelRaw = getRaw('baseHeadingLevel', 'base-heading-level', 'baseheadinglevel');
52+
const level = parseInt(levelRaw, 10);
53+
const baseHeadingLevel = Number.isFinite(level) && level >= 1 && level <= 6 ? level : undefined;
54+
55+
const srRaw = getRaw('includeSrHeading', 'include-sr-heading', 'includesrheading');
56+
const includeSrHeading = srRaw == null ? true : srRaw !== false && srRaw !== 'false';
57+
58+
return { baseHeadingLevel, includeSrHeading };
59+
}
60+
2961
export default class Ebsr extends HTMLElement {
3062
constructor() {
3163
super();
@@ -85,6 +117,12 @@ export default class Ebsr extends HTMLElement {
85117
mode,
86118
keyMode: this._model[key].choicePrefix,
87119
};
120+
121+
// Parts of an EBSR item should not render their own SR headings —
122+
// the EBSR element itself provides the item-level heading.
123+
const { includeSrHeading, baseHeadingLevel } = getPlayerAttributes(this);
124+
part.includeSrHeading = includeSrHeading;
125+
part.baseHeadingLevel = baseHeadingLevel !== undefined ? Math.min(6, baseHeadingLevel + (includeSrHeading ? 1 : 0)) : undefined;
88126
}
89127
}
90128

@@ -116,16 +154,43 @@ export default class Ebsr extends HTMLElement {
116154

117155
connectedCallback() {
118156
this._render();
157+
this._initPlayerObserver();
119158
this.addEventListener(SESSION_CHANGED, this.onSessionUpdated);
120159
}
121160

122161
disconnectedCallback() {
162+
this._disconnectPlayerObserver();
123163
this.removeEventListener(SESSION_CHANGED, this.onSessionUpdated);
124164
}
125165

166+
_initPlayerObserver() {
167+
const player = this.closest('pie-player') || this.closest('pie-item-player');
168+
if (!player) return;
169+
170+
this._playerObserver = new MutationObserver(() => {
171+
this._render();
172+
});
173+
this._playerObserver.observe(player, {
174+
attributes: true,
175+
attributeFilter: ['base-heading-level', 'baseheadinglevel', 'include-sr-heading', 'includesrheading'],
176+
});
177+
}
178+
179+
_disconnectPlayerObserver() {
180+
if (this._playerObserver) {
181+
this._playerObserver.disconnect();
182+
this._playerObserver = null;
183+
}
184+
}
185+
126186
_render() {
127187
this.ariaLabel = 'Two-Part Question';
128188
this.role = 'region';
189+
190+
const { baseHeadingLevel: ebsrLevel, includeSrHeading } = getPlayerAttributes(this);
191+
const headingTag = ebsrLevel ? `h${Math.min(6, ebsrLevel)}` : 'h2';
192+
const srHeading = includeSrHeading ? `<${headingTag} class="srOnly">Two-Part Question</${headingTag}>` : '';
193+
129194
this.innerHTML = `
130195
<style>
131196
.srOnly {
@@ -140,7 +205,7 @@ export default class Ebsr extends HTMLElement {
140205
}
141206
${this._model?.extraCSSRules?.rules}
142207
</style>
143-
<h2 class="srOnly">Two-Part Question</h2>
208+
${srHeading}
144209
<${MC_TAG_NAME} id="a"></${MC_TAG_NAME}>
145210
<${MC_TAG_NAME} id="b"></${MC_TAG_NAME}>
146211
`;

packages/multiple-choice/src/index.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,51 @@ export const isComplete = (session, model, audioComplete, elementContext) => {
4444
return true;
4545
};
4646

47+
function getPlayerAttributes(element) {
48+
const player =
49+
element.closest('pie-player') ||
50+
element.closest('pie-item-player');
51+
52+
if (!player) {
53+
return { baseHeadingLevel: undefined, includeSrHeading: true };
54+
}
55+
56+
const getRaw = (camelCaseName, hyphenatedName, allLowerName) => {
57+
let raw = player[camelCaseName];
58+
59+
// fallback in case someone sets via HTML attribute manually
60+
if (raw == null) {
61+
raw =
62+
player.getAttribute(hyphenatedName) ??
63+
player.getAttribute(allLowerName);
64+
}
65+
66+
return raw;
67+
};
68+
69+
const levelRaw = getRaw('baseHeadingLevel', 'base-heading-level', 'baseheadinglevel');
70+
const level = parseInt(levelRaw, 10);
71+
const baseHeadingLevel = Number.isFinite(level) && level >= 1 && level <= 6 ? level : undefined;
72+
73+
const srRaw = getRaw('includeSrHeading', 'include-sr-heading', 'includesrheading');
74+
const includeSrHeading = srRaw == null ? true : srRaw !== false && srRaw !== 'false';
75+
76+
console.log('getPlayerAttributes', { baseHeadingLevel, includeSrHeading });
77+
return { baseHeadingLevel, includeSrHeading };
78+
}
79+
80+
// Resolves heading attributes for a custom element, preferring explicit instance
81+
// properties (set by a parent element such as EBSR) over player-level attributes.
82+
function resolveHeadingProps(element) {
83+
const fromPlayer = getPlayerAttributes(element);
84+
85+
console.log('element._baseHeadingLevel', element._baseHeadingLevel, 'element._includeSrHeading', element._includeSrHeading);
86+
return {
87+
baseHeadingLevel: element._baseHeadingLevel !== undefined ? element._baseHeadingLevel : fromPlayer.baseHeadingLevel,
88+
includeSrHeading: element._includeSrHeading !== undefined ? element._includeSrHeading : fromPlayer.includeSrHeading,
89+
};
90+
}
91+
4792
export default class MultipleChoice extends HTMLElement {
4893
constructor() {
4994
super();
@@ -67,6 +112,7 @@ export default class MultipleChoice extends HTMLElement {
67112
options: this._options,
68113
onChoiceChanged: this._onChange.bind(this),
69114
onShowCorrectToggle: this.onShowCorrectToggle.bind(this),
115+
...resolveHeadingProps(this),
70116
});
71117

72118
//TODO: aria-label is set in the _rerender because we need to change it when the model.choiceMode is updated. Consider revisiting the placement of the aria-label setting in the _rerender
@@ -182,6 +228,16 @@ export default class MultipleChoice extends HTMLElement {
182228
this._rerender();
183229
}
184230

231+
set baseHeadingLevel(level) {
232+
this._baseHeadingLevel = level;
233+
this._rerender();
234+
}
235+
236+
set includeSrHeading(value) {
237+
this._includeSrHeading = value;
238+
this._rerender();
239+
}
240+
185241
set session(s) {
186242
this._session = s;
187243
this._rerender();
@@ -224,6 +280,7 @@ export default class MultipleChoice extends HTMLElement {
224280

225281
connectedCallback() {
226282
this._initMathObserver();
283+
this._initPlayerObserver();
227284
this._rerender();
228285

229286
// Observation: audio in Chrome will have the autoplay attribute,
@@ -312,8 +369,29 @@ export default class MultipleChoice extends HTMLElement {
312369
}
313370
}
314371

372+
_initPlayerObserver() {
373+
const player = this.closest('pie-player') || this.closest('pie-item-player');
374+
if (!player) return;
375+
376+
this._playerObserver = new MutationObserver(() => {
377+
this._rerender();
378+
});
379+
this._playerObserver.observe(player, {
380+
attributes: true,
381+
attributeFilter: ['base-heading-level', 'baseheadinglevel', 'include-sr-heading', 'includesrheading'],
382+
});
383+
}
384+
385+
_disconnectPlayerObserver() {
386+
if (this._playerObserver) {
387+
this._playerObserver.disconnect();
388+
this._playerObserver = null;
389+
}
390+
}
391+
315392
disconnectedCallback() {
316393
this._disconnectMathObserver();
394+
this._disconnectPlayerObserver();
317395
if (this._keyboardEventsEnabled) {
318396
window.removeEventListener('keydown', this._boundHandleKeyDown);
319397
this._keyboardEventsEnabled = false;

packages/multiple-choice/src/main.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class Main extends React.Component {
1414
names: PropTypes.arrayOf(PropTypes.string),
1515
rules: PropTypes.string,
1616
}),
17+
baseHeadingLevel: PropTypes.number,
18+
includeSrHeading: PropTypes.bool,
1719
};
1820

1921
static defaultProps = {
@@ -22,7 +24,7 @@ class Main extends React.Component {
2224
};
2325

2426
render() {
25-
const { model, onChoiceChanged, session, onShowCorrectToggle, options } = this.props;
27+
const { model, onChoiceChanged, session, onShowCorrectToggle, options, baseHeadingLevel, includeSrHeading } = this.props;
2628
const { extraCSSRules, fontSizeFactor } = model;
2729

2830
// model.partLabel is a property used for ebsr
@@ -34,6 +36,8 @@ class Main extends React.Component {
3436
session={session}
3537
onChoiceChanged={onChoiceChanged}
3638
onShowCorrectToggle={onShowCorrectToggle}
39+
baseHeadingLevel={baseHeadingLevel}
40+
includeSrHeading={includeSrHeading}
3741
/>
3842
</PreviewLayout>
3943
);

packages/multiple-choice/src/multiple-choice.jsx

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import CorrectAnswerToggle from '@pie-lib/correct-answer-toggle';
44
import classNames from 'classnames';
55
import { styled } from '@mui/material/styles';
66
import Box from '@mui/material/Box';
7-
import { color, Collapsible, PreviewPrompt } from '@pie-lib/render-ui';
7+
import { color, Collapsible, PreviewPrompt, transformDataHeadings } from '@pie-lib/render-ui';
88
import Translator from '@pie-lib/translator';
99

1010
import Choice from './choice';
@@ -107,6 +107,8 @@ export class MultipleChoice extends React.Component {
107107
pauseImage: PropTypes.string,
108108
},
109109
options: PropTypes.object,
110+
baseHeadingLevel: PropTypes.number,
111+
includeSrHeading: PropTypes.bool,
110112
};
111113

112114
constructor(props) {
@@ -233,17 +235,21 @@ export class MultipleChoice extends React.Component {
233235

234236
// renderHeading function was added for accessibility.
235237
renderHeading() {
236-
const { mode, choiceMode } = this.props;
238+
const { mode, choiceMode, includeSrHeading, baseHeadingLevel, partLabel } = this.props;
237239

238-
if (mode !== 'gather') {
240+
// When a part label is present the item is an EBSR part — the SR heading
241+
// is provided by the EBSR element, not here.
242+
const shouldRenderSrHeading = !partLabel && includeSrHeading !== false;
243+
244+
if (!shouldRenderSrHeading || mode !== 'gather') {
239245
return null;
240246
}
241247

242-
return choiceMode === 'radio' ? (
243-
<SrOnly>Multiple Choice Question</SrOnly>
244-
) : (
245-
<SrOnly>Multiple Select Question</SrOnly>
246-
);
248+
const clampedLevel = baseHeadingLevel ? Math.min(6, baseHeadingLevel) : 2;
249+
const HeadingTag = SrOnly.withComponent(`h${clampedLevel}`);
250+
const label = choiceMode === 'radio' ? 'Multiple Choice Question' : 'Multiple Select Question';
251+
252+
return <HeadingTag>{label}</HeadingTag>;
247253
}
248254

249255
handleGroupFocus = (e) => {
@@ -284,13 +290,26 @@ export class MultipleChoice extends React.Component {
284290
session,
285291
customAudioButton,
286292
options,
293+
baseHeadingLevel,
287294
} = this.props;
288295
const { showCorrect, maxSelectionsErrorState } = this.state;
289296
const isEvaluateMode = mode === 'evaluate';
290297
const showCorrectAnswerToggle = isEvaluateMode && !responseCorrect;
291298
const columnsStyle = gridColumns > 1 ? { gridTemplateColumns: `repeat(${gridColumns}, 1fr)` } : undefined;
292299
const selections = (session.value && session.value.length) || 0;
293300

301+
// Heading levels are optional and only applied when baseHeadingLevel is provided.
302+
const getContentHeadingLevel = () => {
303+
if (!baseHeadingLevel) return undefined;
304+
// SR heading (rendered or external) sits at baseHeadingLevel.
305+
// Content is always one below that; part label (EBSR) sits between them.
306+
let offset = 1; // content default: baseHeadingLevel + 1
307+
if (partLabel) offset += 1; // part label at base + 1, content pushed to base + 2
308+
return Math.min(6, baseHeadingLevel + offset);
309+
};
310+
const contentHeadingLevel = getContentHeadingLevel();
311+
const transformPrompt = (html) => (html && contentHeadingLevel) ? transformDataHeadings(html, contentHeadingLevel) : html;
312+
294313
const teacherInstructionsDiv = (
295314
<PreviewPrompt
296315
tagName="div"
@@ -326,7 +345,7 @@ export class MultipleChoice extends React.Component {
326345

327346
return (
328347
<MainContainer id={'main-container'} className={classNames(className, 'multiple-choice')}>
329-
{partLabel && <PartLabel>{partLabel}</PartLabel>}
348+
{partLabel && <PartLabel as={baseHeadingLevel ? `h${Math.min(6, baseHeadingLevel + 1)}` : 'h2'}>{partLabel}</PartLabel>}
330349

331350
{this.renderHeading()}
332351

@@ -355,7 +374,7 @@ export class MultipleChoice extends React.Component {
355374
<PreviewPrompt
356375
className="prompt"
357376
defaultClassName="prompt"
358-
prompt={prompt}
377+
prompt={transformPrompt(prompt)}
359378
tagName={'legend'}
360379
autoplayAudioEnabled={autoplayAudioEnabled}
361380
customAudioButton={customAudioButton}

0 commit comments

Comments
 (0)