@olenbetong/appframe-ds 0.9.1 → 1.0.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 (70) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/package.json +21 -16
  3. package/scripts/copyAssets.mjs +3 -0
  4. package/src/autocomplete/Autocomplete.css +13 -0
  5. package/src/autocomplete/Autocomplete.tsx +3 -1
  6. package/src/autocomplete/Combobox.tsx +16 -3
  7. package/src/binding/BoundTextField.tsx +9 -1
  8. package/src/filter/FieldFilterPanel.css +25 -2
  9. package/src/grid/AfGridColumnsPanel.tsx +1 -1
  10. package/src/grid/AfGridContext.tsx +1 -1
  11. package/src/grid/AfGridError.tsx +1 -1
  12. package/src/grid/AfHeaderFilterCell.tsx +1 -1
  13. package/src/grid/Toolbar.tsx +1 -1
  14. package/src/grid/editing.ts +17 -7
  15. package/src/grid/filter.ts +1 -1
  16. package/src/grid/index.css +59 -1
  17. package/src/grid/index.tsx +126 -18
  18. package/src/grid/localization.ts +1 -1
  19. package/src/grid/slots/index.tsx +27 -2
  20. package/src/grid/theme.tsx +3 -50
  21. package/src/grid/useAfColumns.tsx +103 -45
  22. package/src/grid/useAfCurrentIndex.ts +13 -5
  23. package/src/grid/useAfGridApi.ts +6 -1
  24. package/src/grid/useAfKeyBindings.ts +1 -1
  25. package/src/grid/useAfNewItemRow.ts +258 -0
  26. package/src/grid/useAfPagination.ts +2 -2
  27. package/src/grid/useAfPersistedState.ts +9 -1
  28. package/src/grid/useAfRowEditModel.ts +20 -12
  29. package/src/grid/useAfRowGrouping.ts +47 -0
  30. package/src/grid/useAfServerAggregation.ts +145 -0
  31. package/src/grid/useAfSortModel.ts +15 -3
  32. package/src/layout/AppMenuDrawer.css +70 -0
  33. package/src/layout/AppMenuDrawer.tsx +122 -0
  34. package/src/layout/index.tsx +1 -0
  35. package/src/mdi/Mdi.css +138 -0
  36. package/src/mdi/Mdi.test.tsx +87 -0
  37. package/src/mdi/Mdi.tsx +141 -0
  38. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -0
  39. package/src/mdi/MdiContext.tsx +59 -0
  40. package/src/mdi/MdiDocumentContext.tsx +60 -0
  41. package/src/mdi/MdiPanel.tsx +61 -0
  42. package/src/mdi/MdiRestore.test.tsx +82 -0
  43. package/src/mdi/MdiTab.tsx +30 -0
  44. package/src/mdi/controller.test.ts +382 -0
  45. package/src/mdi/controller.ts +656 -0
  46. package/src/mdi/dockviewHost.ts +132 -0
  47. package/src/mdi/fakeHost.ts +200 -0
  48. package/src/mdi/host.ts +44 -0
  49. package/src/mdi/ids.test.ts +20 -0
  50. package/src/mdi/ids.ts +18 -0
  51. package/src/mdi/index.ts +31 -0
  52. package/src/mdi/layoutRestore.test.ts +156 -0
  53. package/src/mdi/layoutRestore.ts +311 -0
  54. package/src/mdi/layoutSpec.test.ts +143 -0
  55. package/src/mdi/layoutSpec.ts +214 -0
  56. package/src/mdi/layoutStore.test.ts +93 -0
  57. package/src/mdi/layoutStore.ts +99 -0
  58. package/src/mdi/memorySettingsStore.ts +21 -0
  59. package/src/mdi/registry.ts +34 -0
  60. package/src/mdi/testTypes.ts +37 -0
  61. package/src/mdi/theme.ts +22 -0
  62. package/src/mdi/types.ts +214 -0
  63. package/src/page/Page.css +10 -2
  64. package/src/page/PageBlock.css +5 -0
  65. package/src/page/PageBlock.tsx +14 -0
  66. package/src/page/PageHeader.css +0 -60
  67. package/src/page/PageHeader.tsx +6 -74
  68. package/src/test/happy-dom-document-class.ts +24 -0
  69. package/src/test/setup.ts +4 -0
  70. package/src/theme/useDsColorScheme.ts +53 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,118 @@
1
1
  # @olenbetong/appframe-ds
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 3c78efe: Migrate the grid from MUI X Pro to MUI X Premium.
8
+
9
+ **Breaking:** the `@mui/x-data-grid-pro` peer dependency is replaced by
10
+ `@mui/x-data-grid-premium`, and the MUI X license key must be a Premium key
11
+ (`S=premium`). A Pro key makes every grid fail with "license key plan mismatch".
12
+
13
+ `AfGrid` still renders and behaves as before — `DataGridPremium` is a superset of
14
+ `DataGridPro` — but the re-exported namespaces now come from the Premium package:
15
+ - `@olenbetong/appframe-ds/grid` → `MuiDataGrid`
16
+ - `@olenbetong/appframe-mui` → `DataGrid`
17
+
18
+ Consumers importing `DataGridPro`, `DataGridProProps` or `GridApiPro` through those
19
+ namespaces must rename them to `DataGridPremium`, `DataGridPremiumProps` and
20
+ `GridApiPremium`.
21
+
22
+ Row grouping and aggregation are now wired up:
23
+ - The column menu gains "Group by …" and "Aggregation", from the Premium package.
24
+ - Both models are persisted with the rest of the grid layout, and restored on
25
+ reload. Turn either off with `persistenceModel.rowGrouping` /
26
+ `persistenceModel.aggregation`.
27
+ - While a grouping is active the grid falls back to client side sorting, since
28
+ grouped rows are built and ordered on the client. `disableServerSorting` no
29
+ longer has to be set by hand for a grouped grid.
30
+ - The new `useAfRowGrouping` hook is exported for grids that compose the grid
31
+ props themselves.
32
+
33
+ Note that grouping and aggregation are computed over the rows the grid holds. For
34
+ a dynamically loading data object that is the current page only, so a grouped grid
35
+ wants `maxRecords` large enough to cover the result set.
36
+
37
+ ### Minor Changes
38
+
39
+ - c6ee375: `AfGrid` can resolve the grand total on the server. MUI aggregates over the rows it has been given,
40
+ which on a dynamically loading data object is the current page — a sum that looks like a total and
41
+ is not one. The grid now runs a second retrieve under the same filters asking only for the
42
+ aggregates, and substitutes that into the footer. On by default when the data object loads
43
+ dynamically; `serverAggregation={false}` opts out. `sum`, `avg`, `min` and `max` are resolved server
44
+ side, group subtotals stay on the client, and a view that cannot be aggregated falls back to the
45
+ client total rather than failing.
46
+
47
+ Aggregation rows also no longer run a column's `renderCell`. A total row has no record behind it, so
48
+ a `renderCell` reading `params.row` rendered nothing there — turning on a sum produced a pinned row
49
+ of empty cells, and unaggregated columns rendered whatever their `renderCell` made of an empty
50
+ record. The grid renders the aggregated value instead, formatted by the column's `valueFormatter`.
51
+
52
+ Links inside a grid cell now take the brand colour and an underline instead of inheriting the cell's
53
+ text colour, which left them indistinguishable from ordinary text until hovered.
54
+
55
+ - 61afab0: Add `AppMenuDrawer` and `useAppMenuDrawer`, so an app can have an app menu without an appbar.
56
+
57
+ Apps on the `ob.2026.application` template get a toolbar from the template. Until now the app menu
58
+ was only reachable through `Page.Header`'s `appbar` variant, which draws a bar of its own — so
59
+ adding navigation meant a second, stacked toolbar. The drawer is now a component in its own right:
60
+ docked beside the content on desktop, an overlay on smaller screens, exactly as before.
61
+
62
+ `Page.Header` is refactored onto the same component and hook, so there is one implementation and
63
+ its behaviour is unchanged.
64
+
65
+ The drawer is now positioned against the page rather than the viewport (`absolute` inside
66
+ `Page`, which becomes `position: relative`), so it stays clear of any site chrome rendered above
67
+ the app. Raising its `z-index` would instead put the menu over that chrome and hide it — and
68
+ `#root` frequently creates its own stacking context, so it would not reliably work anyway. A
69
+ drawer therefore has to be inside `Page`, and `layout="app"` so the page has a height.
70
+
71
+ `Page` with `layout="app"` is now `height: 100%` rather than `100vh`. It fills the element the app
72
+ is mounted in; `100vh` was taller than the space available whenever a template rendered chrome
73
+ above `#root`, cutting off the bottom of the page.
74
+
75
+ - d64c1b1: Add the `Mdi` multi-document workspace under the new `@olenbetong/appframe-ds/mdi` subpath: tiled groups of tabbed documents on dockview, with a document-type registry (type → React component + params), open-or-activate by key, unclosable panes, a dirty-close prompt, popout windows, and per-user layout persistence in IndexedDB including the open documents. Adds the `dockview-react` dependency; it is only bundled by apps that import the subpath.
76
+ - 2e2ef82: Add a DevExpress-style new item row to `AfGrid`: a single non-data row pinned above or below the data (`newItemRow="top" | "bottom" | "none"`, default `bottom`) that reads "Click here to add a new row" until edited. Editing it moves the data object's current index to -1, the framework's new record, so bound forms and child data objects follow; committing it saves the record through `endEdit()`, and Escape discards it. The toolbar Add button and Alt+N start it, and `apiRef.current.startNewItemRow()` starts it from code. When `newItemRow` is not given, the position follows the data object's `newRecordPosition` option, so the row sits where the saved record will land; older data packages without the option get the row at the bottom. The previous approach of injecting an unsaved row into the grid is only used when the new item row is turned off.
77
+
78
+ Row ids are now read through the `uid` symbol exported by `@olenbetong/appframe-data` instead of the global `af.data.uid`. They are the same object in applications; the change matters where the data package is bundled separately, such as Storybook.
79
+
80
+ - e022401: `Page.Block` takes an `align` prop. `start` keeps a width-constrained block against the leading edge
81
+ instead of centring it in whatever space is left — what the old `PageContainer` did, and what an app
82
+ with a docked navigation drawer usually wants, since centring leaves the content floating away from
83
+ the menu it belongs to. Defaults to `center`, so nothing changes for existing blocks.
84
+
85
+ ### Patch Changes
86
+
87
+ - 6fa571f: `Combobox` and `Autocomplete` lay a custom option out as a row. `renderOption` content sat in a
88
+ bare inline `span`, so an option built from an avatar or icon beside a block of text — which is
89
+ what most of them are — dropped the text onto its own line and stacked the two. The wrapper is now
90
+ a flex row with the popup's own gap, and carries a class (`ObAutocomplete-itemContent`) so it can
91
+ be styled.
92
+ - 22cd5f4: `BoundTextField` now accepts the textarea-only props. `TextfieldProps` is a union of the input and
93
+ the textarea variant, and the non-distributive `Omit` over it kept only what both branches share —
94
+ so `multiline` type-checked but `rows` and `cols` did not.
95
+ - 22cd5f4: `Combobox` now reports the selected option's label through `onInputChange` with the reason
96
+ `"reset"`. It already wrote that label into its own input, but the write is a no-op when the caller
97
+ controls `inputValue`, so a controlled input stayed empty next to a value that was set from the
98
+ outside.
99
+ - 21c1977: Fix the distinct value list overlapping the Apply and Clear buttons in the grid's header filter panel.
100
+
101
+ The distinct section was free to shrink to nothing while the list inside it held a minimum height, so once the panel was shorter than its content the list spilled out — the section's overflow is visible — and painted over the buttons and the truncation hint below it. It now has an explicit floor covering the search toolbar, the hint and the list's own minimum, so a panel too short for that scrolls instead of overlapping. The floor cannot be `auto`: that resolves to min-content, and the list is the scroll container, so its min-content height is the entire unscrolled list.
102
+
103
+ The header filter panel's default height also goes from `26rem` to `30rem`, which is what the criteria, the list at its minimum and the hint actually need. The panel is still resizable and still capped at `80vh`.
104
+
105
+ - 96600c7: Stop the data grid from logging `Designsystemet: Missing tabindex="0" attribute on:` for every column header.
106
+
107
+ Designsystemet's tooltip labels its host element and warns when that host is not tabbable. Inside the grid it never is — the column header owns the tab stop and moves focus with the arrow keys, so MUI gives the column menu button `tabindex="-1"` and hangs the sort tooltip on a bare `<span>`. The tooltips work regardless, because Designsystemet resolves the host with `closest()` on hover and focus, so the warning was a false positive — and it is not gated on `NODE_ENV`, so it reached production builds too.
108
+
109
+ The `baseTooltip` slot now applies the accessible name itself, using the same rule Designsystemet does (`aria-description` when the host has text content, `aria-label` when it does not), which makes Designsystemet skip the branch that warns. Markup and behaviour are unchanged, and `window.dsWarnings` stays on for the rest of the app.
110
+
111
+ - Updated dependencies [690f764]
112
+ - Updated dependencies [2e2ef82]
113
+ - @olenbetong/appframe-react@2.1.3
114
+ - @olenbetong/appframe-data@1.7.0
115
+
3
116
  ## 0.9.1
4
117
 
5
118
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-ds",
3
- "version": "0.9.1",
3
+ "version": "1.0.0",
4
4
  "description": "Components that bind Designsystemet components to Appframe data objects",
5
5
  "type": "module",
6
6
  "types": "./es/index.d.ts",
@@ -13,6 +13,10 @@
13
13
  "types": "./es/grid/index.d.ts",
14
14
  "import": "./es/grid/index.js"
15
15
  },
16
+ "./mdi": {
17
+ "types": "./es/mdi/index.d.ts",
18
+ "import": "./es/mdi/index.js"
19
+ },
16
20
  "./report-downloader": {
17
21
  "types": "./es/report-downloader/index.d.ts",
18
22
  "import": "./es/report-downloader/index.js"
@@ -28,7 +32,7 @@
28
32
  "@emotion/react": ">=11.14.0",
29
33
  "@emotion/styled": ">=11.14.1",
30
34
  "@mui/material": ">=9.0.0",
31
- "@mui/x-data-grid-pro": ">=8.0.0 <9.0.0",
35
+ "@mui/x-data-grid-premium": ">=8.0.0 <9.0.0",
32
36
  "@mui/x-license": ">=8.0.0 <9.0.0",
33
37
  "@navikt/aksel-icons": ">=8.16.1",
34
38
  "react": ">=19.2.8",
@@ -46,7 +50,7 @@
46
50
  "@mui/material": {
47
51
  "optional": true
48
52
  },
49
- "@mui/x-data-grid-pro": {
53
+ "@mui/x-data-grid-premium": {
50
54
  "optional": true
51
55
  },
52
56
  "@mui/x-license": {
@@ -55,37 +59,38 @@
55
59
  },
56
60
  "dependencies": {
57
61
  "@atlaskit/pragmatic-drag-and-drop": "2.0.2",
58
- "@atlaskit/pragmatic-drag-and-drop-hitbox": "2.0.0",
62
+ "@atlaskit/pragmatic-drag-and-drop-hitbox": "2.2.0",
59
63
  "@base-ui/react": "1.7.0",
60
64
  "clsx": "^2.1.1",
65
+ "dockview-react": "8.2.0",
61
66
  "localforage": "^1.10.0",
62
67
  "mitt": "^3.0.1",
63
68
  "react-virtualized-auto-sizer": "^1.0.26",
64
69
  "react-window": "^1.8.11",
65
70
  "@olenbetong/appframe-core": "2.13.1",
66
- "@olenbetong/appframe-data": "1.6.2",
67
- "@olenbetong/appframe-react": "2.1.2"
71
+ "@olenbetong/appframe-data": "1.7.0",
72
+ "@olenbetong/appframe-react": "2.1.3"
68
73
  },
69
74
  "devDependencies": {
70
- "@digdir/designsystemet-react": "1.18.0",
75
+ "@digdir/designsystemet-react": "1.21.0",
71
76
  "@emotion/react": "^11.14.0",
72
77
  "@emotion/styled": "^11.14.1",
73
- "@mui/material": "9.3.1",
74
- "@mui/x-data-grid-pro": "8.29.2",
75
- "@mui/x-license": "8.29.2",
78
+ "@mui/material": "9.4.0",
79
+ "@mui/x-data-grid-premium": "8.29.3",
80
+ "@mui/x-license": "8.29.3",
76
81
  "@navikt/aksel-icons": "8.16.1",
77
82
  "@testing-library/dom": "^10.4.1",
78
83
  "@testing-library/jest-dom": "^7.0.1",
79
- "@testing-library/react": "16.3.2",
80
- "@testing-library/user-event": "^14.6.3",
84
+ "@testing-library/react": "16.3.3",
85
+ "@testing-library/user-event": "^14.6.6",
81
86
  "@types/react": "19.2.18",
82
- "@types/react-dom": "19.2.4",
87
+ "@types/react-dom": "19.2.5",
83
88
  "@types/react-window": "^1.8.8",
84
- "happy-dom": "^20.11.1",
89
+ "happy-dom": "^20.12.0",
85
90
  "react": "19.2.8",
86
91
  "react-dom": "19.2.8",
87
- "react-error-boundary": "6.1.2",
88
- "react-router": "8.3.0",
92
+ "react-error-boundary": "6.1.4",
93
+ "react-router": "8.3.1",
89
94
  "typescript": "7.0.2"
90
95
  },
91
96
  "scripts": {
@@ -5,6 +5,7 @@ const files = [
5
5
  ["src/AfLookup/LookupGrid.css", "es/AfLookup/LookupGrid.css"],
6
6
  ["src/AfLookup/LookupCombobox.css", "es/AfLookup/LookupCombobox.css"],
7
7
  ["src/layout/AppMenu.css", "es/layout/AppMenu.css"],
8
+ ["src/layout/AppMenuDrawer.css", "es/layout/AppMenuDrawer.css"],
8
9
  ["src/layout/AppMenuItem.css", "es/layout/AppMenuItem.css"],
9
10
  ["src/layout/AppMenuSubheader.css", "es/layout/AppMenuSubheader.css"],
10
11
  ["src/layout/AppLayoutDesktop.css", "es/layout/AppLayoutDesktop.css"],
@@ -30,6 +31,7 @@ const files = [
30
31
  ["src/filter/SavedFilterTree.css", "es/filter/SavedFilterTree.css"],
31
32
  ["src/grid/index.css", "es/grid/index.css"],
32
33
  ["src/grid/slots/slots.css", "es/grid/slots/slots.css"],
34
+ ["src/mdi/Mdi.css", "es/mdi/Mdi.css"],
33
35
  ["src/report-downloader/ReportDownloader.css", "es/report-downloader/ReportDownloader.css"],
34
36
  ];
35
37
 
@@ -44,6 +46,7 @@ mkdirSync("es/autocomplete", { recursive: true });
44
46
  mkdirSync("es/filter", { recursive: true });
45
47
  mkdirSync("es/grid", { recursive: true });
46
48
  mkdirSync("es/grid/slots", { recursive: true });
49
+ mkdirSync("es/mdi", { recursive: true });
47
50
  mkdirSync("es/report-downloader", { recursive: true });
48
51
 
49
52
  for (let [src, dest] of files) {
@@ -223,6 +223,19 @@
223
223
  opacity: var(--ds-opacity-disabled);
224
224
  }
225
225
 
226
+ /*
227
+ * The option's content. It is a flex row rather than the inline box it looks like, because
228
+ * `renderOption` is normally an icon or an avatar beside a block of text — and a block child in an
229
+ * inline parent drops onto its own line, stacking the two instead of setting them side by side.
230
+ */
231
+ .ObAutocomplete-itemContent {
232
+ display: flex;
233
+ align-items: center;
234
+ gap: var(--ds-size-2);
235
+ flex: 1;
236
+ min-inline-size: 0;
237
+ }
238
+
226
239
  .ObAutocomplete-itemIndicator {
227
240
  display: inline-flex;
228
241
  flex: none;
@@ -228,7 +228,9 @@ export function Autocomplete<
228
228
  data-selected={selected || undefined}
229
229
  onClick={(event) => handleSelectOption(option, event.nativeEvent)}
230
230
  >
231
- <span>{renderOption?.(option, { index, inputValue, selected }) ?? getOptionLabel(option)}</span>
231
+ <span className="ObAutocomplete-itemContent">
232
+ {renderOption?.(option, { index, inputValue, selected }) ?? getOptionLabel(option)}
233
+ </span>
232
234
  </BaseAutocomplete.Item>
233
235
  );
234
236
  }
@@ -121,14 +121,25 @@ export function Combobox<Option, Multiple extends boolean | undefined = false>(p
121
121
  // the user interacts, but not when the selection is set from the outside.
122
122
  let selectedLabel = multiple || value == null ? "" : getOptionLabel(value as Option);
123
123
  let [inputValue, setInputValue] = useControlled(inputValueProp, selectedLabel);
124
- let previousLabel = useRef(selectedLabel);
124
+ // `null` until the first sync, so a value that is already set on mount is synced too.
125
+ let previousLabel = useRef<string | null>(null);
125
126
 
126
127
  useEffect(() => {
127
128
  if (previousLabel.current === selectedLabel) return;
128
129
 
130
+ let firstSync = previousLabel.current === null;
129
131
  previousLabel.current = selectedLabel;
132
+
133
+ // Nothing to correct on mount when no option is selected, and blanking the input would
134
+ // throw away an initial `inputValue` the caller set deliberately.
135
+ if (firstSync && selectedLabel === "") return;
136
+
130
137
  setInputValue(selectedLabel);
131
- }, [selectedLabel, setInputValue]);
138
+ // `setInputValue` is a no-op when the caller controls `inputValue`, so the label has to
139
+ // be reported instead — otherwise a controlled input stays empty next to a selected
140
+ // value. Mirrors MUI, which reports the same thing with the reason `"reset"`.
141
+ onInputChange?.(undefined, selectedLabel, "reset");
142
+ }, [selectedLabel, setInputValue, onInputChange]);
132
143
 
133
144
  let filteredOptions = useMemo(() => {
134
145
  let filter = (filterOptions ?? defaultFilterOptions) as (
@@ -190,7 +201,9 @@ export function Combobox<Option, Multiple extends boolean | undefined = false>(p
190
201
  {multiple && (
191
202
  <BaseCombobox.ItemIndicator className="ObAutocomplete-itemIndicator">✓</BaseCombobox.ItemIndicator>
192
203
  )}
193
- <span>{renderOption?.(option, { index, inputValue, selected }) ?? getOptionLabel(option)}</span>
204
+ <span className="ObAutocomplete-itemContent">
205
+ {renderOption?.(option, { index, inputValue, selected }) ?? getOptionLabel(option)}
206
+ </span>
194
207
  </BaseCombobox.Item>
195
208
  );
196
209
  }
@@ -2,7 +2,15 @@ import { Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
2
2
  import { useField } from "@olenbetong/appframe-react";
3
3
  import { forwardRef } from "react";
4
4
 
5
- export type BoundTextFieldProps = Omit<TextfieldProps, "value" | "onChange" | "onKeyDown"> & {
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> & {
6
14
  field: string;
7
15
  };
8
16
 
@@ -41,13 +41,36 @@
41
41
  }
42
42
 
43
43
  .ObFieldFilterPanel-distinct {
44
+ /*
45
+ * The parts the section cannot usefully shrink past, named so the floor below
46
+ * and the list's own minimum cannot drift apart.
47
+ */
48
+ --obFieldFilterPanel-list-min-size: 8rem;
49
+ --obFieldFilterPanel-toolbar-size: 2.75rem; /* a `sm` search field */
50
+ --obFieldFilterPanel-hint-size: 1.25rem; /* one line of truncation hint */
51
+
44
52
  display: flex;
45
53
  flex-direction: column;
46
54
  flex: 1 1 auto;
47
55
  gap: var(--ds-size-2, 0.5rem);
48
- min-block-size: 0;
49
56
  padding-block-start: var(--ds-size-2, 0.5rem);
50
57
  border-block-start: var(--ds-border-width-default, 1px) solid var(--ds-color-neutral-border-subtle, #e7e7e7);
58
+
59
+ /*
60
+ * The section follows the panel's height, but only down to the toolbar, the
61
+ * hint and the list's own minimum. Without a floor it kept shrinking while
62
+ * the list held its minimum, and since the overflow here is visible the list
63
+ * spilled out and painted over the buttons the panel puts after it.
64
+ *
65
+ * The floor has to be an explicit length. `auto` resolves to min-content, and
66
+ * the list is the scroll container, so its min-content height is the whole
67
+ * unscrolled list — that would stretch the section to fit every distinct
68
+ * value. Once the panel is too short for this, it scrolls instead.
69
+ */
70
+ min-block-size: calc(
71
+ var(--obFieldFilterPanel-list-min-size) + var(--obFieldFilterPanel-toolbar-size) +
72
+ var(--obFieldFilterPanel-hint-size) + 2 * var(--ds-size-2, 0.5rem)
73
+ );
51
74
  }
52
75
 
53
76
  .ObFieldFilterPanel-distinctToolbar {
@@ -80,7 +103,7 @@
80
103
  */
81
104
  .ObFieldFilterPanel-valuesBox {
82
105
  flex: 1 1 auto;
83
- min-block-size: 8rem;
106
+ min-block-size: var(--obFieldFilterPanel-list-min-size, 8rem);
84
107
  border: var(--ds-border-width-default, 1px) solid var(--ds-color-neutral-border-subtle, #e7e7e7);
85
108
  border-radius: var(--ds-border-radius-md, 0.25rem);
86
109
  background: var(--ds-color-neutral-surface-tinted, rgb(0 0 0 / 2%));
@@ -4,7 +4,7 @@ import {
4
4
  gridColumnDefinitionsSelector,
5
5
  gridColumnVisibilityModelSelector,
6
6
  useGridSelector,
7
- } from "@mui/x-data-grid-pro";
7
+ } from "@mui/x-data-grid-premium";
8
8
  import { getLocalizedString } from "@olenbetong/appframe-core";
9
9
 
10
10
  import { useState } from "react";
@@ -1,4 +1,4 @@
1
- import type { GridApi } from "@mui/x-data-grid-pro";
1
+ import type { GridApi } from "@mui/x-data-grid-premium";
2
2
  import type { FieldMetadata, FilterExpression, FilterTree } from "@olenbetong/appframe-core";
3
3
  import { createContext, useContext } from "react";
4
4
 
@@ -1,5 +1,5 @@
1
1
  import { Alert, Button } from "@digdir/designsystemet-react";
2
- import type { GridApi } from "@mui/x-data-grid-pro";
2
+ import type { GridApi } from "@mui/x-data-grid-premium";
3
3
  import { getLocalizedString } from "@olenbetong/appframe-core";
4
4
  import type { DataObject } from "@olenbetong/appframe-data";
5
5
  import { useError } from "@olenbetong/appframe-react";
@@ -1,5 +1,5 @@
1
1
  import { Button, Popover } from "@digdir/designsystemet-react";
2
- import type { GridRenderHeaderFilterProps } from "@mui/x-data-grid-pro";
2
+ import type { GridRenderHeaderFilterProps } from "@mui/x-data-grid-premium";
3
3
  import { FunnelFillIcon, FunnelIcon, XMarkIcon } from "@navikt/aksel-icons";
4
4
  import {
5
5
  createExpression,
@@ -4,7 +4,7 @@ import {
4
4
  type GridExportMenuItemProps,
5
5
  gridVisibleColumnDefinitionsSelector,
6
6
  useGridApiContext,
7
- } from "@mui/x-data-grid-pro";
7
+ } from "@mui/x-data-grid-premium";
8
8
  import {
9
9
  ArrowsCirclepathIcon,
10
10
  Density1Icon,
@@ -1,9 +1,15 @@
1
- import { type GridApi, gridFocusCellSelector, gridVisibleColumnDefinitionsSelector } from "@mui/x-data-grid-pro";
1
+ import { type GridApi, gridFocusCellSelector, gridVisibleColumnDefinitionsSelector } from "@mui/x-data-grid-premium";
2
2
  import { getLocalizedString } from "@olenbetong/appframe-core";
3
- import type { DataObject } from "@olenbetong/appframe-data";
3
+ import { type DataObject, uid } from "@olenbetong/appframe-data";
4
4
 
5
5
  import type React from "react";
6
6
 
7
+ import type { AfGridApi } from "./useAfGridApi.js";
8
+
9
+ /**
10
+ * Starts a new row. With a new item row on the grid that row is focused and put
11
+ * in edit mode; otherwise an unsaved row is appended to the data and edited there.
12
+ */
7
13
  export function addRow<T extends { PrimKey: string }>({
8
14
  apiRef,
9
15
  dataObject,
@@ -11,9 +17,13 @@ export function addRow<T extends { PrimKey: string }>({
11
17
  apiRef: React.RefObject<GridApi | null>;
12
18
  dataObject: DataObject<T>;
13
19
  }) {
20
+ if ((apiRef.current as AfGridApi | null)?.startNewItemRow?.()) {
21
+ return;
22
+ }
23
+
14
24
  let newRow = dataObject.newRow();
15
- let id = (newRow as any)[(af as any).data.uid];
16
- apiRef.current?.updateRows([{ [(af as any).data.uid]: id, id, isNew: true } as any]);
25
+ let id = (newRow as any)[uid];
26
+ apiRef.current?.updateRows([{ [uid]: id, id, isNew: true } as any]);
17
27
  apiRef.current?.startRowEditMode({ id });
18
28
 
19
29
  // Wait for the grid to render with the new row
@@ -48,7 +58,7 @@ export async function deleteRow<T extends { PrimKey: string }>({
48
58
  }
49
59
 
50
60
  let focusBefore = gridFocusCellSelector(apiRef);
51
- let index = dataObject.findIndex((r) => (r as any)[idField] === id || (r as any)[(af as any).data.uid] === id);
61
+ let index = dataObject.findIndex((r) => (r as any)[idField] === id || (r as any)[uid] === id);
52
62
 
53
63
  if (index < 0) {
54
64
  alert(getLocalizedString("Row not found"));
@@ -60,12 +70,12 @@ export async function deleteRow<T extends { PrimKey: string }>({
60
70
  let focusAfter = gridFocusCellSelector(apiRef);
61
71
 
62
72
  // Need to run this operation to prevent MUI errors
63
- apiRef.current?.updateRows([{ [af.data.uid]: id, id, _action: "delete" }]);
73
+ apiRef.current?.updateRows([{ [uid]: id, id, _action: "delete" }]);
64
74
 
65
75
  if (!focusAfter || focusAfter.id === id) {
66
76
  if (dataObject.getDataLength() > 0) {
67
77
  let newIndex = Math.max(0, Math.min(dataObject.getDataLength() - 1, index - 1));
68
- let newId = (dataObject.getData(newIndex) as any)[(af as any).data.uid];
78
+ let newId = (dataObject.getData(newIndex) as any)[uid];
69
79
  let newField = focusBefore?.field ?? gridVisibleColumnDefinitionsSelector(apiRef)[0]?.field;
70
80
 
71
81
  if (newId && newField) {
@@ -10,7 +10,7 @@ import {
10
10
  getGridNumericOperators,
11
11
  getGridSingleSelectOperators,
12
12
  getGridStringOperators,
13
- } from "@mui/x-data-grid-pro";
13
+ } from "@mui/x-data-grid-premium";
14
14
  import { getLocalizedString } from "@olenbetong/appframe-core";
15
15
 
16
16
  const stringOperators = getGridStringOperators();
@@ -162,8 +162,14 @@
162
162
 
163
163
  inline-size: 22rem;
164
164
  max-inline-size: 90vw;
165
- block-size: 26rem;
166
165
  max-block-size: 80vh;
166
+
167
+ /*
168
+ * Tall enough for the criteria, the distinct list at its minimum and the
169
+ * truncation hint, without the panel having to scroll. At 26rem the hint and
170
+ * the buttons below it did not fit.
171
+ */
172
+ block-size: 30rem;
167
173
  }
168
174
 
169
175
  /*
@@ -185,3 +191,55 @@
185
191
  flex: 1 1 auto;
186
192
  min-block-size: 0;
187
193
  }
194
+
195
+ /* New item row */
196
+
197
+ /*
198
+ * The new item row is a pinned row that shows an overlay text until it is
199
+ * edited. The text comes from the `--AfGrid-newItemRowText` custom property the
200
+ * grid root sets, so it is localized without an extra DOM node. In view mode the
201
+ * cell contents are hidden rather than removed, so keyboard focus still lands
202
+ * on a cell and a double-click or keypress starts editing as on any row.
203
+ */
204
+ .MuiDataGrid-row.AfGrid-newItemRow {
205
+ position: relative;
206
+ }
207
+
208
+ .MuiDataGrid-row.AfGrid-newItemRow:not(.MuiDataGrid-row--editing) .MuiDataGrid-cell > * {
209
+ visibility: hidden;
210
+ }
211
+
212
+ .MuiDataGrid-row.AfGrid-newItemRow:not(.MuiDataGrid-row--editing)::after {
213
+ content: var(--AfGrid-newItemRowText, "");
214
+ position: absolute;
215
+ inset: 0;
216
+ display: flex;
217
+ align-items: center;
218
+ padding-inline: var(--ds-size-2, 0.5rem);
219
+ color: var(--ds-color-neutral-text-subtle, #68707c);
220
+ font-style: italic;
221
+ pointer-events: none;
222
+ }
223
+
224
+ /*
225
+ * Links in a cell.
226
+ *
227
+ * The grid sets its own text colour on the cell, and an anchor inherits it, so a link to another
228
+ * record renders as ordinary text and is only discoverable by hovering it. These give it the
229
+ * brand colour and an underline the way a link anywhere else on the site has, without the app
230
+ * having to style every cell that holds one.
231
+ */
232
+ .AfGrid-root .MuiDataGrid-cell a {
233
+ color: var(--ds-color-brand-text-default, #0062ba);
234
+ text-decoration: underline;
235
+ text-underline-offset: 0.15em;
236
+ }
237
+
238
+ .AfGrid-root .MuiDataGrid-cell a:hover {
239
+ color: var(--ds-color-brand-text-subtle, #00417d);
240
+ }
241
+
242
+ /* Action buttons render as anchors when a column links a row somewhere; they style themselves. */
243
+ .AfGrid-root .MuiDataGrid-cell a.ds-button {
244
+ text-decoration: none;
245
+ }