@olenbetong/appframe-ds 0.2.0 → 0.3.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 +6 -0
- package/es/AfLookup/BoundLookup.d.ts +9 -0
- package/es/AfLookup/BoundLookup.js +16 -0
- package/es/AfLookup/Combobox.css +78 -0
- package/es/AfLookup/Combobox.d.ts +15 -0
- package/es/AfLookup/Combobox.js +124 -0
- package/es/AfLookup/Lookup.css +104 -0
- package/es/AfLookup/Lookup.d.ts +34 -0
- package/es/AfLookup/Lookup.js +161 -0
- package/es/AfLookup/index.d.ts +3 -0
- package/es/AfLookup/index.js +3 -0
- package/es/binding/BoundCheckbox.d.ts +9 -0
- package/es/binding/BoundCheckbox.js +13 -0
- package/es/binding/BoundDatePicker.d.ts +7 -0
- package/es/binding/BoundDatePicker.js +12 -0
- package/es/binding/BoundDateTimePicker.d.ts +7 -0
- package/es/binding/BoundDateTimePicker.js +14 -0
- package/es/binding/BoundInput.d.ts +6 -0
- package/es/binding/BoundInput.js +12 -0
- package/es/binding/BoundNumericTextField.d.ts +20 -0
- package/es/binding/BoundNumericTextField.js +58 -0
- package/es/binding/BoundSelect.d.ts +9 -0
- package/es/binding/BoundSelect.js +13 -0
- package/es/binding/BoundSwitch.d.ts +9 -0
- package/es/binding/BoundSwitch.js +13 -0
- package/es/binding/BoundTextField.d.ts +7 -0
- package/es/binding/BoundTextField.js +22 -0
- package/es/binding/CancelButton.d.ts +3 -0
- package/es/binding/CancelButton.js +14 -0
- package/es/binding/CancelIconButton.d.ts +2 -0
- package/es/binding/CancelIconButton.js +15 -0
- package/es/binding/DataEditToolbar.d.ts +13 -0
- package/es/binding/DataEditToolbar.js +28 -0
- package/es/binding/DataObjectSelect.d.ts +50 -0
- package/es/binding/DataObjectSelect.js +26 -0
- package/es/binding/DeleteButton.d.ts +8 -0
- package/es/binding/DeleteButton.js +14 -0
- package/es/binding/DeleteIconButton.d.ts +10 -0
- package/es/binding/DeleteIconButton.js +15 -0
- package/es/binding/RefreshButton.d.ts +3 -0
- package/es/binding/RefreshButton.js +14 -0
- package/es/binding/RefreshIconButton.d.ts +2 -0
- package/es/binding/RefreshIconButton.js +16 -0
- package/es/binding/RefreshRowIconButton.d.ts +7 -0
- package/es/binding/RefreshRowIconButton.js +15 -0
- package/es/binding/SaveButton.d.ts +3 -0
- package/es/binding/SaveButton.js +14 -0
- package/es/binding/SaveIconButton.d.ts +2 -0
- package/es/binding/SaveIconButton.js +15 -0
- package/es/binding/index.d.ts +19 -0
- package/es/binding/index.js +19 -0
- package/es/index.d.ts +3 -0
- package/es/index.js +3 -0
- package/es/layout/AppLayoutDesktop.css +65 -0
- package/es/layout/AppLayoutDesktop.d.ts +3 -0
- package/es/layout/AppLayoutDesktop.js +19 -0
- package/es/layout/AppLayoutMobile.css +75 -0
- package/es/layout/AppLayoutMobile.d.ts +3 -0
- package/es/layout/AppLayoutMobile.js +18 -0
- package/es/layout/AppMenu.css +67 -0
- package/es/layout/AppMenu.d.ts +8 -0
- package/es/layout/AppMenu.js +36 -0
- package/es/layout/AppMenuItem.css +97 -0
- package/es/layout/AppMenuItem.d.ts +50 -0
- package/es/layout/AppMenuItem.js +14 -0
- package/es/layout/AppMenuSubheader.css +18 -0
- package/es/layout/AppMenuSubheader.d.ts +4 -0
- package/es/layout/AppMenuSubheader.js +6 -0
- package/es/layout/ErrorBoundary.css +101 -0
- package/es/layout/ErrorBoundary.d.ts +30 -0
- package/es/layout/ErrorBoundary.js +81 -0
- package/es/layout/PageContainer.css +29 -0
- package/es/layout/PageContainer.d.ts +7 -0
- package/es/layout/PageContainer.js +6 -0
- package/es/layout/PartnerToggle.d.ts +5 -0
- package/es/layout/PartnerToggle.js +9 -0
- package/es/layout/constants.d.ts +2 -0
- package/es/layout/constants.js +2 -0
- package/es/layout/index.css +65 -0
- package/es/layout/index.d.ts +33 -0
- package/es/layout/index.js +31 -0
- package/package.json +14 -9
- package/src/index.ts +1 -0
- package/src/layout/AppLayoutDesktop.css +65 -0
- package/src/layout/AppLayoutDesktop.tsx +45 -0
- package/src/layout/AppLayoutMobile.css +75 -0
- package/src/layout/AppLayoutMobile.tsx +50 -0
- package/src/layout/AppMenu.css +67 -0
- package/src/layout/AppMenu.tsx +75 -0
- package/src/layout/AppMenuItem.css +97 -0
- package/src/layout/AppMenuItem.tsx +80 -0
- package/src/layout/AppMenuSubheader.css +18 -0
- package/src/layout/AppMenuSubheader.tsx +14 -0
- package/src/layout/ErrorBoundary.css +101 -0
- package/src/layout/ErrorBoundary.tsx +162 -0
- package/src/layout/PageContainer.css +29 -0
- package/src/layout/PageContainer.tsx +19 -0
- package/src/layout/PartnerToggle.tsx +23 -0
- package/src/layout/constants.ts +2 -0
- package/src/layout/index.css +65 -0
- package/src/layout/index.tsx +62 -0
- package/tsconfig.build.tsbuildinfo +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @olenbetong/appframe-ds
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8487660: Add `AppLayout`, `AppMenu`, `AppMenuItem`, `AppMenuItemExternalLink`, `AppMenuItemButton`, `AppMenuSubheader` layout components — ported from `appframe-mui` with all MUI dependencies removed and replaced with plain HTML + Designsystemet CSS tokens.
|
|
8
|
+
|
|
3
9
|
## 0.2.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CurrentRow, DataObject } from "@olenbetong/appframe-data";
|
|
2
|
+
import { type AfLookupProps } from "./Lookup.js";
|
|
3
|
+
export interface BoundLookupProps<TBoundData extends Record<string, unknown>, TLookupData extends Record<string, unknown>> extends Omit<AfLookupProps<TLookupData>, "onChange"> {
|
|
4
|
+
dataObject: DataObject<TLookupData>;
|
|
5
|
+
displayField?: keyof TBoundData;
|
|
6
|
+
getChanges: (selectedItem: TLookupData | null) => Partial<TBoundData>;
|
|
7
|
+
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/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCurrentRow, useDataObject } from "@olenbetong/appframe-react";
|
|
3
|
+
import { AfLookup } from "./Lookup.js";
|
|
4
|
+
export function BoundLookup({ dataObject, displayField, getChanges, getDisplayValue, ...props }) {
|
|
5
|
+
let boundDataObject = useDataObject();
|
|
6
|
+
let row = useCurrentRow(boundDataObject);
|
|
7
|
+
function handleChange(item) {
|
|
8
|
+
let changes = getChanges(item);
|
|
9
|
+
for (let key in changes) {
|
|
10
|
+
let newValue = changes[key];
|
|
11
|
+
boundDataObject.currentRow(key, newValue);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
let displayValue = getDisplayValue ? getDisplayValue(row) : displayField ? String(row[displayField] ?? "") : "";
|
|
15
|
+
return _jsx(AfLookup, { dataObject: dataObject, value: displayValue, onChange: handleChange, ...props });
|
|
16
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
.ObCombobox-root {
|
|
2
|
+
height: 100%;
|
|
3
|
+
display: grid;
|
|
4
|
+
grid-template-rows: min-content auto 1fr;
|
|
5
|
+
gap: 0.5rem;
|
|
6
|
+
|
|
7
|
+
overscroll-behavior: contain;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.ObCombobox-actions {
|
|
11
|
+
display: flex;
|
|
12
|
+
justify-content: space-between;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.ObCombobox-listWrap {
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
height: 372px;
|
|
18
|
+
border: 1px solid rgb(0 0 0 / 0.12);
|
|
19
|
+
border-radius: 0.25rem;
|
|
20
|
+
background-color: white;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.ObCombobox-list {
|
|
24
|
+
border: 1px solid rgb(0 0 0 / 0.06);
|
|
25
|
+
height: 100%;
|
|
26
|
+
overflow-y: hidden;
|
|
27
|
+
overscroll-behavior: contain;
|
|
28
|
+
list-style: none;
|
|
29
|
+
margin: 0;
|
|
30
|
+
padding: 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ObCombobox-input {
|
|
34
|
+
background-color: white;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.ObCombobox-listItem {
|
|
38
|
+
--active-color: oklch(from var(--brand-secondary, #999) l c h / 50%);
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
|
|
41
|
+
&.active {
|
|
42
|
+
background-color: var(--active-color);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
&:hover {
|
|
46
|
+
background-color: rgb(0 0 0 / 0.06);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&.active:hover {
|
|
50
|
+
background-color: oklch(from var(--active-color) calc(l + 0.1) calc(c - 0.04) h);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.ObCombobox-progress {
|
|
55
|
+
position: relative;
|
|
56
|
+
height: 4px;
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
background-color: rgb(0 0 0 / 0.08);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.ObCombobox-progress-bar {
|
|
62
|
+
position: absolute;
|
|
63
|
+
top: 0;
|
|
64
|
+
left: -100%;
|
|
65
|
+
height: 100%;
|
|
66
|
+
width: 50%;
|
|
67
|
+
background-color: var(--ds-color-accent-base-default, #0062ba);
|
|
68
|
+
animation: ObCombobox-progress-anim 1.5s ease-in-out infinite;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@keyframes ObCombobox-progress-anim {
|
|
72
|
+
0% {
|
|
73
|
+
left: -100%;
|
|
74
|
+
}
|
|
75
|
+
100% {
|
|
76
|
+
left: 200%;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import "./Combobox.css";
|
|
2
|
+
import type { DataObject } from "@olenbetong/appframe-data";
|
|
3
|
+
export interface ComboboxProps<T extends Record<string, unknown>> {
|
|
4
|
+
nullable?: boolean;
|
|
5
|
+
dataObject: DataObject<T>;
|
|
6
|
+
uniqueIdField?: string;
|
|
7
|
+
isItemSelected?: (item: T) => boolean;
|
|
8
|
+
renderItem: (props: React.ComponentPropsWithRef<"li"> & {
|
|
9
|
+
item: T;
|
|
10
|
+
}) => React.ReactNode;
|
|
11
|
+
onChange?: (item: T | null) => void;
|
|
12
|
+
value?: T;
|
|
13
|
+
onClose?: () => void;
|
|
14
|
+
}
|
|
15
|
+
export declare function Combobox<T extends Record<string, unknown>>({ nullable, dataObject, isItemSelected, uniqueIdField, renderItem: Component, onChange, onClose, }: ComboboxProps<T>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import "./Combobox.css";
|
|
3
|
+
import { Button, Textfield } from "@digdir/designsystemet-react";
|
|
4
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
5
|
+
import { useDataWithFilter, useDebounce, useLoading } from "@olenbetong/appframe-react";
|
|
6
|
+
import clsx from "clsx";
|
|
7
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
8
|
+
import { VariableSizeList as VirtualList } from "react-window";
|
|
9
|
+
export function Combobox({ nullable, dataObject, isItemSelected, uniqueIdField = "PrimKey", renderItem: Component, onChange, onClose, }) {
|
|
10
|
+
let id = useId();
|
|
11
|
+
let [isDesktop, setIsDesktop] = useState(() => typeof window !== "undefined" && window.matchMedia("(width >= 768px) and (pointer: fine)").matches);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let mq = window.matchMedia("(width >= 768px) and (pointer: fine)");
|
|
14
|
+
let handler = (e) => setIsDesktop(e.matches);
|
|
15
|
+
mq.addEventListener("change", handler);
|
|
16
|
+
return () => mq.removeEventListener("change", handler);
|
|
17
|
+
}, []);
|
|
18
|
+
let [currentIndex, setCurrentIndex] = useState(0);
|
|
19
|
+
let listRef = useRef(null);
|
|
20
|
+
let [inputValue, setInputValue] = useState(() => {
|
|
21
|
+
// Preserve filter even if the lookup is unmounted
|
|
22
|
+
let currentFilter = dataObject.getParameter("filterString") ?? "";
|
|
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(" ");
|
|
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
|
+
let wrapperRef = useRef(null);
|
|
35
|
+
let [height, setHeight] = useState(0);
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
if (!wrapperRef.current)
|
|
38
|
+
return;
|
|
39
|
+
let resizeObserver = new ResizeObserver(() => {
|
|
40
|
+
let newHeight = wrapperRef.current.offsetHeight;
|
|
41
|
+
if (newHeight > 0)
|
|
42
|
+
setHeight(newHeight);
|
|
43
|
+
});
|
|
44
|
+
resizeObserver.observe(wrapperRef.current);
|
|
45
|
+
return () => resizeObserver.disconnect();
|
|
46
|
+
}, []);
|
|
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
|
+
let sizeMap = useRef(new Map());
|
|
96
|
+
let getItemSize = (index) => sizeMap.current.get(index) ?? 56;
|
|
97
|
+
let setSize = (index, size) => {
|
|
98
|
+
if (sizeMap.current.get(index) !== size) {
|
|
99
|
+
sizeMap.current.set(index, size);
|
|
100
|
+
// listRef will not be set on the initial render, so delay resetting
|
|
101
|
+
setTimeout(() => listRef.current?.resetAfterIndex(index, true));
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
// biome-ignore lint: nested component defined for performance reasons inside render
|
|
105
|
+
let Row = ({ index, style: { height, ...style } }) => {
|
|
106
|
+
let rowRef = useRef(null);
|
|
107
|
+
useLayoutEffect(() => {
|
|
108
|
+
if (!rowRef.current)
|
|
109
|
+
return;
|
|
110
|
+
setSize(index, rowRef.current.offsetHeight);
|
|
111
|
+
}, [index]);
|
|
112
|
+
let item = data[index];
|
|
113
|
+
let itemId = getItemId(item);
|
|
114
|
+
let active = index === currentIndex;
|
|
115
|
+
return (_jsx(Component, { className: clsx("ObCombobox-listItem", active && "active", isItemSelected?.(item) && "selected"), "aria-selected": active, onClick: () => handleSelectItem(item), item: item, style: {
|
|
116
|
+
...style,
|
|
117
|
+
padding: isDesktop ? "0 8px" : "8px",
|
|
118
|
+
}, ref: rowRef, id: itemId }, itemId));
|
|
119
|
+
};
|
|
120
|
+
useEffect(() => listRef.current?.scrollToItem(currentIndex, "smart"), [currentIndex]);
|
|
121
|
+
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
|
+
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 && (_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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
.ObLookup-dialog {
|
|
2
|
+
border: 0;
|
|
3
|
+
margin-block-start: 0;
|
|
4
|
+
padding: 1rem;
|
|
5
|
+
|
|
6
|
+
background-color: whitesmoke;
|
|
7
|
+
|
|
8
|
+
transition:
|
|
9
|
+
display 0.2s allow-discrete,
|
|
10
|
+
transform 0.2s ease-out,
|
|
11
|
+
scale 0.2s ease-out,
|
|
12
|
+
opacity 0.2s ease-out;
|
|
13
|
+
|
|
14
|
+
&::backdrop {
|
|
15
|
+
opacity: 0;
|
|
16
|
+
background: rgb(0 0 0 / 0.2);
|
|
17
|
+
transition:
|
|
18
|
+
display 0.2s allow-discrete,
|
|
19
|
+
opacity 0.2s ease-out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
&[open]::backdrop {
|
|
23
|
+
opacity: 1;
|
|
24
|
+
|
|
25
|
+
@starting-style {
|
|
26
|
+
opacity: 0;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@media (width < 768px) {
|
|
31
|
+
inset: 10vh 0.5rem 0;
|
|
32
|
+
|
|
33
|
+
border-radius: 1rem 1rem 0 0;
|
|
34
|
+
height: 90vh;
|
|
35
|
+
width: calc(100vw - 1rem);
|
|
36
|
+
max-width: 100vw;
|
|
37
|
+
transform: translateY(100%);
|
|
38
|
+
z-index: 1500;
|
|
39
|
+
|
|
40
|
+
opacity: 0;
|
|
41
|
+
|
|
42
|
+
&[open] {
|
|
43
|
+
transform: translateY(0);
|
|
44
|
+
opacity: 1;
|
|
45
|
+
|
|
46
|
+
@starting-style {
|
|
47
|
+
transform: translateY(100%);
|
|
48
|
+
opacity: 0;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (width >= 768px) {
|
|
54
|
+
inset-block-start: 50%;
|
|
55
|
+
inset-inline-start: 50%;
|
|
56
|
+
translate: -50% -50%;
|
|
57
|
+
scale: 1 0;
|
|
58
|
+
opacity: 0;
|
|
59
|
+
|
|
60
|
+
border-radius: 0.25rem;
|
|
61
|
+
|
|
62
|
+
height: clamp(300px, 80vh, 500px);
|
|
63
|
+
min-width: 320px;
|
|
64
|
+
width: var(--default-width, 400px);
|
|
65
|
+
|
|
66
|
+
&[open] {
|
|
67
|
+
scale: 1;
|
|
68
|
+
opacity: 1;
|
|
69
|
+
|
|
70
|
+
@starting-style {
|
|
71
|
+
scale: 1 0;
|
|
72
|
+
opacity: 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@supports (inset-block-start: anchor(bottom)) {
|
|
78
|
+
@media (width < 768px) or (pointer: coarse) {
|
|
79
|
+
anchor-name: unset !important;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@media screen and (width >= 768px) and (pointer: fine) {
|
|
83
|
+
inset-block-start: anchor(bottom);
|
|
84
|
+
inset-block-end: unset;
|
|
85
|
+
|
|
86
|
+
inset-inline-start: anchor(left);
|
|
87
|
+
inset-inline-end: unset;
|
|
88
|
+
|
|
89
|
+
translate: unset;
|
|
90
|
+
transform-origin: top left;
|
|
91
|
+
|
|
92
|
+
resize: both;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
&[data-anchor="manual"] {
|
|
97
|
+
inset: unset;
|
|
98
|
+
position: fixed;
|
|
99
|
+
inset-inline-start: var(--lookup-anchor-inline-start, 50%);
|
|
100
|
+
inset-block-start: var(--lookup-anchor-block-start, 50%);
|
|
101
|
+
translate: unset;
|
|
102
|
+
transform: none;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import "./Lookup.css";
|
|
2
|
+
import { type TextfieldProps } from "@digdir/designsystemet-react";
|
|
3
|
+
import { type ComboboxProps } from "./Combobox.js";
|
|
4
|
+
export interface AfLookupProps<T extends Record<string, unknown>> extends Omit<ComboboxProps<T>, "value"> {
|
|
5
|
+
/**
|
|
6
|
+
* Allows the user to enter a value manually that might not be in the list.
|
|
7
|
+
*/
|
|
8
|
+
editable?: boolean;
|
|
9
|
+
fullWidth?: boolean;
|
|
10
|
+
label?: string;
|
|
11
|
+
value?: string | null;
|
|
12
|
+
onChange?: (item: T | null) => void;
|
|
13
|
+
slotProps?: {
|
|
14
|
+
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
15
|
+
textField?: Partial<TextfieldProps>;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface LookupProps<T extends Record<string, unknown>> extends Omit<ComboboxProps<T>, "value"> {
|
|
19
|
+
slotProps?: {
|
|
20
|
+
dialog?: Partial<React.HTMLProps<HTMLDialogElement>>;
|
|
21
|
+
};
|
|
22
|
+
getAnchorElement?: () => Element | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function useLookup<T extends Record<string, unknown>>({ getAnchorElement, slotProps, ...props }: LookupProps<T>): {
|
|
25
|
+
open: boolean;
|
|
26
|
+
openDialog: () => void;
|
|
27
|
+
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void;
|
|
28
|
+
onChange: (item: T | null) => void;
|
|
29
|
+
dialogRef: import("react").RefObject<HTMLDialogElement | null>;
|
|
30
|
+
anchorName: string;
|
|
31
|
+
inputId: string;
|
|
32
|
+
dialog: import("react").ReactPortal;
|
|
33
|
+
};
|
|
34
|
+
export declare function AfLookup<T extends Record<string, unknown>>({ fullWidth, label, slotProps, value, ...props }: AfLookupProps<T>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,161 @@
|
|
|
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 { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
9
|
+
import { createPortal, flushSync } from "react-dom";
|
|
10
|
+
import { Combobox } from "./Combobox.js";
|
|
11
|
+
export function useLookup({ getAnchorElement, slotProps, ...props }) {
|
|
12
|
+
let [open, setOpen] = useState(false);
|
|
13
|
+
let anchorName = `--anchor-${CSS.escape(useId())}`;
|
|
14
|
+
let dialogRef = useRef(null);
|
|
15
|
+
let inputId = useId();
|
|
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]);
|
|
77
|
+
function handleChange(item) {
|
|
78
|
+
props.onChange?.(item);
|
|
79
|
+
dialogRef.current?.close();
|
|
80
|
+
}
|
|
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
|
+
return {
|
|
137
|
+
open,
|
|
138
|
+
openDialog: handleOpen,
|
|
139
|
+
onKeyDown: handleKeyDown,
|
|
140
|
+
onChange: handleChange,
|
|
141
|
+
dialogRef,
|
|
142
|
+
anchorName,
|
|
143
|
+
inputId,
|
|
144
|
+
dialog: createPortal(_jsx("dialog", { ref: dialogRef, className: clsx("ObLookup-dialog", slotProps?.dialog?.className), onKeyDown: (evt) => evt.stopPropagation(), style: { positionAnchor: anchorName, ...slotProps?.dialog?.style }, children: open && _jsx(Combobox, { ...props, onClose: () => dialogRef.current?.close(), onChange: handleChange }) }), document.body, inputId),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export function AfLookup({ fullWidth = false, label, slotProps, value, ...props }) {
|
|
148
|
+
let { inputId, open: _open, dialog, anchorName, openDialog, onKeyDown } = useLookup(props);
|
|
149
|
+
return (_jsxs("div", { style: { anchorName }, children: [_jsxs("div", { style: { position: "relative", display: fullWidth ? "block" : "inline-block" }, 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", { style: {
|
|
150
|
+
position: "absolute",
|
|
151
|
+
right: 0,
|
|
152
|
+
top: "50%",
|
|
153
|
+
transform: "translateY(-50%)",
|
|
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) => {
|
|
157
|
+
evt.stopPropagation();
|
|
158
|
+
props.onChange?.(null);
|
|
159
|
+
document.getElementById(inputId)?.focus();
|
|
160
|
+
}, children: _jsx(ClearIcon, {}) })), _jsx(Button, { icon: true, variant: "tertiary", "data-size": "sm", "aria-label": getLocalizedString("Search"), onClick: openDialog, children: _jsx(SearchIcon, {}) })] })] }), dialog] }));
|
|
161
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type CheckboxProps } from "@digdir/designsystemet-react";
|
|
2
|
+
export type BoundCheckboxProps = Omit<CheckboxProps, "onChange" | "onKeyDown"> & {
|
|
3
|
+
field: string;
|
|
4
|
+
onChange?: (evt: React.ChangeEvent<HTMLInputElement>) => void;
|
|
5
|
+
};
|
|
6
|
+
export declare const BoundCheckbox: import("react").ForwardRefExoticComponent<Omit<CheckboxProps, "onChange" | "onKeyDown"> & {
|
|
7
|
+
field: string;
|
|
8
|
+
onChange?: (evt: React.ChangeEvent<HTMLInputElement>) => void;
|
|
9
|
+
} & import("react").RefAttributes<HTMLInputElement>>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Checkbox } from "@digdir/designsystemet-react";
|
|
3
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
4
|
+
import { forwardRef } from "react";
|
|
5
|
+
export const BoundCheckbox = forwardRef(function BoundCheckbox({ field, onChange: onChangeProp, checked: _checked, ...props }, ref) {
|
|
6
|
+
let { value, setValue, onKeyDown } = useField(field);
|
|
7
|
+
return (_jsx(Checkbox, { checked: value ?? false, onChange: (evt) => {
|
|
8
|
+
onChangeProp?.(evt);
|
|
9
|
+
if (!evt.defaultPrevented) {
|
|
10
|
+
setValue(evt.target.checked);
|
|
11
|
+
}
|
|
12
|
+
}, onKeyDown: (evt) => onKeyDown(evt), ref: ref, name: field, ...props }));
|
|
13
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type TextfieldProps } from "@digdir/designsystemet-react";
|
|
2
|
+
export type BoundDatePickerProps = Omit<TextfieldProps, "value" | "onChange" | "onKeyDown" | "type"> & {
|
|
3
|
+
field: string;
|
|
4
|
+
};
|
|
5
|
+
export declare const BoundDatePicker: import("react").ForwardRefExoticComponent<Omit<TextfieldProps, "onChange" | "value" | "onKeyDown" | "type"> & {
|
|
6
|
+
field: string;
|
|
7
|
+
} & import("react").RefAttributes<HTMLInputElement>>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Textfield } from "@digdir/designsystemet-react";
|
|
3
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
4
|
+
import { forwardRef } from "react";
|
|
5
|
+
export const BoundDatePicker = forwardRef(function BoundDatePicker({ field, ...props }, ref) {
|
|
6
|
+
let { error, value, onChange, onKeyDown } = useField(field);
|
|
7
|
+
let inputValue = value;
|
|
8
|
+
if (value instanceof Date) {
|
|
9
|
+
inputValue = value.toISOString().split("T")[0];
|
|
10
|
+
}
|
|
11
|
+
return (_jsx(Textfield, { type: "date", name: field, error: error ?? undefined, ...props, value: inputValue ?? "", onChange: (evt) => onChange(evt), onKeyDown: (evt) => onKeyDown(evt), ref: ref }));
|
|
12
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type TextfieldProps } from "@digdir/designsystemet-react";
|
|
2
|
+
export type BoundDateTimePickerProps = Omit<TextfieldProps, "value" | "onChange" | "onKeyDown" | "type"> & {
|
|
3
|
+
field: string;
|
|
4
|
+
};
|
|
5
|
+
export declare const BoundDateTimePicker: import("react").ForwardRefExoticComponent<Omit<TextfieldProps, "onChange" | "value" | "onKeyDown" | "type"> & {
|
|
6
|
+
field: string;
|
|
7
|
+
} & import("react").RefAttributes<HTMLInputElement>>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Textfield } from "@digdir/designsystemet-react";
|
|
3
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
4
|
+
import { forwardRef } from "react";
|
|
5
|
+
export const BoundDateTimePicker = forwardRef(function BoundDateTimePicker({ field, ...props }, ref) {
|
|
6
|
+
let { error, value, onChange, onKeyDown } = useField(field);
|
|
7
|
+
let inputValue = value;
|
|
8
|
+
if (value instanceof Date) {
|
|
9
|
+
inputValue = value.toISOString().replace("Z", "");
|
|
10
|
+
}
|
|
11
|
+
return (_jsx(Textfield, { type: "datetime-local", name: field, error: error ?? undefined, ...props, value: inputValue ?? "", onChange: (evt) => {
|
|
12
|
+
onChange(evt, evt.target.value ? `${evt.target.value}Z` : null);
|
|
13
|
+
}, onKeyDown: (evt) => onKeyDown(evt), ref: ref }));
|
|
14
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type InputProps } from "@digdir/designsystemet-react";
|
|
2
|
+
export type BoundInputProps = Omit<InputProps, "onChange" | "onKeyDown" | "onBlur"> & {
|
|
3
|
+
field: string;
|
|
4
|
+
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
5
|
+
};
|
|
6
|
+
export declare function BoundInput({ field, onChange: onChangeProp, ...props }: BoundInputProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Input } from "@digdir/designsystemet-react";
|
|
3
|
+
import { useField } from "@olenbetong/appframe-react";
|
|
4
|
+
export function BoundInput({ field, onChange: onChangeProp, ...props }) {
|
|
5
|
+
let { value, onChange, onKeyDown } = useField(field);
|
|
6
|
+
return (_jsx(Input, { name: field, ...props, value: value ?? "", onChange: (event) => {
|
|
7
|
+
onChangeProp?.(event);
|
|
8
|
+
if (!event.defaultPrevented) {
|
|
9
|
+
onChange(event);
|
|
10
|
+
}
|
|
11
|
+
}, onKeyDown: (evt) => onKeyDown(evt) }));
|
|
12
|
+
}
|