Skip to content

Commit eff765e

Browse files
authored
Merge pull request #2370 from themeum/v4-quiz
✨ Build v4 learning-area quiz foundation
2 parents 4a9b07b + 953cce3 commit eff765e

95 files changed

Lines changed: 6656 additions & 1498 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

assets/core/scss/mixins/_inputs.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
background-color: $tutor-surface-l1;
6969
cursor: pointer;
7070
position: relative;
71+
flex-shrink: 0;
7172
@include tutor-transition((
7273
background-color,
7374
border-color,
@@ -155,6 +156,7 @@
155156
background-color: $tutor-surface-l1;
156157
cursor: pointer;
157158
position: relative;
159+
flex-shrink: 0;
158160
@include tutor-transition((
159161
background-color,
160162
border-color,

assets/core/scss/mixins/_layout.scss

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,16 @@
118118
grid-template-columns: repeat($min, 1fr);
119119

120120
@for $i from ($min + 1) through $max {
121-
// Count all children, then subtract if any are dragging
121+
// Count all children to increase column count; ignore drag clones.
122122
&:has(> #{$selector}:nth-child(#{$i})) {
123123
grid-template-columns: repeat($i, 1fr);
124+
}
125+
}
124126

125-
// If one child is dragging, reduce column count by 1
126-
&:has(> #{$selector}[data-dnd-dragging='true']) {
127+
// If a drag clone/placeholder is present, keep columns based on item count minus one.
128+
&:has(> #{$selector}[data-dnd-dragging='true']) {
129+
@for $i from ($min + 1) through ($max + 1) {
130+
&:has(> #{$selector}:nth-child(#{$i})) {
127131
grid-template-columns: repeat(#{$i - 1}, 1fr);
128132
}
129133
}

assets/core/scss/themes/_dark.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
--tutor-icon-disabled: #{$tutor-gray-600};
6565
--tutor-icon-brand: #{$tutor-brand-600};
6666
--tutor-icon-brand-hover: #{$tutor-brand-700};
67+
--tutor-icon-brand-secondary: #{$tutor-brand-300};
6768
--tutor-icon-exception1: #{$tutor-exception-1};
6869
--tutor-icon-exception2: #{$tutor-exception-2};
6970
--tutor-icon-success-primary: #{$tutor-success-600};

assets/core/scss/themes/_light.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
--tutor-icon-disabled: #{$tutor-gray-300};
6666
--tutor-icon-brand: #{$tutor-brand-600};
6767
--tutor-icon-brand-hover: #{$tutor-brand-700};
68+
--tutor-icon-brand-secondary: #{$tutor-brand-300};
6869
--tutor-icon-exception1: #{$tutor-exception-1};
6970
--tutor-icon-exception2: #{$tutor-exception-2};
7071
--tutor-icon-success-primary: #{$tutor-success-700};

assets/core/scss/tokens/_icons.scss

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ $tutor-icon-secondary: var(--tutor-icon-secondary);
1212
$tutor-icon-subdued: var(--tutor-icon-subdued);
1313
$tutor-icon-brand: var(--tutor-icon-brand);
1414
$tutor-icon-brand-hover: var(--tutor-icon-brand-hover);
15+
$tutor-icon-brand-secondary: var(--tutor-icon-brand-secondary);
1516
$tutor-icon-success-primary: var(--tutor-icon-success-primary);
1617
$tutor-icon-critical: var(--tutor-icon-critical);
1718
$tutor-icon-critical-hover: var(--tutor-icon-critical-hover);
@@ -35,6 +36,7 @@ $tutor-icons: (
3536
subdued: $tutor-icon-subdued,
3637
brand: $tutor-icon-brand,
3738
brand-hover: $tutor-icon-brand-hover,
39+
brand-secondary: $tutor-icon-brand-secondary,
3840
success-primary: $tutor-icon-success-primary,
3941
critical: $tutor-icon-critical,
4042
critical-hover: $tutor-icon-critical-hover,
@@ -44,4 +46,4 @@ $tutor-icons: (
4446
exception2: $tutor-icon-exception2,
4547
exception4: $tutor-icon-exception4,
4648
disabled: $tutor-icon-disabled,
47-
);
49+
);

assets/core/ts/components/form.ts

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface FieldConfig {
1818
defaultValue?: unknown;
1919
ref?: HTMLInputElement;
2020
type?: string;
21+
isCheckboxArray?: boolean;
2122
}
2223

2324
export interface ValidationRules {
@@ -418,42 +419,110 @@ export const form = (config: FormControlConfig & { id?: string } = {}) => {
418419
const isCheckbox = type === 'checkbox';
419420
const isFile = type === 'file';
420421

421-
const defaultValue = this.values[name] ?? (isCheckbox ? (element?.checked ?? false) : '');
422+
// Check if a checkbox with this name is already registered (indicates multiple checkboxes)
423+
const isCheckboxArray = isCheckbox && this.fields[name]?.type === 'checkbox';
424+
425+
let defaultValue: unknown;
426+
427+
if (isCheckboxArray) {
428+
// Ensure array type for checkbox groups
429+
const currentValue = this.values[name];
430+
defaultValue = Array.isArray(currentValue) ? currentValue : [];
431+
} else if (isCheckbox) {
432+
defaultValue = this.values[name] ?? element?.checked ?? false;
433+
} else {
434+
defaultValue = this.values[name] ?? '';
435+
}
422436

423437
this.fields[name] = {
424438
name,
425439
rules,
426440
defaultValue,
427441
ref: element,
428442
type,
443+
isCheckboxArray,
429444
};
430445

431-
this.values[name] ??= defaultValue;
446+
// Force upgrade value to array if a collision is detected
447+
if (isCheckboxArray && !Array.isArray(this.values[name])) {
448+
this.values[name] = defaultValue;
449+
} else {
450+
this.values[name] ??= defaultValue;
451+
}
432452

433-
const valueExpression = isCheckbox ? '$event.target.checked' : '$event.target.value';
453+
const valueExpression = isCheckboxArray
454+
? '$event.target.value'
455+
: isCheckbox
456+
? '$event.target.checked'
457+
: '$event.target.value';
434458

435459
const bindings: Record<string, unknown> = {
436460
name,
437461
'x-ref': name,
438-
':aria-invalid': `!!errors.${name}`,
462+
':aria-invalid': `!!errors["${name}"]`,
439463
':class': `{
440-
'tutor-input-error': errors.${name},
441-
'tutor-input-touched': touchedFields.${name},
442-
'tutor-input-dirty': dirtyFields.${name}
464+
'tutor-input-error': errors["${name}"],
465+
'tutor-input-touched': touchedFields["${name}"],
466+
'tutor-input-dirty': dirtyFields["${name}"]
443467
}`,
444468
};
445469

446470
if (!isFile) {
447-
bindings['x-model'] = `values.${name}`;
448-
bindings['@input'] = `handleFieldInput('${name}', ${valueExpression})`;
471+
bindings['x-model'] = `values["${name}"]`;
472+
473+
bindings['@input'] = `handleFieldInput('${name}', ${valueExpression}, $event.target)`;
474+
449475
bindings['@blur'] = `handleFieldBlur('${name}', ${valueExpression})`;
450476
}
451477

452478
return bindings;
453479
},
454480

455-
handleFieldInput(name: string, value: unknown): void {
481+
handleCheckboxArrayInput(name: string, element?: HTMLInputElement): void {
482+
const field = this.fields[name];
483+
const currentValue = this.values[name] as string[];
484+
const valueArray = Array.isArray(currentValue) ? [...currentValue] : [];
485+
486+
// Use the passed element (from $event.target) or try to get from $refs
487+
const checkbox = element || ((this as unknown as AlpineComponent).$refs[name] as HTMLInputElement);
488+
489+
if (!checkbox) return;
490+
491+
const checkboxValue = checkbox.value;
492+
const isChecked = checkbox.checked;
493+
494+
let newValue: string[];
495+
if (isChecked) {
496+
newValue = valueArray.includes(checkboxValue) ? valueArray : [...valueArray, checkboxValue];
497+
} else {
498+
newValue = valueArray.filter((v) => v !== checkboxValue);
499+
}
500+
501+
const defaultArray = Array.isArray(field.defaultValue) ? field.defaultValue : [];
502+
// Sort to compare content regardless of order
503+
const isActuallyChanged = JSON.stringify(newValue.sort()) !== JSON.stringify(defaultArray.sort());
504+
505+
this.values[name] = newValue;
506+
this.dirtyFields[name] = isActuallyChanged;
507+
508+
const shouldValidate = this.config.mode === 'onChange' || this.touchedFields[name];
509+
510+
if (shouldValidate) {
511+
this.validateField(name, newValue);
512+
} else {
513+
this.dispatchStateChange();
514+
}
515+
},
516+
517+
handleFieldInput(name: string, value: unknown, element?: HTMLInputElement): void {
456518
const field = this.fields[name];
519+
520+
if (field?.isCheckboxArray) {
521+
this.handleCheckboxArrayInput(name, element);
522+
return;
523+
}
524+
525+
// Original logic for non-checkbox-array fields
457526
const isNumber = field?.rules?.numberOnly;
458527
const allowNegative = typeof isNumber === 'object' && isNumber.allowNegative;
459528
const whole = typeof isNumber === 'object' && isNumber.whole;
@@ -508,12 +577,22 @@ export const form = (config: FormControlConfig & { id?: string } = {}) => {
508577
if (shouldTouch) this.touchedFields[name] = true;
509578
if (shouldDirty) {
510579
const field = this.fields[name];
511-
this.dirtyFields[name] = String(value) !== String(field?.defaultValue ?? '');
580+
// Handle array comparison for checkbox arrays
581+
if (Array.isArray(value) && Array.isArray(field?.defaultValue)) {
582+
this.dirtyFields[name] = JSON.stringify(value.sort()) !== JSON.stringify(field.defaultValue.sort());
583+
} else {
584+
this.dirtyFields[name] = String(value) !== String(field?.defaultValue ?? '');
585+
}
512586
}
513587

514588
const fieldElement = this.fields[name]?.ref;
515589
if (fieldElement && this.fields[name].type !== 'file') {
516-
DOMUtils.updateElementValue(fieldElement, value);
590+
// For checkbox arrays, we need to update all checkboxes with this name
591+
if (Array.isArray(value) && fieldElement.type === 'checkbox') {
592+
this.syncCheckboxArray(name, value as string[]);
593+
} else {
594+
DOMUtils.updateElementValue(fieldElement, value);
595+
}
517596
}
518597

519598
if (shouldValidate) {
@@ -814,11 +893,29 @@ export const form = (config: FormControlConfig & { id?: string } = {}) => {
814893
for (const [name, value] of Object.entries(this.values)) {
815894
const fieldRef = this.fields[name]?.ref;
816895
if (fieldRef) {
817-
DOMUtils.updateElementValue(fieldRef, value);
896+
// Handle checkbox arrays specially
897+
if (Array.isArray(value) && fieldRef.type === 'checkbox') {
898+
this.syncCheckboxArray(name, value as string[]);
899+
} else {
900+
DOMUtils.updateElementValue(fieldRef, value);
901+
}
818902
}
819903
}
820904
},
821905

906+
syncCheckboxArray(name: string, values: string[]): void {
907+
const component = this as unknown as AlpineComponent;
908+
const formElement = component.$el.closest('form') || component.$el.parentElement;
909+
const checkboxes = formElement?.querySelectorAll(`input[type="checkbox"][name="${name}"]`);
910+
911+
if (checkboxes) {
912+
checkboxes.forEach((checkbox) => {
913+
const input = checkbox as HTMLInputElement;
914+
input.checked = values.includes(input.value);
915+
});
916+
}
917+
},
918+
822919
clearAllState(): void {
823920
this.fields = {};
824921
this.values = {};

assets/core/ts/constant.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ export const TUTOR_CUSTOM_EVENTS = {
1313
TUTOR_PLAYER_READY: 'tutor-player-ready',
1414
COMMENT_REPLIED: 'tutor:comment:replied',
1515
LESSON_PLAYER_READY: 'tutorLessonPlayerReady',
16+
QUIZ_TIME_EXPIRED: 'tutor-quiz-time-expired',
17+
QUIZ_ABANDON_REQUESTED: 'tutor-quiz-abandon-requested',
1618
};

assets/icons/clock-frame.svg

Lines changed: 5 additions & 0 deletions
Loading

assets/images/quiz-intro.svg

Lines changed: 1 addition & 11 deletions
Loading
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { AlpineComponentMeta } from '@Core/ts/types';
2+
3+
interface QuizSummarySidebarConfig {
4+
firstQuestionId?: string | number;
5+
}
6+
7+
const QUESTION_ID_PREFIX = 'question-';
8+
const SUMMARY_HEADER_SELECTOR = '.tutor-quiz-summary-header';
9+
const QUESTION_ID_FALLBACK_SELECTOR = '[data-question-id="%s"]';
10+
const QUESTION_ID_PREFIX_FALLBACK_SELECTOR = `[data-question-id="${QUESTION_ID_PREFIX}%s"]`;
11+
const SIDEBAR_ITEM_SELECTOR = '[data-question-id="%s"]';
12+
const HASH_PATTERN = new RegExp(`^#${QUESTION_ID_PREFIX}(\\d+)$`);
13+
const QUESTION_SCROLL_GAP = 16;
14+
15+
const quizSummarySidebar = (config: QuizSummarySidebarConfig = {}) => ({
16+
activeQuestionId: String(config.firstQuestionId ?? ''),
17+
$el: null as HTMLElement | null,
18+
19+
init() {
20+
const hashQuestionId = this.getQuestionIdFromHash(window.location.hash);
21+
22+
if (hashQuestionId && this.hasQuestionItem(hashQuestionId)) {
23+
this.activeQuestionId = hashQuestionId;
24+
}
25+
},
26+
27+
getQuestionIdFromHash(hash: string): string | null {
28+
const hashMatch = hash.match(HASH_PATTERN);
29+
return hashMatch ? hashMatch[1] : null;
30+
},
31+
32+
hasQuestionItem(questionId: string): boolean {
33+
if (!questionId || !this.$el) {
34+
return false;
35+
}
36+
37+
return !!this.$el.querySelector(SIDEBAR_ITEM_SELECTOR.replace('%s', questionId));
38+
},
39+
40+
setActiveQuestion(questionId: string | number) {
41+
const resolvedId = String(questionId || '');
42+
43+
if (!resolvedId) {
44+
return;
45+
}
46+
47+
this.activeQuestionId = resolvedId;
48+
history.replaceState(null, '', `#${QUESTION_ID_PREFIX}${resolvedId}`);
49+
this.scrollToQuestionAnswer(resolvedId);
50+
},
51+
52+
scrollToQuestionAnswer(questionId: string | number) {
53+
const resolvedId = String(questionId || '');
54+
55+
if (!resolvedId) {
56+
return;
57+
}
58+
59+
const answerElement =
60+
document.getElementById(`${QUESTION_ID_PREFIX}${resolvedId}`) ||
61+
document.querySelector(QUESTION_ID_PREFIX_FALLBACK_SELECTOR.replace('%s', resolvedId)) ||
62+
document.querySelector(QUESTION_ID_FALLBACK_SELECTOR.replace('%s', resolvedId));
63+
64+
if (answerElement instanceof HTMLElement) {
65+
const summaryHeader = document.querySelector(SUMMARY_HEADER_SELECTOR);
66+
const headerOffset =
67+
summaryHeader instanceof HTMLElement
68+
? summaryHeader.getBoundingClientRect().top + summaryHeader.offsetHeight
69+
: 0;
70+
const scrollTop = answerElement.getBoundingClientRect().top + window.scrollY - headerOffset - QUESTION_SCROLL_GAP;
71+
72+
window.scrollTo({
73+
top: Math.max(0, scrollTop),
74+
behavior: 'smooth',
75+
});
76+
}
77+
},
78+
});
79+
80+
export const quizSummarySidebarMeta: AlpineComponentMeta = {
81+
name: 'quizSummarySidebar',
82+
component: quizSummarySidebar,
83+
};

0 commit comments

Comments
 (0)