From 8e77df4c09b719d9b82c9f661d6f382ff577a1f8 Mon Sep 17 00:00:00 2001 From: cchang-vassar <79338042+cchang-vassar@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:45:20 -0700 Subject: [PATCH 1/4] add a variation of plugin-survey-text where new input fields are generated for a question when user presses Enter or spacebar, and can be deleted if the user presses backspace on an empty field (unless it is the first input field) --- .changeset/serious-suns-hang.md | 5 + package-lock.json | 16 + packages/plugin-survey-text-dynamic/demo.html | 78 +++ .../jest.config.cjs | 1 + .../plugin-survey-text-dynamic/package.json | 43 ++ .../rollup.config.mjs | 3 + .../src/index.spec.ts | 215 ++++++++ .../plugin-survey-text-dynamic/src/index.ts | 476 ++++++++++++++++++ .../plugin-survey-text-dynamic/tsconfig.json | 7 + 9 files changed, 844 insertions(+) create mode 100644 .changeset/serious-suns-hang.md create mode 100644 packages/plugin-survey-text-dynamic/demo.html create mode 100644 packages/plugin-survey-text-dynamic/jest.config.cjs create mode 100644 packages/plugin-survey-text-dynamic/package.json create mode 100644 packages/plugin-survey-text-dynamic/rollup.config.mjs create mode 100644 packages/plugin-survey-text-dynamic/src/index.spec.ts create mode 100644 packages/plugin-survey-text-dynamic/src/index.ts create mode 100644 packages/plugin-survey-text-dynamic/tsconfig.json diff --git a/.changeset/serious-suns-hang.md b/.changeset/serious-suns-hang.md new file mode 100644 index 0000000000..e44a20511f --- /dev/null +++ b/.changeset/serious-suns-hang.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-survey-text-dynamic": minor +--- + +FEATURE: Added a new plugin that is a variation on plugin-survey-text, where a new input field is generated on the right for the same question when the user presses Enter or spacebar while in the existing input field. diff --git a/package-lock.json b/package-lock.json index 03f1e9e3d8..c6bdb04bab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2514,6 +2514,10 @@ "resolved": "packages/plugin-survey-text", "link": true }, + "node_modules/@jspsych/plugin-survey-text-dynamic": { + "resolved": "packages/plugin-survey-text-dynamic", + "link": true + }, "node_modules/@jspsych/plugin-video-button-response": { "resolved": "packages/plugin-video-button-response", "link": true @@ -14402,6 +14406,18 @@ "jspsych": ">=7.1.0" } }, + "packages/plugin-survey-text-dynamic": { + "name": "@jspsych/plugin-survey-text-dynamic", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@jspsych/config": "^3.2.0", + "@jspsych/test-utils": "^1.2.0" + }, + "peerDependencies": { + "jspsych": ">=7.1.0" + } + }, "packages/plugin-survey/node_modules/survey-core": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/survey-core/-/survey-core-2.2.0.tgz", diff --git a/packages/plugin-survey-text-dynamic/demo.html b/packages/plugin-survey-text-dynamic/demo.html new file mode 100644 index 0000000000..5aaefa96ee --- /dev/null +++ b/packages/plugin-survey-text-dynamic/demo.html @@ -0,0 +1,78 @@ + + + + Survey Text Dynamic Plugin Demo + + + + + + + + + + diff --git a/packages/plugin-survey-text-dynamic/jest.config.cjs b/packages/plugin-survey-text-dynamic/jest.config.cjs new file mode 100644 index 0000000000..6ac19d5cf3 --- /dev/null +++ b/packages/plugin-survey-text-dynamic/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/plugin-survey-text-dynamic/package.json b/packages/plugin-survey-text-dynamic/package.json new file mode 100644 index 0000000000..21c54f6e43 --- /dev/null +++ b/packages/plugin-survey-text-dynamic/package.json @@ -0,0 +1,43 @@ +{ + "name": "@jspsych/plugin-survey-text-dynamic", + "version": "1.0.0", + "description": "A jspsych plugin for free response survey questions with dynamic input field generation", + "type": "module", + "main": "dist/index.cjs", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "typings": "dist/index.d.ts", + "unpkg": "dist/index.browser.min.js", + "files": [ + "src", + "dist" + ], + "source": "src/index.ts", + "scripts": { + "test": "jest", + "test:watch": "npm test -- --watch", + "tsc": "tsc", + "build": "rollup --config", + "build:watch": "npm run build -- --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jspsych/jsPsych.git", + "directory": "packages/plugin-survey-text-dynamic" + }, + "author": "Cherrie Chang", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jsPsych/issues" + }, + "homepage": "https://www.jspsych.org/latest/plugins/survey-text-dynamic", + "peerDependencies": { + "jspsych": ">=7.1.0" + }, + "devDependencies": { + "@jspsych/config": "^3.2.0", + "@jspsych/test-utils": "^1.2.0" + } +} diff --git a/packages/plugin-survey-text-dynamic/rollup.config.mjs b/packages/plugin-survey-text-dynamic/rollup.config.mjs new file mode 100644 index 0000000000..544ed9788d --- /dev/null +++ b/packages/plugin-survey-text-dynamic/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfig } from "@jspsych/config/rollup"; + +export default makeRollupConfig("jsPsychSurveyTextDynamic"); diff --git a/packages/plugin-survey-text-dynamic/src/index.spec.ts b/packages/plugin-survey-text-dynamic/src/index.spec.ts new file mode 100644 index 0000000000..04f227744d --- /dev/null +++ b/packages/plugin-survey-text-dynamic/src/index.spec.ts @@ -0,0 +1,215 @@ +import { clickTarget, startTimeline } from "@jspsych/test-utils"; + +import surveyTextDynamic from "."; + +jest.useFakeTimers(); + +describe("survey-text-dynamic plugin", () => { + test("loads", async () => { + const { jsPsych } = await startTimeline([ + { + type: surveyTextDynamic, + questions: [ + { + prompt: "What is your name?", + name: "name", + }, + ], + }, + ]); + + expect( + jsPsych.getDisplayElement().querySelector("#jspsych-survey-text-dynamic-form") + ).not.toBeNull(); + }); + + test("data are logged with the right question names", async () => { + const { jsPsych, expectFinished } = await startTimeline([ + { + type: surveyTextDynamic, + questions: [ + { + prompt: "What is your name?", + name: "name", + }, + { + prompt: "What is your age?", + name: "age", + }, + ], + }, + ]); + + const name_input = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; + const age_input = jsPsych.getDisplayElement().querySelector("#input-1-0") as HTMLInputElement; + + name_input.value = "John"; + age_input.value = "25"; + + await clickTarget( + jsPsych.getDisplayElement().querySelector("#jspsych-survey-text-dynamic-next") + ); + + await expectFinished(); + + const data = jsPsych.data.get().values()[0]; + expect(data.response.name).toEqual(["John"]); + expect(data.response.age).toEqual(["25"]); + }); + + test("creates new input when Enter is pressed", async () => { + const { jsPsych } = await startTimeline([ + { + type: surveyTextDynamic, + questions: [ + { + prompt: "List your hobbies:", + name: "hobbies", + }, + ], + }, + ]); + + const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; + firstInput.value = "Reading"; + + // Simulate Enter key press + const enterEvent = new KeyboardEvent("keydown", { key: "Enter" }); + firstInput.dispatchEvent(enterEvent); + + jest.runAllTimers(); + + // Check that a second input was created + const secondInput = jsPsych.getDisplayElement().querySelector("#input-0-1") as HTMLInputElement; + expect(secondInput).not.toBeNull(); + }); + + test("creates new input when spacebar is pressed", async () => { + const { jsPsych } = await startTimeline([ + { + type: surveyTextDynamic, + questions: [ + { + prompt: "List your hobbies:", + name: "hobbies", + }, + ], + }, + ]); + + const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; + firstInput.value = "Reading"; + + // Simulate Enter key press + const enterEvent = new KeyboardEvent("keydown", { key: " " }); + firstInput.dispatchEvent(enterEvent); + + jest.runAllTimers(); + + // Check that a second input was created + const secondInput = jsPsych.getDisplayElement().querySelector("#input-0-1") as HTMLInputElement; + expect(secondInput).not.toBeNull(); + }); + + test("collects data from multiple dynamic inputs", async () => { + const { jsPsych, expectFinished } = await startTimeline([ + { + type: surveyTextDynamic, + questions: [ + { + prompt: "List your hobbies:", + name: "hobbies", + }, + ], + }, + ]); + + const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; + firstInput.value = "Reading"; + + // Create second input + const enterEvent = new KeyboardEvent("keydown", { key: "Enter" }); + firstInput.dispatchEvent(enterEvent); + + jest.runAllTimers(); + + const secondInput = jsPsych.getDisplayElement().querySelector("#input-0-1") as HTMLInputElement; + secondInput.value = "Swimming"; + + await clickTarget( + jsPsych.getDisplayElement().querySelector("#jspsych-survey-text-dynamic-next") + ); + + await expectFinished(); + + const data = jsPsych.data.get().values()[0]; + expect(data.response.hobbies).toEqual(["Reading", "Swimming"]); + }); + + test("deletes empty input field when backspace is pressed (except first input)", async () => { + const { jsPsych } = await startTimeline([ + { + type: surveyTextDynamic, + questions: [ + { + prompt: "List your hobbies:", + name: "hobbies", + }, + ], + }, + ]); + + const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; + firstInput.value = "Reading"; + + // Create second input + const enterEvent = new KeyboardEvent("keydown", { key: "Enter" }); + firstInput.dispatchEvent(enterEvent); + + jest.runAllTimers(); + + // Verify second input was created + const secondInput = jsPsych.getDisplayElement().querySelector("#input-0-1") as HTMLInputElement; + expect(secondInput).not.toBeNull(); + + // Press backspace on empty second input + const backspaceEvent = new KeyboardEvent("keydown", { key: "Backspace" }); + secondInput.dispatchEvent(backspaceEvent); + + jest.runAllTimers(); + + // Check that second input was removed + const deletedInput = jsPsych.getDisplayElement().querySelector("#input-0-1"); + expect(deletedInput).toBeNull(); + + // First input should still exist + const remainingFirstInput = jsPsych.getDisplayElement().querySelector("#input-0-0"); + expect(remainingFirstInput).not.toBeNull(); + }); + + test("does not delete first input field when backspace is pressed", async () => { + const { jsPsych } = await startTimeline([ + { + type: surveyTextDynamic, + questions: [ + { + prompt: "List your hobbies:", + name: "hobbies", + }, + ], + }, + ]); + + const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; + + // Press backspace on empty first input + const backspaceEvent = new KeyboardEvent("keydown", { key: "Backspace" }); + firstInput.dispatchEvent(backspaceEvent); + + jest.runAllTimers(); + + // First input should still exist + const remainingFirstInput = jsPsych.getDisplayElement().querySelector("#input-0-0"); + expect(remainingFirstInput).not.toBeNull(); + }); +}); diff --git a/packages/plugin-survey-text-dynamic/src/index.ts b/packages/plugin-survey-text-dynamic/src/index.ts new file mode 100644 index 0000000000..5dd94deff8 --- /dev/null +++ b/packages/plugin-survey-text-dynamic/src/index.ts @@ -0,0 +1,476 @@ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; + +import { version } from "../package.json"; + +const info = { + name: "survey-text-dynamic", + version: version, + parameters: { + /** + * An array of objects, each object represents a question that appears on the screen. Each object contains a prompt, + * options, required, and horizontal parameter that will be applied to the question. See examples below for further + * clarification.`prompt`: Type string, default value is *undefined*. The string is prompt/question that will be + * associated with a group of options (radio buttons). All questions will get presented on the same page (trial). + * `options`: Type array, defualt value is *undefined*. An array of strings. The array contains a set of options to + * display for an individual question.`required`: Type boolean, default value is null. The boolean value indicates + * if a question is required('true') or not ('false'), using the HTML5 `required` attribute. If this parameter is + * undefined, the question will be optional. `horizontal`:Type boolean, default value is false. If true, then the + * question is centered and the options are displayed horizontally. `name`: Name of the question. Used for storing + * data. If left undefined then default names (`Q0`, `Q1`, `...`) will be used for the questions. + */ + questions: { + type: ParameterType.COMPLEX, + array: true, + default: undefined, + nested: { + /** Question prompt. */ + prompt: { + type: ParameterType.HTML_STRING, + default: undefined, + }, + /** Placeholder text in the response text box. */ + placeholder: { + type: ParameterType.STRING, + default: "", + }, + /** The number of rows for the response text box. */ + rows: { + type: ParameterType.INT, + default: 1, + }, + /** The number of columns for the response text box. */ + columns: { + type: ParameterType.INT, + default: 40, + }, + /** Whether or not a response to this question must be given in order to continue. */ + required: { + type: ParameterType.BOOL, + default: false, + }, + /** Name of the question in the trial data. If no name is given, the questions are named Q0, Q1, etc. */ + name: { + type: ParameterType.STRING, + default: "", + }, + }, + }, + /** + * If true, the display order of `questions` is randomly determined at the start of the trial. In the data + * object, `Q0` will still refer to the first question in the array, regardless of where it was presented + * visually. + */ + randomize_question_order: { + type: ParameterType.BOOL, + default: false, + }, + /** HTML formatted string to display at the top of the page above all the questions. */ + preamble: { + type: ParameterType.HTML_STRING, + default: null, + }, + /** Label of the button to submit responses. */ + button_label: { + type: ParameterType.STRING, + default: "Continue", + }, + /** Setting this to true will enable browser auto-complete or auto-fill for the form. */ + autocomplete: { + type: ParameterType.BOOL, + default: false, + }, + }, + data: { + /** An object containing the response for each question. The object will have a separate key (variable) for each question, with the first question in the trial being recorded in `Q0`, the second in `Q1`, and so on. The responses are recorded as integers, representing the position selected on the likert scale for that question. If the `name` parameter is defined for the question, then the response object will use the value of `name` as the key for each question. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. */ + response: { + type: ParameterType.OBJECT, + }, + /** The response time in milliseconds for the participant to make a response. The time is measured from when the questions first appear on the screen until the participant's response(s) are submitted. */ + rt: { + type: ParameterType.INT, + }, + /** An array with the order of questions. For example `[2,0,1]` would indicate that the first question was `trial.questions[2]` (the third item in the `questions` parameter), the second question was `trial.questions[0]`, and the final question was `trial.questions[1]`. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. */ + question_order: { + type: ParameterType.INT, + array: true, + }, + }, + // prettier-ignore + citations: '__CITATIONS__', +}; + +type Info = typeof info; + +/** + * + * The survey-text-dynamic plugin displays a set of questions with free response text fields that the participant can type answers into. A new input field for the same question is generated when Enter is pressed. + * + * @author Cherrie Chang + * @see {@link https://www.jspsych.org/latest/plugins/survey-text-dynamic/ survey-text-dynamic plugin documentation on jspsych.org} + */ +class SurveyTextDynamicPlugin implements JsPsychPlugin { + static info = info; + + constructor(private jsPsych: JsPsych) {} + + trial(display_element: HTMLElement, trial: TrialType) { + // tracks how many input fields were generated for each question + const inputFieldCounts = new Array(trial.questions.length).fill(1); + + for (var i = 0; i < trial.questions.length; i++) { + if (typeof trial.questions[i].rows == "undefined") { + trial.questions[i].rows = 1; + } + } + for (var i = 0; i < trial.questions.length; i++) { + if (typeof trial.questions[i].columns == "undefined") { + trial.questions[i].columns = 40; + } + } + for (var i = 0; i < trial.questions.length; i++) { + if (typeof trial.questions[i].value == "undefined") { + trial.questions[i].value = ""; + } + } + + var html = ""; + // show preamble text + if (trial.preamble !== null) { + html += + '
' + + trial.preamble + + "
"; + } + // start form + if (trial.autocomplete) { + html += '
'; + } else { + html += ''; + } + // generate question order + var question_order = []; + for (var i = 0; i < trial.questions.length; i++) { + question_order.push(i); + } + if (trial.randomize_question_order) { + question_order = this.jsPsych.randomization.shuffle(question_order); + } + + // add questions + for (var i = 0; i < trial.questions.length; i++) { + var question = trial.questions[question_order[i]]; + var question_index = question_order[i]; + html += + // question + '
'; + html += '

' + question.prompt + "

"; + + // container for dynamic inputs + html += + '
'; + + var autofocus = i == 0 ? "autofocus" : ""; + var req = question.required ? "required" : ""; + if (question.rows == 1) { + html += + ''; + } else { + html += + ''; + } + + html += "
"; // Close the dynamic inputs container + html += "
"; + } + + // add submit button + html += + ''; + + html += "
"; + display_element.innerHTML = html; + + // remove an input field when user presses backspace on empty field + const removeInput = (questionIndex: number, inputIndex: number) => { + if (inputIndex === 0) return; // don't remove the first input field + + const inputToRemove = display_element.querySelector(`#input-${questionIndex}-${inputIndex}`); + if (inputToRemove) { + inputToRemove.remove(); + inputFieldCounts[questionIndex]--; + + // Focus the previous input field + const previousInputIndex = inputIndex - 1; + const previousInput = display_element.querySelector( + `#input-${questionIndex}-${previousInputIndex}` + ) as HTMLInputElement; + if (previousInput) { + previousInput.focus(); + } + } + }; + + // add new input fields to a question when user presses Enter or spacebar + const addNewInput = (questionIndex: number) => { + const container = display_element.querySelector(`#dynamic-inputs-container-${questionIndex}`); + const question = trial.questions[questionIndex]; + const inputIndex = inputFieldCounts[questionIndex]; + + let newInputHtml = ""; + if (question.rows == 1) { + newInputHtml = + ''; + } else { + newInputHtml = + ''; + } + + container.insertAdjacentHTML("beforeend", newInputHtml); + const newInput = container.querySelector( + `#input-${questionIndex}-${inputIndex}` + ) as HTMLInputElement; + + // add event listener for the new input + newInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + addNewInput(questionIndex); + inputFieldCounts[questionIndex]++; + } else if (e.key === "Backspace" && newInput.value === "" && inputIndex > 0) { + e.preventDefault(); + removeInput(questionIndex, inputIndex); + } + }); + + // focus the new input + newInput.focus(); + }; + + // add event listeners to all initial inputs + for (let i = 0; i < trial.questions.length; i++) { + const questionIndex = question_order[i]; + const initialInput = display_element.querySelector( + `#input-${questionIndex}-0` + ) as HTMLInputElement; + + initialInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + addNewInput(questionIndex); + inputFieldCounts[questionIndex]++; + } + }); + } + + // backup in case autofocus doesn't work + display_element.querySelector("#input-" + question_order[0] + "-0").focus(); + + display_element + .querySelector("#jspsych-survey-text-dynamic-form") + .addEventListener("submit", (e) => { + e.preventDefault(); + // measure response time + var endTime = performance.now(); + var response_time = Math.round(endTime - startTime); + + // create object to hold responses + var question_data = {}; + + for (var index = 0; index < trial.questions.length; index++) { + var id = "Q" + index; + var name = trial.questions[index].name || id; + + // collect all inputs for this question + var questionResponses = []; + for (let inputIndex = 0; inputIndex < inputFieldCounts[index]; inputIndex++) { + const inputElement = display_element.querySelector( + `#input-${index}-${inputIndex}` + ) as HTMLInputElement; + if (inputElement && inputElement.value.trim() !== "") { + questionResponses.push(inputElement.value); + } + } + + question_data[name] = questionResponses; + } + + // save data + var trialdata = { + rt: response_time, + response: question_data, + question_order: question_order, + }; + + // next trial + this.jsPsych.finishTrial(trialdata); + }); + + var startTime = performance.now(); + } + + simulate( + trial: TrialType, + simulation_mode, + simulation_options: any, + load_callback: () => void + ) { + if (simulation_mode == "data-only") { + load_callback(); + this.simulate_data_only(trial, simulation_options); + } + if (simulation_mode == "visual") { + this.simulate_visual(trial, simulation_options, load_callback); + } + } + + private create_simulation_data(trial: TrialType, simulation_options) { + const question_data = {}; + let rt = 1000; + + for (const q of trial.questions) { + const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`; + const numInputs = this.jsPsych.randomization.randomInt(1, 4); // simulate 1-4 inputs per question + const responses = []; + + for (let i = 0; i < numInputs; i++) { + const ans_words = + q.rows == 1 + ? this.jsPsych.randomization.sampleExponential(0.25) + : this.jsPsych.randomization.randomInt(1, 10) * q.rows; + responses.push( + this.jsPsych.randomization.randomWords({ + exactly: ans_words, + join: " ", + }) + ); + } + + question_data[name] = responses; + rt += this.jsPsych.randomization.sampleExGaussian(2000, 400, 0.004, true); + } + + const default_data = { + response: question_data, + rt: rt, + question_order: trial.questions.map((_, i) => i), + }; + + const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); + + this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); + + return data; + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, simulation_options); + + this.jsPsych.finishTrial(data); + } + + private simulate_visual(trial: TrialType, simulation_options, load_callback: () => void) { + const data = this.create_simulation_data(trial, simulation_options); + + const display_element = this.jsPsych.getDisplayElement(); + + this.trial(display_element, trial); + load_callback(); + + // simulate typing in the first input of each question + for (let i = 0; i < trial.questions.length; i++) { + const responses = data.response[trial.questions[i].name || `Q${i}`] as string[]; + if (responses.length > 0) { + this.jsPsych.pluginAPI.fillTextInput( + display_element.querySelector(`#input-${i}-0`), + responses[0], + ((data.rt - 1000) / trial.questions.length) * (i + 1) + ); + } + } + + this.jsPsych.pluginAPI.clickTarget( + display_element.querySelector("#jspsych-survey-text-dynamic-next"), + data.rt + ); + } +} + +export default SurveyTextDynamicPlugin; diff --git a/packages/plugin-survey-text-dynamic/tsconfig.json b/packages/plugin-survey-text-dynamic/tsconfig.json new file mode 100644 index 0000000000..588f044808 --- /dev/null +++ b/packages/plugin-survey-text-dynamic/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jspsych/config/tsconfig.core.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +} From ba2765db928642addd4af0d22f2582094da55ee3 Mon Sep 17 00:00:00 2001 From: cchang-vassar <79338042+cchang-vassar@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:46:19 -0700 Subject: [PATCH 2/4] Add question_order as part of generated data for the original survey-text plugin --- .changeset/shiny-rings-destroy.md | 5 +++++ packages/plugin-survey-text/src/index.ts | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .changeset/shiny-rings-destroy.md diff --git a/.changeset/shiny-rings-destroy.md b/.changeset/shiny-rings-destroy.md new file mode 100644 index 0000000000..36ca15511b --- /dev/null +++ b/.changeset/shiny-rings-destroy.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-survey-text": patch +--- + +ENHANCEMENT: Add question_order as part of generated data for the original survey-text plugin. diff --git a/packages/plugin-survey-text/src/index.ts b/packages/plugin-survey-text/src/index.ts index a5ab18299f..a72b00ac3c 100644 --- a/packages/plugin-survey-text/src/index.ts +++ b/packages/plugin-survey-text/src/index.ts @@ -243,6 +243,7 @@ class SurveyTextPlugin implements JsPsychPlugin { var trialdata = { rt: response_time, response: question_data, + question_order: question_order, }; // next trial @@ -287,6 +288,7 @@ class SurveyTextPlugin implements JsPsychPlugin { const default_data = { response: question_data, rt: rt, + question_order: trial.questions.map((_, i) => i), }; const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); From 2510577a6fee73a444ffa6bd1dd11e0e146f2be0 Mon Sep 17 00:00:00 2001 From: cchang-vassar <79338042+cchang-vassar@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:14:32 -0700 Subject: [PATCH 3/4] add survey-text-dynamic functionality as parameter in survey-text --- examples/jspsych-survey-text.html | 27 ++- packages/plugin-survey-text/src/index.ts | 218 +++++++++++++++++++---- 2 files changed, 209 insertions(+), 36 deletions(-) diff --git a/examples/jspsych-survey-text.html b/examples/jspsych-survey-text.html index a84683d48a..8088e54310 100644 --- a/examples/jspsych-survey-text.html +++ b/examples/jspsych-survey-text.html @@ -3,7 +3,7 @@ - + diff --git a/packages/plugin-survey-text/src/index.ts b/packages/plugin-survey-text/src/index.ts index a72b00ac3c..7edb467d64 100644 --- a/packages/plugin-survey-text/src/index.ts +++ b/packages/plugin-survey-text/src/index.ts @@ -79,6 +79,11 @@ const info = { type: ParameterType.BOOL, default: false, }, + /** Setting this to true will make the number of input fields for each question dynamic. When the user presses Enter or spacebar when in an input field, a new one is generated next to the current input field. This allows an array of responses to each question. */ + dynamic_input_fields: { + type: ParameterType.BOOL, + default: false, + }, }, data: { /** An object containing the response for each question. The object will have a separate key (variable) for each question, with the first question in the trial being recorded in `Q0`, the second in `Q1`, and so on. The responses are recorded as integers, representing the position selected on the likert scale for that question. If the `name` parameter is defined for the question, then the response object will use the value of `name` as the key for each question. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. */ @@ -114,6 +119,9 @@ class SurveyTextPlugin implements JsPsychPlugin { constructor(private jsPsych: JsPsych) {} trial(display_element: HTMLElement, trial: TrialType) { + // tracks how many input fields were generated for each question + const inputFieldCounts = new Array(trial.questions.length).fill(1); + for (var i = 0; i < trial.questions.length; i++) { if (typeof trial.questions[i].rows == "undefined") { trial.questions[i].rows = 1; @@ -158,21 +166,33 @@ class SurveyTextPlugin implements JsPsychPlugin { var question = trial.questions[question_order[i]]; var question_index = question_order[i]; html += + // question '
'; html += '

' + question.prompt + "

"; + + if (trial.dynamic_input_fields) { + // container for dynamic inputs + html += + '
'; + } + var autofocus = i == 0 ? "autofocus" : ""; var req = question.required ? "required" : ""; if (question.rows == 1) { html += ' { html += ''; } + + html += "
"; // Close the dynamic inputs container html += "
"; } @@ -213,8 +237,120 @@ class SurveyTextPlugin implements JsPsychPlugin { html += ""; display_element.innerHTML = html; + // remove an input field when user presses backspace on empty field + const removeInput = (questionIndex: number, inputIndex: number) => { + if (inputIndex === 0) return; // don't remove the first input field + + const inputToRemove = display_element.querySelector(`#input-${questionIndex}-${inputIndex}`); + if (inputToRemove) { + inputToRemove.remove(); + inputFieldCounts[questionIndex]--; + + // Focus the previous input field + const previousInputIndex = inputIndex - 1; + const previousInput = display_element.querySelector( + `#input-${questionIndex}-${previousInputIndex}` + ) as HTMLInputElement; + if (previousInput) { + previousInput.focus(); + } + } + }; + + // add new input fields to a question when user presses Enter or spacebar + const addNewInput = (questionIndex: number) => { + const container = display_element.querySelector(`#dynamic-inputs-container-${questionIndex}`); + const question = trial.questions[questionIndex]; + const inputIndex = inputFieldCounts[questionIndex]; + + let newInputHtml = ""; + if (question.rows == 1) { + newInputHtml = + ''; + } else { + newInputHtml = + ''; + } + + container.insertAdjacentHTML("beforeend", newInputHtml); + const newInput = container.querySelector( + `#input-${questionIndex}-${inputIndex}` + ) as HTMLInputElement; + + // add event listener for the new input + newInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + addNewInput(questionIndex); + inputFieldCounts[questionIndex]++; + } else if (e.key === "Backspace" && newInput.value === "" && inputIndex > 0) { + e.preventDefault(); + removeInput(questionIndex, inputIndex); + } + }); + + // focus the new input + newInput.focus(); + }; + + // add event listeners to all initial inputs if dynamic_input_fields is enabled + if (trial.dynamic_input_fields) { + for (let i = 0; i < trial.questions.length; i++) { + const questionIndex = question_order[i]; + const initialInput = display_element.querySelector( + `#input-${questionIndex}-0` + ) as HTMLInputElement; + + initialInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + addNewInput(questionIndex); + inputFieldCounts[questionIndex]++; + } + }); + } + } + // backup in case autofocus doesn't work - display_element.querySelector("#input-" + question_order[0]).focus(); + display_element.querySelector("#input-" + question_order[0] + "-0").focus(); display_element.querySelector("#jspsych-survey-text-form").addEventListener("submit", (e) => { e.preventDefault(); @@ -227,18 +363,22 @@ class SurveyTextPlugin implements JsPsychPlugin { for (var index = 0; index < trial.questions.length; index++) { var id = "Q" + index; - var q_element = document - .querySelector("#jspsych-survey-text-" + index) - .querySelector("textarea, input") as HTMLInputElement; - var val = q_element.value; - var name = q_element.attributes["data-name"].value; - if (name == "") { - name = id; + var name = trial.questions[index].name || id; + + // collect all inputs for this question + var questionResponses = []; + for (let inputIndex = 0; inputIndex < inputFieldCounts[index]; inputIndex++) { + const inputElement = display_element.querySelector( + `#input-${index}-${inputIndex}` + ) as HTMLInputElement; + if (inputElement && inputElement.value.trim() !== "") { + questionResponses.push(inputElement.value); + } } - var obje = {}; - obje[name] = val; - Object.assign(question_data, obje); + + question_data[name] = questionResponses; } + // save data var trialdata = { rt: response_time, @@ -274,14 +414,23 @@ class SurveyTextPlugin implements JsPsychPlugin { for (const q of trial.questions) { const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`; - const ans_words = - q.rows == 1 - ? this.jsPsych.randomization.sampleExponential(0.25) - : this.jsPsych.randomization.randomInt(1, 10) * q.rows; - question_data[name] = this.jsPsych.randomization.randomWords({ - exactly: ans_words, - join: " ", - }); + const numInputs = this.jsPsych.randomization.randomInt(1, 4); // simulate 1-4 inputs per question + const responses = []; + + for (let i = 0; i < numInputs; i++) { + const ans_words = + q.rows == 1 + ? this.jsPsych.randomization.sampleExponential(0.25) + : this.jsPsych.randomization.randomInt(1, 10) * q.rows; + responses.push( + this.jsPsych.randomization.randomWords({ + exactly: ans_words, + join: " ", + }) + ); + } + + question_data[name] = responses; rt += this.jsPsych.randomization.sampleExGaussian(2000, 400, 0.004, true); } @@ -312,15 +461,16 @@ class SurveyTextPlugin implements JsPsychPlugin { this.trial(display_element, trial); load_callback(); - const answers = Object.entries(data.response).map((x) => { - return x[1] as string; - }); - for (let i = 0; i < answers.length; i++) { - this.jsPsych.pluginAPI.fillTextInput( - display_element.querySelector(`#input-${i}`), - answers[i], - ((data.rt - 1000) / answers.length) * (i + 1) - ); + // simulate typing in the first input of each question + for (let i = 0; i < trial.questions.length; i++) { + const responses = data.response[trial.questions[i].name || `Q${i}`] as string[]; + if (responses.length > 0) { + this.jsPsych.pluginAPI.fillTextInput( + display_element.querySelector(`#input-${i}-0`), + responses[0], + ((data.rt - 1000) / trial.questions.length) * (i + 1) + ); + } } this.jsPsych.pluginAPI.clickTarget( From c4d21c7344fdf6e339bebb57e5bd68695b735d01 Mon Sep 17 00:00:00 2001 From: cchang-vassar <79338042+cchang-vassar@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:14:53 -0700 Subject: [PATCH 4/4] delete plugin-survey-text-dynamic --- packages/plugin-survey-text-dynamic/demo.html | 78 --- .../jest.config.cjs | 1 - .../plugin-survey-text-dynamic/package.json | 43 -- .../rollup.config.mjs | 3 - .../src/index.spec.ts | 215 -------- .../plugin-survey-text-dynamic/src/index.ts | 476 ------------------ .../plugin-survey-text-dynamic/tsconfig.json | 7 - 7 files changed, 823 deletions(-) delete mode 100644 packages/plugin-survey-text-dynamic/demo.html delete mode 100644 packages/plugin-survey-text-dynamic/jest.config.cjs delete mode 100644 packages/plugin-survey-text-dynamic/package.json delete mode 100644 packages/plugin-survey-text-dynamic/rollup.config.mjs delete mode 100644 packages/plugin-survey-text-dynamic/src/index.spec.ts delete mode 100644 packages/plugin-survey-text-dynamic/src/index.ts delete mode 100644 packages/plugin-survey-text-dynamic/tsconfig.json diff --git a/packages/plugin-survey-text-dynamic/demo.html b/packages/plugin-survey-text-dynamic/demo.html deleted file mode 100644 index 5aaefa96ee..0000000000 --- a/packages/plugin-survey-text-dynamic/demo.html +++ /dev/null @@ -1,78 +0,0 @@ - - - - Survey Text Dynamic Plugin Demo - - - - - - - - - - diff --git a/packages/plugin-survey-text-dynamic/jest.config.cjs b/packages/plugin-survey-text-dynamic/jest.config.cjs deleted file mode 100644 index 6ac19d5cf3..0000000000 --- a/packages/plugin-survey-text-dynamic/jest.config.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/plugin-survey-text-dynamic/package.json b/packages/plugin-survey-text-dynamic/package.json deleted file mode 100644 index 21c54f6e43..0000000000 --- a/packages/plugin-survey-text-dynamic/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@jspsych/plugin-survey-text-dynamic", - "version": "1.0.0", - "description": "A jspsych plugin for free response survey questions with dynamic input field generation", - "type": "module", - "main": "dist/index.cjs", - "exports": { - "import": "./dist/index.js", - "require": "./dist/index.cjs" - }, - "typings": "dist/index.d.ts", - "unpkg": "dist/index.browser.min.js", - "files": [ - "src", - "dist" - ], - "source": "src/index.ts", - "scripts": { - "test": "jest", - "test:watch": "npm test -- --watch", - "tsc": "tsc", - "build": "rollup --config", - "build:watch": "npm run build -- --watch" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/jspsych/jsPsych.git", - "directory": "packages/plugin-survey-text-dynamic" - }, - "author": "Cherrie Chang", - "license": "MIT", - "bugs": { - "url": "https://github.com/jspsych/jsPsych/issues" - }, - "homepage": "https://www.jspsych.org/latest/plugins/survey-text-dynamic", - "peerDependencies": { - "jspsych": ">=7.1.0" - }, - "devDependencies": { - "@jspsych/config": "^3.2.0", - "@jspsych/test-utils": "^1.2.0" - } -} diff --git a/packages/plugin-survey-text-dynamic/rollup.config.mjs b/packages/plugin-survey-text-dynamic/rollup.config.mjs deleted file mode 100644 index 544ed9788d..0000000000 --- a/packages/plugin-survey-text-dynamic/rollup.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { makeRollupConfig } from "@jspsych/config/rollup"; - -export default makeRollupConfig("jsPsychSurveyTextDynamic"); diff --git a/packages/plugin-survey-text-dynamic/src/index.spec.ts b/packages/plugin-survey-text-dynamic/src/index.spec.ts deleted file mode 100644 index 04f227744d..0000000000 --- a/packages/plugin-survey-text-dynamic/src/index.spec.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { clickTarget, startTimeline } from "@jspsych/test-utils"; - -import surveyTextDynamic from "."; - -jest.useFakeTimers(); - -describe("survey-text-dynamic plugin", () => { - test("loads", async () => { - const { jsPsych } = await startTimeline([ - { - type: surveyTextDynamic, - questions: [ - { - prompt: "What is your name?", - name: "name", - }, - ], - }, - ]); - - expect( - jsPsych.getDisplayElement().querySelector("#jspsych-survey-text-dynamic-form") - ).not.toBeNull(); - }); - - test("data are logged with the right question names", async () => { - const { jsPsych, expectFinished } = await startTimeline([ - { - type: surveyTextDynamic, - questions: [ - { - prompt: "What is your name?", - name: "name", - }, - { - prompt: "What is your age?", - name: "age", - }, - ], - }, - ]); - - const name_input = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; - const age_input = jsPsych.getDisplayElement().querySelector("#input-1-0") as HTMLInputElement; - - name_input.value = "John"; - age_input.value = "25"; - - await clickTarget( - jsPsych.getDisplayElement().querySelector("#jspsych-survey-text-dynamic-next") - ); - - await expectFinished(); - - const data = jsPsych.data.get().values()[0]; - expect(data.response.name).toEqual(["John"]); - expect(data.response.age).toEqual(["25"]); - }); - - test("creates new input when Enter is pressed", async () => { - const { jsPsych } = await startTimeline([ - { - type: surveyTextDynamic, - questions: [ - { - prompt: "List your hobbies:", - name: "hobbies", - }, - ], - }, - ]); - - const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; - firstInput.value = "Reading"; - - // Simulate Enter key press - const enterEvent = new KeyboardEvent("keydown", { key: "Enter" }); - firstInput.dispatchEvent(enterEvent); - - jest.runAllTimers(); - - // Check that a second input was created - const secondInput = jsPsych.getDisplayElement().querySelector("#input-0-1") as HTMLInputElement; - expect(secondInput).not.toBeNull(); - }); - - test("creates new input when spacebar is pressed", async () => { - const { jsPsych } = await startTimeline([ - { - type: surveyTextDynamic, - questions: [ - { - prompt: "List your hobbies:", - name: "hobbies", - }, - ], - }, - ]); - - const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; - firstInput.value = "Reading"; - - // Simulate Enter key press - const enterEvent = new KeyboardEvent("keydown", { key: " " }); - firstInput.dispatchEvent(enterEvent); - - jest.runAllTimers(); - - // Check that a second input was created - const secondInput = jsPsych.getDisplayElement().querySelector("#input-0-1") as HTMLInputElement; - expect(secondInput).not.toBeNull(); - }); - - test("collects data from multiple dynamic inputs", async () => { - const { jsPsych, expectFinished } = await startTimeline([ - { - type: surveyTextDynamic, - questions: [ - { - prompt: "List your hobbies:", - name: "hobbies", - }, - ], - }, - ]); - - const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; - firstInput.value = "Reading"; - - // Create second input - const enterEvent = new KeyboardEvent("keydown", { key: "Enter" }); - firstInput.dispatchEvent(enterEvent); - - jest.runAllTimers(); - - const secondInput = jsPsych.getDisplayElement().querySelector("#input-0-1") as HTMLInputElement; - secondInput.value = "Swimming"; - - await clickTarget( - jsPsych.getDisplayElement().querySelector("#jspsych-survey-text-dynamic-next") - ); - - await expectFinished(); - - const data = jsPsych.data.get().values()[0]; - expect(data.response.hobbies).toEqual(["Reading", "Swimming"]); - }); - - test("deletes empty input field when backspace is pressed (except first input)", async () => { - const { jsPsych } = await startTimeline([ - { - type: surveyTextDynamic, - questions: [ - { - prompt: "List your hobbies:", - name: "hobbies", - }, - ], - }, - ]); - - const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; - firstInput.value = "Reading"; - - // Create second input - const enterEvent = new KeyboardEvent("keydown", { key: "Enter" }); - firstInput.dispatchEvent(enterEvent); - - jest.runAllTimers(); - - // Verify second input was created - const secondInput = jsPsych.getDisplayElement().querySelector("#input-0-1") as HTMLInputElement; - expect(secondInput).not.toBeNull(); - - // Press backspace on empty second input - const backspaceEvent = new KeyboardEvent("keydown", { key: "Backspace" }); - secondInput.dispatchEvent(backspaceEvent); - - jest.runAllTimers(); - - // Check that second input was removed - const deletedInput = jsPsych.getDisplayElement().querySelector("#input-0-1"); - expect(deletedInput).toBeNull(); - - // First input should still exist - const remainingFirstInput = jsPsych.getDisplayElement().querySelector("#input-0-0"); - expect(remainingFirstInput).not.toBeNull(); - }); - - test("does not delete first input field when backspace is pressed", async () => { - const { jsPsych } = await startTimeline([ - { - type: surveyTextDynamic, - questions: [ - { - prompt: "List your hobbies:", - name: "hobbies", - }, - ], - }, - ]); - - const firstInput = jsPsych.getDisplayElement().querySelector("#input-0-0") as HTMLInputElement; - - // Press backspace on empty first input - const backspaceEvent = new KeyboardEvent("keydown", { key: "Backspace" }); - firstInput.dispatchEvent(backspaceEvent); - - jest.runAllTimers(); - - // First input should still exist - const remainingFirstInput = jsPsych.getDisplayElement().querySelector("#input-0-0"); - expect(remainingFirstInput).not.toBeNull(); - }); -}); diff --git a/packages/plugin-survey-text-dynamic/src/index.ts b/packages/plugin-survey-text-dynamic/src/index.ts deleted file mode 100644 index 5dd94deff8..0000000000 --- a/packages/plugin-survey-text-dynamic/src/index.ts +++ /dev/null @@ -1,476 +0,0 @@ -import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; - -import { version } from "../package.json"; - -const info = { - name: "survey-text-dynamic", - version: version, - parameters: { - /** - * An array of objects, each object represents a question that appears on the screen. Each object contains a prompt, - * options, required, and horizontal parameter that will be applied to the question. See examples below for further - * clarification.`prompt`: Type string, default value is *undefined*. The string is prompt/question that will be - * associated with a group of options (radio buttons). All questions will get presented on the same page (trial). - * `options`: Type array, defualt value is *undefined*. An array of strings. The array contains a set of options to - * display for an individual question.`required`: Type boolean, default value is null. The boolean value indicates - * if a question is required('true') or not ('false'), using the HTML5 `required` attribute. If this parameter is - * undefined, the question will be optional. `horizontal`:Type boolean, default value is false. If true, then the - * question is centered and the options are displayed horizontally. `name`: Name of the question. Used for storing - * data. If left undefined then default names (`Q0`, `Q1`, `...`) will be used for the questions. - */ - questions: { - type: ParameterType.COMPLEX, - array: true, - default: undefined, - nested: { - /** Question prompt. */ - prompt: { - type: ParameterType.HTML_STRING, - default: undefined, - }, - /** Placeholder text in the response text box. */ - placeholder: { - type: ParameterType.STRING, - default: "", - }, - /** The number of rows for the response text box. */ - rows: { - type: ParameterType.INT, - default: 1, - }, - /** The number of columns for the response text box. */ - columns: { - type: ParameterType.INT, - default: 40, - }, - /** Whether or not a response to this question must be given in order to continue. */ - required: { - type: ParameterType.BOOL, - default: false, - }, - /** Name of the question in the trial data. If no name is given, the questions are named Q0, Q1, etc. */ - name: { - type: ParameterType.STRING, - default: "", - }, - }, - }, - /** - * If true, the display order of `questions` is randomly determined at the start of the trial. In the data - * object, `Q0` will still refer to the first question in the array, regardless of where it was presented - * visually. - */ - randomize_question_order: { - type: ParameterType.BOOL, - default: false, - }, - /** HTML formatted string to display at the top of the page above all the questions. */ - preamble: { - type: ParameterType.HTML_STRING, - default: null, - }, - /** Label of the button to submit responses. */ - button_label: { - type: ParameterType.STRING, - default: "Continue", - }, - /** Setting this to true will enable browser auto-complete or auto-fill for the form. */ - autocomplete: { - type: ParameterType.BOOL, - default: false, - }, - }, - data: { - /** An object containing the response for each question. The object will have a separate key (variable) for each question, with the first question in the trial being recorded in `Q0`, the second in `Q1`, and so on. The responses are recorded as integers, representing the position selected on the likert scale for that question. If the `name` parameter is defined for the question, then the response object will use the value of `name` as the key for each question. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. */ - response: { - type: ParameterType.OBJECT, - }, - /** The response time in milliseconds for the participant to make a response. The time is measured from when the questions first appear on the screen until the participant's response(s) are submitted. */ - rt: { - type: ParameterType.INT, - }, - /** An array with the order of questions. For example `[2,0,1]` would indicate that the first question was `trial.questions[2]` (the third item in the `questions` parameter), the second question was `trial.questions[0]`, and the final question was `trial.questions[1]`. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. */ - question_order: { - type: ParameterType.INT, - array: true, - }, - }, - // prettier-ignore - citations: '__CITATIONS__', -}; - -type Info = typeof info; - -/** - * - * The survey-text-dynamic plugin displays a set of questions with free response text fields that the participant can type answers into. A new input field for the same question is generated when Enter is pressed. - * - * @author Cherrie Chang - * @see {@link https://www.jspsych.org/latest/plugins/survey-text-dynamic/ survey-text-dynamic plugin documentation on jspsych.org} - */ -class SurveyTextDynamicPlugin implements JsPsychPlugin { - static info = info; - - constructor(private jsPsych: JsPsych) {} - - trial(display_element: HTMLElement, trial: TrialType) { - // tracks how many input fields were generated for each question - const inputFieldCounts = new Array(trial.questions.length).fill(1); - - for (var i = 0; i < trial.questions.length; i++) { - if (typeof trial.questions[i].rows == "undefined") { - trial.questions[i].rows = 1; - } - } - for (var i = 0; i < trial.questions.length; i++) { - if (typeof trial.questions[i].columns == "undefined") { - trial.questions[i].columns = 40; - } - } - for (var i = 0; i < trial.questions.length; i++) { - if (typeof trial.questions[i].value == "undefined") { - trial.questions[i].value = ""; - } - } - - var html = ""; - // show preamble text - if (trial.preamble !== null) { - html += - '
' + - trial.preamble + - "
"; - } - // start form - if (trial.autocomplete) { - html += '
'; - } else { - html += ''; - } - // generate question order - var question_order = []; - for (var i = 0; i < trial.questions.length; i++) { - question_order.push(i); - } - if (trial.randomize_question_order) { - question_order = this.jsPsych.randomization.shuffle(question_order); - } - - // add questions - for (var i = 0; i < trial.questions.length; i++) { - var question = trial.questions[question_order[i]]; - var question_index = question_order[i]; - html += - // question - '
'; - html += '

' + question.prompt + "

"; - - // container for dynamic inputs - html += - '
'; - - var autofocus = i == 0 ? "autofocus" : ""; - var req = question.required ? "required" : ""; - if (question.rows == 1) { - html += - ''; - } else { - html += - ''; - } - - html += "
"; // Close the dynamic inputs container - html += "
"; - } - - // add submit button - html += - ''; - - html += "
"; - display_element.innerHTML = html; - - // remove an input field when user presses backspace on empty field - const removeInput = (questionIndex: number, inputIndex: number) => { - if (inputIndex === 0) return; // don't remove the first input field - - const inputToRemove = display_element.querySelector(`#input-${questionIndex}-${inputIndex}`); - if (inputToRemove) { - inputToRemove.remove(); - inputFieldCounts[questionIndex]--; - - // Focus the previous input field - const previousInputIndex = inputIndex - 1; - const previousInput = display_element.querySelector( - `#input-${questionIndex}-${previousInputIndex}` - ) as HTMLInputElement; - if (previousInput) { - previousInput.focus(); - } - } - }; - - // add new input fields to a question when user presses Enter or spacebar - const addNewInput = (questionIndex: number) => { - const container = display_element.querySelector(`#dynamic-inputs-container-${questionIndex}`); - const question = trial.questions[questionIndex]; - const inputIndex = inputFieldCounts[questionIndex]; - - let newInputHtml = ""; - if (question.rows == 1) { - newInputHtml = - ''; - } else { - newInputHtml = - ''; - } - - container.insertAdjacentHTML("beforeend", newInputHtml); - const newInput = container.querySelector( - `#input-${questionIndex}-${inputIndex}` - ) as HTMLInputElement; - - // add event listener for the new input - newInput.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - addNewInput(questionIndex); - inputFieldCounts[questionIndex]++; - } else if (e.key === "Backspace" && newInput.value === "" && inputIndex > 0) { - e.preventDefault(); - removeInput(questionIndex, inputIndex); - } - }); - - // focus the new input - newInput.focus(); - }; - - // add event listeners to all initial inputs - for (let i = 0; i < trial.questions.length; i++) { - const questionIndex = question_order[i]; - const initialInput = display_element.querySelector( - `#input-${questionIndex}-0` - ) as HTMLInputElement; - - initialInput.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - addNewInput(questionIndex); - inputFieldCounts[questionIndex]++; - } - }); - } - - // backup in case autofocus doesn't work - display_element.querySelector("#input-" + question_order[0] + "-0").focus(); - - display_element - .querySelector("#jspsych-survey-text-dynamic-form") - .addEventListener("submit", (e) => { - e.preventDefault(); - // measure response time - var endTime = performance.now(); - var response_time = Math.round(endTime - startTime); - - // create object to hold responses - var question_data = {}; - - for (var index = 0; index < trial.questions.length; index++) { - var id = "Q" + index; - var name = trial.questions[index].name || id; - - // collect all inputs for this question - var questionResponses = []; - for (let inputIndex = 0; inputIndex < inputFieldCounts[index]; inputIndex++) { - const inputElement = display_element.querySelector( - `#input-${index}-${inputIndex}` - ) as HTMLInputElement; - if (inputElement && inputElement.value.trim() !== "") { - questionResponses.push(inputElement.value); - } - } - - question_data[name] = questionResponses; - } - - // save data - var trialdata = { - rt: response_time, - response: question_data, - question_order: question_order, - }; - - // next trial - this.jsPsych.finishTrial(trialdata); - }); - - var startTime = performance.now(); - } - - simulate( - trial: TrialType, - simulation_mode, - simulation_options: any, - load_callback: () => void - ) { - if (simulation_mode == "data-only") { - load_callback(); - this.simulate_data_only(trial, simulation_options); - } - if (simulation_mode == "visual") { - this.simulate_visual(trial, simulation_options, load_callback); - } - } - - private create_simulation_data(trial: TrialType, simulation_options) { - const question_data = {}; - let rt = 1000; - - for (const q of trial.questions) { - const name = q.name ? q.name : `Q${trial.questions.indexOf(q)}`; - const numInputs = this.jsPsych.randomization.randomInt(1, 4); // simulate 1-4 inputs per question - const responses = []; - - for (let i = 0; i < numInputs; i++) { - const ans_words = - q.rows == 1 - ? this.jsPsych.randomization.sampleExponential(0.25) - : this.jsPsych.randomization.randomInt(1, 10) * q.rows; - responses.push( - this.jsPsych.randomization.randomWords({ - exactly: ans_words, - join: " ", - }) - ); - } - - question_data[name] = responses; - rt += this.jsPsych.randomization.sampleExGaussian(2000, 400, 0.004, true); - } - - const default_data = { - response: question_data, - rt: rt, - question_order: trial.questions.map((_, i) => i), - }; - - const data = this.jsPsych.pluginAPI.mergeSimulationData(default_data, simulation_options); - - this.jsPsych.pluginAPI.ensureSimulationDataConsistency(trial, data); - - return data; - } - - private simulate_data_only(trial: TrialType, simulation_options) { - const data = this.create_simulation_data(trial, simulation_options); - - this.jsPsych.finishTrial(data); - } - - private simulate_visual(trial: TrialType, simulation_options, load_callback: () => void) { - const data = this.create_simulation_data(trial, simulation_options); - - const display_element = this.jsPsych.getDisplayElement(); - - this.trial(display_element, trial); - load_callback(); - - // simulate typing in the first input of each question - for (let i = 0; i < trial.questions.length; i++) { - const responses = data.response[trial.questions[i].name || `Q${i}`] as string[]; - if (responses.length > 0) { - this.jsPsych.pluginAPI.fillTextInput( - display_element.querySelector(`#input-${i}-0`), - responses[0], - ((data.rt - 1000) / trial.questions.length) * (i + 1) - ); - } - } - - this.jsPsych.pluginAPI.clickTarget( - display_element.querySelector("#jspsych-survey-text-dynamic-next"), - data.rt - ); - } -} - -export default SurveyTextDynamicPlugin; diff --git a/packages/plugin-survey-text-dynamic/tsconfig.json b/packages/plugin-survey-text-dynamic/tsconfig.json deleted file mode 100644 index 588f044808..0000000000 --- a/packages/plugin-survey-text-dynamic/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "@jspsych/config/tsconfig.core.json", - "compilerOptions": { - "baseUrl": "." - }, - "include": ["src"] -}