diff --git a/package.json b/package.json index 9b3993df5f..fd6653e5c0 100644 --- a/package.json +++ b/package.json @@ -89,27 +89,27 @@ "@jest/test-sequencer": "29.7.0", "@types/d3-array": "3.0.3", "@pie-lib/categorize": "2.0.2", - "@pie-lib/charting": "7.0.14", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/charting": "7.0.15", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/controller-utils": "2.0.3", "@pie-lib/correct-answer-toggle": "4.0.5", "@pie-lib/drag": "4.1.0", "@pie-lib/editable-html": "13.0.9", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/feedback": "2.0.2", - "@pie-lib/graphing-solution-set": "4.0.14", + "@pie-lib/graphing-solution-set": "4.0.15", "@pie-lib/graphing-utils": "3.0.2", - "@pie-lib/graphing": "4.0.15", + "@pie-lib/graphing": "4.0.16", "@pie-lib/icons": "4.0.3", - "@pie-lib/mask-markup": "3.0.14", + "@pie-lib/mask-markup": "3.0.15", "@pie-lib/math-evaluator": "4.0.2", "@pie-lib/math-input": "8.1.1", "@pie-lib/math-rendering-accessible": "5.0.3", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/math-toolbar": "3.0.6", - "@pie-lib/plot": "4.0.14", + "@pie-lib/plot": "4.0.15", "@pie-lib/render-ui": "6.1.3", - "@pie-lib/rubric": "2.0.14", + "@pie-lib/rubric": "2.0.15", "@pie-lib/scoring-config": "5.0.3", "@pie-lib/style-utils": "2.0.2", "@pie-lib/test-utils": "2.0.2", diff --git a/packages/boilerplate-item-type/configure/package.json b/packages/boilerplate-item-type/configure/package.json index a6add5de84..1c802bb9ee 100644 --- a/packages/boilerplate-item-type/configure/package.json +++ b/packages/boilerplate-item-type/configure/package.json @@ -11,8 +11,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/render-ui": "6.1.3", "debug": "^4.1.1", "lodash-es": "^4.17.23", diff --git a/packages/calculator/configure/package.json b/packages/calculator/configure/package.json index 22ac1efd70..acf5883c9f 100644 --- a/packages/calculator/configure/package.json +++ b/packages/calculator/configure/package.json @@ -11,7 +11,7 @@ "@mui/material": "^7.3.4", "@pie-framework/material-ui-calculator": "4.0.0", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/packages/categorize/configure/package.json b/packages/categorize/configure/package.json index 3baad6a408..3021265d34 100644 --- a/packages/categorize/configure/package.json +++ b/packages/categorize/configure/package.json @@ -16,9 +16,9 @@ "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", "@pie-lib/categorize": "2.0.2", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/drag": "4.1.0", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", "@pie-lib/translator": "4.0.2", diff --git a/packages/categorize/package.json b/packages/categorize/package.json index a40a7ee0ff..224871e32b 100644 --- a/packages/categorize/package.json +++ b/packages/categorize/package.json @@ -16,7 +16,7 @@ "@mui/material": "^7.3.4", "@pie-framework/pie-player-events": "^0.1.0", "@pie-lib/categorize": "2.0.2", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/correct-answer-toggle": "4.0.5", "@pie-lib/drag": "4.1.0", "@pie-lib/math-rendering": "5.1.0", diff --git a/packages/charting/configure/package.json b/packages/charting/configure/package.json index 0c6bc7f7fb..170a93099b 100644 --- a/packages/charting/configure/package.json +++ b/packages/charting/configure/package.json @@ -12,9 +12,9 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/charting": "7.0.14", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/charting": "7.0.15", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", "debug": "^4.1.1", diff --git a/packages/charting/package.json b/packages/charting/package.json index a7adbbf3b1..15ae92e738 100644 --- a/packages/charting/package.json +++ b/packages/charting/package.json @@ -15,7 +15,7 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-player-events": "^0.1.0", - "@pie-lib/charting": "7.0.14", + "@pie-lib/charting": "7.0.15", "@pie-lib/correct-answer-toggle": "4.0.5", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", diff --git a/packages/complex-rubric/configure/package.json b/packages/complex-rubric/configure/package.json index 4c65f74f8f..a63f4af910 100644 --- a/packages/complex-rubric/configure/package.json +++ b/packages/complex-rubric/configure/package.json @@ -14,9 +14,9 @@ "@pie-element/multi-trait-rubric": "^8.1.0", "@pie-element/rubric": "^8.1.0", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/render-ui": "6.1.3", - "@pie-lib/rubric": "2.0.14", + "@pie-lib/rubric": "2.0.15", "debug": "^4.1.1", "lodash-es": "^4.17.23", "prop-types": "^15.8.1", diff --git a/packages/complex-rubric/package.json b/packages/complex-rubric/package.json index e61ee15bb1..81c1056e5b 100644 --- a/packages/complex-rubric/package.json +++ b/packages/complex-rubric/package.json @@ -13,7 +13,7 @@ "@pie-element/multi-trait-rubric": "^8.1.0", "@pie-element/rubric": "^8.1.0", "@pie-framework/pie-player-events": "^0.1.0", - "@pie-lib/rubric": "2.0.14", + "@pie-lib/rubric": "2.0.15", "classnames": "^2.2.6", "debug": "^4.1.1", "lodash-es": "^4.17.23", diff --git a/packages/drag-in-the-blank/configure/package.json b/packages/drag-in-the-blank/configure/package.json index 41ca0978aa..716322ac39 100644 --- a/packages/drag-in-the-blank/configure/package.json +++ b/packages/drag-in-the-blank/configure/package.json @@ -12,9 +12,9 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/drag": "4.1.0", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/math-rendering": "5.1.0", "debug": "^4.1.1", "lodash-es": "^4.17.23", diff --git a/packages/drag-in-the-blank/package.json b/packages/drag-in-the-blank/package.json index ead250cec2..3d37132ab0 100644 --- a/packages/drag-in-the-blank/package.json +++ b/packages/drag-in-the-blank/package.json @@ -17,7 +17,7 @@ "@pie-framework/pie-player-events": "^0.1.0", "@pie-lib/correct-answer-toggle": "4.0.5", "@pie-lib/drag": "4.1.0", - "@pie-lib/mask-markup": "3.0.14", + "@pie-lib/mask-markup": "3.0.15", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", "lodash-es": "^4.17.23", diff --git a/packages/drawing-response/configure/package.json b/packages/drawing-response/configure/package.json index 4a0f2dfce2..d34134cd73 100644 --- a/packages/drawing-response/configure/package.json +++ b/packages/drawing-response/configure/package.json @@ -12,8 +12,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "debug": "^4.1.1", "lodash-es": "^4.17.23", "prop-types": "^15.8.1", diff --git a/packages/ebsr/configure/package.json b/packages/ebsr/configure/package.json index 1d1ebb4d06..50cbebd7dc 100644 --- a/packages/ebsr/configure/package.json +++ b/packages/ebsr/configure/package.json @@ -13,7 +13,7 @@ "@mui/material": "^7.3.4", "@pie-element/multiple-choice": "^13.2.0", "@pie-framework/pie-configure-events": "^1.2.0", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "lodash-es": "^4.17.23", "prop-types": "^15.8.1", "react": "18.3.1", diff --git a/packages/explicit-constructed-response/configure/package.json b/packages/explicit-constructed-response/configure/package.json index cc02943b2d..88a7b87da6 100644 --- a/packages/explicit-constructed-response/configure/package.json +++ b/packages/explicit-constructed-response/configure/package.json @@ -10,8 +10,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "classnames": "^2.2.6", "debug": "^4.1.1", "lodash-es": "^4.17.23", diff --git a/packages/explicit-constructed-response/package.json b/packages/explicit-constructed-response/package.json index 1114a9d4cb..fc2c87d28d 100644 --- a/packages/explicit-constructed-response/package.json +++ b/packages/explicit-constructed-response/package.json @@ -16,7 +16,7 @@ "@mui/material": "^7.3.4", "@pie-framework/pie-player-events": "^0.1.0", "@pie-lib/correct-answer-toggle": "4.0.5", - "@pie-lib/mask-markup": "3.0.14", + "@pie-lib/mask-markup": "3.0.15", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", "@pie-lib/translator": "4.0.2", diff --git a/packages/extended-text-entry/configure/package.json b/packages/extended-text-entry/configure/package.json index 87b6bda538..3de01359bc 100644 --- a/packages/extended-text-entry/configure/package.json +++ b/packages/extended-text-entry/configure/package.json @@ -13,8 +13,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "lodash-es": "^4.17.23", "prop-types": "^15.8.1", "react": "18.3.1", diff --git a/packages/extended-text-entry/package.json b/packages/extended-text-entry/package.json index e40d3abd6c..0997375d5e 100644 --- a/packages/extended-text-entry/package.json +++ b/packages/extended-text-entry/package.json @@ -14,8 +14,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-player-events": "^0.1.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", "classnames": "^2.2.6", diff --git a/packages/fraction-model/configure/package.json b/packages/fraction-model/configure/package.json index 244ec7b2b4..e28607ed10 100644 --- a/packages/fraction-model/configure/package.json +++ b/packages/fraction-model/configure/package.json @@ -11,8 +11,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "debug": "^4.1.1", "lodash-es": "^4.17.23", "prop-types": "^15.8.1", diff --git a/packages/fraction-model/package.json b/packages/fraction-model/package.json index 8538cc6f41..4de0480bb7 100644 --- a/packages/fraction-model/package.json +++ b/packages/fraction-model/package.json @@ -12,7 +12,7 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-player-events": "^0.1.0", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/correct-answer-toggle": "4.0.5", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", diff --git a/packages/graphing-solution-set/configure/package.json b/packages/graphing-solution-set/configure/package.json index 7857881b67..c9bc77c78a 100644 --- a/packages/graphing-solution-set/configure/package.json +++ b/packages/graphing-solution-set/configure/package.json @@ -12,9 +12,9 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", - "@pie-lib/graphing-solution-set": "4.0.14", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", + "@pie-lib/graphing-solution-set": "4.0.15", "@pie-lib/math-rendering": "5.1.0", "classnames": "^2.2.6", "debug": "^4.1.1", diff --git a/packages/graphing-solution-set/package.json b/packages/graphing-solution-set/package.json index 426a119f63..35f032b4be 100644 --- a/packages/graphing-solution-set/package.json +++ b/packages/graphing-solution-set/package.json @@ -16,9 +16,9 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-player-events": "^0.1.0", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/correct-answer-toggle": "4.0.5", - "@pie-lib/graphing-solution-set": "4.0.14", + "@pie-lib/graphing-solution-set": "4.0.15", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", "classnames": "^2.2.6", diff --git a/packages/graphing/configure/package.json b/packages/graphing/configure/package.json index bb8d38faab..53dfce9191 100644 --- a/packages/graphing/configure/package.json +++ b/packages/graphing/configure/package.json @@ -12,9 +12,9 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", - "@pie-lib/graphing": "4.0.15", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", + "@pie-lib/graphing": "4.0.16", "@pie-lib/math-rendering": "5.1.0", "classnames": "^2.2.6", "debug": "^4.1.1", diff --git a/packages/graphing/package.json b/packages/graphing/package.json index 1f1ca6376e..e704e0b272 100644 --- a/packages/graphing/package.json +++ b/packages/graphing/package.json @@ -16,7 +16,7 @@ "@mui/material": "^7.3.4", "@pie-framework/pie-player-events": "^0.1.0", "@pie-lib/correct-answer-toggle": "4.0.5", - "@pie-lib/graphing": "4.0.15", + "@pie-lib/graphing": "4.0.16", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", "classnames": "^2.2.6", diff --git a/packages/hotspot/configure/package.json b/packages/hotspot/configure/package.json index 5e3931b6e1..11cb61c39a 100644 --- a/packages/hotspot/configure/package.json +++ b/packages/hotspot/configure/package.json @@ -12,8 +12,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "debug": "^4.1.1", "konva": "8.3.0", "lodash-es": "^4.17.23", diff --git a/packages/image-cloze-association/configure/package.json b/packages/image-cloze-association/configure/package.json index 7bd51ec1cc..a5274797b0 100644 --- a/packages/image-cloze-association/configure/package.json +++ b/packages/image-cloze-association/configure/package.json @@ -12,8 +12,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "debug": "^4.1.1", "prop-types": "^15.8.1", "react": "18.3.1", diff --git a/packages/inline-dropdown/configure/package.json b/packages/inline-dropdown/configure/package.json index d0a0767a50..9551ca9ad1 100644 --- a/packages/inline-dropdown/configure/package.json +++ b/packages/inline-dropdown/configure/package.json @@ -10,8 +10,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", "classnames": "^2.2.6", diff --git a/packages/inline-dropdown/package.json b/packages/inline-dropdown/package.json index 1347503208..30b0b7f35a 100644 --- a/packages/inline-dropdown/package.json +++ b/packages/inline-dropdown/package.json @@ -16,7 +16,7 @@ "@mui/material": "^7.3.4", "@pie-framework/pie-player-events": "^0.1.0", "@pie-lib/correct-answer-toggle": "4.0.5", - "@pie-lib/mask-markup": "3.0.14", + "@pie-lib/mask-markup": "3.0.15", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", "lodash-es": "^4.17.23", diff --git a/packages/likert/configure/package.json b/packages/likert/configure/package.json index f2479f36da..5bdfc388fc 100644 --- a/packages/likert/configure/package.json +++ b/packages/likert/configure/package.json @@ -12,8 +12,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/render-ui": "6.1.3", "debug": "^4.1.1", "prop-types": "^15.8.1", diff --git a/packages/match-list/src/__tests__/main.test.js b/packages/match-list/src/__tests__/main.test.js index fe8168f4d9..28d2680310 100644 --- a/packages/match-list/src/__tests__/main.test.js +++ b/packages/match-list/src/__tests__/main.test.js @@ -88,5 +88,138 @@ describe('Main', () => { expect(instance.state.showCorrectAnswer).toBe(true); }); }); + + describe('click-to-select / click-to-place', () => { + const choiceA = { type: 'choice', id: 'a', instanceId: 'i1', value: 'A', promptId: undefined }; + const choiceB = { type: 'choice', id: 'b', instanceId: 'i1', value: 'B', promptId: undefined }; + + describe('onChoiceClick (select/switch/deselect)', () => { + it('selects an answer when nothing is selected', () => { + const instance = createInstance(); + instance.onChoiceClick(choiceA); + expect(instance.state.selectedAnswer).toEqual(choiceA); + }); + + it('deselects when clicking the currently-selected answer again', () => { + const instance = createInstance(); + instance.onChoiceClick(choiceA); + instance.onChoiceClick(choiceA); + expect(instance.state.selectedAnswer).toBeNull(); + }); + + it('switches selection when clicking a different answer', () => { + const instance = createInstance(); + instance.onChoiceClick(choiceA); + instance.onChoiceClick(choiceB); + expect(instance.state.selectedAnswer).toEqual(choiceB); + }); + + it('ignores a click that lands immediately after a drag ended', () => { + const instance = createInstance(); + instance.lastDragEndAt = Date.now(); + instance.onChoiceClick(choiceA); + expect(instance.state.selectedAnswer).toBeNull(); + }); + }); + + describe('onPlacementClick (place the selected answer)', () => { + it('does nothing when no answer is selected', () => { + onSessionChange.mockClear(); + const instance = createInstance(); + instance.onPlacementClick({ type: 'drop-zone', promptId: 0 }); + expect(onSessionChange).not.toHaveBeenCalled(); + }); + + it('places the selected choice into an empty response area and clears the selection', () => { + const instance = createInstance({ session: { value: {} } }); + instance.onChoiceClick(choiceA); + instance.onPlacementClick({ type: 'drop-zone', promptId: 0 }); + + expect(onSessionChange).toHaveBeenCalledWith({ value: { 0: 'a' } }); + expect(instance.state.selectedAnswer).toBeNull(); + }); + + it('moving into an occupied response area displaces the previous occupant back to the pool', () => { + // "Return to the pool" happens implicitly: once slot 0's value is overwritten, + // 'existing' no longer appears anywhere in session.value, so ChoicesList's own + // filter (see choices-list.jsx) makes it reappear in the pool — no separate + // "move it back" step is needed here, matching the existing drag-and-drop path. + const instance = createInstance({ session: { value: { 0: 'existing' } } }); + instance.onChoiceClick(choiceA); + instance.onPlacementClick({ type: 'drop-zone', promptId: 0 }); + + expect(onSessionChange).toHaveBeenCalledWith({ value: { 0: 'a' } }); + }); + + it('ignores a click that lands immediately after a drag ended', () => { + const instance = createInstance({ session: { value: {} } }); + instance.onChoiceClick(choiceA); + instance.lastDragEndAt = Date.now(); + onSessionChange.mockClear(); + instance.onPlacementClick({ type: 'drop-zone', promptId: 0 }); + + expect(onSessionChange).not.toHaveBeenCalled(); + // Selection itself is untouched by the guard — only the placement is skipped. + expect(instance.state.selectedAnswer).toEqual(choiceA); + }); + }); + + describe('cross-modal: keyboard selection, then mouse placement', () => { + it('mirrors a real drag start into selectedAnswer, then a placement click completes it and ends the live drag', () => { + const instance = createInstance({ session: { value: {} } }); + const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); + + // Tab+Space/Enter on a choice starts a real dnd-kit drag; onDragStart mirrors it. + instance.onDragStart({ active: { data: { current: choiceA } } }); + expect(instance.state.selectedAnswer).toEqual(choiceA); + + // Mouse click on a response area completes the placement. + instance.onPlacementClick({ type: 'drop-zone', promptId: 0 }); + + expect(onSessionChange).toHaveBeenCalledWith({ value: { 0: 'a' } }); + expect(instance.state.selectedAnswer).toBeNull(); + // The still-live keyboard drag is cleanly ended via a synthetic Escape, so + // dnd-kit doesn't keep listening for further Tab/arrow/Space/Escape input for + // a drag the click already resolved. + const dispatchedEvent = dispatchSpy.mock.calls[dispatchSpy.mock.calls.length - 1][0]; + expect(dispatchedEvent.code).toBe('Escape'); + + dispatchSpy.mockRestore(); + }); + }); + + describe('cross-modal: mouse selection, then keyboard placement', () => { + it('a click-based selection is placed normally when the real dnd-kit drag ends', () => { + const instance = createInstance({ session: { value: {} } }); + + instance.onChoiceClick(choiceA); + expect(instance.state.selectedAnswer).toEqual(choiceA); + + // Tab+Space/Enter on the response area ends the (keyboard-started) drag as usual. + instance.onPlaceAnswer({ + active: { data: { current: choiceA } }, + over: { data: { current: { type: 'drop-zone', promptId: 0 } } }, + }); + + expect(onSessionChange).toHaveBeenCalledWith({ value: { 0: 'a' } }); + expect(instance.state.selectedAnswer).toBeNull(); + }); + }); + }); + + describe('onDragCancel', () => { + it('clears the mirrored selection and does not change the session', () => { + const instance = createInstance(); + instance.onDragStart({ active: { data: { current: { type: 'choice', id: 'a' } } } }); + expect(instance.state.selectedAnswer).not.toBeNull(); + + onSessionChange.mockClear(); + instance.onDragCancel(); + + expect(instance.state.selectedAnswer).toBeNull(); + expect(instance.state.draggingElement).toBeNull(); + expect(onSessionChange).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/match-list/src/answer-area.jsx b/packages/match-list/src/answer-area.jsx index 54c749b965..933e0814be 100644 --- a/packages/match-list/src/answer-area.jsx +++ b/packages/match-list/src/answer-area.jsx @@ -59,6 +59,9 @@ export class AnswerArea extends React.Component { instanceId: PropTypes.string.isRequired, model: PropTypes.object.isRequired, prompt: PropTypes.string, + selectedAnswer: PropTypes.object, + onChoiceClick: PropTypes.func, + onPlacementClick: PropTypes.func, }; getAnswerFromSession = (promptId) => { @@ -129,7 +132,7 @@ export class AnswerArea extends React.Component { }; render() { - const { disabled, instanceId, onRemoveAnswer } = this.props; + const { disabled, instanceId, onRemoveAnswer, selectedAnswer, onChoiceClick, onPlacementClick } = this.props; const rows = this.buildRows(); const correctnessMap = this.getCorrectOrIncorrectMap(); @@ -158,6 +161,9 @@ export class AnswerArea extends React.Component { title={sessionAnswer.title} type={'target'} onRemoveChoice={() => onRemoveAnswer(id)} + selectedAnswer={selectedAnswer} + onSelectClick={onChoiceClick} + onPlacementClick={onPlacementClick} /> ); diff --git a/packages/match-list/src/answer.jsx b/packages/match-list/src/answer.jsx index e356260e0e..8deac040b0 100644 --- a/packages/match-list/src/answer.jsx +++ b/packages/match-list/src/answer.jsx @@ -1,6 +1,6 @@ import { useDraggable, useDroppable } from '@dnd-kit/core'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState } from 'react'; import debug from 'debug'; import { styled } from '@mui/material/styles'; import { PlaceHolder } from '@pie-lib/drag'; @@ -40,7 +40,7 @@ Holder.propTypes = { type: PropTypes.string, }; -const AnswerContentContainer = styled('div')(({ theme, isDragging, isOver, disabled, outcome }) => ({ +const AnswerContentContainer = styled('div')(({ theme, isDragging, isSelected, isOver, disabled, outcome }) => ({ color: color.text(), backgroundColor: color.white(), border: `1px solid ${ @@ -53,12 +53,12 @@ const AnswerContentContainer = styled('div')(({ theme, isDragging, isOver, disab overflow: 'hidden', transition: 'opacity 200ms linear', wordBreak: 'break-word', - opacity: isDragging && !disabled ? 0.5 : isOver && !disabled ? 0.2 : 1, + opacity: (isDragging || isSelected) && !disabled ? 0.5 : isOver && !disabled ? 0.2 : 1, touchAction: 'none', })); const AnswerContent = (props) => { - const { isDragging, isOver, title, disabled, empty, outcome, guideIndex, type } = props; + const { isDragging, isSelected, isOver, title, disabled, empty, outcome, guideIndex, type } = props; if (empty) { return ; @@ -66,6 +66,7 @@ const AnswerContent = (props) => { return ( @@ -147,12 +160,39 @@ export class Answer extends React.Component { } function DragAndDropAnswer(props) { - const { id, instanceId, promptId, draggable = true, disabled = false, type } = props; + const { + id, + instanceId, + promptId, + draggable = true, + disabled = false, + type, + selectedAnswer, + onSelectClick, + onPlacementClick, + } = props; const dragId = `${type || 'answer'}-${id}`; // droppable only if promptId exists const dropId = promptId !== undefined && promptId !== null ? `drop-${promptId}` : undefined; + // Built once and reused for both dnd-kit's own data and the click handlers below, so + // a click carries exactly the same shape dnd-kit's onDragStart/onDragEnd would. + const activeData = { + type: type || 'answer', + id, + instanceId, + value: props.title, + promptId, + }; + const dropZoneData = dropId + ? { + type: 'drop-zone', + promptId, + instanceId, + } + : undefined; + const { attributes, listeners, @@ -162,25 +202,34 @@ function DragAndDropAnswer(props) { isDragging, } = useDraggable({ id: dragId, - data: { - type: type || 'answer', - id, - instanceId, - value: props.title, - promptId, - }, + data: activeData, disabled: !draggable || disabled, }); const droppable = useDroppable({ id: dropId, - data: dropId ? { type: 'drop-zone', promptId, instanceId } : undefined, + data: dropZoneData, disabled: disabled || !dropId, }); const setDropRef = droppable.setNodeRef; const isOver = droppable.isOver; + // dnd-kit's own isOver only reflects real collision detection during an active drag, + // so it stays false while an answer is merely click-selected (no drag in progress). + // Track hovering locally and fold it into the same isOver signal used everywhere + // below, so hovering a response area while something is selected gets the exact same + // treatment as hovering it during a real drag — one code path, no duplicated CSS. + const [isHovered, setIsHovered] = useState(false); + const hasSelection = !!selectedAnswer; + const showsHoverEffect = isOver || (hasSelection && isHovered && !disabled); + + const isSelected = + !!selectedAnswer && + selectedAnswer.type === activeData.type && + selectedAnswer.id === activeData.id && + selectedAnswer.promptId === activeData.promptId; + // compute style: apply transform to the element that actually moves const transformStyle = transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined; @@ -191,37 +240,97 @@ function DragAndDropAnswer(props) { // drag, corrupting collision/keyboard-navigation results. The transform belongs on // the inner draggable node instead. if (dropId) { + const handleResponseAreaClick = () => { + if (disabled) return; + + if (isSelected) { + // Clicking the already-selected placed answer again deselects it, same as + // for a choice in the pool. + onSelectClick?.(activeData); + } else if (selectedAnswer) { + // Something else is selected — place it here, same whether this area is + // currently empty or already occupied. + onPlacementClick?.(dropZoneData); + } else if (draggable) { + // Nothing selected yet, and this response area holds an answer — clicking it + // selects that answer for moving elsewhere, the same way Tab+Space/Enter does. + onSelectClick?.(activeData); + } + + // Empty response area clicked with nothing selected: nothing to place or select. + }; + + // An empty response area isn't draggable, so dnd-kit's own attributes (only + // applied to the inner node, and only when draggable) never make it tabbable — + // this outer wrapper needs its own focus/activation handling so "select a choice, + // then Tab to a response area and press Space/Enter" works even when the area is + // empty. This is independent of, and doesn't change, the existing in-drag + // Tab-cycling (that's driven by an active dnd-kit drag, not native focus). + // + // Only made a native Tab stop when NOT draggable (i.e. empty): when the target is + // filled, the inner node is already independently tabbable via dnd-kit's own + // attributes for the existing pick-up-to-move gesture, and adding a second, + // outer Tab stop for the same visual tile would add an extra stop to the existing + // Tab order. Placing into an occupied area is still fully reachable by mouse click + // here, or by the existing keyboard drag flow (Tab+Space/Enter on the choice, + // Tab-cycle to the occupied target, Space/Enter to swap). + const isNativeTabStop = !draggable && !disabled; + + const handleResponseAreaKeyDown = (e) => { + if (e.code === 'Space' || e.code === 'Enter') { + e.preventDefault(); + handleResponseAreaClick(); + } + }; + return (
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} style={{ flex: 1, - opacity: isDragging ? 0.5 : 1, - backgroundColor: isDragging || isOver ? 'rgba(0,0,0,0.05)' : 'transparent', + opacity: isDragging || isSelected ? 0.5 : 1, + backgroundColor: isDragging || isSelected || showsHoverEffect ? 'rgba(0,0,0,0.05)' : 'transparent', + cursor: hasSelection && !disabled ? 'pointer' : undefined, }} >
- +
); } + const handleChoiceClick = (e) => { + if (disabled) { + return; + } + + e.stopPropagation(); + onSelectClick?.(activeData); + }; + // if there is NO dropId (this is a choice / draggable-only), render only draggable node and apply transform to it. return (
- +
); } @@ -234,6 +343,9 @@ DragAndDropAnswer.propTypes = { draggable: PropTypes.bool, disabled: PropTypes.bool, type: PropTypes.string, + selectedAnswer: PropTypes.object, + onSelectClick: PropTypes.func, + onPlacementClick: PropTypes.func, }; export default DragAndDropAnswer; diff --git a/packages/match-list/src/choices-list.jsx b/packages/match-list/src/choices-list.jsx index 9e4fd35506..398ba71f53 100644 --- a/packages/match-list/src/choices-list.jsx +++ b/packages/match-list/src/choices-list.jsx @@ -16,10 +16,22 @@ export class ChoicesList extends React.Component { model: PropTypes.object.isRequired, disabled: PropTypes.bool.isRequired, onRemoveAnswer: PropTypes.func, + selectedAnswer: PropTypes.object, + onChoiceClick: PropTypes.func, + onPlacementClick: PropTypes.func, }; render() { - const { model, disabled, session, instanceId, onRemoveAnswer } = this.props; + const { + model, + disabled, + session, + instanceId, + onRemoveAnswer, + selectedAnswer, + onChoiceClick, + onPlacementClick, + } = this.props; const { config } = model; const { duplicates } = config; @@ -39,13 +51,20 @@ export class ChoicesList extends React.Component { disabled={disabled} session={session} type="choice" + selectedAnswer={selectedAnswer} + onSelectClick={onChoiceClick} {...answer} /> )); return ( - + {filteredAnswers} diff --git a/packages/match-list/src/droppable-placeholder.jsx b/packages/match-list/src/droppable-placeholder.jsx index f32fb3651f..11db680a60 100644 --- a/packages/match-list/src/droppable-placeholder.jsx +++ b/packages/match-list/src/droppable-placeholder.jsx @@ -16,16 +16,25 @@ const Container = styled('div')(({ theme }) => ({ transition: 'background-color 200ms ease', })); -export function DroppablePlaceholder({ id, disabled, children, ...rest }) { +export function DroppablePlaceholder({ id, disabled, children, onPlacementClick, ...rest }) { const { setNodeRef, isOver } = useDroppable({ id: id || 'choices-pool', data: { type: 'choices-pool' }, disabled, }); + const handleClick = () => { + if (disabled) { + return; + } + + onPlacementClick?.({ type: 'choices-pool' }); + }; + return ( @@ -40,6 +49,7 @@ DroppablePlaceholder.propTypes = { id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), disabled: PropTypes.bool, children: PropTypes.node, + onPlacementClick: PropTypes.func, }; export default DroppablePlaceholder; diff --git a/packages/match-list/src/main.jsx b/packages/match-list/src/main.jsx index 5e6f1ae409..d684c681af 100644 --- a/packages/match-list/src/main.jsx +++ b/packages/match-list/src/main.jsx @@ -12,8 +12,23 @@ import AnswerArea from './answer-area'; import ChoicesList from './choices-list'; import { Answer } from './answer'; +// A click that lands right after a real drag gesture ends (pointer drag-and-drop, or +// the browser's own synthetic click for a keyboard Space/Enter) must be ignored by the +// new click-to-select/click-to-place handlers below, or it would immediately reopen or +// re-trigger a selection for a drag that just completed. +const CLICK_AFTER_DRAG_GUARD_MS = 250; + const sensors = [ - { sensor: PointerSensor, options: {} }, + // Without an activationConstraint, dnd-kit's PointerSensor calls its internal + // handleStart() synchronously on pointerdown, before any movement — meaning a plain + // click is itself "activated" as a drag. Once activated, dnd-kit adds a capture-phase + // document click listener that calls stopPropagation() (to suppress the native + // "ghost click" a real drag leaves behind), which also swallows the click for a + // gesture with zero movement, before it ever reaches our own onClick handlers below. + // Requiring 8px of movement (matching @pie-lib/drag's DragProvider convention used + // elsewhere in this codebase) defers activation until an actual drag gesture is + // underway, so a plain click passes through untouched. + { sensor: PointerSensor, options: { activationConstraint: { distance: 8 } } }, { sensor: KeyboardSensor, options: { @@ -64,7 +79,9 @@ export class Main extends React.Component { this.state = { showCorrectAnswer: false, draggingElement: null, + selectedAnswer: null, }; + this.lastDragEndAt = 0; } onRemoveAnswer(id) { @@ -90,21 +107,19 @@ export class Main extends React.Component { this.setState({ draggingElement: { ...active.data.current, rect }, }); + this.selectAnswer(active.data.current); } }; - onPlaceAnswer = (event) => { + onDragCancel = () => { this.setState({ draggingElement: null }); - const { active, over } = event; - - if (!active) { - return; - } - - const activeData = active.data.current; - const overData = over?.data.current; + this.cancelSelection(); + this.lastDragEndAt = Date.now(); + }; - if (!activeData) { + // Pure placement logic + placeAnswer = (activeData, overData) => { + if (!activeData || !overData) { return; } @@ -128,10 +143,10 @@ export class Main extends React.Component { const sourcePromptId = activeData.promptId; // Handle dropping onto a drop zone - if (overData && overData.type === 'drop-zone' && overData.promptId != null) { + if (overData.type === 'drop-zone' && overData.promptId != null) { const targetPromptId = overData.promptId; - if (activeData.type === 'choice' && overData.type === 'drop-zone' && targetPromptId !== undefined) { + if (activeData.type === 'choice' && targetPromptId !== undefined) { // check if this choice is already placed somewhere const existingPlacement = findKey(session.value, (val) => val === answerId); @@ -166,6 +181,91 @@ export class Main extends React.Component { } }; + onPlaceAnswer = (event) => { + this.setState({ draggingElement: null }); + const { active, over } = event; + + if (!active) { + return; + } + + const activeData = active.data.current; + const overData = over?.data.current; + + if (!activeData) { + return; + } + + this.placeAnswer(activeData, overData); + this.cancelSelection(); + this.lastDragEndAt = Date.now(); + }; + + isSameAnswer = (a, b) => !!a && !!b && a.type === b.type && a.id === b.id && a.promptId === b.promptId; + + // Unconditionally selects (used by the drag-start mirror, and internally when + // switching from one choice to another). + selectAnswer = (data) => { + this.setState({ selectedAnswer: data }); + }; + + // Click-to-select semantics: selecting the currently-selected answer again clears + // the selection instead of re-selecting it. + toggleAnswerSelection = (data) => { + this.setState((state) => ({ + selectedAnswer: this.isSameAnswer(state.selectedAnswer, data) ? null : data, + })); + }; + + cancelSelection = () => { + this.setState({ selectedAnswer: null }); + }; + + // If a real dnd-kit drag (started via keyboard Space/Enter) is still live when a + // click completes the placement below, it needs to be cleanly ended — otherwise + // dnd-kit would still think a drag is in progress (still listening for Tab/arrow/ + // Space/Escape, still showing the drag overlay) for a placement the click already + // performed. Escape is already configured as this sensor's cancel key, and + // dispatching it as a real DOM KeyboardEvent is how dnd-kit's own document-level + // listener is reached from outside its sensor. onDragCancel is intentionally not + // wired to redo any placement — it only resets local UI state — so this is safe to + // call unconditionally, including when no drag is actually live (dnd-kit simply has + // no listener attached in that case, and the dispatch is a no-op). + endAnyLiveKeyboardDrag = () => { + document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape', bubbles: true, cancelable: true })); + }; + + placeSelectedAnswer = (overData) => { + const { selectedAnswer } = this.state; + + if (!selectedAnswer) { + return; + } + + this.placeAnswer(selectedAnswer, overData); + this.cancelSelection(); + this.endAnyLiveKeyboardDrag(); + this.lastDragEndAt = Date.now(); + }; + + isClickSoonAfterDragEnd = () => Date.now() - this.lastDragEndAt < CLICK_AFTER_DRAG_GUARD_MS; + + onChoiceClick = (data) => { + if (this.isClickSoonAfterDragEnd()) { + return; + } + + this.toggleAnswerSelection(data); + }; + + onPlacementClick = (overData) => { + if (this.isClickSoonAfterDragEnd()) { + return; + } + + this.placeSelectedAnswer(overData); + }; + toggleShowCorrect = () => { this.setState({ showCorrectAnswer: !this.state.showCorrectAnswer }); }; @@ -262,11 +362,12 @@ export class Main extends React.Component { collisionDetection={rectIntersection} onDragStart={this.onDragStart} onDragEnd={this.onPlaceAnswer} + onDragCancel={this.onDragCancel} modifiers={[restrictToFirstScrollableAncestor]} accessibility={{ screenReaderInstructions: { draggable: - 'Press Space or Enter to pick up this answer choice. Once picked up, use Tab or Shift+Tab to cycle through response areas, or use arrow keys to move it freely. Press Space or Enter to drop, or Escape to cancel.', + 'Press Space or Enter to pick up this answer choice. Once picked up, use Tab or Shift+Tab to cycle through response areas, or use arrow keys to move it freely. Press Space or Enter to drop, or Escape to cancel. You can also click an answer choice to select it, then click a response area to place it there.', }, }} > @@ -289,6 +390,9 @@ export class Main extends React.Component { onRemoveAnswer={(id) => this.onRemoveAnswer(id)} disabled={mode !== 'gather'} showCorrect={showCorrectAnswer} + selectedAnswer={this.state.selectedAnswer} + onChoiceClick={this.onChoiceClick} + onPlacementClick={this.onPlacementClick} /> this.onRemoveAnswer(id)} + selectedAnswer={this.state.selectedAnswer} + onChoiceClick={this.onChoiceClick} + onPlacementClick={this.onPlacementClick} /> diff --git a/packages/match/configure/package.json b/packages/match/configure/package.json index 78041968c6..ddca9b7816 100644 --- a/packages/match/configure/package.json +++ b/packages/match/configure/package.json @@ -13,9 +13,9 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/drag": "4.1.0", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/render-ui": "6.1.3", "@pie-lib/test-utils": "2.0.2", "classnames": "^2.2.6", diff --git a/packages/math-inline/configure/package.json b/packages/math-inline/configure/package.json index b56d0eb356..239188ee37 100644 --- a/packages/math-inline/configure/package.json +++ b/packages/math-inline/configure/package.json @@ -12,8 +12,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/math-input": "8.1.1", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/math-toolbar": "3.0.6", diff --git a/packages/math-templated/configure/package.json b/packages/math-templated/configure/package.json index de3ab14d9e..d62c7c0088 100644 --- a/packages/math-templated/configure/package.json +++ b/packages/math-templated/configure/package.json @@ -11,8 +11,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/math-toolbar": "3.0.6", "@pie-lib/render-ui": "6.1.3", "debug": "^4.1.1", diff --git a/packages/math-templated/package.json b/packages/math-templated/package.json index afeb853a4d..ae97ca21eb 100644 --- a/packages/math-templated/package.json +++ b/packages/math-templated/package.json @@ -13,7 +13,7 @@ "@mui/material": "^7.3.4", "@pie-framework/pie-player-events": "^0.1.0", "@pie-lib/correct-answer-toggle": "4.0.5", - "@pie-lib/mask-markup": "3.0.14", + "@pie-lib/mask-markup": "3.0.15", "@pie-lib/math-input": "8.1.1", "@pie-lib/math-rendering": "5.1.0", "@pie-lib/render-ui": "6.1.3", diff --git a/packages/matrix/configure/package.json b/packages/matrix/configure/package.json index f22f9f7d8c..342ebe3805 100644 --- a/packages/matrix/configure/package.json +++ b/packages/matrix/configure/package.json @@ -12,8 +12,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/render-ui": "6.1.3", "debug": "^4.1.1", "prop-types": "^15.8.1", diff --git a/packages/multi-trait-rubric/configure/package.json b/packages/multi-trait-rubric/configure/package.json index 55ea50287d..2238c49d5e 100644 --- a/packages/multi-trait-rubric/configure/package.json +++ b/packages/multi-trait-rubric/configure/package.json @@ -11,9 +11,9 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/drag": "4.1.0", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/render-ui": "6.1.3", "debug": "^4.1.1", "lodash-es": "^4.17.23", diff --git a/packages/multiple-choice/configure/package.json b/packages/multiple-choice/configure/package.json index 2a559970a7..e67f7a2505 100644 --- a/packages/multiple-choice/configure/package.json +++ b/packages/multiple-choice/configure/package.json @@ -10,8 +10,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/render-ui": "6.1.3", "debug": "^4.1.1", "lodash-es": "^4.17.23", diff --git a/packages/number-line/configure/package.json b/packages/number-line/configure/package.json index d3802cadc4..b751ef0f6f 100644 --- a/packages/number-line/configure/package.json +++ b/packages/number-line/configure/package.json @@ -10,8 +10,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/render-ui": "6.1.3", "lodash-es": "^4.17.23", "react": "18.3.1", diff --git a/packages/passage/configure/package.json b/packages/passage/configure/package.json index 043c96e57a..f1774ca455 100644 --- a/packages/passage/configure/package.json +++ b/packages/passage/configure/package.json @@ -13,8 +13,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "lodash-es": "^4.17.23", "prop-types": "^15.8.1", "react": "18.3.1", diff --git a/packages/passage/src/stimulus-tabs.jsx b/packages/passage/src/stimulus-tabs.jsx index eab59fe6a9..044fc67c5b 100644 --- a/packages/passage/src/stimulus-tabs.jsx +++ b/packages/passage/src/stimulus-tabs.jsx @@ -79,12 +79,26 @@ const PassageAuthor = styled('div')({ }); const TabStyled = styled(Tab)(({ theme }) => ({ - background: theme.palette.common.white, // replace with color.background() once PD-2801 is DONE + /* + * The tab carries the colour of the panel it opens, which is what makes it read as a + * tab rather than a button, and it has to be the themed panel colour: MUI's palette + * does not follow `--pie-*`, so `common.white` here left the tab white on every color + * scheme while the passage body beneath it went dark. The comment this replaces gated + * the change on PD-2801, which is about OT's `color-contrast` class -- the mechanism + * pie-theme superseded, and blocked since 2023. + */ + background: color.background(), fontSize: 'inherit', fontFamily: 'Roboto, sans-serif', - color: theme.palette.common.black, // remove when PD-2801 is DONE + color: color.text(), borderRadius: `${theme.spacing(2)} ${theme.spacing(2)} 0 0`, - border: '1px solid #D9DADA', + /* + * `--pie-border-gray` is stepped to the 3:1 non-text minimum in every scheme. The + * literal it replaces measured about 1.2:1 against the dark schemes' surfaces, so the + * tab outline -- the only thing separating an unselected tab from the strip -- was + * invisible there. + */ + border: `1px solid ${color.borderGray()}`, borderBottomWidth: 0, minHeight: '56px', padding: '8px 10px', @@ -106,7 +120,7 @@ const TabStyled = styled(Tab)(({ theme }) => ({ }, '&.Mui-selected': { - color: theme.palette.common.black, + color: color.text(), '.passage-label': { opacity: 1, }, @@ -126,11 +140,16 @@ const TabStyled = styled(Tab)(({ theme }) => ({ }, })); -const Underline = styled('div')(({ theme }) => ({ +/* + * The selection indicator, one per tab. Unselected it has to disappear into the tab, so it + * paints the tab's own background rather than a fixed white; selected it becomes + * `--pie-tertiary`, which every scheme sets. + */ +const Underline = styled('div')(() => ({ height: '2px', width: '100%', marginTop: '6px', - background: theme.palette.common.white, // replace with color.background() once PD-2801 is DONE + background: color.background(), })); class StimulusTabs extends React.Component { @@ -381,8 +400,16 @@ class StimulusTabs extends React.Component { // so the reclaimed space flows to the passage content below. zoom: zoomCompensation, '& .MuiTabs-list': { - backgroundColor: 'var(--pie-passage-header-background, #ffffff)', - borderBottom: '1px solid #D9DADA', + /* + * The strip behind the tabs. A host that has opted into + * `--pie-passage-header-background` keeps its own colour on every + * scheme -- Knowledge Checks' pale green-blue is deliberate and is not + * a scheme's business to override. With no host colour the fallback has + * to be a theme token rather than a white literal, or the strip stays + * white over a dark passage body. + */ + backgroundColor: `var(--pie-passage-header-background, ${color.backgroundDark()})`, + borderBottom: `1px solid ${color.borderGray()}`, }, '& .MuiTabs-indicator': { backgroundColor: color.white(), diff --git a/packages/placement-ordering/configure/package.json b/packages/placement-ordering/configure/package.json index e34172c2f3..b29a3cdbbe 100644 --- a/packages/placement-ordering/configure/package.json +++ b/packages/placement-ordering/configure/package.json @@ -11,9 +11,9 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", + "@pie-lib/config-ui": "13.0.15", "@pie-lib/drag": "4.1.0", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/render-ui": "6.1.3", "debug": "^4.1.1", "lodash-es": "^4.17.23", diff --git a/packages/placement-ordering/src/__tests__/placement-ordering-click-selection.test.jsx b/packages/placement-ordering/src/__tests__/placement-ordering-click-selection.test.jsx new file mode 100644 index 0000000000..3f8741c5de --- /dev/null +++ b/packages/placement-ordering/src/__tests__/placement-ordering-click-selection.test.jsx @@ -0,0 +1,163 @@ +import { PlacementOrdering } from '../placement-ordering'; +import { reducer } from '../ordering'; + +jest.mock('../ordering', () => ({ + buildState: jest.fn().mockReturnValue({ tiles: [], choices: [], response: [] }), + reducer: jest.fn().mockReturnValue({ tiles: [], choices: [], response: [] }), +})); + +describe('PlacementOrdering click-to-select / click-to-place state machine', () => { + let onSessionChange; + let instance; + + const createInstance = (mod, sess) => { + onSessionChange = jest.fn(); + const session = { value: [], ...sess }; + const choices = [ + { id: 'c1', label: 'C1' }, + { id: 'c2', label: 'C2' }, + ]; + const model = { config: { includeTargets: true }, choices, ...mod }; + const props = { model, session, onSessionChange }; + + const inst = new PlacementOrdering(props); + inst.setState = jest.fn((state, callback) => { + Object.assign(inst.state, typeof state === 'function' ? state(inst.state) : state); + if (callback) callback(); + }); + inst.props = props; + return inst; + }; + + const choiceData = { id: 'c1', type: 'choice', instanceId: 'i', value: 'C1', index: undefined }; + const otherChoiceData = { id: 'c2', type: 'choice', instanceId: 'i', value: 'C2', index: undefined }; + const targetData = { id: 'c1', type: 'target', instanceId: 'i', value: 'C1', index: 0 }; + + beforeEach(() => { + reducer.mockReset(); + reducer.mockReturnValue({ response: ['x'] }); + instance = createInstance(); + }); + + describe('toggleChoiceSelection', () => { + it('selects a choice when nothing is selected', () => { + instance.toggleChoiceSelection(choiceData); + + expect(instance.state.selectedChoice).toEqual(choiceData); + }); + + it('deselects when the same choice is toggled again', () => { + instance.toggleChoiceSelection(choiceData); + instance.toggleChoiceSelection(choiceData); + + expect(instance.state.selectedChoice).toBeNull(); + }); + + it('switches selection to a different choice', () => { + instance.toggleChoiceSelection(choiceData); + instance.toggleChoiceSelection(otherChoiceData); + + expect(instance.state.selectedChoice).toEqual(otherChoiceData); + }); + }); + + describe('cancelSelection', () => { + it('clears the current selection', () => { + instance.selectChoice(choiceData); + instance.cancelSelection(); + + expect(instance.state.selectedChoice).toBeNull(); + }); + }); + + describe('placeSelectedChoice', () => { + it('does nothing when nothing is selected', () => { + instance.placeSelectedChoice(targetData); + + expect(onSessionChange).not.toHaveBeenCalled(); + }); + + it('places the selected choice, updates the session, and clears the selection', () => { + instance.selectChoice(choiceData); + instance.placeSelectedChoice(targetData); + + expect(reducer).toHaveBeenCalledWith({ type: 'move', from: undefined, to: targetData }, expect.anything()); + expect(onSessionChange).toHaveBeenCalledWith(expect.objectContaining({ value: ['x'] })); + expect(instance.state.selectedChoice).toBeNull(); + }); + }); + + describe('onChoiceClick / onPlacementClick guard against a real drag\'s trailing click', () => { + it('onChoiceClick is a no-op immediately after a real drag ends', () => { + instance.lastDragEndAt = Date.now(); + instance.onChoiceClick(choiceData); + + expect(instance.state.selectedChoice).toBeNull(); + }); + + it('onChoiceClick selects normally once the guard window has passed', () => { + instance.lastDragEndAt = Date.now() - 1000; + instance.onChoiceClick(choiceData); + + expect(instance.state.selectedChoice).toEqual(choiceData); + }); + + it('onPlacementClick is a no-op immediately after a real drag ends', () => { + instance.selectChoice(choiceData); + instance.lastDragEndAt = Date.now(); + instance.onPlacementClick(targetData); + + expect(onSessionChange).not.toHaveBeenCalled(); + }); + + it('onPlacementClick places normally once the guard window has passed', () => { + instance.selectChoice(choiceData); + instance.lastDragEndAt = Date.now() - 1000; + instance.onPlacementClick(targetData); + + expect(onSessionChange).toHaveBeenCalled(); + }); + }); + + describe('cross-modal selection (real drag start mirrors into the same selectedChoice state click uses)', () => { + it('onDragStart mirrors the dragged item into selectedChoice', () => { + instance.onDragStart({ active: { data: { current: choiceData } } }); + + expect(instance.state.selectedChoice).toEqual(choiceData); + }); + + it('keyboard-select (onDragStart) then mouse-place (onPlacementClick) completes the placement', () => { + instance.onDragStart({ active: { data: { current: choiceData } } }); + instance.onPlacementClick(targetData); + + expect(onSessionChange).toHaveBeenCalled(); + expect(instance.state.selectedChoice).toBeNull(); + }); + + it('mouse-select (onChoiceClick) then keyboard-place (onDragEnd) completes the placement', () => { + instance.onChoiceClick(choiceData); + instance.onDragEnd({ + over: { data: { current: targetData } }, + active: { data: { current: choiceData } }, + }); + + expect(onSessionChange).toHaveBeenCalled(); + expect(instance.state.selectedChoice).toBeNull(); + }); + + it('onDragCancel clears the selection without touching the session', () => { + instance.selectChoice(choiceData); + instance.onDragCancel(); + + expect(instance.state.selectedChoice).toBeNull(); + expect(onSessionChange).not.toHaveBeenCalled(); + }); + + it('onDragEnd (a completed real drag) clears any mirrored selection', () => { + instance.selectChoice(choiceData); + instance.onDragEnd({ over: null, active: { data: { current: { type: 'choice' } } } }); + + expect(instance.state.selectedChoice).toBeNull(); + }); + }); +}); diff --git a/packages/placement-ordering/src/__tests__/placement-ordering-click-wiring.test.jsx b/packages/placement-ordering/src/__tests__/placement-ordering-click-wiring.test.jsx new file mode 100644 index 0000000000..b75b454b9e --- /dev/null +++ b/packages/placement-ordering/src/__tests__/placement-ordering-click-wiring.test.jsx @@ -0,0 +1,67 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { PlacementOrdering } from '../placement-ordering'; + +jest.mock('../ordering', () => ({ + buildState: jest.fn().mockReturnValue({ tiles: [], choices: [], response: [] }), + reducer: jest.fn().mockReturnValue({ tiles: [], choices: [], response: [] }), +})); + +const mockTiler = jest.fn(() => null); + +jest.mock('../tiler', () => ({ + HorizontalTiler: (props) => mockTiler(props), + VerticalTiler: (props) => mockTiler(props), +})); + +describe('PlacementOrdering click-to-select/click-to-place wiring to the tiler', () => { + const choices = [ + { id: 'c1', label: 'C1' }, + { id: 'c2', label: 'C2' }, + ]; + + const renderWithConfig = (config) => { + mockTiler.mockClear(); + + render(); + + // React (in dev builds) calls a brand-new function component type an extra time, + // with no arguments, the very first time that type is ever rendered in the process + // (to capture a stack-trace frame for warnings) — harmless, but it means the *last* + // recorded call isn't reliably the real one. Use the last call that actually + // received props instead. + const callsWithProps = mockTiler.mock.calls.filter((call) => call[0] !== undefined); + + return callsWithProps[callsWithProps.length - 1][0]; + }; + + describe('when placementArea is true (includeTargets: true)', () => { + it('passes selectedChoice, onChoiceClick, and onPlacementClick to the tiler', () => { + const props = renderWithConfig({ includeTargets: true, orientation: 'vertical' }); + + expect(props.selectedChoice).toBeNull(); + expect(typeof props.onChoiceClick).toBe('function'); + expect(typeof props.onPlacementClick).toBe('function'); + }); + }); + + describe('when placementArea is false (includeTargets: false)', () => { + it('does not pass selectedChoice, onChoiceClick, or onPlacementClick to the tiler', () => { + const props = renderWithConfig({ includeTargets: false, orientation: 'vertical' }); + + expect(props.selectedChoice).toBeUndefined(); + expect(props.onChoiceClick).toBeUndefined(); + expect(props.onPlacementClick).toBeUndefined(); + }); + }); + + describe('when placementArea is missing entirely', () => { + it('does not pass selectedChoice, onChoiceClick, or onPlacementClick to the tiler', () => { + const props = renderWithConfig({ orientation: 'vertical' }); + + expect(props.selectedChoice).toBeUndefined(); + expect(props.onChoiceClick).toBeUndefined(); + expect(props.onPlacementClick).toBeUndefined(); + }); + }); +}); diff --git a/packages/placement-ordering/src/__tests__/tile.test.jsx b/packages/placement-ordering/src/__tests__/tile.test.jsx new file mode 100644 index 0000000000..61b79c57e6 --- /dev/null +++ b/packages/placement-ordering/src/__tests__/tile.test.jsx @@ -0,0 +1,325 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Tile from '../tile'; + +describe('Tile click-to-select / click-to-place', () => { + const baseChoiceProps = { + id: 'c1', + label: 'Choice 1', + type: 'choice', + empty: false, + draggable: true, + disabled: false, + index: undefined, + instanceId: 'inst', + tileIndex: 0, + }; + + const baseTargetProps = { + id: 'c1', + label: 'Choice 1', + type: 'target', + empty: false, + draggable: true, + disabled: false, + index: 2, + instanceId: 'inst', + tileIndex: 2, + }; + + const emptyTargetProps = { + id: undefined, + label: '', + type: 'target', + empty: true, + draggable: false, + disabled: false, + index: 2, + instanceId: 'inst', + tileIndex: 2, + }; + + const selectedChoice = { id: 'c1', type: 'choice', instanceId: 'inst', value: 'Choice 1', index: undefined }; + const selectedTarget = { id: 'c9', type: 'target', instanceId: 'inst', value: 'Choice 9', index: 0 }; + + describe('choice tiles', () => { + it('selects the choice on click when nothing is selected', () => { + const onChoiceClick = jest.fn(); + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onChoiceClick).toHaveBeenCalledWith({ id: 'c1', type: 'choice', instanceId: 'inst', value: 'Choice 1', index: undefined }); + expect(onPlacementClick).not.toHaveBeenCalled(); + }); + + it('selects/switches on click even when a different choice is already selected', () => { + const onChoiceClick = jest.fn(); + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onChoiceClick).toHaveBeenCalled(); + expect(onPlacementClick).not.toHaveBeenCalled(); + }); + + it('returns a selected placed answer (a "target") to the choices column/row on click', () => { + const onChoiceClick = jest.fn(); + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onPlacementClick).toHaveBeenCalledWith({ id: 'c1', type: 'choice', instanceId: 'inst', value: 'Choice 1', index: undefined }); + expect(onChoiceClick).not.toHaveBeenCalled(); + }); + + it('does nothing when disabled', () => { + const onChoiceClick = jest.fn(); + const { container } = render(); + + fireEvent.click(container.firstChild); + + expect(onChoiceClick).not.toHaveBeenCalled(); + }); + }); + + describe('target (placement area) tiles', () => { + it('places the selected choice when clicked and something is selected (occupied target)', () => { + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onPlacementClick).toHaveBeenCalledWith({ id: 'c1', type: 'target', instanceId: 'inst', value: 'Choice 1', index: 2 }); + }); + + it('places the selected choice into an empty target', () => { + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onPlacementClick).toHaveBeenCalledWith({ + id: undefined, + type: 'target', + instanceId: 'inst', + value: '', + index: 2, + }); + }); + + it('selects the placed answer when clicked and nothing is selected (filled target)', () => { + const onChoiceClick = jest.fn(); + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onChoiceClick).toHaveBeenCalledWith({ id: 'c1', type: 'target', instanceId: 'inst', value: 'Choice 1', index: 2 }); + expect(onPlacementClick).not.toHaveBeenCalled(); + }); + + it('deselects the placed answer when clicked again while it is the selected one', () => { + const onChoiceClick = jest.fn(); + const onPlacementClick = jest.fn(); + const selectedThisTarget = { id: 'c1', type: 'target', instanceId: 'inst', value: 'Choice 1', index: 2 }; + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onChoiceClick).toHaveBeenCalledWith(selectedThisTarget); + expect(onPlacementClick).not.toHaveBeenCalled(); + }); + + it('places a different selected item here (swap) when clicked and this target is not the selected one', () => { + const onChoiceClick = jest.fn(); + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onPlacementClick).toHaveBeenCalledWith({ id: 'c1', type: 'target', instanceId: 'inst', value: 'Choice 1', index: 2 }); + expect(onChoiceClick).not.toHaveBeenCalled(); + }); + + it('does nothing when clicked empty and nothing is selected', () => { + const onPlacementClick = jest.fn(); + const { container } = render(); + + fireEvent.click(container.firstChild); + + expect(onPlacementClick).not.toHaveBeenCalled(); + }); + + it('does nothing when disabled, even with something selected', () => { + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onPlacementClick).not.toHaveBeenCalled(); + }); + }); + + describe('native Tab stop for non-draggable (empty) tiles', () => { + it('is a native Tab stop when empty and not disabled', () => { + const { container } = render(); + + expect(container.firstChild).toHaveAttribute('tabIndex', '0'); + expect(container.firstChild).toHaveAttribute('role', 'button'); + }); + + it('is not a native Tab stop when disabled', () => { + const { container } = render(); + + expect(container.firstChild).not.toHaveAttribute('tabIndex'); + }); + + it('is not an additional native Tab stop when draggable (dnd-kit already grants focus)', () => { + const { container } = render(); + + expect(container.firstChild).not.toHaveAttribute('tabIndex'); + }); + + it('places the selected choice on Enter', () => { + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.keyDown(container.firstChild, { code: 'Enter' }); + + expect(onPlacementClick).toHaveBeenCalled(); + }); + + it('places the selected choice on Space', () => { + const onPlacementClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.keyDown(container.firstChild, { code: 'Space' }); + + expect(onPlacementClick).toHaveBeenCalled(); + }); + }); + + describe('isSelected', () => { + it('does not throw and still fires the click callback when this exact tile is the selected one', () => { + const onChoiceClick = jest.fn(); + const { container } = render( + , + ); + + fireEvent.click(container.firstChild); + + expect(onChoiceClick).toHaveBeenCalledWith(selectedChoice); + }); + }); + + describe('hover effect on response areas while a choice is selected', () => { + it('sets the cursor to pointer on a target while something is selected', () => { + const { container } = render(); + + expect(container.firstChild).toHaveStyle({ cursor: 'pointer' }); + }); + + it('does not change the cursor on a target when nothing is selected', () => { + const { container } = render(); + + expect(container.firstChild).not.toHaveStyle({ cursor: 'pointer' }); + }); + + it('sets the cursor to pointer on an empty target while something is selected', () => { + const { container } = render(); + + expect(container.firstChild).toHaveStyle({ cursor: 'pointer' }); + }); + + it('does not change the cursor on a choice tile even when a target is selected', () => { + const { container } = render(); + + expect(container.firstChild).not.toHaveStyle({ cursor: 'pointer' }); + }); + + it('applies the same visual treatment as a real drag-hover when hovering an occupied target while something is selected', () => { + const { container } = render(); + const content = container.firstChild.firstChild; + const classBeforeHover = content.className; + + fireEvent.mouseEnter(container.firstChild); + expect(content.className).not.toBe(classBeforeHover); + + fireEvent.mouseLeave(container.firstChild); + expect(content.className).toBe(classBeforeHover); + }); + + it('does not apply the hover treatment to a target when nothing is selected', () => { + const { container } = render(); + const content = container.firstChild.firstChild; + const classBeforeHover = content.className; + + fireEvent.mouseEnter(container.firstChild); + expect(content.className).toBe(classBeforeHover); + }); + + it('does not apply the hover treatment to a target while disabled, even with something selected', () => { + const { container } = render(); + const content = container.firstChild.firstChild; + const classBeforeHover = content.className; + + fireEvent.mouseEnter(container.firstChild); + expect(content.className).toBe(classBeforeHover); + }); + + it('does not apply the hover treatment to a choice tile even when a target is selected', () => { + const { container } = render(); + const content = container.firstChild.firstChild; + const classBeforeHover = content.className; + + fireEvent.mouseEnter(container.firstChild); + expect(content.className).toBe(classBeforeHover); + }); + }); +}); diff --git a/packages/placement-ordering/src/placement-ordering.jsx b/packages/placement-ordering/src/placement-ordering.jsx index 6529bba328..5f10cdb7ed 100644 --- a/packages/placement-ordering/src/placement-ordering.jsx +++ b/packages/placement-ordering/src/placement-ordering.jsx @@ -18,6 +18,11 @@ import { buildState, reducer } from './ordering'; import { haveSameValuesButDifferentOrder } from './utils'; import { closestDroppableKeyboardCoordinates } from './keyboard-coordinates'; +// A click that lands right after a real drag gesture ends must be ignored by the +// click-to-select/click-to-place handlers below, or it would immediately reopen or +// re-trigger a selection for a drag that just completed. +const CLICK_AFTER_DRAG_GUARD_MS = 250; + const getKeyboardDragOptions = (includeTargets) => includeTargets ? { @@ -122,7 +127,9 @@ export class PlacementOrdering extends React.Component { this.state = { showingCorrect: false, + selectedChoice: null, }; + this.lastDragEndAt = 0; const { model } = props || {}; const { env } = model || {}; @@ -300,6 +307,12 @@ export class PlacementOrdering extends React.Component { const { over, active } = event; const ordering = this.createOrdering(); + // A real drag (pointer or keyboard) just ended — whatever mirrored selection it + // set on start is now resolved, and any click landing immediately after this must + // not be misread as a fresh selection/placement + this.cancelSelection(); + this.lastDragEndAt = Date.now(); + if (over && active) { const draggedItem = active.data.current; const droppedOnItem = over.data.current; @@ -320,6 +333,86 @@ export class PlacementOrdering extends React.Component { } }; + onDragStart = (event) => { + const { active } = event; + + if (active?.data?.current) { + // A real drag (pointer or keyboard) is itself a selection — mirror it into the + // same selectedChoice state that click-to-select uses, so the two interaction + // models can be freely intermixed + this.selectChoice(active.data.current); + } + }; + + onDragCancel = () => { + this.cancelSelection(); + this.lastDragEndAt = Date.now(); + }; + + isSameChoice = (a, b) => !!a && !!b && a.type === b.type && a.id === b.id && a.index === b.index; + + selectChoice = (data) => { + this.setState({ selectedChoice: data }); + }; + + // Click-to-select semantics: selecting the currently-selected choice again clears + // the selection instead of re-selecting it. + toggleChoiceSelection = (data) => { + this.setState((state) => ({ + selectedChoice: this.isSameChoice(state.selectedChoice, data) ? null : data, + })); + }; + + cancelSelection = () => { + this.setState({ selectedChoice: null }); + }; + + // If a real dnd-kit drag (started via keyboard Space/Enter) is still live when a + // click completes the placement below, it needs to be cleanly ended — otherwise + // dnd-kit would still think a drag is in progress. Escape is already configured as + // this sensor's cancel key (see getKeyboardDragOptions), and dispatching it as a real + // DOM KeyboardEvent is how dnd-kit's own document-level listener is reached from + // outside its sensor. onDragCancel only resets local UI state, not the session, so + // this is safe to call unconditionally, including when no drag is actually live + // (dnd-kit simply has no listener attached in that case, and the dispatch is a + // no-op). + endAnyLiveKeyboardDrag = () => { + document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape', bubbles: true, cancelable: true })); + }; + + placeSelectedChoice = (targetTileData) => { + const { selectedChoice } = this.state; + + if (!selectedChoice) { + return; + } + + const ordering = this.createOrdering(); + + this.onDropChoice(targetTileData, selectedChoice, ordering); + this.cancelSelection(); + this.endAnyLiveKeyboardDrag(); + this.lastDragEndAt = Date.now(); + }; + + isClickSoonAfterDragEnd = () => Date.now() - this.lastDragEndAt < CLICK_AFTER_DRAG_GUARD_MS; + + onChoiceClick = (data) => { + if (this.isClickSoonAfterDragEnd()) { + return; + } + + this.toggleChoiceSelection(data); + }; + + onPlacementClick = (targetTileData) => { + if (this.isClickSoonAfterDragEnd()) { + return; + } + + this.placeSelectedChoice(targetTileData); + }; + render() { const { model } = this.props; const { @@ -364,10 +457,19 @@ export class PlacementOrdering extends React.Component { width: '100%', }; + const clickPlacementProps = includeTargets + ? { + selectedChoice: this.state.selectedChoice, + onChoiceClick: this.onChoiceClick, + onPlacementClick: this.onPlacementClick, + } + : {}; + return ( { }} + onDragStart={this.onDragStart} onDragEnd={this.onDragEnd} + onDragCancel={this.onDragCancel} collisionDetection={rectIntersection} modifiers={[restrictToParentElement]} {...getKeyboardDragOptions(includeTargets)} @@ -407,6 +509,7 @@ export class PlacementOrdering extends React.Component { tileSize={config.tileSize} includeTargets={includeTargets} choiceLabelEnabled={model.config && model.config.choiceLabelEnabled} + {...clickPlacementProps} /> diff --git a/packages/placement-ordering/src/tile.jsx b/packages/placement-ordering/src/tile.jsx index 11a7aa445d..9e5e3d9d40 100644 --- a/packages/placement-ordering/src/tile.jsx +++ b/packages/placement-ordering/src/tile.jsx @@ -29,70 +29,82 @@ Holder.propTypes = { disabled: PropTypes.bool, }; -const StyledTileContent = styled('div')(({ theme, isDragging, isOver, disabled, outcome, label, type }) => ({ - cursor: disabled ? 'not-allowed' : 'grab', - width: '100%', - height: '100%', - padding: '10px', - boxSizing: 'border-box', - overflow: 'hidden', - border: (type === 'choice' || type === 'target') ? `1px solid ${theme.palette.grey[400]}` : '1px solid transparent', - backgroundColor: (type === 'choice' || type === 'target') ? color.background() : 'transparent', - transition: (type === 'choice' || type === 'target') ? 'background-color 150ms ease, border-color 150ms ease, opacity 150ms ease' : 'none', - pointerEvents: 'none', - userSelect: 'none', - - ...((type === 'choice' || type === 'target') && { - '&:hover': { - backgroundColor: disabled ? color.background() : color.secondary(), - borderColor: disabled ? theme.palette.grey[400] : theme.palette.primary.main, - transform: disabled ? 'none' : 'scale(1.02)', - }, - }), +const StyledTileContent = styled('div')( + ({ theme, isDragging, isSelected, isOver, disabled, outcome, label, type }) => ({ + cursor: disabled ? 'not-allowed' : 'grab', + width: '100%', + height: '100%', + padding: '10px', + boxSizing: 'border-box', + overflow: 'hidden', + border: type === 'choice' || type === 'target' ? `1px solid ${theme.palette.grey[400]}` : '1px solid transparent', + backgroundColor: type === 'choice' || type === 'target' ? color.background() : 'transparent', + transition: + type === 'choice' || type === 'target' + ? 'background-color 150ms ease, border-color 150ms ease, opacity 150ms ease' + : 'none', + pointerEvents: 'none', + userSelect: 'none', - // Apply conditional styles based on props (only if not empty spacing tile) - ...((type === 'choice' || type === 'target') && isOver && !disabled && { - opacity: 0.4, - backgroundColor: color.primaryLight(), - borderColor: theme.palette.primary.main, - borderStyle: 'dashed', - transform: 'scale(1.05)', - }), + ...((type === 'choice' || type === 'target') && { + '&:hover': { + backgroundColor: disabled ? color.background() : color.secondary(), + borderColor: disabled ? theme.palette.grey[400] : theme.palette.primary.main, + transform: disabled ? 'none' : 'scale(1.02)', + }, + }), - ...((type === 'choice' || type === 'target') && isDragging && !disabled && { - opacity: 0.6, - backgroundColor: color.secondaryLight(), - transform: 'scale(1.05) rotate(2deg)', - boxShadow: '0 8px 16px rgba(0,0,0,0.2)', - cursor: 'grabbing', - }), + // Apply conditional styles based on props (only if not empty spacing tile) + ...((type === 'choice' || type === 'target') && + isOver && + !disabled && { + opacity: 0.4, + backgroundColor: color.primaryLight(), + borderColor: theme.palette.primary.main, + borderStyle: 'dashed', + transform: 'scale(1.05)', + }), - ...((type === 'choice' || type === 'target') && disabled && { - cursor: 'not-allowed', - '&:hover': { - backgroundColor: color.background(), - transform: 'none', - }, - }), + ...((type === 'choice' || type === 'target') && + (isDragging || isSelected) && + !disabled && { + opacity: 0.6, + backgroundColor: color.secondaryLight(), + transform: 'scale(1.05) rotate(2deg)', + boxShadow: '0 8px 16px rgba(0,0,0,0.2)', + cursor: 'grabbing', + }), - ...((type === 'choice' || type === 'target') && outcome === 'incorrect' && { - border: `1px solid ${color.incorrect()}`, - }), + ...((type === 'choice' || type === 'target') && + disabled && { + cursor: 'not-allowed', + '&:hover': { + backgroundColor: color.background(), + transform: 'none', + }, + }), - ...((type === 'choice' || type === 'target') && outcome === 'correct' && { - border: `1px solid ${color.correct()}`, - }), + ...((type === 'choice' || type === 'target') && + outcome === 'incorrect' && { + border: `1px solid ${color.incorrect()}`, + }), + + ...((type === 'choice' || type === 'target') && + outcome === 'correct' && { + border: `1px solid ${color.correct()}`, + }), - ...(!label && { - border: 'none', - '&:hover': { - backgroundColor: 'unset', - }, + ...(!label && { + border: 'none', + '&:hover': { + backgroundColor: 'unset', + }, + }), }), -})); +); const TileContent = (props) => { - const { type, isDragging, empty, isOver, label, disabled, outcome, guideIndex } = props; + const { type, isDragging, isSelected, empty, isOver, label, disabled, outcome, guideIndex } = props; if (empty) { return ; @@ -101,6 +113,7 @@ const TileContent = (props) => { { instanceId, draggable, tileIndex, + selectedChoice, + onChoiceClick, + onPlacementClick, } = props; // Use type + tileIndex in the IDs to guarantee uniqueness in all modes. @@ -133,6 +149,10 @@ export const Tile = (props) => { const dragId = `tile-${type}-${id != null ? id : 'empty'}-${tileIndex}-${instanceId}`; const dropId = `drop-${type}-${id != null ? id : 'empty'}-${tileIndex}-${instanceId}`; + // Built once and reused for dnd-kit's own drag/drop data and for the click handlers + // below, so a click carries exactly the same shape a real drag would. + const tileData = { id, type, instanceId, value: label, index }; + const { attributes, listeners, @@ -141,33 +161,80 @@ export const Tile = (props) => { isDragging, } = useDraggable({ id: dragId, - data: { - id, - type, - instanceId, - value: label, - index - }, + data: tileData, disabled: !draggable || disabled, }); - const { - setNodeRef: setDropRef, - isOver: dropIsOver, - } = useDroppable({ + const { setNodeRef: setDropRef, isOver: dropIsOver } = useDroppable({ id: dropId, - data: { - id, - type, - instanceId, - value: label, - index - }, + data: tileData, // Disable droppable on the tile currently being dragged so closestCenter // cannot pick it as the drop target (prevents self-collision and wrong matches). disabled: isDragging, }); + const isSelected = + !!selectedChoice && + selectedChoice.type === tileData.type && + selectedChoice.id === tileData.id && + selectedChoice.index === tileData.index; + + // dnd-kit's own isOver (dropIsOver) only reflects real collision detection during an + // active drag, so it stays false while a placement area is merely a candidate for a + // pending click-based placement. Track hovering locally and fold it into the same + // isOver signal the styling below already uses, so hovering a response area while + // something is selected gets the exact same treatment as hovering it during a real + // drag — one code path, no duplicated CSS. + const [isHovered, setIsHovered] = React.useState(false); + const hasSelection = !!selectedChoice && !disabled; + const showsHoverEffect = dropIsOver || (type === 'target' && hasSelection && isHovered); + + // Click-to-select/click-to-place (both choices and targets are handled by this same + // component, differentiated by `type`): + // - Choice tile: selects/switches/deselects it, UNLESS a placed answer (a "target") + // is currently selected, in which case clicking any choice-row tile returns it to + // the choices column/row (choice -> choice has no placement meaning in the + // reducer, so there's no ambiguity there). + // - Target tile: clicking the already-selected placed answer again deselects it; + // clicking it while something else is selected places that selection here + // (empty or occupied); clicking a filled target with nothing selected selects + // that placed answer for moving, the same way Tab+Space/Enter does. An empty + // target with nothing selected is a no-op. + const handleClick = () => { + if (disabled) return; + + if (type === 'choice') { + if (selectedChoice?.type === 'target') { + onPlacementClick?.(tileData); + } else if (draggable) { + onChoiceClick?.(tileData); + } + } else if (type === 'target') { + if (isSelected) { + onChoiceClick?.(tileData); + } else if (selectedChoice) { + onPlacementClick?.(tileData); + } else if (draggable) { + onChoiceClick?.(tileData); + } + } + }; + + // dnd-kit's own draggable attributes (spread below) already make a draggable tile a + // native Tab stop with its own Space/Enter activation. An empty tile is never + // draggable, so without this it wouldn't be reachable by Tab at all — needed for + // "select a choice, then Tab to a placement area and press Space/Enter" to work. + // Gated to non-draggable tiles specifically so it never overrides dnd-kit's own + // keydown handling (see the conditional spread order below). + const isNativeTabStop = !(draggable && !disabled) && !disabled; + + const handleKeyDown = (e) => { + if (e.code === 'Space' || e.code === 'Enter') { + e.preventDefault(); + handleClick(); + } + }; + const ref = React.useRef(null); React.useEffect(() => { @@ -204,7 +271,7 @@ export const Tile = (props) => { margin: 0, textAlign: 'center', pointerEvents: 'auto', - cursor: disabled ? 'not-allowed' : (isDragging ? 'grabbing' : 'grab'), + cursor: disabled ? 'not-allowed' : type === 'target' && hasSelection ? 'pointer' : isDragging ? 'grabbing' : 'grab', zIndex: isDragging ? 1000 : 'auto', willChange: isDragging ? 'transform' : 'auto', }; @@ -213,6 +280,12 @@ export const Tile = (props) => {
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} {...(draggable && !disabled ? { ...listeners, ...attributes } : {})} > { empty={empty} index={index} guideIndex={guideIndex} - isOver={dropIsOver} + isOver={showsHoverEffect} isDragging={isDragging} + isSelected={isSelected} disabled={disabled} outcome={outcome} type={type} @@ -244,6 +318,9 @@ Tile.propTypes = { instanceId: PropTypes.any, draggable: PropTypes.bool, tileIndex: PropTypes.number, + selectedChoice: PropTypes.object, + onChoiceClick: PropTypes.func, + onPlacementClick: PropTypes.func, }; export default Tile; diff --git a/packages/placement-ordering/src/tiler.jsx b/packages/placement-ordering/src/tiler.jsx index f8de3aa0af..c233c6682b 100644 --- a/packages/placement-ordering/src/tiler.jsx +++ b/packages/placement-ordering/src/tiler.jsx @@ -11,6 +11,9 @@ const types = { tiles: PropTypes.array.isRequired, tileSize: PropTypes.string, addGuide: PropTypes.bool, + selectedChoice: PropTypes.object, + onChoiceClick: PropTypes.func, + onPlacementClick: PropTypes.func, }; const defaults = { @@ -26,6 +29,9 @@ const buildTiles = (props) => { tile.guideIndex = props.addGuide ? tile.index + 1 : undefined; // unique key for dnd-kit IDs tile.tileIndex = index; + tile.selectedChoice = props.selectedChoice; + tile.onChoiceClick = props.onChoiceClick; + tile.onPlacementClick = props.onPlacementClick; if (props.includeTargets) { return ; diff --git a/packages/rubric/configure/package.json b/packages/rubric/configure/package.json index a5dfd4970a..dae1d9bc79 100644 --- a/packages/rubric/configure/package.json +++ b/packages/rubric/configure/package.json @@ -11,10 +11,10 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/render-ui": "6.1.3", - "@pie-lib/rubric": "2.0.14", + "@pie-lib/rubric": "2.0.15", "debug": "^4.1.1", "react": "18.3.1", "react-dom": "18.3.1" diff --git a/packages/ruler/configure/package.json b/packages/ruler/configure/package.json index d99b966310..473c20d5ee 100644 --- a/packages/ruler/configure/package.json +++ b/packages/ruler/configure/package.json @@ -10,7 +10,7 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14" + "@pie-lib/config-ui": "13.0.15" }, "author": "", "license": "ISC" diff --git a/packages/select-text/configure/package.json b/packages/select-text/configure/package.json index 1f3276b845..ef49783a97 100644 --- a/packages/select-text/configure/package.json +++ b/packages/select-text/configure/package.json @@ -11,8 +11,8 @@ "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", "@pie-framework/pie-configure-events": "^1.3.0", - "@pie-lib/config-ui": "13.0.14", - "@pie-lib/editable-html-tip-tap": "2.1.12", + "@pie-lib/config-ui": "13.0.15", + "@pie-lib/editable-html-tip-tap": "2.1.13", "@pie-lib/text-select": "3.0.5", "debug": "^4.1.1", "lodash-es": "^4.17.23", diff --git a/pslb/pslb.config.js b/pslb/pslb.config.js index a9e0b301f3..194fc42ae1 100644 --- a/pslb/pslb.config.js +++ b/pslb/pslb.config.js @@ -44,12 +44,12 @@ module.exports = { libs: { repository: 'pie-framework/pie-elements', packages: [ - { name: '@pie-lib/drag-module', version: '4.1.0' }, - { name: '@pie-lib/math-rendering-module', version: '5.1.3' }, - { name: '@pie-lib/math-edit-module', version: '4.2.13' }, - { name: '@pie-lib/shared-module', version: '5.2.13' }, - { name: '@pie-lib/editable-html-module', version: '7.1.15' }, - { name: '@pie-lib/config-module', version: '4.0.18' }, + { name: '@pie-lib/drag-module', version: '4.1.1' }, + { name: '@pie-lib/math-rendering-module', version: '5.1.4' }, + { name: '@pie-lib/math-edit-module', version: '4.2.14' }, + { name: '@pie-lib/shared-module', version: '5.2.14' }, + { name: '@pie-lib/editable-html-module', version: '7.1.16' }, + { name: '@pie-lib/config-module', version: '4.0.19' }, ], }, }; diff --git a/yarn.lock b/yarn.lock index aef0c2ef56..828c11a24a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2855,10 +2855,10 @@ debug "^4.1.1" lodash-es "^4.17.23" -"@pie-lib/charting@7.0.14": - version "7.0.14" - resolved "https://registry.yarnpkg.com/@pie-lib/charting/-/charting-7.0.14.tgz#17a470e395d800da42ce923fb0bce2f27adec239" - integrity sha512-woGswgWvEbwU9CNryL64lZRDxvtRcPtRRhh2HRykzmIrlGPJIbybidPKSJbLbl8VuhZy7Po3J4ZVTnYRGkSttw== +"@pie-lib/charting@7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@pie-lib/charting/-/charting-7.0.15.tgz#78b7ffba147065b1088b9c8e979a296a4d025a23" + integrity sha512-chENjKfvFapfCjqODb7AFQf8gaV3a9rFrpnCh23dHgguJjJQeKMyg9h8q0AZLyVjT6hNi0/AAFPtejFq6n7rWA== dependencies: "@emotion/react" "^11.14.0" "@emotion/styled" "^11.14.1" @@ -2866,7 +2866,7 @@ "@mui/icons-material" "^7.3.4" "@mui/material" "^7.3.4" "@pie-lib/math-rendering" "^5.1.0" - "@pie-lib/plot" "^4.0.14" + "@pie-lib/plot" "^4.0.15" "@pie-lib/render-ui" "^6.1.3" "@pie-lib/translator" "^4.0.2" "@visx/axis" "^3.0.0" @@ -2886,16 +2886,16 @@ react-draggable "^3.3.0" react-input-autosize "^2.2.1" -"@pie-lib/config-ui@13.0.14": - version "13.0.14" - resolved "https://registry.yarnpkg.com/@pie-lib/config-ui/-/config-ui-13.0.14.tgz#7b35992df0090a1fe232a38e6e4c501950423d9b" - integrity sha512-qZIYX04OMX2RRbxfR/+Zw7XuKoX0m53nfDjgVaAtlto3HrFozKuHR8ugtafS7wa1A+lMsffUF7ISSV2shQ0eGw== +"@pie-lib/config-ui@13.0.15": + version "13.0.15" + resolved "https://registry.yarnpkg.com/@pie-lib/config-ui/-/config-ui-13.0.15.tgz#fdda2550d063269df5f6f5f8639f92616a0dcf11" + integrity sha512-9H0eZfmuiY2J5IkP3z05JmrpX+Uaf0wqCdHkcOCg0FHyzin7sqVG0U9C2OEpuVSARk5tvnWndmgTh4Ho70uz9A== dependencies: "@emotion/react" "^11.14.0" "@emotion/styled" "^11.14.1" "@mui/icons-material" "^7.3.4" "@mui/material" "^7.3.4" - "@pie-lib/editable-html-tip-tap" "^2.1.12" + "@pie-lib/editable-html-tip-tap" "^2.1.13" "@pie-lib/icons" "^4.0.3" "@pie-lib/render-ui" "^6.1.3" assert "^1.4.1" @@ -2948,10 +2948,10 @@ prop-types "^15.7.2" react "^18.2.0" -"@pie-lib/editable-html-tip-tap@2.1.12", "@pie-lib/editable-html-tip-tap@^2.1.12": - version "2.1.12" - resolved "https://registry.yarnpkg.com/@pie-lib/editable-html-tip-tap/-/editable-html-tip-tap-2.1.12.tgz#084b93d1b84e88e054eeb448cf54c27e9e394810" - integrity sha512-dROJnr6uUoekSb6bwISiOS57uFxuHNlcDtNIsi17W77FmYQkQiqwYNZ0WSMr+cpziHzF234PboThjoNTXFzc1Q== +"@pie-lib/editable-html-tip-tap@2.1.13", "@pie-lib/editable-html-tip-tap@^2.1.13": + version "2.1.13" + resolved "https://registry.yarnpkg.com/@pie-lib/editable-html-tip-tap/-/editable-html-tip-tap-2.1.13.tgz#c0145c7705b0d4161fc25238f048fe7209e4f52f" + integrity sha512-9+NNn9EdeDSm3qq4KFbJY/1REnANFTqtCI+j+McPlFHFW1v6BbLWFdz4HtrcNBPUloQjiRKioB7k+Vor7s2WYw== dependencies: "@dnd-kit/core" "6.3.1" "@dnd-kit/modifiers" "9.0.0" @@ -3035,10 +3035,10 @@ resolved "https://registry.yarnpkg.com/@pie-lib/feedback/-/feedback-2.0.2.tgz#897e249ba78f8203aa39d75bd46b59e499b8e773" integrity sha512-y7NKifptZJJUb5eynFz+ppai4XoeOjlowSh6BrIrvghEshGkoX+O4EYzQCXcSlFuyOjjiuYaHd/OzNuifaYG5Q== -"@pie-lib/graphing-solution-set@4.0.14": - version "4.0.14" - resolved "https://registry.yarnpkg.com/@pie-lib/graphing-solution-set/-/graphing-solution-set-4.0.14.tgz#79bff42a3d805b712d770ee35184f6e0474395a1" - integrity sha512-Vc4H7UZi8Ky8GJXOSg1H7fJykVJIaHm3RhBDhQAVzlOowNcOuV9UnWCJUJgoNOefnacKkMZEOHiT8ldbRHas+A== +"@pie-lib/graphing-solution-set@4.0.15": + version "4.0.15" + resolved "https://registry.yarnpkg.com/@pie-lib/graphing-solution-set/-/graphing-solution-set-4.0.15.tgz#d905c9047d68cf6bb043310bd68d2ff72ee908e9" + integrity sha512-Ag3rYjeVIQyGyfJims2g9JszV9n6x/6sA6lK2+Gn0lvUF3fm/IX3dzdLw1FELqcEFqbGirRYMW+2sn03qI8C0Q== dependencies: "@dnd-kit/sortable" "10.0.0" "@emotion/react" "^11.14.0" @@ -3047,9 +3047,9 @@ "@mui/icons-material" "^7.3.4" "@mui/material" "^7.3.4" "@pie-lib/drag" "^4.1.0" - "@pie-lib/editable-html-tip-tap" "^2.1.12" + "@pie-lib/editable-html-tip-tap" "^2.1.13" "@pie-lib/graphing-utils" "^3.0.2" - "@pie-lib/plot" "^4.0.14" + "@pie-lib/plot" "^4.0.15" "@pie-lib/render-ui" "^6.1.3" "@pie-lib/tools" "^2.0.3" "@pie-lib/translator" "^4.0.2" @@ -3085,10 +3085,10 @@ debug "^4.1.1" lodash-es "^4.17.23" -"@pie-lib/graphing@4.0.15": - version "4.0.15" - resolved "https://registry.yarnpkg.com/@pie-lib/graphing/-/graphing-4.0.15.tgz#cdfef38e6df5eea9dae396d18cf8ff5180ffdf9c" - integrity sha512-qQum9xWQGelJaw2pz2VS8mYS2g1P96doKXO+2O7ujA29aI9cQ28NDLDgb7Pp7qBOVAq6R7XW5gyJtZu+8BIPdw== +"@pie-lib/graphing@4.0.16": + version "4.0.16" + resolved "https://registry.yarnpkg.com/@pie-lib/graphing/-/graphing-4.0.16.tgz#70f824ad04f74d98152c66d6f22a7850b182cf5b" + integrity sha512-/NmZz7s/Qdca8jYLV6Ir82jZWkTjIPqASugib+u3Ws/ozVphcSQ2bPLgZDtOb4Z+5o/OSYdU1to1ZRBGz60e/w== dependencies: "@dnd-kit/sortable" "10.0.0" "@emotion/react" "^11.14.0" @@ -3097,9 +3097,9 @@ "@mui/icons-material" "^7.3.4" "@mui/material" "^7.3.4" "@pie-lib/drag" "^4.1.0" - "@pie-lib/editable-html-tip-tap" "^2.1.12" + "@pie-lib/editable-html-tip-tap" "^2.1.13" "@pie-lib/graphing-utils" "^3.0.2" - "@pie-lib/plot" "^4.0.14" + "@pie-lib/plot" "^4.0.15" "@pie-lib/render-ui" "^6.1.3" "@pie-lib/translator" "^4.0.2" "@visx/axis" "^3.0.0" @@ -3136,17 +3136,17 @@ "@mui/material" "^7.3.4" prop-types "^15.6.2" -"@pie-lib/mask-markup@3.0.14": - version "3.0.14" - resolved "https://registry.yarnpkg.com/@pie-lib/mask-markup/-/mask-markup-3.0.14.tgz#d1804df2b366100b7670357d1e470db614529e55" - integrity sha512-zcLSkqkPESsPX6OGRLNImw1GOkv2U8PyNOBnSV5SH/JsF1dl7sC5jn919ZjeMWTLIBWyQXKqlrXVC8Noy/XL1w== +"@pie-lib/mask-markup@3.0.15": + version "3.0.15" + resolved "https://registry.yarnpkg.com/@pie-lib/mask-markup/-/mask-markup-3.0.15.tgz#c691402e62489cb66b13eac8ae7b9825053a0899" + integrity sha512-te3AFRgrRgGb5BOt43QqxwQPDaj6SsX3sGxohNpc1sB2c/EvMOL3y3Wuo8YZpI8y6V9j0VmHLAzY/wWLk8n3CA== dependencies: "@emotion/react" "^11.14.0" "@emotion/styled" "^11.14.1" "@mui/icons-material" "^7.3.4" "@mui/material" "^7.3.4" "@pie-lib/drag" "^4.1.0" - "@pie-lib/editable-html-tip-tap" "^2.1.12" + "@pie-lib/editable-html-tip-tap" "^2.1.13" "@pie-lib/math-rendering" "^5.1.0" "@pie-lib/render-ui" "^6.1.3" classnames "^2.2.6" @@ -3220,17 +3220,17 @@ lodash-es "^4.17.23" prop-types "^15.7.2" -"@pie-lib/plot@4.0.14", "@pie-lib/plot@^4.0.14": - version "4.0.14" - resolved "https://registry.yarnpkg.com/@pie-lib/plot/-/plot-4.0.14.tgz#cca5763a512b8d9187847fc396970948a703fea9" - integrity sha512-akMfWbdnQovEXhBDTksRQDbS9TxLfw419rmsSGPUssIkGrNOng48+y4KbHAWa/lXBE7aYjIVoICrbfRgK/OcBQ== +"@pie-lib/plot@4.0.15", "@pie-lib/plot@^4.0.15": + version "4.0.15" + resolved "https://registry.yarnpkg.com/@pie-lib/plot/-/plot-4.0.15.tgz#7b371c146074843da055d80d66d0d7c75a88b94f" + integrity sha512-7p1IifPyQNisiUIHxc30Tta8/TFcba3d6kuMHpEOPgnB9o9BP2c9UgRV/hEnYTQhgtIiwY3PLLuB17owWW2GHA== dependencies: "@emotion/react" "^11.14.0" "@emotion/styled" "^11.14.1" "@mapbox/point-geometry" "^1.1.0" "@mui/icons-material" "^7.3.4" "@mui/material" "^7.3.4" - "@pie-lib/editable-html-tip-tap" "^2.1.12" + "@pie-lib/editable-html-tip-tap" "^2.1.13" "@pie-lib/render-ui" "^6.1.3" assert "^1.4.1" d3-scale "^4.0.2" @@ -3261,17 +3261,17 @@ prop-types "^15.7.2" react-transition-group "^4.4.5" -"@pie-lib/rubric@2.0.14": - version "2.0.14" - resolved "https://registry.yarnpkg.com/@pie-lib/rubric/-/rubric-2.0.14.tgz#c6989195feb340896f74ad601f8470ae762053ce" - integrity sha512-Rt8W/ibL8B+4bO7LqfAJywC1HloIgtJXIQ5X+dACZpK+KDkK1KazSuNGgyEv9kXxH9la6rgyNAxxfz+I9f4W4A== +"@pie-lib/rubric@2.0.15": + version "2.0.15" + resolved "https://registry.yarnpkg.com/@pie-lib/rubric/-/rubric-2.0.15.tgz#3269cb24032667ba0fe3f37792a72a51266983a2" + integrity sha512-jhO9fUNhk62Hrh50cQTfXkY2fWxhp8YEYkgLk2g/kYI3oRvKB0CNYn1yRZbpE6rDgf2H51fcmuEzdrL92etkPw== dependencies: "@emotion/react" "^11.14.0" "@emotion/styled" "^11.14.1" "@hello-pangea/dnd" "^18.0.1" "@mui/icons-material" "^7.3.4" "@mui/material" "^7.3.4" - "@pie-lib/editable-html-tip-tap" "^2.1.12" + "@pie-lib/editable-html-tip-tap" "^2.1.13" debug "^4.1.1" lodash-es "^4.17.23" prop-types "^15.7.2"