@qti-components/portable-custom-interaction 1.2.1-next.1 → 1.3.0-next.3

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.
@@ -9,10 +9,11 @@ export type PciInteractionStatus = 'interacting' | 'suspended' | 'closed' | 'sol
9
9
  * Correct response of a response declaration, mirroring `qti-correct-response`.
10
10
  *
11
11
  * `value` holds the authored `qti-value` content: a single value for `single`
12
- * cardinality, an array of values for every other cardinality.
12
+ * cardinality, a field map (`{ time: '4.0', point: '460 245' }`) for `record`
13
+ * cardinality, and an array of values for every other cardinality.
13
14
  */
14
15
  export interface PciCorrectResponse {
15
- value: string | string[];
16
+ value: string | string[] | Record<string, string | string[] | null>;
16
17
  }
17
18
  /**
18
19
  * Response declaration passed into the `getInstance` configuration so a PCI can
@@ -1 +1 @@
1
- {"version":3,"file":"interface.js","sourceRoot":"","sources":["../src/interface.ts"],"names":[],"mappings":"","sourcesContent":["import type { BaseType, Cardinality } from '@qti-components/base';\n\n/**\n * Shared status values for PCI lifecycle callbacks.\n *\n * Matches the status vocabulary used in IMS QTI 3 PCI documentation.\n */\nexport type PciInteractionStatus = 'interacting' | 'suspended' | 'closed' | 'solution' | 'review';\n\n/**\n * Correct response of a response declaration, mirroring `qti-correct-response`.\n *\n * `value` holds the authored `qti-value` content: a single value for `single`\n * cardinality, an array of values for every other cardinality.\n */\nexport interface PciCorrectResponse {\n value: string | string[];\n}\n\n/**\n * Response declaration passed into the `getInstance` configuration so a PCI can\n * render a correct response itself when the item is in `solution` status.\n *\n * Mirrors `qti-response-declaration` with camelCased names. Every field is\n * optional, a delivery engine passes as much or as little as it has, and\n * `correctResponse` is absent when the response variable has none.\n *\n * `correctResponse` is also withheld while the candidate is still working: it is\n * only sent when `status` is `solution` or `review`, so a PCI never holds the\n * answer key during `interacting`.\n *\n * See https://github.com/1EdTech/qti-project-management/issues/210\n */\nexport interface PciResponseDeclaration {\n baseType?: BaseType;\n cardinality?: Cardinality;\n correctResponse?: PciCorrectResponse;\n}\n\n/**\n * Payload passed to `onready` once a PCI reports it is ready.\n */\nexport interface PciReadyPayload {\n responseIdentifier: string; // The response identifier of the PCI\n status: PciInteractionStatus; // Current status\n}\n\n/**\n * Payload that may be passed to `ondone` when PCI finishes candidate interaction.\n */\nexport interface PciDonePayload {\n responseIdentifier: string; // The response identifier of the PCI\n status: PciInteractionStatus; // The final status after completion\n outcome?: unknown; // Optional outcome data (e.g., score, correctness)\n}\n\n/**\n * Configuration object shape exposed to a PCI instance.\n *\n * Notes for authors:\n * - `properties` contains `data-*` attributes from the host element using dataset rules.\n * - `templateVariables`, `contextVariables`, and `boundTo` follow QTI Appendix-C style JSON values.\n * - `onready` must be called by the PCI once it is interactive.\n * - `ondone` is optional and may be used for explicit completion workflows.\n */\nexport interface ConfigProperties<T> {\n properties: T; // Follows dataset conversion rules (camelCased keys)\n templateVariables: Record<string, unknown>; // Follows structure in Appendix C\n contextVariables: Record<string, unknown>; // Follows structure in Appendix C\n boundTo: unknown; // Follows structure in Appendix C\n responseIdentifier: string; // Unique within interaction scope\n responseDeclaration?: PciResponseDeclaration; // Optional; carries correctResponse only when status is solution/review\n\n onready: (payload: PciReadyPayload) => void; // Callback for when PCI is fully constructed and ready\n ondone?: (payload: PciDonePayload) => void; // Optional callback when candidate finishes interaction\n\n status?: PciInteractionStatus; // Optional, defaults to \"interacting\"\n}\n\n/**\n * IMS-style PCI factory and instance contract.\n *\n * A PCI module registers an object with a `typeIdentifier` and a `getInstance(...)` factory method.\n * The returned PCI instance should implement response/state read/write methods.\n */\nexport interface IMSpci<T> {\n /**\n * Stable PCI type key, expected to match `custom-interaction-type-identifier`.\n */\n typeIdentifier: string;\n\n /** @access public\n * @method getInstance Create a new instance of this portable custom interaction\n * Will be called by the qtiCustomInteractionContext\n * @param {DOM Element} dom - the DOM Element this PCI is being added to\n * @param {Object} configuration - the configuration to apply to this PCI\n * @param {String} state - a previous saved state to apply to this instance.\n * This must have been obtained from a prior call to getState on an\n * instance of this type (same typeIdentifier)\n */\n getInstance: (dom: HTMLElement, configuration: Configuration<T>, state: string) => void;\n\n /** @access public\n * @method getResponse\n * @return {Object} - the value to assign to the bound QTI response variable\n */\n getResponse: () => QtiVariableJSON | undefined;\n\n /** @access public\n * @method setResponse\n * @param {Object} value - the value to assign to the bound QTI response variable\n * NOTE: This is not an official QTI method, so no guarantee it is implemented\n * in all PCIs.\n */\n setResponse: (value: QtiVariableJSON) => void;\n\n /** @access public\n * @method getState\n * @return {String} The current state of this PCI. May be passed to\n * getInstance to later restore this PCI instance.\n * FIXME: host runtime can round-trip object states via prefixed JSON strings;\n * this type remains `string` for IMS compatibility.\n */\n getState: () => string;\n\n /** @access public\n * @method setState\n * @param {String} value - a state previously obtained from getState\n * to restore this PCI instance to a prior state.\n * FIXME: runtime may deserialize prefixed JSON state before applying it.\n */\n setState(value: string): unknown;\n\n /**\n * Optional teardown callback. If present, host may call before unload/cleanup.\n */\n oncompleted?: () => void;\n\n /**\n * Non-standard optional destroy hook; included for compatibility with legacy engines.\n */\n destroy?: () => void; // Not used in IMS and not in TAO implementation, so not used here (optional)\n}\n\n/**\n * Bridge callback signature used by this codebase for PCI registration.\n *\n * IMPORTANT:\n * - This type is intentionally minimal for existing call sites.\n * - Runtime initialization payload in `qti-portable-custom-interaction.ts` contains\n * additional fields (`boundTo`, `templateVariables`, `contextVariables`, etc.).\n * FIXME: align this type with runtime payload to remove ambiguity in generated docs.\n */\nexport declare type Configuration<T> = {\n onready: (pci: IMSpci<ConfigProperties<T>>, state?: string) => void;\n properties: T;\n // PK: following this: https://www.imsglobal.org/spec/qti/v3p0/impl#h.1mc9puik2ft6\n // All below are not used in the IMS Reference implementation, keeping for ref\n /* \n ondone: (pci: IMSPci<T>,response:any,state:string, status?: \"interacting\" | \"closed\" | \"solution\" | \"review\" ) => void;\n status: string;\n templateVariables: any; \n boundTo: any;\n contextVariables\n responseIdentifier\n */\n};\n\n/**\n * QTI base-type `file` representation.\n */\nexport type QtiFileData = {\n data: string;\n mime: string;\n name?: string;\n};\n\nexport type QtiBaseBoolean = boolean;\nexport type QtiBaseInteger = number;\nexport type QtiBaseFloat = number;\nexport type QtiBaseString = string;\nexport type QtiBasePoint = [number, number];\nexport type QtiBasePair = [string, string];\nexport type QtiBaseDirectedPair = [string, string];\nexport type QtiBaseDuration = string; // ISO 8601 duration format\nexport type QtiBaseFile = QtiFileData;\nexport type QtiBaseUri = string;\nexport type QtiBaseIntOrIdentifier = number | string;\nexport type QtiBaseIdentifier = string;\n\n/**\n * Convenience union for primitive response values used by this implementation.\n */\nexport type ResponseType =\n | boolean\n | number\n | string\n | QtiBasePair\n | QtiBasePoint\n | QtiFileData\n | Array<boolean | number | string | QtiBasePair | QtiBasePoint | QtiFileData>;\n\n/**\n * A QTI record entry for `record` cardinality values.\n */\nexport type QtiRecordItem = {\n name: string;\n base?: QtiBaseTypeJSON | null;\n list?: QtiListTypeJSON | null;\n};\nexport type ResponseVariableType = QtiVariableJSON;\n\n/**\n * QTI Appendix-C style JSON representation for base cardinality.\n */\nexport type QtiBaseTypeJSON = {\n boolean?: QtiBaseBoolean;\n integer?: QtiBaseInteger;\n float?: QtiBaseFloat;\n string?: QtiBaseString;\n point?: QtiBasePoint;\n pair?: QtiBasePair;\n directedPair?: QtiBaseDirectedPair;\n duration?: QtiBaseDuration;\n file?: QtiBaseFile;\n uri?: QtiBaseUri;\n intOrIdentifier?: QtiBaseIntOrIdentifier;\n identifier?: QtiBaseIdentifier;\n} | null;\n\n/**\n * QTI Appendix-C style JSON representation for list cardinality.\n */\nexport type QtiListTypeJSON = {\n boolean?: QtiBaseBoolean[];\n integer?: QtiBaseInteger[];\n float?: QtiBaseFloat[];\n string?: QtiBaseString[];\n point?: QtiBasePoint[];\n pair?: QtiBasePair[];\n directedPair?: QtiBaseDirectedPair[];\n duration?: QtiBaseDuration[];\n file?: QtiBaseFile[];\n uri?: QtiBaseUri[];\n intOrIdentifier?: QtiBaseIntOrIdentifier[];\n identifier?: QtiBaseIdentifier[];\n} | null;\n\n/**\n * Complete QTI variable JSON envelope used by PCI bridge messaging.\n */\nexport declare type QtiVariableJSON = {\n base?: QtiBaseTypeJSON;\n list?: QtiListTypeJSON;\n record?: QtiRecordItem[] | null;\n};\n\n/**\n * RequireJS module resolution config accepted by helper utilities and stories.\n */\nexport interface ModuleResolutionConfig {\n waitSeconds?: number;\n context?: string;\n catchError?: boolean;\n paths: {\n [key: string]: string | string[];\n };\n shim?: {\n [key: string]: {\n deps?: string[]; // Array of dependencies\n exports?: string; // The global variable to use as the module's value\n };\n };\n}\n"]}
1
+ {"version":3,"file":"interface.js","sourceRoot":"","sources":["../src/interface.ts"],"names":[],"mappings":"","sourcesContent":["import type { BaseType, Cardinality } from '@qti-components/base';\n\n/**\n * Shared status values for PCI lifecycle callbacks.\n *\n * Matches the status vocabulary used in IMS QTI 3 PCI documentation.\n */\nexport type PciInteractionStatus = 'interacting' | 'suspended' | 'closed' | 'solution' | 'review';\n\n/**\n * Correct response of a response declaration, mirroring `qti-correct-response`.\n *\n * `value` holds the authored `qti-value` content: a single value for `single`\n * cardinality, a field map (`{ time: '4.0', point: '460 245' }`) for `record`\n * cardinality, and an array of values for every other cardinality.\n */\nexport interface PciCorrectResponse {\n value: string | string[] | Record<string, string | string[] | null>;\n}\n\n/**\n * Response declaration passed into the `getInstance` configuration so a PCI can\n * render a correct response itself when the item is in `solution` status.\n *\n * Mirrors `qti-response-declaration` with camelCased names. Every field is\n * optional, a delivery engine passes as much or as little as it has, and\n * `correctResponse` is absent when the response variable has none.\n *\n * `correctResponse` is also withheld while the candidate is still working: it is\n * only sent when `status` is `solution` or `review`, so a PCI never holds the\n * answer key during `interacting`.\n *\n * See https://github.com/1EdTech/qti-project-management/issues/210\n */\nexport interface PciResponseDeclaration {\n baseType?: BaseType;\n cardinality?: Cardinality;\n correctResponse?: PciCorrectResponse;\n}\n\n/**\n * Payload passed to `onready` once a PCI reports it is ready.\n */\nexport interface PciReadyPayload {\n responseIdentifier: string; // The response identifier of the PCI\n status: PciInteractionStatus; // Current status\n}\n\n/**\n * Payload that may be passed to `ondone` when PCI finishes candidate interaction.\n */\nexport interface PciDonePayload {\n responseIdentifier: string; // The response identifier of the PCI\n status: PciInteractionStatus; // The final status after completion\n outcome?: unknown; // Optional outcome data (e.g., score, correctness)\n}\n\n/**\n * Configuration object shape exposed to a PCI instance.\n *\n * Notes for authors:\n * - `properties` contains `data-*` attributes from the host element using dataset rules.\n * - `templateVariables`, `contextVariables`, and `boundTo` follow QTI Appendix-C style JSON values.\n * - `onready` must be called by the PCI once it is interactive.\n * - `ondone` is optional and may be used for explicit completion workflows.\n */\nexport interface ConfigProperties<T> {\n properties: T; // Follows dataset conversion rules (camelCased keys)\n templateVariables: Record<string, unknown>; // Follows structure in Appendix C\n contextVariables: Record<string, unknown>; // Follows structure in Appendix C\n boundTo: unknown; // Follows structure in Appendix C\n responseIdentifier: string; // Unique within interaction scope\n responseDeclaration?: PciResponseDeclaration; // Optional; carries correctResponse only when status is solution/review\n\n onready: (payload: PciReadyPayload) => void; // Callback for when PCI is fully constructed and ready\n ondone?: (payload: PciDonePayload) => void; // Optional callback when candidate finishes interaction\n\n status?: PciInteractionStatus; // Optional, defaults to \"interacting\"\n}\n\n/**\n * IMS-style PCI factory and instance contract.\n *\n * A PCI module registers an object with a `typeIdentifier` and a `getInstance(...)` factory method.\n * The returned PCI instance should implement response/state read/write methods.\n */\nexport interface IMSpci<T> {\n /**\n * Stable PCI type key, expected to match `custom-interaction-type-identifier`.\n */\n typeIdentifier: string;\n\n /** @access public\n * @method getInstance Create a new instance of this portable custom interaction\n * Will be called by the qtiCustomInteractionContext\n * @param {DOM Element} dom - the DOM Element this PCI is being added to\n * @param {Object} configuration - the configuration to apply to this PCI\n * @param {String} state - a previous saved state to apply to this instance.\n * This must have been obtained from a prior call to getState on an\n * instance of this type (same typeIdentifier)\n */\n getInstance: (dom: HTMLElement, configuration: Configuration<T>, state: string) => void;\n\n /** @access public\n * @method getResponse\n * @return {Object} - the value to assign to the bound QTI response variable\n */\n getResponse: () => QtiVariableJSON | undefined;\n\n /** @access public\n * @method setResponse\n * @param {Object} value - the value to assign to the bound QTI response variable\n * NOTE: This is not an official QTI method, so no guarantee it is implemented\n * in all PCIs.\n */\n setResponse: (value: QtiVariableJSON) => void;\n\n /** @access public\n * @method getState\n * @return {String} The current state of this PCI. May be passed to\n * getInstance to later restore this PCI instance.\n * FIXME: host runtime can round-trip object states via prefixed JSON strings;\n * this type remains `string` for IMS compatibility.\n */\n getState: () => string;\n\n /** @access public\n * @method setState\n * @param {String} value - a state previously obtained from getState\n * to restore this PCI instance to a prior state.\n * FIXME: runtime may deserialize prefixed JSON state before applying it.\n */\n setState(value: string): unknown;\n\n /**\n * Optional teardown callback. If present, host may call before unload/cleanup.\n */\n oncompleted?: () => void;\n\n /**\n * Non-standard optional destroy hook; included for compatibility with legacy engines.\n */\n destroy?: () => void; // Not used in IMS and not in TAO implementation, so not used here (optional)\n}\n\n/**\n * Bridge callback signature used by this codebase for PCI registration.\n *\n * IMPORTANT:\n * - This type is intentionally minimal for existing call sites.\n * - Runtime initialization payload in `qti-portable-custom-interaction.ts` contains\n * additional fields (`boundTo`, `templateVariables`, `contextVariables`, etc.).\n * FIXME: align this type with runtime payload to remove ambiguity in generated docs.\n */\nexport declare type Configuration<T> = {\n onready: (pci: IMSpci<ConfigProperties<T>>, state?: string) => void;\n properties: T;\n // PK: following this: https://www.imsglobal.org/spec/qti/v3p0/impl#h.1mc9puik2ft6\n // All below are not used in the IMS Reference implementation, keeping for ref\n /* \n ondone: (pci: IMSPci<T>,response:any,state:string, status?: \"interacting\" | \"closed\" | \"solution\" | \"review\" ) => void;\n status: string;\n templateVariables: any; \n boundTo: any;\n contextVariables\n responseIdentifier\n */\n};\n\n/**\n * QTI base-type `file` representation.\n */\nexport type QtiFileData = {\n data: string;\n mime: string;\n name?: string;\n};\n\nexport type QtiBaseBoolean = boolean;\nexport type QtiBaseInteger = number;\nexport type QtiBaseFloat = number;\nexport type QtiBaseString = string;\nexport type QtiBasePoint = [number, number];\nexport type QtiBasePair = [string, string];\nexport type QtiBaseDirectedPair = [string, string];\nexport type QtiBaseDuration = string; // ISO 8601 duration format\nexport type QtiBaseFile = QtiFileData;\nexport type QtiBaseUri = string;\nexport type QtiBaseIntOrIdentifier = number | string;\nexport type QtiBaseIdentifier = string;\n\n/**\n * Convenience union for primitive response values used by this implementation.\n */\nexport type ResponseType =\n | boolean\n | number\n | string\n | QtiBasePair\n | QtiBasePoint\n | QtiFileData\n | Array<boolean | number | string | QtiBasePair | QtiBasePoint | QtiFileData>;\n\n/**\n * A QTI record entry for `record` cardinality values.\n */\nexport type QtiRecordItem = {\n name: string;\n base?: QtiBaseTypeJSON | null;\n list?: QtiListTypeJSON | null;\n};\nexport type ResponseVariableType = QtiVariableJSON;\n\n/**\n * QTI Appendix-C style JSON representation for base cardinality.\n */\nexport type QtiBaseTypeJSON = {\n boolean?: QtiBaseBoolean;\n integer?: QtiBaseInteger;\n float?: QtiBaseFloat;\n string?: QtiBaseString;\n point?: QtiBasePoint;\n pair?: QtiBasePair;\n directedPair?: QtiBaseDirectedPair;\n duration?: QtiBaseDuration;\n file?: QtiBaseFile;\n uri?: QtiBaseUri;\n intOrIdentifier?: QtiBaseIntOrIdentifier;\n identifier?: QtiBaseIdentifier;\n} | null;\n\n/**\n * QTI Appendix-C style JSON representation for list cardinality.\n */\nexport type QtiListTypeJSON = {\n boolean?: QtiBaseBoolean[];\n integer?: QtiBaseInteger[];\n float?: QtiBaseFloat[];\n string?: QtiBaseString[];\n point?: QtiBasePoint[];\n pair?: QtiBasePair[];\n directedPair?: QtiBaseDirectedPair[];\n duration?: QtiBaseDuration[];\n file?: QtiBaseFile[];\n uri?: QtiBaseUri[];\n intOrIdentifier?: QtiBaseIntOrIdentifier[];\n identifier?: QtiBaseIdentifier[];\n} | null;\n\n/**\n * Complete QTI variable JSON envelope used by PCI bridge messaging.\n */\nexport declare type QtiVariableJSON = {\n base?: QtiBaseTypeJSON;\n list?: QtiListTypeJSON;\n record?: QtiRecordItem[] | null;\n};\n\n/**\n * RequireJS module resolution config accepted by helper utilities and stories.\n */\nexport interface ModuleResolutionConfig {\n waitSeconds?: number;\n context?: string;\n catchError?: boolean;\n paths: {\n [key: string]: string | string[];\n };\n shim?: {\n [key: string]: {\n deps?: string[]; // Array of dependencies\n exports?: string; // The global variable to use as the module's value\n };\n };\n}\n"]}
@@ -1,7 +1,13 @@
1
1
  import { Interaction } from '@qti-components/base';
2
2
  import type { CSSResultGroup } from 'lit';
3
- import type { BaseType, Cardinality, ItemContext, QtiContext } from '@qti-components/base';
3
+ import type { BaseType, Cardinality, ItemContext, QtiContext, RecordValue } from '@qti-components/base';
4
4
  import type { PciInteractionStatus, PciResponseDeclaration, QtiVariableJSON, ResponseVariableType } from './interface';
5
+ /** Text for an inline `<script>`: a `</script` inside it would end the element early. */
6
+ export declare const inlineScriptText: (text: string) => string;
7
+ /** Text for an inline `<style>`: a `</style` inside it would end the element early. */
8
+ export declare const inlineStyleText: (text: string) => string;
9
+ /** A response value as this interaction holds it: a record response is its field map. */
10
+ type PciResponseValue = string | string[] | RecordValue;
5
11
  export declare class QtiPortableCustomInteraction extends Interaction {
6
12
  #private;
7
13
  protected _iframeLoaded: boolean;
@@ -17,6 +23,7 @@ export declare class QtiPortableCustomInteraction extends Interaction {
17
23
  customInteractionTypeIdentifier: string;
18
24
  requirePathsJson: string;
19
25
  requireShimJson: string;
26
+ /** A RequireJS to load in the iframe instead of the inlined copy (src/vendor/require-js.ts). */
20
27
  requireJsUrl: string;
21
28
  baseUrl: string;
22
29
  useDefaultPaths: boolean;
@@ -44,10 +51,6 @@ export declare class QtiPortableCustomInteraction extends Interaction {
44
51
  * Get direct child elements by tag name
45
52
  */
46
53
  private getDirectChildrenByTag;
47
- /**
48
- * Coerce a scalar value to the expected base-type when possible
49
- */
50
- private coerceBaseValue;
51
54
  /**
52
55
  * Normalize an input into a list form
53
56
  */
@@ -56,14 +59,6 @@ export declare class QtiPortableCustomInteraction extends Interaction {
56
59
  * Convert a variable value to QtiVariableJSON
57
60
  */
58
61
  private variableToQtiVariableJSON;
59
- /**
60
- * Convert a record object into QtiVariableJSON
61
- */
62
- private recordToQtiVariableJSON;
63
- /**
64
- * Build a record entry from an arbitrary value
65
- */
66
- private recordEntryFromValue;
67
62
  /**
68
63
  * Extract default QTI_CONTEXT values from qti-context-declaration elements
69
64
  */
@@ -83,7 +78,7 @@ export declare class QtiPortableCustomInteraction extends Interaction {
83
78
  /**
84
79
  * Converts response variables to QtiVariableJSON
85
80
  */
86
- protected responseVariablesToQtiVariableJSON(input: string | string[], cardinality: Cardinality, baseType: BaseType): QtiVariableJSON;
81
+ protected responseVariablesToQtiVariableJSON(input: PciResponseValue, cardinality: Cardinality, baseType: BaseType, fieldBaseTypes?: Record<string, BaseType>): QtiVariableJSON;
87
82
  validate(): boolean;
88
83
  set value(v: string | null);
89
84
  get value(): string | null;
@@ -123,3 +118,4 @@ declare global {
123
118
  'qti-portable-custom-interaction': QtiPortableCustomInteraction;
124
119
  }
125
120
  }
121
+ export {};
@@ -7,20 +7,31 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { css, html } from 'lit';
8
8
  import { property, state } from 'lit/decorators.js';
9
9
  import { consume } from '@lit/context';
10
- import { Interaction, itemContext, qtiContext, removeDoubleSlashes, responseAttributeConverter } from '@qti-components/base';
10
+ import { Interaction, isRecordValue, itemContext, qtiContext, readRecordFieldValues, removeDoubleSlashes, responseAttributeConverter } from '@qti-components/base';
11
+ import nativeCss from '@qti-components/theme/native-css';
11
12
  import styles from './qti-portable-custom-interaction.styles';
13
+ import { coerceBaseValue, recordFromQtiVariableJSON, recordResponseToQtiVariableJSON, recordToQtiVariableJSON } from './record-response';
14
+ import requireJsSource from './vendor/require-js';
15
+ /** Text for an inline `<script>`: a `</script` inside it would end the element early. */
16
+ export const inlineScriptText = (text) => text.replace(/<\/(script)/gi, '<\\/$1');
17
+ /** Text for an inline `<style>`: a `</style` inside it would end the element early. */
18
+ export const inlineStyleText = (text) => text.replace(/<\/(style)/gi, '<\\/$1');
12
19
  /** Statuses in which the item is showing the correct response, so a PCI may legitimately see it. */
13
20
  const STATUSES_SHOWING_CORRECT_RESPONSE = ['solution', 'review'];
14
21
  /**
15
22
  * Whether a response variable actually declares a correct response.
16
23
  *
17
24
  * Non-single cardinality yields `[]` for an empty `<qti-correct-response>`, which is "no correct
18
- * response" and must not be reported as one.
25
+ * response" and must not be reported as one. A record's correct response is a field map.
19
26
  */
20
27
  function hasCorrectResponseValue(value) {
21
28
  if (value === null || value === undefined)
22
29
  return false;
23
- return Array.isArray(value) ? value.length > 0 : value !== '';
30
+ if (Array.isArray(value))
31
+ return value.length > 0;
32
+ if (isRecordValue(value))
33
+ return Object.keys(value).length > 0;
34
+ return value !== '';
24
35
  }
25
36
  export class QtiPortableCustomInteraction extends Interaction {
26
37
  constructor() {
@@ -31,7 +42,8 @@ export class QtiPortableCustomInteraction extends Interaction {
31
42
  this._iframeMessageOrigin = null;
32
43
  this.requirePathsJson = '';
33
44
  this.requireShimJson = '';
34
- this.requireJsUrl = 'https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js';
45
+ /** A RequireJS to load in the iframe instead of the inlined copy (src/vendor/require-js.ts). */
46
+ this.requireJsUrl = '';
35
47
  this.baseUrl = '';
36
48
  this.useDefaultPaths = false;
37
49
  /**
@@ -78,8 +90,9 @@ export class QtiPortableCustomInteraction extends Interaction {
78
90
  break;
79
91
  case 'resize':
80
92
  if (typeof data.height === 'number' && this.iframe) {
93
+ // Only the height follows the content. The width stays 100% of the host, so the PCI keeps
94
+ // following the item's layout when it gets narrower (a column that stacks, a resized window).
81
95
  this.iframe.style.height = `${data.height}px`;
82
- this.iframe.style.width = `${data.width}px`;
83
96
  }
84
97
  break;
85
98
  case 'console':
@@ -132,15 +145,19 @@ export class QtiPortableCustomInteraction extends Interaction {
132
145
  // Treat null/undefined or unconvertible responses as "cleared".
133
146
  // The iframe side is responsible for not emitting an initial clear on load.
134
147
  if (converted === null) {
135
- const emptyResponse = this.responseVariable?.cardinality === 'single' ? '' : [];
148
+ const cardinality = this.responseVariable?.cardinality;
149
+ // A record has no empty literal: a cleared record is NULL.
150
+ const emptyResponse = cardinality === 'record' ? null : cardinality === 'single' ? '' : [];
136
151
  this.response = emptyResponse;
137
152
  this.validate();
138
153
  this.saveResponse(emptyResponse, state);
139
154
  break;
140
155
  }
141
- this.response = converted;
156
+ // A record response travels as its field map; the shared response types only say string | string[].
157
+ const response = converted;
158
+ this.response = response;
142
159
  this.validate();
143
- this.saveResponse(converted, state);
160
+ this.saveResponse(response, state);
144
161
  break;
145
162
  }
146
163
  case 'error':
@@ -271,9 +288,13 @@ export class QtiPortableCustomInteraction extends Interaction {
271
288
  return this.#getRequireShim();
272
289
  }
273
290
  /**
274
- * Converts QtiVariableJSON to a string or string array
291
+ * Converts QtiVariableJSON to a string, a string array, or for a record its field map
275
292
  */
276
293
  #convertQtiVariableJSON(input) {
294
+ if (input && Array.isArray(input.record)) {
295
+ const record = recordFromQtiVariableJSON(input.record);
296
+ return Object.keys(record).length ? record : null;
297
+ }
277
298
  for (const topLevelKey in input) {
278
299
  // eslint-disable-next-line no-prototype-builtins
279
300
  if (input.hasOwnProperty(topLevelKey)) {
@@ -303,44 +324,6 @@ export class QtiPortableCustomInteraction extends Interaction {
303
324
  const needle = tagName.toLowerCase();
304
325
  return Array.from(this.children).filter(child => child.tagName.toLowerCase() === needle);
305
326
  }
306
- /**
307
- * Coerce a scalar value to the expected base-type when possible
308
- */
309
- coerceBaseValue(value, baseType) {
310
- if (value === null || value === undefined)
311
- return value;
312
- switch (baseType) {
313
- case 'integer': {
314
- if (typeof value === 'number')
315
- return value;
316
- const parsed = parseInt(String(value), 10);
317
- return Number.isNaN(parsed) ? value : parsed;
318
- }
319
- case 'float':
320
- case 'duration': {
321
- if (typeof value === 'number')
322
- return value;
323
- const parsed = parseFloat(String(value));
324
- return Number.isNaN(parsed) ? value : parsed;
325
- }
326
- case 'boolean': {
327
- if (typeof value === 'boolean')
328
- return value;
329
- if (typeof value === 'string') {
330
- const lowered = value.toLowerCase();
331
- if (lowered === 'true')
332
- return true;
333
- if (lowered === 'false')
334
- return false;
335
- }
336
- return value;
337
- }
338
- case 'string':
339
- case 'identifier':
340
- default:
341
- return String(value);
342
- }
343
- }
344
327
  /**
345
328
  * Normalize an input into a list form
346
329
  */
@@ -354,63 +337,13 @@ export class QtiPortableCustomInteraction extends Interaction {
354
337
  */
355
338
  variableToQtiVariableJSON(value, cardinality, baseType) {
356
339
  if (cardinality === 'record' || baseType === 'record') {
357
- return this.recordToQtiVariableJSON(value);
340
+ return recordToQtiVariableJSON(value);
358
341
  }
359
342
  if (cardinality !== 'single') {
360
- const listValues = this.normalizeListValue(value).map(v => this.coerceBaseValue(v, baseType));
343
+ const listValues = this.normalizeListValue(value).map(v => coerceBaseValue(v, baseType));
361
344
  return { list: { [baseType]: listValues } };
362
345
  }
363
- return { base: { [baseType]: this.coerceBaseValue(value, baseType) } };
364
- }
365
- /**
366
- * Convert a record object into QtiVariableJSON
367
- */
368
- recordToQtiVariableJSON(value) {
369
- if (!value || typeof value !== 'object') {
370
- return { record: [] };
371
- }
372
- const recordEntries = [];
373
- for (const [name, entryValue] of Object.entries(value)) {
374
- recordEntries.push(this.recordEntryFromValue(name, entryValue));
375
- }
376
- return { record: recordEntries };
377
- }
378
- /**
379
- * Build a record entry from an arbitrary value
380
- */
381
- recordEntryFromValue(name, entryValue) {
382
- if (Array.isArray(entryValue)) {
383
- const filtered = entryValue.filter(v => v !== null && v !== undefined);
384
- if (filtered.length && filtered.every(v => typeof v === 'boolean')) {
385
- return { name, list: { boolean: filtered } };
386
- }
387
- if (filtered.length && filtered.every(v => typeof v === 'number')) {
388
- const numbers = filtered;
389
- const isIntegers = numbers.every(n => Number.isInteger(n));
390
- return { name, list: { [isIntegers ? 'integer' : 'float']: numbers } };
391
- }
392
- return { name, list: { string: entryValue.map(v => (v === null || v === undefined ? '' : String(v))) } };
393
- }
394
- if (typeof entryValue === 'boolean') {
395
- return { name, base: { boolean: entryValue } };
396
- }
397
- if (typeof entryValue === 'number') {
398
- if (Number.isInteger(entryValue))
399
- return { name, base: { integer: entryValue } };
400
- return { name, base: { float: entryValue } };
401
- }
402
- if (entryValue === null || entryValue === undefined) {
403
- return { name, base: { string: '' } };
404
- }
405
- if (typeof entryValue === 'object') {
406
- try {
407
- return { name, base: { string: JSON.stringify(entryValue) } };
408
- }
409
- catch {
410
- return { name, base: { string: String(entryValue) } };
411
- }
412
- }
413
- return { name, base: { string: String(entryValue) } };
346
+ return { base: { [baseType]: coerceBaseValue(value, baseType) } };
414
347
  }
415
348
  /**
416
349
  * Extract default QTI_CONTEXT values from qti-context-declaration elements
@@ -425,15 +358,10 @@ export class QtiPortableCustomInteraction extends Interaction {
425
358
  const defaultValueElement = declaration.querySelector('qti-default-value');
426
359
  if (!defaultValueElement)
427
360
  return;
428
- const valueElements = defaultValueElement.querySelectorAll('qti-value[field-identifier]');
429
- valueElements.forEach(valueElement => {
430
- const fieldIdentifier = valueElement.getAttribute('field-identifier');
431
- if (!fieldIdentifier)
432
- return;
433
- const baseType = (valueElement.getAttribute('base-type') || 'string');
434
- const textContent = valueElement.textContent?.trim() || '';
435
- defaults[fieldIdentifier] = this.coerceBaseValue(textContent, baseType);
436
- });
361
+ const { value, baseTypes } = readRecordFieldValues(defaultValueElement.querySelectorAll('qti-value'));
362
+ for (const [fieldIdentifier, textContent] of Object.entries(value)) {
363
+ defaults[fieldIdentifier] = coerceBaseValue(textContent, baseTypes[fieldIdentifier] ?? 'string');
364
+ }
437
365
  });
438
366
  return defaults;
439
367
  }
@@ -452,7 +380,7 @@ export class QtiPortableCustomInteraction extends Interaction {
452
380
  environmentIdentifier: merged.environmentIdentifier ?? '',
453
381
  ...merged
454
382
  };
455
- return this.recordToQtiVariableJSON(normalized);
383
+ return recordToQtiVariableJSON(normalized);
456
384
  }
457
385
  /**
458
386
  * Collect template variables referenced by qti-template-variable children
@@ -545,7 +473,11 @@ export class QtiPortableCustomInteraction extends Interaction {
545
473
  /**
546
474
  * Converts response variables to QtiVariableJSON
547
475
  */
548
- responseVariablesToQtiVariableJSON(input, cardinality, baseType) {
476
+ responseVariablesToQtiVariableJSON(input, cardinality, baseType, fieldBaseTypes) {
477
+ if (cardinality === 'record') {
478
+ // A record declaration has no base-type: the fields carry their own, declared or inferred.
479
+ return recordResponseToQtiVariableJSON(input, fieldBaseTypes);
480
+ }
549
481
  if (cardinality !== 'single') {
550
482
  const list = { list: {} };
551
483
  list.list[baseType] = input;
@@ -560,6 +492,9 @@ export class QtiPortableCustomInteraction extends Interaction {
560
492
  #hasNonEmptyResponse() {
561
493
  if (this.response === null || this.response === undefined)
562
494
  return false;
495
+ if (isRecordValue(this.response)) {
496
+ return Object.values(this.response).some(v => v !== '' && v !== null && v !== undefined);
497
+ }
563
498
  if (Array.isArray(this.response)) {
564
499
  if (this.response.length === 0)
565
500
  return false;
@@ -590,6 +525,8 @@ export class QtiPortableCustomInteraction extends Interaction {
590
525
  get value() {
591
526
  if (this.#value === null || this.#value === undefined)
592
527
  return null;
528
+ if (isRecordValue(this.#value))
529
+ return JSON.stringify(this.#value);
593
530
  return Array.isArray(this.#value) ? this.#value.join(',') : String(this.#value);
594
531
  }
595
532
  set boundTo(newValue) {
@@ -606,10 +543,11 @@ export class QtiPortableCustomInteraction extends Interaction {
606
543
  const responseVariable = {};
607
544
  const variable = this.context?.variables?.find(v => v.identifier === this.responseIdentifier);
608
545
  if (variable) {
609
- const cardinality = this.context?.variables?.find(v => v.identifier === this.responseIdentifier)?.cardinality || 'single';
610
- const baseType = this.context?.variables?.find(v => v.identifier === this.responseIdentifier)?.baseType || 'string';
611
- const value = this.context?.variables?.find(v => v.identifier === this.responseIdentifier)?.value || null;
612
- const responseVal = this.responseVariablesToQtiVariableJSON(value, cardinality, baseType);
546
+ const cardinality = variable.cardinality || 'single';
547
+ const baseType = variable.baseType || 'string';
548
+ const value = (variable.value || null);
549
+ const fieldBaseTypes = variable.fieldBaseTypes;
550
+ const responseVal = this.responseVariablesToQtiVariableJSON(value, cardinality, baseType, fieldBaseTypes);
613
551
  responseVariable[this.responseIdentifier] = responseVal;
614
552
  }
615
553
  return responseVariable;
@@ -825,15 +763,15 @@ export class QtiPortableCustomInteraction extends Interaction {
825
763
  : `${iframeBaseUrl}/`
826
764
  : `${window.location.origin}/`;
827
765
  const forwardConsole = this.dataset.forwardConsole === 'true';
828
- const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
829
- const defaultComponentsCdnUrl = isLocalhost
830
- ? `${window.location.origin}/cdn/index.js`
831
- : 'https://unpkg.com/@citolab/qti-components/cdn';
832
- const defaultComponentsCssUrl = isLocalhost
833
- ? `${window.location.origin}/dist/item.css`
834
- : 'https://unpkg.com/@citolab/qti-components@latest/dist/item.css';
835
- const componentsCdnUrl = this.dataset.componentsCdnUrl || defaultComponentsCdnUrl;
836
- const componentsCssUrl = this.dataset.componentsCssUrl || defaultComponentsCssUrl;
766
+ // Everything the iframe needs is inline: RequireJS (it loads the PCI module) and the QTI 3 shared vocabulary
767
+ // (qti-layout-row, qti-well, …) that interaction markup may use, both from this version of qti-components. No
768
+ // public CDN is involved. The qti-components bundle and the full item.css are not needed to run a PCI (the markup
769
+ // is plain HTML); a host that wants them in the iframe anyway names its own copy with these attributes.
770
+ const componentsCdnUrl = this.dataset.componentsCdnUrl;
771
+ const componentsCssUrl = this.dataset.componentsCssUrl;
772
+ const requireJsTag = this.requireJsUrl
773
+ ? `<script src="${this.requireJsUrl}"></script>`
774
+ : `<script>${inlineScriptText(requireJsSource)}</script>`;
837
775
  // Extract just the font-related properties you want to copy
838
776
  const fontStyles = `
839
777
  font-family: ${parentStyles.getPropertyValue('font-family')};
@@ -842,15 +780,20 @@ export class QtiPortableCustomInteraction extends Interaction {
842
780
  font-weight: ${parentStyles.getPropertyValue('font-weight')};
843
781
  color: ${parentStyles.getPropertyValue('color')};
844
782
  `;
783
+ // The language of the interaction (the item's xml:lang, or the page's lang), so a PCI that reads
784
+ // `lang` in its iframe shows its own texts in the item's language.
785
+ const langHost = this.closest('[lang], [xml\\:lang]');
786
+ const iframeLang = [langHost?.getAttribute('lang'), langHost?.getAttribute('xml:lang'), document.documentElement.lang]
787
+ .map(l => (l ?? '').trim())
788
+ .find(l => /^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*$/.test(l)) ?? 'en';
845
789
  return /* html */ `<!DOCTYPE html>
846
- <html lang="en">
790
+ <html lang="${iframeLang}">
847
791
  <head>
848
792
  <meta charset="utf-8" />
849
793
  <title>QTI PCI Container</title>
850
794
  <base href="${iframeBaseHref}" />
851
- <script type="module">
852
- import '${componentsCdnUrl}';
853
- </script>
795
+ ${componentsCdnUrl ? `<script type="module">import '${componentsCdnUrl}';</script>` : ''}
796
+ <style>${inlineStyleText(nativeCss)}</style>
854
797
  <style>
855
798
  body, html {
856
799
  margin: 0;
@@ -867,6 +810,8 @@ export class QtiPortableCustomInteraction extends Interaction {
867
810
  }
868
811
  #pci-container {
869
812
  width: 100%;
813
+ /* Keeps the first and last child's margins inside, so scrollHeight is the full height. */
814
+ display: flow-root;
870
815
  }
871
816
  qti-interaction-markup {
872
817
  display: block;
@@ -874,8 +819,8 @@ export class QtiPortableCustomInteraction extends Interaction {
874
819
  min-height: 50px;
875
820
  }
876
821
  </style>
877
- <link href="${componentsCssUrl}" rel="stylesheet" />
878
- <script src="${this.requireJsUrl}"></script>
822
+ ${componentsCssUrl ? `<link href="${componentsCssUrl}" rel="stylesheet" />` : ''}
823
+ ${requireJsTag}
879
824
  <script>
880
825
  const forwardConsole = ${forwardConsole ? 'true' : 'false'};
881
826
  if (forwardConsole) {
@@ -1620,7 +1565,8 @@ export class QtiPortableCustomInteraction extends Interaction {
1620
1565
  let previousHeight = 0;
1621
1566
  const notifyResize = () => {
1622
1567
  const container = document.getElementById('pci-container');
1623
- const newHeight = container.scrollHeight + 100;
1568
+ // The content's own height (subpixel, rounded up) or what overflows it, e.g. an open popup.
1569
+ const newHeight = Math.max(Math.ceil(container.getBoundingClientRect().height), container.scrollHeight);
1624
1570
  if (newHeight !== previousHeight) {
1625
1571
  previousHeight = newHeight;
1626
1572
  clearTimeout(resizeTimeout);