diff --git a/.changeset/common-cougars-hang.md b/.changeset/common-cougars-hang.md
new file mode 100644
index 00000000..a14ac250
--- /dev/null
+++ b/.changeset/common-cougars-hang.md
@@ -0,0 +1,5 @@
+---
+"@pie-element/multiple-choice": patch
+---
+
+test multiple-choice release flow
diff --git a/.changeset/pre.json b/.changeset/pre.json
new file mode 100644
index 00000000..99b66b7b
--- /dev/null
+++ b/.changeset/pre.json
@@ -0,0 +1,84 @@
+{
+ "mode": "pre",
+ "tag": "next",
+ "initialVersions": {
+ "@pie-element/element-demo": "0.1.1",
+ "esm-player-test": "1.0.0",
+ "@pie-element/core": "0.1.0",
+ "@pie-element/element-player": "0.1.1",
+ "@pie-element/element-theme": "0.1.0",
+ "@pie-element/element-theme-daisyui": "0.1.0",
+ "@pie-element/categorize": "13.0.1",
+ "@pie-element/charting": "12.0.1",
+ "@pie-element/complex-rubric": "7.0.1",
+ "@pie-element/drag-in-the-blank": "10.0.1",
+ "@pie-element/drawing-response": "12.0.1",
+ "@pie-element/ebsr": "14.1.0",
+ "@pie-element/explicit-constructed-response": "11.0.1",
+ "@pie-element/extended-text-entry": "15.0.1",
+ "@pie-element/fraction-model": "6.0.1",
+ "@pie-element/graphing": "10.0.1",
+ "@pie-element/graphing-solution-set": "6.0.1",
+ "@pie-element/hotspot": "11.0.1",
+ "@pie-element/image-cloze-association": "10.0.1",
+ "@pie-element/inline-dropdown": "10.0.1",
+ "@pie-element/likert": "4.0.1",
+ "@pie-element/match": "12.0.1",
+ "@pie-element/match-list": "7.0.1",
+ "@pie-element/math-inline": "0.1.0",
+ "@pie-element/math-templated": "0.1.0",
+ "@pie-element/matrix": "4.0.1",
+ "@pie-element/multi-trait-rubric": "8.0.1",
+ "@pie-element/multiple-choice": "13.1.0",
+ "@pie-element/number-line": "13.0.1",
+ "@pie-element/passage": "7.0.1",
+ "@pie-element/placement-ordering": "14.0.1",
+ "@pie-element/rubric": "8.0.1",
+ "@pie-element/select-text": "13.0.1",
+ "@pie-element/mc-populated-blank": "0.2.10",
+ "@pie-element/simple-cloze": "0.1.3",
+ "@pie-element/venn-classification": "0.1.0",
+ "@pie-lib/categorize": "2.0.1",
+ "@pie-lib/charting": "7.0.1",
+ "@pie-lib/config-ui": "13.0.1",
+ "@pie-lib/controller-utils": "2.0.1",
+ "@pie-lib/correct-answer-toggle": "4.0.1",
+ "@pie-lib/drag": "4.0.1",
+ "@pie-lib/editable-html-tip-tap": "2.0.1",
+ "@pie-lib/graphing": "4.0.2",
+ "@pie-lib/graphing-solution-set": "4.0.1",
+ "@pie-lib/graphing-utils": "3.0.1",
+ "@pie-lib/icons": "4.0.1",
+ "@pie-lib/mask-markup": "3.0.1",
+ "@pie-lib/math-input": "0.1.0",
+ "@pie-lib/math-rendering": "0.1.0",
+ "@pie-lib/math-toolbar": "3.0.1",
+ "@pie-lib/plot": "4.0.1",
+ "@pie-lib/render-ui": "6.0.1",
+ "@pie-lib/rubric": "2.0.1",
+ "@pie-lib/style-utils": "2.0.1",
+ "@pie-lib/test-utils": "2.0.1",
+ "@pie-lib/text-select": "3.0.1",
+ "@pie-lib/tools": "2.0.1",
+ "@pie-lib/translator": "4.0.1",
+ "@pie-lib/delivery-events-svelte": "0.1.0",
+ "@pie-lib/editable-html-tiptap-svelte": "0.1.2",
+ "@pie-lib/math-input-svelte": "0.1.0",
+ "@pie-lib/styling-svelte": "0.1.2",
+ "@pie-element/print-player": "1.0.1",
+ "@pie-element/element-bundler": "0.1.1",
+ "@pie-element/bundler-shared": "0.1.1",
+ "@pie-element/shared-configure-events": "0.1.0",
+ "@pie-element/shared-controller-utils": "0.1.0",
+ "@pie-element/shared-feedback": "0.1.0",
+ "@pie-element/shared-math-rendering-mathjax": "0.1.0",
+ "@pie-element/shared-player-events": "0.1.0",
+ "@pie-element/shared-test-utils": "0.1.0",
+ "@pie-element/shared-theming": "0.1.0",
+ "@pie-element/shared-theming-mui": "0.1.0",
+ "@pie-element/shared-types": "0.1.0",
+ "@pie-element/shared-utils": "0.1.0",
+ "@pie-element/cli": "0.1.1"
+ },
+ "changesets": []
+}
diff --git a/bun.lock b/bun.lock
index 89b77535..5083e586 100644
--- a/bun.lock
+++ b/bun.lock
@@ -310,7 +310,7 @@
},
"packages/elements-react/ebsr": {
"name": "@pie-element/ebsr",
- "version": "14.0.1",
+ "version": "14.1.0",
"dependencies": {
"@mui/material": "^7.3.4",
"@pie-element/multiple-choice": "workspace:*",
@@ -858,7 +858,7 @@
},
"packages/elements-react/multiple-choice": {
"name": "@pie-element/multiple-choice",
- "version": "13.0.1",
+ "version": "13.1.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/style": "^0.8.0",
@@ -1340,7 +1340,7 @@
},
"packages/lib-react/graphing": {
"name": "@pie-lib/graphing",
- "version": "4.0.1",
+ "version": "4.0.2",
"dependencies": {
"@dnd-kit/core": "^6.3.0",
"@dnd-kit/sortable": "10.0.0",
diff --git a/packages/elements-react/ebsr/package.json b/packages/elements-react/ebsr/package.json
index 717252a4..c3948598 100644
--- a/packages/elements-react/ebsr/package.json
+++ b/packages/elements-react/ebsr/package.json
@@ -1,6 +1,6 @@
{
"name": "@pie-element/ebsr",
- "version": "14.0.1",
+ "version": "14.1.0",
"description": "",
"dependencies": {
"@pie-element/multiple-choice": "workspace:*",
diff --git a/packages/elements-react/ebsr/src/delivery/index.tsx b/packages/elements-react/ebsr/src/delivery/index.tsx
index b2d500e2..e7d33697 100644
--- a/packages/elements-react/ebsr/src/delivery/index.tsx
+++ b/packages/elements-react/ebsr/src/delivery/index.tsx
@@ -36,6 +36,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();
@@ -95,6 +127,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;
}
}
@@ -126,16 +164,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 = `
-
Two-Part Question
+ ${srHeading}
<${MC_TAG_NAME} id="a">${MC_TAG_NAME}>
<${MC_TAG_NAME} id="b">${MC_TAG_NAME}>
`;
diff --git a/packages/elements-react/multiple-choice/package.json b/packages/elements-react/multiple-choice/package.json
index 89db7430..3bd231af 100644
--- a/packages/elements-react/multiple-choice/package.json
+++ b/packages/elements-react/multiple-choice/package.json
@@ -1,6 +1,6 @@
{
"name": "@pie-element/multiple-choice",
- "version": "13.0.1",
+ "version": "13.1.0",
"description": "React implementation of multiple-choice element synced from pie-elements",
"dependencies": {
"@emotion/react": "^11.14.0",
diff --git a/packages/elements-react/multiple-choice/src/delivery/index.ts b/packages/elements-react/multiple-choice/src/delivery/index.ts
index 119da07f..bf0f16c8 100644
--- a/packages/elements-react/multiple-choice/src/delivery/index.ts
+++ b/packages/elements-react/multiple-choice/src/delivery/index.ts
@@ -82,6 +82,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();
@@ -105,6 +150,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
@@ -220,6 +266,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();
@@ -262,6 +318,7 @@ export default class MultipleChoice extends HTMLElement {
connectedCallback() {
this._initMathObserver();
+ this._initPlayerObserver();
this._rerender();
// Observation: audio in Chrome will have the autoplay attribute,
@@ -350,8 +407,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;
diff --git a/packages/elements-react/multiple-choice/src/delivery/main.tsx b/packages/elements-react/multiple-choice/src/delivery/main.tsx
index a3ffe622..867d2079 100644
--- a/packages/elements-react/multiple-choice/src/delivery/main.tsx
+++ b/packages/elements-react/multiple-choice/src/delivery/main.tsx
@@ -52,6 +52,8 @@ class Main extends React.Component {
names: PropTypes.arrayOf(PropTypes.string),
rules: PropTypes.string,
}),
+ baseHeadingLevel: PropTypes.number,
+ includeSrHeading: PropTypes.bool,
};
static defaultProps = {
@@ -60,7 +62,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
@@ -72,6 +74,8 @@ class Main extends React.Component {
session={session}
onChoiceChanged={onChoiceChanged}
onShowCorrectToggle={onShowCorrectToggle}
+ baseHeadingLevel={baseHeadingLevel}
+ includeSrHeading={includeSrHeading}
/>
);
diff --git a/packages/elements-react/multiple-choice/src/delivery/multiple-choice.tsx b/packages/elements-react/multiple-choice/src/delivery/multiple-choice.tsx
index 0c47e8d2..433b89e9 100644
--- a/packages/elements-react/multiple-choice/src/delivery/multiple-choice.tsx
+++ b/packages/elements-react/multiple-choice/src/delivery/multiple-choice.tsx
@@ -14,7 +14,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 as CollapsibleImport, PreviewPrompt as PreviewPromptImport } from '@pie-lib/render-ui';
+import { color, Collapsible as CollapsibleImport, PreviewPrompt as PreviewPromptImport, transformDataHeadings } from '@pie-lib/render-ui';
function isRenderableReactInteropType(value: any) {
return (
@@ -146,6 +146,8 @@ export class MultipleChoice extends React.Component {
pauseImage: PropTypes.string,
},
options: PropTypes.object,
+ baseHeadingLevel: PropTypes.number,
+ includeSrHeading: PropTypes.bool,
};
constructor(props) {
@@ -272,17 +274,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' ? (
- Multiple Choice Question
- ) : (
- Multiple Select Question
- );
+ 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 {label};
}
handleGroupFocus: any = (e) => {
@@ -323,6 +329,7 @@ export class MultipleChoice extends React.Component {
session,
customAudioButton,
options,
+ baseHeadingLevel,
} = this.props;
const { showCorrect, maxSelectionsErrorState } = this.state;
const isEvaluateMode = mode === 'evaluate';
@@ -330,6 +337,18 @@ export class MultipleChoice extends React.Component {
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 = (
- {partLabel && {partLabel}}
+ {partLabel && {partLabel}}
{this.renderHeading()}
@@ -394,7 +413,7 @@ export class MultipleChoice extends React.Component {
{
+ if (this.input[index]) {
+ this.input[index].focus();
+ }
+ }, 0);
return;
}