policyengine-household-wizard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/dist/WizardReviewList-De9RTK_4.js +245 -0
  4. package/dist/WizardReviewList-tfP9LcqU.cjs +1 -0
  5. package/dist/index.cjs +1 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +35 -0
  9. package/dist/primitives/WizardNavigation.d.ts +19 -0
  10. package/dist/primitives/WizardNavigation.d.ts.map +1 -0
  11. package/dist/primitives/WizardOptionCard.d.ts +9 -0
  12. package/dist/primitives/WizardOptionCard.d.ts.map +1 -0
  13. package/dist/primitives/WizardProgress.d.ts +8 -0
  14. package/dist/primitives/WizardProgress.d.ts.map +1 -0
  15. package/dist/primitives/WizardReviewList.d.ts +14 -0
  16. package/dist/primitives/WizardReviewList.d.ts.map +1 -0
  17. package/dist/primitives/index.d.ts +11 -0
  18. package/dist/primitives/index.d.ts.map +1 -0
  19. package/dist/primitives/types.d.ts +30 -0
  20. package/dist/primitives/types.d.ts.map +1 -0
  21. package/dist/primitives/useWizardSteps.d.ts +3 -0
  22. package/dist/primitives/useWizardSteps.d.ts.map +1 -0
  23. package/dist/primitives.cjs +1 -0
  24. package/dist/primitives.d.ts +1 -0
  25. package/dist/primitives.js +8 -0
  26. package/dist/us-household/adapters/index.d.ts +2 -0
  27. package/dist/us-household/adapters/index.d.ts.map +1 -0
  28. package/dist/us-household/adapters/v1Payload.d.ts +68 -0
  29. package/dist/us-household/adapters/v1Payload.d.ts.map +1 -0
  30. package/dist/us-household/counties.d.ts +25 -0
  31. package/dist/us-household/counties.d.ts.map +1 -0
  32. package/dist/us-household/draft.d.ts +28 -0
  33. package/dist/us-household/draft.d.ts.map +1 -0
  34. package/dist/us-household/index.d.ts +9 -0
  35. package/dist/us-household/index.d.ts.map +1 -0
  36. package/dist/us-household/normalize.d.ts +11 -0
  37. package/dist/us-household/normalize.d.ts.map +1 -0
  38. package/dist/us-household/serialize.d.ts +4 -0
  39. package/dist/us-household/serialize.d.ts.map +1 -0
  40. package/dist/us-household/states.d.ts +15 -0
  41. package/dist/us-household/states.d.ts.map +1 -0
  42. package/dist/us-household/types.d.ts +80 -0
  43. package/dist/us-household/types.d.ts.map +1 -0
  44. package/dist/us-household/validate.d.ts +13 -0
  45. package/dist/us-household/validate.d.ts.map +1 -0
  46. package/dist/us-household-adapters.cjs +1 -0
  47. package/dist/us-household-adapters.d.ts +1 -0
  48. package/dist/us-household-adapters.js +92 -0
  49. package/dist/us-household.cjs +1 -0
  50. package/dist/us-household.d.ts +1 -0
  51. package/dist/us-household.js +556 -0
  52. package/package.json +76 -0
  53. package/src/index.ts +2 -0
  54. package/src/primitives/WizardNavigation.tsx +85 -0
  55. package/src/primitives/WizardOptionCard.tsx +55 -0
  56. package/src/primitives/WizardProgress.tsx +50 -0
  57. package/src/primitives/WizardReviewList.tsx +73 -0
  58. package/src/primitives/index.ts +15 -0
  59. package/src/primitives/types.ts +32 -0
  60. package/src/primitives/useWizardSteps.ts +150 -0
  61. package/src/styles.css +183 -0
  62. package/src/us-household/adapters/index.ts +15 -0
  63. package/src/us-household/adapters/v1Payload.ts +213 -0
  64. package/src/us-household/counties.ts +96 -0
  65. package/src/us-household/data/counties-by-state.json +12802 -0
  66. package/src/us-household/draft.ts +130 -0
  67. package/src/us-household/index.ts +59 -0
  68. package/src/us-household/normalize.ts +251 -0
  69. package/src/us-household/serialize.ts +153 -0
  70. package/src/us-household/states.ts +168 -0
  71. package/src/us-household/types.ts +82 -0
  72. package/src/us-household/validate.ts +129 -0
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "policyengine-household-wizard",
3
+ "version": "0.1.0",
4
+ "description": "Shared wizard primitives and US household draft contract for PolicyEngine apps.",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./primitives": {
16
+ "types": "./dist/primitives/index.d.ts",
17
+ "import": "./dist/primitives.js",
18
+ "require": "./dist/primitives.cjs"
19
+ },
20
+ "./us-household": {
21
+ "types": "./dist/us-household/index.d.ts",
22
+ "import": "./dist/us-household.js",
23
+ "require": "./dist/us-household.cjs"
24
+ },
25
+ "./us-household/adapters": {
26
+ "types": "./dist/us-household/adapters/index.d.ts",
27
+ "import": "./dist/us-household-adapters.js",
28
+ "require": "./dist/us-household-adapters.cjs"
29
+ },
30
+ "./styles.css": "./src/styles.css"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src"
35
+ ],
36
+ "scripts": {
37
+ "build": "vite build && tsc -p tsconfig.build.json --emitDeclarationOnly && tsx scripts/emit-subpath-types.ts",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "typecheck": "tsc --noEmit",
41
+ "lint": "tsc --noEmit",
42
+ "regenerate-counties": "tsx scripts/generate-counties.ts",
43
+ "prepublishOnly": "bun run build"
44
+ },
45
+ "sideEffects": [
46
+ "**/*.css"
47
+ ],
48
+ "peerDependencies": {
49
+ "react": ">=19",
50
+ "react-dom": ">=19"
51
+ },
52
+ "devDependencies": {
53
+ "@testing-library/jest-dom": "^6.6.3",
54
+ "@testing-library/react": "^16.3.0",
55
+ "@testing-library/user-event": "^14.6.1",
56
+ "@types/node": "^22.10.0",
57
+ "@types/react": "^19.0.0",
58
+ "@types/react-dom": "^19.0.0",
59
+ "@vitejs/plugin-react": "^4.3.4",
60
+ "jsdom": "^25.0.1",
61
+ "react": "^19.0.0",
62
+ "react-dom": "^19.0.0",
63
+ "tsx": "^4.20.0",
64
+ "typescript": "^5.7.0",
65
+ "vite": "^6.3.0",
66
+ "vitest": "^3.1.0"
67
+ },
68
+ "publishConfig": {
69
+ "access": "public"
70
+ },
71
+ "license": "MIT",
72
+ "repository": {
73
+ "type": "git",
74
+ "url": "https://github.com/PolicyEngine/policyengine-household-wizard.git"
75
+ }
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './primitives';
2
+ export * from './us-household';
@@ -0,0 +1,85 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+
3
+ export interface WizardNavigationProps extends HTMLAttributes<HTMLDivElement> {
4
+ canAdvance: boolean;
5
+ isFirstStep: boolean;
6
+ isLastStep: boolean;
7
+ backLabel?: ReactNode;
8
+ continueLabel?: ReactNode;
9
+ submitLabel?: ReactNode;
10
+ busyLabel?: ReactNode;
11
+ busy?: boolean;
12
+ onBack?: () => void;
13
+ onContinue?: () => void;
14
+ onSubmit?: () => void;
15
+ leadingActions?: ReactNode;
16
+ trailingActions?: ReactNode;
17
+ hideBack?: boolean;
18
+ }
19
+
20
+ export function WizardNavigation({
21
+ canAdvance,
22
+ isFirstStep,
23
+ isLastStep,
24
+ backLabel = 'Back',
25
+ continueLabel = 'Continue',
26
+ submitLabel = 'Submit',
27
+ busyLabel,
28
+ busy = false,
29
+ onBack,
30
+ onContinue,
31
+ onSubmit,
32
+ leadingActions,
33
+ trailingActions,
34
+ hideBack = false,
35
+ className,
36
+ ...rest
37
+ }: WizardNavigationProps) {
38
+ const primaryDisabled = busy || !canAdvance;
39
+ const primaryLabel = busy && busyLabel ? busyLabel : isLastStep ? submitLabel : continueLabel;
40
+
41
+ const handlePrimary = () => {
42
+ if (primaryDisabled) {
43
+ return;
44
+ }
45
+ if (isLastStep) {
46
+ onSubmit?.();
47
+ } else {
48
+ onContinue?.();
49
+ }
50
+ };
51
+
52
+ return (
53
+ <div
54
+ className={['pe-wizard-nav', className].filter(Boolean).join(' ')}
55
+ {...rest}
56
+ >
57
+ {leadingActions ? (
58
+ <div className="pe-wizard-nav-leading">{leadingActions}</div>
59
+ ) : null}
60
+ {!hideBack ? (
61
+ <button
62
+ type="button"
63
+ className="pe-wizard-nav-back"
64
+ onClick={onBack}
65
+ disabled={isFirstStep || busy}
66
+ aria-label={typeof backLabel === 'string' ? backLabel : undefined}
67
+ >
68
+ {backLabel}
69
+ </button>
70
+ ) : null}
71
+ <button
72
+ type="button"
73
+ className="pe-wizard-nav-primary"
74
+ onClick={handlePrimary}
75
+ disabled={primaryDisabled}
76
+ aria-disabled={primaryDisabled}
77
+ >
78
+ {primaryLabel}
79
+ </button>
80
+ {trailingActions ? (
81
+ <div className="pe-wizard-nav-trailing">{trailingActions}</div>
82
+ ) : null}
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,55 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
2
+
3
+ export interface WizardOptionCardProps
4
+ extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title' | 'onSelect'> {
5
+ selected?: boolean;
6
+ title: ReactNode;
7
+ description?: ReactNode;
8
+ onSelect?: () => void;
9
+ }
10
+
11
+ export function WizardOptionCard({
12
+ selected = false,
13
+ title,
14
+ description,
15
+ onSelect,
16
+ onClick,
17
+ className,
18
+ type,
19
+ disabled,
20
+ ...rest
21
+ }: WizardOptionCardProps) {
22
+ const classes = ['pe-wizard-option-card'];
23
+ if (selected) {
24
+ classes.push('pe-wizard-option-card--selected');
25
+ }
26
+ if (className) {
27
+ classes.push(className);
28
+ }
29
+
30
+ return (
31
+ <button
32
+ type={type ?? 'button'}
33
+ role="radio"
34
+ aria-checked={selected}
35
+ aria-disabled={disabled ? true : undefined}
36
+ disabled={disabled}
37
+ className={classes.join(' ')}
38
+ onClick={(event) => {
39
+ if (disabled) {
40
+ return;
41
+ }
42
+ onClick?.(event);
43
+ if (!event.defaultPrevented) {
44
+ onSelect?.();
45
+ }
46
+ }}
47
+ {...rest}
48
+ >
49
+ <span className="pe-wizard-option-card-title">{title}</span>
50
+ {description ? (
51
+ <span className="pe-wizard-option-card-description">{description}</span>
52
+ ) : null}
53
+ </button>
54
+ );
55
+ }
@@ -0,0 +1,50 @@
1
+ import type { CSSProperties, HTMLAttributes } from 'react';
2
+
3
+ export interface WizardProgressProps extends HTMLAttributes<HTMLDivElement> {
4
+ totalSteps: number;
5
+ currentStepIndex: number;
6
+ currentStepLabel?: string;
7
+ }
8
+
9
+ export function WizardProgress({
10
+ totalSteps,
11
+ currentStepIndex,
12
+ currentStepLabel,
13
+ className,
14
+ ...rest
15
+ }: WizardProgressProps) {
16
+ const safeTotal = Math.max(totalSteps, 0);
17
+ const clampedIndex = Math.max(0, Math.min(currentStepIndex, safeTotal - 1));
18
+ const progress = safeTotal > 0 ? ((clampedIndex + 1) / safeTotal) * 100 : 0;
19
+ const stepNumber = safeTotal > 0 ? clampedIndex + 1 : 0;
20
+
21
+ const trackStyle: CSSProperties = { width: `${progress}%` };
22
+
23
+ return (
24
+ <div
25
+ role="group"
26
+ aria-label={rest['aria-label'] ?? 'Wizard progress'}
27
+ className={['pe-wizard-progress', className].filter(Boolean).join(' ')}
28
+ {...rest}
29
+ >
30
+ <div className="pe-wizard-progress-topline">
31
+ <span data-testid="pe-wizard-progress-step">
32
+ Step {stepNumber} of {safeTotal}
33
+ </span>
34
+ {currentStepLabel ? (
35
+ <span data-testid="pe-wizard-progress-label">{currentStepLabel}</span>
36
+ ) : null}
37
+ </div>
38
+ <div
39
+ className="pe-wizard-progress-track"
40
+ role="progressbar"
41
+ aria-valuemin={0}
42
+ aria-valuemax={safeTotal}
43
+ aria-valuenow={stepNumber}
44
+ aria-valuetext={currentStepLabel}
45
+ >
46
+ <div className="pe-wizard-progress-bar" style={trackStyle} />
47
+ </div>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,73 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+
3
+ export interface WizardReviewItem {
4
+ id: string;
5
+ label: ReactNode;
6
+ value: ReactNode;
7
+ missing?: boolean;
8
+ onEdit?: () => void;
9
+ editLabel?: ReactNode;
10
+ }
11
+
12
+ export interface WizardReviewListProps extends HTMLAttributes<HTMLDivElement> {
13
+ items: WizardReviewItem[];
14
+ }
15
+
16
+ export function WizardReviewList({ items, className, ...rest }: WizardReviewListProps) {
17
+ return (
18
+ <div
19
+ className={['pe-wizard-review-list', className].filter(Boolean).join(' ')}
20
+ role="list"
21
+ {...rest}
22
+ >
23
+ {items.map((item) => {
24
+ const classes = ['pe-wizard-review-item'];
25
+ if (item.missing) {
26
+ classes.push('pe-wizard-review-item--missing');
27
+ }
28
+ const editable = typeof item.onEdit === 'function';
29
+ const content = (
30
+ <>
31
+ <span className="pe-wizard-review-item-label">{item.label}</span>
32
+ <span className="pe-wizard-review-item-value">
33
+ {item.missing && (item.value === null || item.value === undefined || item.value === '')
34
+ ? 'Missing'
35
+ : item.value}
36
+ </span>
37
+ </>
38
+ );
39
+
40
+ if (!editable) {
41
+ return (
42
+ <div
43
+ key={item.id}
44
+ role="listitem"
45
+ className={classes.join(' ')}
46
+ data-testid={`pe-wizard-review-${item.id}`}
47
+ >
48
+ {content}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <button
55
+ key={item.id}
56
+ type="button"
57
+ role="listitem"
58
+ className={classes.join(' ')}
59
+ data-testid={`pe-wizard-review-${item.id}`}
60
+ onClick={item.onEdit}
61
+ aria-label={
62
+ typeof item.label === 'string'
63
+ ? `${item.editLabel ?? 'Edit'} ${item.label}`
64
+ : undefined
65
+ }
66
+ >
67
+ {content}
68
+ </button>
69
+ );
70
+ })}
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,15 @@
1
+ export { useWizardSteps } from './useWizardSteps';
2
+ export { WizardProgress } from './WizardProgress';
3
+ export type { WizardProgressProps } from './WizardProgress';
4
+ export { WizardOptionCard } from './WizardOptionCard';
5
+ export type { WizardOptionCardProps } from './WizardOptionCard';
6
+ export { WizardNavigation } from './WizardNavigation';
7
+ export type { WizardNavigationProps } from './WizardNavigation';
8
+ export { WizardReviewList } from './WizardReviewList';
9
+ export type { WizardReviewItem, WizardReviewListProps } from './WizardReviewList';
10
+ export type {
11
+ ResolvedWizardStep,
12
+ UseWizardStepsOptions,
13
+ UseWizardStepsResult,
14
+ WizardStepConfig,
15
+ } from './types';
@@ -0,0 +1,32 @@
1
+ export interface WizardStepConfig<TState> {
2
+ id: string;
3
+ label: string;
4
+ isComplete?: (state: TState) => boolean;
5
+ isVisible?: (state: TState) => boolean;
6
+ }
7
+
8
+ export interface ResolvedWizardStep {
9
+ id: string;
10
+ label: string;
11
+ }
12
+
13
+ export interface UseWizardStepsOptions<TState> {
14
+ steps: ReadonlyArray<WizardStepConfig<TState>>;
15
+ state: TState;
16
+ initialStepId?: string;
17
+ onStepChange?: (stepId: string) => void;
18
+ }
19
+
20
+ export interface UseWizardStepsResult {
21
+ visibleSteps: ResolvedWizardStep[];
22
+ currentStep: ResolvedWizardStep | null;
23
+ currentStepIndex: number;
24
+ totalSteps: number;
25
+ isFirstStep: boolean;
26
+ isLastStep: boolean;
27
+ canAdvance: boolean;
28
+ goNext: () => void;
29
+ goBack: () => void;
30
+ goToStep: (stepId: string) => void;
31
+ reset: () => void;
32
+ }
@@ -0,0 +1,150 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type {
3
+ ResolvedWizardStep,
4
+ UseWizardStepsOptions,
5
+ UseWizardStepsResult,
6
+ WizardStepConfig,
7
+ } from './types';
8
+
9
+ function resolveVisibleSteps<TState>(
10
+ steps: ReadonlyArray<WizardStepConfig<TState>>,
11
+ state: TState,
12
+ ): WizardStepConfig<TState>[] {
13
+ return steps.filter((step) => (step.isVisible ? step.isVisible(state) : true));
14
+ }
15
+
16
+ function toResolvedStep<TState>(step: WizardStepConfig<TState>): ResolvedWizardStep {
17
+ return { id: step.id, label: step.label };
18
+ }
19
+
20
+ function pickInitialStepId<TState>(
21
+ visibleSteps: WizardStepConfig<TState>[],
22
+ preferredId: string | undefined,
23
+ ): string | null {
24
+ if (visibleSteps.length === 0) {
25
+ return null;
26
+ }
27
+
28
+ if (preferredId && visibleSteps.some((step) => step.id === preferredId)) {
29
+ return preferredId;
30
+ }
31
+
32
+ return visibleSteps[0].id;
33
+ }
34
+
35
+ export function useWizardSteps<TState>(
36
+ options: UseWizardStepsOptions<TState>,
37
+ ): UseWizardStepsResult {
38
+ const { steps, state, initialStepId, onStepChange } = options;
39
+
40
+ const visibleStepConfigs = useMemo(
41
+ () => resolveVisibleSteps(steps, state),
42
+ [steps, state],
43
+ );
44
+
45
+ const visibleSteps = useMemo(
46
+ () => visibleStepConfigs.map(toResolvedStep),
47
+ [visibleStepConfigs],
48
+ );
49
+
50
+ const initialResolvedId = useMemo(
51
+ () => pickInitialStepId(visibleStepConfigs, initialStepId),
52
+ // Only recompute when explicit initialStepId or the set of step ids changes.
53
+ // eslint-disable-next-line react-hooks/exhaustive-deps
54
+ [initialStepId],
55
+ );
56
+
57
+ const [currentStepId, setCurrentStepId] = useState<string | null>(initialResolvedId);
58
+
59
+ // If the visible set changes (e.g. a conditional step is hidden), keep the
60
+ // currentStepId in sync. If the currently selected step is no longer visible,
61
+ // fall back to the first visible step.
62
+ useEffect(() => {
63
+ if (visibleStepConfigs.length === 0) {
64
+ if (currentStepId !== null) {
65
+ setCurrentStepId(null);
66
+ }
67
+ return;
68
+ }
69
+
70
+ const exists = visibleStepConfigs.some((step) => step.id === currentStepId);
71
+ if (!exists) {
72
+ setCurrentStepId(visibleStepConfigs[0].id);
73
+ }
74
+ }, [visibleStepConfigs, currentStepId]);
75
+
76
+ const onStepChangeRef = useRef(onStepChange);
77
+ useEffect(() => {
78
+ onStepChangeRef.current = onStepChange;
79
+ }, [onStepChange]);
80
+
81
+ const setStepAndNotify = useCallback((stepId: string) => {
82
+ setCurrentStepId((previous) => {
83
+ if (previous === stepId) {
84
+ return previous;
85
+ }
86
+ onStepChangeRef.current?.(stepId);
87
+ return stepId;
88
+ });
89
+ }, []);
90
+
91
+ const currentStepIndex = useMemo(
92
+ () => visibleStepConfigs.findIndex((step) => step.id === currentStepId),
93
+ [visibleStepConfigs, currentStepId],
94
+ );
95
+
96
+ const currentStepConfig = currentStepIndex >= 0 ? visibleStepConfigs[currentStepIndex] : null;
97
+ const currentStep = currentStepConfig ? toResolvedStep(currentStepConfig) : null;
98
+
99
+ const totalSteps = visibleStepConfigs.length;
100
+ const isFirstStep = currentStepIndex <= 0;
101
+ const isLastStep = totalSteps === 0 ? true : currentStepIndex === totalSteps - 1;
102
+
103
+ const canAdvance = currentStepConfig?.isComplete
104
+ ? currentStepConfig.isComplete(state)
105
+ : true;
106
+
107
+ const goNext = useCallback(() => {
108
+ if (currentStepIndex < 0 || currentStepIndex >= visibleStepConfigs.length - 1) {
109
+ return;
110
+ }
111
+ setStepAndNotify(visibleStepConfigs[currentStepIndex + 1].id);
112
+ }, [currentStepIndex, visibleStepConfigs, setStepAndNotify]);
113
+
114
+ const goBack = useCallback(() => {
115
+ if (currentStepIndex <= 0) {
116
+ return;
117
+ }
118
+ setStepAndNotify(visibleStepConfigs[currentStepIndex - 1].id);
119
+ }, [currentStepIndex, visibleStepConfigs, setStepAndNotify]);
120
+
121
+ const goToStep = useCallback(
122
+ (stepId: string) => {
123
+ if (visibleStepConfigs.some((step) => step.id === stepId)) {
124
+ setStepAndNotify(stepId);
125
+ }
126
+ },
127
+ [visibleStepConfigs, setStepAndNotify],
128
+ );
129
+
130
+ const reset = useCallback(() => {
131
+ const fallback = pickInitialStepId(visibleStepConfigs, initialStepId);
132
+ if (fallback) {
133
+ setStepAndNotify(fallback);
134
+ }
135
+ }, [visibleStepConfigs, initialStepId, setStepAndNotify]);
136
+
137
+ return {
138
+ visibleSteps,
139
+ currentStep,
140
+ currentStepIndex,
141
+ totalSteps,
142
+ isFirstStep,
143
+ isLastStep,
144
+ canAdvance,
145
+ goNext,
146
+ goBack,
147
+ goToStep,
148
+ reset,
149
+ };
150
+ }