@olenbetong/appframe-ds 0.3.0 → 0.4.1

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 (49) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/es/AfLookup/BoundLookup.d.ts +4 -3
  3. package/es/AfLookup/Combobox.css +1 -1
  4. package/es/AfLookup/Combobox.d.ts +22 -5
  5. package/es/AfLookup/Combobox.js +24 -72
  6. package/es/AfLookup/Lookup.css +21 -0
  7. package/es/AfLookup/Lookup.d.ts +7 -6
  8. package/es/AfLookup/Lookup.js +32 -134
  9. package/es/AfLookup/LookupEdit.d.ts +21 -0
  10. package/es/AfLookup/LookupEdit.js +49 -0
  11. package/es/AfLookup/LookupGrid.css +57 -0
  12. package/es/AfLookup/LookupGrid.d.ts +13 -0
  13. package/es/AfLookup/LookupGrid.js +27 -0
  14. package/es/AfLookup/index.d.ts +2 -0
  15. package/es/AfLookup/index.js +2 -0
  16. package/es/binding/BoundDatePicker.d.ts +1 -1
  17. package/es/binding/BoundDateTimePicker.d.ts +1 -1
  18. package/es/binding/BoundInput.d.ts +1 -1
  19. package/es/binding/BoundNumericTextField.d.ts +1 -1
  20. package/es/binding/BoundSwitch.d.ts +1 -1
  21. package/es/binding/BoundTextField.d.ts +1 -1
  22. package/es/binding/DataEditToolbar.d.ts +1 -1
  23. package/es/binding/DataEditToolbar.js +1 -1
  24. package/es/binding/DataObjectSelect.d.ts +2 -2
  25. package/es/binding/DataObjectSelect.js +2 -2
  26. package/es/layout/AppLayoutDesktop.d.ts +1 -1
  27. package/es/layout/AppLayoutDesktop.js +1 -1
  28. package/es/layout/AppLayoutMobile.d.ts +1 -1
  29. package/es/layout/AppLayoutMobile.js +3 -3
  30. package/es/layout/AppMenu.d.ts +1 -1
  31. package/es/layout/AppMenu.js +1 -1
  32. package/es/layout/AppMenuSubheader.d.ts +1 -1
  33. package/es/layout/ErrorBoundary.d.ts +4 -4
  34. package/es/layout/PageContainer.d.ts +1 -1
  35. package/es/layout/index.d.ts +1 -1
  36. package/package.json +15 -15
  37. package/scripts/copyAssets.mjs +22 -0
  38. package/src/AfLookup/BoundLookup.tsx +4 -4
  39. package/src/AfLookup/Combobox.css +1 -1
  40. package/src/AfLookup/Combobox.tsx +94 -108
  41. package/src/AfLookup/Lookup.css +21 -0
  42. package/src/AfLookup/Lookup.tsx +60 -161
  43. package/src/AfLookup/LookupEdit.tsx +131 -0
  44. package/src/AfLookup/LookupGrid.css +57 -0
  45. package/src/AfLookup/LookupGrid.tsx +98 -0
  46. package/src/AfLookup/index.ts +2 -0
  47. package/src/binding/DataObjectSelect.tsx +1 -1
  48. package/src/layout/AppLayoutMobile.tsx +1 -1
  49. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import "./LookupGrid.css";
3
+ import { getLocalizedString } from "@olenbetong/appframe-core";
4
+ import clsx from "clsx";
5
+ import { useEffect, useMemo, useRef } from "react";
6
+ import { FixedSizeList as VirtualList } from "react-window";
7
+ const ROW_HEIGHT = 40;
8
+ const HEADER_HEIGHT = 40;
9
+ function getColumnWidth(column) {
10
+ if (typeof column.width === "number") {
11
+ return `${column.width}px`;
12
+ }
13
+ return column.width ?? "minmax(80px, 1fr)";
14
+ }
15
+ export function LookupGrid({ columns, data, height, currentIndex, currentColumn, getItemId, isItemSelected, onSelectItem, }) {
16
+ let listRef = useRef(null);
17
+ let gridTemplateColumns = useMemo(() => columns.map(getColumnWidth).join(" "), [columns]);
18
+ useEffect(() => listRef.current?.scrollToItem(currentIndex, "smart"), [currentIndex]);
19
+ // oxlint-disable-next-line react/no-nested-component-definitions -- react-window row renderer
20
+ let Row = ({ index, style }) => {
21
+ let item = data[index];
22
+ let itemId = getItemId(item);
23
+ let active = index === currentIndex;
24
+ return (_jsx("div", { role: "row", "aria-selected": active, className: clsx("ObLookupGrid-row", active && "active", isItemSelected?.(item) && "selected"), style: { ...style, gridTemplateColumns }, onClick: () => onSelectItem(item), id: itemId, children: columns.map((column, columnIndex) => (_jsx("div", { role: "gridcell", className: clsx("ObLookupGrid-cell", active && columnIndex === currentColumn && "active"), title: String(item[column.field] ?? ""), children: column.renderCell ? column.renderCell(item[column.field], item) : String(item[column.field] ?? "") }, column.field))) }, itemId));
25
+ };
26
+ return (_jsxs("div", { role: "grid", "aria-label": getLocalizedString("Search results"), className: "ObLookupGrid-root", children: [_jsx("div", { role: "row", className: "ObLookupGrid-header", style: { gridTemplateColumns }, children: columns.map((column) => (_jsx("div", { role: "columnheader", className: "ObLookupGrid-headerCell", children: column.headerName ?? column.field }, column.field))) }), _jsx(VirtualList, { ref: listRef, height: Math.max(0, height - HEADER_HEIGHT), width: "100%", itemCount: data.length, itemSize: ROW_HEIGHT, overscanCount: 5, children: Row })] }));
27
+ }
@@ -1,3 +1,5 @@
1
1
  export * from "./BoundLookup.js";
2
2
  export * from "./Combobox.js";
3
3
  export * from "./Lookup.js";
4
+ export * from "./LookupEdit.js";
5
+ export * from "./LookupGrid.js";
@@ -1,3 +1,5 @@
1
1
  export * from "./BoundLookup.js";
2
2
  export * from "./Combobox.js";
3
3
  export * from "./Lookup.js";
4
+ export * from "./LookupEdit.js";
5
+ export * from "./LookupGrid.js";
@@ -2,6 +2,6 @@ import { type TextfieldProps } from "@digdir/designsystemet-react";
2
2
  export type BoundDatePickerProps = Omit<TextfieldProps, "value" | "onChange" | "onKeyDown" | "type"> & {
3
3
  field: string;
4
4
  };
5
- export declare const BoundDatePicker: import("react").ForwardRefExoticComponent<Omit<TextfieldProps, "onChange" | "value" | "onKeyDown" | "type"> & {
5
+ export declare const BoundDatePicker: import("react").ForwardRefExoticComponent<Omit<TextfieldProps, "onChange" | "onKeyDown" | "type" | "value"> & {
6
6
  field: string;
7
7
  } & import("react").RefAttributes<HTMLInputElement>>;
@@ -2,6 +2,6 @@ import { type TextfieldProps } from "@digdir/designsystemet-react";
2
2
  export type BoundDateTimePickerProps = Omit<TextfieldProps, "value" | "onChange" | "onKeyDown" | "type"> & {
3
3
  field: string;
4
4
  };
5
- export declare const BoundDateTimePicker: import("react").ForwardRefExoticComponent<Omit<TextfieldProps, "onChange" | "value" | "onKeyDown" | "type"> & {
5
+ export declare const BoundDateTimePicker: import("react").ForwardRefExoticComponent<Omit<TextfieldProps, "onChange" | "onKeyDown" | "type" | "value"> & {
6
6
  field: string;
7
7
  } & import("react").RefAttributes<HTMLInputElement>>;
@@ -3,4 +3,4 @@ export type BoundInputProps = Omit<InputProps, "onChange" | "onKeyDown" | "onBlu
3
3
  field: string;
4
4
  onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
5
5
  };
6
- export declare function BoundInput({ field, onChange: onChangeProp, ...props }: BoundInputProps): import("react/jsx-runtime").JSX.Element;
6
+ export declare function BoundInput({ field, onChange: onChangeProp, ...props }: BoundInputProps): import("react").JSX.Element;
@@ -17,4 +17,4 @@ export type BoundNumericTextFieldProps = Omit<TextfieldProps, "onChange" | "onKe
17
17
  onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
18
18
  onError?: (error: string | null) => void;
19
19
  };
20
- export declare function BoundNumericTextField({ field, precision, scale, onChange: onChangeProp, onError, ...props }: BoundNumericTextFieldProps): import("react/jsx-runtime").JSX.Element;
20
+ export declare function BoundNumericTextField({ field, precision, scale, onChange: onChangeProp, onError, ...props }: BoundNumericTextFieldProps): import("react").JSX.Element;
@@ -3,7 +3,7 @@ export type BoundSwitchProps = Omit<SwitchProps, "onChange" | "checked"> & {
3
3
  field: string;
4
4
  onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
5
5
  };
6
- export declare const BoundSwitch: import("react").ForwardRefExoticComponent<Omit<SwitchProps, "onChange" | "checked"> & {
6
+ export declare const BoundSwitch: import("react").ForwardRefExoticComponent<Omit<SwitchProps, "checked" | "onChange"> & {
7
7
  field: string;
8
8
  onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
9
9
  } & import("react").RefAttributes<HTMLInputElement>>;
@@ -2,6 +2,6 @@ import { type TextfieldProps } from "@digdir/designsystemet-react";
2
2
  export type BoundTextFieldProps = Omit<TextfieldProps, "value" | "onChange" | "onKeyDown"> & {
3
3
  field: string;
4
4
  };
5
- export declare const BoundTextField: import("react").ForwardRefExoticComponent<Omit<TextfieldProps, "onChange" | "value" | "onKeyDown"> & {
5
+ export declare const BoundTextField: import("react").ForwardRefExoticComponent<Omit<TextfieldProps, "onChange" | "onKeyDown" | "value"> & {
6
6
  field: string;
7
7
  } & import("react").RefAttributes<HTMLInputElement & HTMLTextAreaElement>>;
@@ -10,4 +10,4 @@ export type DataEditToolbarProps = React.HTMLAttributes<HTMLDivElement> & {
10
10
  */
11
11
  showNavigation?: boolean;
12
12
  };
13
- export declare function DataEditToolbar({ children, navigationDataObject, showNavigation, style, ...props }: DataEditToolbarProps): import("react/jsx-runtime").JSX.Element;
13
+ export declare function DataEditToolbar({ children, navigationDataObject, showNavigation, style, ...props }: DataEditToolbarProps): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Button, Tooltip } from "@digdir/designsystemet-react";
3
3
  import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
4
4
  import ChevronRightIcon from "@mui/icons-material/ChevronRight";
@@ -41,10 +41,10 @@ export type DataObjectSelectProps<T extends Record<string, unknown>> = React.Sel
41
41
  */
42
42
  getOptionLabel?: (item: T) => string;
43
43
  };
44
- export declare function DataObjectSelect<T extends Record<string, unknown>>({ nullable, blankLabel, dataObject, filter, id: idProp, label, name, getOptionLabel, getOptionValue, valueField, labelField, value, defaultValue, ...props }: DataObjectSelectProps<T>): import("react/jsx-runtime").JSX.Element;
44
+ export declare function DataObjectSelect<T extends Record<string, unknown>>({ nullable, blankLabel, dataObject, filter, id: idProp, label, name, getOptionLabel, getOptionValue, valueField, labelField, value, defaultValue, ...props }: DataObjectSelectProps<T>): import("react").JSX.Element;
45
45
  export declare function BoundDataObjectSelect<T extends Record<string, unknown>>({ field, ...props }: DataObjectSelectProps<T> & {
46
46
  /**
47
47
  * Field to bind the value of the select component to.
48
48
  */
49
49
  field: string;
50
- }): import("react/jsx-runtime").JSX.Element;
50
+ }): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Field, Label, Select } from "@digdir/designsystemet-react";
3
3
  import { useFetchData, useField } from "@olenbetong/appframe-react";
4
4
  import { useEffect, useId, useRef } from "react";
@@ -7,7 +7,7 @@ export function DataObjectSelect({ nullable = false, blankLabel = "", dataObject
7
7
  let fallbackId = useId();
8
8
  let id = idProp ?? fallbackId;
9
9
  let inputRef = useRef(null);
10
- // biome-ignore lint/correctness/useExhaustiveDependencies: default value should only affect the initial value
10
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- default value should only affect the initial value
11
11
  useEffect(() => {
12
12
  let defaultExists = data.find((r) => String(getOptionValue?.(r) ?? r[valueField]) === defaultValue);
13
13
  if (inputRef.current && defaultExists) {
@@ -1,3 +1,3 @@
1
1
  import "./AppLayoutDesktop.css";
2
2
  import type { AppLayoutProps } from "./index.js";
3
- export declare function AppLayoutDesktop({ AppMenuProps, children, menu, partner, }: AppLayoutProps): import("react/jsx-runtime").JSX.Element;
3
+ export declare function AppLayoutDesktop({ AppMenuProps, children, menu, partner }: AppLayoutProps): import("react").JSX.Element;
@@ -13,7 +13,7 @@ function LoadingFallback() {
13
13
  animation: "progress-indeterminate 1.5s ease-in-out infinite",
14
14
  } }));
15
15
  }
16
- export function AppLayoutDesktop({ AppMenuProps, children, menu, partner, }) {
16
+ export function AppLayoutDesktop({ AppMenuProps, children, menu, partner }) {
17
17
  let [open, setOpen] = useState(true);
18
18
  return (_jsxs("div", { className: clsx(partner ? "app-view" : "LayoutLarge-container"), children: [partner && menu && _jsx(PartnerToggle, { open: open, onClick: () => setOpen((o) => !o) }), _jsxs("div", { className: clsx("LayoutLarge-root", menu && "LayoutLarge-withMenu", open && "open"), children: [menu && (_jsx(AppMenu, { className: "LayoutLarge-menu AppMenu-drawer", ...AppMenuProps, children: menu })), _jsx("main", { className: "LayoutLarge-main", children: _jsx(Suspense, { fallback: _jsx(LoadingFallback, {}), children: _jsx(ErrorBoundary, { FallbackComponent: ErrorAlertFallback, children: children }) }) })] })] }));
19
19
  }
@@ -1,3 +1,3 @@
1
1
  import "./AppLayoutMobile.css";
2
2
  import type { AppLayoutProps } from "./index.js";
3
- export declare function AppLayoutMobile({ AppMenuProps, children, menu, partner }: AppLayoutProps): import("react/jsx-runtime").JSX.Element;
3
+ export declare function AppLayoutMobile({ AppMenuProps, children, menu, partner }: AppLayoutProps): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import "./AppLayoutMobile.css";
3
3
  import { useEffect, useState } from "react";
4
4
  import { useLocation } from "react-router";
@@ -10,9 +10,9 @@ function Toggle({ onToggle }) {
10
10
  export function AppLayoutMobile({ AppMenuProps, children, menu, partner }) {
11
11
  let [drawerOpen, setDrawerOpen] = useState(false);
12
12
  let location = useLocation();
13
- // biome-ignore lint/correctness/useExhaustiveDependencies: close drawer on navigation
13
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- close drawer on navigation
14
14
  useEffect(() => {
15
15
  setDrawerOpen(false);
16
16
  }, [location]);
17
- return (_jsxs("div", { className: "LayoutSmall-root", children: [partner && menu && (_jsx(PartnerToggle, { open: drawerOpen, onClick: () => setDrawerOpen((c) => !c) })), !partner && menu && (_jsx("div", { className: "LayoutSmall-menuBar", children: _jsx(Toggle, { onToggle: () => setDrawerOpen((c) => !c) }) })), menu && (_jsxs(_Fragment, { children: [_jsx("div", { className: `LayoutSmall-drawerOverlay${drawerOpen ? " open" : ""}`, onClick: () => setDrawerOpen(false), "aria-hidden": "true" }), _jsx("div", { className: `LayoutSmall-drawer AppMenu-drawer${drawerOpen ? " open" : ""}`, children: _jsx(AppMenu, { ...AppMenuProps, children: menu }) })] })), children] }));
17
+ return (_jsxs("div", { className: "LayoutSmall-root", children: [partner && menu && _jsx(PartnerToggle, { open: drawerOpen, onClick: () => setDrawerOpen((c) => !c) }), !partner && menu && (_jsx("div", { className: "LayoutSmall-menuBar", children: _jsx(Toggle, { onToggle: () => setDrawerOpen((c) => !c) }) })), menu && (_jsxs(_Fragment, { children: [_jsx("div", { className: `LayoutSmall-drawerOverlay${drawerOpen ? " open" : ""}`, onClick: () => setDrawerOpen(false), "aria-hidden": "true" }), _jsx("div", { className: `LayoutSmall-drawer AppMenu-drawer${drawerOpen ? " open" : ""}`, children: _jsx(AppMenu, { ...AppMenuProps, children: menu }) })] })), children] }));
18
18
  }
@@ -5,4 +5,4 @@ export type AppMenuProps = Pick<React.HTMLProps<HTMLElement>, "aria-label" | "ar
5
5
  headerPrimary?: string;
6
6
  headerSecondary?: string;
7
7
  };
8
- export declare function AppMenu({ children, className, headerPrimary, headerSecondary, ...props }: AppMenuProps): import("react/jsx-runtime").JSX.Element;
8
+ export declare function AppMenu({ children, className, headerPrimary, headerSecondary, ...props }: AppMenuProps): React.JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import "./AppMenu.css";
3
3
  import { getLocalizedString } from "@olenbetong/appframe-core";
4
4
  import clsx from "clsx";
@@ -1,4 +1,4 @@
1
1
  import "./AppMenuSubheader.css";
2
2
  import type React from "react";
3
3
  export type AppMenuSubheaderProps = React.HTMLAttributes<HTMLDivElement>;
4
- export declare function AppMenuSubheader({ children, className, ...props }: AppMenuSubheaderProps): import("react/jsx-runtime").JSX.Element;
4
+ export declare function AppMenuSubheader({ children, className, ...props }: AppMenuSubheaderProps): React.JSX.Element;
@@ -21,10 +21,10 @@ export type ErrorAlertFallbackProps = {
21
21
  error: unknown;
22
22
  className?: string;
23
23
  };
24
- export declare function ErrorAlertFallback({ error, className }: ErrorAlertFallbackProps): import("react/jsx-runtime").JSX.Element;
25
- export declare function RouteErrorPage(): import("react/jsx-runtime").JSX.Element;
26
- export declare function AsyncErrorAlert(): import("react/jsx-runtime").JSX.Element;
24
+ export declare function ErrorAlertFallback({ error, className }: ErrorAlertFallbackProps): import("react").JSX.Element;
25
+ export declare function RouteErrorPage(): import("react").JSX.Element;
26
+ export declare function AsyncErrorAlert(): import("react").JSX.Element;
27
27
  export declare function DataObjectErrorAlert({ dataObject }: {
28
28
  dataObject: DataObject<any>;
29
- }): import("react/jsx-runtime").JSX.Element | null;
29
+ }): import("react").JSX.Element | null;
30
30
  export {};
@@ -4,4 +4,4 @@ export type PageContainerProps = React.HTMLAttributes<HTMLDivElement> & {
4
4
  fullHeight?: boolean;
5
5
  maxWidth?: "xs" | "sm" | "md" | "lg" | "xl" | false;
6
6
  };
7
- export declare function PageContainer({ children, className, fullHeight, maxWidth, ...props }: PageContainerProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function PageContainer({ children, className, fullHeight, maxWidth, ...props }: PageContainerProps): React.JSX.Element;
@@ -30,4 +30,4 @@ export * from "./constants.js";
30
30
  export * from "./ErrorBoundary.js";
31
31
  export * from "./PageContainer.js";
32
32
  export * from "./PartnerToggle.js";
33
- export declare function AppLayout(props: AppLayoutProps): import("react/jsx-runtime").JSX.Element;
33
+ export declare function AppLayout(props: AppLayoutProps): React.JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-ds",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Components that bind Designsystemet components to Appframe data objects",
5
5
  "type": "module",
6
6
  "types": "./es/index.d.ts",
@@ -15,37 +15,37 @@
15
15
  "author": "Bjørnar Vister Hansen <bvh@olenbetong.no>",
16
16
  "license": "MIT",
17
17
  "peerDependencies": {
18
- "@digdir/designsystemet-react": ">=1.15.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",
22
- "react-error-boundary": ">=5.0.0",
23
- "react-router": ">=7.0.0"
20
+ "react": ">=19.2.8",
21
+ "react-dom": ">=19.2.8",
22
+ "react-error-boundary": ">=6.1.2",
23
+ "react-router": ">=8.3.0"
24
24
  },
25
25
  "dependencies": {
26
26
  "@olenbetong/appframe-core": "2.11.10",
27
27
  "@olenbetong/appframe-data": "1.5.0",
28
- "@olenbetong/appframe-react": "1.21.25",
28
+ "@olenbetong/appframe-react": "1.23.0",
29
29
  "react-window": "^1.8.11",
30
30
  "react-virtualized-auto-sizer": "^1.0.26",
31
31
  "clsx": "^2.1.1"
32
32
  },
33
33
  "devDependencies": {
34
- "@digdir/designsystemet-react": "1.15.0",
35
- "@mui/icons-material": "9.0.1",
36
- "@types/react": "19.2.15",
34
+ "@digdir/designsystemet-react": "1.18.0",
35
+ "@mui/icons-material": "9.2.0",
36
+ "@types/react": "19.2.17",
37
37
  "@types/react-dom": "19.2.3",
38
38
  "@types/react-window": "^1.8.8",
39
- "react": "19.2.6",
40
- "react-dom": "19.2.6",
39
+ "react": "19.2.8",
40
+ "react-dom": "19.2.8",
41
41
  "react-error-boundary": "6.1.2",
42
- "react-router": "7.16.0",
43
- "typescript": "6.0.3"
42
+ "react-router": "8.3.0",
43
+ "typescript": "7.0.2"
44
44
  },
45
45
  "optionalDependencies": {},
46
46
  "scripts": {
47
47
  "build": "pnpm run build:tsc && pnpm run build:css",
48
48
  "build:tsc": "node --no-warnings ../../scripts/clean.ts ./es tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
49
- "build:css": "cp src/AfLookup/Lookup.css es/AfLookup/Lookup.css && cp src/AfLookup/Combobox.css es/AfLookup/Combobox.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"
49
+ "build:css": "node scripts/copyAssets.mjs"
50
50
  }
51
51
  }
@@ -0,0 +1,22 @@
1
+ import { cpSync, mkdirSync } from "node:fs";
2
+
3
+ const files = [
4
+ ["src/AfLookup/Lookup.css", "es/AfLookup/Lookup.css"],
5
+ ["src/AfLookup/LookupGrid.css", "es/AfLookup/LookupGrid.css"],
6
+ ["src/AfLookup/Combobox.css", "es/AfLookup/Combobox.css"],
7
+ ["src/layout/AppMenu.css", "es/layout/AppMenu.css"],
8
+ ["src/layout/AppMenuItem.css", "es/layout/AppMenuItem.css"],
9
+ ["src/layout/AppMenuSubheader.css", "es/layout/AppMenuSubheader.css"],
10
+ ["src/layout/AppLayoutDesktop.css", "es/layout/AppLayoutDesktop.css"],
11
+ ["src/layout/AppLayoutMobile.css", "es/layout/AppLayoutMobile.css"],
12
+ ["src/layout/index.css", "es/layout/index.css"],
13
+ ["src/layout/ErrorBoundary.css", "es/layout/ErrorBoundary.css"],
14
+ ["src/layout/PageContainer.css", "es/layout/PageContainer.css"],
15
+ ];
16
+
17
+ mkdirSync("es/layout", { recursive: true });
18
+
19
+ for (let [src, dest] of files) {
20
+ cpSync(src, dest);
21
+ console.log(`Copied ${src} → ${dest}`);
22
+ }
@@ -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,58 @@ 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;
18
+ initialSearchValue?: string;
16
19
  isItemSelected?: (item: T) => boolean;
17
- renderItem: (props: React.ComponentPropsWithRef<"li"> & { item: T }) => React.ReactNode;
18
20
  onChange?: (item: T | null) => void;
19
21
  value?: T;
20
22
  onClose?: () => void;
21
23
  }
22
24
 
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();
25
+ export interface ComboboxListProps<T extends Record<string, unknown>> extends ComboboxBaseProps<T> {
26
+ /**
27
+ * Renders the popup content as a list (default).
28
+ */
29
+ variant?: "list";
30
+ renderItem: (props: React.ComponentPropsWithRef<"li"> & { item: T }) => React.ReactNode;
31
+ }
32
+
33
+ export interface ComboboxGridProps<T extends Record<string, unknown>> extends ComboboxBaseProps<T> {
34
+ /**
35
+ * Renders the popup content as a grid with a header row and one column per
36
+ * entry in `columns`.
37
+ */
38
+ variant: "grid";
39
+ columns: LookupColumn<T>[];
40
+ }
41
+
42
+ export type ComboboxProps<T extends Record<string, unknown>> = ComboboxListProps<T> | ComboboxGridProps<T>;
43
+
44
+ export function Combobox<T extends Record<string, unknown>>(props: ComboboxProps<T>) {
45
+ let {
46
+ nullable,
47
+ dataObject,
48
+ isItemSelected,
49
+ uniqueIdField = "PrimKey",
50
+ initialSearchValue,
51
+ onChange,
52
+ onClose,
53
+ } = props;
54
+ let isGrid = props.variant === "grid";
55
+ let columns = isGrid ? (props as ComboboxGridProps<T>).columns : undefined;
56
+ let listRef = useRef<VirtualList>(null);
57
+
33
58
  let [isDesktop, setIsDesktop] = useState(
34
59
  () => typeof window !== "undefined" && window.matchMedia("(width >= 768px) and (pointer: fine)").matches,
35
60
  );
@@ -41,27 +66,23 @@ export function Combobox<T extends Record<string, unknown>>({
41
66
  return () => mq.removeEventListener("change", handler);
42
67
  }, []);
43
68
 
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(
69
+ let {
70
+ id,
71
+ data,
72
+ loading,
73
+ inputValue,
74
+ setInputValue,
75
+ currentIndex,
76
+ currentColumn,
77
+ getItemId,
78
+ handleSelectItem,
79
+ handleKeyDown,
80
+ } = useComboboxData<T>({
59
81
  dataObject,
60
- query
61
- .split(/\s+/g)
62
- .map((word) => `[SearchColumn] LIKE '%${word}%'`)
63
- .join(" AND "),
64
- );
82
+ uniqueIdField,
83
+ columnCount: columns?.length ?? 1,
84
+ onChange,
85
+ });
65
86
 
66
87
  let wrapperRef = useRef<HTMLDivElement>(null);
67
88
  let [height, setHeight] = useState<number>(0);
@@ -79,71 +100,16 @@ export function Combobox<T extends Record<string, unknown>>({
79
100
  return () => resizeObserver.disconnect();
80
101
  }, []);
81
102
 
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
103
  let sizeMap = useRef(new Map<number, number>());
137
104
  let getItemSize = (index: number) => sizeMap.current.get(index) ?? 56;
138
- let setSize = (index: number, size: number) => {
105
+ let setSize = useCallback((index: number, size: number) => {
139
106
  if (sizeMap.current.get(index) !== size) {
140
107
  sizeMap.current.set(index, size);
141
108
  // listRef will not be set on the initial render, so delay resetting
142
109
  setTimeout(() => listRef.current?.resetAfterIndex(index, true));
143
110
  }
144
- };
111
+ }, []);
145
112
 
146
- // biome-ignore lint: nested component defined for performance reasons inside render
147
113
  let Row = ({ index, style: { height, ...style } }: { index: number; style: React.CSSProperties }) => {
148
114
  let rowRef = useRef<HTMLLIElement>(null);
149
115
 
@@ -155,9 +121,10 @@ export function Combobox<T extends Record<string, unknown>>({
155
121
  let item = data[index];
156
122
  let itemId = getItemId(item);
157
123
  let active = index === currentIndex;
124
+ let ItemComponent = (props as ComboboxListProps<T>).renderItem;
158
125
 
159
126
  return (
160
- <Component
127
+ <ItemComponent
161
128
  className={clsx("ObCombobox-listItem", active && "active", isItemSelected?.(item) && "selected")}
162
129
  aria-selected={active}
163
130
  onClick={() => handleSelectItem(item)}
@@ -175,9 +142,15 @@ export function Combobox<T extends Record<string, unknown>>({
175
142
 
176
143
  useEffect(() => listRef.current?.scrollToItem(currentIndex, "smart"), [currentIndex]);
177
144
 
145
+ useEffect(() => {
146
+ if (initialSearchValue !== undefined) {
147
+ setInputValue(initialSearchValue);
148
+ }
149
+ }, [initialSearchValue, setInputValue]);
150
+
178
151
  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
152
+ // oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- element is not interactive, click is used to prevent dialog close
153
+ // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- element is not interactive, click is used to prevent dialog close
181
154
  <div className="ObCombobox-root" id={id} onClick={(evt) => evt.stopPropagation()}>
182
155
  <div className="ObCombobox-actions">
183
156
  {nullable && (
@@ -201,7 +174,8 @@ export function Combobox<T extends Record<string, unknown>>({
201
174
  <Textfield
202
175
  type="search"
203
176
  className="ObCombobox-input"
204
- label={getLocalizedString("Search")}
177
+ aria-label={getLocalizedString("Search")}
178
+ placeholder={getLocalizedString("Search")}
205
179
  onKeyDown={handleKeyDown}
206
180
  aria-activedescendant={getItemId(data[currentIndex]) ?? ""}
207
181
  value={inputValue}
@@ -213,20 +187,32 @@ export function Combobox<T extends Record<string, unknown>>({
213
187
  <div className="ObCombobox-progress-bar" />
214
188
  </div>
215
189
  )}
216
- {height > 0 && (
217
- <div className="ObCombobox-list" role="listbox">
218
- <VirtualList
219
- ref={listRef}
190
+ {height > 0 &&
191
+ (columns ? (
192
+ <LookupGrid<T>
193
+ columns={columns}
194
+ data={data}
220
195
  height={height}
221
- width="100%"
222
- itemCount={data.length}
223
- itemSize={getItemSize}
224
- overscanCount={5}
225
- >
226
- {Row}
227
- </VirtualList>
228
- </div>
229
- )}
196
+ currentIndex={currentIndex}
197
+ currentColumn={currentColumn}
198
+ getItemId={getItemId}
199
+ isItemSelected={isItemSelected}
200
+ onSelectItem={handleSelectItem}
201
+ />
202
+ ) : (
203
+ <div className="ObCombobox-list" role="listbox">
204
+ <VirtualList
205
+ ref={listRef}
206
+ height={height}
207
+ width="100%"
208
+ itemCount={data.length}
209
+ itemSize={getItemSize}
210
+ overscanCount={5}
211
+ >
212
+ {Row}
213
+ </VirtualList>
214
+ </div>
215
+ ))}
230
216
  </div>
231
217
  </div>
232
218
  );