diff --git a/examples/index.html b/examples/index.html index 00d5809..faf2179 100644 --- a/examples/index.html +++ b/examples/index.html @@ -24,7 +24,7 @@
- + \ No newline at end of file diff --git a/examples/studies/element-showcase.js b/examples/studies/element-showcase.js new file mode 100644 index 0000000..096939d --- /dev/null +++ b/examples/studies/element-showcase.js @@ -0,0 +1,147 @@ +import Survey from '../../library/core/survey.js'; +import BoundingBox from '../../library/elements/boundingBox.js'; +import CheckBox from '../../library/elements/checkBox.js'; +import DropdownSelect from '../../library/elements/dropdownSelect.js'; +import Grid from '../../library/elements/grid.js'; +import HTML from '../../library/elements/HTML.js'; +import MultiSelect from '../../library/elements/multiSelect.js'; +import NumberEntry from '../../library/elements/numberEntry.js'; +import NumberScale from '../../library/elements/numberScale.js'; +import OpenEnd from '../../library/elements/openEnd.js'; +import SingleSelect from '../../library/elements/singleSelect.js'; +import ProgressBar from '../../library/plugins/progressBar.js'; +import pageHTML from '../../library/plugins/pageHTML.js'; + +async function runComprehensiveSurvey() { + const survey = new Survey({ + title: "Comprehensive Survey Example", + description: "This survey demonstrates all available question types.", + styles: { + body: { + background: '#f9f9f7', + } + } + }); + + const htmlIntro = new HTML({ + id: 'intro', + content: '

Welcome to our element showcase

This survey include examples of every question type and plugin. The code for the survey is available on our GitHub.

', + styles: { + root: { + textAlign: 'center', + } + } + }); + + const singleSelect = new SingleSelect({ + id: 'favorite-color', + text: 'What is your favorite color?', + subText: 'Select one option', + options: ['Red', 'Blue', 'Green', 'Yellow'], + required: true, + allowOther: true, + }); + + const multiSelect = new MultiSelect({ + id: 'hobbies', + text: 'Which of the following are your hobbies? (Select all that apply)', + options: ['Reading', 'Sports', 'Cooking', 'Gaming', 'Traveling'], + required: true, + minSelect: 1, + maxSelect: 3 + }); + + const dropdownSelect = new DropdownSelect({ + id: 'country', + text: 'Select your country of residence', + options: ['United States', 'Canada', 'United Kingdom', 'Australia', 'Germany', 'France', 'Japan', 'Other'], + required: true + }); + + const checkBox = new CheckBox({ + id: 'terms', + text: 'I agree to the terms and conditions', + required: true + }); + + const numberEntry = new NumberEntry({ + id: 'age', + text: 'Please enter your age', + min: 18, + max: 100, + required: true + }); + + const numberScale = new NumberScale({ + id: 'satisfaction', + text: 'How satisfied are you with our service?', + min: 1, + max: 10, + minLabel: 'Not at all satisfied', + maxLabel: 'Extremely satisfied', + required: true + }); + + const openEnd = new OpenEnd({ + id: 'feedback', + text: 'Please provide any additional feedback you may have', + required: false, + maxLength: 500 + }); + + const grid = new Grid({ + id: 'feature-rating', + text: 'Please rate the following features of our product', + rows: ['Ease of use', 'Performance', 'Design', 'Customer support'], + columns: ['Poor', 'Fair', 'Good', 'Excellent'], + required: true + }); + + const boundingBox = new BoundingBox({ + id: 'image-selection', + text: 'Please draw a box around the house', + imageUrl: 'https://images.pexels.com/photos/106399/pexels-photo-106399.jpeg', + required: true + }); + + // Group questions into pages + const page1 = { id: 'page1', elements: [htmlIntro, singleSelect, multiSelect, dropdownSelect] }; + const page2 = { id: 'page2', elements: [checkBox, numberEntry, numberScale, openEnd] }; + const page3 = { id: 'page3', elements: [grid, boundingBox] }; + + const progress = new ProgressBar({ maxPages: 3 }); + + const logo = new pageHTML({ + id: 'logo', + content: 'Company Logo', + position: 'top', + styles:{ + root: { + margin: '0 auto', + width: '100%', + maxWidth: '180px', + marginTop: '5px', + marginBottom: '10px' + } + } + }); + survey.addPlugin(logo); + survey.addPlugin(progress); + + // Show pages sequentially + await survey.showPage(page1); + await survey.showPage(page2); + await survey.showPage(page3); + + // Finish the survey + survey.finishSurvey(` +

Thank you for completing our comprehensive survey!

+

Your responses have been recorded and will help us improve our services.

+ `); + + // Log the survey data (in a real scenario, you'd probably send this to a server) + console.log('Survey data:', survey.getAllSurveyData()); +} + +// Run the survey +runComprehensiveSurvey(); \ No newline at end of file diff --git a/examples/studies/enterprise-market-research.js b/examples/studies/enterprise-market-research.js index 453b395..07087c3 100644 --- a/examples/studies/enterprise-market-research.js +++ b/examples/studies/enterprise-market-research.js @@ -3,7 +3,7 @@ import OpenEnd from '../../library/elements/openEnd.js'; import HTML from '../../library/elements/HTML.js'; import MultiSelect from '../../library/elements/multiSelect.js'; import SingleSelect from '../../library/elements/singleSelect.js'; -import OrderedScale from '../../library/elements/orderedScale.js'; +import NumberScale from '../../library/elements/NumberScale.js'; import Grid from '../../library/elements/grid.js'; import NumberEntry from '../../library/elements/numberEntry.js'; import ProgressBar from '../../library/plugins/progressBar.js'; @@ -29,13 +29,15 @@ async function runSurvey() { backgroundColor: '#5f9ea0', } }, - question: { - borderBottom: '1px solid #b0d4ff', - paddingBottom: '20px', - '@media (max-width: 650px)': { - borderBottom: 'none', - paddingBottom: '0px', - }, + Element: { + root:{ + borderBottom: '1px solid #b0d4ff', + paddingBottom: '20px', + '@media (max-width: 650px)': { + borderBottom: 'none', + paddingBottom: '0px', + }, + } } } }); @@ -93,7 +95,7 @@ async function runSurvey() { await survey.showPage({ id: `page_${destination}`, elements: [q_destination] }); } - const q3 = new OrderedScale({ + const q3 = new NumberScale({ id: 'travel_importance', text: 'How important are the following factors when choosing a business travel destination?', min: 1, diff --git a/library/core/element.js b/library/core/element.js index d0cf8a5..b082592 100644 --- a/library/core/element.js +++ b/library/core/element.js @@ -6,9 +6,18 @@ class Element { responseTimestamp: null }; - static defaultStyles = {}; + static styleKeys = ['root', 'innerContainer', 'textContainer', 'text', 'subText', 'errorMessage']; + + static selectorMap = { + root: '', + innerContainer: '.inner-container', + textContainer: '.text-container', + text: '.question-text', + subText: '.question-subtext', + errorMessage: '.error-message' + }; - constructor({ id, type, store_data = false, required = false }) { + constructor({ id, type, store_data = false, required = false, customValidation = null, styles = {} }) { if (!id || typeof id !== 'string') { throw new Error('Invalid id: must be a non-empty string'); } @@ -21,45 +30,36 @@ class Element { this.required = Boolean(required); this.data = { id, type, response: null, responded: false }; this.initialResponse = null; - this.styles = {}; + this.styles = styles; this.eventListeners = []; + this.elementStyleKeys = [...this.constructor.styleKeys]; + this.selectorMap = { ...this.constructor.selectorMap }; + this.customValidation = customValidation; + } + + mergeStyles(surveyElementStyles, elementStyles) { + const mergedStyles = {}; + this.elementStyleKeys.forEach(key => { + mergedStyles[key] = { + ...(surveyElementStyles[key] || {}), + ...(this.constructor.defaultStyles?.[key] || {}), + ...(this.styles[key] || {}), + ...(elementStyles[key] || {}) + }; + }); + return mergedStyles; } - validate() { - if (this.required && !this.data.response) { - this.showValidationError('This question is required. Please provide an answer.'); - return false; + generateStylesheet(surveyElementStyles) { + const mergedStyles = this.mergeStyles(surveyElementStyles, this.styles); + return this.elementStyleKeys.map(key => { + return this.generateStyleForSelector(this.getSelectorForKey(key), mergedStyles[key]) } - this.showValidationError(null); - return true; - } - - showValidationError(message) { - const errorElement = document.getElementById(`${this.id}-error`); - if (errorElement) { - errorElement.textContent = message || ''; - errorElement.style.display = message ? 'block' : 'none'; - } else { - console.warn(`Error element not found for ${this.id}`); - } - } - - mergeStyles(defaultStyles, customStyles) { - this.styles = this.constructor.styleKeys.reduce((merged, key) => { - merged[key] = { ...defaultStyles[key], ...customStyles[key] }; - return merged; - }, {}); - } - - generateStylesheet() { - return this.constructor.styleKeys.map(key => - this.generateStyleForSelector(this.getSelectorForKey(key), this.styles[key]) ).join('\n'); } getSelectorForKey(key) { - // This method should be overridden by subclasses - return ''; + return this.selectorMap[key] || ''; } generateStyleForSelector(selector, rules) { @@ -68,7 +68,8 @@ class Element { return ''; } - const fullSelector = selector ? `#${this.id}-container ${selector}` : `#${this.id}-container`; + const baseSelector = `#${this.id}-container`; + const fullSelector = selector ? `${baseSelector} ${selector}` : baseSelector; const baseStyles = this.rulesToString(rules); let styleString = `${fullSelector} { ${baseStyles} }`; @@ -100,26 +101,25 @@ class Element { this.initialResponse = value; } - render() { + render(surveyElementStyles) { const questionContainer = document.getElementById('question-container'); if (questionContainer) { - const elementContainer = document.createElement('div'); - elementContainer.id = `${this.id}-container`; - elementContainer.innerHTML = this.generateHTML(); + const elementHtml = this.generateHTML(); + const tempContainer = document.createElement('div'); + tempContainer.innerHTML = elementHtml; + + const elementContainer = tempContainer.firstElementChild; // Apply styles const styleElement = document.createElement('style'); - styleElement.textContent = this.generateStylesheet(); + styleElement.textContent = this.generateStylesheet(surveyElementStyles); elementContainer.prepend(styleElement); - + questionContainer.appendChild(elementContainer); this.attachEventListeners(); // Set the initial response after rendering - if (this.initialResponse !== null) { - this.setResponse(this.initialResponse); - this.initialResponse = null; - } + this.data.response = this.initialResponse; } else { console.error('Question container not found'); } @@ -154,25 +154,47 @@ class Element { this.showValidationError(null); } - setResponded() { + addData(key, value) { if (this.store_data) { - this.data.responded = true; + this.data[key] = value; } } - addData(key, value) { - if (this.store_data) { - this.data[key] = value; + validate() { + let isValid = true; + let errorMessage = ''; + + // Check if the question is required and answered + if (this.required && !this.data.responded) { + isValid = false; + errorMessage = 'Please provide a response.'; + } + + // If basic validation passed and custom validation is provided, run it + if (isValid && typeof this.customValidation === 'function') { + const customValidationResult = this.customValidation(this.data.response); + if (customValidationResult !== true) { + isValid = false; + errorMessage = customValidationResult || 'Invalid input.'; + } } + + return { isValid, errorMessage }; } - isValid() { - return this.validate(); + showValidationError(message) { + const errorElement = document.getElementById(`${this.id}-error`); + if (errorElement) { + errorElement.textContent = message || ''; + errorElement.style.display = message ? 'block' : 'none'; + } else { + console.warn(`Error element not found for ${this.id}`); + } } destroy() { // Remove all event listeners if they exist - if (this.eventListeners){ + if (this.eventListeners) { this.eventListeners.forEach(({ element, eventType, handler }) => { element.removeEventListener(eventType, handler); }); diff --git a/library/core/plugin.js b/library/core/plugin.js index 7d2e1a3..35ffdde 100644 --- a/library/core/plugin.js +++ b/library/core/plugin.js @@ -1,91 +1,81 @@ class Plugin { - static styleKeys = ['root']; // To be overridden by specific plugins - - static defaultStyles = { - root: {} - }; - - constructor({ styles = {} } = {}) { - if (new.target === Plugin) { - throw new Error('Plugin is an abstract class and cannot be instantiated directly.'); - } - this.styles = this.mergeStyles(this.constructor.defaultStyles, styles); - this.survey = null; + constructor({ targetId = 'survey-container', position = 'top', styles = {} }) { + this.targetId = targetId; + this.position = position; + this.styles = styles; + this.pluginId = `plugin-${Math.random().toString(36).substr(2, 9)}`; } - mergeStyles(defaultStyles, customStyles) { - return this.constructor.styleKeys.reduce((merged, key) => { - merged[key] = { ...defaultStyles[key], ...customStyles[key] }; - return merged; - }, {}); + initialize(survey) { + this.survey = survey; + this.injectPlugin(); } - generateStylesheet() { - return this.constructor.styleKeys.map(key => - this.generateStyleForSelector(this.getSelectorForKey(key), this.styles[key]) - ).join('\n'); - } + injectPlugin() { + const targetContainer = document.getElementById(this.targetId); + if (!targetContainer) { + console.warn(`Target container with id "${this.targetId}" not found`); + return; + } - getSelectorForKey(key) { - // To be overridden by specific plugins - return ''; + let pluginContainer = this.getOrCreatePluginContainer(targetContainer); + + const pluginElement = this.createPluginElement(); + pluginContainer.appendChild(pluginElement); } - generateStyleForSelector(selector, rules) { - if (!rules || typeof rules !== 'object') { - console.warn(`Invalid rules for selector ${selector}`); - return ''; + getOrCreatePluginContainer(targetContainer) { + const containerId = `${this.targetId}-${this.position}-plugins`; + let pluginContainer = document.getElementById(containerId); + + if (!pluginContainer) { + pluginContainer = document.createElement('div'); + pluginContainer.id = containerId; + + if (this.position === 'top') { + targetContainer.insertBefore(pluginContainer, targetContainer.firstChild); + } else { + targetContainer.appendChild(pluginContainer); + } } - const baseStyles = this.rulesToString(rules); - let styleString = `${selector} { ${baseStyles} }`; - - Object.entries(rules) - .filter(([key, value]) => typeof value === 'object') - .forEach(([key, value]) => { - if (key.startsWith('@media')) { - styleString += `\n${key} { ${selector} { ${this.rulesToString(value)} } }`; - } else if (key.startsWith('&')) { - styleString += `\n${selector}${key.slice(1)} { ${this.rulesToString(value)} }`; - } - }); - - return styleString; + return pluginContainer; } - rulesToString(rules) { - return Object.entries(rules) - .filter(([key, value]) => typeof value !== 'object') - .map(([key, value]) => `${this.camelToKebab(key)}: ${value};`) - .join(' '); + createPluginElement() { + const element = document.createElement('div'); + element.id = this.pluginId; + element.innerHTML = this.generateContent(); + this.applyStyles(element); + return element; } - camelToKebab(string) { - return string.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase(); + generateContent() { + // To be implemented by subclasses + throw new Error('generateContent method must be implemented by subclasses'); } - initialize(survey) { - if (!survey) { - throw new Error('A survey instance must be provided to initialize the plugin.'); - } - this.survey = survey; + applyStyles(element) { + Object.assign(element.style, this.styles.root || {}); } - beforePageRender(page) { - // To be implemented by specific plugins + beforePageRender() { + // This method is intentionally left empty } - afterPageRender(page) { - // To be implemented by specific plugins + afterPageRender() { + // This method is intentionally left empty } beforeSurveyFinish() { - // To be implemented by specific plugins + // This method is intentionally left empty } destroy() { - // To be implemented by specific plugins for cleanup - this.survey = null; + const element = document.getElementById(this.pluginId); + if (element) { + element.remove(); + } } } diff --git a/library/core/survey.js b/library/core/survey.js index b6a3c28..8b4c597 100644 --- a/library/core/survey.js +++ b/library/core/survey.js @@ -1,159 +1,130 @@ class Survey { - static styleKeys = [ - "body", - "container", - "question", - "navigation", - "button", - "errorMessage", - "nextButtonError", - "finishMessage", - "questionRoot", - "questionLabel", - "questionSubText", - ]; - + static styleKeys = ['body', 'container', 'navigation', 'button', 'errorMessage', 'nextButtonError', 'finishMessage']; + static defaultStyles = { - body: { - fontFamily: "Arial, sans-serif", - lineHeight: "1.4", - color: "#333", - backgroundColor: "#f7f7f7", - padding: "25px", - "@media (max-width: 650px)": { - background: "white", - padding: "0px", + body: { + fontFamily: 'Helvetica, Arial, sans-serif', + lineHeight: '1.4', + color: 'black', + backgroundColor: '#f7f7f7', + padding: '25px', + '@media (max-width: 650px)': { + background: 'white', + padding: '0px', + }, }, - }, - container: { - width: "100%", - maxWidth: "700px", - boxSizing: "border-box", - margin: "0 auto", - padding: "25px", - backgroundColor: "#ffffff", - boxSizing: "border-box", - boxShadow: "0 0 10px rgba(0,0,0,0.1)", - borderRadius: "12px", - "@media (max-width: 650px)": { - boxShadow: "none", - padding: "20px", + container: { + width: '100%', + maxWidth: '680px', + boxSizing: 'border-box', + margin: '0 auto', + padding: '25px', + backgroundColor: '#ffffff', + boxShadow: '0 0 10px rgba(0,0,0,0.1)', + borderRadius: '12px', + '@media (max-width: 650px)': { + boxShadow: 'none', + padding: '20px', + }, }, - }, - question: { - marginBottom: "30px", - }, - questionRoot: { - padding: "10px", - border: "1px solid #ddd", - borderRadius: "5px", - }, - questionLabel: { - fontSize: "1em", - fontWeight: "bold", - marginBottom: "5px", - display: "block", - }, - questionSubText: { - fontSize: "0.9em", - color: "#777", - }, - navigation: { - marginTop: "45px", - }, - button: { - backgroundColor: "#333", - maxWidth: "100%", - color: "#ffffff", - padding: "12px 34px", - border: "none", - fontSize: "1em", - borderRadius: "5px", - cursor: "pointer", - "&:hover": { - backgroundColor: "#444", + navigation: { + marginTop: '45px', }, - }, - errorMessage: { - color: "#fa5252", - fontWeight: "500", - marginTop: "5px", - fontSize: "0.9em", - }, - nextButtonError: { - color: "#fa5252", - fontWeight: "500", - marginLeft: "10px", - display: "inline-block", - fontSize: "0.9em", - }, - finishMessage: { - display: "none", - fontSize: "1.1em", - fontWeight: "bold", - textAlign: "center", - }, + button: { + backgroundColor: '#333', + maxWidth: '100%', + color: '#ffffff', + padding: '12px 34px', + border: 'none', + fontSize: '1em', + borderRadius: '8px', + cursor: 'pointer', + '&:hover': { + backgroundColor: '#444', + } + }, + errorMessage: { + color: '#fa5252', + marginTop: '5px', + fontSize: '0.9em', + }, + nextButtonError: { + color: '#fa5252', + marginLeft: '10px', + display: 'inline-block', + fontSize: '0.9em', + }, + finishMessage: { + display: 'none', + fontSize: '1.1em', + textAlign: 'center', + }, + }; + + static defaultElementStyles = { + root: { + marginBottom: '40px', + }, + innerContainer: { + marginTop: '5px', + }, + textContainer: { + marginBottom: '10px', + }, + text: { + display: 'block', + }, + subText: { + display: 'block', + color: '#888', + fontSize: '1em', + }, + errorMessage: { + color: '#fa5252', + fontSize: '0.9em', + marginTop: '5px' + } }; - + constructor(customSurveyDetails = {}) { - this.responses = []; - this.currentPage = null; - this.nextButtonListener = null; - this.plugins = []; - this.currentPageElements = []; - this.nextButtonListener = null; - - this.surveyDetails = { - startTime: new Date().toISOString(), - ...customSurveyDetails, - }; - - this.globalStyles = this.mergeStyles( - Survey.defaultStyles, - this.surveyDetails.styles || {} - ); - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", () => this.initialize()); - } else { - this.initialize(); - } + this.responses = []; + this.currentPage = null; + this.nextButtonListener = null; + this.plugins = []; + this.currentPageElements = []; + + this.surveyDetails = { + startTime: new Date().toISOString(), + ...customSurveyDetails + }; + + this.globalStyles = this.mergeStyles(Survey.defaultStyles, this.surveyDetails.styles || {}); + this.elementStyles = this.mergeStyles(Survey.defaultElementStyles, this.surveyDetails.styles?.Element || {}); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => this.initialize()); + } else { + this.initialize(); + } } - + initialize() { - try { - this.validateStyles(); - this.applyGlobalStyles(); - this.revealContent(); - } catch (error) { - console.error("Error during survey initialization:", error); - } - } - - applyGlobalStyles() { - const styleElement = document.createElement("style"); - styleElement.textContent = this.generateStylesheet(); - document.head.appendChild(styleElement); - } - - revealContent() { - const surveyContainer = document.getElementById("survey-container"); - if (surveyContainer) { - surveyContainer.classList.remove("hidden"); - } else { - console.warn("Survey container not found"); - } + try { + this.validateStyles(); + this.applyGlobalStyles(); + this.revealContent(); + } catch (error) { + console.error('Error during survey initialization:', error); + } } - + mergeStyles(defaultStyles, customStyles) { - return Object.fromEntries( - Survey.styleKeys.map((key) => [ - key, - this.deepMerge(defaultStyles[key], customStyles[key]), - ]) - ); + const keys = Object.keys(defaultStyles); + return Object.fromEntries( + keys.map(key => [key, this.deepMerge(defaultStyles[key], customStyles[key])]) + ); } - + deepMerge(target, source) { if (!source || typeof source !== 'object') return target; if (!target || typeof target !== 'object') return source; @@ -182,285 +153,283 @@ class Survey { }); return merged; } - + + applyGlobalStyles() { + const styleElement = document.createElement('style'); + styleElement.textContent = this.generateStylesheet(); + document.head.appendChild(styleElement); + } + generateStylesheet() { - return Survey.styleKeys - .map((key) => - this.generateStyleForSelector( - this.getSelectorForKey(key), - this.globalStyles[key] - ) - ) - .join("\n"); + return Survey.styleKeys.map(key => + this.generateStyleForSelector(this.getSelectorForKey(key), this.globalStyles[key]) + ).join('\n'); } - + getSelectorForKey(key) { - const selectorMap = { - body: "body", - container: "#survey-container", - question: "#question-container > div", - questionRoot: ".question-root", - questionLabel: ".question-label", - questionSubText: ".question-subText", - button: "#next-button", - errorMessage: ".error-message", - nextButtonError: "#next-button-error", - navigation: "#navigation", - finishMessage: "#finish", - }; - return selectorMap[key] || ""; + const selectorMap = { + body: 'body', + container: '#survey-container', + button: '#next-button', + errorMessage: '.error-message', + nextButtonError: '#next-button-error', + navigation: '#navigation', + finishMessage: '#finish', + }; + return selectorMap[key] || ''; } - + generateStyleForSelector(selector, rules) { - const baseStyles = this.rulesToString(rules); - let styleString = `${selector} { ${baseStyles} }`; - - Object.entries(rules) - .filter(([key, value]) => typeof value === "object") - .forEach(([key, value]) => { - if (key.startsWith("@media")) { - styleString += `\n${key} { ${selector} { ${this.rulesToString( - value - )} } }`; - } else if (key.startsWith("&")) { - styleString += `\n${selector}${key.slice(1)} { ${this.rulesToString( - value - )} }`; - } - }); - - return styleString; + if (!rules || typeof rules !== 'object') return ''; + + const baseStyles = this.rulesToString(rules); + let styleString = `${selector} { ${baseStyles} }`; + + Object.entries(rules) + .filter(([key, value]) => typeof value === 'object') + .forEach(([key, value]) => { + if (key.startsWith('@media')) { + styleString += `\n${key} { ${selector} { ${this.rulesToString(value)} } }`; + } else if (key.startsWith('&')) { + styleString += `\n${selector}${key.slice(1)} { ${this.rulesToString(value)} }`; + } + }); + + return styleString; } - + rulesToString(rules) { - return Object.entries(rules) - .filter(([key, value]) => typeof value !== "object") - .map(([key, value]) => `${this.camelToKebab(key)}: ${value};`) - .join(" "); + return Object.entries(rules) + .filter(([key, value]) => typeof value !== 'object') + .map(([key, value]) => `${this.camelToKebab(key)}: ${value};`) + .join(' '); } - + camelToKebab(string) { - return string - .replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1-$2") - .toLowerCase(); + return string.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase(); } - + validateStyles() { - Object.keys(this.globalStyles).forEach((key) => { - if (!Survey.styleKeys.includes(key)) { - console.warn( - `Invalid style key '${key}' for Survey. Valid keys are: ${Survey.styleKeys.join( - ", " - )}` - ); + Object.keys(this.globalStyles).forEach(key => { + if (!Survey.styleKeys.includes(key)) { + console.warn(`Invalid style key '${key}' for Survey. Valid keys are: ${Survey.styleKeys.join(', ')}`); + } + }); + } + + revealContent() { + const surveyContainer = document.getElementById('survey-container'); + if (surveyContainer) { + surveyContainer.classList.remove('hidden'); + } else { + console.warn('Survey container not found'); } - }); } - + addPlugin(plugin) { - if (typeof plugin.initialize === "function") { - this.plugins.push(plugin); - plugin.initialize(this); - } else { - console.warn("Invalid plugin: missing initialize method"); - } + if (typeof plugin.initialize === 'function') { + this.plugins.push(plugin); + plugin.initialize(this); + } else { + console.warn('Invalid plugin: missing initialize method'); + } + } + + removePlugin(plugin) { + const index = this.plugins.indexOf(plugin); + if (index > -1) { + plugin.destroy(); + this.plugins.splice(index, 1); + return true; + } + return false; } - + setSurveyDetail(key, value) { - this.surveyDetails[key] = value; + this.surveyDetails[key] = value; } - + getSurveyDetail(key) { - return this.surveyDetails[key]; + return this.surveyDetails[key]; } - + async showPage(page) { - try { - // Clean up elements from the previous page - this.cleanupCurrentPage(); - - this.currentPage = page; - - for (const plugin of this.plugins) { - await plugin.beforePageRender(page); - } - - const pageContainer = document.getElementById("page-container"); - if (!pageContainer) throw new Error("Page container not found"); - - pageContainer.innerHTML = ""; - const questionContainer = document.createElement("div"); - questionContainer.id = "question-container"; - pageContainer.appendChild(questionContainer); - - // Render new elements and keep track of them - this.currentPageElements = []; - for (const element of page.elements) { - element.render(); - this.currentPageElements.push(element); - } - - await this.setupNextButton(); - - for (const plugin of this.plugins) { - await plugin.afterPageRender(page); + try { + this.cleanupCurrentPage(); + + this.currentPage = page; + + for (const plugin of this.plugins) { + await plugin.beforePageRender(page); + } + + const pageContainer = document.getElementById('page-container'); + if (!pageContainer) throw new Error('Page container not found'); + + pageContainer.innerHTML = ''; + const questionContainer = document.createElement('div'); + questionContainer.id = 'question-container'; + pageContainer.appendChild(questionContainer); + + this.currentPageElements = []; + for (const element of page.elements) { + element.render(this.elementStyles); + this.currentPageElements.push(element); + } + + await this.setupNextButton(); + + for (const plugin of this.plugins) { + await plugin.afterPageRender(page); + } + } catch (error) { + console.error('Error showing page:', error); } - } catch (error) { - console.error("Error showing page:", error); - } } - + cleanupCurrentPage() { - if (this.currentPageElements && this.currentPageElements.length > 0) { - for (const element of this.currentPageElements) { - if (typeof element.destroy === "function") { - element.destroy(); - } else { - console.warn(`Element ${element.id} does not have a destroy method`); - } + if (this.currentPageElements && this.currentPageElements.length > 0) { + for (const element of this.currentPageElements) { + if (typeof element.destroy === 'function') { + element.destroy(); + } else { + console.warn(`Element ${element.id} does not have a destroy method`); + } + } + this.currentPageElements = []; + } + + const nextButton = document.getElementById('next-button'); + if (nextButton && this.nextButtonListener) { + nextButton.removeEventListener('click', this.nextButtonListener); + this.nextButtonListener = null; } - this.currentPageElements = []; - } - - // Clean up the next button listener - const nextButton = document.getElementById("next-button"); - if (nextButton && this.nextButtonListener) { - nextButton.removeEventListener("click", this.nextButtonListener); - this.nextButtonListener = null; - } } - + async setupNextButton() { - const nextButton = document.getElementById("next-button"); - if (!nextButton) throw new Error("Next button not found"); - - const nextButtonError = document.createElement("span"); - nextButtonError.id = "next-button-error"; - nextButtonError.className = "next-button-error"; - nextButtonError.style.display = "none"; - nextButton.parentNode.insertBefore(nextButtonError, nextButton.nextSibling); - - if (this.nextButtonListener) { - nextButton.removeEventListener("click", this.nextButtonListener); - } - - return new Promise((resolve) => { - this.nextButtonListener = async () => { - try { - if (await this.validateCurrentPage()) { - this.collectPageData(this.currentPage); - nextButtonError.style.display = "none"; - nextButtonError.textContent = ""; - resolve(); - } else { - nextButtonError.style.display = "inline-block"; - nextButtonError.textContent = "Please check your answers."; - } - } catch (error) { - console.error("Error in next button handler:", error); - } - }; - - nextButton.addEventListener("click", this.nextButtonListener); - }); + const nextButton = document.getElementById('next-button'); + if (!nextButton) throw new Error('Next button not found'); + + const nextButtonError = document.createElement('span'); + nextButtonError.id = 'next-button-error'; + nextButtonError.className = 'next-button-error'; + nextButtonError.style.display = 'none'; + nextButton.parentNode.insertBefore(nextButtonError, nextButton.nextSibling); + + if (this.nextButtonListener) { + nextButton.removeEventListener('click', this.nextButtonListener); + } + + return new Promise(resolve => { + this.nextButtonListener = async () => { + try { + if (await this.validateCurrentPage()) { + this.collectPageData(this.currentPage); + nextButtonError.style.display = 'none'; + nextButtonError.textContent = ''; + resolve(); + } else { + nextButtonError.style.display = 'inline-block'; + nextButtonError.textContent = 'Please check your answers.'; + } + } catch (error) { + console.error('Error in next button handler:', error); + } + }; + + nextButton.addEventListener('click', this.nextButtonListener); + }); } - + async validateCurrentPage() { - let isValid = true; - for (const element of this.currentPage.elements) { - if (typeof element.validate === "function") { - const elementValid = await element.validate(true); - if (!elementValid) { - isValid = false; - } + let isValid = true; + for (const element of this.currentPage.elements) { + if (typeof element.validate === 'function') { + const { isValid: elementValid, errorMessage } = await element.validate(); + if (!elementValid) { + isValid = false; + element.showValidationError(errorMessage); + } else { + element.showValidationError(null); // Clear any previous error + } + } } - } - return isValid; + return isValid; } - + collectPageData(page) { - page.elements.forEach((element) => { - const elementData = element.collectData(); - if (elementData !== null) { - this.updateData(elementData); - } - }); + page.elements.forEach(element => { + const elementData = element.collectData(); + if (elementData !== null) { + this.updateData(elementData); + } + }); } - + updateData(elementData) { - this.responses.push({ - ...elementData, - responseTimestamp: new Date().toISOString(), - }); + this.responses.push({ + ...elementData, + responseTimestamp: new Date().toISOString() + }); } - + getResponse(questionId) { - const responses = this.responses.filter((r) => r.id === questionId); - return responses.length > 0 - ? responses[responses.length - 1].response - : null; + const responses = this.responses.filter(r => r.id === questionId); + return responses.length > 0 ? responses[responses.length - 1].response : null; } - + responded(questionId) { - const responses = this.responses.filter((r) => r.id === questionId); - return responses.length > 0 - ? responses[responses.length - 1].responded - : false; + const responses = this.responses.filter(r => r.id === questionId); + return responses.length > 0 ? responses[responses.length - 1].responded : false; } - + getAllResponses() { - return this.responses; + return this.responses; } - + getAllSurveyData() { - return { - surveyDetails: this.surveyDetails, - responses: this.responses, - }; + return { + surveyDetails: this.surveyDetails, + responses: this.responses + }; } - + finishSurvey(message) { - try { - // Clean up elements from the last page - this.cleanupCurrentPage(); - - // Remove navigation elements - const navigation = document.getElementById("navigation"); - if (navigation) navigation.remove(); - - for (const plugin of this.plugins) { - plugin.beforeSurveyFinish(); - } - - // Clear the page container - const pageContainer = document.getElementById("page-container"); - if (pageContainer) { - pageContainer.innerHTML = ""; - } - - // Display the finish message - const finishElement = - document.getElementById("finish") || document.createElement("div"); - finishElement.id = "finish"; - finishElement.innerHTML = message; - finishElement.style.display = "block"; - - // If the finish element doesn't exist in the DOM, append it - if (!document.getElementById("finish")) { - const surveyContainer = document.getElementById("survey-container"); - if (surveyContainer) { - surveyContainer.appendChild(finishElement); - } else { - console.error("Survey container not found"); - } + try { + this.cleanupCurrentPage(); + + const navigation = document.getElementById('navigation'); + if (navigation) navigation.remove(); + + for (const plugin of this.plugins) { + plugin.beforeSurveyFinish(); + } + + const pageContainer = document.getElementById('page-container'); + if (pageContainer) { + pageContainer.innerHTML = ''; + } + + const finishElement = document.getElementById('finish') || document.createElement('div'); + finishElement.id = 'finish'; + finishElement.innerHTML = message; + finishElement.style.display = 'block'; + + if (!document.getElementById('finish')) { + const surveyContainer = document.getElementById('survey-container'); + if (surveyContainer) { + surveyContainer.appendChild(finishElement); + } else { + console.error('Survey container not found'); + } + } + + this.surveyDetails.endTime = new Date().toISOString(); + + } catch (error) { + console.error('Error finishing survey:', error); } - - this.surveyDetails.endTime = new Date().toISOString(); - } catch (error) { - console.error("Error finishing survey:", error); - } } - } - - export default Survey; - \ No newline at end of file +} + +export default Survey; \ No newline at end of file diff --git a/library/elements/HTML.js b/library/elements/HTML.js index ec09894..3fbfb89 100644 --- a/library/elements/HTML.js +++ b/library/elements/HTML.js @@ -1,28 +1,30 @@ import Element from '../core/element.js'; class HTML extends Element { - static styleKeys = ['root'] + static styleKeys = [...Element.styleKeys]; - static defaultStyles = { - root: { - background: 'white', - } + static selectorMap = { + ...Element.selectorMap }; + static defaultStyles = { }; + constructor({ id, content, styles = {} }) { - super({ id, type: 'html', store_data: false, required: false }); + super({ id, type: 'html', store_data: false, required: false, styles }); if (typeof content !== 'string' || content.trim() === '') { throw new Error('Content must be a non-empty string'); } this.content = content; - this.mergeStyles(HTML.defaultStyles, styles); this.rendered = false; + this.required = false; + this.elementStyleKeys = [...HTML.styleKeys]; + this.selectorMap = { ...HTML.selectorMap }; } getSelectorForKey(key) { - return key === 'root' ? '' : key; + return this.selectorMap[key] || ''; } generateHTML() { @@ -33,7 +35,7 @@ class HTML extends Element { `; } - render() { + render(surveyElementStyles) { if (this.rendered) { // If already rendered, update the content instead of recreating const container = document.getElementById(`${this.id}-container`); @@ -44,7 +46,7 @@ class HTML extends Element { } // If not rendered or container not found, render as usual - super.render(); + super.render(surveyElementStyles); this.rendered = true; } @@ -66,8 +68,11 @@ class HTML extends Element { } validate() { - // HTML elements are always valid - return true; + return {isValid: true, errorMessage: ''}; + } + + showValidationError() { + // Do nothing, HTML elements don't show validation errors } } diff --git a/library/elements/boundingBox.js b/library/elements/boundingBox.js index 92b56bf..767c7be 100644 --- a/library/elements/boundingBox.js +++ b/library/elements/boundingBox.js @@ -1,21 +1,17 @@ import Element from '../core/element.js'; class BoundingBox extends Element { - static styleKeys = ['root', 'innerContainer', 'label', 'canvas', 'controls', 'controlPoint', 'button', 'errorMessage']; + static styleKeys = [...Element.styleKeys, 'canvas', 'controls', 'controlPoint', 'button']; + + static selectorMap = { + ...Element.selectorMap, + canvas: 'canvas', + controls: '.controls', + controlPoint: '.control-point', + button: 'button' + }; static defaultStyles = { - root: { - position: 'relative', - marginBottom: '20px', - width: '100%', - }, - innerContainer: { }, - label: { - display: 'block', - fontSize: '1.1em', - fontWeight: 'bold', - marginBottom: '10px', - }, canvas: { cursor: 'crosshair', display: 'block', @@ -50,40 +46,66 @@ class BoundingBox extends Element { cursor: 'not-allowed', } }, - errorMessage: { - color: '#fa5252', - fontSize: '0.9em', - marginTop: '5px', - }, }; constructor({ id, text, + subText = '', imageUrl, boxColor = '#FF0000', boxOpacity = 0.3, maxBoxes = Infinity, required = true, + customValidation = null, styles = {} }) { - super({ id, type: 'bounding-box', store_data: true, required }); + super({ id, type: 'bounding-box', store_data: true, required, customValidation, styles }); if (!imageUrl) { throw new Error('Image URL is required'); } this.text = text; + this.subText = subText; this.imageUrl = imageUrl; this.boxColor = boxColor; this.boxOpacity = boxOpacity; this.maxBoxes = maxBoxes; - this.mergeStyles(BoundingBox.defaultStyles, styles); + this.addData('text', text); + this.addData('subText', subText); + this.addData('imageUrl', imageUrl); + this.addData('boxColor', boxColor); + this.addData('boxOpacity', boxOpacity); + this.addData('maxBoxes', maxBoxes); this.initializeState(); this.bindEventHandlers(); - this.setInitialResponse([]); + this.initialResponse = []; + + this.elementStyleKeys = [...BoundingBox.styleKeys]; + this.selectorMap = { ...BoundingBox.selectorMap }; + } + + generateHTML() { + return ` +
+
+
+ + ${this.subText ? `${this.subText}` : ''} +
+ +
+ + + +
+
+ +
+ `; } initializeState() { @@ -115,37 +137,6 @@ class BoundingBox extends Element { this.handleResize = this.handleResize.bind(this); } - getSelectorForKey(key) { - const selectorMap = { - root: '', - innerContainer: `#${this.id}-inner-container`, - label: '.question-label', - canvas: 'canvas', - controls: '.controls', - controlPoint: '.control-point', - button: 'button', - errorMessage: '.error-message' - }; - return selectorMap[key] || ''; - } - - generateHTML() { - return ` -
-
- - -
- - - -
-
- -
- `; - } - attachEventListeners() { this.canvas = document.getElementById(`${this.id}-canvas`); this.ctx = this.canvas.getContext('2d'); @@ -581,17 +572,20 @@ class BoundingBox extends Element { setResponse(value) { super.setResponse(value, value.length > 0); - this.showValidationError(''); + this.showValidationError(null); } - validate(showError = false) { - const isValid = !this.required || this.boxes.length > 0; - if (showError && !isValid) { - this.showValidationError('Please draw at least one bounding box.'); - } else { - this.showValidationError(''); + validate() { + // BoundingBox-specific validation + if (this.required && this.boxes.length === 0) { + return { + isValid: false, + errorMessage: 'Please draw at least one bounding box.' + }; } - return isValid; + + // If BoundingBox-specific validation passed, call parent's validate method + return super.validate(); } destroy() { diff --git a/library/elements/checkBox.js b/library/elements/checkBox.js index 2f7ad1f..fdd50b4 100644 --- a/library/elements/checkBox.js +++ b/library/elements/checkBox.js @@ -1,62 +1,64 @@ import Element from '../core/element.js'; class CheckBox extends Element { - static styleKeys = ['root', 'innerContainer', 'label', 'checkbox', 'errorMessage']; + static styleKeys = [...Element.styleKeys, 'checkboxFlexContainer', 'checkbox', 'checkboxLabel'] + + static selectorMap = { + ...Element.selectorMap, + checkboxFlexContainer: '.checkbox-flex-container', + checkbox: 'input[type="checkbox"]', + checkboxLabel: '.checkbox-label', + }; static defaultStyles = { - root: { - marginBottom: '20px', - fontSize: '1em', - }, - innerContainer: { + checkboxFlexContainer: { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: '5px', - }, - label: { - display: 'block', - fontSize: '1.1em', - fontWeight: 'bold', + background: 'white', }, checkbox: { - height: '16px', - width: '16px', + width: '20px', + height: '20px', + accentColor: 'black', + borderColor: 'black', + backgroundColor: 'transparent', + marginRight: '5px', + '@media (max-width: 650px)': { + width: '16px', + height: '16px' + } }, - errorMessage: { - color: '#fa5252', - fontSize: '0.9em', - marginTop: '5px', - } + checkboxLabel: { } }; - constructor({ id, text, required = true, styles = {} }) { - super({ id, type: 'checkbox', store_data: true, required }); + constructor({ + id, + text, + required = true, + customValidation = null, + styles = {} + }) { + super({ id, type: 'checkbox', store_data: true, required, customValidation, styles }); this.text = text; - this.mergeStyles(CheckBox.defaultStyles, styles); this.addData('text', text); - this.setInitialResponse(false); - } + this.initialResponse = false; - getSelectorForKey(key) { - const selectorMap = { - root: '', - innerContainer: `#${this.id}-inner-container`, - label: 'label', - checkbox: 'input[type="checkbox"]', - errorMessage: '.error-message' - }; - return selectorMap[key] || ''; + this.elementStyleKeys = [...CheckBox.styleKeys]; + this.selectorMap = { ...CheckBox.selectorMap }; } generateHTML() { return `
-
- - +
+
+ + +
- `; } @@ -70,16 +72,20 @@ class CheckBox extends Element { setResponse(value) { super.setResponse(Boolean(value)); + this.showValidationError(null); } - validate(showError = false) { - const isValid = !this.required || this.data.response === true; - if (showError && !isValid) { - this.showValidationError('This checkbox is required.'); - } else { - this.showValidationError(''); + validate() { + // CheckBox-specific validation + if (this.required && this.data.response !== true) { + return { + isValid: false, + errorMessage: 'This checkbox is required.' + }; } - return isValid; + + // If CheckBox-specific validation passed, call parent's validate method + return super.validate(); } } diff --git a/library/elements/dropdownSelect.js b/library/elements/dropdownSelect.js new file mode 100644 index 0000000..2cc145b --- /dev/null +++ b/library/elements/dropdownSelect.js @@ -0,0 +1,114 @@ +import Element from '../core/element.js'; + +class DropdownSelect extends Element { + static styleKeys = [...Element.styleKeys, 'select', 'option']; + + static selectorMap = { + ...Element.selectorMap, + select: 'select', + option: 'option' + }; + + static defaultStyles = { + select: { + width: '100%', + cursor: 'pointer', + padding: '12px', + border: '1px solid #ccc', + borderRadius: '8px', + fontSize: '1em', + marginBottom: '0px', + display: 'block', + appearance: 'none', + backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'12\' height=\'12\' fill=\'%23333\' viewBox=\'0 0 16 16\'%3E%3Cpath d=\'M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z\'/%3E%3C/svg%3E")', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'right 12px center', + backgroundSize: '12px', + '&:focus': { + borderColor: 'black', + outline: 'none', + }, + }, + option: { + padding: '8px', + } + }; + + constructor({ + id, + text, + subText = '', + options, + required = true, + placeholder = 'Select an option', + customValidation = null, + styles = {}, + }) { + super({ id, type: 'dropdown-select', store_data: true, required, customValidation, styles }); + + if (!Array.isArray(options) || options.length === 0) { + throw new Error('Options must be a non-empty array'); + } + + this.text = text; + this.subText = subText; + this.options = options; + this.placeholder = placeholder; + + this.addData('text', text); + this.addData('subText', subText); + this.addData('options', options); + + this.initialResponse = ''; + this.elementStyleKeys = [...DropdownSelect.styleKeys]; + this.selectorMap = { ...DropdownSelect.selectorMap }; + } + + generateHTML() { + const optionsHTML = this.options.map(option => + `` + ).join(''); + + return ` + + `; + } + + attachEventListeners() { + const select = document.getElementById(this.id); + this.addEventListenerWithTracking(select, 'change', this.handleChange.bind(this)); + } + + handleChange(e) { + const value = e.target.value; + this.setResponse(value); + } + + validate() { + const value = this.data.response; + + if (!value && this.required) { + return { isValid: false, errorMessage: 'Please select an option.' }; + } + + if (value && !this.options.includes(value)) { + return { isValid: false, errorMessage: 'Selected option is not valid.' }; + } + + return super.validate(); + } +} + +export default DropdownSelect; \ No newline at end of file diff --git a/library/elements/grid.js b/library/elements/grid.js index 3c92e75..8ab9a46 100644 --- a/library/elements/grid.js +++ b/library/elements/grid.js @@ -1,72 +1,93 @@ import Element from '../core/element.js'; class Grid extends Element { - static styleKeys = ['root', 'innerContainer', 'label', 'subText', 'table', 'headerRow', 'headerCell', 'row', 'rowLabel', 'cell', 'radio', 'errorMessage']; + static styleKeys = [...Element.styleKeys, 'table', 'headerRow', 'headerCell', 'rowWrapper', 'row', 'rowLabel', 'cell', 'radio']; + + static selectorMap = { + ...Element.selectorMap, + table: 'table', + headerRow: 'thead tr', + headerCell: 'thead th', + rowWrapper: '.row-wrapper', + row: 'tbody tr', + rowLabel: 'tbody td.row-label', + cell: 'tbody td', + radio: 'input[type="radio"]' + }; static defaultStyles = { - root: { - marginBottom: '20px', - borderRadius: '5px' - }, - innerContainer: { }, - label: { - display: 'block', - marginBottom: '5px', - fontWeight: 'bold', - fontSize: '1.1em', - }, - subText: { - display: 'block', - marginBottom: '10px', - color: '#6c757d', - fontSize: '1.1em', - }, table: { width: '100%', - borderCollapse: 'collapse' - }, - headerRow: { - backgroundColor: '#f2f2f2' + borderCollapse: 'separate', + borderSpacing: '0 10px', + lineHeight: '1', + '@media (max-width: 650px)': { + fontSize: '0.9em' + } }, + headerRow: {}, headerCell: { - padding: '10px', + padding: '0px 16px', textAlign: 'center', - fontWeight: 'bold' + fontWeight: 'normal', + '@media (max-width: 650px)': { + padding: '0px 12px' + } }, row: { - borderBottom: '1px solid #dee2e6' + backgroundColor: '#f0f0f0', + borderRadius: '12px', }, rowLabel: { - padding: '10px', - fontWeight: 'bold', - textAlign: 'left' + padding: '12px 16px', + textAlign: 'left', + borderTopLeftRadius: '8px', + borderBottomLeftRadius: '8px', + '@media (max-width: 650px)': { + padding: '12px' + } }, cell: { - padding: '10px', - textAlign: 'center' + textAlign: 'center', + verticalAlign: 'middle', }, radio: { - margin: '0 auto' - }, - errorMessage: { - color: '#fa5252', - fontSize: '0.9em', - marginTop: '5px', + appearance: 'none', + width: '20px', + height: '20px', + borderRadius: '50%', + border: '1px solid black', + border: '1px solid #767676', + outline: 'none', + margin: '0 auto', + background: 'white', + cursor: 'pointer', + verticalAlign: 'middle', + '&:checked': { + backgroundColor: 'black', + boxShadow: 'inset 0 0 0 3px #ffffff' + }, + '@media (max-width: 650px)': { + width: '16px', + height: '16px' + } } }; - constructor({ - id, - text, - subText = '', - rows, - columns, - required = true, - randomizeRows = false, - randomizeColumns = false, - styles = {} + + constructor({ + id, + text, + subText = '', + rows, + columns, + required = true, + randomizeRows = false, + randomizeColumns = false, + customValidation = null, + styles = {} }) { - super({ id, type: 'grid', store_data: true, required }); + super({ id, type: 'grid', store_data: true, required, customValidation, styles }); if (!Array.isArray(rows) || rows.length === 0 || !Array.isArray(columns) || columns.length === 0) { throw new Error('Rows and columns must be non-empty arrays'); @@ -79,8 +100,6 @@ class Grid extends Element { this.randomizeRows = Boolean(randomizeRows); this.randomizeColumns = Boolean(randomizeColumns); - this.mergeStyles(Grid.defaultStyles, styles); - this.addData('text', text); this.addData('subText', subText); this.addData('rows', rows); @@ -88,25 +107,10 @@ class Grid extends Element { this.addData('randomizeRows', this.randomizeRows); this.addData('randomizeColumns', this.randomizeColumns); - this.setInitialResponse({}); - } + this.initialResponse = {}; - getSelectorForKey(key) { - const selectorMap = { - root: '', - innerContainer: `#${this.id}-inner-container`, - label: '.question-label', - subText: '.question-subtext', - table: 'table', - headerRow: 'thead tr', - headerCell: 'thead th', - row: 'tbody tr', - rowLabel: 'tbody td.row-label', - cell: 'tbody td', - radio: 'input[type="radio"]', - errorMessage: '.error-message' - }; - return selectorMap[key] || ''; + this.elementStyleKeys = [...Grid.styleKeys]; + this.selectorMap = { ...Grid.selectorMap }; } generateHTML() { @@ -134,9 +138,11 @@ class Grid extends Element { return `
-
- - ${this.subText ? `${this.subText}` : ''} +
+
+ + ${this.subText ? `${this.subText}` : ''} +
${headerRow}${bodyRows} @@ -147,6 +153,18 @@ class Grid extends Element { `; } + generateStylesheet(surveyElementStyles) { + const stylesheet = super.generateStylesheet(surveyElementStyles); + + // Add styles for the last cell in each row + const lastCellStyles = this.generateStyleForSelector(`${this.getSelectorForKey('cell')}:last-child`, { + borderTopRightRadius: '8px', + borderBottomRightRadius: '8px', + }); + + return stylesheet + '\n' + lastCellStyles; + } + shuffleArray(array) { return array.sort(() => Math.random() - 0.5); } @@ -174,22 +192,23 @@ class Grid extends Element { } setResponse(value) { - const valueHasEntries = Object.values(value).some(val => val !== null); - super.setResponse(value, valueHasEntries); - this.showValidationError(''); + super.setResponse(value); + this.showValidationError(null); } - validate(showError = false) { + validate() { + // Grid-specific validation const unansweredRows = this.rows.filter(row => !this.data.response[row]); - const isValid = !this.required || unansweredRows.length === 0; - - if (showError && !isValid) { - this.showValidationError(`Please provide a response for all rows. Missing: ${unansweredRows.join(', ')}`); - } else { - this.showValidationError(''); + + if (unansweredRows.length > 0) { + return { + isValid: false, + errorMessage: `Please provide a response for all rows. Missing: ${unansweredRows.join(', ')}` + }; } - return isValid; + // If Grid-specific validation passed, call parent's validate method + return super.validate(); } } diff --git a/library/elements/multiSelect.js b/library/elements/multiSelect.js index ac83ae4..8f81d0c 100644 --- a/library/elements/multiSelect.js +++ b/library/elements/multiSelect.js @@ -1,204 +1,172 @@ -import Element from "../core/element.js"; +import Element from '../core/element.js'; class MultiSelect extends Element { - static styleKeys = [ - "questionRoot", - "questionInnerContainer", - "questionLabel", - "questionSubText", - "optionsContainer", - "option", - "checkbox", - "errorMessage", - ]; - - static defaultStyles = { - questionRoot: { - marginBottom: "20px", - borderRadius: "5px", - }, - questionInnerContainer: {}, - questionLabel: { - display: "block", - marginBottom: "5px", - fontWeight: "bold", - fontSize: "1.1em", - }, - questionSubText: { - display: "block", - marginBottom: "10px", - color: "#6c757d", - fontSize: "1.1em", - }, - optionsContainer: { - display: "flex", - flexDirection: "column", - }, - option: { - marginBottom: "5px", - }, - checkbox: { - marginRight: "5px", - }, - errorMessage: { - color: "#fa5252", - fontSize: "0.9em", - marginTop: "5px", - }, - }; - - constructor({ - id, - text, - subText = "", - options, - required = true, - randomize = false, - minSelected = 0, - maxSelected = null, - styles = {}, - }) { - super({ id, type: "multi-select", store_data: true, required }); - - if (!Array.isArray(options) || options.length === 0) { - throw new Error("Options must be a non-empty array"); - } - if ( - minSelected < 0 || - (maxSelected !== null && minSelected > maxSelected) - ) { - throw new Error("Invalid minSelected or maxSelected values"); - } + static styleKeys = [...Element.styleKeys, 'optionsContainer', 'option', 'checkbox', 'label']; + + static selectorMap = { + ...Element.selectorMap, + optionsContainer: '.options-container', + option: '.option', + checkbox: 'input[type="checkbox"]', + label: 'label' + }; - this.text = text; - this.subText = subText; - this.options = options; - this.randomize = Boolean(randomize); - this.minSelected = minSelected; - this.maxSelected = maxSelected; - - this.mergeStyles(MultiSelect.defaultStyles, styles); - - this.addData("text", text); - this.addData("subText", subText); - this.addData("options", options); - this.addData("randomize", this.randomize); - this.addData("minSelected", minSelected); - this.addData("maxSelected", maxSelected); - this.setInitialResponse([]); - } - - getSelectorForKey(key) { - const selectorMap = { - questionRoot: "", - questionInnerContainer: `#${this.id}-inner-container`, - questionLabel: ".question-label", - questionSubText: ".question-subtext", - optionsContainer: ".options-container", - option: ".option", - checkbox: 'input[type="checkbox"]', - errorMessage: ".error-message", + static defaultStyles = { + optionsContainer: { + display: 'flex', + flexDirection: 'column', + gap: '10px', + lineHeight: '1', + }, + option: { + backgroundColor: '#f0f0f0', + borderRadius: '8px', + padding: '12px 16px', + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + '&:hover': { + backgroundColor: '#e0e0e0', + '@media (max-width: 650px)': { + backgroundColor: '#f0f0f0' + } + }, + '@media (max-width: 650px)': { + padding: '12px' + } + }, + + checkbox: { + width: '20px', + height: '20px', + accentColor: 'black', + borderColor: 'black', + backgroundColor: 'transparent', + margin: '0', + marginRight: '10px', + '@media (max-width: 650px)': { + width: '16px', + height: '16px' + } + }, + label: { + cursor: 'pointer', + flexGrow: 1 + } }; - return selectorMap[key] || ""; - } - - generateHTML() { - let optionsHTML = this.randomize - ? this.shuffleArray([...this.options]) - : this.options; - - const optionsString = optionsHTML - .map( - (option, index) => ` -
+ + constructor({ + id, + text, + subText = '', + options, + required = true, + randomize = false, + minSelected = 0, + maxSelected = null, + customValidation = null, + styles = {} + }) { + super({ id, type: 'multi-select', store_data: true, required, customValidation, styles }); + + if (!Array.isArray(options) || options.length === 0) { + throw new Error('Options must be a non-empty array'); + } + if (minSelected < 0 || (maxSelected !== null && minSelected > maxSelected)) { + throw new Error('Invalid minSelected or maxSelected values'); + } + + this.text = text; + this.subText = subText; + this.options = options; + this.randomize = Boolean(randomize); + this.minSelected = minSelected; + this.maxSelected = maxSelected; + + this.addData('text', text); + this.addData('subText', subText); + this.addData('options', options); + this.addData('randomize', this.randomize); + this.addData('minSelected', minSelected); + this.addData('maxSelected', maxSelected); + this.initialResponse = []; + + this.elementStyleKeys = [...MultiSelect.styleKeys]; + this.selectorMap = { ...MultiSelect.selectorMap }; + } + + generateHTML() { + let optionsHTML = this.randomize ? this.shuffleArray([...this.options]) : this.options; + + const optionsString = optionsHTML.map((option, index) => ` +
- ` - ) - .join(""); + `).join(''); - return ` + return `
-
- - ${ - this.subText - ? `${this.subText}` - : "" - } +
+
+ + ${this.subText ? `${this.subText}` : ''} +
${optionsString}
- +
`; - } + } + attachEventListeners() { + const container = document.getElementById(`${this.id}-container`); + this.addEventListenerWithTracking(container, 'click', this.handleClick.bind(this)); + } - shuffleArray(array) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; + handleClick(e) { + const optionDiv = e.target.closest('.option'); + if (optionDiv) { + const checkbox = optionDiv.querySelector('input[type="checkbox"]'); + checkbox.checked = !checkbox.checked; + this.updateResponse(); + } } - return array; - } - - attachEventListeners() { - const container = document.getElementById(`${this.id}-container`); - this.addEventListenerWithTracking( - container, - "change", - this.handleChange.bind(this) - ); - } - - handleChange(e) { - if (e.target.type === "checkbox") { - this.updateResponse(); + + updateResponse() { + const container = document.getElementById(`${this.id}-container`); + const checkedBoxes = container.querySelectorAll(`input[name="${this.id}"]:checked`); + const selectedOptions = Array.from(checkedBoxes).map(cb => cb.value); + this.setResponse(selectedOptions); } - } - - updateResponse() { - const container = document.getElementById(`${this.id}-container`); - const checkedBoxes = container.querySelectorAll( - `input[name="${this.id}"]:checked` - ); - const selectedOptions = Array.from(checkedBoxes).map((cb) => cb.value); - this.setResponse(selectedOptions); - } - - setResponse(value) { - super.setResponse(value, value.length > 0); - this.showValidationError(""); - } - - validate(showError = false) { - const selectedCount = this.data.response ? this.data.response.length : 0; - let isValid = true; - let errorMessage = ""; - - if (this.required && selectedCount === 0) { - isValid = false; - errorMessage = "Please select at least one option."; - } else if (this.minSelected > 0 && selectedCount < this.minSelected) { - isValid = false; - errorMessage = `Please select at least ${this.minSelected} option(s).`; - } else if (this.maxSelected !== null && selectedCount > this.maxSelected) { - isValid = false; - errorMessage = `Please select no more than ${this.maxSelected} option(s).`; + + setResponse(value) { + super.setResponse(value); + this.showValidationError(null); } - if (showError) { - this.showValidationError(errorMessage); - } else { - this.showValidationError(""); + validate() { + const selectedCount = this.data.response ? this.data.response.length : 0; + + if (this.minSelected > 0 && selectedCount < this.minSelected) { + return { isValid: false, errorMessage: `Please select at least ${this.minSelected} option(s).` }; + } + + if (this.maxSelected !== null && selectedCount > this.maxSelected) { + return { isValid: false, errorMessage: `Please select no more than ${this.maxSelected} option(s).` }; + } + + return super.validate(); } - return isValid; - } + shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + } } export default MultiSelect; \ No newline at end of file diff --git a/library/elements/numberEntry.js b/library/elements/numberEntry.js index d099cfa..c6872cf 100644 --- a/library/elements/numberEntry.js +++ b/library/elements/numberEntry.js @@ -1,25 +1,15 @@ import Element from '../core/element.js'; class NumberEntry extends Element { - static styleKeys = ['root', 'innerContainer','label', 'subText', 'input', 'unit', 'errorMessage']; + static styleKeys = [...Element.styleKeys, 'input', 'unit']; + + static selectorMap = { + ...Element.selectorMap, + input: 'input[type="number"]', + unit: '.unit-label' + }; static defaultStyles = { - root: { - marginBottom: '20px', - }, - innerContainer: { }, - label: { - display: 'block', - marginBottom: '5px', - fontWeight: 'bold', - fontSize: '1.1em', - }, - subText: { - display: 'block', - marginBottom: '10px', - color: '#6c757d', - fontSize: '1.1em', - }, input: { width: '80px', padding: '8px', @@ -30,43 +20,39 @@ class NumberEntry extends Element { unit: { marginLeft: '5px', fontSize: '0.9em', - }, - errorMessage: { - marginTop: '5px', - color: '#fa5252', - fontSize: '0.9em', } }; - constructor({ id, text, subText = '', min = null, max = null, step = 1, unit = '', required = true, styles = {} }) { - super({ id, type: 'number-entry', store_data: true, required }); + constructor({ + id, + text, + subText = '', + min = null, + max = null, + step = 1, + unit = '', + required = true, + customValidation = null, + styles = {} + }) { + super({ id, type: 'number-entry', store_data: true, required, customValidation, styles }); this.text = text; this.subText = subText; this.min = min; this.max = max; this.step = step; this.unit = unit; - this.mergeStyles(NumberEntry.defaultStyles, styles); + this.addData('text', text); this.addData('subText', subText); this.addData('min', min); this.addData('max', max); this.addData('step', step); this.addData('unit', unit); - this.setInitialResponse(''); - } + this.initialResponse = ''; - getSelectorForKey(key) { - const selectorMap = { - root: '', - innerContainer: `#${this.id}-inner-container`, - label: 'label', - subText: '.question-subtext', - input: 'input[type="number"]', - unit: '.unit-label', - errorMessage: '.error-message' - }; - return selectorMap[key] || ''; + this.elementStyleKeys = [...NumberEntry.styleKeys]; + this.selectorMap = { ...NumberEntry.selectorMap }; } generateHTML() { @@ -75,9 +61,11 @@ class NumberEntry extends Element { return `
-
- - ${this.subText ? `${this.subText}` : ''} +
+
+ + ${this.subText ? `${this.subText}` : ''} +
{ + this.addEventListenerWithTracking(input, 'input', (e) => { this.setResponse(e.target.value); }); } setResponse(value) { - super.setResponse(value, value !== ''); + super.setResponse(value); this.addData('numericValue', value !== '' ? parseFloat(value) : null); + this.showValidationError(null); } - validate(showError = false) { + validate() { const value = this.data.response; - let isValid = true; - let errorMessage = ''; - if (this.required && value === '') { - isValid = false; - errorMessage = 'This field is required.'; - } else if (value !== '') { + // NumberEntry-specific validation + if (value !== '') { const numValue = parseFloat(value); + if (isNaN(numValue)) { - isValid = false; - errorMessage = 'Please enter a valid number.'; - } else if (this.min !== null && numValue < this.min) { - isValid = false; - errorMessage = `Please enter a number greater than or equal to ${this.min}.`; - } else if (this.max !== null && numValue > this.max) { - isValid = false; - errorMessage = `Please enter a number less than or equal to ${this.max}.`; + return { isValid: false, errorMessage: 'Please enter a valid number.' }; } - } - if (showError) { - this.showValidationError(errorMessage); + if (this.min !== null && numValue < this.min) { + return { isValid: false, errorMessage: `Please enter a number greater than or equal to ${this.min}.` }; + } + + if (this.max !== null && numValue > this.max) { + return { isValid: false, errorMessage: `Please enter a number less than or equal to ${this.max}.` }; + } } - return isValid; + // If NumberEntry-specific validation passes, call parent's validate method + return super.validate(); } } diff --git a/library/elements/numberScale.js b/library/elements/numberScale.js new file mode 100644 index 0000000..4a5d799 --- /dev/null +++ b/library/elements/numberScale.js @@ -0,0 +1,195 @@ +import Element from '../core/element.js'; + +class NumberScale extends Element { + static styleKeys = [...Element.styleKeys, 'scaleContainer', 'scaleItem', 'scaleInput', 'scaleCircle', 'scaleNumber', 'scaleLabels']; + + static selectorMap = { + ...Element.selectorMap, + scaleContainer: '.scale-container', + scaleItem: '.scale-item', + scaleInput: 'input[type="radio"]', + scaleCircle: '.scale-circle', + scaleNumber: '.scale-number', + scaleLabels: '.scale-labels' + }; + + static defaultStyles = { + scaleOuterContainer: {}, + scaleContainer: { + display: 'grid', + gridTemplateColumns: `repeat(auto-fit, minmax(36px, 1fr))`, + gridGap: '5px', + rowGap: '5px', + }, + scaleItem: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + position: 'relative', + height: '42px', + }, + scaleInput: { + position: 'absolute', + opacity: '0', + cursor: 'pointer', + height: '0', + width: '0', + }, + scaleCircle: { + width: '100%', + height: '42px', + flexGrow: '1', + borderRadius: '12px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + cursor: 'pointer', + backgroundColor: '#f0f0f0', + '&:hover': { + backgroundColor: '#e0e0e0', + }, + }, + scaleNumber: { + fontSize: '16px', + textAlign: 'center', + color: '#333', + }, + scaleLabels: { + marginTop: '5px', + fontSize: '12px', + color: '#888', + display: 'flex', + justifyContent: 'space-between', + } + }; + + constructor({ + id, + text, + subText = '', + required = true, + min = 1, + max = 5, + minLabel = '', + maxLabel = '', + customValidation = null, + styles = {} + }) { + super({ id, type: 'number-scale', store_data: true, required, customValidation, styles }); + + if (min >= max) { + throw new Error('Min value must be less than max value'); + } + + this.text = text; + this.subText = subText; + this.min = min; + this.max = max; + this.minLabel = minLabel; + this.maxLabel = maxLabel; + this.selectedBackgroundColor = '#333'; + this.selectedTextColor = '#fff'; + + this.addData('text', text); + this.addData('subText', subText); + this.addData('min', min); + this.addData('max', max); + this.addData('minLabel', minLabel); + this.addData('maxLabel', maxLabel); + + this.initialResponse = null; + + this.elementStyleKeys = [...NumberScale.styleKeys]; + this.selectorMap = { ...NumberScale.selectorMap }; + } + + generateHTML() { + const scaleItems = []; + for (let i = this.min; i <= this.max; i++) { + scaleItems.push(` +
+ + +
+ `); + } + + const showLabels = this.minLabel || this.maxLabel; + + return ` +
+
+
+ + ${this.subText ? `${this.subText}` : ''} +
+
+ ${scaleItems.join('')} +
+ ${showLabels ? ` +
+
${this.min} - ${this.minLabel}
+
${this.max} - ${this.maxLabel}
+
+ ` : ''} +
+ +
+ `; + } + + attachEventListeners() { + const container = document.getElementById(`${this.id}-container`); + this.addEventListenerWithTracking(container, 'change', this.handleChange.bind(this)); + this.updateSelectedStyles(); + } + + handleChange(e) { + if (e.target.type === 'radio') { + this.setResponse(parseInt(e.target.value, 10)); + this.updateSelectedStyles(); + } + } + + updateSelectedStyles() { + const container = document.getElementById(`${this.id}-container`); + const circles = container.querySelectorAll('.scale-circle'); + const selectedInput = container.querySelector('input:checked'); + + circles.forEach(circle => { + // Remove inline styles for all circles + circle.style.removeProperty('background-color'); + circle.querySelector('.scale-number').style.removeProperty('color'); + }); + + if (selectedInput) { + const selectedCircle = selectedInput.nextElementSibling; + selectedCircle.style.backgroundColor = this.selectedBackgroundColor; + selectedCircle.querySelector('.scale-number').style.color = this.selectedTextColor; + } + } + + setResponse(value) { + super.setResponse(value); + this.showValidationError(null); + this.updateSelectedStyles(); + } + + validate() { + const response = this.data.response; + + if (response === null || isNaN(response)) { + return { isValid: false, errorMessage: 'Please select a rating.' }; + } + + if (response < this.min || response > this.max) { + return { isValid: false, errorMessage: `Please select a rating between ${this.min} and ${this.max}.` }; + } + + return super.validate(); + } +} + +export default NumberScale; \ No newline at end of file diff --git a/library/elements/openEnd.js b/library/elements/openEnd.js index e5b2496..427f053 100644 --- a/library/elements/openEnd.js +++ b/library/elements/openEnd.js @@ -1,25 +1,14 @@ import Element from '../core/element.js'; class OpenEnd extends Element { - static styleKeys = ['root', 'innerContainer','label', 'subText', 'textarea', 'errorMessage']; + static styleKeys = [...Element.styleKeys, 'textarea']; + + static selectorMap = { + ...Element.selectorMap, + textarea: 'textarea' + }; static defaultStyles = { - root: { - marginBottom: '20px', - }, - innerContainer: { }, - label: { - display: 'block', - marginBottom: '5px', - fontWeight: 'bold', - fontSize: '1.1em', - }, - subText: { - display: 'block', - marginBottom: '10px', - color: '#6c757d', - fontSize: '1.1em', - }, textarea: { width: '100%', padding: '12px', @@ -30,27 +19,27 @@ class OpenEnd extends Element { fontSize: '1em', marginBottom: '0px', display: 'block', - }, - errorMessage: { - marginTop: '5px', - color: '#fa5252', - fontSize: '0.9em', + '&:focus': { + borderColor: 'black', + outline: 'none', + }, } }; - constructor({ - id, - text, - subText = '', - minLength = 0, - maxLength = 10000, - rows = 2, - placeholder = '', - required = true, - includeAlias = true, - styles = {} + constructor({ + id, + text, + subText = '', + minLength = 0, + maxLength = 10000, + rows = 2, + placeholder = '', + required = true, + includeAlias = true, + customValidation = null, + styles = {} }) { - super({ id, type: 'open-end', store_data: true, required }); + super({ id, type: 'open-end', store_data: true, required, customValidation, styles }); if (minLength < 0 || maxLength < minLength) { throw new Error('Invalid length constraints'); @@ -63,11 +52,11 @@ class OpenEnd extends Element { this.rows = rows; this.placeholder = placeholder; this.includeAlias = Boolean(includeAlias); - this.mergeStyles(OpenEnd.defaultStyles, styles); this.aliasMaxLength = 10000; this.aliasTypingHistory = []; this.aliasStartTime = null; this.aliasTextOverLength = false; + this.addData('text', text); this.addData('subText', subText); this.addData('minLength', minLength); @@ -75,27 +64,20 @@ class OpenEnd extends Element { this.addData('includeAlias', this.includeAlias); this.addData('aliasMaxLength', this.aliasMaxLength); this.addData('aliasTypingHistory', this.aliasTypingHistory); - this.setInitialResponse(''); - } + this.initialResponse = ''; - getSelectorForKey(key) { - const selectorMap = { - root: '', - innerContainer: `#${this.id}-inner-container`, - label: 'label', - subText: '.question-subtext', - textarea: 'textarea', - errorMessage: '.error-message' - }; - return selectorMap[key] || ''; + this.elementStyleKeys = [...OpenEnd.styleKeys]; + this.selectorMap = { ...OpenEnd.selectorMap }; } generateHTML() { return `
-
- - ${this.subText && `${this.subText}`} +
+
+ + ${this.subText ? `${this.subText}` : ''} +