Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0 # REQUIRED for lerna version
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 24
cache: yarn
Expand All @@ -35,14 +35,14 @@ jobs:
contents: write
id-token: write # REQUIRED for npm trusted publishing
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0 # REQUIRED for lerna version
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 24
registry-url: https://registry.npmjs.org/
Expand All @@ -61,14 +61,14 @@ jobs:
contents: write
id-token: write # REQUIRED for npm trusted publishing
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0 # REQUIRED for lerna version
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: 24
registry-url: https://registry.npmjs.org/
Expand Down
67 changes: 66 additions & 1 deletion packages/ebsr/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,38 @@ export const isSessionComplete = (session) => {
return isNonEmptyArray(a) && isNonEmptyArray(b);
};

function getPlayerAttributes(element) {
const player =
element.closest('pie-player') ||
element.closest('pie-item-player');

if (!player) {
return { baseHeadingLevel: undefined, includeSrHeading: true };
}

const getRaw = (camelCaseName, hyphenatedName, allLowerName) => {
let raw = player[camelCaseName];

// fallback in case someone sets via HTML attribute manually
if (raw == null) {
raw =
player.getAttribute(hyphenatedName) ??
player.getAttribute(allLowerName);
}

return raw;
};

const levelRaw = getRaw('baseHeadingLevel', 'base-heading-level', 'baseheadinglevel');
const level = parseInt(levelRaw, 10);
const baseHeadingLevel = Number.isFinite(level) && level >= 1 && level <= 6 ? level : undefined;

const srRaw = getRaw('includeSrHeading', 'include-sr-heading', 'includesrheading');
const includeSrHeading = srRaw == null ? true : srRaw !== false && srRaw !== 'false';

return { baseHeadingLevel, includeSrHeading };
}

export default class Ebsr extends HTMLElement {
constructor() {
super();
Expand Down Expand Up @@ -85,6 +117,12 @@ export default class Ebsr extends HTMLElement {
mode,
keyMode: this._model[key].choicePrefix,
};

// Parts of an EBSR item should not render their own SR headings —
// the EBSR element itself provides the item-level heading.
const { includeSrHeading, baseHeadingLevel } = getPlayerAttributes(this);
part.includeSrHeading = includeSrHeading;
part.baseHeadingLevel = baseHeadingLevel !== undefined ? Math.min(6, baseHeadingLevel + (includeSrHeading ? 1 : 0)) : undefined;
}
}

Expand Down Expand Up @@ -116,16 +154,43 @@ export default class Ebsr extends HTMLElement {

connectedCallback() {
this._render();
this._initPlayerObserver();
this.addEventListener(SESSION_CHANGED, this.onSessionUpdated);
}

disconnectedCallback() {
this._disconnectPlayerObserver();
this.removeEventListener(SESSION_CHANGED, this.onSessionUpdated);
}

_initPlayerObserver() {
const player = this.closest('pie-player') || this.closest('pie-item-player');
if (!player) return;

this._playerObserver = new MutationObserver(() => {
this._render();
});
this._playerObserver.observe(player, {
attributes: true,
attributeFilter: ['base-heading-level', 'baseheadinglevel', 'include-sr-heading', 'includesrheading'],
});
}

_disconnectPlayerObserver() {
if (this._playerObserver) {
this._playerObserver.disconnect();
this._playerObserver = null;
}
}

_render() {
this.ariaLabel = 'Two-Part Question';
this.role = 'region';

const { baseHeadingLevel: ebsrLevel, includeSrHeading } = getPlayerAttributes(this);
const headingTag = ebsrLevel ? `h${Math.min(6, ebsrLevel)}` : 'h2';
const srHeading = includeSrHeading ? `<${headingTag} class="srOnly">Two-Part Question</${headingTag}>` : '';

this.innerHTML = `
<style>
.srOnly {
Expand All @@ -140,7 +205,7 @@ export default class Ebsr extends HTMLElement {
}
${this._model?.extraCSSRules?.rules}
</style>
<h2 class="srOnly">Two-Part Question</h2>
${srHeading}
<${MC_TAG_NAME} id="a"></${MC_TAG_NAME}>
<${MC_TAG_NAME} id="b"></${MC_TAG_NAME}>
`;
Expand Down
78 changes: 78 additions & 0 deletions packages/multiple-choice/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,51 @@ export const isComplete = (session, model, audioComplete, elementContext) => {
return true;
};

function getPlayerAttributes(element) {
const player =
element.closest('pie-player') ||
element.closest('pie-item-player');

if (!player) {
return { baseHeadingLevel: undefined, includeSrHeading: true };
}

const getRaw = (camelCaseName, hyphenatedName, allLowerName) => {
let raw = player[camelCaseName];

// fallback in case someone sets via HTML attribute manually
if (raw == null) {
raw =
player.getAttribute(hyphenatedName) ??
player.getAttribute(allLowerName);
}

return raw;
};

const levelRaw = getRaw('baseHeadingLevel', 'base-heading-level', 'baseheadinglevel');
const level = parseInt(levelRaw, 10);
const baseHeadingLevel = Number.isFinite(level) && level >= 1 && level <= 6 ? level : undefined;

const srRaw = getRaw('includeSrHeading', 'include-sr-heading', 'includesrheading');
const includeSrHeading = srRaw == null ? true : srRaw !== false && srRaw !== 'false';

console.log('getPlayerAttributes', { baseHeadingLevel, includeSrHeading });
return { baseHeadingLevel, includeSrHeading };
}

// Resolves heading attributes for a custom element, preferring explicit instance
// properties (set by a parent element such as EBSR) over player-level attributes.
function resolveHeadingProps(element) {
const fromPlayer = getPlayerAttributes(element);

console.log('element._baseHeadingLevel', element._baseHeadingLevel, 'element._includeSrHeading', element._includeSrHeading);
return {
baseHeadingLevel: element._baseHeadingLevel !== undefined ? element._baseHeadingLevel : fromPlayer.baseHeadingLevel,
includeSrHeading: element._includeSrHeading !== undefined ? element._includeSrHeading : fromPlayer.includeSrHeading,
};
}

export default class MultipleChoice extends HTMLElement {
constructor() {
super();
Expand All @@ -67,6 +112,7 @@ export default class MultipleChoice extends HTMLElement {
options: this._options,
onChoiceChanged: this._onChange.bind(this),
onShowCorrectToggle: this.onShowCorrectToggle.bind(this),
...resolveHeadingProps(this),
});

//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
Expand Down Expand Up @@ -182,6 +228,16 @@ export default class MultipleChoice extends HTMLElement {
this._rerender();
}

set baseHeadingLevel(level) {
this._baseHeadingLevel = level;
this._rerender();
}

set includeSrHeading(value) {
this._includeSrHeading = value;
this._rerender();
}

set session(s) {
this._session = s;
this._rerender();
Expand Down Expand Up @@ -224,6 +280,7 @@ export default class MultipleChoice extends HTMLElement {

connectedCallback() {
this._initMathObserver();
this._initPlayerObserver();
this._rerender();

// Observation: audio in Chrome will have the autoplay attribute,
Expand Down Expand Up @@ -312,8 +369,29 @@ export default class MultipleChoice extends HTMLElement {
}
}

_initPlayerObserver() {
const player = this.closest('pie-player') || this.closest('pie-item-player');
if (!player) return;

this._playerObserver = new MutationObserver(() => {
this._rerender();
});
this._playerObserver.observe(player, {
attributes: true,
attributeFilter: ['base-heading-level', 'baseheadinglevel', 'include-sr-heading', 'includesrheading'],
});
}

_disconnectPlayerObserver() {
if (this._playerObserver) {
this._playerObserver.disconnect();
this._playerObserver = null;
}
}

disconnectedCallback() {
this._disconnectMathObserver();
this._disconnectPlayerObserver();
if (this._keyboardEventsEnabled) {
window.removeEventListener('keydown', this._boundHandleKeyDown);
this._keyboardEventsEnabled = false;
Expand Down
6 changes: 5 additions & 1 deletion packages/multiple-choice/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class Main extends React.Component {
names: PropTypes.arrayOf(PropTypes.string),
rules: PropTypes.string,
}),
baseHeadingLevel: PropTypes.number,
includeSrHeading: PropTypes.bool,
};

static defaultProps = {
Expand All @@ -22,7 +24,7 @@ class Main extends React.Component {
};

render() {
const { model, onChoiceChanged, session, onShowCorrectToggle, options } = this.props;
const { model, onChoiceChanged, session, onShowCorrectToggle, options, baseHeadingLevel, includeSrHeading } = this.props;
const { extraCSSRules, fontSizeFactor } = model;

// model.partLabel is a property used for ebsr
Expand All @@ -34,6 +36,8 @@ class Main extends React.Component {
session={session}
onChoiceChanged={onChoiceChanged}
onShowCorrectToggle={onShowCorrectToggle}
baseHeadingLevel={baseHeadingLevel}
includeSrHeading={includeSrHeading}
/>
</PreviewLayout>
);
Expand Down
39 changes: 29 additions & 10 deletions packages/multiple-choice/src/multiple-choice.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CorrectAnswerToggle from '@pie-lib/correct-answer-toggle';
import classNames from 'classnames';
import { styled } from '@mui/material/styles';
import Box from '@mui/material/Box';
import { color, Collapsible, PreviewPrompt } from '@pie-lib/render-ui';
import { color, Collapsible, PreviewPrompt, transformDataHeadings } from '@pie-lib/render-ui';
import Translator from '@pie-lib/translator';

import Choice from './choice';
Expand Down Expand Up @@ -107,6 +107,8 @@ export class MultipleChoice extends React.Component {
pauseImage: PropTypes.string,
},
options: PropTypes.object,
baseHeadingLevel: PropTypes.number,
includeSrHeading: PropTypes.bool,
};

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

// renderHeading function was added for accessibility.
renderHeading() {
const { mode, choiceMode } = this.props;
const { mode, choiceMode, includeSrHeading, baseHeadingLevel, partLabel } = this.props;

if (mode !== 'gather') {
// When a part label is present the item is an EBSR part — the SR heading
// is provided by the EBSR element, not here.
const shouldRenderSrHeading = !partLabel && includeSrHeading !== false;

if (!shouldRenderSrHeading || mode !== 'gather') {
return null;
}

return choiceMode === 'radio' ? (
<SrOnly>Multiple Choice Question</SrOnly>
) : (
<SrOnly>Multiple Select Question</SrOnly>
);
const clampedLevel = baseHeadingLevel ? Math.min(6, baseHeadingLevel) : 2;
const HeadingTag = SrOnly.withComponent(`h${clampedLevel}`);
const label = choiceMode === 'radio' ? 'Multiple Choice Question' : 'Multiple Select Question';

return <HeadingTag>{label}</HeadingTag>;
}

handleGroupFocus = (e) => {
Expand Down Expand Up @@ -284,13 +290,26 @@ export class MultipleChoice extends React.Component {
session,
customAudioButton,
options,
baseHeadingLevel,
} = this.props;
const { showCorrect, maxSelectionsErrorState } = this.state;
const isEvaluateMode = mode === 'evaluate';
const showCorrectAnswerToggle = isEvaluateMode && !responseCorrect;
const columnsStyle = gridColumns > 1 ? { gridTemplateColumns: `repeat(${gridColumns}, 1fr)` } : undefined;
const selections = (session.value && session.value.length) || 0;

// Heading levels are optional and only applied when baseHeadingLevel is provided.
const getContentHeadingLevel = () => {
if (!baseHeadingLevel) return undefined;
// SR heading (rendered or external) sits at baseHeadingLevel.
// Content is always one below that; part label (EBSR) sits between them.
let offset = 1; // content default: baseHeadingLevel + 1
if (partLabel) offset += 1; // part label at base + 1, content pushed to base + 2
return Math.min(6, baseHeadingLevel + offset);
};
const contentHeadingLevel = getContentHeadingLevel();
const transformPrompt = (html) => (html && contentHeadingLevel) ? transformDataHeadings(html, contentHeadingLevel) : html;

const teacherInstructionsDiv = (
<PreviewPrompt
tagName="div"
Expand Down Expand Up @@ -326,7 +345,7 @@ export class MultipleChoice extends React.Component {

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

{this.renderHeading()}

Expand Down Expand Up @@ -355,7 +374,7 @@ export class MultipleChoice extends React.Component {
<PreviewPrompt
className="prompt"
defaultClassName="prompt"
prompt={prompt}
prompt={transformPrompt(prompt)}
tagName={'legend'}
autoplayAudioEnabled={autoplayAudioEnabled}
customAudioButton={customAudioButton}
Expand Down
Loading