Quiz Assessment Runner
blockeducationInteractive multiple-choice exam & quiz assessment runner: 2-column test canvas with real-time countdown timer, question navigator grid with status indicators (completed, active, flagged, unanswered), syntax-highlighted code snippets, single-choice selection cards, submit confirmation modal, and comprehensive score breakdown with review explanations.
Installation
$pnpm dlx uipkge-ng@latest add quiz-assessment-runner$npx uipkge-ng@latest add quiz-assessment-runner$yarn dlx uipkge-ng@latest add quiz-assessment-runner$bunx uipkge-ng@latest add quiz-assessment-runnerManifest:
https://uipkge.dev/r/angular/quiz-assessment-runner.jsonInstalls to:src/app/components/blocks/quiz-assessment-runner/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
QuizOptioninterface QuizOption {
id: string
text: string
}QuizQuestioninterface QuizQuestion {
id: number
category: string
prompt: string
codeSnippet?: string
codeLanguage?: string
options: QuizOption[]
correctOptionId: string
explanation: string
}Theming
CSS custom properties referenced in this item. Override any of them in your :root or per-element to retheme.
--background--card--foreground--primaryFiles installed (2)
src/app/components/blocks/quiz-assessment-runner/quiz-assessment-runner.component.ts47.1 kB
// Interactive multiple-choice exam & quiz assessment runner: 2-column test canvas with // real-time countdown timer, question navigator grid with status indicators, code context // snippets, single-choice selection cards, submit confirmation modal, and a comprehensive // score breakdown with review explanations. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core' import { AlertCircle, Check, CheckCircle2, ChevronLeft, ChevronRight, Clock, FileCode, Flag, Lightbulb, LogOut, LucideAngularModule, RotateCcw, XCircle, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiProgressComponent } from '@/ui/progress/progress.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' export interface QuizOption { id: string text: string } export interface QuizQuestion { id: number category: string prompt: string codeSnippet?: string codeLanguage?: string options: QuizOption[] correctOptionId: string explanation: string } export const DEFAULT_QUESTIONS: QuizQuestion[] = [ { id: 1, category: 'Architecture', prompt: 'What is the primary architectural difference between a UI Primitive and a UI Block in the UIPKGE component registry?', codeSnippet: `// Primitive (registry:ui) vs Block (registry:block)\nconst Primitive = <Button variant="outline">Save Changes</Button>\nconst Block = <QuizAssessmentRunner initialQuestion={1} />`, codeLanguage: 'TypeScript', options: [ { id: 'A', text: 'Primitives are pre-compiled npm packages, while Blocks are static JSON schema files.' }, { id: 'B', text: 'Primitives encapsulate low-level mechanics and styling tokens, while Blocks compose primitives into transparent, copy-pastable domain layouts.', }, { id: 'C', text: 'Primitives can only be rendered server-side, while Blocks require client-side WebSockets.' }, { id: 'D', text: 'Primitives require Zod runtime validation schemas, while Blocks only support TypeScript interfaces.', }, ], correctOptionId: 'B', explanation: 'In the UIPKGE architecture, primitives (registry:ui) encapsulate accessibility, focus states, and styling tokens, whereas blocks (registry:block) compose those primitives raw into unabstracted layouts where developers own and customize the code.', }, { id: 2, category: 'Vue 3.5 SSR', prompt: 'Why must CVA (Class Variance Authority) variant functions in Vue registry components be placed in a dedicated `<name>.variants.ts` file rather than `index.ts`?', codeSnippet: `// button.variants.ts\nexport const buttonVariants = cva(...)\n\n// Button.vue\nimport { buttonVariants } from './button.variants'\n\n// index.ts\nexport { default as Button } from './Button.vue'\nexport { buttonVariants } from './button.variants'`, codeLanguage: 'TypeScript', options: [ { id: 'A', text: 'To comply with ECMAScript dynamic tree-shaking requirements for bundlers.' }, { id: 'B', text: 'To prevent Vue SSR circular module dependency deadlocks where $setup.xxxVariants is undefined at runtime.', }, { id: 'C', text: 'Because TypeScript forbids exporting types and constants from the same index file.' }, { id: 'D', text: 'To enable hot-module replacement specifically for PostCSS variable transformations.' }, ], correctOptionId: 'B', explanation: 'Circular imports between Component.vue and index.ts cause runtime evaluation failures during SSR/SSG pre-rendering, resulting in "$setup.xxxVariants is not a function" errors.', }, { id: 3, category: 'Design Tokens', prompt: "Which Tailwind CSS v4 directive establishes the OKLCH design token mapping in UIPKGE's theme layer?", codeSnippet: `@theme inline {\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-primary: var(--primary);\n --color-card: var(--card);\n}`, codeLanguage: 'CSS', options: [ { id: 'A', text: '@apply tokens.oklch;' }, { id: 'B', text: '@theme inline' }, { id: 'C', text: "@config 'tailwind.theme.ts';" }, { id: 'D', text: '@utility theme-variables;' }, ], correctOptionId: 'B', explanation: 'Tailwind CSS v4 uses `@theme inline` blocks to bind OKLCH CSS variables directly to Tailwind utility classes without needing a separate tailwind.config.js file.', }, { id: 4, category: 'Reka UI / Vue', prompt: 'When authoring polymorphic components in Vue 3.5 with Reka UI, which pattern cleanly strips the class prop for `cn()` forwarding?', codeSnippet: `const props = defineProps<ButtonProps>()\nconst delegatedProps = reactiveOmit(props, 'class')\nconst forwarded = useForwardProps(delegatedProps)`, codeLanguage: 'TypeScript', options: [ { id: 'A', text: "Directly passing v-bind='props' to the template without stripping the class prop." }, { id: 'B', text: "Using reactiveOmit(props, 'class') combined with useForwardProps and explicit :class='cn(...)'. ", }, { id: 'C', text: 'Using Object.assign({}, props) inside an onMounted lifecycle hook.' }, { id: 'D', text: "Using v-bind='$attrs' with inheritAttrs: true set on the SFC." }, ], correctOptionId: 'B', explanation: 'reactiveOmit from @vueuse/core strips the class attribute while retaining reactivity, allowing custom class names to merge via cn() on the root element.', }, { id: 5, category: 'Design Craft', prompt: 'Why is the sub-12px micro-text rule (avoiding `text-xs`, `text-xs`) strictly enforced in UIPKGE craft standards?', codeSnippet: `// Anti-pattern:\n<span class="text-xs uppercase text-muted-foreground">Status</span>\n\n// Standard-compliant:\n<span class="text-xs font-medium uppercase text-muted-foreground">Status</span>`, codeLanguage: 'HTML', options: [ { id: 'A', text: 'Sub-12px text triggers subpixel antialiasing rendering bugs on macOS WebKit.' }, { id: 'B', text: 'Arbitrary micro-text degrades legibility, violates WCAG AA contrast guidelines, and fragments typographic scale discipline.', }, { id: 'C', text: 'Tailwind CSS v4 ignores arbitrary bracket pixel values in production builds.' }, { id: 'D', text: 'Screen readers automatically skip DOM elements rendered smaller than 12px.' }, ], correctOptionId: 'B', explanation: 'UIPKGE design craft mandates semantic typography starting at text-xs (12px) for badges and metadata to maintain readability and systematic hierarchy across devices.', }, { id: 6, category: 'React Architecture', prompt: 'In the React registry mirror, what headless primitive pattern allows consumers to pass custom trigger elements via `asChild`?', codeSnippet: `const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n ({ className, asChild = false, ...props }, ref) => {\n const Comp = asChild ? Slot : 'button'\n return <Comp ref={ref} className={cn(buttonVariants(), className)} {...props} />\n }\n)`, codeLanguage: 'TSX', options: [ { id: 'A', text: 'Radix UI Slot primitive forwarded with asChild boolean prop.' }, { id: 'B', text: 'React cloneElement with recursive prop decoration.' }, { id: 'C', text: 'Custom Shadow DOM web component injection.' }, { id: 'D', text: 'Direct DOM mutation through React portal boundaries.' }, ], correctOptionId: 'A', explanation: 'The Radix UI Slot primitive merges props and event listeners directly onto its immediate child, enabling seamless element composition without wrapper DIVs.', }, { id: 7, category: 'Typography / UX', prompt: 'Which CSS font utility prevents numerical values like timers, counters, and metrics from causing horizontal layout shift during updates?', codeSnippet: `<div class="font-mono text-sm font-semibold tabular-nums text-foreground">\n 18:42 remaining\n</div>`, codeLanguage: 'HTML', options: [ { id: 'A', text: 'tabular-nums (font-variant-numeric: tabular-nums)' }, { id: 'B', text: 'font-stretch-condensed' }, { id: 'C', text: 'tracking-tightest' }, { id: 'D', text: 'text-balance' }, ], correctOptionId: 'A', explanation: 'tabular-nums enforces uniform width for numerical glyphs (0-9), preventing visual jitter when numbers change in timers and live feeds.', }, { id: 8, category: 'Registry Conventions', prompt: 'What is the core rule regarding data arrays and tile layout abstractions when authoring UIPKGE blocks?', codeSnippet: `// Allowed in blocks:\n<div class="grid grid-cols-1 md:grid-cols-3 gap-4">\n <Card><CardHeader><CardTitle>Total Revenue</CardTitle></CardHeader>...</Card>\n <Card><CardHeader><CardTitle>Active Users</CardTitle></CardHeader>...</Card>\n</div>`, codeLanguage: 'TSX', options: [ { id: 'A', text: 'Always abstract card lists into a generic <StatCard items={data} /> primitive.' }, { id: 'B', text: 'Blocks must compose primitives raw and top-to-bottom so developers can inspect and edit layout structure directly.', }, { id: 'C', text: 'Never render more than two cards per block layout.' }, { id: 'D', text: 'All block components must persist their state to IndexedDB storage.' }, ], correctOptionId: 'B', explanation: 'UIPKGE explicitly bans monolithic StatCard-shaped primitives. Blocks must expose raw primitive composition inline so users have full ownership of the rendered markup.', }, { id: 9, category: 'Accessibility (a11y)', prompt: 'Which focus ring class pattern guarantees high-contrast keyboard accessibility without creating persistent outlines on mouse clicks?', codeSnippet: `class="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none"`, codeLanguage: 'HTML', options: [ { id: 'A', text: 'focus:outline-none with no replacement outline or ring.' }, { id: 'B', text: 'focus-visible:ring-2 focus-visible:ring-ring with visible focus ring tokens.' }, { id: 'C', text: 'active:scale-95 on all interactive mouse events.' }, { id: 'D', text: 'hover:border-destructive on standard form input elements.' }, ], correctOptionId: 'B', explanation: 'focus-visible only applies the focus ring when keyboard navigation is detected, preserving clean aesthetics on mouse clicks while strictly satisfying WCAG 2.1 criteria.', }, { id: 10, category: 'Registry Distribution', prompt: 'What is the purpose of the `registryDependencies` array in `<name>.registry.ts` item manifests?', codeSnippet: `export default defineRegistryItem({\n name: 'quiz-assessment-runner',\n type: 'registry:block',\n registryDependencies: [\n 'https://uipkge.dev/r/badge.json',\n 'https://uipkge.dev/r/button.json',\n 'https://uipkge.dev/r/card.json',\n ],\n})`, codeLanguage: 'TypeScript', options: [ { id: 'A', text: 'It publishes private registry tarballs directly to npmjs.com.' }, { id: 'B', text: 'It instructs shadcn / shadcn-vue CLIs to transitively resolve and copy required UI primitives into the consumer project.', }, { id: 'C', text: 'It configures Webpack chunk splitting for client-side routing.' }, { id: 'D', text: 'It generates backend database migration tables automatically.' }, ], correctOptionId: 'B', explanation: 'registryDependencies allows shadcn and shadcn-vue CLI tools to resolve and download all required component dependencies when installing a block.', }, ] export type ReviewFilter = 'all' | 'correct' | 'incorrect' | 'flagged' @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-quiz-assessment-runner, [ui-quiz-assessment-runner]', standalone: true, host: { '[attr.data-slot]': '"quiz-assessment-runner"', '[class]': 'hostClass', }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiProgressComponent, UiSeparatorComponent, ], template: ` @if (!isSubmitted()) { <!-- Quiz Top Bar / Header --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-content class="p-4 sm:p-5"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <div class="flex flex-wrap items-center gap-2"> <span ui-badge variant="secondary" class="text-xs font-medium"> Certification Assessment </span> <span ui-badge variant="outline" class="text-muted-foreground font-mono text-xs"> Exam #TS-804 </span> </div> <h1 class="text-foreground text-base font-bold tracking-tight sm:text-lg"> TypeScript & Component Architecture Certification Quiz </h1> </div> <div class="flex flex-wrap items-center gap-2.5 sm:gap-3"> <div [class]="timerPillClass"> <lucide-icon [img]="Clock" class="size-3.5 shrink-0" /> <span class="font-mono font-semibold tabular-nums">{{ formattedTime() }}</span> <span class="text-muted-foreground text-xs">remaining</span> </div> <span ui-badge variant="outline" class="h-8 px-2.5 text-xs font-medium tabular-nums"> Question {{ currentQuestionIndex() + 1 }} of {{ totalQuestions }} </span> <button ui-button variant="ghost" size="sm" class="text-muted-foreground hover:text-foreground text-xs" (click)="isExitDialogOpen.set(true)" > <lucide-icon [img]="LogOut" class="mr-1.5 size-3.5" /> Exit Quiz </button> </div> </div> </div> </div> <!-- 2-Column Quiz Canvas --> <div class="grid grid-cols-1 items-start gap-6 lg:grid-cols-12"> <div class="space-y-6 lg:col-span-8"> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="space-y-3 pb-4"> <div class="flex items-center justify-between gap-2"> <div class="flex items-center gap-2"> <span ui-badge variant="secondary" class="text-xs font-medium"> {{ currentQuestion().category }} </span> <span class="text-muted-foreground text-xs font-medium tabular-nums"> Question {{ currentQuestionIndex() + 1 }} of {{ totalQuestions }} </span> </div> <button ui-button variant="outline" size="sm" [class]="flagButtonClass" (click)="toggleFlag(currentQuestion().id)" > <lucide-icon [img]="Flag" [class]=" flagged().has(currentQuestion().id) ? 'fill-warning text-warning mr-1.5 size-3.5' : 'mr-1.5 size-3.5' " /> {{ flagged().has(currentQuestion().id) ? 'Flagged' : 'Flag for Review' }} </button> </div> <h3 ui-card-title class="text-foreground text-base leading-relaxed font-semibold sm:text-lg"> {{ currentQuestion().prompt }} </h3> </div> <div ui-card-content class="space-y-5"> @if (currentQuestion().codeSnippet) { <div class="border-border/80 bg-muted/40 dark:bg-muted/20 overflow-hidden rounded-lg border"> <div class="border-border/70 bg-muted/80 dark:bg-muted/40 flex items-center justify-between border-b px-3.5 py-1.5" > <div class="flex items-center gap-2"> <lucide-icon [img]="FileCode" class="text-muted-foreground size-3.5" /> <span class="text-muted-foreground font-mono text-xs font-medium"> {{ currentQuestion().codeLanguage || 'TypeScript' }} </span> </div> <span class="text-muted-foreground font-mono text-xs">Context</span> </div> <pre class="text-foreground/90 overflow-x-auto p-4 font-mono text-xs leading-relaxed whitespace-pre" ><code>{{ currentQuestion().codeSnippet }}</code></pre> </div> } <div class="space-y-3"> <p class="text-muted-foreground text-xs font-semibold tracking-wider uppercase"> Select the correct answer: </p> <div class="grid gap-2.5"> @for (opt of currentQuestion().options; track opt.id) { <div role="button" tabindex="0" [class]="optionClass(opt.id)" (click)="selectOption(opt.id)" (keydown.enter)="selectOption(opt.id)" (keydown.space)="selectOption(opt.id); $event.preventDefault()" > <div [class]="optionLetterClass(opt.id)">{{ opt.id }}</div> <div class="flex-1 space-y-0.5"> <p class="text-foreground text-sm leading-relaxed font-medium">{{ opt.text }}</p> </div> @if (answers()[currentQuestion().id] === opt.id) { <div class="text-primary mt-0.5 shrink-0"> <lucide-icon [img]="Check" class="size-4" /> </div> } </div> } </div> </div> </div> <div ui-card-footer class="border-border bg-muted/10 flex flex-wrap items-center justify-between gap-3 border-t p-4 sm:p-5" > <button ui-button variant="outline" size="sm" [disabled]="currentQuestionIndex() === 0" class="text-xs" (click)="prevQuestion()" > <lucide-icon [img]="ChevronLeft" class="mr-1 size-4" /> Previous Question </button> <div class="flex items-center gap-2"> @if (currentQuestionIndex() === totalQuestions - 1) { <button ui-button variant="default" size="sm" class="text-xs font-medium" (click)="isSubmitDialogOpen.set(true)" > <lucide-icon [img]="CheckCircle2" class="mr-1.5 size-4" /> Review & Submit </button> } @else { <button ui-button variant="default" size="sm" class="text-xs font-medium" (click)="nextQuestion()"> Save & Next <lucide-icon [img]="ChevronRight" class="ml-1 size-4" /> </button> } </div> </div> </div> </div> <!-- Right: Question Navigation Sidebar --> <div class="space-y-4 lg:sticky lg:top-6 lg:col-span-4"> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <h3 ui-card-title class="text-sm font-semibold">Question Navigator</h3> <span ui-badge variant="outline" class="font-mono text-xs tabular-nums"> {{ answeredCount() }}/{{ totalQuestions }} done </span> </div> <p ui-card-description class="text-xs">Jump directly to any question or review status.</p> </div> <div ui-card-content class="space-y-4"> <div class="space-y-1.5"> <div class="flex items-center justify-between text-xs"> <span class="text-muted-foreground font-medium">Exam Progress</span> <span class="text-foreground font-semibold tabular-nums">{{ progressPercentage() }}% Complete</span> </div> <ui-progress [value]="progressPercentage()" class="h-2" /> </div> <ui-separator /> <div class="space-y-2"> <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase"> Questions </span> <div class="grid grid-cols-5 gap-2"> @for (q of questions; track q.id; let idx = $index) { <button type="button" [class]="navButtonClass(idx, q.id)" (click)="goToQuestion(idx)"> {{ q.id }} @if (flagged().has(q.id) && idx !== currentQuestionIndex()) { <span class="ring-background bg-warning absolute -top-1 -right-1 size-2 rounded-full ring-2" title="Flagged" ></span> } </button> } </div> </div> <div class="border-border/70 bg-muted/30 space-y-2 rounded-lg border p-3 text-xs"> <span class="text-foreground font-medium">Status Legend</span> <div class="text-muted-foreground grid grid-cols-2 gap-2"> <div class="flex items-center gap-1.5"> <span class="bg-success size-2 rounded-full"></span> <span>Answered ({{ answeredCount() }})</span> </div> <div class="flex items-center gap-1.5"> <span class="bg-warning size-2 rounded-full"></span> <span>Flagged ({{ flaggedCount() }})</span> </div> <div class="flex items-center gap-1.5"> <span class="bg-primary size-2 rounded-full"></span> <span>Current</span> </div> <div class="flex items-center gap-1.5"> <span class="bg-muted-foreground/40 size-2 rounded-full"></span> <span>Unanswered ({{ unansweredCount() }})</span> </div> </div> </div> <ui-separator /> <button ui-button variant="default" class="h-10 w-full text-xs font-semibold shadow-xs" (click)="isSubmitDialogOpen.set(true)" > <lucide-icon [img]="CheckCircle2" class="mr-2 size-4" /> Submit Exam </button> </div> </div> </div> </div> } @else { <div class="space-y-6"> <!-- Results Hero Score Card --> <div ui-card class="border-border bg-card overflow-hidden shadow-xs"> <div class="border-border bg-muted/20 border-b p-6 sm:p-8"> <div class="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-2"> <div class="flex flex-wrap items-center gap-2"> <span ui-badge [variant]="isPassed() ? 'secondary' : 'outline'" [class]=" isPassed() ? 'border-success/30 bg-success/10 text-success text-xs font-semibold' : 'bg-destructive/10 text-destructive border-destructive/30 text-xs font-semibold' " > {{ isPassed() ? 'PASSED · CERTIFIED' : 'DID NOT PASS · RETAKE RECOMMENDED' }} </span> <span ui-badge variant="outline" class="text-muted-foreground font-mono text-xs"> Exam Code #TS-804 </span> </div> <h1 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl"> <span class="font-mono tabular-nums">{{ scorePercentage() }}%</span> Score · {{ gradeLabel() }} </h1> <p class="text-muted-foreground max-w-xl text-sm"> {{ isPassed() ? 'Congratulations! You have satisfied the technical competency standards for TypeScript and UIPKGE Component Architecture.' : 'You scored below the 80% certification threshold. Review the explanations below and retake the assessment when ready.' }} </p> </div> <div class="flex shrink-0 items-center gap-3"> <button ui-button variant="default" size="sm" class="text-xs font-semibold" (click)="retakeQuiz()"> <lucide-icon [img]="RotateCcw" class="mr-1.5 size-3.5" /> Retake Exam </button> </div> </div> </div> <div ui-card-content class="p-6"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div class="border-border bg-card space-y-1 rounded-lg border p-4"> <span class="text-muted-foreground text-xs font-medium">Total Score</span> <div class="text-foreground text-xl font-bold tabular-nums"> {{ correctCount() }} <span class="text-muted-foreground text-sm font-normal">/ {{ totalQuestions }}</span> </div> <div class="text-muted-foreground font-mono text-xs">{{ scorePercentage() }}% accuracy</div> </div> <div class="border-border bg-card space-y-1 rounded-lg border p-4"> <span class="text-muted-foreground text-xs font-medium">Result Status</span> <div [class]="cn('text-xl font-bold', isPassed() ? 'text-success' : 'text-destructive')"> {{ isPassed() ? 'Passed' : 'Failed' }} </div> <div class="text-muted-foreground font-mono text-xs">Passing threshold: 80%</div> </div> <div class="border-border bg-card space-y-1 rounded-lg border p-4"> <span class="text-muted-foreground text-xs font-medium">Time Elapsed</span> <div class="text-foreground font-mono text-xl font-bold tabular-nums">{{ timeSpentFormatted() }}</div> <div class="text-muted-foreground font-mono text-xs">18m 42s allocated</div> </div> <div class="border-border bg-card space-y-1 rounded-lg border p-4"> <span class="text-muted-foreground text-xs font-medium">Flagged Questions</span> <div class="text-foreground text-xl font-bold tabular-nums">{{ flaggedCount() }}</div> <div class="text-muted-foreground font-mono text-xs">Reviewed items</div> </div> </div> </div> </div> <!-- Question Review Section --> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="pb-4"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div> <h3 ui-card-title class="text-base font-semibold">Answer Review & Explanations</h3> <p ui-card-description class="text-xs"> Detailed technical breakdown for every question in the assessment. </p> </div> <div class="flex flex-wrap items-center gap-1.5"> <button ui-button [variant]="reviewFilter() === 'all' ? 'default' : 'outline'" size="sm" class="h-7.5 px-2.5 text-xs" (click)="reviewFilter.set('all')" > All ({{ totalQuestions }}) </button> <button ui-button [variant]="reviewFilter() === 'correct' ? 'default' : 'outline'" size="sm" class="h-7.5 px-2.5 text-xs" (click)="reviewFilter.set('correct')" > Correct ({{ correctCount() }}) </button> <button ui-button [variant]="reviewFilter() === 'incorrect' ? 'default' : 'outline'" size="sm" class="h-7.5 px-2.5 text-xs" (click)="reviewFilter.set('incorrect')" > Incorrect ({{ incorrectCount() }}) </button> <button ui-button [variant]="reviewFilter() === 'flagged' ? 'default' : 'outline'" size="sm" class="h-7.5 px-2.5 text-xs" (click)="reviewFilter.set('flagged')" > Flagged ({{ flaggedCount() }}) </button> </div> </div> </div> <div ui-card-content class="space-y-4"> @for (q of filteredReviewQuestions(); track q.id) { <div [class]="reviewCardClass(q)"> <div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex items-center gap-2"> <span ui-badge variant="outline" class="text-xs font-bold tabular-nums"> Question {{ q.id }} </span> <span ui-badge variant="secondary" class="text-xs">{{ q.category }}</span> @if (flagged().has(q.id)) { <span ui-badge variant="outline" class="border-warning/40 bg-warning/10 text-warning text-xs"> <lucide-icon [img]="Flag" class="fill-warning mr-1 size-3" /> Flagged </span> } </div> <span ui-badge [variant]="answers()[q.id] === q.correctOptionId ? 'secondary' : 'outline'" [class]=" answers()[q.id] === q.correctOptionId ? 'border-success/30 bg-success/10 text-success text-xs font-semibold' : 'bg-destructive/10 text-destructive border-destructive/30 text-xs font-semibold' " > <lucide-icon [img]="answers()[q.id] === q.correctOptionId ? CheckCircle2 : XCircle" class="mr-1 size-3.5" /> {{ answers()[q.id] === q.correctOptionId ? 'Correct (+10 pts)' : answers()[q.id] ? 'Incorrect (0 pts)' : 'Unanswered (0 pts)' }} </span> </div> <p class="text-foreground text-sm leading-relaxed font-semibold">{{ q.prompt }}</p> @if (q.codeSnippet) { <div class="border-border/80 bg-muted/40 dark:bg-muted/20 overflow-hidden rounded-md border"> <pre class="text-foreground/80 overflow-x-auto p-3 font-mono text-xs leading-relaxed whitespace-pre" ><code>{{ q.codeSnippet }}</code></pre> </div> } <div class="grid gap-2 sm:grid-cols-2"> <div [class]="userAnswerClass(q)"> <span class="block text-xs font-semibold tracking-wider uppercase opacity-80"> Your Response: </span> <div class="font-medium"> @if (answers()[q.id]) { <span class="mr-1 font-bold">Option {{ answers()[q.id] }}:</span> <span>{{ optionText(q, answers()[q.id]!) }}</span> } @else { <span>No answer submitted</span> } </div> </div> <div class="border-success/30 bg-success/10 text-foreground text-success space-y-1 rounded-md border p-3 text-xs" > <span class="block text-xs font-semibold tracking-wider uppercase opacity-80"> Correct Answer: </span> <div class="font-medium"> <span class="mr-1 font-bold">Option {{ q.correctOptionId }}:</span> <span>{{ optionText(q, q.correctOptionId) }}</span> </div> </div> </div> <div class="border-border/80 bg-muted/40 text-muted-foreground space-y-1 rounded-md border p-3 text-xs"> <div class="text-foreground flex items-center gap-1.5 font-semibold"> <lucide-icon [img]="Lightbulb" class="text-primary size-3.5" /> <span>Architectural Explanation:</span> </div> <p class="leading-relaxed">{{ q.explanation }}</p> </div> </div> } </div> <div ui-card-footer class="border-border bg-muted/10 flex items-center justify-between border-t p-4 sm:p-5"> <span class="text-muted-foreground font-mono text-xs"> Exam ID: 804-FE-UIPKGE </span> <button ui-button variant="default" size="sm" class="text-xs" (click)="retakeQuiz()"> <lucide-icon [img]="RotateCcw" class="mr-1.5 size-3.5" /> Retake Assessment </button> </div> </div> </div> } <!-- DIALOG: CONFIRM EXAM SUBMISSION --> <ui-dialog [open]="isSubmitDialogOpen()" (openChange)="isSubmitDialogOpen.set($event)"> <ui-dialog-content class="sm:max-w-md"> <div ui-dialog-header> <h2 ui-dialog-title class="text-base font-semibold">Submit Assessment?</h2> <p ui-dialog-description class="text-xs">Review your completion status before finalizing your submission.</p> </div> <div class="space-y-3 py-2 text-xs"> <div class="grid grid-cols-3 gap-2 text-center"> <div class="border-border bg-muted/40 space-y-0.5 rounded-lg border p-2.5"> <span class="text-muted-foreground">Answered</span> <p class="text-foreground text-base font-bold tabular-nums">{{ answeredCount() }}/{{ totalQuestions }}</p> </div> <div class="border-border bg-muted/40 space-y-0.5 rounded-lg border p-2.5"> <span class="text-muted-foreground">Flagged</span> <p class="text-warning text-base font-bold tabular-nums">{{ flaggedCount() }}</p> </div> <div class="border-border bg-muted/40 space-y-0.5 rounded-lg border p-2.5"> <span class="text-muted-foreground">Unanswered</span> <p [class]=" cn('text-base font-bold tabular-nums', unansweredCount() > 0 ? 'text-destructive' : 'text-foreground') " > {{ unansweredCount() }} </p> </div> </div> @if (unansweredCount() > 0) { <div class="border-warning/30 bg-warning/10 text-warning flex items-start gap-2 rounded-lg border p-3 text-xs" > <lucide-icon [img]="AlertCircle" class="mt-0.5 size-4 shrink-0" /> <p> You have <strong>{{ unansweredCount() }} unanswered question{{ unansweredCount() === 1 ? '' : 's' }}</strong >. Unanswered questions receive 0 points. </p> </div> } <p class="text-muted-foreground text-xs"> Once submitted, you will immediately receive your final score and detailed technical explanations. </p> </div> <div ui-dialog-footer class="gap-2 sm:gap-0"> <button ui-button variant="outline" size="sm" class="text-xs" (click)="isSubmitDialogOpen.set(false)"> Continue Quiz </button> <button ui-button variant="default" size="sm" class="text-xs font-semibold" (click)="submitExam()"> Confirm & Submit </button> </div> </ui-dialog-content> </ui-dialog> <!-- DIALOG: CONFIRM EXIT --> <ui-dialog [open]="isExitDialogOpen()" (openChange)="isExitDialogOpen.set($event)"> <ui-dialog-content class="sm:max-w-md"> <div ui-dialog-header> <h2 ui-dialog-title class="text-base font-semibold">Exit Assessment?</h2> <p ui-dialog-description class="text-xs"> Are you sure you want to exit? Your answers for this session will be cleared. </p> </div> <div class="text-muted-foreground py-2 text-xs"> You have answered {{ answeredCount() }} of {{ totalQuestions }} questions. Exiting now will reset your attempt. </div> <div ui-dialog-footer class="gap-2 sm:gap-0"> <button ui-button variant="outline" size="sm" class="text-xs" (click)="isExitDialogOpen.set(false)"> Cancel </button> <button ui-button variant="destructive" size="sm" class="text-xs" (click)="confirmExit()">Exit Quiz</button> </div> </ui-dialog-content> </ui-dialog> `, }) export class UiQuizAssessmentRunnerComponent { protected readonly AlertCircle = AlertCircle protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly ChevronLeft = ChevronLeft protected readonly ChevronRight = ChevronRight protected readonly Clock = Clock protected readonly FileCode = FileCode protected readonly Flag = Flag protected readonly Lightbulb = Lightbulb protected readonly LogOut = LogOut protected readonly RotateCcw = RotateCcw protected readonly XCircle = XCircle protected readonly cn = cn protected readonly questions = DEFAULT_QUESTIONS protected readonly totalQuestions = DEFAULT_QUESTIONS.length @Input() set initialQuestion(v: number | undefined) { const idx = Math.max(0, Math.min((v ?? 1) - 1, this.totalQuestions - 1)) this.currentQuestionIndex.set(idx) } @Input() set initialTimeRemaining(v: number | undefined) { this.initialBudget = v ?? 1122 this.timeRemaining.set(v ?? 1122) } @Input() set initialAnswers(v: Record<number, string> | undefined) { this.answers.set({ ...(v ?? {}) }) } @Input() set initialFlagged(v: number[] | undefined) { this.flagged.set(new Set(v ?? [])) } @Input() set initialSubmitted(v: boolean | undefined) { this.isSubmitted.set(!!v) } @Input('class') className?: string private initialBudget = 1122 private timer: ReturnType<typeof setInterval> | null = null readonly currentQuestionIndex = signal(0) readonly answers = signal<Record<number, string>>({}) readonly flagged = signal<Set<number>>(new Set()) readonly timeRemaining = signal(1122) readonly isSubmitted = signal(false) readonly isSubmitDialogOpen = signal(false) readonly isExitDialogOpen = signal(false) readonly reviewFilter = signal<ReviewFilter>('all') constructor() { const destroyRef = inject(DestroyRef) destroyRef.onDestroy(() => this.clearTimer()) // Start the countdown on the next macrotask so constructor-time input setters land first. setTimeout(() => this.startTimer(), 0) } readonly currentQuestion = computed(() => DEFAULT_QUESTIONS[this.currentQuestionIndex()]!) readonly answeredCount = computed(() => Object.keys(this.answers()).length) readonly flaggedCount = computed(() => this.flagged().size) readonly unansweredCount = computed(() => this.totalQuestions - this.answeredCount()) readonly progressPercentage = computed(() => Math.round((this.answeredCount() / this.totalQuestions) * 100)) readonly correctCount = computed( () => DEFAULT_QUESTIONS.filter((q) => this.answers()[q.id] === q.correctOptionId).length, ) readonly incorrectCount = computed( () => DEFAULT_QUESTIONS.filter((q) => this.answers()[q.id] && this.answers()[q.id] !== q.correctOptionId).length, ) readonly scorePercentage = computed(() => Math.round((this.correctCount() / this.totalQuestions) * 100)) readonly isPassed = computed(() => this.scorePercentage() >= 80) readonly gradeLabel = computed(() => { const s = this.scorePercentage() if (s >= 90) return 'Grade A (Mastery)' if (s >= 80) return 'Grade B (Proficient)' if (s >= 70) return 'Grade C (Needs Review)' return 'Grade F (Did Not Pass)' }) readonly formattedTime = computed(() => { const t = Math.max(0, this.timeRemaining()) return `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}` }) readonly timeSpentFormatted = computed(() => { const spent = Math.max(0, this.initialBudget - this.timeRemaining()) return `${Math.floor(spent / 60)}m ${String(spent % 60).padStart(2, '0')}s` }) readonly filteredReviewQuestions = computed(() => { const f = this.reviewFilter() if (f === 'correct') return DEFAULT_QUESTIONS.filter((q) => this.answers()[q.id] === q.correctOptionId) if (f === 'incorrect') return DEFAULT_QUESTIONS.filter((q) => this.answers()[q.id] !== q.correctOptionId) if (f === 'flagged') return DEFAULT_QUESTIONS.filter((q) => this.flagged().has(q.id)) return DEFAULT_QUESTIONS }) get hostClass(): string { return cn('mx-auto w-full max-w-6xl space-y-6', this.className) } get timerPillClass(): string { return cn( 'flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium shadow-xs transition-colors', this.timeRemaining() < 300 ? 'border-destructive/40 bg-destructive/10 text-destructive' : 'border-border bg-muted/50 text-foreground', ) } get flagButtonClass(): string { return cn( 'text-xs', this.flagged().has(this.currentQuestion().id) ? 'border-warning/40 bg-warning/10 text-warning font-semibold' : 'text-muted-foreground hover:text-foreground', ) } optionClass(optionId: string): string { return cn( 'group relative flex cursor-pointer items-start gap-3.5 rounded-lg border p-3.5 text-left transition-colors sm:p-4', 'focus-visible:ring-ring select-none focus-visible:ring-2 focus-visible:outline-none', this.answers()[this.currentQuestion().id] === optionId ? 'border-primary bg-primary/[0.04] dark:bg-primary/10 ring-primary shadow-xs ring-1' : 'border-border bg-card hover:bg-muted/40 hover:border-muted-foreground/30', ) } optionLetterClass(optionId: string): string { return cn( 'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-bold transition-colors', this.answers()[this.currentQuestion().id] === optionId ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-muted/60 text-muted-foreground group-hover:border-foreground/30 group-hover:text-foreground', ) } navButtonClass(idx: number, qid: number): string { return cn( 'relative flex size-10 items-center justify-center rounded-lg border text-xs font-semibold tabular-nums transition-colors', 'focus-visible:ring-ring cursor-pointer focus-visible:ring-2 focus-visible:outline-none', idx === this.currentQuestionIndex() ? 'border-primary bg-primary/10 text-primary ring-primary font-bold shadow-xs ring-2' : this.answers()[qid] ? 'border-success/30 bg-success/10 text-success hover:bg-success/20' : this.flagged().has(qid) ? 'border-warning/30 bg-warning/10 text-warning hover:bg-warning/20' : 'border-border bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground', ) } reviewCardClass(q: QuizQuestion): string { return cn( 'space-y-4 rounded-lg border p-4 transition-colors sm:p-5', this.answers()[q.id] === q.correctOptionId ? 'border-success/30 bg-success/[0.02] bg-success/[0.05]' : 'border-destructive/30 bg-destructive/[0.02] dark:bg-destructive/[0.05]', ) } userAnswerClass(q: QuizQuestion): string { return cn( 'space-y-1 rounded-md border p-3 text-xs', this.answers()[q.id] === q.correctOptionId ? 'border-success/30 bg-success/10 text-foreground text-success' : 'border-destructive/30 bg-destructive/10 text-destructive', ) } optionText(q: QuizQuestion, optionId: string): string { return q.options.find((o) => o.id === optionId)?.text ?? '' } toggleFlag(questionId: number): void { this.flagged.update((prev) => { const next = new Set(prev) if (next.has(questionId)) next.delete(questionId) else next.add(questionId) return next }) } selectOption(optionId: string): void { const qid = this.currentQuestion().id this.answers.update((prev) => ({ ...prev, [qid]: optionId })) } goToQuestion(index: number): void { this.currentQuestionIndex.set(Math.max(0, Math.min(index, this.totalQuestions - 1))) } prevQuestion(): void { if (this.currentQuestionIndex() > 0) this.currentQuestionIndex.update((i) => i - 1) } nextQuestion(): void { if (this.currentQuestionIndex() < this.totalQuestions - 1) this.currentQuestionIndex.update((i) => i + 1) else this.isSubmitDialogOpen.set(true) } submitExam(): void { this.isSubmitted.set(true) this.isSubmitDialogOpen.set(false) this.clearTimer() } retakeQuiz(): void { this.answers.set({}) this.flagged.set(new Set()) this.timeRemaining.set(this.initialBudget) this.currentQuestionIndex.set(0) this.isSubmitted.set(false) this.reviewFilter.set('all') this.clearTimer() this.startTimer() } confirmExit(): void { this.isExitDialogOpen.set(false) this.retakeQuiz() } /** Decrement the clock once — extracted so specs can drive time without fake timers. */ tick(): void { if (this.timeRemaining() > 0) this.timeRemaining.update((t) => t - 1) else if (!this.isSubmitted()) { this.isSubmitted.set(true) this.isSubmitDialogOpen.set(false) } } private startTimer(): void { this.clearTimer() if (this.isSubmitted()) return this.timer = setInterval(() => this.tick(), 1000) } private clearTimer(): void { if (this.timer) { clearInterval(this.timer) this.timer = null } } }src/app/components/blocks/quiz-assessment-runner/index.ts0.2 kB
export { UiQuizAssessmentRunnerComponent, DEFAULT_QUESTIONS, type QuizOption, type QuizQuestion, type ReviewFilter, } from './quiz-assessment-runner.component'
Raw manifest:https://uipkge.dev/r/angular/quiz-assessment-runner.json