create-warlock 5.40.0 → 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 +14 -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 +8 -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/src/web/design-system/components/radio-input.tsx +2 -1
- package/templates/design-system/src/web/design-system/hooks/use-calendar-day-button.ts +3 -1
- package/templates/design-system/src/web/design-system/hooks/use-control.ts +3 -2
- package/templates/design-system/src/web/design-system/hooks/use-date-control.ts +14 -5
- package/templates/design-system/src/web/design-system/hooks/use-file-control.ts +3 -1
- package/templates/design-system/src/web/design-system/hooks/use-select-control.ts +11 -4
- 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,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 EmailInput(props: ControlProps) {
|
|
7
|
+
const field = useControl(props, "email");
|
|
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="email"
|
|
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,65 @@
|
|
|
1
|
+
import { Input } from "@mantine/core";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { ControlProps } from "../types/control-props";
|
|
4
|
+
|
|
5
|
+
type FrameProps = Pick<ControlProps, "id" | "label" | "hint" | "error" | "dir" | "className"> & {
|
|
6
|
+
required?: boolean;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The label, hint and error frame around a control that has no Mantine wrapper
|
|
12
|
+
* of its own. Mantine's own inputs take `label`, `description` and `error`
|
|
13
|
+
* directly, so they use `frameProps` instead and skip this wrapper.
|
|
14
|
+
*/
|
|
15
|
+
export default function FieldFrame({
|
|
16
|
+
id,
|
|
17
|
+
label,
|
|
18
|
+
hint,
|
|
19
|
+
error,
|
|
20
|
+
children,
|
|
21
|
+
dir,
|
|
22
|
+
className,
|
|
23
|
+
required,
|
|
24
|
+
}: FrameProps) {
|
|
25
|
+
return (
|
|
26
|
+
<Input.Wrapper
|
|
27
|
+
id={id}
|
|
28
|
+
dir={dir}
|
|
29
|
+
className={className}
|
|
30
|
+
label={label ?? undefined}
|
|
31
|
+
description={hint}
|
|
32
|
+
error={error}
|
|
33
|
+
withAsterisk={required}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</Input.Wrapper>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The props every Mantine input shares, derived from the contract props and the
|
|
42
|
+
* value returned by `useControl`. Validation errors reach the control through
|
|
43
|
+
* Mantine's `error` prop, so Mantine owns the invalid styling and the
|
|
44
|
+
* `aria-invalid` / `aria-describedby` wiring.
|
|
45
|
+
*/
|
|
46
|
+
export function frameProps(
|
|
47
|
+
props: Pick<ControlProps, "id" | "name" | "label" | "hint" | "dir" | "required" | "readOnly">,
|
|
48
|
+
error: ReactNode,
|
|
49
|
+
disabled: boolean | undefined,
|
|
50
|
+
onBlur: (() => unknown) | undefined,
|
|
51
|
+
) {
|
|
52
|
+
return {
|
|
53
|
+
id: props.id,
|
|
54
|
+
name: props.name,
|
|
55
|
+
label: props.label ?? undefined,
|
|
56
|
+
description: props.hint,
|
|
57
|
+
error,
|
|
58
|
+
withAsterisk: props.required,
|
|
59
|
+
disabled,
|
|
60
|
+
readOnly: props.readOnly,
|
|
61
|
+
dir: props.dir,
|
|
62
|
+
onBlur,
|
|
63
|
+
"aria-required": props.required || undefined,
|
|
64
|
+
} as const;
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { FileInput as MantineFileInput } from "@mantine/core";
|
|
2
|
+
import { useFileControl } from "../hooks/use-file-control";
|
|
3
|
+
import type { ControlProps } from "../types/control-props";
|
|
4
|
+
import { frameProps } from "./field-frame";
|
|
5
|
+
|
|
6
|
+
export default function FileInput(props: ControlProps) {
|
|
7
|
+
const field = useFileControl(props);
|
|
8
|
+
const { ref: bindRef } = field.binding;
|
|
9
|
+
const files = Array.isArray(field.value)
|
|
10
|
+
? field.value
|
|
11
|
+
: field.value instanceof File
|
|
12
|
+
? field.value
|
|
13
|
+
: null;
|
|
14
|
+
return (
|
|
15
|
+
<MantineFileInput
|
|
16
|
+
{...frameProps(props, field.error, field.disabled, field.onBlur)}
|
|
17
|
+
ref={bindRef}
|
|
18
|
+
className={props.className}
|
|
19
|
+
disabled={field.disabled || props.readOnly}
|
|
20
|
+
accept={props.accept}
|
|
21
|
+
multiple={props.multiple}
|
|
22
|
+
placeholder={props.placeholder}
|
|
23
|
+
clearable
|
|
24
|
+
value={files as never}
|
|
25
|
+
onChange={(value: File | File[] | null) =>
|
|
26
|
+
field.changeValue(
|
|
27
|
+
props.multiple ? ((value as File[] | null) ?? []) : ((value as File | null) ?? ""),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
package/templates/design-system-mantine/src/web/design-system/components/multi-select-input.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { MultiSelect } 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 MultiSelectInput(props: SelectProps) {
|
|
7
|
+
const field = useSelectControl(props, true);
|
|
8
|
+
const { ref: bindRef } = field.binding;
|
|
9
|
+
return (
|
|
10
|
+
<MultiSelect
|
|
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}
|
|
16
|
+
onChange={(values) => {
|
|
17
|
+
if (!field.disabled && !props.readOnly) {
|
|
18
|
+
field.changeValue(values);
|
|
19
|
+
}
|
|
20
|
+
}}
|
|
21
|
+
placeholder={props.placeholder}
|
|
22
|
+
searchable
|
|
23
|
+
clearable
|
|
24
|
+
nothingFoundMessage={props.emptyLabel ?? "No options found"}
|
|
25
|
+
clearButtonProps={{ "aria-label": props.clearLabel ?? "Clear" }}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -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
|
+
}
|