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) {
+
+
+
+
+ @if (dragDisabled) {
+ @if (item.correctClass === 'correct') {
+
+ } @else if (item.correctClass === 'incorrect') {
+
+ } @else if (item.correctClass.startsWith('partial')) {
+
+ }
+ }
+
+
+
+
+
+ @if (!dragDisabled) {
+
+
+
+
+
+
+
+
+ }
+
+
+ }
+
+
+
+
+
+}
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.