diff --git a/projects/sunbird-quml-player-react/package.json b/projects/sunbird-quml-player-react/package.json index e6ae205c..c741e5e7 100644 --- a/projects/sunbird-quml-player-react/package.json +++ b/projects/sunbird-quml-player-react/package.json @@ -1,7 +1,7 @@ { "name": "@project-sunbird/sunbird-quml-player-web-component-react", "private": true, - "version": "0.1.10", + "version": "0.1.11", "type": "module", "scripts": { "dev": "vite", diff --git a/projects/sunbird-quml-player-react/src/App.tsx b/projects/sunbird-quml-player-react/src/App.tsx index 6092b9b2..522bfeed 100644 --- a/projects/sunbird-quml-player-react/src/App.tsx +++ b/projects/sunbird-quml-player-react/src/App.tsx @@ -24,10 +24,13 @@ function resolveConfig(): PlayerConfig { context: { uid: 'dev-user', sid: 'dev-session', channel: 'dev', host: '' }, // Only set language when ?lang is present; otherwise let the language // precedence (localStorage['app-language'] → 'en') apply. - config: { language: params.get('lang') ?? undefined, maxAttempts: 3 }, + config: { language: params.get('lang') ?? undefined }, // API mode: only an identifier, no embedded sections. Online asset hosts // come from each media[].baseUrl in the backend response (Angular parity). data: { identifier }, + // maxAttempts is host/backend data (Angular parity: playerConfig.metadata), + // not a player UI setting. + metadata: { maxAttempts: 3 }, }; } diff --git a/projects/sunbird-quml-player-react/src/components/Hint/Hint.module.scss b/projects/sunbird-quml-player-react/src/components/Hint/Hint.module.scss index bf80cc6a..d2a5b168 100644 --- a/projects/sunbird-quml-player-react/src/components/Hint/Hint.module.scss +++ b/projects/sunbird-quml-player-react/src/components/Hint/Hint.module.scss @@ -6,6 +6,10 @@ flex-direction: column; gap: v.$space-4; margin-top: v.$space-8; + + @include m.short { + margin-top: v.$space-3; + } } .actions { diff --git a/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.max-attempts.test.tsx b/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.max-attempts.test.tsx new file mode 100644 index 00000000..5f292fe8 --- /dev/null +++ b/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.max-attempts.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { QumlProvider } from '../../context/QumlContext'; +import { MainPlayer } from './MainPlayer'; +import type { PlayerConfig } from '../../types'; + +// Angular parity (main-player.component.ts:249,253,483-485) — maxAttempts is +// host/backend data under `playerConfig.metadata`, not `config`. +const baseData = { + showTimer: false, + sections: [ + { + identifier: 's1', + name: 'Section 1', + timeLimits: { questionSet: { max: 0, min: 0 } }, + children: [ + { + identifier: 'q1', + body: '

Q1

', + primaryCategory: 'Multiple Choice Question', + interactions: { response1: { options: [{ value: 0, label: 'Apple' }, { value: 1, label: 'Banana' }] } }, + responseDeclaration: { + response1: { cardinality: 'single', type: 'integer', correctResponse: { value: 0 } }, + }, + }, + ], + }, + ], +}; + +const enterAssessment = () => { + fireEvent.click(screen.getByRole('button', { name: /start assessment/i })); + fireEvent.click(screen.getByRole('button', { name: /start section/i })); +}; + +const submitAssessment = () => { + fireEvent.click(screen.getAllByRole('radio')[0]); // answer correctly + fireEvent.click(screen.getAllByRole('button', { name: /^submit$/i })[0]); + const dialog = screen.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: /^submit$/i })); +}; + +describe('MainPlayer — max attempts (Angular parity)', () => { + it('hides Retake on Results once this attempt is the last one allowed', () => { + const cfg: PlayerConfig = { + context: {}, + config: { language: 'en' }, + metadata: { maxAttempts: 1 }, + data: baseData, + }; + render( + + + , + ); + enterAssessment(); + submitAssessment(); + expect(screen.getByRole('heading', { name: /your results/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /retake/i })).not.toBeInTheDocument(); + }); + + it('keeps Retake when attempts remain', () => { + const cfg: PlayerConfig = { + context: {}, + config: { language: 'en' }, + metadata: { maxAttempts: 3 }, + data: baseData, + }; + render( + + + , + ); + enterAssessment(); + submitAssessment(); + expect(screen.getByRole('button', { name: /retake/i })).toBeInTheDocument(); + }); + + it('emits an exdata isLastAttempt event when the final attempt starts', () => { + const onPlayerEvent = vi.fn(); + const cfg: PlayerConfig = { + context: {}, + config: { language: 'en' }, + metadata: { maxAttempts: 1 }, + data: baseData, + }; + render( + + + , + ); + expect(onPlayerEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eid: 'exdata', + edata: expect.objectContaining({ + currentattempt: 1, + isLastAttempt: true, + maxLimitExceeded: false, + }), + }), + ); + }); + + it('emits an exdata maxLimitExceeded event when the last attempt is submitted', () => { + const onPlayerEvent = vi.fn(); + const cfg: PlayerConfig = { + context: {}, + config: { language: 'en' }, + metadata: { maxAttempts: 1 }, + data: baseData, + }; + render( + + + , + ); + onPlayerEvent.mockClear(); + enterAssessment(); + submitAssessment(); + expect(onPlayerEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eid: 'exdata', + edata: expect.objectContaining({ + currentattempt: 1, + isLastAttempt: false, + maxLimitExceeded: true, + }), + }), + ); + }); + + it('does not restrict Retake when maxAttempts is not sent (unlimited)', () => { + const cfg: PlayerConfig = { + context: {}, + config: { language: 'en' }, + data: baseData, + }; + render( + + + , + ); + enterAssessment(); + submitAssessment(); + expect(screen.getByRole('button', { name: /retake/i })).toBeInTheDocument(); + }); +}); diff --git a/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.timer-showtimer-parity.test.tsx b/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.timer-showtimer-parity.test.tsx new file mode 100644 index 00000000..982b7877 --- /dev/null +++ b/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.timer-showtimer-parity.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, act, within } from '@testing-library/react'; +import { QumlProvider } from '../../context/QumlContext'; +import { MainPlayer } from './MainPlayer'; +import type { PlayerConfig } from '../../types'; + +// Angular parity (section-player.component.ts:232-235, main-player.component.ts +// :172,257,488-493) — `showTimer` only gates the LIVE widget's visibility; it +// has no bearing on whether the clock is tracked, whether a time limit is +// enforced, or whether the results screen can report duration. These configs +// all set `showTimer: false` (or omit it) while still exercising the clock. + +const mcq = (id: string) => ({ + identifier: id, + body: `

${id}

`, + primaryCategory: 'Multiple Choice Question', + interactions: { response1: { options: [{ value: 0, label: 'Apple' }, { value: 1, label: 'Banana' }] } }, + responseDeclaration: { + response1: { cardinality: 'single', type: 'integer', correctResponse: { value: 0 } }, + }, +}); + +const enterAssessment = () => { + fireEvent.click(screen.getByRole('button', { name: /start assessment/i })); + fireEvent.click(screen.getByRole('button', { name: /start section/i })); +}; + +describe('MainPlayer — showTimer/summaryType parity (Angular)', () => { + it('auto-submits at time-limit expiry even when showTimer is false (hidden limit is still enforced)', () => { + const cfg: PlayerConfig = { + context: {}, + config: { language: 'en' }, + data: { + showTimer: false, + timeLimits: { questionSet: { max: 2, min: 0 } }, + sections: [{ identifier: 's1', name: 'Section 1', children: [mcq('q1')] }], + }, + }; + vi.useFakeTimers(); + try { + render( + + + , + ); + enterAssessment(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + // No visible countdown — showTimer is false. + expect(screen.queryByRole('timer')).not.toBeInTheDocument(); + // Past the 2s limit → auto-submit, no confirmation needed. + act(() => vi.advanceTimersByTime(2100)); + expect(screen.getByRole('heading', { name: /your results/i })).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it('reports duration on Results when showTimer is false and summaryType allows it', () => { + const cfg: PlayerConfig = { + context: {}, + config: { language: 'en' }, + data: { + showTimer: false, + summaryType: 'Score and Duration', + sections: [{ identifier: 's1', name: 'Section 1', children: [mcq('q1')] }], + }, + }; + vi.useFakeTimers(); + try { + render( + + + , + ); + enterAssessment(); + act(() => vi.advanceTimersByTime(3000)); // 3s elapsed, count-up mode (no time limit) + fireEvent.click(screen.getAllByRole('radio')[0]); + fireEvent.click(screen.getAllByRole('button', { name: /^submit$/i })[0]); + const dialog = screen.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: /^submit$/i })); + expect(screen.getByRole('heading', { name: /your results/i })).toBeInTheDocument(); + expect(screen.getByText(/time taken/i)).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.tsx b/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.tsx index 3ee3ce1d..cfa695ec 100644 --- a/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.tsx +++ b/projects/sunbird-quml-player-react/src/components/MainPlayer/MainPlayer.tsx @@ -78,6 +78,12 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { const [timeRemaining, setTimeRemaining] = useState(null); // One-shot guard for the showStartPage:'No' auto-advance past the overview. const autoStartedRef = useRef(false); + // True once the assessment has been entered at least once this attempt — the + // brand-click (onBrandClick) returns to Overview WITHOUT resetting progress + // or the clock, so the CTA there must read "Resume", not "Start" (Retake is + // the only path that clears this, since it's the only path that actually + // restarts from zero). + const [hasStarted, setHasStarted] = useState(false); // Section intros can be disabled via config (spec §6.0). const sectionIntrosEnabled = @@ -93,6 +99,16 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { const requiresSubmitConfirmation = metadata.requiresSubmit !== 'No' && metadata.requiresSubmit !== false; + // Angular parity (main-player.component.ts:249) — host/backend data under + // `playerConfig.metadata`, not the player's own `config` (UI-only settings). + // Read once here since it's needed beyond the overview (Retake gating below + // + the exdata event effect further down). + const maxAttempts = + (playerConfig?.metadata as { maxAttempts?: number } | undefined)?.maxAttempts ?? null; + // Angular parity (main-player.component.ts:253 `showReplay`) — once this + // attempt IS the last allowed one, Retake must not offer another. + const canRetake = maxAttempts == null || state.attemptNumber < maxAttempts; + // Initialize config + normalized sections. // // Two data sources, decided by shape (never both): @@ -106,6 +122,17 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { if (!playerConfig) return; setPlayerConfig(playerConfig); + // Angular parity (main-player.component.ts:250) — the host tracks attempts + // used across PAST sessions and passes the count back; this session's + // attempt number is one past that. Retake's own `setAttempt(nextAttempt)` + // (called right after this function) overrides this for the in-session + // case, so this only matters on a fresh mount. + const seedAttempt = (playerConfig.metadata as { currentAttempt?: number } | undefined) + ?.currentAttempt; + if (typeof seedAttempt === 'number') { + setAttempt(seedAttempt + 1); + } + const data = (playerConfig.data as Record | undefined) ?? {}; // Host-contract compatibility: the Sunbird editor/portal follow the Angular // contract — they pass the questionset under `playerConfig.metadata` and leave @@ -181,7 +208,7 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { err instanceof QumlApiError ? err.message : 'Failed to load the assessment.'; setError(message); } - }, [playerConfig, setPlayerConfig, setSections, setLoading, setError]); + }, [playerConfig, setPlayerConfig, setSections, setLoading, setError, setAttempt]); useEffect(() => { initializeFromConfig(); @@ -223,7 +250,6 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { ); const timeLimits = (data.timeLimits as { questionSet?: { max?: number } } | undefined) ?.questionSet; - const cfg = (playerConfig?.config as { maxAttempts?: number } | undefined) ?? {}; return { title: readI18n(data.name as I18nValue | undefined, language) || t(language, 'ASSESSMENT_OVERVIEW'), description: readI18n(data.description as I18nValue | undefined, language) || undefined, @@ -235,10 +261,43 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { // the timer is shown ONLY when the content opts in via `showTimer`. Absent / // false → no timer at all; the count-up fallback also requires it. showTimer: data.showTimer === true || data.showTimer === 'true', + // Angular parity (main-player.component.ts:488-524) — gates score/duration + // visibility on the results screen. 'Complete'→score as a fraction, + // 'Duration'→score hidden, 'Score'→duration hidden, 'Score and Duration'/ + // absent→both shown as plain values. + summaryType: data.summaryType as string | undefined, maxScore, - attemptsLeft: Math.max(0, (cfg.maxAttempts ?? 3) - (state.attemptNumber - 1)), + // Absent (not sent by the backend) → unlimited attempts: null, distinct + // from an explicit 0/low maxAttempts, so StartPage can show "No Limit" + // instead of a fabricated default. + attemptsLeft: + maxAttempts == null ? null : Math.max(0, maxAttempts - (state.attemptNumber - 1)), }; - }, [metadata, playerConfig, state.sections, state.attemptNumber, language]); + }, [metadata, maxAttempts, state.sections, state.attemptNumber, language]); + + // Angular parity (main-player.component.ts:258,276-282,415-417 emitMaxAttemptEvents + // + replayContent) — tell the host when the CURRENT attempt is the last one + // allowed, or already past the limit (the host decides how to react, e.g. its + // own "no attempts left" messaging). Keyed on attemptNumber, so this covers + // BOTH Angular call sites (component init AND Retake) in one place — Retake + // changes attemptNumber, which re-fires this effect with the new value. + useEffect(() => { + if (maxAttempts == null) return; + const current = state.attemptNumber; + if (current < maxAttempts) return; + onPlayerEvent?.({ + eid: 'exdata', + edata: { + type: 'exdata', + currentattempt: current, + isLastAttempt: current === maxAttempts, + maxLimitExceeded: current > maxAttempts, + }, + }); + // onPlayerEvent identity isn't guaranteed stable across host re-renders; + // only the attempt boundary itself should re-trigger this. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.attemptNumber, maxAttempts]); // Stages where the exam clock runs: once started, it keeps ticking through // section intros (switching sections doesn't stop the clock). It PAUSES on @@ -268,13 +327,25 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { }, [isClockRunning]); // Count-up elapsed timer (Angular header showCountUp parity): when the - // assessment has NO time limit, the header shows time spent instead of a + // assessment has NO time limit, this tracks time spent instead of a // countdown. Same run/pause semantics as the countdown, anchored to a // timestamp so it never drifts. + // + // Angular parity — NOT gated on `showTimer`: showTimer only controls whether + // the live widget is *visible* (main-player.component.ts:172, section-player + // .component.ts:58,235 — fed straight into the timer display component, + // nothing else). Duration tracking itself (main-player.component.ts:257 + // initialTime, :488-493 setDurationSpent) and time-limit enforcement + // (section-player.component.ts:232-233) both run unconditionally in + // Angular. Previously this effect also required `overview.showTimer`, so a + // hidden timer (showTimer:false/unset) meant elapsed time was never tracked + // at all — the results screen had nothing to show regardless of + // `summaryType`. The header's OWN display of this value is still gated on + // `showTimer` separately, further down — only the tracking moved. const [timeElapsed, setTimeElapsed] = useState(0); const elapsedAnchorRef = useRef(null); useEffect(() => { - if (!isClockRunning || overview.timeLimit > 0 || !overview.showTimer) return; + if (!isClockRunning || overview.timeLimit > 0) return; elapsedAnchorRef.current = Date.now() - timeElapsed * 1000; const id = setInterval(() => { setTimeElapsed(Math.floor((Date.now() - elapsedAnchorRef.current!) / 1000)); @@ -283,7 +354,7 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { // Re-anchor only when the clock starts/stops; timeElapsed is read once as // the resume point. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isClockRunning, overview.timeLimit, overview.showTimer]); + }, [isClockRunning, overview.timeLimit]); // Time up → auto-submit straight to results (no confirmation dialog). useEffect(() => { @@ -310,9 +381,11 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { // ── Flow transitions ─────────────────────────────────────────────────────── const beginAssessmentTimer = () => { - // No countdown (and therefore no auto-submit on expiry) unless the content - // opts into the timer — Angular's header never starts the interval otherwise. - if (overview.showTimer && timeRemaining == null && overview.timeLimit > 0) { + // Angular parity — NOT gated on `showTimer` (see the count-up effect's + // comment above for the citations): a real `timeLimit` always counts down + // and enforces auto-submit-at-zero, whether or not the content shows the + // live widget. `showTimer` only gates the header's own display, below. + if (timeRemaining == null && overview.timeLimit > 0) { setTimeRemaining(overview.timeLimit); } }; @@ -321,6 +394,7 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { setCurrentSection(0); setCurrentQuestion(0); beginAssessmentTimer(); + setHasStarted(true); setStage(sectionIntrosEnabled ? 'sectionIntro' : 'assessment'); }; @@ -334,6 +408,7 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { setCurrentSection(index); setCurrentQuestion(0); beginAssessmentTimer(); + setHasStarted(true); setStage(sectionIntrosEnabled ? 'sectionIntro' : 'assessment'); }; @@ -348,6 +423,19 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { setSubmitDialog(false); setStage('results'); onPlayerEvent?.({ type: 'quizEnd', summary }); + // Angular parity (main-player.component.ts:483-485 raiseEndEvent) — flag + // to the host that this attempt, now finished, was the last one allowed. + if (maxAttempts != null && state.attemptNumber >= maxAttempts) { + onPlayerEvent?.({ + eid: 'exdata', + edata: { + type: 'exdata', + currentattempt: state.attemptNumber, + isLastAttempt: false, + maxLimitExceeded: true, + }, + }); + } }; const handleCancelSubmit = () => setSubmitDialog(false); @@ -368,6 +456,7 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { setTimeRemaining(null); setTimeElapsed(0); setSubmitDialog(false); + setHasStarted(false); setStage('overview'); }; @@ -406,6 +495,7 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { setCurrentSection(0); setCurrentQuestion(0); beginAssessmentTimer(); + setHasStarted(true); setStage('assessment'); // Guarded by the ref; the setters/timer are stable enough here. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -454,6 +544,7 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { totalSections={overview.totalSections} timeLimit={overview.timeLimit} attemptsLeft={overview.attemptsLeft} + hasStarted={hasStarted} onStart={handleStart} onSectionSelect={handleSectionSelectFromOverview} language={language} @@ -461,21 +552,20 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { ); } else if (stage === 'results') { // Total time spent: countdown mode → limit minus what was left; count-up - // mode → the elapsed counter (both tick only during the assessment stage). - // When the timer is suppressed (showTimer off) neither clock runs, so there - // is no meaningful elapsed value — pass null so Results omits "Time Taken" - // rather than showing a misleading 0:00. - const timeTaken = !overview.showTimer - ? null - : overview.timeLimit > 0 - ? overview.timeLimit - (timeRemaining ?? overview.timeLimit) - : timeElapsed; + // mode → the elapsed counter (both tick unconditionally once the + // assessment stage starts — Angular parity, see the count-up effect's + // comment above: NOT gated on `showTimer`, which only controls the live + // widget's visibility, not whether time is tracked). `summaryType:'Score'` + // hides duration on-screen — that gating lives in ResultsScreen, not here. + const timeTaken = + overview.timeLimit > 0 ? overview.timeLimit - (timeRemaining ?? overview.timeLimit) : timeElapsed; content = ( ); @@ -501,6 +591,7 @@ export function MainPlayer({ playerConfig, onPlayerEvent }: MainPlayerProps) { sectionIndex={state.currentSectionIndex} totalSections={state.sections.length} onBegin={handleBegin} + onPrevious={() => setStage('overview')} language={language} /> ) : ( diff --git a/projects/sunbird-quml-player-react/src/components/QuestionCard/QuestionCard.module.scss b/projects/sunbird-quml-player-react/src/components/QuestionCard/QuestionCard.module.scss index c060f874..f93ba270 100644 --- a/projects/sunbird-quml-player-react/src/components/QuestionCard/QuestionCard.module.scss +++ b/projects/sunbird-quml-player-react/src/components/QuestionCard/QuestionCard.module.scss @@ -12,13 +12,22 @@ @include m.mobile { padding: v.$space-5; } + + @include m.short { + padding: v.$space-4; + } } .meta { display: flex; + align-items: center; flex-wrap: wrap; gap: v.$space-2; margin-bottom: v.$space-5; + + @include m.short { + margin-bottom: v.$space-3; + } } .category, @@ -43,6 +52,23 @@ color: v.$g600; } +// Mobile-app only — replaces the top nav bar's counter, which is hidden in +// compact mode (see SectionPlayer.module.scss .counter). Desktop/tablet/ +// portal/editor keep the top-bar counter and never show this. +.progress { + display: none; + font-family: v.$ff-base; + font-size: v.$text-xs; + font-weight: v.$fw-semibold; + color: v.$g400; + font-variant-numeric: tabular-nums; + + @include m.compact { + display: inline-flex; + align-items: center; + } +} + .body { font-family: v.$ff-question; } @@ -51,4 +77,9 @@ margin-top: v.$space-6; padding-top: v.$space-5; border-top: 1px solid v.$gray-200; + + @include m.short { + margin-top: v.$space-3; + padding-top: v.$space-3; + } } diff --git a/projects/sunbird-quml-player-react/src/components/QuestionCard/QuestionCard.tsx b/projects/sunbird-quml-player-react/src/components/QuestionCard/QuestionCard.tsx index cc470026..72012949 100644 --- a/projects/sunbird-quml-player-react/src/components/QuestionCard/QuestionCard.tsx +++ b/projects/sunbird-quml-player-react/src/components/QuestionCard/QuestionCard.tsx @@ -13,13 +13,15 @@ export interface QuestionCardProps { question: Question; children: ReactNode; meta?: { category?: string; difficulty?: string }; + /** Position within the section, e.g. { current: 2, total: 4 } → "2/4". */ + progress?: { current: number; total: number }; footer?: ReactNode; } -export function QuestionCard({ question, children, meta, footer }: QuestionCardProps) { +export function QuestionCard({ question, children, meta, progress, footer }: QuestionCardProps) { const category = meta?.category ?? question.primaryCategory; const difficulty = meta?.difficulty; - const hasMeta = Boolean(category || difficulty); + const hasMeta = Boolean(category || difficulty || progress); return (
@@ -27,6 +29,11 @@ export function QuestionCard({ question, children, meta, footer }: QuestionCardP
{category && {category}} {difficulty && {difficulty}} + {progress && ( + + {progress.current}/{progress.total} + + )}
)} diff --git a/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.module.scss b/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.module.scss index b2e84bd1..a374832c 100644 --- a/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.module.scss +++ b/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.module.scss @@ -8,6 +8,10 @@ padding: v.$space-10 v.$space-6; background: v.$cream-bg; font-family: v.$ff-base; + + @include m.short { + padding: v.$space-3; + } } .card { @@ -22,6 +26,10 @@ @include m.mobile { padding: v.$space-6 v.$space-5; } + + @include m.short { + padding: v.$space-4; + } } .title { @@ -29,6 +37,11 @@ font-size: v.$text-2xl; font-weight: v.$fw-bold; color: v.$g900; + + @include m.short { + margin-bottom: v.$space-3; + font-size: v.$text-xl; + } } .timeTaken { @@ -36,6 +49,10 @@ font-size: v.$text-sm; color: v.$gray-500; font-variant-numeric: tabular-nums; + + @include m.short { + margin-top: v.$space-2; + } } .actions { @@ -43,6 +60,10 @@ justify-content: center; gap: v.$space-3; margin-top: v.$space-8; + + @include m.short { + margin-top: v.$space-3; + } } .reviewBtn { @@ -55,6 +76,10 @@ &:hover:not(:disabled) { background: v.$g800; } + + @include m.short { + padding: v.$space-2 v.$space-4; + } } .retakeBtn { @@ -65,6 +90,10 @@ color: v.$g700; border: 1.5px solid v.$gray-300; + @include m.short { + padding: v.$space-2 v.$space-4; + } + &:hover:not(:disabled) { border-color: v.$g400; background: v.$cream-bg; diff --git a/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.test.tsx b/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.test.tsx index 4edf682e..07626101 100644 --- a/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.test.tsx +++ b/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.test.tsx @@ -31,4 +31,67 @@ describe('ResultsScreen', () => { expect(onReviewAll).toHaveBeenCalledTimes(1); expect(onRetake).toHaveBeenCalledTimes(1); }); + + it('hides Retake when omitted (attempts exhausted — Angular parity: showReplay=false)', () => { + render(); + expect(screen.queryByRole('button', { name: /retake/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /review all/i })).toBeInTheDocument(); + }); + + // Angular parity (main-player.component.ts:488-524) — summaryType gates + // score/duration visibility. The correct/incorrect/partial/skipped breakdown + // has no Angular equivalent and is intentionally always shown regardless. + describe('summaryType (Angular parity)', () => { + it('"Complete" shows the score as a fraction', () => { + render( + , + ); + expect(screen.getByText('7 / 10')).toBeInTheDocument(); + }); + + it('"Score" shows a plain score and hides duration', () => { + render( + , + ); + expect(screen.getByText('7')).toBeInTheDocument(); + expect(screen.queryByText(/time taken/i)).not.toBeInTheDocument(); + }); + + it('"Duration" hides the score entirely', () => { + render( + , + ); + expect(screen.queryByText(/^score/i)).not.toBeInTheDocument(); + expect(screen.getByText('3:24')).toBeInTheDocument(); + }); + + it('"Score and Duration" (and absent) shows a plain score and duration', () => { + render( + , + ); + expect(screen.getByText('7')).toBeInTheDocument(); + expect(screen.getByText('3:24')).toBeInTheDocument(); + }); + + it('always shows the correct/incorrect/partial/skipped breakdown regardless of summaryType', () => { + render(); + expect(screen.getByRole('region', { name: /quiz summary/i })).toBeInTheDocument(); + expect(screen.getByText('6')).toBeInTheDocument(); // correct count + }); + }); }); diff --git a/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.tsx b/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.tsx index 9d146f8a..702cb652 100644 --- a/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.tsx +++ b/projects/sunbird-quml-player-react/src/components/ResultsScreen/ResultsScreen.tsx @@ -20,8 +20,17 @@ export interface ResultsScreenProps { }; /** Total seconds spent answering (shell timer); null/omitted hides the line. */ timeTaken?: number | null; + /** + * Angular parity (main-player.component.ts:488-524) — 'Complete' shows the + * score as a `score/max` fraction; 'Duration' hides the score entirely; + * anything else (including absent) shows a plain score number. Does NOT + * affect the correct/incorrect/partial/skipped breakdown below, which has + * no Angular equivalent and is intentionally always shown. + */ + summaryType?: string; onReviewAll: () => void; - onRetake: () => void; + /** Omit to hide the Retake CTA (Angular parity: `showReplay=false` once attempts are exhausted). */ + onRetake?: () => void; language?: string; } @@ -35,11 +44,16 @@ function formatMmSs(seconds: number): string { export function ResultsScreen({ summary, timeTaken = null, + summaryType, onReviewAll, onRetake, language = 'en', }: ResultsScreenProps) { const { totalScore } = summary; + const showScore = summaryType !== 'Duration'; + const showDuration = summaryType !== 'Score'; + const scoreLabel = + summaryType === 'Complete' && summary.maxScore ? `${totalScore} / ${summary.maxScore}` : undefined; return (
@@ -52,9 +66,12 @@ export function ResultsScreen({ partial={summary.partial} skipped={summary.skipped} totalScore={totalScore} + showScore={showScore} + scoreLabel={scoreLabel} + language={language} /> - {timeTaken != null && ( + {timeTaken != null && showDuration && (

{t(language, 'TIME_TAKEN')}: {formatMmSs(timeTaken)}

@@ -64,9 +81,11 @@ export function ResultsScreen({ - + {onRetake && ( + + )}
diff --git a/projects/sunbird-quml-player-react/src/components/ReviewScreen/ReviewScreen.module.scss b/projects/sunbird-quml-player-react/src/components/ReviewScreen/ReviewScreen.module.scss index 7eb326ab..cfa4432f 100644 --- a/projects/sunbird-quml-player-react/src/components/ReviewScreen/ReviewScreen.module.scss +++ b/projects/sunbird-quml-player-react/src/components/ReviewScreen/ReviewScreen.module.scss @@ -59,7 +59,11 @@ border-inline-end: 1px solid v.$gray-200; background: v.$ivory; - @include m.mobile { + // Mobile-app only — was width-only (`m.mobile`), so a landscape phone (short + // height, but wide enough to miss that width tier) still showed the full + // section list with its per-section question counts, same crowding problem + // as everything else we've compacted. `m.compact` covers both orientations. + @include m.compact { display: none; } } @@ -71,6 +75,8 @@ min-width: 0; overflow-y: auto; padding: v.$space-8 v.$space-12; + // Positioning context for .navBtn in compact mode (see below). + position: relative; @include m.tablet { padding: v.$space-6 v.$space-6; @@ -79,6 +85,17 @@ @include m.mobile { padding: v.$space-5 v.$space-4; } + + // Mobile-app only (see SectionPlayer.module.scss .content for the same + // treatment) — widen the inline gutter so the floating Prev/Next buttons + // land beside the question, not over it. Also shrink the top padding: with + // the counter hidden and the buttons floated out of flow, `.navBar` collapses + // to ~0 height, so the old top padding (sized for a full nav row) just left + // a tall gap above the verdict pill and card. + @include m.compact { + padding-block: v.$space-3 v.$space-4; + padding-inline: 2.75rem; + } } .verdict { @@ -88,6 +105,10 @@ border-radius: v.$r-pill; font-size: v.$text-sm; font-weight: v.$fw-semibold; + + @include m.compact { + margin-bottom: v.$space-3; + } } .correct { @@ -121,6 +142,13 @@ justify-content: space-between; gap: v.$space-4; margin-bottom: v.$space-6; + + // Mobile-app only: the counter is hidden and the buttons float out of flow + // (see .counter/.navBtn below), so this row has no in-flow content left — + // drop the margin it no longer needs to reserve. + @include m.compact { + margin-bottom: 0; + } } .counter { @@ -128,6 +156,12 @@ font-weight: v.$fw-medium; color: v.$g600; font-variant-numeric: tabular-nums; + + // Mobile-app only: replaced by QuestionCard's progress badge next to the + // question-type tag (same treatment as SectionPlayer.module.scss .counter). + @include m.compact { + display: none; + } } .navBtn { @@ -143,12 +177,50 @@ color: v.$g900; background: v.$cream-bg; } + + // Mobile-app only — float beside the question, vertically centered on the + // full main-column height (`.main`, not `.navBar`), icon-only circle. Same + // treatment as SectionPlayer.module.scss .navBtn. + @include m.compact { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 5; + min-width: 0; + min-height: 0; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border-radius: v.$r-pill; + background: v.$white; + box-shadow: v.$shadow-sm; + + &:hover:not(:disabled) { + transform: translateY(-50%); + } + } +} + +.navPrev { + @include m.compact { + left: v.$space-1; + } +} + +.navNext { + @include m.compact { + right: v.$space-1; + } } .navLabel { @include m.mobile { display: none; } + + @include m.compact { + display: none; + } } .empty { diff --git a/projects/sunbird-quml-player-react/src/components/ReviewScreen/ReviewScreen.tsx b/projects/sunbird-quml-player-react/src/components/ReviewScreen/ReviewScreen.tsx index 155c8f47..63734d7f 100644 --- a/projects/sunbird-quml-player-react/src/components/ReviewScreen/ReviewScreen.tsx +++ b/projects/sunbird-quml-player-react/src/components/ReviewScreen/ReviewScreen.tsx @@ -143,7 +143,7 @@ export function ReviewScreen({
- + void; + language?: string; + /** Angular parity (summaryType:'Duration') — hide the score line entirely. Default true. */ + showScore?: boolean; + /** Override the displayed score value (e.g. "7 / 10" for summaryType:'Complete'). Defaults to the plain `totalScore`. */ + scoreLabel?: string; } export function Scoreboard({ @@ -23,17 +29,20 @@ export function Scoreboard({ skipped = 0, totalScore = 0, onSubmit, + language = 'en', + showScore = true, + scoreLabel, }: ScoreboardProps) { const stats: Array<{ key: string; label: string; value: number; variant: string }> = [ - { key: 'correct', label: 'Correct', value: correct, variant: styles.correct }, - { key: 'incorrect', label: 'Incorrect', value: incorrect, variant: styles.incorrect }, - { key: 'partial', label: 'Partial', value: partial, variant: styles.partial }, - { key: 'skipped', label: 'Skipped', value: skipped, variant: styles.skipped }, + { key: 'correct', label: t(language, 'CORRECT'), value: correct, variant: styles.correct }, + { key: 'incorrect', label: t(language, 'INCORRECT'), value: incorrect, variant: styles.incorrect }, + { key: 'partial', label: t(language, 'PARTIAL'), value: partial, variant: styles.partial }, + { key: 'skipped', label: t(language, 'SKIPPED'), value: skipped, variant: styles.skipped }, ]; return ( -
-

Quiz Summary

+
+

{t(language, 'QUIZ_SUMMARY')}

{stats.map(({ key, label, value, variant }) => ( @@ -44,13 +53,15 @@ export function Scoreboard({ ))}
-

- Score: {totalScore} -

+ {showScore && ( +

+ {t(language, 'SCORE')}: {scoreLabel ?? totalScore} +

+ )} {onSubmit && ( )}
diff --git a/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.module.scss b/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.module.scss index ea51cd5a..d35a7df0 100644 --- a/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.module.scss +++ b/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.module.scss @@ -11,11 +11,46 @@ @include m.mobile { padding: v.$space-6 v.$space-4; } + + @include m.short { + padding: v.$space-3 v.$space-4; + } } -.card { +// Groups the back link + card into a single column so `.wrap`'s existing +// row-centering (over one child) still centers everything as a unit. +.column { width: 100%; max-width: 800px; + display: flex; + flex-direction: column; +} + +// Mirrors StartPage.module.scss .backBtn (back to Overview — the "first page"). +.backBtn { + @include m.focus-ring; + + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: v.$space-1; + margin-bottom: v.$space-3; + padding: v.$space-1 0; + background: none; + border: none; + color: v.$gray-500; + font-family: v.$ff-base; + font-size: v.$text-sm; + font-weight: v.$fw-semibold; + cursor: pointer; + + &:hover { + color: v.$g900; + } +} + +.card { + width: 100%; background: v.$white; border-radius: v.$r-lg; box-shadow: v.$shadow-lg; @@ -35,6 +70,11 @@ @include m.mobile { padding: v.$space-6; } + + @include m.short { + padding: v.$space-3; + gap: v.$space-3; + } } .bannerBadge { @@ -49,6 +89,12 @@ color: v.$white; font-size: v.$text-2xl; font-weight: v.$fw-bold; + + @include m.short { + width: 2.5rem; + height: 2.5rem; + font-size: v.$text-lg; + } } .bannerText { @@ -70,6 +116,10 @@ font-size: clamp(1.25rem, 3.5vw, #{v.$text-xl}); font-weight: v.$fw-bold; color: v.$white; + + @include m.short { + font-size: v.$text-md; + } } // Decorative translucent circle (top-right of the banner). @@ -81,6 +131,10 @@ height: 11rem; border-radius: v.$r-pill; background: rgba(255, 255, 255, 0.10); + + @include m.short { + display: none; + } } // ── Body ───────────────────────────────────────────────────────────────────── @@ -93,12 +147,21 @@ @include m.mobile { padding: v.$space-5; } + + @include m.short { + gap: v.$space-3; + padding: v.$space-3; + } } .instructions { padding: v.$space-6; background: v.$cream-bg; border-radius: v.$r-md; + + @include m.short { + padding: v.$space-3; + } } .instructionsHeading { @@ -108,6 +171,10 @@ letter-spacing: 0.08em; text-transform: uppercase; color: v.$gray-400; + + @include m.short { + margin-bottom: v.$space-1; + } } .instructionsBody { @@ -115,6 +182,10 @@ font-size: v.$text-md; line-height: v.$lh-normal; color: v.$g700; + + @include m.short { + font-size: v.$text-sm; + } } .startBtn { @@ -139,4 +210,9 @@ &:hover { background: v.$g800; } + + @include m.short { + padding: v.$space-3; + font-size: v.$text-base; + } } diff --git a/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.test.tsx b/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.test.tsx index 1c822b1e..b7ff308d 100644 --- a/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.test.tsx +++ b/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.test.tsx @@ -45,4 +45,24 @@ describe('SectionIntro', () => { fireEvent.click(screen.getByRole('button', { name: /start section b/i })); expect(onBegin).toHaveBeenCalledTimes(1); }); + + it('omits the Previous link when onPrevious is not provided', () => { + render(); + expect(screen.queryByRole('button', { name: /previous/i })).not.toBeInTheDocument(); + }); + + it('emits onPrevious (back to the overview) from the Previous link', () => { + const onPrevious = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /previous/i })); + expect(onPrevious).toHaveBeenCalledTimes(1); + }); }); diff --git a/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.tsx b/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.tsx index f02838b0..84c3b4c7 100644 --- a/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.tsx +++ b/projects/sunbird-quml-player-react/src/components/SectionIntro/SectionIntro.tsx @@ -1,4 +1,5 @@ import { t, readI18n } from '../../i18n/translations'; +import { PreviousIcon } from '../icons'; import type { Section } from '../../types'; import styles from './SectionIntro.module.scss'; @@ -14,47 +15,67 @@ export interface SectionIntroProps { sectionIndex: number; totalSections: number; onBegin: () => void; + /** Back to the assessment overview (the "first page"). Renders a Previous link when set. */ onPrevious?: () => void; language?: string; } -export function SectionIntro({ section, sectionIndex, onBegin, language = 'en' }: SectionIntroProps) { +export function SectionIntro({ + section, + sectionIndex, + onBegin, + onPrevious, + language = 'en', +}: SectionIntroProps) { const letter = String.fromCharCode(65 + sectionIndex); const questionCount = section.children?.length ?? 0; const instructions = readI18n(section.instructions, language) || t(language, 'MANDATORY_NOTE'); return (
-
-
- -
-

- {t(language, 'SECTION')} {letter} -

-

- {questionCount} {t(language, 'QUESTIONS')} -

-
-
+
+ {onPrevious && ( + + )} -
-
-

{t(language, 'INSTRUCTIONS')}

-
+
+
+ +
+

+ {t(language, 'SECTION')} {letter} +

+

+ {questionCount} {t(language, 'QUESTIONS')} +

+
+
- -
-
+
+
+

{t(language, 'INSTRUCTIONS')}

+
+
+ + +
+
+ ); } diff --git a/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.module.scss b/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.module.scss index dd3ffbb9..9168c492 100644 --- a/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.module.scss +++ b/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.module.scss @@ -9,6 +9,9 @@ // whole player growing past a fixed-height host. height: 100%; min-height: 0; + // Positioning context for .navBtn in compact mode (see below) — harmless + // otherwise since nothing else in here is positioned. + position: relative; } // Shared centered column so the nav bar and question card line up 1:1. @@ -32,10 +35,31 @@ @include m.mobile { padding: v.$space-5 v.$space-4; } + + @include m.short { + padding: v.$space-3 v.$space-4; + + // On a short viewport most questions don't need the full height, so + // stretching the card to fill it just inflates the card's own blank + // interior. Let the card size to its content instead — any leftover + // space shows as the shell's background, not dead space inside a card. + > * { + flex: 0 1 auto; + } + } + + // Compact (mobile-app) layout floats Prev/Next beside the card instead of in + // a top bar (see .navBtn below) — widen the inline gutter so they land + // beside the card, not over it. + @include m.compact { + padding-inline: 2.75rem; + } } // Question navigation, now at the TOP of the player (was a bottom footer). // Same max-width + gutters as .content so its edges align with the card. +// Desktop/tablet/portal/editor: a normal top bar. Mobile-app only (m.compact, +// below) — visually floats Prev/Next to the sides instead. .navBar { display: flex; align-items: center; @@ -49,6 +73,10 @@ @include m.mobile { padding: v.$space-3 v.$space-4; } + + @include m.short { + padding: v.$space-2 v.$space-4; + } } .counter { @@ -58,6 +86,12 @@ color: v.$g600; font-variant-numeric: tabular-nums; white-space: nowrap; + + // Mobile-app only: replaced by QuestionCard's progress badge next to the + // question-type tag (see QuestionCard.module.scss .progress). + @include m.compact { + display: none; + } } .navBtn { @@ -87,12 +121,51 @@ min-width: 2.75rem; min-height: 2.75rem; } + + // Mobile-app only: float beside the card, vertically centered on the + // section's full height (`.sectionPlayer`, not `.navBar`), icon-only circle. + // Desktop/tablet/portal/editor are untouched — this rule only fires under + // m.compact. + @include m.compact { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 5; + min-width: 0; + min-height: 0; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border-radius: v.$r-pill; + + // The base hover lift (translateY(-1px)) would fight the centering + // transform above — keep the shadow/color feedback, drop the lift. + &:hover:not(:disabled) { + transform: translateY(-50%); + } + } +} + +.navPrev { + @include m.compact { + left: v.$space-1; + } +} + +.navNext { + @include m.compact { + right: v.$space-1; + } } .navLabel { @include m.mobile { display: none; } + + @include m.compact { + display: none; + } } .submit { diff --git a/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.tsx b/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.tsx index 8a8f7885..f756260b 100644 --- a/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.tsx +++ b/projects/sunbird-quml-player-react/src/components/SectionPlayer/SectionPlayer.tsx @@ -201,7 +201,7 @@ export function SectionPlayer({ section, onSectionEnd, isLastSection = true }: S
+ {/* Desktop/tablet only (hidden in compact mode via QuestionCard's + progress badge) — kept here so the counter stays centered. */} {t(language, 'QUESTION')} {currentSlide + 1} {t(language, 'OF')} {questions.length} @@ -222,7 +224,7 @@ export function SectionPlayer({ section, onSectionEnd, isLastSection = true }: S // non-interactive (aria-hidden, not focusable, disabled). + ))} + +
+ )} -

- - {t(language, 'TIMER_START_NOTE', { attempts: attemptsLeft })} -

+ {showSections && ( + // Mobile-app only: also a flex column, but the SECTION GRID is the + // flex:1/scrollable piece (not the whole screen) — heading/note stay + // pinned at top and the Start/Resume CTA + footer note stay pinned + // at the bottom, so the CTA is always reachable without scrolling + // even with many sections; only the grid between them scrolls. +
+ {isCompact && ( + + )} + +

{t(language, 'ASSESSMENT_SECTIONS')}

+

{t(language, 'SECTIONS_COVER_NOTE')}

+ +
+ {sections.map((section, i) => { + const blurb = readI18n(section.description, language); + const name = readI18n(section.name, language); + const Tag = onSectionSelect ? 'button' : 'div'; + return ( + onSectionSelect(i) } + : {})} + > +
+ {i < BADGE_COLORS.length && ( + + )} + {name} + +
+ {questionLabel(section.children.length)} + {blurb && {blurb}} +
+ ); + })} +
+ + + +

+ + {t(language, hasStarted ? 'TIMER_RESUME_NOTE' : 'TIMER_START_NOTE', { attempts: attemptsDisplay })} +

+
+ )} + + {/* Mobile-app only — a small floating "Next" over the info screen's + bottom-right corner (screen 1 → screen 2, see `step` above). */} + {isCompact && showInfo && ( + + )} ); } diff --git a/projects/sunbird-quml-player-react/src/components/StartPage/useIsCompactViewport.ts b/projects/sunbird-quml-player-react/src/components/StartPage/useIsCompactViewport.ts new file mode 100644 index 00000000..f9496806 --- /dev/null +++ b/projects/sunbird-quml-player-react/src/components/StartPage/useIsCompactViewport.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import type { RefObject } from 'react'; + +// Mirrors the SCSS `m.compact` mixin (styles/mixins.scss) — narrow width OR +// short height. Kept in sync manually: SCSS can't export tokens to JS, and +// this is the one piece of "mobile-app-only" behavior that can't be done in +// pure CSS (it changes what's IN the DOM — paging the overview into two +// screens — not just how existing DOM looks). +const MOBILE_MAX_WIDTH = 768; +const SHORT_MAX_HEIGHT = 720; + +/** + * True when the player is effectively rendering as the mobile app — narrow + * width (container-based, so it also matches the editor's mobile preview) OR + * a short viewport (a real device in landscape). False on portal/desktop/ + * editor-desktop-preview, where nothing here should change behavior. + */ +export function useIsCompactViewport(containerRef: RefObject): boolean { + const [isCompact, setIsCompact] = useState(false); + + useEffect(() => { + const el = containerRef.current; + // Both are unavailable under jsdom (no test env implements viewport + // layout anyway) — fall back to the default `false` (today's single-page + // layout), which is what every existing test already renders against. + if (!el || typeof ResizeObserver === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + + const heightQuery = window.matchMedia(`(max-height: ${SHORT_MAX_HEIGHT}px)`); + const update = () => { + const width = el.getBoundingClientRect().width; + setIsCompact(width <= MOBILE_MAX_WIDTH || heightQuery.matches); + }; + + const ro = new ResizeObserver(update); + ro.observe(el); + heightQuery.addEventListener('change', update); + update(); + + return () => { + ro.disconnect(); + heightQuery.removeEventListener('change', update); + }; + }, [containerRef]); + + return isCompact; +} diff --git a/projects/sunbird-quml-player-react/src/components/questions/ReoQuestion/ReoQuestion.module.scss b/projects/sunbird-quml-player-react/src/components/questions/ReoQuestion/ReoQuestion.module.scss index 003bbd6e..20cbe3bc 100644 --- a/projects/sunbird-quml-player-react/src/components/questions/ReoQuestion/ReoQuestion.module.scss +++ b/projects/sunbird-quml-player-react/src/components/questions/ReoQuestion/ReoQuestion.module.scss @@ -91,6 +91,12 @@ font-size: v.$text-sm; font-weight: v.$fw-medium; cursor: grab; + // Touch drag: prevent the webview from consuming the finger-drag as a scroll + // so react-dnd's TouchBackend receives the move (else the chip won't move on + // mobile). + touch-action: none; + user-select: none; + -webkit-user-select: none; &:hover:not(:disabled) { border-color: v.$brick; diff --git a/projects/sunbird-quml-player-react/src/dev/sample-data.ts b/projects/sunbird-quml-player-react/src/dev/sample-data.ts index 5b8761ca..4bd74c8c 100644 --- a/projects/sunbird-quml-player-react/src/dev/sample-data.ts +++ b/projects/sunbird-quml-player-react/src/dev/sample-data.ts @@ -10,7 +10,10 @@ import type { PlayerConfig } from '../types'; */ export const sampleConfig: PlayerConfig = { context: { uid: 'dev-user', sid: 'dev-session', channel: 'dev' }, - config: { language: 'en', showFeedback: true, maxAttempts: 3 }, + config: { language: 'en', showFeedback: true }, + // maxAttempts is host/backend data (Angular parity: playerConfig.metadata), + // not a player UI setting. + metadata: { maxAttempts: 3 }, data: { identifier: 'do_sample_set', name: 'Sunbird Assessment', diff --git a/projects/sunbird-quml-player-react/src/i18n/translations-ar.ts b/projects/sunbird-quml-player-react/src/i18n/translations-ar.ts index c1be888b..1ee31d39 100644 --- a/projects/sunbird-quml-player-react/src/i18n/translations-ar.ts +++ b/projects/sunbird-quml-player-react/src/i18n/translations-ar.ts @@ -38,6 +38,7 @@ export const translations: Record = { MINUTES: 'دقائق', // Scoring + QUIZ_SUMMARY: 'ملخّص الاختبار', SCORE: 'الدرجة', TOTAL_SCORE: 'الدرجة الكلية', CORRECT: 'صحيح', @@ -95,9 +96,12 @@ export const translations: Record = { ASSESSMENT_SECTIONS: 'أقسام التقييم', SECTIONS_COVER_NOTE: 'إليك ما ستغطّيه في هذا التقييم.', START_ASSESSMENT: 'بدء التقييم', + RESUME_ASSESSMENT: 'استئناف التقييم', ATTEMPTS_LEFT: 'المحاولات المتبقية', MINUTES_LABEL: 'دقائق', + NO_LIMIT: 'بلا حدّ', TIMER_START_NOTE: 'يبدأ المؤقّت عند النقر. لديك {attempts} محاولات لهذا التقييم.', + TIMER_RESUME_NOTE: 'المؤقّت ما زال يعمل. لديك {attempts} محاولات لهذا التقييم.', // Assessment shell header / section intro (Phase 6 design) START_SECTION: 'بدء القسم', diff --git a/projects/sunbird-quml-player-react/src/i18n/translations-en.ts b/projects/sunbird-quml-player-react/src/i18n/translations-en.ts index e2e94adf..7fed1c3c 100644 --- a/projects/sunbird-quml-player-react/src/i18n/translations-en.ts +++ b/projects/sunbird-quml-player-react/src/i18n/translations-en.ts @@ -35,6 +35,7 @@ export const translations: Record = { MINUTES: 'minutes', // Scoring + QUIZ_SUMMARY: 'Quiz Summary', SCORE: 'Score', TOTAL_SCORE: 'Total Score', CORRECT: 'Correct', @@ -92,9 +93,12 @@ export const translations: Record = { ASSESSMENT_SECTIONS: 'Assessment sections', SECTIONS_COVER_NOTE: "Here's what you'll be covering in this assessment.", START_ASSESSMENT: 'Start assessment', + RESUME_ASSESSMENT: 'Resume assessment', ATTEMPTS_LEFT: 'Attempts Left', MINUTES_LABEL: 'Minutes', + NO_LIMIT: 'No Limit', TIMER_START_NOTE: 'The timer starts when you click. You have {attempts} attempts for this assessment.', + TIMER_RESUME_NOTE: 'Your timer is still running. You have {attempts} attempts for this assessment.', // Assessment shell header / section intro (Phase 6 design) START_SECTION: 'Start section', diff --git a/projects/sunbird-quml-player-react/src/i18n/translations-fr.ts b/projects/sunbird-quml-player-react/src/i18n/translations-fr.ts index 2f05e04e..6a2a9cbd 100644 --- a/projects/sunbird-quml-player-react/src/i18n/translations-fr.ts +++ b/projects/sunbird-quml-player-react/src/i18n/translations-fr.ts @@ -38,6 +38,7 @@ export const translations: Record = { MINUTES: 'minutes', // Scoring + QUIZ_SUMMARY: 'Résumé du quiz', SCORE: 'Score', TOTAL_SCORE: 'Score total', CORRECT: 'Correct', @@ -95,10 +96,14 @@ export const translations: Record = { ASSESSMENT_SECTIONS: 'Sections de l’évaluation', SECTIONS_COVER_NOTE: 'Voici ce que vous allez aborder dans cette évaluation.', START_ASSESSMENT: 'Commencer l’évaluation', + RESUME_ASSESSMENT: 'Reprendre l’évaluation', ATTEMPTS_LEFT: 'Tentatives restantes', MINUTES_LABEL: 'Minutes', + NO_LIMIT: 'Sans limite', TIMER_START_NOTE: 'Le minuteur démarre lorsque vous cliquez. Vous avez {attempts} tentatives pour cette évaluation.', + TIMER_RESUME_NOTE: + 'Votre minuteur est toujours en cours. Vous avez {attempts} tentatives pour cette évaluation.', // Assessment shell header / section intro (Phase 6 design) START_SECTION: 'Commencer la section', diff --git a/projects/sunbird-quml-player-react/src/i18n/translations-pt.ts b/projects/sunbird-quml-player-react/src/i18n/translations-pt.ts index 0a124d2e..8126f13d 100644 --- a/projects/sunbird-quml-player-react/src/i18n/translations-pt.ts +++ b/projects/sunbird-quml-player-react/src/i18n/translations-pt.ts @@ -38,6 +38,7 @@ export const translations: Record = { MINUTES: 'minutos', // Scoring + QUIZ_SUMMARY: 'Resumo do questionário', SCORE: 'Pontuação', TOTAL_SCORE: 'Pontuação total', CORRECT: 'Correto', @@ -95,10 +96,14 @@ export const translations: Record = { ASSESSMENT_SECTIONS: 'Seções da avaliação', SECTIONS_COVER_NOTE: 'Veja o que você abordará nesta avaliação.', START_ASSESSMENT: 'Iniciar avaliação', + RESUME_ASSESSMENT: 'Continuar avaliação', ATTEMPTS_LEFT: 'Tentativas restantes', MINUTES_LABEL: 'Minutos', + NO_LIMIT: 'Sem limite', TIMER_START_NOTE: 'O cronômetro começa quando você clica. Você tem {attempts} tentativas para esta avaliação.', + TIMER_RESUME_NOTE: + 'Seu cronômetro continua em execução. Você tem {attempts} tentativas para esta avaliação.', // Assessment shell header / section intro (Phase 6 design) START_SECTION: 'Iniciar seção', diff --git a/projects/sunbird-quml-player-react/src/styles/mixins.scss b/projects/sunbird-quml-player-react/src/styles/mixins.scss index 03bf373b..37533e92 100644 --- a/projects/sunbird-quml-player-react/src/styles/mixins.scss +++ b/projects/sunbird-quml-player-react/src/styles/mixins.scss @@ -34,6 +34,37 @@ } } +// Respond to a short viewport (e.g. a phone in landscape), regardless of width. +// `.appShell`'s container is `inline-size`-only (see its comment), so width +// tiers above can't see height at all — a landscape phone can be "desktop" or +// "tablet" width while its actual height is a fraction of a portrait phone's, +// pushing primary CTAs (e.g. "Start assessment") below the fold. This is a real +// `@media` height query (not `@container`), because block-size containment +// isn't set up (doing so risks the height:100% chain — see the freeze-fix +// history) and, on real devices, the player fills the viewport anyway so +// viewport height == available height. +@mixin short { + @media (max-height: #{v.$bp-short-height}) { + @content; + } +} + +// "Are we effectively the mobile app" — narrow width OR short height. A real +// device is one or the other depending on orientation (portrait → narrow, +// landscape → short), while portal/desktop/editor stay comfortably outside +// both. Use this instead of `mobile`/`short` individually for changes that +// should be mobile-app-only and must NOT show up on desktop/portal/editor at +// any orientation. `@container` and `@media` can't be OR'd in one rule, so +// this just repeats @content under each condition. +@mixin compact { + @include mobile { + @content; + } + @include short { + @content; + } +} + // Accessible focus ring — white halo + primary outline (keyboard focus). @mixin focus-ring($color: v.$brick) { outline: none; diff --git a/projects/sunbird-quml-player-react/src/styles/variables.scss b/projects/sunbird-quml-player-react/src/styles/variables.scss index 0635f56d..11887845 100644 --- a/projects/sunbird-quml-player-react/src/styles/variables.scss +++ b/projects/sunbird-quml-player-react/src/styles/variables.scss @@ -112,3 +112,4 @@ $sidebar-w: 280px; $header-h: 4rem; $bp-mobile: 768px; $bp-tablet: 1024px; // tablet/laptop boundary — ≤1023px is "tablet and below" +$bp-short-height: 720px; // landscape-phone height boundary — see m.short