diff --git a/packages/core/stories/patterns/experience-customization/DataFormatContent.tsx b/packages/core/stories/patterns/experience-customization/DataFormatContent.tsx
new file mode 100644
index 00000000000..b3cf2239f01
--- /dev/null
+++ b/packages/core/stories/patterns/experience-customization/DataFormatContent.tsx
@@ -0,0 +1,194 @@
+import {
+ Card,
+ Display3,
+ FlexItem,
+ FlexLayout,
+ FormField,
+ FormFieldLabel,
+ RadioButton,
+ RadioButtonGroup,
+ StackLayout,
+ Switch,
+ Text,
+ useTheme,
+} from "@salt-ds/core";
+import { US } from "@salt-ds/countries";
+import { ArrowDownIcon, ArrowUpIcon } from "@salt-ds/icons";
+import type { FormContentProps } from "./experience-customization.stories";
+import NegativeTrend from "./img/negative-trend.png";
+import NegativeTrendDark from "./img/negative-trend-dark.png";
+import PositiveTrend from "./img/positive-trend.png";
+import PositiveTrendDark from "./img/positive-trend-dark.png";
+
+const stockCards = [
+ {
+ ticker: "VRT",
+ fullName: "VERTIV HOLDINGS CO-A",
+ exchange: "NYSE",
+ trendImage: PositiveTrend,
+ trendImageDark: PositiveTrendDark,
+ trendAlt: "Positive trend",
+ isPositive: true,
+ changeText: "+6.27 (+1.95%)",
+ changeColor: "success" as const,
+ metrics: {
+ lastPrice: "328.02",
+ absolute: "2.25",
+ marketCap: "66.199B",
+ },
+ },
+ {
+ ticker: "GEV",
+ fullName: "GE VERNOVA INC",
+ exchange: "NYSE",
+ trendImage: NegativeTrend,
+ trendImageDark: NegativeTrendDark,
+ trendAlt: "Negative trend",
+ isPositive: false,
+ changeText: "-4.03 (-0.35%)",
+ changeColor: "error" as const,
+ metrics: {
+ lastPrice: "1147.27",
+ absolute: "-8.92",
+ marketCap: "684.15B",
+ },
+ },
+];
+
+export const DataFormatContent = ({
+ formData,
+ handleRadioChange,
+ handleCheckboxChange,
+}: FormContentProps) => {
+ const { mode } = useTheme();
+
+ const showExchangeText = formData.exchangeAndRegionDisplay !== "flag";
+ const showFlag = formData.exchangeAndRegionDisplay !== "text";
+ const getDisplayMetric = (stock: (typeof stockCards)[number]) => {
+ if (formData.visibleMetrics === "absolute") {
+ return stock.metrics.absolute;
+ }
+
+ if (formData.visibleMetrics === "marketCap") {
+ return stock.metrics.marketCap;
+ }
+
+ return stock.metrics.lastPrice;
+ };
+
+ return (
+
+
+
+ Stock name display
+
+
+
+
+
+
+ Exchange & Region
+
+
+
+
+
+
+
+ Visible metrics
+
+
+
+
+
+
+
+
+ Performance chart
+
+
+
+
+
+
+ {stockCards.map((stock) => (
+
+
+
+
+
+ {stock.ticker}
+
+ {formData.stockNameDisplay === "fullNameTicker" && (
+ {stock.fullName}
+ )}
+
+ {getDisplayMetric(stock)}
+ {stock.isPositive ? (
+
+ ) : (
+
+ )}
+
+ {stock.changeText}
+
+
+
+ {showExchangeText && (
+ {stock.exchange}
+ )}
+ {showFlag && }
+
+
+
+
+ {formData.performanceChart && (
+
+ )}
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/packages/core/stories/patterns/experience-customization/FoundationContent.tsx b/packages/core/stories/patterns/experience-customization/FoundationContent.tsx
new file mode 100644
index 00000000000..c7e9537f1ea
--- /dev/null
+++ b/packages/core/stories/patterns/experience-customization/FoundationContent.tsx
@@ -0,0 +1,138 @@
+import {
+ Banner,
+ BannerContent,
+ Checkbox,
+ FlexItem,
+ FlexLayout,
+ FormField,
+ FormFieldHelperText,
+ InteractableCard,
+ InteractableCardGroup,
+ Link,
+ RadioButtonIcon,
+ StackLayout,
+ Text,
+ useId,
+ useTheme,
+} from "@salt-ds/core";
+import type { FormContentProps } from "./experience-customization.stories";
+import HighDensityTable from "./img/table-high.png";
+import HighDensityTableDark from "./img/table-high-dark.png";
+import LowDensityTable from "./img/table-low.png";
+import LowDensityTableDark from "./img/table-low-dark.png";
+import MediumDensityTable from "./img/table-medium.png";
+import MediumDensityTableDark from "./img/table-medium-dark.png";
+
+const displayDensityOptions = [
+ {
+ value: "high",
+ label: "High density",
+ image: HighDensityTable,
+ darkImage: HighDensityTableDark,
+ alt: "High Density",
+ },
+ {
+ value: "medium",
+ label: "Medium density",
+ image: MediumDensityTable,
+ darkImage: MediumDensityTableDark,
+ alt: "Medium Density",
+ },
+ {
+ value: "low",
+ label: "Low density",
+ image: LowDensityTable,
+ darkImage: LowDensityTableDark,
+ alt: "Low Density",
+ },
+] as const;
+
+export const FoundationContent = ({
+ formData,
+ handleSelectChange,
+ stepFieldValidation,
+ handleCheckboxChange,
+}: FormContentProps) => {
+ const { mode } = useTheme();
+ const densityId = useId();
+
+ return (
+
+ {formData.displayDensity === "high" && (
+
+
+ High density doesn't meet the{" "}
+
+ WCAG-defined minimum target size
+
+ , which may reduce readability and make interactions harder.
+
+
+ )}
+
+
+
+
+ Choose a density
+
+ {
+ handleSelectChange?.(value as string, "displayDensity");
+ }}
+ >
+
+ {displayDensityOptions.map((option) => (
+
+
+
+
+
+
+
+ {option.label}
+
+
+
+
+
+ ))}
+
+
+
+
+
+ {formData.displayDensity === "high" && (
+
+
+ {stepFieldValidation.acceptTerms?.status && (
+
+ {stepFieldValidation.acceptTerms.message}
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/packages/core/stories/patterns/experience-customization/NotificationsContent.tsx b/packages/core/stories/patterns/experience-customization/NotificationsContent.tsx
new file mode 100644
index 00000000000..c42bd8ec7a2
--- /dev/null
+++ b/packages/core/stories/patterns/experience-customization/NotificationsContent.tsx
@@ -0,0 +1,119 @@
+import {
+ FlexItem,
+ FlexLayout,
+ FormField,
+ FormFieldLabel,
+ InteractableCard,
+ InteractableCardGroup,
+ RadioButtonIcon,
+ StackLayout,
+ Switch,
+ Text,
+} from "@salt-ds/core";
+import type { CSSProperties } from "react";
+import type { FormContentProps } from "./experience-customization.stories";
+
+export const NOTIFICATION_POSITIONS = [
+ {
+ value: "top-left",
+ label: "Top Left",
+ },
+ {
+ value: "top-right",
+ label: "Top Right",
+ },
+ {
+ value: "bottom-left",
+ label: "Bottom Left",
+ },
+ {
+ value: "bottom-right",
+ label: "Bottom Right",
+ },
+];
+
+export const NotificationPosition = ({ position }: { position: string }) => {
+ const positionStyles: Record = {
+ "top-left": { top: 10, left: 5.5 },
+ "top-right": { top: 10, right: 5.5 },
+ "bottom-left": { bottom: 10, left: 5.5 },
+ "bottom-right": { bottom: 10, right: 5.5 },
+ };
+
+ return (
+
+ );
+};
+
+export const NotificationsContent = ({
+ formData,
+ handleSelectChange,
+ handleCheckboxChange,
+}: FormContentProps) => {
+ return (
+
+ {
+ handleSelectChange?.(value as string, "position");
+ }}
+ >
+
+ {NOTIFICATION_POSITIONS.map(({ value, label }) => (
+
+
+
+
+
+
+
+
+ {label}
+
+
+
+
+ ))}
+
+
+
+ Automatically dismiss notifications
+
+
+
+ Extend notification display time
+
+
+
+ );
+};
diff --git a/packages/core/stories/patterns/experience-customization/RegionalSettingsContent.tsx b/packages/core/stories/patterns/experience-customization/RegionalSettingsContent.tsx
new file mode 100644
index 00000000000..6acbfd817fe
--- /dev/null
+++ b/packages/core/stories/patterns/experience-customization/RegionalSettingsContent.tsx
@@ -0,0 +1,160 @@
+import {
+ Dropdown,
+ FormField,
+ FormFieldHelperText,
+ FormFieldLabel,
+ GridItem,
+ GridLayout,
+ Option,
+ RadioButton,
+ RadioButtonGroup,
+ StackLayout,
+} from "@salt-ds/core";
+import { CalendarIcon, LocationIcon, SearchIcon } from "@salt-ds/icons";
+import type { CSSProperties } from "react";
+import type { FormContentProps } from "./experience-customization.stories";
+
+export const RegionalSettingsContent = ({
+ formData,
+ handleRadioChange,
+ handleSelectChange,
+ stepFieldValidation,
+ style,
+}: FormContentProps & { style?: CSSProperties }) => {
+ return (
+
+
+
+
+ Choose a language
+ }
+ bordered
+ placeholder="Search"
+ name="language"
+ value={formData.language}
+ onSelectionChange={(_e, value) =>
+ handleSelectChange?.(value[0], "language")
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {stepFieldValidation.language?.status && (
+
+ {stepFieldValidation.language.message}
+
+ )}
+
+
+ Region / Country
+ }
+ bordered
+ placeholder="Search"
+ name="region"
+ value={formData.region}
+ onSelectionChange={(_e, value) =>
+ handleSelectChange?.(value[0], "region")
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Public holiday calendar
+ }
+ bordered
+ placeholder="Search"
+ name="publicHolidayCalendar"
+ value={formData.publicHolidayCalendar}
+ onSelectionChange={(_e, value) =>
+ handleSelectChange?.(value[0], "publicHolidayCalendar")
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ First day of the week
+
+
+
+
+
+
+
+ Time format
+
+
+
+
+
+
+ Measurement system
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/core/stories/patterns/experience-customization/experience-customization.stories.css b/packages/core/stories/patterns/experience-customization/experience-customization.stories.css
new file mode 100644
index 00000000000..ebf9bc380e3
--- /dev/null
+++ b/packages/core/stories/patterns/experience-customization/experience-customization.stories.css
@@ -0,0 +1,11 @@
+.visuallyHidden {
+ position: absolute;
+ height: 1px;
+ width: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
diff --git a/packages/core/stories/patterns/experience-customization/experience-customization.stories.tsx b/packages/core/stories/patterns/experience-customization/experience-customization.stories.tsx
new file mode 100644
index 00000000000..115bff16cdf
--- /dev/null
+++ b/packages/core/stories/patterns/experience-customization/experience-customization.stories.tsx
@@ -0,0 +1,1266 @@
+import {
+ Banner,
+ BannerContent,
+ Button,
+ Checkbox,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogHeader,
+ Dropdown,
+ FlexItem,
+ FlexLayout,
+ FormField,
+ FormFieldLabel,
+ GridItem,
+ GridLayout,
+ H2,
+ InteractableCard,
+ InteractableCardGroup,
+ type InteractableCardValue,
+ Link,
+ Option,
+ ParentChildLayout,
+ RadioButton,
+ RadioButtonGroup,
+ RadioButtonIcon,
+ SplitLayout,
+ StackLayout,
+ type StackLayoutProps,
+ Step,
+ Stepper,
+ Switch,
+ Text,
+ useResponsiveProp,
+ VerticalNavigation,
+ VerticalNavigationItem,
+ VerticalNavigationItemContent,
+ VerticalNavigationItemLabel,
+ VerticalNavigationItemTrigger,
+} from "@salt-ds/core";
+import {
+ BuildingIcon,
+ CalendarIcon,
+ GlobeIcon,
+ LocationIcon,
+ LockedIcon,
+ SearchIcon,
+ WarningSolidIcon,
+} from "@salt-ds/icons";
+import type { Meta } from "@storybook/react-vite";
+import {
+ type ChangeEvent,
+ type ElementType,
+ type FocusEvent,
+ type ReactElement,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+import * as Yup from "yup";
+import { ContentOverflow } from "../wizard/ContentOverflow";
+import { type FieldValidation, useWizardForm } from "../wizard/useWizardForm";
+import { getStepStage, validateStep } from "../wizard/utils";
+import { DataFormatContent } from "./DataFormatContent";
+import { FoundationContent } from "./FoundationContent";
+import { NotificationsContent } from "./NotificationsContent";
+import { RegionalSettingsContent } from "./RegionalSettingsContent";
+import "../wizard/ContentOverflow.css";
+import "./experience-customization.stories.css";
+
+export default {
+ title: "Patterns/Experience Customization",
+ parameters: {
+ layout: "padded",
+ },
+} as Meta;
+
+export interface ECFormData {
+ acceptTerms: boolean;
+ language: string;
+ region: string;
+ publicHolidayCalendar: string;
+ position: string;
+ displayDensity: string;
+ currency: string;
+ currencyFormat: string;
+ stockNameDisplay: string;
+ exchangeAndRegionDisplay: string;
+ visibleMetrics: string;
+ performanceChart: boolean;
+ autoDismiss: boolean;
+ extendDisplayTime: boolean;
+ firstDayOfWeek?: string;
+ timeFormat?: string;
+ measurementSystem?: string;
+}
+
+export interface FormContentProps {
+ formData: ECFormData;
+ handleInputChange?: (event: ChangeEvent) => void;
+ handleCheckboxChange?: (event: ChangeEvent) => void;
+ stepFieldValidation: Record;
+ handleSelectChange?: (value: string, name: string) => void;
+ onBlur?: (event: FocusEvent) => void;
+ handleRadioChange?: (event: ChangeEvent) => void;
+}
+
+const wizardSteps = [
+ {
+ id: "foundation",
+ label: "Foundation",
+ stepTitle: "Foundation",
+ },
+ { id: "regional", label: "Regional settings", stepTitle: "Regional" },
+ {
+ id: "dataFormat",
+ label: "Data format ",
+ stepTitle: "Data format",
+ },
+ {
+ id: "notifications",
+ label: "Notification and settings",
+ stepTitle: "Notifications",
+ },
+] as const;
+const stepIds = wizardSteps.map((s) => s.id);
+
+const initialFormData: ECFormData = {
+ // Foundation
+ displayDensity: "",
+ acceptTerms: false,
+ // Regional
+ language: "",
+ region: "",
+ publicHolidayCalendar: "",
+ firstDayOfWeek: "",
+ timeFormat: "",
+ measurementSystem: "",
+ // Data format
+ currency: "usd",
+ currencyFormat: "standard",
+ stockNameDisplay: "fullNameTicker",
+ exchangeAndRegionDisplay: "both",
+ visibleMetrics: "lastPrice",
+ performanceChart: true,
+ // Notifications
+ position: "top-right",
+ autoDismiss: false,
+ extendDisplayTime: false,
+};
+
+const defaultDataFormatValues = {
+ currency: initialFormData.currency,
+ currencyFormat: initialFormData.currencyFormat,
+ stockNameDisplay: initialFormData.stockNameDisplay,
+ exchangeAndRegionDisplay: initialFormData.exchangeAndRegionDisplay,
+ visibleMetrics: initialFormData.visibleMetrics,
+ performanceChart: initialFormData.performanceChart,
+} as const;
+
+const hasDataFormatChanges = (formData: ECFormData) => {
+ return (
+ formData.currency !== defaultDataFormatValues.currency ||
+ formData.currencyFormat !== defaultDataFormatValues.currencyFormat ||
+ formData.stockNameDisplay !== defaultDataFormatValues.stockNameDisplay ||
+ formData.exchangeAndRegionDisplay !==
+ defaultDataFormatValues.exchangeAndRegionDisplay ||
+ formData.visibleMetrics !== defaultDataFormatValues.visibleMetrics ||
+ formData.performanceChart !== defaultDataFormatValues.performanceChart
+ );
+};
+
+const resetDataFormatFields = (
+ updateField: (name: string, value: string | boolean) => void,
+) => {
+ updateField("currency", defaultDataFormatValues.currency);
+ updateField("currencyFormat", defaultDataFormatValues.currencyFormat);
+ updateField("stockNameDisplay", defaultDataFormatValues.stockNameDisplay);
+ updateField(
+ "exchangeAndRegionDisplay",
+ defaultDataFormatValues.exchangeAndRegionDisplay,
+ );
+ updateField("visibleMetrics", defaultDataFormatValues.visibleMetrics);
+ updateField("performanceChart", defaultDataFormatValues.performanceChart);
+};
+
+const stepValidationSchemas: Record<
+ string,
+ // biome-ignore lint/suspicious/noExplicitAny: This is acceptable for an example.
+ Yup.ObjectSchema>
+> = {
+ foundation: Yup.object({
+ acceptTerms: Yup.boolean().when("displayDensity", {
+ is: "high",
+ // biome-ignore lint/suspicious/noThenProperty: This is the correct Yup syntax for conditional validation.
+ then: (schema) =>
+ schema.oneOf([true], "Please check the box to continue."),
+ otherwise: (schema) => schema.notRequired(),
+ }),
+ }),
+ regional: Yup.object({
+ language: Yup.string().required("Language is required."),
+ }),
+ displayMode: Yup.object({
+ displayDensity: Yup.string().test({
+ name: "high-density-warning",
+ message: "warning",
+ test(value, ctx) {
+ if (!value) return true;
+ if (value === "high") {
+ return ctx.createError({
+ params: { severity: "warning" },
+ });
+ }
+ return true;
+ },
+ }),
+ }),
+};
+
+const MultiStepTemplate = () => {
+ const stepHeadingRef = useRef(null);
+ const navigatedRef = useRef(false);
+
+ const direction: StackLayoutProps["direction"] =
+ useResponsiveProp(
+ {
+ xs: "column",
+ sm: "row",
+ },
+ "row",
+ );
+
+ const {
+ state: { activeStepIndex, formData, validationsByStep },
+ currentStepId,
+ updateField,
+ next,
+ previous,
+ reset,
+ runValidationAndStore,
+ } = useWizardForm({
+ steps: stepIds,
+ initialState: {
+ activeStepIndex: 3,
+ formData: initialFormData,
+ validationsByStep: {},
+ },
+ validateStep: (stepId, data) =>
+ validateStep(stepValidationSchemas, stepId, data),
+ });
+ const isLastStep = activeStepIndex === wizardSteps.length - 1;
+ const isFirstStep = activeStepIndex === 0;
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Update focus when active step changes
+ useEffect(() => {
+ if (!navigatedRef.current) return;
+ navigatedRef.current = false;
+ stepHeadingRef.current?.focus();
+ }, [activeStepIndex]);
+
+ const handleNext = async () => {
+ const valid = await runValidationAndStore();
+ if (!valid) return;
+ if (isLastStep) {
+ return;
+ }
+ navigatedRef.current = true;
+ next();
+ };
+
+ const handlePrevious = () => {
+ navigatedRef.current = true;
+ previous();
+ };
+
+ const sharedFormProps: FormContentProps = {
+ formData: formData as FormContentProps["formData"],
+ handleInputChange: (e) => updateField(e.target.name, e.target.value),
+ handleCheckboxChange: (e) => updateField(e.target.name, e.target.checked),
+ handleSelectChange: (value: string, name: string) =>
+ updateField(name, value),
+ handleRadioChange: (e) => updateField(e.target.name, e.target.value),
+ stepFieldValidation: validationsByStep[currentStepId]?.fields || {},
+ };
+
+ const contentByStep: Record = {
+ foundation: ,
+ regional: ,
+ dataFormat: ,
+ notifications: ,
+ };
+
+ const header = (
+
+
+
+ Create a new account
+
+ {wizardSteps[activeStepIndex].label}
+
+ {`, step ${activeStepIndex + 1} of ${wizardSteps.length}`}
+
+
+
+
+
+
+ {wizardSteps.map((step, index) => (
+
+ ))}
+
+
+
+ );
+
+ const cancel = (
+
+ );
+
+ const nextBtn = (
+
+ );
+
+ const prevBtn = !isFirstStep && (
+
+ );
+
+ const endFooter = (
+
+ {cancel}
+ {prevBtn}
+ {nextBtn}
+
+ );
+
+ const startFooter =
+ activeStepIndex === 2 && hasDataFormatChanges(formData as ECFormData) ? (
+
+ ) : null;
+
+ const footer =
+ direction === "column" ? (
+
+ {nextBtn}
+ {prevBtn}
+ {cancel}
+ {startFooter}
+
+ ) : (
+
+ );
+
+ return (
+
+ {header}
+
+
+ {contentByStep[currentStepId]}
+
+
+ {footer}
+
+ );
+};
+
+export const StandardControls = () => {
+ const [formData, setFormData] = useState({ ...initialFormData });
+
+ const handleCheckboxChange = (event: ChangeEvent) => {
+ const { name, checked } = event.target;
+ setFormData((prev) => ({ ...prev, [name]: checked }));
+ };
+
+ const handleSelectChange = (value: string, name: string) => {
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ return (
+
+ );
+};
+
+export const CardSelection = () => {
+ const [formData, setFormData] = useState({ ...initialFormData });
+
+ const handleCheckboxChange = (event: ChangeEvent) => {
+ const { name, checked } = event.target;
+ setFormData((prev) => ({ ...prev, [name]: checked }));
+ };
+
+ const handleSelectChange = (value: string, name: string) => {
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ return (
+
+ );
+};
+
+export const DynamicPreview = () => {
+ const [formData, setFormData] = useState({ ...initialFormData });
+
+ const handleCheckboxChange = (event: ChangeEvent) => {
+ const { name, checked } = event.target;
+ setFormData((prev) => ({ ...prev, [name]: checked }));
+ };
+
+ const handleRadioChange = (event: ChangeEvent) => {
+ const { name, value } = event.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const EndToEnd = {
+ render: () => ,
+};
+
+export const EndToEndModal = () => {
+ type WizardState = "form" | "cancel-warning";
+ const [wizardState, setWizardState] = useState("form");
+ const [open, setOpen] = useState(false);
+ const stepHeadingRef = useRef(null);
+ const navigatedRef = useRef(false);
+
+ const {
+ state: { activeStepIndex, formData, validationsByStep },
+ currentStepId,
+ updateField,
+ next,
+ previous,
+ reset,
+ runValidationAndStore,
+ } = useWizardForm({
+ steps: stepIds,
+ initialState: {
+ activeStepIndex: 0,
+ formData: initialFormData,
+ validationsByStep: {},
+ },
+ validateStep: (stepId, data) =>
+ validateStep(stepValidationSchemas, stepId, data),
+ });
+
+ const direction: StackLayoutProps["direction"] =
+ useResponsiveProp(
+ {
+ xs: "column",
+ sm: "row",
+ },
+ "row",
+ );
+
+ const isLastStep = activeStepIndex === wizardSteps.length - 1;
+ const isFirstStep = activeStepIndex === 0;
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Update focus when active step changes
+ useEffect(() => {
+ if (!navigatedRef.current) return;
+ navigatedRef.current = false;
+ stepHeadingRef.current?.focus();
+ }, [activeStepIndex]);
+
+ const openWizard = () => {
+ reset();
+ setWizardState("form");
+ setOpen(true);
+ };
+
+ const closeWizardAndReset = () => {
+ setOpen(false);
+ setTimeout(() => {
+ reset();
+ setWizardState("form");
+ }, 300);
+ };
+
+ const showCancelWarning = () => setWizardState("cancel-warning");
+ const backToForm = () => setWizardState("form");
+
+ const onOpenChange = (value: boolean) => {
+ if (!value && !isLastStep) {
+ showCancelWarning();
+ return;
+ }
+ setOpen(value);
+ };
+
+ const handleNext = async () => {
+ const valid = await runValidationAndStore();
+ if (!valid) return;
+ if (isLastStep) {
+ closeWizardAndReset();
+ return;
+ }
+ navigatedRef.current = true;
+ next();
+ };
+
+ const handlePrevious = () => {
+ navigatedRef.current = true;
+ previous();
+ };
+
+ const sharedFormProps: FormContentProps = {
+ formData: formData as FormContentProps["formData"],
+ handleInputChange: (e) => updateField(e.target.name, e.target.value),
+ handleCheckboxChange: (e) => updateField(e.target.name, e.target.checked),
+ handleSelectChange: (value: string, name: string) =>
+ updateField(name, value),
+ handleRadioChange: (e) => updateField(e.target.name, e.target.value),
+ stepFieldValidation: validationsByStep[currentStepId]?.fields || {},
+ };
+
+ const contentByStep: Record = {
+ foundation: ,
+ regional: ,
+ notifications: ,
+ dataFormat: ,
+ };
+
+ const cancel = (
+
+ );
+
+ const nextBtn = (
+
+ );
+
+ const prevBtn = !isFirstStep && (
+
+ );
+
+ const endFooter = (
+
+ {cancel}
+ {prevBtn}
+ {nextBtn}
+
+ );
+
+ const startFooter =
+ activeStepIndex === 2 && hasDataFormatChanges(formData as ECFormData) ? (
+
+ ) : null;
+
+ const footer =
+ direction === "column" ? (
+
+ {nextBtn}
+ {prevBtn}
+ {cancel}
+ {startFooter}
+
+ ) : (
+
+ );
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export const MandatoryConfigurations = () => {
+ const [selected, setSelected] = useState();
+ const [hasError, setHasError] = useState(false);
+
+ const handleSubmit = () => {
+ if (!selected) {
+ setHasError(true);
+ }
+ };
+
+ const governanceOptions = [
+ {
+ value: "standard",
+ title: "Standard",
+ description: "Business-recommended. Standard access logging.",
+ Icon: BuildingIcon,
+ },
+ {
+ value: "restricted",
+ title: "Credit Card",
+ description: "High compliance, Full data logging and MFA required.",
+ Icon: LockedIcon,
+ },
+ {
+ value: "external",
+ title: "External",
+ description: "Allow controlled access for partners.",
+ Icon: GlobeIcon,
+ },
+ ];
+
+ return (
+
+
+
+ Customize your experience
+
+ Set governance & privacy standards
+
+ A selection is required to proceed
+
+
+
+
+
+
+ {hasError && (
+
+
+ A selection is required to proceed
+
+
+ )}
+
+ {
+ setHasError(false);
+ setSelected(value);
+ }}
+ >
+ {governanceOptions.map(
+ ({ value, title, description, Icon }) => (
+
+
+
+
+ {title}
+
+
+
+ {description}
+
+
+
+ ),
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+function PreferencesNavigation({
+ items,
+ location,
+ onChange,
+}: {
+ items: string[];
+ location: string;
+ onChange: (location: string) => void;
+}) {
+ return (
+
+ {items.map((item) => (
+
+
+ onChange(item)}
+ render={}
+ >
+ {item}
+
+
+
+ ))}
+
+ );
+}
+
+type PreferenceSection =
+ | "Foundation"
+ | "Regional"
+ | "Data format"
+ | "Notification";
+
+interface PreferenceDialogFormData {
+ displayDensity: string;
+ acceptTerms: boolean;
+ language: string;
+ region: string;
+ publicHolidayCalendar: string;
+ firstDayOfWeek: string;
+ timeFormat: string;
+ measurementSystem: string;
+ stockNameDisplay: string;
+ exchangeAndRegionDisplay: string;
+ visibleMetrics: string;
+ performanceChart: boolean;
+ position: string;
+ autoDismiss: boolean;
+ extendDisplayTime: boolean;
+}
+
+function PreferencesContent({
+ currentSection,
+ formData,
+ onDropdownChange,
+ onSwitchChange,
+ onRadioChange,
+}: {
+ currentSection: PreferenceSection;
+ formData: PreferenceDialogFormData;
+ onDropdownChange: (
+ field: keyof PreferenceDialogFormData,
+ value: string,
+ ) => void;
+ onSwitchChange: (
+ field: keyof PreferenceDialogFormData,
+ checked: boolean,
+ ) => void;
+ onRadioChange: (field: keyof PreferenceDialogFormData, value: string) => void;
+}) {
+ let content;
+
+ if (currentSection === "Foundation") {
+ content = (
+
+ {formData.displayDensity === "high" && (
+
+
+ High density doesn't meet the{" "}
+
+ WCAG-defined minimum target size
+
+ , which may reduce readability and make interactions harder.
+
+
+ )}
+
+ Choose a density
+
+ onRadioChange("displayDensity", event.target.value)
+ }
+ direction="horizontal"
+ >
+
+
+
+
+
+ {formData.displayDensity === "high" && (
+
+
+ onSwitchChange("acceptTerms", event.target.checked)
+ }
+ label="I understand that High density reduces target sizes and may affect readability and ease of use."
+ />
+
+ )}
+
+ );
+ }
+
+ if (currentSection === "Regional") {
+ content = (
+
+
+ Choose a language
+ }
+ bordered
+ placeholder="Search"
+ value={formData.language}
+ onSelectionChange={(_event, value) =>
+ onDropdownChange("language", value[0])
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Region / Country
+ }
+ bordered
+ placeholder="Search"
+ value={formData.region}
+ onSelectionChange={(_event, value) =>
+ onDropdownChange("region", value[0])
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Public holiday calendar
+ }
+ bordered
+ placeholder="Search"
+ value={formData.publicHolidayCalendar}
+ onSelectionChange={(_event, value) =>
+ onDropdownChange("publicHolidayCalendar", value[0])
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+ First day of the week
+
+ onRadioChange("firstDayOfWeek", event.target.value)
+ }
+ >
+
+
+
+
+
+
+ Time format
+
+ onRadioChange("timeFormat", event.target.value)
+ }
+ >
+
+
+
+
+
+ Measurement system
+
+ onRadioChange("measurementSystem", event.target.value)
+ }
+ >
+
+
+
+
+
+ );
+ }
+
+ if (currentSection === "Data format") {
+ content = (
+
+
+ Stock name display
+
+ onRadioChange("stockNameDisplay", event.target.value)
+ }
+ >
+
+
+
+
+
+ Exchange & Region
+
+ onRadioChange("exchangeAndRegionDisplay", event.target.value)
+ }
+ >
+
+
+
+
+
+
+ Visible metrics
+
+ onRadioChange("visibleMetrics", event.target.value)
+ }
+ >
+
+
+
+
+
+
+ Performance chart
+
+ onSwitchChange("performanceChart", event.target.checked)
+ }
+ label={formData.performanceChart ? "Visible" : "Hidden"}
+ />
+
+
+ );
+ }
+
+ if (currentSection === "Notification") {
+ content = (
+
+
+ Notification position
+ onRadioChange("position", event.target.value)}
+ >
+
+
+
+
+
+
+
+ Automatically dismiss notifications
+
+ onSwitchChange("autoDismiss", event.target.checked)
+ }
+ label={formData.autoDismiss ? "On" : "Off"}
+ />
+
+
+ Extend notification display time
+
+ onSwitchChange("extendDisplayTime", event.target.checked)
+ }
+ label={formData.extendDisplayTime ? "On" : "Off"}
+ />
+
+
+ );
+ }
+
+ return (
+
+
+ {currentSection}
+
+ {content}
+
+ );
+}
+
+export const PreferenceDialog = () => {
+ const sections: PreferenceSection[] = [
+ "Foundation",
+ "Regional",
+ "Data format",
+ "Notification",
+ ];
+ const [currentSection, setCurrentSection] = useState(
+ sections[0],
+ );
+ const [collapsed, setCollapsed] = useState(false);
+ const [view, setView] = useState<"parent" | "child">("parent");
+ const [formData, setFormData] = useState({
+ displayDensity: "medium",
+ acceptTerms: false,
+ language: "English",
+ region: "United States",
+ publicHolidayCalendar: "selected-country",
+ firstDayOfWeek: "monday",
+ timeFormat: "24-hour",
+ measurementSystem: "metric",
+ stockNameDisplay: "fullNameTicker",
+ exchangeAndRegionDisplay: "both",
+ visibleMetrics: "lastPrice",
+ performanceChart: true,
+ position: "Top Right",
+ autoDismiss: false,
+ extendDisplayTime: false,
+ });
+
+ const handleSectionChange = (section: string) => {
+ setView("child");
+ setCurrentSection(section as PreferenceSection);
+ };
+
+ const handleDropdownChange = (
+ field: keyof PreferenceDialogFormData,
+ value: string,
+ ) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ };
+
+ const handleSwitchChange = (
+ field: keyof PreferenceDialogFormData,
+ checked: boolean,
+ ) => {
+ setFormData((prev) => ({ ...prev, [field]: checked }));
+ };
+
+ const handleRadioChange = (
+ field: keyof PreferenceDialogFormData,
+ value: string,
+ ) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ };
+
+ return (
+
+ );
+};
diff --git a/packages/core/stories/patterns/experience-customization/img/negative-trend-dark.png b/packages/core/stories/patterns/experience-customization/img/negative-trend-dark.png
new file mode 100644
index 00000000000..b40cf34a03a
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/negative-trend-dark.png differ
diff --git a/packages/core/stories/patterns/experience-customization/img/negative-trend.png b/packages/core/stories/patterns/experience-customization/img/negative-trend.png
new file mode 100644
index 00000000000..fc95fc99be6
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/negative-trend.png differ
diff --git a/packages/core/stories/patterns/experience-customization/img/positive-trend-dark.png b/packages/core/stories/patterns/experience-customization/img/positive-trend-dark.png
new file mode 100644
index 00000000000..0122d28b1bf
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/positive-trend-dark.png differ
diff --git a/packages/core/stories/patterns/experience-customization/img/positive-trend.png b/packages/core/stories/patterns/experience-customization/img/positive-trend.png
new file mode 100644
index 00000000000..4f58776d43e
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/positive-trend.png differ
diff --git a/packages/core/stories/patterns/experience-customization/img/table-high-dark.png b/packages/core/stories/patterns/experience-customization/img/table-high-dark.png
new file mode 100644
index 00000000000..0ea5ceff94e
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/table-high-dark.png differ
diff --git a/packages/core/stories/patterns/experience-customization/img/table-high.png b/packages/core/stories/patterns/experience-customization/img/table-high.png
new file mode 100644
index 00000000000..11387800c9e
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/table-high.png differ
diff --git a/packages/core/stories/patterns/experience-customization/img/table-low-dark.png b/packages/core/stories/patterns/experience-customization/img/table-low-dark.png
new file mode 100644
index 00000000000..3389fd1f277
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/table-low-dark.png differ
diff --git a/packages/core/stories/patterns/experience-customization/img/table-low.png b/packages/core/stories/patterns/experience-customization/img/table-low.png
new file mode 100644
index 00000000000..8f8879e1c90
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/table-low.png differ
diff --git a/packages/core/stories/patterns/experience-customization/img/table-medium-dark.png b/packages/core/stories/patterns/experience-customization/img/table-medium-dark.png
new file mode 100644
index 00000000000..6657e783f8b
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/table-medium-dark.png differ
diff --git a/packages/core/stories/patterns/experience-customization/img/table-medium.png b/packages/core/stories/patterns/experience-customization/img/table-medium.png
new file mode 100644
index 00000000000..945ea7f8e3e
Binary files /dev/null and b/packages/core/stories/patterns/experience-customization/img/table-medium.png differ
diff --git a/packages/core/stories/patterns/wizard/useWizardForm.ts b/packages/core/stories/patterns/wizard/useWizardForm.ts
index 362e7631fab..8662f65c211 100644
--- a/packages/core/stories/patterns/wizard/useWizardForm.ts
+++ b/packages/core/stories/patterns/wizard/useWizardForm.ts
@@ -31,7 +31,7 @@ export interface WizardFormState {
}
export type WizardFormAction =
- | { type: "UPDATE_FIELD"; name: string; value: string } // Update a specific field in the form data
+ | { type: "UPDATE_FIELD"; name: string; value: unknown } // Update a specific field in the form data
| {
type: "SET_VALIDATION"; // Set validation results for a step
stepId: string; // The ID of the step being validated
@@ -134,13 +134,21 @@ export function useWizardForm({
[currentStepId, state.formData, validateStep],
);
- // Updates a specific field in the form data and revalidates the step
+ // Updates a specific field in the form data and revalidates if there are existing errors
const updateField = useCallback(
- (name: string, value: string) => {
+ (name: string, value: unknown) => {
dispatch({ type: "UPDATE_FIELD", name, value });
- runValidationAndStore({ ...state.formData, [name]: value });
+ // Only revalidate if there are already validation errors on this step
+ if (state.validationsByStep[currentStepId]) {
+ runValidationAndStore({ ...state.formData, [name]: value });
+ }
},
- [state.formData, runValidationAndStore],
+ [
+ state.formData,
+ state.validationsByStep,
+ currentStepId,
+ runValidationAndStore,
+ ],
);
// Moves to the next step if the current step is valid
diff --git a/packages/core/stories/patterns/wizard/utils/index.ts b/packages/core/stories/patterns/wizard/utils/index.ts
new file mode 100644
index 00000000000..22f7bbb3b62
--- /dev/null
+++ b/packages/core/stories/patterns/wizard/utils/index.ts
@@ -0,0 +1,63 @@
+import type {
+ FieldValidation,
+ StepValidationResult,
+ ValidationStatus,
+} from "../useWizardForm";
+
+// Map Yup validation errors (including custom warning severity) to FieldValidation shape
+interface YupValidationErrorShape {
+ inner?: Array<{
+ path: string;
+ message: string;
+ params?: Record;
+ }>;
+ path?: string;
+ message?: string;
+}
+
+interface ValidationSchemaShape {
+ validate: (data: any, options: { abortEarly: boolean }) => Promise;
+}
+
+export function mapYupErrors(
+ err: YupValidationErrorShape,
+): StepValidationResult["fields"] {
+ const out: StepValidationResult["fields"] = {};
+ const list = err.inner ?? [];
+
+ for (const e of list) {
+ const rawSeverity = e.params?.severity as ValidationStatus | undefined;
+ const status: ValidationStatus =
+ rawSeverity === "warning" ? "warning" : "error";
+ // Last message wins for a field; overwrite for clarity
+ out[e.path] = { status, message: e.message };
+ }
+ // Fallback single error (when abortEarly true or inner empty)
+ if (!list.length && err.path) {
+ out[err.path] = { status: "error", message: err.message };
+ }
+
+ return out;
+}
+
+// Validate a single wizard step given current form data; returns fields map
+export async function validateStep(
+ stepValidationSchemas: Record,
+ stepId: string,
+ data: any,
+): Promise> {
+ const schema = stepValidationSchemas[stepId];
+ if (!schema) return {};
+ try {
+ await schema.validate(data, { abortEarly: false });
+ return {}; // valid
+ } catch (err) {
+ return mapYupErrors(err as YupValidationErrorShape);
+ }
+}
+
+export const getStepStage = (index: number, activeStepIndex: number) => {
+ if (index === activeStepIndex) return "active";
+ if (index < activeStepIndex) return "completed";
+ return "pending";
+};
diff --git a/packages/core/stories/patterns/wizard/wizard.stories.tsx b/packages/core/stories/patterns/wizard/wizard.stories.tsx
index dd52821d866..a78d0431dd8 100644
--- a/packages/core/stories/patterns/wizard/wizard.stories.tsx
+++ b/packages/core/stories/patterns/wizard/wizard.stories.tsx
@@ -34,13 +34,9 @@ import { AccountTypeContent } from "./AccountTypeContent";
import { AdditionalInfoContent } from "./AdditionalInfoContent";
import { ContentOverflow } from "./ContentOverflow";
import { ReviewAccountContent } from "./ReviewAccountContent";
-import {
- type FieldValidation,
- type StepValidationResult,
- useWizardForm,
- type ValidationStatus,
-} from "./useWizardForm";
+import { type FieldValidation, useWizardForm } from "./useWizardForm";
import "./ContentOverflow.css";
+import { getStepStage, validateStep } from "./utils";
export default {
title: "Patterns/Wizard",
@@ -141,60 +137,6 @@ const stepValidationSchemas: Record<
review: Yup.object({}), // No validation
};
-// Map Yup validation errors (including custom warning severity) to FieldValidation shape
-interface YupValidationErrorShape {
- inner?: Array<{
- path: string;
- message: string;
- params?: Record;
- }>;
- path?: string;
- message?: string;
-}
-
-function mapYupErrors(
- err: YupValidationErrorShape,
-): StepValidationResult["fields"] {
- const out: StepValidationResult["fields"] = {};
- const list = err.inner ?? [];
-
- for (const e of list) {
- const rawSeverity = e.params?.severity as ValidationStatus | undefined;
- const status: ValidationStatus =
- rawSeverity === "warning" ? "warning" : "error";
- // Last message wins for a field; overwrite for clarity
- out[e.path] = { status, message: e.message };
- }
- // Fallback single error (when abortEarly true or inner empty)
- if (!list.length && err.path) {
- out[err.path] = { status: "error", message: err.message };
- }
-
- return out;
-}
-
-// Validate a single wizard step given current form data; returns fields map
-async function validateStep(
- stepId: string,
- // biome-ignore lint/suspicious/noExplicitAny: This is acceptable for an example.
- data: any,
-): Promise> {
- const schema = stepValidationSchemas[stepId];
- if (!schema) return {};
- try {
- await schema.validate(data, { abortEarly: false });
- return {}; // valid
- } catch (err) {
- return mapYupErrors(err as YupValidationErrorShape);
- }
-}
-
-const getStepStage = (index: number, activeStepIndex: number) => {
- if (index === activeStepIndex) return "active";
- if (index < activeStepIndex) return "completed";
- return "pending";
-};
-
export const Horizontal = () => {
const {
state: { activeStepIndex, formData, validationsByStep },
@@ -211,7 +153,8 @@ export const Horizontal = () => {
formData: initialFormData,
validationsByStep: {},
},
- validateStep,
+ validateStep: (stepId, data) =>
+ validateStep(stepValidationSchemas, stepId, data),
});
const [successOpen, setSuccessOpen] = useState(false);
@@ -249,10 +192,7 @@ export const Horizontal = () => {
handleInputChange: (e) => updateField(e.target.name, e.target.value),
handleSelectChange: (value: string, name: string) =>
updateField(name, value),
- handleRadioChange: (e) => {
- console.log("x");
- updateField(e.target.name, e.target.value);
- },
+ handleRadioChange: (e) => updateField(e.target.name, e.target.value),
stepFieldValidation: validationsByStep[currentStepId]?.fields || {},
};
@@ -376,7 +316,8 @@ export const HorizontalWithCancelConfirmation = () => {
formData: initialFormData,
validationsByStep: {},
},
- validateStep,
+ validateStep: (stepId, data) =>
+ validateStep(stepValidationSchemas, stepId, data),
});
const [successOpen, setSuccessOpen] = useState(false);
@@ -556,7 +497,8 @@ export const VerticalWithCancelConfirmation = () => {
formData: initialFormData,
validationsByStep: {},
},
- validateStep,
+ validateStep: (stepId, data) =>
+ validateStep(stepValidationSchemas, stepId, data),
});
const [successOpen, setSuccessOpen] = useState(false);
@@ -730,7 +672,8 @@ export const Modal = () => {
formData: initialFormData,
validationsByStep: {},
},
- validateStep,
+ validateStep: (stepId, data) =>
+ validateStep(stepValidationSchemas, stepId, data),
});
const [open, setOpen] = useState(false);
@@ -881,7 +824,8 @@ export const ModalWithConfirmations = () => {
formData: initialFormData,
validationsByStep: {},
},
- validateStep,
+ validateStep: (stepId, data) =>
+ validateStep(stepValidationSchemas, stepId, data),
});
const stepHeadingRef = useRef(null);
diff --git a/site/docs/patterns/experience-customization.mdx b/site/docs/patterns/experience-customization.mdx
index 2fb4c6a7980..27e0204a63b 100644
--- a/site/docs/patterns/experience-customization.mdx
+++ b/site/docs/patterns/experience-customization.mdx
@@ -13,6 +13,10 @@ data:
]
components: ["Banner", "Dropdown", "Radio button", "Stepper", "Switch", "Toggle button"]
relatedPatterns: ["Button bar", "Selectable card", "Wizard"]
+ resources:
+ [
+ { href: "https://storybook.saltdesignsystem.com/?path=/story/patterns-experience-customization", label: "Examples"},
+ ]
---