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
JavaScript
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