@olenbetong/appframe-ds 0.2.0 → 0.4.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 (117) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/es/AfLookup/BoundLookup.d.ts +10 -0
  3. package/es/AfLookup/BoundLookup.js +16 -0
  4. package/es/AfLookup/Combobox.css +78 -0
  5. package/es/AfLookup/Combobox.d.ts +31 -0
  6. package/es/AfLookup/Combobox.js +71 -0
  7. package/es/AfLookup/Lookup.css +125 -0
  8. package/es/AfLookup/Lookup.d.ts +35 -0
  9. package/es/AfLookup/Lookup.js +42 -0
  10. package/es/AfLookup/LookupEdit.d.ts +21 -0
  11. package/es/AfLookup/LookupEdit.js +49 -0
  12. package/es/AfLookup/LookupGrid.css +57 -0
  13. package/es/AfLookup/LookupGrid.d.ts +13 -0
  14. package/es/AfLookup/LookupGrid.js +27 -0
  15. package/es/AfLookup/index.d.ts +5 -0
  16. package/es/AfLookup/index.js +5 -0
  17. package/es/binding/BoundCheckbox.d.ts +9 -0
  18. package/es/binding/BoundCheckbox.js +13 -0
  19. package/es/binding/BoundDatePicker.d.ts +7 -0
  20. package/es/binding/BoundDatePicker.js +12 -0
  21. package/es/binding/BoundDateTimePicker.d.ts +7 -0
  22. package/es/binding/BoundDateTimePicker.js +14 -0
  23. package/es/binding/BoundInput.d.ts +6 -0
  24. package/es/binding/BoundInput.js +12 -0
  25. package/es/binding/BoundNumericTextField.d.ts +20 -0
  26. package/es/binding/BoundNumericTextField.js +58 -0
  27. package/es/binding/BoundSelect.d.ts +9 -0
  28. package/es/binding/BoundSelect.js +13 -0
  29. package/es/binding/BoundSwitch.d.ts +9 -0
  30. package/es/binding/BoundSwitch.js +13 -0
  31. package/es/binding/BoundTextField.d.ts +7 -0
  32. package/es/binding/BoundTextField.js +22 -0
  33. package/es/binding/CancelButton.d.ts +3 -0
  34. package/es/binding/CancelButton.js +14 -0
  35. package/es/binding/CancelIconButton.d.ts +2 -0
  36. package/es/binding/CancelIconButton.js +15 -0
  37. package/es/binding/DataEditToolbar.d.ts +13 -0
  38. package/es/binding/DataEditToolbar.js +28 -0
  39. package/es/binding/DataObjectSelect.d.ts +50 -0
  40. package/es/binding/DataObjectSelect.js +26 -0
  41. package/es/binding/DeleteButton.d.ts +8 -0
  42. package/es/binding/DeleteButton.js +14 -0
  43. package/es/binding/DeleteIconButton.d.ts +10 -0
  44. package/es/binding/DeleteIconButton.js +15 -0
  45. package/es/binding/RefreshButton.d.ts +3 -0
  46. package/es/binding/RefreshButton.js +14 -0
  47. package/es/binding/RefreshIconButton.d.ts +2 -0
  48. package/es/binding/RefreshIconButton.js +16 -0
  49. package/es/binding/RefreshRowIconButton.d.ts +7 -0
  50. package/es/binding/RefreshRowIconButton.js +15 -0
  51. package/es/binding/SaveButton.d.ts +3 -0
  52. package/es/binding/SaveButton.js +14 -0
  53. package/es/binding/SaveIconButton.d.ts +2 -0
  54. package/es/binding/SaveIconButton.js +15 -0
  55. package/es/binding/index.d.ts +19 -0
  56. package/es/binding/index.js +19 -0
  57. package/es/index.d.ts +3 -0
  58. package/es/index.js +3 -0
  59. package/es/layout/AppLayoutDesktop.css +65 -0
  60. package/es/layout/AppLayoutDesktop.d.ts +3 -0
  61. package/es/layout/AppLayoutDesktop.js +19 -0
  62. package/es/layout/AppLayoutMobile.css +75 -0
  63. package/es/layout/AppLayoutMobile.d.ts +3 -0
  64. package/es/layout/AppLayoutMobile.js +18 -0
  65. package/es/layout/AppMenu.css +67 -0
  66. package/es/layout/AppMenu.d.ts +8 -0
  67. package/es/layout/AppMenu.js +36 -0
  68. package/es/layout/AppMenuItem.css +97 -0
  69. package/es/layout/AppMenuItem.d.ts +50 -0
  70. package/es/layout/AppMenuItem.js +14 -0
  71. package/es/layout/AppMenuSubheader.css +18 -0
  72. package/es/layout/AppMenuSubheader.d.ts +4 -0
  73. package/es/layout/AppMenuSubheader.js +6 -0
  74. package/es/layout/ErrorBoundary.css +101 -0
  75. package/es/layout/ErrorBoundary.d.ts +30 -0
  76. package/es/layout/ErrorBoundary.js +81 -0
  77. package/es/layout/PageContainer.css +29 -0
  78. package/es/layout/PageContainer.d.ts +7 -0
  79. package/es/layout/PageContainer.js +6 -0
  80. package/es/layout/PartnerToggle.d.ts +5 -0
  81. package/es/layout/PartnerToggle.js +9 -0
  82. package/es/layout/constants.d.ts +2 -0
  83. package/es/layout/constants.js +2 -0
  84. package/es/layout/index.css +65 -0
  85. package/es/layout/index.d.ts +33 -0
  86. package/es/layout/index.js +31 -0
  87. package/package.json +17 -12
  88. package/src/AfLookup/BoundLookup.tsx +4 -4
  89. package/src/AfLookup/Combobox.css +1 -1
  90. package/src/AfLookup/Combobox.tsx +79 -108
  91. package/src/AfLookup/Lookup.css +21 -0
  92. package/src/AfLookup/Lookup.tsx +25 -157
  93. package/src/AfLookup/LookupEdit.tsx +131 -0
  94. package/src/AfLookup/LookupGrid.css +57 -0
  95. package/src/AfLookup/LookupGrid.tsx +98 -0
  96. package/src/AfLookup/index.ts +2 -0
  97. package/src/binding/DataObjectSelect.tsx +1 -1
  98. package/src/index.ts +1 -0
  99. package/src/layout/AppLayoutDesktop.css +65 -0
  100. package/src/layout/AppLayoutDesktop.tsx +45 -0
  101. package/src/layout/AppLayoutMobile.css +75 -0
  102. package/src/layout/AppLayoutMobile.tsx +50 -0
  103. package/src/layout/AppMenu.css +67 -0
  104. package/src/layout/AppMenu.tsx +75 -0
  105. package/src/layout/AppMenuItem.css +97 -0
  106. package/src/layout/AppMenuItem.tsx +80 -0
  107. package/src/layout/AppMenuSubheader.css +18 -0
  108. package/src/layout/AppMenuSubheader.tsx +14 -0
  109. package/src/layout/ErrorBoundary.css +101 -0
  110. package/src/layout/ErrorBoundary.tsx +162 -0
  111. package/src/layout/PageContainer.css +29 -0
  112. package/src/layout/PageContainer.tsx +19 -0
  113. package/src/layout/PartnerToggle.tsx +23 -0
  114. package/src/layout/constants.ts +2 -0
  115. package/src/layout/index.css +65 -0
  116. package/src/layout/index.tsx +62 -0
  117. package/tsconfig.build.tsbuildinfo +1 -0
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import "./ErrorBoundary.css";
3
+ import { getLocalizedString } from "@olenbetong/appframe-core";
4
+ import { useError } from "@olenbetong/appframe-react";
5
+ import { Component, useEffect } from "react";
6
+ import { useAsyncError, useRouteError } from "react-router";
7
+ import { PageContainer } from "./PageContainer.js";
8
+ function parseError(error) {
9
+ let message = "Unspecified error";
10
+ let stackTrace = null;
11
+ let title = getLocalizedString("Unexpected application error");
12
+ if (typeof error === "string") {
13
+ message = error;
14
+ }
15
+ else if (error && typeof error === "object") {
16
+ const err = error;
17
+ if (err.status) {
18
+ title = `${err.status} ${err.statusText}`;
19
+ message = err.message ?? "";
20
+ }
21
+ else if (err.message) {
22
+ message = err.message;
23
+ if (err.type) {
24
+ message = `${err.type}: ${message}`;
25
+ }
26
+ }
27
+ else {
28
+ message = String(error);
29
+ }
30
+ if (err.stack || err.stackTrace) {
31
+ stackTrace = err.stack ?? err.stackTrace;
32
+ }
33
+ }
34
+ return { message, stackTrace, title };
35
+ }
36
+ export class AppErrorBoundary extends Component {
37
+ constructor(props) {
38
+ super(props);
39
+ this.state = { hasError: false, error: null };
40
+ }
41
+ static getDerivedStateFromError(error) {
42
+ return { hasError: true, error };
43
+ }
44
+ componentDidCatch(error, errorInfo) {
45
+ console.error(error, errorInfo);
46
+ }
47
+ render() {
48
+ if (this.state.hasError) {
49
+ const { imgSrc, message, image } = this.props;
50
+ const { message: errorMessage } = parseError(this.state.error);
51
+ const customMessage = typeof message === "function" ? message(this.state.error) : message;
52
+ return (_jsx("div", { className: "AppErrorBoundary-root", children: _jsxs("div", { className: "AppErrorBoundary-inner", children: [image ?? (_jsx("img", { alt: "", src: imgSrc ?? "/file/site/graphic/error-broken-robot.svg", className: "AppErrorBoundary-image" })), _jsx("h1", { className: "AppErrorBoundary-title", children: getLocalizedString("Something went terribly wrong!") }), _jsx("div", { className: "AppErrorBoundary-alert", children: errorMessage }), _jsx("p", { className: "AppErrorBoundary-message", children: customMessage ??
53
+ getLocalizedString("Sometimes it is enough to simply refresh the page. If the error persists, contact the IT department.") }), _jsx("button", { type: "button", className: "AppErrorBoundary-reload", onClick: () => window.location.reload(), children: getLocalizedString("Refresh page") })] }) }));
54
+ }
55
+ return this.props.children;
56
+ }
57
+ }
58
+ export function ErrorAlertFallback({ error, className }) {
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ return (_jsxs("div", { className: `ErrorAlertFallback-root${className ? ` ${className}` : ""}`, children: [_jsxs("div", { className: "ErrorAlertFallback-content", children: [_jsx("p", { className: "ErrorAlertFallback-title", children: getLocalizedString("An error occured. Try refreshing the page") }), _jsx("p", { children: message })] }), _jsx("button", { type: "button", className: "ErrorAlertFallback-reload", onClick: () => window.location.reload(), children: getLocalizedString("Reload page") })] }));
61
+ }
62
+ // ── RouteErrorPage ────────────────────────────────────────────────────────────
63
+ export function RouteErrorPage() {
64
+ const error = useRouteError();
65
+ const { message, stackTrace, title } = parseError(error);
66
+ useEffect(() => {
67
+ globalThis.af?.common?.logError?.(error);
68
+ }, [error]);
69
+ return (_jsxs(PageContainer, { className: "flow", children: [_jsx("h1", { children: title }), message && _jsx("p", { children: message }), stackTrace && _jsx("pre", { className: "RouteErrorPage-stack", children: stackTrace })] }));
70
+ }
71
+ // ── AsyncErrorAlert ───────────────────────────────────────────────────────────
72
+ export function AsyncErrorAlert() {
73
+ const error = useAsyncError();
74
+ const message = typeof error === "string" ? error : error?.message;
75
+ return _jsx("div", { className: "ErrorAlertFallback-root", children: message });
76
+ }
77
+ // ── DataObjectErrorAlert ──────────────────────────────────────────────────────
78
+ export function DataObjectErrorAlert({ dataObject }) {
79
+ const error = useError(dataObject);
80
+ return error ? _jsx("div", { className: "ErrorAlertFallback-root", children: error }) : null;
81
+ }
@@ -0,0 +1,29 @@
1
+ @media screen {
2
+ .ObPageContainer-root {
3
+ max-height: 100%;
4
+ overflow: auto;
5
+ height: auto;
6
+ }
7
+
8
+ .ObPageContainer-root.ObPageContainer-fullHeight {
9
+ height: 100%;
10
+ }
11
+
12
+ .ObPageContainer-inner {
13
+ margin-left: 0;
14
+ height: auto;
15
+ padding: 1rem;
16
+ box-sizing: border-box;
17
+ max-width: 100%;
18
+ }
19
+
20
+ .ObPageContainer-fullHeight .ObPageContainer-inner {
21
+ height: 100%;
22
+ }
23
+ }
24
+
25
+ @media screen and (min-width: 600px) {
26
+ .ObPageContainer-inner {
27
+ padding: 1rem 1.5rem;
28
+ }
29
+ }
@@ -0,0 +1,7 @@
1
+ import "./PageContainer.css";
2
+ import type React from "react";
3
+ export type PageContainerProps = React.HTMLAttributes<HTMLDivElement> & {
4
+ fullHeight?: boolean;
5
+ maxWidth?: "xs" | "sm" | "md" | "lg" | "xl" | false;
6
+ };
7
+ export declare function PageContainer({ children, className, fullHeight, maxWidth, ...props }: PageContainerProps): React.JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import "./PageContainer.css";
3
+ import clsx from "clsx";
4
+ export function PageContainer({ children, className, fullHeight, maxWidth, ...props }) {
5
+ return (_jsx("div", { className: clsx("ObPageContainer-root", fullHeight && "ObPageContainer-fullHeight"), children: _jsx("div", { className: clsx("ObPageContainer-inner", className), ...props, children: children }) }));
6
+ }
@@ -0,0 +1,5 @@
1
+ import type React from "react";
2
+ export declare function PartnerToggle({ onClick, open, }: {
3
+ open: boolean;
4
+ onClick?: React.MouseEventHandler<HTMLButtonElement>;
5
+ }): React.ReactNode;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createPortal } from "react-dom";
3
+ let toggleRoot = document.createElement("div");
4
+ let brand = document.querySelector(".partner-nav__brand");
5
+ toggleRoot.id = "toggle-root";
6
+ document.querySelector(".partner-nav")?.insertBefore(toggleRoot, brand);
7
+ export function PartnerToggle({ onClick, open, }) {
8
+ return createPortal(_jsx("button", { type: "button", className: "LayoutSmall-toggle", onClick: onClick, children: open ? "◀" : "☰" }), toggleRoot);
9
+ }
@@ -0,0 +1,2 @@
1
+ export declare const AF_MUI_PAGE_TITLE_ID = "PageTitleSmallLayout";
2
+ export declare const AF_MUI_APPBAR_TOOLS_ID = "AppBarTools";
@@ -0,0 +1,2 @@
1
+ export const AF_MUI_PAGE_TITLE_ID = "PageTitleSmallLayout";
2
+ export const AF_MUI_APPBAR_TOOLS_ID = "AppBarTools";
@@ -0,0 +1,65 @@
1
+ .app-view {
2
+ height: 100%;
3
+ overflow-y: hidden;
4
+ }
5
+
6
+ .scroll-view {
7
+ height: 100%;
8
+ max-height: 100%;
9
+ overflow-y: auto;
10
+ }
11
+
12
+ .flow > * + * {
13
+ margin-top: var(--flow-spacing, 1rem) !important;
14
+ }
15
+
16
+ .flow-xs > * {
17
+ --flow-spacing: 0.25rem;
18
+ }
19
+
20
+ .flow-sm > * {
21
+ --flow-spacing: 0.5rem;
22
+ }
23
+
24
+ .flow-lg > * {
25
+ --flow-spacing: 1.5rem;
26
+ }
27
+
28
+ .flow-xl > * {
29
+ --flow-spacing: 2rem;
30
+ }
31
+
32
+ .flow-xxl > * {
33
+ --flow-spacing: 3rem;
34
+ }
35
+
36
+ .app-grid {
37
+ height: 100%;
38
+ overflow-y: hidden;
39
+ display: grid;
40
+ gap: 1rem;
41
+ }
42
+
43
+ .rows-af {
44
+ grid-template-rows: auto 1fr;
45
+ }
46
+
47
+ .rows-aaf {
48
+ grid-template-rows: auto auto 1fr;
49
+ }
50
+
51
+ .rows-afaf {
52
+ grid-template-rows: auto 1fr auto 1fr;
53
+ }
54
+
55
+ .rows-aff {
56
+ grid-template-rows: auto 1fr 1fr;
57
+ }
58
+
59
+ .rows-f {
60
+ grid-template-rows: 1fr;
61
+ }
62
+
63
+ .rows-ff {
64
+ grid-template-rows: 1fr 1fr;
65
+ }
@@ -0,0 +1,33 @@
1
+ import "./index.css";
2
+ import type React from "react";
3
+ import type { AppMenuProps } from "./AppMenu.js";
4
+ export type AppLayoutProps = {
5
+ AppMenuProps?: AppMenuProps;
6
+ children: React.ReactNode | React.ReactNode[];
7
+ menu?: React.ReactNode | React.ReactNode[];
8
+ /**
9
+ * @deprecated The toolbar is now server-rendered. This prop has no effect.
10
+ */
11
+ toolbar?: React.ReactNode | React.ReactNode[];
12
+ /**
13
+ * @deprecated The title is now rendered in the server-rendered toolbar. This prop has no effect.
14
+ */
15
+ title?: React.ReactNode;
16
+ /**
17
+ * @deprecated The toolbar is now server-rendered. This prop has no effect.
18
+ */
19
+ enableMenuToggle?: boolean;
20
+ /**
21
+ * Enable partner mode (for partner.olenbetong.no applications).
22
+ * Adds a toggle button to the existing partner header instead of the app bar.
23
+ */
24
+ partner?: boolean;
25
+ };
26
+ export * from "./AppMenu.js";
27
+ export * from "./AppMenuItem.js";
28
+ export * from "./AppMenuSubheader.js";
29
+ export * from "./constants.js";
30
+ export * from "./ErrorBoundary.js";
31
+ export * from "./PageContainer.js";
32
+ export * from "./PartnerToggle.js";
33
+ export declare function AppLayout(props: AppLayoutProps): React.JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import "./index.css";
3
+ import { useEffect, useState } from "react";
4
+ import { AppLayoutDesktop } from "./AppLayoutDesktop.js";
5
+ import { AppLayoutMobile } from "./AppLayoutMobile.js";
6
+ function useIsMobile() {
7
+ let [isMobile, setIsMobile] = useState(() => {
8
+ if (typeof window === "undefined")
9
+ return false;
10
+ return window.matchMedia("(max-width: 1199px)").matches;
11
+ });
12
+ useEffect(() => {
13
+ let mq = window.matchMedia("(max-width: 1199px)");
14
+ let handler = (e) => setIsMobile(e.matches);
15
+ mq.addEventListener("change", handler);
16
+ return () => mq.removeEventListener("change", handler);
17
+ }, []);
18
+ return isMobile;
19
+ }
20
+ export * from "./AppMenu.js";
21
+ export * from "./AppMenuItem.js";
22
+ export * from "./AppMenuSubheader.js";
23
+ export * from "./constants.js";
24
+ export * from "./ErrorBoundary.js";
25
+ export * from "./PageContainer.js";
26
+ export * from "./PartnerToggle.js";
27
+ export function AppLayout(props) {
28
+ let isSmall = useIsMobile();
29
+ let Component = isSmall ? AppLayoutMobile : AppLayoutDesktop;
30
+ return _jsx(Component, { ...props });
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-ds",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Components that bind Designsystemet components to Appframe data objects",
5
5
  "type": "module",
6
6
  "types": "./es/index.d.ts",
@@ -15,32 +15,37 @@
15
15
  "author": "Bjørnar Vister Hansen <bvh@olenbetong.no>",
16
16
  "license": "MIT",
17
17
  "peerDependencies": {
18
- "@digdir/designsystemet-react": ">=1.0.0",
18
+ "@digdir/designsystemet-react": ">=1.18.0",
19
19
  "@mui/icons-material": ">=7.0.0",
20
- "react": ">=18.2.0",
21
- "react-dom": ">=18.2.0"
20
+ "react": ">=19.2.7",
21
+ "react-dom": ">=19.2.7",
22
+ "react-error-boundary": ">=5.0.0",
23
+ "react-router": ">=7.0.0"
22
24
  },
23
25
  "dependencies": {
24
26
  "@olenbetong/appframe-core": "2.11.10",
25
27
  "@olenbetong/appframe-data": "1.5.0",
26
- "@olenbetong/appframe-react": "1.21.25",
28
+ "@olenbetong/appframe-react": "1.22.0",
27
29
  "react-window": "^1.8.11",
28
30
  "react-virtualized-auto-sizer": "^1.0.26",
29
31
  "clsx": "^2.1.1"
30
32
  },
31
33
  "devDependencies": {
32
- "@digdir/designsystemet-react": "1.13.3",
33
- "@mui/icons-material": "9.0.0",
34
- "@types/react": "19.2.14",
34
+ "@digdir/designsystemet-react": "1.18.0",
35
+ "@mui/icons-material": "9.2.0",
36
+ "@types/react": "19.2.17",
35
37
  "@types/react-dom": "19.2.3",
36
38
  "@types/react-window": "^1.8.8",
37
- "react": "19.2.5",
38
- "react-dom": "19.2.5",
39
- "typescript": "6.0.3"
39
+ "react": "19.2.7",
40
+ "react-dom": "19.2.7",
41
+ "react-error-boundary": "6.1.2",
42
+ "react-router": "7.17.0",
43
+ "typescript": "7.0.2"
40
44
  },
45
+ "optionalDependencies": {},
41
46
  "scripts": {
42
47
  "build": "pnpm run build:tsc && pnpm run build:css",
43
48
  "build:tsc": "node --no-warnings ../../scripts/clean.ts ./es tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
44
- "build:css": "cp src/AfLookup/Lookup.css es/AfLookup/Lookup.css && cp src/AfLookup/Combobox.css es/AfLookup/Combobox.css"
49
+ "build:css": "cp src/AfLookup/Lookup.css es/AfLookup/Lookup.css && cp src/AfLookup/Combobox.css es/AfLookup/Combobox.css && cp src/AfLookup/LookupGrid.css es/AfLookup/LookupGrid.css && mkdir -p es/layout && cp src/layout/AppMenu.css es/layout/AppMenu.css && cp src/layout/AppMenuItem.css es/layout/AppMenuItem.css && cp src/layout/AppMenuSubheader.css es/layout/AppMenuSubheader.css && cp src/layout/AppLayoutDesktop.css es/layout/AppLayoutDesktop.css && cp src/layout/AppLayoutMobile.css es/layout/AppLayoutMobile.css && cp src/layout/index.css es/layout/index.css && cp src/layout/ErrorBoundary.css es/layout/ErrorBoundary.css && cp src/layout/PageContainer.css es/layout/PageContainer.css"
45
50
  }
46
51
  }
@@ -1,17 +1,17 @@
1
1
  import type { CurrentRow, DataObject } from "@olenbetong/appframe-data";
2
- import { useCurrentRow, useDataObject } from "@olenbetong/appframe-react";
2
+ import { type DistributiveOmit, useCurrentRow, useDataObject } from "@olenbetong/appframe-react";
3
3
 
4
4
  import { AfLookup, type AfLookupProps } from "./Lookup.js";
5
5
 
6
- export interface BoundLookupProps<
6
+ export type BoundLookupProps<
7
7
  TBoundData extends Record<string, unknown>,
8
8
  TLookupData extends Record<string, unknown>,
9
- > extends Omit<AfLookupProps<TLookupData>, "onChange"> {
9
+ > = DistributiveOmit<AfLookupProps<TLookupData>, "onChange"> & {
10
10
  dataObject: DataObject<TLookupData>;
11
11
  displayField?: keyof TBoundData;
12
12
  getChanges: (selectedItem: TLookupData | null) => Partial<TBoundData>;
13
13
  getDisplayValue?: (currentRow: CurrentRow<TBoundData>) => string;
14
- }
14
+ };
15
15
 
16
16
  export function BoundLookup<
17
17
  TBoundData extends Record<string, unknown>,
@@ -14,7 +14,7 @@
14
14
 
15
15
  .ObCombobox-listWrap {
16
16
  overflow: hidden;
17
- height: 372px;
17
+ min-height: 0;
18
18
  border: 1px solid rgb(0 0 0 / 0.12);
19
19
  border-radius: 0.25rem;
20
20
  background-color: white;
@@ -3,33 +3,49 @@ import "./Combobox.css";
3
3
  import { Button, Textfield } from "@digdir/designsystemet-react";
4
4
  import { getLocalizedString } from "@olenbetong/appframe-core";
5
5
  import type { DataObject } from "@olenbetong/appframe-data";
6
- import { useDataWithFilter, useDebounce, useLoading } from "@olenbetong/appframe-react";
6
+ import { type LookupColumn, useComboboxData } from "@olenbetong/appframe-react";
7
7
 
8
8
  import clsx from "clsx";
9
- import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
9
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
10
10
  import { VariableSizeList as VirtualList } from "react-window";
11
11
 
12
- export interface ComboboxProps<T extends Record<string, unknown>> {
12
+ import { LookupGrid } from "./LookupGrid.js";
13
+
14
+ export interface ComboboxBaseProps<T extends Record<string, unknown>> {
13
15
  nullable?: boolean;
14
16
  dataObject: DataObject<T>;
15
17
  uniqueIdField?: string;
16
18
  isItemSelected?: (item: T) => boolean;
17
- renderItem: (props: React.ComponentPropsWithRef<"li"> & { item: T }) => React.ReactNode;
18
19
  onChange?: (item: T | null) => void;
19
20
  value?: T;
20
21
  onClose?: () => void;
21
22
  }
22
23
 
23
- export function Combobox<T extends Record<string, unknown>>({
24
- nullable,
25
- dataObject,
26
- isItemSelected,
27
- uniqueIdField = "PrimKey",
28
- renderItem: Component,
29
- onChange,
30
- onClose,
31
- }: ComboboxProps<T>) {
32
- let id = useId();
24
+ export interface ComboboxListProps<T extends Record<string, unknown>> extends ComboboxBaseProps<T> {
25
+ /**
26
+ * Renders the popup content as a list (default).
27
+ */
28
+ variant?: "list";
29
+ renderItem: (props: React.ComponentPropsWithRef<"li"> & { item: T }) => React.ReactNode;
30
+ }
31
+
32
+ export interface ComboboxGridProps<T extends Record<string, unknown>> extends ComboboxBaseProps<T> {
33
+ /**
34
+ * Renders the popup content as a grid with a header row and one column per
35
+ * entry in `columns`.
36
+ */
37
+ variant: "grid";
38
+ columns: LookupColumn<T>[];
39
+ }
40
+
41
+ export type ComboboxProps<T extends Record<string, unknown>> = ComboboxListProps<T> | ComboboxGridProps<T>;
42
+
43
+ export function Combobox<T extends Record<string, unknown>>(props: ComboboxProps<T>) {
44
+ let { nullable, dataObject, isItemSelected, uniqueIdField = "PrimKey", onChange, onClose } = props;
45
+ let isGrid = props.variant === "grid";
46
+ let columns = isGrid ? (props as ComboboxGridProps<T>).columns : undefined;
47
+ let listRef = useRef<VirtualList>(null);
48
+
33
49
  let [isDesktop, setIsDesktop] = useState(
34
50
  () => typeof window !== "undefined" && window.matchMedia("(width >= 768px) and (pointer: fine)").matches,
35
51
  );
@@ -41,27 +57,23 @@ export function Combobox<T extends Record<string, unknown>>({
41
57
  return () => mq.removeEventListener("change", handler);
42
58
  }, []);
43
59
 
44
- let [currentIndex, setCurrentIndex] = useState<number>(0);
45
- let listRef = useRef<VirtualList>(null);
46
- let [inputValue, setInputValue] = useState(() => {
47
- // Preserve filter even if the lookup is unmounted
48
- let currentFilter = dataObject.getParameter("filterString") ?? "";
49
- let filters = currentFilter.split(/\s+AND\s+/g);
50
- let wordFilters = filters.filter((filter) => filter.includes("[SearchColumn]"));
51
- let words = wordFilters.map((filter) => filter.match(/'%([^']*)%'/)?.[1] ?? "").filter(Boolean);
52
-
53
- return words.join(" ");
54
- });
55
-
56
- let query = useDebounce(inputValue, 300);
57
- let loading = useLoading(dataObject);
58
- let data = useDataWithFilter(
60
+ let {
61
+ id,
62
+ data,
63
+ loading,
64
+ inputValue,
65
+ setInputValue,
66
+ currentIndex,
67
+ currentColumn,
68
+ getItemId,
69
+ handleSelectItem,
70
+ handleKeyDown,
71
+ } = useComboboxData<T>({
59
72
  dataObject,
60
- query
61
- .split(/\s+/g)
62
- .map((word) => `[SearchColumn] LIKE '%${word}%'`)
63
- .join(" AND "),
64
- );
73
+ uniqueIdField,
74
+ columnCount: columns?.length ?? 1,
75
+ onChange,
76
+ });
65
77
 
66
78
  let wrapperRef = useRef<HTMLDivElement>(null);
67
79
  let [height, setHeight] = useState<number>(0);
@@ -79,71 +91,16 @@ export function Combobox<T extends Record<string, unknown>>({
79
91
  return () => resizeObserver.disconnect();
80
92
  }, []);
81
93
 
82
- // Reset current index when new data is loaded
83
- // biome-ignore lint/correctness/useExhaustiveDependencies: isn't it obvious?
84
- useEffect(() => {
85
- setCurrentIndex(0);
86
- }, [data]);
87
-
88
- let getItemId = useCallback(
89
- (item: T | undefined) => {
90
- if (!item) {
91
- return;
92
- }
93
-
94
- let itemId = item[uniqueIdField];
95
-
96
- return `${id}-${itemId}`;
97
- },
98
- [id, uniqueIdField],
99
- );
100
-
101
- function handleSelectItem(item: T) {
102
- onChange?.(item);
103
- setInputValue("");
104
- }
105
-
106
- function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
107
- let newIndex: number | null = null;
108
-
109
- if (event.key === "ArrowDown") {
110
- newIndex = currentIndex + 1;
111
- } else if (event.key === "ArrowUp") {
112
- newIndex = currentIndex - 1;
113
- } else if (event.key === "Home" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
114
- newIndex = 0;
115
- } else if (event.key === "End" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
116
- newIndex = data.length - 1;
117
- } else if (event.key === "Enter") {
118
- event.stopPropagation();
119
- event.preventDefault();
120
- handleSelectItem(data[currentIndex]);
121
- return;
122
- }
123
-
124
- if (newIndex !== null) {
125
- event.stopPropagation();
126
- event.preventDefault();
127
- if (newIndex < 0) {
128
- newIndex = data.length - 1;
129
- } else if (newIndex >= data.length) {
130
- newIndex = 0;
131
- }
132
- setCurrentIndex(newIndex);
133
- }
134
- }
135
-
136
94
  let sizeMap = useRef(new Map<number, number>());
137
95
  let getItemSize = (index: number) => sizeMap.current.get(index) ?? 56;
138
- let setSize = (index: number, size: number) => {
96
+ let setSize = useCallback((index: number, size: number) => {
139
97
  if (sizeMap.current.get(index) !== size) {
140
98
  sizeMap.current.set(index, size);
141
99
  // listRef will not be set on the initial render, so delay resetting
142
100
  setTimeout(() => listRef.current?.resetAfterIndex(index, true));
143
101
  }
144
- };
102
+ }, []);
145
103
 
146
- // biome-ignore lint: nested component defined for performance reasons inside render
147
104
  let Row = ({ index, style: { height, ...style } }: { index: number; style: React.CSSProperties }) => {
148
105
  let rowRef = useRef<HTMLLIElement>(null);
149
106
 
@@ -155,9 +112,10 @@ export function Combobox<T extends Record<string, unknown>>({
155
112
  let item = data[index];
156
113
  let itemId = getItemId(item);
157
114
  let active = index === currentIndex;
115
+ let ItemComponent = (props as ComboboxListProps<T>).renderItem;
158
116
 
159
117
  return (
160
- <Component
118
+ <ItemComponent
161
119
  className={clsx("ObCombobox-listItem", active && "active", isItemSelected?.(item) && "selected")}
162
120
  aria-selected={active}
163
121
  onClick={() => handleSelectItem(item)}
@@ -176,8 +134,8 @@ export function Combobox<T extends Record<string, unknown>>({
176
134
  useEffect(() => listRef.current?.scrollToItem(currentIndex, "smart"), [currentIndex]);
177
135
 
178
136
  return (
179
- // biome-ignore lint/a11y/useKeyWithClickEvents: element is not interactive, click is used to prevent dialog close
180
- // biome-ignore lint/a11y/noStaticElementInteractions: element is not interactive, click is used to prevent dialog close
137
+ // oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- element is not interactive, click is used to prevent dialog close
138
+ // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- element is not interactive, click is used to prevent dialog close
181
139
  <div className="ObCombobox-root" id={id} onClick={(evt) => evt.stopPropagation()}>
182
140
  <div className="ObCombobox-actions">
183
141
  {nullable && (
@@ -201,7 +159,8 @@ export function Combobox<T extends Record<string, unknown>>({
201
159
  <Textfield
202
160
  type="search"
203
161
  className="ObCombobox-input"
204
- label={getLocalizedString("Search")}
162
+ aria-label={getLocalizedString("Search")}
163
+ placeholder={getLocalizedString("Search")}
205
164
  onKeyDown={handleKeyDown}
206
165
  aria-activedescendant={getItemId(data[currentIndex]) ?? ""}
207
166
  value={inputValue}
@@ -213,20 +172,32 @@ export function Combobox<T extends Record<string, unknown>>({
213
172
  <div className="ObCombobox-progress-bar" />
214
173
  </div>
215
174
  )}
216
- {height > 0 && (
217
- <div className="ObCombobox-list" role="listbox">
218
- <VirtualList
219
- ref={listRef}
175
+ {height > 0 &&
176
+ (columns ? (
177
+ <LookupGrid<T>
178
+ columns={columns}
179
+ data={data}
220
180
  height={height}
221
- width="100%"
222
- itemCount={data.length}
223
- itemSize={getItemSize}
224
- overscanCount={5}
225
- >
226
- {Row}
227
- </VirtualList>
228
- </div>
229
- )}
181
+ currentIndex={currentIndex}
182
+ currentColumn={currentColumn}
183
+ getItemId={getItemId}
184
+ isItemSelected={isItemSelected}
185
+ onSelectItem={handleSelectItem}
186
+ />
187
+ ) : (
188
+ <div className="ObCombobox-list" role="listbox">
189
+ <VirtualList
190
+ ref={listRef}
191
+ height={height}
192
+ width="100%"
193
+ itemCount={data.length}
194
+ itemSize={getItemSize}
195
+ overscanCount={5}
196
+ >
197
+ {Row}
198
+ </VirtualList>
199
+ </div>
200
+ ))}
230
201
  </div>
231
202
  </div>
232
203
  );
@@ -1,7 +1,28 @@
1
+ .ObLookup-inputWrap {
2
+ position: relative;
3
+
4
+ .ds-input {
5
+ padding-inline-end: var(--ObLookup-buttons-width, 3rem);
6
+ }
7
+ }
8
+
9
+ .ObLookup-buttons {
10
+ position: absolute;
11
+ inset-inline-end: 0;
12
+ inset-block-end: 0;
13
+ /* Match the .ds-input height so the buttons center on the input row,
14
+ also when a label or description is rendered above the input */
15
+ height: var(--ds-size-12, 3rem);
16
+ display: flex;
17
+ align-items: center;
18
+ padding-inline-end: var(--ds-size-1, 0.25rem);
19
+ }
20
+
1
21
  .ObLookup-dialog {
2
22
  border: 0;
3
23
  margin-block-start: 0;
4
24
  padding: 1rem;
25
+ overflow: hidden;
5
26
 
6
27
  background-color: whitesmoke;
7
28