diff --git a/scripts/langindex.json b/scripts/langindex.json index a271644f8..302324170 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1147,6 +1147,7 @@ "addon.privatefiles.sitefiles": "moodle", "addon.qtype_essay.maxwordlimitboundary": "qtype_essay", "addon.qtype_essay.minwordlimitboundary": "qtype_essay", + "addon.qtype_ordering.moved": "qtype_ordering", "addon.report_insights.actionsaved": "report_insights", "addon.report_insights.fixedack": "analytics", "addon.report_insights.incorrectlyflagged": "analytics", @@ -2247,6 +2248,8 @@ "core.mod_workshop": "workshop/pluginname", "core.moduleintro": "moodle", "core.more": "moodle/moremenu", + "core.movedown": "moodle", + "core.moveup": "moodle", "core.mygroups": "group", "core.name": "moodle", "core.needhelp": "local_moodlemobileapp", diff --git a/src/addons/qtype/ordering/component/addon-qtype-ordering.html b/src/addons/qtype/ordering/component/addon-qtype-ordering.html new file mode 100644 index 000000000..79ca41eab --- /dev/null +++ b/src/addons/qtype/ordering/component/addon-qtype-ordering.html @@ -0,0 +1,55 @@ +@if (question && (question.text || question.text === '')) { +
+ + + + + + + @if (a11yAnnouncement) { +
{{ a11yAnnouncement }}
+ } + + + @for (item of question.items; track item.id) { + + + + + } + + + + +
+} diff --git a/src/addons/qtype/ordering/component/ordering.scss b/src/addons/qtype/ordering/component/ordering.scss new file mode 100644 index 000000000..779819d0e --- /dev/null +++ b/src/addons/qtype/ordering/component/ordering.scss @@ -0,0 +1,81 @@ +@use "theme/globals" as *; + +:host { + ion-reorder-group .item { + ion-label { + display: list-item; + list-style-position: inside; + list-style-type: none; + } + + .core-correct-icon { + @include margin-horizontal(0px, 8px); + vertical-align: middle; + } + } + + ion-reorder-group.numbering123 .item ion-label { + list-style-type: decimal; + } + ion-reorder-group.numberingabc .item ion-label { + list-style-type: lower-alpha; + } + ion-reorder-group.numberingABCD .item ion-label { + list-style-type: upper-alpha; + } + ion-reorder-group.numberingiii .item ion-label { + list-style-type: lower-roman; + } + ion-reorder-group.numberingIIII .item ion-label { + list-style-type: upper-roman; + } + + ion-reorder-group ion-card { + contain: layout paint; // This is needed to make list-style to work properly (otherwise all items were 1/a/i). + + &:first-of-type [data-action="move-backward"], + &:last-of-type [data-action="move-forward"] { + visibility: hidden; + } + + &.core-question-answer-correct { + --border-color: var(--core-question-correct-color); + .item { + --color: var(--core-question-correct-color); + --background: var(--core-question-correct-color-bg); + } + } + + &.core-question-answer-incorrect { + --border-color: var(--core-question-incorrect-color); + .item { + --color: var(--core-question-incorrect-color); + --background: var(--core-question-incorrect-color-bg); + } + } + + &.core-question-answer-partial66 { + --border-color: #ff9900; + .item { + --color: var(--core-question-correct-color); + --background: var(--core-question-correct-color-bg); + } + } + + &.core-question-answer-partial33 { + --border-color: #ff9900; + .item { + --color: var(--core-question-feedback-color); + --background: var(--core-question-feedback-color-bg); + } + } + + &.core-question-answer-partial00 { + --border-color: #ff9900; + .item { + --color: var(--core-question-incorrect-color); + --background: var(--core-question-incorrect-color-bg); + } + } + } +} diff --git a/src/addons/qtype/ordering/component/ordering.ts b/src/addons/qtype/ordering/component/ordering.ts new file mode 100644 index 000000000..5bd693420 --- /dev/null +++ b/src/addons/qtype/ordering/component/ordering.ts @@ -0,0 +1,200 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ElementRef } from '@angular/core'; +import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreDomUtils } from '@services/utils/dom'; +import { ItemReorderEventDetail } from '@ionic/angular'; +import { Translate } from '@singletons'; +import { CoreUtils } from '@services/utils/utils'; +import { CorePlatform } from '@services/platform'; + +/** + * Component to render an ordering question. + */ +@Component({ + selector: 'addon-qtype-ordering', + templateUrl: 'addon-qtype-ordering.html', + styleUrls: ['../../../../core/features/question/question.scss', 'ordering.scss'], +}) +export class AddonQtypeOrderingComponent extends CoreQuestionBaseComponent { + + dragDisabled = false; + numberingClass = ''; + a11yAnnouncement = ''; + responseInput = { + name: '', + value: '', + }; + + constructor(elementRef: ElementRef) { + super('AddonQtypeOrderingComponent', elementRef); + } + + /** + * @inheritdoc + */ + init(): void { + if (!this.question) { + return; + } + + const questionElement = this.initComponent(); + if (!questionElement) { + return; + } + + // Replace Moodle's feedback classes with our own. + CoreQuestionHelper.replaceFeedbackClasses(questionElement); + + // Find the list and its items. + const listContainer = questionElement.querySelector('.sortablelist'); + if (!listContainer) { + this.logger.warn('Aborting because of an error parsing question.', this.question.slot); + + return CoreQuestionHelper.showComponentError(this.onAbort); + } + + this.dragDisabled = listContainer.classList.contains('notactive') || !listContainer.querySelector('.sortableitem'); + this.numberingClass = Array.from(listContainer.classList).find(className => className.startsWith('numbering')) ?? ''; + + const itemsElements = Array.from(listContainer.querySelectorAll('li')); + this.question.items = itemsElements.map(element => { + // Remove correctness icons from the content. + const itemContentEl = element.querySelector('[data-itemcontent]'); + itemContentEl?.querySelector('.icon.fa-check, .icon.fa-remove, .icon.fa-check-square')?.remove(); + + return { + id: element.id, + content: itemContentEl?.innerHTML ?? '', + contentText: itemContentEl?.innerText ?? '', + correctClass: Array.from(element.classList) + .find(className => className.includes('correct') || className.includes('partial')) ?? 'pending', + }; + }); + + // Find the input where the answer is stored. + const inputEl = questionElement.querySelector('input[name*="_response_"]'); + if (inputEl) { + this.responseInput.name = inputEl.name; + this.responseInput.value = inputEl.value; + } + + // Re-calculate the text of the question, removing the elements that the app already renders. + questionElement.querySelector('.ablock')?.remove(); + inputEl?.remove(); + this.question.text = CoreDomUtils.getContentsOfElement(questionElement, '.qtext'); + } + + /** + * Reorder items list. + * + * @param eventDetail Details of the reorder. + */ + moveItem(eventDetail: ItemReorderEventDetail): void { + if (!this.question?.items) { + return; + } + + const itemToMove = this.question.items.splice(eventDetail.from, 1)[0]; + this.question.items.splice(eventDetail.to, 0, itemToMove); + + this.responseInput.value = this.question.items.map(item => item.id).join(','); + + this.a11yAnnouncement = Translate.instant('addon.qtype_ordering.moved', { + $a: { + item: this.hostElement.querySelector(`#${itemToMove.id}-text`)?.innerText ?? itemToMove.id, + position: eventDetail.to + 1, + total: this.question.items.length, + }, + }); + + eventDetail.complete(); + } + + /** + * Move an item to the previous or next position. + * + * @param event Event. + * @param moveNext Whether to move to the next position or the previous position. + * @param itemId Item ID. + */ + async moveItemByClick(event: Event, moveNext: boolean, itemId: string): Promise { + event.preventDefault(); + event.stopPropagation(); + + const target = event.target as HTMLElement; + if (!target || !this.question?.items) { + return; + } + + const initialPosition = this.question.items.findIndex(item => item.id === itemId); + const endPosition = moveNext ? initialPosition + 1 : initialPosition - 1; + if (endPosition < 0 || endPosition >= this.question.items.length) { + // Invalid position. + return; + } + + this.moveItem({ + from: initialPosition, + to: endPosition, + complete: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + }); + + await CoreUtils.nextTick(); + + // When moving an item to the first or last position, the button that was clicked will be hidden. In this case, we need to + // focus the other button. Otherwise, re-focus the same button since the focus is lost in some cases. + const movedCard = document.querySelector(`#${itemId}`); + let elementToFocus = target; + + if (movedCard && !movedCard.previousElementSibling) { + elementToFocus = movedCard.querySelector('[data-action="move-forward"]') ?? target; + } else if (movedCard && !movedCard.nextElementSibling) { + elementToFocus = movedCard.querySelector('[data-action="move-backward"]') ?? target; + } + + CoreDomUtils.focusElement(elementToFocus); + + if (CorePlatform.isIOS()) { + // In iOS, when the focus is lost VoiceOver automatically focus the element in the same position where the focus was. + // If that happens, make sure the focus stays in the button we want to focus. + const reFocus = () => { + elementToFocus.removeEventListener('blur', reFocus); + CoreDomUtils.focusElement(elementToFocus); + }; + elementToFocus.addEventListener('blur', reFocus); + setTimeout(() => { + elementToFocus.removeEventListener('blur', reFocus); + }, 300); + } + } + +} + +/** + * Data for ordering question. + */ +export type AddonQtypeOrderingQuestionData = AddonModQuizQuestionBasicData & { + readOnly?: boolean; + items?: OrderingItem[]; +}; + +type OrderingItem = { + id: string; + content: string; + contentText: string; + correctClass: string; +}; diff --git a/src/addons/qtype/ordering/lang.json b/src/addons/qtype/ordering/lang.json new file mode 100644 index 000000000..0d4c80aba --- /dev/null +++ b/src/addons/qtype/ordering/lang.json @@ -0,0 +1,3 @@ +{ + "moved": "{{$a.item}} moved. New position: {{$a.position}} of {{$a.total}}." +} diff --git a/src/addons/qtype/ordering/ordering.module.ts b/src/addons/qtype/ordering/ordering.module.ts new file mode 100644 index 000000000..8edd33678 --- /dev/null +++ b/src/addons/qtype/ordering/ordering.module.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeOrderingHandler } from './services/handlers/ordering'; +import { AddonQtypeOrderingComponent } from './component/ordering'; +import { CoreSharedModule } from '@/core/shared.module'; + +@NgModule({ + declarations: [ + AddonQtypeOrderingComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + CoreQuestionDelegate.registerHandler(AddonQtypeOrderingHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeOrderingComponent, + ], +}) +export class AddonQtypeOrderingModule {} diff --git a/src/addons/qtype/ordering/services/handlers/ordering.ts b/src/addons/qtype/ordering/services/handlers/ordering.ts new file mode 100644 index 000000000..2ff30b451 --- /dev/null +++ b/src/addons/qtype/ordering/services/handlers/ordering.ts @@ -0,0 +1,73 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Type } from '@angular/core'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { makeSingleton } from '@singletons'; +import { CoreSites } from '@services/sites'; + +/** + * Handler to support ordering question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeOrderingHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeOrdering'; + type = 'qtype_ordering'; + + /** + * @inheritdoc + */ + async getComponent(): Promise> { + const { AddonQtypeOrderingComponent } = await import('@addons/qtype/ordering/component/ordering'); + + return AddonQtypeOrderingComponent; + } + + /** + * @inheritdoc + */ + isCompleteResponse(): number { + return 1; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return !!CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('4.4'); + } + + /** + * @inheritdoc + */ + isGradableResponse(): number { + return 1; + } + + /** + * @inheritdoc + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreQuestion.compareAllAnswers(prevAnswers, newAnswers); + } + +} + +export const AddonQtypeOrderingHandler = makeSingleton(AddonQtypeOrderingHandlerService); diff --git a/src/addons/qtype/qtype.module.ts b/src/addons/qtype/qtype.module.ts index a51925082..c6a8571ab 100644 --- a/src/addons/qtype/qtype.module.ts +++ b/src/addons/qtype/qtype.module.ts @@ -27,6 +27,7 @@ import { AddonQtypeMatchModule } from './match/match.module'; import { AddonQtypeMultiAnswerModule } from './multianswer/multianswer.module'; import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module'; import { AddonQtypeNumericalModule } from './numerical/numerical.module'; +import { AddonQtypeOrderingModule } from './ordering/ordering.module'; import { AddonQtypeRandomSaMatchModule } from './randomsamatch/randomsamatch.module'; import { AddonQtypeShortAnswerModule } from './shortanswer/shortanswer.module'; import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; @@ -46,6 +47,7 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; AddonQtypeMultiAnswerModule, AddonQtypeMultichoiceModule, AddonQtypeNumericalModule, + AddonQtypeOrderingModule, AddonQtypeRandomSaMatchModule, AddonQtypeShortAnswerModule, AddonQtypeTrueFalseModule, diff --git a/src/core/features/question/question.scss b/src/core/features/question/question.scss index c36dbfaca..0a20e3a21 100644 --- a/src/core/features/question/question.scss +++ b/src/core/features/question/question.scss @@ -19,6 +19,10 @@ margin: 0 0 .5em; } + p { + --color: var(--core-question-feedback-color); + } + .correctness { display: inline-block; padding: 2px 4px; diff --git a/src/core/lang.json b/src/core/lang.json index a3382b288..dc987b022 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -204,6 +204,8 @@ "mod_workshop": "Workshop", "moduleintro": "Description", "more": "More", + "movedown": "Move down", + "moveup": "Move up", "mygroups": "My groups", "name": "Name", "needhelp": "Need help?", diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index ff9720ac1..7ecded4ce 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -295,10 +295,11 @@ export class CoreDomUtilsProvider { * @param element HTML element to focus. */ async focusElement( - element: HTMLIonInputElement | HTMLIonTextareaElement | HTMLIonSearchbarElement | HTMLElement, + element: HTMLIonInputElement | HTMLIonTextareaElement | HTMLIonSearchbarElement | HTMLIonButtonElement | HTMLElement, ): Promise { let retries = 10; + const isIonButton = element.tagName === 'ION-BUTTON'; let elementToFocus = element; /** @@ -319,6 +320,10 @@ export class CoreDomUtilsProvider { // If it's an Ionic element get the right input to use. elementToFocus.componentOnReady && await elementToFocus.componentOnReady(); elementToFocus = await elementToFocus.getInputElement(); + } else if (isIonButton) { + // For ion-button, we need to call focus on the inner button. But the activeElement will be the ion-button. + ('componentOnReady' in elementToFocus) && await elementToFocus.componentOnReady(); + elementToFocus = elementToFocus.shadowRoot?.querySelector('.button-native') ?? elementToFocus; } if (!elementToFocus || !elementToFocus.focus) { @@ -328,7 +333,7 @@ export class CoreDomUtilsProvider { while (retries > 0 && elementToFocus !== document.activeElement) { elementToFocus.focus(); - if (elementToFocus === document.activeElement) { + if (elementToFocus === document.activeElement || (isIonButton && element === document.activeElement)) { await CoreUtils.nextTick(); if (CorePlatform.isAndroid() && this.supportsInputKeyboard(elementToFocus)) { // On some Android versions the keyboard doesn't open automatically.