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.
- package/CHANGELOG.md +38 -0
- package/README.md +8 -0
- package/esm/commands/create-new-app/index.mjs +4 -3
- package/esm/commands/create-new-app/index.mjs.map +1 -1
- package/esm/commands/create-warlock-app/index.mjs +7 -1
- package/esm/commands/create-warlock-app/index.mjs.map +1 -1
- package/esm/features/features-map.mjs +8 -2
- package/esm/features/features-map.mjs.map +1 -1
- package/esm/helpers/app.mjs +1 -0
- package/esm/helpers/app.mjs.map +1 -1
- package/esm/prompts/ask-stack.mjs +1 -1
- package/esm/prompts/ask-stack.mjs.map +1 -1
- package/llms-full.txt +64 -28
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/skills/{create-a-warlock-project/SKILL.md → SKILL.md} +63 -27
- package/templates/design-system/SOURCES.md +23 -0
- package/templates/design-system/package.json +14 -0
- package/templates/design-system/src/web/components/ui/LICENSE.md +21 -0
- package/templates/design-system/src/web/components/ui/button.tsx +61 -0
- package/templates/design-system/src/web/components/ui/calendar.tsx +178 -0
- package/templates/design-system/src/web/components/ui/checkbox.tsx +28 -0
- package/templates/design-system/src/web/components/ui/command.tsx +161 -0
- package/templates/design-system/src/web/components/ui/dialog.tsx +144 -0
- package/templates/design-system/src/web/components/ui/input.tsx +20 -0
- package/templates/design-system/src/web/components/ui/label.tsx +20 -0
- package/templates/design-system/src/web/components/ui/popover.tsx +73 -0
- package/templates/design-system/src/web/components/ui/radio-group.tsx +44 -0
- package/templates/design-system/src/web/components/ui/switch.tsx +34 -0
- package/templates/design-system/src/web/components/ui/textarea.tsx +17 -0
- package/templates/design-system/src/web/design-system/components/checkbox-input.tsx +19 -0
- package/templates/design-system/src/web/design-system/components/date-picker-input.tsx +59 -0
- package/templates/design-system/src/web/design-system/components/date-time-picker-input.tsx +67 -0
- package/templates/design-system/src/web/design-system/components/email-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/components/field-frame.tsx +36 -0
- package/templates/design-system/src/web/design-system/components/file-input.tsx +24 -0
- package/templates/design-system/src/web/design-system/components/multi-select-input.tsx +94 -0
- package/templates/design-system/src/web/design-system/components/password-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/components/radio-input.tsx +41 -0
- package/templates/design-system/src/web/design-system/components/select-input.tsx +94 -0
- package/templates/design-system/src/web/design-system/components/submit-button.tsx +20 -0
- package/templates/design-system/src/web/design-system/components/switch-input.tsx +19 -0
- package/templates/design-system/src/web/design-system/components/text-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/components/textarea-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/components/time-picker-input.tsx +22 -0
- package/templates/design-system/src/web/design-system/hooks/use-calendar-day-button.ts +9 -0
- package/templates/design-system/src/web/design-system/hooks/use-control.ts +29 -0
- package/templates/design-system/src/web/design-system/hooks/use-date-control.ts +42 -0
- package/templates/design-system/src/web/design-system/hooks/use-file-control.ts +24 -0
- package/templates/design-system/src/web/design-system/hooks/use-select-control.ts +78 -0
- package/templates/design-system/src/web/design-system/hooks/use-submit-control.ts +13 -0
- package/templates/design-system/src/web/design-system/types/control-props.ts +46 -0
- package/templates/toolchains/oxc/.oxlintrc.json +3 -1
- package/templates/warlock/.oxlintrc.json +3 -1
- package/templates/warlock/AGENTS.md +10 -10
- package/templates/warlock/e2e/home.e2e.ts +19 -0
- package/templates/warlock/package.json +1 -1
- package/templates/warlock/skills/api-design/SKILL.md +180 -57
- package/templates/warlock/skills/code-standards/SKILL.md +48 -39
- package/templates/warlock/skills/data-and-persistence/SKILL.md +72 -57
- package/templates/warlock/skills/git-workflow/SKILL.md +9 -9
- package/templates/warlock/skills/module-boundaries/SKILL.md +2 -2
- package/templates/warlock/skills/observability-and-resilience/SKILL.md +8 -6
- package/templates/warlock/skills/security-baseline/SKILL.md +23 -22
- package/templates/warlock/skills/testing-strategy/SKILL.md +53 -12
- package/templates/warlock/skills/web-code-standards/SKILL.md +195 -60
- package/templates/warlock/src/app/auth/controllers/forgot-password.controller.ts +6 -0
- package/templates/warlock/src/app/auth/controllers/login.controller.ts +17 -0
- package/templates/warlock/src/app/auth/controllers/logout-all.controller.ts +6 -0
- package/templates/warlock/src/app/auth/controllers/logout.controller.ts +6 -0
- package/templates/warlock/src/app/auth/controllers/me.controller.ts +7 -0
- package/templates/warlock/src/app/auth/controllers/refresh-token.controller.ts +12 -0
- package/templates/warlock/src/app/auth/controllers/reset-password.controller.ts +6 -0
- package/templates/warlock/src/app/auth/models/otp/otp.model.ts +9 -3
- package/templates/warlock/src/app/auth/services/otp.service.ts +18 -6
- package/templates/warlock/src/app/contact/controllers/contact.controller.ts +6 -0
- package/templates/warlock/src/app/home/controllers/home-page.controller.ts +4 -0
- package/templates/warlock/src/app/locale/controllers/locale.controller.ts +6 -0
- package/templates/warlock/src/app/posts/controllers/create-new-post.controller.ts +7 -0
- package/templates/warlock/src/app/posts/controllers/update-post.controller.ts +15 -9
- package/templates/warlock/src/app/posts/models/post/migrations/09-01-2026_02-07-51-post.migration.ts +1 -0
- package/templates/warlock/src/app/posts/models/post/post.model.ts +6 -0
- package/templates/warlock/src/app/posts/resources/post.resource.ts +1 -1
- package/templates/warlock/src/app/posts/schema/update-post.schema.ts +1 -0
- package/templates/warlock/src/app/shared/utils/format-http-error-response.ts +53 -0
- package/templates/warlock/src/app/users/commands/hello-world.command.ts +2 -1
- package/templates/warlock/src/app/users/controllers/create-new-user.controller.ts +13 -1
- package/templates/warlock/src/app/users/controllers/list-users.controller.ts +24 -1
- package/templates/warlock/src/app/users/models/user/migrations/11-12-2025_23-58-03-user.migration.ts +1 -0
- package/templates/warlock/src/app/users/models/user/user.model.ts +5 -0
- package/templates/warlock/src/app/users/repositories/users.repository.ts +2 -2
- package/templates/warlock/src/app/users/resources/user.resource.ts +1 -1
- package/templates/warlock/src/app/users/schema/list-users.schema.ts +1 -1
- package/templates/warlock/src/config/http.ts +6 -0
- package/templates/warlock/src/web/home/components/contact-field.tsx +18 -0
- package/templates/warlock/src/web/home/components/contact-section.tsx +5 -29
- package/templates/warlock/src/web/home/components/contact-submit-button.tsx +16 -0
- package/templates/warlock/src/web/home/hooks/use-contact-field.ts +9 -0
- package/templates/warlock/src/web/home/hooks/use-contact-form.ts +5 -8
- package/templates/warlock/src/web/home/hooks/use-contact-locale.ts +34 -0
- package/templates/warlock/src/web/home/hooks/use-contact-submit-button.ts +6 -0
- package/templates/warlock/src/web/home/index.page.tsx +1 -1
- package/templates/warlock/src/web/home/types/contact-controls.type.ts +13 -0
- package/templates/warlock/src/web/shared/utils/set-form-errors.ts +3 -1
- package/templates/warlock/warlock.config.ts +1 -0
- 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,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
|
+
}
|