@olenbetong/appframe-ds 0.2.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 +31 -0
- package/package.json +46 -0
- package/src/AfLookup/BoundLookup.tsx +35 -0
- package/src/AfLookup/Combobox.css +78 -0
- package/src/AfLookup/Combobox.tsx +233 -0
- package/src/AfLookup/Lookup.css +104 -0
- package/src/AfLookup/Lookup.tsx +264 -0
- package/src/AfLookup/index.ts +3 -0
- package/src/binding/BoundCheckbox.tsx +31 -0
- package/src/binding/BoundDatePicker.tsx +32 -0
- package/src/binding/BoundDateTimePicker.tsx +37 -0
- package/src/binding/BoundInput.tsx +26 -0
- package/src/binding/BoundNumericTextField.tsx +102 -0
- package/src/binding/BoundSelect.tsx +31 -0
- package/src/binding/BoundSwitch.tsx +30 -0
- package/src/binding/BoundTextField.tsx +41 -0
- package/src/binding/CancelButton.tsx +27 -0
- package/src/binding/CancelIconButton.tsx +30 -0
- package/src/binding/DataEditToolbar.tsx +93 -0
- package/src/binding/DataObjectSelect.tsx +121 -0
- package/src/binding/DeleteButton.tsx +31 -0
- package/src/binding/DeleteIconButton.tsx +34 -0
- package/src/binding/RefreshButton.tsx +27 -0
- package/src/binding/RefreshIconButton.tsx +32 -0
- package/src/binding/RefreshRowIconButton.tsx +32 -0
- package/src/binding/SaveButton.tsx +27 -0
- package/src/binding/SaveIconButton.tsx +31 -0
- package/src/binding/index.ts +19 -0
- package/src/index.ts +2 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import "./Lookup.css";
|
|
2
|
+
|
|
3
|
+
import { Button, Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
|
|
4
|
+
import ClearIcon from "@mui/icons-material/Close";
|
|
5
|
+
import SearchIcon from "@mui/icons-material/Search";
|
|
6
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
7
|
+
|
|
8
|
+
import clsx from "clsx";
|
|
9
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
10
|
+
import { createPortal, flushSync } from "react-dom";
|
|
11
|
+
|
|
12
|
+
import { Combobox, type ComboboxProps } from "./Combobox.js";
|
|
13
|
+
|
|
14
|
+
export interface AfLookupProps<T extends Record<string, unknown>> extends Omit<ComboboxProps<T>, "value"> {
|
|
15
|
+
/**
|
|
16
|
+
* Allows the user to enter a value manually that might not be in the list.
|
|
17
|
+
*/
|
|
18
|
+
editable?: boolean;
|
|
19
|
+
fullWidth?: boolean;
|
|
20
|
+
label?: string;
|
|
21
|
+
value?: string | null;
|
|
22
|
+
onChange?: (item: T | null) => void;
|
|
23
|
+
slotProps?: {
|
|
24
|
+
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
25
|
+
textField?: Partial<TextfieldProps>;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LookupProps<T extends Record<string, unknown>> extends Omit<ComboboxProps<T>, "value"> {
|
|
30
|
+
slotProps?: {
|
|
31
|
+
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
32
|
+
};
|
|
33
|
+
getAnchorElement?: () => Element | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useLookup<T extends Record<string, unknown>>({
|
|
37
|
+
getAnchorElement,
|
|
38
|
+
slotProps,
|
|
39
|
+
...props
|
|
40
|
+
}: LookupProps<T>) {
|
|
41
|
+
let [open, setOpen] = useState(false);
|
|
42
|
+
let anchorName = `--anchor-${CSS.escape(useId())}`;
|
|
43
|
+
let dialogRef = useRef<HTMLDialogElement>(null);
|
|
44
|
+
let inputId = useId();
|
|
45
|
+
let resetManualAnchor = useCallback(() => {
|
|
46
|
+
let $dialog = dialogRef.current;
|
|
47
|
+
if ($dialog) {
|
|
48
|
+
$dialog.style.removeProperty("--lookup-anchor-inline-start");
|
|
49
|
+
$dialog.style.removeProperty("--lookup-anchor-block-start");
|
|
50
|
+
delete $dialog.dataset.anchor;
|
|
51
|
+
}
|
|
52
|
+
}, []);
|
|
53
|
+
let updateManualAnchor = useCallback(() => {
|
|
54
|
+
if (typeof window === "undefined") {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!window.matchMedia?.("(pointer: fine)").matches) {
|
|
58
|
+
resetManualAnchor();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
let anchorElement = getAnchorElement?.() ?? null;
|
|
62
|
+
let $dialog = dialogRef.current;
|
|
63
|
+
if (!$dialog || !anchorElement) {
|
|
64
|
+
resetManualAnchor();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
let rect = anchorElement.getBoundingClientRect();
|
|
68
|
+
let dialogHeight = $dialog.offsetHeight || 0;
|
|
69
|
+
let dialogWidth = $dialog.offsetWidth || 0;
|
|
70
|
+
let top = rect.bottom + 8;
|
|
71
|
+
let availableBelow = window.innerHeight - rect.bottom - 16;
|
|
72
|
+
if (availableBelow < dialogHeight) {
|
|
73
|
+
top = Math.max(16, rect.top - dialogHeight - 8);
|
|
74
|
+
}
|
|
75
|
+
let left = rect.left;
|
|
76
|
+
let minInline = 16;
|
|
77
|
+
let maxInline = window.innerWidth - dialogWidth - 16;
|
|
78
|
+
if (Number.isFinite(maxInline)) {
|
|
79
|
+
left = Math.min(Math.max(left, minInline), Math.max(minInline, maxInline));
|
|
80
|
+
}
|
|
81
|
+
$dialog.style.setProperty("--lookup-anchor-inline-start", `${Math.round(left)}px`);
|
|
82
|
+
$dialog.style.setProperty("--lookup-anchor-block-start", `${Math.round(top)}px`);
|
|
83
|
+
$dialog.dataset.anchor = "manual";
|
|
84
|
+
}, [getAnchorElement, resetManualAnchor]);
|
|
85
|
+
|
|
86
|
+
function handleOpen() {
|
|
87
|
+
let $dialog = dialogRef.current;
|
|
88
|
+
|
|
89
|
+
if ($dialog) {
|
|
90
|
+
flushSync(() => {
|
|
91
|
+
$dialog.showModal();
|
|
92
|
+
setOpen(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
$dialog.querySelector<HTMLInputElement>('input[type="search"]')?.focus();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
|
100
|
+
if (event.key === "ArrowDown" || event.key === "F4") {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
event.stopPropagation();
|
|
103
|
+
handleOpen();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let handleClose = useCallback(() => {
|
|
108
|
+
setOpen(false);
|
|
109
|
+
document.getElementById(inputId)?.focus();
|
|
110
|
+
}, [inputId]);
|
|
111
|
+
|
|
112
|
+
function handleChange(item: T | null) {
|
|
113
|
+
props.onChange?.(item);
|
|
114
|
+
dialogRef.current?.close();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (!open) {
|
|
119
|
+
resetManualAnchor();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let frame = requestAnimationFrame(() => {
|
|
123
|
+
updateManualAnchor();
|
|
124
|
+
});
|
|
125
|
+
updateManualAnchor();
|
|
126
|
+
let cleanupTargets: Array<
|
|
127
|
+
[EventTarget, string, EventListenerOrEventListenerObject, AddEventListenerOptions | boolean | undefined]
|
|
128
|
+
> = [];
|
|
129
|
+
let listener = () => updateManualAnchor();
|
|
130
|
+
cleanupTargets.push([window, "resize", listener, undefined]);
|
|
131
|
+
cleanupTargets.push([window, "scroll", listener, true]);
|
|
132
|
+
let anchorElement = getAnchorElement?.() ?? null;
|
|
133
|
+
let scrollParent = anchorElement?.closest<HTMLElement>("[data-virtualized-scroller]") ?? null;
|
|
134
|
+
if (scrollParent) {
|
|
135
|
+
cleanupTargets.push([scrollParent, "scroll", listener, { passive: true }]);
|
|
136
|
+
}
|
|
137
|
+
for (let [target, type, handler, options] of cleanupTargets) {
|
|
138
|
+
target.addEventListener(type, handler, options);
|
|
139
|
+
}
|
|
140
|
+
return () => {
|
|
141
|
+
cancelAnimationFrame(frame);
|
|
142
|
+
for (let [target, type, handler, options] of cleanupTargets) {
|
|
143
|
+
target.removeEventListener(type, handler, options);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}, [getAnchorElement, open, resetManualAnchor, updateManualAnchor]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
let $dialog = dialogRef.current;
|
|
150
|
+
if ($dialog) {
|
|
151
|
+
let controller = new AbortController();
|
|
152
|
+
let signal = controller.signal;
|
|
153
|
+
let closeEvent = () => {
|
|
154
|
+
// Wait for transition to finish before closing
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
handleClose();
|
|
157
|
+
}, 150);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
$dialog.addEventListener("close", closeEvent, { signal });
|
|
161
|
+
$dialog.addEventListener("cancel", closeEvent, { signal });
|
|
162
|
+
$dialog.addEventListener(
|
|
163
|
+
"click",
|
|
164
|
+
(event) => {
|
|
165
|
+
let rect = $dialog.getBoundingClientRect();
|
|
166
|
+
if (
|
|
167
|
+
rect.left > event.clientX ||
|
|
168
|
+
rect.right < event.clientX ||
|
|
169
|
+
rect.top > event.clientY ||
|
|
170
|
+
rect.bottom < event.clientY
|
|
171
|
+
) {
|
|
172
|
+
$dialog.close();
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
{ signal },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return () => {
|
|
179
|
+
controller.abort();
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}, [handleClose]);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
open,
|
|
186
|
+
openDialog: handleOpen,
|
|
187
|
+
onKeyDown: handleKeyDown,
|
|
188
|
+
onChange: handleChange,
|
|
189
|
+
dialogRef,
|
|
190
|
+
anchorName,
|
|
191
|
+
inputId,
|
|
192
|
+
dialog: createPortal(
|
|
193
|
+
<dialog
|
|
194
|
+
ref={dialogRef}
|
|
195
|
+
className={clsx("ObLookup-dialog", slotProps?.dialog?.className)}
|
|
196
|
+
onKeyDown={(evt) => evt.stopPropagation()}
|
|
197
|
+
style={{ positionAnchor: anchorName, ...slotProps?.dialog?.style } as React.CSSProperties}
|
|
198
|
+
>
|
|
199
|
+
{open && <Combobox {...props} onClose={() => dialogRef.current?.close()} onChange={handleChange} />}
|
|
200
|
+
</dialog>,
|
|
201
|
+
document.body,
|
|
202
|
+
inputId,
|
|
203
|
+
),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function AfLookup<T extends Record<string, unknown>>({
|
|
208
|
+
fullWidth = false,
|
|
209
|
+
label,
|
|
210
|
+
slotProps,
|
|
211
|
+
value,
|
|
212
|
+
...props
|
|
213
|
+
}: AfLookupProps<T>) {
|
|
214
|
+
let { inputId, open: _open, dialog, anchorName, openDialog, onKeyDown } = useLookup<T>(props);
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div style={{ anchorName } as React.CSSProperties}>
|
|
218
|
+
<div style={{ position: "relative", display: fullWidth ? "block" : "inline-block" }}>
|
|
219
|
+
<Textfield
|
|
220
|
+
id={inputId}
|
|
221
|
+
value={value ?? ""}
|
|
222
|
+
label={label}
|
|
223
|
+
readOnly
|
|
224
|
+
onClick={openDialog}
|
|
225
|
+
onKeyDown={onKeyDown}
|
|
226
|
+
className={clsx(slotProps?.textField?.className, "ObLookup-input")}
|
|
227
|
+
{...(slotProps?.textField as any)}
|
|
228
|
+
style={{ width: fullWidth ? "100%" : undefined, ...slotProps?.textField?.style }}
|
|
229
|
+
/>
|
|
230
|
+
<div
|
|
231
|
+
style={{
|
|
232
|
+
position: "absolute",
|
|
233
|
+
right: 0,
|
|
234
|
+
top: "50%",
|
|
235
|
+
transform: "translateY(-50%)",
|
|
236
|
+
display: "flex",
|
|
237
|
+
alignItems: "center",
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
{props.nullable && value && props.onChange && (
|
|
241
|
+
<Button
|
|
242
|
+
icon
|
|
243
|
+
variant="tertiary"
|
|
244
|
+
data-size="sm"
|
|
245
|
+
aria-label={getLocalizedString("Clear")}
|
|
246
|
+
onKeyDown={(evt) => evt.stopPropagation()}
|
|
247
|
+
onClick={(evt) => {
|
|
248
|
+
evt.stopPropagation();
|
|
249
|
+
props.onChange?.(null);
|
|
250
|
+
document.getElementById(inputId)?.focus();
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<ClearIcon />
|
|
254
|
+
</Button>
|
|
255
|
+
)}
|
|
256
|
+
<Button icon variant="tertiary" data-size="sm" aria-label={getLocalizedString("Search")} onClick={openDialog}>
|
|
257
|
+
<SearchIcon />
|
|
258
|
+
</Button>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
{dialog}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Checkbox, type CheckboxProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
|
|
5
|
+
export type BoundCheckboxProps = Omit<CheckboxProps, "onChange" | "onKeyDown"> & {
|
|
6
|
+
field: string;
|
|
7
|
+
onChange?: (evt: React.ChangeEvent<HTMLInputElement>) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const BoundCheckbox = forwardRef<HTMLInputElement, BoundCheckboxProps>(function BoundCheckbox(
|
|
11
|
+
{ field, onChange: onChangeProp, checked: _checked, ...props },
|
|
12
|
+
ref,
|
|
13
|
+
) {
|
|
14
|
+
let { value, setValue, onKeyDown } = useField<any, any>(field);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Checkbox
|
|
18
|
+
checked={value ?? false}
|
|
19
|
+
onChange={(evt) => {
|
|
20
|
+
onChangeProp?.(evt);
|
|
21
|
+
if (!evt.defaultPrevented) {
|
|
22
|
+
setValue(evt.target.checked);
|
|
23
|
+
}
|
|
24
|
+
}}
|
|
25
|
+
onKeyDown={(evt) => onKeyDown(evt as any)}
|
|
26
|
+
ref={ref}
|
|
27
|
+
name={field}
|
|
28
|
+
{...(props as any)}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
|
|
5
|
+
export type BoundDatePickerProps = Omit<TextfieldProps, "value" | "onChange" | "onKeyDown" | "type"> & {
|
|
6
|
+
field: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const BoundDatePicker = forwardRef<HTMLInputElement, BoundDatePickerProps>(function BoundDatePicker(
|
|
10
|
+
{ field, ...props },
|
|
11
|
+
ref,
|
|
12
|
+
) {
|
|
13
|
+
let { error, value, onChange, onKeyDown } = useField<any, any>(field);
|
|
14
|
+
|
|
15
|
+
let inputValue = value;
|
|
16
|
+
if (value instanceof Date) {
|
|
17
|
+
inputValue = value.toISOString().split("T")[0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Textfield
|
|
22
|
+
type="date"
|
|
23
|
+
name={field}
|
|
24
|
+
error={error ?? undefined}
|
|
25
|
+
{...(props as any)}
|
|
26
|
+
value={inputValue ?? ""}
|
|
27
|
+
onChange={(evt) => onChange(evt as any)}
|
|
28
|
+
onKeyDown={(evt) => onKeyDown(evt as any)}
|
|
29
|
+
ref={ref}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
|
|
5
|
+
export type BoundDateTimePickerProps = Omit<TextfieldProps, "value" | "onChange" | "onKeyDown" | "type"> & {
|
|
6
|
+
field: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const BoundDateTimePicker = forwardRef<HTMLInputElement, BoundDateTimePickerProps>(function BoundDateTimePicker(
|
|
10
|
+
{ field, ...props },
|
|
11
|
+
ref,
|
|
12
|
+
) {
|
|
13
|
+
let { error, value, onChange, onKeyDown } = useField<any, any>(field);
|
|
14
|
+
|
|
15
|
+
let inputValue = value;
|
|
16
|
+
if (value instanceof Date) {
|
|
17
|
+
inputValue = value.toISOString().replace("Z", "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Textfield
|
|
22
|
+
type="datetime-local"
|
|
23
|
+
name={field}
|
|
24
|
+
error={error ?? undefined}
|
|
25
|
+
{...(props as any)}
|
|
26
|
+
value={inputValue ?? ""}
|
|
27
|
+
onChange={(evt) => {
|
|
28
|
+
onChange(
|
|
29
|
+
evt as any,
|
|
30
|
+
(evt.target as HTMLInputElement).value ? `${(evt.target as HTMLInputElement).value}Z` : null,
|
|
31
|
+
);
|
|
32
|
+
}}
|
|
33
|
+
onKeyDown={(evt) => onKeyDown(evt as any)}
|
|
34
|
+
ref={ref}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Input, type InputProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
3
|
+
|
|
4
|
+
export type BoundInputProps = Omit<InputProps, "onChange" | "onKeyDown" | "onBlur"> & {
|
|
5
|
+
field: string;
|
|
6
|
+
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function BoundInput({ field, onChange: onChangeProp, ...props }: BoundInputProps) {
|
|
10
|
+
let { value, onChange, onKeyDown } = useField<any, any>(field);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Input
|
|
14
|
+
name={field}
|
|
15
|
+
{...props}
|
|
16
|
+
value={value ?? ""}
|
|
17
|
+
onChange={(event) => {
|
|
18
|
+
onChangeProp?.(event as any);
|
|
19
|
+
if (!event.defaultPrevented) {
|
|
20
|
+
onChange(event as any);
|
|
21
|
+
}
|
|
22
|
+
}}
|
|
23
|
+
onKeyDown={(evt) => onKeyDown(evt as any)}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { getLocalizedString, localize } from "@olenbetong/appframe-core";
|
|
3
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Designsystemet `Textfield` bound to an Appframe data source field via `useField`.
|
|
8
|
+
*
|
|
9
|
+
* Adds client-side numeric validation: rejects non-numeric characters and enforces
|
|
10
|
+
* optional `precision` (total digits) and `scale` (decimal places) constraints.
|
|
11
|
+
* Validation errors are reported to the parent via the `onError` callback so the
|
|
12
|
+
* parent can track whether any field is in an error state (e.g., to disable Save).
|
|
13
|
+
*
|
|
14
|
+
* On blur the displayed value is normalized to a JS `Number` (comma decimal separators
|
|
15
|
+
* are converted to dots) so the data source always stores a proper numeric value.
|
|
16
|
+
*/
|
|
17
|
+
export type BoundNumericTextFieldProps = Omit<TextfieldProps, "onChange" | "onKeyDown" | "onError"> & {
|
|
18
|
+
field: string;
|
|
19
|
+
precision?: number;
|
|
20
|
+
scale?: number;
|
|
21
|
+
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
22
|
+
onError?: (error: string | null) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function BoundNumericTextField({
|
|
26
|
+
field,
|
|
27
|
+
precision,
|
|
28
|
+
scale,
|
|
29
|
+
onChange: onChangeProp,
|
|
30
|
+
onError,
|
|
31
|
+
...props
|
|
32
|
+
}: BoundNumericTextFieldProps) {
|
|
33
|
+
let { value, setValue, onChange, onKeyDown } = useField<any, any>(field);
|
|
34
|
+
|
|
35
|
+
let [error, setError] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
function checkNumber(value: string, precision: number, scale: number) {
|
|
38
|
+
if (value[0] === "0") {
|
|
39
|
+
value = value[0].replace("0", "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let [integer, decimal] = value.split(",");
|
|
43
|
+
if (value.includes(".")) {
|
|
44
|
+
[integer, decimal] = value.split(".");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
integer = integer.replace("-", "");
|
|
48
|
+
|
|
49
|
+
if (integer.length > precision - scale) {
|
|
50
|
+
setError(localize`The amount of whole numbers is too high. Allowed limit is ${precision - scale}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (decimal && decimal.length > scale) {
|
|
55
|
+
setError(localize`The amount of decimal places is too high. Allowed limit is ${scale}`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setError(null);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function checkError(value: string, precision: number | undefined, scale: number | undefined) {
|
|
63
|
+
let regExp = /[a-zA-Z `!@#$%^&*()_+=[\]{};':"\\|<>/?~]/;
|
|
64
|
+
|
|
65
|
+
if (regExp.test(value)) {
|
|
66
|
+
setError(getLocalizedString("Please enter a valid number"));
|
|
67
|
+
} else if (value.substring(1).includes("-")) {
|
|
68
|
+
setError(getLocalizedString("Please enter a valid number"));
|
|
69
|
+
} else if (precision !== undefined && scale !== undefined) {
|
|
70
|
+
checkNumber(value, precision, scale);
|
|
71
|
+
} else {
|
|
72
|
+
setError(null);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
onError?.(error);
|
|
78
|
+
}, [error, onError]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Textfield
|
|
82
|
+
name={field}
|
|
83
|
+
error={error ?? undefined}
|
|
84
|
+
{...(props as any)}
|
|
85
|
+
value={value ?? ""}
|
|
86
|
+
onChange={(event) => {
|
|
87
|
+
onChangeProp?.(event as any);
|
|
88
|
+
if (!event.defaultPrevented) {
|
|
89
|
+
checkError((event.target as HTMLInputElement).value, precision, scale);
|
|
90
|
+
onChange(event as any);
|
|
91
|
+
}
|
|
92
|
+
}}
|
|
93
|
+
onKeyDown={(evt) => onKeyDown(evt as any)}
|
|
94
|
+
onBlur={(evt) => {
|
|
95
|
+
const raw = (evt.target as HTMLInputElement).value;
|
|
96
|
+
if (raw !== "") {
|
|
97
|
+
setValue(Number(raw.replaceAll(",", ".")));
|
|
98
|
+
}
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Select, type SelectProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
|
|
5
|
+
export type BoundSelectProps = Omit<SelectProps, "onChange" | "value"> & {
|
|
6
|
+
field: string;
|
|
7
|
+
onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const BoundSelect = forwardRef<HTMLSelectElement, BoundSelectProps>(function BoundSelect(
|
|
11
|
+
{ field, onChange: onChangeProp, ...props },
|
|
12
|
+
ref,
|
|
13
|
+
) {
|
|
14
|
+
let { value, onChange, onKeyDown } = useField<any, any>(field);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Select
|
|
18
|
+
name={field}
|
|
19
|
+
value={value ?? ""}
|
|
20
|
+
onChange={(evt) => {
|
|
21
|
+
onChangeProp?.(evt);
|
|
22
|
+
if (!evt.defaultPrevented) {
|
|
23
|
+
onChange(evt as any);
|
|
24
|
+
}
|
|
25
|
+
}}
|
|
26
|
+
onKeyDown={(evt) => onKeyDown(evt as any)}
|
|
27
|
+
{...props}
|
|
28
|
+
ref={ref}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Switch, type SwitchProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
|
|
5
|
+
export type BoundSwitchProps = Omit<SwitchProps, "onChange" | "checked"> & {
|
|
6
|
+
field: string;
|
|
7
|
+
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const BoundSwitch = forwardRef<HTMLInputElement, BoundSwitchProps>(function BoundSwitch(
|
|
11
|
+
{ field, onChange: onChangeProp, ...props },
|
|
12
|
+
ref,
|
|
13
|
+
) {
|
|
14
|
+
let { value, setValue } = useField<any, any>(field);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Switch
|
|
18
|
+
name={field}
|
|
19
|
+
{...(props as any)}
|
|
20
|
+
checked={value ?? false}
|
|
21
|
+
onChange={(evt) => {
|
|
22
|
+
onChangeProp?.(evt);
|
|
23
|
+
if (!evt.defaultPrevented) {
|
|
24
|
+
setValue(evt.target.checked);
|
|
25
|
+
}
|
|
26
|
+
}}
|
|
27
|
+
ref={ref}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
|
|
5
|
+
export type BoundTextFieldProps = Omit<TextfieldProps, "value" | "onChange" | "onKeyDown"> & {
|
|
6
|
+
field: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const BoundTextField = forwardRef<HTMLInputElement & HTMLTextAreaElement, BoundTextFieldProps>(
|
|
10
|
+
function BoundTextField({ field, ...props }, ref) {
|
|
11
|
+
let { error, value, onChange, onKeyDown } = useField<any, any>(field);
|
|
12
|
+
|
|
13
|
+
let inputValue = value;
|
|
14
|
+
if (props.type === "date" && value instanceof Date) {
|
|
15
|
+
inputValue = value.toISOString().split("T")[0];
|
|
16
|
+
} else if (["datetime", "datetime-local"].includes(props.type ?? "") && value instanceof Date) {
|
|
17
|
+
inputValue = value.toISOString().replace("Z", "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Textfield
|
|
22
|
+
name={field}
|
|
23
|
+
error={error ?? undefined}
|
|
24
|
+
{...(props as any)}
|
|
25
|
+
value={inputValue ?? ""}
|
|
26
|
+
onChange={(evt) => {
|
|
27
|
+
if (["datetime", "datetime-local"].includes(props.type ?? "")) {
|
|
28
|
+
onChange(
|
|
29
|
+
evt as any,
|
|
30
|
+
(evt.target as HTMLInputElement).value ? `${(evt.target as HTMLInputElement).value}Z` : null,
|
|
31
|
+
);
|
|
32
|
+
} else {
|
|
33
|
+
onChange(evt as any);
|
|
34
|
+
}
|
|
35
|
+
}}
|
|
36
|
+
onKeyDown={(evt) => onKeyDown(evt as any)}
|
|
37
|
+
ref={ref}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
},
|
|
41
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Button, type ButtonProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
3
|
+
import { useCancelButton } from "@olenbetong/appframe-react";
|
|
4
|
+
import { type ForwardRefExoticComponent, forwardRef, type RefAttributes } from "react";
|
|
5
|
+
|
|
6
|
+
export const CancelButton: ForwardRefExoticComponent<ButtonProps & RefAttributes<HTMLButtonElement>> = forwardRef<
|
|
7
|
+
HTMLButtonElement,
|
|
8
|
+
ButtonProps
|
|
9
|
+
>(function CancelButton({ onClick, children, ...props }, ref) {
|
|
10
|
+
let { cancelEdit } = useCancelButton();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Button
|
|
14
|
+
onClick={(evt) => {
|
|
15
|
+
onClick?.(evt);
|
|
16
|
+
if (!evt.defaultPrevented) {
|
|
17
|
+
cancelEdit();
|
|
18
|
+
}
|
|
19
|
+
}}
|
|
20
|
+
variant="secondary"
|
|
21
|
+
{...props}
|
|
22
|
+
ref={ref}
|
|
23
|
+
>
|
|
24
|
+
{children ?? getLocalizedString("Cancel")}
|
|
25
|
+
</Button>
|
|
26
|
+
);
|
|
27
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Button, type ButtonProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import UndoIcon from "@mui/icons-material/Undo";
|
|
3
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
4
|
+
import { useCancelButton } from "@olenbetong/appframe-react";
|
|
5
|
+
import { forwardRef } from "react";
|
|
6
|
+
|
|
7
|
+
export const CancelIconButton = forwardRef<HTMLButtonElement, Omit<ButtonProps, "icon">>(function CancelIconButton(
|
|
8
|
+
{ onClick, children, ...props },
|
|
9
|
+
ref,
|
|
10
|
+
) {
|
|
11
|
+
let { cancelEdit } = useCancelButton();
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Button
|
|
15
|
+
icon
|
|
16
|
+
aria-label={getLocalizedString("Revert unsaved changes")}
|
|
17
|
+
variant="tertiary"
|
|
18
|
+
onClick={(evt) => {
|
|
19
|
+
onClick?.(evt);
|
|
20
|
+
if (!evt.defaultPrevented) {
|
|
21
|
+
cancelEdit();
|
|
22
|
+
}
|
|
23
|
+
}}
|
|
24
|
+
{...props}
|
|
25
|
+
ref={ref}
|
|
26
|
+
>
|
|
27
|
+
{children ?? <UndoIcon />}
|
|
28
|
+
</Button>
|
|
29
|
+
);
|
|
30
|
+
});
|