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.
- package/CHANGELOG.md +6 -0
- package/esm/commands/create-new-app/index.mjs +12 -7
- package/esm/commands/create-new-app/index.mjs.map +1 -1
- package/esm/commands/create-new-app/types.d.mts +2 -0
- package/esm/commands/create-warlock-app/index.mjs +5 -2
- package/esm/commands/create-warlock-app/index.mjs.map +1 -1
- package/esm/features/features-map.mjs +6 -0
- package/esm/features/features-map.mjs.map +1 -1
- package/esm/features/ui-kit.mjs +39 -0
- package/esm/features/ui-kit.mjs.map +1 -0
- package/esm/flags/definitions.mjs +5 -0
- package/esm/flags/definitions.mjs.map +1 -1
- package/esm/flags/seed-from-flags.mjs +7 -2
- package/esm/flags/seed-from-flags.mjs.map +1 -1
- package/esm/helpers/app.mjs +7 -2
- package/esm/helpers/app.mjs.map +1 -1
- package/esm/index.mjs +5 -1
- package/esm/index.mjs.map +1 -1
- package/esm/prompts/ask-stack.mjs +5 -0
- package/esm/prompts/ask-stack.mjs.map +1 -1
- package/esm/prompts/ask-ui-kit.mjs +27 -0
- package/esm/prompts/ask-ui-kit.mjs.map +1 -0
- package/llms-full.txt +6 -1
- package/package.json +2 -2
- package/skills/SKILL.md +6 -1
- package/templates/design-system-mantine/SOURCES.md +23 -0
- package/templates/design-system-mantine/package.json +16 -0
- package/templates/design-system-mantine/src/web/design-system/components/checkbox-input.tsx +19 -0
- package/templates/design-system-mantine/src/web/design-system/components/date-picker-input.tsx +22 -0
- package/templates/design-system-mantine/src/web/design-system/components/date-time-picker-input.tsx +32 -0
- package/templates/design-system-mantine/src/web/design-system/components/email-input.tsx +21 -0
- package/templates/design-system-mantine/src/web/design-system/components/field-frame.tsx +65 -0
- package/templates/design-system-mantine/src/web/design-system/components/file-input.tsx +32 -0
- package/templates/design-system-mantine/src/web/design-system/components/multi-select-input.tsx +28 -0
- package/templates/design-system-mantine/src/web/design-system/components/paginated-table.tsx +94 -0
- package/templates/design-system-mantine/src/web/design-system/components/password-input.tsx +20 -0
- package/templates/design-system-mantine/src/web/design-system/components/radio-input.tsx +34 -0
- package/templates/design-system-mantine/src/web/design-system/components/select-input.tsx +24 -0
- package/templates/design-system-mantine/src/web/design-system/components/submit-button.tsx +24 -0
- package/templates/design-system-mantine/src/web/design-system/components/switch-input.tsx +19 -0
- package/templates/design-system-mantine/src/web/design-system/components/text-input.tsx +21 -0
- package/templates/design-system-mantine/src/web/design-system/components/textarea-input.tsx +21 -0
- package/templates/design-system-mantine/src/web/design-system/components/time-picker-input.tsx +19 -0
- package/templates/design-system-mantine/src/web/design-system/confirm.ts +40 -0
- package/templates/design-system-mantine/src/web/design-system/hooks/use-control.ts +30 -0
- package/templates/design-system-mantine/src/web/design-system/hooks/use-date-control.ts +51 -0
- package/templates/design-system-mantine/src/web/design-system/hooks/use-file-control.ts +26 -0
- package/templates/design-system-mantine/src/web/design-system/hooks/use-select-control.ts +85 -0
- package/templates/design-system-mantine/src/web/design-system/hooks/use-submit-control.ts +13 -0
- package/templates/design-system-mantine/src/web/design-system/mantine-theme.ts +32 -0
- package/templates/design-system-mantine/src/web/design-system/notify.ts +72 -0
- package/templates/design-system-mantine/src/web/design-system/provider.tsx +28 -0
- 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
|
+
}
|
package/templates/design-system-mantine/src/web/design-system/components/time-picker-input.tsx
ADDED
|
@@ -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
|
+
}
|