@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.
- package/CHANGELOG.md +19 -0
- package/es/AfLookup/BoundLookup.d.ts +4 -3
- package/es/AfLookup/Combobox.css +1 -1
- package/es/AfLookup/Combobox.d.ts +22 -5
- package/es/AfLookup/Combobox.js +24 -72
- package/es/AfLookup/Lookup.css +21 -0
- package/es/AfLookup/Lookup.d.ts +7 -6
- package/es/AfLookup/Lookup.js +32 -134
- 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 +15 -15
- package/scripts/copyAssets.mjs +22 -0
- package/src/AfLookup/BoundLookup.tsx +4 -4
- package/src/AfLookup/Combobox.css +1 -1
- package/src/AfLookup/Combobox.tsx +94 -108
- package/src/AfLookup/Lookup.css +21 -0
- package/src/AfLookup/Lookup.tsx +60 -161
- 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/src/AfLookup/Lookup.css
CHANGED
|
@@ -1,7 +1,28 @@
|
|
|
1
|
+
.ObLookup-inputWrap {
|
|
2
|
+
position: relative;
|
|
3
|
+
|
|
4
|
+
.ds-input {
|
|
5
|
+
padding-inline-end: var(--ObLookup-buttons-width, 3rem);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.ObLookup-buttons {
|
|
10
|
+
position: absolute;
|
|
11
|
+
inset-inline-end: 0;
|
|
12
|
+
inset-block-end: 0;
|
|
13
|
+
/* Match the .ds-input height so the buttons center on the input row,
|
|
14
|
+
also when a label or description is rendered above the input */
|
|
15
|
+
height: var(--ds-size-12, 3rem);
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
padding-inline-end: var(--ds-size-1, 0.25rem);
|
|
19
|
+
}
|
|
20
|
+
|
|
1
21
|
.ObLookup-dialog {
|
|
2
22
|
border: 0;
|
|
3
23
|
margin-block-start: 0;
|
|
4
24
|
padding: 1rem;
|
|
25
|
+
overflow: hidden;
|
|
5
26
|
|
|
6
27
|
background-color: whitesmoke;
|
|
7
28
|
|
package/src/AfLookup/Lookup.tsx
CHANGED
|
@@ -4,14 +4,15 @@ import { Button, Textfield, type TextfieldProps } from "@digdir/designsystemet-r
|
|
|
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 { type DistributiveOmit, useLookupState } from "@olenbetong/appframe-react";
|
|
7
8
|
|
|
8
9
|
import clsx from "clsx";
|
|
9
|
-
import {
|
|
10
|
-
import { createPortal
|
|
10
|
+
import { useRef } from "react";
|
|
11
|
+
import { createPortal } from "react-dom";
|
|
11
12
|
|
|
12
13
|
import { Combobox, type ComboboxProps } from "./Combobox.js";
|
|
13
14
|
|
|
14
|
-
export
|
|
15
|
+
export type AfLookupProps<T extends Record<string, unknown>> = DistributiveOmit<ComboboxProps<T>, "value"> & {
|
|
15
16
|
/**
|
|
16
17
|
* Allows the user to enter a value manually that might not be in the list.
|
|
17
18
|
*/
|
|
@@ -24,167 +25,40 @@ export interface AfLookupProps<T extends Record<string, unknown>> extends Omit<C
|
|
|
24
25
|
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
25
26
|
textField?: Partial<TextfieldProps>;
|
|
26
27
|
};
|
|
27
|
-
}
|
|
28
|
+
};
|
|
28
29
|
|
|
29
|
-
export
|
|
30
|
+
export type LookupProps<T extends Record<string, unknown>> = DistributiveOmit<ComboboxProps<T>, "value"> & {
|
|
30
31
|
slotProps?: {
|
|
31
32
|
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
32
33
|
};
|
|
33
34
|
getAnchorElement?: () => Element | null;
|
|
34
|
-
}
|
|
35
|
+
};
|
|
35
36
|
|
|
36
37
|
export function useLookup<T extends Record<string, unknown>>({
|
|
37
38
|
getAnchorElement,
|
|
38
39
|
slotProps,
|
|
39
40
|
...props
|
|
40
41
|
}: LookupProps<T>) {
|
|
41
|
-
let
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
let
|
|
46
|
-
let $dialog = dialogRef.current;
|
|
47
|
-
if ($dialog) {
|
|
48
|
-
$dialog.style.removeProperty("--lookup-anchor-inline-start");
|
|
49
|
-
$dialog.style.removeProperty("--lookup-anchor-block-start");
|
|
50
|
-
delete $dialog.dataset.anchor;
|
|
51
|
-
}
|
|
52
|
-
}, []);
|
|
53
|
-
let updateManualAnchor = useCallback(() => {
|
|
54
|
-
if (typeof window === "undefined") {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
if (!window.matchMedia?.("(pointer: fine)").matches) {
|
|
58
|
-
resetManualAnchor();
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
let anchorElement = getAnchorElement?.() ?? null;
|
|
62
|
-
let $dialog = dialogRef.current;
|
|
63
|
-
if (!$dialog || !anchorElement) {
|
|
64
|
-
resetManualAnchor();
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
let rect = anchorElement.getBoundingClientRect();
|
|
68
|
-
let dialogHeight = $dialog.offsetHeight || 0;
|
|
69
|
-
let dialogWidth = $dialog.offsetWidth || 0;
|
|
70
|
-
let top = rect.bottom + 8;
|
|
71
|
-
let availableBelow = window.innerHeight - rect.bottom - 16;
|
|
72
|
-
if (availableBelow < dialogHeight) {
|
|
73
|
-
top = Math.max(16, rect.top - dialogHeight - 8);
|
|
74
|
-
}
|
|
75
|
-
let left = rect.left;
|
|
76
|
-
let minInline = 16;
|
|
77
|
-
let maxInline = window.innerWidth - dialogWidth - 16;
|
|
78
|
-
if (Number.isFinite(maxInline)) {
|
|
79
|
-
left = Math.min(Math.max(left, minInline), Math.max(minInline, maxInline));
|
|
80
|
-
}
|
|
81
|
-
$dialog.style.setProperty("--lookup-anchor-inline-start", `${Math.round(left)}px`);
|
|
82
|
-
$dialog.style.setProperty("--lookup-anchor-block-start", `${Math.round(top)}px`);
|
|
83
|
-
$dialog.dataset.anchor = "manual";
|
|
84
|
-
}, [getAnchorElement, resetManualAnchor]);
|
|
85
|
-
|
|
86
|
-
function handleOpen() {
|
|
87
|
-
let $dialog = dialogRef.current;
|
|
88
|
-
|
|
89
|
-
if ($dialog) {
|
|
90
|
-
flushSync(() => {
|
|
91
|
-
$dialog.showModal();
|
|
92
|
-
setOpen(true);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
$dialog.querySelector<HTMLInputElement>('input[type="search"]')?.focus();
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
|
100
|
-
if (event.key === "ArrowDown" || event.key === "F4") {
|
|
101
|
-
event.preventDefault();
|
|
102
|
-
event.stopPropagation();
|
|
103
|
-
handleOpen();
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
let handleClose = useCallback(() => {
|
|
108
|
-
setOpen(false);
|
|
109
|
-
document.getElementById(inputId)?.focus();
|
|
110
|
-
}, [inputId]);
|
|
42
|
+
let { open, openDialog, onKeyDown, dialogRef, anchorName, inputId } = useLookupState({
|
|
43
|
+
getAnchorElement,
|
|
44
|
+
scrollParentSelector: "[data-virtualized-scroller]",
|
|
45
|
+
});
|
|
46
|
+
let initialSearchValueRef = useRef<string | undefined>(undefined);
|
|
111
47
|
|
|
112
48
|
function handleChange(item: T | null) {
|
|
113
49
|
props.onChange?.(item);
|
|
114
50
|
dialogRef.current?.close();
|
|
115
51
|
}
|
|
116
52
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
let frame = requestAnimationFrame(() => {
|
|
123
|
-
updateManualAnchor();
|
|
124
|
-
});
|
|
125
|
-
updateManualAnchor();
|
|
126
|
-
let cleanupTargets: Array<
|
|
127
|
-
[EventTarget, string, EventListenerOrEventListenerObject, AddEventListenerOptions | boolean | undefined]
|
|
128
|
-
> = [];
|
|
129
|
-
let listener = () => updateManualAnchor();
|
|
130
|
-
cleanupTargets.push([window, "resize", listener, undefined]);
|
|
131
|
-
cleanupTargets.push([window, "scroll", listener, true]);
|
|
132
|
-
let anchorElement = getAnchorElement?.() ?? null;
|
|
133
|
-
let scrollParent = anchorElement?.closest<HTMLElement>("[data-virtualized-scroller]") ?? null;
|
|
134
|
-
if (scrollParent) {
|
|
135
|
-
cleanupTargets.push([scrollParent, "scroll", listener, { passive: true }]);
|
|
136
|
-
}
|
|
137
|
-
for (let [target, type, handler, options] of cleanupTargets) {
|
|
138
|
-
target.addEventListener(type, handler, options);
|
|
139
|
-
}
|
|
140
|
-
return () => {
|
|
141
|
-
cancelAnimationFrame(frame);
|
|
142
|
-
for (let [target, type, handler, options] of cleanupTargets) {
|
|
143
|
-
target.removeEventListener(type, handler, options);
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
}, [getAnchorElement, open, resetManualAnchor, updateManualAnchor]);
|
|
147
|
-
|
|
148
|
-
useEffect(() => {
|
|
149
|
-
let $dialog = dialogRef.current;
|
|
150
|
-
if ($dialog) {
|
|
151
|
-
let controller = new AbortController();
|
|
152
|
-
let signal = controller.signal;
|
|
153
|
-
let closeEvent = () => {
|
|
154
|
-
// Wait for transition to finish before closing
|
|
155
|
-
setTimeout(() => {
|
|
156
|
-
handleClose();
|
|
157
|
-
}, 150);
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
$dialog.addEventListener("close", closeEvent, { signal });
|
|
161
|
-
$dialog.addEventListener("cancel", closeEvent, { signal });
|
|
162
|
-
$dialog.addEventListener(
|
|
163
|
-
"click",
|
|
164
|
-
(event) => {
|
|
165
|
-
let rect = $dialog.getBoundingClientRect();
|
|
166
|
-
if (
|
|
167
|
-
rect.left > event.clientX ||
|
|
168
|
-
rect.right < event.clientX ||
|
|
169
|
-
rect.top > event.clientY ||
|
|
170
|
-
rect.bottom < event.clientY
|
|
171
|
-
) {
|
|
172
|
-
$dialog.close();
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
{ signal },
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
return () => {
|
|
179
|
-
controller.abort();
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
}, [handleClose]);
|
|
53
|
+
function handleOpenDialog(searchValue?: string) {
|
|
54
|
+
initialSearchValueRef.current = searchValue;
|
|
55
|
+
openDialog();
|
|
56
|
+
}
|
|
183
57
|
|
|
184
58
|
return {
|
|
185
59
|
open,
|
|
186
|
-
openDialog:
|
|
187
|
-
onKeyDown
|
|
60
|
+
openDialog: handleOpenDialog,
|
|
61
|
+
onKeyDown,
|
|
188
62
|
onChange: handleChange,
|
|
189
63
|
dialogRef,
|
|
190
64
|
anchorName,
|
|
@@ -196,7 +70,14 @@ export function useLookup<T extends Record<string, unknown>>({
|
|
|
196
70
|
onKeyDown={(evt) => evt.stopPropagation()}
|
|
197
71
|
style={{ positionAnchor: anchorName, ...slotProps?.dialog?.style } as React.CSSProperties}
|
|
198
72
|
>
|
|
199
|
-
{open &&
|
|
73
|
+
{open && (
|
|
74
|
+
<Combobox
|
|
75
|
+
{...props}
|
|
76
|
+
initialSearchValue={initialSearchValueRef.current}
|
|
77
|
+
onClose={() => dialogRef.current?.close()}
|
|
78
|
+
onChange={handleChange}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
200
81
|
</dialog>,
|
|
201
82
|
document.body,
|
|
202
83
|
inputId,
|
|
@@ -213,31 +94,43 @@ export function AfLookup<T extends Record<string, unknown>>({
|
|
|
213
94
|
}: AfLookupProps<T>) {
|
|
214
95
|
let { inputId, open: _open, dialog, anchorName, openDialog, onKeyDown } = useLookup<T>(props);
|
|
215
96
|
|
|
97
|
+
let hasClear = !!(props.nullable && value && props.onChange);
|
|
98
|
+
|
|
99
|
+
function handleInputKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
|
100
|
+
let hasModifier = event.ctrlKey || event.altKey || event.metaKey;
|
|
101
|
+
let isTypeaheadKey = event.key.length === 1 && !hasModifier && !event.nativeEvent.isComposing;
|
|
102
|
+
if (isTypeaheadKey) {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
event.stopPropagation();
|
|
105
|
+
openDialog(event.key);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
onKeyDown(event);
|
|
109
|
+
}
|
|
110
|
+
|
|
216
111
|
return (
|
|
217
112
|
<div style={{ anchorName } as React.CSSProperties}>
|
|
218
|
-
<div
|
|
113
|
+
<div
|
|
114
|
+
className="ObLookup-inputWrap"
|
|
115
|
+
style={
|
|
116
|
+
{
|
|
117
|
+
display: fullWidth ? "block" : "inline-block",
|
|
118
|
+
"--ObLookup-buttons-width": hasClear ? "5.5rem" : "3rem",
|
|
119
|
+
} as React.CSSProperties
|
|
120
|
+
}
|
|
121
|
+
>
|
|
219
122
|
<Textfield
|
|
220
123
|
id={inputId}
|
|
221
124
|
value={value ?? ""}
|
|
222
125
|
label={label}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
onKeyDown={onKeyDown}
|
|
126
|
+
onClick={() => openDialog()}
|
|
127
|
+
onKeyDown={handleInputKeyDown}
|
|
226
128
|
className={clsx(slotProps?.textField?.className, "ObLookup-input")}
|
|
227
129
|
{...(slotProps?.textField as any)}
|
|
228
130
|
style={{ width: fullWidth ? "100%" : undefined, ...slotProps?.textField?.style }}
|
|
229
131
|
/>
|
|
230
|
-
<div
|
|
231
|
-
|
|
232
|
-
position: "absolute",
|
|
233
|
-
right: 0,
|
|
234
|
-
top: "50%",
|
|
235
|
-
transform: "translateY(-50%)",
|
|
236
|
-
display: "flex",
|
|
237
|
-
alignItems: "center",
|
|
238
|
-
}}
|
|
239
|
-
>
|
|
240
|
-
{props.nullable && value && props.onChange && (
|
|
132
|
+
<div className="ObLookup-buttons">
|
|
133
|
+
{hasClear && (
|
|
241
134
|
<Button
|
|
242
135
|
icon
|
|
243
136
|
variant="tertiary"
|
|
@@ -253,7 +146,13 @@ export function AfLookup<T extends Record<string, unknown>>({
|
|
|
253
146
|
<ClearIcon />
|
|
254
147
|
</Button>
|
|
255
148
|
)}
|
|
256
|
-
<Button
|
|
149
|
+
<Button
|
|
150
|
+
icon
|
|
151
|
+
variant="tertiary"
|
|
152
|
+
data-size="sm"
|
|
153
|
+
aria-label={getLocalizedString("Search")}
|
|
154
|
+
onClick={() => openDialog()}
|
|
155
|
+
>
|
|
257
156
|
<SearchIcon />
|
|
258
157
|
</Button>
|
|
259
158
|
</div>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import "./Lookup.css";
|
|
2
|
+
|
|
3
|
+
import { Button, Textfield, type TextfieldProps } 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 type { DistributiveOmit } from "@olenbetong/appframe-react";
|
|
8
|
+
|
|
9
|
+
import clsx from "clsx";
|
|
10
|
+
import type React from "react";
|
|
11
|
+
|
|
12
|
+
import type { ComboboxProps } from "./Combobox.js";
|
|
13
|
+
import { useLookup } from "./Lookup.js";
|
|
14
|
+
|
|
15
|
+
export type AfLookupEditProps<T extends Record<string, unknown>> = DistributiveOmit<
|
|
16
|
+
ComboboxProps<T>,
|
|
17
|
+
"value" | "onChange"
|
|
18
|
+
> & {
|
|
19
|
+
/**
|
|
20
|
+
* Allows the user to enter a value manually that might not be in the list.
|
|
21
|
+
*/
|
|
22
|
+
editable?: boolean;
|
|
23
|
+
fullWidth?: boolean;
|
|
24
|
+
label?: string;
|
|
25
|
+
value?: string | null;
|
|
26
|
+
lookupField: keyof T;
|
|
27
|
+
onChange?: (evt: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
28
|
+
slotProps?: {
|
|
29
|
+
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
30
|
+
textField?: Partial<TextfieldProps>;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function AfLookupEdit<T extends Record<string, unknown>>({
|
|
35
|
+
fullWidth = false,
|
|
36
|
+
label,
|
|
37
|
+
onChange,
|
|
38
|
+
slotProps,
|
|
39
|
+
nullable,
|
|
40
|
+
lookupField,
|
|
41
|
+
value,
|
|
42
|
+
...props
|
|
43
|
+
}: AfLookupEditProps<T>) {
|
|
44
|
+
let { inputId, dialog, anchorName, openDialog, onKeyDown } = useLookup<T>({
|
|
45
|
+
...props,
|
|
46
|
+
nullable,
|
|
47
|
+
slotProps: { dialog: slotProps?.dialog },
|
|
48
|
+
onChange: (item) => applyValue(item),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// The popup selects a single value from the dataset: the item's lookupField
|
|
52
|
+
// is written to the input through the native value setter so the same
|
|
53
|
+
// change event fires whether the user types or picks from the list/grid.
|
|
54
|
+
function applyValue(item: T | null) {
|
|
55
|
+
let input = document.getElementById(inputId) as HTMLInputElement;
|
|
56
|
+
let nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
|
57
|
+
|
|
58
|
+
if (input && nativeInputValueSetter) {
|
|
59
|
+
nativeInputValueSetter.call(input, item ? String(item[lookupField]) : "");
|
|
60
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
|
|
65
|
+
if (event.key === "Enter") {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
event.stopPropagation();
|
|
68
|
+
openDialog();
|
|
69
|
+
} else {
|
|
70
|
+
onKeyDown(event);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let hasClear = !!(nullable && value && onChange);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div style={{ anchorName } as React.CSSProperties}>
|
|
78
|
+
<div
|
|
79
|
+
className="ObLookup-inputWrap"
|
|
80
|
+
style={
|
|
81
|
+
{
|
|
82
|
+
display: fullWidth ? "block" : "inline-block",
|
|
83
|
+
"--ObLookup-buttons-width": hasClear ? "5.5rem" : "3rem",
|
|
84
|
+
} as React.CSSProperties
|
|
85
|
+
}
|
|
86
|
+
>
|
|
87
|
+
<Textfield
|
|
88
|
+
id={inputId}
|
|
89
|
+
value={value ?? ""}
|
|
90
|
+
label={label}
|
|
91
|
+
onChange={onChange}
|
|
92
|
+
onKeyDown={handleKeyDown}
|
|
93
|
+
className={clsx(slotProps?.textField?.className, "ObLookup-input")}
|
|
94
|
+
{...(slotProps?.textField as any)}
|
|
95
|
+
style={{ width: fullWidth ? "100%" : undefined, ...slotProps?.textField?.style }}
|
|
96
|
+
/>
|
|
97
|
+
<div className="ObLookup-buttons">
|
|
98
|
+
{hasClear && (
|
|
99
|
+
<Button
|
|
100
|
+
icon
|
|
101
|
+
variant="tertiary"
|
|
102
|
+
data-size="sm"
|
|
103
|
+
aria-label={getLocalizedString("Clear")}
|
|
104
|
+
onKeyDown={(evt) => evt.stopPropagation()}
|
|
105
|
+
onClick={(evt) => {
|
|
106
|
+
evt.stopPropagation();
|
|
107
|
+
applyValue(null);
|
|
108
|
+
document.getElementById(inputId)?.focus();
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<ClearIcon />
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
<Button
|
|
115
|
+
icon
|
|
116
|
+
variant="tertiary"
|
|
117
|
+
data-size="sm"
|
|
118
|
+
aria-label={getLocalizedString("Search")}
|
|
119
|
+
onClick={(evt) => {
|
|
120
|
+
evt.stopPropagation();
|
|
121
|
+
openDialog();
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<SearchIcon />
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
{dialog}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -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,98 @@
|
|
|
1
|
+
import "./LookupGrid.css";
|
|
2
|
+
|
|
3
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
4
|
+
import type { LookupColumn } from "@olenbetong/appframe-react";
|
|
5
|
+
|
|
6
|
+
import clsx from "clsx";
|
|
7
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
8
|
+
import { FixedSizeList as VirtualList } from "react-window";
|
|
9
|
+
|
|
10
|
+
const ROW_HEIGHT = 40;
|
|
11
|
+
const HEADER_HEIGHT = 40;
|
|
12
|
+
|
|
13
|
+
export interface LookupGridProps<T extends Record<string, unknown>> {
|
|
14
|
+
columns: LookupColumn<T>[];
|
|
15
|
+
data: T[];
|
|
16
|
+
height: number;
|
|
17
|
+
currentIndex: number;
|
|
18
|
+
currentColumn: number;
|
|
19
|
+
getItemId: (item: T | undefined) => string | undefined;
|
|
20
|
+
isItemSelected?: (item: T) => boolean;
|
|
21
|
+
onSelectItem: (item: T) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getColumnWidth<T extends Record<string, unknown>>(column: LookupColumn<T>) {
|
|
25
|
+
if (typeof column.width === "number") {
|
|
26
|
+
return `${column.width}px`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return column.width ?? "minmax(80px, 1fr)";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function LookupGrid<T extends Record<string, unknown>>({
|
|
33
|
+
columns,
|
|
34
|
+
data,
|
|
35
|
+
height,
|
|
36
|
+
currentIndex,
|
|
37
|
+
currentColumn,
|
|
38
|
+
getItemId,
|
|
39
|
+
isItemSelected,
|
|
40
|
+
onSelectItem,
|
|
41
|
+
}: LookupGridProps<T>) {
|
|
42
|
+
let listRef = useRef<VirtualList>(null);
|
|
43
|
+
let gridTemplateColumns = useMemo(() => columns.map(getColumnWidth).join(" "), [columns]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => listRef.current?.scrollToItem(currentIndex, "smart"), [currentIndex]);
|
|
46
|
+
|
|
47
|
+
// oxlint-disable-next-line react/no-nested-component-definitions -- react-window row renderer
|
|
48
|
+
let Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
|
|
49
|
+
let item = data[index];
|
|
50
|
+
let itemId = getItemId(item);
|
|
51
|
+
let active = index === currentIndex;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
role="row"
|
|
56
|
+
aria-selected={active}
|
|
57
|
+
className={clsx("ObLookupGrid-row", active && "active", isItemSelected?.(item) && "selected")}
|
|
58
|
+
style={{ ...style, gridTemplateColumns }}
|
|
59
|
+
onClick={() => onSelectItem(item)}
|
|
60
|
+
key={itemId}
|
|
61
|
+
id={itemId}
|
|
62
|
+
>
|
|
63
|
+
{columns.map((column, columnIndex) => (
|
|
64
|
+
<div
|
|
65
|
+
role="gridcell"
|
|
66
|
+
className={clsx("ObLookupGrid-cell", active && columnIndex === currentColumn && "active")}
|
|
67
|
+
key={column.field}
|
|
68
|
+
title={String(item[column.field] ?? "")}
|
|
69
|
+
>
|
|
70
|
+
{column.renderCell ? column.renderCell(item[column.field], item) : String(item[column.field] ?? "")}
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div role="grid" aria-label={getLocalizedString("Search results")} className="ObLookupGrid-root">
|
|
79
|
+
<div role="row" className="ObLookupGrid-header" style={{ gridTemplateColumns }}>
|
|
80
|
+
{columns.map((column) => (
|
|
81
|
+
<div role="columnheader" className="ObLookupGrid-headerCell" key={column.field}>
|
|
82
|
+
{column.headerName ?? column.field}
|
|
83
|
+
</div>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
<VirtualList
|
|
87
|
+
ref={listRef}
|
|
88
|
+
height={Math.max(0, height - HEADER_HEIGHT)}
|
|
89
|
+
width="100%"
|
|
90
|
+
itemCount={data.length}
|
|
91
|
+
itemSize={ROW_HEIGHT}
|
|
92
|
+
overscanCount={5}
|
|
93
|
+
>
|
|
94
|
+
{Row}
|
|
95
|
+
</VirtualList>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
package/src/AfLookup/index.ts
CHANGED
|
@@ -67,7 +67,7 @@ export function DataObjectSelect<T extends Record<string, unknown>>({
|
|
|
67
67
|
let id = idProp ?? fallbackId;
|
|
68
68
|
let inputRef = useRef<HTMLSelectElement>(null);
|
|
69
69
|
|
|
70
|
-
//
|
|
70
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps -- default value should only affect the initial value
|
|
71
71
|
useEffect(() => {
|
|
72
72
|
let defaultExists = data.find((r) => String(getOptionValue?.(r) ?? r[valueField]) === defaultValue);
|
|
73
73
|
if (inputRef.current && defaultExists) {
|
|
@@ -19,7 +19,7 @@ export function AppLayoutMobile({ AppMenuProps, children, menu, partner }: AppLa
|
|
|
19
19
|
let [drawerOpen, setDrawerOpen] = useState(false);
|
|
20
20
|
let location = useLocation();
|
|
21
21
|
|
|
22
|
-
//
|
|
22
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps -- close drawer on navigation
|
|
23
23
|
useEffect(() => {
|
|
24
24
|
setDrawerOpen(false);
|
|
25
25
|
}, [location]);
|