diff --git a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js index 0a1fb9161b..8df5ae95c6 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/commonStrings.js @@ -82,4 +82,20 @@ export const commonStrings = createTranslator('CommonStrings', { message: 'Removed {label}', context: 'Announced when an option is removed. {label} is the name of the option', }, + moveUpLabel: { + message: 'Move up', + context: 'Label for the button that moves a resource up in the list. Not visible in the UI.', + }, + moveDownLabel: { + message: 'Move down', + context: 'Label for the button that moves a resource down in the list. Not visible in the UI.', + }, + moveLeftLabel: { + message: 'Move left', + context: 'Label for the button that moves a resource left in the list. Not visible in the UI.', + }, + moveRightLabel: { + message: 'Move right', + context: 'Label for the button that moves a resource right in the list. Not visible in the UI.', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js index 05fa5419f4..17cc9e46bd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js @@ -112,6 +112,47 @@ describe('useChoiceInteraction', () => { }); }); + describe('setChoiceOrder()', () => { + it('applies the given order', () => { + const { state, setChoiceOrder } = setup([ + makeAnswer({ id: 'a' }), + makeAnswer({ id: 'b' }), + makeAnswer({ id: 'c' }), + ]); + setChoiceOrder(['c', 'a', 'b']); + expect(state.value.choices.map(a => a.id)).toEqual(['c', 'a', 'b']); + }); + + it('is a no-op when the given order is not a permutation of the current ids', () => { + const { state, setChoiceOrder } = setup([ + makeAnswer({ id: 'a' }), + makeAnswer({ id: 'b' }), + makeAnswer({ id: 'c' }), + ]); + + setChoiceOrder(['c', 'a']); + expect(state.value.choices.map(a => a.id)).toEqual(['a', 'b', 'c']); + + setChoiceOrder(['c', 'a', 'zzz']); + expect(state.value.choices.map(a => a.id)).toEqual(['a', 'b', 'c']); + }); + + it('produces the same bodyXml as moveChoiceUp for the equivalent move', () => { + const choices = [ + makeAnswer({ id: 'a', content: 'A' }), + makeAnswer({ id: 'b', content: 'B' }), + makeAnswer({ id: 'c', content: 'C' }), + ]; + const { moveChoiceUp, bodyXml: chevronBodyXml } = setup(choices); + moveChoiceUp('c'); + + const { setChoiceOrder, bodyXml: dragBodyXml } = setup(choices); + setChoiceOrder(['a', 'c', 'b']); + + expect(dragBodyXml.value).toBe(chevronBodyXml.value); + }); + }); + describe('toggleCorrectChoice()', () => { it('singleSelect: sets only the target as correct and clears others', () => { const { state, toggleCorrectChoice, questionTypeRef } = setup([ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useOrderingInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useOrderingInteraction.spec.js index f46199f9a6..296b5a5331 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useOrderingInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useOrderingInteraction.spec.js @@ -102,6 +102,37 @@ describe('useOrderingInteraction', () => { }); }); + describe('setItemOrder()', () => { + it('applies the given order', () => { + const { state, setItemOrder } = setup(); + const [firstId, secondId, thirdId] = state.value.items.map(i => i.id); + setItemOrder([thirdId, firstId, secondId]); + expect(state.value.items.map(i => i.id)).toEqual([thirdId, firstId, secondId]); + }); + + it('is a no-op when the given order is not a permutation of the current ids', () => { + const { state, setItemOrder } = setup(); + const [firstId, secondId, thirdId] = state.value.items.map(i => i.id); + + setItemOrder([thirdId, firstId]); + expect(state.value.items.map(i => i.id)).toEqual([firstId, secondId, thirdId]); + + setItemOrder([thirdId, firstId, 'order_zzzzzzzz']); + expect(state.value.items.map(i => i.id)).toEqual([firstId, secondId, thirdId]); + }); + + it('produces the same bodyXml as moveItemUp for the equivalent move', () => { + const { state, moveItemUp, bodyXml: chevronBodyXml } = setup(); + const [firstId, secondId, thirdId] = state.value.items.map(i => i.id); + moveItemUp(secondId); + + const { setItemOrder, bodyXml: dragBodyXml } = setup(); + setItemOrder([secondId, firstId, thirdId]); + + expect(dragBodyXml.value).toBe(chevronBodyXml.value); + }); + }); + describe('setItemContent()', () => { it('updates only the targeted item content', () => { const { state, setItemContent } = setup(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js index d030f693d2..dff8864cb7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js @@ -53,6 +53,14 @@ export function useChoiceInteraction(interactionBlock, questionType) { state.value = { ...state.value, choices }; } + /** Apply a permutation of the current choice ids; anything else is ignored. */ + function setChoiceOrder(orderedIds) { + const byId = new Map(state.value.choices.map(c => [c.id, c])); + const choices = [...new Set(orderedIds)].map(id => byId.get(id)); + if (choices.length !== byId.size || choices.includes(undefined)) return; + state.value = { ...state.value, choices }; + } + /** * Toggle the correct flag for a single choice. * @@ -98,6 +106,7 @@ export function useChoiceInteraction(interactionBlock, questionType) { removeChoice, moveChoiceUp, moveChoiceDown, + setChoiceOrder, toggleCorrectChoice, setPrompt, setChoiceContent, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js index 9590207c3b..67922513c2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js @@ -44,6 +44,14 @@ export function useOrderingInteraction(interactionBlock, questionType) { state.value = { ...state.value, items }; } + /** Apply a permutation of the current item ids; anything else is ignored. */ + function setItemOrder(orderedIds) { + const byId = new Map(state.value.items.map(item => [item.id, item])); + const items = [...new Set(orderedIds)].map(id => byId.get(id)); + if (items.length !== byId.size || items.includes(undefined)) return; + state.value = { ...state.value, items }; + } + function setItemContent(id, html) { state.value = { ...state.value, @@ -62,6 +70,7 @@ export function useOrderingInteraction(interactionBlock, questionType) { removeItem, moveItemUp, moveItemDown, + setItemOrder, setItemContent, setPrompt, }; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue index ff72914d66..b60d04edc1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue @@ -81,115 +81,151 @@ {{ answersDescription }} - + -
- - -
-
+ + -
- - - - +
+ + +
+ +
+
+ + +
+ + + + +
+ +
+ +
+ +
+ +
+
- -
- -
- - -
+ - -
-
+ {{ errorEmptyChoiceContent$() }} + + + {{ errorDuplicateChoiceContent$() }} + +
- - - {{ errorEmptyChoiceContent$() }} - - - {{ errorDuplicateChoiceContent$() }} - -
-
-
+ + + state.value.choices.find(a => a.correct)?.id ?? null); + const isOnlyChoice = computed(() => state.value.choices.length <= 1); + + const listTag = computed(() => (isSingleSelect.value ? 'KRadioButtonGroup' : 'div')); + const errorCodes = computed(() => errors.value.map(e => e.code)); const emptyChoiceIds = computed( () => @@ -395,33 +441,8 @@ } } - function getChoiceRowActions(answerId, index) { - return [ - { - id: 'up', - icon: 'chevronUp', - label: moveChoiceUpBtn$(), - disabled: index === 0, - handler: () => moveChoiceUp(answerId), - collapsed: windowIsSmall.value, - }, - { - id: 'down', - icon: 'chevronDown', - label: moveChoiceDownBtn$(), - disabled: index === state.value.choices.length - 1, - handler: () => moveChoiceDown(answerId), - collapsed: windowIsSmall.value, - }, - { - id: 'delete', - icon: 'close', - label: deleteChoiceBtn$(), - disabled: state.value.choices.length <= 1, - handler: () => onRemoveChoice(answerId), - collapsed: windowIsSmall.value, - }, - ]; + function onReorderChoices(nextChoices) { + setChoiceOrder(nextChoices.map(c => c.id)); } const isPromptEditing = computed(() => props.mode === 'edit' && isQuestionOpen.value); @@ -496,6 +517,8 @@ closeQuestion, closeChoice, correctChoiceId, + isOnlyChoice, + listTag, questionHasError, noCorrectAnswerError, tooManyCorrectError, @@ -508,7 +531,10 @@ setShowAnswerCount, onToggleCorrect, onAddChoice, - getChoiceRowActions, + onRemoveChoice, + onReorderChoices, + moveChoiceUp, + moveChoiceDown, isChoiceClosed, isChoiceOpen, getChoiceClasses, @@ -516,6 +542,8 @@ handlePromptClick, handleChoiceClick, addChoiceBtn$, + deleteChoiceBtn$, + choiceItemLabel$, markCorrectLabel$, errorPromptRequired$, errorNoCorrectAnswer$, @@ -603,8 +631,10 @@ flex-direction: column; } + /* Opaque, so a row dragged over the rows beneath it stays readable */ .choice-border { position: relative; + background-color: v-bind('$themeTokens.surface'); border: 1px solid; border-radius: 4px; transition: background-color 0.3s; @@ -628,7 +658,7 @@ padding: 7.5px; } - /* Flex row: [selection] [content] [actions] */ + /* Flex row: [drag] [selection] [content] [actions] */ .choice-layout { display: flex; align-items: center; @@ -643,36 +673,60 @@ flex-wrap: wrap; align-items: center; - .choice-selection { - flex: 0 0 auto; - order: 0; - margin-bottom: 4px; - } - + .choice-drag, + .choice-selection, .choice-actions { flex: 0 0 auto; - order: 1; margin-bottom: 4px; } .choice-content { flex: 0 0 100%; - order: 2; + order: 1; min-width: 0; } } } } + .choice-drag { + display: flex; + flex-shrink: 0; + align-items: center; + margin-right: 8px; + + .small-screen & { + margin-right: 4px; + } + } + + /* KRadioButton/KCheckbox wrap their 24px control in a table with 8px block margins, + and the inline icon adds descender space under it. Both push the control off the + row centre, out of line with the drag handle. */ .choice-selection { + display: flex; flex-shrink: 0; + align-items: center; margin-right: 16px; + line-height: 0; + + ::v-deep .k-radio-button-container, + ::v-deep .k-checkbox-container { + margin-top: 0; + margin-bottom: 0; + } .small-screen & { margin-right: 6px; } } + .choice-error-icon { + top: 0; + width: 24px; + height: 24px; + } + .choice-content { position: relative; flex: 1; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js index 032cf2885e..e73c269f3c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js @@ -1,4 +1,5 @@ import { render, screen, fireEvent, within } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; import ChoiceInteractionEditor from '../ChoiceInteractionEditor.vue'; @@ -14,8 +15,19 @@ import { } from '../../../utils/testingFixtures'; import { QuestionType } from '../../../constants'; import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; +import { dragSortStrings as dragTr } from 'shared/views/dragSort/dragSortStrings'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); +// jsdom cannot produce the pointer events real SortableJS listens for, so the tests +// call the captured `onEnd` instead. +let mockSortableInstances; +jest.mock('sortablejs', () => + jest.fn().mockImplementation((el, options) => { + const instance = { el, options, option: jest.fn(), destroy: jest.fn() }; + mockSortableInstances.push(instance); + return instance; + }), +); jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { const { ref } = require('vue'); return { @@ -27,6 +39,7 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { let teleportContainer; beforeEach(() => { + mockSortableInstances = []; teleportContainer = document.createElement('div'); teleportContainer.id = 'test-settings-target'; document.body.appendChild(teleportContainer); @@ -38,12 +51,29 @@ afterEach(() => { } }); +const choiceLabel = number => tr.$tr('choiceItemLabel', { number }); +const moveUpName = number => dragTr.$tr('moveItemUpLabel', { item: choiceLabel(number) }); +const moveDownName = number => dragTr.$tr('moveItemDownLabel', { item: choiceLabel(number) }); + const renderEditor = (props = {}) => render(ChoiceInteractionEditor, { props: { mode: 'edit', teleportTargetId: 'test-settings-target', ...props }, routes: new VueRouter(), }); +const dragFirstRowToLast = async () => { + const { options, el } = mockSortableInstances[mockSortableInstances.length - 1]; + options.onEnd({ + item: el.children[0], + from: el, + to: el, + oldIndex: 0, + oldDraggableIndex: 0, + newDraggableIndex: el.children.length - 1, + }); + await nextTick(); +}; + describe('ChoiceInteractionEditor', () => { describe('prompt rendering', () => { it('renders the prompt text from the XML', () => { @@ -182,34 +212,67 @@ describe('ChoiceInteractionEditor', () => { expect(screen.getAllByRole('radio')).toHaveLength(4); }); - it('renders move-up, move-down, and delete buttons for each non-fixed choice', () => { + it('gives every choice row its own delete button', () => { renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT, }); - expect(screen.getAllByRole('button', { name: tr.$tr('moveChoiceUpBtn') })).toHaveLength(3); - expect(screen.getAllByRole('button', { name: tr.$tr('moveChoiceDownBtn') })).toHaveLength(3); expect(screen.getAllByRole('button', { name: tr.$tr('deleteChoiceBtn') })).toHaveLength(3); }); - it('disables move-up on the first choice', () => { + it('hides move-up on the first choice and move-down on the last', () => { renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT, }); - const moveUpBtns = screen.getAllByRole('button', { name: tr.$tr('moveChoiceUpBtn') }); - expect(moveUpBtns[0]).toBeDisabled(); - expect(moveUpBtns[1]).toBeEnabled(); + // `v-show` hides the out-of-range control, dropping it out of the accessibility tree + expect(screen.queryByRole('button', { name: moveUpName(1) })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveUpName(2) })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveUpName(3) })).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: moveDownName(1) })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveDownName(2) })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: moveDownName(3) })).not.toBeInTheDocument(); }); - it('disables move-down on the last choice', () => { - renderEditor({ + it('reorders the choices when a row is dragged to a new position', async () => { + const { emitted } = renderEditor({ + interaction: block(CHOICE_SINGLE_SELECT_XML), + questionType: QuestionType.SINGLE_SELECT, + }); + await dragFirstRowToLast(); + + const { bodyXml } = emitted()['update:interaction'].pop()[0]; + expect(bodyXml.indexOf('identifier="mercury"')).toBeGreaterThan( + bodyXml.indexOf('identifier="earth"'), + ); + }); + + it('binds the drag list to the rendered element after the question type changes', async () => { + // Switching question type swaps KRadioButtonGroup for a plain div. Left bound to the + // detached element, drag silently stops working, so assert on the element SortableJS + // was handed rather than on anything the editor emits. + const { updateProps } = renderEditor({ + interaction: block(CHOICE_SINGLE_SELECT_XML), + questionType: QuestionType.SINGLE_SELECT, + }); + await updateProps({ questionType: QuestionType.MULTI_SELECT }); + + const { el } = mockSortableInstances[mockSortableInstances.length - 1]; + expect(document.body).toContainElement(el); + }); + + it('reorders the choices when a row is moved down by keyboard', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor({ interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT, }); - const moveDownBtns = screen.getAllByRole('button', { name: tr.$tr('moveChoiceDownBtn') }); - expect(moveDownBtns[2]).toBeDisabled(); - expect(moveDownBtns[1]).toBeEnabled(); + await user.click(screen.getByRole('button', { name: moveDownName(1) })); + const { bodyXml } = emitted()['update:interaction'].pop()[0]; + expect(bodyXml.indexOf('identifier="venus"')).toBeLessThan( + bodyXml.indexOf('identifier="mercury"'), + ); }); it('disables delete when only one choice remains', async () => { @@ -304,6 +367,7 @@ describe('ChoiceInteractionEditor', () => { expect( screen.queryByRole('button', { name: tr.$tr('deleteChoiceBtn') }), ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: moveDownName(1) })).not.toBeInTheDocument(); }); }); @@ -335,6 +399,25 @@ describe('ChoiceInteractionEditor', () => { expect(screen.getAllByRole('alert').length).toBeGreaterThan(0); }); + it('replaces the selection control with the error icon on an invalid choice', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderEditor({ + interaction: block(CHOICE_SINGLE_SELECT_XML), + questionType: QuestionType.SINGLE_SELECT, + }); + expect(screen.getAllByRole('radio')).toHaveLength(3); + // The added choice starts empty, so the debounced validation flags it. + await user.click(screen.getByRole('button', { name: /add choice/i })); + await nextTick(); + jest.advanceTimersByTime(400); + await nextTick(); + jest.useRealTimers(); + // Four rows, but the invalid one shows the error icon where its radio was + expect(screen.getAllByRole('button', { name: tr.$tr('deleteChoiceBtn') })).toHaveLength(4); + expect(screen.getAllByRole('radio')).toHaveLength(3); + }); + it('shows no-correct-choice error after toggling and running validation', async () => { jest.useFakeTimers(); renderEditor({ @@ -477,9 +560,7 @@ describe('ChoiceInteractionEditor', () => { interaction: block(CHOICE_SINGLE_SELECT_XML), questionType: QuestionType.SINGLE_SELECT, }); - screen - .getAllByRole('button', { name: tr.$tr('moveChoiceUpBtn') }) - .forEach(b => expect(b).toHaveAccessibleName()); + screen.getAllByRole('button').forEach(b => expect(b).toHaveAccessibleName()); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue index 6d400f9366..78ec470450 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/OrderingInteractionEditor.vue @@ -66,87 +66,120 @@ -
    -
  1. - - -
    -
    + + - -
    - +
    + + +
    + +
    +
    + + + +
    + +
    + +
    + +
    +
    - -
    + - -
    -
    -
    - - - - {{ errorEmptyItemContent$() }} - - - {{ errorDuplicateItemContent$() }} - -
    -
  2. -
+ {{ errorEmptyItemContent$() }} + + + {{ errorDuplicateItemContent$() }} + + + + + + import { computed, ref, watch } from 'vue'; - import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import { themeTokens } from 'kolibri-design-system/lib/styles/theme'; import { qtiEditorStrings } from '../../qtiEditorStrings'; import { ValidationError } from '../../constants'; import { useOrderingInteraction } from '../../composables/useOrderingInteraction'; - import CollapsibleToolbar from '../../components/CollapsibleToolbar/index.vue'; import ValidationMessage from '../../components/ValidationMessage/index.vue'; import AddListItemButton from '../../components/AddListItemButton/index.vue'; import ClickableRegion from '../../components/ClickableRegion/index.vue'; + import DraggableRegion from 'shared/views/dragSort/DraggableRegion.vue'; + import DraggableItem from 'shared/views/dragSort/DraggableItem.vue'; + import DraggableHandle from 'shared/views/dragSort/DraggableHandle.vue'; + import DragSortWidget from 'shared/views/dragSort/DragSortWidget/index.vue'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor'; import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; @@ -180,14 +215,16 @@ components: { TipTapEditor, - CollapsibleToolbar, ValidationMessage, AddListItemButton, ClickableRegion, + DraggableRegion, + DraggableItem, + DraggableHandle, + DragSortWidget, }, setup(props, { emit }) { - const { windowIsSmall } = useKResponsiveWindow(); const tokens = themeTokens(); const { @@ -197,8 +234,7 @@ correctOrderDescription$, addItemBtn$, deleteItemBtn$, - moveItemUpBtn$, - moveItemDownBtn$, + orderingItemLabel$, errorTooFewChoices$, errorEmptyItemContent$, errorDuplicateItemContent$, @@ -217,6 +253,7 @@ removeItem, moveItemUp, moveItemDown, + setItemOrder, setItemContent, setPrompt, } = useOrderingInteraction(props.interaction, questionTypeRef); @@ -283,6 +320,8 @@ emit('update:interaction', newVal); }); + const isOnlyItem = computed(() => state.value.items.length <= 1); + const errorCodes = computed(() => errors.value.map(e => e.code)); const promptHasError = computed(() => @@ -341,9 +380,12 @@ }; } + function itemHasError(id) { + return emptyItemIds.value.has(id) || duplicateItemIds.value.has(id); + } + function getItemStyle(item) { - const hasError = emptyItemIds.value.has(item.id) || duplicateItemIds.value.has(item.id); - return { borderColor: hasError ? tokens.error : tokens.fineLine }; + return { borderColor: itemHasError(item.id) ? tokens.error : tokens.fineLine }; } function onAddItem() { @@ -353,38 +395,14 @@ if (newId) openItem(newId); } - function getItemActions(itemId, index) { - return [ - { - id: 'up', - icon: 'chevronUp', - label: moveItemUpBtn$({ number: index + 1 }), - disabled: index === 0, - handler: () => moveItemUp(itemId), - collapsed: windowIsSmall.value, - }, - { - id: 'down', - icon: 'chevronDown', - label: moveItemDownBtn$({ number: index + 1 }), - disabled: index === state.value.items.length - 1, - handler: () => moveItemDown(itemId), - collapsed: windowIsSmall.value, - }, - { - id: 'delete', - icon: 'close', - label: deleteItemBtn$({ number: index + 1 }), - disabled: state.value.items.length <= 1, - handler: () => removeItem(itemId), - collapsed: windowIsSmall.value, - }, - ]; + function onReorderItems(nextItems) { + setItemOrder(nextItems.map(item => item.id)); } return { EditorImageProcessor, state, + isOnlyItem, promptHasError, tooFewItemsError, emptyItemIds, @@ -399,16 +417,22 @@ isItemOpen, getItemClasses, getItemStyle, + itemHasError, handleItemClick, onAddItem, setItemContent, setPrompt, - getItemActions, + moveItemUp, + moveItemDown, + removeItem, + onReorderItems, questionLabel$, errorPromptRequired$, correctOrderLabel$, correctOrderDescription$, addItemBtn$, + deleteItemBtn$, + orderingItemLabel$, errorTooFewChoices$, errorEmptyItemContent$, errorDuplicateItemContent$, @@ -481,7 +505,9 @@ font-weight: 400; } + /* Opaque, so a row dragged over the rows beneath it stays readable */ .item-border { + background-color: v-bind('$themeTokens.surface'); border: 1px solid; border-radius: 4px; transition: background-color 0.3s; @@ -542,6 +568,13 @@ } } + .item-drag { + display: flex; + flex-shrink: 0; + align-items: center; + margin-right: 8px; + } + .position-badge { display: flex; flex-shrink: 0; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js index 6e082e94cf..fca24c360a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/OrderingInteractionEditor.spec.js @@ -1,4 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; import OrderingInteractionEditor from '../OrderingInteractionEditor.vue'; @@ -11,22 +12,47 @@ import { } from '../../../utils/testingFixtures'; import { QuestionType } from '../../../constants'; import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; +import { dragSortStrings as dragTr } from 'shared/views/dragSort/dragSortStrings'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); -jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { - const { ref } = require('vue'); - return { - __esModule: true, - default: () => ({ windowIsSmall: ref(false) }), - }; +// jsdom cannot produce the pointer events real SortableJS listens for, so the tests +// call the captured `onEnd` instead. +let mockSortableInstances; +jest.mock('sortablejs', () => + jest.fn().mockImplementation((el, options) => { + const instance = { el, options, option: jest.fn(), destroy: jest.fn() }; + mockSortableInstances.push(instance); + return instance; + }), +); + +beforeEach(() => { + mockSortableInstances = []; }); +const itemLabel = number => tr.$tr('orderingItemLabel', { number }); +const moveUpName = number => dragTr.$tr('moveItemUpLabel', { item: itemLabel(number) }); +const moveDownName = number => dragTr.$tr('moveItemDownLabel', { item: itemLabel(number) }); + const renderEditor = (props = {}) => render(OrderingInteractionEditor, { props: { mode: 'edit', ...props }, routes: new VueRouter(), }); +const dragFirstRowToLast = async () => { + const { options, el } = mockSortableInstances[mockSortableInstances.length - 1]; + options.onEnd({ + item: el.children[0], + from: el, + to: el, + oldIndex: 0, + oldDraggableIndex: 0, + newDraggableIndex: el.children.length - 1, + }); + await nextTick(); +}; + describe('OrderingInteractionEditor', () => { describe('edit mode rendering', () => { it('renders the prompt text from the XML', () => { @@ -93,30 +119,45 @@ describe('OrderingInteractionEditor', () => { expect(screen.getByText('4')).toBeInTheDocument(); }); - it('disables move-up button for the first item', () => { + it('hides move-up on the first item and move-down on the last', () => { renderEditor({ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), questionType: QuestionType.ORDERING, }); - expect( - screen.getByRole('button', { name: tr.$tr('moveItemUpBtn', { number: 1 }) }), - ).toBeDisabled(); - expect( - screen.getByRole('button', { name: tr.$tr('moveItemUpBtn', { number: 2 }) }), - ).toBeEnabled(); + // `v-show` hides the out-of-range control, dropping it out of the accessibility tree + expect(screen.queryByRole('button', { name: moveUpName(1) })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveUpName(2) })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveUpName(3) })).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: moveDownName(1) })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: moveDownName(2) })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: moveDownName(3) })).not.toBeInTheDocument(); }); - it('disables move-down button for the last item', () => { - renderEditor({ + it('reorders the items when a row is dragged to a new position', async () => { + const { emitted } = renderEditor({ interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), questionType: QuestionType.ORDERING, }); - expect( - screen.getByRole('button', { name: tr.$tr('moveItemDownBtn', { number: 3 }) }), - ).toBeDisabled(); - expect( - screen.getByRole('button', { name: tr.$tr('moveItemDownBtn', { number: 1 }) }), - ).toBeEnabled(); + await dragFirstRowToLast(); + + const { bodyXml } = emitted()['update:interaction'].pop()[0]; + expect(bodyXml.indexOf('identifier="order_aaa11111"')).toBeGreaterThan( + bodyXml.indexOf('identifier="order_ccc33333"'), + ); + }); + + it('reorders the items when a row is moved down by keyboard', async () => { + const user = userEvent.setup(); + const { emitted } = renderEditor({ + interaction: blockWithDecl(ORDERING_XML, ORDERING_DECL_XML), + questionType: QuestionType.ORDERING, + }); + await user.click(screen.getByRole('button', { name: moveDownName(1) })); + const { bodyXml } = emitted()['update:interaction'].pop()[0]; + expect(bodyXml.indexOf('identifier="order_bbb22222"')).toBeLessThan( + bodyXml.indexOf('identifier="order_aaa11111"'), + ); }); it('disables delete button when only one item remains', async () => { @@ -176,6 +217,7 @@ describe('OrderingInteractionEditor', () => { expect( screen.queryByRole('button', { name: tr.$tr('deleteItemBtn', { number: 1 }) }), ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: moveDownName(1) })).not.toBeInTheDocument(); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue index 4eb1a85120..d4cbbbe0a7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue @@ -133,7 +133,7 @@ :ariaLabel="deleteAnswerBtn$({ number: index + 1 })" :disabled="state.answers.length <= 1" :color=" - state.answers.length <= 1 ? $themeTokens.textDisabled : $themePalette.grey.v_800 + state.answers.length <= 1 ? $themeTokens.textDisabled : $themePalette.grey.v_700 " size="small" @click="onRemoveAnswer(answer.id)" diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index f597368a2e..503a844152 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -79,13 +79,10 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Delete option {number}', context: 'Accessible label for the delete icon button next to an ordering item row', }, - moveItemUpBtn: { - message: 'Move option {number} up', - context: 'Accessible label for the move-up icon button next to an ordering item row', - }, - moveItemDownBtn: { - message: 'Move option {number} down', - context: 'Accessible label for the move-down icon button next to an ordering item row', + orderingItemLabel: { + message: 'Option {number}', + context: + 'Names an ordering row in its reorder controls and in the screen reader announcement after it moves', }, errorTooFewChoices: { message: 'At least 2 items are required for an ordering question.', @@ -151,13 +148,10 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Delete choice', context: 'Accessible label for the delete-choice icon button', }, - moveChoiceUpBtn: { - message: 'Move choice up', - context: 'Accessible label for the move-up icon button', - }, - moveChoiceDownBtn: { - message: 'Move choice down', - context: 'Accessible label for the move-down icon button', + choiceItemLabel: { + message: 'Choice {number}', + context: + 'Names a choice row in its reorder controls and in the screen reader announcement after it moves', }, markCorrectLabel: { message: 'Mark as correct answer', diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/DragSortWidget/index.vue b/contentcuration/contentcuration/frontend/shared/views/dragSort/DragSortWidget/index.vue new file mode 100644 index 0000000000..b2e0441445 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/DragSortWidget/index.vue @@ -0,0 +1,296 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableHandle.vue b/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableHandle.vue new file mode 100644 index 0000000000..c51c704fd4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableHandle.vue @@ -0,0 +1,15 @@ + diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableItem.vue b/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableItem.vue new file mode 100644 index 0000000000..9c1be951af --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableItem.vue @@ -0,0 +1,27 @@ + diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableRegion.vue b/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableRegion.vue new file mode 100644 index 0000000000..9a7cec23a7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableRegion.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableUniverse.vue b/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableUniverse.vue new file mode 100644 index 0000000000..9bc9faea33 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/DraggableUniverse.vue @@ -0,0 +1,39 @@ + diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DragSortWidget.spec.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DragSortWidget.spec.js new file mode 100644 index 0000000000..82de028047 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DragSortWidget.spec.js @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils'; +import useKLiveRegion from 'kolibri-design-system/lib/composables/useKLiveRegion'; +import DragSortWidget from '../DragSortWidget/index.vue'; + +jest.mock('kolibri-design-system/lib/composables/useKLiveRegion'); + +describe('DragSortWidget', () => { + let sendPoliteMessage; + + beforeEach(() => { + sendPoliteMessage = jest.fn(); + useKLiveRegion.mockReturnValue({ sendPoliteMessage }); + }); + + function makeWrapper(propsData = {}) { + return mount(DragSortWidget, { + propsData: { + isFirst: false, + isLast: false, + ...propsData, + }, + }); + } + + describe('move-button announcements', () => { + it('announces the item and its new position on move up', async () => { + const wrapper = makeWrapper({ itemLabel: 'Rubens Barrichello', position: 2, total: 3 }); + await wrapper.findComponent({ ref: 'upBtn' }).vm.$emit('click'); + expect(sendPoliteMessage).toHaveBeenCalledWith('Rubens Barrichello moved to position 1 of 3'); + }); + + it('announces the item and its new position on move down', async () => { + const wrapper = makeWrapper({ itemLabel: 'Rubens Barrichello', position: 2, total: 3 }); + await wrapper.findComponent({ ref: 'dnBtn' }).vm.$emit('click'); + expect(sendPoliteMessage).toHaveBeenCalledWith('Rubens Barrichello moved to position 3 of 3'); + }); + + it('does not announce when itemLabel is not provided', async () => { + const wrapper = makeWrapper({ position: 2, total: 3 }); + await wrapper.findComponent({ ref: 'upBtn' }).vm.$emit('click'); + expect(sendPoliteMessage).not.toHaveBeenCalled(); + }); + + it('does not announce when total is not provided', async () => { + const wrapper = makeWrapper({ itemLabel: 'Rubens Barrichello', position: 2 }); + await wrapper.findComponent({ ref: 'upBtn' }).vm.$emit('click'); + expect(sendPoliteMessage).not.toHaveBeenCalled(); + }); + }); + + describe('item-specific aria-labels', () => { + it('uses the item label in vertical mode', () => { + const wrapper = makeWrapper({ itemLabel: 'Rubens Barrichello', position: 2, total: 3 }); + expect(wrapper.findComponent({ ref: 'upBtn' }).props('ariaLabel')).toBe( + 'Move Rubens Barrichello up', + ); + expect(wrapper.findComponent({ ref: 'dnBtn' }).props('ariaLabel')).toBe( + 'Move Rubens Barrichello down', + ); + }); + + it('falls back to the generic label when no itemLabel is given', () => { + const wrapper = makeWrapper(); + expect(wrapper.findComponent({ ref: 'upBtn' }).props('ariaLabel')).toBe('Move up'); + expect(wrapper.findComponent({ ref: 'dnBtn' }).props('ariaLabel')).toBe('Move down'); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DraggableItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DraggableItem.spec.js new file mode 100644 index 0000000000..e458ebacb8 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DraggableItem.spec.js @@ -0,0 +1,64 @@ +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'; + +// The slot's own element is what gets marked, so every mount supplies one. +const slots = { default: '
' }; + +describe('DraggableItem', () => { + it('marks the element the consumer wrote instead of wrapping it', () => { + const wrapper = mount(DraggableItem, { slots: { default: '
  • row
  • ' } }); + expect(wrapper.element.tagName).toBe('LI'); + expect(wrapper.classes()).toContain(ITEM_CLASS); + }); + + it('keeps the classes that element already had', () => { + const wrapper = mount(DraggableItem, { slots: { default: '
    ' } }); + expect(wrapper.classes()).toContain(ITEM_CLASS); + expect(wrapper.classes()).toContain('my-row'); + }); + + it('merges a class bound on the component onto that same element', () => { + 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, { slots }).classes()).not.toContain(DISABLED_CLASS); + expect(mount(DraggableItem, { propsData: { disabled: true }, slots }).classes()).toContain( + DISABLED_CLASS, + ); + }); + + it('forwards attributes to that element', () => { + const wrapper = mount(DraggableItem, { attrs: { tabindex: '-1' }, slots }); + expect(wrapper.attributes('tabindex')).toBe('-1'); + }); + + it('renders its slot content', () => { + const wrapper = mount(DraggableItem, { slots: { default: '
    hello
    ' } }); + expect(wrapper.text()).toBe('hello'); + }); +}); + +describe('DraggableHandle', () => { + it('marks the element the consumer wrote instead of wrapping it', () => { + const wrapper = mount(DraggableHandle, { slots: { default: 'grip' } }); + expect(wrapper.element.tagName).toBe('SPAN'); + expect(wrapper.classes()).toContain(HANDLE_CLASS); + }); + + it('marks the root element of a component in its slot', () => { + const Grip = { name: 'Grip', template: '' }; + const wrapper = mount(DraggableHandle, { slots: { default: Grip } }); + expect(wrapper.element.tagName).toBe('BUTTON'); + expect(wrapper.classes()).toContain(HANDLE_CLASS); + expect(wrapper.classes()).toContain('grip'); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DraggableRegion.spec.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DraggableRegion.spec.js new file mode 100644 index 0000000000..762c97d470 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DraggableRegion.spec.js @@ -0,0 +1,459 @@ +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) => { + const instance = { el, options, option: jest.fn(), destroy: jest.fn() }; + mockInstances.push(instance); + return instance; + }), +); + +// 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. The region marks + // the element written inside it rather than rendering one of its own. + async function mountRegion(propsData = {}, mountOptions = {}) { + const wrapper = mount(DraggableRegion, { + propsData: { items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], ...propsData }, + slots: { default: '
    ' }, + ...mountOptions, + }); + await wrapper.vm.$nextTick(); + const instance = mockInstances[mockInstances.length - 1]; + return { wrapper, options: instance.options, sortable: instance }; + } + + it('sorts the element written inside it, rather than one of its own', async () => { + const { wrapper, sortable } = await mountRegion({}, { slots: { default: '
      ' } }); + expect(wrapper.element.tagName).toBe('UL'); + expect(sortable.el).toBe(wrapper.element); + }); + + it('confines the press-and-hold delay to touch, so a mouse drag is not swallowed', async () => { + const { options } = await mountRegion(); + expect(options.delayOnTouchOnly).toBe(true); + }); + + 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); + }); + }); + + describe('options that change after mount', () => { + it('pushes a flipped sortable prop into the SortableJS instance', async () => { + const { wrapper, sortable } = await mountRegion({ sortable: true }); + await wrapper.setProps({ sortable: false }); + expect(sortable.option).toHaveBeenCalledWith('sort', false); + }); + + it('pushes a flipped clone prop into the SortableJS instance', async () => { + const { wrapper, sortable } = await mountRegion({ clone: false }); + await wrapper.setProps({ clone: true }); + expect(sortable.option).toHaveBeenCalledWith( + 'group', + expect.objectContaining({ pull: 'clone' }), + ); + }); + + it("pushes the universe's changed delay into the SortableJS instance", async () => { + const wrapper = mount({ + components: { DraggableUniverse, DraggableRegion }, + data() { + return { delay: 250, items: [{ id: 'a' }] }; + }, + template: ` + +
      +
      +
      + + `, + }); + await wrapper.vm.$nextTick(); + const sortable = mockInstances[mockInstances.length - 1]; + expect(sortable.options.delay).toBe(250); + + await wrapper.setData({ delay: 0 }); + expect(sortable.option).toHaveBeenCalledWith('delay', 0); + }); + }); + + 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); + }); + }); + + // Two regions inside one universe so they share a SortableJS group and registry. + async function mountUniverse(targetProps = {}, { sourceProps = {}, sourceItems } = {}) { + const wrapper = mount({ + components: { DraggableUniverse, DraggableRegion }, + data() { + return { + source: sourceItems || [{ id: 'a' }, { id: 'b' }], + target: [{ id: 'x' }], + sourceProps, + 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, + }; + } + + describe('cross-region move', () => { + 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, + }); + + 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']); + }); + + // SortableJS builds a clone node for every drag, whether or not it is shown + it('removes the clone node SortableJS left in the source region', async () => { + const { 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, + }); + + 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, + }); + 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, + }); + expect(sourceRegion.emitted('update:items')).toBeUndefined(); + }); + }); + + describe('cross-region clone', () => { + // Drags the first source item onto the front of the target region, as a clone. + async function dropClone({ sourceProps, sourceItems } = {}) { + const context = await mountUniverse( + {}, + { sourceProps: sourceProps || { clone: true }, sourceItems }, + ); + const { sourceOptions, sourceEl, targetEl } = context; + 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: 'clone', + }); + return context; + } + + it('sets pull to clone when the clone prop is set', async () => { + const { options } = await mountRegion({ clone: true }); + expect(options.group.pull).toBe('clone'); + }); + + it('leaves the source array untouched', async () => { + const { sourceRegion } = await dropClone(); + expect(sourceRegion.emitted('update:items')).toBeUndefined(); + }); + + it('inserts a copy, not the source object itself', async () => { + const { sourceRegion, targetRegion } = await dropClone(); + const inserted = targetRegion.emitted('update:items')[0][0][0]; + const original = sourceRegion.props('items')[0]; + expect(inserted).not.toBe(original); + expect(inserted).toEqual(original); + }); + + it('does not mutate the source item when the clone is changed', async () => { + const { sourceRegion, targetRegion } = await dropClone(); + const inserted = targetRegion.emitted('update:items')[0][0][0]; + inserted.id = 'changed'; + inserted.matched = true; + expect(sourceRegion.props('items')[0]).toEqual({ id: 'a' }); + }); + + it('keeps the identifier field the source data uses', async () => { + const { targetRegion } = await dropClone({ + sourceItems: [ + { identifier: 'CHOICE_A', text: 'Alpha' }, + { identifier: 'CHOICE_B', text: 'Beta' }, + ], + }); + expect(targetRegion.emitted('update:items')[0][0][0]).toEqual({ + identifier: 'CHOICE_A', + text: 'Alpha', + }); + }); + + it('uses a transform function passed as the clone prop', async () => { + let next = 0; + const { targetRegion } = await dropClone({ + sourceProps: { clone: original => ({ ...original, uid: `copy-${++next}` }) }, + }); + expect(targetRegion.emitted('update:items')[0][0][0]).toEqual({ id: 'a', uid: 'copy-1' }); + }); + }); + + describe('full-order announcement on focus-exit', () => { + // The announcement waits a frame to see where focus settled. + function nextFrame() { + return new Promise(resolve => requestAnimationFrame(() => resolve())); + } + + // A region in the document, so document.activeElement can be inside it. + async function mountAttachedRegion() { + const { wrapper } = await mountRegion({}, { attachTo: document.body }); + const outside = document.createElement('button'); + document.body.appendChild(outside); + return { wrapper, outside }; + } + + it('announces the current order when focus leaves the region', async () => { + const { wrapper, outside } = await mountAttachedRegion(); + // 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); + + await wrapper.trigger('focusout', { relatedTarget: outside }); + outside.focus(); + await nextFrame(); + + expect(sendPoliteMessage).toHaveBeenCalledWith( + 'Current order: 1. First, 2. Second, 3. Third', + ); + document.body.removeChild(outside); + wrapper.destroy(); + }); + + it('does not announce when no items are registered', async () => { + const { wrapper, outside } = await mountAttachedRegion(); + await wrapper.trigger('focusout', { relatedTarget: outside }); + outside.focus(); + await nextFrame(); + expect(sendPoliteMessage).not.toHaveBeenCalled(); + document.body.removeChild(outside); + wrapper.destroy(); + }); + + it('does not announce anything for items that have been unregistered', async () => { + const { wrapper } = await mountRegion(); + const provided = wrapper.vm._provided; + provided.registerSortItem(0, 'First', 1); + provided.registerSortItem(1, 'Second', 2); + provided.registerSortItem(2, 'Third', 3); + [0, 1, 2].forEach(uid => provided.unregisterSortItem(uid)); + + 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 }); + await nextFrame(); + expect(sendPoliteMessage).not.toHaveBeenCalled(); + }); + }); + + describe('no announcement on row-to-row focus movement', () => { + it('does not announce when focus moves to another row inside the region', async () => { + const { wrapper } = await mountRegion(); + const provided = wrapper.vm._provided; + provided.registerSortItem(0, 'First', 1); + provided.registerSortItem(1, 'Second', 2); + const secondRow = row('Second'); + wrapper.element.appendChild(secondRow); + + await wrapper.trigger('focusout', { relatedTarget: secondRow }); + + expect(sendPoliteMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DraggableUniverse.spec.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DraggableUniverse.spec.js new file mode 100644 index 0000000000..aa3add5030 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/__tests__/DraggableUniverse.spec.js @@ -0,0 +1,80 @@ +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', () => { + expect(createDraggableUniverse({ delay: 0 }).delay.value).toBe(0); + expect(createDraggableUniverse().delay.value).toBe(250); + }); + + it('tracks later changes to the delay prop', async () => { + let injected = null; + const Child = { + render: () => null, + setup() { + injected = injectDraggableUniverse(); + }, + }; + const wrapper = mount(DraggableUniverse, { + propsData: { delay: 250 }, + slots: { default: Child }, + }); + await wrapper.setProps({ delay: 0 }); + expect(injected.delay.value).toBe(0); + }); + + 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/contentcuration/contentcuration/frontend/shared/views/dragSort/classDefinitions.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/classDefinitions.js new file mode 100644 index 0000000000..3aced79995 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/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/contentcuration/contentcuration/frontend/shared/views/dragSort/domUtils.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/domUtils.js new file mode 100644 index 0000000000..1e1a65194d --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/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/contentcuration/contentcuration/frontend/shared/views/dragSort/dragSortStrings.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/dragSortStrings.js new file mode 100644 index 0000000000..9a8eb58340 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/dragSortStrings.js @@ -0,0 +1,34 @@ +import { createTranslator } from 'shared/i18n'; + +export const dragSortStrings = createTranslator('DragSortStrings', { + moveItemUpLabel: { + message: 'Move {item} up', + context: 'Button label to move a specific item up in a vertical reorderable list', + }, + moveItemDownLabel: { + message: 'Move {item} down', + context: 'Button label to move a specific item down in a vertical reorderable list', + }, + moveItemLeftLabel: { + message: 'Move {item} left', + context: 'Button label to move a specific item left in a horizontal reorderable list', + }, + moveItemRightLabel: { + message: 'Move {item} right', + context: 'Button label to move a specific item right in a horizontal reorderable list', + }, + itemMovedToPosition: { + message: '{item} moved to position {position, number} of {total, number}', + context: 'Live region announcement after moving an item in a reorderable list', + }, + currentOrder: { + message: 'Current order: {order}', + 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/contentcuration/contentcuration/frontend/shared/views/dragSort/draggable.scss b/contentcuration/contentcuration/frontend/shared/views/dragSort/draggable.scss new file mode 100644 index 0000000000..a5039ea132 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/draggable.scss @@ -0,0 +1,59 @@ +@import '~kolibri-design-system/lib/styles/definitions'; + +// here rather than in DraggableHandle because the handle marks an element the +// consumer owns, which is outside the reach of a scoped style there +.draggable-handle { + cursor: grab; +} + +// Content inside a drag item offers no expand affordance: `display: none` takes +// it out of the tab order and the accessibility tree with no JS involved. +// Read by `.expand-overlay` in SafeHtmlImage.vue; AccordionItem.vue's `.content` +// opts back in. +.draggable-item { + --content-affordance-display: none; +} + +.draggable-item--mirror { + @extend %dropshadow-6dp; + + z-index: 8; + cursor: grabbing; + border-radius: $radius; +} + +// `visibility` alone is not enough: a descendant can set `visibility: visible` on +// itself and stay painted over the list — KListWithOverflow does exactly that for +// the TipTap toolbar buttons. `opacity` cannot be overridden from inside. +.draggable-item--ghost { + visibility: hidden; + opacity: 0; +} + +.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); + } +} + +@media (prefers-reduced-motion: reduce) { + .draggable-item--placed { + animation: none; + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/renderSlotRoot.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/renderSlotRoot.js new file mode 100644 index 0000000000..2536ee006e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/renderSlotRoot.js @@ -0,0 +1,34 @@ +import logging from 'shared/logging'; + +/** + * Render a draggable component's slot content as it was written, adding the SortableJS + * marker classes to the consumer's own element rather than wrapping it in one. The + * consumer therefore chooses the element — ``, `
        `, ``, `
      • ` — by + * writing it, and its class, style, and attribute bindings stay on it. + * @param {import('vue').default} vm - the component instance rendering its slot + * @param {string|object|Array} [classes] - classes to add to the rendered element, in + * any form Vue's class binding accepts + * @returns {?import('vue').VNode} the slot's root node, or null when the slot is empty + */ +export default function renderSlotRoot(vm, classes) { + // whitespace text and `v-if` placeholders carry no tag + const nodes = (vm.$slots.default || []).filter(node => node.tag); + if (!nodes.length) { + return null; + } + if (nodes.length > 1) { + logging.error( + new Error( + `<${vm.$options.name}> renders a single root element; the rest of its slot is ignored`, + ), + ); + } + const [root] = nodes; + if (classes) { + // The parent re-renders whenever these components do, so this is applied to a + // fresh vnode each time rather than accumulating on one. + const data = root.data || (root.data = {}); + data.class = [data.class, classes]; + } + return root; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/useDraggableRegion.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/useDraggableRegion.js new file mode 100644 index 0000000000..99ee174087 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/useDraggableRegion.js @@ -0,0 +1,249 @@ +import Sortable from 'sortablejs'; +import { onMounted, onBeforeUnmount, provide, watch } from 'vue'; +import { injectDraggableUniverse, createDraggableUniverse } from './useDraggableUniverse'; +import { DISABLED_CLASS, PLACED_CLASS } from './classDefinitions'; +import { removeNode, insertNodeAt } from './domUtils'; +import { dragSortStrings } from './dragSortStrings'; + +// Default `clone` transform: a copy that is a distinct object but keeps every field +// of the original, identifiers included. Consumers whose clones need independent +// identities pass their own transform. +const shallowClone = original => ({ ...original }); + +/** + * 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 {() => HTMLElement} getRootEl - returns the region's root element; called once + * the component is mounted, since the region renders its consumer's element rather than + * one of its own + * @returns {{ handleStart: Function, handleEnd: Function, canAccept: Function }} the + * drag lifecycle callbacks, exposed for unit tests + */ +export default function useDraggableRegion(props, emit, getRootEl) { + // Regions grouped for cross-region drops share a + const universe = injectDraggableUniverse() || createDraggableUniverse(); + + const { currentOrder$, itemMovedToRegion$ } = dragSortStrings; + + let sortable = null; + + let rootEl = null; + + // frame handle for the deferred focus-exit announcement, null when none is queued + let pendingAnnouncement = 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; + + // exit early if the item was dropped back in its original position + if (from === to && oldDraggableIndex === newDraggableIndex) { + return; + } + + // 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) { + 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]; + if (pullMode === 'clone') { + // a copy, so the two regions never share a reference to the same item + target.insertAt(cloneItem(movedItem), newDraggableIndex); + } else { + target.insertAt(movedItem, newDraggableIndex); + 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 cloneItem(original) { + return typeof props.clone === 'function' ? props.clone(original) : shallowClone(original); + } + + function groupOption() { + return { + name: universe.groupName, + pull: props.clone ? 'clone' : true, + // a closure, so capacity/disabled/accepts are re-read on every drop check + put: canAccept, + }; + } + + // SortableJS copies its options at construction, so anything reactive has to be + // pushed into the instance when it changes (see the watchers below). + function updateOption(name, value) { + if (sortable) { + sortable.option(name, value); + } + } + + watch( + () => props.sortable, + sort => updateOption('sort', sort), + ); + // on the pull mode rather than on `clone` itself, so an inline transform function + // being a new identity on each render does not churn the instance + watch( + () => Boolean(props.clone), + () => updateOption('group', groupOption()), + ); + watch(universe.delay, delay => updateOption('delay', delay)); + + function announceOrder() { + 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 })); + } + + 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 && rootEl.contains(event.relatedTarget)) { + return; + } + // A keyboard move re-renders the region, which detaches the moved row and blurs + // the move button with a null relatedTarget before focus is restored to it. Wait + // a frame and look at where focus actually settled, so a move does not get + // reported as a list-exit — and so the order we read is the post-move one. + cancelPendingAnnouncement(); + pendingAnnouncement = requestAnimationFrame(() => { + pendingAnnouncement = null; + if (!rootEl || !document.hasFocus() || rootEl.contains(document.activeElement)) { + return; + } + announceOrder(); + }); + } + + function cancelPendingAnnouncement() { + if (pendingAnnouncement !== null) { + cancelAnimationFrame(pendingAnnouncement); + pendingAnnouncement = null; + } + } + + // Provided for the a11y move buttons + provide('registerSortItem', (uid, label, position) => { + registeredItems[uid] = { label, position }; + }); + provide('unregisterSortItem', uid => { + delete registeredItems[uid]; + }); + + onMounted(() => { + rootEl = getRootEl(); + universe.registerRegion(rootEl, regionApi); + rootEl.addEventListener('focusout', handleFocusOut); + + sortable = new Sortable(rootEl, { + ...universe.sortableDefaults, + delay: universe.delay.value, + sort: props.sortable, + filter: `.${DISABLED_CLASS}`, + group: groupOption(), + onStart: handleStart, + onEnd: handleEnd, + }); + }); + + onBeforeUnmount(() => { + cancelPendingAnnouncement(); + if (sortable) { + sortable.destroy(); + sortable = null; + } + if (rootEl) { + rootEl.removeEventListener('focusout', handleFocusOut); + universe.unregisterRegion(rootEl); + rootEl = null; + } + }); + + // Exposed for unit tests + return { handleStart, handleEnd, canAccept }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/dragSort/useDraggableUniverse.js b/contentcuration/contentcuration/frontend/shared/views/dragSort/useDraggableUniverse.js new file mode 100644 index 0000000000..f673f29e19 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/dragSort/useDraggableUniverse.js @@ -0,0 +1,103 @@ +import { ref, provide, inject } from 'vue'; +import { v4 as uuidv4 } from 'uuid'; +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'); + +/** + * 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, the way to deliberately share + * one group across separate component trees; defaults to a generated unique id. + * Intentionally initial-value-only: it is read once here, and a universe keeps the + * group name it was created with for its whole lifetime. Regions cannot be moved + * between groups after mount. + * @param {number} [options.delay] - initial press-and-hold delay (ms) before a drag + * begins, touch input only; set `delay.value` on the returned context to change it + * afterwards + * @returns {object} the universe context + */ +export function createDraggableUniverse({ name, delay } = {}) { + const groupName = name || `draggable-universe-${uuidv4()}`; + + // 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(); + + // Reactive so a universe can change the press-and-hold delay after its regions + // have mounted; each region watches this and updates its SortableJS instance. + const dragDelay = ref(delay == null ? 250 : delay); + + const prefersReducedMotion = + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; + + const sortableDefaults = { + forceFallback: true, + fallbackOnBody: false, // keep the clone inside the region subtree so overrides still match + draggable: `.${ITEM_CLASS}`, + handle: `.${HANDLE_CLASS}`, + // A delayed mouse drag is cancelled when the pointer moves, so the delay must + // only apply to touch input. + delayOnTouchOnly: true, + fallbackClass: MIRROR_CLASS, + ghostClass: GHOST_CLASS, + chosenClass: CHOSEN_CLASS, + dragClass: DRAG_CLASS, + animation: prefersReducedMotion ? 0 : 150, + }; + + return { + groupName, + sortableDefaults, + delay: dragDelay, + 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/package.json b/package.json index 9a43d729b4..853a684855 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "pdfjs-dist": "^2.16.105", "qs": "^6.15.2", "regenerator-runtime": "^0.14.1", + "sortablejs": "1.15.7", "spark-md5": "^3.0.0", "store2": "^2.14.4", "string-strip-html": "8.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4154e0a794..45e4e2aab0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: regenerator-runtime: specifier: ^0.14.1 version: 0.14.1 + sortablejs: + specifier: 1.15.7 + version: 1.15.7 spark-md5: specifier: ^3.0.0 version: 3.0.2 @@ -6875,6 +6878,9 @@ packages: resolution: {integrity: sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sortablejs@1.15.7: + resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} + source-list-map@2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} @@ -15871,6 +15877,8 @@ snapshots: ip-address: 9.0.5 smart-buffer: 4.2.0 + sortablejs@1.15.7: {} + source-list-map@2.0.1: {} source-map-js@1.2.1: {}