diff --git a/kolibri/plugins/coach/frontend/views/common/QuestionsAccordion.vue b/kolibri/plugins/coach/frontend/views/common/QuestionsAccordion.vue index e289e8d26d4..e84c30f058a 100644 --- a/kolibri/plugins/coach/frontend/views/common/QuestionsAccordion.vue +++ b/kolibri/plugins/coach/frontend/views/common/QuestionsAccordion.vue @@ -34,102 +34,98 @@ - - - - - + + - - - - - - + + + + + @@ -139,10 +135,10 @@ import { computed, ref } from 'vue'; import { enhancedQuizManagementStrings } from 'kolibri-common/strings/enhancedQuizManagementStrings'; - import Draggable from 'kolibri-common/components/sortable/Draggable'; - import DragHandle from 'kolibri-common/components/sortable/DragHandle'; - import DragContainer from 'kolibri-common/components/sortable/DragContainer'; - import DragSortWidget from 'kolibri-common/components/sortable/DragSortWidget'; + import DraggableItem from 'kolibri-common/components/draggable/DraggableItem'; + import DraggableHandle from 'kolibri-common/components/draggable/DraggableHandle'; + import DraggableRegion from 'kolibri-common/components/draggable/DraggableRegion'; + import DragSortWidget from 'kolibri-common/components/draggable/DragSortWidget'; import AccordionItem from 'kolibri-common/components/accordion/AccordionItem'; import commonCoreStrings from 'kolibri/uiText/commonCoreStrings'; import AccordionContainer from 'kolibri-common/components/accordion/AccordionContainer'; @@ -151,9 +147,9 @@ export default { name: 'QuestionsAccordion', components: { - Draggable, - DragHandle, - DragContainer, + DraggableItem, + DraggableHandle, + DraggableRegion, DragSortWidget, AccordionItem, AccordionContainer, @@ -319,17 +315,21 @@ // Used to mitigate the issue of text being selected while dragging this.dragActive = true; }, - handleQuestionOrderChange({ newArray }) { - this.$emit('sort', { newArray }); + handleDragEnd() { + // Reset on drag end (not only on a reorder) so a drag that changes nothing + // still re-enables text selection. this.dragActive = false; }, + handleQuestionOrderChange(newArray) { + this.$emit('sort', { newArray }); + }, handleKeyboardDragDown(oldIndex) { const newArray = this.moveDownOne(oldIndex, this.questions); - this.handleQuestionOrderChange({ newArray }); + this.handleQuestionOrderChange(newArray); }, handleKeyboardDragUp(oldIndex) { const newArray = this.moveUpOne(oldIndex, this.questions); - this.handleQuestionOrderChange({ newArray }); + this.handleQuestionOrderChange(newArray); }, handleQuestionCheckboxChange(questionItem, value, $event) { $event.stopPropagation(); diff --git a/kolibri/plugins/coach/frontend/views/lessons/LessonSummaryPage/tables/LessonResourcesTable.vue b/kolibri/plugins/coach/frontend/views/lessons/LessonSummaryPage/tables/LessonResourcesTable.vue index 66427312008..e487e8717e7 100644 --- a/kolibri/plugins/coach/frontend/views/lessons/LessonSummaryPage/tables/LessonResourcesTable.vue +++ b/kolibri/plugins/coach/frontend/views/lessons/LessonSummaryPage/tables/LessonResourcesTable.vue @@ -8,78 +8,74 @@ @@ -92,10 +88,10 @@ import CoreTable from 'kolibri/components/CoreTable'; import TimeDuration from 'kolibri-common/components/TimeDuration'; import { coreStrings } from 'kolibri/uiText/commonCoreStrings'; - import DragContainer from 'kolibri-common/components/sortable/DragContainer'; - import DragHandle from 'kolibri-common/components/sortable/DragHandle'; - import DragSortWidget from 'kolibri-common/components/sortable/DragSortWidget'; - import Draggable from 'kolibri-common/components/sortable/Draggable'; + import DraggableRegion from 'kolibri-common/components/draggable/DraggableRegion'; + import DraggableHandle from 'kolibri-common/components/draggable/DraggableHandle'; + import DragSortWidget from 'kolibri-common/components/draggable/DragSortWidget'; + import DraggableItem from 'kolibri-common/components/draggable/DraggableItem'; import { coachStrings } from '../../../common/commonCoachStrings'; import CSVExporter from '../../../../csv/exporter'; import * as csvFields from '../../../../csv/fields'; @@ -107,10 +103,10 @@ CoreTable, StatusSummary, TimeDuration, - DragContainer, - DragHandle, + DraggableRegion, + DraggableHandle, DragSortWidget, - Draggable, + DraggableItem, }, setup() { const { resourcesLabel$, removeAction$, progressLabel$ } = coreStrings; @@ -146,12 +142,12 @@ }, }, methods: { - handleResourcesOrderChange({ newArray }) { + handleResourcesOrderChange(newArray) { this.$emit('change', { newArray }); }, handleRemoveEntry(entry) { const newArray = this.entries.filter(({ node_id }) => node_id !== entry.node_id); - this.handleResourcesOrderChange({ newArray }); + this.handleResourcesOrderChange(newArray); }, moveUpOne(oldIndex) { this.swap(oldIndex, oldIndex - 1); @@ -165,7 +161,7 @@ newArray[newIndex] = newArray[oldIndex]; newArray[oldIndex] = oldResource; - this.handleResourcesOrderChange({ newArray }); + this.handleResourcesOrderChange(newArray); }, /** * Triggers a CSV download of the resource progress data currently displayed in the table. @@ -200,7 +196,7 @@ diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/OrderInteraction.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/OrderInteraction.vue index 03d70684fad..03994ab698c 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/interactions/OrderInteraction.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/OrderInteraction.vue @@ -5,10 +5,10 @@ import { computed, h, inject, watch } from 'vue'; import { themeTokens } from 'kolibri-design-system/lib/styles/theme'; import { createTranslator } from 'kolibri/utils/i18n'; - import DragContainer from 'kolibri-common/components/sortable/DragContainer'; - import DragHandle from 'kolibri-common/components/sortable/DragHandle'; - import DragSortWidget from 'kolibri-common/components/sortable/DragSortWidget'; - import Draggable from 'kolibri-common/components/sortable/Draggable'; + import DraggableRegion from 'kolibri-common/components/draggable/DraggableRegion'; + import DraggableHandle from 'kolibri-common/components/draggable/DraggableHandle'; + import DragSortWidget from 'kolibri-common/components/draggable/DragSortWidget'; + import DraggableItem from 'kolibri-common/components/draggable/DraggableItem'; import AnswerGuide, { answerGuideStrings } from '../AnswerGuide.vue'; import { BooleanProp, OrientationProp, QTIIdentifierProp } from '../../utils/props'; import useTypedProps from '../../composables/useTypedProps'; @@ -144,7 +144,7 @@ { immediate: true }, ); - function handleSort({ newArray }) { + function handleSort(newArray) { if (!interactive.value) { return; } @@ -230,58 +230,50 @@ ...nonChoiceContent, h(AnswerGuide, { props: { text: orderGuideText.value } }), h( - DragContainer, + DraggableRegion, { props: { items, + tag: 'ul', }, - on: { sort: handleSort }, + attrs: { 'aria-label': orderListLabel$() }, + class: listClasses(), + on: { 'update:items': handleSort }, }, - [ + items.map((item, index) => h( - 'ul', - { - attrs: { 'aria-label': orderListLabel$() }, - class: listClasses(), - }, - items.map((item, index) => - h(Draggable, { key: item.identifier }, [ + DraggableItem, + { props: { tag: 'li' }, key: item.identifier, class: 'qti-order-row-wrapper' }, + [ + // Label sits outside the card, updates reactively + renderLabel(index), + h('div', { class: 'qti-order-row', style: rowStyle }, [ + h(DraggableHandle, [ + h(DragSortWidget, { + props: { + isFirst: index === 0, + isLast: index === items.length - 1, + horizontal: isHorizontal.value, + itemLabel: textByIdentifier[item.identifier], + position: index + 1, + total: items.length, + }, + on: { + moveUp: () => moveItem(item.identifier, -1), + moveDown: () => moveItem(item.identifier, 1), + mousedown: e => e.preventDefault(), + }, + }), + ]), h( - 'li', - { class: 'qti-order-row-wrapper' }, - [ - // Label sits outside the card, updates reactively - renderLabel(index), - h('div', { class: 'qti-order-row', style: rowStyle }, [ - h(DragHandle, [ - h(DragSortWidget, { - props: { - isFirst: index === 0, - isLast: index === items.length - 1, - horizontal: isHorizontal.value, - itemLabel: textByIdentifier[item.identifier], - position: index + 1, - total: items.length, - }, - on: { - moveUp: () => moveItem(item.identifier, -1), - moveDown: () => moveItem(item.identifier, 1), - mousedown: e => e.preventDefault(), - }, - }), - ]), - h( - 'div', - { class: 'qti-order-row-content' }, - contentByIdentifier[item.identifier], - ), - ]), - ].filter(Boolean), + 'div', + { class: 'qti-order-row-content' }, + contentByIdentifier[item.identifier], ), ]), - ), + ].filter(Boolean), ), - ], + ), ), ]); }; diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleAssociableChoice.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleAssociableChoice.vue new file mode 100644 index 00000000000..faba8b92212 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleAssociableChoice.vue @@ -0,0 +1,37 @@ + + + + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/AssociateInteraction.spec.js b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/AssociateInteraction.spec.js new file mode 100644 index 00000000000..e69a5f31005 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/AssociateInteraction.spec.js @@ -0,0 +1,674 @@ +import { fireEvent, screen, waitFor, within } from '@testing-library/vue'; +import items from '../../__fixtures__/items'; +import { renderAssessmentItem } from '../../__tests__/helpers'; +import { answerGuideStrings } from '../../AnswerGuide.vue'; +import { associateStrings } from '../AssociateInteraction.vue'; + +const { + responsePoolLabel$, + firstSlotEmpty$, + secondSlotEmpty$, + firstSlotFilled$, + secondSlotFilled$, +} = associateStrings; + +// Container-scoped: the fixtures shuffle, so pool order is seeded and only +// membership is stable. Tests that care about order say so explicitly. +function poolEntries(container) { + return within(within(container).getByLabelText(responsePoolLabel$())) + .getAllByRole('listitem') + .map(entry => entry.textContent.trim()); +} + +function slots(container) { + return Array.from(container.querySelectorAll('.qti-associate-slot')); +} + +// Response content comes from the fixture XML rather than a translation, so it +// is matched on the rendered chip instead of through a *ByText query. +function poolChip(container, text) { + return Array.from( + container.querySelectorAll('.qti-associate-pool-entry .qti-associate-chip'), + ).find(chip => chip.textContent.trim() === text); +} + +// A placed response stays in the pool, disabled, so the pool never reflows. +function availablePoolChips(container) { + return Array.from(container.querySelectorAll('.qti-associate-pool-entry .qti-associate-chip')) + .filter(chip => !chip.classList.contains('qti-associate-chip-disabled')) + .map(chip => chip.textContent.trim()); +} + +describe('Smoke', () => { + it('renders the prompt', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + expect(container).toHaveTextContent(/Match each country to its capital city\./); + }); + + it('renders the associate answer guide', () => { + renderAssessmentItem(items['associate-interaction-1'].xml); + expect( + screen.getByText(answerGuideStrings.associate$(), { + selector: 'p.qti-selection-instructions', + }), + ).toBeVisible(); + }); + + it('puts every response in the pool', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + expect(poolEntries(container).sort()).toEqual([ + 'Berlin', + 'France', + 'Germany', + 'Japan', + 'Paris', + 'Tokyo', + ]); + }); + + it('renders images as response content', () => { + const { container } = renderAssessmentItem(items['associate-interaction-images'].xml); + expect(container.querySelectorAll('.qti-associate-chip img')).toHaveLength(3); + }); +}); + +describe('Pair rows', () => { + it('renders one row of two slots per association', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + // max-associations="3" + expect(container.querySelectorAll('.qti-associate-row')).toHaveLength(3); + expect(slots(container)).toHaveLength(6); + }); + + it('renders every slot empty before the learner answers', () => { + renderAssessmentItem(items['associate-interaction-1'].xml); + [1, 2, 3].forEach(number => { + expect(screen.getByLabelText(firstSlotEmpty$({ number }))).toBeVisible(); + expect(screen.getByLabelText(secondSlotEmpty$({ number }))).toBeVisible(); + }); + }); +}); + +describe('Restoring an answer', () => { + it('fills slots from injected answerState and disables those responses in the pool', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { + RESPONSE: [ + ['C1', 'C4'], + ['C2', 'C5'], + ], + }, + }); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ).toBeVisible(); + expect(poolEntries(container)).toHaveLength(6); + expect(availablePoolChips(container).sort()).toEqual(['Japan', 'Tokyo']); + }); + + it('re-renders when the answer state changes', async () => { + const { container, setAnswerState } = renderAssessmentItem( + items['associate-interaction-1'].xml, + ); + expect(availablePoolChips(container)).toHaveLength(6); + + setAnswerState({ RESPONSE: [['C3', 'C6']] }); + + await waitFor(() => { + expect(availablePoolChips(container).sort()).toEqual([ + 'Berlin', + 'France', + 'Germany', + 'Paris', + ]); + }); + }); +}); + +describe('Placed responses in the pool', () => { + it('keeps the response in place, disabled, rather than removing it', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const orderBefore = poolEntries(container); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + expect(poolEntries(container)).toEqual(orderBefore); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-disabled'); + expect(poolChip(container, 'France')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not let a disabled response be picked up from the pool', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { RESPONSE: [['C1', 'C4']] }, + }); + + await fireEvent.click(poolChip(container, 'France')); + + expect(container.querySelectorAll('.qti-associate-slot-target')).toHaveLength(0); + }); + + it('leaves a disabled response out of the draggable items', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { RESPONSE: [['C1', 'C4']] }, + }); + + expect(poolChip(container, 'France').closest('.draggable-item')).toBeNull(); + expect(poolChip(container, 'Japan').closest('.draggable-item')).not.toBeNull(); + }); +}); + +describe('Shuffle', () => { + it('presents the pool in a different order per candidate', () => { + const a = renderAssessmentItem(items['associate-interaction-1'].xml, { + candidateIdentifier: 'candidate-a', + }); + const b = renderAssessmentItem(items['associate-interaction-1'].xml, { + candidateIdentifier: 'candidate-b', + }); + + expect(poolEntries(a.container)).not.toEqual(poolEntries(b.container)); + }); + + it('keeps a fixed response in its authored position', () => { + // NILE is fixed="true" and authored first, so it leads the pool whatever + // the seed does to the rest. + const { container } = renderAssessmentItem(items['associate-interaction-fixed'].xml, { + candidateIdentifier: 'candidate-a', + }); + expect(poolEntries(container)[0]).toBe('Nile'); + }); +}); + +// SortableJS cannot be driven in jsdom, so a drag is exercised the way the +// abstraction reports it: useDraggableRegion's handleEnd inserts into the +// destination region first, then emits the source region's remaining items. +function findRegions() { + const mounted = Array.from(document.body.querySelectorAll('*')).find(el => el.__vue__); + const regions = []; + const walk = vm => { + if (!vm) { + return; + } + if (vm.$options.name === 'DraggableRegion') { + regions.push(vm); + } + (vm.$children || []).forEach(walk); + }; + walk(mounted && mounted.__vue__.$root); + return regions; +} + +function regionLabelled(label) { + return findRegions().find(region => region.label === label); +} + +async function dragInto(label, identifier) { + const regions = findRegions(); + const target = regions.find(region => region.label === label); + const source = regions.find(region => region.items.some(item => item.identifier === identifier)); + const sourceItemsBeforeDrag = source.items; + + target.$emit('update:items', [...target.items, { identifier }]); + source.$emit( + 'update:items', + sourceItemsBeforeDrag.filter(item => item.identifier !== identifier), + ); + await target.$nextTick(); +} + +describe('Placing by drag', () => { + it('fills an empty slot dragged onto from the pool', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await dragInto(firstSlotEmpty$({ number: 1 }), 'C1'); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ).toBeVisible(); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-disabled'); + }); + + it('frees the displaced response in the pool when dropped on a filled slot', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { RESPONSE: [['C1', 'C4']] }, + }); + + await dragInto(firstSlotFilled$({ number: 1, response: 'France' }), 'C3'); + + expect(screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'Japan' }))).toBeVisible(); + expect(poolChip(container, 'France')).not.toHaveClass('qti-associate-chip-disabled'); + expect(poolChip(container, 'Japan')).toHaveClass('qti-associate-chip-disabled'); + }); + + it('swaps two responses when one filled slot is dropped on the other', async () => { + renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { + RESPONSE: [ + ['C1', 'C4'], + ['C2', 'C5'], + ], + }, + }); + + await dragInto(firstSlotFilled$({ number: 2, response: 'Germany' }), 'C1'); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 2, response: 'France' })), + ).toBeVisible(); + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'Germany' })), + ).toBeVisible(); + }); + + it('empties the slot when a response is dragged back to the pool', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { RESPONSE: [['C1', 'C4']] }, + }); + + await dragInto(responsePoolLabel$(), 'C1'); + + expect(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))).toBeVisible(); + expect(poolChip(container, 'France')).toBeDefined(); + }); + + it('updates the response variable after a drag completes a pair', async () => { + const { checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await dragInto(firstSlotEmpty$({ number: 1 }), 'C2'); + await dragInto(secondSlotEmpty$({ number: 1 }), 'C5'); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([['C2', 'C5']]); + }); + }); + + it('disables the regions in review mode', () => { + renderAssessmentItem(items['associate-interaction-1'].xml, { interactive: false }); + + expect(findRegions().every(region => region.disabled)).toBe(true); + }); + + it('labels each region so the drop is announced', () => { + renderAssessmentItem(items['associate-interaction-1'].xml); + + expect(regionLabelled(responsePoolLabel$())).toBeDefined(); + expect(regionLabelled(firstSlotEmpty$({ number: 1 }))).toBeDefined(); + expect(regionLabelled(secondSlotEmpty$({ number: 3 }))).toBeDefined(); + }); +}); + +describe('Placing by click', () => { + it('places a selected response into the slot clicked next', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ).toBeVisible(); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-disabled'); + }); + + it('places into a slot chosen before the response', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(screen.getByLabelText(secondSlotEmpty$({ number: 2 }))); + await fireEvent.click(poolChip(container, 'Tokyo')); + + expect( + screen.getByLabelText(secondSlotFilled$({ number: 2, response: 'Tokyo' })), + ).toBeVisible(); + }); + + it('highlights the empty slots once a response is selected', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + expect(container.querySelectorAll('.qti-associate-slot-target')).toHaveLength(0); + + await fireEvent.click(poolChip(container, 'France')); + + expect(container.querySelectorAll('.qti-associate-slot-target')).toHaveLength(6); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-selected'); + }); + + it('does not highlight a filled slot as a valid target', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + await fireEvent.click(poolChip(container, 'Japan')); + + const highlighted = container.querySelectorAll('.qti-associate-slot-target'); + expect(highlighted).toHaveLength(5); + Array.from(highlighted).forEach(slot => { + expect(slot).not.toHaveClass('qti-associate-slot-filled'); + }); + }); + + it('highlights the pool once a slot is selected', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + expect(container.querySelectorAll('.qti-associate-chip-candidate')).toHaveLength(6); + }); + + it('deselects a response when it is clicked again', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(poolChip(container, 'France')); + + expect(container.querySelectorAll('.qti-associate-slot-target')).toHaveLength(0); + }); + + it('swaps two responses when one filled slot is clicked then the other', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { + RESPONSE: [ + ['C1', 'C4'], + ['C2', 'C5'], + ], + }, + }); + + await fireEvent.click( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ); + await fireEvent.click( + screen.getByLabelText(firstSlotFilled$({ number: 2, response: 'Germany' })), + ); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'Germany' })), + ).toBeVisible(); + expect( + screen.getByLabelText(firstSlotFilled$({ number: 2, response: 'France' })), + ).toBeVisible(); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-disabled'); + }); + + it('frees the displaced response in the pool when a filled slot is reused', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + await fireEvent.click(poolChip(container, 'Japan')); + await fireEvent.click( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ); + + expect(screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'Japan' }))).toBeVisible(); + expect(poolChip(container, 'France')).not.toHaveClass('qti-associate-chip-disabled'); + }); + + it('ignores clicks in review mode', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + interactive: false, + }); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + expect(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))).toBeVisible(); + }); +}); + +describe('Response variable', () => { + it('reports a completed pair on submit', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + await fireEvent.click(poolChip(container, 'Paris')); + await fireEvent.click(screen.getByLabelText(secondSlotEmpty$({ number: 1 }))); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([['C1', 'C4']]); + }); + }); + + it('omits a row that is only half filled', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([]); + }); + }); + + it('keeps a half-filled row when the completed pairs are written back', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + // Complete row 2, then half-fill row 1: writing the derived pairs must not + // compact the rows and lose the lone response. + await fireEvent.click(poolChip(container, 'Germany')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 2 }))); + await fireEvent.click(poolChip(container, 'Berlin')); + await fireEvent.click(screen.getByLabelText(secondSlotEmpty$({ number: 2 }))); + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + await waitFor(() => { + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ).toBeVisible(); + }); + expect( + screen.getByLabelText(firstSlotFilled$({ number: 2, response: 'Germany' })), + ).toBeVisible(); + }); + + it('scores the item through the mapping when the answer is correct', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + const pairs = [ + ['France', 'Paris'], + ['Germany', 'Berlin'], + ['Japan', 'Tokyo'], + ]; + for (const [rowIndex, [left, right]] of pairs.entries()) { + const number = rowIndex + 1; + await fireEvent.click(poolChip(container, left)); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number }))); + await fireEvent.click(poolChip(container, right)); + await fireEvent.click(screen.getByLabelText(secondSlotEmpty$({ number }))); + } + + await waitFor(() => { + expect(checkAnswer().outcomes.SCORE).toBe(3); + }); + }); + + it('does not write to the variable in review mode', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml, { + interactive: false, + }); + + await fireEvent.click(poolChip(container, 'France')); + + expect(checkAnswer().answerState.RESPONSE).toBeNull(); + }); +}); + +describe('Keyboard', () => { + function slotAt(container, index) { + return container.querySelectorAll('.qti-associate-slot')[index]; + } + + function optionsOf(slot) { + return Array.from(slot.querySelectorAll('[role="option"]')).map(option => ({ + text: option.textContent.trim(), + selected: option.getAttribute('aria-selected'), + id: option.id, + })); + } + + function activeOptionText(slot) { + const id = slot.getAttribute('aria-activedescendant'); + return slot.querySelector(`#${id}`).textContent.trim(); + } + + it('exposes each slot as a listbox that tab can reach', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + slots(container).forEach(slot => { + expect(slot).toHaveAttribute('role', 'listbox'); + expect(slot).toHaveAttribute('tabindex', '0'); + }); + }); + + it('keeps the response pool out of the tab order', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const pool = container.querySelector('.qti-associate-pool'); + + expect(pool.querySelectorAll('[tabindex="0"]')).toHaveLength(0); + expect(pool.querySelectorAll('button, a, input, select')).toHaveLength(0); + }); + + it('hides the options visually while exposing them to a screen reader', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const slot = slotAt(container, 0); + + expect(slot.querySelector('[role="option"]').closest('.qti-visually-hidden')).not.toBeNull(); + expect(optionsOf(slot)).toHaveLength(6); + }); + + it('fills the slot with the first valid response when focused', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const slot = slotAt(container, 0); + const firstCandidate = optionsOf(slot)[0].text; + + await fireEvent.focus(slot); + + expect(slotAt(container, 0)).toHaveAttribute( + 'aria-label', + firstSlotFilled$({ number: 1, response: firstCandidate }), + ); + }); + + it('does not fill the slot when a pointer press is what focused it', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const slot = slotAt(container, 0); + + // A press always precedes the focus it causes + await fireEvent.mouseDown(slot); + await fireEvent.focus(slot); + + expect(slotAt(container, 0)).toHaveAttribute('aria-label', firstSlotEmpty$({ number: 1 })); + }); + + it('cycles the value in place with the arrow keys', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const candidates = optionsOf(slotAt(container, 0)).map(option => option.text); + + await fireEvent.focus(slotAt(container, 0)); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[0]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowDown' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[1]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowUp' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[0]); + }); + + it('clamps at the ends and jumps with Home and End', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const candidates = optionsOf(slotAt(container, 0)).map(option => option.text); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowUp' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[0]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'End' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[candidates.length - 1]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowDown' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[candidates.length - 1]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'Home' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[0]); + }); + + it('tracks the current value with aria-selected', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowDown' }); + + const options = optionsOf(slotAt(container, 0)); + expect(options.filter(option => option.selected === 'true')).toHaveLength(1); + expect(options[1].selected).toBe('true'); + }); + + it('keeps the slot own response among its candidates so cycling is reversible', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const before = optionsOf(slotAt(container, 0)).map(option => option.text); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowDown' }); + + expect(optionsOf(slotAt(container, 0)).map(option => option.text)).toEqual(before); + }); + + it('empties the slot on Escape', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.keyDown(slotAt(container, 0), { key: 'Escape' }); + + expect(slotAt(container, 0)).toHaveAttribute('aria-label', firstSlotEmpty$({ number: 1 })); + }); + + it('records the pair built with the keyboard in the response variable', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.blur(slotAt(container, 0)); + await fireEvent.focus(slotAt(container, 1)); + await fireEvent.blur(slotAt(container, 1)); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toHaveLength(1); + }); + }); + + it('names an image response by its alt text', () => { + const { container } = renderAssessmentItem(items['associate-interaction-images'].xml); + + expect(optionsOf(slotAt(container, 0)).map(option => option.text)).toEqual( + expect.arrayContaining(['A blue circle', 'A pink square', 'A green triangle']), + ); + }); + + it('is not reachable or operable in review mode', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + interactive: false, + }); + const slot = slotAt(container, 0); + + expect(slot).not.toHaveAttribute('tabindex'); + expect(slot).not.toHaveAttribute('role', 'listbox'); + expect(slot.querySelectorAll('[role="option"]')).toHaveLength(0); + + await fireEvent.focus(slot); + expect(slotAt(container, 0)).toHaveAttribute('aria-label', firstSlotEmpty$({ number: 1 })); + }); +}); + +describe('Review mode', () => { + it('marks the interaction read-only', async () => { + const { container, setInteractive } = renderAssessmentItem( + items['associate-interaction-1'].xml, + ); + setInteractive(false); + + await waitFor(() => { + expect(container.querySelector('.qti-associate-readonly')).toBeInTheDocument(); + }); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/OrderInteraction.spec.js b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/OrderInteraction.spec.js index edaa288cb33..7043a04f97f 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/OrderInteraction.spec.js +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/OrderInteraction.spec.js @@ -1,5 +1,5 @@ import { fireEvent, screen, waitFor, within } from '@testing-library/vue'; -import { dragSortStrings } from 'kolibri-common/components/sortable/dragSortStrings'; +import { dragSortStrings } from 'kolibri-common/components/draggable/dragSortStrings'; import items from '../../__fixtures__/items'; import { renderAssessmentItem } from '../../__tests__/helpers'; import { answerGuideStrings } from '../../AnswerGuide.vue'; diff --git a/kolibri/plugins/qti_viewer/frontend/composables/__tests__/useAssociateSlots.spec.js b/kolibri/plugins/qti_viewer/frontend/composables/__tests__/useAssociateSlots.spec.js new file mode 100644 index 00000000000..1b0f9cb911a --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/composables/__tests__/useAssociateSlots.spec.js @@ -0,0 +1,292 @@ +import { ref } from 'vue'; +import useAssociateSlots from '../useAssociateSlots.js'; + +const CHOICES = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6']; + +// Slot sides within an association row +const FIRST = 0; +const SECOND = 1; + +function setup({ identifiers = CHOICES, rowCount = 3 } = {}) { + return useAssociateSlots(identifiers, rowCount); +} + +describe('slots', () => { + it('starts with the requested number of empty rows', () => { + const { slots } = setup({ rowCount: 3 }); + expect(slots.value).toEqual([ + [null, null], + [null, null], + [null, null], + ]); + }); + + it('keeps what is already placed when the row count grows', () => { + const rowCount = ref(1); + const { slots, place } = setup({ rowCount }); + place('C1', 0, FIRST); + + rowCount.value = 2; + + expect(slots.value).toEqual([ + ['C1', null], + [null, null], + ]); + }); + + it('drops the trailing rows when the row count shrinks', () => { + const rowCount = ref(2); + const { slots, pool, place } = setup({ rowCount }); + place('C1', 1, FIRST); + + rowCount.value = 1; + + expect(slots.value).toEqual([[null, null]]); + expect(pool.value).toContain('C1'); + }); +}); + +describe('pool', () => { + it('starts as every response, in presentation order', () => { + const { pool } = setup(); + expect(pool.value).toEqual(CHOICES); + }); + + it('drops a response once it is placed, preserving the order of the rest', () => { + const { pool, place } = setup(); + place('C3', 0, FIRST); + expect(pool.value).toEqual(['C1', 'C2', 'C4', 'C5', 'C6']); + }); + + it('takes the response back when its slot is cleared', () => { + const { pool, place, clear } = setup(); + place('C3', 0, FIRST); + clear(0, FIRST); + expect(pool.value).toEqual(CHOICES); + }); +}); + +describe('place', () => { + it('fills an empty slot from the pool', () => { + const { slots, place } = setup(); + place('C1', 1, SECOND); + expect(slots.value[1]).toEqual([null, 'C1']); + }); + + it('returns the displaced response to the pool when the incoming one came from the pool', () => { + const { slots, pool, place } = setup(); + place('C1', 0, FIRST); + place('C2', 0, FIRST); + + expect(slots.value[0]).toEqual(['C2', null]); + expect(pool.value).toContain('C1'); + }); + + it('swaps two responses when the incoming one came from another slot', () => { + const { slots, pool, place } = setup(); + place('C1', 0, FIRST); + place('C2', 1, FIRST); + + place('C1', 1, FIRST); + + expect(slots.value[0]).toEqual(['C2', null]); + expect(slots.value[1]).toEqual(['C1', null]); + expect(pool.value).not.toContain('C1'); + expect(pool.value).not.toContain('C2'); + }); + + it('empties the origin slot when moving a response to an empty slot', () => { + const { slots, place } = setup(); + place('C1', 0, FIRST); + place('C1', 2, SECOND); + + expect(slots.value[0]).toEqual([null, null]); + expect(slots.value[2]).toEqual([null, 'C1']); + }); + + it('leaves state untouched when the response is already in that slot', () => { + const { slots, place } = setup(); + place('C1', 0, FIRST); + const before = slots.value; + + place('C1', 0, FIRST); + + expect(slots.value).toEqual(before); + }); + + it('ignores a row index outside the available rows', () => { + const { slots, pool, place } = setup({ rowCount: 2 }); + place('C1', 5, FIRST); + + expect(slots.value).toEqual([ + [null, null], + [null, null], + ]); + expect(pool.value).toEqual(CHOICES); + }); +}); + +describe('remove', () => { + it('sends a placed response back to the pool', () => { + const { slots, pool, place, remove } = setup(); + place('C4', 1, SECOND); + + remove('C4'); + + expect(slots.value[1]).toEqual([null, null]); + expect(pool.value).toEqual(CHOICES); + }); + + it('ignores a response that is not placed', () => { + const { slots, pool, remove } = setup(); + remove('C4'); + + expect(slots.value).toEqual([ + [null, null], + [null, null], + [null, null], + ]); + expect(pool.value).toEqual(CHOICES); + }); +}); + +describe('candidatesFor', () => { + it('offers every response for an empty slot', () => { + const { candidatesFor } = setup(); + expect(candidatesFor(0, FIRST)).toEqual(CHOICES); + }); + + it('excludes responses placed in another slot', () => { + const { candidatesFor, place } = setup(); + place('C2', 1, FIRST); + place('C5', 2, SECOND); + + expect(candidatesFor(0, FIRST)).toEqual(['C1', 'C3', 'C4', 'C6']); + }); + + it("keeps the slot's own response as a candidate", () => { + const { candidatesFor, place } = setup(); + place('C2', 0, FIRST); + + expect(candidatesFor(0, FIRST)).toEqual(['C1', 'C2', 'C3', 'C4', 'C5', 'C6']); + }); +}); + +describe('pairs', () => { + it('is empty until a row is filled on both sides', () => { + const { pairs, place } = setup(); + place('C1', 0, FIRST); + expect(pairs.value).toEqual([]); + }); + + it('reports each fully filled row as a pair', () => { + const { pairs, place } = setup(); + place('C1', 0, FIRST); + place('C4', 0, SECOND); + place('C2', 2, FIRST); + place('C5', 2, SECOND); + + expect(pairs.value).toEqual([ + ['C1', 'C4'], + ['C2', 'C5'], + ]); + }); + + it('omits a row again once one of its sides is cleared', () => { + const { pairs, place, clear } = setup(); + place('C1', 0, FIRST); + place('C4', 0, SECOND); + clear(0, SECOND); + + expect(pairs.value).toEqual([]); + }); +}); + +describe('hydrate', () => { + it('restores slots from a response variable value', () => { + const { slots, pool, hydrate } = setup(); + hydrate([ + ['C1', 'C4'], + ['C2', 'C5'], + ]); + + expect(slots.value).toEqual([ + ['C1', 'C4'], + ['C2', 'C5'], + [null, null], + ]); + expect(pool.value).toEqual(['C3', 'C6']); + }); + + it('round-trips the value derived from pairs', () => { + const { pairs, place, hydrate } = setup(); + place('C3', 0, FIRST); + place('C6', 0, SECOND); + const derived = pairs.value; + + hydrate(derived); + + expect(pairs.value).toEqual(derived); + }); + + it('clears any existing placements', () => { + const { slots, place, hydrate } = setup(); + place('C1', 2, FIRST); + + hydrate([['C2', 'C5']]); + + expect(slots.value).toEqual([ + ['C2', 'C5'], + [null, null], + [null, null], + ]); + }); + + it('drops pairs naming a response the item does not declare', () => { + const { slots, hydrate } = setup(); + hydrate([ + ['C1', 'NOPE'], + ['C2', 'C5'], + ]); + + expect(slots.value[0]).toEqual(['C2', 'C5']); + }); + + it('drops a later pair that reuses an already placed response', () => { + const { slots, hydrate } = setup(); + hydrate([ + ['C1', 'C4'], + ['C1', 'C5'], + ]); + + expect(slots.value).toEqual([ + ['C1', 'C4'], + [null, null], + [null, null], + ]); + }); + + it('ignores malformed entries and non-array values', () => { + const { slots, hydrate } = setup(); + + hydrate([['C1'], 'C2 C5', null, ['C3', 'C6']]); + expect(slots.value[0]).toEqual(['C3', 'C6']); + + hydrate(null); + expect(slots.value).toEqual([ + [null, null], + [null, null], + [null, null], + ]); + }); + + it('keeps only as many pairs as there are rows', () => { + const { slots, hydrate } = setup({ rowCount: 1 }); + hydrate([ + ['C1', 'C4'], + ['C2', 'C5'], + ]); + + expect(slots.value).toEqual([['C1', 'C4']]); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/composables/useAssociateSlots.js b/kolibri/plugins/qti_viewer/frontend/composables/useAssociateSlots.js new file mode 100644 index 00000000000..07c7c1f5575 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/composables/useAssociateSlots.js @@ -0,0 +1,115 @@ +import { computed, ref, unref } from 'vue'; + +const SLOTS_PER_ROW = 2; + +function emptyRow() { + return new Array(SLOTS_PER_ROW).fill(null); +} + +// Find where an identifier is in the slots +function locate(rows, identifier) { + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + const side = rows[rowIndex].indexOf(identifier); + if (side !== -1) { + return [rowIndex, side]; + } + } + return null; +} + +export default function useAssociateSlots(identifiers, rowCount) { + const stored = ref([]); + + // Normalise on read rather than resizing on change + const slots = computed(() => + Array.from({ length: unref(rowCount) }, (_, index) => + stored.value[index] ? [...stored.value[index]] : emptyRow(), + ), + ); + + const placed = computed(() => new Set(slots.value.flat().filter(Boolean))); + + const pool = computed(() => unref(identifiers).filter(id => !placed.value.has(id))); + + const pairs = computed(() => slots.value.filter(row => row.every(Boolean))); + + function place(identifier, rowIndex, side) { + const next = slots.value; + const displaced = next[rowIndex]?.[side]; + // Out of range, or the response is already where it is being put + if (displaced === undefined || displaced === identifier) { + return; + } + const origin = locate(next, identifier); + next[rowIndex][side] = identifier; + if (origin) { + next[origin[0]][origin[1]] = displaced; + } + // A response arriving from the pool displaces its predecessor back to the + // pool implicitly, since the pool is whatever is not in a slot. + stored.value = next; + } + + function clear(rowIndex, side) { + const next = slots.value; + if (!next[rowIndex]) { + return; + } + next[rowIndex][side] = null; + stored.value = next; + } + + // Send a response back to the pool, wherever it currently sits + function remove(identifier) { + const next = slots.value; + const origin = locate(next, identifier); + if (!origin) { + return; + } + next[origin[0]][origin[1]] = null; + stored.value = next; + } + + function candidatesFor(rowIndex, side) { + const current = slots.value[rowIndex]?.[side] ?? null; + // The slot's own response stays a candidate so the candidate can step past + // it and back again without losing their answer. + return unref(identifiers).filter(id => !placed.value.has(id) || id === current); + } + + function hydrate(value) { + const known = new Set(unref(identifiers)); + const next = []; + const used = new Set(); + + for (const pair of Array.isArray(value) ? value : []) { + if (next.length >= unref(rowCount)) { + break; + } + // Skip any pair that is not an array of the right length, or contains + // identifiers that are not known or already used. + if (!Array.isArray(pair) || pair.length !== SLOTS_PER_ROW) { + continue; + } + if (pair.some(id => !known.has(id) || used.has(id))) { + continue; + } + pair.forEach(id => used.add(id)); + next.push([...pair]); + } + + stored.value = next; + } + + return { + slots, + pool, + placed, + pairs, + place, + clear, + remove, + candidatesFor, + hydrate, + }; +} diff --git a/packages/kolibri-common/components/sortable/DragSortWidget/index.vue b/packages/kolibri-common/components/draggable/DragSortWidget/index.vue similarity index 100% rename from packages/kolibri-common/components/sortable/DragSortWidget/index.vue rename to packages/kolibri-common/components/draggable/DragSortWidget/index.vue diff --git a/packages/kolibri-common/components/draggable/DraggableHandle.vue b/packages/kolibri-common/components/draggable/DraggableHandle.vue new file mode 100644 index 00000000000..318920ea45b --- /dev/null +++ b/packages/kolibri-common/components/draggable/DraggableHandle.vue @@ -0,0 +1,41 @@ + + + + + + + diff --git a/packages/kolibri-common/components/draggable/DraggableItem.vue b/packages/kolibri-common/components/draggable/DraggableItem.vue new file mode 100644 index 00000000000..585c33828b2 --- /dev/null +++ b/packages/kolibri-common/components/draggable/DraggableItem.vue @@ -0,0 +1,39 @@ + + + + diff --git a/packages/kolibri-common/components/draggable/DraggableRegion.vue b/packages/kolibri-common/components/draggable/DraggableRegion.vue new file mode 100644 index 00000000000..edecc1dd8f3 --- /dev/null +++ b/packages/kolibri-common/components/draggable/DraggableRegion.vue @@ -0,0 +1,75 @@ + + + + + + + + diff --git a/packages/kolibri-common/components/draggable/DraggableUniverse.vue b/packages/kolibri-common/components/draggable/DraggableUniverse.vue new file mode 100644 index 00000000000..de7e900fd20 --- /dev/null +++ b/packages/kolibri-common/components/draggable/DraggableUniverse.vue @@ -0,0 +1,40 @@ + + + + diff --git a/packages/kolibri-common/components/sortable/__tests__/DragSortWidget.spec.js b/packages/kolibri-common/components/draggable/__tests__/DragSortWidget.spec.js similarity index 100% rename from packages/kolibri-common/components/sortable/__tests__/DragSortWidget.spec.js rename to packages/kolibri-common/components/draggable/__tests__/DragSortWidget.spec.js diff --git a/packages/kolibri-common/components/draggable/__tests__/DraggableItem.spec.js b/packages/kolibri-common/components/draggable/__tests__/DraggableItem.spec.js new file mode 100644 index 00000000000..9ce0bbac6e0 --- /dev/null +++ b/packages/kolibri-common/components/draggable/__tests__/DraggableItem.spec.js @@ -0,0 +1,58 @@ +import { mount } from '@vue/test-utils'; +import DraggableItem from '../DraggableItem.vue'; +import DraggableHandle from '../DraggableHandle.vue'; +import { ITEM_CLASS, HANDLE_CLASS, DISABLED_CLASS } from '../classDefinitions'; + +describe('DraggableItem', () => { + it('renders the requested tag with the item marker class', () => { + const wrapper = mount(DraggableItem, { propsData: { tag: 'li' } }); + expect(wrapper.element.tagName).toBe('LI'); + expect(wrapper.classes()).toContain(ITEM_CLASS); + }); + + it('merges a consumer-supplied class onto the same root', () => { + const host = mount({ + components: { DraggableItem }, + template: ``, + }); + const item = host.findComponent(DraggableItem); + expect(item.classes()).toContain(ITEM_CLASS); + expect(item.classes()).toContain('my-row'); + }); + + it('adds the disabled class only when disabled', () => { + expect(mount(DraggableItem).classes()).not.toContain(DISABLED_CLASS); + expect(mount(DraggableItem, { propsData: { disabled: true } }).classes()).toContain( + DISABLED_CLASS, + ); + }); + + it('forwards attributes and listeners to the root element', async () => { + const onClick = jest.fn(); + const wrapper = mount(DraggableItem, { + attrs: { tabindex: '-1' }, + listeners: { click: onClick }, + }); + expect(wrapper.attributes('tabindex')).toBe('-1'); + await wrapper.trigger('click'); + expect(onClick).toHaveBeenCalled(); + }); + + it('renders its slot content', () => { + const wrapper = mount(DraggableItem, { slots: { default: 'hello' } }); + expect(wrapper.text()).toBe('hello'); + }); +}); + +describe('DraggableHandle', () => { + it('renders the requested tag with the handle marker class', () => { + const wrapper = mount(DraggableHandle, { propsData: { tag: 'span' } }); + expect(wrapper.element.tagName).toBe('SPAN'); + expect(wrapper.classes()).toContain(HANDLE_CLASS); + }); + + it('renders its slot content', () => { + const wrapper = mount(DraggableHandle, { slots: { default: 'grip' } }); + expect(wrapper.text()).toBe('grip'); + }); +}); diff --git a/packages/kolibri-common/components/draggable/__tests__/DraggableRegion.spec.js b/packages/kolibri-common/components/draggable/__tests__/DraggableRegion.spec.js new file mode 100644 index 00000000000..f802d60b203 --- /dev/null +++ b/packages/kolibri-common/components/draggable/__tests__/DraggableRegion.spec.js @@ -0,0 +1,290 @@ +import { mount } from '@vue/test-utils'; +import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion'; +import DraggableUniverse from '../DraggableUniverse.vue'; +import DraggableRegion from '../DraggableRegion.vue'; +import { ITEM_CLASS } from '../classDefinitions'; + +jest.mock('kolibri-design-system/lib/composables/useKLiveRegion'); + +// Real SortableJS drives pointer events jsdom can't produce; we only need the +// options object it's constructed with, so we can drive the region's own +// lifecycle callbacks (onStart / onEnd / group.put) against real jsdom nodes. +let mockInstances; +jest.mock('sortablejs', () => + jest.fn().mockImplementation((el, options) => { + mockInstances.push({ el, options }); + return { destroy: jest.fn() }; + }), +); + +// A row element carrying the draggable marker class, so insertNodeAt has real +// children to index into. +function row(text) { + const el = document.createElement('div'); + el.className = ITEM_CLASS; + el.textContent = text; + return el; +} + +describe('DraggableRegion', () => { + let sendPoliteMessage; + + beforeEach(() => { + mockInstances = []; + sendPoliteMessage = jest.fn(); + useKLiveRegion.mockReturnValue({ sendPoliteMessage }); + document.hasFocus = jest.fn(() => true); + }); + + // Mounts a lone region and returns its captured Sortable options. + async function mountRegion(propsData = {}) { + const wrapper = mount(DraggableRegion, { + propsData: { items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], ...propsData }, + }); + await wrapper.vm.$nextTick(); + return { wrapper, options: mockInstances[mockInstances.length - 1].options }; + } + + describe('capacity (group.put)', () => { + it('accepts a drop while below capacity and rejects it once full', async () => { + const { options } = await mountRegion({ items: [{ id: 'a' }], capacity: 2 }); + expect(options.group.put()).toBe(true); + const { options: full } = await mountRegion({ + items: [{ id: 'a' }, { id: 'b' }], + capacity: 2, + }); + expect(full.group.put()).toBe(false); + }); + + it('never rejects when capacity is null (unlimited)', async () => { + const { options } = await mountRegion({ items: [{ id: 'a' }, { id: 'b' }], capacity: null }); + expect(options.group.put()).toBe(true); + }); + + it('rejects every drop when disabled', async () => { + const { options } = await mountRegion({ items: [], capacity: 5, disabled: true }); + expect(options.group.put()).toBe(false); + }); + + it('rejects when the accepts predicate returns false, even below capacity', async () => { + const { options } = await mountRegion({ + items: [{ id: 'a' }], + capacity: 5, + accepts: () => false, + }); + expect(options.group.put()).toBe(false); + }); + + it('sets pull to clone when the clone prop is set', async () => { + const { options } = await mountRegion({ clone: true }); + expect(options.group.pull).toBe('clone'); + }); + }); + + describe('reorder within a region', () => { + it('emits the reordered array on a same-region move', async () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + const { wrapper, options } = await mountRegion({ items }); + const from = wrapper.element; + [row('a'), row('b'), row('c')].forEach(r => from.appendChild(r)); + const item = from.children[0]; + + options.onEnd({ + item, + from, + to: from, + oldIndex: 0, + oldDraggableIndex: 0, + newDraggableIndex: 2, + }); + + const emitted = wrapper.emitted('update:items'); + expect(emitted).toHaveLength(1); + expect(emitted[0][0].map(i => i.id)).toEqual(['b', 'c', 'a']); + }); + + it('emits nothing for a no-op drag (same index)', async () => { + const { wrapper, options } = await mountRegion(); + const from = wrapper.element; + from.appendChild(row('a')); + options.onEnd({ + item: from.children[0], + from, + to: from, + oldIndex: 0, + oldDraggableIndex: 1, + newDraggableIndex: 1, + }); + expect(wrapper.emitted('update:items')).toBeUndefined(); + }); + + it('reverts the DOM so the moved node is back under its source at its old index', async () => { + const { wrapper, options } = await mountRegion(); + const from = wrapper.element; + [row('a'), row('b')].forEach(r => from.appendChild(r)); + const item = from.children[0]; + options.onEnd({ + item, + from, + to: from, + oldIndex: 0, + oldDraggableIndex: 0, + newDraggableIndex: 1, + }); + expect(item.parentElement).toBe(from); + expect(from.children[0]).toBe(item); + }); + }); + + describe('cross-region move and clone', () => { + // Two regions inside one universe so they share a SortableJS group and registry. + async function mountUniverse(targetProps = {}) { + const wrapper = mount({ + components: { DraggableUniverse, DraggableRegion }, + data() { + return { + source: [{ id: 'a' }, { id: 'b' }], + target: [{ id: 'x' }], + targetProps, + }; + }, + template: ` + + + + + `, + }); + await wrapper.vm.$nextTick(); + const regions = wrapper.findAllComponents({ name: 'DraggableRegion' }); + return { + wrapper, + sourceRegion: regions.at(0), + targetRegion: regions.at(1), + sourceOptions: mockInstances[0].options, + sourceEl: regions.at(0).element, + targetEl: regions.at(1).element, + }; + } + + it('moves an item: source loses it, target gains it at the drop index', async () => { + const { sourceRegion, targetRegion, sourceOptions, sourceEl, targetEl } = + await mountUniverse(); + sourceEl.appendChild(row('a')); + sourceEl.appendChild(row('b')); + const item = sourceEl.children[0]; + + sourceOptions.onStart({ oldDraggableIndex: 0 }); + sourceOptions.onEnd({ + item, + from: sourceEl, + to: targetEl, + oldIndex: 0, + oldDraggableIndex: 0, + newDraggableIndex: 1, + pullMode: true, + }); + + expect(sourceRegion.emitted('update:items')[0][0].map(i => i.id)).toEqual(['b']); + expect(targetRegion.emitted('update:items')[0][0].map(i => i.id)).toEqual(['x', 'a']); + }); + + it('clones an item: source is unchanged, target gains a copy, clone node removed', async () => { + const { sourceRegion, targetRegion, sourceOptions, sourceEl, targetEl } = + await mountUniverse(); + sourceEl.appendChild(row('a')); + sourceEl.appendChild(row('b')); + const item = sourceEl.children[0]; + const clone = row('a-clone'); + sourceEl.appendChild(clone); + + sourceOptions.onStart({ oldDraggableIndex: 0 }); + sourceOptions.onEnd({ + item, + clone, + from: sourceEl, + to: targetEl, + oldIndex: 0, + oldDraggableIndex: 0, + newDraggableIndex: 0, + pullMode: 'clone', + }); + + expect(sourceRegion.emitted('update:items')).toBeUndefined(); + expect(targetRegion.emitted('update:items')[0][0].map(i => i.id)).toEqual(['a', 'x']); + expect(clone.parentNode).toBeNull(); + }); + + it('announces the drop when the target region has a label', async () => { + const { sourceOptions, sourceEl, targetEl } = await mountUniverse({ label: 'Gap 1' }); + sourceEl.appendChild(row('a')); + sourceEl.appendChild(row('b')); + sourceOptions.onStart({ oldDraggableIndex: 0 }); + sourceOptions.onEnd({ + item: sourceEl.children[0], + from: sourceEl, + to: targetEl, + oldIndex: 0, + oldDraggableIndex: 0, + newDraggableIndex: 0, + pullMode: true, + }); + expect(sendPoliteMessage).toHaveBeenCalledWith('Moved to Gap 1'); + }); + + it('leaves data untouched when dropped outside the universe', async () => { + const { sourceRegion, sourceOptions, sourceEl } = await mountUniverse(); + sourceEl.appendChild(row('a')); + sourceEl.appendChild(row('b')); + const stray = document.createElement('div'); + sourceOptions.onStart({ oldDraggableIndex: 0 }); + sourceOptions.onEnd({ + item: sourceEl.children[0], + from: sourceEl, + to: stray, + oldIndex: 0, + oldDraggableIndex: 0, + newDraggableIndex: 0, + pullMode: true, + }); + expect(sourceRegion.emitted('update:items')).toBeUndefined(); + }); + }); + + describe('full-order announcement on focus-exit', () => { + it('announces the current order when focus leaves the region', async () => { + const { wrapper } = await mountRegion(); + // Simulate DragSortWidget registrations via the provided callbacks. + const provided = wrapper.vm._provided; + provided.registerSortItem(0, 'First', 1); + provided.registerSortItem(1, 'Second', 2); + provided.registerSortItem(2, 'Third', 3); + + const outside = document.createElement('button'); + document.body.appendChild(outside); + await wrapper.trigger('focusout', { relatedTarget: outside }); + + expect(sendPoliteMessage).toHaveBeenCalledWith( + 'Current order: 1. First, 2. Second, 3. Third', + ); + document.body.removeChild(outside); + }); + + it('does not announce when no items are registered', async () => { + const { wrapper } = await mountRegion(); + const outside = document.createElement('button'); + document.body.appendChild(outside); + await wrapper.trigger('focusout', { relatedTarget: outside }); + expect(sendPoliteMessage).not.toHaveBeenCalled(); + document.body.removeChild(outside); + }); + + it('does not announce on window blur (document not focused)', async () => { + document.hasFocus = jest.fn(() => false); + const { wrapper } = await mountRegion(); + wrapper.vm._provided.registerSortItem(0, 'First', 1); + await wrapper.trigger('focusout', { relatedTarget: null }); + expect(sendPoliteMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/kolibri-common/components/draggable/__tests__/DraggableUniverse.spec.js b/packages/kolibri-common/components/draggable/__tests__/DraggableUniverse.spec.js new file mode 100644 index 00000000000..23ad448c879 --- /dev/null +++ b/packages/kolibri-common/components/draggable/__tests__/DraggableUniverse.spec.js @@ -0,0 +1,64 @@ +import { mount } from '@vue/test-utils'; +import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion'; +import DraggableUniverse from '../DraggableUniverse.vue'; +import { createDraggableUniverse, injectDraggableUniverse } from '../useDraggableUniverse'; + +jest.mock('kolibri-design-system/lib/composables/useKLiveRegion'); + +describe('useDraggableUniverse', () => { + beforeEach(() => { + useKLiveRegion.mockReturnValue({ sendPoliteMessage: jest.fn() }); + }); + + it('gives separate universes distinct group names', () => { + const a = createDraggableUniverse(); + const b = createDraggableUniverse(); + expect(a.groupName).not.toEqual(b.groupName); + }); + + it('uses an explicit name when provided', () => { + expect(createDraggableUniverse({ name: 'gaps' }).groupName).toBe('gaps'); + }); + + it('honours a custom delay in the shared SortableJS defaults', () => { + expect(createDraggableUniverse({ delay: 0 }).sortableDefaults.delay).toBe(0); + expect(createDraggableUniverse().sortableDefaults.delay).toBe(250); + }); + + it('resolves a registered region element back to its API', () => { + const universe = createDraggableUniverse(); + const el = document.createElement('div'); + const api = { insertAt: jest.fn() }; + universe.registerRegion(el, api); + expect(universe.getRegion(el)).toBe(api); + universe.unregisterRegion(el); + expect(universe.getRegion(el)).toBeUndefined(); + }); + + it('provides the context to descendants that inject it', () => { + let injected = null; + const Child = { + render: () => null, + setup() { + injected = injectDraggableUniverse(); + }, + }; + mount(DraggableUniverse, { + propsData: { name: 'shared' }, + slots: { default: Child }, + }); + expect(injected).not.toBeNull(); + expect(injected.groupName).toBe('shared'); + }); + + it('injects null when there is no universe ancestor', () => { + let injected = 'unset'; + mount({ + render: () => null, + setup() { + injected = injectDraggableUniverse(); + }, + }); + expect(injected).toBeNull(); + }); +}); diff --git a/packages/kolibri-common/components/draggable/classDefinitions.js b/packages/kolibri-common/components/draggable/classDefinitions.js new file mode 100644 index 00000000000..3aced799951 --- /dev/null +++ b/packages/kolibri-common/components/draggable/classDefinitions.js @@ -0,0 +1,16 @@ +// CSS classes shared between the draggable composables +// SortableJS draws the drag affordances itself and only needs the class names + +export const ITEM_CLASS = 'draggable-item'; +export const HANDLE_CLASS = 'draggable-handle'; +export const DISABLED_CLASS = 'draggable-item--disabled'; +// The clone that follows the pointer (SortableJS fallbackClass). +export const MIRROR_CLASS = 'draggable-item--mirror'; +// The placeholder left in the list showing where the item will land (ghostClass). +export const GHOST_CLASS = 'draggable-item--ghost'; +// The item being dragged, still in its source list (chosenClass). +export const CHOSEN_CLASS = 'draggable-item--chosen'; +// The copy under the cursor in native-drag mode (dragClass). +export const DRAG_CLASS = 'draggable-item--drag'; +// Hand-rolled drop "bounce"; SortableJS has no equivalent. +export const PLACED_CLASS = 'draggable-item--placed'; diff --git a/packages/kolibri-common/components/draggable/domUtils.js b/packages/kolibri-common/components/draggable/domUtils.js new file mode 100644 index 00000000000..1e1a65194df --- /dev/null +++ b/packages/kolibri-common/components/draggable/domUtils.js @@ -0,0 +1,23 @@ +// SortableJS reorders the DOM directly. To keep Vue's virtual DOM the single +// source of truth we revert that mutation and then drive the change through data, + +/** + * Remove a node from its parent, if it has one. + * @param {HTMLElement} node - the node to detach from the DOM + */ +export function removeNode(node) { + if (node.parentElement !== null) { + node.parentElement.removeChild(node); + } +} + +/** + * Insert a node into a parent at a given child position. + * @param {HTMLElement} parent - the element to insert into + * @param {HTMLElement} node - the node to insert + * @param {number} position - the child index the node should occupy + */ +export function insertNodeAt(parent, node, position) { + const refNode = position === 0 ? parent.children[0] : parent.children[position - 1].nextSibling; + parent.insertBefore(node, refNode); +} diff --git a/packages/kolibri-common/components/sortable/dragSortStrings.js b/packages/kolibri-common/components/draggable/dragSortStrings.js similarity index 86% rename from packages/kolibri-common/components/sortable/dragSortStrings.js rename to packages/kolibri-common/components/draggable/dragSortStrings.js index 9920b2a7f96..d56623f74f3 100644 --- a/packages/kolibri-common/components/sortable/dragSortStrings.js +++ b/packages/kolibri-common/components/draggable/dragSortStrings.js @@ -26,4 +26,9 @@ export const dragSortStrings = createTranslator('DragSortStrings', { context: 'Live region announcement of the full list order after focus leaves the reorderable list', }, + itemMovedToRegion: { + message: 'Moved to {region}', + context: + 'Live region announcement after an item is dragged into a named drop zone (e.g. a gap)', + }, }); diff --git a/packages/kolibri-common/components/draggable/draggable.scss b/packages/kolibri-common/components/draggable/draggable.scss new file mode 100644 index 00000000000..2fd475d9752 --- /dev/null +++ b/packages/kolibri-common/components/draggable/draggable.scss @@ -0,0 +1,35 @@ +@import '~kolibri-design-system/lib/styles/definitions'; + +.draggable-item--mirror { + @extend %dropshadow-6dp; + + z-index: 8; + cursor: grabbing; + border-radius: $radius; +} + +.draggable-item--ghost { + visibility: hidden; +} + +.draggable-item--placed { + animation-name: bounce-in; + animation-duration: $core-time; +} + +@keyframes bounce-in { + 0% { + transform: scale3d(1.05, 1.05, 1.05); + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); + } + + 50% { + transform: scale3d(0.98, 0.98, 0.98); + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); + } + + 100% { + transform: scale3d(1, 1, 1); + animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); + } +} diff --git a/packages/kolibri-common/components/draggable/useDraggableRegion.js b/packages/kolibri-common/components/draggable/useDraggableRegion.js new file mode 100644 index 00000000000..463309c7086 --- /dev/null +++ b/packages/kolibri-common/components/draggable/useDraggableRegion.js @@ -0,0 +1,179 @@ +import Sortable from 'sortablejs'; +import { onMounted, onBeforeUnmount, provide } from 'vue'; +import { injectDraggableUniverse, createDraggableUniverse } from './useDraggableUniverse'; +import { DISABLED_CLASS, PLACED_CLASS } from './classDefinitions'; +import { removeNode, insertNodeAt } from './domUtils'; +import { dragSortStrings } from './dragSortStrings'; + +/** + * Wire up the SortableJS instance and reconciliation for one region. Call from the + * `setup()` of DraggableRegion. + * @param {object} props - the DraggableRegion props (reactive) + * @param {(event: string, ...args: unknown[]) => void} emit - the component's emit + * @param {import('vue').Ref} rootElRef - ref to the region's root element + * @returns {{ handleStart: Function, handleEnd: Function, canAccept: Function }} the + * drag lifecycle callbacks, exposed for unit tests + */ +export default function useDraggableRegion(props, emit, rootElRef) { + // Regions grouped for cross-region drops share a + const universe = injectDraggableUniverse() || createDraggableUniverse(); + + const { currentOrder$, itemMovedToRegion$ } = dragSortStrings; + + let sortable = null; + + // used only for the full-order announcement when focus leaves the region. + const registeredItems = {}; + + // This region's API, registered with the universe so a *source* region can hand + // this region an item on a cross-region drop. + const regionApi = { + get items() { + return props.items; + }, + get label() { + return props.label; + }, + insertAt(item, index) { + const next = [...props.items]; + next.splice(index, 0, item); + emit('update:items', next); + }, + }; + + function reordered(list, fromIndex, toIndex) { + const next = [...list]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + return next; + } + + function addBounce(node) { + node.classList.add(PLACED_CLASS); + node.addEventListener('animationend', () => node.classList.remove(PLACED_CLASS), { + once: true, + }); + } + + function handleStart(evt) { + universe.isDragging.value = true; + universe.activeRegion.value = regionApi; + universe.draggedItem.value = props.items[evt.oldDraggableIndex]; + emit('dragstart'); + } + + function handleEnd(evt) { + universe.isDragging.value = false; + universe.activeRegion.value = null; + universe.draggedItem.value = null; + emit('dragend'); + + const { item, clone, from, to, oldIndex, oldDraggableIndex, newDraggableIndex, pullMode } = evt; + + // 1. Undo SortableJS's DOM mutation + removeNode(item); + if (clone && clone.parentNode) { + removeNode(clone); + } + insertNodeAt(from, item, oldIndex); + + // 2. Apply the change to our sorable data. + if (to === from) { + if (oldDraggableIndex === newDraggableIndex) { + return; + } + emit('update:items', reordered(props.items, oldDraggableIndex, newDraggableIndex)); + addBounce(item); + return; + } + + const target = universe.getRegion(to); + if (!target) { + // Dropped outside this universe + return; + } + const movedItem = props.items[oldDraggableIndex]; + target.insertAt(movedItem, newDraggableIndex); + if (pullMode !== 'clone') { + emit( + 'update:items', + props.items.filter((_, i) => i !== oldDraggableIndex), + ); + } + if (target.label) { + universe.sendPoliteMessage(itemMovedToRegion$({ region: target.label })); + } + } + + function canAccept() { + if (props.disabled) { + return false; + } + if (props.capacity != null && props.items.length >= props.capacity) { + return false; + } + return props.accepts(universe.draggedItem.value, universe.activeRegion.value); + } + + function handleFocusOut(event) { + // window/tab blur: relatedTarget is null but focus hasn't actually left + if (!document.hasFocus()) { + return; + } + // focus moved to another row inside this region: not a list-exit, don't announce + if (event.relatedTarget && rootElRef.value.contains(event.relatedTarget)) { + return; + } + const entries = Object.values(registeredItems); + if (!entries.length) { + return; + } + const order = entries + .sort((a, b) => a.position - b.position) + .map((entry, index) => `${index + 1}. ${entry.label}`) + .join(', '); + universe.sendPoliteMessage(currentOrder$({ order })); + } + + // Provided for the a11y move buttons + provide('registerSortItem', (uid, label, position) => { + registeredItems[uid] = { label, position }; + }); + provide('unregisterSortItem', uid => { + delete registeredItems[uid]; + }); + + onMounted(() => { + const el = rootElRef.value; + universe.registerRegion(el, regionApi); + el.addEventListener('focusout', handleFocusOut); + + sortable = new Sortable(el, { + ...universe.sortableDefaults, + sort: props.sortable, + filter: `.${DISABLED_CLASS}`, + group: { + name: universe.groupName, + pull: props.clone ? 'clone' : true, + put: canAccept, + }, + onStart: handleStart, + onEnd: handleEnd, + }); + }); + + onBeforeUnmount(() => { + const el = rootElRef.value; + if (sortable) { + sortable.destroy(); + sortable = null; + } + if (el) { + el.removeEventListener('focusout', handleFocusOut); + universe.unregisterRegion(el); + } + }); + + // Exposed for unit tests + return { handleStart, handleEnd, canAccept }; +} diff --git a/packages/kolibri-common/components/draggable/useDraggableUniverse.js b/packages/kolibri-common/components/draggable/useDraggableUniverse.js new file mode 100644 index 00000000000..5f47c7436ab --- /dev/null +++ b/packages/kolibri-common/components/draggable/useDraggableUniverse.js @@ -0,0 +1,90 @@ +import { ref, provide, inject } from 'vue'; +import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion'; +import { + ITEM_CLASS, + HANDLE_CLASS, + MIRROR_CLASS, + GHOST_CLASS, + CHOSEN_CLASS, + DRAG_CLASS, +} from './classDefinitions'; + +const DraggableUniverseSymbol = Symbol('draggableUniverse'); + +// Backs the generated group name so distinct universes never share a group +let universeCounter = 0; + +/** + * Build a universe context. Kept separate from `provide` so a region with no + * `` ancestor can create its own standalone context. + * @param {object} [options] - universe configuration + * @param {string} [options.name] - explicit group name; defaults to a unique id + * @param {number} [options.delay] - press-and-hold delay (ms) before a drag begins + * @returns {object} the universe context + */ +export function createDraggableUniverse({ name, delay } = {}) { + universeCounter += 1; + const groupName = name || `draggable-universe-${universeCounter}`; + + // each region's root element -> its API + const regions = new Map(); + + // drag state + const isDragging = ref(false); + const activeRegion = ref(null); + const draggedItem = ref(null); + + const { sendPoliteMessage } = useKLiveRegion(); + + const sortableDefaults = { + delay: delay == null ? 250 : delay, + forceFallback: true, + fallbackOnBody: false, // keep the clone inside the region subtree so overrides still match + draggable: `.${ITEM_CLASS}`, + handle: `.${HANDLE_CLASS}`, + fallbackClass: MIRROR_CLASS, + ghostClass: GHOST_CLASS, + chosenClass: CHOSEN_CLASS, + dragClass: DRAG_CLASS, + animation: 150, + }; + + return { + groupName, + sortableDefaults, + isDragging, + activeRegion, + draggedItem, + sendPoliteMessage, + registerRegion(el, api) { + regions.set(el, api); + }, + unregisterRegion(el) { + regions.delete(el); + }, + getRegion(el) { + return regions.get(el); + }, + }; +} + +/** + * Create a universe context and provide it to descendant regions. Call from the + * `setup()` of a component that wraps several regions meant to share items. + * @param {object} [options] - name/delay options, see {@link createDraggableUniverse} + * @returns {object} the universe context + */ +export default function useDraggableUniverse(options = {}) { + const context = createDraggableUniverse(options); + provide(DraggableUniverseSymbol, context); + return context; +} + +/** + * Inject the nearest universe context, or `null` when a region has no + * `` ancestor. + * @returns {?object} the universe context + */ +export function injectDraggableUniverse() { + return inject(DraggableUniverseSymbol, null); +} diff --git a/packages/kolibri-common/components/sortable/DragContainer.vue b/packages/kolibri-common/components/sortable/DragContainer.vue deleted file mode 100644 index 7c459506514..00000000000 --- a/packages/kolibri-common/components/sortable/DragContainer.vue +++ /dev/null @@ -1,175 +0,0 @@ - - - - diff --git a/packages/kolibri-common/components/sortable/DragHandle.vue b/packages/kolibri-common/components/sortable/DragHandle.vue deleted file mode 100644 index c23dfc004a5..00000000000 --- a/packages/kolibri-common/components/sortable/DragHandle.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - - diff --git a/packages/kolibri-common/components/sortable/Draggable.vue b/packages/kolibri-common/components/sortable/Draggable.vue deleted file mode 100644 index e1307048e71..00000000000 --- a/packages/kolibri-common/components/sortable/Draggable.vue +++ /dev/null @@ -1,27 +0,0 @@ - diff --git a/packages/kolibri-common/components/sortable/__tests__/DragContainer.spec.js b/packages/kolibri-common/components/sortable/__tests__/DragContainer.spec.js deleted file mode 100644 index 17e39821d80..00000000000 --- a/packages/kolibri-common/components/sortable/__tests__/DragContainer.spec.js +++ /dev/null @@ -1,130 +0,0 @@ -import { mount } from '@vue/test-utils'; -import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion'; -import DragContainer from '../DragContainer.vue'; - -jest.mock('kolibri-design-system/lib/composables/useKLiveRegion'); - -// Real SortableJS manipulates pointer events in ways jsdom doesn't support; -// only its constructor shape matters for these tests. -jest.mock('sortablejs', () => - jest.fn().mockImplementation(() => ({ - destroy: jest.fn(), - })), -); - -describe('DragContainer', () => { - let sendPoliteMessage; - const items = [ - { id: 1, title: 'First' }, - { id: 2, title: 'Second' }, - { id: 3, title: 'Third' }, - ]; - - beforeEach(() => { - sendPoliteMessage = jest.fn(); - useKLiveRegion.mockReturnValue({ sendPoliteMessage }); - }); - - async function makeWrapper(propsData = {}) { - const wrapper = mount(DragContainer, { - propsData: { items, ...propsData }, - slots: { - default: ` -
-
-
-
-
- `, - }, - }); - // initialize() runs on $nextTick after mount, and is what registers - // the focusout listener we're testing against - await wrapper.vm.$nextTick(); - return wrapper; - } - - function registerItems(wrapper, labeledItems) { - labeledItems.forEach((item, index) => { - wrapper.vm.registerSortItem(index, item.title, index + 1); - }); - } - - describe('full-order announcement on focus-exit', () => { - it('announces the full current order when focus moves outside the container', async () => { - document.hasFocus = jest.fn(() => true); - const wrapper = await makeWrapper(); - registerItems(wrapper, items); - const outsideEl = document.createElement('button'); - document.body.appendChild(outsideEl); - - await wrapper.trigger('focusout', { relatedTarget: outsideEl }); - - expect(sendPoliteMessage).toHaveBeenCalledWith( - 'Current order: 1. First, 2. Second, 3. Third', - ); - document.body.removeChild(outsideEl); - }); - - it('announces the full order even when relatedTarget is null, provided the window is still focused', async () => { - document.hasFocus = jest.fn(() => true); - const wrapper = await makeWrapper(); - registerItems(wrapper, items); - - await wrapper.trigger('focusout', { relatedTarget: null }); - - expect(sendPoliteMessage).toHaveBeenCalledWith(expect.stringContaining('Current order:')); - }); - - it('does not announce anything when no items are registered', async () => { - document.hasFocus = jest.fn(() => true); - const wrapper = await makeWrapper(); - const outsideEl = document.createElement('button'); - document.body.appendChild(outsideEl); - - await wrapper.trigger('focusout', { relatedTarget: outsideEl }); - - expect(sendPoliteMessage).not.toHaveBeenCalled(); - document.body.removeChild(outsideEl); - }); - - it('does not announce anything for items that have been unregistered', async () => { - document.hasFocus = jest.fn(() => true); - const wrapper = await makeWrapper(); - registerItems(wrapper, items); - items.forEach((item, index) => wrapper.vm.unregisterSortItem(index)); - const outsideEl = document.createElement('button'); - document.body.appendChild(outsideEl); - - await wrapper.trigger('focusout', { relatedTarget: outsideEl }); - - expect(sendPoliteMessage).not.toHaveBeenCalled(); - document.body.removeChild(outsideEl); - }); - }); - - describe('no announcement on row-to-row focus movement', () => { - it('does not announce when focus moves to another row inside the container', async () => { - document.hasFocus = jest.fn(() => true); - const wrapper = await makeWrapper(); - registerItems(wrapper, items); - const rowB = wrapper.find('[data-test="row-1"]').element; - - await wrapper.trigger('focusout', { relatedTarget: rowB }); - - expect(sendPoliteMessage).not.toHaveBeenCalled(); - }); - }); - - describe('window/tab blur', () => { - it('does not announce on window blur, even with a null relatedTarget', async () => { - document.hasFocus = jest.fn(() => false); - const wrapper = await makeWrapper(); - registerItems(wrapper, items); - - await wrapper.trigger('focusout', { relatedTarget: null }); - - expect(sendPoliteMessage).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/kolibri-common/components/sortable/classDefinitions.js b/packages/kolibri-common/components/sortable/classDefinitions.js deleted file mode 100644 index afd8721f839..00000000000 --- a/packages/kolibri-common/components/sortable/classDefinitions.js +++ /dev/null @@ -1,5 +0,0 @@ -export const SORTABLE_CLASS = 'sortable-item'; -export const HANDLE_CLASS = 'sortable-handle'; -export const MIRROR_CLASS = 'sortable-item--mirror'; -export const GHOST_CLASS = 'sortable-item--ghost'; -export const PLACED_CLASS = 'sortable-item--placed';