UNPKG

jspsych-attention-check

Version:

A jsPsych plugin for adding multiple-choice attention check questions to an experiment timeline.

500 lines (483 loc) 23.9 kB
var $iA2ta$reactjsxruntime = require("react/jsx-runtime"); var $iA2ta$react = require("react"); var $iA2ta$reactdomclient = require("react-dom/client"); var $iA2ta$grommet = require("grommet"); var $iA2ta$grommeticons = require("grommet-icons"); var $iA2ta$usehooksts = require("usehooks-ts"); /** * jspsych-attention-check * * @description A jsPsych plugin for adding multiple-choice attention check * questions to an experiment timeline. * * This plugin is NOT compatible with jsPsych versions 7.0+. * * @author Henry Burgess <henry.burgess@wustl.edu> * */ /** * Default class to support the plugin. */ class $43e9903c36f83d7f$var$Runner { // private root: /** * Default constructor * @param {HTMLElement} displayElement target element for jsPsych display * @param {any} trial jsPsych trial data */ constructor(displayElement, trial){ // Copy and store the plugin configuration this.displayElement = displayElement; this.trial = trial; this.root = $iA2ta$reactdomclient.createRoot(this.displayElement); } /** * Validate the configuration passed to the plugin * @return {boolean} */ validate() { if (this.trial.responses.length === 0) { console.error(new Error('Invalid "responses" value specified. Ensure at least one response is provided.')); return false; } if (this.trial.correct < 0 || this.trial.correct >= this.trial.responses.length) { console.error(new Error('Invalid "correct" index specified. Ensure value is >= 0 and corresponds to a value in the "responses" array.')); return false; } let keyCount = 0; for (const key of Object.keys(this.trial.input_schema))if (typeof this.trial.input_schema[key] === "string") keyCount += 1; if (keyCount > 0 && keyCount < 3) console.warn('Some keys are not assigned correctly in "input_schema".'); if (this.trial.style === "default" && keyCount !== 0) { console.warn(new Error(`Do not specify keys for the "default" style.`)); return false; } return true; } /** * Render method for the plugin * @param {React.ReactNode} content React content to render */ render(content) { this.root.render(content); } /** * End the trial, unmount the React component then submit data to jsPsych * @param {{ attentionSelection: string, attentionCorrect: boolean, attentionRT: number }} data collected response data */ endTrial(data) { this.root.unmount(); jsPsych.finishTrial(data); } } var $43e9903c36f83d7f$export$2e2bcd8739ae039 = $43e9903c36f83d7f$var$Runner; // React and Grommet // Theme object const $67d8bd73256b8650$export$14faa19a0f3bbeb2 = { // Global colours global: { colors: { brand: { dark: "#CBF3F0", light: "#2EC4B6" }, border: { dark: "#444444", light: "#CCCCCC" } }, focus: { border: { color: "#FFFFFF" } } }, // 'Select' input component select: { options: { text: { size: "large" } } }, // 'RadioButtonGroup' component radioButtonGroup: { container: { gap: "medium", width: "large" } }, // 'RadioButton' input component radioButton: { border: { color: "dark-3", width: "5px" }, hover: { border: { color: "dark-2" } }, size: "30px" } }; /** * Construct and return the 'View' component * @param {ViewProps} props component props * @return {ReactElement} */ const $03c2fa2f707d4729$var$View = (props)=>{ // Participant selection and correctness const [selection, setSelection] = (0, $iA2ta$react.useState)(""); const [selectionCorrect, setSelectionCorrect] = (0, $iA2ta$react.useState)(false); const [selectedResponseIndex, setSelectedResponseIndex] = (0, $iA2ta$react.useState)(0); // Label of the continue button underneath the responses const [buttonLabel, setButtonLabel] = (0, $iA2ta$react.useState)("Continue"); // Enable or disable input depending on timeout conditions const [timeoutExpired, setTimeoutExpired] = (0, $iA2ta$react.useState)(false); // Toggle confirmation text and feedback layer visibility const [showConfirmation, setShowConfirmation] = (0, $iA2ta$react.useState)(false); const [showFeedback, setShowFeedback] = (0, $iA2ta$react.useState)(false); // Selection state if using alternate input scheme const [selectedElementIndex, setSelectedElementIndex] = (0, $iA2ta$react.useState)(0); const [elementFocused, setElementFocused] = (0, $iA2ta$react.useState)(false); const useAlternateInput = props.input_schema.select !== null && props.input_schema.next !== null && props.input_schema.previous !== null; /** * Either prompt confirmation or check the response depending on * the 'continue' configuration * @return {void} */ const continueTrial = ()=>{ // Show the confirmation message if it enabled and has not been shown if (props.confirm_continue === true && showConfirmation === false) { setButtonLabel("Confirm?"); setShowConfirmation(true); return; } checkResponse(selection); setShowFeedback(true); }; /** * Validate the status of the response * @param {string} finalSelection the selection made by the participant */ const checkResponse = (finalSelection)=>{ setSelectionCorrect(props.responses.indexOf(finalSelection) === props.correct); }; /** * End the trial, calling the callback function */ const endTrial = ()=>{ // Call the callback function props.callback({ attentionSelection: selection, attentionCorrect: selectionCorrect, attentionRT: performance.now() - startTime }); }; /** * Generic keyboard handler, filters input and progresses trial * as required * @param {React.KeyboardEvent<HTMLElement>} event the keyboard event to be processed * @return {void} */ const keyboardHandler = (event)=>{ // Ignore all input if the timeout has not expired if (timeoutExpired === false) return; // Ignore all input if keyboard input is not enabled if (props.style === "default" || props.input_schema === null || event.repeat) return; const key = event.key.toString(); if ((key === props.input_schema.next || key === props.input_schema.previous) && showFeedback === false) { // Accept `next` and `previous` input until the feedback screen is shown if (elementFocused === false) // Elements not yet focused, moving between UI elements setSelectedElementIndex(selectedElementIndex === 0 ? 1 : 0); else if (selectedElementIndex === 0) { // Option within radio buttons let updatedSelectedResponseIndex = selectedResponseIndex; if (selection !== "" && key === props.input_schema.next) updatedSelectedResponseIndex = selectedResponseIndex + 1 < props.responses.length ? selectedResponseIndex + 1 : props.responses.length - 1; else if (selection !== "" && key === props.input_schema.previous) updatedSelectedResponseIndex = selectedResponseIndex - 1 >= 0 ? selectedResponseIndex - 1 : 0; else updatedSelectedResponseIndex = 0; // Update selection state setSelectedResponseIndex(updatedSelectedResponseIndex); setSelection(props.responses[updatedSelectedResponseIndex]); } } else if (key === props.input_schema.select) { if (selectedElementIndex === 1 && !showFeedback) // Continue button { if (selection !== "" && timeoutExpired) continueTrial(); } else if (showFeedback === true) // End the trial if presenting the feedback when this button is pressed endTrial(); else // Flip the element focused status setElementFocused(!elementFocused); } }; // Set a timeout to block input (0, $iA2ta$usehooksts.useTimeout)(()=>setTimeoutExpired(true), props.input_timeout); // Record start time const startTime = performance.now(); // Return component return /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Grommet), { theme: (0, $67d8bd73256b8650$export$14faa19a0f3bbeb2), children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Keyboard), { onKeyDown: keyboardHandler, target: "document", children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsxs)((0, $iA2ta$grommet.Box), { direction: "column", align: "center", justify: "center", gap: "small", fill: true, children: [ /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Heading), { size: "small", level: "2", children: props.prompt }), /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { pad: "medium", margin: { top: "xsmall", bottom: "large" }, align: "center", justify: "center", fill: "horizontal", border: { color: useAlternateInput && selectedElementIndex === 0 && !elementFocused ? "lightgray" : "transparent", size: "large" }, round: true, children: props.style === "default" ? // 'Select' component /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { width: { max: "md" }, fill: true, children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Select), { name: "responses", options: props.responses.map((r)=>{ return r; }), size: "medium", width: "large", value: selection, onChange: ({ option: option })=>setSelection(option), placeholder: "Select", disabled: !timeoutExpired }) }) : // 'RadioButtonGroup' component with radio selectors visible /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.RadioButtonGroup), { name: "responses", width: { min: "small", max: "2xl" }, options: props.responses.map((r)=>{ return { id: r, label: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { direction: "row", justify: "center", align: "center", gap: "medium", animation: "fadeIn", children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Text), { size: "medium", children: r }) }), value: r }; }), value: selection, onChange: (event)=>{ // Update selection only if input is enabled if (timeoutExpired) setSelection(event.target.value); }, disabled: !timeoutExpired }) }), /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { direction: "row", gap: "medium", pad: "none", border: { color: useAlternateInput && !showFeedback && selectedElementIndex === 1 ? "lightgray" : "transparent", size: "large" }, style: { borderRadius: "32px" }, round: true, children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Button), { label: buttonLabel, disabled: selection === "" || !timeoutExpired, onClick: ()=>continueTrial(), icon: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommeticons.Next), {}), color: showConfirmation === false ? "brand" : "status-warning", primary: true, reverse: true }) }), showFeedback && /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Layer), { children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsxs)((0, $iA2ta$grommet.Box), { direction: "column", pad: "medium", gap: "medium", align: "center", fill: true, children: [ /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Heading), { level: "2", margin: "small", color: selectionCorrect === true ? "green" : "red", children: selectionCorrect === true ? "Correct!" : "Incorrect." }) }), /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Text), { size: "large", children: props.prompt }) }), /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsxs)((0, $iA2ta$grommet.Box), { align: "left", gap: "small", fill: true, children: [ /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsxs)((0, $iA2ta$grommet.Text), { size: "large", children: [ /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)("strong", { children: "You selected:" }), ' "', selection, '"' ] }) }), selectionCorrect === false ? /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsxs)((0, $iA2ta$grommet.Text), { size: "large", children: [ /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)("strong", { children: "Correct response:" }), ' "', props.responses[props.correct], '"' ] }) }) : null, /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsxs)((0, $iA2ta$grommet.Text), { size: "large", children: [ /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)("strong", { children: "Feedback:" }), " ", selectionCorrect === true ? props.feedback.correct : props.feedback.incorrect ] }) }) ] }), /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Box), { direction: "row", gap: "medium", pad: "none", border: { color: useAlternateInput ? "lightgray" : "transparent", size: "large" }, style: { borderRadius: "32px" }, round: true, children: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommet.Button), { label: "Continue", disabled: selection === "", onClick: ()=>endTrial(), icon: /*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $iA2ta$grommeticons.Next), {}), color: "brand", primary: true, reverse: true }) }) ] }) }) ] }) }) }); }; var $03c2fa2f707d4729$export$2e2bcd8739ae039 = $03c2fa2f707d4729$var$View; // Instantiate the plugin function jsPsych.plugins["attention-check"] = function() { const plugin = { info: {}, trial: (displayElement, trial)=>{} }; plugin.info = { name: "attention-check", parameters: { prompt: { type: jsPsych.plugins.parameterType.STRING, pretty_name: "Text prompt", default: undefined, description: "The prompt to be presented to the participant." }, style: { type: jsPsych.plugins.parameterType.STRING, pretty_name: "Alternate display for options", default: "radio", description: "Change the options to display as a series of radio options instead of a drop-down." }, responses: { type: jsPsych.plugins.parameterType.COMPLEX, pretty_name: "List of responses to the prompt", default: undefined, description: "A list of responses that the participant can select as their answer to the attention-check prompt." }, correct: { type: jsPsych.plugins.parameterType.INT, pretty_name: "Index of correct response from the `responses` array", default: 0, description: "The index of the correct response, as the response is located the `responses` array." }, feedback: { type: jsPsych.plugins.parameterType.COMPLEX, pretty_name: "Set the feedback messages", default: undefined, description: "Describe feedback to the participant in the case of correct and incorrect responses." }, input_timeout: { type: jsPsych.plugins.parameterType.INT, pretty_name: "Timeout before input permitted", default: 0, description: "Force the participant to wait for a duration before intput is accepted." }, input_schema: { type: jsPsych.plugins.parameterType.COMPLEX, pretty_name: "Input schema", default: { select: null, next: null, previous: null }, description: "Specify the keyboard inputs used to interact with the attention check questions." }, confirm_continue: { type: jsPsych.plugins.parameterType.BOOL, pretty_name: "Require confirmation before continuing", default: false, description: "Require the user to confirm their response before continuing with the task." } } }; plugin.trial = (displayElement, trial)=>{ // Instantiate the 'Runner' class for this plugin const runner = new (0, $43e9903c36f83d7f$export$2e2bcd8739ae039)(displayElement, trial); // If validation of the provided parameters passes, render the screen if (runner.validate() === true) runner.render(/*#__PURE__*/ (0, $iA2ta$reactjsxruntime.jsx)((0, $03c2fa2f707d4729$export$2e2bcd8739ae039), { ...trial, callback: runner.endTrial.bind(runner) })); }; return plugin; }(); //# sourceMappingURL=main.js.map