create-warlock 5.34.2 → 5.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +8 -0
  3. package/esm/commands/create-new-app/index.mjs +4 -3
  4. package/esm/commands/create-new-app/index.mjs.map +1 -1
  5. package/esm/commands/create-warlock-app/index.mjs +7 -1
  6. package/esm/commands/create-warlock-app/index.mjs.map +1 -1
  7. package/esm/features/features-map.mjs +8 -2
  8. package/esm/features/features-map.mjs.map +1 -1
  9. package/esm/helpers/app.mjs +1 -0
  10. package/esm/helpers/app.mjs.map +1 -1
  11. package/esm/prompts/ask-stack.mjs +1 -1
  12. package/esm/prompts/ask-stack.mjs.map +1 -1
  13. package/llms-full.txt +64 -28
  14. package/llms.txt +1 -1
  15. package/package.json +2 -2
  16. package/skills/{create-a-warlock-project/SKILL.md → SKILL.md} +63 -27
  17. package/templates/design-system/SOURCES.md +23 -0
  18. package/templates/design-system/package.json +14 -0
  19. package/templates/design-system/src/web/components/ui/LICENSE.md +21 -0
  20. package/templates/design-system/src/web/components/ui/button.tsx +61 -0
  21. package/templates/design-system/src/web/components/ui/calendar.tsx +178 -0
  22. package/templates/design-system/src/web/components/ui/checkbox.tsx +28 -0
  23. package/templates/design-system/src/web/components/ui/command.tsx +161 -0
  24. package/templates/design-system/src/web/components/ui/dialog.tsx +144 -0
  25. package/templates/design-system/src/web/components/ui/input.tsx +20 -0
  26. package/templates/design-system/src/web/components/ui/label.tsx +20 -0
  27. package/templates/design-system/src/web/components/ui/popover.tsx +73 -0
  28. package/templates/design-system/src/web/components/ui/radio-group.tsx +44 -0
  29. package/templates/design-system/src/web/components/ui/switch.tsx +34 -0
  30. package/templates/design-system/src/web/components/ui/textarea.tsx +17 -0
  31. package/templates/design-system/src/web/design-system/components/checkbox-input.tsx +19 -0
  32. package/templates/design-system/src/web/design-system/components/date-picker-input.tsx +59 -0
  33. package/templates/design-system/src/web/design-system/components/date-time-picker-input.tsx +67 -0
  34. package/templates/design-system/src/web/design-system/components/email-input.tsx +22 -0
  35. package/templates/design-system/src/web/design-system/components/field-frame.tsx +36 -0
  36. package/templates/design-system/src/web/design-system/components/file-input.tsx +24 -0
  37. package/templates/design-system/src/web/design-system/components/multi-select-input.tsx +94 -0
  38. package/templates/design-system/src/web/design-system/components/password-input.tsx +22 -0
  39. package/templates/design-system/src/web/design-system/components/radio-input.tsx +41 -0
  40. package/templates/design-system/src/web/design-system/components/select-input.tsx +94 -0
  41. package/templates/design-system/src/web/design-system/components/submit-button.tsx +20 -0
  42. package/templates/design-system/src/web/design-system/components/switch-input.tsx +19 -0
  43. package/templates/design-system/src/web/design-system/components/text-input.tsx +22 -0
  44. package/templates/design-system/src/web/design-system/components/textarea-input.tsx +22 -0
  45. package/templates/design-system/src/web/design-system/components/time-picker-input.tsx +22 -0
  46. package/templates/design-system/src/web/design-system/hooks/use-calendar-day-button.ts +9 -0
  47. package/templates/design-system/src/web/design-system/hooks/use-control.ts +29 -0
  48. package/templates/design-system/src/web/design-system/hooks/use-date-control.ts +42 -0
  49. package/templates/design-system/src/web/design-system/hooks/use-file-control.ts +24 -0
  50. package/templates/design-system/src/web/design-system/hooks/use-select-control.ts +78 -0
  51. package/templates/design-system/src/web/design-system/hooks/use-submit-control.ts +13 -0
  52. package/templates/design-system/src/web/design-system/types/control-props.ts +46 -0
  53. package/templates/toolchains/oxc/.oxlintrc.json +3 -1
  54. package/templates/warlock/.oxlintrc.json +3 -1
  55. package/templates/warlock/AGENTS.md +10 -10
  56. package/templates/warlock/e2e/home.e2e.ts +19 -0
  57. package/templates/warlock/package.json +1 -1
  58. package/templates/warlock/skills/api-design/SKILL.md +180 -57
  59. package/templates/warlock/skills/code-standards/SKILL.md +48 -39
  60. package/templates/warlock/skills/data-and-persistence/SKILL.md +72 -57
  61. package/templates/warlock/skills/git-workflow/SKILL.md +9 -9
  62. package/templates/warlock/skills/module-boundaries/SKILL.md +2 -2
  63. package/templates/warlock/skills/observability-and-resilience/SKILL.md +8 -6
  64. package/templates/warlock/skills/security-baseline/SKILL.md +23 -22
  65. package/templates/warlock/skills/testing-strategy/SKILL.md +53 -12
  66. package/templates/warlock/skills/web-code-standards/SKILL.md +195 -60
  67. package/templates/warlock/src/app/auth/controllers/forgot-password.controller.ts +6 -0
  68. package/templates/warlock/src/app/auth/controllers/login.controller.ts +17 -0
  69. package/templates/warlock/src/app/auth/controllers/logout-all.controller.ts +6 -0
  70. package/templates/warlock/src/app/auth/controllers/logout.controller.ts +6 -0
  71. package/templates/warlock/src/app/auth/controllers/me.controller.ts +7 -0
  72. package/templates/warlock/src/app/auth/controllers/refresh-token.controller.ts +12 -0
  73. package/templates/warlock/src/app/auth/controllers/reset-password.controller.ts +6 -0
  74. package/templates/warlock/src/app/auth/models/otp/otp.model.ts +9 -3
  75. package/templates/warlock/src/app/auth/services/otp.service.ts +18 -6
  76. package/templates/warlock/src/app/contact/controllers/contact.controller.ts +6 -0
  77. package/templates/warlock/src/app/home/controllers/home-page.controller.ts +4 -0
  78. package/templates/warlock/src/app/locale/controllers/locale.controller.ts +6 -0
  79. package/templates/warlock/src/app/posts/controllers/create-new-post.controller.ts +7 -0
  80. package/templates/warlock/src/app/posts/controllers/update-post.controller.ts +15 -9
  81. package/templates/warlock/src/app/posts/models/post/migrations/09-01-2026_02-07-51-post.migration.ts +1 -0
  82. package/templates/warlock/src/app/posts/models/post/post.model.ts +6 -0
  83. package/templates/warlock/src/app/posts/resources/post.resource.ts +1 -1
  84. package/templates/warlock/src/app/posts/schema/update-post.schema.ts +1 -0
  85. package/templates/warlock/src/app/shared/utils/format-http-error-response.ts +53 -0
  86. package/templates/warlock/src/app/users/commands/hello-world.command.ts +2 -1
  87. package/templates/warlock/src/app/users/controllers/create-new-user.controller.ts +13 -1
  88. package/templates/warlock/src/app/users/controllers/list-users.controller.ts +24 -1
  89. package/templates/warlock/src/app/users/models/user/migrations/11-12-2025_23-58-03-user.migration.ts +1 -0
  90. package/templates/warlock/src/app/users/models/user/user.model.ts +5 -0
  91. package/templates/warlock/src/app/users/repositories/users.repository.ts +2 -2
  92. package/templates/warlock/src/app/users/resources/user.resource.ts +1 -1
  93. package/templates/warlock/src/app/users/schema/list-users.schema.ts +1 -1
  94. package/templates/warlock/src/config/http.ts +6 -0
  95. package/templates/warlock/src/web/home/components/contact-field.tsx +18 -0
  96. package/templates/warlock/src/web/home/components/contact-section.tsx +5 -29
  97. package/templates/warlock/src/web/home/components/contact-submit-button.tsx +16 -0
  98. package/templates/warlock/src/web/home/hooks/use-contact-field.ts +9 -0
  99. package/templates/warlock/src/web/home/hooks/use-contact-form.ts +5 -8
  100. package/templates/warlock/src/web/home/hooks/use-contact-locale.ts +34 -0
  101. package/templates/warlock/src/web/home/hooks/use-contact-submit-button.ts +6 -0
  102. package/templates/warlock/src/web/home/index.page.tsx +1 -1
  103. package/templates/warlock/src/web/home/types/contact-controls.type.ts +13 -0
  104. package/templates/warlock/src/web/shared/utils/set-form-errors.ts +3 -1
  105. package/templates/warlock/warlock.config.ts +1 -0
  106. package/templates/warlock/src/web/home/components/contact-form-controls.tsx +0 -42
@@ -0,0 +1,22 @@
1
+ import { Input } from "web/components/ui/input";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import FieldFrame from "./field-frame";
5
+
6
+ export default function EmailInput(props: ControlProps) {
7
+ const field = useControl(props, "email");
8
+ return (
9
+ <FieldFrame {...props} error={field.error}>
10
+ <Input
11
+ {...field.binding}
12
+ type="email"
13
+ value={typeof field.value === "string" ? field.value : ""}
14
+ onChange={(event) => field.changeValue(event.target.value)}
15
+ placeholder={props.placeholder}
16
+ readOnly={props.readOnly}
17
+ autoComplete={props.autoComplete}
18
+ dir={props.dir}
19
+ />
20
+ </FieldFrame>
21
+ );
22
+ }
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from "react";
2
+ import { Label } from "web/components/ui/label";
3
+ import type { ControlProps } from "../types/control-props";
4
+
5
+ export default function FieldFrame({
6
+ id,
7
+ label,
8
+ hint,
9
+ error,
10
+ children,
11
+ dir,
12
+ className,
13
+ }: Pick<ControlProps, "id" | "label" | "hint" | "error" | "dir" | "className"> & {
14
+ children: ReactNode;
15
+ }) {
16
+ return (
17
+ <div dir={dir} className={className ?? "grid gap-2 text-start"}>
18
+ {label != null ? (
19
+ <Label id={`${id}-label`} htmlFor={id}>
20
+ {label}
21
+ </Label>
22
+ ) : null}
23
+ {children}
24
+ {hint ? (
25
+ <p id={`${id}-hint`} className="text-sm text-muted-foreground">
26
+ {hint}
27
+ </p>
28
+ ) : null}
29
+ {error ? (
30
+ <p id={`${id}-error`} role="alert" className="text-sm text-destructive">
31
+ {error}
32
+ </p>
33
+ ) : null}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,24 @@
1
+ import { Input } from "web/components/ui/input";
2
+ import { useFileControl } from "../hooks/use-file-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import FieldFrame from "./field-frame";
5
+
6
+ export default function FileInput(props: ControlProps) {
7
+ const field = useFileControl(props);
8
+ return (
9
+ <FieldFrame {...props} error={field.error}>
10
+ <Input
11
+ {...field.binding}
12
+ type="file"
13
+ disabled={field.disabled || props.readOnly}
14
+ accept={props.accept}
15
+ multiple={props.multiple}
16
+ onChange={(event) =>
17
+ field.changeValue(
18
+ props.multiple ? Array.from(event.target.files ?? []) : (event.target.files?.[0] ?? ""),
19
+ )
20
+ }
21
+ />
22
+ </FieldFrame>
23
+ );
24
+ }
@@ -0,0 +1,94 @@
1
+ import { Button } from "web/components/ui/button";
2
+ import { Input } from "web/components/ui/input";
3
+ import { Popover, PopoverContent, PopoverTrigger } from "web/components/ui/popover";
4
+ import { useSelectControl } from "../hooks/use-select-control";
5
+ import type { SelectProps } from "../types/control-props";
6
+ import FieldFrame from "./field-frame";
7
+
8
+ export default function MultiSelectInput(props: SelectProps) {
9
+ const field = useSelectControl(props, true);
10
+ return (
11
+ <FieldFrame {...props} error={field.error}>
12
+ <Popover open={field.open} onOpenChange={field.setOpen}>
13
+ <PopoverTrigger asChild>
14
+ <Button
15
+ {...field.binding}
16
+ type="button"
17
+ variant="outline"
18
+ disabled={field.disabled || props.readOnly}
19
+ aria-labelledby={`${props.id}-label ${props.id}-value`}
20
+ aria-haspopup="dialog"
21
+ aria-controls={`${props.id}-dialog`}
22
+ >
23
+ <span id={`${props.id}-value`}>
24
+ {props.options
25
+ .filter((option) => field.selected.includes(option.value))
26
+ .map((option) => option.label)
27
+ .join(", ") ||
28
+ props.placeholder ||
29
+ "Choose"}
30
+ </span>
31
+ </Button>
32
+ </PopoverTrigger>
33
+ <PopoverContent
34
+ id={`${props.id}-dialog`}
35
+ dir={props.dir}
36
+ align="start"
37
+ aria-labelledby={`${props.id}-label`}
38
+ onCloseAutoFocus={field.onBlur}
39
+ >
40
+ <Input
41
+ id={`${props.id}-search`}
42
+ role="combobox"
43
+ aria-label={props.searchLabel ?? "Search options"}
44
+ aria-expanded={field.open}
45
+ aria-controls={`${props.id}-listbox`}
46
+ aria-autocomplete="list"
47
+ aria-activedescendant={
48
+ field.activeOption
49
+ ? `${props.id}-option-${encodeURIComponent(field.activeOption.value)}`
50
+ : undefined
51
+ }
52
+ value={field.search}
53
+ onChange={(event) => field.setSearch(event.target.value)}
54
+ onKeyDown={field.onKeyDown}
55
+ />
56
+ <div
57
+ id={`${props.id}-listbox`}
58
+ role="listbox"
59
+ aria-labelledby={`${props.id}-label`}
60
+ aria-multiselectable={true}
61
+ className="max-h-60 overflow-auto py-2"
62
+ >
63
+ {field.filtered.map((option) => (
64
+ <Button
65
+ key={option.value}
66
+ id={`${props.id}-option-${encodeURIComponent(option.value)}`}
67
+ type="button"
68
+ role="option"
69
+ tabIndex={-1}
70
+ aria-selected={field.selected.includes(option.value)}
71
+ aria-disabled={option.disabled}
72
+ disabled={option.disabled}
73
+ variant="ghost"
74
+ className="w-full justify-start aria-selected:bg-accent data-[active=true]:ring-2 data-[active=true]:ring-ring"
75
+ data-active={field.activeOption === option}
76
+ onMouseDown={(event) => event.preventDefault()}
77
+ onClick={() => field.select(option.value)}
78
+ >
79
+ {field.selected.includes(option.value) ? "✓ " : ""}
80
+ {option.label}
81
+ </Button>
82
+ ))}
83
+ </div>
84
+ {field.filtered.length === 0 ? (
85
+ <p role="status">{props.emptyLabel ?? "No options found"}</p>
86
+ ) : null}
87
+ <Button type="button" variant="ghost" onClick={field.clear}>
88
+ {props.clearLabel ?? "Clear"}
89
+ </Button>
90
+ </PopoverContent>
91
+ </Popover>
92
+ </FieldFrame>
93
+ );
94
+ }
@@ -0,0 +1,22 @@
1
+ import { Input } from "web/components/ui/input";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import FieldFrame from "./field-frame";
5
+
6
+ export default function PasswordInput(props: ControlProps) {
7
+ const field = useControl(props, "password");
8
+ return (
9
+ <FieldFrame {...props} error={field.error}>
10
+ <Input
11
+ {...field.binding}
12
+ type="password"
13
+ value={typeof field.value === "string" ? field.value : ""}
14
+ onChange={(event) => field.changeValue(event.target.value)}
15
+ placeholder={props.placeholder}
16
+ readOnly={props.readOnly}
17
+ autoComplete={props.autoComplete}
18
+ dir={props.dir}
19
+ />
20
+ </FieldFrame>
21
+ );
22
+ }
@@ -0,0 +1,41 @@
1
+ import { RadioGroup, RadioGroupItem } from "web/components/ui/radio-group";
2
+ import { Label } from "web/components/ui/label";
3
+ import { useControl } from "../hooks/use-control";
4
+ import type { RadioProps } from "../types/control-props";
5
+ import FieldFrame from "./field-frame";
6
+
7
+ export default function RadioInput(props: RadioProps) {
8
+ // Register the group once as a scalar; individual Radix items own keyboard focus.
9
+ const field = useControl(props);
10
+ return (
11
+ <FieldFrame {...props} error={field.error}>
12
+ <RadioGroup
13
+ {...field.binding}
14
+ ref={(element) => {
15
+ field.visibleElementRef.current = element;
16
+ }}
17
+ dir={props.dir}
18
+ disabled={field.disabled || props.readOnly}
19
+ value={typeof field.value === "string" ? field.value : ""}
20
+ onValueChange={field.changeValue}
21
+ aria-labelledby={`${props.id}-label`}
22
+ >
23
+ {props.options.map((option) => (
24
+ <div key={option.value} className="flex items-center gap-2">
25
+ <RadioGroupItem
26
+ ref={
27
+ option === props.options.find((item) => !item.disabled) ? field.inputRef : undefined
28
+ }
29
+ id={`${props.id}-option-${encodeURIComponent(option.value)}`}
30
+ value={option.value}
31
+ disabled={option.disabled}
32
+ />
33
+ <Label htmlFor={`${props.id}-option-${encodeURIComponent(option.value)}`}>
34
+ {option.label}
35
+ </Label>
36
+ </div>
37
+ ))}
38
+ </RadioGroup>
39
+ </FieldFrame>
40
+ );
41
+ }
@@ -0,0 +1,94 @@
1
+ import { Button } from "web/components/ui/button";
2
+ import { Input } from "web/components/ui/input";
3
+ import { Popover, PopoverContent, PopoverTrigger } from "web/components/ui/popover";
4
+ import { useSelectControl } from "../hooks/use-select-control";
5
+ import type { SelectProps } from "../types/control-props";
6
+ import FieldFrame from "./field-frame";
7
+
8
+ export default function SelectInput(props: SelectProps) {
9
+ const field = useSelectControl(props, false);
10
+ return (
11
+ <FieldFrame {...props} error={field.error}>
12
+ <Popover open={field.open} onOpenChange={field.setOpen}>
13
+ <PopoverTrigger asChild>
14
+ <Button
15
+ {...field.binding}
16
+ type="button"
17
+ variant="outline"
18
+ disabled={field.disabled || props.readOnly}
19
+ aria-labelledby={`${props.id}-label ${props.id}-value`}
20
+ aria-haspopup="dialog"
21
+ aria-controls={`${props.id}-dialog`}
22
+ >
23
+ <span id={`${props.id}-value`}>
24
+ {props.options
25
+ .filter((option) => field.selected.includes(option.value))
26
+ .map((option) => option.label)
27
+ .join(", ") ||
28
+ props.placeholder ||
29
+ "Choose"}
30
+ </span>
31
+ </Button>
32
+ </PopoverTrigger>
33
+ <PopoverContent
34
+ id={`${props.id}-dialog`}
35
+ dir={props.dir}
36
+ align="start"
37
+ aria-labelledby={`${props.id}-label`}
38
+ onCloseAutoFocus={field.onBlur}
39
+ >
40
+ <Input
41
+ id={`${props.id}-search`}
42
+ role="combobox"
43
+ aria-label={props.searchLabel ?? "Search options"}
44
+ aria-expanded={field.open}
45
+ aria-controls={`${props.id}-listbox`}
46
+ aria-autocomplete="list"
47
+ aria-activedescendant={
48
+ field.activeOption
49
+ ? `${props.id}-option-${encodeURIComponent(field.activeOption.value)}`
50
+ : undefined
51
+ }
52
+ value={field.search}
53
+ onChange={(event) => field.setSearch(event.target.value)}
54
+ onKeyDown={field.onKeyDown}
55
+ />
56
+ <div
57
+ id={`${props.id}-listbox`}
58
+ role="listbox"
59
+ aria-labelledby={`${props.id}-label`}
60
+ aria-multiselectable={false}
61
+ className="max-h-60 overflow-auto py-2"
62
+ >
63
+ {field.filtered.map((option) => (
64
+ <Button
65
+ key={option.value}
66
+ id={`${props.id}-option-${encodeURIComponent(option.value)}`}
67
+ type="button"
68
+ role="option"
69
+ tabIndex={-1}
70
+ aria-selected={field.selected.includes(option.value)}
71
+ aria-disabled={option.disabled}
72
+ disabled={option.disabled}
73
+ variant="ghost"
74
+ className="w-full justify-start aria-selected:bg-accent data-[active=true]:ring-2 data-[active=true]:ring-ring"
75
+ data-active={field.activeOption === option}
76
+ onMouseDown={(event) => event.preventDefault()}
77
+ onClick={() => field.select(option.value)}
78
+ >
79
+ {field.selected.includes(option.value) ? "✓ " : ""}
80
+ {option.label}
81
+ </Button>
82
+ ))}
83
+ </div>
84
+ {field.filtered.length === 0 ? (
85
+ <p role="status">{props.emptyLabel ?? "No options found"}</p>
86
+ ) : null}
87
+ <Button type="button" variant="ghost" onClick={field.clear}>
88
+ {props.clearLabel ?? "Clear"}
89
+ </Button>
90
+ </PopoverContent>
91
+ </Popover>
92
+ </FieldFrame>
93
+ );
94
+ }
@@ -0,0 +1,20 @@
1
+ import { Button } from "web/components/ui/button";
2
+ import { useSubmitControl } from "../hooks/use-submit-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import FieldFrame from "./field-frame";
5
+
6
+ export default function SubmitButton(props: ControlProps) {
7
+ const field = useSubmitControl(props);
8
+ return (
9
+ <FieldFrame {...props} label={null} error={field.error}>
10
+ <Button
11
+ {...field.binding}
12
+ type="submit"
13
+ disabled={field.disabled}
14
+ aria-busy={field.isSubmitting}
15
+ >
16
+ {props.label}
17
+ </Button>
18
+ </FieldFrame>
19
+ );
20
+ }
@@ -0,0 +1,19 @@
1
+ import { Switch } from "web/components/ui/switch";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { CheckProps } from "../types/control-props";
4
+ import FieldFrame from "./field-frame";
5
+
6
+ export default function SwitchInput(props: CheckProps) {
7
+ const field = useControl(props, "checkbox");
8
+ return (
9
+ <FieldFrame {...props} error={field.error}>
10
+ <Switch
11
+ {...field.binding}
12
+ disabled={field.disabled || props.readOnly}
13
+ checked={Boolean(field.checked)}
14
+ onCheckedChange={(checked) => field.setChecked(checked === true)}
15
+ aria-labelledby={`${props.id}-label`}
16
+ />
17
+ </FieldFrame>
18
+ );
19
+ }
@@ -0,0 +1,22 @@
1
+ import { Input } from "web/components/ui/input";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import FieldFrame from "./field-frame";
5
+
6
+ export default function TextInput(props: ControlProps) {
7
+ const field = useControl(props, "text");
8
+ return (
9
+ <FieldFrame {...props} error={field.error}>
10
+ <Input
11
+ {...field.binding}
12
+ type="text"
13
+ value={typeof field.value === "string" ? field.value : ""}
14
+ onChange={(event) => field.changeValue(event.target.value)}
15
+ placeholder={props.placeholder}
16
+ readOnly={props.readOnly}
17
+ autoComplete={props.autoComplete}
18
+ dir={props.dir}
19
+ />
20
+ </FieldFrame>
21
+ );
22
+ }
@@ -0,0 +1,22 @@
1
+ import { Textarea } from "web/components/ui/textarea";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import FieldFrame from "./field-frame";
5
+
6
+ export default function TextareaInput(props: ControlProps) {
7
+ const field = useControl(props, "textarea");
8
+ return (
9
+ <FieldFrame {...props} error={field.error}>
10
+ <Textarea
11
+ {...field.binding}
12
+ rows={props.rows}
13
+ value={typeof field.value === "string" ? field.value : ""}
14
+ onChange={(event) => field.changeValue(event.target.value)}
15
+ placeholder={props.placeholder}
16
+ readOnly={props.readOnly}
17
+ autoComplete={props.autoComplete}
18
+ dir={props.dir}
19
+ />
20
+ </FieldFrame>
21
+ );
22
+ }
@@ -0,0 +1,22 @@
1
+ import { Input } from "web/components/ui/input";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import FieldFrame from "./field-frame";
5
+
6
+ export default function TimePickerInput(props: ControlProps) {
7
+ const field = useControl(props, "time");
8
+ return (
9
+ <FieldFrame {...props} error={field.error}>
10
+ <Input
11
+ {...field.binding}
12
+ type="time"
13
+ value={typeof field.value === "string" ? field.value : ""}
14
+ onChange={(event) => field.changeValue(event.target.value)}
15
+ placeholder={props.placeholder}
16
+ readOnly={props.readOnly}
17
+ autoComplete={props.autoComplete}
18
+ dir={props.dir}
19
+ />
20
+ </FieldFrame>
21
+ );
22
+ }
@@ -0,0 +1,9 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ export function useCalendarDayButton(focused: boolean) {
4
+ const ref = useRef<HTMLButtonElement>(null);
5
+ useEffect(() => {
6
+ if (focused) ref.current?.focus();
7
+ }, [focused]);
8
+ return ref;
9
+ }
@@ -0,0 +1,29 @@
1
+ import { useFormControl } from "@mongez/react-form";
2
+ import type { ControlProps } from "../types/control-props";
3
+
4
+ /** Explicit ids keep SSR, hydration, portal labels and repeated forms aligned. */
5
+ export function useControl(props: ControlProps, type = "text", multiple = false) {
6
+ const control = useFormControl(
7
+ { ...props, type, multiple },
8
+ { multiple, isCollectable: type === "submit" ? () => false : undefined },
9
+ );
10
+ const error = props.error ?? control.error;
11
+ const describedBy =
12
+ [props.hint ? `${props.id}-hint` : "", error ? `${props.id}-error` : ""]
13
+ .filter(Boolean)
14
+ .join(" ") || undefined;
15
+ const binding = {
16
+ id: props.id,
17
+ name: props.name,
18
+ disabled: control.disabled,
19
+ onBlur: control.onBlur,
20
+ ref: (element: HTMLElement | null) => {
21
+ control.inputRef.current = element;
22
+ control.visibleElementRef.current = element;
23
+ },
24
+ "aria-invalid": Boolean(error),
25
+ "aria-required": props.required || undefined,
26
+ "aria-describedby": describedBy,
27
+ };
28
+ return { ...control, error, binding };
29
+ }
@@ -0,0 +1,42 @@
1
+ import { useState } from "react";
2
+ import { useControl } from "./use-control";
3
+ import type { DateProps } from "../types/control-props";
4
+
5
+ export function calendarDate(date: Date): string {
6
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
7
+ }
8
+ export function parseCalendarDate(value: string): Date | undefined {
9
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
10
+ const [year = 0, month = 0, day = 0] = value.split("-").map(Number);
11
+ const date = new Date(0);
12
+ date.setFullYear(year, month - 1, day);
13
+ date.setHours(12, 0, 0, 0);
14
+ return calendarDate(date) === value ? date : undefined;
15
+ }
16
+ export function useDateControl(props: DateProps, withTime = false) {
17
+ const field = useControl(props);
18
+ const [open, setOpen] = useState(false);
19
+ const value = typeof field.value === "string" ? field.value : "";
20
+ const [datePart = "", time = ""] = value.split("T");
21
+ return {
22
+ ...field,
23
+ open,
24
+ setOpen,
25
+ value,
26
+ time,
27
+ selected: parseCalendarDate(datePart),
28
+ select: (date: Date | undefined) => {
29
+ if (field.disabled || props.readOnly) return;
30
+ field.changeValue(date ? calendarDate(date) + (withTime ? `T${time || "00:00"}` : "") : "");
31
+ if (!withTime) setOpen(false);
32
+ },
33
+ changeTime: (next: string) => {
34
+ if (!field.disabled && !props.readOnly)
35
+ field.changeValue(next && parseCalendarDate(datePart) ? `${datePart}T${next}` : "");
36
+ },
37
+ clear: () => {
38
+ if (!field.disabled && !props.readOnly) field.changeValue("");
39
+ setOpen(false);
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,24 @@
1
+ import { useEffect } from "react";
2
+ import { useControl } from "./use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+
5
+ export function useFileControl(props: ControlProps) {
6
+ const field = useControl(
7
+ { ...props, value: undefined, defaultValue: props.multiple ? [] : "" },
8
+ "file",
9
+ props.multiple,
10
+ );
11
+ useEffect(() => {
12
+ const clear = () => {
13
+ const input: HTMLInputElement | undefined = field.inputRef.current;
14
+ if (input) input.value = "";
15
+ };
16
+ const reset = field.formControl.onReset(clear);
17
+ const cleared = field.formControl.onClear(clear);
18
+ return () => {
19
+ reset.unsubscribe();
20
+ cleared.unsubscribe();
21
+ };
22
+ }, [field.formControl, field.inputRef]);
23
+ return field;
24
+ }
@@ -0,0 +1,78 @@
1
+ import { useState, type KeyboardEvent } from "react";
2
+ import { useControl } from "./use-control";
3
+ import type { SelectProps } from "../types/control-props";
4
+
5
+ export function selectedValues(value: unknown): string[] {
6
+ return Array.isArray(value)
7
+ ? value.filter((item): item is string => typeof item === "string")
8
+ : typeof value === "string" && value !== ""
9
+ ? [value]
10
+ : [];
11
+ }
12
+ export function toggleSelection(values: readonly string[], value: string): string[] {
13
+ return values.includes(value) ? values.filter((item) => item !== value) : [...values, value];
14
+ }
15
+ export function useSelectControl(props: SelectProps, multiple = false) {
16
+ const field = useControl(props, "text", multiple);
17
+ const [open, setOpen] = useState(false);
18
+ const [search, setSearch] = useState("");
19
+ const [active, setActive] = useState(0);
20
+ const selected = selectedValues(field.value);
21
+ const filtered = props.options.filter((option) =>
22
+ option.label.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
23
+ );
24
+ const enabled = filtered.filter((option) => !option.disabled);
25
+ const activeOption = enabled[Math.min(active, Math.max(0, enabled.length - 1))];
26
+ const select = (value: string) => {
27
+ if (field.disabled || props.readOnly) return;
28
+ field.changeValue(multiple ? toggleSelection(selected, value) : value);
29
+ if (!multiple) setOpen(false);
30
+ };
31
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
32
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
33
+ event.preventDefault();
34
+ const current = activeOption ? enabled.indexOf(activeOption) : 0;
35
+ const next =
36
+ event.key === "Home"
37
+ ? 0
38
+ : event.key === "End"
39
+ ? enabled.length - 1
40
+ : (current + (event.key === "ArrowDown" ? 1 : -1) + enabled.length) %
41
+ Math.max(1, enabled.length);
42
+ setActive(Math.max(0, next));
43
+ const option = enabled[Math.max(0, next)];
44
+ if (option)
45
+ document
46
+ .getElementById(`${props.id}-option-${encodeURIComponent(option.value)}`)
47
+ ?.scrollIntoView({ block: "nearest" });
48
+ } else if (
49
+ (event.key === "Enter" || (multiple && event.key === " " && search === "")) &&
50
+ activeOption
51
+ ) {
52
+ event.preventDefault();
53
+ select(activeOption.value);
54
+ }
55
+ };
56
+ return {
57
+ ...field,
58
+ open,
59
+ setOpen: (next: boolean) => {
60
+ setOpen(next && !field.disabled && !props.readOnly);
61
+ setSearch("");
62
+ setActive(0);
63
+ },
64
+ search,
65
+ setSearch: (next: string) => {
66
+ setSearch(next);
67
+ setActive(0);
68
+ },
69
+ selected,
70
+ filtered,
71
+ activeOption,
72
+ select,
73
+ onKeyDown,
74
+ clear: () => {
75
+ if (!field.disabled && !props.readOnly) field.changeValue(multiple ? [] : "");
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,13 @@
1
+ import { useSubmitButton } from "@mongez/react-form";
2
+ import { useControl } from "./use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+
5
+ export function useSubmitControl(props: ControlProps) {
6
+ const field = useControl(props, "submit");
7
+ const submit = useSubmitButton();
8
+ return {
9
+ ...field,
10
+ disabled: field.disabled || submit.disabled || submit.isSubmitting,
11
+ isSubmitting: submit.isSubmitting,
12
+ };
13
+ }