From 5c9d18ad6a829854c0ca54939c59fa06e5b04b1c Mon Sep 17 00:00:00 2001 From: uttam12331 Date: Sun, 28 Jun 2026 22:55:17 +0530 Subject: [PATCH] feat(modal): implement multi-step (steps) mechanism Add multi-step modal support via a new `steps` prop. Each step defines its own `elements` and is rendered one page at a time with Next/Back navigation, GSAP-animated transitions, a progress indicator and per-step titles. - Reuses the existing field rendering and validation pipeline (SerializeData, handleValidation) per step. - Next/Back replace the submit button; Next becomes submit on the final step. - Per-step validation runs before advancing; the final submit re-validates every step. - `data` and `errors` persist across steps. - Classic `elements` based modals are unaffected (the field wrapper uses display: contents when not stepped). - Adds a two-step registration example to the devbench. Closes #276 --- devbench/src/component/modal.jsx | 58 ++ .../modal/dependencies/style/elements.css | 57 ++ src/components/modal/modal.jsx | 510 +++++++++++------- 3 files changed, 441 insertions(+), 184 deletions(-) diff --git a/devbench/src/component/modal.jsx b/devbench/src/component/modal.jsx index fa34e097..62bc77c6 100644 --- a/devbench/src/component/modal.jsx +++ b/devbench/src/component/modal.jsx @@ -39,6 +39,64 @@ export function ModalTest() { return props; }); + // Multi-step modal example: a two-page registration flow. Toggle `useSteps` + // to compare against the classic single-view `elements` modal above. + const useSteps = true; + const registrationSteps = [ + { + title: 'Account Details', + elements: [ + { + type: 'text', + name: 'firstName', + placeholder: 'First Name', + amount: 1 + }, + { + type: 'email', + name: 'email', + placeholder: 'Email', + amount: 1, + validation: 'email' + } + ] + }, + { + title: 'Security', + elements: [ + { + type: 'password', + name: 'password', + placeholder: 'Password', + id: 'password', + amount: 1 + }, + { + type: 'password', + name: 'confirmPassword', + placeholder: 'Confirm Password', + match: 'password', + amount: 1 + } + ] + } + ]; + + if (useSteps) { + return ( + console.log('submitted', data)} + onChange={(data) => console.log(data)} + /> + ); + } + return ( } [props.elements] - Array of element configs + * @param {Array<{title?: string, elements: Array}>} [props.steps] - Multi-step configuration. When provided, the modal is split into sequential pages with `Next`/`Back` navigation. Each step renders its own `elements`; `elements` is ignored when `steps` is set. * @param {Object} [props.style] - Inline style overrides */ function Modal({ title = '!/', type = `form`, elements, + steps, preset = '!/', theme = '!/', background, @@ -74,8 +76,20 @@ function Modal({ const [status, SetStatus] = React.useState('entering'); const [configs, SetConfig] = React.useState({}); const [fields, SetFields] = React.useState([]); + const [stepsFields, SetStepsFields] = React.useState([]); + const [currentStep, SetCurrentStep] = React.useState(0); const instanceId = React.useId(); const modalRef = React.useRef(null); + const stepsBodyRef = React.useRef(null); + + // When `steps` is provided the modal renders one page at a time. `currentFields` + // is the field set that is currently visible (the active step, or the flat + // `elements` list for a classic modal). Data and errors live in shared state, so + // they persist as the user navigates between steps. + const isStepped = Array.isArray(steps) && steps.length > 0; + const allFields = isStepped ? stepsFields.flat() : fields; + const currentFields = isStepped ? stepsFields[currentStep] || [] : fields; + const isLastStep = isStepped && currentStep >= steps.length - 1; function handleInputChange(name, value) { const nextData = { ...data, [name]: value }; SetData(nextData); @@ -91,13 +105,13 @@ function Modal({ onClose(); } } - function handleValidation(data) { + function handleValidation(data, fieldList = currentFields) { const newErrors = {}; - for (const field of fields) { + for (const field of fieldList) { if (Array.isArray(field.match)) { for (const [i, matchTo] of field.match.entries()) { if (matchTo) { - const matchToFields = fields.find((f) => f.id.includes(matchTo)); + const matchToFields = fieldList.find((f) => f.id.includes(matchTo)); if (matchToFields) { const matchToIndex = matchToFields.id.findIndex( (f) => f === matchTo @@ -155,13 +169,58 @@ function Modal({ return newErrors; } function handleSubmit() { - const newErrors = handleValidation(data); + // On a stepped modal the final submit re-validates every step's fields, not + // just the visible one, so a skipped or back-navigated step can't slip through. + const newErrors = handleValidation(data, allFields); const allow = Object.values(newErrors).every((val) => val === null); if (typeof onSubmit === 'function' && allow) { onSubmit(data); } } + // Animate the current step out, swap to the target step, then animate it in. + // Falls back to an instant swap if GSAP has no node to target. + function transitionStep(commit, direction) { + const node = stepsBodyRef.current; + if (!node) { + commit(); + return; + } + const offset = direction === 'back' ? 24 : -24; + gsap.to(node, { + opacity: 0, + x: offset, + duration: 0.18, + ease: 'power2.in', + onComplete: () => { + commit(); + gsap.fromTo( + node, + { opacity: 0, x: -offset }, + { opacity: 1, x: 0, duration: 0.25, ease: 'power2.out' } + ); + } + }); + } + + function handleStepNext() { + // Validate only the active step before advancing; persisted data carries over. + const newErrors = handleValidation(data, currentFields); + const allow = Object.values(newErrors).every((val) => val === null); + if (!allow) return; + if (isLastStep) { + handleSubmit(); + } else { + transitionStep(() => SetCurrentStep((s) => s + 1), 'next'); + } + } + + function handleStepBack() { + if (currentStep > 0) { + transitionStep(() => SetCurrentStep((s) => s - 1), 'back'); + } + } + const currentType = typesData.find( (e) => e.type.trim().toLowerCase() === type.trim().toLowerCase() ); @@ -200,14 +259,16 @@ function Modal({ 8: '53rem', 9: '57rem' }; - let idealSize = heightMap[fields?.length] || '26rem'; + let idealSize = heightMap[currentFields?.length] || '26rem'; const geometryBuffer = - currentTheme?.radiused || !currentTheme ? (2.5 * fields?.length) / 3 : 0; + currentTheme?.radiused || !currentTheme + ? (2.5 * currentFields?.length) / 3 + : 0; idealSize = `calc(${idealSize} + ${geometryBuffer}rem)`; const isMobile = window.matchMedia('(max-width: 768px)').matches; const dynamicHeight = isMobile ? `min(${idealSize}, 95vh)` : idealSize; const dynamicWidth = `min(${idealSize}, 95vw, 95vh)`; - const isCentered = fields?.length <= 4; + const isCentered = currentFields?.length <= 4; const dynamicMargin = isCentered ? '12vh auto' : '1.5rem auto'; const defaultStyle = { @@ -237,21 +298,46 @@ function Modal({ React.useEffect(() => { async function GetFields() { - const data = await SerializeData( - title, - type, - elements, - preset, - theme, - animation, - Id, - className, - onSubmit, - SetConfig, - instanceId - ); + if (isStepped) { + // Serialize each step independently, reusing the same field pipeline. + // Sequential (not parallel) because SerializeData writes shared module + // state while resolving theme/animation/preset config. + const serialized = []; + for (const step of steps) { + const stepData = await SerializeData( + title, + type, + step.elements, + preset, + theme, + animation, + Id, + className, + onSubmit, + SetConfig, + instanceId + ); + serialized.push(Array.isArray(stepData) ? stepData : []); + } + SetStepsFields(serialized); + SetCurrentStep(0); + } else { + const data = await SerializeData( + title, + type, + elements, + preset, + theme, + animation, + Id, + className, + onSubmit, + SetConfig, + instanceId + ); - SetFields(data); + SetFields(data); + } } GetFields(); @@ -260,15 +346,17 @@ function Modal({ const ele = document.getElementById(key); if (ele) ele.remove(); }; - }, [theme, preset, elements, animation, title]); + }, [theme, preset, elements, steps, animation, title]); React.useEffect(() => { - fields?.forEach((field) => { + // Initialise every field across all steps to null once, so persisted data is + // not reset while navigating between steps (navigation only changes currentStep). + allFields?.forEach((field) => { field.name.forEach((name) => { SetData((prev) => ({ ...prev, [name]: null })); }); }); - }, [fields]); + }, [fields, stepsFields]); // Auto-focus for the first input when modal opens React.useEffect(() => { @@ -283,7 +371,7 @@ function Modal({ firstInput.focus(); } } - }, [visibility]); // It only runs when the modal opens/closes + }, [visibility, currentStep]); // Runs when the modal opens/closes or the step changes useGSAP(() => { if (!modalRef.current || !currentAnimation) return; @@ -337,174 +425,228 @@ function Modal({ - {fields?.map((field, i) => { - const elementDef = - elementsData.find((e) => e.element === field.type) || - elementsData.find((e) => - e['inherited-element']?.includes(field.type) - ); - const Tag = elementDef.is_custom - ? componentsMap[elementDef.tag] - : elementDef.tag; + {isStepped && ( + + )} + {isStepped && steps[currentStep]?.title && ( +

+ {steps[currentStep].title} +

+ )} +
+ {currentFields?.map((field, i) => { + const elementDef = + elementsData.find((e) => e.element === field.type) || + elementsData.find((e) => + e['inherited-element']?.includes(field.type) + ); + const Tag = elementDef.is_custom + ? componentsMap[elementDef.tag] + : elementDef.tag; - return ( -
- {Array.from({ length: field.amount }, (_, j) => { - const name = field.name[j]; - const id = field.id[j]; - const fontSize = field.amount === 3 ? '0.6rem' : 'normal'; - const fontWeight = field.amount === 3 ? '520' : '200'; - // Spread aria props safely to avoid runtime errors if elementDef.aria is missing or null - let ariaProps = elementDef.aria - ? { ...elementDef.aria } - : {}; - // Allow field-specific aria overrides for inherited elements (e.g., search gets role="searchbox") - const overrideConfig = - elementDef['inherit-overrides']?.[field.type]; + return ( +
+ {Array.from({ length: field.amount }, (_, j) => { + const name = field.name[j]; + const id = field.id[j]; + const fontSize = field.amount === 3 ? '0.6rem' : 'normal'; + const fontWeight = field.amount === 3 ? '520' : '200'; + // Spread aria props safely to avoid runtime errors if elementDef.aria is missing or null + let ariaProps = elementDef.aria + ? { ...elementDef.aria } + : {}; + // Allow field-specific aria overrides for inherited elements (e.g., search gets role="searchbox") + const overrideConfig = + elementDef['inherit-overrides']?.[field.type]; - if (overrideConfig && overrideConfig.aria) { - ariaProps = { ...ariaProps, ...overrideConfig.aria }; - } + if (overrideConfig && overrideConfig.aria) { + ariaProps = { ...ariaProps, ...overrideConfig.aria }; + } - // Build aria attributes object with defensive checks for undefined/null values - const ariaAttributes = {}; + // Build aria attributes object with defensive checks for undefined/null values + const ariaAttributes = {}; - if ( - ariaProps.role !== undefined && - ariaProps.role !== null - ) { - ariaAttributes.role = ariaProps.role; - } - if ( - ariaProps['aria-label'] !== undefined && - ariaProps['aria-label'] !== null - ) { - ariaAttributes['aria-label'] = ariaProps['aria-label']; - } - if ( - ariaProps['aria-required'] !== undefined && - ariaProps['aria-required'] !== null - ) { - ariaAttributes['aria-required'] = - ariaProps['aria-required']; - } - const options = - Tag === 'select' || elementDef.tag === 'DynamicSelect' - ? Array.isArray(field.options[0]) - ? field.options[j] - : field.options - : []; - const fieldError = errors[name]; - const ErrorId = - `${id && id !== '!/' ? id : field.placeholder[j]}-error` - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-'); - const Tagprobs = { - className: `modal-element ` + elementDef['default-class'], - name: name, - theme: theme, - style: { - fontSize: fontSize, - fontWeight: fontWeight, - ...(elementDef['tag'] !== 'DyvixInput' && { - ...themeInputStyle - }) - }, - ...ariaAttributes, - ...(id && id !== '!/' && { id: id }), - ...(elementDef['is_custom'] && { animation: null }), - ...(elementDef['supports-placeholder'] && { - placeholder: field.placeholder[j], - 'aria-label': field.placeholder[j] - }), - ...(elementDef['supports-label'] && { - label: field.placeholder[j] - }), - ...(elementDef['supports_type'] && { type: field.type }), - ...(elementDef['supports_autocomplete'] && { - autoComplete: - field.type === 'password' ? 'current-password' : 'on' - }), - ...(elementDef.tag === 'DynamicSelect' && { - elements: options, - animation: '!/', - className: 'modal-element' - }), - ...(ErrorId && { - 'aria-describedby': ErrorId - }), - ...(elementDef.tag !== 'DyvixFile' && { - onChange: (e) => { - const value = - elementDef.tag === 'DyvixInput' - ? e.target.value - : elementDef['is_custom'] - ? e - : field.type === 'checkbox' - ? e.target.checked - : e.target.value; - handleInputChange(name, value); - } - }), - ...(elementDef.tag === 'DyvixFile' && { - onUpload: (e) => { - handleInputChange(name, e); + if ( + ariaProps.role !== undefined && + ariaProps.role !== null + ) { + ariaAttributes.role = ariaProps.role; + } + if ( + ariaProps['aria-label'] !== undefined && + ariaProps['aria-label'] !== null + ) { + ariaAttributes['aria-label'] = ariaProps['aria-label']; + } + if ( + ariaProps['aria-required'] !== undefined && + ariaProps['aria-required'] !== null + ) { + ariaAttributes['aria-required'] = + ariaProps['aria-required']; + } + const options = + Tag === 'select' || elementDef.tag === 'DynamicSelect' + ? Array.isArray(field.options[0]) + ? field.options[j] + : field.options + : []; + const fieldError = errors[name]; + const ErrorId = + `${id && id !== '!/' ? id : field.placeholder[j]}-error` + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-'); + const Tagprobs = { + className: + `modal-element ` + elementDef['default-class'], + name: name, + theme: theme, + style: { + fontSize: fontSize, + fontWeight: fontWeight, + ...(elementDef['tag'] !== 'DyvixInput' && { + ...themeInputStyle + }) }, - ...((theme === '!/' || !theme) && { - background: 'transparent' + ...ariaAttributes, + ...(id && id !== '!/' && { id: id }), + ...(elementDef['is_custom'] && { animation: null }), + ...(elementDef['supports-placeholder'] && { + placeholder: field.placeholder[j], + 'aria-label': field.placeholder[j] + }), + ...(elementDef['supports-label'] && { + label: field.placeholder[j] + }), + ...(elementDef['supports_type'] && { + type: field.type + }), + ...(elementDef['supports_autocomplete'] && { + autoComplete: + field.type === 'password' + ? 'current-password' + : 'on' + }), + ...(elementDef.tag === 'DynamicSelect' && { + elements: options, + animation: '!/', + className: 'modal-element' + }), + ...(ErrorId && { + 'aria-describedby': ErrorId + }), + ...(elementDef.tag !== 'DyvixFile' && { + onChange: (e) => { + const value = + elementDef.tag === 'DyvixInput' + ? e.target.value + : elementDef['is_custom'] + ? e + : field.type === 'checkbox' + ? e.target.checked + : e.target.value; + handleInputChange(name, value); + } + }), + ...(elementDef.tag === 'DyvixFile' && { + onUpload: (e) => { + handleInputChange(name, e); + }, + ...((theme === '!/' || !theme) && { + background: 'transparent' + }) }) - }) - }; + }; - return ( -
- {elementDef['requires-options'] && Tag === 'select' ? ( - - - {options.map((opt, index) => ( - + ))} + + ) : field.type === 'checkbox' ? ( + + ) : ( + + )} + + {fieldError} + +
+ ); + })} +
+ ); + })} +
+ {currentType.submit && + (isStepped ? ( +
+ {currentStep > 0 && ( + + Back + + )} + + {isLastStep ? currentType.submitLabel : 'Next'} +
- ); - })} - {currentType.submit && ( - - {currentType.submitLabel} - - )} + ) : ( + + {currentType.submitLabel} + + ))}
)}