diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx index 6b04ef1779..a527320b03 100644 --- a/apps/prs/react/src/app/app.tsx +++ b/apps/prs/react/src/app/app.tsx @@ -89,6 +89,7 @@ export function App() { v2 header icons 3137 Work Side Menu Group 3306 Custom slug value for tabs + Public form A diff --git a/apps/prs/react/src/main.tsx b/apps/prs/react/src/main.tsx index aa2d0bbd40..9cb2f32730 100644 --- a/apps/prs/react/src/main.tsx +++ b/apps/prs/react/src/main.tsx @@ -68,6 +68,7 @@ import { Feat3241Route } from "./routes/features/feat3241"; import { FeatV2IconsRoute } from "./routes/features/featV2Icons"; import { Feat3137Route } from "./routes/features/feat3137"; import Feat3306Route from "./routes/features/feat3306"; +import { PublicFormRoute } from "./routes/features/public-form"; const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); @@ -144,6 +145,7 @@ root.render( } /> } /> } /> + } /> diff --git a/apps/prs/react/src/routes/everything.tsx b/apps/prs/react/src/routes/everything.tsx index ecd5d6fac2..e03d98b7d9 100644 --- a/apps/prs/react/src/routes/everything.tsx +++ b/apps/prs/react/src/routes/everything.tsx @@ -26,7 +26,6 @@ import { GoabDrawer, GoabDropdown, GoabDropdownItem, - GoabFieldset, GoabFileUploadCard, GoabFileUploadInput, GoabFilterChip, @@ -50,13 +49,6 @@ import { GoabPages, GoabPagination, GoabPopover, - GoabPublicForm, - GoabPublicFormPage, - GoabPublicFormSummary, - GoabPublicFormTask, - GoabPublicFormTaskList, - GoabPublicSubform, - GoabPublicSubformIndex, GoabRadioGroup, GoabRadioItem, GoabSideMenu, @@ -103,7 +95,6 @@ import { GoabFileUploadInputOnSelectFileDetail, GoabFileUploadOnCancelDetail, GoabFileUploadOnDeleteDetail, - GoabFormState, GoabFormStepStatus, GoabFormStepperOnChangeDetail, GoabIconButtonVariant, @@ -351,11 +342,6 @@ export function EverythingRoute(): JSX.Element { const [inputTrailingClicks, setInputTrailingClicks] = useState(0); const [numberInputTrailingClicks, setNumberInputTrailingClicks] = useState(0); const [menuAction, setMenuAction] = useState(); - const [publicFormEvents, setPublicFormEvents] = useState([]); - const [fieldsetContinueEvents, setFieldsetContinueEvents] = useState< - GoabFieldsetOnContinueDetail[] - >([]); - const [publicSubformEvents, setPublicSubformEvents] = useState([]); const logEvent = (name: string, detail: unknown) => { console.log(`[everything][react] ${name}`, detail); const entry: EventLogEntry = { name, detail, timestamp: new Date().toISOString() }; diff --git a/apps/prs/react/src/routes/features/public-form.tsx b/apps/prs/react/src/routes/features/public-form.tsx new file mode 100644 index 0000000000..b0212b1c46 --- /dev/null +++ b/apps/prs/react/src/routes/features/public-form.tsx @@ -0,0 +1,218 @@ +import { GoabFormItem, GoabInput, GoabPublicForm, GoabPublicFormPage, GoabPublicFormSummary, GoabRadioGroup, GoabRadioItem } from "@abgov/react-components"; +import { LengthValidator, NumericValidator, PFState, RequiredValidator, SINValidator } from "@abgov/ui-components-common"; +import React, { useState } from "react"; + +const outline: PFOutline = { + role: { + subform: false, + props: { + heading: "What is your role in the court order?", + "section-title": "Support order details", + }, + fields: { + role: { + label: "What is your role?", + formatter: (val: string) => val.toUpperCase(), + hideInSummary: "never", + }, + }, + next: (state: PFState) => { + const role = state.dataBuffer["role"]; + return role === "Payor" ? "salary" : "identification"; + }, + validators: { + role: [RequiredValidator("Role is required")], + }, + }, + + salary: { + subform: false, + props: { + heading: "Payor salary", + }, + fields: { + salary: { label: "Yearly income", hideInSummary: "never" }, + }, + next: "summary", + validators: { + salary: [NumericValidator({ min: 0 })], + }, + }, + + identification: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Do you know any of the identifiers about the other party?", + }, + fields: { + sin: { + label: "Social Insurance #", + formatter: (val: string) => val.match(/(.{3})/g)?.join(" ") || val, + hideInSummary: "never", + }, + ahcn: { + label: "Alberta Health Care #", + formatter: (val: string) => val.match(/(.{4})/g)?.join("-") || val, + hideInSummary: "never", + }, + info: { label: "Additional information", hideInSummary: "never" }, + }, + next: (state: PFState): string => { + const sin = state.dataBuffer["sin"]; + const ahcn = state.dataBuffer["ahcn"]; + + if (!sin && !ahcn) { + throw "Either sin or ahcn is required"; + } + + return "address"; + }, + validators: { + sin: [SINValidator()], + ahcn: [LengthValidator({ min: 8 })], + }, + }, + + payor: { + subform: false, + props: { + heading: "Payor Name", + }, + fields: { + firstName: { label: "First name", hideInSummary: "never" }, + lastName: { label: "Last name", hideInSummary: "never" }, + }, + summarize: (page: PFPage) => ({ + "Full name": `${page["firstName"]} ${page["lastName"]}`.trim(), + }), + next: "address", + validators: {}, + }, + + address: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Your current address", + }, + fields: { + city: { label: "City/Town", hideInSummary: "never" }, + street: { label: "Street #", hideInSummary: "never" }, + "postal-code": { label: "Postal code", hideInSummary: "never" }, + }, + next: "summary", + validators: {}, + }, + + summary: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Summary", + }, + fields: {}, + next: (state: PFState): string => { + console.log("submit to backend here", state); + return ""; + }, + validators: {}, + }, +}; + +export function PublicFormRoute() { + const [state, setState] = useState(undefined); + + const handleInit = (initFn: any) => { + // Initialize with restored state + const initialState = initFn(null, { outline }); + setState(initialState); + }; + + const handleChange = (detail: any) => { + console.log("onChange", detail); + }; + + const handleNext = (newState: PFState) => { + setState(newState); + console.log("onNext", newState); + }; + + const handleSubformChange = (newState: PFState) => { + setState({ ...newState }); + console.log("onSubformChange", newState); + }; + + const getPage = (pageId: string, defaultValue: unknown) => { + return state?.data?.[pageId] || defaultValue; + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} diff --git a/apps/prs/web/src/app/App.svelte b/apps/prs/web/src/app/App.svelte index 3f219265f7..00244c2122 100644 --- a/apps/prs/web/src/app/App.svelte +++ b/apps/prs/web/src/app/App.svelte @@ -3,6 +3,7 @@ import { Router, Route } from "svelte-routing"; import Issue2333 from "../routes/2333.svelte"; import Issue3279 from "../routes/3279.svelte"; + import PublicFormExample from "../routes/public-form.svelte"; @@ -12,4 +13,5 @@ + diff --git a/apps/prs/web/src/routes/public-form.svelte b/apps/prs/web/src/routes/public-form.svelte new file mode 100644 index 0000000000..1c53f9807e --- /dev/null +++ b/apps/prs/web/src/routes/public-form.svelte @@ -0,0 +1,393 @@ + + +
+ + + + + + + + + + + + + + {#each getPage(_state, "children", []) as child (child._id)} + + + + + {/each} +
{child["first-name"]}{child["last-name"]}Edit Remove +
+ +
+ + + + + + + + + +
+
+
+ + + This is a page that is read-only + + + Lorem ipsum dolor sit amet consectetur adipisicing elit. Possimus, odit! + Voluptatem dolor soluta aspernatur ipsa dolorem est iure vitae eaque ea, vero + architecto praesentium, quia excepturi, odio porro? Fuga, officia? + + + + + + + + + +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/apps/public-form-demo/FEEDBACK.md b/apps/public-form-demo/FEEDBACK.md new file mode 100644 index 0000000000..b4d44c0f4a --- /dev/null +++ b/apps/public-form-demo/FEEDBACK.md @@ -0,0 +1,401 @@ +# Public Form Pattern Feedback + +## Summary + +While implementing a full public form demo using `GoabPublicForm`, we encountered friction points and gaps in the current pattern. This document catalogs those issues along with workarounds we implemented and suggestions for pattern improvements. + +**Demo:** https://publicform.netlify.app + +**Overview:** +- 4 bug fixes applied +- 6 enhancements implemented (logic changes) +- 6 checkbox/form pattern issues documented +- 5 related component bugs found +- 11 suggestions +- Styling adjustments +- ⚠️ **~1,550 lines deleted** — architecture simplified (see "Architecture Changes" section) + +--- + +## Issues & Workarounds + +### Back Navigation + +**The scenario:** On the first question of a task, "Back" should say "Back to all tasks" and return to the task list. On subsequent questions, it should say "Back" and go to the previous question. This is the standard Figma pattern for multi-task forms. + +**The problems:** + +- **No `backText` prop** — Can't customize the back button text per page. We needed "Back to all tasks" on first page, "Back" on others. + +- **No first-page detection** — No way to know if current page is first in the form, so we can't conditionally show different text/behavior. + +- **No `onBack` callback** — When user clicks the form's Back button, the parent component isn't notified. We need to know when they click "Back to all tasks" so we can navigate to the task list instead of the previous question. + +- **Content renders below heading** — The back link needs to appear *above* the page heading (per Figma), but `GoabPublicFormPage` children render below it. + +**What we did:** Hide the native back button (`backVisibility="hidden"`), render our own `BackLink` component *outside* `GoabPublicForm`, and manually track the current page from state to determine what text to show and where to navigate. + +**Open to better approaches** — If there's a cleaner way to handle this (props, callbacks, slots), happy to refactor. + +### Link-Style Buttons + +**The scenario:** Forms need action links that look like text links but trigger actions — "Save and exit", "Back to all tasks", etc. These aren't navigation links (no href), they're buttons styled as links. + +**The problem:** No component currently handles this. GoabxLink is for navigation (no onClick), and Button doesn't have a text/link variant. + +**What we did:** Created custom `BackLink` and `ActionLink` components using styled ` + ); +} diff --git a/apps/public-form-demo/src/components/FormShell.css b/apps/public-form-demo/src/components/FormShell.css new file mode 100644 index 0000000000..d23da920ba --- /dev/null +++ b/apps/public-form-demo/src/components/FormShell.css @@ -0,0 +1,29 @@ +.form-shell { + min-height: 100vh; + padding: var(--goa-space-xl) var(--goa-space-m); +} + +.form-shell__header { + max-width: 60ch; + margin: 0 auto var(--goa-space-2xl); +} + +.form-shell__service-name { + font-size: var(--goa-font-size-8); + font-weight: var(--goa-font-weight-bold); + line-height: var(--goa-line-height-2); + margin: 0 0 var(--goa-space-xs); + color: var(--goa-color-text-default); +} + +.form-shell__service-description { + font-size: var(--goa-font-size-4); + line-height: var(--goa-line-height-3); + margin: 0; + color: var(--goa-color-text-secondary); +} + +.form-shell__content { + max-width: 60ch; + margin: 0 auto; +} diff --git a/apps/public-form-demo/src/components/FormShell.tsx b/apps/public-form-demo/src/components/FormShell.tsx new file mode 100644 index 0000000000..128ad83e03 --- /dev/null +++ b/apps/public-form-demo/src/components/FormShell.tsx @@ -0,0 +1,39 @@ +import { ReactNode } from "react"; +import "./FormShell.css"; + +interface FormShellProps { + /** Service name shown at the top */ + serviceName?: string; + /** Optional subtitle or description */ + serviceDescription?: string; + /** The form content */ + children: ReactNode; +} + +/** + * A clean container for public-facing forms. + * Provides consistent width, spacing, and optional service branding. + * + * Designed for citizen-facing services using the "one idea per page" pattern. + */ +export function FormShell({ + serviceName, + serviceDescription, + children +}: FormShellProps) { + return ( +
+ {serviceName && ( +
+

{serviceName}

+ {serviceDescription && ( +

{serviceDescription}

+ )} +
+ )} +
+ {children} +
+
+ ); +} diff --git a/apps/public-form-demo/src/components/index.ts b/apps/public-form-demo/src/components/index.ts new file mode 100644 index 0000000000..902ee42270 --- /dev/null +++ b/apps/public-form-demo/src/components/index.ts @@ -0,0 +1 @@ +export { BackLink } from "./BackLink"; diff --git a/apps/public-form-demo/src/main.tsx b/apps/public-form-demo/src/main.tsx new file mode 100644 index 0000000000..d14c65b5cc --- /dev/null +++ b/apps/public-form-demo/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react"; +import * as ReactDOM from "react-dom/client"; +import "@abgov/web-components"; +// Component styles (fonts, reset, base styles) +import "@abgov/style"; +// V2 design tokens - loaded after to override v1 token values +import "@abgov/design-tokens/dist/tokens.css"; +import App from "./App"; + +const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); + +root.render( + + + +); diff --git a/apps/public-form-demo/src/styles.css b/apps/public-form-demo/src/styles.css new file mode 100644 index 0000000000..78058f4cef --- /dev/null +++ b/apps/public-form-demo/src/styles.css @@ -0,0 +1,103 @@ +/* Global styles for Public Form Demo */ + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; +} + +body { + font-family: acumin-pro-semi-condensed, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* App layout - footer just below the fold */ +.app { + display: flex; + flex-direction: column; +} + +.main-content { + min-height: calc(100vh - 66px); /* Account for header height */ + padding: var(--goa-space-xl) 0 var(--goa-space-3xl); /* 32px top, 64px bottom */ +} + +.form-container { + max-width: 60ch; + margin: 0 auto; +} + +.service-description { + font-size: var(--goa-font-size-4); + color: var(--goa-color-text-secondary); + margin: 0 0 var(--goa-space-xl); +} + +/* ============================================================================= + FormSet Pattern + Centered form container with consistent spacing. + See Brief 05a for design rationale. + ============================================================================= */ + +.form-set { + max-width: 640px; + margin: 0 auto; + padding: 0 var(--goa-space-m); /* Horizontal only; vertical handled by .main-content */ +} + +/* Section spacing - 32px between major sections */ +.form-set > * + * { + margin-top: var(--goa-space-xl); +} + +/* Field spacing within sections - 16px between fields */ +.form-section > * + * { + margin-top: var(--goa-space-m); +} + +/* Form fields container - 32px gap between fields (matches Figma goa-form-set) */ +.form-fields { + display: flex; + flex-direction: column; +} + +/* Default spacing between form elements - 32px */ +.form-fields > * + * { + margin-top: var(--goa-space-xl); /* 32px */ +} + +/* Details following a form-item gets reduced spacing - 16px */ +.form-fields > goa-form-item + goa-details, +.form-fields > goax-form-item + goa-details { + margin-top: var(--goa-space-m); /* 16px */ +} + +/* ============================================================================= + Action Link + Button styled to look like a link (for onClick handlers that aren't navigation). + GoabxLink uses action-based events which don't work well with React handlers. + ============================================================================= */ + +.action-link { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--goa-color-interactive-default); + text-decoration: underline; + cursor: pointer; +} + +.action-link:hover { + color: var(--goa-color-interactive-hover); +} + +.action-link:focus-visible { + outline: var(--goa-border-width-l) solid var(--goa-color-interactive-focus); + outline-offset: 2px; + border-radius: var(--goa-border-radius-s); +} diff --git a/apps/public-form-demo/src/task1/Task1Form.tsx b/apps/public-form-demo/src/task1/Task1Form.tsx new file mode 100644 index 0000000000..ae724046ef --- /dev/null +++ b/apps/public-form-demo/src/task1/Task1Form.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect, useRef } from "react"; +import { + GoabPublicForm, + GoabPublicFormPage, + GoabPublicFormSummary, +} from "@abgov/react-components"; +import { GoabxCheckbox, GoabxFormItem } from "@abgov/react-components/experimental"; +import { PFOutline, PFState } from "@abgov/ui-components-common"; +import { BackLink } from "../components"; +import { task1Outline } from "./outline"; + +type InitFunction = (data: PFState, props: { outline: PFOutline }) => PFState; +import { + UrgentNeed, + MaritalStatus, + Address, + ContactPreference, + Consent, + UploadId, + Dependants, +} from "./pages"; + +type Task1FormProps = { + initialState: PFState | null; + onComplete: (state: PFState) => void; + onExit: (state: PFState | null) => void; +}; + +/** + * Task 1: Personal Information + * + * Full GoabPublicForm implementation with 8 pages: + * 1. Urgent need (radio) + * 2. Marital status (dropdown) + * 3. Address (multi-field) + * 4. Contact preference (checkbox + conditional) + * 5. Consent (checkboxes + textarea) + * 6. Upload ID (file upload) + * 7. Dependants (repeater/subform) + * 8. Review (GoabPublicFormSummary) + */ +export function Task1Form({ initialState, onComplete, onExit }: Task1FormProps) { + const [state, setState] = useState(); + const formContainerRef = useRef(null); + + // Initialize form with outline and any existing state + const handleInit = (initFn: InitFunction) => { + let stateToUse = initialState || { data: {}, dataBuffer: {}, history: [] }; + + // If re-entering a completed task, clean up history so it ends with "review" + // (completed tasks have "" at end of history which would otherwise reset to first page) + if (stateToUse.history.length > 0 && stateToUse.history[stateToUse.history.length - 1] === "") { + const cleanedHistory = stateToUse.history.filter((h) => h !== ""); + if (!cleanedHistory.includes("review")) { + cleanedHistory.push("review"); + } + stateToUse = { ...stateToUse, history: cleanedHistory }; + } + + const initial = initFn(stateToUse, { outline: task1Outline }); + setState(initial); + }; + + // Listen for back navigation (form doesn't have onBack callback - workaround) + // Use capture phase on document since the event may not bubble + useEffect(() => { + const handleBack = () => { + // When back is clicked, the form pops history internally + // We need to sync our state by also popping + setState((prev) => { + if (!prev || prev.history.length <= 1) return prev; + const newHistory = [...prev.history]; + newHistory.pop(); + return { ...prev, history: newHistory }; + }); + }; + + document.addEventListener("form-page:back", handleBack, true); + return () => document.removeEventListener("form-page:back", handleBack, true); + }, []); + + // Handle navigation to next page + const handleNext = (newState: PFState) => { + // Deep copy to avoid mutation issues (form mutates its internal state directly) + const stateCopy: PFState = { + data: { ...newState.data }, + dataBuffer: { ...newState.dataBuffer }, + history: [...newState.history], + }; + setState(stateCopy); + + // Empty string in history signals form completion + const lastPage = newState.history[newState.history.length - 1]; + if (lastPage === "") { + onComplete(newState); + } + }; + + // Handle subform changes (add/edit/delete dependants) + const handleSubformChange = (newState: PFState) => { + // Spread to create new reference for React re-render + setState({ ...newState }); + }; + + // Get dependants from state for the Dependants page + const getDependants = () => { + if (!state?.data?.dependants) return []; + const deps = state.data.dependants; + if (Array.isArray(deps)) { + return deps as Array<{ _id: string; dependantName?: string }>; + } + return []; + }; + + // Determine current page from history (last item is current page, or first page if empty) + const currentPage = state?.history?.[state.history.length - 1] || "urgent-need"; + const isFirstPage = currentPage === "urgent-need"; + + return ( +
+ {/* Back link above the form - only on first page */} +
+ onExit(state || null)}> + Back to all tasks + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+ ); +} diff --git a/apps/public-form-demo/src/task1/index.ts b/apps/public-form-demo/src/task1/index.ts new file mode 100644 index 0000000000..1c102a5850 --- /dev/null +++ b/apps/public-form-demo/src/task1/index.ts @@ -0,0 +1,2 @@ +export { Task1Form } from "./Task1Form"; +export { task1Outline } from "./outline"; diff --git a/apps/public-form-demo/src/task1/outline.ts b/apps/public-form-demo/src/task1/outline.ts new file mode 100644 index 0000000000..f78b2292e7 --- /dev/null +++ b/apps/public-form-demo/src/task1/outline.ts @@ -0,0 +1,193 @@ +import { + PFOutline, + PFPage, + RequiredValidator, + ConditionalRequiredValidator, + PostalCodeValidator, + PhoneNumberValidator, + EmailValidator, +} from "@abgov/ui-components-common"; + +/** + * Task 1: Personal Information + * + * Pages: + * 1. urgent-need - Radio: Do you have an urgent financial need? + * 2. marital-status - Dropdown: What is your marital status? + * 3. address - Multi-field: Current address (street, city, province, postal) + * 4. contact-preference - Checkbox + conditional: How should we contact you? + * 5. consent - Checkboxes + textarea: Consent and additional info + * 6. upload-id - File upload: Supporting documents + * 7. dependants - Repeater/subform: Add dependants + * 8. review - Summary page with GoabPublicFormSummary + */ +export const task1Outline: PFOutline = { + "urgent-need": { + subform: false, + props: { + "section-title": "Personal information", + heading: "Do you have an urgent financial need?", + }, + fields: { + urgentNeed: { label: "Urgent financial need", hideInSummary: "never" }, + }, + validators: { + urgentNeed: [RequiredValidator("Select yes or no")], + }, + next: "marital-status", + }, + + "marital-status": { + subform: false, + props: { + "section-title": "Personal information", + heading: "What is your marital status?", + }, + fields: { + maritalStatus: { label: "Marital status", hideInSummary: "never" }, + }, + validators: { + maritalStatus: [RequiredValidator("Select your marital status")], + }, + next: "address", + }, + + address: { + subform: false, + props: { + "section-title": "Personal information", + heading: "What is your current address?", + }, + fields: { + streetAddress: { label: "Street address", hideInSummary: "never" }, + city: { label: "City or town", hideInSummary: "never" }, + province: { label: "Province or territory", hideInSummary: "never" }, + postalCode: { label: "Postal code", hideInSummary: "never" }, + }, + validators: { + streetAddress: [RequiredValidator("Enter your street address")], + city: [RequiredValidator("Enter your city or town")], + province: [RequiredValidator("Select your province or territory")], + postalCode: [RequiredValidator("Enter your postal code"), PostalCodeValidator()], + }, + next: "contact-preference", + }, + + "contact-preference": { + subform: false, + props: { + "section-title": "Personal information", + heading: "How would you like to be contacted?", + }, + fields: { + contactByPhone: { label: "Phone", hideInSummary: "ifBlank" }, + phoneNumber: { label: "Phone number", hideInSummary: "ifBlank" }, + contactByEmail: { label: "Email", hideInSummary: "ifBlank" }, + emailAddress: { label: "Email address", hideInSummary: "ifBlank" }, + contactByMail: { label: "Mail (postal address)", hideInSummary: "ifBlank" }, + }, + validators: { + // At least one contact method required - validate on first checkbox + contactByPhone: [ + ConditionalRequiredValidator( + (pageData) => !pageData?.contactByPhone && !pageData?.contactByEmail && !pageData?.contactByMail, + "Select at least one contact method" + ), + ], + phoneNumber: [ + ConditionalRequiredValidator( + (pageData) => !!pageData?.contactByPhone, + "Enter your phone number" + ), + PhoneNumberValidator("Enter a valid phone number"), + ], + emailAddress: [ + ConditionalRequiredValidator( + (pageData) => !!pageData?.contactByEmail, + "Enter your email address" + ), + EmailValidator("Enter a valid email address"), + ], + }, + next: "consent", + }, + + consent: { + subform: false, + props: { + "section-title": "Personal information", + heading: "Consent to use your personal information", + }, + fields: { + consentCheck: { + label: "I consent to having a check completed", + hideInSummary: "never", + }, + consentTruth: { + label: "Information provided is true", + hideInSummary: "never", + }, + fullName: { label: "Full name", hideInSummary: "never" }, + signature: { label: "Signature", hideInSummary: "never" }, + }, + validators: { + consentCheck: [RequiredValidator("You must consent to continue")], + consentTruth: [RequiredValidator("You must confirm information is true")], + fullName: [RequiredValidator("Enter your full name")], + signature: [RequiredValidator("Enter your signature")], + }, + summarize: (page: PFPage) => ({ + "Full name": (page["fullName"] as string) || "", + Signature: page["signature"] ? "Complete" : "Incomplete", + }), + next: "upload-id", + }, + + "upload-id": { + subform: false, + props: { + "section-title": "Personal information", + heading: "Upload your personal identification card", + }, + fields: { + idDocument: { + label: "Personal identification card", + hideInSummary: "never", + type: "file", + }, + }, + validators: { + idDocument: [RequiredValidator("Upload your identification")], + }, + next: "dependants", + }, + + dependants: { + subform: true, // Repeater pattern + props: { + "section-title": "Personal information", + heading: "Add dependants under the age of 18", + }, + fields: { + dependantName: { label: "Full name", hideInSummary: "never" }, + }, + validators: { + dependantName: [RequiredValidator("Enter the dependant's full name")], + }, + next: "review", + }, + + review: { + subform: false, + props: { + heading: "Review your answers", + }, + fields: { + confirmCorrect: { label: "Confirmation", hideInSummary: "always" }, + }, + validators: { + confirmCorrect: [RequiredValidator("You must confirm the information is correct")], + }, + next: "", // Empty string signals form completion + }, +}; diff --git a/apps/public-form-demo/src/task1/pages/Address.tsx b/apps/public-form-demo/src/task1/pages/Address.tsx new file mode 100644 index 0000000000..cf4a50733d --- /dev/null +++ b/apps/public-form-demo/src/task1/pages/Address.tsx @@ -0,0 +1,46 @@ +import { GoabxFormItem, GoabxInput, GoabxDropdown, GoabxDropdownItem } from "@abgov/react-components/experimental"; + +/** + * Page 3: Address + * + * Multi-field form with street, city, province dropdown, postal code. + * Province and postal code in horizontal layout. + * Heading comes from outline.props automatically. + */ +export function Address() { + return ( +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/apps/public-form-demo/src/task1/pages/Consent.tsx b/apps/public-form-demo/src/task1/pages/Consent.tsx new file mode 100644 index 0000000000..dfcc4fdedd --- /dev/null +++ b/apps/public-form-demo/src/task1/pages/Consent.tsx @@ -0,0 +1,44 @@ +import { GoabText } from "@abgov/react-components"; +import { GoabxFormItem, GoabxCheckbox, GoabxInput, GoabxTextArea } from "@abgov/react-components/experimental"; + +/** + * Page 5: Consent + * + * Rich content page with checkboxes for consent + full name input + signature textarea. + * Heading comes from outline.props automatically. + */ +export function Consent() { + return ( +
+ {/* TODO: Spacing not ideal - heading has 32px mb, want 8px. Negative margin doesn't work. See brief bug. */} + + The personal information collected through this service is for searching for + your record. This collection is authorized by section 33C of the Freedom of + Information and Protection of Privacy Act. + + + + <> + + + + + + + + + + + + +
+ ); +} diff --git a/apps/public-form-demo/src/task1/pages/ContactPreference.tsx b/apps/public-form-demo/src/task1/pages/ContactPreference.tsx new file mode 100644 index 0000000000..9b86607594 --- /dev/null +++ b/apps/public-form-demo/src/task1/pages/ContactPreference.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { GoabxFormItem, GoabxCheckbox, GoabxInput } from "@abgov/react-components/experimental"; + +/** + * Page 4: Contact Preference + * + * Each checkbox has a unique name because the public form system + * overwrites fields with the same name. + * + * IMPORTANT: Conditional inputs must always be in the DOM (just hidden) + * because the form registers fields at initialization. If they're + * conditionally rendered, their _change events get rejected with + * "Invalid formField key" error. + * + * Known issues documented in brief: + * - CheckboxList doesn't work with public form pattern + * - Multiple checkboxes with same name don't work (only last one registers) + * - Reveal slot inputs don't register with form + * - Conditionally rendered inputs don't work (must be hidden, not unmounted) + */ +export function ContactPreference() { + const [showPhone, setShowPhone] = useState(false); + const [showEmail, setShowEmail] = useState(false); + + const revealStyle = (visible: boolean) => ({ + borderLeft: "4px solid var(--goa-color-greyscale-200)", + paddingLeft: "var(--goa-space-l)", + marginLeft: "var(--goa-space-s)", + marginTop: "var(--goa-space-xs)", + marginBottom: "var(--goa-space-xs)", + display: visible ? "block" : "none", + }); + + return ( +
+ + <> + setShowPhone(e.checked)} + /> +
+ + + +
+ + setShowEmail(e.checked)} + /> +
+ + + +
+ + + +
+
+ ); +} diff --git a/apps/public-form-demo/src/task1/pages/Dependants.tsx b/apps/public-form-demo/src/task1/pages/Dependants.tsx new file mode 100644 index 0000000000..f9f11f146d --- /dev/null +++ b/apps/public-form-demo/src/task1/pages/Dependants.tsx @@ -0,0 +1,119 @@ +import { useRef } from "react"; +import { GoabxFormItem, GoabxInput } from "@abgov/react-components/experimental"; +import { GoabPfSubform } from "@abgov/react-components"; + +/** + * Page 7: Dependants (Repeater with Subform) + * + * Uses GoabPfSubform for add/edit/remove dependants. + * The subform opens a modal for adding/editing. + * + * Note: The dependants list is rendered OUTSIDE the GoabPfSubform because + * React children in web component slots don't update dynamically. We manually + * dispatch edit/delete events to the subform element. + * + * Heading comes from outline.props automatically. + */ + +function DependantForm() { + return ( + + + + ); +} + +type DependantsProps = { + dependants?: Array<{ _id: string; dependantName?: string }>; +}; + +export function Dependants({ dependants = [] }: DependantsProps) { + const subformRef = useRef(null); + + // Dispatch edit/delete events to the subform's inner element + // The subform listens on its inner _rootEl div inside the shadow DOM + const handleEdit = (id: string) => { + const subformEl = subformRef.current?.querySelector("goa-pf-subform"); + if (subformEl?.shadowRoot) { + const innerEl = subformEl.shadowRoot.querySelector("div"); + if (innerEl) { + innerEl.dispatchEvent(new CustomEvent("edit", { detail: id, bubbles: true })); + } + } + }; + + const handleDelete = (id: string) => { + const subformEl = subformRef.current?.querySelector("goa-pf-subform"); + if (subformEl?.shadowRoot) { + const innerEl = subformEl.shadowRoot.querySelector("div"); + if (innerEl) { + innerEl.dispatchEvent(new CustomEvent("delete", { detail: id, bubbles: true })); + } + } + }; + + return ( + <> + {/* Description in "description" slot - renders before error summary in FormPage */} +

+ Please enter the full name of any dependants under the age of 18 who should be + included in this application. This information will help determine eligibility for + services and benefits. If you have multiple dependants, you can add them using + the option below. Ensure that names are entered exactly as they appear on + official documents, such as birth certificates or legal guardianship papers. +

+ +
+ {/* Render list OUTSIDE the subform - React children in web component slots don't update */} + {dependants.length > 0 && ( +
+ {dependants.map((dep, index) => ( +
+ + Dependant {index + 1}: {dep.dependantName || "Unnamed"} + +
+ + +
+
+ ))} +
+ )} + +
+ } + addButtonText="Add a dependant" + addButtonType="tertiary" + addButtonSize="compact" + addButtonIcon="add" + addHeading="Add a dependant" + editHeading="Edit dependant" + /> +
+
+ + ); +} diff --git a/apps/public-form-demo/src/task1/pages/MaritalStatus.tsx b/apps/public-form-demo/src/task1/pages/MaritalStatus.tsx new file mode 100644 index 0000000000..32134b49da --- /dev/null +++ b/apps/public-form-demo/src/task1/pages/MaritalStatus.tsx @@ -0,0 +1,40 @@ +import { GoabxFormItem, GoabxDropdown, GoabxDropdownItem } from "@abgov/react-components/experimental"; +import { GoabDetails } from "@abgov/react-components"; + +/** + * Page 2: Marital Status + * + * Dropdown selection with Details component for help text. + * Heading comes from outline.props automatically. + */ +export function MaritalStatus() { + return ( +
+ + + + + + + + + + + + +

+ Your marital status is your legal relationship status. Choose the option + that best describes your current situation. +

+
    +
  • Single: Never been legally married
  • +
  • Married: Currently legally married
  • +
  • Common-law: Living with a partner for at least 12 months
  • +
  • Separated: Legally married but living apart from your spouse
  • +
  • Divorced: Marriage has been legally dissolved
  • +
  • Widowed: Spouse has passed away
  • +
+
+
+ ); +} diff --git a/apps/public-form-demo/src/task1/pages/UploadId.tsx b/apps/public-form-demo/src/task1/pages/UploadId.tsx new file mode 100644 index 0000000000..acabe2f524 --- /dev/null +++ b/apps/public-form-demo/src/task1/pages/UploadId.tsx @@ -0,0 +1,128 @@ +import { useState, useRef, useEffect } from "react"; +import { GoabxFormItem, GoabxFileUploadInput, GoabxFileUploadCard } from "@abgov/react-components/experimental"; +import { GoabDetails } from "@abgov/react-components"; + +type UploadedFile = { + name: string; + size: number; + type: string; + url: string; // Blob URL for preview/download +}; + +/** + * Page 6: Upload ID + * + * File upload for personal identification card. + * Uses GoabxFileUploadCard to display uploaded file. + * Dispatches _change event for public form to capture the filename. + * Heading comes from outline.props automatically. + */ +export function UploadId() { + const [uploadedFile, setUploadedFile] = useState(null); + const inputRef = useRef(null); + + // When file changes, dispatch _change event for public form + useEffect(() => { + if (inputRef.current) { + // Store file data as JSON string (includes blob URL for preview in review summary) + const fileData = uploadedFile + ? JSON.stringify({ + name: uploadedFile.name, + url: uploadedFile.url, + size: uploadedFile.size, + type: uploadedFile.type, + }) + : ""; + + // Public form listens for _change events with { name, value } detail + const event = new CustomEvent("_change", { + bubbles: true, + detail: { + name: "idDocument", + value: fileData, + }, + }); + inputRef.current.dispatchEvent(event); + } + }, [uploadedFile]); + + const handleSelectFile = (detail: { file: File }) => { + const file = detail.file; + if (file) { + // Create blob URL for preview/download in review summary + const blobUrl = URL.createObjectURL(file); + setUploadedFile({ + name: file.name, + size: file.size, + type: file.type, + url: blobUrl, + }); + // In production: upload file to backend and store permanent URL instead + } + }; + + const handleDelete = () => { + // Revoke the blob URL to free memory + if (uploadedFile?.url) { + URL.revokeObjectURL(uploadedFile.url); + } + setUploadedFile(null); + }; + + return ( +
+ + {/* Text input (visually hidden) to capture filename for public form */} + {}} // Controlled by state + style={{ + position: "absolute", + width: "1px", + height: "1px", + padding: 0, + margin: "-1px", + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + border: 0, + }} + aria-hidden="true" + tabIndex={-1} + /> + + {!uploadedFile ? ( + + ) : ( + + )} + + + +

You can upload any government-issued photo ID, such as:

+
    +
  • Driver's licence
  • +
  • Passport
  • +
  • Provincial ID card
  • +
  • Permanent resident card
  • +
+

+ Make sure the image is clear and all text is readable. Both sides of + the ID may be required. +

+
+
+ ); +} diff --git a/apps/public-form-demo/src/task1/pages/UrgentNeed.tsx b/apps/public-form-demo/src/task1/pages/UrgentNeed.tsx new file mode 100644 index 0000000000..20a334d69c --- /dev/null +++ b/apps/public-form-demo/src/task1/pages/UrgentNeed.tsx @@ -0,0 +1,18 @@ +import { GoabxFormItem, GoabxRadioGroup, GoabxRadioItem } from "@abgov/react-components/experimental"; + +/** + * Page 1: Urgent Financial Need + * + * Simple radio group question with Yes/No options. + * Heading and section-title come from outline.props automatically. + */ +export function UrgentNeed() { + return ( + + + + + + + ); +} diff --git a/apps/public-form-demo/src/task1/pages/index.ts b/apps/public-form-demo/src/task1/pages/index.ts new file mode 100644 index 0000000000..5f7592ec8b --- /dev/null +++ b/apps/public-form-demo/src/task1/pages/index.ts @@ -0,0 +1,7 @@ +export { UrgentNeed } from "./UrgentNeed"; +export { MaritalStatus } from "./MaritalStatus"; +export { Address } from "./Address"; +export { ContactPreference } from "./ContactPreference"; +export { Consent } from "./Consent"; +export { UploadId } from "./UploadId"; +export { Dependants } from "./Dependants"; diff --git a/apps/public-form-demo/src/task2/Task2Form.tsx b/apps/public-form-demo/src/task2/Task2Form.tsx new file mode 100644 index 0000000000..4557d5b534 --- /dev/null +++ b/apps/public-form-demo/src/task2/Task2Form.tsx @@ -0,0 +1,133 @@ +import { useState, useEffect } from "react"; +import { + GoabPublicForm, + GoabPublicFormPage, + GoabPublicFormSummary, +} from "@abgov/react-components"; +import { GoabxCheckbox, GoabxFormItem } from "@abgov/react-components/experimental"; +import { PFOutline, PFState } from "@abgov/ui-components-common"; +import { BackLink } from "../components"; +import { task2Outline } from "./outline"; +import { IdInfo, DateOfBirth } from "./pages"; + +type InitFunction = (data: PFState, props: { outline: PFOutline }) => PFState; + +type Task2FormProps = { + initialState: PFState | null; + onComplete: (state: PFState) => void; + onIneligible: () => void; + onExit: (state: PFState | null) => void; +}; + +/** + * Task 2: Verify Your Identity + * + * 3 pages: + * 1. ID info (info-only) + * 2. Date of birth (date picker) + * 3. Review (GoabPublicFormSummary) + */ +export function Task2Form({ initialState, onComplete, onIneligible, onExit }: Task2FormProps) { + const [state, setState] = useState(); + + // Initialize form with outline and any existing state + const handleInit = (initFn: InitFunction) => { + let stateToUse = initialState || { data: {}, dataBuffer: {}, history: [] }; + + // If re-entering a completed task, clean up history so it ends with "review" + if (stateToUse.history.length > 0 && stateToUse.history[stateToUse.history.length - 1] === "") { + const cleanedHistory = stateToUse.history.filter((h) => h !== ""); + if (!cleanedHistory.includes("review")) { + cleanedHistory.push("review"); + } + stateToUse = { ...stateToUse, history: cleanedHistory }; + } + + const initial = initFn(stateToUse, { outline: task2Outline }); + setState(initial); + }; + + // Listen for back navigation + useEffect(() => { + const handleBack = () => { + setState((prev) => { + if (!prev || prev.history.length <= 1) return prev; + const newHistory = [...prev.history]; + newHistory.pop(); + return { ...prev, history: newHistory }; + }); + }; + + document.addEventListener("form-page:back", handleBack, true); + return () => document.removeEventListener("form-page:back", handleBack, true); + }, []); + + // Handle navigation to next page + const handleNext = (newState: PFState) => { + const stateCopy: PFState = { + data: { ...newState.data }, + dataBuffer: { ...newState.dataBuffer }, + history: [...newState.history], + }; + setState(stateCopy); + + // Empty string in history signals form exit + const lastPage = newState.history[newState.history.length - 1]; + if (lastPage === "") { + // Check which page we exited from + const exitedFrom = newState.history[newState.history.length - 2]; + if (exitedFrom === "date-of-birth") { + // Exited from date-of-birth means ineligible (under 18) + onIneligible(); + } else { + onComplete(newState); + } + } + }; + + // Determine current page from history + const currentPage = state?.history?.[state.history.length - 1] || "id-info"; + const isFirstPage = currentPage === "id-info"; + + return ( +
+ {/* Back link above the form - only on first page */} +
+ onExit(state || null)}> + Back to all tasks + +
+ + + + + + + + + + + + +
+ + + +
+
+
+
+ ); +} diff --git a/apps/public-form-demo/src/task2/index.ts b/apps/public-form-demo/src/task2/index.ts new file mode 100644 index 0000000000..450149697e --- /dev/null +++ b/apps/public-form-demo/src/task2/index.ts @@ -0,0 +1,2 @@ +export { Task2Form } from "./Task2Form"; +export { task2Outline } from "./outline"; diff --git a/apps/public-form-demo/src/task2/outline.ts b/apps/public-form-demo/src/task2/outline.ts new file mode 100644 index 0000000000..31a2232e77 --- /dev/null +++ b/apps/public-form-demo/src/task2/outline.ts @@ -0,0 +1,85 @@ +import { + PFOutline, + PFState, + RequiredValidator, + DateValidator, +} from "@abgov/ui-components-common"; + +/** + * Calculate age from date of birth + */ +function calculateAge(dateOfBirth: string): number { + const dob = new Date(dateOfBirth); + const today = new Date(); + let age = today.getFullYear() - dob.getFullYear(); + const monthDiff = today.getMonth() - dob.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) { + age--; + } + return age; +} + +/** + * Task 2: Verify Your Age + * + * Pages: + * 1. id-info - Info-only page explaining what's needed + * 2. date-of-birth - Date picker for birthdate (branches based on age) + * 3. review - Summary page with GoabPublicFormSummary (if 18+) + * 4. ineligible - Exit page if under 18 + */ +export const task2Outline: PFOutline = { + "id-info": { + subform: false, + props: { + "section-title": "Verify your age", + heading: "What you'll need to verify your identity", + }, + fields: {}, + validators: {}, + next: "date-of-birth", + }, + + "date-of-birth": { + subform: false, + props: { + "section-title": "Verify your age", + heading: "What is your date of birth?", + }, + fields: { + dateOfBirth: { label: "Date of birth", hideInSummary: "never" }, + }, + validators: { + dateOfBirth: [ + RequiredValidator("Enter your date of birth"), + DateValidator({ + min: new Date(1900, 0, 1), + max: new Date(), + minMsg: "Enter a valid date of birth", + maxMsg: "Date of birth must be in the past", + }), + ], + }, + next: (state: PFState) => { + const dob = state.dataBuffer?.dateOfBirth as string; + if (dob && calculateAge(dob) < 18) { + return ""; // Exit immediately - ineligible + } + return "review"; + }, + }, + + review: { + subform: false, + props: { + heading: "Review your answers", + }, + fields: { + confirmCorrect: { label: "Confirmation", hideInSummary: "always" }, + }, + validators: { + confirmCorrect: [RequiredValidator("You must confirm the information is correct")], + }, + next: "", // Empty string signals form completion + }, +}; diff --git a/apps/public-form-demo/src/task2/pages/DateOfBirth.tsx b/apps/public-form-demo/src/task2/pages/DateOfBirth.tsx new file mode 100644 index 0000000000..43e30b8748 --- /dev/null +++ b/apps/public-form-demo/src/task2/pages/DateOfBirth.tsx @@ -0,0 +1,21 @@ +import { GoabxFormItem, GoabxDatePicker } from "@abgov/react-components/experimental"; + +/** + * Date of birth page - single date picker for past dates. + * Uses type="input" for month/day/year fields (better for known dates far in the past). + * No label needed - page heading serves as the label. + */ +export function DateOfBirth() { + return ( +
+ + + +
+ ); +} diff --git a/apps/public-form-demo/src/task2/pages/IdInfo.tsx b/apps/public-form-demo/src/task2/pages/IdInfo.tsx new file mode 100644 index 0000000000..89da7054c5 --- /dev/null +++ b/apps/public-form-demo/src/task2/pages/IdInfo.tsx @@ -0,0 +1,16 @@ +import { GoabText } from "@abgov/react-components"; + +/** + * Info-only page - no form inputs, just explanatory content. + * User clicks Continue to proceed. + */ +export function IdInfo() { + return ( +
+ + To verify your age, you will need to provide your date of birth. This + helps us determine your eligibility for age-restricted services. + +
+ ); +} diff --git a/apps/public-form-demo/src/task2/pages/Review.tsx b/apps/public-form-demo/src/task2/pages/Review.tsx new file mode 100644 index 0000000000..a3660fb128 --- /dev/null +++ b/apps/public-form-demo/src/task2/pages/Review.tsx @@ -0,0 +1,5 @@ +// Review page content is rendered directly in Task2Form.tsx +// using GoabPublicFormSummary + confirmation checkbox +// This file exists only for documentation purposes + +export {}; diff --git a/apps/public-form-demo/src/task2/pages/index.ts b/apps/public-form-demo/src/task2/pages/index.ts new file mode 100644 index 0000000000..94418bead6 --- /dev/null +++ b/apps/public-form-demo/src/task2/pages/index.ts @@ -0,0 +1,2 @@ +export { IdInfo } from "./IdInfo"; +export { DateOfBirth } from "./DateOfBirth"; diff --git a/apps/public-form-demo/src/task3/Task3Form.tsx b/apps/public-form-demo/src/task3/Task3Form.tsx new file mode 100644 index 0000000000..9259603854 --- /dev/null +++ b/apps/public-form-demo/src/task3/Task3Form.tsx @@ -0,0 +1,137 @@ +import { useState, useEffect } from "react"; +import { + GoabPublicForm, + GoabPublicFormPage, + GoabPublicFormSummary, +} from "@abgov/react-components"; +import { GoabxCheckbox, GoabxFormItem } from "@abgov/react-components/experimental"; +import { PFOutline, PFState } from "@abgov/ui-components-common"; +import { BackLink } from "../components"; +import { task3Outline } from "./outline"; +import { ApplyingFor, Relationship, Employment, WhenNeeded } from "./pages"; + +type InitFunction = (data: PFState, props: { outline: PFOutline }) => PFState; + +type Task3FormProps = { + initialState: PFState | null; + onComplete: (state: PFState) => void; + onExit: (state: PFState | null) => void; +}; + +/** + * Task 3: Your Situation + * + * 5 pages with branching: + * 1. Applying for (radio with branching) + * 2. Relationship (conditional - only if "someone else") + * 3. Employment (dropdown) + * 4. When needed (date picker) + * 5. Review (GoabPublicFormSummary) + */ +export function Task3Form({ initialState, onComplete, onExit }: Task3FormProps) { + const [state, setState] = useState(); + + // Initialize form with outline and any existing state + const handleInit = (initFn: InitFunction) => { + let stateToUse = initialState || { data: {}, dataBuffer: {}, history: [] }; + + // If re-entering a completed task, clean up history so it ends with "review" + if (stateToUse.history.length > 0 && stateToUse.history[stateToUse.history.length - 1] === "") { + const cleanedHistory = stateToUse.history.filter((h) => h !== ""); + if (!cleanedHistory.includes("review")) { + cleanedHistory.push("review"); + } + stateToUse = { ...stateToUse, history: cleanedHistory }; + } + + const initial = initFn(stateToUse, { outline: task3Outline }); + setState(initial); + }; + + // Listen for back navigation + useEffect(() => { + const handleBack = () => { + setState((prev) => { + if (!prev || prev.history.length <= 1) return prev; + const newHistory = [...prev.history]; + newHistory.pop(); + return { ...prev, history: newHistory }; + }); + }; + + document.addEventListener("form-page:back", handleBack, true); + return () => document.removeEventListener("form-page:back", handleBack, true); + }, []); + + // Handle navigation to next page + const handleNext = (newState: PFState) => { + const stateCopy: PFState = { + data: { ...newState.data }, + dataBuffer: { ...newState.dataBuffer }, + history: [...newState.history], + }; + setState(stateCopy); + + // Empty string in history signals form completion + const lastPage = newState.history[newState.history.length - 1]; + if (lastPage === "") { + onComplete(newState); + } + }; + + // Determine current page from history + const currentPage = state?.history?.[state.history.length - 1] || "applying-for"; + const isFirstPage = currentPage === "applying-for"; + + + return ( +
+ {/* Back link above the form - only on first page */} +
+ onExit(state || null)}> + Back to all tasks + +
+ + + + + + + {/* Conditional page - only shown when branching logic navigates here */} + + + + + + + + + + + + + + +
+ + + +
+
+
+
+ ); +} diff --git a/apps/public-form-demo/src/task3/index.ts b/apps/public-form-demo/src/task3/index.ts new file mode 100644 index 0000000000..67225e3e60 --- /dev/null +++ b/apps/public-form-demo/src/task3/index.ts @@ -0,0 +1,2 @@ +export { Task3Form } from "./Task3Form"; +export { task3Outline } from "./outline"; diff --git a/apps/public-form-demo/src/task3/outline.ts b/apps/public-form-demo/src/task3/outline.ts new file mode 100644 index 0000000000..7122bcf1d5 --- /dev/null +++ b/apps/public-form-demo/src/task3/outline.ts @@ -0,0 +1,108 @@ +import { + PFOutline, + PFState, + RequiredValidator, + DateValidator, +} from "@abgov/ui-components-common"; + +/** + * Task 3: Your Situation + * + * Pages: + * 1. applying-for - Radio: "myself" or "someone else" (BRANCHING) + * 2. relationship - CONDITIONAL: only shows if "someone else" selected + * 3. employment - Dropdown: employment status + * 4. when-needed - Date picker: future dates + * 5. review - Summary page + */ +export const task3Outline: PFOutline = { + "applying-for": { + subform: false, + props: { + "section-title": "Your situation", + heading: "Are you applying for yourself or someone else?", + }, + fields: { + applyingFor: { label: "Applying for", hideInSummary: "never" }, + }, + validators: { + applyingFor: [RequiredValidator("Select who you are applying for")], + }, + // BRANCHING LOGIC: Skip to employment if "myself", show relationship if "someone-else" + // NOTE: Must read from dataBuffer, not data - the next function runs BEFORE data is saved + next: (state: PFState) => { + // dataBuffer contains the current page's form values (before save) + const currentSelection = state.dataBuffer?.applyingFor; + if (currentSelection === "someone-else") { + return "relationship"; + } + return "employment"; // Skip relationship page + }, + }, + + relationship: { + subform: false, + props: { + "section-title": "Your situation", + heading: "What is your relationship to the person you are applying for?", + }, + fields: { + relationship: { label: "Relationship", hideInSummary: "never" }, + }, + validators: { + relationship: [RequiredValidator("Select your relationship")], + }, + next: "employment", + }, + + employment: { + subform: false, + props: { + "section-title": "Your situation", + heading: "What is the current employment status?", + }, + fields: { + employmentStatus: { label: "Employment status", hideInSummary: "never" }, + }, + validators: { + employmentStatus: [RequiredValidator("Select your employment status")], + }, + next: "when-needed", + }, + + "when-needed": { + subform: false, + props: { + "section-title": "Your situation", + heading: "When is assistance needed to begin?", + }, + fields: { + assistanceDate: { label: "Assistance start date", hideInSummary: "never" }, + }, + validators: { + assistanceDate: [ + RequiredValidator("Enter when you need assistance"), + DateValidator({ + // Tomorrow or later + min: new Date(new Date().setHours(24, 0, 0, 0)), + minMsg: "Date must be in the future", + }), + ], + }, + next: "review", + }, + + review: { + subform: false, + props: { + heading: "Review your answers", + }, + fields: { + confirmCorrect: { label: "Confirmation", hideInSummary: "always" }, + }, + validators: { + confirmCorrect: [RequiredValidator("You must confirm the information is correct")], + }, + next: "", // Empty string signals form completion + }, +}; diff --git a/apps/public-form-demo/src/task3/pages/ApplyingFor.tsx b/apps/public-form-demo/src/task3/pages/ApplyingFor.tsx new file mode 100644 index 0000000000..176638ec58 --- /dev/null +++ b/apps/public-form-demo/src/task3/pages/ApplyingFor.tsx @@ -0,0 +1,22 @@ +import { + GoabxFormItem, + GoabxRadioGroup, + GoabxRadioItem, +} from "@abgov/react-components/experimental"; + +/** + * Radio page with branching - "myself" or "someone else". + * The branching logic is handled in outline.ts via the `next` function. + */ +export function ApplyingFor() { + return ( +
+ + + + + + +
+ ); +} diff --git a/apps/public-form-demo/src/task3/pages/Employment.tsx b/apps/public-form-demo/src/task3/pages/Employment.tsx new file mode 100644 index 0000000000..ada85a131a --- /dev/null +++ b/apps/public-form-demo/src/task3/pages/Employment.tsx @@ -0,0 +1,28 @@ +import { + GoabxFormItem, + GoabxDropdown, + GoabxDropdownItem, +} from "@abgov/react-components/experimental"; + +/** + * Employment status dropdown page. + * No label needed - page heading serves as the label. + */ +export function Employment() { + return ( +
+ + + + + + + + + + + + +
+ ); +} diff --git a/apps/public-form-demo/src/task3/pages/Relationship.tsx b/apps/public-form-demo/src/task3/pages/Relationship.tsx new file mode 100644 index 0000000000..a89fa64af8 --- /dev/null +++ b/apps/public-form-demo/src/task3/pages/Relationship.tsx @@ -0,0 +1,28 @@ +import { + GoabxFormItem, + GoabxDropdown, + GoabxDropdownItem, +} from "@abgov/react-components/experimental"; + +/** + * Conditional page - only shown when user selects "someone else" on ApplyingFor. + * The branching logic is handled in outline.ts. + * No label needed - page heading serves as the label. + */ +export function Relationship() { + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/apps/public-form-demo/src/task3/pages/WhenNeeded.tsx b/apps/public-form-demo/src/task3/pages/WhenNeeded.tsx new file mode 100644 index 0000000000..6b0f75333c --- /dev/null +++ b/apps/public-form-demo/src/task3/pages/WhenNeeded.tsx @@ -0,0 +1,23 @@ +import { GoabxFormItem, GoabxDatePicker } from "@abgov/react-components/experimental"; + +/** + * Future date picker - when is assistance needed to begin? + * No label needed - page heading serves as the label. + */ +export function WhenNeeded() { + // Tomorrow (disables today in the calendar) + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + return ( +
+ + + +
+ ); +} diff --git a/apps/public-form-demo/src/task3/pages/index.ts b/apps/public-form-demo/src/task3/pages/index.ts new file mode 100644 index 0000000000..73efa8880d --- /dev/null +++ b/apps/public-form-demo/src/task3/pages/index.ts @@ -0,0 +1,4 @@ +export { ApplyingFor } from "./ApplyingFor"; +export { Relationship } from "./Relationship"; +export { Employment } from "./Employment"; +export { WhenNeeded } from "./WhenNeeded"; diff --git a/apps/public-form-demo/src/types.ts b/apps/public-form-demo/src/types.ts new file mode 100644 index 0000000000..d781aeb423 --- /dev/null +++ b/apps/public-form-demo/src/types.ts @@ -0,0 +1,83 @@ +import { PFState } from "@abgov/ui-components-common"; + +/** + * Views in the public form application. + * Three-level hierarchy: my-applications → task-list → task-n + */ +export type View = + | "start" + | "my-applications" + | "task-list" + | "task-1" + | "task-2" + | "task-3" + | "confirmation" + | "results" + | "ineligible"; + +/** + * Application status lifecycle. + * - not-started: Created but no tasks begun + * - in-progress: At least one task has been started + * - submitted: Application fully submitted + */ +export type ApplicationStatus = "not-started" | "in-progress" | "submitted"; + +/** + * A single application instance. + * Users can have multiple applications (drafts, submitted, etc.) + */ +export type Application = { + id: string; + referenceId: string; // Generated at creation (e.g., "APP-7X2K9M") + status: ApplicationStatus; + createdAt: Date; + updatedAt: Date; + + // Task state preservation (for resume) + task1State: PFState | null; + task2State: PFState | null; + task3State: PFState | null; + + // Explicit completion tracking (set when "Submit section" clicked) + task1Complete: boolean; + task2Complete: boolean; + task3Complete: boolean; +}; + +/** + * Status for individual tasks within an application. + */ +export type TaskStatus = "not-started" | "in-progress" | "complete"; + +/** + * Generate a short reference ID (e.g., "7X2K9M"). + * Uses base36 (0-9, A-Z) for readability. + */ +function generateReferenceId(): string { + // Use timestamp + random to ensure uniqueness + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).substring(2, 5).toUpperCase(); + // Take last 4 of timestamp + 3 random = 7 chars (e.g., "1234ABC") + return timestamp.slice(-4) + random.slice(0, 3); +} + +/** + * Create a new application with default values. + */ +export function createApplication(): Application { + const now = new Date(); + return { + id: crypto.randomUUID(), + referenceId: generateReferenceId(), + status: "not-started", + createdAt: now, + updatedAt: now, + task1State: null, + task2State: null, + task3State: null, + task1Complete: false, + task2Complete: false, + task3Complete: false, + }; +} diff --git a/apps/public-form-demo/src/views/ConfirmationPage.tsx b/apps/public-form-demo/src/views/ConfirmationPage.tsx new file mode 100644 index 0000000000..8eb73e66f6 --- /dev/null +++ b/apps/public-form-demo/src/views/ConfirmationPage.tsx @@ -0,0 +1,34 @@ +import { GoabxButton, GoabxCallout } from "@abgov/react-components/experimental"; +import { BackLink } from "../components"; + +type ConfirmationPageProps = { + onConfirm: () => void; + onBack: () => void; +}; + +export function ConfirmationPage({ onConfirm, onBack }: ConfirmationPageProps) { + return ( +
+
+ + Back to task list + +
+ +

Confirm and submit

+ + + This page will include: +
    +
  • High-level summary of application
  • +
  • Declaration checkbox
  • +
  • Final submit button
  • +
+
+ +
+ Submit application +
+
+ ); +} diff --git a/apps/public-form-demo/src/views/IneligibleResultsPage.tsx b/apps/public-form-demo/src/views/IneligibleResultsPage.tsx new file mode 100644 index 0000000000..f5437d3316 --- /dev/null +++ b/apps/public-form-demo/src/views/IneligibleResultsPage.tsx @@ -0,0 +1,57 @@ +import { GoabButton, GoabButtonGroup, GoabText } from "@abgov/react-components"; +import { GoabxCallout } from "@abgov/react-components/experimental"; + +type IneligibleResultsPageProps = { + onBackToStart: () => void; + onBackToApplications: () => void; +}; + +/** + * Ineligible Results Page - shown when user doesn't meet eligibility criteria + * Matches Figma design: node-id=60560-205776 + */ +export function IneligibleResultsPage({ onBackToStart, onBackToApplications }: IneligibleResultsPageProps) { + return ( +
+ + You are not eligible for this service + + + + You must be 18 years or older to apply for this service. +

+ You may now close this window. +
+ + + If you have questions about your application + + + + Contact the Service Support team. + + +
+ + Email:{" "} + information@gov.ab.ca + +
+ +
+ + Phone: 780 123 4567 + +
+ + + + Return to my applications + + + Back to Alberta.ca + + +
+ ); +} diff --git a/apps/public-form-demo/src/views/MyApplicationsPage.tsx b/apps/public-form-demo/src/views/MyApplicationsPage.tsx new file mode 100644 index 0000000000..c04ffdea29 --- /dev/null +++ b/apps/public-form-demo/src/views/MyApplicationsPage.tsx @@ -0,0 +1,238 @@ +import { useState } from "react"; +import { GoabxButton, GoabxBadge, GoabxModal } from "@abgov/react-components/experimental"; +import { GoabButtonGroup, GoabText } from "@abgov/react-components"; +import { Application, ApplicationStatus } from "../types"; + +type MyApplicationsPageProps = { + applications: Application[]; + onNewApplication: () => void; + onResumeApplication: (id: string) => void; + onDeleteApplication: (id: string) => void; +}; + +function StatusBadge({ status }: { status: ApplicationStatus }) { + switch (status) { + case "not-started": + return ; + case "in-progress": + return ; + case "submitted": + return ; + } +} + +function formatDate(date: Date): string { + return date.toLocaleDateString("en-CA", { + month: "long", + day: "numeric", + year: "numeric", + }); +} + +function getActionButtonText(status: ApplicationStatus): string { + switch (status) { + case "not-started": + return "Start"; + case "in-progress": + return "Continue"; + case "submitted": + return "View"; + } +} + +function getActionButtonType( + status: ApplicationStatus +): "secondary" | "tertiary" { + return status === "submitted" ? "tertiary" : "secondary"; +} + +export function MyApplicationsPage({ + applications, + onNewApplication, + onResumeApplication, + onDeleteApplication, +}: MyApplicationsPageProps) { + const [deleteModalApp, setDeleteModalApp] = useState(null); + + const handleDeleteClick = (app: Application) => { + setDeleteModalApp(app); + }; + + const handleDeleteConfirm = () => { + if (deleteModalApp) { + onDeleteApplication(deleteModalApp.id); + setDeleteModalApp(null); + } + }; + + const handleDeleteCancel = () => { + setDeleteModalApp(null); + }; + + return ( +
+ {/* Heading row with "+ New application" on right */} +
+ + My applications + + {/* TODO: Future enhancement - add "text" button type that looks like a link */} + + + New application + +
+ + {/* Subtext */} + + You may have up to 5 active registrations. Be aware files must be + activated within 6 months or else they will expire. + + + {/* Application cards */} + {applications.length === 0 ? ( + + You don't have any applications yet. Click "+ New application" to get + started. + + ) : ( +
+ {applications.map((app) => ( +
+ {/* Top row: Reference ID + Actions */} +
+ + {app.referenceId} + + + + onResumeApplication(app.id)} + > + {getActionButtonText(app.status)} + + handleDeleteClick(app)} + > + Delete + + +
+ + {/* Bottom row: 3-column metadata */} +
+ {/* Status */} +
+
+ Status +
+ +
+ + {/* Last updated */} +
+
+ Last updated +
+ + {formatDate(app.updatedAt)} + +
+ + {/* Created */} +
+
+ Created +
+ + {formatDate(app.createdAt)} + +
+
+
+ ))} +
+ )} + + {/* Delete confirmation modal */} + + + Cancel + + + Delete application + + + } + onClose={handleDeleteCancel} + > + You are about to delete the application {deleteModalApp?.referenceId}. This cannot be undone. + +
+ ); +} diff --git a/apps/public-form-demo/src/views/ResultsPage.tsx b/apps/public-form-demo/src/views/ResultsPage.tsx new file mode 100644 index 0000000000..df79569605 --- /dev/null +++ b/apps/public-form-demo/src/views/ResultsPage.tsx @@ -0,0 +1,77 @@ +import { GoabButton, GoabButtonGroup, GoabText } from "@abgov/react-components"; +import { GoabxCallout, GoabxLink } from "@abgov/react-components/experimental"; + +type ResultsPageProps = { + referenceId?: string; + onBackToDashboard: () => void; + onBackToStart: () => void; +}; + +/** + * Results Page - shown after successful application submission + * Matches Figma design: node-id=60560-209724 + */ +export function ResultsPage({ referenceId, onBackToDashboard, onBackToStart }: ResultsPageProps) { + return ( +
+ + You have submitted your application + + + + You will receive a copy of the confirmation to the email name@email.com. +
+ Your reference number is {referenceId || "1234ABC"} +
+ + Download PDF of submitted application + +
+
+ + + What happens next + + + + We've sent your application for review. You will be contacted by email if we + need any more information from you. You can now close this window. + + + + What did you think of this service?{" "} + Give feedback + + + + If you have questions about your application + + + + Contact the Service Support team. + + +
+ + Email:{" "} + information@gov.ab.ca + +
+ +
+ + Phone: 780 123 4567 + +
+ + + + Return to my applications + + + Back to Alberta.ca + + +
+ ); +} diff --git a/apps/public-form-demo/src/views/StartPage.tsx b/apps/public-form-demo/src/views/StartPage.tsx new file mode 100644 index 0000000000..f129b13455 --- /dev/null +++ b/apps/public-form-demo/src/views/StartPage.tsx @@ -0,0 +1,90 @@ +import { + GoabxButton, + GoabxCallout, + GoabxBadge, +} from "@abgov/react-components/experimental"; +import { GoabButtonGroup, GoabText } from "@abgov/react-components"; +import illustrationSrc from "../assets/person-using-computer.svg"; + +type StartPageProps = { + onStartNew: () => void; + onContinue: () => void; +}; + +export function StartPage({ onStartNew, onContinue }: StartPageProps) { + return ( +
+ + Apply for a service + + + Apply for supports to pay for basic expenses like food, clothing and + shelter. + + + Person using a computer to apply for services + + +
    +
  • See if you or a family member is eligible for service.
  • +
  • Communicate and submit an application for service.
  • +
  • Continue an application that you already started.
  • +
+
+ +
+ + Before you begin + + +
+ + + In order to complete the application, you will need: + +
    +
  • A government issue ID for the person applying
  • +
  • At Alberta account
  • +
+ + + + Start an application + + + Continue an application + + + + + Other information about the service + + + This section contains supplementary details about the service, + including descriptions of less common scenarios, exceptions, and + additional resources available. It provides context and additional + insights that may be relevant to your specific circumstances or + interests, helping you understand the full scope and utility of the + service offered. + + + + Support + + + For assistance, email us at{" "} + help@gov.ab.ca + +
+ ); +} diff --git a/apps/public-form-demo/src/views/TaskListPage.tsx b/apps/public-form-demo/src/views/TaskListPage.tsx new file mode 100644 index 0000000000..9173e49603 --- /dev/null +++ b/apps/public-form-demo/src/views/TaskListPage.tsx @@ -0,0 +1,376 @@ +import { useState } from "react"; +import { + GoabxCallout, + GoabxBadge, + GoabxCheckbox, + GoabxFormItem, +} from "@abgov/react-components/experimental"; +import { GoabButton, GoabText } from "@abgov/react-components"; +import { Application, TaskStatus } from "../types"; + +// Simple action link styled like GoabxLink (for onClick handlers) +function ActionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +type TaskListPageProps = { + application: Application; + onStartTask: (taskNumber: 1 | 2 | 3) => void; + onSubmitApplication: () => void; + onBackToDashboard: () => void; + canSubmit: boolean; +}; + +function getTaskStatus( + hasState: boolean, + isComplete: boolean +): TaskStatus { + if (isComplete) return "complete"; + if (hasState) return "in-progress"; + return "not-started"; +} + +// ============================================ +// Task Item Component +// ============================================ +type TaskItemProps = { + name: string; + status: TaskStatus | "locked"; + onClick?: () => void; + isLast?: boolean; +}; + +function TaskItem({ name, status, onClick, isLast }: TaskItemProps) { + const isLocked = status === "locked"; + const [isHovered, setIsHovered] = useState(false); + + return ( +
!isLocked && setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={() => !isLocked && onClick?.()} + role={isLocked ? undefined : "button"} + tabIndex={isLocked ? undefined : 0} + onKeyDown={(e) => { + if (!isLocked && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick?.(); + } + }} + > + {/* Task name */} + + {name} + + + {/* Status indicator */} + {status === "not-started" && ( + + )} + {status === "in-progress" && ( + + )} + {status === "complete" && ( + + )} + {status === "locked" && ( + + Cannot start yet + + )} +
+ ); +} + +// ============================================ +// Task Group Component +// ============================================ +type TaskGroupProps = { + number: number; + title: string; + locked?: boolean; + lockedMessage?: string; + children?: React.ReactNode; +}; + +function TaskGroup({ number, title, locked, lockedMessage, children }: TaskGroupProps) { + return ( +
+ {/* Group heading */} + + {number}. {title} + + + {/* Task items or locked message */} + {locked ? ( +
+ + {lockedMessage || "You need to complete the previous section before you can start this task."} + +
+ ) : ( +
+ {children} +
+ )} +
+ ); +} + +// ============================================ +// Status Callout Component +// ============================================ +type StatusCalloutProps = { + completedCount: number; + totalCount: number; + hasAnyProgress: boolean; // True if any task has been started or completed + onContinue?: () => void; +}; + +function StatusCallout({ completedCount, totalCount, hasAnyProgress, onContinue }: StatusCalloutProps) { + const allComplete = completedCount === totalCount; + const noneComplete = completedCount === 0; + + if (allComplete) { + return ( + + You have completed all sections of this application. +
+ Submit your application below +
+ ); + } + + if (noneComplete && !hasAnyProgress) { + return ( + + onContinue?.()}>Start first section + + ); + } + + // Some progress made but not all sections complete + const headingText = completedCount === 0 + ? "No sections complete" + : `${completedCount} ${completedCount === 1 ? "section" : "sections"} complete`; + return ( + + You have completed {completedCount} of {totalCount} sections + + ); +} + +// ============================================ +// Main TaskListPage Component +// ============================================ +export function TaskListPage({ + application, + onStartTask, + onSubmitApplication, + onBackToDashboard, + canSubmit, +}: TaskListPageProps) { + const [confirmChecked, setConfirmChecked] = useState(false); + const [showConfirmError, setShowConfirmError] = useState(false); + + const task1Status = getTaskStatus(!!application.task1State, application.task1Complete); + const task2Status = getTaskStatus(!!application.task2State, application.task2Complete); + const task3Status = getTaskStatus(!!application.task3State, application.task3Complete); + + // Section 1 is complete when tasks 2 and 3 are complete + const section1Complete = application.task2Complete && application.task3Complete; + // Section 2 is complete when task 1 is complete + const section2Complete = application.task1Complete; + + // Count completed sections + const completedSections = [section1Complete, section2Complete].filter(Boolean).length; + const totalSections = 2; + + // Check if any task has been started or completed + const hasAnyProgress = !!(application.task1State || application.task2State || application.task3State); + + // Find the next task to continue (Section 1: tasks 2,3 then Section 2: task 1) + const getNextTask = (): 1 | 2 | 3 => { + if (!application.task2Complete) return 2; + if (!application.task3Complete) return 3; + return 1; // Section 2 + }; + + const handleSubmit = () => { + if (!confirmChecked) { + setShowConfirmError(true); + return; + } + setShowConfirmError(false); + onSubmitApplication(); + }; + + const handleConfirmChange = () => { + setConfirmChecked(!confirmChecked); + if (showConfirmError) { + setShowConfirmError(false); + } + }; + + return ( +
+ + Application {application.referenceId} + + + + Your progress is saved automatically. You can return to complete this application at any time. + + + onStartTask(getNextTask())} + /> + +
+ {/* Section 1: Prepare your application */} + + onStartTask(2)} + /> + onStartTask(3)} + isLast + /> + + + {/* Section 2: Additional information (locked until section 1 complete) */} + + onStartTask(1)} + isLast + /> + +
+ + {/* Submit Section */} +
+ {/* Not ready to submit */} + {!canSubmit && ( + <> + + Complete all sections above to submit your application. + + + Submit application + + + )} + + {/* Ready to submit */} + {canSubmit && ( + <> + + + +
+ + Submit application + +
+ + )} +
+
+ ); +} diff --git a/apps/public-form-demo/src/views/index.ts b/apps/public-form-demo/src/views/index.ts new file mode 100644 index 0000000000..255ed97125 --- /dev/null +++ b/apps/public-form-demo/src/views/index.ts @@ -0,0 +1,6 @@ +export { StartPage } from "./StartPage"; +export { MyApplicationsPage } from "./MyApplicationsPage"; +export { TaskListPage } from "./TaskListPage"; +export { ConfirmationPage } from "./ConfirmationPage"; +export { ResultsPage } from "./ResultsPage"; +export { IneligibleResultsPage } from "./IneligibleResultsPage"; diff --git a/apps/public-form-demo/tsconfig.app.json b/apps/public-form-demo/tsconfig.app.json new file mode 100644 index 0000000000..7c6c2381e9 --- /dev/null +++ b/apps/public-form-demo/tsconfig.app.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/apps/public-form-demo/tsconfig.json b/apps/public-form-demo/tsconfig.json new file mode 100644 index 0000000000..fe609d7af3 --- /dev/null +++ b/apps/public-form-demo/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["vite/client"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/apps/public-form-demo/vite.config.ts b/apps/public-form-demo/vite.config.ts new file mode 100644 index 0000000000..b54d74c020 --- /dev/null +++ b/apps/public-form-demo/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; + +export default defineConfig({ + root: __dirname, + cacheDir: "../../node_modules/.vite/apps/public-form-demo", + + server: { + port: 4210, + host: "0.0.0.0", + }, + + preview: { + port: 4310, + host: "localhost", + }, + + plugins: [react(), nxViteTsPaths()], + + build: { + outDir: "../../dist/apps/public-form-demo", + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, +}); diff --git a/libs/angular-components/src/lib/components/form/fieldset.spec.ts b/libs/angular-components/src/lib/components/form/fieldset.spec.ts deleted file mode 100644 index fa23cf9605..0000000000 --- a/libs/angular-components/src/lib/components/form/fieldset.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabFieldset } from "./fieldset"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { GoabFieldsetOnContinueDetail } from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabFieldset], - template: ` - -
Test content
-
- `, -}) -class TestFieldsetComponent { - sectionTitle?: string; - dispatchOn: "change" | "continue" = "continue"; - id?: string; - - handleContinue(event: GoabFieldsetOnContinueDetail): void {/** do nothing **/} -} - -describe("GoabFieldSet", () => { - let fixture: ComponentFixture; - let component: TestFieldsetComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GoabFieldset, TestFieldsetComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestFieldsetComponent); - component = fixture.componentInstance; - - component.sectionTitle = "Test Section"; - component.dispatchOn = "continue"; - component.id = "test-fieldset"; - }); - - it("should render with properties", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-fieldset")).nativeElement; - - expect(el?.getAttribute("section-title")).toBe(component.sectionTitle); - expect(el?.getAttribute("dispatch-on")).toBe(component.dispatchOn); - expect(el?.getAttribute("id")).toBe(component.id); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should emit onContinue event", () => { - fixture.detectChanges(); - const spy = jest.spyOn(component, "handleContinue"); - - const el = fixture.debugElement.query(By.css("goa-fieldset")).nativeElement; - const detail = { value: "test" }; - - el.dispatchEvent(new CustomEvent("_continue", { detail })); - expect(spy).toHaveBeenCalledWith(detail); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/fieldset.ts b/libs/angular-components/src/lib/components/form/fieldset.ts deleted file mode 100644 index 5428e87c7d..0000000000 --- a/libs/angular-components/src/lib/components/form/fieldset.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input, Output, EventEmitter } from "@angular/core"; -import { GoabFormDispatchOn, GoabFieldsetOnContinueDetail } from "@abgov/ui-components-common"; - -@Component({ - selector: 'goab-fieldset', - template: ` - - - `, - standalone: true, - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class GoabFieldset { - @Input() id?: string; - @Input() sectionTitle?: string; - @Input() dispatchOn: GoabFormDispatchOn = "continue"; - - @Output() onContinue = new EventEmitter(); - - _onContinue(event: Event) { - const detail = (event as CustomEvent).detail; - this.onContinue.emit(detail); - } -} diff --git a/libs/angular-components/src/lib/components/form/public-subform-index.spec.ts b/libs/angular-components/src/lib/components/form/public-subform-index.spec.ts deleted file mode 100644 index 7f6a995a5b..0000000000 --- a/libs/angular-components/src/lib/components/form/public-subform-index.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicSubformIndex } from "./public-subform-index"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { Spacing } from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabPublicSubformIndex], - template: ` - -
Test content
-
- `, -}) -class TestPublicSubformIndexComponent { - heading = "Test Heading"; - sectionTitle = "Test Section Title"; - actionButtonText = "Add Item"; - buttonVisibility: "visible" | "hidden" = "visible"; - mt = "s" as Spacing; - mr = "m" as Spacing; - mb = "l" as Spacing; - ml = "xl" as Spacing; -} - -describe("GoabPublicSubformIndex", () => { - let fixture: ComponentFixture; - let component: TestPublicSubformIndexComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GoabPublicSubformIndex, TestPublicSubformIndexComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicSubformIndexComponent); - component = fixture.componentInstance; - }); - - it("should render with properties", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform-index")).nativeElement; - - expect(el?.getAttribute("heading")).toBe(component.heading); - expect(el?.getAttribute("section-title")).toBe(component.sectionTitle); - expect(el?.getAttribute("action-button-text")).toBe(component.actionButtonText); - expect(el?.getAttribute("button-visibility")).toBe(component.buttonVisibility); - expect(el?.getAttribute("mt")).toBe(component.mt); - expect(el?.getAttribute("mr")).toBe(component.mr); - expect(el?.getAttribute("mb")).toBe(component.mb); - expect(el?.getAttribute("ml")).toBe(component.ml); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should have default values", () => { - const subformIndex = new GoabPublicSubformIndex(); - expect(subformIndex.heading).toBe(""); - expect(subformIndex.sectionTitle).toBe(""); - expect(subformIndex.actionButtonText).toBe(""); - expect(subformIndex.buttonVisibility).toBe("hidden"); - }); - - it("should have the correct slot attribute on host element", () => { - fixture.detectChanges(); - - const hostElement = fixture.debugElement.query(By.css("goab-public-subform-index")).nativeElement; - expect(hostElement.getAttribute("slot")).toBe("subform-index"); - }); - - it("should pass through different property values", () => { - component.heading = "Updated Heading"; - component.sectionTitle = "Updated Section"; - component.actionButtonText = "Add Another"; - component.buttonVisibility = "hidden"; - component.mt = "none"; - component.mr = "xs"; - component.mb = "2xl"; - component.ml = "3xl"; - - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform-index")).nativeElement; - - expect(el?.getAttribute("heading")).toBe("Updated Heading"); - expect(el?.getAttribute("section-title")).toBe("Updated Section"); - expect(el?.getAttribute("action-button-text")).toBe("Add Another"); - expect(el?.getAttribute("button-visibility")).toBe("hidden"); - expect(el?.getAttribute("mt")).toBe("none"); - expect(el?.getAttribute("mr")).toBe("xs"); - expect(el?.getAttribute("mb")).toBe("2xl"); - expect(el?.getAttribute("ml")).toBe("3xl"); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/public-subform.spec.ts b/libs/angular-components/src/lib/components/form/public-subform.spec.ts deleted file mode 100644 index 11d7b30744..0000000000 --- a/libs/angular-components/src/lib/components/form/public-subform.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicSubform } from "./public-subform"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { Spacing } from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabPublicSubform], - template: ` - -
Test content
-
- `, -}) -class TestPublicSubformComponent { - id = "test-subform"; - name = "test-subform-name"; - continueMsg = "Continue to next step"; - mt = "s" as Spacing; - mr = "m" as Spacing; - mb = "l" as Spacing; - ml = "xl" as Spacing; - - initEventCalled = false; - stateChangeEventCalled = false; - lastInitEvent: Event | null = null; - lastStateChangeEvent: Event | null = null; - - handleInit(event: Event): void { - this.initEventCalled = true; - this.lastInitEvent = event; - } - - handleStateChange(event: Event): void { - this.stateChangeEventCalled = true; - this.lastStateChangeEvent = event; - } -} - -describe("GoabPublicSubform", () => { - let fixture: ComponentFixture; - let component: TestPublicSubformComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [GoabPublicSubform, TestPublicSubformComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicSubformComponent); - component = fixture.componentInstance; - }); - - it("should render with properties", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - - expect(el?.getAttribute("id")).toBe(component.id); - expect(el?.getAttribute("name")).toBe(component.name); - expect(el?.getAttribute("continue-msg")).toBe(component.continueMsg); - expect(el?.getAttribute("mt")).toBe(component.mt); - expect(el?.getAttribute("mr")).toBe(component.mr); - expect(el?.getAttribute("mb")).toBe(component.mb); - expect(el?.getAttribute("ml")).toBe(component.ml); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should have default values", () => { - const subform = new GoabPublicSubform(); - expect(subform.id).toBe(""); - expect(subform.name).toBe(""); - expect(subform.continueMsg).toBe(""); - }); - - it("should emit onInit event", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - const testEvent = new CustomEvent("_init", { detail: { test: "data" } }); - - el.dispatchEvent(testEvent); - - expect(component.initEventCalled).toBe(true); - expect(component.lastInitEvent).toBeTruthy(); - }); - - it("should emit onStateChange event", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - const testEvent = new CustomEvent("_stateChange", { detail: { state: "changed" } }); - - el.dispatchEvent(testEvent); - - expect(component.stateChangeEventCalled).toBe(true); - expect(component.lastStateChangeEvent).toBeTruthy(); - }); - - it("should pass through different property values", () => { - component.id = "updated-id"; - component.name = "updated-name"; - component.continueMsg = "Updated continue message"; - component.mt = "none"; - component.mr = "xs"; - component.mb = "2xl"; - component.ml = "3xl"; - - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - - expect(el?.getAttribute("id")).toBe("updated-id"); - expect(el?.getAttribute("name")).toBe("updated-name"); - expect(el?.getAttribute("continue-msg")).toBe("Updated continue message"); - expect(el?.getAttribute("mt")).toBe("none"); - expect(el?.getAttribute("mr")).toBe("xs"); - expect(el?.getAttribute("mb")).toBe("2xl"); - expect(el?.getAttribute("ml")).toBe("3xl"); - }); - - it("should handle empty string attributes correctly", () => { - component.id = ""; - component.name = ""; - component.continueMsg = ""; - - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - - expect(el?.getAttribute("id")).toBe(""); - expect(el?.getAttribute("name")).toBe(""); - expect(el?.getAttribute("continue-msg")).toBe(""); - }); - - it("should emit multiple events in sequence", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-subform")).nativeElement; - - // Reset counters - component.initEventCalled = false; - component.stateChangeEventCalled = false; - - // Emit init event - el.dispatchEvent(new CustomEvent("_init")); - expect(component.initEventCalled).toBe(true); - expect(component.stateChangeEventCalled).toBe(false); - - // Emit state change event - el.dispatchEvent(new CustomEvent("_stateChange")); - expect(component.stateChangeEventCalled).toBe(true); - }); -}); diff --git a/libs/angular-components/src/lib/components/form/task.spec.ts b/libs/angular-components/src/lib/components/form/task.spec.ts deleted file mode 100644 index 45541fec83..0000000000 --- a/libs/angular-components/src/lib/components/form/task.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { GoabPublicFormTask } from "./task"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; -import { By } from "@angular/platform-browser"; -import { GoabPublicFormTaskStatus } from "@abgov/ui-components-common"; - -@Component({ - standalone: true, - imports: [GoabPublicFormTask], - template: ` - -
Task content
-
- `, -}) -class TestPublicFormTaskComponent { - status: GoabPublicFormTaskStatus = "not-started"; -} - -describe("GoabPublicFormTask", () => { - let fixture: ComponentFixture; - let component: TestPublicFormTaskComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TestPublicFormTaskComponent, GoabPublicFormTask], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestPublicFormTaskComponent); - component = fixture.componentInstance; - }); - - it("should render with status property", () => { - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - - expect(el?.getAttribute("status")).toBe(component.status); - - // Content is rendered - expect(el?.querySelector("[data-testid='content']")).toBeTruthy(); - }); - - it("should have undefined status by default", () => { - const task = new GoabPublicFormTask(); - expect(task.status).toBeUndefined(); - }); - - it("should handle all valid status values", () => { - const statuses: GoabPublicFormTaskStatus[] = ["completed", "not-started", "cannot-start"]; - - statuses.forEach(status => { - component.status = status; - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.getAttribute("status")).toBe(status); - }); - }); - - it("should handle status changes", () => { - // Start with not-started - component.status = "not-started"; - fixture.detectChanges(); - - let el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.getAttribute("status")).toBe("not-started"); - - // Change to completed - component.status = "completed"; - fixture.detectChanges(); - - el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.getAttribute("status")).toBe("completed"); - - // Change to cannot-start - component.status = "cannot-start"; - fixture.detectChanges(); - - el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.getAttribute("status")).toBe("cannot-start"); - }); - - it("should render without status attribute when undefined", () => { - component.status = undefined as any; - fixture.detectChanges(); - - const el = fixture.debugElement.query(By.css("goa-public-form-task")).nativeElement; - expect(el?.hasAttribute("status")).toBeFalsy(); - }); -}); diff --git a/libs/angular-components/src/lib/components/index.ts b/libs/angular-components/src/lib/components/index.ts index 1204e799a5..858e4cb622 100644 --- a/libs/angular-components/src/lib/components/index.ts +++ b/libs/angular-components/src/lib/components/index.ts @@ -37,7 +37,6 @@ export * from "./form/public-subform"; export * from "./form/public-subform-index"; export * from "./form/task"; export * from "./form/task-list"; -export * from "./form/fieldset"; export * from "./form-item/form-item"; export * from "./form-item/form-item-slot"; export * from "./form-step/form-step"; diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 1a4e965dce..68f2191cf9 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -1,6 +1,6 @@ export * from "./lib/common"; export * from "./lib/experimental/common"; export * from "./lib/validators"; -export * from "./lib/public-form-controller"; export * from "./lib/temporary-notification-controller/temporary-notification-controller"; export * from "./lib/messaging/messaging"; +export type * from "./lib/public-form"; diff --git a/libs/common/src/lib/messaging/messaging.ts b/libs/common/src/lib/messaging/messaging.ts index df70e72922..6a855849b9 100644 --- a/libs/common/src/lib/messaging/messaging.ts +++ b/libs/common/src/lib/messaging/messaging.ts @@ -3,40 +3,62 @@ export function dispatch( el: HTMLElement | Element | null | undefined, eventName: string, detail?: T, - opts?: { bubbles?: boolean }, + opts?: { bubbles?: boolean; cancelable?: boolean; timeout?: number }, ) { - if (!el) { - console.error("dispatch element is null"); - return; + const dispatch = () => { + try { + el?.dispatchEvent?.( + new CustomEvent(eventName, { + composed: true, + bubbles: opts?.bubbles, + cancelable: opts?.cancelable, + detail, + }), + ); + } catch (e) { + console.error("dispatch() error:", e); + } + }; + + if (opts?.timeout) { + setTimeout(dispatch, opts.timeout); + } else { + dispatch(); } - el.dispatchEvent( - new CustomEvent(eventName, { - composed: true, - bubbles: opts?.bubbles, - detail: detail, - }), - ); } -// Public helper function to relay messages export function relay( el: HTMLElement | Element | null | undefined, eventName: string, data?: T, - opts?: { bubbles?: boolean }, + opts?: { bubbles?: boolean; cancelable?: boolean; timeout?: number }, ) { if (!el) { - console.error("dispatch element is null"); + console.warn("relay() el is null | undefined"); return; } - el.dispatchEvent( - new CustomEvent<{ action: string; data?: T }>("msg", { - composed: true, - bubbles: opts?.bubbles, - detail: { - action: eventName, - data, - }, - }), - ); + + const dispatch = () => { + try { + el?.dispatchEvent?.( + new CustomEvent<{ action: string; data?: T }>("msg", { + composed: true, + bubbles: opts?.bubbles, + cancelable: opts?.cancelable, + detail: { + action: eventName, + data, + }, + }), + ); + } catch (e) { + console.error("relay() error:", e); + } + }; + + if (opts?.timeout) { + setTimeout(dispatch, opts.timeout); + } else { + dispatch(); + } } diff --git a/libs/common/src/lib/public-form-controller.spec.ts b/libs/common/src/lib/public-form-controller.spec.ts deleted file mode 100644 index f350fa92c2..0000000000 --- a/libs/common/src/lib/public-form-controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { PublicFormController } from "./public-form-controller"; - -describe("PublicFormController", () => { - const pfc = new PublicFormController("details"); - - describe("clean", () => { - const data = JSON.parse( - `{"uuid":"5cdcd318-0219-49a8-9dc1-cd2df5f02875","form":{"what-is-your-role":{"data":{"type":"details","fieldsets":{"role":{"name":"role","value":"Recipient","label":"Role","order":1}}}},"children-subform":{"data":{"type":"list","items":[{"uuid":"f47d2410-8ab9-4bb0-9571-9fc5211416a9","form":{"name":{"data":{"type":"details","fieldsets":{"firstName":{"name":"firstName","value":"asdf","label":"First name","order":1},"lastName":{"name":"lastName","value":"asdf","label":"Last name","order":2}}}},"alternate-name":{"data":{"type":"details","fieldsets":{"alternate-name":{"name":"alternate-name","value":"asdf","label":"Alternate name","order":1}}}},"dob":{"heading":""},"complete":{"heading":"Complete"}},"history":["name","alternate-name","dob","complete"],"editting":"","lastModified":"2025-01-28T19:50:56.255Z","status":"not-started"},{"uuid":"9d3c44fe-2a38-4faf-a684-8d93799bbf8b","form":{"name":{"data":{"type":"details","fieldsets":{"firstName":{"name":"firstName","value":"dsfdfgh","label":"First name","order":1},"lastName":{"name":"lastName","value":"dfgh","label":"Last name","order":2}}}},"alternate-name":{"data":{"type":"details","fieldsets":{"alternate-name":{"name":"alternate-name","value":"dfgh","label":"Alternate name","order":1}}}}},"history":["name","alternate-name","dob","complete"],"editting":"","lastModified":"2025-01-28T19:51:02.919Z","status":"not-started"}]}},"address":{"data":{"type":"details","fieldsets":{"city":{"name":"city","value":"dfgh","label":"City","order":1},"address":{"name":"address","value":"dfgh","label":"Address","order":2},"postal-code":{"name":"postal-code","value":"dfg","label":"Postal Code","order":3}}}},"summary":{"heading":"Summary"},"index":{"heading":""}},"history":["children-subform","address","summary"],"editting":"","status":"not-started"}`, - ); - - it("should clean the data", () => { - const cleaned = pfc.clean(data); - expect(data.history.length).toBe(3); - expect(data.history.length).toEqual(Object.keys(cleaned).length); - }); - }); -}); diff --git a/libs/common/src/lib/public-form-controller.ts b/libs/common/src/lib/public-form-controller.ts deleted file mode 100644 index 14f9fb72a5..0000000000 --- a/libs/common/src/lib/public-form-controller.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { FieldsetItemState, FieldValidator } from "./validators"; -import { - GoabFieldsetItemValue, GoabFormDispatchOn, -} from "./common"; -import { relay, dispatch } from "./messaging/messaging"; - -export type FormStatus = "not-started" | "incomplete" | "complete"; - -// Public type to define the state of the form -export type AppState = { - uuid: string; - form: Record>; - history: string[]; - editting: string; - lastModified?: Date; - status: FormStatus; - currentFieldset?: { id: T; dispatchType: GoabFormDispatchOn }; -}; - -export type Fieldset = { - heading: string; - data: - | { type: "details"; fieldsets: Record } - | { type: "list"; items: AppState[] }; -}; - -export class PublicFormController { - state?: AppState | AppState[]; - _formData?: Record = undefined; - _formRef?: HTMLElement = undefined; - private _isCompleting = false; - - constructor(private type: "details" | "list") {} - - // Obtain reference to the form element - init(e: Event) { - // FIXME: This condition should not be needed, but currently it is the only way to get things working - if (this._formRef) { - console.warn("init: form element has already been set"); - return; - } - this._formRef = (e as CustomEvent).detail.el; - - this.state = { - uuid: crypto.randomUUID(), - form: {}, - history: [], - editting: "", - status: "not-started", - }; - } - - initList(e: Event) { - this._formRef = (e as CustomEvent).detail.el; - this.state = []; - } - - // Public method to allow for the initialization of the state - initState(state?: string | AppState | AppState[], callback?: () => void) { - relay(this._formRef, "external::init:state", state); - - if (typeof state === "string") { - this.state = JSON.parse(state); - } else if (!Array.isArray(state)) { - this.state = state; - } - - if (callback) { - setTimeout(callback, 200); - } - } - - updateListState(e: Event) { - const detail = (e as CustomEvent).detail; - - if (!Array.isArray(detail.data)) { - return; - } - - this.state = detail.data; - } - - #updateObjectListState(detail: { data: AppState[]; index: number; id: string }) { - if (!Array.isArray(detail.data)) { - return; - } - - if (Array.isArray(this.state)) { - return; - } - - this.state = { - ...this.state, - form: { - ...(this.state?.form || {}), - [detail.id]: detail.data, - }, - } as AppState; - } - - updateObjectState(e: Event) { - if (Array.isArray(this.state)) { - return; - } - - const detail = (e as CustomEvent).detail; - if (detail.type === "list") { - // form state being updated with subform array data - this.state = { - ...this.state, - form: { ...(this.state?.form || {}), [detail.id]: detail.data }, - } as AppState; - } else { - // form state being updated with form data - this.state = { - ...this.state, - ...detail.data, - form: { ...(this.state?.form || {}), ...detail.data.form }, - history: detail.data.history, - } as AppState; - } - } - - getStateList(): Record[] { - if (!this.state) { - return []; - } - if (!Array.isArray(this.state)) { - console.warn( - "Utils:getStateList: unable to update the state of a non-multi form type", - this.state, - ); - return []; - } - if (this.state.length === 0) { - return []; - } - - return this.state.map((s) => { - return Object.values(s.form) - .filter((item) => { - return item?.data?.type === "details"; - }) - .map((item) => { - return (item.data.type === "details" && item.data?.fieldsets) || {}; - }) - .reduce( - (acc, item) => { - for (const [key, value] of Object.entries(item)) { - acc[key] = value.value; - } - return acc; - }, - {} as Record, - ); - }); - } - - // getStateItems(group: string): Record[] { - // if (Array.isArray(this.state)) { - // console.error( - // "Utils:getStateItems: unable to update the state of a multi form type", - // ); - // return []; - // } - // if (!this.state) { - // console.error("Utils:getStateItems: state has not yet been set"); - // return []; - // } - // - // const data = this.state.form[group].data; - // if (data.type !== "list") { - // return []; - // } - // - // return data.items.; - // } - - // Public method to allow for the retrieval of the state value - getStateValue(group: string, key: string): string { - if (Array.isArray(this.state)) { - console.error("getStateValue: unable to update the state of a multi form type"); - return ""; - } - if (!this.state) { - console.error("getStateValue: state has not yet been set"); - return ""; - } - - const data = this.state.form[group].data; - if (data.type !== "details") { - return ""; - } - - return data.fieldsets[key].value; - } - - // Public method to allow for the continuing to the next page - continueTo(next: T | undefined) { - if (!next) { - console.error("continueTo [name] is undefined"); - return; - } - // Relay the continue message to the form element which will - // set the visibility of the fieldsets - // FIXME: this makes a call to the subform instead of the form - relay<{ next: T }>(this._formRef, "external::continue", { next }); - } - - // Public method to perform validation and send the appropriate messages to the form elements - validate( - e: Event, - field: string, - validators: FieldValidator[], - options?: { grouped: boolean }, - ): [boolean, GoabFieldsetItemValue] { - const { el, state, cancelled } = (e as CustomEvent).detail; - const value = state?.[field]?.value; - - window.scrollTo({ top: 0, behavior: "smooth" }); - - if (cancelled) { - return [true, value]; - } - - for (const validator of validators) { - const msg = validator(value); - this.#dispatchError(el, field, msg, options); - if (msg) { - return [false, ""]; - } - } - return [true, value]; - } - - /** - * Validates a group of fields ensuring that at least `minPassCount` of the items within the group - * passes. This is useful in the scenario when n number fields are required out of n+m number of fields. - * - * @param {string[]} fields - An array of field names to be validated. - * @param {Event} e - The event object associated with the validation trigger. - * @param {FieldValidator[]} validators - An array of validator functions to apply to the fields. - * @return {[number, Record]} - Returns back the number of fields that passed and a record of the fields and their pass status. - */ - validateGroup( - e: Event, - fields: string[], - validators: FieldValidator[], - ): [number, Record] { - let passCount = 0; - const validGroups = {} as Record; - - for (const field of fields) { - const [_valid] = this.validate(e, field, validators, { grouped: true }); - if (_valid) { - validGroups[field] = true; - passCount++; - } - } - - return [passCount, validGroups]; - } - - edit(index: number) { - relay(this._formRef, "external::alter:state", { index, operation: "edit" }); - } - - remove(index: number) { - relay(this._formRef, "external::alter:state", { - index, - operation: "remove", - }); - } - - /** - * Completes the form and triggers the onComplete callback. - * This method should be used when you want to complete a form without navigating to a summary page. - * - * @important Developers must validate the form before calling this method. - * - * @example - * // Validate first, then complete - * const [isValid] = this.validate(e, "firstName", [ - * requiredValidator("First name is required.") - * ]); - * if (isValid) { - * this.complete(); - * } - * @returns void - */ - complete() { - if (!this._formRef) { - console.error("complete: form ref is not set"); - return; - } - - if (this._isCompleting) { - console.warn("complete: completion already in progress"); - return; - } - - this._isCompleting = true; - relay(this._formRef, "fieldset::submit", null, { bubbles: true }); - this._isCompleting = false; - } - - /** - * Completes a subform and returns control to the parent form. - * This method should be used when working with subforms that need to complete without a summary page. - * - * @important Developers must validate the subform before calling this method. - * - * @example - * // Validate first, then complete the subform - * const [isValid] = this._childFormController.validate(e, "fullName", [ - * requiredValidator("Please enter the dependent's full name.") - * ]); - * if (isValid) { - * this._childFormController.completeSubform(); - * } - * @returns void - */ - completeSubform() { - if (!this._formRef) { - console.error("completeSubform: form ref is not set"); - return; - } - - if (this._isCompleting) { - console.warn("completeSubform: completion already in progress"); - return; - } - // Capture form reference to avoid TypeScript undefined error in closures - const formRef = this._formRef; - - // Set flag to prevent multiple calls - this._isCompleting = true; - - const stateChangeHandler = (e: Event) => { - formRef.removeEventListener('_stateChange', stateChangeHandler); - - // Now we know state is updated, safe to complete - // The _formRef points to the inner form within the SubForm - // We need to trigger the form's completion which will be caught by SubForm's onChildFormComplete - dispatch(formRef, "_complete", {}, { bubbles: true }); - this._isCompleting = false; - }; - - formRef.addEventListener('_stateChange', stateChangeHandler); - - dispatch(formRef, "_continue", null, { bubbles: true }); - } - - // Private method to dispatch the error message to the form element - #dispatchError( - el: HTMLElement, - name: string, - msg: string, - options?: { grouped: boolean }, - ) { - el.dispatchEvent( - new CustomEvent("msg", { - composed: true, - detail: { - action: "external::set:error", - data: { - name, - msg, - grouped: options?.grouped, - }, - }, - }), - ); - } - - // removes any data collected that doesn't correspond with the final history path - clean(data: AppState) { - return data.history.reduce>((acc, fieldsetId) => { - acc[fieldsetId] = data.form[fieldsetId]; - return acc; - }, {}); - } -} - diff --git a/libs/common/src/lib/public-form.ts b/libs/common/src/lib/public-form.ts new file mode 100644 index 0000000000..743a35967c --- /dev/null +++ b/libs/common/src/lib/public-form.ts @@ -0,0 +1,35 @@ +import { AnyValidator } from "./validators"; + +// TODO: right now the value is a string, but for the subform we need to allow for an array +export type PFPage = { + _id?: `${string}-${string}-${string}-${string}-${string}` | undefined; + [key: string]: string | undefined; +}; + +export type PFState = { + data: Record; + dataBuffer: PFPage; // Record; + history: string[]; +}; + +export type PFSummary = Omit & { + outline: Omit; +}; + +export type PFField = { + label: string; + formatter?: (input: string) => string; + hideInSummary: "always" | "ifBlank" | "never"; + type?: "file" | "text"; // Optional field type for special rendering (e.g., file links in summary) +}; + +export type PFOutline = Record; + +export type PFOutlineItem = { + subform: boolean; + props: Record; + fields: Record; + summarize?: (page: PFPage) => Record; + next: string | ((state: PFState) => string); + validators: Record; +}; diff --git a/libs/common/src/lib/validators.ts b/libs/common/src/lib/validators.ts index 451d19b091..c9a3d46150 100644 --- a/libs/common/src/lib/validators.ts +++ b/libs/common/src/lib/validators.ts @@ -1,4 +1,6 @@ export type FieldValidator = (value: unknown) => string; +export type PageValidator = (value: unknown, pageData: Record) => string; +export type AnyValidator = FieldValidator | PageValidator; export type FieldsetState = Record; export type FieldsetItemState = { name: string; @@ -35,10 +37,10 @@ export class FormValidator { } } -export function birthDayValidator(): FieldValidator[] { +export function BirthDayValidator(): FieldValidator[] { return [ - requiredValidator("Day is required"), - numericValidator({ + RequiredValidator("Day is required"), + NumericValidator({ min: 1, max: 31, minMsg: "Day must be between 1 and 31", @@ -47,10 +49,10 @@ export function birthDayValidator(): FieldValidator[] { ]; } -export function birthMonthValidator(): FieldValidator[] { +export function BirthMonthValidator(): FieldValidator[] { return [ - requiredValidator("Month is required"), - numericValidator({ + RequiredValidator("Month is required"), + NumericValidator({ min: 0, max: 11, minMsg: "Month must be between Jan and Dec", @@ -59,11 +61,11 @@ export function birthMonthValidator(): FieldValidator[] { ]; } -export function birthYearValidator(): FieldValidator[] { +export function BirthYearValidator(): FieldValidator[] { const maxYear = new Date().getFullYear(); return [ - requiredValidator("Year is required"), - numericValidator({ + RequiredValidator("Year is required"), + NumericValidator({ min: 1900, max: maxYear, minMsg: "Year must be greater than 1900", @@ -72,7 +74,7 @@ export function birthYearValidator(): FieldValidator[] { ]; } -export function requiredValidator(msg?: string): FieldValidator { +export function RequiredValidator(msg?: string): FieldValidator { return (value: unknown) => { msg = msg || "Required"; @@ -86,17 +88,17 @@ export function requiredValidator(msg?: string): FieldValidator { }; } -export function phoneNumberValidator(msg?: string): FieldValidator { +export function PhoneNumberValidator(msg?: string): FieldValidator { const regex = new RegExp(/^\+?[\d-() ]{10,18}$/); - return regexValidator(regex, msg || "Invalid phone number"); + return RegexValidator(regex, msg || "Invalid phone number"); } -export function emailValidator(msg?: string): FieldValidator { +export function EmailValidator(msg?: string): FieldValidator { // emailregex.com const regex = new RegExp( /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, ); - return regexValidator(regex, msg || "Invalid email address"); + return RegexValidator(regex, msg || "Invalid email address"); } // SIN# Generator: https://singen.ca @@ -132,14 +134,14 @@ export function SINValidator(): FieldValidator { }; } -export function postalCodeValidator(): FieldValidator { - return regexValidator( +export function PostalCodeValidator(): FieldValidator { + return RegexValidator( /^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z][ -]?\d[ABCEGHJ-NPRSTV-Z]\d$/i, "Invalid postal code", ); } -export function regexValidator(regex: RegExp, msg: string): FieldValidator { +export function RegexValidator(regex: RegExp, msg: string): FieldValidator { return (value: unknown) => { if (!value) { return ""; @@ -160,7 +162,7 @@ interface DateValidatorOptions { max?: Date; } -export function dateValidator({ +export function DateValidator({ invalidMsg, minMsg, maxMsg, @@ -207,7 +209,7 @@ interface NumericValidatorOptions { max?: number; } -export function numericValidator({ +export function NumericValidator({ invalidTypeMsg, minMsg, maxMsg, @@ -251,7 +253,7 @@ interface LengthValidatorOptions { min?: number; } -export function lengthValidator({ +export function LengthValidator({ invalidTypeMsg, minMsg, maxMsg, @@ -279,3 +281,66 @@ export function lengthValidator({ return ""; }; } + +/** + * Marker to identify PageValidators that need full page data + */ +export const PAGE_VALIDATOR_MARKER = Symbol("pageValidator"); + +export type MarkedPageValidator = PageValidator & { + [PAGE_VALIDATOR_MARKER]: true; +}; + +/** + * Helper to check if a validator is a PageValidator + */ +export function isPageValidator( + validator: AnyValidator +): validator is MarkedPageValidator { + return PAGE_VALIDATOR_MARKER in validator; +} + +/** + * Creates a PageValidator that marks itself for full page data access + */ +function createPageValidator(fn: PageValidator): MarkedPageValidator { + const marked = fn as MarkedPageValidator; + marked[PAGE_VALIDATOR_MARKER] = true; + return marked; +} + +/** + * Conditional required validator - requires value only when condition is met + * + * @param conditionFn - Function that receives pageData and returns true if field should be required + * @param msg - Error message when required but empty + * + * @example + * // Require phone number only when "phone" is selected in contactMethod + * ConditionalRequiredValidator( + * (pageData) => String(pageData.contactMethod || "").includes("phone"), + * "Enter your phone number" + * ) + */ +export function ConditionalRequiredValidator( + conditionFn: (pageData: Record) => boolean, + msg?: string +): MarkedPageValidator { + return createPageValidator((value: unknown, pageData: Record) => { + // Only validate if condition is met + if (!conditionFn(pageData)) { + return ""; // Not required, skip validation + } + + // Same logic as RequiredValidator + msg = msg || "Required"; + + if (typeof value === "number" && !isNaN(value)) { + return ""; + } + if (value) { + return ""; + } + return msg; + }); +} diff --git a/libs/react-components/specs/datepicker.browser.spec.tsx b/libs/react-components/specs/datepicker.browser.spec.tsx index 2cd6fbb8db..479c2e0d6d 100644 --- a/libs/react-components/specs/datepicker.browser.spec.tsx +++ b/libs/react-components/specs/datepicker.browser.spec.tsx @@ -329,7 +329,7 @@ describe("Date Picker input type", () => { const result = render(); const datePickerMonth = result.getByTestId("input-month"); - const datePickerMonthMarch = result.getByTestId("dropdown-item-3"); + const datePickerMonthMarch = result.getByTestId("dropdown-item-3"); // march = 3 const datePickerDay = result.getByTestId("input-day"); const datePickerYear = result.getByTestId("input-year"); @@ -352,7 +352,7 @@ describe("Date Picker input type", () => { rootElChangeHandler.mockClear(); // Input day - await datePickerDay.click(); + await userEvent.click(datePickerDay); await userEvent.type(datePickerDay, "1"); // Select month diff --git a/libs/react-components/specs/public-form.browser.spec.tsx b/libs/react-components/specs/public-form.browser.spec.tsx new file mode 100644 index 0000000000..028e25752e --- /dev/null +++ b/libs/react-components/specs/public-form.browser.spec.tsx @@ -0,0 +1,431 @@ +import { render } from "vitest-browser-react"; +import { + GoabPublicForm, + GoabPublicFormPage, + GoabPublicFormSummary, + GoabFormItem, + GoabRadioGroup, + GoabRadioItem, + GoabInput, +} from "../src"; +import { expect, describe, it, vi } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { useState } from "react"; +import { + RequiredValidator, + LengthValidator, + SINValidator, + NumericValidator, + PFState, + PFOutline, + PFPage, +} from "@abgov/ui-components-common"; + +const outline: PFOutline = { + role: { + subform: false, + props: { + heading: "What is your role in the court order?", + "section-title": "Support order details", + }, + fields: { + role: { + label: "What is your role?", + formatter: (val: string) => val.toUpperCase(), + hideInSummary: "never", + }, + }, + next: (state: PFState) => { + const role = state.dataBuffer["role"]; + return role === "Payor" ? "salary" : "children"; + }, + validators: { + role: [RequiredValidator("Role is required")], + }, + }, + + children: { + subform: true, + props: { + heading: "Do you have children", + }, + fields: { + "first-name": { + label: "First name", + formatter: (val: string) => val[0]?.toUpperCase() + val.substring(1), + hideInSummary: "never", + }, + "last-name": { + label: "Last name", + formatter: (val: string) => val[0]?.toUpperCase() + val.substring(1), + hideInSummary: "never", + }, + birthdate: { label: "Birthdate", hideInSummary: "never" }, + }, + next: "identification", + validators: {}, + }, + + salary: { + subform: false, + props: { + heading: "Payor salary", + }, + fields: { + salary: { label: "Yearly income", hideInSummary: "never" }, + }, + next: "summary", + validators: { + salary: [NumericValidator({ min: 0 })], + }, + }, + + identification: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Do you know any of the identifiers about the other party?", + }, + fields: { + sin: { + label: "Social Insurance #", + formatter: (val: string) => val.match(/(.{3})/g)?.join(" ") || val, + hideInSummary: "never", + }, + ahcn: { + label: "Alberta Health Care #", + formatter: (val: string) => val.match(/(.{4})/g)?.join("-") || val, + hideInSummary: "never", + }, + info: { label: "Additional information", hideInSummary: "never" }, + }, + next: (state: PFState): string => { + const sin = state.dataBuffer["sin"]; + const ahcn = state.dataBuffer["ahcn"]; + + if (!sin && !ahcn) { + throw "Either sin or ahcn is required"; + } + + return "address"; + }, + validators: { + sin: [SINValidator()], + ahcn: [LengthValidator({ min: 8 })], + }, + }, + + payor: { + subform: false, + props: { + heading: "Payor Name", + }, + fields: { + firstName: { label: "First name", hideInSummary: "never" }, + lastName: { label: "Last name", hideInSummary: "never" }, + }, + summarize: (page: PFPage) => ({ + "Full name": `${page["firstName"]} ${page["lastName"]}`.trim(), + }), + next: "address", + validators: {}, + }, + + address: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Your current address", + }, + fields: { + city: { label: "City/Town", hideInSummary: "never" }, + street: { label: "Street #", hideInSummary: "never" }, + "postal-code": { label: "Postal code", hideInSummary: "never" }, + }, + next: "summary", + validators: {}, + }, + + summary: { + subform: false, + props: { + "section-title": "Support order details", + heading: "Summary", + }, + fields: {}, + next: (state: PFState): string => { + console.log("submit to backend here", state); + return ""; + }, + validators: {}, + }, +}; + +function PublicFormTestComponent() { + const [state, setState] = useState(undefined); + + const handleInit = (initFn: any) => { + // Initialize with restored state + const initialState = initFn(null, { outline }); + setState(initialState); + }; + + const handleChange = (detail: any) => { + console.log("onChange", detail); + }; + + const handleNext = (newState: PFState) => { + setState(newState); + console.log("onNext", newState); + }; + + const handleSubformChange = (newState: PFState) => { + setState({ ...newState }); + console.log("onSubformChange", newState); + }; + + const getPage = (pageId: string, defaultValue: unknown) => { + return state?.data?.[pageId] || defaultValue; + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +describe("PublicForm Browser Tests", () => { + it.only("initializes the form", async () => { + const result = render(); + + await vi.waitFor(() => { + const form = result.container.querySelector("goa-public-form"); + expect(form).toBeTruthy(); + }); + + // Wait for form to initialize + await vi.waitFor( + () => { + // on first page, so it should be visible + const recipient = result.container.querySelector( + 'input[type="radio"][name="role"][value="Recipient"]' + ); + + // on later page, so should not be visible yet + const sin = result.container.querySelector( + 'input[name=sin]' + ); + expect(recipient).toBeTruthy(); + expect(recipient).toBeVisible(); + console.log(result.container.innerHTML) + expect(sin).toBeTruthy(); + expect(sin).not.toBeVisible(); + }, + { timeout: 3000 } + ); + }); + + it("can select a radio button and continue to next page", async () => { + const result = render(); + + await vi.waitFor(() => { + const form = result.container.querySelector("goa-public-form"); + expect(form).toBeTruthy(); + }); + + // Wait for radio items to be present + await vi.waitFor( + () => { + const recipientRadio = result.container.querySelector( + 'goa-radio-item[value="Recipient"]' + ); + expect(recipientRadio).toBeTruthy(); + }, + { timeout: 3000 } + ); + + // Select the Recipient radio button + const recipientRadio = result.container.querySelector( + 'goa-radio-item[value="Recipient"]' + ) as HTMLElement; + await userEvent.click(recipientRadio); + + // Click continue button + await vi.waitFor( + () => { + const continueButton = result.container.querySelector( + 'goa-button[type="primary"]' + ) as HTMLElement; + expect(continueButton).toBeTruthy(); + }, + { timeout: 1000 } + ); + + const continueButton = result.container.querySelector( + 'goa-button[type="primary"]' + ) as HTMLElement; + await userEvent.click(continueButton); + + // Should navigate to children page (for Recipient role) + await vi.waitFor( + () => { + const childrenPage = result.container.querySelector( + 'goa-public-form-page[id="children"]' + ); + expect(childrenPage).toBeTruthy(); + }, + { timeout: 2000 } + ); + }); + + it("validates required fields and shows errors", async () => { + const result = render(); + + await vi.waitFor(() => { + const form = result.container.querySelector("goa-public-form"); + expect(form).toBeTruthy(); + }); + + // Wait for form to initialize + await vi.waitFor( + () => { + const continueButton = result.container.querySelector( + 'goa-button[type="primary"]' + ); + expect(continueButton).toBeTruthy(); + }, + { timeout: 3000 } + ); + + // Try to click continue without selecting a role + const continueButton = result.container.querySelector( + 'goa-button[type="primary"]' + ) as HTMLElement; + await userEvent.click(continueButton); + + // Should show validation error + await vi.waitFor( + () => { + const formItem = result.container.querySelector("goa-form-item[error]"); + expect(formItem).toBeTruthy(); + }, + { timeout: 2000 } + ); + }); + + it("supports conditional navigation based on form data", async () => { + const result = render(); + + await vi.waitFor(() => { + const form = result.container.querySelector("goa-public-form"); + expect(form).toBeTruthy(); + }); + + // Wait for radio items + await vi.waitFor( + () => { + const payorRadio = result.container.querySelector( + 'goa-radio-item[value="Payor"]' + ); + expect(payorRadio).toBeTruthy(); + }, + { timeout: 3000 } + ); + + // Select Payor (should go to salary page instead of children) + const payorRadio = result.container.querySelector( + 'goa-radio-item[value="Payor"]' + ) as HTMLElement; + await userEvent.click(payorRadio); + + // Click continue + const continueButton = result.container.querySelector( + 'goa-button[type="primary"]' + ) as HTMLElement; + await userEvent.click(continueButton); + + // Should navigate to salary page (not children) + await vi.waitFor( + () => { + const salaryPage = result.container.querySelector( + 'goa-public-form-page[id="salary"]' + ); + expect(salaryPage).toBeTruthy(); + }, + { timeout: 2000 } + ); + }); + + it("renders the summary page with form data", async () => { + const result = render(); + + await vi.waitFor(() => { + const summary = result.container.querySelector("goa-public-form-summary"); + expect(summary).toBeTruthy(); + }); + }); +}); diff --git a/libs/react-components/src/index.ts b/libs/react-components/src/index.ts index 693d58c97d..86ef45e0c1 100644 --- a/libs/react-components/src/index.ts +++ b/libs/react-components/src/index.ts @@ -25,7 +25,6 @@ export * from "./lib/file-upload-input/file-upload-input"; export * from "./lib/footer/footer"; export * from "./lib/footer-meta-section/footer-meta-section"; export * from "./lib/footer-nav-section/footer-nav-section"; -export * from "./lib/form/fieldset"; export * from "./lib/form/public-form-page"; export * from "./lib/form/public-form-summary"; export * from "./lib/form/public-form"; @@ -73,4 +72,3 @@ export * from "./lib/three-column-layout/three-column-layout"; export * from "./lib/tooltip/tooltip"; export * from "./lib/two-column-layout/two-column-layout"; export * from "./lib/filter-chip/filter-chip"; -export * from "./lib/use-public-form-controller"; diff --git a/libs/react-components/src/lib/form/fieldset.tsx b/libs/react-components/src/lib/form/fieldset.tsx deleted file mode 100644 index 3710714c17..0000000000 --- a/libs/react-components/src/lib/form/fieldset.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { ReactNode, useEffect, useRef, type JSX } from "react"; -import { - DataAttributes, - GoabFieldsetOnContinueDetail, - GoabFormDispatchOn, -} from "@abgov/ui-components-common"; -import { transformProps, kebab } from "../common/extract-props"; - -interface WCProps { - id?: string; - "section-title"?: string; - "dispatch-on"?: string; -} - -declare module "react" { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - interface IntrinsicElements { - "goa-fieldset": WCProps & React.HTMLAttributes & { - ref: React.RefObject; - }; - } - } -} - -interface GoabFieldsetProps extends DataAttributes { - id?: string; - sectionTitle?: string; - dispatchOn?: GoabFormDispatchOn; - onContinue?: (event: GoabFieldsetOnContinueDetail) => void; - children: ReactNode; -} - -export function GoabFieldset({ - onContinue, - children, - ...rest -}: GoabFieldsetProps): JSX.Element { - const ref = useRef(null); - - const _props = transformProps(rest, kebab); - - useEffect(() => { - if (!ref.current) return; - const current = ref.current; - - const continueListener = (e: Event) => { - const event = (e as CustomEvent).detail; - return onContinue?.(event); - }; - - if (onContinue) { - current.addEventListener("_continue", continueListener); - } - - return () => { - if (onContinue) { - current.removeEventListener("_continue", continueListener); - } - }; - }, [ref, onContinue]); - - return ( - - {children} - - ); -} - -export default GoabFieldset; diff --git a/libs/react-components/src/lib/form/public-form-page.tsx b/libs/react-components/src/lib/form/public-form-page.tsx index 1c38ccfe2f..3634864a94 100644 --- a/libs/react-components/src/lib/form/public-form-page.tsx +++ b/libs/react-components/src/lib/form/public-form-page.tsx @@ -1,85 +1,66 @@ -import { ReactNode, useEffect, useRef } from "react"; -import { - GoabPublicFormPageButtonVisibility, - GoabPublicFormPageStep, - Margins, DataAttributes, -} from "@abgov/ui-components-common"; +import { ReactNode } from "react"; +import { DataAttributes } from "@abgov/ui-components-common"; import { transformProps, kebab } from "../common/extract-props"; -interface WCProps extends Margins { +interface WCProps { id?: string; heading?: string; "sub-heading"?: string; "section-title"?: string; - "back-url"?: string; - type?: string; "button-text"?: string; - "button-visibility"?: string; - "summary-heading"?: string; + "back-visibility"?: string; + "error-summary-position"?: string; + error?: string; + "data-pf-editting"?: string; } declare module "react" { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { interface IntrinsicElements { - "goa-public-form-page": WCProps & React.HTMLAttributes & { - ref: React.RefObject; - }; + "goa-public-form-page": WCProps & React.HTMLAttributes; } } } -interface GoabPublicFormPageProps extends Margins, DataAttributes { +interface GoabPublicFormPageProps extends DataAttributes { id?: string; heading?: string; subHeading?: string; - summaryHeading?: string; sectionTitle?: string; - backUrl?: string; - type?: GoabPublicFormPageStep; buttonText?: string; - buttonVisibility?: GoabPublicFormPageButtonVisibility; - /** - * Triggered when the form page continues to the next step - * @param event - The continue event details - */ - onContinue?: (event: Event) => void; + backVisibility?: "visible" | "hidden"; + /** Controls where the error summary callout appears. Default is "top". */ + errorSummaryPosition?: "top" | "bottom" | "none"; + error?: string; + editting?: boolean; children: ReactNode; } export function GoabPublicFormPage({ - onContinue, + subHeading, + sectionTitle, + buttonText, + backVisibility, + errorSummaryPosition, + editting, children, ...rest }: GoabPublicFormPageProps) { - const ref = useRef(null); - - const _props = transformProps(rest, kebab); - - useEffect(() => { - if (!ref.current) return; - const current = ref.current; - - const continueListener = (e: Event) => { - onContinue?.(e); - }; - - if (onContinue) { - current.addEventListener("_continue", continueListener); - } - - return () => { - if (onContinue) { - current.removeEventListener("_continue", continueListener); - } - }; - }, [ref, onContinue]); - - return ( - - {children} - + const _props = transformProps( + { + ...rest, + "sub-heading": subHeading, + "section-title": sectionTitle, + "button-text": buttonText, + "back-visibility": backVisibility, + "error-summary-position": errorSummaryPosition, + "data-pf-editting": editting ? "true" : undefined, + }, + kebab ); + + return {children}; } export default GoabPublicFormPage; diff --git a/libs/react-components/src/lib/form/public-form.tsx b/libs/react-components/src/lib/form/public-form.tsx index 3a81fa3054..4a1ceb5d15 100644 --- a/libs/react-components/src/lib/form/public-form.tsx +++ b/libs/react-components/src/lib/form/public-form.tsx @@ -1,16 +1,14 @@ -import { ReactNode, useRef, useLayoutEffect } from "react"; +import { ReactNode, useRef, useEffect } from "react"; import { DataAttributes, - GoabFormState, - GoabPublicFormStatus, + Margins, + PFState, + PFOutline, } from "@abgov/ui-components-common"; import { transformProps, lowercase } from "../common/extract-props"; -interface WCProps { - status?: string; - name?: string; -} +interface WCProps extends Margins {} declare module "react" { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -23,71 +21,70 @@ declare module "react" { } } -interface GoabPublicFormProps extends DataAttributes { - status?: GoabPublicFormStatus; - name?: string; - onInit?: (event: Event) => void; - onComplete?: (event: GoabFormState) => void; - onStateChange?: (event: GoabFormState) => void; +type InitFunction = (data: PFState, props: { outline: PFOutline }) => PFState; + +interface GoabPublicFormChangeDetail { + state: PFState; + name: string; + value: string; +} + +interface GoabPublicFormProps extends Margins, DataAttributes { + onInit?: (initFn: InitFunction) => void; + onChange?: (detail: GoabPublicFormChangeDetail) => void; + onNext?: (state: PFState) => void; + onSubformChange?: (state: PFState) => void; children: ReactNode; } export function GoabPublicForm({ onInit, - onComplete, - onStateChange, + onChange, + onNext, + onSubformChange, children, ...rest }: GoabPublicFormProps) { const ref = useRef(null); - const initialized = useRef(false); const _props = transformProps(rest, lowercase); - // Use useLayoutEffect to set up listeners before the component mounts - useLayoutEffect(() => { + useEffect(() => { if (!ref.current) return; const current = ref.current; const initListener = (e: Event) => { - onInit?.(e); + const initFn = (e as CustomEvent).detail; + onInit?.(initFn); }; - // First time initialization, add init listener immediately - if (onInit && !initialized.current) { - current.addEventListener("_init", initListener); - } - - const completeListener = (e: Event) => { - const detail = (e as CustomEvent).detail; - onComplete?.(detail); + const changeListener = (e: Event) => { + const detail = (e as CustomEvent).detail; + onChange?.(detail); }; - const stateChangeListener = (e: Event) => { - const detail = (e as CustomEvent).detail; - onStateChange?.(detail.data); + const nextListener = (e: Event) => { + const state = (e as CustomEvent).detail; + onNext?.(state); }; - if (onComplete) { - current.addEventListener("_complete", completeListener); - } + const subformChangeListener = (e: Event) => { + const state = (e as CustomEvent).detail; + onSubformChange?.(state); + }; - if (onStateChange) { - current.addEventListener("_stateChange", stateChangeListener); - } + current.addEventListener("_init", initListener); + current.addEventListener("_change", changeListener); + current.addEventListener("_next", nextListener); + current.addEventListener("_subformChange", subformChangeListener); return () => { - if (onInit) { - current.removeEventListener("_init", initListener); - } - if (onComplete) { - current.removeEventListener("_complete", completeListener); - } - if (onStateChange) { - current.removeEventListener("_stateChange", stateChangeListener); - } + current.removeEventListener("_init", initListener); + current.removeEventListener("_change", changeListener); + current.removeEventListener("_next", nextListener); + current.removeEventListener("_subformChange", subformChangeListener); }; - }, [onInit, onComplete, onStateChange]); + }, [onInit, onChange, onNext, onSubformChange]); return ( diff --git a/libs/react-components/src/lib/form/public-subform-index.spec.tsx b/libs/react-components/src/lib/form/public-subform-index.spec.tsx deleted file mode 100644 index 9f792d4938..0000000000 --- a/libs/react-components/src/lib/form/public-subform-index.spec.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { GoabPublicSubformIndex } from "./public-subform-index"; - -describe("GoabPublicSubformIndex", () => { - it("renders with all properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("heading")).toBe("Test Heading"); - expect(el?.getAttribute("section-title")).toBe("Test Section Title"); - expect(el?.getAttribute("action-button-text")).toBe("Add Item"); - expect(el?.getAttribute("button-visibility")).toBe("visible"); - expect(el?.getAttribute("slot")).toBe("subform-index"); - expect(el?.getAttribute("mt")).toBe("s"); - expect(el?.getAttribute("mr")).toBe("m"); - expect(el?.getAttribute("mb")).toBe("l"); - expect(el?.getAttribute("ml")).toBe("xl"); - - // Content is rendered - expect(baseElement.querySelector("[data-testid='test-content']")).toBeTruthy(); - }); - - it("renders with default values", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("heading")).toBe(""); - expect(el?.getAttribute("section-title")).toBe(""); - expect(el?.getAttribute("action-button-text")).toBe(""); - expect(el?.getAttribute("button-visibility")).toBe("hidden"); - expect(el?.getAttribute("slot")).toBe("subform-index"); - }); - - it("renders with hidden button visibility", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("button-visibility")).toBe("hidden"); - }); - - it("renders complex children content", () => { - const { baseElement } = render( - -

- Please add information about your dependents. -

- - - - - - - - - - - - - -
NameActions
John Doe - - -
-
- ); - - expect(baseElement.querySelector("[data-testid='description']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='dependents-table']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='edit-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='remove-btn']")).toBeTruthy(); - }); - - it("handles empty string properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("heading")).toBe(""); - expect(el?.getAttribute("section-title")).toBe(""); - expect(el?.getAttribute("action-button-text")).toBe(""); - }); - - it("handles special characters in text properties", () => { - const specialTexts = { - heading: "Dependents & Family", - sectionTitle: "Section > Details", - actionButtonText: "Add \"New\" Item", - }; - - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("heading")).toBe(specialTexts.heading); - expect(el?.getAttribute("section-title")).toBe(specialTexts.sectionTitle); - expect(el?.getAttribute("action-button-text")).toBe(specialTexts.actionButtonText); - }); - - it("always renders with slot='subform-index' attribute", () => { - const { baseElement } = render( - -
Any content
-
- ); - - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("slot")).toBe("subform-index"); - }); - - it("renders nested components correctly", () => { - const { baseElement } = render( - -
- Please complete the following tasks: -
-
- Task 1: Complete profile - -
-
- Task 2: Upload documents - -
-
- ); - - expect(baseElement.querySelector("[data-testid='text-content']")?.textContent) - .toContain("Please complete the following tasks:"); - expect(baseElement.querySelector("[data-testid='task-item-1']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='task-item-2']")).toBeTruthy(); - expect(baseElement.querySelectorAll("button")).toHaveLength(2); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - -
Test content
-
- ); - const el = baseElement.querySelector("goa-public-subform-index"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); diff --git a/libs/react-components/src/lib/form/public-subform.spec.tsx b/libs/react-components/src/lib/form/public-subform.spec.tsx deleted file mode 100644 index f0aa1ec9ed..0000000000 --- a/libs/react-components/src/lib/form/public-subform.spec.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { render, cleanup } from "@testing-library/react"; -import { describe, it, expect, afterEach } from "vitest"; -import { GoabPublicSubform } from "./public-subform"; - -describe("GoabPublicSubform", () => { - afterEach(() => { - cleanup(); - }); - - it("renders with all properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("id")).toBe("test-subform"); - expect(el?.getAttribute("name")).toBe("test-subform-name"); - expect(el?.getAttribute("continue-msg")).toBe("Continue to next step"); - expect(el?.getAttribute("mt")).toBe("s"); - expect(el?.getAttribute("mr")).toBe("m"); - expect(el?.getAttribute("mb")).toBe("l"); - expect(el?.getAttribute("ml")).toBe("xl"); - - // Content is rendered - expect(baseElement.querySelector("[data-testid='test-content']")).toBeTruthy(); - }); - - it("renders with default values", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("id")).toBe(""); - expect(el?.getAttribute("name")).toBe(""); - expect(el?.getAttribute("continue-msg")).toBe(""); - }); - - it("renders without margin attributes when undefined", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.hasAttribute("mt")).toBe(false); - expect(el?.hasAttribute("mr")).toBe(false); - expect(el?.hasAttribute("mb")).toBe(false); - expect(el?.hasAttribute("ml")).toBe(false); - }); - - it("renders complex children content", () => { - const { baseElement } = render( - -
-

Dependents List

- - - - - - - - - - - - - - - -
NameAgeActions
John Doe10 - - -
-
-
-

Add New Dependent

- - - -
-
- ); - - expect(baseElement.querySelector("[data-testid='subform-index']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='form-page']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='edit-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='remove-btn']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='name-input']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='save-btn']")).toBeTruthy(); - }); - - it("handles empty string properties", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("id")).toBe(""); - expect(el?.getAttribute("name")).toBe(""); - expect(el?.getAttribute("continue-msg")).toBe(""); - }); - - it("renders with all margin values", () => { - const marginValues = ["none", "3xs", "2xs", "xs", "s", "m", "l", "xl", "2xl", "3xl"]; - - marginValues.forEach(margin => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("mt")).toBe(margin); - expect(el?.getAttribute("mr")).toBe(margin); - expect(el?.getAttribute("mb")).toBe(margin); - expect(el?.getAttribute("ml")).toBe(margin); - - cleanup(); - }); - }); - - it("handles special characters in text properties", () => { - const specialTexts = { - id: "subform-id-123", - name: "form & subform", - continueMsg: "Continue > Next Step", - }; - - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("id")).toBe(specialTexts.id); - expect(el?.getAttribute("name")).toBe(specialTexts.name); - expect(el?.getAttribute("continue-msg")).toBe(specialTexts.continueMsg); - }); - - it("handles camelCase to kebab-case conversion correctly", () => { - const { baseElement } = render( - -
Test content
-
- ); - - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("continue-msg")).toBe("Test message"); - expect(el?.hasAttribute("continueMsg")).toBe(false); - }); - - it("renders nested subform components correctly", () => { - const { baseElement } = render( - -
-

List View

-
-
Item 1
-
Item 2
-
-
-
-
-

Page 1

- -
-
-

Page 2

- -
-
-
- ); - - expect(baseElement.querySelector("[data-testid='subform-index']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='form-pages']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='item-list']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='page-1']")).toBeTruthy(); - expect(baseElement.querySelector("[data-testid='page-2']")).toBeTruthy(); - expect(baseElement.querySelectorAll("input")).toHaveLength(1); - expect(baseElement.querySelectorAll("textarea")).toHaveLength(1); - }); - - it("should pass data-grid attributes", () => { - const { baseElement } = render( - -
Test content
-
- ); - const el = baseElement.querySelector("goa-public-subform"); - expect(el?.getAttribute("data-grid")).toBe("cell"); - }); -}); diff --git a/libs/react-components/src/lib/form/public-subform.tsx b/libs/react-components/src/lib/form/public-subform.tsx index 30b3f78e9a..b828e542ee 100644 --- a/libs/react-components/src/lib/form/public-subform.tsx +++ b/libs/react-components/src/lib/form/public-subform.tsx @@ -1,83 +1,69 @@ -import { ReactNode, useEffect, useRef } from "react"; -import { Margins, DataAttributes } from "@abgov/ui-components-common"; -import { transformProps, kebab } from "../common/extract-props"; +import { ReactNode } from "react"; +import { DataAttributes, GoabButtonType, GoabButtonSize } from "@abgov/ui-components-common"; -interface WCProps extends Margins { - id?: string; - name?: string; - "continue-msg"?: string; +interface WCProps { + addbuttontext?: string; + addbuttontype?: string; + addbuttonsize?: string; + addbuttonicon?: string; + addheading?: string; + editheading?: string; } declare module "react" { // eslint-disable-next-line @typescript-eslint/no-namespace namespace JSX { interface IntrinsicElements { - "goa-public-subform": WCProps & React.HTMLAttributes & { - ref: React.RefObject; - }; + "goa-pf-subform": WCProps & + React.HTMLAttributes & { + children?: React.ReactNode; + }; } } } -interface GoabPublicSubformProps extends Margins, DataAttributes { - id?: string; - name?: string; - continueMsg?: string; - onInit?: (event: Event) => void; - onStateChange?: (event: Event) => void; - children: ReactNode; +interface GoabPfSubformProps extends DataAttributes { + formContent?: ReactNode; + children?: ReactNode; + /** Text for the Add button. Default: "Add" */ + addButtonText?: string; + /** Button type for the Add button. Default: "primary" */ + addButtonType?: GoabButtonType; + /** Button size for the Add button. Default: "default" */ + addButtonSize?: GoabButtonSize; + /** Leading icon for the Add button */ + addButtonIcon?: string; + /** Modal heading when adding a new item */ + addHeading?: string; + /** Modal heading when editing an existing item */ + editHeading?: string; } -export function GoabPublicSubform({ - id = "", - name = "", - continueMsg = "", - onInit, - onStateChange, +export function GoabPfSubform({ + formContent, children, + addButtonText, + addButtonType, + addButtonSize, + addButtonIcon, + addHeading, + editHeading, ...rest -}: GoabPublicSubformProps) { - const ref = useRef(null); - - const _props = transformProps( - { id, name, "continue-msg": continueMsg, ...rest }, - kebab - ); - - useEffect(() => { - if (!ref.current) return; - const current = ref.current; - - const initListener = (e: Event) => { - onInit?.(e); - }; - - const stateChangeListener = (e: Event) => { - onStateChange?.(e); - }; - - if (onInit) { - current.addEventListener("_init", initListener); - } - if (onStateChange) { - current.addEventListener("_stateChange", stateChangeListener); - } - - return () => { - if (onInit) { - current.removeEventListener("_init", initListener); - } - if (onStateChange) { - current.removeEventListener("_stateChange", stateChangeListener); - } - }; - }, [ref, onInit, onStateChange]); - +}: GoabPfSubformProps) { return ( - + + {formContent &&
{formContent}
} {children} -
+ ); } -export default GoabPublicSubform; +export default GoabPfSubform; diff --git a/libs/react-components/src/lib/use-public-form-controller.spec.ts b/libs/react-components/src/lib/use-public-form-controller.spec.ts deleted file mode 100644 index 1a460aad41..0000000000 --- a/libs/react-components/src/lib/use-public-form-controller.spec.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { usePublicFormController } from "./use-public-form-controller"; - -describe("usePublicFormController", () => { - const mockFormElement = document.createElement("form"); - const mockInitEvent = { - el: mockFormElement - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should initialize with empty state", () => { - const { result } = renderHook(() => usePublicFormController("details")); - expect(result.current.state).toBe(undefined); - }); - - it("should handle null element during initialization", () => { - const consoleError = vi.spyOn(console, "error"); - const { result } = renderHook(() => usePublicFormController("details")); - - result.current.init({ el: undefined as unknown as HTMLFormElement }); - - expect(consoleError).toHaveBeenCalledWith("El is null during initialization"); - }); - - it("should initialize list state", () => { - const { result } = renderHook(() => usePublicFormController("details")); - const mockEvent = new CustomEvent("init", { - detail: { el: mockFormElement } - }); - - result.current.initList(mockEvent); - - expect(result.current.getStateList()).toEqual([]); - }); - - it("should handle null element during list initialization", () => { - const consoleError = vi.spyOn(console, "error"); - const { result } = renderHook(() => usePublicFormController("details")); - const mockEvent = new CustomEvent("init", { - detail: { el: null } - }); - - result.current.initList(mockEvent); - - expect(consoleError).toHaveBeenCalledWith("El is null during list initialization"); - }); - - it("should initialize state with callback", () => { - const { result } = renderHook(() => usePublicFormController("details")); - const mockCallback = vi.fn(); - const mockState = { - uuid: "test-uuid", - form: { - testForm: { - heading: "Test Form", - data: { - type: "details" as const, - fieldsets: { - testField: { - name: "testField", - label: "Test Field", - value: "test value", - order: 1 - } - } - } - } - }, - history: [], - editting: "", - status: "not-started" as const - }; - - result.current.init(mockInitEvent); - - result.current.initState(mockState, mockCallback); - - return new Promise((resolve) => { - setTimeout(() => { - expect(mockCallback).toHaveBeenCalled(); - resolve(); - }, 300); - }); - }); - - it("should handle state initialization without form reference", () => { - const consoleError = vi.spyOn(console, "error"); - const { result } = renderHook(() => usePublicFormController("details")); - const mockState = { - uuid: "test-uuid", - form: {}, - history: [], - editting: "", - status: "not-started" as const - }; - - result.current.initState(mockState); - - expect(consoleError).toHaveBeenCalledWith("Form ref not set."); - }); - - it("should validate field value", () => { - const { result } = renderHook(() => usePublicFormController("details")); - const mockValidator = vi.fn().mockReturnValue([true, "test value"]); - const mockEvent = { - el: document.createElement("div"), - state: { - testField: { - name: "testField", - label: "Test Field", - value: "test value", - order: 1 - } - }, - cancelled: false - }; - - const [isValid, value] = result.current.validate(mockEvent, "testField", [mockValidator]); - - expect(isValid).toBe(true); - expect(value).toBe("test value"); - expect(mockValidator).toHaveBeenCalledWith("test value"); - }); -}); diff --git a/libs/react-components/src/lib/use-public-form-controller.ts b/libs/react-components/src/lib/use-public-form-controller.ts deleted file mode 100644 index f18497b2b5..0000000000 --- a/libs/react-components/src/lib/use-public-form-controller.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { useRef, useEffect, useState, useCallback } from 'react'; -import { - AppState, - GoabFieldsetItemValue, - FieldValidator, PublicFormController, -} from "@abgov/ui-components-common"; - -function usePublicFormController(type: "details" | "list" = "details") { - const controllerRef = useRef>(new PublicFormController(type)); - const [state, setState] = useState | AppState[] | undefined>(undefined); - - useEffect(() => { - // Create a proxy that updates React state when controller's state changes - const originalStateGetter = Object.getOwnPropertyDescriptor( - Object.getPrototypeOf(controllerRef.current), - 'state' - )?.get; - - const originalStateSetter = Object.getOwnPropertyDescriptor( - Object.getPrototypeOf(controllerRef.current), - 'state' - )?.set; - - if (originalStateGetter && originalStateSetter) { - Object.defineProperty(controllerRef.current, 'state', { - get: function() { - return originalStateGetter.call(this); - }, - set: function(value) { - originalStateSetter.call(this, value); - setState(value); - }, - configurable: true - }); - } - }, []); - - - const init = useCallback((e: Event) => { - if (!(e as CustomEvent).detail?.el) { - console.error('El is null during initialization'); - return; - } - controllerRef.current.init(e); - }, []); - - const initList = useCallback((e: Event) => { - const customEvent = e as CustomEvent; - if (!customEvent?.detail?.el) { - console.error('El is null during list initialization'); - return; - } - controllerRef.current.initList(e); - }, []); - - const initState = useCallback((state?: string | AppState | AppState[], callback?: () => void) => { - if (!controllerRef.current._formRef) { - console.error('Form ref not set.'); - return; - } - controllerRef.current.initState(state, callback); - }, []); - - const continueTo = useCallback((next: T | undefined) => { - controllerRef.current.continueTo(next); - }, []); - - const validate = useCallback(( - e: Event, - field: string, - validators: FieldValidator[] - ): [boolean, GoabFieldsetItemValue] => { - return controllerRef.current.validate(e, field, validators); - }, []); - - const getStateValue = useCallback((group: string, key: string): string => { - return controllerRef.current.getStateValue(group, key); - }, []); - - const getStateList = useCallback((): Record[] => { - return controllerRef.current.getStateList(); - }, []); - - const complete = useCallback(() => { - controllerRef.current.complete(); - }, []); - - const completeSubform = useCallback(() => { - controllerRef.current.completeSubform(); - }, []); - - return { - state, - init, - initList, - initState, - continueTo, - validate, - getStateValue, - getStateList, - complete, - completeSubform, - controller: controllerRef.current - }; -} - -export { usePublicFormController }; diff --git a/libs/web-components/src/common/utils.ts b/libs/web-components/src/common/utils.ts index 40b5d52afc..a1e5d45cbf 100644 --- a/libs/web-components/src/common/utils.ts +++ b/libs/web-components/src/common/utils.ts @@ -3,7 +3,7 @@ // non-key properties change. Using the `watch` function provides the control and makes // it clear what the function is watching export function watch(fn: () => void, _: unknown[]) { - fn() + fn(); } // Creates a style string from a list of styles. @@ -53,7 +53,7 @@ export function receive( const listener = (e: Event) => { const ce = e as CustomEvent; handler(ce.detail.action, ce.detail.data, e); - } + }; el?.addEventListener("msg", listener); @@ -290,9 +290,14 @@ export function isPointInRectangle( rectX: number, rectY: number, rectWidth: number, - rectHeight: number + rectHeight: number, ): boolean { - return x >= rectX && x <= rectX + rectWidth && y >= rectY && y <= rectY + rectHeight; + return ( + x >= rectX && + x <= rectX + rectWidth && + y >= rectY && + y <= rectY + rectHeight + ); } export function ensureSlotExists(el: HTMLElement) { @@ -372,7 +377,7 @@ export function getLocalDateValues(input: string | Date): { year: +matches[1], month: +matches[2], day: +matches[3], - } + }; } } @@ -381,7 +386,7 @@ export function getLocalDateValues(input: string | Date): { year: input.getFullYear(), month: input.getMonth() + 1, day: input.getDate(), - } + }; } return null; diff --git a/libs/web-components/src/components/checkbox-list/CheckboxList.svelte b/libs/web-components/src/components/checkbox-list/CheckboxList.svelte index 54d0ee30f4..b45e656706 100644 --- a/libs/web-components/src/components/checkbox-list/CheckboxList.svelte +++ b/libs/web-components/src/components/checkbox-list/CheckboxList.svelte @@ -1,4 +1,9 @@ - + -
{#if type === "calendar"} {#if width && width.includes("%")}
@@ -345,6 +297,7 @@ - + diff --git a/libs/web-components/src/components/dropdown/Dropdown.svelte b/libs/web-components/src/components/dropdown/Dropdown.svelte index f75da4666b..c2913cce05 100644 --- a/libs/web-components/src/components/dropdown/Dropdown.svelte +++ b/libs/web-components/src/components/dropdown/Dropdown.svelte @@ -7,6 +7,7 @@ reflect: true, attribute: "disable-global-close-popover", }, + value: { reflect: true }, }, }} /> @@ -34,16 +35,6 @@ toBoolean, } from "../../common/utils"; import { calculateMargin } from "../../common/styling"; - import { - FieldsetErrorRelayDetail, - FieldsetResetErrorsMsg, - FieldsetSetErrorMsg, - FormFieldMountMsg, - FormFieldMountRelayDetail, - FieldsetSetValueMsg, - FieldsetSetValueRelayDetail, - FieldsetResetFieldsMsg, - } from "../../types/relay-types"; interface EventHandler { handleKeyUp: (e: KeyboardEvent) => void; @@ -173,7 +164,6 @@ onMount(() => { ensureSlotExists(_rootEl); addRelayListener(); - sendMountedMessage(); setupPopoverListeners(); if (disableGlobalClosePopover) { @@ -183,8 +173,20 @@ ? new ComboboxKeyUpHandler(_inputEl) : new DropdownKeyUpHandler(_inputEl); showDeprecationWarnings(); + + bindReset(_rootEl); }); + function bindReset(el: HTMLElement) { + el.addEventListener("goa:reset", () => { + if (value) { + value = ""; + dispatch(el, "_change", { name, value }, { bubbles: true }) ; + } + }); + dispatch(el, "goa:bind", el, { bubbles: true }); + } + // // Functions // @@ -220,11 +222,11 @@ } function setupPopoverListeners() { - _popoverEl?.addEventListener("_open", (e) => { + _popoverEl?.addEventListener("_open", (_e) => { _isMenuVisible = true; }); - _popoverEl?.addEventListener("_close", (e) => { + _popoverEl?.addEventListener("_close", (_e) => { _isMenuVisible = false; }); } @@ -238,20 +240,8 @@ } function addRelayListener() { - receive(_rootEl, (action, data, event) => { + receive(_rootEl, (action, data, _event) => { switch (action) { - case FieldsetSetValueMsg: - onSetValue(data as FieldsetSetValueRelayDetail); - break; - case FieldsetSetErrorMsg: - setError(data as FieldsetErrorRelayDetail); - break; - case FieldsetResetErrorsMsg: - error = "false"; - break; - case FieldsetResetFieldsMsg: - onSetValue({ name, value: "" }); - break; case DropdownItemMountedMsg: onChildMounted(data as DropdownItemMountedRelayDetail); break; @@ -262,25 +252,6 @@ }); } - function setError(detail: FieldsetErrorRelayDetail) { - error = detail.error ? "true" : "false"; - } - - function onSetValue(detail: FieldsetSetValueRelayDetail) { - // @ts-expect-error - value = detail.value; - dispatch(_rootEl, "_change", { name, value }, { bubbles: true }); - } - - function sendMountedMessage() { - relay( - _rootEl, - FormFieldMountMsg, - { name, el: _rootEl }, - { bubbles: true, timeout: 10 }, - ); - } - /** * Called when a new child option is added to the slot. This component must send * a reference to itself back to the child to allow for the child to send messages @@ -527,7 +498,7 @@ } // Auto-select matching option from input after browser autofill/autocomplete or paste from clipboard. - function onInputChange(e: Event) { + function onInputChange(_e: Event) { if (_disabled || !_filterable) return; const isAutofilled = @@ -606,7 +577,7 @@ setDisplayedValue(); } - function onFocus(e: Event) { + function onFocus(_e: Event) { dispatch(_rootEl, "help-text::announce", undefined, { bubbles: true }); } @@ -917,7 +888,7 @@ data-value={option.value} role="option" style="display: block" - on:click={(e) => { + on:click={(_e) => { onFilteredOptionClick(option); _inputEl?.focus(); }} diff --git a/libs/web-components/src/components/form-item/FormItem.svelte b/libs/web-components/src/components/form-item/FormItem.svelte index ed05b0a7ff..bfb2cc53bd 100644 --- a/libs/web-components/src/components/form-item/FormItem.svelte +++ b/libs/web-components/src/components/form-item/FormItem.svelte @@ -1,8 +1,5 @@ @@ -18,23 +15,10 @@ import { calculateMargin } from "../../common/styling"; import { receive, - relay, generateRandomId, typeValidator, announceToScreenReader, } from "../../common/utils"; - import { - FieldsetResetErrorsMsg, - FieldsetSetErrorMsg, - FormFieldMountMsg, - FormItemMountMsg, - } from "../../types/relay-types"; - - import type { - FieldsetErrorRelayDetail, - FormFieldMountRelayDetail, - FormItemMountRelayDetail, - } from "../../types/relay-types"; // Validators const [REQUIREMENT_TYPES, validateRequirementType] = typeValidator( @@ -89,11 +73,6 @@ /** Specifies the input type for appropriate message spacing. Used with checkbox-list or radio-group. */ export let type: InputType = ""; - /** Overrides the label value within the form-summary. For public-form use only. */ - export let name: string = "blank"; - /** Sets the display order within the form summary. For public-form use only. */ - export let publicFormSummaryOrder: number = 0; - let _rootEl: HTMLElement; let _inputEl: HTMLElement; let _errorId = `error-${generateRandomId()}`; @@ -110,44 +89,10 @@ validateVersion(version); validateType(type); - receive(_rootEl, (action, data) => { - switch (action) { - case FormFieldMountMsg: - onInputMount(data as FormFieldMountRelayDetail); - break; - case FieldsetSetErrorMsg: - onSetError(data as FieldsetErrorRelayDetail); - break; - case FieldsetResetErrorsMsg: - error = ""; - break; - } - }); - - _rootEl?.addEventListener("form-field::bind", handleInputMounted); _rootEl?.addEventListener("error::change", handleErrorChange); _rootEl?.addEventListener("help-text::announce", handleAnnounceHelperText); }); - function handleInputMounted(e: Event) { - const ce = e as CustomEvent; - _inputEl = ce.detail.el; - - // Check if aria-label is present and has a value in the child element - const ariaLabel = _inputEl.getAttribute("aria-label"); - if (!ariaLabel || ariaLabel.trim() === "") { - _inputEl.setAttribute("aria-label", label); - } - - // Set aria-required - _inputEl.setAttribute( - "aria-required", - requirement === "required" ? "true" : "false", - ); - - updateAriaDescribedBy(); - } - function handleErrorChange(e: Event) { const ce = e as CustomEvent<{ isError: boolean }>; if (_hasError !== ce.detail.isError) { @@ -176,33 +121,6 @@ _inputEl.setAttribute("aria-describedby", ""); } } - - function onSetError(d: FieldsetErrorRelayDetail) { - error = (d as Record)["error"]; - } - - function onInputMount(props: FormFieldMountRelayDetail) { - const { el, name } = props; - - // Check if aria-label is present and has a value in the child element - const ariaLabel = el.getAttribute("aria-label"); - if (!ariaLabel || ariaLabel.trim() === "") { - el.setAttribute("aria-label", label); - } - - sendMountedMessage(name); - } - - // Allows binding to Fieldset components. The `_name` value is what was obtained from the "input" element's - // event, which ensures that the requirement of the "input" and formitem having the same name will be met. - function sendMountedMessage(_name: string) { - relay( - _rootEl, - FormItemMountMsg, - { id: _name, label: name !== "blank" ? name : label, el: _rootEl, order: publicFormSummaryOrder }, - { bubbles: true, timeout: 10 }, - ); - } diff --git a/libs/web-components/src/components/form/Fieldset.svelte b/libs/web-components/src/components/form/Fieldset.svelte deleted file mode 100644 index eca5416ae6..0000000000 --- a/libs/web-components/src/components/form/Fieldset.svelte +++ /dev/null @@ -1,402 +0,0 @@ - - - - -
- {#if Object.values(_errors).filter((err) => !!err).length} - - - - {/if} - - - - - -
- - diff --git a/libs/web-components/src/components/form/Form.svelte b/libs/web-components/src/components/form/Form.svelte index 1420246b93..1e120e8801 100644 --- a/libs/web-components/src/components/form/Form.svelte +++ b/libs/web-components/src/components/form/Form.svelte @@ -4,540 +4,524 @@ }} /> + + -
- -
+ + +
diff --git a/libs/web-components/src/components/form/FormPage.svelte b/libs/web-components/src/components/form/FormPage.svelte index 661c3e8a87..c01193b547 100644 --- a/libs/web-components/src/components/form/FormPage.svelte +++ b/libs/web-components/src/components/form/FormPage.svelte @@ -2,227 +2,139 @@ customElement={{ tag: "goa-public-form-page", props: { + id: { type: "String", reflect: true }, + error: { type: "String", reflect: true }, + errors: { type: "String", reflect: true }, + editting: { type: "Boolean", attribute: "data-pf-editting", reflect: true }, buttonText: { type: "String", attribute: "button-text" }, - buttonVisibility: { type: "String", attribute: "button-visibility" }, subHeading: { type: "String", attribute: "sub-heading" }, sectionTitle: { type: "String", attribute: "section-title" }, - backUrl: { attribute: "back-url", type: "String" }, - summaryHeading: { attribute: "summary-heading", type: "String" }, - } + backVisibility: { attribute: "back-visibility", type: "String" }, + errorSummaryPosition: { attribute: "error-summary-position", type: "String" }, + }, }} /> -
-
- {#if _editting !== id} - {#if backUrl} - - {#if backUrl === "#"} - Back - {:else} - Back - {/if} - - {/if} - - {#if !backUrl && type === "step"} - - Back - - {/if} - {/if} - - {#if sectionTitle} - {sectionTitle} - {/if} - {#if heading} - {heading} - {/if} - {#if subHeading} - {subHeading} - {/if} - - - - {#if type !== "multistep"} - - {#if _editting === id} - dispatchContinueMsg(true)} type="secondary"> - Cancel - - {/if} - - {#if type === "summary"} - dispatchCompletion()} type="primary"> - {buttonText || "Confirm"} - - {:else} - {#if buttonVisibility === "visible"} - dispatchContinueMsg()} type="primary"> - {buttonText || "Continue"} - - {/if} - {/if} - +
+
+ {#if !editting && backVisibility === "visible"} + + Back + + {/if} + + {#if sectionTitle} + {sectionTitle} + {/if} + {#if heading} + {heading} + {/if} + {#if subHeading} + {subHeading} + {/if} + + {#if error} + {error} + {/if} + + + + + {#if hasErrors && errorSummaryPosition === "top"} + +
    + {#each Object.entries(parsedErrors) as [_fieldName, errorMsg]} +
  • {errorMsg}
  • + {/each} +
+
+ {/if} + + + + {#if hasErrors && errorSummaryPosition === "bottom"} + +
    + {#each Object.entries(parsedErrors) as [_fieldName, errorMsg]} +
  • {errorMsg}
  • + {/each} +
+
+ {/if} + + + {#if editting} + + Cancel + {/if} -
+ + {buttonText || "Continue"} + +
diff --git a/libs/web-components/src/components/form/FormSummary.svelte b/libs/web-components/src/components/form/FormSummary.svelte index 6fcf764305..e04d782c5e 100644 --- a/libs/web-components/src/components/form/FormSummary.svelte +++ b/libs/web-components/src/components/form/FormSummary.svelte @@ -2,322 +2,385 @@ -
+
{#if heading} - {heading} {/if} - {#if _state} - {#each _state.history as page} - {#if _state.form[page]} - -
- {#if getHeading(page)} - {getHeading(page)} + {#if Object.keys(_state || {}).length > 0} + {#each _state?.history as page} + {#if showInSummary(page)} +
+
+ {#if shouldShowHeading(page)} +

{getHeading(page)}

{/if} - -
- {#if _state.form[page]?.data?.type} - {#if _state.form[page]?.data?.type === "details"} - - {#each Object.entries(getData(_state, page)) as [_, data]} - - - - - {/each} -
{data.label} - {#if Array.isArray(formatValue(data.value, data.valueLabel, data.labels))} - {#each formatValue(data.value, data.valueLabel, data.labels) as label} -
{label}
- {/each} - {:else} - {formatValue(data.value, data.valueLabel, data.labels)} - {/if} -
+ {#if isSubform(page)} + + {#each getDataItems(page) as item, index} + {#if getOutlineItem(page).summarize} + {#each Object.entries(getSummary(page)) as [key, value]} +
+

{getLabel(page, key)}

+

{format(value)}

+
+ {/each} {:else} - {#each getDataList(_state, page) as item, index} - - {#each Object.entries(item) as [_, data]} - - - - - {/each} -
{data.label} - {#if Array.isArray(formatValue(data.value, data.valueLabel, data.labels))} - {#each formatValue(data.value, data.valueLabel, data.labels) as label} -
{label}
- {/each} - {:else} - {formatValue(data.value, data.valueLabel, data.labels)} - {/if} -
- {#if index < getDataList(_state, page).length - 1} - + {#each Object.entries(item) as [key, value]} + {#if showField(page, key, value)} +
+

{getLabel(page, key)}

+

{formatValue(value, getField(page, key).formatter)}

+
{/if} {/each} {/if} + {#if getDataItems(page).length - 1 !== index} + + {/if} + {/each} + {:else} + + {#if getOutlineItem(page).summarize} + {#each Object.entries(getSummary(page)) as [key, value]} +
+

{getLabel(page, key)}

+

{format(value)}

+
+ {/each} + {:else} + {#each Object.entries(getDataItem(page)) as [key, value]} + {#if getField(page, key).hideInSummary !== "always" && !(getField(page, key).hideInSummary === "ifBlank" && value === "")} +
+

{getLabel(page, key)}

+ {#if isFileField(page, key) && value} + {@const fileData = parseFileData(value)} + {#if fileData?.url} + + {:else} + + {/if} + {:else} +

{formatValue(value, getField(page, key).formatter)}

+ {/if} +
+ {/if} + {/each} {/if} -
-
- - Change - -
+ {/if} +
+
+ + Change +
- +
{/if} {/each} {/if}
diff --git a/libs/web-components/src/components/form/SubForm.svelte b/libs/web-components/src/components/form/SubForm.svelte deleted file mode 100644 index 38751f02ae..0000000000 --- a/libs/web-components/src/components/form/SubForm.svelte +++ /dev/null @@ -1,344 +0,0 @@ - - - - -
-
- -
- - - - -
diff --git a/libs/web-components/src/components/form/SubFormIndex.svelte b/libs/web-components/src/components/form/SubFormIndex.svelte deleted file mode 100644 index f215a4775c..0000000000 --- a/libs/web-components/src/components/form/SubFormIndex.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - - - -
- - - {actionButtonText} - -
-
diff --git a/libs/web-components/src/components/form/Subform.svelte b/libs/web-components/src/components/form/Subform.svelte new file mode 100644 index 0000000000..4cb3a69826 --- /dev/null +++ b/libs/web-components/src/components/form/Subform.svelte @@ -0,0 +1,83 @@ + + + + +
+ + +
+ + Cancel + Save + +
+
+ + + {addbuttontext} +
diff --git a/libs/web-components/src/components/input/Input.svelte b/libs/web-components/src/components/input/Input.svelte index 359a744beb..276587be70 100644 --- a/libs/web-components/src/components/input/Input.svelte +++ b/libs/web-components/src/components/input/Input.svelte @@ -15,8 +15,6 @@ import { typeValidator, toBoolean, - relay, - receive, dispatch, styles, } from "../../common/utils"; @@ -24,16 +22,7 @@ import type { Spacing } from "../../common/styling"; import { calculateMargin } from "../../common/styling"; import { onMount } from "svelte"; - import { - FieldsetErrorRelayDetail, - FieldsetResetErrorsMsg, - FieldsetResetFieldsMsg, - FieldsetSetErrorMsg, - FormFieldMountMsg, - FormFieldMountRelayDetail, - FieldsetSetValueMsg, - FieldsetSetValueRelayDetail, - } from "../../types/relay-types"; + // Validators const [Types, validateType] = typeValidator("Input type", [ "text", @@ -187,16 +176,29 @@ validateType(type); validateAutoCapitalize(autocapitalize); validateTextAlign(textalign); - addRelayListener(); showDeprecationWarnings(); checkSlots(); - sendMountedMessage(); const { containerStyle, inputWidth } = handleWidth(width, type); _containerStyle = containerStyle; _inputWidth = inputWidth; + + bindReset(_rootEl); }); + function bindReset(el: HTMLElement) { + el.addEventListener("goa:reset", (e) => { + // TODO: ensure all other rese events stop the events + e.stopPropagation(); + if (value) { + value = ""; + dispatch(el, "_change", { name, value }, { bubbles: true }); + } + }); + + dispatch(el, "goa:bind", el, { bubbles: true }); + } + // ========= // Functions // ========= @@ -235,51 +237,6 @@ }; } - function addRelayListener() { - receive(_inputEl, (action, data) => { - switch (action) { - case FieldsetSetValueMsg: - setValue(data as FieldsetSetValueRelayDetail); - break; - case FieldsetSetErrorMsg: - setError(data as FieldsetErrorRelayDetail); - break; - case FieldsetResetErrorsMsg: - error = "false"; - break; - case FieldsetResetFieldsMsg: - setValue({ name, value: "" }); - break; - } - }); - } - - function setError(detail: FieldsetErrorRelayDetail) { - error = detail.error ? "true" : "false"; - } - - function setValue(detail: FieldsetSetValueRelayDetail) { - // @ts-expect-error - value = detail.value; - dispatchOnChange(value); - } - - function dispatchOnChange(value: string) { - dispatch(_rootEl, "_change", { name, value: value }, { bubbles: true }); - } - - // Relay message up the chain to allow any parent element to have a reference to the input element - function sendMountedMessage() { - if (name) { - relay( - _rootEl, - FormFieldMountMsg, - { name, el: _inputEl }, - { bubbles: true, timeout: 10 }, - ); - } - } - function onInput(e: Event) { const input = e.target as HTMLInputElement; @@ -543,7 +500,9 @@ ); } - .goa-input:not(.error):not(.input--disabled):hover:not(:has(input:focus-visible)) { + .goa-input:not(.error):not(.input--disabled):hover:not( + :has(input:focus-visible) + ) { /* hover border */ box-shadow: var(--goa-text-input-border-hover); } @@ -696,7 +655,9 @@ } /* V2: Read-only input field styling (exclude disabled inputs) */ - .container.v2.goa-input:not(.error)::has(input:read-only:not(:disabled):not(:focus-visible):not(:hover)) { + .container.v2.goa-input:not(.error)::has( + input:read-only:not(:disabled):not(:focus-visible):not(:hover) + ) { box-shadow: var(--goa-text-input-border-readonly); } diff --git a/libs/web-components/src/components/link/Link.svelte b/libs/web-components/src/components/link/Link.svelte index dc702b0dce..baf4263eec 100644 --- a/libs/web-components/src/components/link/Link.svelte +++ b/libs/web-components/src/components/link/Link.svelte @@ -57,12 +57,13 @@ }) function handleClick(e: Event) { + dispatch(_rootEl, action, actionArg || actionArgs, { bubbles: true }); e.preventDefault(); - dispatch(e.target as Element, action, actionArg || actionArgs, { bubbles: true }); }