@olenbetong/appframe-ds 0.2.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 ADDED
@@ -0,0 +1,31 @@
1
+ # @olenbetong/appframe-ds
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6b58d71: Initial release of `@olenbetong/appframe-ds`
8
+
9
+ New package providing [Designsystemet](https://github.com/digdir/designsystemet) equivalents of the components in `@olenbetong/appframe-mui`. Enables gradual per-app migration from MUI to `@digdir/designsystemet-react`.
10
+
11
+ **Binding components** (`import { … } from "@olenbetong/appframe-ds"`):
12
+ - `BoundTextField` — `<Textfield>` bound to an Appframe field via `useField`; handles date/datetime-local formatting
13
+ - `BoundInput` — bare `<Input>` binding, use inside a DS `<Field>` wrapper
14
+ - `BoundNumericTextField` — `<Textfield>` with optional `precision`/`scale` numeric validation and blur-normalisation
15
+ - `BoundCheckbox` — `<Checkbox>` bound to a boolean field
16
+ - `BoundSelect` — `<Select>` (native) bound to a field
17
+ - `BoundSwitch` — `<Switch>` bound to a boolean field
18
+ - `BoundDatePicker` — `<Textfield type="date">` bound to a date field
19
+ - `BoundDateTimePicker` — `<Textfield type="datetime-local">` bound to a datetime field
20
+ - `SaveButton` / `SaveIconButton` — wraps `useSaveButton`
21
+ - `CancelButton` / `CancelIconButton` — wraps `useCancelButton`
22
+ - `DeleteButton` / `DeleteIconButton` — wraps `useDeleteButton`, renders with `data-color="danger"`
23
+ - `RefreshButton` / `RefreshIconButton` — wraps `useRefreshButton` / `dataObject.refreshDataSource()`
24
+ - `RefreshRowIconButton` — wraps `useRefreshRowButton`
25
+ - `DataEditToolbar` — flex toolbar with save/cancel/refresh-row icon buttons and optional index navigator
26
+ - `DataObjectSelect` / `BoundDataObjectSelect` — DS `<Select>` populated from a data object's data handler
27
+
28
+ **Lookup components** (`import { … } from "@olenbetong/appframe-ds"`):
29
+ - `Combobox` — virtualised search-and-select list using `react-window`; no MUI dependency
30
+ - `AfLookup` / `useLookup` — full lookup field with DS `<Textfield>` + native `<dialog>` popup
31
+ - `BoundLookup` — `AfLookup` bound to a data object via `useCurrentRow`
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@olenbetong/appframe-ds",
3
+ "version": "0.2.0",
4
+ "description": "Components that bind Designsystemet components to Appframe data objects",
5
+ "type": "module",
6
+ "types": "./es/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./es/index.js"
10
+ }
11
+ },
12
+ "sideEffects": [
13
+ "**/*.css"
14
+ ],
15
+ "author": "Bjørnar Vister Hansen <bvh@olenbetong.no>",
16
+ "license": "MIT",
17
+ "peerDependencies": {
18
+ "@digdir/designsystemet-react": ">=1.0.0",
19
+ "@mui/icons-material": ">=7.0.0",
20
+ "react": ">=18.2.0",
21
+ "react-dom": ">=18.2.0"
22
+ },
23
+ "dependencies": {
24
+ "@olenbetong/appframe-core": "2.11.10",
25
+ "@olenbetong/appframe-data": "1.5.0",
26
+ "@olenbetong/appframe-react": "1.21.25",
27
+ "react-window": "^1.8.11",
28
+ "react-virtualized-auto-sizer": "^1.0.26",
29
+ "clsx": "^2.1.1"
30
+ },
31
+ "devDependencies": {
32
+ "@digdir/designsystemet-react": "1.13.3",
33
+ "@mui/icons-material": "9.0.0",
34
+ "@types/react": "19.2.14",
35
+ "@types/react-dom": "19.2.3",
36
+ "@types/react-window": "^1.8.8",
37
+ "react": "19.2.5",
38
+ "react-dom": "19.2.5",
39
+ "typescript": "6.0.3"
40
+ },
41
+ "scripts": {
42
+ "build": "pnpm run build:tsc && pnpm run build:css",
43
+ "build:tsc": "node --no-warnings ../../scripts/clean.ts ./es tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
44
+ "build:css": "cp src/AfLookup/Lookup.css es/AfLookup/Lookup.css && cp src/AfLookup/Combobox.css es/AfLookup/Combobox.css"
45
+ }
46
+ }
@@ -0,0 +1,35 @@
1
+ import type { CurrentRow, DataObject } from "@olenbetong/appframe-data";
2
+ import { useCurrentRow, useDataObject } from "@olenbetong/appframe-react";
3
+
4
+ import { AfLookup, type AfLookupProps } from "./Lookup.js";
5
+
6
+ export interface BoundLookupProps<
7
+ TBoundData extends Record<string, unknown>,
8
+ TLookupData extends Record<string, unknown>,
9
+ > extends Omit<AfLookupProps<TLookupData>, "onChange"> {
10
+ dataObject: DataObject<TLookupData>;
11
+ displayField?: keyof TBoundData;
12
+ getChanges: (selectedItem: TLookupData | null) => Partial<TBoundData>;
13
+ getDisplayValue?: (currentRow: CurrentRow<TBoundData>) => string;
14
+ }
15
+
16
+ export function BoundLookup<
17
+ TBoundData extends Record<string, unknown>,
18
+ TLookupData extends Record<string, unknown> = any,
19
+ >({ dataObject, displayField, getChanges, getDisplayValue, ...props }: BoundLookupProps<TBoundData, TLookupData>) {
20
+ let boundDataObject = useDataObject<TBoundData>();
21
+ let row = useCurrentRow(boundDataObject);
22
+
23
+ function handleChange(item: TLookupData | null) {
24
+ let changes = getChanges(item);
25
+
26
+ for (let key in changes) {
27
+ let newValue = changes[key] as TBoundData[keyof TBoundData];
28
+ boundDataObject.currentRow(key, newValue);
29
+ }
30
+ }
31
+
32
+ let displayValue = getDisplayValue ? getDisplayValue(row) : displayField ? String(row[displayField] ?? "") : "";
33
+
34
+ return <AfLookup<TLookupData> dataObject={dataObject} value={displayValue} onChange={handleChange} {...props} />;
35
+ }
@@ -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,233 @@
1
+ import "./Combobox.css";
2
+
3
+ import { Button, Textfield } from "@digdir/designsystemet-react";
4
+ import { getLocalizedString } from "@olenbetong/appframe-core";
5
+ import type { DataObject } from "@olenbetong/appframe-data";
6
+ import { useDataWithFilter, useDebounce, useLoading } from "@olenbetong/appframe-react";
7
+
8
+ import clsx from "clsx";
9
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
10
+ import { VariableSizeList as VirtualList } from "react-window";
11
+
12
+ export interface ComboboxProps<T extends Record<string, unknown>> {
13
+ nullable?: boolean;
14
+ dataObject: DataObject<T>;
15
+ uniqueIdField?: string;
16
+ isItemSelected?: (item: T) => boolean;
17
+ renderItem: (props: React.ComponentPropsWithRef<"li"> & { item: T }) => React.ReactNode;
18
+ onChange?: (item: T | null) => void;
19
+ value?: T;
20
+ onClose?: () => void;
21
+ }
22
+
23
+ export function Combobox<T extends Record<string, unknown>>({
24
+ nullable,
25
+ dataObject,
26
+ isItemSelected,
27
+ uniqueIdField = "PrimKey",
28
+ renderItem: Component,
29
+ onChange,
30
+ onClose,
31
+ }: ComboboxProps<T>) {
32
+ let id = useId();
33
+ let [isDesktop, setIsDesktop] = useState(
34
+ () => typeof window !== "undefined" && window.matchMedia("(width >= 768px) and (pointer: fine)").matches,
35
+ );
36
+
37
+ useEffect(() => {
38
+ let mq = window.matchMedia("(width >= 768px) and (pointer: fine)");
39
+ let handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
40
+ mq.addEventListener("change", handler);
41
+ return () => mq.removeEventListener("change", handler);
42
+ }, []);
43
+
44
+ let [currentIndex, setCurrentIndex] = useState<number>(0);
45
+ let listRef = useRef<VirtualList>(null);
46
+ let [inputValue, setInputValue] = useState(() => {
47
+ // Preserve filter even if the lookup is unmounted
48
+ let currentFilter = dataObject.getParameter("filterString") ?? "";
49
+ let filters = currentFilter.split(/\s+AND\s+/g);
50
+ let wordFilters = filters.filter((filter) => filter.includes("[SearchColumn]"));
51
+ let words = wordFilters.map((filter) => filter.match(/'%([^']*)%'/)?.[1] ?? "").filter(Boolean);
52
+
53
+ return words.join(" ");
54
+ });
55
+
56
+ let query = useDebounce(inputValue, 300);
57
+ let loading = useLoading(dataObject);
58
+ let data = useDataWithFilter(
59
+ dataObject,
60
+ query
61
+ .split(/\s+/g)
62
+ .map((word) => `[SearchColumn] LIKE '%${word}%'`)
63
+ .join(" AND "),
64
+ );
65
+
66
+ let wrapperRef = useRef<HTMLDivElement>(null);
67
+ let [height, setHeight] = useState<number>(0);
68
+
69
+ useLayoutEffect(() => {
70
+ if (!wrapperRef.current) return;
71
+
72
+ let resizeObserver = new ResizeObserver(() => {
73
+ let newHeight = wrapperRef.current!.offsetHeight;
74
+ if (newHeight > 0) setHeight(newHeight);
75
+ });
76
+
77
+ resizeObserver.observe(wrapperRef.current);
78
+
79
+ return () => resizeObserver.disconnect();
80
+ }, []);
81
+
82
+ // Reset current index when new data is loaded
83
+ // biome-ignore lint/correctness/useExhaustiveDependencies: isn't it obvious?
84
+ useEffect(() => {
85
+ setCurrentIndex(0);
86
+ }, [data]);
87
+
88
+ let getItemId = useCallback(
89
+ (item: T | undefined) => {
90
+ if (!item) {
91
+ return;
92
+ }
93
+
94
+ let itemId = item[uniqueIdField];
95
+
96
+ return `${id}-${itemId}`;
97
+ },
98
+ [id, uniqueIdField],
99
+ );
100
+
101
+ function handleSelectItem(item: T) {
102
+ onChange?.(item);
103
+ setInputValue("");
104
+ }
105
+
106
+ function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
107
+ let newIndex: number | null = null;
108
+
109
+ if (event.key === "ArrowDown") {
110
+ newIndex = currentIndex + 1;
111
+ } else if (event.key === "ArrowUp") {
112
+ newIndex = currentIndex - 1;
113
+ } else if (event.key === "Home" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
114
+ newIndex = 0;
115
+ } else if (event.key === "End" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
116
+ newIndex = data.length - 1;
117
+ } else if (event.key === "Enter") {
118
+ event.stopPropagation();
119
+ event.preventDefault();
120
+ handleSelectItem(data[currentIndex]);
121
+ return;
122
+ }
123
+
124
+ if (newIndex !== null) {
125
+ event.stopPropagation();
126
+ event.preventDefault();
127
+ if (newIndex < 0) {
128
+ newIndex = data.length - 1;
129
+ } else if (newIndex >= data.length) {
130
+ newIndex = 0;
131
+ }
132
+ setCurrentIndex(newIndex);
133
+ }
134
+ }
135
+
136
+ let sizeMap = useRef(new Map<number, number>());
137
+ let getItemSize = (index: number) => sizeMap.current.get(index) ?? 56;
138
+ let setSize = (index: number, size: number) => {
139
+ if (sizeMap.current.get(index) !== size) {
140
+ sizeMap.current.set(index, size);
141
+ // listRef will not be set on the initial render, so delay resetting
142
+ setTimeout(() => listRef.current?.resetAfterIndex(index, true));
143
+ }
144
+ };
145
+
146
+ // biome-ignore lint: nested component defined for performance reasons inside render
147
+ let Row = ({ index, style: { height, ...style } }: { index: number; style: React.CSSProperties }) => {
148
+ let rowRef = useRef<HTMLLIElement>(null);
149
+
150
+ useLayoutEffect(() => {
151
+ if (!rowRef.current) return;
152
+ setSize(index, rowRef.current.offsetHeight);
153
+ }, [index]);
154
+
155
+ let item = data[index];
156
+ let itemId = getItemId(item);
157
+ let active = index === currentIndex;
158
+
159
+ return (
160
+ <Component
161
+ className={clsx("ObCombobox-listItem", active && "active", isItemSelected?.(item) && "selected")}
162
+ aria-selected={active}
163
+ onClick={() => handleSelectItem(item)}
164
+ item={item}
165
+ style={{
166
+ ...style,
167
+ padding: isDesktop ? "0 8px" : "8px",
168
+ }}
169
+ ref={rowRef}
170
+ key={itemId}
171
+ id={itemId}
172
+ />
173
+ );
174
+ };
175
+
176
+ useEffect(() => listRef.current?.scrollToItem(currentIndex, "smart"), [currentIndex]);
177
+
178
+ return (
179
+ // biome-ignore lint/a11y/useKeyWithClickEvents: element is not interactive, click is used to prevent dialog close
180
+ // biome-ignore lint/a11y/noStaticElementInteractions: element is not interactive, click is used to prevent dialog close
181
+ <div className="ObCombobox-root" id={id} onClick={(evt) => evt.stopPropagation()}>
182
+ <div className="ObCombobox-actions">
183
+ {nullable && (
184
+ <Button
185
+ className="ObCombobox-clear"
186
+ data-size={isDesktop ? "sm" : "md"}
187
+ variant="tertiary"
188
+ onClick={() => {
189
+ onChange?.(null);
190
+ }}
191
+ >
192
+ {getLocalizedString("Clear")}
193
+ </Button>
194
+ )}
195
+ {onClose && (
196
+ <Button data-size={isDesktop ? "sm" : "md"} variant="tertiary" className="ObCombobox-close" onClick={onClose}>
197
+ {getLocalizedString("Close")}
198
+ </Button>
199
+ )}
200
+ </div>
201
+ <Textfield
202
+ type="search"
203
+ className="ObCombobox-input"
204
+ label={getLocalizedString("Search")}
205
+ onKeyDown={handleKeyDown}
206
+ aria-activedescendant={getItemId(data[currentIndex]) ?? ""}
207
+ value={inputValue}
208
+ onChange={(event) => setInputValue((event.target as HTMLInputElement).value)}
209
+ />
210
+ <div className="ObCombobox-listWrap" ref={wrapperRef}>
211
+ {loading && (
212
+ <div className="ObCombobox-progress">
213
+ <div className="ObCombobox-progress-bar" />
214
+ </div>
215
+ )}
216
+ {height > 0 && (
217
+ <div className="ObCombobox-list" role="listbox">
218
+ <VirtualList
219
+ ref={listRef}
220
+ height={height}
221
+ width="100%"
222
+ itemCount={data.length}
223
+ itemSize={getItemSize}
224
+ overscanCount={5}
225
+ >
226
+ {Row}
227
+ </VirtualList>
228
+ </div>
229
+ )}
230
+ </div>
231
+ </div>
232
+ );
233
+ }
@@ -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
+ }