question-bank-framework 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +22 -7
  2. package/framework/components/app-shell.tsx +40 -26
  3. package/framework/components/exam-timer.tsx +134 -0
  4. package/framework/components/filter-builder.tsx +65 -63
  5. package/framework/components/markdown-content.tsx +15 -15
  6. package/framework/components/question-bank.tsx +150 -227
  7. package/framework/components/question-response.tsx +41 -31
  8. package/framework/components/quiz-list.tsx +42 -37
  9. package/framework/components/quiz-page.tsx +63 -62
  10. package/framework/components/quiz-question-editor.tsx +11 -11
  11. package/framework/components/ui/badge.tsx +29 -23
  12. package/framework/components/ui/button.tsx +24 -28
  13. package/framework/components/ui/card.tsx +104 -0
  14. package/framework/components/ui/checkbox.tsx +9 -10
  15. package/framework/components/ui/dropdown-menu.tsx +139 -123
  16. package/framework/components/ui/field.tsx +238 -0
  17. package/framework/components/ui/input.tsx +6 -5
  18. package/framework/components/ui/kbd.tsx +28 -0
  19. package/framework/components/ui/label.tsx +19 -0
  20. package/framework/components/ui/popover.tsx +35 -32
  21. package/framework/components/ui/select.tsx +80 -65
  22. package/framework/components/ui/separator.tsx +24 -0
  23. package/framework/components/ui/sheet.tsx +36 -39
  24. package/framework/components/ui/table.tsx +4 -4
  25. package/framework/components/ui/toast.tsx +234 -0
  26. package/framework/components/ui/tooltip.tsx +65 -0
  27. package/framework/index.ts +4 -0
  28. package/framework/lib/exam-timer.ts +115 -0
  29. package/framework/styles/theme.css +66 -28
  30. package/package.json +4 -1
  31. package/template/src/routes/__root.tsx +1 -1
  32. package/template/src/routes/styles.css +0 -4
  33. package/template/src/routes/timer.tsx +4 -0
@@ -16,9 +16,9 @@ import {
16
16
  type SortingFn,
17
17
  type SortingState,
18
18
  } from '@tanstack/react-table';
19
- import { Search } from 'lucide-react';
19
+ import { ArrowDown, ArrowUp, ChevronsUpDown, Search } from 'lucide-react';
20
20
  import { useBank } from '../../core/config.tsx';
21
- import type { BaseQuestion, QuestionKind, QuestionStatus, QuestionStatusMap, QuizSession } from '../../core/types.ts';
21
+ import type { BaseQuestion, QuestionStatus, QuestionStatusMap, QuizSession } from '../../core/types.ts';
22
22
  import { getPairingSlots, isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
23
23
  import { Badge } from './ui/badge';
24
24
  import { Button } from './ui/button';
@@ -26,7 +26,7 @@ import { Checkbox } from './ui/checkbox';
26
26
  import { Input } from './ui/input';
27
27
  import { FilterBuilder } from './filter-builder';
28
28
  import { MarkdownContent } from './markdown-content';
29
- import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from './ui/popover';
29
+ import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
30
30
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
31
31
  import {
32
32
  createFilterFields,
@@ -60,19 +60,10 @@ const naturalSort: SortingFn<BaseQuestion> = (rowA, rowB, columnId) =>
60
60
 
61
61
  const previewCloseDelay = 180;
62
62
  const previewOpenDelay = 400;
63
- const questionStatusStyles: Record<QuestionStatus, string> = {
64
- unanswered: 'border-gray-300 bg-gray-50 text-gray-700',
65
- revisit: 'border-amber-300 bg-amber-50 text-amber-900',
66
- complete: 'border-green-300 bg-green-50 text-green-800',
67
- };
68
-
69
- const questionKindStyles: Record<QuestionKind, string> = {
70
- written: 'border-gray-300 bg-gray-50 text-gray-700',
71
- single: 'border-sky-300 bg-sky-50 text-sky-900',
72
- multiple: 'border-violet-300 bg-violet-50 text-violet-900',
73
- numerical: 'border-emerald-300 bg-emerald-50 text-emerald-900',
74
- matching: 'border-orange-300 bg-orange-50 text-orange-900',
75
- dropdown: 'border-rose-300 bg-rose-50 text-rose-900',
63
+ const questionStatusDot: Record<QuestionStatus, string> = {
64
+ unanswered: 'bg-muted-foreground/40',
65
+ revisit: 'bg-warning',
66
+ complete: 'bg-success',
76
67
  };
77
68
 
78
69
  const optionLetter = (index: number): string => String.fromCharCode(65 + index);
@@ -95,132 +86,50 @@ function kindDetail(question: BaseQuestion): string | undefined {
95
86
  }
96
87
 
97
88
  function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
98
- const [open, setOpen] = useState(false);
99
- const openTimeout = useRef<number | null>(null);
100
- const closeTimeout = useRef<number | null>(null);
101
- const openedByPointer = useRef(false);
102
-
103
- const clearOpenTimeout = () => {
104
- if (openTimeout.current !== null) {
105
- window.clearTimeout(openTimeout.current);
106
- openTimeout.current = null;
107
- }
108
- };
109
- const clearCloseTimeout = () => {
110
- if (closeTimeout.current !== null) {
111
- window.clearTimeout(closeTimeout.current);
112
- closeTimeout.current = null;
113
- }
114
- };
115
- const openPreview = () => {
116
- clearOpenTimeout();
117
- clearCloseTimeout();
118
- setOpen(true);
119
- };
120
- const scheduleOpen = () => {
121
- clearOpenTimeout();
122
- clearCloseTimeout();
123
- openTimeout.current = window.setTimeout(() => {
124
- setOpen(true);
125
- openTimeout.current = null;
126
- }, previewOpenDelay);
127
- };
128
- const scheduleClose = () => {
129
- clearOpenTimeout();
130
- clearCloseTimeout();
131
- closeTimeout.current = window.setTimeout(() => {
132
- setOpen(false);
133
- openedByPointer.current = false;
134
- closeTimeout.current = null;
135
- }, previewCloseDelay);
136
- };
137
-
138
- useEffect(
139
- () => () => {
140
- if (openTimeout.current !== null) window.clearTimeout(openTimeout.current);
141
- if (closeTimeout.current !== null) window.clearTimeout(closeTimeout.current);
142
- },
143
- [],
144
- );
145
-
89
+ const pairingSlots = getPairingSlots(question);
146
90
  return (
147
- <Popover
148
- open={open}
149
- onOpenChange={(nextOpen) => {
150
- if (nextOpen) {
151
- openPreview();
152
- } else {
153
- clearOpenTimeout();
154
- clearCloseTimeout();
155
- setOpen(false);
156
- openedByPointer.current = false;
157
- }
158
- }}
159
- >
160
- <PopoverAnchor asChild>
161
- <button
162
- type="button"
163
- aria-haspopup="dialog"
164
- aria-expanded={open}
165
- aria-label={`Preview question: ${question.title}`}
166
- className="block max-w-full rounded-sm text-left text-base font-semibold leading-6 text-foreground hover:underline hover:decoration-dotted hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
167
- onPointerEnter={() => {
168
- openedByPointer.current = true;
169
- scheduleOpen();
170
- }}
171
- onPointerLeave={scheduleClose}
172
- onFocus={() => {
173
- if (!openedByPointer.current) scheduleOpen();
174
- }}
175
- onBlur={scheduleClose}
176
- onClick={(event) => {
177
- event.stopPropagation();
178
- scheduleOpen();
179
- }}
180
- >
181
- {question.title}
182
- </button>
183
- </PopoverAnchor>
91
+ <Popover>
92
+ <PopoverTrigger
93
+ openOnHover
94
+ delay={previewOpenDelay}
95
+ closeDelay={previewCloseDelay}
96
+ aria-label={`Preview question: ${question.title}`}
97
+ onClick={(event) => event.stopPropagation()}
98
+ className="block max-w-full rounded-sm text-left text-sm font-medium text-foreground decoration-muted-foreground/50 underline-offset-4 hover:underline focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
99
+ >
100
+ {question.title}
101
+ </PopoverTrigger>
184
102
  <PopoverContent
185
103
  side="right"
186
104
  align="start"
187
105
  sideOffset={8}
188
106
  collisionPadding={12}
189
107
  aria-label={`Preview: ${question.title}`}
190
- className="max-h-[min(70dvh,calc(100dvh-1.5rem))] w-[min(680px,calc(100vw-2rem))] max-w-[calc(100vw-1.5rem)] overflow-y-auto p-0"
191
- onOpenAutoFocus={(event) => {
192
- if (openedByPointer.current) event.preventDefault();
193
- }}
194
- onPointerEnter={openPreview}
195
- onPointerLeave={scheduleClose}
196
- onFocusCapture={openPreview}
197
- onBlurCapture={scheduleClose}
108
+ className="max-h-[min(70dvh,calc(100dvh-1.5rem))] w-[min(640px,calc(100vw-2rem))] gap-0 overflow-y-auto p-4"
198
109
  >
199
- <div className="px-4 py-3">
200
- <MarkdownContent content={question.prompt} />
201
- {isChoiceQuestion(question) && (
202
- <ol className="mt-3 space-y-1.5">
203
- {(question.options ?? []).map((option, index) => (
204
- <li key={option.id} className="flex items-start gap-2 text-sm text-gray-800">
205
- <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-gray-50 text-[11px] font-bold text-gray-600">
206
- {optionLetter(index)}
207
- </span>
208
- <MarkdownContent content={option.text} className="min-w-0 flex-1 text-sm leading-6" />
209
- </li>
210
- ))}
211
- </ol>
212
- )}
213
- {getPairingSlots(question).length > 0 && (
214
- <ol className="mt-3 list-decimal space-y-1 pl-6 text-sm text-gray-800">
215
- {getPairingSlots(question).map((slot, index) => (
216
- <li key={slot.id}>
217
- <MarkdownContent inline content={slot.label?.trim() ? slot.label : `Blank ${index + 1}`} />
218
- <span className="text-gray-500"> → ___</span>
219
- </li>
220
- ))}
221
- </ol>
222
- )}
223
- </div>
110
+ <MarkdownContent content={question.prompt} />
111
+ {isChoiceQuestion(question) && (
112
+ <ol className="mt-3 grid gap-1.5">
113
+ {(question.options ?? []).map((option, index) => (
114
+ <li key={option.id} className="flex items-start gap-2.5 text-sm">
115
+ <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border text-[11px] font-medium text-muted-foreground tabular-nums">
116
+ {optionLetter(index)}
117
+ </span>
118
+ <MarkdownContent content={option.text} className="min-w-0 flex-1 text-sm leading-6" />
119
+ </li>
120
+ ))}
121
+ </ol>
122
+ )}
123
+ {pairingSlots.length > 0 && (
124
+ <ol className="mt-3 list-decimal space-y-1 pl-6 text-sm marker:text-muted-foreground">
125
+ {pairingSlots.map((slot, index) => (
126
+ <li key={slot.id}>
127
+ <MarkdownContent inline content={slot.label?.trim() ? slot.label : `Blank ${index + 1}`} />
128
+ <span className="text-muted-foreground"> → ___</span>
129
+ </li>
130
+ ))}
131
+ </ol>
132
+ )}
224
133
  </PopoverContent>
225
134
  </Popover>
226
135
  );
@@ -267,11 +176,11 @@ export function QuestionBank() {
267
176
  );
268
177
 
269
178
  // Size the ID column to the data: fits typical ids on one line, caps
270
- // monsters (they wrap via break-all). Mono averages ~8.4px/char at
271
- // text-sm, plus px-4 padding. e.g. econ ≈168, info hits the 320 cap.
179
+ // monsters (they wrap via break-all). Mono averages ~7.3px/char at
180
+ // text-xs, plus px-3 padding each side.
272
181
  const idWidth = useMemo(() => {
273
182
  const longest = bank.questions.reduce((n, q) => Math.max(n, q.id.length), 0);
274
- return Math.min(320, Math.max(120, Math.ceil(longest * 8.4 + 32)));
183
+ return Math.min(280, Math.max(96, Math.ceil(longest * 7.3 + 24)));
275
184
  }, [bank.questions]);
276
185
 
277
186
  const handleAddFilter = (field: string) => {
@@ -323,14 +232,14 @@ export function QuestionBank() {
323
232
  return (
324
233
  <div className="flex flex-wrap gap-1">
325
234
  {(raw as readonly string[]).map((tag) => (
326
- <Badge key={String(tag)} variant="secondary">
235
+ <Badge key={String(tag)} variant="secondary" className="h-auto rounded-md font-normal break-words whitespace-normal">
327
236
  {String(tag)}
328
237
  </Badge>
329
238
  ))}
330
239
  </div>
331
240
  );
332
241
  }
333
- return <span className="whitespace-normal break-words text-sm text-foreground">{formatCellValue(raw)}</span>;
242
+ return <span className="text-sm break-words whitespace-normal text-muted-foreground">{formatCellValue(raw)}</span>;
334
243
  },
335
244
  enableSorting: sortable,
336
245
  ...(def.type === 'number' ? {} : { sortingFn: naturalSort }),
@@ -344,8 +253,9 @@ export function QuestionBank() {
344
253
  header: ({ table }) => (
345
254
  <Checkbox
346
255
  aria-label="Select all matching questions"
347
- checked={table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? 'indeterminate' : false}
348
- onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked === true)}
256
+ checked={table.getIsAllPageRowsSelected()}
257
+ indeterminate={!table.getIsAllPageRowsSelected() && table.getIsSomePageRowsSelected()}
258
+ onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
349
259
  />
350
260
  ),
351
261
  cell: ({ row }) => (
@@ -382,13 +292,13 @@ export function QuestionBank() {
382
292
  />
383
293
  ),
384
294
  enableSorting: false,
385
- size: 44,
295
+ size: 48,
386
296
  },
387
297
  {
388
298
  accessorKey: 'id',
389
299
  header: 'ID',
390
300
  cell: ({ row }) => (
391
- <span className="whitespace-normal break-all font-mono text-sm text-muted-foreground">{row.original.id}</span>
301
+ <span className="font-mono text-xs break-all whitespace-normal text-muted-foreground">{row.original.id}</span>
392
302
  ),
393
303
  sortingFn: naturalSort,
394
304
  size: idWidth,
@@ -420,7 +330,7 @@ export function QuestionBank() {
420
330
  cell: ({ row }) => (
421
331
  <div className="flex min-w-0 flex-wrap gap-1">
422
332
  {row.original.tags.map((tag) => (
423
- <Badge key={tag} variant="secondary" className="min-w-0 whitespace-normal break-words">
333
+ <Badge key={tag} variant="secondary" className="h-auto min-w-0 rounded-md font-normal break-words whitespace-normal">
424
334
  {tag}
425
335
  </Badge>
426
336
  ))}
@@ -435,7 +345,7 @@ export function QuestionBank() {
435
345
  cell: ({ row }) => {
436
346
  const kind = questionKind(row.original);
437
347
  return (
438
- <Badge variant="outline" className={questionKindStyles[kind]} title={kindDetail(row.original)}>
348
+ <Badge variant="outline" className="font-normal text-muted-foreground" title={kindDetail(row.original)}>
439
349
  {questionKindLabels[kind]}
440
350
  </Badge>
441
351
  );
@@ -450,9 +360,10 @@ export function QuestionBank() {
450
360
  cell: ({ row }) => {
451
361
  const status = questionStatusMap[row.original.id] ?? 'unanswered';
452
362
  return (
453
- <Badge variant="outline" className={questionStatusStyles[status]}>
363
+ <span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
364
+ <span aria-hidden="true" className={cn('size-1.5 shrink-0 rounded-full', questionStatusDot[status])} />
454
365
  {questionStatusLabels[status]}
455
- </Badge>
366
+ </span>
456
367
  );
457
368
  },
458
369
  size: 108,
@@ -668,59 +579,78 @@ export function QuestionBank() {
668
579
  }
669
580
  };
670
581
 
582
+ const resultCount = tableRows.length;
583
+
671
584
  return (
672
585
  <div className="flex min-h-0 flex-1 flex-col">
673
- <div className="border-b bg-background px-4 py-3">
674
- <div className="flex flex-wrap items-center gap-3">
675
- <label className="relative min-w-[240px] flex-1">
676
- <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
677
- <Input
678
- value={search}
679
- onChange={(event) => setSearch(event.target.value)}
680
- placeholder="Search questions, topics, exams..."
681
- aria-label="Search questions"
682
- className="h-11 pl-10 text-base"
683
- />
684
- </label>
685
- <FilterBuilder
686
- fields={filterFields}
687
- filters={filters}
688
- match={filterMatch}
689
- onAdd={handleAddFilter}
690
- onChange={updateFilter}
691
- onRemove={removeFilter}
692
- onClear={clearFilters}
693
- onMatchChange={setFilterMatch}
586
+ <div className="flex flex-wrap items-center gap-2 border-b px-6 py-3">
587
+ <label className="relative w-full max-w-sm min-w-60 flex-1 sm:flex-none">
588
+ <Search aria-hidden="true" className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
589
+ <Input
590
+ type="search"
591
+ value={search}
592
+ onChange={(event) => setSearch(event.target.value)}
593
+ placeholder="Search"
594
+ aria-label="Search questions"
595
+ className="pl-8"
694
596
  />
695
- {filters.length > 0 && (
696
- <Button variant="ghost" className="h-11 text-base" onClick={clearFilters}>
697
- Clear filters
698
- </Button>
699
- )}
700
- </div>
597
+ </label>
598
+ <FilterBuilder
599
+ fields={filterFields}
600
+ filters={filters}
601
+ match={filterMatch}
602
+ onAdd={handleAddFilter}
603
+ onChange={updateFilter}
604
+ onRemove={removeFilter}
605
+ onClear={clearFilters}
606
+ onMatchChange={setFilterMatch}
607
+ />
608
+ {filters.length > 0 && (
609
+ <Button variant="ghost" onClick={clearFilters}>
610
+ Clear filters
611
+ </Button>
612
+ )}
613
+ <p className="ml-auto text-xs text-muted-foreground tabular-nums" aria-live="polite">
614
+ {resultCount} {resultCount === 1 ? 'question' : 'questions'}
615
+ </p>
701
616
  </div>
702
617
 
703
- <Table containerRef={tableContainerRef} containerClassName="min-h-0 flex-1 overflow-auto" className="table-fixed" style={{ minWidth: minTableWidth }}>
618
+ <Table
619
+ containerRef={tableContainerRef}
620
+ containerClassName="min-h-0 flex-1 overflow-auto"
621
+ className="table-fixed"
622
+ style={{ minWidth: minTableWidth }}
623
+ >
704
624
  <TableHeader>
705
625
  {table.getHeaderGroups().map((headerGroup) => (
706
- <TableRow key={headerGroup.id}>
707
- {headerGroup.headers.map((header) => (
708
- <TableHead
709
- key={header.id}
710
- // Title and tags are the absorbent columns (no width → take up
711
- // slack on wide screens). minTableWidth below floors them.
712
- style={header.column.id === 'tags' || header.column.id === 'title' ? undefined : { width: header.getSize() }}
713
- className="sticky top-0 z-10 h-12 border-b bg-muted px-4 text-sm font-semibold text-foreground"
714
- >
715
- {header.isPlaceholder ? null : header.column.getCanSort() ? (
716
- <button className="font-semibold hover:underline" onClick={header.column.getToggleSortingHandler()}>
717
- {flexRender(header.column.columnDef.header, header.getContext())}
718
- </button>
719
- ) : (
720
- flexRender(header.column.columnDef.header, header.getContext())
721
- )}
722
- </TableHead>
723
- ))}
626
+ <TableRow key={headerGroup.id} className="hover:bg-transparent">
627
+ {headerGroup.headers.map((header) => {
628
+ const sorted = header.column.getIsSorted();
629
+ const SortIcon = sorted === 'asc' ? ArrowUp : sorted === 'desc' ? ArrowDown : ChevronsUpDown;
630
+ return (
631
+ <TableHead
632
+ key={header.id}
633
+ // Title and tags are the absorbent columns (no width → take up
634
+ // slack on wide screens). minTableWidth floors them.
635
+ style={header.column.id === 'tags' || header.column.id === 'title' ? undefined : { width: header.getSize() }}
636
+ aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : undefined}
637
+ className="sticky top-0 z-10 h-9 bg-background px-3 text-xs font-medium text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] first:pl-6 last:pr-6"
638
+ >
639
+ {header.isPlaceholder ? null : header.column.getCanSort() ? (
640
+ <button
641
+ type="button"
642
+ className="-mx-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
643
+ onClick={header.column.getToggleSortingHandler()}
644
+ >
645
+ {flexRender(header.column.columnDef.header, header.getContext())}
646
+ <SortIcon aria-hidden="true" className={cn('size-3.5', !sorted && 'opacity-40')} />
647
+ </button>
648
+ ) : (
649
+ flexRender(header.column.columnDef.header, header.getContext())
650
+ )}
651
+ </TableHead>
652
+ );
653
+ })}
724
654
  </TableRow>
725
655
  ))}
726
656
  </TableHeader>
@@ -728,8 +658,8 @@ export function QuestionBank() {
728
658
  {tableRows.length > 0 ? (
729
659
  <>
730
660
  {topPadding > 0 && (
731
- <TableRow aria-hidden="true" className="border-0">
732
- <TableCell colSpan={columns.length} className="border-0 p-0" style={{ height: topPadding }} />
661
+ <TableRow aria-hidden="true" className="border-0 hover:bg-transparent">
662
+ <TableCell colSpan={columns.length} className="p-0" style={{ height: topPadding }} />
733
663
  </TableRow>
734
664
  )}
735
665
  {virtualRows.map((virtualRow) => {
@@ -743,7 +673,7 @@ export function QuestionBank() {
743
673
  data-virtual-index={virtualRow.index}
744
674
  data-question-row={row.id}
745
675
  data-state={row.getIsSelected() ? 'selected' : undefined}
746
- className="group cursor-pointer"
676
+ className="cursor-pointer select-none"
747
677
  onPointerEnter={() => updateDragRange(virtualRow.index)}
748
678
  onClick={() => {
749
679
  if (suppressRowClick.current === row.id) {
@@ -757,8 +687,8 @@ export function QuestionBank() {
757
687
  <TableCell
758
688
  key={cell.id}
759
689
  className={cn(
760
- 'border-b px-4 py-2 align-middle',
761
- cell.column.id === 'title' && 'whitespace-normal break-words',
690
+ 'px-3 py-2.5 align-middle first:pl-6 last:pr-6',
691
+ cell.column.id === 'title' && 'break-words whitespace-normal',
762
692
  )}
763
693
  onClick={cell.column.id === 'select' ? (event) => event.stopPropagation() : undefined}
764
694
  >
@@ -769,16 +699,15 @@ export function QuestionBank() {
769
699
  );
770
700
  })}
771
701
  {bottomPadding > 0 && (
772
- <TableRow aria-hidden="true" className="border-0">
773
- <TableCell colSpan={columns.length} className="border-0 p-0" style={{ height: bottomPadding }} />
702
+ <TableRow aria-hidden="true" className="border-0 hover:bg-transparent">
703
+ <TableCell colSpan={columns.length} className="p-0" style={{ height: bottomPadding }} />
774
704
  </TableRow>
775
705
  )}
776
706
  </>
777
707
  ) : (
778
- <TableRow>
779
- <TableCell colSpan={columns.length} className="h-40 text-center">
780
- <p className="text-lg font-semibold">No matching questions</p>
781
- <p className="mt-1 text-base text-muted-foreground">Try another search or clear your filters.</p>
708
+ <TableRow className="hover:bg-transparent">
709
+ <TableCell colSpan={columns.length} className="h-48 text-center whitespace-normal">
710
+ <p className="text-sm text-muted-foreground">No matching questions</p>
782
711
  <Button
783
712
  variant="outline"
784
713
  className="mt-4"
@@ -796,27 +725,21 @@ export function QuestionBank() {
796
725
  </Table>
797
726
 
798
727
  {selectedIds.length > 0 && (
799
- <div className="fixed bottom-5 right-1/2 z-30 flex w-[min(600px,calc(100vw-32px))] translate-x-1/2 items-center gap-3 rounded-xl border bg-background p-3 pl-4 shadow-lg">
800
- <span className="text-base font-semibold">
801
- {selectedIds.length} {selectedIds.length === 1 ? 'question' : 'questions'} selected
802
- </span>
803
- <Button variant="ghost" className="text-base" onClick={() => setRowSelection({})}>
728
+ <div className="fixed bottom-6 left-1/2 z-30 flex w-[min(560px,calc(100vw-2rem))] -translate-x-1/2 items-center gap-2 rounded-xl border bg-popover p-2 pl-4 text-popover-foreground shadow-lg animate-in fade-in-0 slide-in-from-bottom-2">
729
+ <span className="text-sm font-medium tabular-nums">{selectedIds.length} selected</span>
730
+ <Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => setRowSelection({})}>
804
731
  Clear
805
732
  </Button>
806
733
  <div className="ml-auto flex gap-2">
807
734
  <Popover open={addToQuizOpen} onOpenChange={openAddToQuiz}>
808
- <PopoverTrigger asChild>
809
- <Button variant="outline" className="text-base">
810
- Add to quiz
811
- </Button>
812
- </PopoverTrigger>
813
- <PopoverContent side="top" align="end" className="w-80 p-1">
735
+ <PopoverTrigger render={<Button variant="outline" />}>Add to quiz</PopoverTrigger>
736
+ <PopoverContent side="top" align="end" sideOffset={8} className="w-80 gap-0 p-1">
814
737
  {existingQuizzes === null ? (
815
- <p className="m-0 px-3 py-4 text-center text-sm text-muted-foreground">Loading quizzes...</p>
738
+ <p className="px-3 py-4 text-center text-sm text-muted-foreground">Loading…</p>
816
739
  ) : existingQuizzes.length === 0 ? (
817
- <p className="m-0 px-3 py-4 text-center text-sm text-muted-foreground">No quizzes yet. Use "Make quiz" instead.</p>
740
+ <p className="px-3 py-4 text-center text-sm text-muted-foreground">No quizzes yet</p>
818
741
  ) : (
819
- <ul className="m-0 max-h-72 list-none overflow-y-auto p-0">
742
+ <ul className="max-h-72 overflow-y-auto">
820
743
  {existingQuizzes.map((quiz) => {
821
744
  const newCount = selectedIds.filter((id) => !quiz.questionIds.includes(id)).length;
822
745
  return (
@@ -825,14 +748,14 @@ export function QuestionBank() {
825
748
  type="button"
826
749
  disabled={addingToQuizId !== null}
827
750
  onClick={() => addToQuiz(quiz)}
828
- className="flex w-full flex-col items-start rounded-sm px-3 py-2 text-left hover:bg-muted focus-visible:bg-muted focus-visible:outline-none disabled:opacity-50"
751
+ className="flex w-full flex-col items-start gap-0.5 rounded-md px-2.5 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none disabled:opacity-50"
829
752
  >
830
753
  <span className="text-sm font-medium">{quiz.title}</span>
831
- <span className="text-xs text-muted-foreground">
754
+ <span className="text-xs text-muted-foreground tabular-nums">
832
755
  {addingToQuizId === quiz.id
833
- ? 'Adding...'
834
- : `${quiz.questionIds.length} ${quiz.questionIds.length === 1 ? 'question' : 'questions'} • ${
835
- newCount === 0 ? 'all selected already included' : `+${newCount} new`
756
+ ? 'Adding…'
757
+ : `${quiz.questionIds.length} ${quiz.questionIds.length === 1 ? 'question' : 'questions'} · ${
758
+ newCount === 0 ? 'all already included' : `+${newCount} new`
836
759
  }`}
837
760
  </span>
838
761
  </button>
@@ -842,14 +765,14 @@ export function QuestionBank() {
842
765
  </ul>
843
766
  )}
844
767
  {addToQuizError && (
845
- <p role="alert" className="m-0 px-3 py-2 text-sm text-red-700">
846
- Could not add to that quiz. Please try again.
768
+ <p role="alert" className="px-2.5 py-2 text-sm text-destructive">
769
+ Could not add to that quiz.
847
770
  </p>
848
771
  )}
849
772
  </PopoverContent>
850
773
  </Popover>
851
- <Button className="text-base" onClick={makeQuiz} disabled={creatingQuiz}>
852
- {creatingQuiz ? 'Making quiz...' : 'Make quiz'}
774
+ <Button onClick={makeQuiz} disabled={creatingQuiz}>
775
+ {creatingQuiz ? 'Making quiz…' : 'Make quiz'}
853
776
  </Button>
854
777
  </div>
855
778
  </div>