Menu

Change language

GitHub repo

Quiz Assessment Runner

blockeducation

Interactive 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

$npx uipkge-ng@latest add quiz-assessment-runner
Manifest:https://uipkge.dev/r/angular/quiz-assessment-runner.jsonInstalls to:src/app/components/blocks/quiz-assessment-runner/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

QuizOption
interface QuizOption {
  id: string
  text: string
}
QuizQuestion
interface 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--primary

Files 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

Raw manifest:https://uipkge.dev/r/angular/quiz-assessment-runner.json