torch-glare 2.4.1 → 2.4.2
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/apps/lib/components/Button.tsx +1 -1
- package/apps/lib/components/Card.tsx +47 -20
- package/apps/lib/components/ColorPicker.tsx +441 -0
- package/apps/lib/components/ConclusionHeader.tsx +148 -0
- package/apps/lib/components/DatePicker.tsx +2 -0
- package/apps/lib/components/Drawer.tsx +66 -24
- package/apps/lib/components/FormBuilder/DisplayField.tsx +40 -0
- package/apps/lib/components/FormBuilder/context.ts +71 -0
- package/apps/lib/components/FormBuilder/fields/ChoiceFields.tsx +65 -0
- package/apps/lib/components/FormBuilder/fields/ColorField.tsx +76 -0
- package/apps/lib/components/FormBuilder/fields/CustomField.tsx +16 -0
- package/apps/lib/components/FormBuilder/fields/DateField.tsx +34 -0
- package/apps/lib/components/FormBuilder/fields/FieldArray.tsx +79 -0
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +174 -0
- package/apps/lib/components/FormBuilder/fields/FileField.tsx +40 -0
- package/apps/lib/components/FormBuilder/fields/OptionListFields.tsx +139 -0
- package/apps/lib/components/FormBuilder/fields/OtpField.tsx +32 -0
- package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +82 -0
- package/apps/lib/components/FormBuilder/fields/RichTextEditorField.tsx +37 -0
- package/apps/lib/components/FormBuilder/fields/SelectField.tsx +101 -0
- package/apps/lib/components/FormBuilder/fields/SignatureField.tsx +175 -0
- package/apps/lib/components/FormBuilder/fields/SliderField.tsx +72 -0
- package/apps/lib/components/FormBuilder/fields/SwitchBoxField.tsx +42 -0
- package/apps/lib/components/FormBuilder/fields/TableField.tsx +312 -0
- package/apps/lib/components/FormBuilder/fields/TextField.tsx +222 -0
- package/apps/lib/components/FormBuilder/fields/TreeSelectField.tsx +49 -0
- package/apps/lib/components/FormBuilder/fields/countries.ts +303 -0
- package/apps/lib/components/FormBuilder/fields/index.ts +25 -0
- package/apps/lib/components/FormBuilder/form-builder.tsx +292 -0
- package/apps/lib/components/FormBuilder/header.tsx +105 -0
- package/apps/lib/components/FormBuilder/index.ts +37 -0
- package/apps/lib/components/FormBuilder/numberFormat.ts +16 -0
- package/apps/lib/components/FormBuilder/stepper.tsx +217 -0
- package/apps/lib/components/FormBuilder/submit.tsx +41 -0
- package/apps/lib/components/FormBuilder/types.ts +264 -0
- package/apps/lib/components/FormBuilder/viewFormat.tsx +137 -0
- package/apps/lib/components/FormRenderer/FormDrawer.tsx +121 -0
- package/apps/lib/components/FormRenderer/form-renderer.tsx +113 -0
- package/apps/lib/components/FormRenderer/index.ts +9 -0
- package/apps/lib/components/FormRenderer/types.ts +79 -0
- package/apps/lib/components/FormSummary.tsx +282 -0
- package/apps/lib/components/ImageAttachment.tsx +36 -61
- package/apps/lib/components/Label.tsx +49 -42
- package/apps/lib/components/RadioCard.tsx +2 -0
- package/apps/lib/components/SearchableSelect.tsx +9 -3
- package/apps/lib/components/SectionBlock.tsx +16 -8
- package/apps/lib/components/Select.tsx +41 -120
- package/apps/lib/components/TextEditor/RichTextField.tsx +46 -0
- package/apps/lib/components/{TextEditor.tsx → TextEditor/TextEditor.tsx} +63 -9
- package/apps/lib/components/TextEditor/TextEditorToolbar.tsx +429 -0
- package/apps/lib/components/TextEditor/editor-tools/AlignmentTune.ts +70 -0
- package/apps/lib/components/TextEditor/editor-tools/ColorInlineTool.ts +50 -0
- package/apps/lib/components/TextEditor/editor-tools/StrikethroughInlineTool.ts +48 -0
- package/apps/lib/components/TextEditor/editor-tools/inlineFormat.ts +98 -0
- package/apps/lib/{types → components/TextEditor}/editorjs.d.ts +19 -0
- package/apps/lib/components/TextEditor/index.ts +7 -0
- package/apps/lib/components/Textarea.tsx +1 -1
- package/apps/lib/registry.json +51 -58
- package/apps/lib/tsconfig.tsbuildinfo +1 -0
- package/apps/lib/utils/color.ts +175 -0
- package/docs/components/card.md +4 -2
- package/docs/components/chart-block-tool.md +5 -4
- package/docs/components/color-picker.md +101 -0
- package/docs/components/conclusion-header.md +80 -0
- package/docs/components/drawer.md +153 -102
- package/docs/components/form-builder.md +270 -0
- package/docs/components/form-renderer.md +228 -0
- package/docs/components/form-summary.md +123 -0
- package/docs/components/image-attachment.md +10 -4
- package/docs/components/table-dnd-wrapper.md +5 -3
- package/docs/components/text-editor.md +18 -0
- package/docs/how-to/form-and-list-recipes.md +26 -19
- package/docs/how-to/forms-with-form-builder.md +408 -0
- package/docs/how-to/guides.md +153 -179
- package/docs/tutorials/building-first-form.md +150 -159
- package/package.json +1 -1
- /package/apps/lib/components/{ChartBlockTool.ts → TextEditor/ChartBlockTool.ts} +0 -0
- /package/apps/lib/components/{TableDnDWrapper.ts → TextEditor/TableDnDWrapper.ts} +0 -0
- /package/apps/lib/{utils → components/TextEditor}/markdownParser.ts +0 -0
package/docs/how-to/guides.md
CHANGED
|
@@ -13,6 +13,12 @@ related:
|
|
|
13
13
|
|
|
14
14
|
Practical, step-by-step guides for accomplishing common tasks with TORCH Glare.
|
|
15
15
|
|
|
16
|
+
> **Building a form? Use [`FormBuilder`](../components/form-builder.md).** The Form Validation
|
|
17
|
+
> section below shows the low-level, hand-wired resolver plumbing for understanding — but in an
|
|
18
|
+
> app you author fields as `FormBuilder` JSX children and pass a react-hook-form resolver, rather
|
|
19
|
+
> than tracking field state yourself. See the [Forms with FormBuilder](./forms-with-form-builder.md)
|
|
20
|
+
> guide.
|
|
21
|
+
|
|
16
22
|
## Table of Contents
|
|
17
23
|
|
|
18
24
|
1. [Custom Themes](#custom-themes)
|
|
@@ -37,18 +43,18 @@ export default {
|
|
|
37
43
|
theme: {
|
|
38
44
|
extend: {
|
|
39
45
|
colors: {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
"brand-primary": "#4200FF",
|
|
47
|
+
"brand-secondary": "#5215CC",
|
|
48
|
+
"brand-accent": "#CFBEFF",
|
|
49
|
+
"brand-dark": "#0D0F4E",
|
|
50
|
+
"brand-light": "#F9F9F9",
|
|
45
51
|
},
|
|
46
52
|
},
|
|
47
53
|
},
|
|
48
54
|
plugins: [
|
|
49
|
-
require(
|
|
50
|
-
require(
|
|
51
|
-
require(
|
|
55
|
+
require("mapping-color-system"),
|
|
56
|
+
require("glare-torch-mode"),
|
|
57
|
+
require("glare-typography"),
|
|
52
58
|
],
|
|
53
59
|
} satisfies Config;
|
|
54
60
|
```
|
|
@@ -63,15 +69,15 @@ export default {
|
|
|
63
69
|
|
|
64
70
|
@layer base {
|
|
65
71
|
[data-theme="light"] {
|
|
66
|
-
--background-presentation-action-primary: #
|
|
67
|
-
--content-presentation-action-light-primary: #
|
|
68
|
-
--border-presentation-action-primary: #
|
|
72
|
+
--background-presentation-action-primary: #4200ff;
|
|
73
|
+
--content-presentation-action-light-primary: #ffffff;
|
|
74
|
+
--border-presentation-action-primary: #5215cc;
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
[data-theme="dark"] {
|
|
72
|
-
--background-presentation-action-primary: #
|
|
73
|
-
--content-presentation-action-light-primary: #
|
|
74
|
-
--border-presentation-action-primary: #
|
|
78
|
+
--background-presentation-action-primary: #5215cc;
|
|
79
|
+
--content-presentation-action-light-primary: #f9f9f9;
|
|
80
|
+
--border-presentation-action-primary: #cfbeff;
|
|
75
81
|
}
|
|
76
82
|
}
|
|
77
83
|
```
|
|
@@ -88,9 +94,9 @@ export function BrandButton({ children, ...props }: any) {
|
|
|
88
94
|
<Button
|
|
89
95
|
{...props}
|
|
90
96
|
className={cn(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
props.className
|
|
97
|
+
"bg-brand-primary hover:bg-brand-secondary",
|
|
98
|
+
"text-white border-brand-accent",
|
|
99
|
+
props.className,
|
|
94
100
|
)}
|
|
95
101
|
>
|
|
96
102
|
{children}
|
|
@@ -104,21 +110,21 @@ export function BrandButton({ children, ...props }: any) {
|
|
|
104
110
|
```tsx
|
|
105
111
|
// hooks/useCustomTheme.ts
|
|
106
112
|
import { useTheme } from "@/providers/ThemeProvider";
|
|
107
|
-
import { useEffect } from
|
|
113
|
+
import { useEffect } from "react";
|
|
108
114
|
|
|
109
115
|
export function useCustomTheme() {
|
|
110
116
|
const { theme, updateTheme } = useTheme();
|
|
111
117
|
|
|
112
118
|
useEffect(() => {
|
|
113
119
|
// Apply custom theme classes
|
|
114
|
-
if (theme ===
|
|
115
|
-
document.documentElement.classList.add(
|
|
120
|
+
if (theme === "light") {
|
|
121
|
+
document.documentElement.classList.add("brand-light-theme");
|
|
116
122
|
} else {
|
|
117
|
-
document.documentElement.classList.add(
|
|
123
|
+
document.documentElement.classList.add("brand-dark-theme");
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
return () => {
|
|
121
|
-
document.documentElement.classList.remove(
|
|
127
|
+
document.documentElement.classList.remove("brand-light-theme", "brand-dark-theme");
|
|
122
128
|
};
|
|
123
129
|
}, [theme]);
|
|
124
130
|
|
|
@@ -135,9 +141,7 @@ import { ThemeProvider } from "@/providers/ThemeProvider";
|
|
|
135
141
|
export function BrandThemeProvider({ children }: { children: React.ReactNode }) {
|
|
136
142
|
return (
|
|
137
143
|
<ThemeProvider defaultTheme="light" defaultThemeMode="TORCH">
|
|
138
|
-
<div className="brand-theme-wrapper">
|
|
139
|
-
{children}
|
|
140
|
-
</div>
|
|
144
|
+
<div className="brand-theme-wrapper">{children}</div>
|
|
141
145
|
</ThemeProvider>
|
|
142
146
|
);
|
|
143
147
|
}
|
|
@@ -147,20 +151,20 @@ export function BrandThemeProvider({ children }: { children: React.ReactNode })
|
|
|
147
151
|
|
|
148
152
|
```tsx
|
|
149
153
|
// __tests__/BrandButton.test.tsx
|
|
150
|
-
import { render } from
|
|
154
|
+
import { render } from "@testing-library/react";
|
|
151
155
|
import { ThemeProvider } from "@/providers/ThemeProvider";
|
|
152
|
-
import { BrandButton } from
|
|
156
|
+
import { BrandButton } from "../components/BrandButton";
|
|
153
157
|
|
|
154
|
-
describe(
|
|
155
|
-
it(
|
|
158
|
+
describe("BrandButton", () => {
|
|
159
|
+
it("applies custom brand colors", () => {
|
|
156
160
|
const { container } = render(
|
|
157
161
|
<ThemeProvider defaultTheme="light">
|
|
158
162
|
<BrandButton>Click me</BrandButton>
|
|
159
|
-
</ThemeProvider
|
|
163
|
+
</ThemeProvider>,
|
|
160
164
|
);
|
|
161
165
|
|
|
162
|
-
const button = container.querySelector(
|
|
163
|
-
expect(button).toHaveClass(
|
|
166
|
+
const button = container.querySelector("button");
|
|
167
|
+
expect(button).toHaveClass("bg-brand-primary");
|
|
164
168
|
});
|
|
165
169
|
});
|
|
166
170
|
```
|
|
@@ -175,7 +179,7 @@ Implement comprehensive form validation with error handling.
|
|
|
175
179
|
|
|
176
180
|
```tsx
|
|
177
181
|
// hooks/useFormValidation.ts
|
|
178
|
-
import { useState, useCallback } from
|
|
182
|
+
import { useState, useCallback } from "react";
|
|
179
183
|
|
|
180
184
|
type ValidationRules<T> = {
|
|
181
185
|
[K in keyof T]?: Array<(value: T[K]) => string | undefined>;
|
|
@@ -183,7 +187,7 @@ type ValidationRules<T> = {
|
|
|
183
187
|
|
|
184
188
|
export function useFormValidation<T extends Record<string, any>>(
|
|
185
189
|
initialValues: T,
|
|
186
|
-
rules: ValidationRules<T
|
|
190
|
+
rules: ValidationRules<T>,
|
|
187
191
|
) {
|
|
188
192
|
const [values, setValues] = useState<T>(initialValues);
|
|
189
193
|
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
|
@@ -200,7 +204,7 @@ export function useFormValidation<T extends Record<string, any>>(
|
|
|
200
204
|
}
|
|
201
205
|
return undefined;
|
|
202
206
|
},
|
|
203
|
-
[rules]
|
|
207
|
+
[rules],
|
|
204
208
|
);
|
|
205
209
|
|
|
206
210
|
const handleChange = useCallback(
|
|
@@ -213,7 +217,7 @@ export function useFormValidation<T extends Record<string, any>>(
|
|
|
213
217
|
setErrors((prev) => ({ ...prev, [field]: error }));
|
|
214
218
|
}
|
|
215
219
|
},
|
|
216
|
-
[touched, validateField]
|
|
220
|
+
[touched, validateField],
|
|
217
221
|
);
|
|
218
222
|
|
|
219
223
|
const handleBlur = useCallback(
|
|
@@ -222,7 +226,7 @@ export function useFormValidation<T extends Record<string, any>>(
|
|
|
222
226
|
const error = validateField(field, values[field]);
|
|
223
227
|
setErrors((prev) => ({ ...prev, [field]: error }));
|
|
224
228
|
},
|
|
225
|
-
[values, validateField]
|
|
229
|
+
[values, validateField],
|
|
226
230
|
);
|
|
227
231
|
|
|
228
232
|
const validateForm = useCallback((): boolean => {
|
|
@@ -238,9 +242,7 @@ export function useFormValidation<T extends Record<string, any>>(
|
|
|
238
242
|
});
|
|
239
243
|
|
|
240
244
|
setErrors(newErrors);
|
|
241
|
-
setTouched(
|
|
242
|
-
Object.keys(rules).reduce((acc, field) => ({ ...acc, [field]: true }), {})
|
|
243
|
-
);
|
|
245
|
+
setTouched(Object.keys(rules).reduce((acc, field) => ({ ...acc, [field]: true }), {}));
|
|
244
246
|
|
|
245
247
|
return isValid;
|
|
246
248
|
}, [rules, values, validateField]);
|
|
@@ -267,16 +269,16 @@ export function useFormValidation<T extends Record<string, any>>(
|
|
|
267
269
|
|
|
268
270
|
```tsx
|
|
269
271
|
// utils/validationRules.ts
|
|
270
|
-
export const required = (message =
|
|
272
|
+
export const required = (message = "This field is required") => {
|
|
271
273
|
return (value: any) => {
|
|
272
|
-
if (!value || (typeof value ===
|
|
274
|
+
if (!value || (typeof value === "string" && !value.trim())) {
|
|
273
275
|
return message;
|
|
274
276
|
}
|
|
275
277
|
return undefined;
|
|
276
278
|
};
|
|
277
279
|
};
|
|
278
280
|
|
|
279
|
-
export const email = (message =
|
|
281
|
+
export const email = (message = "Invalid email address") => {
|
|
280
282
|
return (value: string) => {
|
|
281
283
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
282
284
|
if (value && !emailRegex.test(value)) {
|
|
@@ -327,54 +329,47 @@ export const matchField = (otherField: string, message?: string) => {
|
|
|
327
329
|
|
|
328
330
|
```tsx
|
|
329
331
|
// components/RegistrationForm.tsx
|
|
330
|
-
|
|
332
|
+
"use client";
|
|
331
333
|
|
|
332
|
-
import { useFormValidation } from
|
|
333
|
-
import { required, email, minLength } from
|
|
334
|
+
import { useFormValidation } from "../hooks/useFormValidation";
|
|
335
|
+
import { required, email, minLength } from "../utils/validationRules";
|
|
334
336
|
import { Button } from "@/components/Button";
|
|
335
337
|
import { LabelField } from "@/components/LabelField";
|
|
336
338
|
import { toast } from "@/components/Toast";
|
|
337
339
|
|
|
338
340
|
export default function RegistrationForm() {
|
|
339
|
-
const {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
return 'Passwords must match';
|
|
361
|
-
}
|
|
362
|
-
return undefined;
|
|
363
|
-
},
|
|
364
|
-
],
|
|
365
|
-
}
|
|
366
|
-
);
|
|
341
|
+
const { values, errors, touched, handleChange, handleBlur, validateForm, reset } =
|
|
342
|
+
useFormValidation(
|
|
343
|
+
{
|
|
344
|
+
email: "",
|
|
345
|
+
password: "",
|
|
346
|
+
confirmPassword: "",
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
email: [required(), email()],
|
|
350
|
+
password: [required(), minLength(8, "Password must be at least 8 characters")],
|
|
351
|
+
confirmPassword: [
|
|
352
|
+
required(),
|
|
353
|
+
(value) => {
|
|
354
|
+
if (value !== values.password) {
|
|
355
|
+
return "Passwords must match";
|
|
356
|
+
}
|
|
357
|
+
return undefined;
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
);
|
|
367
362
|
|
|
368
363
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
369
364
|
e.preventDefault();
|
|
370
365
|
|
|
371
366
|
if (!validateForm()) {
|
|
372
|
-
toast.error(
|
|
367
|
+
toast.error("Please fix validation errors");
|
|
373
368
|
return;
|
|
374
369
|
}
|
|
375
370
|
|
|
376
371
|
// Submit form
|
|
377
|
-
toast.success(
|
|
372
|
+
toast.success("Registration successful!");
|
|
378
373
|
reset();
|
|
379
374
|
};
|
|
380
375
|
|
|
@@ -386,8 +381,8 @@ export default function RegistrationForm() {
|
|
|
386
381
|
requiredLabel="*"
|
|
387
382
|
type="email"
|
|
388
383
|
value={values.email}
|
|
389
|
-
onChange={handleChange(
|
|
390
|
-
onBlur={handleBlur(
|
|
384
|
+
onChange={handleChange("email")}
|
|
385
|
+
onBlur={handleBlur("email")}
|
|
391
386
|
errorMessage={touched.email ? errors.email : undefined}
|
|
392
387
|
/>
|
|
393
388
|
|
|
@@ -397,8 +392,8 @@ export default function RegistrationForm() {
|
|
|
397
392
|
requiredLabel="*"
|
|
398
393
|
type="password"
|
|
399
394
|
value={values.password}
|
|
400
|
-
onChange={handleChange(
|
|
401
|
-
onBlur={handleBlur(
|
|
395
|
+
onChange={handleChange("password")}
|
|
396
|
+
onBlur={handleBlur("password")}
|
|
402
397
|
errorMessage={touched.password ? errors.password : undefined}
|
|
403
398
|
/>
|
|
404
399
|
|
|
@@ -408,8 +403,8 @@ export default function RegistrationForm() {
|
|
|
408
403
|
requiredLabel="*"
|
|
409
404
|
type="password"
|
|
410
405
|
value={values.confirmPassword}
|
|
411
|
-
onChange={handleChange(
|
|
412
|
-
onBlur={handleBlur(
|
|
406
|
+
onChange={handleChange("confirmPassword")}
|
|
407
|
+
onBlur={handleBlur("confirmPassword")}
|
|
413
408
|
errorMessage={touched.confirmPassword ? errors.confirmPassword : undefined}
|
|
414
409
|
/>
|
|
415
410
|
|
|
@@ -425,7 +420,7 @@ export default function RegistrationForm() {
|
|
|
425
420
|
|
|
426
421
|
```tsx
|
|
427
422
|
// Example: Check if email is available
|
|
428
|
-
export const emailAvailable = (message =
|
|
423
|
+
export const emailAvailable = (message = "Email already taken") => {
|
|
429
424
|
return async (value: string) => {
|
|
430
425
|
if (!value) return undefined;
|
|
431
426
|
|
|
@@ -437,7 +432,7 @@ export const emailAvailable = (message = 'Email already taken') => {
|
|
|
437
432
|
return message;
|
|
438
433
|
}
|
|
439
434
|
} catch (error) {
|
|
440
|
-
console.error(
|
|
435
|
+
console.error("Email validation error:", error);
|
|
441
436
|
}
|
|
442
437
|
|
|
443
438
|
return undefined;
|
|
@@ -455,11 +450,11 @@ Implement comprehensive dark mode support.
|
|
|
455
450
|
|
|
456
451
|
```tsx
|
|
457
452
|
// components/DarkModeToggle.tsx
|
|
458
|
-
|
|
453
|
+
"use client";
|
|
459
454
|
|
|
460
455
|
import { Button } from "@/components/Button";
|
|
461
456
|
import { useTheme } from "@/providers/ThemeProvider";
|
|
462
|
-
import { useEffect, useState } from
|
|
457
|
+
import { useEffect, useState } from "react";
|
|
463
458
|
|
|
464
459
|
export default function DarkModeToggle() {
|
|
465
460
|
const { theme, updateTheme } = useTheme();
|
|
@@ -483,10 +478,10 @@ export default function DarkModeToggle() {
|
|
|
483
478
|
theme={theme as any}
|
|
484
479
|
variant="PrimeContStyle"
|
|
485
480
|
buttonType="icon"
|
|
486
|
-
onClick={() => updateTheme(theme ===
|
|
487
|
-
aria-label={`Switch to ${theme ===
|
|
481
|
+
onClick={() => updateTheme(theme === "light" ? "dark" : "light")}
|
|
482
|
+
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
|
|
488
483
|
>
|
|
489
|
-
{theme ===
|
|
484
|
+
{theme === "light" ? (
|
|
490
485
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
491
486
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
|
492
487
|
</svg>
|
|
@@ -508,7 +503,7 @@ export default function DarkModeToggle() {
|
|
|
508
503
|
|
|
509
504
|
```tsx
|
|
510
505
|
// hooks/useSystemTheme.ts
|
|
511
|
-
import { useEffect } from
|
|
506
|
+
import { useEffect } from "react";
|
|
512
507
|
import { useTheme } from "@/providers/ThemeProvider";
|
|
513
508
|
|
|
514
509
|
export function useSystemTheme() {
|
|
@@ -516,21 +511,21 @@ export function useSystemTheme() {
|
|
|
516
511
|
|
|
517
512
|
useEffect(() => {
|
|
518
513
|
// Only sync with system if theme is 'default'
|
|
519
|
-
if (theme !==
|
|
514
|
+
if (theme !== "default") return;
|
|
520
515
|
|
|
521
|
-
const mediaQuery = window.matchMedia(
|
|
516
|
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
522
517
|
|
|
523
518
|
const handleChange = (e: MediaQueryListEvent) => {
|
|
524
|
-
updateTheme(e.matches ?
|
|
519
|
+
updateTheme(e.matches ? "dark" : "light");
|
|
525
520
|
};
|
|
526
521
|
|
|
527
522
|
// Set initial value
|
|
528
|
-
updateTheme(mediaQuery.matches ?
|
|
523
|
+
updateTheme(mediaQuery.matches ? "dark" : "light");
|
|
529
524
|
|
|
530
525
|
// Listen for changes
|
|
531
|
-
mediaQuery.addEventListener(
|
|
526
|
+
mediaQuery.addEventListener("change", handleChange);
|
|
532
527
|
|
|
533
|
-
return () => mediaQuery.removeEventListener(
|
|
528
|
+
return () => mediaQuery.removeEventListener("change", handleChange);
|
|
534
529
|
}, [theme, updateTheme]);
|
|
535
530
|
}
|
|
536
531
|
```
|
|
@@ -540,7 +535,7 @@ export function useSystemTheme() {
|
|
|
540
535
|
```tsx
|
|
541
536
|
// components/ThemedImage.tsx
|
|
542
537
|
import { useTheme } from "@/providers/ThemeProvider";
|
|
543
|
-
import Image from
|
|
538
|
+
import Image from "next/image";
|
|
544
539
|
|
|
545
540
|
interface ThemedImageProps {
|
|
546
541
|
lightSrc: string;
|
|
@@ -561,7 +556,7 @@ export function ThemedImage({
|
|
|
561
556
|
|
|
562
557
|
return (
|
|
563
558
|
<Image
|
|
564
|
-
src={theme ===
|
|
559
|
+
src={theme === "dark" ? darkSrc : lightSrc}
|
|
565
560
|
alt={alt}
|
|
566
561
|
width={width}
|
|
567
562
|
height={height}
|
|
@@ -575,33 +570,33 @@ export function ThemedImage({
|
|
|
575
570
|
|
|
576
571
|
```tsx
|
|
577
572
|
// __tests__/DarkMode.test.tsx
|
|
578
|
-
import { render } from
|
|
573
|
+
import { render } from "@testing-library/react";
|
|
579
574
|
import { ThemeProvider } from "@/providers/ThemeProvider";
|
|
580
|
-
import userEvent from
|
|
581
|
-
import DarkModeToggle from
|
|
575
|
+
import userEvent from "@testing-library/user-event";
|
|
576
|
+
import DarkModeToggle from "../components/DarkModeToggle";
|
|
582
577
|
|
|
583
|
-
describe(
|
|
584
|
-
it(
|
|
578
|
+
describe("Dark Mode", () => {
|
|
579
|
+
it("switches between light and dark themes", async () => {
|
|
585
580
|
const user = userEvent.setup();
|
|
586
581
|
|
|
587
582
|
const { getByRole } = render(
|
|
588
583
|
<ThemeProvider defaultTheme="light">
|
|
589
584
|
<DarkModeToggle />
|
|
590
|
-
</ThemeProvider
|
|
585
|
+
</ThemeProvider>,
|
|
591
586
|
);
|
|
592
587
|
|
|
593
|
-
const toggle = getByRole(
|
|
588
|
+
const toggle = getByRole("button");
|
|
594
589
|
|
|
595
590
|
// Initial theme is light
|
|
596
|
-
expect(document.documentElement.getAttribute(
|
|
591
|
+
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
|
597
592
|
|
|
598
593
|
// Click to switch to dark
|
|
599
594
|
await user.click(toggle);
|
|
600
|
-
expect(document.documentElement.getAttribute(
|
|
595
|
+
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
|
601
596
|
|
|
602
597
|
// Click to switch back to light
|
|
603
598
|
await user.click(toggle);
|
|
604
|
-
expect(document.documentElement.getAttribute(
|
|
599
|
+
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
|
605
600
|
});
|
|
606
601
|
});
|
|
607
602
|
```
|
|
@@ -616,9 +611,9 @@ Ensure your application is accessible to all users.
|
|
|
616
611
|
|
|
617
612
|
```tsx
|
|
618
613
|
// components/AccessibleMenu.tsx
|
|
619
|
-
|
|
614
|
+
"use client";
|
|
620
615
|
|
|
621
|
-
import { useState, useRef, useEffect } from
|
|
616
|
+
import { useState, useRef, useEffect } from "react";
|
|
622
617
|
import { Button } from "@/components/Button";
|
|
623
618
|
|
|
624
619
|
export default function AccessibleMenu() {
|
|
@@ -626,7 +621,7 @@ export default function AccessibleMenu() {
|
|
|
626
621
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
627
622
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
628
623
|
|
|
629
|
-
const menuItems = [
|
|
624
|
+
const menuItems = ["Profile", "Settings", "Logout"];
|
|
630
625
|
|
|
631
626
|
useEffect(() => {
|
|
632
627
|
if (isOpen) {
|
|
@@ -637,22 +632,22 @@ export default function AccessibleMenu() {
|
|
|
637
632
|
|
|
638
633
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
639
634
|
switch (e.key) {
|
|
640
|
-
case
|
|
635
|
+
case "ArrowDown":
|
|
641
636
|
e.preventDefault();
|
|
642
637
|
setFocusedIndex((prev) => (prev + 1) % menuItems.length);
|
|
643
638
|
break;
|
|
644
|
-
case
|
|
639
|
+
case "ArrowUp":
|
|
645
640
|
e.preventDefault();
|
|
646
641
|
setFocusedIndex((prev) => (prev - 1 + menuItems.length) % menuItems.length);
|
|
647
642
|
break;
|
|
648
|
-
case
|
|
643
|
+
case "Escape":
|
|
649
644
|
setIsOpen(false);
|
|
650
645
|
break;
|
|
651
|
-
case
|
|
646
|
+
case "Home":
|
|
652
647
|
e.preventDefault();
|
|
653
648
|
setFocusedIndex(0);
|
|
654
649
|
break;
|
|
655
|
-
case
|
|
650
|
+
case "End":
|
|
656
651
|
e.preventDefault();
|
|
657
652
|
setFocusedIndex(menuItems.length - 1);
|
|
658
653
|
break;
|
|
@@ -717,17 +712,14 @@ export default function AccessibleMenu() {
|
|
|
717
712
|
// components/AccessibleForm.tsx
|
|
718
713
|
import { Button } from "@/components/Button";
|
|
719
714
|
import { LabelField } from "@/components/LabelField";
|
|
720
|
-
import { useState } from
|
|
715
|
+
import { useState } from "react";
|
|
721
716
|
|
|
722
717
|
export default function AccessibleForm() {
|
|
723
|
-
const [email, setEmail] = useState(
|
|
724
|
-
const [error, setError] = useState(
|
|
718
|
+
const [email, setEmail] = useState("");
|
|
719
|
+
const [error, setError] = useState("");
|
|
725
720
|
|
|
726
721
|
return (
|
|
727
|
-
<form
|
|
728
|
-
aria-label="Contact form"
|
|
729
|
-
onSubmit={(e) => e.preventDefault()}
|
|
730
|
-
>
|
|
722
|
+
<form aria-label="Contact form" onSubmit={(e) => e.preventDefault()}>
|
|
731
723
|
<LabelField
|
|
732
724
|
theme="light"
|
|
733
725
|
label="Email"
|
|
@@ -736,7 +728,7 @@ export default function AccessibleForm() {
|
|
|
736
728
|
value={email}
|
|
737
729
|
onChange={(e) => setEmail(e.target.value)}
|
|
738
730
|
aria-invalid={!!error}
|
|
739
|
-
aria-describedby={error ?
|
|
731
|
+
aria-describedby={error ? "email-error" : undefined}
|
|
740
732
|
/>
|
|
741
733
|
|
|
742
734
|
{error && (
|
|
@@ -749,12 +741,7 @@ export default function AccessibleForm() {
|
|
|
749
741
|
</p>
|
|
750
742
|
)}
|
|
751
743
|
|
|
752
|
-
<Button
|
|
753
|
-
theme="light"
|
|
754
|
-
variant="PrimeStyle"
|
|
755
|
-
type="submit"
|
|
756
|
-
aria-label="Submit contact form"
|
|
757
|
-
>
|
|
744
|
+
<Button theme="light" variant="PrimeStyle" type="submit" aria-label="Submit contact form">
|
|
758
745
|
Submit
|
|
759
746
|
</Button>
|
|
760
747
|
</form>
|
|
@@ -766,7 +753,7 @@ export default function AccessibleForm() {
|
|
|
766
753
|
|
|
767
754
|
```tsx
|
|
768
755
|
// hooks/useFocusTrap.ts
|
|
769
|
-
import { useEffect, useRef } from
|
|
756
|
+
import { useEffect, useRef } from "react";
|
|
770
757
|
|
|
771
758
|
export function useFocusTrap<T extends HTMLElement>(isActive: boolean) {
|
|
772
759
|
const ref = useRef<T>(null);
|
|
@@ -778,14 +765,14 @@ export function useFocusTrap<T extends HTMLElement>(isActive: boolean) {
|
|
|
778
765
|
if (!element) return;
|
|
779
766
|
|
|
780
767
|
const focusableElements = element.querySelectorAll(
|
|
781
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
768
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
782
769
|
);
|
|
783
770
|
|
|
784
771
|
const firstElement = focusableElements[0] as HTMLElement;
|
|
785
772
|
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
|
786
773
|
|
|
787
774
|
const handleTabKey = (e: KeyboardEvent) => {
|
|
788
|
-
if (e.key !==
|
|
775
|
+
if (e.key !== "Tab") return;
|
|
789
776
|
|
|
790
777
|
if (e.shiftKey && document.activeElement === firstElement) {
|
|
791
778
|
e.preventDefault();
|
|
@@ -796,11 +783,11 @@ export function useFocusTrap<T extends HTMLElement>(isActive: boolean) {
|
|
|
796
783
|
}
|
|
797
784
|
};
|
|
798
785
|
|
|
799
|
-
element.addEventListener(
|
|
786
|
+
element.addEventListener("keydown", handleTabKey);
|
|
800
787
|
firstElement?.focus();
|
|
801
788
|
|
|
802
789
|
return () => {
|
|
803
|
-
element.removeEventListener(
|
|
790
|
+
element.removeEventListener("keydown", handleTabKey);
|
|
804
791
|
};
|
|
805
792
|
}, [isActive]);
|
|
806
793
|
|
|
@@ -820,11 +807,7 @@ interface AccessibleButtonProps {
|
|
|
820
807
|
children: React.ReactNode;
|
|
821
808
|
}
|
|
822
809
|
|
|
823
|
-
export function AccessibleButton({
|
|
824
|
-
onClick,
|
|
825
|
-
isLoading,
|
|
826
|
-
children,
|
|
827
|
-
}: AccessibleButtonProps) {
|
|
810
|
+
export function AccessibleButton({ onClick, isLoading, children }: AccessibleButtonProps) {
|
|
828
811
|
return (
|
|
829
812
|
<Button
|
|
830
813
|
theme="light"
|
|
@@ -832,7 +815,7 @@ export function AccessibleButton({
|
|
|
832
815
|
onClick={onClick}
|
|
833
816
|
disabled={isLoading}
|
|
834
817
|
aria-busy={isLoading}
|
|
835
|
-
aria-label={isLoading ?
|
|
818
|
+
aria-label={isLoading ? "Loading..." : undefined}
|
|
836
819
|
>
|
|
837
820
|
{isLoading ? (
|
|
838
821
|
<>
|
|
@@ -869,34 +852,34 @@ export type FormTouched<T> = Partial<Record<keyof T, boolean>>;
|
|
|
869
852
|
|
|
870
853
|
```tsx
|
|
871
854
|
// components/TypeSafeForm.tsx
|
|
872
|
-
import { useState } from
|
|
855
|
+
import { useState } from "react";
|
|
873
856
|
import { Button } from "@/components/Button";
|
|
874
857
|
import { LabelField } from "@/components/LabelField";
|
|
875
858
|
import { toast } from "@/components/Toast";
|
|
876
|
-
import type { ContactForm, FormErrors, FormTouched } from
|
|
859
|
+
import type { ContactForm, FormErrors, FormTouched } from "../types/forms";
|
|
877
860
|
|
|
878
861
|
export default function TypeSafeForm() {
|
|
879
862
|
const [formData, setFormData] = useState<ContactForm>({
|
|
880
|
-
name:
|
|
881
|
-
email:
|
|
882
|
-
message:
|
|
863
|
+
name: "",
|
|
864
|
+
email: "",
|
|
865
|
+
message: "",
|
|
883
866
|
});
|
|
884
867
|
|
|
885
868
|
const [errors, setErrors] = useState<FormErrors<ContactForm>>({});
|
|
886
869
|
const [touched, setTouched] = useState<FormTouched<ContactForm>>({});
|
|
887
870
|
|
|
888
|
-
const handleChange =
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
871
|
+
const handleChange =
|
|
872
|
+
<K extends keyof ContactForm>(field: K) =>
|
|
873
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
874
|
+
setFormData((prev) => ({
|
|
875
|
+
...prev,
|
|
876
|
+
[field]: e.target.value,
|
|
877
|
+
}));
|
|
878
|
+
};
|
|
896
879
|
|
|
897
880
|
const handleSubmit = (e: React.FormEvent) => {
|
|
898
881
|
e.preventDefault();
|
|
899
|
-
toast.success(
|
|
882
|
+
toast.success("Form submitted!");
|
|
900
883
|
};
|
|
901
884
|
|
|
902
885
|
return (
|
|
@@ -905,7 +888,7 @@ export default function TypeSafeForm() {
|
|
|
905
888
|
theme="light"
|
|
906
889
|
label="Name"
|
|
907
890
|
value={formData.name}
|
|
908
|
-
onChange={handleChange(
|
|
891
|
+
onChange={handleChange("name")}
|
|
909
892
|
errorMessage={touched.name ? errors.name : undefined}
|
|
910
893
|
/>
|
|
911
894
|
|
|
@@ -929,17 +912,11 @@ interface ListProps<T> {
|
|
|
929
912
|
keyExtractor: (item: T) => string;
|
|
930
913
|
}
|
|
931
914
|
|
|
932
|
-
export function TypedList<T>({
|
|
933
|
-
items,
|
|
934
|
-
renderItem,
|
|
935
|
-
keyExtractor,
|
|
936
|
-
}: ListProps<T>) {
|
|
915
|
+
export function TypedList<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
|
|
937
916
|
return (
|
|
938
917
|
<div className="space-y-2">
|
|
939
918
|
{items.map((item) => (
|
|
940
|
-
<div key={keyExtractor(item)}>
|
|
941
|
-
{renderItem(item)}
|
|
942
|
-
</div>
|
|
919
|
+
<div key={keyExtractor(item)}>{renderItem(item)}</div>
|
|
943
920
|
))}
|
|
944
921
|
</div>
|
|
945
922
|
);
|
|
@@ -950,14 +927,11 @@ export function TypedList<T>({
|
|
|
950
927
|
|
|
951
928
|
```tsx
|
|
952
929
|
// hooks/useTypedLocalStorage.ts
|
|
953
|
-
import { useState, useEffect } from
|
|
930
|
+
import { useState, useEffect } from "react";
|
|
954
931
|
|
|
955
|
-
export function useTypedLocalStorage<T>(
|
|
956
|
-
key: string,
|
|
957
|
-
initialValue: T
|
|
958
|
-
): [T, (value: T) => void] {
|
|
932
|
+
export function useTypedLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
|
|
959
933
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
960
|
-
if (typeof window ===
|
|
934
|
+
if (typeof window === "undefined") return initialValue;
|
|
961
935
|
|
|
962
936
|
try {
|
|
963
937
|
const item = window.localStorage.getItem(key);
|
|
@@ -971,7 +945,7 @@ export function useTypedLocalStorage<T>(
|
|
|
971
945
|
const setValue = (value: T) => {
|
|
972
946
|
try {
|
|
973
947
|
setStoredValue(value);
|
|
974
|
-
if (typeof window !==
|
|
948
|
+
if (typeof window !== "undefined") {
|
|
975
949
|
window.localStorage.setItem(key, JSON.stringify(value));
|
|
976
950
|
}
|
|
977
951
|
} catch (error) {
|
|
@@ -998,11 +972,11 @@ export interface ExtendedButtonProps extends ButtonProps {
|
|
|
998
972
|
```tsx
|
|
999
973
|
// components/ExtendedButton.tsx
|
|
1000
974
|
import { Button } from "@/components/Button";
|
|
1001
|
-
import type { ExtendedButtonProps } from
|
|
975
|
+
import type { ExtendedButtonProps } from "../types/components";
|
|
1002
976
|
|
|
1003
977
|
export function ExtendedButton({
|
|
1004
978
|
isLoading,
|
|
1005
|
-
loadingText =
|
|
979
|
+
loadingText = "Loading...",
|
|
1006
980
|
children,
|
|
1007
981
|
...props
|
|
1008
982
|
}: ExtendedButtonProps) {
|
|
@@ -1018,19 +992,19 @@ export function ExtendedButton({
|
|
|
1018
992
|
|
|
1019
993
|
```tsx
|
|
1020
994
|
// utils/typeGuards.ts
|
|
1021
|
-
import type { User } from
|
|
995
|
+
import type { User } from "../types";
|
|
1022
996
|
|
|
1023
997
|
export function isValidEmail(value: unknown): value is string {
|
|
1024
|
-
return typeof value ===
|
|
998
|
+
return typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
1025
999
|
}
|
|
1026
1000
|
|
|
1027
1001
|
export function isUser(value: unknown): value is User {
|
|
1028
1002
|
return (
|
|
1029
|
-
typeof value ===
|
|
1003
|
+
typeof value === "object" &&
|
|
1030
1004
|
value !== null &&
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1005
|
+
"id" in value &&
|
|
1006
|
+
"email" in value &&
|
|
1007
|
+
"name" in value
|
|
1034
1008
|
);
|
|
1035
1009
|
}
|
|
1036
1010
|
```
|