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/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';