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 && ( + {stock.trendAlt} + )} + + + ))} + + + + + ); +}; 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 ( + <> + + + {wizardState === "cancel-warning" ? ( + <> + + + + + + Are you sure you want to cancel? + + Any changes you've made will be lost after you confirm + cancelling. + + + + + + + + + + + + + ) : ( + <> + + {wizardSteps[activeStepIndex].label} + + {`, step ${activeStepIndex + 1} of ${wizardSteps.length}`} + + + } + preheader="Customize your experience" + actions={ + + {wizardSteps.map((step, index) => ( + + ))} + + } + /> + {contentByStep[currentStepId]} + {footer} + + )} + + + ); +}; + +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={ + ) : undefined + } + endItem={ + + + + + } + /> + + + ); +}; 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"}, + ] ---