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.
Files changed (59) hide show
  1. package/CHANGELOG.md +14 -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 +8 -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/src/web/design-system/components/radio-input.tsx +2 -1
  27. package/templates/design-system/src/web/design-system/hooks/use-calendar-day-button.ts +3 -1
  28. package/templates/design-system/src/web/design-system/hooks/use-control.ts +3 -2
  29. package/templates/design-system/src/web/design-system/hooks/use-date-control.ts +14 -5
  30. package/templates/design-system/src/web/design-system/hooks/use-file-control.ts +3 -1
  31. package/templates/design-system/src/web/design-system/hooks/use-select-control.ts +11 -4
  32. package/templates/design-system-mantine/SOURCES.md +23 -0
  33. package/templates/design-system-mantine/package.json +16 -0
  34. package/templates/design-system-mantine/src/web/design-system/components/checkbox-input.tsx +19 -0
  35. package/templates/design-system-mantine/src/web/design-system/components/date-picker-input.tsx +22 -0
  36. package/templates/design-system-mantine/src/web/design-system/components/date-time-picker-input.tsx +32 -0
  37. package/templates/design-system-mantine/src/web/design-system/components/email-input.tsx +21 -0
  38. package/templates/design-system-mantine/src/web/design-system/components/field-frame.tsx +65 -0
  39. package/templates/design-system-mantine/src/web/design-system/components/file-input.tsx +32 -0
  40. package/templates/design-system-mantine/src/web/design-system/components/multi-select-input.tsx +28 -0
  41. package/templates/design-system-mantine/src/web/design-system/components/paginated-table.tsx +94 -0
  42. package/templates/design-system-mantine/src/web/design-system/components/password-input.tsx +20 -0
  43. package/templates/design-system-mantine/src/web/design-system/components/radio-input.tsx +34 -0
  44. package/templates/design-system-mantine/src/web/design-system/components/select-input.tsx +24 -0
  45. package/templates/design-system-mantine/src/web/design-system/components/submit-button.tsx +24 -0
  46. package/templates/design-system-mantine/src/web/design-system/components/switch-input.tsx +19 -0
  47. package/templates/design-system-mantine/src/web/design-system/components/text-input.tsx +21 -0
  48. package/templates/design-system-mantine/src/web/design-system/components/textarea-input.tsx +21 -0
  49. package/templates/design-system-mantine/src/web/design-system/components/time-picker-input.tsx +19 -0
  50. package/templates/design-system-mantine/src/web/design-system/confirm.ts +40 -0
  51. package/templates/design-system-mantine/src/web/design-system/hooks/use-control.ts +30 -0
  52. package/templates/design-system-mantine/src/web/design-system/hooks/use-date-control.ts +51 -0
  53. package/templates/design-system-mantine/src/web/design-system/hooks/use-file-control.ts +26 -0
  54. package/templates/design-system-mantine/src/web/design-system/hooks/use-select-control.ts +85 -0
  55. package/templates/design-system-mantine/src/web/design-system/hooks/use-submit-control.ts +13 -0
  56. package/templates/design-system-mantine/src/web/design-system/mantine-theme.ts +32 -0
  57. package/templates/design-system-mantine/src/web/design-system/notify.ts +72 -0
  58. package/templates/design-system-mantine/src/web/design-system/provider.tsx +28 -0
  59. 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
+ }
@@ -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
+ }
@@ -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
+ }