Skip to content

Commit 7bca033

Browse files
committed
feat(components): enhance error handling and form styling with improved UX
- Update Await component to use errorResetKeys prop for better error state management - Rename ErrorBoundary fallback prop to errorFallback for semantic clarity - Change ErrorBoundary resetKeys to errorResetKeys for consistency - Refactor AwaitDemo with dedicated reset handler and error state tracking - Add Reset button to AwaitDemo that appears only when error is triggered - Update error trigger ID from 999 to 666 in demo examples - Enhance FormDemo styling with rounded container, improved input styling, and better visual hierarchy - Add form header with title and description to FormDemo - Update form field styling with larger padding, enhanced focus states, and improved error colors - Refactor form field control prop usage for better form integration - Improve error message styling with better color contrast and dark mode support
1 parent c1390e3 commit 7bca033

7 files changed

Lines changed: 132 additions & 67 deletions

File tree

apps/docs/examples/await-demo.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const fetchUser = async (id: number) => {
2121

2222
await waitFor(1500);
2323

24-
if (id === 999) {
24+
if (id === 666) {
2525
reject(new Error("User not found"));
2626

2727
return promise;
@@ -39,6 +39,7 @@ const fetchUser = async (id: number) => {
3939
export default function AwaitDemo() {
4040
const [userId, setUserId] = useState(1);
4141
const [userPromise, setUserPromise] = useState(() => fetchUser(userId));
42+
const [resetKey, setResetKey] = useState(0);
4243

4344
const loadUser = (id: number) => {
4445
setUserId(id);
@@ -50,28 +51,37 @@ export default function AwaitDemo() {
5051
};
5152

5253
const handleTriggerError = () => {
53-
loadUser(999);
54+
loadUser(666);
5455
};
5556

57+
const handleReset = () => {
58+
setUserId(1);
59+
setUserPromise(fetchUser(1));
60+
setResetKey((prev) => prev + 1);
61+
};
62+
63+
const hasError = userId === 666;
64+
5665
return (
5766
<section className="flex w-full max-w-md flex-col gap-4">
5867
<div className="flex gap-2">
5968
<button
6069
className={cnJoin(
6170
"rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition-all active:scale-95",
62-
userId !== 999 ?
71+
userId !== 666 ?
6372
"bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/90"
64-
: "bg-fd-primary/50 text-fd-primary-foreground/70"
73+
: "cursor-not-allowed bg-fd-primary/50 text-fd-primary-foreground/70"
6574
)}
6675
onClick={handleLoadRandomUser}
6776
type="button"
77+
disabled={hasError}
6878
>
6979
Load User
7080
</button>
7181
<button
7282
className={cnJoin(
7383
"rounded-lg px-4 py-2 text-sm font-medium transition-all active:scale-95",
74-
userId === 999 ?
84+
hasError ?
7585
"bg-red-500 text-white shadow-lg shadow-red-500/30 hover:bg-red-600"
7686
: "border border-fd-border bg-fd-card hover:bg-fd-muted"
7787
)}
@@ -80,9 +90,20 @@ export default function AwaitDemo() {
8090
>
8191
Trigger Error
8292
</button>
93+
94+
{hasError && (
95+
<button
96+
className="rounded-lg border border-fd-border bg-fd-card px-4 py-2 text-sm font-medium
97+
transition-all hover:bg-fd-muted active:scale-95"
98+
onClick={handleReset}
99+
type="button"
100+
>
101+
Reset
102+
</button>
103+
)}
83104
</div>
84105

85-
<Await.Root promise={userPromise} onErrorReset={handleLoadRandomUser}>
106+
<Await.Root promise={userPromise} errorResetKeys={[resetKey]} onErrorReset={handleReset}>
86107
<Await.Pending>
87108
<div
88109
className="rounded-xl border border-fd-border bg-fd-card/40 p-6 shadow-sm

apps/docs/examples/error-boundary-demo.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ export default function ErrorBoundaryDemo() {
6565
</div>
6666

6767
<ErrorBoundary
68-
resetKeys={[resetKey]}
6968
onErrorReset={handleReset}
70-
fallback={({ error, resetErrorBoundary }) => (
69+
errorResetKeys={[resetKey]}
70+
errorFallback={({ error, resetErrorBoundary }) => (
7171
<div
7272
className="rounded-xl border border-red-200 bg-red-50/50 p-6 shadow-sm backdrop-blur-sm
7373
dark:border-red-900/50 dark:bg-red-950/50"

apps/docs/examples/form-demo.tsx

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,69 +30,106 @@ export default function FormDemo() {
3030

3131
return (
3232
<div className="w-full max-w-md">
33-
<Form.Root form={form} onSubmit={(event) => void onSubmit(event)} className="gap-6">
34-
<Form.Field name="name">
33+
<Form.Root
34+
form={form}
35+
onSubmit={(event) => void onSubmit(event)}
36+
className="gap-5 rounded-2xl border border-fd-border bg-fd-card/40 p-6 shadow-sm
37+
backdrop-blur-sm"
38+
>
39+
<header>
40+
<h3 className="text-lg font-semibold text-fd-foreground">Contact Us</h3>
41+
<p className="text-sm text-fd-muted-foreground">
42+
Send us a message and we'll get back to you.
43+
</p>
44+
</header>
45+
46+
<Form.Field control={form.control} name="name" className="mt-4">
3547
<Form.Label className="text-sm font-medium text-fd-foreground">Name</Form.Label>
3648
<Form.Input
3749
placeholder="John Doe"
38-
className="mt-1.5 w-full rounded-md border border-fd-border bg-fd-background px-3 py-2
39-
text-sm text-fd-foreground placeholder:text-fd-muted-foreground
40-
focus:border-fd-primary focus:ring-1 focus:ring-fd-primary focus:outline-none
41-
data-invalid:border-fd-destructive data-invalid:focus:ring-fd-destructive"
50+
className="w-full rounded-lg border border-fd-border bg-fd-background px-3.5 py-2.5
51+
text-sm text-fd-foreground shadow-sm transition-all
52+
placeholder:text-fd-muted-foreground focus:border-fd-primary focus:ring-2
53+
focus:ring-fd-primary/20 focus:outline-none data-invalid:border-red-500
54+
data-invalid:focus:ring-red-500/20"
4255
/>
43-
<Form.ErrorMessage className="mt-1.5 text-xs text-fd-destructive" />
56+
<Form.ErrorMessage className="text-xs text-red-600 dark:text-red-400" />
4457
</Form.Field>
4558

46-
<Form.Field name="email">
59+
<Form.Field control={form.control} name="email">
4760
<Form.Label className="text-sm font-medium text-fd-foreground">Email</Form.Label>
4861
<Form.Input
4962
type="email"
5063
placeholder="john@example.com"
51-
className="mt-1.5 w-full rounded-md border border-fd-border bg-fd-background px-3 py-2
52-
text-sm text-fd-foreground placeholder:text-fd-muted-foreground
53-
focus:border-fd-primary focus:ring-1 focus:ring-fd-primary focus:outline-none
54-
data-invalid:border-fd-destructive data-invalid:focus:ring-fd-destructive"
64+
className="w-full rounded-lg border border-fd-border bg-fd-background px-3.5 py-2.5
65+
text-sm text-fd-foreground shadow-sm transition-all
66+
placeholder:text-fd-muted-foreground focus:border-fd-primary focus:ring-2
67+
focus:ring-fd-primary/20 focus:outline-none data-invalid:border-red-500
68+
data-invalid:focus:ring-red-500/20"
5569
/>
56-
<Form.ErrorMessage className="mt-1.5 text-xs text-fd-destructive" />
70+
<Form.ErrorMessage className="text-xs text-red-600 dark:text-red-400" />
5771
</Form.Field>
5872

59-
<Form.Field name="message">
73+
<Form.Field control={form.control} name="message">
6074
<Form.Label className="text-sm font-medium text-fd-foreground">Message</Form.Label>
6175
<Form.TextArea
6276
placeholder="Tell us what you think..."
6377
rows={4}
64-
className="mt-1.5 w-full resize-none rounded-md border border-fd-border bg-fd-background
65-
px-3 py-2 text-sm text-fd-foreground placeholder:text-fd-muted-foreground
66-
focus:border-fd-primary focus:ring-1 focus:ring-fd-primary focus:outline-none
67-
data-invalid:border-fd-destructive data-invalid:focus:ring-fd-destructive"
78+
className="w-full resize-none rounded-lg border border-fd-border bg-fd-background px-3.5
79+
py-2.5 text-sm text-fd-foreground shadow-sm transition-all
80+
placeholder:text-fd-muted-foreground focus:border-fd-primary focus:ring-2
81+
focus:ring-fd-primary/20 focus:outline-none data-invalid:border-red-500
82+
data-invalid:focus:ring-red-500/20"
6883
/>
69-
<Form.ErrorMessage className="mt-1.5 text-xs text-fd-destructive" />
84+
<Form.ErrorMessage className="text-xs text-red-600 dark:text-red-400" />
7085
</Form.Field>
7186

72-
<Form.Field name="subscribe">
73-
<div className="flex items-center gap-2">
74-
<Form.Input
75-
type="checkbox"
76-
className="size-4 rounded-sm border-fd-border text-fd-primary focus:ring-2
77-
focus:ring-fd-primary focus:ring-offset-2"
78-
/>
79-
<Form.Label className="text-sm text-fd-foreground">Subscribe to newsletter</Form.Label>
80-
</div>
87+
<Form.Field
88+
control={form.control}
89+
name="subscribe"
90+
className="flex-row items-center gap-2.5 rounded-lg bg-fd-muted/30 px-3 py-2.5"
91+
>
92+
<Form.Input
93+
type="checkbox"
94+
className="size-4 rounded-sm border-fd-border text-fd-primary transition-all focus:ring-2
95+
focus:ring-fd-primary/20 focus:ring-offset-0"
96+
/>
97+
<Form.Label className="text-sm font-medium text-fd-foreground">
98+
Subscribe to newsletter
99+
</Form.Label>
81100
</Form.Field>
82101

83102
<Form.StateSubscribe>
84103
{({ isSubmitting, isValid }) => (
85104
<Form.Submit
86105
disabled={isSubmitting || !isValid}
87-
className="w-full rounded-md bg-fd-primary px-4 py-2.5 text-sm font-medium
88-
text-fd-primary-foreground transition-colors hover:bg-fd-primary/90
89-
disabled:cursor-not-allowed disabled:opacity-50"
106+
className="w-full rounded-lg bg-fd-primary px-4 py-2.5 text-sm font-medium
107+
text-fd-primary-foreground shadow-sm transition-all hover:bg-fd-primary/90
108+
active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50"
90109
>
91-
{isSubmitting ? "Sending..." : "Send Message"}
110+
{isSubmitting ?
111+
<LoadingSpinner />
112+
: "Send Message"}
92113
</Form.Submit>
93114
)}
94115
</Form.StateSubscribe>
95116
</Form.Root>
96117
</div>
97118
);
98119
}
120+
121+
function LoadingSpinner() {
122+
return (
123+
<span className="flex items-center justify-center gap-2">
124+
<svg className="size-4 animate-spin" fill="none" viewBox="0 0 24 24">
125+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
126+
<path
127+
className="opacity-75"
128+
fill="currentColor"
129+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
130+
/>
131+
</svg>
132+
Sending...
133+
</span>
134+
);
135+
}

packages/ui-react/src/components/common/await/await.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,17 @@ export type AwaitRootProps<TValue> = Omit<SuspenseWithBoundaryProps, "children">
2626

2727
export function AwaitRoot<TValue>(props: AwaitRootProps<TValue>) {
2828
const {
29+
asChild,
2930
children,
3031
errorFallback,
32+
errorResetKeys,
3133
fallback,
34+
name,
3235
onError,
3336
onErrorReset,
37+
promise,
3438
withErrorBoundary = true,
3539
withSuspense = true,
36-
...restOfProps
3740
} = props;
3841

3942
const WithErrorBoundary = withErrorBoundary ? ErrorBoundary : ReactFragment;
@@ -50,13 +53,16 @@ export function AwaitRoot<TValue>(props: AwaitRootProps<TValue>) {
5053
return (
5154
<WithErrorBoundary
5255
{...(withErrorBoundary && {
53-
fallback: resolvedErrorFallback,
56+
errorFallback: resolvedErrorFallback,
57+
errorResetKeys,
5458
onError,
5559
onErrorReset,
5660
})}
5761
>
58-
<WithSuspense {...(withSuspense && { fallback: resolvedPendingFallback })}>
59-
<AwaitRootImpl {...restOfProps}>{slots.default}</AwaitRootImpl>
62+
<WithSuspense {...(withSuspense && { fallback: resolvedPendingFallback, name })}>
63+
<AwaitRootImpl promise={promise} asChild={asChild}>
64+
{slots.default}
65+
</AwaitRootImpl>
6066
</WithSuspense>
6167
</WithErrorBoundary>
6268
);
@@ -103,7 +109,7 @@ export function AwaitSuccess<TPromiseOrValue, TValue = Awaited<TPromiseOrValue>>
103109

104110
Object.assign(AwaitSuccess, withSlotNameAndSymbol<AwaitSuccessProps>("default"));
105111

106-
type AwaitErrorProps = GetSlotComponentProps<"error", ErrorBoundaryProps["fallback"]>;
112+
type AwaitErrorProps = GetSlotComponentProps<"error", ErrorBoundaryProps["errorFallback"]>;
107113

108114
export const AwaitError = withSlotNameAndSymbol<AwaitErrorProps, { asChild?: boolean }>(
109115
"error",

packages/ui-react/src/components/common/error-boundary/error-boundary.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,20 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
4949

5050
override componentDidUpdate(prevProps: ErrorBoundaryProps, prevState: ErrorBoundaryState) {
5151
const { hasError } = this.state;
52-
const { resetKeys } = this.props;
52+
const { errorResetKeys } = this.props;
5353

5454
// == There's an edge case where if the thing that triggered the error happens to *also* be in the resetKeys array, we'd end up resetting the error boundary immediately.
5555
// == This would likely trigger a second error to be thrown.
5656
// == So we make sure that we don't check the resetKeys on the first call of cDU after the error is set.
5757

58-
if (hasError && prevState.error !== null && hasArrayChanged(prevProps.resetKeys, resetKeys)) {
58+
if (
59+
hasError
60+
&& prevState.error !== null
61+
&& hasArrayChanged(prevProps.errorResetKeys, errorResetKeys)
62+
) {
5963
this.props.onErrorReset?.({
60-
next: resetKeys,
61-
prev: prevProps.resetKeys,
64+
next: errorResetKeys,
65+
prev: prevProps.errorResetKeys,
6266
reason: "keys",
6367
});
6468

@@ -67,23 +71,23 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
6771
}
6872

6973
override render() {
70-
const { children, fallback } = this.props;
74+
const { children, errorFallback } = this.props;
7175
const { error, hasError } = this.state;
7276

7377
let childToRender = children;
7478

7579
if (hasError) {
7680
switch (true) {
77-
case isFunction(fallback): {
78-
childToRender = fallback({
81+
case isFunction(errorFallback): {
82+
childToRender = errorFallback({
7983
error,
8084
resetErrorBoundary: this.#resetErrorBoundary,
8185
});
8286
break;
8387
}
8488

85-
case Boolean(fallback): {
86-
childToRender = fallback;
89+
case Boolean(errorFallback): {
90+
childToRender = errorFallback;
8791
break;
8892
}
8993

packages/ui-react/src/components/common/error-boundary/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ export type ErrorFallbackRenderProps = {
55

66
export type ErrorBoundaryProps = {
77
children: React.ReactNode;
8-
fallback?: React.ReactNode | ((props: ErrorFallbackRenderProps) => React.ReactNode);
8+
errorFallback?: React.ReactNode | ((props: ErrorFallbackRenderProps) => React.ReactNode);
9+
errorResetKeys?: unknown[];
910
onError?: (context: { error: Error; info: React.ErrorInfo & { ownerStack?: string } }) => void;
1011
onErrorReset?: (
1112
context:
@@ -19,5 +20,4 @@ export type ErrorBoundaryProps = {
1920
reason: "keys";
2021
}
2122
) => void;
22-
resetKeys?: unknown[];
2323
};
Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
1-
import { Suspense } from "react";
1+
import { Suspense, type SuspenseProps } from "react";
22
import { ErrorBoundary, type ErrorBoundaryProps } from "../error-boundary";
33

4-
export type SuspenseWithBoundaryProps = {
5-
children: React.ReactNode;
6-
errorFallback?: ErrorBoundaryProps["fallback"];
7-
fallback?: React.ReactNode;
8-
onError?: ErrorBoundaryProps["onError"];
9-
onErrorReset?: ErrorBoundaryProps["onErrorReset"];
10-
};
4+
export type SuspenseWithBoundaryProps = ErrorBoundaryProps & SuspenseProps;
115

126
export function SuspenseWithBoundary(props: SuspenseWithBoundaryProps) {
13-
const { children, errorFallback, fallback, onError, onErrorReset } = props;
7+
const { children, fallback, name, ...restOfErrorBoundaryProps } = props;
148

159
return (
16-
<ErrorBoundary fallback={errorFallback} onError={onError} onErrorReset={onErrorReset}>
17-
<Suspense fallback={fallback}>{children}</Suspense>
10+
<ErrorBoundary {...restOfErrorBoundaryProps}>
11+
<Suspense fallback={fallback} name={name}>
12+
{" "}
13+
{children}
14+
</Suspense>
1815
</ErrorBoundary>
1916
);
2017
}

0 commit comments

Comments
 (0)