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