@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.
- package/CHANGELOG.md +19 -0
- package/es/AfLookup/BoundLookup.d.ts +10 -0
- package/es/AfLookup/BoundLookup.js +16 -0
- package/es/AfLookup/Combobox.css +78 -0
- package/es/AfLookup/Combobox.d.ts +31 -0
- package/es/AfLookup/Combobox.js +71 -0
- package/es/AfLookup/Lookup.css +125 -0
- package/es/AfLookup/Lookup.d.ts +35 -0
- package/es/AfLookup/Lookup.js +42 -0
- package/es/AfLookup/LookupEdit.d.ts +21 -0
- package/es/AfLookup/LookupEdit.js +49 -0
- package/es/AfLookup/LookupGrid.css +57 -0
- package/es/AfLookup/LookupGrid.d.ts +13 -0
- package/es/AfLookup/LookupGrid.js +27 -0
- package/es/AfLookup/index.d.ts +5 -0
- package/es/AfLookup/index.js +5 -0
- package/es/binding/BoundCheckbox.d.ts +9 -0
- package/es/binding/BoundCheckbox.js +13 -0
- package/es/binding/BoundDatePicker.d.ts +7 -0
- package/es/binding/BoundDatePicker.js +12 -0
- package/es/binding/BoundDateTimePicker.d.ts +7 -0
- package/es/binding/BoundDateTimePicker.js +14 -0
- package/es/binding/BoundInput.d.ts +6 -0
- package/es/binding/BoundInput.js +12 -0
- package/es/binding/BoundNumericTextField.d.ts +20 -0
- package/es/binding/BoundNumericTextField.js +58 -0
- package/es/binding/BoundSelect.d.ts +9 -0
- package/es/binding/BoundSelect.js +13 -0
- package/es/binding/BoundSwitch.d.ts +9 -0
- package/es/binding/BoundSwitch.js +13 -0
- package/es/binding/BoundTextField.d.ts +7 -0
- package/es/binding/BoundTextField.js +22 -0
- package/es/binding/CancelButton.d.ts +3 -0
- package/es/binding/CancelButton.js +14 -0
- package/es/binding/CancelIconButton.d.ts +2 -0
- package/es/binding/CancelIconButton.js +15 -0
- package/es/binding/DataEditToolbar.d.ts +13 -0
- package/es/binding/DataEditToolbar.js +28 -0
- package/es/binding/DataObjectSelect.d.ts +50 -0
- package/es/binding/DataObjectSelect.js +26 -0
- package/es/binding/DeleteButton.d.ts +8 -0
- package/es/binding/DeleteButton.js +14 -0
- package/es/binding/DeleteIconButton.d.ts +10 -0
- package/es/binding/DeleteIconButton.js +15 -0
- package/es/binding/RefreshButton.d.ts +3 -0
- package/es/binding/RefreshButton.js +14 -0
- package/es/binding/RefreshIconButton.d.ts +2 -0
- package/es/binding/RefreshIconButton.js +16 -0
- package/es/binding/RefreshRowIconButton.d.ts +7 -0
- package/es/binding/RefreshRowIconButton.js +15 -0
- package/es/binding/SaveButton.d.ts +3 -0
- package/es/binding/SaveButton.js +14 -0
- package/es/binding/SaveIconButton.d.ts +2 -0
- package/es/binding/SaveIconButton.js +15 -0
- package/es/binding/index.d.ts +19 -0
- package/es/binding/index.js +19 -0
- package/es/index.d.ts +3 -0
- package/es/index.js +3 -0
- package/es/layout/AppLayoutDesktop.css +65 -0
- package/es/layout/AppLayoutDesktop.d.ts +3 -0
- package/es/layout/AppLayoutDesktop.js +19 -0
- package/es/layout/AppLayoutMobile.css +75 -0
- package/es/layout/AppLayoutMobile.d.ts +3 -0
- package/es/layout/AppLayoutMobile.js +18 -0
- package/es/layout/AppMenu.css +67 -0
- package/es/layout/AppMenu.d.ts +8 -0
- package/es/layout/AppMenu.js +36 -0
- package/es/layout/AppMenuItem.css +97 -0
- package/es/layout/AppMenuItem.d.ts +50 -0
- package/es/layout/AppMenuItem.js +14 -0
- package/es/layout/AppMenuSubheader.css +18 -0
- package/es/layout/AppMenuSubheader.d.ts +4 -0
- package/es/layout/AppMenuSubheader.js +6 -0
- package/es/layout/ErrorBoundary.css +101 -0
- package/es/layout/ErrorBoundary.d.ts +30 -0
- package/es/layout/ErrorBoundary.js +81 -0
- package/es/layout/PageContainer.css +29 -0
- package/es/layout/PageContainer.d.ts +7 -0
- package/es/layout/PageContainer.js +6 -0
- package/es/layout/PartnerToggle.d.ts +5 -0
- package/es/layout/PartnerToggle.js +9 -0
- package/es/layout/constants.d.ts +2 -0
- package/es/layout/constants.js +2 -0
- package/es/layout/index.css +65 -0
- package/es/layout/index.d.ts +33 -0
- package/es/layout/index.js +31 -0
- package/package.json +17 -12
- package/src/AfLookup/BoundLookup.tsx +4 -4
- package/src/AfLookup/Combobox.css +1 -1
- package/src/AfLookup/Combobox.tsx +79 -108
- package/src/AfLookup/Lookup.css +21 -0
- package/src/AfLookup/Lookup.tsx +25 -157
- package/src/AfLookup/LookupEdit.tsx +131 -0
- package/src/AfLookup/LookupGrid.css +57 -0
- package/src/AfLookup/LookupGrid.tsx +98 -0
- package/src/AfLookup/index.ts +2 -0
- package/src/binding/DataObjectSelect.tsx +1 -1
- package/src/index.ts +1 -0
- package/src/layout/AppLayoutDesktop.css +65 -0
- package/src/layout/AppLayoutDesktop.tsx +45 -0
- package/src/layout/AppLayoutMobile.css +75 -0
- package/src/layout/AppLayoutMobile.tsx +50 -0
- package/src/layout/AppMenu.css +67 -0
- package/src/layout/AppMenu.tsx +75 -0
- package/src/layout/AppMenuItem.css +97 -0
- package/src/layout/AppMenuItem.tsx +80 -0
- package/src/layout/AppMenuSubheader.css +18 -0
- package/src/layout/AppMenuSubheader.tsx +14 -0
- package/src/layout/ErrorBoundary.css +101 -0
- package/src/layout/ErrorBoundary.tsx +162 -0
- package/src/layout/PageContainer.css +29 -0
- package/src/layout/PageContainer.tsx +19 -0
- package/src/layout/PartnerToggle.tsx +23 -0
- package/src/layout/constants.ts +2 -0
- package/src/layout/index.css +65 -0
- package/src/layout/index.tsx +62 -0
- 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,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,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.
|
|
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.
|
|
18
|
+
"@digdir/designsystemet-react": ">=1.18.0",
|
|
19
19
|
"@mui/icons-material": ">=7.0.0",
|
|
20
|
-
"react": ">=
|
|
21
|
-
"react-dom": ">=
|
|
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.
|
|
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.
|
|
33
|
-
"@mui/icons-material": "9.
|
|
34
|
-
"@types/react": "19.2.
|
|
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.
|
|
38
|
-
"react-dom": "19.2.
|
|
39
|
-
"
|
|
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
|
|
6
|
+
export type BoundLookupProps<
|
|
7
7
|
TBoundData extends Record<string, unknown>,
|
|
8
8
|
TLookupData extends Record<string, unknown>,
|
|
9
|
-
>
|
|
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>,
|
|
@@ -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 {
|
|
6
|
+
import { type LookupColumn, useComboboxData } from "@olenbetong/appframe-react";
|
|
7
7
|
|
|
8
8
|
import clsx from "clsx";
|
|
9
|
-
import { useCallback, useEffect,
|
|
9
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
10
10
|
import { VariableSizeList as VirtualList } from "react-window";
|
|
11
11
|
|
|
12
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
renderItem:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
<
|
|
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
|
-
//
|
|
180
|
-
//
|
|
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
|
-
|
|
218
|
-
<
|
|
219
|
-
|
|
175
|
+
{height > 0 &&
|
|
176
|
+
(columns ? (
|
|
177
|
+
<LookupGrid<T>
|
|
178
|
+
columns={columns}
|
|
179
|
+
data={data}
|
|
220
180
|
height={height}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
);
|
package/src/AfLookup/Lookup.css
CHANGED
|
@@ -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
|
|