diff --git a/assets/scripts/components/stepper.js b/assets/scripts/components/stepper.js new file mode 100644 index 00000000000..eff4a7f8dd4 --- /dev/null +++ b/assets/scripts/components/stepper.js @@ -0,0 +1,209 @@ +const STORAGE_PREFIX = 'stepper-progress-'; +const MAX_STORED_STEPPERS = 10; + +function getStorageKey(stepperId) { + return `${STORAGE_PREFIX}${location.pathname}:${stepperId}`; +} + +function loadProgress(stepperId) { + try { + const data = localStorage.getItem(getStorageKey(stepperId)); + return data ? JSON.parse(data) : null; + } catch { + return null; + } +} + +function saveProgress(stepperId, state) { + try { + const key = getStorageKey(stepperId); + if (!localStorage.getItem(key)) { + pruneOldEntries(); + } + localStorage.setItem(key, JSON.stringify({ ...state, timestamp: Date.now() })); + } catch { + // Ignore storage errors, the stepper will still work without localStorage persistence + } +} + +function pruneOldEntries() { + try { + const entries = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(STORAGE_PREFIX)) { + const data = JSON.parse(localStorage.getItem(key)); + entries.push({ key, timestamp: data.timestamp || 0 }); + } + } + // If at the cap, remove the oldest entry to make room + if (entries.length >= MAX_STORED_STEPPERS) { + entries.sort((a, b) => a.timestamp - b.timestamp); + const toRemove = entries.length - MAX_STORED_STEPPERS + 1; + for (let i = 0; i < toRemove; i++) { + localStorage.removeItem(entries[i].key); + } + } + } catch { + // Ignore storage errors, the stepper will still work without localStorage persistence + } +} + +function setHidden(el, hidden) { + if (!el) return; + if (hidden) { + el.setAttribute('data-hidden', 'true'); + } else { + el.removeAttribute('data-hidden'); + } +} + +function initStepper(stepper) { + const stepperId = stepper.id; + const steps = stepper.querySelectorAll('.stepper__step'); + const finishedEl = stepper.querySelector('.stepper__finished'); + const resetEl = stepper.querySelector('.stepper__reset'); + const showAllBtn = stepper.querySelector('.stepper__show-all-btn'); + const collapseBtn = stepper.querySelector('.stepper__collapse-btn'); + + if (!steps.length) return; + + // Set step numbers as data attributes for CSS + // (CSS counters don't work reliably when steps use display:none) + steps.forEach((step, i) => { + step.dataset.stepNumber = String(i + 1); + }); + + // Mark first/last steps for CSS line endpoints + steps[0].classList.add('stepper__step--first'); + steps[steps.length - 1].classList.add('stepper__step--last'); + + let currentIndex = 0; + let finished = false; + let isAllExpanded = stepper.classList.contains('stepper--open'); + + // Restore saved progress + const saved = loadProgress(stepperId); + if (saved) { + if (saved.finished) { + finished = true; + } else if (typeof saved.stepIndex === 'number' && saved.stepIndex >= 0 && saved.stepIndex < steps.length) { + currentIndex = saved.stepIndex; + } + if (typeof saved.isAllExpanded === 'boolean') { + isAllExpanded = saved.isAllExpanded; + } + } + + function persist() { + saveProgress(stepperId, { + stepIndex: currentIndex, + finished, + isAllExpanded + }); + } + + function render() { + stepper.classList.toggle('stepper--all-expanded', isAllExpanded); + + // Toggle viz control buttons + setHidden(showAllBtn, isAllExpanded); + setHidden(collapseBtn, !isAllExpanded); + + steps.forEach((step, i) => { + const isActive = !finished && i === currentIndex; + const isCompleted = finished || i < currentIndex; + + step.classList.toggle('stepper__step--active', isActive); + step.classList.toggle('stepper__step--completed', isCompleted); + + // The step title, number, etc. should always be visible, + // even if the step body itself is not visible + setHidden(step, false); + + // Nav: hidden when expanded or finished, visible only for active step + const nav = step.querySelector('.stepper__nav'); + if (nav) { + setHidden(nav, isAllExpanded || !isActive || finished); + } + }); + + // Finished message + setHidden(finishedEl, !finished); + + // Start over: visible only when finished + setHidden(resetEl, !finished); + } + + function goToStep(index) { + finished = false; + currentIndex = Math.max(0, Math.min(index, steps.length - 1)); + persist(); + render(); + } + + function handleFinish() { + finished = true; + persist(); + render(); + } + + function handleReset() { + finished = false; + currentIndex = 0; + persist(); + render(); + } + + // Clicking a step title in accordion (collapsed) mode makes it the active step + steps.forEach((step, i) => { + const title = step.querySelector('.stepper__step-title'); + if (title) { + title.addEventListener('click', (e) => { + e.preventDefault(); + if (isAllExpanded) return; + goToStep(i); + }); + } + }); + + stepper.addEventListener('click', (e) => { + const btn = e.target.closest('.stepper__btn'); + if (!btn) return; + + if (btn.classList.contains('stepper__next-btn')) { + goToStep(currentIndex + 1); + } else if (btn.classList.contains('stepper__prev-btn')) { + goToStep(currentIndex - 1); + } else if (btn.classList.contains('stepper__finish-btn')) { + handleFinish(); + } else if (btn.classList.contains('stepper__reset-btn')) { + handleReset(); + } else if (btn.classList.contains('stepper__show-all-btn')) { + isAllExpanded = true; + persist(); + render(); + } else if (btn.classList.contains('stepper__collapse-btn')) { + isAllExpanded = false; + persist(); + render(); + } + }); + + // Initial render + render(); + stepper.classList.add('stepper--initialized'); +} + +function initAllSteppers() { + const steppers = document.querySelectorAll('.stepper'); + steppers.forEach(initStepper); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initAllSteppers); +} else { + initAllSteppers(); +} + +export { initAllSteppers, initStepper }; diff --git a/assets/scripts/main-dd-js.js b/assets/scripts/main-dd-js.js index 57cd5b4c21d..5596e1d2d32 100644 --- a/assets/scripts/main-dd-js.js +++ b/assets/scripts/main-dd-js.js @@ -19,6 +19,7 @@ import './components/mobile-nav'; // should move this to websites-modules import './components/accordion-auto-open'; import './components/signup'; import './components/conversational-search'; +import './components/stepper'; // Add Bootstrap Tooltip across docs const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') diff --git a/assets/styles/_bootstrap-custom.scss b/assets/styles/_bootstrap-custom.scss index f49266a2fea..f756c383ebd 100644 --- a/assets/styles/_bootstrap-custom.scss +++ b/assets/styles/_bootstrap-custom.scss @@ -255,10 +255,14 @@ button.list-group-item-white:hover { } } -// remove outlines -button:focus { +// Hide focus outlines for mouse/touch only; keep for keyboard +button:focus:not(:focus-visible) { outline: none; } +button:focus-visible { + outline: 2px solid $ddpurple; + outline-offset: 2px; +} .form-control:focus { box-shadow: none; } diff --git a/assets/styles/components/_collapsible-section.scss b/assets/styles/components/_collapsible-section.scss index 5b4485d6431..2d80e69ba97 100644 --- a/assets/styles/components/_collapsible-section.scss +++ b/assets/styles/components/_collapsible-section.scss @@ -11,7 +11,6 @@ align-items: center; padding: 8px; cursor: pointer; - outline: none; background-color: #ffffff00; border-radius: 7px 7px 7px 7px; transition: background-color 0.3s, @@ -22,6 +21,15 @@ display: none; } +.collapsible-header:focus:not(:focus-visible) { + outline: none; +} + +.collapsible-header:focus-visible { + outline: 2px solid $ddpurple; + outline-offset: -2px; +} + .collapsible-header:hover { background-color: #eae2f8; } diff --git a/assets/styles/components/_stepper.scss b/assets/styles/components/_stepper.scss new file mode 100644 index 00000000000..09e1eeb13b1 --- /dev/null +++ b/assets/styles/components/_stepper.scss @@ -0,0 +1,253 @@ +$stepper-circle-size: 24px; +$stepper-gutter: 48px; +$stepper-completed-color: #2a7e41; +$stepper-line-width: 1px; +$stepper-line-left: ($stepper-circle-size * 0.5) - ($stepper-line-width * 0.5); +$stepper-title-pad-top: 16px; +$stepper-circle-center: $stepper-title-pad-top + 1px + ($stepper-circle-size * 0.5); + +.stepper { + position: relative; + margin-bottom: 24px; + opacity: 0; + + &--open { + opacity: 1; + } + + &--initialized { + opacity: 1; + } + + // Hide any element with data-hidden="true" within steppers + [data-hidden="true"] { + display: none !important; + } +} + +.stepper__steps { + padding: 0; +} + +.stepper__step { + position: relative; + padding-left: $stepper-gutter; + + // Vertical connecting line + &::before { + content: ''; + position: absolute; + left: $stepper-line-left; + top: 0; + bottom: 0; + width: $stepper-line-width; + background-color: $gray-light; + } + + // Number circle (uses data attribute set by JS instead of CSS counter) + &::after { + content: attr(data-step-number); + position: absolute; + left: 0; + top: $stepper-title-pad-top + 2px; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: $stepper-circle-size; + height: $stepper-circle-size; + border-radius: 50%; + background-color: $gray-dark; + color: $white; + font-size: 15px; + font-weight: 700; + line-height: 1; + font-variant-numeric: lining-nums; + padding-bottom: 2px; + transition: background-color 0.15s; + box-shadow: 0 0 0 5px $white; + } + + // First step: line starts below the first circle + &--first::before { + top: $stepper-circle-center; + } + + // Last step: line ends at the circle center + &--last::before { + bottom: auto; + height: $stepper-circle-center; + } + + // Single step: no line + &--first#{&}--last::before { + display: none; + } +} + +// Active step: purple circle +.stepper__step--active::after { + background-color: $brand-primary; +} + +// All-expanded mode: all circles purple with step numbers +.stepper--all-expanded .stepper__step::after { + content: attr(data-step-number); + background-color: $brand-primary; + background-image: none; +} + +// Completed step: green circle with checkmark icon +.stepper__step--completed::after { + content: ''; + background-color: $stepper-completed-color; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E%3Cpath fill='%23fff' d='m22.567 4.73l-1.795-1.219a1.09 1.09 0 0 0-1.507.287l-8.787 12.959l-4.039-4.039a1.09 1.09 0 0 0-1.533 0l-1.533 1.536a1.084 1.084 0 0 0 0 1.534L9.582 22c.349.347.895.615 1.387.615s.988-.31 1.307-.774l10.58-15.606a1.085 1.085 0 0 0-.289-1.505'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 14px 14px; +} + +.stepper__step-title { + #cdoc-content.customizable & { + scroll-margin-top: 175px; + } + display: block; + margin: 0; + padding: $stepper-title-pad-top 0; + font-size: 18px; + font-weight: 600; + line-height: $stepper-circle-size; + cursor: pointer; + user-select: none; + + .stepper--all-expanded & { + cursor: default; + } +} + +.stepper__step-content { + display: none; + padding-bottom: 8px; + + > *:last-child { + margin-bottom: 0; + } +} + +// Show content for the active step +.stepper__step--active .stepper__step-content { + display: block; +} + +// Show all content in expanded mode +.stepper--all-expanded .stepper__step-content { + display: block; +} + +.stepper__nav { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + padding: 8px 0 16px; +} + +.stepper__step-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; +} + +.stepper__viz-controls { + display: flex; + gap: 8px; + flex-shrink: 0; + margin-right: 4px; +} + +.stepper__btn { + display: inline-flex; + align-items: center; + padding: 9px 16px 11px; + border: 1px solid $brand-primary; + border-radius: 4px; + background-color: $white; + color: $brand-primary; + font-size: 16px; + font-weight: 500; + line-height: 1; + cursor: pointer; + transition: background-color 0.15s, color 0.15s; + + &:hover { + background-color: $brand-primary; + color: $white; + } +} + +.stepper__next-btn, +.stepper__finish-btn, +.stepper__reset-btn { + background-color: $brand-primary; + color: $white; + + &:hover { + background-color: darken($brand-primary, 10%); + border-color: darken($brand-primary, 10%); + } +} + +.stepper__show-all-btn, +.stepper__collapse-btn { + padding: 0; + border: none; + border-radius: 0; + background-color: transparent; + color: $brand-primary; + font-size: 14px; + font-weight: 500; + text-transform: none; + letter-spacing: 0.01em; + line-height: $stepper-circle-size; + gap: 4px; + + &:hover { + background-color: transparent; + color: darken($brand-primary, 10%); + text-decoration: underline; + } +} + +.stepper__btn-icon { + display: none; + width: 28px; + height: 28px; + position: relative; + top: 8px; + + @media (max-width: 479px) { + display: inline-block; + left: 9px; + } +} + +.stepper__btn-label { + @media (max-width: 479px) { + display: none; + } +} + +.stepper__finished { + padding: 16px 0; + margin-left: $stepper-gutter; + + > *:last-child { + margin-bottom: 0; + } +} + +.stepper__reset { + padding: 8px 0; + margin-left: $stepper-gutter; +} diff --git a/assets/styles/components/_tab-toggle.scss b/assets/styles/components/_tab-toggle.scss index 31b5ecac0c8..15a2f04775c 100644 --- a/assets/styles/components/_tab-toggle.scss +++ b/assets/styles/components/_tab-toggle.scss @@ -138,13 +138,17 @@ $tab-active-shadow: 0 -4px 12px rgba(99, 44, 166, 0.08); display: inline-block; position: relative; color: rgba(0, 0, 0, 0.6); - outline: none !important; transition: none; - &:focus { + &:focus:not(:focus-visible) { outline: none !important; box-shadow: none !important; } + + &:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px $tab-active-color; + } } // Styles for the currently active tab - includes white background, purple text, and subtle shadow @@ -161,13 +165,17 @@ $tab-active-shadow: 0 -4px 12px rgba(99, 44, 166, 0.08); padding-bottom: 9px; border-top-left-radius: 4px; border-top-right-radius: 4px; - outline: none !important; transition: none; - &:focus { + &:focus:not(:focus-visible) { outline: none !important; box-shadow: $tab-active-shadow !important; } + + &:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px $tab-active-color, $tab-active-shadow; + } } // Container for tab content - white background with border and shadow diff --git a/assets/styles/pages/_global.scss b/assets/styles/pages/_global.scss index f35ab2d681b..bab0472e680 100644 --- a/assets/styles/pages/_global.scss +++ b/assets/styles/pages/_global.scss @@ -104,11 +104,19 @@ a { color: #000; } -button:focus, -a:focus { +// Hide focus outlines for mouse/touch interactions only +button:focus:not(:focus-visible), +a:focus:not(:focus-visible) { outline: none !important; } +// Show focus outlines for keyboard navigation (accessibility) +button:focus-visible, +a:focus-visible { + outline: 2px solid $ddpurple; + outline-offset: 2px; +} + .custom-select { width: 100%; background-color: #fff; diff --git a/assets/styles/style.scss b/assets/styles/style.scss index a2360c8ba1b..8cc343d288a 100644 --- a/assets/styles/style.scss +++ b/assets/styles/style.scss @@ -76,6 +76,7 @@ $baseImgURL: '{{.Site.Params.img_url}}'; @import 'components/demo-request-modal'; // imported from websites-modules @import 'components/collapsible-section'; // Collapsible section styles +@import 'components/stepper'; @import 'components/tooltip'; // Tooltip shortcode styles @import "components/multifilter-search"; diff --git a/static/images/icons/collapse-mdi.svg b/static/images/icons/collapse-mdi.svg new file mode 100644 index 00000000000..d90450d26c6 --- /dev/null +++ b/static/images/icons/collapse-mdi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/icons/expand-mdi.svg b/static/images/icons/expand-mdi.svg new file mode 100644 index 00000000000..2770056dcea --- /dev/null +++ b/static/images/icons/expand-mdi.svg @@ -0,0 +1 @@ + \ No newline at end of file