@olenbetong/appframe-ds 1.8.0 → 1.9.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 (147) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +2 -2
  3. package/src/AfLookup/Lookup.css +9 -1
  4. package/src/AfLookup/Lookup.tsx +8 -10
  5. package/src/AfLookup/LookupCombobox.tsx +1 -9
  6. package/src/AfLookup/LookupEdit.tsx +3 -3
  7. package/src/AfLookup/LookupGrid.css +3 -2
  8. package/src/AfLookup/LookupGrid.tsx +8 -4
  9. package/src/autocomplete/Autocomplete.css +6 -17
  10. package/src/binding/BoundNumberInput.tsx +42 -42
  11. package/src/binding/BoundNumericTextField.tsx +102 -102
  12. package/src/binding/BoundTextField.tsx +49 -49
  13. package/src/binding/CancelButton.tsx +30 -30
  14. package/src/binding/CancelIconButton.tsx +30 -30
  15. package/src/binding/DataEditToolbar.css +23 -23
  16. package/src/binding/DataEditToolbar.tsx +84 -84
  17. package/src/binding/DeleteButton.tsx +35 -35
  18. package/src/binding/DeleteIconButton.tsx +35 -35
  19. package/src/binding/RefreshButton.tsx +31 -31
  20. package/src/binding/RefreshIconButton.tsx +33 -33
  21. package/src/binding/RefreshRowIconButton.tsx +33 -33
  22. package/src/binding/SaveButton.tsx +37 -37
  23. package/src/binding/SaveIconButton.tsx +38 -38
  24. package/src/binding/index.ts +20 -20
  25. package/src/container/Container.tsx +50 -50
  26. package/src/container/index.ts +1 -1
  27. package/src/cutoff/CutOffPicker.css +12 -12
  28. package/src/cutoff/CutOffPicker.tsx +165 -165
  29. package/src/cutoff/context.tsx +194 -194
  30. package/src/cutoff/dsCutOffDates.ts +21 -21
  31. package/src/cutoff/index.ts +4 -4
  32. package/src/cutoff/utils.test.ts +59 -59
  33. package/src/cutoff/utils.ts +276 -276
  34. package/src/filter/ChipListInput.tsx +79 -79
  35. package/src/filter/FieldFilterPanel.css +150 -150
  36. package/src/filter/FieldFilterPanel.tsx +303 -309
  37. package/src/filter/FilterBuilder.css +152 -152
  38. package/src/filter/FilterBuilder.test.tsx +153 -153
  39. package/src/filter/FilterBuilder.tsx +435 -435
  40. package/src/filter/FilterEditor.css +231 -231
  41. package/src/filter/FilterEditor.test.tsx +285 -285
  42. package/src/filter/FilterEditor.tsx +75 -75
  43. package/src/filter/FilterGroupEditor.tsx +160 -160
  44. package/src/filter/FilterNameDialog.css +22 -22
  45. package/src/filter/FilterNameDialog.tsx +81 -81
  46. package/src/filter/FilterRow.tsx +117 -117
  47. package/src/filter/FilterShareDialog.css +28 -28
  48. package/src/filter/FilterShareDialog.tsx +195 -201
  49. package/src/filter/FilterStringField.tsx +72 -78
  50. package/src/filter/FilterValueEditor.tsx +239 -239
  51. package/src/filter/SavedFilterTree.css +107 -107
  52. package/src/filter/SavedFilterTree.test.tsx +94 -94
  53. package/src/filter/SavedFilterTree.tsx +218 -218
  54. package/src/filter/fieldFilter.test.ts +158 -158
  55. package/src/filter/fieldFilter.ts +139 -139
  56. package/src/filter/index.ts +27 -27
  57. package/src/filter/types.ts +38 -38
  58. package/src/filter/useDistinctValues.test.ts +102 -102
  59. package/src/filter/useDistinctValues.ts +230 -230
  60. package/src/global.d.ts +11 -11
  61. package/src/grid/AfGridColumnsPanel.tsx +4 -7
  62. package/src/grid/AfGridError.tsx +2 -2
  63. package/src/grid/AfHeaderFilterCell.tsx +0 -3
  64. package/src/grid/AfHeaderFilterPanel.tsx +1 -3
  65. package/src/grid/GridEditLookup.tsx +0 -2
  66. package/src/grid/Toolbar.tsx +9 -38
  67. package/src/grid/index.css +44 -0
  68. package/src/grid/index.tsx +12 -3
  69. package/src/grid/slots/index.tsx +15 -40
  70. package/src/grid/useAfGridApi.ts +0 -1
  71. package/src/grid/useAfPersistedState.ts +4 -8
  72. package/src/grid/useAfSizeMode.ts +19 -0
  73. package/src/i18n.ts +13 -13
  74. package/src/index.ts +14 -14
  75. package/src/input/NumberInput.tsx +0 -2
  76. package/src/layout/AppMenu.tsx +74 -74
  77. package/src/layout/AppMenuDrawer.css +71 -71
  78. package/src/layout/AppMenuDrawer.tsx +122 -122
  79. package/src/layout/DialogMotion.css +79 -79
  80. package/src/layout/ErrorBoundary.tsx +162 -162
  81. package/src/layout/PageContainer.css +48 -48
  82. package/src/layout/PageContainer.tsx +41 -41
  83. package/src/layout/ResponsiveDialog.css +86 -86
  84. package/src/layout/ResponsiveDialog.tsx +55 -55
  85. package/src/layout/index.tsx +70 -70
  86. package/src/link/Link.tsx +23 -23
  87. package/src/link/index.ts +1 -1
  88. package/src/list/ItemList.css +2 -1
  89. package/src/mdi/Mdi.test.tsx +91 -91
  90. package/src/mdi/Mdi.tsx +141 -141
  91. package/src/mdi/MdiConfirmCloseDialog.tsx +41 -47
  92. package/src/mdi/MdiContext.tsx +59 -59
  93. package/src/mdi/MdiDocumentContext.tsx +60 -60
  94. package/src/mdi/MdiPanel.tsx +61 -61
  95. package/src/mdi/MdiRestore.test.tsx +86 -86
  96. package/src/mdi/MdiTab.tsx +30 -30
  97. package/src/mdi/controller.test.ts +382 -382
  98. package/src/mdi/controller.ts +656 -656
  99. package/src/mdi/dockviewHost.ts +132 -132
  100. package/src/mdi/fakeHost.ts +200 -200
  101. package/src/mdi/host.ts +44 -44
  102. package/src/mdi/ids.test.ts +20 -20
  103. package/src/mdi/ids.ts +18 -18
  104. package/src/mdi/index.ts +31 -31
  105. package/src/mdi/layoutRestore.test.ts +156 -156
  106. package/src/mdi/layoutRestore.ts +311 -311
  107. package/src/mdi/layoutSpec.test.ts +143 -143
  108. package/src/mdi/layoutSpec.ts +214 -214
  109. package/src/mdi/layoutStore.test.ts +93 -93
  110. package/src/mdi/layoutStore.ts +99 -99
  111. package/src/mdi/memorySettingsStore.ts +21 -21
  112. package/src/mdi/registry.ts +34 -34
  113. package/src/mdi/testTypes.ts +37 -37
  114. package/src/mdi/theme.ts +22 -22
  115. package/src/mdi/types.ts +214 -214
  116. package/src/page/Page.css +57 -57
  117. package/src/page/PageBlock.css +64 -64
  118. package/src/page/PageBlock.tsx +93 -93
  119. package/src/page/PageHeader.css +88 -88
  120. package/src/page/PageHeader.tsx +115 -115
  121. package/src/page/PageTitle.css +5 -5
  122. package/src/page/PageTitle.tsx +21 -21
  123. package/src/page/index.ts +4 -4
  124. package/src/paper/Paper.tsx +52 -52
  125. package/src/progress/LinearProgress.css +121 -121
  126. package/src/progress/LinearProgress.tsx +106 -106
  127. package/src/progress/index.ts +1 -1
  128. package/src/report-downloader/ReportDownloader.css +67 -67
  129. package/src/report-downloader/components/StatusItem.tsx +87 -87
  130. package/src/report-downloader/components/StatusList.tsx +28 -28
  131. package/src/report-downloader/index.ts +41 -41
  132. package/src/report-downloader/status.tsx +92 -92
  133. package/src/sortable/DropIndicator.css +51 -51
  134. package/src/sortable/DropIndicator.tsx +27 -27
  135. package/src/sortable/SortableItemList.css +21 -21
  136. package/src/sortable/SortableItemList.tsx +264 -264
  137. package/src/sortable/index.ts +8 -8
  138. package/src/sortable/reorderSortOrder.ts +126 -126
  139. package/src/sortable/sortOrder.ts +101 -101
  140. package/src/sortable/types.ts +9 -9
  141. package/src/sortable/useSortableContainer.ts +53 -53
  142. package/src/sortable/useSortableItem.ts +95 -95
  143. package/src/sortable/useSortableList.ts +117 -117
  144. package/src/test/happy-dom-document-class.ts +24 -24
  145. package/src/test/setup.ts +27 -27
  146. package/src/theme/sizeMode.ts +62 -0
  147. package/src/theme/useDsColorScheme.ts +53 -53
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @olenbetong/appframe-ds
2
2
 
3
+ ## 1.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3bb1a08: Everything follows the Designsystemet size mode (`data-size` on an ancestor) instead of setting its own size. Components no longer hard-code `data-size="sm"`, and the filter components' `size` prop no longer defaults to `"sm"`: they inherit, and a caller can still pass a size. The 2026 site template sets `sm` on the app's `<main>`, so on those pages they look as before; on a page without a size mode they are now md.
8
+
9
+ `AfGrid` uses the size mode in place of MUI's density. Rows and headers are `--ds-size-10` tall (25px at xs, 31px at sm, 35px at md), the text is the size mode's body text, the toolbar, header filters, footer and slot components inherit the size, and editors and buttons in a cell fit the row. The density entries are gone from the toolbar menu, the grid renders at the standard density, and saved layouts no longer restore a density (`persistenceModel.density` is deprecated and ignored); a `rowHeight` passed to the grid still wins. The multi-column lookup grid sizes its rows the same way, the lookup popup (portaled to `<body>`) takes the size mode of its field, and the list's secondary line, the lookup grid's padding and `Autocomplete`'s padding and gap are sized from the size mode instead of fixed rem/px.
10
+
3
11
  ## 1.8.0
4
12
 
5
13
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-ds",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Components that bind Designsystemet components to Appframe data objects",
5
5
  "type": "module",
6
6
  "types": "./es/index.d.ts",
@@ -64,7 +64,7 @@
64
64
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "3.0.0",
65
65
  "@base-ui/react": "1.9.0",
66
66
  "@olenbetong/appframe-core": "^2.15.2",
67
- "@olenbetong/appframe-data": "^1.8.5",
67
+ "@olenbetong/appframe-data": "^1.8.6",
68
68
  "@olenbetong/appframe-react": "^2.2.8",
69
69
  "clsx": "^2.1.1",
70
70
  "dockview-react": "8.6.1",
@@ -2,7 +2,7 @@
2
2
  position: relative;
3
3
 
4
4
  .ds-input {
5
- padding-inline-end: var(--ObLookup-buttons-width, 3rem);
5
+ padding-inline-end: var(--ObLookup-buttons-width, var(--ds-size-12, 3rem));
6
6
  }
7
7
  }
8
8
 
@@ -22,6 +22,14 @@
22
22
  padding-inline-end: var(--ds-size-1, 0.25rem);
23
23
  }
24
24
 
25
+ /*
26
+ * The buttons follow the inherited size mode, so in the same mode as the input they would be as
27
+ * tall as it. Fit them inside the input instead, with --ds-size-1 to spare above and below.
28
+ */
29
+ .ObLookup-buttons > .ds-button {
30
+ --dsc-button-size: calc(var(--ds-size-12) - 2 * var(--ds-size-1));
31
+ }
32
+
25
33
  .ObLookup-dialog {
26
34
  border: 0;
27
35
  margin-block-start: 0;
@@ -10,6 +10,7 @@ import { createPortal } from "react-dom";
10
10
 
11
11
  import { LookupCombobox, type LookupComboboxProps } from "./LookupCombobox.js";
12
12
  import { getLocalizedString } from "../i18n.js";
13
+ import { copySizeMode } from "../theme/sizeMode.js";
13
14
 
14
15
  export type LabelTag = "optional" | "required";
15
16
 
@@ -24,7 +25,7 @@ function labelWithTag(label: string | undefined, labelTag: LabelTag | undefined)
24
25
  return (
25
26
  <>
26
27
  {label}
27
- <Tag className="ObLookup-labelTag" data-color={LABEL_TAG_COLOR[labelTag]} data-size="sm">
28
+ <Tag className="ObLookup-labelTag" data-color={LABEL_TAG_COLOR[labelTag]}>
28
29
  {getLocalizedString(LABEL_TAG_TEXT[labelTag])}
29
30
  </Tag>
30
31
  </>
@@ -71,6 +72,8 @@ export function useLookup<T extends Record<string, unknown>>({
71
72
  }
72
73
 
73
74
  function handleOpenDialog(searchValue?: string) {
75
+ // The dialog is portaled to <body>, so it would not inherit the field's size mode.
76
+ copySizeMode(dialogRef.current, document.getElementById(inputId) ?? getAnchorElement?.());
74
77
  // openDialog renders synchronously (flushSync), which includes this update, so the combobox
75
78
  // mounts with the search value already set.
76
79
  setInitialSearchValue(searchValue);
@@ -138,7 +141,9 @@ export function AfLookup<T extends Record<string, unknown>>({
138
141
  style={
139
142
  {
140
143
  display: fullWidth ? "block" : "inline-block",
141
- "--ObLookup-buttons-width": hasClear ? "5.5rem" : "3rem",
144
+ "--ObLookup-buttons-width": hasClear
145
+ ? "calc(2 * var(--ds-size-12) - var(--ds-size-2))"
146
+ : "var(--ds-size-12)",
142
147
  } as React.CSSProperties
143
148
  }
144
149
  >
@@ -157,7 +162,6 @@ export function AfLookup<T extends Record<string, unknown>>({
157
162
  <Button
158
163
  icon
159
164
  variant="tertiary"
160
- data-size="sm"
161
165
  aria-label={getLocalizedString("Clear")}
162
166
  onKeyDown={(evt) => evt.stopPropagation()}
163
167
  onClick={(evt) => {
@@ -169,13 +173,7 @@ export function AfLookup<T extends Record<string, unknown>>({
169
173
  <XMarkIcon />
170
174
  </Button>
171
175
  )}
172
- <Button
173
- icon
174
- variant="tertiary"
175
- data-size="sm"
176
- aria-label={getLocalizedString("Search")}
177
- onClick={() => openDialog()}
178
- >
176
+ <Button icon variant="tertiary" aria-label={getLocalizedString("Search")} onClick={() => openDialog()}>
179
177
  <MagnifyingGlassIcon />
180
178
  </Button>
181
179
  </div>
@@ -183,15 +183,7 @@ export function LookupCombobox<T extends Record<string, unknown>>(props: LookupC
183
183
  return (
184
184
  // oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- element is not interactive, click is used to prevent dialog close
185
185
  // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- element is not interactive, click is used to prevent dialog close
186
- // Compact on desktop, where the popup is a small dialog next to the field; the mobile
187
- // bottom sheet keeps the default size for touch. The size cascades to the search field,
188
- // the rows and the buttons.
189
- <div
190
- className="ObCombobox-root"
191
- id={id}
192
- data-size={isDesktop ? "sm" : "md"}
193
- onClick={(evt) => evt.stopPropagation()}
194
- >
186
+ <div className="ObCombobox-root" id={id} onClick={(evt) => evt.stopPropagation()}>
195
187
  <Textfield
196
188
  type="search"
197
189
  className="ObCombobox-input"
@@ -79,7 +79,9 @@ export function AfLookupEdit<T extends Record<string, unknown>>({
79
79
  style={
80
80
  {
81
81
  display: fullWidth ? "block" : "inline-block",
82
- "--ObLookup-buttons-width": hasClear ? "5.5rem" : "3rem",
82
+ "--ObLookup-buttons-width": hasClear
83
+ ? "calc(2 * var(--ds-size-12) - var(--ds-size-2))"
84
+ : "var(--ds-size-12)",
83
85
  } as React.CSSProperties
84
86
  }
85
87
  >
@@ -98,7 +100,6 @@ export function AfLookupEdit<T extends Record<string, unknown>>({
98
100
  <Button
99
101
  icon
100
102
  variant="tertiary"
101
- data-size="sm"
102
103
  aria-label={getLocalizedString("Clear")}
103
104
  onKeyDown={(evt) => evt.stopPropagation()}
104
105
  onClick={(evt) => {
@@ -113,7 +114,6 @@ export function AfLookupEdit<T extends Record<string, unknown>>({
113
114
  <Button
114
115
  icon
115
116
  variant="tertiary"
116
- data-size="sm"
117
117
  aria-label={getLocalizedString("Search")}
118
118
  onClick={(evt) => {
119
119
  evt.stopPropagation();
@@ -16,6 +16,7 @@
16
16
  * row inside is moved sideways with a transform to follow it.
17
17
  */
18
18
  .ObLookupGrid-header {
19
+ /* The height is set inline from the size mode (`useDsSize`); this is the fallback. */
19
20
  height: 40px;
20
21
  overflow: hidden;
21
22
  border-bottom: 1px solid var(--ds-color-neutral-border-default);
@@ -35,7 +36,7 @@
35
36
  display: flex;
36
37
  align-items: center;
37
38
  height: 100%;
38
- padding: 0 8px;
39
+ padding: 0 var(--ds-size-2, 8px);
39
40
  overflow: hidden;
40
41
  }
41
42
 
@@ -123,7 +124,7 @@
123
124
  }
124
125
 
125
126
  .ObLookupGrid-cell {
126
- padding: 0 8px;
127
+ padding: 0 var(--ds-size-2, 8px);
127
128
  height: 100%;
128
129
  display: flex;
129
130
  align-items: center;
@@ -7,9 +7,10 @@ import clsx from "clsx";
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { List, type RowComponentProps, useListRef } from "react-window";
9
9
  import { getLocalizedString } from "../i18n.js";
10
+ import { useDsSize } from "../theme/sizeMode.js";
10
11
 
12
+ /** The row and header height until `--ds-size-10` is measured, which follows the size mode like AfGrid's rows. */
11
13
  const ROW_HEIGHT = 40;
12
- const HEADER_HEIGHT = 40;
13
14
  /** Narrowest a column can be made, and what a flexible column counts for in the minimum width */
14
15
  const MIN_COLUMN_WIDTH = 80;
15
16
 
@@ -190,6 +191,8 @@ export function LookupGrid<T extends Record<string, unknown>>({
190
191
  }: LookupGridProps<T>) {
191
192
  let listRef = useListRef(null);
192
193
  let headerRowRef = useRef<HTMLDivElement>(null);
194
+ let rootRef = useRef<HTMLDivElement>(null);
195
+ let rowHeight = useDsSize(rootRef, 10, ROW_HEIGHT);
193
196
  let [resizedWidths, setResizedWidths] = useState<Partial<Record<string, number>>>({});
194
197
  let [resizing, setResizing] = useState<{ field: string; startWidth: number } | null>(null);
195
198
  let [listInnerWidth, setListInnerWidth] = useState<number>();
@@ -246,12 +249,13 @@ export function LookupGrid<T extends Record<string, unknown>>({
246
249
 
247
250
  return (
248
251
  <div
252
+ ref={rootRef}
249
253
  role="grid"
250
254
  aria-label={getLocalizedString("Search results")}
251
255
  className="ObLookupGrid-root"
252
256
  data-resizing={resizing ? "" : undefined}
253
257
  >
254
- <div className="ObLookupGrid-header">
258
+ <div className="ObLookupGrid-header" style={{ height: rowHeight }}>
255
259
  <div
256
260
  role="row"
257
261
  ref={headerRowRef}
@@ -301,9 +305,9 @@ export function LookupGrid<T extends Record<string, unknown>>({
301
305
  role="rowgroup"
302
306
  listRef={listRef}
303
307
  className="ObLookupGrid-list"
304
- style={{ height: Math.max(0, height - HEADER_HEIGHT) }}
308
+ style={{ height: Math.max(0, height - rowHeight) }}
305
309
  rowCount={data.length}
306
- rowHeight={ROW_HEIGHT}
310
+ rowHeight={rowHeight}
307
311
  overscanCount={5}
308
312
  onScroll={handleScroll}
309
313
  rowComponent={LookupGridRow}
@@ -1,6 +1,6 @@
1
1
  .ObAutocomplete-root {
2
- --obAutocomplete-padding: 0.25rem;
3
- --obAutocomplete-gap: 0.25rem;
2
+ --obAutocomplete-padding: var(--ds-size-1, 0.25rem);
3
+ --obAutocomplete-gap: var(--ds-size-1, 0.25rem);
4
4
 
5
5
  display: inline-grid;
6
6
  }
@@ -25,22 +25,11 @@
25
25
 
26
26
  /*
27
27
  * Font size is deliberately never set: `ds-input` inherits its font size when a
28
- * `data-size` attribute is present, so referencing `--ds-font-size-*` here — which
29
- * `data-size` rescales — would render the text smaller than a `Textfield` or
30
- * `Select` of the same size. Only the padding varies per size, and only for the
31
- * sizes Designsystemet components come in (sm, md, lg; xs and xl are typography-only).
28
+ * `data-size` attribute is present, so referencing `--ds-font-size-*` here - which
29
+ * `data-size` rescales - would render the text smaller than a `Textfield` or
30
+ * `Select` of the same size. The padding and gap are `--ds-size-1`, which follows
31
+ * the size mode on its own, so no size needs a rule of its own.
32
32
  */
33
- .ObAutocomplete-root[data-size="sm"] {
34
- --obAutocomplete-padding: 0.25rem;
35
- }
36
-
37
- .ObAutocomplete-root[data-size="md"] {
38
- --obAutocomplete-padding: 0.25rem;
39
- }
40
-
41
- .ObAutocomplete-root[data-size="lg"] {
42
- --obAutocomplete-padding: 0.375rem;
43
- }
44
33
 
45
34
  /*
46
35
  * The control is the visual input: it owns the border, background, hover and
@@ -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
+ }
@@ -1,49 +1,49 @@
1
- import { Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
2
- import { useField } from "@olenbetong/appframe-react";
3
- import { forwardRef } from "react";
4
-
5
- /*
6
- * `TextfieldProps` is a union of the input and the textarea variant. A plain `Omit` over it keeps
7
- * only the props both branches share, which silently drops the textarea-only ones (`rows`,
8
- * `cols`) — so `multiline` was accepted but `rows` was not. Distributing the `Omit` over the union
9
- * keeps each branch intact.
10
- */
11
- type OmitBoundProps<T> = T extends unknown ? Omit<T, "value" | "onChange" | "onKeyDown"> : never;
12
-
13
- export type BoundTextFieldProps = OmitBoundProps<TextfieldProps> & {
14
- field: string;
15
- };
16
-
17
- export const BoundTextField = forwardRef<HTMLInputElement & HTMLTextAreaElement, BoundTextFieldProps>(
18
- function BoundTextField({ field, ...props }, ref) {
19
- let { error, value, onChange, onKeyDown } = useField<any, any>(field);
20
-
21
- let inputValue = value;
22
- if (props.type === "date" && value instanceof Date) {
23
- inputValue = value.toISOString().split("T")[0];
24
- } else if (["datetime", "datetime-local"].includes(props.type ?? "") && value instanceof Date) {
25
- inputValue = value.toISOString().replace("Z", "");
26
- }
27
-
28
- return (
29
- <Textfield
30
- name={field}
31
- error={error ?? undefined}
32
- {...(props as any)}
33
- value={inputValue ?? ""}
34
- onChange={(evt) => {
35
- if (["datetime", "datetime-local"].includes(props.type ?? "")) {
36
- onChange(
37
- evt as any,
38
- (evt.target as HTMLInputElement).value ? `${(evt.target as HTMLInputElement).value}Z` : null,
39
- );
40
- } else {
41
- onChange(evt as any);
42
- }
43
- }}
44
- onKeyDown={(evt) => onKeyDown(evt as any)}
45
- ref={ref}
46
- />
47
- );
48
- },
49
- );
1
+ import { Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
2
+ import { useField } from "@olenbetong/appframe-react";
3
+ import { forwardRef } from "react";
4
+
5
+ /*
6
+ * `TextfieldProps` is a union of the input and the textarea variant. A plain `Omit` over it keeps
7
+ * only the props both branches share, which silently drops the textarea-only ones (`rows`,
8
+ * `cols`) — so `multiline` was accepted but `rows` was not. Distributing the `Omit` over the union
9
+ * keeps each branch intact.
10
+ */
11
+ type OmitBoundProps<T> = T extends unknown ? Omit<T, "value" | "onChange" | "onKeyDown"> : never;
12
+
13
+ export type BoundTextFieldProps = OmitBoundProps<TextfieldProps> & {
14
+ field: string;
15
+ };
16
+
17
+ export const BoundTextField = forwardRef<HTMLInputElement & HTMLTextAreaElement, BoundTextFieldProps>(
18
+ function BoundTextField({ field, ...props }, ref) {
19
+ let { error, value, onChange, onKeyDown } = useField<any, any>(field);
20
+
21
+ let inputValue = value;
22
+ if (props.type === "date" && value instanceof Date) {
23
+ inputValue = value.toISOString().split("T")[0];
24
+ } else if (["datetime", "datetime-local"].includes(props.type ?? "") && value instanceof Date) {
25
+ inputValue = value.toISOString().replace("Z", "");
26
+ }
27
+
28
+ return (
29
+ <Textfield
30
+ name={field}
31
+ error={error ?? undefined}
32
+ {...(props as any)}
33
+ value={inputValue ?? ""}
34
+ onChange={(evt) => {
35
+ if (["datetime", "datetime-local"].includes(props.type ?? "")) {
36
+ onChange(
37
+ evt as any,
38
+ (evt.target as HTMLInputElement).value ? `${(evt.target as HTMLInputElement).value}Z` : null,
39
+ );
40
+ } else {
41
+ onChange(evt as any);
42
+ }
43
+ }}
44
+ onKeyDown={(evt) => onKeyDown(evt as any)}
45
+ ref={ref}
46
+ />
47
+ );
48
+ },
49
+ );