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` : ''; + this.innerHTML = ` -

Two-Part Question

+ ${srHeading} <${MC_TAG_NAME} id="a"> <${MC_TAG_NAME} id="b"> `; 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; }