diff --git a/.changeset/new-shrimps-smoke.md b/.changeset/new-shrimps-smoke.md new file mode 100644 index 00000000..a8929461 --- /dev/null +++ b/.changeset/new-shrimps-smoke.md @@ -0,0 +1,5 @@ +--- +"@jspsych-contrib/plugin-html-keyboard-slider": major +--- + +Add HTML Keyboard Slider diff --git a/README.md b/README.md index 27811182..9c1247cf 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Plugin/Extension | Contributor | Description [gamepad](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-gamepad/README.md) | [Shaobin Jiang](https://github.com/Shaobin-Jiang) | This plugin allows one to use gamepads in a jsPsych experiment. [html-choice](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-choice/README.md) | [Younes Strittmatter](https://github.com/younesStrittmatter) | This plugin displays clickable html elements that can be used to present a choice. [html-keyboard-response-raf](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-keyboard-response-raf/README.md) | [Josh de Leeuw](https://github.com/jodeleeuw) | This plugin displays an arbitrary HTML string and collects responses using the keyboard. It uses requestAnimationFrame for timing. +[html-keyboard-slider](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-keyboard-slider/docs/jspsych-html-keyboard-slider.md) | [Max Lovell](https://github.com/Max-Lovell) | Sliders which allow for keyboard responses. [html-multi-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-multi-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an arbitrary HTML string using both button clicks and key presses. [html-swipe-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-swipe-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an arbitrary HTML string using swipe gestures and keyboard responses. [html-vas-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-vas-response/README.md) | [Isaac Kinley](https://github.com/kinleyid) | This plugin collects responses to an arbitrary HTML string using a point-and-click visual analogue scale. @@ -42,6 +43,7 @@ Plugin/Extension | Contributor | Description [rok](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-rok/docs/jspsych-rok.md#jspsych-rok-plugin) | [Younes Strittmatter](https://github.com/younesStrittmatter) | This plugin displays a Random Object Kinematogram (ROK) and allows the subject to report the primary direction of motion or the primary orientation by pressing a key on the keyboard. [self-paced-reading](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-self-paced-reading/docs/jspsych-self-paced-reading.md) | [@igmmgi](https://github.com/igmmgi) | Self-paced reading tasks with different display options. [survey-number](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-survey-number/README.md) | [Josh de Leeuw](https://github.com/jodeleeuw) | This plugin displays a survey question and collects a numeric response. +[survey-slider](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-survey-slider/README.md) | [Max Lovell](https://github.com/Max-Lovell) & [Dominique Makowski](https://github.com/DominiqueMakowski) | Add several analogue scales on the same page for use in questionnaires. [vsl-animate-occlusion](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-vsl-animate-occlusion/docs/jspsych-vsl-animate-occlusion.md#jspsych-vsl-animate-occlusion-plugin) | [Josh de Leeuw](https://github.com/jodeleeuw) | The VSL (visual statistical learning) animate occlusion plugin displays an animated sequence of shapes that disappear behind an occluding rectangle while they change from one shape to another. [vsl-grid-scene](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-vsl-grid-scene/docs/jspsych-vsl-grid-scene.md#jspsych-vsl-grid-scene-plugin) | [Josh de Leeuw](https://github.com/jodeleeuw) | The VSL (visual statistical learning) grid scene plugin displays images arranged in a grid. diff --git a/package-lock.json b/package-lock.json index 09b483e5..629f0b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3139,6 +3139,10 @@ "resolved": "packages/plugin-html-keyboard-response-raf", "link": true }, + "node_modules/@jspsych-contrib/plugin-html-keyboard-slider": { + "resolved": "packages/plugin-html-keyboard-slider", + "link": true + }, "node_modules/@jspsych-contrib/plugin-html-multi-response": { "resolved": "packages/plugin-html-multi-response", "link": true @@ -17815,6 +17819,19 @@ "jspsych": ">=7.0.0" } }, + "packages/plugin-html-keyboard-slider": { + "name": "@jspsych-contrib/plugin-html-keyboard-slider", + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "@jspsych/config": "^2.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + }, + "peerDependencies": { + "jspsych": ">=7.0.0" + } + }, "packages/plugin-html-multi-response": { "name": "@jspsych-contrib/plugin-html-multi-response", "version": "1.0.2", diff --git a/packages/plugin-html-keyboard-slider/README.md b/packages/plugin-html-keyboard-slider/README.md new file mode 100644 index 00000000..293d2f0d --- /dev/null +++ b/packages/plugin-html-keyboard-slider/README.md @@ -0,0 +1,35 @@ +# html-keyboard-slider + +## Overview + +HTML slider which allows for keyboard responses + +## Loading + +### In browser + +```js + +``` + +### Via NPM + +``` +npm install @jspsych-contrib/plugin-html-keyboard-slider +``` + +```js +import jsPsychHtmlKeyboardSlider from '@jspsych-contrib/plugin-html-keyboard-slider'; +``` + +## Compatibility + +jsPsych 7.0.0 + +## Documentation + +See [documentation](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-keyboard-slider/docs/jspsych-html-keyboard-slider.md) + +## Author / Citation + +Max Lovell diff --git a/packages/plugin-html-keyboard-slider/docs/html-keyboard-slider.md b/packages/plugin-html-keyboard-slider/docs/html-keyboard-slider.md new file mode 100644 index 00000000..36f047ee --- /dev/null +++ b/packages/plugin-html-keyboard-slider/docs/html-keyboard-slider.md @@ -0,0 +1,198 @@ +# html-keyboard-slider + +HTML slider which allows for keyboard responses, with a few extra parameters. + +## Parameters + +In addition to the [parameters available in all plugins](https://jspsych.org/latest/overview/plugins.md#parameters-available-in-all-plugins), this plugin accepts the following parameters. Parameters with a default value of undefined must be specified. Other parameters can be left unspecified if the default value is acceptable. +| Parameter | Type | Default Value | Description | +| ------------------- | ---------------- | ------------------ | ---------------------------------------- | +| min | INT | 0 | Slider minimum value. Can be an integer or a float. | +| max | INT | 10 | Slider maximum value. Can be an integer or a float. | +| step | INT | 1 | Minimum increase in value for the slider. | +| step_any | BOOL | false | For a more coninuous slider, set HTML Range input's step attribute to 'any', see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range#examples. Step values above still apply to 'increase_keys' and 'decrease_keys'. | +| slider_start | INT | null | Starting value of the slider. Defaults to minimum value. | +| slider_width | INT | null | Width of the slider in pixels. Defaults to 100% of the container. | +| minimum_keys | KEYS | ["`", "§"] | Keys that set the slider to its minimum value. Note '..._keys' parameters can take either single string or array, can include numbers, and either empty [] or '' turns relevant functionality off. Also are case sensitive (e.g. ['a', 'A']). | +| maximum_keys | KEYS | ["="] | Keys that set the slider to its maximum value. | +| decrease_keys | KEYS | ["ArrowLeft", "ArrowDown"] | Keys that decrease the slider by one step. | +| increase_keys | KEYS | ["ArrowRight", "ArrowUp"] | Keys that increase the slider by one step. | +| number_keys | BOOL | true | Whether or not to listen to number keys. | +| keys_step | FLOAT | null | Amount the increase and decrease keys change the slider value. Defaults to step size. | +| input_multiplier | INT | 1 | Multiplies the input value after key buffer is accounted for (e.g. 1=10, 2=20 on 0-100% scale)| +| key_buffer_on | BOOL | false | Tracks key presses over a specified time to allow multiple button presses. Handles '-' and '.' if not set to _keys params above. | +| key_buffer_timeout | INT | 300 | Length of time consecutive key presses are held in memory (ms). | +| prompt | HTML_STRING | "" | Prompt displayed above the slider. | +| ticks | BOOL | true | Whether to display ticks under each value of the slider. These are also slightly 'sticky'. | +| ticks_interval | FLOAT | null | Interval at which to display ticks. Defaults to step size. | +| labels | HTML_STRING | null | Labels displayed equidistantly below the stimulus. Accepts HTML. | +| label_dividers | BOOL | true | Whether to display dividing lines between labels. | +| display_value | BOOL | true | Whether to display the current value of the slider below it. | +| unit_text | STRING | "" | Text displayed next to display value (e.g., %, cm). | +| prepend_unit | BOOL | false | Whether to prepend the unit text (e.g., £5). Default is to append (e.g., 5%). | +| stimulus | HTML_STRING | null | Stimulus to be displayed. Any HTML is valid. | +| stimulus_duration | INT | null | Duration of stimulus (ms). | +| trial_duration | INT | null | Duration of trial (ms). Response recorded as null if no response is made. | +| response_ends_trial | BOOL | false | Whether a response ends the trial. | +| require_movement | BOOL | false | Whether the slider must be interacted with to continue. | +| button_label | STRING | "Continue" | Label of the button displayed - Note button is also clicked by 'Enter' key | + +## Data Generated + +In addition to the [default data collected by all plugins](https://jspsych.org/latest/overview/plugins.md#data-collected-by-all-plugins), this plugin collects the following data for each trial. + +| Name | Type | Description | +| -------------- | -------- | -------------------------------------- | +| response | INT | Final value of the slider. Defaults to slider starting value if not interacted with, or null if trial_duration ends first. | +| rt | FLOAT | Reaction time in milliseconds. | +| stimulus | HTML_STRING | Stimulus presented. | +| slider_start | INT | Starting value of the slider. | + +## Install + +Using the CDN-hosted JavaScript file: + +```js + +``` + +Using the JavaScript file downloaded from a GitHub release dist archive: + +```js + +``` + +Using NPM: + +``` +npm install @jspsych-contrib/plugin-html-keyboard-slider +``` + +```js +import HtmlKeyboardSlider from '@jspsych-contrib/plugin-html-keyboard-slider'; +``` + +## Examples + +### Simple discreet/categorical slider + +Labelled discreet slider displaying defaults. Note pressing 'Enter' key also clicks the 'Continue' button. + +```javascript +var discreet = { + type: jsPsychHtmlKeyboardSlider, + min: 1, + max: 5, + prompt: "Rate your confidence in your response:", + labels: ["Pure guess", "More or less guessing", "Somewhat confident", "Almost sure", "Certain"], +} +``` + +### Percentage slider + +Percentage slider where pressing 1 goes to 10, unless you press 1 again within 300 ms, in which case goes to 11 + +```javascript +var percentage = { + type: jsPsychHtmlKeyboardSlider, + min: 0, + max: 100, + step: 1, + input_multiplier: 10, + slider_start: 50, + ticks: false, + key_buffer_on: true, + display_value: true, + unit_text: '%', + prompt: "Rate your confidence in your response: ", + //labels: ["Complete Guess", "Complete Certainty"], +} + +``` + +### Cost comparison + +This is more of a list of parameters to play around with + +```javascript +//https://www.freecodecamp.org/news/javascript-range-create-an-array-of-numbers-with-the-from-method/ +// function to create array of £ labels +var arrayRange = (start, stop, step) => + Array.from({ length: (stop - start) / step + 1 }, (value, index) => start + index * step +); + +var cost = { + type: jsPsychHtmlKeyboardSlider, + // Slider properties + min: -5, + max: 5, + step: 0.01, + step_any: false, // Generally don't worry about this one! + slider_start: 0, + slider_width: 700, + // Special input keys + number_keys: true, + minimum_keys: '[', // an empty array or string turns _keys functionality off + maximum_keys: ']', + decrease_keys: ['ArrowLeft','ArrowDown'], + increase_keys: ['ArrowRight','ArrowUp','+'], + keys_step: 1, + // Inputs extras + input_multiplier: 1, + key_buffer_on: true, + key_buffer_timeout: 700, + // Text + stimulus: '
', + ticks: true, + ticks_interval: 0.5, + prompt: "How much more/less expensive is the car on the left?", + //labels: arrayRange(-5, 5, 1).map(i => '£' + i), //add £ sign to front of array -5 to 5 + labels: ['£-5.00','','','','','£0.00','','','','','£5.00'], // Spacing can be handled this way too + label_dividers: false, + display_value: true, + unit_text: 'Difference in Value: £', + prepend_unit: true, + button_label: 'Submit', + // Meta-trial parameters + stimulus_duration: 3000, // After 3 seconds + trial_duration: 20000, // After 20 seconds + require_movement: true, + response_ends_trial: false, +} +``` + +### Visual scale labels + +2 happiness scale examples showing how to use other label types with HTML + +```javascript +var emojis = { + type: jsPsychHtmlKeyboardSlider, + min: -2, + max: 2, + slider_start: 0, + display_value: false, + ticks: false, + key_buffer_on: true, + display_value: true, + key_buffer_timeout: 300, + stimulus: '', + prompt: "How do you feel about this dog?", + labels: ["😭","😫","😐", "😃","😁"], +} + +var images = { + type: jsPsychHtmlKeyboardSlider, + min: -1, + max: 1, + slider_start: 0, + slider_width: 500, + display_value: false, + prompt: "How happy are you?", + labels: [ + "", + "", + "" + ], +} +``` \ No newline at end of file diff --git a/packages/plugin-html-keyboard-slider/examples/keyboard-slider-example.html b/packages/plugin-html-keyboard-slider/examples/keyboard-slider-example.html new file mode 100644 index 00000000..5414e560 --- /dev/null +++ b/packages/plugin-html-keyboard-slider/examples/keyboard-slider-example.html @@ -0,0 +1,133 @@ + + + + + + + + + + \ No newline at end of file diff --git a/packages/plugin-html-keyboard-slider/jest.config.cjs b/packages/plugin-html-keyboard-slider/jest.config.cjs new file mode 100644 index 00000000..6ac19d5c --- /dev/null +++ b/packages/plugin-html-keyboard-slider/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/plugin-html-keyboard-slider/package.json b/packages/plugin-html-keyboard-slider/package.json new file mode 100644 index 00000000..eac2d065 --- /dev/null +++ b/packages/plugin-html-keyboard-slider/package.json @@ -0,0 +1,44 @@ +{ + "name": "@jspsych-contrib/plugin-html-keyboard-slider", + "version": "0.0.1", + "description": "HTML slider which allows for keyboard responses", + "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-contrib.git", + "directory": "packages/plugin-html-keyboard-slider" + }, + "author": "Max Lovell", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jspsych-contrib/issues" + }, + "homepage": "https://github.com/jspsych/jspsych-contrib/tree/main/packages/plugin-html-keyboard-slider", + "peerDependencies": { + "jspsych": ">=7.0.0" + }, + "devDependencies": { + "@jspsych/config": "^2.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + } +} diff --git a/packages/plugin-html-keyboard-slider/rollup.config.mjs b/packages/plugin-html-keyboard-slider/rollup.config.mjs new file mode 100644 index 00000000..87f70171 --- /dev/null +++ b/packages/plugin-html-keyboard-slider/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfig } from "@jspsych/config/rollup"; + +export default makeRollupConfig("jsPsychHtmlKeyboardSlider"); diff --git a/packages/plugin-html-keyboard-slider/src/index.spec.ts b/packages/plugin-html-keyboard-slider/src/index.spec.ts new file mode 100644 index 00000000..6a079b98 --- /dev/null +++ b/packages/plugin-html-keyboard-slider/src/index.spec.ts @@ -0,0 +1,44 @@ +import { clickTarget, pressKey, startTimeline } from "@jspsych/test-utils"; + +import jsPsychHtmlKeyboardSlider from "."; + +jest.useFakeTimers(); + +describe("my plugin", () => { + it("should load and finish", async () => { + const { expectFinished, getHTML, getData, displayElement, jsPsych } = await startTimeline([ + { + type: jsPsychHtmlKeyboardSlider, + min: 1, + max: 5, + prompt: "Rate your confidence in your response:", + labels: [ + "Pure guess", + "More or less guessing", + "Somewhat confident", + "Almost sure", + "Certain", + ], + // Options Jest needs to end trial: + //trial_duration: 5000 + //require_movement: false, // Allow the trial to end without interaction + //response_ends_trial: true, // End the trial after a response + }, + ]); + + // Simulate key press + //pressKey('3'); + + // Click continue button + clickTarget(displayElement.querySelector("#keyboardSliderButton")); + + // Advance timers for setTimeout + //jest.runAllTimers(); + + await expectFinished(); + + // check the trial output + // const data = getData().values()[0]; + // expect(data).toBeDefined(); + }); +}); diff --git a/packages/plugin-html-keyboard-slider/src/index.ts b/packages/plugin-html-keyboard-slider/src/index.ts new file mode 100644 index 00000000..ccc36246 --- /dev/null +++ b/packages/plugin-html-keyboard-slider/src/index.ts @@ -0,0 +1,577 @@ +// Needs ability to map keys to values and functions using object array? e.g. add a different value to the step controlled by arrow keys +// Need to trim params down in future + +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; +//Cannot find module '../package.json'. Consider using '--resolveJsonModule' to import module with '.json' extension.ts(2732)// +//import { version } from '../package.json'; + +const info = { + name: "html-keyboard-slider", + version: "1.0.0", + parameters: { + // HTML Attributes + /** + * Slider minimum - Note Ints here can also be floats without issue + */ + min: { + type: ParameterType.INT, // BOOL, STRING, INT, FLOAT, FUNCTION, KEY, KEYS, SELECT, HTML_STRING, IMAGE, AUDIO, VIDEO, OBJECT, COMPLEX + default: 0, + }, + /** + * Slider maximum + */ + max: { + type: ParameterType.INT, + default: 10, + }, + /** + * Slider minimum increase in value + */ + step: { + type: ParameterType.INT, + default: 1, + }, + /** + * For a more coninuous slider, set HTML Range input's step attribute to 'any', see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range#examples. Step values above still apply to 'increase_keys' and 'decrease_keys'. + */ + step_any: { + type: ParameterType.BOOL, + default: false, + }, + /** + * Where to start the slider, defaults to minimum value + */ + slider_start: { + type: ParameterType.INT, + default: null, + }, + /** + * Width of the slider in pixels - defaults to 100% of container otherwise + */ + slider_width: { + type: ParameterType.INT, + default: null, + }, + + // KEYS + /** + * single charater or array of character(s) indicating which keys bottom out slider to minimum - notes: These fields are case sensitive e.g. use ['A','a'] to control slider with A key. Set to empty array or string to turn functionality off. + */ + minimum_keys: { + type: ParameterType.KEYS, + default: ["`", "§"], // nullable and won't do anything if passed empty array + }, + /** + * keys which raise slider to maximum - can be a number e.g. "0" on a scale with 10 as maximum + */ + maximum_keys: { + type: ParameterType.KEYS, + default: ["="], + }, + /** + * keys which decrease slider 1 step + */ + decrease_keys: { + type: ParameterType.KEYS, + default: ["ArrowLeft", "ArrowDown"], + }, + /** + * keys which increase slider 1 step + */ + increase_keys: { + type: ParameterType.KEYS, + default: ["ArrowRight", "ArrowUp"], + }, + /** + * Whether or not to listen to number keys + */ + number_keys: { + type: ParameterType.BOOL, + default: true, + }, + /** + * How much the increase and decrease keys change the value on the slider - defaults to step size + */ + keys_step: { + type: ParameterType.FLOAT, + default: null, + }, + + // INPUT MANIPULATION + /** + * Multiplies the input value after key_buffer is accounted for if on - e.g. to multiply input by 10 with 0-100% + */ + input_multiplier: { + type: ParameterType.INT, + default: 1, + }, + /** + * Key buffer tracks presses over seconds to allow multiple button presses e.g. pressing 6 then 5 = 65. Handles negative values '-' and decimals '.' so long as they aren't assigned to other roles above. + */ + key_buffer_on: { + type: ParameterType.BOOL, + default: false, + }, + /** + * Length of time consecutive key presses are held in memory + */ + key_buffer_timeout: { + type: ParameterType.INT, + default: 300, + }, + + // TEXT + /** + * Prompt displayed above slider + */ + prompt: { + type: ParameterType.HTML_STRING, + default: "", + }, + /** + * Whether to display ticks under each value of the slider - these are also slightly 'sticky' + */ + ticks: { + type: ParameterType.BOOL, + default: true, + }, + /** + * Interval at which to display ticks - defaults to step size + */ + ticks_interval: { + type: ParameterType.FLOAT, + default: null, + }, + /** + * Labels (array or single value) displayed equidistantly below stimulus - Accepts HTML, e.g. "" or special chars "😁" + */ + labels: { + type: ParameterType.HTML_STRING, + default: null, + }, + /** + * Whether to display dividing lines between labels + */ + label_dividers: { + type: ParameterType.BOOL, + default: true, + }, + /** + * Whether or not to display the current value of the slider below + */ + display_value: { + type: ParameterType.BOOL, + default: true, + }, + /** + * Text displayed next to display_value, e.g. %, cm, or another unit of measure + */ + unit_text: { + type: ParameterType.STRING, + default: "", + }, + /** + * Whether or not to prepend unit e.g. £5 - default is append e.g. 5% + */ + prepend_unit: { + type: ParameterType.BOOL, + default: false, + }, + + // NON-SLIDER-COMPONENTS + /** + * Stimulus to be displayed, any html is valid + */ + stimulus: { + type: ParameterType.HTML_STRING, + default: null, // null is nullable, undefined must be set? + }, + /** + * Duration of stimulus + */ + stimulus_duration: { + type: ParameterType.INT, + default: null, + }, + /** + * Duration of trial - if no response is made the response is recorded as null + */ + trial_duration: { + type: ParameterType.INT, + default: null, + }, + /** + * Whether or not a response ends the trial + */ + response_ends_trial: { + type: ParameterType.BOOL, + default: false, + }, + /** + * Whether the slider must be interacted with to continue + */ + require_movement: { + type: ParameterType.BOOL, + default: false, + }, + /** + * Label of the button displayed + */ + button_label: { + type: ParameterType.STRING, + default: "Continue", + }, + }, + data: { + /** + * Final value of the slider - defaults to slider starting value if slider not interacted with + */ + response: { + type: ParameterType.INT, + }, + /** + * Reaction time in milliseconds + */ + rt: { + type: ParameterType.FLOAT, + }, + /** + * Stimulus presented + */ + stimulus: { + type: ParameterType.HTML_STRING, + }, + /** + * Starting value of the slider + */ + slider_start: { + type: ParameterType.INT, + }, + }, +}; + +type Info = typeof info; + +/** + * **html-keyboard-slider** + * + * HTML slider which allows for keyboard responses, with some extra customisations + * + * @author Max Lovell + * @see {@link https://github.com/jspsych/jspsych-contrib/packages/plugin-html-keyboard-slider/README.md}} + + */ +class HtmlKeyboardSliderPlugin implements JsPsychPlugin { + static info = info; + private keyboardListener: any; // Allows this.keyboardListener id to be saved + + constructor(private jsPsych: JsPsych) {} + + trial(display_element: HTMLElement, trial: TrialType) { + // Init Data + const startTime = performance.now(); + + let data = { + response: null, + rt: null, + slider_start: null, + stimulus: trial.stimulus, + }; + + // CREATE SLIDER HTML AND CSS --------------------------------------------------------- + let slider, continueButton, sliderValueDisplay, stimulus; + addSliderElementsWrapper(); + + // wrapper + function addSliderElementsWrapper() { + const container = createContainer(); + if (trial.stimulus !== null) stimulus = addStimulus(container); + addPrompt(container); + slider = addSlider(container); + if (trial.ticks) addTicks(container); + if (trial.labels) { + const labels = addLabels(container); //make this parameter + // Handling the location of labels in a simple manner is tricky.... + //Expanding the width of the labels is difficult as their addition already changes the length of the slider + //labels.style.width = ((trial.labels.length + 1) / trial.labels.length) * 100 + "%"; + //labels.style.width = (slider.clientWidth*((trial.labels.length+1)/trial.labels.length)) +'px' + // Note this lines up better but reduces the size of the slider too much: + //slider.style.width = ((trial.labels.length-1)/trial.labels.length)*100 +'%' + // This seems good enough for an easy solution but isn't quite the individual placement in the standard slider: + // Width in % to allow expansion beyond container. + if (trial.slider_width === null) + labels.style.width = ((trial.labels.length + 1) / trial.labels.length) * 100 + "%"; + else + labels.style.width = + ((trial.labels.length + 1) / trial.labels.length) * slider.offsetWidth + "px"; + } + + if (trial.display_value) sliderValueDisplay = addValueText(container); + continueButton = addContinueButton(container); + } + + // HTML + CSS functions + function createContainer() { + // Create + const container = document.createElement("div"); + // CSS + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.alignItems = "center"; + container.style.justifyContent = "center"; + container.style.textAlign = "center"; + container.style.rowGap = "10px"; + container.style.width = "100%"; + // Append + display_element.appendChild(container); + return container; + } + + // Instructions text + function addStimulus(container) { + const stimulus = document.createElement("div"); + stimulus.id = "keyboardSliderStimulus"; + stimulus.innerHTML = trial.stimulus; + container.appendChild(stimulus); + return stimulus; + } + + function addPrompt(container) { + const prompt = document.createElement("p"); + prompt.id = "keyboardSliderPrompt"; + prompt.textContent = trial.prompt; + container.appendChild(prompt); + } + + // Slider display + function addSlider(container) { + const slider = document.createElement("input"); + slider.type = "range"; + slider.id = "keyboardSlider"; + slider.min = "" + trial.min; + slider.max = "" + trial.max; + slider.step = trial.step_any ? "any" : "" + trial.step; + slider.value = trial.slider_start === null ? "" + trial.min : "" + trial.slider_start; + data.slider_start = slider.value; + if (trial.slider_width !== null) slider.style.width = trial.slider_width + "px"; + else slider.style.width = "100%"; + container.appendChild(slider); + return slider; + } + + function addTicks(container) { + const ticks = document.createElement("datalist"); + ticks.id = "ticks"; + const interval = trial.ticks_interval === null ? trial.step : trial.ticks_interval; + for (let t = trial.min; t <= trial.max; t += interval) { + const tick = document.createElement("option"); + tick.classList.add("tick"); + tick.value = "" + t; + + //tick.label = "" + (t+1) // Visible labels in CSS (see proposal) but looks a little funny.... + ticks.appendChild(tick); + } + slider.setAttribute("list", "ticks"); // Attribute is 'readOnly' so set like this + container.appendChild(ticks); + } + + function addLabels(container) { + const labels = document.createElement("div"); + labels.id = "labels"; + labels.style.display = "flex"; + labels.style.wordBreak = "break-word"; //this is needed to keep everything the right size + // labels.style.width = "100%"; + // labels.style.justifyContent = "space-between"; + // labels.style.flex = "1 1 0px"; + + const nLabels = trial.labels.length; + for (let l = 0; l < nLabels; l++) { + // Container + const labelContainer = document.createElement("span"); + labelContainer.classList.add("labelContainer"); + labelContainer.style.width = "100%"; + labelContainer.style.textAlign = "center"; + labelContainer.style.padding = "5px"; + if (l < nLabels - 1 && trial.label_dividers) + labelContainer.style.borderRight = "1px solid grey"; + // Text + const label = document.createElement("span"); + label.classList.add("label"); + label.innerHTML = trial.labels[l]; // Use innerHTML to allow for images + //Append + labelContainer.appendChild(label); + labels.appendChild(labelContainer); + } + //or use display: grid and labels.style.gridTemplateColumns = 'repeat('+ nLabels +', 1fr)'; + //labels.style.width = ((nLabels+1)/nLabels)*100 +'%' + labels.style.maxWidth = "100vw"; + container.appendChild(labels); + return labels; + } + + // User Info + function addValueText(container) { + const sliderText = document.createElement("output"); + sliderText.id = "sliderText"; + sliderText.textContent = trial.prepend_unit + ? trial.unit_text + slider.value + : slider.value + trial.unit_text; + container.appendChild(sliderText); + return sliderText; + } + + function addContinueButton(container) { + const button = document.createElement("button"); + button.id = "keyboardSliderButton"; //id="jspsych-survey-text-next" + button.innerHTML = trial.button_label; + button.disabled = trial.require_movement; + button.classList.add("jspsych-btn", "jspsych-survey-text"); + container.appendChild(button); + return button; + } + + // KEYBOARD BUFFER --------------------------------------------------------- + let keyBuffer = [], + timerId; + function addToKeyBuffer(key) { + // if '-' and not first value clear first. if '.' and first value add 0 first. + if (key === "-" && keyBuffer.length > 0) clearKeybuffer(); + else if (key === "." && keyBuffer.length === 0) keyBuffer.push("0"); + keyBuffer.push(key); + // Set new timeout + clearTimeout(timerId); //does nothing if undefined by spec + timerId = undefined; + timerId = setTimeout(clearKeybuffer, trial.key_buffer_timeout); + return concatenateKeyBuffer(); + } + + function concatenateKeyBuffer() { + let concatNums = ""; + for (let i = 0; i < keyBuffer.length; i++) { + concatNums += keyBuffer[i]; + } + const numericBufferValue = +concatNums; + if (numericBufferValue > slider.max || numericBufferValue < slider) clearKeybuffer(); //clear buffer if out of bounds + return numericBufferValue; + } + + function clearKeybuffer() { + clearTimeout(timerId); + timerId = undefined; + keyBuffer = []; + } + + // KEYPRESSES --------------------------------------------------------- + const updateSliderValue = (info) => { + const stepSize = trial.keys_step === null ? trial.step : trial.keys_step; + if (info.key === "Enter" && !continueButton.disabled) endTrial(); + else if (trial.decrease_keys.includes(info.key)) + slider.value = "" + (+slider.value - +stepSize); + else if (trial.increase_keys.includes(info.key)) + slider.value = "" + (+slider.value + +stepSize); + else if (trial.minimum_keys.includes(info.key)) slider.value = slider.min; + else if (trial.maximum_keys.includes(info.key)) slider.value = slider.max; + else if (["-", "."].includes(info.key) && trial.key_buffer_on) + slider.value = addToKeyBuffer(info.key); + else if (isFinite(info.key) && trial.number_keys) { + // if is Number + let sliderValue = +info.key; //+ is shorthand for str to int + if (trial.key_buffer_on) sliderValue = addToKeyBuffer(sliderValue); + // * multiplier if is only value in keyBuffer or keyBuffer is off - otherwise just input direct values + if (keyBuffer.length < 2) sliderValue *= trial.input_multiplier; + slider.value = "" + sliderValue; + } + + // Update text + if (trial.display_value) + sliderValueDisplay.innerHTML = trial.prepend_unit + ? trial.unit_text + slider.value + : slider.value + trial.unit_text; + + // Record data /// consider using slider.dispatchEvent(new Event("input")); + data.rt = info.rt; + data.response = slider.value; //record here so response must be made + + // end the trial + if (trial.response_ends_trial) endTrial(); + if (info.key !== "Enter") continueButton.disabled = false; + }; + + // Note keyboard inputs don't call slider input event type so this handles clicks/touches + function sliderClick(e) { + // Add data + data.rt = Math.round(e.timeStamp - startTime); // Same as used here: https://github.com/jspsych/jsPsych/blob/main/packages/jspsych/src/modules/plugin-api/KeyboardListenerAPI.ts + data.response = slider.value; //record here so response must be made + + // display current value + if (trial.display_value) + sliderValueDisplay.textContent = trial.prepend_unit + ? trial.unit_text + slider.value + : slider.value + trial.unit_text; + continueButton.disabled = false; + } + slider.addEventListener("input", sliderClick); + + // Setup initial keyboard event + this.keyboardListener = this.jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: updateSliderValue, + valid_responses: [ + ...trial.minimum_keys, + ...trial.maximum_keys, + ...trial.decrease_keys, + ...trial.increase_keys, + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "Enter", + "-", + ".", + ], + persist: true, // Needed for key buffer + allow_held_key: true, // Good for step buttons + }); + + // END TRIAL CONDITIONS + FUNCTIONS --------------------------------------------------------- + + // Remove Stimulus after stimulus_duration + if (trial.stimulus_duration !== null) { + this.jsPsych.pluginAPI.setTimeout(function () { + stimulus.hidden = true; //.style.display = 'none' + }, trial.stimulus_duration); + } + + // Remove trial after trial_duration + if (trial.trial_duration !== null) { + this.jsPsych.pluginAPI.setTimeout(function () { + endTrial(); + }, trial.trial_duration); + } + + // End Trial function + const endTrial = () => { + //clear and cancel things + clearKeybuffer(); + this.jsPsych.pluginAPI.cancelKeyboardResponse(this.keyboardListener); + display_element.innerHTML = ""; + + // if no response and trial_duration not set + if (data.response === null && trial.trial_duration === null) + data.response = data.slider_start; //set response to slider start + + // end trial + this.jsPsych.finishTrial(data); + }; + + // End trial if continue button is clicked + continueButton.addEventListener("click", endTrial); + } +} + +export default HtmlKeyboardSliderPlugin; diff --git a/packages/plugin-html-keyboard-slider/tsconfig.json b/packages/plugin-html-keyboard-slider/tsconfig.json new file mode 100644 index 00000000..3eabd0c2 --- /dev/null +++ b/packages/plugin-html-keyboard-slider/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jspsych/config/tsconfig.contrib.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +}