@olenbetong/appframe-ds 1.6.0 → 1.7.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 (154) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +3 -3
  3. package/scripts/copyAssets.mjs +27 -27
  4. package/src/autocomplete/Autocomplete.css +253 -253
  5. package/src/autocomplete/Autocomplete.tsx +320 -320
  6. package/src/autocomplete/AutocompleteFrame.tsx +168 -168
  7. package/src/autocomplete/Combobox.tsx +286 -286
  8. package/src/autocomplete/index.ts +4 -4
  9. package/src/autocomplete/types.ts +204 -204
  10. package/src/autocomplete/utils.ts +183 -183
  11. package/src/binding/BoundNumberInput.tsx +42 -42
  12. package/src/binding/BoundNumericTextField.tsx +102 -102
  13. package/src/binding/BoundTextField.tsx +49 -49
  14. package/src/binding/CancelButton.tsx +30 -30
  15. package/src/binding/CancelIconButton.tsx +30 -30
  16. package/src/binding/DataEditToolbar.css +23 -23
  17. package/src/binding/DataEditToolbar.tsx +84 -84
  18. package/src/binding/DeleteButton.tsx +35 -35
  19. package/src/binding/DeleteIconButton.tsx +35 -35
  20. package/src/binding/RefreshButton.tsx +31 -31
  21. package/src/binding/RefreshIconButton.tsx +33 -33
  22. package/src/binding/RefreshRowIconButton.tsx +33 -33
  23. package/src/binding/SaveButton.tsx +37 -37
  24. package/src/binding/SaveIconButton.tsx +38 -38
  25. package/src/binding/index.ts +20 -20
  26. package/src/container/Container.tsx +50 -50
  27. package/src/container/index.ts +1 -1
  28. package/src/cutoff/CutOffPicker.css +12 -12
  29. package/src/cutoff/CutOffPicker.tsx +165 -165
  30. package/src/cutoff/context.tsx +194 -194
  31. package/src/cutoff/dsCutOffDates.ts +21 -21
  32. package/src/cutoff/index.ts +4 -4
  33. package/src/cutoff/utils.test.ts +59 -59
  34. package/src/cutoff/utils.ts +276 -276
  35. package/src/filter/ChipListInput.tsx +79 -79
  36. package/src/filter/FieldFilterPanel.css +150 -150
  37. package/src/filter/FieldFilterPanel.tsx +309 -309
  38. package/src/filter/FilterBuilder.css +152 -152
  39. package/src/filter/FilterBuilder.test.tsx +153 -153
  40. package/src/filter/FilterBuilder.tsx +435 -435
  41. package/src/filter/FilterEditor.css +231 -231
  42. package/src/filter/FilterEditor.test.tsx +285 -285
  43. package/src/filter/FilterEditor.tsx +75 -75
  44. package/src/filter/FilterGroupEditor.tsx +160 -160
  45. package/src/filter/FilterNameDialog.css +22 -22
  46. package/src/filter/FilterNameDialog.tsx +81 -81
  47. package/src/filter/FilterRow.tsx +117 -117
  48. package/src/filter/FilterShareDialog.css +28 -28
  49. package/src/filter/FilterShareDialog.tsx +201 -201
  50. package/src/filter/FilterStringField.tsx +78 -78
  51. package/src/filter/FilterValueEditor.tsx +239 -239
  52. package/src/filter/SavedFilterTree.css +107 -107
  53. package/src/filter/SavedFilterTree.test.tsx +94 -94
  54. package/src/filter/SavedFilterTree.tsx +218 -218
  55. package/src/filter/fieldFilter.test.ts +158 -158
  56. package/src/filter/fieldFilter.ts +139 -139
  57. package/src/filter/index.ts +27 -27
  58. package/src/filter/types.ts +38 -38
  59. package/src/filter/useDistinctValues.test.ts +102 -102
  60. package/src/filter/useDistinctValues.ts +230 -230
  61. package/src/global.d.ts +11 -11
  62. package/src/grid/formatters.ts +23 -0
  63. package/src/grid/index.css +8 -2
  64. package/src/grid/index.tsx +73 -3
  65. package/src/grid/useAfColumns.tsx +28 -4
  66. package/src/grid/useAfGridApi.ts +3 -0
  67. package/src/grid/utcDate.test.ts +25 -0
  68. package/src/grid/utcDate.ts +28 -0
  69. package/src/grid/utcDateEditing.tsx +11 -0
  70. package/src/i18n.ts +13 -13
  71. package/src/index.ts +14 -14
  72. package/src/input/InputAdornments.css +80 -80
  73. package/src/input/InputAdornments.tsx +102 -102
  74. package/src/input/NumberInput.css +9 -9
  75. package/src/input/NumberInput.test.tsx +212 -212
  76. package/src/input/NumberInput.tsx +268 -268
  77. package/src/input/index.ts +3 -3
  78. package/src/input/numberFormat.test.ts +79 -79
  79. package/src/input/numberFormat.ts +94 -94
  80. package/src/layout/AppMenu.tsx +74 -74
  81. package/src/layout/AppMenuDrawer.css +71 -71
  82. package/src/layout/AppMenuDrawer.tsx +122 -122
  83. package/src/layout/AppMenuItem.css +6 -2
  84. package/src/layout/DialogMotion.css +79 -79
  85. package/src/layout/ErrorBoundary.tsx +162 -162
  86. package/src/layout/PageContainer.css +48 -48
  87. package/src/layout/PageContainer.tsx +41 -41
  88. package/src/layout/ResponsiveDialog.css +86 -86
  89. package/src/layout/ResponsiveDialog.tsx +55 -55
  90. package/src/layout/index.tsx +70 -70
  91. package/src/link/Link.tsx +23 -23
  92. package/src/link/index.ts +1 -1
  93. package/src/mdi/Mdi.css +8 -8
  94. package/src/mdi/Mdi.test.tsx +91 -91
  95. package/src/mdi/Mdi.tsx +141 -141
  96. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
  97. package/src/mdi/MdiContext.tsx +59 -59
  98. package/src/mdi/MdiDocumentContext.tsx +60 -60
  99. package/src/mdi/MdiPanel.tsx +61 -61
  100. package/src/mdi/MdiRestore.test.tsx +86 -86
  101. package/src/mdi/MdiTab.tsx +30 -30
  102. package/src/mdi/controller.test.ts +382 -382
  103. package/src/mdi/controller.ts +656 -656
  104. package/src/mdi/dockviewHost.ts +132 -132
  105. package/src/mdi/fakeHost.ts +200 -200
  106. package/src/mdi/host.ts +44 -44
  107. package/src/mdi/ids.test.ts +20 -20
  108. package/src/mdi/ids.ts +18 -18
  109. package/src/mdi/index.ts +31 -31
  110. package/src/mdi/layoutRestore.test.ts +156 -156
  111. package/src/mdi/layoutRestore.ts +311 -311
  112. package/src/mdi/layoutSpec.test.ts +143 -143
  113. package/src/mdi/layoutSpec.ts +214 -214
  114. package/src/mdi/layoutStore.test.ts +93 -93
  115. package/src/mdi/layoutStore.ts +99 -99
  116. package/src/mdi/memorySettingsStore.ts +21 -21
  117. package/src/mdi/registry.ts +34 -34
  118. package/src/mdi/testTypes.ts +37 -37
  119. package/src/mdi/theme.ts +22 -22
  120. package/src/mdi/types.ts +214 -214
  121. package/src/page/Page.css +57 -57
  122. package/src/page/PageBlock.css +64 -64
  123. package/src/page/PageBlock.tsx +93 -93
  124. package/src/page/PageHeader.css +88 -88
  125. package/src/page/PageHeader.tsx +115 -115
  126. package/src/page/PageTitle.css +5 -5
  127. package/src/page/PageTitle.tsx +21 -21
  128. package/src/page/index.ts +4 -4
  129. package/src/paper/Paper.tsx +52 -52
  130. package/src/progress/LinearProgress.css +121 -121
  131. package/src/progress/LinearProgress.tsx +106 -106
  132. package/src/progress/index.ts +1 -1
  133. package/src/report-downloader/ReportDownloader.css +67 -67
  134. package/src/report-downloader/components/StatusItem.tsx +87 -87
  135. package/src/report-downloader/components/StatusList.tsx +28 -28
  136. package/src/report-downloader/index.ts +41 -41
  137. package/src/report-downloader/status.tsx +92 -92
  138. package/src/sortable/DropIndicator.css +51 -51
  139. package/src/sortable/DropIndicator.tsx +27 -27
  140. package/src/sortable/SortableItemList.css +21 -21
  141. package/src/sortable/SortableItemList.tsx +264 -264
  142. package/src/sortable/index.ts +8 -8
  143. package/src/sortable/reorderSortOrder.ts +126 -126
  144. package/src/sortable/sortOrder.ts +101 -101
  145. package/src/sortable/types.ts +9 -9
  146. package/src/sortable/useSortableContainer.ts +53 -53
  147. package/src/sortable/useSortableItem.ts +95 -95
  148. package/src/sortable/useSortableList.ts +117 -117
  149. package/src/test/happy-dom-document-class.ts +24 -24
  150. package/src/test/setup.ts +27 -27
  151. package/src/theme/useDsColorScheme.ts +53 -53
  152. package/tsconfig.build.json +9 -9
  153. package/tsconfig.json +8 -8
  154. package/vitest.config.ts +10 -10
@@ -1,183 +1,183 @@
1
- import { useCallback, useRef, useState } from "react";
2
-
3
- import type {
4
- AutocompleteChangeReason,
5
- AutocompleteFilterOptionsState,
6
- AutocompleteInputChangeReason,
7
- } from "./types.js";
8
-
9
- /**
10
- * Default label resolver: uses `label`, then `name`, then the value itself.
11
- */
12
- export function defaultGetOptionLabel(option: unknown): string {
13
- if (option == null) return "";
14
- if (typeof option === "string") return option;
15
- if (typeof option === "object") {
16
- let record = option as Record<string, unknown>;
17
- if (typeof record.label === "string") return record.label;
18
- if (typeof record.name === "string") return record.name;
19
- }
20
-
21
- return String(option);
22
- }
23
-
24
- export interface CreateFilterOptionsConfig<Option> {
25
- /**
26
- * Removes diacritics before matching.
27
- * @default true
28
- */
29
- ignoreAccents?: boolean;
30
- /**
31
- * Matches case insensitively.
32
- * @default true
33
- */
34
- ignoreCase?: boolean;
35
- /**
36
- * Where in the option label the query has to match.
37
- * @default "any"
38
- */
39
- matchFrom?: "any" | "start";
40
- /**
41
- * The maximum number of options returned.
42
- */
43
- limit?: number;
44
- /**
45
- * Trims the query before matching.
46
- * @default false
47
- */
48
- trim?: boolean;
49
- /**
50
- * Returns the string the query is matched against.
51
- */
52
- stringify?: (option: Option) => string;
53
- }
54
-
55
- /**
56
- * Creates a filter function for the `filterOptions` prop, equivalent to MUI's
57
- * `createFilterOptions`.
58
- *
59
- * @example
60
- * ```jsx
61
- * let filterOptions = createFilterOptions({ matchFrom: "start", limit: 20 });
62
- * <Combobox options={options} filterOptions={filterOptions} />
63
- * ```
64
- */
65
- export function createFilterOptions<Option>(config: CreateFilterOptionsConfig<Option> = {}) {
66
- let { ignoreAccents = true, ignoreCase = true, limit, matchFrom = "any", stringify, trim = false } = config;
67
-
68
- return (options: readonly Option[], state: AutocompleteFilterOptionsState<Option>) => {
69
- let input = trim ? state.inputValue.trim() : state.inputValue;
70
- if (ignoreCase) input = input.toLowerCase();
71
- if (ignoreAccents) input = stripDiacritics(input);
72
-
73
- let filtered = input
74
- ? options.filter((option) => {
75
- let candidate = (stringify ?? state.getOptionLabel)(option);
76
- if (ignoreCase) candidate = candidate.toLowerCase();
77
- if (ignoreAccents) candidate = stripDiacritics(candidate);
78
-
79
- return matchFrom === "start" ? candidate.startsWith(input) : candidate.includes(input);
80
- })
81
- : [...options];
82
-
83
- return typeof limit === "number" ? filtered.slice(0, limit) : filtered;
84
- };
85
- }
86
-
87
- function stripDiacritics(value: string) {
88
- return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
89
- }
90
-
91
- export const defaultFilterOptions = createFilterOptions<unknown>();
92
-
93
- export interface OptionGroup<Option> {
94
- /**
95
- * Group header text. Named `value` because Base UI uses it as the group key.
96
- */
97
- value: string;
98
- items: Option[];
99
- [key: string]: unknown;
100
- }
101
-
102
- /**
103
- * Splits an already sorted list of options into consecutive groups, the same
104
- * way MUI's `groupBy` works.
105
- */
106
- export function groupOptions<Option>(options: readonly Option[], groupBy: (option: Option) => string) {
107
- let groups: OptionGroup<Option>[] = [];
108
-
109
- for (let option of options) {
110
- let group = groupBy(option);
111
- let last = groups[groups.length - 1];
112
-
113
- if (last && last.value === group) {
114
- last.items.push(option);
115
- } else {
116
- groups.push({ value: group, items: [option] });
117
- }
118
- }
119
-
120
- return groups;
121
- }
122
-
123
- /**
124
- * State that is controlled when `controlled` is not `undefined`, and
125
- * uncontrolled otherwise.
126
- */
127
- export function useControlled<T>(controlled: T | undefined, defaultValue: T) {
128
- let [uncontrolled, setUncontrolled] = useState(defaultValue);
129
- let isControlled = useRef(controlled !== undefined).current;
130
- let value = isControlled ? (controlled as T) : uncontrolled;
131
-
132
- let setValue = useCallback(
133
- (next: T) => {
134
- if (!isControlled) setUncontrolled(next);
135
- },
136
- [isControlled],
137
- );
138
-
139
- return [value, setValue] as const;
140
- }
141
-
142
- /**
143
- * Compares two options, falling back to reference equality like MUI does.
144
- */
145
- export function areOptionsEqual<Option>(
146
- a: Option,
147
- b: Option,
148
- isOptionEqualToValue?: (option: Option, value: Option) => boolean,
149
- ) {
150
- return isOptionEqualToValue ? isOptionEqualToValue(a, b) : Object.is(a, b);
151
- }
152
-
153
- /**
154
- * Maps a Base UI change reason to the MUI-style reason.
155
- */
156
- export function mapChangeReason(reason: string, next: unknown): AutocompleteChangeReason {
157
- if (reason === "clear-press" || reason === "input-clear") return "clear";
158
- if (reason === "chip-remove-press") return "removeOption";
159
- if (next == null || (Array.isArray(next) && next.length === 0)) return "clear";
160
-
161
- return "selectOption";
162
- }
163
-
164
- function mapInputChangeReason(reason: string): AutocompleteInputChangeReason {
165
- if (reason === "input-change") return "input";
166
- if (reason === "input-clear" || reason === "clear-press") return "clear";
167
-
168
- return "reset";
169
- }
170
-
171
- /**
172
- * Keeps the input value state in sync and forwards the change to
173
- * `onInputChange` with a MUI-style reason.
174
- */
175
- export function handleInputValueChange(
176
- next: string,
177
- details: { event?: Event; reason: string },
178
- setInputValue: (value: string) => void,
179
- onInputChange?: (event: Event | undefined, value: string, reason: AutocompleteInputChangeReason) => void,
180
- ) {
181
- setInputValue(next);
182
- onInputChange?.(details.event, next, mapInputChangeReason(details.reason));
183
- }
1
+ import { useCallback, useRef, useState } from "react";
2
+
3
+ import type {
4
+ AutocompleteChangeReason,
5
+ AutocompleteFilterOptionsState,
6
+ AutocompleteInputChangeReason,
7
+ } from "./types.js";
8
+
9
+ /**
10
+ * Default label resolver: uses `label`, then `name`, then the value itself.
11
+ */
12
+ export function defaultGetOptionLabel(option: unknown): string {
13
+ if (option == null) return "";
14
+ if (typeof option === "string") return option;
15
+ if (typeof option === "object") {
16
+ let record = option as Record<string, unknown>;
17
+ if (typeof record.label === "string") return record.label;
18
+ if (typeof record.name === "string") return record.name;
19
+ }
20
+
21
+ return String(option);
22
+ }
23
+
24
+ export interface CreateFilterOptionsConfig<Option> {
25
+ /**
26
+ * Removes diacritics before matching.
27
+ * @default true
28
+ */
29
+ ignoreAccents?: boolean;
30
+ /**
31
+ * Matches case insensitively.
32
+ * @default true
33
+ */
34
+ ignoreCase?: boolean;
35
+ /**
36
+ * Where in the option label the query has to match.
37
+ * @default "any"
38
+ */
39
+ matchFrom?: "any" | "start";
40
+ /**
41
+ * The maximum number of options returned.
42
+ */
43
+ limit?: number;
44
+ /**
45
+ * Trims the query before matching.
46
+ * @default false
47
+ */
48
+ trim?: boolean;
49
+ /**
50
+ * Returns the string the query is matched against.
51
+ */
52
+ stringify?: (option: Option) => string;
53
+ }
54
+
55
+ /**
56
+ * Creates a filter function for the `filterOptions` prop, equivalent to MUI's
57
+ * `createFilterOptions`.
58
+ *
59
+ * @example
60
+ * ```jsx
61
+ * let filterOptions = createFilterOptions({ matchFrom: "start", limit: 20 });
62
+ * <Combobox options={options} filterOptions={filterOptions} />
63
+ * ```
64
+ */
65
+ export function createFilterOptions<Option>(config: CreateFilterOptionsConfig<Option> = {}) {
66
+ let { ignoreAccents = true, ignoreCase = true, limit, matchFrom = "any", stringify, trim = false } = config;
67
+
68
+ return (options: readonly Option[], state: AutocompleteFilterOptionsState<Option>) => {
69
+ let input = trim ? state.inputValue.trim() : state.inputValue;
70
+ if (ignoreCase) input = input.toLowerCase();
71
+ if (ignoreAccents) input = stripDiacritics(input);
72
+
73
+ let filtered = input
74
+ ? options.filter((option) => {
75
+ let candidate = (stringify ?? state.getOptionLabel)(option);
76
+ if (ignoreCase) candidate = candidate.toLowerCase();
77
+ if (ignoreAccents) candidate = stripDiacritics(candidate);
78
+
79
+ return matchFrom === "start" ? candidate.startsWith(input) : candidate.includes(input);
80
+ })
81
+ : [...options];
82
+
83
+ return typeof limit === "number" ? filtered.slice(0, limit) : filtered;
84
+ };
85
+ }
86
+
87
+ function stripDiacritics(value: string) {
88
+ return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
89
+ }
90
+
91
+ export const defaultFilterOptions = createFilterOptions<unknown>();
92
+
93
+ export interface OptionGroup<Option> {
94
+ /**
95
+ * Group header text. Named `value` because Base UI uses it as the group key.
96
+ */
97
+ value: string;
98
+ items: Option[];
99
+ [key: string]: unknown;
100
+ }
101
+
102
+ /**
103
+ * Splits an already sorted list of options into consecutive groups, the same
104
+ * way MUI's `groupBy` works.
105
+ */
106
+ export function groupOptions<Option>(options: readonly Option[], groupBy: (option: Option) => string) {
107
+ let groups: OptionGroup<Option>[] = [];
108
+
109
+ for (let option of options) {
110
+ let group = groupBy(option);
111
+ let last = groups[groups.length - 1];
112
+
113
+ if (last && last.value === group) {
114
+ last.items.push(option);
115
+ } else {
116
+ groups.push({ value: group, items: [option] });
117
+ }
118
+ }
119
+
120
+ return groups;
121
+ }
122
+
123
+ /**
124
+ * State that is controlled when `controlled` is not `undefined`, and
125
+ * uncontrolled otherwise.
126
+ */
127
+ export function useControlled<T>(controlled: T | undefined, defaultValue: T) {
128
+ let [uncontrolled, setUncontrolled] = useState(defaultValue);
129
+ let isControlled = useRef(controlled !== undefined).current;
130
+ let value = isControlled ? (controlled as T) : uncontrolled;
131
+
132
+ let setValue = useCallback(
133
+ (next: T) => {
134
+ if (!isControlled) setUncontrolled(next);
135
+ },
136
+ [isControlled],
137
+ );
138
+
139
+ return [value, setValue] as const;
140
+ }
141
+
142
+ /**
143
+ * Compares two options, falling back to reference equality like MUI does.
144
+ */
145
+ export function areOptionsEqual<Option>(
146
+ a: Option,
147
+ b: Option,
148
+ isOptionEqualToValue?: (option: Option, value: Option) => boolean,
149
+ ) {
150
+ return isOptionEqualToValue ? isOptionEqualToValue(a, b) : Object.is(a, b);
151
+ }
152
+
153
+ /**
154
+ * Maps a Base UI change reason to the MUI-style reason.
155
+ */
156
+ export function mapChangeReason(reason: string, next: unknown): AutocompleteChangeReason {
157
+ if (reason === "clear-press" || reason === "input-clear") return "clear";
158
+ if (reason === "chip-remove-press") return "removeOption";
159
+ if (next == null || (Array.isArray(next) && next.length === 0)) return "clear";
160
+
161
+ return "selectOption";
162
+ }
163
+
164
+ function mapInputChangeReason(reason: string): AutocompleteInputChangeReason {
165
+ if (reason === "input-change") return "input";
166
+ if (reason === "input-clear" || reason === "clear-press") return "clear";
167
+
168
+ return "reset";
169
+ }
170
+
171
+ /**
172
+ * Keeps the input value state in sync and forwards the change to
173
+ * `onInputChange` with a MUI-style reason.
174
+ */
175
+ export function handleInputValueChange(
176
+ next: string,
177
+ details: { event?: Event; reason: string },
178
+ setInputValue: (value: string) => void,
179
+ onInputChange?: (event: Event | undefined, value: string, reason: AutocompleteInputChangeReason) => void,
180
+ ) {
181
+ setInputValue(next);
182
+ onInputChange?.(details.event, next, mapInputChangeReason(details.reason));
183
+ }
@@ -1,42 +1,42 @@
1
- import { useField } from "@olenbetong/appframe-react";
2
-
3
- import { NumberInput, type NumberInputProps } from "../input/NumberInput.js";
4
-
5
- export type BoundNumberInputProps = Omit<NumberInputProps, "value" | "onChange" | "onKeyDown"> & {
6
- /**
7
- * Name of the number field in the data object from context
8
- */
9
- field: string;
10
- /**
11
- * Called with the new value before it is written to the data object
12
- */
13
- onChange?: (value: number | null) => void;
14
- };
15
-
16
- /**
17
- * A `NumberInput` bound to a number field of the data object from `DataObjectProvider`
18
- * via `useField`. Escape resets the field, and a conversion error from the data object is
19
- * shown below the input unless an `error` is passed.
20
- *
21
- * @example
22
- * ```jsx
23
- * <BoundNumberInput field="TotHrs" label="Total hours" step={0.25} min={0} max={24} />
24
- * ```
25
- */
26
- export function BoundNumberInput({ field, onChange: onChangeProp, error: errorProp, ...props }: BoundNumberInputProps) {
27
- let { error, value, onChange, onKeyDown } = useField<any, any>(field);
28
-
29
- return (
30
- <NumberInput
31
- name={field}
32
- {...props}
33
- error={errorProp ?? error ?? undefined}
34
- value={typeof value === "number" ? value : null}
35
- onChange={(next) => {
36
- onChangeProp?.(next);
37
- onChange({ target: { value: next } });
38
- }}
39
- onKeyDown={(event) => onKeyDown(event)}
40
- />
41
- );
42
- }
1
+ import { useField } from "@olenbetong/appframe-react";
2
+
3
+ import { NumberInput, type NumberInputProps } from "../input/NumberInput.js";
4
+
5
+ export type BoundNumberInputProps = Omit<NumberInputProps, "value" | "onChange" | "onKeyDown"> & {
6
+ /**
7
+ * Name of the number field in the data object from context
8
+ */
9
+ field: string;
10
+ /**
11
+ * Called with the new value before it is written to the data object
12
+ */
13
+ onChange?: (value: number | null) => void;
14
+ };
15
+
16
+ /**
17
+ * A `NumberInput` bound to a number field of the data object from `DataObjectProvider`
18
+ * via `useField`. Escape resets the field, and a conversion error from the data object is
19
+ * shown below the input unless an `error` is passed.
20
+ *
21
+ * @example
22
+ * ```jsx
23
+ * <BoundNumberInput field="TotHrs" label="Total hours" step={0.25} min={0} max={24} />
24
+ * ```
25
+ */
26
+ export function BoundNumberInput({ field, onChange: onChangeProp, error: errorProp, ...props }: BoundNumberInputProps) {
27
+ let { error, value, onChange, onKeyDown } = useField<any, any>(field);
28
+
29
+ return (
30
+ <NumberInput
31
+ name={field}
32
+ {...props}
33
+ error={errorProp ?? error ?? undefined}
34
+ value={typeof value === "number" ? value : null}
35
+ onChange={(next) => {
36
+ onChangeProp?.(next);
37
+ onChange({ target: { value: next } });
38
+ }}
39
+ onKeyDown={(event) => onKeyDown(event)}
40
+ />
41
+ );
42
+ }
@@ -1,102 +1,102 @@
1
- import { Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
2
- import { useField } from "@olenbetong/appframe-react";
3
- import { useEffect, useState } from "react";
4
- import { getLocalizedString, localize } from "../i18n.js";
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
- }
1
+ import { Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
2
+ import { useField } from "@olenbetong/appframe-react";
3
+ import { useEffect, useState } from "react";
4
+ import { getLocalizedString, localize } from "../i18n.js";
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
+ }