@olenbetong/appframe-ds 0.3.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 +13 -0
- package/es/AfLookup/BoundLookup.d.ts +4 -3
- package/es/AfLookup/Combobox.css +1 -1
- package/es/AfLookup/Combobox.d.ts +21 -5
- package/es/AfLookup/Combobox.js +19 -72
- package/es/AfLookup/Lookup.css +21 -0
- package/es/AfLookup/Lookup.d.ts +6 -5
- package/es/AfLookup/Lookup.js +13 -132
- 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 +2 -0
- package/es/AfLookup/index.js +2 -0
- package/es/binding/BoundDatePicker.d.ts +1 -1
- package/es/binding/BoundDateTimePicker.d.ts +1 -1
- package/es/binding/BoundInput.d.ts +1 -1
- package/es/binding/BoundNumericTextField.d.ts +1 -1
- package/es/binding/BoundSwitch.d.ts +1 -1
- package/es/binding/BoundTextField.d.ts +1 -1
- package/es/binding/DataEditToolbar.d.ts +1 -1
- package/es/binding/DataEditToolbar.js +1 -1
- package/es/binding/DataObjectSelect.d.ts +2 -2
- package/es/binding/DataObjectSelect.js +2 -2
- package/es/layout/AppLayoutDesktop.d.ts +1 -1
- package/es/layout/AppLayoutDesktop.js +1 -1
- package/es/layout/AppLayoutMobile.d.ts +1 -1
- package/es/layout/AppLayoutMobile.js +3 -3
- package/es/layout/AppMenu.d.ts +1 -1
- package/es/layout/AppMenu.js +1 -1
- package/es/layout/AppMenuSubheader.d.ts +1 -1
- package/es/layout/ErrorBoundary.d.ts +4 -4
- package/es/layout/PageContainer.d.ts +1 -1
- package/es/layout/index.d.ts +1 -1
- package/package.json +13 -13
- 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/layout/AppLayoutMobile.tsx +1 -1
- package/tsconfig.build.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @olenbetong/appframe-ds
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 46177d7: Add grid variant support to AfLookup and extract shared lookup logic.
|
|
8
|
+
- `@olenbetong/appframe-react`: new `useLookupState` and `useComboboxData` hooks containing the shared non-UI lookup logic (dialog state, anchor positioning, search filtering, keyboard navigation), plus the `LookupColumn` and `DistributiveOmit` types.
|
|
9
|
+
- `@olenbetong/appframe-mui` / `@olenbetong/appframe-ds`: the lookup popup now supports `variant="grid"` with a `columns` definition (`field`, `headerName`, `width`, `renderCell`). Grid keyboard navigation is cell-by-cell (tracks current row and column) and works while focus is in the search field.
|
|
10
|
+
- `@olenbetong/appframe-ds`: new `AfLookupEdit` component — an editable lookup input where the popup selects a single value (the item's `lookupField`) instead of an entire row, so typing and selecting behave the same. The existing `AfLookupEdit` in `@olenbetong/appframe-mui` now shares the same lookup state logic and supports the grid variant.
|
|
11
|
+
|
|
12
|
+
Fixes:
|
|
13
|
+
- The lookup dialog no longer scrolls its own content (`overflow: hidden` on `.ObLookup-dialog`), and the popup list/grid now grows and shrinks vertically when the dialog is resized (the list wrapper previously had a fixed height).
|
|
14
|
+
- The search field in the `appframe-ds` lookup no longer renders a label above the input; it uses an accessible label and placeholder instead.
|
|
15
|
+
|
|
3
16
|
## 0.3.0
|
|
4
17
|
|
|
5
18
|
### Minor Changes
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { CurrentRow, DataObject } from "@olenbetong/appframe-data";
|
|
2
|
+
import { type DistributiveOmit } from "@olenbetong/appframe-react";
|
|
2
3
|
import { type AfLookupProps } from "./Lookup.js";
|
|
3
|
-
export
|
|
4
|
+
export type BoundLookupProps<TBoundData extends Record<string, unknown>, TLookupData extends Record<string, unknown>> = DistributiveOmit<AfLookupProps<TLookupData>, "onChange"> & {
|
|
4
5
|
dataObject: DataObject<TLookupData>;
|
|
5
6
|
displayField?: keyof TBoundData;
|
|
6
7
|
getChanges: (selectedItem: TLookupData | null) => Partial<TBoundData>;
|
|
7
8
|
getDisplayValue?: (currentRow: CurrentRow<TBoundData>) => string;
|
|
8
|
-
}
|
|
9
|
-
export declare function BoundLookup<TBoundData extends Record<string, unknown>, TLookupData extends Record<string, unknown> = any>({ dataObject, displayField, getChanges, getDisplayValue, ...props }: BoundLookupProps<TBoundData, TLookupData>): import("react
|
|
9
|
+
};
|
|
10
|
+
export declare function BoundLookup<TBoundData extends Record<string, unknown>, TLookupData extends Record<string, unknown> = any>({ dataObject, displayField, getChanges, getDisplayValue, ...props }: BoundLookupProps<TBoundData, TLookupData>): import("react").JSX.Element;
|
package/es/AfLookup/Combobox.css
CHANGED
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
import "./Combobox.css";
|
|
2
2
|
import type { DataObject } from "@olenbetong/appframe-data";
|
|
3
|
-
|
|
3
|
+
import { type LookupColumn } from "@olenbetong/appframe-react";
|
|
4
|
+
export interface ComboboxBaseProps<T extends Record<string, unknown>> {
|
|
4
5
|
nullable?: boolean;
|
|
5
6
|
dataObject: DataObject<T>;
|
|
6
7
|
uniqueIdField?: string;
|
|
7
8
|
isItemSelected?: (item: T) => boolean;
|
|
8
|
-
renderItem: (props: React.ComponentPropsWithRef<"li"> & {
|
|
9
|
-
item: T;
|
|
10
|
-
}) => React.ReactNode;
|
|
11
9
|
onChange?: (item: T | null) => void;
|
|
12
10
|
value?: T;
|
|
13
11
|
onClose?: () => void;
|
|
14
12
|
}
|
|
15
|
-
export
|
|
13
|
+
export interface ComboboxListProps<T extends Record<string, unknown>> extends ComboboxBaseProps<T> {
|
|
14
|
+
/**
|
|
15
|
+
* Renders the popup content as a list (default).
|
|
16
|
+
*/
|
|
17
|
+
variant?: "list";
|
|
18
|
+
renderItem: (props: React.ComponentPropsWithRef<"li"> & {
|
|
19
|
+
item: T;
|
|
20
|
+
}) => React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
export interface ComboboxGridProps<T extends Record<string, unknown>> extends ComboboxBaseProps<T> {
|
|
23
|
+
/**
|
|
24
|
+
* Renders the popup content as a grid with a header row and one column per
|
|
25
|
+
* entry in `columns`.
|
|
26
|
+
*/
|
|
27
|
+
variant: "grid";
|
|
28
|
+
columns: LookupColumn<T>[];
|
|
29
|
+
}
|
|
30
|
+
export type ComboboxProps<T extends Record<string, unknown>> = ComboboxListProps<T> | ComboboxGridProps<T>;
|
|
31
|
+
export declare function Combobox<T extends Record<string, unknown>>(props: ComboboxProps<T>): import("react").JSX.Element;
|
package/es/AfLookup/Combobox.js
CHANGED
|
@@ -2,12 +2,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import "./Combobox.css";
|
|
3
3
|
import { Button, Textfield } from "@digdir/designsystemet-react";
|
|
4
4
|
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
5
|
-
import {
|
|
5
|
+
import { useComboboxData } from "@olenbetong/appframe-react";
|
|
6
6
|
import clsx from "clsx";
|
|
7
|
-
import { useCallback, useEffect,
|
|
7
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
8
8
|
import { VariableSizeList as VirtualList } from "react-window";
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import { LookupGrid } from "./LookupGrid.js";
|
|
10
|
+
export function Combobox(props) {
|
|
11
|
+
let { nullable, dataObject, isItemSelected, uniqueIdField = "PrimKey", onChange, onClose } = props;
|
|
12
|
+
let isGrid = props.variant === "grid";
|
|
13
|
+
let columns = isGrid ? props.columns : undefined;
|
|
14
|
+
let listRef = useRef(null);
|
|
11
15
|
let [isDesktop, setIsDesktop] = useState(() => typeof window !== "undefined" && window.matchMedia("(width >= 768px) and (pointer: fine)").matches);
|
|
12
16
|
useEffect(() => {
|
|
13
17
|
let mq = window.matchMedia("(width >= 768px) and (pointer: fine)");
|
|
@@ -15,22 +19,12 @@ export function Combobox({ nullable, dataObject, isItemSelected, uniqueIdField =
|
|
|
15
19
|
mq.addEventListener("change", handler);
|
|
16
20
|
return () => mq.removeEventListener("change", handler);
|
|
17
21
|
}, []);
|
|
18
|
-
let
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
let filters = currentFilter.split(/\s+AND\s+/g);
|
|
24
|
-
let wordFilters = filters.filter((filter) => filter.includes("[SearchColumn]"));
|
|
25
|
-
let words = wordFilters.map((filter) => filter.match(/'%([^']*)%'/)?.[1] ?? "").filter(Boolean);
|
|
26
|
-
return words.join(" ");
|
|
22
|
+
let { id, data, loading, inputValue, setInputValue, currentIndex, currentColumn, getItemId, handleSelectItem, handleKeyDown, } = useComboboxData({
|
|
23
|
+
dataObject,
|
|
24
|
+
uniqueIdField,
|
|
25
|
+
columnCount: columns?.length ?? 1,
|
|
26
|
+
onChange,
|
|
27
27
|
});
|
|
28
|
-
let query = useDebounce(inputValue, 300);
|
|
29
|
-
let loading = useLoading(dataObject);
|
|
30
|
-
let data = useDataWithFilter(dataObject, query
|
|
31
|
-
.split(/\s+/g)
|
|
32
|
-
.map((word) => `[SearchColumn] LIKE '%${word}%'`)
|
|
33
|
-
.join(" AND "));
|
|
34
28
|
let wrapperRef = useRef(null);
|
|
35
29
|
let [height, setHeight] = useState(0);
|
|
36
30
|
useLayoutEffect(() => {
|
|
@@ -44,64 +38,15 @@ export function Combobox({ nullable, dataObject, isItemSelected, uniqueIdField =
|
|
|
44
38
|
resizeObserver.observe(wrapperRef.current);
|
|
45
39
|
return () => resizeObserver.disconnect();
|
|
46
40
|
}, []);
|
|
47
|
-
// Reset current index when new data is loaded
|
|
48
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: isn't it obvious?
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
setCurrentIndex(0);
|
|
51
|
-
}, [data]);
|
|
52
|
-
let getItemId = useCallback((item) => {
|
|
53
|
-
if (!item) {
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
let itemId = item[uniqueIdField];
|
|
57
|
-
return `${id}-${itemId}`;
|
|
58
|
-
}, [id, uniqueIdField]);
|
|
59
|
-
function handleSelectItem(item) {
|
|
60
|
-
onChange?.(item);
|
|
61
|
-
setInputValue("");
|
|
62
|
-
}
|
|
63
|
-
function handleKeyDown(event) {
|
|
64
|
-
let newIndex = null;
|
|
65
|
-
if (event.key === "ArrowDown") {
|
|
66
|
-
newIndex = currentIndex + 1;
|
|
67
|
-
}
|
|
68
|
-
else if (event.key === "ArrowUp") {
|
|
69
|
-
newIndex = currentIndex - 1;
|
|
70
|
-
}
|
|
71
|
-
else if (event.key === "Home" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
|
|
72
|
-
newIndex = 0;
|
|
73
|
-
}
|
|
74
|
-
else if (event.key === "End" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
|
|
75
|
-
newIndex = data.length - 1;
|
|
76
|
-
}
|
|
77
|
-
else if (event.key === "Enter") {
|
|
78
|
-
event.stopPropagation();
|
|
79
|
-
event.preventDefault();
|
|
80
|
-
handleSelectItem(data[currentIndex]);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (newIndex !== null) {
|
|
84
|
-
event.stopPropagation();
|
|
85
|
-
event.preventDefault();
|
|
86
|
-
if (newIndex < 0) {
|
|
87
|
-
newIndex = data.length - 1;
|
|
88
|
-
}
|
|
89
|
-
else if (newIndex >= data.length) {
|
|
90
|
-
newIndex = 0;
|
|
91
|
-
}
|
|
92
|
-
setCurrentIndex(newIndex);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
41
|
let sizeMap = useRef(new Map());
|
|
96
42
|
let getItemSize = (index) => sizeMap.current.get(index) ?? 56;
|
|
97
|
-
let setSize = (index, size) => {
|
|
43
|
+
let setSize = useCallback((index, size) => {
|
|
98
44
|
if (sizeMap.current.get(index) !== size) {
|
|
99
45
|
sizeMap.current.set(index, size);
|
|
100
46
|
// listRef will not be set on the initial render, so delay resetting
|
|
101
47
|
setTimeout(() => listRef.current?.resetAfterIndex(index, true));
|
|
102
48
|
}
|
|
103
|
-
};
|
|
104
|
-
// biome-ignore lint: nested component defined for performance reasons inside render
|
|
49
|
+
}, []);
|
|
105
50
|
let Row = ({ index, style: { height, ...style } }) => {
|
|
106
51
|
let rowRef = useRef(null);
|
|
107
52
|
useLayoutEffect(() => {
|
|
@@ -112,7 +57,8 @@ export function Combobox({ nullable, dataObject, isItemSelected, uniqueIdField =
|
|
|
112
57
|
let item = data[index];
|
|
113
58
|
let itemId = getItemId(item);
|
|
114
59
|
let active = index === currentIndex;
|
|
115
|
-
|
|
60
|
+
let ItemComponent = props.renderItem;
|
|
61
|
+
return (_jsx(ItemComponent, { className: clsx("ObCombobox-listItem", active && "active", isItemSelected?.(item) && "selected"), "aria-selected": active, onClick: () => handleSelectItem(item), item: item, style: {
|
|
116
62
|
...style,
|
|
117
63
|
padding: isDesktop ? "0 8px" : "8px",
|
|
118
64
|
}, ref: rowRef, id: itemId }, itemId));
|
|
@@ -120,5 +66,6 @@ export function Combobox({ nullable, dataObject, isItemSelected, uniqueIdField =
|
|
|
120
66
|
useEffect(() => listRef.current?.scrollToItem(currentIndex, "smart"), [currentIndex]);
|
|
121
67
|
return (_jsxs("div", { className: "ObCombobox-root", id: id, onClick: (evt) => evt.stopPropagation(), children: [_jsxs("div", { className: "ObCombobox-actions", children: [nullable && (_jsx(Button, { className: "ObCombobox-clear", "data-size": isDesktop ? "sm" : "md", variant: "tertiary", onClick: () => {
|
|
122
68
|
onChange?.(null);
|
|
123
|
-
}, children: getLocalizedString("Clear") })), onClose && (_jsx(Button, { "data-size": isDesktop ? "sm" : "md", variant: "tertiary", className: "ObCombobox-close", onClick: onClose, children: getLocalizedString("Close") }))] }), _jsx(Textfield, { type: "search", className: "ObCombobox-input", label: getLocalizedString("Search"), onKeyDown: handleKeyDown, "aria-activedescendant": getItemId(data[currentIndex]) ?? "", value: inputValue, onChange: (event) => setInputValue(event.target.value) }), _jsxs("div", { className: "ObCombobox-listWrap", ref: wrapperRef, children: [loading && (_jsx("div", { className: "ObCombobox-progress", children: _jsx("div", { className: "ObCombobox-progress-bar" }) })), height > 0 &&
|
|
69
|
+
}, children: getLocalizedString("Clear") })), onClose && (_jsx(Button, { "data-size": isDesktop ? "sm" : "md", variant: "tertiary", className: "ObCombobox-close", onClick: onClose, children: getLocalizedString("Close") }))] }), _jsx(Textfield, { type: "search", className: "ObCombobox-input", "aria-label": getLocalizedString("Search"), placeholder: getLocalizedString("Search"), onKeyDown: handleKeyDown, "aria-activedescendant": getItemId(data[currentIndex]) ?? "", value: inputValue, onChange: (event) => setInputValue(event.target.value) }), _jsxs("div", { className: "ObCombobox-listWrap", ref: wrapperRef, children: [loading && (_jsx("div", { className: "ObCombobox-progress", children: _jsx("div", { className: "ObCombobox-progress-bar" }) })), height > 0 &&
|
|
70
|
+
(columns ? (_jsx(LookupGrid, { columns: columns, data: data, height: height, currentIndex: currentIndex, currentColumn: currentColumn, getItemId: getItemId, isItemSelected: isItemSelected, onSelectItem: handleSelectItem })) : (_jsx("div", { className: "ObCombobox-list", role: "listbox", children: _jsx(VirtualList, { ref: listRef, height: height, width: "100%", itemCount: data.length, itemSize: getItemSize, overscanCount: 5, children: Row }) })))] })] }));
|
|
124
71
|
}
|
package/es/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
|
|
package/es/AfLookup/Lookup.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import "./Lookup.css";
|
|
2
2
|
import { type TextfieldProps } from "@digdir/designsystemet-react";
|
|
3
|
+
import { type DistributiveOmit } from "@olenbetong/appframe-react";
|
|
3
4
|
import { type ComboboxProps } from "./Combobox.js";
|
|
4
|
-
export
|
|
5
|
+
export type AfLookupProps<T extends Record<string, unknown>> = DistributiveOmit<ComboboxProps<T>, "value"> & {
|
|
5
6
|
/**
|
|
6
7
|
* Allows the user to enter a value manually that might not be in the list.
|
|
7
8
|
*/
|
|
@@ -14,13 +15,13 @@ export interface AfLookupProps<T extends Record<string, unknown>> extends Omit<C
|
|
|
14
15
|
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
15
16
|
textField?: Partial<TextfieldProps>;
|
|
16
17
|
};
|
|
17
|
-
}
|
|
18
|
-
export
|
|
18
|
+
};
|
|
19
|
+
export type LookupProps<T extends Record<string, unknown>> = DistributiveOmit<ComboboxProps<T>, "value"> & {
|
|
19
20
|
slotProps?: {
|
|
20
21
|
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
21
22
|
};
|
|
22
23
|
getAnchorElement?: () => Element | null;
|
|
23
|
-
}
|
|
24
|
+
};
|
|
24
25
|
export declare function useLookup<T extends Record<string, unknown>>({ getAnchorElement, slotProps, ...props }: LookupProps<T>): {
|
|
25
26
|
open: boolean;
|
|
26
27
|
openDialog: () => void;
|
|
@@ -31,4 +32,4 @@ export declare function useLookup<T extends Record<string, unknown>>({ getAnchor
|
|
|
31
32
|
inputId: string;
|
|
32
33
|
dialog: import("react").ReactPortal;
|
|
33
34
|
};
|
|
34
|
-
export declare function AfLookup<T extends Record<string, unknown>>({ fullWidth, label, slotProps, value, ...props }: AfLookupProps<T>): import("react
|
|
35
|
+
export declare function AfLookup<T extends Record<string, unknown>>({ fullWidth, label, slotProps, value, ...props }: AfLookupProps<T>): import("react").JSX.Element;
|
package/es/AfLookup/Lookup.js
CHANGED
|
@@ -4,139 +4,23 @@ import { Button, Textfield } from "@digdir/designsystemet-react";
|
|
|
4
4
|
import ClearIcon from "@mui/icons-material/Close";
|
|
5
5
|
import SearchIcon from "@mui/icons-material/Search";
|
|
6
6
|
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
7
|
+
import { useLookupState } from "@olenbetong/appframe-react";
|
|
7
8
|
import clsx from "clsx";
|
|
8
|
-
import {
|
|
9
|
-
import { createPortal, flushSync } from "react-dom";
|
|
9
|
+
import { createPortal } from "react-dom";
|
|
10
10
|
import { Combobox } from "./Combobox.js";
|
|
11
11
|
export function useLookup({ getAnchorElement, slotProps, ...props }) {
|
|
12
|
-
let
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
let resetManualAnchor = useCallback(() => {
|
|
17
|
-
let $dialog = dialogRef.current;
|
|
18
|
-
if ($dialog) {
|
|
19
|
-
$dialog.style.removeProperty("--lookup-anchor-inline-start");
|
|
20
|
-
$dialog.style.removeProperty("--lookup-anchor-block-start");
|
|
21
|
-
delete $dialog.dataset.anchor;
|
|
22
|
-
}
|
|
23
|
-
}, []);
|
|
24
|
-
let updateManualAnchor = useCallback(() => {
|
|
25
|
-
if (typeof window === "undefined") {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
if (!window.matchMedia?.("(pointer: fine)").matches) {
|
|
29
|
-
resetManualAnchor();
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
let anchorElement = getAnchorElement?.() ?? null;
|
|
33
|
-
let $dialog = dialogRef.current;
|
|
34
|
-
if (!$dialog || !anchorElement) {
|
|
35
|
-
resetManualAnchor();
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
let rect = anchorElement.getBoundingClientRect();
|
|
39
|
-
let dialogHeight = $dialog.offsetHeight || 0;
|
|
40
|
-
let dialogWidth = $dialog.offsetWidth || 0;
|
|
41
|
-
let top = rect.bottom + 8;
|
|
42
|
-
let availableBelow = window.innerHeight - rect.bottom - 16;
|
|
43
|
-
if (availableBelow < dialogHeight) {
|
|
44
|
-
top = Math.max(16, rect.top - dialogHeight - 8);
|
|
45
|
-
}
|
|
46
|
-
let left = rect.left;
|
|
47
|
-
let minInline = 16;
|
|
48
|
-
let maxInline = window.innerWidth - dialogWidth - 16;
|
|
49
|
-
if (Number.isFinite(maxInline)) {
|
|
50
|
-
left = Math.min(Math.max(left, minInline), Math.max(minInline, maxInline));
|
|
51
|
-
}
|
|
52
|
-
$dialog.style.setProperty("--lookup-anchor-inline-start", `${Math.round(left)}px`);
|
|
53
|
-
$dialog.style.setProperty("--lookup-anchor-block-start", `${Math.round(top)}px`);
|
|
54
|
-
$dialog.dataset.anchor = "manual";
|
|
55
|
-
}, [getAnchorElement, resetManualAnchor]);
|
|
56
|
-
function handleOpen() {
|
|
57
|
-
let $dialog = dialogRef.current;
|
|
58
|
-
if ($dialog) {
|
|
59
|
-
flushSync(() => {
|
|
60
|
-
$dialog.showModal();
|
|
61
|
-
setOpen(true);
|
|
62
|
-
});
|
|
63
|
-
$dialog.querySelector('input[type="search"]')?.focus();
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
function handleKeyDown(event) {
|
|
67
|
-
if (event.key === "ArrowDown" || event.key === "F4") {
|
|
68
|
-
event.preventDefault();
|
|
69
|
-
event.stopPropagation();
|
|
70
|
-
handleOpen();
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
let handleClose = useCallback(() => {
|
|
74
|
-
setOpen(false);
|
|
75
|
-
document.getElementById(inputId)?.focus();
|
|
76
|
-
}, [inputId]);
|
|
12
|
+
let { open, openDialog, onKeyDown, dialogRef, anchorName, inputId } = useLookupState({
|
|
13
|
+
getAnchorElement,
|
|
14
|
+
scrollParentSelector: "[data-virtualized-scroller]",
|
|
15
|
+
});
|
|
77
16
|
function handleChange(item) {
|
|
78
17
|
props.onChange?.(item);
|
|
79
18
|
dialogRef.current?.close();
|
|
80
19
|
}
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (!open) {
|
|
83
|
-
resetManualAnchor();
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
let frame = requestAnimationFrame(() => {
|
|
87
|
-
updateManualAnchor();
|
|
88
|
-
});
|
|
89
|
-
updateManualAnchor();
|
|
90
|
-
let cleanupTargets = [];
|
|
91
|
-
let listener = () => updateManualAnchor();
|
|
92
|
-
cleanupTargets.push([window, "resize", listener, undefined]);
|
|
93
|
-
cleanupTargets.push([window, "scroll", listener, true]);
|
|
94
|
-
let anchorElement = getAnchorElement?.() ?? null;
|
|
95
|
-
let scrollParent = anchorElement?.closest("[data-virtualized-scroller]") ?? null;
|
|
96
|
-
if (scrollParent) {
|
|
97
|
-
cleanupTargets.push([scrollParent, "scroll", listener, { passive: true }]);
|
|
98
|
-
}
|
|
99
|
-
for (let [target, type, handler, options] of cleanupTargets) {
|
|
100
|
-
target.addEventListener(type, handler, options);
|
|
101
|
-
}
|
|
102
|
-
return () => {
|
|
103
|
-
cancelAnimationFrame(frame);
|
|
104
|
-
for (let [target, type, handler, options] of cleanupTargets) {
|
|
105
|
-
target.removeEventListener(type, handler, options);
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
}, [getAnchorElement, open, resetManualAnchor, updateManualAnchor]);
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
let $dialog = dialogRef.current;
|
|
111
|
-
if ($dialog) {
|
|
112
|
-
let controller = new AbortController();
|
|
113
|
-
let signal = controller.signal;
|
|
114
|
-
let closeEvent = () => {
|
|
115
|
-
// Wait for transition to finish before closing
|
|
116
|
-
setTimeout(() => {
|
|
117
|
-
handleClose();
|
|
118
|
-
}, 150);
|
|
119
|
-
};
|
|
120
|
-
$dialog.addEventListener("close", closeEvent, { signal });
|
|
121
|
-
$dialog.addEventListener("cancel", closeEvent, { signal });
|
|
122
|
-
$dialog.addEventListener("click", (event) => {
|
|
123
|
-
let rect = $dialog.getBoundingClientRect();
|
|
124
|
-
if (rect.left > event.clientX ||
|
|
125
|
-
rect.right < event.clientX ||
|
|
126
|
-
rect.top > event.clientY ||
|
|
127
|
-
rect.bottom < event.clientY) {
|
|
128
|
-
$dialog.close();
|
|
129
|
-
}
|
|
130
|
-
}, { signal });
|
|
131
|
-
return () => {
|
|
132
|
-
controller.abort();
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
}, [handleClose]);
|
|
136
20
|
return {
|
|
137
21
|
open,
|
|
138
|
-
openDialog
|
|
139
|
-
onKeyDown
|
|
22
|
+
openDialog,
|
|
23
|
+
onKeyDown,
|
|
140
24
|
onChange: handleChange,
|
|
141
25
|
dialogRef,
|
|
142
26
|
anchorName,
|
|
@@ -146,14 +30,11 @@ export function useLookup({ getAnchorElement, slotProps, ...props }) {
|
|
|
146
30
|
}
|
|
147
31
|
export function AfLookup({ fullWidth = false, label, slotProps, value, ...props }) {
|
|
148
32
|
let { inputId, open: _open, dialog, anchorName, openDialog, onKeyDown } = useLookup(props);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
display: "flex",
|
|
155
|
-
alignItems: "center",
|
|
156
|
-
}, children: [props.nullable && value && props.onChange && (_jsx(Button, { icon: true, variant: "tertiary", "data-size": "sm", "aria-label": getLocalizedString("Clear"), onKeyDown: (evt) => evt.stopPropagation(), onClick: (evt) => {
|
|
33
|
+
let hasClear = !!(props.nullable && value && props.onChange);
|
|
34
|
+
return (_jsxs("div", { style: { anchorName }, children: [_jsxs("div", { className: "ObLookup-inputWrap", style: {
|
|
35
|
+
display: fullWidth ? "block" : "inline-block",
|
|
36
|
+
"--ObLookup-buttons-width": hasClear ? "5.5rem" : "3rem",
|
|
37
|
+
}, children: [_jsx(Textfield, { id: inputId, value: value ?? "", label: label, readOnly: true, onClick: openDialog, onKeyDown: onKeyDown, className: clsx(slotProps?.textField?.className, "ObLookup-input"), ...slotProps?.textField, style: { width: fullWidth ? "100%" : undefined, ...slotProps?.textField?.style } }), _jsxs("div", { className: "ObLookup-buttons", children: [hasClear && (_jsx(Button, { icon: true, variant: "tertiary", "data-size": "sm", "aria-label": getLocalizedString("Clear"), onKeyDown: (evt) => evt.stopPropagation(), onClick: (evt) => {
|
|
157
38
|
evt.stopPropagation();
|
|
158
39
|
props.onChange?.(null);
|
|
159
40
|
document.getElementById(inputId)?.focus();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import "./Lookup.css";
|
|
2
|
+
import { type TextfieldProps } from "@digdir/designsystemet-react";
|
|
3
|
+
import type { DistributiveOmit } from "@olenbetong/appframe-react";
|
|
4
|
+
import type React from "react";
|
|
5
|
+
import type { ComboboxProps } from "./Combobox.js";
|
|
6
|
+
export type AfLookupEditProps<T extends Record<string, unknown>> = DistributiveOmit<ComboboxProps<T>, "value" | "onChange"> & {
|
|
7
|
+
/**
|
|
8
|
+
* Allows the user to enter a value manually that might not be in the list.
|
|
9
|
+
*/
|
|
10
|
+
editable?: boolean;
|
|
11
|
+
fullWidth?: boolean;
|
|
12
|
+
label?: string;
|
|
13
|
+
value?: string | null;
|
|
14
|
+
lookupField: keyof T;
|
|
15
|
+
onChange?: (evt: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
16
|
+
slotProps?: {
|
|
17
|
+
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
18
|
+
textField?: Partial<TextfieldProps>;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export declare function AfLookupEdit<T extends Record<string, unknown>>({ fullWidth, label, onChange, slotProps, nullable, lookupField, value, ...props }: AfLookupEditProps<T>): React.JSX.Element;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import "./Lookup.css";
|
|
3
|
+
import { Button, Textfield } from "@digdir/designsystemet-react";
|
|
4
|
+
import ClearIcon from "@mui/icons-material/Close";
|
|
5
|
+
import SearchIcon from "@mui/icons-material/Search";
|
|
6
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
7
|
+
import clsx from "clsx";
|
|
8
|
+
import { useLookup } from "./Lookup.js";
|
|
9
|
+
export function AfLookupEdit({ fullWidth = false, label, onChange, slotProps, nullable, lookupField, value, ...props }) {
|
|
10
|
+
let { inputId, dialog, anchorName, openDialog, onKeyDown } = useLookup({
|
|
11
|
+
...props,
|
|
12
|
+
nullable,
|
|
13
|
+
slotProps: { dialog: slotProps?.dialog },
|
|
14
|
+
onChange: (item) => applyValue(item),
|
|
15
|
+
});
|
|
16
|
+
// The popup selects a single value from the dataset: the item's lookupField
|
|
17
|
+
// is written to the input through the native value setter so the same
|
|
18
|
+
// change event fires whether the user types or picks from the list/grid.
|
|
19
|
+
function applyValue(item) {
|
|
20
|
+
let input = document.getElementById(inputId);
|
|
21
|
+
let nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
|
22
|
+
if (input && nativeInputValueSetter) {
|
|
23
|
+
nativeInputValueSetter.call(input, item ? String(item[lookupField]) : "");
|
|
24
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function handleKeyDown(event) {
|
|
28
|
+
if (event.key === "Enter") {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
event.stopPropagation();
|
|
31
|
+
openDialog();
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
onKeyDown(event);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let hasClear = !!(nullable && value && onChange);
|
|
38
|
+
return (_jsxs("div", { style: { anchorName }, children: [_jsxs("div", { className: "ObLookup-inputWrap", style: {
|
|
39
|
+
display: fullWidth ? "block" : "inline-block",
|
|
40
|
+
"--ObLookup-buttons-width": hasClear ? "5.5rem" : "3rem",
|
|
41
|
+
}, children: [_jsx(Textfield, { id: inputId, value: value ?? "", label: label, onChange: onChange, onKeyDown: handleKeyDown, className: clsx(slotProps?.textField?.className, "ObLookup-input"), ...slotProps?.textField, style: { width: fullWidth ? "100%" : undefined, ...slotProps?.textField?.style } }), _jsxs("div", { className: "ObLookup-buttons", children: [hasClear && (_jsx(Button, { icon: true, variant: "tertiary", "data-size": "sm", "aria-label": getLocalizedString("Clear"), onKeyDown: (evt) => evt.stopPropagation(), onClick: (evt) => {
|
|
42
|
+
evt.stopPropagation();
|
|
43
|
+
applyValue(null);
|
|
44
|
+
document.getElementById(inputId)?.focus();
|
|
45
|
+
}, children: _jsx(ClearIcon, {}) })), _jsx(Button, { icon: true, variant: "tertiary", "data-size": "sm", "aria-label": getLocalizedString("Search"), onClick: (evt) => {
|
|
46
|
+
evt.stopPropagation();
|
|
47
|
+
openDialog();
|
|
48
|
+
}, children: _jsx(SearchIcon, {}) })] })] }), dialog] }));
|
|
49
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
.ObLookupGrid-root {
|
|
2
|
+
height: 100%;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
overscroll-behavior: contain;
|
|
5
|
+
border: 1px solid rgb(0 0 0 / 0.06);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.ObLookupGrid-header {
|
|
9
|
+
display: grid;
|
|
10
|
+
height: 40px;
|
|
11
|
+
align-items: center;
|
|
12
|
+
border-bottom: 1px solid rgb(0 0 0 / 0.12);
|
|
13
|
+
background-color: rgb(0 0 0 / 0.03);
|
|
14
|
+
font-weight: 600;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.ObLookupGrid-headerCell {
|
|
18
|
+
padding: 0 8px;
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
text-overflow: ellipsis;
|
|
21
|
+
white-space: nowrap;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.ObLookupGrid-row {
|
|
25
|
+
--active-color: oklch(from var(--brand-secondary, #999) l c h / 50%);
|
|
26
|
+
display: grid;
|
|
27
|
+
align-items: center;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
border-bottom: 1px solid rgb(0 0 0 / 0.06);
|
|
30
|
+
|
|
31
|
+
&.active {
|
|
32
|
+
background-color: var(--active-color);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&:hover {
|
|
36
|
+
background-color: rgb(0 0 0 / 0.06);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&.active:hover {
|
|
40
|
+
background-color: oklch(from var(--active-color) calc(l + 0.1) calc(c - 0.04) h);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.ObLookupGrid-cell {
|
|
45
|
+
padding: 0 8px;
|
|
46
|
+
height: 100%;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
text-overflow: ellipsis;
|
|
51
|
+
white-space: nowrap;
|
|
52
|
+
|
|
53
|
+
&.active {
|
|
54
|
+
outline: 2px solid oklch(from var(--brand-secondary, #666) calc(l - 0.2) c h);
|
|
55
|
+
outline-offset: -2px;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import "./LookupGrid.css";
|
|
2
|
+
import type { LookupColumn } from "@olenbetong/appframe-react";
|
|
3
|
+
export interface LookupGridProps<T extends Record<string, unknown>> {
|
|
4
|
+
columns: LookupColumn<T>[];
|
|
5
|
+
data: T[];
|
|
6
|
+
height: number;
|
|
7
|
+
currentIndex: number;
|
|
8
|
+
currentColumn: number;
|
|
9
|
+
getItemId: (item: T | undefined) => string | undefined;
|
|
10
|
+
isItemSelected?: (item: T) => boolean;
|
|
11
|
+
onSelectItem: (item: T) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function LookupGrid<T extends Record<string, unknown>>({ columns, data, height, currentIndex, currentColumn, getItemId, isItemSelected, onSelectItem, }: LookupGridProps<T>): import("react").JSX.Element;
|
|
@@ -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
|
+
}
|
package/es/AfLookup/index.d.ts
CHANGED
package/es/AfLookup/index.js
CHANGED
|
@@ -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" | "
|
|
5
|
+
export declare const BoundDatePicker: import("react").ForwardRefExoticComponent<Omit<TextfieldProps, "onChange" | "onKeyDown" | "type" | "value"> & {
|
|
6
6
|
field: string;
|
|
7
7
|
} & import("react").RefAttributes<HTMLInputElement>>;
|