Enhance audience management features with new components and API integrations
- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI. - Implemented audience invite actions including resend and revoke functionality. - Updated audience API to support importing members and assigning courses. - Refactored audience data handling to include status and invite management. - Improved overall user experience with pagination and selection features in the audience table.
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
class={cn('ui:flex ui:my-4 ui:py-2 ui:flex-col', isSticky && 'ui:sticky ui:top-13 ui:z-10 ui:bg-white', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<div class="ui:flex ui:items-start ui:justify-between ui:gap-4">
|
||||
<div class="ui:flex ui:items-start ui:justify-between ui:gap-4 ui:flex-col ui:md:flex-row">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import { cn } from '../../tools';
|
||||
import { Button } from '../../base/button';
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
label?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { href, label, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Button variant="link" class={cn('ui:h-fit! ui:justify-start! ui:p-0', className)} {href}>
|
||||
<ArrowLeftIcon class="custom" />
|
||||
{#if label}
|
||||
<span class="ui:text-xs">{label}</span>
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -0,0 +1,4 @@
|
||||
import BackButton from './back-button.svelte';
|
||||
|
||||
export { BackButton };
|
||||
export { BackButton as default };
|
||||
@@ -12,6 +12,8 @@
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onchange?: (e: Event) => void;
|
||||
/** When set, the whole row handles the click (e.g. multi-select). Avoids nesting a native button inside the checkbox control. */
|
||||
onclick?: () => void;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
@@ -24,25 +26,70 @@
|
||||
disabled = false,
|
||||
className = '',
|
||||
onchange = () => {},
|
||||
onclick,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const rowClass = $derived(
|
||||
cn(
|
||||
'group ui:inline-flex ui:w-full ui:items-center ui:hover:bg-muted ui:rounded-md ui:p-2 ui:text-left',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
className
|
||||
)
|
||||
);
|
||||
|
||||
function handleRowClick() {
|
||||
if (disabled || onclick === undefined) return;
|
||||
onclick();
|
||||
}
|
||||
|
||||
function handleRowKeydown(event: KeyboardEvent) {
|
||||
if (disabled || onclick === undefined) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onclick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'group ui:inline-flex ui:w-full ui:items-center',
|
||||
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Checkbox {name} {value} disabled={disabled || isEditable} bind:checked />
|
||||
{#if isEditable}
|
||||
{#if onclick !== undefined}
|
||||
<div
|
||||
class={rowClass}
|
||||
role="button"
|
||||
tabindex={disabled ? -1 : 0}
|
||||
aria-pressed={checked}
|
||||
aria-disabled={disabled ? true : undefined}
|
||||
onclick={handleRowClick}
|
||||
onkeydown={handleRowKeydown}
|
||||
>
|
||||
<Checkbox class="ui:pointer-events-none" {name} {value} disabled={disabled || isEditable} bind:checked />
|
||||
{#if isEditable}
|
||||
<!-- Isolate text field from the row button (rare: onclick + isEditable) -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="ui:w-2/4" onclick={(e) => e.stopPropagation()}>
|
||||
<InputField bind:value={label} placeholder="Your option" className="ui:ml-1" type="text" {onchange} />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="ui:ml-2 ui:dark:text-white ui:text-sm">{label}</span>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else if isEditable}
|
||||
<div class={rowClass}>
|
||||
<Checkbox {name} {value} disabled={disabled || isEditable} bind:checked />
|
||||
<div class="ui:w-2/4">
|
||||
<InputField bind:value={label} placeholder="Your option" className="ui:ml-1" type="text" {onchange} />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="ui:ml-2 ui:dark:text-white">{label}</span>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else}
|
||||
<label class={rowClass}>
|
||||
<Checkbox {name} {value} {disabled} bind:checked />
|
||||
<span class="ui:ml-2 ui:dark:text-white ui:text-sm">{label}</span>
|
||||
|
||||
{@render children?.()}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
@@ -40,6 +40,7 @@ export { default as CheckboxPreview } from './renderers/checkbox/preview.svelte'
|
||||
export { default as TextareaPreview } from './renderers/textarea/preview.svelte';
|
||||
export { default as MatchingPreview } from './renderers/matching/preview.svelte';
|
||||
export { default as OrderingPreview } from './renderers/ordering/preview.svelte';
|
||||
export { default as OrderingDisplay } from './renderers/ordering/ordering-display.svelte';
|
||||
export { default as HotspotPreview } from './renderers/hotspot/preview.svelte';
|
||||
|
||||
export { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
|
||||
|
||||
@@ -61,6 +61,14 @@ import { default as ShortAnswerSubmission } from './renderers/short-answer/submi
|
||||
import { default as TextareaSubmission } from './renderers/textarea/submission.svelte';
|
||||
import { default as TrueFalseSubmission } from './renderers/true-false/submission.svelte';
|
||||
|
||||
import { default as RadioReview } from './renderers/radio/review.svelte';
|
||||
import { default as CheckboxReview } from './renderers/checkbox/review.svelte';
|
||||
import { default as NumericReview } from './renderers/numeric/review.svelte';
|
||||
import { default as ShortAnswerReview } from './renderers/short-answer/review.svelte';
|
||||
import { default as FillBlankReview } from './renderers/fill-blank/review.svelte';
|
||||
import { default as OrderingReview } from './renderers/ordering/review.svelte';
|
||||
import { default as TrueFalseReview } from './renderers/true-false/review.svelte';
|
||||
|
||||
type SharedRendererComponent = Component<ExerciseQuestionRendererProps>;
|
||||
|
||||
export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<SharedRendererComponent> = {
|
||||
@@ -68,13 +76,15 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
|
||||
edit: RadioEdit,
|
||||
take: RadioTake,
|
||||
preview: RadioPreview,
|
||||
submission: RadioSubmission
|
||||
submission: RadioSubmission,
|
||||
review: RadioReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.CHECKBOX]: {
|
||||
edit: CheckboxEdit,
|
||||
take: CheckboxTake,
|
||||
preview: CheckboxPreview,
|
||||
submission: CheckboxSubmission
|
||||
submission: CheckboxSubmission,
|
||||
review: CheckboxReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.TEXTAREA]: {
|
||||
edit: TextareaEdit,
|
||||
@@ -86,25 +96,29 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
|
||||
edit: TrueFalseEdit,
|
||||
take: TrueFalseTake,
|
||||
preview: TrueFalsePreview,
|
||||
submission: TrueFalseSubmission
|
||||
submission: TrueFalseSubmission,
|
||||
review: TrueFalseReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.SHORT_ANSWER]: {
|
||||
edit: ShortAnswerEdit,
|
||||
take: ShortAnswerTake,
|
||||
preview: ShortAnswerPreview,
|
||||
submission: ShortAnswerSubmission
|
||||
submission: ShortAnswerSubmission,
|
||||
review: ShortAnswerReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.NUMERIC]: {
|
||||
edit: NumericEdit,
|
||||
take: NumericTake,
|
||||
preview: NumericPreview,
|
||||
submission: NumericSubmission
|
||||
submission: NumericSubmission,
|
||||
review: NumericReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.FILL_BLANK]: {
|
||||
edit: FillBlankEdit,
|
||||
take: FillBlankTake,
|
||||
preview: FillBlankPreview,
|
||||
submission: FillBlankSubmission
|
||||
submission: FillBlankSubmission,
|
||||
review: FillBlankReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.FILE_UPLOAD]: {
|
||||
edit: FileUploadEdit,
|
||||
@@ -122,7 +136,8 @@ export const EXERCISE_QUESTION_RENDERER_CONTRACT: ExerciseRendererRegistry<Share
|
||||
edit: OrderingEdit,
|
||||
take: OrderingTake,
|
||||
preview: OrderingPreview,
|
||||
submission: OrderingSubmission
|
||||
submission: OrderingSubmission,
|
||||
review: OrderingReview
|
||||
},
|
||||
[QUESTION_TYPE_KEY.HOTSPOT]: {
|
||||
edit: HotspotEdit,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { getOptionImageUrl, hasOptionImages } from '../option-image-utils';
|
||||
import { cn } from '../../../../tools';
|
||||
import McqPreviewOptionRow from '../shared/mcq-preview-option-row.svelte';
|
||||
|
||||
let { question, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const optionsHaveImages = $derived(hasOptionImages(question.options));
|
||||
</script>
|
||||
@@ -14,22 +11,7 @@
|
||||
<div class="ui:space-y-2">
|
||||
<ul class={optionsHaveImages ? 'ui:grid ui:grid-cols-2 ui:gap-3' : 'ui:space-y-1'}>
|
||||
{#each question.options ?? [] as option, index}
|
||||
{@const optionImageUrl = getOptionImageUrl(option)}
|
||||
<li class={cn(optionsHaveImages && 'ui:space-y-2 ui:rounded-md ui:p-2', 'ui:text-sm')}>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="preview"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{option.label || option.value || [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
{#if option.isCorrect}
|
||||
<span class="ui:ml-2 ui:text-xs ui:text-green-600">{label('common.correct_badge')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
<McqPreviewOptionRow {option} {index} optionImageUrl={getOptionImageUrl(option)} {optionsHaveImages} {labels} />
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { Checkbox } from '../../../../base/checkbox';
|
||||
import { Label } from '../../../../base/label';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import { getOptionImageUrl, hasOptionImages } from '../option-image-utils';
|
||||
import { getMcqReviewRowBorderClass, getOptionNumericId } from '../option-review-utils';
|
||||
import OptionCorrectnessBadges from '../shared/option-correctness-badges.svelte';
|
||||
import { cn } from '../../../../tools';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const selectedIds = $derived.by(() => {
|
||||
if (answer?.type !== 'CHECKBOX') return [] as number[];
|
||||
return answer.optionIds;
|
||||
});
|
||||
|
||||
const optionsHaveImages = $derived(hasOptionImages(question.options));
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<div class={optionsHaveImages ? 'ui:grid ui:grid-cols-2 ui:gap-3' : 'ui:space-y-2'}>
|
||||
{#each question.options ?? [] as option, index}
|
||||
{@const optionId = `checkbox-review-${question.id ?? 'q'}-${index}`}
|
||||
{@const optionImageUrl = getOptionImageUrl(option)}
|
||||
{@const oid = getOptionNumericId(option)}
|
||||
{@const isSelected = oid !== null && selectedIds.includes(oid)}
|
||||
{@const isCorrect = option.isCorrect === true}
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'ui:rounded-md ui:border ui:p-2',
|
||||
optionsHaveImages ? 'ui:space-y-2' : 'ui:flex ui:items-center ui:gap-2',
|
||||
getMcqReviewRowBorderClass(isSelected, isCorrect)
|
||||
)}
|
||||
>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="take"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div class="ui:flex ui:items-center ui:gap-2">
|
||||
<Checkbox id={optionId} checked={isSelected} disabled={true} />
|
||||
<Label class="ui:cursor-default ui:font-normal ui:w-full" for={optionId}>
|
||||
{option.label ||
|
||||
option.value ||
|
||||
[label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
<OptionCorrectnessBadges {labels} showCorrect={isCorrect} showIncorrect={isSelected && !isCorrect} />
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import LabeledValueRow from '../shared/labeled-value-row.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const studentJoined = $derived.by(() => {
|
||||
if (answer?.type !== 'FILL_BLANK') return '';
|
||||
return answer.values
|
||||
.map((token) => String(token).trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
});
|
||||
|
||||
const acceptedRaw = $derived(String((question.settings?.acceptedAnswers as string | undefined) ?? ''));
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<LabeledValueRow
|
||||
label={label('fill_blank.review.your_answer_label')}
|
||||
value={studentJoined || label('common.not_set')}
|
||||
/>
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{label('fill_blank.preview.accepted_answers_label')}: {acceptedRaw || label('common.not_set')}
|
||||
</p>
|
||||
</div>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
|
||||
let { question, labels }: Pick<ExerciseQuestionRendererProps, 'question' | 'labels'> = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const correctValue = $derived(question.settings?.correctValue as number | undefined);
|
||||
const tolerance = $derived(question.settings?.tolerance as number | undefined);
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{label('numeric.preview.correct_value_label')}: {correctValue ?? label('common.not_set')}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground ui:text-sm">{label('numeric.preview.tolerance_label')}: {tolerance ?? 0}</p>
|
||||
</div>
|
||||
@@ -1,17 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import NumericExpectedFields from './numeric-expected-fields.svelte';
|
||||
|
||||
let { question, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const correctValue = $derived(question.settings?.correctValue as number | undefined);
|
||||
const tolerance = $derived(question.settings?.tolerance as number | undefined);
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{label('numeric.preview.correct_value_label')}: {correctValue ?? label('common.not_set')}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground ui:text-sm">{label('numeric.preview.tolerance_label')}: {tolerance ?? 0}</p>
|
||||
</div>
|
||||
<NumericExpectedFields {question} {labels} />
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { cn } from '../../../../tools';
|
||||
import LabeledValueRow from '../shared/labeled-value-row.svelte';
|
||||
import NumericExpectedFields from './numeric-expected-fields.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const studentVal = $derived.by(() => {
|
||||
if (answer?.type !== 'NUMERIC' || typeof answer.value !== 'number' || !Number.isFinite(answer.value)) {
|
||||
return null;
|
||||
}
|
||||
return answer.value;
|
||||
});
|
||||
|
||||
const correct = $derived(question.settings?.correctValue as number | undefined);
|
||||
const tolerance = $derived((question.settings?.tolerance as number | undefined) ?? 0);
|
||||
|
||||
const inRange = $derived.by(() => {
|
||||
if (studentVal === null || correct === undefined) return null;
|
||||
return Math.abs(studentVal - correct) <= tolerance;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<LabeledValueRow
|
||||
label={label('numeric.review.your_answer_label')}
|
||||
value={studentVal !== null ? String(studentVal) : label('common.not_set')}
|
||||
/>
|
||||
{#if studentVal !== null && correct !== undefined && inRange !== null}
|
||||
<p class={cn(inRange ? 'ui:text-xs ui:text-emerald-600' : 'ui:text-xs ui:text-red-600')}>
|
||||
{inRange ? label('numeric.review.in_range') : label('numeric.review.out_of_range')}
|
||||
</p>
|
||||
{/if}
|
||||
<NumericExpectedFields {question} {labels} />
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared helpers for MCQ-style review rows (radio, checkbox).
|
||||
*/
|
||||
|
||||
export function getOptionNumericId(option: { id?: number | string }): number | null {
|
||||
if (option.id === undefined || option.id === null) return null;
|
||||
const n = Number(option.id);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
export function getMcqReviewRowBorderClass(isSelected: boolean, isCorrect: boolean): string {
|
||||
if (isSelected && isCorrect) {
|
||||
return 'ui:border-emerald-500 ui:bg-emerald-50/40 dark:ui:bg-emerald-950/30';
|
||||
}
|
||||
if (isSelected && !isCorrect) {
|
||||
return 'ui:border-red-500 ui:bg-red-50/40 dark:ui:bg-red-950/30';
|
||||
}
|
||||
if (!isSelected && isCorrect) {
|
||||
return 'ui:border-emerald-300 ui:bg-emerald-50/20 dark:ui:bg-emerald-950/20';
|
||||
}
|
||||
return 'ui:border-muted';
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { applyOrderingAnswer, areSameOrdering, getOrderingItemsFromQuestion } from '../ordering-utils';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const expectedOrder = $derived.by(() => getOrderingItemsFromQuestion(question));
|
||||
const submittedOrder = $derived.by(() => applyOrderingAnswer(expectedOrder, answer));
|
||||
const hasSubmittedAnswer = $derived(answer?.type === 'ORDERING' ? answer.orderedValues.length > 0 : false);
|
||||
const isSubmittedOrderCorrect = $derived(areSameOrdering(expectedOrder, submittedOrder));
|
||||
|
||||
function getRowClass(index: number): string {
|
||||
if (!hasSubmittedAnswer) return 'ui:border-muted';
|
||||
|
||||
const expected = expectedOrder[index];
|
||||
const submitted = submittedOrder[index];
|
||||
const isMatch = expected && submitted ? expected.answerValue === submitted.answerValue : false;
|
||||
|
||||
return isMatch ? 'ui:border-emerald-400 ui:bg-emerald-50/30' : 'ui:border-amber-400 ui:bg-amber-50/30';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-3">
|
||||
{#if hasSubmittedAnswer}
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-xs">{label('ordering.preview.submitted_label')}</p>
|
||||
{#each submittedOrder as item, index (item.id)}
|
||||
<div class={`ui:flex ui:items-center ui:gap-3 ui:rounded-md ui:border ui:p-2 ${getRowClass(index)}`}>
|
||||
<span class="ui:bg-muted ui:text-muted-foreground ui:rounded-full ui:px-2 ui:py-1 ui:text-xs"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<span class="ui:text-sm">{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class={`ui:text-xs ${isSubmittedOrderCorrect ? 'ui:text-emerald-600' : 'ui:text-amber-600'}`}>
|
||||
{isSubmittedOrderCorrect ? label('ordering.preview.matches') : label('ordering.preview.differs')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-xs">{label('ordering.preview.expected_label')}</p>
|
||||
{#each expectedOrder as item, index (item.id)}
|
||||
<div class="ui:bg-background ui:flex ui:items-center ui:gap-3 ui:rounded-md ui:border ui:p-2">
|
||||
<span class="ui:bg-muted ui:text-muted-foreground ui:rounded-full ui:px-2 ui:py-1 ui:text-xs">{index + 1}</span>
|
||||
<span class="ui:text-sm">{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,53 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { applyOrderingAnswer, areSameOrdering, getOrderingItemsFromQuestion } from '../ordering-utils';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OrderingDisplay from './ordering-display.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const expectedOrder = $derived.by(() => getOrderingItemsFromQuestion(question));
|
||||
const submittedOrder = $derived.by(() => applyOrderingAnswer(expectedOrder, answer));
|
||||
const hasSubmittedAnswer = $derived(answer?.type === 'ORDERING' ? answer.orderedValues.length > 0 : false);
|
||||
const isSubmittedOrderCorrect = $derived(areSameOrdering(expectedOrder, submittedOrder));
|
||||
|
||||
function getRowClass(index: number): string {
|
||||
if (!hasSubmittedAnswer) return 'ui:border-muted';
|
||||
|
||||
const expected = expectedOrder[index];
|
||||
const submitted = submittedOrder[index];
|
||||
const isMatch = expected && submitted ? expected.answerValue === submitted.answerValue : false;
|
||||
|
||||
return isMatch ? 'ui:border-emerald-400 ui:bg-emerald-50/30' : 'ui:border-amber-400 ui:bg-amber-50/30';
|
||||
}
|
||||
let props: ExerciseQuestionRendererProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-3">
|
||||
{#if hasSubmittedAnswer}
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-xs">{label('ordering.preview.submitted_label')}</p>
|
||||
{#each submittedOrder as item, index (item.id)}
|
||||
<div class={`ui:flex ui:items-center ui:gap-3 ui:rounded-md ui:border ui:p-2 ${getRowClass(index)}`}>
|
||||
<span class="ui:bg-muted ui:text-muted-foreground ui:rounded-full ui:px-2 ui:py-1 ui:text-xs"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<span class="ui:text-sm">{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class={`ui:text-xs ${isSubmittedOrderCorrect ? 'ui:text-emerald-600' : 'ui:text-amber-600'}`}>
|
||||
{isSubmittedOrderCorrect ? label('ordering.preview.matches') : label('ordering.preview.differs')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="ui:space-y-1">
|
||||
<p class="ui:text-muted-foreground ui:text-xs">{label('ordering.preview.expected_label')}</p>
|
||||
{#each expectedOrder as item, index (item.id)}
|
||||
<div class="ui:bg-background ui:flex ui:items-center ui:gap-3 ui:rounded-md ui:border ui:p-2">
|
||||
<span class="ui:bg-muted ui:text-muted-foreground ui:rounded-full ui:px-2 ui:py-1 ui:text-xs">{index + 1}</span>
|
||||
<span class="ui:text-sm">{item.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<OrderingDisplay {...props} answer={null} />
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OrderingDisplay from './ordering-display.svelte';
|
||||
|
||||
let props: ExerciseQuestionRendererProps = $props();
|
||||
</script>
|
||||
|
||||
<OrderingDisplay {...props} />
|
||||
@@ -1,12 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { getOptionImageUrl, hasOptionImages } from '../option-image-utils';
|
||||
import { cn } from '../../../../tools';
|
||||
import McqPreviewOptionRow from '../shared/mcq-preview-option-row.svelte';
|
||||
|
||||
let { question, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const optionsHaveImages = $derived(hasOptionImages(question.options));
|
||||
</script>
|
||||
@@ -14,22 +11,7 @@
|
||||
<div class="ui:space-y-2">
|
||||
<ul class={optionsHaveImages ? 'ui:grid ui:grid-cols-2 ui:gap-3' : 'ui:space-y-1'}>
|
||||
{#each question.options ?? [] as option, index}
|
||||
{@const optionImageUrl = getOptionImageUrl(option)}
|
||||
<li class={cn(optionsHaveImages && 'ui:space-y-2 ui:rounded-md ui:p-2', 'ui:text-sm')}>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="preview"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{option.label || option.value || [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
{#if option.isCorrect}
|
||||
<span class="ui:ml-2 ui:text-xs ui:text-green-600">{label('common.correct_badge')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
<McqPreviewOptionRow {option} {index} optionImageUrl={getOptionImageUrl(option)} {optionsHaveImages} {labels} />
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import { getOptionImageUrl, hasOptionImages } from '../option-image-utils';
|
||||
import { getMcqReviewRowBorderClass, getOptionNumericId } from '../option-review-utils';
|
||||
import OptionCorrectnessBadges from '../shared/option-correctness-badges.svelte';
|
||||
import { cn } from '../../../../tools';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const selectedOptionId = $derived.by(() => {
|
||||
if (answer?.type !== 'RADIO') return null;
|
||||
return answer.optionId;
|
||||
});
|
||||
|
||||
const optionsHaveImages = $derived(hasOptionImages(question.options));
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<div class={optionsHaveImages ? 'ui:grid ui:grid-cols-2 ui:gap-3' : 'ui:space-y-2'}>
|
||||
{#each question.options ?? [] as option, index}
|
||||
{@const optionImageUrl = getOptionImageUrl(option)}
|
||||
{@const oid = getOptionNumericId(option)}
|
||||
{@const isSelected = oid !== null && selectedOptionId !== null && selectedOptionId === oid}
|
||||
{@const isCorrect = option.isCorrect === true}
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'ui:rounded-md ui:border ui:p-2',
|
||||
optionsHaveImages ? 'ui:space-y-2' : 'ui:flex ui:items-center ui:gap-2',
|
||||
getMcqReviewRowBorderClass(isSelected, isCorrect)
|
||||
)}
|
||||
>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="take"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div class="ui:flex ui:min-w-0 ui:flex-1 ui:items-center ui:gap-2">
|
||||
<span
|
||||
class="ui:inline-flex ui:h-4 ui:w-4 ui:shrink-0 ui:rounded-full ui:border ui:border-muted-foreground"
|
||||
aria-hidden="true"
|
||||
style={`box-shadow: inset 0 0 0 3px ${isSelected ? 'var(--foreground)' : 'transparent'}`}
|
||||
></span>
|
||||
<div class="ui:text-sm ui:font-normal">
|
||||
{option.label ||
|
||||
option.value ||
|
||||
[label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
<OptionCorrectnessBadges {labels} showCorrect={isCorrect} showIncorrect={isSelected && !isCorrect} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Chart from '../../../../base/chart';
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import type { ChartConfig } from '../../../../base/chart';
|
||||
|
||||
import SubmissionResponsePieChart from '../shared/submission-response-pie-chart.svelte';
|
||||
import { getAnswerForQuestion, getSubmissionLabel, normalizeLabel, withChartColors } from '../submission-utils';
|
||||
|
||||
let {
|
||||
@@ -15,7 +16,7 @@
|
||||
label: getSubmissionLabel(labels, 'submission.chart.responses', 'Responses'),
|
||||
color: 'var(--chart-1)'
|
||||
}
|
||||
} satisfies Chart.ChartConfig);
|
||||
} satisfies ChartConfig);
|
||||
|
||||
const chartData = $derived.by(() => {
|
||||
const byOptionId = new Map<string, { key: string; label: string; responses: number }>();
|
||||
@@ -62,13 +63,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if chartData.length > 0}
|
||||
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full ui:flex-col ui:items-center">
|
||||
<Chart.PieChart data={chartData} key="key" label="label" value="responses" c="color" />
|
||||
<Chart.ChartLegend items={chartData.map((d) => ({ label: d.label, color: d.color, value: d.responses }))} />
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
|
||||
</p>
|
||||
{/if}
|
||||
<SubmissionResponsePieChart
|
||||
{chartData}
|
||||
{chartConfig}
|
||||
emptyLabel={getSubmissionLabel(labels, 'submission.chart.no_data', 'No responses yet')}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '../../../../tools';
|
||||
|
||||
interface Props {
|
||||
/** Muted label (already translated by caller). */
|
||||
label: string;
|
||||
/** Main value text. */
|
||||
value: string;
|
||||
class?: string;
|
||||
valueClass?: string;
|
||||
}
|
||||
|
||||
let { label, value, class: className = '', valueClass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<p class={cn('ui:text-sm', className)}>
|
||||
<span class="ui:text-muted-foreground">{label}</span>
|
||||
{' '}
|
||||
<span class={cn('ui:font-medium', valueClass)}>{value}</span>
|
||||
</p>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionLabels } from '@cio/question-types';
|
||||
import OptionImage from '../option-image.svelte';
|
||||
import { cn } from '../../../../tools';
|
||||
import OptionCorrectnessBadges from './option-correctness-badges.svelte';
|
||||
|
||||
interface Props {
|
||||
option: { label: string; value?: string; isCorrect?: boolean };
|
||||
index: number;
|
||||
optionImageUrl: string | null;
|
||||
optionsHaveImages: boolean;
|
||||
labels: ExerciseQuestionLabels | undefined;
|
||||
}
|
||||
|
||||
let { option, index, optionImageUrl, optionsHaveImages, labels }: Props = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
</script>
|
||||
|
||||
<li class={cn(optionsHaveImages && 'ui:space-y-2 ui:rounded-md ui:p-2', 'ui:text-sm')}>
|
||||
<OptionImage
|
||||
src={optionImageUrl}
|
||||
alt={label('question.edit.image_alt')}
|
||||
variant="preview"
|
||||
hasAnyImageInOptions={optionsHaveImages}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{option.label || option.value || [label('common.option_prefix'), String(index + 1)].filter(Boolean).join(' ')}
|
||||
<OptionCorrectnessBadges {labels} showCorrect={option.isCorrect === true} />
|
||||
</div>
|
||||
</li>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionLabels } from '@cio/question-types';
|
||||
import { Badge } from '../../../../base/badge';
|
||||
|
||||
interface Props {
|
||||
labels: ExerciseQuestionLabels | undefined;
|
||||
showCorrect?: boolean;
|
||||
showIncorrect?: boolean;
|
||||
}
|
||||
|
||||
let { labels, showCorrect = false, showIncorrect = false }: Props = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
</script>
|
||||
|
||||
{#if showCorrect}
|
||||
<Badge variant="success" class="ui:ml-2">{label('common.correct_badge')}</Badge>
|
||||
{/if}
|
||||
{#if showIncorrect}
|
||||
<Badge variant="destructive" class="ui:ml-2">{label('common.incorrect_badge')}</Badge>
|
||||
{/if}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import * as Chart from '../../../../base/chart';
|
||||
import type { ChartConfig } from '../../../../base/chart';
|
||||
|
||||
import { CHART_COLORS } from '../submission-utils';
|
||||
|
||||
interface Row {
|
||||
key: string;
|
||||
label: string;
|
||||
responses: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
chartData: Row[];
|
||||
chartConfig: ChartConfig;
|
||||
emptyLabel: string;
|
||||
}
|
||||
|
||||
let { chartData, chartConfig, emptyLabel }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if chartData.length > 0}
|
||||
<Chart.Container config={chartConfig} class="ui:h-[280px] ui:w-full ui:flex-col ui:items-center">
|
||||
<Chart.PieChart data={chartData} key="key" label="label" value="responses" c="color" />
|
||||
<Chart.ChartLegend
|
||||
items={chartData.map((d) => ({
|
||||
label: d.label,
|
||||
color: d.color ?? CHART_COLORS[0],
|
||||
value: d.responses
|
||||
}))}
|
||||
/>
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<p class="ui:text-muted-foreground ui:text-sm">{emptyLabel}</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import LabeledValueRow from '../shared/labeled-value-row.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
function stripHtml(raw: string): string {
|
||||
return raw.replace(/<[^>]*>/g, '').trim();
|
||||
}
|
||||
|
||||
function normalizeToken(s: string): string {
|
||||
return s.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const studentText = $derived.by(() => {
|
||||
if (answer?.type !== 'SHORT_ANSWER') return '';
|
||||
return stripHtml(String(answer.text ?? ''));
|
||||
});
|
||||
|
||||
const acceptedList = $derived.by(() => {
|
||||
const raw = question.settings?.acceptedAnswers;
|
||||
if (typeof raw !== 'string') return [] as string[];
|
||||
return raw
|
||||
.split(',')
|
||||
.map((token) => normalizeToken(token))
|
||||
.filter(Boolean);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<LabeledValueRow
|
||||
label={label('short_answer.review.your_answer_label')}
|
||||
value={studentText || label('common.not_set')}
|
||||
/>
|
||||
{#if acceptedList.length > 0}
|
||||
<p class="ui:text-muted-foreground ui:text-sm">
|
||||
{label('short_answer.review.accepted_label')}: {acceptedList.join(', ')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="ui:text-muted-foreground ui:text-sm">{label('short_answer.preview.helper')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { getTrueFalseCorrectIsTrue } from './true-false-correct';
|
||||
|
||||
let { question, labels }: ExerciseQuestionRendererProps = $props();
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
|
||||
const correctValue = $derived(
|
||||
(question.settings?.correctValue as boolean | undefined) === false
|
||||
? label('true_false.false_label')
|
||||
: label('true_false.true_label')
|
||||
getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined)
|
||||
? label('true_false.true_label')
|
||||
: label('true_false.false_label')
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
|
||||
import { getTrueFalseCorrectIsTrue } from './true-false-correct';
|
||||
import TrueFalseReviewToggle from './true-false-review-toggle.svelte';
|
||||
|
||||
let { question, answer = null, labels }: ExerciseQuestionRendererProps = $props();
|
||||
|
||||
const correctIsTrue = $derived(getTrueFalseCorrectIsTrue(question.settings as Record<string, unknown> | undefined));
|
||||
|
||||
const studentValue = $derived.by(() => {
|
||||
if (answer?.type !== 'TRUE_FALSE') return '';
|
||||
return answer.value ? 'true' : 'false';
|
||||
});
|
||||
|
||||
const isCorrect = $derived.by(() => {
|
||||
if (answer?.type !== 'TRUE_FALSE') return null;
|
||||
return answer.value === correctIsTrue;
|
||||
});
|
||||
</script>
|
||||
|
||||
<TrueFalseReviewToggle {labels} {correctIsTrue} {studentValue} {isCorrect} />
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Matches preview/review: undefined `correctValue` defaults to true.
|
||||
*/
|
||||
export function getTrueFalseCorrectIsTrue(settings: Record<string, unknown> | undefined): boolean {
|
||||
return (settings?.correctValue as boolean | undefined) !== false;
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { getExerciseQuestionLabel, type ExerciseQuestionLabels } from '@cio/question-types';
|
||||
import * as ToggleGroup from '../../../../base/toggle-group';
|
||||
import OptionCorrectnessBadges from '../shared/option-correctness-badges.svelte';
|
||||
|
||||
interface Props {
|
||||
labels: ExerciseQuestionLabels | undefined;
|
||||
correctIsTrue: boolean;
|
||||
studentValue: string;
|
||||
isCorrect: boolean | null;
|
||||
}
|
||||
|
||||
let { labels, correctIsTrue, studentValue, isCorrect }: Props = $props();
|
||||
|
||||
const label = (key: Parameters<typeof getExerciseQuestionLabel>[1], fallback = '') =>
|
||||
getExerciseQuestionLabel(labels, key, fallback);
|
||||
</script>
|
||||
|
||||
<div class="ui:space-y-2">
|
||||
<ToggleGroup.Root type="single" value={studentValue} disabled={true} variant="outline">
|
||||
<ToggleGroup.Item
|
||||
value="true"
|
||||
class={correctIsTrue ? 'ui:border-emerald-500 ui:bg-emerald-50/40 dark:ui:bg-emerald-950/30' : ''}
|
||||
>
|
||||
{label('true_false.true_label')}
|
||||
<OptionCorrectnessBadges
|
||||
{labels}
|
||||
showCorrect={correctIsTrue}
|
||||
showIncorrect={studentValue === 'true' && isCorrect === false}
|
||||
/>
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item
|
||||
value="false"
|
||||
class={!correctIsTrue ? 'ui:border-emerald-500 ui:bg-emerald-50/40 dark:ui:bg-emerald-950/30' : ''}
|
||||
>
|
||||
{label('true_false.false_label')}
|
||||
<OptionCorrectnessBadges
|
||||
{labels}
|
||||
showCorrect={!correctIsTrue}
|
||||
showIncorrect={studentValue === 'false' && isCorrect === false}
|
||||
/>
|
||||
</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
import MultiSelectList from './multi-select-list.svelte';
|
||||
|
||||
export { MultiSelectList };
|
||||
export { MultiSelectList as default };
|
||||
export type { MultiSelectListItem } from './types';
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { CheckboxField } from '../checkbox-field';
|
||||
import { cn } from '../../tools';
|
||||
import type { MultiSelectListItem } from './types';
|
||||
|
||||
interface Props {
|
||||
heading: string;
|
||||
emptyMessage: string;
|
||||
items: MultiSelectListItem[];
|
||||
isSelected: (id: string) => boolean;
|
||||
onToggle: (id: string) => void;
|
||||
/** Prefix for each row's checkbox `name` (e.g. `assign-course` → `assign-course-<id>`). */
|
||||
namePrefix?: string;
|
||||
class?: string;
|
||||
/** Extra classes for the scrollable list container (`data-slot="multi-select-list-items"`). */
|
||||
listClass?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
heading,
|
||||
emptyMessage,
|
||||
items,
|
||||
isSelected,
|
||||
onToggle,
|
||||
namePrefix = 'multi-select',
|
||||
class: className,
|
||||
listClass
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="multi-select-list" class={cn('ui:border-border space-y-3 rounded-md border', className)}>
|
||||
<p class="ui:border-border border-b px-2 py-2 text-sm font-medium">
|
||||
{heading} ({items.length})
|
||||
</p>
|
||||
{#if items.length === 0}
|
||||
<p class="ui:text-muted-foreground px-2 pb-2 text-sm">{emptyMessage}</p>
|
||||
{:else}
|
||||
<div data-slot="multi-select-list-items" class={cn('max-h-60 space-y-2 overflow-y-auto px-2 pb-2', listClass)}>
|
||||
{#each items as item (item.id)}
|
||||
<CheckboxField
|
||||
name={`${namePrefix}-${item.id}`}
|
||||
label={item.label || item.id}
|
||||
checked={isSelected(item.id)}
|
||||
onclick={() => onToggle(item.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
export type MultiSelectListItem = { id: string; label: string };
|
||||
@@ -7,7 +7,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="ui:flex ui:items-center ui:justify-center ui:gap-2"
|
||||
>
|
||||
<img src="https://classroomio.com/logo-512.png" class="ui:w-7 ui:h-7" alt="ClassroomIO Logo" />
|
||||
<img src="/logo-512.png" class="ui:w-7 ui:h-7" alt="ClassroomIO Logo" />
|
||||
<span class="ui:text-foreground ui:text-md">ClassroomIO</span>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -66,4 +66,6 @@ export { RadioOptionCard, RadioOptionCardGroup, type RadioOptionCardOption } fro
|
||||
export { DocumentCard } from './custom/document-card';
|
||||
export * as ExerciseQuestion from './custom/exercise-question';
|
||||
export * as MediaPlayer from './custom/media-player';
|
||||
export { MultiSelectList, type MultiSelectListItem } from './custom/multi-select-list';
|
||||
export * as YoutubeLinkForm from './custom/youtube-link-form';
|
||||
export { BackButton } from './custom/back-button';
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user