@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
@@ -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
 
@@ -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 { useCallback, useEffect, useId, useRef, useState } from "react";
10
- import { createPortal, flushSync } from "react-dom";
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 interface AfLookupProps<T extends Record<string, unknown>> extends Omit<ComboboxProps<T>, "value"> {
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 interface LookupProps<T extends Record<string, unknown>> extends Omit<ComboboxProps<T>, "value"> {
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 [open, setOpen] = useState(false);
42
- let anchorName = `--anchor-${CSS.escape(useId())}`;
43
- let dialogRef = useRef<HTMLDialogElement>(null);
44
- let inputId = useId();
45
- let resetManualAnchor = useCallback(() => {
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
- useEffect(() => {
118
- if (!open) {
119
- resetManualAnchor();
120
- return;
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: handleOpen,
187
- onKeyDown: handleKeyDown,
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 && <Combobox {...props} onClose={() => dialogRef.current?.close()} onChange={handleChange} />}
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 style={{ position: "relative", display: fullWidth ? "block" : "inline-block" }}>
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
- readOnly
224
- onClick={openDialog}
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
- style={{
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 icon variant="tertiary" data-size="sm" aria-label={getLocalizedString("Search")} onClick={openDialog}>
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
+ }
@@ -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";
@@ -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
- // biome-ignore lint/correctness/useExhaustiveDependencies: default value should only affect the initial value
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
- // biome-ignore lint/correctness/useExhaustiveDependencies: close drawer on navigation
22
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- close drawer on navigation
23
23
  useEffect(() => {
24
24
  setDrawerOpen(false);
25
25
  }, [location]);