@olenbetong/appframe-ds 1.6.0 → 1.6.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.
- package/CHANGELOG.md +6 -0
- package/package.json +2 -2
- package/scripts/copyAssets.mjs +27 -27
- package/src/AfLookup/Lookup.css +147 -147
- package/src/AfLookup/Lookup.tsx +186 -186
- package/src/AfLookup/LookupCombobox.css +69 -69
- package/src/AfLookup/LookupCombobox.tsx +273 -273
- package/src/AfLookup/LookupEdit.tsx +130 -130
- package/src/AfLookup/LookupGrid.css +145 -145
- package/src/AfLookup/LookupGrid.test.tsx +142 -142
- package/src/AfLookup/LookupGrid.tsx +324 -324
- package/src/AfLookup/index.ts +5 -5
- package/src/autocomplete/Autocomplete.css +253 -253
- package/src/autocomplete/Autocomplete.tsx +320 -320
- package/src/autocomplete/AutocompleteFrame.tsx +168 -168
- package/src/autocomplete/Combobox.tsx +286 -286
- package/src/autocomplete/index.ts +4 -4
- package/src/autocomplete/types.ts +204 -204
- package/src/autocomplete/utils.ts +183 -183
- package/src/binding/BoundNumberInput.tsx +42 -42
- package/src/binding/BoundNumericTextField.tsx +102 -102
- package/src/binding/BoundTextField.tsx +49 -49
- package/src/binding/CancelButton.tsx +30 -30
- package/src/binding/CancelIconButton.tsx +30 -30
- package/src/binding/DataEditToolbar.css +23 -23
- package/src/binding/DataEditToolbar.tsx +84 -84
- package/src/binding/DeleteButton.tsx +35 -35
- package/src/binding/DeleteIconButton.tsx +35 -35
- package/src/binding/RefreshButton.tsx +31 -31
- package/src/binding/RefreshIconButton.tsx +33 -33
- package/src/binding/RefreshRowIconButton.tsx +33 -33
- package/src/binding/SaveButton.tsx +37 -37
- package/src/binding/SaveIconButton.tsx +38 -38
- package/src/binding/index.ts +20 -20
- package/src/container/Container.tsx +50 -50
- package/src/container/index.ts +1 -1
- package/src/cutoff/CutOffPicker.css +12 -12
- package/src/cutoff/CutOffPicker.tsx +165 -165
- package/src/cutoff/context.tsx +194 -194
- package/src/cutoff/dsCutOffDates.ts +21 -21
- package/src/cutoff/index.ts +4 -4
- package/src/cutoff/utils.test.ts +59 -59
- package/src/cutoff/utils.ts +276 -276
- package/src/filter/ChipListInput.tsx +79 -79
- package/src/filter/FieldFilterPanel.css +150 -150
- package/src/filter/FieldFilterPanel.tsx +309 -309
- package/src/filter/FilterBuilder.css +152 -152
- package/src/filter/FilterBuilder.test.tsx +153 -153
- package/src/filter/FilterBuilder.tsx +435 -435
- package/src/filter/FilterEditor.css +231 -231
- package/src/filter/FilterEditor.test.tsx +285 -285
- package/src/filter/FilterEditor.tsx +75 -75
- package/src/filter/FilterGroupEditor.tsx +160 -160
- package/src/filter/FilterNameDialog.css +22 -22
- package/src/filter/FilterNameDialog.tsx +81 -81
- package/src/filter/FilterRow.tsx +117 -117
- package/src/filter/FilterShareDialog.css +28 -28
- package/src/filter/FilterShareDialog.tsx +201 -201
- package/src/filter/FilterStringField.tsx +78 -78
- package/src/filter/FilterValueEditor.tsx +239 -239
- package/src/filter/SavedFilterTree.css +107 -107
- package/src/filter/SavedFilterTree.test.tsx +94 -94
- package/src/filter/SavedFilterTree.tsx +218 -218
- package/src/filter/fieldFilter.test.ts +158 -158
- package/src/filter/fieldFilter.ts +139 -139
- package/src/filter/index.ts +27 -27
- package/src/filter/types.ts +38 -38
- package/src/filter/useDistinctValues.test.ts +102 -102
- package/src/filter/useDistinctValues.ts +230 -230
- package/src/global.d.ts +11 -11
- package/src/grid/formatters.ts +23 -0
- package/src/grid/useAfColumns.tsx +28 -4
- package/src/grid/utcDate.test.ts +25 -0
- package/src/grid/utcDate.ts +28 -0
- package/src/grid/utcDateEditing.tsx +11 -0
- package/src/i18n.ts +13 -13
- package/src/index.ts +14 -14
- package/src/input/InputAdornments.css +80 -80
- package/src/input/InputAdornments.tsx +102 -102
- package/src/input/NumberInput.css +9 -9
- package/src/input/NumberInput.test.tsx +212 -212
- package/src/input/NumberInput.tsx +268 -268
- package/src/input/index.ts +3 -3
- package/src/input/numberFormat.test.ts +79 -79
- package/src/input/numberFormat.ts +94 -94
- package/src/layout/AppMenu.tsx +74 -74
- package/src/layout/AppMenuDrawer.css +71 -71
- package/src/layout/AppMenuDrawer.tsx +122 -122
- package/src/layout/DialogMotion.css +79 -79
- package/src/layout/ErrorBoundary.tsx +162 -162
- package/src/layout/PageContainer.css +48 -48
- package/src/layout/PageContainer.tsx +41 -41
- package/src/layout/ResponsiveDialog.css +86 -86
- package/src/layout/ResponsiveDialog.tsx +55 -55
- package/src/layout/index.tsx +70 -70
- package/src/link/Link.tsx +23 -23
- package/src/link/index.ts +1 -1
- package/src/mdi/Mdi.css +138 -138
- package/src/mdi/Mdi.test.tsx +91 -91
- package/src/mdi/Mdi.tsx +141 -141
- package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
- package/src/mdi/MdiContext.tsx +59 -59
- package/src/mdi/MdiDocumentContext.tsx +60 -60
- package/src/mdi/MdiPanel.tsx +61 -61
- package/src/mdi/MdiRestore.test.tsx +86 -86
- package/src/mdi/MdiTab.tsx +30 -30
- package/src/mdi/controller.test.ts +382 -382
- package/src/mdi/controller.ts +656 -656
- package/src/mdi/dockviewHost.ts +132 -132
- package/src/mdi/fakeHost.ts +200 -200
- package/src/mdi/host.ts +44 -44
- package/src/mdi/ids.test.ts +20 -20
- package/src/mdi/ids.ts +18 -18
- package/src/mdi/index.ts +31 -31
- package/src/mdi/layoutRestore.test.ts +156 -156
- package/src/mdi/layoutRestore.ts +311 -311
- package/src/mdi/layoutSpec.test.ts +143 -143
- package/src/mdi/layoutSpec.ts +214 -214
- package/src/mdi/layoutStore.test.ts +93 -93
- package/src/mdi/layoutStore.ts +99 -99
- package/src/mdi/memorySettingsStore.ts +21 -21
- package/src/mdi/registry.ts +34 -34
- package/src/mdi/testTypes.ts +37 -37
- package/src/mdi/theme.ts +22 -22
- package/src/mdi/types.ts +214 -214
- package/src/page/Page.css +57 -57
- package/src/page/PageBlock.css +64 -64
- package/src/page/PageBlock.tsx +93 -93
- package/src/page/PageHeader.css +88 -88
- package/src/page/PageHeader.tsx +115 -115
- package/src/page/PageTitle.css +5 -5
- package/src/page/PageTitle.tsx +21 -21
- package/src/page/index.ts +4 -4
- package/src/paper/Paper.tsx +52 -52
- package/src/progress/LinearProgress.css +121 -121
- package/src/progress/LinearProgress.tsx +106 -106
- package/src/progress/index.ts +1 -1
- package/src/report-downloader/ReportDownloader.css +67 -67
- package/src/report-downloader/components/StatusItem.tsx +87 -87
- package/src/report-downloader/components/StatusList.tsx +28 -28
- package/src/report-downloader/index.ts +41 -41
- package/src/report-downloader/status.tsx +92 -92
- package/src/sortable/DropIndicator.css +51 -51
- package/src/sortable/DropIndicator.tsx +27 -27
- package/src/sortable/SortableItemList.css +21 -21
- package/src/sortable/SortableItemList.tsx +264 -264
- package/src/sortable/index.ts +8 -8
- package/src/sortable/reorderSortOrder.ts +126 -126
- package/src/sortable/sortOrder.ts +101 -101
- package/src/sortable/types.ts +9 -9
- package/src/sortable/useSortableContainer.ts +53 -53
- package/src/sortable/useSortableItem.ts +95 -95
- package/src/sortable/useSortableList.ts +117 -117
- package/src/test/happy-dom-document-class.ts +24 -24
- package/src/test/setup.ts +27 -27
- package/src/theme/useDsColorScheme.ts +53 -53
- package/tsconfig.build.json +9 -9
- package/tsconfig.json +8 -8
- package/vitest.config.ts +10 -10
|
@@ -1,273 +1,273 @@
|
|
|
1
|
-
import "./LookupCombobox.css";
|
|
2
|
-
|
|
3
|
-
import { Button, Textfield } from "@digdir/designsystemet-react";
|
|
4
|
-
import { type DataObject, SortOrder } from "@olenbetong/appframe-data";
|
|
5
|
-
import { type LookupColumn, useComboboxData } from "@olenbetong/appframe-react";
|
|
6
|
-
|
|
7
|
-
import clsx from "clsx";
|
|
8
|
-
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
9
|
-
import { List, type RowComponentProps, useDynamicRowHeight, useListRef } from "react-window";
|
|
10
|
-
|
|
11
|
-
import { LookupGrid, type LookupGridSort } from "./LookupGrid.js";
|
|
12
|
-
import { getLocalizedString } from "../i18n.js";
|
|
13
|
-
import { LinearProgress } from "../progress/LinearProgress.js";
|
|
14
|
-
|
|
15
|
-
export interface LookupComboboxBaseProps<T extends Record<string, unknown>> {
|
|
16
|
-
nullable?: boolean;
|
|
17
|
-
dataObject: DataObject<T>;
|
|
18
|
-
uniqueIdField?: string;
|
|
19
|
-
initialSearchValue?: string;
|
|
20
|
-
isItemSelected?: (item: T) => boolean;
|
|
21
|
-
onChange?: (item: T | null) => void;
|
|
22
|
-
value?: T;
|
|
23
|
-
onClose?: () => void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface LookupComboboxListProps<T extends Record<string, unknown>> extends LookupComboboxBaseProps<T> {
|
|
27
|
-
/**
|
|
28
|
-
* Renders the popup content as a list (default).
|
|
29
|
-
*/
|
|
30
|
-
variant?: "list";
|
|
31
|
-
renderItem: (props: React.ComponentPropsWithRef<"li"> & { item: T }) => React.ReactNode;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface LookupComboboxGridProps<T extends Record<string, unknown>> extends LookupComboboxBaseProps<T> {
|
|
35
|
-
/**
|
|
36
|
-
* Renders the popup content as a grid with a header row and one column per
|
|
37
|
-
* entry in `columns`.
|
|
38
|
-
*/
|
|
39
|
-
variant: "grid";
|
|
40
|
-
columns: LookupColumn<T>[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type LookupComboboxProps<T extends Record<string, unknown>> =
|
|
44
|
-
| LookupComboboxListProps<T>
|
|
45
|
-
| LookupComboboxGridProps<T>;
|
|
46
|
-
|
|
47
|
-
type LookupComboboxRowProps<T extends Record<string, unknown>> = {
|
|
48
|
-
data: T[];
|
|
49
|
-
currentIndex: number;
|
|
50
|
-
isDesktop: boolean;
|
|
51
|
-
getItemId: (item: T | undefined) => string | undefined;
|
|
52
|
-
isItemSelected?: (item: T) => boolean;
|
|
53
|
-
onSelectItem: (item: T) => void;
|
|
54
|
-
renderItem: LookupComboboxListProps<T>["renderItem"];
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
function LookupComboboxRow<T extends Record<string, unknown>>({
|
|
58
|
-
index,
|
|
59
|
-
style,
|
|
60
|
-
data,
|
|
61
|
-
currentIndex,
|
|
62
|
-
isDesktop,
|
|
63
|
-
getItemId,
|
|
64
|
-
isItemSelected,
|
|
65
|
-
onSelectItem,
|
|
66
|
-
renderItem: ItemComponent,
|
|
67
|
-
}: RowComponentProps<LookupComboboxRowProps<T>>) {
|
|
68
|
-
let item = data[index];
|
|
69
|
-
let itemId = getItemId(item);
|
|
70
|
-
let active = index === currentIndex;
|
|
71
|
-
|
|
72
|
-
return (
|
|
73
|
-
<ItemComponent
|
|
74
|
-
className={clsx("ObCombobox-listItem", active && "active", isItemSelected?.(item) && "selected")}
|
|
75
|
-
aria-selected={active}
|
|
76
|
-
onClick={() => onSelectItem(item)}
|
|
77
|
-
item={item}
|
|
78
|
-
style={{
|
|
79
|
-
...style,
|
|
80
|
-
padding: isDesktop ? "4px 8px" : "8px",
|
|
81
|
-
}}
|
|
82
|
-
id={itemId}
|
|
83
|
-
/>
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function LookupCombobox<T extends Record<string, unknown>>(props: LookupComboboxProps<T>) {
|
|
88
|
-
let {
|
|
89
|
-
nullable,
|
|
90
|
-
dataObject,
|
|
91
|
-
isItemSelected,
|
|
92
|
-
uniqueIdField = "PrimKey",
|
|
93
|
-
initialSearchValue,
|
|
94
|
-
onChange,
|
|
95
|
-
onClose,
|
|
96
|
-
} = props;
|
|
97
|
-
let isGrid = props.variant === "grid";
|
|
98
|
-
let columns = isGrid ? (props as LookupComboboxGridProps<T>).columns : undefined;
|
|
99
|
-
let listRef = useListRef(null);
|
|
100
|
-
|
|
101
|
-
let [isDesktop, setIsDesktop] = useState(
|
|
102
|
-
() => typeof window !== "undefined" && window.matchMedia("(width >= 768px) and (pointer: fine)").matches,
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
let mq = window.matchMedia("(width >= 768px) and (pointer: fine)");
|
|
107
|
-
let handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
|
108
|
-
mq.addEventListener("change", handler);
|
|
109
|
-
return () => mq.removeEventListener("change", handler);
|
|
110
|
-
}, []);
|
|
111
|
-
|
|
112
|
-
let {
|
|
113
|
-
id,
|
|
114
|
-
data,
|
|
115
|
-
loading,
|
|
116
|
-
inputValue,
|
|
117
|
-
setInputValue,
|
|
118
|
-
currentIndex,
|
|
119
|
-
currentColumn,
|
|
120
|
-
getItemId,
|
|
121
|
-
handleSelectItem,
|
|
122
|
-
handleKeyDown,
|
|
123
|
-
} = useComboboxData<T>({
|
|
124
|
-
dataObject,
|
|
125
|
-
uniqueIdField,
|
|
126
|
-
columnCount: columns?.length ?? 1,
|
|
127
|
-
onChange,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// Sorting goes through the data object, so it is done by the server and covers every page.
|
|
131
|
-
// Like the search filter, it stays on the data object after the popup closes.
|
|
132
|
-
let [sortOrder, setSortOrder] = useState<Record<string, SortOrder>[]>(
|
|
133
|
-
() => (dataObject.getParameter("sortOrder") as Record<string, SortOrder>[] | undefined) ?? [],
|
|
134
|
-
);
|
|
135
|
-
let sort = useMemo<LookupGridSort | undefined>(() => {
|
|
136
|
-
let [first] = sortOrder;
|
|
137
|
-
let entry = first && Object.entries(first)[0];
|
|
138
|
-
if (!entry) return undefined;
|
|
139
|
-
let [field, direction] = entry;
|
|
140
|
-
return { field, direction: String(direction).startsWith("desc") ? "desc" : "asc" };
|
|
141
|
-
}, [sortOrder]);
|
|
142
|
-
|
|
143
|
-
function handleSort(field: string) {
|
|
144
|
-
let direction = sort?.field === field && sort.direction === "asc" ? SortOrder.Desc : SortOrder.Asc;
|
|
145
|
-
let next = [{ [field]: direction }];
|
|
146
|
-
setSortOrder(next);
|
|
147
|
-
dataObject.setParameter("sortOrder", next as never);
|
|
148
|
-
dataObject.refreshDataSource();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
let wrapperRef = useRef<HTMLDivElement>(null);
|
|
152
|
-
let [height, setHeight] = useState<number>(0);
|
|
153
|
-
|
|
154
|
-
useLayoutEffect(() => {
|
|
155
|
-
if (!wrapperRef.current) return;
|
|
156
|
-
|
|
157
|
-
let resizeObserver = new ResizeObserver(() => {
|
|
158
|
-
let newHeight = wrapperRef.current!.offsetHeight;
|
|
159
|
-
if (newHeight > 0) setHeight(newHeight);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
resizeObserver.observe(wrapperRef.current);
|
|
163
|
-
|
|
164
|
-
return () => resizeObserver.disconnect();
|
|
165
|
-
}, []);
|
|
166
|
-
|
|
167
|
-
// Rows are rendered by the consumer and have no fixed height, so let the
|
|
168
|
-
// list measure them as they mount.
|
|
169
|
-
let rowHeight = useDynamicRowHeight({ defaultRowHeight: 56 });
|
|
170
|
-
|
|
171
|
-
useEffect(() => {
|
|
172
|
-
if (currentIndex >= 0 && currentIndex < data.length) {
|
|
173
|
-
listRef.current?.scrollToRow({ index: currentIndex, align: "smart" });
|
|
174
|
-
}
|
|
175
|
-
}, [currentIndex, data.length, listRef]);
|
|
176
|
-
|
|
177
|
-
useEffect(() => {
|
|
178
|
-
if (initialSearchValue !== undefined) {
|
|
179
|
-
setInputValue(initialSearchValue);
|
|
180
|
-
}
|
|
181
|
-
}, [initialSearchValue, setInputValue]);
|
|
182
|
-
|
|
183
|
-
return (
|
|
184
|
-
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- element is not interactive, click is used to prevent dialog close
|
|
185
|
-
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- element is not interactive, click is used to prevent dialog close
|
|
186
|
-
// Compact on desktop, where the popup is a small dialog next to the field; the mobile
|
|
187
|
-
// bottom sheet keeps the default size for touch. The size cascades to the search field,
|
|
188
|
-
// the rows and the buttons.
|
|
189
|
-
<div
|
|
190
|
-
className="ObCombobox-root"
|
|
191
|
-
id={id}
|
|
192
|
-
data-size={isDesktop ? "sm" : "md"}
|
|
193
|
-
onClick={(evt) => evt.stopPropagation()}
|
|
194
|
-
>
|
|
195
|
-
<Textfield
|
|
196
|
-
type="search"
|
|
197
|
-
className="ObCombobox-input"
|
|
198
|
-
aria-label={getLocalizedString("Search")}
|
|
199
|
-
placeholder={getLocalizedString("Search")}
|
|
200
|
-
onKeyDown={handleKeyDown}
|
|
201
|
-
aria-activedescendant={getItemId(data[currentIndex]) ?? ""}
|
|
202
|
-
value={inputValue}
|
|
203
|
-
onChange={(event) => setInputValue((event.target as HTMLInputElement).value)}
|
|
204
|
-
/>
|
|
205
|
-
<div className="ObCombobox-listWrap" ref={wrapperRef}>
|
|
206
|
-
{loading && (
|
|
207
|
-
<LinearProgress
|
|
208
|
-
overlay
|
|
209
|
-
className="ObCombobox-progress"
|
|
210
|
-
data-size="xs"
|
|
211
|
-
aria-label={getLocalizedString("Loading")}
|
|
212
|
-
/>
|
|
213
|
-
)}
|
|
214
|
-
{height > 0 &&
|
|
215
|
-
(columns ? (
|
|
216
|
-
<LookupGrid<T>
|
|
217
|
-
columns={columns}
|
|
218
|
-
data={data}
|
|
219
|
-
height={height}
|
|
220
|
-
currentIndex={currentIndex}
|
|
221
|
-
currentColumn={currentColumn}
|
|
222
|
-
getItemId={getItemId}
|
|
223
|
-
isItemSelected={isItemSelected}
|
|
224
|
-
onSelectItem={handleSelectItem}
|
|
225
|
-
sort={sort}
|
|
226
|
-
onSort={handleSort}
|
|
227
|
-
/>
|
|
228
|
-
) : (
|
|
229
|
-
<div className="ObCombobox-list" role="listbox">
|
|
230
|
-
<List<LookupComboboxRowProps<T>>
|
|
231
|
-
role="presentation"
|
|
232
|
-
listRef={listRef}
|
|
233
|
-
style={{ height }}
|
|
234
|
-
rowCount={data.length}
|
|
235
|
-
rowHeight={rowHeight}
|
|
236
|
-
overscanCount={5}
|
|
237
|
-
rowComponent={LookupComboboxRow}
|
|
238
|
-
rowProps={{
|
|
239
|
-
data,
|
|
240
|
-
currentIndex,
|
|
241
|
-
isDesktop,
|
|
242
|
-
getItemId,
|
|
243
|
-
isItemSelected,
|
|
244
|
-
onSelectItem: handleSelectItem,
|
|
245
|
-
renderItem: (props as LookupComboboxListProps<T>).renderItem,
|
|
246
|
-
}}
|
|
247
|
-
/>
|
|
248
|
-
</div>
|
|
249
|
-
))}
|
|
250
|
-
</div>
|
|
251
|
-
{(nullable || onClose) && (
|
|
252
|
-
<div className="ObCombobox-actions">
|
|
253
|
-
{nullable && (
|
|
254
|
-
<Button
|
|
255
|
-
className="ObCombobox-clear"
|
|
256
|
-
variant="tertiary"
|
|
257
|
-
onClick={() => {
|
|
258
|
-
onChange?.(null);
|
|
259
|
-
}}
|
|
260
|
-
>
|
|
261
|
-
{getLocalizedString("Clear")}
|
|
262
|
-
</Button>
|
|
263
|
-
)}
|
|
264
|
-
{onClose && (
|
|
265
|
-
<Button variant="tertiary" className="ObCombobox-close" onClick={onClose}>
|
|
266
|
-
{getLocalizedString("Close")}
|
|
267
|
-
</Button>
|
|
268
|
-
)}
|
|
269
|
-
</div>
|
|
270
|
-
)}
|
|
271
|
-
</div>
|
|
272
|
-
);
|
|
273
|
-
}
|
|
1
|
+
import "./LookupCombobox.css";
|
|
2
|
+
|
|
3
|
+
import { Button, Textfield } from "@digdir/designsystemet-react";
|
|
4
|
+
import { type DataObject, SortOrder } from "@olenbetong/appframe-data";
|
|
5
|
+
import { type LookupColumn, useComboboxData } from "@olenbetong/appframe-react";
|
|
6
|
+
|
|
7
|
+
import clsx from "clsx";
|
|
8
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { List, type RowComponentProps, useDynamicRowHeight, useListRef } from "react-window";
|
|
10
|
+
|
|
11
|
+
import { LookupGrid, type LookupGridSort } from "./LookupGrid.js";
|
|
12
|
+
import { getLocalizedString } from "../i18n.js";
|
|
13
|
+
import { LinearProgress } from "../progress/LinearProgress.js";
|
|
14
|
+
|
|
15
|
+
export interface LookupComboboxBaseProps<T extends Record<string, unknown>> {
|
|
16
|
+
nullable?: boolean;
|
|
17
|
+
dataObject: DataObject<T>;
|
|
18
|
+
uniqueIdField?: string;
|
|
19
|
+
initialSearchValue?: string;
|
|
20
|
+
isItemSelected?: (item: T) => boolean;
|
|
21
|
+
onChange?: (item: T | null) => void;
|
|
22
|
+
value?: T;
|
|
23
|
+
onClose?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LookupComboboxListProps<T extends Record<string, unknown>> extends LookupComboboxBaseProps<T> {
|
|
27
|
+
/**
|
|
28
|
+
* Renders the popup content as a list (default).
|
|
29
|
+
*/
|
|
30
|
+
variant?: "list";
|
|
31
|
+
renderItem: (props: React.ComponentPropsWithRef<"li"> & { item: T }) => React.ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LookupComboboxGridProps<T extends Record<string, unknown>> extends LookupComboboxBaseProps<T> {
|
|
35
|
+
/**
|
|
36
|
+
* Renders the popup content as a grid with a header row and one column per
|
|
37
|
+
* entry in `columns`.
|
|
38
|
+
*/
|
|
39
|
+
variant: "grid";
|
|
40
|
+
columns: LookupColumn<T>[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type LookupComboboxProps<T extends Record<string, unknown>> =
|
|
44
|
+
| LookupComboboxListProps<T>
|
|
45
|
+
| LookupComboboxGridProps<T>;
|
|
46
|
+
|
|
47
|
+
type LookupComboboxRowProps<T extends Record<string, unknown>> = {
|
|
48
|
+
data: T[];
|
|
49
|
+
currentIndex: number;
|
|
50
|
+
isDesktop: boolean;
|
|
51
|
+
getItemId: (item: T | undefined) => string | undefined;
|
|
52
|
+
isItemSelected?: (item: T) => boolean;
|
|
53
|
+
onSelectItem: (item: T) => void;
|
|
54
|
+
renderItem: LookupComboboxListProps<T>["renderItem"];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function LookupComboboxRow<T extends Record<string, unknown>>({
|
|
58
|
+
index,
|
|
59
|
+
style,
|
|
60
|
+
data,
|
|
61
|
+
currentIndex,
|
|
62
|
+
isDesktop,
|
|
63
|
+
getItemId,
|
|
64
|
+
isItemSelected,
|
|
65
|
+
onSelectItem,
|
|
66
|
+
renderItem: ItemComponent,
|
|
67
|
+
}: RowComponentProps<LookupComboboxRowProps<T>>) {
|
|
68
|
+
let item = data[index];
|
|
69
|
+
let itemId = getItemId(item);
|
|
70
|
+
let active = index === currentIndex;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<ItemComponent
|
|
74
|
+
className={clsx("ObCombobox-listItem", active && "active", isItemSelected?.(item) && "selected")}
|
|
75
|
+
aria-selected={active}
|
|
76
|
+
onClick={() => onSelectItem(item)}
|
|
77
|
+
item={item}
|
|
78
|
+
style={{
|
|
79
|
+
...style,
|
|
80
|
+
padding: isDesktop ? "4px 8px" : "8px",
|
|
81
|
+
}}
|
|
82
|
+
id={itemId}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function LookupCombobox<T extends Record<string, unknown>>(props: LookupComboboxProps<T>) {
|
|
88
|
+
let {
|
|
89
|
+
nullable,
|
|
90
|
+
dataObject,
|
|
91
|
+
isItemSelected,
|
|
92
|
+
uniqueIdField = "PrimKey",
|
|
93
|
+
initialSearchValue,
|
|
94
|
+
onChange,
|
|
95
|
+
onClose,
|
|
96
|
+
} = props;
|
|
97
|
+
let isGrid = props.variant === "grid";
|
|
98
|
+
let columns = isGrid ? (props as LookupComboboxGridProps<T>).columns : undefined;
|
|
99
|
+
let listRef = useListRef(null);
|
|
100
|
+
|
|
101
|
+
let [isDesktop, setIsDesktop] = useState(
|
|
102
|
+
() => typeof window !== "undefined" && window.matchMedia("(width >= 768px) and (pointer: fine)").matches,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
let mq = window.matchMedia("(width >= 768px) and (pointer: fine)");
|
|
107
|
+
let handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
|
108
|
+
mq.addEventListener("change", handler);
|
|
109
|
+
return () => mq.removeEventListener("change", handler);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
let {
|
|
113
|
+
id,
|
|
114
|
+
data,
|
|
115
|
+
loading,
|
|
116
|
+
inputValue,
|
|
117
|
+
setInputValue,
|
|
118
|
+
currentIndex,
|
|
119
|
+
currentColumn,
|
|
120
|
+
getItemId,
|
|
121
|
+
handleSelectItem,
|
|
122
|
+
handleKeyDown,
|
|
123
|
+
} = useComboboxData<T>({
|
|
124
|
+
dataObject,
|
|
125
|
+
uniqueIdField,
|
|
126
|
+
columnCount: columns?.length ?? 1,
|
|
127
|
+
onChange,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Sorting goes through the data object, so it is done by the server and covers every page.
|
|
131
|
+
// Like the search filter, it stays on the data object after the popup closes.
|
|
132
|
+
let [sortOrder, setSortOrder] = useState<Record<string, SortOrder>[]>(
|
|
133
|
+
() => (dataObject.getParameter("sortOrder") as Record<string, SortOrder>[] | undefined) ?? [],
|
|
134
|
+
);
|
|
135
|
+
let sort = useMemo<LookupGridSort | undefined>(() => {
|
|
136
|
+
let [first] = sortOrder;
|
|
137
|
+
let entry = first && Object.entries(first)[0];
|
|
138
|
+
if (!entry) return undefined;
|
|
139
|
+
let [field, direction] = entry;
|
|
140
|
+
return { field, direction: String(direction).startsWith("desc") ? "desc" : "asc" };
|
|
141
|
+
}, [sortOrder]);
|
|
142
|
+
|
|
143
|
+
function handleSort(field: string) {
|
|
144
|
+
let direction = sort?.field === field && sort.direction === "asc" ? SortOrder.Desc : SortOrder.Asc;
|
|
145
|
+
let next = [{ [field]: direction }];
|
|
146
|
+
setSortOrder(next);
|
|
147
|
+
dataObject.setParameter("sortOrder", next as never);
|
|
148
|
+
dataObject.refreshDataSource();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let wrapperRef = useRef<HTMLDivElement>(null);
|
|
152
|
+
let [height, setHeight] = useState<number>(0);
|
|
153
|
+
|
|
154
|
+
useLayoutEffect(() => {
|
|
155
|
+
if (!wrapperRef.current) return;
|
|
156
|
+
|
|
157
|
+
let resizeObserver = new ResizeObserver(() => {
|
|
158
|
+
let newHeight = wrapperRef.current!.offsetHeight;
|
|
159
|
+
if (newHeight > 0) setHeight(newHeight);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
resizeObserver.observe(wrapperRef.current);
|
|
163
|
+
|
|
164
|
+
return () => resizeObserver.disconnect();
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
// Rows are rendered by the consumer and have no fixed height, so let the
|
|
168
|
+
// list measure them as they mount.
|
|
169
|
+
let rowHeight = useDynamicRowHeight({ defaultRowHeight: 56 });
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (currentIndex >= 0 && currentIndex < data.length) {
|
|
173
|
+
listRef.current?.scrollToRow({ index: currentIndex, align: "smart" });
|
|
174
|
+
}
|
|
175
|
+
}, [currentIndex, data.length, listRef]);
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (initialSearchValue !== undefined) {
|
|
179
|
+
setInputValue(initialSearchValue);
|
|
180
|
+
}
|
|
181
|
+
}, [initialSearchValue, setInputValue]);
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- element is not interactive, click is used to prevent dialog close
|
|
185
|
+
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- element is not interactive, click is used to prevent dialog close
|
|
186
|
+
// Compact on desktop, where the popup is a small dialog next to the field; the mobile
|
|
187
|
+
// bottom sheet keeps the default size for touch. The size cascades to the search field,
|
|
188
|
+
// the rows and the buttons.
|
|
189
|
+
<div
|
|
190
|
+
className="ObCombobox-root"
|
|
191
|
+
id={id}
|
|
192
|
+
data-size={isDesktop ? "sm" : "md"}
|
|
193
|
+
onClick={(evt) => evt.stopPropagation()}
|
|
194
|
+
>
|
|
195
|
+
<Textfield
|
|
196
|
+
type="search"
|
|
197
|
+
className="ObCombobox-input"
|
|
198
|
+
aria-label={getLocalizedString("Search")}
|
|
199
|
+
placeholder={getLocalizedString("Search")}
|
|
200
|
+
onKeyDown={handleKeyDown}
|
|
201
|
+
aria-activedescendant={getItemId(data[currentIndex]) ?? ""}
|
|
202
|
+
value={inputValue}
|
|
203
|
+
onChange={(event) => setInputValue((event.target as HTMLInputElement).value)}
|
|
204
|
+
/>
|
|
205
|
+
<div className="ObCombobox-listWrap" ref={wrapperRef}>
|
|
206
|
+
{loading && (
|
|
207
|
+
<LinearProgress
|
|
208
|
+
overlay
|
|
209
|
+
className="ObCombobox-progress"
|
|
210
|
+
data-size="xs"
|
|
211
|
+
aria-label={getLocalizedString("Loading")}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
{height > 0 &&
|
|
215
|
+
(columns ? (
|
|
216
|
+
<LookupGrid<T>
|
|
217
|
+
columns={columns}
|
|
218
|
+
data={data}
|
|
219
|
+
height={height}
|
|
220
|
+
currentIndex={currentIndex}
|
|
221
|
+
currentColumn={currentColumn}
|
|
222
|
+
getItemId={getItemId}
|
|
223
|
+
isItemSelected={isItemSelected}
|
|
224
|
+
onSelectItem={handleSelectItem}
|
|
225
|
+
sort={sort}
|
|
226
|
+
onSort={handleSort}
|
|
227
|
+
/>
|
|
228
|
+
) : (
|
|
229
|
+
<div className="ObCombobox-list" role="listbox">
|
|
230
|
+
<List<LookupComboboxRowProps<T>>
|
|
231
|
+
role="presentation"
|
|
232
|
+
listRef={listRef}
|
|
233
|
+
style={{ height }}
|
|
234
|
+
rowCount={data.length}
|
|
235
|
+
rowHeight={rowHeight}
|
|
236
|
+
overscanCount={5}
|
|
237
|
+
rowComponent={LookupComboboxRow}
|
|
238
|
+
rowProps={{
|
|
239
|
+
data,
|
|
240
|
+
currentIndex,
|
|
241
|
+
isDesktop,
|
|
242
|
+
getItemId,
|
|
243
|
+
isItemSelected,
|
|
244
|
+
onSelectItem: handleSelectItem,
|
|
245
|
+
renderItem: (props as LookupComboboxListProps<T>).renderItem,
|
|
246
|
+
}}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
))}
|
|
250
|
+
</div>
|
|
251
|
+
{(nullable || onClose) && (
|
|
252
|
+
<div className="ObCombobox-actions">
|
|
253
|
+
{nullable && (
|
|
254
|
+
<Button
|
|
255
|
+
className="ObCombobox-clear"
|
|
256
|
+
variant="tertiary"
|
|
257
|
+
onClick={() => {
|
|
258
|
+
onChange?.(null);
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
{getLocalizedString("Clear")}
|
|
262
|
+
</Button>
|
|
263
|
+
)}
|
|
264
|
+
{onClose && (
|
|
265
|
+
<Button variant="tertiary" className="ObCombobox-close" onClick={onClose}>
|
|
266
|
+
{getLocalizedString("Close")}
|
|
267
|
+
</Button>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|