create-warlock 5.40.1 → 5.41.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 (53) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/esm/commands/create-new-app/index.mjs +12 -7
  3. package/esm/commands/create-new-app/index.mjs.map +1 -1
  4. package/esm/commands/create-new-app/types.d.mts +2 -0
  5. package/esm/commands/create-warlock-app/index.mjs +5 -2
  6. package/esm/commands/create-warlock-app/index.mjs.map +1 -1
  7. package/esm/features/features-map.mjs +6 -0
  8. package/esm/features/features-map.mjs.map +1 -1
  9. package/esm/features/ui-kit.mjs +39 -0
  10. package/esm/features/ui-kit.mjs.map +1 -0
  11. package/esm/flags/definitions.mjs +5 -0
  12. package/esm/flags/definitions.mjs.map +1 -1
  13. package/esm/flags/seed-from-flags.mjs +7 -2
  14. package/esm/flags/seed-from-flags.mjs.map +1 -1
  15. package/esm/helpers/app.mjs +7 -2
  16. package/esm/helpers/app.mjs.map +1 -1
  17. package/esm/index.mjs +5 -1
  18. package/esm/index.mjs.map +1 -1
  19. package/esm/prompts/ask-stack.mjs +5 -0
  20. package/esm/prompts/ask-stack.mjs.map +1 -1
  21. package/esm/prompts/ask-ui-kit.mjs +27 -0
  22. package/esm/prompts/ask-ui-kit.mjs.map +1 -0
  23. package/llms-full.txt +6 -1
  24. package/package.json +2 -2
  25. package/skills/SKILL.md +6 -1
  26. package/templates/design-system-mantine/SOURCES.md +23 -0
  27. package/templates/design-system-mantine/package.json +16 -0
  28. package/templates/design-system-mantine/src/web/design-system/components/checkbox-input.tsx +19 -0
  29. package/templates/design-system-mantine/src/web/design-system/components/date-picker-input.tsx +22 -0
  30. package/templates/design-system-mantine/src/web/design-system/components/date-time-picker-input.tsx +32 -0
  31. package/templates/design-system-mantine/src/web/design-system/components/email-input.tsx +21 -0
  32. package/templates/design-system-mantine/src/web/design-system/components/field-frame.tsx +65 -0
  33. package/templates/design-system-mantine/src/web/design-system/components/file-input.tsx +32 -0
  34. package/templates/design-system-mantine/src/web/design-system/components/multi-select-input.tsx +28 -0
  35. package/templates/design-system-mantine/src/web/design-system/components/paginated-table.tsx +94 -0
  36. package/templates/design-system-mantine/src/web/design-system/components/password-input.tsx +20 -0
  37. package/templates/design-system-mantine/src/web/design-system/components/radio-input.tsx +34 -0
  38. package/templates/design-system-mantine/src/web/design-system/components/select-input.tsx +24 -0
  39. package/templates/design-system-mantine/src/web/design-system/components/submit-button.tsx +24 -0
  40. package/templates/design-system-mantine/src/web/design-system/components/switch-input.tsx +19 -0
  41. package/templates/design-system-mantine/src/web/design-system/components/text-input.tsx +21 -0
  42. package/templates/design-system-mantine/src/web/design-system/components/textarea-input.tsx +21 -0
  43. package/templates/design-system-mantine/src/web/design-system/components/time-picker-input.tsx +19 -0
  44. package/templates/design-system-mantine/src/web/design-system/confirm.ts +40 -0
  45. package/templates/design-system-mantine/src/web/design-system/hooks/use-control.ts +30 -0
  46. package/templates/design-system-mantine/src/web/design-system/hooks/use-date-control.ts +51 -0
  47. package/templates/design-system-mantine/src/web/design-system/hooks/use-file-control.ts +26 -0
  48. package/templates/design-system-mantine/src/web/design-system/hooks/use-select-control.ts +85 -0
  49. package/templates/design-system-mantine/src/web/design-system/hooks/use-submit-control.ts +13 -0
  50. package/templates/design-system-mantine/src/web/design-system/mantine-theme.ts +32 -0
  51. package/templates/design-system-mantine/src/web/design-system/notify.ts +72 -0
  52. package/templates/design-system-mantine/src/web/design-system/provider.tsx +28 -0
  53. package/templates/design-system-mantine/src/web/design-system/types/control-props.ts +46 -0
@@ -0,0 +1,94 @@
1
+ import { Center, Pagination, Stack, Table, Text } from "@mantine/core";
2
+ import type { ReactNode } from "react";
3
+
4
+ /** The `pagination` block of a Warlock paginated API response (`listEndpoint`, `paginate()`). */
5
+ export type PaginationInfo = { page: number; pages: number; total?: number; limit?: number };
6
+
7
+ export type PaginatedColumn<Row> = {
8
+ /** A stable key for the column. */
9
+ key: string;
10
+ header: ReactNode;
11
+ /** Render a cell; defaults to the row's `key` property as text. */
12
+ render?: (row: Row, index: number) => ReactNode;
13
+ align?: "start" | "center" | "end";
14
+ };
15
+
16
+ export type PaginatedTableProps<Row> = {
17
+ columns: readonly PaginatedColumn<Row>[];
18
+ rows: readonly Row[];
19
+ pagination: PaginationInfo;
20
+ onPageChange: (page: number) => void;
21
+ /** Reads a stable row key; defaults to the row's `id`, then its index. */
22
+ rowKey?: (row: Row, index: number) => string | number;
23
+ loading?: boolean;
24
+ emptyLabel?: ReactNode;
25
+ caption?: ReactNode;
26
+ };
27
+
28
+ function defaultKey(row: unknown, index: number): string | number {
29
+ const id = (row as { id?: unknown } | null)?.id;
30
+ return typeof id === "string" || typeof id === "number" ? id : index;
31
+ }
32
+
33
+ /**
34
+ * A Mantine `Table` with `Pagination`, driven by the response shape of a
35
+ * Warlock list endpoint: pass its items as `rows` and its `pagination` block
36
+ * as-is, then load the requested page in `onPageChange`.
37
+ */
38
+ export default function PaginatedTable<Row>({
39
+ columns,
40
+ rows,
41
+ pagination,
42
+ onPageChange,
43
+ rowKey = defaultKey,
44
+ loading = false,
45
+ emptyLabel = "Nothing to show",
46
+ caption,
47
+ }: PaginatedTableProps<Row>) {
48
+ return (
49
+ <Stack gap="md" aria-busy={loading}>
50
+ <Table.ScrollContainer minWidth={480}>
51
+ <Table striped highlightOnHover opacity={loading ? 0.6 : 1}>
52
+ {caption ? <Table.Caption>{caption}</Table.Caption> : null}
53
+ <Table.Thead>
54
+ <Table.Tr>
55
+ {columns.map((column) => (
56
+ <Table.Th key={column.key} ta={column.align}>
57
+ {column.header}
58
+ </Table.Th>
59
+ ))}
60
+ </Table.Tr>
61
+ </Table.Thead>
62
+ <Table.Tbody>
63
+ {rows.map((row, index) => (
64
+ <Table.Tr key={rowKey(row, index)}>
65
+ {columns.map((column) => (
66
+ <Table.Td key={column.key} ta={column.align}>
67
+ {column.render
68
+ ? column.render(row, index)
69
+ : String((row as Record<string, unknown>)[column.key] ?? "")}
70
+ </Table.Td>
71
+ ))}
72
+ </Table.Tr>
73
+ ))}
74
+ </Table.Tbody>
75
+ </Table>
76
+ </Table.ScrollContainer>
77
+ {rows.length === 0 ? (
78
+ <Text role="status" c="dimmed" ta="center">
79
+ {emptyLabel}
80
+ </Text>
81
+ ) : null}
82
+ {pagination.pages > 1 ? (
83
+ <Center>
84
+ <Pagination
85
+ total={pagination.pages}
86
+ value={pagination.page}
87
+ onChange={onPageChange}
88
+ disabled={loading}
89
+ />
90
+ </Center>
91
+ ) : null}
92
+ </Stack>
93
+ );
94
+ }
@@ -0,0 +1,20 @@
1
+ import { PasswordInput as MantinePasswordInput } from "@mantine/core";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import { frameProps } from "./field-frame";
5
+
6
+ export default function PasswordInput(props: ControlProps) {
7
+ const field = useControl(props, "password");
8
+ const { ref: bindRef } = field.binding;
9
+ return (
10
+ <MantinePasswordInput
11
+ {...frameProps(props, field.error, field.disabled, field.onBlur)}
12
+ ref={bindRef}
13
+ className={props.className}
14
+ value={typeof field.value === "string" ? field.value : ""}
15
+ onChange={(event) => field.changeValue(event.currentTarget.value)}
16
+ placeholder={props.placeholder}
17
+ autoComplete={props.autoComplete}
18
+ />
19
+ );
20
+ }
@@ -0,0 +1,34 @@
1
+ import { Radio } from "@mantine/core";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { RadioProps } from "../types/control-props";
4
+ import { frameProps } from "./field-frame";
5
+
6
+ export default function RadioInput(props: RadioProps) {
7
+ // One scalar for the whole group; the first enabled radio receives the focus ref.
8
+ const field = useControl(props);
9
+ const { ref: bindRef } = field.binding;
10
+ const firstEnabled = props.options.find((item) => !item.disabled);
11
+ return (
12
+ <Radio.Group
13
+ {...frameProps(props, field.error, field.disabled, field.onBlur)}
14
+ className={props.className}
15
+ value={typeof field.value === "string" ? field.value : ""}
16
+ onChange={field.changeValue}
17
+ readOnly={props.readOnly}
18
+ >
19
+ <div>
20
+ {props.options.map((option) => (
21
+ <Radio
22
+ key={option.value}
23
+ ref={option === firstEnabled ? bindRef : undefined}
24
+ id={`${props.id}-option-${encodeURIComponent(option.value)}`}
25
+ value={option.value}
26
+ label={option.label}
27
+ disabled={field.disabled || option.disabled}
28
+ mt="xs"
29
+ />
30
+ ))}
31
+ </div>
32
+ </Radio.Group>
33
+ );
34
+ }
@@ -0,0 +1,24 @@
1
+ import { Select } from "@mantine/core";
2
+ import { useSelectControl } from "../hooks/use-select-control";
3
+ import type { SelectProps } from "../types/control-props";
4
+ import { frameProps } from "./field-frame";
5
+
6
+ export default function SelectInput(props: SelectProps) {
7
+ const field = useSelectControl(props, false);
8
+ const { ref: bindRef } = field.binding;
9
+ return (
10
+ <Select
11
+ {...frameProps(props, field.error, field.disabled, field.onBlur)}
12
+ ref={bindRef}
13
+ className={props.className}
14
+ data={props.options.map((option) => ({ ...option }))}
15
+ value={field.selected[0] ?? null}
16
+ onChange={(value) => (value === null ? field.clear() : field.select(value))}
17
+ placeholder={props.placeholder}
18
+ searchable
19
+ clearable
20
+ nothingFoundMessage={props.emptyLabel ?? "No options found"}
21
+ clearButtonProps={{ "aria-label": props.clearLabel ?? "Clear" }}
22
+ />
23
+ );
24
+ }
@@ -0,0 +1,24 @@
1
+ import { Button } from "@mantine/core";
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
+ const { ref: bindRef } = field.binding;
9
+ return (
10
+ <FieldFrame {...props} label={null} error={field.error}>
11
+ <Button
12
+ ref={bindRef}
13
+ id={props.id}
14
+ name={props.name}
15
+ type="submit"
16
+ disabled={field.disabled}
17
+ loading={field.isSubmitting}
18
+ aria-busy={field.isSubmitting}
19
+ >
20
+ {props.label}
21
+ </Button>
22
+ </FieldFrame>
23
+ );
24
+ }
@@ -0,0 +1,19 @@
1
+ import { Switch } from "@mantine/core";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { CheckProps } from "../types/control-props";
4
+ import { frameProps } from "./field-frame";
5
+
6
+ export default function SwitchInput(props: CheckProps) {
7
+ const field = useControl(props, "checkbox");
8
+ const { ref: bindRef } = field.binding;
9
+ return (
10
+ <Switch
11
+ {...frameProps(props, field.error, field.disabled, field.onBlur)}
12
+ ref={bindRef}
13
+ className={props.className}
14
+ disabled={field.disabled || props.readOnly}
15
+ checked={Boolean(field.checked)}
16
+ onChange={(event) => field.setChecked(event.currentTarget.checked)}
17
+ />
18
+ );
19
+ }
@@ -0,0 +1,21 @@
1
+ import { TextInput as MantineTextInput } from "@mantine/core";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import { frameProps } from "./field-frame";
5
+
6
+ export default function TextInput(props: ControlProps) {
7
+ const field = useControl(props, "text");
8
+ const { ref: bindRef } = field.binding;
9
+ return (
10
+ <MantineTextInput
11
+ {...frameProps(props, field.error, field.disabled, field.onBlur)}
12
+ ref={bindRef}
13
+ className={props.className}
14
+ type="text"
15
+ value={typeof field.value === "string" ? field.value : ""}
16
+ onChange={(event) => field.changeValue(event.currentTarget.value)}
17
+ placeholder={props.placeholder}
18
+ autoComplete={props.autoComplete}
19
+ />
20
+ );
21
+ }
@@ -0,0 +1,21 @@
1
+ import { Textarea } from "@mantine/core";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import { frameProps } from "./field-frame";
5
+
6
+ export default function TextareaInput(props: ControlProps) {
7
+ const field = useControl(props, "textarea");
8
+ const { ref: bindRef } = field.binding;
9
+ return (
10
+ <Textarea
11
+ {...frameProps(props, field.error, field.disabled, field.onBlur)}
12
+ ref={bindRef}
13
+ className={props.className}
14
+ rows={props.rows}
15
+ value={typeof field.value === "string" ? field.value : ""}
16
+ onChange={(event) => field.changeValue(event.currentTarget.value)}
17
+ placeholder={props.placeholder}
18
+ autoComplete={props.autoComplete}
19
+ />
20
+ );
21
+ }
@@ -0,0 +1,19 @@
1
+ import { TimeInput } from "@mantine/dates";
2
+ import { useControl } from "../hooks/use-control";
3
+ import type { ControlProps } from "../types/control-props";
4
+ import { frameProps } from "./field-frame";
5
+
6
+ export default function TimePickerInput(props: ControlProps) {
7
+ const field = useControl(props, "time");
8
+ const { ref: bindRef } = field.binding;
9
+ return (
10
+ <TimeInput
11
+ {...frameProps(props, field.error, field.disabled, field.onBlur)}
12
+ ref={bindRef}
13
+ className={props.className}
14
+ value={typeof field.value === "string" ? field.value : ""}
15
+ onChange={(event) => field.changeValue(event.currentTarget.value)}
16
+ placeholder={props.placeholder}
17
+ />
18
+ );
19
+ }
@@ -0,0 +1,40 @@
1
+ import { modals } from "@mantine/modals";
2
+ import type { ReactNode } from "react";
3
+
4
+ export type ConfirmOptions = {
5
+ title?: ReactNode;
6
+ message: ReactNode;
7
+ confirmLabel?: string;
8
+ cancelLabel?: string;
9
+ /** Paints the confirm button red, for deletes and other destructive actions. */
10
+ danger?: boolean;
11
+ };
12
+
13
+ /**
14
+ * Ask the visitor to confirm an action. Resolves `true` on confirm and `false`
15
+ * on cancel or when the modal is dismissed. Needs `DesignSystemProvider`.
16
+ */
17
+ export function confirm(options: ConfirmOptions): Promise<boolean> {
18
+ return new Promise((resolve) => {
19
+ let settled = false;
20
+ const settle = (value: boolean) => {
21
+ if (settled) {
22
+ return;
23
+ }
24
+ settled = true;
25
+ resolve(value);
26
+ };
27
+ modals.openConfirmModal({
28
+ title: options.title ?? "Please confirm",
29
+ children: options.message,
30
+ labels: {
31
+ confirm: options.confirmLabel ?? "Confirm",
32
+ cancel: options.cancelLabel ?? "Cancel",
33
+ },
34
+ confirmProps: options.danger ? { color: "red" } : undefined,
35
+ onConfirm: () => settle(true),
36
+ onCancel: () => settle(false),
37
+ onClose: () => settle(false),
38
+ });
39
+ });
40
+ }
@@ -0,0 +1,30 @@
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 { inputRef, visibleElementRef } = control;
11
+ const error = props.error ?? control.error;
12
+ const describedBy =
13
+ [props.hint ? `${props.id}-hint` : "", error ? `${props.id}-error` : ""]
14
+ .filter(Boolean)
15
+ .join(" ") || undefined;
16
+ const binding = {
17
+ id: props.id,
18
+ name: props.name,
19
+ disabled: control.disabled,
20
+ onBlur: control.onBlur,
21
+ ref: (element: HTMLElement | null) => {
22
+ inputRef.current = element;
23
+ visibleElementRef.current = element;
24
+ },
25
+ "aria-invalid": Boolean(error),
26
+ "aria-required": props.required || undefined,
27
+ "aria-describedby": describedBy,
28
+ };
29
+ return { ...control, error, binding };
30
+ }
@@ -0,0 +1,51 @@
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)) {
10
+ return undefined;
11
+ }
12
+ const [year = 0, month = 0, day = 0] = value.split("-").map(Number);
13
+ const date = new Date(0);
14
+ date.setFullYear(year, month - 1, day);
15
+ date.setHours(12, 0, 0, 0);
16
+ return calendarDate(date) === value ? date : undefined;
17
+ }
18
+ export function useDateControl(props: DateProps, withTime = false) {
19
+ const field = useControl(props);
20
+ const [open, setOpen] = useState(false);
21
+ const value = typeof field.value === "string" ? field.value : "";
22
+ const [datePart = "", time = ""] = value.split("T");
23
+ return {
24
+ ...field,
25
+ open,
26
+ setOpen,
27
+ value,
28
+ time,
29
+ selected: parseCalendarDate(datePart),
30
+ select: (date: Date | undefined) => {
31
+ if (field.disabled || props.readOnly) {
32
+ return;
33
+ }
34
+ field.changeValue(date ? calendarDate(date) + (withTime ? `T${time || "00:00"}` : "") : "");
35
+ if (!withTime) {
36
+ setOpen(false);
37
+ }
38
+ },
39
+ changeTime: (next: string) => {
40
+ if (!field.disabled && !props.readOnly) {
41
+ field.changeValue(next && parseCalendarDate(datePart) ? `${datePart}T${next}` : "");
42
+ }
43
+ },
44
+ clear: () => {
45
+ if (!field.disabled && !props.readOnly) {
46
+ field.changeValue("");
47
+ }
48
+ setOpen(false);
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,26 @@
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) {
15
+ input.value = "";
16
+ }
17
+ };
18
+ const reset = field.formControl.onReset(clear);
19
+ const cleared = field.formControl.onClear(clear);
20
+ return () => {
21
+ reset.unsubscribe();
22
+ cleared.unsubscribe();
23
+ };
24
+ }, [field.formControl, field.inputRef]);
25
+ return field;
26
+ }
@@ -0,0 +1,85 @@
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) {
28
+ return;
29
+ }
30
+ field.changeValue(multiple ? toggleSelection(selected, value) : value);
31
+ if (!multiple) {
32
+ setOpen(false);
33
+ }
34
+ };
35
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
36
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
37
+ event.preventDefault();
38
+ const current = activeOption ? enabled.indexOf(activeOption) : 0;
39
+ const next =
40
+ event.key === "Home"
41
+ ? 0
42
+ : event.key === "End"
43
+ ? enabled.length - 1
44
+ : (current + (event.key === "ArrowDown" ? 1 : -1) + enabled.length) %
45
+ Math.max(1, enabled.length);
46
+ setActive(Math.max(0, next));
47
+ const option = enabled[Math.max(0, next)];
48
+ if (option) {
49
+ document
50
+ .getElementById(`${props.id}-option-${encodeURIComponent(option.value)}`)
51
+ ?.scrollIntoView({ block: "nearest" });
52
+ }
53
+ } else if (
54
+ (event.key === "Enter" || (multiple && event.key === " " && search === "")) &&
55
+ activeOption
56
+ ) {
57
+ event.preventDefault();
58
+ select(activeOption.value);
59
+ }
60
+ };
61
+ return {
62
+ ...field,
63
+ open,
64
+ setOpen: (next: boolean) => {
65
+ setOpen(next && !field.disabled && !props.readOnly);
66
+ setSearch("");
67
+ setActive(0);
68
+ },
69
+ search,
70
+ setSearch: (next: string) => {
71
+ setSearch(next);
72
+ setActive(0);
73
+ },
74
+ selected,
75
+ filtered,
76
+ activeOption,
77
+ select,
78
+ onKeyDown,
79
+ clear: () => {
80
+ if (!field.disabled && !props.readOnly) {
81
+ field.changeValue(multiple ? [] : "");
82
+ }
83
+ },
84
+ };
85
+ }
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ import { createTheme, type MantineColorScheme } from "@mantine/core";
2
+
3
+ /**
4
+ * The design tokens of this app. Edit this file; everything Mantine renders
5
+ * reads it through `DesignSystemProvider`.
6
+ *
7
+ * `primaryColor` takes any key of Mantine's palette (`blue`, `violet`, `teal`
8
+ * and so on) or a custom palette you add under `colors`.
9
+ */
10
+ export const theme = createTheme({
11
+ primaryColor: "blue",
12
+ defaultRadius: "md",
13
+ fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
14
+ fontFamilyMonospace: 'ui-monospace, "Cascadia Code", Consolas, "Liberation Mono", monospace',
15
+ headings: {
16
+ fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
17
+ },
18
+ spacing: {
19
+ xs: "0.625rem",
20
+ sm: "0.75rem",
21
+ md: "1rem",
22
+ lg: "1.25rem",
23
+ xl: "2rem",
24
+ },
25
+ });
26
+
27
+ /**
28
+ * `auto` follows the visitor's system preference. The same value feeds the
29
+ * `ColorSchemeScript` in the root and the provider, so the first paint and the
30
+ * hydrated page agree.
31
+ */
32
+ export const defaultColorScheme: MantineColorScheme = "auto";
@@ -0,0 +1,72 @@
1
+ import { notifications } from "@mantine/notifications";
2
+ import type { ReactNode } from "react";
3
+
4
+ type Message = ReactNode;
5
+
6
+ function show(color: string, message: Message, title?: ReactNode) {
7
+ return notifications.show({ color, message, title });
8
+ }
9
+
10
+ /** Toast helpers on top of `@mantine/notifications`. Call them from event handlers and callbacks. */
11
+ export const notify = {
12
+ success: (message: Message, title?: ReactNode) => show("green", message, title),
13
+ error: (message: Message, title?: ReactNode) => show("red", message, title),
14
+ info: (message: Message, title?: ReactNode) => show("blue", message, title),
15
+ warning: (message: Message, title?: ReactNode) => show("yellow", message, title),
16
+ };
17
+
18
+ /** The first human-readable message in an HTTP result, a page-action payload or an Error. */
19
+ export function messageFrom(value: unknown): string | undefined {
20
+ if (!value || typeof value !== "object") {
21
+ return typeof value === "string" ? value : undefined;
22
+ }
23
+ const source = value as {
24
+ message?: unknown;
25
+ formErrors?: unknown;
26
+ error?: { body?: unknown; message?: unknown };
27
+ data?: unknown;
28
+ };
29
+ if (Array.isArray(source.formErrors) && typeof source.formErrors[0] === "string") {
30
+ return source.formErrors[0];
31
+ }
32
+ const body = source.error?.body ?? source.data;
33
+ const nested =
34
+ body && typeof body === "object" ? (body as { message?: unknown }).message : undefined;
35
+ for (const candidate of [nested, source.error?.message, source.message]) {
36
+ if (typeof candidate === "string" && candidate !== "") {
37
+ return candidate;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ type SubmitMessages = {
44
+ success: Message | ((result: unknown) => Message);
45
+ error?: Message | ((result: unknown) => Message);
46
+ };
47
+
48
+ /**
49
+ * Spread the result into `useSubmitForm` or `useSubmitAction` to show a toast
50
+ * when the submission succeeds or fails:
51
+ *
52
+ * `useSubmitForm({ route: "contact.send", ...notifyOnSubmit({ success: "Sent" }) })`
53
+ *
54
+ * Without an `error` message the server's own message is shown, falling back to
55
+ * a generic line.
56
+ */
57
+ export function notifyOnSubmit(messages: SubmitMessages) {
58
+ const resolve = (message: Message | ((result: unknown) => Message), result: unknown) =>
59
+ typeof message === "function" ? (message as (result: unknown) => Message)(result) : message;
60
+ return {
61
+ onSuccess: (result: unknown) => {
62
+ notify.success(resolve(messages.success, result));
63
+ },
64
+ onError: (result: unknown) => {
65
+ notify.error(
66
+ messages.error !== undefined
67
+ ? resolve(messages.error, result)
68
+ : (messageFrom(result) ?? "Something went wrong. Please try again."),
69
+ );
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,28 @@
1
+ import { ColorSchemeScript, MantineProvider } from "@mantine/core";
2
+ import { ModalsProvider } from "@mantine/modals";
3
+ import { Notifications } from "@mantine/notifications";
4
+ import type { ReactNode } from "react";
5
+ import { defaultColorScheme, theme } from "./mantine-theme";
6
+
7
+ /**
8
+ * Mounts Mantine's theme, the modals manager and the notifications host around
9
+ * the page. Render it once in `src/web/root.tsx`, around `#vessel`.
10
+ */
11
+ export default function DesignSystemProvider({ children }: { children: ReactNode }) {
12
+ return (
13
+ <MantineProvider theme={theme} defaultColorScheme={defaultColorScheme}>
14
+ <ModalsProvider>
15
+ <Notifications />
16
+ {children}
17
+ </ModalsProvider>
18
+ </MantineProvider>
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Sets the stored or system colour scheme on `<html>` before first paint, so a
24
+ * dark-mode visitor never sees a light flash. Render it inside `<head>`.
25
+ */
26
+ export function DesignSystemColorScheme({ nonce }: { nonce?: string }) {
27
+ return <ColorSchemeScript defaultColorScheme={defaultColorScheme} nonce={nonce} />;
28
+ }