@arolariu/components 1.0.0 → 1.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.
- package/CHANGELOG.md +57 -0
- package/EXAMPLES.md +2510 -0
- package/dist/components/ui/alert-dialog.d.ts +4 -16
- package/dist/components/ui/alert-dialog.d.ts.map +1 -1
- package/dist/components/ui/alert-dialog.js +18 -14
- package/dist/components/ui/alert-dialog.js.map +1 -1
- package/dist/components/ui/avatar.d.ts +3 -12
- package/dist/components/ui/avatar.d.ts.map +1 -1
- package/dist/components/ui/avatar.js +18 -15
- package/dist/components/ui/avatar.js.map +1 -1
- package/dist/components/ui/button-group.d.ts +1 -1
- package/dist/components/ui/button-group.d.ts.map +1 -1
- package/dist/components/ui/calendar.d.ts +1 -4
- package/dist/components/ui/calendar.d.ts.map +1 -1
- package/dist/components/ui/calendar.js +7 -7
- package/dist/components/ui/calendar.js.map +1 -1
- package/dist/components/ui/carousel.d.ts.map +1 -1
- package/dist/components/ui/carousel.js.map +1 -1
- package/dist/components/ui/chart.d.ts.map +1 -1
- package/dist/components/ui/chart.js +125 -59
- package/dist/components/ui/chart.js.map +1 -1
- package/dist/components/ui/checkbox-group.d.ts +2 -6
- package/dist/components/ui/checkbox-group.d.ts.map +1 -1
- package/dist/components/ui/checkbox-group.js +8 -7
- package/dist/components/ui/checkbox-group.js.map +1 -1
- package/dist/components/ui/checkbox.d.ts +3 -1
- package/dist/components/ui/checkbox.d.ts.map +1 -1
- package/dist/components/ui/checkbox.js +4 -1
- package/dist/components/ui/checkbox.js.map +1 -1
- package/dist/components/ui/collapsible.d.ts.map +1 -1
- package/dist/components/ui/collapsible.js.map +1 -1
- package/dist/components/ui/combobox.d.ts +335 -0
- package/dist/components/ui/combobox.d.ts.map +1 -0
- package/dist/components/ui/combobox.js +206 -0
- package/dist/components/ui/combobox.js.map +1 -0
- package/dist/components/ui/combobox.module.js +23 -0
- package/dist/components/ui/combobox.module.js.map +1 -0
- package/dist/components/ui/combobox_module.css +142 -0
- package/dist/components/ui/combobox_module.css.map +1 -0
- package/dist/components/ui/command.d.ts.map +1 -1
- package/dist/components/ui/command.js +25 -16
- package/dist/components/ui/command.js.map +1 -1
- package/dist/components/ui/context-menu.d.ts.map +1 -1
- package/dist/components/ui/context-menu.js.map +1 -1
- package/dist/components/ui/drawer.d.ts.map +1 -1
- package/dist/components/ui/drawer.js.map +1 -1
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
- package/dist/components/ui/dropdown-menu.js.map +1 -1
- package/dist/components/ui/dropdrawer.d.ts +10 -16
- package/dist/components/ui/dropdrawer.d.ts.map +1 -1
- package/dist/components/ui/dropdrawer.js +28 -20
- package/dist/components/ui/dropdrawer.js.map +1 -1
- package/dist/components/ui/item.d.ts +1 -1
- package/dist/components/ui/item.d.ts.map +1 -1
- package/dist/components/ui/menubar.d.ts +11 -13
- package/dist/components/ui/menubar.d.ts.map +1 -1
- package/dist/components/ui/menubar.js.map +1 -1
- package/dist/components/ui/meter.d.ts +8 -24
- package/dist/components/ui/meter.d.ts.map +1 -1
- package/dist/components/ui/meter.js +23 -19
- package/dist/components/ui/meter.js.map +1 -1
- package/dist/components/ui/navigation-menu.d.ts +3 -12
- package/dist/components/ui/navigation-menu.d.ts.map +1 -1
- package/dist/components/ui/navigation-menu.js +14 -11
- package/dist/components/ui/navigation-menu.js.map +1 -1
- package/dist/components/ui/number-field.d.ts +6 -12
- package/dist/components/ui/number-field.d.ts.map +1 -1
- package/dist/components/ui/number-field.js.map +1 -1
- package/dist/components/ui/progress.d.ts +1 -4
- package/dist/components/ui/progress.d.ts.map +1 -1
- package/dist/components/ui/progress.js +10 -9
- package/dist/components/ui/progress.js.map +1 -1
- package/dist/components/ui/radio-group.d.ts +2 -4
- package/dist/components/ui/radio-group.d.ts.map +1 -1
- package/dist/components/ui/radio-group.js.map +1 -1
- package/dist/components/ui/resizable.d.ts +3 -3
- package/dist/components/ui/resizable.d.ts.map +1 -1
- package/dist/components/ui/resizable.js.map +1 -1
- package/dist/components/ui/scratcher.d.ts +1 -1
- package/dist/components/ui/scratcher.d.ts.map +1 -1
- package/dist/components/ui/scratcher.js +5 -4
- package/dist/components/ui/scratcher.js.map +1 -1
- package/dist/components/ui/scroll-area.d.ts +2 -4
- package/dist/components/ui/scroll-area.d.ts.map +1 -1
- package/dist/components/ui/scroll-area.js.map +1 -1
- package/dist/components/ui/separator.d.ts +1 -4
- package/dist/components/ui/separator.d.ts.map +1 -1
- package/dist/components/ui/separator.js +9 -8
- package/dist/components/ui/separator.js.map +1 -1
- package/dist/components/ui/sheet.d.ts.map +1 -1
- package/dist/components/ui/sheet.js.map +1 -1
- package/dist/components/ui/sidebar.d.ts +1 -1
- package/dist/components/ui/sidebar.d.ts.map +1 -1
- package/dist/components/ui/sidebar.js.map +1 -1
- package/dist/components/ui/sonner.d.ts +5 -4
- package/dist/components/ui/sonner.d.ts.map +1 -1
- package/dist/components/ui/sonner.js +7 -6
- package/dist/components/ui/sonner.js.map +1 -1
- package/dist/components/ui/toggle-group.d.ts +2 -8
- package/dist/components/ui/toggle-group.d.ts.map +1 -1
- package/dist/components/ui/toggle-group.js +12 -10
- package/dist/components/ui/toggle-group.js.map +1 -1
- package/dist/components/ui/toolbar.d.ts +10 -30
- package/dist/components/ui/toolbar.d.ts.map +1 -1
- package/dist/components/ui/toolbar.js +28 -23
- package/dist/components/ui/toolbar.js.map +1 -1
- package/dist/hooks/useClipboard.d.ts +77 -0
- package/dist/hooks/useClipboard.d.ts.map +1 -0
- package/dist/hooks/useClipboard.js +42 -0
- package/dist/hooks/useClipboard.js.map +1 -0
- package/dist/hooks/useControllableState.d.ts +54 -0
- package/dist/hooks/useControllableState.d.ts.map +1 -0
- package/dist/hooks/useControllableState.js +29 -0
- package/dist/hooks/useControllableState.js.map +1 -0
- package/dist/hooks/useDebounce.d.ts +33 -0
- package/dist/hooks/useDebounce.d.ts.map +1 -0
- package/dist/hooks/useDebounce.js +20 -0
- package/dist/hooks/useDebounce.js.map +1 -0
- package/dist/hooks/useEventCallback.d.ts +34 -0
- package/dist/hooks/useEventCallback.d.ts.map +1 -0
- package/dist/hooks/useEventCallback.js +12 -0
- package/dist/hooks/useEventCallback.js.map +1 -0
- package/dist/hooks/useId.d.ts +30 -0
- package/dist/hooks/useId.d.ts.map +1 -0
- package/dist/hooks/useId.js +9 -0
- package/dist/hooks/useId.js.map +1 -0
- package/dist/hooks/useIntersectionObserver.d.ts +51 -0
- package/dist/hooks/useIntersectionObserver.d.ts.map +1 -0
- package/dist/hooks/useIntersectionObserver.js +25 -0
- package/dist/hooks/useIntersectionObserver.js.map +1 -0
- package/dist/hooks/useInterval.d.ts +55 -0
- package/dist/hooks/useInterval.d.ts.map +1 -0
- package/dist/hooks/useInterval.js +24 -0
- package/dist/hooks/useInterval.js.map +1 -0
- package/dist/hooks/useLocalStorage.d.ts +43 -0
- package/dist/hooks/useLocalStorage.d.ts.map +1 -0
- package/dist/hooks/useLocalStorage.js +53 -0
- package/dist/hooks/useLocalStorage.js.map +1 -0
- package/dist/hooks/useMergedRefs.d.ts +27 -0
- package/dist/hooks/useMergedRefs.d.ts.map +1 -0
- package/dist/hooks/useMergedRefs.js +11 -0
- package/dist/hooks/useMergedRefs.js.map +1 -0
- package/dist/hooks/useOnClickOutside.d.ts +32 -0
- package/dist/hooks/useOnClickOutside.d.ts.map +1 -0
- package/dist/hooks/useOnClickOutside.js +23 -0
- package/dist/hooks/useOnClickOutside.js.map +1 -0
- package/dist/hooks/usePrevious.d.ts +33 -0
- package/dist/hooks/usePrevious.d.ts.map +1 -0
- package/dist/hooks/usePrevious.js +14 -0
- package/dist/hooks/usePrevious.js.map +1 -0
- package/dist/hooks/useThrottle.d.ts +37 -0
- package/dist/hooks/useThrottle.d.ts.map +1 -0
- package/dist/hooks/useThrottle.js +34 -0
- package/dist/hooks/useThrottle.js.map +1 -0
- package/dist/hooks/useTimeout.d.ts +28 -0
- package/dist/hooks/useTimeout.d.ts.map +1 -0
- package/dist/hooks/useTimeout.js +24 -0
- package/dist/hooks/useTimeout.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/lib/utilities.d.ts +2 -3
- package/dist/lib/utilities.d.ts.map +1 -1
- package/dist/lib/utilities.js.map +1 -1
- package/dist/motion/tokens.js +5 -5
- package/dist/motion/tokens.js.map +1 -1
- package/dist/rslib-runtime.js +39 -0
- package/dist/rslib-runtime.js.map +1 -0
- package/package.json +82 -3
- package/src/components/ui/alert-dialog.tsx +15 -8
- package/src/components/ui/avatar.tsx +9 -6
- package/src/components/ui/calendar.tsx +7 -13
- package/src/components/ui/carousel.tsx +2 -0
- package/src/components/ui/chart.tsx +63 -60
- package/src/components/ui/checkbox-group.tsx +4 -5
- package/src/components/ui/checkbox.tsx +10 -2
- package/src/components/ui/collapsible.tsx +1 -0
- package/src/components/ui/combobox.module.css +158 -0
- package/src/components/ui/combobox.tsx +569 -0
- package/src/components/ui/command.tsx +31 -15
- package/src/components/ui/context-menu.tsx +3 -0
- package/src/components/ui/drawer.tsx +2 -0
- package/src/components/ui/dropdown-menu.tsx +3 -0
- package/src/components/ui/dropdrawer.tsx +80 -62
- package/src/components/ui/menubar.tsx +9 -10
- package/src/components/ui/meter.tsx +16 -17
- package/src/components/ui/navigation-menu.tsx +41 -33
- package/src/components/ui/number-field.tsx +6 -13
- package/src/components/ui/progress.tsx +3 -2
- package/src/components/ui/radio-group.tsx +2 -5
- package/src/components/ui/resizable.tsx +2 -2
- package/src/components/ui/scratcher.tsx +6 -10
- package/src/components/ui/scroll-area.tsx +2 -5
- package/src/components/ui/separator.tsx +4 -3
- package/src/components/ui/sheet.tsx +3 -0
- package/src/components/ui/sidebar.tsx +1 -0
- package/src/components/ui/sonner.tsx +20 -12
- package/src/components/ui/toggle-group.tsx +6 -4
- package/src/components/ui/toolbar.tsx +20 -21
- package/src/hooks/useClipboard.tsx +137 -0
- package/src/hooks/useControllableState.tsx +81 -0
- package/src/hooks/useDebounce.tsx +50 -0
- package/src/hooks/useEventCallback.tsx +47 -0
- package/src/hooks/useId.tsx +36 -0
- package/src/hooks/useIntersectionObserver.tsx +81 -0
- package/src/hooks/useInterval.tsx +80 -0
- package/src/hooks/useLocalStorage.tsx +111 -0
- package/src/hooks/useMergedRefs.tsx +48 -0
- package/src/hooks/useOnClickOutside.tsx +55 -0
- package/src/hooks/usePrevious.tsx +44 -0
- package/src/hooks/useThrottle.tsx +78 -0
- package/src/hooks/useTimeout.tsx +51 -0
- package/src/index.ts +23 -0
- package/src/lib/utilities.ts +4 -4
- package/src/motion/tokens.ts +4 -4
- package/src/stories/DesignPrinciples.mdx +48 -0
- package/src/stories/GettingStarted.mdx +92 -0
- package/src/stories/Welcome.mdx +44 -0
package/EXAMPLES.md
CHANGED
|
@@ -1158,3 +1158,2513 @@ import { Button, Card } from "@arolariu/components";
|
|
|
1158
1158
|
```
|
|
1159
1159
|
|
|
1160
1160
|
Ready to build something amazing? **[🚀 Start with our Quick Start Guide](./README.md#-quick-start)**
|
|
1161
|
+
|
|
1162
|
+
---
|
|
1163
|
+
|
|
1164
|
+
## 🎓 Pattern Recipes
|
|
1165
|
+
|
|
1166
|
+
> **Real-world patterns ready to copy, paste, and customize.** These recipes demonstrate common UI patterns using @arolariu/components with best practices for forms, data, modals, and error handling.
|
|
1167
|
+
|
|
1168
|
+
### Recipe 1: Login Form with Validation
|
|
1169
|
+
|
|
1170
|
+
**Complete login form with zod validation, error handling, and loading states.**
|
|
1171
|
+
|
|
1172
|
+
```tsx
|
|
1173
|
+
import {zodResolver} from "@hookform/resolvers/zod";
|
|
1174
|
+
import {useForm} from "react-hook-form";
|
|
1175
|
+
import * as z from "zod";
|
|
1176
|
+
|
|
1177
|
+
import {Alert, AlertDescription} from "@arolariu/components/alert";
|
|
1178
|
+
import {Button} from "@arolariu/components/button";
|
|
1179
|
+
import {
|
|
1180
|
+
Card,
|
|
1181
|
+
CardContent,
|
|
1182
|
+
CardDescription,
|
|
1183
|
+
CardFooter,
|
|
1184
|
+
CardHeader,
|
|
1185
|
+
CardTitle,
|
|
1186
|
+
} from "@arolariu/components/card";
|
|
1187
|
+
import {Checkbox} from "@arolariu/components/checkbox";
|
|
1188
|
+
import {
|
|
1189
|
+
Form,
|
|
1190
|
+
FormControl,
|
|
1191
|
+
FormField,
|
|
1192
|
+
FormItem,
|
|
1193
|
+
FormLabel,
|
|
1194
|
+
FormMessage,
|
|
1195
|
+
} from "@arolariu/components/form";
|
|
1196
|
+
import {Input} from "@arolariu/components/input";
|
|
1197
|
+
import {toast} from "@arolariu/components/sonner";
|
|
1198
|
+
import styles from "./login-form.module.css";
|
|
1199
|
+
|
|
1200
|
+
// Define validation schema
|
|
1201
|
+
const loginSchema = z.object({
|
|
1202
|
+
email: z.string().email("Please enter a valid email address"),
|
|
1203
|
+
password: z.string().min(8, "Password must be at least 8 characters"),
|
|
1204
|
+
rememberMe: z.boolean().default(false),
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
type LoginFormValues = z.infer<typeof loginSchema>;
|
|
1208
|
+
|
|
1209
|
+
export function LoginForm() {
|
|
1210
|
+
const form = useForm<LoginFormValues>({
|
|
1211
|
+
resolver: zodResolver(loginSchema),
|
|
1212
|
+
defaultValues: {
|
|
1213
|
+
email: "",
|
|
1214
|
+
password: "",
|
|
1215
|
+
rememberMe: false,
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
async function onSubmit(values: LoginFormValues) {
|
|
1220
|
+
try {
|
|
1221
|
+
// Simulate API call
|
|
1222
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1223
|
+
|
|
1224
|
+
// Check credentials (mock)
|
|
1225
|
+
if (values.email === "demo@example.com" && values.password === "password123") {
|
|
1226
|
+
toast.success("Login successful! Redirecting...");
|
|
1227
|
+
// Redirect to dashboard
|
|
1228
|
+
window.location.href = "/dashboard";
|
|
1229
|
+
} else {
|
|
1230
|
+
throw new Error("Invalid credentials");
|
|
1231
|
+
}
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
toast.error("Login failed. Please check your credentials and try again.");
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
return (
|
|
1238
|
+
<div className={styles.page}>
|
|
1239
|
+
<Card className={styles.card}>
|
|
1240
|
+
<CardHeader className={styles.header}>
|
|
1241
|
+
<CardTitle>Welcome Back</CardTitle>
|
|
1242
|
+
<CardDescription>
|
|
1243
|
+
Sign in to your account to continue
|
|
1244
|
+
</CardDescription>
|
|
1245
|
+
</CardHeader>
|
|
1246
|
+
|
|
1247
|
+
<Form {...form}>
|
|
1248
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
1249
|
+
<CardContent className={styles.content}>
|
|
1250
|
+
{form.formState.errors.root ? (
|
|
1251
|
+
<Alert variant="destructive">
|
|
1252
|
+
<AlertDescription>
|
|
1253
|
+
{form.formState.errors.root.message}
|
|
1254
|
+
</AlertDescription>
|
|
1255
|
+
</Alert>
|
|
1256
|
+
) : null}
|
|
1257
|
+
|
|
1258
|
+
<FormField
|
|
1259
|
+
control={form.control}
|
|
1260
|
+
name="email"
|
|
1261
|
+
render={({field}) => (
|
|
1262
|
+
<FormItem>
|
|
1263
|
+
<FormLabel>Email</FormLabel>
|
|
1264
|
+
<FormControl>
|
|
1265
|
+
<Input
|
|
1266
|
+
type="email"
|
|
1267
|
+
placeholder="you@example.com"
|
|
1268
|
+
autoComplete="email"
|
|
1269
|
+
{...field}
|
|
1270
|
+
/>
|
|
1271
|
+
</FormControl>
|
|
1272
|
+
<FormMessage />
|
|
1273
|
+
</FormItem>
|
|
1274
|
+
)}
|
|
1275
|
+
/>
|
|
1276
|
+
|
|
1277
|
+
<FormField
|
|
1278
|
+
control={form.control}
|
|
1279
|
+
name="password"
|
|
1280
|
+
render={({field}) => (
|
|
1281
|
+
<FormItem>
|
|
1282
|
+
<FormLabel>Password</FormLabel>
|
|
1283
|
+
<FormControl>
|
|
1284
|
+
<Input
|
|
1285
|
+
type="password"
|
|
1286
|
+
placeholder="••••••••"
|
|
1287
|
+
autoComplete="current-password"
|
|
1288
|
+
{...field}
|
|
1289
|
+
/>
|
|
1290
|
+
</FormControl>
|
|
1291
|
+
<FormMessage />
|
|
1292
|
+
</FormItem>
|
|
1293
|
+
)}
|
|
1294
|
+
/>
|
|
1295
|
+
|
|
1296
|
+
<FormField
|
|
1297
|
+
control={form.control}
|
|
1298
|
+
name="rememberMe"
|
|
1299
|
+
render={({field}) => (
|
|
1300
|
+
<FormItem className={styles.checkboxItem}>
|
|
1301
|
+
<FormControl>
|
|
1302
|
+
<Checkbox
|
|
1303
|
+
checked={field.value}
|
|
1304
|
+
onCheckedChange={field.onChange}
|
|
1305
|
+
/>
|
|
1306
|
+
</FormControl>
|
|
1307
|
+
<FormLabel className={styles.checkboxLabel}>
|
|
1308
|
+
Remember me for 30 days
|
|
1309
|
+
</FormLabel>
|
|
1310
|
+
</FormItem>
|
|
1311
|
+
)}
|
|
1312
|
+
/>
|
|
1313
|
+
</CardContent>
|
|
1314
|
+
|
|
1315
|
+
<CardFooter className={styles.footer}>
|
|
1316
|
+
<Button
|
|
1317
|
+
type="submit"
|
|
1318
|
+
className={styles.submitButton}
|
|
1319
|
+
disabled={form.formState.isSubmitting}
|
|
1320
|
+
>
|
|
1321
|
+
{form.formState.isSubmitting ? "Signing in..." : "Sign In"}
|
|
1322
|
+
</Button>
|
|
1323
|
+
|
|
1324
|
+
<div className={styles.links}>
|
|
1325
|
+
<a href="/forgot-password" className={styles.link}>
|
|
1326
|
+
Forgot password?
|
|
1327
|
+
</a>
|
|
1328
|
+
<a href="/signup" className={styles.link}>
|
|
1329
|
+
Create account
|
|
1330
|
+
</a>
|
|
1331
|
+
</div>
|
|
1332
|
+
</CardFooter>
|
|
1333
|
+
</form>
|
|
1334
|
+
</Form>
|
|
1335
|
+
</Card>
|
|
1336
|
+
</div>
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
```css
|
|
1342
|
+
/* login-form.module.css */
|
|
1343
|
+
.page {
|
|
1344
|
+
display: flex;
|
|
1345
|
+
align-items: center;
|
|
1346
|
+
justify-content: center;
|
|
1347
|
+
min-height: 100vh;
|
|
1348
|
+
padding: 1rem;
|
|
1349
|
+
background-color: var(--ac-muted);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
.card {
|
|
1353
|
+
width: min(28rem, 100%);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
.header {
|
|
1357
|
+
text-align: center;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
.content {
|
|
1361
|
+
display: grid;
|
|
1362
|
+
gap: 1rem;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
.checkboxItem {
|
|
1366
|
+
display: flex;
|
|
1367
|
+
flex-direction: row;
|
|
1368
|
+
align-items: center;
|
|
1369
|
+
gap: 0.5rem;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
.checkboxLabel {
|
|
1373
|
+
margin-top: 0;
|
|
1374
|
+
font-weight: 400;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
.footer {
|
|
1378
|
+
display: flex;
|
|
1379
|
+
flex-direction: column;
|
|
1380
|
+
gap: 1rem;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
.submitButton {
|
|
1384
|
+
width: 100%;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
.links {
|
|
1388
|
+
display: flex;
|
|
1389
|
+
justify-content: space-between;
|
|
1390
|
+
font-size: 0.875rem;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
.link {
|
|
1394
|
+
color: var(--ac-primary);
|
|
1395
|
+
text-decoration: none;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
.link:hover {
|
|
1399
|
+
text-decoration: underline;
|
|
1400
|
+
}
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
---
|
|
1404
|
+
|
|
1405
|
+
### Recipe 2: Data Table with Sorting (TanStack Table)
|
|
1406
|
+
|
|
1407
|
+
**Sortable, filterable data table with row actions and pagination.**
|
|
1408
|
+
|
|
1409
|
+
```tsx
|
|
1410
|
+
import {
|
|
1411
|
+
createColumnHelper,
|
|
1412
|
+
flexRender,
|
|
1413
|
+
getCoreRowModel,
|
|
1414
|
+
getPaginationRowModel,
|
|
1415
|
+
getSortedRowModel,
|
|
1416
|
+
useReactTable,
|
|
1417
|
+
type SortingState,
|
|
1418
|
+
} from "@tanstack/react-table";
|
|
1419
|
+
import {ArrowUpDown, ChevronLeft, ChevronRight, MoreHorizontal} from "lucide-react";
|
|
1420
|
+
import {useState} from "react";
|
|
1421
|
+
|
|
1422
|
+
import {Badge} from "@arolariu/components/badge";
|
|
1423
|
+
import {Button} from "@arolariu/components/button";
|
|
1424
|
+
import {
|
|
1425
|
+
DropdownMenu,
|
|
1426
|
+
DropdownMenuContent,
|
|
1427
|
+
DropdownMenuItem,
|
|
1428
|
+
DropdownMenuLabel,
|
|
1429
|
+
DropdownMenuSeparator,
|
|
1430
|
+
DropdownMenuTrigger,
|
|
1431
|
+
} from "@arolariu/components/dropdown-menu";
|
|
1432
|
+
import {Input} from "@arolariu/components/input";
|
|
1433
|
+
import {
|
|
1434
|
+
Table,
|
|
1435
|
+
TableBody,
|
|
1436
|
+
TableCell,
|
|
1437
|
+
TableHead,
|
|
1438
|
+
TableHeader,
|
|
1439
|
+
TableRow,
|
|
1440
|
+
} from "@arolariu/components/table";
|
|
1441
|
+
import {toast} from "@arolariu/components/sonner";
|
|
1442
|
+
import styles from "./data-table.module.css";
|
|
1443
|
+
|
|
1444
|
+
interface User {
|
|
1445
|
+
id: string;
|
|
1446
|
+
name: string;
|
|
1447
|
+
email: string;
|
|
1448
|
+
role: "admin" | "user" | "guest";
|
|
1449
|
+
status: "active" | "inactive";
|
|
1450
|
+
createdAt: Date;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
const data: User[] = [
|
|
1454
|
+
{
|
|
1455
|
+
id: "1",
|
|
1456
|
+
name: "John Doe",
|
|
1457
|
+
email: "john@example.com",
|
|
1458
|
+
role: "admin",
|
|
1459
|
+
status: "active",
|
|
1460
|
+
createdAt: new Date("2024-01-15"),
|
|
1461
|
+
},
|
|
1462
|
+
{
|
|
1463
|
+
id: "2",
|
|
1464
|
+
name: "Jane Smith",
|
|
1465
|
+
email: "jane@example.com",
|
|
1466
|
+
role: "user",
|
|
1467
|
+
status: "active",
|
|
1468
|
+
createdAt: new Date("2024-02-20"),
|
|
1469
|
+
},
|
|
1470
|
+
{
|
|
1471
|
+
id: "3",
|
|
1472
|
+
name: "Bob Johnson",
|
|
1473
|
+
email: "bob@example.com",
|
|
1474
|
+
role: "guest",
|
|
1475
|
+
status: "inactive",
|
|
1476
|
+
createdAt: new Date("2024-03-10"),
|
|
1477
|
+
},
|
|
1478
|
+
];
|
|
1479
|
+
|
|
1480
|
+
const columnHelper = createColumnHelper<User>();
|
|
1481
|
+
|
|
1482
|
+
export function DataTableWithSorting() {
|
|
1483
|
+
const [sorting, setSorting] = useState<SortingState>([]);
|
|
1484
|
+
const [globalFilter, setGlobalFilter] = useState("");
|
|
1485
|
+
|
|
1486
|
+
const columns = [
|
|
1487
|
+
columnHelper.accessor("name", {
|
|
1488
|
+
header: ({column}) => (
|
|
1489
|
+
<button
|
|
1490
|
+
type="button"
|
|
1491
|
+
className={styles.sortButton}
|
|
1492
|
+
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
1493
|
+
>
|
|
1494
|
+
Name
|
|
1495
|
+
<ArrowUpDown className={styles.sortIcon} />
|
|
1496
|
+
</button>
|
|
1497
|
+
),
|
|
1498
|
+
cell: (info) => <span className={styles.emphasisText}>{info.getValue()}</span>,
|
|
1499
|
+
}),
|
|
1500
|
+
columnHelper.accessor("email", {
|
|
1501
|
+
header: "Email",
|
|
1502
|
+
}),
|
|
1503
|
+
columnHelper.accessor("role", {
|
|
1504
|
+
header: "Role",
|
|
1505
|
+
cell: (info) => {
|
|
1506
|
+
const role = info.getValue();
|
|
1507
|
+
return (
|
|
1508
|
+
<Badge variant={role === "admin" ? "default" : "secondary"}>
|
|
1509
|
+
{role}
|
|
1510
|
+
</Badge>
|
|
1511
|
+
);
|
|
1512
|
+
},
|
|
1513
|
+
}),
|
|
1514
|
+
columnHelper.accessor("status", {
|
|
1515
|
+
header: "Status",
|
|
1516
|
+
cell: (info) => {
|
|
1517
|
+
const status = info.getValue();
|
|
1518
|
+
return (
|
|
1519
|
+
<Badge variant={status === "active" ? "default" : "outline"}>
|
|
1520
|
+
{status}
|
|
1521
|
+
</Badge>
|
|
1522
|
+
);
|
|
1523
|
+
},
|
|
1524
|
+
}),
|
|
1525
|
+
columnHelper.accessor("createdAt", {
|
|
1526
|
+
header: ({column}) => (
|
|
1527
|
+
<button
|
|
1528
|
+
type="button"
|
|
1529
|
+
className={styles.sortButton}
|
|
1530
|
+
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
1531
|
+
>
|
|
1532
|
+
Created At
|
|
1533
|
+
<ArrowUpDown className={styles.sortIcon} />
|
|
1534
|
+
</button>
|
|
1535
|
+
),
|
|
1536
|
+
cell: (info) => info.getValue().toLocaleDateString(),
|
|
1537
|
+
}),
|
|
1538
|
+
columnHelper.display({
|
|
1539
|
+
id: "actions",
|
|
1540
|
+
cell: ({row}) => (
|
|
1541
|
+
<DropdownMenu>
|
|
1542
|
+
<DropdownMenuTrigger render={<button type="button" className={styles.iconButton} />}>
|
|
1543
|
+
<MoreHorizontal />
|
|
1544
|
+
</DropdownMenuTrigger>
|
|
1545
|
+
<DropdownMenuContent align="end">
|
|
1546
|
+
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
1547
|
+
<DropdownMenuItem
|
|
1548
|
+
onClick={() => {
|
|
1549
|
+
navigator.clipboard.writeText(row.original.id);
|
|
1550
|
+
toast.success("User ID copied to clipboard");
|
|
1551
|
+
}}
|
|
1552
|
+
>
|
|
1553
|
+
Copy ID
|
|
1554
|
+
</DropdownMenuItem>
|
|
1555
|
+
<DropdownMenuSeparator />
|
|
1556
|
+
<DropdownMenuItem onClick={() => toast.info(`Viewing user: ${row.original.name}`)}>
|
|
1557
|
+
View details
|
|
1558
|
+
</DropdownMenuItem>
|
|
1559
|
+
<DropdownMenuItem onClick={() => toast.info(`Editing user: ${row.original.name}`)}>
|
|
1560
|
+
Edit user
|
|
1561
|
+
</DropdownMenuItem>
|
|
1562
|
+
</DropdownMenuContent>
|
|
1563
|
+
</DropdownMenu>
|
|
1564
|
+
),
|
|
1565
|
+
}),
|
|
1566
|
+
];
|
|
1567
|
+
|
|
1568
|
+
const table = useReactTable({
|
|
1569
|
+
data,
|
|
1570
|
+
columns,
|
|
1571
|
+
state: {
|
|
1572
|
+
sorting,
|
|
1573
|
+
globalFilter,
|
|
1574
|
+
},
|
|
1575
|
+
onSortingChange: setSorting,
|
|
1576
|
+
onGlobalFilterChange: setGlobalFilter,
|
|
1577
|
+
getCoreRowModel: getCoreRowModel(),
|
|
1578
|
+
getSortedRowModel: getSortedRowModel(),
|
|
1579
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
1580
|
+
initialState: {
|
|
1581
|
+
pagination: {
|
|
1582
|
+
pageSize: 5,
|
|
1583
|
+
},
|
|
1584
|
+
},
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
return (
|
|
1588
|
+
<div className={styles.container}>
|
|
1589
|
+
<div className={styles.toolbar}>
|
|
1590
|
+
<Input
|
|
1591
|
+
placeholder="Search users..."
|
|
1592
|
+
value={globalFilter}
|
|
1593
|
+
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
1594
|
+
className={styles.searchInput}
|
|
1595
|
+
/>
|
|
1596
|
+
</div>
|
|
1597
|
+
|
|
1598
|
+
<div className={styles.tableWrapper}>
|
|
1599
|
+
<Table>
|
|
1600
|
+
<TableHeader>
|
|
1601
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
1602
|
+
<TableRow key={headerGroup.id}>
|
|
1603
|
+
{headerGroup.headers.map((header) => (
|
|
1604
|
+
<TableHead key={header.id}>
|
|
1605
|
+
{header.isPlaceholder
|
|
1606
|
+
? null
|
|
1607
|
+
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
1608
|
+
</TableHead>
|
|
1609
|
+
))}
|
|
1610
|
+
</TableRow>
|
|
1611
|
+
))}
|
|
1612
|
+
</TableHeader>
|
|
1613
|
+
<TableBody>
|
|
1614
|
+
{table.getRowModel().rows.length > 0 ? (
|
|
1615
|
+
table.getRowModel().rows.map((row) => (
|
|
1616
|
+
<TableRow key={row.id}>
|
|
1617
|
+
{row.getVisibleCells().map((cell) => (
|
|
1618
|
+
<TableCell key={cell.id}>
|
|
1619
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
1620
|
+
</TableCell>
|
|
1621
|
+
))}
|
|
1622
|
+
</TableRow>
|
|
1623
|
+
))
|
|
1624
|
+
) : (
|
|
1625
|
+
<TableRow>
|
|
1626
|
+
<TableCell colSpan={columns.length} className={styles.emptyCell}>
|
|
1627
|
+
No results found.
|
|
1628
|
+
</TableCell>
|
|
1629
|
+
</TableRow>
|
|
1630
|
+
)}
|
|
1631
|
+
</TableBody>
|
|
1632
|
+
</Table>
|
|
1633
|
+
</div>
|
|
1634
|
+
|
|
1635
|
+
<div className={styles.pagination}>
|
|
1636
|
+
<div className={styles.paginationInfo}>
|
|
1637
|
+
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
|
1638
|
+
</div>
|
|
1639
|
+
<div className={styles.paginationButtons}>
|
|
1640
|
+
<Button
|
|
1641
|
+
variant="outline"
|
|
1642
|
+
size="sm"
|
|
1643
|
+
onClick={() => table.previousPage()}
|
|
1644
|
+
disabled={!table.getCanPreviousPage()}
|
|
1645
|
+
>
|
|
1646
|
+
<ChevronLeft />
|
|
1647
|
+
Previous
|
|
1648
|
+
</Button>
|
|
1649
|
+
<Button
|
|
1650
|
+
variant="outline"
|
|
1651
|
+
size="sm"
|
|
1652
|
+
onClick={() => table.nextPage()}
|
|
1653
|
+
disabled={!table.getCanNextPage()}
|
|
1654
|
+
>
|
|
1655
|
+
Next
|
|
1656
|
+
<ChevronRight />
|
|
1657
|
+
</Button>
|
|
1658
|
+
</div>
|
|
1659
|
+
</div>
|
|
1660
|
+
</div>
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
```css
|
|
1666
|
+
/* data-table.module.css */
|
|
1667
|
+
.container {
|
|
1668
|
+
display: grid;
|
|
1669
|
+
gap: 1rem;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
.toolbar {
|
|
1673
|
+
display: flex;
|
|
1674
|
+
gap: 0.5rem;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
.searchInput {
|
|
1678
|
+
max-width: 20rem;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
.tableWrapper {
|
|
1682
|
+
border: 1px solid var(--ac-border);
|
|
1683
|
+
border-radius: var(--ac-radius-md);
|
|
1684
|
+
overflow: hidden;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
.sortButton {
|
|
1688
|
+
display: inline-flex;
|
|
1689
|
+
align-items: center;
|
|
1690
|
+
gap: 0.5rem;
|
|
1691
|
+
font-weight: 500;
|
|
1692
|
+
background: none;
|
|
1693
|
+
border: none;
|
|
1694
|
+
cursor: pointer;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
.sortIcon {
|
|
1698
|
+
width: 1rem;
|
|
1699
|
+
height: 1rem;
|
|
1700
|
+
opacity: 0.5;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
.emphasisText {
|
|
1704
|
+
font-weight: 500;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
.iconButton {
|
|
1708
|
+
display: inline-flex;
|
|
1709
|
+
align-items: center;
|
|
1710
|
+
justify-content: center;
|
|
1711
|
+
width: 2rem;
|
|
1712
|
+
height: 2rem;
|
|
1713
|
+
border: none;
|
|
1714
|
+
background: none;
|
|
1715
|
+
border-radius: var(--ac-radius-sm);
|
|
1716
|
+
cursor: pointer;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
.iconButton:hover {
|
|
1720
|
+
background-color: var(--ac-accent);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
.emptyCell {
|
|
1724
|
+
text-align: center;
|
|
1725
|
+
padding: 2rem;
|
|
1726
|
+
color: var(--ac-muted-foreground);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
.pagination {
|
|
1730
|
+
display: flex;
|
|
1731
|
+
align-items: center;
|
|
1732
|
+
justify-content: space-between;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
.paginationInfo {
|
|
1736
|
+
font-size: 0.875rem;
|
|
1737
|
+
color: var(--ac-muted-foreground);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
.paginationButtons {
|
|
1741
|
+
display: flex;
|
|
1742
|
+
gap: 0.5rem;
|
|
1743
|
+
}
|
|
1744
|
+
```
|
|
1745
|
+
|
|
1746
|
+
---
|
|
1747
|
+
|
|
1748
|
+
### Recipe 3: Modal Form (Dialog + Form + Validation)
|
|
1749
|
+
|
|
1750
|
+
**Dialog with form validation and async submission.**
|
|
1751
|
+
|
|
1752
|
+
```tsx
|
|
1753
|
+
import {zodResolver} from "@hookform/resolvers/zod";
|
|
1754
|
+
import {Plus} from "lucide-react";
|
|
1755
|
+
import {useState} from "react";
|
|
1756
|
+
import {useForm} from "react-hook-form";
|
|
1757
|
+
import * as z from "zod";
|
|
1758
|
+
|
|
1759
|
+
import {Button} from "@arolariu/components/button";
|
|
1760
|
+
import {
|
|
1761
|
+
Dialog,
|
|
1762
|
+
DialogContent,
|
|
1763
|
+
DialogDescription,
|
|
1764
|
+
DialogFooter,
|
|
1765
|
+
DialogHeader,
|
|
1766
|
+
DialogTitle,
|
|
1767
|
+
DialogTrigger,
|
|
1768
|
+
} from "@arolariu/components/dialog";
|
|
1769
|
+
import {
|
|
1770
|
+
Form,
|
|
1771
|
+
FormControl,
|
|
1772
|
+
FormDescription,
|
|
1773
|
+
FormField,
|
|
1774
|
+
FormItem,
|
|
1775
|
+
FormLabel,
|
|
1776
|
+
FormMessage,
|
|
1777
|
+
} from "@arolariu/components/form";
|
|
1778
|
+
import {Input} from "@arolariu/components/input";
|
|
1779
|
+
import {
|
|
1780
|
+
Select,
|
|
1781
|
+
SelectContent,
|
|
1782
|
+
SelectItem,
|
|
1783
|
+
SelectTrigger,
|
|
1784
|
+
SelectValue,
|
|
1785
|
+
} from "@arolariu/components/select";
|
|
1786
|
+
import {Textarea} from "@arolariu/components/textarea";
|
|
1787
|
+
import {toast} from "@arolariu/components/sonner";
|
|
1788
|
+
import styles from "./modal-form.module.css";
|
|
1789
|
+
|
|
1790
|
+
const projectSchema = z.object({
|
|
1791
|
+
name: z.string().min(3, "Project name must be at least 3 characters"),
|
|
1792
|
+
description: z.string().max(500, "Description must be less than 500 characters").optional(),
|
|
1793
|
+
category: z.enum(["web", "mobile", "desktop", "other"], {
|
|
1794
|
+
required_error: "Please select a category",
|
|
1795
|
+
}),
|
|
1796
|
+
budget: z.string().regex(/^\d+$/, "Budget must be a valid number"),
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
type ProjectFormValues = z.infer<typeof projectSchema>;
|
|
1800
|
+
|
|
1801
|
+
export function CreateProjectModal() {
|
|
1802
|
+
const [open, setOpen] = useState(false);
|
|
1803
|
+
|
|
1804
|
+
const form = useForm<ProjectFormValues>({
|
|
1805
|
+
resolver: zodResolver(projectSchema),
|
|
1806
|
+
defaultValues: {
|
|
1807
|
+
name: "",
|
|
1808
|
+
description: "",
|
|
1809
|
+
category: undefined,
|
|
1810
|
+
budget: "",
|
|
1811
|
+
},
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
async function onSubmit(values: ProjectFormValues) {
|
|
1815
|
+
try {
|
|
1816
|
+
// Simulate API call
|
|
1817
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
1818
|
+
|
|
1819
|
+
console.log("Project created:", values);
|
|
1820
|
+
toast.success("Project created successfully!");
|
|
1821
|
+
|
|
1822
|
+
// Close modal and reset form
|
|
1823
|
+
setOpen(false);
|
|
1824
|
+
form.reset();
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
toast.error("Failed to create project. Please try again.");
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
return (
|
|
1831
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
1832
|
+
<DialogTrigger render={<Button />}>
|
|
1833
|
+
<Plus />
|
|
1834
|
+
Create Project
|
|
1835
|
+
</DialogTrigger>
|
|
1836
|
+
|
|
1837
|
+
<DialogContent className={styles.content}>
|
|
1838
|
+
<DialogHeader>
|
|
1839
|
+
<DialogTitle>Create New Project</DialogTitle>
|
|
1840
|
+
<DialogDescription>
|
|
1841
|
+
Fill in the details below to create a new project. Click save when you're done.
|
|
1842
|
+
</DialogDescription>
|
|
1843
|
+
</DialogHeader>
|
|
1844
|
+
|
|
1845
|
+
<Form {...form}>
|
|
1846
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className={styles.form}>
|
|
1847
|
+
<FormField
|
|
1848
|
+
control={form.control}
|
|
1849
|
+
name="name"
|
|
1850
|
+
render={({field}) => (
|
|
1851
|
+
<FormItem>
|
|
1852
|
+
<FormLabel>Project Name</FormLabel>
|
|
1853
|
+
<FormControl>
|
|
1854
|
+
<Input placeholder="My Awesome Project" {...field} />
|
|
1855
|
+
</FormControl>
|
|
1856
|
+
<FormDescription>
|
|
1857
|
+
Choose a unique name for your project.
|
|
1858
|
+
</FormDescription>
|
|
1859
|
+
<FormMessage />
|
|
1860
|
+
</FormItem>
|
|
1861
|
+
)}
|
|
1862
|
+
/>
|
|
1863
|
+
|
|
1864
|
+
<FormField
|
|
1865
|
+
control={form.control}
|
|
1866
|
+
name="category"
|
|
1867
|
+
render={({field}) => (
|
|
1868
|
+
<FormItem>
|
|
1869
|
+
<FormLabel>Category</FormLabel>
|
|
1870
|
+
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
1871
|
+
<FormControl>
|
|
1872
|
+
<SelectTrigger>
|
|
1873
|
+
<SelectValue placeholder="Select a category" />
|
|
1874
|
+
</SelectTrigger>
|
|
1875
|
+
</FormControl>
|
|
1876
|
+
<SelectContent>
|
|
1877
|
+
<SelectItem value="web">Web Application</SelectItem>
|
|
1878
|
+
<SelectItem value="mobile">Mobile App</SelectItem>
|
|
1879
|
+
<SelectItem value="desktop">Desktop Application</SelectItem>
|
|
1880
|
+
<SelectItem value="other">Other</SelectItem>
|
|
1881
|
+
</SelectContent>
|
|
1882
|
+
</Select>
|
|
1883
|
+
<FormMessage />
|
|
1884
|
+
</FormItem>
|
|
1885
|
+
)}
|
|
1886
|
+
/>
|
|
1887
|
+
|
|
1888
|
+
<FormField
|
|
1889
|
+
control={form.control}
|
|
1890
|
+
name="budget"
|
|
1891
|
+
render={({field}) => (
|
|
1892
|
+
<FormItem>
|
|
1893
|
+
<FormLabel>Budget (USD)</FormLabel>
|
|
1894
|
+
<FormControl>
|
|
1895
|
+
<Input type="text" placeholder="10000" {...field} />
|
|
1896
|
+
</FormControl>
|
|
1897
|
+
<FormMessage />
|
|
1898
|
+
</FormItem>
|
|
1899
|
+
)}
|
|
1900
|
+
/>
|
|
1901
|
+
|
|
1902
|
+
<FormField
|
|
1903
|
+
control={form.control}
|
|
1904
|
+
name="description"
|
|
1905
|
+
render={({field}) => (
|
|
1906
|
+
<FormItem>
|
|
1907
|
+
<FormLabel>Description</FormLabel>
|
|
1908
|
+
<FormControl>
|
|
1909
|
+
<Textarea
|
|
1910
|
+
placeholder="Describe your project..."
|
|
1911
|
+
className={styles.textarea}
|
|
1912
|
+
{...field}
|
|
1913
|
+
/>
|
|
1914
|
+
</FormControl>
|
|
1915
|
+
<FormMessage />
|
|
1916
|
+
</FormItem>
|
|
1917
|
+
)}
|
|
1918
|
+
/>
|
|
1919
|
+
|
|
1920
|
+
<DialogFooter>
|
|
1921
|
+
<Button
|
|
1922
|
+
type="button"
|
|
1923
|
+
variant="outline"
|
|
1924
|
+
onClick={() => setOpen(false)}
|
|
1925
|
+
>
|
|
1926
|
+
Cancel
|
|
1927
|
+
</Button>
|
|
1928
|
+
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
1929
|
+
{form.formState.isSubmitting ? "Creating..." : "Create Project"}
|
|
1930
|
+
</Button>
|
|
1931
|
+
</DialogFooter>
|
|
1932
|
+
</form>
|
|
1933
|
+
</Form>
|
|
1934
|
+
</DialogContent>
|
|
1935
|
+
</Dialog>
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
```
|
|
1939
|
+
|
|
1940
|
+
```css
|
|
1941
|
+
/* modal-form.module.css */
|
|
1942
|
+
.content {
|
|
1943
|
+
max-width: 32rem;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
.form {
|
|
1947
|
+
display: grid;
|
|
1948
|
+
gap: 1rem;
|
|
1949
|
+
padding-block: 1rem;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
.textarea {
|
|
1953
|
+
min-height: 6rem;
|
|
1954
|
+
resize: vertical;
|
|
1955
|
+
}
|
|
1956
|
+
```
|
|
1957
|
+
|
|
1958
|
+
---
|
|
1959
|
+
|
|
1960
|
+
### Recipe 4: Toast Notifications (Sonner)
|
|
1961
|
+
|
|
1962
|
+
**Comprehensive toast notification patterns for all use cases.**
|
|
1963
|
+
|
|
1964
|
+
```tsx
|
|
1965
|
+
import {CheckCircle2, Info, Loader2, XCircle} from "lucide-react";
|
|
1966
|
+
|
|
1967
|
+
import {Button} from "@arolariu/components/button";
|
|
1968
|
+
import {Card, CardContent, CardHeader, CardTitle} from "@arolariu/components/card";
|
|
1969
|
+
import {toast, Toaster} from "@arolariu/components/sonner";
|
|
1970
|
+
import styles from "./toast-demo.module.css";
|
|
1971
|
+
|
|
1972
|
+
export function ToastDemo() {
|
|
1973
|
+
// Basic toasts
|
|
1974
|
+
const showSuccess = () => {
|
|
1975
|
+
toast.success("Operation completed successfully!");
|
|
1976
|
+
};
|
|
1977
|
+
|
|
1978
|
+
const showError = () => {
|
|
1979
|
+
toast.error("Something went wrong. Please try again.");
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
const showInfo = () => {
|
|
1983
|
+
toast.info("This is an informational message.");
|
|
1984
|
+
};
|
|
1985
|
+
|
|
1986
|
+
const showWarning = () => {
|
|
1987
|
+
toast.warning("Warning: This action cannot be undone!");
|
|
1988
|
+
};
|
|
1989
|
+
|
|
1990
|
+
// Toast with action
|
|
1991
|
+
const showWithAction = () => {
|
|
1992
|
+
toast.success("File uploaded successfully", {
|
|
1993
|
+
action: {
|
|
1994
|
+
label: "View",
|
|
1995
|
+
onClick: () => console.log("View clicked"),
|
|
1996
|
+
},
|
|
1997
|
+
});
|
|
1998
|
+
};
|
|
1999
|
+
|
|
2000
|
+
// Toast with description
|
|
2001
|
+
const showWithDescription = () => {
|
|
2002
|
+
toast.success("Project created", {
|
|
2003
|
+
description: "Your project has been created and is now live.",
|
|
2004
|
+
});
|
|
2005
|
+
};
|
|
2006
|
+
|
|
2007
|
+
// Promise toast (loading → success/error)
|
|
2008
|
+
const showPromiseToast = () => {
|
|
2009
|
+
const uploadPromise = new Promise((resolve, reject) => {
|
|
2010
|
+
setTimeout(() => {
|
|
2011
|
+
Math.random() > 0.5 ? resolve({name: "document.pdf"}) : reject(new Error("Upload failed"));
|
|
2012
|
+
}, 2000);
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
toast.promise(uploadPromise, {
|
|
2016
|
+
loading: "Uploading file...",
|
|
2017
|
+
success: (data: {name: string}) => `${data.name} uploaded successfully!`,
|
|
2018
|
+
error: "Failed to upload file.",
|
|
2019
|
+
});
|
|
2020
|
+
};
|
|
2021
|
+
|
|
2022
|
+
// Custom styled toast
|
|
2023
|
+
const showCustomToast = () => {
|
|
2024
|
+
toast.custom(
|
|
2025
|
+
<div className={styles.customToast}>
|
|
2026
|
+
<CheckCircle2 className={styles.customIcon} />
|
|
2027
|
+
<div className={styles.customContent}>
|
|
2028
|
+
<div className={styles.customTitle}>Custom Toast</div>
|
|
2029
|
+
<div className={styles.customDescription}>
|
|
2030
|
+
This is a fully customized toast notification.
|
|
2031
|
+
</div>
|
|
2032
|
+
</div>
|
|
2033
|
+
</div>
|
|
2034
|
+
);
|
|
2035
|
+
};
|
|
2036
|
+
|
|
2037
|
+
// Loading toast (manual control)
|
|
2038
|
+
const showLoadingToast = () => {
|
|
2039
|
+
const toastId = toast.loading("Processing your request...");
|
|
2040
|
+
|
|
2041
|
+
setTimeout(() => {
|
|
2042
|
+
toast.success("Request processed!", {id: toastId});
|
|
2043
|
+
}, 3000);
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
return (
|
|
2047
|
+
<>
|
|
2048
|
+
<Toaster position="top-right" richColors />
|
|
2049
|
+
|
|
2050
|
+
<div className={styles.container}>
|
|
2051
|
+
<Card>
|
|
2052
|
+
<CardHeader>
|
|
2053
|
+
<CardTitle>Toast Notification Examples</CardTitle>
|
|
2054
|
+
</CardHeader>
|
|
2055
|
+
<CardContent className={styles.grid}>
|
|
2056
|
+
<div className={styles.section}>
|
|
2057
|
+
<h3 className={styles.sectionTitle}>Basic Toasts</h3>
|
|
2058
|
+
<div className={styles.buttonGroup}>
|
|
2059
|
+
<Button onClick={showSuccess} variant="default">
|
|
2060
|
+
<CheckCircle2 />
|
|
2061
|
+
Success Toast
|
|
2062
|
+
</Button>
|
|
2063
|
+
<Button onClick={showError} variant="destructive">
|
|
2064
|
+
<XCircle />
|
|
2065
|
+
Error Toast
|
|
2066
|
+
</Button>
|
|
2067
|
+
<Button onClick={showInfo} variant="outline">
|
|
2068
|
+
<Info />
|
|
2069
|
+
Info Toast
|
|
2070
|
+
</Button>
|
|
2071
|
+
<Button onClick={showWarning} variant="outline">
|
|
2072
|
+
Warning Toast
|
|
2073
|
+
</Button>
|
|
2074
|
+
</div>
|
|
2075
|
+
</div>
|
|
2076
|
+
|
|
2077
|
+
<div className={styles.section}>
|
|
2078
|
+
<h3 className={styles.sectionTitle}>Advanced Toasts</h3>
|
|
2079
|
+
<div className={styles.buttonGroup}>
|
|
2080
|
+
<Button onClick={showWithAction} variant="secondary">
|
|
2081
|
+
Toast with Action
|
|
2082
|
+
</Button>
|
|
2083
|
+
<Button onClick={showWithDescription} variant="secondary">
|
|
2084
|
+
Toast with Description
|
|
2085
|
+
</Button>
|
|
2086
|
+
<Button onClick={showPromiseToast} variant="secondary">
|
|
2087
|
+
<Loader2 />
|
|
2088
|
+
Promise Toast
|
|
2089
|
+
</Button>
|
|
2090
|
+
<Button onClick={showLoadingToast} variant="secondary">
|
|
2091
|
+
Loading Toast
|
|
2092
|
+
</Button>
|
|
2093
|
+
</div>
|
|
2094
|
+
</div>
|
|
2095
|
+
|
|
2096
|
+
<div className={styles.section}>
|
|
2097
|
+
<h3 className={styles.sectionTitle}>Custom Toast</h3>
|
|
2098
|
+
<Button onClick={showCustomToast} variant="outline">
|
|
2099
|
+
Show Custom Toast
|
|
2100
|
+
</Button>
|
|
2101
|
+
</div>
|
|
2102
|
+
</CardContent>
|
|
2103
|
+
</Card>
|
|
2104
|
+
</div>
|
|
2105
|
+
</>
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
```
|
|
2109
|
+
|
|
2110
|
+
```css
|
|
2111
|
+
/* toast-demo.module.css */
|
|
2112
|
+
.container {
|
|
2113
|
+
display: flex;
|
|
2114
|
+
align-items: center;
|
|
2115
|
+
justify-content: center;
|
|
2116
|
+
min-height: 100vh;
|
|
2117
|
+
padding: 1rem;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
.grid {
|
|
2121
|
+
display: grid;
|
|
2122
|
+
gap: 2rem;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
.section {
|
|
2126
|
+
display: grid;
|
|
2127
|
+
gap: 1rem;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
.sectionTitle {
|
|
2131
|
+
font-size: 1rem;
|
|
2132
|
+
font-weight: 600;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
.buttonGroup {
|
|
2136
|
+
display: grid;
|
|
2137
|
+
gap: 0.5rem;
|
|
2138
|
+
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
.customToast {
|
|
2142
|
+
display: flex;
|
|
2143
|
+
align-items: flex-start;
|
|
2144
|
+
gap: 0.75rem;
|
|
2145
|
+
padding: 1rem;
|
|
2146
|
+
background-color: var(--ac-card);
|
|
2147
|
+
border: 1px solid var(--ac-border);
|
|
2148
|
+
border-radius: var(--ac-radius-md);
|
|
2149
|
+
box-shadow: var(--ac-shadow-lg);
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
.customIcon {
|
|
2153
|
+
flex-shrink: 0;
|
|
2154
|
+
width: 1.25rem;
|
|
2155
|
+
height: 1.25rem;
|
|
2156
|
+
color: var(--ac-primary);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
.customContent {
|
|
2160
|
+
display: grid;
|
|
2161
|
+
gap: 0.25rem;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
.customTitle {
|
|
2165
|
+
font-weight: 600;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
.customDescription {
|
|
2169
|
+
font-size: 0.875rem;
|
|
2170
|
+
color: var(--ac-muted-foreground);
|
|
2171
|
+
}
|
|
2172
|
+
```
|
|
2173
|
+
|
|
2174
|
+
---
|
|
2175
|
+
|
|
2176
|
+
### Recipe 5: Sidebar Navigation (with Keyboard Support)
|
|
2177
|
+
|
|
2178
|
+
**Responsive sidebar with keyboard navigation and active states.**
|
|
2179
|
+
|
|
2180
|
+
```tsx
|
|
2181
|
+
import {
|
|
2182
|
+
ChevronDown,
|
|
2183
|
+
FileText,
|
|
2184
|
+
Home,
|
|
2185
|
+
Settings,
|
|
2186
|
+
Users,
|
|
2187
|
+
} from "lucide-react";
|
|
2188
|
+
import {useState} from "react";
|
|
2189
|
+
|
|
2190
|
+
import {
|
|
2191
|
+
Collapsible,
|
|
2192
|
+
CollapsibleContent,
|
|
2193
|
+
CollapsibleTrigger,
|
|
2194
|
+
} from "@arolariu/components/collapsible";
|
|
2195
|
+
import {
|
|
2196
|
+
Sidebar,
|
|
2197
|
+
SidebarContent,
|
|
2198
|
+
SidebarGroup,
|
|
2199
|
+
SidebarGroupContent,
|
|
2200
|
+
SidebarGroupLabel,
|
|
2201
|
+
SidebarMenu,
|
|
2202
|
+
SidebarMenuButton,
|
|
2203
|
+
SidebarMenuItem,
|
|
2204
|
+
SidebarMenuSub,
|
|
2205
|
+
SidebarMenuSubButton,
|
|
2206
|
+
SidebarMenuSubItem,
|
|
2207
|
+
SidebarProvider,
|
|
2208
|
+
SidebarTrigger,
|
|
2209
|
+
} from "@arolariu/components/sidebar";
|
|
2210
|
+
import styles from "./app-sidebar.module.css";
|
|
2211
|
+
|
|
2212
|
+
const menuItems = [
|
|
2213
|
+
{
|
|
2214
|
+
title: "Dashboard",
|
|
2215
|
+
icon: Home,
|
|
2216
|
+
url: "/dashboard",
|
|
2217
|
+
},
|
|
2218
|
+
{
|
|
2219
|
+
title: "Team",
|
|
2220
|
+
icon: Users,
|
|
2221
|
+
url: "/team",
|
|
2222
|
+
submenu: [
|
|
2223
|
+
{title: "Members", url: "/team/members"},
|
|
2224
|
+
{title: "Roles", url: "/team/roles"},
|
|
2225
|
+
{title: "Invitations", url: "/team/invitations"},
|
|
2226
|
+
],
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
title: "Projects",
|
|
2230
|
+
icon: FileText,
|
|
2231
|
+
url: "/projects",
|
|
2232
|
+
submenu: [
|
|
2233
|
+
{title: "Active", url: "/projects/active"},
|
|
2234
|
+
{title: "Archived", url: "/projects/archived"},
|
|
2235
|
+
{title: "Templates", url: "/projects/templates"},
|
|
2236
|
+
],
|
|
2237
|
+
},
|
|
2238
|
+
{
|
|
2239
|
+
title: "Settings",
|
|
2240
|
+
icon: Settings,
|
|
2241
|
+
url: "/settings",
|
|
2242
|
+
},
|
|
2243
|
+
];
|
|
2244
|
+
|
|
2245
|
+
export function AppSidebar() {
|
|
2246
|
+
const [activeItem, setActiveItem] = useState("/dashboard");
|
|
2247
|
+
|
|
2248
|
+
return (
|
|
2249
|
+
<SidebarProvider>
|
|
2250
|
+
<div className={styles.layout}>
|
|
2251
|
+
<Sidebar>
|
|
2252
|
+
<SidebarContent>
|
|
2253
|
+
<SidebarGroup>
|
|
2254
|
+
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
|
2255
|
+
<SidebarGroupContent>
|
|
2256
|
+
<SidebarMenu>
|
|
2257
|
+
{menuItems.map((item) => {
|
|
2258
|
+
const isActive = activeItem === item.url || activeItem.startsWith(item.url + "/");
|
|
2259
|
+
|
|
2260
|
+
if (item.submenu) {
|
|
2261
|
+
return (
|
|
2262
|
+
<Collapsible key={item.title} defaultOpen={isActive}>
|
|
2263
|
+
<SidebarMenuItem>
|
|
2264
|
+
<CollapsibleTrigger asChild>
|
|
2265
|
+
<SidebarMenuButton isActive={isActive}>
|
|
2266
|
+
<item.icon />
|
|
2267
|
+
<span>{item.title}</span>
|
|
2268
|
+
<ChevronDown className={styles.chevron} />
|
|
2269
|
+
</SidebarMenuButton>
|
|
2270
|
+
</CollapsibleTrigger>
|
|
2271
|
+
<CollapsibleContent>
|
|
2272
|
+
<SidebarMenuSub>
|
|
2273
|
+
{item.submenu.map((subitem) => (
|
|
2274
|
+
<SidebarMenuSubItem key={subitem.title}>
|
|
2275
|
+
<SidebarMenuSubButton
|
|
2276
|
+
isActive={activeItem === subitem.url}
|
|
2277
|
+
onClick={() => setActiveItem(subitem.url)}
|
|
2278
|
+
>
|
|
2279
|
+
{subitem.title}
|
|
2280
|
+
</SidebarMenuSubButton>
|
|
2281
|
+
</SidebarMenuSubItem>
|
|
2282
|
+
))}
|
|
2283
|
+
</SidebarMenuSub>
|
|
2284
|
+
</CollapsibleContent>
|
|
2285
|
+
</SidebarMenuItem>
|
|
2286
|
+
</Collapsible>
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
return (
|
|
2291
|
+
<SidebarMenuItem key={item.title}>
|
|
2292
|
+
<SidebarMenuButton
|
|
2293
|
+
isActive={isActive}
|
|
2294
|
+
onClick={() => setActiveItem(item.url)}
|
|
2295
|
+
>
|
|
2296
|
+
<item.icon />
|
|
2297
|
+
<span>{item.title}</span>
|
|
2298
|
+
</SidebarMenuButton>
|
|
2299
|
+
</SidebarMenuItem>
|
|
2300
|
+
);
|
|
2301
|
+
})}
|
|
2302
|
+
</SidebarMenu>
|
|
2303
|
+
</SidebarGroupContent>
|
|
2304
|
+
</SidebarGroup>
|
|
2305
|
+
</SidebarContent>
|
|
2306
|
+
</Sidebar>
|
|
2307
|
+
|
|
2308
|
+
<main className={styles.main}>
|
|
2309
|
+
<div className={styles.header}>
|
|
2310
|
+
<SidebarTrigger />
|
|
2311
|
+
<h1 className={styles.title}>Welcome to Dashboard</h1>
|
|
2312
|
+
</div>
|
|
2313
|
+
|
|
2314
|
+
<div className={styles.content}>
|
|
2315
|
+
<p>Current route: {activeItem}</p>
|
|
2316
|
+
</div>
|
|
2317
|
+
</main>
|
|
2318
|
+
</div>
|
|
2319
|
+
</SidebarProvider>
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
```
|
|
2323
|
+
|
|
2324
|
+
```css
|
|
2325
|
+
/* app-sidebar.module.css */
|
|
2326
|
+
.layout {
|
|
2327
|
+
display: flex;
|
|
2328
|
+
min-height: 100vh;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
.main {
|
|
2332
|
+
flex: 1;
|
|
2333
|
+
display: grid;
|
|
2334
|
+
grid-template-rows: auto 1fr;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
.header {
|
|
2338
|
+
display: flex;
|
|
2339
|
+
align-items: center;
|
|
2340
|
+
gap: 1rem;
|
|
2341
|
+
padding: 1rem;
|
|
2342
|
+
border-bottom: 1px solid var(--ac-border);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
.title {
|
|
2346
|
+
font-size: 1.5rem;
|
|
2347
|
+
font-weight: 600;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
.content {
|
|
2351
|
+
padding: 2rem;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
.chevron {
|
|
2355
|
+
margin-left: auto;
|
|
2356
|
+
transition: transform 150ms;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
:global([data-state="open"]) .chevron {
|
|
2360
|
+
transform: rotate(180deg);
|
|
2361
|
+
}
|
|
2362
|
+
```
|
|
2363
|
+
|
|
2364
|
+
---
|
|
2365
|
+
|
|
2366
|
+
### Recipe 6: Accessible Dropdown Menu (with Keyboard Nav)
|
|
2367
|
+
|
|
2368
|
+
**Fully accessible dropdown menu with keyboard shortcuts.**
|
|
2369
|
+
|
|
2370
|
+
```tsx
|
|
2371
|
+
import {
|
|
2372
|
+
Copy,
|
|
2373
|
+
Download,
|
|
2374
|
+
Edit,
|
|
2375
|
+
LogOut,
|
|
2376
|
+
MoreVertical,
|
|
2377
|
+
Share2,
|
|
2378
|
+
Trash2,
|
|
2379
|
+
User,
|
|
2380
|
+
} from "lucide-react";
|
|
2381
|
+
|
|
2382
|
+
import {Button} from "@arolariu/components/button";
|
|
2383
|
+
import {
|
|
2384
|
+
DropdownMenu,
|
|
2385
|
+
DropdownMenuContent,
|
|
2386
|
+
DropdownMenuGroup,
|
|
2387
|
+
DropdownMenuItem,
|
|
2388
|
+
DropdownMenuLabel,
|
|
2389
|
+
DropdownMenuSeparator,
|
|
2390
|
+
DropdownMenuShortcut,
|
|
2391
|
+
DropdownMenuTrigger,
|
|
2392
|
+
} from "@arolariu/components/dropdown-menu";
|
|
2393
|
+
import {toast} from "@arolariu/components/sonner";
|
|
2394
|
+
import styles from "./dropdown-demo.module.css";
|
|
2395
|
+
|
|
2396
|
+
export function AccessibleDropdownMenu() {
|
|
2397
|
+
const handleAction = (action: string) => {
|
|
2398
|
+
toast.info(`Action: ${action}`);
|
|
2399
|
+
};
|
|
2400
|
+
|
|
2401
|
+
return (
|
|
2402
|
+
<div className={styles.container}>
|
|
2403
|
+
<DropdownMenu>
|
|
2404
|
+
<DropdownMenuTrigger render={<Button variant="outline" />}>
|
|
2405
|
+
<MoreVertical />
|
|
2406
|
+
Actions
|
|
2407
|
+
</DropdownMenuTrigger>
|
|
2408
|
+
|
|
2409
|
+
<DropdownMenuContent align="end" className={styles.content}>
|
|
2410
|
+
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
|
2411
|
+
|
|
2412
|
+
<DropdownMenuSeparator />
|
|
2413
|
+
|
|
2414
|
+
<DropdownMenuGroup>
|
|
2415
|
+
<DropdownMenuItem onClick={() => handleAction("Profile")}>
|
|
2416
|
+
<User />
|
|
2417
|
+
<span>Profile</span>
|
|
2418
|
+
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
|
2419
|
+
</DropdownMenuItem>
|
|
2420
|
+
|
|
2421
|
+
<DropdownMenuItem onClick={() => handleAction("Edit")}>
|
|
2422
|
+
<Edit />
|
|
2423
|
+
<span>Edit</span>
|
|
2424
|
+
<DropdownMenuShortcut>⌘E</DropdownMenuShortcut>
|
|
2425
|
+
</DropdownMenuItem>
|
|
2426
|
+
|
|
2427
|
+
<DropdownMenuItem onClick={() => handleAction("Copy")}>
|
|
2428
|
+
<Copy />
|
|
2429
|
+
<span>Copy Link</span>
|
|
2430
|
+
<DropdownMenuShortcut>⌘C</DropdownMenuShortcut>
|
|
2431
|
+
</DropdownMenuItem>
|
|
2432
|
+
|
|
2433
|
+
<DropdownMenuItem onClick={() => handleAction("Share")}>
|
|
2434
|
+
<Share2 />
|
|
2435
|
+
<span>Share</span>
|
|
2436
|
+
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
|
2437
|
+
</DropdownMenuItem>
|
|
2438
|
+
</DropdownMenuGroup>
|
|
2439
|
+
|
|
2440
|
+
<DropdownMenuSeparator />
|
|
2441
|
+
|
|
2442
|
+
<DropdownMenuGroup>
|
|
2443
|
+
<DropdownMenuItem onClick={() => handleAction("Download")}>
|
|
2444
|
+
<Download />
|
|
2445
|
+
<span>Download</span>
|
|
2446
|
+
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
|
|
2447
|
+
</DropdownMenuItem>
|
|
2448
|
+
</DropdownMenuGroup>
|
|
2449
|
+
|
|
2450
|
+
<DropdownMenuSeparator />
|
|
2451
|
+
|
|
2452
|
+
<DropdownMenuItem
|
|
2453
|
+
onClick={() => handleAction("Delete")}
|
|
2454
|
+
className={styles.dangerItem}
|
|
2455
|
+
>
|
|
2456
|
+
<Trash2 />
|
|
2457
|
+
<span>Delete</span>
|
|
2458
|
+
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
|
|
2459
|
+
</DropdownMenuItem>
|
|
2460
|
+
|
|
2461
|
+
<DropdownMenuSeparator />
|
|
2462
|
+
|
|
2463
|
+
<DropdownMenuItem onClick={() => handleAction("Logout")}>
|
|
2464
|
+
<LogOut />
|
|
2465
|
+
<span>Log out</span>
|
|
2466
|
+
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
|
2467
|
+
</DropdownMenuItem>
|
|
2468
|
+
</DropdownMenuContent>
|
|
2469
|
+
</DropdownMenu>
|
|
2470
|
+
|
|
2471
|
+
<div className={styles.instructions}>
|
|
2472
|
+
<p className={styles.instructionTitle}>Keyboard Navigation:</p>
|
|
2473
|
+
<ul className={styles.instructionList}>
|
|
2474
|
+
<li><kbd>Enter</kbd> or <kbd>Space</kbd> - Open menu</li>
|
|
2475
|
+
<li><kbd>↑</kbd> <kbd>↓</kbd> - Navigate items</li>
|
|
2476
|
+
<li><kbd>Enter</kbd> - Select item</li>
|
|
2477
|
+
<li><kbd>Esc</kbd> - Close menu</li>
|
|
2478
|
+
</ul>
|
|
2479
|
+
</div>
|
|
2480
|
+
</div>
|
|
2481
|
+
);
|
|
2482
|
+
}
|
|
2483
|
+
```
|
|
2484
|
+
|
|
2485
|
+
```css
|
|
2486
|
+
/* dropdown-demo.module.css */
|
|
2487
|
+
.container {
|
|
2488
|
+
display: flex;
|
|
2489
|
+
flex-direction: column;
|
|
2490
|
+
align-items: center;
|
|
2491
|
+
gap: 2rem;
|
|
2492
|
+
padding: 4rem 1rem;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
.content {
|
|
2496
|
+
min-width: 14rem;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
.dangerItem {
|
|
2500
|
+
color: var(--ac-destructive);
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
.instructions {
|
|
2504
|
+
display: grid;
|
|
2505
|
+
gap: 0.5rem;
|
|
2506
|
+
padding: 1rem;
|
|
2507
|
+
background-color: var(--ac-muted);
|
|
2508
|
+
border-radius: var(--ac-radius-md);
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
.instructionTitle {
|
|
2512
|
+
font-weight: 600;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
.instructionList {
|
|
2516
|
+
display: grid;
|
|
2517
|
+
gap: 0.25rem;
|
|
2518
|
+
padding-left: 1.5rem;
|
|
2519
|
+
font-size: 0.875rem;
|
|
2520
|
+
color: var(--ac-muted-foreground);
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
.instructionList kbd {
|
|
2524
|
+
display: inline-block;
|
|
2525
|
+
padding: 0.125rem 0.375rem;
|
|
2526
|
+
background-color: var(--ac-background);
|
|
2527
|
+
border: 1px solid var(--ac-border);
|
|
2528
|
+
border-radius: var(--ac-radius-sm);
|
|
2529
|
+
font-family: var(--ac-font-mono);
|
|
2530
|
+
font-size: 0.75rem;
|
|
2531
|
+
}
|
|
2532
|
+
```
|
|
2533
|
+
|
|
2534
|
+
---
|
|
2535
|
+
|
|
2536
|
+
### Recipe 7: Date Picker (Calendar Integration)
|
|
2537
|
+
|
|
2538
|
+
**Calendar-based date picker with range selection.**
|
|
2539
|
+
|
|
2540
|
+
```tsx
|
|
2541
|
+
import {format} from "date-fns";
|
|
2542
|
+
import {Calendar as CalendarIcon} from "lucide-react";
|
|
2543
|
+
import {useState} from "react";
|
|
2544
|
+
|
|
2545
|
+
import {Button} from "@arolariu/components/button";
|
|
2546
|
+
import {Calendar} from "@arolariu/components/calendar";
|
|
2547
|
+
import {
|
|
2548
|
+
Popover,
|
|
2549
|
+
PopoverContent,
|
|
2550
|
+
PopoverTrigger,
|
|
2551
|
+
} from "@arolariu/components/popover";
|
|
2552
|
+
import {cn} from "@arolariu/components/utilities";
|
|
2553
|
+
import styles from "./date-picker.module.css";
|
|
2554
|
+
|
|
2555
|
+
export function DatePicker() {
|
|
2556
|
+
const [date, setDate] = useState<Date | undefined>();
|
|
2557
|
+
|
|
2558
|
+
return (
|
|
2559
|
+
<div className={styles.container}>
|
|
2560
|
+
<div className={styles.field}>
|
|
2561
|
+
<label className={styles.label}>Select Date</label>
|
|
2562
|
+
<Popover>
|
|
2563
|
+
<PopoverTrigger render={<Button variant="outline" className={styles.trigger} />}>
|
|
2564
|
+
<CalendarIcon className={styles.icon} />
|
|
2565
|
+
{date ? format(date, "PPP") : <span>Pick a date</span>}
|
|
2566
|
+
</PopoverTrigger>
|
|
2567
|
+
<PopoverContent align="start" className={styles.popoverContent}>
|
|
2568
|
+
<Calendar
|
|
2569
|
+
mode="single"
|
|
2570
|
+
selected={date}
|
|
2571
|
+
onSelect={setDate}
|
|
2572
|
+
initialFocus
|
|
2573
|
+
/>
|
|
2574
|
+
</PopoverContent>
|
|
2575
|
+
</Popover>
|
|
2576
|
+
</div>
|
|
2577
|
+
</div>
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
export function DateRangePicker() {
|
|
2582
|
+
const [dateRange, setDateRange] = useState<{from: Date | undefined; to: Date | undefined}>({
|
|
2583
|
+
from: undefined,
|
|
2584
|
+
to: undefined,
|
|
2585
|
+
});
|
|
2586
|
+
|
|
2587
|
+
return (
|
|
2588
|
+
<div className={styles.container}>
|
|
2589
|
+
<div className={styles.field}>
|
|
2590
|
+
<label className={styles.label}>Select Date Range</label>
|
|
2591
|
+
<Popover>
|
|
2592
|
+
<PopoverTrigger render={<Button variant="outline" className={styles.trigger} />}>
|
|
2593
|
+
<CalendarIcon className={styles.icon} />
|
|
2594
|
+
{dateRange.from ? (
|
|
2595
|
+
dateRange.to ? (
|
|
2596
|
+
<>
|
|
2597
|
+
{format(dateRange.from, "LLL dd, y")} - {format(dateRange.to, "LLL dd, y")}
|
|
2598
|
+
</>
|
|
2599
|
+
) : (
|
|
2600
|
+
format(dateRange.from, "LLL dd, y")
|
|
2601
|
+
)
|
|
2602
|
+
) : (
|
|
2603
|
+
<span>Pick a date range</span>
|
|
2604
|
+
)}
|
|
2605
|
+
</PopoverTrigger>
|
|
2606
|
+
<PopoverContent align="start" className={styles.popoverContent}>
|
|
2607
|
+
<Calendar
|
|
2608
|
+
mode="range"
|
|
2609
|
+
selected={dateRange}
|
|
2610
|
+
onSelect={(range) =>
|
|
2611
|
+
setDateRange({
|
|
2612
|
+
from: range?.from,
|
|
2613
|
+
to: range?.to,
|
|
2614
|
+
})
|
|
2615
|
+
}
|
|
2616
|
+
numberOfMonths={2}
|
|
2617
|
+
initialFocus
|
|
2618
|
+
/>
|
|
2619
|
+
</PopoverContent>
|
|
2620
|
+
</Popover>
|
|
2621
|
+
</div>
|
|
2622
|
+
</div>
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
```
|
|
2626
|
+
|
|
2627
|
+
```css
|
|
2628
|
+
/* date-picker.module.css */
|
|
2629
|
+
.container {
|
|
2630
|
+
display: flex;
|
|
2631
|
+
align-items: center;
|
|
2632
|
+
justify-content: center;
|
|
2633
|
+
min-height: 100vh;
|
|
2634
|
+
padding: 1rem;
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
.field {
|
|
2638
|
+
display: grid;
|
|
2639
|
+
gap: 0.5rem;
|
|
2640
|
+
width: min(24rem, 100%);
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
.label {
|
|
2644
|
+
font-weight: 500;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
.trigger {
|
|
2648
|
+
justify-content: flex-start;
|
|
2649
|
+
text-align: left;
|
|
2650
|
+
font-weight: 400;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
.icon {
|
|
2654
|
+
width: 1rem;
|
|
2655
|
+
height: 1rem;
|
|
2656
|
+
margin-right: 0.5rem;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
.popoverContent {
|
|
2660
|
+
width: auto;
|
|
2661
|
+
padding: 0;
|
|
2662
|
+
}
|
|
2663
|
+
```
|
|
2664
|
+
|
|
2665
|
+
---
|
|
2666
|
+
|
|
2667
|
+
### Recipe 8: File Upload Area (with Progress)
|
|
2668
|
+
|
|
2669
|
+
**Drag-and-drop file upload with progress tracking.**
|
|
2670
|
+
|
|
2671
|
+
```tsx
|
|
2672
|
+
import {Upload, X} from "lucide-react";
|
|
2673
|
+
import {useState} from "react";
|
|
2674
|
+
|
|
2675
|
+
import {Button} from "@arolariu/components/button";
|
|
2676
|
+
import {Card, CardContent, CardHeader, CardTitle} from "@arolariu/components/card";
|
|
2677
|
+
import {Progress} from "@arolariu/components/progress";
|
|
2678
|
+
import {toast} from "@arolariu/components/sonner";
|
|
2679
|
+
import styles from "./file-upload.module.css";
|
|
2680
|
+
|
|
2681
|
+
interface FileUpload {
|
|
2682
|
+
id: string;
|
|
2683
|
+
file: File;
|
|
2684
|
+
progress: number;
|
|
2685
|
+
status: "uploading" | "complete" | "error";
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
export function FileUploadArea() {
|
|
2689
|
+
const [uploads, setUploads] = useState<FileUpload[]>([]);
|
|
2690
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
2691
|
+
|
|
2692
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
2693
|
+
e.preventDefault();
|
|
2694
|
+
setIsDragging(true);
|
|
2695
|
+
};
|
|
2696
|
+
|
|
2697
|
+
const handleDragLeave = () => {
|
|
2698
|
+
setIsDragging(false);
|
|
2699
|
+
};
|
|
2700
|
+
|
|
2701
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
2702
|
+
e.preventDefault();
|
|
2703
|
+
setIsDragging(false);
|
|
2704
|
+
|
|
2705
|
+
const files = Array.from(e.dataTransfer.files);
|
|
2706
|
+
handleFiles(files);
|
|
2707
|
+
};
|
|
2708
|
+
|
|
2709
|
+
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
2710
|
+
const files = e.target.files ? Array.from(e.target.files) : [];
|
|
2711
|
+
handleFiles(files);
|
|
2712
|
+
};
|
|
2713
|
+
|
|
2714
|
+
const handleFiles = (files: File[]) => {
|
|
2715
|
+
const newUploads: FileUpload[] = files.map((file) => ({
|
|
2716
|
+
id: Math.random().toString(36).substring(7),
|
|
2717
|
+
file,
|
|
2718
|
+
progress: 0,
|
|
2719
|
+
status: "uploading" as const,
|
|
2720
|
+
}));
|
|
2721
|
+
|
|
2722
|
+
setUploads((prev) => [...prev, ...newUploads]);
|
|
2723
|
+
|
|
2724
|
+
// Simulate upload progress
|
|
2725
|
+
newUploads.forEach((upload) => {
|
|
2726
|
+
simulateUpload(upload.id);
|
|
2727
|
+
});
|
|
2728
|
+
};
|
|
2729
|
+
|
|
2730
|
+
const simulateUpload = (id: string) => {
|
|
2731
|
+
const interval = setInterval(() => {
|
|
2732
|
+
setUploads((prev) =>
|
|
2733
|
+
prev.map((upload) => {
|
|
2734
|
+
if (upload.id === id) {
|
|
2735
|
+
const newProgress = Math.min(upload.progress + 10, 100);
|
|
2736
|
+
const newStatus = newProgress === 100 ? "complete" : upload.status;
|
|
2737
|
+
|
|
2738
|
+
if (newStatus === "complete") {
|
|
2739
|
+
clearInterval(interval);
|
|
2740
|
+
toast.success(`${upload.file.name} uploaded successfully`);
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
return {...upload, progress: newProgress, status: newStatus};
|
|
2744
|
+
}
|
|
2745
|
+
return upload;
|
|
2746
|
+
})
|
|
2747
|
+
);
|
|
2748
|
+
}, 300);
|
|
2749
|
+
};
|
|
2750
|
+
|
|
2751
|
+
const removeUpload = (id: string) => {
|
|
2752
|
+
setUploads((prev) => prev.filter((upload) => upload.id !== id));
|
|
2753
|
+
};
|
|
2754
|
+
|
|
2755
|
+
return (
|
|
2756
|
+
<div className={styles.container}>
|
|
2757
|
+
<Card className={styles.card}>
|
|
2758
|
+
<CardHeader>
|
|
2759
|
+
<CardTitle>Upload Files</CardTitle>
|
|
2760
|
+
</CardHeader>
|
|
2761
|
+
<CardContent className={styles.content}>
|
|
2762
|
+
<div
|
|
2763
|
+
className={`${styles.dropZone} ${isDragging ? styles.dropZoneActive : ""}`}
|
|
2764
|
+
onDragOver={handleDragOver}
|
|
2765
|
+
onDragLeave={handleDragLeave}
|
|
2766
|
+
onDrop={handleDrop}
|
|
2767
|
+
>
|
|
2768
|
+
<Upload className={styles.uploadIcon} />
|
|
2769
|
+
<div className={styles.dropZoneText}>
|
|
2770
|
+
<p className={styles.dropZoneTitle}>Drop files here or click to upload</p>
|
|
2771
|
+
<p className={styles.dropZoneSubtitle}>
|
|
2772
|
+
Supports: Images, PDFs, Documents (Max 10MB)
|
|
2773
|
+
</p>
|
|
2774
|
+
</div>
|
|
2775
|
+
<input
|
|
2776
|
+
type="file"
|
|
2777
|
+
multiple
|
|
2778
|
+
className={styles.fileInput}
|
|
2779
|
+
onChange={handleFileInput}
|
|
2780
|
+
/>
|
|
2781
|
+
</div>
|
|
2782
|
+
|
|
2783
|
+
{uploads.length > 0 ? (
|
|
2784
|
+
<div className={styles.uploadList}>
|
|
2785
|
+
{uploads.map((upload) => (
|
|
2786
|
+
<div key={upload.id} className={styles.uploadItem}>
|
|
2787
|
+
<div className={styles.uploadInfo}>
|
|
2788
|
+
<div className={styles.uploadName}>{upload.file.name}</div>
|
|
2789
|
+
<div className={styles.uploadSize}>
|
|
2790
|
+
{(upload.file.size / 1024 / 1024).toFixed(2)} MB
|
|
2791
|
+
</div>
|
|
2792
|
+
</div>
|
|
2793
|
+
|
|
2794
|
+
<div className={styles.uploadProgress}>
|
|
2795
|
+
<Progress value={upload.progress} className={styles.progressBar} />
|
|
2796
|
+
<span className={styles.uploadPercent}>{upload.progress}%</span>
|
|
2797
|
+
</div>
|
|
2798
|
+
|
|
2799
|
+
{upload.status === "complete" ? (
|
|
2800
|
+
<Button
|
|
2801
|
+
variant="ghost"
|
|
2802
|
+
size="icon"
|
|
2803
|
+
onClick={() => removeUpload(upload.id)}
|
|
2804
|
+
className={styles.removeButton}
|
|
2805
|
+
>
|
|
2806
|
+
<X />
|
|
2807
|
+
</Button>
|
|
2808
|
+
) : null}
|
|
2809
|
+
</div>
|
|
2810
|
+
))}
|
|
2811
|
+
</div>
|
|
2812
|
+
) : null}
|
|
2813
|
+
</CardContent>
|
|
2814
|
+
</Card>
|
|
2815
|
+
</div>
|
|
2816
|
+
);
|
|
2817
|
+
}
|
|
2818
|
+
```
|
|
2819
|
+
|
|
2820
|
+
```css
|
|
2821
|
+
/* file-upload.module.css */
|
|
2822
|
+
.container {
|
|
2823
|
+
display: flex;
|
|
2824
|
+
align-items: center;
|
|
2825
|
+
justify-content: center;
|
|
2826
|
+
min-height: 100vh;
|
|
2827
|
+
padding: 1rem;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
.card {
|
|
2831
|
+
width: min(40rem, 100%);
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
.content {
|
|
2835
|
+
display: grid;
|
|
2836
|
+
gap: 1.5rem;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
.dropZone {
|
|
2840
|
+
position: relative;
|
|
2841
|
+
display: flex;
|
|
2842
|
+
flex-direction: column;
|
|
2843
|
+
align-items: center;
|
|
2844
|
+
gap: 1rem;
|
|
2845
|
+
padding: 3rem 2rem;
|
|
2846
|
+
border: 2px dashed var(--ac-border);
|
|
2847
|
+
border-radius: var(--ac-radius-md);
|
|
2848
|
+
background-color: var(--ac-muted);
|
|
2849
|
+
cursor: pointer;
|
|
2850
|
+
transition: all 150ms;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
.dropZone:hover {
|
|
2854
|
+
border-color: var(--ac-primary);
|
|
2855
|
+
background-color: color-mix(in oklch, var(--ac-primary) 5%, var(--ac-muted));
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
.dropZoneActive {
|
|
2859
|
+
border-color: var(--ac-primary);
|
|
2860
|
+
background-color: color-mix(in oklch, var(--ac-primary) 10%, var(--ac-muted));
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
.uploadIcon {
|
|
2864
|
+
width: 3rem;
|
|
2865
|
+
height: 3rem;
|
|
2866
|
+
color: var(--ac-muted-foreground);
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
.dropZoneText {
|
|
2870
|
+
text-align: center;
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
.dropZoneTitle {
|
|
2874
|
+
font-weight: 600;
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
.dropZoneSubtitle {
|
|
2878
|
+
margin-top: 0.25rem;
|
|
2879
|
+
font-size: 0.875rem;
|
|
2880
|
+
color: var(--ac-muted-foreground);
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
.fileInput {
|
|
2884
|
+
position: absolute;
|
|
2885
|
+
inset: 0;
|
|
2886
|
+
width: 100%;
|
|
2887
|
+
height: 100%;
|
|
2888
|
+
opacity: 0;
|
|
2889
|
+
cursor: pointer;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
.uploadList {
|
|
2893
|
+
display: grid;
|
|
2894
|
+
gap: 1rem;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
.uploadItem {
|
|
2898
|
+
display: grid;
|
|
2899
|
+
grid-template-columns: 1fr auto;
|
|
2900
|
+
gap: 1rem;
|
|
2901
|
+
padding: 1rem;
|
|
2902
|
+
border: 1px solid var(--ac-border);
|
|
2903
|
+
border-radius: var(--ac-radius-md);
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
.uploadInfo {
|
|
2907
|
+
display: grid;
|
|
2908
|
+
gap: 0.25rem;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
.uploadName {
|
|
2912
|
+
font-weight: 500;
|
|
2913
|
+
word-break: break-all;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
.uploadSize {
|
|
2917
|
+
font-size: 0.875rem;
|
|
2918
|
+
color: var(--ac-muted-foreground);
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
.uploadProgress {
|
|
2922
|
+
grid-column: 1 / -1;
|
|
2923
|
+
display: flex;
|
|
2924
|
+
align-items: center;
|
|
2925
|
+
gap: 0.75rem;
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
.progressBar {
|
|
2929
|
+
flex: 1;
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
.uploadPercent {
|
|
2933
|
+
font-size: 0.875rem;
|
|
2934
|
+
font-weight: 500;
|
|
2935
|
+
color: var(--ac-muted-foreground);
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
.removeButton {
|
|
2939
|
+
align-self: flex-start;
|
|
2940
|
+
}
|
|
2941
|
+
```
|
|
2942
|
+
|
|
2943
|
+
---
|
|
2944
|
+
|
|
2945
|
+
### Recipe 9: Settings Page (Tabs + Form + Switch)
|
|
2946
|
+
|
|
2947
|
+
**Complete settings page with tabs and form controls.**
|
|
2948
|
+
|
|
2949
|
+
```tsx
|
|
2950
|
+
import {zodResolver} from "@hookform/resolvers/zod";
|
|
2951
|
+
import {useForm} from "react-hook-form";
|
|
2952
|
+
import * as z from "zod";
|
|
2953
|
+
|
|
2954
|
+
import {Button} from "@arolariu/components/button";
|
|
2955
|
+
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@arolariu/components/card";
|
|
2956
|
+
import {
|
|
2957
|
+
Form,
|
|
2958
|
+
FormControl,
|
|
2959
|
+
FormDescription,
|
|
2960
|
+
FormField,
|
|
2961
|
+
FormItem,
|
|
2962
|
+
FormLabel,
|
|
2963
|
+
FormMessage,
|
|
2964
|
+
} from "@arolariu/components/form";
|
|
2965
|
+
import {Input} from "@arolariu/components/input";
|
|
2966
|
+
import {
|
|
2967
|
+
Select,
|
|
2968
|
+
SelectContent,
|
|
2969
|
+
SelectItem,
|
|
2970
|
+
SelectTrigger,
|
|
2971
|
+
SelectValue,
|
|
2972
|
+
} from "@arolariu/components/select";
|
|
2973
|
+
import {Switch} from "@arolariu/components/switch";
|
|
2974
|
+
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@arolariu/components/tabs";
|
|
2975
|
+
import {Textarea} from "@arolariu/components/textarea";
|
|
2976
|
+
import {toast} from "@arolariu/components/sonner";
|
|
2977
|
+
import styles from "./settings-page.module.css";
|
|
2978
|
+
|
|
2979
|
+
const profileSchema = z.object({
|
|
2980
|
+
username: z.string().min(3, "Username must be at least 3 characters"),
|
|
2981
|
+
email: z.string().email("Please enter a valid email address"),
|
|
2982
|
+
bio: z.string().max(500, "Bio must be less than 500 characters").optional(),
|
|
2983
|
+
});
|
|
2984
|
+
|
|
2985
|
+
const notificationSchema = z.object({
|
|
2986
|
+
emailNotifications: z.boolean(),
|
|
2987
|
+
pushNotifications: z.boolean(),
|
|
2988
|
+
weeklyDigest: z.boolean(),
|
|
2989
|
+
});
|
|
2990
|
+
|
|
2991
|
+
const appearanceSchema = z.object({
|
|
2992
|
+
theme: z.enum(["light", "dark", "system"]),
|
|
2993
|
+
language: z.string(),
|
|
2994
|
+
});
|
|
2995
|
+
|
|
2996
|
+
export function SettingsPage() {
|
|
2997
|
+
const profileForm = useForm<z.infer<typeof profileSchema>>({
|
|
2998
|
+
resolver: zodResolver(profileSchema),
|
|
2999
|
+
defaultValues: {
|
|
3000
|
+
username: "johndoe",
|
|
3001
|
+
email: "john@example.com",
|
|
3002
|
+
bio: "",
|
|
3003
|
+
},
|
|
3004
|
+
});
|
|
3005
|
+
|
|
3006
|
+
const notificationForm = useForm<z.infer<typeof notificationSchema>>({
|
|
3007
|
+
resolver: zodResolver(notificationSchema),
|
|
3008
|
+
defaultValues: {
|
|
3009
|
+
emailNotifications: true,
|
|
3010
|
+
pushNotifications: false,
|
|
3011
|
+
weeklyDigest: true,
|
|
3012
|
+
},
|
|
3013
|
+
});
|
|
3014
|
+
|
|
3015
|
+
const appearanceForm = useForm<z.infer<typeof appearanceSchema>>({
|
|
3016
|
+
resolver: zodResolver(appearanceSchema),
|
|
3017
|
+
defaultValues: {
|
|
3018
|
+
theme: "system",
|
|
3019
|
+
language: "en",
|
|
3020
|
+
},
|
|
3021
|
+
});
|
|
3022
|
+
|
|
3023
|
+
const onProfileSubmit = (values: z.infer<typeof profileSchema>) => {
|
|
3024
|
+
console.log("Profile updated:", values);
|
|
3025
|
+
toast.success("Profile updated successfully!");
|
|
3026
|
+
};
|
|
3027
|
+
|
|
3028
|
+
const onNotificationSubmit = (values: z.infer<typeof notificationSchema>) => {
|
|
3029
|
+
console.log("Notifications updated:", values);
|
|
3030
|
+
toast.success("Notification preferences updated!");
|
|
3031
|
+
};
|
|
3032
|
+
|
|
3033
|
+
const onAppearanceSubmit = (values: z.infer<typeof appearanceSchema>) => {
|
|
3034
|
+
console.log("Appearance updated:", values);
|
|
3035
|
+
toast.success("Appearance settings updated!");
|
|
3036
|
+
};
|
|
3037
|
+
|
|
3038
|
+
return (
|
|
3039
|
+
<div className={styles.container}>
|
|
3040
|
+
<div className={styles.header}>
|
|
3041
|
+
<h1 className={styles.title}>Settings</h1>
|
|
3042
|
+
<p className={styles.subtitle}>
|
|
3043
|
+
Manage your account settings and preferences.
|
|
3044
|
+
</p>
|
|
3045
|
+
</div>
|
|
3046
|
+
|
|
3047
|
+
<Tabs defaultValue="profile" className={styles.tabs}>
|
|
3048
|
+
<TabsList>
|
|
3049
|
+
<TabsTrigger value="profile">Profile</TabsTrigger>
|
|
3050
|
+
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
|
3051
|
+
<TabsTrigger value="appearance">Appearance</TabsTrigger>
|
|
3052
|
+
</TabsList>
|
|
3053
|
+
|
|
3054
|
+
<TabsContent value="profile" className={styles.tabContent}>
|
|
3055
|
+
<Card>
|
|
3056
|
+
<CardHeader>
|
|
3057
|
+
<CardTitle>Profile Information</CardTitle>
|
|
3058
|
+
<CardDescription>
|
|
3059
|
+
Update your profile details and public information.
|
|
3060
|
+
</CardDescription>
|
|
3061
|
+
</CardHeader>
|
|
3062
|
+
<CardContent>
|
|
3063
|
+
<Form {...profileForm}>
|
|
3064
|
+
<form
|
|
3065
|
+
onSubmit={profileForm.handleSubmit(onProfileSubmit)}
|
|
3066
|
+
className={styles.form}
|
|
3067
|
+
>
|
|
3068
|
+
<FormField
|
|
3069
|
+
control={profileForm.control}
|
|
3070
|
+
name="username"
|
|
3071
|
+
render={({field}) => (
|
|
3072
|
+
<FormItem>
|
|
3073
|
+
<FormLabel>Username</FormLabel>
|
|
3074
|
+
<FormControl>
|
|
3075
|
+
<Input placeholder="johndoe" {...field} />
|
|
3076
|
+
</FormControl>
|
|
3077
|
+
<FormDescription>
|
|
3078
|
+
This is your public display name.
|
|
3079
|
+
</FormDescription>
|
|
3080
|
+
<FormMessage />
|
|
3081
|
+
</FormItem>
|
|
3082
|
+
)}
|
|
3083
|
+
/>
|
|
3084
|
+
|
|
3085
|
+
<FormField
|
|
3086
|
+
control={profileForm.control}
|
|
3087
|
+
name="email"
|
|
3088
|
+
render={({field}) => (
|
|
3089
|
+
<FormItem>
|
|
3090
|
+
<FormLabel>Email</FormLabel>
|
|
3091
|
+
<FormControl>
|
|
3092
|
+
<Input type="email" placeholder="john@example.com" {...field} />
|
|
3093
|
+
</FormControl>
|
|
3094
|
+
<FormDescription>
|
|
3095
|
+
Your email address for account notifications.
|
|
3096
|
+
</FormDescription>
|
|
3097
|
+
<FormMessage />
|
|
3098
|
+
</FormItem>
|
|
3099
|
+
)}
|
|
3100
|
+
/>
|
|
3101
|
+
|
|
3102
|
+
<FormField
|
|
3103
|
+
control={profileForm.control}
|
|
3104
|
+
name="bio"
|
|
3105
|
+
render={({field}) => (
|
|
3106
|
+
<FormItem>
|
|
3107
|
+
<FormLabel>Bio</FormLabel>
|
|
3108
|
+
<FormControl>
|
|
3109
|
+
<Textarea
|
|
3110
|
+
placeholder="Tell us about yourself..."
|
|
3111
|
+
className={styles.textarea}
|
|
3112
|
+
{...field}
|
|
3113
|
+
/>
|
|
3114
|
+
</FormControl>
|
|
3115
|
+
<FormDescription>
|
|
3116
|
+
Brief description for your profile. Max 500 characters.
|
|
3117
|
+
</FormDescription>
|
|
3118
|
+
<FormMessage />
|
|
3119
|
+
</FormItem>
|
|
3120
|
+
)}
|
|
3121
|
+
/>
|
|
3122
|
+
|
|
3123
|
+
<Button type="submit">Save Changes</Button>
|
|
3124
|
+
</form>
|
|
3125
|
+
</Form>
|
|
3126
|
+
</CardContent>
|
|
3127
|
+
</Card>
|
|
3128
|
+
</TabsContent>
|
|
3129
|
+
|
|
3130
|
+
<TabsContent value="notifications" className={styles.tabContent}>
|
|
3131
|
+
<Card>
|
|
3132
|
+
<CardHeader>
|
|
3133
|
+
<CardTitle>Notification Preferences</CardTitle>
|
|
3134
|
+
<CardDescription>
|
|
3135
|
+
Choose how you want to receive notifications.
|
|
3136
|
+
</CardDescription>
|
|
3137
|
+
</CardHeader>
|
|
3138
|
+
<CardContent>
|
|
3139
|
+
<Form {...notificationForm}>
|
|
3140
|
+
<form
|
|
3141
|
+
onSubmit={notificationForm.handleSubmit(onNotificationSubmit)}
|
|
3142
|
+
className={styles.form}
|
|
3143
|
+
>
|
|
3144
|
+
<FormField
|
|
3145
|
+
control={notificationForm.control}
|
|
3146
|
+
name="emailNotifications"
|
|
3147
|
+
render={({field}) => (
|
|
3148
|
+
<FormItem className={styles.switchItem}>
|
|
3149
|
+
<div className={styles.switchContent}>
|
|
3150
|
+
<FormLabel>Email Notifications</FormLabel>
|
|
3151
|
+
<FormDescription>
|
|
3152
|
+
Receive email updates about your account activity.
|
|
3153
|
+
</FormDescription>
|
|
3154
|
+
</div>
|
|
3155
|
+
<FormControl>
|
|
3156
|
+
<Switch
|
|
3157
|
+
checked={field.value}
|
|
3158
|
+
onCheckedChange={field.onChange}
|
|
3159
|
+
/>
|
|
3160
|
+
</FormControl>
|
|
3161
|
+
</FormItem>
|
|
3162
|
+
)}
|
|
3163
|
+
/>
|
|
3164
|
+
|
|
3165
|
+
<FormField
|
|
3166
|
+
control={notificationForm.control}
|
|
3167
|
+
name="pushNotifications"
|
|
3168
|
+
render={({field}) => (
|
|
3169
|
+
<FormItem className={styles.switchItem}>
|
|
3170
|
+
<div className={styles.switchContent}>
|
|
3171
|
+
<FormLabel>Push Notifications</FormLabel>
|
|
3172
|
+
<FormDescription>
|
|
3173
|
+
Get push notifications on your devices.
|
|
3174
|
+
</FormDescription>
|
|
3175
|
+
</div>
|
|
3176
|
+
<FormControl>
|
|
3177
|
+
<Switch
|
|
3178
|
+
checked={field.value}
|
|
3179
|
+
onCheckedChange={field.onChange}
|
|
3180
|
+
/>
|
|
3181
|
+
</FormControl>
|
|
3182
|
+
</FormItem>
|
|
3183
|
+
)}
|
|
3184
|
+
/>
|
|
3185
|
+
|
|
3186
|
+
<FormField
|
|
3187
|
+
control={notificationForm.control}
|
|
3188
|
+
name="weeklyDigest"
|
|
3189
|
+
render={({field}) => (
|
|
3190
|
+
<FormItem className={styles.switchItem}>
|
|
3191
|
+
<div className={styles.switchContent}>
|
|
3192
|
+
<FormLabel>Weekly Digest</FormLabel>
|
|
3193
|
+
<FormDescription>
|
|
3194
|
+
Receive a weekly summary of your activity.
|
|
3195
|
+
</FormDescription>
|
|
3196
|
+
</div>
|
|
3197
|
+
<FormControl>
|
|
3198
|
+
<Switch
|
|
3199
|
+
checked={field.value}
|
|
3200
|
+
onCheckedChange={field.onChange}
|
|
3201
|
+
/>
|
|
3202
|
+
</FormControl>
|
|
3203
|
+
</FormItem>
|
|
3204
|
+
)}
|
|
3205
|
+
/>
|
|
3206
|
+
|
|
3207
|
+
<Button type="submit">Save Preferences</Button>
|
|
3208
|
+
</form>
|
|
3209
|
+
</Form>
|
|
3210
|
+
</CardContent>
|
|
3211
|
+
</Card>
|
|
3212
|
+
</TabsContent>
|
|
3213
|
+
|
|
3214
|
+
<TabsContent value="appearance" className={styles.tabContent}>
|
|
3215
|
+
<Card>
|
|
3216
|
+
<CardHeader>
|
|
3217
|
+
<CardTitle>Appearance Settings</CardTitle>
|
|
3218
|
+
<CardDescription>
|
|
3219
|
+
Customize how the application looks and feels.
|
|
3220
|
+
</CardDescription>
|
|
3221
|
+
</CardHeader>
|
|
3222
|
+
<CardContent>
|
|
3223
|
+
<Form {...appearanceForm}>
|
|
3224
|
+
<form
|
|
3225
|
+
onSubmit={appearanceForm.handleSubmit(onAppearanceSubmit)}
|
|
3226
|
+
className={styles.form}
|
|
3227
|
+
>
|
|
3228
|
+
<FormField
|
|
3229
|
+
control={appearanceForm.control}
|
|
3230
|
+
name="theme"
|
|
3231
|
+
render={({field}) => (
|
|
3232
|
+
<FormItem>
|
|
3233
|
+
<FormLabel>Theme</FormLabel>
|
|
3234
|
+
<Select
|
|
3235
|
+
onValueChange={field.onChange}
|
|
3236
|
+
defaultValue={field.value}
|
|
3237
|
+
>
|
|
3238
|
+
<FormControl>
|
|
3239
|
+
<SelectTrigger>
|
|
3240
|
+
<SelectValue placeholder="Select a theme" />
|
|
3241
|
+
</SelectTrigger>
|
|
3242
|
+
</FormControl>
|
|
3243
|
+
<SelectContent>
|
|
3244
|
+
<SelectItem value="light">Light</SelectItem>
|
|
3245
|
+
<SelectItem value="dark">Dark</SelectItem>
|
|
3246
|
+
<SelectItem value="system">System</SelectItem>
|
|
3247
|
+
</SelectContent>
|
|
3248
|
+
</Select>
|
|
3249
|
+
<FormDescription>
|
|
3250
|
+
Choose your preferred color scheme.
|
|
3251
|
+
</FormDescription>
|
|
3252
|
+
<FormMessage />
|
|
3253
|
+
</FormItem>
|
|
3254
|
+
)}
|
|
3255
|
+
/>
|
|
3256
|
+
|
|
3257
|
+
<FormField
|
|
3258
|
+
control={appearanceForm.control}
|
|
3259
|
+
name="language"
|
|
3260
|
+
render={({field}) => (
|
|
3261
|
+
<FormItem>
|
|
3262
|
+
<FormLabel>Language</FormLabel>
|
|
3263
|
+
<Select
|
|
3264
|
+
onValueChange={field.onChange}
|
|
3265
|
+
defaultValue={field.value}
|
|
3266
|
+
>
|
|
3267
|
+
<FormControl>
|
|
3268
|
+
<SelectTrigger>
|
|
3269
|
+
<SelectValue placeholder="Select a language" />
|
|
3270
|
+
</SelectTrigger>
|
|
3271
|
+
</FormControl>
|
|
3272
|
+
<SelectContent>
|
|
3273
|
+
<SelectItem value="en">English</SelectItem>
|
|
3274
|
+
<SelectItem value="es">Español</SelectItem>
|
|
3275
|
+
<SelectItem value="fr">Français</SelectItem>
|
|
3276
|
+
<SelectItem value="de">Deutsch</SelectItem>
|
|
3277
|
+
</SelectContent>
|
|
3278
|
+
</Select>
|
|
3279
|
+
<FormDescription>
|
|
3280
|
+
Select your preferred language.
|
|
3281
|
+
</FormDescription>
|
|
3282
|
+
<FormMessage />
|
|
3283
|
+
</FormItem>
|
|
3284
|
+
)}
|
|
3285
|
+
/>
|
|
3286
|
+
|
|
3287
|
+
<Button type="submit">Save Settings</Button>
|
|
3288
|
+
</form>
|
|
3289
|
+
</Form>
|
|
3290
|
+
</CardContent>
|
|
3291
|
+
</Card>
|
|
3292
|
+
</TabsContent>
|
|
3293
|
+
</Tabs>
|
|
3294
|
+
</div>
|
|
3295
|
+
);
|
|
3296
|
+
}
|
|
3297
|
+
```
|
|
3298
|
+
|
|
3299
|
+
```css
|
|
3300
|
+
/* settings-page.module.css */
|
|
3301
|
+
.container {
|
|
3302
|
+
max-width: 56rem;
|
|
3303
|
+
margin-inline: auto;
|
|
3304
|
+
padding: 2rem 1rem;
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
.header {
|
|
3308
|
+
margin-bottom: 2rem;
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
.title {
|
|
3312
|
+
font-size: 2rem;
|
|
3313
|
+
font-weight: 700;
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
.subtitle {
|
|
3317
|
+
margin-top: 0.5rem;
|
|
3318
|
+
color: var(--ac-muted-foreground);
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
.tabs {
|
|
3322
|
+
display: grid;
|
|
3323
|
+
gap: 1rem;
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
.tabContent {
|
|
3327
|
+
margin-top: 1rem;
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
.form {
|
|
3331
|
+
display: grid;
|
|
3332
|
+
gap: 1.5rem;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
.textarea {
|
|
3336
|
+
min-height: 6rem;
|
|
3337
|
+
resize: vertical;
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
.switchItem {
|
|
3341
|
+
display: flex;
|
|
3342
|
+
flex-direction: row;
|
|
3343
|
+
align-items: center;
|
|
3344
|
+
justify-content: space-between;
|
|
3345
|
+
padding: 1rem;
|
|
3346
|
+
border: 1px solid var(--ac-border);
|
|
3347
|
+
border-radius: var(--ac-radius-md);
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
.switchContent {
|
|
3351
|
+
display: grid;
|
|
3352
|
+
gap: 0.25rem;
|
|
3353
|
+
}
|
|
3354
|
+
```
|
|
3355
|
+
|
|
3356
|
+
---
|
|
3357
|
+
|
|
3358
|
+
### Recipe 10: Error Handling (ErrorBoundary + AsyncBoundary + Toast)
|
|
3359
|
+
|
|
3360
|
+
**Comprehensive error handling pattern with boundaries and toast notifications.**
|
|
3361
|
+
|
|
3362
|
+
```tsx
|
|
3363
|
+
import {AlertTriangle, RefreshCw} from "lucide-react";
|
|
3364
|
+
import {Component, Suspense, type ReactNode} from "react";
|
|
3365
|
+
|
|
3366
|
+
import {Alert, AlertDescription, AlertTitle} from "@arolariu/components/alert";
|
|
3367
|
+
import {AsyncBoundary} from "@arolariu/components/async-boundary";
|
|
3368
|
+
import {Button} from "@arolariu/components/button";
|
|
3369
|
+
import {Card, CardContent, CardHeader, CardTitle} from "@arolariu/components/card";
|
|
3370
|
+
import {ErrorBoundary} from "@arolariu/components/error-boundary";
|
|
3371
|
+
import {Skeleton} from "@arolariu/components/skeleton";
|
|
3372
|
+
import {toast} from "@arolariu/components/sonner";
|
|
3373
|
+
import styles from "./error-handling.module.css";
|
|
3374
|
+
|
|
3375
|
+
// Async data fetching component
|
|
3376
|
+
async function fetchUserData(userId: string) {
|
|
3377
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
3378
|
+
|
|
3379
|
+
// Simulate random error
|
|
3380
|
+
if (Math.random() > 0.7) {
|
|
3381
|
+
throw new Error("Failed to fetch user data");
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
return {
|
|
3385
|
+
id: userId,
|
|
3386
|
+
name: "John Doe",
|
|
3387
|
+
email: "john@example.com",
|
|
3388
|
+
};
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
function UserProfile({userId}: {userId: string}) {
|
|
3392
|
+
const user = use(fetchUserData(userId));
|
|
3393
|
+
|
|
3394
|
+
return (
|
|
3395
|
+
<Card>
|
|
3396
|
+
<CardHeader>
|
|
3397
|
+
<CardTitle>User Profile</CardTitle>
|
|
3398
|
+
</CardHeader>
|
|
3399
|
+
<CardContent className={styles.profile}>
|
|
3400
|
+
<div className={styles.profileField}>
|
|
3401
|
+
<span className={styles.profileLabel}>Name:</span>
|
|
3402
|
+
<span>{user.name}</span>
|
|
3403
|
+
</div>
|
|
3404
|
+
<div className={styles.profileField}>
|
|
3405
|
+
<span className={styles.profileLabel}>Email:</span>
|
|
3406
|
+
<span>{user.email}</span>
|
|
3407
|
+
</div>
|
|
3408
|
+
</CardContent>
|
|
3409
|
+
</Card>
|
|
3410
|
+
);
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
// Loading fallback
|
|
3414
|
+
function ProfileSkeleton() {
|
|
3415
|
+
return (
|
|
3416
|
+
<Card>
|
|
3417
|
+
<CardHeader>
|
|
3418
|
+
<Skeleton className={styles.skeletonTitle} />
|
|
3419
|
+
</CardHeader>
|
|
3420
|
+
<CardContent className={styles.skeletonContent}>
|
|
3421
|
+
<Skeleton className={styles.skeletonField} />
|
|
3422
|
+
<Skeleton className={styles.skeletonField} />
|
|
3423
|
+
</CardContent>
|
|
3424
|
+
</Card>
|
|
3425
|
+
);
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
// Error fallback
|
|
3429
|
+
function ProfileError({error, reset}: {error: Error; reset: () => void}) {
|
|
3430
|
+
return (
|
|
3431
|
+
<Alert variant="destructive">
|
|
3432
|
+
<AlertTriangle />
|
|
3433
|
+
<AlertTitle>Error Loading Profile</AlertTitle>
|
|
3434
|
+
<AlertDescription className={styles.errorDescription}>
|
|
3435
|
+
{error.message}
|
|
3436
|
+
<Button
|
|
3437
|
+
variant="outline"
|
|
3438
|
+
size="sm"
|
|
3439
|
+
onClick={reset}
|
|
3440
|
+
className={styles.retryButton}
|
|
3441
|
+
>
|
|
3442
|
+
<RefreshCw />
|
|
3443
|
+
Retry
|
|
3444
|
+
</Button>
|
|
3445
|
+
</AlertDescription>
|
|
3446
|
+
</Alert>
|
|
3447
|
+
);
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
export function ErrorHandlingDemo() {
|
|
3451
|
+
const handleAPIError = async () => {
|
|
3452
|
+
try {
|
|
3453
|
+
// Simulate API call that fails
|
|
3454
|
+
await new Promise((_, reject) =>
|
|
3455
|
+
setTimeout(() => reject(new Error("API request failed")), 1000)
|
|
3456
|
+
);
|
|
3457
|
+
} catch (error) {
|
|
3458
|
+
toast.error("Failed to load data. Please try again.", {
|
|
3459
|
+
action: {
|
|
3460
|
+
label: "Retry",
|
|
3461
|
+
onClick: () => handleAPIError(),
|
|
3462
|
+
},
|
|
3463
|
+
});
|
|
3464
|
+
}
|
|
3465
|
+
};
|
|
3466
|
+
|
|
3467
|
+
const handleValidationError = () => {
|
|
3468
|
+
toast.error("Validation failed: Email is required");
|
|
3469
|
+
};
|
|
3470
|
+
|
|
3471
|
+
const handleNetworkError = () => {
|
|
3472
|
+
toast.error("Network error. Please check your connection.", {
|
|
3473
|
+
duration: 5000,
|
|
3474
|
+
});
|
|
3475
|
+
};
|
|
3476
|
+
|
|
3477
|
+
return (
|
|
3478
|
+
<div className={styles.container}>
|
|
3479
|
+
<div className={styles.header}>
|
|
3480
|
+
<h1 className={styles.title}>Error Handling Patterns</h1>
|
|
3481
|
+
<p className={styles.subtitle}>
|
|
3482
|
+
Comprehensive error handling with boundaries and toast notifications.
|
|
3483
|
+
</p>
|
|
3484
|
+
</div>
|
|
3485
|
+
|
|
3486
|
+
<div className={styles.grid}>
|
|
3487
|
+
{/* Pattern 1: AsyncBoundary (React 19 pattern) */}
|
|
3488
|
+
<div className={styles.section}>
|
|
3489
|
+
<h2 className={styles.sectionTitle}>1. AsyncBoundary (Suspense + ErrorBoundary)</h2>
|
|
3490
|
+
<AsyncBoundary
|
|
3491
|
+
suspenseFallback={<ProfileSkeleton />}
|
|
3492
|
+
errorFallback={({error, reset}) => <ProfileError error={error} reset={reset} />}
|
|
3493
|
+
>
|
|
3494
|
+
<UserProfile userId="123" />
|
|
3495
|
+
</AsyncBoundary>
|
|
3496
|
+
</div>
|
|
3497
|
+
|
|
3498
|
+
{/* Pattern 2: Toast for API errors */}
|
|
3499
|
+
<div className={styles.section}>
|
|
3500
|
+
<h2 className={styles.sectionTitle}>2. Toast Notifications for Errors</h2>
|
|
3501
|
+
<Card>
|
|
3502
|
+
<CardContent className={styles.buttonGroup}>
|
|
3503
|
+
<Button onClick={handleAPIError} variant="destructive">
|
|
3504
|
+
Trigger API Error
|
|
3505
|
+
</Button>
|
|
3506
|
+
<Button onClick={handleValidationError} variant="destructive">
|
|
3507
|
+
Trigger Validation Error
|
|
3508
|
+
</Button>
|
|
3509
|
+
<Button onClick={handleNetworkError} variant="destructive">
|
|
3510
|
+
Trigger Network Error
|
|
3511
|
+
</Button>
|
|
3512
|
+
</CardContent>
|
|
3513
|
+
</Card>
|
|
3514
|
+
</div>
|
|
3515
|
+
|
|
3516
|
+
{/* Pattern 3: Inline error display */}
|
|
3517
|
+
<div className={styles.section}>
|
|
3518
|
+
<h2 className={styles.sectionTitle}>3. Inline Error Display</h2>
|
|
3519
|
+
<Alert variant="destructive">
|
|
3520
|
+
<AlertTriangle />
|
|
3521
|
+
<AlertTitle>Authentication Error</AlertTitle>
|
|
3522
|
+
<AlertDescription>
|
|
3523
|
+
Your session has expired. Please log in again to continue.
|
|
3524
|
+
<Button
|
|
3525
|
+
variant="outline"
|
|
3526
|
+
size="sm"
|
|
3527
|
+
className={styles.loginButton}
|
|
3528
|
+
onClick={() => toast.info("Redirecting to login...")}
|
|
3529
|
+
>
|
|
3530
|
+
Log In
|
|
3531
|
+
</Button>
|
|
3532
|
+
</AlertDescription>
|
|
3533
|
+
</Alert>
|
|
3534
|
+
</div>
|
|
3535
|
+
</div>
|
|
3536
|
+
</div>
|
|
3537
|
+
);
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
// React 19 use() hook polyfill (remove when React 19 is stable)
|
|
3541
|
+
function use<T>(promise: Promise<T>): T {
|
|
3542
|
+
if ((promise as any).status === "fulfilled") {
|
|
3543
|
+
return (promise as any).value;
|
|
3544
|
+
} else if ((promise as any).status === "rejected") {
|
|
3545
|
+
throw (promise as any).reason;
|
|
3546
|
+
} else {
|
|
3547
|
+
throw promise.then(
|
|
3548
|
+
(value) => {
|
|
3549
|
+
(promise as any).status = "fulfilled";
|
|
3550
|
+
(promise as any).value = value;
|
|
3551
|
+
},
|
|
3552
|
+
(reason) => {
|
|
3553
|
+
(promise as any).status = "rejected";
|
|
3554
|
+
(promise as any).reason = reason;
|
|
3555
|
+
}
|
|
3556
|
+
);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
```
|
|
3560
|
+
|
|
3561
|
+
```css
|
|
3562
|
+
/* error-handling.module.css */
|
|
3563
|
+
.container {
|
|
3564
|
+
max-width: 56rem;
|
|
3565
|
+
margin-inline: auto;
|
|
3566
|
+
padding: 2rem 1rem;
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
.header {
|
|
3570
|
+
margin-bottom: 2rem;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
.title {
|
|
3574
|
+
font-size: 2rem;
|
|
3575
|
+
font-weight: 700;
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
.subtitle {
|
|
3579
|
+
margin-top: 0.5rem;
|
|
3580
|
+
color: var(--ac-muted-foreground);
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
.grid {
|
|
3584
|
+
display: grid;
|
|
3585
|
+
gap: 2rem;
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
.section {
|
|
3589
|
+
display: grid;
|
|
3590
|
+
gap: 1rem;
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
.sectionTitle {
|
|
3594
|
+
font-size: 1.25rem;
|
|
3595
|
+
font-weight: 600;
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
.profile {
|
|
3599
|
+
display: grid;
|
|
3600
|
+
gap: 0.75rem;
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
.profileField {
|
|
3604
|
+
display: flex;
|
|
3605
|
+
gap: 0.5rem;
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
.profileLabel {
|
|
3609
|
+
font-weight: 600;
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
.skeletonTitle {
|
|
3613
|
+
height: 1.5rem;
|
|
3614
|
+
width: 8rem;
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
.skeletonContent {
|
|
3618
|
+
display: grid;
|
|
3619
|
+
gap: 0.75rem;
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
.skeletonField {
|
|
3623
|
+
height: 1rem;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
.errorDescription {
|
|
3627
|
+
display: flex;
|
|
3628
|
+
align-items: center;
|
|
3629
|
+
gap: 1rem;
|
|
3630
|
+
margin-top: 0.5rem;
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
.retryButton,
|
|
3634
|
+
.loginButton {
|
|
3635
|
+
margin-left: auto;
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
.buttonGroup {
|
|
3639
|
+
display: grid;
|
|
3640
|
+
gap: 0.5rem;
|
|
3641
|
+
padding: 1.5rem;
|
|
3642
|
+
}
|
|
3643
|
+
```
|
|
3644
|
+
|
|
3645
|
+
---
|
|
3646
|
+
|
|
3647
|
+
## 🎓 Key Takeaways from Pattern Recipes
|
|
3648
|
+
|
|
3649
|
+
### ✅ Best Practices Demonstrated
|
|
3650
|
+
|
|
3651
|
+
1. **Form Validation**: Always use `zod` + `react-hook-form` for type-safe validation
|
|
3652
|
+
2. **Error Handling**: Combine boundaries, inline errors, and toast notifications
|
|
3653
|
+
3. **Loading States**: Use `Skeleton` components and `AsyncBoundary` for async data
|
|
3654
|
+
4. **Accessibility**: Keyboard navigation, ARIA attributes, and semantic HTML
|
|
3655
|
+
5. **Responsive Design**: CSS Modules with container queries and media queries
|
|
3656
|
+
6. **Type Safety**: Leverage namespace types (`Component.Props`, `Component.State`)
|
|
3657
|
+
7. **Composition**: Use `render` prop for element composition (Base UI pattern)
|
|
3658
|
+
8. **Toast Notifications**: Use `toast.promise()` for async operations
|
|
3659
|
+
9. **State Management**: Keep form state close to components, lift when needed
|
|
3660
|
+
10. **Progressive Enhancement**: Start with semantic HTML, enhance with JavaScript
|
|
3661
|
+
|
|
3662
|
+
### 📚 Additional Resources
|
|
3663
|
+
|
|
3664
|
+
- [Base UI Documentation](https://base-ui.com/react/components)
|
|
3665
|
+
- [React Hook Form](https://react-hook-form.com/)
|
|
3666
|
+
- [Zod Validation](https://zod.dev/)
|
|
3667
|
+
- [TanStack Table](https://tanstack.com/table)
|
|
3668
|
+
- [Date-fns](https://date-fns.org/)
|
|
3669
|
+
|
|
3670
|
+
Ready to build something amazing? **[🚀 Start with our Quick Start Guide](./README.md#-quick-start)**
|