From f8e605dc11fc6d420e4c844b3a4852beae99f63b Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Tue, 7 Apr 2026 07:29:23 -0500 Subject: [PATCH] Add keyboard controls for drag and drop problems. This is for problmes that use the `dragndrop.js` JavaScript and the `DragNDrop.pm` module via the `draggableProof.pl` and `draggableSubsets.pl` macros. The elements in a drag and drop list can now be focused using tab and shift-tab. Once focused the arrow keys move the elements around. If an element is moved with the keyboard controls the changes are announced in a visually hidden span. Note that drag and drop actions via the mouse cursor are not aria announced for now. I am assuming that a screen reader user would not be using the mouse for drag and drop. There is now also a "Drag and Drop Help" button that is shown below the drag and drop lists. If pressed, help is shown describing the keyboard controls. This help can be customized by the problem. There are new options for the macros that allow for customizing the help and associated texts. See the updated POD in the module and macros for details. Also, I don't like that there are some options that are for the `DragNDrop.pm` package that are passed in from the macros, but are different in the macros than in the module. Basically the first letter is upper case in the macros, but lower case in the module. So the upper case first letter variants is deprecated (but will still work with a compatibility layer). I don't like that the macros use Pascal case to begin with for options. Options should be camel case. Note that I also made all of the texts for the drag and drop object translatable via `maketext`. The texts can be customized per problem as mentioned above, but if only a translation is needed there is no need for such customization. --- htdocs/js/DragNDrop/dragndrop.js | 362 ++++++++++++++++++++++++++--- htdocs/js/DragNDrop/dragndrop.scss | 51 +++- lib/DragNDrop.pm | 155 ++++++++++-- macros/math/draggableProof.pl | 47 ++-- macros/math/draggableSubsets.pl | 134 +++++------ 5 files changed, 609 insertions(+), 140 deletions(-) diff --git a/htdocs/js/DragNDrop/dragndrop.js b/htdocs/js/DragNDrop/dragndrop.js index 4c45cd72c0..233bc4b9cf 100644 --- a/htdocs/js/DragNDrop/dragndrop.js +++ b/htdocs/js/DragNDrop/dragndrop.js @@ -8,29 +8,38 @@ this.answerName = el.dataset.answerName ?? ''; this.buckets = []; - this.removeButtonText = el.dataset.removeButtonText ?? 'Remove'; - this.answerInput = el.parentElement.querySelector(`input[name="${this.answerName}"]`); + this.answerInput = el.parentElement?.querySelector(`input[name="${this.answerName}"]`); if (!this.answerInput) { // This should not happen if using the macros. alert(`FATAL ERROR: Unable to find answer input corresponding to ${this.answerName}.`); return; } - this.bucketContainer = document.createElement('div'); - this.bucketContainer.classList.add('dd-pool-bucket-container'); - el.prepend(this.bucketContainer); + el.role = 'application'; this.itemList = JSON.parse(el.dataset.itemList ?? '[]'); this.defaultState = JSON.parse(el.dataset.defaultState ?? '[]'); + this.showUniversalSet = 'showUniversalSet' in el.dataset; + + // Translatable/customizable text. this.labelFormat = el.dataset.labelFormat; + this.removeButtonText = el.dataset.removeButtonText ?? 'Remove'; + this.universalSetLabel = el.dataset.universalSetLabel ?? 'Universal Set'; + this.addFromUniversalText = + el.dataset.addFromUnversalText ?? 'Item %1s in the universal set added as item %2s to list %3s.'; + this.removeUniversalItemText = el.dataset.removeUniversalItemText ?? 'Item %1s removed from list %2s.'; + this.reorderText = el.dataset.reorderText ?? 'Moved item %1s in list %2s to item %3s.'; + this.moveText = el.dataset.moveText ?? 'Moved item %1s in list %2s to item %3s in list %4s.'; - this.showUniversalSet = 'showUniversalSet' in el.dataset; + this.bucketContainer = document.createElement('div'); + this.bucketContainer.classList.add('dd-pool-bucket-container'); + el.prepend(this.bucketContainer); if (this.answerInput.value) { // Need to check for things like (3,2,1) for backwards compatibility. Now it will be {3,2,1}. - const matches = this.answerInput.value.match(/((?:\{|\()[^\{\}\(\)]*(?:\}|\)))/g); - for (const match of matches) { + const matches = this.answerInput.value.match(/((?:\{|\()[^{}()]*(?:\}|\)))/g); + for (const match of matches ?? []) { const i = this.buckets.length; const bucket = { removable: i < this.defaultState.length ? this.defaultState[i].removable : 1, @@ -39,6 +48,7 @@ .replaceAll(/\{|\}|\(|\)/g, '') .split(',') .filter((index) => index !== '') + .map((i) => parseInt(i)) }; this.buckets.push(new Bucket(this, i, bucket)); } @@ -57,7 +67,7 @@ this.universalSetBucket = new Bucket(this, this.buckets.length, { isUniversalSet: true, removable: false, - label: el.dataset.universalSetLabel ?? 'Universal Set', + label: this.universalSetLabel, indices: this.itemList.map((_el, i) => i) }); } @@ -81,11 +91,136 @@ } this.buckets = []; - for (const bucket of this.defaultState) { + for (const bucket of this.defaultState ?? []) { this.buckets.push(new Bucket(this, this.buckets.length, bucket)); } this.updateAnswerInput(); }); + + this.announcer = document.createElement('div'); + this.announcer.setAttribute('aria-live', 'assertive'); + this.announcer.className = 'visually-hidden'; + el.append(this.announcer); + + el.addEventListener('keydown', (e) => { + if ( + !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) || + !e.target || + !(e.target instanceof HTMLElement) + ) + return; + const item = e.target.closest('.dd-item'); + if (!item || !(item instanceof HTMLElement)) return; + + if (this.universalSetBucket) { + const children = this.universalSetBucket.items; + const index = children.indexOf(item); + if (index !== -1) { + e.preventDefault(); + let toBucketIndex = 0; + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + while ( + toBucketIndex < this.buckets.length && + this.buckets[toBucketIndex].hasItem(item.dataset.id) + ) + ++toBucketIndex; + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + toBucketIndex = this.buckets.length - 1; + while (toBucketIndex >= 0 && this.buckets[toBucketIndex].hasItem(item.dataset.id)) + --toBucketIndex; + } + if (toBucketIndex >= 0 && toBucketIndex < this.buckets.length) { + this.copyUniversalItem(item, this.buckets[toBucketIndex]); + this.announce( + this.addFromUniversalText, + index + 1, + this.buckets[toBucketIndex].items.length, + toBucketIndex + 1 + ); + } + this.updateAnswerInput(); + return; + } + } + + for (const [bucketIndex, bucket] of this.buckets.entries()) { + const children = bucket.items; + const index = children.indexOf(item); + if (index === -1) continue; + + e.preventDefault(); + + if (e.key === 'ArrowUp' && index > 0) { + bucket.swapItems(item, children[index - 1]); + this.announce(this.reorderText, index + 1, bucketIndex + 1, index); + } else if (e.key === 'ArrowDown' && index < children.length - 1) { + bucket.swapItems(item, children[index + 1]); + this.announce(this.reorderText, index + 1, bucketIndex + 1, index + 2); + } else if ( + e.key === 'ArrowLeft' && + (bucketIndex > 0 || (this.universalSetBucket && bucketIndex === 0)) + ) { + let toBucketIndex = bucketIndex - 1; + if (this.universalSetBucket) { + while (toBucketIndex >= 0 && this.buckets[toBucketIndex].hasItem(item.dataset.id)) + --toBucketIndex; + } + if (toBucketIndex >= 0) { + this.moveItem(item, bucket, this.buckets[toBucketIndex]); + this.announce( + this.moveText, + index + 1, + bucketIndex + 1, + this.buckets[toBucketIndex].items.length, + toBucketIndex + 1 + ); + } else if (this.universalSetBucket) { + bucket.removeItem(item); + for (const universalItem of this.universalSetBucket.items) { + if (universalItem.dataset.id === item.dataset.id) { + universalItem.focus(); + break; + } + } + this.announce(this.removeUniversalItemText, index + 1, bucketIndex + 1); + } + } else if ( + e.key === 'ArrowRight' && + (bucketIndex < this.buckets.length - 1 || + (this.universalSetBucket && bucketIndex === this.buckets.length - 1)) + ) { + let toBucketIndex = bucketIndex + 1; + if (this.universalSetBucket) { + while ( + toBucketIndex < this.buckets.length && + this.buckets[toBucketIndex].hasItem(item.dataset.id) + ) + ++toBucketIndex; + } + if (toBucketIndex < this.buckets.length) { + this.moveItem(item, bucket, this.buckets[toBucketIndex]); + this.announce( + this.moveText, + index + 1, + bucketIndex + 1, + this.buckets[toBucketIndex].items.length, + toBucketIndex + 1 + ); + } else if (this.universalSetBucket) { + bucket.removeItem(item); + for (const universalItem of this.universalSetBucket.items) { + if (universalItem.dataset.id === item.dataset.id) { + universalItem.focus(); + break; + } + } + this.announce(this.removeUniversalItemText, index + 1, bucketIndex + 1); + } + } + this.updateAnswerInput(); + break; + } + }); } updateAnswerInput() { @@ -98,6 +233,142 @@ this.answerInput.value = '(' + contents.join(',') + ')'; } + + announce(message, ...replacements) { + let interpolatedMessage = message; + for (const interpolation of message.matchAll(/(%(\d)s)/g)) { + const position = parseInt(interpolation[2]) - 1; + interpolatedMessage = interpolatedMessage.replace(interpolation[1], replacements[position]); + } + this.announcer.textContent = interpolatedMessage; + } + + // Move a list item from one bucket to another and animate the changes to the DOM. + moveItem(element, fromBucket, toBucket) { + const siblings = fromBucket.items.filter((e) => e !== element); + + const fromBucketHeight = fromBucket.el.getBoundingClientRect().height; + const toBucketHeight = toBucket.el.getBoundingClientRect().height; + const elementRect = element.getBoundingClientRect(); + const siblingRects = siblings.map((e) => e.getBoundingClientRect()); + + toBucket.ddList.append(element); + element.focus(); + + const newFromBucketHeight = fromBucket.el.getBoundingClientRect().height; + const newToBucketHeight = toBucket.el.getBoundingClientRect().height; + const newElementRect = element.getBoundingClientRect(); + const newSiblingRects = siblings.map((e) => e.getBoundingClientRect()); + + requestAnimationFrame(() => { + for (const [bucket, height, newHeight] of [ + [fromBucket, fromBucketHeight, newFromBucketHeight], + [toBucket, toBucketHeight, newToBucketHeight] + ]) { + bucket.el.animate([{ height: `${height}px` }, { height: `${newHeight}px` }], { + duration: 150, + easing: 'ease' + }); + } + + element.style.position = 'fixed'; + element.style.top = `${newElementRect.top}px`; + element.style.left = `${newElementRect.left}px`; + element.style.width = `${newElementRect.width}px`; + element.style.pointerEvents = 'none'; + + element + .animate( + [ + { + transformOrigin: 'top left', + transform: `translate(${ + elementRect.left - newElementRect.left + }px, ${elementRect.top - newElementRect.top}px)` + }, + { transformOrigin: 'top left', transform: 'none' } + ], + { duration: 150, easing: 'ease' } + ) + .finished.then(() => { + element.style.position = ''; + element.style.top = ''; + element.style.left = ''; + element.style.width = ''; + element.style.pointerEvents = ''; + element.focus(); + }) + .catch(() => { + /* ignore */ + }); + + for (const [index, sibling] of siblings.entries()) { + sibling.animate( + [ + { + transformOrigin: 'top left', + transform: `translate(${ + siblingRects[index].left - newSiblingRects[index].left + }px, ${siblingRects[index].top - newSiblingRects[index].top}px)` + }, + { transformOrigin: 'top left', transform: 'none' } + ], + { duration: 150, easing: 'ease' } + ); + } + }); + } + + // Copy an item from the universal set bucket to another bucket and animate it moving there. + copyUniversalItem(element, toBucket) { + const toBucketHeight = toBucket.el.getBoundingClientRect().height; + const elementRect = element.getBoundingClientRect(); + + const elementCopy = element.cloneNode(true); + toBucket.ddList.append(elementCopy); + elementCopy.focus(); + + const newToBucketHeight = toBucket.el.getBoundingClientRect().height; + const newElementRect = elementCopy.getBoundingClientRect(); + + requestAnimationFrame(() => { + toBucket.el.animate([{ height: `${toBucketHeight}px` }, { height: `${newToBucketHeight}px` }], { + duration: 150, + easing: 'ease' + }); + + elementCopy.style.position = 'fixed'; + elementCopy.style.top = `${newElementRect.top}px`; + elementCopy.style.left = `${newElementRect.left}px`; + elementCopy.style.width = `${newElementRect.width}px`; + elementCopy.style.pointerEvents = 'none'; + + elementCopy + .animate( + [ + { + transformOrigin: 'top left', + transform: `translate(${ + elementRect.left - newElementRect.left + }px, ${elementRect.top - newElementRect.top}px)` + }, + { transformOrigin: 'top left', transform: 'none' } + ], + { duration: 150, easing: 'ease' } + ) + .finished.then(() => { + elementCopy.style.position = ''; + elementCopy.style.top = ''; + elementCopy.style.left = ''; + elementCopy.style.width = ''; + elementCopy.style.pointerEvents = ''; + elementCopy.focus(); + }) + .catch(() => { + /* ignore */ + }); + }); + } } class Bucket { @@ -105,20 +376,25 @@ this.id = id; this.bucketPool = bucketPool; - this.el = this.htmlBucket(bucketData.label, bucketData.removable, bucketData.indices); + this.el = this.htmlBucket(bucketData.label ?? '', bucketData.removable ?? 0, bucketData.indices); - if (bucketData.isUniversalSet) bucketPool.universalSetContainer.append(this.el); + if (bucketData.isUniversalSet) bucketPool.universalSetContainer?.append(this.el); else bucketPool.bucketContainer.append(this.el); // Typeset any math content that may be in the added html. if (window.MathJax) { - MathJax.startup.promise = MathJax.startup.promise.then(() => MathJax.typesetPromise([this.el])); + window.MathJax.startup.promise = window.MathJax.startup.promise.then(() => + window.MathJax.typesetPromise([this.el]) + ); } const options = { group: { name: bucketPool.answerName }, animation: 150, - onEnd: () => this.bucketPool.updateAnswerInput() + onEnd: (evt) => { + evt.item.focus(); + this.bucketPool.updateAnswerInput(); + } }; if (bucketPool.showUniversalSet) { @@ -128,23 +404,21 @@ options.group.put = false; } else { options.removeOnSpill = true; - options.group.put = (to, _from, dragEl) => - !Array.from(to.el.children).some((child) => child.dataset.id === dragEl.dataset.id); + options.group.put = (to, _from, dragEl) => !to.toArray().some((id) => id === dragEl.dataset.id); } } this.sortable = Sortable.create(this.ddList, options); } - htmlBucket(label, removable, indices = []) { + htmlBucket(label, removable, indices) { const bucketElement = document.createElement('div'); bucketElement.classList.add('dd-bucket'); const bucketLabel = document.createElement('div'); bucketLabel.classList.add('dd-bucket-label'); bucketLabel.innerHTML = - label || - (this.bucketPool.labelFormat ? `${this.bucketPool.labelFormat.replace(/%s/, this.id + 1)}` : ''); + label || (this.bucketPool.labelFormat ? this.bucketPool.labelFormat.replace(/%s/, this.id + 1) : ''); this.ddList = document.createElement('div'); this.ddList.classList.add('dd-list'); @@ -152,17 +426,20 @@ bucketElement.append(bucketLabel, this.ddList); for (const index of indices) { - if (index < 0 || index > this.bucketPool.itemList.length) continue; + if (index < 0 || index > (this.bucketPool.itemList?.length ?? 0)) continue; const listElement = document.createElement('div'); listElement.classList.add('dd-item'); + listElement.role = 'button'; + listElement.draggable = true; + listElement.tabIndex = 0; listElement.dataset.id = index; - listElement.innerHTML = this.bucketPool.itemList[index]; + listElement.innerHTML = this.bucketPool.itemList?.[index] ?? ''; this.ddList.append(listElement); } - bucketElement.style.backgroundColor = `hsla(${(100 + this.id * 100) % 360}, 40%, 90%, 1)`; + bucketElement.style.backgroundColor = `hsl(${(100 + this.id * 100) % 360} 40% 90%)`; // The first bucket is not allowed to be removable. if (this.id !== 0 && removable) { @@ -172,12 +449,10 @@ removeButton.textContent = this.bucketPool.removeButtonText; removeButton.addEventListener('click', () => { - const firstBucketList = this.bucketPool.buckets[0].ddList; - const firstBucketListItems = Array.from(firstBucketList.querySelectorAll('.dd-item')).map( - (item) => item.dataset.id - ); - for (const item of this.ddList.querySelectorAll('.dd-item')) { - if (!firstBucketListItems.includes(item.dataset.id)) firstBucketList.append(item); + const firstBucketListItemIds = this.bucketPool.buckets[0].sortable.toArray(); + for (const item of this.items) { + if (typeof item.dataset.id !== 'undefined' && !firstBucketListItemIds.includes(item.dataset.id)) + this.bucketPool.buckets[0].ddList.append(item); } bucketElement.remove(); @@ -191,6 +466,35 @@ return bucketElement; } + + get items() { + return Array.from(this.ddList.children); + } + + hasItem(id) { + return this.items.some((i) => i.dataset.id === id); + } + + // Swap the position of two list items and animate the change. + swapItems(element1, element2) { + const listIds = this.sortable.toArray(); + const element1Index = listIds.findIndex((i) => i === element1.dataset.id); + const element2Index = this.sortable.toArray().findIndex((i) => i === element2.dataset.id); + if (element1Index === -1 || element2Index === -1) return; + [listIds[element1Index], listIds[element2Index]] = [listIds[element2Index], listIds[element1Index]]; + this.sortable.sort(listIds, true); + element1.focus(); + } + + // Remove a list item and animate the remaining list items moving up to fill its place. + removeItem(element) { + const listIds = this.sortable.toArray(); + const elementIndex = listIds.findIndex((i) => i === element.dataset.id); + if (elementIndex === -1) return; + listIds.splice(elementIndex, 1); + this.sortable.sort(listIds, true); + element.remove(); + } } // Set up bucket pools that are already in the page. @@ -202,7 +506,7 @@ const observer = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { for (const node of mutation.addedNodes) { - if (node instanceof Element) { + if (node instanceof HTMLElement) { if (node.classList.contains('dd-bucket-pool')) { if (!node.dataset.bucketPoolInitialized) new BucketPool(node); } else { diff --git a/htdocs/js/DragNDrop/dragndrop.scss b/htdocs/js/DragNDrop/dragndrop.scss index 54229e2ead..8dee4a354d 100644 --- a/htdocs/js/DragNDrop/dragndrop.scss +++ b/htdocs/js/DragNDrop/dragndrop.scss @@ -1,8 +1,7 @@ .dd-bucket-pool { .dd-pool-bucket-container { display: flex; - flex-direction: row; - flex-wrap: wrap; + flex-flow: row wrap; justify-content: space-evenly; gap: 1rem; margin-bottom: 1rem; @@ -12,16 +11,19 @@ flex-direction: column; width: 350px; padding: 0.5rem; - color: #000000; + color: #000; border: 1px solid #388e8e; border-radius: 5px; text-align: center; .dd-bucket-label { - margin: 0 0 10px 0; + margin: 0 0 10px; } .dd-list { + list-style: none; + margin: 0; + padding: 0; display: flex; flex-direction: column; flex-grow: 1; @@ -48,11 +50,28 @@ text-align: center; height: auto; - &:hover { + &:hover:not(.sortable-chosen) { cursor: pointer; background: #eee3ce; color: #222; } + + &:focus { + border-color: blue; + box-shadow: inset 0 1px 0 #ffffff26; + filter: drop-shadow(0px 5px 10px rgb(0 0 255 / 75%)); + outline: 0; + } + + &.sortable-ghost { + background: #f5f5f5; + border-color: blue; + box-shadow: inset 0 1px 0 #ffffff26; + filter: drop-shadow(0px 5px 10px rgb(0 0 255 / 75%)); + + // Override an opacity setting set by webwork2 for the problem set detail page. + opacity: 1 !important; + } } } } @@ -69,4 +88,26 @@ margin-top: 0.5rem; align-self: center; } + + .dd-keyboard-help { + summary { + list-style: none; + } + + .dd-keyboard-help-content { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: max-content; + max-width: 750px; + box-shadow: 0 0 100px black; + border-radius: 0.375rem; + z-index: 10; + + @media only screen and (width <= 768px) { + max-width: 90%; + } + } + } } diff --git a/lib/DragNDrop.pm b/lib/DragNDrop.pm index 0f3e929d55..d71589bee2 100644 --- a/lib/DragNDrop.pm +++ b/lib/DragNDrop.pm @@ -65,8 +65,8 @@ drag and drop bucket when clicked on. If the C option is defined, then buckets for which an explicit label is not provided will be will be created with the label with the C<%s> in the string replaced with the bucket number in the pool. This also -applies to new buckets that are added by JavaScript. An example value for this -option is C<< 'Subset %s' >>. +applies to new buckets that are added by the user via JavaScript if +C is 1. An example value for this option is C<'Subset %s'>. =item resetButtonText (Default: C<< 'Reset' >>) @@ -98,6 +98,77 @@ buckets, but not into it. Label shown for the universal set bucket if C is 1. +=item addFromUnversalText + +The aria announcement text format that is used when a universal set item is +added to a bucket via keyboard controls. The default format for this text is +C<'Item %1s in the universal set added as item %2s to list %3s.'>. Note that if +this is customized it must contain C<%1s>, C<%2s>, and C<%3s> as in the default +value. + +=item removeUniversalItemText + +The aria announcement text format that is used when an item from the universal +set that is in a bucket is removed via keyboard controls. The default format for +this text is C<'Item %1s removed from list %2s.'>. Note that if this is +customized it must contain C<%1s> and C<%2s> as in the default value. + +=item reorderText + +The aria announcement text format that is used when an item is moved up or down +in a bucket via keyboard controls. The default format for this text is +C<'Moved item %1s in list %2s to item %3s.'>. Note that if this is customized +it must contain C<%1s>, C<%2s>, and C<%3s> as in the default value. + +=item moveText + +The aria announcement text format that is used when an item is moved from one +bucket to another via keyboard controls. The default format for this text is +C<'Moved item %1s in list %2s to item %3s in list %4s.'>. Note that if this is +customized it must contain C<%1s>, C<%2s>, C<%3s>, and C<%4s> as in the default +value. + +=item helpButtonText (Default: C<'Drag and Drop Help'>) + +The text shown on the button that opens the drag and drop help. + +=item closeHelpButtonText (Default: C<'Close Help'>) + +The text shown on the button that closes the drag and drop help. + +=item dragAndDropHelpText + +The help that is shown when the drag and drop help button is pressed. + +The default text is + +=over 4 + +Drag to reorganize items within lists or to move items to a different list. Tab +and shift-tab can be used to focus list items. The left and right arrow keys +move a focused list item to the list to the left or right. The up and down +arrow keys move a focused list item up and down inside a list. + +=back + +=item universalSetHelpText + +If the option C is set to 1, then this is shown before the +C in the help. + +The default text for this is + +=over 4 + +Drag items in the universal set to copy them to a list. Tab and shift-tab can +be used to focus universal set items. A focused item in the universal set can +be added to the first list with the right or down arrow keys, or added to the +last list with the left or up arrow keys. A focused item in a list can be +removed by using the left or right arrow key until it returns to the universal +set. + +=back + =back =head2 METHODS @@ -138,18 +209,41 @@ use PGcore; sub new { my ($self, $answerName, $itemList, $defaultBuckets, %options) = @_; + my $PG = eval('$main::PG'); + return bless { - answerName => $answerName, - itemList => $itemList, - defaultBuckets => $defaultBuckets, - allowNewBuckets => 0, - bucketLabelFormat => undef, - resetButtonText => 'Reset', - addButtonText => 'Add Bucket', - removeButtonText => 'Remove', - multicolsWidth => '300pt', - showUniversalSet => 0, - universalSetLabel => 'Universal Set', + answerName => $answerName, + itemList => $itemList, + defaultBuckets => $defaultBuckets, + allowNewBuckets => 0, + bucketLabelFormat => undef, + resetButtonText => $PG->maketext('Reset'), + addButtonText => $PG->maketext('Add Bucket'), + removeButtonText => $PG->maketext('Remove'), + multicolsWidth => '300pt', + showUniversalSet => 0, + universalSetLabel => $PG->maketext('Universal Set'), + addFromUnversalText => + $PG->maketext('Item [_1] in the universal set added as item [_2] to list [_3].', '%1s', '%2s', '%3s'), + removeUniversalItemText => $PG->maketext('Item [_1] removed from list [_2].', '%1s', '%2s'), + reorderText => $PG->maketext('Moved item [_1] in list [_2] to item [_3].', '%1s', '%2s', '%3s'), + moveText => + $PG->maketext('Moved item [_1] in list [_2] to item [_3] in list [_4].', '%1s', '%2s', '%3s', '%4s'), + helpButtonText => $PG->maketext('Drag and Drop Help'), + closeHelpButtonText => $PG->maketext('Close Help'), + dragAndDropHelpText => $PG->maketext( + 'Drag to reorganize items within lists or to move items to a different list. ' + . 'Tab and shift-tab can be used to focus list items. ' + . 'The left and right arrow keys move a focused list item to the list to the left or right. ' + . 'The up and down arrow keys move a focused list item up and down inside a list.' + ), + universalSetHelpText => $PG->maketext( + 'Drag items in the universal set to copy them to a list. ' + . 'Tab and shift-tab can be used to focus universal set items. ' + . 'A focused item in the universal set can be added to the first list with the right or down arrow ' + . 'keys, or added to the last list with the left or up arrow keys. A focused item in a list can ' + . 'be removed by using the left or right arrow key until it returns to the universal set.' + ), %options, }, ref($self) || $self; @@ -165,6 +259,10 @@ sub HTML { $out .= qq{ data-label-format="$self->{bucketLabelFormat}"} if $self->{bucketLabelFormat}; $out .= " data-show-universal-set" if $self->{showUniversalSet}; $out .= ' data-universal-set-label="' . PGcore::encode_pg_and_html($self->{universalSetLabel}) . '"'; + $out .= ' data-add-from-universal-text="' . PGcore::encode_pg_and_html($self->{addFromUnversalText}) . '"'; + $out .= ' data-remove-universal-item-text="' . PGcore::encode_pg_and_html($self->{removeUniversalItemText}) . '"'; + $out .= ' data-reorder-text="' . PGcore::encode_pg_and_html($self->{reorderText}) . '"'; + $out .= ' data-move-text="' . PGcore::encode_pg_and_html($self->{moveText}) . '"'; $out .= '>'; $out .= '
$self->{resetButtonText}}; $out .= qq{} if ($self->{allowNewBuckets}); - $out .= '
'; + $out .= ''; + + $out .= + '
' + . '} + . qq{' + . '
'; + + $out .= ''; return $out; } diff --git a/macros/math/draggableProof.pl b/macros/math/draggableProof.pl index 1877a4d22f..1ec99f261e 100644 --- a/macros/math/draggableProof.pl +++ b/macros/math/draggableProof.pl @@ -66,19 +66,26 @@ =head1 DESCRIPTION DamerauLevenshtein => 0 or 1 InferenceMatrix => IrrelevancePenalty => - ResetButtonText => + cmpOptions => -Their usage is explained in the example below. + resetButtonText => + reorderText => + moveText => + helpButtonText => + closeHelpButtonText => + dragAndDropHelpText => + +The last six options above are really options of a C object and are +passed to that module on construction. See the L +for details on those options. Note that C is a deprecated alias +for the C option. + +The usage of all but the last six options are demonstrated in the example below. =head1 SYNOPSIS DOCUMENT(); - loadMacros( - 'PGstandard.pl', - 'PGML.pl', - 'MathObjects.pl', - 'draggableProof.pl' - ); + loadMacros('PGstandard.pl', 'PGML.pl', 'draggableProof.pl', 'PGcourse.pl'); $draggable = DraggableProof( # The proof given in the correct order. @@ -138,11 +145,6 @@ =head1 SYNOPSIS # The default value if not given is 1. IrrelevancePenalty => 1 - # This is the text label for the button shown that resets the drag and - # drop element to its default state. The default value if not given is - # "Reset". - ResetButtonText => 'zurücksetzen' - # These are options that will be passed to the $draggable->cmp method. cmpOptions => { checker => sub { ... } } ); @@ -203,7 +205,6 @@ sub new { NumBuckets => 2, lines => [ @$statements, @$extra_statements ], numNeeded => scalar(@$statements), - ResetButtonText => 'Reset', cmpOptions => {}, Levenshtein => 0, DamerauLevenshtein => 0, @@ -212,6 +213,9 @@ sub new { %options }; + # Backwards compatibility. + $base->{resetButtonText} = delete $base->{ResetButtonText} if defined $base->{ResetButtonText}; + $base->{order} = do { my @indices = 0 .. $#{ $base->{lines} }; [ map { splice(@indices, main::random(0, $#indices), 1) } @indices ]; @@ -270,6 +274,16 @@ sub ANS_NAME { sub ans_rule { my $self = shift; + my %options; + + for my $option ( + 'resetButtonText', 'reorderText', 'moveText', 'helpButtonText', + 'closeHelpButtonText', 'dragAndDropHelpText' + ) + { + $options{$option} = $self->{$option} if defined $self->{$option}; + } + if ($self->{NumBuckets} == 2) { $self->{dnd} = DragNDrop->new( $self->ANS_NAME, @@ -278,14 +292,13 @@ sub ans_rule { { indices => [ 0 .. $#{ $self->{lines} } ], label => $self->{SourceLabel} }, { indices => [], label => $self->{TargetLabel} } ], - resetButtonText => $self->{ResetButtonText} + %options ); } elsif ($self->{NumBuckets} == 1) { $self->{dnd} = DragNDrop->new( $self->ANS_NAME, $self->{shuffledLines}, - [ { indices => [ 0 .. $#{ $self->{lines} } ], label => $self->{TargetLabel} } ], - resetButtonText => $self->{ResetButtonText} + [ { indices => [ 0 .. $#{ $self->{lines} } ], label => $self->{TargetLabel} } ], %options ); } diff --git a/macros/math/draggableSubsets.pl b/macros/math/draggableSubsets.pl index 0c0c7bc77e..340a0058a4 100644 --- a/macros/math/draggableSubsets.pl +++ b/macros/math/draggableSubsets.pl @@ -66,24 +66,38 @@ =head1 DESCRIPTION DefaultSubsets => OrderedSubsets => 0 or 1 - AllowNewBuckets => 0 or 1 - BucketLabelFormat => - ResetButtonText => - AddButtonText => - RemoveButtonText => - ShowUniversalSet => 0 or 1 - UniversalSetLabel => - -Their usage is demonstrated in the example below. + cmpOptions => + + allowNewBuckets => 0 or 1 + bucketLabelFormat => + resetButtonText => + addButtonText => + removeButtonText => + showUniversalSet => 0 or 1 + universalSetLabel => + addFromUnversalText => + removeUniversalItemText => , + reorderText => + moveText => + helpButtonText => + closeHelpButtonText => + dragAndDropHelpText => + +All of the options above except for the first three are really options of a +C object and are passed to that module on construction. See the +L for details on those options. Note that +C, C, C, C, +C, C, and C are +deprecated aliases for the corresponding option with the first letter lower +case. Note that the default value of C is 1 for this macro (it +is 0 for the C package). + +The usage of the first three options is demonstrated in the example below. =head1 SYNOPSIS DOCUMENT(); - loadMacros( - 'PGstandard.pl', - 'PGML.pl', - 'draggableSubsets.pl' - ); + loadMacros('PGstandard.pl', 'PGML.pl', 'draggableSubsets.pl', 'PGcourse.pl'); $draggable = DraggableSubsets( # Full set. Make sure to use "\(...\)" for math and not "`...`" for correct display. @@ -129,42 +143,6 @@ =head1 SYNOPSIS # The default value if not given is 0. OrderedSubsets => 0, - # 0 means no new buckets may be added by student. 1 means otherwise. - # The default value if not given is 1. - AllowNewBuckets => 1, - - # If this option is defined then labels for buckets for which a specific - # label is not provided will be created by replacing %s with the bucket - # number to this prefix. These labels will also be used for buckets - # added by the user if AllowNewBuckets is 1. This string should contain - # exactly one instance of %s. The default value if not given is - # undefined. - BucketLabelFormat => 'Subset %s' - - # This is the text label for the button shown that resets the drag and - # drop element to its default state. The default value if not given is - # "Reset". - ResetButtonText => 'zurücksetzen' - - # This is the text label for the button shown that adds new buckets. - # The button is only shown if AllowNewBuckets is 1. - # The default value if not given is "Add Bucket". - AddButtonText => 'Add Subset' - - # This is the text label for the remove button that is added to any - # removable buckets. The default value if not given is "Remove". - RemoveButtonText => 'Delete' - - # If this is true then a separate bucket containing the full set passed - # as the first argument above (the universal set) will be shown, and the - # elements of the set can be distributed to the other subsets (or - # buckets) that are shown. The default value if not given is 0. - ShowUniversalSet => 1, - - # Label for the bucket representing the universal set. - # The default value if not given is "Universal Set". - UniversalSetLabel => 'Universal Set', - # These are options that will be passed to the $draggable->cmp method. cmpOptions => { checker => sub { ... } } ); @@ -214,23 +192,26 @@ sub new { my ($invocant, $set, $subsets, %options) = @_; my $base = bless { - set => $set, - DefaultSubsets => [], - OrderedSubsets => 0, - AllowNewBuckets => 1, - cmpOptions => {}, - BucketLabelFormat => undef, - ResetButtonText => 'Reset', - AddButtonText => 'Add Bucket', - RemoveButtonText => 'Remove', - ShowUniversalSet => 0, - UniversalSetLabel => 'Universal Set', + set => $set, + DefaultSubsets => [], + OrderedSubsets => 0, + allowNewBuckets => 1, + cmpOptions => {}, %options }, ref($invocant) || $invocant; Value::Error('Answer subsets must be an array reference.') unless ref($subsets) eq 'ARRAY'; + # Backwards compatibility. + for my $option ( + 'AllowNewBuckets', 'BucketLabelFormat', 'ShowUniversalSet', 'ResetButtonText', + 'AddButtonText', 'RemoveButtonText', 'UniversalSetLabel' + ) + { + $base->{ lcfirst($option) } = delete $base->{$option} if defined $base->{$option}; + } + my %seenIndices; for my $subset (@$subsets) { Value::Error('Each answer subset must be a reference to an array of indices.') @@ -238,8 +219,8 @@ sub new { for (@$subset) { Value::Error('An index in an answer subset is out of range.') unless $_ < @$set; Value::Error('An index is repeated in multiple answer subsets. ' - . 'This can only be the case if ShowUniversalSet is 1.') - if !$base->{ShowUniversalSet} && $seenIndices{$_}; + . 'This can only be the case if showUniversalSet is 1.') + if !$base->{showUniversalSet} && $seenIndices{$_}; $seenIndices{$_} = 1; } } @@ -255,8 +236,8 @@ sub new { for (@{ $subset->{indices} }) { Value::Error('An index in a default subset is out of range.') unless $_ < @$set; Value::Error('An index is repeated in multiple default subsets.' - . 'This can only be the case if ShowUniversalSet is 1.') - if !$base->{ShowUniversalSet} && $seenIndices{$_}; + . 'This can only be the case if showUniversalSet is 1.') + if !$base->{showUniversalSet} && $seenIndices{$_}; $seenIndices{$_} = 1; } } @@ -327,16 +308,19 @@ sub ans_rule { push(@buckets, { label => '', indices => [ 0 .. $#{ $self->{set} } ] }); } - $self->{dnd} = DragNDrop->new( - $self->ANS_NAME, $self->{shuffledSet}, \@buckets, - allowNewBuckets => $self->{AllowNewBuckets}, - bucketLabelFormat => $self->{BucketLabelFormat}, - resetButtonText => $self->{ResetButtonText}, - addButtonText => $self->{AddButtonText}, - removeButtonText => $self->{RemoveButtonText}, - showUniversalSet => $self->{ShowUniversalSet}, - universalSetLabel => $self->{UniversalSetLabel}, - ); + my %options; + + for my $option ( + 'allowNewBuckets', 'bucketLabelFormat', 'resetButtonText', 'addButtonText', + 'removeButtonText', 'showUniversalSet', 'universalSetLabel', 'addFromUnversalText', + 'removeUniversalItemText', 'reorderText', 'moveText', 'helpButtonText', + 'closeHelpButtonText', 'dragAndDropHelpText' + ) + { + $options{$option} = $self->{$option} if defined $self->{$option}; + } + + $self->{dnd} = DragNDrop->new($self->ANS_NAME, $self->{shuffledSet}, \@buckets, %options); my $ans_rule = main::NAMED_HIDDEN_ANS_RULE($self->ANS_NAME); if ($main::displayMode eq 'TeX') {