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') {